BAV_KI / src /orchestrator.py
AlixJabda's picture
Aktualisierung der AM-RL-Module und Retrieval-Logik
d80690d
Raw
History Blame Contribute Delete
49.9 kB
from __future__ import annotations
import re
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING
from llm_client_groq import classify_question, is_meta_question
if TYPE_CHECKING:
from llm_client_groq import ConversationMemory
else:
ConversationMemory = Any # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Regexes and lightweight legal intent detection
# ---------------------------------------------------------------------------
NORM_REF_RE = re.compile(
r"§{1,2}\s*(?P<section>\d+[a-zA-Z]?)"
r"(?:\s*(?:-|–|bis)\s*(?P<section_to>\d+[a-zA-Z]?))?"
r"(?:\s*(?:Abs\.|Absatz)\s*(?P<subsection>\d+[a-zA-Z]?))?"
r"(?:\s*Satz\s*(?P<sentence>\d+[a-zA-Z]?))?"
r"(?:\s*(?:Nr\.|Nummer)\s*(?P<number>\d+[a-zA-Z]?))?"
r"(?:\s*(?:Buchst\.|Buchstabe|lit\.)\s*(?P<letter>[a-zA-Z]))?",
re.I,
)
SOURCE_MARKER_RE = re.compile(r"\[Quelle\s+(\d+)(?:[^\]]*)\]", re.I)
SOURCE_MARKER_WITH_OPTIONAL_REF_RE = re.compile(
r"\[Quelle\s+(\d+)(?:[^\]]*)\]"
r"(?:\s*\(§[^)]{1,120}\))?",
re.I,
)
ORPHAN_LEGAL_REF_PAREN_RE = re.compile(r"\(§\s*\d{1,3}[a-z]?(?:\s+[^)]{0,80})?\)", re.I)
NEGATIVE_ANSWER_RE = re.compile(
r"\b(keine\s+(relevante\s+)?textstelle|nichts?\s+(geregelt|enthalten|auffindbar)|"
r"keine\s+(regelung|informationen|aussage)|nicht\s+belastbar\s+ableitbar|"
r"kann\s+.*?nicht\s+(festgestellt|beantwortet)\s+werden)\b",
re.I,
)
# Fine-grained citation fragments generated by the LLM must be verified against
# actual source metadata. In practice, models often over-specialise references
# such as "§ 6 Abs. 1 Buchst. a" although the underlying chunk only supports
# "§ 6 Abs. 1". The orchestrator therefore downgrades unsupported fine refs
# instead of passing them through as if they were verified citations.
FINE_BUCHST_REF_RE = re.compile(
r"§\s*(?P<section>\d{1,3}[a-z]?)\s+"
r"(?:Abs\.|Absatz)\s*(?P<subsection>\d{1,3}[a-z]?)\s+"
r"(?:Buchst\.|Buchstabe|lit\.)\s*(?P<letter>[a-z])",
re.I,
)
CITATION_PAREN_RE = re.compile(
r"(?P<marker>\[Quelle\s+\d+(?:[^\]]*)\])\s*"
r"\((?P<ref>§[^)]{1,120})\)",
re.I,
)
DANGLING_CITATION_GRAMMAR_REPLACEMENTS: Tuple[Tuple[re.Pattern[str], str], ...] = (
# Remove orphan legal-ref parentheses that remain after invalid source markers
# were stripped, e.g. "[Quelle 1] (§ 6) und (§ 6)".
(re.compile(r"(\[Quelle\s+\d+(?:[^\]]*)\](?:\s*\(§[^)]{1,120}\))?)\s+und\s+\(§[^)]{1,120}\)", re.I), r"\1"),
(re.compile(r"(\[Quelle\s+\d+(?:[^\]]*)\](?:\s*\(§[^)]{1,120}\))?)\s*,\s*\(§[^)]{1,120}\)", re.I), r"\1"),
(re.compile(r"\s+(?:und|oder)\s+\(§[^)]{1,120}\)", re.I), r""),
(re.compile(r"\s+und\s+(beschrieben|genannt|geregelt|dargelegt|aufgeführt)\b", re.I), r" \1"),
(re.compile(r"\bwie\s+in\s+([^.;:\n]{1,160}?)\s+und\s+(beschrieben|genannt|geregelt|dargelegt)\b", re.I), r"wie in \1 \2"),
(re.compile(r"\bdie\s+in\s+den\s+Quellen\s+([^.;:\n]{1,160}?)\s+und\s+genannt\s+sind", re.I), r"die in \1 genannt sind"),
(re.compile(r"\bdie\s+in\s+([^.;:\n]{1,160}?)\s+und\s+genannt\s+sind", re.I), r"die in \1 genannt sind"),
(re.compile(r"\bund\s+weiteren\s+Quellen\s+(dargelegt|beschrieben|genannt)\s+(sind|ist)", re.I), r"\1 \2"),
)
DEFINITION_RE = re.compile(
r"\b(was\s+(versteht|bedeutet)|wie\s+definiert|definition|legaldefinition|"
r"begriff|unter\s+[„\"']?[^?]+[”\"']?\s+versteht)\b",
re.I,
)
ENUMERATION_RE = re.compile(
r"\b(welche|alle|sämtliche|liste|nennt|kriterien|voraussetzungen|tatbestandsmerkmale|"
r"bestandteile|anforderungen|fälle|maßnahmen|regelt\s+§)\b",
re.I,
)
COMPARISON_RE = re.compile(r"\b(unterschied|vergleiche|vergleich|gegenüber|vs\.?|versus)\b", re.I)
CLARIFICATION_RISK_RE = re.compile(
r"\b(das|dies|diese|der\s+fall|so\s+ein\s+fall|dort|hierbei|teilnahme|anspruch|"
r"abrechnung|pflicht|folge|konsequenz)\b",
re.I,
)
BUILTIN_QUERY_EXPANSIONS: Dict[str, List[str]] = {
"nicht verfügbar": ["Nichtverfügbarkeit", "lieferfähig", "nicht lieferbar", "Lieferengpass", "Verfügbarkeit"],
"beitritt": ["teilnehmen", "Teilnahme", "Mitgliedsverband", "DAV", "Erklärung", "beitreten"],
"auseinzelung": ["Teilmenge", "Auseinzelung", "einzelne Einheit", "Packung", "Entnahme"],
"pharmazeutische dienstleistungen": ["pharmazeutische Dienstleistungen", "Anlage 11", "Anspruchsvoraussetzungen", "Vergütung", "Abrechnung"],
"wunscharzneimittel": ["Wunscharzneimittel", "Kostenerstattung", "anderes Fertigarzneimittel", "§§ 11 bis 14"],
}
# ---------------------------------------------------------------------------
# Public dataclasses
# ---------------------------------------------------------------------------
@dataclass(slots=True, frozen=True)
class NormReference:
section: str
section_to: str | None = None
subsection: str | None = None
sentence: str | None = None
number: str | None = None
letter: str | None = None
@property
def section_id(self) -> str:
return f"§ {self.section}"
@property
def canonical_ref(self) -> str:
parts = [self.section_id]
if self.subsection:
parts.append(f"Abs. {self.subsection}")
if self.sentence:
parts.append(f"Satz {self.sentence}")
if self.number:
parts.append(f"Nr. {self.number}")
if self.letter:
parts.append(f"Buchst. {self.letter.lower()}")
return " ".join(parts)
@dataclass(slots=True)
class QuestionAnalysis:
question: str
question_kind: str
intent: str
norm_references: List[NormReference] = field(default_factory=list)
query_terms: List[str] = field(default_factory=list)
expanded_terms: List[str] = field(default_factory=list)
needs_clarification: bool = False
clarification_question: str | None = None
reasons: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"question": self.question,
"question_kind": self.question_kind,
"intent": self.intent,
"norm_references": [asdict(ref) | {"canonical_ref": ref.canonical_ref} for ref in self.norm_references],
"query_terms": list(self.query_terms),
"expanded_terms": list(self.expanded_terms),
"needs_clarification": self.needs_clarification,
"clarification_question": self.clarification_question,
"reasons": list(self.reasons),
}
@dataclass(slots=True)
class RetrievalAssessment:
hit_count: int = 0
direct_hit_count: int = 0
exact_norm_hit_count: int = 0
definition_hit_count: int = 0
parent_context_count: int = 0
neighbor_count: int = 0
containers: List[str] = field(default_factory=list)
sections: List[str] = field(default_factory=list)
canonical_refs: List[str] = field(default_factory=list)
low_confidence: bool = False
negative_recheck_performed: bool = False
negative_recheck_added_hits: int = 0
reasons: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@dataclass(slots=True)
class AnswerAudit:
cited_source_numbers: List[int] = field(default_factory=list)
invalid_source_numbers: List[int] = field(default_factory=list)
cited_canonical_refs: List[str] = field(default_factory=list)
answer_basis: str = "unknown" # explicit | derived | negative | insufficient | unknown
negative_answer_detected: bool = False
completeness_warnings: List[str] = field(default_factory=list)
citation_warnings: List[str] = field(default_factory=list)
auto_fixes: List[str] = field(default_factory=list)
recommended_action: str = "accept" # accept | recheck | clarify | caution
confidence: float = 0.5
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@dataclass(slots=True)
class OrchestratorOptions:
top_k: int = 8
fetch_k: int = 24
max_final_results: int = 10
max_sources: int = 5
include_neighbors: bool = True
include_explicit_sections: bool = True
# False: das Ranking priorisiert den Hauptvertrag; ein harter Filter machte
# Anlagen/Anhänge (Anlage 11, Dienstleistungs-Anhänge) unauffindbar.
restrict_to_default_container: bool = False
default_container_id: str = "Vertrag"
min_score: float | None = None
enable_negative_recheck: bool = True
enable_clarification: bool = True
enable_answer_audit: bool = True
enrich_citations_with_canonical_refs: bool = True
debug: bool = False
@dataclass(slots=True)
class OrchestratorResult:
answer: str
answer_type: str
hits: List[Dict[str, Any]] = field(default_factory=list)
raw_sources: List[Dict[str, Any]] = field(default_factory=list)
sources: List[Dict[str, Any]] = field(default_factory=list)
analysis: QuestionAnalysis | None = None
retrieval_assessment: RetrievalAssessment | None = None
answer_audit: AnswerAudit | None = None
needs_clarification: bool = False
clarification_question: str | None = None
debug: Dict[str, Any] = field(default_factory=dict)
def to_debug_dict(self) -> Dict[str, Any]:
return {
"analysis": self.analysis.to_dict() if self.analysis else None,
"retrieval_assessment": self.retrieval_assessment.to_dict() if self.retrieval_assessment else None,
"answer_audit": self.answer_audit.to_dict() if self.answer_audit else None,
"needs_clarification": self.needs_clarification,
"clarification_question": self.clarification_question,
"debug": dict(self.debug),
}
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def _normalize(text: str) -> str:
return " ".join((text or "").strip().split())
def _lower(text: str) -> str:
return _normalize(text).lower()
def _hit_meta(hit: Dict[str, Any]) -> Dict[str, Any]:
meta = hit.get("metadata") or {}
return meta if isinstance(meta, dict) else {}
def _hit_value(hit: Dict[str, Any], *keys: str, default: Any = None) -> Any:
meta = _hit_meta(hit)
for key in keys:
value = hit.get(key)
if value is not None and value != "":
return value
for key in keys:
value = meta.get(key)
if value is not None and value != "":
return value
return default
def _hit_text(hit: Dict[str, Any]) -> str:
return str(hit.get("text") or hit.get("document") or "").strip()
def _hit_kinds(hit: Dict[str, Any]) -> List[str]:
kinds = hit.get("retrieval_kinds") or []
if isinstance(kinds, str):
return [kinds]
return [str(k) for k in kinds]
def _hit_score(hit: Dict[str, Any]) -> float:
try:
return float(hit.get("rank_score", hit.get("score", 0.0)) or 0.0)
except (TypeError, ValueError):
return 0.0
def _source_key(hit: Dict[str, Any]) -> Tuple[Any, ...]:
meta = _hit_meta(hit)
text_hash = meta.get("text_hash") or hit.get("text_hash")
if text_hash:
return ("hash", text_hash)
legal_unit_id = meta.get("legal_unit_id") or hit.get("legal_unit_id")
if legal_unit_id:
return ("legal_unit", legal_unit_id)
return (
_hit_value(hit, "container", "container_id", default=""),
_hit_value(hit, "section", "section_id", default=""),
_hit_value(hit, "chunk_index", "chunk_index_in_section", default=""),
_hit_text(hit)[:160],
)
def _canonical_ref_from_hit(hit: Dict[str, Any]) -> str:
meta = _hit_meta(hit)
direct = _hit_value(hit, "canonical_ref", default=None)
if direct:
return str(direct)
paragraph = meta.get("paragraph") or hit.get("paragraph") or _hit_value(hit, "section", "section_id", default="")
subsection = meta.get("subsection") or hit.get("subsection")
sentence = meta.get("sentence") or hit.get("sentence")
number = meta.get("number") or hit.get("number")
letter = meta.get("letter") or hit.get("letter")
parts = [str(paragraph)] if paragraph else []
if subsection:
parts.append(f"Abs. {subsection}")
if sentence:
parts.append(f"Satz {sentence}")
if number:
parts.append(f"Nr. {number}")
if letter:
parts.append(f"Buchst. {str(letter).lower()}")
return " ".join(parts)
def _dedupe_hits(hits: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
merged: Dict[Tuple[Any, ...], Dict[str, Any]] = {}
for hit in hits:
key = _source_key(hit)
old = merged.get(key)
if old is None or _hit_score(hit) > _hit_score(old):
merged[key] = dict(hit)
else:
old_kinds = set(_hit_kinds(old))
old_kinds.update(_hit_kinds(hit))
old["retrieval_kinds"] = sorted(old_kinds)
out = list(merged.values())
out.sort(key=lambda h: (_hit_score(h), "parent_context" in set(_hit_kinds(h))), reverse=True)
return out
def extract_norm_references(question: str, *, max_range: int = 30) -> List[NormReference]:
refs: List[NormReference] = []
for match in NORM_REF_RE.finditer(question or ""):
start = match.group("section")
end = match.group("section_to")
if end and start.isdigit() and end.isdigit():
start_i, end_i = int(start), int(end)
if start_i <= end_i and (end_i - start_i) <= max_range:
for no in range(start_i, end_i + 1):
refs.append(NormReference(section=str(no)))
continue
refs.append(
NormReference(
section=start,
section_to=end,
subsection=match.group("subsection"),
sentence=match.group("sentence"),
number=match.group("number"),
letter=match.group("letter"),
)
)
seen: set[str] = set()
unique: List[NormReference] = []
for ref in refs:
key = ref.canonical_ref
if key not in seen:
seen.add(key)
unique.append(ref)
return unique
def expand_query_terms(question: str) -> List[str]:
q = _lower(question)
terms: List[str] = []
for key, values in BUILTIN_QUERY_EXPANSIONS.items():
if key in q or any(v.lower() in q for v in values):
terms.extend([key, *values])
# Extract quoted terms as high-value legal-definition candidates.
for quoted in re.findall(r"[„\"']([^„”\"']{2,80})[”\"']", question or ""):
terms.append(quoted.strip())
return list(dict.fromkeys(t for t in terms if t))
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
class LegalAnswerOrchestrator:
"""
Fachliche Ablaufsteuerung für die juristische RAG-Antwort.
Diese Klasse ist bewusst unabhängig von FastAPI. `app.py` bleibt die
Aussteuerungs- und API-Schicht und kann diesen Orchestrator als einen
Knoten im Ask-Graph verwenden.
Erwartete externe Komponenten:
- retriever: besitzt idealerweise `query(...)` und optional `verify_negative_result(...)`
- composer: besitzt `compose_with_sources(...)` oder `compose(...)`
"""
def __init__(self, retriever: Any, composer: Any, *, options: OrchestratorOptions | None = None):
self.retriever = retriever
self.composer = composer
self.options = options or OrchestratorOptions()
# ------------------------------------------------------------------
# Analysis and planning
# ------------------------------------------------------------------
def analyze_question(self, question: str) -> QuestionAnalysis:
q = _normalize(question)
lower = q.lower()
refs = extract_norm_references(q)
expanded = expand_query_terms(q)
reasons: List[str] = []
if is_meta_question(q):
intent = "meta"
reasons.append("meta_question")
elif DEFINITION_RE.search(q):
intent = "definition"
reasons.append("definition_pattern")
elif refs and ENUMERATION_RE.search(q):
intent = "norm_enumeration"
reasons.append("explicit_norm_and_enumeration_pattern")
elif refs:
intent = "explicit_norm"
reasons.append("explicit_norm_reference")
elif COMPARISON_RE.search(q):
intent = "comparison"
reasons.append("comparison_pattern")
elif ENUMERATION_RE.search(q):
intent = "enumeration"
reasons.append("enumeration_pattern")
else:
intent = "legal" if classify_question(q) == "legal" else classify_question(q)
reasons.append("classified_by_llm_client_rules")
# Conservative clarification detection: only ask when no explicit norm is
# present and the wording is likely underspecified.
needs_clarification = False
clarification_question: str | None = None
if self.options.enable_clarification and not refs and CLARIFICATION_RISK_RE.search(lower):
if len(expanded) == 0 and len(q.split()) <= 12:
needs_clarification = True
clarification_question = (
"Meinst du eine konkrete Regelung im Rahmenvertrag, eine bestimmte Anlage "
"oder die rechtliche Folge für einen bestimmten Sachverhalt?"
)
reasons.append("underspecified_without_norm_reference")
return QuestionAnalysis(
question=q,
question_kind=classify_question(q),
intent=intent,
norm_references=refs,
query_terms=[r.canonical_ref for r in refs],
expanded_terms=expanded,
needs_clarification=needs_clarification,
clarification_question=clarification_question,
reasons=reasons,
)
def _retriever_query(self, question: str, analysis: QuestionAnalysis, options: OrchestratorOptions) -> List[Dict[str, Any]]:
where = {"container_id": options.default_container_id} if options.restrict_to_default_container else None
explicit_sections = [ref.section_id for ref in analysis.norm_references] or None
kwargs: Dict[str, Any] = {
"question": question,
"top_k": options.top_k,
"where": where,
"fetch_k": options.fetch_k,
"include_explicit_sections": options.include_explicit_sections,
"explicit_sections": explicit_sections,
"include_neighbors": options.include_neighbors,
"max_final_results": options.max_final_results,
"restrict_to_default_container": options.restrict_to_default_container,
}
if options.min_score is not None:
kwargs["min_score"] = options.min_score
# Newer standalone retriever may accept these flags; older one will not.
if analysis.intent == "definition":
kwargs["enable_definition_lookup"] = True
kwargs["verify_negative_answer"] = False
try:
return list(self.retriever.query(**kwargs) or [])
except TypeError:
# Backward-compatible fallback for older retrievers.
kwargs.pop("enable_definition_lookup", None)
kwargs.pop("verify_negative_answer", None)
try:
return list(self.retriever.query(**kwargs) or [])
except TypeError:
try:
return list(self.retriever.query(question=question, top_k=options.top_k, where=where) or [])
except TypeError:
return list(self.retriever.query(question, top_k=options.top_k) or [])
def assess_retrieval(self, hits: Sequence[Dict[str, Any]], analysis: QuestionAnalysis) -> RetrievalAssessment:
kinds = [kind for hit in hits for kind in _hit_kinds(hit)]
containers = list(dict.fromkeys(str(_hit_value(h, "container", "container_id", default="")) for h in hits if _hit_value(h, "container", "container_id", default="")))
sections = list(dict.fromkeys(str(_hit_value(h, "section", "section_id", default="")) for h in hits if _hit_value(h, "section", "section_id", default="")))
canonical_refs = list(dict.fromkeys(ref for ref in (_canonical_ref_from_hit(h) for h in hits) if ref))
direct_hit_count = sum(1 for k in kinds if k not in {"neighbor", "parent_context"})
exact_count = sum(1 for k in kinds if k in {"exact_norm", "explicit_section", "section_lookup"})
definition_count = sum(1 for h in hits if bool(_hit_value(h, "is_definition", default=False)) or "definition" in set(_hit_kinds(h)))
parent_count = sum(1 for k in kinds if k == "parent_context")
neighbor_count = sum(1 for k in kinds if k == "neighbor")
low_confidence = False
reasons: List[str] = []
if not hits:
low_confidence = True
reasons.append("no_hits")
if analysis.norm_references and exact_count == 0:
low_confidence = True
reasons.append("explicit_norm_without_exact_hit")
if analysis.intent == "definition" and definition_count == 0:
low_confidence = True
reasons.append("definition_question_without_definition_hit")
if direct_hit_count == 0 and hits:
low_confidence = True
reasons.append("only_context_hits")
return RetrievalAssessment(
hit_count=len(hits),
direct_hit_count=direct_hit_count,
exact_norm_hit_count=exact_count,
definition_hit_count=definition_count,
parent_context_count=parent_count,
neighbor_count=neighbor_count,
containers=containers,
sections=sections,
canonical_refs=canonical_refs[:20],
low_confidence=low_confidence,
reasons=reasons,
)
def _negative_recheck(
self,
question: str,
analysis: QuestionAnalysis,
hits: List[Dict[str, Any]],
assessment: RetrievalAssessment,
options: OrchestratorOptions,
) -> Tuple[List[Dict[str, Any]], RetrievalAssessment]:
if not options.enable_negative_recheck or not assessment.low_confidence:
return hits, assessment
before = len(hits)
recheck_hits: List[Dict[str, Any]] = []
if hasattr(self.retriever, "verify_negative_result"):
try:
check = self.retriever.verify_negative_result(question)
if isinstance(check, dict):
recheck_hits = list(check.get("results") or check.get("hits") or [])
elif isinstance(check, list):
recheck_hits = list(check)
except TypeError:
try:
check = self.retriever.verify_negative_result(question=question)
if isinstance(check, dict):
recheck_hits = list(check.get("results") or check.get("hits") or [])
except Exception:
recheck_hits = []
except Exception:
recheck_hits = []
if not recheck_hits:
# Manual broad fallback: all containers, no restrictive score if possible.
broad_options = OrchestratorOptions(**asdict(options))
broad_options.restrict_to_default_container = False
broad_options.min_score = None
broad_options.include_neighbors = True
broad_options.include_explicit_sections = True
broad_options.fetch_k = max(options.fetch_k, options.top_k * 6)
broad_options.max_final_results = max(options.max_final_results, 16)
recheck_hits = self._retriever_query(question, analysis, broad_options)
combined = _dedupe_hits([*hits, *recheck_hits])
assessment = self.assess_retrieval(combined, analysis)
assessment.negative_recheck_performed = True
assessment.negative_recheck_added_hits = max(0, len(combined) - before)
if assessment.negative_recheck_added_hits:
assessment.reasons.append("negative_recheck_added_hits")
return combined, assessment
# ------------------------------------------------------------------
# Composition and audit
# ------------------------------------------------------------------
def _compose(self, question: str, hits: List[Dict[str, Any]], memory: Optional[ConversationMemory]) -> Tuple[str, str, List[Dict[str, Any]]]:
if hasattr(self.composer, "compose_with_sources"):
try:
return self.composer.compose_with_sources(question, hits, memory=memory)
except TypeError:
pass
answer, answer_type = self.composer.compose(question, hits, memory=memory)
sources: List[Dict[str, Any]] = []
if hasattr(self.composer, "build_sources"):
try:
sources = self.composer.build_sources(hits)
except Exception:
sources = []
return answer, answer_type, sources
@staticmethod
def _source_numbers_from_source(source: Dict[str, Any], *, fallback: int | None = None) -> List[int]:
"""Return all source numbers represented by a Composer/API source item."""
numbers: List[int] = []
raw_numbers = source.get("source_numbers")
if raw_numbers is not None:
if not isinstance(raw_numbers, (list, tuple, set)):
raw_numbers = [raw_numbers]
for value in raw_numbers:
try:
number = int(value)
except (TypeError, ValueError):
continue
if number not in numbers:
numbers.append(number)
raw_single = source.get("source_number")
if raw_single is not None:
try:
number = int(raw_single)
if number not in numbers:
numbers.append(number)
except (TypeError, ValueError):
pass
if not numbers and fallback is not None:
numbers.append(fallback)
return sorted(numbers)
@staticmethod
def _canonical_refs_from_source(source: Dict[str, Any]) -> List[str]:
refs: List[str] = []
raw_refs = source.get("canonical_refs")
if raw_refs is not None:
if not isinstance(raw_refs, (list, tuple, set)):
raw_refs = [raw_refs]
for value in raw_refs:
ref = _normalize(str(value or ""))
if ref and ref not in refs:
refs.append(ref)
direct = _normalize(str(source.get("canonical_ref") or ""))
if direct and direct not in refs:
refs.append(direct)
fallback = _normalize(_canonical_ref_from_hit(source))
if fallback and fallback not in refs:
refs.append(fallback)
return refs
@classmethod
def _source_number_to_ref(cls, sources: Sequence[Dict[str, Any]], hits: Sequence[Dict[str, Any]]) -> Dict[int, str]:
"""Map every visible [Quelle n] number to the best known canonical ref.
Composer sources may group multiple source numbers into one displayed
source item. The previous implementation only considered
``source_number`` and silently lost grouped numbers such as
``source_numbers=[1, 3]``.
"""
mapping: Dict[int, str] = {}
for idx, source in enumerate(sources, start=1):
numbers = cls._source_numbers_from_source(source, fallback=idx)
refs = cls._canonical_refs_from_source(source)
if not refs:
continue
# Prefer a concise, stable ref for inline enrichment. Fine-grained
# refs remain available in the separate source list; inline enrichment
# must not invent suspicious Buchst.-level citations.
ref = refs[0]
for candidate in refs:
if "Buchst." not in candidate and "Buchstabe" not in candidate:
ref = candidate
break
for number in numbers:
mapping.setdefault(number, ref)
# Fallback: use retrieval hit order only when Composer sources did not
# provide any number mapping. This keeps older stacks usable while not
# overriding Composer numbering.
if not mapping:
for idx, hit in enumerate(hits, start=1):
ref = _canonical_ref_from_hit(hit)
if ref:
mapping[idx] = ref
return mapping
@staticmethod
def _answer_has_inline_ref_after_marker(answer: str, marker_end: int) -> bool:
return bool(re.match(r"\s*\(", (answer or "")[marker_end : marker_end + 8]))
@classmethod
def _enrich_answer_citations(cls, answer: str, source_to_ref: Dict[int, str]) -> str:
"""Conservatively add canonical refs to bare source markers.
Inline enrichment is intentionally restrained. Generic section-only
references such as ``§ 6`` add little value and caused noisy outputs
like ``[Quelle 1] (§ 6) und (§ 6)``. More specific refs may still be
added when the marker is bare and the model did not already provide a
parenthetical.
"""
if not answer or not source_to_ref:
return answer
def repl(match: re.Match[str]) -> str:
source_no = int(match.group(1))
ref = source_to_ref.get(source_no)
marker = match.group(0)
if not ref:
return marker
if cls._answer_has_inline_ref_after_marker(answer, match.end()):
return marker
if not re.search(r"\b(?:Abs\.|Satz|Nr\.|Buchst\.)\b", ref, flags=re.I):
return marker
return f"{marker} ({ref})"
return SOURCE_MARKER_RE.sub(repl, answer)
@staticmethod
def _extract_source_numbers(answer: str) -> List[int]:
out: List[int] = []
for match in SOURCE_MARKER_RE.finditer(answer or ""):
try:
out.append(int(match.group(1)))
except ValueError:
continue
return list(dict.fromkeys(out))
@staticmethod
def _normalize_legal_ref(ref: str) -> str:
s = _normalize(ref)
s = re.sub(r"§\s*", "§ ", s)
s = re.sub(r"\bAbsatz\b", "Abs.", s, flags=re.I)
s = re.sub(r"\bBuchstabe\b|\blit\.", "Buchst.", s, flags=re.I)
s = re.sub(r"\s+", " ", s).strip(" .,;:")
return s.lower()
@classmethod
def _supported_ref_set(cls, sources: Sequence[Dict[str, Any]], hits: Sequence[Dict[str, Any]]) -> set[str]:
refs: set[str] = set()
for source in sources:
for ref in cls._canonical_refs_from_source(source):
refs.add(cls._normalize_legal_ref(ref))
for hit in hits:
ref = _canonical_ref_from_hit(hit)
if ref:
refs.add(cls._normalize_legal_ref(ref))
return refs
@classmethod
def _downgrade_unsupported_fine_refs(
cls,
answer: str,
sources: Sequence[Dict[str, Any]],
hits: Sequence[Dict[str, Any]],
) -> Tuple[str, List[str]]:
"""Downgrade unsupported Buchst.-level references to Absatz level."""
fixes: List[str] = []
if not answer:
return answer, fixes
supported = cls._supported_ref_set(sources, hits)
def repl(match: re.Match[str]) -> str:
full = match.group(0)
norm_full = cls._normalize_legal_ref(full)
if norm_full in supported:
return full
downgraded = f"§ {match.group('section')} Abs. {match.group('subsection')}"
fixes.append(f"downgraded_unsupported_fine_ref:{full}->{downgraded}")
return downgraded
cleaned = FINE_BUCHST_REF_RE.sub(repl, answer)
# Collapse duplicates introduced by downgrading, e.g.
# "[Quelle 1] (§ 6 Abs. 1), § 6 Abs. 1".
cleaned = re.sub(
r"(\[Quelle\s+\d+(?:[^\]]*)\]\s*\((§\s*\d{1,3}[a-z]?\s+Abs\.\s*\d{1,3}[a-z]?)\))\s*,\s*\2",
r"\1",
cleaned,
flags=re.I,
)
cleaned = re.sub(
r"(\((§\s*\d{1,3}[a-z]?\s+Abs\.\s*\d{1,3}[a-z]?)\))\s*,\s*\2",
r"\1",
cleaned,
flags=re.I,
)
return cleaned, fixes
@classmethod
def _valid_source_numbers(cls, sources: Sequence[Dict[str, Any]], hits: Sequence[Dict[str, Any]]) -> set[int]:
valid: set[int] = set()
for idx, source in enumerate(sources, start=1):
valid.update(cls._source_numbers_from_source(source, fallback=idx))
if not valid:
valid.update(range(1, len(hits) + 1))
return valid
@classmethod
def _strip_invalid_source_markers_safely(
cls,
answer: str,
sources: Sequence[Dict[str, Any]],
hits: Sequence[Dict[str, Any]],
) -> Tuple[str, List[str]]:
"""Remove invalid source markers including attached parenthetical refs.
Previous versions removed only ``[Quelle n]`` and left the enrichment
tail behind. That produced broken fragments like ``und (§ 6)``. This
method removes the whole citation atom for invalid markers and then runs
a grammar cleanup pass.
"""
fixes: List[str] = []
if not answer:
return answer, fixes
valid = cls._valid_source_numbers(sources, hits)
def repl(match: re.Match[str]) -> str:
try:
number = int(match.group(1))
except (TypeError, ValueError):
fixes.append("removed_malformed_source_marker")
return ""
if number in valid:
return match.group(0)
fixes.append(f"removed_invalid_source_marker_with_ref:{number}")
return ""
cleaned = SOURCE_MARKER_WITH_OPTIONAL_REF_RE.sub(repl, answer)
cleaned = cls._cleanup_citation_grammar(cleaned)
return cleaned.strip(), fixes
@staticmethod
def _cleanup_citation_grammar(answer: str) -> str:
"""Repair grammar after citation cleanup without changing legal content."""
cleaned = answer or ""
# Collapse model/enrichment duplicates such as:
# [Quelle 1] (§ 6), § 6 Abs. 1 -> [Quelle 1] (§ 6 Abs. 1)
cleaned = re.sub(
r"(\[Quelle\s+\d+(?:[^\]]*)\])\s*\(§\s*(\d{1,3}[a-z]?)\)\s*,\s*(§\s*\2\s+Abs\.\s*\d{1,3}[a-z]?)",
r"\1 (\3)",
cleaned,
flags=re.I,
)
for pattern, replacement in DANGLING_CITATION_GRAMMAR_REPLACEMENTS:
cleaned = pattern.sub(replacement, cleaned)
# Remove duplicate adjacent identical parenthetical legal refs.
cleaned = re.sub(
r"(\(§[^)]{1,120}\))\s*(?:,|und)\s*\1",
r"\1",
cleaned,
flags=re.I,
)
# Clean common connective leftovers.
cleaned = re.sub(r"\s+und\s+([,.;:])", r"\1", cleaned, flags=re.I)
cleaned = re.sub(r"\s+und\s*(?=\.)", "", cleaned, flags=re.I)
cleaned = re.sub(r"\(\s*\)", "", cleaned)
cleaned = re.sub(r"\s+([,.;:])", r"\1", cleaned)
cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
cleaned = re.sub(r"\n[ \t]+", "\n", cleaned)
return cleaned.strip()
@classmethod
def _ensure_cited_sources_visible(
cls,
answer: str,
sources: List[Dict[str, Any]],
hits: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Ensure every cited [Quelle n] is represented in returned sources."""
cited = cls._extract_source_numbers(answer)
if not cited:
return sources
visible = cls._valid_source_numbers(sources, [])
missing = [n for n in cited if n not in visible]
if not missing:
return sources
out = list(sources)
for number in missing:
idx = number - 1
if 0 <= idx < len(hits):
source = dict(hits[idx])
source["source_number"] = number
source["source_numbers"] = [number]
if "canonical_ref" not in source or not source.get("canonical_ref"):
source["canonical_ref"] = _canonical_ref_from_hit(source)
out.append(source)
return out
@classmethod
def _filter_sources_to_cited_sources(
cls,
answer: str,
sources: List[Dict[str, Any]],
hits: List[Dict[str, Any]],
*,
max_sources: int,
) -> List[Dict[str, Any]]:
"""Return only sources that are actually cited, with hit fallback.
For answer quality, visible sources should not include unrelated
retrieval leftovers when the answer cites only a small subset. If the
model cites no source markers, keep the curated source list.
"""
cited = cls._extract_source_numbers(answer)
if not cited:
return list(sources)[:max_sources]
by_number: Dict[int, Dict[str, Any]] = {}
for idx, source in enumerate(sources, start=1):
for number in cls._source_numbers_from_source(source, fallback=idx):
by_number.setdefault(number, source)
out: List[Dict[str, Any]] = []
seen_ids: set[int] = set()
for number in cited:
source = by_number.get(number)
if source is None:
hit_idx = number - 1
if 0 <= hit_idx < len(hits):
source = dict(hits[hit_idx])
source["source_number"] = number
source["source_numbers"] = [number]
source.setdefault("canonical_ref", _canonical_ref_from_hit(source))
if source is None:
continue
ident = id(source)
if ident in seen_ids:
continue
seen_ids.add(ident)
out.append(source)
if len(out) >= max_sources:
break
return out or list(sources)[:max_sources]
@classmethod
def _postprocess_answer(
cls,
answer: str,
sources: List[Dict[str, Any]],
hits: List[Dict[str, Any]],
) -> Tuple[str, List[str]]:
fixes: List[str] = []
cleaned, ref_fixes = cls._downgrade_unsupported_fine_refs(answer, sources, hits)
fixes.extend(ref_fixes)
cleaned, marker_fixes = cls._strip_invalid_source_markers_safely(cleaned, sources, hits)
fixes.extend(marker_fixes)
cleaned = cls._cleanup_citation_grammar(cleaned)
return cleaned, fixes
@staticmethod
def _detect_answer_basis(answer: str) -> str:
a = _lower(answer)
if NEGATIVE_ANSWER_RE.search(answer or ""):
return "negative"
if "ausdrücklich geregelt" in a:
return "explicit"
if "systematisch" in a or "ableitbar" in a or "auslegung" in a:
return "derived"
if "nicht belastbar" in a or "keine belastbare" in a:
return "insufficient"
return "unknown"
@staticmethod
def _expected_list_labels_from_hits(hits: Sequence[Dict[str, Any]]) -> List[str]:
text = "\n".join(_hit_text(h) for h in hits[:8])
labels = set()
for m in re.finditer(r"(?:^|\n|\s)([a-z])\)\s+", text):
labels.add(f"{m.group(1).lower()})")
for m in re.finditer(r"(?:^|\n|\s)(\d{1,2})[.)]\s+", text):
labels.add(f"{m.group(1)}")
ordered_letters = [f"{chr(i)})" for i in range(ord("a"), ord("z") + 1) if f"{chr(i)})" in labels]
ordered_nums = sorted([x for x in labels if x.isdigit()], key=lambda n: int(n))
return ordered_letters + ordered_nums
def audit_answer(
self,
question: str,
answer: str,
hits: List[Dict[str, Any]],
sources: List[Dict[str, Any]],
analysis: QuestionAnalysis,
assessment: RetrievalAssessment,
) -> AnswerAudit:
cited = self._extract_source_numbers(answer)
valid_numbers = self._valid_source_numbers(sources, hits)
invalid = [n for n in cited if n not in valid_numbers]
basis = self._detect_answer_basis(answer)
negative = basis == "negative"
completeness_warnings: List[str] = []
citation_warnings: List[str] = []
if not cited and hits and basis != "negative":
citation_warnings.append("answer_contains_no_source_markers")
if invalid:
citation_warnings.append("answer_contains_invalid_source_markers")
if analysis.norm_references and not any(ref.section_id in " ".join(assessment.sections + assessment.canonical_refs) for ref in analysis.norm_references):
completeness_warnings.append("explicit_norm_not_reflected_in_retrieved_context")
if analysis.intent in {"enumeration", "norm_enumeration"}:
labels = self._expected_list_labels_from_hits(hits)
if len(labels) >= 3:
answer_lower = answer.lower()
missing_labels = [label for label in labels if label not in answer_lower]
# Do not force exact labels in prose answers, but flag likely omissions.
if len(missing_labels) >= max(2, len(labels) // 2):
completeness_warnings.append(
"possible_incomplete_enumeration: expected list markers " + ", ".join(labels[:12])
)
if negative and assessment.hit_count > 0 and not assessment.low_confidence:
completeness_warnings.append("negative_answer_despite_available_context")
confidence = 0.55
if assessment.hit_count:
confidence += 0.15
if assessment.exact_norm_hit_count and analysis.norm_references:
confidence += 0.15
if assessment.definition_hit_count and analysis.intent == "definition":
confidence += 0.15
if citation_warnings:
confidence -= 0.15
if completeness_warnings:
confidence -= 0.15
if negative and assessment.low_confidence:
confidence -= 0.10
confidence = round(max(0.0, min(confidence, 0.98)), 2)
recommended_action = "accept"
if analysis.needs_clarification:
recommended_action = "clarify"
elif negative and assessment.low_confidence:
recommended_action = "recheck"
elif citation_warnings or completeness_warnings:
recommended_action = "caution"
refs_by_no = self._source_number_to_ref(sources, hits)
cited_refs = [refs_by_no[n] for n in cited if n in refs_by_no]
return AnswerAudit(
cited_source_numbers=cited,
invalid_source_numbers=invalid,
cited_canonical_refs=cited_refs,
answer_basis=basis,
negative_answer_detected=negative,
completeness_warnings=completeness_warnings,
citation_warnings=citation_warnings,
recommended_action=recommended_action,
confidence=confidence,
)
def _maybe_recompose_after_negative_answer(
self,
question: str,
answer: str,
hits: List[Dict[str, Any]],
sources: List[Dict[str, Any]],
analysis: QuestionAnalysis,
assessment: RetrievalAssessment,
memory: Optional[ConversationMemory],
options: OrchestratorOptions,
) -> Tuple[str, str, List[Dict[str, Any]], List[Dict[str, Any]], RetrievalAssessment]:
if not options.enable_negative_recheck or not NEGATIVE_ANSWER_RE.search(answer or ""):
return answer, "document", sources, hits, assessment
old_count = len(hits)
hits2, assessment2 = self._negative_recheck(question, analysis, hits, assessment, options)
if len(hits2) <= old_count:
return answer, "document", sources, hits, assessment2
answer2, answer_type2, sources2 = self._compose(question, hits2, memory)
return answer2, answer_type2, sources2, hits2, assessment2
# ------------------------------------------------------------------
# Public flow
# ------------------------------------------------------------------
def run(
self,
question: str,
*,
memory: Optional[ConversationMemory] = None,
options: OrchestratorOptions | None = None,
) -> OrchestratorResult:
options = options or self.options
question = _normalize(question)
analysis = self.analyze_question(question)
if analysis.intent == "meta":
answer, answer_type = self.composer.compose(question, [], memory=memory)
return OrchestratorResult(
answer=answer,
answer_type=answer_type,
analysis=analysis,
retrieval_assessment=RetrievalAssessment(),
answer_audit=AnswerAudit(answer_basis="meta", confidence=1.0),
)
if analysis.needs_clarification:
answer = analysis.clarification_question or "Bitte präzisiere deine Frage."
return OrchestratorResult(
answer=answer,
answer_type="clarification",
analysis=analysis,
retrieval_assessment=RetrievalAssessment(low_confidence=True, reasons=["clarification_required"]),
answer_audit=AnswerAudit(answer_basis="clarification", recommended_action="clarify", confidence=0.4),
needs_clarification=True,
clarification_question=answer,
)
hits = self._retriever_query(question, analysis, options)
hits = _dedupe_hits(hits)
assessment = self.assess_retrieval(hits, analysis)
hits, assessment = self._negative_recheck(question, analysis, hits, assessment, options)
if not hits:
answer = (
"Ich habe im verfügbaren Vertragskorpus keine belastbare Textstelle gefunden. "
"Das bedeutet nicht zwingend, dass der Sachverhalt rechtlich nicht geregelt ist; "
"es heißt zunächst nur, dass die relevante Regelung im aktuellen Retrieval-Kontext "
"nicht auffindbar war."
)
audit = AnswerAudit(
answer_basis="insufficient",
negative_answer_detected=True,
recommended_action="caution",
confidence=0.25,
completeness_warnings=["no_retrieval_hits_after_recheck"],
)
return OrchestratorResult(
answer=answer,
answer_type="none",
hits=[],
raw_sources=[],
sources=[],
analysis=analysis,
retrieval_assessment=assessment,
answer_audit=audit,
)
answer, answer_type, sources = self._compose(question, hits, memory)
answer, answer_type, sources, hits, assessment = self._maybe_recompose_after_negative_answer(
question, answer, hits, sources, analysis, assessment, memory, options
)
# Keep source visibility and answer postprocessing inside the same
# numbering universe as the Composer. This prevents the API layer from
# deleting markers such as [Quelle 3] while the answer still contains
# dangling grammar fragments.
sources = self._ensure_cited_sources_visible(answer, sources, hits)
refs_by_no = self._source_number_to_ref(sources, hits)
if options.enrich_citations_with_canonical_refs:
answer = self._enrich_answer_citations(answer, refs_by_no)
sources = self._ensure_cited_sources_visible(answer, sources, hits)
answer, auto_fixes = self._postprocess_answer(answer, sources, hits)
sources = self._ensure_cited_sources_visible(answer, sources, hits)
sources = self._filter_sources_to_cited_sources(
answer,
sources,
hits,
max_sources=options.max_sources,
)
audit = self.audit_answer(question, answer, hits, sources, analysis, assessment) if options.enable_answer_audit else AnswerAudit()
audit.auto_fixes.extend(auto_fixes)
debug: Dict[str, Any] = {}
if options.debug:
debug = {
"source_number_to_canonical_ref": refs_by_no,
"retrieved_hit_count": len(hits),
"source_count": len(sources),
"answer_auto_fixes": auto_fixes,
"returned_source_numbers": [
self._source_numbers_from_source(source, fallback=idx)
for idx, source in enumerate(sources, start=1)
],
"cited_source_numbers_after_postprocess": self._extract_source_numbers(answer),
}
return OrchestratorResult(
answer=answer,
answer_type=answer_type,
hits=hits,
raw_sources=sources,
sources=sources,
analysis=analysis,
retrieval_assessment=assessment,
answer_audit=audit,
needs_clarification=False,
debug=debug,
)