All checks were successful
- Introduced a new set of non-identity entity types (FOOD, DISH, MEAL, CUISINE, FOODSTUFF, INGREDIENT) that are omitted from detection results without causing contract violations. - Updated the `DetectionStats` class to track omitted non-identity types. - Modified validation logic to ensure that food-related mentions are not treated as identity types, preserving the context of mentions as either PERSON or omitted. - Enhanced tests to verify the correct handling of food homonyms and ensure that invalid entity types are recorded appropriately without causing errors. Co-authored-by: Cursor <cursoragent@cursor.com>
885 lines
32 KiB
Python
885 lines
32 KiB
Python
"""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}"
|