Files
Onyscribe-Admin/vendor/platform/core/protocol.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

161 lines
5.0 KiB
Python

"""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