Kansho/backend/debug_store.py
2026-08-29 11:04:34 +02:00

838 lines
30 KiB
Python

"""Opt-in local development debug persist. Mapping labels and secrets stay out."""
from __future__ import annotations
import json
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",
}
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) -> Any:
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if key in FORBIDDEN_KEYS:
continue
cleaned[key] = sanitize(item)
return cleaned
if isinstance(value, list):
return [sanitize(item) for item in value]
if isinstance(value, tuple):
return [sanitize(item) for item in value]
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:
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))
if diag.get("log") is not None:
merged.setdefault("run_log", diag.get("log"))
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=diag.get("trace"),
extra=merged,
)
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}"