ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека exact-маршрута payables на дату: confirmed_balance без эвристического финала

This commit is contained in:
dctouch 2026-04-12 13:46:14 +03:00
parent ca2feab893
commit 1b2ee93176
19 changed files with 2453 additions and 252 deletions

View File

@ -0,0 +1,254 @@
# Address Query Spec: Confirmed Payables As Of Date
## 1. Контекст проблемы
Запросы вида:
- `кому мы должны на май 2020`
- `кому должны заплатить на 31.05.2020`
- `по кому кредиторка на дату`
относятся к классу **балансных бухгалтерских запросов** и требуют детерминированного расчета открытых обязательств на дату среза.
Текущее поведение в части сценариев использует эвристический shortlist по движениям, что допустимо только как внутренний аварийный режим и не должно быть финальным пользовательским ответом для этого класса запросов.
## 2. Цель
Ввести для payables-вопросов на дату строгий маршрут `payables_confirmed_as_of_date_v1`, который:
- строит подтвержденный реестр обязательств к оплате на дату,
- возвращает только доказуемые позиции,
- не отдает `heuristic_candidates` как финальный ответ,
- при невозможности точного расчета возвращает технически честный `LIMITED_WITH_REASON` с причинами.
## 3. Scope
Входит в scope:
- интенты payables на дату (`кому должны`, `кому заплатить`, `кредиторка на дату`);
- расчет остатка по контрагенту/договору/объекту расчетов;
- категоризация обязательств;
- evidence chain по каждой строке ответа;
- контракт ответа и debug-поля.
Не входит в scope:
- прогнозный cash-flow и приоритизация оплат по бизнес-правилам;
- оптимизация платежного календаря;
- юридическая интерпретация договорных сроков оплаты.
## 4. Канонизация запроса
Пользовательская формулировка должна переводиться в канонический контракт:
- `intent = payables_confirmed_as_of_date`
- `as_of_date = <дата среза>` (например `2020-05-31`)
- `group_by = counterparty`
- `detail_level = contract + settlement_object + source_refs`
- `output_mode = confirmed_balance_only`
Правило периода:
- если задан месяц/год (`май 2020`) и нет явной даты, то `as_of_date = конец_месяца`.
## 5. Архитектурный pipeline
`normalize -> route -> evidence acquisition -> balance calculation -> admissibility gate -> response compose`
### 5.1 Route
Для `payables_confirmed_as_of_date` запрещен прямой пользовательский выход в эвристику.
Разрешенные вычислительные режимы:
1. `direct_balance` (предпочтительно)
2. `reconstructed_balance` (fallback расчета)
3. `limited_exact_unavailable` (честный отказ exact)
### 5.2 Evidence acquisition
Собираемые данные (минимум):
- организация;
- контрагент;
- договор;
- объект расчетов/документ расчетов;
- счет учета;
- сумма;
- период движения;
- связи возникновения/погашения.
### 5.3 Balance calculation
При `direct_balance`:
- используются остатки/регистры на дату среза.
При `reconstructed_balance`:
- рассчитывается:
`balance_as_of = opening + accrued - paid +/- adjustments` на дату среза;
- расчет ведется по ключу:
`organization + counterparty + contract + settlement_object + account`.
### 5.4 Admissibility gate
Позиция включается в confirmed-ответ только если:
- есть идентифицированный контрагент;
- рассчитан остаток на дату;
- остаток `> 0` (для payables);
- есть source refs достаточной силы;
- нет противоречащего сигнала полного закрытия на дату.
Иначе позиция исключается из confirmed output.
## 6. Доменные сущности
### 6.1 `payable_position`
- `organization`
- `counterparty`
- `contract`
- `settlement_object`
- `account`
- `category`
- `currency`
- `opening_amount`
- `accrued_amount`
- `paid_amount`
- `adjusted_amount`
- `balance_as_of`
- `as_of_date`
- `source_refs[]`
### 6.2 `payable_source_ref`
- `source_type`
- `document_ref`
- `register_ref`
- `movement_ref`
- `period`
- `amount`
- `role_in_chain` (`origin|payment|adjustment|reclass|balance_snapshot`)
### 6.3 `payable_evidence_bundle`
- `position_id`
- `evidence_strength` (`weak|medium|strong`)
- `balance_confirmed` (`true|false`)
- `balance_derivation_mode` (`direct_balance|reconstructed_balance`)
- `missing_fields[]`
- `conflicting_signals[]`
### 6.4 `payable_category`
- `supplier_contractor`
- `bank_credit`
- `tax_state`
- `payroll_related`
- `other`
## 7. Политика fallback
Для `payables_confirmed_as_of_date`:
- запрещено возвращать `heuristic_candidates` как финальный пользовательский ответ;
- если exact не собран, вернуть `LIMITED_WITH_REASON` с перечислением:
- каких полей/связок не хватило,
- на каком шаге не пройдена доказуемость,
- что нужно для точного ответа.
Допускается внутренний heuristic-pass только для диагностики, без публикации как фактического ответа.
## 8. Контракт ответа
### 8.1 Confirmed ответ (`FACTUAL_LIST`)
Обязательные блоки:
1. Статус результата (`confirmed_balance`)
2. Дата среза и basis расчета
3. Сводка (контрагентов, общая сумма)
4. Категории обязательств
5. Реестр подтвержденных позиций
6. Source refs (минимум по top-N)
### 8.2 Exact недоступен (`LIMITED_WITH_REASON`)
Обязательные блоки:
1. Что именно не удалось доказать
2. Какие поля/связки отсутствуют
3. На каком этапе сорвался расчет
4. Что нужно для подтвержденного ответа
## 9. Debug/Telemetry требования
Добавить/соблюдать поля:
- `requested_result_mode = confirmed_balance`
- `result_mode = confirmed_balance | limited_exact_unavailable`
- `balance_confirmed = true | false`
- `balance_derivation_mode = direct_balance | reconstructed_balance | unavailable`
- `selected_recipe_effective`
- `evidence_strength`
- `admissibility_gate_outcome`
- `missing_required_evidence[]`
Инвариант:
- если `balance_confirmed = false`, то `response_type != FACTUAL_LIST` для данного интента.
## 10. Acceptance criteria
Кейс:
- запрос: `кому мы должны на май 2020`
- ожидаемая дата среза: `31.05.2020`
Критерии приемки:
1. Запрос маршрутизируется в `payables_confirmed_as_of_date`.
2. Финальный ответ либо `confirmed_balance`, либо `LIMITED_WITH_REASON` exact-недоступности.
3. Нет финального эвристического shortlist для этого интента.
4. По каждой подтвержденной строке есть evidence chain.
5. Категории обязательств разделены и не смешиваются в неразмеченный “общий топ”.
6. В ответе явно указана дата среза и способ расчета.
## 11. Тестовый контур (минимум)
Unit:
- канонизация месяца в `as_of_date`;
- route lock на `payables_confirmed_as_of_date`;
- запрет heuristic final output;
- admissibility gate.
Integration:
- `direct_balance` happy path;
- `reconstructed_balance` fallback path;
- `limited_exact_unavailable` path с понятной причиной.
Regression:
- сценарии с сокращениями контрагентов;
- follow-up после списков;
- кейсы с банками/депозитами/госорганами.
## 12. Rollout
1. Feature flag: `FEATURE_PAYABLES_CONFIRMED_AS_OF_V1`.
2. Shadow mode (сравнение старого/нового результата без выдачи пользователю).
3. Limited pilot на продукционных диалогах.
4. Полный switch при достижении приемочных метрик.
## 13. Запреты
Для `payables_confirmed_as_of_date` запрещено:
- возвращать “кандидаты на проверку” как финальный factual-answer;
- использовать формулировки платежной рекомендации при `balance_confirmed=false`;
- подменять расчет обязательств простым списком движений.

View File

@ -300,6 +300,10 @@ const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [
"самые доходные заказчики",
"топ клиентов по сумме поступлений",
"топ заказчиков по сумме поступлений",
"кто больше всего принес денег",
"кто больше всего принёс денег",
"кто принес больше всего денег",
"кто принёс больше всего денег",
"кто нам больше всего занес",
"кто нам больше всего занёс",
"кто нам принес больше всего",
@ -685,6 +689,7 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksWhoBringsMostMoney = /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(text);
const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
@ -708,6 +713,9 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}
@ -823,6 +831,18 @@ function hasSupplierTailRiskSignal(text) {
const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text);
return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
}
function hasPayablesDebtLifecycleSignal(text) {
const hasOweSignal = /(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test(text);
if (!hasOweSignal) {
return false;
}
const hasPastPaymentSignal = /(?:заплатил(?:и)?|платил(?:и)?|кому\s+ушло|выплатил(?:и)?|списан|outflow|payout)/iu.test(text);
const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text);
if (hasPastPaymentSignal && hasTopRankingSignal) {
return false;
}
return true;
}
function hasReceivablesLatencyRiskSignal(text) {
const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text);
const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text);
@ -1204,10 +1224,14 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return {
intent: "list_payables_counterparties",
confidence: "high",
reasons: ["payables_signal_detected"]
reasons
};
}
if (hasSettlementGapSignal(text)) {
@ -1242,7 +1266,7 @@ function resolveAddressIntent(userMessage) {
return {
intent: "list_payables_counterparties",
confidence: "medium",
reasons: ["supplier_tail_risk_signal_detected"]
reasons: ["supplier_tail_risk_signal_detected", "payables_debt_lifecycle_signal_detected"]
};
}
if (hasDocumentsFormingBalanceSignal(text) && hasDocumentsFormingBalanceAccountAnchor(text)) {

View File

@ -631,6 +631,92 @@ function isCounterpartyRiskIntent(intent) {
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract");
}
function isHeuristicCandidatesIntent(intent) {
return (intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract");
}
function isConfirmedBalanceIntent(intent) {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function resolveAsOfDateBasis(filters) {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
if (asOfDate) {
return "explicit_as_of_date";
}
const periodFrom = normalizeAnalysisDateHint(filters.period_from);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
if (periodFrom && periodTo) {
return "period_range";
}
if (!periodFrom && periodTo) {
return "period_end";
}
if (periodFrom) {
return "period_range";
}
return null;
}
function deriveAddressEvidenceStrength(input) {
if (isHeuristicCandidatesIntent(input.intent)) {
if (input.rowsMatched <= 0 || input.responseType === "LIMITED_WITH_REASON") {
return "weak";
}
if (input.selectedRecipe === "address_open_items_by_party_or_contract_v1") {
return "medium";
}
return "weak";
}
if (isConfirmedBalanceIntent(input.intent)) {
if (input.rowsMatched > 0) {
return "strong";
}
return input.responseType === "LIMITED_WITH_REASON" ? "weak" : "medium";
}
return undefined;
}
function resolveRequestedResultMode(intent, filters) {
if (isConfirmedBalanceIntent(intent)) {
return "confirmed_balance";
}
if (isHeuristicCandidatesIntent(intent)) {
const asOfDateBasis = resolveAsOfDateBasis(filters);
if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") {
return "confirmed_balance";
}
return "heuristic_candidates";
}
return undefined;
}
function deriveAddressResultSemantics(input) {
const asOfDateBasis = resolveAsOfDateBasis(input.filters);
const requestedResultMode = resolveRequestedResultMode(input.intent, input.filters);
if (isHeuristicCandidatesIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "heuristic_candidates",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: false,
as_of_date_basis: asOfDateBasis
};
}
if (isConfirmedBalanceIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true,
as_of_date_basis: asOfDateBasis ?? "period_end"
};
}
if (requestedResultMode) {
return {
requested_result_mode: requestedResultMode
};
}
return {};
}
function resolveFutureGuardReferenceDate(analysisDate, filters) {
if (analysisDate) {
return analysisDate;
@ -1196,6 +1282,13 @@ function composeLimitedReply(input) {
}
function buildLimitedExecutionResult(input) {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
const resultSemantics = deriveAddressResultSemantics({
intent: input.intent.intent,
selectedRecipe: input.selectedRecipe,
filters: input.filters,
responseType: "LIMITED_WITH_REASON",
rowsMatched: input.rowsMatched
});
return {
handled: true,
reply_text: composeLimitedReply({
@ -1246,6 +1339,7 @@ function buildLimitedExecutionResult(input) {
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
...resultSemantics,
limitations: input.limitations,
reasons: input.reasons
}
@ -1288,11 +1382,25 @@ class AddressQueryService {
const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) &&
intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
const debtLifecyclePayablesScenario = intent.intent === "list_payables_counterparties" &&
Array.isArray(intent.reasons) &&
(intent.reasons.includes("payables_debt_lifecycle_signal_detected") ||
intent.reasons.includes("supplier_tail_risk_signal_detected") ||
intent.reasons.includes("payables_signal_detected"));
const recipeIntent = debtLifecycleReceivablesScenario || debtLifecyclePayablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, filters.extracted_filters);
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle");
}
if (debtLifecyclePayablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_payables_debt_lifecycle");
}
if (requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) {
baseReasons.push("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
}
if (intent.intent === "unknown") {
return buildLimitedExecutionResult({
mode,
@ -1576,6 +1684,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: recoveredRows.length
}),
limitations: [...filters.warnings, recoveryReason],
reasons: [...baseReasons, recoveryReason]
}
@ -1692,6 +1807,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: expandedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: expandedFactual.responseType,
rowsMatched: expandedFilteredRows.length
}),
limitations: expandedLimitations,
reasons: expandedReasons
}
@ -1803,6 +1925,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: broadenedFactual.responseType,
rowsMatched: broadenedFilteredRows.length
}),
limitations: broadenedLimitations,
reasons: broadenedReasons
}
@ -1922,6 +2051,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: historicalFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: historicalSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: historicalFactual.responseType,
rowsMatched: historicalFilteredRows.length
}),
limitations: historicalLimitations,
reasons: historicalReasons
}
@ -1986,6 +2122,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: fallbackFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: fallbackFactual.responseType,
rowsMatched: documentBankFallbackRows.length
}),
limitations: fallbackLimitations,
reasons: fallbackReasons
}
@ -2142,6 +2285,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}),
limitations: filters.warnings,
reasons: baseReasons
}

