ОРРКЕСТРАЦИЯ - Оркестрация домена: ужесточить автофикс loop и назначать primary repair focus
This commit is contained in:
parent
5934f5f3fc
commit
bc381c012e
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>): 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<Record<string, unknown>>): 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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export interface AddressExecutionDebug {
|
|||
mcp_call_status_legacy: Exclude<AddressMcpCallStatus, "materialized_but_not_anchor_matched" | "materialized_but_filtered_out_by_recipe">;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("совпадений не нашлось");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue