refactor(harness): route workflow persistence through repositories
This commit is contained in:
+48
-37
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from pydantic import ValidationError
|
||||
|
||||
from tht.decisions import DecisionRecord, list_decisions
|
||||
from tht.session.models import SchemaLinking
|
||||
from tht.session.models import SchemaLinking, SessionSnapshot
|
||||
from tht.workflow import load_workflow
|
||||
|
||||
|
||||
@@ -31,11 +31,22 @@ def _phase_num(subject: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _audit_excluding_retracted(session_dir: Path) -> list[DecisionRecord]:
|
||||
def _decisions(source: Path | SessionSnapshot) -> list[DecisionRecord]:
|
||||
return list(source.decisions) if isinstance(source, SessionSnapshot) else list_decisions(source)
|
||||
|
||||
|
||||
def _artifact(source: Path | SessionSnapshot, key: str, filename: str) -> str | None:
|
||||
if isinstance(source, SessionSnapshot):
|
||||
return source.artifacts.get(key)
|
||||
path = source / filename
|
||||
return path.read_text() if path.exists() else None
|
||||
|
||||
|
||||
def _audit_excluding_retracted(source: Path | SessionSnapshot) -> list[DecisionRecord]:
|
||||
"""Tutto il ledger (append-only) tranne le decisioni ritirate e i marker di ritrazione.
|
||||
Base per il fold di current_phase: il guard 'n == cur' del fold e' gia' reopen-aware
|
||||
(una phase_approved:N dopo un reopen a M<N non fa avanzare perche' cur!=N)."""
|
||||
all_d = list_decisions(session_dir)
|
||||
all_d = _decisions(source)
|
||||
retracted_seqs = {
|
||||
d.retracts for d in all_d if d.type == "decision_retracted" and d.retracts is not None
|
||||
}
|
||||
@@ -46,7 +57,7 @@ def _audit_excluding_retracted(session_dir: Path) -> list[DecisionRecord]:
|
||||
]
|
||||
|
||||
|
||||
def current_phase(session_dir: Path) -> int:
|
||||
def current_phase(source: Path | SessionSnapshot) -> int:
|
||||
"""Fase corrente come fold cronologico sull'audit (con ritirate escluse).
|
||||
|
||||
cur parte da 1; ogni phase_approved/phase_auto_approved per la fase CORRENTE avanza
|
||||
@@ -57,7 +68,7 @@ def current_phase(session_dir: Path) -> int:
|
||||
wf = load_workflow()
|
||||
max_plus_one = wf.max_phase + 1
|
||||
cur = 1
|
||||
for d in _audit_excluding_retracted(session_dir):
|
||||
for d in _audit_excluding_retracted(source):
|
||||
n = _phase_num(d.subject)
|
||||
if n is None:
|
||||
continue
|
||||
@@ -68,7 +79,7 @@ def current_phase(session_dir: Path) -> int:
|
||||
return cur
|
||||
|
||||
|
||||
def effective_decisions(session_dir: Path) -> list[DecisionRecord]:
|
||||
def effective_decisions(source: Path | SessionSnapshot) -> list[DecisionRecord]:
|
||||
"""La vista canonica 'effective as of pointer'. TUTTI gli helper non-fold devono usare questa.
|
||||
|
||||
Semantica: una decisione e' effective se appartiene a una fase <= current_phase.
|
||||
@@ -81,9 +92,9 @@ def effective_decisions(session_dir: Path) -> list[DecisionRecord]:
|
||||
|
||||
Inoltre esclude le decisioni ritirate (decision_retracted) e i marker stessi.
|
||||
"""
|
||||
cur = current_phase(session_dir)
|
||||
cur = current_phase(source)
|
||||
out: list[DecisionRecord] = []
|
||||
for d in _audit_excluding_retracted(session_dir):
|
||||
for d in _audit_excluding_retracted(source):
|
||||
n = _phase_num(d.subject)
|
||||
# Per le decisioni con subject "a nome" (es. cte_approved -> nome CTE, evidence_*
|
||||
# -> id evidence) il subject non porta la fase: si usa la fase emittente registrata
|
||||
@@ -106,9 +117,9 @@ _META_TYPES = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def substantive_count_current_phase(session_dir: Path) -> int:
|
||||
def substantive_count_current_phase(source: Path | SessionSnapshot) -> int:
|
||||
"""Numero di decisioni sostanziali dall'ultimo confine di fase (vista effective)."""
|
||||
decs = effective_decisions(session_dir)
|
||||
decs = effective_decisions(source)
|
||||
start = 0
|
||||
for i, d in enumerate(decs):
|
||||
if d.type in _BOUNDARY_TYPES:
|
||||
@@ -116,14 +127,14 @@ def substantive_count_current_phase(session_dir: Path) -> int:
|
||||
return sum(1 for d in decs[start:] if d.type not in _META_TYPES)
|
||||
|
||||
|
||||
def auto_advance_eligible(session_dir: Path) -> bool:
|
||||
def auto_advance_eligible(source: Path | SessionSnapshot) -> bool:
|
||||
"""Vero sse la fase corrente puo' auto-avanzare (zero decisioni sostanziali + prereq ok)."""
|
||||
cur = current_phase(session_dir)
|
||||
cur = current_phase(source)
|
||||
if cur not in _AUTO_ADVANCE_PHASES:
|
||||
return False
|
||||
if substantive_count_current_phase(session_dir) > 0:
|
||||
if substantive_count_current_phase(source) > 0:
|
||||
return False
|
||||
return not advance_problems(session_dir, cur)
|
||||
return not advance_problems(source, cur)
|
||||
|
||||
|
||||
# --- CTE helpers (consultano effective_decisions) ---------------------------
|
||||
@@ -131,21 +142,21 @@ def auto_advance_eligible(session_dir: Path) -> bool:
|
||||
CTE_PLAN_FILE = "cte_plan.json"
|
||||
|
||||
|
||||
def cte_plan(session_dir: Path) -> list[str]:
|
||||
path = session_dir / CTE_PLAN_FILE
|
||||
if not path.exists():
|
||||
def cte_plan(source: Path | SessionSnapshot) -> list[str]:
|
||||
raw = _artifact(source, "cte_plan", CTE_PLAN_FILE)
|
||||
if raw is None:
|
||||
return []
|
||||
return json.loads(path.read_text())
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def approved_ctes(session_dir: Path) -> set[str]:
|
||||
def approved_ctes(source: Path | SessionSnapshot) -> set[str]:
|
||||
"""Insieme dei CTE approvati, dalla vista effective (esclude stale post-reopen)."""
|
||||
return {d.subject for d in effective_decisions(session_dir) if d.type == "cte_approved"}
|
||||
return {d.subject for d in effective_decisions(source) if d.type == "cte_approved"}
|
||||
|
||||
|
||||
def next_cte(session_dir: Path) -> str | None:
|
||||
approved = approved_ctes(session_dir)
|
||||
for name in cte_plan(session_dir):
|
||||
def next_cte(source: Path | SessionSnapshot) -> str | None:
|
||||
approved = approved_ctes(source)
|
||||
for name in cte_plan(source):
|
||||
if name not in approved:
|
||||
return name
|
||||
return None
|
||||
@@ -153,18 +164,18 @@ def next_cte(session_dir: Path) -> str | None:
|
||||
|
||||
# --- advance_problems (ladder if-phase-N che consulta effective_decisions) ---
|
||||
|
||||
def _has_decision(session_dir: Path, type_: str) -> bool:
|
||||
return any(d.type == type_ for d in effective_decisions(session_dir))
|
||||
def _has_decision(source: Path | SessionSnapshot, type_: str) -> bool:
|
||||
return any(d.type == type_ for d in effective_decisions(source))
|
||||
|
||||
|
||||
def _has_decision_subject(session_dir: Path, type_: str, subject: str) -> bool:
|
||||
def _has_decision_subject(source: Path | SessionSnapshot, type_: str, subject: str) -> bool:
|
||||
return any(
|
||||
d.type == type_ and d.subject == subject
|
||||
for d in effective_decisions(session_dir)
|
||||
for d in effective_decisions(source)
|
||||
)
|
||||
|
||||
|
||||
def advance_problems(session_dir: Path, phase: int) -> list[str]:
|
||||
def advance_problems(source: Path | SessionSnapshot, phase: int) -> list[str]:
|
||||
"""Prerequisiti minimi per chiudere `phase` (lista vuota = ok).
|
||||
|
||||
Ladder if-phase-N (Strada 2): la logica specifica resta, ma ogni lettura passa per
|
||||
@@ -172,19 +183,19 @@ def advance_problems(session_dir: Path, phase: int) -> list[str]:
|
||||
l'evaluator generico (F2 pieno) entra in un secondo momento.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
if phase == 3 and not _has_decision(session_dir, "question_rewritten"):
|
||||
if phase == 3 and not _has_decision(source, "question_rewritten"):
|
||||
problems.append("manca la decisione question_rewritten (Fase 3)")
|
||||
if phase == 5:
|
||||
path = session_dir / "schema_linking.json"
|
||||
if not path.exists():
|
||||
raw = _artifact(source, "schema_linking", "schema_linking.json")
|
||||
if raw is None:
|
||||
problems.append("schema_linking.json assente (Fase 5)")
|
||||
else:
|
||||
try:
|
||||
SchemaLinking.model_validate(json.loads(path.read_text()))
|
||||
SchemaLinking.model_validate(json.loads(raw))
|
||||
except (json.JSONDecodeError, ValidationError) as e:
|
||||
problems.append(f"schema_linking.json non valido (Fase 5): {e}")
|
||||
if phase == 6 and not _has_decision_subject(session_dir, "phase_skipped", "phase:6"):
|
||||
plan = cte_plan(session_dir)
|
||||
if phase == 6 and not _has_decision_subject(source, "phase_skipped", "phase:6"):
|
||||
plan = cte_plan(source)
|
||||
if not plan:
|
||||
problems.append(
|
||||
"Fase 6: nessun piano CTE (cte_plan.json) e nessun salto esplicito. "
|
||||
@@ -192,14 +203,14 @@ def advance_problems(session_dir: Path, phase: int) -> list[str]:
|
||||
"registrando una decisione phase_skipped subject phase:6."
|
||||
)
|
||||
else:
|
||||
nc = next_cte(session_dir)
|
||||
nc = next_cte(source)
|
||||
if nc is not None:
|
||||
problems.append(f"CTE non ancora approvato: {nc} (Fase 6)")
|
||||
if phase == 7 and not _has_decision(session_dir, "sql_approved"):
|
||||
if phase == 7 and not _has_decision(source, "sql_approved"):
|
||||
problems.append("manca la decisione sql_approved (Fase 7)")
|
||||
if phase == 8 and not any(
|
||||
d.type in ("datamart_requested", "datamart_declined")
|
||||
for d in effective_decisions(session_dir)
|
||||
for d in effective_decisions(source)
|
||||
):
|
||||
problems.append(
|
||||
"Fase 8: nessuna risposta sulla generazione dbt del datamart "
|
||||
|
||||
Reference in New Issue
Block a user