ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека exact-маршрута payables на дату: confirmed_balance без эвристического финала
This commit is contained in:
parent
ca2feab893
commit
1b2ee93176
|
|
@ -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`;
|
||||||
|
- подменять расчет обязательств простым списком движений.
|
||||||
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
previousAnchorType = "counterparty";
|
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||||
previousAnchor = counterpartyFromFollowupText;
|
previousAnchorType = "counterparty";
|
||||||
resolvedCounterpartyFromDisplay = true;
|
previousAnchor = resolvedEntityFromFollowup.value;
|
||||||
|
resolvedCounterpartyFromDisplay = true;
|
||||||
|
}
|
||||||
|
else if (resolvedEntityFromFollowup.entityType === "contract") {
|
||||||
|
previousFilters.contract = resolvedEntityFromFollowup.value;
|
||||||
|
previousAnchorType = "contract";
|
||||||
|
previousAnchor = resolvedEntityFromFollowup.value;
|
||||||
|
}
|
||||||
if (followupSelectionMode !== "switch_to_suggested_intent") {
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,13 @@ const MOVEMENTS_QUERY_TEMPLATE = `
|
||||||
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
|
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
|
||||||
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
|
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
|
||||||
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
|
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
|
||||||
Движения.Сумма КАК Сумма
|
Движения.Сумма КАК Сумма,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
|
||||||
ИЗ
|
ИЗ
|
||||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||||
__WHERE_CLAUSE__
|
__WHERE_CLAUSE__
|
||||||
|
|
|
||||||
|
|
@ -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 lines = [
|
const payablesAsOfDate = resolvePayablesAsOfDate(options);
|
||||||
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
|
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
|
||||||
`Строк в выборке: ${rows.length}.`,
|
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
|
||||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
const periodTo = normalizeIsoDateOnly(options.periodTo);
|
||||||
];
|
const scopeLine = asOfDate
|
||||||
if (counterparties.length > 0) {
|
? `- Дата среза: ${formatDateRu(asOfDate)}.`
|
||||||
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
|
: periodFrom || periodTo
|
||||||
lines.push(
|
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
|
||||||
...counterparties
|
: null;
|
||||||
.slice(0, 8)
|
const carryoverLine =
|
||||||
.map(
|
asOfDate || periodFrom || periodTo
|
||||||
|
? "- В список могут попадать обязательства, возникшие раньше выбранного периода, если они потенциально оставались открытыми на дату среза."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const formatHeuristicItem = (item: PayablesCounterpartyRiskAggregate, index: number): string =>
|
||||||
|
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
|
||||||
|
|
||||||
|
const pushCategorySlice = (
|
||||||
|
lines: string[],
|
||||||
|
title: string,
|
||||||
|
items: PayablesCounterpartyRiskAggregate[],
|
||||||
|
limit: number
|
||||||
|
): void => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(title);
|
||||||
|
lines.push(...items.slice(0, limit).map(formatHeuristicItem));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildHeuristicLines = (forcedFallbackFromConfirmed: boolean): string[] => {
|
||||||
|
const lines = [
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
forcedFallbackFromConfirmed
|
||||||
|
? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен."
|
||||||
|
: "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).",
|
||||||
|
"- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Как читать результат",
|
||||||
|
"- Это shortlist кандидатов: нужна ручная проверка бухгалтером.",
|
||||||
|
"- Это не подтвержденный остаток к оплате и не готовое платежное поручение.",
|
||||||
|
...(scopeLine ? [scopeLine] : []),
|
||||||
|
...(carryoverLine ? [carryoverLine] : []),
|
||||||
|
"",
|
||||||
|
"Блок 3. Сводка выборки",
|
||||||
|
`- Строк в выборке: ${rows.length}.`,
|
||||||
|
`- Контрагентов-кандидатов: ${counterparties.length}.`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (counterparties.length > 0) {
|
||||||
|
const categoryCounts = counterparties.reduce<Record<PayablesLiabilityCategory, number>>(
|
||||||
|
(acc, item) => {
|
||||||
|
acc[item.category] += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }
|
||||||
|
);
|
||||||
|
const suppliers = counterparties.filter((item) => item.category === "supplier_or_contractor");
|
||||||
|
const banks = counterparties.filter((item) => item.category === "bank_or_credit");
|
||||||
|
const taxOrState = counterparties.filter((item) => item.category === "tax_or_state");
|
||||||
|
const other = counterparties.filter((item) => item.category === "other");
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Блок 4. Категории обязательств");
|
||||||
|
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
|
||||||
|
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
|
||||||
|
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
|
||||||
|
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Блок 5. Кандидаты на проверку в первую очередь");
|
||||||
|
pushCategorySlice(lines, "5.1 Поставщики/подрядчики:", suppliers, 6);
|
||||||
|
pushCategorySlice(lines, "5.2 Банки/кредиты:", banks, 4);
|
||||||
|
pushCategorySlice(lines, "5.3 Налоги/госорганы:", taxOrState, 4);
|
||||||
|
pushCategorySlice(lines, "5.4 Прочие:", other, 4);
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Блок 6. Примеры исходных строк");
|
||||||
|
lines.push(...formatTopRows(rows, 4));
|
||||||
|
} else {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Блок 4. Категории обязательств");
|
||||||
|
lines.push("- Явных кандидатов на незакрытые обязательства по доступному срезу не найдено.");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Блок 5. Примеры исходных строк");
|
||||||
|
lines.push(...formatTopRows(rows, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.requestedResultMode === "confirmed_balance") {
|
||||||
|
const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
|
||||||
|
if (confirmedBalances.length > 0) {
|
||||||
|
const categoryCounts = confirmedBalances.reduce<Record<PayablesLiabilityCategory, number>>(
|
||||||
|
(acc, item) => {
|
||||||
|
acc[item.category] += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }
|
||||||
|
);
|
||||||
|
const lines: string[] = [
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
"- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 60/76.",
|
||||||
|
"- Тип результата: подтвержденные остатки к оплате.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Что учтено",
|
||||||
|
`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`,
|
||||||
|
...(periodFrom || periodTo
|
||||||
|
? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`]
|
||||||
|
: []),
|
||||||
|
"- Основание: движения обязательств и оплат в пределах доступного live-среза.",
|
||||||
|
...(carryoverLine ? [carryoverLine] : []),
|
||||||
|
"",
|
||||||
|
"Блок 3. Сводка выборки",
|
||||||
|
`- Строк в выборке: ${rows.length}.`,
|
||||||
|
`- Контрагентов с подтвержденным остатком: ${confirmedBalances.length}.`,
|
||||||
|
"",
|
||||||
|
"Блок 4. Категории обязательств",
|
||||||
|
`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`,
|
||||||
|
`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`,
|
||||||
|
`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`,
|
||||||
|
`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`,
|
||||||
|
"",
|
||||||
|
"Блок 5. Кому нужно заплатить в первую очередь (по сумме остатка):",
|
||||||
|
...confirmedBalances.slice(0, 10).map(
|
||||||
(item, index) =>
|
(item, index) =>
|
||||||
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
|
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
lines.push("Примеры исходных строк:");
|
return {
|
||||||
lines.push(...formatTopRows(rows, 4));
|
responseType: "FACTUAL_LIST",
|
||||||
} else {
|
text: lines.join("\n"),
|
||||||
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
|
semantics: {
|
||||||
lines.push(...formatTopRows(rows, 6));
|
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 {
|
return {
|
||||||
responseType: "FACTUAL_LIST",
|
responseType: "FACTUAL_LIST",
|
||||||
text: lines.join("\n")
|
text: lines.join("\n"),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "heuristic_candidates",
|
||||||
|
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
|
||||||
|
balance_confirmed: false
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "list_receivables_counterparties") {
|
if (intent === "list_receivables_counterparties") {
|
||||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||||
const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage);
|
const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage);
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
previousAnchorType = "counterparty";
|
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||||
previousAnchor = counterpartyFromFollowupText;
|
previousAnchorType = "counterparty";
|
||||||
resolvedCounterpartyFromDisplay = true;
|
previousAnchor = resolvedEntityFromFollowup.value;
|
||||||
|
resolvedCounterpartyFromDisplay = true;
|
||||||
|
}
|
||||||
|
else if (resolvedEntityFromFollowup.entityType === "contract") {
|
||||||
|
previousFilters.contract = resolvedEntityFromFollowup.value;
|
||||||
|
previousAnchorType = "contract";
|
||||||
|
previousAnchor = resolvedEntityFromFollowup.value;
|
||||||
|
}
|
||||||
if (followupSelectionMode !== "switch_to_suggested_intent") {
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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("с каких кликентов самый высокий чек");
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
<article
|
if (item.source === "assistant_live") {
|
||||||
key={item.annotation_id}
|
const annotation = item.assistant;
|
||||||
className={selectedAnnotationId === item.annotation_id ? "autoruns-comment-item selected" : "autoruns-comment-item"}
|
return (
|
||||||
onClick={() => void openAnnotationContext(item)}
|
<article key={item.key} className="autoruns-comment-item">
|
||||||
role="button"
|
<div className="autoruns-comment-head">
|
||||||
tabIndex={0}
|
<strong>{renderRatingDots(annotation.rating)}</strong>
|
||||||
onKeyDown={(event) => {
|
<div className="autoruns-comment-head-actions">
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
<span>{formatDateTime(annotation.updated_at)}</span>
|
||||||
event.preventDefault();
|
</div>
|
||||||
void openAnnotationContext(item);
|
</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">
|
||||||
<div className="autoruns-comment-head">
|
source=assistant_live
|
||||||
<strong>{renderRatingDots(item.rating)}</strong>
|
{annotation.annotation_author ? ` | author=${annotation.annotation_author}` : ""}
|
||||||
<div className="autoruns-comment-head-actions">
|
</div>
|
||||||
<span>{formatDateTime(item.updated_at)}</span>
|
{annotation.context.question_text ? <p>Q: {annotation.context.question_text}</p> : null}
|
||||||
<button
|
{annotation.context.answer_text ? <p>A: {annotation.context.answer_text}</p> : null}
|
||||||
type="button"
|
<p>{annotation.comment}</p>
|
||||||
className={item.resolved ? "autoruns-resolve-toggle resolved" : "autoruns-resolve-toggle"}
|
</article>
|
||||||
onClick={(event) => {
|
);
|
||||||
event.preventDefault();
|
}
|
||||||
event.stopPropagation();
|
const annotation = item.autorun;
|
||||||
void toggleAnnotationResolved(item, !item.resolved);
|
return (
|
||||||
}}
|
<article
|
||||||
disabled={annotationResolutionBusyId === item.annotation_id}
|
key={item.key}
|
||||||
title={
|
className={selectedAnnotationId === annotation.annotation_id ? "autoruns-comment-item selected" : "autoruns-comment-item"}
|
||||||
item.resolved
|
onClick={() => void openAnnotationContext(annotation)}
|
||||||
? "\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"
|
role="button"
|
||||||
: "\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"
|
tabIndex={0}
|
||||||
}
|
onKeyDown={(event) => {
|
||||||
aria-label={
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
item.resolved
|
event.preventDefault();
|
||||||
? "\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"
|
void openAnnotationContext(annotation);
|
||||||
: "\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} />
|
<div className="autoruns-comment-head">
|
||||||
</button>
|
<strong>{renderRatingDots(annotation.rating)}</strong>
|
||||||
|
<div className="autoruns-comment-head-actions">
|
||||||
|
<span>{formatDateTime(annotation.updated_at)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={annotation.resolved ? "autoruns-resolve-toggle resolved" : "autoruns-resolve-toggle"}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void toggleAnnotationResolved(annotation, !annotation.resolved);
|
||||||
|
}}
|
||||||
|
disabled={annotationResolutionBusyId === annotation.annotation_id}
|
||||||
|
title={
|
||||||
|
annotation.resolved
|
||||||
|
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
|
||||||
|
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
annotation.resolved
|
||||||
|
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
|
||||||
|
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CommentResolvedIcon resolved={annotation.resolved} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="autoruns-run-meta">{annotation.run_id}</div>
|
||||||
<div className="autoruns-run-meta">{item.run_id}</div>
|
|
||||||
<div className="autoruns-run-meta">
|
|
||||||
case={item.case_id} | msg={item.message_index}
|
|
||||||
</div>
|
|
||||||
<div className="autoruns-run-meta">
|
|
||||||
decision={item.manual_case_decision}
|
|
||||||
{item.annotation_author ? ` | author=${item.annotation_author}` : ""}
|
|
||||||
</div>
|
|
||||||
{item.resolved_at ? (
|
|
||||||
<div className="autoruns-run-meta">
|
<div className="autoruns-run-meta">
|
||||||
{"\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e"}: {formatDateTime(item.resolved_at)}
|
case={annotation.case_id} | msg={annotation.message_index}
|
||||||
{item.resolved_by ? ` | by=${item.resolved_by}` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="autoruns-run-meta">
|
||||||
{item.context.question_text ? <p>Q: {item.context.question_text}</p> : null}
|
decision={annotation.manual_case_decision}
|
||||||
{item.context.answer_text ? <p>A: {item.context.answer_text}</p> : null}
|
{annotation.annotation_author ? ` | author=${annotation.annotation_author}` : ""}
|
||||||
<p>{item.comment}</p>
|
</div>
|
||||||
</article>
|
{annotation.resolved_at ? (
|
||||||
))}
|
<div className="autoruns-run-meta">
|
||||||
|
{"\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e"}: {formatDateTime(annotation.resolved_at)}
|
||||||
|
{annotation.resolved_by ? ` | by=${annotation.resolved_by}` : ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{annotation.context.question_text ? <p>Q: {annotation.context.question_text}</p> : null}
|
||||||
|
{annotation.context.answer_text ? <p>A: {annotation.context.answer_text}</p> : null}
|
||||||
|
<p>{annotation.comment}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue