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

@@ -1544,20 +1544,14 @@ def _parse_opportunity_dt(value: object):
def _opportunity_lifecycle_state(opp: dict) -> str:
state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active"
now = datetime.now(timezone.utc)
pending_call = str(opp.get("pending_follow_up_action_code") or "").strip().upper() == "CALL_CUSTOMER"
pending_call_due = _parse_opportunity_dt(opp.get("pending_follow_up_due_at"))
if pending_call:
return "follow_up_due" if pending_call_due and pending_call_due <= now else "scheduled_follow_up"
# Compatibility only: a stale denormalized lifecycle marker cannot invent
# a scheduled call when no active CALL_CUSTOMER task exists.
if state == "scheduled_follow_up":
pending_followup = str(opp.get("pending_follow_up_action_code") or "").strip().upper()
pending_due = _parse_opportunity_dt(opp.get("pending_follow_up_due_at"))
if pending_followup:
return "follow_up_due" if pending_due and pending_due <= now else "scheduled_follow_up"
# Compatibility-only timestamps/lifecycle markers cannot create work. An
# active pending follow-up task and its due_at are the operational source.
if state in {"scheduled_follow_up", "follow_up_due"}:
return "active"
nurture_until = _parse_opportunity_dt(opp.get("nurture_until"))
next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at"))
if state == "nurture" and nurture_until and nurture_until <= now:
return "follow_up_due"
if state in {"awaiting_customer", "active", "scheduled_follow_up"} and next_follow_up and next_follow_up <= now:
return "follow_up_due"
return state

View File

@@ -102,14 +102,15 @@ def rebuild_blif_flow_v2_projection(
) -> dict[str, Any]:
"""Idempotently rebuild projection rows and state-change transitions.
``off`` is a no-op. ``shadow`` writes only additive projection tables.
Compare/authoritative behavior is deliberately not implemented.
``off`` is a no-op. ``shadow`` and ``compare`` write only the same additive
projection tables. Compare affects observation at the read boundary, never
the persisted business rows. Authoritative remains deliberately disabled.
"""
selected_mode = str(mode or settings.blif_flow_v2_mode or "off").strip().lower()
if selected_mode == "off":
return {"mode": "off", "projection_count": 0, "transitions_written": 0, "disabled": True}
if selected_mode != "shadow":
raise RuntimeError(f"BLIF Flow v2 mode {selected_mode!r} is not implemented; only off/shadow are safe")
if selected_mode not in {"shadow", "compare"}:
raise RuntimeError(f"BLIF Flow v2 mode {selected_mode!r} is disabled; only off/shadow/compare are safe")
if not re.fullmatch(r"[a-z_][a-z0-9_]*", target_schema):
raise ValueError("invalid target_schema")
@@ -208,7 +209,7 @@ def rebuild_blif_flow_v2_projection(
conn.close()
return {
"mode": "shadow", "projection_count": len(values),
"mode": selected_mode, "projection_count": len(values),
"canonical_count": sum(not value["is_duplicate_representation"] for value in values),
"duplicate_count": sum(value["is_duplicate_representation"] for value in values),
"transitions_written": transitions_written, "disabled": False,

View File

@@ -21,8 +21,9 @@ class Settings(BaseSettings):
# TIMESTAMPTZ nem os índices usados pelo schema core.
database_url: str
clientflow_persist: bool = True
# Flow v2 is additive shadow scaffolding only. Compare/authoritative values
# are reserved for future work and are not activated by this implementation.
# Flow v2 remains non-authoritative. Compare observes V1/V2 differences
# while returning V1; authoritative is accepted by config only to fail
# closed at the switching boundary.
blif_flow_v2_mode: Literal["off", "shadow", "compare", "authoritative"] = "off"
clientflow_webhook_secret: str = ""

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