ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека 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; asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text); const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/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) && const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text); /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text); const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
@ -708,6 +713,9 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) { if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true; return true;
} }
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) { if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true; return true;
} }
@ -823,6 +831,18 @@ function hasSupplierTailRiskSignal(text) {
const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text); const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text);
return hasSupplier && hasTail && (hasRisk || hasPeriodCue); 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) { function hasReceivablesLatencyRiskSignal(text) {
const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text); const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text);
const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text); const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text);
@ -1204,10 +1224,14 @@ function resolveAddressIntent(userMessage) {
}; };
} }
if (hasAny(text, PAYABLES_STRONG)) { if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return { return {
intent: "list_payables_counterparties", intent: "list_payables_counterparties",
confidence: "high", confidence: "high",
reasons: ["payables_signal_detected"] reasons
}; };
} }
if (hasSettlementGapSignal(text)) { if (hasSettlementGapSignal(text)) {
@ -1242,7 +1266,7 @@ function resolveAddressIntent(userMessage) {
return { return {
intent: "list_payables_counterparties", intent: "list_payables_counterparties",
confidence: "medium", confidence: "medium",
reasons: ["supplier_tail_risk_signal_detected"] reasons: ["supplier_tail_risk_signal_detected", "payables_debt_lifecycle_signal_detected"]
}; };
} }
if (hasDocumentsFormingBalanceSignal(text) && hasDocumentsFormingBalanceAccountAnchor(text)) { if (hasDocumentsFormingBalanceSignal(text) && hasDocumentsFormingBalanceAccountAnchor(text)) {

View File

@ -631,6 +631,92 @@ function isCounterpartyRiskIntent(intent) {
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract"); 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) { function resolveFutureGuardReferenceDate(analysisDate, filters) {
if (analysisDate) { if (analysisDate) {
return analysisDate; return analysisDate;
@ -1196,6 +1282,13 @@ function composeLimitedReply(input) {
} }
function buildLimitedExecutionResult(input) { function buildLimitedExecutionResult(input) {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters); 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 { return {
handled: true, handled: true,
reply_text: composeLimitedReply({ reply_text: composeLimitedReply({
@ -1246,6 +1339,7 @@ function buildLimitedExecutionResult(input) {
runtime_readiness: runtimeReadinessForLimitedCategory(input.category), runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category, limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON", response_type: "LIMITED_WITH_REASON",
...resultSemantics,
limitations: input.limitations, limitations: input.limitations,
reasons: input.reasons reasons: input.reasons
} }
@ -1288,11 +1382,25 @@ class AddressQueryService {
const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" && const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) && Array.isArray(intent.reasons) &&
intent.reasons.includes("receivables_debt_lifecycle_signal_detected"); 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 recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, filters.extracted_filters);
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) { if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle"); 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") { if (intent.intent === "unknown") {
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
@ -1576,6 +1684,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: factual.responseType, 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], limitations: [...filters.warnings, recoveryReason],
reasons: [...baseReasons, recoveryReason] reasons: [...baseReasons, recoveryReason]
} }
@ -1692,6 +1807,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: expandedFactual.responseType, 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, limitations: expandedLimitations,
reasons: expandedReasons reasons: expandedReasons
} }
@ -1803,6 +1925,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: broadenedFactual.responseType, 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, limitations: broadenedLimitations,
reasons: broadenedReasons reasons: broadenedReasons
} }
@ -1922,6 +2051,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: historicalFactual.responseType, 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, limitations: historicalLimitations,
reasons: historicalReasons reasons: historicalReasons
} }
@ -1986,6 +2122,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: fallbackFactual.responseType, 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, limitations: fallbackLimitations,
reasons: fallbackReasons reasons: fallbackReasons
} }
@ -2142,6 +2285,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: factual.responseType, 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, limitations: filters.warnings,
reasons: baseReasons reasons: baseReasons
} }

View File

