mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-09-07 18:23:46 +00:00
bugfixes: router stickness vs transaction workflow parameters
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,12 +25,203 @@ from typing import Any, AsyncIterator, Iterator
|
||||
from .checkpoint_repository import create_checkpoint_repository
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
def _parse_legacy_json_container(value: Any, expected: type) -> Any:
|
||||
"""Recover containers that older JSON backends persisted as JSON strings.
|
||||
|
||||
This is intentionally field-scoped: ordinary business strings must stay
|
||||
strings, even if their text happens to look like JSON.
|
||||
"""
|
||||
current = value
|
||||
for _ in range(3):
|
||||
if isinstance(current, expected):
|
||||
return current
|
||||
if not isinstance(current, str):
|
||||
break
|
||||
text = current.strip()
|
||||
if not text:
|
||||
break
|
||||
if expected is dict and not text.startswith("{"):
|
||||
break
|
||||
if expected is list and not text.startswith("["):
|
||||
break
|
||||
try:
|
||||
json.dumps(value, default=str)
|
||||
current = json.loads(text)
|
||||
except Exception:
|
||||
break
|
||||
return current if isinstance(current, expected) else expected()
|
||||
|
||||
|
||||
def _strict_json_value(value: Any, *, path: str = "$") -> Any:
|
||||
"""Convert to repository-safe JSON without ever falling back to ``str``.
|
||||
|
||||
``default=str`` is unsafe for LangGraph checkpoints: runtime/task objects can
|
||||
become ordinary strings and later be consumed as typed values by Pregel.
|
||||
Keep native JSON containers recursively and fail loudly for an unsupported
|
||||
object instead of corrupting it silently.
|
||||
"""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
except TypeError:
|
||||
return json.loads(json.dumps(value, default=str))
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _strict_json_value(item, path=f"{path}.{key}")
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [
|
||||
_strict_json_value(item, path=f"{path}[{idx}]")
|
||||
for idx, item in enumerate(value)
|
||||
]
|
||||
# Common durable scalar types that JSON does not know natively.
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
try:
|
||||
from datetime import date, datetime
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from enum import Enum
|
||||
if isinstance(value, Enum):
|
||||
return _strict_json_value(value.value, path=path)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||
return _strict_json_value(value.model_dump(), path=path)
|
||||
raise TypeError(
|
||||
f"Checkpoint contém valor não serializável em {path}: "
|
||||
f"{type(value).__module__}.{type(value).__qualname__}"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_checkpoint(checkpoint: Any) -> dict[str, Any]:
|
||||
checkpoint = _parse_legacy_json_container(checkpoint, dict)
|
||||
if not isinstance(checkpoint, dict):
|
||||
return {}
|
||||
out = dict(checkpoint)
|
||||
out["channel_values"] = _parse_legacy_json_container(out.get("channel_values"), dict)
|
||||
out["channel_versions"] = _parse_legacy_json_container(out.get("channel_versions"), dict)
|
||||
raw_seen = _parse_legacy_json_container(out.get("versions_seen"), dict)
|
||||
out["versions_seen"] = {
|
||||
str(node): _parse_legacy_json_container(versions, dict)
|
||||
for node, versions in raw_seen.items()
|
||||
}
|
||||
if "pending_sends" in out:
|
||||
out["pending_sends"] = _parse_legacy_json_container(out.get("pending_sends"), list)
|
||||
if "updated_channels" in out and isinstance(out.get("updated_channels"), str):
|
||||
out["updated_channels"] = _parse_legacy_json_container(out.get("updated_channels"), list)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_metadata(metadata: Any) -> dict[str, Any]:
|
||||
value = _parse_legacy_json_container(metadata, dict)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _normalize_config(config: Any) -> dict[str, Any]:
|
||||
value = _parse_legacy_json_container(config, dict)
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
out = dict(value)
|
||||
out["configurable"] = _parse_legacy_json_container(out.get("configurable"), dict)
|
||||
return out
|
||||
|
||||
|
||||
_EPHEMERAL_RUNTIME_KEYS = {"__pregel_runtime", "__pregel_store"}
|
||||
|
||||
|
||||
def _strip_runtime_refs(value: Any) -> Any:
|
||||
"""Recursively remove process-local runtime/store references only.
|
||||
|
||||
Checkpoints may legitimately contain LangGraph internal channels whose names
|
||||
also start with ``__pregel_`` (for example task channels). Those are durable
|
||||
graph state and must be preserved. The corruption that triggers
|
||||
``str.override`` is specifically a runtime/store object captured inside a
|
||||
nested RunnableConfig and later stringified by the JSON repository.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _strip_runtime_refs(item)
|
||||
for key, item in value.items()
|
||||
if str(key) not in _EPHEMERAL_RUNTIME_KEYS
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_runtime_refs(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_strip_runtime_refs(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _durable_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return a checkpoint-safe copy of a LangGraph RunnableConfig.
|
||||
|
||||
LangGraph injects ephemeral private values such as ``__pregel_runtime`` and
|
||||
``__pregel_store`` under ``configurable`` while a graph is running. They are
|
||||
process-local and must never cross the durable checkpoint boundary.
|
||||
|
||||
The scrub is recursive because task/pending-write config fragments may be
|
||||
nested below regular config fields in newer LangGraph versions.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
cleaned = _strip_runtime_refs(config)
|
||||
if not isinstance(cleaned, dict):
|
||||
return {}
|
||||
configurable = cleaned.get("configurable")
|
||||
if isinstance(configurable, dict):
|
||||
cleaned = dict(cleaned)
|
||||
cleaned["configurable"] = {
|
||||
key: value
|
||||
for key, value in configurable.items()
|
||||
if not str(key).startswith("__pregel_")
|
||||
}
|
||||
return cleaned
|
||||
|
||||
|
||||
def _canonical_checkpoint_config(
|
||||
payload: dict[str, Any],
|
||||
request_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rebuild the RunnableConfig returned to LangGraph from durable IDs only.
|
||||
|
||||
Official LangGraph savers do not re-bind the full config that happened to be
|
||||
present when a checkpoint was written. They reconstruct a fresh config from
|
||||
``thread_id``, ``checkpoint_ns`` and ``checkpoint_id``. Doing the same here
|
||||
prevents a historical/factory-time runtime value from being rebound into a
|
||||
new execution while remaining backward compatible with existing rows.
|
||||
"""
|
||||
requested = _durable_config(request_config)
|
||||
stored = _durable_config(_normalize_config(payload.get("config")) if isinstance(payload, dict) else None)
|
||||
req_cfg = requested.get("configurable") if isinstance(requested.get("configurable"), dict) else {}
|
||||
stored_cfg = stored.get("configurable") if isinstance(stored.get("configurable"), dict) else {}
|
||||
checkpoint = payload.get("checkpoint") if isinstance(payload, dict) else {}
|
||||
checkpoint = checkpoint if isinstance(checkpoint, dict) else {}
|
||||
|
||||
thread_id = (
|
||||
req_cfg.get("thread_id")
|
||||
or stored_cfg.get("thread_id")
|
||||
or payload.get("thread_id")
|
||||
or "default"
|
||||
)
|
||||
checkpoint_ns = req_cfg.get("checkpoint_ns")
|
||||
if checkpoint_ns is None:
|
||||
checkpoint_ns = stored_cfg.get("checkpoint_ns", "")
|
||||
|
||||
requested_checkpoint_id = req_cfg.get("checkpoint_id")
|
||||
checkpoint_id = (
|
||||
requested_checkpoint_id
|
||||
or payload.get("checkpoint_id")
|
||||
or checkpoint.get("id")
|
||||
or stored_cfg.get("checkpoint_id")
|
||||
)
|
||||
|
||||
configurable: dict[str, Any] = {
|
||||
"thread_id": str(thread_id),
|
||||
"checkpoint_ns": str(checkpoint_ns or ""),
|
||||
}
|
||||
if checkpoint_id not in (None, ""):
|
||||
configurable["checkpoint_id"] = str(checkpoint_id)
|
||||
return {"configurable": configurable}
|
||||
|
||||
|
||||
def _thread_id(config: dict[str, Any] | None) -> str:
|
||||
@@ -85,6 +276,7 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
|
||||
"""Checkpoint saver nativo para LangGraph usando os repositories do framework."""
|
||||
|
||||
def __init__(self, settings, repository=None):
|
||||
super().__init__()
|
||||
self.settings = settings
|
||||
self.repository = repository or create_checkpoint_repository(settings)
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
@@ -100,20 +292,40 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
||||
return ex.submit(lambda: asyncio.run(coro)).result()
|
||||
|
||||
def _make_tuple(self, payload: dict[str, Any] | None):
|
||||
def _make_tuple(
|
||||
self,
|
||||
payload: dict[str, Any] | None,
|
||||
request_config: dict[str, Any] | None = None,
|
||||
):
|
||||
if not payload:
|
||||
return None
|
||||
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 [])
|
||||
# Second-stage protection: never re-bind the full persisted RunnableConfig.
|
||||
# Rebuild only the durable identifiers, as official LangGraph savers do.
|
||||
config = _canonical_checkpoint_config(payload, request_config)
|
||||
checkpoint = _strip_runtime_refs(_normalize_checkpoint(payload.get("checkpoint") or {}))
|
||||
metadata = _strip_runtime_refs(_normalize_metadata(payload.get("metadata") or {}))
|
||||
raw_parent_config = payload.get("parent_config")
|
||||
if isinstance(raw_parent_config, dict):
|
||||
parent_payload = {
|
||||
"thread_id": payload.get("thread_id"),
|
||||
"config": raw_parent_config,
|
||||
"checkpoint_id": (raw_parent_config.get("configurable") or {}).get("checkpoint_id")
|
||||
if isinstance(raw_parent_config.get("configurable"), dict)
|
||||
else None,
|
||||
"checkpoint": {},
|
||||
}
|
||||
parent_config = _canonical_checkpoint_config(parent_payload)
|
||||
else:
|
||||
parent_config = None
|
||||
pending_writes = _normalize_pending_writes(
|
||||
_strip_runtime_refs(payload.get("pending_writes") or [])
|
||||
)
|
||||
try:
|
||||
from langgraph.checkpoint.base import CheckpointTuple
|
||||
return CheckpointTuple(config=config, checkpoint=checkpoint, metadata=metadata, parent_config=parent_config, pending_writes=pending_writes)
|
||||
except Exception:
|
||||
return {
|
||||
"config": config,
|
||||
"config": _durable_config(config),
|
||||
"checkpoint": checkpoint,
|
||||
"metadata": metadata,
|
||||
"parent_config": parent_config,
|
||||
@@ -121,7 +333,10 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
|
||||
}
|
||||
|
||||
async def aget_tuple(self, config: dict[str, Any]):
|
||||
return self._make_tuple(await self.repository.get_latest(_thread_id(config)))
|
||||
return self._make_tuple(
|
||||
await self.repository.get_latest(_thread_id(config)),
|
||||
request_config=config,
|
||||
)
|
||||
|
||||
def get_tuple(self, config: dict[str, Any]):
|
||||
return self._run(self.aget_tuple(config))
|
||||
@@ -129,20 +344,24 @@ 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)
|
||||
clean_config = _durable_config(config)
|
||||
clean_cfg = clean_config.get("configurable") if isinstance(clean_config.get("configurable"), dict) else {}
|
||||
checkpoint_ns = str(clean_cfg.get("checkpoint_ns") or "")
|
||||
# Return a fresh canonical config. Never feed process-local/factory-time
|
||||
# configurable values back into the next LangGraph super-step.
|
||||
next_config = {
|
||||
**(config or {}),
|
||||
"configurable": {
|
||||
**((config or {}).get("configurable") or {}),
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
await self.repository.put(thread_id, {
|
||||
"thread_id": thread_id,
|
||||
"config": _jsonable(next_config),
|
||||
"checkpoint": _jsonable(checkpoint),
|
||||
"metadata": _jsonable(metadata or {}),
|
||||
"new_versions": _jsonable(new_versions or {}),
|
||||
"config": _strict_json_value(next_config, path="$.config"),
|
||||
"checkpoint": _strict_json_value(_strip_runtime_refs(_normalize_checkpoint(checkpoint)), path="$.checkpoint"),
|
||||
"metadata": _strict_json_value(_strip_runtime_refs(_normalize_metadata(metadata or {})), path="$.metadata"),
|
||||
"new_versions": _strict_json_value(_strip_runtime_refs(new_versions or {}), path="$.new_versions"),
|
||||
"checkpoint_id": checkpoint_id,
|
||||
})
|
||||
return next_config
|
||||
@@ -153,19 +372,46 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
|
||||
async def aput_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
|
||||
thread_id = _thread_id(config)
|
||||
try:
|
||||
latest = await self.repository.get_latest(thread_id) or {"thread_id": thread_id, "config": config, "checkpoint": {}, "metadata": {}}
|
||||
latest = await self.repository.get_latest(thread_id) or {"thread_id": thread_id, "config": _durable_config(config), "checkpoint": {}, "metadata": {}}
|
||||
except:
|
||||
latest = {
|
||||
"thread_id": thread_id,
|
||||
"config": config,
|
||||
"config": _durable_config(config),
|
||||
"checkpoint": {},
|
||||
"metadata": {},
|
||||
"pending_writes": [],
|
||||
}
|
||||
|
||||
if isinstance(latest, dict):
|
||||
# Do not keep extending a persisted RunnableConfig across super-steps.
|
||||
# Rebuild the same canonical config that aget_tuple() will expose.
|
||||
latest["config"] = _canonical_checkpoint_config(latest, config)
|
||||
if isinstance(latest.get("checkpoint"), dict):
|
||||
latest["checkpoint"] = _strip_runtime_refs(latest.get("checkpoint"))
|
||||
if isinstance(latest.get("metadata"), dict):
|
||||
latest["metadata"] = _strip_runtime_refs(latest.get("metadata"))
|
||||
if isinstance(latest.get("parent_config"), dict):
|
||||
parent_payload = {
|
||||
"thread_id": latest.get("thread_id") or thread_id,
|
||||
"config": latest.get("parent_config"),
|
||||
"checkpoint_id": (latest.get("parent_config", {}).get("configurable") or {}).get("checkpoint_id")
|
||||
if isinstance(latest.get("parent_config", {}).get("configurable"), dict)
|
||||
else None,
|
||||
"checkpoint": {},
|
||||
}
|
||||
latest["parent_config"] = _canonical_checkpoint_config(parent_payload)
|
||||
|
||||
pending = list(latest.get("pending_writes") or [])
|
||||
for channel, value in writes or []:
|
||||
pending.append({"task_id": task_id, "task_path": task_path, "channel": channel, "value": _jsonable(value)})
|
||||
# Writes may contain nested task/RunnableConfig fragments. Scrub the
|
||||
# private runtime before the repository's JSON ``default=str`` layer.
|
||||
durable_value = _strip_runtime_refs(value)
|
||||
pending.append({
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"channel": channel,
|
||||
"value": _strict_json_value(durable_value, path=f"$.pending_writes[{task_id}].{channel}"),
|
||||
})
|
||||
latest["pending_writes"] = pending
|
||||
await self.repository.put(thread_id, latest)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -80,6 +80,13 @@ _REWRITE_INSTRUCTIONS_BY_CODE: dict[str, str] = {
|
||||
"pergunta de confirmação direta e curta, mencionando o serviço ou ação "
|
||||
"pendente. Sem executar nem prometer ação."
|
||||
),
|
||||
"FRASEOLOGIA": (
|
||||
"Preserve integralmente os fatos, valores, nomes de produtos e o resultado "
|
||||
"de negócio já informado. Reescreva SOMENTE o trecho apontado como "
|
||||
"fraseologia inadequada, trocando vocabulário de implementação, processo "
|
||||
"interno, categoria técnica ou operação por linguagem natural de cliente. "
|
||||
"Não invente ação, não altere o resultado e não acrescente oferta."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -48,8 +48,13 @@ A) Termos e rotulos proibidos (o cliente nao deve ouvi-los):
|
||||
so pelo nome e valor. a menos que seja perguntado diretamente sobre.
|
||||
Alguns itens possuem o nome parecido com códigos, como BEMOBI_GAM ESMENSALM
|
||||
São PERMITIDOS. Pois seu nome do produto é dessa forma.
|
||||
A3. nomes de ferramentas/tools, JSON, chaves tecnicas, checklist interno ou
|
||||
raciocinio expostos ao cliente -> falar so o resultado, em linguagem natural.
|
||||
A3. nomes de ferramentas/tools, JSON, chaves tecnicas, parametros/chaves de
|
||||
implementacao, checklist interno, estados do workflow ou raciocinio interno
|
||||
expostos ao cliente -> falar so o resultado ou fazer a pergunta necessaria
|
||||
em linguagem natural. Exemplos de termos internos proibidos: "subject",
|
||||
"asset_id", "invoice_id", "tool", "workflow", "route", "intent",
|
||||
"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION" e nomes de tools como
|
||||
"cancelar_vas_avulso" / "contestar_cobranca".
|
||||
A4. Dizer que vai encaminhar uma jornada adequada, dizer que vai encaminhar para um especialista.
|
||||
Preferivel dizer que não pode ajudar sobre isso
|
||||
A5. Dizer que está "fora do escopo". Preferivel dizer "Sobre X não posso ajudar com isso"
|
||||
@@ -64,7 +69,9 @@ B) Construcoes proibidas:
|
||||
B4. orientar o cliente a procurar atendimento ou outro canal: "entre em contato
|
||||
com a central", "ligue para o atendimento", "fale com um atendente",
|
||||
"procure uma loja", "acesse o app/site para resolver" -> resolver a duvida
|
||||
aqui mesmo, sem encaminhar o cliente para outro canal.
|
||||
aqui mesmo, sem encaminhar o cliente para outro canal. ATENCAO: pedir para
|
||||
o cliente tentar ou solicitar novamente NESTA MESMA CONVERSA, sem citar
|
||||
central, loja, app, site, telefone, atendente ou outro canal, NAO viola B4.
|
||||
|
||||
C) Ofertas e promessas proibidas (revisao humana — sobrepoe outros rails):
|
||||
C1. oferecer plano mais barato, troca, migracao ou rebaixe de plano (inclusive
|
||||
@@ -72,6 +79,20 @@ C) Ofertas e promessas proibidas (revisao humana — sobrepoe outros rails):
|
||||
C2. conceder ressarcimento em dobro -> usar a fala fixa de ajuste na fatura.
|
||||
|
||||
NAO marque FRASEOLOGIA (fraseados OBRIGATORIOS — sempre OK):
|
||||
- perguntas ou pedidos de DADOS DE NEGOCIO que o cliente conhece e que sao
|
||||
necessarios para continuar o atendimento. Isso NAO expoe raciocinio nem
|
||||
processo interno. Exemplos SEMPRE OK: "Para prosseguir, informe valor.",
|
||||
"Qual foi o valor da cobranca?", "Informe a data da cobranca.",
|
||||
"Qual servico voce deseja cancelar?", "Qual e o nome do produto?".
|
||||
Nao confunda o nome natural do dado de negocio ("valor", "data", "servico",
|
||||
"cobranca", "fatura", "produto") com o nome tecnico da chave interna
|
||||
("subject", "asset_id", "invoice_id" etc.).
|
||||
- confirmacoes de uma acao ja em andamento em linguagem natural, por exemplo
|
||||
"Voce confirma o cancelamento do servico TIM Fashion?", sao interacao normal
|
||||
com o cliente e NAO constituem exposicao de processo interno.
|
||||
- em caso de falha tecnica, orientar a repetir a mesma solicitacao aqui mesmo,
|
||||
por exemplo "Se desejar tentar novamente, solicite o cancelamento novamente",
|
||||
e permitido; isso NAO e encaminhamento para outro canal.
|
||||
- "incluso no seu plano" / "faz parte do seu plano" / "beneficio incluso".
|
||||
- citar o servico por nome e valor SEM rotulo de origem.
|
||||
- a fala fixa de ressarcimento ("Por aqui, nao consigo seguir com o
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,6 +10,7 @@ from .rail_result import RailResult
|
||||
from .parallel_executor import ParallelRailExecutor
|
||||
from .llm_rails import LLMOutputGRLRail
|
||||
from .config_loader import load_guardrails_config
|
||||
from .framework_llm_client import classify_with_framework_llm
|
||||
|
||||
logger = logging.getLogger("agent_framework.guardrails.output_supervisor")
|
||||
|
||||
@@ -122,11 +123,81 @@ class OutputSupervisor:
|
||||
)
|
||||
)
|
||||
|
||||
# FRASEOLOGIA é um rail de wording. Quando ele for o único rail impeditivo,
|
||||
# não descarte uma resposta factual/grounded: faça uma única reescrita
|
||||
# cirúrgica, depois submeta o texto reescrito a TODOS os rails novamente.
|
||||
# A flag no contexto impede loop infinito caso a nova versão continue
|
||||
# inadequada.
|
||||
phraseology_block = next(
|
||||
(r for r in results if str(r.code or "").upper() == "FRASEOLOGIA" and r.action == RailAction.BLOCK),
|
||||
None,
|
||||
)
|
||||
other_impediments = [
|
||||
r for r in results
|
||||
if r is not phraseology_block and r.action in {RailAction.BLOCK, RailAction.RETRY, RailAction.HANDOVER}
|
||||
]
|
||||
if (
|
||||
phraseology_block is not None
|
||||
and not other_impediments
|
||||
and int(ctx.get("__phraseology_rewrite_attempt", 0)) < 1
|
||||
):
|
||||
rewritten = await self._rewrite_phraseology(candidate, phraseology_block, ctx)
|
||||
if rewritten and rewritten.strip() and rewritten.strip() != candidate.strip():
|
||||
rewrite_ctx = dict(ctx)
|
||||
rewrite_ctx["__phraseology_rewrite_attempt"] = 1
|
||||
rewrite_ctx["phraseology_original_candidate"] = candidate
|
||||
rewrite_ctx["phraseology_original_reason"] = phraseology_block.reason
|
||||
decision = await self.evaluate(rewritten.strip(), rewrite_ctx)
|
||||
decision.results.insert(0, RailResult(
|
||||
code="FRASEOLOGIA_REWRITE",
|
||||
action=RailAction.OBSERVE,
|
||||
reason=phraseology_block.reason,
|
||||
metadata={
|
||||
"rewritten": True,
|
||||
"original_code": "FRASEOLOGIA",
|
||||
"rewrite_attempt": 1,
|
||||
},
|
||||
))
|
||||
decision.metadata = {
|
||||
**dict(decision.metadata or {}),
|
||||
"phraseology_rewritten": True,
|
||||
"phraseology_rewrite_attempts": 1,
|
||||
}
|
||||
return decision
|
||||
|
||||
decision = self.aggregate(candidate, list(results), ctx)
|
||||
await self._emit_events(results, decision, ctx)
|
||||
await self._emit_final(decision, ctx)
|
||||
return decision
|
||||
|
||||
|
||||
async def _rewrite_phraseology(self, candidate: str, result: RailResult, context: dict[str, Any]) -> str | None:
|
||||
"""Reescreve apenas wording bloqueado por FRASEOLOGIA.
|
||||
|
||||
A saída é sempre reavaliada por ``evaluate`` antes de ser liberada. Uma
|
||||
falha do LLM ou uma resposta vazia mantém o comportamento fail-closed.
|
||||
"""
|
||||
try:
|
||||
rewrite_context = {
|
||||
**dict(context or {}),
|
||||
"guardrail_code": "FRASEOLOGIA",
|
||||
"guardrail_reason": result.reason,
|
||||
}
|
||||
out = await classify_with_framework_llm(
|
||||
self.llm,
|
||||
"FALLBACK",
|
||||
{"text": candidate, "context": rewrite_context},
|
||||
profile_name="grl",
|
||||
component_name="guardrail.fraseologia.rewrite",
|
||||
generation_name="guardrail.fraseologia.rewrite",
|
||||
)
|
||||
# O prompt FALLBACK usa ``reason`` como texto final reescrito.
|
||||
rewritten = str(out.get("reason") or "").strip()
|
||||
return rewritten or None
|
||||
except Exception:
|
||||
logger.exception("output_supervisor.phraseology_rewrite_failed")
|
||||
return None
|
||||
|
||||
def aggregate(self, candidate: str, results: list[RailResult], context: dict[str, Any] | None = None) -> RailDecisionV2:
|
||||
ctx = context or {}
|
||||
final_action = max((r.action for r in results), key=lambda a: _SEVERITY.get(a, 0), default=RailAction.ALLOW)
|
||||
|
||||
@@ -304,13 +304,55 @@ class PrematureActionRail(Guardrail):
|
||||
|
||||
|
||||
class ProactiveOfferRail(Guardrail):
|
||||
"""AOFERTA calibrado: bloqueia oferta proativa não solicitada no output."""
|
||||
"""AOFERTA calibrado: bloqueia oferta proativa não solicitada no output.
|
||||
|
||||
Estados transacionais determinísticos de continuidade não são uma nova
|
||||
oferta do agente. Quando o runtime já abriu uma transação e está apenas
|
||||
coletando parâmetros obrigatórios ou aguardando confirmação, AOFERTA deve
|
||||
permitir a mensagem sem consultar a LLM. Outros rails de saída (por exemplo
|
||||
FRASEOLOGIA) continuam sendo executados normalmente pelo pipeline.
|
||||
"""
|
||||
|
||||
code = "AOFERTA"
|
||||
stage = "output"
|
||||
_TRANSACTION_CONTINUATION_STATUSES = {
|
||||
"COLLECTING_PARAMETERS",
|
||||
"AWAITING_CONFIRMATION",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _transaction_continuation_status(cls, ctx: dict[str, Any]) -> str | None:
|
||||
status = str(ctx.get("transaction_status") or "").strip().upper()
|
||||
if status in cls._TRANSACTION_CONTINUATION_STATUSES:
|
||||
return status
|
||||
|
||||
# Compatibilidade com callers que ainda só expõem o estado por meio
|
||||
# dos resultados das tools. O runtime transacional já grava o status
|
||||
# nesses resultados; não inferimos pelo texto da resposta.
|
||||
for result in reversed(list(ctx.get("mcp_results") or ctx.get("tool_result") or [])):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
result_status = str(result.get("transaction_status") or "").strip().upper()
|
||||
if result_status in cls._TRANSACTION_CONTINUATION_STATUSES:
|
||||
return result_status
|
||||
return None
|
||||
|
||||
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
|
||||
ctx = _ctx(context)
|
||||
continuation_status = self._transaction_continuation_status(ctx)
|
||||
if continuation_status:
|
||||
return RailDecision(
|
||||
code=self.code,
|
||||
allowed=True,
|
||||
reason=f"continuidade_transacional:{continuation_status}",
|
||||
sanitized_text=text,
|
||||
metadata={
|
||||
"mechanism": "deterministic_transaction_bypass",
|
||||
"transaction_status": continuation_status,
|
||||
"calibrated": True,
|
||||
},
|
||||
)
|
||||
|
||||
out = await classify_with_framework_llm(
|
||||
_llm(ctx),
|
||||
"AOFERTA",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user