diff --git a/docs/orchestration/active_domain_contract.json b/docs/orchestration/active_domain_contract.json index 1454e88..0528486 100644 --- a/docs/orchestration/active_domain_contract.json +++ b/docs/orchestration/active_domain_contract.json @@ -756,7 +756,21 @@ "title": "Current stock root", "question": "Какие товары сейчас лежат на складе", "expected_capability": "confirmed_inventory_on_hand_as_of_date", - "expected_result_mode": "confirmed_balance" + "analysis_context": { + "as_of_date": "2021-09-30", + "source": "binding_target_date_current" + }, + "expected_result_mode": "confirmed_balance", + "required_filters": { + "as_of_date": "2021-09-30", + "period_from": "2021-09-01", + "period_to": "2021-09-30" + }, + "invariant_severity": { + "wrong_as_of_date": "P0", + "wrong_period_from": "P0", + "wrong_period_to": "P0" + } }, { "step_id": "step_02_stock_on_historical_date", @@ -771,7 +785,21 @@ "source": "binding_target_date_historical" }, "expected_capability": "confirmed_inventory_on_hand_as_of_date", - "expected_result_mode": "confirmed_balance" + "analysis_context": { + "as_of_date": "2021-09-30", + "source": "binding_target_date_current" + }, + "expected_result_mode": "confirmed_balance", + "required_filters": { + "as_of_date": "2021-09-30", + "period_from": "2021-09-01", + "period_to": "2021-09-30" + }, + "invariant_severity": { + "wrong_as_of_date": "P0", + "wrong_period_from": "P0", + "wrong_period_to": "P0" + } }, { "step_id": "step_02b_stock_on_named_month_prepositional", @@ -801,6 +829,20 @@ { "step_id": "step_03_account_41_now", "question_id": "Q03", + "analysis_context": { + "as_of_date": "2021-09-30", + "source": "binding_target_date_current" + }, + "required_filters": { + "as_of_date": "2021-09-30", + "period_from": "2021-09-01", + "period_to": "2021-09-30" + }, + "invariant_severity": { + "wrong_as_of_date": "P0", + "wrong_period_from": "P0", + "wrong_period_to": "P0" + }, "node_id": "N02_account_41_snapshot", "node_role": "root_variant", "paraphrase_family": "canonical", @@ -1361,6 +1403,19 @@ { "step_id": "step_05_supplier_items_on_date", "question_id": "Q12", + "required_filters": { + "as_of_date": "2019-03-31", + "period_from": "2019-03-01", + "period_to": "2019-03-31" + }, + "required_carryover_invariants": [ + "date_scope" + ], + "invariant_severity": { + "wrong_as_of_date": "P0", + "wrong_period_from": "P0", + "wrong_period_to": "P0" + }, "node_id": "N08_supplier_items_on_date", "node_role": "supporting_child", "paraphrase_family": "canonical", @@ -1473,6 +1528,23 @@ { "step_id": "step_05_unresolved_supplier_link", "question_id": "Q14", + "analysis_context": { + "as_of_date": "2021-09-30", + "source": "binding_target_date_current" + }, + "required_filters": { + "as_of_date": "2021-09-30", + "period_from": "2021-09-01", + "period_to": "2021-09-30" + }, + "required_carryover_invariants": [ + "date_scope" + ], + "invariant_severity": { + "wrong_as_of_date": "P0", + "wrong_period_from": "P0", + "wrong_period_to": "P0" + }, "node_id": "N10_unresolved_supplier_link", "node_role": "supporting_child", "paraphrase_family": "canonical", diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index ae804ec..e41b627 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -1048,7 +1048,9 @@ function requiredFiltersByIntent(intent) { return []; } function usesAsOfPrimaryWindow(intent) { - return (intent === "inventory_on_hand_as_of_date" || + return (intent === "account_balance_snapshot" || + intent === "documents_forming_balance" || + intent === "inventory_on_hand_as_of_date" || intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || @@ -1249,7 +1251,8 @@ function extractAddressFilters(userMessage, intent) { const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") || warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_phrase"); - if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to) { + const preserveDerivedPeriodWindow = intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; + if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) { delete filters.period_from; delete filters.period_to; warnings.push("period_window_cleared_for_as_of_intent"); diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 431529d..3018c4d 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -629,6 +629,12 @@ function tokenizeAnchor(value) { .map((token) => token.trim()) .filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token)); } +function tokenizeSearchableText(value) { + return normalizeSearchText(value) + .split(" ") + .map((token) => token.trim()) + .filter(Boolean); +} function anchorTokenVariants(token) { const source = String(token ?? "").trim().toLowerCase(); if (!source) { @@ -666,6 +672,51 @@ function matchesAnchorText(searchable, anchor) { }); }); } +function normalizeInventoryItemAnchorSignature(value) { + return String(value ?? "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[\s,.;:!?()\[\]{}'"`«»]/g, "") + .replace(/(?:[xх×*\/._-]+)/giu, "x"); +} +function matchesRelaxedInventoryItemAnchorText(searchable, anchor) { + if (matchesAnchorText(searchable, anchor)) { + return true; + } + const searchableNormalized = normalizeSearchText(searchable); + const searchableLatin = transliterateCyrillicToLatin(searchableNormalized); + const searchableTokens = tokenizeSearchableText(searchable); + const anchorSignature = normalizeInventoryItemAnchorSignature(anchor); + const searchableSignature = normalizeInventoryItemAnchorSignature(searchable); + if (anchorSignature && searchableSignature.includes(anchorSignature)) { + return true; + } + const relaxedTokens = tokenizeAnchor(anchor).filter((token) => { + if (/^\d+$/u.test(token)) { + return false; + } + return !/^\d+(?:[xх×*\/._-]\d+)+$/iu.test(token); + }); + if (relaxedTokens.length === 0) { + return false; + } + return relaxedTokens.every((token) => { + const variants = anchorTokenVariants(token); + return variants.some((variant) => { + const tokenLatin = transliterateCyrillicToLatin(variant); + if (searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin)) { + return true; + } + return searchableTokens.some((candidate) => { + const candidateLatin = transliterateCyrillicToLatin(candidate); + return (candidate.startsWith(variant) || + variant.startsWith(candidate) || + candidateLatin.startsWith(tokenLatin) || + tokenLatin.startsWith(candidateLatin)); + }); + }); + }); +} function isLikelyLowQualityPartyAnchor(value) { const normalized = normalizeSearchText(String(value ?? "")); if (!normalized) { @@ -967,7 +1018,7 @@ function toNormalizedRows(rows) { const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); - const item = firstNonEmptyString(row.Номенклатура, row.Item, row.item, row.НоменклатураПредставление); + const item = firstNonEmptyString(row.Номенклатура, row.Item, row.item, row.НоменклатураПредставление, row.SubcontoDt1, row.SubcontoDt2, row.SubcontoDt3, row.SubcontoKt1, row.SubcontoKt2, row.SubcontoKt3, row.СубконтоДт1, row.СубконтоДт2, row.СубконтоДт3, row.СубконтоКт1, row.СубконтоКт2, row.СубконтоКт3); const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление); const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление); const analytics = collectAnalyticsStrings(row); @@ -987,7 +1038,9 @@ function toNormalizedRows(rows) { .filter((item) => Boolean(item.period || item.registrator)); } function rowSearchableText(row) { - return [row.registrator, row.account_dt ?? "", row.account_kt ?? "", ...row.analytics].join(" ").toLowerCase(); + return [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics] + .join(" ") + .toLowerCase(); } function rowMatchesAnyAccount(row, accountScope) { if (accountScope.length === 0) { @@ -1061,7 +1114,7 @@ function applyAddressFilters(rows, filters) { if (filters.item && String(filters.item).trim()) { const needle = String(filters.item); const before = filtered.length; - filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle)); + filtered = filtered.filter((row) => matchesRelaxedInventoryItemAnchorText(rowSearchableText(row), needle)); if (before > 0 && filtered.length === 0 && mismatchReason === null) { mismatchReason = "item_anchor_not_matched_in_materialized_rows"; } diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 9d0fe27..64fb5f7 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -345,6 +345,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { const previousContract = toNonEmptyString(previous.contract); const previousAccount = toNonEmptyString(previous.account); const previousItem = toNonEmptyString(previous.item); + const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null; const previousOrganization = toNonEmptyString(previous.organization); const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousPeriodFrom = toNonEmptyString(previous.period_from); @@ -462,10 +463,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date") && - !toNonEmptyString(merged.item) && - previousItem) { - if (intent !== "inventory_aging_by_purchase_date") { - merged.item = previousItem; + !toNonEmptyString(merged.item)) { + const inheritedItem = previousItem ?? previousAnchorItem; + if (inheritedItem && intent !== "inventory_aging_by_purchase_date") { + merged.item = inheritedItem; reasons.push("item_from_followup_context"); } } @@ -493,6 +494,28 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("as_of_date_from_followup_context"); } } + if (!sameDateRequested && + (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + !hasExplicitPeriodLiteral(userMessage) && + !hasExplicitCurrentDateHint(userMessage)) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } + if (!sameDateRequested && + (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + hasOpenItemsHint(userMessage)) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_open_items_followup_context"); + } + } if (intent === "inventory_aging_by_purchase_date") { const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? "")); if (toNonEmptyString(merged.item) && !explicitItemMention) { @@ -569,7 +592,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("period_from_followup_context"); } } - if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) { + if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) { if (previousPeriodFrom) { merged.period_from = previousPeriodFrom; } diff --git a/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js b/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js index f715602..5cc600a 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js @@ -101,13 +101,36 @@ function matchesAnchorText(searchable, anchor) { } return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct)); } - return tokens.every((token) => { + const fullMatch = tokens.every((token) => { const variants = anchorTokenVariants(token); return variants.some((variant) => { const tokenLatin = transliterateCyrillicToLatin(variant); return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); }); }); + if (fullMatch) { + return true; + } + // Sale-trace item labels and warehouse labels can differ by punctuation or + // compact suffixes in the materialized row text. For those anchors, allow a + // narrow token-overlap fallback so the exact selected object still resolves + // against live rows instead of dropping into an empty match. + const overlapCount = tokens.reduce((count, token) => { + const variants = anchorTokenVariants(token); + return (count + + (variants.some((variant) => { + const tokenLatin = transliterateCyrillicToLatin(variant); + return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); + }) + ? 1 + : 0)); + }, 0); + const numericTokens = tokens.filter((token) => /\d/.test(token)); + const numericOverlap = numericTokens.every((token) => { + const tokenLatin = transliterateCyrillicToLatin(token); + return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin); + }); + return numericOverlap && overlapCount >= Math.max(2, Math.ceil(tokens.length * 0.75)); } function uniqueStrings(values) { return Array.from(new Set(values @@ -118,6 +141,8 @@ function resolvePrimaryAnchor(intent, filters) { const account = typeof filters.account === "string" ? filters.account.trim() : ""; const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : ""; const contract = typeof filters.contract === "string" ? filters.contract.trim() : ""; + const item = typeof filters.item === "string" ? filters.item.trim() : ""; + const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : ""; const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : ""; if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { if (account) { @@ -170,6 +195,29 @@ function resolvePrimaryAnchor(intent, filters) { ambiguity_count: 0 }; } + if ((intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date") && + item) { + return { + anchor_type: "item", + anchor_value_raw: item, + anchor_value_resolved: item, + resolver_confidence: "medium", + ambiguity_count: 0 + }; + } + if (warehouse) { + return { + anchor_type: "warehouse", + anchor_value_raw: warehouse, + anchor_value_resolved: warehouse, + resolver_confidence: "medium", + ambiguity_count: 0 + }; + } if (documentRef) { return { anchor_type: "document_ref", @@ -191,15 +239,20 @@ function refineAnchorFromRows(anchor, rows) { if (rows.length === 0) { return anchor; } - if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") { + if (anchor.anchor_type !== "counterparty" && + anchor.anchor_type !== "contract" && + anchor.anchor_type !== "item" && + anchor.anchor_type !== "warehouse") { return anchor; } const needleRaw = String(anchor.anchor_value_raw ?? "").trim(); if (!needleRaw) { return anchor; } - const candidates = uniqueStrings(rows - .flatMap((row) => row.analytics) + const searchableRows = anchor.anchor_type === "item" || anchor.anchor_type === "warehouse" + ? rows.flatMap((row) => [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics]) + : rows.flatMap((row) => row.analytics); + const candidates = uniqueStrings(searchableRows .map((value) => value.trim()) .filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))); if (candidates.length === 0) { diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index eade237..b28663f 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2839,6 +2839,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes previousAnchorType = "contract"; previousAnchor = resolvedEntityFromFollowup.value; } + else if (resolvedEntityFromFollowup.entityType === "item") { + previousFilters.item = resolvedEntityFromFollowup.value; + previousAnchorType = "item"; + previousAnchor = resolvedEntityFromFollowup.value; + } if (followupSelectionMode !== "switch_to_suggested_intent") { followupSelectionMode = "carry_referenced_entity"; } diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index a158b01..0e429fd 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -1442,7 +1442,9 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent warnings.includes("period_derived_from_month_phrase") || warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_phrase"); - if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to) { + const preserveDerivedPeriodWindow = + intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; + if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) { delete filters.period_from; delete filters.period_to; warnings.push("period_window_cleared_for_as_of_intent"); diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 6dacff4..7ab9dca 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -794,6 +794,13 @@ function tokenizeAnchor(value: string): string[] { .filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token)); } +function tokenizeSearchableText(value: string): string[] { + return normalizeSearchText(value) + .split(" ") + .map((token) => token.trim()) + .filter(Boolean); +} + function anchorTokenVariants(token: string): string[] { const source = String(token ?? "").trim().toLowerCase(); if (!source) { @@ -836,6 +843,55 @@ function matchesAnchorText(searchable: string, anchor: string): boolean { }); } +function normalizeInventoryItemAnchorSignature(value: string): string { + return String(value ?? "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[\s,.;:!?()\[\]{}'"`«»]/g, "") + .replace(/(?:[xх×*\/._-]+)/giu, "x"); +} + +function matchesRelaxedInventoryItemAnchorText(searchable: string, anchor: string): boolean { + if (matchesAnchorText(searchable, anchor)) { + return true; + } + const searchableNormalized = normalizeSearchText(searchable); + const searchableLatin = transliterateCyrillicToLatin(searchableNormalized); + const searchableTokens = tokenizeSearchableText(searchable); + const anchorSignature = normalizeInventoryItemAnchorSignature(anchor); + const searchableSignature = normalizeInventoryItemAnchorSignature(searchable); + if (anchorSignature && searchableSignature.includes(anchorSignature)) { + return true; + } + const relaxedTokens = tokenizeAnchor(anchor).filter((token) => { + if (/^\d+$/u.test(token)) { + return false; + } + return !/^\d+(?:[xх×*\/._-]\d+)+$/iu.test(token); + }); + if (relaxedTokens.length === 0) { + return false; + } + return relaxedTokens.every((token) => { + const variants = anchorTokenVariants(token); + return variants.some((variant) => { + const tokenLatin = transliterateCyrillicToLatin(variant); + if (searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin)) { + return true; + } + return searchableTokens.some((candidate) => { + const candidateLatin = transliterateCyrillicToLatin(candidate); + return ( + candidate.startsWith(variant) || + variant.startsWith(candidate) || + candidateLatin.startsWith(tokenLatin) || + tokenLatin.startsWith(candidateLatin) + ); + }); + }); + }); +} + function isLikelyLowQualityPartyAnchor(value: string | null | undefined): boolean { const normalized = normalizeSearchText(String(value ?? "")); if (!normalized) { @@ -1172,7 +1228,24 @@ function toNormalizedRows(rows: Array>): NormalizedAddre const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); - const item = firstNonEmptyString(row.Номенклатура, row.Item, row.item, row.НоменклатураПредставление); + const item = firstNonEmptyString( + row.Номенклатура, + row.Item, + row.item, + row.НоменклатураПредставление, + row.SubcontoDt1, + row.SubcontoDt2, + row.SubcontoDt3, + row.SubcontoKt1, + row.SubcontoKt2, + row.SubcontoKt3, + row.СубконтоДт1, + row.СубконтоДт2, + row.СубконтоДт3, + row.СубконтоКт1, + row.СубконтоКт2, + row.СубконтоКт3 + ); const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление); const organization = firstNonEmptyString( row.Организация, @@ -1200,7 +1273,9 @@ function toNormalizedRows(rows: Array>): NormalizedAddre } function rowSearchableText(row: NormalizedAddressRow): string { - return [row.registrator, row.account_dt ?? "", row.account_kt ?? "", ...row.analytics].join(" ").toLowerCase(); + return [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics] + .join(" ") + .toLowerCase(); } function rowMatchesAnyAccount(row: NormalizedAddressRow, accountScope: string[]): boolean { @@ -1287,7 +1362,7 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte if (filters.item && String(filters.item).trim()) { const needle = String(filters.item); const before = filtered.length; - filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle)); + filtered = filtered.filter((row) => matchesRelaxedInventoryItemAnchorText(rowSearchableText(row), needle)); if (before > 0 && filtered.length === 0 && mismatchReason === null) { mismatchReason = "item_anchor_not_matched_in_materialized_rows"; } diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 1d9e708..10f92ee 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -13,7 +13,7 @@ import { extractAddressFilters } from "../addressFilterExtractor"; export interface AddressFollowupContext { previous_intent?: AddressIntent; previous_filters?: AddressFilterSet; - previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null; + previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null; previous_anchor_value?: string | null; resolved_counterparty_from_display?: boolean; } @@ -451,6 +451,7 @@ function mergeFollowupFilters( const previousContract = toNonEmptyString(previous.contract); const previousAccount = toNonEmptyString(previous.account); const previousItem = toNonEmptyString(previous.item); + const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null; const previousOrganization = toNonEmptyString(previous.organization); const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousPeriodFrom = toNonEmptyString(previous.period_from); @@ -587,11 +588,11 @@ function mergeFollowupFilters( intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date") && - !toNonEmptyString(merged.item) && - previousItem + !toNonEmptyString(merged.item) ) { - if (intent !== "inventory_aging_by_purchase_date") { - merged.item = previousItem; + const inheritedItem = previousItem ?? previousAnchorItem; + if (inheritedItem && intent !== "inventory_aging_by_purchase_date") { + merged.item = inheritedItem; reasons.push("item_from_followup_context"); } } @@ -621,6 +622,32 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } + if ( + !sameDateRequested && + (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + !hasExplicitPeriodLiteral(userMessage) && + !hasExplicitCurrentDateHint(userMessage) + ) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } + if ( + !sameDateRequested && + (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + hasOpenItemsHint(userMessage) + ) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_open_items_followup_context"); + } + } if (intent === "inventory_aging_by_purchase_date") { const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test( String(userMessage ?? "") @@ -711,7 +738,7 @@ function mergeFollowupFilters( } } - if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) { + if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) { if (previousPeriodFrom) { merged.period_from = previousPeriodFrom; } diff --git a/llm_normalizer/backend/src/services/address_runtime/resolveStage.ts b/llm_normalizer/backend/src/services/address_runtime/resolveStage.ts index d430802..5d7d188 100644 --- a/llm_normalizer/backend/src/services/address_runtime/resolveStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/resolveStage.ts @@ -16,7 +16,7 @@ const PARTY_ANCHOR_STOPWORDS = new Set([ ]); export interface AnchorResolutionDebug { - anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null; + anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null; anchor_value_raw: string | null; anchor_value_resolved: string | null; resolver_confidence: "high" | "medium" | "low" | null; @@ -28,6 +28,8 @@ export interface ResolveStageRow { account_dt: string | null; account_kt: string | null; analytics: string[]; + item?: string | null; + warehouse?: string | null; } function transliterateCyrillicToLatin(value: string): string { @@ -122,13 +124,39 @@ function matchesAnchorText(searchable: string, anchor: string): boolean { } return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct)); } - return tokens.every((token) => { + const fullMatch = tokens.every((token) => { const variants = anchorTokenVariants(token); return variants.some((variant) => { const tokenLatin = transliterateCyrillicToLatin(variant); return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); }); }); + if (fullMatch) { + return true; + } + + // Sale-trace item labels and warehouse labels can differ by punctuation or + // compact suffixes in the materialized row text. For those anchors, allow a + // narrow token-overlap fallback so the exact selected object still resolves + // against live rows instead of dropping into an empty match. + const overlapCount = tokens.reduce((count, token) => { + const variants = anchorTokenVariants(token); + return ( + count + + (variants.some((variant) => { + const tokenLatin = transliterateCyrillicToLatin(variant); + return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); + }) + ? 1 + : 0) + ); + }, 0); + const numericTokens = tokens.filter((token) => /\d/.test(token)); + const numericOverlap = numericTokens.every((token) => { + const tokenLatin = transliterateCyrillicToLatin(token); + return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin); + }); + return numericOverlap && overlapCount >= Math.max(2, Math.ceil(tokens.length * 0.75)); } function uniqueStrings(values: string[]): string[] { @@ -145,6 +173,8 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt const account = typeof filters.account === "string" ? filters.account.trim() : ""; const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : ""; const contract = typeof filters.contract === "string" ? filters.contract.trim() : ""; + const item = typeof filters.item === "string" ? filters.item.trim() : ""; + const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : ""; const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : ""; if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { @@ -203,6 +233,33 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt }; } + if ( + (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date") && + item + ) { + return { + anchor_type: "item", + anchor_value_raw: item, + anchor_value_resolved: item, + resolver_confidence: "medium", + ambiguity_count: 0 + }; + } + + if (warehouse) { + return { + anchor_type: "warehouse", + anchor_value_raw: warehouse, + anchor_value_resolved: warehouse, + resolver_confidence: "medium", + ambiguity_count: 0 + }; + } + if (documentRef) { return { anchor_type: "document_ref", @@ -226,16 +283,24 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv if (rows.length === 0) { return anchor; } - if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") { + if ( + anchor.anchor_type !== "counterparty" && + anchor.anchor_type !== "contract" && + anchor.anchor_type !== "item" && + anchor.anchor_type !== "warehouse" + ) { return anchor; } const needleRaw = String(anchor.anchor_value_raw ?? "").trim(); if (!needleRaw) { return anchor; } + const searchableRows = + anchor.anchor_type === "item" || anchor.anchor_type === "warehouse" + ? rows.flatMap((row) => [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics]) + : rows.flatMap((row) => row.analytics); const candidates = uniqueStrings( - rows - .flatMap((row) => row.analytics) + searchableRows .map((value) => value.trim()) .filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)) ); diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 639f7ca..2e5c22d 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2796,6 +2796,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes previousAnchorType = "contract"; previousAnchor = resolvedEntityFromFollowup.value; } + else if (resolvedEntityFromFollowup.entityType === "item") { + previousFilters.item = resolvedEntityFromFollowup.value; + previousAnchorType = "item"; + previousAnchor = resolvedEntityFromFollowup.value; + } if (followupSelectionMode !== "switch_to_suggested_intent") { followupSelectionMode = "carry_referenced_entity"; } diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index b30e165..70233da 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -188,7 +188,7 @@ export interface AddressExecutionDebug { mcp_call_status_legacy: Exclude; account_scope_mode: AddressAccountScopeMode; account_scope_fallback_applied: boolean; - anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null; + anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null; anchor_value_raw: string | null; anchor_value_resolved: string | null; resolver_confidence: "high" | "medium" | "low" | null; diff --git a/llm_normalizer/backend/src/types/assistant.ts b/llm_normalizer/backend/src/types/assistant.ts index 8e5ae86..68a6132 100644 --- a/llm_normalizer/backend/src/types/assistant.ts +++ b/llm_normalizer/backend/src/types/assistant.ts @@ -390,7 +390,7 @@ export interface AssistantDebugPayload { mcp_call_status_legacy?: "skipped" | "error" | "no_raw_rows" | "raw_rows_received_but_not_materialized" | "materialized_but_not_matched" | "matched_non_empty"; account_scope_mode?: "strict" | "preferred"; account_scope_fallback_applied?: boolean; - anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null; + anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null; anchor_value_raw?: string | null; anchor_value_resolved?: string | null; resolver_confidence?: "high" | "medium" | "low" | null; diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index ebe4d02..db1f1c8 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -436,4 +436,35 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ИП Покупатель"); expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); }); + + it("matches sale-trace item anchors from subconto fields when the item is not materialized explicitly", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-04-12T00:00:00Z", + Registrator: "Реализация товаров и услуг 00000000112 от 12.04.2020 0:00:00", + AccountDt: "90.02", + AccountKt: "41.01", + Amount: 833.33, + SubcontoDt1: "Шкаф картотечный 1000*400*2100", + SubcontoKt1: "ИП Покупатель", + SubcontoKt2: "Коммерческая структура", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("Кому был продан товар Шкаф картотечный 1000*400*2100?", {}); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); + expect(result?.debug.mcp_call_status).toBe("matched_non_empty"); + expect(result?.debug.rows_matched).toBeGreaterThan(0); + expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось"); + }); }); diff --git a/scripts/domain_case_loop.py b/scripts/domain_case_loop.py index 4ad75da..bb38981 100644 --- a/scripts/domain_case_loop.py +++ b/scripts/domain_case_loop.py @@ -61,6 +61,19 @@ DEFAULT_INVARIANT_SEVERITY: dict[str, str] = { } REPAIR_TARGET_SEVERITY_ORDER = {"P0": 0, "P1": 1, "P2": 2} +REPAIR_TARGET_PROBLEM_ORDER = { + "temporal_honesty_gap": 0, + "edge_carryover_gap": 1, + "followup_action_resolution_gap": 2, + "object_memory_gap": 3, + "route_gap": 4, + "answer_shape_mismatch": 5, + "presentation_gap": 6, + "domain_anchor_gap": 7, + "capability_gap": 8, + "evidence_gap": 9, + "other": 10, +} REPAIR_TARGET_FILE_HINTS: dict[str, list[str]] = { "followup_action_resolution_gap": [ @@ -132,6 +145,65 @@ def read_text_file(file_path: Path) -> str: return file_path.read_text(encoding="utf-8-sig") +def resolve_repo_relative_path(raw_path: str | None) -> Path | None: + candidate = str(raw_path or "").strip().replace("\\", "/") + if not candidate: + return None + path = Path(candidate) + if path.is_absolute() or any(part == ".." for part in path.parts): + return None + return REPO_ROOT / path + + +def build_coder_snapshot_paths(repair_targets: dict[str, Any]) -> list[Path]: + collected: list[Path] = [] + seen: set[Path] = set() + groups = [] + if isinstance(repair_targets, dict): + groups.extend(repair_targets.get("priority_foci") or []) + groups.extend(repair_targets.get("targets") or []) + for item in groups: + if not isinstance(item, dict): + continue + for raw_path in normalize_string_list(item.get("candidate_files")): + resolved = resolve_repo_relative_path(raw_path) + if resolved is None or not resolved.exists() or resolved in seen: + continue + seen.add(resolved) + collected.append(resolved) + return collected + + +def snapshot_coder_candidate_files(paths: list[Path]) -> dict[str, bytes]: + snapshots: dict[str, bytes] = {} + for path in paths: + if not path.exists() or not path.is_file(): + continue + snapshots[str(path)] = path.read_bytes() + return snapshots + + +def restore_line_collapsed_files_from_snapshot(snapshots: dict[str, bytes]) -> list[str]: + restored: list[str] = [] + for raw_path, before_bytes in snapshots.items(): + path = Path(raw_path) + if not path.exists() or not path.is_file(): + continue + after_bytes = path.read_bytes() + before_line_count = before_bytes.count(b"\n") + after_line_count = after_bytes.count(b"\n") + if before_line_count < 1 or after_line_count != 0: + continue + if before_bytes.replace(b"\r", b"").replace(b"\n", b"") != after_bytes.replace(b"\r", b"").replace(b"\n", b""): + continue + path.write_bytes(before_bytes) + try: + restored.append(str(path.relative_to(REPO_ROOT)).replace("\\", "/")) + except ValueError: + restored.append(str(path)) + return restored + + def sanitize_export_text(value: str) -> str: raw = str(value or "") debug_heading = re.search( @@ -1551,6 +1623,7 @@ def build_scenario_step_state( step: dict[str, Any], step_index: int, question_resolved: str, + analysis_context: dict[str, Any], turn_artifact: dict[str, Any], entries: list[dict[str, Any]], ) -> dict[str, Any]: @@ -1570,7 +1643,12 @@ def build_scenario_step_state( reply_type = assistant_item.get("reply_type") assistant_text = str(assistant_item.get("text") or "") top_non_empty = first_non_empty_lines(assistant_text, limit=3) - analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {} + turn_scenario = turn_artifact.get("scenario") if isinstance(turn_artifact.get("scenario"), dict) else {} + effective_analysis_context = normalize_analysis_context(turn_scenario.get("analysis_context")) + if not effective_analysis_context: + effective_analysis_context = normalize_analysis_context(analysis_context) + if not effective_analysis_context: + effective_analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {} step_state = { "schema_version": SCENARIO_STEP_STATE_SCHEMA_VERSION, @@ -1582,7 +1660,7 @@ def build_scenario_step_state( "depends_on": step["depends_on"], "question_template": step["question_template"], "question_resolved": question_resolved, - "analysis_context": analysis_context, + "analysis_context": effective_analysis_context, "expected_intents": step.get("expected_intents") or [], "expected_capability": step.get("expected_capability"), "expected_recipe": step.get("expected_recipe"), @@ -1789,6 +1867,7 @@ def run_assistant_step( step=step, step_index=step_index, question_resolved=question_resolved, + analysis_context=analysis_context, turn_artifact=turn_artifact, entries=entries, ) @@ -2764,6 +2843,63 @@ def build_step_repair_target( } +def build_repair_focus_signature(target: dict[str, Any]) -> str: + problem_type = str(target.get("problem_type") or "other").strip() or "other" + candidate_files = normalize_string_list(target.get("candidate_files")) + primary_file = candidate_files[0] if candidate_files else "no_file_hint" + return f"{problem_type}|{primary_file}" + + +def build_priority_repair_foci(targets: list[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[str, dict[str, Any]] = {} + for target in targets: + focus_id = build_repair_focus_signature(target) + focus = grouped.setdefault( + focus_id, + { + "focus_id": focus_id, + "severity": str(target.get("severity") or "P2"), + "problem_type": str(target.get("problem_type") or "other"), + "root_cause_layers": normalize_string_list(target.get("root_cause_layers")), + "candidate_files": normalize_string_list(target.get("candidate_files")), + "target_ids": [], + "scenario_ids": set(), + }, + ) + focus["target_ids"].append(str(target.get("target_id") or "")) + scenario_id = str(target.get("scenario_id") or "").strip() + if scenario_id: + focus["scenario_ids"].add(scenario_id) + + priority_foci: list[dict[str, Any]] = [] + for focus in grouped.values(): + scenario_ids = sorted(focus.pop("scenario_ids")) + target_ids = [target_id for target_id in focus.get("target_ids", []) if target_id] + focus["target_count"] = len(target_ids) + focus["scenario_count"] = len(scenario_ids) + focus["target_ids"] = target_ids + focus["scenario_ids"] = scenario_ids + priority_foci.append(focus) + + priority_foci.sort( + key=lambda item: ( + REPAIR_TARGET_SEVERITY_ORDER.get(str(item.get("severity") or "P2"), 99), + -int(item.get("target_count") or 0), + -int(item.get("scenario_count") or 0), + REPAIR_TARGET_PROBLEM_ORDER.get(str(item.get("problem_type") or "other"), 99), + str(item.get("focus_id") or ""), + ) + ) + for index, focus in enumerate(priority_foci, start=1): + primary_file = normalize_string_list(focus.get("candidate_files"))[:1] + focus["focus_rank"] = index + focus["rank_reason"] = ( + f"severity={focus.get('severity')} targets={focus.get('target_count')} " + f"scenarios={focus.get('scenario_count')} primary_file={primary_file[0] if primary_file else 'n/a'}" + ) + return priority_foci + + def build_deterministic_repair_targets( pack_state: dict[str, Any], scenario_artifacts: list[dict[str, Any]], @@ -2792,9 +2928,36 @@ def build_deterministic_repair_targets( if target: targets.append(target) + priority_foci = build_priority_repair_foci(targets) + focus_rank_by_id = { + str(focus.get("focus_id") or ""): int(focus.get("focus_rank") or 999) + for focus in priority_foci + if isinstance(focus, dict) + } + focus_target_count_by_id = { + str(focus.get("focus_id") or ""): int(focus.get("target_count") or 0) + for focus in priority_foci + if isinstance(focus, dict) + } + focus_scenario_count_by_id = { + str(focus.get("focus_id") or ""): int(focus.get("scenario_count") or 0) + for focus in priority_foci + if isinstance(focus, dict) + } + for target in targets: + focus_id = build_repair_focus_signature(target) + target["repair_focus_id"] = focus_id + target["repair_focus_rank"] = focus_rank_by_id.get(focus_id, 999) + target["repair_focus_target_count"] = focus_target_count_by_id.get(focus_id, 0) + target["repair_focus_scenario_count"] = focus_scenario_count_by_id.get(focus_id, 0) + targets.sort( key=lambda item: ( REPAIR_TARGET_SEVERITY_ORDER.get(str(item.get("severity") or "P2"), 99), + int(item.get("repair_focus_rank") or 999), + -int(item.get("repair_focus_target_count") or 0), + -int(item.get("repair_focus_scenario_count") or 0), + REPAIR_TARGET_PROBLEM_ORDER.get(str(item.get("problem_type") or "other"), 99), str(item.get("scenario_id") or ""), str(item.get("step_id") or ""), ) @@ -2811,10 +2974,21 @@ def build_deterministic_repair_targets( "final_status": pack_state.get("final_status"), "target_count": len(targets), "severity_counts": severity_counts, + "priority_foci": priority_foci, "targets": targets, } +def select_primary_repair_focus(repair_targets: dict[str, Any]) -> dict[str, Any] | None: + if not isinstance(repair_targets, dict): + return None + priority_foci = repair_targets.get("priority_foci") + if not isinstance(priority_foci, list) or not priority_foci: + return None + primary_focus = priority_foci[0] + return primary_focus if isinstance(primary_focus, dict) else None + + def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str: lines = [ "# Repair targets", @@ -2823,9 +2997,36 @@ def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str: f"- domain: `{repair_targets.get('domain') or 'n/a'}`", f"- target_count: `{repair_targets.get('target_count') or 0}`", f"- severity_counts: `{dump_json(repair_targets.get('severity_counts') or {})}`", - "", - "## Targets", ] + priority_foci = repair_targets.get("priority_foci") or [] + if isinstance(priority_foci, list) and priority_foci: + lines.extend( + [ + "", + "## Priority foci", + ] + ) + for focus in priority_foci: + if not isinstance(focus, dict): + continue + lines.extend( + [ + f"- `{focus.get('focus_id')}`", + f" focus_rank: `{focus.get('focus_rank')}`", + f" severity: `{focus.get('severity')}`", + f" problem_type: `{focus.get('problem_type')}`", + f" target_count: `{focus.get('target_count')}`", + f" scenario_count: `{focus.get('scenario_count')}`", + f" candidate_files: {', '.join(focus.get('candidate_files') or []) or 'none'}", + f" rank_reason: {focus.get('rank_reason') or 'n/a'}", + ] + ) + lines.extend( + [ + "", + "## Targets", + ] + ) for target in repair_targets.get("targets") or []: if not isinstance(target, dict): continue @@ -2834,6 +3035,8 @@ def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str: f"- `{target.get('target_id')}`", f" severity: `{target.get('severity')}`", f" problem_type: `{target.get('problem_type')}`", + f" repair_focus_rank: `{target.get('repair_focus_rank')}`", + f" repair_focus_target_count: `{target.get('repair_focus_target_count')}`", f" root_cause_layers: {', '.join(target.get('root_cause_layers') or []) or 'none'}", f" fix_goal: {target.get('fix_goal') or 'n/a'}", f" candidate_files: {', '.join(target.get('candidate_files') or []) or 'none'}", @@ -2989,6 +3192,7 @@ def build_analyst_loop_prompt( - `accepted` is allowed only if quality_score >= {target_score}, unresolved_p0_count = 0, and regression_detected = false; - `accepted` is forbidden if the evidence bundle shows `pack_state.final_status != accepted` or the deterministic repair targets still contain any `P0` or `P1` items; - `accepted` also requires `direct_answer_ok = true`, `business_usefulness_ok = true`, `temporal_honesty_ok = true`, and `field_truth_ok = true`; + - when several failing steps share one deterministic repair focus, call out the highest-leverage shared focus first instead of only the lexicographically first failing step; - `partial` means the pack is usable but exactness, routing, or coverage is still insufficient; - `needs_exact_capability` means the primary blocker is a missing exact route or capability, but the loop should still continue autonomously unless a user decision is required; - `continue` means there is a clear next patch cycle; @@ -3035,9 +3239,22 @@ def build_coder_loop_prompt( pack_dir: Path, repair_targets_path: Path, repair_targets_json: str, + assigned_focus: dict[str, Any] | None, analyst_verdict_path: Path, analyst_verdict_json: str, ) -> str: + assigned_focus_block = ( + textwrap.dedent( + f"""\ + Assigned deterministic repair focus for this iteration: + ```json + {dump_json(assigned_focus)} + ``` + """ + ).strip() + if assigned_focus + else "Assigned deterministic repair focus for this iteration: none" + ) return textwrap.dedent( f"""\ You are the `domain_coder` for NDC_1C. @@ -3061,8 +3278,11 @@ def build_coder_loop_prompt( - do not present heuristic answers as confirmed; - do not touch unrelated files; - preserve already successful baseline flows. + - preserve UTF-8 without BOM and the existing line structure of edited files; do not leave whole-file normalization-only rewrites or single-line collapses in the worktree; + - use minimal local edits; if a tool rewrites a file into normalization noise, restore the original file first and then apply only the intended semantic patch; - use `root_cause_layers`, `broken_edge_ids`, `violated_invariants`, and business-utility scores from the analyst verdict to choose the smallest fix; - - use the deterministic repair targets to choose the narrowest failing edge before touching broader scenarios; + - use the deterministic repair targets to choose the highest-leverage repair focus first; within that focus, patch the narrowest shared layer that can clear the most `P0`/`P1` targets without architecture drift; + - the assigned deterministic repair focus below is mandatory for this iteration; do not switch to a lower-priority focus unless you are blocked from making a safe patch for the assigned focus; - if the analyst verdict is optimistic but deterministic repair targets still contain `P0` or `P1`, trust the deterministic repair targets and keep fixing the pack; - prioritize state continuity, selected-object persistence, stable `focus_object`, stable `answer_object`, reusable `provenance_bundle` / `sale_trace_bundle`, action-first answer behavior, compact micro-action answers, answer layering, temporal honesty, and field-truth mapping when those are the blocking layers; - do not broaden scope when the analyst says the defect is mainly `object_memory_gap`, `followup_action_resolution_gap`, `bundle_reuse_gap`, `field_mapping_gap`, `temporal_honesty_gap`, `answer_shape_mismatch`, or `business_utility_gap`; @@ -3082,6 +3302,8 @@ def build_coder_loop_prompt( {repair_targets_json} ``` + {assigned_focus_block} + - then return JSON only and follow the schema exactly. """ ).strip() @@ -3218,6 +3440,8 @@ def build_loop_summary(loop_state: dict[str, Any]) -> str: f" requires_user_decision: `{item.get('requires_user_decision')}`", f" user_decision_type: `{item.get('user_decision_type') or 'none'}`", f" coder_status: `{item.get('coder_status') or 'n/a'}`", + f" assigned_repair_focus_id: `{item.get('assigned_repair_focus_id') or 'none'}`", + f" coder_workspace_hygiene_restored_files: `{', '.join(item.get('coder_workspace_hygiene_restored_files') or []) or 'none'}`", f" analyst_verdict: `{item.get('analyst_verdict_path') or 'n/a'}`", f" repair_targets: `{item.get('repair_targets_path') or 'n/a'}`", f" repair_target_count: `{item.get('repair_target_count')}`", @@ -3396,16 +3620,20 @@ def handle_run_pack_loop(args: argparse.Namespace) -> int: break coder_result_path = iteration_dir / "coder_result.json" + assigned_focus = select_primary_repair_focus(repair_targets) coder_prompt = build_coder_loop_prompt( loop_dir=loop_dir, iteration_dir=iteration_dir, pack_dir=pack_dir, repair_targets_path=repair_targets_path, repair_targets_json=repair_targets_json, + assigned_focus=assigned_focus, analyst_verdict_path=analyst_verdict_path, analyst_verdict_json=dump_json(analyst_verdict), ) write_text(iteration_dir / "coder_prompt.md", coder_prompt + "\n") + coder_snapshot_paths = build_coder_snapshot_paths(repair_targets) + coder_snapshots = snapshot_coder_candidate_files(coder_snapshot_paths) coder_command = build_codex_exec_command( args, output_file=coder_result_path, @@ -3422,10 +3650,15 @@ def handle_run_pack_loop(args: argparse.Namespace) -> int: stdout_path=iteration_dir / "coder_exec.stdout.log", stderr_path=iteration_dir / "coder_exec.stderr.log", ) + restored_files = restore_line_collapsed_files_from_snapshot(coder_snapshots) coder_result = read_json_output(coder_result_path) coder_status = str(coder_result.get("status") or "").strip() or "unknown" iteration_record["coder_status"] = coder_status iteration_record["coder_result_path"] = str(coder_result_path) + if assigned_focus: + iteration_record["assigned_repair_focus_id"] = str(assigned_focus.get("focus_id") or "") + if restored_files: + iteration_record["coder_workspace_hygiene_restored_files"] = restored_files loop_state["iterations"].append(iteration_record) loop_state["updated_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat() write_json(loop_dir / "loop_state.json", loop_state) diff --git a/tests/test_domain_case_loop.py b/tests/test_domain_case_loop.py index 0762ac2..0a62103 100644 --- a/tests/test_domain_case_loop.py +++ b/tests/test_domain_case_loop.py @@ -7,7 +7,10 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from scripts.domain_case_loop import ( + build_coder_loop_prompt, + build_coder_snapshot_paths, build_deterministic_repair_targets, + build_scenario_step_state, build_scenario_acceptance_matrix, carry_forward_analysis_context, derive_pack_final_status, @@ -15,6 +18,9 @@ from scripts.domain_case_loop import ( evaluate_deterministic_loop_gate, load_scenario_pack, merge_scenario_date_scope, + select_primary_repair_focus, + restore_line_collapsed_files_from_snapshot, + snapshot_coder_candidate_files, validate_step_contract, ) @@ -582,6 +588,191 @@ def test_build_deterministic_repair_targets_marks_anchor_gap_as_p1() -> None: assert "addressQueryService.ts" in " ".join(target["candidate_files"]) +def test_build_deterministic_repair_targets_prioritizes_high_leverage_focus() -> None: + repair_targets = build_deterministic_repair_targets( + {"pack_id": "demo_pack", "domain": "inventory_stock", "final_status": "partial"}, + [ + { + "scenario_id": "inventory_aging_and_unresolved", + "title": "Aging and unresolved", + "artifact_dir": "artifacts/domain_runs/demo/scenarios/inventory_aging_and_unresolved", + "scenario_state": { + "step_outputs": { + "step_05_unresolved_supplier_link": { + "step_id": "step_05_unresolved_supplier_link", + "question_resolved": "Какие товары сейчас висят в остатке без понятной привязки к поставщику", + "execution_status": "exact", + "acceptance_status": "rejected", + "reply_type": "factual", + "selected_recipe": "address_inventory_supplier_stock_overlap_as_of_date_v1", + "capability_id": "inventory_inventory_supplier_stock_overlap_as_of_date", + "violated_invariants": [ + "wrong_as_of_date", + "missing_required_filter", + "wrong_date_scope_state", + ], + "warnings": [], + "hard_fail": True, + } + } + }, + }, + { + "scenario_id": "inventory_snapshot_roots", + "title": "Root stock snapshots", + "artifact_dir": "artifacts/domain_runs/demo/scenarios/inventory_snapshot_roots", + "scenario_state": { + "step_outputs": { + "step_01_stock_now": { + "step_id": "step_01_stock_now", + "question_resolved": "Какие товары сейчас лежат на складе", + "execution_status": "exact", + "acceptance_status": "rejected", + "reply_type": "factual", + "selected_recipe": "address_inventory_on_hand_as_of_date_v1", + "capability_id": "confirmed_inventory_on_hand_as_of_date", + "violated_invariants": [ + "wrong_as_of_date", + "missing_required_filter", + ], + "warnings": [], + "hard_fail": True, + }, + "step_02_stock_on_historical_date": { + "step_id": "step_02_stock_on_historical_date", + "question_resolved": "Покажи остатки на складе на март 2019", + "execution_status": "exact", + "acceptance_status": "rejected", + "reply_type": "factual", + "selected_recipe": "address_inventory_on_hand_as_of_date_v1", + "capability_id": "confirmed_inventory_on_hand_as_of_date", + "violated_invariants": [ + "wrong_as_of_date", + "wrong_period_from", + "wrong_period_to", + ], + "warnings": [], + "hard_fail": True, + }, + } + }, + }, + ], + ) + + assert repair_targets["target_count"] == 3 + assert repair_targets["priority_foci"][0]["problem_type"] == "temporal_honesty_gap" + assert repair_targets["priority_foci"][0]["target_count"] == 2 + assert repair_targets["targets"][0]["problem_type"] == "temporal_honesty_gap" + assert repair_targets["targets"][0]["repair_focus_rank"] == 1 + + +def test_build_coder_loop_prompt_demands_high_leverage_focus_first(tmp_path) -> None: + prompt = build_coder_loop_prompt( + loop_dir=tmp_path / "loop", + iteration_dir=tmp_path / "loop" / "iterations" / "iteration_00", + pack_dir=tmp_path / "loop" / "iterations" / "iteration_00" / "pack_output" / "pack_run", + repair_targets_path=tmp_path / "loop" / "iterations" / "iteration_00" / "pack_output" / "pack_run" / "repair_targets.json", + repair_targets_json='{"priority_foci":[{"focus_rank":1,"problem_type":"temporal_honesty_gap","target_count":4}]}', + assigned_focus={"focus_id": "temporal_honesty_gap|addressFilterExtractor.ts", "problem_type": "temporal_honesty_gap"}, + analyst_verdict_path=tmp_path / "loop" / "iterations" / "iteration_00" / "analyst_verdict.json", + analyst_verdict_json='{"quality_score":56}', + ) + + assert "highest-leverage repair focus first" in prompt + assert "patch the narrowest shared layer" in prompt + assert "single-line collapses" in prompt + assert "mandatory for this iteration" in prompt + assert "temporal_honesty_gap|addressFilterExtractor.ts" in prompt + + +def test_select_primary_repair_focus_returns_top_priority_focus() -> None: + focus = select_primary_repair_focus( + { + "priority_foci": [ + {"focus_id": "focus-1", "focus_rank": 1}, + {"focus_id": "focus-2", "focus_rank": 2}, + ] + } + ) + + assert focus == {"focus_id": "focus-1", "focus_rank": 1} + + +def test_build_coder_snapshot_paths_collects_candidate_files_once(tmp_path) -> None: + repo_root = tmp_path + file_a = repo_root / "llm_normalizer/backend/src/services/addressFilterExtractor.ts" + file_b = repo_root / "llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts" + file_a.parent.mkdir(parents=True, exist_ok=True) + file_b.parent.mkdir(parents=True, exist_ok=True) + file_a.write_text("line1\nline2\n", encoding="utf-8") + file_b.write_text("line1\nline2\n", encoding="utf-8") + + original_repo_root = sys.modules["scripts.domain_case_loop"].REPO_ROOT + sys.modules["scripts.domain_case_loop"].REPO_ROOT = repo_root + try: + paths = build_coder_snapshot_paths( + { + "priority_foci": [ + { + "candidate_files": [ + "llm_normalizer/backend/src/services/addressFilterExtractor.ts", + "llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts", + ] + } + ], + "targets": [ + { + "candidate_files": [ + "llm_normalizer/backend/src/services/addressFilterExtractor.ts", + "../outside.ts", + ] + } + ], + } + ) + finally: + sys.modules["scripts.domain_case_loop"].REPO_ROOT = original_repo_root + + assert paths == [file_a, file_b] + + +def test_restore_line_collapsed_files_from_snapshot_recovers_original_text(tmp_path) -> None: + sample = tmp_path / "sample.ts" + original = "const a = 1;\nconst b = 2;\n" + sample.write_text(original, encoding="utf-8") + snapshots = snapshot_coder_candidate_files([sample]) + sample.write_text("const a = 1;const b = 2;", encoding="utf-8") + + original_repo_root = sys.modules["scripts.domain_case_loop"].REPO_ROOT + sys.modules["scripts.domain_case_loop"].REPO_ROOT = tmp_path + try: + restored = restore_line_collapsed_files_from_snapshot(snapshots) + finally: + sys.modules["scripts.domain_case_loop"].REPO_ROOT = original_repo_root + + assert restored == ["sample.ts"] + assert sample.read_text(encoding="utf-8") == original + + +def test_restore_line_collapsed_files_from_snapshot_keeps_semantic_changes(tmp_path) -> None: + sample = tmp_path / "sample.ts" + original = "const a = 1;\nconst b = 2;\n" + sample.write_text(original, encoding="utf-8") + snapshots = snapshot_coder_candidate_files([sample]) + sample.write_text("const a = 1;const b = 3;", encoding="utf-8") + + original_repo_root = sys.modules["scripts.domain_case_loop"].REPO_ROOT + sys.modules["scripts.domain_case_loop"].REPO_ROOT = tmp_path + try: + restored = restore_line_collapsed_files_from_snapshot(snapshots) + finally: + sys.modules["scripts.domain_case_loop"].REPO_ROOT = original_repo_root + + assert restored == [] + assert sample.read_text(encoding="utf-8") == "const a = 1;const b = 3;" + + def test_evaluate_deterministic_loop_gate_rejects_partial_pack_even_without_targets() -> None: gate_ok, reason = evaluate_deterministic_loop_gate( {"final_status": "partial"}, @@ -610,3 +801,83 @@ def test_evaluate_deterministic_loop_gate_accepts_clean_pack_without_remaining_p assert gate_ok is True assert reason == "deterministic_gate_passed" + + +def test_build_scenario_step_state_uses_effective_analysis_context_from_turn_artifact() -> None: + step_state = build_scenario_step_state( + scenario_id="inventory_snapshot_roots", + domain="inventory_stock", + step={ + "step_id": "step_03_account_41_now", + "title": "Account 41 current composition", + "depends_on": [], + "question_template": "Из каких товаров состоит остаток по 41 счету", + "analysis_context": {}, + "expected_intents": ["inventory_on_hand_as_of_date"], + "expected_capability": "confirmed_inventory_on_hand_as_of_date", + "expected_recipe": None, + "expected_result_mode": "confirmed_balance", + "required_filters": { + "period_from": "2021-09-01", + "period_to": "2021-09-30", + }, + "forbidden_capabilities": [], + "forbidden_recipes": [], + "required_state_objects": [], + "required_answer_shape": "item_list_with_account_41_scope", + "forbidden_answer_patterns": [], + "required_carryover_invariants": [], + "invariant_severity": {}, + }, + step_index=3, + question_resolved="Из каких товаров состоит остаток по 41 счету", + analysis_context={"as_of_date": "2021-09-30", "source": "scenario_manifest"}, + turn_artifact={ + "scenario": { + "analysis_context": { + "as_of_date": "2021-09-30", + "source": "scenario_manifest", + } + }, + "assistant_message": { + "reply_type": "factual", + "text": "На 31.03.2019 на складе подтверждено 16 позиций.", + }, + "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_route_mode": "exact", + "route_expectation_status": "matched", + "result_mode": "confirmed_balance", + "response_type": "FACTUAL_LIST", + "extracted_filters": { + "as_of_date": "2019-03-31", + "period_from": "2019-03-01", + "period_to": "2019-03-31", + }, + "fallback_type": "none", + "mcp_call_status": "matched_non_empty", + "balance_confirmed": True, + }, + "session_summary": { + "address_navigation_state": { + "session_context": { + "date_scope": { + "as_of_date": "2019-03-31", + "period_from": "2019-03-01", + "period_to": "2019-03-31", + } + } + } + }, + }, + entries=[], + ) + + assert step_state["analysis_context"]["as_of_date"] == "2021-09-30" + assert "wrong_as_of_date" in step_state["violated_invariants"] + assert "wrong_period_from" in step_state["violated_invariants"] + assert "wrong_period_to" in step_state["violated_invariants"] + assert step_state["acceptance_status"] == "rejected"