ОРРКЕСТРАЦИЯ - Оркестрация домена: ужесточить автофикс loop и назначать primary repair focus

This commit is contained in:
dctouch 2026-04-15 08:09:42 +03:00
parent 5934f5f3fc
commit bc381c012e
16 changed files with 956 additions and 38 deletions

View File

@ -756,7 +756,21 @@
"title": "Current stock root", "title": "Current stock root",
"question": "Какие товары сейчас лежат на складе", "question": "Какие товары сейчас лежат на складе",
"expected_capability": "confirmed_inventory_on_hand_as_of_date", "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", "step_id": "step_02_stock_on_historical_date",
@ -771,7 +785,21 @@
"source": "binding_target_date_historical" "source": "binding_target_date_historical"
}, },
"expected_capability": "confirmed_inventory_on_hand_as_of_date", "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", "step_id": "step_02b_stock_on_named_month_prepositional",
@ -801,6 +829,20 @@
{ {
"step_id": "step_03_account_41_now", "step_id": "step_03_account_41_now",
"question_id": "Q03", "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_id": "N02_account_41_snapshot",
"node_role": "root_variant", "node_role": "root_variant",
"paraphrase_family": "canonical", "paraphrase_family": "canonical",
@ -1361,6 +1403,19 @@
{ {
"step_id": "step_05_supplier_items_on_date", "step_id": "step_05_supplier_items_on_date",
"question_id": "Q12", "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_id": "N08_supplier_items_on_date",
"node_role": "supporting_child", "node_role": "supporting_child",
"paraphrase_family": "canonical", "paraphrase_family": "canonical",
@ -1473,6 +1528,23 @@
{ {
"step_id": "step_05_unresolved_supplier_link", "step_id": "step_05_unresolved_supplier_link",
"question_id": "Q14", "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_id": "N10_unresolved_supplier_link",
"node_role": "supporting_child", "node_role": "supporting_child",
"paraphrase_family": "canonical", "paraphrase_family": "canonical",

View File

@ -1048,7 +1048,9 @@ function requiredFiltersByIntent(intent) {
return []; return [];
} }
function usesAsOfPrimaryWindow(intent) { 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_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" || intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" || 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") || const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_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_from;
delete filters.period_to; delete filters.period_to;
warnings.push("period_window_cleared_for_as_of_intent"); warnings.push("period_window_cleared_for_as_of_intent");

View File

@ -629,6 +629,12 @@ function tokenizeAnchor(value) {
.map((token) => token.trim()) .map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token)); .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) { function anchorTokenVariants(token) {
const source = String(token ?? "").trim().toLowerCase(); const source = String(token ?? "").trim().toLowerCase();
if (!source) { 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) { function isLikelyLowQualityPartyAnchor(value) {
const normalized = normalizeSearchText(String(value ?? "")); const normalized = normalizeSearchText(String(value ?? ""));
if (!normalized) { if (!normalized) {
@ -967,7 +1018,7 @@ function toNormalizedRows(rows) {
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); 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 warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление);
const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление); const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление);
const analytics = collectAnalyticsStrings(row); const analytics = collectAnalyticsStrings(row);
@ -987,7 +1038,9 @@ function toNormalizedRows(rows) {
.filter((item) => Boolean(item.period || item.registrator)); .filter((item) => Boolean(item.period || item.registrator));
} }
function rowSearchableText(row) { 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) { function rowMatchesAnyAccount(row, accountScope) {
if (accountScope.length === 0) { if (accountScope.length === 0) {
@ -1061,7 +1114,7 @@ function applyAddressFilters(rows, filters) {
if (filters.item && String(filters.item).trim()) { if (filters.item && String(filters.item).trim()) {
const needle = String(filters.item); const needle = String(filters.item);
const before = filtered.length; 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) { if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "item_anchor_not_matched_in_materialized_rows"; mismatchReason = "item_anchor_not_matched_in_materialized_rows";
} }

View File

@ -345,6 +345,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const previousContract = toNonEmptyString(previous.contract); const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account); const previousAccount = toNonEmptyString(previous.account);
const previousItem = toNonEmptyString(previous.item); const previousItem = toNonEmptyString(previous.item);
const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null;
const previousOrganization = toNonEmptyString(previous.organization); const previousOrganization = toNonEmptyString(previous.organization);
const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodFrom = toNonEmptyString(previous.period_from);
@ -462,10 +463,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_sale_trace_for_item" || intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" || intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") && intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item) && !toNonEmptyString(merged.item)) {
previousItem) { const inheritedItem = previousItem ?? previousAnchorItem;
if (intent !== "inventory_aging_by_purchase_date") { if (inheritedItem && intent !== "inventory_aging_by_purchase_date") {
merged.item = previousItem; merged.item = inheritedItem;
reasons.push("item_from_followup_context"); reasons.push("item_from_followup_context");
} }
} }
@ -493,6 +494,28 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("as_of_date_from_followup_context"); 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") { if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? "")); const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? ""));
if (toNonEmptyString(merged.item) && !explicitItemMention) { if (toNonEmptyString(merged.item) && !explicitItemMention) {
@ -569,7 +592,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("period_from_followup_context"); reasons.push("period_from_followup_context");
} }
} }
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) { if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
if (previousPeriodFrom) { if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom; merged.period_from = previousPeriodFrom;
} }

View File

@ -101,13 +101,36 @@ function matchesAnchorText(searchable, anchor) {
} }
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct)); return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
} }
return tokens.every((token) => { const fullMatch = tokens.every((token) => {
const variants = anchorTokenVariants(token); const variants = anchorTokenVariants(token);
return variants.some((variant) => { return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant); const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); 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) { function uniqueStrings(values) {
return Array.from(new Set(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 account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : ""; const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
const contract = typeof filters.contract === "string" ? filters.contract.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() : ""; const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (account) { if (account) {
@ -170,6 +195,29 @@ function resolvePrimaryAnchor(intent, filters) {
ambiguity_count: 0 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) { if (documentRef) {
return { return {
anchor_type: "document_ref", anchor_type: "document_ref",
@ -191,15 +239,20 @@ function refineAnchorFromRows(anchor, rows) {
if (rows.length === 0) { if (rows.length === 0) {
return anchor; 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; return anchor;
} }
const needleRaw = String(anchor.anchor_value_raw ?? "").trim(); const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) { if (!needleRaw) {
return anchor; return anchor;
} }
const candidates = uniqueStrings(rows const searchableRows = anchor.anchor_type === "item" || anchor.anchor_type === "warehouse"
.flatMap((row) => row.analytics) ? 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()) .map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))); .filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)));
if (candidates.length === 0) { if (candidates.length === 0) {

View File

@ -2839,6 +2839,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousAnchorType = "contract"; previousAnchorType = "contract";
previousAnchor = resolvedEntityFromFollowup.value; previousAnchor = resolvedEntityFromFollowup.value;
} }
else if (resolvedEntityFromFollowup.entityType === "item") {
previousFilters.item = resolvedEntityFromFollowup.value;
previousAnchorType = "item";
previousAnchor = resolvedEntityFromFollowup.value;
}
if (followupSelectionMode !== "switch_to_suggested_intent") { if (followupSelectionMode !== "switch_to_suggested_intent") {
followupSelectionMode = "carry_referenced_entity"; followupSelectionMode = "carry_referenced_entity";
} }

View File

@ -1442,7 +1442,9 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings.includes("period_derived_from_month_phrase") || warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_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_from;
delete filters.period_to; delete filters.period_to;
warnings.push("period_window_cleared_for_as_of_intent"); warnings.push("period_window_cleared_for_as_of_intent");

View File

@ -794,6 +794,13 @@ function tokenizeAnchor(value: string): string[] {
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token)); .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[] { function anchorTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase(); const source = String(token ?? "").trim().toLowerCase();
if (!source) { 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 { function isLikelyLowQualityPartyAnchor(value: string | null | undefined): boolean {
const normalized = normalizeSearchText(String(value ?? "")); const normalized = normalizeSearchText(String(value ?? ""));
if (!normalized) { if (!normalized) {
@ -1172,7 +1228,24 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); 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 warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление);
const organization = firstNonEmptyString( const organization = firstNonEmptyString(
row.Организация, row.Организация,
@ -1200,7 +1273,9 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
} }
function rowSearchableText(row: NormalizedAddressRow): string { 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 { function rowMatchesAnyAccount(row: NormalizedAddressRow, accountScope: string[]): boolean {
@ -1287,7 +1362,7 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
if (filters.item && String(filters.item).trim()) { if (filters.item && String(filters.item).trim()) {
const needle = String(filters.item); const needle = String(filters.item);
const before = filtered.length; 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) { if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "item_anchor_not_matched_in_materialized_rows"; mismatchReason = "item_anchor_not_matched_in_materialized_rows";
} }

View File

@ -13,7 +13,7 @@ import { extractAddressFilters } from "../addressFilterExtractor";
export interface AddressFollowupContext { export interface AddressFollowupContext {
previous_intent?: AddressIntent; previous_intent?: AddressIntent;
previous_filters?: AddressFilterSet; 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; previous_anchor_value?: string | null;
resolved_counterparty_from_display?: boolean; resolved_counterparty_from_display?: boolean;
} }
@ -451,6 +451,7 @@ function mergeFollowupFilters(
const previousContract = toNonEmptyString(previous.contract); const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account); const previousAccount = toNonEmptyString(previous.account);
const previousItem = toNonEmptyString(previous.item); const previousItem = toNonEmptyString(previous.item);
const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null;
const previousOrganization = toNonEmptyString(previous.organization); const previousOrganization = toNonEmptyString(previous.organization);
const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodFrom = toNonEmptyString(previous.period_from);
@ -587,11 +588,11 @@ function mergeFollowupFilters(
intent === "inventory_sale_trace_for_item" || intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" || intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") && intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item) && !toNonEmptyString(merged.item)
previousItem
) { ) {
if (intent !== "inventory_aging_by_purchase_date") { const inheritedItem = previousItem ?? previousAnchorItem;
merged.item = previousItem; if (inheritedItem && intent !== "inventory_aging_by_purchase_date") {
merged.item = inheritedItem;
reasons.push("item_from_followup_context"); reasons.push("item_from_followup_context");
} }
} }
@ -621,6 +622,32 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context"); 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") { if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test( const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
String(userMessage ?? "") String(userMessage ?? "")
@ -711,7 +738,7 @@ function mergeFollowupFilters(
} }
} }
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) { if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
if (previousPeriodFrom) { if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom; merged.period_from = previousPeriodFrom;
} }

View File

@ -16,7 +16,7 @@ const PARTY_ANCHOR_STOPWORDS = new Set([
]); ]);
export interface AnchorResolutionDebug { 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_raw: string | null;
anchor_value_resolved: string | null; anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null; resolver_confidence: "high" | "medium" | "low" | null;
@ -28,6 +28,8 @@ export interface ResolveStageRow {
account_dt: string | null; account_dt: string | null;
account_kt: string | null; account_kt: string | null;
analytics: string[]; analytics: string[];
item?: string | null;
warehouse?: string | null;
} }
function transliterateCyrillicToLatin(value: string): string { 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 searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
} }
return tokens.every((token) => { const fullMatch = tokens.every((token) => {
const variants = anchorTokenVariants(token); const variants = anchorTokenVariants(token);
return variants.some((variant) => { return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant); const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); 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[] { 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 account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : ""; const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
const contract = typeof filters.contract === "string" ? filters.contract.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() : ""; const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { 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) { if (documentRef) {
return { return {
anchor_type: "document_ref", anchor_type: "document_ref",
@ -226,16 +283,24 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv
if (rows.length === 0) { if (rows.length === 0) {
return anchor; 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; return anchor;
} }
const needleRaw = String(anchor.anchor_value_raw ?? "").trim(); const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) { if (!needleRaw) {
return anchor; 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( const candidates = uniqueStrings(
rows searchableRows
.flatMap((row) => row.analytics)
.map((value) => value.trim()) .map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)) .filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
); );

View File

@ -2796,6 +2796,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousAnchorType = "contract"; previousAnchorType = "contract";
previousAnchor = resolvedEntityFromFollowup.value; previousAnchor = resolvedEntityFromFollowup.value;
} }
else if (resolvedEntityFromFollowup.entityType === "item") {
previousFilters.item = resolvedEntityFromFollowup.value;
previousAnchorType = "item";
previousAnchor = resolvedEntityFromFollowup.value;
}
if (followupSelectionMode !== "switch_to_suggested_intent") { if (followupSelectionMode !== "switch_to_suggested_intent") {
followupSelectionMode = "carry_referenced_entity"; followupSelectionMode = "carry_referenced_entity";
} }

View File

@ -188,7 +188,7 @@ export interface AddressExecutionDebug {
mcp_call_status_legacy: Exclude<AddressMcpCallStatus, "materialized_but_not_anchor_matched" | "materialized_but_filtered_out_by_recipe">; mcp_call_status_legacy: Exclude<AddressMcpCallStatus, "materialized_but_not_anchor_matched" | "materialized_but_filtered_out_by_recipe">;
account_scope_mode: AddressAccountScopeMode; account_scope_mode: AddressAccountScopeMode;
account_scope_fallback_applied: boolean; 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_raw: string | null;
anchor_value_resolved: string | null; anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null; resolver_confidence: "high" | "medium" | "low" | null;

View File

@ -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"; 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_mode?: "strict" | "preferred";
account_scope_fallback_applied?: boolean; 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_raw?: string | null;
anchor_value_resolved?: string | null; anchor_value_resolved?: string | null;
resolver_confidence?: "high" | "medium" | "low" | null; resolver_confidence?: "high" | "medium" | "low" | null;

View File

@ -436,4 +436,35 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ИП Покупатель"); expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ИП Покупатель");
expect(String(result?.reply_text ?? "")).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("совпадений не нашлось");
});
}); });

View File

@ -61,6 +61,19 @@ DEFAULT_INVARIANT_SEVERITY: dict[str, str] = {
} }
REPAIR_TARGET_SEVERITY_ORDER = {"P0": 0, "P1": 1, "P2": 2} 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]] = { REPAIR_TARGET_FILE_HINTS: dict[str, list[str]] = {
"followup_action_resolution_gap": [ "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") 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: def sanitize_export_text(value: str) -> str:
raw = str(value or "") raw = str(value or "")
debug_heading = re.search( debug_heading = re.search(
@ -1551,6 +1623,7 @@ def build_scenario_step_state(
step: dict[str, Any], step: dict[str, Any],
step_index: int, step_index: int,
question_resolved: str, question_resolved: str,
analysis_context: dict[str, Any],
turn_artifact: dict[str, Any], turn_artifact: dict[str, Any],
entries: list[dict[str, Any]], entries: list[dict[str, Any]],
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -1570,7 +1643,12 @@ def build_scenario_step_state(
reply_type = assistant_item.get("reply_type") reply_type = assistant_item.get("reply_type")
assistant_text = str(assistant_item.get("text") or "") assistant_text = str(assistant_item.get("text") or "")
top_non_empty = first_non_empty_lines(assistant_text, limit=3) 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 = { step_state = {
"schema_version": SCENARIO_STEP_STATE_SCHEMA_VERSION, "schema_version": SCENARIO_STEP_STATE_SCHEMA_VERSION,
@ -1582,7 +1660,7 @@ def build_scenario_step_state(
"depends_on": step["depends_on"], "depends_on": step["depends_on"],
"question_template": step["question_template"], "question_template": step["question_template"],
"question_resolved": question_resolved, "question_resolved": question_resolved,
"analysis_context": analysis_context, "analysis_context": effective_analysis_context,
"expected_intents": step.get("expected_intents") or [], "expected_intents": step.get("expected_intents") or [],
"expected_capability": step.get("expected_capability"), "expected_capability": step.get("expected_capability"),
"expected_recipe": step.get("expected_recipe"), "expected_recipe": step.get("expected_recipe"),
@ -1789,6 +1867,7 @@ def run_assistant_step(
step=step, step=step,
step_index=step_index, step_index=step_index,
question_resolved=question_resolved, question_resolved=question_resolved,
analysis_context=analysis_context,
turn_artifact=turn_artifact, turn_artifact=turn_artifact,
entries=entries, 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( def build_deterministic_repair_targets(
pack_state: dict[str, Any], pack_state: dict[str, Any],
scenario_artifacts: list[dict[str, Any]], scenario_artifacts: list[dict[str, Any]],
@ -2792,9 +2928,36 @@ def build_deterministic_repair_targets(
if target: if target:
targets.append(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( targets.sort(
key=lambda item: ( key=lambda item: (
REPAIR_TARGET_SEVERITY_ORDER.get(str(item.get("severity") or "P2"), 99), 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("scenario_id") or ""),
str(item.get("step_id") or ""), str(item.get("step_id") or ""),
) )
@ -2811,10 +2974,21 @@ def build_deterministic_repair_targets(
"final_status": pack_state.get("final_status"), "final_status": pack_state.get("final_status"),
"target_count": len(targets), "target_count": len(targets),
"severity_counts": severity_counts, "severity_counts": severity_counts,
"priority_foci": priority_foci,
"targets": targets, "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: def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str:
lines = [ lines = [
"# Repair targets", "# 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"- domain: `{repair_targets.get('domain') or 'n/a'}`",
f"- target_count: `{repair_targets.get('target_count') or 0}`", f"- target_count: `{repair_targets.get('target_count') or 0}`",
f"- severity_counts: `{dump_json(repair_targets.get('severity_counts') or {})}`", 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 []: for target in repair_targets.get("targets") or []:
if not isinstance(target, dict): if not isinstance(target, dict):
continue continue
@ -2834,6 +3035,8 @@ def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str:
f"- `{target.get('target_id')}`", f"- `{target.get('target_id')}`",
f" severity: `{target.get('severity')}`", f" severity: `{target.get('severity')}`",
f" problem_type: `{target.get('problem_type')}`", 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" root_cause_layers: {', '.join(target.get('root_cause_layers') or []) or 'none'}",
f" fix_goal: {target.get('fix_goal') or 'n/a'}", f" fix_goal: {target.get('fix_goal') or 'n/a'}",
f" candidate_files: {', '.join(target.get('candidate_files') or []) or 'none'}", 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 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` 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`; - `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; - `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; - `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; - `continue` means there is a clear next patch cycle;
@ -3035,9 +3239,22 @@ def build_coder_loop_prompt(
pack_dir: Path, pack_dir: Path,
repair_targets_path: Path, repair_targets_path: Path,
repair_targets_json: str, repair_targets_json: str,
assigned_focus: dict[str, Any] | None,
analyst_verdict_path: Path, analyst_verdict_path: Path,
analyst_verdict_json: str, analyst_verdict_json: str,
) -> 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( return textwrap.dedent(
f"""\ f"""\
You are the `domain_coder` for NDC_1C. 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 present heuristic answers as confirmed;
- do not touch unrelated files; - do not touch unrelated files;
- preserve already successful baseline flows. - 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 `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; - 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; - 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`; - 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} {repair_targets_json}
``` ```
{assigned_focus_block}
- then return JSON only and follow the schema exactly. - then return JSON only and follow the schema exactly.
""" """
).strip() ).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" requires_user_decision: `{item.get('requires_user_decision')}`",
f" user_decision_type: `{item.get('user_decision_type') or 'none'}`", f" user_decision_type: `{item.get('user_decision_type') or 'none'}`",
f" coder_status: `{item.get('coder_status') or 'n/a'}`", 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" analyst_verdict: `{item.get('analyst_verdict_path') or 'n/a'}`",
f" repair_targets: `{item.get('repair_targets_path') or 'n/a'}`", f" repair_targets: `{item.get('repair_targets_path') or 'n/a'}`",
f" repair_target_count: `{item.get('repair_target_count')}`", f" repair_target_count: `{item.get('repair_target_count')}`",
@ -3396,16 +3620,20 @@ def handle_run_pack_loop(args: argparse.Namespace) -> int:
break break
coder_result_path = iteration_dir / "coder_result.json" coder_result_path = iteration_dir / "coder_result.json"
assigned_focus = select_primary_repair_focus(repair_targets)
coder_prompt = build_coder_loop_prompt( coder_prompt = build_coder_loop_prompt(
loop_dir=loop_dir, loop_dir=loop_dir,
iteration_dir=iteration_dir, iteration_dir=iteration_dir,
pack_dir=pack_dir, pack_dir=pack_dir,
repair_targets_path=repair_targets_path, repair_targets_path=repair_targets_path,
repair_targets_json=repair_targets_json, repair_targets_json=repair_targets_json,
assigned_focus=assigned_focus,
analyst_verdict_path=analyst_verdict_path, analyst_verdict_path=analyst_verdict_path,
analyst_verdict_json=dump_json(analyst_verdict), analyst_verdict_json=dump_json(analyst_verdict),
) )
write_text(iteration_dir / "coder_prompt.md", coder_prompt + "\n") 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( coder_command = build_codex_exec_command(
args, args,
output_file=coder_result_path, 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", stdout_path=iteration_dir / "coder_exec.stdout.log",
stderr_path=iteration_dir / "coder_exec.stderr.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_result = read_json_output(coder_result_path)
coder_status = str(coder_result.get("status") or "").strip() or "unknown" coder_status = str(coder_result.get("status") or "").strip() or "unknown"
iteration_record["coder_status"] = coder_status iteration_record["coder_status"] = coder_status
iteration_record["coder_result_path"] = str(coder_result_path) 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["iterations"].append(iteration_record)
loop_state["updated_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat() loop_state["updated_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
write_json(loop_dir / "loop_state.json", loop_state) write_json(loop_dir / "loop_state.json", loop_state)

View File

@ -7,7 +7,10 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.domain_case_loop import ( from scripts.domain_case_loop import (
build_coder_loop_prompt,
build_coder_snapshot_paths,
build_deterministic_repair_targets, build_deterministic_repair_targets,
build_scenario_step_state,
build_scenario_acceptance_matrix, build_scenario_acceptance_matrix,
carry_forward_analysis_context, carry_forward_analysis_context,
derive_pack_final_status, derive_pack_final_status,
@ -15,6 +18,9 @@ from scripts.domain_case_loop import (
evaluate_deterministic_loop_gate, evaluate_deterministic_loop_gate,
load_scenario_pack, load_scenario_pack,
merge_scenario_date_scope, merge_scenario_date_scope,
select_primary_repair_focus,
restore_line_collapsed_files_from_snapshot,
snapshot_coder_candidate_files,
validate_step_contract, 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"]) 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: def test_evaluate_deterministic_loop_gate_rejects_partial_pack_even_without_targets() -> None:
gate_ok, reason = evaluate_deterministic_loop_gate( gate_ok, reason = evaluate_deterministic_loop_gate(
{"final_status": "partial"}, {"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 gate_ok is True
assert reason == "deterministic_gate_passed" 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"