Compare commits
2 Commits
477e550b0c
...
2cec57888a
| Author | SHA1 | Date |
|---|---|---|
|
|
2cec57888a | |
|
|
4d32b7fad5 |
|
|
@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.isLowQualityCounterpartyAnchorValue = isLowQualityCounterpartyAnchorValue;
|
||||
exports.isLowQualityInventoryItemAnchorValue = isLowQualityInventoryItemAnchorValue;
|
||||
exports.isInventoryItemAnchorDegradation = isInventoryItemAnchorDegradation;
|
||||
exports.extractSelectedObjectQuotedValue = extractSelectedObjectQuotedValue;
|
||||
|
|
@ -642,13 +643,17 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
|
|||
const value = String(rawValue ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(?:товар(?:ы|а|ов|у|ом|ами|ах)?|номенклатур(?:а|ы|у|ой|ами)?|позици(?:я|и|ю|ей)|остат(?:ок|ки|ка|ков|кам|ками)?|склад|складе|складу)$/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AddressQueryService = void 0;
|
||||
exports.ensureInventorySupplierOverlapLeadLine = ensureInventorySupplierOverlapLeadLine;
|
||||
const config_1 = require("../config");
|
||||
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
|
||||
const addressMcpClient_1 = require("./addressMcpClient");
|
||||
|
|
@ -1988,6 +1989,10 @@ function hasExplicitPeriodWindow(filters) {
|
|||
function asksForUnresolvedInventorySupplierLink(userMessage) {
|
||||
return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? ""));
|
||||
}
|
||||
function messagePairAsksForUnresolvedInventorySupplierLink(userMessage, rawUserMessage) {
|
||||
return (asksForUnresolvedInventorySupplierLink(userMessage) ||
|
||||
asksForUnresolvedInventorySupplierLink(rawUserMessage));
|
||||
}
|
||||
function canAutoBroadenPeriodWindow(intent, filters) {
|
||||
if (Array.isArray(filters.warnings) && filters.warnings?.includes("exact_historical_period_window_requested")) {
|
||||
return false;
|
||||
|
|
@ -2037,7 +2042,11 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) {
|
|||
intent !== "inventory_purchase_to_sale_chain") {
|
||||
return false;
|
||||
}
|
||||
return (reasons.includes("period_window_semantic_from_inventory_snapshot_context") ||
|
||||
const historyIntent = intent === "inventory_sale_trace_for_item" ||
|
||||
intent === "inventory_profitability_for_item" ||
|
||||
intent === "inventory_purchase_to_sale_chain";
|
||||
return ((historyIntent && reasons.includes("as_of_date_from_analysis_context")) ||
|
||||
reasons.includes("period_window_semantic_from_inventory_snapshot_context") ||
|
||||
reasons.includes("period_window_semantic_from_inventory_as_of_month") ||
|
||||
reasons.includes("as_of_date_from_followup_context") ||
|
||||
reasons.includes("period_from_followup_context") ||
|
||||
|
|
@ -2119,6 +2128,75 @@ function injectNoticeAfterLeadLine(text, notice) {
|
|||
}
|
||||
return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n");
|
||||
}
|
||||
function injectNoticeBeforeLeadLine(text, notice) {
|
||||
const normalizedText = typeof text === "string" ? text : "";
|
||||
const normalizedNotice = typeof notice === "string" ? notice.trim() : "";
|
||||
if (!normalizedText.trim()) {
|
||||
return normalizedNotice;
|
||||
}
|
||||
if (!normalizedNotice) {
|
||||
return normalizedText;
|
||||
}
|
||||
return [normalizedNotice, normalizedText].join("\n");
|
||||
}
|
||||
function injectAutoBroadenedPeriodNotice(intent, text, notice) {
|
||||
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return injectNoticeBeforeLeadLine(text, notice);
|
||||
}
|
||||
return injectNoticeAfterLeadLine(text, notice);
|
||||
}
|
||||
function extractSupplierOverlapReplyCounterparties(text) {
|
||||
const counterparties = [];
|
||||
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\n]+)/giu;
|
||||
for (const match of String(text ?? "").matchAll(pattern)) {
|
||||
const value = String(match[1] ?? "").replace(/\s+/gu, " ").trim();
|
||||
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
|
||||
continue;
|
||||
}
|
||||
counterparties.push(value);
|
||||
}
|
||||
return uniqueStrings(counterparties);
|
||||
}
|
||||
function formatSupplierOverlapCounterpartyLead(counterparties, limit = 6) {
|
||||
const visible = counterparties.slice(0, limit);
|
||||
const remaining = counterparties.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
function hasInventorySupplierOverlapDefensiveLead(firstLine) {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized);
|
||||
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized);
|
||||
return startsWithStockScope && hasDefensiveAttribution;
|
||||
}
|
||||
function ensureInventorySupplierOverlapLeadLine(intent, text) {
|
||||
if (intent !== "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return text;
|
||||
}
|
||||
const normalizedText = typeof text === "string" ? text : "";
|
||||
const lines = normalizedText.split(/\r?\n/u);
|
||||
const firstLine = lines[0]?.trim() ?? "";
|
||||
const displayedCounterparties = extractSupplierOverlapReplyCounterparties(normalizedText);
|
||||
if (displayedCounterparties.length > 0 &&
|
||||
hasInventorySupplierOverlapDefensiveLead(firstLine)) {
|
||||
const repairedLead = displayedCounterparties.length === 1
|
||||
? `По складскому остатку найден поставщик закупочного следа: ${displayedCounterparties[0]}.`
|
||||
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatSupplierOverlapCounterpartyLead(displayedCounterparties)}.`;
|
||||
return [repairedLead, firstLine, ...lines.slice(1)].join("\n");
|
||||
}
|
||||
if (!normalizedText.trim() ||
|
||||
/^По\s+(?:складскому\s+остатку|окну)\b/iu.test(firstLine) ||
|
||||
/^В\s+текущем\s+складском\s+срезе\b/iu.test(firstLine)) {
|
||||
return normalizedText;
|
||||
}
|
||||
if (!/^(?:Что\s+проверили:|Часть\s+закупочных\s+операций|Опорные\s+документы:)/iu.test(firstLine)) {
|
||||
return normalizedText;
|
||||
}
|
||||
return [
|
||||
"По складскому остатку однозначный поставщик текущего остатка не подтвержден; ниже показан проверенный закупочный след и ограничения.",
|
||||
normalizedText
|
||||
].join("\n");
|
||||
}
|
||||
function runtimeReadinessForLimitedCategory(category) {
|
||||
if (category === "empty_match" || category === "missing_anchor") {
|
||||
return "LIVE_QUERYABLE_WITH_LIMITS";
|
||||
|
|
@ -2971,7 +3049,7 @@ class AddressQueryService {
|
|||
if (intent.intent === "inventory_supplier_stock_overlap_as_of_date" &&
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.period_from) &&
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.period_to) &&
|
||||
asksForUnresolvedInventorySupplierLink(userMessage) &&
|
||||
messagePairAsksForUnresolvedInventorySupplierLink(userMessage, options.rawUserMessage) &&
|
||||
!/(?:за\s+вс[её]\s+время|за\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)) {
|
||||
const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date);
|
||||
if (monthWindow) {
|
||||
|
|
@ -3079,6 +3157,19 @@ class AddressQueryService {
|
|||
receivablesConfirmedExecution?.executionFilters ??
|
||||
vatPayableConfirmedExecution?.executionFilters ??
|
||||
filters.extracted_filters;
|
||||
if (intent.intent === "inventory_aging_by_purchase_date" &&
|
||||
toNonEmptyFilterValue(executionFilters.as_of_date) &&
|
||||
(toNonEmptyFilterValue(executionFilters.period_from) || toNonEmptyFilterValue(executionFilters.period_to))) {
|
||||
executionFilters = { ...executionFilters };
|
||||
delete executionFilters.period_from;
|
||||
delete executionFilters.period_to;
|
||||
if (!filters.warnings.includes("period_window_detached_for_inventory_aging_execution")) {
|
||||
filters.warnings.push("period_window_detached_for_inventory_aging_execution");
|
||||
}
|
||||
if (!baseReasons.includes("period_window_detached_for_inventory_aging_execution")) {
|
||||
baseReasons.push("period_window_detached_for_inventory_aging_execution");
|
||||
}
|
||||
}
|
||||
if (intent.intent === "counterparty_activity_lifecycle" &&
|
||||
typeof executionFilters.counterparty === "string" &&
|
||||
sameOrganizationEntityReference(executionFilters.counterparty, executionFilters.organization ?? activeOrganization)) {
|
||||
|
|
@ -3233,6 +3324,7 @@ class AddressQueryService {
|
|||
: typeof filterSet.account === "string"
|
||||
? filterSet.account
|
||||
: undefined,
|
||||
warehouseHint: typeof filterSet.warehouse === "string" ? filterSet.warehouse : undefined,
|
||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
|
|
@ -3861,7 +3953,7 @@ class AddressQueryService {
|
|||
});
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters),
|
||||
reply_text: repairCounterpartyReplyLabel(ensureInventorySupplierOverlapLeadLine(intent.intent, replyText), intent.intent, filters.extracted_filters),
|
||||
reply_type: (0, composeStage_1.inferReplyType)(responseType),
|
||||
response_type: responseType,
|
||||
debug: debugPayload
|
||||
|
|
@ -3983,7 +4075,7 @@ class AddressQueryService {
|
|||
});
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: repairCounterpartyReplyLabel(input.replyText, intent.intent, (input.extractedFilters ?? filters.extracted_filters)),
|
||||
reply_text: repairCounterpartyReplyLabel(ensureInventorySupplierOverlapLeadLine(intent.intent, input.replyText), intent.intent, (input.extractedFilters ?? filters.extracted_filters)),
|
||||
reply_type: (0, composeStage_1.inferReplyType)(input.responseType),
|
||||
response_type: input.responseType,
|
||||
debug: debugPayload
|
||||
|
|
@ -4263,7 +4355,7 @@ class AddressQueryService {
|
|||
resultMode: broadenedCoverageEvidence.result_mode ?? undefined
|
||||
});
|
||||
return buildFactualExecutionResult({
|
||||
replyText: injectNoticeAfterLeadLine(broadenedFactual.text, broadenedPrefix),
|
||||
replyText: injectAutoBroadenedPeriodNotice(intent.intent, broadenedFactual.text, broadenedPrefix),
|
||||
responseType: broadenedFactual.responseType,
|
||||
responseSemantics: broadenedFactual.semantics,
|
||||
selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
|
||||
|
|
|
|||
|
|
@ -1441,20 +1441,11 @@ function buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit) {
|
|||
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
|
||||
];
|
||||
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
|
||||
const currentStockItemSubquery = [
|
||||
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
|
||||
" Остатки.Субконто1",
|
||||
" ИЗ",
|
||||
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
|
||||
buildStaticWhereClause(onHandScopeConditions, " "),
|
||||
")"
|
||||
].join("\n");
|
||||
const purchaseWhereClause = buildStaticWhereClause([
|
||||
"Товары.Ссылка.Проведен = ИСТИНА",
|
||||
`Товары.Ссылка.Дата <= ${asOfExpr}`,
|
||||
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]),
|
||||
`Товары.Номенклатура В ${currentStockItemSubquery}`
|
||||
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"])
|
||||
]);
|
||||
return `
|
||||
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
|
||||
|
|
|
|||
|
|
@ -559,6 +559,9 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
|
|||
if (!canReenterInventoryRoot) {
|
||||
return false;
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
return false;
|
||||
}
|
||||
if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -584,13 +587,13 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
|
|||
}
|
||||
function hasSelectedObjectInventorySignal(text) {
|
||||
const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? ""));
|
||||
if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(repairedSelectedObjectText)) {
|
||||
if (/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(repairedSelectedObjectText)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) {
|
||||
if (/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) {
|
||||
return true;
|
||||
}
|
||||
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""));
|
||||
return /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""));
|
||||
}
|
||||
function hasSelectedObjectInlineSnapshotMetadata(text) {
|
||||
return /(?:дата\s+строки|строка\s+от|количество\s*:|стоимость\s*:|склад\s*:|организация\s*:|\|\s*(?:склад|количество|стоимость|организация|дата\s+строки)\s*:)/iu.test(String(text ?? ""));
|
||||
|
|
@ -618,6 +621,7 @@ function hasInventorySupplierFollowupCue(text) {
|
|||
function hasInventoryPurchaseDocumentsFollowupCue(text) {
|
||||
const value = textWithRepairedVariant(String(text ?? ""));
|
||||
return (/(?:по\s+каким\s+документам\s+(?:это|его|ее|её|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|ее|её|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|подтверждена\s+закупка|доказан[оа]?|закрыт[оа]?)|закупк[аи]\s+подтвержден[аы]?\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test(value) ||
|
||||
/(?:документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици)|какие\s+документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици))/iu.test(value) ||
|
||||
/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) ||
|
||||
/(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value));
|
||||
}
|
||||
|
|
@ -823,6 +827,22 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
};
|
||||
const clearInventoryOnHandWarehouseAliasItem = () => {
|
||||
if (intent !== "inventory_on_hand_as_of_date") {
|
||||
return;
|
||||
}
|
||||
const currentItem = toNonEmptyString(merged.item);
|
||||
const currentWarehouse = toNonEmptyString(merged.warehouse);
|
||||
if (!currentItem || !currentWarehouse) {
|
||||
return;
|
||||
}
|
||||
const normalizedItem = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(currentItem);
|
||||
const normalizedWarehouse = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(currentWarehouse);
|
||||
if (normalizedItem && normalizedWarehouse && normalizedItem === normalizedWarehouse) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot");
|
||||
}
|
||||
};
|
||||
if (!followupContext) {
|
||||
if ((intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
|
|
@ -838,17 +858,20 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
: "as_of_date_derived_from_period_for_open_contracts");
|
||||
}
|
||||
}
|
||||
clearInventoryOnHandWarehouseAliasItem();
|
||||
clearInventoryAgingOrganizationAliasItem(null);
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
const previous = followupContext.previous_filters ?? {};
|
||||
const root = followupContext.root_filters ?? {};
|
||||
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
|
||||
const previousCounterparty = toNonEmptyString(previous.counterparty);
|
||||
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 previousOrganization = toNonEmptyString(previous.organization) ?? toNonEmptyString(root.organization);
|
||||
const previousWarehouse = toNonEmptyString(previous.warehouse) ?? toNonEmptyString(root.warehouse);
|
||||
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
|
||||
const previousPeriodFrom = toNonEmptyString(previous.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
|
|
@ -1231,6 +1254,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
reasons.push("period_derived_from_inventory_root_frame_year");
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
if (previousWarehouse && !toNonEmptyString(merged.warehouse)) {
|
||||
merged.warehouse = previousWarehouse;
|
||||
reasons.push("warehouse_from_followup_context");
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(previousOrganization);
|
||||
}
|
||||
if (!sameDateRequested &&
|
||||
|
|
@ -1254,6 +1281,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
delete merged.period_to;
|
||||
reasons.push("period_cleared_by_all_time_followup");
|
||||
}
|
||||
clearInventoryOnHandWarehouseAliasItem();
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
if (intent === "counterparty_activity_lifecycle" &&
|
||||
|
|
@ -1414,6 +1442,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
reasons.push("counterparty_cleared_as_organization_scope_alias");
|
||||
}
|
||||
}
|
||||
clearInventoryOnHandWarehouseAliasItem();
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
function resolveMissingRequiredFilters(intent, filters) {
|
||||
|
|
@ -1441,16 +1470,50 @@ function resolveMissingRequiredFilters(intent, filters) {
|
|||
});
|
||||
}
|
||||
function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) {
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
|
||||
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
|
||||
if (hasSelectedObjectReference && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date") {
|
||||
return {
|
||||
intent: "inventory_purchase_documents_for_item",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_documents"]
|
||||
};
|
||||
}
|
||||
}
|
||||
if (hasSelectedObjectReference && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date") {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_supplier"]
|
||||
};
|
||||
}
|
||||
}
|
||||
if (hasSelectedObjectReference && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" || detectedIntent.intent === "inventory_on_hand_as_of_date") {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_purchase_date"]
|
||||
};
|
||||
}
|
||||
}
|
||||
return detectedIntent;
|
||||
}
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const purchaseBridgeContinuationSignal = followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
|
||||
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
|
||||
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal;
|
||||
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal || hasSelectedObjectReference;
|
||||
if (!hasFollowupSignal) {
|
||||
return detectedIntent;
|
||||
}
|
||||
|
|
@ -1481,7 +1544,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
followupContext.root_anchor_type === "item" ||
|
||||
followupContext.current_frame_kind === "inventory_root" ||
|
||||
followupContext.current_frame_kind === "inventory_drilldown";
|
||||
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
|
||||
const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal));
|
||||
const previousCounterpartyLaneActive = hasPreviousCounterparty &&
|
||||
(followupContext.previous_anchor_type === "counterparty" ||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,53 @@ function inventoryRequestedPartyMatches(requested, actualParties) {
|
|||
function inventoryPartyListOrUnknown(parties) {
|
||||
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
|
||||
}
|
||||
function inventoryPartyLeadList(parties, limit = 6) {
|
||||
const visible = parties.slice(0, limit);
|
||||
const remaining = parties.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
function inventoryItemLeadList(items, limit = 6) {
|
||||
const visible = items.slice(0, limit);
|
||||
const remaining = items.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
function collectInventoryTraceItems(rows) {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const item = String(row.item ?? "").trim();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const comparable = normalizeInventoryReplyEntityToken(item);
|
||||
if (!comparable || seen.has(comparable)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(comparable);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function extractInventoryCounterpartiesFromEvidenceLines(lines) {
|
||||
const counterparties = [];
|
||||
for (const line of lines) {
|
||||
const match = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|]+)/iu.exec(line);
|
||||
const value = String(match?.[1] ?? "").replace(/\s+/gu, " ").trim();
|
||||
if (value) {
|
||||
counterparties.push(value);
|
||||
}
|
||||
}
|
||||
return counterparties;
|
||||
}
|
||||
function hasSupplierScopedItemListQuestion(options) {
|
||||
if (!String(options.counterpartyHint ?? "").trim()) {
|
||||
return false;
|
||||
}
|
||||
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
|
||||
return /(?:какие?\s+товар|товар(?:ы|ов)?\s+(?:от|у)\s+поставщик|позици(?:и|я|ю)|номенклатур|лежат|остат(?:ок|ки|ка|ков))/iu.test(text);
|
||||
}
|
||||
function omitAmountFromInventoryTraceEvidence(lines) {
|
||||
return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, ""));
|
||||
}
|
||||
|
|
@ -131,6 +178,36 @@ function sumInventoryRowAmount(rows) {
|
|||
function sumInventoryRowQuantity(rows) {
|
||||
return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0);
|
||||
}
|
||||
function cleanInventoryScopeHint(value) {
|
||||
const cleaned = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||
return cleaned.length > 0 ? cleaned : null;
|
||||
}
|
||||
function inventoryOnHandAccountScopeLabel(options) {
|
||||
const accountHint = cleanInventoryScopeHint(options.accountHint);
|
||||
if (accountHint) {
|
||||
return `по счету ${accountHint}`;
|
||||
}
|
||||
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
|
||||
return /(?:^|[^\d])41(?:[.,]\d+)?(?:[^\d]|$)|41\s*сч[её]т/iu.test(text) ? "по счету 41" : null;
|
||||
}
|
||||
function inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses) {
|
||||
const warehouseHint = cleanInventoryScopeHint(options.warehouseHint);
|
||||
if (warehouseHint) {
|
||||
return `по складу ${warehouseHint}`;
|
||||
}
|
||||
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
|
||||
if (uniqueWarehouses.length === 1 && /(?:по|на)\s+склад[уе]?/iu.test(text)) {
|
||||
return `по складу ${uniqueWarehouses[0]}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function inventoryOnHandScopePhrase(options, uniqueWarehouses) {
|
||||
const scopes = [
|
||||
inventoryOnHandAccountScopeLabel(options),
|
||||
inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses)
|
||||
].filter((item) => Boolean(item));
|
||||
return scopes.length > 0 ? scopes.join(" и ") : "на складе";
|
||||
}
|
||||
function formatInventoryPercent(value, formatNumberWithDots) {
|
||||
return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`;
|
||||
}
|
||||
|
|
@ -222,9 +299,10 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item));
|
||||
const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
|
||||
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
|
||||
const scopePhrase = inventoryOnHandScopePhrase(options, uniqueWarehouses);
|
||||
const directAnswerLine = positions.length > 0
|
||||
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
|
||||
? `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
: `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтвержденных товарных остатков не найдено.`;
|
||||
const lines = [directAnswerLine];
|
||||
if (positions.length > 0) {
|
||||
const visiblePositionsLimit = 6;
|
||||
|
|
@ -250,13 +328,22 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
"- На дату среза товары с ненулевым остатком не найдены."
|
||||
]);
|
||||
}
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [
|
||||
const summaryBullets = [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
|
||||
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
|
||||
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
|
||||
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
|
||||
]);
|
||||
];
|
||||
const accountScope = inventoryOnHandAccountScopeLabel(options);
|
||||
const warehouseScope = inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses);
|
||||
if (accountScope) {
|
||||
summaryBullets.splice(1, 0, `Срез ${accountScope}.`);
|
||||
}
|
||||
if (warehouseScope) {
|
||||
summaryBullets.splice(accountScope ? 2 : 1, 0, `Срез ${warehouseScope}.`);
|
||||
}
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", summaryBullets);
|
||||
if (rows.length !== positions.length) {
|
||||
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
|
||||
}
|
||||
|
|
@ -398,49 +485,111 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
|
||||
const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(options.userMessage ?? ""));
|
||||
const purchaseRowCounterparties = purchaseRows.map((row) => deps.uniqueStrings([
|
||||
...deps.extractInventoryCounterpartyCandidates(row),
|
||||
...extractInventoryCounterpartiesFromEvidenceLines(deps.formatInventoryTraceRows([row], 1))
|
||||
]));
|
||||
const observedCounterparties = deps.uniqueStrings([
|
||||
...summary.counterparties,
|
||||
...purchaseRowCounterparties.flat()
|
||||
]);
|
||||
const unresolvedRows = purchaseRows.filter((_, index) => (purchaseRowCounterparties[index]?.length ?? 0) === 0);
|
||||
const unresolvedSupplierQuestionText = `${String(options.userMessage ?? "")}\n${String(options.rawUserMessage ?? "")}`;
|
||||
const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(unresolvedSupplierQuestionText);
|
||||
if (unresolvedSupplierQuestion) {
|
||||
const unresolvedItems = collectInventoryTraceItems(unresolvedRows);
|
||||
const unresolvedItemPreviewLimit = 3;
|
||||
const unresolvedDocumentPreviewLimit = 2;
|
||||
const directAnswerLine = unresolvedRows.length > 0
|
||||
? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`
|
||||
? unresolvedItems.length > 0
|
||||
? `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено ${deps.formatNumberWithDots(unresolvedItems.length)} позиций без надежной привязки к поставщику; первые примеры ниже.`
|
||||
: `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`
|
||||
: "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены.";
|
||||
const lines = [directAnswerLine];
|
||||
if (unresolvedItems.length > 0) {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции без надежной привязки к поставщику:", unresolvedItems.slice(0, unresolvedItemPreviewLimit).map((item, index) => `${index + 1}. ${item}`));
|
||||
if (unresolvedItems.length > unresolvedItemPreviewLimit) {
|
||||
lines.push(`- Показаны первые ${unresolvedItemPreviewLimit} из ${deps.formatNumberWithDots(unresolvedItems.length)} таких позиций.`);
|
||||
}
|
||||
}
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`,
|
||||
`Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`,
|
||||
`Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.`
|
||||
`Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(observedCounterparties.length)}.`
|
||||
]);
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
|
||||
"Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии."
|
||||
]);
|
||||
if (unresolvedRows.length > 0) {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции без явно выделенного поставщика:", deps.formatInventoryTraceRows(unresolvedRows, 12));
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(unresolvedRows, unresolvedDocumentPreviewLimit));
|
||||
if (unresolvedRows.length > unresolvedDocumentPreviewLimit) {
|
||||
lines.push(`- Показаны первые ${unresolvedDocumentPreviewLimit} из ${deps.formatNumberWithDots(unresolvedRows.length)} строк без выделенного поставщика.`);
|
||||
}
|
||||
}
|
||||
else if (summary.counterparties.length > 0) {
|
||||
lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`);
|
||||
else if (observedCounterparties.length > 0) {
|
||||
lines.push(`- В доступном закупочном следе встречаются поставщики: ${observedCounterparties.slice(0, 6).join("; ")}.`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(unresolvedRows.length > 0 ? "medium" : "strong", true));
|
||||
}
|
||||
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
|
||||
const directAnswerLine = summary.counterparties.length === 1
|
||||
? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.`
|
||||
: summary.counterparties.length > 1
|
||||
? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.`
|
||||
: `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`;
|
||||
const purchasePeriodLabel = summary.firstPeriod && summary.lastPeriod
|
||||
? `${deps.inventoryTraceDateLabel(summary.firstPeriod)}..${deps.inventoryTraceDateLabel(summary.lastPeriod)}`
|
||||
: "даты закупки не выделены";
|
||||
const supplierScopedItems = collectInventoryTraceItems(purchaseRows);
|
||||
const supplierScopedItemQuestion = hasSupplierScopedItemListQuestion(options);
|
||||
if (supplierScopedItemQuestion) {
|
||||
const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim();
|
||||
const directAnswerLine = supplierScopedItems.length > 0
|
||||
? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.`
|
||||
: `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`;
|
||||
const lines = [directAnswerLine];
|
||||
if (supplierScopedItems.length > 0) {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", supplierScopedItems.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||
if (supplierScopedItems.length > 8) {
|
||||
lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`);
|
||||
}
|
||||
}
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Период найденного закупочного следа: ${purchasePeriodLabel}.`,
|
||||
`Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
]);
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
|
||||
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
|
||||
]);
|
||||
if (observedCounterparties.length > 0) {
|
||||
lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
|
||||
}
|
||||
if (purchaseRows.length > 0) {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT));
|
||||
if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
|
||||
lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`);
|
||||
}
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (supplierScopedItems.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0));
|
||||
}
|
||||
const directAnswerLine = observedCounterparties.length === 1
|
||||
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} выявлен поставщик закупочного следа: ${observedCounterparties[0]}.`
|
||||
: observedCounterparties.length > 1
|
||||
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} найдено несколько поставщиков закупочного следа: ${inventoryPartyLeadList(observedCounterparties)}.`
|
||||
: unresolvedRows.length > 0
|
||||
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначный поставщик текущего остатка не подтвержден: в ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операциях поставщик не выделен.`
|
||||
: `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} поставщик в доступных данных не выделен.`;
|
||||
const lines = [directAnswerLine];
|
||||
if (unresolvedRows.length > 0) {
|
||||
lines.push(`По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначная атрибуция части текущего остатка не подтверждена: ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операций без явно выделенного поставщика.`);
|
||||
}
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||
`Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||
`Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`,
|
||||
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
`Период найденного закупочного следа: ${purchasePeriodLabel}.`,
|
||||
`Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
]);
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
|
||||
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
|
||||
]);
|
||||
if (summary.counterparties.length > 0) {
|
||||
lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`);
|
||||
if (observedCounterparties.length > 0) {
|
||||
lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
|
||||
}
|
||||
else if (purchaseRows.length > 0) {
|
||||
lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных.");
|
||||
|
|
@ -448,11 +597,11 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
else {
|
||||
lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу.");
|
||||
}
|
||||
if (unresolvedRows.length > 0) {
|
||||
lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`);
|
||||
}
|
||||
if (purchaseRows.length > 0) {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10));
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT));
|
||||
if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
|
||||
lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`);
|
||||
}
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0));
|
||||
}
|
||||
|
|
@ -770,17 +919,29 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties);
|
||||
const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties);
|
||||
const mismatchParts = [];
|
||||
if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) {
|
||||
if (requestedSupplier && purchaseRows.length === 0) {
|
||||
mismatchParts.push(`закупка у запрошенного поставщика ${requestedSupplier} не найдена`);
|
||||
}
|
||||
else if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) {
|
||||
mismatchParts.push(`запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}`);
|
||||
}
|
||||
if (requestedBuyer && saleRows.length > 0 && !buyerMatches) {
|
||||
if (requestedBuyer && saleRows.length === 0) {
|
||||
mismatchParts.push(`продажа/выбытие запрошенному покупателю ${requestedBuyer} не найдены`);
|
||||
}
|
||||
else if (requestedBuyer && saleRows.length > 0 && !buyerMatches) {
|
||||
mismatchParts.push(`запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}`);
|
||||
}
|
||||
const directAnswerLine = mismatchParts.length > 0
|
||||
? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.`
|
||||
: purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
|
||||
: purchaseRows.length > 0 && saleRows.length > 0 && purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
|
||||
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
|
||||
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
|
||||
: purchaseRows.length > 0 && saleRows.length > 0
|
||||
? `По товару ${itemLabel} найдены обе стороны цепочки поставки и продажи; участников несколько или они неоднозначны, детали ниже.`
|
||||
: purchaseRows.length > 0
|
||||
? `По товару ${itemLabel} закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.`
|
||||
: saleRows.length > 0
|
||||
? `По товару ${itemLabel} найдена продажа/выбытие со счета 41.01, но закупочная часть цепочки в доступных данных не подтверждена.`
|
||||
: `По товару ${itemLabel} цепочка поставки и продажи не подтверждена: движений по счету 41.01 в доступных данных не найдено.`;
|
||||
const lines = [directAnswerLine, "", "Подтверждение:"];
|
||||
lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`);
|
||||
lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`);
|
||||
|
|
|
|||
|
|
@ -133,11 +133,17 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
|
|||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) {
|
||||
extractedFilters.counterparty = scopeTargetText;
|
||||
pushWarning(warnings, "counterparty_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "counterparty";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
if ((0, addressFilterExtractor_1.isLowQualityCounterpartyAnchorValue)(scopeTargetText)) {
|
||||
delete extractedFilters.counterparty;
|
||||
pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
|
||||
}
|
||||
else {
|
||||
extractedFilters.counterparty = scopeTargetText;
|
||||
pushWarning(warnings, "counterparty_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "counterparty";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
}
|
||||
if (semanticHints.scope_target_kind === "contract" && scopeTargetText) {
|
||||
extractedFilters.contract = scopeTargetText;
|
||||
|
|
@ -159,6 +165,11 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
|
|||
pushWarning(warnings, "item_llm_semantics_ignored");
|
||||
}
|
||||
}
|
||||
if (toNonEmptyString(extractedFilters.counterparty) &&
|
||||
(0, addressFilterExtractor_1.isLowQualityCounterpartyAnchorValue)(String(extractedFilters.counterparty))) {
|
||||
delete extractedFilters.counterparty;
|
||||
pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
|
||||
}
|
||||
return {
|
||||
...extraction,
|
||||
extracted_filters: extractedFilters,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,182 @@ function toNullableBoolean(value) {
|
|||
function normalizeAddressReplyType(value) {
|
||||
return value === "factual" || value === "partial_coverage" ? value : "partial_coverage";
|
||||
}
|
||||
function uniqueNormalizedStrings(values) {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
for (const value of values) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const key = text.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(text);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function extractInventorySupplierOverlapCounterpartiesFromReply(replyText) {
|
||||
const values = [];
|
||||
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\r\n]+)/giu;
|
||||
for (const match of replyText.matchAll(pattern)) {
|
||||
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
|
||||
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
|
||||
continue;
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
return uniqueNormalizedStrings(values);
|
||||
}
|
||||
function formatInventorySupplierOverlapCounterpartyLead(counterparties, limit = 6) {
|
||||
const visible = counterparties.slice(0, limit);
|
||||
const remaining = counterparties.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
function hasInventorySupplierOverlapDefensiveLead(firstLine) {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized);
|
||||
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized);
|
||||
return startsWithStockScope && hasDefensiveAttribution;
|
||||
}
|
||||
function firstNonEmptyReplyLine(text) {
|
||||
return String(text ?? "")
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? "";
|
||||
}
|
||||
function hasInventorySupplierOverlapCounterpartyLead(firstLine) {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
return (/^\u043f\u043e\s+\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443/u.test(normalized) &&
|
||||
/\u043d\u0430\u0439\u0434\u0435\u043d/u.test(normalized) &&
|
||||
/\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a/u.test(normalized) &&
|
||||
/\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u043e\u0433\u043e\s+\u0441\u043b\u0435\u0434\u0430/u.test(normalized));
|
||||
}
|
||||
function preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize(input) {
|
||||
const debug = toRecordObject(input.addressDebug);
|
||||
const detectedIntent = toNullableString(debug?.detected_intent);
|
||||
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return { text: input.sanitizedReply, applied: false };
|
||||
}
|
||||
const rawLead = firstNonEmptyReplyLine(input.rawReply);
|
||||
if (!hasInventorySupplierOverlapCounterpartyLead(rawLead)) {
|
||||
return { text: input.sanitizedReply, applied: false };
|
||||
}
|
||||
const sanitizedLead = firstNonEmptyReplyLine(input.sanitizedReply);
|
||||
if (hasInventorySupplierOverlapCounterpartyLead(sanitizedLead)) {
|
||||
return { text: input.sanitizedReply, applied: false };
|
||||
}
|
||||
const sanitizedText = String(input.sanitizedReply ?? "").trim();
|
||||
return {
|
||||
text: sanitizedText ? [rawLead, sanitizedText].join("\n\n") : rawLead,
|
||||
applied: true
|
||||
};
|
||||
}
|
||||
function inspectInventorySupplierOverlapDefensiveLead(firstLine) {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
return {
|
||||
stockScopeMatch: /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized),
|
||||
defensiveMatch: /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized)
|
||||
};
|
||||
}
|
||||
function buildInventorySupplierOverlapSurfaceRepairAudit(input) {
|
||||
return {
|
||||
schema_version: "inventory_supplier_overlap_surface_repair_v1",
|
||||
applied: input.applied,
|
||||
detected_intent: input.detectedIntent,
|
||||
counterparties: input.counterparties ?? [],
|
||||
first_line_sample: input.firstLineSample ?? null,
|
||||
stock_scope_match: input.stockScopeMatch ?? null,
|
||||
defensive_match: input.defensiveMatch ?? null,
|
||||
reason_codes: input.reasonCodes
|
||||
};
|
||||
}
|
||||
function repairInventorySupplierOverlapSurfaceReply(replyText, addressDebug) {
|
||||
const text = String(replyText ?? "");
|
||||
const debug = toRecordObject(addressDebug);
|
||||
const detectedIntent = toNullableString(debug?.detected_intent);
|
||||
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return {
|
||||
text,
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: false,
|
||||
detectedIntent,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_intent_not_applicable"]
|
||||
})
|
||||
};
|
||||
}
|
||||
const lines = text.split(/\r?\n/u);
|
||||
const firstLine = lines[0]?.trim() ?? "";
|
||||
const leadInspection = inspectInventorySupplierOverlapDefensiveLead(firstLine);
|
||||
if (!hasInventorySupplierOverlapDefensiveLead(firstLine)) {
|
||||
return {
|
||||
text,
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: false,
|
||||
detectedIntent,
|
||||
firstLineSample: firstLine.slice(0, 220),
|
||||
stockScopeMatch: leadInspection.stockScopeMatch,
|
||||
defensiveMatch: leadInspection.defensiveMatch,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_first_line_not_defensive"]
|
||||
})
|
||||
};
|
||||
}
|
||||
const counterparties = extractInventorySupplierOverlapCounterpartiesFromReply(text);
|
||||
if (counterparties.length === 0) {
|
||||
return {
|
||||
text,
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: false,
|
||||
detectedIntent,
|
||||
counterparties,
|
||||
firstLineSample: firstLine.slice(0, 220),
|
||||
stockScopeMatch: leadInspection.stockScopeMatch,
|
||||
defensiveMatch: leadInspection.defensiveMatch,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_no_counterparties"]
|
||||
})
|
||||
};
|
||||
}
|
||||
const repairedLead = counterparties.length === 1
|
||||
? `По складскому остатку найден поставщик закупочного следа: ${counterparties[0]}.`
|
||||
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatInventorySupplierOverlapCounterpartyLead(counterparties)}.`;
|
||||
return {
|
||||
text: [repairedLead, firstLine, ...lines.slice(1)].join("\n"),
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: true,
|
||||
detectedIntent,
|
||||
counterparties,
|
||||
firstLineSample: firstLine.slice(0, 220),
|
||||
stockScopeMatch: leadInspection.stockScopeMatch,
|
||||
defensiveMatch: leadInspection.defensiveMatch,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_applied"]
|
||||
})
|
||||
};
|
||||
}
|
||||
function extractSingleWarehouseScopeFromReply(replyText) {
|
||||
const values = [];
|
||||
const normalized = String(replyText ?? "");
|
||||
const byFieldPattern = /(?:^|\|)\s*склад\s*:\s*([^|\r\n]+)/giu;
|
||||
for (const match of normalized.matchAll(byFieldPattern)) {
|
||||
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
|
||||
if (value) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
const fieldUnique = uniqueNormalizedStrings(values);
|
||||
if (fieldUnique.length > 0) {
|
||||
return fieldUnique.length === 1 ? fieldUnique[0] : null;
|
||||
}
|
||||
const leadMatch = normalized.match(/(?:по|на)\s+склад[уе]?\s+([^.,;\r\n]+)/iu);
|
||||
const leadWarehouse = toNullableString(leadMatch?.[1]?.replace(/[.;,\s]+$/u, ""));
|
||||
if (leadWarehouse) {
|
||||
values.push(leadWarehouse);
|
||||
}
|
||||
const unique = uniqueNormalizedStrings(values);
|
||||
return unique.length === 1 ? unique[0] : null;
|
||||
}
|
||||
function sameBusinessLabel(left, right) {
|
||||
const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
|
||||
const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
|
||||
|
|
@ -65,7 +241,7 @@ function cleanComparisonScopeCompanyLine(line, organization) {
|
|||
.replace(/\bcompany-level\b/giu, "общий по компании")
|
||||
.replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез");
|
||||
if (organization) {
|
||||
clean = clean.replace(/по компании\s+Альтернатива Плюс/iu, `по компании ${organization}`);
|
||||
clean = clean.replace(/по компании\s+.+?(?=\s+(?:подтвержден|подтвержд[её]н|зафиксирован|показан|есть)\b|:|$)/iu, `по компании ${organization}`);
|
||||
}
|
||||
return clean.trim();
|
||||
}
|
||||
|
|
@ -77,9 +253,7 @@ function hasComparisonScopeProofCue(value) {
|
|||
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(text);
|
||||
}
|
||||
function buildComparisonScopeProofReply(input) {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return null;
|
||||
}
|
||||
const legalOrganizationFromCurrentTurn = legalOrganizationLabelFromClarification(input.userMessage);
|
||||
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
|
||||
const turnInput = toRecordObject(entryPoint?.turn_input);
|
||||
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
|
|
@ -87,6 +261,9 @@ function buildComparisonScopeProofReply(input) {
|
|||
if (!isBusinessOverview) {
|
||||
return null;
|
||||
}
|
||||
if (!hasComparisonScopeProofCue(input.userMessage) && !legalOrganizationFromCurrentTurn) {
|
||||
return null;
|
||||
}
|
||||
const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates)
|
||||
? turnMeaning.business_overview_separate_entity_candidates
|
||||
: [];
|
||||
|
|
@ -113,7 +290,7 @@ function buildComparisonScopeProofReply(input) {
|
|||
if (!incomingAmount && !outgoingAmount && !netAmount) {
|
||||
return null;
|
||||
}
|
||||
const organization = legalOrganizationLabelFromClarification(input.userMessage)
|
||||
const organization = legalOrganizationFromCurrentTurn
|
||||
?? toNullableString(turnMeaning?.explicit_organization_scope)
|
||||
?? toNullableString(toRecordObject(comparisonScope?.organization)?.label);
|
||||
const documentCount = Number(toRecordObject(documentBundle)?.document_count);
|
||||
|
|
@ -277,8 +454,21 @@ function normalizeLlmPreDecomposeMeta(value) {
|
|||
}
|
||||
function runAssistantAddressLaneResponseRuntime(input) {
|
||||
const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? assistantAddressTurnFinalizeRuntimeAdapter_1.finalizeAssistantAddressTurn;
|
||||
const safeAddressReply = input.sanitizeOutgoingAssistantText(input.addressLane.reply_text);
|
||||
const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta);
|
||||
const surfaceRepair = repairInventorySupplierOverlapSurfaceReply(input.addressLane.reply_text, debug);
|
||||
const safeAddressReplyBase = input.sanitizeOutgoingAssistantText(surfaceRepair.text);
|
||||
const preservedAddressReply = preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize({
|
||||
rawReply: surfaceRepair.text,
|
||||
sanitizedReply: safeAddressReplyBase,
|
||||
addressDebug: debug
|
||||
});
|
||||
const safeAddressReply = preservedAddressReply.text;
|
||||
if (surfaceRepair.audit) {
|
||||
debug.inventory_supplier_overlap_surface_repair_v1 = {
|
||||
...surfaceRepair.audit,
|
||||
lead_preserved_after_sanitize: preservedAddressReply.applied
|
||||
};
|
||||
}
|
||||
const followupOffer = input.buildAddressFollowupOffer(debug);
|
||||
if (followupOffer) {
|
||||
debug.address_followup_offer = followupOffer;
|
||||
|
|
@ -309,14 +499,36 @@ function runAssistantAddressLaneResponseRuntime(input) {
|
|||
const rootFilters = followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object"
|
||||
? followupContextSource.root_filters
|
||||
: null;
|
||||
if (rootIntent || currentFrameKind) {
|
||||
const detectedIntent = input.toNonEmptyString(debug.detected_intent);
|
||||
const rootFrameWarehouse = input.toNonEmptyString(rootFilters?.warehouse) ??
|
||||
input.toNonEmptyString(debugFilters?.warehouse) ??
|
||||
(detectedIntent === "inventory_on_hand_as_of_date" ? extractSingleWarehouseScopeFromReply(safeAddressReply) : null);
|
||||
const shouldPersistInventoryRootFrame = rootIntent || currentFrameKind || detectedIntent === "inventory_on_hand_as_of_date";
|
||||
if (shouldPersistInventoryRootFrame) {
|
||||
const rootFiltersForDebug = {
|
||||
...(rootFilters ?? {}),
|
||||
organization: input.toNonEmptyString(rootFilters?.organization) ?? input.toNonEmptyString(debugFilters?.organization),
|
||||
warehouse: rootFrameWarehouse,
|
||||
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date) ?? input.toNonEmptyString(debugFilters?.as_of_date),
|
||||
period_from: input.toNonEmptyString(rootFilters?.period_from) ?? input.toNonEmptyString(debugFilters?.period_from),
|
||||
period_to: input.toNonEmptyString(rootFilters?.period_to) ?? input.toNonEmptyString(debugFilters?.period_to)
|
||||
};
|
||||
for (const key of Object.keys(rootFiltersForDebug)) {
|
||||
if (!input.toNonEmptyString(rootFiltersForDebug[key])) {
|
||||
delete rootFiltersForDebug[key];
|
||||
}
|
||||
}
|
||||
debug.address_root_frame_context = {
|
||||
root_intent: rootIntent,
|
||||
current_frame_kind: currentFrameKind,
|
||||
organization: input.toNonEmptyString(rootFilters?.organization),
|
||||
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date),
|
||||
period_from: input.toNonEmptyString(rootFilters?.period_from),
|
||||
period_to: input.toNonEmptyString(rootFilters?.period_to)
|
||||
root_intent: rootIntent ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_on_hand_as_of_date" : null),
|
||||
root_filters: rootFiltersForDebug,
|
||||
root_anchor_type: input.toNonEmptyString(followupContextSource?.root_anchor_type) ?? undefined,
|
||||
root_anchor_value: input.toNonEmptyString(followupContextSource?.root_anchor_value) ?? undefined,
|
||||
current_frame_kind: currentFrameKind ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_root" : null),
|
||||
organization: input.toNonEmptyString(rootFiltersForDebug.organization),
|
||||
warehouse: input.toNonEmptyString(rootFiltersForDebug.warehouse),
|
||||
as_of_date: input.toNonEmptyString(rootFiltersForDebug.as_of_date),
|
||||
period_from: input.toNonEmptyString(rootFiltersForDebug.period_from),
|
||||
period_to: input.toNonEmptyString(rootFiltersForDebug.period_to)
|
||||
};
|
||||
}
|
||||
const debugWithRuntimeContracts = (0, assistantRuntimeContractResolver_1.attachAssistantRuntimeContractShadow)(debug, {
|
||||
|
|
|
|||
|
|
@ -948,9 +948,21 @@ function buildInventoryRootFrameFromAddressDebug(debug, toNonEmptyString = fallb
|
|||
return null;
|
||||
}
|
||||
const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters);
|
||||
const rootFrameFiltersCandidate = rootFrameContext
|
||||
? {
|
||||
organization: toNonEmptyString(rootFrameContext.organization),
|
||||
warehouse: toNonEmptyString(rootFrameContext.warehouse),
|
||||
as_of_date: toNonEmptyString(rootFrameContext.as_of_date),
|
||||
period_from: toNonEmptyString(rootFrameContext.period_from),
|
||||
period_to: toNonEmptyString(rootFrameContext.period_to)
|
||||
}
|
||||
: null;
|
||||
const rootFrameFilters = rootFrameFiltersCandidate
|
||||
? Object.fromEntries(Object.entries(rootFrameFiltersCandidate).filter(([, value]) => Boolean(value)))
|
||||
: null;
|
||||
const filters = {
|
||||
...(rootFiltersCandidate ?? {}),
|
||||
...(rootFiltersCandidate ? {} : extractedFilters)
|
||||
...(rootFiltersCandidate ? {} : rootFrameFilters && Object.keys(rootFrameFilters).length > 0 ? rootFrameFilters : extractedFilters)
|
||||
};
|
||||
if (!filters.organization) {
|
||||
const organization = readAddressDebugOrganization(debug, toNonEmptyString);
|
||||
|
|
|
|||
|
|
@ -405,6 +405,30 @@ function hasInventoryMarginRankingAddressReply(input, entryPoint) {
|
|||
selectedRecipe === "address_inventory_margin_ranking_for_nomenclature_v1" ||
|
||||
capabilityId === "inventory_inventory_margin_ranking_for_nomenclature");
|
||||
}
|
||||
function hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint) {
|
||||
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasEffectivelyFactualAddressReply(input)) {
|
||||
return false;
|
||||
}
|
||||
const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase();
|
||||
if (source !== "address_query_runtime_v1" && source !== "address_exact" && source !== "address_lane") {
|
||||
return false;
|
||||
}
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
|
||||
const capabilityId = toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
|
||||
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id);
|
||||
if (detectedIntent !== "inventory_purchase_to_sale_chain" &&
|
||||
selectedRecipe !== "address_inventory_purchase_to_sale_chain_v1" &&
|
||||
capabilityId !== "inventory_inventory_purchase_to_sale_chain") {
|
||||
return false;
|
||||
}
|
||||
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
|
||||
const responseType = toNonEmptyString(input.addressRuntimeMeta?.response_type);
|
||||
return Boolean(hasConfirmedAddressExecution(input) || mcpCallStatus === "matched_non_empty" || responseType === "FACTUAL_LIST");
|
||||
}
|
||||
function hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint) {
|
||||
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
|
||||
return false;
|
||||
|
|
@ -471,6 +495,14 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) {
|
|||
}
|
||||
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
|
||||
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
|
||||
const askedAction = toNonEmptyString(turnMeaning?.asked_action_family);
|
||||
if (detectedIntent === "inventory_supplier_stock_overlap_as_of_date" &&
|
||||
hasConfirmedAddressExecution(input) &&
|
||||
(askedDomain === "entity_resolution" || askedAction === "search_business_entity")) {
|
||||
return true;
|
||||
}
|
||||
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -682,6 +714,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
|
||||
const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint);
|
||||
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
|
||||
const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint);
|
||||
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
|
||||
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
|
||||
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint);
|
||||
|
|
@ -689,6 +722,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply &&
|
||||
!semanticConflictWithDiscoveryTurnMeaning &&
|
||||
!valueFlowActionConflictWithDiscoveryTurnMeaning;
|
||||
const exactInventoryChainProtectsCurrent = exactInventoryPurchaseToSaleChainAddressReply &&
|
||||
candidate.candidate_status === "clarification_candidate" &&
|
||||
valueFlowActionConflictWithDiscoveryTurnMeaning;
|
||||
if (!entryPoint) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point");
|
||||
}
|
||||
|
|
@ -755,6 +791,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
if (inventoryMarginRankingAddressReply) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply");
|
||||
}
|
||||
if (exactInventoryChainProtectsCurrent) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification");
|
||||
}
|
||||
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate");
|
||||
}
|
||||
|
|
@ -787,6 +826,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
!exactBankOperationsProtectsCurrent &&
|
||||
!exactDocumentListAddressReply &&
|
||||
!inventoryMarginRankingAddressReply &&
|
||||
!exactInventoryChainProtectsCurrent &&
|
||||
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
|
||||
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
|
||||
candidate.eligible_for_future_hot_runtime &&
|
||||
|
|
|
|||
|
|
@ -67,6 +67,13 @@ function detectSupportedIntent(text, deps) {
|
|||
reason: "address_intent_resolver_current_turn_signal"
|
||||
};
|
||||
}
|
||||
if (hasInventorySupplierStockOverlapExactSignal(text)) {
|
||||
return {
|
||||
intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||
confidence: "high",
|
||||
reason: "inventory_supplier_stock_overlap_current_turn_signal"
|
||||
};
|
||||
}
|
||||
if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) {
|
||||
return {
|
||||
intent: "receivables_confirmed_as_of_date",
|
||||
|
|
@ -214,6 +221,17 @@ function hasSelectedObjectInventoryExactSignal(text) {
|
|||
}
|
||||
return /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u0440\u043e\u0434\u0430\u043b|\u0437\u0430\u043a\u0443\u043f|\u043f\u043e\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0446\u0435\u043f\u043e\u0447|profit|margin|sale|purchase|document)/iu.test(normalized);
|
||||
}
|
||||
function hasInventorySupplierStockOverlapExactSignal(text) {
|
||||
const normalized = String(text ?? "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const hasStockScope = /(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442|\u043b\u0435\u0436\p{L}*|\u043d\u0430\u0445\u043e\u0434\p{L}*|\u0447\u0438\u0441\u043b\p{L}*|warehouse|stock|inventory|on\s+hand)/iu.test(normalized);
|
||||
const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0437\u0430\u043a\u0443\u043f|\u043a\u0443\u043f\p{L}*|\u043f\u0440\u0438\u043e\u0431\u0440\p{L}*|supplier|vendor|purchase|bought|supplied|procurement)/iu.test(normalized);
|
||||
const hasOverlapAction = /(?:\u0443\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\s+\u043a\u0430\u043a\u043e\u043c\u0443\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*[\s\S]{0,80}\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0439\u0442\p{L}*[\s\S]{0,80}\u0442\u043e\u0432\u0430\u0440|\u043a\u0430\u043a\u0438\u0435\s+\u0442\u043e\u0432\u0430\u0440|supplier[\s\S]{0,80}(?:stock|inventory)|(?:stock|inventory)[\s\S]{0,80}supplier)/iu.test(normalized);
|
||||
const hasSupplierIdentityAsk = /(?:\u043a\u0430\u043a\u043e(?:\u0433\u043e|\u043c\u0443)\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|which\s+supplier|what\s+supplier)/iu.test(normalized);
|
||||
return hasStockScope && hasSupplierCue && (hasOverlapAction || hasSupplierIdentityAsk);
|
||||
}
|
||||
function hasOrganizationLevelEarningsOverviewSignal(text) {
|
||||
const normalized = String(text ?? "");
|
||||
if (!normalized || hasExplicitCounterpartyValueObject(normalized)) {
|
||||
|
|
@ -381,12 +399,15 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText);
|
||||
const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText);
|
||||
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
|
||||
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
|
||||
const inventorySupplierStockOverlapExact = supportedIntent?.intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
llmIntent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
hasInventorySupplierStockOverlapExactSignal(joinedText);
|
||||
const broadBusinessEvaluation = compactOrganizationCashflowDisplay
|
||||
? { family: "broad_business_evaluation" }
|
||||
: selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family
|
||||
: selectedObjectInventoryExact || inventorySupplierStockOverlapExact || counterpartyBidirectionalValueFlow?.family
|
||||
? null
|
||||
: detectBroadBusinessEvaluation(joinedText);
|
||||
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
|
||||
const explicitIntentCandidate = broadBusinessEvaluation?.family
|
||||
? null
|
||||
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
|
||||
|
|
@ -454,11 +475,13 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
? "confirmed_snapshot"
|
||||
: explicitIntentCandidate === "vat_payable_forecast"
|
||||
? "forecast"
|
||||
: explicitIntentCandidate === "list_documents_by_counterparty"
|
||||
? "list_documents"
|
||||
: counterpartyTurnover?.family
|
||||
? "counterparty_value_or_turnover"
|
||||
: null;
|
||||
: explicitIntentCandidate === "inventory_supplier_stock_overlap_as_of_date"
|
||||
? "supplier_overlap"
|
||||
: explicitIntentCandidate === "list_documents_by_counterparty"
|
||||
? "list_documents"
|
||||
: counterpartyTurnover?.family
|
||||
? "counterparty_value_or_turnover"
|
||||
: null;
|
||||
const staleReplayForbidden = Boolean(unsupportedFamily ||
|
||||
broadBusinessEvaluation?.family ||
|
||||
(counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) ||
|
||||
|
|
|
|||
|
|
@ -726,17 +726,21 @@ function extractKnownFinancialCounterpartyAnchor(text: string): string | undefin
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
||||
export function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
||||
const value = String(rawValue ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(?:товар(?:ы|а|ов|у|ом|ами|ах)?|номенклатур(?:а|ы|у|ой|ами)?|позици(?:я|и|ю|ей)|остат(?:ок|ки|ка|ков|кам|ками)?|склад|складе|складу)$/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test(
|
||||
value
|
||||
|
|
|
|||
|
|
@ -2465,6 +2465,16 @@ function asksForUnresolvedInventorySupplierLink(userMessage: string | null | und
|
|||
);
|
||||
}
|
||||
|
||||
function messagePairAsksForUnresolvedInventorySupplierLink(
|
||||
userMessage: string | null | undefined,
|
||||
rawUserMessage: string | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
asksForUnresolvedInventorySupplierLink(userMessage) ||
|
||||
asksForUnresolvedInventorySupplierLink(rawUserMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
|
||||
if (Array.isArray((filters as { warnings?: unknown }).warnings) && (filters as { warnings?: string[] }).warnings?.includes("exact_historical_period_window_requested")) {
|
||||
return false;
|
||||
|
|
@ -2530,7 +2540,12 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(
|
|||
return false;
|
||||
}
|
||||
|
||||
const historyIntent =
|
||||
intent === "inventory_sale_trace_for_item" ||
|
||||
intent === "inventory_profitability_for_item" ||
|
||||
intent === "inventory_purchase_to_sale_chain";
|
||||
return (
|
||||
(historyIntent && reasons.includes("as_of_date_from_analysis_context")) ||
|
||||
reasons.includes("period_window_semantic_from_inventory_snapshot_context") ||
|
||||
reasons.includes("period_window_semantic_from_inventory_as_of_month") ||
|
||||
reasons.includes("as_of_date_from_followup_context") ||
|
||||
|
|
@ -2629,6 +2644,90 @@ function injectNoticeAfterLeadLine(text: string, notice: string): string {
|
|||
return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n");
|
||||
}
|
||||
|
||||
function injectNoticeBeforeLeadLine(text: string, notice: string): string {
|
||||
const normalizedText = typeof text === "string" ? text : "";
|
||||
const normalizedNotice = typeof notice === "string" ? notice.trim() : "";
|
||||
if (!normalizedText.trim()) {
|
||||
return normalizedNotice;
|
||||
}
|
||||
if (!normalizedNotice) {
|
||||
return normalizedText;
|
||||
}
|
||||
return [normalizedNotice, normalizedText].join("\n");
|
||||
}
|
||||
|
||||
function injectAutoBroadenedPeriodNotice(intent: string, text: string, notice: string): string {
|
||||
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return injectNoticeBeforeLeadLine(text, notice);
|
||||
}
|
||||
return injectNoticeAfterLeadLine(text, notice);
|
||||
}
|
||||
|
||||
function extractSupplierOverlapReplyCounterparties(text: string): string[] {
|
||||
const counterparties: string[] = [];
|
||||
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\n]+)/giu;
|
||||
for (const match of String(text ?? "").matchAll(pattern)) {
|
||||
const value = String(match[1] ?? "").replace(/\s+/gu, " ").trim();
|
||||
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
|
||||
continue;
|
||||
}
|
||||
counterparties.push(value);
|
||||
}
|
||||
return uniqueStrings(counterparties);
|
||||
}
|
||||
|
||||
function formatSupplierOverlapCounterpartyLead(counterparties: string[], limit = 6): string {
|
||||
const visible = counterparties.slice(0, limit);
|
||||
const remaining = counterparties.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
|
||||
function hasInventorySupplierOverlapDefensiveLead(firstLine: string): boolean {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(
|
||||
normalized
|
||||
);
|
||||
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(
|
||||
normalized
|
||||
);
|
||||
return startsWithStockScope && hasDefensiveAttribution;
|
||||
}
|
||||
|
||||
export function ensureInventorySupplierOverlapLeadLine(intent: string, text: string): string {
|
||||
if (intent !== "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return text;
|
||||
}
|
||||
const normalizedText = typeof text === "string" ? text : "";
|
||||
const lines = normalizedText.split(/\r?\n/u);
|
||||
const firstLine = lines[0]?.trim() ?? "";
|
||||
const displayedCounterparties = extractSupplierOverlapReplyCounterparties(normalizedText);
|
||||
if (
|
||||
displayedCounterparties.length > 0 &&
|
||||
hasInventorySupplierOverlapDefensiveLead(firstLine)
|
||||
) {
|
||||
const repairedLead =
|
||||
displayedCounterparties.length === 1
|
||||
? `По складскому остатку найден поставщик закупочного следа: ${displayedCounterparties[0]}.`
|
||||
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatSupplierOverlapCounterpartyLead(displayedCounterparties)}.`;
|
||||
return [repairedLead, firstLine, ...lines.slice(1)].join("\n");
|
||||
}
|
||||
if (
|
||||
!normalizedText.trim() ||
|
||||
/^По\s+(?:складскому\s+остатку|окну)\b/iu.test(firstLine) ||
|
||||
/^В\s+текущем\s+складском\s+срезе\b/iu.test(firstLine)
|
||||
) {
|
||||
return normalizedText;
|
||||
}
|
||||
if (!/^(?:Что\s+проверили:|Часть\s+закупочных\s+операций|Опорные\s+документы:)/iu.test(firstLine)) {
|
||||
return normalizedText;
|
||||
}
|
||||
return [
|
||||
"По складскому остатку однозначный поставщик текущего остатка не подтвержден; ниже показан проверенный закупочный след и ограничения.",
|
||||
normalizedText
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
|
||||
if (category === "empty_match" || category === "missing_anchor") {
|
||||
return "LIVE_QUERYABLE_WITH_LIMITS";
|
||||
|
|
@ -3684,7 +3783,7 @@ export class AddressQueryService {
|
|||
intent.intent === "inventory_supplier_stock_overlap_as_of_date" &&
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.period_from) &&
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.period_to) &&
|
||||
asksForUnresolvedInventorySupplierLink(userMessage) &&
|
||||
messagePairAsksForUnresolvedInventorySupplierLink(userMessage, options.rawUserMessage) &&
|
||||
!/(?:за\s+вс[её]\s+время|за\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)
|
||||
) {
|
||||
const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date);
|
||||
|
|
@ -3808,6 +3907,21 @@ export class AddressQueryService {
|
|||
receivablesConfirmedExecution?.executionFilters ??
|
||||
vatPayableConfirmedExecution?.executionFilters ??
|
||||
filters.extracted_filters;
|
||||
if (
|
||||
intent.intent === "inventory_aging_by_purchase_date" &&
|
||||
toNonEmptyFilterValue(executionFilters.as_of_date) &&
|
||||
(toNonEmptyFilterValue(executionFilters.period_from) || toNonEmptyFilterValue(executionFilters.period_to))
|
||||
) {
|
||||
executionFilters = { ...executionFilters };
|
||||
delete executionFilters.period_from;
|
||||
delete executionFilters.period_to;
|
||||
if (!filters.warnings.includes("period_window_detached_for_inventory_aging_execution")) {
|
||||
filters.warnings.push("period_window_detached_for_inventory_aging_execution");
|
||||
}
|
||||
if (!baseReasons.includes("period_window_detached_for_inventory_aging_execution")) {
|
||||
baseReasons.push("period_window_detached_for_inventory_aging_execution");
|
||||
}
|
||||
}
|
||||
if (
|
||||
intent.intent === "counterparty_activity_lifecycle" &&
|
||||
typeof executionFilters.counterparty === "string" &&
|
||||
|
|
@ -3992,6 +4106,7 @@ export class AddressQueryService {
|
|||
: typeof filterSet.account === "string"
|
||||
? filterSet.account
|
||||
: undefined,
|
||||
warehouseHint: typeof filterSet.warehouse === "string" ? filterSet.warehouse : undefined,
|
||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
|
|
@ -4703,7 +4818,11 @@ export class AddressQueryService {
|
|||
);
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters),
|
||||
reply_text: repairCounterpartyReplyLabel(
|
||||
ensureInventorySupplierOverlapLeadLine(intent.intent, replyText),
|
||||
intent.intent,
|
||||
filters.extracted_filters
|
||||
),
|
||||
reply_type: inferReplyType(responseType),
|
||||
response_type: responseType,
|
||||
debug: debugPayload
|
||||
|
|
@ -4871,7 +4990,7 @@ export class AddressQueryService {
|
|||
return {
|
||||
handled: true,
|
||||
reply_text: repairCounterpartyReplyLabel(
|
||||
input.replyText,
|
||||
ensureInventorySupplierOverlapLeadLine(intent.intent, input.replyText),
|
||||
intent.intent,
|
||||
(input.extractedFilters ?? filters.extracted_filters) as AddressFilterSet
|
||||
),
|
||||
|
|
@ -5207,7 +5326,7 @@ export class AddressQueryService {
|
|||
resultMode: broadenedCoverageEvidence.result_mode ?? undefined
|
||||
});
|
||||
return buildFactualExecutionResult({
|
||||
replyText: injectNoticeAfterLeadLine(broadenedFactual.text, broadenedPrefix),
|
||||
replyText: injectAutoBroadenedPeriodNotice(intent.intent, broadenedFactual.text, broadenedPrefix),
|
||||
responseType: broadenedFactual.responseType,
|
||||
responseSemantics: broadenedFactual.semantics,
|
||||
selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
|
||||
|
|
|
|||
|
|
@ -1552,20 +1552,11 @@ function buildInventoryAgingByPurchaseDateQuery(filters: AddressFilterSet, resol
|
|||
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
|
||||
];
|
||||
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
|
||||
const currentStockItemSubquery = [
|
||||
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
|
||||
" Остатки.Субконто1",
|
||||
" ИЗ",
|
||||
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
|
||||
buildStaticWhereClause(onHandScopeConditions, " "),
|
||||
")"
|
||||
].join("\n");
|
||||
const purchaseWhereClause = buildStaticWhereClause([
|
||||
"Товары.Ссылка.Проведен = ИСТИНА",
|
||||
`Товары.Ссылка.Дата <= ${asOfExpr}`,
|
||||
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]),
|
||||
`Товары.Номенклатура В ${currentStockItemSubquery}`
|
||||
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"])
|
||||
]);
|
||||
|
||||
return `
|
||||
|
|
|
|||
|
|
@ -693,6 +693,9 @@ function shouldRestoreInventoryRootFrame(
|
|||
if (!canReenterInventoryRoot) {
|
||||
return false;
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
return false;
|
||||
}
|
||||
if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -723,20 +726,20 @@ function shouldRestoreInventoryRootFrame(
|
|||
function hasSelectedObjectInventorySignal(text: string): boolean {
|
||||
const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? ""));
|
||||
if (
|
||||
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
|
||||
/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
|
||||
repairedSelectedObjectText
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
|
||||
/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
|
||||
String(text ?? "")
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
|
||||
return /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
|
||||
String(text ?? "")
|
||||
);
|
||||
}
|
||||
|
|
@ -775,6 +778,7 @@ export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean
|
|||
/(?:по\s+каким\s+документам\s+(?:это|его|ее|её|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|ее|её|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|подтверждена\s+закупка|доказан[оа]?|закрыт[оа]?)|закупк[аи]\s+подтвержден[аы]?\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test(
|
||||
value
|
||||
) ||
|
||||
/(?:документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици)|какие\s+документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици))/iu.test(value) ||
|
||||
/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
|
||||
value
|
||||
) ||
|
||||
|
|
@ -1062,6 +1066,22 @@ function mergeFollowupFilters(
|
|||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
};
|
||||
const clearInventoryOnHandWarehouseAliasItem = (): void => {
|
||||
if (intent !== "inventory_on_hand_as_of_date") {
|
||||
return;
|
||||
}
|
||||
const currentItem = toNonEmptyString(merged.item);
|
||||
const currentWarehouse = toNonEmptyString(merged.warehouse);
|
||||
if (!currentItem || !currentWarehouse) {
|
||||
return;
|
||||
}
|
||||
const normalizedItem = normalizeOrganizationScopeSearchText(currentItem);
|
||||
const normalizedWarehouse = normalizeOrganizationScopeSearchText(currentWarehouse);
|
||||
if (normalizedItem && normalizedWarehouse && normalizedItem === normalizedWarehouse) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot");
|
||||
}
|
||||
};
|
||||
if (!followupContext) {
|
||||
if (
|
||||
(intent === "list_open_contracts" ||
|
||||
|
|
@ -1081,18 +1101,21 @@ function mergeFollowupFilters(
|
|||
);
|
||||
}
|
||||
}
|
||||
clearInventoryOnHandWarehouseAliasItem();
|
||||
clearInventoryAgingOrganizationAliasItem(null);
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
const previous = followupContext.previous_filters ?? {};
|
||||
const root = followupContext.root_filters ?? {};
|
||||
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
|
||||
const previousCounterparty = toNonEmptyString(previous.counterparty);
|
||||
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 previousOrganization = toNonEmptyString(previous.organization) ?? toNonEmptyString(root.organization);
|
||||
const previousWarehouse = toNonEmptyString(previous.warehouse) ?? toNonEmptyString(root.warehouse);
|
||||
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
|
||||
const previousPeriodFrom = toNonEmptyString(previous.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
|
|
@ -1539,6 +1562,10 @@ function mergeFollowupFilters(
|
|||
reasons.push("period_derived_from_inventory_root_frame_year");
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
if (previousWarehouse && !toNonEmptyString(merged.warehouse)) {
|
||||
merged.warehouse = previousWarehouse;
|
||||
reasons.push("warehouse_from_followup_context");
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(previousOrganization);
|
||||
}
|
||||
if (
|
||||
|
|
@ -1565,6 +1592,7 @@ function mergeFollowupFilters(
|
|||
delete merged.period_to;
|
||||
reasons.push("period_cleared_by_all_time_followup");
|
||||
}
|
||||
clearInventoryOnHandWarehouseAliasItem();
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
|
|
@ -1755,6 +1783,7 @@ function mergeFollowupFilters(
|
|||
}
|
||||
}
|
||||
|
||||
clearInventoryOnHandWarehouseAliasItem();
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
|
|
@ -1788,18 +1817,56 @@ function deriveIntentWithFollowupContext(
|
|||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): AddressIntentResolution {
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
|
||||
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
|
||||
if (hasSelectedObjectReference && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage)) {
|
||||
if (
|
||||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date"
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_documents_for_item",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_documents"]
|
||||
};
|
||||
}
|
||||
}
|
||||
if (hasSelectedObjectReference && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (
|
||||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date"
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_supplier"]
|
||||
};
|
||||
}
|
||||
}
|
||||
if (hasSelectedObjectReference && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" || detectedIntent.intent === "inventory_on_hand_as_of_date") {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_purchase_date"]
|
||||
};
|
||||
}
|
||||
}
|
||||
return detectedIntent;
|
||||
}
|
||||
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const purchaseBridgeContinuationSignal =
|
||||
followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
|
||||
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
|
||||
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal;
|
||||
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal || hasSelectedObjectReference;
|
||||
if (!hasFollowupSignal) {
|
||||
return detectedIntent;
|
||||
}
|
||||
|
|
@ -1834,7 +1901,6 @@ function deriveIntentWithFollowupContext(
|
|||
followupContext.root_anchor_type === "item" ||
|
||||
followupContext.current_frame_kind === "inventory_root" ||
|
||||
followupContext.current_frame_kind === "inventory_drilldown";
|
||||
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
|
||||
const inventorySelectedObjectFollowup =
|
||||
inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal));
|
||||
const previousCounterpartyLaneActive =
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ import type { ComposeStageRow } from "./composeStage";
|
|||
|
||||
interface InventoryComposeOptions {
|
||||
userMessage?: string;
|
||||
rawUserMessage?: string;
|
||||
itemHint?: string;
|
||||
counterpartyHint?: string;
|
||||
accountHint?: string;
|
||||
warehouseHint?: string;
|
||||
asOfDate?: string;
|
||||
periodFrom?: string;
|
||||
periodTo?: string;
|
||||
|
|
@ -134,6 +138,60 @@ function inventoryPartyListOrUnknown(parties: string[]): string {
|
|||
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
|
||||
}
|
||||
|
||||
function inventoryPartyLeadList(parties: string[], limit = 6): string {
|
||||
const visible = parties.slice(0, limit);
|
||||
const remaining = parties.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
|
||||
function inventoryItemLeadList(items: string[], limit = 6): string {
|
||||
const visible = items.slice(0, limit);
|
||||
const remaining = items.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
|
||||
function collectInventoryTraceItems(rows: ComposeStageRow[]): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const item = String(row.item ?? "").trim();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const comparable = normalizeInventoryReplyEntityToken(item);
|
||||
if (!comparable || seen.has(comparable)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(comparable);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractInventoryCounterpartiesFromEvidenceLines(lines: string[]): string[] {
|
||||
const counterparties: string[] = [];
|
||||
for (const line of lines) {
|
||||
const match = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|]+)/iu.exec(line);
|
||||
const value = String(match?.[1] ?? "").replace(/\s+/gu, " ").trim();
|
||||
if (value) {
|
||||
counterparties.push(value);
|
||||
}
|
||||
}
|
||||
return counterparties;
|
||||
}
|
||||
|
||||
function hasSupplierScopedItemListQuestion(options: InventoryComposeOptions): boolean {
|
||||
if (!String(options.counterpartyHint ?? "").trim()) {
|
||||
return false;
|
||||
}
|
||||
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
|
||||
return /(?:какие?\s+товар|товар(?:ы|ов)?\s+(?:от|у)\s+поставщик|позици(?:и|я|ю)|номенклатур|лежат|остат(?:ок|ки|ка|ков))/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function omitAmountFromInventoryTraceEvidence(lines: string[]): string[] {
|
||||
return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, ""));
|
||||
}
|
||||
|
|
@ -216,6 +274,43 @@ function sumInventoryRowQuantity(rows: ComposeStageRow[]): number {
|
|||
return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0);
|
||||
}
|
||||
|
||||
function cleanInventoryScopeHint(value: string | null | undefined): string | null {
|
||||
const cleaned = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||
return cleaned.length > 0 ? cleaned : null;
|
||||
}
|
||||
|
||||
function inventoryOnHandAccountScopeLabel(options: InventoryComposeOptions): string | null {
|
||||
const accountHint = cleanInventoryScopeHint(options.accountHint);
|
||||
if (accountHint) {
|
||||
return `по счету ${accountHint}`;
|
||||
}
|
||||
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
|
||||
return /(?:^|[^\d])41(?:[.,]\d+)?(?:[^\d]|$)|41\s*сч[её]т/iu.test(text) ? "по счету 41" : null;
|
||||
}
|
||||
|
||||
function inventoryOnHandWarehouseScopeLabel(
|
||||
options: InventoryComposeOptions,
|
||||
uniqueWarehouses: string[]
|
||||
): string | null {
|
||||
const warehouseHint = cleanInventoryScopeHint(options.warehouseHint);
|
||||
if (warehouseHint) {
|
||||
return `по складу ${warehouseHint}`;
|
||||
}
|
||||
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
|
||||
if (uniqueWarehouses.length === 1 && /(?:по|на)\s+склад[уе]?/iu.test(text)) {
|
||||
return `по складу ${uniqueWarehouses[0]}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inventoryOnHandScopePhrase(options: InventoryComposeOptions, uniqueWarehouses: string[]): string {
|
||||
const scopes = [
|
||||
inventoryOnHandAccountScopeLabel(options),
|
||||
inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses)
|
||||
].filter((item): item is string => Boolean(item));
|
||||
return scopes.length > 0 ? scopes.join(" и ") : "на складе";
|
||||
}
|
||||
|
||||
function formatInventoryPercent(value: number | null, formatNumberWithDots: (value: number, fractionDigits?: number) => string): string {
|
||||
return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`;
|
||||
}
|
||||
|
|
@ -354,10 +449,11 @@ export function composeInventoryReply(
|
|||
positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)
|
||||
);
|
||||
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
|
||||
const scopePhrase = inventoryOnHandScopePhrase(options, uniqueWarehouses);
|
||||
const directAnswerLine =
|
||||
positions.length > 0
|
||||
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
|
||||
? `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
: `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтвержденных товарных остатков не найдено.`;
|
||||
const lines: string[] = [directAnswerLine];
|
||||
|
||||
if (positions.length > 0) {
|
||||
|
|
@ -392,13 +488,22 @@ export function composeInventoryReply(
|
|||
]);
|
||||
}
|
||||
|
||||
appendInventoryBulletSection(lines, "Сводка:", [
|
||||
const summaryBullets = [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
|
||||
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
|
||||
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
|
||||
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
|
||||
]);
|
||||
];
|
||||
const accountScope = inventoryOnHandAccountScopeLabel(options);
|
||||
const warehouseScope = inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses);
|
||||
if (accountScope) {
|
||||
summaryBullets.splice(1, 0, `Срез ${accountScope}.`);
|
||||
}
|
||||
if (warehouseScope) {
|
||||
summaryBullets.splice(accountScope ? 2 : 1, 0, `Срез ${warehouseScope}.`);
|
||||
}
|
||||
appendInventoryBulletSection(lines, "Сводка:", summaryBullets);
|
||||
if (rows.length !== positions.length) {
|
||||
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
|
||||
}
|
||||
|
|
@ -561,22 +666,50 @@ export function composeInventoryReply(
|
|||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
|
||||
const purchaseRowCounterparties = purchaseRows.map((row) =>
|
||||
deps.uniqueStrings([
|
||||
...deps.extractInventoryCounterpartyCandidates(row),
|
||||
...extractInventoryCounterpartiesFromEvidenceLines(deps.formatInventoryTraceRows([row], 1))
|
||||
])
|
||||
);
|
||||
const observedCounterparties = deps.uniqueStrings([
|
||||
...summary.counterparties,
|
||||
...purchaseRowCounterparties.flat()
|
||||
]);
|
||||
const unresolvedRows = purchaseRows.filter((_, index) => (purchaseRowCounterparties[index]?.length ?? 0) === 0);
|
||||
const unresolvedSupplierQuestionText = `${String(options.userMessage ?? "")}\n${String(options.rawUserMessage ?? "")}`;
|
||||
const unresolvedSupplierQuestion =
|
||||
/(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(
|
||||
String(options.userMessage ?? "")
|
||||
unresolvedSupplierQuestionText
|
||||
);
|
||||
if (unresolvedSupplierQuestion) {
|
||||
const unresolvedItems = collectInventoryTraceItems(unresolvedRows);
|
||||
const unresolvedItemPreviewLimit = 3;
|
||||
const unresolvedDocumentPreviewLimit = 2;
|
||||
const directAnswerLine =
|
||||
unresolvedRows.length > 0
|
||||
? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`
|
||||
? unresolvedItems.length > 0
|
||||
? `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено ${deps.formatNumberWithDots(unresolvedItems.length)} позиций без надежной привязки к поставщику; первые примеры ниже.`
|
||||
: `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`
|
||||
: "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены.";
|
||||
const lines: string[] = [directAnswerLine];
|
||||
if (unresolvedItems.length > 0) {
|
||||
appendInventorySection(
|
||||
lines,
|
||||
"Позиции без надежной привязки к поставщику:",
|
||||
unresolvedItems.slice(0, unresolvedItemPreviewLimit).map((item, index) => `${index + 1}. ${item}`)
|
||||
);
|
||||
if (unresolvedItems.length > unresolvedItemPreviewLimit) {
|
||||
lines.push(
|
||||
`- Показаны первые ${unresolvedItemPreviewLimit} из ${deps.formatNumberWithDots(unresolvedItems.length)} таких позиций.`
|
||||
);
|
||||
}
|
||||
}
|
||||
appendInventoryBulletSection(lines, "Что проверили:", [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`,
|
||||
`Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`,
|
||||
`Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.`
|
||||
`Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(observedCounterparties.length)}.`
|
||||
]);
|
||||
appendInventoryBulletSection(lines, "Ограничения:", [
|
||||
"Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии."
|
||||
|
|
@ -584,11 +717,16 @@ export function composeInventoryReply(
|
|||
if (unresolvedRows.length > 0) {
|
||||
appendInventorySection(
|
||||
lines,
|
||||
"Позиции без явно выделенного поставщика:",
|
||||
deps.formatInventoryTraceRows(unresolvedRows, 12)
|
||||
"Опорные документы:",
|
||||
deps.formatInventoryTraceRows(unresolvedRows, unresolvedDocumentPreviewLimit)
|
||||
);
|
||||
} else if (summary.counterparties.length > 0) {
|
||||
lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`);
|
||||
if (unresolvedRows.length > unresolvedDocumentPreviewLimit) {
|
||||
lines.push(
|
||||
`- Показаны первые ${unresolvedDocumentPreviewLimit} из ${deps.formatNumberWithDots(unresolvedRows.length)} строк без выделенного поставщика.`
|
||||
);
|
||||
}
|
||||
} else if (observedCounterparties.length > 0) {
|
||||
lines.push(`- В доступном закупочном следе встречаются поставщики: ${observedCounterparties.slice(0, 6).join("; ")}.`);
|
||||
}
|
||||
return buildFactualSummaryReply(
|
||||
lines,
|
||||
|
|
@ -596,35 +734,100 @@ export function composeInventoryReply(
|
|||
);
|
||||
}
|
||||
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
|
||||
const purchasePeriodLabel =
|
||||
summary.firstPeriod && summary.lastPeriod
|
||||
? `${deps.inventoryTraceDateLabel(summary.firstPeriod)}..${deps.inventoryTraceDateLabel(summary.lastPeriod)}`
|
||||
: "даты закупки не выделены";
|
||||
const supplierScopedItems = collectInventoryTraceItems(purchaseRows);
|
||||
const supplierScopedItemQuestion = hasSupplierScopedItemListQuestion(options);
|
||||
if (supplierScopedItemQuestion) {
|
||||
const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim();
|
||||
const directAnswerLine =
|
||||
supplierScopedItems.length > 0
|
||||
? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.`
|
||||
: `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`;
|
||||
const lines: string[] = [directAnswerLine];
|
||||
if (supplierScopedItems.length > 0) {
|
||||
appendInventorySection(
|
||||
lines,
|
||||
"Позиции:",
|
||||
supplierScopedItems.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)
|
||||
);
|
||||
if (supplierScopedItems.length > 8) {
|
||||
lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`);
|
||||
}
|
||||
}
|
||||
appendInventoryBulletSection(lines, "Что проверили:", [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Период найденного закупочного следа: ${purchasePeriodLabel}.`,
|
||||
`Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
]);
|
||||
appendInventoryBulletSection(lines, "Ограничения:", [
|
||||
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
|
||||
]);
|
||||
if (observedCounterparties.length > 0) {
|
||||
lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
|
||||
}
|
||||
if (purchaseRows.length > 0) {
|
||||
appendInventorySection(
|
||||
lines,
|
||||
"Опорные документы:",
|
||||
deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)
|
||||
);
|
||||
if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
|
||||
lines.push(
|
||||
`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return buildFactualSummaryReply(
|
||||
lines,
|
||||
buildConfirmedBalanceSemantics(
|
||||
purchaseRows.length > 0 ? (supplierScopedItems.length > 0 ? "strong" : "medium") : "medium",
|
||||
purchaseRows.length > 0
|
||||
)
|
||||
);
|
||||
}
|
||||
const directAnswerLine =
|
||||
summary.counterparties.length === 1
|
||||
? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.`
|
||||
: summary.counterparties.length > 1
|
||||
? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.`
|
||||
: `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`;
|
||||
observedCounterparties.length === 1
|
||||
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} выявлен поставщик закупочного следа: ${observedCounterparties[0]}.`
|
||||
: observedCounterparties.length > 1
|
||||
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} найдено несколько поставщиков закупочного следа: ${inventoryPartyLeadList(observedCounterparties)}.`
|
||||
: unresolvedRows.length > 0
|
||||
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначный поставщик текущего остатка не подтвержден: в ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операциях поставщик не выделен.`
|
||||
: `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} поставщик в доступных данных не выделен.`;
|
||||
const lines: string[] = [directAnswerLine];
|
||||
if (unresolvedRows.length > 0) {
|
||||
lines.push(
|
||||
`По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначная атрибуция части текущего остатка не подтверждена: ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операций без явно выделенного поставщика.`
|
||||
);
|
||||
}
|
||||
appendInventoryBulletSection(lines, "Что проверили:", [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||
`Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||
`Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`,
|
||||
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
`Период найденного закупочного следа: ${purchasePeriodLabel}.`,
|
||||
`Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
]);
|
||||
appendInventoryBulletSection(lines, "Ограничения:", [
|
||||
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
|
||||
]);
|
||||
if (summary.counterparties.length > 0) {
|
||||
lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`);
|
||||
if (observedCounterparties.length > 0) {
|
||||
lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
|
||||
} else if (purchaseRows.length > 0) {
|
||||
lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных.");
|
||||
} else {
|
||||
lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу.");
|
||||
}
|
||||
if (unresolvedRows.length > 0) {
|
||||
lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`);
|
||||
}
|
||||
if (purchaseRows.length > 0) {
|
||||
appendInventorySection(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10));
|
||||
appendInventorySection(
|
||||
lines,
|
||||
"Опорные документы:",
|
||||
deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)
|
||||
);
|
||||
if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
|
||||
lines.push(
|
||||
`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return buildFactualSummaryReply(
|
||||
lines,
|
||||
|
|
@ -1025,12 +1228,16 @@ export function composeInventoryReply(
|
|||
const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties);
|
||||
const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties);
|
||||
const mismatchParts: string[] = [];
|
||||
if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) {
|
||||
if (requestedSupplier && purchaseRows.length === 0) {
|
||||
mismatchParts.push(`закупка у запрошенного поставщика ${requestedSupplier} не найдена`);
|
||||
} else if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) {
|
||||
mismatchParts.push(
|
||||
`запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}`
|
||||
);
|
||||
}
|
||||
if (requestedBuyer && saleRows.length > 0 && !buyerMatches) {
|
||||
if (requestedBuyer && saleRows.length === 0) {
|
||||
mismatchParts.push(`продажа/выбытие запрошенному покупателю ${requestedBuyer} не найдены`);
|
||||
} else if (requestedBuyer && saleRows.length > 0 && !buyerMatches) {
|
||||
mismatchParts.push(
|
||||
`запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}`
|
||||
);
|
||||
|
|
@ -1038,9 +1245,15 @@ export function composeInventoryReply(
|
|||
const directAnswerLine =
|
||||
mismatchParts.length > 0
|
||||
? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.`
|
||||
: purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
|
||||
: purchaseRows.length > 0 && saleRows.length > 0 && purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
|
||||
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
|
||||
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
|
||||
: purchaseRows.length > 0 && saleRows.length > 0
|
||||
? `По товару ${itemLabel} найдены обе стороны цепочки поставки и продажи; участников несколько или они неоднозначны, детали ниже.`
|
||||
: purchaseRows.length > 0
|
||||
? `По товару ${itemLabel} закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.`
|
||||
: saleRows.length > 0
|
||||
? `По товару ${itemLabel} найдена продажа/выбытие со счета 41.01, но закупочная часть цепочки в доступных данных не подтверждена.`
|
||||
: `По товару ${itemLabel} цепочка поставки и продажи не подтверждена: движений по счету 41.01 в доступных данных не найдено.`;
|
||||
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
|
||||
lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`);
|
||||
lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface ComposeFactualReplyOptions<TVatDirectSourceProbe = unknown> {
|
|||
counterpartyHint?: string;
|
||||
organizationHint?: string;
|
||||
accountHint?: string;
|
||||
warehouseHint?: string;
|
||||
periodFrom?: string;
|
||||
periodTo?: string;
|
||||
asOfDate?: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import type {
|
|||
AddressLlmSemanticHints,
|
||||
AddressSemanticFrame
|
||||
} from "../../types/addressQuery";
|
||||
import { isInventoryItemAnchorDegradation, isLowQualityInventoryItemAnchorValue } from "../addressFilterExtractor";
|
||||
import {
|
||||
isInventoryItemAnchorDegradation,
|
||||
isLowQualityCounterpartyAnchorValue,
|
||||
isLowQualityInventoryItemAnchorValue
|
||||
} from "../addressFilterExtractor";
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
|
|
@ -162,11 +166,16 @@ export function applyAddressLlmSemanticHintsToExtraction(
|
|||
}
|
||||
|
||||
if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) {
|
||||
extractedFilters.counterparty = scopeTargetText;
|
||||
pushWarning(warnings, "counterparty_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "counterparty";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
if (isLowQualityCounterpartyAnchorValue(scopeTargetText)) {
|
||||
delete extractedFilters.counterparty;
|
||||
pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
|
||||
} else {
|
||||
extractedFilters.counterparty = scopeTargetText;
|
||||
pushWarning(warnings, "counterparty_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "counterparty";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
}
|
||||
|
||||
if (semanticHints.scope_target_kind === "contract" && scopeTargetText) {
|
||||
|
|
@ -189,6 +198,13 @@ export function applyAddressLlmSemanticHintsToExtraction(
|
|||
pushWarning(warnings, "item_llm_semantics_ignored");
|
||||
}
|
||||
}
|
||||
if (
|
||||
toNonEmptyString(extractedFilters.counterparty) &&
|
||||
isLowQualityCounterpartyAnchorValue(String(extractedFilters.counterparty))
|
||||
) {
|
||||
delete extractedFilters.counterparty;
|
||||
pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
|
||||
}
|
||||
|
||||
return {
|
||||
...extraction,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,222 @@ function normalizeAddressReplyType(value: unknown): AssistantReplyType {
|
|||
return value === "factual" || value === "partial_coverage" ? value : "partial_coverage";
|
||||
}
|
||||
|
||||
function uniqueNormalizedStrings(values: Array<string | null | undefined>): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const key = text.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(text);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractInventorySupplierOverlapCounterpartiesFromReply(replyText: string): string[] {
|
||||
const values: string[] = [];
|
||||
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\r\n]+)/giu;
|
||||
for (const match of replyText.matchAll(pattern)) {
|
||||
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
|
||||
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
|
||||
continue;
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
return uniqueNormalizedStrings(values);
|
||||
}
|
||||
|
||||
function formatInventorySupplierOverlapCounterpartyLead(counterparties: string[], limit = 6): string {
|
||||
const visible = counterparties.slice(0, limit);
|
||||
const remaining = counterparties.length - visible.length;
|
||||
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
|
||||
return `${visible.join("; ")}${suffix}`;
|
||||
}
|
||||
|
||||
function hasInventorySupplierOverlapDefensiveLead(firstLine: string): boolean {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(
|
||||
normalized
|
||||
);
|
||||
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(
|
||||
normalized
|
||||
);
|
||||
return startsWithStockScope && hasDefensiveAttribution;
|
||||
}
|
||||
|
||||
function firstNonEmptyReplyLine(text: unknown): string {
|
||||
return String(text ?? "")
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? "";
|
||||
}
|
||||
|
||||
function hasInventorySupplierOverlapCounterpartyLead(firstLine: string): boolean {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
return (
|
||||
/^\u043f\u043e\s+\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443/u.test(normalized) &&
|
||||
/\u043d\u0430\u0439\u0434\u0435\u043d/u.test(normalized) &&
|
||||
/\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a/u.test(normalized) &&
|
||||
/\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u043e\u0433\u043e\s+\u0441\u043b\u0435\u0434\u0430/u.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize(input: {
|
||||
rawReply: string;
|
||||
sanitizedReply: string;
|
||||
addressDebug: unknown;
|
||||
}): { text: string; applied: boolean } {
|
||||
const debug = toRecordObject(input.addressDebug);
|
||||
const detectedIntent = toNullableString(debug?.detected_intent);
|
||||
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return { text: input.sanitizedReply, applied: false };
|
||||
}
|
||||
const rawLead = firstNonEmptyReplyLine(input.rawReply);
|
||||
if (!hasInventorySupplierOverlapCounterpartyLead(rawLead)) {
|
||||
return { text: input.sanitizedReply, applied: false };
|
||||
}
|
||||
const sanitizedLead = firstNonEmptyReplyLine(input.sanitizedReply);
|
||||
if (hasInventorySupplierOverlapCounterpartyLead(sanitizedLead)) {
|
||||
return { text: input.sanitizedReply, applied: false };
|
||||
}
|
||||
const sanitizedText = String(input.sanitizedReply ?? "").trim();
|
||||
return {
|
||||
text: sanitizedText ? [rawLead, sanitizedText].join("\n\n") : rawLead,
|
||||
applied: true
|
||||
};
|
||||
}
|
||||
|
||||
function inspectInventorySupplierOverlapDefensiveLead(firstLine: string): {
|
||||
stockScopeMatch: boolean;
|
||||
defensiveMatch: boolean;
|
||||
} {
|
||||
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
|
||||
return {
|
||||
stockScopeMatch: /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(
|
||||
normalized
|
||||
),
|
||||
defensiveMatch: /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(
|
||||
normalized
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function buildInventorySupplierOverlapSurfaceRepairAudit(input: {
|
||||
applied: boolean;
|
||||
detectedIntent: string | null;
|
||||
reasonCodes: string[];
|
||||
counterparties?: string[];
|
||||
firstLineSample?: string;
|
||||
stockScopeMatch?: boolean;
|
||||
defensiveMatch?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
schema_version: "inventory_supplier_overlap_surface_repair_v1",
|
||||
applied: input.applied,
|
||||
detected_intent: input.detectedIntent,
|
||||
counterparties: input.counterparties ?? [],
|
||||
first_line_sample: input.firstLineSample ?? null,
|
||||
stock_scope_match: input.stockScopeMatch ?? null,
|
||||
defensive_match: input.defensiveMatch ?? null,
|
||||
reason_codes: input.reasonCodes
|
||||
};
|
||||
}
|
||||
|
||||
function repairInventorySupplierOverlapSurfaceReply(
|
||||
replyText: unknown,
|
||||
addressDebug: unknown
|
||||
): { text: string; audit: Record<string, unknown> | null } {
|
||||
const text = String(replyText ?? "");
|
||||
const debug = toRecordObject(addressDebug);
|
||||
const detectedIntent = toNullableString(debug?.detected_intent);
|
||||
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
|
||||
return {
|
||||
text,
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: false,
|
||||
detectedIntent,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_intent_not_applicable"]
|
||||
})
|
||||
};
|
||||
}
|
||||
const lines = text.split(/\r?\n/u);
|
||||
const firstLine = lines[0]?.trim() ?? "";
|
||||
const leadInspection = inspectInventorySupplierOverlapDefensiveLead(firstLine);
|
||||
if (!hasInventorySupplierOverlapDefensiveLead(firstLine)) {
|
||||
return {
|
||||
text,
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: false,
|
||||
detectedIntent,
|
||||
firstLineSample: firstLine.slice(0, 220),
|
||||
stockScopeMatch: leadInspection.stockScopeMatch,
|
||||
defensiveMatch: leadInspection.defensiveMatch,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_first_line_not_defensive"]
|
||||
})
|
||||
};
|
||||
}
|
||||
const counterparties = extractInventorySupplierOverlapCounterpartiesFromReply(text);
|
||||
if (counterparties.length === 0) {
|
||||
return {
|
||||
text,
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: false,
|
||||
detectedIntent,
|
||||
counterparties,
|
||||
firstLineSample: firstLine.slice(0, 220),
|
||||
stockScopeMatch: leadInspection.stockScopeMatch,
|
||||
defensiveMatch: leadInspection.defensiveMatch,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_no_counterparties"]
|
||||
})
|
||||
};
|
||||
}
|
||||
const repairedLead =
|
||||
counterparties.length === 1
|
||||
? `По складскому остатку найден поставщик закупочного следа: ${counterparties[0]}.`
|
||||
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatInventorySupplierOverlapCounterpartyLead(counterparties)}.`;
|
||||
return {
|
||||
text: [repairedLead, firstLine, ...lines.slice(1)].join("\n"),
|
||||
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
|
||||
applied: true,
|
||||
detectedIntent,
|
||||
counterparties,
|
||||
firstLineSample: firstLine.slice(0, 220),
|
||||
stockScopeMatch: leadInspection.stockScopeMatch,
|
||||
defensiveMatch: leadInspection.defensiveMatch,
|
||||
reasonCodes: ["inventory_supplier_overlap_surface_repair_applied"]
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function extractSingleWarehouseScopeFromReply(replyText: string): string | null {
|
||||
const values: string[] = [];
|
||||
const normalized = String(replyText ?? "");
|
||||
const byFieldPattern = /(?:^|\|)\s*склад\s*:\s*([^|\r\n]+)/giu;
|
||||
for (const match of normalized.matchAll(byFieldPattern)) {
|
||||
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
|
||||
if (value) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
const fieldUnique = uniqueNormalizedStrings(values);
|
||||
if (fieldUnique.length > 0) {
|
||||
return fieldUnique.length === 1 ? fieldUnique[0] : null;
|
||||
}
|
||||
const leadMatch = normalized.match(/(?:по|на)\s+склад[уе]?\s+([^.,;\r\n]+)/iu);
|
||||
const leadWarehouse = toNullableString(leadMatch?.[1]?.replace(/[.;,\s]+$/u, ""));
|
||||
if (leadWarehouse) {
|
||||
values.push(leadWarehouse);
|
||||
}
|
||||
const unique = uniqueNormalizedStrings(values);
|
||||
return unique.length === 1 ? unique[0] : null;
|
||||
}
|
||||
|
||||
function sameBusinessLabel(left: unknown, right: unknown): boolean {
|
||||
const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
|
||||
const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
|
||||
|
|
@ -115,7 +331,10 @@ function cleanComparisonScopeCompanyLine(line: string, organization: string | nu
|
|||
.replace(/\bcompany-level\b/giu, "общий по компании")
|
||||
.replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез");
|
||||
if (organization) {
|
||||
clean = clean.replace(/по компании\s+Альтернатива Плюс/iu, `по компании ${organization}`);
|
||||
clean = clean.replace(
|
||||
/по компании\s+.+?(?=\s+(?:подтвержден|подтвержд[её]н|зафиксирован|показан|есть)\b|:|$)/iu,
|
||||
`по компании ${organization}`
|
||||
);
|
||||
}
|
||||
return clean.trim();
|
||||
}
|
||||
|
|
@ -136,9 +355,7 @@ function buildComparisonScopeProofReply(input: {
|
|||
session: unknown;
|
||||
userMessage?: unknown;
|
||||
}): { reply: string; audit: Record<string, unknown> } | null {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return null;
|
||||
}
|
||||
const legalOrganizationFromCurrentTurn = legalOrganizationLabelFromClarification(input.userMessage);
|
||||
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
|
||||
const turnInput = toRecordObject(entryPoint?.turn_input);
|
||||
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
|
|
@ -146,6 +363,9 @@ function buildComparisonScopeProofReply(input: {
|
|||
if (!isBusinessOverview) {
|
||||
return null;
|
||||
}
|
||||
if (!hasComparisonScopeProofCue(input.userMessage) && !legalOrganizationFromCurrentTurn) {
|
||||
return null;
|
||||
}
|
||||
const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates)
|
||||
? turnMeaning.business_overview_separate_entity_candidates
|
||||
: [];
|
||||
|
|
@ -172,7 +392,7 @@ function buildComparisonScopeProofReply(input: {
|
|||
if (!incomingAmount && !outgoingAmount && !netAmount) {
|
||||
return null;
|
||||
}
|
||||
const organization = legalOrganizationLabelFromClarification(input.userMessage)
|
||||
const organization = legalOrganizationFromCurrentTurn
|
||||
?? toNullableString(turnMeaning?.explicit_organization_scope)
|
||||
?? toNullableString(toRecordObject(comparisonScope?.organization)?.label);
|
||||
const documentCount = Number(toRecordObject(documentBundle)?.document_count);
|
||||
|
|
@ -355,8 +575,24 @@ export function runAssistantAddressLaneResponseRuntime<ResponseType = AssistantM
|
|||
input: RunAssistantAddressLaneResponseRuntimeInput<ResponseType>
|
||||
): RunAssistantAddressLaneResponseRuntimeOutput<ResponseType> {
|
||||
const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? finalizeAssistantAddressTurn;
|
||||
const safeAddressReply = input.sanitizeOutgoingAssistantText(input.addressLane.reply_text);
|
||||
const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta);
|
||||
const surfaceRepair = repairInventorySupplierOverlapSurfaceReply(
|
||||
input.addressLane.reply_text,
|
||||
debug
|
||||
);
|
||||
const safeAddressReplyBase = input.sanitizeOutgoingAssistantText(surfaceRepair.text);
|
||||
const preservedAddressReply = preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize({
|
||||
rawReply: surfaceRepair.text,
|
||||
sanitizedReply: safeAddressReplyBase,
|
||||
addressDebug: debug
|
||||
});
|
||||
const safeAddressReply = preservedAddressReply.text;
|
||||
if (surfaceRepair.audit) {
|
||||
debug.inventory_supplier_overlap_surface_repair_v1 = {
|
||||
...surfaceRepair.audit,
|
||||
lead_preserved_after_sanitize: preservedAddressReply.applied
|
||||
};
|
||||
}
|
||||
const followupOffer = input.buildAddressFollowupOffer(debug);
|
||||
if (followupOffer) {
|
||||
debug.address_followup_offer = followupOffer;
|
||||
|
|
@ -391,14 +627,38 @@ export function runAssistantAddressLaneResponseRuntime<ResponseType = AssistantM
|
|||
followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object"
|
||||
? (followupContextSource.root_filters as Record<string, unknown>)
|
||||
: null;
|
||||
if (rootIntent || currentFrameKind) {
|
||||
const detectedIntent = input.toNonEmptyString(debug.detected_intent);
|
||||
const rootFrameWarehouse =
|
||||
input.toNonEmptyString(rootFilters?.warehouse) ??
|
||||
input.toNonEmptyString(debugFilters?.warehouse) ??
|
||||
(detectedIntent === "inventory_on_hand_as_of_date" ? extractSingleWarehouseScopeFromReply(safeAddressReply) : null);
|
||||
const shouldPersistInventoryRootFrame =
|
||||
rootIntent || currentFrameKind || detectedIntent === "inventory_on_hand_as_of_date";
|
||||
if (shouldPersistInventoryRootFrame) {
|
||||
const rootFiltersForDebug: Record<string, unknown> = {
|
||||
...(rootFilters ?? {}),
|
||||
organization: input.toNonEmptyString(rootFilters?.organization) ?? input.toNonEmptyString(debugFilters?.organization),
|
||||
warehouse: rootFrameWarehouse,
|
||||
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date) ?? input.toNonEmptyString(debugFilters?.as_of_date),
|
||||
period_from: input.toNonEmptyString(rootFilters?.period_from) ?? input.toNonEmptyString(debugFilters?.period_from),
|
||||
period_to: input.toNonEmptyString(rootFilters?.period_to) ?? input.toNonEmptyString(debugFilters?.period_to)
|
||||
};
|
||||
for (const key of Object.keys(rootFiltersForDebug)) {
|
||||
if (!input.toNonEmptyString(rootFiltersForDebug[key])) {
|
||||
delete rootFiltersForDebug[key];
|
||||
}
|
||||
}
|
||||
debug.address_root_frame_context = {
|
||||
root_intent: rootIntent,
|
||||
current_frame_kind: currentFrameKind,
|
||||
organization: input.toNonEmptyString(rootFilters?.organization),
|
||||
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date),
|
||||
period_from: input.toNonEmptyString(rootFilters?.period_from),
|
||||
period_to: input.toNonEmptyString(rootFilters?.period_to)
|
||||
root_intent: rootIntent ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_on_hand_as_of_date" : null),
|
||||
root_filters: rootFiltersForDebug,
|
||||
root_anchor_type: input.toNonEmptyString(followupContextSource?.root_anchor_type) ?? undefined,
|
||||
root_anchor_value: input.toNonEmptyString(followupContextSource?.root_anchor_value) ?? undefined,
|
||||
current_frame_kind: currentFrameKind ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_root" : null),
|
||||
organization: input.toNonEmptyString(rootFiltersForDebug.organization),
|
||||
warehouse: input.toNonEmptyString(rootFiltersForDebug.warehouse),
|
||||
as_of_date: input.toNonEmptyString(rootFiltersForDebug.as_of_date),
|
||||
period_from: input.toNonEmptyString(rootFiltersForDebug.period_from),
|
||||
period_to: input.toNonEmptyString(rootFiltersForDebug.period_to)
|
||||
};
|
||||
}
|
||||
const debugWithRuntimeContracts = attachAssistantRuntimeContractShadow(debug, {
|
||||
|
|
|
|||
|
|
@ -1479,9 +1479,23 @@ export function buildInventoryRootFrameFromAddressDebug(
|
|||
}
|
||||
|
||||
const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters);
|
||||
const rootFrameFiltersCandidate = rootFrameContext
|
||||
? {
|
||||
organization: toNonEmptyString(rootFrameContext.organization),
|
||||
warehouse: toNonEmptyString(rootFrameContext.warehouse),
|
||||
as_of_date: toNonEmptyString(rootFrameContext.as_of_date),
|
||||
period_from: toNonEmptyString(rootFrameContext.period_from),
|
||||
period_to: toNonEmptyString(rootFrameContext.period_to)
|
||||
}
|
||||
: null;
|
||||
const rootFrameFilters = rootFrameFiltersCandidate
|
||||
? Object.fromEntries(
|
||||
Object.entries(rootFrameFiltersCandidate).filter(([, value]) => Boolean(value))
|
||||
)
|
||||
: null;
|
||||
const filters = {
|
||||
...(rootFiltersCandidate ?? {}),
|
||||
...(rootFiltersCandidate ? {} : extractedFilters)
|
||||
...(rootFiltersCandidate ? {} : rootFrameFilters && Object.keys(rootFrameFilters).length > 0 ? rootFrameFilters : extractedFilters)
|
||||
};
|
||||
if (!filters.organization) {
|
||||
const organization = readAddressDebugOrganization(debug, toNonEmptyString);
|
||||
|
|
|
|||
|
|
@ -572,6 +572,37 @@ function hasInventoryMarginRankingAddressReply(
|
|||
);
|
||||
}
|
||||
|
||||
function hasExactInventoryPurchaseToSaleChainAddressReply(
|
||||
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
|
||||
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
|
||||
): boolean {
|
||||
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasEffectivelyFactualAddressReply(input)) {
|
||||
return false;
|
||||
}
|
||||
const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase();
|
||||
if (source !== "address_query_runtime_v1" && source !== "address_exact" && source !== "address_lane") {
|
||||
return false;
|
||||
}
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
|
||||
const capabilityId =
|
||||
toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
|
||||
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id);
|
||||
if (
|
||||
detectedIntent !== "inventory_purchase_to_sale_chain" &&
|
||||
selectedRecipe !== "address_inventory_purchase_to_sale_chain_v1" &&
|
||||
capabilityId !== "inventory_inventory_purchase_to_sale_chain"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
|
||||
const responseType = toNonEmptyString(input.addressRuntimeMeta?.response_type);
|
||||
return Boolean(hasConfirmedAddressExecution(input) || mcpCallStatus === "matched_non_empty" || responseType === "FACTUAL_LIST");
|
||||
}
|
||||
|
||||
function hasValueFlowActionConflictWithDiscoveryTurnMeaning(
|
||||
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
|
||||
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
|
||||
|
|
@ -651,6 +682,16 @@ function hasExactMatchedFactualAddressReply(
|
|||
}
|
||||
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
|
||||
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
|
||||
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
|
||||
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
|
||||
const askedAction = toNonEmptyString(turnMeaning?.asked_action_family);
|
||||
if (
|
||||
detectedIntent === "inventory_supplier_stock_overlap_as_of_date" &&
|
||||
hasConfirmedAddressExecution(input) &&
|
||||
(askedDomain === "entity_resolution" || askedAction === "search_business_entity")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -916,6 +957,10 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
|
||||
const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint);
|
||||
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
|
||||
const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(
|
||||
input,
|
||||
entryPoint
|
||||
);
|
||||
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
|
||||
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
|
||||
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(
|
||||
|
|
@ -930,6 +975,10 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
exactBankOperationsAddressReply &&
|
||||
!semanticConflictWithDiscoveryTurnMeaning &&
|
||||
!valueFlowActionConflictWithDiscoveryTurnMeaning;
|
||||
const exactInventoryChainProtectsCurrent =
|
||||
exactInventoryPurchaseToSaleChainAddressReply &&
|
||||
candidate.candidate_status === "clarification_candidate" &&
|
||||
valueFlowActionConflictWithDiscoveryTurnMeaning;
|
||||
|
||||
if (!entryPoint) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point");
|
||||
|
|
@ -1009,6 +1058,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
if (inventoryMarginRankingAddressReply) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply");
|
||||
}
|
||||
if (exactInventoryChainProtectsCurrent) {
|
||||
pushReason(
|
||||
reasonCodes,
|
||||
"mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification"
|
||||
);
|
||||
}
|
||||
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
|
||||
pushReason(
|
||||
reasonCodes,
|
||||
|
|
@ -1046,6 +1101,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
!exactBankOperationsProtectsCurrent &&
|
||||
!exactDocumentListAddressReply &&
|
||||
!inventoryMarginRankingAddressReply &&
|
||||
!exactInventoryChainProtectsCurrent &&
|
||||
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
|
||||
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
|
||||
candidate.eligible_for_future_hot_runtime &&
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ function detectSupportedIntent(text, deps) {
|
|||
reason: "address_intent_resolver_current_turn_signal"
|
||||
};
|
||||
}
|
||||
if (hasInventorySupplierStockOverlapExactSignal(text)) {
|
||||
return {
|
||||
intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||
confidence: "high",
|
||||
reason: "inventory_supplier_stock_overlap_current_turn_signal"
|
||||
};
|
||||
}
|
||||
if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) {
|
||||
return {
|
||||
intent: "receivables_confirmed_as_of_date",
|
||||
|
|
@ -246,6 +253,30 @@ function hasSelectedObjectInventoryExactSignal(text) {
|
|||
);
|
||||
}
|
||||
|
||||
function hasInventorySupplierStockOverlapExactSignal(text) {
|
||||
const normalized = String(text ?? "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const hasStockScope =
|
||||
/(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442|\u043b\u0435\u0436\p{L}*|\u043d\u0430\u0445\u043e\u0434\p{L}*|\u0447\u0438\u0441\u043b\p{L}*|warehouse|stock|inventory|on\s+hand)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const hasSupplierCue =
|
||||
/(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0437\u0430\u043a\u0443\u043f|\u043a\u0443\u043f\p{L}*|\u043f\u0440\u0438\u043e\u0431\u0440\p{L}*|supplier|vendor|purchase|bought|supplied|procurement)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const hasOverlapAction =
|
||||
/(?:\u0443\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\s+\u043a\u0430\u043a\u043e\u043c\u0443\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*[\s\S]{0,80}\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0439\u0442\p{L}*[\s\S]{0,80}\u0442\u043e\u0432\u0430\u0440|\u043a\u0430\u043a\u0438\u0435\s+\u0442\u043e\u0432\u0430\u0440|supplier[\s\S]{0,80}(?:stock|inventory)|(?:stock|inventory)[\s\S]{0,80}supplier)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const hasSupplierIdentityAsk =
|
||||
/(?:\u043a\u0430\u043a\u043e(?:\u0433\u043e|\u043c\u0443)\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|which\s+supplier|what\s+supplier)/iu.test(
|
||||
normalized
|
||||
);
|
||||
return hasStockScope && hasSupplierCue && (hasOverlapAction || hasSupplierIdentityAsk);
|
||||
}
|
||||
|
||||
function hasOrganizationLevelEarningsOverviewSignal(text) {
|
||||
const normalized = String(text ?? "");
|
||||
if (!normalized || hasExplicitCounterpartyValueObject(normalized)) {
|
||||
|
|
@ -508,13 +539,17 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText);
|
||||
const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText);
|
||||
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
|
||||
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
|
||||
const inventorySupplierStockOverlapExact =
|
||||
supportedIntent?.intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
llmIntent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
hasInventorySupplierStockOverlapExactSignal(joinedText);
|
||||
const broadBusinessEvaluation =
|
||||
compactOrganizationCashflowDisplay
|
||||
? { family: "broad_business_evaluation" }
|
||||
: selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family
|
||||
: selectedObjectInventoryExact || inventorySupplierStockOverlapExact || counterpartyBidirectionalValueFlow?.family
|
||||
? null
|
||||
: detectBroadBusinessEvaluation(joinedText);
|
||||
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
|
||||
const explicitIntentCandidate =
|
||||
broadBusinessEvaluation?.family
|
||||
? null
|
||||
|
|
@ -585,8 +620,10 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
? "confirmed_tax_period"
|
||||
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
|
||||
? "confirmed_snapshot"
|
||||
: explicitIntentCandidate === "vat_payable_forecast"
|
||||
? "forecast"
|
||||
: explicitIntentCandidate === "vat_payable_forecast"
|
||||
? "forecast"
|
||||
: explicitIntentCandidate === "inventory_supplier_stock_overlap_as_of_date"
|
||||
? "supplier_overlap"
|
||||
: explicitIntentCandidate === "list_documents_by_counterparty"
|
||||
? "list_documents"
|
||||
: counterpartyTurnover?.family
|
||||
|
|
|
|||
|
|
@ -36,6 +36,26 @@ describe("inventory root frame regressions", () => {
|
|||
expect(result?.filters.warnings).toContain("period_to_from_followup_context");
|
||||
});
|
||||
|
||||
it("does not treat an explicit warehouse as an item in root inventory snapshot", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
"Какие конкретно номенклатуры формируют остаток по складу Основной склад на дату 2019-03-31",
|
||||
null,
|
||||
{
|
||||
scope_target_kind: "item",
|
||||
scope_target_text: "Основной склад",
|
||||
date_scope_kind: "explicit",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад");
|
||||
expect(result?.filters.extracted_filters.item).toBeUndefined();
|
||||
expect(result?.filters.warnings).toContain("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot");
|
||||
});
|
||||
|
||||
it("promotes selected-object provenance slang with 'где мы взяли это' into inventory provenance", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
'По выбранному объекту "Зеркало для инвалидов поворотное травмобезопасное": где мы взяли это говнище?',
|
||||
|
|
@ -110,6 +130,39 @@ describe("inventory root frame regressions", () => {
|
|||
expect(result?.intent.intent).not.toBe("bank_operations_by_counterparty");
|
||||
expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)");
|
||||
});
|
||||
|
||||
it("routes inline selected-object purchase document wording without external followup context", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили',
|
||||
null
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item");
|
||||
expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
|
||||
});
|
||||
|
||||
it("routes normalized selected-item purchase document wording from LLM predecompose", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
"Уточнить, какие документы поступления связаны с выбранным товаром «Столешница 600*3050*26 дуб ниагара»",
|
||||
{
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
item: "Столешница 600*3050*26 дуб ниагара",
|
||||
as_of_date: "2019-03-31",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
},
|
||||
previous_anchor_type: "item",
|
||||
previous_anchor_value: "Столешница 600*3050*26 дуб ниагара",
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
current_frame_kind: "inventory_drilldown"
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item");
|
||||
expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
|
||||
});
|
||||
it("restores stock snapshot intent for selected-object restatement on the previously reviewed date", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
'По выбранному объекту "Столешница 600*3050*26 альмандин": покажи еще раз остатки на дату которую до этого рассматривали',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { detectAddressQuestionMode } from "../src/services/addressQueryClassifie
|
|||
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
||||
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
|
||||
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
||||
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
import { AddressQueryService, ensureInventorySupplierOverlapLeadLine } from "../src/services/addressQueryService";
|
||||
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
|
||||
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
|
||||
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
|
||||
|
|
@ -157,6 +157,15 @@ describe("address query shape classifier", () => {
|
|||
expect(filters.warehouse).toBe("Основной склад");
|
||||
});
|
||||
|
||||
it("does not extract generic goods wording as counterparty for supplier-overlap identity questions", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
|
||||
"inventory_supplier_stock_overlap_as_of_date"
|
||||
).extracted_filters;
|
||||
expect(filters.counterparty).toBeUndefined();
|
||||
expect(filters.warehouse).toBe("Основной склад");
|
||||
});
|
||||
|
||||
it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
|
||||
|
|
@ -499,6 +508,35 @@ describe("address query shape classifier", () => {
|
|||
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
|
||||
});
|
||||
|
||||
it("states directly when only purchase side of the inventory chain is confirmed", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_purchase_to_sale_chain",
|
||||
[
|
||||
{
|
||||
period: "2019-12-10T00:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 00000000111 от 10.12.2019 12:00:01",
|
||||
account_dt: "41.01",
|
||||
account_kt: "",
|
||||
amount: 855000,
|
||||
analytics: ["Шкаф картотечный 1000*400*2100", "Основной склад", "ООО Альтернатива Плюс", "ЭталонМебель"],
|
||||
item: "Шкаф картотечный 1000*400*2100",
|
||||
counterparty: "ЭталонМебель",
|
||||
organization: "ООО Альтернатива Плюс"
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2020-03-31",
|
||||
userMessage: "Есть ли цепочка закупка -> продажа по товару Шкаф картотечный 1000*400*2100?"
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.text.split("\n")[0]).toContain("закупочная часть цепочки подтверждена");
|
||||
expect(reply.text.split("\n")[0]).toContain("продажа/выбытие со счета 41.01");
|
||||
expect(reply.text.split("\n")[0]).not.toContain("частично или разнообразно");
|
||||
expect(reply.text).toContain("Строк закупки на 41.01: 1");
|
||||
expect(reply.text).toContain("Строк продажи со счета 41.01: 0");
|
||||
});
|
||||
|
||||
it("states when requested supplier-to-buyer chain parties are not confirmed by item documents", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_purchase_to_sale_chain",
|
||||
|
|
@ -3648,6 +3686,24 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 9000
|
|||
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
|
||||
});
|
||||
|
||||
it("detaches analysis-context snapshot date for inventory purchase-to-sale chain execution", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
|
||||
{
|
||||
analysisDateHint: "2020-03-31",
|
||||
activeOrganization: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_to_sale_chain");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
|
||||
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
|
||||
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.reply_text).toContain("Строк продажи со счета 41.01: 1");
|
||||
});
|
||||
|
||||
it("returns soft out-of-scope reply without technical jargon for unsupported supplier-control wording", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
|
|
@ -4511,6 +4567,33 @@ it("auto-broadens out-of-window period after contracts pivot and keeps requested
|
|||
});
|
||||
|
||||
describe("address decompose stage follow-up carryover", () => {
|
||||
it("keeps old-purchase residue follow-up in aging route with inherited stock date", () => {
|
||||
const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
target_intent: "inventory_on_hand_as_of_date",
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
current_frame_kind: "inventory_root",
|
||||
previous_filters: {
|
||||
as_of_date: "2021-09-30",
|
||||
period_from: "2021-09-01",
|
||||
period_to: "2021-09-30",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
},
|
||||
root_filters: {
|
||||
as_of_date: "2021-09-30",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2021-09-30");
|
||||
expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад");
|
||||
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
|
||||
});
|
||||
|
||||
it("promotes selected-object supplier slang follow-up into inventory provenance with inherited date context", () => {
|
||||
const result = runAddressDecomposeStage('По выбранному объекту "Столешница 600*3050*26 дуб ниагара": кто это поставил нам', {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
|
|
@ -4736,6 +4819,39 @@ describe("address decompose stage follow-up carryover", () => {
|
|||
expect(result.warnings).toContain("item_llm_semantics_ignored");
|
||||
});
|
||||
|
||||
it("clears generic goods nouns from llm semantic counterparty hints", () => {
|
||||
const result = applyAddressLlmSemanticHintsToExtraction(
|
||||
{
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
warehouse: "Основной склад"
|
||||
},
|
||||
missing_required_filters: [],
|
||||
warnings: [],
|
||||
semantic_frame: {
|
||||
scope_kind: "explicit_anchor",
|
||||
anchor_kind: "warehouse",
|
||||
anchor_value: "Основной склад",
|
||||
date_scope_kind: "implicit_current",
|
||||
date_basis_hint: "implicit_current_snapshot",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
},
|
||||
{
|
||||
scope_target_kind: "counterparty",
|
||||
scope_target_text: "товаров,",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.extracted_filters.counterparty).toBeUndefined();
|
||||
expect(result.extracted_filters.warehouse).toBe("Основной склад");
|
||||
expect(result.warnings).toContain("counterparty_cleared_low_quality_llm_semantics");
|
||||
});
|
||||
|
||||
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
|
||||
const result = runAddressDecomposeStage("выведи всех заков за все время", null);
|
||||
expect(result).not.toBeNull();
|
||||
|
|
@ -5889,7 +6005,7 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
|||
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки(");
|
||||
expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары");
|
||||
expect(plan.query).toContain("Остатки.КоличествоРазвернутыйОстатокДт > 0");
|
||||
expect(plan.query).toContain("Товары.Номенклатура В");
|
||||
expect(plan.query).not.toContain("Товары.Номенклатура В");
|
||||
expect(plan.query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2026, 4, 18, 23, 59, 59)");
|
||||
});
|
||||
|
||||
|
|
@ -6045,6 +6161,8 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
|||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text.split("\n")[0]).toContain("выявлен поставщик закупочного следа");
|
||||
expect(reply.text.split("\n")[0]).toContain("Торговый дом");
|
||||
expect(reply.text).toContain("Что проверили:");
|
||||
expect(reply.text).toContain("Опорные документы:");
|
||||
expect(reply.text).not.toContain("exact-контур");
|
||||
|
|
@ -6070,17 +6188,133 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
|||
],
|
||||
{
|
||||
asOfDate: "2021-09-30",
|
||||
userMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику",
|
||||
userMessage: "Покажи закупочный след текущего складского остатка",
|
||||
rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text.split("\n")[0]).toContain("без явно выделенного поставщика");
|
||||
expect(reply.text).toContain("Позиции без явно выделенного поставщика:");
|
||||
expect(reply.text.split("\n")[0]).toContain("без надежной привязки к поставщику");
|
||||
expect(reply.text.split("\n")[0]).toContain("первые примеры ниже");
|
||||
expect(reply.text).toContain("Позиции без надежной привязки к поставщику:");
|
||||
expect(reply.text).toContain("Товар без поставщика");
|
||||
expect(reply.text).toContain("Опорные документы:");
|
||||
expect(reply.text).not.toContain("Уточните");
|
||||
});
|
||||
|
||||
it("derives unresolved supplier-link month filters from raw user wording before organization clarification", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("По какому поставщику проходит текущий товарный остаток", {
|
||||
analysisDateHint: "2021-09-30",
|
||||
knownOrganizations: ['ООО "Альтернатива Плюс"', 'ООО "Актив"'],
|
||||
rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику"
|
||||
});
|
||||
|
||||
expect(result?.debug.detected_intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||
expect(result?.debug.extracted_filters).toMatchObject({
|
||||
as_of_date: "2021-09-30",
|
||||
period_from: "2021-09-01",
|
||||
period_to: "2021-09-30"
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps broad supplier-overlap answers direct-first when supplier is not separated", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_supplier_stock_overlap_as_of_date",
|
||||
[
|
||||
{
|
||||
period: "2021-09-30T00:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 00000000999 от 30.09.2021 0:00:00",
|
||||
account_dt: "41.01",
|
||||
account_kt: "60.01",
|
||||
amount: 1200,
|
||||
analytics: ["Товар без поставщика", "Основной склад"],
|
||||
item: "Товар без поставщика",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2021-09-30",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
|
||||
const firstLine = reply.text.split("\n")[0] ?? "";
|
||||
expect(firstLine).toContain("По складскому остатку Основной склад");
|
||||
expect(firstLine).toContain("однозначный поставщик текущего остатка не подтвержден");
|
||||
expect(firstLine).toContain("1");
|
||||
expect(firstLine).not.toContain("Часть закупочных операций");
|
||||
expect(reply.text).not.toContain("Часть закупочных операций");
|
||||
expect(reply.text).toContain("закупочных операций без явно выделенного поставщика");
|
||||
});
|
||||
|
||||
it("repairs supplier-overlap lead line when evidence rows display counterparties", () => {
|
||||
const staleText = [
|
||||
"По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.",
|
||||
"Что проверили:",
|
||||
"- Дата среза: 30.09.2021.",
|
||||
"Опорные документы:",
|
||||
"1. Поступление товаров и услуг 00000000001 | контрагент: А",
|
||||
"2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ",
|
||||
"3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова"
|
||||
].join("\n");
|
||||
|
||||
const repaired = ensureInventorySupplierOverlapLeadLine("inventory_supplier_stock_overlap_as_of_date", staleText);
|
||||
const lines = repaired.split("\n");
|
||||
|
||||
expect(lines[0]).toContain("найдено несколько поставщиков закупочного следа");
|
||||
expect(lines[0]).toContain("А");
|
||||
expect(lines[0]).toContain("ВИЗАНТИЯ");
|
||||
expect(lines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена");
|
||||
});
|
||||
|
||||
it("answers supplier-scoped stock questions with item list first", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_supplier_stock_overlap_as_of_date",
|
||||
[
|
||||
{
|
||||
period: "2019-02-12T00:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
|
||||
account_dt: "41.01",
|
||||
account_kt: "60.01",
|
||||
amount: 3075,
|
||||
analytics: ["Столешница 600*3050*26 альмандин", "Основной склад", "Торговый дом \"Союз МСК\""],
|
||||
item: "Столешница 600*3050*26 альмандин",
|
||||
warehouse: "Основной склад",
|
||||
counterparty: "Торговый дом \"Союз МСК\"",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
},
|
||||
{
|
||||
period: "2019-02-12T00:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
|
||||
account_dt: "41.01",
|
||||
account_kt: "60.01",
|
||||
amount: 3724.17,
|
||||
analytics: ["Столешница 600*3050*26 дуб ниагара", "Основной склад", "Торговый дом \"Союз МСК\""],
|
||||
item: "Столешница 600*3050*26 дуб ниагара",
|
||||
warehouse: "Основной склад",
|
||||
counterparty: "Торговый дом \"Союз МСК\"",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2021-09-30",
|
||||
counterpartyHint: "Торговый дом \\Союз",
|
||||
userMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
|
||||
const firstLine = reply.text.split("\n")[0] ?? "";
|
||||
expect(firstLine).toContain("подтверждены позиции");
|
||||
expect(firstLine).toContain("Столешница 600*3050*26 альмандин");
|
||||
expect(firstLine).toContain("Столешница 600*3050*26 дуб ниагара");
|
||||
expect(firstLine).not.toContain("выявлен поставщик закупочного следа");
|
||||
expect(reply.text).toContain("Позиции:");
|
||||
});
|
||||
|
||||
it("routes inventory provenance questions to a dedicated intent", () => {
|
||||
const result = resolveAddressIntent("От какого поставщика куплен товар Шкаф картоотечный?");
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
|
|
@ -6145,6 +6379,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
|||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
});
|
||||
|
||||
it("routes old-purchase residue follow-up wording to aging intent", () => {
|
||||
const result = resolveAddressIntent("Какие остатки по товарам на эту дату относятся к старым закупкам");
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
});
|
||||
|
||||
it("routes old stock wording with residue anchor to aging intent", () => {
|
||||
const result = resolveAddressIntent("Это остаток по очень давно купленному товару?");
|
||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||
|
|
|
|||
|
|
@ -526,6 +526,73 @@ describe("address reply builders regressions", () => {
|
|||
expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список");
|
||||
});
|
||||
|
||||
it("surfaces account and warehouse scope in on-hand snapshot top line", () => {
|
||||
const result = composeInventoryReply(
|
||||
"inventory_on_hand_as_of_date",
|
||||
[
|
||||
{
|
||||
amount: 614098.33,
|
||||
quantity: 13,
|
||||
item: "Моноблок леново",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period: "2019-03-31",
|
||||
registrator: "Остатки"
|
||||
} as any
|
||||
],
|
||||
{
|
||||
userMessage: "Какие товары числятся на 41 счете на дату 2019-03-31",
|
||||
accountHint: "41",
|
||||
warehouseHint: "Основной склад",
|
||||
asOfDate: "2019-03-31"
|
||||
},
|
||||
{
|
||||
resolvePayablesAsOfDate: () => "2019-03-31",
|
||||
buildInventoryOnHandAggregate: () => [
|
||||
{
|
||||
item: "Моноблок леново",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
quantity: 13,
|
||||
amount: 614098.33,
|
||||
operations: 1,
|
||||
firstPeriod: "2019-03-31",
|
||||
lastPeriod: "2019-03-31",
|
||||
sourceRefs: []
|
||||
}
|
||||
],
|
||||
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
|
||||
formatDateRu: (value: string) => value,
|
||||
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
|
||||
formatMoneyRub: (value: number) => `${value} ₽`,
|
||||
isInventoryPurchaseMovement: () => false,
|
||||
summarizeInventoryTraceRows: () => ({
|
||||
item: null,
|
||||
warehouses: [],
|
||||
organizations: [],
|
||||
counterparties: [],
|
||||
documents: [],
|
||||
firstPeriod: null,
|
||||
lastPeriod: null,
|
||||
totalAmount: 0
|
||||
}),
|
||||
formatInventoryTraceRows: () => [],
|
||||
hasInventoryPurchaseDateActionFocus: () => false,
|
||||
inventoryTraceDateLabel: () => "",
|
||||
extractInventoryCounterpartyCandidates: () => [],
|
||||
buildInventoryAgingByItemAggregate: () => [],
|
||||
formatInventoryAgingRows: () => [],
|
||||
isInventorySaleMovement: () => false
|
||||
}
|
||||
);
|
||||
|
||||
const firstLine = result?.text.split("\n")[0] ?? "";
|
||||
expect(firstLine).toContain("по счету 41");
|
||||
expect(firstLine).toContain("по складу Основной склад");
|
||||
expect(result?.text).toContain("Срез по счету 41.");
|
||||
expect(result?.text).toContain("Срез по складу Основной склад.");
|
||||
});
|
||||
|
||||
it("answers cost-base evidence follow-up directly when margin ranking has sales without confirmed cost", () => {
|
||||
const result = composeInventoryReply(
|
||||
"inventory_margin_ranking_for_nomenclature",
|
||||
|
|
@ -753,4 +820,87 @@ describe("address reply builders regressions", () => {
|
|||
expect(result?.text).not.toContain("сумма:");
|
||||
expect(result?.text).not.toContain("8.520,00 ₽");
|
||||
});
|
||||
|
||||
it("keeps supplier-overlap stock answer direct-first and compact on broad warehouse traces", () => {
|
||||
const rows = [
|
||||
{
|
||||
period: "2021-03-09",
|
||||
registrator: "Поступление товаров и услуг 00000000001",
|
||||
item: "Рабочая станция",
|
||||
warehouse: "Основной склад",
|
||||
counterparty: "А"
|
||||
},
|
||||
{
|
||||
period: "2020-11-20",
|
||||
registrator: "Поступление товаров и услуг 00000000030",
|
||||
item: "Зеркало",
|
||||
warehouse: "Основной склад",
|
||||
counterparty: "ВИЗАНТИЯ"
|
||||
},
|
||||
{
|
||||
period: "2020-07-13",
|
||||
registrator: "Поступление товаров и услуг 00000000029",
|
||||
item: "Стул",
|
||||
warehouse: "Основной склад",
|
||||
counterparty: "ИП Тучкова"
|
||||
},
|
||||
{
|
||||
period: "2019-01-10",
|
||||
registrator: "Поступление товаров и услуг 00000000077",
|
||||
item: "Позиция без поставщика",
|
||||
warehouse: "Основной склад"
|
||||
}
|
||||
] as any[];
|
||||
const result = composeInventoryReply(
|
||||
"inventory_supplier_stock_overlap_as_of_date",
|
||||
rows,
|
||||
{
|
||||
userMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад",
|
||||
asOfDate: "2021-09-30"
|
||||
},
|
||||
{
|
||||
resolvePayablesAsOfDate: () => "2021-09-30",
|
||||
buildInventoryOnHandAggregate: () => [],
|
||||
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
|
||||
formatDateRu: (value: string) => value,
|
||||
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
|
||||
formatMoneyRub: (value: number) => `${value} ₽`,
|
||||
isInventoryPurchaseMovement: () => true,
|
||||
summarizeInventoryTraceRows: (inputRows: any[]) => ({
|
||||
item: inputRows[0]?.item ?? null,
|
||||
warehouses: ["Основной склад"],
|
||||
organizations: [],
|
||||
counterparties: [],
|
||||
documents: inputRows.map((row) => String(row.registrator ?? "")),
|
||||
firstPeriod: "2019-01-10",
|
||||
lastPeriod: "2021-03-09",
|
||||
totalAmount: 0
|
||||
}),
|
||||
formatInventoryTraceRows: (inputRows: any[], limit = 10) =>
|
||||
inputRows.slice(0, limit).map((row, index) => {
|
||||
const parts = [`${index + 1}. ${row.registrator}`];
|
||||
if (row.counterparty) {
|
||||
parts.push(`контрагент: ${row.counterparty}`);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
}),
|
||||
hasInventoryPurchaseDateActionFocus: () => false,
|
||||
inventoryTraceDateLabel: (value: string | null) => String(value ?? ""),
|
||||
extractInventoryCounterpartyCandidates: () => [],
|
||||
buildInventoryAgingByItemAggregate: () => [],
|
||||
formatInventoryAgingRows: () => [],
|
||||
isInventorySaleMovement: () => false
|
||||
}
|
||||
);
|
||||
|
||||
const text = result?.text ?? "";
|
||||
const firstLine = text.split("\n")[0] ?? "";
|
||||
expect(firstLine).toContain("найдено несколько поставщиков закупочного следа");
|
||||
expect(firstLine).toContain("А");
|
||||
expect(firstLine).toContain("ВИЗАНТИЯ");
|
||||
expect(text.indexOf(firstLine)).toBeLessThan(text.indexOf("Что проверили:"));
|
||||
expect(text).toContain("однозначная атрибуция части текущего остатка не подтверждена: 1 закупочных операций без явно выделенного поставщика.");
|
||||
expect(text).not.toContain("Часть закупочных операций");
|
||||
expect(text.match(/^\d+\./gmu)?.length ?? 0).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,6 +100,126 @@ describe("assistant address lane response runtime adapter", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("repairs inventory supplier-overlap lead line before finalizing the user reply", () => {
|
||||
const finalizeAddressTurn = vi.fn(() => ({
|
||||
response: {
|
||||
ok: true
|
||||
}
|
||||
}));
|
||||
|
||||
const runtime = runAssistantAddressLaneResponseRuntime({
|
||||
sessionId: "asst-supplier-overlap",
|
||||
userMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
|
||||
effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
|
||||
addressLane: {
|
||||
handled: true,
|
||||
reply_text: [
|
||||
"По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.",
|
||||
"Что проверили:",
|
||||
"- Дата среза: 30.09.2021.",
|
||||
"Опорные документы:",
|
||||
"1. Поступление товаров и услуг 00000000001 | контрагент: А",
|
||||
"2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ",
|
||||
"3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова"
|
||||
].join("\n"),
|
||||
reply_type: "factual",
|
||||
debug: {
|
||||
extracted_filters: {
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-09-30"
|
||||
}
|
||||
}
|
||||
},
|
||||
knownOrganizations: [],
|
||||
activeOrganization: null,
|
||||
sanitizeOutgoingAssistantText: (text) =>
|
||||
String(text ?? "")
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => !line.includes("найдено несколько поставщиков закупочного следа"))
|
||||
.join("\n")
|
||||
.trim(),
|
||||
buildAddressDebugPayload: (addressDebug) => ({
|
||||
...(addressDebug as Record<string, unknown>),
|
||||
detected_intent: "inventory_supplier_stock_overlap_as_of_date"
|
||||
}),
|
||||
buildAddressFollowupOffer: () => null,
|
||||
mergeKnownOrganizations: (items) => Array.from(new Set(items)),
|
||||
toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null),
|
||||
appendItem: () => {},
|
||||
getSession: () => ({ session_id: "asst-supplier-overlap", updated_at: "", items: [], investigation_state: null } as any),
|
||||
persistSession: () => {},
|
||||
cloneConversation: (items) => items,
|
||||
logEvent: () => {},
|
||||
messageIdFactory: () => "msg-supplier-overlap",
|
||||
finalizeAddressTurn
|
||||
});
|
||||
|
||||
const assistantReply = (finalizeAddressTurn.mock.calls[0]?.[0] as any)?.assistantReply ?? "";
|
||||
const nonEmptyLines = assistantReply.split("\n").filter((line: string) => line.trim().length > 0);
|
||||
expect(nonEmptyLines[0]).toContain("найдено несколько поставщиков закупочного следа");
|
||||
expect(nonEmptyLines[0]).toContain("А");
|
||||
expect(nonEmptyLines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена");
|
||||
expect(runtime.debug.inventory_supplier_overlap_surface_repair_v1).toEqual(
|
||||
expect.objectContaining({
|
||||
applied: true,
|
||||
lead_preserved_after_sanitize: true,
|
||||
counterparties: ["А", "ВИЗАНТИЯ", "ИП Тучкова"]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("persists inventory root frame filters with a single warehouse derived from the factual reply", () => {
|
||||
const runtime = runAssistantAddressLaneResponseRuntime({
|
||||
sessionId: "asst-inventory-root",
|
||||
userMessage: "какие остатки на складе на сентябрь 2021",
|
||||
effectiveAddressUserMessage: "какие остатки на складе на сентябрь 2021",
|
||||
addressLane: {
|
||||
handled: true,
|
||||
reply_text:
|
||||
'На 30.09.2021 по складу Основной склад подтверждено 11 позиций.\n1. Диван | склад: Основной склад | организация: ООО "Альтернатива Плюс"',
|
||||
reply_type: "factual",
|
||||
debug: {
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2021-09-30",
|
||||
period_from: "2021-09-01",
|
||||
period_to: "2021-09-30"
|
||||
}
|
||||
}
|
||||
},
|
||||
knownOrganizations: ['ООО "Альтернатива Плюс"'],
|
||||
activeOrganization: 'ООО "Альтернатива Плюс"',
|
||||
sanitizeOutgoingAssistantText: (text) => String(text ?? "").trim(),
|
||||
buildAddressDebugPayload: (addressDebug) => ({ ...(addressDebug as Record<string, unknown>) }),
|
||||
buildAddressFollowupOffer: () => null,
|
||||
mergeKnownOrganizations: (items) => Array.from(new Set(items)),
|
||||
toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null),
|
||||
appendItem: () => {},
|
||||
getSession: () => ({ session_id: "asst-inventory-root", updated_at: "", items: [], investigation_state: null } as any),
|
||||
persistSession: () => {},
|
||||
cloneConversation: (items) => items,
|
||||
logEvent: () => {},
|
||||
messageIdFactory: () => "msg-inventory-root",
|
||||
finalizeAddressTurn: () => ({ response: { ok: true } as any })
|
||||
});
|
||||
|
||||
expect(runtime.debug.address_root_frame_context).toEqual(
|
||||
expect.objectContaining({
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
current_frame_kind: "inventory_root",
|
||||
warehouse: "Основной склад",
|
||||
root_filters: expect.objectContaining({
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-09-30",
|
||||
period_from: "2021-09-01",
|
||||
period_to: "2021-09-30"
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches MCP discovery summary from predecompose runtime meta without changing the reply", () => {
|
||||
const runtime = runAssistantAddressLaneResponseRuntime({
|
||||
sessionId: "asst-mcp",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
applyReferencedEntityCarryover,
|
||||
applySelectedItemCarryover,
|
||||
applyTemporalCarryoverFilters,
|
||||
buildInventoryRootFrameFromAddressDebug,
|
||||
buildRootScopedCarryoverFilters,
|
||||
hydrateInventoryRootFrameState,
|
||||
readAddressDebugCounterparty,
|
||||
|
|
@ -479,6 +480,35 @@ describe("assistantContinuityPolicy organization authority", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("builds inventory root frame from compact root-frame context without leaking drilldown item scope", () => {
|
||||
const frame = buildInventoryRootFrameFromAddressDebug({
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item: "Четки Пост (84*117)",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2021-09-30"
|
||||
},
|
||||
address_root_frame_context: {
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-09-30",
|
||||
period_from: "2021-09-01",
|
||||
period_to: "2021-09-30",
|
||||
current_frame_kind: "inventory_drilldown"
|
||||
}
|
||||
});
|
||||
|
||||
expect(frame?.filters).toEqual({
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-09-30",
|
||||
period_from: "2021-09-01",
|
||||
period_to: "2021-09-30"
|
||||
});
|
||||
expect(frame?.filters).not.toHaveProperty("item");
|
||||
});
|
||||
|
||||
it("does not carry MCP discovery counterparty from a stale replay-forbidden turn", () => {
|
||||
const filters = resolveAddressDebugCarryoverFilters({
|
||||
detected_intent: "payables_confirmed_as_of_date",
|
||||
|
|
|
|||
|
|
@ -439,6 +439,108 @@ describe("assistant MCP discovery response policy", () => {
|
|||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
|
||||
});
|
||||
|
||||
it("keeps exact inventory purchase-to-sale replies over stale value-flow clarification candidates", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply:
|
||||
"По товару Шкаф картотечный 1000*400*2100 закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.",
|
||||
currentReplySource: "address_query_runtime_v1",
|
||||
currentReplyType: "factual",
|
||||
addressRuntimeMeta: {
|
||||
detected_intent: "inventory_purchase_to_sale_chain",
|
||||
selected_recipe: "address_inventory_purchase_to_sale_chain_v1",
|
||||
mcp_call_status: "matched_non_empty",
|
||||
truth_mode: "confirmed",
|
||||
capability_binding_status: "bound",
|
||||
capability_binding_violations: [],
|
||||
answer_shape_contract: {
|
||||
reply_type: "factual",
|
||||
capability_contract_id: "inventory_inventory_purchase_to_sale_chain"
|
||||
},
|
||||
assistant_mcp_discovery_entry_point_v1: entryPoint({
|
||||
bridge: {
|
||||
bridge_status: "needs_clarification",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: false,
|
||||
requires_user_clarification: true,
|
||||
answer_draft: {
|
||||
answer_mode: "clarification_required",
|
||||
headline:
|
||||
"Коротко: Могу посчитать общий денежный поток в проверяемом окне, но для проверяемого поиска в 1С нужен проверяемый период.",
|
||||
confirmed_lines: [],
|
||||
inference_lines: [],
|
||||
unknown_lines: [],
|
||||
limitation_lines: [],
|
||||
next_step_line: "Уточните период, и я продолжу поиск по денежному потоку в 1С."
|
||||
}
|
||||
},
|
||||
turn_input: {
|
||||
adapter_status: "ready",
|
||||
should_run_discovery: true,
|
||||
turn_meaning_ref: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "payout",
|
||||
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
|
||||
},
|
||||
data_need_graph: {
|
||||
business_fact_family: "value_flow",
|
||||
action_family: "payout",
|
||||
clarification_gaps: ["period"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.decision).toBe("keep_current_reply");
|
||||
expect(result.reply_text).toContain("закупочная часть цепочки подтверждена");
|
||||
expect(result.reply_text).not.toContain("денежный поток");
|
||||
expect(result.reason_codes).toContain(
|
||||
"mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification"
|
||||
);
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied");
|
||||
});
|
||||
|
||||
it("keeps confirmed supplier-overlap address replies over entity-resolution discovery candidates", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply:
|
||||
'По складскому остатку Основной склад на 30.09.2021 выявлен поставщик закупочного следа: Торговый дом "Союз МСК".',
|
||||
currentReplySource: "address_query_runtime_v1",
|
||||
currentReplyType: "factual",
|
||||
addressRuntimeMeta: {
|
||||
detected_intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||
selected_recipe: "address_inventory_supplier_stock_overlap_as_of_date_v1",
|
||||
mcp_call_status: "matched_non_empty",
|
||||
truth_mode: "confirmed",
|
||||
capability_binding_status: "bound",
|
||||
capability_binding_violations: [],
|
||||
answer_shape_contract: {
|
||||
reply_type: "factual",
|
||||
capability_contract_id: "inventory_inventory_supplier_stock_overlap_as_of_date"
|
||||
},
|
||||
assistant_mcp_discovery_entry_point_v1: entryPoint({
|
||||
turn_input: {
|
||||
adapter_status: "ready",
|
||||
should_run_discovery: true,
|
||||
turn_meaning_ref: {
|
||||
asked_domain_family: "entity_resolution",
|
||||
asked_action_family: "search_business_entity",
|
||||
unsupported_but_understood_family: "entity_resolution",
|
||||
explicit_entity_candidates: ['Торговый дом "Союз"']
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.decision).toBe("keep_current_reply");
|
||||
expect(result.reply_text).toContain("выявлен поставщик закупочного следа");
|
||||
expect(result.reply_text).not.toContain("Confirmed fact");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
|
||||
});
|
||||
|
||||
it("lets metadata discovery override a stale exact address answer outside protected inventory chains", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "РРџ Калинин Рќ.Рњ. | СЃСѓРјРјР°: 216600",
|
||||
|
|
|
|||
|
|
@ -336,6 +336,38 @@ describe("assistantTurnMeaningPolicy", () => {
|
|||
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
rawUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
|
||||
effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад"
|
||||
},
|
||||
{
|
||||
rawUserMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад",
|
||||
effectiveAddressUserMessage:
|
||||
"Определить поставщика, с которого получен текущий товарный остаток на складе Основной склад"
|
||||
},
|
||||
{
|
||||
rawUserMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад",
|
||||
effectiveAddressUserMessage:
|
||||
"Найти товары, которые находятся на складе Основной склад и были поставлены поставщиком Торговый дом \\Союз"
|
||||
}
|
||||
])("keeps supplier stock-overlap wording in the exact inventory lane", ({ rawUserMessage, effectiveAddressUserMessage }) => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
const meaning = policy.resolveAssistantTurnMeaning({
|
||||
rawUserMessage,
|
||||
effectiveAddressUserMessage
|
||||
});
|
||||
|
||||
expect(meaning.explicit_intent_candidate).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||
expect(meaning.asked_domain_family).toBe("inventory");
|
||||
expect(meaning.asked_action_family).toBe("supplier_overlap");
|
||||
expect(meaning.unsupported_but_understood_family).toBeNull();
|
||||
expect(meaning.stale_replay_forbidden).toBe(false);
|
||||
expect(meaning.reason_codes).toContain("address_intent_resolver_current_turn_signal");
|
||||
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
|
||||
});
|
||||
|
||||
it("treats paired receivables/payables position as business overview instead of one debt side", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
|
|
|
|||
|
|
@ -4894,6 +4894,12 @@ def build_deterministic_repair_targets(
|
|||
"execution_status": pack_state.get("execution_status"),
|
||||
"acceptance_status": pack_state.get("acceptance_status"),
|
||||
"final_status": pack_state.get("final_status"),
|
||||
"coverage_mode": pack_state.get("coverage_mode"),
|
||||
"max_scenarios": pack_state.get("max_scenarios"),
|
||||
"total_scenario_count": pack_state.get("total_scenario_count"),
|
||||
"selected_scenario_count": pack_state.get("selected_scenario_count"),
|
||||
"selected_scenario_ids": pack_state.get("selected_scenario_ids") or [],
|
||||
"unexecuted_scenario_ids": pack_state.get("unexecuted_scenario_ids") or [],
|
||||
"target_count": len(targets),
|
||||
"severity_counts": severity_counts,
|
||||
"priority_foci": priority_foci,
|
||||
|
|
@ -5206,6 +5212,22 @@ def analyst_target_contradicts_pack_acceptance_status(
|
|||
)
|
||||
|
||||
|
||||
def analyst_target_outside_limited_slice(
|
||||
repair_targets: dict[str, Any],
|
||||
target: dict[str, Any],
|
||||
) -> bool:
|
||||
coverage_mode = str(repair_targets.get("coverage_mode") or "").strip()
|
||||
if coverage_mode != "limited_slice":
|
||||
return False
|
||||
unexecuted_scenario_ids = {
|
||||
str(item or "").strip()
|
||||
for item in normalize_string_list(repair_targets.get("unexecuted_scenario_ids"))
|
||||
if str(item or "").strip()
|
||||
}
|
||||
scenario_id = str(target.get("scenario_id") or "").strip()
|
||||
return bool(scenario_id and scenario_id in unexecuted_scenario_ids)
|
||||
|
||||
|
||||
def merge_analyst_priority_repair_targets(
|
||||
repair_targets: dict[str, Any],
|
||||
analyst_verdict: dict[str, Any],
|
||||
|
|
@ -5239,6 +5261,19 @@ def merge_analyst_priority_repair_targets(
|
|||
analyst_count += 1
|
||||
target_id = str(analyst_target.get("target_id") or "").strip()
|
||||
step_snapshot = step_validation_index.get(target_id) if isinstance(step_validation_index, dict) else None
|
||||
if analyst_target_outside_limited_slice(repair_targets, analyst_target):
|
||||
suppressed_analyst_targets.append(
|
||||
{
|
||||
"target_id": target_id,
|
||||
"reason": "analyst_target_outside_limited_slice",
|
||||
"coverage": {
|
||||
"coverage_mode": repair_targets.get("coverage_mode"),
|
||||
"selected_scenario_ids": repair_targets.get("selected_scenario_ids") or [],
|
||||
"unexecuted_scenario_ids": repair_targets.get("unexecuted_scenario_ids") or [],
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
if analyst_target_contradicts_validated_step(analyst_target, step_snapshot):
|
||||
suppressed_analyst_targets.append(
|
||||
{
|
||||
|
|
@ -5733,6 +5768,10 @@ def build_analyst_loop_prompt(
|
|||
Rules:
|
||||
- `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.acceptance_status != accepted` (or, for old artifacts without `acceptance_status`, `pack_state.final_status != accepted`) or the deterministic repair targets still contain any `P0` or `P1` items;
|
||||
- if `pack_state.coverage_mode = limited_slice`, judge only `pack_state.selected_scenario_ids` and the scenario artifacts that actually exist in the evidence bundle; do not create priority targets, broken edges, or P0/P1 failures for `pack_state.unexecuted_scenario_ids` merely because they were intentionally outside `--max-scenarios`;
|
||||
- for a limited slice, mention missing scenarios only as a coverage limitation / next replay scope, not as a semantic defect, unless an executed step actually proves a cross-scenario regression;
|
||||
- for a limited slice, `pack_state.acceptance_status=partial` can be caused solely by the intentional scenario limit; do not lower `direct_answer_ok`, `business_usefulness_ok`, `temporal_honesty_ok`, or `field_truth_ok` because unexecuted scenarios are absent;
|
||||
- for a limited slice, set `direct_answer_ok`, `business_usefulness_ok`, `temporal_honesty_ok`, and `field_truth_ok` from the executed selected scenarios only; if those answers are semantically clean, these booleans should be true even when `loop_decision` remains `partial` for full-pack coverage;
|
||||
- `pack_state.final_status=partial` with `acceptance_status=accepted` means the run still had partial execution evidence; treat it as a proof-strength signal, not as a presentation bug by itself;
|
||||
- do not create a priority target solely to change `pack_state.final_status=partial` when `pack_state.acceptance_status=accepted`; only create a pack-status target if `acceptance_status` is missing/not accepted or if the user-facing scenario semantics are actually wrong;
|
||||
- `accepted` also requires `direct_answer_ok = true`, `business_usefulness_ok = true`, `temporal_honesty_ok = true`, and `field_truth_ok = true`;
|
||||
|
|
@ -6585,6 +6624,21 @@ def handle_run_pack(args: argparse.Namespace) -> int:
|
|||
scenario_results: list[dict[str, Any]] = []
|
||||
max_scenarios = max(0, int(args.max_scenarios)) if args.max_scenarios is not None else None
|
||||
scenarios_to_run = pack["scenarios"][:max_scenarios] if max_scenarios else pack["scenarios"]
|
||||
total_scenario_count = len(pack.get("scenarios") or [])
|
||||
selected_scenario_ids = [
|
||||
str(scenario.get("scenario_id") or "").strip()
|
||||
for scenario in scenarios_to_run
|
||||
if isinstance(scenario, dict) and str(scenario.get("scenario_id") or "").strip()
|
||||
]
|
||||
selected_scenario_id_set = set(selected_scenario_ids)
|
||||
unexecuted_scenario_ids = [
|
||||
str(scenario.get("scenario_id") or "").strip()
|
||||
for scenario in pack.get("scenarios", [])
|
||||
if isinstance(scenario, dict)
|
||||
and str(scenario.get("scenario_id") or "").strip()
|
||||
and str(scenario.get("scenario_id") or "").strip() not in selected_scenario_id_set
|
||||
]
|
||||
coverage_mode = "limited_slice" if len(selected_scenario_ids) < total_scenario_count else "full_pack"
|
||||
for scenario in scenarios_to_run:
|
||||
scenario_manifest = normalize_scenario_manifest(
|
||||
scenario,
|
||||
|
|
@ -6622,6 +6676,12 @@ def handle_run_pack(args: argparse.Namespace) -> int:
|
|||
"title": pack["title"],
|
||||
"analysis_context": pack.get("analysis_context") or {},
|
||||
"bindings": pack.get("bindings") or {},
|
||||
"coverage_mode": coverage_mode,
|
||||
"max_scenarios": max_scenarios,
|
||||
"total_scenario_count": total_scenario_count,
|
||||
"selected_scenario_count": len(selected_scenario_ids),
|
||||
"selected_scenario_ids": selected_scenario_ids,
|
||||
"unexecuted_scenario_ids": unexecuted_scenario_ids,
|
||||
"scenario_results": scenario_results,
|
||||
"execution_status": execution_status,
|
||||
"acceptance_status": acceptance_status,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from pathlib import Path
|
|||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from scripts.domain_case_loop import (
|
||||
build_analyst_loop_prompt,
|
||||
build_coder_loop_prompt,
|
||||
build_coder_snapshot_paths,
|
||||
build_deterministic_repair_targets,
|
||||
|
|
@ -18,6 +19,7 @@ from scripts.domain_case_loop import (
|
|||
evaluate_deterministic_loop_gate,
|
||||
load_scenario_pack,
|
||||
load_shared_local_llm_defaults,
|
||||
merge_analyst_priority_repair_targets,
|
||||
merge_scenario_date_scope,
|
||||
select_primary_repair_focus,
|
||||
restore_line_collapsed_files_from_snapshot,
|
||||
|
|
@ -840,7 +842,7 @@ def test_evaluate_deterministic_loop_gate_rejects_partial_pack_even_without_targ
|
|||
)
|
||||
|
||||
assert gate_ok is False
|
||||
assert reason == "pack_final_status=partial"
|
||||
assert reason == "pack_acceptance_status=partial;pack_final_status=partial"
|
||||
|
||||
|
||||
def test_evaluate_deterministic_loop_gate_rejects_remaining_p1_targets() -> None:
|
||||
|
|
@ -863,6 +865,62 @@ def test_evaluate_deterministic_loop_gate_accepts_clean_pack_without_remaining_p
|
|||
assert reason == "deterministic_gate_passed"
|
||||
|
||||
|
||||
def test_build_analyst_loop_prompt_treats_limited_slice_as_coverage_scope(tmp_path: Path) -> None:
|
||||
prompt = build_analyst_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" / "repair_targets.json",
|
||||
previous_pack_dir=None,
|
||||
previous_verdict_path=None,
|
||||
target_score=90,
|
||||
review_bundle_json=json.dumps(
|
||||
{
|
||||
"pack_state": {
|
||||
"coverage_mode": "limited_slice",
|
||||
"selected_scenario_ids": ["inventory_snapshot_roots"],
|
||||
"unexecuted_scenario_ids": ["inventory_selected_item_provenance"],
|
||||
"acceptance_status": "partial",
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
repair_targets_json=json.dumps({"targets": []}),
|
||||
previous_verdict_json=None,
|
||||
)
|
||||
|
||||
assert "pack_state.coverage_mode = limited_slice" in prompt
|
||||
assert "do not create priority targets" in prompt
|
||||
assert "pack_state.unexecuted_scenario_ids" in prompt
|
||||
|
||||
|
||||
def test_merge_analyst_priority_targets_suppresses_unexecuted_limited_slice_scenarios() -> None:
|
||||
merged = merge_analyst_priority_repair_targets(
|
||||
{
|
||||
"coverage_mode": "limited_slice",
|
||||
"selected_scenario_ids": ["inventory_snapshot_roots"],
|
||||
"unexecuted_scenario_ids": ["inventory_selected_item_provenance"],
|
||||
"targets": [],
|
||||
"step_validation_index": {},
|
||||
},
|
||||
{
|
||||
"priority_targets": [
|
||||
{
|
||||
"scenario_id": "inventory_selected_item_provenance",
|
||||
"step_id": "step_02_selected_item_supplier_ui",
|
||||
"severity": "P1",
|
||||
"problem_type": "loop_coverage_gap",
|
||||
"fix_goal": "run the selected item scenario",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert merged["targets"] == []
|
||||
assert merged["suppressed_analyst_priority_target_count"] == 1
|
||||
assert merged["suppressed_analyst_priority_targets"][0]["reason"] == "analyst_target_outside_limited_slice"
|
||||
|
||||
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue