АДРЕСНЫЙ РЕЖИМ - авторан история - базовая версия

This commit is contained in:
dctouch 2026-04-09 12:34:10 +03:00
parent df29798fa2
commit edfa09c9af
31 changed files with 5261 additions and 2392 deletions

372
docs/ADDRESS/1010.txt Normal file
View File

@ -0,0 +1,372 @@
Да. И ключевая мысль тут такая: **эта система не заменяет ваш продуктовый контур**, а вешается **рядом** с ним как внешний тестовый раннер и слой контроля качества.
То есть не так: “перенесли ассистента в Promptfoo/DeepEval и теперь он там живёт”.
А так: **ваш ассистент остаётся у вас в коде**, со всеми маршрутами, настройками, тулзами и ограничениями. Eval-система просто **вызывает его**, подаёт тестовые сценарии и проверяет, что он ответил правильно. Promptfoo умеет ходить в произвольный HTTP endpoint, а также подключать кастомный Python provider; у него же есть поддержка system/user/assistant сообщений и multi-turn chat threads. ([Promptfoo][1])
Как это выглядит по-человечески.
## Что именно вы разворачиваете
Самый приземлённый вариант:
* ваш текущий backend ассистента;
* рядом Promptfoo **или** DeepEval;
* желательно ещё Langfuse **или** Phoenix для трассировок и накопления провалов.
Langfuse умеет хранить traces/sessions, собирать datasets, в том числе из production traces, и запускать experiments по этим датасетам. Phoenix тоже self-hosted, с tracing/evals/datasets/experiments и умеет гонять evals поверх уже собранных trace-данных. ([langfuse.com][2])
## Вы минуете интерфейс или нет
**Для основных eval-тестов — да, интерфейс лучше миновать.**
И это нормально.
Почему:
* интерфейс даёт лишний шум;
* вам важнее проверить не кнопку и не рендер, а **маршрут, параметры, tool use, числа, память диалога и финальный ответ**;
* если тест идёт прямо в backend-контур, вы проверяете именно логику ассистента.
Практически я бы делал так:
**Слой 1. Основной eval-контур — мимо UI.**
Promptfoo или DeepEval бьёт прямо в ваш backend endpoint, например `/assistant/chat` или специальный `/assistant/test-run`.
**Слой 2. Маленький smoke-набор через UI.**
510 сценариев, просто чтобы не развалился фронт, история сообщений, отображение ответа, кнопки и т.д.
То есть **95% качества ассистента** проверяется не через UI, а через его реальный backend contract.
## Что именно туда “подключается”
Есть 3 рабочих способа.
### Вариант A. Через HTTP endpoint
Самый простой.
У вас уже есть endpoint, который принимает:
* system context,
* историю диалога,
* user message,
* maybe org/company/period,
* maybe debug flags.
Тогда Promptfoo просто шлёт туда POST-запрос. Это его штатный режим. Для OpenAI-compatible или вообще любых HTTP endpoint он умеет собирать body, headers и доставать ответ из нужного поля JSON. ([Promptfoo][1])
### Вариант B. Через Python wrapper
Если ассистент лучше вызывать не по HTTP, а прямо функцией внутри кода, Promptfoo умеет кастомный Python provider, а DeepEval вообще изначально Python-first и ближе к pytest-стилю. ([Promptfoo][3])
### Вариант C. Через “test harness” endpoint
Часто это лучший вариант для сложных ассистентов.
Вы делаете отдельную ручку, условно:
`POST /internal/eval/run`
И она:
* принимает сообщение и историю,
* запускает **ровно тот же** роутер и те же тулзы, что и боевой контур,
* но дополнительно возвращает debug:
* какой маршрут выбрался,
* какие параметры извлеклись,
* какие тулзы вызвались,
* какие SQL/MCP/1C-запросы ушли,
* какие evidence вернулись,
* почему был выбран именно этот ответ.
Вот это уже идеальная почва для нормальных evals.
## Где хранится промпт
Тут важный момент.
Есть два режима:
### 1) Тестировать “изолированный промпт”
Это когда system prompt реально хранится в Promptfoo/DeepEval-конфиге. Такой режим полезен, если вы хотите сравнивать версии системного промпта отдельно от продукта. Promptfoo поддерживает prompt files, сообщения в chat-формате и эксперименты с разными prompt/provider комбинациями. Langfuse тоже умеет prompt management, versioning и experiments against datasets. ([Promptfoo][4])
### 2) Тестировать реальный продуктовый контур
Это когда системный промпт живёт у вас в коде, а eval-система просто вызывает ваш ассистент как чёрный ящик.
**Для вашего случая я бы начинал именно со второго.**
Потому что у вас не “просто промпт”, а целая продуктовая логика: маршруты, ограничения, диалог, бухгалтерские сценарии, tool calling.
Иначе вы получите ложную картину: “в Promptfoo всё хорошо”, а в реальном продукте всё поплыло.
## Что считать корректным ответом
Вот тут и находится главный узел.
У вас **не может быть одного типа проверки** для всех кейсов. Нужен гибрид.
### Тип 1. Жёсткая проверка
Для кейсов вроде:
* посчитать НДС,
* показать остатки,
* топ контрагентов,
* сколько заплатили подрядчикам,
* остатки по счёту 51,
* прогноз НДС на период.
Тут правильность — это:
* правильный маршрут;
* правильные фильтры;
* правильный период;
* правильная организация;
* правильные числа;
* правильная сортировка.
То есть тут нужен не “похоже на хороший ответ”, а почти unit/integration test.
Пример:
* expected route = `vat_forecast`
* expected entity = `ООО Ромашка`
* expected period = `2026-03`
* expected total = `1_245_330.17`
* допуск по числам = 0.01
### Тип 2. Структурная проверка
Например:
* ответ должен содержать таблицу по контрагентам;
* не должен выдумывать документы;
* должен явно указать период;
* если данных нет, должен честно сказать, что не хватает данных;
* должен дать evidence/source refs.
### Тип 3. Rubric / LLM-as-a-judge
Для свободного диалога.
Например:
* объяснил ли понятным языком;
* не ушёл ли за рамки бухгалтерского домена;
* не потерял ли ограничение;
* не начал ли советовать что-то юридически/налогово опасное без оговорок;
* корректно ли обработал follow-up.
Promptfoo для этого использует `llm-rubric`, а DeepEval — G-Eval и conversational G-Eval для whole-conversation оценки. Promptfoo отдельно предупреждает, что у model-graded assertions PASS/FAIL зависит от `pass` и `threshold`, и без порога можно получить “зелёный” тест даже при низком score. DeepEval даёт кастомные judge-метрики и отдельно умеет conversational G-Eval для оценки целого диалога, а не одного ответа. ([Promptfoo][5])
И вот это очень похоже на вашу проблему:
**“кодекс сказал зелёное, а руками тестишь — пиздец”**
часто означает одно из трёх:
1. критерии слишком общие;
2. judge-модель оценивает “по стилю”, а не по факту;
3. нет жёсткого threshold и нет проверки маршрута/чисел/tool use.
## Как я бы устроил это у вас пошагово
### Шаг 1. Не пытаться сразу “умную автопочинку”
Сначала нужен **контур истины**.
Минимальный набор:
* 100200 кейсов;
* разбивка по типам;
* единые поля expected behavior.
Пример структуры кейса:
```json
{
"id": "vat_001",
"history": [],
"input": "Посчитай НДС к уплате за март 2026 по ООО Альфа",
"expected": {
"route": "vat_summary",
"entities": {
"company": "ООО Альфа",
"period": "2026-03"
},
"must_call_tools": ["vat_turnover_query"],
"must_not_call_tools": ["counterparty_top_query"],
"answer_checks": {
"contains_period": true,
"must_include_total": true
},
"numeric_targets": {
"vat_due": 1245330.17,
"tolerance": 0.01
}
}
}
```
### Шаг 2. Подключить ассистента как black box
Лучше всего — через HTTP.
То есть eval-система не знает, как внутри устроены ваши маршруты.
Она просто шлёт запрос и получает:
* final answer,
* trace/debug json.
### Шаг 3. Разбить проверки на 4 уровня
Я бы делал именно так:
**A. Router correctness**
Правильно ли выбран маршрут.
**B. Tool / query correctness**
Те ли инструменты/запросы пошли.
**C. Factual correctness**
Правильные ли числа и факты.
**D. Dialogue quality**
Не потерялся ли контекст, не ушёл ли в болтовню, не выдумал ли лишнего.
### Шаг 4. Собирать реальные фейлы из жизни
Вот тут очень полезен Langfuse или Phoenix.
Идея такая:
* боевые диалоги пишутся в traces;
* хорошие/плохие помечаются;
* из них рождается dataset;
* dataset пополняется не вручную с нуля, а из реальных провалов.
Langfuse это прямо позиционирует как сценарий: datasets, experiments, traces, production feedback. Phoenix тоже умеет запускать evals на traces. ([langfuse.com][2])
### Шаг 5. Только после этого впрягать Codex/Aider/OpenHands
И тут принцип очень важный:
**Codex не должен быть судьёй.**
Он должен быть:
* генератором кейсов,
* генератором follow-up вопросов,
* генератором adversarial сценариев,
* фиксером кода/промпта/роутера,
* но не финальным источником истины.
Иначе он начинает сам себя хвалить.
Aider, например, умеет автоматически гонять линтеры и тесты после своих изменений и пытаться чинить найденные проблемы. OpenHands позиционируется как open-source coding-agent платформа/SDK с локальным запуском агентов и CLI. ([aider.chat][6])
## Как подключить “мощь кодекса”, чтобы он сам задавал вопросы
Вот это уже реально хорошая идея. Но делать это надо не “кодекс сам всё решит”, а в двух ролях:
### Роль 1. Генератор тестов
Codex берёт:
* ваши маршруты,
* текущие кейсы,
* реальные trace-провалы,
* описание домена,
и генерирует новые вопросы:
* пограничные;
* двусмысленные;
* с пропущенным периодом;
* с несколькими организациями;
* со сменой темы внутри диалога;
* с follow-up типа “а по прошлому месяцу?”;
* с конфликтующими параметрами.
То есть он расширяет ваш eval suite.
### Роль 2. Фиксер
После падения тестов Codex получает:
* failing cases,
* expected behavior,
* actual trace,
* diff последних изменений,
и правит:
* router,
* extraction,
* system prompt,
* guardrails,
* answer composer,
* test suite.
А потом снова гоняется eval.
То есть получается петля:
**реальные traces / ручные кейсы → eval suite → падения → codex fixer → повторный прогон → merge only if green**
## Что я бы рекомендовал конкретно вам
Для вашего кейса я бы не делал слишком жирную схему сразу.
### Стартовый стек
**Promptfoo + Langfuse + Codex/Aider**
Почему:
* Promptfoo быстро подключается к HTTP endpoint и удобен для регрессионных прогонов, rubric-assertions и multi-turn сценариев. ([Promptfoo][1])
* Langfuse хорошо подходит как накопитель traces/datasets/experiments. ([langfuse.com][2])
* Codex/Aider использовать не как judge, а как test generator + fixer. ([aider.chat][6])
### Когда брать DeepEval
Если захотите больше Python-first логики и метрик уровня:
* component tests,
* conversational metrics,
* custom LLM judge,
* synthetic dataset generation.
DeepEval прямо под это и заточен: unit-test style, end-to-end/component level, custom LLMs for evaluation, conversational G-Eval для многоходовых диалогов. ([DeepEval][7])
## Самый важный практический вывод
Вам не нужна “система, куда вставил промпт и она magically поняла, что хорошо”.
Вам нужен **контракт качества**:
* какой маршрут должен быть выбран;
* какие параметры должны быть извлечены;
* какие тулзы должны вызваться;
* какие числа должны получиться;
* как ассистент должен вести себя в follow-up;
* где он обязан честно сказать “не знаю / не хватает данных”.
Пока этого слоя нет, любой Codex, хоть очень умный, будет красить вам зелёное “по ощущению”.
А когда этот слой появится, уже можно сделать очень сильную штуку:
* Codex сам генерит новые кейсы,
* сам находит слабые места,
* сам правит код,
* но **истину проверяет не он**, а ваш eval harness.
Если хочешь, следующим сообщением я могу сразу дать **конкретную схему под ваш стек**:
`Qwen 2.5 локально + backend ассистента + Promptfoo + Langfuse + Codex`,
с прямым пошаговым сценарием “что ставить, какой endpoint сделать, какой JSON возвращать и как начать с первых 20 кейсов”.
[1]: https://www.promptfoo.dev/docs/providers/http/ "HTTP/HTTPS API | Promptfoo"
[2]: https://langfuse.com/docs/evaluation/experiments/datasets "Datasets - Langfuse"
[3]: https://www.promptfoo.dev/docs/providers/python/ "Python Provider | Promptfoo"
[4]: https://www.promptfoo.dev/docs/configuration/chat/ "Chat Conversations and Multi-Turn Threads | Promptfoo"
[5]: https://www.promptfoo.dev/docs/configuration/expected-outputs/model-graded/llm-rubric/ "LLM Rubric | Promptfoo"
[6]: https://aider.chat/docs/usage/lint-test.html?utm_source=chatgpt.com "Linting and testing"
[7]: https://deepeval.com/docs/getting-started "Quick Introduction | DeepEval by Confident AI - The LLM Evaluation Framework"

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@ -21,6 +21,17 @@ function toNumberFlag(value, defaultValue) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function toStringListFlag(value, defaultValue) {
const source = String(value ?? "").trim();
if (!source) {
return [...defaultValue];
}
const tokens = source
.split(/[,\s;]+/g)
.map((item) => item.trim())
.filter((item) => item.length > 0);
return tokens.length > 0 ? Array.from(new Set(tokens)) : [...defaultValue];
}
exports.PORT = Number(process.env.PORT ?? 8787);
exports.TIMEZONE = process.env.TZ_FALLBACK ?? "Europe/Moscow";
exports.DEFAULT_OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
@ -53,6 +64,8 @@ exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http:
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
exports.ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
exports.VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]);
exports.VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]);
exports.DATA_DIR = process.env.DATA_DIR ?? path_1.default.resolve(exports.MODULE_ROOT, "data");
exports.TRACES_DIR = path_1.default.resolve(exports.DATA_DIR, "traces");
exports.PRESETS_DIR = path_1.default.resolve(exports.DATA_DIR, "presets");

View File

