fix: harden Flow v2 authoritative cutover semantics

This commit is contained in:
plx
2026-08-16 01:38:39 +00:00
parent ac385342f5
commit 213ba6b41b
7 changed files with 211 additions and 30 deletions

View File

@@ -48,7 +48,8 @@ class AuthoritativeOperationalDecision:
"reason_if_blocked": self.reason_code if not self.eligible else None,
"operational_queue": self.queue,
"authoritative_v2": True,
"suppress_current_card": self.queue == "not_current",
"is_duplicate_representation": self.reason_code == "DUPLICATE_SUPPRESSED",
"suppress_current_card": self.reason_code == "DUPLICATE_SUPPRESSED",
})
return result
@@ -78,7 +79,8 @@ def _active_obligations(obligations: Iterable[Mapping[str, Any]]) -> list[Mappin
def _ref(row: Mapping[str, Any]) -> dict[str, Any]:
return {"source": "task", "id": str(row.get("id") or ""), "status": "pending",
"action_code": _code(row.get("action_code"))}
"action_code": _code(row.get("action_code")),
"source_system": str(row.get("source_system") or "")}
def decide_authoritative_operation(
@@ -110,6 +112,10 @@ def decide_authoritative_operation(
oid, canonical, state, business_action, hard_blocker, "blocked", True,
"HARD_FACTUAL_BLOCKER", "Um bloqueio factual ou de sistema impede a ação atual.", hard_blocker, confidence=confidence,
)
# Terminal factual state cannot be reopened by a leftover operational task.
if state in TERMINAL_STATES:
return AuthoritativeOperationalDecision(oid, canonical, state, business_action, None, "not_current", False,
"TERMINAL_BUSINESS_STATE", "O processo factual está concluído.", confidence=confidence)
active = _active_obligations(obligations)
by_code: dict[str, list[Mapping[str, Any]]] = {}
@@ -143,7 +149,8 @@ def decide_authoritative_operation(
picked = choose(("REVIEW_MANUALLY", "REVIEW_REQUIRED", "REVIEW", "REVIEW_RECONSTRUCTED_PROCESS"), "review", "ACTIVE_REVIEW_OBLIGATION")
picked = picked or choose(("SUPPORT",), "do_now", "ACTIVE_SUPPORT_OBLIGATION")
picked = picked or choose(("SEND_INFO",), "do_now", "ACTIVE_RESPONSE_OBLIGATION")
if state == "INQUIRY" or business_action == "SEND_INFO":
picked = picked or choose(("SEND_INFO",), "do_now", "ACTIVE_RESPONSE_OBLIGATION")
picked = picked or choose(("CALL_CUSTOMER",), "do_now", "EXPLICIT_CALL_OBLIGATION")
if picked:
return picked
@@ -168,9 +175,6 @@ def decide_authoritative_operation(
obligation_source_refs=tuple(_ref(row) for row in rows), confidence=confidence,
)
if state in TERMINAL_STATES:
return AuthoritativeOperationalDecision(oid, canonical, state, business_action, None, "not_current", False,
"TERMINAL_BUSINESS_STATE", "O processo factual está concluído.", confidence=confidence)
if business_action:
queue = "review" if business_action in REVIEW_ACTIONS else "do_now"
return AuthoritativeOperationalDecision(oid, canonical, state, business_action, business_action, queue, True,

View File

@@ -408,6 +408,7 @@ def _opportunity_item(
"business_next_action": decision.get("business_next_action"),
"effective_action": decision.get("effective_action"),
"canonical_opportunity_id": decision.get("canonical_opportunity_id") or opportunity_id,
"material_process_key": decision.get("material_process_key"),
})
return item, independent_exceptions

View File

@@ -38,6 +38,53 @@ def _int(value: Any) -> int:
return 0
def _authoritative_material_identity(item: Dict[str, Any]) -> str:
return str(item.get("material_process_key") or item.get("canonical_opportunity_id")
or item.get("process_key") or item.get("work_item_key") or item.get("id"))
def _collapse_authoritative_material_processes(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Enforce one current card per material process without deleting evidence."""
current_queues = {"do_now", "review", "blocked", "exception"}
action_rank = {
"REVIEW_MANUALLY": 10, "REVIEW_RECONSTRUCTED_PROCESS": 11, "REVIEW_REQUIRED": 12,
"SUPPORT": 20, "SEND_INFO": 30, "CALL_CUSTOMER": 40,
"FOLLOW_UP_CUSTOMER_REVIEW": 50, "FOLLOW_UP_PAYMENT": 51,
}
groups: Dict[str, List[Dict[str, Any]]] = {}
noncurrent: List[Dict[str, Any]] = []
for item in items:
if item.get("operational_queue") not in current_queues:
noncurrent.append(item)
continue
groups.setdefault(_authoritative_material_identity(item), []).append(item)
selected: List[Dict[str, Any]] = []
for identity, group in groups.items():
winner = min(group, key=lambda row: (
0 if row.get("operational_queue") in {"blocked", "exception"} else 1,
action_rank.get(str(row.get("action_code") or ""), 100),
str(row.get("work_item_key") or ""),
))
merged = dict(winner)
if len(group) > 1:
merged["suppressed_competing_actions"] = [
{"work_item_key": row.get("work_item_key"), "action_code": row.get("action_code"),
"source_refs": row.get("source_refs") or []}
for row in group if row is not winner
]
refs, seen = list(merged.get("source_refs") or []), set()
for ref in refs:
seen.add((str(ref.get("source")), str(ref.get("id"))))
for row in group:
for ref in row.get("source_refs") or []:
key = (str(ref.get("source")), str(ref.get("id")))
if key not in seen:
refs.append(ref); seen.add(key)
merged["source_refs"] = refs
selected.append(merged)
return selected + noncurrent
def _is_manually_resolved_error(value: Any) -> bool:
text_value = str(value or "").strip().casefold()
return "limpo manualmente" in text_value or "resolvido manualmente" in text_value
@@ -722,6 +769,8 @@ def get_operations_summary(
) if opportunity_ids else {})
projection = canonicalize_operations(normalized_rows, decisions, evidence_rows=[dict(row) for row in evidence_rows])
canonical_items = _attach_operation_urls(list(projection["items"]))
if active_flow_mode == "authoritative":
canonical_items = _collapse_authoritative_material_processes(canonical_items)
partition = partition_canonical_items(canonical_items, display_limit=display_limit)
actionable_items = partition["actionable_items"]
all_waiting_items = partition["waiting_items"]
@@ -750,7 +799,7 @@ def get_operations_summary(
"backlog_total": partition["backlog_total"],
"not_current_total": partition["not_current_total"],
}
return {
result = {
"counts": cleaned_counts,
"recent_outbox": [dict(r) for r in recent_outbox],
"recent_documents": [dict(r) for r in recent_documents],

View File

@@ -481,8 +481,8 @@ def _get_authoritative_opportunity_decisions(ids: list[str], *, connection: Any
FROM opportunity_flow_state_v2 WHERE opportunity_id::text IN :opportunity_ids
""", ids)
tasks = _bulk_rows(conn, """
SELECT id::text, opportunity_id::text, action_code, status, due_at,
resolved_at, superseded_by_task_id::text, created_at, metadata
SELECT id::text, opportunity_id::text, action_code, status, due_at, source_system,
resolved_at, resolution_code, superseded_by_task_id::text, created_at, metadata
FROM tasks WHERE opportunity_id::text IN :opportunity_ids AND status = 'pending'
""", ids)
customers = _bulk_rows(conn, """
@@ -514,10 +514,31 @@ def _get_authoritative_opportunity_decisions(ids: list[str], *, connection: Any
reconciliation_blocking=oid in reconciliation_ids,
)
value = decision.to_dict()
winning_ids = {str(ref.get("id") or "") for ref in value.get("obligation_source_refs") or []}
obligation_audit = []
for task in tasks_by_id.get(oid, []):
task_id = str(task.get("id") or "")
if task_id in winning_ids:
classification = "VALID_ACTIVE_OBLIGATION"
elif task.get("superseded_by_task_id"):
classification = "SUPERSEDED_OBLIGATION"
elif task.get("resolved_at") or task.get("resolution_code"):
classification = "SATISFIED_OBLIGATION"
elif ((task_code := str(task.get("action_code") or "").upper()).startswith("SEND_")
and task_code != "SEND_INFO") or task_code == "CREATE_JASMIN_QUOTE":
classification = "PREMATURE_OR_STALE_OBLIGATION"
else:
classification = "STALE_LEGACY_OBLIGATION"
obligation_audit.append({
"id": task_id, "action_code": task.get("action_code"), "status": task.get("status"),
"source_system": task.get("source_system"), "classification": classification,
})
value["obligation_audit_refs"] = obligation_audit
if projection is None:
value["opportunity_id"] = oid
value["target_url"] = f"/opportunities/{oid}"
else:
value["material_process_key"] = projection.get("material_process_key")
value["target_url"] = f"/opportunities/{decision.canonical_opportunity_id or oid}"
decisions[oid] = value
return decisions