Починить Hm-прогон ассистента по НДС, остаткам и MCP-ответам
This commit is contained in:
@@ -2777,106 +2777,11 @@ export function buildAutoRunsRouter(services: AppServices, openaiClient = new Op
|
||||
|
||||
router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
|
||||
try {
|
||||
const body = toRecord(req.body);
|
||||
if (!body) {
|
||||
throw new ApiError("INVALID_AUTOGEN_PAYLOAD", "JSON body is required", 400);
|
||||
}
|
||||
const mode = parseAutoGenMode(body.mode);
|
||||
const count = parseAutogenCount(body.count);
|
||||
const domain = parseAutogenDomain(body.domain);
|
||||
const persistCaseSet = toBooleanSafe(body.persist_to_eval_cases) ?? true;
|
||||
const generatedBy = parseAnnotationAuthor(body.generated_by);
|
||||
const context = toRecord(body.context);
|
||||
const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
|
||||
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
|
||||
|
||||
if (mode === "saved_user_sessions") {
|
||||
throw new ApiError(
|
||||
"AUTOGEN_MODE_NOT_SUPPORTED",
|
||||
"Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
let questions: string[] = [];
|
||||
if (mode === "qwen_seed") {
|
||||
if (!llmConfig) {
|
||||
throw new ApiError(
|
||||
"AUTOGEN_LLM_CONFIG_REQUIRED",
|
||||
"Для режима qwen_seed нужен активный LLM-контур (provider/model/baseUrl) из настроек подключения.",
|
||||
400
|
||||
);
|
||||
}
|
||||
questions = await generateQwenSeedQuestionsLive({
|
||||
count,
|
||||
domain,
|
||||
personalityPrompt,
|
||||
llmConfig,
|
||||
client: openaiClient
|
||||
});
|
||||
} else {
|
||||
questions = generateCodexCreativeQuestions(count, domain);
|
||||
}
|
||||
questions = Array.from(new Set(questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0))).slice(
|
||||
0,
|
||||
count
|
||||
throw new ApiError(
|
||||
"AUTOGEN_QUESTION_GENERATION_DISABLED",
|
||||
"Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.",
|
||||
410
|
||||
);
|
||||
const generationId = generateAutogenId();
|
||||
|
||||
let savedCaseSetFile: string | null = null;
|
||||
if (persistCaseSet) {
|
||||
if (!fs.existsSync(EVAL_CASES_DIR)) {
|
||||
fs.mkdirSync(EVAL_CASES_DIR, { recursive: true });
|
||||
}
|
||||
const fileName = buildAutogenCaseSetFileName(mode, generationId);
|
||||
const filePath = path.resolve(EVAL_CASES_DIR, fileName);
|
||||
const payload = buildAutogenCaseSetPayload({
|
||||
generationId,
|
||||
mode,
|
||||
domain,
|
||||
questions
|
||||
});
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
savedCaseSetFile = fileName;
|
||||
}
|
||||
|
||||
const record: AutoGenHistoryRecord = {
|
||||
generation_id: generationId,
|
||||
created_at: new Date().toISOString(),
|
||||
mode,
|
||||
title: null,
|
||||
count: questions.length,
|
||||
domain,
|
||||
questions,
|
||||
generated_by: generatedBy,
|
||||
saved_case_set_file: savedCaseSetFile,
|
||||
context: context
|
||||
? {
|
||||
llm_provider: toStringSafe(context.llm_provider),
|
||||
model: toStringSafe(context.model),
|
||||
assistant_prompt_version: toStringSafe(context.assistant_prompt_version),
|
||||
decomposition_prompt_version: toStringSafe(context.decomposition_prompt_version),
|
||||
prompt_fingerprint: toStringSafe(context.prompt_fingerprint)
|
||||
? repairAutogenMojibake(String(context.prompt_fingerprint))
|
||||
: null,
|
||||
autogen_personality_id: toStringSafe(context.autogen_personality_id),
|
||||
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
|
||||
? repairAutogenMojibake(String(context.autogen_personality_prompt))
|
||||
: null,
|
||||
source_session_id: null,
|
||||
saved_session_file: null,
|
||||
saved_case_set_kind: "single_turn_list"
|
||||
}
|
||||
: null
|
||||
};
|
||||
const history = readAutoGenHistory();
|
||||
history.unshift(record);
|
||||
writeAutoGenHistory(history.slice(0, 500));
|
||||
|
||||
ok(res, {
|
||||
ok: true,
|
||||
generation: record
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -3944,6 +3944,18 @@ export class AddressQueryService {
|
||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
requestedResultMode,
|
||||
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
|
||||
purchaseDateBridge:
|
||||
typeof filterSet.purchase_date_bridge_selected === "string" ||
|
||||
typeof filterSet.purchase_date_bridge_first === "string" ||
|
||||
typeof filterSet.purchase_date_bridge_last === "string"
|
||||
? {
|
||||
selectedPurchaseDate: filterSet.purchase_date_bridge_selected,
|
||||
firstPurchaseDate: filterSet.purchase_date_bridge_first,
|
||||
lastPurchaseDate: filterSet.purchase_date_bridge_last,
|
||||
basis: filterSet.purchase_date_bridge_basis,
|
||||
hasMultiplePurchaseDates: filterSet.purchase_date_bridge_has_multiple === true
|
||||
}
|
||||
: undefined,
|
||||
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
|
||||
useRubCurrency: options.useRubCurrency ?? undefined
|
||||
});
|
||||
|
||||
@@ -1520,6 +1520,91 @@ function buildInventoryMovementQuery(
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
}
|
||||
|
||||
function buildStaticWhereClause(conditions: Array<string | null | undefined>, indent = " "): string {
|
||||
const cleaned = conditions.map((condition) => String(condition ?? "").trim()).filter((condition) => condition.length > 0);
|
||||
return cleaned.length > 0 ? `ГДЕ\n${indent}${cleaned.join(`\n${indent}И `)}` : "";
|
||||
}
|
||||
|
||||
function resolveInventoryAgingAsOfExpr(filters: AddressFilterSet): string {
|
||||
const now = new Date();
|
||||
return (
|
||||
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
||||
? toDateTimeExpr(filters.as_of_date, true)
|
||||
: null) ??
|
||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_to, true)
|
||||
: null) ??
|
||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_from, true)
|
||||
: null) ??
|
||||
`ДАТАВРЕМЯ(${now.getFullYear()}, ${now.getMonth() + 1}, ${now.getDate()}, 23, 59, 59)`
|
||||
);
|
||||
}
|
||||
|
||||
function buildInventoryAgingByPurchaseDateQuery(filters: AddressFilterSet, resolvedLimit: number): string {
|
||||
const asOfExpr = resolveInventoryAgingAsOfExpr(filters);
|
||||
const inventoryAccountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]);
|
||||
const onHandScopeConditions = [
|
||||
"Остатки.КоличествоРазвернутыйОстатокДт > 0",
|
||||
inventoryAccountPredicate,
|
||||
buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]),
|
||||
buildWarehouseReferenceCondition(filters, ["Остатки.Субконто3"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
|
||||
];
|
||||
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
|
||||
const currentStockItemSubquery = [
|
||||
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
|
||||
" Остатки.Субконто1",
|
||||
" ИЗ",
|
||||
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
|
||||
buildStaticWhereClause(onHandScopeConditions, " "),
|
||||
")"
|
||||
].join("\n");
|
||||
const purchaseWhereClause = buildStaticWhereClause([
|
||||
"Товары.Ссылка.Проведен = ИСТИНА",
|
||||
`Товары.Ссылка.Дата <= ${asOfExpr}`,
|
||||
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]),
|
||||
`Товары.Номенклатура В ${currentStockItemSubquery}`
|
||||
]);
|
||||
|
||||
return `
|
||||
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
|
||||
${asOfExpr} КАК Период,
|
||||
"Остатки на дату" КАК Регистратор,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура,
|
||||
"" КАК Контрагент,
|
||||
"" КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад,
|
||||
Остатки.КоличествоРазвернутыйОстатокДт КАК Количество
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки
|
||||
${onHandWhereClause}
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
|
||||
Товары.Ссылка.Дата КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
|
||||
"41.01" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Товары.Сумма КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
|
||||
"" КАК Склад,
|
||||
Товары.Количество КАК Количество
|
||||
ИЗ
|
||||
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
|
||||
${purchaseWhereClause}
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Период ВОЗР
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function buildWarehouseReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
|
||||
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
|
||||
if (!warehouse) {
|
||||
@@ -2199,7 +2284,7 @@ export function buildAddressRecipePlan(
|
||||
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
|
||||
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
|
||||
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||
? buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "inventory_quality_events_profile"
|
||||
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "contracts_by_counterparty_profile"
|
||||
|
||||
@@ -743,12 +743,42 @@ function needsVatPurchaseDateAnchorDisclosure(userMessage: string | null | undef
|
||||
);
|
||||
}
|
||||
|
||||
function buildVatPurchaseDateAnchorDisclosureLine(
|
||||
function vatPurchaseDateBasisLabel(basis: string | null | undefined, hasMultiplePurchaseDates: boolean): string {
|
||||
if (!hasMultiplePurchaseDates) {
|
||||
return "подтвержденную дату закупки";
|
||||
}
|
||||
if (basis === "last_confirmed_purchase") {
|
||||
return "последнюю подтвержденную дату закупки";
|
||||
}
|
||||
return "первую подтвержденную дату закупки";
|
||||
}
|
||||
|
||||
function buildVatPurchaseDateAnchorDisclosureLines(
|
||||
options: ComposeFactualReplyOptions,
|
||||
periodWindowLabel: string | null
|
||||
): string | null {
|
||||
if (!periodWindowLabel || !needsVatPurchaseDateAnchorDisclosure(options.userMessage)) {
|
||||
return null;
|
||||
): string[] {
|
||||
const bridge = options.purchaseDateBridge ?? null;
|
||||
const selectedPurchaseDate = normalizeIsoDateOnly(bridge?.selectedPurchaseDate);
|
||||
const firstPurchaseDate = normalizeIsoDateOnly(bridge?.firstPurchaseDate);
|
||||
const lastPurchaseDate = normalizeIsoDateOnly(bridge?.lastPurchaseDate);
|
||||
const hasMultiplePurchaseDates = Boolean(
|
||||
bridge?.hasMultiplePurchaseDates || (firstPurchaseDate && lastPurchaseDate && firstPurchaseDate !== lastPurchaseDate)
|
||||
);
|
||||
if (!periodWindowLabel || (!selectedPurchaseDate && !needsVatPurchaseDateAnchorDisclosure(options.userMessage))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (selectedPurchaseDate) {
|
||||
const basisLabel = vatPurchaseDateBasisLabel(bridge?.basis, hasMultiplePurchaseDates);
|
||||
const lines = [
|
||||
`- Якорь периода: для расчета я использую ${basisLabel} ${formatDateRu(selectedPurchaseDate)}; налоговый период ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
if (hasMultiplePurchaseDates && firstPurchaseDate && lastPurchaseDate) {
|
||||
lines.push(
|
||||
`- Важно: у позиции несколько подтвержденных дат закупки (${formatDateRu(firstPurchaseDate)}..${formatDateRu(lastPurchaseDate)}); это расчет по выбранному якорю, а не единственная возможная дата.`
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
|
||||
@@ -765,10 +795,14 @@ function buildVatPurchaseDateAnchorDisclosureLine(
|
||||
asOfTs >= fromTs &&
|
||||
asOfTs <= toTs
|
||||
) {
|
||||
return `- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`;
|
||||
return [
|
||||
`- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
}
|
||||
|
||||
return `- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`;
|
||||
return [
|
||||
`- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
}
|
||||
|
||||
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
|
||||
@@ -1292,14 +1326,85 @@ function hasInventoryAccountPrefix(value: string | null | undefined, prefix: str
|
||||
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
|
||||
}
|
||||
|
||||
function isInventoryOnHandSnapshotRow(row: ComposeStageRow): boolean {
|
||||
const registrator = String(row.registrator ?? "").trim().toLowerCase();
|
||||
return /(?:остатки\s+на\s+дату|stock\s*on\s*hand|balance\s+as\s+of)/iu.test(registrator);
|
||||
}
|
||||
|
||||
function isInventoryPurchaseMovement(row: ComposeStageRow): boolean {
|
||||
return hasInventoryAccountPrefix(row.account_dt, "41.01");
|
||||
return hasInventoryAccountPrefix(row.account_dt, "41.01") && !isInventoryOnHandSnapshotRow(row);
|
||||
}
|
||||
|
||||
function isInventorySaleMovement(row: ComposeStageRow): boolean {
|
||||
return hasInventoryAccountPrefix(row.account_kt, "41.01");
|
||||
}
|
||||
|
||||
interface InventoryOnHandAgingScope {
|
||||
itemKeys: Set<string>;
|
||||
itemOrgKeys: Set<string>;
|
||||
fullKeys: Set<string>;
|
||||
orgKeys: Set<string>;
|
||||
}
|
||||
|
||||
function inventoryAgingKeyPart(value: string | null | undefined): string {
|
||||
return normalizeEntityToken(value) ?? "";
|
||||
}
|
||||
|
||||
function buildInventoryOnHandAgingScope(rows: ComposeStageRow[]): InventoryOnHandAgingScope {
|
||||
const scope: InventoryOnHandAgingScope = {
|
||||
itemKeys: new Set(),
|
||||
itemOrgKeys: new Set(),
|
||||
fullKeys: new Set(),
|
||||
orgKeys: new Set()
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (!isInventoryOnHandSnapshotRow(row)) {
|
||||
continue;
|
||||
}
|
||||
const quantity = extractInventoryQuantity(row);
|
||||
if (quantity === null || quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
|
||||
if (!itemKey) {
|
||||
continue;
|
||||
}
|
||||
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
|
||||
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
|
||||
scope.itemKeys.add(itemKey);
|
||||
if (organizationKey) {
|
||||
scope.orgKeys.add(organizationKey);
|
||||
scope.itemOrgKeys.add(`${itemKey}|${organizationKey}`);
|
||||
}
|
||||
if (organizationKey || warehouseKey) {
|
||||
scope.fullKeys.add(`${itemKey}|${warehouseKey}|${organizationKey}`);
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function inventoryPurchaseMatchesOnHandAgingScope(row: ComposeStageRow, scope: InventoryOnHandAgingScope): boolean {
|
||||
if (scope.itemKeys.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
|
||||
if (!itemKey || !scope.itemKeys.has(itemKey)) {
|
||||
return false;
|
||||
}
|
||||
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
|
||||
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
|
||||
if (scope.fullKeys.has(`${itemKey}|${warehouseKey}|${organizationKey}`)) {
|
||||
return true;
|
||||
}
|
||||
if (organizationKey && scope.itemOrgKeys.has(`${itemKey}|${organizationKey}`)) {
|
||||
return true;
|
||||
}
|
||||
if (organizationKey && scope.orgKeys.size > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeInventoryTraceDocumentToken(value: string): boolean {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized) {
|
||||
@@ -1526,8 +1631,12 @@ function buildInventoryAgingByItemAggregate(
|
||||
}
|
||||
>();
|
||||
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
|
||||
const onHandScope = buildInventoryOnHandAgingScope(rows);
|
||||
|
||||
for (const row of rows) {
|
||||
if (!isInventoryPurchaseMovement(row) || !inventoryPurchaseMatchesOnHandAgingScope(row, onHandScope)) {
|
||||
continue;
|
||||
}
|
||||
const item = extractInventoryItemName(row);
|
||||
if (!item) {
|
||||
continue;
|
||||
@@ -1613,19 +1722,11 @@ function formatInventoryAgingRows(items: InventoryAgingByItemAggregate[], asOfDa
|
||||
const parts = [
|
||||
`${index + 1}. ${item.item}`,
|
||||
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
|
||||
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`,
|
||||
`документов: ${formatNumberWithDots(item.documentCount)}`,
|
||||
`операций: ${formatNumberWithDots(item.operations)}`
|
||||
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`
|
||||
];
|
||||
if (item.ageDays !== null) {
|
||||
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`);
|
||||
}
|
||||
if (item.warehouse) {
|
||||
parts.push(`склад: ${item.warehouse}`);
|
||||
}
|
||||
if (item.organization) {
|
||||
parts.push(`организация: ${item.organization}`);
|
||||
}
|
||||
if (item.counterparties.length > 0) {
|
||||
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`);
|
||||
}
|
||||
@@ -3934,16 +4035,30 @@ function composeFactualReplyBody(
|
||||
const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
|
||||
const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint);
|
||||
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
|
||||
const purchaseDateAnchorLine = buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel);
|
||||
const purchaseDateAnchorLines = buildVatPurchaseDateAnchorDisclosureLines(options, periodWindowLabel);
|
||||
const selectedPurchaseDate = normalizeIsoDateOnly(options.purchaseDateBridge?.selectedPurchaseDate);
|
||||
const hasMultiplePurchaseDates = Boolean(
|
||||
options.purchaseDateBridge?.hasMultiplePurchaseDates ||
|
||||
(normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) &&
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate) &&
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) !==
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate))
|
||||
);
|
||||
const purchaseBasisLabel = vatPurchaseDateBasisLabel(options.purchaseDateBridge?.basis, hasMultiplePurchaseDates);
|
||||
const directVatLine = selectedPurchaseDate
|
||||
? hasMultiplePurchaseDates
|
||||
? `Коротко: если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`
|
||||
: `Коротко: по дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`
|
||||
: `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`;
|
||||
|
||||
const lines = [
|
||||
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`,
|
||||
directVatLine,
|
||||
"Расчет сделан по книгам продаж и покупок.",
|
||||
"",
|
||||
"Что вошло в расчет:",
|
||||
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
|
||||
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
|
||||
...(purchaseDateAnchorLine ? [purchaseDateAnchorLine] : []),
|
||||
...purchaseDateAnchorLines,
|
||||
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
|
||||
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
|
||||
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`
|
||||
|
||||
@@ -574,6 +574,24 @@ function hasRelativeYearHint(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMonthPeriodFromIsoDate(isoDate: string | null | undefined): { period_from: string; period_to: string; as_of_date: string } | null {
|
||||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
|
||||
return null;
|
||||
}
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return {
|
||||
period_from: `${match[1]}-${match[2]}-01`,
|
||||
period_to: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`,
|
||||
as_of_date: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRelativeMonthPeriodFromInventoryRoot(
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
@@ -801,6 +819,36 @@ export function hasInventoryPurchaseDateVatBridgeCue(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasInventoryPurchaseDateVatBridgeContinuationCue(text: string): boolean {
|
||||
const normalized = textWithRepairedVariant(String(text ?? ""))
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const mentionsPurchase = /(?:покупк|закупк|purchase)/iu.test(normalized);
|
||||
const mentionsFirstOrLast = /(?:перв|последн|earliest|oldest|latest|last)/iu.test(normalized);
|
||||
return mentionsPurchase && mentionsFirstOrLast;
|
||||
}
|
||||
|
||||
function purchaseDateBridgeBasisFromMessage(userMessage: string): string | null {
|
||||
const normalized = textWithRepairedVariant(String(userMessage ?? ""))
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:последн|latest|last)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
|
||||
return "last_confirmed_purchase";
|
||||
}
|
||||
if (/(?:перв|earliest|oldest)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
|
||||
return "first_confirmed_purchase";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
|
||||
const normalized = textWithRepairedVariant(String(text ?? ""))
|
||||
.toLowerCase()
|
||||
@@ -968,6 +1016,34 @@ function mergeFollowupFilters(
|
||||
): { filters: AddressFilterSet; reasons: string[] } {
|
||||
const merged: AddressFilterSet = { ...current };
|
||||
const reasons: string[] = [];
|
||||
const clearInventoryAgingOrganizationAliasItem = (organizationHint: string | null): void => {
|
||||
if (intent !== "inventory_aging_by_purchase_date") {
|
||||
return;
|
||||
}
|
||||
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
|
||||
String(userMessage ?? "")
|
||||
);
|
||||
const agingItem = toNonEmptyString(merged.item);
|
||||
const agingOrganization = toNonEmptyString(merged.organization) ?? organizationHint;
|
||||
const normalizedAgingItem = normalizeOrganizationScopeSearchText(agingItem ?? "");
|
||||
const normalizedAgingOrganization = normalizeOrganizationScopeSearchText(agingOrganization ?? "");
|
||||
const itemLooksLikeOrganizationScope = Boolean(
|
||||
agingItem &&
|
||||
agingOrganization &&
|
||||
(organizationsLikelySameEntity(agingItem, agingOrganization) ||
|
||||
(normalizedAgingItem &&
|
||||
normalizedAgingOrganization &&
|
||||
(normalizedAgingOrganization.includes(normalizedAgingItem) ||
|
||||
normalizedAgingItem.includes(normalizedAgingOrganization))))
|
||||
);
|
||||
if (agingItem && itemLooksLikeOrganizationScope) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_as_organization_scope_alias_for_stock_aging");
|
||||
} else if (agingItem && !explicitItemMention) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
};
|
||||
if (!followupContext) {
|
||||
if (
|
||||
(intent === "list_open_contracts" ||
|
||||
@@ -987,6 +1063,7 @@ function mergeFollowupFilters(
|
||||
);
|
||||
}
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(null);
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
@@ -1001,6 +1078,13 @@ function mergeFollowupFilters(
|
||||
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
|
||||
const previousPeriodFrom = toNonEmptyString(previous.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
const previousPurchaseDateBridgeSelected = toNonEmptyString(previous.purchase_date_bridge_selected);
|
||||
const previousPurchaseDateBridgeFirst = toNonEmptyString(previous.purchase_date_bridge_first);
|
||||
const previousPurchaseDateBridgeLast = toNonEmptyString(previous.purchase_date_bridge_last);
|
||||
const previousPurchaseDateBridgeBasis = toNonEmptyString(previous.purchase_date_bridge_basis);
|
||||
const previousPurchaseDateBridgeHasMultiple =
|
||||
previous.purchase_date_bridge_has_multiple === true ||
|
||||
String(previous.purchase_date_bridge_has_multiple ?? "").trim().toLowerCase() === "true";
|
||||
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
|
||||
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
|
||||
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||
@@ -1020,6 +1104,57 @@ function mergeFollowupFilters(
|
||||
reasons.push("organization_from_followup_context");
|
||||
}
|
||||
|
||||
if (intent === "vat_liability_confirmed_for_tax_period" && previousPurchaseDateBridgeSelected) {
|
||||
const requestedBridgeBasis = purchaseDateBridgeBasisFromMessage(userMessage);
|
||||
const shouldCarryPurchaseDateBridge =
|
||||
!currentHasExplicitTemporalScope &&
|
||||
(Boolean(requestedBridgeBasis) ||
|
||||
hasInventoryPurchaseDateVatBridgeCue(userMessage) ||
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(userMessage));
|
||||
if (!shouldCarryPurchaseDateBridge) {
|
||||
delete merged.purchase_date_bridge_selected;
|
||||
delete merged.purchase_date_bridge_first;
|
||||
delete merged.purchase_date_bridge_last;
|
||||
delete merged.purchase_date_bridge_basis;
|
||||
delete merged.purchase_date_bridge_has_multiple;
|
||||
reasons.push(
|
||||
currentHasExplicitTemporalScope
|
||||
? "purchase_date_bridge_suppressed_by_explicit_temporal_scope"
|
||||
: "purchase_date_bridge_not_reused_without_bridge_cue"
|
||||
);
|
||||
} else {
|
||||
const selectedBridgeDate =
|
||||
requestedBridgeBasis === "last_confirmed_purchase"
|
||||
? previousPurchaseDateBridgeLast ?? previousPurchaseDateBridgeSelected
|
||||
: requestedBridgeBasis === "first_confirmed_purchase"
|
||||
? previousPurchaseDateBridgeFirst ?? previousPurchaseDateBridgeSelected
|
||||
: previousPurchaseDateBridgeSelected;
|
||||
merged.purchase_date_bridge_selected = selectedBridgeDate;
|
||||
const selectedBridgeWindow = resolveMonthPeriodFromIsoDate(selectedBridgeDate);
|
||||
if (selectedBridgeWindow) {
|
||||
merged.period_from = selectedBridgeWindow.period_from;
|
||||
merged.period_to = selectedBridgeWindow.period_to;
|
||||
merged.as_of_date = selectedBridgeWindow.as_of_date;
|
||||
reasons.push("period_from_purchase_date_bridge_followup_context");
|
||||
reasons.push("period_to_purchase_date_bridge_followup_context");
|
||||
}
|
||||
if (previousPurchaseDateBridgeFirst) {
|
||||
merged.purchase_date_bridge_first = previousPurchaseDateBridgeFirst;
|
||||
}
|
||||
if (previousPurchaseDateBridgeLast) {
|
||||
merged.purchase_date_bridge_last = previousPurchaseDateBridgeLast;
|
||||
}
|
||||
const effectiveBridgeBasis = requestedBridgeBasis ?? previousPurchaseDateBridgeBasis;
|
||||
if (effectiveBridgeBasis) {
|
||||
merged.purchase_date_bridge_basis = effectiveBridgeBasis;
|
||||
}
|
||||
if (previousPurchaseDateBridgeHasMultiple) {
|
||||
merged.purchase_date_bridge_has_multiple = true;
|
||||
}
|
||||
reasons.push("purchase_date_bridge_from_followup_context");
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
intent === "inventory_on_hand_as_of_date" &&
|
||||
followupContext.previous_intent === "inventory_on_hand_as_of_date" &&
|
||||
@@ -1386,13 +1521,7 @@ function mergeFollowupFilters(
|
||||
reasons.push("period_derived_from_inventory_root_frame_year");
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
|
||||
String(userMessage ?? "")
|
||||
);
|
||||
if (toNonEmptyString(merged.item) && !explicitItemMention) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(previousOrganization);
|
||||
}
|
||||
if (
|
||||
!sameDateRequested &&
|
||||
@@ -1646,7 +1775,13 @@ function deriveIntentWithFollowupContext(
|
||||
}
|
||||
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
const hasFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
|
||||
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;
|
||||
if (!hasFollowupSignal) {
|
||||
return detectedIntent;
|
||||
}
|
||||
@@ -1655,7 +1790,6 @@ function deriveIntentWithFollowupContext(
|
||||
if (!sourceIntent && !fallbackIntent) {
|
||||
return detectedIntent;
|
||||
}
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
|
||||
const previousContract = toNonEmptyString(previousFilters.contract);
|
||||
@@ -1672,6 +1806,7 @@ function deriveIntentWithFollowupContext(
|
||||
isVatFollowup &&
|
||||
hasSamePeriodHint(normalizedMessage) &&
|
||||
Boolean(previousPeriodFrom || previousPeriodTo);
|
||||
const previousPurchaseDateBridgeSelected = toNonEmptyString(previousFilters.purchase_date_bridge_selected);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined);
|
||||
const inventoryLineageActive =
|
||||
@@ -1700,8 +1835,13 @@ function deriveIntentWithFollowupContext(
|
||||
) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
|
||||
const staleInventoryLineageCanYieldToCounterparty =
|
||||
previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
|
||||
const vatPurchaseDateBridgeContinuation =
|
||||
sourceIntent === "vat_liability_confirmed_for_tax_period" &&
|
||||
Boolean(previousPurchaseDateBridgeSelected) &&
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
|
||||
const inventoryPurchaseDateVatBridge =
|
||||
inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
|
||||
(inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage)) ||
|
||||
vatPurchaseDateBridgeContinuation;
|
||||
const marginRankingLineageActive =
|
||||
sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
|
||||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
|
||||
@@ -1728,6 +1868,7 @@ function deriveIntentWithFollowupContext(
|
||||
inventoryPurchaseDateVatBridge &&
|
||||
(detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === sourceIntent ||
|
||||
detectedIntent.intent === "account_balance_snapshot" ||
|
||||
detectedIntent.intent === "vat_payable_confirmed_as_of_date" ||
|
||||
detectedIntent.intent === "vat_payable_forecast")
|
||||
) {
|
||||
|
||||
@@ -134,6 +134,17 @@ function inventoryPartyListOrUnknown(parties: string[]): string {
|
||||
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
|
||||
}
|
||||
|
||||
function normalizeInventoryReplyEntityToken(value: string | null | undefined): string | null {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/gu, "е")
|
||||
.replace(/[^a-zа-я0-9]+/giu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
|
||||
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||
}
|
||||
@@ -550,30 +561,50 @@ export function composeInventoryReply(
|
||||
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const agingItems = deps.buildInventoryAgingByItemAggregate(rows, asOfDate);
|
||||
const agingItemTokens = new Set(
|
||||
agingItems
|
||||
.map((item) => normalizeInventoryReplyEntityToken(item.item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
const purchaseRows = rows.filter((row) => {
|
||||
if (!deps.isInventoryPurchaseMovement(row)) {
|
||||
return false;
|
||||
}
|
||||
if (agingItemTokens.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const item = deps.summarizeInventoryTraceRows([row]).item;
|
||||
const itemToken = normalizeInventoryReplyEntityToken(item);
|
||||
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
||||
});
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
|
||||
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
||||
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
||||
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
||||
const oldestAnswerPreview = agingItems
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
|
||||
.join("; ");
|
||||
const directAnswerLine =
|
||||
agingItems.length > 0
|
||||
? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
|
||||
: "По доступному закупочному следу позиции со старыми закупками не материализованы.";
|
||||
? `Среди фактических положительных остатков есть давно закупавшиеся позиции: ${oldestAnswerPreview}.`
|
||||
: "В фактическом положительном остатке не найдено позиций с подтвержденным старым закупочным следом.";
|
||||
const lines: string[] = [directAnswerLine];
|
||||
appendInventoryBulletSection(lines, "Сводка:", [
|
||||
const summaryLines = [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
|
||||
`Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||
`Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`,
|
||||
`Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`,
|
||||
`Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
]);
|
||||
];
|
||||
if (organizationLabel) {
|
||||
summaryLines.splice(1, 0, `Организация: ${organizationLabel}.`);
|
||||
}
|
||||
appendInventoryBulletSection(lines, "Сводка:", summaryLines);
|
||||
appendInventoryBulletSection(lines, "Ограничения:", [
|
||||
"Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота."
|
||||
"Берем только позиции с положительным остатком на дату среза; без партионного учета это возраст закупочного следа по номенклатуре, а не доказанный возраст конкретной партии."
|
||||
]);
|
||||
if (oldestPurchaseAgeDays !== null) {
|
||||
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
|
||||
@@ -582,10 +613,10 @@ export function composeInventoryReply(
|
||||
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
|
||||
}
|
||||
if (agingItems.length > 0) {
|
||||
appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12));
|
||||
appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 5));
|
||||
} else {
|
||||
appendInventorySection(lines, "Позиции от самых старых закупок:", [
|
||||
"- В доступных данных не найдено закупочных движений для выбранного среза."
|
||||
"- В доступных данных не найдено закупочных движений по позициям, которые есть в положительном остатке на дату среза."
|
||||
]);
|
||||
}
|
||||
return buildFactualSummaryReply(
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface ComposeFactualReplyOptions<TVatDirectSourceProbe = unknown> {
|
||||
asOfDate?: string;
|
||||
requestedResultMode?: AddressResultMode;
|
||||
vatDirectSourceProbe?: TVatDirectSourceProbe | null;
|
||||
purchaseDateBridge?: {
|
||||
selectedPurchaseDate?: string | null;
|
||||
firstPurchaseDate?: string | null;
|
||||
lastPurchaseDate?: string | null;
|
||||
basis?: string | null;
|
||||
hasMultiplePurchaseDates?: boolean;
|
||||
} | null;
|
||||
emphasizeNumbers?: boolean;
|
||||
useRubCurrency?: boolean;
|
||||
}
|
||||
|
||||
@@ -120,12 +120,25 @@ function cleanComparisonScopeCompanyLine(line: string, organization: string | nu
|
||||
return clean.trim();
|
||||
}
|
||||
|
||||
function hasComparisonScopeProofCue(value: unknown): boolean {
|
||||
const text = toNullableString(value)?.toLocaleLowerCase("ru-RU").replace(/\s+/gu, " ").trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
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: {
|
||||
baseReply: string;
|
||||
debug: Record<string, unknown>;
|
||||
session: unknown;
|
||||
userMessage?: unknown;
|
||||
}): { reply: string; audit: Record<string, unknown> } | null {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return null;
|
||||
}
|
||||
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
|
||||
const turnInput = toRecordObject(entryPoint?.turn_input);
|
||||
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
|
||||
+64
-12
@@ -150,7 +150,7 @@ function hasCompactCashflowFollowupSignal(text: string | null): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b)/iu.test(value);
|
||||
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b|кто\s+больше\s+всего\s+зан[её]с|кому\s+больше\s+всего\s+ушл\p{L}*|банк\s+не\s+называй\s+обычн\p{L}*\s+клиент\p{L}*|главн\p{L}*\s+клиент|главн\p{L}*\s+поставщик)/iu.test(value);
|
||||
}
|
||||
|
||||
function dateScopeToFilterWindow(dateScope: string | null): Record<string, string> | null {
|
||||
@@ -169,6 +169,37 @@ function dateScopeToFilterWindow(dateScope: string | null): Record<string, strin
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterWindowFromExtractedFilters(filters: Record<string, unknown> | null): Record<string, string> | null {
|
||||
if (!filters) {
|
||||
return null;
|
||||
}
|
||||
const asOfDate = typeof filters.as_of_date === "string" ? filters.as_of_date.trim() : "";
|
||||
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(asOfDate)) {
|
||||
return { as_of_date: asOfDate };
|
||||
}
|
||||
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
|
||||
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
|
||||
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo)) {
|
||||
return {
|
||||
period_from: periodFrom,
|
||||
period_to: periodTo
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasBoundedFilterWindow(filters: Record<string, unknown> | null): boolean {
|
||||
if (!filters) {
|
||||
return false;
|
||||
}
|
||||
if (typeof filters.as_of_date === "string" && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(filters.as_of_date.trim())) {
|
||||
return true;
|
||||
}
|
||||
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
|
||||
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
|
||||
return /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo);
|
||||
}
|
||||
|
||||
function looksLikeReliableOrganizationScope(value: string | null): boolean {
|
||||
const text = compactLower(value);
|
||||
if (!text) {
|
||||
@@ -197,12 +228,17 @@ function inferBusinessOverviewDiscoveryContextFromSessionItems(
|
||||
continue;
|
||||
}
|
||||
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope));
|
||||
const debugFilters = toRecordObject(debug?.extracted_filters);
|
||||
const filterWindow =
|
||||
dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope)) ??
|
||||
filterWindowFromExtractedFilters(debugFilters);
|
||||
if (!filterWindow) {
|
||||
continue;
|
||||
}
|
||||
const previousFilters: Record<string, unknown> = { ...filterWindow };
|
||||
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope);
|
||||
const organization =
|
||||
toNonEmptyString(turnMeaningRef?.explicit_organization_scope) ??
|
||||
toNonEmptyString(debugFilters?.organization);
|
||||
if (looksLikeReliableOrganizationScope(organization)) {
|
||||
previousFilters.organization = organization;
|
||||
}
|
||||
@@ -229,11 +265,7 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
|
||||
return input.followupContext;
|
||||
}
|
||||
const existingFilters = toRecordObject(input.followupContext?.previous_filters);
|
||||
if (
|
||||
input.toNonEmptyString(existingFilters?.period_from) ||
|
||||
input.toNonEmptyString(existingFilters?.period_to) ||
|
||||
input.toNonEmptyString(existingFilters?.as_of_date)
|
||||
) {
|
||||
if (hasBoundedFilterWindow(existingFilters)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
|
||||
@@ -244,12 +276,12 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
|
||||
...inferred,
|
||||
...(input.followupContext ?? {}),
|
||||
previous_filters: {
|
||||
...(toRecordObject(inferred.previous_filters) ?? {}),
|
||||
...(toRecordObject(input.followupContext?.previous_filters) ?? {})
|
||||
...(toRecordObject(input.followupContext?.previous_filters) ?? {}),
|
||||
...(toRecordObject(inferred.previous_filters) ?? {})
|
||||
},
|
||||
root_filters: {
|
||||
...(toRecordObject(inferred.root_filters) ?? {}),
|
||||
...(toRecordObject(input.followupContext?.root_filters) ?? {})
|
||||
...(toRecordObject(input.followupContext?.root_filters) ?? {}),
|
||||
...(toRecordObject(inferred.root_filters) ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -281,6 +313,16 @@ function sameEntityHint(expected: string | null, actual: string | null): boolean
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
}
|
||||
|
||||
function hasComparisonScopeProofCue(text: string | null): boolean {
|
||||
const value = compactLower(text);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
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(
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
function isBusinessOverviewDiscoveryFollowup(
|
||||
followupContext: Record<string, unknown> | null,
|
||||
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
|
||||
@@ -489,6 +531,7 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
|
||||
followupContext: Record<string, unknown> | null;
|
||||
sessionAddressNavigationState: unknown;
|
||||
predecomposeContract: Record<string, unknown> | null;
|
||||
userMessage: string;
|
||||
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
|
||||
}): Record<string, unknown> | null {
|
||||
const currentValueBundle = toRecordObject(input.followupContext?.previous_discovery_bidirectional_value_flow);
|
||||
@@ -508,6 +551,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
|
||||
const entities = toRecordObject(input.predecomposeContract?.entities);
|
||||
const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization));
|
||||
const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString);
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) {
|
||||
return input.followupContext;
|
||||
}
|
||||
@@ -550,9 +596,13 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
|
||||
|
||||
function mergeBusinessOverviewProofBundlesFromSessionItems(input: {
|
||||
followupContext: Record<string, unknown> | null;
|
||||
userMessage: string;
|
||||
sessionItems: unknown[];
|
||||
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
|
||||
}): Record<string, unknown> | null {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
@@ -853,6 +903,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
|
||||
});
|
||||
const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({
|
||||
followupContext: discoveryFollowupContext,
|
||||
userMessage: input.userMessage,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
@@ -860,6 +911,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
|
||||
followupContext: discoveryFollowupContextWithProofBundles,
|
||||
sessionAddressNavigationState: input.sessionAddressNavigationState,
|
||||
predecomposeContract,
|
||||
userMessage: input.userMessage,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(
|
||||
|
||||
@@ -188,6 +188,21 @@ function normalizeAddressRuntimeMetaForDeep(
|
||||
};
|
||||
}
|
||||
|
||||
function stripRoutineShortAnswerPrefix(value: string): string {
|
||||
return String(value ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(
|
||||
/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u,
|
||||
"$1"
|
||||
)
|
||||
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1")
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function runAssistantDeepTurnResponseRuntime(
|
||||
input: RunAssistantDeepTurnResponseRuntimeInput
|
||||
): RunAssistantDeepTurnResponseRuntimeOutput {
|
||||
@@ -243,9 +258,11 @@ export function runAssistantDeepTurnResponseRuntime(
|
||||
currentReplySource: "deep_analysis",
|
||||
addressRuntimeMeta: addressRuntimeMetaForDeep as unknown as Record<string, unknown> | null
|
||||
});
|
||||
const assistantReply = mcpDiscoveryResponsePolicy.applied
|
||||
? mcpDiscoveryResponsePolicy.reply_text
|
||||
: packagingRuntime.safeAssistantReply;
|
||||
const assistantReply = stripRoutineShortAnswerPrefix(
|
||||
mcpDiscoveryResponsePolicy.applied
|
||||
? input.sanitizeReply(mcpDiscoveryResponsePolicy.reply_text, packagingRuntime.safeAssistantReply)
|
||||
: packagingRuntime.safeAssistantReply
|
||||
);
|
||||
const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type;
|
||||
const debug = {
|
||||
...packagingRuntime.debug,
|
||||
|
||||
@@ -87,6 +87,21 @@ function hasInternalMechanics(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function stripRoutineShortAnswerPrefix(value: string): string {
|
||||
return String(value ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(
|
||||
/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u,
|
||||
"$1"
|
||||
)
|
||||
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1")
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcpDiscoveryRuntimeEntryPointContract {
|
||||
const record = toRecordObject(value);
|
||||
return (
|
||||
@@ -1052,12 +1067,13 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
||||
}
|
||||
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
|
||||
const replyText = stripRoutineShortAnswerPrefix(String(candidate.reply_text));
|
||||
return {
|
||||
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy",
|
||||
decision: "apply_candidate",
|
||||
applied: true,
|
||||
reply_text: String(candidate.reply_text),
|
||||
reply_text: replyText || String(candidate.reply_text),
|
||||
reply_source: "mcp_discovery_response_candidate_guarded",
|
||||
candidate,
|
||||
reason_codes: reasonCodes
|
||||
|
||||
@@ -225,6 +225,9 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean {
|
||||
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
|
||||
text
|
||||
) ||
|
||||
/^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
|
||||
text
|
||||
) ||
|
||||
/^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(
|
||||
text
|
||||
) ||
|
||||
@@ -2458,6 +2461,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
||||
? null
|
||||
: explicitCurrentCounterpartyOverridesFollowupEntity
|
||||
? null
|
||||
: businessOverviewSuppressesFollowupCounterparty
|
||||
? rawMetadataScopeHint
|
||||
: rawMetadataScopeHint ??
|
||||
followupSeed.metadataScopeHint ??
|
||||
followupSeed.discoveryEntity ??
|
||||
@@ -2685,7 +2690,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
||||
normalizedPredecomposeDateScope &&
|
||||
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)
|
||||
);
|
||||
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope";
|
||||
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope" && !currentTurnCarriesExplicitPeriod;
|
||||
const explicitDateScope =
|
||||
rawAllTimeScopeSignal || followupAllTimeScopeApplied
|
||||
? null
|
||||
|
||||
@@ -1982,7 +1982,11 @@ function repairAddressMojibake(value) {
|
||||
}
|
||||
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
|
||||
const repaired = repairAddressMojibake(String(value ?? ""));
|
||||
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "").trim();
|
||||
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.replace(/^Коротко:\s*/u, "").replace(/(:\s*)Коротко:\s*/u, "$1"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
function computeMonthWindowFromIso(isoDate) {
|
||||
function computeMonthWindowFromIso(isoDate, bridgeFacts = {}) {
|
||||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
@@ -318,11 +318,17 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
purchase_date: isoDate,
|
||||
period_from: `${year}-${mm}-01`,
|
||||
period_to: `${year}-${mm}-${dd}`,
|
||||
as_of_date: `${year}-${mm}-${dd}`
|
||||
as_of_date: `${year}-${mm}-${dd}`,
|
||||
purchase_date_bridge_selected: isoDate,
|
||||
...bridgeFacts
|
||||
};
|
||||
}
|
||||
|
||||
function extractEarliestDmyDateFromEntityRefs(entityRefs) {
|
||||
function uniqueSortedIsoDates(dates) {
|
||||
return Array.from(new Set(dates.filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
function extractDmyDatesFromEntityRefs(entityRefs) {
|
||||
const dates = [];
|
||||
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
|
||||
const value = deps.toNonEmptyString(entityRef?.value);
|
||||
@@ -340,40 +346,96 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return dates.sort()[0] ?? null;
|
||||
return uniqueSortedIsoDates(dates);
|
||||
}
|
||||
|
||||
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) {
|
||||
function detectPurchaseDateBridgeBasis(userMessage, alternateMessage) {
|
||||
const text = [userMessage, alternateMessage]
|
||||
.map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase()).replace(/ё/g, "е"))
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (/(?:последн|latest|last|сам[а-я\s-]*поздн)/iu.test(text)) {
|
||||
return "last_confirmed_purchase";
|
||||
}
|
||||
if (/(?:перв|earliest|oldest|ранн|сам[а-я\s-]*стар)/iu.test(text)) {
|
||||
return "first_confirmed_purchase";
|
||||
}
|
||||
return "first_confirmed_purchase_default";
|
||||
}
|
||||
|
||||
function extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
|
||||
const previousFilters = readAddressDebugFilters(previousAddressItem?.debug ?? null);
|
||||
let firstIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_first);
|
||||
let lastIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_last);
|
||||
const selectedIsoDateFromFilters = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_selected);
|
||||
if (!firstIsoDate && selectedIsoDateFromFilters) {
|
||||
firstIsoDate = selectedIsoDateFromFilters;
|
||||
}
|
||||
if (!lastIsoDate && selectedIsoDateFromFilters) {
|
||||
lastIsoDate = selectedIsoDateFromFilters;
|
||||
}
|
||||
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
|
||||
if (replyText) {
|
||||
if (replyText && (!firstIsoDate || !lastIsoDate)) {
|
||||
const repairedText = deps.repairAddressMojibake(replyText);
|
||||
const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
|
||||
const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
|
||||
if (explicitFirstDateIso) {
|
||||
return computeMonthWindowFromIso(explicitFirstDateIso);
|
||||
}
|
||||
const explicitFirstDateMatch = repairedText.match(
|
||||
/первая\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
|
||||
);
|
||||
const explicitLastDateMatch = repairedText.match(
|
||||
/последняя\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
|
||||
);
|
||||
firstIsoDate = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
|
||||
lastIsoDate = explicitLastDateMatch ? parseDmyDateToIso(explicitLastDateMatch[1]) : null;
|
||||
}
|
||||
|
||||
const navigationSessionState = resolveNavigationSessionContextState(
|
||||
addressNavigationState,
|
||||
deps.toNonEmptyString,
|
||||
deps.normalizeOrganizationScopeValue
|
||||
);
|
||||
const focusObject = navigationSessionState.focusObject;
|
||||
const preferredResultSetId =
|
||||
deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
|
||||
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
|
||||
const preferredResultSet =
|
||||
(preferredResultSetId
|
||||
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
|
||||
: null) ??
|
||||
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
|
||||
null;
|
||||
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
|
||||
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
|
||||
if (!firstIsoDate || !lastIsoDate) {
|
||||
const navigationSessionState = resolveNavigationSessionContextState(
|
||||
addressNavigationState,
|
||||
deps.toNonEmptyString,
|
||||
deps.normalizeOrganizationScopeValue
|
||||
);
|
||||
const focusObject = navigationSessionState.focusObject;
|
||||
const preferredResultSetId =
|
||||
deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
|
||||
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
|
||||
const preferredResultSet =
|
||||
(preferredResultSetId
|
||||
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
|
||||
: null) ??
|
||||
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
|
||||
null;
|
||||
const datesFromResultSet = extractDmyDatesFromEntityRefs(preferredResultSet?.entity_refs);
|
||||
firstIsoDate = firstIsoDate ?? datesFromResultSet[0] ?? null;
|
||||
lastIsoDate = lastIsoDate ?? datesFromResultSet[datesFromResultSet.length - 1] ?? null;
|
||||
}
|
||||
|
||||
if (!firstIsoDate && !lastIsoDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFirst = firstIsoDate ?? lastIsoDate;
|
||||
const normalizedLast = lastIsoDate ?? firstIsoDate;
|
||||
const basis = detectPurchaseDateBridgeBasis(userMessage, alternateMessage);
|
||||
const selectedIsoDate = basis === "last_confirmed_purchase" ? normalizedLast : normalizedFirst;
|
||||
return {
|
||||
selectedIsoDate,
|
||||
firstIsoDate: normalizedFirst,
|
||||
lastIsoDate: normalizedLast,
|
||||
basis,
|
||||
hasMultiplePurchaseDates: Boolean(normalizedFirst && normalizedLast && normalizedFirst !== normalizedLast)
|
||||
};
|
||||
}
|
||||
|
||||
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
|
||||
const facts = extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage);
|
||||
if (!facts?.selectedIsoDate) {
|
||||
return null;
|
||||
}
|
||||
return computeMonthWindowFromIso(facts.selectedIsoDate, {
|
||||
purchase_date_bridge_first: facts.firstIsoDate,
|
||||
purchase_date_bridge_last: facts.lastIsoDate,
|
||||
purchase_date_bridge_basis: facts.basis,
|
||||
purchase_date_bridge_has_multiple: facts.hasMultiplePurchaseDates
|
||||
});
|
||||
}
|
||||
|
||||
function isUsableFollowupSourceDebug(debug) {
|
||||
@@ -605,11 +667,20 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(
|
||||
userMessage,
|
||||
alternateMessage,
|
||||
sourceIntentHint,
|
||||
hasInventoryItemFocusHint,
|
||||
sourceHasPurchaseDateBridgeHint = false
|
||||
) {
|
||||
const vatPurchaseBridgeContinuation =
|
||||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" && sourceHasPurchaseDateBridgeHint;
|
||||
if (
|
||||
sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
|
||||
!hasInventoryItemFocusHint &&
|
||||
!deps.isInventorySelectedObjectIntent(sourceIntentHint)
|
||||
!deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
|
||||
!vatPurchaseBridgeContinuation
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -621,10 +692,13 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
}
|
||||
return samples.some(
|
||||
(sample) =>
|
||||
/(?:ндс|vat)/iu.test(sample) &&
|
||||
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
|
||||
sample
|
||||
)
|
||||
((/(?:ндс|vat)/iu.test(sample) &&
|
||||
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
|
||||
sample
|
||||
)) ||
|
||||
((vatPurchaseBridgeContinuation || sourceIntentHint === "vat_liability_confirmed_for_tax_period") &&
|
||||
/(?:перв|последн|earliest|oldest|latest|last)/iu.test(sample) &&
|
||||
/(?:покупк|закупк|purchase)/iu.test(sample)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -932,10 +1006,17 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
|
||||
const navigationSessionState = earlyNavigationSessionState;
|
||||
const navigationFocusObjectHint = navigationSessionState.focusObject;
|
||||
const sourceFiltersHint = readAddressDebugFilters(carryoverSourceDebug);
|
||||
const sourceHasPurchaseDateBridgeHint = Boolean(
|
||||
deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected)
|
||||
);
|
||||
const hasNavigationInventoryItemFocusHint = Boolean(
|
||||
deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
|
||||
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
|
||||
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
|
||||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" ||
|
||||
sourceIntentHint === "vat_payable_forecast" ||
|
||||
sourceIntentHint === "vat_payable_confirmed_as_of_date" ||
|
||||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
deps.isInventorySelectedObjectIntent(sourceIntentHint))
|
||||
);
|
||||
@@ -943,7 +1024,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
userMessage,
|
||||
alternateMessage,
|
||||
sourceIntentHint,
|
||||
hasNavigationInventoryItemFocusHint
|
||||
hasNavigationInventoryItemFocusHint,
|
||||
sourceHasPurchaseDateBridgeHint
|
||||
);
|
||||
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
|
||||
userMessage,
|
||||
@@ -1457,10 +1539,21 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
readAddressDebugItem(carryoverSourceDebug, deps.toNonEmptyString) ??
|
||||
deps.toNonEmptyString(previousFilters.item)
|
||||
) ?? previousAddressItem;
|
||||
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
|
||||
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(
|
||||
purchaseBridgeItem,
|
||||
addressNavigationState,
|
||||
userMessage,
|
||||
alternateMessage
|
||||
);
|
||||
if (purchaseBridgeWindow) {
|
||||
previousFilters.period_from = purchaseBridgeWindow.period_from;
|
||||
previousFilters.period_to = purchaseBridgeWindow.period_to;
|
||||
previousFilters.as_of_date = purchaseBridgeWindow.as_of_date;
|
||||
previousFilters.purchase_date_bridge_selected = purchaseBridgeWindow.purchase_date_bridge_selected;
|
||||
previousFilters.purchase_date_bridge_first = purchaseBridgeWindow.purchase_date_bridge_first;
|
||||
previousFilters.purchase_date_bridge_last = purchaseBridgeWindow.purchase_date_bridge_last;
|
||||
previousFilters.purchase_date_bridge_basis = purchaseBridgeWindow.purchase_date_bridge_basis;
|
||||
previousFilters.purchase_date_bridge_has_multiple = purchaseBridgeWindow.purchase_date_bridge_has_multiple;
|
||||
}
|
||||
}
|
||||
previousFilters = applyTemporalCarryoverFilters(
|
||||
|
||||
@@ -163,6 +163,11 @@ export interface AddressFilterSet {
|
||||
warehouse?: string;
|
||||
document_type?: string;
|
||||
document_ref?: string;
|
||||
purchase_date_bridge_selected?: string;
|
||||
purchase_date_bridge_first?: string;
|
||||
purchase_date_bridge_last?: string;
|
||||
purchase_date_bridge_basis?: string;
|
||||
purchase_date_bridge_has_multiple?: boolean;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
sort?: "period_desc" | "period_asc";
|
||||
|
||||
Reference in New Issue
Block a user