eurlex-chat-api / app /relation_extractor.py
nedaktov-ops
phase-1: CI workflow, ruff linting, keepalive alerting, VITE_API_URL, pre-commit hooks
f3e893e
Raw
History Blame Contribute Delete
6.4 kB
"""Lightweight legal relation extraction for EU directive text.
Identifies:
- Obligations (shall, must, required to)
- Rights (has the right to, may, entitled to)
- Prohibitions (shall not, prohibited, not permitted)
- Conditions (provided that, subject to, where)
- Legal actors (employer, controller, member state, etc.)
"""
import re
OBLIGATION_PATTERNS = [
(r"(shall|must)\s+(ensure|take|establish|implement|provide|report|disclose|notify|adopt|designate|maintain)", "positive_obligation"),
(r"(shall|must)\s+[a-z]+\s+and\s+(shall|must)", "compound_obligation"),
(r"has\s+(?:the\s+)?duty\s+to", "positive_obligation"),
(r"is\s+responsible\s+for", "responsibility"),
(r"is\s+required\s+to", "requirement"),
(r"shall\s+be\s+(?:responsible|accountable|liable)\s+for", "accountability"),
(r"(obligation|duty|responsibility)\s+(?:to|of|shall|under)", "obligation_statement"),
]
PROHIBITION_PATTERNS = [
(r"shall\s+not\s+(permit|allow|use|disclose|process|transfer|discriminate)", "prohibition"),
(r"prohibited\s+from", "prohibition"),
(r"not\s+permitted\s+to", "prohibition"),
(r"may\s+not\s+(use|disclose|process|transfer|discriminate)", "restriction"),
(r"shall\s+not\s+be\s+(construed|interpreted)\s+as", "non_application"),
(r"no\s+(person|employer|undertaking|organisation)\s+(shall|may)", "prohibition"),
]
RIGHT_PATTERNS = [
(r"has\s+(?:the\s+)?right\s+to", "right"),
(r"is\s+entitled\s+to", "entitlement"),
(r"may\s+(?:request|receive|access|obtain|submit|appeal)", "right"),
(r"have\s+the\s+right\s+to", "right"),
(r"(?:right|entitlement)\s+(?:of|to|under)", "right_statement"),
(r"shall\s+have\s+the\s+right\s+to", "right"),
]
CONDITION_PATTERNS = [
(r"provided\s+that", "condition"),
(r"subject\s+to", "condition"),
(r"where\s+the\s+(?:employer|controller|processor|undertaking|member\s+state)", "condition"),
(r"unless\s+", "exception"),
(r"except\s+where", "exception"),
(r"in\s+the\s+event\s+that", "condition"),
(r"in\s+accordance\s+with", "compliance_condition"),
]
ACTOR_PATTERNS = [
r"(employer|employee|worker|applicant|job\s+seeker)",
r"(controller|processor|data\s+subject)",
r"(member\s+state|national\s+authority|competent\s+authority)",
r"(commission|council|parliament|european\s+parliament)",
r"(undertaking|company|enterprise|organization)",
]
DEONTIC_PATTERNS = {
"shall", "must", "required", "obliged", "duty", "duties",
"responsible", "liability", "sanction", "penalty", "breach",
"prohibited", "not permitted", "shall ensure", "shall take",
"shall establish", "shall implement", "shall report",
"obligation", "obligations", "responsibility", "responsibilities",
"mandatory", "compliance", "compliant",
}
def extract_legal_relations(text: str) -> dict:
"""Extract legal relations from text chunks.
Args:
text: The text content of a chunk.
Returns:
Dict with keys: obligations, prohibitions, rights, conditions, actors
"""
relations = {
"obligations": [],
"prohibitions": [],
"rights": [],
"conditions": [],
"actors": [],
}
text_lower = text.lower()
def _extract_with_context(patterns, text_lower, text, context_window=60):
results = []
for pattern, rel_type in patterns:
for match in re.finditer(pattern, text_lower):
start = max(0, match.start() - context_window)
end = min(len(text), match.end() + context_window)
context = text[start:end]
results.append({
"type": rel_type,
"matched_text": match.group(0),
"context": context.strip(),
})
return results
relations["obligations"] = _extract_with_context(OBLIGATION_PATTERNS, text_lower, text)
relations["prohibitions"] = _extract_with_context(PROHIBITION_PATTERNS, text_lower, text, 50)
relations["rights"] = _extract_with_context(RIGHT_PATTERNS, text_lower, text, 50)
relations["conditions"] = _extract_with_context(CONDITION_PATTERNS, text_lower, text, 40)
# Extract actors
seen_actors = set()
for pattern in ACTOR_PATTERNS:
for match in re.finditer(pattern, text_lower):
actor = match.group(0).strip()
if actor not in seen_actors:
seen_actors.add(actor)
relations["actors"].append(actor)
return relations
def has_deontic_language(text: str) -> bool:
"""Check if text contains deontic (obligation) language."""
text_lower = text.lower()
return any(kw in text_lower for kw in DEONTIC_PATTERNS)
def count_deontic_density(text: str) -> int:
"""Count the number of deontic language occurrences in text."""
text_lower = text.lower()
return sum(text_lower.count(kw) for kw in DEONTIC_PATTERNS)
def summarize_relations(relations: dict) -> str:
"""Create a human-readable summary of legal relations for prompt injection.
Args:
relations: Output from extract_legal_relations()
Returns:
A compact text summary of the legal relations found.
"""
parts = []
if relations["obligations"]:
parts.append(f"OBLIGATIONS ({len(relations['obligations'])} found):")
seen_types = {}
for obl in relations["obligations"]:
t = obl["type"]
if t not in seen_types:
seen_types[t] = 0
seen_types[t] += 1
for t, count in sorted(seen_types.items(), key=lambda x: -x[1]):
parts.append(f" - {t}: {count} instance(s)")
if relations["prohibitions"]:
parts.append(f"PROHIBITIONS ({len(relations['prohibitions'])} found):")
for pro in relations["prohibitions"][:2]:
ctx = pro["context"][:80]
parts.append(f" - {pro['type']}: ...{ctx}...")
if relations["rights"]:
parts.append(f"RIGHTS ({len(relations['rights'])} found)")
for right in relations["rights"][:2]:
ctx = right["context"][:80]
parts.append(f" - {right['type']}: ...{ctx}...")
if relations["conditions"]:
parts.append(f"CONDITIONS ({len(relations['conditions'])} found)")
if relations["actors"]:
parts.append(f"ACTORS: {', '.join(relations['actors'][:5])}")
return "\n".join(parts)