139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
"""Evidence-specific projection into persisted session artifacts."""
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from tht.evidence.corpus.store import CorpusStore
|
|
from tht.evidence.search import resolve_citation
|
|
|
|
if TYPE_CHECKING:
|
|
from tht.decisions import DecisionRecord
|
|
from tht.session.models import SchemaLinking
|
|
|
|
|
|
def _resolve_session_citation(evidence_root: Path, evidence_id: str) -> str:
|
|
# New deployments resolve only immutable materialized files from ACTIVE. Keep the
|
|
# curated-tree fallback for sessions created before a canonical corpus exists.
|
|
corpus_root = evidence_root.parent.parent / "corpus"
|
|
if corpus_root.exists():
|
|
return resolve_citation(
|
|
CorpusStore(corpus_root),
|
|
evidence_id,
|
|
materialized_root=evidence_root.parent / ".materialized-evidence",
|
|
)
|
|
for match in evidence_root.rglob(f"{evidence_id}.md"):
|
|
return str(match)
|
|
return ""
|
|
|
|
|
|
def project_session(
|
|
decisions: list["DecisionRecord"],
|
|
linking: "SchemaLinking",
|
|
evidence_root: Path,
|
|
) -> list[dict]:
|
|
"""Project cited and reviewed Evidence into the session artifact shape."""
|
|
entries: dict[str, dict] = {}
|
|
for candidate in linking.candidates:
|
|
for evidence_id in candidate.evidence:
|
|
entries.setdefault(
|
|
evidence_id,
|
|
{
|
|
"id": evidence_id,
|
|
"file": _resolve_session_citation(evidence_root, evidence_id),
|
|
"esito": "usata",
|
|
"decision_seq": candidate.decision_seq,
|
|
},
|
|
)
|
|
for decision in decisions:
|
|
if decision.type not in ("evidence_accepted", "evidence_rejected"):
|
|
continue
|
|
entries[decision.subject] = {
|
|
"id": decision.subject,
|
|
"file": _resolve_session_citation(evidence_root, decision.subject),
|
|
"esito": "accettata" if decision.type == "evidence_accepted" else "scartata",
|
|
"decision_seq": decision.seq,
|
|
}
|
|
return list(entries.values()) + _formula_proposals(decisions, linking)
|
|
|
|
|
|
def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinking") -> list[dict]:
|
|
"""Project locally approved F4 formulas without representing them as Evidence.
|
|
|
|
A proposal remains in the persisted session artifact until a separate curator
|
|
imports, reviews, and publishes it in the workspace repository.
|
|
"""
|
|
retracted = {
|
|
decision.retracts
|
|
for decision in decisions
|
|
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):
|
|
continue
|
|
concept = formula.get("concept")
|
|
sql = formula.get("sql")
|
|
columns = formula.get("columns")
|
|
if not isinstance(concept, str) or not isinstance(sql, str) or not isinstance(columns, list):
|
|
continue
|
|
# A referenced published Formula Evidence is already represented by its
|
|
# Evidence receipt/citation, so it must not be reintroduced as a proposal.
|
|
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",
|
|
"publication": "session_only",
|
|
"concept": concept,
|
|
"columns": columns,
|
|
"sql": sql,
|
|
"sources": formula.get("sources", []),
|
|
"decision_seq": decision.seq,
|
|
})
|
|
return proposals
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvidenceReceipt:
|
|
stage: str
|
|
purpose: str
|
|
vector_generation: str
|
|
evidence_ids: tuple[str, ...]
|
|
|
|
def payload(self) -> dict:
|
|
return {
|
|
"stage": self.stage,
|
|
"purpose": self.purpose,
|
|
"vector_generation": self.vector_generation,
|
|
"evidence_ids": list(self.evidence_ids),
|
|
}
|
|
|
|
|
|
def replace_evidence_receipt(repository, session_id: str, receipt: EvidenceReceipt) -> None:
|
|
"""Replace the one minimal receipt for a semantic stage; preserve other stages."""
|
|
current = repository.read_artifact(session_id, "evidence_receipts")
|
|
try:
|
|
receipts = json.loads(current) if current else []
|
|
except json.JSONDecodeError as error:
|
|
raise ValueError("Evidence receipts artifact is malformed") from error
|
|
if not isinstance(receipts, list):
|
|
raise TypeError("Evidence receipts artifact is malformed")
|
|
replaced = [item for item in receipts if isinstance(item, dict) and item.get("stage") != receipt.stage]
|
|
replaced.append(receipt.payload())
|
|
repository.write_artifact(session_id, "evidence_receipts", json.dumps(replaced, ensure_ascii=False, indent=2) + "\n")
|
|
|
|
|
|
__all__ = ["EvidenceReceipt", "project_session", "replace_evidence_receipt"]
|