diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js index 5fe11d2..a9ebf91 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js @@ -407,6 +407,17 @@ exports.ROOT_EXACT_CAPABILITY_CONTRACTS = [ resultShape: "open_contracts_snapshot", answerObjectShape: "open_contracts_snapshot" }), + rootExactCapability({ + capability_id: "address_open_items_by_counterparty_or_contract", + domainId: "contracts", + intent_ids: ["open_items_by_counterparty_or_contract"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + optionalAnchors: ["organization", "date_scope", "account", "counterparty", "contract"], + resultShape: "open_items_by_account_counterparty_or_contract", + answerObjectShape: "open_items_snapshot", + scenarioFamilies: ["canonical", "colloquial", "account_scope", "exact_negative_answer"] + }), rootExactCapability({ capability_id: "confirmed_vat_payable_as_of_date", domainId: "vat", diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 400b919..6c788eb 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -33,6 +33,28 @@ function createAssistantTransitionPolicy(deps) { .some((normalized) => normalized && /(?:\b(?:same|this|that)\s+period\b|(?:за|на)\s+(?:этот|тот|такой\s+же|тот\s+же)\s+период|(?:Р·Р°|РЅР°)\s+(?:этот|тот|такой\s+Р¶Рµ|тот\s+Р¶Рµ)\s+период)/iu.test(normalized)); } + function hasExplicitDateScopeSignal(...values) { + return values + .map((value) => normalizeFollowupText(value)) + .some((normalized) => { + if (!normalized) { + return false; + } + return /(?:\b(?:19|20)\d{2}\b|\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}[./-]\d{1,2}[./-]\d{1,2}\b|за\s+(?:период|месяц|год|квартал)|на\s+(?:дат|конец|сегодня)|январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр|same\s+period|this\s+period|that\s+period)/iu.test(normalized); + }); + } + function shouldClearStaleDateScopeForCounterpartyDocsRoot(targetIntent, sourceIntentHint, userMessage, alternateMessage) { + if (targetIntent !== "list_documents_by_counterparty") { + return false; + } + if (sourceIntentHint === "list_documents_by_counterparty" || sourceIntentHint === "list_documents_by_contract") { + return false; + } + if (hasSamePeriodReferenceCue(userMessage, alternateMessage) || hasExplicitDateScopeSignal(userMessage, alternateMessage)) { + return false; + } + return true; + } function hasBankOperationsPivotCue(text) { const normalized = normalizeFollowupText(text); if (!normalized) { @@ -1224,6 +1246,18 @@ function createAssistantTransitionPolicy(deps) { if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { return null; } + const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const carryoverTargetIntentPreview = explicitIntent ?? predecomposeIntent ?? previousIntent; + if (shouldClearStaleDateScopeForCounterpartyDocsRoot(carryoverTargetIntentPreview, sourceIntentHint, userMessage, alternateMessage)) { + delete previousFilters.as_of_date; + delete previousFilters.period_from; + delete previousFilters.period_to; + delete previousFilters.purchase_date_bridge_selected; + delete previousFilters.purchase_date_bridge_first; + delete previousFilters.purchase_date_bridge_last; + delete previousFilters.purchase_date_bridge_basis; + delete previousFilters.purchase_date_bridge_has_multiple; + } const shouldAttachInventoryRootFrame = Boolean(inventoryRootFrame && (rootScopedPivot || deps.isInventoryRootFrameIntent(sourceIntentHint) || @@ -1238,7 +1272,6 @@ function createAssistantTransitionPolicy(deps) { hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate)); const previousHasPeriodWindow = Boolean(deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to)); - const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const shouldRetargetVatSamePeriod = previousHasPeriodWindow && hasSamePeriodReferenceCue(userMessage, alternateMessage) && (explicitIntent === "vat_payable_confirmed_as_of_date" || diff --git a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts index d9ef9fa..264c7ff 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts @@ -435,6 +435,17 @@ export const ROOT_EXACT_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContra resultShape: "open_contracts_snapshot", answerObjectShape: "open_contracts_snapshot" }), + rootExactCapability({ + capability_id: "address_open_items_by_counterparty_or_contract", + domainId: "contracts", + intent_ids: ["open_items_by_counterparty_or_contract"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + optionalAnchors: ["organization", "date_scope", "account", "counterparty", "contract"], + resultShape: "open_items_by_account_counterparty_or_contract", + answerObjectShape: "open_items_snapshot", + scenarioFamilies: ["canonical", "colloquial", "account_scope", "exact_negative_answer"] + }), rootExactCapability({ capability_id: "confirmed_vat_payable_as_of_date", domainId: "vat", diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index cbcba2a..44de2e8 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -84,6 +84,37 @@ export function createAssistantTransitionPolicy(deps) { ); } + function hasExplicitDateScopeSignal(...values) { + return values + .map((value) => normalizeFollowupText(value)) + .some((normalized) => { + if (!normalized) { + return false; + } + return /(?:\b(?:19|20)\d{2}\b|\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}[./-]\d{1,2}[./-]\d{1,2}\b|за\s+(?:период|месяц|год|квартал)|на\s+(?:дат|конец|сегодня)|январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр|same\s+period|this\s+period|that\s+period)/iu.test( + normalized + ); + }); + } + + function shouldClearStaleDateScopeForCounterpartyDocsRoot( + targetIntent, + sourceIntentHint, + userMessage, + alternateMessage + ) { + if (targetIntent !== "list_documents_by_counterparty") { + return false; + } + if (sourceIntentHint === "list_documents_by_counterparty" || sourceIntentHint === "list_documents_by_contract") { + return false; + } + if (hasSamePeriodReferenceCue(userMessage, alternateMessage) || hasExplicitDateScopeSignal(userMessage, alternateMessage)) { + return false; + } + return true; + } + function hasBankOperationsPivotCue(text) { const normalized = normalizeFollowupText(text); if (!normalized) { @@ -1663,6 +1694,25 @@ export function createAssistantTransitionPolicy(deps) { if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { return null; } + const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const carryoverTargetIntentPreview = explicitIntent ?? predecomposeIntent ?? previousIntent; + if ( + shouldClearStaleDateScopeForCounterpartyDocsRoot( + carryoverTargetIntentPreview, + sourceIntentHint, + userMessage, + alternateMessage + ) + ) { + delete previousFilters.as_of_date; + delete previousFilters.period_from; + delete previousFilters.period_to; + delete previousFilters.purchase_date_bridge_selected; + delete previousFilters.purchase_date_bridge_first; + delete previousFilters.purchase_date_bridge_last; + delete previousFilters.purchase_date_bridge_basis; + delete previousFilters.purchase_date_bridge_has_multiple; + } const shouldAttachInventoryRootFrame = Boolean( inventoryRootFrame && (rootScopedPivot || @@ -1681,7 +1731,6 @@ export function createAssistantTransitionPolicy(deps) { const previousHasPeriodWindow = Boolean( deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to) ); - const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const shouldRetargetVatSamePeriod = previousHasPeriodWindow && hasSamePeriodReferenceCue(userMessage, alternateMessage) && diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 1128812..f04673c 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -4944,6 +4944,27 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.baseReasons).toContain("organization_from_followup_context"); }); + it("does not inherit stale debt date when a new counterparty documents root starts", () => { + const result = runAddressDecomposeStage("покажи документы по свк", { + previous_intent: "receivables_confirmed_as_of_date", + previous_filters: { + organization: 'ООО "Альтернатива Плюс"', + period_from: "2017-05-01", + period_to: "2017-05-31", + as_of_date: "2017-05-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }); + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("list_documents_by_counterparty"); + expect(result?.filters.extracted_filters.counterparty).toBe("свк"); + expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"'); + expect(result?.filters.extracted_filters.as_of_date).toBeUndefined(); + expect(result?.filters.extracted_filters.period_from).toBeUndefined(); + expect(result?.filters.extracted_filters.period_to).toBeUndefined(); + }); + it("inherits as_of_date from previous period for same-date balance follow-up", () => { const result = runAddressDecomposeStage("а по счету 60.01 на ту же дату", { previous_intent: "list_documents_by_counterparty", diff --git a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts index 63deaab..0553124 100644 --- a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts +++ b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts @@ -107,6 +107,16 @@ describe("assistant runtime contract registry", () => { expect(contract?.execution_adapter).toBe("AddressQueryService"); }); + it("declares open-items account/counterparty capability as a contracted runtime route", () => { + const contract = getAssistantCapabilityContractByIntent("open_items_by_counterparty_or_contract"); + + expect(contract?.capability_id).toBe("address_open_items_by_counterparty_or_contract"); + expect(contract?.runtime_lane).toBe("address_exact"); + expect(contract?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]); + expect(contract?.result_shape).toBe("open_items_by_account_counterparty_or_contract"); + expect(contract?.required_scenario_families).toContain("exact_negative_answer"); + }); + it("declares counterparty document and value-flow contracts used by semantic dialog authority replay", () => { const documents = getAssistantCapabilityContractByIntent("list_documents_by_counterparty"); expect(documents?.capability_id).toBe("documents_drilldown"); diff --git a/scripts/domain_case_loop.py b/scripts/domain_case_loop.py index e6d1b21..863f38b 100644 --- a/scripts/domain_case_loop.py +++ b/scripts/domain_case_loop.py @@ -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) diff --git a/scripts/test_domain_case_loop_step_state.py b/scripts/test_domain_case_loop_step_state.py index a8c7d76..5928a79 100644 --- a/scripts/test_domain_case_loop_step_state.py +++ b/scripts/test_domain_case_loop_step_state.py @@ -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( {