АДРЕСНЫЙ РЕЖИМ -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
@@ -9,6 +9,37 @@ export interface ComposeStageRow {
analytics: string[];
}
interface ComposeFactualReplyOptions {
userMessage?: string;
}
type PeriodProfileFocus =
| "full_profile"
| "coverage_years"
| "top_year_docs"
| "bottom_year_docs"
| "top_month_ops"
| "bottom_month_ops";
type DocumentSectionProfileFocus =
| "full_profile"
| "doc_types_only"
| "doc_types_rare_only"
| "sections_only"
| "sections_rare_only";
type CounterpartyProfileFocus =
| "full_profile"
| "total_only"
| "roles_only"
| "suppliers_only"
| "customers_only"
| "mixed_only";
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
interface YearAggPoint {
year: number;
count: number;
}
function uniqueStrings(values: string[]): string[] {
return Array.from(
new Set(
@@ -29,6 +60,280 @@ function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
});
}
function extractYearFromIso(value: string | null): number | null {
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: string | null): string | null {
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: Record<string, string> = {
"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: number, total: number): string | null {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) {
return null;
}
return `${((value / total) * 100).toFixed(1)}%`;
}
function extractAccountSectionCode(value: string | null): string | null {
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: string | null | undefined): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/\s+/g, " ")
.trim();
}
function detectPeriodProfileFocus(userMessage: string | null | undefined): PeriodProfileFocus {
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: string | null | undefined): DocumentSectionProfileFocus {
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: string | null | undefined): CounterpartyProfileFocus {
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: string | null | undefined): CounterpartyLifecycleFocus {
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: string | null | undefined): number | null {
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: ComposeStageRow): string | null {
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: YearAggPoint[],
yearOps: YearAggPoint[]
): {
dataFrom: number | null;
dataTo: number | null;
operationalFrom: number | null;
operationalTo: number | null;
tailYears: number[];
} {
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
};
}
export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
const candidates: string[] = [];
for (const row of rows) {
@@ -47,8 +352,492 @@ export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
export function composeFactualReply(
intent: AddressIntent,
rows: ComposeStageRow[]
rows: ComposeStageRow[],
options: ComposeFactualReplyOptions = {}
): { responseType: AddressResponseType; text: string } {
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
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<string, number>();
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: string[] = [
"Профиль типов документов и разделов учета собран (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<string, ComposeStageRow[]>();
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: YearAggPoint[] = (rowsByMarker.get("YEAR_OPS") ?? [])
.map((row) => ({
year: extractYearFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item): item is YearAggPoint => item.year !== null)
.sort((a, b) => b.count - a.count);
const yearDocs: YearAggPoint[] = (rowsByMarker.get("YEAR_DOCS") ?? [])
.map((row) => ({
year: extractYearFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item): item is YearAggPoint => 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 is { month: string; count: number } => 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: string[] = [
"Профиль периодов базы собран (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<string, ComposeStageRow[]>();
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: string): number =>
(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: string[] = [
"Профиль контрагентов собран (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<string, { name: string; opsCount: number; lastPeriod: string | null }>();
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: string[] = [
"Собран профиль активности заказчиков (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<string, ComposeStageRow[]>();
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: string): number =>
(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: string[] = [
"Профиль договорной базы собран (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 = [
@@ -109,6 +898,47 @@ export function composeFactualReply(
};
}
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: string[] = [
"Собран список договоров по контрагенту (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).",
@@ -194,7 +194,11 @@ function mergeFollowupFilters(
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 ??
@@ -282,6 +286,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
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"]
};
@@ -0,0 +1,165 @@
import type { AddressFilterSet, AddressIntent, AddressQuestionMode, AddressQueryShape } from "../../types/addressQuery";
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
export type AddressPredecomposePeriodScope = "all_time" | "year" | "range" | "as_of" | "unspecified";
export type AddressPredecomposeAggregationProfile =
| "management_profile"
| "list_lookup"
| "balance_snapshot"
| "open_items"
| "unknown";
export interface AddressLlmPredecomposeContractV1 {
schema_version: "address_llm_predecompose_contract_v1";
source_message: string;
canonical_message: string;
mode: AddressQuestionMode;
mode_confidence: "high" | "medium" | "low";
query_shape: AddressQueryShape;
query_shape_confidence: "high" | "medium" | "low";
intent: AddressIntent;
intent_confidence: "high" | "medium" | "low";
entities: {
account: string | null;
counterparty: string | null;
contract: string | null;
document_type: string | null;
document_ref: string | null;
organization: string | null;
};
period: {
scope: AddressPredecomposePeriodScope;
period_from: string | null;
period_to: string | null;
as_of_date: string | null;
has_explicit_period: boolean;
};
aggregation_profile: AddressPredecomposeAggregationProfile;
}
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function hasAllTimeHint(text: string): boolean {
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: AddressFilterSet, canonicalMessage: string): AddressPredecomposePeriodScope {
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: AddressIntent, shape: AddressQueryShape): AddressPredecomposeAggregationProfile {
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";
}
export function buildAddressLlmPredecomposeContractV1(input: {
sourceMessage: string;
canonicalMessage: string;
}): AddressLlmPredecomposeContractV1 {
const sourceMessage = String(input.sourceMessage ?? "").trim();
const canonicalMessage = String(input.canonicalMessage ?? "").trim() || sourceMessage;
const mode = detectAddressQuestionMode(canonicalMessage);
const shape = classifyAddressQueryShape(canonicalMessage);
const intent = resolveAddressIntent(canonicalMessage);
const extraction = 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)
};
}
@@ -89,6 +89,28 @@ function tokenizeAnchor(value: string): string[] {
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function anchorTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set<string>([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: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
@@ -101,8 +123,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
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);
});
});
}
@@ -146,6 +171,18 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
}
}
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",