new feature: frontend with separated responses
This commit is contained in:
@@ -338,9 +338,43 @@ class LoopRail(Guardrail):
|
||||
code = "VLOOP"
|
||||
stage = "input"
|
||||
|
||||
@staticmethod
|
||||
def _transaction_status(ctx: dict[str, Any]) -> str:
|
||||
nonterminal = {
|
||||
"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "EXECUTING",
|
||||
"PAUSED", "WAITING_INPUT",
|
||||
}
|
||||
for result in reversed(list(ctx.get("mcp_results") or [])):
|
||||
if isinstance(result, dict):
|
||||
result_status = str(result.get("transaction_status") or "").strip().upper()
|
||||
if result_status in nonterminal:
|
||||
return result_status
|
||||
active_transaction = ctx.get("active_transaction")
|
||||
if isinstance(active_transaction, dict):
|
||||
active_status = str(active_transaction.get("status") or "").strip().upper()
|
||||
if active_status in nonterminal:
|
||||
return active_status
|
||||
return str(ctx.get("transaction_status") or "").strip().upper()
|
||||
|
||||
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
|
||||
ctx = _ctx(context)
|
||||
transaction_status = self._transaction_status(ctx)
|
||||
if transaction_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}:
|
||||
return RailDecision(
|
||||
code=self.code,
|
||||
allowed=True,
|
||||
reason=f"continuidade_transacional:{transaction_status}",
|
||||
sanitized_text=text,
|
||||
metadata={
|
||||
"history_window": len(list(ctx.get("history_texts") or [])[-6:]),
|
||||
"repeated": False,
|
||||
"mechanism": "deterministic_transaction_bypass",
|
||||
"transaction_status": transaction_status,
|
||||
"calibrated": True,
|
||||
},
|
||||
)
|
||||
normalized = _lower(text).strip()
|
||||
history = [_lower(h).strip() for h in _ctx(context).get("history_texts", [])[-6:]]
|
||||
history = [_lower(h).strip() for h in ctx.get("history_texts", [])[-6:]]
|
||||
repeated = history.count(normalized) >= 2 if normalized else False
|
||||
return RailDecision(
|
||||
code=self.code,
|
||||
|
||||
@@ -358,30 +358,38 @@ class LoopRail(Guardrail):
|
||||
|
||||
@staticmethod
|
||||
def _transaction_status(ctx: dict[str, Any]) -> str:
|
||||
status = str(ctx.get("transaction_status") or "").strip().upper()
|
||||
if status:
|
||||
return status
|
||||
nonterminal = {
|
||||
"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "EXECUTING",
|
||||
"PAUSED", "WAITING_INPUT",
|
||||
}
|
||||
# A queued multi-intent transaction may be promoted immediately after
|
||||
# the previous one completed. Prefer the newest live MCP evidence over
|
||||
# a stale top-level COMPLETED status left by checkpoint merge order.
|
||||
for result in reversed(list(ctx.get("mcp_results") or [])):
|
||||
if isinstance(result, dict):
|
||||
result_status = str(result.get("transaction_status") or "").strip().upper()
|
||||
if result_status in nonterminal:
|
||||
return result_status
|
||||
active_transaction = ctx.get("active_transaction")
|
||||
if isinstance(active_transaction, dict):
|
||||
return str(active_transaction.get("status") or "").strip().upper()
|
||||
return ""
|
||||
active_status = str(active_transaction.get("status") or "").strip().upper()
|
||||
if active_status in nonterminal:
|
||||
return active_status
|
||||
return str(ctx.get("transaction_status") or "").strip().upper()
|
||||
|
||||
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
|
||||
ctx = _ctx(context)
|
||||
transaction_status = self._transaction_status(ctx)
|
||||
|
||||
# A short confirmation (for example ``sim``/``não``) may legitimately
|
||||
# appear several times in the same session because each transaction has
|
||||
# its own confirmation boundary. VLOOP protects against conversational
|
||||
# repetition; it must not consume the input that belongs to an active
|
||||
# transaction awaiting confirmation. The transaction runtime/classifier
|
||||
# remains responsible for deciding whether the utterance is actually a
|
||||
# valid confirm/reject response.
|
||||
if transaction_status == "AWAITING_CONFIRMATION":
|
||||
# Parameter values and short confirmations may legitimately repeat in
|
||||
# the same session. VLOOP must not consume input owned by the active
|
||||
# transaction contract; extraction/confirmation remains responsible
|
||||
# for deciding whether the value is valid.
|
||||
if transaction_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}:
|
||||
return RailDecision(
|
||||
code=self.code,
|
||||
allowed=True,
|
||||
reason="continuidade_transacional:AWAITING_CONFIRMATION",
|
||||
reason=f"continuidade_transacional:{transaction_status}",
|
||||
sanitized_text=text,
|
||||
metadata={
|
||||
"history_window": len(list(ctx.get("history_texts") or [])[-6:]),
|
||||
|
||||
@@ -69,17 +69,30 @@ class MultiIntentPlanner:
|
||||
|
||||
def _intent_for_clause(self, clause: str) -> IntentDefinition | None:
|
||||
normalized = self._normalize(clause)
|
||||
clause_tokens = set(re.findall(r"[\w]+", normalized, flags=re.UNICODE))
|
||||
ordered_clause_tokens = re.findall(r"[\w]+", normalized, flags=re.UNICODE)
|
||||
clause_tokens = set(ordered_clause_tokens)
|
||||
matches: list[tuple[int, int, IntentDefinition]] = []
|
||||
for intent in self.intents:
|
||||
for keyword in intent.keywords:
|
||||
token = self._normalize(keyword)
|
||||
keyword_tokens = re.findall(r"[\w]+", token, flags=re.UNICODE)
|
||||
# Expressões configuradas representam conceitos, não somente
|
||||
# substrings literais. Preserve a ordem dos termos e aceite
|
||||
# qualificadores intermediários: "cancelar pedido" também
|
||||
# reconhece "cancelar meu pedido", sem enumerar variações no YAML.
|
||||
cursor = 0
|
||||
ordered_match = bool(keyword_tokens)
|
||||
for keyword_token in keyword_tokens:
|
||||
try:
|
||||
cursor = ordered_clause_tokens.index(keyword_token, cursor) + 1
|
||||
except ValueError:
|
||||
ordered_match = False
|
||||
break
|
||||
matched = bool(
|
||||
keyword_tokens
|
||||
and (
|
||||
(len(keyword_tokens) == 1 and keyword_tokens[0] in clause_tokens)
|
||||
or (len(keyword_tokens) > 1 and token in normalized)
|
||||
or (len(keyword_tokens) > 1 and ordered_match)
|
||||
)
|
||||
)
|
||||
if matched:
|
||||
@@ -146,7 +159,7 @@ class MultiIntentPlanner:
|
||||
if len(operations) < 2:
|
||||
return None
|
||||
transactional = [item for item in operations if self._is_transactional(item)]
|
||||
if len(transactional) == 1:
|
||||
if transactional:
|
||||
primary_operation = transactional[0]
|
||||
operations.sort(key=lambda item: item is not primary_operation)
|
||||
for operation in operations[1:]:
|
||||
|
||||
@@ -16,6 +16,29 @@ class PendingTopicDrain:
|
||||
handled_topics: list[dict[str, Any]]
|
||||
mcp_results: list[dict[str, Any]]
|
||||
rag_results: list[dict[str, Any]]
|
||||
state_patch: dict[str, Any]
|
||||
|
||||
|
||||
_LIVE_TRANSACTION_STATUSES = {
|
||||
"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "WAITING_CONFIRMATION",
|
||||
"PENDING", "RUNNING", "IN_PROGRESS",
|
||||
}
|
||||
|
||||
_TRANSACTION_STATE_KEYS = {
|
||||
"active_transaction", "transaction_status", "transaction_pre_validation",
|
||||
"confirmation_required", "confirmation_received", "selected_tool_call",
|
||||
"pending_tool_call", "pending_tool_clarification", "pending_domain_workflow",
|
||||
"missing_parameters", "tool_policy_result", "next_state", "workflow_id",
|
||||
}
|
||||
|
||||
|
||||
def _has_live_transaction(state: dict[str, Any]) -> bool:
|
||||
active = state.get("active_transaction")
|
||||
active_status = active.get("status") if isinstance(active, dict) else None
|
||||
status = str(active_status or state.get("transaction_status") or "").upper()
|
||||
if status:
|
||||
return status in _LIVE_TRANSACTION_STATUSES
|
||||
return bool(state.get("confirmation_required") or state.get("pending_tool_call"))
|
||||
|
||||
|
||||
async def drain_pending_topics(
|
||||
@@ -34,6 +57,8 @@ async def drain_pending_topics(
|
||||
combined_mcp_results = list(state.get("mcp_results") or [])
|
||||
combined_rag_results = list(state.get("rag_results") or [])
|
||||
secondary_answers: list[str] = []
|
||||
state_patch: dict[str, Any] = {}
|
||||
may_promote_deferred = not _has_live_transaction(state)
|
||||
agent_responses = list(state.get("agent_responses") or [])
|
||||
if not agent_responses and str(candidate or "").strip():
|
||||
primary_agent = str(
|
||||
@@ -53,9 +78,15 @@ async def drain_pending_topics(
|
||||
})
|
||||
for topic in topics:
|
||||
disposition = topic.get("disposition")
|
||||
promoted = False
|
||||
if disposition == "defer":
|
||||
remaining.append(topic)
|
||||
continue
|
||||
if not may_promote_deferred:
|
||||
remaining.append(topic)
|
||||
continue
|
||||
topic = {**topic, "disposition": "execute", "status": "pending"}
|
||||
disposition = "execute"
|
||||
promoted = True
|
||||
may_promote_deferred = False
|
||||
if disposition != "execute":
|
||||
handled.append({**topic, "status": "completed"})
|
||||
continue
|
||||
@@ -123,14 +154,42 @@ async def drain_pending_topics(
|
||||
if rag_evidence not in combined_rag_results:
|
||||
combined_rag_results.append(rag_evidence)
|
||||
secondary_answers.append(answer)
|
||||
if promoted:
|
||||
agent_responses = [{**item, "primary": False} for item in agent_responses]
|
||||
agent_responses.append({
|
||||
"operation_id": topic.get("operation_id"),
|
||||
"agent": topic.get("agent"),
|
||||
"intent": topic.get("intent"),
|
||||
"answer": answer,
|
||||
"primary": False,
|
||||
"primary": promoted,
|
||||
"status": "completed",
|
||||
})
|
||||
if promoted:
|
||||
for key in _TRANSACTION_STATE_KEYS:
|
||||
if key in (result or {}):
|
||||
state_patch[key] = result[key]
|
||||
state_patch.update({
|
||||
"route": topic.get("agent"),
|
||||
"active_agent": topic.get("agent"),
|
||||
"intent": topic.get("intent"),
|
||||
"domain": topic.get("domain"),
|
||||
"mcp_tools": topic.get("tools") or [],
|
||||
# Replace the completed transaction's continuity snapshot so
|
||||
# the next reply cannot be routed back to the previous agent.
|
||||
"route_decision": {
|
||||
"route": topic.get("agent"),
|
||||
"agent": topic.get("agent"),
|
||||
"intent": topic.get("intent"),
|
||||
"domain": topic.get("domain"),
|
||||
"mcp_tools": topic.get("tools") or [],
|
||||
"next_state": (result or {}).get("next_state"),
|
||||
"metadata": {
|
||||
"multi_intent_secondary": True,
|
||||
"promoted_from_pending_topics": True,
|
||||
"operation_id": topic.get("operation_id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
handled.append({**topic, "status": "completed"})
|
||||
|
||||
secondary_answers.extend(MultiIntentPlanner.public_messages(plan))
|
||||
@@ -148,4 +207,5 @@ async def drain_pending_topics(
|
||||
handled_topics=handled,
|
||||
mcp_results=combined_mcp_results,
|
||||
rag_results=combined_rag_results,
|
||||
state_patch=state_patch,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user