@ -0,0 +1,690 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildAutoRunsRouter = buildAutoRunsRouter;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const express_1 = require("express");
const config_1 = require("../config");
const http_1 = require("../utils/http");
function toRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value;
}
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function toStringSafe(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toNumberSafe(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toBooleanSafe(value) {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const lowered = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(lowered))
return true;
if (["0", "false", "no", "off"].includes(lowered))
return false;
}
return null;
}
function parseDateMs(value) {
const asString = toStringSafe(value);
if (!asString) {
return null;
}
const ms = Date.parse(asString);
return Number.isFinite(ms) ? ms : null;
}
function clampInt(value, min, max, fallback) {
if (value === null || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.trunc(value);
if (rounded < min)
return min;
if (rounded > max)
return max;
return rounded;
}
function resolveRunTarget(input) {
const explicit = toStringSafe(input.report.eval_target);
if (explicit === "assistant_stage1" || explicit === "assistant_stage2" || explicit === "assistant_p0" || explicit === "normalizer") {
return explicit;
}
if (input.runId.startsWith("assistant-stage1-"))
return "assistant_stage1";
if (input.runId.startsWith("assistant-stage2-"))
return "assistant_stage2";
if (input.runId.startsWith("assistant-p0-"))
return "assistant_p0";
if (input.runId.startsWith("eval-"))
return "normalizer";
if (input.reportPath.endsWith(".report.json"))
return "normalizer";
return "unknown";
}
function normalizeTimestamp(report, fileMtimeMs) {
const first = parseDateMs(report.run_timestamp);
if (first !== null) {
return { iso: new Date(first).toISOString(), ms: first };
}
const second = parseDateMs(report.timestamp);
if (second !== null) {
return { iso: new Date(second).toISOString(), ms: second };
}
return { iso: new Date(fileMtimeMs).toISOString(), ms: fileMtimeMs };
}
function rateToPercent(value) {
if (value === null)
return null;
if (value <= 1.2)
return Math.max(0, Math.min(100, value * 100));
return Math.max(0, Math.min(100, value));
}
function scoreToPercent(value) {
if (value === null)
return null;
if (value <= 5.2)
return Math.max(0, Math.min(100, (value / 5) * 100));
return Math.max(0, Math.min(100, value));
}
function average(values) {
const filtered = values.filter((item) => typeof item === "number" && Number.isFinite(item));
if (filtered.length === 0) {
return null;
}
const sum = filtered.reduce((acc, item) => acc + item, 0);
return Number((sum / filtered.length).toFixed(2));
}
function getMetricRecord(report) {
const metrics = toRecord(report.metrics);
if (!metrics)
return null;
const raw = toRecord(metrics.raw);
return raw ?? metrics;
}
function computeScoreIndex(report, target) {
const metrics = getMetricRecord(report);
if (!metrics) {
return null;
}
if (target === "assistant_p0") {
return average([
rateToPercent(toNumberSafe(metrics.problem_first_answer_rate)),
scoreToPercent(toNumberSafe(metrics.mechanism_coherence_score)),
rateToPercent(1 - (toNumberSafe(metrics.entity_leakage_rate) ?? 1)),
scoreToPercent(toNumberSafe(metrics.accountant_actionability_score)),
rateToPercent(toNumberSafe(metrics.route_correctness_rate)),
rateToPercent(toNumberSafe(metrics.domain_purity_rate)),
rateToPercent(toNumberSafe(metrics.limitation_honesty_rate)),
rateToPercent(toNumberSafe(metrics.top_problem_unit_match_rate))
]);
}
if (target === "assistant_stage1") {
return average([
rateToPercent(toNumberSafe(metrics.retrieval_differentiation_rate)),
rateToPercent(1 - (toNumberSafe(metrics.generic_explanation_rate) ?? 1)),
scoreToPercent(toNumberSafe(metrics.accountant_actionability_score)),
rateToPercent(1 - (toNumberSafe(metrics.false_confidence_rate) ?? 1)),
rateToPercent(1 - (toNumberSafe(metrics.broad_answer_rate) ?? 1)),
scoreToPercent(toNumberSafe(metrics.mechanism_specificity_score)),
scoreToPercent(toNumberSafe(metrics.followup_context_retention_score))
]);
}
if (target === "assistant_stage2") {
return average([
rateToPercent(toNumberSafe(metrics.problem_unit_precision)),
rateToPercent(toNumberSafe(metrics.problem_unit_recall_proxy)),
rateToPercent(toNumberSafe(metrics.duplicate_collapse_rate)),
scoreToPercent(toNumberSafe(metrics.mechanism_coherence_score)),
scoreToPercent(toNumberSafe(metrics.problem_clarity_score)),
rateToPercent(toNumberSafe(metrics.problem_first_answer_rate)),
rateToPercent(1 - (toNumberSafe(metrics.entity_leakage_rate) ?? 1))
]);
}
return average([
rateToPercent(toNumberSafe(metrics.schema_validation_pass_rate)),
rateToPercent(toNumberSafe(metrics.route_resolution_accuracy) ?? toNumberSafe(metrics.route_hint_accuracy)),
rateToPercent(toNumberSafe(metrics.execution_state_consistency_rate) ?? toNumberSafe(metrics.intent_class_accuracy)),
rateToPercent(100 - (toNumberSafe(metrics.high_confidence_error_rate) ?? 0))
]);
}
function countFailures(report) {
const acceptanceGate = toRecord(report.acceptance_gate);
const baselineGate = toRecord(report.baseline_stability_gate);
const blocking = toArray(acceptanceGate?.blocking_failures).length + toArray(baselineGate?.blocking_regressions).length;
const quality = toArray(acceptanceGate?.quality_failures).length +
toArray(baselineGate?.legacy_quality_failures).length +
toArray(baselineGate?.quality_gap_failures).length;
return { blocking, quality };
}
function caseScoreFromMetricSubscores(metricSubscores) {
if (!metricSubscores)
return null;
const directProduct = scoreToPercent(toNumberSafe(metricSubscores.case_product_score));
if (directProduct !== null) {
return Number(directProduct.toFixed(2));
}
const candidates = [
scoreToPercent(toNumberSafe(metricSubscores.problem_clarity_score)),
scoreToPercent(toNumberSafe(metricSubscores.mechanism_coherence_score)),
rateToPercent(toNumberSafe(metricSubscores.problem_first_answer_rate)),
rateToPercent(1 - (toNumberSafe(metricSubscores.entity_leakage_rate) ?? 1)),
scoreToPercent(toNumberSafe(metricSubscores.accountant_usefulness_score))
];
return average(candidates);
}
function isCaseClosed(input) {
const checks = input.checks;
if (checks) {
const routeCorrect = toBooleanSafe(checks.route_correct);
const domainPure = toBooleanSafe(checks.domain_pure);
const problemFirst = toBooleanSafe(checks.problem_first_answer);
if (routeCorrect !== null || domainPure !== null || problemFirst !== null) {
if (routeCorrect === false)
return false;
if (domainPure === false)
return false;
if (problemFirst === false)
return false;
return true;
}
}
if (typeof input.scoreIndex === "number") {
return input.scoreIndex >= 65;
}
return null;
}
function getResultCases(report) {
return toArray(report.results)
.map((item) => toRecord(item))
.filter((item) => item !== null);
}
function buildCaseSummaries(report, runId, checkDialogAvailability) {
const results = getResultCases(report);
return results.map((item, index) => {
const caseId = toStringSafe(item.case_id) ?? `case-${index + 1}`;
const checks = toRecord(item.checks);
const metricSubscores = toRecord(item.metric_subscores);
const scoreIndex = caseScoreFromMetricSubscores(metricSubscores) ??
scoreToPercent(toNumberSafe(item.accountant_usefulness_score)) ??
null;
const closedState = isCaseClosed({ checks, scoreIndex });
const sessionId = `${runId}-${caseId}`;
const dialogAvailable = checkDialogAvailability
? fs_1.default.existsSync(path_1.default.resolve(config_1.ASSISTANT_SESSIONS_DIR, `${sessionId}.json`))
: false;
return {
case_id: caseId,
domain: toStringSafe(item.domain),
query_class: toStringSafe(item.query_class),
status: closedState === null ? "unknown" : closedState ? "closed" : "open",
score_index: scoreIndex === null ? null : Number(scoreIndex.toFixed(2)),
trace_id: toStringSafe(item.trace_id),
reply_type: toStringSafe(item.reply_type),
session_id: sessionId,
dialog_available: dialogAvailable,
checks,
metric_subscores: metricSubscores
};
});
}
function buildCoverageFromCases(cases) {
const coverageByDomain = new Map();
let closedCases = 0;
let openCases = 0;
for (const item of cases) {
if (item.status === "closed")
closedCases += 1;
if (item.status === "open")
openCases += 1;
const domainKey = item.domain ?? "unknown";
const current = coverageByDomain.get(domainKey) ?? { total: 0, closed: 0 };
current.total += 1;
if (item.status === "closed")
current.closed += 1;
coverageByDomain.set(domainKey, current);
}
const domainCoverage = Array.from(coverageByDomain.entries())
.map(([domain, value]) => ({
domain,
total_cases: value.total,
closed_cases: value.closed
}))
.sort((a, b) => b.total_cases - a.total_cases);
return {
closed_cases: closedCases,
open_cases: openCases,
domain_coverage: domainCoverage
};
}
function collectJsonCandidates(scanLimit) {
const candidates = [];
const sources = [
{ dir: config_1.REPORTS_DIR, suffix: ".json" },
{ dir: config_1.EVAL_CASES_DIR, suffix: ".report.json" }
];
for (const source of sources) {
if (!fs_1.default.existsSync(source.dir))
continue;
const entries = fs_1.default.readdirSync(source.dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile())
continue;
if (!entry.name.endsWith(source.suffix))
continue;
const fullPath = path_1.default.resolve(source.dir, entry.name);
try {
const stat = fs_1.default.statSync(fullPath);
candidates.push({ path: fullPath, mtimeMs: stat.mtimeMs });
}
catch {
// skip broken file stat
}
}
}
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, scanLimit);
}
function indexRuns(scanLimit) {
const files = collectJsonCandidates(scanLimit);
const dedup = new Map();
for (const item of files) {
let parsed;
try {
const raw = fs_1.default.readFileSync(item.path, "utf-8");
parsed = JSON.parse(raw);
}
catch {
continue;
}
const report = toRecord(parsed);
if (!report)
continue;
const runId = toStringSafe(report.run_id);
if (!runId)
continue;
const evalTarget = resolveRunTarget({ report, runId, reportPath: item.path });
const normalizedTime = normalizeTimestamp(report, item.mtimeMs);
const indexed = {
run_id: runId,
eval_target: evalTarget,
report_path: item.path,
report,
timestamp_iso: normalizedTime.iso,
timestamp_ms: normalizedTime.ms
};
const current = dedup.get(runId);
if (!current || indexed.timestamp_ms > current.timestamp_ms) {
dedup.set(runId, indexed);
}
}
return Array.from(dedup.values()).sort((a, b) => b.timestamp_ms - a.timestamp_ms);
}
function parseFilters(query) {
const fromMs = parseDateMs(query.from);
const toMs = parseDateMs(query.to);
const targetRaw = toStringSafe(query.target)?.toLowerCase() ?? "all";
const target = targetRaw === "normalizer" || targetRaw === "assistant_stage1" || targetRaw === "assistant_stage2" || targetRaw === "assistant_p0"
? targetRaw
: "all";
const useMock = toStringSafe(query.use_mock);
const useMockFilter = useMock === null || useMock.toLowerCase() === "any" ? null : toBooleanSafe(useMock);
const mode = toStringSafe(query.mode)?.toLowerCase() ?? "all";
const promptContains = (toStringSafe(query.prompt_contains) ?? "").toLowerCase();
const limit = clampInt(toNumberSafe(query.limit), 1, 500, 120);
const scanLimit = clampInt(toNumberSafe(query.scan_limit), 50, 5000, 900);
return {
from_ms: fromMs,
to_ms: toMs,
target,
use_mock: useMockFilter,
prompt_contains: promptContains,
mode,
limit,
scan_limit: scanLimit
};
}
function matchesFilters(run, filters) {
if (filters.from_ms !== null && run.timestamp_ms < filters.from_ms)
return false;
if (filters.to_ms !== null && run.timestamp_ms > filters.to_ms)
return false;
if (filters.target !== "all" && run.eval_target !== filters.target)
return false;
const modeValue = (toStringSafe(run.report.mode) ?? "").toLowerCase();
if (filters.mode !== "all" && modeValue !== filters.mode)
return false;
if (filters.use_mock !== null) {
const useMockValue = toBooleanSafe(run.report.use_mock);
if (useMockValue !== filters.use_mock)
return false;
}
if (filters.prompt_contains.length > 0) {
const promptVersion = (toStringSafe(run.report.prompt_version) ?? "").toLowerCase();
if (!promptVersion.includes(filters.prompt_contains))
return false;
}
return true;
}
function buildRunSummary(run) {
const connection = toRecord(run.report.connection);
const normalizeConfig = toRecord(run.report.normalize_config) ?? toRecord(run.report.normalizeConfig);
const llmProvider = toStringSafe(run.report.llm_provider) ??
toStringSafe(run.report.llmProvider) ??
toStringSafe(connection?.llm_provider) ??
toStringSafe(connection?.llmProvider) ??
toStringSafe(normalizeConfig?.llm_provider) ??
toStringSafe(normalizeConfig?.llmProvider);
const model = toStringSafe(run.report.model) ??
toStringSafe(connection?.model) ??
toStringSafe(normalizeConfig?.model);
const cases = buildCaseSummaries(run.report, run.run_id, false);
const coverage = buildCoverageFromCases(cases);
const failures = countFailures(run.report);
return {
run_id: run.run_id,
eval_target: run.eval_target,
run_timestamp: run.timestamp_iso,
mode: toStringSafe(run.report.mode),
llm_provider: llmProvider,
model,
use_mock: toBooleanSafe(run.report.use_mock),
prompt_version: toStringSafe(run.report.prompt_version),
schema_version: toStringSafe(run.report.schema_version),
suite_id: toStringSafe(run.report.suite_id),
cases_total: toNumberSafe(run.report.cases_total) ?? cases.length,
requests_total: toNumberSafe(toRecord(run.report.budget)?.requests_total),
report_path: run.report_path,
score_index: computeScoreIndex(run.report, run.eval_target),
blocking_failures: failures.blocking,
quality_failures: failures.quality,
closed_cases: coverage.closed_cases,
open_cases: coverage.open_cases,
domain_coverage: coverage.domain_coverage
};
}
function mergeDomainCoverage(summaries) {
const merged = new Map();
for (const summary of summaries) {
for (const item of summary.domain_coverage) {
const current = merged.get(item.domain) ?? { total: 0, closed: 0 };
current.total += item.total_cases;
current.closed += item.closed_cases;
merged.set(item.domain, current);
}
}
return Array.from(merged.entries())
.map(([domain, value]) => ({
domain,
total_cases: value.total,
closed_cases: value.closed
}))
.sort((a, b) => b.total_cases - a.total_cases);
}
function buildHistoryStats(summaries) {
const byTarget = {};
let blockingRuns = 0;
let qualityRuns = 0;
const scoreValues = [];
for (const item of summaries) {
byTarget[item.eval_target] = (byTarget[item.eval_target] ?? 0) + 1;
if (item.blocking_failures > 0)
blockingRuns += 1;
if (item.quality_failures > 0)
qualityRuns += 1;
if (typeof item.score_index === "number")
scoreValues.push(item.score_index);
}
const latestScore = typeof summaries[0]?.score_index === "number" ? summaries[0].score_index : null;
const previousScore = typeof summaries[1]?.score_index === "number" ? summaries[1].score_index : null;
const trend = latestScore === null || previousScore === null
? "flat"
: latestScore > previousScore + 0.5
? "up"
: latestScore < previousScore - 0.5
? "down"
: "flat";
return {
runs_total: summaries.length,
by_target: byTarget,
blocking_runs: blockingRuns,
quality_gap_runs: qualityRuns,
avg_score_index: scoreValues.length > 0 ? Number((scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length).toFixed(2)) : null,
latest_score_index: latestScore,
previous_score_index: previousScore,
trend,
domain_coverage: mergeDomainCoverage(summaries)
};
}
function findRunById(runId, scanLimit = 3000) {
const indexed = indexRuns(scanLimit);
return indexed.find((item) => item.run_id === runId) ?? null;
}
function buildAssistantModeSummary(dialogRecord) {
if (!dialogRecord)
return null;
const conversation = toArray(dialogRecord.conversation)
.map((item) => toRecord(item))
.filter((item) => item !== null);
const lastAssistant = [...conversation]
.reverse()
.find((item) => toStringSafe(item.role) === "assistant");
const debug = toRecord(lastAssistant?.debug);
return {
reply_type: toStringSafe(lastAssistant?.reply_type),
trace_id: toStringSafe(lastAssistant?.trace_id),
detected_mode: toStringSafe(debug?.detected_mode),
execution_lane: toStringSafe(debug?.execution_lane),
tool_gate_decision: toStringSafe(debug?.tool_gate_decision),
living_router_mode: toStringSafe(debug?.living_router_mode),
fallback_type: toStringSafe(debug?.fallback_type)
};
}
function loadSessionDialog(runId, caseId) {
const sessionId = `${runId}-${caseId}`;
const filePath = path_1.default.resolve(config_1.ASSISTANT_SESSIONS_DIR, `${sessionId}.json`);
if (!fs_1.default.existsSync(filePath)) {
return null;
}
let parsed;
try {
parsed = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8"));
}
catch {
return null;
}
const record = toRecord(parsed);
if (!record)
return null;
const conversation = toArray(record.conversation)
.map((item) => toRecord(item))
.filter((item) => item !== null);
const messages = conversation.map((item) => ({
role: toStringSafe(item.role) ?? "unknown",
text: toStringSafe(item.text) ?? "",
created_at: toStringSafe(item.created_at),
trace_id: toStringSafe(item.trace_id),
reply_type: toStringSafe(item.reply_type)
}));
const turns = toArray(record.turns)
.map((item) => toRecord(item))
.filter((item) => item !== null);
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
const humanReadable = toRecord(lastTurn?.human_readable);
const decomposition = toArray(humanReadable?.decomposition)
.map((item) => toStringSafe(item))
.filter((item) => item !== null);
return {
source: "assistant_session",
session_id: sessionId,
messages,
decomposition,
assistant_mode: buildAssistantModeSummary(record)
};
}
function buildFallbackDialog(run, caseId) {
const sessionId = `${run.run_id}-${caseId}`;
const results = getResultCases(run.report);
const targetCase = results.find((item) => (toStringSafe(item.case_id) ?? "") === caseId) ?? null;
if (!targetCase) {
return {
source: "none",
session_id: sessionId,
messages: [],
decomposition: [],
assistant_mode: null
};
}
const userText = toStringSafe(targetCase.raw_question) ??
toStringSafe(targetCase.user_query_raw) ??
`Case ${caseId}`;
const assistantSummaryParts = [];
const validationPassed = toBooleanSafe(targetCase.validation_passed);
if (validationPassed !== null)
assistantSummaryParts.push(`validation_passed=${validationPassed}`);
const routeMatch = toBooleanSafe(targetCase.route_match);
if (routeMatch !== null)
assistantSummaryParts.push(`route_match=${routeMatch}`);
const intentMatch = toBooleanSafe(targetCase.intent_match);
if (intentMatch !== null)
assistantSummaryParts.push(`intent_match=${intentMatch}`);
const confidence = toStringSafe(targetCase.confidence_overall);
if (confidence)
assistantSummaryParts.push(`confidence=${confidence}`);
const metricSubscores = toRecord(targetCase.metric_subscores);
if (metricSubscores) {
for (const [key, value] of Object.entries(metricSubscores)) {
if (toNumberSafe(value) !== null) {
assistantSummaryParts.push(`${key}=${value}`);
}
}
}
if (assistantSummaryParts.length === 0) {
assistantSummaryParts.push("No structured assistant dialog is available for this case in report artifacts.");
}
return {
source: "report_fallback",
session_id: sessionId,
messages: [
{
role: "user",
text: userText,
created_at: null,
trace_id: null,
reply_type: null
},
{
role: "assistant",
text: assistantSummaryParts.join("\n"),
created_at: null,
trace_id: toStringSafe(targetCase.trace_id),
reply_type: toStringSafe(targetCase.reply_type)
}
],
decomposition: [],
assistant_mode: null
};
}
function buildAutoRunsRouter() {
const router = (0, express_1.Router)();
router.get("/api/autoruns/history", (req, res) => {
const filters = parseFilters(req.query);
const indexed = indexRuns(filters.scan_limit);
const filtered = indexed.filter((run) => matchesFilters(run, filters)).slice(0, filters.limit);
const summaries = filtered.map((run) => buildRunSummary(run));
const availableTargets = Array.from(new Set(indexed.map((item) => item.eval_target))).sort();
const availableModes = Array.from(new Set(indexed.map((item) => toStringSafe(item.report.mode)).filter((item) => item !== null))).sort();
const availablePromptVersions = Array.from(new Set(indexed.map((item) => toStringSafe(item.report.prompt_version)).filter((item) => item !== null))).sort();
(0, http_1.ok)(res, {
ok: true,
generated_at: new Date().toISOString(),
filters_applied: {
from: filters.from_ms === null ? null : new Date(filters.from_ms).toISOString(),
to: filters.to_ms === null ? null : new Date(filters.to_ms).toISOString(),
target: filters.target,
use_mock: filters.use_mock,
prompt_contains: filters.prompt_contains,
mode: filters.mode,
limit: filters.limit,
scan_limit: filters.scan_limit
},
available: {
targets: availableTargets,
modes: availableModes,
prompt_versions: availablePromptVersions
},
items: summaries,
stats: buildHistoryStats(summaries)
});
});
router.get("/api/autoruns/history/:run_id", (req, res, next) => {
try {
const runId = String(req.params.run_id ?? "").trim();
if (!runId) {
throw new http_1.ApiError("INVALID_RUN_ID", "run_id is required", 400);
}
const run = findRunById(runId);
if (!run) {
throw new http_1.ApiError("AUTORUN_NOT_FOUND", `Run not found: ${runId}`, 404);
}
const cases = buildCaseSummaries(run.report, run.run_id, true);
const coverage = buildCoverageFromCases(cases);
(0, http_1.ok)(res, {
ok: true,
run: buildRunSummary(run),
coverage,
cases,
report: run.report
});
}
catch (error) {
next(error);
}
});
router.get("/api/autoruns/history/:run_id/case/:case_id/dialog", (req, res, next) => {
try {
const runId = String(req.params.run_id ?? "").trim();
const caseId = String(req.params.case_id ?? "").trim();
if (!runId || !caseId) {
throw new http_1.ApiError("INVALID_DIALOG_REQUEST", "run_id and case_id are required", 400);
}
const run = findRunById(runId);
if (!run) {
throw new http_1.ApiError("AUTORUN_NOT_FOUND", `Run not found: ${runId}`, 404);
}
const sessionDialog = loadSessionDialog(runId, caseId);
const dialog = sessionDialog ?? buildFallbackDialog(run, caseId);
(0, http_1.ok)(res, {
ok: true,
run_id: runId,
case_id: caseId,
...dialog
});
}
catch (error) {
next(error);
}
});
return router;
}

View File

