Уточнить валидацию guarded MCP ответов агента

This commit is contained in:
dctouch 2026-06-15 23:47:04 +03:00
parent fa1731a5b4
commit 2873758579
4 changed files with 146 additions and 7 deletions

View File

@ -518,12 +518,24 @@ def evaluate_limited_next_action(
limited_outputs = 0
for output in outputs:
text = current_answer_text_for_output(output)
step_state_path = output.get("step_state_path")
step_state = read_json_object(step_state_path) if isinstance(step_state_path, Path) else {}
review = step_state.get("business_first_review") if isinstance(step_state.get("business_first_review"), dict) else {}
review_limited = review.get("limited_answer_detected")
if review_limited is False:
continue
limited_match = first_pattern_search(limited_patterns, text)
if not limited_match:
if review_limited is not True and not limited_match:
continue
limited_outputs += 1
if not first_pattern_search(next_action_patterns, text):
failures.append({"path": output["repo_path"], "limited_match": limited_match.group(0)[:240]})
next_action_present = review.get("next_action_present") is True or bool(first_pattern_search(next_action_patterns, text))
if not next_action_present:
evidence = {"path": output["repo_path"]}
if limited_match:
evidence["limited_match"] = limited_match.group(0)[:240]
else:
evidence["limited_match"] = "business_first_review.limited_answer_detected"
failures.append(evidence)
status = "fail" if failures else "pass"
if failures:
message = "limited answer has no next action"

View File

@ -260,6 +260,10 @@ GUARDED_INSUFFICIENCY_PRIMARY_MARKERS = (
"\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430",
"\u043d\u0435\u043b\u044c\u0437\u044f \u0447\u0435\u0441\u0442\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434",
"\u043d\u0435\u043b\u044c\u0437\u044f \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e \u043e\u043f\u0440\u0435\u0434\u0435\u043b",
"\u043d\u0435\u043b\u044c\u0437\u044f \u0434\u043e\u043a\u0430\u0437\u0430\u0442\u044c",
"\u043d\u0435 \u0434\u043e\u043a\u0430\u0437\u0430\u0442\u044c",
"\u043d\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d \u0441\u0440\u043e\u043a \u043e\u043f\u043b\u0430\u0442\u044b",
"\u043d\u0435\u0442 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0441\u0440\u043e\u043a\u0430 \u043e\u043f\u043b\u0430\u0442\u044b",
"\u0447\u0435\u0441\u0442\u043d\u043e \u043f\u043e\u0441\u0447\u0438\u0442\u0430\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f",
"\u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e\u0439 \u0431\u0430\u0437\u044b",
"\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u043e\u0439 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c",
@ -275,6 +279,9 @@ GUARDED_INSUFFICIENCY_LIMITATION_MARKERS = (
"\u043d\u0435 \u0434\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 \u043e\u0441\u0442\u0430\u0442\u043e\u043a",
"\u043d\u0435 \u0444\u0438\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u0435\u0441\u0442\u0440",
"\u0433\u0440\u0430\u043d\u0438\u0446\u0430 \u043e\u0442\u0432\u0435\u0442\u0430",
"due date",
"\u0441\u0440\u043e\u043a \u043e\u043f\u043b\u0430\u0442\u044b",
"\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u043d\u043e\u0439 \u043f\u0440\u043e\u0441\u0440\u043e\u0447",
"\u0440\u0430\u0441\u0447\u0435\u0442\u043d\u0443\u044e \u0431\u0430\u0437\u0443",
"\u0440\u0430\u0441\u0447\u0451\u0442\u043d\u0443\u044e \u0431\u0430\u0437\u0443",
)
@ -2798,6 +2805,26 @@ def has_guarded_insufficiency_language(answer_text: str) -> bool:
return has_unconfirmed_marker and has_limitation_marker
def is_validated_guarded_mcp_discovery_insufficiency(state: dict[str, Any]) -> bool:
if state.get("mcp_discovery_response_applied") is not True:
return False
if str(state.get("mcp_discovery_response_candidate_status") or "").strip() != "ready_for_guarded_use":
return False
if str(state.get("mcp_discovery_route_candidate_status") or "").strip() != "ready_for_reviewed_execution":
return False
if str(state.get("mcp_discovery_execution_handoff_can_use_guarded_response") or "").casefold() not in {"true", "1"}:
return False
if str(state.get("truth_gate_contract_status") or "").strip() != "partial_supported":
return False
if str(state.get("truth_mode") or "").strip() not in GUARDED_INSUFFICIENCY_TRUTH_MODES:
return False
if str(state.get("answer_shape") or "").strip() not in GUARDED_INSUFFICIENCY_ANSWER_SHAPES:
return False
if str(state.get("evidence_grade") or "").strip() not in {"weak", "medium", "strong"}:
return False
return bool(str(state.get("assistant_text") or "").strip())
def is_validated_guarded_insufficiency_answer(
state: dict[str, Any],
execution_status: str,
@ -2808,13 +2835,14 @@ def is_validated_guarded_insufficiency_answer(
return False
if violations:
return False
if state.get("mcp_discovery_response_applied") is True:
mcp_discovery_guarded = is_validated_guarded_mcp_discovery_insufficiency(state)
if state.get("mcp_discovery_response_applied") is True and not mcp_discovery_guarded:
return False
if str(state.get("reply_type") or "").strip() not in {"factual", "factual_with_explanation", "partial_coverage"}:
return False
if str(state.get("fallback_type") or "").strip() not in {"", "none"}:
if not mcp_discovery_guarded and str(state.get("fallback_type") or "").strip() not in {"", "none"}:
return False
if str(state.get("mcp_call_status") or "").strip() != "matched_non_empty":
if not mcp_discovery_guarded and str(state.get("mcp_call_status") or "").strip() != "matched_non_empty":
return False
result_mode = str(state.get("result_mode") or "").strip()
truth_mode = str(state.get("truth_mode") or "").strip()
@ -2825,7 +2853,7 @@ def is_validated_guarded_insufficiency_answer(
or answer_shape in GUARDED_INSUFFICIENCY_ANSWER_SHAPES
or state.get("balance_confirmed") is False
)
if not has_limited_evidence_mode:
if not (has_limited_evidence_mode or mcp_discovery_guarded):
return False
if not has_guarded_insufficiency_language(str(state.get("assistant_text") or "")):
return False

View File

@ -160,6 +160,65 @@ class AgentDetectorRunnerTests(unittest.TestCase):
self.assertEqual(results["summary"]["status"], "pass")
self.assertEqual(results["results"][0]["message"], "limited answer includes a next action")
def test_limited_next_action_ignores_non_limited_off_domain_can_not_phrase(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
artifact_dir = root / "run"
step_dir = artifact_dir / "scenarios" / "chat" / "steps" / "s01"
write_text(step_dir / "output.md", "Нет, пингвины не могут летать.")
write_json(
step_dir / "step_state.json",
{
"business_first_review": {
"limited_answer_detected": False,
"next_action_present": False,
}
},
)
registry_path = root / "detector_registry.json"
issue_catalog_path = root / "issue_catalog.json"
write_json(
registry_path,
{
"schema_version": "agent_detector_registry_v1",
"detectors": {
"limited_answer_without_next_action": {
"kind": "answer_text_required_when_limited",
"automation_level": "semi_automatic",
"description": "Limited answers need a next action.",
"issue_codes": ["business_next_step_missing"],
"inputs": ["output.md"],
"check": {
"limited_patterns": ["(?i)не могу"],
"required_next_action_patterns_any": ["(?i)что проверить дальше"],
},
}
},
},
)
write_json(
issue_catalog_path,
{
"schema_version": "agent_issue_catalog_v1",
"issues": {
"business_next_step_missing": {
"detectors": ["limited_answer_without_next_action"],
}
},
},
)
results = runner.build_detector_results(
artifact_dir,
issue_codes=["business_next_step_missing"],
registry_path=registry_path,
issue_catalog_path=issue_catalog_path,
include_default_global=False,
)
self.assertEqual(results["summary"]["status"], "pass")
self.assertEqual(results["results"][0]["message"], "answer is not limited; next action requirement not triggered")
def test_candidate_forbidden_regex_is_limited_to_evidence_path(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)

View File

@ -1617,6 +1617,46 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
self.assertTrue(step_state["guarded_insufficiency_validated"])
self.assertEqual(step_state["acceptance_status"], "validated")
def test_guarded_mcp_discovery_debt_boundary_followup_validates(self) -> None:
answer_text = (
"Да, доказать просрочку по компании ООО Альтернатива Плюс на 2020-12-31 нельзя, "
"потому что в подтвержденных данных не установлен срок оплаты.\n"
"Открытый долг на 35 472 380,36 руб. виден, но без due date это задолженность "
"без доказанной договорной просрочки."
)
step_state = dcl.validate_step_contract(
{
"execution_status": "partial",
"reply_type": "partial_coverage",
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": [
"(?i)срок.*оплат|due[- ]?date|договор",
"(?i)не установлен|не хватает|не подтвержд|не доказ",
"(?i)просроч",
"(?i)2020|2020-12-31|конец 2020",
],
"fallback_type": "partial",
"truth_gate_contract_status": "partial_supported",
"truth_mode": "limited",
"answer_shape": "limited_with_reason",
"evidence_grade": "medium",
"mcp_discovery_response_applied": True,
"mcp_discovery_response_candidate_status": "ready_for_guarded_use",
"mcp_discovery_route_candidate_status": "ready_for_reviewed_execution",
"mcp_discovery_execution_handoff_can_use_guarded_response": True,
"assistant_text": answer_text,
"actual_direct_answer": (
"Да, доказать просрочку по компании ООО Альтернатива Плюс на 2020-12-31 нельзя, "
"потому что в подтвержденных данных не установлен срок оплаты."
),
"top_non_empty_lines": answer_text.splitlines(),
}
)
self.assertNotIn("required_answer_patterns_all_missing", step_state["violated_invariants"])
self.assertTrue(step_state["guarded_insufficiency_validated"])
self.assertEqual(step_state["acceptance_status"], "validated")
def test_expected_clarification_partial_answer_validates(self) -> None:
answer_text = (
"Для рейтинга прибыльности номенклатуры нужен период.\n\n"