АРЧ АП11 - Оркестрация: дожать агентный прогон по выбору компании и возрасту активности в 1С

This commit is contained in:
2026-04-17 16:40:08 +03:00
parent 7be037e225
commit cd0b78d1de
31 changed files with 1909 additions and 1187 deletions
@@ -4,6 +4,7 @@ import type {
AddressResponseType,
AddressResultMode
} from "../../types/addressQuery";
import { normalizeOrganizationScopeValue } from "../assistantOrganizationMatcher";
export interface ComposeStageRow {
period: string | null;
@@ -42,6 +43,7 @@ interface ComposeFactualReplyOptions {
userMessage?: string;
itemHint?: string;
counterpartyHint?: string;
organizationHint?: string;
accountHint?: string;
periodFrom?: string;
periodTo?: string;
@@ -778,6 +780,44 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
return null;
}
function normalizeCounterpartyLookupText(value: string | null | undefined): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9]+/giu, " ")
.replace(/\s+/g, " ")
.trim();
}
function counterpartyLookupMatches(candidate: string | null | undefined, hint: string | null | undefined): boolean {
const normalizedCandidate = normalizeCounterpartyLookupText(candidate);
const normalizedHint = normalizeCounterpartyLookupText(hint);
if (!normalizedCandidate || !normalizedHint) {
return false;
}
if (normalizedCandidate === normalizedHint) {
return true;
}
if (normalizedCandidate.includes(normalizedHint) || normalizedHint.includes(normalizedCandidate)) {
return true;
}
const hintTokens = normalizedHint.split(" ").filter((token) => token.length >= 3);
if (hintTokens.length === 0) {
return false;
}
return hintTokens.every((token) => normalizedCandidate.includes(token));
}
function hasCounterpartyActivityAgeQuestion(userMessage: string | null | undefined): boolean {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
return /(?:сколько\s+лет\s+активности|сколько\s+лет\s+в\s+базе|возраст\s+активности|перв(?:ая|ый|ое)\s+(?:активность|платеж|поступление|документ)|последн(?:яя|ий|ее)\s+активность|с\s+какого\s+года\s+актив)/iu.test(
text
);
}
function hasCounterpartyItemFlowQuestion(userMessage: string | undefined): boolean {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
@@ -3065,6 +3105,9 @@ export function composeFactualReply(
}
if (intent === "counterparty_activity_lifecycle") {
const activityFirstRows = rows.filter(
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST"
);
const activityRows = rows.filter(
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"
);
@@ -3073,9 +3116,46 @@ export function composeFactualReply(
);
const byCounterparty = new Map<
string,
{ name: string; opsCount: number; lastPeriod: string | null; firstPeriod: string | null; years: Set<number> }
{
name: string;
opsCount: number;
lastPeriod: string | null;
firstPeriod: string | null;
firstObservedActivity: string | null;
years: Set<number>;
}
>();
for (const row of activityFirstRows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
const year = extractYearFromIso(row.period);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
name,
opsCount,
lastPeriod: row.period,
firstPeriod: row.period,
firstObservedActivity: row.period,
years: new Set<number>(year !== null ? [year] : [])
});
continue;
}
if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) {
current.firstObservedActivity = row.period;
}
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if (year !== null) {
current.years.add(year);
}
}
for (const row of activityYearRows) {
const name = extractCounterpartyName(row);
if (!name) {
@@ -3090,6 +3170,7 @@ export function composeFactualReply(
opsCount,
lastPeriod: row.period,
firstPeriod: row.period,
firstObservedActivity: null,
years: new Set<number>(year !== null ? [year] : [])
});
continue;
@@ -3120,6 +3201,7 @@ export function composeFactualReply(
opsCount,
lastPeriod: row.period,
firstPeriod: row.period,
firstObservedActivity: row.period,
years: new Set<number>(year !== null ? [year] : [])
});
continue;
@@ -3143,6 +3225,7 @@ export function composeFactualReply(
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
const activityAgeQuestion = hasCounterpartyActivityAgeQuestion(options.userMessage);
const rankingLimit = detectRankingLimit(options.userMessage, 10);
const counterparties = counterpartiesRaw.sort((left, right) => {
if (longevityQuestion) {
@@ -3163,6 +3246,112 @@ export function composeFactualReply(
? `в ${requestedYear} году`
: "в выбранном периоде";
if (activityAgeQuestion) {
const focusedCounterparty =
counterparties.find((item) => counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null;
if (focusedCounterparty) {
const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod;
const lastObservedActivity = focusedCounterparty.lastPeriod;
const firstTimestamp = toUtcDayTimestamp(firstObservedActivity);
const lastTimestamp = toUtcDayTimestamp(lastObservedActivity);
const observedDays =
firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
: null;
const observedAgeLabel =
observedDays !== null
? formatAgeYearsMonthsDays(observedDays)
: focusedCounterparty.years.size > 0
? `${focusedCounterparty.years.size} г.`
: null;
const directLine =
observedAgeLabel && firstObservedActivity && lastObservedActivity
? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.`
: `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`;
const lines: string[] = [directLine];
if (firstObservedActivity) {
lines.push(`Первая подтвержденная активность: ${formatDateRu(firstObservedActivity)}.`);
}
if (lastObservedActivity) {
lines.push(`Последняя подтвержденная активность: ${formatDateRu(lastObservedActivity)}.`);
}
lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`);
if (focusedCounterparty.years.size > 0) {
const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b);
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
}
lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
const organizationHint = normalizeOrganizationScopeValue(options.organizationHint ?? null);
if (organizationHint && counterparties.length > 0) {
const organizationFirstObservedActivity = counterparties.reduce<string | null>((earliest, item) => {
const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null;
if (!candidate) {
return earliest;
}
if (!earliest || candidate < earliest) {
return candidate;
}
return earliest;
}, null);
const organizationLastObservedActivity = counterparties.reduce<string | null>((latest, item) => {
const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null;
if (!candidate) {
return latest;
}
if (!latest || candidate > latest) {
return candidate;
}
return latest;
}, null);
const organizationYears = new Set<number>();
let organizationOpsCount = 0;
for (const item of counterparties) {
organizationOpsCount += item.opsCount;
for (const year of item.years) {
organizationYears.add(year);
}
}
const firstTimestamp = toUtcDayTimestamp(organizationFirstObservedActivity);
const lastTimestamp = toUtcDayTimestamp(organizationLastObservedActivity);
const observedDays =
firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
: null;
const observedAgeLabel =
observedDays !== null
? formatAgeYearsMonthsDays(observedDays)
: organizationYears.size > 0
? `${organizationYears.size} г.`
: null;
const lines: string[] = [
observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity
? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.`
: `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.`
];
if (organizationFirstObservedActivity) {
lines.push(`Первая подтвержденная активность: ${formatDateRu(organizationFirstObservedActivity)}.`);
}
if (organizationLastObservedActivity) {
lines.push(`Последняя подтвержденная активность: ${formatDateRu(organizationLastObservedActivity)}.`);
}
lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`);
if (organizationYears.size > 0) {
const years = Array.from(organizationYears).sort((a, b) => a - b);
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
}
lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
}
const lines: string[] = longevityQuestion
? [
`Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
@@ -547,7 +547,7 @@ function shouldRestoreInventoryRootFrame(
const canReenterInventoryRoot =
comingFromInventoryDrilldown ||
rootContextOnly ||
(currentFrameKind === "inventory_root" && hasSamePeriodHint(normalized)) ||
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
if (!canReenterInventoryRoot) {
return false;
@@ -694,6 +694,7 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
function isValueCounterpartyIntent(intent: AddressIntent): boolean {
return (
intent === "counterparty_activity_lifecycle" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value"