@ -10,6 +10,7 @@ const express_1 = __importDefault(require("express"));
const config_1 = require("./config");
const accountingAgent_1 = require("./routes/accountingAgent");
const assistant_1 = require("./routes/assistant");
const autoRuns_1 = require("./routes/autoRuns");
const eval_1 = require("./routes/eval");
const history_1 = require("./routes/history");
const normalize_1 = require("./routes/normalize");
@ -58,6 +59,7 @@ function createApp() {
app.use((0, normalize_1.buildNormalizeRouter)(services));
app.use((0, eval_1.buildEvalRouter)(services));
app.use((0, assistant_1.buildAssistantRouter)(services));
app.use((0, autoRuns_1.buildAutoRunsRouter)());
app.use((0, history_1.buildHistoryRouter)());
app.use((0, presets_1.buildPresetsRouter)());
app.use((0, accountingAgent_1.buildAccountingAgentRouter)(services));

View File

@ -11,6 +11,7 @@ const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контраге
const CONTRACT_PATTERN = /(?:по\s+(?:договору|контракту)|(?:договор|контракт)(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const DATE_DMY_MONTH_NAME_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2}|\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const PERIOD_RANGE_PATTERN_2 = /(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const YEAR_RANGE_PATTERN = /(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
@ -100,6 +101,36 @@ function extractAsOfDate(text) {
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
return toIsoDate(year, month, day) ?? undefined;
}
const dmyByMonthName = text.match(DATE_DMY_MONTH_NAME_PATTERN);
if (dmyByMonthName) {
const day = Number(dmyByMonthName[1]);
const month = resolveMonthByName(String(dmyByMonthName[2] ?? ""));
const yearRaw = Number(dmyByMonthName[3]);
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
if (month) {
return toIsoDate(year, month, day) ?? undefined;
}
}
return undefined;
}
function extractAsOfDateWithCue(text) {
const source = String(text ?? "");
if (!source) {
return undefined;
}
const numericCue = source.match(/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})(?=$|[\s,.;:!?()\-])/iu);
if (numericCue) {
return parseDateToken(String(numericCue[1] ?? ""));
}
const monthNameCue = source.match(/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu);
if (monthNameCue) {
const day = Number(monthNameCue[1]);
const month = resolveMonthByName(String(monthNameCue[2] ?? ""));
const year = Number(monthNameCue[3]);
if (month && Number.isFinite(year) && Number.isFinite(day)) {
return toIsoDate(year, month, day) ?? undefined;
}
}
return undefined;
}
function parseDateToken(token) {
@ -155,6 +186,25 @@ function resolveMonthByName(rawMonthName) {
return 12;
return undefined;
}
function deriveQuarterWindowForDate(asOfIso) {
const token = String(asOfIso ?? "").trim();
const match = token.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
return null;
}
const quarterStartMonth = Math.floor((month - 1) / 3) * 3 + 1;
const quarterEndMonth = quarterStartMonth + 2;
const quarterEndDay = new Date(Date.UTC(year, quarterEndMonth, 0)).getUTCDate();
return {
period_from: `${year}-${String(quarterStartMonth).padStart(2, "0")}-01`,
period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}`
};
}
function extractMonthPeriod(text) {
const numericMonthYearMatch = text.match(MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN);
if (numericMonthYearMatch) {
@ -532,6 +582,19 @@ function isLikelyCounterpartyToken(rawToken) {
"меньше",
"платит",
"платят",
"прогноз",
"forecast",
"план",
"плана",
"ндс",
"vat",
"налог",
"оплата",
"оплаты",
"платеж",
"платёж",
"платежа",
"платежи",
"денег",
"деньги",
"объем",
@ -898,7 +961,8 @@ function extractAddressFilters(userMessage, intent) {
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value";
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast";
const filters = {
sort: "period_desc"
};
@ -906,6 +970,8 @@ function extractAddressFilters(userMessage, intent) {
filters.limit = 20;
}
const warnings = [];
const explicitAsOfDate = extractAsOfDate(text);
const explicitAsOfDateWithCue = extractAsOfDateWithCue(text);
const accountMatch = text.match(ACCOUNT_PATTERN);
if (accountMatch) {
filters.account = String(accountMatch[1]).replace(",", ".");
@ -1011,11 +1077,24 @@ function extractAddressFilters(userMessage, intent) {
warnings.push("period_derived_from_year_phrase");
}
}
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
if (quarterWindow) {
filters.period_from = quarterWindow.period_from;
warnings.push("period_from_derived_from_quarter_for_vat_forecast");
filters.period_to = vatAsOfDate;
warnings.push("period_to_derived_from_as_of_date_for_vat_forecast");
if (filters.period_from && filters.period_to && filters.period_from > filters.period_to) {
filters.period_from = quarterWindow.period_from;
warnings.push("period_from_adjusted_for_vat_as_of_window");
}
}
}
if (isManagementProfileIntent && !filters.period_to && !filters.as_of_date) {
filters.period_to = new Date().toISOString().slice(0, 10);
warnings.push("period_to_defaulted_today_for_management_profile");
}
const explicitAsOfDate = extractAsOfDate(text);
if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) {
filters.as_of_date = explicitAsOfDate;
const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") ||

View File

@ -442,8 +442,25 @@ function hasFuzzyLexeme(text, lexemeRoots) {
return false;
}
function hasCompactAccountCodeToken(text) {
// Match compact account tokens like 60.01 / 62, while avoiding date fragments.
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})?(?![\d-])/u.test(text);
// Match compact account tokens while reducing false positives on short-year literals like "22 год".
const source = String(text ?? "");
if (!source) {
return false;
}
// Safe compact form: 60.01 / 62.1
if (/(?<![\d-])\d{2}[.,]\d{1,2}(?![\d-])/u.test(source)) {
return true;
}
// Plain two-digit code is accepted only in explicit account context.
if (/(?:сч[её]т|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) {
return true;
}
if (/(?:^|\s)по\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) {
if (!/(?:^|\s)(?:за|в)\s+\d{2}\s*(?:г(?:од|ода)?|year)\b/iu.test(source)) {
return true;
}
}
return false;
}
function hasDocumentsFormingBalanceSignal(text) {
if (hasAny(text, DOCUMENTS_FORMING_BALANCE_HINTS)) {
@ -496,6 +513,13 @@ function hasAccountBalanceSignal(text) {
const hasFollowupBalanceVerb = /(?:вернись|вернуться|вернуть|back|return)/iu.test(text);
return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb;
}
function hasForecastTaxSignal(text) {
const hasForecastLexeme = /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text);
const hasVatPayableEstimatePattern = /(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test(text);
return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern);
}
function hasPeriodCoverageProfileSignal(text) {
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
return true;
@ -652,7 +676,9 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(text);
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text);
const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test(text);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text);
const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue;
if (asksCountOnly) {
@ -670,6 +696,9 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}
if (asksCounterpartySource && asksValue) {
return true;
}
@ -1065,6 +1094,13 @@ function hasAccountNumberAnchor(text) {
}
function resolveAddressIntent(userMessage) {
const text = String(userMessage ?? "").trim().toLowerCase();
if (hasForecastTaxSignal(text)) {
return {
intent: "vat_payable_forecast",
confidence: "high",
reasons: ["forecast_tax_signal_detected"]
};
}
if (hasAny(text, RECEIVABLES_STRONG)) {
return {
intent: "list_receivables_counterparties",

View File

@ -427,7 +427,12 @@ function collectAnalyticsStrings(row) {
"Counterparty",
"Контрагент",
"Contract",
"Договор"
"Договор",
"Organization",
"Организация",
"ОрганизацияПредставление",
"organization",
"organization_name"
];
const collected = [];
for (const key of fixedKeys) {
@ -438,7 +443,12 @@ function collectAnalyticsStrings(row) {
}
for (const [key, rawValue] of Object.entries(row)) {
const lowerKey = key.toLowerCase();
if (lowerKey.includes("subconto") || lowerKey.includes("субконто") || lowerKey.includes("контраг") || lowerKey.includes("договор")) {
if (lowerKey.includes("subconto") ||
lowerKey.includes("субконто") ||
lowerKey.includes("контраг") ||
lowerKey.includes("договор") ||
lowerKey.includes("organization") ||
lowerKey.includes("организац")) {
const value = valueAsString(rawValue).trim();
if (value) {
collected.push(value);
@ -533,6 +543,14 @@ function applyAddressFilters(rows, filters) {
mismatchReason = "contract_anchor_not_matched_in_materialized_rows";
}
}
if (filters.organization && String(filters.organization).trim()) {
const needle = String(filters.organization);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
}
}
if (filters.document_ref && String(filters.document_ref).trim()) {
const needle = String(filters.document_ref);
const before = filtered.length;
@ -825,6 +843,11 @@ class AddressQueryService {
return null;
}
const { mode, shape, intent, filters, baseReasons } = decompose;
const composeOptionsFromFilters = (filterSet) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined
});
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, filters.extracted_filters);
if (intent.intent === "unknown") {
@ -1043,7 +1066,7 @@ class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, { userMessage });
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
const recoveryReason = recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
: "contract_docs_recovered_via_anchor_rows";
@ -1150,7 +1173,7 @@ class AddressQueryService {
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
const expandedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, expandedFilteredRows, { userMessage });
const expandedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, expandedFilteredRows, composeOptionsFromFilters(expandedLimitFilters));
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
@ -1252,7 +1275,7 @@ class AddressQueryService {
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows, { userMessage });
const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows, composeOptionsFromFilters(autoBroadenedFilters));
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
@ -1357,7 +1380,7 @@ class AddressQueryService {
rowsAnchorMatched: historicalRowsByAnchor.length,
rowsMatched: historicalFilteredRows.length
});
const historicalFactual = (0, composeStage_1.composeFactualReply)(intent.intent, historicalFilteredRows, { userMessage });
const historicalFactual = (0, composeStage_1.composeFactualReply)(intent.intent, historicalFilteredRows, composeOptionsFromFilters(historicalFilters));
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
const historicalSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
@ -1421,7 +1444,7 @@ class AddressQueryService {
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, { userMessage });
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, composeOptionsFromFilters(filters.extracted_filters));
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
@ -1583,7 +1606,7 @@ class AddressQueryService {
reasons: baseReasons
});
}
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, { userMessage });
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
return {
handled: true,
reply_text: factual.text,

View File

@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.selectAddressRecipe = selectAddressRecipe;
exports.buildAddressRecipePlan = buildAddressRecipePlan;
const config_1 = require("../config");
const MOVEMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Движения.Период КАК Период,
@ -333,6 +334,65 @@ const CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE = `
ИЗ
Справочник.ДоговорыКонтрагентов КАК Договоры
`;
const VAT_PAYABLE_FORECAST_QUERY_TEMPLATE = `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_68_CREDIT" КАК Регистратор,
"68" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT68_KT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_68_DEBIT" КАК Регистратор,
"68" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT68_DT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_19_DEBIT" КАК Регистратор,
"19" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT19_DT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_19_CREDIT" КАК Регистратор,
"19" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT19_KT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Регистратор
`;
const BASE_RECIPES = [
{
recipe_id: "address_period_coverage_profile_v1",
@ -414,6 +474,16 @@ const BASE_RECIPES = [
account_scope_mode: "preferred",
query_template: "contract_value_profile"
},
{
recipe_id: "address_vat_payable_forecast_v1",
intent: "vat_payable_forecast",
purpose: "Estimate VAT payable from factual turnovers on accounts 68 and 19 for selected period",
required_filters: [],
optional_filters: ["period_from", "period_to", "as_of_date", "organization"],
default_limit: 32,
account_scope_mode: "preferred",
query_template: "vat_payable_forecast_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@ -632,6 +702,50 @@ function buildMovementAccountCondition(filters) {
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function normalizeAccountPrefixForQuery(value) {
const normalized = String(value ?? "")
.trim()
.replace(",", ".")
.replace(/[^0-9.]+/g, "");
if (!normalized) {
return null;
}
if (!/^\d{2}(?:\.\d{1,3})*$/.test(normalized)) {
return null;
}
return normalized;
}
function accountPrefixVariants(prefix) {
const value = normalizeAccountPrefixForQuery(prefix);
if (!value) {
return [];
}
const variants = new Set([value]);
const segments = value.split(".");
if (segments.length <= 1) {
return Array.from(variants);
}
const base = segments[0];
const normalizedTail = segments.slice(1).map((segment) => {
const trimmed = segment.replace(/^0+(?=\d)/, "");
return trimmed.length > 0 ? trimmed : "0";
});
const compact = [base, ...normalizedTail].join(".");
if (compact !== value) {
variants.add(compact);
}
return Array.from(variants);
}
function buildAccountPrefixPredicate(fieldPath, prefixes) {
const normalizedPrefixes = Array.from(new Set((prefixes ?? [])
.flatMap((item) => accountPrefixVariants(item))
.filter((item) => Boolean(item))));
if (normalizedPrefixes.length === 0) {
return "ЛОЖЬ";
}
const clauses = normalizedPrefixes.map((prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`);
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function shouldBoostLimitForAllTimeCounterparty(filters) {
const hasAnchor = (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
(typeof filters.contract === "string" && filters.contract.trim().length > 0);
@ -652,6 +766,7 @@ function maxLimitForIntent(intent) {
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@ -690,7 +805,8 @@ function buildAddressRecipePlan(recipe, filters) {
const isManagementAggregateRecipe = recipe.query_template === "period_profile" ||
recipe.query_template === "document_section_profile" ||
recipe.query_template === "counterparty_roles_profile" ||
recipe.query_template === "contract_usage_profile";
recipe.query_template === "contract_usage_profile" ||
recipe.query_template === "vat_payable_forecast_profile";
const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
: recipe.default_limit;
@ -750,6 +866,13 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__WHERE_IN_VALUE__", buildContractValueWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.ДоговорКонтрагента"))
.replaceAll("__WHERE_OUT_VALUE__", buildContractValueWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента"))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "vat_payable_forecast_profile"
? VAT_PAYABLE_FORECAST_QUERY_TEMPLATE
.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
.replaceAll("__VAT68_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_19_PREFIXES))
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: MOVEMENTS_QUERY_TEMPLATE

View File

@ -75,6 +75,69 @@ function formatPercent(value, total) {
}
return `${((value / total) * 100).toFixed(1)}%`;
}
function formatMoney(value) {
if (!Number.isFinite(value)) {
return "0.00";
}
return value.toFixed(2);
}
function parseIsoDateToken(value) {
const source = String(value ?? "").trim();
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return { year, month, day };
}
function toIsoDate(year, month, day) {
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function formatDateRu(isoDate) {
const parsed = parseIsoDateToken(isoDate);
if (!parsed) {
return isoDate;
}
return `${String(parsed.day).padStart(2, "0")}.${String(parsed.month).padStart(2, "0")}.${String(parsed.year).padStart(4, "0")}`;
}
function buildIsoDateWithMonthShift(year, monthOneBased, day, monthShift = 0) {
const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
return date.toISOString().slice(0, 10);
}
function deriveVatDeadlineCalendar(periodFrom, periodTo) {
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
if (!reference) {
return null;
}
const quarterIndex = Math.floor((reference.month - 1) / 3);
const quarterNumber = quarterIndex + 1;
const quarterStartMonth = quarterIndex * 3 + 1;
const quarterEndMonth = quarterStartMonth + 2;
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
return {
periodLabel: `${quarterNumber} кв. ${reference.year}`,
quarterStart,
quarterEnd,
declarationDueDate,
paymentDueDates: [payment1, payment2, payment3],
windowFrom: periodFrom ?? null,
windowTo: periodTo ?? null
};
}
function extractAccountSectionCode(value) {
const source = String(value ?? "").trim();
if (!source) {
@ -93,6 +156,17 @@ function normalizeQuestionText(value) {
.replace(/\s+/g, " ")
.trim();
}
function needsVatWhyExplanation(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
const asksReason = /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text);
if (!asksReason) {
return false;
}
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
}
function detectRankingLimit(userMessage, fallback = 20) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@ -1004,6 +1078,70 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n")
};
}
if (intent === "vat_payable_forecast") {
const rowsByMarker = new Map();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
const nextValue = (rowsByMarker.get(marker) ?? 0) + (row.amount ?? 0);
rowsByMarker.set(marker, nextValue);
}
const turnover68Credit = rowsByMarker.get("VAT_68_CREDIT") ?? 0;
const turnover68Debit = rowsByMarker.get("VAT_68_DEBIT") ?? 0;
const turnover19Debit = rowsByMarker.get("VAT_19_DEBIT") ?? 0;
const turnover19Credit = rowsByMarker.get("VAT_19_CREDIT") ?? 0;
const netVat = turnover68Credit - turnover68Debit;
const vatToPay = Math.max(0, netVat);
const carryoverOrOverpayment = Math.max(0, -netVat);
const totalVatTurnoverAbs = Math.abs(turnover68Credit) + Math.abs(turnover68Debit) + Math.abs(turnover19Debit) + Math.abs(turnover19Credit);
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const lines = [
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
`Строк агрегата: ${rows.length}.`,
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
];
if (!vatActivityDetected) {
lines.push("В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00.");
}
else if (vatToPay === 0 && netVatIsEffectivelyZero) {
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
}
else if (vatToPay === 0 && netVat < 0) {
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
}
if (vatToPay === 0) {
lines.push("Чеклист проверки в 1С (почему к уплате 0):", `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов.");
}
if (vatCalendar) {
const periodWindowLabel = vatCalendar.windowFrom && vatCalendar.windowTo
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
const [payment1, payment2, payment3] = vatCalendar.paymentDueDates;
const installmentRaw = vatToPay / 3;
const installmentRounded = Number(installmentRaw.toFixed(2));
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
lines.push(`Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, `Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.");
}
if (explainWhyRequested) {
lines.push("Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", `За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`, netVat <= 0
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
: "Разница положительная, поэтому к уплате берется эта положительная величина.", "Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация.");
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [

View File

@ -247,6 +247,11 @@ function hasAddressFollowupContextSignal(text) {
return true;
}
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
if (tokenCount <= 12 &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(normalized) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)) {
return true;
}
const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized);
if (tokenCount <= 8 && hasPeriodLiteral) {
return true;
@ -264,11 +269,16 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const previousOrganization = toNonEmptyString(previous.organization);
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to);
const allTimeRequested = hasAllTimeHint(userMessage);
const sameDateRequested = hasSameDateHint(userMessage);
if (!toNonEmptyString(merged.organization) && previousOrganization) {
merged.organization = previousOrganization;
reasons.push("organization_from_followup_context");
}
if (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty") {
@ -350,9 +360,26 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("period_to_cleared_for_lifecycle_followup");
}
}
const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage);
const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage);
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
if (intent === "vat_payable_forecast" && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
const currentPeriodFrom = toNonEmptyString(merged.period_from);
const currentPeriodTo = toNonEmptyString(merged.period_to);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = !currentPeriodFrom && currentPeriodTo === todayIso;
if (!currentPeriodFrom || currentLooksDefaultedToToday) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
if (previousPeriodTo) {
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
}
}
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
@ -477,7 +504,10 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
function runAddressDecomposeStage(userMessage, followupContext) {
const detectedMode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
const allowExplainAsFollowup = shape.shape === "EXPLAIN_OR_REASON" &&
Boolean(followupContext?.previous_intent) &&
hasAddressFollowupContextSignal(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON" && !allowExplainAsFollowup) {
return null;
}
const detectedIntent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);

View File

@ -43,7 +43,8 @@ function inferAggregationProfile(intent, shape) {
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value") {
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast") {
return "management_profile";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {

View File

@ -39,6 +39,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.AssistantService = void 0;
exports.evaluateCoverageForTests = evaluateCoverageForTests;
exports.extractSubjectTokensForTests = extractSubjectTokensForTests;
exports.resolveAssistantOrchestrationDecision = resolveAssistantOrchestrationDecision;
exports.resolveSessionOrganizationScopeContextForTests = resolveSessionOrganizationScopeContextForTests;
exports.extractOrganizationFactsFromRowsForTests = extractOrganizationFactsFromRowsForTests;
exports.resolveOrganizationNamesByRefsForTests = resolveOrganizationNamesByRefsForTests;
exports.resolveLivingAssistantModeDecision = resolveLivingAssistantModeDecision;
@ -1431,10 +1433,25 @@ function compactWhitespace(value) {
return value.replace(/\s+/g, " ").trim();
}
function hasAccountingSignal(text) {
const lower = text.toLowerCase();
if (/(?:^|[\s,;:])\d{2}(?:\.\d{2})?(?=$|[\s,.;:])/i.test(lower)) {
const lower = repairAddressMojibake(String(text ?? "")).toLowerCase();
const excludedSpans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower), ...collectContractSpans(lower)];
const accountTokenPattern = /\b(?:01|02|07|08|10|13|19|20|21|23|25|26|28|29|41|43|44|50|51|52|55|57|58|60|62|66|67|68|69|70|71|73|75|76|80|81|84|90|91|97)(?:[.,]\d{1,2})?\b/g;
let accountMatch = null;
while ((accountMatch = accountTokenPattern.exec(lower)) !== null) {
const token = String(accountMatch[0] ?? "").trim();
if (!token) {
continue;
}
const start = accountMatch.index;
const end = start + token.length;
if (intersectsAnySpan(start, end, excludedSpans)) {
continue;
}
const hasExplicitSubaccount = /[.,]\d{1,2}/.test(token);
if (hasExplicitSubaccount || hasAccountContextAround(lower, start, end) || countTokens(lower) <= 4) {
return true;
}
}
return /(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|счёт|ндс|амортиз|рбп|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|закрыти|период|postavshchik|kontragent|schet|schetu|period|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(lower);
}
function hasFollowupMarker(text) {
@ -1819,6 +1836,7 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
sanitized_user_message: llmMeta?.sanitizedUserMessage ?? null,
tool_gate_decision: llmMeta?.toolGateDecision ?? null,
tool_gate_reason: llmMeta?.toolGateReason ?? null,
orchestration_contract_v1: llmMeta?.orchestrationContract ?? null,
dialog_continuation_contract_v2: llmMeta?.dialogContinuationContract ?? null,
address_retry_audit: llmMeta?.addressRetryAudit ?? null,
answer_structure_v11: null,
@ -2394,6 +2412,15 @@ function repairAddressMojibake(value) {
}
return candidate;
}
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
const repaired = repairAddressMojibake(String(value ?? ""));
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "").trim();
if (sanitized) {
return sanitized;
}
const fallbackText = String(fallback ?? "").trim();
return fallbackText || "Не смог сформировать читаемый ответ. Уточните запрос.";
}
function extractAddressAnchorTokens(value) {
const source = repairAddressMojibake(compactWhitespace(String(value ?? "").toLowerCase()));
if (!source) {
@ -2632,6 +2659,11 @@ function hasAddressFollowupContextSignal(userMessage) {
if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
return true;
}
if (shortFollowup &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) {
return true;
}
if (shortFollowup &&
/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) &&
!/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) {
@ -2693,6 +2725,12 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousFilters.counterparty = historicalCounterparty;
}
}
if (!toNonEmptyString(previousFilters.organization)) {
const historicalOrganization = findRecentAddressFilterValue(items, "organization");
if (historicalOrganization) {
previousFilters.organization = historicalOrganization;
}
}
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null;
}
@ -3409,6 +3447,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntentConfidence !== "low";
const hasLlmCanonicalDataSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) &&
Boolean(llmPreDecomposeMeta?.applied) &&
llmContractMode === "address_query" &&
hasStrongDataIntentSignal(repairedInputMessage);
const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
@ -3461,9 +3500,157 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
reason: "no_address_signal_after_l0"
};
}
function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
const repairedRawUserMessage = repairAddressMojibake(rawUserMessage);
const repairedEffectiveAddressUserMessage = repairAddressMojibake(effectiveAddressUserMessage);
const followupContext = input?.followupContext ?? null;
const llmPreDecomposeMeta = input?.llmPreDecomposeMeta ?? null;
const useMock = Boolean(input?.useMock);
const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawUserMessage) ||
hasAssistantDataScopeMetaQuestionSignal(repairedRawUserMessage) ||
hasAssistantDataScopeMetaQuestionSignal(effectiveAddressUserMessage) ||
hasAssistantDataScopeMetaQuestionSignal(repairedEffectiveAddressUserMessage);
const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawUserMessage) ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedRawUserMessage) ||
shouldHandleAsAssistantCapabilityMetaQuery(effectiveAddressUserMessage) ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedEffectiveAddressUserMessage);
const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawUserMessage) ||
hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage);
const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage;
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample);
const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample);
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
hasStrongDataIntentSignal(repairedRawUserMessage) ||
hasStrongDataIntentSignal(effectiveAddressUserMessage) ||
hasStrongDataIntentSignal(repairedEffectiveAddressUserMessage) ||
hasAccountingSignal(rawUserMessage) ||
hasAccountingSignal(repairedRawUserMessage) ||
hasAccountingSignal(effectiveAddressUserMessage) ||
hasAccountingSignal(repairedEffectiveAddressUserMessage) ||
hasDataRetrievalRequestSignal(rawUserMessage) ||
hasDataRetrievalRequestSignal(repairedRawUserMessage);
const hardMetaMode = dataScopeMetaQuery
? "data_scope"
: capabilityMetaQuery && !dataRetrievalSignal
? "capability"
: null;
if (hardMetaMode === "data_scope") {
return {
runAddressLane: false,
toolGateDecision: "skip_address_lane",
toolGateReason: "assistant_data_scope_query_detected",
livingMode: "chat",
livingReason: "assistant_data_scope_query_detected",
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: "data_scope",
address_mode: modeDetection.mode,
address_mode_confidence: modeDetection.confidence,
address_intent: intentResolution.intent,
address_intent_confidence: intentResolution.confidence,
strong_data_signal_detected: strongDataSignal,
data_retrieval_signal_detected: dataRetrievalSignal,
followup_context_detected: Boolean(followupContext),
unsupported_address_intent_fallback_to_deep: false,
final_decision: {
run_address_lane: false,
tool_gate_decision: "skip_address_lane",
tool_gate_reason: "assistant_data_scope_query_detected",
living_mode: "chat",
living_reason: "assistant_data_scope_query_detected"
}
}
};
}
if (hardMetaMode === "capability") {
return {
runAddressLane: false,
toolGateDecision: "skip_address_lane",
toolGateReason: "assistant_capability_query_detected",
livingMode: "chat",
livingReason: "assistant_capability_query_detected",
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: "capability",
address_mode: modeDetection.mode,
address_mode_confidence: modeDetection.confidence,
address_intent: intentResolution.intent,
address_intent_confidence: intentResolution.confidence,
strong_data_signal_detected: strongDataSignal,
data_retrieval_signal_detected: dataRetrievalSignal,
followup_context_detected: Boolean(followupContext),
unsupported_address_intent_fallback_to_deep: false,
final_decision: {
run_address_lane: false,
tool_gate_decision: "skip_address_lane",
tool_gate_reason: "assistant_capability_query_detected",
living_mode: "chat",
living_reason: "assistant_capability_query_detected"
}
}
};
}
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
const unsupportedAddressIntentFallbackToDeep = Boolean(!followupContext &&
baseToolGate?.runAddressLane &&
modeDetection.mode === "address_query" &&
intentResolution.intent === "unknown" &&
strongDataSignal);
let runAddressLane = Boolean(baseToolGate?.runAddressLane);
let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane");
let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0");
if (unsupportedAddressIntentFallbackToDeep) {
runAddressLane = false;
toolGateDecision = "skip_address_lane";
toolGateReason = "address_signal_unsupported_intent_fallback_to_deep";
}
let livingDecision = resolveLivingAssistantModeDecision({
userMessage: rawUserMessage,
addressLaneTriggered: runAddressLane,
useMock,
predecomposeMode: llmPreDecomposeMeta?.predecomposeContract?.mode ?? null,
predecomposeModeConfidence: llmPreDecomposeMeta?.predecomposeContract?.mode_confidence ?? null
});
if (unsupportedAddressIntentFallbackToDeep) {
livingDecision = {
mode: "deep_analysis",
reason: "unsupported_address_intent_fallback_to_deep"
};
}
return {
runAddressLane,
toolGateDecision,
toolGateReason,
livingMode: livingDecision.mode,
livingReason: livingDecision.reason,
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: null,
address_mode: modeDetection.mode,
address_mode_confidence: modeDetection.confidence,
address_intent: intentResolution.intent,
address_intent_confidence: intentResolution.confidence,
strong_data_signal_detected: strongDataSignal,
data_retrieval_signal_detected: dataRetrievalSignal,
followup_context_detected: Boolean(followupContext),
unsupported_address_intent_fallback_to_deep: unsupportedAddressIntentFallbackToDeep,
final_decision: {
run_address_lane: runAddressLane,
tool_gate_decision: toolGateDecision,
tool_gate_reason: toolGateReason,
living_mode: livingDecision.mode,
living_reason: livingDecision.reason
}
}
};
}
function hasStrongDataIntentSignal(text) {
const lower = String(text ?? "").toLowerCase();
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account)/i.test(lower);
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower);
}
function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
@ -3475,7 +3662,7 @@ function hasDataRetrievalRequestSignal(text) {
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
return false;
}
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting)/i.test(lower);
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
if (!hasRetrievalObject) {
return false;
}
@ -3485,6 +3672,77 @@ function hasDataRetrievalRequestSignal(text) {
const hasMetaCapabilityShape = /(?:мож(?:ем|ешь|ете|но)|уме(?:ешь|ете)|доступ|подключ|чья|как\s+называ(?:ет|ется)|работ(?:ать|аем|аешь|аете)|в\s+тебе|у\s+тебя)/i.test(lower);
return !hasMetaCapabilityShape;
}
function hasOrganizationFactLookupSignal(text) {
const repaired = repairAddressMojibake(String(text ?? ""));
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
const hasFactCue = /(?:возраст|сколько\s+лет|дата\s+регистрац|когда\s+(?:зарегистр|создан|основан)|год\s+регистрац|год\s+основан|с\s+какого\s+года|when\s+was\s+(?:it\s+)?(?:registered|founded|created))/i.test(normalized);
if (!hasFactCue) {
return false;
}
return /(?:организац|компан|контор|фирм|ооо|ао|зао|ип|альтернатив|лайсвуд|райм|organization|company)/i.test(normalized);
}
function findLastAssistantLivingChatDebug(items) {
if (!Array.isArray(items)) {
return null;
}
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant") {
continue;
}
if (item.debug && typeof item.debug === "object") {
return item.debug;
}
}
return null;
}
function hasOrganizationFactFollowupSignal(userMessage, items) {
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
if (hasOrganizationFactLookupSignal(normalized)) {
return false;
}
const hasFollowupCue = /(?:^|\s)(?:давай|го|погнали|ок(?:ей)?|хорошо|принято|подтверждаю|запрашивай|запроси|проверь|продолжай|ну\s+давай|да\s+давай)(?=$|[\s,.!?;:])/iu.test(normalized);
if (!hasFollowupCue) {
return false;
}
const lastDebug = findLastAssistantLivingChatDebug(items);
const lastSource = toNonEmptyString(lastDebug?.living_chat_response_source);
const lastGuardReason = toNonEmptyString(lastDebug?.living_chat_grounding_guard_reason);
const inOrganizationFactBoundary = lastSource === "deterministic_organization_fact_boundary" ||
lastSource === "deterministic_organization_fact_boundary_followup" ||
lastGuardReason === "organization_fact_without_live_source_blocked";
return inOrganizationFactBoundary;
}
function shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization) {
const selected = normalizeOrganizationScopeValue(selectedOrganization);
if (!selected) {
return false;
}
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
if (hasOrganizationFactLookupSignal(normalized) || hasDataRetrievalRequestSignal(normalized) || hasStrongDataIntentSignal(normalized)) {
return false;
}
const hasAnalyticalCue = /(?:какой|какая|какие|когда|сколько|кто|почему|зачем|возраст|дата|регистрац|ндс|налог|контракт|договор|документ|операц|оборот|сумм|остат|сальдо|founded|registered|created)/i.test(normalized);
if (hasAnalyticalCue) {
return false;
}
const hasSelectionCue = /(?:давай|го|погнали|ок(?:ей)?|хорошо|отлично|берем|выберем|выбираем|переключ(?:им|аем|ай)|фиксир|работаем|обсудим|тогда)\b/i.test(normalized);
if (hasSelectionCue) {
return true;
}
return normalized.length <= 36 && !/[?]/.test(String(userMessage ?? ""));
}
function hasOperationalAdminActionRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
@ -3527,18 +3785,28 @@ function hasAssistantCapabilityQuestionSignal(text) {
"что ты умеешь",
"какой у тебя функционал",
"какие у тебя функции",
"какие фичи",
"что отработано",
"что у тебя отработано",
"полный список возможностей",
"полный список"
];
if (directCapabilityPhrases.some((phrase) => normalized.includes(phrase))) {
return true;
}
if (/(?:каки[ею].*(?:фич|функц|возможност|отработан)|какого\s+рода\s+ошибк.*ты\s+мож(?:ешь|ете)|какие\s+ошибк.*ты\s+мож(?:ешь|ете))/iu.test(normalized)) {
return true;
}
const hasCanVerb = /(?:можешь|можете|умеешь|умеете|можно)/i.test(normalized);
const hasControlAction = /(?:настро|установ|подключ|обнов|созда|подготов|сдела|делат|дела)/i.test(normalized);
const hasAnalysisAction = /(?:найт|искать|провер|анализ|разоб|объясн|расска|подсказ|показ)/i.test(normalized);
const hasCapabilityObject = /(?:1с|1c|док|документ|баз|отчет|отч[её]т|конфигурац|настройк)/i.test(normalized);
if (hasCanVerb && hasControlAction && hasCapabilityObject) {
return true;
}
if (hasCanVerb && hasAnalysisAction && !hasDataRetrievalRequestSignal(normalized)) {
return true;
}
const hasCapabilityMetaQuestion = /(?:что|чем)\s+(?:ты\s+)?(?:мож(?:ешь|ете)|уме(?:ешь|ете)|можно)(?=$|[\s,.!?;:])/iu.test(normalized);
if (hasCapabilityMetaQuestion && hasCapabilityObject) {
return true;
@ -3672,6 +3940,320 @@ function normalizeScopeLabel(value) {
function normalizeScopeKey(value) {
return repairAddressMojibake(String(value ?? "")).toLowerCase().replace(/ё/g, "е");
}
const ORGANIZATION_SCOPE_STOPWORDS = new Set([
"ооо",
"ao",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"организация",
"организации",
"контора",
"конторы",
"фирма",
"фирмы",
"по",
"для",
"над",
"под",
"без",
"с",
"со",
"в",
"во",
"на",
"и",
"или",
"а",
"но",
"не",
"мы",
"нам",
"наш",
"наша",
"наше",
"наши",
"ты",
"тебе",
"твой",
"сейчас",
"щас",
"тут",
"вот",
"давай",
"го",
"погнали",
"тогда",
"обсудим",
"обсуждать",
"работать",
"работаем",
"работаешь",
"работаете",
"можем",
"можно",
"какая",
"какой",
"какие",
"чья",
"чье",
"чьи"
]);
function normalizeOrganizationScopeValue(value) {
const normalized = normalizeScopeLabel(value);
if (!normalized) {
return null;
}
const unwrapped = normalized
.replace(/^\\+|\\+$/g, "")
.replace(/^"+|"+$/g, "")
.replace(/^'+|'+$/g, "")
.trim();
return unwrapped ? unwrapped : null;
}
function normalizeOrganizationScopeSearchText(value) {
const source = normalizeScopeKey(value);
return source
.replace(/[^a-zа-я0-9]+/giu, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeOrganizationScope(value) {
const normalized = normalizeOrganizationScopeSearchText(value);
if (!normalized) {
return [];
}
return normalized
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !ORGANIZATION_SCOPE_STOPWORDS.has(token));
}
function organizationTokenVariants(token) {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set([source]);
const withoutLongEnding = source.replace(/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой)$/iu, "");
if (withoutLongEnding.length >= 4) {
variants.add(withoutLongEnding);
}
const withoutShortEnding = source.replace(/[аеёиоуыэюя]$/iu, "");
if (withoutShortEnding.length >= 4) {
variants.add(withoutShortEnding);
}
return Array.from(variants);
}
function scoreOrganizationMentionInMessage(message, organization) {
const messageNorm = normalizeOrganizationScopeSearchText(message);
const organizationNorm = normalizeOrganizationScopeSearchText(organization);
if (!messageNorm || !organizationNorm) {
return 0;
}
if (messageNorm.includes(organizationNorm)) {
return 10_000 + organizationNorm.length;
}
const organizationTokens = tokenizeOrganizationScope(organizationNorm);
if (organizationTokens.length === 0) {
return 0;
}
const messageTokens = tokenizeOrganizationScope(messageNorm);
if (messageTokens.length === 0) {
return 0;
}
let matchedTokens = 0;
let score = 0;
for (const token of organizationTokens) {
const variants = organizationTokenVariants(token);
let matched = false;
let variantScore = 0;
for (const variant of variants) {
if (!variant) {
continue;
}
if (messageNorm.includes(variant)) {
matched = true;
variantScore = Math.max(variantScore, variant.length * 5);
continue;
}
const fuzzyMatched = messageTokens.some((messageToken) => {
if (messageToken === variant) {
return true;
}
if (messageToken.length >= 5 && variant.length >= 5) {
return messageToken.startsWith(variant) || variant.startsWith(messageToken);
}
return false;
});
if (fuzzyMatched) {
matched = true;
variantScore = Math.max(variantScore, Math.max(20, variant.length * 3));
}
}
if (matched) {
matchedTokens += 1;
score += variantScore > 0 ? variantScore : 10;
}
}
if (matchedTokens === 0) {
return 0;
}
if (matchedTokens === organizationTokens.length) {
score += 400;
}
else {
score += matchedTokens * 50;
}
return score;
}
function parseOrganizationsFromDataScopeAssistantText(text) {
const source = repairAddressMojibake(String(text ?? ""));
if (!source) {
return [];
}
const extracted = [];
const singleMatch = source.match(/доступна\s+организация:\s*([^.\n]+)/iu);
if (singleMatch) {
const value = normalizeOrganizationScopeValue(singleMatch[1]);
if (value) {
extracted.push(value);
}
}
const multiMatch = source.match(/доступны\s+организац(?:ии|ия)\s*(?:\(\d+\))?:\s*([^.\n]+)/iu);
if (multiMatch) {
const parts = String(multiMatch[1] ?? "")
.split(",")
.map((item) => normalizeOrganizationScopeValue(item))
.filter(Boolean);
extracted.push(...parts);
}
return Array.from(new Set(extracted));
}
function mergeKnownOrganizations(values) {
const dedup = new Map();
for (const raw of Array.isArray(values) ? values : []) {
const normalized = normalizeOrganizationScopeValue(raw);
if (!normalized) {
continue;
}
const key = normalizeOrganizationScopeSearchText(normalized);
if (!key) {
continue;
}
if (!dedup.has(key)) {
dedup.set(key, normalized);
}
}
return Array.from(dedup.values()).slice(0, 20);
}
function extractKnownOrganizationsFromHistory(items) {
const collected = [];
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant") {
continue;
}
const debug = item.debug && typeof item.debug === "object" ? item.debug : null;
if (debug) {
const directFromProbe = Array.isArray(debug.living_chat_data_scope_probe_organizations)
? debug.living_chat_data_scope_probe_organizations
: [];
const knownFromDebug = Array.isArray(debug.assistant_known_organizations)
? debug.assistant_known_organizations
: [];
if (directFromProbe.length > 0 || knownFromDebug.length > 0) {
collected.push(...directFromProbe, ...knownFromDebug);
}
}
const parsedFromText = parseOrganizationsFromDataScopeAssistantText(item.text);
if (parsedFromText.length > 0) {
collected.push(...parsedFromText);
}
if (collected.length >= 20) {
break;
}
}
return mergeKnownOrganizations(collected);
}
function findLastAssistantActiveOrganization(items) {
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
const direct = normalizeOrganizationScopeValue(item.debug.assistant_active_organization);
if (direct) {
return direct;
}
const selected = normalizeOrganizationScopeValue(item.debug.living_chat_selected_organization);
if (selected) {
return selected;
}
}
return null;
}
function resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations) {
const known = mergeKnownOrganizations(knownOrganizations);
if (!userMessage || known.length === 0) {
return null;
}
const messageNorm = normalizeOrganizationScopeSearchText(userMessage);
if (!messageNorm) {
return null;
}
const scored = known
.map((organization) => ({
organization,
score: scoreOrganizationMentionInMessage(messageNorm, organization)
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.organization.length - b.organization.length);
if (scored.length === 0) {
return null;
}
const best = scored[0];
const second = scored[1];
if (best.score < 90) {
return null;
}
if (second && second.score === best.score) {
return null;
}
return best.organization;
}
function resolveSessionOrganizationScopeContext(userMessage, items) {
const knownOrganizations = extractKnownOrganizationsFromHistory(items);
const selectedOrganization = resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations);
const lastActiveOrganization = findLastAssistantActiveOrganization(items);
const activeOrganization = selectedOrganization ?? normalizeOrganizationScopeValue(lastActiveOrganization);
return {
knownOrganizations,
selectedOrganization,
activeOrganization
};
}
function mergeFollowupContextWithOrganizationScope(followupContext, organization) {
const normalizedOrganization = normalizeOrganizationScopeValue(organization);
const base = followupContext && typeof followupContext === "object" ? { ...followupContext } : {};
if (!normalizedOrganization) {
return followupContext && typeof followupContext === "object" ? base : null;
}
const previousFilters = base.previous_filters && typeof base.previous_filters === "object"
? { ...base.previous_filters }
: {};
if (!toNonEmptyString(previousFilters.organization)) {
previousFilters.organization = normalizedOrganization;
}
base.previous_filters = previousFilters;
return base;
}
function resolveSessionOrganizationScopeContextForTests(userMessage, items) {
return resolveSessionOrganizationScopeContext(userMessage, items);
}
function normalizeGuidValue(value) {
const source = normalizeScopeLabel(value);
if (!source) {
@ -4046,6 +4628,26 @@ function buildAssistantDataScopeContractReply(scopeProbe = null) {
"Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
].join(" ");
}
function buildAssistantDataScopeSelectionReply(organization) {
const selected = normalizeOrganizationScopeValue(organization) ?? String(organization ?? "").trim();
return [
`Отлично, фиксирую рабочую организацию: ${selected}.`,
"Дальше буду держать этот контур как активный, пока вы не переключите организацию."
].join(" ");
}
function buildAssistantOrganizationFactBoundaryReply(organization) {
const selected = normalizeOrganizationScopeValue(organization) ?? String(organization ?? "").trim();
if (selected) {
return [
`По организации ${selected} не буду называть дату/возраст без live-подтвержденного источника.`,
"Если нужно, запрошу факт из 1С и верну только подтвержденный ответ."
].join(" ");
}
return [
"Не буду называть дату/возраст организации без live-подтвержденного источника.",
"Сначала получу факт из 1С, потом дам точный ответ."
].join(" ");
}
function buildAssistantOperationalBoundaryReply() {
return [
"Понимаю, что ситуация срочная.",
@ -4109,6 +4711,45 @@ function applyLivingChatScriptGuard(chatText, userMessage) {
reason: "unexpected_cjk_fragment_fallback"
};
}
function applyLivingChatGroundingGuard(input) {
const userMessage = String(input?.userMessage ?? "");
const chatText = String(input?.chatText ?? "").trim();
const organization = toNonEmptyString(input?.organization);
if (!chatText) {
return {
text: chatText,
applied: false,
reason: null
};
}
if (!hasOrganizationFactLookupSignal(userMessage)) {
return {
text: chatText,
applied: false,
reason: null
};
}
if (/(?:не\s+могу|не\s+вижу|после\s+проверки|live|подтвержден)/i.test(chatText)) {
return {
text: chatText,
applied: false,
reason: null
};
}
const hasSpecificUnverifiedFact = /(?:\b\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})\b|\b(?:19|20)\d{2}\b|\b\d+\s+лет\b)/i.test(chatText);
if (!hasSpecificUnverifiedFact) {
return {
text: chatText,
applied: false,
reason: null
};
}
return {
text: buildAssistantOrganizationFactBoundaryReply(organization),
applied: true,
reason: "organization_fact_without_live_source_blocked"
};
}
function resolveLivingAssistantModeDecision(input) {
const userMessage = String(input?.userMessage ?? "");
if (input?.addressLaneTriggered) {
@ -4187,7 +4828,9 @@ class AssistantService {
async handleMessage(payload) {
const session = this.sessions.ensureSession(payload.session_id);
const sessionId = session.session_id;
const userMessage = String(payload.user_message ?? payload.message ?? "").trim();
const userMessageRaw = String(payload.user_message ?? payload.message ?? "").trim();
const repairedUserMessage = compactWhitespace(repairAddressMojibake(userMessageRaw));
const userMessage = repairedUserMessage || userMessageRaw;
const userItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
@ -4199,13 +4842,23 @@ class AssistantService {
debug: null
};
this.sessions.appendItem(sessionId, userItem);
const sessionOrganizationScope = resolveSessionOrganizationScopeContext(userMessage, session.items);
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const safeAddressReply = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(addressLane.reply_text) ?? "").trim() || String(addressLane.reply_text ?? "");
const safeAddressReply = sanitizeOutgoingAssistantText(addressLane.reply_text);
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
const followupOffer = buildAddressFollowupOffer(debug);
if (followupOffer) {
debug.address_followup_offer = followupOffer;
}
const debugKnownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations);
const debugActiveOrganization = toNonEmptyString(debug?.extracted_filters?.organization) ??
toNonEmptyString(sessionOrganizationScope.activeOrganization);
if (debugKnownOrganizations.length > 0) {
debug.assistant_known_organizations = debugKnownOrganizations;
}
if (debugActiveOrganization) {
debug.assistant_active_organization = debugActiveOrganization;
}
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
@ -4311,6 +4964,11 @@ class AssistantService {
let livingChatSource = "llm_chat";
let livingChatScriptGuardApplied = false;
let livingChatScriptGuardReason = null;
let livingChatGroundingGuardApplied = false;
let livingChatGroundingGuardReason = null;
let knownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations);
let selectedOrganization = toNonEmptyString(sessionOrganizationScope.selectedOrganization);
let activeOrganization = toNonEmptyString(sessionOrganizationScope.activeOrganization);
if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) {
chatText = buildAssistantSafetyRefusalReply();
livingChatSource = "deterministic_safety_refusal";
@ -4318,10 +4976,35 @@ class AssistantService {
else if (dataScopeMetaQuery) {
dataScopeProbe = await resolveAssistantDataScopeProbe();
chatText = buildAssistantDataScopeContractReply(dataScopeProbe);
knownOrganizations = mergeKnownOrganizations([
...knownOrganizations,
...(Array.isArray(dataScopeProbe?.organizations) ? dataScopeProbe.organizations : [])
]);
if (!activeOrganization && knownOrganizations.length === 1) {
activeOrganization = knownOrganizations[0];
}
livingChatSource = dataScopeProbe?.status === "resolved"
? "deterministic_data_scope_contract_live"
: "deterministic_data_scope_contract";
}
else if ((selectedOrganization || activeOrganization) && hasOrganizationFactLookupSignal(userMessage)) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization);
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_organization_fact_boundary";
}
else if ((selectedOrganization || activeOrganization) && hasOrganizationFactFollowupSignal(userMessage, session.items)) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization);
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_organization_fact_boundary_followup";
}
else if (!capabilityMetaQuery && shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization ?? activeOrganization)) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = buildAssistantDataScopeSelectionReply(scopedOrganization);
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_data_scope_selection_contract";
}
else if (capabilityMetaQuery && operationalSignal && !hasAssistantCapabilityQuestionSignal(userMessage)) {
chatText = buildAssistantOperationalBoundaryReply();
livingChatSource = "deterministic_operational_boundary";
@ -4353,7 +5036,7 @@ class AssistantService {
maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)),
temperature: payload.temperature ?? 0.35
});
chatText = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(chatResponse?.outputText ?? "") ?? "").trim();
chatText = sanitizeOutgoingAssistantText(chatResponse?.outputText ?? "", "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.");
const scriptGuard = applyLivingChatScriptGuard(chatText, userMessage);
chatText = scriptGuard.text;
if (scriptGuard.applied) {
@ -4361,6 +5044,17 @@ class AssistantService {
livingChatScriptGuardReason = scriptGuard.reason;
livingChatSource = "llm_chat_script_guard";
}
const groundingGuard = applyLivingChatGroundingGuard({
userMessage,
chatText,
organization: activeOrganization ?? selectedOrganization ?? null
});
chatText = groundingGuard.text;
if (groundingGuard.applied) {
livingChatGroundingGuardApplied = true;
livingChatGroundingGuardReason = groundingGuard.reason;
livingChatSource = "llm_chat_grounding_guard";
}
}
if (!chatText) {
return null;
@ -4378,16 +5072,25 @@ class AssistantService {
living_chat_response_source: livingChatSource,
living_chat_script_guard_applied: livingChatScriptGuardApplied,
living_chat_script_guard_reason: livingChatScriptGuardReason,
living_chat_grounding_guard_applied: livingChatGroundingGuardApplied,
living_chat_grounding_guard_reason: livingChatGroundingGuardReason,
living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null,
living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null,
living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations)
? dataScopeProbe.organizations.length
: 0,
living_chat_data_scope_probe_organizations: Array.isArray(dataScopeProbe?.organizations)
? mergeKnownOrganizations(dataScopeProbe.organizations)
: [],
living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null,
living_chat_selected_organization: selectedOrganization ?? null,
assistant_known_organizations: knownOrganizations,
assistant_active_organization: activeOrganization ?? null,
address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted),
address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied),
address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
address_llm_predecompose_contract: addressRuntimeMeta?.predecomposeContract ?? null,
orchestration_contract_v1: addressRuntimeMeta?.orchestrationContract ?? null,
tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null,
tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null,
normalized: null,
@ -4476,23 +5179,27 @@ class AssistantService {
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage, addressPreDecompose);
const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null, addressPreDecompose, userMessage);
const orchestrationDecision = resolveAssistantOrchestrationDecision({
rawUserMessage: userMessage,
effectiveAddressUserMessage: addressInputMessage,
followupContext: carryover?.followupContext ?? null,
llmPreDecomposeMeta: addressPreDecompose,
useMock: Boolean(payload.useMock)
});
const dialogContinuationContract = buildAddressDialogContinuationContractV2(userMessage, addressInputMessage, carryover, addressPreDecompose);
const addressRuntimeMeta = {
...addressPreDecompose,
toolGateDecision: toolGate.decision,
toolGateReason: toolGate.reason,
dialogContinuationContract
toolGateDecision: orchestrationDecision.toolGateDecision,
toolGateReason: orchestrationDecision.toolGateReason,
dialogContinuationContract,
orchestrationContract: orchestrationDecision.orchestrationContract
};
addressRuntimeMetaForDeep = addressRuntimeMeta;
const livingModeDecision = resolveLivingAssistantModeDecision({
userMessage,
addressLaneTriggered: toolGate.runAddressLane,
useMock: Boolean(payload.useMock),
predecomposeMode: addressRuntimeMeta?.predecomposeContract?.mode ?? null,
predecomposeModeConfidence: addressRuntimeMeta?.predecomposeContract?.mode_confidence ?? null
});
if (!toolGate.runAddressLane) {
const livingModeDecision = {
mode: orchestrationDecision.livingMode,
reason: orchestrationDecision.livingReason
};
if (!orchestrationDecision.runAddressLane) {
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
@ -4508,6 +5215,7 @@ class AssistantService {
address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
address_fallback_rule_hit: addressRuntimeMeta?.fallbackRuleHit ?? null,
address_sanitized_user_message: addressRuntimeMeta?.sanitizedUserMessage ?? null,
assistant_orchestration_contract_v1: addressRuntimeMeta?.orchestrationContract ?? null,
address_tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null,
address_llm_predecompose_contract_intent: addressRuntimeMeta?.predecomposeContract?.intent ?? null,
@ -4522,7 +5230,7 @@ class AssistantService {
}
}
}
if (toolGate.runAddressLane) {
if (orchestrationDecision.runAddressLane) {
const shouldPreferContextualLane = Boolean(carryover?.followupContext);
const canRetryWithRawUserMessage = compactWhitespace(String(addressInputMessage ?? "").toLowerCase()) !==
compactWhitespace(String(userMessage ?? "").toLowerCase());
@ -4563,9 +5271,10 @@ class AssistantService {
};
};
const runAddressLaneAttempt = async (messageUsed, carryMeta) => {
if (carryMeta?.followupContext) {
const scopedFollowupContext = mergeFollowupContextWithOrganizationScope(carryMeta?.followupContext ?? null, sessionOrganizationScope.activeOrganization);
if (scopedFollowupContext) {
return this.addressQueryService.tryHandle(messageUsed, {
followupContext: carryMeta.followupContext
followupContext: scopedFollowupContext
});
}
return this.addressQueryService.tryHandle(messageUsed);
@ -4824,7 +5533,7 @@ class AssistantService {
enableProblemCentricAnswerV1: config_1.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1,
enableLifecycleAnswerV1: config_1.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1
});
const safeAssistantReplyBase = (0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(composition.assistant_reply);
const safeAssistantReplyBase = sanitizeOutgoingAssistantText(composition.assistant_reply, "Нужны уточнения для надежного ответа.");
const safeAssistantReply = String(safeAssistantReplyBase ?? "")
.replace(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json)\b[\s\S]*$/gi, "")
.replace(/\b(?:debug_payload_json|technical_breakdown_json)\b[\s\S]*$/gi, "")
@ -4927,6 +5636,7 @@ class AssistantService {
address_tool_gate_decision: addressRuntimeMetaForDeep?.toolGateDecision ?? null,
address_tool_gate_reason: addressRuntimeMetaForDeep?.toolGateReason ?? null,
address_llm_predecompose_contract: addressRuntimeMetaForDeep?.predecomposeContract ?? null,
orchestration_contract_v1: addressRuntimeMetaForDeep?.orchestrationContract ?? null,
answer_structure_v11: answerStructureV11,
investigation_state_snapshot: investigationStateSnapshot,
normalized: normalized.normalized

View File

@ -0,0 +1,821 @@
import fs from "fs";
import path from "path";
import { Router } from "express";
import { ASSISTANT_SESSIONS_DIR, EVAL_CASES_DIR, REPORTS_DIR } from "../config";
import { ApiError, ok } from "../utils/http";
type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown";
type AutoRunTrend = "up" | "down" | "flat";
interface IndexedRun {
run_id: string;
eval_target: AutoRunTarget;
report_path: string;
report: Record<string, unknown>;
timestamp_iso: string;
timestamp_ms: number;
}
interface RunFilters {
from_ms: number | null;
to_ms: number | null;
target: AutoRunTarget | "all";
use_mock: boolean | null;
prompt_contains: string;
mode: string;
limit: number;
scan_limit: number;
}
interface DomainCoverage {
domain: string;
total_cases: number;
closed_cases: number;
}
interface RunCoverage {
closed_cases: number;
open_cases: number;
domain_coverage: DomainCoverage[];
}
interface RunSummary {
run_id: string;
eval_target: AutoRunTarget;
run_timestamp: string;
mode: string | null;
llm_provider: string | null;
model: string | null;
use_mock: boolean | null;
prompt_version: string | null;
schema_version: string | null;
suite_id: string | null;
cases_total: number;
requests_total: number | null;
report_path: string;
score_index: number | null;
blocking_failures: number;
quality_failures: number;
closed_cases: number;
open_cases: number;
domain_coverage: DomainCoverage[];
}
interface CaseSummary {
case_id: string;
domain: string | null;
query_class: string | null;
status: "closed" | "open" | "unknown";
score_index: number | null;
trace_id: string | null;
reply_type: string | null;
session_id: string;
dialog_available: boolean;
checks: Record<string, unknown> | null;
metric_subscores: Record<string, unknown> | null;
}
interface HistoryStats {
runs_total: number;
by_target: Record<string, number>;
blocking_runs: number;
quality_gap_runs: number;
avg_score_index: number | null;
latest_score_index: number | null;
previous_score_index: number | null;
trend: AutoRunTrend;
domain_coverage: DomainCoverage[];
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toStringSafe(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toNumberSafe(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toBooleanSafe(value: unknown): boolean | null {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const lowered = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(lowered)) return true;
if (["0", "false", "no", "off"].includes(lowered)) return false;
}
return null;
}
function parseDateMs(value: unknown): number | null {
const asString = toStringSafe(value);
if (!asString) {
return null;
}
const ms = Date.parse(asString);
return Number.isFinite(ms) ? ms : null;
}
function clampInt(value: number | null, min: number, max: number, fallback: number): number {
if (value === null || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.trunc(value);
if (rounded < min) return min;
if (rounded > max) return max;
return rounded;
}
function resolveRunTarget(input: { report: Record<string, unknown>; runId: string; reportPath: string }): AutoRunTarget {
const explicit = toStringSafe(input.report.eval_target);
if (explicit === "assistant_stage1" || explicit === "assistant_stage2" || explicit === "assistant_p0" || explicit === "normalizer") {
return explicit;
}
if (input.runId.startsWith("assistant-stage1-")) return "assistant_stage1";
if (input.runId.startsWith("assistant-stage2-")) return "assistant_stage2";
if (input.runId.startsWith("assistant-p0-")) return "assistant_p0";
if (input.runId.startsWith("eval-")) return "normalizer";
if (input.reportPath.endsWith(".report.json")) return "normalizer";
return "unknown";
}
function normalizeTimestamp(report: Record<string, unknown>, fileMtimeMs: number): { iso: string; ms: number } {
const first = parseDateMs(report.run_timestamp);
if (first !== null) {
return { iso: new Date(first).toISOString(), ms: first };
}
const second = parseDateMs(report.timestamp);
if (second !== null) {
return { iso: new Date(second).toISOString(), ms: second };
}
return { iso: new Date(fileMtimeMs).toISOString(), ms: fileMtimeMs };
}
function rateToPercent(value: number | null): number | null {
if (value === null) return null;
if (value <= 1.2) return Math.max(0, Math.min(100, value * 100));
return Math.max(0, Math.min(100, value));
}
function scoreToPercent(value: number | null): number | null {
if (value === null) return null;
if (value <= 5.2) return Math.max(0, Math.min(100, (value / 5) * 100));
return Math.max(0, Math.min(100, value));
}
function average(values: Array<number | null>): number | null {
const filtered = values.filter((item): item is number => typeof item === "number" && Number.isFinite(item));
if (filtered.length === 0) {
return null;
}
const sum = filtered.reduce((acc, item) => acc + item, 0);
return Number((sum / filtered.length).toFixed(2));
}
function getMetricRecord(report: Record<string, unknown>): Record<string, unknown> | null {
const metrics = toRecord(report.metrics);
if (!metrics) return null;
const raw = toRecord(metrics.raw);
return raw ?? metrics;
}
function computeScoreIndex(report: Record<string, unknown>, target: AutoRunTarget): number | null {
const metrics = getMetricRecord(report);
if (!metrics) {
return null;
}
if (target === "assistant_p0") {
return average([
rateToPercent(toNumberSafe(metrics.problem_first_answer_rate)),
scoreToPercent(toNumberSafe(metrics.mechanism_coherence_score)),
rateToPercent(1 - (toNumberSafe(metrics.entity_leakage_rate) ?? 1)),
scoreToPercent(toNumberSafe(metrics.accountant_actionability_score)),
rateToPercent(toNumberSafe(metrics.route_correctness_rate)),
rateToPercent(toNumberSafe(metrics.domain_purity_rate)),
rateToPercent(toNumberSafe(metrics.limitation_honesty_rate)),
rateToPercent(toNumberSafe(metrics.top_problem_unit_match_rate))
]);
}
if (target === "assistant_stage1") {
return average([
rateToPercent(toNumberSafe(metrics.retrieval_differentiation_rate)),
rateToPercent(1 - (toNumberSafe(metrics.generic_explanation_rate) ?? 1)),
scoreToPercent(toNumberSafe(metrics.accountant_actionability_score)),
rateToPercent(1 - (toNumberSafe(metrics.false_confidence_rate) ?? 1)),
rateToPercent(1 - (toNumberSafe(metrics.broad_answer_rate) ?? 1)),
scoreToPercent(toNumberSafe(metrics.mechanism_specificity_score)),
scoreToPercent(toNumberSafe(metrics.followup_context_retention_score))
]);
}
if (target === "assistant_stage2") {
return average([
rateToPercent(toNumberSafe(metrics.problem_unit_precision)),
rateToPercent(toNumberSafe(metrics.problem_unit_recall_proxy)),
rateToPercent(toNumberSafe(metrics.duplicate_collapse_rate)),
scoreToPercent(toNumberSafe(metrics.mechanism_coherence_score)),
scoreToPercent(toNumberSafe(metrics.problem_clarity_score)),
rateToPercent(toNumberSafe(metrics.problem_first_answer_rate)),
rateToPercent(1 - (toNumberSafe(metrics.entity_leakage_rate) ?? 1))
]);
}
return average([
rateToPercent(toNumberSafe(metrics.schema_validation_pass_rate)),
rateToPercent(toNumberSafe(metrics.route_resolution_accuracy) ?? toNumberSafe(metrics.route_hint_accuracy)),
rateToPercent(toNumberSafe(metrics.execution_state_consistency_rate) ?? toNumberSafe(metrics.intent_class_accuracy)),
rateToPercent(100 - (toNumberSafe(metrics.high_confidence_error_rate) ?? 0))
]);
}
function countFailures(report: Record<string, unknown>): { blocking: number; quality: number } {
const acceptanceGate = toRecord(report.acceptance_gate);
const baselineGate = toRecord(report.baseline_stability_gate);
const blocking =
toArray(acceptanceGate?.blocking_failures).length + toArray(baselineGate?.blocking_regressions).length;
const quality =
toArray(acceptanceGate?.quality_failures).length +
toArray(baselineGate?.legacy_quality_failures).length +
toArray(baselineGate?.quality_gap_failures).length;
return { blocking, quality };
}
function caseScoreFromMetricSubscores(metricSubscores: Record<string, unknown> | null): number | null {
if (!metricSubscores) return null;
const directProduct = scoreToPercent(toNumberSafe(metricSubscores.case_product_score));
if (directProduct !== null) {
return Number(directProduct.toFixed(2));
}
const candidates: Array<number | null> = [
scoreToPercent(toNumberSafe(metricSubscores.problem_clarity_score)),
scoreToPercent(toNumberSafe(metricSubscores.mechanism_coherence_score)),
rateToPercent(toNumberSafe(metricSubscores.problem_first_answer_rate)),
rateToPercent(1 - (toNumberSafe(metricSubscores.entity_leakage_rate) ?? 1)),
scoreToPercent(toNumberSafe(metricSubscores.accountant_usefulness_score))
];
return average(candidates);
}
function isCaseClosed(input: {
checks: Record<string, unknown> | null;
scoreIndex: number | null;
}): boolean | null {
const checks = input.checks;
if (checks) {
const routeCorrect = toBooleanSafe(checks.route_correct);
const domainPure = toBooleanSafe(checks.domain_pure);
const problemFirst = toBooleanSafe(checks.problem_first_answer);
if (routeCorrect !== null || domainPure !== null || problemFirst !== null) {
if (routeCorrect === false) return false;
if (domainPure === false) return false;
if (problemFirst === false) return false;
return true;
}
}
if (typeof input.scoreIndex === "number") {
return input.scoreIndex >= 65;
}
return null;
}
function getResultCases(report: Record<string, unknown>): Array<Record<string, unknown>> {
return toArray(report.results)
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null);
}
function buildCaseSummaries(report: Record<string, unknown>, runId: string, checkDialogAvailability: boolean): CaseSummary[] {
const results = getResultCases(report);
return results.map((item, index) => {
const caseId = toStringSafe(item.case_id) ?? `case-${index + 1}`;
const checks = toRecord(item.checks);
const metricSubscores = toRecord(item.metric_subscores);
const scoreIndex =
caseScoreFromMetricSubscores(metricSubscores) ??
scoreToPercent(toNumberSafe(item.accountant_usefulness_score)) ??
null;
const closedState = isCaseClosed({ checks, scoreIndex });
const sessionId = `${runId}-${caseId}`;
const dialogAvailable = checkDialogAvailability
? fs.existsSync(path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`))
: false;
return {
case_id: caseId,
domain: toStringSafe(item.domain),
query_class: toStringSafe(item.query_class),
status: closedState === null ? "unknown" : closedState ? "closed" : "open",
score_index: scoreIndex === null ? null : Number(scoreIndex.toFixed(2)),
trace_id: toStringSafe(item.trace_id),
reply_type: toStringSafe(item.reply_type),
session_id: sessionId,
dialog_available: dialogAvailable,
checks,
metric_subscores: metricSubscores
};
});
}
function buildCoverageFromCases(cases: CaseSummary[]): RunCoverage {
const coverageByDomain = new Map<string, { total: number; closed: number }>();
let closedCases = 0;
let openCases = 0;
for (const item of cases) {
if (item.status === "closed") closedCases += 1;
if (item.status === "open") openCases += 1;
const domainKey = item.domain ?? "unknown";
const current = coverageByDomain.get(domainKey) ?? { total: 0, closed: 0 };
current.total += 1;
if (item.status === "closed") current.closed += 1;
coverageByDomain.set(domainKey, current);
}
const domainCoverage = Array.from(coverageByDomain.entries())
.map(([domain, value]) => ({
domain,
total_cases: value.total,
closed_cases: value.closed
}))
.sort((a, b) => b.total_cases - a.total_cases);
return {
closed_cases: closedCases,
open_cases: openCases,
domain_coverage: domainCoverage
};
}
function collectJsonCandidates(scanLimit: number): Array<{ path: string; mtimeMs: number }> {
const candidates: Array<{ path: string; mtimeMs: number }> = [];
const sources: Array<{ dir: string; suffix: string }> = [
{ dir: REPORTS_DIR, suffix: ".json" },
{ dir: EVAL_CASES_DIR, suffix: ".report.json" }
];
for (const source of sources) {
if (!fs.existsSync(source.dir)) continue;
const entries = fs.readdirSync(source.dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(source.suffix)) continue;
const fullPath = path.resolve(source.dir, entry.name);
try {
const stat = fs.statSync(fullPath);
candidates.push({ path: fullPath, mtimeMs: stat.mtimeMs });
} catch {
// skip broken file stat
}
}
}
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, scanLimit);
}
function indexRuns(scanLimit: number): IndexedRun[] {
const files = collectJsonCandidates(scanLimit);
const dedup = new Map<string, IndexedRun>();
for (const item of files) {
let parsed: unknown;
try {
const raw = fs.readFileSync(item.path, "utf-8");
parsed = JSON.parse(raw) as unknown;
} catch {
continue;
}
const report = toRecord(parsed);
if (!report) continue;
const runId = toStringSafe(report.run_id);
if (!runId) continue;
const evalTarget = resolveRunTarget({ report, runId, reportPath: item.path });
const normalizedTime = normalizeTimestamp(report, item.mtimeMs);
const indexed: IndexedRun = {
run_id: runId,
eval_target: evalTarget,
report_path: item.path,
report,
timestamp_iso: normalizedTime.iso,
timestamp_ms: normalizedTime.ms
};
const current = dedup.get(runId);
if (!current || indexed.timestamp_ms > current.timestamp_ms) {
dedup.set(runId, indexed);
}
}
return Array.from(dedup.values()).sort((a, b) => b.timestamp_ms - a.timestamp_ms);
}
function parseFilters(query: Record<string, unknown>): RunFilters {
const fromMs = parseDateMs(query.from);
const toMs = parseDateMs(query.to);
const targetRaw = toStringSafe(query.target)?.toLowerCase() ?? "all";
const target =
targetRaw === "normalizer" || targetRaw === "assistant_stage1" || targetRaw === "assistant_stage2" || targetRaw === "assistant_p0"
? targetRaw
: "all";
const useMock = toStringSafe(query.use_mock);
const useMockFilter = useMock === null || useMock.toLowerCase() === "any" ? null : toBooleanSafe(useMock);
const mode = toStringSafe(query.mode)?.toLowerCase() ?? "all";
const promptContains = (toStringSafe(query.prompt_contains) ?? "").toLowerCase();
const limit = clampInt(toNumberSafe(query.limit), 1, 500, 120);
const scanLimit = clampInt(toNumberSafe(query.scan_limit), 50, 5000, 900);
return {
from_ms: fromMs,
to_ms: toMs,
target,
use_mock: useMockFilter,
prompt_contains: promptContains,
mode,
limit,
scan_limit: scanLimit
};
}
function matchesFilters(run: IndexedRun, filters: RunFilters): boolean {
if (filters.from_ms !== null && run.timestamp_ms < filters.from_ms) return false;
if (filters.to_ms !== null && run.timestamp_ms > filters.to_ms) return false;
if (filters.target !== "all" && run.eval_target !== filters.target) return false;
const modeValue = (toStringSafe(run.report.mode) ?? "").toLowerCase();
if (filters.mode !== "all" && modeValue !== filters.mode) return false;
if (filters.use_mock !== null) {
const useMockValue = toBooleanSafe(run.report.use_mock);
if (useMockValue !== filters.use_mock) return false;
}
if (filters.prompt_contains.length > 0) {
const promptVersion = (toStringSafe(run.report.prompt_version) ?? "").toLowerCase();
if (!promptVersion.includes(filters.prompt_contains)) return false;
}
return true;
}
function buildRunSummary(run: IndexedRun): RunSummary {
const connection = toRecord(run.report.connection);
const normalizeConfig = toRecord(run.report.normalize_config) ?? toRecord(run.report.normalizeConfig);
const llmProvider =
toStringSafe(run.report.llm_provider) ??
toStringSafe(run.report.llmProvider) ??
toStringSafe(connection?.llm_provider) ??
toStringSafe(connection?.llmProvider) ??
toStringSafe(normalizeConfig?.llm_provider) ??
toStringSafe(normalizeConfig?.llmProvider);
const model =
toStringSafe(run.report.model) ??
toStringSafe(connection?.model) ??
toStringSafe(normalizeConfig?.model);
const cases = buildCaseSummaries(run.report, run.run_id, false);
const coverage = buildCoverageFromCases(cases);
const failures = countFailures(run.report);
return {
run_id: run.run_id,
eval_target: run.eval_target,
run_timestamp: run.timestamp_iso,
mode: toStringSafe(run.report.mode),
llm_provider: llmProvider,
model,
use_mock: toBooleanSafe(run.report.use_mock),
prompt_version: toStringSafe(run.report.prompt_version),
schema_version: toStringSafe(run.report.schema_version),
suite_id: toStringSafe(run.report.suite_id),
cases_total: toNumberSafe(run.report.cases_total) ?? cases.length,
requests_total: toNumberSafe(toRecord(run.report.budget)?.requests_total),
report_path: run.report_path,
score_index: computeScoreIndex(run.report, run.eval_target),
blocking_failures: failures.blocking,
quality_failures: failures.quality,
closed_cases: coverage.closed_cases,
open_cases: coverage.open_cases,
domain_coverage: coverage.domain_coverage
};
}
function mergeDomainCoverage(summaries: RunSummary[]): DomainCoverage[] {
const merged = new Map<string, { total: number; closed: number }>();
for (const summary of summaries) {
for (const item of summary.domain_coverage) {
const current = merged.get(item.domain) ?? { total: 0, closed: 0 };
current.total += item.total_cases;
current.closed += item.closed_cases;
merged.set(item.domain, current);
}
}
return Array.from(merged.entries())
.map(([domain, value]) => ({
domain,
total_cases: value.total,
closed_cases: value.closed
}))
.sort((a, b) => b.total_cases - a.total_cases);
}
function buildHistoryStats(summaries: RunSummary[]): HistoryStats {
const byTarget: Record<string, number> = {};
let blockingRuns = 0;
let qualityRuns = 0;
const scoreValues: number[] = [];
for (const item of summaries) {
byTarget[item.eval_target] = (byTarget[item.eval_target] ?? 0) + 1;
if (item.blocking_failures > 0) blockingRuns += 1;
if (item.quality_failures > 0) qualityRuns += 1;
if (typeof item.score_index === "number") scoreValues.push(item.score_index);
}
const latestScore = typeof summaries[0]?.score_index === "number" ? (summaries[0].score_index as number) : null;
const previousScore = typeof summaries[1]?.score_index === "number" ? (summaries[1].score_index as number) : null;
const trend: AutoRunTrend =
latestScore === null || previousScore === null
? "flat"
: latestScore > previousScore + 0.5
? "up"
: latestScore < previousScore - 0.5
? "down"
: "flat";
return {
runs_total: summaries.length,
by_target: byTarget,
blocking_runs: blockingRuns,
quality_gap_runs: qualityRuns,
avg_score_index: scoreValues.length > 0 ? Number((scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length).toFixed(2)) : null,
latest_score_index: latestScore,
previous_score_index: previousScore,
trend,
domain_coverage: mergeDomainCoverage(summaries)
};
}
function findRunById(runId: string, scanLimit = 3000): IndexedRun | null {
const indexed = indexRuns(scanLimit);
return indexed.find((item) => item.run_id === runId) ?? null;
}
function buildAssistantModeSummary(dialogRecord: Record<string, unknown> | null): Record<string, unknown> | null {
if (!dialogRecord) return null;
const conversation = toArray(dialogRecord.conversation)
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null);
const lastAssistant = [...conversation]
.reverse()
.find((item) => toStringSafe(item.role) === "assistant");
const debug = toRecord(lastAssistant?.debug);
return {
reply_type: toStringSafe(lastAssistant?.reply_type),
trace_id: toStringSafe(lastAssistant?.trace_id),
detected_mode: toStringSafe(debug?.detected_mode),
execution_lane: toStringSafe(debug?.execution_lane),
tool_gate_decision: toStringSafe(debug?.tool_gate_decision),
living_router_mode: toStringSafe(debug?.living_router_mode),
fallback_type: toStringSafe(debug?.fallback_type)
};
}
function loadSessionDialog(runId: string, caseId: string): {
source: "assistant_session";
session_id: string;
messages: Array<Record<string, unknown>>;
decomposition: string[];
assistant_mode: Record<string, unknown> | null;
} | null {
const sessionId = `${runId}-${caseId}`;
const filePath = path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`);
if (!fs.existsSync(filePath)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
} catch {
return null;
}
const record = toRecord(parsed);
if (!record) return null;
const conversation = toArray(record.conversation)
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null);
const messages = conversation.map((item) => ({
role: toStringSafe(item.role) ?? "unknown",
text: toStringSafe(item.text) ?? "",
created_at: toStringSafe(item.created_at),
trace_id: toStringSafe(item.trace_id),
reply_type: toStringSafe(item.reply_type)
}));
const turns = toArray(record.turns)
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null);
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
const humanReadable = toRecord(lastTurn?.human_readable);
const decomposition = toArray(humanReadable?.decomposition)
.map((item) => toStringSafe(item))
.filter((item): item is string => item !== null);
return {
source: "assistant_session",
session_id: sessionId,
messages,
decomposition,
assistant_mode: buildAssistantModeSummary(record)
};
}
function buildFallbackDialog(run: IndexedRun, caseId: string): {
source: "report_fallback" | "none";
session_id: string;
messages: Array<Record<string, unknown>>;
decomposition: string[];
assistant_mode: Record<string, unknown> | null;
} {
const sessionId = `${run.run_id}-${caseId}`;
const results = getResultCases(run.report);
const targetCase = results.find((item) => (toStringSafe(item.case_id) ?? "") === caseId) ?? null;
if (!targetCase) {
return {
source: "none",
session_id: sessionId,
messages: [],
decomposition: [],
assistant_mode: null
};
}
const userText =
toStringSafe(targetCase.raw_question) ??
toStringSafe(targetCase.user_query_raw) ??
`Case ${caseId}`;
const assistantSummaryParts: string[] = [];
const validationPassed = toBooleanSafe(targetCase.validation_passed);
if (validationPassed !== null) assistantSummaryParts.push(`validation_passed=${validationPassed}`);
const routeMatch = toBooleanSafe(targetCase.route_match);
if (routeMatch !== null) assistantSummaryParts.push(`route_match=${routeMatch}`);
const intentMatch = toBooleanSafe(targetCase.intent_match);
if (intentMatch !== null) assistantSummaryParts.push(`intent_match=${intentMatch}`);
const confidence = toStringSafe(targetCase.confidence_overall);
if (confidence) assistantSummaryParts.push(`confidence=${confidence}`);
const metricSubscores = toRecord(targetCase.metric_subscores);
if (metricSubscores) {
for (const [key, value] of Object.entries(metricSubscores)) {
if (toNumberSafe(value) !== null) {
assistantSummaryParts.push(`${key}=${value}`);
}
}
}
if (assistantSummaryParts.length === 0) {
assistantSummaryParts.push("No structured assistant dialog is available for this case in report artifacts.");
}
return {
source: "report_fallback",
session_id: sessionId,
messages: [
{
role: "user",
text: userText,
created_at: null,
trace_id: null,
reply_type: null
},
{
role: "assistant",
text: assistantSummaryParts.join("\n"),
created_at: null,
trace_id: toStringSafe(targetCase.trace_id),
reply_type: toStringSafe(targetCase.reply_type)
}
],
decomposition: [],
assistant_mode: null
};
}
export function buildAutoRunsRouter(): Router {
const router = Router();
router.get("/api/autoruns/history", (req, res) => {
const filters = parseFilters(req.query as Record<string, unknown>);
const indexed = indexRuns(filters.scan_limit);
const filtered = indexed.filter((run) => matchesFilters(run, filters)).slice(0, filters.limit);
const summaries = filtered.map((run) => buildRunSummary(run));
const availableTargets = Array.from(new Set(indexed.map((item) => item.eval_target))).sort();
const availableModes = Array.from(
new Set(indexed.map((item) => toStringSafe(item.report.mode)).filter((item): item is string => item !== null))
).sort();
const availablePromptVersions = Array.from(
new Set(indexed.map((item) => toStringSafe(item.report.prompt_version)).filter((item): item is string => item !== null))
).sort();
ok(res, {
ok: true,
generated_at: new Date().toISOString(),
filters_applied: {
from: filters.from_ms === null ? null : new Date(filters.from_ms).toISOString(),
to: filters.to_ms === null ? null : new Date(filters.to_ms).toISOString(),
target: filters.target,
use_mock: filters.use_mock,
prompt_contains: filters.prompt_contains,
mode: filters.mode,
limit: filters.limit,
scan_limit: filters.scan_limit
},
available: {
targets: availableTargets,
modes: availableModes,
prompt_versions: availablePromptVersions
},
items: summaries,
stats: buildHistoryStats(summaries)
});
});
router.get("/api/autoruns/history/:run_id", (req, res, next) => {
try {
const runId = String(req.params.run_id ?? "").trim();
if (!runId) {
throw new ApiError("INVALID_RUN_ID", "run_id is required", 400);
}
const run = findRunById(runId);
if (!run) {
throw new ApiError("AUTORUN_NOT_FOUND", `Run not found: ${runId}`, 404);
}
const cases = buildCaseSummaries(run.report, run.run_id, true);
const coverage = buildCoverageFromCases(cases);
ok(res, {
ok: true,
run: buildRunSummary(run),
coverage,
cases,
report: run.report
});
} catch (error) {
next(error);
}
});
router.get("/api/autoruns/history/:run_id/case/:case_id/dialog", (req, res, next) => {
try {
const runId = String(req.params.run_id ?? "").trim();
const caseId = String(req.params.case_id ?? "").trim();
if (!runId || !caseId) {
throw new ApiError("INVALID_DIALOG_REQUEST", "run_id and case_id are required", 400);
}
const run = findRunById(runId);
if (!run) {
throw new ApiError("AUTORUN_NOT_FOUND", `Run not found: ${runId}`, 404);
}
const sessionDialog = loadSessionDialog(runId, caseId);
const dialog = sessionDialog ?? buildFallbackDialog(run, caseId);
ok(res, {
ok: true,
run_id: runId,
case_id: caseId,
...dialog
});
} catch (error) {
next(error);
}
});
return router;
}

View File

@ -4,6 +4,7 @@ import express from "express";
import { PORT, PRESETS_DIR, TRACES_DIR, EVAL_CASES_DIR, REPORTS_DIR, TIMEZONE, ASSISTANT_SESSIONS_DIR } from "./config";
import { buildAccountingAgentRouter } from "./routes/accountingAgent";
import { buildAssistantRouter } from "./routes/assistant";
import { buildAutoRunsRouter } from "./routes/autoRuns";
import { buildEvalRouter } from "./routes/eval";
import { buildHistoryRouter } from "./routes/history";
import { buildNormalizeRouter } from "./routes/normalize";
@ -59,6 +60,7 @@ export function createApp(): express.Express {
app.use(buildNormalizeRouter(services));
app.use(buildEvalRouter(services));
app.use(buildAssistantRouter(services));
app.use(buildAutoRunsRouter());
app.use(buildHistoryRouter());
app.use(buildPresetsRouter());
app.use(buildAccountingAgentRouter(services));

View File

@ -499,7 +499,12 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
"Counterparty",
"Контрагент",
"Contract",
"Договор"
"Договор",
"Organization",
"Организация",
"ОрганизацияПредставление",
"organization",
"organization_name"
];
const collected: string[] = [];
@ -512,7 +517,14 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
for (const [key, rawValue] of Object.entries(row)) {
const lowerKey = key.toLowerCase();
if (lowerKey.includes("subconto") || lowerKey.includes("субконто") || lowerKey.includes("контраг") || lowerKey.includes("договор")) {
if (
lowerKey.includes("subconto") ||
lowerKey.includes("субконто") ||
lowerKey.includes("контраг") ||
lowerKey.includes("договор") ||
lowerKey.includes("organization") ||
lowerKey.includes("организац")
) {
const value = valueAsString(rawValue).trim();
if (value) {
collected.push(value);
@ -624,6 +636,15 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
}
}
if (filters.organization && String(filters.organization).trim()) {
const needle = String(filters.organization);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
}
}
if (filters.document_ref && String(filters.document_ref).trim()) {
const needle = String(filters.document_ref);
const before = filtered.length;

View File

@ -338,11 +338,16 @@ function mergeFollowupFilters(
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const previousOrganization = toNonEmptyString(previous.organization);
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to);
const allTimeRequested = hasAllTimeHint(userMessage);
const sameDateRequested = hasSameDateHint(userMessage);
if (!toNonEmptyString(merged.organization) && previousOrganization) {
merged.organization = previousOrganization;
reasons.push("organization_from_followup_context");
}
if (
intent === "list_documents_by_counterparty" ||

View File

@ -2681,6 +2681,12 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousFilters.counterparty = historicalCounterparty;
}
}
if (!toNonEmptyString(previousFilters.organization)) {
const historicalOrganization = findRecentAddressFilterValue(items, "organization");
if (historicalOrganization) {
previousFilters.organization = historicalOrganization;
}
}
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null;
}
@ -3600,7 +3606,7 @@ export function resolveAssistantOrchestrationDecision(input) {
}
function hasStrongDataIntentSignal(text) {
const lower = String(text ?? "").toLowerCase();
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account)/i.test(lower);
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower);
}
function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
@ -3612,7 +3618,7 @@ function hasDataRetrievalRequestSignal(text) {
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
return false;
}
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting)/i.test(lower);
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
if (!hasRetrievalObject) {
return false;
}
@ -3622,6 +3628,77 @@ function hasDataRetrievalRequestSignal(text) {
const hasMetaCapabilityShape = /(?:мож(?:ем|ешь|ете|но)|уме(?:ешь|ете)|доступ|подключ|чья|как\s+называ(?:ет|ется)|работ(?:ать|аем|аешь|аете)|в\s+тебе|у\s+тебя)/i.test(lower);
return !hasMetaCapabilityShape;
}
function hasOrganizationFactLookupSignal(text) {
const repaired = repairAddressMojibake(String(text ?? ""));
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
const hasFactCue = /(?:возраст|сколько\s+лет|дата\s+регистрац|когда\s+(?:зарегистр|создан|основан)|год\s+регистрац|год\s+основан|с\s+какого\s+года|when\s+was\s+(?:it\s+)?(?:registered|founded|created))/i.test(normalized);
if (!hasFactCue) {
return false;
}
return /(?:организац|компан|контор|фирм|ооо|ао|зао|ип|альтернатив|лайсвуд|райм|organization|company)/i.test(normalized);
}
function findLastAssistantLivingChatDebug(items) {
if (!Array.isArray(items)) {
return null;
}
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant") {
continue;
}
if (item.debug && typeof item.debug === "object") {
return item.debug;
}
}
return null;
}
function hasOrganizationFactFollowupSignal(userMessage, items) {
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
if (hasOrganizationFactLookupSignal(normalized)) {
return false;
}
const hasFollowupCue = /(?:^|\s)(?:давай|го|погнали|ок(?:ей)?|хорошо|принято|подтверждаю|запрашивай|запроси|проверь|продолжай|ну\s+давай|да\s+давай)(?=$|[\s,.!?;:])/iu.test(normalized);
if (!hasFollowupCue) {
return false;
}
const lastDebug = findLastAssistantLivingChatDebug(items);
const lastSource = toNonEmptyString(lastDebug?.living_chat_response_source);
const lastGuardReason = toNonEmptyString(lastDebug?.living_chat_grounding_guard_reason);
const inOrganizationFactBoundary = lastSource === "deterministic_organization_fact_boundary" ||
lastSource === "deterministic_organization_fact_boundary_followup" ||
lastGuardReason === "organization_fact_without_live_source_blocked";
return inOrganizationFactBoundary;
}
function shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization) {
const selected = normalizeOrganizationScopeValue(selectedOrganization);
if (!selected) {
return false;
}
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
if (hasOrganizationFactLookupSignal(normalized) || hasDataRetrievalRequestSignal(normalized) || hasStrongDataIntentSignal(normalized)) {
return false;
}
const hasAnalyticalCue = /(?:какой|какая|какие|когда|сколько|кто|почему|зачем|возраст|дата|регистрац|ндс|налог|контракт|договор|документ|операц|оборот|сумм|остат|сальдо|founded|registered|created)/i.test(normalized);
if (hasAnalyticalCue) {
return false;
}
const hasSelectionCue = /(?:давай|го|погнали|ок(?:ей)?|хорошо|отлично|берем|выберем|выбираем|переключ(?:им|аем|ай)|фиксир|работаем|обсудим|тогда)\b/i.test(normalized);
if (hasSelectionCue) {
return true;
}
return normalized.length <= 36 && !/[?]/.test(String(userMessage ?? ""));
}
function hasOperationalAdminActionRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
@ -3819,6 +3896,319 @@ function normalizeScopeLabel(value) {
function normalizeScopeKey(value) {
return repairAddressMojibake(String(value ?? "")).toLowerCase().replace(/ё/g, "е");
}
const ORGANIZATION_SCOPE_STOPWORDS = new Set([
"ооо",
"ao",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"организация",
"организации",
"контора",
"конторы",
"фирма",
"фирмы",
"по",
"для",
"над",
"под",
"без",
"с",
"со",
"в",
"во",
"на",
"и",
"или",
"а",
"но",
"не",
"мы",
"нам",
"наш",
"наша",
"наше",
"наши",
"ты",
"тебе",
"твой",
"сейчас",
"щас",
"тут",
"вот",
"давай",
"го",
"погнали",
"тогда",
"обсудим",
"обсуждать",
"работать",
"работаем",
"работаешь",
"работаете",
"можем",
"можно",
"какая",
"какой",
"какие",
"чья",
"чье",
"чьи"
]);
function normalizeOrganizationScopeValue(value) {
const normalized = normalizeScopeLabel(value);
if (!normalized) {
return null;
}
const unwrapped = normalized
.replace(/^\\+|\\+$/g, "")
.replace(/^"+|"+$/g, "")
.replace(/^'+|'+$/g, "")
.trim();
return unwrapped ? unwrapped : null;
}
function normalizeOrganizationScopeSearchText(value) {
const source = normalizeScopeKey(value);
return source
.replace(/[^a-zа-я0-9]+/giu, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeOrganizationScope(value) {
const normalized = normalizeOrganizationScopeSearchText(value);
if (!normalized) {
return [];
}
return normalized
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !ORGANIZATION_SCOPE_STOPWORDS.has(token));
}
function organizationTokenVariants(token) {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set([source]);
const withoutLongEnding = source.replace(/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой)$/iu, "");
if (withoutLongEnding.length >= 4) {
variants.add(withoutLongEnding);
}
const withoutShortEnding = source.replace(/[аеёиоуыэюя]$/iu, "");
if (withoutShortEnding.length >= 4) {
variants.add(withoutShortEnding);
}
return Array.from(variants);
}
function scoreOrganizationMentionInMessage(message, organization) {
const messageNorm = normalizeOrganizationScopeSearchText(message);
const organizationNorm = normalizeOrganizationScopeSearchText(organization);
if (!messageNorm || !organizationNorm) {
return 0;
}
if (messageNorm.includes(organizationNorm)) {
return 10_000 + organizationNorm.length;
}
const organizationTokens = tokenizeOrganizationScope(organizationNorm);
if (organizationTokens.length === 0) {
return 0;
}
const messageTokens = tokenizeOrganizationScope(messageNorm);
if (messageTokens.length === 0) {
return 0;
}
let matchedTokens = 0;
let score = 0;
for (const token of organizationTokens) {
const variants = organizationTokenVariants(token);
let matched = false;
let variantScore = 0;
for (const variant of variants) {
if (!variant) {
continue;
}
if (messageNorm.includes(variant)) {
matched = true;
variantScore = Math.max(variantScore, variant.length * 5);
continue;
}
const fuzzyMatched = messageTokens.some((messageToken) => {
if (messageToken === variant) {
return true;
}
if (messageToken.length >= 5 && variant.length >= 5) {
return messageToken.startsWith(variant) || variant.startsWith(messageToken);
}
return false;
});
if (fuzzyMatched) {
matched = true;
variantScore = Math.max(variantScore, Math.max(20, variant.length * 3));
}
}
if (matched) {
matchedTokens += 1;
score += variantScore > 0 ? variantScore : 10;
}
}
if (matchedTokens === 0) {
return 0;
}
if (matchedTokens === organizationTokens.length) {
score += 400;
} else {
score += matchedTokens * 50;
}
return score;
}
function parseOrganizationsFromDataScopeAssistantText(text) {
const source = repairAddressMojibake(String(text ?? ""));
if (!source) {
return [];
}
const extracted = [];
const singleMatch = source.match(/доступна\s+организация:\s*([^.\n]+)/iu);
if (singleMatch) {
const value = normalizeOrganizationScopeValue(singleMatch[1]);
if (value) {
extracted.push(value);
}
}
const multiMatch = source.match(/доступны\s+организац(?:ии|ия)\s*(?:\(\d+\))?:\s*([^.\n]+)/iu);
if (multiMatch) {
const parts = String(multiMatch[1] ?? "")
.split(",")
.map((item) => normalizeOrganizationScopeValue(item))
.filter(Boolean);
extracted.push(...parts);
}
return Array.from(new Set(extracted));
}
function mergeKnownOrganizations(values) {
const dedup = new Map();
for (const raw of Array.isArray(values) ? values : []) {
const normalized = normalizeOrganizationScopeValue(raw);
if (!normalized) {
continue;
}
const key = normalizeOrganizationScopeSearchText(normalized);
if (!key) {
continue;
}
if (!dedup.has(key)) {
dedup.set(key, normalized);
}
}
return Array.from(dedup.values()).slice(0, 20);
}
function extractKnownOrganizationsFromHistory(items) {
const collected = [];
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant") {
continue;
}
const debug = item.debug && typeof item.debug === "object" ? item.debug : null;
if (debug) {
const directFromProbe = Array.isArray(debug.living_chat_data_scope_probe_organizations)
? debug.living_chat_data_scope_probe_organizations
: [];
const knownFromDebug = Array.isArray(debug.assistant_known_organizations)
? debug.assistant_known_organizations
: [];
if (directFromProbe.length > 0 || knownFromDebug.length > 0) {
collected.push(...directFromProbe, ...knownFromDebug);
}
}
const parsedFromText = parseOrganizationsFromDataScopeAssistantText(item.text);
if (parsedFromText.length > 0) {
collected.push(...parsedFromText);
}
if (collected.length >= 20) {
break;
}
}
return mergeKnownOrganizations(collected);
}
function findLastAssistantActiveOrganization(items) {
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
const direct = normalizeOrganizationScopeValue(item.debug.assistant_active_organization);
if (direct) {
return direct;
}
const selected = normalizeOrganizationScopeValue(item.debug.living_chat_selected_organization);
if (selected) {
return selected;
}
}
return null;
}
function resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations) {
const known = mergeKnownOrganizations(knownOrganizations);
if (!userMessage || known.length === 0) {
return null;
}
const messageNorm = normalizeOrganizationScopeSearchText(userMessage);
if (!messageNorm) {
return null;
}
const scored = known
.map((organization) => ({
organization,
score: scoreOrganizationMentionInMessage(messageNorm, organization)
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.organization.length - b.organization.length);
if (scored.length === 0) {
return null;
}
const best = scored[0];
const second = scored[1];
if (best.score < 90) {
return null;
}
if (second && second.score === best.score) {
return null;
}
return best.organization;
}
function resolveSessionOrganizationScopeContext(userMessage, items) {
const knownOrganizations = extractKnownOrganizationsFromHistory(items);
const selectedOrganization = resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations);
const lastActiveOrganization = findLastAssistantActiveOrganization(items);
const activeOrganization = selectedOrganization ?? normalizeOrganizationScopeValue(lastActiveOrganization);
return {
knownOrganizations,
selectedOrganization,
activeOrganization
};
}
function mergeFollowupContextWithOrganizationScope(followupContext, organization) {
const normalizedOrganization = normalizeOrganizationScopeValue(organization);
const base = followupContext && typeof followupContext === "object" ? { ...followupContext } : {};
if (!normalizedOrganization) {
return followupContext && typeof followupContext === "object" ? base : null;
}
const previousFilters = base.previous_filters && typeof base.previous_filters === "object"
? { ...base.previous_filters }
: {};
if (!toNonEmptyString(previousFilters.organization)) {
previousFilters.organization = normalizedOrganization;
}
base.previous_filters = previousFilters;
return base;
}
export function resolveSessionOrganizationScopeContextForTests(userMessage, items) {
return resolveSessionOrganizationScopeContext(userMessage, items);
}
function normalizeGuidValue(value) {
const source = normalizeScopeLabel(value);
if (!source) {
@ -4193,6 +4583,26 @@ function buildAssistantDataScopeContractReply(scopeProbe = null) {
"Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
].join(" ");
}
function buildAssistantDataScopeSelectionReply(organization) {
const selected = normalizeOrganizationScopeValue(organization) ?? String(organization ?? "").trim();
return [
`Отлично, фиксирую рабочую организацию: ${selected}.`,
"Дальше буду держать этот контур как активный, пока вы не переключите организацию."
].join(" ");
}
function buildAssistantOrganizationFactBoundaryReply(organization) {
const selected = normalizeOrganizationScopeValue(organization) ?? String(organization ?? "").trim();
if (selected) {
return [
`По организации ${selected} не буду называть дату/возраст без live-подтвержденного источника.`,
"Если нужно, запрошу факт из 1С и верну только подтвержденный ответ."
].join(" ");
}
return [
"Не буду называть дату/возраст организации без live-подтвержденного источника.",
"Сначала получу факт из 1С, потом дам точный ответ."
].join(" ");
}
function buildAssistantOperationalBoundaryReply() {
return [
"Понимаю, что ситуация срочная.",
@ -4256,6 +4666,45 @@ function applyLivingChatScriptGuard(chatText, userMessage) {
reason: "unexpected_cjk_fragment_fallback"
};
}
function applyLivingChatGroundingGuard(input) {
const userMessage = String(input?.userMessage ?? "");
const chatText = String(input?.chatText ?? "").trim();
const organization = toNonEmptyString(input?.organization);
if (!chatText) {
return {
text: chatText,
applied: false,
reason: null
};
}
if (!hasOrganizationFactLookupSignal(userMessage)) {
return {
text: chatText,
applied: false,
reason: null
};
}
if (/(?:не\s+могу|не\s+вижу|после\s+проверки|live|подтвержден)/i.test(chatText)) {
return {
text: chatText,
applied: false,
reason: null
};
}
const hasSpecificUnverifiedFact = /(?:\b\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})\b|\b(?:19|20)\d{2}\b|\b\d+\s+лет\b)/i.test(chatText);
if (!hasSpecificUnverifiedFact) {
return {
text: chatText,
applied: false,
reason: null
};
}
return {
text: buildAssistantOrganizationFactBoundaryReply(organization),
applied: true,
reason: "organization_fact_without_live_source_blocked"
};
}
export function resolveLivingAssistantModeDecision(input) {
const userMessage = String(input?.userMessage ?? "");
if (input?.addressLaneTriggered) {
@ -4334,7 +4783,9 @@ export class AssistantService {
async handleMessage(payload) {
const session = this.sessions.ensureSession(payload.session_id);
const sessionId = session.session_id;
const userMessage = String(payload.user_message ?? payload.message ?? "").trim();
const userMessageRaw = String(payload.user_message ?? payload.message ?? "").trim();
const repairedUserMessage = compactWhitespace(repairAddressMojibake(userMessageRaw));
const userMessage = repairedUserMessage || userMessageRaw;
const userItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
@ -4346,6 +4797,7 @@ export class AssistantService {
debug: null
};
this.sessions.appendItem(sessionId, userItem);
const sessionOrganizationScope = resolveSessionOrganizationScopeContext(userMessage, session.items);
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const safeAddressReply = sanitizeOutgoingAssistantText(addressLane.reply_text);
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
@ -4353,6 +4805,15 @@ export class AssistantService {
if (followupOffer) {
debug.address_followup_offer = followupOffer;
}
const debugKnownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations);
const debugActiveOrganization = toNonEmptyString(debug?.extracted_filters?.organization) ??
toNonEmptyString(sessionOrganizationScope.activeOrganization);
if (debugKnownOrganizations.length > 0) {
debug.assistant_known_organizations = debugKnownOrganizations;
}
if (debugActiveOrganization) {
debug.assistant_active_organization = debugActiveOrganization;
}
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
@ -4458,6 +4919,11 @@ export class AssistantService {
let livingChatSource = "llm_chat";
let livingChatScriptGuardApplied = false;
let livingChatScriptGuardReason = null;
let livingChatGroundingGuardApplied = false;
let livingChatGroundingGuardReason = null;
let knownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations);
let selectedOrganization = toNonEmptyString(sessionOrganizationScope.selectedOrganization);
let activeOrganization = toNonEmptyString(sessionOrganizationScope.activeOrganization);
if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) {
chatText = buildAssistantSafetyRefusalReply();
livingChatSource = "deterministic_safety_refusal";
@ -4465,10 +4931,35 @@ export class AssistantService {
else if (dataScopeMetaQuery) {
dataScopeProbe = await resolveAssistantDataScopeProbe();
chatText = buildAssistantDataScopeContractReply(dataScopeProbe);
knownOrganizations = mergeKnownOrganizations([
...knownOrganizations,
...(Array.isArray(dataScopeProbe?.organizations) ? dataScopeProbe.organizations : [])
]);
if (!activeOrganization && knownOrganizations.length === 1) {
activeOrganization = knownOrganizations[0];
}
livingChatSource = dataScopeProbe?.status === "resolved"
? "deterministic_data_scope_contract_live"
: "deterministic_data_scope_contract";
}
else if ((selectedOrganization || activeOrganization) && hasOrganizationFactLookupSignal(userMessage)) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization);
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_organization_fact_boundary";
}
else if ((selectedOrganization || activeOrganization) && hasOrganizationFactFollowupSignal(userMessage, session.items)) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization);
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_organization_fact_boundary_followup";
}
else if (!capabilityMetaQuery && shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization ?? activeOrganization)) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = buildAssistantDataScopeSelectionReply(scopedOrganization);
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_data_scope_selection_contract";
}
else if (capabilityMetaQuery && operationalSignal && !hasAssistantCapabilityQuestionSignal(userMessage)) {
chatText = buildAssistantOperationalBoundaryReply();
livingChatSource = "deterministic_operational_boundary";
@ -4508,6 +4999,17 @@ export class AssistantService {
livingChatScriptGuardReason = scriptGuard.reason;
livingChatSource = "llm_chat_script_guard";
}
const groundingGuard = applyLivingChatGroundingGuard({
userMessage,
chatText,
organization: activeOrganization ?? selectedOrganization ?? null
});
chatText = groundingGuard.text;
if (groundingGuard.applied) {
livingChatGroundingGuardApplied = true;
livingChatGroundingGuardReason = groundingGuard.reason;
livingChatSource = "llm_chat_grounding_guard";
}
}
if (!chatText) {
return null;
@ -4525,12 +5027,20 @@ export class AssistantService {
living_chat_response_source: livingChatSource,
living_chat_script_guard_applied: livingChatScriptGuardApplied,
living_chat_script_guard_reason: livingChatScriptGuardReason,
living_chat_grounding_guard_applied: livingChatGroundingGuardApplied,
living_chat_grounding_guard_reason: livingChatGroundingGuardReason,
living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null,
living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null,
living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations)
? dataScopeProbe.organizations.length
: 0,
living_chat_data_scope_probe_organizations: Array.isArray(dataScopeProbe?.organizations)
? mergeKnownOrganizations(dataScopeProbe.organizations)
: [],
living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null,
living_chat_selected_organization: selectedOrganization ?? null,
assistant_known_organizations: knownOrganizations,
assistant_active_organization: activeOrganization ?? null,
address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted),
address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied),
address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
@ -4716,9 +5226,10 @@ export class AssistantService {
};
};
const runAddressLaneAttempt = async (messageUsed, carryMeta) => {
if (carryMeta?.followupContext) {
const scopedFollowupContext = mergeFollowupContextWithOrganizationScope(carryMeta?.followupContext ?? null, sessionOrganizationScope.activeOrganization);
if (scopedFollowupContext) {
return this.addressQueryService.tryHandle(messageUsed, {
followupContext: carryMeta.followupContext
followupContext: scopedFollowupContext
});
}
return this.addressQueryService.tryHandle(messageUsed);

View File

@ -2696,6 +2696,24 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("address_followup_context_applied");
});
it("inherits organization scope from follow-up context when organization is omitted in user text", () => {
const result = runAddressDecomposeStage("покажи документы по свк за 2020", {
previous_intent: "list_documents_by_counterparty",
previous_filters: {
organization: "ООО Альтернатива Плюс",
counterparty: "свк",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "counterparty",
previous_anchor_value: "свк"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("list_documents_by_counterparty");
expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс");
expect(result?.baseReasons).toContain("organization_from_followup_context");
});
it("inherits as_of_date from previous period for same-date balance follow-up", () => {
const result = runAddressDecomposeStage("а по счету 60.01 на ту же дату", {
previous_intent: "list_documents_by_counterparty",

View File

@ -672,4 +672,61 @@ describe("assistant address follow-up carryover", () => {
expect(String(calls[0].message).toLowerCase()).toContain("свк");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
});
it("passes active organization scope into address lane follow-up context", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
return buildAddressLaneResult();
})
} 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-org-scope-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-seed-org",
session_id: sessionId,
role: "assistant",
text: "Data scope organizations are available.",
reply_type: "factual_with_explanation",
created_at: new Date().toISOString(),
trace_id: "chat-org-seed",
debug: {
trace_id: "chat-org-seed",
living_chat_data_scope_probe_status: "resolved",
living_chat_data_scope_probe_organizations: ["Alternative Plus LLC", "Lacewood LLC", "RIME"],
assistant_active_organization: "Alternative Plus LLC"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "show docs by svk for 2020",
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls.length).toBeGreaterThan(0);
const scopedCall = calls.find((entry) => Boolean(entry.options?.followupContext?.previous_filters?.organization));
expect(scopedCall).toBeTruthy();
expect(scopedCall?.options?.followupContext?.previous_filters?.organization).toBe("Alternative Plus LLC");
});
});

View File

@ -1,8 +1,201 @@
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantService, resolveSessionOrganizationScopeContextForTests } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
describe("assistant living chat mode", () => {
it("resolves active organization from slang mention using previously discovered organization list", () => {
const items = [
{
role: "assistant",
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
debug: {
trace_id: "chat-org-scope",
living_chat_data_scope_probe_status: "resolved",
living_chat_data_scope_probe_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"]
}
} as any
];
const context = resolveSessionOrganizationScopeContextForTests("га альтернативу тогда обсудим", items as any);
expect(context.knownOrganizations).toContain("ООО Альтернатива Плюс");
expect(context.selectedOrganization).toBe("ООО Альтернатива Плюс");
expect(context.activeOrganization).toBe("ООО Альтернатива Плюс");
});
it("repairs mojibake user text before persisting conversation", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-mojibake-user",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-mojibake-user" },
outputText: "Привет. Я на связи.",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-mojibake-user",
user_message: "че там",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.conversation?.[0]?.role).toBe("user");
expect(response.conversation?.[0]?.text).toBe("че там");
expect(chatClient.chat).toHaveBeenCalledTimes(1);
});
it("does not treat organization fact lookup as scope-selection confirmation", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-org-fact-boundary",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const sessionId = "asst-living-chat-org-fact-boundary";
sessions.ensureSession(sessionId);
sessions.appendItem(sessionId, {
message_id: "msg-seed-org-scope",
session_id: sessionId,
role: "assistant",
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
reply_type: "factual_with_explanation",
created_at: new Date().toISOString(),
trace_id: "chat-seed-org-scope",
debug: {
living_chat_data_scope_probe_status: "resolved",
living_chat_data_scope_probe_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
assistant_known_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
assistant_active_organization: "ООО Альтернатива Плюс"
}
} as any);
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-should-not-run-org-fact-boundary" },
outputText: "Для ООО Альтернатива Плюс дата регистрации 01.07.2015, возраст 8 лет.",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "какой возраст у альтернативы?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.living_chat_response_source).toBe("deterministic_organization_fact_boundary");
expect(String(response.assistant_reply).toLowerCase()).toContain("не буду называть");
expect(String(response.assistant_reply)).not.toContain("01.07.2015");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("keeps organization fact follow-up in deterministic boundary mode on short confirmation", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-org-fact-followup",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const sessionId = "asst-living-chat-org-fact-followup";
sessions.ensureSession(sessionId);
sessions.appendItem(sessionId, {
message_id: "msg-seed-org-fact-boundary",
session_id: sessionId,
role: "assistant",
text: "По организации ООО Альтернатива Плюс не буду называть дату/возраст без live-подтвержденного источника.",
reply_type: "factual_with_explanation",
created_at: new Date().toISOString(),
trace_id: "chat-seed-org-fact-boundary",
debug: {
living_chat_response_source: "deterministic_organization_fact_boundary",
assistant_known_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
assistant_active_organization: "ООО Альтернатива Плюс"
}
} as any);
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-should-not-run-org-fact-followup" },
outputText: "Дата регистрации: 2005-12-08",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "давай",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.living_chat_response_source).toBe("deterministic_organization_fact_boundary_followup");
expect(String(response.assistant_reply).toLowerCase()).toContain("не буду называть");
expect(String(response.assistant_reply)).not.toContain("2005-12-08");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("handles casual greeting in chat mode without deep-pipeline pass", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-BFy6DcyX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ch7jCAii.css">
<script type="module" crossorigin src="/assets/index-D6Y_lHrc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BMWPMdQA.css">
</head>
<body>
<div id="root"></div>

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { apiClient } from "./api/client";
import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel";
import { AssistantPanel } from "./components/AssistantPanel";
import { ConnectionPanel } from "./components/ConnectionPanel";
import { HistoryPanel } from "./components/HistoryPanel";
@ -22,7 +23,7 @@ import type {
} from "./state/types";
const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
const ASSISTANT_STAGES = ["Разбираю запрос", "Ищу данные", "Собираю ответ"];
const ASSISTANT_STAGES = ["Analyzing request", "Fetching data", "Composing answer"];
const DEFAULT_UI_MODE: UiMode = "assistant";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
@ -508,12 +509,12 @@ export default function App() {
});
setAssistantSessionId(response.session_id);
setAssistantConversation(response.conversation);
setAssistantStatus("Ответ готов");
setAssistantStatus("Reply is ready");
log(`Assistant reply received: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantError(message);
setAssistantStatus("Ошибка ассистента");
setAssistantStatus("Assistant error");
log(`Assistant error: ${message}`);
} finally {
stopTicker();
@ -536,15 +537,18 @@ export default function App() {
<main className="app-root">
<div className="hero">
<h1>NDC AI First Layer</h1>
<p>Два режима в одном интерфейсе: диагностика декомпозиции и диалоговый ассистент на общем backend-контуре.</p>
<p>Three modes in one UI: assistant, decomposition diagnostics, and auto-run history with regression visibility.</p>
</div>
<div className="mode-switch-row">
<button type="button" className={uiMode === "assistant" ? "tab active" : "tab"} onClick={() => setUiMode("assistant")}>
Ассистент
Assistant
</button>
<button type="button" className={uiMode === "decomposition" ? "tab active" : "tab"} onClick={() => setUiMode("decomposition")}>
Декомпозиция
Decomposition
</button>
<button type="button" className={uiMode === "autoruns" ? "tab active" : "tab"} onClick={() => setUiMode("autoruns")}>
AutoRun History
</button>
</div>
@ -595,7 +599,7 @@ export default function App() {
errorMessage={assistantError}
/>
</div>
) : (
) : uiMode === "decomposition" ? (
<div className="layout-grid">
<ConnectionPanel
value={connection}
@ -655,7 +659,18 @@ export default function App() {
evalReport={evalReport}
/>
</div>
) : (
<div className="layout-grid">
<AutoRunsHistoryPanel
connection={connection}
prompts={prompts}
assistantPromptVersion={ASSISTANT_PROMPT_VERSION}
decompositionPromptVersion={AUTOLOAD_PROMPT_VERSION}
onLog={log}
/>
</div>
)}
</main>
);
}

