АДРЕСНЫЙ РЕЖИМ - авторан история - базовая версия
This commit is contained in:
@@ -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.**
|
||||
5–10 сценариев, просто чтобы не развалился фронт, история сообщений, отображение ответа, кнопки и т.д.
|
||||
|
||||
То есть **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. Не пытаться сразу “умную автопочинку”
|
||||
|
||||
Сначала нужен **контур истины**.
|
||||
|
||||
Минимальный набор:
|
||||
|
||||
* 100–200 кейсов;
|
||||
* разбивка по типам;
|
||||
* единые поля 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"
|
||||
+245
-2305
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -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");
|
||||
|
||||
+690
@@ -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;
|
||||
}
|
||||
+2
@@ -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));
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+31
-8
@@ -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,
|
||||
|
||||
+137
-14
@@ -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,19 +866,26 @@ 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 === "contracts_by_counterparty_profile"
|
||||
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||
: MOVEMENTS_QUERY_TEMPLATE
|
||||
.replace("__LIMIT__", String(resolvedLimit))
|
||||
.replace("__WHERE_CLAUSE__", (() => {
|
||||
const extraConditions = [];
|
||||
const accountCondition = buildMovementAccountCondition(filters);
|
||||
if (accountCondition) {
|
||||
extraConditions.push(accountCondition);
|
||||
}
|
||||
return buildWhereClause(filters, "Движения.Период", extraConditions);
|
||||
})())
|
||||
.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
|
||||
.replace("__LIMIT__", String(resolvedLimit))
|
||||
.replace("__WHERE_CLAUSE__", (() => {
|
||||
const extraConditions = [];
|
||||
const accountCondition = buildMovementAccountCondition(filters);
|
||||
if (accountCondition) {
|
||||
extraConditions.push(accountCondition);
|
||||
}
|
||||
return buildWhereClause(filters, "Движения.Период", extraConditions);
|
||||
})())
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
return {
|
||||
recipe,
|
||||
query,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-1
@@ -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") {
|
||||
|
||||
+734
-24
@@ -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,9 +1433,24 @@ 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)) {
|
||||
return true;
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user