@ -470,6 +470,142 @@ function extractCounterpartyName(row) {
} }
return null; 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) { function buildCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map(); const byCounterparty = new Map();
for (const row of rows) { for (const row of rows) {
@ -1528,22 +1664,55 @@ function composeFactualReply(intent, rows, options = {}) {
}; };
} }
if (intent === "list_payables_counterparties") { 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 = [ const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).", "Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).",
"",
"Что это значит:",
"- Режим результата: эвристический скоринг по движениям.",
"- Это не финальный подтвержденный остаток к оплате.",
...(scopeLine ? ["", scopeLine] : []),
"",
`Строк в выборке: ${rows.length}.`, `Строк в выборке: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.` `Контрагентов-кандидатов: ${counterparties.length}.`
]; ];
if (counterparties.length > 0) { 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 lines.push(...counterparties
.slice(0, 8) .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("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 4)); lines.push(...formatTopRows(rows, 4));
} }
else { else {
lines.push("Явных признаков системной задолженности по доступному срезу не найдено."); lines.push("");
lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено.");
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 6)); lines.push(...formatTopRows(rows, 6));
} }
return { return {

View File

@ -1460,6 +1460,11 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
runtime_readiness: addressDebug.runtime_readiness, runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category, limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type, 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", execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied), llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted), llm_decomposition_attempted: Boolean(llmMeta?.attempted),
@ -1588,6 +1593,19 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"kakoi", "kakoi",
"vse", "vse",
"all", "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", "blya",
"blyat", "blyat",
"епт", "епт",
@ -1612,51 +1630,51 @@ const ADDRESS_FALLBACK_STRIP_TOKENS = new Set([
"please" "please"
]); ]);
const ADDRESS_MONTH_ALIAS_MAP = { const ADDRESS_MONTH_ALIAS_MAP = {
янв: "01", "\u044f\u043d\u0432": "01",
январ: "01", "\u044f\u043d\u0432\u0430\u0440": "01",
january: "01", january: "01",
jan: "01", jan: "01",
фев: "02", "\u0444\u0435\u0432": "02",
феврал: "02", "\u0444\u0435\u0432\u0440\u0430\u043b": "02",
february: "02", february: "02",
feb: "02", feb: "02",
мар: "03", "\u043c\u0430\u0440": "03",
март: "03", "\u043c\u0430\u0440\u0442": "03",
march: "03", march: "03",
apr: "04", apr: "04",
апр: "04", "\u0430\u043f\u0440": "04",
апрел: "04", "\u0430\u043f\u0440\u0435\u043b": "04",
april: "04", april: "04",
май: "05", "\u043c\u0430\u0439": "05",
ма: "05", "\u043c\u0430": "05",
may: "05", may: "05",
июн: "06", "\u0438\u044e\u043d": "06",
июнь: "06", "\u0438\u044e\u043d\u044c": "06",
june: "06", june: "06",
jun: "06", jun: "06",
июл: "07", "\u0438\u044e\u043b": "07",
июль: "07", "\u0438\u044e\u043b\u044c": "07",
july: "07", july: "07",
jul: "07", jul: "07",
авг: "08", "\u0430\u0432\u0433": "08",
август: "08", "\u0430\u0432\u0433\u0443\u0441\u0442": "08",
august: "08", august: "08",
aug: "08", aug: "08",
сен: "09", "\u0441\u0435\u043d": "09",
сент: "09", "\u0441\u0435\u043d\u0442": "09",
сентябр: "09", "\u0441\u0435\u043d\u0442\u044f\u0431\u0440": "09",
september: "09", september: "09",
sep: "09", sep: "09",
окт: "10", "\u043e\u043a\u0442": "10",
октябр: "10", "\u043e\u043a\u0442\u044f\u0431\u0440": "10",
october: "10", october: "10",
oct: "10", oct: "10",
ноя: "11", "\u043d\u043e\u044f": "11",
ноябр: "11", "\u043d\u043e\u044f\u0431\u0440": "11",
november: "11", november: "11",
nov: "11", nov: "11",
дек: "12", "\u0434\u0435\u043a": "12",
декабр: "12", "\u0434\u0435\u043a\u0430\u0431\u0440": "12",
december: "12", december: "12",
dec: "12" dec: "12"
}; };
@ -1883,6 +1901,10 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
const bankSignal = ADDRESS_BANK_SIGNAL_PATTERN.test(source); const bankSignal = ADDRESS_BANK_SIGNAL_PATTERN.test(source);
const contractSignal = ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source); const contractSignal = ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
const balanceSignal = ADDRESS_BALANCE_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) { if (balanceSignal && account) {
let periodClause = ""; let periodClause = "";
let rule = "balance_account_rewrite"; let rule = "balance_account_rewrite";
@ -2201,6 +2223,14 @@ const FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS = new Set([
"company", "company",
"group" "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) { function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? "")) return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase() .toLowerCase()
@ -2211,7 +2241,25 @@ function normalizeCounterpartyForFollowupMatch(value) {
function normalizeCounterpartyTokenForFollowupMatch(value) { function normalizeCounterpartyTokenForFollowupMatch(value) {
return normalizeCounterpartyForFollowupMatch(value).replace(/[._-]+/g, ""); 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 lines = String(replyText ?? "").split(/\r?\n/);
const candidates = []; const candidates = [];
for (const line of lines) { for (const line of lines) {
@ -2219,10 +2267,15 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!compactLine) { if (!compactLine) {
continue; continue;
} }
if (!/^\d+\.\s+/.test(compactLine)) { const numberedMatch = compactLine.match(/^(\d+)\.\s+(.+)$/);
if (!numberedMatch) {
continue; 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)); const parts = afterNumber.split("|").map((item) => compactWhitespace(item));
let counterpartyCandidate = parts[0] ?? ""; let counterpartyCandidate = parts[0] ?? "";
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(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) { if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue; 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) { function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
const aliases = new Set(); const aliases = new Set();
@ -2247,13 +2311,14 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
.split(/\s+/) .split(/\s+/)
.map((token) => token.trim()) .map((token) => token.trim())
.filter(Boolean); .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)) .filter((token) => !FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS.has(token))
.join(" "); .join(" ");
if (withoutLegalTokens) { if (withoutLegalTokens) {
aliases.add(withoutLegalTokens); aliases.add(withoutLegalTokens);
} }
for (const token of normalizedTokens) { for (const token of tokensForAlias) {
const compactToken = normalizeCounterpartyTokenForFollowupMatch(token); const compactToken = normalizeCounterpartyTokenForFollowupMatch(token);
if (compactToken.length < 3) { if (compactToken.length < 3) {
continue; continue;
@ -2265,6 +2330,10 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
continue; continue;
} }
aliases.add(compactToken); aliases.add(compactToken);
const stemToken = normalizeCounterpartyStemForFollowupMatch(compactToken);
if (stemToken.length >= 4) {
aliases.add(stemToken);
}
} }
return Array.from(aliases) return Array.from(aliases)
.map((alias) => compactWhitespace(alias)) .map((alias) => compactWhitespace(alias))
@ -2278,31 +2347,95 @@ function hasCounterpartyAliasMention(normalizedMessage, alias) {
} }
const aliasPattern = escapeRegex(trimmedAlias).replace(/\s+/g, "\\s+"); const aliasPattern = escapeRegex(trimmedAlias).replace(/\s+/g, "\\s+");
const boundaryPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}(?:$|[^a-zа-я0-9])`, "iu"); 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); const normalizedMessage = normalizeCounterpartyForFollowupMatch(userMessage);
if (!normalizedMessage) { if (!normalizedMessage) {
return null; return null;
} }
if (!Array.isArray(displayedCounterparties) || displayedCounterparties.length === 0) { if (!Array.isArray(displayedEntities) || displayedEntities.length === 0) {
return null; 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; let bestMatch = null;
for (const candidate of displayedCounterparties) { for (const candidate of displayedEntities) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate); const aliases = buildCounterpartyAliasesForFollowupMatch(candidate.value);
for (const alias of aliases) { for (const alias of aliases) {
if (!hasCounterpartyAliasMention(normalizedMessage, alias)) { if (!hasCounterpartyAliasMention(normalizedMessage, alias)) {
continue; 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) { if (!bestMatch || score > bestMatch.score) {
bestMatch = { value: candidate, score }; bestMatch = {
value: candidate.value,
entityType: candidate.entityType,
index: candidate.index,
matchKind: "alias",
score
};
} }
break; 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) { function findRecentAddressFilterValue(items, key) {
for (let index = items.length - 1; index >= 0; index -= 1) { for (let index = items.length - 1; index >= 0; index -= 1) {
@ -2479,12 +2612,17 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) ? hasAddressFollowupContextSignal(alternateMessage)
: false; : false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
: false;
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) { if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null; return null;
} }
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) { if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null; return null;
} }
if (!previousAddressDebug) { if (!previousAddressDebug) {
@ -2531,16 +2669,24 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousFilters.organization = historicalOrganization; previousFilters.organization = historicalOrganization;
} }
} }
const displayedCounterparties = extractDisplayedCounterpartyCandidates(toNonEmptyString(previousAddressItem?.text) ?? ""); const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent);
const counterpartyFromFollowupText = resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) ?? const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
(toNonEmptyString(alternateMessage) (toNonEmptyString(alternateMessage)
? resolveDisplayedCounterpartyMention(String(alternateMessage ?? ""), displayedCounterparties) ? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
: null); : null);
if (counterpartyFromFollowupText) { if (resolvedEntityFromFollowup) {
previousFilters.counterparty = counterpartyFromFollowupText; if (resolvedEntityFromFollowup.entityType === "counterparty") {
previousFilters.counterparty = resolvedEntityFromFollowup.value;
previousAnchorType = "counterparty"; previousAnchorType = "counterparty";
previousAnchor = counterpartyFromFollowupText; previousAnchor = resolvedEntityFromFollowup.value;
resolvedCounterpartyFromDisplay = true; resolvedCounterpartyFromDisplay = true;
}
else if (resolvedEntityFromFollowup.entityType === "contract") {
previousFilters.contract = resolvedEntityFromFollowup.value;
previousAnchorType = "contract";
previousAnchor = resolvedEntityFromFollowup.value;
}
if (followupSelectionMode !== "switch_to_suggested_intent") { if (followupSelectionMode !== "switch_to_suggested_intent") {
followupSelectionMode = "carry_referenced_entity"; followupSelectionMode = "carry_referenced_entity";
} }
@ -3373,7 +3519,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown" && llmContractIntent === "unknown" &&
!followupContext && !followupContext &&
!hasClassifierSignal && !hasClassifierSignal &&
!strongDataSignalFromRawMessage) { !hasIntentSignal &&
!strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) {
return { return {
runAddressLane: false, runAddressLane: false,
decision: "skip_address_lane", decision: "skip_address_lane",
@ -4510,7 +4658,7 @@ function isPlausibleOrganizationName(value) {
if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) { if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) {
return false; 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) { function appendOrganizationFactsFromValue(value, hints, bucket, depth = 0) {
if (depth > 4 || value === null || value === undefined) { 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; asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text); const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text); const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksWhoBringsMostMoney =
/(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(
text
);
const asksDealBudgetRanking = const asksDealBudgetRanking =
/(?:сделк|deal|бюджет)/iu.test(text) && /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test( /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(
@ -816,6 +824,9 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) { if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true; return true;
} }
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) { if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true; return true;
} }
@ -956,6 +967,22 @@ function hasSupplierTailRiskSignal(text: string): boolean {
return hasSupplier && hasTail && (hasRisk || hasPeriodCue); 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 { function hasReceivablesLatencyRiskSignal(text: string): boolean {
const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text); const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text);
const hasCounterparty = /(?:контрагент|counterparty|partner)/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)) { if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return { return {
intent: "list_payables_counterparties", intent: "list_payables_counterparties",
confidence: "high", confidence: "high",
reasons: ["payables_signal_detected"] reasons
}; };
} }
@ -1447,7 +1478,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
return { return {
intent: "list_payables_counterparties", intent: "list_payables_counterparties",
confidence: "medium", 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_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
} from "../config"; } from "../config";
import type { import type {
AddressAsOfDateBasis,
AddressEvidenceStrength,
AddressExecutionResult, AddressExecutionResult,
AddressFilterSet, AddressFilterSet,
AddressIntent, AddressIntent,
@ -10,14 +12,19 @@ import type {
AddressMatchFailureStage, AddressMatchFailureStage,
AddressMcpCallStatus, AddressMcpCallStatus,
AddressQueryShapeDetection, AddressQueryShapeDetection,
AddressResultMode,
AddressResponseType, AddressResponseType,
AddressRuntimeReadiness AddressRuntimeReadiness
} from "../types/addressQuery"; } from "../types/addressQuery";
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog"; import {
buildAddressRecipePlan,
selectAddressRecipe,
type AddressRecipeExecutionPlan
} from "./addressRecipeCatalog";
import { executeAddressMcpQuery } from "./addressMcpClient"; import { executeAddressMcpQuery } from "./addressMcpClient";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage"; import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage"; 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 { interface NormalizedAddressRow {
period: string | null; 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_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 ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000; const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const PARTY_ANCHOR_STOPWORDS = new Set([ 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 { function resolveFutureGuardReferenceDate(analysisDate: string | null, filters: AddressFilterSet): string | null {
if (analysisDate) { if (analysisDate) {
return analysisDate; return analysisDate;
@ -1494,6 +1693,20 @@ function buildLimitedExecutionResult(input: {
category: AddressLimitedReasonCategory; category: AddressLimitedReasonCategory;
}): AddressExecutionResult { }): AddressExecutionResult {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters); 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 { return {
handled: true, handled: true,
reply_text: composeLimitedReply({ reply_text: composeLimitedReply({
@ -1544,8 +1757,9 @@ function buildLimitedExecutionResult(input: {
runtime_readiness: runtimeReadinessForLimitedCategory(input.category), runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category, limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON", response_type: "LIMITED_WITH_REASON",
...resultSemantics,
limitations: input.limitations, limitations: input.limitations,
reasons: input.reasons reasons
} }
}; };
} }
@ -1579,23 +1793,66 @@ export class AddressQueryService {
baseReasons.push("as_of_date_from_analysis_context"); 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) => ({ const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
userMessage, userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : 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); let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const debtLifecycleReceivablesScenario = const debtLifecycleReceivablesScenario =
intent.intent === "list_receivables_counterparties" && intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) && Array.isArray(intent.reasons) &&
intent.reasons.includes("receivables_debt_lifecycle_signal_detected"); intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent; const debtLifecyclePayablesScenario =
const recipeSelection = selectAddressRecipe(recipeIntent, filters.extracted_filters); 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) { if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle"); 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") { if (intent.intent === "unknown") {
return buildLimitedExecutionResult({ 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({ let mcp = await executeAddressMcpQuery({
query: plan.query, query: plan.query,
limit: plan.limit limit: plan.limit
}); });
if ( if (
mcp.error && 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) 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) { 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({ const fallbackMcp = await executeAddressMcpQuery({
query: fallbackPlan.query, query: fallbackPlan.query,
limit: fallbackPlan.limit limit: fallbackPlan.limit
@ -1736,9 +2001,18 @@ export class AddressQueryService {
if (!fallbackMcp.error) { if (!fallbackMcp.error) {
plan = fallbackPlan; plan = fallbackPlan;
mcp = fallbackMcp; 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")) { if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("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 { } else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed"); baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
@ -1759,7 +2033,7 @@ export class AddressQueryService {
intent, intent,
filters: filters.extracted_filters, filters: filters.extracted_filters,
missingRequiredFilters: [], missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id, selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode, accountScopeMode: plan.account_scope_mode,
anchor, anchor,
mcpCallStatus: deriveMcpStageStatus({ mcpCallStatus: deriveMcpStageStatus({
@ -1797,10 +2071,10 @@ export class AddressQueryService {
anchor = refineAnchorFromRows(anchor, normalizedRows); anchor = refineAnchorFromRows(anchor, normalizedRows);
const filtersForMatching: AddressFilterSet = const filtersForMatching: AddressFilterSet =
anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved 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 : anchor.anchor_type === "contract" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved } ? { ...executionFilters, contract: anchor.anchor_value_resolved }
: filters.extracted_filters; : executionFilters;
const accountScopeAudit = buildAccountScopeAudit({ const accountScopeAudit = buildAccountScopeAudit({
intent: intent.intent, intent: intent.intent,
filters: filtersForMatching, filters: filtersForMatching,
@ -1849,7 +2123,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors); const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors; const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) { if (recoveredRows.length > 0) {
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters)); const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters));
const recoveryReason = const recoveryReason =
recoveredBankRows.length > 0 recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback" ? "contract_docs_recovered_via_bank_fallback"
@ -1872,7 +2146,7 @@ export class AddressQueryService {
detected_intent_confidence: intent.confidence, detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters, extracted_filters: filters.extracted_filters,
missing_required_filters: [], missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id, selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"), mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"),
account_scope_mode: plan.account_scope_mode, account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied, account_scope_fallback_applied: accountScopeFallbackApplied,
@ -1900,8 +2174,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: factual.responseType, 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], 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") stageStatus === "raw_rows_received_but_not_materialized")
) { ) {
const currentLimit = const currentLimit =
typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit) typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit)) ? Math.max(1, Math.trunc(executionFilters.limit))
: plan.limit; : plan.limit;
if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) { if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) {
const expandedLimitFilters: AddressFilterSet = { const expandedLimitFilters: AddressFilterSet = {
...filters.extracted_filters, ...executionFilters,
limit: ADDRESS_ANCHOR_RECOVERY_LIMIT limit: ADDRESS_ANCHOR_RECOVERY_LIMIT
}; };
const expandedSelection = selectAddressRecipe(intent.intent, expandedLimitFilters); const expandedSelection = selectAddressRecipe(intent.intent, expandedLimitFilters);
@ -2034,8 +2322,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: expandedFactual.responseType, 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, limitations: expandedLimitations,
reasons: expandedReasons reasons: withConfirmedBalanceFallbackReason(
expandedReasons,
requestedResultMode,
expandedFactual.semantics
)
} }
}; };
} }
@ -2160,8 +2462,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: broadenedFactual.responseType, 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, limitations: broadenedLimitations,
reasons: broadenedReasons reasons: withConfirmedBalanceFallbackReason(
broadenedReasons,
requestedResultMode,
broadenedFactual.semantics
)
} }
}; };
} }
@ -2298,8 +2614,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: historicalFactual.responseType, 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, limitations: historicalLimitations,
reasons: historicalReasons reasons: withConfirmedBalanceFallbackReason(
historicalReasons,
requestedResultMode,
historicalFactual.semantics
)
} }
}; };
} }
@ -2319,7 +2649,7 @@ export class AddressQueryService {
const fallbackFactual = composeFactualReply( const fallbackFactual = composeFactualReply(
intent.intent, intent.intent,
documentBankFallbackRows, documentBankFallbackRows,
composeOptionsFromFilters(filters.extracted_filters) composeOptionsFromFilters(executionFilters)
); );
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы."; const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion = const fallbackSuggestion =
@ -2342,7 +2672,7 @@ export class AddressQueryService {
detected_intent_confidence: intent.confidence, detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters, extracted_filters: filters.extracted_filters,
missing_required_filters: [], missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id, selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: "matched_non_empty", mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: plan.account_scope_mode, account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied, account_scope_fallback_applied: accountScopeFallbackApplied,
@ -2370,8 +2700,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: fallbackFactual.responseType, 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, limitations: fallbackLimitations,
reasons: fallbackReasons reasons: withConfirmedBalanceFallbackReason(
fallbackReasons,
requestedResultMode,
fallbackFactual.semantics
)
} }
}; };
} }
@ -2467,7 +2811,7 @@ export class AddressQueryService {
intent, intent,
filters: filters.extracted_filters, filters: filters.extracted_filters,
missingRequiredFilters: [], missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id, selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode, accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied, accountScopeFallbackApplied,
accountScopeAudit, 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 { return {
handled: true, handled: true,
reply_text: factual.text, reply_text: factual.text,
@ -2506,7 +2860,7 @@ export class AddressQueryService {
detected_intent_confidence: intent.confidence, detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters, extracted_filters: filters.extracted_filters,
missing_required_filters: [], missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id, selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus), mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode, account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied, account_scope_fallback_applied: accountScopeFallbackApplied,
@ -2534,8 +2888,14 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: factual.responseType, response_type: factual.responseType,
...factualResultSemantics,
limitations: filters.warnings, 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__ __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 { export interface ComposeStageRow {
period: string | null; period: string | null;
@ -14,6 +19,13 @@ interface ComposeFactualReplyOptions {
periodFrom?: string; periodFrom?: string;
periodTo?: string; periodTo?: string;
asOfDate?: string; asOfDate?: string;
requestedResultMode?: AddressResultMode;
}
export interface ComposeReplySemantics {
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
} }
type PeriodProfileFocus = type PeriodProfileFocus =
@ -618,6 +630,307 @@ interface CounterpartyRiskAggregate {
lastPeriod: string | null; 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[] { function buildCounterpartyRiskAggregate(rows: ComposeStageRow[]): CounterpartyRiskAggregate[] {
const byCounterparty = new Map<string, CounterpartyRiskAggregate>(); const byCounterparty = new Map<string, CounterpartyRiskAggregate>();
@ -885,7 +1198,7 @@ export function composeFactualReply(
intent: AddressIntent, intent: AddressIntent,
rows: ComposeStageRow[], rows: ComposeStageRow[],
options: ComposeFactualReplyOptions = {} options: ComposeFactualReplyOptions = {}
): { responseType: AddressResponseType; text: string } { ): { responseType: AddressResponseType; text: string; semantics?: ComposeReplySemantics } {
if (intent === "document_type_and_account_section_profile") { if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>(); const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) { for (const row of rows) {
@ -1940,34 +2253,172 @@ export function composeFactualReply(
} }
if (intent === "list_payables_counterparties") { if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows); 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 = [ const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).", "Блок 1. Статус результата",
`Строк в выборке: ${rows.length}.`, forcedFallbackFromConfirmed
`Контрагентов с сигналом: ${counterparties.length}.` ? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен."
: "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).",
"- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.",
"",
"Блок 2. Как читать результат",
"- Это shortlist кандидатов: нужна ручная проверка бухгалтером.",
"- Это не подтвержденный остаток к оплате и не готовое платежное поручение.",
...(scopeLine ? [scopeLine] : []),
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов-кандидатов: ${counterparties.length}.`
]; ];
if (counterparties.length > 0) { if (counterparties.length > 0) {
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):"); const categoryCounts = counterparties.reduce<Record<PayablesLiabilityCategory, number>>(
lines.push( (acc, item) => {
...counterparties acc[item.category] += 1;
.slice(0, 8) return acc;
.map( },
(item, index) => { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
)
); );
lines.push("Примеры исходных строк:"); 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)); lines.push(...formatTopRows(rows, 4));
} else { } else {
lines.push("Явных признаков системной задолженности по доступному срезу не найдено."); lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push("- Явных кандидатов на незакрытые обязательства по доступному срезу не найдено.");
lines.push("");
lines.push("Блок 5. Примеры исходных строк");
lines.push(...formatTopRows(rows, 6)); 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} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`
)
];
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") 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"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
if (intent === "list_receivables_counterparties") { if (intent === "list_receivables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows); const counterparties = buildCounterpartyRiskAggregate(rows);
const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage); const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage);

View File

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

View File

@ -24,6 +24,9 @@ export type AddressIntent =
| "unknown"; | "unknown";
export type AddressResponseType = "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON"; 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 = export type AddressQueryShape =
| "AGGREGATE_LOOKUP" | "AGGREGATE_LOOKUP"
@ -189,6 +192,11 @@ export interface AddressExecutionDebug {
runtime_readiness: AddressRuntimeReadiness; runtime_readiness: AddressRuntimeReadiness;
limited_reason_category: AddressLimitedReasonCategory | null; limited_reason_category: AddressLimitedReasonCategory | null;
response_type: AddressResponseType; response_type: AddressResponseType;
requested_result_mode?: AddressResultMode;
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
as_of_date_basis?: AddressAsOfDateBasis | null;
limitations: string[]; limitations: string[];
reasons: 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"; 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; limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON"; 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"; execution_lane?: "address_query" | "deep_analysis";
llm_decomposition_applied?: boolean; llm_decomposition_applied?: boolean;
llm_decomposition_attempted?: 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 { detectAddressQuestionMode } from "../src/services/addressQueryClassifier";
import { resolveAddressIntent } from "../src/services/addressIntentResolver"; import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier"; import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
@ -1830,6 +1830,12 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_payables_counterparties"); 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", () => { it("keeps out-of-scope supplier control wording as unknown intent", () => {
const result = resolveAddressIntent( 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"); 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 () => { it("routes shipment-to-payment lag wording into receivables lane without missing-anchor fallback", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle( 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); 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 () => { it("routes typo highest-check wording into customer value aggregate recipe", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle("с каких кликентов самый высокий чек"); const result = await service.tryHandle("с каких кликентов самый высокий чек");

View File

@ -708,6 +708,312 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled(); 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 () => { it("does not carry address follow-up context into capability question", async () => {
const calls: Array<{ message: string; options?: any }> = []; const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020"; const firstMessage = "покажи документы по свк за 2020";

View File

@ -219,6 +219,39 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered"); 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", () => { it("routes unsupported turnover-by-organization query to deep analysis", () => {
const decision = resolveAssistantOrchestrationDecision({ 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", 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 charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-D4dtBq8A.css">
</head> </head>
<body> <body>

View File

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