View File

@ -1,4 +1,7 @@
import type {
AutoRunDetailResponse,
AutoRunDialogResponse,
AutoRunHistoryResponse,
AssistantMessageResultState,
AssistantConversationItem,
ConnectionState,
@ -247,5 +250,36 @@ export const apiClient = {
async loadAssistantSession(sessionId: string): Promise<{ ok: boolean; session: { items: AssistantConversationItem[] } }> {
return request(`/assistant/session/${sessionId}`);
},
async loadAutoRunsHistory(input?: {
from?: string;
to?: string;
target?: string;
mode?: string;
use_mock?: "any" | "true" | "false";
prompt_contains?: string;
limit?: number;
scan_limit?: number;
}): Promise<AutoRunHistoryResponse> {
const params = new URLSearchParams();
if (input?.from) params.set("from", input.from);
if (input?.to) params.set("to", input.to);
if (input?.target) params.set("target", input.target);
if (input?.mode) params.set("mode", input.mode);
if (input?.use_mock) params.set("use_mock", input.use_mock);
if (input?.prompt_contains) params.set("prompt_contains", input.prompt_contains);
if (typeof input?.limit === "number") params.set("limit", String(input.limit));
if (typeof input?.scan_limit === "number") params.set("scan_limit", String(input.scan_limit));
const query = params.toString();
return request(`/autoruns/history${query ? `?${query}` : ""}`);
},
async loadAutoRunDetail(runId: string): Promise<AutoRunDetailResponse> {
return request(`/autoruns/history/${encodeURIComponent(runId)}`);
},
async loadAutoRunCaseDialog(runId: string, caseId: string): Promise<AutoRunDialogResponse> {
return request(`/autoruns/history/${encodeURIComponent(runId)}/case/${encodeURIComponent(caseId)}/dialog`);
}
};

View File

@ -0,0 +1,664 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiClient } from "../api/client";
import type {
AutoRunCaseSummary,
AutoRunDetailResponse,
AutoRunDialogResponse,
AutoRunDomainCoverage,
AutoRunHistoryResponse,
AutoRunSummary,
ConnectionState,
PromptState
} from "../state/types";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
interface AutoRunsHistoryPanelProps {
connection: ConnectionState;
prompts: PromptState;
assistantPromptVersion: string;
decompositionPromptVersion: string;
onLog?: (message: string) => void;
}
type UseMockFilter = "any" | "true" | "false";
interface AutoRunsFilters {
fromLocal: string;
toLocal: string;
target: string;
mode: string;
useMock: UseMockFilter;
promptContains: string;
limit: number;
}
const DEFAULT_FILTERS: AutoRunsFilters = {
fromLocal: "",
toLocal: "",
target: "all",
mode: "all",
useMock: "any",
promptContains: "",
limit: 120
};
function dateToInputValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:${minute}`;
}
function defaultFromDateValue(): string {
const date = new Date();
date.setDate(date.getDate() - 14);
return dateToInputValue(date);
}
function localInputToIso(value: string): string | undefined {
if (!value.trim()) return undefined;
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return undefined;
return new Date(parsed).toISOString();
}
function formatDateTime(iso: string): string {
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return iso;
return new Date(parsed).toLocaleString("ru-RU");
}
function toPercent(closed: number, total: number): number {
if (total <= 0) return 0;
return Math.max(0, Math.min(100, Number(((closed / total) * 100).toFixed(1))));
}
function formatScore(value: number | null): string {
if (typeof value !== "number") return "n/a";
return `${value.toFixed(1)}%`;
}
function formatShortTarget(value: string): string {
if (value === "assistant_stage1") return "assistant/s1";
if (value === "assistant_stage2") return "assistant/s2";
if (value === "assistant_p0") return "assistant/p0";
return value;
}
function trendLabel(value: "up" | "down" | "flat"): string {
if (value === "up") return "Рост";
if (value === "down") return "Регресс";
return "Без изменений";
}
function getSelectedCase(cases: AutoRunCaseSummary[], caseId: string): AutoRunCaseSummary | null {
return cases.find((item) => item.case_id === caseId) ?? null;
}
function renderCoverageRows(items: AutoRunDomainCoverage[]) {
if (items.length === 0) {
return <p className="muted">Покрытие доменов пока не сформировано.</p>;
}
return (
<div className="autoruns-coverage-list">
{items.map((item) => {
const percent = toPercent(item.closed_cases, item.total_cases);
return (
<div key={item.domain} className="autoruns-coverage-item">
<div className="autoruns-coverage-head">
<strong>{item.domain}</strong>
<span>
{item.closed_cases}/{item.total_cases} ({percent}%)
</span>
</div>
<div className="autoruns-coverage-bar">
<div style={{ width: `${percent}%` }} />
</div>
</div>
);
})}
</div>
);
}
export function AutoRunsHistoryPanel({
connection,
prompts,
assistantPromptVersion,
decompositionPromptVersion,
onLog
}: AutoRunsHistoryPanelProps) {
const [filters, setFilters] = useState<AutoRunsFilters>({
...DEFAULT_FILTERS,
fromLocal: defaultFromDateValue()
});
const [history, setHistory] = useState<AutoRunHistoryResponse | null>(null);
const [runDetail, setRunDetail] = useState<AutoRunDetailResponse | null>(null);
const [dialog, setDialog] = useState<AutoRunDialogResponse | null>(null);
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState("");
const [historyBusy, setHistoryBusy] = useState(false);
const [detailBusy, setDetailBusy] = useState(false);
const [dialogBusy, setDialogBusy] = useState(false);
const [errorText, setErrorText] = useState("");
const [showAssistantMode, setShowAssistantMode] = useState(true);
const [showDecompositionMode, setShowDecompositionMode] = useState(true);
const [showProgressMode, setShowProgressMode] = useState(true);
const activeRunSummary: AutoRunSummary | null =
history?.items.find((item) => item.run_id === selectedRunId) ?? null;
const activeCase = runDetail ? getSelectedCase(runDetail.cases, selectedCaseId) : null;
const log = useCallback(
(message: string) => {
onLog?.(`[autoruns] ${message}`);
},
[onLog]
);
const loadCaseDialog = useCallback(
async (runId: string, caseId: string) => {
setDialogBusy(true);
try {
const payload = await apiClient.loadAutoRunCaseDialog(runId, caseId);
setDialog(payload);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Диалог кейса: ${message}`);
log(`Dialog load error for ${runId}/${caseId}: ${message}`);
setDialog(null);
} finally {
setDialogBusy(false);
}
},
[log]
);
const loadRunDetail = useCallback(
async (runId: string, preferredCaseId?: string) => {
setDetailBusy(true);
try {
const payload = await apiClient.loadAutoRunDetail(runId);
setRunDetail(payload);
const nextCaseId =
(preferredCaseId && payload.cases.some((item) => item.case_id === preferredCaseId) ? preferredCaseId : "") ||
payload.cases[0]?.case_id ||
"";
setSelectedCaseId(nextCaseId);
if (nextCaseId) {
await loadCaseDialog(runId, nextCaseId);
} else {
setDialog(null);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Детализация прогона: ${message}`);
log(`Run detail load error for ${runId}: ${message}`);
setRunDetail(null);
setDialog(null);
} finally {
setDetailBusy(false);
}
},
[loadCaseDialog, log]
);
const loadHistory = useCallback(
async (options?: { keepSelection?: boolean; preferredRunId?: string; preferredCaseId?: string }) => {
setHistoryBusy(true);
setErrorText("");
try {
const payload = await apiClient.loadAutoRunsHistory({
from: localInputToIso(filters.fromLocal),
to: localInputToIso(filters.toLocal),
target: filters.target,
mode: filters.mode,
use_mock: filters.useMock,
prompt_contains: filters.promptContains.trim() || undefined,
limit: filters.limit
});
setHistory(payload);
const hasRuns = payload.items.length > 0;
if (!hasRuns) {
setSelectedRunId("");
setSelectedCaseId("");
setRunDetail(null);
setDialog(null);
return;
}
const keepSelection = options?.keepSelection ?? true;
const preferredRunId = options?.preferredRunId ?? "";
const preferredCaseId = options?.preferredCaseId ?? "";
const nextRunId =
keepSelection && preferredRunId && payload.items.some((item) => item.run_id === preferredRunId)
? preferredRunId
: payload.items[0].run_id;
setSelectedRunId(nextRunId);
await loadRunDetail(nextRunId, keepSelection ? preferredCaseId : undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`История прогонов: ${message}`);
log(`History load error: ${message}`);
} finally {
setHistoryBusy(false);
}
},
[filters.fromLocal, filters.limit, filters.mode, filters.promptContains, filters.target, filters.toLocal, filters.useMock, loadRunDetail, log]
);
useEffect(() => {
void loadHistory({ keepSelection: false });
}, [loadHistory]);
const dynamicColumns = useMemo(() => {
const columns = ["minmax(290px, 340px)", "minmax(300px, 360px)", "minmax(420px, 1fr)"];
if (showAssistantMode) columns.push("minmax(280px, 320px)");
if (showDecompositionMode) columns.push("minmax(280px, 320px)");
if (showProgressMode) columns.push("minmax(280px, 320px)");
return columns.join(" ");
}, [showAssistantMode, showDecompositionMode, showProgressMode]);
return (
<PanelFrame
title="История автопрогонов"
subtitle="Центральный экран диагностики: фильтры, список прогонов, диалог по кейсу, режимы ассистента/декомпозиции и тренд качества."
actions={
<div className="assistant-panel-actions">
<button type="button" className={showAssistantMode ? "tab active" : "tab"} onClick={() => setShowAssistantMode((prev) => !prev)}>
Режим ассистента
</button>
<button
type="button"
className={showDecompositionMode ? "tab active" : "tab"}
onClick={() => setShowDecompositionMode((prev) => !prev)}
>
Режим декомпозиции
</button>
<button type="button" className={showProgressMode ? "tab active" : "tab"} onClick={() => setShowProgressMode((prev) => !prev)}>
Прогресс/регресс
</button>
</div>
}
>
<div className="autoruns-columns" style={{ gridTemplateColumns: dynamicColumns }}>
<section className="autoruns-col">
<h3>Настройки выборки</h3>
<div className="autoruns-form-grid">
<label>
Дата с
<input
type="datetime-local"
value={filters.fromLocal}
onChange={(event) => setFilters((prev) => ({ ...prev, fromLocal: event.target.value }))}
/>
</label>
<label>
Дата по
<input type="datetime-local" value={filters.toLocal} onChange={(event) => setFilters((prev) => ({ ...prev, toLocal: event.target.value }))} />
</label>
<label>
Целевой контур
<select value={filters.target} onChange={(event) => setFilters((prev) => ({ ...prev, target: event.target.value }))}>
<option value="all">all</option>
{(history?.available.targets ?? []).map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label>
Режим
<select value={filters.mode} onChange={(event) => setFilters((prev) => ({ ...prev, mode: event.target.value }))}>
<option value="all">all</option>
{(history?.available.modes ?? []).map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label>
use_mock
<select value={filters.useMock} onChange={(event) => setFilters((prev) => ({ ...prev, useMock: event.target.value as UseMockFilter }))}>
<option value="any">any</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label>
Лимит
<input
type="number"
min={1}
max={500}
value={filters.limit}
onChange={(event) => setFilters((prev) => ({ ...prev, limit: Number(event.target.value || 120) }))}
/>
</label>
<label className="full-width">
Версия промпта содержит
<input
value={filters.promptContains}
onChange={(event) => setFilters((prev) => ({ ...prev, promptContains: event.target.value }))}
placeholder="normalizer_v2_0_2 / address_query_runtime_v1"
list="autoruns-prompt-versions"
/>
</label>
</div>
<datalist id="autoruns-prompt-versions">
{(history?.available.prompt_versions ?? []).map((item) => (
<option key={item} value={item} />
))}
</datalist>
<div className="button-row">
<button type="button" disabled={historyBusy} onClick={() => void loadHistory({ keepSelection: false })}>
{historyBusy ? "Обновляю..." : "Применить"}
</button>
<button
type="button"
className="tab"
onClick={() => {
setFilters({
...DEFAULT_FILTERS,
fromLocal: defaultFromDateValue()
});
setErrorText("");
}}
>
Сбросить фильтры
</button>
</div>
<h4>Контур генерации</h4>
<div className="autoruns-meta-list">
<div>
<span>Провайдер:</span>
<strong>{connection.llmProvider}</strong>
</div>
<div>
<span>Модель:</span>
<strong>{connection.model || "n/a"}</strong>
</div>
<div>
<span>Prompt assistant:</span>
<strong>{assistantPromptVersion}</strong>
</div>
<div>
<span>Prompt decomposition:</span>
<strong>{decompositionPromptVersion}</strong>
</div>
</div>
<details className="autoruns-prompt-details">
<summary>Дублирование главного промпта (read-only)</summary>
<label>
System
<textarea readOnly value={prompts.systemPrompt} />
</label>
<label>
Developer
<textarea readOnly value={prompts.developerPrompt} />
</label>
<label>
Domain
<textarea readOnly value={prompts.domainPrompt} />
</label>
<label>
Schema notes
<textarea readOnly value={prompts.schemaNotes} />
</label>
<label>
Few-shot
<textarea readOnly value={prompts.fewShotExamples} />
</label>
</details>
{errorText ? <p className="error-text">{errorText}</p> : null}
</section>
<section className="autoruns-col">
<h3>Выдача прогонов</h3>
<div className="autoruns-stats-grid">
<div>
<span>Всего</span>
<strong>{history?.stats.runs_total ?? 0}</strong>
</div>
<div>
<span>Средний score</span>
<strong>{formatScore(history?.stats.avg_score_index ?? null)}</strong>
</div>
<div>
<span>Тренд</span>
<strong>{history ? trendLabel(history.stats.trend) : "n/a"}</strong>
</div>
<div>
<span>Блокеры</span>
<strong>{history?.stats.blocking_runs ?? 0}</strong>
</div>
</div>
<div className="autoruns-run-list">
{(history?.items ?? []).map((run) => (
<button
key={run.run_id}
type="button"
className={selectedRunId === run.run_id ? "autoruns-run-item selected" : "autoruns-run-item"}
onClick={() => {
setSelectedRunId(run.run_id);
void loadRunDetail(run.run_id);
}}
>
<div className="autoruns-run-head">
<strong>{formatDateTime(run.run_timestamp)}</strong>
<span>{formatShortTarget(run.eval_target)}</span>
</div>
<div className="autoruns-run-meta">{run.run_id}</div>
<div className="autoruns-run-meta">
mode={run.mode ?? "n/a"} | mock={String(run.use_mock)}
</div>
{run.llm_provider || run.model ? (
<div className="autoruns-run-meta">
llm={run.llm_provider ?? "n/a"} | model={run.model ?? "n/a"}
</div>
) : null}
<div className="autoruns-run-meta">prompt={run.prompt_version ?? "n/a"}</div>
<div className="autoruns-run-foot">
<span>score: {formatScore(run.score_index)}</span>
<span>
closed/open: {run.closed_cases}/{run.open_cases}
</span>
</div>
<div className="autoruns-run-foot">
<span>blocking: {run.blocking_failures}</span>
<span>quality: {run.quality_failures}</span>
</div>
</button>
))}
{(history?.items.length ?? 0) === 0 ? <p className="muted">За выбранный диапазон прогонов нет.</p> : null}
</div>
</section>
<section className="autoruns-col">
<h3>Диалог прогона</h3>
<div className="autoruns-dialog-toolbar">
<label>
Прогон
<select
value={selectedRunId}
onChange={(event) => {
const nextRunId = event.target.value;
setSelectedRunId(nextRunId);
void loadRunDetail(nextRunId);
}}
>
{(history?.items ?? []).map((item) => (
<option key={item.run_id} value={item.run_id}>
{formatDateTime(item.run_timestamp)} | {item.run_id}
</option>
))}
</select>
</label>
<label>
Кейc
<select
value={selectedCaseId}
onChange={(event) => {
const nextCaseId = event.target.value;
setSelectedCaseId(nextCaseId);
if (selectedRunId && nextCaseId) {
void loadCaseDialog(selectedRunId, nextCaseId);
}
}}
>
{(runDetail?.cases ?? []).map((item) => (
<option key={item.case_id} value={item.case_id}>
{item.case_id} | {item.status}
</option>
))}
</select>
</label>
</div>
<div className="autoruns-case-list">
{(runDetail?.cases ?? []).map((item) => (
<button
key={item.case_id}
type="button"
className={selectedCaseId === item.case_id ? "autoruns-case-item selected" : "autoruns-case-item"}
onClick={() => {
setSelectedCaseId(item.case_id);
if (selectedRunId) {
void loadCaseDialog(selectedRunId, item.case_id);
}
}}
>
<span>{item.case_id}</span>
<span>{item.status}</span>
</button>
))}
</div>
<div className="autoruns-dialog-view">
{dialogBusy || detailBusy ? <p className="muted">Загружаю диалог...</p> : null}
{!dialogBusy && !detailBusy && (dialog?.messages.length ?? 0) === 0 ? <p className="muted">Диалог для этого кейса не найден.</p> : null}
{(dialog?.messages ?? []).map((item, index) => {
const role = item.role === "assistant" ? "assistant" : "user";
return (
<article key={`${role}-${index}`} className={`autoruns-msg ${role}`}>
<header>
<strong>{role === "assistant" ? "Система" : "Модель/вопрос"}</strong>
<span>{item.created_at ? formatDateTime(item.created_at) : "n/a"}</span>
</header>
<p>{item.text}</p>
{(item.trace_id || item.reply_type) && (
<footer>
{item.trace_id ? <span>trace={item.trace_id}</span> : null}
{item.reply_type ? <span>reply_type={item.reply_type}</span> : null}
</footer>
)}
</article>
);
})}
</div>
</section>
{showAssistantMode ? (
<section className="autoruns-col">
<h3>Режим ассистента</h3>
<div className="autoruns-meta-list">
<div>
<span>source:</span>
<strong>{dialog?.source ?? "n/a"}</strong>
</div>
<div>
<span>session:</span>
<strong>{dialog?.session_id ?? "n/a"}</strong>
</div>
<div>
<span>run target:</span>
<strong>{activeRunSummary?.eval_target ?? "n/a"}</strong>
</div>
<div>
<span>run score:</span>
<strong>{formatScore(activeRunSummary?.score_index ?? null)}</strong>
</div>
</div>
<h4>Assistant mode payload</h4>
<JsonView value={dialog?.assistant_mode ?? { note: "assistant_mode unavailable" }} />
<h4 style={{ marginTop: 12 }}>Case checks</h4>
<JsonView value={activeCase?.checks ?? { note: "checks unavailable" }} />
<h4 style={{ marginTop: 12 }}>Metric subscores</h4>
<JsonView value={activeCase?.metric_subscores ?? { note: "metric_subscores unavailable" }} />
</section>
) : null}
{showDecompositionMode ? (
<section className="autoruns-col">
<h3>Режим декомпозиции</h3>
<div className="autoruns-meta-list">
<div>
<span>Case:</span>
<strong>{activeCase?.case_id ?? "n/a"}</strong>
</div>
<div>
<span>Domain:</span>
<strong>{activeCase?.domain ?? "n/a"}</strong>
</div>
<div>
<span>Query class:</span>
<strong>{activeCase?.query_class ?? "n/a"}</strong>
</div>
<div>
<span>Trace:</span>
<strong>{activeCase?.trace_id ?? "n/a"}</strong>
</div>
</div>
<h4>Шаги декомпозиции</h4>
{(dialog?.decomposition.length ?? 0) > 0 ? (
<ol className="autoruns-decomposition-list">
{(dialog?.decomposition ?? []).map((item, index) => (
<li key={`${index}-${item.slice(0, 24)}`}>{item}</li>
))}
</ol>
) : (
<p className="muted">В логах кейса нет явной декомпозиции.</p>
)}
</section>
) : null}
{showProgressMode ? (
<section className="autoruns-col">
<h3>Прогресс / регресс</h3>
<div className="autoruns-stats-grid">
<div>
<span>Latest score</span>
<strong>{formatScore(history?.stats.latest_score_index ?? null)}</strong>
</div>
<div>
<span>Previous</span>
<strong>{formatScore(history?.stats.previous_score_index ?? null)}</strong>
</div>
<div>
<span>Trend</span>
<strong>{history ? trendLabel(history.stats.trend) : "n/a"}</strong>
</div>
<div>
<span>Quality gaps</span>
<strong>{history?.stats.quality_gap_runs ?? 0}</strong>
</div>
</div>
<h4>Покрытие доменов (история)</h4>
{renderCoverageRows(history?.stats.domain_coverage ?? [])}
<h4 style={{ marginTop: 14 }}>Покрытие доменов (выбранный прогон)</h4>
{renderCoverageRows(runDetail?.coverage.domain_coverage ?? [])}
</section>
) : null}
</div>
</PanelFrame>
);
}

View File

@ -66,7 +66,119 @@ export interface RuntimeRun {
updatedAt: string;
}
export type UiMode = "assistant" | "decomposition";
export type UiMode = "assistant" | "decomposition" | "autoruns";
export type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown";
export type AutoRunTrend = "up" | "down" | "flat";
export interface AutoRunDomainCoverage {
domain: string;
total_cases: number;
closed_cases: number;
}
export interface AutoRunSummary {
run_id: string;
eval_target: AutoRunTarget;
run_timestamp: string;
mode: string | null;
llm_provider: string | null;
model: string | null;
use_mock: boolean | null;
prompt_version: string | null;
schema_version: string | null;
suite_id: string | null;
cases_total: number;
requests_total: number | null;
report_path: string;
score_index: number | null;
blocking_failures: number;
quality_failures: number;
closed_cases: number;
open_cases: number;
domain_coverage: AutoRunDomainCoverage[];
}
export interface AutoRunCaseSummary {
case_id: string;
domain: string | null;
query_class: string | null;
status: "closed" | "open" | "unknown";
score_index: number | null;
trace_id: string | null;
reply_type: string | null;
session_id: string;
dialog_available: boolean;
checks: Record<string, unknown> | null;
metric_subscores: Record<string, unknown> | null;
}
export interface AutoRunCoverage {
closed_cases: number;
open_cases: number;
domain_coverage: AutoRunDomainCoverage[];
}
export interface AutoRunHistoryStats {
runs_total: number;
by_target: Record<string, number>;
blocking_runs: number;
quality_gap_runs: number;
avg_score_index: number | null;
latest_score_index: number | null;
previous_score_index: number | null;
trend: AutoRunTrend;
domain_coverage: AutoRunDomainCoverage[];
}
export interface AutoRunHistoryResponse {
ok: boolean;
generated_at: string;
filters_applied: {
from: string | null;
to: string | null;
target: AutoRunTarget | "all";
use_mock: boolean | null;
prompt_contains: string;
mode: string;
limit: number;
scan_limit: number;
};
available: {
targets: AutoRunTarget[];
modes: string[];
prompt_versions: string[];
};
items: AutoRunSummary[];
stats: AutoRunHistoryStats;
}
export interface AutoRunDetailResponse {
ok: boolean;
run: AutoRunSummary;
coverage: AutoRunCoverage;
cases: AutoRunCaseSummary[];
report: Record<string, unknown>;
}
export interface AutoRunDialogMessage {
role: string;
text: string;
created_at: string | null;
trace_id: string | null;
reply_type: string | null;
}
export interface AutoRunDialogResponse {
ok: boolean;
run_id: string;
case_id: string;
source: "assistant_session" | "report_fallback" | "none";
session_id: string;
messages: AutoRunDialogMessage[];
decomposition: string[];
assistant_mode: Record<string, unknown> | null;
}
export type AssistantFallbackType = "none" | "out_of_scope" | "clarification" | "partial" | "unknown";
export type AssistantReplyType =

View File

@ -454,10 +454,263 @@ button:disabled {
font-size: 0.84rem;
}
.autoruns-columns {
display: grid;
gap: 12px;
}
.autoruns-col {
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 14px;
background: rgba(8, 13, 10, 0.72);
padding: 12px;
min-height: 220px;
}
.autoruns-col h3 {
margin: 0 0 10px;
font-size: 0.95rem;
}
.autoruns-col h4 {
margin: 12px 0 8px;
font-size: 0.82rem;
color: var(--text-muted);
}
.autoruns-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.autoruns-meta-list {
display: grid;
gap: 8px;
}
.autoruns-meta-list > div {
display: flex;
justify-content: space-between;
gap: 8px;
border: 1px solid rgba(157, 255, 190, 0.14);
border-radius: 10px;
background: rgba(10, 18, 13, 0.65);
padding: 8px 9px;
font-size: 0.79rem;
}
.autoruns-meta-list span {
color: var(--text-muted);
}
.autoruns-prompt-details summary {
cursor: pointer;
color: var(--lime-main);
font-size: 0.8rem;
margin-bottom: 8px;
}
.autoruns-prompt-details textarea {
min-height: 68px;
}
.autoruns-stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.autoruns-stats-grid > div {
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 10px;
background: rgba(10, 18, 13, 0.7);
padding: 8px;
display: grid;
gap: 3px;
}
.autoruns-stats-grid span {
color: var(--text-muted);
font-size: 0.74rem;
}
.autoruns-stats-grid strong {
color: var(--lime-main);
font-size: 0.84rem;
}
.autoruns-run-list {
display: grid;
gap: 8px;
max-height: 760px;
overflow: auto;
padding-right: 2px;
}
.autoruns-run-item {
width: 100%;
text-align: left;
border-radius: 12px;
border: 1px solid rgba(157, 255, 190, 0.23);
background: rgba(11, 18, 14, 0.75);
color: var(--text-main);
padding: 10px;
display: grid;
gap: 5px;
}
.autoruns-run-item.selected {
border-color: var(--line-strong);
}
.autoruns-run-head,
.autoruns-run-foot {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.76rem;
}
.autoruns-run-meta {
color: var(--text-muted);
font-size: 0.75rem;
word-break: break-word;
}
.autoruns-dialog-toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.autoruns-case-list {
margin-top: 8px;
display: grid;
gap: 6px;
max-height: 170px;
overflow: auto;
}
.autoruns-case-item {
width: 100%;
text-align: left;
border-radius: 10px;
border: 1px solid rgba(157, 255, 190, 0.22);
background: rgba(9, 14, 11, 0.72);
color: var(--text-main);
padding: 7px 8px;
display: flex;
justify-content: space-between;
gap: 6px;
font-size: 0.76rem;
}
.autoruns-case-item.selected {
border-color: var(--line-strong);
}
.autoruns-dialog-view {
margin-top: 10px;
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 12px;
background: rgba(5, 8, 6, 0.7);
padding: 10px;
max-height: 570px;
overflow: auto;
display: grid;
gap: 8px;
}
.autoruns-msg {
border: 1px solid rgba(157, 255, 190, 0.22);
border-radius: 12px;
background: rgba(11, 18, 14, 0.8);
padding: 8px 10px;
display: grid;
gap: 6px;
}
.autoruns-msg header,
.autoruns-msg footer {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.74rem;
color: var(--text-muted);
}
.autoruns-msg p {
margin: 0;
white-space: pre-wrap;
line-height: 1.35;
font-size: 0.84rem;
}
.autoruns-msg.assistant {
margin-right: 12%;
}
.autoruns-msg.user {
margin-left: 12%;
border-color: rgba(95, 179, 255, 0.35);
background: rgba(10, 18, 24, 0.75);
}
.autoruns-decomposition-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 7px;
font-size: 0.8rem;
}
.autoruns-coverage-list {
display: grid;
gap: 8px;
}
.autoruns-coverage-item {
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 10px;
background: rgba(11, 18, 14, 0.68);
padding: 8px;
}
.autoruns-coverage-head {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.76rem;
margin-bottom: 5px;
}
.autoruns-coverage-head span {
color: var(--text-muted);
}
.autoruns-coverage-bar {
height: 7px;
border-radius: 999px;
background: rgba(157, 255, 190, 0.14);
overflow: hidden;
}
.autoruns-coverage-bar > div {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #6ee0ff, #8fffad);
}
@media (max-width: 1200px) {
.metrics-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.autoruns-columns {
grid-template-columns: 1fr !important;
}
}
@media (max-width: 920px) {
@ -469,6 +722,12 @@ button:disabled {
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.autoruns-form-grid,
.autoruns-dialog-toolbar,
.autoruns-stats-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {

View File

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/components/assistantpanel.tsx","./src/components/connectionpanel.tsx","./src/components/historypanel.tsx","./src/components/jsonview.tsx","./src/components/metricspanel.tsx","./src/components/outputpanel.tsx","./src/components/panelframe.tsx","./src/components/promptpanel.tsx","./src/components/querypanel.tsx","./src/components/runtimepanel.tsx","./src/state/defaults.ts","./src/state/types.ts","./src/utils/conversationexport.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/components/assistantpanel.tsx","./src/components/autorunshistorypanel.tsx","./src/components/connectionpanel.tsx","./src/components/historypanel.tsx","./src/components/jsonview.tsx","./src/components/metricspanel.tsx","./src/components/outputpanel.tsx","./src/components/panelframe.tsx","./src/components/promptpanel.tsx","./src/components/querypanel.tsx","./src/components/runtimepanel.tsx","./src/state/defaults.ts","./src/state/types.ts","./src/utils/conversationexport.ts"],"version":"5.9.3"}