← Files VeraARCHIVED FILE
modules/bilancio-xbrl-it/scripts/client_history.py
6.64 KB · Oct 5, 2026 · 18:29 UTC
#!/usr/bin/env python3
"""Tenant-isolated reuse of approved client knowledge as suggestions only."""
from __future__ import annotations
import hashlib
import json
import tempfile
from pathlib import Path
from typing import Any, Mapping
__all__ = ["client_history_suggestions", "remember_approved_client_history"]
SCHEMA_VERSION = 1
def _canonical_hash(value: Any) -> str:
encoded = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _client_key(case: Mapping[str, Any]) -> str:
stable = str(case["entity"].get("client_id") or case["entity"]["tax_identifier"])
material = f"{case['tenant_id']}\x00{stable}".encode("utf-8")
return hashlib.sha256(material).hexdigest()
def _read_store(path: Path, tenant_id: str) -> dict[str, Any]:
if path.is_symlink():
raise ValueError("Client history must not use a symbolic link")
if not path.exists():
return {
"schema_version": SCHEMA_VERSION,
"tenant_id": tenant_id,
"clients": [],
}
if not path.is_file():
raise ValueError("Client history must be a regular JSON file")
store = json.loads(path.read_text(encoding="utf-8"))
if store.get("schema_version") != SCHEMA_VERSION:
raise ValueError("Unsupported client-history schema version")
if store.get("tenant_id") != tenant_id:
raise ValueError("Cross-tenant client-history access is forbidden")
if not isinstance(store.get("clients"), list):
raise ValueError("Client-history clients must be an array")
return store
def client_history_suggestions(
case: Mapping[str, Any], history_path: Path
) -> dict[str, Any]:
"""Return the most recent earlier approved history as unconfirmed suggestions."""
store = _read_store(history_path.resolve(), str(case["tenant_id"]))
current_end = str(case["period"]["end"])
candidates = [
item
for item in store["clients"]
if item.get("client_key") == _client_key(case)
and str(item.get("period_end", "")) < current_end
]
if not candidates:
return {
"source": "APPROVED_CLIENT_HISTORY",
"prior_period_end": None,
"form_suggestion": None,
"answer_suggestions": [],
"narrative_suggestions": [],
"recurring_evidence_suggestions": [],
}
prior = max(candidates, key=lambda item: str(item["period_end"]))
snapshot_ref = f"approved_snapshot:{prior['snapshot_hash']}"
return {
"source": "APPROVED_CLIENT_HISTORY",
"prior_period_end": prior["period_end"],
"form_suggestion": {
"selected_form": prior["selected_form"],
"status": "UNCONFIRMED_PRIOR_SUGGESTION",
"source_ref": snapshot_ref,
},
"answer_suggestions": [
{
"key": item["key"],
"value": item.get("value"),
"prior_status": item["prior_status"],
"status": "UNCONFIRMED_PRIOR_SUGGESTION",
"requires_reconfirmation": True,
"source_refs": [snapshot_ref],
}
for item in prior["answers"]
],
"narrative_suggestions": [
{
"suggestion_id": (
f"client_history_{prior['period_end']}_{item['block_id']}"
),
"section_id": item["section_id"],
"text": item["text"],
"status": "UNCONFIRMED_STALE_SUGGESTION",
"requires_redline": True,
"source_refs": [snapshot_ref],
}
for item in prior["narratives"]
],
"recurring_evidence_suggestions": [
{
"rule_id": rule_id,
"status": "PRIOR_PERIOD_TRIGGER_ONLY",
"requires_current_period_evaluation": True,
"source_refs": [snapshot_ref],
}
for rule_id in prior["triggered_rule_ids"]
],
}
def remember_approved_client_history(
case: Mapping[str, Any], history_path: Path, actor: str
) -> dict[str, Any]:
"""Persist reviewed form, answers, and narrative without current amounts."""
approval = case.get("approval")
if not approval or case.get("state") not in {"APPROVED", "EXPORTED"}:
raise ValueError("Only approved cases may enter client history")
if _canonical_hash(approval.get("snapshot")) != approval.get("snapshot_hash"):
raise ValueError("Approved client-history snapshot hash is invalid")
target = history_path.resolve()
store = _read_store(target, str(case["tenant_id"]))
snapshot = approval["snapshot"]
record = {
"client_key": _client_key(case),
"period_end": snapshot["period"]["end"],
"selected_form": snapshot["selected_form"],
"answers": [
{
"key": item["key"],
"value": item.get("value"),
"prior_status": item["status"],
}
for item in snapshot.get("disclosure_answers", [])
if item.get("status") in {"ACCEPTED", "NOT_APPLICABLE_CONFIRMED"}
],
"narratives": [
{
"block_id": item["block_id"],
"section_id": item["section_id"],
"text": item["text"],
}
for item in snapshot.get("narrative_blocks", [])
if item.get("status") == "ACCEPTED"
],
"triggered_rule_ids": [
item["rule_id"]
for item in (snapshot.get("disclosure_coverage") or {}).get("coverage", [])
if item.get("triggered")
],
"snapshot_hash": approval["snapshot_hash"],
"remembered_by": actor,
}
store["clients"] = [
item
for item in store["clients"]
if not (
item.get("client_key") == record["client_key"]
and item.get("period_end") == record["period_end"]
)
] + [record]
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink():
raise ValueError("Refusing to write client history through a symbolic link")
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
prefix=f".{target.name}.",
suffix=".tmp",
dir=target.parent,
delete=False,
) as handle:
handle.write(
json.dumps(store, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ "\n"
)
temporary = Path(handle.name)
temporary.replace(target)
return {
"remembered_period_end": record["period_end"],
"history_file_name": target.name,
}
SHA-256: 44577069791d4cefe0d2b2b6b2812f081ce963843c4434f8a2e8fe16a11f9a60