From 84beaf554016bd0b06076f99780e5e7f47927bc2 Mon Sep 17 00:00:00 2001 From: dctouch Date: Thu, 23 Apr 2026 20:01:03 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D1=83=D0=B4=D0=B5=D1=80=D0=B6=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20year-switch=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20c?= =?UTF-8?q?ontracts=20pivot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...e72_document_to_contracts_year_switch.json | 53 +++++++++++ ...hase73_document_to_contracts_all_time.json | 53 +++++++++++ .../dist/services/addressQueryService.js | 69 ++++++++++++++- .../dist/services/addressRecipeCatalog.js | 2 +- .../src/services/addressQueryService.ts | 88 ++++++++++++++++++- .../src/services/addressRecipeCatalog.ts | 2 +- .../tests/addressQueryRuntimeM23.test.ts | 22 +++++ 7 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase72_document_to_contracts_year_switch.json create mode 100644 docs/orchestration/address_truth_harness_phase73_document_to_contracts_all_time.json diff --git a/docs/orchestration/address_truth_harness_phase72_document_to_contracts_year_switch.json b/docs/orchestration/address_truth_harness_phase72_document_to_contracts_year_switch.json new file mode 100644 index 0000000..29ecd25 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase72_document_to_contracts_year_switch.json @@ -0,0 +1,53 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase72_document_to_contracts_year_switch", + "domain": "address_phase72_document_to_contracts_year_switch", + "title": "Phase 72 document to contracts year-switch continuity", + "description": "Replay for a human chain where the user opens documents by counterparty, pivots to contracts with a pronoun follow-up, and then switches the year without renaming the counterparty.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_documents_by_counterparty", + "title": "Open documents for the counterparty", + "question": "Покажи документы по Жуковке 51.", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк", + "(?i)документ|сч[её]т|акт|накладн|строк" + ], + "criticality": "critical", + "semantic_tags": ["documents_by_counterparty", "pronoun_pivot_seed", "integrity_guard"] + }, + { + "step_id": "step_02_contracts_by_pronoun_followup", + "title": "Pivot to contracts via pronoun follow-up", + "question": "А по нему договоры?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк|контрагент", + "(?i)договор|контракт|соглаш" + ], + "criticality": "critical", + "semantic_tags": ["contracts_followup", "counterparty_pronoun_resolution", "integrity_guard"] + }, + { + "step_id": "step_03_year_switch_after_contracts_pivot", + "title": "Switch the year without renaming the counterparty", + "question": "А за 2021?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2021", + "(?i)жуковк|контрагент", + "(?i)договор|контракт|соглаш" + ], + "forbidden_answer_patterns": [ + "(?i)уточните .* контрагент", + "(?i)метадан", + "(?i)схем", + "(?i)объект[а-я]* 1с" + ], + "criticality": "critical", + "semantic_tags": ["year_switch_after_pivot", "contracts_followup", "integrity_guard"] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase73_document_to_contracts_all_time.json b/docs/orchestration/address_truth_harness_phase73_document_to_contracts_all_time.json new file mode 100644 index 0000000..49020e7 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase73_document_to_contracts_all_time.json @@ -0,0 +1,53 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase73_document_to_contracts_all_time", + "domain": "address_phase73_document_to_contracts_all_time", + "title": "Phase 73 document to contracts all-time continuity", + "description": "Replay for a human chain where the user opens documents by counterparty, pivots to contracts with a pronoun follow-up, and then asks for all-time scope without renaming the counterparty.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_documents_by_counterparty", + "title": "Open documents for the counterparty", + "question": "Покажи документы по Жуковке 51.", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк", + "(?i)документ|сч[её]т|акт|накладн|строк" + ], + "criticality": "critical", + "semantic_tags": ["documents_by_counterparty", "pronoun_pivot_seed", "integrity_guard"] + }, + { + "step_id": "step_02_contracts_by_pronoun_followup", + "title": "Pivot to contracts via pronoun follow-up", + "question": "А по нему договоры?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк|контрагент", + "(?i)договор|контракт|соглаш" + ], + "criticality": "critical", + "semantic_tags": ["contracts_followup", "counterparty_pronoun_resolution", "integrity_guard"] + }, + { + "step_id": "step_03_all_time_after_contracts_pivot", + "title": "Switch to all-time scope without renaming the counterparty", + "question": "А за все время?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк|контрагент", + "(?i)договор|контракт|соглаш" + ], + "forbidden_answer_patterns": [ + "(?i)уточните .* контрагент", + "(?i)уточните .* период", + "(?i)метадан", + "(?i)схем", + "(?i)объект[а-я]* 1с" + ], + "criticality": "critical", + "semantic_tags": ["all_time_after_pivot", "contracts_followup", "integrity_guard"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 71b69c1..70d356b 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1839,6 +1839,49 @@ function applyFutureDatedRowsGuard(rows, intent, referenceDate) { droppedCount }; } +function applyExplicitPeriodWindowFilter(rows, filters) { + const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : ""; + const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : ""; + if (!periodFrom && !periodTo) { + return { + rows, + droppedCount: 0, + applied: false + }; + } + const periodFromTs = periodFrom ? parseIsoDateUtcTimestamp(periodFrom) : null; + const periodToTs = periodTo ? parseIsoDateUtcTimestamp(periodTo) : null; + if ((periodFrom && periodFromTs === null) || (periodTo && periodToTs === null)) { + return { + rows, + droppedCount: 0, + applied: false + }; + } + const keptRows = []; + let droppedCount = 0; + for (const row of rows) { + const rowTs = parseIsoDateUtcTimestamp(row.period); + if (rowTs === null) { + droppedCount += 1; + continue; + } + if (periodFromTs !== null && rowTs < periodFromTs) { + droppedCount += 1; + continue; + } + if (periodToTs !== null && rowTs > periodToTs) { + droppedCount += 1; + continue; + } + keptRows.push(row); + } + return { + rows: keptRows, + droppedCount, + applied: true + }; +} function hasExplicitPeriodWindow(filters) { return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || (typeof filters.period_to === "string" && filters.period_to.trim().length > 0)); @@ -1855,6 +1898,7 @@ function canAutoBroadenPeriodWindow(intent, filters) { } return (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || + intent === "list_contracts_by_counterparty" || intent === "list_documents_by_contract" || intent === "bank_operations_by_contract" || intent === "inventory_purchase_provenance_for_item" || @@ -3327,7 +3371,8 @@ class AddressQueryService { }); let anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); let filterByAnchors = anchorFilter.rows; - let filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors); + let explicitPeriodWindowFilter = applyExplicitPeriodWindowFilter(filterByAnchors, executionFilters); + let filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, explicitPeriodWindowFilter.rows); let filteredRowsFutureGuard = applyFutureDatedRowsGuard(filteredRowsBeforeFutureGuard, intent.intent, futureGuardReferenceDate); let filteredRows = filteredRowsFutureGuard.rows; let organizationWarehouseRecoveryApplied = false; @@ -3369,7 +3414,8 @@ class AddressQueryService { } anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); filterByAnchors = anchorFilter.rows; - filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors); + explicitPeriodWindowFilter = applyExplicitPeriodWindowFilter(filterByAnchors, executionFilters); + filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, explicitPeriodWindowFilter.rows); filteredRowsFutureGuard = applyFutureDatedRowsGuard(filteredRowsBeforeFutureGuard, intent.intent, futureGuardReferenceDate); filteredRows = filteredRowsFutureGuard.rows; organizationWarehouseRecoveryApplied = filteredRows.length > 0; @@ -3382,6 +3428,19 @@ class AddressQueryService { baseReasons.push("future_rows_excluded_from_response"); } } + if (explicitPeriodWindowFilter.applied) { + if (!baseReasons.includes("explicit_period_window_post_filter_applied")) { + baseReasons.push("explicit_period_window_post_filter_applied"); + } + if (explicitPeriodWindowFilter.droppedCount > 0) { + if (!filters.warnings.includes("rows_outside_explicit_period_window_excluded")) { + filters.warnings.push("rows_outside_explicit_period_window_excluded"); + } + if (!baseReasons.includes("rows_outside_explicit_period_window_excluded")) { + baseReasons.push("rows_outside_explicit_period_window_excluded"); + } + } + } const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length); const stageStatus = deriveMcpStageStatus({ rawRowsReceived: mcp.raw_rows.length, @@ -3758,7 +3817,8 @@ class AddressQueryService { }); const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching); const expandedRowsByAnchor = expandedAnchorFilter.rows; - const expandedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor); + const expandedExplicitPeriodWindowFilter = applyExplicitPeriodWindowFilter(expandedRowsByAnchor, expandedLimitFilters); + const expandedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, expandedExplicitPeriodWindowFilter.rows); const expandedFutureGuard = applyFutureDatedRowsGuard(expandedFilteredRowsBeforeFutureGuard, intent.intent, resolveFutureGuardReferenceDate(analysisDate, expandedLimitFilters)); const expandedFilteredRows = expandedFutureGuard.rows; if (expandedFutureGuard.droppedCount > 0) { @@ -3981,7 +4041,8 @@ class AddressQueryService { }); const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching); const historicalRowsByAnchor = historicalAnchorFilter.rows; - const historicalFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor); + const historicalExplicitPeriodWindowFilter = applyExplicitPeriodWindowFilter(historicalRowsByAnchor, historicalFilters); + const historicalFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, historicalExplicitPeriodWindowFilter.rows); const historicalFutureGuard = applyFutureDatedRowsGuard(historicalFilteredRowsBeforeFutureGuard, intent.intent, resolveFutureGuardReferenceDate(analysisDate, historicalFilters)); const historicalFilteredRows = historicalFutureGuard.rows; if (historicalFutureGuard.droppedCount > 0) { diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index d8c9c27..7434e86 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -871,7 +871,7 @@ const BASE_RECIPES = [ intent: "list_contracts_by_counterparty", purpose: "List contracts by counterparty from contract catalog", required_filters: ["counterparty"], - optional_filters: ["limit", "sort"], + optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"], default_limit: 300, account_scope_mode: "preferred", query_template: "contracts_by_counterparty_profile" diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 80a568a..179bc01 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -2274,6 +2274,56 @@ function applyFutureDatedRowsGuard( }; } +function applyExplicitPeriodWindowFilter( + rows: NormalizedAddressRow[], + filters: AddressFilterSet +): { rows: NormalizedAddressRow[]; droppedCount: number; applied: boolean } { + const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : ""; + const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : ""; + if (!periodFrom && !periodTo) { + return { + rows, + droppedCount: 0, + applied: false + }; + } + + const periodFromTs = periodFrom ? parseIsoDateUtcTimestamp(periodFrom) : null; + const periodToTs = periodTo ? parseIsoDateUtcTimestamp(periodTo) : null; + if ((periodFrom && periodFromTs === null) || (periodTo && periodToTs === null)) { + return { + rows, + droppedCount: 0, + applied: false + }; + } + + const keptRows: NormalizedAddressRow[] = []; + let droppedCount = 0; + for (const row of rows) { + const rowTs = parseIsoDateUtcTimestamp(row.period); + if (rowTs === null) { + droppedCount += 1; + continue; + } + if (periodFromTs !== null && rowTs < periodFromTs) { + droppedCount += 1; + continue; + } + if (periodToTs !== null && rowTs > periodToTs) { + droppedCount += 1; + continue; + } + keptRows.push(row); + } + + return { + rows: keptRows, + droppedCount, + applied: true + }; +} + function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean { return ( (typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || @@ -2295,6 +2345,7 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte return ( intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || + intent === "list_contracts_by_counterparty" || intent === "list_documents_by_contract" || intent === "bank_operations_by_contract" || intent === "inventory_purchase_provenance_for_item" || @@ -4082,7 +4133,8 @@ export class AddressQueryService { }); let anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); let filterByAnchors = anchorFilter.rows; - let filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors); + let explicitPeriodWindowFilter = applyExplicitPeriodWindowFilter(filterByAnchors, executionFilters); + let filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, explicitPeriodWindowFilter.rows); let filteredRowsFutureGuard = applyFutureDatedRowsGuard( filteredRowsBeforeFutureGuard, intent.intent, @@ -4130,7 +4182,8 @@ export class AddressQueryService { } anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); filterByAnchors = anchorFilter.rows; - filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors); + explicitPeriodWindowFilter = applyExplicitPeriodWindowFilter(filterByAnchors, executionFilters); + filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, explicitPeriodWindowFilter.rows); filteredRowsFutureGuard = applyFutureDatedRowsGuard( filteredRowsBeforeFutureGuard, intent.intent, @@ -4147,6 +4200,19 @@ export class AddressQueryService { baseReasons.push("future_rows_excluded_from_response"); } } + if (explicitPeriodWindowFilter.applied) { + if (!baseReasons.includes("explicit_period_window_post_filter_applied")) { + baseReasons.push("explicit_period_window_post_filter_applied"); + } + if (explicitPeriodWindowFilter.droppedCount > 0) { + if (!filters.warnings.includes("rows_outside_explicit_period_window_excluded")) { + filters.warnings.push("rows_outside_explicit_period_window_excluded"); + } + if (!baseReasons.includes("rows_outside_explicit_period_window_excluded")) { + baseReasons.push("rows_outside_explicit_period_window_excluded"); + } + } + } const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length); const stageStatus = deriveMcpStageStatus({ rawRowsReceived: mcp.raw_rows.length, @@ -4599,7 +4665,14 @@ export class AddressQueryService { }); const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching); const expandedRowsByAnchor = expandedAnchorFilter.rows; - const expandedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor); + const expandedExplicitPeriodWindowFilter = applyExplicitPeriodWindowFilter( + expandedRowsByAnchor, + expandedLimitFilters + ); + const expandedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter( + intent.intent, + expandedExplicitPeriodWindowFilter.rows + ); const expandedFutureGuard = applyFutureDatedRowsGuard( expandedFilteredRowsBeforeFutureGuard, intent.intent, @@ -4859,7 +4932,14 @@ export class AddressQueryService { }); const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching); const historicalRowsByAnchor = historicalAnchorFilter.rows; - const historicalFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor); + const historicalExplicitPeriodWindowFilter = applyExplicitPeriodWindowFilter( + historicalRowsByAnchor, + historicalFilters + ); + const historicalFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter( + intent.intent, + historicalExplicitPeriodWindowFilter.rows + ); const historicalFutureGuard = applyFutureDatedRowsGuard( historicalFilteredRowsBeforeFutureGuard, intent.intent, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index aae1fa5..4c316a5 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -895,7 +895,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ intent: "list_contracts_by_counterparty", purpose: "List contracts by counterparty from contract catalog", required_filters: ["counterparty"], - optional_filters: ["limit", "sort"], + optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"], default_limit: 300, account_scope_mode: "preferred", query_template: "contracts_by_counterparty_profile" diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 8d3b354..e617692 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -4080,6 +4080,28 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 }); }); +it("auto-broadens out-of-window period after contracts pivot and keeps requested year in the reply", async () => { + const service = new AddressQueryService(); + const seed = await service.tryHandle("покажи РґРѕРіРѕРІРѕСЂР° РІСЃРµ РїРѕ Р¶СѓРєРѕРІРєРµ 51"); + expect(seed?.handled).toBe(true); + expect(seed?.debug.detected_intent).toBe("list_contracts_by_counterparty"); + + const result = await service.tryHandle("Р° Р·Р° 2021?", { + followupContext: { + previous_intent: (seed?.debug.detected_intent as any) ?? "list_contracts_by_counterparty", + previous_filters: seed?.debug.extracted_filters, + previous_anchor_type: (seed?.debug.anchor_type as any) ?? "counterparty", + previous_anchor_value: seed?.debug.anchor_value_resolved ?? seed?.debug.anchor_value_raw ?? null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_contracts_by_counterparty"); + expect(result?.debug.selected_recipe).toBe("address_contracts_by_counterparty_v1"); + expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data"); + expect(String(result?.reply_text ?? "")).toContain("2021"); +}); + describe("address decompose stage follow-up carryover", () => { it("promotes selected-object supplier slang follow-up into inventory provenance with inherited date context", () => { const result = runAddressDecomposeStage('По выбранному объекту "Столешница 600*3050*26 дуб ниагара": кто это поставил нам', {