АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests

This commit is contained in:
2026-04-02 23:11:49 +03:00
parent 53716e548e
commit 88094c09f8
243 changed files with 94296 additions and 181 deletions
@@ -17,6 +17,234 @@ function formatTopRows(rows, limit = 6) {
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
});
}
function extractYearFromIso(value) {
const source = String(value ?? "");
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
return Number.isFinite(year) ? year : null;
}
function extractYearMonthFromIso(value) {
const source = String(value ?? "");
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
return `${match[1]}-${match[2]}`;
}
const ACCOUNT_SECTION_LABELS = {
"01": "Основные средства",
"04": "Нематериальные активы",
"08": "Вложения во внеоборотные активы",
"10": "Материалы",
"19": "НДС по приобретенным ценностям",
"20": "Основное производство",
"23": "Вспомогательные производства",
"25": "Общепроизводственные расходы",
"26": "Общехозяйственные расходы",
"41": "Товары",
"43": "Готовая продукция",
"44": "Расходы на продажу",
"50": "Касса",
"51": "Расчетные счета",
"52": "Валютные счета",
"55": "Специальные счета в банках",
"58": "Финансовые вложения",
"60": "Расчеты с поставщиками и подрядчиками",
"62": "Расчеты с покупателями и заказчиками",
"66": "Краткосрочные кредиты и займы",
"67": "Долгосрочные кредиты и займы",
"68": "Расчеты по налогам и сборам",
"69": "Расчеты по социальному страхованию",
"70": "Расчеты с персоналом по оплате труда",
"71": "Расчеты с подотчетными лицами",
"73": "Расчеты с персоналом по прочим операциям",
"75": "Расчеты с учредителями",
"76": "Расчеты с разными дебиторами и кредиторами",
"80": "Уставный капитал",
"81": "Собственные акции (доли)",
"84": "Нераспределенная прибыль (непокрытый убыток)",
"90": "Продажи",
"91": "Прочие доходы и расходы"
};
function formatPercent(value, total) {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) {
return null;
}
return `${((value / total) * 100).toFixed(1)}%`;
}
function extractAccountSectionCode(value) {
const source = String(value ?? "").trim();
if (!source) {
return null;
}
const match = source.match(/(^|[^0-9])(\d{2})(?:[.,]\d{1,2})?/);
if (!match) {
return null;
}
return match[2];
}
function normalizeQuestionText(value) {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/\s+/g, " ")
.trim();
}
function detectPeriodProfileFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "full_profile";
}
const asksYear = /(?:\byear\b|год(?:а|у|ом|е|ы)?)/iu.test(text);
const asksMonth = /(?:\bmonth\b|месяц(?:а|у|ем|е|ы)?)/iu.test(text);
const asksDocs = /(?:\bdocument(?:s)?\b|док(?:умент(?:ы|ов|ам|ами|ах|а)?|и|ов)?)/iu.test(text);
const asksOps = /(?:\bops?\b|\boperation(?:s)?\b|операц)/iu.test(text);
const asksTop = /(?:сам(?:ый|ая|ое)\s+актив|наибол[её]е\s+актив|чаще\s+всего|most\s+active|top)/iu.test(text);
const asksBottom = /(?:сам(?:ый|ая|ое)\s+пассив|наимен[её]е\s+актив|least\s+active|миним(?:ум|альн)|наименьш)/iu.test(text);
if (asksYear && asksDocs && asksBottom) {
return "bottom_year_docs";
}
if (asksYear && asksDocs && asksTop) {
return "top_year_docs";
}
if (asksMonth && asksOps && asksBottom) {
return "bottom_month_ops";
}
if (asksMonth && asksOps && asksTop) {
return "top_month_ops";
}
if (/(?:за\s+какие\s+год[а-яё]*|годы?\s+с\s+данными|покрыт(?:ие|ия)\s+период|диапазон\s+лет|профил[ья]\s+данн|year\s+coverage|data\s+coverage)/iu.test(text)) {
return "coverage_years";
}
return "full_profile";
}
function detectDocumentSectionProfileFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "full_profile";
}
const asksDocTypes = /(?:тип[аы]?\s+док|типы?\s+документ|document\s+types?)/iu.test(text);
const asksSections = /(?:раздел[ыа]?\s+уч[её]та|account\s+section)/iu.test(text);
const asksRare = /(?:реже|редк|наимен[её]е|почти\s+не|least|rare|миним(?:ум|альн))/iu.test(text);
const asksTop = /(?:чаще\s+всего|наибол[её]е|most|top|максим)/iu.test(text);
if (asksDocTypes && !asksSections) {
if (asksRare && !asksTop) {
return "doc_types_rare_only";
}
return "doc_types_only";
}
if (asksSections && !asksDocTypes) {
if (asksRare && !asksTop) {
return "sections_rare_only";
}
return "sections_only";
}
return "full_profile";
}
function detectCounterpartyProfileFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "full_profile";
}
const asksTotal = /(?:(?:сколько|скока|скок)\s+(?:всего\s+)?(?:уникальн(?:ых|ые|ого)?\s+)?контрагент(?:ов|а)?(?:\s+в\s+баз[еы])?|total\s+counterpart(?:y|ies))/iu.test(text);
const hasSupplierToken = /(?:поставщик(?:ов|а)?|supplier(?:s)?)/iu.test(text);
const hasCustomerToken = /(?:заказчик(?:ов|а)?|клиент(?:ов|а)?|customer(?:s)?|client(?:s)?)/iu.test(text);
const hasMixedToken = /(?:смешан|проч(?:их|ие)|mixed)/iu.test(text);
const asksRoles = /(?:заказчик(?:ов|а)?|поставщик(?:ов|а)?|смешан|проч(?:их|ие)|типы?\s+контрагент|разбей|раздели|roles?|split)/iu.test(text);
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken && !asksTotal) {
return "suppliers_only";
}
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
return "customers_only";
}
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
return "mixed_only";
}
if (asksTotal && !asksRoles) {
return "total_only";
}
if (asksRoles && !asksTotal) {
return "roles_only";
}
return "full_profile";
}
function detectCounterpartyLifecycleFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "active_customers_period";
}
if (/(?:за\s+вс[её]\s+время|all\s+time|за\s+всю\s+истори(?:ю|и))/iu.test(text)) {
return "active_customers_all_time";
}
return "active_customers_period";
}
function extractRequestedYearFromQuestion(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return null;
}
const fullYearMatch = text.match(/\b(19|20)\d{2}\b/);
if (fullYearMatch) {
const parsed = Number(fullYearMatch[0]);
return Number.isFinite(parsed) ? parsed : null;
}
const shortYearMatch = text.match(/(?:^|[^\d])(\d{2})\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)/iu);
if (!shortYearMatch) {
return null;
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
return null;
}
return 2000 + shortYear;
}
function extractCounterpartyName(row) {
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
continue;
}
return normalized;
}
return null;
}
function deriveOperationalYearWindow(yearDocs, yearOps) {
const docsSeries = [...yearDocs].sort((a, b) => a.year - b.year);
const fallbackSeries = [...yearOps].sort((a, b) => a.year - b.year);
const series = docsSeries.length > 0 ? docsSeries : fallbackSeries;
if (series.length === 0) {
return {
dataFrom: null,
dataTo: null,
operationalFrom: null,
operationalTo: null,
tailYears: []
};
}
const dataFrom = series[0]?.year ?? null;
const dataTo = series[series.length - 1]?.year ?? null;
const maxCount = Math.max(...series.map((item) => item.count));
const significantThreshold = Math.max(20, Math.ceil(maxCount * 0.02));
const significantYears = series.filter((item) => item.count >= significantThreshold).map((item) => item.year);
const operationalFrom = significantYears[0] ?? dataFrom;
const operationalTo = significantYears[significantYears.length - 1] ?? dataTo;
const tailYears = series
.filter((item) => operationalTo !== null && item.year > operationalTo)
.map((item) => item.year);
return {
dataFrom,
dataTo,
operationalFrom,
operationalTo,
tailYears
};
}
function contractCandidatesFromRows(rows) {
const candidates = [];
for (const row of rows) {
@@ -32,7 +260,406 @@ function contractCandidatesFromRows(rows) {
}
return uniqueStrings(candidates);
}
function composeFactualReply(intent, rows) {
function composeFactualReply(intent, rows, options = {}) {
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker).push(row);
}
const docTypeRanking = (rowsByMarker.get("DOC_TYPE_DOCS") ?? [])
.map((row) => ({
docType: String(row.account_dt ?? "").trim(),
count: row.amount ?? 0
}))
.filter((item) => item.docType.length > 0)
.sort((a, b) => b.count - a.count);
const docTypeRankingLow = [...docTypeRanking]
.sort((a, b) => a.count - b.count || a.docType.localeCompare(b.docType))
.slice(0, 10);
const sectionCounter = new Map();
for (const marker of ["SECTION_DT_OPS", "SECTION_KT_OPS"]) {
for (const row of rowsByMarker.get(marker) ?? []) {
const sectionCode = extractAccountSectionCode(row.account_dt);
if (!sectionCode) {
continue;
}
const nextValue = (sectionCounter.get(sectionCode) ?? 0) + (row.amount ?? 0);
sectionCounter.set(sectionCode, nextValue);
}
}
const sectionRanking = Array.from(sectionCounter.entries())
.map(([section, count]) => ({ section, count }))
.sort((a, b) => b.count - a.count || a.section.localeCompare(b.section));
const sectionRankingLow = [...sectionRanking]
.sort((a, b) => a.count - b.count || a.section.localeCompare(b.section))
.slice(0, 10);
const docTypeTotal = docTypeRanking.reduce((sum, item) => sum + item.count, 0);
const sectionTotal = sectionRanking.reduce((sum, item) => sum + item.count, 0);
const focus = detectDocumentSectionProfileFocus(options.userMessage);
const includeDocTypes = focus === "full_profile" || focus === "doc_types_only" || focus === "doc_types_rare_only";
const includeSections = focus === "full_profile" || focus === "sections_only" || focus === "sections_rare_only";
const includeDocTypesLowOnly = focus === "doc_types_rare_only";
const includeSectionsLowOnly = focus === "sections_rare_only";
const lines = [
"Профиль типов документов и разделов учета собран (movement-based aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (includeDocTypes) {
if (docTypeRanking.length > 0) {
if (includeDocTypesLowOnly) {
lines.push("Наименее используемые типы документов (по числу уникальных регистраторов):");
lines.push(...docTypeRankingLow.map((item, index) => {
const share = formatPercent(item.count, docTypeTotal);
return share
? `${index + 1}. ${item.docType}: ${item.count} (${share})`
: `${index + 1}. ${item.docType}: ${item.count}`;
}));
}
else {
lines.push("Топ типов документов (по числу уникальных регистраторов):");
lines.push(...docTypeRanking.slice(0, 10).map((item, index) => {
const share = formatPercent(item.count, docTypeTotal);
return share
? `${index + 1}. ${item.docType}: ${item.count} (${share})`
: `${index + 1}. ${item.docType}: ${item.count}`;
}));
}
}
else {
lines.push("По типам документов агрегатных строк не найдено.");
}
}
if (includeSections) {
if (sectionRanking.length > 0) {
if (includeSectionsLowOnly) {
lines.push("Наименее заполненные разделы учета (по операциям Дт+Кт):");
lines.push(...sectionRankingLow.map((item, index) => {
const label = ACCOUNT_SECTION_LABELS[item.section];
const sectionTitle = label ? `${item.section} (${label})` : item.section;
const share = formatPercent(item.count, sectionTotal);
return share
? `${index + 1}. ${sectionTitle}: ${item.count} (${share})`
: `${index + 1}. ${sectionTitle}: ${item.count}`;
}));
}
else {
lines.push("Наиболее заполненные разделы учета (по операциям Дт+Кт):");
lines.push(...sectionRanking.slice(0, 10).map((item, index) => {
const label = ACCOUNT_SECTION_LABELS[item.section];
const sectionTitle = label ? `${item.section} (${label})` : item.section;
const share = formatPercent(item.count, sectionTotal);
return share
? `${index + 1}. ${sectionTitle}: ${item.count} (${share})`
: `${index + 1}. ${sectionTitle}: ${item.count}`;
}));
lines.push("Разделы с минимальной активностью (среди использованных):");
lines.push(...sectionRankingLow.map((item, index) => {
const label = ACCOUNT_SECTION_LABELS[item.section];
const sectionTitle = label ? `${item.section} (${label})` : item.section;
return `${index + 1}. ${sectionTitle}: ${item.count}`;
}));
}
}
else {
lines.push("По разделам учета агрегатных строк не найдено.");
}
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "period_coverage_profile") {
const rowsByMarker = new Map();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker).push(row);
}
const minDate = rowsByMarker.get("MIN_DATE")?.[0]?.period ?? null;
const maxDate = rowsByMarker.get("MAX_DATE")?.[0]?.period ?? null;
const yearOps = (rowsByMarker.get("YEAR_OPS") ?? [])
.map((row) => ({
year: extractYearFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item) => item.year !== null)
.sort((a, b) => b.count - a.count);
const yearDocs = (rowsByMarker.get("YEAR_DOCS") ?? [])
.map((row) => ({
year: extractYearFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item) => item.year !== null)
.sort((a, b) => b.count - a.count);
const monthOps = (rowsByMarker.get("MONTH_OPS") ?? [])
.map((row) => ({
month: extractYearMonthFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item) => item.month !== null)
.sort((a, b) => b.count - a.count);
const focus = detectPeriodProfileFocus(options.userMessage);
const includeCoverage = focus === "full_profile" || focus === "coverage_years";
const includeTopYear = focus === "full_profile" || focus === "top_year_docs";
const includeBottomYear = focus === "bottom_year_docs";
const includeTopMonth = focus === "full_profile" || focus === "top_month_ops";
const includeBottomMonth = focus === "bottom_month_ops";
const operationalWindow = deriveOperationalYearWindow(yearDocs, yearOps);
const yearsCoverage = (yearOps.length > 0 ? yearOps : yearDocs).map((item) => item.year).sort((a, b) => a - b);
const yearDocsWithinOperational = operationalWindow.operationalFrom !== null && operationalWindow.operationalTo !== null
? yearDocs.filter((item) => item.year >= operationalWindow.operationalFrom && item.year <= operationalWindow.operationalTo)
: yearDocs;
const yearDocsForRanking = yearDocsWithinOperational.length > 0 ? yearDocsWithinOperational : yearDocs;
const yearDocsTop = [...yearDocsForRanking].sort((a, b) => b.count - a.count || a.year - b.year);
const yearDocsBottom = [...yearDocsForRanking].sort((a, b) => a.count - b.count || a.year - b.year);
const monthOpsWithinOperational = operationalWindow.operationalFrom !== null && operationalWindow.operationalTo !== null
? monthOps.filter((item) => {
const year = Number(item.month.slice(0, 4));
return (Number.isFinite(year) &&
year >= operationalWindow.operationalFrom &&
year <= operationalWindow.operationalTo);
})
: monthOps;
const monthOpsForRanking = monthOpsWithinOperational.length > 0 ? monthOpsWithinOperational : monthOps;
const monthOpsTop = [...monthOpsForRanking].sort((a, b) => b.count - a.count || a.month.localeCompare(b.month));
const monthOpsBottom = [...monthOpsForRanking].sort((a, b) => a.count - b.count || a.month.localeCompare(b.month));
const topYearByDocs = yearDocsTop[0] ?? null;
const bottomYearByDocs = yearDocsBottom[0] ?? null;
const topMonthByOps = monthOpsTop[0] ?? null;
const bottomMonthByOps = monthOpsBottom[0] ?? null;
const hasTailYears = operationalWindow.tailYears.length > 0 &&
operationalWindow.operationalTo !== null &&
operationalWindow.dataTo !== null &&
operationalWindow.operationalTo < operationalWindow.dataTo;
const lines = [
"Профиль периодов базы собран (movement-based aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (includeCoverage) {
if (hasTailYears &&
operationalWindow.operationalFrom !== null &&
operationalWindow.operationalTo !== null) {
lines.push(`Операционный период с выраженной активностью: ${operationalWindow.operationalFrom}..${operationalWindow.operationalTo}.`);
lines.push(`Низкоактивный хвост (единичные записи): ${operationalWindow.tailYears.join(", ")}.`);
lines.push(`Полный технический диапазон дат: ${minDate ?? "н/д"} .. ${maxDate ?? "н/д"}.`);
}
else {
lines.push(`Покрытие по датам: ${minDate ?? "н/д"} .. ${maxDate ?? "н/д"}.`);
if (yearsCoverage.length > 0) {
lines.push(`Годы с данными: ${yearsCoverage[0]}..${yearsCoverage[yearsCoverage.length - 1]} (уникальных: ${yearsCoverage.length}).`);
}
}
}
if (includeTopYear && topYearByDocs) {
lines.push(`Самый активный год по документам: ${topYearByDocs.year} (${topYearByDocs.count}).`);
lines.push(...yearDocsTop
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.year}: ${item.count}`));
}
if (includeBottomYear && bottomYearByDocs) {
lines.push(`Самый пассивный год по документам: ${bottomYearByDocs.year} (${bottomYearByDocs.count}).`);
lines.push(...yearDocsBottom
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.year}: ${item.count}`));
}
if (includeTopMonth && topMonthByOps) {
lines.push(`Самый активный месяц по операциям: ${topMonthByOps.month} (${topMonthByOps.count}).`);
lines.push(...monthOpsTop
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.month}: ${item.count}`));
}
if (includeBottomMonth && bottomMonthByOps) {
lines.push(`Самый пассивный месяц по операциям: ${bottomMonthByOps.month} (${bottomMonthByOps.count}).`);
lines.push(...monthOpsBottom
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.month}: ${item.count}`));
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "counterparty_population_and_roles") {
const rowsByMarker = new Map();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker).push(row);
}
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
const totalCounterparties = sumMarker("CP_TOTAL");
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
const activeUnion = sumMarker("CP_ACTIVE_UNION");
const customerOnly = Math.max(0, customerActive - mixedActive);
const supplierOnly = Math.max(0, supplierActive - mixedActive);
const resolvedActive = customerOnly + supplierOnly + mixedActive;
const activeCounterparties = Math.max(activeUnion, resolvedActive);
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
const focus = detectCounterpartyProfileFocus(options.userMessage);
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
const lines = [
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (includeTotal) {
if (totalCounterparties > 0) {
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
}
else if (activeCounterparties > 0) {
lines.push(`Total из справочника не получен, оценка по активности в документах: ${activeCounterparties} контрагентов.`);
}
else {
lines.push("По количеству контрагентов агрегатных строк не найдено.");
}
}
if (includeRoles) {
if (resolvedActive > 0 || activeCounterparties > 0) {
lines.push("Роли контрагентов по активности:");
lines.push(`1. Заказчики (только customer-роль): ${customerOnly}.`);
lines.push(`2. Поставщики (только supplier-роль): ${supplierOnly}.`);
lines.push(`3. Смешанные (и покупатель, и поставщик): ${mixedActive}.`);
lines.push(`4. Активные контрагенты (union ролей): ${activeCounterparties}.`);
if (otherCounterparties !== null) {
lines.push(`5. Прочие/неактивные в выбранном окне: ${otherCounterparties}.`);
}
}
else {
lines.push("По role-split контрагентов агрегатных строк не найдено.");
}
}
if (focus === "suppliers_only") {
lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`);
}
if (focus === "customers_only") {
lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`);
}
if (focus === "mixed_only") {
lines.push(`Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`);
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "counterparty_activity_lifecycle") {
const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY");
const byCounterparty = new Map();
for (const row of activityRows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, { name, opsCount, lastPeriod: row.period });
continue;
}
if (opsCount > current.opsCount) {
current.opsCount = opsCount;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
}
const counterparties = Array.from(byCounterparty.values()).sort((left, right) => {
if (right.opsCount !== left.opsCount) {
return right.opsCount - left.opsCount;
}
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
});
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
const scopeLabel = focus === "active_customers_all_time"
? "за все время"
: requestedYear
? `в ${requestedYear} году`
: "в выбранном периоде";
const lines = [
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) {
lines.push("Активных заказчиков по выбранному окну не найдено.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
lines.push(`Активные заказчики ${scopeLabel}: ${counterparties.length}.`);
const visible = counterparties.slice(0, 120);
lines.push(...visible.map((item, index) => {
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}`;
}));
if (counterparties.length > visible.length) {
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "contract_usage_overview") {
const rowsByMarker = new Map();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker).push(row);
}
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
const totalContracts = sumMarker("CT_TOTAL");
const usedContracts = sumMarker("CT_USED");
const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
const lines = [
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (totalContracts > 0) {
lines.push(`Всего договоров в базе: ${totalContracts}.`);
}
else {
lines.push("Общее количество договоров не получено (пустой/недоступный срез справочника).");
}
lines.push(`Использованных договоров (есть factual связь с операциями): ${usedContracts}.`);
if (unusedContracts !== null) {
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
}
if (usedShare) {
lines.push(`Доля используемых договоров: ${usedShare}.`);
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
@@ -90,6 +717,40 @@ function composeFactualReply(intent, rows) {
text: lines.join("\n")
};
}
if (intent === "list_contracts_by_counterparty") {
const contracts = uniqueStrings(rows
.map((row) => String(row.registrator ?? "").trim())
.filter((item) => item.length > 0));
const counterparties = uniqueStrings(rows
.flatMap((row) => row.analytics)
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0));
const lines = [
"Собран список договоров по контрагенту (catalog address lane).",
`Строк отобрано: ${rows.length}.`,
`Уникальных договоров: ${contracts.length}.`
];
if (counterparties.length === 1) {
lines.push(`Контрагент: ${counterparties[0]}.`);
}
else if (counterparties.length > 1) {
lines.push(`Контрагенты в выборке: ${counterparties.length}.`);
}
if (contracts.length > 0) {
const visible = contracts.slice(0, 120);
lines.push(...visible.map((item, index) => `${index + 1}. ${item}`));
if (contracts.length > visible.length) {
lines.push(`Показаны первые ${visible.length} из ${contracts.length} договоров.`);
}
}
else {
lines.push("Договоры по указанному якорю в текущем live-срезе не найдены.");
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
@@ -133,7 +133,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const previousPeriodTo = toNonEmptyString(previous.period_to);
const allTimeRequested = hasAllTimeHint(userMessage);
const sameDateRequested = hasSameDateHint(userMessage);
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty") {
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
@@ -212,6 +214,7 @@ function resolveMissingRequiredFilters(intent, filters) {
documents_forming_balance: ["account", "as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"],
list_documents_by_contract: ["contract"],
bank_operations_by_contract: ["contract"]
};
@@ -251,7 +254,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
hasAccountSignal(normalizedMessage) &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "bank_operations_by_counterparty")) {
detectedIntent.intent === "bank_operations_by_counterparty" ||
detectedIntent.intent === "account_balance_snapshot")) {
const preferDocumentsForming = hasDocumentSignal(normalizedMessage) &&
/(?:раскрой|раскры|формир|документами|по\s+документ)/iu.test(normalizedMessage);
return {
@@ -0,0 +1,106 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildAddressLlmPredecomposeContractV1 = buildAddressLlmPredecomposeContractV1;
const addressQueryClassifier_1 = require("../addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier");
const addressIntentResolver_1 = require("../addressIntentResolver");
const addressFilterExtractor_1 = require("../addressFilterExtractor");
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function hasAllTimeHint(text) {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|for\s+all\s+time|all\s+time|entire\s+period|full\s+history)/iu.test(String(text ?? ""));
}
function inferPeriodScope(filters, canonicalMessage) {
const asOfDate = toNonEmptyString(filters.as_of_date);
const periodFrom = toNonEmptyString(filters.period_from);
const periodTo = toNonEmptyString(filters.period_to);
if (asOfDate) {
return "as_of";
}
if (periodFrom && periodTo) {
const yearFrom = periodFrom.match(/^(\d{4})-01-01$/);
const yearTo = periodTo.match(/^(\d{4})-12-31$/);
if (yearFrom && yearTo && yearFrom[1] === yearTo[1]) {
return "year";
}
return "range";
}
if (hasAllTimeHint(canonicalMessage)) {
return "all_time";
}
return "unspecified";
}
function inferAggregationProfile(intent, shape) {
if (intent === "period_coverage_profile" ||
intent === "document_type_and_account_section_profile" ||
intent === "counterparty_population_and_roles" ||
intent === "counterparty_activity_lifecycle" ||
intent === "contract_usage_overview") {
return "management_profile";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return "balance_snapshot";
}
if (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "list_payables_counterparties" ||
intent === "list_receivables_counterparties") {
return "open_items";
}
if (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract") {
return "list_lookup";
}
if (shape === "AGGREGATE_LOOKUP") {
return "management_profile";
}
if (shape === "DOCUMENT_LIST" || shape === "OBJECT_LOOKUP" || shape === "DRILLDOWN_REQUEST") {
return "list_lookup";
}
return "unknown";
}
function buildAddressLlmPredecomposeContractV1(input) {
const sourceMessage = String(input.sourceMessage ?? "").trim();
const canonicalMessage = String(input.canonicalMessage ?? "").trim() || sourceMessage;
const mode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(canonicalMessage);
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(canonicalMessage);
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(canonicalMessage);
const extraction = (0, addressFilterExtractor_1.extractAddressFilters)(canonicalMessage, intent.intent);
const filters = extraction.extracted_filters;
const periodScope = inferPeriodScope(filters, canonicalMessage);
return {
schema_version: "address_llm_predecompose_contract_v1",
source_message: sourceMessage,
canonical_message: canonicalMessage,
mode: mode.mode,
mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
intent: intent.intent,
intent_confidence: intent.confidence,
entities: {
account: toNonEmptyString(filters.account),
counterparty: toNonEmptyString(filters.counterparty),
contract: toNonEmptyString(filters.contract),
document_type: toNonEmptyString(filters.document_type),
document_ref: toNonEmptyString(filters.document_ref),
organization: toNonEmptyString(filters.organization)
},
period: {
scope: periodScope,
period_from: toNonEmptyString(filters.period_from),
period_to: toNonEmptyString(filters.period_to),
as_of_date: toNonEmptyString(filters.as_of_date),
has_explicit_period: Boolean(toNonEmptyString(filters.as_of_date) || toNonEmptyString(filters.period_from) || toNonEmptyString(filters.period_to))
},
aggregation_profile: inferAggregationProfile(intent.intent, shape.shape)
};
}
@@ -72,6 +72,24 @@ function tokenizeAnchor(value) {
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function anchorTokenVariants(token) {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set([source]);
if (/^[а-яё]+$/iu.test(source) && source.length >= 4) {
const withoutEnding = source.replace(/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой|е|у|ы|а|я|и|ю)$/iu, "");
if (withoutEnding.length >= 3) {
variants.add(withoutEnding);
}
const withoutTrailingVowel = source.replace(/[аеёиоуыэюя]$/iu, "");
if (withoutTrailingVowel.length >= 3) {
variants.add(withoutTrailingVowel);
}
}
return Array.from(variants);
}
function matchesAnchorText(searchable, anchor) {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
@@ -84,8 +102,11 @@ function matchesAnchorText(searchable, anchor) {
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
const variants = anchorTokenVariants(token);
return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
});
});
}
function uniqueStrings(values) {
@@ -120,6 +141,17 @@ function resolvePrimaryAnchor(intent, filters) {
};
}
}
if (intent === "list_contracts_by_counterparty") {
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",