fix(evidence): preserve legacy formula migration boundaries
This commit is contained in:
@@ -8,11 +8,12 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from tht.evidence.canonical import CuratedEvidence
|
||||
|
||||
@@ -56,6 +57,16 @@ class ConceptFormula(BaseModel):
|
||||
return cls.model_validate({**meta, "sql": body.strip("\n")})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyFormulaMigrationFailure:
|
||||
"""A reviewed legacy formula that must be resolved manually before publication."""
|
||||
|
||||
code: Literal["legacy_formula_requires_manual_review"]
|
||||
legacy_path: str
|
||||
formula: ConceptFormula
|
||||
problems: tuple[str, ...]
|
||||
|
||||
|
||||
def _next_path(root: Path, slug: str) -> Path:
|
||||
"""First free <slug>-<n>.sql.md path under root (n starts at 1)."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -110,7 +121,7 @@ def legacy_formula_to_curated(
|
||||
formula: ConceptFormula,
|
||||
*,
|
||||
legacy_path: str,
|
||||
) -> CuratedEvidence | None:
|
||||
) -> CuratedEvidence | LegacyFormulaMigrationFailure | None:
|
||||
"""Convert one reviewed legacy formula into its deterministic curated counterpart.
|
||||
|
||||
Drafts and model-generated formulas have no global publication status. Their
|
||||
@@ -123,23 +134,38 @@ def legacy_formula_to_curated(
|
||||
)
|
||||
source_sha256 = hashlib.sha256(formula.dump().encode("utf-8")).hexdigest()
|
||||
source_file = legacy_path if legacy_path.startswith("source/") else f"source/{legacy_path}"
|
||||
return CuratedEvidence.model_validate({
|
||||
"schema_version": 1,
|
||||
"id": f"evidence:{formula._slug}",
|
||||
"title": formula.concept[:1].upper() + formula.concept[1:],
|
||||
"kind": "formula",
|
||||
"purposes": ["schema_linking", "sql_generation"],
|
||||
"applies_to": {"concepts": [formula.concept], "columns": formula.columns},
|
||||
"language": "it",
|
||||
"provenance": {
|
||||
"source_file": source_file,
|
||||
"source_sha256": f"sha256:{source_sha256}",
|
||||
"supporting_excerpts": source_notes,
|
||||
},
|
||||
"review_items": [],
|
||||
"payload": {
|
||||
"concept": formula.concept,
|
||||
"columns": formula.columns,
|
||||
"sql": formula.sql,
|
||||
},
|
||||
})
|
||||
# The legacy path is the immutable identity of this unit during migration. Keeping
|
||||
# its full digest avoids a duplicate public ID when the same concept has reviewed
|
||||
# competing formulas, while retaining the readable concept slug as the prefix.
|
||||
legacy_identity = hashlib.sha256(legacy_path.encode("utf-8")).hexdigest()
|
||||
try:
|
||||
return CuratedEvidence.model_validate({
|
||||
"schema_version": 1,
|
||||
"id": f"evidence:{formula._slug}-{legacy_identity}",
|
||||
"title": formula.concept[:1].upper() + formula.concept[1:],
|
||||
"kind": "formula",
|
||||
"purposes": ["schema_linking", "sql_generation"],
|
||||
"applies_to": {"concepts": [formula.concept], "columns": formula.columns},
|
||||
"language": "it",
|
||||
"provenance": {
|
||||
"source_file": source_file,
|
||||
"source_sha256": f"sha256:{source_sha256}",
|
||||
"supporting_excerpts": source_notes,
|
||||
},
|
||||
"review_items": [],
|
||||
"payload": {
|
||||
"concept": formula.concept,
|
||||
"columns": formula.columns,
|
||||
"sql": formula.sql,
|
||||
},
|
||||
})
|
||||
except ValidationError as error:
|
||||
return LegacyFormulaMigrationFailure(
|
||||
code="legacy_formula_requires_manual_review",
|
||||
legacy_path=legacy_path,
|
||||
formula=formula,
|
||||
problems=tuple(sorted(
|
||||
".".join(str(part) for part in issue["loc"])
|
||||
for issue in error.errors()
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -64,11 +64,17 @@ def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinkin
|
||||
A proposal remains in the persisted session artifact until a separate curator
|
||||
imports, reviews, and publishes it in the workspace repository.
|
||||
"""
|
||||
approved = {
|
||||
decision.detail: decision.seq
|
||||
retracted = {
|
||||
decision.retracts
|
||||
for decision in decisions
|
||||
if decision.type == "concept_formula_approved" and decision.detail
|
||||
if decision.type == "decision_retracted" and decision.retracts is not None
|
||||
}
|
||||
latest_f4_decision = {}
|
||||
for decision in sorted(decisions, key=lambda item: item.seq):
|
||||
if decision.seq in retracted or decision.subject != "phase:4" or not decision.detail:
|
||||
continue
|
||||
if decision.type in {"concept_formula_approved", "concept_formula_rejected"}:
|
||||
latest_f4_decision[decision.detail] = decision
|
||||
proposals = []
|
||||
for formula in linking.concept_formulas:
|
||||
if not isinstance(formula, dict):
|
||||
@@ -83,6 +89,9 @@ def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinkin
|
||||
evidence_id = formula.get("evidence_id", formula.get("id", ""))
|
||||
if isinstance(evidence_id, str) and evidence_id.startswith("evidence:"):
|
||||
continue
|
||||
decision = latest_f4_decision.get(concept)
|
||||
if decision is None or decision.type != "concept_formula_approved":
|
||||
continue
|
||||
proposals.append({
|
||||
"schema_version": 1,
|
||||
"kind": "formula_proposal",
|
||||
@@ -91,7 +100,7 @@ def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinkin
|
||||
"columns": columns,
|
||||
"sql": sql,
|
||||
"sources": formula.get("sources", []),
|
||||
"decision_seq": approved.get(concept),
|
||||
"decision_seq": decision.seq,
|
||||
})
|
||||
return proposals
|
||||
|
||||
|
||||
Reference in New Issue
Block a user