bugfix: route stickness precedences (transaction in the same intent)

This commit is contained in:
2026-08-20 09:22:03 -03:00
parent bb0ef019bf
commit 9df2467deb
434 changed files with 7098 additions and 281 deletions

View File

@@ -26,104 +26,11 @@ from .checkpoint_repository import create_checkpoint_repository
def _jsonable(value: Any) -> Any:
"""Convert a value to a JSON-safe tree without collapsing containers to strings.
The previous implementation used ``json.dumps(..., default=str)`` only as a
probe and then returned the *original* object. Because ``default=str`` makes
virtually every object serializable, the fallback branch was never reached.
JSON repositories could therefore stringify arbitrary nested LangGraph objects
later, and restored checkpoints would contain strings where mappings were
required (for example ``metadata``, ``versions_seen`` or ``checkpoint_map``).
Keep dict/list structure durable and stringify only unsupported leaf objects.
"""
if value is None or isinstance(value, (str, int, float, bool)):
try:
json.dumps(value, default=str)
return value
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_jsonable(v) for v in value]
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return _jsonable(model_dump(mode="python"))
except TypeError:
return _jsonable(model_dump())
except Exception:
pass
# Unsupported runtime objects are not durable. At this point the object is a
# leaf; converting only the leaf to text cannot destroy an enclosing mapping.
return str(value)
def _mapping(value: Any, *, default: dict[str, Any] | None = None) -> dict[str, Any]:
"""Recover a mapping from native or legacy JSON-string values.
Legacy rows written with permissive ``default=str`` serializers may contain a
JSON object encoded as text. Parse that representation when possible; if the
value is an opaque string, return a safe empty/default mapping instead of
letting LangGraph fail with ``'str' object has no attribute 'items'``.
"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
decoded = json.loads(value)
except Exception:
decoded = None
if isinstance(decoded, dict):
return decoded
return dict(default or {})
def _normalize_checkpoint(checkpoint: Any) -> dict[str, Any]:
"""Normalize LangGraph checkpoint mapping fields after JSON persistence."""
cp = _mapping(checkpoint)
if not cp:
return {}
cp = dict(cp)
cp["channel_values"] = _mapping(cp.get("channel_values"))
cp["channel_versions"] = _mapping(cp.get("channel_versions"))
versions_seen = _mapping(cp.get("versions_seen"))
cp["versions_seen"] = {
str(node): _mapping(versions) for node, versions in versions_seen.items()
}
pending_sends = cp.get("pending_sends")
if pending_sends is None:
cp["pending_sends"] = []
elif not isinstance(pending_sends, list):
cp["pending_sends"] = [pending_sends]
return cp
def _durable_config(config: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return the persistable subset of a LangGraph RunnableConfig.
LangGraph injects private execution objects under ``configurable`` (notably
``__pregel_runtime``). Those objects are valid only for the current graph
invocation and must never be persisted by a checkpointer. JSON-backed
repositories stringify them; on restore LangGraph then sees that string as
its runtime object and calls ``.override(...)``, producing errors such as
``AttributeError: 'str' object has no attribute 'override'`` before the first
workflow node runs.
Durable checkpoint identity lives in the normal configurable keys
(thread_id/checkpoint_ns/checkpoint_id). User-defined configurable values are
preserved; only LangGraph's private ``__pregel_*`` execution keys are removed.
"""
if config is None:
return None
cleaned = dict(_mapping(config))
configurable = dict(_mapping(cleaned.get("configurable")))
for key in list(configurable):
if str(key).startswith("__pregel_"):
configurable.pop(key, None)
cleaned["configurable"] = configurable
return cleaned
except TypeError:
return json.loads(json.dumps(value, default=str))
def _thread_id(config: dict[str, Any] | None) -> str:
@@ -178,13 +85,6 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
"""Checkpoint saver nativo para LangGraph usando os repositories do framework."""
def __init__(self, settings, repository=None):
# BaseCheckpointSaver initializes LangGraph serializer state in current
# checkpoint releases. Keep compatibility with the lightweight fallback
# class used by framework-only unit tests.
try:
super().__init__()
except TypeError:
pass
self.settings = settings
self.repository = repository or create_checkpoint_repository(settings)
self._loop: asyncio.AbstractEventLoop | None = None
@@ -203,10 +103,10 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
def _make_tuple(self, payload: dict[str, Any] | None):
if not payload:
return None
config = _durable_config(payload.get("config")) or {"configurable": {"thread_id": payload.get("thread_id")}}
checkpoint = _normalize_checkpoint(payload.get("checkpoint"))
metadata = _mapping(payload.get("metadata"))
parent_config = _durable_config(payload.get("parent_config"))
config = payload.get("config") or {"configurable": {"thread_id": payload.get("thread_id")}}
checkpoint = payload.get("checkpoint") or {}
metadata = payload.get("metadata") or {}
parent_config = payload.get("parent_config")
pending_writes = _normalize_pending_writes(payload.get("pending_writes") or [])
try:
from langgraph.checkpoint.base import CheckpointTuple
@@ -229,13 +129,11 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
async def aput(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
thread_id = _thread_id(config)
checkpoint_id = _checkpoint_id(checkpoint)
base_config = _durable_config(config) or {}
next_config = {
**base_config,
**(config or {}),
"configurable": {
**_mapping(base_config.get("configurable")),
**((config or {}).get("configurable") or {}),
"thread_id": thread_id,
"checkpoint_ns": str(_mapping(base_config.get("configurable")).get("checkpoint_ns") or ""),
"checkpoint_id": checkpoint_id,
},
}

View File

@@ -129,6 +129,8 @@ def _is_vas_section_name(section_name: str) -> bool:
or "servicos de valor adicionado" in normalized
or "servicos valor adicionado" in normalized
or "sva detalhe total" in normalized
or "servicos contratados de parceiros" in normalized
or "servico contratado de parceiro" in normalized
)
@@ -185,13 +187,23 @@ def _extract_contestation_invoice_items(
"validatedAmount",
)
if candidate_name and candidate_amount is not None and candidate_amount > 0:
payload_type = str(payload.get("type") or payload.get("tipo") or "").strip()
payload_desc = str(payload.get("desc") or "").strip()
classe = str(payload.get("classe", "")).strip().lower()
is_vas = (
_is_vas_section_name(section_name)
or _is_vas_section_name(payload_type)
or classe in {"avulso", "estrategico"}
)
found.append(
{
"name": candidate_name,
"amount": _money(candidate_amount),
"is_vas": _is_vas_section_name(section_name),
"is_vas": is_vas,
"section": section_name,
"classe": str(payload.get("classe", "")).strip().lower(),
"source_type": payload_type,
"source_desc": payload_desc,
"classe": classe,
"estrategico": bool(payload.get("estrategico")),
"verb": str(payload.get("verb", "")).strip().lower(),
}
@@ -397,14 +409,25 @@ def validate_contestation_items(
"vas_estrategico": False,
"status": "em_validacao",
}
matched_candidate = next(
(
candidate
for candidate in candidates
if _is_same_plan_name(candidate.get("name", ""), item_name)
),
None,
matching_candidates = [
candidate
for candidate in candidates
if _is_same_plan_name(candidate.get("name", ""), item_name)
]
# A mesma cobrança pode aparecer em múltiplas visões da fatura.
# Prefira a evidência que traz classificação explícita de VAS em vez
# de aceitar a primeira ocorrência genérica e concluir incorretamente
# que o item está fora da seção VAS.
matching_candidates.sort(
key=lambda candidate: (
0 if (
str(candidate.get("classe", "")).strip().lower() in {"avulso", "estrategico"}
or bool(candidate.get("is_vas"))
) else 1,
0 if _normalize_match_text(candidate.get("name", "")) == _normalize_match_text(item_name) else 1,
)
)
matched_candidate = matching_candidates[0] if matching_candidates else None
if matched_candidate is None:
_record_failure(
item_log,
@@ -414,6 +437,9 @@ def validate_contestation_items(
continue
item_log["item_na_fatura"] = True
item_log["item_confirmado"] = True
item_log["item_fatura_resolvido"] = str(matched_candidate.get("name", "") or "")
item_log["secao_fatura"] = str(matched_candidate.get("section", "") or "")
item_log["tipo_fatura"] = str(matched_candidate.get("source_type", "") or "")
classe = str(matched_candidate.get("classe", "")).strip().lower()
is_strategic = (

Some files were not shown because too many files have changed in this diff Show More