316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""Tests for `tht session documents --json` and build_documents."""
|
|
import json
|
|
from datetime import UTC, datetime
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from tht.cli.session_cmd import session_app
|
|
from tht.config import DatabaseConfig
|
|
from tht.decisions import DecisionRecord
|
|
from tht.session.models import SessionSnapshot
|
|
from tht.session.store import (
|
|
build_documents,
|
|
build_snapshot_documents,
|
|
create_session,
|
|
new_session_manifest,
|
|
)
|
|
|
|
|
|
def _db():
|
|
return DatabaseConfig(
|
|
database="testdb", user="u", password="p",
|
|
schema="public",
|
|
)
|
|
|
|
|
|
def test_manifest_persists_workspace_revision():
|
|
manifest = new_session_manifest(
|
|
"q", _db(), workspace_id="psd-clinical", workspace_revision="a" * 40
|
|
)
|
|
|
|
assert manifest.workspace_id == "psd-clinical"
|
|
assert manifest.workspace_revision == "a" * 40
|
|
|
|
|
|
def test_build_documents_always_has_original_question(tmp_path):
|
|
m = create_session("quante ablazioni nel 2024", _db(), tmp_path)
|
|
docs = build_documents(m, tmp_path / m.id)
|
|
keys = [d["key"] for d in docs]
|
|
assert keys[0] == "question"
|
|
assert docs[0]["format"] == "text"
|
|
assert docs[0]["content"] == "quante ablazioni nel 2024"
|
|
# question.md is written by create_session -> revised_question present
|
|
assert "revised_question" in keys
|
|
|
|
|
|
def test_build_documents_includes_existing_artifacts_only(tmp_path):
|
|
m = create_session("q", _db(), tmp_path)
|
|
sdir = tmp_path / m.id
|
|
(sdir / "sql_final.sql").write_text("SELECT 1")
|
|
(sdir / "schema_linking.json").write_text('{"question":"q","candidates":[]}')
|
|
docs = {d["key"]: d for d in build_documents(m, sdir)}
|
|
assert docs["sql"]["format"] == "sql"
|
|
assert docs["sql"]["content"] == "SELECT 1"
|
|
assert docs["schema_linking"]["format"] == "schema-linking"
|
|
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, tzinfo=UTC),
|
|
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"),
|
|
_decision(9, "cte_approved", "eventi_device"),
|
|
_decision(10, "sql_approved", "phase:7"),
|
|
_decision(11, "datamart_requested", "phase:8"),
|
|
_decision(12, "datamart_declined", "phase:8"),
|
|
]
|
|
(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",
|
|
"cte_approved",
|
|
"sql_approved",
|
|
"datamart_requested",
|
|
"datamart_declined",
|
|
):
|
|
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_memory_projection_excludes_standalone_schema_tables_but_keeps_mappings(tmp_path):
|
|
manifest = create_session("q", _db(), tmp_path)
|
|
session_dir = tmp_path / manifest.id
|
|
decisions = [
|
|
_decision(1, "table_promoted", "fact_procedure", "Tabella dei fatti"),
|
|
_decision(2, "memory_promoted", "fact_procedure", "seq:1"),
|
|
_decision(3, "table_excluded", "dim_time", "Dimensione temporale"),
|
|
_decision(4, "memory_promotion_declined", "dim_time", "Dimensione temporale"),
|
|
_decision(
|
|
5,
|
|
"concept_clarified",
|
|
"ablazione valida",
|
|
"Mappata su `fact_procedure.ablazione_transcatetere = TRUE`.",
|
|
),
|
|
_decision(6, "memory_promoted", "ablazione valida", "seq:5"),
|
|
_decision(
|
|
7,
|
|
"memory_promotion_declined",
|
|
"conteggio annuale",
|
|
"`COUNT(DISTINCT patient_id)` raggruppato per `dim_time.year`.",
|
|
),
|
|
]
|
|
(session_dir / "schema_linking.json").write_text(json.dumps({
|
|
"question": "q",
|
|
"candidates": [{"kind": "table", "name": "fact_procedure"}],
|
|
"joins": [],
|
|
"excluded": [{"kind": "table", "name": "dim_time"}],
|
|
"open_questions": [],
|
|
}))
|
|
(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"]) == [
|
|
{
|
|
"status": "approved",
|
|
"subject": "ablazione valida",
|
|
"detail": "Mappata su `fact_procedure.ablazione_transcatetere = TRUE`.",
|
|
"rationale": "",
|
|
},
|
|
{
|
|
"status": "declined",
|
|
"subject": "conteggio annuale",
|
|
"detail": "`COUNT(DISTINCT patient_id)` raggruppato per `dim_time.year`.",
|
|
"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
|
|
|
|
# The CLI resolves its local principal through THT_HOME. Keep this test hermetic instead
|
|
# of changing the developer's real identity directory while exercising JSON output.
|
|
monkeypatch.setenv("THT_HOME", str(tmp_path / "thoth-home"))
|
|
|
|
class FakePaths:
|
|
sessions = tmp_path
|
|
|
|
class FakeCfg:
|
|
paths = FakePaths()
|
|
database = _db()
|
|
|
|
monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: FakeCfg())
|
|
res = CliRunner().invoke(session_app, ["documents", m.id, "--json"])
|
|
assert res.exit_code == 0, res.output
|
|
docs = json.loads(res.output)
|
|
assert isinstance(docs, list)
|
|
assert docs[0]["key"] == "question"
|