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>
161 lines
5.0 KiB
Python
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
|