ОРРКЕСТРАЦИЯ - Усилить agent loop object-centric аудитом и добить pronoun follow-up по документам закупки

This commit is contained in:
2026-04-14 18:47:38 +03:00
parent cb0eb450d7
commit 97b2a9b028
19 changed files with 469 additions and 44 deletions
@@ -1341,7 +1341,7 @@ function hasInventorySaleTraceSignal(text) {
return /(?:продаж|покупател|buyer|sale trace|purchase[\s-]?to[\s-]?sale|purchase -> warehouse -> sale|закупка.*продаж)/iu.test(text);
}
function hasSelectedObjectInventoryCue(text) {
return /(?:по\s+выбранному\s+объекту|selected\s+object)/iu.test(text);
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+нему|по\s+ней|по\s+нему\s+же|по\s+ней\s+же|selected\s+object)/iu.test(text);
}
function hasSelectedObjectInventoryProvenanceSignal(text) {
return (hasSelectedObjectInventoryCue(text) &&
@@ -3107,7 +3107,13 @@ function composeFactualReply(intent, rows, options = {}) {
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine = summary.counterparties.length === 1
? `По товару ${itemLabel} документы поступления связаны с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} документы поступления ведут к нескольким поставщикам: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найдены документы поступления, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const lines = [
directAnswerLine,
`Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -3191,7 +3197,13 @@ function composeFactualReply(intent, rows, options = {}) {
const summary = summarizeInventoryTraceRows(purchaseRows);
const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0);
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} поставщик в текущем exact-контуре не материализован.`;
const lines = [
directAnswerLine,
`Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -3287,7 +3299,13 @@ function composeFactualReply(intent, rows, options = {}) {
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const summary = summarizeInventoryTraceRows(saleRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine = summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`;
const lines = [
directAnswerLine,
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -3332,7 +3350,11 @@ function composeFactualReply(intent, rows, options = {}) {
const purchaseSummary = summarizeInventoryTraceRows(purchaseRows);
const saleSummary = summarizeInventoryTraceRows(saleRows);
const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен";
const directAnswerLine = purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
const lines = [
directAnswerLine,
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -260,7 +260,7 @@ function hasInventorySupplierFollowupCue(text) {
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|supplier|vendor|поставщик)/iu.test(String(text ?? ""));
}
function hasInventoryPurchaseDocumentsFollowupCue(text) {
return /(?:по\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(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+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(String(text ?? ""));
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
@@ -328,6 +328,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const previousItem = toNonEmptyString(previous.item);
const previousOrganization = toNonEmptyString(previous.organization);
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
@@ -440,6 +441,16 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
if ((intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item) &&
previousItem) {
merged.item = previousItem;
reasons.push("item_from_followup_context");
}
if (sameDateRequested) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
@@ -1604,7 +1604,9 @@ function hasInventorySaleTraceSignal(text: string): boolean {
}
function hasSelectedObjectInventoryCue(text: string): boolean {
return /(?:по\s+выбранному\s+объекту|selected\s+object)/iu.test(text);
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+нему|по\s+ней|по\s+нему\s+же|по\s+ней\s+же|selected\s+object)/iu.test(
text
);
}
function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean {
@@ -4020,7 +4020,14 @@ export function composeFactualReply(
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine =
summary.counterparties.length === 1
? `По товару ${itemLabel} документы поступления связаны с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} документы поступления ведут к нескольким поставщикам: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найдены документы поступления, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const lines: string[] = [
directAnswerLine,
`Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -4105,7 +4112,14 @@ export function composeFactualReply(
const summary = summarizeInventoryTraceRows(purchaseRows);
const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0);
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} поставщик в текущем exact-контуре не материализован.`;
const lines: string[] = [
directAnswerLine,
`Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -4201,7 +4215,14 @@ export function composeFactualReply(
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const summary = summarizeInventoryTraceRows(saleRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine =
summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`;
const lines: string[] = [
directAnswerLine,
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -4244,7 +4265,12 @@ export function composeFactualReply(
const purchaseSummary = summarizeInventoryTraceRows(purchaseRows);
const saleSummary = summarizeInventoryTraceRows(saleRows);
const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен";
const directAnswerLine =
purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
const lines: string[] = [
directAnswerLine,
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -329,7 +329,7 @@ function hasInventorySupplierFollowupCue(text: string): boolean {
}
function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
return /(?:по\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(
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+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
String(text ?? "")
);
}
@@ -424,6 +424,7 @@ function mergeFollowupFilters(
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const previousItem = toNonEmptyString(previous.item);
const previousOrganization = toNonEmptyString(previous.organization);
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
@@ -554,6 +555,18 @@ function mergeFollowupFilters(
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
if (
(intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item) &&
previousItem
) {
merged.item = previousItem;
reasons.push("item_from_followup_context");
}
if (sameDateRequested) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
@@ -293,6 +293,7 @@ describe("address query shape classifier", () => {
useRubCurrency: true
}
);
expect(reply.text.split("\n")[0]).toContain("поставщиком");
expect(reply.text).toContain("Шкаф картотечный");
expect(reply.text).toContain("Поступление товаров и услуг 0001");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
@@ -319,6 +320,7 @@ describe("address query shape classifier", () => {
useRubCurrency: true
}
);
expect(reply.text.split("\n")[0]).toContain("поставщиком");
expect(reply.text).toContain("закупочный след");
expect(reply.text).toContain("Гамма-мебель, ООО");
expect(reply.semantics?.balance_confirmed).toBe(true);
@@ -345,6 +347,7 @@ describe("address query shape classifier", () => {
useRubCurrency: true
}
);
expect(reply.text.split("\n")[0]).toContain("покупатель");
expect(reply.text).toContain("след выбытия");
expect(reply.text).toContain("Реализация товаров и услуг 0007");
expect(reply.text).toContain("Департамент капитального ремонта города Москвы");
@@ -3947,6 +3950,28 @@ describe("address decompose stage follow-up carryover", () => {
).toBe(true);
});
it("promotes pronoun selected-item purchase-doc follow-up into inventory purchase documents with inherited date context", () => {
const result = runAddressDecomposeStage('покажи документы по этой позиции', {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
as_of_date: "2019-03-31",
period_from: "2019-03-01",
period_to: "2019-03-31",
item: "Столешница 600*3050*26 дуб ниагара"
},
previous_anchor_type: "unknown",
previous_anchor_value: 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 дуб ниагара");
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-03-31");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_purchase_documents_signal_detected")
).toBe(true);
});
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
const result = runAddressDecomposeStage("выведи всех заков за все время", null);
expect(result).not.toBeNull();