АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests
This commit is contained in:
@@ -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 {
|
||||
|
||||
+106
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user