View File

@ -470,6 +470,142 @@ function extractCounterpartyName(row) {
}
return null;
}
function liabilityCategoryLabel(category) {
if (category === "supplier_or_contractor") {
return "поставщики/подрядчики";
}
if (category === "bank_or_credit") {
return "банки/кредиты";
}
if (category === "tax_or_state") {
return "налоги/госорганы";
}
return "прочие";
}
function classifyPayablesLiabilityCategory(row, counterparty) {
const scores = {
supplier_or_contractor: 0,
bank_or_credit: 0,
tax_or_state: 0,
other: 0
};
const reasons = new Set();
const text = `${counterparty} ${row.registrator} ${row.analytics.join(" ")}`.toLowerCase();
const accountPrefixes = [extractAccountSectionCode(row.account_dt), extractAccountSectionCode(row.account_kt)].filter((item) => Boolean(item));
if (accountPrefixes.includes("60")) {
scores.supplier_or_contractor += 3;
reasons.add("участие счета 60");
}
if (accountPrefixes.includes("66") || accountPrefixes.includes("67")) {
scores.bank_or_credit += 4;
reasons.add("участие счета 66/67");
}
if (accountPrefixes.includes("68") || accountPrefixes.includes("69")) {
scores.tax_or_state += 4;
reasons.add("участие счета 68/69");
}
if (accountPrefixes.includes("76")) {
scores.supplier_or_contractor += 1;
reasons.add("участие счета 76");
}
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|loan|overdraft)/iu.test(text)) {
scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике");
}
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос)/iu.test(text)) {
scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике");
}
if (/(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor)/iu.test(text)) {
scores.supplier_or_contractor += 2;
reasons.add("коммерческий контрагент в аналитике");
}
return {
scores,
reasons: Array.from(reasons)
};
}
function buildPayablesCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const amountRaw = row.amount ?? 0;
if (!Number.isFinite(amountRaw)) {
continue;
}
const amount = Math.abs(amountRaw);
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
base: {
name,
totalAmount: amount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period
},
categoryScores: {
supplier_or_contractor: classified.scores.supplier_or_contractor,
bank_or_credit: classified.scores.bank_or_credit,
tax_or_state: classified.scores.tax_or_state,
other: classified.scores.other
},
reasons: new Set(classified.reasons)
});
continue;
}
current.base.totalAmount += amount;
current.base.operations += 1;
if ((row.period ?? "") < (current.base.firstPeriod ?? "")) {
current.base.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.base.lastPeriod ?? "")) {
current.base.lastPeriod = row.period;
}
current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor;
current.categoryScores.bank_or_credit += classified.scores.bank_or_credit;
current.categoryScores.tax_or_state += classified.scores.tax_or_state;
current.categoryScores.other += classified.scores.other;
for (const reason of classified.reasons) {
current.reasons.add(reason);
}
}
const scoreKeys = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
const toCategory = (scores) => {
let winner = "other";
let best = Number.NEGATIVE_INFINITY;
for (const key of scoreKeys) {
const score = scores[key];
if (score > best) {
best = score;
winner = key;
}
}
if (best <= 0) {
return "other";
}
return winner;
};
return Array.from(byCounterparty.values())
.map((item) => ({
...item.base,
category: toCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.sort((left, right) => {
if (right.totalAmount !== left.totalAmount) {
return right.totalAmount - left.totalAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.name.localeCompare(right.name);
});
}
function buildCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
@ -1528,22 +1664,55 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const scopeLine = (() => {
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
if (asOfDate) {
return `Дата среза: ${formatDateRu(asOfDate)}.`;
}
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
if (periodFrom || periodTo) {
return `Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`;
}
return null;
})();
const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
"Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).",
"",
"Что это значит:",
"- Режим результата: эвристический скоринг по движениям.",
"- Это не финальный подтвержденный остаток к оплате.",
...(scopeLine ? ["", scopeLine] : []),
"",
`Строк в выборке: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.`
`Контрагентов-кандидатов: ${counterparties.length}.`
];
if (counterparties.length > 0) {
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
const categoryCounts = counterparties.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
lines.push("");
lines.push("Категории обязательств:");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Приоритет ручной проверки (по сумме/частоте сигналов):");
lines.push(...counterparties
.slice(0, 8)
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
.map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | статус: требует ручной проверки${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`));
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 4));
}
else {
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
lines.push("");
lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено.");
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 6));
}
return {

View File

@ -1460,6 +1460,11 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
requested_result_mode: addressDebug.requested_result_mode ?? undefined,
result_mode: addressDebug.result_mode ?? undefined,
evidence_strength: addressDebug.evidence_strength ?? undefined,
balance_confirmed: typeof addressDebug.balance_confirmed === "boolean" ? addressDebug.balance_confirmed : undefined,
as_of_date_basis: addressDebug.as_of_date_basis ?? undefined,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
@ -1588,6 +1593,19 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"kakoi",
"vse",
"all",
"\u043f\u0443\u043d\u043a\u0442",
"\u043f\u0443\u043d\u043a\u0442\u0430",
"\u043f\u0443\u043d\u043a\u0442\u0443",
"\u043f\u0443\u043d\u043a\u0442\u043e\u043c",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044f",
"\u043f\u043e\u0437\u0438\u0446\u0438\u0438",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044e",
"\u0441\u0442\u0440\u043e\u043a\u0430",
"\u0441\u0442\u0440\u043e\u043a\u0438",
"\u0441\u0442\u0440\u043e\u043a\u0443",
"item",
"row",
"line",
"blya",
"blyat",
"епт",
@ -1612,51 +1630,51 @@ const ADDRESS_FALLBACK_STRIP_TOKENS = new Set([
"please"
]);
const ADDRESS_MONTH_ALIAS_MAP = {
янв: "01",
январ: "01",
"\u044f\u043d\u0432": "01",
"\u044f\u043d\u0432\u0430\u0440": "01",
january: "01",
jan: "01",
фев: "02",
феврал: "02",
"\u0444\u0435\u0432": "02",
"\u0444\u0435\u0432\u0440\u0430\u043b": "02",
february: "02",
feb: "02",
мар: "03",
март: "03",
"\u043c\u0430\u0440": "03",
"\u043c\u0430\u0440\u0442": "03",
march: "03",
apr: "04",
апр: "04",
апрел: "04",
"\u0430\u043f\u0440": "04",
"\u0430\u043f\u0440\u0435\u043b": "04",
april: "04",
май: "05",
ма: "05",
"\u043c\u0430\u0439": "05",
"\u043c\u0430": "05",
may: "05",
июн: "06",
июнь: "06",
"\u0438\u044e\u043d": "06",
"\u0438\u044e\u043d\u044c": "06",
june: "06",
jun: "06",
июл: "07",
июль: "07",
"\u0438\u044e\u043b": "07",
"\u0438\u044e\u043b\u044c": "07",
july: "07",
jul: "07",
авг: "08",
август: "08",
"\u0430\u0432\u0433": "08",
"\u0430\u0432\u0433\u0443\u0441\u0442": "08",
august: "08",
aug: "08",
сен: "09",
сент: "09",
сентябр: "09",
"\u0441\u0435\u043d": "09",
"\u0441\u0435\u043d\u0442": "09",
"\u0441\u0435\u043d\u0442\u044f\u0431\u0440": "09",
september: "09",
sep: "09",
окт: "10",
октябр: "10",
"\u043e\u043a\u0442": "10",
"\u043e\u043a\u0442\u044f\u0431\u0440": "10",
october: "10",
oct: "10",
ноя: "11",
ноябр: "11",
"\u043d\u043e\u044f": "11",
"\u043d\u043e\u044f\u0431\u0440": "11",
november: "11",
nov: "11",
дек: "12",
декабр: "12",
"\u0434\u0435\u043a": "12",
"\u0434\u0435\u043a\u0430\u0431\u0440": "12",
december: "12",
dec: "12"
};
@ -1883,6 +1901,10 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
const bankSignal = ADDRESS_BANK_SIGNAL_PATTERN.test(source);
const contractSignal = ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
const balanceSignal = ADDRESS_BALANCE_SIGNAL_PATTERN.test(source);
const hasIndexPointerSignal = /(?:\u043f\u0443\u043d\u043a\u0442|\u043f\u043e\u0437\u0438\u0446|\u0441\u0442\u0440\u043e\u043a|item|row|line)/iu.test(sourceRaw);
if (hasIndexPointerSignal && extractDisplayedEntityIndexMention(sourceRaw) !== null) {
return null;
}
if (balanceSignal && account) {
let periodClause = "";
let rule = "balance_account_rewrite";
@ -2201,6 +2223,14 @@ const FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS = new Set([
"company",
"group"
]);
const FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT = {
counterparty_activity_lifecycle: "counterparty",
customer_revenue_and_payments: "counterparty",
supplier_payouts_profile: "counterparty",
counterparty_population_and_roles: "counterparty",
contract_usage_and_value: "contract",
list_contracts_by_counterparty: "contract"
};
function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
@ -2211,7 +2241,25 @@ function normalizeCounterpartyForFollowupMatch(value) {
function normalizeCounterpartyTokenForFollowupMatch(value) {
return normalizeCounterpartyForFollowupMatch(value).replace(/[._-]+/g, "");
}
function extractDisplayedCounterpartyCandidates(replyText) {
function normalizeCounterpartyStemForFollowupMatch(value) {
const compact = normalizeCounterpartyTokenForFollowupMatch(value);
if (!compact || !/[а-яё]/iu.test(compact)) {
return compact;
}
const stem = compact.replace(/(?:иями|ями|ами|ией|ей|ий|ов|ев|ом|ем|ах|ях|ую|юю|ая|яя|ое|ее|ые|ие|ого|его|ому|ему|ыми|ими|ым|им|ам|ям|у|ю|а|я|е|и|ы|о)$/iu, "");
return stem.length >= 3 ? stem : compact;
}
function inferDisplayedEntityTypeFromIntent(intent) {
const normalized = compactWhitespace(String(intent ?? "").toLowerCase());
if (!normalized) {
return "unknown";
}
return FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT[normalized] ?? "unknown";
}
function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknown") {
if (entityType === "unknown") {
return [];
}
const lines = String(replyText ?? "").split(/\r?\n/);
const candidates = [];
for (const line of lines) {
@ -2219,10 +2267,15 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!compactLine) {
continue;
}
if (!/^\d+\.\s+/.test(compactLine)) {
const numberedMatch = compactLine.match(/^(\d+)\.\s+(.+)$/);
if (!numberedMatch) {
continue;
}
const afterNumber = compactLine.replace(/^\d+\.\s+/, "");
const index = Number.parseInt(String(numberedMatch[1] ?? ""), 10);
if (!Number.isFinite(index) || index <= 0) {
continue;
}
const afterNumber = String(numberedMatch[2] ?? "");
const parts = afterNumber.split("|").map((item) => compactWhitespace(item));
let counterpartyCandidate = parts[0] ?? "";
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
@ -2232,9 +2285,20 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
candidates.push(cleanedCandidate);
candidates.push({
index,
value: cleanedCandidate,
entityType
});
}
return Array.from(new Set(candidates));
const dedup = new Map();
for (const candidate of candidates) {
const key = `${candidate.entityType}:${candidate.index}:${normalizeCounterpartyForFollowupMatch(candidate.value)}`;
if (!dedup.has(key)) {
dedup.set(key, candidate);
}
}
return Array.from(dedup.values());
}
function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
const aliases = new Set();
@ -2247,13 +2311,14 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
const withoutLegalTokens = normalizedTokens
const tokensForAlias = Array.from(new Set(normalizedTokens.flatMap((token) => [token, ...token.split(/-+/).map((part) => part.trim()).filter(Boolean)])));
const withoutLegalTokens = tokensForAlias
.filter((token) => !FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS.has(token))
.join(" ");
if (withoutLegalTokens) {
aliases.add(withoutLegalTokens);
}
for (const token of normalizedTokens) {
for (const token of tokensForAlias) {
const compactToken = normalizeCounterpartyTokenForFollowupMatch(token);
if (compactToken.length < 3) {
continue;
@ -2265,6 +2330,10 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
continue;
}
aliases.add(compactToken);
const stemToken = normalizeCounterpartyStemForFollowupMatch(compactToken);
if (stemToken.length >= 4) {
aliases.add(stemToken);
}
}
return Array.from(aliases)
.map((alias) => compactWhitespace(alias))
@ -2278,31 +2347,95 @@ function hasCounterpartyAliasMention(normalizedMessage, alias) {
}
const aliasPattern = escapeRegex(trimmedAlias).replace(/\s+/g, "\\s+");
const boundaryPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}(?:$|[^a-zа-я0-9])`, "iu");
return boundaryPattern.test(normalizedMessage);
if (boundaryPattern.test(normalizedMessage)) {
return true;
}
if (trimmedAlias.length < 4 || !/[а-яё]/iu.test(trimmedAlias)) {
return false;
}
const fuzzyPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}[а-яё]{0,3}(?:$|[^a-zа-я0-9])`, "iu");
return fuzzyPattern.test(normalizedMessage);
}
function resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) {
function extractDisplayedEntityIndexMention(userMessage) {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
if (!normalized) {
return null;
}
const tokenStart = "(?:^|[^\\p{L}\\p{N}_])";
const tokenEnd = "(?=$|[^\\p{L}\\p{N}_])";
const pointerPattern = "(?:\\u043f\\u0443\\u043d\\u043a\\u0442(?:\\u0430|\\u0443|\\u043e\\u043c)?|\\u043f\\u043e\\u0437\\u0438\\u0446\\u0438(?:\\u044f|\\u0438|\\u044e|\\u0435\\u0439)|\\u0441\\u0442\\u0440\\u043e\\u043a(?:\\u0430|\\u0438|\\u0435|\\u0443)|item|row|line)";
const pointerSignalPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}`, "iu");
const directPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}\\D{0,8}(\\d{1,3})(?!\\d)`, "iu");
const directMatch = normalized.match(directPattern);
if (directMatch) {
const value = Number.parseInt(String(directMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
const reversePattern = new RegExp(`${tokenStart}(\\d{1,3})(?:-?(?:\\u0439|\\u044f|\\u0435|\\u0433\\u043e|\\u043c\\u0443))?\\s+${pointerPattern}${tokenEnd}`, "iu");
const reverseMatch = normalized.match(reversePattern);
if (reverseMatch) {
const value = Number.parseInt(String(reverseMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
if (pointerSignalPattern.test(normalized)) {
const numericMatches = Array.from(normalized.matchAll(/(?:^|[^\p{N}])(\d{1,3})(?!\d)/gu))
.map((match) => Number.parseInt(String(match[1] ?? ""), 10))
.filter((value) => Number.isFinite(value) && value > 0);
if (numericMatches.length === 1) {
return numericMatches[0];
}
}
return null;
}
function resolveDisplayedAddressEntityMention(userMessage, displayedEntities) {
const normalizedMessage = normalizeCounterpartyForFollowupMatch(userMessage);
if (!normalizedMessage) {
return null;
}
if (!Array.isArray(displayedCounterparties) || displayedCounterparties.length === 0) {
if (!Array.isArray(displayedEntities) || displayedEntities.length === 0) {
return null;
}
const indexMention = extractDisplayedEntityIndexMention(userMessage);
if (indexMention !== null) {
const indexedCandidate = displayedEntities.find((candidate) => Number(candidate.index) === indexMention);
if (indexedCandidate) {
return {
value: indexedCandidate.value,
entityType: indexedCandidate.entityType,
matchKind: "index",
index: indexedCandidate.index
};
}
}
let bestMatch = null;
for (const candidate of displayedCounterparties) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate);
for (const candidate of displayedEntities) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate.value);
for (const alias of aliases) {
if (!hasCounterpartyAliasMention(normalizedMessage, alias)) {
continue;
}
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate) === alias ? 1 : 0);
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate.value) === alias ? 1 : 0);
if (!bestMatch || score > bestMatch.score) {
bestMatch = { value: candidate, score };
bestMatch = {
value: candidate.value,
entityType: candidate.entityType,
index: candidate.index,
matchKind: "alias",
score
};
}
break;
}
}
return bestMatch?.value ?? null;
if (!bestMatch) {
return null;
}
return {
value: bestMatch.value,
entityType: bestMatch.entityType,
matchKind: bestMatch.matchKind,
index: bestMatch.index
};
}
function findRecentAddressFilterValue(items, key) {
for (let index = items.length - 1; index >= 0; index -= 1) {
@ -2479,12 +2612,17 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
: false;
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) {
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!previousAddressDebug) {
@ -2531,16 +2669,24 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousFilters.organization = historicalOrganization;
}
}
const displayedCounterparties = extractDisplayedCounterpartyCandidates(toNonEmptyString(previousAddressItem?.text) ?? "");
const counterpartyFromFollowupText = resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) ??
const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent);
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
(toNonEmptyString(alternateMessage)
? resolveDisplayedCounterpartyMention(String(alternateMessage ?? ""), displayedCounterparties)
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
: null);
if (counterpartyFromFollowupText) {
previousFilters.counterparty = counterpartyFromFollowupText;
previousAnchorType = "counterparty";
previousAnchor = counterpartyFromFollowupText;
resolvedCounterpartyFromDisplay = true;
if (resolvedEntityFromFollowup) {
if (resolvedEntityFromFollowup.entityType === "counterparty") {
previousFilters.counterparty = resolvedEntityFromFollowup.value;
previousAnchorType = "counterparty";
previousAnchor = resolvedEntityFromFollowup.value;
resolvedCounterpartyFromDisplay = true;
}
else if (resolvedEntityFromFollowup.entityType === "contract") {
previousFilters.contract = resolvedEntityFromFollowup.value;
previousAnchorType = "contract";
previousAnchor = resolvedEntityFromFollowup.value;
}
if (followupSelectionMode !== "switch_to_suggested_intent") {
followupSelectionMode = "carry_referenced_entity";
}
@ -3373,7 +3519,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown" &&
!followupContext &&
!hasClassifierSignal &&
!strongDataSignalFromRawMessage) {
!hasIntentSignal &&
!strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@ -4510,7 +4658,7 @@ function isPlausibleOrganizationName(value) {
if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) {
return false;
}
return /[A-Za-zА-Яа-яЁё]/u.test(candidate);
return /[A-Za-z\u0400-\u04FF]/u.test(candidate);
}
function appendOrganizationFactsFromValue(value, hints, bucket, depth = 0) {
if (depth > 4 || value === null || value === undefined) {

View File

@ -313,6 +313,10 @@ const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [
"самые доходные заказчики",
"топ клиентов по сумме поступлений",
"топ заказчиков по сумме поступлений",
"кто больше всего принес денег",
"кто больше всего принёс денег",
"кто принес больше всего денег",
"кто принёс больше всего денег",
"кто нам больше всего занес",
"кто нам больше всего занёс",
"кто нам принес больше всего",
@ -782,6 +786,10 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksWhoBringsMostMoney =
/(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(
text
);
const asksDealBudgetRanking =
/(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(
@ -816,6 +824,9 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}
@ -956,6 +967,22 @@ function hasSupplierTailRiskSignal(text: string): boolean {
return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
}
function hasPayablesDebtLifecycleSignal(text: string): boolean {
const hasOweSignal =
/(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test(
text
);
if (!hasOweSignal) {
return false;
}
const hasPastPaymentSignal = /(?:заплатил(?:и)?|платил(?:и)?|кому\s+ушло|выплатил(?:и)?|списан|outflow|payout)/iu.test(text);
const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text);
if (hasPastPaymentSignal && hasTopRankingSignal) {
return false;
}
return true;
}
function hasReceivablesLatencyRiskSignal(text: string): boolean {
const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text);
const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text);
@ -1404,10 +1431,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
}
if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return {
intent: "list_payables_counterparties",
confidence: "high",
reasons: ["payables_signal_detected"]
reasons
};
}
@ -1447,7 +1478,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
return {
intent: "list_payables_counterparties",
confidence: "medium",
reasons: ["supplier_tail_risk_signal_detected"]
reasons: ["supplier_tail_risk_signal_detected", "payables_debt_lifecycle_signal_detected"]
};
}

View File

@ -1,8 +1,10 @@
import {
import {
FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
} from "../config";
import type {
AddressAsOfDateBasis,
AddressEvidenceStrength,
AddressExecutionResult,
AddressFilterSet,
AddressIntent,
@ -10,14 +12,19 @@ import type {
AddressMatchFailureStage,
AddressMcpCallStatus,
AddressQueryShapeDetection,
AddressResultMode,
AddressResponseType,
AddressRuntimeReadiness
} from "../types/addressQuery";
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
import {
buildAddressRecipePlan,
selectAddressRecipe,
type AddressRecipeExecutionPlan
} from "./addressRecipeCatalog";
import { executeAddressMcpQuery } from "./addressMcpClient";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
import { composeFactualReply, inferReplyType } from "./address_runtime/composeStage";
import { composeFactualReply, inferReplyType, type ComposeReplySemantics } from "./address_runtime/composeStage";
interface NormalizedAddressRow {
period: string | null;
@ -36,6 +43,7 @@ interface AddressTryHandleOptions {
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const PARTY_ANCHOR_STOPWORDS = new Set([
@ -742,6 +750,197 @@ function isCounterpartyRiskIntent(intent: AddressIntent): boolean {
);
}
function isHeuristicCandidatesIntent(intent: AddressIntent): boolean {
return (
intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract"
);
}
function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function resolveAsOfDateBasis(filters: AddressFilterSet): AddressAsOfDateBasis | null {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
if (asOfDate) {
return "explicit_as_of_date";
}
const periodFrom = normalizeAnalysisDateHint(filters.period_from);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
if (periodFrom && periodTo) {
return "period_range";
}
if (!periodFrom && periodTo) {
return "period_end";
}
if (periodFrom) {
return "period_range";
}
return null;
}
function deriveAddressEvidenceStrength(input: {
intent: AddressIntent;
selectedRecipe: string | null;
responseType: AddressResponseType;
rowsMatched: number;
}): AddressEvidenceStrength | undefined {
if (isHeuristicCandidatesIntent(input.intent)) {
if (input.rowsMatched <= 0 || input.responseType === "LIMITED_WITH_REASON") {
return "weak";
}
if (input.selectedRecipe === "address_open_items_by_party_or_contract_v1") {
return "medium";
}
return "weak";
}
if (isConfirmedBalanceIntent(input.intent)) {
if (input.rowsMatched > 0) {
return "strong";
}
return input.responseType === "LIMITED_WITH_REASON" ? "weak" : "medium";
}
return undefined;
}
function resolveRequestedResultMode(intent: AddressIntent, filters: AddressFilterSet): AddressResultMode | undefined {
if (isConfirmedBalanceIntent(intent)) {
return "confirmed_balance";
}
if (isHeuristicCandidatesIntent(intent)) {
const asOfDateBasis = resolveAsOfDateBasis(filters);
if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") {
return "confirmed_balance";
}
return "heuristic_candidates";
}
return undefined;
}
function deriveAddressResultSemantics(input: {
intent: AddressIntent;
selectedRecipe: string | null;
filters: AddressFilterSet;
responseType: AddressResponseType;
rowsMatched: number;
}): {
requested_result_mode?: AddressResultMode;
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
as_of_date_basis?: AddressAsOfDateBasis | null;
} {
const asOfDateBasis = resolveAsOfDateBasis(input.filters);
const requestedResultMode = resolveRequestedResultMode(input.intent, input.filters);
if (isHeuristicCandidatesIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "heuristic_candidates",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: false,
as_of_date_basis: asOfDateBasis
};
}
if (isConfirmedBalanceIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true,
as_of_date_basis: asOfDateBasis ?? "period_end"
};
}
if (requestedResultMode) {
return {
requested_result_mode: requestedResultMode
};
}
return {};
}
type AddressResultSemantics = ReturnType<typeof deriveAddressResultSemantics>;
function mergeAddressResultSemantics(
base: AddressResultSemantics,
override: ComposeReplySemantics | undefined
): AddressResultSemantics {
if (!override) {
return base;
}
return {
...base,
...(override.result_mode ? { result_mode: override.result_mode } : {}),
...(override.evidence_strength ? { evidence_strength: override.evidence_strength } : {}),
...(typeof override.balance_confirmed === "boolean" ? { balance_confirmed: override.balance_confirmed } : {})
};
}
function withConfirmedBalanceFallbackReason(
reasons: string[],
requestedResultMode: AddressResultMode | undefined,
semantics: ComposeReplySemantics | undefined,
baseResultMode?: AddressResultMode
): string[] {
if (requestedResultMode !== "confirmed_balance") {
return reasons;
}
const effectiveResultMode = semantics?.result_mode ?? baseResultMode;
if (effectiveResultMode !== "heuristic_candidates") {
return reasons;
}
if (reasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) {
return reasons;
}
return [...reasons, "confirmed_balance_unavailable_fallback_to_heuristic_candidates"];
}
function enforceStrictAccountScopeForIntent(
plan: AddressRecipeExecutionPlan,
intent: AddressIntent
): AddressRecipeExecutionPlan {
if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") {
return plan;
}
return {
...plan,
account_scope_mode: "strict"
};
}
function resolveExecutionFiltersForPayablesConfirmedBalance(
filters: AddressFilterSet,
analysisDate: string | null
): {
executionFilters: AddressFilterSet;
asOfDerived: string | null;
} {
const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null;
const executionFilters: AddressFilterSet = {
...filters
};
if (derivedAsOf) {
executionFilters.as_of_date = derivedAsOf;
}
delete executionFilters.period_from;
delete executionFilters.period_to;
const limit =
typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(executionFilters.limit))
: null;
if (limit === null || limit < ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT) {
executionFilters.limit = Math.max(ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT, limit ?? 0);
}
return {
executionFilters,
asOfDerived: derivedAsOf
};
}
function resolveFutureGuardReferenceDate(analysisDate: string | null, filters: AddressFilterSet): string | null {
if (analysisDate) {
return analysisDate;
@ -1494,6 +1693,20 @@ function buildLimitedExecutionResult(input: {
category: AddressLimitedReasonCategory;
}): AddressExecutionResult {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
const resultSemantics = deriveAddressResultSemantics({
intent: input.intent.intent,
selectedRecipe: input.selectedRecipe,
filters: input.filters,
responseType: "LIMITED_WITH_REASON",
rowsMatched: input.rowsMatched
});
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const reasons = withConfirmedBalanceFallbackReason(
input.reasons,
requestedResultMode,
undefined,
resultSemantics.result_mode
);
return {
handled: true,
reply_text: composeLimitedReply({
@ -1544,8 +1757,9 @@ function buildLimitedExecutionResult(input: {
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
...resultSemantics,
limitations: input.limitations,
reasons: input.reasons
reasons
}
};
}
@ -1579,23 +1793,66 @@ export class AddressQueryService {
baseReasons.push("as_of_date_from_analysis_context");
}
}
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
const payablesConfirmedExecution =
intent.intent === "list_payables_counterparties" && requestedResultMode === "confirmed_balance"
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters = payablesConfirmedExecution?.executionFilters ?? filters.extracted_filters;
if (
payablesConfirmedExecution?.asOfDerived &&
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)
) {
if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) {
filters.warnings.push("as_of_date_derived_for_confirmed_payables");
}
if (!baseReasons.includes("as_of_date_derived_for_confirmed_payables")) {
baseReasons.push("as_of_date_derived_for_confirmed_payables");
}
}
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode
});
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, filters.extracted_filters);
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const debtLifecycleReceivablesScenario =
intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) &&
intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
const recipeSelection = selectAddressRecipe(recipeIntent, filters.extracted_filters);
const debtLifecyclePayablesScenario =
intent.intent === "list_payables_counterparties" &&
Array.isArray(intent.reasons) &&
(intent.reasons.includes("payables_debt_lifecycle_signal_detected") ||
intent.reasons.includes("supplier_tail_risk_signal_detected") ||
intent.reasons.includes("payables_signal_detected"));
const preferConfirmedBalanceForPayablesLifecycle =
debtLifecyclePayablesScenario && requestedResultMode === "confirmed_balance";
const recipeIntent = debtLifecycleReceivablesScenario
? "open_items_by_counterparty_or_contract"
: debtLifecyclePayablesScenario && !preferConfirmedBalanceForPayablesLifecycle
? "open_items_by_counterparty_or_contract"
: intent.intent;
const recipeSelection = selectAddressRecipe(recipeIntent, executionFilters);
if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle");
}
if (debtLifecyclePayablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_payables_debt_lifecycle");
}
if (preferConfirmedBalanceForPayablesLifecycle && !baseReasons.includes("confirmed_balance_attempt_for_payables_debt_lifecycle")) {
baseReasons.push("confirmed_balance_attempt_for_payables_debt_lifecycle");
}
if (
requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")
) {
baseReasons.push("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
}
if (intent.intent === "unknown") {
return buildLimitedExecutionResult({
@ -1716,19 +1973,27 @@ export class AddressQueryService {
}
}
let plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id;
let plan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(recipeSelection.selected_recipe, executionFilters),
intent.intent
);
let mcp = await executeAddressMcpQuery({
query: plan.query,
limit: plan.limit
});
if (
mcp.error &&
recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error)
) {
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", filters.extracted_filters);
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = buildAddressRecipePlan(fallbackSelection.selected_recipe, filters.extracted_filters);
const fallbackPlan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(fallbackSelection.selected_recipe, executionFilters),
intent.intent
);
const fallbackMcp = await executeAddressMcpQuery({
query: fallbackPlan.query,
limit: fallbackPlan.limit
@ -1736,9 +2001,18 @@ export class AddressQueryService {
if (!fallbackMcp.error) {
plan = fallbackPlan;
mcp = fallbackMcp;
if (intent.intent === "list_payables_counterparties") {
effectiveRecipeId = fallbackSelection.selected_recipe.recipe_id;
}
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items");
}
if (
intent.intent === "list_payables_counterparties" &&
!baseReasons.includes("fallback_recipe_switched_to_open_items")
) {
baseReasons.push("fallback_recipe_switched_to_open_items");
}
} else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
@ -1759,7 +2033,7 @@ export class AddressQueryService {
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
anchor,
mcpCallStatus: deriveMcpStageStatus({
@ -1797,10 +2071,10 @@ export class AddressQueryService {
anchor = refineAnchorFromRows(anchor, normalizedRows);
const filtersForMatching: AddressFilterSet =
anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
? { ...executionFilters, counterparty: anchor.anchor_value_resolved }
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved }
: filters.extracted_filters;
? { ...executionFilters, contract: anchor.anchor_value_resolved }
: executionFilters;
const accountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: filtersForMatching,
@ -1849,7 +2123,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters));
const recoveryReason =
recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
@ -1872,7 +2146,7 @@ export class AddressQueryService {
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
@ -1900,8 +2174,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: recoveredRows.length
}),
factual.semantics
),
limitations: [...filters.warnings, recoveryReason],
reasons: [...baseReasons, recoveryReason]
reasons: withConfirmedBalanceFallbackReason(
[...baseReasons, recoveryReason],
requestedResultMode,
factual.semantics
)
}
};
}
@ -1915,12 +2203,12 @@ export class AddressQueryService {
stageStatus === "raw_rows_received_but_not_materialized")
) {
const currentLimit =
typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit))
typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(executionFilters.limit))
: plan.limit;
if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) {
const expandedLimitFilters: AddressFilterSet = {
...filters.extracted_filters,
...executionFilters,
limit: ADDRESS_ANCHOR_RECOVERY_LIMIT
};
const expandedSelection = selectAddressRecipe(intent.intent, expandedLimitFilters);
@ -2034,8 +2322,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
...mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: expandedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: expandedFactual.responseType,
rowsMatched: expandedFilteredRows.length
}),
expandedFactual.semantics
),
limitations: expandedLimitations,
reasons: expandedReasons
reasons: withConfirmedBalanceFallbackReason(
expandedReasons,
requestedResultMode,
expandedFactual.semantics
)
}
};
}
@ -2160,8 +2462,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
...mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: broadenedFactual.responseType,
rowsMatched: broadenedFilteredRows.length
}),
broadenedFactual.semantics
),
limitations: broadenedLimitations,
reasons: broadenedReasons
reasons: withConfirmedBalanceFallbackReason(
broadenedReasons,
requestedResultMode,
broadenedFactual.semantics
)
}
};
}
@ -2298,8 +2614,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: historicalFactual.responseType,
...mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: historicalSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: historicalFactual.responseType,
rowsMatched: historicalFilteredRows.length
}),
historicalFactual.semantics
),
limitations: historicalLimitations,
reasons: historicalReasons
reasons: withConfirmedBalanceFallbackReason(
historicalReasons,
requestedResultMode,
historicalFactual.semantics
)
}
};
}
@ -2319,7 +2649,7 @@ export class AddressQueryService {
const fallbackFactual = composeFactualReply(
intent.intent,
documentBankFallbackRows,
composeOptionsFromFilters(filters.extracted_filters)
composeOptionsFromFilters(executionFilters)
);
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion =
@ -2342,7 +2672,7 @@ export class AddressQueryService {
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
@ -2370,8 +2700,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: fallbackFactual.responseType,
...mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: fallbackFactual.responseType,
rowsMatched: documentBankFallbackRows.length
}),
fallbackFactual.semantics
),
limitations: fallbackLimitations,
reasons: fallbackReasons
reasons: withConfirmedBalanceFallbackReason(
fallbackReasons,
requestedResultMode,
fallbackFactual.semantics
)
}
};
}
@ -2467,7 +2811,7 @@ export class AddressQueryService {
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
@ -2491,7 +2835,17 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters));
const factualResultSemantics = mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}),
factual.semantics
);
return {
handled: true,
reply_text: factual.text,
@ -2506,7 +2860,7 @@ export class AddressQueryService {
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
@ -2534,8 +2888,14 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...factualResultSemantics,
limitations: filters.warnings,
reasons: baseReasons
reasons: withConfirmedBalanceFallbackReason(
baseReasons,
requestedResultMode,
factual.semantics,
factualResultSemantics.result_mode
)
}
};
}

View File

@ -12,7 +12,13 @@ const MOVEMENTS_QUERY_TEMPLATE = `
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
Движения.Сумма КАК Сумма
Движения.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__

View File

@ -1,4 +1,9 @@
import type { AddressIntent, AddressResponseType } from "../../types/addressQuery";
import type {
AddressEvidenceStrength,
AddressIntent,
AddressResponseType,
AddressResultMode
} from "../../types/addressQuery";
export interface ComposeStageRow {
period: string | null;
@ -14,6 +19,13 @@ interface ComposeFactualReplyOptions {
periodFrom?: string;
periodTo?: string;
asOfDate?: string;
requestedResultMode?: AddressResultMode;
}
export interface ComposeReplySemantics {
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
}
type PeriodProfileFocus =
@ -618,6 +630,307 @@ interface CounterpartyRiskAggregate {
lastPeriod: string | null;
}
type PayablesLiabilityCategory = "supplier_or_contractor" | "bank_or_credit" | "tax_or_state" | "other";
interface PayablesCounterpartyRiskAggregate extends CounterpartyRiskAggregate {
category: PayablesLiabilityCategory;
categoryReasons: string[];
}
interface PayablesConfirmedBalanceAggregate {
name: string;
outstandingAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
category: PayablesLiabilityCategory;
categoryReasons: string[];
}
function liabilityCategoryLabel(category: PayablesLiabilityCategory): string {
if (category === "supplier_or_contractor") {
return "поставщики/подрядчики";
}
if (category === "bank_or_credit") {
return "банки/кредиты";
}
if (category === "tax_or_state") {
return "налоги/госорганы";
}
return "прочие";
}
function classifyPayablesLiabilityCategory(row: ComposeStageRow, counterparty: string): {
scores: Record<PayablesLiabilityCategory, number>;
reasons: string[];
} {
const scores: Record<PayablesLiabilityCategory, number> = {
supplier_or_contractor: 0,
bank_or_credit: 0,
tax_or_state: 0,
other: 0
};
const reasons = new Set<string>();
const text = `${counterparty} ${row.registrator} ${row.analytics.join(" ")}`.toLowerCase();
const accountPrefixes = [extractAccountSectionCode(row.account_dt), extractAccountSectionCode(row.account_kt)].filter(
(item): item is string => Boolean(item)
);
if (accountPrefixes.includes("60")) {
scores.supplier_or_contractor += 3;
reasons.add("участие счета 60");
}
if (accountPrefixes.includes("66") || accountPrefixes.includes("67")) {
scores.bank_or_credit += 4;
reasons.add("участие счета 66/67");
}
if (accountPrefixes.includes("68") || accountPrefixes.includes("69")) {
scores.tax_or_state += 4;
reasons.add("участие счета 68/69");
}
if (accountPrefixes.includes("76")) {
scores.supplier_or_contractor += 1;
reasons.add("участие счета 76");
}
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) {
scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике");
}
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) {
scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике");
}
if (/(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor)/iu.test(text)) {
scores.supplier_or_contractor += 2;
reasons.add("коммерческий контрагент в аналитике");
}
return {
scores,
reasons: Array.from(reasons)
};
}
const PAYABLES_CATEGORY_KEYS: PayablesLiabilityCategory[] = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
function resolvePayablesLiabilityCategory(
scores: Record<PayablesLiabilityCategory, number>
): PayablesLiabilityCategory {
let winner: PayablesLiabilityCategory = "other";
let best = Number.NEGATIVE_INFINITY;
for (const key of PAYABLES_CATEGORY_KEYS) {
const score = scores[key];
if (score > best) {
best = score;
winner = key;
}
}
if (best <= 0) {
return "other";
}
return winner;
}
function hasPayablesSectionPrefix(account: string | null): boolean {
const section = extractAccountSectionCode(account);
return section === "60" || section === "76";
}
function resolvePayablesAsOfDate(options: ComposeFactualReplyOptions): string {
const explicit = normalizeIsoDateOnly(options.asOfDate);
if (explicit) {
return explicit;
}
const periodTo = normalizeIsoDateOnly(options.periodTo);
if (periodTo) {
return periodTo;
}
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
if (periodFrom) {
return periodFrom;
}
const now = new Date();
return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate());
}
function buildPayablesCounterpartyRiskAggregate(rows: ComposeStageRow[]): PayablesCounterpartyRiskAggregate[] {
const byCounterparty = new Map<
string,
{
base: CounterpartyRiskAggregate;
categoryScores: Record<PayablesLiabilityCategory, number>;
reasons: Set<string>;
}
>();
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const amountRaw = row.amount ?? 0;
if (!Number.isFinite(amountRaw)) {
continue;
}
const amount = Math.abs(amountRaw);
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
base: {
name,
totalAmount: amount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period
},
categoryScores: {
supplier_or_contractor: classified.scores.supplier_or_contractor,
bank_or_credit: classified.scores.bank_or_credit,
tax_or_state: classified.scores.tax_or_state,
other: classified.scores.other
},
reasons: new Set(classified.reasons)
});
continue;
}
current.base.totalAmount += amount;
current.base.operations += 1;
if ((row.period ?? "") < (current.base.firstPeriod ?? "")) {
current.base.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.base.lastPeriod ?? "")) {
current.base.lastPeriod = row.period;
}
current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor;
current.categoryScores.bank_or_credit += classified.scores.bank_or_credit;
current.categoryScores.tax_or_state += classified.scores.tax_or_state;
current.categoryScores.other += classified.scores.other;
for (const reason of classified.reasons) {
current.reasons.add(reason);
}
}
return Array.from(byCounterparty.values())
.map((item) => ({
...item.base,
category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.sort((left, right) => {
if (right.totalAmount !== left.totalAmount) {
return right.totalAmount - left.totalAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.name.localeCompare(right.name);
});
}
function buildPayablesConfirmedBalanceAggregate(
rows: ComposeStageRow[],
asOfDate: string
): PayablesConfirmedBalanceAggregate[] {
const byCounterparty = new Map<
string,
{
outstandingAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
categoryScores: Record<PayablesLiabilityCategory, number>;
reasons: Set<string>;
}
>();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const amount = row.amount;
if (!Number.isFinite(amount)) {
continue;
}
const absAmount = Math.abs(amount);
let delta = 0;
if (hasPayablesSectionPrefix(row.account_kt)) {
delta += absAmount;
}
if (hasPayablesSectionPrefix(row.account_dt)) {
delta -= absAmount;
}
if (Math.abs(delta) <= 0.0000001) {
continue;
}
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
outstandingAmount: delta,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
categoryScores: {
supplier_or_contractor: classified.scores.supplier_or_contractor,
bank_or_credit: classified.scores.bank_or_credit,
tax_or_state: classified.scores.tax_or_state,
other: classified.scores.other
},
reasons: new Set(classified.reasons)
});
continue;
}
current.outstandingAmount += delta;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor;
current.categoryScores.bank_or_credit += classified.scores.bank_or_credit;
current.categoryScores.tax_or_state += classified.scores.tax_or_state;
current.categoryScores.other += classified.scores.other;
for (const reason of classified.reasons) {
current.reasons.add(reason);
}
}
return Array.from(byCounterparty.entries())
.map(([name, item]) => ({
name,
outstandingAmount: item.outstandingAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.filter((item) => item.outstandingAmount > 0.005)
.sort((left, right) => {
if (right.outstandingAmount !== left.outstandingAmount) {
return right.outstandingAmount - left.outstandingAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.name.localeCompare(right.name);
});
}
function buildCounterpartyRiskAggregate(rows: ComposeStageRow[]): CounterpartyRiskAggregate[] {
const byCounterparty = new Map<string, CounterpartyRiskAggregate>();
@ -885,7 +1198,7 @@ export function composeFactualReply(
intent: AddressIntent,
rows: ComposeStageRow[],
options: ComposeFactualReplyOptions = {}
): { responseType: AddressResponseType; text: string } {
): { responseType: AddressResponseType; text: string; semantics?: ComposeReplySemantics } {
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
@ -1940,34 +2253,172 @@ export function composeFactualReply(
}
if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
`Строк в выборке: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.`
];
if (counterparties.length > 0) {
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
lines.push(
...counterparties
.slice(0, 8)
.map(
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const payablesAsOfDate = resolvePayablesAsOfDate(options);
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const scopeLine = asOfDate
? `- Дата среза: ${formatDateRu(asOfDate)}.`
: periodFrom || periodTo
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
const carryoverLine =
asOfDate || periodFrom || periodTo
? "- В список могут попадать обязательства, возникшие раньше выбранного периода, если они потенциально оставались открытыми на дату среза."
: null;
const formatHeuristicItem = (item: PayablesCounterpartyRiskAggregate, index: number): string =>
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const pushCategorySlice = (
lines: string[],
title: string,
items: PayablesCounterpartyRiskAggregate[],
limit: number
): void => {
if (items.length === 0) {
return;
}
lines.push("");
lines.push(title);
lines.push(...items.slice(0, limit).map(formatHeuristicItem));
};
const buildHeuristicLines = (forcedFallbackFromConfirmed: boolean): string[] => {
const lines = [
"Блок 1. Статус результата",
forcedFallbackFromConfirmed
? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен."
: "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).",
"- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.",
"",
"Блок 2. Как читать результат",
"- Это shortlist кандидатов: нужна ручная проверка бухгалтером.",
"- Это не подтвержденный остаток к оплате и не готовое платежное поручение.",
...(scopeLine ? [scopeLine] : []),
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов-кандидатов: ${counterparties.length}.`
];
if (counterparties.length > 0) {
const categoryCounts = counterparties.reduce<Record<PayablesLiabilityCategory, number>>(
(acc, item) => {
acc[item.category] += 1;
return acc;
},
{ supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }
);
const suppliers = counterparties.filter((item) => item.category === "supplier_or_contractor");
const banks = counterparties.filter((item) => item.category === "bank_or_credit");
const taxOrState = counterparties.filter((item) => item.category === "tax_or_state");
const other = counterparties.filter((item) => item.category === "other");
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Блок 5. Кандидаты на проверку в первую очередь");
pushCategorySlice(lines, "5.1 Поставщики/подрядчики:", suppliers, 6);
pushCategorySlice(lines, "5.2 Банки/кредиты:", banks, 4);
pushCategorySlice(lines, "5.3 Налоги/госорганы:", taxOrState, 4);
pushCategorySlice(lines, "5.4 Прочие:", other, 4);
lines.push("");
lines.push("Блок 6. Примеры исходных строк");
lines.push(...formatTopRows(rows, 4));
} else {
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push("- Явных кандидатов на незакрытые обязательства по доступному срезу не найдено.");
lines.push("");
lines.push("Блок 5. Примеры исходных строк");
lines.push(...formatTopRows(rows, 6));
}
return lines;
};
if (options.requestedResultMode === "confirmed_balance") {
const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
if (confirmedBalances.length > 0) {
const categoryCounts = confirmedBalances.reduce<Record<PayablesLiabilityCategory, number>>(
(acc, item) => {
acc[item.category] += 1;
return acc;
},
{ supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }
);
const lines: string[] = [
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 60/76.",
"- Тип результата: подтвержденные остатки к оплате.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`,
...(periodFrom || periodTo
? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`]
: []),
"- Основание: движения обязательств и оплат в пределах доступного live-среза.",
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов с подтвержденным остатком: ${confirmedBalances.length}.`,
"",
"Блок 4. Категории обязательств",
`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`,
`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`,
`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`,
`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`,
"",
"Блок 5. Кому нужно заплатить в первую очередь (по сумме остатка):",
...confirmedBalances.slice(0, 10).map(
(item, index) =>
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`
)
);
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 4));
} else {
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
lines.push(...formatTopRows(rows, 6));
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
const fallbackLines = buildHeuristicLines(true);
return {
responseType: "FACTUAL_LIST",
text: fallbackLines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
const lines = buildHeuristicLines(false);
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
text: lines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
if (intent === "list_receivables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage);

View File

@ -1414,6 +1414,11 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
requested_result_mode: addressDebug.requested_result_mode ?? undefined,
result_mode: addressDebug.result_mode ?? undefined,
evidence_strength: addressDebug.evidence_strength ?? undefined,
balance_confirmed: typeof addressDebug.balance_confirmed === "boolean" ? addressDebug.balance_confirmed : undefined,
as_of_date_basis: addressDebug.as_of_date_basis ?? undefined,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
@ -1542,6 +1547,19 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"kakoi",
"vse",
"all",
"\u043f\u0443\u043d\u043a\u0442",
"\u043f\u0443\u043d\u043a\u0442\u0430",
"\u043f\u0443\u043d\u043a\u0442\u0443",
"\u043f\u0443\u043d\u043a\u0442\u043e\u043c",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044f",
"\u043f\u043e\u0437\u0438\u0446\u0438\u0438",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044e",
"\u0441\u0442\u0440\u043e\u043a\u0430",
"\u0441\u0442\u0440\u043e\u043a\u0438",
"\u0441\u0442\u0440\u043e\u043a\u0443",
"item",
"row",
"line",
"blya",
"blyat",
"епт",
@ -1566,51 +1584,51 @@ const ADDRESS_FALLBACK_STRIP_TOKENS = new Set([
"please"
]);
const ADDRESS_MONTH_ALIAS_MAP = {
янв: "01",
январ: "01",
"\u044f\u043d\u0432": "01",
"\u044f\u043d\u0432\u0430\u0440": "01",
january: "01",
jan: "01",
фев: "02",
феврал: "02",
"\u0444\u0435\u0432": "02",
"\u0444\u0435\u0432\u0440\u0430\u043b": "02",
february: "02",
feb: "02",
мар: "03",
март: "03",
"\u043c\u0430\u0440": "03",
"\u043c\u0430\u0440\u0442": "03",
march: "03",
apr: "04",
апр: "04",
апрел: "04",
"\u0430\u043f\u0440": "04",
"\u0430\u043f\u0440\u0435\u043b": "04",
april: "04",
май: "05",
ма: "05",
"\u043c\u0430\u0439": "05",
"\u043c\u0430": "05",
may: "05",
июн: "06",
июнь: "06",
"\u0438\u044e\u043d": "06",
"\u0438\u044e\u043d\u044c": "06",
june: "06",
jun: "06",
июл: "07",
июль: "07",
"\u0438\u044e\u043b": "07",
"\u0438\u044e\u043b\u044c": "07",
july: "07",
jul: "07",
авг: "08",
август: "08",
"\u0430\u0432\u0433": "08",
"\u0430\u0432\u0433\u0443\u0441\u0442": "08",
august: "08",
aug: "08",
сен: "09",
сент: "09",
сентябр: "09",
"\u0441\u0435\u043d": "09",
"\u0441\u0435\u043d\u0442": "09",
"\u0441\u0435\u043d\u0442\u044f\u0431\u0440": "09",
september: "09",
sep: "09",
окт: "10",
октябр: "10",
"\u043e\u043a\u0442": "10",
"\u043e\u043a\u0442\u044f\u0431\u0440": "10",
october: "10",
oct: "10",
ноя: "11",
ноябр: "11",
"\u043d\u043e\u044f": "11",
"\u043d\u043e\u044f\u0431\u0440": "11",
november: "11",
nov: "11",
дек: "12",
декабр: "12",
"\u0434\u0435\u043a": "12",
"\u0434\u0435\u043a\u0430\u0431\u0440": "12",
december: "12",
dec: "12"
};
@ -1839,6 +1857,10 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
const bankSignal = ADDRESS_BANK_SIGNAL_PATTERN.test(source);
const contractSignal = ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
const balanceSignal = ADDRESS_BALANCE_SIGNAL_PATTERN.test(source);
const hasIndexPointerSignal = /(?:\u043f\u0443\u043d\u043a\u0442|\u043f\u043e\u0437\u0438\u0446|\u0441\u0442\u0440\u043e\u043a|item|row|line)/iu.test(sourceRaw);
if (hasIndexPointerSignal && extractDisplayedEntityIndexMention(sourceRaw) !== null) {
return null;
}
if (balanceSignal && account) {
let periodClause = "";
let rule = "balance_account_rewrite";
@ -2157,6 +2179,14 @@ const FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS = new Set([
"company",
"group"
]);
const FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT = {
counterparty_activity_lifecycle: "counterparty",
customer_revenue_and_payments: "counterparty",
supplier_payouts_profile: "counterparty",
counterparty_population_and_roles: "counterparty",
contract_usage_and_value: "contract",
list_contracts_by_counterparty: "contract"
};
function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
@ -2167,7 +2197,25 @@ function normalizeCounterpartyForFollowupMatch(value) {
function normalizeCounterpartyTokenForFollowupMatch(value) {
return normalizeCounterpartyForFollowupMatch(value).replace(/[._-]+/g, "");
}
function extractDisplayedCounterpartyCandidates(replyText) {
function normalizeCounterpartyStemForFollowupMatch(value) {
const compact = normalizeCounterpartyTokenForFollowupMatch(value);
if (!compact || !/[а-яё]/iu.test(compact)) {
return compact;
}
const stem = compact.replace(/(?:иями|ями|ами|ией|ей|ий|ов|ев|ом|ем|ах|ях|ую|юю|ая|яя|ое|ее|ые|ие|ого|его|ому|ему|ыми|ими|ым|им|ам|ям|у|ю|а|я|е|и|ы|о)$/iu, "");
return stem.length >= 3 ? stem : compact;
}
function inferDisplayedEntityTypeFromIntent(intent) {
const normalized = compactWhitespace(String(intent ?? "").toLowerCase());
if (!normalized) {
return "unknown";
}
return FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT[normalized] ?? "unknown";
}
function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknown") {
if (entityType === "unknown") {
return [];
}
const lines = String(replyText ?? "").split(/\r?\n/);
const candidates = [];
for (const line of lines) {
@ -2175,10 +2223,15 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!compactLine) {
continue;
}
if (!/^\d+\.\s+/.test(compactLine)) {
const numberedMatch = compactLine.match(/^(\d+)\.\s+(.+)$/);
if (!numberedMatch) {
continue;
}
const afterNumber = compactLine.replace(/^\d+\.\s+/, "");
const index = Number.parseInt(String(numberedMatch[1] ?? ""), 10);
if (!Number.isFinite(index) || index <= 0) {
continue;
}
const afterNumber = String(numberedMatch[2] ?? "");
const parts = afterNumber.split("|").map((item) => compactWhitespace(item));
let counterpartyCandidate = parts[0] ?? "";
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
@ -2188,9 +2241,20 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
candidates.push(cleanedCandidate);
candidates.push({
index,
value: cleanedCandidate,
entityType
});
}
return Array.from(new Set(candidates));
const dedup = new Map();
for (const candidate of candidates) {
const key = `${candidate.entityType}:${candidate.index}:${normalizeCounterpartyForFollowupMatch(candidate.value)}`;
if (!dedup.has(key)) {
dedup.set(key, candidate);
}
}
return Array.from(dedup.values());
}
function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
const aliases = new Set();
@ -2203,13 +2267,14 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
const withoutLegalTokens = normalizedTokens
const tokensForAlias = Array.from(new Set(normalizedTokens.flatMap((token) => [token, ...token.split(/-+/).map((part) => part.trim()).filter(Boolean)])));
const withoutLegalTokens = tokensForAlias
.filter((token) => !FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS.has(token))
.join(" ");
if (withoutLegalTokens) {
aliases.add(withoutLegalTokens);
}
for (const token of normalizedTokens) {
for (const token of tokensForAlias) {
const compactToken = normalizeCounterpartyTokenForFollowupMatch(token);
if (compactToken.length < 3) {
continue;
@ -2221,6 +2286,10 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
continue;
}
aliases.add(compactToken);
const stemToken = normalizeCounterpartyStemForFollowupMatch(compactToken);
if (stemToken.length >= 4) {
aliases.add(stemToken);
}
}
return Array.from(aliases)
.map((alias) => compactWhitespace(alias))
@ -2234,31 +2303,95 @@ function hasCounterpartyAliasMention(normalizedMessage, alias) {
}
const aliasPattern = escapeRegex(trimmedAlias).replace(/\s+/g, "\\s+");
const boundaryPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}(?:$|[^a-zа-я0-9])`, "iu");
return boundaryPattern.test(normalizedMessage);
if (boundaryPattern.test(normalizedMessage)) {
return true;
}
if (trimmedAlias.length < 4 || !/[а-яё]/iu.test(trimmedAlias)) {
return false;
}
const fuzzyPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}[а-яё]{0,3}(?:$|[^a-zа-я0-9])`, "iu");
return fuzzyPattern.test(normalizedMessage);
}
function resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) {
function extractDisplayedEntityIndexMention(userMessage) {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
if (!normalized) {
return null;
}
const tokenStart = "(?:^|[^\\p{L}\\p{N}_])";
const tokenEnd = "(?=$|[^\\p{L}\\p{N}_])";
const pointerPattern = "(?:\\u043f\\u0443\\u043d\\u043a\\u0442(?:\\u0430|\\u0443|\\u043e\\u043c)?|\\u043f\\u043e\\u0437\\u0438\\u0446\\u0438(?:\\u044f|\\u0438|\\u044e|\\u0435\\u0439)|\\u0441\\u0442\\u0440\\u043e\\u043a(?:\\u0430|\\u0438|\\u0435|\\u0443)|item|row|line)";
const pointerSignalPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}`, "iu");
const directPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}\\D{0,8}(\\d{1,3})(?!\\d)`, "iu");
const directMatch = normalized.match(directPattern);
if (directMatch) {
const value = Number.parseInt(String(directMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
const reversePattern = new RegExp(`${tokenStart}(\\d{1,3})(?:-?(?:\\u0439|\\u044f|\\u0435|\\u0433\\u043e|\\u043c\\u0443))?\\s+${pointerPattern}${tokenEnd}`, "iu");
const reverseMatch = normalized.match(reversePattern);
if (reverseMatch) {
const value = Number.parseInt(String(reverseMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
if (pointerSignalPattern.test(normalized)) {
const numericMatches = Array.from(normalized.matchAll(/(?:^|[^\p{N}])(\d{1,3})(?!\d)/gu))
.map((match) => Number.parseInt(String(match[1] ?? ""), 10))
.filter((value) => Number.isFinite(value) && value > 0);
if (numericMatches.length === 1) {
return numericMatches[0];
}
}
return null;
}
function resolveDisplayedAddressEntityMention(userMessage, displayedEntities) {
const normalizedMessage = normalizeCounterpartyForFollowupMatch(userMessage);
if (!normalizedMessage) {
return null;
}
if (!Array.isArray(displayedCounterparties) || displayedCounterparties.length === 0) {
if (!Array.isArray(displayedEntities) || displayedEntities.length === 0) {
return null;
}
const indexMention = extractDisplayedEntityIndexMention(userMessage);
if (indexMention !== null) {
const indexedCandidate = displayedEntities.find((candidate) => Number(candidate.index) === indexMention);
if (indexedCandidate) {
return {
value: indexedCandidate.value,
entityType: indexedCandidate.entityType,
matchKind: "index",
index: indexedCandidate.index
};
}
}
let bestMatch = null;
for (const candidate of displayedCounterparties) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate);
for (const candidate of displayedEntities) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate.value);
for (const alias of aliases) {
if (!hasCounterpartyAliasMention(normalizedMessage, alias)) {
continue;
}
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate) === alias ? 1 : 0);
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate.value) === alias ? 1 : 0);
if (!bestMatch || score > bestMatch.score) {
bestMatch = { value: candidate, score };
bestMatch = {
value: candidate.value,
entityType: candidate.entityType,
index: candidate.index,
matchKind: "alias",
score
};
}
break;
}
}
return bestMatch?.value ?? null;
if (!bestMatch) {
return null;
}
return {
value: bestMatch.value,
entityType: bestMatch.entityType,
matchKind: bestMatch.matchKind,
index: bestMatch.index
};
}
function findRecentAddressFilterValue(items, key) {
for (let index = items.length - 1; index >= 0; index -= 1) {
@ -2435,12 +2568,17 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
: false;
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) {
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!previousAddressDebug) {
@ -2487,16 +2625,24 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousFilters.organization = historicalOrganization;
}
}
const displayedCounterparties = extractDisplayedCounterpartyCandidates(toNonEmptyString(previousAddressItem?.text) ?? "");
const counterpartyFromFollowupText = resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) ??
const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent);
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
(toNonEmptyString(alternateMessage)
? resolveDisplayedCounterpartyMention(String(alternateMessage ?? ""), displayedCounterparties)
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
: null);
if (counterpartyFromFollowupText) {
previousFilters.counterparty = counterpartyFromFollowupText;
previousAnchorType = "counterparty";
previousAnchor = counterpartyFromFollowupText;
resolvedCounterpartyFromDisplay = true;
if (resolvedEntityFromFollowup) {
if (resolvedEntityFromFollowup.entityType === "counterparty") {
previousFilters.counterparty = resolvedEntityFromFollowup.value;
previousAnchorType = "counterparty";
previousAnchor = resolvedEntityFromFollowup.value;
resolvedCounterpartyFromDisplay = true;
}
else if (resolvedEntityFromFollowup.entityType === "contract") {
previousFilters.contract = resolvedEntityFromFollowup.value;
previousAnchorType = "contract";
previousAnchor = resolvedEntityFromFollowup.value;
}
if (followupSelectionMode !== "switch_to_suggested_intent") {
followupSelectionMode = "carry_referenced_entity";
}
@ -3330,7 +3476,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown" &&
!followupContext &&
!hasClassifierSignal &&
!strongDataSignalFromRawMessage) {
!hasIntentSignal &&
!strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@ -4466,7 +4614,7 @@ function isPlausibleOrganizationName(value) {
if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) {
return false;
}
return /[A-Za-zА-Яа-яЁё]/u.test(candidate);
return /[A-Za-z\u0400-\u04FF]/u.test(candidate);
}
function appendOrganizationFactsFromValue(value, hints, bucket, depth = 0) {
if (depth > 4 || value === null || value === undefined) {

View File

@ -24,6 +24,9 @@ export type AddressIntent =
| "unknown";
export type AddressResponseType = "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
export type AddressResultMode = "heuristic_candidates" | "confirmed_balance";
export type AddressEvidenceStrength = "weak" | "medium" | "strong";
export type AddressAsOfDateBasis = "period_end" | "explicit_as_of_date" | "period_range";
export type AddressQueryShape =
| "AGGREGATE_LOOKUP"
@ -189,6 +192,11 @@ export interface AddressExecutionDebug {
runtime_readiness: AddressRuntimeReadiness;
limited_reason_category: AddressLimitedReasonCategory | null;
response_type: AddressResponseType;
requested_result_mode?: AddressResultMode;
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
as_of_date_basis?: AddressAsOfDateBasis | null;
limitations: string[];
reasons: string[];
}

View File

@ -427,6 +427,11 @@ export interface AssistantDebugPayload {
runtime_readiness?: "LIVE_QUERYABLE" | "LIVE_QUERYABLE_WITH_LIMITS" | "REQUIRES_SPECIALIZED_RECIPE" | "DEEP_ONLY" | "UNKNOWN";
limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
requested_result_mode?: "heuristic_candidates" | "confirmed_balance";
result_mode?: "heuristic_candidates" | "confirmed_balance";
evidence_strength?: "weak" | "medium" | "strong";
balance_confirmed?: boolean;
as_of_date_basis?: "period_end" | "explicit_as_of_date" | "period_range" | null;
execution_lane?: "address_query" | "deep_analysis";
llm_decomposition_applied?: boolean;
llm_decomposition_attempted?: boolean;

View File

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { detectAddressQuestionMode } from "../src/services/addressQueryClassifier";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
@ -1830,6 +1830,12 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_payables_counterparties");
});
it("marks 'кому мы должны заплатить' as payables debt lifecycle intent", () => {
const result = resolveAddressIntent("каму мы должны заплатить за май 2020");
expect(result.intent).toBe("list_payables_counterparties");
expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected");
});
it("keeps out-of-scope supplier control wording as unknown intent", () => {
const result = resolveAddressIntent(
"Какие поставщики у нас уже пару месяцев сдают акты без приходок. Может, их надо проконтролировать отдельно чтоб не засорять бухгалтерию дальше?"
@ -2488,6 +2494,33 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes 'каму мы должны заплатить за май 2020' into confirmed payables flow with explicit fallback contract", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("каму мы должны заплатить за май 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_payables_counterparties");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.as_of_date_basis).toBe("period_range");
expect(Array.isArray(result?.debug.reasons)).toBe(true);
const reply = String(result?.reply_text ?? "");
if (result?.debug.result_mode === "confirmed_balance") {
expect(result?.debug.selected_recipe).toBe("address_movements_payables_v1");
expect(result?.debug.balance_confirmed).toBe(true);
expect(reply).toContain("подтвержденный срез обязательств к оплате");
expect(reply).toContain("Кому нужно заплатить в первую очередь");
} else {
expect(result?.debug.result_mode).toBe("heuristic_candidates");
expect(result?.debug.balance_confirmed).toBe(false);
expect(result?.debug.reasons).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
expect(reply).toContain("эвристический скоринг");
expect(reply).toContain("Контрагентов-кандидатов:");
expect(reply).toContain("Блок 1. Статус результата");
expect(reply).toContain("\n\nБлок 2. Как читать результат");
expect(reply).toContain("\n\nБлок 3. Сводка выборки");
expect(reply).not.toContain("Кому нужно заплатить в первую очередь");
}
});
it("routes shipment-to-payment lag wording into receivables lane without missing-anchor fallback", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@ -2685,6 +2718,16 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes 'кто больше всего принес денег в 2020' into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("кто больше всего принес денег в 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes typo highest-check wording into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("с каких кликентов самый высокий чек");

View File

@ -708,6 +708,312 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves short declined counterparty mention from displayed top list into contracts follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "топ топ клиентов по приходам за 2020";
const followupMessage = "покажи договор по гамме";
const topReply = [
"Топ-6 заказчиков по сумме поступлений:",
"1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600",
"2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88",
"4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000",
"5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31",
counterparty: "Гамма-мебель, ООО"
},
anchor_type: "counterparty",
anchor_value_raw: "гамме",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: topReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-gamma-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО");
expect(contextualCall?.options?.followupContext?.previous_filters?.period_from).toBe("2020-01-01");
expect(contextualCall?.options?.followupContext?.previous_filters?.period_to).toBe("2020-12-31");
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
expect(second.debug?.dialog_continuation_contract_v2?.target_intent).toBe("list_contracts_by_counterparty");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves numbered item from displayed top list into counterparty drill-down", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "топ топ клиентов по приходам за 2020";
const followupMessage = "покажи договор по пункту 4";
const topReply = [
"Топ-6 заказчиков по сумме поступлений:",
"1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600",
"2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88",
"4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000",
"5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31",
counterparty: "Гамма-мебель, ООО"
},
anchor_type: "counterparty",
anchor_value_raw: "пункт 4",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: topReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-index-counterparty-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО");
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves numbered contract item from previous contract list into documents-by-contract follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи договоры по гамме";
const followupMessage = "покажи документы по пункту 2";
const contractsReply = [
"Собран список договоров по контрагенту Гамма-мебель, ООО.",
"1. Договор № 1-ГМ/2020 | операций: 4 | последняя активность: 2020-12-14T12:00:00Z",
"2. Договор № 2-ГМ/2020 | операций: 3 | последняя активность: 2020-12-30T12:00:00Z",
"3. Договор № 3-ГМ/2020 | операций: 1 | последняя активность: 2020-08-11T13:15:30Z"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.contract !== "Договор № 2-ГМ/2020") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список документов по договору № 2-ГМ/2020.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_documents_by_contract",
selected_recipe: "address_documents_by_contract_v1",
extracted_filters: {
contract: "Договор № 2-ГМ/2020"
},
anchor_type: "contract",
anchor_value_raw: "пункт 2",
anchor_value_resolved: "Договор № 2-ГМ/2020",
reasons: ["address_action_detected", "documents_by_contract_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: contractsReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
counterparty: "Гамма-мебель, ООО",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "counterparty",
anchor_value_raw: "гамма",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-index-contract-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_documents_by_contract");
expect(second.debug?.extracted_filters?.contract).toBe("Договор № 2-ГМ/2020");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.contract === "Договор № 2-ГМ/2020"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("contract");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Договор № 2-ГМ/2020");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not carry address follow-up context into capability question", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020";

View File

@ -219,6 +219,39 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps customer-value ranking question in address lane even when LLM semantic guard rejects canonical rewrite", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "кто больше всего принес денег в 2020",
effectiveAddressUserMessage: "кто больше всего принес денег в 2020",
followupContext: null,
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
},
semanticExtractionContract: {
valid: false,
apply_canonical_recommended: false,
reason_codes: ["unsupported_low_confidence_contract"]
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected"]).toContain(
String(decision.toolGateReason)
);
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("routes unsupported turnover-by-organization query to deep analysis", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-B_Dz87Mp.js"></script>
<script type="module" crossorigin src="/assets/index-CVIU9teH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4dtBq8A.css">
</head>
<body>

View File

@ -141,6 +141,24 @@ interface AutoRunsUiConfig {
hideResolvedAnnotations?: boolean;
}
type UnifiedCommentListItem =
| {
source: "autorun";
key: string;
updated_at: string;
rating: number;
autorun: AutoRunAnnotationListItem;
assistant: null;
}
| {
source: "assistant_live";
key: string;
updated_at: string;
rating: number;
autorun: null;
assistant: AssistantAnnotationRecord;
};
const DEFAULT_AUTOGEN_SETTINGS: AutoGenSettingsState = {
mode: "codex_creative",
count: 24,
@ -557,11 +575,31 @@ export function AutoRunsHistoryPanel({
return null;
}, [assistantLiveCommentModal.messageIndex, assistantLiveConversation]);
const unifiedVisibleAnnotations = useMemo<UnifiedCommentListItem[]>(() => {
const autorunItems: UnifiedCommentListItem[] = visibleAnnotations.map((item) => ({
source: "autorun",
key: `autorun:${item.annotation_id}`,
updated_at: item.updated_at,
rating: item.rating,
autorun: item,
assistant: null
}));
const assistantItems: UnifiedCommentListItem[] = assistantLiveAnnotations.map((item) => ({
source: "assistant_live",
key: `assistant:${item.annotation_id}`,
updated_at: item.updated_at,
rating: item.rating,
autorun: null,
assistant: item
}));
return [...autorunItems, ...assistantItems].sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at));
}, [assistantLiveAnnotations, visibleAnnotations]);
const annotationsAverageRating = useMemo(() => {
if (visibleAnnotations.length === 0) return null;
const avg = visibleAnnotations.reduce((acc, item) => acc + item.rating, 0) / visibleAnnotations.length;
if (unifiedVisibleAnnotations.length === 0) return null;
const avg = unifiedVisibleAnnotations.reduce((acc, item) => acc + item.rating, 0) / unifiedVisibleAnnotations.length;
return Number(avg.toFixed(2));
}, [visibleAnnotations]);
}, [unifiedVisibleAnnotations]);
const runSelectItems = useMemo(() => {
const list = [...(history?.items ?? [])];
@ -2411,7 +2449,7 @@ export function AutoRunsHistoryPanel({
<div className="autoruns-stats-grid">
<div>
<span>Комментариев</span>
<strong>{visibleAnnotations.length}</strong>
<strong>{unifiedVisibleAnnotations.length}</strong>
</div>
<div>
<span>Средний рейтинг</span>
@ -2419,7 +2457,9 @@ export function AutoRunsHistoryPanel({
</div>
<div>
<span>Последний</span>
<strong>{visibleAnnotations.length > 0 ? formatDateTime(visibleAnnotations[0].updated_at) : "нет данных"}</strong>
<strong>
{unifiedVisibleAnnotations.length > 0 ? formatDateTime(unifiedVisibleAnnotations[0].updated_at) : "нет данных"}
</strong>
</div>
<div>
<span>Статус</span>
@ -2438,74 +2478,99 @@ export function AutoRunsHistoryPanel({
<div className="autoruns-comments-list">
{annotationsBusy ? <p className="muted">Загружаю комментарии...</p> : null}
{!annotationsBusy && visibleAnnotations.length === 0 ? (
{!annotationsBusy && unifiedVisibleAnnotations.length === 0 ? (
<p className="muted">
{annotations.length === 0
{annotations.length === 0 && assistantLiveAnnotations.length === 0
? "Пока нет откомментированных ответов."
: "\u041d\u0435\u0442 \u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0445 \u043a\u0435\u0439\u0441\u043e\u0432 \u043f\u043e \u0442\u0435\u043a\u0443\u0449\u0435\u043c\u0443 \u0444\u0438\u043b\u044c\u0442\u0440\u0443."}
</p>
) : null}
{visibleAnnotations.map((item) => (
<article
key={item.annotation_id}
className={selectedAnnotationId === item.annotation_id ? "autoruns-comment-item selected" : "autoruns-comment-item"}
onClick={() => void openAnnotationContext(item)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void openAnnotationContext(item);
}
}}
>
<div className="autoruns-comment-head">
<strong>{renderRatingDots(item.rating)}</strong>
<div className="autoruns-comment-head-actions">
<span>{formatDateTime(item.updated_at)}</span>
<button
type="button"
className={item.resolved ? "autoruns-resolve-toggle resolved" : "autoruns-resolve-toggle"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void toggleAnnotationResolved(item, !item.resolved);
}}
disabled={annotationResolutionBusyId === item.annotation_id}
title={
item.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
aria-label={
item.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
>
<CommentResolvedIcon resolved={item.resolved} />
</button>
{unifiedVisibleAnnotations.map((item) => {
if (item.source === "assistant_live") {
const annotation = item.assistant;
return (
<article key={item.key} className="autoruns-comment-item">
<div className="autoruns-comment-head">
<strong>{renderRatingDots(annotation.rating)}</strong>
<div className="autoruns-comment-head-actions">
<span>{formatDateTime(annotation.updated_at)}</span>
</div>
</div>
<div className="autoruns-run-meta">live-session: {annotation.session_id}</div>
<div className="autoruns-run-meta">msg={annotation.message_index}</div>
<div className="autoruns-run-meta">
source=assistant_live
{annotation.annotation_author ? ` | author=${annotation.annotation_author}` : ""}
</div>
{annotation.context.question_text ? <p>Q: {annotation.context.question_text}</p> : null}
{annotation.context.answer_text ? <p>A: {annotation.context.answer_text}</p> : null}
<p>{annotation.comment}</p>
</article>
);
}
const annotation = item.autorun;
return (
<article
key={item.key}
className={selectedAnnotationId === annotation.annotation_id ? "autoruns-comment-item selected" : "autoruns-comment-item"}
onClick={() => void openAnnotationContext(annotation)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void openAnnotationContext(annotation);
}
}}
>
<div className="autoruns-comment-head">
<strong>{renderRatingDots(annotation.rating)}</strong>
<div className="autoruns-comment-head-actions">
<span>{formatDateTime(annotation.updated_at)}</span>
<button
type="button"
className={annotation.resolved ? "autoruns-resolve-toggle resolved" : "autoruns-resolve-toggle"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void toggleAnnotationResolved(annotation, !annotation.resolved);
}}
disabled={annotationResolutionBusyId === annotation.annotation_id}
title={
annotation.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
aria-label={
annotation.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
>
<CommentResolvedIcon resolved={annotation.resolved} />
</button>
</div>
</div>
</div>
<div className="autoruns-run-meta">{item.run_id}</div>
<div className="autoruns-run-meta">
case={item.case_id} | msg={item.message_index}
</div>
<div className="autoruns-run-meta">
decision={item.manual_case_decision}
{item.annotation_author ? ` | author=${item.annotation_author}` : ""}
</div>
{item.resolved_at ? (
<div className="autoruns-run-meta">{annotation.run_id}</div>
<div className="autoruns-run-meta">
{"\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e"}: {formatDateTime(item.resolved_at)}
{item.resolved_by ? ` | by=${item.resolved_by}` : ""}
case={annotation.case_id} | msg={annotation.message_index}
</div>
) : null}
{item.context.question_text ? <p>Q: {item.context.question_text}</p> : null}
{item.context.answer_text ? <p>A: {item.context.answer_text}</p> : null}
<p>{item.comment}</p>
</article>
))}
<div className="autoruns-run-meta">
decision={annotation.manual_case_decision}
{annotation.annotation_author ? ` | author=${annotation.annotation_author}` : ""}
</div>
{annotation.resolved_at ? (
<div className="autoruns-run-meta">
{"\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e"}: {formatDateTime(annotation.resolved_at)}
{annotation.resolved_by ? ` | by=${annotation.resolved_by}` : ""}
</div>
) : null}
{annotation.context.question_text ? <p>Q: {annotation.context.question_text}</p> : null}
{annotation.context.answer_text ? <p>A: {annotation.context.answer_text}</p> : null}
<p>{annotation.comment}</p>
</article>
);
})}
</div>
{selectedAnnotation ? (
@ -2574,7 +2639,7 @@ export function AutoRunsHistoryPanel({
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Комментарий сохраняется отдельно от комментариев автопрогонов.</p>
<p className="muted">Комментарий будет добавлен в общий список комментариев справа с меткой `assistant_live`.</p>
{assistantLiveCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>