АДРЕСНЫЙ РЕЖИМ - авторан история - базовая версия
This commit is contained in:
parent
df29798fa2
commit
edfa09c9af
|
|
@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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"}
|
||||
Loading…
Reference in New Issue