"""Parsing/normalisation of the Claude protocol output. Tenant-independent: turns the raw LLM text into a structured shape that both the PDF template and the e-mail rendering consume. """ from __future__ import annotations import re SECTION_HEADINGS = ( "Anlass & Kontext", "Besprochene Themen", "Entscheidungen", "Action Items", "Offene Punkte", "Nächste Schritte", ) def normalize_protocol_text(text: str) -> str: normalized_lines = [] for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): line = raw_line.rstrip() if line.startswith("===") and line.endswith("==="): normalized_lines.append(line.strip()) continue stripped = line.strip() if not stripped: normalized_lines.append("") continue cleaned = re.sub(r"^\s{0,3}#{1,6}\s*", "", stripped) cleaned = cleaned.replace("**", "").replace("__", "") cleaned = re.sub(r"^\s*[-*]\s+", "• ", cleaned) cleaned = re.sub(r"\s+", " ", cleaned).strip() normalized_lines.append(cleaned) text = "\n".join(normalized_lines) text = re.sub(r"\n{3,}", "\n\n", text).strip() return text def parse_protocol_sections(text: str) -> dict: """Zerlegt den Claude-Output in Metadaten-Blöcke und Abschnitte.""" lines = text.replace("\r\n", "\n").split("\n") def collect_block(marker: str) -> list[str] | None: open_tag = f"==={marker}===" close_tag = f"===/{marker}===" try: start = lines.index(open_tag) end = lines.index(close_tag, start + 1) except ValueError: return None block = [ln.strip() for ln in lines[start + 1:end] if ln.strip()] return block projekt = collect_block("PROJEKT") or [] kontakt = collect_block("KONTAKT") or [] terminart_block = collect_block("TERMINART") or [] terminart = terminart_block[0] if terminart_block else "" if len(kontakt) == 1 and kontakt[0].lower().strip("() ") in {"keiner", "keine", "none", ""}: kontakt = [] body_lines: list[str] = [] skip = False for ln in lines: stripped = ln.strip() if stripped.startswith("===") and stripped.endswith("==="): if stripped.startswith("===/"): skip = False continue skip = True continue if skip: continue body_lines.append(ln) sections: list[dict] = [] current = None for raw in body_lines: line = raw.strip() if line in SECTION_HEADINGS: if current is not None: sections.append(current) current = {"heading": line, "lines": []} continue if current is None: continue if line.startswith("• "): current["lines"].append(("bullet", line[2:].strip())) elif line: current["lines"].append(("paragraph", line)) else: current["lines"].append(("blank", "")) if current is not None: sections.append(current) return { "projekt": projekt, "kontakt": kontakt, "terminart": terminart, "sections": sections, } def parse_address_lines(address: str) -> list[str]: """Splits a free-form address string into [street, PLZ city] lines.""" address = address.strip() if not address: return [] if "\n" in address: return [l.strip() for l in address.splitlines() if l.strip()] if "," in address: return [p.strip() for p in address.split(",") if p.strip()] m = re.search(r"(\b\d{5}\b.*)", address) if m: before = address[: m.start()].strip().rstrip(",").strip() plz_city = m.group(1).strip() return ([before] if before else []) + [plz_city] return [address] def project_label(projekt_lines: list[str]) -> str: """Erste nicht-leere Zeile des PROJEKT-Blocks (für Dateinamen/Subject).""" for line in projekt_lines: if line and line.strip(): return line.strip() return "Projekt" def safe_filename(title: str) -> str: safe = re.sub(r"[^\w\-äöüÄÖÜß ]", "", title).strip() safe = re.sub(r"\s+", "_", safe) return safe or "Dokument" def group_section_blocks(section: dict) -> list[dict]: """Turn the flat (kind, text) line list into HTML-friendly blocks: consecutive bullets become one {'type':'list','items':[...]}, paragraphs become {'type':'paragraph','text':...}.""" blocks: list[dict] = [] bullets: list[str] = [] def flush_bullets(): if bullets: blocks.append({"type": "list", "items": list(bullets)}) bullets.clear() for kind, text in section.get("lines", []): if kind == "bullet": bullets.append(text) elif kind == "paragraph": flush_bullets() blocks.append({"type": "paragraph", "text": text}) # blank lines are visual only — ignored in the structured shape flush_bullets() return blocks