feat: add BLIF Flow v2 shadow compare cutover layer
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user