From 4060a5e575644601400b14443e94d8a209c88821 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 29 Mar 2026 22:41:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=94=D0=A0=D0=95=D0=A1=D0=9D=D0=AB?= =?UTF-8?q?=D0=99=20=D0=A0=D0=95=D0=96=D0=98=D0=9C=20-=20M2.3c=20=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=BE=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2=20=D0=BE=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BA=D0=BE=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1.txt | 225 ++++++++++++++++++ .../dist/services/addressFilterExtractor.js | 25 +- .../dist/services/addressRecipeCatalog.js | 17 +- .../src/services/addressFilterExtractor.ts | 30 ++- .../src/services/addressRecipeCatalog.ts | 21 +- .../tests/addressQueryRuntimeM23.test.ts | 59 +++++ 6 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/1.txt diff --git a/docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/1.txt b/docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/1.txt new file mode 100644 index 0000000..aaf591a --- /dev/null +++ b/docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/1.txt @@ -0,0 +1,225 @@ +# Assistant conversation export +session_id: asst-6FlZY4h6vE +export_mode: technical +exported_at: 2026-03-29T18:54:42.757Z + +## 1. user +message_id: msg-ytF_ybdXTx +created_at: 2026-03-29T18:52:46.296Z +reply_type: n/a + +Какой остаток по счету 60 на дату 2020-07-31 + +## 2. assistant +message_id: msg-lqh9qULwpD +created_at: 2026-03-29T18:52:47.102Z +reply_type: partial_coverage +trace_id: address-u6vpJC9cQq + +Не удалось выдать полноценный адресный factual-ответ в V1. +Причина: по выбранному recipe нет строк в live-выборке. +Что нужно уточнить: уточнить период или фильтры. + +### technical_debug_payload_json +```json +{ + "trace_id": "address-u6vpJC9cQq", + "prompt_version": "address_query_runtime_v1", + "schema_version": "address_query_runtime_v1", + "fallback_type": "partial", + "route_summary": null, + "fragments": [], + "requirements_extracted": [], + "coverage_report": { + "requirements_total": 0, + "requirements_covered": 0, + "requirements_uncovered": [], + "requirements_partially_covered": [], + "clarification_needed_for": [], + "out_of_scope_requirements": [] + }, + "routes": [], + "retrieval_status": [], + "retrieval_results": [], + "answer_grounding_check": { + "status": "partial", + "route_subject_match": true, + "missing_requirements": [], + "reasons": [ + "address_action_detected", + "address_entity_detected", + "account_balance_signal_detected" + ], + "why_included_summary": [], + "selection_reason_summary": [] + }, + "dropped_intent_segments": [], + "detected_mode": "address_query", + "detected_mode_confidence": "high", + "detected_intent": "account_balance_snapshot", + "detected_intent_confidence": "high", + "extracted_filters": { + "sort": "period_desc", + "limit": 20, + "account": "60", + "as_of_date": "2031-07-20" + }, + "missing_required_filters": [], + "selected_recipe": "address_movements_account_snapshot_v1", + "mcp_call_status": "empty", + "rows_fetched": 20, + "rows_matched": 0, + "response_type": "LIMITED_WITH_REASON", + "answer_structure_v11": null, + "investigation_state_snapshot": null, + "normalized": null, + "normalizer_output": null +} +``` + +## 3. user +message_id: msg-SHZVsxgJlv +created_at: 2026-03-29T18:53:30.732Z +reply_type: n/a + +Какие договоры не закрыты на дату 2020-07-31 + +## 4. assistant +message_id: msg-YXWwlMLzBZ +created_at: 2026-03-29T18:53:31.811Z +reply_type: partial_coverage +trace_id: address-4ozG_gIFqA + +Не удалось выдать полноценный адресный factual-ответ в V1. +Причина: в live строках нет явных договорных аналитик для уверенного списка незакрытых договоров. +Что нужно уточнить: сузить запрос по контрагенту или добавить номер договора. + +### technical_debug_payload_json +```json +{ + "trace_id": "address-4ozG_gIFqA", + "prompt_version": "address_query_runtime_v1", + "schema_version": "address_query_runtime_v1", + "fallback_type": "partial", + "route_summary": null, + "fragments": [], + "requirements_extracted": [], + "coverage_report": { + "requirements_total": 0, + "requirements_covered": 0, + "requirements_uncovered": [], + "requirements_partially_covered": [], + "clarification_needed_for": [], + "out_of_scope_requirements": [] + }, + "routes": [], + "retrieval_status": [], + "retrieval_results": [], + "answer_grounding_check": { + "status": "partial", + "route_subject_match": true, + "missing_requirements": [], + "reasons": [ + "address_action_detected", + "address_entity_detected", + "open_contract_signal_detected" + ], + "why_included_summary": [], + "selection_reason_summary": [] + }, + "dropped_intent_segments": [], + "detected_mode": "address_query", + "detected_mode_confidence": "high", + "detected_intent": "list_open_contracts", + "detected_intent_confidence": "medium", + "extracted_filters": { + "sort": "period_desc", + "limit": 20, + "as_of_date": "2031-07-20" + }, + "missing_required_filters": [], + "selected_recipe": "address_open_contracts_candidates_v1", + "mcp_call_status": "empty", + "rows_fetched": 20, + "rows_matched": 0, + "response_type": "LIMITED_WITH_REASON", + "answer_structure_v11": null, + "investigation_state_snapshot": null, + "normalized": null, + "normalizer_output": null +} +``` + +## 5. user +message_id: msg-Z2N2uECEpu +created_at: 2026-03-29T18:54:31.437Z +reply_type: n/a + +Какие документы формируют остаток по счету 62 на дату 2020-07-31 + +## 6. assistant +message_id: msg-qsm4s1CpVs +created_at: 2026-03-29T18:54:31.793Z +reply_type: partial_coverage +trace_id: address-zx8NZ3oPYI + +Не удалось выдать полноценный адресный factual-ответ в V1. +Причина: по выбранному recipe нет строк в live-выборке. +Что нужно уточнить: уточнить период или фильтры. + +### technical_debug_payload_json +```json +{ + "trace_id": "address-zx8NZ3oPYI", + "prompt_version": "address_query_runtime_v1", + "schema_version": "address_query_runtime_v1", + "fallback_type": "partial", + "route_summary": null, + "fragments": [], + "requirements_extracted": [], + "coverage_report": { + "requirements_total": 0, + "requirements_covered": 0, + "requirements_uncovered": [], + "requirements_partially_covered": [], + "clarification_needed_for": [], + "out_of_scope_requirements": [] + }, + "routes": [], + "retrieval_status": [], + "retrieval_results": [], + "answer_grounding_check": { + "status": "partial", + "route_subject_match": true, + "missing_requirements": [], + "reasons": [ + "address_action_detected", + "address_entity_detected", + "account_balance_signal_detected" + ], + "why_included_summary": [], + "selection_reason_summary": [] + }, + "dropped_intent_segments": [], + "detected_mode": "address_query", + "detected_mode_confidence": "high", + "detected_intent": "account_balance_snapshot", + "detected_intent_confidence": "high", + "extracted_filters": { + "sort": "period_desc", + "limit": 20, + "account": "62", + "as_of_date": "2031-07-20" + }, + "missing_required_filters": [], + "selected_recipe": "address_movements_account_snapshot_v1", + "mcp_call_status": "empty", + "rows_fetched": 20, + "rows_matched": 0, + "response_type": "LIMITED_WITH_REASON", + "answer_structure_v11": null, + "investigation_state_snapshot": null, + "normalized": null, + "normalizer_output": null +} +``` diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 21ed0cd..b3dcafa 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -78,11 +78,29 @@ function cleanupAnchorValue(value) { if (!normalized) { return ""; } + // Remove trailing period qualifiers that can be swallowed by broad anchor regexes: + // " с 2020-07-01 по 2020-07-31", " from 2020-07-01 to 2020-07-31" + const periodTailPattern = /\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu; + if (periodTailPattern.test(normalized)) { + return normalized.replace(periodTailPattern, "").trim(); + } + const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu; + if (allTimeTailPattern.test(normalized)) { + return normalized.replace(allTimeTailPattern, "").trim(); + } + const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu; + if (allTimeTailPatternEn.test(normalized)) { + return normalized.replace(allTimeTailPatternEn, "").trim(); + } return normalized - .replace(/\s+(?:from|to|between|and)\b[\s\S]*$/i, "") - .replace(/\s+(?:с|по|за)\b[\s\S]*$/i, "") + .replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "") + .replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "") .trim(); } +function hasAllTimeHint(text) { + const value = String(text ?? ""); + return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value); +} function shiftDaysIso(baseIso, deltaDays) { const date = new Date(`${baseIso}T00:00:00.000Z`); date.setUTCDate(date.getUTCDate() + deltaDays); @@ -140,7 +158,8 @@ function extractAddressFilters(userMessage, intent) { // For document/bank lists we default to a short recent window if no explicit period was provided. if ((intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") && !filters.period_from && - !filters.period_to) { + !filters.period_to && + !hasAllTimeHint(text)) { const today = new Date().toISOString().slice(0, 10); filters.period_to = today; filters.period_from = shiftDaysIso(today, -90); diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 544f0fa..08840e1 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -162,6 +162,16 @@ function buildWhereClause(filters, fieldPath) { } return ""; } +function shouldBoostLimitForAllTimeCounterparty(filters) { + const hasCounterparty = typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0; + if (!hasCounterparty) { + return false; + } + const hasPeriod = Boolean((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0) || + (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0)); + return !hasPeriod; +} function selectAddressRecipe(intent, filters) { const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null; if (!recipe) { @@ -182,9 +192,14 @@ function selectAddressRecipe(intent, filters) { }; } function buildAddressRecipePlan(recipe, filters) { - const resolvedLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) + const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.min(200, Math.trunc(filters.limit))) : recipe.default_limit; + const boostedLimit = (recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") && + shouldBoostLimitForAllTimeCounterparty(filters) + ? Math.max(baseLimit, 200) + : baseLimit; + const resolvedLimit = Math.max(1, Math.min(200, boostedLimit)); const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account ? [String(filters.account)] : Array.isArray(recipe.account_scope) diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index ac1af06..801e9f1 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -86,12 +86,35 @@ function cleanupAnchorValue(value: string): string { if (!normalized) { return ""; } + + // Remove trailing period qualifiers that can be swallowed by broad anchor regexes: + // " с 2020-07-01 по 2020-07-31", " from 2020-07-01 to 2020-07-31" + const periodTailPattern = + /\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu; + if (periodTailPattern.test(normalized)) { + return normalized.replace(periodTailPattern, "").trim(); + } + + const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu; + if (allTimeTailPattern.test(normalized)) { + return normalized.replace(allTimeTailPattern, "").trim(); + } + const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu; + if (allTimeTailPatternEn.test(normalized)) { + return normalized.replace(allTimeTailPatternEn, "").trim(); + } + return normalized - .replace(/\s+(?:from|to|between|and)\b[\s\S]*$/i, "") - .replace(/\s+(?:с|по|за)\b[\s\S]*$/i, "") + .replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "") + .replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "") .trim(); } +function hasAllTimeHint(text: string): boolean { + const value = String(text ?? ""); + return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value); +} + function shiftDaysIso(baseIso: string, deltaDays: number): string { const date = new Date(`${baseIso}T00:00:00.000Z`); date.setUTCDate(date.getUTCDate() + deltaDays); @@ -159,7 +182,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent if ( (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") && !filters.period_from && - !filters.period_to + !filters.period_to && + !hasAllTimeHint(text) ) { const today = new Date().toISOString().slice(0, 10); filters.period_to = today; diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index ef3cfb7..90bcba0 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -183,6 +183,19 @@ function buildWhereClause(filters: AddressFilterSet, fieldPath: string): string return ""; } +function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean { + const hasCounterparty = typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0; + if (!hasCounterparty) { + return false; + } + const hasPeriod = Boolean( + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0) || + (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0) + ); + return !hasPeriod; +} + export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection { const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null; if (!recipe) { @@ -209,10 +222,16 @@ export function buildAddressRecipePlan( recipe: AddressRecipeDefinition, filters: AddressFilterSet ): AddressRecipeExecutionPlan { - const resolvedLimit = + const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.min(200, Math.trunc(filters.limit))) : recipe.default_limit; + const boostedLimit = + (recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") && + shouldBoostLimitForAllTimeCounterparty(filters) + ? Math.max(baseLimit, 200) + : baseLimit; + const resolvedLimit = Math.max(1, Math.min(200, boostedLimit)); const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index b3d7f42..e146ef9 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -3,6 +3,7 @@ import { resolveAddressIntent } from "../src/services/addressIntentResolver"; import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier"; import { extractAddressFilters } from "../src/services/addressFilterExtractor"; import { AddressQueryService } from "../src/services/addressQueryService"; +import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog"; describe("address query shape classifier", () => { it("classifies explain question as deep-shape", () => { @@ -46,6 +47,27 @@ describe("address filter extraction for balance drilldown", () => { expect(result.extracted_filters.as_of_date).toBeDefined(); expect(result.missing_required_filters).toEqual([]); }); + + it("cuts period tail from counterparty anchor", () => { + const result = extractAddressFilters( + "Покажи документы по контрагенту test_cp с 2020-07-01 по 2020-07-31", + "list_documents_by_counterparty" + ); + expect(result.extracted_filters.counterparty).toBe("test_cp"); + expect(result.extracted_filters.period_from).toBe("2020-07-01"); + expect(result.extracted_filters.period_to).toBe("2020-07-31"); + }); + + it("cuts all-time tail from counterparty anchor and skips 90-day default window", () => { + const result = extractAddressFilters( + "Покажи документы по контрагенту тестовый за все время", + "list_documents_by_counterparty" + ); + expect(result.extracted_filters.counterparty).toBe("тестовый"); + expect(result.extracted_filters.period_from).toBeUndefined(); + expect(result.extracted_filters.period_to).toBeUndefined(); + expect(result.warnings).not.toContain("period_defaulted_last_90_days"); + }); }); describe("address query limited taxonomy and stage diagnostics", () => { @@ -101,3 +123,40 @@ describe("address query limited taxonomy and stage diagnostics", () => { expect(result?.debug.account_scope_drop_reason).toBeDefined(); }); }); + +describe("address recipe catalog counterparty filtering", () => { + it("boosts limit for all-time counterparty queries", () => { + const filters = extractAddressFilters( + "Покажи документы по контрагенту тестовый за все время", + "list_documents_by_counterparty" + ).extracted_filters; + const selected = selectAddressRecipe("list_documents_by_counterparty", filters); + expect(selected.selected_recipe).toBeTruthy(); + const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); + + expect(plan.limit).toBe(200); + }); + + it("boosts limit for english all-time counterparty queries", () => { + const filters = extractAddressFilters( + "show documents by counterparty test_cp for all time", + "list_documents_by_counterparty" + ).extracted_filters; + const selected = selectAddressRecipe("list_documents_by_counterparty", filters); + expect(selected.selected_recipe).toBeTruthy(); + const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); + + expect(plan.limit).toBe(200); + }); + + it("cuts english all-time tail from counterparty anchor", () => { + const result = extractAddressFilters( + "show documents by counterparty test_cp for all time", + "list_documents_by_counterparty" + ); + expect(result.extracted_filters.counterparty).toBe("test_cp"); + expect(result.extracted_filters.period_from).toBeUndefined(); + expect(result.extracted_filters.period_to).toBeUndefined(); + expect(result.warnings).not.toContain("period_defaulted_last_90_days"); + }); +});