ОРРКЕСТРАЦИЯ - Оркестрация домена: ужесточить автофикс loop и назначать primary repair focus
This commit is contained in:
@@ -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");
|
||||
|
||||
+56
-3
@@ -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("совпадений не нашлось");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user