feat: redesign persisted session summaries

This commit is contained in:
2026-07-23 15:35:31 +02:00
parent 12c7a976d7
commit 0db85e4e4e
12 changed files with 1090 additions and 71 deletions
+182 -1
View File
@@ -1,10 +1,13 @@
"""Tests for `tht session documents --json` and build_documents."""
import json
from datetime import datetime
from typer.testing import CliRunner
from tht.config import DatabaseConfig
from tht.session.store import build_documents, create_session
from tht.decisions import DecisionRecord
from tht.session.models import SessionSnapshot
from tht.session.store import build_documents, build_snapshot_documents, create_session
from tht.cli.session_cmd import session_app
@@ -38,6 +41,184 @@ def test_build_documents_includes_existing_artifacts_only(tmp_path):
assert "validation_report" not in docs # not written
def _decision(seq: int, type_: str, subject: str, detail: str = "", rationale: str = ""):
return DecisionRecord(
seq=seq,
ts=datetime(2026, 1, 1),
type=type_,
subject=subject,
detail=detail,
rationale=rationale,
)
def _rich_session(tmp_path):
manifest = create_session("domanda originale", _db(), tmp_path)
session_dir = tmp_path / manifest.id
question = (
"# Domanda\n\n"
"Conta i pazienti con **ablazione**.\n\n"
"## Assunzioni\n\n"
"- Considera solo gli adulti\n"
"- Usa l'ultima procedura disponibile\n"
)
report = (
"# Validation report\n\n"
"## Parsing e read-only\n\n- parsing: OK\n\n"
"## Preview / aggregato\n\n"
"- tempo di esecuzione: 12 ms (limit 10)\n\n"
"| count |\n|---|\n| 42 |\n\n"
"## Note del reviewer\n\nRisultato verificato.\n"
)
decisions = [
_decision(1, "concept_clarified", "paziente attivo", "`flag_attivo = TRUE`"),
_decision(2, "memory_promotion_declined", "paziente attivo", "seq:1"),
_decision(
3,
"concept_clarified",
"ablazione valida",
"Procedura con stato **completato**.",
"Definizione approvata dal reviewer.",
),
_decision(4, "memory_promoted", "ablazione valida", "seq:3"),
_decision(5, "phase_approved", "phase:1"),
_decision(6, "table_promoted", "fact_procedure"),
_decision(7, "column_promoted", "fact_procedure.status"),
_decision(8, "column_excluded", "fact_procedure.note", "Non pertinente"),
]
(session_dir / "question.md").write_text(question)
(session_dir / "sql_final.sql").write_text("SELECT COUNT(*) FROM fact_procedure")
(session_dir / "validation_report.md").write_text(report)
(session_dir / "schema_linking.json").write_text(
'{"question":"q","candidates":[],"joins":[],"excluded":[],"open_questions":[]}'
)
(session_dir / "review_decisions.jsonl").write_text(
"\n".join(d.model_dump_json() for d in decisions) + "\n"
)
return manifest, session_dir, question, report, decisions
def test_build_documents_projects_the_approved_summary_order_and_sections(tmp_path):
manifest, session_dir, _question, _report, _decisions = _rich_session(tmp_path)
docs = build_documents(manifest, session_dir)
assert [doc["key"] for doc in docs] == [
"question",
"sql",
"preview",
"revised_question",
"assumptions",
"memories",
"schema_linking",
"validation_report",
"decisions",
]
by_key = {doc["key"]: doc for doc in docs}
assert by_key["preview"]["format"] == "markdown"
assert "tempo di esecuzione: 12 ms" in by_key["preview"]["content"]
assert "| 42 |" in by_key["preview"]["content"]
assert "Parsing e read-only" not in by_key["preview"]["content"]
assert by_key["revised_question"]["content"] == (
"# Domanda\n\nConta i pazienti con **ablazione**."
)
assert by_key["assumptions"]["content"] == (
"## Assunzioni\n\n- Considera solo gli adulti\n"
"- Usa l'ultima procedura disponibile"
)
assert "Preview / aggregato" not in by_key["validation_report"]["content"]
assert "Parsing e read-only" in by_key["validation_report"]["content"]
assert "Note del reviewer" in by_key["validation_report"]["content"]
def test_build_documents_groups_memories_and_filters_summary_decisions(tmp_path):
manifest, session_dir, _question, _report, _decisions = _rich_session(tmp_path)
by_key = {doc["key"]: doc for doc in build_documents(manifest, session_dir)}
memories = json.loads(by_key["memories"]["content"])
assert memories == [
{
"status": "approved",
"subject": "ablazione valida",
"detail": "Procedura con stato **completato**.",
"rationale": "Definizione approvata dal reviewer.",
},
{
"status": "declined",
"subject": "paziente attivo",
"detail": "`flag_attivo = TRUE`",
"rationale": "",
},
]
decisions = by_key["decisions"]["content"]
assert "column_excluded" in decisions
for hidden in (
"concept_clarified",
"memory_promoted",
"memory_promotion_declined",
"phase_approved",
"table_promoted",
"column_promoted",
):
assert hidden not in decisions
def test_build_documents_keeps_validation_report_when_it_has_no_preview(tmp_path):
manifest = create_session("q", _db(), tmp_path)
session_dir = tmp_path / manifest.id
report = "# Validation report\n\n## Parsing\n\n- OK\n"
(session_dir / "validation_report.md").write_text(report)
by_key = {doc["key"]: doc for doc in build_documents(manifest, session_dir)}
assert "preview" not in by_key
assert by_key["validation_report"]["content"] == report.strip()
def test_memory_projection_does_not_repeat_detail_as_marker_rationale(tmp_path):
manifest = create_session("q", _db(), tmp_path)
session_dir = tmp_path / manifest.id
decisions = [
_decision(1, "concept_clarified", "termine", "Definizione in **Markdown**."),
_decision(
2,
"memory_promoted",
"termine",
"seq:1",
"Definizione in **Markdown**.",
),
]
(session_dir / "review_decisions.jsonl").write_text(
"\n".join(d.model_dump_json() for d in decisions) + "\n"
)
by_key = {doc["key"]: doc for doc in build_documents(manifest, session_dir)}
assert json.loads(by_key["memories"]["content"])[0] == {
"status": "approved",
"subject": "termine",
"detail": "Definizione in **Markdown**.",
"rationale": "",
}
def test_filesystem_and_snapshot_builders_share_the_same_projection(tmp_path):
manifest, session_dir, question, report, decisions = _rich_session(tmp_path)
snapshot = SessionSnapshot(
manifest=manifest,
artifacts={
"question": question,
"schema_linking": (session_dir / "schema_linking.json").read_text(),
"sql_final": (session_dir / "sql_final.sql").read_text(),
"validation_report": report,
},
decisions=decisions,
)
assert build_snapshot_documents(snapshot) == build_documents(manifest, session_dir)
def test_cli_documents_json(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
from tht.cli import session_cmd
+166 -33
View File
@@ -1,5 +1,6 @@
import json
import os
import re
import shutil
import uuid
from datetime import UTC, datetime
@@ -143,22 +144,160 @@ def new_session_manifest(
)
def build_snapshot_documents(snapshot) -> list[dict]:
docs = [{"phase": "—", "key": "question", "title": "Original question", "format": "text", "content": snapshot.manifest.question}]
spec = [
("question", "F3", "revised_question", "Revised question", "markdown"),
("schema_linking", "F4", "schema_linking", "Schema linking", "schema-linking"),
("sql_final", "F7", "sql", "Final SQL", "sql"),
("validation_report", "finalize", "validation_report", "Validation report", "markdown"),
_ASSUMPTIONS_HEADING = re.compile(
r"^#{1,6}\s+(?:assunzioni|assumptions)\s*$", re.IGNORECASE | re.MULTILINE
)
_PREVIEW_HEADING = re.compile(
r"^##\s+preview(?:\s*/\s*aggregato)?\s*$", re.IGNORECASE | re.MULTILINE
)
_NEXT_H2 = re.compile(r"^##\s+", re.MULTILINE)
_MEMORY_TYPES = frozenset(
{"concept_clarified", "memory_promoted", "memory_promotion_declined", "memory_rejected"}
)
_SUMMARY_HIDDEN_TYPES = _MEMORY_TYPES | frozenset(
{
"phase_approved",
"phase_auto_approved",
"table_approved",
"table_promoted",
"column_promoted",
}
)
def _split_question_sections(content: str) -> tuple[str, str]:
"""Split legacy combined question Markdown without rewriting the artifact."""
match = _ASSUMPTIONS_HEADING.search(content)
if match is None:
return content.strip(), ""
return content[:match.start()].strip(), content[match.start():].strip()
def _split_validation_preview(content: str) -> tuple[str, str]:
"""Return the persisted preview section and the remaining validation report."""
match = _PREVIEW_HEADING.search(content)
if match is None:
return "", content.strip()
body_start = match.end()
next_heading = _NEXT_H2.search(content, body_start)
section_end = next_heading.start() if next_heading else len(content)
preview = content[body_start:section_end].strip()
remaining = (content[:match.start()] + content[section_end:]).strip()
return preview, remaining
def _referenced_decision(marker, by_seq: dict[int, object]):
match = re.fullmatch(r"seq:(\d+)", marker.detail.strip())
return by_seq.get(int(match.group(1))) if match else None
def _memory_projection(decisions: list) -> list[dict[str, str]]:
by_seq = {decision.seq: decision for decision in decisions}
approved = [decision for decision in decisions if decision.type == "memory_promoted"]
declined = [
decision
for decision in decisions
if decision.type in {"memory_promotion_declined", "memory_rejected"}
]
for artifact, phase, key, title, fmt in spec:
if artifact in snapshot.artifacts:
docs.append({"phase": phase, "key": key, "title": title, "format": fmt, "content": snapshot.artifacts[artifact]})
if snapshot.decisions:
docs.append({"phase": "—", "key": "decisions", "title": "Decisions", "format": "decisions", "content": "\n".join(d.model_dump_json() for d in snapshot.decisions) + "\n"})
items: list[dict[str, str]] = []
for status, markers in (("approved", approved), ("declined", declined)):
for marker in markers:
origin = _referenced_decision(marker, by_seq)
detail = origin.detail if origin is not None else marker.detail
if re.fullmatch(r"seq:\d+", detail.strip()):
detail = ""
rationale = (
origin.rationale if origin is not None and origin.rationale
else marker.rationale
)
if rationale.strip() == detail.strip():
rationale = ""
items.append(
{
"status": status,
"subject": (origin.subject if origin is not None else marker.subject),
"detail": detail,
"rationale": rationale,
}
)
return items
def _document_bundle(manifest: SessionManifest, artifacts: dict[str, str], decisions: list) -> list[dict]:
docs: list[dict] = [{
"phase": "—",
"key": "question",
"title": "Original question",
"format": "text",
"content": manifest.question,
}]
sql = artifacts.get("sql_final")
if sql is not None:
docs.append({
"phase": "F7", "key": "sql", "title": "Final SQL",
"format": "sql", "content": sql,
})
validation = artifacts.get("validation_report")
preview, validation_without_preview = _split_validation_preview(validation or "")
if preview:
docs.append({
"phase": "finalize", "key": "preview", "title": "Data preview",
"format": "markdown", "content": preview,
})
revised, assumptions = _split_question_sections(artifacts.get("question", ""))
if revised:
docs.append({
"phase": "F3", "key": "revised_question", "title": "Revised question",
"format": "markdown", "content": revised,
})
if assumptions:
docs.append({
"phase": "F3", "key": "assumptions", "title": "Assumptions",
"format": "markdown", "content": assumptions,
})
memories = _memory_projection(decisions)
if memories:
docs.append({
"phase": "F8", "key": "memories", "title": "Memories",
"format": "memories", "content": json.dumps(memories, ensure_ascii=False),
})
schema_linking = artifacts.get("schema_linking")
if schema_linking is not None:
docs.append({
"phase": "F4", "key": "schema_linking", "title": "Schema linking",
"format": "schema-linking", "content": schema_linking,
})
if validation_without_preview:
docs.append({
"phase": "finalize", "key": "validation_report", "title": "Validation report",
"format": "markdown", "content": validation_without_preview,
})
visible_decisions = [d for d in decisions if d.type not in _SUMMARY_HIDDEN_TYPES]
if visible_decisions:
docs.append({
"phase": "—", "key": "decisions", "title": "Decisions",
"format": "decisions",
"content": "\n".join(d.model_dump_json() for d in visible_decisions) + "\n",
})
return docs
def build_snapshot_documents(snapshot) -> list[dict]:
from tht.phase import effective_decisions
return _document_bundle(
snapshot.manifest,
snapshot.artifacts,
effective_decisions(snapshot),
)
def touch_manifest(
session_id: str, sessions_root: Path, *, updated_by: str | None = None
) -> SessionManifest:
@@ -379,24 +518,18 @@ def persist_verified_finalization(
def build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]:
"""Ordered, read-only document bundle for the UI panel. Only documents that exist
on disk are returned. CTE artifacts (F6) are intentionally excluded (intermediate)."""
docs: list[dict] = [{
"phase": "—", "key": "question", "title": "Original question",
"format": "text", "content": manifest.question,
}]
spec = [
("question.md", "F3", "revised_question", "Revised question", "markdown"),
("schema_linking.json", "F4", "schema_linking", "Schema linking", "schema-linking"),
("sql_final.sql", "F7", "sql", "Final SQL", "sql"),
("validation_report.md", "finalize", "validation_report", "Validation report", "markdown"),
("review_decisions.jsonl", "—", "decisions", "Decisions", "decisions"),
]
for filename, phase, key, title, fmt in spec:
path = session_dir / filename
if path.exists():
docs.append({
"phase": phase, "key": key, "title": title,
"format": fmt, "content": path.read_text(),
})
return docs
"""Build the canonical read-only bundle for legacy filesystem sessions."""
from tht.phase import effective_decisions
artifact_files = {
"question": "question.md",
"schema_linking": "schema_linking.json",
"sql_final": "sql_final.sql",
"validation_report": "validation_report.md",
}
artifacts = {
key: path.read_text()
for key, filename in artifact_files.items()
if (path := session_dir / filename).exists()
}
return _document_bundle(manifest, artifacts, effective_decisions(session_dir))