"""Opt-in local development debug persist. Mapping labels and secrets stay out.""" from __future__ import annotations import json import logging from datetime import datetime, timezone from typing import Any import uuid from db import get_db, row_to_dict from version import APP_VERSION, BUILD_DATE SETTING_KEY = "debug.persist_traces" EXPORT_KIND = "kansho.debug_export" EXPORT_VERSION = 2 UNASSIGNED_SPACE_TITLE = "Ohne Space" UNASSIGNED_DAY_LABEL = "Ohne Tag" FORBIDDEN_KEYS = frozenset( { "local_label", "demask_label", "mapping_table", "replacements", "restore_by_token", "password", "password_hash", "key", "api_key", "secret", "token", } ) LAYER_FOR_PURPOSE = { "dialogue_turn": "dialogzug", "journal_generate": "journalentwurf", "local_source_artifact": "journalquellen", "journal_reconstruct": "journalrekonstruktion", "profile_review": "profilreview", } SANITIZE_MAX_DEPTH = 80 LOGGER = logging.getLogger("kansho.debug") def _now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def persist_enabled() -> bool: with get_db() as conn: row = row_to_dict( conn.execute("SELECT value FROM app_settings WHERE key = ?", (SETTING_KEY,)).fetchone() ) return (row or {}).get("value") == "1" def set_persist_enabled(enabled: bool) -> dict: value = "1" if enabled else "0" with get_db() as conn: conn.execute( """ INSERT INTO app_settings (key, value, updated) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated = datetime('now') """, (SETTING_KEY, value), ) return settings_payload() def _is_admin(profile_id: str) -> bool: with get_db() as conn: row = row_to_dict( conn.execute("SELECT role FROM profiles WHERE id = ?", (profile_id,)).fetchone() ) return (row or {}).get("role") == "admin" def should_persist(profile_id: str | None) -> bool: if not profile_id: return False return persist_enabled() and _is_admin(profile_id) def sanitize(value: Any, *, _stack: set[int] | None = None, _depth: int = 0) -> Any: if _depth > SANITIZE_MAX_DEPTH: return None if isinstance(value, (dict, list, tuple)): stack = _stack if _stack is not None else set() ident = id(value) if ident in stack: return None stack.add(ident) try: if isinstance(value, dict): cleaned = {} for key, item in value.items(): if key in FORBIDDEN_KEYS: continue cleaned[key] = sanitize(item, _stack=stack, _depth=_depth + 1) return cleaned return [sanitize(item, _stack=stack, _depth=_depth + 1) for item in value] finally: stack.discard(ident) return value def settings_payload(profile_id: str | None = None) -> dict: enabled = persist_enabled() count = 0 if profile_id: with get_db() as conn: count = conn.execute( "SELECT COUNT(*) AS n FROM debug_runs WHERE profile_id = ?", (profile_id,), ).fetchone()["n"] return { "persist_enabled": enabled, "run_count": count, "note": ( "Entwicklungsoption. Speichert die Admin-Testspur lokal je Dialogschritt. " "Zuordnung Space / Tag / Gespräch. Mapping-Tabelle, Secrets und Klarnamen-Labels " "bleiben ausgeschlossen. Nur Aktionen des Admin-Profils. Default aus." ), } def resolve_placement( profile_id: str, *, conversation_id: str | None = None, journal_day_id: str | None = None, space_id: str | None = None, ) -> dict: place = { "space_id": space_id or None, "journal_day_id": journal_day_id or None, "conversation_id": conversation_id or None, "space_title": "", "calendar_date": "", "conversation_title": "", } with get_db() as conn: if conversation_id: conv = row_to_dict( conn.execute( """ SELECT id, title, space_id, journal_day_id FROM conversations WHERE id = ? AND profile_id = ? """, (conversation_id, profile_id), ).fetchone() ) if conv: place["conversation_id"] = conv["id"] place["conversation_title"] = conv.get("title") or "" place["space_id"] = place["space_id"] or conv.get("space_id") place["journal_day_id"] = place["journal_day_id"] or conv.get("journal_day_id") if place["journal_day_id"]: day = row_to_dict( conn.execute( """ SELECT id, calendar_date, space_id FROM journal_days WHERE id = ? AND profile_id = ? """, (place["journal_day_id"], profile_id), ).fetchone() ) if day: place["calendar_date"] = day.get("calendar_date") or "" place["space_id"] = place["space_id"] or day.get("space_id") if place["space_id"]: space = row_to_dict( conn.execute( """ SELECT id, title FROM spaces WHERE id = ? AND profile_id = ? """, (place["space_id"], profile_id), ).fetchone() ) if space: place["space_title"] = space.get("title") or "" return place def persist_step( profile_id: str, *, purpose: str, status: str = "ok", layer: str = "", subject_type: str = "", subject_id: str = "", conversation_id: str | None = None, message_id: str | None = None, journal_day_id: str | None = None, space_id: str | None = None, decision: dict | None = None, trace: dict | None = None, extra: dict | None = None, ) -> str | None: if not should_persist(profile_id): return None if not journal_day_id and subject_type == "journal_day" and subject_id: journal_day_id = subject_id place = resolve_placement( profile_id, conversation_id=conversation_id, journal_day_id=journal_day_id, space_id=space_id, ) payload = sanitize( { "decision": decision, "trace": trace, **(extra or {}), } ) run_id = str(uuid.uuid4()) with get_db() as conn: conn.execute( """ INSERT INTO debug_runs ( id, profile_id, purpose, layer, status, subject_type, subject_id, conversation_id, message_id, space_id, journal_day_id, space_title, calendar_date, conversation_title, payload_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( run_id, profile_id, purpose, layer or LAYER_FOR_PURPOSE.get(purpose, ""), status, subject_type or "", subject_id or "", place["conversation_id"], message_id, place["space_id"], place["journal_day_id"], place["space_title"], place["calendar_date"], place["conversation_title"], json.dumps(payload, ensure_ascii=False, default=str), ), ) return run_id def persist_engine_error( profile_id: str, *, purpose: str, exc, layer: str = "", subject_type: str = "", subject_id: str = "", conversation_id: str | None = None, journal_day_id: str | None = None, extra: dict | None = None, ) -> str | None: try: diag = dict(getattr(exc, "diagnostics", None) or {}) merged = dict(extra or {}) merged.setdefault("code", getattr(exc, "code", None)) merged.setdefault("message", getattr(exc, "message", None) or str(exc)) log = diag.get("log") if log is not None: merged.setdefault("run_log", list(log) if isinstance(log, list) else log) trace = diag.get("trace") if isinstance(diag.get("trace"), dict) else {} try: from privacy_gateway import compact_diagnostics, public_cost_report compact = compact_diagnostics({**diag, **trace, **merged}) for key, value in compact.items(): merged.setdefault(key, value) merged.setdefault("cost_report", public_cost_report({**diag, **merged})) except Exception: LOGGER.exception("Kompakte Fehlerdiagnose konnte nicht erzeugt werden.") for key in ( "generation_selection", "style_application", "abort_reason", "detect_attempts", "detect_passes", "detect_partial_discarded", "contract_violation", "invalid_entity_type", "omitted_non_identity_types", "generate_called", ): if merged.get(key) is None: if diag.get(key) is not None: merged[key] = diag.get(key) elif trace.get(key) is not None: merged[key] = trace.get(key) merged.setdefault("generate_called", False) return persist_step( profile_id, purpose=purpose, status="error", layer=layer, subject_type=subject_type, subject_id=subject_id, conversation_id=conversation_id, journal_day_id=journal_day_id, trace=trace or None, extra=merged, ) except Exception: LOGGER.exception( "Diagnosepersistenz fehlgeschlagen; ursprünglicher Fehler bleibt %s", getattr(exc, "code", type(exc).__name__), ) return None def _parse_payload(raw: str | None) -> dict: if not raw: return {} try: data = json.loads(raw) except json.JSONDecodeError: return {} return data if isinstance(data, dict) else {} def _row_public(row: dict, *, include_payload: bool = True) -> dict: payload = _parse_payload(row.get("payload_json")) item = { "id": row["id"], "created": row["created"], "purpose": row["purpose"], "layer": row["layer"], "status": row["status"], "subject_type": row.get("subject_type") or "", "subject_id": row.get("subject_id") or "", "conversation_id": row.get("conversation_id"), "message_id": row.get("message_id"), "space_id": row.get("space_id"), "journal_day_id": row.get("journal_day_id"), "space_title": row.get("space_title") or "", "calendar_date": row.get("calendar_date") or "", "conversation_title": row.get("conversation_title") or "", "decision": payload.get("decision"), "summary": _summary(row, payload), } if include_payload: item["payload"] = payload item["trace"] = payload.get("trace") item["run_log"] = payload.get("run_log") or ((payload.get("trace") or {}).get("log")) return item def _summary(row: dict, payload: dict) -> str: decision = payload.get("decision") or {} label = decision.get("label") or decision.get("operation") or "" purpose = row.get("purpose") or "" status = row.get("status") or "" parts = [purpose] if label: parts.append(str(label)) if status and status != "ok": parts.append(status) code = payload.get("code") if code: parts.append(str(code)) return " · ".join(parts) def list_runs(profile_id: str, *, limit: int = 200) -> list[dict]: capped = max(1, min(int(limit or 200), 500)) with get_db() as conn: rows = [ dict(item) for item in conn.execute( """ SELECT * FROM debug_runs WHERE profile_id = ? ORDER BY created DESC, id DESC LIMIT ? """, (profile_id, capped), ).fetchall() ] return [_row_public(row, include_payload=False) for row in rows] def latest_run( profile_id: str, *, journal_day_id: str | None = None, purpose: str | None = None, ) -> dict | None: clauses = ["profile_id = ?"] args: list[Any] = [profile_id] if journal_day_id: clauses.append("journal_day_id = ?") args.append(journal_day_id) if purpose: clauses.append("purpose = ?") args.append(purpose) with get_db() as conn: row = row_to_dict( conn.execute( f""" SELECT * FROM debug_runs WHERE {' AND '.join(clauses)} ORDER BY created DESC, id DESC LIMIT 1 """, args, ).fetchone() ) if not row: return None items = _fill_placement(profile_id, [_row_public(row, include_payload=True)]) return items[0] if items else None def get_run(profile_id: str, run_id: str) -> dict | None: with get_db() as conn: row = row_to_dict( conn.execute( "SELECT * FROM debug_runs WHERE id = ? AND profile_id = ?", (run_id, profile_id), ).fetchone() ) if not row: return None items = _fill_placement(profile_id, [_row_public(row, include_payload=True)]) return items[0] def delete_run(profile_id: str, run_id: str) -> bool: with get_db() as conn: cursor = conn.execute( "DELETE FROM debug_runs WHERE id = ? AND profile_id = ?", (run_id, profile_id), ) return cursor.rowcount > 0 def clear_runs(profile_id: str) -> int: with get_db() as conn: cursor = conn.execute("DELETE FROM debug_runs WHERE profile_id = ?", (profile_id,)) return cursor.rowcount def list_runs_full(profile_id: str) -> list[dict]: with get_db() as conn: rows = [ dict(item) for item in conn.execute( """ SELECT * FROM debug_runs WHERE profile_id = ? ORDER BY created ASC, id ASC """, (profile_id,), ).fetchall() ] items = [_row_public(row, include_payload=True) for row in rows] return _fill_placement(profile_id, items) def _fill_placement(profile_id: str, items: list[dict]) -> list[dict]: missing_conv = [ item.get("conversation_id") for item in items if item.get("conversation_id") and not item.get("space_id") and not item.get("journal_day_id") ] missing_days = [ item.get("journal_day_id") for item in items if item.get("journal_day_id") and not item.get("space_id") ] conv_ids = list(dict.fromkeys(missing_conv)) day_ids = list(dict.fromkeys(missing_days)) convs: dict[str, dict] = {} days: dict[str, dict] = {} spaces: dict[str, dict] = {} with get_db() as conn: if conv_ids: marks = ",".join("?" * len(conv_ids)) for row in conn.execute( f"SELECT id, title, space_id, journal_day_id FROM conversations WHERE profile_id = ? AND id IN ({marks})", [profile_id, *conv_ids], ): convs[row["id"]] = dict(row) if row["journal_day_id"]: day_ids.append(row["journal_day_id"]) day_ids = list(dict.fromkeys(day_ids)) if day_ids: marks = ",".join("?" * len(day_ids)) for row in conn.execute( f"SELECT id, calendar_date, space_id FROM journal_days WHERE profile_id = ? AND id IN ({marks})", [profile_id, *day_ids], ): days[row["id"]] = dict(row) space_ids = [ item for item in ( [row.get("space_id") for row in convs.values()] + [row.get("space_id") for row in days.values()] + [item.get("space_id") for item in items] ) if item ] space_ids = list(dict.fromkeys(space_ids)) if space_ids: marks = ",".join("?" * len(space_ids)) for row in conn.execute( f"SELECT id, title FROM spaces WHERE profile_id = ? AND id IN ({marks})", [profile_id, *space_ids], ): spaces[row["id"]] = dict(row) for item in items: conv = convs.get(item.get("conversation_id") or "") if conv: item["conversation_title"] = item.get("conversation_title") or conv.get("title") or "" item["space_id"] = item.get("space_id") or conv.get("space_id") item["journal_day_id"] = item.get("journal_day_id") or conv.get("journal_day_id") day = days.get(item.get("journal_day_id") or "") if day: item["calendar_date"] = item.get("calendar_date") or day.get("calendar_date") or "" item["space_id"] = item.get("space_id") or day.get("space_id") space = spaces.get(item.get("space_id") or "") if space: item["space_title"] = item.get("space_title") or space.get("title") or "" return items def _export_run(item: dict) -> dict: return { "id": item["id"], "created": item["created"], "purpose": item["purpose"], "layer": item["layer"], "status": item["status"], "subject_type": item["subject_type"], "subject_id": item["subject_id"], "conversation_id": item.get("conversation_id"), "message_id": item.get("message_id"), "space_id": item.get("space_id"), "journal_day_id": item.get("journal_day_id"), "space_title": item.get("space_title") or "", "calendar_date": item.get("calendar_date") or "", "conversation_title": item.get("conversation_title") or "", "summary": item["summary"], **(item.get("payload") or {}), } def _step_summary(item: dict) -> dict: return { "id": item["id"], "created": item["created"], "purpose": item["purpose"], "layer": item["layer"], "status": item["status"], "summary": item["summary"], "message_id": item.get("message_id"), } def _filter_runs( runs: list[dict], *, conversation_id: str | None = None, journal_day_id: str | None = None, space_id: str | None = None, purpose: str | None = None, ) -> list[dict]: selected = runs if conversation_id: selected = [ item for item in selected if item.get("conversation_id") == conversation_id and item.get("purpose") != "journal_generate" ] elif journal_day_id: selected = [item for item in selected if item.get("journal_day_id") == journal_day_id] if space_id: selected = [item for item in selected if item.get("space_id") == space_id] if purpose: selected = [item for item in selected if item.get("purpose") == purpose] return selected def group_tree(runs: list[dict], *, include_payload: bool = False) -> list[dict]: spaces_map: dict[str, dict] = {} space_order: list[str] = [] for run in runs: exported = _export_run(run) if include_payload else _step_summary(run) sid = run.get("space_id") or "" space_key = sid or "_unassigned" if space_key not in spaces_map: spaces_map[space_key] = { "id": sid or None, "title": run.get("space_title") or (UNASSIGNED_SPACE_TITLE if not sid else ""), "days_map": {}, "day_order": [], } space_order.append(space_key) space = spaces_map[space_key] if run.get("space_title") and not space["title"]: space["title"] = run.get("space_title") did = run.get("journal_day_id") or "" day_key = did or "_unassigned" if day_key not in space["days_map"]: space["days_map"][day_key] = { "id": did or None, "calendar_date": run.get("calendar_date") or (UNASSIGNED_DAY_LABEL if not did else ""), "conversations_map": {}, "conversation_order": [], "generates": [], } space["day_order"].append(day_key) day = space["days_map"][day_key] if run.get("calendar_date") and (not day["calendar_date"] or day["calendar_date"] == UNASSIGNED_DAY_LABEL): day["calendar_date"] = run.get("calendar_date") if run.get("purpose") == "journal_generate": day["generates"].append(exported) continue cid = run.get("conversation_id") or "" conv_key = cid or "_none" if conv_key not in day["conversations_map"]: day["conversations_map"][conv_key] = { "id": cid or None, "title": run.get("conversation_title") or "Gespräch", "steps": [], } day["conversation_order"].append(conv_key) conv = day["conversations_map"][conv_key] if run.get("conversation_title"): conv["title"] = run.get("conversation_title") conv["steps"].append(exported) spaces = [] for space_key in space_order: space = spaces_map[space_key] days = [] for day_key in space["day_order"]: day = space["days_map"][day_key] conversations = [] for conv_key in day["conversation_order"]: conv = day["conversations_map"][conv_key] conversations.append( { "id": conv["id"], "title": conv["title"] or "Gespräch", "step_count": len(conv["steps"]), "steps": conv["steps"], } ) days.append( { "id": day["id"], "calendar_date": day["calendar_date"] or UNASSIGNED_DAY_LABEL, "conversations": conversations, "generates": day["generates"], } ) spaces.append({"id": space["id"], "title": space["title"] or UNASSIGNED_SPACE_TITLE, "days": days}) return spaces def debug_tree(profile_id: str) -> dict: runs = list_runs_full(profile_id) return {"spaces": group_tree(runs, include_payload=False), "run_count": len(runs)} def export_document( profile_id: str, *, conversation_id: str | None = None, journal_day_id: str | None = None, purpose: str | None = None, ) -> dict: runs = list_runs_full(profile_id) selected = _filter_runs( runs, conversation_id=conversation_id, journal_day_id=journal_day_id, purpose=purpose, ) scope = "all" if purpose == "journal_generate": scope = "journal_generate" elif conversation_id: scope = "conversation" elif journal_day_id: scope = "day" spaces = group_tree(selected, include_payload=True) first_space = spaces[0] if spaces else None first_day = ((first_space or {}).get("days") or [None])[0] if first_space else None first_conv = ((first_day or {}).get("conversations") or [None])[0] if first_day else None return { "kind": EXPORT_KIND, "version": EXPORT_VERSION, "exported_at": _now(), "app_version": APP_VERSION, "build_date": BUILD_DATE, "scope": scope, "note": ( "Lokale Entwicklungsdiagnose, gegliedert nach Space / Tag / Gespräch / Schritt. " "Mapping-Tabelle und Secrets sind ausgeschlossen. " "Enthält Klartext-Vorlagen (intern) und maskierten Egress des eigenen Admin-Profils. " "Nicht an Provider senden, außer bewusst zur lokalen Auswertung." ), "persist_enabled": persist_enabled(), "run_count": len(selected), "space": {"id": (first_space or {}).get("id"), "title": (first_space or {}).get("title")} if scope != "all" else None, "day": ( {"id": (first_day or {}).get("id"), "calendar_date": (first_day or {}).get("calendar_date")} if scope in {"conversation", "day", "journal_generate"} else None ), "conversation": ( {"id": (first_conv or {}).get("id"), "title": (first_conv or {}).get("title")} if scope == "conversation" else None ), "spaces": spaces, "runs": [_export_run(item) for item in selected], } def _fence(text: str | None) -> str: body = (text or "").replace("```", "'''") return f"```\n{body}\n```" if body.strip() else "_leer_" def _kv(title: str, value: Any) -> str: if value is None or value == "": return f"- {title}: nicht verfügbar" if isinstance(value, (dict, list)): return f"- {title}: `{json.dumps(value, ensure_ascii=False)}`" return f"- {title}: {value}" def _render_trace_markdown(trace: dict | None, heading: str) -> list[str]: if not trace: return [f"### {heading}", "", "_keine Testspur_", ""] lines = [f"### {heading}", ""] lines.append(_kv("Zweck", trace.get("purpose"))) lines.append(_kv("Schicht", trace.get("layer"))) lines.append(_kv("Prompt", trace.get("prompt_slug"))) lines.append(_kv("Prompt-Revision", trace.get("prompt_revision"))) lines.append(_kv("Provider", trace.get("provider"))) lines.append(_kv("Modell", trace.get("model"))) lines.append(_kv("Detect", trace.get("detect_note") or trace.get("detect_provider"))) lines.append(_kv("Detect-Modell", trace.get("detect_model"))) lines.append(_kv("Abbruchgrund", trace.get("abort_reason"))) lines.append(_kv("Modelltext übernommen", trace.get("model_text_accepted"))) budget = trace.get("budget") or {} if budget: lines.append(_kv("Prompt-Tokens", budget.get("prompt_tokens"))) lines.append(_kv("Completion-Tokens", budget.get("completion_tokens"))) lines.append(_kv("Kosten", budget.get("cost"))) lines.append(_kv("Generate-ms", budget.get("generate_ms") or trace.get("generate_ms"))) lines.extend(["", "#### Intern (Klartext-Vorlage, nicht gesendet)", "", _fence(trace.get("intern")), ""]) lines.extend(["#### Egress (maskiert, gesendet)", "", _fence(trace.get("egress")), ""]) lines.extend(["#### Maskierung, Eingabe", "", _fence(trace.get("mask_input")), ""]) lines.extend(["#### Modellantwort roh", "", _fence(trace.get("raw")), ""]) lines.extend(["#### Lokal demaskiert", "", _fence(trace.get("reply")), ""]) stored = "\n\n".join(part for part in (trace.get("stored_title"), trace.get("stored_body")) if part) if stored: lines.extend(["#### Gespeicherter Entwurf", "", _fence(stored), ""]) if trace.get("source_preview"): lines.extend(["#### Lokale Quellenansicht", "", _fence(trace.get("source_preview")), ""]) return lines def render_markdown(document: dict) -> str: lines = [ "# Kanshō Debug-Export", "", _kv("kind", document.get("kind")), _kv("version", document.get("version")), _kv("scope", document.get("scope")), _kv("exported_at", document.get("exported_at")), _kv("app_version", document.get("app_version")), _kv("build_date", document.get("build_date")), _kv("run_count", document.get("run_count")), _kv("persist_enabled", document.get("persist_enabled")), "", document.get("note") or "", "", "Gliederung: Space / Tag / Gespräch / Schritt. Die Schritte eines Gesprächs sind chronologisch.", "", ] if (document.get("space") or {}).get("title"): lines.append(_kv("Space", (document.get("space") or {}).get("title"))) if (document.get("day") or {}).get("calendar_date"): lines.append(_kv("Tag", (document.get("day") or {}).get("calendar_date"))) if (document.get("conversation") or {}).get("title"): lines.append(_kv("Gespräch", (document.get("conversation") or {}).get("title"))) lines.append("") spaces = document.get("spaces") or [] if spaces: for space in spaces: lines.extend(["---", "", f"# Space: {space.get('title') or UNASSIGNED_SPACE_TITLE}", ""]) for day in space.get("days") or []: lines.extend([f"## Tag: {day.get('calendar_date') or UNASSIGNED_DAY_LABEL}", ""]) for conv in day.get("conversations") or []: lines.extend([f"### Gespräch: {conv.get('title') or 'Gespräch'}", ""]) for index, run in enumerate(conv.get("steps") or [], start=1): lines.extend(_render_run_markdown(run, index)) for index, run in enumerate(day.get("generates") or [], start=1): lines.extend([f"### Journalentwurf {index}", ""]) lines.extend(_render_run_markdown(run, index)) else: for index, run in enumerate(document.get("runs") or [], start=1): lines.extend(_render_run_markdown(run, index)) lines.append("") return "\n".join(lines) def _render_run_markdown(run: dict, index: int) -> list[str]: created = run.get("created") or "" purpose = run.get("purpose") or "" status = run.get("status") or "" lines = [ "---", "", f"#### {index}. {purpose} · {created} · {status}", "", _kv("id", run.get("id")), _kv("layer", run.get("layer")), _kv("conversation_id", run.get("conversation_id")), _kv("code", run.get("code")), ] decision = run.get("decision") if decision: lines.append(_kv("Operation", decision.get("label") or decision.get("operation"))) if decision.get("guard"): lines.append(_kv("Guard", decision.get("guard"))) user = run.get("user") or {} assistant = run.get("assistant") or {} if user.get("body"): lines.extend(["", "##### Nutzer", "", _fence(user.get("body")), ""]) if assistant.get("body"): lines.extend(["##### Antwort", "", _fence(assistant.get("body")), ""]) traces = [] if run.get("call_traces"): traces = list(run.get("call_traces") or []) elif run.get("trace"): nested = (run.get("trace") or {}).get("stages") traces = list(nested) if nested else [run.get("trace")] if not traces and run.get("trace"): traces = [run.get("trace")] for offset, stage in enumerate(traces): title = f"Schritt {offset + 1}" if isinstance(stage, dict) and stage.get("purpose"): title = f"Schritt {offset + 1}: {stage.get('purpose')}" lines.extend(_render_trace_markdown(stage if isinstance(stage, dict) else None, title)) log = run.get("run_log") or ((run.get("trace") or {}).get("log") if isinstance(run.get("trace"), dict) else None) if log: lines.extend( [ "##### Kompaktes Laufprotokoll (ohne Promptkörper)", "", _fence(json.dumps(log, ensure_ascii=False, indent=2)), "", ] ) return lines def export_filename(fmt: str, document: dict | None = None) -> str: stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") suffix = "md" if fmt == "markdown" else "json" scope = (document or {}).get("scope") or "all" if scope == "conversation": return f"kansho-debug-conversation-{stamp}.{suffix}" if scope == "journal_generate": return f"kansho-debug-journal-{stamp}.{suffix}" if scope == "day": return f"kansho-debug-day-{stamp}.{suffix}" return f"kansho-debug-{stamp}.{suffix}"