Укрепить валидацию агентного лупа по сценарным якорям
This commit is contained in:
parent
bbb3620956
commit
e2b928ac19
|
|
@ -1695,6 +1695,47 @@ def resolve_question_template(question_template: str, scenario_state: dict[str,
|
||||||
return pattern.sub(replace, question_template).strip()
|
return pattern.sub(replace, question_template).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_scenario_contract_placeholders(value: Any, scenario_state: dict[str, Any]) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
runtime_resolved = str(resolve_runtime_placeholders(value))
|
||||||
|
if "{{" not in runtime_resolved:
|
||||||
|
return runtime_resolved
|
||||||
|
pattern = re.compile(r"{{\s*([^{}]+?)\s*}}")
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
value = lookup_placeholder_value(match.group(1), scenario_state)
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return dump_json(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
return pattern.sub(replace, runtime_resolved)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [resolve_scenario_contract_placeholders(item, scenario_state) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: resolve_scenario_contract_placeholders(item, scenario_state)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_step_contract_placeholders(step: dict[str, Any], scenario_state: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
resolved_step = dict(step)
|
||||||
|
resolved_step["required_filters"] = normalize_validation_filters(
|
||||||
|
resolve_scenario_contract_placeholders(step.get("required_filters") or {}, scenario_state)
|
||||||
|
)
|
||||||
|
for pattern_field in (
|
||||||
|
"forbidden_answer_patterns",
|
||||||
|
"required_answer_patterns_any",
|
||||||
|
"required_answer_patterns_all",
|
||||||
|
):
|
||||||
|
if pattern_field in step:
|
||||||
|
resolved_step[pattern_field] = normalize_pattern_list(
|
||||||
|
resolve_scenario_contract_placeholders(step.get(pattern_field) or [], scenario_state)
|
||||||
|
)
|
||||||
|
return resolved_step
|
||||||
|
|
||||||
|
|
||||||
def build_failed_step_state(
|
def build_failed_step_state(
|
||||||
*,
|
*,
|
||||||
scenario_id: str,
|
scenario_id: str,
|
||||||
|
|
@ -2313,6 +2354,9 @@ def step_intent_matches_expected(state: dict[str, Any], expected_intents: list[s
|
||||||
observed_intents = [
|
observed_intents = [
|
||||||
str(state.get("detected_intent") or "").strip(),
|
str(state.get("detected_intent") or "").strip(),
|
||||||
*normalize_string_list(state.get("mcp_discovery_effective_intents")),
|
*normalize_string_list(state.get("mcp_discovery_effective_intents")),
|
||||||
|
str(state.get("mcp_discovery_selected_chain_id") or "").strip(),
|
||||||
|
str(state.get("mcp_discovery_route_candidate_fact_family") or "").strip(),
|
||||||
|
str(state.get("mcp_discovery_route_candidate_action_family") or "").strip(),
|
||||||
]
|
]
|
||||||
return any(identifier_in_list(intent, expected_intents) for intent in observed_intents if intent)
|
return any(identifier_in_list(intent, expected_intents) for intent in observed_intents if intent)
|
||||||
|
|
||||||
|
|
@ -2551,6 +2595,8 @@ def is_validated_memory_checkpoint_answer(
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if is_validated_comparison_boundary_memory_checkpoint(state, business_review, violations):
|
if is_validated_comparison_boundary_memory_checkpoint(state, business_review, violations):
|
||||||
return True
|
return True
|
||||||
|
if is_validated_bare_organization_scope_checkpoint(state, business_review, violations):
|
||||||
|
return True
|
||||||
tags = set(normalize_string_list(state.get("semantic_tags")))
|
tags = set(normalize_string_list(state.get("semantic_tags")))
|
||||||
if "memory" not in tags:
|
if "memory" not in tags:
|
||||||
return False
|
return False
|
||||||
|
|
@ -2566,6 +2612,39 @@ def is_validated_memory_checkpoint_answer(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_validated_bare_organization_scope_checkpoint(
|
||||||
|
state: dict[str, Any],
|
||||||
|
business_review: dict[str, Any],
|
||||||
|
violations: list[str],
|
||||||
|
) -> bool:
|
||||||
|
if violations:
|
||||||
|
return False
|
||||||
|
tags = set(normalize_string_list(state.get("semantic_tags")))
|
||||||
|
if not tags.intersection({"bare_org_scope", "company_scope", "company_selected"}):
|
||||||
|
return False
|
||||||
|
if str(state.get("living_chat_response_source") or "").strip() != "deterministic_data_scope_selection_contract":
|
||||||
|
return False
|
||||||
|
if str(state.get("living_chat_data_scope_probe_status") or "").strip() != "resolved":
|
||||||
|
return False
|
||||||
|
selected_organization = first_non_empty_string(
|
||||||
|
state.get("living_chat_selected_organization"),
|
||||||
|
state.get("assistant_active_organization"),
|
||||||
|
)
|
||||||
|
if not selected_organization:
|
||||||
|
return False
|
||||||
|
assistant_text = str(state.get("assistant_text") or "")
|
||||||
|
normalized_answer = normalize_entity_filter_value_for_comparison(assistant_text)
|
||||||
|
normalized_organization = normalize_entity_filter_value_for_comparison(selected_organization)
|
||||||
|
if not normalized_organization or normalized_organization not in normalized_answer:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
business_review.get("business_usefulness_ok") is True
|
||||||
|
and business_review.get("direct_answer_first_ok") is True
|
||||||
|
and business_review.get("answer_layering_ok") is True
|
||||||
|
and business_review.get("technical_garbage_present") is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_validated_comparison_boundary_memory_checkpoint(
|
def is_validated_comparison_boundary_memory_checkpoint(
|
||||||
state: dict[str, Any],
|
state: dict[str, Any],
|
||||||
business_review: dict[str, Any],
|
business_review: dict[str, Any],
|
||||||
|
|
@ -3235,6 +3314,10 @@ def build_scenario_step_state(
|
||||||
"detected_intent": debug.get("detected_intent"),
|
"detected_intent": debug.get("detected_intent"),
|
||||||
"selected_recipe": debug.get("selected_recipe"),
|
"selected_recipe": debug.get("selected_recipe"),
|
||||||
"capability_id": debug.get("capability_id"),
|
"capability_id": debug.get("capability_id"),
|
||||||
|
"living_chat_response_source": debug.get("living_chat_response_source"),
|
||||||
|
"living_chat_data_scope_probe_status": debug.get("living_chat_data_scope_probe_status"),
|
||||||
|
"living_chat_selected_organization": debug.get("living_chat_selected_organization"),
|
||||||
|
"assistant_active_organization": debug.get("assistant_active_organization"),
|
||||||
"capability_contract_id": first_non_empty_string(
|
"capability_contract_id": first_non_empty_string(
|
||||||
debug.get("capability_contract_id"),
|
debug.get("capability_contract_id"),
|
||||||
runtime_contract.get("capability_contract_id"),
|
runtime_contract.get("capability_contract_id"),
|
||||||
|
|
@ -3600,6 +3683,7 @@ def execute_scenario_manifest(
|
||||||
step_dir = steps_dir / step["step_id"]
|
step_dir = steps_dir / step["step_id"]
|
||||||
try:
|
try:
|
||||||
resolved_question = resolve_question_template(step["question_template"], scenario_state)
|
resolved_question = resolve_question_template(step["question_template"], scenario_state)
|
||||||
|
resolved_step = resolve_step_contract_placeholders(step, scenario_state)
|
||||||
step_analysis_context = merge_analysis_context(manifest.get("analysis_context"), step.get("analysis_context"))
|
step_analysis_context = merge_analysis_context(manifest.get("analysis_context"), step.get("analysis_context"))
|
||||||
step_analysis_context = carry_forward_analysis_context(
|
step_analysis_context = carry_forward_analysis_context(
|
||||||
scenario_state,
|
scenario_state,
|
||||||
|
|
@ -3611,7 +3695,7 @@ def execute_scenario_manifest(
|
||||||
args=args,
|
args=args,
|
||||||
domain=manifest["domain"],
|
domain=manifest["domain"],
|
||||||
scenario_id=manifest["scenario_id"],
|
scenario_id=manifest["scenario_id"],
|
||||||
step=step,
|
step=resolved_step,
|
||||||
step_index=step_index,
|
step_index=step_index,
|
||||||
session_id=scenario_state.get("session_id"),
|
session_id=scenario_state.get("session_id"),
|
||||||
question_resolved=resolved_question,
|
question_resolved=resolved_question,
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,109 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
|
||||||
self.assertIn("required_answer_patterns_any_missing", step_state["violated_invariants"])
|
self.assertIn("required_answer_patterns_any_missing", step_state["violated_invariants"])
|
||||||
self.assertEqual(step_state["acceptance_status"], "rejected")
|
self.assertEqual(step_state["acceptance_status"], "rejected")
|
||||||
|
|
||||||
|
def test_binding_placeholder_required_filter_is_resolved_before_step_validation(self) -> None:
|
||||||
|
step = dcl.normalize_step_definition(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"step_id": "counterparty_binding_filter",
|
||||||
|
"title": "Counterparty binding filter",
|
||||||
|
"question": "Отдельно по {{bindings.counterparty}}: какой это поток?",
|
||||||
|
"required_filters": {"counterparty": "{{bindings.counterparty}}"},
|
||||||
|
"required_answer_patterns_all": ["(?i){{bindings.counterparty}}|банк"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
scenario_state = {
|
||||||
|
"bindings": {"counterparty": "СБЕРБАНК"},
|
||||||
|
"step_outputs": {},
|
||||||
|
"semantic_memory": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved_step = dcl.resolve_step_contract_placeholders(step, scenario_state)
|
||||||
|
|
||||||
|
self.assertEqual(resolved_step["required_filters"]["counterparty"], "СБЕРБАНК")
|
||||||
|
self.assertEqual(resolved_step["required_answer_patterns_all"], ["(?i)СБЕРБАНК|банк"])
|
||||||
|
|
||||||
|
step_state = dcl.build_scenario_step_state(
|
||||||
|
scenario_id="binding_filter_demo",
|
||||||
|
domain="agentic_loop",
|
||||||
|
step=resolved_step,
|
||||||
|
step_index=1,
|
||||||
|
question_resolved="Отдельно по СБЕРБАНК: какой это поток?",
|
||||||
|
analysis_context={},
|
||||||
|
turn_artifact={
|
||||||
|
"assistant_message": {
|
||||||
|
"reply_type": "factual",
|
||||||
|
"text": "СБЕРБАНК подтвержден как банковский/финансовый поток.",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_intent": "bank_operations_by_counterparty",
|
||||||
|
"selected_recipe": "address_bank_operations_by_counterparty_v1",
|
||||||
|
"capability_id": "bank_operations_drilldown",
|
||||||
|
"truth_gate_contract_status": "full_confirmed",
|
||||||
|
"mcp_call_status": "matched_non_empty",
|
||||||
|
"extracted_filters": {"counterparty": "СБЕРБАНК"},
|
||||||
|
},
|
||||||
|
"session_summary": {},
|
||||||
|
},
|
||||||
|
entries=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("missing_required_filter", step_state["violated_invariants"])
|
||||||
|
|
||||||
|
def test_expected_intent_accepts_discovery_chain_id(self) -> None:
|
||||||
|
self.assertTrue(
|
||||||
|
dcl.step_intent_matches_expected(
|
||||||
|
{
|
||||||
|
"detected_intent": "supplier_payouts_profile",
|
||||||
|
"mcp_discovery_selected_chain_id": "value_flow",
|
||||||
|
"mcp_discovery_route_candidate_fact_family": "value_flow",
|
||||||
|
"mcp_discovery_route_candidate_action_family": "payout",
|
||||||
|
},
|
||||||
|
["value_flow"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bare_organization_scope_selection_is_validated_memory_checkpoint(self) -> None:
|
||||||
|
step_state = dcl.build_scenario_step_state(
|
||||||
|
scenario_id="org_scope_demo",
|
||||||
|
domain="agentic_loop",
|
||||||
|
step={
|
||||||
|
"step_id": "step_01",
|
||||||
|
"title": "Choose organization",
|
||||||
|
"depends_on": [],
|
||||||
|
"question_template": "ООО Альтернатива Плюс",
|
||||||
|
"semantic_tags": ["bare_org_scope", "company_scope"],
|
||||||
|
"required_answer_patterns_all": ["(?i)фиксир|рабоч.*организац|контур"],
|
||||||
|
},
|
||||||
|
step_index=1,
|
||||||
|
question_resolved="ООО Альтернатива Плюс",
|
||||||
|
analysis_context={},
|
||||||
|
turn_artifact={
|
||||||
|
"assistant_message": {
|
||||||
|
"reply_type": "factual_with_explanation",
|
||||||
|
"text": 'Отлично, фиксирую рабочую организацию: ООО "Альтернатива Плюс".',
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_mode": "chat",
|
||||||
|
"fallback_type": "none",
|
||||||
|
"living_chat_response_source": "deterministic_data_scope_selection_contract",
|
||||||
|
"living_chat_data_scope_probe_status": "resolved",
|
||||||
|
"living_chat_selected_organization": 'ООО "Альтернатива Плюс"',
|
||||||
|
"assistant_active_organization": 'ООО "Альтернатива Плюс"',
|
||||||
|
},
|
||||||
|
"session_summary": {},
|
||||||
|
},
|
||||||
|
entries=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(step_state["status"], "validated")
|
||||||
|
self.assertTrue(step_state["memory_checkpoint_validated"])
|
||||||
|
|
||||||
def test_repair_targets_promote_route_candidate_enablement_gaps(self) -> None:
|
def test_repair_targets_promote_route_candidate_enablement_gaps(self) -> None:
|
||||||
repair_targets = dcl.build_deterministic_repair_targets(
|
repair_targets = dcl.build_deterministic_repair_targets(
|
||||||
{"pack_id": "route_candidate_pack", "domain": "open_world", "final_status": "accepted"},
|
{"pack_id": "route_candidate_pack", "domain": "open_world", "final_status": "accepted"},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue