Закрепить широкий phase12 agent replay

This commit is contained in:
2026-06-02 10:02:12 +03:00
parent b52279d19e
commit 7995980215
8 changed files with 435 additions and 10 deletions
+119 -8
View File
@@ -712,6 +712,21 @@ def merge_scenario_date_scope(
def question_resets_temporal_scope(value: Any) -> bool:
normalized = str(value or "").casefold().replace("ё", "е")
same_scope_markers = (
"за тот же период",
"на тот же период",
"за этот же период",
"на этот же период",
"за этот период",
"на этот период",
"на ту же дату",
"на эту же дату",
"на эту дату",
"same period",
"same date",
)
if any(marker in normalized for marker in same_scope_markers):
return False
markers = (
"за все доступное время",
"за все время",
@@ -725,7 +740,14 @@ def question_resets_temporal_scope(value: Any) -> bool:
"не тащи период",
"не тащи старый период",
)
return any(marker in normalized for marker in markers)
if any(marker in normalized for marker in markers):
return True
has_document_signal = bool(re.search(r"\b(?:док(?:и|умент\w*)?|docs?|documents?)\b", normalized, flags=re.IGNORECASE))
has_anchor_signal = bool(re.search(r"(?:^|\s)по\s+[\w\"'«».-]{2,}", normalized, flags=re.IGNORECASE))
has_root_action_signal = bool(
re.search(r"\b(?:покажи|выведи|найди|дай|посмотри|show|list|find)\b", normalized, flags=re.IGNORECASE)
)
return has_document_signal and has_anchor_signal and has_root_action_signal
def repair_text_mojibake(value: str) -> str:
@@ -809,6 +831,21 @@ def normalize_validation_filters(raw_filters: Any) -> dict[str, str]:
return normalized
def normalize_entity_filter_value_for_comparison(value: Any) -> str:
text = repair_text_mojibake(str(value or "")).casefold().replace("ё", "е")
text = re.sub(r"[\"'«»“”„]", "", text)
text = re.sub(r"[\s\-–—]+", " ", text)
return text.strip()
def validation_filter_values_match(filter_key: str, actual_value: str, expected_value: str) -> bool:
if actual_value == expected_value:
return True
if filter_key in {"organization", "counterparty", "contract", "warehouse"}:
return normalize_entity_filter_value_for_comparison(actual_value) == normalize_entity_filter_value_for_comparison(expected_value)
return False
def normalize_invariant_severity(raw_mapping: Any) -> dict[str, str]:
if not isinstance(raw_mapping, dict):
return {}
@@ -2461,15 +2498,34 @@ def is_validated_confirmed_runtime_answer(
and business_review.get("answer_layering_ok") is True
and business_review.get("technical_garbage_present") is False
)
if str(state.get("reply_type") or "").strip() not in {"factual", "factual_with_explanation", "empty_but_valid"}:
reply_type = str(state.get("reply_type") or "").strip()
if reply_type not in {"factual", "factual_with_explanation", "partial_coverage", "empty_but_valid"}:
return False
if str(state.get("fallback_type") or "").strip() not in {"", "none"}:
return False
if str(state.get("mcp_call_status") or "").strip() != "matched_non_empty":
return False
response_type = str(state.get("response_type") or "").strip()
if response_type not in {"FACTUAL_LIST", "FACTUAL_SUMMARY"}:
return False
mcp_call_status = str(state.get("mcp_call_status") or "").strip()
selected_recipe = str(state.get("selected_recipe") or "").strip()
truth_gate_contract_status = str(state.get("truth_gate_contract_status") or "").strip()
exact_negative_open_items = (
mcp_call_status == "no_raw_rows"
and state.get("balance_confirmed") is False
and selected_recipe == "address_open_items_by_party_or_contract_v1"
and truth_gate_contract_status == "full_confirmed"
)
if exact_negative_open_items:
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
)
if reply_type not in {"factual", "factual_with_explanation", "empty_but_valid"}:
return False
if mcp_call_status != "matched_non_empty":
return False
result_mode = str(state.get("result_mode") or "").strip()
truth_mode = str(state.get("truth_mode") or "").strip()
answer_shape = str(state.get("answer_shape") or "").strip()
@@ -2590,6 +2646,42 @@ def is_validated_missing_axis_clarification_answer(
)
def is_validated_clean_meta_chat_answer(
state: dict[str, Any],
execution_status: str,
business_review: dict[str, Any],
violations: list[str],
) -> bool:
if execution_status != "needs_exact_capability":
return False
if violations:
return False
semantic_tags = set(normalize_string_list(state.get("semantic_tags")))
allowed_tags = {
"meta_smalltalk",
"company_selected",
"organization_authority",
"meta_capability",
"human_answer_quality",
"meta_historical_capability",
"capability_over_followup",
}
if not semantic_tags.intersection(allowed_tags):
return False
if str(state.get("detected_mode") or "").strip() not in {"chat", "unsupported", ""}:
return False
if str(state.get("reply_type") or "").strip() not in {"factual", "factual_with_explanation", "partial_coverage"}:
return False
if not str(state.get("assistant_text") or "").strip():
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 _business_review_is_clean(step_state: dict[str, Any]) -> bool:
business_review = step_state.get("business_first_review")
if not isinstance(business_review, dict):
@@ -2710,12 +2802,12 @@ def business_expected_result_mode_matches(expected_result_mode: str, step_state:
def acceptance_status_from_execution(execution_status: str, hard_fail: bool, semantic_validated: bool = False) -> str:
if execution_status == "blocked":
return "blocked"
if execution_status == "needs_exact_capability":
return "needs_exact_capability"
if hard_fail:
return "rejected"
if semantic_validated:
return "validated"
if execution_status == "needs_exact_capability":
return "needs_exact_capability"
if execution_status == "exact":
return "validated"
return "rejected"
@@ -2784,7 +2876,7 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
if not actual_value:
violated_invariants.append("missing_required_filter")
continue
if actual_value != expected_value:
if not validation_filter_values_match(filter_key, actual_value, expected_value):
if filter_key == "as_of_date":
violated_invariants.append("wrong_as_of_date")
elif filter_key == "period_from":
@@ -2876,6 +2968,12 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
business_review,
unique_violations,
)
clean_meta_chat_validated = is_validated_clean_meta_chat_answer(
state,
execution_status,
business_review,
unique_violations,
)
state["violated_invariants"] = unique_violations
state["warnings"] = list(dict.fromkeys(warnings))
state["hard_fail"] = hard_fail
@@ -2885,6 +2983,7 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
state["guarded_insufficiency_validated"] = guarded_insufficiency_validated
state["clarification_answer_validated"] = clarification_validated
state["missing_axis_clarification_validated"] = missing_axis_clarification_validated
state["clean_meta_chat_answer_validated"] = clean_meta_chat_validated
state["acceptance_status"] = acceptance_status_from_execution(
execution_status,
hard_fail,
@@ -2895,6 +2994,7 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
or guarded_insufficiency_validated
or clarification_validated
or missing_axis_clarification_validated
or clean_meta_chat_validated
),
)
state["status"] = state["acceptance_status"]
@@ -2935,8 +3035,19 @@ def build_scenario_step_state(
if not effective_analysis_context:
effective_analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {}
current_date_scope = derive_mcp_discovery_business_overview_date_scope(debug)
if current_date_scope is None:
current_date_scope_from_session_context = current_date_scope is None
if current_date_scope_from_session_context:
current_date_scope = context.get("date_scope")
debug_extracted_filters = debug.get("extracted_filters") if isinstance(debug.get("extracted_filters"), dict) else {}
if current_date_scope_from_session_context and question_resets_temporal_scope(question_resolved) and not any(
normalize_iso_date(debug_extracted_filters.get(key)) for key in ("as_of_date", "period_from", "period_to")
):
current_date_scope = {
"as_of_date": None,
"period_from": None,
"period_to": None,
"source": "question_temporal_scope_reset",
}
runtime_contract = (
debug.get("assistant_runtime_contract_v1")
if isinstance(debug.get("assistant_runtime_contract_v1"), dict)
+179
View File
@@ -251,6 +251,9 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
def test_temporal_reset_question_skips_carried_date_scope(self) -> None:
self.assertTrue(dcl.question_resets_temporal_scope("show money za all time"))
self.assertTrue(dcl.question_resets_temporal_scope("сколько всего денег за все доступное время"))
self.assertTrue(dcl.question_resets_temporal_scope("по чепурнову покажи все доки"))
self.assertTrue(dcl.question_resets_temporal_scope("покажи документы по свк"))
self.assertFalse(dcl.question_resets_temporal_scope("а документы по этому же договору за тот же период"))
carried = dcl.carry_forward_analysis_context(
{
@@ -271,6 +274,182 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
self.assertNotIn("as_of_date", carried)
self.assertEqual(carried["organization_scope"], {"label": "ООО Альтернатива Плюс"})
def test_temporal_reset_question_clears_stale_navigation_date_scope(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="docs_reset_demo",
domain="agentic_loop",
step={
"step_id": "step_01",
"title": "Docs root",
"depends_on": [],
"question_template": "по чепурнову покажи все доки",
"expected_intents": ["list_documents_by_counterparty"],
"expected_recipe": "address_documents_by_counterparty_v1",
},
step_index=1,
question_resolved="по чепурнову покажи все доки",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "factual",
"text": "Контрагент: Чепурнов П.Д. Найдено документов: 1.",
"message_id": "msg-1",
"trace_id": "trace-1",
},
"technical_debug_payload": {
"detected_mode": "address_query",
"detected_intent": "list_documents_by_counterparty",
"selected_recipe": "address_documents_by_counterparty_v1",
"capability_id": "documents_drilldown",
"capability_contract_id": "documents_drilldown",
"truth_gate_contract_status": "full_confirmed",
"extracted_filters": {"counterparty": "Чепурнов", "organization": "ООО Альтернатива Плюс"},
},
"session_summary": {
"address_navigation_state": {
"session_context": {
"date_scope": {
"as_of_date": "2017-05-31",
"period_from": None,
"period_to": None,
}
}
}
},
},
entries=[],
)
self.assertIsNone(step_state["date_scope"]["as_of_date"])
self.assertEqual(step_state["date_scope"]["source"], "question_temporal_scope_reset")
def test_open_items_exact_negative_answer_validates_without_rows(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="open_items_negative_demo",
domain="agentic_loop",
step={
"step_id": "step_01",
"title": "Open items account 60",
"depends_on": [],
"question_template": "хвосты покажи по счету 60 на август 2022",
"expected_intents": ["open_items_by_counterparty_or_contract"],
},
step_index=1,
question_resolved="хвосты покажи по счету 60 на август 2022",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "partial_coverage",
"text": "По счету 60 за период 2022-08-01..2022-08-31 открытых остатков не найдено.",
"message_id": "msg-1",
"trace_id": "trace-1",
},
"technical_debug_payload": {
"detected_mode": "address_query",
"detected_intent": "open_items_by_counterparty_or_contract",
"selected_recipe": "address_open_items_by_party_or_contract_v1",
"capability_id": "address_open_items_by_counterparty_or_contract",
"capability_contract_id": "address_open_items_by_counterparty_or_contract",
"truth_gate_contract_status": "full_confirmed",
"response_type": "FACTUAL_SUMMARY",
"result_mode": "confirmed_balance",
"truth_mode": "limited",
"answer_shape": "limited_with_reason",
"mcp_call_status": "no_raw_rows",
"balance_confirmed": False,
"extracted_filters": {
"account": "60",
"period_from": "2022-08-01",
"period_to": "2022-08-31",
"as_of_date": "2022-08-31",
},
},
"session_summary": {},
},
entries=[],
)
self.assertEqual(step_state["status"], "validated")
self.assertTrue(step_state["runtime_factual_answer_validated"])
def test_required_organization_filter_accepts_quoted_legal_name(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="quoted_org_filter_demo",
domain="agentic_loop",
step={
"step_id": "step_01",
"title": "Inventory root",
"depends_on": [],
"question_template": "что там на складе по остаткам",
"expected_intents": ["inventory_on_hand_as_of_date"],
"required_filters": {"organization": "ООО Альтернатива Плюс"},
},
step_index=1,
question_resolved="что там на складе по остаткам",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "factual",
"text": "На складе подтверждено 11 позиций.",
"message_id": "msg-1",
"trace_id": "trace-1",
},
"technical_debug_payload": {
"detected_mode": "address_query",
"detected_intent": "inventory_on_hand_as_of_date",
"selected_recipe": "address_inventory_on_hand_as_of_date_v1",
"capability_id": "confirmed_inventory_on_hand_as_of_date",
"capability_contract_id": "confirmed_inventory_on_hand_as_of_date",
"truth_gate_contract_status": "full_confirmed",
"response_type": "FACTUAL_LIST",
"truth_mode": "confirmed",
"answer_shape": "confirmed_factual",
"mcp_call_status": "matched_non_empty",
"balance_confirmed": True,
"extracted_filters": {"organization": 'ООО "Альтернатива Плюс"', "as_of_date": "2026-06-02"},
},
"session_summary": {},
},
entries=[],
)
self.assertEqual(step_state["status"], "validated")
self.assertNotIn("missing_required_filter", step_state["violated_invariants"])
def test_clean_meta_chat_answer_validates_without_exact_capability(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="meta_chat_demo",
domain="agentic_loop",
step={
"step_id": "step_01",
"title": "Capability meta",
"depends_on": [],
"question_template": "расскажи что можешь интересного",
"semantic_tags": ["meta_capability", "human_answer_quality"],
"required_answer_patterns_all": ["(?i)1с", "(?i)могу"],
},
step_index=1,
question_resolved="расскажи что можешь интересного",
analysis_context={},
turn_artifact={
"assistant_message": {
"reply_type": "factual_with_explanation",
"text": "Могу помочь с быстрым анализом данных 1С: долги, остатки, НДС и документы.",
"message_id": "msg-1",
"trace_id": "trace-1",
},
"technical_debug_payload": {
"detected_mode": "chat",
"detected_intent": None,
},
"session_summary": {},
},
entries=[],
)
self.assertEqual(step_state["status"], "validated")
self.assertTrue(step_state["clean_meta_chat_answer_validated"])
def test_merge_scenario_date_scope_keeps_current_scope_over_stale_previous(self) -> None:
merged = dcl.merge_scenario_date_scope(
{