feat: add BLIF Flow v2 shadow compare cutover layer

This commit is contained in:
plx
2026-08-16 00:12:20 +00:00
parent 3b60c3fb70
commit 32e7773957
7 changed files with 417 additions and 24 deletions

View File

@@ -7,11 +7,14 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from collections import defaultdict
import json
import logging
from typing import Any, Dict, Iterable, Optional
from sqlalchemy import bindparam, text
from app.db import engine
from app.config import settings
# Backward-compatible static anchors from v1.5.59: quotation_doc, invoice_doc, confirmar pagamento antes de emitir fatura, Pagamento confirmado com base em, Criar/enviar fatura.
from app.domain.opportunity_flow import (
OpportunityEvidence,
@@ -21,6 +24,9 @@ from app.domain.opportunity_flow import (
)
logger = logging.getLogger(__name__)
@dataclass
class OpportunityNextAction:
action_code: str
@@ -37,6 +43,67 @@ class OpportunityNextAction:
return asdict(self)
def _flow_v2_mode() -> str:
return str(settings.blif_flow_v2_mode or "off").strip().lower()
def _load_v2_comparison_rows(opportunity_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Read the persisted projection only; comparison must never derive writes."""
if not opportunity_ids:
return {}
with engine.connect() as conn:
rows = conn.execute(text("""
SELECT opportunity_id::text, material_process_key,
canonical_opportunity_id::text, is_duplicate_representation,
business_state, business_next_action, diagnostic_status,
confidence, reason_code, reason_text, flow_version
FROM opportunity_flow_state_v2
WHERE opportunity_id::text IN :opportunity_ids
""").bindparams(bindparam("opportunity_ids", expanding=True)),
{"opportunity_ids": opportunity_ids}).mappings().all()
return {str(row["opportunity_id"]): dict(row) for row in rows}
def compare_v1_v2_decisions(
v1_decisions: Dict[str, Dict[str, Any]],
v2_rows: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
"""Return payload-safe structured comparisons without message content."""
comparisons = []
for opportunity_id, v1 in v1_decisions.items():
v2 = v2_rows.get(opportunity_id)
v1_action = str(v1.get("action_code") or "") or None
v2_action = (v2 or {}).get("business_next_action")
comparisons.append({
"opportunity_id": opportunity_id,
"material_process_key": (v2 or {}).get("material_process_key"),
"canonical_opportunity_id": (v2 or {}).get("canonical_opportunity_id"),
"is_duplicate_representation": bool((v2 or {}).get("is_duplicate_representation")),
"v1_action": v1_action,
"v1_reason_code": v1.get("reason_if_blocked") or v1.get("decision_version"),
"v2_business_state": (v2 or {}).get("business_state"),
"v2_business_next_action": v2_action,
"v2_reason_code": (v2 or {}).get("reason_code"),
"v2_diagnostic_status": (v2 or {}).get("diagnostic_status"),
"v2_confidence": (v2 or {}).get("confidence"),
"projection_present": v2 is not None,
"action_agrees": v2 is not None and v1_action == v2_action,
"returned_source": "v1",
})
return comparisons
def _observe_flow_v2(v1_decisions: Dict[str, Dict[str, Any]]) -> None:
mode = _flow_v2_mode()
if mode == "authoritative":
raise RuntimeError("BLIF Flow v2 authoritative mode is disabled; cutover boundary is fail-closed")
if mode != "compare" or not v1_decisions:
return
rows = _load_v2_comparison_rows(list(v1_decisions))
for comparison in compare_v1_v2_decisions(v1_decisions, rows):
logger.info("blif_flow_v2_compare %s", json.dumps(comparison, sort_keys=True, default=str))
def _first_row(conn: Any, sql: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
row = conn.execute(text(sql), params).mappings().first()
return dict(row) if row else None
@@ -160,6 +227,8 @@ def get_opportunity_next_action(
decision is now produced by the company workflow engine.
"""
if _flow_v2_mode() == "authoritative":
raise RuntimeError("BLIF Flow v2 authoritative mode is disabled; cutover boundary is fail-closed")
evidence = (_build_db_evidence(opportunity_id, preloaded=preloaded)
if preloaded is not None else _build_db_evidence(opportunity_id))
if evidence is None:
@@ -172,8 +241,9 @@ def get_opportunity_next_action(
reason_if_blocked="opportunity_not_found",
).to_dict()
decision = decide_opportunity_next_action(evidence, load_company_profile(evidence.company_profile))
return decision.to_dict()
decision = decide_opportunity_next_action(evidence, load_company_profile(evidence.company_profile)).to_dict()
_observe_flow_v2({opportunity_id: decision})
return decision
def _bulk_statement(sql: str):
@@ -298,6 +368,8 @@ def _bulk_operation_snapshots(
def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]:
"""Return the same decisions as the single-item API with a fixed query count."""
if _flow_v2_mode() == "authoritative":
raise RuntimeError("BLIF Flow v2 authoritative mode is disabled; cutover boundary is fail-closed")
ids = list(dict.fromkeys(str(value).strip() for value in opportunity_ids if str(value).strip()))
if not ids:
return {}
@@ -376,4 +448,5 @@ def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Di
company_profile="blif",
)
decisions[oid] = decide_opportunity_next_action(evidence, profile).to_dict()
_observe_flow_v2(decisions)
return decisions