| from __future__ import annotations |
|
|
| import re |
| from typing import Any, Dict, List, Optional, Sequence, Tuple, Set |
|
|
| import norm_rang |
| from answer_schema import SHORT_ANSWER_HEADING, is_denial, split_sections |
| from llm_client_groq import ConversationMemory, is_meta_question |
|
|
|
|
| |
| |
| |
| _ORDINAL_MAP: Dict[str, int] = { |
| "erste": 1, |
| "zweite": 2, |
| "dritte": 3, |
| "vierte": 4, |
| "fünfte": 5, |
| "sechste": 6, |
| "siebte": 7, |
| "achte": 8, |
| "neunte": 9, |
| "zehnte": 10, |
| "elfte": 11, |
| "zwölfte": 12, |
| "letzte": -1, |
| "vorletzte": -2, |
| "vorherige": -1, |
| } |
|
|
| _ANSWER_REQUEST_PATTERNS = [ |
| re.compile(r"\bantwort\s+(auf|zu)\b", re.I), |
| re.compile(r"\bwie\s+(hast\s+du|lautete\s+die\s+antwort)\b", re.I), |
| re.compile(r"\bwas\s+hast\s+du\s+geantwortet\b", re.I), |
| re.compile(r"\bwas\s+war\s+deine\s+antwort\b", re.I), |
| re.compile(r"\bgib\s+mir\s+die\s+antwort\b", re.I), |
| ] |
|
|
| _SOURCE_MARKER_RE = re.compile(r"\[Quelle\s+(\d+)\]", re.I) |
| _SOURCE_MARKER_MULTI_RE = re.compile(r"\[Quellen\s+([\d,\s]+)\]", re.I) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _CITATION_ATOM_RE = re.compile( |
| r"\[Quellen?\s+(?P<numbers>\d+(?:\s*,\s*\d+)*)\]" |
| r"(?P<tail>\s*\(§[^)]{1,120}\))?", |
| re.I, |
| ) |
| _MARKER_NUMBER_RE = re.compile(r"\d+") |
|
|
| |
| |
| |
| |
| _CITATION_CLEANUP: Tuple[Tuple[re.Pattern[str], str], ...] = ( |
| |
| (re.compile(r"\(\s*\)"), ""), |
| |
| (re.compile(r"(?:\s*[,;])+\s*(?=[.;:!?])"), ""), |
| |
| (re.compile(r"(?:\s*,){2,}"), ","), |
| |
| (re.compile(r",(\s*\b(?:und|oder)\b\s)", re.I), r"\1"), |
| |
| |
| |
| (re.compile(r"(?<=\w)\s+\b(?:und|oder)\b\s+(?=\[Quelle)", re.I), " "), |
| |
| (re.compile(r"\s+\b(?:und|oder)\b\s*(?=[.;:!?])", re.I), ""), |
| (re.compile(r"\s+([,.;:!?])"), r"\1"), |
| (re.compile(r"[ \t]{2,}"), " "), |
| ) |
| _GRANULAR_BUCHSTABE_REF_RE = re.compile( |
| r"(§\s*\d{1,3}[a-z]?\s+Abs\.?\s*\d+[a-z]?)\s+" |
| r"(?:Buchst\.?|Buchstabe)\s*([a-z])", |
| re.I, |
| ) |
| _DUPLICATE_PAREN_BASE_REF_RE = re.compile( |
| r"\((§\s*\d{1,3}[a-z]?\s+Abs\.?\s*\d+[a-z]?)(?:\s+(?:Buchst\.?|Buchstabe)\s*[a-z])\),\s*\1", |
| re.I, |
| ) |
| _DUPLICATE_BASE_REF_RE = re.compile( |
| r"(§\s*\d{1,3}[a-z]?\s+Abs\.?\s*\d+[a-z]?)(?:\s+(?:Buchst\.?|Buchstabe)\s*[a-z]),\s*\1", |
| re.I, |
| ) |
| _SECTION_REF_RE = re.compile(r"§\s*(\d{1,3}[a-z]?)(?!\d)", re.I) |
| _TOKEN_RE = re.compile(r"[a-zäöüß0-9]{3,}", re.I) |
| _STOPWORDS = { |
| "und", "oder", "der", "die", "das", "den", "dem", "des", "ein", "eine", |
| "einer", "einem", "einen", "für", "mit", "nach", "aus", "bei", "von", |
| "zur", "zum", "ist", "sind", "wird", "werden", "wann", "was", "wie", |
| "welche", "welcher", "welches", "apotheke", "rahmenvertrag", "vertrag", |
| } |
|
|
| _DOCUMENT_TASK_PROMPT = """ |
| Du bist ein juristischer Assistent. |
| |
| Beantworte ausschließlich anhand der bereitgestellten Textstellen. |
| |
| ========================= |
| EVIDENCE FIRST |
| ========================= |
| |
| Jede einzelne Aussage muss unmittelbar auf den bereitgestellten Quellen beruhen. |
| |
| Verwende niemals eigenes Wissen. |
| |
| Erfinde niemals |
| |
| - Paragraphen |
| - Fundstellen |
| - Definitionen |
| - Rechtsfolgen |
| - Ausnahmen |
| - Beispiele |
| - Vermutungen. |
| |
| Wenn sich eine Aussage nicht unmittelbar aus den Quellen ergibt, |
| darf sie nicht ausgegeben werden. |
| |
| ========================= |
| LEGALDEFINITIONEN |
| ========================= |
| |
| Wenn nach der Bedeutung eines Begriffes gefragt wird: |
| |
| 1. Suche zuerst nach einer Legaldefinition. |
| |
| 2. Gib diese wortlautnah wieder. |
| |
| 3. Erst danach darfst du eine kurze Einordnung geben. |
| |
| Eigene Definitionen sind unzulässig. |
| |
| ========================= |
| AUFZÄHLUNGEN |
| ========================= |
| |
| Wenn eine Norm |
| |
| - Voraussetzungen |
| - Tatbestandsmerkmale |
| - Fallgruppen |
| - Alternativen |
| - Kataloge |
| |
| enthält, |
| |
| müssen sämtliche Punkte vollständig wiedergegeben werden. |
| |
| Verwende niemals |
| |
| - unter anderem |
| - beispielsweise |
| - insbesondere |
| |
| wenn die Norm abschließend formuliert ist. |
| |
| ========================= |
| NICHT GEREGELT |
| ========================= |
| |
| Schreibe |
| |
| "nicht geregelt" |
| |
| nur wenn |
| |
| - keine einschlägige Quelle vorhanden ist |
| |
| UND |
| |
| - keine Legaldefinition vorhanden ist |
| |
| UND |
| |
| - keine andere bereitgestellte Norm die Frage beantwortet. |
| |
| ========================= |
| VORHANDENE PARAGRAPHEN NIEMALS LEUGNEN |
| ========================= |
| |
| Wenn eine bereitgestellte Textstelle den gefragten Paragraphen enthält |
| (erkennbar am Feld "Abschnitt:" oder "Norm:" der Quelle, z. B. § 23), |
| dann IST dieser Paragraph vorhanden. |
| |
| Sage in diesem Fall NIEMALS, der Paragraph sei |
| - "nicht vorhanden" |
| - "nicht enthalten" |
| - "nicht in den bereitgestellten Quellen" |
| - "nicht geregelt" |
| |
| und schreibe NIEMALS "es gibt keine Regelung/Aussage/Information zu diesem |
| Paragraphen". |
| |
| Gib stattdessen wieder, was die Textstelle tatsächlich sagt — auch wenn sie kurz |
| ist oder nur aus einer Verweisung besteht. |
| |
| ========================= |
| VERWEISUNGEN AUF EXTERNE NORMEN |
| ========================= |
| |
| Manche Paragraphen regeln einen Sachverhalt nicht selbst, sondern verweisen auf |
| eine externe Norm (z. B. "Der Apothekenabschlag richtet sich nach § 130 SGB V"). |
| |
| Wenn die bereitgestellte Textstelle nur auf eine externe Norm verweist: |
| |
| - Gib den Paragraphen und seine Verweisung wieder, mit dem Quellenmarker des |
| Paragraphen (z. B. "§ 23 bestimmt, dass sich der Apothekenabschlag nach |
| § 130 SGB V richtet [Quelle n]"). |
| - Weise darauf hin, dass die Einzelheiten in der verwiesenen Norm geregelt sind |
| und diese Norm nicht Teil der bereitgestellten Texte ist. |
| |
| Das ist eine gültige, vollständige Antwort und NICHT "nicht geregelt". |
| |
| ========================= |
| NORMRANG |
| ========================= |
| |
| Jede Quelle nennt im Feld "Rang", was sie ist: Gesetz, Richtlinie, Vertrag |
| oder Anlage. Bei Vertrag und Richtlinie steht dort zusätzlich die Vorschrift, |
| auf der das Regelwerk beruht. |
| |
| Maßgeblich ist die Vorschrift, die die Frage entscheidet — nicht die, die der |
| Frage am ähnlichsten klingt. |
| |
| Wenn der Kontext eine ausführende Regelung (Vertrag, Richtlinie, Anlage) UND |
| die Norm enthält, auf der sie beruht: |
| |
| - Nenne beide unter "Maßgebliche Norm". |
| - Nenne zuerst die Norm, aus der sich die Rechtsfolge ergibt, danach die |
| Regelung, die sie ausführt. |
| |
| Nenne eine Anlage nicht als alleinige maßgebliche Norm, wenn die Vorschrift, |
| die auf sie verweist, im Kontext steht. |
| |
| Erschließe den Rang niemals aus dem Namen eines Dokuments, sondern |
| ausschließlich aus dem Feld "Rang". |
| |
| ========================= |
| QUELLEN |
| ========================= |
| |
| Verwende ausschließlich vorhandene Marker. |
| |
| Jede tragende Aussage erhält mindestens einen Quellenmarker. |
| |
| Erfinde niemals Quellenmarker. |
| |
| Erfinde niemals Paragraphen. |
| |
| ========================= |
| ANTWORTSCHEMA |
| ========================= |
| |
| Gliedere die Antwort mit genau diesen Abschnitts-Überschriften. |
| |
| Jede Überschrift steht auf einer eigenen Zeile und endet mit einem Doppelpunkt. |
| Der zugehörige Inhalt folgt in der nächsten Zeile. |
| Zwischen den Abschnitten steht eine Leerzeile. |
| |
| Verwende reinen Text, KEIN Markdown: |
| kein #, kein *, kein Fettdruck, keine Nummerierung der Überschriften. |
| |
| Kurzantwort: |
| - Das Ergebnis in 1–3 Sätzen, mit Quellenmarker. |
| |
| Maßgebliche Norm: |
| - Die einschlägige(n) Vorschrift(en), z. B. § 9 Abs. 3, mit Quellenmarker. |
| |
| Wortlaut / Kriterien: |
| - Den maßgeblichen Normtext wortlautnah wiedergeben. |
| - Aufzählungen (Buchstaben a–z, Nummern, Absätze) VOLLSTÄNDIG wiedergeben. |
| |
| Einordnung: |
| - Nur, wenn zum Verständnis erforderlich und durch die Quellen gedeckt. |
| - Andernfalls diesen Abschnitt inklusive Überschrift vollständig weglassen. |
| |
| Regeln zum Schema: |
| |
| - Abschnitte ohne Evidenz vollständig weglassen (inklusive Überschrift). |
| - Keine Ergebnisformel, kein Fazit, keine Zusammenfassung am Ende. |
| - Keine hypothetischen Ausnahmen. |
| - Keine freien Schlussfolgerungen. |
| |
| ========================= |
| STIL |
| ========================= |
| |
| Präzise. |
| |
| Juristisch belastbar. |
| |
| Keine Wiederholungen. |
| |
| Keine Quellenliste am Ende. |
| """.strip() |
|
|
|
|
| def _format_source_marker(numbers: Sequence[int]) -> str: |
| """The one place that decides how a citation marker is written. |
| |
| The stripper parses these markers back out, so builder and parser have to |
| agree on the format — three separate copies of the f-string were one edit |
| away from drifting apart. |
| """ |
| ordered = list(numbers) |
| if not ordered: |
| return "" |
| if len(ordered) == 1: |
| return f"[Quelle {ordered[0]}]" |
| return "[Quellen " + ", ".join(str(n) for n in ordered) + "]" |
|
|
|
|
| def _normalize(text: str) -> str: |
| return ( |
| " ".join((text or "").lower().strip().split()) |
| .replace("?", "") |
| .replace("!", "") |
| .replace(".", "") |
| .replace(":", "") |
| ) |
|
|
|
|
| class AnswerComposer: |
| """ |
| Komponiert Antworten auf Fach- und Meta-Fragen. |
| |
| Verbesserungen gegenüber der einfachen Version: |
| - RAG-Kontext wird kuratiert, dedupliziert und begrenzt. |
| - Quellen werden getrennt vom Kontext aufgebaut. |
| - Reine Neighbor-Treffer werden nicht als Hauptquellen angezeigt. |
| - Treffer aus falschen/unerwünschten Containern können gefiltert werden. |
| - Die LLM-Anweisung zwingt zu Quellenmarkern [Quelle n] statt freier Quellenfantasie. |
| - Optional kann die finale Quellenliste direkt an die Antwort angehängt werden. |
| |
| Erwartete hit-Felder: |
| - container oder container_id |
| - section oder section_id |
| - page_range oder page_start/page_end |
| - text oder document |
| Optional: |
| - retrieval_kinds |
| - score |
| - rank_score |
| - chunk_index |
| - metadata.text_hash |
| """ |
|
|
| def __init__( |
| self, |
| llm_client: Any, |
| *, |
| max_context_chars: int = 12000, |
| pass_memory_to_llm_for_documents: bool = False, |
| allowed_container_ids: Optional[List[str]] = None, |
| max_hits_for_context: int = 10, |
| max_chunks_per_section: int = 4, |
| max_sources: int = 5, |
| min_score_for_context: Optional[float] = None, |
| include_neighbor_hits_in_context: bool = True, |
| include_pure_neighbors_as_sources: bool = False, |
| append_sources_to_answer: bool = False, |
| display_all_context_sources: bool = True, |
| validate_source_markers: bool = True, |
| prefer_direct_hits_over_neighbors: bool = True, |
| ): |
| self.llm = llm_client |
| self.max_context_chars = max_context_chars |
| self.pass_memory_to_llm_for_documents = pass_memory_to_llm_for_documents |
| self.allowed_container_ids = set(allowed_container_ids or []) |
| self.max_hits_for_context = max_hits_for_context |
| self.max_chunks_per_section = max_chunks_per_section |
| self.max_sources = max_sources |
| self.min_score_for_context = min_score_for_context |
| self.include_neighbor_hits_in_context = include_neighbor_hits_in_context |
| self.include_pure_neighbors_as_sources = include_pure_neighbors_as_sources |
| self.append_sources_to_answer = append_sources_to_answer |
| self.display_all_context_sources = display_all_context_sources |
| self.validate_source_markers = validate_source_markers |
| self.prefer_direct_hits_over_neighbors = prefer_direct_hits_over_neighbors |
|
|
| |
| |
| |
|
|
| def _is_meta_question(self, question: str) -> bool: |
| return is_meta_question(question) |
|
|
| def _is_answer_request(self, question: str) -> bool: |
| q = _normalize(question) |
| return any(pattern.search(q) for pattern in _ANSWER_REQUEST_PATTERNS) |
|
|
| def _extract_meta_target(self, question: str) -> Optional[int]: |
| q = _normalize(question) |
|
|
| m = re.search(r"\b(\d+)\s*(frage|fachfrage|antwort)?\b", q) |
| if m: |
| return int(m.group(1)) |
|
|
| for word, idx in _ORDINAL_MAP.items(): |
| if re.search(rf"\b{word}\b", q): |
| return idx |
|
|
| return None |
|
|
| @staticmethod |
| def _resolve_relative_index(target: int, length: int) -> Optional[int]: |
| if target > 0: |
| idx = target - 1 |
| else: |
| idx = length + target |
| return idx if 0 <= idx < length else None |
|
|
| def _answer_meta( |
| self, |
| question: str, |
| memory: Optional[ConversationMemory], |
| ) -> Tuple[str, str]: |
| if memory is None or memory.factual_question_count == 0: |
| return ("Zu dieser Frage liegt noch kein Konversationsverlauf vor.", "none") |
|
|
| factual_qa = memory.get_factual_qa() |
| target = self._extract_meta_target(question) |
|
|
| if target is None: |
| target = -1 |
|
|
| resolved_idx = self._resolve_relative_index(target, len(factual_qa)) |
| if resolved_idx is None: |
| if target > 0: |
| msg = f"Der bisherige Gesprächsverlauf enthält keine {target}. Fachfrage." |
| else: |
| msg = "Der bisherige Gesprächsverlauf enthält dafür nicht genug Fachfragen." |
| return (msg, "none") |
|
|
| entry = factual_qa[resolved_idx] |
| idx_display = resolved_idx + 1 |
|
|
| if self._is_answer_request(question): |
| return ( |
| f"Die Antwort auf deine {idx_display}. Fachfrage lautete:\n\n" |
| f"{entry.get('answer', '')}", |
| "meta", |
| ) |
|
|
| return ( |
| f"Deine {idx_display}. Fachfrage war: \"{entry.get('question', '')}\"", |
| "meta", |
| ) |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def _hit_value(hit: Dict[str, Any], *keys: str, default: Any = "") -> Any: |
| for key in keys: |
| value = hit.get(key) |
| if value is not None: |
| return value |
| metadata = hit.get("metadata") or {} |
| for key in keys: |
| value = metadata.get(key) |
| if value is not None: |
| return value |
| return default |
|
|
| @classmethod |
| def _page_range(cls, hit: Dict[str, Any]) -> str: |
| direct = hit.get("page_range") |
| if direct: |
| return str(direct) |
|
|
| start = cls._hit_value(hit, "page_start", default=None) |
| end = cls._hit_value(hit, "page_end", default=start) |
| if start is not None and end is not None: |
| return f"{start}–{end}" |
| if start is not None: |
| return str(start) |
| return "?" |
|
|
| @staticmethod |
| def _int_or_none(value: Any) -> Optional[int]: |
| try: |
| return int(value) |
| except (TypeError, ValueError): |
| return None |
|
|
| @classmethod |
| def _page_bounds(cls, hit: Dict[str, Any]) -> Tuple[Optional[int], Optional[int]]: |
| start = cls._int_or_none(cls._hit_value(hit, "page_start", default=None)) |
| end = cls._int_or_none(cls._hit_value(hit, "page_end", default=None)) |
| if end is None: |
| end = start |
| return start, end |
|
|
| @classmethod |
| def _highlight_text(cls, hit: Dict[str, Any], *, max_chars: int = 2000) -> str: |
| """Chunk-Text für die PDF-Fundstellen-Hervorhebung. |
| |
| Die Ingest-Pipeline stellt jedem Chunk eine konstruierte Kontextzeile |
| wie "Vertrag · § 6 Titel" voran, die so nicht im PDF steht. Sie wird |
| entfernt, damit der Viewer nur echten Dokumenttext matchen muss. |
| """ |
| text = cls._text(hit) |
| if not text: |
| return "" |
| head, sep, rest = text.partition("\n\n") |
| if sep and " · " in head and len(head) <= 200 and rest.strip(): |
| text = rest.strip() |
| return text[:max_chars] |
|
|
| @classmethod |
| def _container(cls, hit: Dict[str, Any]) -> str: |
| return str(cls._hit_value(hit, "container", "container_id", default="Unbekannt")) |
|
|
| @classmethod |
| def _section(cls, hit: Dict[str, Any]) -> str: |
| return str(cls._hit_value(hit, "section", "section_id", default="ohne Abschnitt")) |
|
|
| @classmethod |
| def _doc_id(cls, hit: Dict[str, Any]) -> str: |
| return str(cls._hit_value(hit, "doc_id", default="") or "") |
|
|
| @classmethod |
| def _doc_title(cls, hit: Dict[str, Any]) -> str: |
| """Kurzer Dokumentname für Zitate über mehrere Korpora hinweg. |
| |
| Ohne diese Angabe ist eine Quelle mehrdeutig: Rahmenvertrag und SGB V |
| teilen sich 34 §-Nummern mit völlig verschiedenem Inhalt (§ 16 ist einmal |
| "Teilmenge, Auseinzelung", einmal "Ruhen des Anspruchs"). Leerer String |
| bei alten Collections ohne doc_title -> Anzeige fällt auf das bisherige |
| Format zurück. |
| """ |
| title = str(cls._hit_value(hit, "doc_title", default="") or "").strip() |
| return title or cls._doc_id(hit) |
|
|
| @classmethod |
| def _chunk_index(cls, hit: Dict[str, Any]) -> Any: |
| return cls._hit_value(hit, "chunk_index", "chunk_index_in_section", default="?") |
|
|
| @classmethod |
| def _canonical_ref(cls, hit: Dict[str, Any]) -> str: |
| direct = cls._hit_value(hit, "canonical_ref", default="") |
| if direct: |
| return str(direct) |
|
|
| metadata = hit.get("metadata") or {} |
| paragraph = metadata.get("paragraph") or hit.get("paragraph") or cls._section(hit) |
| subsection = metadata.get("subsection") or hit.get("subsection") |
| sentence = metadata.get("sentence") or hit.get("sentence") |
| number = metadata.get("number") or hit.get("number") |
| letter = metadata.get("letter") or hit.get("letter") |
|
|
| parts: List[str] = [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) |
|
|
| @classmethod |
| def _text(cls, hit: Dict[str, Any]) -> str: |
| return str(hit.get("text") or hit.get("document") or "").strip() |
|
|
| @classmethod |
| def _retrieval_kinds(cls, 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] |
|
|
| @classmethod |
| def _score(cls, 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 |
|
|
| @classmethod |
| def _source_number(cls, hit: Dict[str, Any]) -> Optional[int]: |
| value = cls._hit_value(hit, "source_number", default=None) |
| try: |
| return int(value) if value is not None else None |
| except (TypeError, ValueError): |
| return None |
|
|
| @classmethod |
| def _is_pure_neighbor(cls, hit: Dict[str, Any]) -> bool: |
| kinds = set(cls._retrieval_kinds(hit)) |
| return bool(kinds) and kinds == {"neighbor"} |
|
|
| @classmethod |
| def _is_direct_evidence(cls, hit: Dict[str, Any]) -> bool: |
| kinds = set(cls._retrieval_kinds(hit)) |
| if not kinds: |
| return True |
| return not cls._is_pure_neighbor(hit) |
|
|
| @staticmethod |
| def _query_tokens(question: str) -> Set[str]: |
| tokens = {t.lower() for t in _TOKEN_RE.findall(question or "")} |
| return {t for t in tokens if t not in _STOPWORDS and not t.isdigit()} |
|
|
| @staticmethod |
| def _section_refs_from_question(question: str) -> Set[str]: |
| return {f"§ {m.group(1)}" for m in _SECTION_REF_RE.finditer(question or "")} |
|
|
| @classmethod |
| def _lexical_overlap(cls, question: str, hit: Dict[str, Any]) -> int: |
| query_terms = cls._query_tokens(question) |
| if not query_terms: |
| return 0 |
| haystack = " ".join( |
| [ |
| cls._canonical_ref(hit), |
| cls._section(hit), |
| cls._text(hit)[:2500], |
| ] |
| ).lower() |
| return sum(1 for term in query_terms if term in haystack) |
|
|
| @classmethod |
| def _specificity_score(cls, hit: Dict[str, Any]) -> int: |
| ref = cls._canonical_ref(hit).lower() |
| score = 0 |
| if "abs." in ref or "absatz" in ref: |
| score += 2 |
| if "satz" in ref: |
| score += 1 |
| if "nr." in ref or "buchst." in ref: |
| score += 1 |
| if cls._hit_value(hit, "unit_type", default="") == "definition": |
| score += 1 |
| if cls._hit_value(hit, "chunk_kind", default="") == "parent": |
| score += 1 |
| return score |
|
|
| def _hit_relevance_key(self, hit: Dict[str, Any], question: str = "") -> Tuple[Any, ...]: |
| kinds = set(self._retrieval_kinds(hit)) |
| q_sections = self._section_refs_from_question(question) |
| section = self._section(hit) |
| canonical = self._canonical_ref(hit) |
|
|
| explicit = int(bool({"explicit_section", "section_lookup", "exact_reference", "definition_lookup"} & kinds)) |
| direct = int(self._is_direct_evidence(hit)) if self.prefer_direct_hits_over_neighbors else 0 |
| section_match = int(bool(q_sections and (section in q_sections or any(ref in canonical for ref in q_sections)))) |
| definition = int(self._hit_value(hit, "unit_type", default="") == "definition") |
| specificity = self._specificity_score(hit) |
| overlap = self._lexical_overlap(question, hit) |
| pure_neighbor_penalty = -1 if self._is_pure_neighbor(hit) else 0 |
| chunk_idx = self._chunk_index(hit) |
| try: |
| chunk_sort = -int(chunk_idx) |
| except (TypeError, ValueError): |
| chunk_sort = 0 |
|
|
| return ( |
| definition * 100, |
| explicit * 50, |
| section_match * 20, |
| direct * 10, |
| specificity * 5, |
| overlap, |
| self._score(hit), |
| pure_neighbor_penalty, |
| chunk_sort, |
| ) |
|
|
| @classmethod |
| def _source_key(cls, hit: Dict[str, Any]) -> Tuple[Any, ...]: |
| metadata = hit.get("metadata") or {} |
| text_hash = metadata.get("text_hash") or hit.get("text_hash") |
| if text_hash: |
| return ("hash", text_hash) |
|
|
| return ( |
| cls._doc_id(hit), |
| cls._container(hit), |
| cls._section(hit), |
| cls._page_range(hit), |
| cls._chunk_index(hit), |
| ) |
|
|
| @classmethod |
| def _source_display_key(cls, hit: Dict[str, Any]) -> Tuple[Any, ...]: |
| |
| |
| return ( |
| cls._doc_id(hit), |
| cls._container(hit), |
| cls._section(hit), |
| cls._page_range(hit), |
| ) |
|
|
| def _is_allowed_container(self, hit: Dict[str, Any]) -> bool: |
| if not self.allowed_container_ids: |
| return True |
| return self._container(hit) in self.allowed_container_ids |
|
|
| def _should_keep_for_context(self, hit: Dict[str, Any]) -> bool: |
| if not self._is_allowed_container(hit): |
| return False |
|
|
| text = self._text(hit) |
| if not text: |
| return False |
|
|
| kinds = set(self._retrieval_kinds(hit)) |
| if kinds == {"neighbor"} and not self.include_neighbor_hits_in_context: |
| return False |
|
|
| if self.min_score_for_context is not None: |
| |
| if not ({"explicit_section", "section_lookup"} & kinds): |
| if self._score(hit) < self.min_score_for_context: |
| return False |
|
|
| return True |
|
|
| def _prepare_hits_for_context( |
| self, |
| hits: List[Dict[str, Any]], |
| question: str = "", |
| ) -> List[Dict[str, Any]]: |
| """ |
| Filtert, priorisiert und begrenzt Treffer für den LLM-Kontext. |
| |
| Priorität: |
| |
| 1. Legaldefinitionen |
| 2. explizite Paragraphentreffer |
| 3. direkte Evidenz |
| 4. Parent-Chunks |
| 5. hohe Textübereinstimmung |
| 6. Retrieval-Score |
| """ |
|
|
| if not hits: |
| return [] |
|
|
| filtered = [ |
| hit |
| for hit in hits |
| if self._should_keep_for_context(hit) |
| ] |
|
|
| |
| |
| |
|
|
| deduped = {} |
|
|
| for hit in filtered: |
|
|
| key = self._source_key(hit) |
|
|
| if ( |
| key not in deduped |
| or self._score(hit) > self._score(deduped[key]) |
| ): |
| deduped[key] = hit |
|
|
| prepared = list(deduped.values()) |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| q_sections = self._section_refs_from_question(question) |
|
|
| def ranking(hit): |
|
|
| retrieval = set(self._retrieval_kinds(hit)) |
|
|
| return ( |
|
|
| |
| |
| |
| |
|
|
| bool(q_sections and self._section(hit) in q_sections), |
|
|
| |
| |
| |
|
|
| self._hit_value(hit, "unit_type", default="") == "definition", |
|
|
| |
| |
| |
|
|
| bool( |
| { |
| "explicit_section", |
| "section_lookup", |
| "definition_lookup", |
| "exact_reference", |
| } |
| & retrieval |
| ), |
|
|
| |
| |
| |
|
|
| self._is_direct_evidence(hit), |
|
|
| |
| |
| |
|
|
| self._hit_value( |
| hit, |
| "chunk_kind", |
| default="", |
| ) |
| == "parent", |
|
|
| |
| |
| |
|
|
| self._specificity_score(hit), |
|
|
| |
| |
| |
|
|
| self._lexical_overlap( |
| question, |
| hit, |
| ), |
|
|
| |
| |
| |
|
|
| self._score(hit), |
|
|
| |
| |
| |
|
|
| not self._is_pure_neighbor(hit), |
|
|
| ) |
|
|
| prepared.sort( |
| key=ranking, |
| reverse=True, |
| ) |
|
|
| |
| |
| |
|
|
| section_limited = [] |
|
|
| per_section_count = {} |
|
|
| for hit in prepared: |
|
|
| key = ( |
| self._container(hit), |
| self._section(hit), |
| ) |
|
|
| if ( |
| per_section_count.get(key, 0) |
| >= self.max_chunks_per_section |
| ): |
| continue |
|
|
| per_section_count[key] = ( |
| per_section_count.get(key, 0) |
| + 1 |
| ) |
|
|
| section_limited.append(hit) |
|
|
| |
| |
| |
| |
|
|
| definitions = [ |
| h |
| for h in prepared |
| if self._hit_value( |
| h, |
| "unit_type", |
| default="", |
| ) |
| == "definition" |
| ] |
|
|
| if definitions: |
|
|
| first_definition = definitions[0] |
|
|
| if first_definition not in section_limited: |
|
|
| section_limited.insert( |
| 0, |
| first_definition, |
| ) |
|
|
| |
| |
| |
|
|
| return section_limited[: self.max_hits_for_context] |
|
|
| @classmethod |
| def build_sources( |
| cls, |
| hits: List[Dict[str, Any]], |
| *, |
| max_sources: int = 5, |
| include_pure_neighbors: bool = False, |
| allowed_container_ids: Optional[List[str]] = None, |
| referenced_source_numbers: Optional[Set[int]] = None, |
| ) -> List[Dict[str, Any]]: |
| """ |
| Baut eine kuratierte, nummern-stabile Quellenliste für die Anzeige. |
| |
| Wichtig: |
| - Quellenmarker aus dem RAG-Kontext bleiben stabil erhalten. |
| - Mehrere Chunks derselben Section/Seiten werden gruppiert, aber die |
| zugehörigen [Quelle n]-Nummern bleiben sichtbar. |
| - Reine Neighbor-Treffer werden standardmäßig nicht als Quellen angezeigt. |
| """ |
| allowed = set(allowed_container_ids or []) |
| grouped: Dict[Tuple[Any, Any, Any], Dict[str, Any]] = {} |
|
|
| def order_key(hit: Dict[str, Any]) -> Tuple[int, float]: |
| number = cls._source_number(hit) |
| if number is not None: |
| return (number, 0.0) |
| return (10_000, -cls._score(hit)) |
|
|
| for hit in sorted(hits, key=order_key): |
| container = cls._container(hit) |
| if allowed and container not in allowed: |
| continue |
|
|
| number = cls._source_number(hit) |
| is_referenced = bool(referenced_source_numbers and number in referenced_source_numbers) |
| if cls._is_pure_neighbor(hit) and not include_pure_neighbors and not is_referenced: |
| continue |
|
|
| text = cls._text(hit) |
| if not text: |
| continue |
|
|
| key = cls._source_display_key(hit) |
| number = cls._source_number(hit) |
| canonical = cls._canonical_ref(hit) |
| kinds = cls._retrieval_kinds(hit) |
|
|
| |
| |
| page_start, page_end = cls._page_bounds(hit) |
| source_file = str(cls._hit_value(hit, "source_file", default="") or "") |
| doc_id = cls._doc_id(hit) |
| doc_title = cls._doc_title(hit) |
| |
| |
| |
| |
| corpus_id = str(cls._hit_value(hit, "corpus_id", default="") or "") |
| highlight = cls._highlight_text(hit) |
| highlight_entry = ( |
| {"page_start": page_start, "page_end": page_end, "text": highlight} |
| if highlight |
| else None |
| ) |
|
|
| item = grouped.get(key) |
| if item is None: |
| grouped[key] = { |
| "source_number": number, |
| "source_numbers": [number] if number is not None else [], |
| "source_marker": f"[Quelle {number}]" if number is not None else "", |
| "source_label": f"[Quelle {number}]" if number is not None else "", |
| "container": container, |
| "section": cls._section(hit), |
| "canonical_ref": canonical, |
| "canonical_refs": [canonical] if canonical else [], |
| "page_range": cls._page_range(hit), |
| "page_start": page_start, |
| "page_end": page_end, |
| "source_file": source_file, |
| "doc_id": doc_id, |
| "doc_title": doc_title, |
| "corpus_id": corpus_id, |
| "highlights": [highlight_entry] if highlight_entry else [], |
| "path": cls._hit_value(hit, "path", "section_path", default=""), |
| "score": round(cls._score(hit), 4), |
| "retrieval_kinds": list(dict.fromkeys(kinds)), |
| "chunk_index": cls._chunk_index(hit), |
| "display_title": "", |
| "display_label": "", |
| } |
| continue |
|
|
| if page_start is not None and (item.get("page_start") is None or page_start < item["page_start"]): |
| item["page_start"] = page_start |
| if page_end is not None and (item.get("page_end") is None or page_end > item["page_end"]): |
| item["page_end"] = page_end |
| if source_file and not item.get("source_file"): |
| item["source_file"] = source_file |
| if doc_id and not item.get("doc_id"): |
| item["doc_id"] = doc_id |
| if doc_title and not item.get("doc_title"): |
| item["doc_title"] = doc_title |
| if highlight_entry is not None: |
| existing_texts = {(h.get("text") or "")[:120] for h in item.get("highlights") or []} |
| if highlight[:120] not in existing_texts: |
| item.setdefault("highlights", []).append(highlight_entry) |
|
|
| if number is not None and number not in item["source_numbers"]: |
| item["source_numbers"].append(number) |
| item["source_numbers"].sort() |
| item["source_number"] = item["source_numbers"][0] |
| item["source_marker"] = _format_source_marker(item["source_numbers"]) |
| item["source_label"] = item["source_marker"] |
|
|
| if canonical and canonical not in item["canonical_refs"]: |
| item["canonical_refs"].append(canonical) |
| |
| item["canonical_ref"] = max(item["canonical_refs"], key=lambda r: ("Abs." in r, "Satz" in r, len(r))) |
|
|
| item["score"] = max(float(item.get("score", 0.0)), round(cls._score(hit), 4)) |
| for kind in kinds: |
| if kind not in item["retrieval_kinds"]: |
| item["retrieval_kinds"].append(kind) |
|
|
| sources = list(grouped.values()) |
|
|
| for source in sources: |
| label = _format_source_marker(source.get("source_numbers") or []) |
| source["source_marker"] = label |
| source["source_label"] = label |
| canonical_refs = source.get("canonical_refs") or [] |
| canonical = "; ".join(canonical_refs[:3]) if canonical_refs else source.get("canonical_ref") |
| if canonical and canonical != source.get("section"): |
| source["display_title"] = f"{label} {source.get('container')}::{source.get('section')} ({canonical}), Seiten {source.get('page_range')}".strip() |
| else: |
| source["display_title"] = f"{label} {source.get('container')}::{source.get('section')}, Seiten {source.get('page_range')}".strip() |
| source["display_label"] = source["display_title"] |
|
|
| sources.sort(key=lambda s: (s.get("source_numbers") or [10_000])[0]) |
|
|
| referenced = set(referenced_source_numbers or set()) |
| if referenced: |
| referenced_sources = [ |
| s for s in sources |
| if referenced.intersection(set(s.get("source_numbers") or [])) |
| ] |
| other_sources = [s for s in sources if s not in referenced_sources] |
| |
| budget = max(int(max_sources), len(referenced_sources)) |
| return (referenced_sources + other_sources[: max(0, budget - len(referenced_sources))]) |
|
|
| return sources[:max_sources] |
|
|
| @staticmethod |
| def format_sources_markdown(sources: List[Dict[str, Any]]) -> str: |
| if not sources: |
| return "" |
|
|
| lines = ["Quellen:"] |
| for source in sources: |
| container = source.get("container", "Unbekannt") |
| section = source.get("section", "ohne Abschnitt") |
| canonical_refs = source.get("canonical_refs") or [] |
| canonical = "; ".join(canonical_refs[:3]) if canonical_refs else source.get("canonical_ref") |
| pages = source.get("page_range", "?") |
| marker = source.get("source_marker") or source.get("source_label") or "" |
| if not marker: |
| marker = _format_source_marker(source.get("source_numbers") or []) or "-" |
|
|
| role = "" |
| kinds = set(source.get("retrieval_kinds") or []) |
| if kinds == {"neighbor"}: |
| role = " · Kontext/Nachbar" |
|
|
| if canonical and canonical != section: |
| lines.append(f"- {marker} {container}::{section} ({canonical}), Seiten {pages}{role}") |
| else: |
| lines.append(f"- {marker} {container}::{section}, Seiten {pages}{role}") |
| return "\n".join(lines) |
|
|
| |
| |
| |
|
|
| @classmethod |
| def _format_source_block(cls, index: int, hit: Dict[str, Any]) -> str: |
| container = cls._container(hit) |
| section = cls._section(hit) |
| page_range = cls._page_range(hit) |
| chunk_index = cls._chunk_index(hit) |
|
|
| score = cls._score(hit) |
| text = cls._text(hit) |
|
|
| canonical_ref = cls._canonical_ref(hit) |
|
|
| role = ( |
| "Kontext/Nachbar" |
| if cls._is_pure_neighbor(hit) |
| else "Hauptquelle" |
| ) |
|
|
| retrieval = ", ".join(cls._retrieval_kinds(hit)) or "retrieved" |
|
|
| unit_type = cls._hit_value(hit, "unit_type", default="") |
|
|
| chunk_kind = cls._hit_value(hit, "chunk_kind", default="") |
|
|
| |
| |
| doc_title = cls._doc_title(hit) |
| doc_line = f"Dokument: {doc_title}\n" if doc_title else "" |
|
|
| |
| |
| |
| |
| rang_wert = norm_rang.rang(hit) |
| rang_line = f"Rang: {rang_wert}\n" if rang_wert else "" |
|
|
| return ( |
| f"[Quelle {index}]\n" |
| f"{doc_line}" |
| f"{rang_line}" |
| f"Container: {container}\n" |
| f"Abschnitt: {section}\n" |
| f"Norm: {canonical_ref}\n" |
| f"Typ: {unit_type or '-'}\n" |
| f"Chunk: {chunk_kind or '-'}\n" |
| f"Rolle: {role}\n" |
| f"Retrieval: {retrieval}\n" |
| f"Seiten: {page_range}\n" |
| f"Chunk-Index: {chunk_index}\n" |
| f"Score: {score:.4f}\n" |
| f"\n" |
| f"Text:\n" |
| f"{text}" |
| ) |
|
|
| def _build_rag_context(self, hits: List[Dict[str, Any]], question: str = "") -> Tuple[str, List[Dict[str, Any]]]: |
| prepared_hits = self._prepare_hits_for_context(hits, question=question) |
| if not prepared_hits: |
| return "", [] |
|
|
| parts: List[str] = [] |
| total = 0 |
|
|
| used_hits: List[Dict[str, Any]] = [] |
| for i, hit in enumerate(prepared_hits, start=1): |
| hit_with_number = dict(hit) |
| hit_with_number["source_number"] = i |
| block = self._format_source_block(i, hit_with_number) |
| if self.max_context_chars and total + len(block) + 2 > self.max_context_chars: |
| break |
| parts.append(block) |
| used_hits.append(hit_with_number) |
| total += len(block) + 2 |
|
|
| return "\n\n".join(parts), used_hits |
|
|
| @staticmethod |
| def _build_document_prompt(question: str) -> str: |
| return f"{_DOCUMENT_TASK_PROMPT}\n\nNutzerfrage:\n{question}" |
|
|
| def _call_llm_for_document_question( |
| self, |
| *, |
| question: str, |
| rag_context: str, |
| memory: Optional[ConversationMemory], |
| ) -> str: |
| prompt = self._build_document_prompt(question) |
| llm_memory = memory if self.pass_memory_to_llm_for_documents else None |
|
|
| try: |
| return self.llm.chat( |
| prompt, |
| memory=llm_memory, |
| rag_context=rag_context, |
| include_history_for_factual=self.pass_memory_to_llm_for_documents, |
| ) |
| except TypeError: |
| return self.llm.chat( |
| prompt, |
| memory=llm_memory, |
| rag_context=rag_context, |
| ) |
|
|
| @staticmethod |
| def _strip_model_generated_sources(answer: str) -> str: |
| """ |
| Entfernt eine vom Modell frei erzeugte Quellenliste am Ende. |
| |
| Die Anwendung kann anschließend die kuratierte Quellenliste aus |
| build_sources() anhängen. Das verhindert lange, unkontrollierte Quellenblöcke. |
| """ |
| text = (answer or "").strip() |
| if not text: |
| return text |
|
|
| |
| |
| pattern = re.compile( |
| r"(?:\n{1,3}|(?<=\.)\s+|^)(Quellen|Fundstellen)\s*:\s*(?:\n|.)*$", |
| flags=re.I, |
| ) |
| return pattern.sub("", text).strip() |
|
|
| @staticmethod |
| def _used_source_numbers(used_hits: List[Dict[str, Any]]) -> Set[int]: |
| numbers: Set[int] = set() |
| for hit in used_hits: |
| value = hit.get("source_number") |
| try: |
| if value is not None: |
| numbers.add(int(value)) |
| except (TypeError, ValueError): |
| continue |
| return numbers |
|
|
| @staticmethod |
| def _legal_ref_norm(text: str) -> str: |
| """Normalize legal references for conservative support checks.""" |
| s = (text or "").lower() |
| s = s.replace("§§", "§") |
| s = re.sub(r"\babsatz\b", "abs", s) |
| s = re.sub(r"\babs\.\b", "abs", s) |
| s = re.sub(r"\bbuchstabe\b", "buchst", s) |
| s = re.sub(r"\bbuchst\.\b", "buchst", s) |
| s = re.sub(r"\s+", " ", s) |
| return s.strip(" .,:;()[]") |
|
|
| @classmethod |
| def _supported_legal_ref_blobs(cls, used_hits: List[Dict[str, Any]]) -> Set[str]: |
| """Build normalized blobs used to decide whether a granular ref is grounded.""" |
| blobs: Set[str] = set() |
| for hit in used_hits: |
| bits = [ |
| cls._canonical_ref(hit), |
| cls._section(hit), |
| str(cls._hit_value(hit, "paragraph", default="")), |
| str(cls._hit_value(hit, "subsection", default="")), |
| str(cls._hit_value(hit, "letter", default="")), |
| cls._text(hit)[:3000], |
| ] |
| blob = cls._legal_ref_norm(" ".join(bits)) |
| if blob: |
| blobs.add(blob) |
| return blobs |
|
|
| @classmethod |
| def _strip_unsupported_granular_refs(cls, answer: str, used_hits: List[Dict[str, Any]]) -> str: |
| """Remove unsupported Buchst.-precision while preserving supported paragraph/Abs. refs. |
| |
| This is intentionally conservative. It does not delete the base norm; it only |
| downgrades e.g. "§ 6 Abs. 1 Buchst. a" to "§ 6 Abs. 1" when that granular |
| letter reference is not present in any canonical ref or source text. |
| """ |
| text = answer or "" |
| if not text: |
| return text |
|
|
| |
| text = _DUPLICATE_PAREN_BASE_REF_RE.sub(r"(\1)", text) |
| text = _DUPLICATE_BASE_REF_RE.sub(r"\1", text) |
| supported_blobs = cls._supported_legal_ref_blobs(used_hits) |
|
|
| def is_supported(full: str, base: str, letter: str) -> bool: |
| full_norm = cls._legal_ref_norm(full) |
| |
| variants = { |
| full_norm, |
| cls._legal_ref_norm(f"{base} Buchst. {letter}"), |
| cls._legal_ref_norm(f"{base} Buchstabe {letter}"), |
| cls._legal_ref_norm(f"{base} {letter})"), |
| } |
| return any(any(v and v in blob for blob in supported_blobs) for v in variants) |
|
|
| def repl(match: re.Match[str]) -> str: |
| full = match.group(0) |
| base = re.sub(r"\s+", " ", match.group(1)).strip() |
| letter = match.group(2).lower() |
| return full if is_supported(full, base, letter) else base |
|
|
| text = _GRANULAR_BUCHSTABE_REF_RE.sub(repl, text) |
| text = re.sub(r"\(\s*(§\s*\d{1,3}[a-z]?\s+Abs\.?\s*\d+[a-z]?)\s*\),\s*\1", r"(\1)", text, flags=re.I) |
| return text |
|
|
| @classmethod |
| def _referenced_source_numbers(cls, answer: str) -> Set[int]: |
| numbers: Set[int] = set() |
| for m in _SOURCE_MARKER_RE.finditer(answer or ""): |
| try: |
| numbers.add(int(m.group(1))) |
| except (TypeError, ValueError): |
| pass |
| for m in _SOURCE_MARKER_MULTI_RE.finditer(answer or ""): |
| for part in re.split(r"[,\s]+", m.group(1)): |
| if not part: |
| continue |
| try: |
| numbers.add(int(part)) |
| except (TypeError, ValueError): |
| pass |
| return numbers |
|
|
| @classmethod |
| def cited_hits(cls, answer: str, hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
| """Die Treffer, auf die sich die Antwort mit ``[Quelle n]`` beruft. |
| |
| Der Trefferpool ist nicht dasselbe wie die herangezogenen Textstellen: |
| das Modell bekommt acht Quellen und zitiert regelmäßig zwei. Wer über |
| die Antwort etwas aussagen will — was sie belegt, worauf sie verweist, |
| wo ihre Grenze liegt —, muss die zitierten meinen und nicht den Pool. |
| |
| Die Nummern sind die Ränge aus dem RAG-Kontext. Der Aufruf gehört |
| deshalb **vor** die Umnummerierung für die Anzeige; danach trägt der |
| Antworttext Anzeigenummern und die Treffer weiter ihre Ränge. |
| |
| Leere Rückgabe heißt „nicht feststellbar" und nicht „keine": eine |
| Antwort ohne Marker lässt keine Aussage darüber zu, und der Aufrufer |
| entscheidet, was das für ihn bedeutet. |
| """ |
| numbers = cls._referenced_source_numbers(answer) |
| if not numbers: |
| return [] |
| return [hit for hit in hits or [] if cls._source_number(hit) in numbers] |
|
|
| @classmethod |
| def _postprocess_document_answer( |
| cls, |
| answer: str, |
| used_hits: List[Dict[str, Any]], |
| *, |
| validate_markers: bool = True, |
| ) -> str: |
| """ |
| Bereitet die LLM-Antwort nach. |
| |
| Schritte: |
| 1. Entfernt frei erzeugte Quellenlisten. |
| 2. Entfernt ungültige Quellenmarker. |
| 3. Entfernt nicht belegte Buchstaben-/Untergliederungsreferenzen. |
| 4. Entfernt typische Halluzinationsformulierungen. |
| 5. Bereinigt Formatierung. |
| """ |
|
|
| text = cls._strip_model_generated_sources(answer) |
|
|
| if validate_markers: |
| text = cls._strip_invalid_source_markers(text, used_hits) |
|
|
| text = cls._strip_unsupported_granular_refs(text, used_hits) |
|
|
| |
| |
| |
|
|
| hallucination_patterns = [ |
|
|
| |
| r"(?im)^Ergebnis:\s*", |
|
|
| |
| r"(?im)^Es bestehen keine Ausnahmen\.?\s*$", |
| r"(?im)^Es sind keine Ausnahmen geregelt\.?\s*$", |
| r"(?im)^Weitere Ausnahmen sind nicht vorgesehen\.?\s*$", |
|
|
| |
| r"(?im)^Im Einzelfall kann.*$", |
| r"(?im)^Die Krankenkasse kann im Einzelfall.*$", |
|
|
| |
| r"(?im)^Zusammenfassend gilt.*$", |
| ] |
|
|
| for pattern in hallucination_patterns: |
| text = re.sub(pattern, "", text) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| text = cls._strip_empty_section_headings(text) |
|
|
| |
| |
| |
|
|
| text = re.sub(r"[ \t]{2,}", " ", text) |
|
|
| text = re.sub(r"\n{3,}", "\n\n", text) |
|
|
| text = text.strip() |
|
|
| return text |
|
|
| @classmethod |
| def _strip_empty_section_headings(cls, text: str) -> str: |
| """Entfernt Abschnitte ohne eigenen Inhalt. |
| |
| "Ohne Inhalt" heißt zweierlei: es folgt gar keine Zeile — oder es folgt |
| nur die Feststellung, dass es nichts gibt. Der Prompt verlangt beides |
| wegzulassen ("Abschnitte ohne Evidenz vollständig weglassen"); das Modell |
| hält sich an die Überschriften und schreibt viermal "Es gibt keine ...". |
| |
| Die Kurzantwort bleibt immer stehen: sie *ist* die Antwort, auch wenn sie |
| negativ ausfällt. Bliebe sonst überhaupt nichts übrig, bleibt der Text |
| unverändert — eine leere Antwort ist schlechter als eine redundante. |
| """ |
| if not text: |
| return text |
|
|
| blocks = split_sections(text) |
| if not any(heading for heading, _ in blocks): |
| return text |
|
|
| parts: List[str] = [] |
| for heading, body_lines in blocks: |
| body = "\n".join(body_lines).strip() |
|
|
| if heading is None: |
| if body: |
| parts.append(body) |
| continue |
|
|
| if not body: |
| continue |
| if heading != SHORT_ANSWER_HEADING and is_denial(body): |
| continue |
|
|
| parts.append(f"{heading}:\n{body}") |
|
|
| if not parts: |
| return text |
| return "\n\n".join(parts) |
|
|
| @classmethod |
| def _strip_invalid_source_markers(cls, answer: str, used_hits: List[Dict[str, Any]]) -> str: |
| """Entfernt Marker wie [Quelle 9], wenn Quelle 9 nicht im Kontext stand. |
| |
| Entfernt wird die ganze Zitat-Einheit: der Marker, eine angehängte |
| Fundstelle wie "(§ 6 Abs. 1)" und die Trennzeichen, die sie mit dem |
| nächsten Zitat verbanden. Blieben die Trennzeichen stehen, las sich eine |
| Verneinung als „… in den bereitgestellten Quellen , , , , ,." — sechs |
| entfernte Marker, deren Kommata den Eindruck von sechs Belegen |
| hinterließen. |
| |
| Sammelmarker werden auf ihren belegten Teil zurückgeführt: aus |
| "[Quellen 3, 8]" wird "[Quelle 3]", wenn nur 3 im Kontext stand. Nur wenn |
| keine einzige Nummer trägt, fällt die ganze Einheit weg. Ein Sammelmarker |
| bündelt mehrere Belege für dieselbe Aussage — der belegte Teil bleibt |
| richtig, auch wenn der Rest erfunden war. |
| """ |
| valid_numbers = cls._used_source_numbers(used_hits) |
|
|
| def repl(match: re.Match[str]) -> str: |
| numbers = [int(n) for n in _MARKER_NUMBER_RE.findall(match.group("numbers"))] |
| kept = [n for n in numbers if n in valid_numbers] |
| if not kept: |
| return "" |
| if kept == numbers: |
| return match.group(0) |
| return f"{_format_source_marker(kept)}{match.group('tail') or ''}" |
|
|
| original = answer or "" |
| cleaned = _CITATION_ATOM_RE.sub(repl, original) |
| if cleaned == original: |
| return cleaned.strip() |
|
|
| |
| |
| for pattern, replacement in _CITATION_CLEANUP: |
| cleaned = pattern.sub(replacement, cleaned) |
| return cleaned.strip() |
|
|
| |
| |
| |
|
|
| def compose( |
| self, |
| question: str, |
| hits: List[Dict[str, Any]], |
| memory: Optional[ConversationMemory] = None, |
| ) -> Tuple[str, str]: |
| """ |
| Erzeugt eine Antwort auf eine Fach- oder Meta-Frage. |
| |
| Rückgabe: |
| (answer_text, source_type) |
| |
| source_type: |
| - "meta": deterministisch aus ConversationMemory |
| - "document": aus RAG-Textstellen und LLM |
| - "none": keine verwertbare Grundlage |
| """ |
| if self._is_meta_question(question): |
| return self._answer_meta(question, memory) |
|
|
| if not hits: |
| return ( |
| "Die bereitgestellten Dokumente enthalten hierzu keine relevante Textstelle. " |
| "Eine belastbare juristische Antwort kann ich auf dieser Grundlage nicht geben.", |
| "none", |
| ) |
|
|
| rag_context, used_hits = self._build_rag_context(hits, question=question) |
| if not rag_context.strip(): |
| return ( |
| "Die gefundenen Treffer enthalten keinen verwertbaren Text. " |
| "Eine belastbare juristische Antwort kann ich auf dieser Grundlage nicht geben.", |
| "none", |
| ) |
|
|
| answer = self._call_llm_for_document_question( |
| question=question, |
| rag_context=rag_context, |
| memory=memory, |
| ) |
| answer = self._postprocess_document_answer(answer, used_hits, validate_markers=self.validate_source_markers) |
| referenced_numbers = self._referenced_source_numbers(answer) |
|
|
| if self.append_sources_to_answer: |
| sources = self.build_sources( |
| used_hits, |
| max_sources=(len(used_hits) if self.display_all_context_sources else self.max_sources), |
| include_pure_neighbors=(self.include_pure_neighbors_as_sources or self.display_all_context_sources), |
| allowed_container_ids=list(self.allowed_container_ids) if self.allowed_container_ids else None, |
| referenced_source_numbers=referenced_numbers, |
| ) |
| sources_md = self.format_sources_markdown(sources) |
| if sources_md: |
| answer = f"{answer}\n\n{sources_md}" |
|
|
| return answer, "document" |
|
|
| def compose_with_sources( |
| self, |
| question: str, |
| hits: List[Dict[str, Any]], |
| memory: Optional[ConversationMemory] = None, |
| ) -> Tuple[str, str, List[Dict[str, Any]]]: |
| """ |
| Erweiterte API für Apps, die Antwort und kuratierte Quellen getrennt anzeigen wollen. |
| """ |
| if self._is_meta_question(question): |
| answer, source_type = self._answer_meta(question, memory) |
| return answer, source_type, [] |
|
|
| rag_context, used_hits = self._build_rag_context(hits, question=question) |
| if not rag_context.strip(): |
| return ( |
| "Die gefundenen Treffer enthalten keinen verwertbaren Text. " |
| "Eine belastbare juristische Antwort kann ich auf dieser Grundlage nicht geben.", |
| "none", |
| [], |
| ) |
|
|
| answer = self._call_llm_for_document_question( |
| question=question, |
| rag_context=rag_context, |
| memory=memory, |
| ) |
| answer = self._postprocess_document_answer(answer, used_hits, validate_markers=self.validate_source_markers) |
| referenced_numbers = self._referenced_source_numbers(answer) |
|
|
| sources = self.build_sources( |
| used_hits, |
| max_sources=(len(used_hits) if self.display_all_context_sources else self.max_sources), |
| include_pure_neighbors=(self.include_pure_neighbors_as_sources or self.display_all_context_sources), |
| allowed_container_ids=list(self.allowed_container_ids) if self.allowed_container_ids else None, |
| referenced_source_numbers=referenced_numbers, |
| ) |
| return answer, "document", sources |
|
|