Initial import NDC_1C
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Backend
|
||||
PORT=8787
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
OPENAI_TEMPERATURE=0
|
||||
OPENAI_MAX_OUTPUT_TOKENS=700
|
||||
DATA_DIR=./data
|
||||
TZ_FALLBACK=Europe/Moscow
|
||||
|
||||
# Frontend (optional, usually proxy to backend)
|
||||
VITE_API_BASE=/api
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "NDC: Open Frontend",
|
||||
"type": "pwa-msedge",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:5174",
|
||||
"webRoot": "${workspaceFolder}/frontend/src"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "NDC: Run All + Open UI",
|
||||
"configurations": ["NDC: Open Frontend"],
|
||||
"preLaunchTask": "NDC: Dev All (Backend + Frontend)"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "NDC: Install All",
|
||||
"type": "shell",
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npm.cmd run install:all"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "NDC: Dev All (Backend + Frontend)",
|
||||
"type": "shell",
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npm.cmd run dev:all"],
|
||||
"isBackground": true,
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": [
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "."
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "Local:"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "NDC: Build All",
|
||||
"type": "shell",
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npm.cmd run build:all"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "NDC: Test Backend",
|
||||
"type": "shell",
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npm.cmd run test:backend"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# NDC AI First Layer (LLM Normalizer Playground)
|
||||
|
||||
Локальный модуль `front + back` для нормализации бухгалтерских запросов через OpenAI token.
|
||||
|
||||
Ключевые свойства:
|
||||
- русифицированный GUI (терминология `NDC`);
|
||||
- backend-proxy (ключ не уходит во фронт);
|
||||
- Responses API + structured JSON schema `normalized_query_v1` / `normalized_query_v2` / `normalized_query_v2_0_1`;
|
||||
- trace/history/eval;
|
||||
- совместимый `accounting-agent` namespace для будущей интеграции в `dc_node`.
|
||||
|
||||
## Быстрый запуск (Windows)
|
||||
|
||||
1. Опционально: отдельная среда Miniconda
|
||||
|
||||
```powershell
|
||||
conda create -n ndc-gui nodejs=22 -y
|
||||
conda activate ndc-gui
|
||||
```
|
||||
|
||||
2. Backend
|
||||
|
||||
```powershell
|
||||
cd X:\1C\NDC_1C\llm_normalizer\backend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Frontend (в новом терминале)
|
||||
|
||||
```powershell
|
||||
cd X:\1C\NDC_1C\llm_normalizer\frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Открыть GUI
|
||||
|
||||
- `http://localhost:5174`
|
||||
|
||||
Backend по умолчанию:
|
||||
- `http://localhost:8787`
|
||||
|
||||
## Запуск из одной папки (VS Code)
|
||||
|
||||
Открой в VS Code папку:
|
||||
- `X:\1C\NDC_1C\llm_normalizer`
|
||||
|
||||
Дальше 2 варианта:
|
||||
|
||||
1. Через Tasks:
|
||||
- `Terminal -> Run Task -> NDC: Install All` (первый раз)
|
||||
- `Terminal -> Run Task -> NDC: Dev All (Backend + Frontend)`
|
||||
|
||||
2. Через одну команду в терминале:
|
||||
|
||||
```powershell
|
||||
cd X:\1C\NDC_1C\llm_normalizer
|
||||
start-dev.cmd
|
||||
```
|
||||
|
||||
Или:
|
||||
|
||||
```powershell
|
||||
cd X:\1C\NDC_1C\llm_normalizer
|
||||
npm.cmd run dev:all
|
||||
```
|
||||
|
||||
## Основные endpoint-ы
|
||||
|
||||
- `POST /api/openai/test-connection`
|
||||
- `POST /api/normalize`
|
||||
- `POST /api/eval/run`
|
||||
- `GET /api/history`
|
||||
- `GET /api/history/:trace_id`
|
||||
- `POST /api/presets/save`
|
||||
- `GET /api/presets`
|
||||
- `GET /api/health`
|
||||
- `GET /api/accounting-agent/v1/health`
|
||||
|
||||
## Где хранятся данные
|
||||
|
||||
- traces: `llm_normalizer/data/traces`
|
||||
- presets: `llm_normalizer/data/presets`
|
||||
- eval cases/reports: `llm_normalizer/data/eval_cases`
|
||||
|
||||
Для `POST /api/eval/run` поддержан batch-ввод через `rawQuestions` (разделитель `;` или пустая строка).
|
||||
|
||||
## Тесты backend
|
||||
|
||||
```powershell
|
||||
cd X:\1C\NDC_1C\llm_normalizer\backend
|
||||
npm test
|
||||
```
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
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.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, "..");
|
||||
function toBooleanFlag(value, defaultValue) {
|
||||
if (!value || value.trim() === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
const lowered = value.trim().toLowerCase();
|
||||
return !(lowered === "0" || lowered === "false" || lowered === "off" || lowered === "no");
|
||||
}
|
||||
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";
|
||||
exports.DEFAULT_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini";
|
||||
exports.DEFAULT_TEMPERATURE = Number(process.env.OPENAI_TEMPERATURE ?? 0);
|
||||
exports.DEFAULT_MAX_OUTPUT_TOKENS = Number(process.env.OPENAI_MAX_OUTPUT_TOKENS ?? 700);
|
||||
exports.DEFAULT_PROMPT_VERSION = process.env.DEFAULT_PROMPT_VERSION ?? "normalizer_v2_0_2";
|
||||
exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1, true);
|
||||
exports.FEATURE_ASSISTANT_CONTRACTS_V11 = toBooleanFlag(process.env.FEATURE_ASSISTANT_CONTRACTS_V11, true);
|
||||
exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1, true);
|
||||
exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1, true);
|
||||
exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_BROAD_GUARD_V1, true);
|
||||
exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ANSWER_POLICY_V11, false);
|
||||
exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1, true);
|
||||
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");
|
||||
exports.EVAL_CASES_DIR = path_1.default.resolve(exports.DATA_DIR, "eval_cases");
|
||||
exports.ASSISTANT_SESSIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "assistant_sessions");
|
||||
exports.PROMPTS_DIR = path_1.default.resolve(exports.MODULE_ROOT, "prompts");
|
||||
exports.REPORTS_DIR = path_1.default.resolve(exports.MODULE_ROOT, "reports");
|
||||
exports.EVAL_DATASETS_DIR = path_1.default.resolve(exports.MODULE_ROOT, "eval_cases");
|
||||
exports.SCHEMAS_DIR = path_1.default.resolve(exports.BACKEND_ROOT, "src", "schemas");
|
||||
exports.ARCH_EXPORT_2020_DIR = path_1.default.resolve(exports.MODULE_ROOT, "..", "docs", "ARCH", "2020экспорт");
|
||||
@@ -0,0 +1,158 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildAccountingAgentRouter = buildAccountingAgentRouter;
|
||||
const express_1 = require("express");
|
||||
const http_1 = require("../utils/http");
|
||||
const PREFIX = "/api/accounting-agent/v1";
|
||||
function buildAccountingAgentRouter(services) {
|
||||
const router = (0, express_1.Router)();
|
||||
const runtime = services.runtimeAdapter;
|
||||
router.post(`${PREFIX}/runs/start`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const run = runtime.startRun({
|
||||
sessionId: body.sessionId ? String(body.sessionId) : undefined,
|
||||
initiator: body.initiator ? String(body.initiator) : "operator",
|
||||
source: body.source ? String(body.source) : "gui",
|
||||
metadata: (body.metadata ?? {}),
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
(0, http_1.created)(res, {
|
||||
ok: true,
|
||||
sessionId: run.sessionId,
|
||||
runId: run.runId,
|
||||
status: run.status,
|
||||
run
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.post(`${PREFIX}/runs/finish`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const status = String(body.status ?? "DONE");
|
||||
if (!["DONE", "ERROR", "CANCELLED"].includes(status)) {
|
||||
throw new http_1.ApiError("INVALID_STATUS", `Invalid finish status: ${status}`, 400);
|
||||
}
|
||||
const run = runtime.finishRun({
|
||||
runId: String(body.runId ?? ""),
|
||||
status,
|
||||
source: body.source ? String(body.source) : "gui",
|
||||
reason: body.reason ? String(body.reason) : undefined,
|
||||
metadata: (body.metadata ?? {}),
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
run
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(`${PREFIX}/runs`, (_req, res) => {
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
items: runtime.listRuns()
|
||||
});
|
||||
});
|
||||
router.get(`${PREFIX}/runs/:runId`, (req, res, next) => {
|
||||
try {
|
||||
const run = runtime.getRun(String(req.params.runId));
|
||||
if (!run) {
|
||||
throw new http_1.ApiError("RUN_NOT_FOUND", `Run not found: ${req.params.runId}`, 404);
|
||||
}
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
run
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.post(`${PREFIX}/tasks/enqueue`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const task = runtime.enqueueTask({
|
||||
runId: String(body.runId ?? ""),
|
||||
payload: (body.payload ?? {}),
|
||||
source: body.source ? String(body.source) : "gui",
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
(0, http_1.created)(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.post(`${PREFIX}/tasks/claim`, (_req, res) => {
|
||||
const task = runtime.claimTask();
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
});
|
||||
router.post(`${PREFIX}/tasks/:taskId/complete`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const task = runtime.completeTask({
|
||||
taskId: String(req.params.taskId),
|
||||
result: (body.result ?? {}),
|
||||
source: body.source ? String(body.source) : "worker",
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.post(`${PREFIX}/tasks/:taskId/error`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const task = runtime.failTask({
|
||||
taskId: String(req.params.taskId),
|
||||
error: {
|
||||
code: String(body.code ?? "TASK_ERROR"),
|
||||
message: String(body.message ?? "Task failed"),
|
||||
details: body.details
|
||||
},
|
||||
source: body.source ? String(body.source) : "worker",
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(`${PREFIX}/results`, (_req, res) => {
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
items: runtime.getResults()
|
||||
});
|
||||
});
|
||||
router.get(`${PREFIX}/trace/run/:runId`, (req, res) => {
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
items: runtime.getRunTrace(String(req.params.runId))
|
||||
});
|
||||
});
|
||||
router.get(`${PREFIX}/health`, (_req, res) => {
|
||||
(0, http_1.ok)(res, runtime.health());
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildAssistantRouter = buildAssistantRouter;
|
||||
const express_1 = require("express");
|
||||
const http_1 = require("../utils/http");
|
||||
function buildAssistantRouter(services) {
|
||||
const router = (0, express_1.Router)();
|
||||
router.post("/api/assistant/message", async (req, res, next) => {
|
||||
try {
|
||||
const payload = (req.body ?? {});
|
||||
const userMessageSource = typeof payload.user_message === "string"
|
||||
? payload.user_message
|
||||
: typeof payload.message === "string"
|
||||
? payload.message
|
||||
: "";
|
||||
const userMessage = userMessageSource.trim();
|
||||
if (!userMessage) {
|
||||
throw new http_1.ApiError("INVALID_ASSISTANT_MESSAGE", "Field `user_message` or `message` is required.", 400);
|
||||
}
|
||||
const response = await services.assistantService.handleMessage({
|
||||
...payload,
|
||||
user_message: userMessage,
|
||||
message: userMessage,
|
||||
mode: typeof payload.mode === "string" ? payload.mode : "assistant"
|
||||
});
|
||||
(0, http_1.ok)(res, response);
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get("/api/assistant/session/:session_id", (req, res, next) => {
|
||||
try {
|
||||
const sessionId = String(req.params.session_id ?? "");
|
||||
const session = services.assistantService.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new http_1.ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
|
||||
}
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
session
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildEvalRouter = buildEvalRouter;
|
||||
const express_1 = require("express");
|
||||
const http_1 = require("../utils/http");
|
||||
function buildEvalRouter(services) {
|
||||
const router = (0, express_1.Router)();
|
||||
router.post("/api/eval/run", async (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const report = await services.evalService.run({
|
||||
normalizeConfig: (body.normalizeConfig ?? {}),
|
||||
caseIds: Array.isArray(body.caseIds) ? body.caseIds : undefined,
|
||||
useMock: Boolean(body.useMock),
|
||||
mode: body.mode ?? "standard",
|
||||
caseSetFile: typeof body.caseSetFile === "string" ? body.caseSetFile : undefined,
|
||||
rawQuestions: typeof body.rawQuestions === "string" ? body.rawQuestions : undefined,
|
||||
evalTarget: body.eval_target ?? "normalizer",
|
||||
compareWithReportFile: typeof body.compare_with_report_file === "string"
|
||||
? body.compare_with_report_file
|
||||
: typeof body.comparisonBaselineReportFile === "string"
|
||||
? body.comparisonBaselineReportFile
|
||||
: undefined
|
||||
});
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
report
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildHistoryRouter = buildHistoryRouter;
|
||||
const express_1 = require("express");
|
||||
const traceLogger_1 = require("../services/traceLogger");
|
||||
const http_1 = require("../utils/http");
|
||||
function buildHistoryRouter() {
|
||||
const router = (0, express_1.Router)();
|
||||
router.get("/api/history", (_req, res) => {
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
items: (0, traceLogger_1.listTraces)(200)
|
||||
});
|
||||
});
|
||||
router.get("/api/history/:trace_id", (req, res, next) => {
|
||||
try {
|
||||
const traceId = String(req.params.trace_id);
|
||||
const trace = (0, traceLogger_1.getTrace)(traceId);
|
||||
if (!trace) {
|
||||
throw new http_1.ApiError("TRACE_NOT_FOUND", `Trace not found: ${traceId}`, 404);
|
||||
}
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
trace
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildNormalizeRouter = buildNormalizeRouter;
|
||||
const express_1 = require("express");
|
||||
const http_1 = require("../utils/http");
|
||||
function buildNormalizeRouter(services) {
|
||||
const router = (0, express_1.Router)();
|
||||
router.post("/api/normalize", async (req, res, next) => {
|
||||
try {
|
||||
const payload = req.body;
|
||||
const result = await services.normalizerService.normalize(payload);
|
||||
(0, http_1.ok)(res, result);
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildPresetsRouter = buildPresetsRouter;
|
||||
const express_1 = require("express");
|
||||
const nanoid_1 = require("nanoid");
|
||||
const config_1 = require("../config");
|
||||
const promptBuilder_1 = require("../services/promptBuilder");
|
||||
const traceLogger_1 = require("../services/traceLogger");
|
||||
const http_1 = require("../utils/http");
|
||||
function buildPresetsRouter() {
|
||||
const router = (0, express_1.Router)();
|
||||
router.get("/api/presets", (_req, res) => {
|
||||
const stored = (0, traceLogger_1.listPresets)();
|
||||
const builtin = (0, promptBuilder_1.listBuiltinPromptPresets)();
|
||||
const combined = [...builtin, ...stored];
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
default_prompt_version: config_1.DEFAULT_PROMPT_VERSION,
|
||||
presets: combined
|
||||
});
|
||||
});
|
||||
router.post("/api/presets/save", (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const now = new Date().toISOString();
|
||||
const preset = {
|
||||
id: body.id ?? `preset-${(0, nanoid_1.nanoid)(8)}`,
|
||||
name: body.name ?? "Пользовательский пресет",
|
||||
createdAt: body.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
prompt_version: body.prompt_version ?? config_1.DEFAULT_PROMPT_VERSION,
|
||||
systemPrompt: body.systemPrompt ?? "",
|
||||
developerPrompt: body.developerPrompt ?? "",
|
||||
domainPrompt: body.domainPrompt ?? "",
|
||||
schemaNotes: body.schemaNotes ?? "",
|
||||
fewShotExamples: body.fewShotExamples ?? ""
|
||||
};
|
||||
(0, traceLogger_1.savePreset)(preset);
|
||||
(0, http_1.created)(res, {
|
||||
ok: true,
|
||||
preset
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildTestConnectionRouter = buildTestConnectionRouter;
|
||||
const express_1 = require("express");
|
||||
const config_1 = require("../config");
|
||||
const http_1 = require("../utils/http");
|
||||
function buildTestConnectionRouter(client) {
|
||||
const router = (0, express_1.Router)();
|
||||
router.post("/api/openai/test-connection", async (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {});
|
||||
const result = await client.testConnection({
|
||||
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||
model: String(body.model ?? config_1.DEFAULT_MODEL),
|
||||
baseUrl: String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL)
|
||||
});
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
model: result.model,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.InMemoryRuntimeAdapter = void 0;
|
||||
const nanoid_1 = require("nanoid");
|
||||
const http_1 = require("../utils/http");
|
||||
class InMemoryRuntimeAdapter {
|
||||
runs = [];
|
||||
tasks = [];
|
||||
traces = [];
|
||||
idempotencyCache = new Map();
|
||||
now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
cacheKey(action, key) {
|
||||
if (!key || !key.trim())
|
||||
return null;
|
||||
return `${action}:${key.trim()}`;
|
||||
}
|
||||
readIdempotency(action, idempotencyKey) {
|
||||
const cache = this.cacheKey(action, idempotencyKey);
|
||||
if (!cache)
|
||||
return null;
|
||||
return this.idempotencyCache.get(cache) ?? null;
|
||||
}
|
||||
writeIdempotency(action, idempotencyKey, value) {
|
||||
const cache = this.cacheKey(action, idempotencyKey);
|
||||
if (!cache)
|
||||
return;
|
||||
this.idempotencyCache.set(cache, value);
|
||||
}
|
||||
pushEvent(input) {
|
||||
this.traces.push({
|
||||
timestamp: this.now(),
|
||||
level: input.level ?? "info",
|
||||
service: "llm_normalizer_backend",
|
||||
sessionId: input.sessionId,
|
||||
runId: input.runId,
|
||||
taskId: input.taskId ?? null,
|
||||
eventType: input.eventType,
|
||||
payload: input.payload
|
||||
});
|
||||
}
|
||||
startRun(input) {
|
||||
const cached = this.readIdempotency("startRun", input.idempotencyKey);
|
||||
if (cached)
|
||||
return cached;
|
||||
const record = {
|
||||
sessionId: input.sessionId ?? `session_${(0, nanoid_1.nanoid)(8)}`,
|
||||
runId: `run_${(0, nanoid_1.nanoid)(10)}`,
|
||||
status: "RUNNING",
|
||||
initiator: input.initiator ?? "operator",
|
||||
source: input.source ?? "gui",
|
||||
createdAt: this.now(),
|
||||
updatedAt: this.now(),
|
||||
metadata: input.metadata ?? {}
|
||||
};
|
||||
this.runs.unshift(record);
|
||||
this.pushEvent({
|
||||
runId: record.runId,
|
||||
sessionId: record.sessionId,
|
||||
eventType: "RUN_STARTED",
|
||||
payload: record.metadata
|
||||
});
|
||||
this.writeIdempotency("startRun", input.idempotencyKey, record);
|
||||
return record;
|
||||
}
|
||||
finishRun(input) {
|
||||
const cached = this.readIdempotency("finishRun", input.idempotencyKey);
|
||||
if (cached)
|
||||
return cached;
|
||||
const record = this.runs.find((item) => item.runId === input.runId);
|
||||
if (!record) {
|
||||
throw new http_1.ApiError("RUN_NOT_FOUND", `Run not found: ${input.runId}`, 404);
|
||||
}
|
||||
record.status = input.status;
|
||||
record.updatedAt = this.now();
|
||||
record.source = input.source ?? record.source;
|
||||
record.metadata = {
|
||||
...(record.metadata ?? {}),
|
||||
...(input.metadata ?? {}),
|
||||
reason: input.reason ?? null
|
||||
};
|
||||
this.pushEvent({
|
||||
runId: record.runId,
|
||||
sessionId: record.sessionId,
|
||||
eventType: `RUN_FINISHED_${input.status}`,
|
||||
level: input.status === "ERROR" ? "error" : "info",
|
||||
payload: { reason: input.reason ?? null }
|
||||
});
|
||||
this.writeIdempotency("finishRun", input.idempotencyKey, record);
|
||||
return record;
|
||||
}
|
||||
listRuns() {
|
||||
return [...this.runs];
|
||||
}
|
||||
getRun(runId) {
|
||||
return this.runs.find((item) => item.runId === runId) ?? null;
|
||||
}
|
||||
enqueueTask(input) {
|
||||
const cached = this.readIdempotency("enqueueTask", input.idempotencyKey);
|
||||
if (cached)
|
||||
return cached;
|
||||
const run = this.getRun(input.runId);
|
||||
if (!run) {
|
||||
throw new http_1.ApiError("RUN_NOT_FOUND", `Run not found: ${input.runId}`, 404);
|
||||
}
|
||||
const task = {
|
||||
taskId: `task_${(0, nanoid_1.nanoid)(10)}`,
|
||||
runId: run.runId,
|
||||
status: "QUEUED",
|
||||
payload: input.payload,
|
||||
source: input.source ?? "gui",
|
||||
createdAt: this.now(),
|
||||
updatedAt: this.now()
|
||||
};
|
||||
this.tasks.unshift(task);
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_ENQUEUED",
|
||||
payload: task.payload
|
||||
});
|
||||
this.writeIdempotency("enqueueTask", input.idempotencyKey, task);
|
||||
return task;
|
||||
}
|
||||
claimTask() {
|
||||
const task = this.tasks.find((item) => item.status === "QUEUED");
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
task.status = "RUNNING";
|
||||
task.updatedAt = this.now();
|
||||
const run = this.getRun(task.runId);
|
||||
if (run) {
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_CLAIMED"
|
||||
});
|
||||
}
|
||||
return task;
|
||||
}
|
||||
completeTask(input) {
|
||||
const cached = this.readIdempotency("completeTask", input.idempotencyKey);
|
||||
if (cached)
|
||||
return cached;
|
||||
const task = this.tasks.find((item) => item.taskId === input.taskId);
|
||||
if (!task) {
|
||||
throw new http_1.ApiError("TASK_NOT_FOUND", `Task not found: ${input.taskId}`, 404);
|
||||
}
|
||||
task.status = "DONE";
|
||||
task.updatedAt = this.now();
|
||||
task.result = input.result;
|
||||
task.source = input.source ?? task.source;
|
||||
const run = this.getRun(task.runId);
|
||||
if (run) {
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_DONE",
|
||||
payload: input.result
|
||||
});
|
||||
}
|
||||
this.writeIdempotency("completeTask", input.idempotencyKey, task);
|
||||
return task;
|
||||
}
|
||||
failTask(input) {
|
||||
const cached = this.readIdempotency("failTask", input.idempotencyKey);
|
||||
if (cached)
|
||||
return cached;
|
||||
const task = this.tasks.find((item) => item.taskId === input.taskId);
|
||||
if (!task) {
|
||||
throw new http_1.ApiError("TASK_NOT_FOUND", `Task not found: ${input.taskId}`, 404);
|
||||
}
|
||||
task.status = "ERROR";
|
||||
task.updatedAt = this.now();
|
||||
task.error = input.error;
|
||||
task.source = input.source ?? task.source;
|
||||
const run = this.getRun(task.runId);
|
||||
if (run) {
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_ERROR",
|
||||
level: "error",
|
||||
payload: {
|
||||
errorCode: input.error.code,
|
||||
errorMessage: input.error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
this.writeIdempotency("failTask", input.idempotencyKey, task);
|
||||
return task;
|
||||
}
|
||||
getResults() {
|
||||
return this.tasks.filter((item) => item.status === "DONE" || item.status === "ERROR");
|
||||
}
|
||||
getRunTrace(runId) {
|
||||
return this.traces.filter((item) => item.runId === runId);
|
||||
}
|
||||
health() {
|
||||
return {
|
||||
ok: true,
|
||||
queueDepth: this.tasks.filter((item) => item.status === "QUEUED").length,
|
||||
runsTotal: this.runs.length
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.InMemoryRuntimeAdapter = InMemoryRuntimeAdapter;
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createApp = createApp;
|
||||
require("dotenv/config");
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const config_1 = require("./config");
|
||||
const accountingAgent_1 = require("./routes/accountingAgent");
|
||||
const assistant_1 = require("./routes/assistant");
|
||||
const eval_1 = require("./routes/eval");
|
||||
const history_1 = require("./routes/history");
|
||||
const normalize_1 = require("./routes/normalize");
|
||||
const presets_1 = require("./routes/presets");
|
||||
const testConnection_1 = require("./routes/testConnection");
|
||||
const inMemoryRuntimeAdapter_1 = require("./runtime/inMemoryRuntimeAdapter");
|
||||
const assistantService_1 = require("./services/assistantService");
|
||||
const assistantSessionStore_1 = require("./services/assistantSessionStore");
|
||||
const evalService_1 = require("./services/evalService");
|
||||
const normalizerService_1 = require("./services/normalizerService");
|
||||
const openaiResponsesClient_1 = require("./services/openaiResponsesClient");
|
||||
const files_1 = require("./utils/files");
|
||||
const http_1 = require("./utils/http");
|
||||
const log_1 = require("./utils/log");
|
||||
function createApp() {
|
||||
(0, files_1.ensureDir)(config_1.TRACES_DIR);
|
||||
(0, files_1.ensureDir)(config_1.PRESETS_DIR);
|
||||
(0, files_1.ensureDir)(config_1.EVAL_CASES_DIR);
|
||||
(0, files_1.ensureDir)(config_1.REPORTS_DIR);
|
||||
(0, files_1.ensureDir)(config_1.ASSISTANT_SESSIONS_DIR);
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, cors_1.default)());
|
||||
app.use(express_1.default.json({ type: ["application/json", "application/*+json"], limit: "2mb" }));
|
||||
const openaiClient = new openaiResponsesClient_1.OpenAIResponsesClient();
|
||||
const normalizerService = new normalizerService_1.NormalizerService(openaiClient);
|
||||
const evalService = new evalService_1.EvalService(normalizerService);
|
||||
const assistantSessionStore = new assistantSessionStore_1.AssistantSessionStore();
|
||||
const assistantService = new assistantService_1.AssistantService(normalizerService, assistantSessionStore);
|
||||
const runtimeAdapter = new inMemoryRuntimeAdapter_1.InMemoryRuntimeAdapter();
|
||||
const services = {
|
||||
normalizerService,
|
||||
evalService,
|
||||
assistantService,
|
||||
runtimeAdapter
|
||||
};
|
||||
app.get("/api/health", (_req, res) => {
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
service: "llm-normalizer-backend",
|
||||
status: "RUNNING",
|
||||
timezone: config_1.TIMEZONE,
|
||||
now: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
app.use((0, testConnection_1.buildTestConnectionRouter)(openaiClient));
|
||||
app.use((0, normalize_1.buildNormalizeRouter)(services));
|
||||
app.use((0, eval_1.buildEvalRouter)(services));
|
||||
app.use((0, assistant_1.buildAssistantRouter)(services));
|
||||
app.use((0, history_1.buildHistoryRouter)());
|
||||
app.use((0, presets_1.buildPresetsRouter)());
|
||||
app.use((0, accountingAgent_1.buildAccountingAgentRouter)(services));
|
||||
app.use(http_1.errorMiddleware);
|
||||
return app;
|
||||
}
|
||||
if (require.main === module) {
|
||||
const app = createApp();
|
||||
app.listen(config_1.PORT, () => {
|
||||
(0, log_1.logJson)({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
service: "llm_normalizer_backend",
|
||||
message: `Backend started on http://localhost:${config_1.PORT}`
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -0,0 +1,704 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.composeAssistantAnswer = composeAssistantAnswer;
|
||||
function fallbackFromSummary(routeSummary) {
|
||||
if (!routeSummary || routeSummary.mode !== "deterministic_v2") {
|
||||
return "none";
|
||||
}
|
||||
return routeSummary.fallback.type;
|
||||
}
|
||||
function uniqueStrings(values, limit = 6) {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).slice(0, limit);
|
||||
}
|
||||
function formatList(items) {
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return items.map((item) => `- ${item}`).join("\n");
|
||||
}
|
||||
function extractTopFacts(results) {
|
||||
const lines = [];
|
||||
for (const result of results.filter((item) => item.status === "ok").slice(0, 3)) {
|
||||
if (result.result_type === "chain") {
|
||||
const top = result.items.slice(0, 3).map((item) => {
|
||||
const counterparty = String(item.counterparty_id ?? "не указан");
|
||||
const operations = String(item.operations_count ?? "0");
|
||||
const docs = String(item.document_refs_count ?? "0");
|
||||
return `Контрагент ${counterparty}: операций ${operations}, документов в связке ${docs}.`;
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
if (result.result_type === "ranking") {
|
||||
const top = result.items
|
||||
.slice(0, 5)
|
||||
.map((item) => `${item.rank ?? "•"}. ${String(item.entity ?? "Сущность")} — ${String(item.records_count ?? 0)}.`);
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
if (result.result_type === "list") {
|
||||
const top = result.items.slice(0, 5).map((item) => {
|
||||
if (item.risk_score !== undefined) {
|
||||
return `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}) — риск ${String(item.risk_score)}.`;
|
||||
}
|
||||
return `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`;
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
const top = result.items
|
||||
.slice(0, 3)
|
||||
.map((item) => `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`);
|
||||
lines.push(...top);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
function extractWhyIncluded(results) {
|
||||
return uniqueStrings(results.flatMap((item) => item.why_included));
|
||||
}
|
||||
function extractSelectionReasons(results) {
|
||||
return uniqueStrings(results.flatMap((item) => item.selection_reason));
|
||||
}
|
||||
function extractRiskFactors(results) {
|
||||
return uniqueStrings(results.flatMap((item) => item.risk_factors));
|
||||
}
|
||||
function extractBusinessInterpretation(results) {
|
||||
return uniqueStrings(results.flatMap((item) => item.business_interpretation));
|
||||
}
|
||||
function extractLimitations(results) {
|
||||
return uniqueStrings(results.flatMap((item) => item.limitations));
|
||||
}
|
||||
function summaryValue(result, key) {
|
||||
const summary = result.summary ?? {};
|
||||
return Object.prototype.hasOwnProperty.call(summary, key) ? summary[key] : undefined;
|
||||
}
|
||||
function summaryBoolean(result, key) {
|
||||
return summaryValue(result, key) === true;
|
||||
}
|
||||
function summaryString(result, key) {
|
||||
const value = summaryValue(result, key);
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
function suggestNextStep(requirements, coverage) {
|
||||
const next = [];
|
||||
if (coverage.clarification_needed_for.length > 0) {
|
||||
next.push("Уточните период, счет, документ или контрагента для требований: " + coverage.clarification_needed_for.join(", ") + ".");
|
||||
}
|
||||
if (coverage.requirements_uncovered.length > 0) {
|
||||
next.push("Проверьте непокрытые требования: " + coverage.requirements_uncovered.join(", ") + ".");
|
||||
}
|
||||
if (coverage.out_of_scope_requirements.length > 0) {
|
||||
next.push("Часть запроса вне текущего учетного контура: " + coverage.out_of_scope_requirements.join(", ") + ".");
|
||||
}
|
||||
if (next.length === 0 && requirements.length > 0) {
|
||||
next.push("Следующим шагом можно открыть технический разбор и углубить проверку по выбранным объектам.");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
function flattenEvidence(results) {
|
||||
return results.flatMap((item) => item.evidence);
|
||||
}
|
||||
function buildClaimEvidenceLinks(results) {
|
||||
const byClaim = new Map();
|
||||
for (const evidence of flattenEvidence(results)) {
|
||||
const claimRef = String(evidence.claim_ref ?? "").trim();
|
||||
const evidenceId = String(evidence.evidence_id ?? "").trim();
|
||||
if (!claimRef || !evidenceId) {
|
||||
continue;
|
||||
}
|
||||
const current = byClaim.get(claimRef) ?? [];
|
||||
current.push(evidenceId);
|
||||
byClaim.set(claimRef, current);
|
||||
}
|
||||
return Array.from(byClaim.entries())
|
||||
.slice(0, 10)
|
||||
.map(([claim_ref, evidenceIds]) => ({
|
||||
claim_ref,
|
||||
evidence_ids: uniqueStrings(evidenceIds, 10)
|
||||
}));
|
||||
}
|
||||
function aggregatePolicySignals(results) {
|
||||
const broad_query_detected = results.some((item) => summaryBoolean(item, "broad_query_detected"));
|
||||
const broad_result_flag = results.some((item) => summaryBoolean(item, "broad_result_flag"));
|
||||
const minimum_evidence_failed = results.some((item) => summaryBoolean(item, "minimum_evidence_failed"));
|
||||
let degraded_to = null;
|
||||
for (const result of results) {
|
||||
const degraded = summaryString(result, "degraded_to");
|
||||
if (degraded === "clarification") {
|
||||
degraded_to = "clarification";
|
||||
break;
|
||||
}
|
||||
if (degraded === "partial") {
|
||||
degraded_to = "partial";
|
||||
}
|
||||
}
|
||||
const narrowingOrder = {
|
||||
weak: 0,
|
||||
medium: 1,
|
||||
strong: 2
|
||||
};
|
||||
let narrowing_strength = null;
|
||||
for (const result of results) {
|
||||
const value = summaryString(result, "narrowing_strength");
|
||||
if (value !== "weak" && value !== "medium" && value !== "strong") {
|
||||
continue;
|
||||
}
|
||||
if (!narrowing_strength || narrowingOrder[value] < narrowingOrder[narrowing_strength]) {
|
||||
narrowing_strength = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
broad_query_detected,
|
||||
broad_result_flag,
|
||||
minimum_evidence_failed,
|
||||
degraded_to,
|
||||
narrowing_strength
|
||||
};
|
||||
}
|
||||
function confidenceToScore(value) {
|
||||
if (value === "high")
|
||||
return 3;
|
||||
if (value === "medium")
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
function aggregateConfidence(results, evidenceItems) {
|
||||
const scores = [];
|
||||
for (const evidence of evidenceItems) {
|
||||
scores.push(confidenceToScore(evidence.confidence));
|
||||
}
|
||||
for (const result of results) {
|
||||
if (result.status === "error") {
|
||||
continue;
|
||||
}
|
||||
scores.push(confidenceToScore(result.confidence));
|
||||
}
|
||||
if (scores.length === 0) {
|
||||
return "low";
|
||||
}
|
||||
const average = scores.reduce((acc, item) => acc + item, 0) / scores.length;
|
||||
if (average >= 2.6)
|
||||
return "high";
|
||||
if (average >= 1.8)
|
||||
return "medium";
|
||||
return "low";
|
||||
}
|
||||
function collectLimitationReasonCodes(evidenceItems) {
|
||||
const codes = evidenceItems
|
||||
.map((item) => item.limitation?.reason_code ?? null)
|
||||
.filter((item) => Boolean(item));
|
||||
return uniqueStrings(codes, 8);
|
||||
}
|
||||
function limitationReasonToText(code) {
|
||||
if (code === "snapshot_only")
|
||||
return "Evidence is snapshot-only and may lag source-of-record.";
|
||||
if (code === "heuristic_inference")
|
||||
return "Part of the conclusion relies on heuristic inference.";
|
||||
if (code === "missing_mechanism")
|
||||
return "Mechanism is unresolved for part of the evidence.";
|
||||
if (code === "weak_source_mapping")
|
||||
return "Source mapping is weak for part of the evidence.";
|
||||
if (code === "insufficient_detail")
|
||||
return "Evidence lacks detail for a strong factual claim.";
|
||||
return "Some evidence limitations remain unresolved.";
|
||||
}
|
||||
function detectMissingAnchors(userMessage) {
|
||||
const lower = String(userMessage ?? "").toLowerCase();
|
||||
const hasPeriod = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/.test(lower);
|
||||
const hasAccount = /(?:\bсчет\b|\baccount\b|\bschet\b|\b\d{2}(?:\.\d{2})?\b)/i.test(lower);
|
||||
const hasDocumentOrObject = /(?:документ|invoice|guid|object|obj|#\d+|\bid\b|\bref\b|dokument|doc)/i.test(lower);
|
||||
const hasCounterparty = /(?:контрагент|supplier|buyer|customer|kontragent|postavsh|pokupatel)/i.test(lower);
|
||||
const hasAnomalyType = /(?:аномал|risk|отклон|разрыв|mismatch|duplicate|tail|цепочк|anomali|hvost)/i.test(lower);
|
||||
return {
|
||||
period: !hasPeriod,
|
||||
account: !hasAccount,
|
||||
documentOrObject: !hasDocumentOrObject,
|
||||
counterparty: !hasCounterparty,
|
||||
anomalyType: !hasAnomalyType
|
||||
};
|
||||
}
|
||||
function buildClarificationQuestions(input) {
|
||||
const questions = [];
|
||||
const shouldAsk = input.mode === "clarification_required" || input.coverageReport.clarification_needed_for.length > 0;
|
||||
if (!shouldAsk) {
|
||||
return questions;
|
||||
}
|
||||
if (input.missingAnchors.period) {
|
||||
questions.push("Уточните период проверки (например, 2020-06).");
|
||||
}
|
||||
if (input.missingAnchors.account) {
|
||||
questions.push("Уточните счет или группу счетов (например, 19, 60, 62).");
|
||||
}
|
||||
if (input.missingAnchors.documentOrObject) {
|
||||
questions.push("Укажите документ/GUID/конкретный объект для трассировки.");
|
||||
}
|
||||
if (input.missingAnchors.counterparty) {
|
||||
questions.push("Укажите контрагента или группу контрагентов.");
|
||||
}
|
||||
if (input.policySignals.broad_query_detected && input.missingAnchors.anomalyType) {
|
||||
questions.push("Уточните тип отклонения: разрыв цепочки, неверный документ или аномальный риск.");
|
||||
}
|
||||
if (input.coverageReport.clarification_needed_for.length > 0) {
|
||||
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
|
||||
}
|
||||
return uniqueStrings(questions, 6);
|
||||
}
|
||||
function buildRecommendedActions(input) {
|
||||
const actions = [];
|
||||
if (input.mode === "focused_grounded") {
|
||||
actions.push("Проверьте 1-2 ключевые записи по source_ref и зафиксируйте итог в рабочем файле проверки.");
|
||||
}
|
||||
if (input.mode === "broad_partial") {
|
||||
actions.push("Сузьте запрос до периода + счета или периода + документа и повторите проверку.");
|
||||
}
|
||||
if (input.mode === "clarification_required") {
|
||||
actions.push("Дайте недостающие якоря (период/счет/объект), иначе сильный factual вывод невозможен.");
|
||||
}
|
||||
if (input.coverageReport.requirements_uncovered.length > 0) {
|
||||
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
|
||||
}
|
||||
if (input.coverageReport.requirements_partially_covered.length > 0) {
|
||||
actions.push(`Доуточните частично покрытые требования: ${input.coverageReport.requirements_partially_covered.join(", ")}.`);
|
||||
}
|
||||
if (input.policySignals.broad_query_detected && input.policySignals.narrowing_strength !== "strong") {
|
||||
actions.push("Добавьте более узкий контекст: тип отклонения, группу документов и бизнес-участок.");
|
||||
}
|
||||
if (input.limitationReasonCodes.includes("snapshot_only")) {
|
||||
actions.push("Сверьте критичные выводы с live source-of-record в 1C.");
|
||||
}
|
||||
if (input.limitationReasonCodes.includes("weak_source_mapping")) {
|
||||
actions.push("Проверьте source mapping для связей document/register по указанным ref.");
|
||||
}
|
||||
if (input.sourceRefs.length > 0) {
|
||||
actions.push(`Начните проверку с source_ref: ${input.sourceRefs.slice(0, 2).join(", ")}.`);
|
||||
}
|
||||
return uniqueStrings(actions, 6);
|
||||
}
|
||||
function firstMeaningfulFact(results) {
|
||||
const facts = extractTopFacts(results);
|
||||
return facts.length > 0 ? facts[0] : null;
|
||||
}
|
||||
function buildPolicyDecision(input) {
|
||||
const hasCoverageGaps = input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0;
|
||||
if (input.fallbackType === "out_of_scope" && input.coverageReport.requirements_covered === 0) {
|
||||
return {
|
||||
mode: "out_of_scope",
|
||||
fallback_type: "out_of_scope",
|
||||
reply_type: "out_of_scope"
|
||||
};
|
||||
}
|
||||
if (input.groundingCheck.status === "route_mismatch_blocked") {
|
||||
return {
|
||||
mode: "route_mismatch",
|
||||
fallback_type: "partial",
|
||||
reply_type: "route_mismatch_blocked"
|
||||
};
|
||||
}
|
||||
if ((input.policySignals.degraded_to === "clarification" && input.policySignals.minimum_evidence_failed) ||
|
||||
(input.fallbackType === "clarification" && !input.hasSupport) ||
|
||||
(input.groundingCheck.status === "no_grounded_answer" && !input.hasSupport)) {
|
||||
return {
|
||||
mode: "clarification_required",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
if (input.errorResults.length > 0 && input.okResults.length === 0 && input.partialResults.length === 0) {
|
||||
return {
|
||||
mode: "backend_error",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
if (input.okResults.length === 0 && input.partialResults.length === 0 && input.emptyResults.length > 0) {
|
||||
return {
|
||||
mode: "empty",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "empty_but_valid"
|
||||
};
|
||||
}
|
||||
if (input.groundingCheck.status === "no_grounded_answer" && input.okResults.length === 0 && input.partialResults.length === 0) {
|
||||
return {
|
||||
mode: "no_grounded",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "no_grounded_answer"
|
||||
};
|
||||
}
|
||||
if (input.focusedStrong &&
|
||||
!input.policySignals.broad_query_detected &&
|
||||
!input.policySignals.minimum_evidence_failed &&
|
||||
!hasCoverageGaps) {
|
||||
return {
|
||||
mode: "focused_grounded",
|
||||
fallback_type: "none",
|
||||
reply_type: "factual_with_explanation"
|
||||
};
|
||||
}
|
||||
if (input.okResults.length > 0 ||
|
||||
input.partialResults.length > 0 ||
|
||||
hasCoverageGaps ||
|
||||
input.policySignals.minimum_evidence_failed ||
|
||||
input.policySignals.broad_result_flag ||
|
||||
input.groundingCheck.status === "partial") {
|
||||
return {
|
||||
mode: "broad_partial",
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: "backend_error",
|
||||
fallback_type: "unknown",
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
function buildAnswerSummary(mode) {
|
||||
if (mode === "focused_grounded")
|
||||
return "Сформирован прямой ответ на основе подтвержденной опоры.";
|
||||
if (mode === "broad_partial")
|
||||
return "Вывод ограничен: есть частичная опора, но не полный coverage.";
|
||||
if (mode === "clarification_required")
|
||||
return "Нужны уточнения: без сужения strong factual вывод ненадежен.";
|
||||
if (mode === "out_of_scope")
|
||||
return "Запрос вне доступного учетного контура.";
|
||||
if (mode === "route_mismatch")
|
||||
return "Результат маршрута не совпал с предметом вопроса.";
|
||||
if (mode === "empty")
|
||||
return "В текущем срезе данных релевантные записи не обнаружены.";
|
||||
if (mode === "no_grounded")
|
||||
return "Недостаточно опоры для обоснованного ответа.";
|
||||
return "Не удалось собрать обоснованный ответ по текущему запросу.";
|
||||
}
|
||||
function buildDirectAnswer(input) {
|
||||
const topFact = firstMeaningfulFact(input.retrievalResults);
|
||||
if (input.mode === "focused_grounded") {
|
||||
return topFact ?? "Подтвержденный результат получен; можно продолжать предметную проверку без деградации.";
|
||||
}
|
||||
if (input.mode === "broad_partial") {
|
||||
if (topFact) {
|
||||
return `Доступен ограниченный подтвержденный фрагмент: ${topFact}`;
|
||||
}
|
||||
return "Есть только ограниченная опора; вывод дан в частичном режиме без ложной точности.";
|
||||
}
|
||||
if (input.mode === "clarification_required") {
|
||||
return "Текущий запрос слишком широкий или недоопределен; надежный factual вывод пока невозможен.";
|
||||
}
|
||||
if (input.mode === "out_of_scope") {
|
||||
return "Могу отвечать только в пределах данных доступного учетного контура.";
|
||||
}
|
||||
if (input.mode === "route_mismatch") {
|
||||
return "Предмет результата не совпал с предметом вопроса; требуется уточнение фокуса.";
|
||||
}
|
||||
if (input.mode === "empty") {
|
||||
return "В текущем срезе данных проблемные записи по заданному условию не найдены.";
|
||||
}
|
||||
if (input.mode === "no_grounded") {
|
||||
return "Недостаточно подтвержденной опоры для ответа в требуемой точности.";
|
||||
}
|
||||
if (input.policySignals.minimum_evidence_failed) {
|
||||
return "Маршрут отработал, но минимальная evidence-опора не пройдена.";
|
||||
}
|
||||
return "Не удалось сформировать обоснованный ответ; нужно уточнение запроса.";
|
||||
}
|
||||
function renderPolicyReply(structure) {
|
||||
const mechanismLines = [`status=${structure.mechanism_block.status}`];
|
||||
if (structure.mechanism_block.mechanism_notes.length > 0) {
|
||||
mechanismLines.push(...structure.mechanism_block.mechanism_notes.map((item) => `note: ${item}`));
|
||||
}
|
||||
if (structure.mechanism_block.limitation_reason_codes.length > 0) {
|
||||
mechanismLines.push(`limitation_codes: ${structure.mechanism_block.limitation_reason_codes.join(", ")}`);
|
||||
}
|
||||
if (structure.mechanism_block.status === "unresolved" && structure.mechanism_block.mechanism_notes.length === 0) {
|
||||
mechanismLines.push("mechanism_note is intentionally omitted due to weak or missing mechanism evidence");
|
||||
}
|
||||
const evidenceLines = [
|
||||
`coverage=${structure.evidence_block.coverage_note}`,
|
||||
`evidence_ids=${structure.evidence_block.evidence_ids.length > 0 ? structure.evidence_block.evidence_ids.join(", ") : "none"}`
|
||||
];
|
||||
if (Array.isArray(structure.evidence_block.source_refs) && structure.evidence_block.source_refs.length > 0) {
|
||||
evidenceLines.push(`source_refs=${structure.evidence_block.source_refs.join(", ")}`);
|
||||
}
|
||||
if (Array.isArray(structure.evidence_block.claim_evidence_links) && structure.evidence_block.claim_evidence_links.length > 0) {
|
||||
const compactLinks = structure.evidence_block.claim_evidence_links
|
||||
.slice(0, 4)
|
||||
.map((item) => `${item.claim_ref}:${item.evidence_ids.join("|")}`);
|
||||
evidenceLines.push(`claim_evidence_links=${compactLinks.join("; ")}`);
|
||||
}
|
||||
const uncertaintyLines = [
|
||||
...structure.uncertainty_block.open_uncertainties.map((item) => `open: ${item}`),
|
||||
...structure.uncertainty_block.limitations.map((item) => `limit: ${item}`)
|
||||
];
|
||||
if (uncertaintyLines.length === 0) {
|
||||
uncertaintyLines.push("No material uncertainty detected in current scoped answer.");
|
||||
}
|
||||
const nextStepLines = [
|
||||
...structure.next_step_block.recommended_actions.map((item) => `action: ${item}`),
|
||||
...structure.next_step_block.clarification_questions.map((item) => `clarify: ${item}`)
|
||||
];
|
||||
if (nextStepLines.length === 0) {
|
||||
nextStepLines.push("No additional action is required for this scoped answer.");
|
||||
}
|
||||
return [
|
||||
`Answer summary: ${structure.answer_summary}`,
|
||||
`Direct answer:\n${structure.direct_answer}`,
|
||||
`Mechanism block:\n${formatList(mechanismLines)}`,
|
||||
`Evidence block:\n${formatList(evidenceLines)}`,
|
||||
`Uncertainty block:\n${formatList(uncertaintyLines)}`,
|
||||
`Next step block:\n${formatList(nextStepLines)}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
function composeAssistantAnswerV11(input) {
|
||||
const fallbackType = fallbackFromSummary(input.routeSummary);
|
||||
const okResults = input.retrievalResults.filter((item) => item.status === "ok");
|
||||
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
|
||||
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
|
||||
const errorResults = input.retrievalResults.filter((item) => item.status === "error");
|
||||
const evidenceItems = flattenEvidence(input.retrievalResults);
|
||||
const policySignals = aggregatePolicySignals(input.retrievalResults);
|
||||
const limitationReasonCodes = collectLimitationReasonCodes(evidenceItems);
|
||||
const sourceRefs = uniqueStrings(evidenceItems
|
||||
.map((item) => item.source_ref?.canonical_ref)
|
||||
.filter((item) => typeof item === "string" && item.trim().length > 0), 8);
|
||||
const mechanismNotes = uniqueStrings(evidenceItems
|
||||
.map((item) => item.mechanism_note)
|
||||
.filter((item) => typeof item === "string" && item.trim().length > 0), 6);
|
||||
const claimEvidenceLinks = buildClaimEvidenceLinks(input.retrievalResults);
|
||||
const aggregateEvidenceConfidence = aggregateConfidence(input.retrievalResults, evidenceItems);
|
||||
const hasSupport = okResults.length > 0 ||
|
||||
partialResults.length > 0 ||
|
||||
evidenceItems.length > 0 ||
|
||||
input.retrievalResults.some((item) => item.items.length > 0);
|
||||
const hasCoverageGaps = input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0;
|
||||
const hasCriticalEvidenceLimitation = limitationReasonCodes.includes("weak_source_mapping") ||
|
||||
limitationReasonCodes.includes("insufficient_detail");
|
||||
const hasNonLowRouteConfidence = input.retrievalResults.some((item) => item.status === "ok" && item.confidence !== "low");
|
||||
const focusedStrong = okResults.length > 0 &&
|
||||
input.groundingCheck.status === "grounded" &&
|
||||
!hasCoverageGaps &&
|
||||
!policySignals.broad_query_detected &&
|
||||
!policySignals.broad_result_flag &&
|
||||
!policySignals.minimum_evidence_failed &&
|
||||
!hasCriticalEvidenceLimitation &&
|
||||
(aggregateEvidenceConfidence !== "low" || hasNonLowRouteConfidence);
|
||||
const decision = buildPolicyDecision({
|
||||
fallbackType,
|
||||
coverageReport: input.coverageReport,
|
||||
groundingCheck: input.groundingCheck,
|
||||
okResults,
|
||||
partialResults,
|
||||
emptyResults,
|
||||
errorResults,
|
||||
hasSupport,
|
||||
focusedStrong,
|
||||
policySignals
|
||||
});
|
||||
const missingAnchors = detectMissingAnchors(input.userMessage);
|
||||
const clarificationQuestions = buildClarificationQuestions({
|
||||
mode: decision.mode,
|
||||
missingAnchors,
|
||||
coverageReport: input.coverageReport,
|
||||
policySignals
|
||||
});
|
||||
const recommendedActions = buildRecommendedActions({
|
||||
mode: decision.mode,
|
||||
coverageReport: input.coverageReport,
|
||||
policySignals,
|
||||
limitationReasonCodes,
|
||||
sourceRefs
|
||||
});
|
||||
const limitations = uniqueStrings([
|
||||
...limitationReasonCodes.map((code) => limitationReasonToText(code)),
|
||||
...extractLimitations(input.retrievalResults),
|
||||
...input.groundingCheck.reasons,
|
||||
...(policySignals.minimum_evidence_failed ? ["Minimum evidence gate failed for current scope."] : []),
|
||||
...(policySignals.broad_query_detected && policySignals.narrowing_strength === "weak"
|
||||
? ["Broad query remains weakly narrowed; precision is intentionally limited."]
|
||||
: [])
|
||||
], 10);
|
||||
const openUncertainties = uniqueStrings([
|
||||
...input.groundingCheck.missing_requirements,
|
||||
...(decision.mode === "clarification_required" && missingAnchors.period ? ["missing_anchor:period"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.account ? ["missing_anchor:account"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.documentOrObject ? ["missing_anchor:document_or_object"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.counterparty ? ["missing_anchor:counterparty"] : [])
|
||||
], 8);
|
||||
const mechanismStatus = mechanismNotes.length === 0
|
||||
? "unresolved"
|
||||
: limitationReasonCodes.includes("missing_mechanism") || limitationReasonCodes.includes("heuristic_inference")
|
||||
? "limited"
|
||||
: "grounded";
|
||||
const answerStructure = {
|
||||
schema_version: "answer_structure_v1_1",
|
||||
answer_summary: buildAnswerSummary(decision.mode),
|
||||
direct_answer: buildDirectAnswer({
|
||||
mode: decision.mode,
|
||||
retrievalResults: input.retrievalResults,
|
||||
policySignals
|
||||
}),
|
||||
mechanism_block: {
|
||||
status: mechanismStatus,
|
||||
mechanism_notes: mechanismNotes,
|
||||
limitation_reason_codes: limitationReasonCodes
|
||||
},
|
||||
evidence_block: {
|
||||
evidence_ids: uniqueStrings(evidenceItems.map((item) => item.evidence_id), 10),
|
||||
source_refs: sourceRefs,
|
||||
mechanism_notes: mechanismNotes,
|
||||
coverage_note: input.coverageReport.requirements_total > 0 &&
|
||||
input.coverageReport.requirements_total === input.coverageReport.requirements_covered &&
|
||||
input.coverageReport.requirements_uncovered.length === 0 &&
|
||||
input.coverageReport.requirements_partially_covered.length === 0
|
||||
? "coverage_full_or_near_full"
|
||||
: "coverage_partial_or_limited",
|
||||
...(claimEvidenceLinks.length > 0
|
||||
? {
|
||||
claim_evidence_links: claimEvidenceLinks
|
||||
}
|
||||
: {})
|
||||
},
|
||||
uncertainty_block: {
|
||||
open_uncertainties: openUncertainties,
|
||||
limitations
|
||||
},
|
||||
next_step_block: {
|
||||
recommended_actions: recommendedActions,
|
||||
clarification_questions: clarificationQuestions
|
||||
}
|
||||
};
|
||||
return {
|
||||
assistant_reply: renderPolicyReply(answerStructure),
|
||||
fallback_type: decision.fallback_type,
|
||||
reply_type: decision.reply_type,
|
||||
answer_structure_v11: answerStructure
|
||||
};
|
||||
}
|
||||
function composeExplainableAnswer(input, scopeLabel) {
|
||||
const facts = extractTopFacts(input.retrievalResults);
|
||||
const whyIncluded = extractWhyIncluded(input.retrievalResults);
|
||||
const selectionReasons = extractSelectionReasons(input.retrievalResults);
|
||||
const riskFactors = extractRiskFactors(input.retrievalResults);
|
||||
const interpretation = extractBusinessInterpretation(input.retrievalResults);
|
||||
const limitations = uniqueStrings([...extractLimitations(input.retrievalResults), ...input.groundingCheck.reasons]);
|
||||
const nextSteps = suggestNextStep(input.requirements, input.coverageReport);
|
||||
const lead = scopeLabel === "full"
|
||||
? "Итог: запрос обработан по предмету, найденные объекты подтверждены данными контура."
|
||||
: "Итог: запрос обработан частично, ниже подтвержденная часть и ограничения.";
|
||||
return [
|
||||
lead,
|
||||
facts.length > 0 ? "Подтвержденные результаты:\n" + formatList(facts) : "",
|
||||
whyIncluded.length > 0 ? "Почему это попало в ответ:\n" + formatList(whyIncluded) : "",
|
||||
selectionReasons.length > 0 ? "Основание отбора:\n" + formatList(selectionReasons) : "",
|
||||
riskFactors.length > 0 ? "Подтверждающие признаки:\n" + formatList(riskFactors) : "",
|
||||
interpretation.length > 0 ? "Практический смысл:\n" + formatList(interpretation) : "",
|
||||
limitations.length > 0 ? "Ограничения:\n" + formatList(limitations) : "",
|
||||
nextSteps.length > 0 ? "Что проверить дальше:\n" + formatList(nextSteps) : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
function composeAssistantAnswer(input) {
|
||||
if (input.enableAnswerPolicyV11) {
|
||||
return composeAssistantAnswerV11(input);
|
||||
}
|
||||
const fallbackType = fallbackFromSummary(input.routeSummary);
|
||||
const okResults = input.retrievalResults.filter((item) => item.status === "ok");
|
||||
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
|
||||
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
|
||||
const errorResults = input.retrievalResults.filter((item) => item.status === "error");
|
||||
const hasBroadMinimumEvidenceSignal = input.retrievalResults.some((item) => summaryBoolean(item, "broad_guard_applied") && summaryBoolean(item, "minimum_evidence_failed"));
|
||||
const hasBroadClarificationSignal = input.retrievalResults.some((item) => summaryBoolean(item, "broad_guard_applied") &&
|
||||
summaryBoolean(item, "minimum_evidence_failed") &&
|
||||
summaryString(item, "degraded_to") === "clarification");
|
||||
if (fallbackType === "out_of_scope" && input.coverageReport.requirements_covered === 0) {
|
||||
return {
|
||||
assistant_reply: "Я могу отвечать только по данным вашей учетной базы. Этот запрос выходит за рамки доступного контура.",
|
||||
fallback_type: "out_of_scope",
|
||||
reply_type: "out_of_scope"
|
||||
};
|
||||
}
|
||||
if (input.groundingCheck.status === "route_mismatch_blocked") {
|
||||
return {
|
||||
assistant_reply: [
|
||||
"Не отправляю финальный ответ, потому что предмет результата не совпал с предметом вопроса.",
|
||||
"Уточните формулировку (например, нужный счет/участок учета), и я выполню повторный проход."
|
||||
].join("\n\n"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "route_mismatch_blocked"
|
||||
};
|
||||
}
|
||||
if (input.groundingCheck.status === "no_grounded_answer" && okResults.length === 0 && !hasBroadMinimumEvidenceSignal) {
|
||||
return {
|
||||
assistant_reply: "Пока не удалось собрать предметно подтвержденный ответ по вашему вопросу. Нужны дополнительные уточнения по периоду или объекту проверки.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "no_grounded_answer"
|
||||
};
|
||||
}
|
||||
if (hasBroadClarificationSignal && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: "Запрос слишком широкий для надежного вывода по текущей опоре. Уточните период, участок учета или объект проверки, после чего я дам предметный результат.",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
if (fallbackType === "clarification" && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: "Уточните, пожалуйста, период, счет, документ или контрагента, чтобы закрыть все части вопроса корректно.",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
if (errorResults.length > 0 && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: "Не удалось получить данные из контура. Попробуйте повторить запрос или уточнить формулировку.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
if (partialResults.length > 0 && okResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "partial"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
if (okResults.length === 0 && partialResults.length === 0 && emptyResults.length > 0) {
|
||||
return {
|
||||
assistant_reply: "По заданному условию в текущем срезе данных явных проблемных записей не найдено.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "empty_but_valid"
|
||||
};
|
||||
}
|
||||
const hasPartialCoverage = input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0 ||
|
||||
input.groundingCheck.status === "partial" ||
|
||||
errorResults.length > 0;
|
||||
if (okResults.length > 0 && hasPartialCoverage) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "partial"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
if (okResults.length > 0) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "full"),
|
||||
fallback_type: "none",
|
||||
reply_type: "factual_with_explanation"
|
||||
};
|
||||
}
|
||||
return {
|
||||
assistant_reply: "По текущему запросу не удалось построить обоснованный ответ. Уточните формулировку и попробуйте снова.",
|
||||
fallback_type: "unknown",
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AssistantSessionLogger = void 0;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const config_1 = require("../config");
|
||||
const files_1 = require("../utils/files");
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter((item) => typeof item === "string" && item.length > 0)));
|
||||
}
|
||||
function toObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function toStringOrNull(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
function extractFragments(assistantItem) {
|
||||
if (!assistantItem.debug || !Array.isArray(assistantItem.debug.fragments)) {
|
||||
return [];
|
||||
}
|
||||
return assistantItem.debug.fragments
|
||||
.map((item) => toObject(item))
|
||||
.filter((item) => item !== null);
|
||||
}
|
||||
function extractNormalizedQuestion(userText, assistantItem) {
|
||||
const normalized = toObject(assistantItem.debug?.normalized);
|
||||
if (normalized) {
|
||||
const fromUserMessageRaw = toStringOrNull(normalized.user_message_raw);
|
||||
if (fromUserMessageRaw)
|
||||
return fromUserMessageRaw;
|
||||
const fromUserQuestionRaw = toStringOrNull(normalized.user_question_raw);
|
||||
if (fromUserQuestionRaw)
|
||||
return fromUserQuestionRaw;
|
||||
const fromNormalizedQuestion = toStringOrNull(normalized.normalized_question);
|
||||
if (fromNormalizedQuestion)
|
||||
return fromNormalizedQuestion;
|
||||
}
|
||||
const fragments = extractFragments(assistantItem);
|
||||
if (fragments.length > 0) {
|
||||
const joined = fragments
|
||||
.map((fragment) => toStringOrNull(fragment.normalized_fragment_text) ?? toStringOrNull(fragment.raw_fragment_text))
|
||||
.filter((item) => Boolean(item))
|
||||
.join(" | ");
|
||||
if (joined) {
|
||||
return joined;
|
||||
}
|
||||
}
|
||||
return userText;
|
||||
}
|
||||
function buildRouteLookup(assistantItem) {
|
||||
const output = new Map();
|
||||
if (!assistantItem.debug || !Array.isArray(assistantItem.debug.routes)) {
|
||||
return output;
|
||||
}
|
||||
for (const route of assistantItem.debug.routes) {
|
||||
const routeObject = toObject(route);
|
||||
if (!routeObject)
|
||||
continue;
|
||||
const fragmentId = toStringOrNull(routeObject.fragment_id);
|
||||
if (!fragmentId)
|
||||
continue;
|
||||
output.set(fragmentId, routeObject);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
function buildDecompositionLines(assistantItem) {
|
||||
const fragments = extractFragments(assistantItem);
|
||||
if (fragments.length === 0) {
|
||||
return ["Фрагменты декомпозиции не выделены."];
|
||||
}
|
||||
const routeLookup = buildRouteLookup(assistantItem);
|
||||
return fragments.map((fragment, index) => {
|
||||
const fragmentId = toStringOrNull(fragment.fragment_id) ?? `F${index + 1}`;
|
||||
const fragmentText = toStringOrNull(fragment.normalized_fragment_text) ??
|
||||
toStringOrNull(fragment.raw_fragment_text) ??
|
||||
"текст фрагмента отсутствует";
|
||||
const executionReadiness = toStringOrNull(fragment.execution_readiness);
|
||||
const routeStatus = toStringOrNull(fragment.route_status);
|
||||
const routeObject = routeLookup.get(fragmentId);
|
||||
const route = toStringOrNull(routeObject?.route);
|
||||
const noRouteReason = toStringOrNull(fragment.no_route_reason) ?? toStringOrNull(routeObject?.no_route_reason);
|
||||
const parts = [`${fragmentId}: ${fragmentText}`];
|
||||
if (executionReadiness)
|
||||
parts.push(`execution_readiness=${executionReadiness}`);
|
||||
if (routeStatus)
|
||||
parts.push(`route_status=${routeStatus}`);
|
||||
if (route)
|
||||
parts.push(`route=${route}`);
|
||||
if (noRouteReason)
|
||||
parts.push(`no_route_reason=${noRouteReason}`);
|
||||
return parts.join("; ");
|
||||
});
|
||||
}
|
||||
function toHumanBlock(input) {
|
||||
const lines = [];
|
||||
lines.push(`Вопрос: ${input.questionRaw}`);
|
||||
lines.push(`Понято как: ${input.questionUnderstood}`);
|
||||
lines.push("Декомпозиция:");
|
||||
lines.push(...input.decomposition.map((item) => `- ${item}`));
|
||||
lines.push(`Ответ: ${input.answer}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
function buildTurns(items) {
|
||||
const turns = [];
|
||||
const pendingUsers = [];
|
||||
for (const item of items) {
|
||||
if (item.role === "user") {
|
||||
pendingUsers.push(item);
|
||||
continue;
|
||||
}
|
||||
const pairedUser = pendingUsers.shift();
|
||||
if (!pairedUser) {
|
||||
continue;
|
||||
}
|
||||
const questionRaw = pairedUser.text;
|
||||
const questionUnderstood = extractNormalizedQuestion(questionRaw, item);
|
||||
const decomposition = buildDecompositionLines(item);
|
||||
const answer = item.text;
|
||||
turns.push({
|
||||
turn_id: `turn-${turns.length + 1}`,
|
||||
started_at: pairedUser.created_at ?? null,
|
||||
completed_at: item.created_at ?? null,
|
||||
human_block: toHumanBlock({
|
||||
questionRaw,
|
||||
questionUnderstood,
|
||||
decomposition,
|
||||
answer
|
||||
}),
|
||||
human_readable: {
|
||||
question_raw: questionRaw,
|
||||
question_understood: questionUnderstood,
|
||||
decomposition,
|
||||
answer,
|
||||
reply_type: item.reply_type
|
||||
},
|
||||
technical_json: {
|
||||
trace_id: item.trace_id,
|
||||
user_message: pairedUser,
|
||||
assistant_message: item,
|
||||
debug: item.debug
|
||||
}
|
||||
});
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
class AssistantSessionLogger {
|
||||
rootDir;
|
||||
constructor(rootDir = config_1.ASSISTANT_SESSIONS_DIR) {
|
||||
this.rootDir = rootDir;
|
||||
}
|
||||
persistSession(session) {
|
||||
(0, files_1.ensureDir)(this.rootDir);
|
||||
const filePath = path_1.default.resolve(this.rootDir, `${session.session_id}.json`);
|
||||
const startedAt = session.items[0]?.created_at ?? session.updated_at;
|
||||
const userMessages = session.items.filter((item) => item.role === "user").length;
|
||||
const assistantMessages = session.items.filter((item) => item.role === "assistant").length;
|
||||
const assistantItems = session.items.filter((item) => item.role === "assistant");
|
||||
const lastAssistant = assistantItems.length > 0 ? assistantItems[assistantItems.length - 1] : null;
|
||||
const traceIds = unique(session.items.map((item) => item.trace_id));
|
||||
const replyTypes = Array.from(new Set(session.items
|
||||
.map((item) => item.reply_type)
|
||||
.filter((item) => typeof item === "string" && item.length > 0)));
|
||||
const turns = buildTurns(session.items);
|
||||
const record = {
|
||||
schema_version: "assistant_session_log_v1",
|
||||
session_id: session.session_id,
|
||||
started_at: startedAt,
|
||||
updated_at: session.updated_at,
|
||||
counters: {
|
||||
total_messages: session.items.length,
|
||||
user_messages: userMessages,
|
||||
assistant_messages: assistantMessages
|
||||
},
|
||||
trace_ids: traceIds,
|
||||
reply_types: replyTypes,
|
||||
investigation_state: session.investigation_state,
|
||||
turns,
|
||||
conversation: session.items,
|
||||
last_assistant: {
|
||||
message_id: lastAssistant?.message_id ?? null,
|
||||
reply_type: lastAssistant?.reply_type ?? null,
|
||||
trace_id: lastAssistant?.trace_id ?? null,
|
||||
created_at: lastAssistant?.created_at ?? null
|
||||
}
|
||||
};
|
||||
(0, files_1.writeJsonFile)(filePath, record);
|
||||
}
|
||||
}
|
||||
exports.AssistantSessionLogger = AssistantSessionLogger;
|
||||
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AssistantSessionStore = void 0;
|
||||
const nanoid_1 = require("nanoid");
|
||||
const config_1 = require("../config");
|
||||
const investigationState_1 = require("./investigationState");
|
||||
const MAX_ITEMS_PER_SESSION = 200;
|
||||
function cloneItem(item) {
|
||||
return {
|
||||
...item,
|
||||
debug: item.debug ? { ...item.debug } : null
|
||||
};
|
||||
}
|
||||
function cloneSession(state) {
|
||||
return {
|
||||
session_id: state.session_id,
|
||||
updated_at: state.updated_at,
|
||||
items: state.items.map(cloneItem),
|
||||
investigation_state: (0, investigationState_1.cloneInvestigationState)(state.investigation_state)
|
||||
};
|
||||
}
|
||||
function normalizeSessionShape(state) {
|
||||
const legacy = state;
|
||||
const normalizedItems = Array.isArray(legacy.items) ? legacy.items : [];
|
||||
const investigationState = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1
|
||||
? legacy.investigation_state ?? (0, investigationState_1.createEmptyInvestigationState)(state.session_id)
|
||||
: legacy.investigation_state ?? null;
|
||||
state.items = normalizedItems;
|
||||
state.updated_at = typeof legacy.updated_at === "string" && legacy.updated_at.trim() ? legacy.updated_at : new Date().toISOString();
|
||||
state.investigation_state = investigationState;
|
||||
return state;
|
||||
}
|
||||
class AssistantSessionStore {
|
||||
sessions = new Map();
|
||||
ensureSession(sessionId) {
|
||||
const resolvedId = (sessionId ?? "").trim() || `asst-${(0, nanoid_1.nanoid)(10)}`;
|
||||
const existing = this.sessions.get(resolvedId);
|
||||
if (existing) {
|
||||
return cloneSession(normalizeSessionShape(existing));
|
||||
}
|
||||
const created = {
|
||||
session_id: resolvedId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? (0, investigationState_1.createEmptyInvestigationState)(resolvedId) : null
|
||||
};
|
||||
this.sessions.set(resolvedId, created);
|
||||
return cloneSession(created);
|
||||
}
|
||||
appendItem(sessionId, item) {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
session.items.push(item);
|
||||
if (session.items.length > MAX_ITEMS_PER_SESSION) {
|
||||
session.items = session.items.slice(session.items.length - MAX_ITEMS_PER_SESSION);
|
||||
}
|
||||
session.updated_at = new Date().toISOString();
|
||||
return cloneItem(item);
|
||||
}
|
||||
getSession(sessionId) {
|
||||
const found = this.sessions.get(sessionId);
|
||||
return found ? cloneSession(normalizeSessionShape(found)) : null;
|
||||
}
|
||||
setInvestigationState(sessionId, state) {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
session.investigation_state = (0, investigationState_1.cloneInvestigationState)(state);
|
||||
session.updated_at = new Date().toISOString();
|
||||
return (0, investigationState_1.cloneInvestigationState)(session.investigation_state);
|
||||
}
|
||||
ensureMutableSession(sessionId) {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (existing) {
|
||||
return normalizeSessionShape(existing);
|
||||
}
|
||||
const created = {
|
||||
session_id: sessionId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? (0, investigationState_1.createEmptyInvestigationState)(sessionId) : null
|
||||
};
|
||||
this.sessions.set(sessionId, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
exports.AssistantSessionStore = AssistantSessionStore;
|
||||
+1542
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.cloneInvestigationState = cloneInvestigationState;
|
||||
exports.createEmptyInvestigationState = createEmptyInvestigationState;
|
||||
exports.updateInvestigationState = updateInvestigationState;
|
||||
const stage1Contracts_1 = require("../types/stage1Contracts");
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
function capStrings(values, max) {
|
||||
return uniqueStrings(values).slice(0, max);
|
||||
}
|
||||
function detectAccounts(text) {
|
||||
return capStrings(text.match(/\b\d{2}(?:\.\d{2})?\b/g) ?? [], stage1Contracts_1.INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
|
||||
}
|
||||
function detectPeriod(text) {
|
||||
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
|
||||
if (monthly)
|
||||
return `${monthly[1]}-${monthly[2]}`;
|
||||
const yearly = text.match(/\b(20\d{2})\b/);
|
||||
if (yearly)
|
||||
return yearly[1];
|
||||
return null;
|
||||
}
|
||||
function deriveDomain(routeSummary) {
|
||||
if (!routeSummary)
|
||||
return null;
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return routeSummary.route_hint;
|
||||
}
|
||||
const routes = routeSummary.decisions.map((item) => item.route).filter((route) => route !== "no_route");
|
||||
const uniqueRoutes = uniqueStrings(routes);
|
||||
if (uniqueRoutes.length === 0) {
|
||||
return "no_route";
|
||||
}
|
||||
return uniqueRoutes.join(",");
|
||||
}
|
||||
function deriveNarrowingStatus(routeSummary, coverageReport) {
|
||||
if (!routeSummary) {
|
||||
return "unknown";
|
||||
}
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return "not_needed";
|
||||
}
|
||||
if (routeSummary.fallback.type === "clarification" || coverageReport.clarification_needed_for.length > 0) {
|
||||
return "needs_clarification";
|
||||
}
|
||||
const hasNoRoute = routeSummary.decisions.some((item) => item.route === "no_route");
|
||||
if (hasNoRoute) {
|
||||
return "broad_guarded";
|
||||
}
|
||||
return routeSummary.decisions.length > 1 ? "applied" : "not_needed";
|
||||
}
|
||||
function deriveQueryModeHint(routeSummary) {
|
||||
if (!routeSummary) {
|
||||
return "investigation_candidate";
|
||||
}
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return "direct_answer";
|
||||
}
|
||||
return routeSummary.fallback.type === "none" ? "direct_answer" : "investigation_candidate";
|
||||
}
|
||||
function collectEvidenceRefs(retrievalResults) {
|
||||
const refs = retrievalResults.flatMap((result) => result.evidence.map((item) => item.evidence_id));
|
||||
return capStrings(refs, stage1Contracts_1.INVESTIGATION_MAX_EVIDENCE_REFS);
|
||||
}
|
||||
function collectOpenUncertainties(coverageReport, retrievalResults) {
|
||||
const requirementNotes = [
|
||||
...coverageReport.requirements_uncovered.map((item) => `uncovered:${item}`),
|
||||
...coverageReport.requirements_partially_covered.map((item) => `partial:${item}`),
|
||||
...coverageReport.clarification_needed_for.map((item) => `clarify:${item}`),
|
||||
...coverageReport.out_of_scope_requirements.map((item) => `out_of_scope:${item}`)
|
||||
];
|
||||
const limitationNotes = retrievalResults.flatMap((result) => result.limitations).slice(0, 6);
|
||||
return capStrings([...requirementNotes, ...limitationNotes], stage1Contracts_1.INVESTIGATION_MAX_UNCERTAINTIES);
|
||||
}
|
||||
function cloneInvestigationState(state) {
|
||||
if (!state)
|
||||
return null;
|
||||
return {
|
||||
...state,
|
||||
focus: {
|
||||
...state.focus,
|
||||
primary_accounts: [...state.focus.primary_accounts]
|
||||
},
|
||||
evidence_refs: [...state.evidence_refs],
|
||||
open_uncertainties: [...state.open_uncertainties],
|
||||
followup_context: state.followup_context
|
||||
? {
|
||||
...state.followup_context,
|
||||
referenced_requirement_ids: [...state.followup_context.referenced_requirement_ids]
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
function createEmptyInvestigationState(sessionId, timestamp = new Date().toISOString()) {
|
||||
return {
|
||||
schema_version: stage1Contracts_1.INVESTIGATION_STATE_SCHEMA_VERSION,
|
||||
session_id: sessionId,
|
||||
status: "idle",
|
||||
turn_index: 0,
|
||||
updated_at: timestamp,
|
||||
question_id: null,
|
||||
focus: {
|
||||
domain: null,
|
||||
period: null,
|
||||
primary_accounts: [],
|
||||
active_query_subject: null
|
||||
},
|
||||
narrowing_status: "unknown",
|
||||
evidence_refs: [],
|
||||
open_uncertainties: [],
|
||||
last_answer_mode: null,
|
||||
followup_context: null,
|
||||
query_mode_hint: "direct_answer"
|
||||
};
|
||||
}
|
||||
function updateInvestigationState(input) {
|
||||
const previous = input.previous;
|
||||
const focusFromMessage = capStrings(detectAccounts(input.userMessage), stage1Contracts_1.INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
|
||||
const requirementIds = capStrings(input.requirements.map((item) => item.requirement_id), stage1Contracts_1.INVESTIGATION_MAX_REQUIREMENT_LINKS);
|
||||
const mainRequirement = input.requirements[0]?.requirement_text ?? input.userMessage;
|
||||
return {
|
||||
schema_version: stage1Contracts_1.INVESTIGATION_STATE_SCHEMA_VERSION,
|
||||
session_id: previous.session_id,
|
||||
status: "active",
|
||||
turn_index: previous.turn_index + 1,
|
||||
updated_at: input.timestamp,
|
||||
question_id: input.questionId,
|
||||
focus: {
|
||||
domain: deriveDomain(input.routeSummary) ?? previous.focus.domain,
|
||||
period: detectPeriod(input.userMessage) ?? previous.focus.period,
|
||||
primary_accounts: capStrings([...focusFromMessage, ...previous.focus.primary_accounts], stage1Contracts_1.INVESTIGATION_MAX_PRIMARY_ACCOUNTS),
|
||||
active_query_subject: mainRequirement.slice(0, 180)
|
||||
},
|
||||
narrowing_status: deriveNarrowingStatus(input.routeSummary, input.coverageReport),
|
||||
evidence_refs: capStrings([...collectEvidenceRefs(input.retrievalResults), ...previous.evidence_refs], stage1Contracts_1.INVESTIGATION_MAX_EVIDENCE_REFS),
|
||||
open_uncertainties: collectOpenUncertainties(input.coverageReport, input.retrievalResults),
|
||||
last_answer_mode: input.replyType,
|
||||
followup_context: {
|
||||
previous_question_id: previous.question_id,
|
||||
last_user_message: input.userMessage.slice(0, 240),
|
||||
referenced_requirement_ids: requirementIds
|
||||
},
|
||||
query_mode_hint: deriveQueryModeHint(input.routeSummary)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NormalizerService = void 0;
|
||||
const nanoid_1 = require("nanoid");
|
||||
const config_1 = require("../config");
|
||||
const promptBuilder_1 = require("./promptBuilder");
|
||||
const routeHintAdapter_1 = require("./routeHintAdapter");
|
||||
const schemaValidator_1 = require("./schemaValidator");
|
||||
const traceLogger_1 = require("./traceLogger");
|
||||
const RETRY_INSTRUCTION_V1 = "IMPORTANT: return valid JSON strictly matching schema normalized_query_v1. No markdown.";
|
||||
const RETRY_INSTRUCTION_V2 = "IMPORTANT: return valid JSON strictly matching schema normalized_query_v2. No markdown.";
|
||||
const RETRY_INSTRUCTION_V2_0_1 = "IMPORTANT: return valid JSON strictly matching schema normalized_query_v2_0_1. No markdown.";
|
||||
const RETRY_INSTRUCTION_V2_0_2 = "IMPORTANT: return valid JSON strictly matching schema normalized_query_v2_0_2. No markdown.";
|
||||
function safeJsonParse(text) {
|
||||
const cleaned = text.trim().replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```$/i, "").trim();
|
||||
return JSON.parse(cleaned);
|
||||
}
|
||||
function resolveSchemaVersion(payload) {
|
||||
const explicit = String(payload.schemaVersion ?? "").toLowerCase().trim();
|
||||
if (explicit === "v2_0_2" || explicit === "normalized_query_v2_0_2") {
|
||||
return "v2_0_2";
|
||||
}
|
||||
if (explicit === "v2_0_1" || explicit === "normalized_query_v2_0_1") {
|
||||
return "v2_0_1";
|
||||
}
|
||||
if (explicit === "v2" || explicit === "normalized_query_v2") {
|
||||
return "v2";
|
||||
}
|
||||
if (explicit === "v1" || explicit === "normalized_query_v1") {
|
||||
return "v1";
|
||||
}
|
||||
const promptVersion = String(payload.promptVersion ?? config_1.DEFAULT_PROMPT_VERSION).toLowerCase().trim();
|
||||
if (promptVersion === "normalizer_v2" || promptVersion.startsWith("normalizer_v2")) {
|
||||
if (promptVersion === "normalizer_v2_0_2") {
|
||||
return "v2_0_2";
|
||||
}
|
||||
if (promptVersion === "normalizer_v2_0_1") {
|
||||
return "v2_0_1";
|
||||
}
|
||||
return "v2";
|
||||
}
|
||||
return "v1";
|
||||
}
|
||||
function shouldEscalateOutputBudget(rawModelResponse) {
|
||||
if (!rawModelResponse || typeof rawModelResponse !== "object") {
|
||||
return false;
|
||||
}
|
||||
const root = rawModelResponse;
|
||||
const status = String(root.status ?? "").toLowerCase();
|
||||
const details = (root.incomplete_details ?? {});
|
||||
const reason = String(details.reason ?? "").toLowerCase();
|
||||
return status === "incomplete" && reason === "max_output_tokens";
|
||||
}
|
||||
function computeRetryMaxOutputTokens(current, rawModelResponse) {
|
||||
if (!shouldEscalateOutputBudget(rawModelResponse)) {
|
||||
return current;
|
||||
}
|
||||
const escalated = Math.max(current + 400, Math.ceil(current * 1.6));
|
||||
return Math.min(escalated, 2400);
|
||||
}
|
||||
function collectDateSpans(text) {
|
||||
const spans = [];
|
||||
const datePattern = /\b20\d{2}[-/.](?:0[1-9]|1[0-2])(?:[-/.](?:0[1-9]|[12]\d|3[01]))?\b/g;
|
||||
let match = null;
|
||||
while ((match = datePattern.exec(text)) !== null) {
|
||||
spans.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length
|
||||
});
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
function intersectsAnySpan(start, end, spans) {
|
||||
return spans.some((span) => start < span.end && end > span.start);
|
||||
}
|
||||
function extractAccounts(text) {
|
||||
const lower = String(text ?? "").toLowerCase();
|
||||
const explicitAccounts = new Set();
|
||||
const contextualPattern = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b)\s*(?:№|#|:)?\s*(\d{2}(?:\.\d{2})?)/giu;
|
||||
let contextual = null;
|
||||
while ((contextual = contextualPattern.exec(lower)) !== null) {
|
||||
if (contextual[1]) {
|
||||
explicitAccounts.add(contextual[1]);
|
||||
}
|
||||
}
|
||||
if (explicitAccounts.size > 0) {
|
||||
return Array.from(explicitAccounts);
|
||||
}
|
||||
const spans = collectDateSpans(lower);
|
||||
const extracted = [];
|
||||
const genericPattern = /\b\d{2}(?:\.\d{2})?\b/g;
|
||||
let generic = null;
|
||||
while ((generic = genericPattern.exec(lower)) !== null) {
|
||||
const value = generic[0];
|
||||
const start = generic.index;
|
||||
const end = start + value.length;
|
||||
if (intersectsAnySpan(start, end, spans)) {
|
||||
continue;
|
||||
}
|
||||
extracted.push(value);
|
||||
}
|
||||
return Array.from(new Set(extracted));
|
||||
}
|
||||
function detectRouteByHeuristicsV1(question) {
|
||||
const q = question.toLowerCase();
|
||||
const hasExactTrace = /(документ\s*(№|#)|\bref\b|\bid\b|строк[аи].*проводк|конкретн(ый|ого|ая).*документ|точн(ый|ого).*источник|trx-\d+|inv-\d+)/i.test(q);
|
||||
const hasCrossChain = /(разлож|цепоч|чем подтверж|связк|документ.*оплат|закрывающ|взаиморасчет|хвост.*(документ|оплат|проводк))/i.test(q);
|
||||
const hasPeriodCloseRisk = /(предзакры|закрыти[ея].*период|перед сдачей отчетност|последн(ий|его).*(день|дня)|срыв.*закрыт|может взорвать)/i.test(q);
|
||||
const hasHeavyOverview = /(рейтинг|топ|в целом|обзор|приоритиз|company|самых|концентрац|срез)/i.test(q);
|
||||
const hasRiskProbe = /(аномал|подозр|зоны риска|ручной ошиб|подозрительн|риск|хвост)/i.test(q);
|
||||
const hasRuleControl = /(контрол|правил|ошибк.*дат|срок.*амортиз|настройк|\b97\b|\bос\b|68\.02|ндс)/i.test(q);
|
||||
if (hasExactTrace) {
|
||||
return "live_mcp_drilldown";
|
||||
}
|
||||
if (hasCrossChain) {
|
||||
return "hybrid_store_plus_live";
|
||||
}
|
||||
if (hasPeriodCloseRisk || hasHeavyOverview) {
|
||||
return "batch_refresh_then_store";
|
||||
}
|
||||
if (hasRiskProbe || hasRuleControl) {
|
||||
return "store_feature_risk";
|
||||
}
|
||||
return "store_canonical";
|
||||
}
|
||||
function buildMockNormalizedV1(userQuestion, expectedRoute) {
|
||||
const q = userQuestion.toLowerCase();
|
||||
const routeHint = expectedRoute ?? detectRouteByHeuristicsV1(userQuestion);
|
||||
const hasPeriod = /(январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр|квартал|период|конец месяца|20\d{2})/i.test(userQuestion);
|
||||
const hasHeavyGoal = /(рейтинг|топ|обзор|приоритиз|срез|в целом|концентрац|самых)/i.test(q);
|
||||
const hasCloseRisk = /(предзакры|закрыти[ея].*период|срыв.*закрыт|последн.*день)/i.test(q);
|
||||
const hasRule = /(правил|контрол|ошибк.*дат|амортиз|настройк|\b97\b|ндс|\b01\b|\b02\b)/i.test(q);
|
||||
const hasAnomaly = /(аномал|подозр|риск|хвост|не сход|завис|крив)/i.test(q);
|
||||
const hasExactTrace = routeHint === "live_mcp_drilldown";
|
||||
let intentClass = "simple_factual";
|
||||
if (routeHint === "live_mcp_drilldown") {
|
||||
intentClass = "drilldown_explain";
|
||||
}
|
||||
else if (routeHint === "hybrid_store_plus_live") {
|
||||
intentClass = "cross_entity";
|
||||
}
|
||||
else if (routeHint === "batch_refresh_then_store") {
|
||||
intentClass = hasCloseRisk && !hasHeavyGoal ? "period_close_risk" : "heavy_analytical";
|
||||
}
|
||||
else if (routeHint === "store_feature_risk") {
|
||||
intentClass = hasRule ? "rule_based_account_control" : hasAnomaly ? "anomaly_probe" : "ambiguous_human_query";
|
||||
}
|
||||
const expectedOutputShape = intentClass === "period_close_risk"
|
||||
? "prioritized_review_list"
|
||||
: routeHint === "batch_refresh_then_store"
|
||||
? "ranked_list"
|
||||
: routeHint === "hybrid_store_plus_live"
|
||||
? "reconciliation_report"
|
||||
: routeHint === "live_mcp_drilldown"
|
||||
? "evidence_chain"
|
||||
: hasAnomaly
|
||||
? "anomaly_summary"
|
||||
: "point_answer";
|
||||
return {
|
||||
schema_version: "normalized_query_v1",
|
||||
user_question_raw: userQuestion,
|
||||
normalized_question: userQuestion.trim(),
|
||||
intent_class: intentClass,
|
||||
business_problem_type: "normalization_playground",
|
||||
domain_entities: routeHint === "hybrid_store_plus_live" ? ["контрагент", "документ", "проводка"] : ["счет"],
|
||||
accounts_mentioned: extractAccounts(userQuestion),
|
||||
documents_mentioned: /документ|реализац|поступлен|выписк|платеж/i.test(userQuestion) ? ["документ"] : [],
|
||||
registers_mentioned: /регистр|движен/i.test(userQuestion) ? ["регистр"] : [],
|
||||
period_scope: {
|
||||
type: hasPeriod ? "inferred" : "missing",
|
||||
value: hasPeriod ? "2020-06" : null,
|
||||
confidence: hasPeriod ? "medium" : "low"
|
||||
},
|
||||
requires: {
|
||||
needs_cross_entity_join: routeHint === "hybrid_store_plus_live",
|
||||
needs_causal_chain: routeHint === "hybrid_store_plus_live" || /почему|чем подтверж|где рвется/i.test(userQuestion),
|
||||
needs_exact_object_trace: hasExactTrace,
|
||||
needs_ranking: routeHint === "batch_refresh_then_store" && intentClass !== "period_close_risk",
|
||||
needs_anomaly_summary: hasAnomaly && routeHint !== "hybrid_store_plus_live",
|
||||
needs_runtime_truth: hasExactTrace,
|
||||
needs_period_cut: hasPeriod,
|
||||
needs_evidence: routeHint === "hybrid_store_plus_live" || hasExactTrace
|
||||
},
|
||||
expected_output_shape: expectedOutputShape,
|
||||
route_hint: routeHint,
|
||||
ambiguities: hasPeriod
|
||||
? []
|
||||
: [
|
||||
{
|
||||
field: "period_scope",
|
||||
reason: "period is not explicitly provided",
|
||||
severity: "medium"
|
||||
}
|
||||
],
|
||||
confidence: {
|
||||
overall: hasPeriod ? "medium" : "low",
|
||||
intent_class: "medium",
|
||||
route_hint: hasPeriod ? "medium" : "low"
|
||||
}
|
||||
};
|
||||
}
|
||||
function applyConfidenceGuardV1(item) {
|
||||
const wordCount = item.user_question_raw.trim().split(/\s+/).filter(Boolean).length;
|
||||
const hasAmbiguity = item.ambiguities.length > 0;
|
||||
const longLayeredQuestion = wordCount >= 20;
|
||||
const uncertainPeriod = item.period_scope.type !== "explicit";
|
||||
const hasPeriodBoundaryLex = /(предзакры|закрыти[ея].*период|перед сдачей отчетност|перед закрытием)/i.test(item.user_question_raw) &&
|
||||
/(рейтинг|топ|обзор|summary|срез|концентрац|в целом|приоритиз|самых)/i.test(item.user_question_raw);
|
||||
const suspicious = hasAmbiguity || longLayeredQuestion || uncertainPeriod || hasPeriodBoundaryLex;
|
||||
if (!suspicious) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
confidence: {
|
||||
...item.confidence,
|
||||
overall: item.confidence.overall === "high" ? "medium" : item.confidence.overall,
|
||||
route_hint: item.confidence.route_hint === "high" ? "medium" : item.confidence.route_hint
|
||||
}
|
||||
};
|
||||
}
|
||||
function splitIntoCandidateFragments(message) {
|
||||
const primary = message
|
||||
.split(/[\n;]+|(?<=[.!?])\s+/)
|
||||
.map((item) => item.replace(/^\s*[-*•]\s*/, "").trim())
|
||||
.filter(Boolean);
|
||||
if (primary.length > 0) {
|
||||
return primary;
|
||||
}
|
||||
const fallback = message.trim();
|
||||
return fallback ? [fallback] : [];
|
||||
}
|
||||
function inferTimeScope(text) {
|
||||
const explicit = text.match(/\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/);
|
||||
if (explicit) {
|
||||
return {
|
||||
type: "explicit",
|
||||
value: explicit[1],
|
||||
confidence: "high"
|
||||
};
|
||||
}
|
||||
const inferred = text.match(/(январ[ья]|феврал[ья]|март[ае]?|апрел[ья]|ма[йя]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|квартал|конец месяца|период)/i);
|
||||
if (inferred) {
|
||||
return {
|
||||
type: "inferred",
|
||||
value: inferred[1],
|
||||
confidence: "medium"
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
};
|
||||
}
|
||||
function pickCandidateLabels(flags, domainRelevance) {
|
||||
if (domainRelevance !== "in_scope") {
|
||||
return [];
|
||||
}
|
||||
const labels = [];
|
||||
if (flags.asks_for_exact_object_trace)
|
||||
labels.push("drilldown_explain");
|
||||
if (flags.has_multi_entity_scope && flags.asks_for_chain_explanation)
|
||||
labels.push("cross_entity");
|
||||
if (flags.asks_for_rule_check)
|
||||
labels.push("rule_based_account_control");
|
||||
if (flags.asks_for_anomaly_scan)
|
||||
labels.push("anomaly_probe");
|
||||
if (flags.asks_for_ranking_or_top || flags.asks_for_period_summary)
|
||||
labels.push("heavy_analytical");
|
||||
if (flags.mentions_period_close_context && !flags.asks_for_ranking_or_top)
|
||||
labels.push("period_close_risk");
|
||||
if (labels.length === 0)
|
||||
labels.push("simple_factual");
|
||||
return Array.from(new Set(labels));
|
||||
}
|
||||
function buildFragmentV2(rawText, index) {
|
||||
const text = rawText.trim();
|
||||
if (text.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const lower = text.toLowerCase();
|
||||
const noiseOnly = /^(ну|короче|типа|ладно|ага|ок(ей)?)$/i.test(lower);
|
||||
if (noiseOnly) {
|
||||
return null;
|
||||
}
|
||||
const inScopeTokens = /(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|ндс|амортиз|расходы будущих периодов|рбп|ос|контрагент|оплат|банк|выписк|склад|товар|материал)/i.test(lower);
|
||||
const translitInScopeTokens = /\b(?:schet|scheta|schetu|schetom|postavsh|kontragent|dokument|doc|oplata|oplati|platezh|vypisk|provodk|realiz|postuplen|nds|os|saldo|hvost|tail|anomali|risk|zakryt)\b/i.test(lower);
|
||||
const genericAccountingTokens = /(фсбу|налогов(ый|ого)|нк рф|закон|форма отчетности|как правильно в бухгалтерии)/i.test(lower);
|
||||
const offTopicTokens = /(погода|анекдот|музык|фильм|игр[аы]|рецепт|курс валют в мире)/i.test(lower);
|
||||
let domainRelevance = "unclear";
|
||||
let businessScope = "unclear";
|
||||
if (offTopicTokens) {
|
||||
domainRelevance = "out_of_scope";
|
||||
businessScope = "offtopic";
|
||||
}
|
||||
else if (genericAccountingTokens && !inScopeTokens && !translitInScopeTokens) {
|
||||
domainRelevance = "out_of_scope";
|
||||
businessScope = "generic_accounting";
|
||||
}
|
||||
else if (inScopeTokens || translitInScopeTokens) {
|
||||
domainRelevance = "in_scope";
|
||||
businessScope = "company_specific_accounting";
|
||||
}
|
||||
const entityTokenCount = (lower.match(/(документ|оплат|проводк|контрагент|договор|реализац|поступлен|выписк|закрыт|взаиморасчет|склад|товар|материал)/g) ?? [])
|
||||
.length;
|
||||
const translitEntityTokenCount = (lower.match(/\b(?:dokument|oplata|platezh|provodk|kontragent|realiz|postuplen|vypisk|zakryt|schet|sklad|tovar|material)\b/g) ?? []).length;
|
||||
const entityTokenCountTotal = entityTokenCount + translitEntityTokenCount;
|
||||
const flags = {
|
||||
has_multi_entity_scope: entityTokenCountTotal >= 2,
|
||||
asks_for_chain_explanation: /(цепоч|разлож|почему|чем подтверж|где рвет|связк|логик.*операц)/i.test(lower),
|
||||
asks_for_ranking_or_top: /(топ|рейтинг|сам(ые|ых)|максимальн|сильнее всего|приоритиз)/i.test(lower),
|
||||
asks_for_period_summary: /(срез|обзор|в целом|картина периода|summary|по периоду)/i.test(lower),
|
||||
asks_for_rule_check: /(правил|контрол|корректн|ошибк.*дат|срок списан|амортиз|настройк|проверь)/i.test(lower),
|
||||
asks_for_anomaly_scan: /(аномал|подозр|риск|хвост|не сход|завис|крив|искажа)/i.test(lower),
|
||||
asks_for_exact_object_trace: /(документ\s*(№|#)|\bref\b|\bid\b|строк[аи]\s+проводк|операц.*№|trx-\d+|inv-\d+|doc-\d+)/i.test(lower),
|
||||
asks_for_evidence: /(чем подтверж|документ|проводк|движен|акт сверк|доказат|evidence)/i.test(lower),
|
||||
mentions_period_close_context: /(закрыти[ея]\s+период|предзакры|конец месяца|сдач[аи]\s+отчетност)/i.test(lower)
|
||||
};
|
||||
const translitHints = {
|
||||
chain: /\b(?:razlozh|pochemu|chem podtver|gde rv|svyaz|razryv|chain)\b/i.test(lower),
|
||||
rule: /\b(?:prover|check|rule|control|korrekt)\b/i.test(lower),
|
||||
anomaly: /\b(?:anomal|risk|hvost|tail|mismatch)\b/i.test(lower),
|
||||
evidence: /\b(?:dokument|provodk|evidence|doc)\b/i.test(lower)
|
||||
};
|
||||
if (translitHints.chain)
|
||||
flags.asks_for_chain_explanation = true;
|
||||
if (translitHints.rule)
|
||||
flags.asks_for_rule_check = true;
|
||||
if (translitHints.anomaly)
|
||||
flags.asks_for_anomaly_scan = true;
|
||||
if (translitHints.evidence)
|
||||
flags.asks_for_evidence = true;
|
||||
const candidateLabels = pickCandidateLabels(flags, domainRelevance);
|
||||
let confidence = "medium";
|
||||
if (domainRelevance === "out_of_scope" || domainRelevance === "unclear") {
|
||||
confidence = "low";
|
||||
}
|
||||
else if (flags.asks_for_exact_object_trace || flags.asks_for_ranking_or_top) {
|
||||
confidence = "high";
|
||||
}
|
||||
return {
|
||||
fragment_id: `F${index + 1}`,
|
||||
raw_fragment_text: text,
|
||||
normalized_fragment_text: text.charAt(0).toUpperCase() + text.slice(1),
|
||||
domain_relevance: domainRelevance,
|
||||
business_scope: businessScope,
|
||||
entity_hints: Array.from(new Set(Array.from(lower.matchAll(/(поставщик|покупател|контрагент|договор|банк|склад|товар|материал|ос|взаиморасчет|реализац|поступлен)/g)).map((item) => item[0]))),
|
||||
account_hints: extractAccounts(text),
|
||||
document_hints: Array.from(new Set(Array.from(lower.matchAll(/(документ|реализац|поступлен|платеж|выписк|акт сверк)/g)).map((item) => item[0]))),
|
||||
register_hints: Array.from(new Set(Array.from(lower.matchAll(/(регистр|движен|остатк|сальдо)/g)).map((item) => item[0]))),
|
||||
time_scope: inferTimeScope(text),
|
||||
flags,
|
||||
candidate_labels: candidateLabels,
|
||||
confidence
|
||||
};
|
||||
}
|
||||
function buildMockNormalizedV2(userMessage) {
|
||||
const rawFragments = splitIntoCandidateFragments(userMessage);
|
||||
const fragments = [];
|
||||
const discarded = [];
|
||||
rawFragments.forEach((raw, index) => {
|
||||
const built = buildFragmentV2(raw, index);
|
||||
if (!built) {
|
||||
discarded.push({
|
||||
raw_fragment_text: raw,
|
||||
reason: "noise_or_too_short"
|
||||
});
|
||||
return;
|
||||
}
|
||||
fragments.push(built);
|
||||
});
|
||||
const inScopeCount = fragments.filter((item) => item.domain_relevance === "in_scope").length;
|
||||
const unclearCount = fragments.filter((item) => item.domain_relevance === "unclear").length;
|
||||
const messageInScope = inScopeCount > 0;
|
||||
const scopeConfidence = messageInScope ? (unclearCount > 0 ? "medium" : "high") : "low";
|
||||
const needsClarification = messageInScope && (unclearCount > 0 || fragments.some((item) => item.time_scope.type === "missing"));
|
||||
return {
|
||||
schema_version: "normalized_query_v2",
|
||||
user_message_raw: userMessage,
|
||||
message_in_scope: messageInScope,
|
||||
scope_confidence: scopeConfidence,
|
||||
contains_multiple_tasks: fragments.length > 1,
|
||||
fragments,
|
||||
discarded_fragments: discarded,
|
||||
global_notes: {
|
||||
needs_clarification: needsClarification,
|
||||
clarification_reason: needsClarification ? "Недостаточно периода/контекста по части фрагментов." : null
|
||||
}
|
||||
};
|
||||
}
|
||||
function hasSessionPeriodContext(context) {
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
const periodHint = String(context.period_hint ?? "").trim();
|
||||
const businessContext = String(context.business_context ?? "").toLowerCase();
|
||||
if (periodHint.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return (businessContext.includes("current_analysis_period") ||
|
||||
businessContext.includes("active_period") ||
|
||||
businessContext.includes("рабочий месяц") ||
|
||||
businessContext.includes("активный период"));
|
||||
}
|
||||
function hasBusinessNodeSignals(fragment) {
|
||||
if (fragment.domain_relevance !== "in_scope") {
|
||||
return false;
|
||||
}
|
||||
return (fragment.entity_hints.length > 0 ||
|
||||
fragment.account_hints.length > 0 ||
|
||||
fragment.document_hints.length > 0 ||
|
||||
fragment.register_hints.length > 0 ||
|
||||
fragment.candidate_labels.length > 0 ||
|
||||
Object.values(fragment.flags).some((value) => value));
|
||||
}
|
||||
function routeCanBeSelected(fragment) {
|
||||
if (fragment.domain_relevance !== "in_scope") {
|
||||
return false;
|
||||
}
|
||||
if (fragment.business_scope === "unclear") {
|
||||
return false;
|
||||
}
|
||||
return hasBusinessNodeSignals(fragment);
|
||||
}
|
||||
function dedupeSoftAssumptions(input) {
|
||||
return Array.from(new Set(input));
|
||||
}
|
||||
function decideFragmentExecutionPolicy(fragment, sessionContext) {
|
||||
const softAssumptions = [];
|
||||
const hasPeriodContext = hasSessionPeriodContext(sessionContext);
|
||||
const periodIsCritical = fragment.flags.asks_for_period_summary || fragment.flags.mentions_period_close_context || fragment.flags.asks_for_ranking_or_top;
|
||||
if (fragment.domain_relevance === "out_of_scope") {
|
||||
return {
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "fragment_out_of_scope",
|
||||
soft_assumption_used: []
|
||||
};
|
||||
}
|
||||
if (fragment.domain_relevance === "unclear") {
|
||||
return {
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "domain_or_scope_unclear",
|
||||
soft_assumption_used: []
|
||||
};
|
||||
}
|
||||
if (!hasBusinessNodeSignals(fragment)) {
|
||||
return {
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "business_area_not_identified",
|
||||
soft_assumption_used: []
|
||||
};
|
||||
}
|
||||
if (!routeCanBeSelected(fragment)) {
|
||||
return {
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "route_cannot_be_selected_reliably",
|
||||
soft_assumption_used: []
|
||||
};
|
||||
}
|
||||
if (fragment.time_scope.type === "missing") {
|
||||
if (hasPeriodContext) {
|
||||
softAssumptions.push("period_from_session_context");
|
||||
}
|
||||
else if (periodIsCritical) {
|
||||
return {
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "critical_period_missing",
|
||||
soft_assumption_used: []
|
||||
};
|
||||
}
|
||||
}
|
||||
if (fragment.flags.asks_for_anomaly_scan ||
|
||||
fragment.flags.asks_for_rule_check ||
|
||||
fragment.flags.asks_for_ranking_or_top ||
|
||||
fragment.flags.asks_for_period_summary) {
|
||||
softAssumptions.push("problem_scan_mode_enabled");
|
||||
}
|
||||
if (fragment.business_scope === "company_specific_accounting" && fragment.entity_hints.length === 0 && fragment.account_hints.length === 0) {
|
||||
softAssumptions.push("company_scope_defaulted");
|
||||
}
|
||||
const assumptions = dedupeSoftAssumptions(softAssumptions);
|
||||
if (assumptions.length > 0) {
|
||||
return {
|
||||
execution_readiness: "executable_with_soft_assumptions",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: assumptions
|
||||
};
|
||||
}
|
||||
return {
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: []
|
||||
};
|
||||
}
|
||||
function toV201Fragment(fragment, sessionContext) {
|
||||
const policy = decideFragmentExecutionPolicy(fragment, sessionContext);
|
||||
return {
|
||||
...fragment,
|
||||
execution_readiness: policy.execution_readiness,
|
||||
clarification_reason: policy.clarification_reason,
|
||||
soft_assumption_used: policy.soft_assumption_used
|
||||
};
|
||||
}
|
||||
function applyClarificationPolicyV201(candidate, userMessage, sessionContext) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
const source = candidate;
|
||||
if (!Array.isArray(source.fragments)) {
|
||||
return null;
|
||||
}
|
||||
const baseFragments = source.fragments
|
||||
.map((item) => item)
|
||||
.filter((item) => item && typeof item === "object" && typeof item.fragment_id === "string");
|
||||
const fragments = baseFragments.map((fragment) => toV201Fragment(fragment, sessionContext));
|
||||
const inScopeFragments = fragments.filter((fragment) => fragment.domain_relevance === "in_scope");
|
||||
const blockingFragments = inScopeFragments.filter((fragment) => fragment.execution_readiness === "needs_clarification");
|
||||
const needsClarification = inScopeFragments.length > 0 && blockingFragments.length === inScopeFragments.length;
|
||||
return {
|
||||
schema_version: "normalized_query_v2_0_1",
|
||||
user_message_raw: String(source.user_message_raw ?? userMessage),
|
||||
message_in_scope: inScopeFragments.length > 0,
|
||||
scope_confidence: source.scope_confidence ?? (inScopeFragments.length > 0 ? "medium" : "low"),
|
||||
contains_multiple_tasks: typeof source.contains_multiple_tasks === "boolean" ? source.contains_multiple_tasks : fragments.length > 1,
|
||||
fragments,
|
||||
discarded_fragments: Array.isArray(source.discarded_fragments)
|
||||
? source.discarded_fragments
|
||||
: [],
|
||||
global_notes: {
|
||||
needs_clarification: needsClarification,
|
||||
clarification_reason: needsClarification ? blockingFragments[0]?.clarification_reason ?? "clarification_required" : null
|
||||
}
|
||||
};
|
||||
}
|
||||
function resolveFragmentExecutionStateV202(fragment, sessionContext) {
|
||||
const v201 = decideFragmentExecutionPolicy(fragment, sessionContext);
|
||||
if (fragment.domain_relevance === "out_of_scope") {
|
||||
return {
|
||||
execution_readiness: "no_route",
|
||||
clarification_reason: "fragment_out_of_scope",
|
||||
soft_assumption_used: [],
|
||||
route_status: "no_route",
|
||||
no_route_reason: "out_of_scope"
|
||||
};
|
||||
}
|
||||
if (v201.execution_readiness === "needs_clarification") {
|
||||
return {
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: v201.clarification_reason ?? "insufficient_specificity",
|
||||
soft_assumption_used: [],
|
||||
route_status: "no_route",
|
||||
no_route_reason: "insufficient_specificity"
|
||||
};
|
||||
}
|
||||
if (!routeCanBeSelected(fragment)) {
|
||||
return {
|
||||
execution_readiness: "no_route",
|
||||
clarification_reason: "route_mapping_missing",
|
||||
soft_assumption_used: [],
|
||||
route_status: "no_route",
|
||||
no_route_reason: "missing_mapping"
|
||||
};
|
||||
}
|
||||
// Deterministic no-route guard:
|
||||
// routable in-scope fragments cannot remain unresolved.
|
||||
return {
|
||||
execution_readiness: v201.execution_readiness,
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: v201.soft_assumption_used,
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
};
|
||||
}
|
||||
function toV202Fragment(fragment, sessionContext) {
|
||||
const policy = resolveFragmentExecutionStateV202(fragment, sessionContext);
|
||||
return {
|
||||
...fragment,
|
||||
execution_readiness: policy.execution_readiness,
|
||||
clarification_reason: policy.clarification_reason,
|
||||
soft_assumption_used: policy.soft_assumption_used,
|
||||
route_status: policy.route_status,
|
||||
no_route_reason: policy.no_route_reason
|
||||
};
|
||||
}
|
||||
function applyExecutionStatePolicyV202(candidate, userMessage, sessionContext) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
const source = candidate;
|
||||
if (!Array.isArray(source.fragments)) {
|
||||
return null;
|
||||
}
|
||||
const baseFragments = source.fragments
|
||||
.map((item) => item)
|
||||
.filter((item) => item && typeof item === "object" && typeof item.fragment_id === "string");
|
||||
const fragments = baseFragments.map((fragment) => toV202Fragment(fragment, sessionContext));
|
||||
const inScopeFragments = fragments.filter((fragment) => fragment.domain_relevance === "in_scope");
|
||||
const clarificationBlocks = inScopeFragments.filter((fragment) => fragment.execution_readiness === "needs_clarification");
|
||||
const needsClarification = inScopeFragments.length > 0 && clarificationBlocks.length === inScopeFragments.length;
|
||||
return {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: String(source.user_message_raw ?? userMessage),
|
||||
message_in_scope: inScopeFragments.length > 0,
|
||||
scope_confidence: source.scope_confidence ?? (inScopeFragments.length > 0 ? "medium" : "low"),
|
||||
contains_multiple_tasks: typeof source.contains_multiple_tasks === "boolean" ? source.contains_multiple_tasks : fragments.length > 1,
|
||||
fragments,
|
||||
discarded_fragments: Array.isArray(source.discarded_fragments)
|
||||
? source.discarded_fragments
|
||||
: [],
|
||||
global_notes: {
|
||||
needs_clarification: needsClarification,
|
||||
clarification_reason: needsClarification ? clarificationBlocks[0]?.clarification_reason ?? "clarification_required" : null
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildMockNormalizedV2_0_1(userMessage, sessionContext) {
|
||||
const v2 = buildMockNormalizedV2(userMessage);
|
||||
const adjusted = applyClarificationPolicyV201(v2, userMessage, sessionContext);
|
||||
if (adjusted) {
|
||||
return adjusted;
|
||||
}
|
||||
return {
|
||||
schema_version: "normalized_query_v2_0_1",
|
||||
user_message_raw: userMessage,
|
||||
message_in_scope: v2.message_in_scope,
|
||||
scope_confidence: v2.scope_confidence,
|
||||
contains_multiple_tasks: v2.contains_multiple_tasks,
|
||||
fragments: v2.fragments.map((fragment) => ({
|
||||
...fragment,
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "policy_fallback",
|
||||
soft_assumption_used: []
|
||||
})),
|
||||
discarded_fragments: v2.discarded_fragments,
|
||||
global_notes: {
|
||||
needs_clarification: true,
|
||||
clarification_reason: "policy_fallback"
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildMockNormalizedV2_0_2(userMessage, sessionContext) {
|
||||
const v2 = buildMockNormalizedV2(userMessage);
|
||||
const adjusted = applyExecutionStatePolicyV202(v2, userMessage, sessionContext);
|
||||
if (adjusted) {
|
||||
return adjusted;
|
||||
}
|
||||
return {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: userMessage,
|
||||
message_in_scope: v2.message_in_scope,
|
||||
scope_confidence: v2.scope_confidence,
|
||||
contains_multiple_tasks: v2.contains_multiple_tasks,
|
||||
fragments: v2.fragments.map((fragment) => ({
|
||||
...fragment,
|
||||
execution_readiness: "needs_clarification",
|
||||
clarification_reason: "policy_fallback",
|
||||
soft_assumption_used: [],
|
||||
route_status: "no_route",
|
||||
no_route_reason: "unsupported_fragment_type"
|
||||
})),
|
||||
discarded_fragments: v2.discarded_fragments,
|
||||
global_notes: {
|
||||
needs_clarification: true,
|
||||
clarification_reason: "policy_fallback"
|
||||
}
|
||||
};
|
||||
}
|
||||
function routeHintForHistory(normalized, routeSummary) {
|
||||
if (!normalized || !routeSummary) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.schema_version === "normalized_query_v1") {
|
||||
return normalized.route_hint;
|
||||
}
|
||||
const decision = routeSummary.mode === "deterministic_v2" ? routeSummary.decisions.find((item) => item.route !== "no_route") : null;
|
||||
return decision?.route ?? null;
|
||||
}
|
||||
function confidenceForHistory(normalized, routeSummary) {
|
||||
if (!normalized || !routeSummary) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.schema_version === "normalized_query_v1") {
|
||||
return normalized.confidence.route_hint;
|
||||
}
|
||||
return normalized.scope_confidence;
|
||||
}
|
||||
function collectTraceCompletenessIssues(input) {
|
||||
const issues = [];
|
||||
if (!input.rawModelResponse) {
|
||||
issues.push("missing_raw_model_output");
|
||||
}
|
||||
if (!input.normalized) {
|
||||
issues.push("missing_parsed_normalized_json");
|
||||
return issues;
|
||||
}
|
||||
if (input.normalized.schema_version === "normalized_query_v1") {
|
||||
return issues;
|
||||
}
|
||||
if (!Array.isArray(input.normalized.fragments)) {
|
||||
issues.push("missing_parsed_fragments");
|
||||
return issues;
|
||||
}
|
||||
for (const fragment of input.normalized.fragments) {
|
||||
const needsResolvedExecutionState = input.normalized.schema_version === "normalized_query_v2_0_1" || input.normalized.schema_version === "normalized_query_v2_0_2";
|
||||
if (needsResolvedExecutionState && !("execution_readiness" in fragment)) {
|
||||
issues.push(`fragment_${fragment.fragment_id}_missing_execution_readiness`);
|
||||
}
|
||||
if (input.normalized.schema_version === "normalized_query_v2_0_2") {
|
||||
if (!("route_status" in fragment)) {
|
||||
issues.push(`fragment_${fragment.fragment_id}_missing_route_status`);
|
||||
}
|
||||
if (!("no_route_reason" in fragment)) {
|
||||
issues.push(`fragment_${fragment.fragment_id}_missing_no_route_reason`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!input.routeHintSummary || input.routeHintSummary.mode !== "deterministic_v2") {
|
||||
issues.push("missing_route_hint_summary_v2");
|
||||
return issues;
|
||||
}
|
||||
const decisionCount = Array.isArray(input.routeHintSummary.decisions) ? input.routeHintSummary.decisions.length : 0;
|
||||
if (decisionCount !== input.normalized.fragments.length) {
|
||||
issues.push("route_decision_count_mismatch");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
class NormalizerService {
|
||||
openaiClient;
|
||||
constructor(openaiClient) {
|
||||
this.openaiClient = openaiClient;
|
||||
}
|
||||
async normalize(payload) {
|
||||
const traceId = (0, nanoid_1.nanoid)(14);
|
||||
const startedAt = Date.now();
|
||||
const model = payload.model ?? config_1.DEFAULT_MODEL;
|
||||
const baseUrl = payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL;
|
||||
const temperature = payload.temperature ?? config_1.DEFAULT_TEMPERATURE;
|
||||
const maxOutputTokens = payload.maxOutputTokens ?? config_1.DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
const retryPolicy = payload.retryPolicy ?? "default";
|
||||
const schemaVersion = resolveSchemaVersion(payload);
|
||||
const promptBundle = (0, promptBuilder_1.buildPromptBundle)({
|
||||
promptVersion: payload.promptVersion,
|
||||
systemPrompt: payload.systemPrompt,
|
||||
developerPrompt: payload.developerPrompt,
|
||||
domainPrompt: payload.domainPrompt,
|
||||
schemaNotes: undefined,
|
||||
fewShotExamples: payload.fewShotExamples
|
||||
});
|
||||
let rawModelResponse = null;
|
||||
let outputText = "";
|
||||
let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
||||
let requestCountForCase = 0;
|
||||
if (payload.useMock) {
|
||||
const mock = schemaVersion === "v2"
|
||||
? buildMockNormalizedV2(payload.userQuestion)
|
||||
: schemaVersion === "v2_0_2"
|
||||
? buildMockNormalizedV2_0_2(payload.userQuestion, payload.context)
|
||||
: schemaVersion === "v2_0_1"
|
||||
? buildMockNormalizedV2_0_1(payload.userQuestion, payload.context)
|
||||
: buildMockNormalizedV1(payload.userQuestion, payload.context?.expected_route);
|
||||
rawModelResponse = { mode: "mock", schema_version: schemaVersion };
|
||||
outputText = JSON.stringify(mock, null, 2);
|
||||
}
|
||||
else {
|
||||
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
|
||||
const firstTry = await this.openaiClient.normalize({
|
||||
apiKey: String(apiKey ?? ""),
|
||||
model,
|
||||
baseUrl,
|
||||
temperature,
|
||||
maxOutputTokens
|
||||
}, {
|
||||
systemPrompt: promptBundle.systemPrompt,
|
||||
developerPrompt: promptBundle.combinedDeveloperPrompt,
|
||||
domainPrompt: promptBundle.domainPrompt,
|
||||
userQuestion: payload.userQuestion,
|
||||
schemaVersion
|
||||
});
|
||||
requestCountForCase += 1;
|
||||
rawModelResponse = firstTry.raw;
|
||||
outputText = firstTry.outputText;
|
||||
usage = firstTry.usage;
|
||||
}
|
||||
let normalizedCandidate;
|
||||
let validation = { passed: false, errors: ["NO_VALIDATION"] };
|
||||
try {
|
||||
normalizedCandidate = safeJsonParse(outputText);
|
||||
if (schemaVersion === "v2_0_2") {
|
||||
normalizedCandidate = applyExecutionStatePolicyV202(normalizedCandidate, payload.userQuestion, payload.context);
|
||||
}
|
||||
else if (schemaVersion === "v2_0_1") {
|
||||
normalizedCandidate = applyClarificationPolicyV201(normalizedCandidate, payload.userQuestion, payload.context);
|
||||
}
|
||||
validation = (0, schemaValidator_1.validateNormalized)(normalizedCandidate, schemaVersion);
|
||||
}
|
||||
catch (error) {
|
||||
normalizedCandidate = null;
|
||||
validation = {
|
||||
passed: false,
|
||||
errors: [`JSON_PARSE_ERROR: ${error instanceof Error ? error.message : String(error)}`]
|
||||
};
|
||||
}
|
||||
const canRetry = retryPolicy === "default" || retryPolicy === "single-pass-strict";
|
||||
if (!payload.useMock && !validation.passed && canRetry) {
|
||||
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
|
||||
const retry = await this.openaiClient.normalize({
|
||||
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||
model,
|
||||
baseUrl,
|
||||
temperature,
|
||||
maxOutputTokens: retryMaxOutputTokens
|
||||
}, {
|
||||
systemPrompt: promptBundle.systemPrompt,
|
||||
developerPrompt: promptBundle.combinedDeveloperPrompt,
|
||||
domainPrompt: promptBundle.domainPrompt,
|
||||
userQuestion: payload.userQuestion,
|
||||
schemaVersion,
|
||||
controlledRetryInstruction: schemaVersion === "v2"
|
||||
? RETRY_INSTRUCTION_V2
|
||||
: schemaVersion === "v2_0_2"
|
||||
? RETRY_INSTRUCTION_V2_0_2
|
||||
: schemaVersion === "v2_0_1"
|
||||
? RETRY_INSTRUCTION_V2_0_1
|
||||
: RETRY_INSTRUCTION_V1
|
||||
});
|
||||
requestCountForCase += 1;
|
||||
rawModelResponse = retry.raw;
|
||||
outputText = retry.outputText;
|
||||
usage = retry.usage;
|
||||
try {
|
||||
normalizedCandidate = safeJsonParse(outputText);
|
||||
if (schemaVersion === "v2_0_2") {
|
||||
normalizedCandidate = applyExecutionStatePolicyV202(normalizedCandidate, payload.userQuestion, payload.context);
|
||||
}
|
||||
else if (schemaVersion === "v2_0_1") {
|
||||
normalizedCandidate = applyClarificationPolicyV201(normalizedCandidate, payload.userQuestion, payload.context);
|
||||
}
|
||||
validation = (0, schemaValidator_1.validateNormalized)(normalizedCandidate, schemaVersion);
|
||||
}
|
||||
catch (error) {
|
||||
normalizedCandidate = null;
|
||||
validation = {
|
||||
passed: false,
|
||||
errors: [`JSON_PARSE_ERROR_AFTER_RETRY: ${error instanceof Error ? error.message : String(error)}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
let normalized = null;
|
||||
if (validation.passed) {
|
||||
if (schemaVersion === "v1") {
|
||||
normalized = applyConfidenceGuardV1(normalizedCandidate);
|
||||
}
|
||||
else if (schemaVersion === "v2_0_2") {
|
||||
normalized = normalizedCandidate;
|
||||
}
|
||||
else if (schemaVersion === "v2_0_1") {
|
||||
normalized = normalizedCandidate;
|
||||
}
|
||||
else {
|
||||
normalized = normalizedCandidate;
|
||||
}
|
||||
}
|
||||
const routeHintSummary = normalized ? (0, routeHintAdapter_1.toRouteHintSummary)(normalized) : null;
|
||||
const latency = Date.now() - startedAt;
|
||||
const traceCompletenessIssues = collectTraceCompletenessIssues({
|
||||
traceId,
|
||||
schemaVersion,
|
||||
rawModelResponse: rawModelResponse ?? outputText,
|
||||
normalized,
|
||||
routeHintSummary
|
||||
});
|
||||
if (traceCompletenessIssues.length > 0) {
|
||||
console.error(`[trace-completeness] trace_id=${traceId} schema=${schemaVersion} issues=${traceCompletenessIssues.join(",")}`);
|
||||
}
|
||||
const response = {
|
||||
trace_id: traceId,
|
||||
ok: validation.passed,
|
||||
normalized,
|
||||
route_hint_summary: routeHintSummary,
|
||||
raw_model_output: rawModelResponse ?? outputText,
|
||||
validation,
|
||||
usage,
|
||||
latency_ms: latency,
|
||||
prompt_version: promptBundle.prompt_version,
|
||||
schema_version: schemaVersion,
|
||||
request_count_for_case: requestCountForCase
|
||||
};
|
||||
const traceRouteHint = routeHintForHistory(normalized, routeHintSummary);
|
||||
const traceConfidence = confidenceForHistory(normalized, routeHintSummary);
|
||||
const traceRecord = {
|
||||
trace_id: traceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
model,
|
||||
prompt_version: promptBundle.prompt_version,
|
||||
schema_version: schemaVersion,
|
||||
case_id: payload.context?.case_id,
|
||||
user_question_raw: payload.userQuestion,
|
||||
context: {
|
||||
period_hint: payload.context?.period_hint ?? null,
|
||||
business_context: payload.context?.business_context ?? null,
|
||||
expected_route: payload.context?.expected_route ?? null,
|
||||
case_id: payload.context?.case_id ?? null,
|
||||
eval_mode: payload.context?.eval_mode ?? null,
|
||||
trace_completeness_issues: traceCompletenessIssues
|
||||
},
|
||||
request_payload_redacted: (0, traceLogger_1.redactRequestPayload)({
|
||||
...payload,
|
||||
apiKey: payload.apiKey ? "***REDACTED***" : undefined
|
||||
}),
|
||||
raw_model_response: rawModelResponse ?? outputText,
|
||||
parsed_normalized_json: normalized,
|
||||
validation_result: validation,
|
||||
route_hint_summary: routeHintSummary,
|
||||
route_hint: traceRouteHint,
|
||||
confidence: traceConfidence,
|
||||
usage,
|
||||
latency_ms: latency,
|
||||
expected_route: payload.context?.expected_route,
|
||||
eval_label: payload.context?.eval_label,
|
||||
eval_mode: payload.context?.eval_mode,
|
||||
request_count_for_case: requestCountForCase
|
||||
};
|
||||
(0, traceLogger_1.saveTrace)(traceRecord);
|
||||
if (payload.saveAsTestCase && normalized?.schema_version === "normalized_query_v1") {
|
||||
(0, traceLogger_1.saveEvalCase)({
|
||||
case_id: `NQ-${Date.now()}`,
|
||||
raw_question: payload.userQuestion,
|
||||
expected: {
|
||||
intent_class: normalized.intent_class,
|
||||
route_hint: normalized.route_hint,
|
||||
requires: {
|
||||
needs_cross_entity_join: normalized.requires.needs_cross_entity_join,
|
||||
needs_causal_chain: normalized.requires.needs_causal_chain
|
||||
},
|
||||
accounts_mentioned: normalized.accounts_mentioned,
|
||||
expected_output_shape: normalized.expected_output_shape
|
||||
}
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
exports.NormalizerService = NormalizerService;
|
||||
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.OpenAIResponsesClient = void 0;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const config_1 = require("../config");
|
||||
const http_1 = require("../utils/http");
|
||||
function extractUsage(raw) {
|
||||
const usage = (raw.usage ?? {});
|
||||
const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? usage.completion_tokens ?? 0);
|
||||
const total = Number(usage.total_tokens ?? input + output);
|
||||
return {
|
||||
input_tokens: Number.isFinite(input) ? input : 0,
|
||||
output_tokens: Number.isFinite(output) ? output : 0,
|
||||
total_tokens: Number.isFinite(total) ? total : 0
|
||||
};
|
||||
}
|
||||
function extractOutputText(raw) {
|
||||
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
||||
return raw.output_text;
|
||||
}
|
||||
const output = raw.output;
|
||||
if (Array.isArray(output)) {
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const content = item.content;
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
for (const c of content) {
|
||||
if (!c || typeof c !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = c;
|
||||
if (typeof block.text === "string" && block.text.trim()) {
|
||||
return block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = raw.response;
|
||||
if (response && typeof response === "object") {
|
||||
const nested = response;
|
||||
if (typeof nested.output_text === "string" && nested.output_text.trim().length > 0) {
|
||||
return nested.output_text;
|
||||
}
|
||||
}
|
||||
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
|
||||
}
|
||||
function loadSchemaForTransport(schemaVersion) {
|
||||
const schemaFile = schemaVersion === "v1"
|
||||
? "normalized_query_v1.json"
|
||||
: schemaVersion === "v2_0_1"
|
||||
? "normalized_query_v2_0_1.json"
|
||||
: schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2.json"
|
||||
: "normalized_query_v2.json";
|
||||
const schemaPath = path_1.default.resolve(config_1.SCHEMAS_DIR, schemaFile);
|
||||
return JSON.parse(fs_1.default.readFileSync(schemaPath, "utf-8"));
|
||||
}
|
||||
class OpenAIResponsesClient {
|
||||
async testConnection(config) {
|
||||
const payload = {
|
||||
model: config.model,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "ping" }]
|
||||
}
|
||||
],
|
||||
max_output_tokens: 16
|
||||
};
|
||||
await this.post(config, payload);
|
||||
return { ok: true, model: config.model };
|
||||
}
|
||||
async normalize(config, prompt) {
|
||||
const schema = loadSchemaForTransport(prompt.schemaVersion);
|
||||
const schemaName = prompt.schemaVersion === "v1"
|
||||
? "normalized_query_v1"
|
||||
: prompt.schemaVersion === "v2_0_1"
|
||||
? "normalized_query_v2_0_1"
|
||||
: prompt.schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2"
|
||||
: "normalized_query_v2";
|
||||
const developerPrompt = prompt.controlledRetryInstruction
|
||||
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
||||
: prompt.developerPrompt;
|
||||
const payload = {
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0,
|
||||
max_output_tokens: config.maxOutputTokens ?? 700,
|
||||
input: [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: prompt.systemPrompt }]
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
content: [{ type: "input_text", text: developerPrompt }]
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: schemaName,
|
||||
strict: true,
|
||||
schema
|
||||
}
|
||||
}
|
||||
};
|
||||
const raw = await this.post(config, payload);
|
||||
const outputText = extractOutputText(raw);
|
||||
return {
|
||||
raw,
|
||||
outputText,
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
}
|
||||
async post(config, payload) {
|
||||
if (!config.apiKey || config.apiKey.trim().length < 10) {
|
||||
throw new http_1.ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
|
||||
}
|
||||
const url = `${(config.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
}
|
||||
catch {
|
||||
throw new http_1.ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorObj = (data.error ?? {});
|
||||
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", String(errorObj.message ?? `OpenAI request failed with status ${response.status}`), response.status, {
|
||||
status: response.status,
|
||||
type: errorObj.type ?? null,
|
||||
code: errorObj.code ?? null
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
exports.OpenAIResponsesClient = OpenAIResponsesClient;
|
||||
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.listBuiltinPromptPresets = listBuiltinPromptPresets;
|
||||
exports.loadDefaultPrompts = loadDefaultPrompts;
|
||||
exports.buildPromptBundle = buildPromptBundle;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const config_1 = require("../config");
|
||||
function readPromptFile(relativePath) {
|
||||
const filePath = path_1.default.resolve(config_1.PROMPTS_DIR, relativePath);
|
||||
if (!fs_1.default.existsSync(filePath)) {
|
||||
throw new Error(`Prompt file not found: ${filePath}`);
|
||||
}
|
||||
return fs_1.default.readFileSync(filePath, "utf-8").trim();
|
||||
}
|
||||
const BUILTIN_PROMPT_PRESETS = {
|
||||
normalizer_v1: {
|
||||
id: "default-normalizer-v1",
|
||||
name: "Стандартный пресет NDC v1",
|
||||
promptVersion: "normalizer_v1",
|
||||
schemaNotes: "Используется схема normalized_query_v1. Строго соблюдать enum/required поля.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "default.txt"),
|
||||
domain: path_1.default.join("domain", "default.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "default.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1: {
|
||||
id: "default-normalizer-v1_1",
|
||||
name: "Стандартный пресет NDC v1.1",
|
||||
promptVersion: "normalizer_v1_1",
|
||||
schemaNotes: "v1.1: усиленная taxonomy intent/route и confidence policy. Используется схема normalized_query_v1 без дополнительных полей.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "normalizer_v1_1.txt"),
|
||||
domain: path_1.default.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "normalizer_fewshot_v1_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_1: {
|
||||
id: "default-normalizer-v1_1_1",
|
||||
name: "Стандартный пресет NDC v1.1.1",
|
||||
promptVersion: "normalizer_v1_1_1",
|
||||
schemaNotes: "v1.1.1: surgical patch для period_close_risk, exact drilldown requires и anomaly route escalation. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "normalizer_v1_1_1.txt"),
|
||||
domain: path_1.default.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "normalizer_fewshot_v1_1_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_2: {
|
||||
id: "default-normalizer-v1_1_2",
|
||||
name: "Стандартный пресет NDC v1.1.2",
|
||||
promptVersion: "normalizer_v1_1_2",
|
||||
schemaNotes: "v1.1.2: точечный patch границы heavy_analytical vs period_close_risk + confidence guard на boundary кейсах. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "normalizer_v1_1_2.txt"),
|
||||
domain: path_1.default.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "normalizer_fewshot_v1_1_2.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_2_1: {
|
||||
id: "default-normalizer-v1_1_2_1",
|
||||
name: "Стандартный пресет NDC v1.1.2.1",
|
||||
promptVersion: "normalizer_v1_1_2_1",
|
||||
schemaNotes: "v1.1.2.1: stable prompt baseline v1.1.2 + accounting-review phrasing anchors for 30-case validation pack. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "normalizer_v1_1_2_1.txt"),
|
||||
domain: path_1.default.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "normalizer_fewshot_v1_1_2_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2: {
|
||||
id: "default-normalizer-v2",
|
||||
name: "Стандартный пресет NDC v2",
|
||||
promptVersion: "normalizer_v2",
|
||||
schemaNotes: "v2: decomposition-first pre-router. LLM returns fragments + scope + flags; deterministic routing happens in code. Схема normalized_query_v2.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "normalizer_v2.txt"),
|
||||
domain: path_1.default.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "normalizer_v2.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2_0_1: {
|
||||
id: "default-normalizer-v2_0_1",
|
||||
name: "Стандартный пресет NDC v2.0.1",
|
||||
promptVersion: "normalizer_v2_0_1",
|
||||
schemaNotes: "v2.0.1: clarification-threshold policy. Вопросы в контуре и с понятным route должны исполняться без лишних уточнений. Схема normalized_query_v2_0_1.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "normalizer_v2_0_1.txt"),
|
||||
domain: path_1.default.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "normalizer_v2_0_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2_0_2: {
|
||||
id: "default-normalizer-v2_0_2",
|
||||
name: "Стандартный пресет NDC v2.0.2",
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
schemaNotes: "v2.0.2: execution-state hardening + explicit route_status/no_route_reason. Схема normalized_query_v2_0_2.",
|
||||
files: {
|
||||
system: path_1.default.join("system", "default.txt"),
|
||||
developer: path_1.default.join("developer", "normalizer_v2_0_2.txt"),
|
||||
domain: path_1.default.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path_1.default.join("fewshot", "normalizer_v2_0_2.txt")
|
||||
}
|
||||
}
|
||||
};
|
||||
function isPromptVersion(value) {
|
||||
return (value === "normalizer_v1" ||
|
||||
value === "normalizer_v1_1" ||
|
||||
value === "normalizer_v1_1_1" ||
|
||||
value === "normalizer_v1_1_2" ||
|
||||
value === "normalizer_v1_1_2_1" ||
|
||||
value === "normalizer_v2" ||
|
||||
value === "normalizer_v2_0_1" ||
|
||||
value === "normalizer_v2_0_2");
|
||||
}
|
||||
function resolvePromptVersion(requested) {
|
||||
if (isPromptVersion(requested)) {
|
||||
return requested;
|
||||
}
|
||||
if (isPromptVersion(config_1.DEFAULT_PROMPT_VERSION)) {
|
||||
return config_1.DEFAULT_PROMPT_VERSION;
|
||||
}
|
||||
return "normalizer_v2_0_2";
|
||||
}
|
||||
function loadBuiltinPreset(promptVersion) {
|
||||
const now = new Date().toISOString();
|
||||
const definition = BUILTIN_PROMPT_PRESETS[promptVersion];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
prompt_version: definition.promptVersion,
|
||||
systemPrompt: readPromptFile(definition.files.system),
|
||||
developerPrompt: readPromptFile(definition.files.developer),
|
||||
domainPrompt: readPromptFile(definition.files.domain),
|
||||
schemaNotes: definition.schemaNotes,
|
||||
fewShotExamples: readPromptFile(definition.files.fewshot)
|
||||
};
|
||||
}
|
||||
function listBuiltinPromptPresets() {
|
||||
return Object.keys(BUILTIN_PROMPT_PRESETS).map((version) => loadBuiltinPreset(version));
|
||||
}
|
||||
function loadDefaultPrompts(promptVersion) {
|
||||
return loadBuiltinPreset(resolvePromptVersion(promptVersion));
|
||||
}
|
||||
function buildPromptBundle(input) {
|
||||
const selectedPromptVersion = resolvePromptVersion(input.promptVersion);
|
||||
const defaults = loadDefaultPrompts(selectedPromptVersion);
|
||||
const systemPrompt = (input.systemPrompt ?? defaults.systemPrompt).trim();
|
||||
const developerPrompt = (input.developerPrompt ?? defaults.developerPrompt).trim();
|
||||
const domainPrompt = (input.domainPrompt ?? defaults.domainPrompt).trim();
|
||||
const schemaNotes = (input.schemaNotes ?? defaults.schemaNotes ?? "").trim();
|
||||
const fewShotExamples = (input.fewShotExamples ?? defaults.fewShotExamples ?? "").trim();
|
||||
const prompt_version = (input.promptVersion ?? defaults.prompt_version).trim() || selectedPromptVersion;
|
||||
const sections = [developerPrompt, `Schema notes:\n${schemaNotes}`];
|
||||
if (fewShotExamples) {
|
||||
sections.push(`Few-shot examples:\n${fewShotExamples}`);
|
||||
}
|
||||
return {
|
||||
prompt_version,
|
||||
systemPrompt,
|
||||
developerPrompt,
|
||||
domainPrompt,
|
||||
schemaNotes,
|
||||
fewShotExamples,
|
||||
combinedDeveloperPrompt: sections.join("\n\n")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.normalizeRetrievalResult = normalizeRetrievalResult;
|
||||
const config_1 = require("../config");
|
||||
const stage1Contracts_1 = require("../types/stage1Contracts");
|
||||
function toObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function toStringOrNull(value) {
|
||||
if (typeof value !== "string")
|
||||
return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
function toNumberOrNull(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function normalizeStatus(value) {
|
||||
if (value === "ok" || value === "empty" || value === "partial" || value === "error") {
|
||||
return value;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
function normalizeResultType(value) {
|
||||
if (value === "list" || value === "summary" || value === "object" || value === "chain" || value === "ranking") {
|
||||
return value;
|
||||
}
|
||||
return "summary";
|
||||
}
|
||||
function normalizeObjectArray(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => (item && typeof item === "object" ? item : null))
|
||||
.filter((item) => item !== null);
|
||||
}
|
||||
function normalizeSummary(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function normalizeErrors(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
function normalizeStringArray(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
function normalizeConfidence(value) {
|
||||
if (value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
function parseEvidenceConfidence(value) {
|
||||
if (value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function normalizeEvidenceNamespace(value) {
|
||||
const normalized = toStringOrNull(value)?.toLowerCase();
|
||||
if (!normalized)
|
||||
return "unknown";
|
||||
if (normalized === "snapshot_2020" || normalized === "snapshot")
|
||||
return "snapshot_2020";
|
||||
if (normalized === "assistant_derived" || normalized === "derived")
|
||||
return "assistant_derived";
|
||||
return "unknown";
|
||||
}
|
||||
function inferEvidenceKind(item) {
|
||||
if (item.mechanism_of_failure !== undefined || item.failed_expected_edge !== undefined || item.expected_next_step !== undefined) {
|
||||
return "mechanism_link";
|
||||
}
|
||||
if (item.risk_score !== undefined || item.zero_guid_values !== undefined || item.unknown_link_count !== undefined) {
|
||||
return "anomaly_signal";
|
||||
}
|
||||
if (item.records_count !== undefined || item.operations_count !== undefined || item.document_refs_count !== undefined) {
|
||||
return "aggregation";
|
||||
}
|
||||
if (item.limitation !== undefined || item.is_snapshot_limited !== undefined) {
|
||||
return "limitation_note";
|
||||
}
|
||||
return "factual_anchor";
|
||||
}
|
||||
function inferMechanismNoteLegacy(kind, item) {
|
||||
const explicit = toStringOrNull(item.mechanism_note);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (kind === "mechanism_link") {
|
||||
const failure = toStringOrNull(item.mechanism_of_failure);
|
||||
if (failure)
|
||||
return failure;
|
||||
return "Mechanism link inferred from retrieval evidence.";
|
||||
}
|
||||
if (kind === "anomaly_signal") {
|
||||
return "Anomaly signal inferred from risk-oriented fields.";
|
||||
}
|
||||
if (kind === "aggregation") {
|
||||
return "Aggregated evidence item.";
|
||||
}
|
||||
if (kind === "limitation_note") {
|
||||
return "Evidence includes explicit limitation hints.";
|
||||
}
|
||||
return "Factual evidence anchor.";
|
||||
}
|
||||
function resolveMechanismNote(kind, item) {
|
||||
const explicit = toStringOrNull(item.mechanism_note);
|
||||
if (explicit) {
|
||||
return {
|
||||
note: explicit,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
if (kind === "mechanism_link") {
|
||||
const failure = toStringOrNull(item.mechanism_of_failure);
|
||||
if (failure) {
|
||||
return {
|
||||
note: failure,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
const failedEdge = toStringOrNull(item.failed_expected_edge);
|
||||
const expectedNext = toStringOrNull(item.expected_next_step);
|
||||
const composed = [failedEdge ? `failed_edge=${failedEdge}` : null, expectedNext ? `expected_next_step=${expectedNext}` : null]
|
||||
.filter((part) => Boolean(part))
|
||||
.join("; ");
|
||||
if (composed) {
|
||||
return {
|
||||
note: composed,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!config_1.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return {
|
||||
note: inferMechanismNoteLegacy(kind, item),
|
||||
reliable: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
note: null,
|
||||
reliable: false
|
||||
};
|
||||
}
|
||||
function normalizeEvidenceSourceType(value, record) {
|
||||
const normalized = toStringOrNull(value);
|
||||
if (normalized === "retrieval_item" || normalized === "retrieval_summary" || normalized === "derived") {
|
||||
return normalized;
|
||||
}
|
||||
if (record.records_count !== undefined || record.operations_count !== undefined || record.document_refs_count !== undefined) {
|
||||
return "retrieval_summary";
|
||||
}
|
||||
return "retrieval_item";
|
||||
}
|
||||
function readPointer(record) {
|
||||
const pointer = toObject(record.pointer);
|
||||
return pointer ?? {};
|
||||
}
|
||||
function normalizeEvidencePointer(fragmentId, route, record, index) {
|
||||
const pointer = readPointer(record);
|
||||
const source = toObject(pointer.source);
|
||||
const locator = toObject(pointer.locator);
|
||||
const sourceEntityCandidate = toStringOrNull(source?.entity) ?? toStringOrNull(record.source_entity);
|
||||
const sourceEntity = sourceEntityCandidate ?? "unknown_entity";
|
||||
const sourceIdCandidate = toStringOrNull(source?.id) ?? toStringOrNull(record.source_id);
|
||||
const sourceId = sourceIdCandidate ?? `${route}:${fragmentId}:${index + 1}`;
|
||||
const period = toStringOrNull(source?.period) ?? toStringOrNull(record.period);
|
||||
const namespace = normalizeEvidenceNamespace(source?.namespace ?? record.source_namespace);
|
||||
return {
|
||||
pointer: {
|
||||
fragment_id: toStringOrNull(pointer.fragment_id) ?? fragmentId,
|
||||
route: toStringOrNull(pointer.route) ?? route,
|
||||
source: {
|
||||
namespace,
|
||||
entity: sourceEntity,
|
||||
id: sourceId,
|
||||
period
|
||||
},
|
||||
locator: {
|
||||
field_path: toStringOrNull(locator?.field_path) ?? toStringOrNull(record.field_path),
|
||||
item_index: toNumberOrNull(locator?.item_index) ?? index
|
||||
}
|
||||
},
|
||||
fallback_source_namespace: namespace === "unknown",
|
||||
fallback_source_entity: sourceEntityCandidate === null,
|
||||
fallback_source_id: sourceIdCandidate === null
|
||||
};
|
||||
}
|
||||
function canonicalizeSourceRefPart(value) {
|
||||
return encodeURIComponent((value ?? "none").trim().toLowerCase());
|
||||
}
|
||||
function buildSourceRef(pointer) {
|
||||
return {
|
||||
schema_version: stage1Contracts_1.EVIDENCE_SOURCE_REF_SCHEMA_VERSION,
|
||||
namespace: pointer.source.namespace,
|
||||
entity: pointer.source.entity,
|
||||
id: pointer.source.id,
|
||||
period: pointer.source.period,
|
||||
canonical_ref: [
|
||||
stage1Contracts_1.EVIDENCE_SOURCE_REF_SCHEMA_VERSION,
|
||||
canonicalizeSourceRefPart(pointer.source.namespace),
|
||||
canonicalizeSourceRefPart(pointer.source.entity),
|
||||
canonicalizeSourceRefPart(pointer.source.id),
|
||||
canonicalizeSourceRefPart(pointer.source.period)
|
||||
].join("|")
|
||||
};
|
||||
}
|
||||
function toBoolean(value) {
|
||||
if (typeof value === "boolean")
|
||||
return value;
|
||||
if (typeof value === "number")
|
||||
return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.trim().toLowerCase();
|
||||
return lowered === "true" || lowered === "1" || lowered === "yes";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function limitationCodeFromText(text) {
|
||||
const lower = text.toLowerCase();
|
||||
if (/(snapshot|read-only|read only)/i.test(lower)) {
|
||||
return "snapshot_only";
|
||||
}
|
||||
if (/heuristic/i.test(lower)) {
|
||||
return "heuristic_inference";
|
||||
}
|
||||
if (/mechanism/i.test(lower)) {
|
||||
return "missing_mechanism";
|
||||
}
|
||||
if (/(guid|detail|specific)/i.test(lower)) {
|
||||
return "insufficient_detail";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
function resolveEvidenceLimitation(input) {
|
||||
const explicitLimitation = toStringOrNull(input.record.limitation);
|
||||
if (explicitLimitation) {
|
||||
return {
|
||||
reason_code: limitationCodeFromText(explicitLimitation),
|
||||
note: explicitLimitation
|
||||
};
|
||||
}
|
||||
if (toBoolean(input.record.is_snapshot_limited)) {
|
||||
return {
|
||||
reason_code: "snapshot_only",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (!config_1.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return null;
|
||||
}
|
||||
if (input.mechanismExpected && !input.mechanismReliable) {
|
||||
return {
|
||||
reason_code: "missing_mechanism",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.pointerWeak) {
|
||||
return {
|
||||
reason_code: "weak_source_mapping",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.sourceType === "derived") {
|
||||
return {
|
||||
reason_code: "heuristic_inference",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.evidenceKind === "limitation_note") {
|
||||
return {
|
||||
reason_code: "unknown",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function downgradeConfidence(value) {
|
||||
if (value === "high")
|
||||
return "medium";
|
||||
if (value === "medium")
|
||||
return "low";
|
||||
return "low";
|
||||
}
|
||||
function resolveEvidenceConfidence(input) {
|
||||
if (!config_1.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return input.explicitConfidence ?? "medium";
|
||||
}
|
||||
let confidence = input.explicitConfidence ?? (input.sourceType === "retrieval_item" ? "medium" : "low");
|
||||
if (input.limitation?.reason_code === "missing_mechanism" || input.limitation?.reason_code === "weak_source_mapping") {
|
||||
confidence = downgradeConfidence(confidence);
|
||||
}
|
||||
if (input.sourceType === "derived" && !input.explicitConfidence) {
|
||||
confidence = "low";
|
||||
}
|
||||
if (input.mechanismExpected && !input.mechanismReliable) {
|
||||
confidence = "low";
|
||||
}
|
||||
if (input.pointerWeak) {
|
||||
confidence = "low";
|
||||
}
|
||||
return confidence;
|
||||
}
|
||||
function normalizeEvidenceItems(fragmentId, requirementIds, route, value) {
|
||||
const records = normalizeObjectArray(value);
|
||||
return records.map((record, index) => {
|
||||
const evidenceId = toStringOrNull(record.evidence_id) ?? `ev-${fragmentId}-${index + 1}`;
|
||||
const claimRef = toStringOrNull(record.claim_ref) ??
|
||||
(requirementIds[0] ? `requirement:${requirementIds[0]}` : `fragment:${fragmentId}`);
|
||||
const evidenceKind = inferEvidenceKind(record);
|
||||
const sourceType = normalizeEvidenceSourceType(record.source_type, record);
|
||||
const pointerResult = normalizeEvidencePointer(fragmentId, route, record, index);
|
||||
const mechanism = resolveMechanismNote(evidenceKind, record);
|
||||
const mechanismExpected = evidenceKind === "mechanism_link" || evidenceKind === "anomaly_signal" || evidenceKind === "aggregation";
|
||||
const pointerWeak = pointerResult.fallback_source_namespace || pointerResult.fallback_source_entity || pointerResult.fallback_source_id;
|
||||
const limitation = resolveEvidenceLimitation({
|
||||
record,
|
||||
sourceType,
|
||||
evidenceKind,
|
||||
mechanismReliable: mechanism.reliable,
|
||||
mechanismExpected,
|
||||
pointerWeak
|
||||
});
|
||||
const confidence = resolveEvidenceConfidence({
|
||||
explicitConfidence: parseEvidenceConfidence(record.confidence),
|
||||
sourceType,
|
||||
mechanismReliable: mechanism.reliable,
|
||||
mechanismExpected,
|
||||
limitation,
|
||||
pointerWeak
|
||||
});
|
||||
return {
|
||||
evidence_id: evidenceId,
|
||||
claim_ref: claimRef,
|
||||
source_type: sourceType,
|
||||
source_ref: buildSourceRef(pointerResult.pointer),
|
||||
pointer: pointerResult.pointer,
|
||||
evidence_kind: evidenceKind,
|
||||
mechanism_note: mechanism.note,
|
||||
confidence,
|
||||
limitation,
|
||||
payload: record
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeRetrievalResult(fragmentId, requirementIds, route, raw) {
|
||||
return {
|
||||
fragment_id: fragmentId,
|
||||
requirement_ids: requirementIds,
|
||||
route,
|
||||
status: normalizeStatus(raw.status),
|
||||
result_type: normalizeResultType(raw.result_type),
|
||||
items: normalizeObjectArray(raw.items),
|
||||
summary: normalizeSummary(raw.summary),
|
||||
evidence: normalizeEvidenceItems(fragmentId, requirementIds, route, raw.evidence),
|
||||
why_included: normalizeStringArray(raw.why_included),
|
||||
selection_reason: normalizeStringArray(raw.selection_reason),
|
||||
risk_factors: normalizeStringArray(raw.risk_factors),
|
||||
business_interpretation: normalizeStringArray(raw.business_interpretation),
|
||||
confidence: normalizeConfidence(raw.confidence),
|
||||
limitations: normalizeStringArray(raw.limitations),
|
||||
errors: normalizeErrors(raw.errors)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.simulateDeterministicRouting = simulateDeterministicRouting;
|
||||
exports.toRouteHintSummary = toRouteHintSummary;
|
||||
exports.toRouterInput = toRouterInput;
|
||||
function toRouteHintSummaryV1(normalized) {
|
||||
return {
|
||||
mode: "legacy_v1",
|
||||
intent_class: normalized.intent_class,
|
||||
route_hint: normalized.route_hint,
|
||||
confidence: normalized.confidence.route_hint,
|
||||
decision_flags: {
|
||||
needs_cross_entity_join: normalized.requires.needs_cross_entity_join,
|
||||
needs_causal_chain: normalized.requires.needs_causal_chain,
|
||||
needs_exact_object_trace: normalized.requires.needs_exact_object_trace,
|
||||
needs_ranking: normalized.requires.needs_ranking,
|
||||
needs_anomaly_summary: normalized.requires.needs_anomaly_summary,
|
||||
needs_runtime_truth: normalized.requires.needs_runtime_truth,
|
||||
needs_period_cut: normalized.requires.needs_period_cut,
|
||||
needs_evidence: normalized.requires.needs_evidence
|
||||
},
|
||||
period_scope: normalized.period_scope,
|
||||
entities: {
|
||||
domain_entities: normalized.domain_entities,
|
||||
accounts_mentioned: normalized.accounts_mentioned,
|
||||
documents_mentioned: normalized.documents_mentioned,
|
||||
registers_mentioned: normalized.registers_mentioned
|
||||
}
|
||||
};
|
||||
}
|
||||
function reasonForNoRoute(noRouteReason) {
|
||||
if (noRouteReason === "out_of_scope") {
|
||||
return "Fragment is out-of-scope for company-specific accounting contour.";
|
||||
}
|
||||
if (noRouteReason === "missing_mapping") {
|
||||
return "Fragment is in-scope but route mapping is currently missing.";
|
||||
}
|
||||
if (noRouteReason === "unsupported_fragment_type") {
|
||||
return "Fragment type is not supported by the current deterministic route map.";
|
||||
}
|
||||
return "Fragment requires clarification or is too underspecified for safe routing.";
|
||||
}
|
||||
function explicitRouteStatus(fragment) {
|
||||
return "route_status" in fragment ? fragment.route_status : null;
|
||||
}
|
||||
function explicitNoRouteReason(fragment) {
|
||||
return "no_route_reason" in fragment ? fragment.no_route_reason : null;
|
||||
}
|
||||
function executionReadiness(fragment) {
|
||||
return "execution_readiness" in fragment ? fragment.execution_readiness : null;
|
||||
}
|
||||
function clarificationReason(fragment) {
|
||||
return "clarification_reason" in fragment ? fragment.clarification_reason : null;
|
||||
}
|
||||
function softAssumptions(fragment) {
|
||||
return "soft_assumption_used" in fragment ? fragment.soft_assumption_used : [];
|
||||
}
|
||||
function buildNoRouteDecision(fragment, noRouteReason) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: executionReadiness(fragment),
|
||||
clarification_reason: clarificationReason(fragment),
|
||||
soft_assumption_used: softAssumptions(fragment),
|
||||
route_status: "no_route",
|
||||
no_route_reason: noRouteReason ?? "insufficient_specificity",
|
||||
route: "no_route",
|
||||
reason: reasonForNoRoute(noRouteReason)
|
||||
};
|
||||
}
|
||||
function decideRouteForFragment(fragment) {
|
||||
const status = explicitRouteStatus(fragment);
|
||||
const noRouteReason = explicitNoRouteReason(fragment);
|
||||
const readiness = executionReadiness(fragment);
|
||||
const clarification = clarificationReason(fragment);
|
||||
const soft = softAssumptions(fragment);
|
||||
if (status === "no_route") {
|
||||
return buildNoRouteDecision(fragment, noRouteReason);
|
||||
}
|
||||
if (readiness === "needs_clarification" || readiness === "no_route") {
|
||||
return buildNoRouteDecision(fragment, noRouteReason ?? "insufficient_specificity");
|
||||
}
|
||||
if (fragment.domain_relevance !== "in_scope") {
|
||||
return buildNoRouteDecision(fragment, "out_of_scope");
|
||||
}
|
||||
if (fragment.flags.asks_for_exact_object_trace) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "live_mcp_drilldown",
|
||||
reason: "Exact object trace requested."
|
||||
};
|
||||
}
|
||||
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "batch_refresh_then_store",
|
||||
reason: "Ranking/summary semantics require batch analytical route."
|
||||
};
|
||||
}
|
||||
if (fragment.flags.has_multi_entity_scope && fragment.flags.asks_for_chain_explanation) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "hybrid_store_plus_live",
|
||||
reason: "Multi-entity causal chain requested."
|
||||
};
|
||||
}
|
||||
if (fragment.flags.asks_for_rule_check && !fragment.flags.asks_for_chain_explanation) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_feature_risk",
|
||||
reason: "Rule-control check without causal decomposition."
|
||||
};
|
||||
}
|
||||
if (fragment.flags.asks_for_anomaly_scan &&
|
||||
!fragment.flags.asks_for_ranking_or_top &&
|
||||
!(fragment.flags.has_multi_entity_scope && fragment.flags.asks_for_chain_explanation)) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_feature_risk",
|
||||
reason: "Anomaly scan without heavy ranking or causal chain."
|
||||
};
|
||||
}
|
||||
if (status === "routed") {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_canonical",
|
||||
reason: "Routed fragment without deep analytical or causal signals."
|
||||
};
|
||||
}
|
||||
return buildNoRouteDecision(fragment, "missing_mapping");
|
||||
}
|
||||
function fallbackMessageFor(type) {
|
||||
if (type === "out_of_scope") {
|
||||
return "Я работаю только с данными и бухгалтерским контуром текущей компании. Запрос вне доступной предметной области.";
|
||||
}
|
||||
if (type === "clarification") {
|
||||
return "Могу проверить это в контуре компании, но нужно уточнить период, документ, счет или участок учета.";
|
||||
}
|
||||
if (type === "partial") {
|
||||
return "Обработаю только часть запроса, которая относится к данным компании. Остальное выходит за пределы доступного контура.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function simulateDeterministicRouting(normalized) {
|
||||
const decisions = normalized.fragments.map((fragment) => decideRouteForFragment(fragment));
|
||||
const inScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope").length;
|
||||
const outOfScopeCount = decisions.filter((item) => item.domain_relevance === "out_of_scope").length;
|
||||
const routedInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.route !== "no_route").length;
|
||||
const clarificationInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.execution_readiness === "needs_clarification").length;
|
||||
const noRouteInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.route === "no_route").length;
|
||||
let fallbackType = "none";
|
||||
if (!normalized.message_in_scope || inScopeCount === 0) {
|
||||
fallbackType = "out_of_scope";
|
||||
}
|
||||
else if (routedInScopeCount === 0 && clarificationInScopeCount > 0) {
|
||||
fallbackType = "clarification";
|
||||
}
|
||||
else if (routedInScopeCount === 0 && noRouteInScopeCount > 0) {
|
||||
fallbackType = "clarification";
|
||||
}
|
||||
else if ((inScopeCount > 0 && outOfScopeCount > 0) || (routedInScopeCount > 0 && noRouteInScopeCount > 0)) {
|
||||
fallbackType = "partial";
|
||||
}
|
||||
return {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: normalized.message_in_scope,
|
||||
scope_confidence: normalized.scope_confidence,
|
||||
planner: {
|
||||
total_fragments: normalized.fragments.length,
|
||||
in_scope_fragments: inScopeCount,
|
||||
out_of_scope_fragments: outOfScopeCount,
|
||||
discarded_fragments: normalized.discarded_fragments.length,
|
||||
contains_multiple_tasks: normalized.contains_multiple_tasks
|
||||
},
|
||||
decisions,
|
||||
fallback: {
|
||||
type: fallbackType,
|
||||
message: fallbackMessageFor(fallbackType)
|
||||
}
|
||||
};
|
||||
}
|
||||
function toRouteHintSummary(normalized) {
|
||||
if (normalized.schema_version === "normalized_query_v2" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_1" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_2") {
|
||||
return simulateDeterministicRouting(normalized);
|
||||
}
|
||||
return toRouteHintSummaryV1(normalized);
|
||||
}
|
||||
function toRouterInput(normalized) {
|
||||
if (normalized.schema_version === "normalized_query_v2" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_1" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_2") {
|
||||
return {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: normalized.message_in_scope,
|
||||
scope_confidence: normalized.scope_confidence,
|
||||
contains_multiple_tasks: normalized.contains_multiple_tasks,
|
||||
fragments: normalized.fragments.map((fragment) => ({
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
execution_readiness: "execution_readiness" in fragment ? fragment.execution_readiness : null,
|
||||
clarification_reason: "clarification_reason" in fragment ? fragment.clarification_reason : null,
|
||||
soft_assumption_used: "soft_assumption_used" in fragment ? fragment.soft_assumption_used : [],
|
||||
route_status: "route_status" in fragment ? fragment.route_status : null,
|
||||
no_route_reason: "no_route_reason" in fragment ? fragment.no_route_reason : null,
|
||||
flags: fragment.flags,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
confidence: fragment.confidence
|
||||
}))
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: "legacy_v1",
|
||||
intent_class: normalized.intent_class,
|
||||
decision_flags: {
|
||||
needs_cross_entity_join: normalized.requires.needs_cross_entity_join,
|
||||
needs_causal_chain: normalized.requires.needs_causal_chain,
|
||||
needs_exact_object_trace: normalized.requires.needs_exact_object_trace,
|
||||
needs_ranking: normalized.requires.needs_ranking,
|
||||
needs_anomaly_summary: normalized.requires.needs_anomaly_summary,
|
||||
needs_runtime_truth: normalized.requires.needs_runtime_truth
|
||||
},
|
||||
route_hint: normalized.route_hint,
|
||||
confidence: normalized.confidence.overall,
|
||||
entities: {
|
||||
domain_entities: normalized.domain_entities,
|
||||
accounts_mentioned: normalized.accounts_mentioned,
|
||||
documents_mentioned: normalized.documents_mentioned,
|
||||
registers_mentioned: normalized.registers_mentioned
|
||||
},
|
||||
period_scope: normalized.period_scope
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.validateNormalized = validateNormalized;
|
||||
exports.assertNormalized = assertNormalized;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const _2020_1 = __importDefault(require("ajv/dist/2020"));
|
||||
const config_1 = require("../config");
|
||||
const validators = new Map();
|
||||
function schemaPath(version) {
|
||||
if (version === "v1") {
|
||||
return path_1.default.resolve(config_1.SCHEMAS_DIR, "normalized_query_v1.json");
|
||||
}
|
||||
if (version === "v2_0_1") {
|
||||
return path_1.default.resolve(config_1.SCHEMAS_DIR, "normalized_query_v2_0_1.json");
|
||||
}
|
||||
if (version === "v2_0_2") {
|
||||
return path_1.default.resolve(config_1.SCHEMAS_DIR, "normalized_query_v2_0_2.json");
|
||||
}
|
||||
return path_1.default.resolve(config_1.SCHEMAS_DIR, "normalized_query_v2.json");
|
||||
}
|
||||
function loadValidator(version) {
|
||||
const cached = validators.get(version);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const raw = fs_1.default.readFileSync(schemaPath(version), "utf-8");
|
||||
const schema = JSON.parse(raw);
|
||||
const ajv = new _2020_1.default({ allErrors: true, strict: false });
|
||||
const compiled = ajv.compile(schema);
|
||||
validators.set(version, compiled);
|
||||
return compiled;
|
||||
}
|
||||
function normalizeAjvErrors(errors) {
|
||||
if (!errors || errors.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return errors.map((item) => `${item.instancePath || "/"} ${item.message ?? "validation error"}`.trim());
|
||||
}
|
||||
function validateNormalized(payload, schemaVersion = "v1") {
|
||||
const check = loadValidator(schemaVersion);
|
||||
const passed = check(payload);
|
||||
return {
|
||||
passed: Boolean(passed),
|
||||
errors: passed ? [] : normalizeAjvErrors(check.errors)
|
||||
};
|
||||
}
|
||||
function assertNormalized(payload, schemaVersion = "v1") {
|
||||
const validation = validateNormalized(payload, schemaVersion);
|
||||
if (!validation.passed) {
|
||||
throw new Error(`Invalid normalized JSON: ${validation.errors.join("; ")}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.saveTrace = saveTrace;
|
||||
exports.listTraces = listTraces;
|
||||
exports.getTrace = getTrace;
|
||||
exports.savePreset = savePreset;
|
||||
exports.listPresets = listPresets;
|
||||
exports.saveEvalCase = saveEvalCase;
|
||||
exports.redactRequestPayload = redactRequestPayload;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const config_1 = require("../config");
|
||||
const files_1 = require("../utils/files");
|
||||
function redactSecrets(payload) {
|
||||
const output = { ...payload };
|
||||
delete output.apiKey;
|
||||
return output;
|
||||
}
|
||||
function saveTrace(record) {
|
||||
(0, files_1.ensureDir)(config_1.TRACES_DIR);
|
||||
const target = path_1.default.resolve(config_1.TRACES_DIR, `${record.trace_id}.json`);
|
||||
(0, files_1.writeJsonFile)(target, record);
|
||||
}
|
||||
function listTraces(limit = 100) {
|
||||
(0, files_1.ensureDir)(config_1.TRACES_DIR);
|
||||
const files = fs_1.default
|
||||
.readdirSync(config_1.TRACES_DIR)
|
||||
.filter((item) => item.endsWith(".json"))
|
||||
.sort((a, b) => {
|
||||
const pa = path_1.default.resolve(config_1.TRACES_DIR, a);
|
||||
const pb = path_1.default.resolve(config_1.TRACES_DIR, b);
|
||||
return fs_1.default.statSync(pb).mtimeMs - fs_1.default.statSync(pa).mtimeMs;
|
||||
})
|
||||
.slice(0, limit);
|
||||
return files.map((fileName) => {
|
||||
const raw = fs_1.default.readFileSync(path_1.default.resolve(config_1.TRACES_DIR, fileName), "utf-8");
|
||||
const item = JSON.parse(raw);
|
||||
return {
|
||||
trace_id: item.trace_id,
|
||||
timestamp: item.timestamp,
|
||||
model: item.model,
|
||||
question_short: item.user_question_raw.slice(0, 110),
|
||||
confidence: item.confidence,
|
||||
validation_passed: item.validation_result.passed,
|
||||
route_hint: item.route_hint,
|
||||
save_status: "saved"
|
||||
};
|
||||
});
|
||||
}
|
||||
function getTrace(traceId) {
|
||||
(0, files_1.ensureDir)(config_1.TRACES_DIR);
|
||||
const target = path_1.default.resolve(config_1.TRACES_DIR, `${traceId}.json`);
|
||||
if (!fs_1.default.existsSync(target)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs_1.default.readFileSync(target, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
function savePreset(preset) {
|
||||
(0, files_1.ensureDir)(config_1.PRESETS_DIR);
|
||||
(0, files_1.writeJsonFile)(path_1.default.resolve(config_1.PRESETS_DIR, `${preset.id}.json`), preset);
|
||||
}
|
||||
function listPresets() {
|
||||
(0, files_1.ensureDir)(config_1.PRESETS_DIR);
|
||||
return fs_1.default
|
||||
.readdirSync(config_1.PRESETS_DIR)
|
||||
.filter((item) => item.endsWith(".json"))
|
||||
.map((fileName) => {
|
||||
const raw = fs_1.default.readFileSync(path_1.default.resolve(config_1.PRESETS_DIR, fileName), "utf-8");
|
||||
return JSON.parse(raw);
|
||||
})
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
function saveEvalCase(casePayload) {
|
||||
(0, files_1.ensureDir)(config_1.EVAL_CASES_DIR);
|
||||
const id = String(casePayload.case_id ?? `NQ-${Date.now()}`);
|
||||
(0, files_1.writeJsonFile)(path_1.default.resolve(config_1.EVAL_CASES_DIR, `${id}.json`), casePayload);
|
||||
return id;
|
||||
}
|
||||
function redactRequestPayload(payload) {
|
||||
return redactSecrets(payload);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -0,0 +1,48 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ACCOUNTANT_SCORING_RUBRIC_V01 = exports.INVESTIGATION_MAX_REQUIREMENT_LINKS = exports.INVESTIGATION_MAX_PRIMARY_ACCOUNTS = exports.INVESTIGATION_MAX_UNCERTAINTIES = exports.INVESTIGATION_MAX_EVIDENCE_REFS = exports.EVIDENCE_SOURCE_REF_SCHEMA_VERSION = exports.ASSISTANT_EVAL_RECORD_SCHEMA_VERSION = exports.ANSWER_STRUCTURE_SCHEMA_VERSION = exports.INVESTIGATION_STATE_SCHEMA_VERSION = void 0;
|
||||
exports.INVESTIGATION_STATE_SCHEMA_VERSION = "investigation_state_v1";
|
||||
exports.ANSWER_STRUCTURE_SCHEMA_VERSION = "answer_structure_v1_1";
|
||||
exports.ASSISTANT_EVAL_RECORD_SCHEMA_VERSION = "assistant_eval_record_v0_1";
|
||||
exports.EVIDENCE_SOURCE_REF_SCHEMA_VERSION = "evidence_source_ref_v1";
|
||||
exports.INVESTIGATION_MAX_EVIDENCE_REFS = 24;
|
||||
exports.INVESTIGATION_MAX_UNCERTAINTIES = 12;
|
||||
exports.INVESTIGATION_MAX_PRIMARY_ACCOUNTS = 8;
|
||||
exports.INVESTIGATION_MAX_REQUIREMENT_LINKS = 8;
|
||||
exports.ACCOUNTANT_SCORING_RUBRIC_V01 = {
|
||||
retrieval_differentiation_rate: [
|
||||
{ score: 0, label: "No Differentiation", description: "Ответы почти одинаковые для разных кейсов." },
|
||||
{ score: 3, label: "Partial Differentiation", description: "Различия есть, но по механизмам недостаточно стабильны." },
|
||||
{ score: 5, label: "Strong Differentiation", description: "Ответы устойчиво различаются по предмету и механизму." }
|
||||
],
|
||||
generic_explanation_rate: [
|
||||
{ score: 0, label: "Mostly Generic", description: "Преобладают общие объяснения без локальной опоры." },
|
||||
{ score: 3, label: "Mixed", description: "Есть и предметные, и общие блоки объяснения." },
|
||||
{ score: 5, label: "Mostly Specific", description: "Объяснение в основном case-specific и операбельно." }
|
||||
],
|
||||
accountant_actionability_score: [
|
||||
{ score: 0, label: "Not Actionable", description: "Бухгалтер не получает понятного следующего шага." },
|
||||
{ score: 3, label: "Partially Actionable", description: "Следующий шаг есть, но недостаточно конкретен." },
|
||||
{ score: 5, label: "Actionable", description: "Есть конкретные проверяемые действия и приоритет." }
|
||||
],
|
||||
false_confidence_rate: [
|
||||
{ score: 0, label: "High False Confidence", description: "Часто дается уверенный тон при слабой опоре." },
|
||||
{ score: 3, label: "Moderate False Confidence", description: "Периодически встречается избыточная уверенность." },
|
||||
{ score: 5, label: "Low False Confidence", description: "Неопределенность обозначается честно и вовремя." }
|
||||
],
|
||||
broad_answer_rate: [
|
||||
{ score: 0, label: "Broad by Default", description: "Часто даются широкие ответы без controlled narrowing." },
|
||||
{ score: 3, label: "Partially Controlled", description: "Broad-ответы периодически сужаются, но не всегда." },
|
||||
{ score: 5, label: "Controlled", description: "Broad-ответы редки и сопровождаются корректным сужением." }
|
||||
],
|
||||
mechanism_specificity_score: [
|
||||
{ score: 0, label: "No Mechanism", description: "Есть только лейблы без механики поломки." },
|
||||
{ score: 3, label: "Partial Mechanism", description: "Механизм описан частично, без полной связки." },
|
||||
{ score: 5, label: "Mechanism-Aware", description: "Механизм поломки и опорные объекты связаны явно." }
|
||||
],
|
||||
followup_context_retention_score: [
|
||||
{ score: 0, label: "Context Lost", description: "Follow-up теряет фокус текущего разбора." },
|
||||
{ score: 3, label: "Context Partial", description: "Фокус удерживается частично, с дрейфом." },
|
||||
{ score: 5, label: "Context Retained", description: "Follow-up устойчиво держит предмет и ограничения." }
|
||||
]
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ensureDir = ensureDir;
|
||||
exports.readJsonFile = readJsonFile;
|
||||
exports.writeJsonFile = writeJsonFile;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
function ensureDir(path) {
|
||||
if (!fs_1.default.existsSync(path)) {
|
||||
fs_1.default.mkdirSync(path, { recursive: true });
|
||||
}
|
||||
}
|
||||
function readJsonFile(path, fallback) {
|
||||
try {
|
||||
const raw = fs_1.default.readFileSync(path, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
function writeJsonFile(path, value) {
|
||||
fs_1.default.writeFileSync(path, JSON.stringify(value, null, 2), "utf-8");
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ApiError = void 0;
|
||||
exports.ok = ok;
|
||||
exports.created = created;
|
||||
exports.errorMiddleware = errorMiddleware;
|
||||
class ApiError extends Error {
|
||||
code;
|
||||
status;
|
||||
details;
|
||||
constructor(code, message, status = 400, details) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
exports.ApiError = ApiError;
|
||||
function ok(res, payload) {
|
||||
return res.status(200).json(payload);
|
||||
}
|
||||
function created(res, payload) {
|
||||
return res.status(201).json(payload);
|
||||
}
|
||||
function errorMiddleware(err, _req, res, _next) {
|
||||
if (err instanceof ApiError) {
|
||||
res.status(err.status).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: err.details ?? null
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fallback = err instanceof Error ? err.message : "Unknown error";
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: fallback,
|
||||
details: null
|
||||
}
|
||||
});
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.logJson = logJson;
|
||||
const REDACT_KEYS = new Set(["apiKey", "authorization", "Authorization", "openai_api_key", "OPENAI_API_KEY"]);
|
||||
function redactObject(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(redactObject);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const source = value;
|
||||
const out = {};
|
||||
for (const [key, field] of Object.entries(source)) {
|
||||
if (REDACT_KEYS.has(key)) {
|
||||
out[key] = "***REDACTED***";
|
||||
}
|
||||
else {
|
||||
out[key] = redactObject(field);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function logJson(entry) {
|
||||
const safe = {
|
||||
...entry,
|
||||
details: redactObject(entry.details)
|
||||
};
|
||||
// Structured JSON logs for diagnostics/trace aggregation.
|
||||
process.stdout.write(JSON.stringify(safe) + "\n");
|
||||
}
|
||||
Generated
+3030
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "llm-normalizer-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "NDC Accounting Agent - LLM normalizer backend proxy",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.21.2",
|
||||
"llm-normalizer-workspace": "file:..",
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"supertest": "^7.1.4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import path from "path";
|
||||
|
||||
export const BACKEND_ROOT = path.resolve(__dirname, "..");
|
||||
export const MODULE_ROOT = path.resolve(BACKEND_ROOT, "..");
|
||||
|
||||
function toBooleanFlag(value: string | undefined, defaultValue: boolean): boolean {
|
||||
if (!value || value.trim() === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
const lowered = value.trim().toLowerCase();
|
||||
return !(lowered === "0" || lowered === "false" || lowered === "off" || lowered === "no");
|
||||
}
|
||||
|
||||
export const PORT = Number(process.env.PORT ?? 8787);
|
||||
export const TIMEZONE = process.env.TZ_FALLBACK ?? "Europe/Moscow";
|
||||
export const DEFAULT_OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
|
||||
export const DEFAULT_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini";
|
||||
export const DEFAULT_TEMPERATURE = Number(process.env.OPENAI_TEMPERATURE ?? 0);
|
||||
export const DEFAULT_MAX_OUTPUT_TOKENS = Number(process.env.OPENAI_MAX_OUTPUT_TOKENS ?? 700);
|
||||
export const DEFAULT_PROMPT_VERSION = process.env.DEFAULT_PROMPT_VERSION ?? "normalizer_v2_0_2";
|
||||
export const FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_CONTRACTS_V11 = toBooleanFlag(process.env.FEATURE_ASSISTANT_CONTRACTS_V11, true);
|
||||
export const FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_BROAD_GUARD_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_BROAD_GUARD_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ANSWER_POLICY_V11 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ANSWER_POLICY_V11,
|
||||
false
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1,
|
||||
true
|
||||
);
|
||||
|
||||
export const DATA_DIR = process.env.DATA_DIR ?? path.resolve(MODULE_ROOT, "data");
|
||||
export const TRACES_DIR = path.resolve(DATA_DIR, "traces");
|
||||
export const PRESETS_DIR = path.resolve(DATA_DIR, "presets");
|
||||
export const EVAL_CASES_DIR = path.resolve(DATA_DIR, "eval_cases");
|
||||
export const ASSISTANT_SESSIONS_DIR = path.resolve(DATA_DIR, "assistant_sessions");
|
||||
|
||||
export const PROMPTS_DIR = path.resolve(MODULE_ROOT, "prompts");
|
||||
export const REPORTS_DIR = path.resolve(MODULE_ROOT, "reports");
|
||||
export const EVAL_DATASETS_DIR = path.resolve(MODULE_ROOT, "eval_cases");
|
||||
export const SCHEMAS_DIR = path.resolve(BACKEND_ROOT, "src", "schemas");
|
||||
export const ARCH_EXPORT_2020_DIR = path.resolve(MODULE_ROOT, "..", "docs", "ARCH", "2020экспорт");
|
||||
@@ -0,0 +1,23 @@
|
||||
Классификация intent_class:
|
||||
- heavy_analytical: общий агрегированный риск-срез, рейтинг, приоритизация.
|
||||
- cross_entity: связки между документами/проводками/оплатами/договорами/контрагентами.
|
||||
- drilldown_explain: точечное объяснение причин по объекту или малому набору объектов.
|
||||
- rule_based_account_control: контрольные правила по счетам (ОС, 97, 10 и т.п.).
|
||||
- anomaly_probe: поиск нетипичных паттернов.
|
||||
- period_close_risk: фокус на предзакрытии периода.
|
||||
- ambiguous_human_query: широкая человеческая формулировка без точного scope.
|
||||
- simple_factual: простой факт без сложной аналитики.
|
||||
|
||||
Правила route_hint:
|
||||
- live_mcp_drilldown: если точечный object trace.
|
||||
- hybrid_store_plus_live: если cross_entity + causal explain.
|
||||
- batch_refresh_then_store: если full-period heavy aggregate/ranking без готовой агрегации.
|
||||
- store_feature_risk: если тренд/аномалии/контроли, когда точечный runtime не обязателен.
|
||||
- store_canonical: простые факты и легкие запросы при достаточном контексте.
|
||||
|
||||
Правила requires:
|
||||
- needs_cross_entity_join=true для связок между разными сущностями.
|
||||
- needs_causal_chain=true для формулировок "почему", "чем подтверждается", "разложи цепочку".
|
||||
- needs_exact_object_trace=true для конкретного документа/проводки/строки/номера/ref.
|
||||
- needs_period_cut=true если вопрос про конец периода или периодную сверку.
|
||||
- needs_evidence=true если требуется подтверждение документами/движениями/проводками.
|
||||
@@ -0,0 +1,11 @@
|
||||
Домен бухгалтерии:
|
||||
- ключевые счета: 01, 02, 10, 41, 51, 60, 62, 68.02, 90, 97;
|
||||
- сущности: контрагент, договор, реализация, поступление, оплата, проводка, регистр;
|
||||
- типовые паттерны: "не бьется", "хвост", "акт сверки", "закрывающие", "реализация без оплаты";
|
||||
- товарные аномалии: "продажа раньше прихода", "подозрительный остаток";
|
||||
- ОС: "амортизационная группа", "срок амортизации", "карточка ОС";
|
||||
- банк: "выписка", "движение по 51", "разрыв цепочки документ-проводка";
|
||||
- периодная аналитика: предзакрытие, риск-срез, приоритизация ручных проверок.
|
||||
|
||||
Если присутствуют одновременно риск-слова и document/payment/posting chain,
|
||||
не понижать сценарий до чистого risk-route автоматически.
|
||||
@@ -0,0 +1,36 @@
|
||||
Q: По каким покупателям у нас отгрузки без оплаты на конец июня, свяжи с реализациями, договорами и проводками.
|
||||
Expected:
|
||||
{
|
||||
"intent_class": "cross_entity",
|
||||
"requires": {
|
||||
"needs_cross_entity_join": true,
|
||||
"needs_causal_chain": true,
|
||||
"needs_exact_object_trace": false
|
||||
},
|
||||
"expected_output_shape": "reconciliation_report",
|
||||
"route_hint": "hybrid_store_plus_live"
|
||||
}
|
||||
|
||||
Q: Сделай рейтинг самых рисковых счетов перед закрытием июня.
|
||||
Expected:
|
||||
{
|
||||
"intent_class": "heavy_analytical",
|
||||
"requires": {
|
||||
"needs_ranking": true,
|
||||
"needs_period_cut": true
|
||||
},
|
||||
"expected_output_shape": "ranked_list",
|
||||
"route_hint": "batch_refresh_then_store"
|
||||
}
|
||||
|
||||
Q: Покажи документ №123 и проводку по нему, нужна точная строка.
|
||||
Expected:
|
||||
{
|
||||
"intent_class": "drilldown_explain",
|
||||
"requires": {
|
||||
"needs_exact_object_trace": true,
|
||||
"needs_runtime_truth": true
|
||||
},
|
||||
"expected_output_shape": "evidence_chain",
|
||||
"route_hint": "live_mcp_drilldown"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
Ты semantic-normalizer для бухгалтерского ассистента NDC.
|
||||
Твоя роль: только нормализация запроса пользователя в строгий JSON-контракт.
|
||||
|
||||
Жесткие правила:
|
||||
1) Не давай бухгалтерский ответ по сути вопроса.
|
||||
2) Возвращай только JSON без markdown и пояснений.
|
||||
3) JSON обязан соответствовать переданной schema normalized_query_v1.
|
||||
4) Если период не указан, не выдумывай его; отмечай ambiguity.
|
||||
5) Для цепочек документов/проводок/оплат поднимай causal и cross-entity признаки.
|
||||
6) Для точечного object trace (номер/строка/ref) поднимай needs_exact_object_trace=true.
|
||||
7) Используй терминологию NDC.
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Router } from "express";
|
||||
import type { AppServices } from "../serverContext";
|
||||
import { ApiError, created, ok } from "../utils/http";
|
||||
|
||||
const PREFIX = "/api/accounting-agent/v1";
|
||||
|
||||
export function buildAccountingAgentRouter(services: AppServices): Router {
|
||||
const router = Router();
|
||||
const runtime = services.runtimeAdapter;
|
||||
|
||||
router.post(`${PREFIX}/runs/start`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const run = runtime.startRun({
|
||||
sessionId: body.sessionId ? String(body.sessionId) : undefined,
|
||||
initiator: body.initiator ? String(body.initiator) : "operator",
|
||||
source: body.source ? String(body.source) : "gui",
|
||||
metadata: (body.metadata ?? {}) as Record<string, unknown>,
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
created(res, {
|
||||
ok: true,
|
||||
sessionId: run.sessionId,
|
||||
runId: run.runId,
|
||||
status: run.status,
|
||||
run
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(`${PREFIX}/runs/finish`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const status = String(body.status ?? "DONE") as "DONE" | "ERROR" | "CANCELLED";
|
||||
if (!["DONE", "ERROR", "CANCELLED"].includes(status)) {
|
||||
throw new ApiError("INVALID_STATUS", `Invalid finish status: ${status}`, 400);
|
||||
}
|
||||
const run = runtime.finishRun({
|
||||
runId: String(body.runId ?? ""),
|
||||
status,
|
||||
source: body.source ? String(body.source) : "gui",
|
||||
reason: body.reason ? String(body.reason) : undefined,
|
||||
metadata: (body.metadata ?? {}) as Record<string, unknown>,
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
ok(res, {
|
||||
ok: true,
|
||||
run
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get(`${PREFIX}/runs`, (_req, res) => {
|
||||
ok(res, {
|
||||
ok: true,
|
||||
items: runtime.listRuns()
|
||||
});
|
||||
});
|
||||
|
||||
router.get(`${PREFIX}/runs/:runId`, (req, res, next) => {
|
||||
try {
|
||||
const run = runtime.getRun(String(req.params.runId));
|
||||
if (!run) {
|
||||
throw new ApiError("RUN_NOT_FOUND", `Run not found: ${req.params.runId}`, 404);
|
||||
}
|
||||
ok(res, {
|
||||
ok: true,
|
||||
run
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(`${PREFIX}/tasks/enqueue`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const task = runtime.enqueueTask({
|
||||
runId: String(body.runId ?? ""),
|
||||
payload: (body.payload ?? {}) as Record<string, unknown>,
|
||||
source: body.source ? String(body.source) : "gui",
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
created(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(`${PREFIX}/tasks/claim`, (_req, res) => {
|
||||
const task = runtime.claimTask();
|
||||
ok(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
});
|
||||
|
||||
router.post(`${PREFIX}/tasks/:taskId/complete`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const task = runtime.completeTask({
|
||||
taskId: String(req.params.taskId),
|
||||
result: (body.result ?? {}) as Record<string, unknown>,
|
||||
source: body.source ? String(body.source) : "worker",
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
ok(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(`${PREFIX}/tasks/:taskId/error`, (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const task = runtime.failTask({
|
||||
taskId: String(req.params.taskId),
|
||||
error: {
|
||||
code: String(body.code ?? "TASK_ERROR"),
|
||||
message: String(body.message ?? "Task failed"),
|
||||
details: body.details
|
||||
},
|
||||
source: body.source ? String(body.source) : "worker",
|
||||
idempotencyKey: body.idempotencyKey ? String(body.idempotencyKey) : undefined
|
||||
});
|
||||
ok(res, {
|
||||
ok: true,
|
||||
task
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get(`${PREFIX}/results`, (_req, res) => {
|
||||
ok(res, {
|
||||
ok: true,
|
||||
items: runtime.getResults()
|
||||
});
|
||||
});
|
||||
|
||||
router.get(`${PREFIX}/trace/run/:runId`, (req, res) => {
|
||||
ok(res, {
|
||||
ok: true,
|
||||
items: runtime.getRunTrace(String(req.params.runId))
|
||||
});
|
||||
});
|
||||
|
||||
router.get(`${PREFIX}/health`, (_req, res) => {
|
||||
ok(res, runtime.health());
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Router } from "express";
|
||||
import type { AppServices } from "../serverContext";
|
||||
import type { AssistantMessageRequestPayload } from "../types/assistant";
|
||||
import { ApiError, ok } from "../utils/http";
|
||||
|
||||
export function buildAssistantRouter(services: AppServices): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/assistant/message", async (req, res, next) => {
|
||||
try {
|
||||
const payload = (req.body ?? {}) as Partial<AssistantMessageRequestPayload>;
|
||||
const userMessageSource =
|
||||
typeof payload.user_message === "string"
|
||||
? payload.user_message
|
||||
: typeof payload.message === "string"
|
||||
? payload.message
|
||||
: "";
|
||||
const userMessage = userMessageSource.trim();
|
||||
if (!userMessage) {
|
||||
throw new ApiError("INVALID_ASSISTANT_MESSAGE", "Field `user_message` or `message` is required.", 400);
|
||||
}
|
||||
|
||||
const response = await services.assistantService.handleMessage({
|
||||
...payload,
|
||||
user_message: userMessage,
|
||||
message: userMessage,
|
||||
mode: typeof payload.mode === "string" ? payload.mode : "assistant"
|
||||
});
|
||||
ok(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/assistant/session/:session_id", (req, res, next) => {
|
||||
try {
|
||||
const sessionId = String(req.params.session_id ?? "");
|
||||
const session = services.assistantService.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
|
||||
}
|
||||
ok(res, {
|
||||
ok: true,
|
||||
session
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Router } from "express";
|
||||
import type { AppServices } from "../serverContext";
|
||||
import { ok } from "../utils/http";
|
||||
import type { EvalRunMode, NormalizeRequestPayload } from "../types/normalizer";
|
||||
import type { EvalTarget } from "../types/assistantEval";
|
||||
|
||||
export function buildEvalRouter(services: AppServices): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/eval/run", async (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const report = await services.evalService.run({
|
||||
normalizeConfig: (body.normalizeConfig ?? {}) as Omit<NormalizeRequestPayload, "userQuestion" | "context">,
|
||||
caseIds: Array.isArray(body.caseIds) ? (body.caseIds as string[]) : undefined,
|
||||
useMock: Boolean(body.useMock),
|
||||
mode: (body.mode as EvalRunMode | undefined) ?? "standard",
|
||||
caseSetFile: typeof body.caseSetFile === "string" ? body.caseSetFile : undefined,
|
||||
rawQuestions: typeof body.rawQuestions === "string" ? body.rawQuestions : undefined,
|
||||
evalTarget: (body.eval_target as EvalTarget | undefined) ?? "normalizer",
|
||||
compareWithReportFile:
|
||||
typeof body.compare_with_report_file === "string"
|
||||
? body.compare_with_report_file
|
||||
: typeof body.comparisonBaselineReportFile === "string"
|
||||
? body.comparisonBaselineReportFile
|
||||
: undefined
|
||||
});
|
||||
ok(res, {
|
||||
ok: true,
|
||||
report
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Router } from "express";
|
||||
import { getTrace, listTraces } from "../services/traceLogger";
|
||||
import { ApiError, ok } from "../utils/http";
|
||||
|
||||
export function buildHistoryRouter(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get("/api/history", (_req, res) => {
|
||||
ok(res, {
|
||||
ok: true,
|
||||
items: listTraces(200)
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/api/history/:trace_id", (req, res, next) => {
|
||||
try {
|
||||
const traceId = String(req.params.trace_id);
|
||||
const trace = getTrace(traceId);
|
||||
if (!trace) {
|
||||
throw new ApiError("TRACE_NOT_FOUND", `Trace not found: ${traceId}`, 404);
|
||||
}
|
||||
ok(res, {
|
||||
ok: true,
|
||||
trace
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Router } from "express";
|
||||
import { ok } from "../utils/http";
|
||||
import type { AppServices } from "../serverContext";
|
||||
import type { NormalizeRequestPayload } from "../types/normalizer";
|
||||
|
||||
export function buildNormalizeRouter(services: AppServices): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/normalize", async (req, res, next) => {
|
||||
try {
|
||||
const payload = req.body as NormalizeRequestPayload;
|
||||
const result = await services.normalizerService.normalize(payload);
|
||||
ok(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Router } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import { DEFAULT_PROMPT_VERSION } from "../config";
|
||||
import { listBuiltinPromptPresets } from "../services/promptBuilder";
|
||||
import { listPresets, savePreset } from "../services/traceLogger";
|
||||
import type { PromptPreset } from "../types/preset";
|
||||
import { created, ok } from "../utils/http";
|
||||
|
||||
export function buildPresetsRouter(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get("/api/presets", (_req, res) => {
|
||||
const stored = listPresets();
|
||||
const builtin = listBuiltinPromptPresets();
|
||||
const combined = [...builtin, ...stored];
|
||||
ok(res, {
|
||||
ok: true,
|
||||
default_prompt_version: DEFAULT_PROMPT_VERSION,
|
||||
presets: combined
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/api/presets/save", (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Partial<PromptPreset>;
|
||||
const now = new Date().toISOString();
|
||||
const preset: PromptPreset = {
|
||||
id: body.id ?? `preset-${nanoid(8)}`,
|
||||
name: body.name ?? "Пользовательский пресет",
|
||||
createdAt: body.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
prompt_version: body.prompt_version ?? DEFAULT_PROMPT_VERSION,
|
||||
systemPrompt: body.systemPrompt ?? "",
|
||||
developerPrompt: body.developerPrompt ?? "",
|
||||
domainPrompt: body.domainPrompt ?? "",
|
||||
schemaNotes: body.schemaNotes ?? "",
|
||||
fewShotExamples: body.fewShotExamples ?? ""
|
||||
};
|
||||
savePreset(preset);
|
||||
created(res, {
|
||||
ok: true,
|
||||
preset
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Router } from "express";
|
||||
import { DEFAULT_MODEL, DEFAULT_OPENAI_BASE_URL } from "../config";
|
||||
import { OpenAIResponsesClient } from "../services/openaiResponsesClient";
|
||||
import { ok } from "../utils/http";
|
||||
|
||||
export function buildTestConnectionRouter(client: OpenAIResponsesClient): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/openai/test-connection", async (req, res, next) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const result = await client.testConnection({
|
||||
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||
model: String(body.model ?? DEFAULT_MODEL),
|
||||
baseUrl: String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL)
|
||||
});
|
||||
ok(res, {
|
||||
ok: true,
|
||||
model: result.model,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { RuntimeAdapter } from "./runtimeAdapter";
|
||||
import type { RunRecord, TaskRecord, TraceEvent } from "../types/accountingAgent";
|
||||
import { ApiError } from "../utils/http";
|
||||
|
||||
export class InMemoryRuntimeAdapter implements RuntimeAdapter {
|
||||
private runs: RunRecord[] = [];
|
||||
private tasks: TaskRecord[] = [];
|
||||
private traces: TraceEvent[] = [];
|
||||
private idempotencyCache = new Map<string, unknown>();
|
||||
|
||||
private now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
private cacheKey(action: string, key?: string): string | null {
|
||||
if (!key || !key.trim()) return null;
|
||||
return `${action}:${key.trim()}`;
|
||||
}
|
||||
|
||||
private readIdempotency<T>(action: string, idempotencyKey?: string): T | null {
|
||||
const cache = this.cacheKey(action, idempotencyKey);
|
||||
if (!cache) return null;
|
||||
return (this.idempotencyCache.get(cache) as T | undefined) ?? null;
|
||||
}
|
||||
|
||||
private writeIdempotency(action: string, idempotencyKey: string | undefined, value: unknown): void {
|
||||
const cache = this.cacheKey(action, idempotencyKey);
|
||||
if (!cache) return;
|
||||
this.idempotencyCache.set(cache, value);
|
||||
}
|
||||
|
||||
private pushEvent(input: {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
taskId?: string | null;
|
||||
level?: "info" | "warn" | "error";
|
||||
eventType: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}): void {
|
||||
this.traces.push({
|
||||
timestamp: this.now(),
|
||||
level: input.level ?? "info",
|
||||
service: "llm_normalizer_backend",
|
||||
sessionId: input.sessionId,
|
||||
runId: input.runId,
|
||||
taskId: input.taskId ?? null,
|
||||
eventType: input.eventType,
|
||||
payload: input.payload
|
||||
});
|
||||
}
|
||||
|
||||
public startRun(input: {
|
||||
sessionId?: string;
|
||||
initiator?: string;
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotencyKey?: string;
|
||||
}): RunRecord {
|
||||
const cached = this.readIdempotency<RunRecord>("startRun", input.idempotencyKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const record: RunRecord = {
|
||||
sessionId: input.sessionId ?? `session_${nanoid(8)}`,
|
||||
runId: `run_${nanoid(10)}`,
|
||||
status: "RUNNING",
|
||||
initiator: input.initiator ?? "operator",
|
||||
source: input.source ?? "gui",
|
||||
createdAt: this.now(),
|
||||
updatedAt: this.now(),
|
||||
metadata: input.metadata ?? {}
|
||||
};
|
||||
this.runs.unshift(record);
|
||||
this.pushEvent({
|
||||
runId: record.runId,
|
||||
sessionId: record.sessionId,
|
||||
eventType: "RUN_STARTED",
|
||||
payload: record.metadata as Record<string, unknown>
|
||||
});
|
||||
this.writeIdempotency("startRun", input.idempotencyKey, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public finishRun(input: {
|
||||
runId: string;
|
||||
status: "DONE" | "ERROR" | "CANCELLED";
|
||||
source?: string;
|
||||
reason?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotencyKey?: string;
|
||||
}): RunRecord {
|
||||
const cached = this.readIdempotency<RunRecord>("finishRun", input.idempotencyKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const record = this.runs.find((item) => item.runId === input.runId);
|
||||
if (!record) {
|
||||
throw new ApiError("RUN_NOT_FOUND", `Run not found: ${input.runId}`, 404);
|
||||
}
|
||||
record.status = input.status;
|
||||
record.updatedAt = this.now();
|
||||
record.source = input.source ?? record.source;
|
||||
record.metadata = {
|
||||
...(record.metadata ?? {}),
|
||||
...(input.metadata ?? {}),
|
||||
reason: input.reason ?? null
|
||||
};
|
||||
this.pushEvent({
|
||||
runId: record.runId,
|
||||
sessionId: record.sessionId,
|
||||
eventType: `RUN_FINISHED_${input.status}`,
|
||||
level: input.status === "ERROR" ? "error" : "info",
|
||||
payload: { reason: input.reason ?? null }
|
||||
});
|
||||
this.writeIdempotency("finishRun", input.idempotencyKey, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public listRuns(): RunRecord[] {
|
||||
return [...this.runs];
|
||||
}
|
||||
|
||||
public getRun(runId: string): RunRecord | null {
|
||||
return this.runs.find((item) => item.runId === runId) ?? null;
|
||||
}
|
||||
|
||||
public enqueueTask(input: {
|
||||
runId: string;
|
||||
payload: Record<string, unknown>;
|
||||
source?: string;
|
||||
idempotencyKey?: string;
|
||||
}): TaskRecord {
|
||||
const cached = this.readIdempotency<TaskRecord>("enqueueTask", input.idempotencyKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const run = this.getRun(input.runId);
|
||||
if (!run) {
|
||||
throw new ApiError("RUN_NOT_FOUND", `Run not found: ${input.runId}`, 404);
|
||||
}
|
||||
|
||||
const task: TaskRecord = {
|
||||
taskId: `task_${nanoid(10)}`,
|
||||
runId: run.runId,
|
||||
status: "QUEUED",
|
||||
payload: input.payload,
|
||||
source: input.source ?? "gui",
|
||||
createdAt: this.now(),
|
||||
updatedAt: this.now()
|
||||
};
|
||||
this.tasks.unshift(task);
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_ENQUEUED",
|
||||
payload: task.payload
|
||||
});
|
||||
this.writeIdempotency("enqueueTask", input.idempotencyKey, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public claimTask(): TaskRecord | null {
|
||||
const task = this.tasks.find((item) => item.status === "QUEUED");
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
task.status = "RUNNING";
|
||||
task.updatedAt = this.now();
|
||||
const run = this.getRun(task.runId);
|
||||
if (run) {
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_CLAIMED"
|
||||
});
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
public completeTask(input: {
|
||||
taskId: string;
|
||||
result: Record<string, unknown>;
|
||||
source?: string;
|
||||
idempotencyKey?: string;
|
||||
}): TaskRecord {
|
||||
const cached = this.readIdempotency<TaskRecord>("completeTask", input.idempotencyKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const task = this.tasks.find((item) => item.taskId === input.taskId);
|
||||
if (!task) {
|
||||
throw new ApiError("TASK_NOT_FOUND", `Task not found: ${input.taskId}`, 404);
|
||||
}
|
||||
task.status = "DONE";
|
||||
task.updatedAt = this.now();
|
||||
task.result = input.result;
|
||||
task.source = input.source ?? task.source;
|
||||
|
||||
const run = this.getRun(task.runId);
|
||||
if (run) {
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_DONE",
|
||||
payload: input.result
|
||||
});
|
||||
}
|
||||
this.writeIdempotency("completeTask", input.idempotencyKey, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public failTask(input: {
|
||||
taskId: string;
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
source?: string;
|
||||
idempotencyKey?: string;
|
||||
}): TaskRecord {
|
||||
const cached = this.readIdempotency<TaskRecord>("failTask", input.idempotencyKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const task = this.tasks.find((item) => item.taskId === input.taskId);
|
||||
if (!task) {
|
||||
throw new ApiError("TASK_NOT_FOUND", `Task not found: ${input.taskId}`, 404);
|
||||
}
|
||||
task.status = "ERROR";
|
||||
task.updatedAt = this.now();
|
||||
task.error = input.error;
|
||||
task.source = input.source ?? task.source;
|
||||
|
||||
const run = this.getRun(task.runId);
|
||||
if (run) {
|
||||
this.pushEvent({
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
taskId: task.taskId,
|
||||
eventType: "TASK_ERROR",
|
||||
level: "error",
|
||||
payload: {
|
||||
errorCode: input.error.code,
|
||||
errorMessage: input.error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
this.writeIdempotency("failTask", input.idempotencyKey, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public getResults(): TaskRecord[] {
|
||||
return this.tasks.filter((item) => item.status === "DONE" || item.status === "ERROR");
|
||||
}
|
||||
|
||||
public getRunTrace(runId: string): TraceEvent[] {
|
||||
return this.traces.filter((item) => item.runId === runId);
|
||||
}
|
||||
|
||||
public health(): { ok: boolean; queueDepth: number; runsTotal: number } {
|
||||
return {
|
||||
ok: true,
|
||||
queueDepth: this.tasks.filter((item) => item.status === "QUEUED").length,
|
||||
runsTotal: this.runs.length
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RunRecord, TaskRecord, TraceEvent } from "../types/accountingAgent";
|
||||
|
||||
export interface RuntimeAdapter {
|
||||
startRun(input: {
|
||||
sessionId?: string;
|
||||
initiator?: string;
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotencyKey?: string;
|
||||
}): RunRecord;
|
||||
finishRun(input: {
|
||||
runId: string;
|
||||
status: "DONE" | "ERROR" | "CANCELLED";
|
||||
source?: string;
|
||||
reason?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotencyKey?: string;
|
||||
}): RunRecord;
|
||||
listRuns(): RunRecord[];
|
||||
getRun(runId: string): RunRecord | null;
|
||||
enqueueTask(input: {
|
||||
runId: string;
|
||||
payload: Record<string, unknown>;
|
||||
source?: string;
|
||||
idempotencyKey?: string;
|
||||
}): TaskRecord;
|
||||
claimTask(): TaskRecord | null;
|
||||
completeTask(input: {
|
||||
taskId: string;
|
||||
result: Record<string, unknown>;
|
||||
source?: string;
|
||||
idempotencyKey?: string;
|
||||
}): TaskRecord;
|
||||
failTask(input: {
|
||||
taskId: string;
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
source?: string;
|
||||
idempotencyKey?: string;
|
||||
}): TaskRecord;
|
||||
getResults(): TaskRecord[];
|
||||
getRunTrace(runId: string): TraceEvent[];
|
||||
health(): { ok: boolean; queueDepth: number; runsTotal: number };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "normalized_query_v1",
|
||||
"title": "Normalized Query V1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"user_question_raw",
|
||||
"normalized_question",
|
||||
"intent_class",
|
||||
"business_problem_type",
|
||||
"domain_entities",
|
||||
"accounts_mentioned",
|
||||
"documents_mentioned",
|
||||
"registers_mentioned",
|
||||
"period_scope",
|
||||
"requires",
|
||||
"expected_output_shape",
|
||||
"route_hint",
|
||||
"ambiguities",
|
||||
"confidence"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "normalized_query_v1"
|
||||
},
|
||||
"user_question_raw": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"normalized_question": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"intent_class": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"heavy_analytical",
|
||||
"cross_entity",
|
||||
"drilldown_explain",
|
||||
"rule_based_account_control",
|
||||
"anomaly_probe",
|
||||
"period_close_risk",
|
||||
"ambiguous_human_query",
|
||||
"simple_factual"
|
||||
]
|
||||
},
|
||||
"business_problem_type": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"domain_entities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"accounts_mentioned": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"documents_mentioned": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"registers_mentioned": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"period_scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "value", "confidence"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["explicit", "inferred", "missing"]
|
||||
},
|
||||
"value": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"needs_cross_entity_join",
|
||||
"needs_causal_chain",
|
||||
"needs_exact_object_trace",
|
||||
"needs_ranking",
|
||||
"needs_anomaly_summary",
|
||||
"needs_runtime_truth",
|
||||
"needs_period_cut",
|
||||
"needs_evidence"
|
||||
],
|
||||
"properties": {
|
||||
"needs_cross_entity_join": { "type": "boolean" },
|
||||
"needs_causal_chain": { "type": "boolean" },
|
||||
"needs_exact_object_trace": { "type": "boolean" },
|
||||
"needs_ranking": { "type": "boolean" },
|
||||
"needs_anomaly_summary": { "type": "boolean" },
|
||||
"needs_runtime_truth": { "type": "boolean" },
|
||||
"needs_period_cut": { "type": "boolean" },
|
||||
"needs_evidence": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"expected_output_shape": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ranked_list",
|
||||
"evidence_chain",
|
||||
"anomaly_summary",
|
||||
"point_answer",
|
||||
"reconciliation_report",
|
||||
"prioritized_review_list"
|
||||
]
|
||||
},
|
||||
"route_hint": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"store_canonical",
|
||||
"store_feature_risk",
|
||||
"hybrid_store_plus_live",
|
||||
"live_mcp_drilldown",
|
||||
"batch_refresh_then_store"
|
||||
]
|
||||
},
|
||||
"ambiguities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "reason", "severity"],
|
||||
"properties": {
|
||||
"field": { "type": "string" },
|
||||
"reason": { "type": "string" },
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["overall", "intent_class", "route_hint"],
|
||||
"properties": {
|
||||
"overall": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
},
|
||||
"intent_class": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
},
|
||||
"route_hint": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "normalized_query_v2",
|
||||
"title": "Normalized Query V2",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"user_message_raw",
|
||||
"message_in_scope",
|
||||
"scope_confidence",
|
||||
"contains_multiple_tasks",
|
||||
"fragments",
|
||||
"discarded_fragments",
|
||||
"global_notes"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "normalized_query_v2"
|
||||
},
|
||||
"user_message_raw": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"message_in_scope": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"scope_confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
},
|
||||
"contains_multiple_tasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fragments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"fragment_id",
|
||||
"raw_fragment_text",
|
||||
"normalized_fragment_text",
|
||||
"domain_relevance",
|
||||
"business_scope",
|
||||
"entity_hints",
|
||||
"account_hints",
|
||||
"document_hints",
|
||||
"register_hints",
|
||||
"time_scope",
|
||||
"flags",
|
||||
"candidate_labels",
|
||||
"confidence"
|
||||
],
|
||||
"properties": {
|
||||
"fragment_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"raw_fragment_text": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"normalized_fragment_text": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"domain_relevance": {
|
||||
"type": "string",
|
||||
"enum": ["in_scope", "out_of_scope", "unclear"]
|
||||
},
|
||||
"business_scope": {
|
||||
"type": "string",
|
||||
"enum": ["company_specific_accounting", "generic_accounting", "offtopic", "unclear"]
|
||||
},
|
||||
"entity_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"account_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"document_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"register_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"time_scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "value", "confidence"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["explicit", "inferred", "missing"]
|
||||
},
|
||||
"value": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"has_multi_entity_scope",
|
||||
"asks_for_chain_explanation",
|
||||
"asks_for_ranking_or_top",
|
||||
"asks_for_period_summary",
|
||||
"asks_for_rule_check",
|
||||
"asks_for_anomaly_scan",
|
||||
"asks_for_exact_object_trace",
|
||||
"asks_for_evidence",
|
||||
"mentions_period_close_context"
|
||||
],
|
||||
"properties": {
|
||||
"has_multi_entity_scope": { "type": "boolean" },
|
||||
"asks_for_chain_explanation": { "type": "boolean" },
|
||||
"asks_for_ranking_or_top": { "type": "boolean" },
|
||||
"asks_for_period_summary": { "type": "boolean" },
|
||||
"asks_for_rule_check": { "type": "boolean" },
|
||||
"asks_for_anomaly_scan": { "type": "boolean" },
|
||||
"asks_for_exact_object_trace": { "type": "boolean" },
|
||||
"asks_for_evidence": { "type": "boolean" },
|
||||
"mentions_period_close_context": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"candidate_labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"heavy_analytical",
|
||||
"cross_entity",
|
||||
"drilldown_explain",
|
||||
"rule_based_account_control",
|
||||
"anomaly_probe",
|
||||
"period_close_risk",
|
||||
"ambiguous_human_query",
|
||||
"simple_factual"
|
||||
]
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"discarded_fragments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["raw_fragment_text", "reason"],
|
||||
"properties": {
|
||||
"raw_fragment_text": { "type": "string", "minLength": 1 },
|
||||
"reason": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"global_notes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["needs_clarification", "clarification_reason"],
|
||||
"properties": {
|
||||
"needs_clarification": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"clarification_reason": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "normalized_query_v2_0_1",
|
||||
"title": "Normalized Query V2.0.1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"user_message_raw",
|
||||
"message_in_scope",
|
||||
"scope_confidence",
|
||||
"contains_multiple_tasks",
|
||||
"fragments",
|
||||
"discarded_fragments",
|
||||
"global_notes"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "normalized_query_v2_0_1"
|
||||
},
|
||||
"user_message_raw": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"message_in_scope": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"scope_confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
},
|
||||
"contains_multiple_tasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fragments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"fragment_id",
|
||||
"raw_fragment_text",
|
||||
"normalized_fragment_text",
|
||||
"domain_relevance",
|
||||
"business_scope",
|
||||
"entity_hints",
|
||||
"account_hints",
|
||||
"document_hints",
|
||||
"register_hints",
|
||||
"time_scope",
|
||||
"flags",
|
||||
"candidate_labels",
|
||||
"confidence",
|
||||
"execution_readiness",
|
||||
"clarification_reason",
|
||||
"soft_assumption_used"
|
||||
],
|
||||
"properties": {
|
||||
"fragment_id": { "type": "string", "minLength": 1 },
|
||||
"raw_fragment_text": { "type": "string", "minLength": 1 },
|
||||
"normalized_fragment_text": { "type": "string", "minLength": 1 },
|
||||
"domain_relevance": {
|
||||
"type": "string",
|
||||
"enum": ["in_scope", "out_of_scope", "unclear"]
|
||||
},
|
||||
"business_scope": {
|
||||
"type": "string",
|
||||
"enum": ["company_specific_accounting", "generic_accounting", "offtopic", "unclear"]
|
||||
},
|
||||
"entity_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"account_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"document_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"register_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"time_scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "value", "confidence"],
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["explicit", "inferred", "missing"] },
|
||||
"value": { "type": ["string", "null"] },
|
||||
"confidence": { "type": "string", "enum": ["high", "medium", "low"] }
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"has_multi_entity_scope",
|
||||
"asks_for_chain_explanation",
|
||||
"asks_for_ranking_or_top",
|
||||
"asks_for_period_summary",
|
||||
"asks_for_rule_check",
|
||||
"asks_for_anomaly_scan",
|
||||
"asks_for_exact_object_trace",
|
||||
"asks_for_evidence",
|
||||
"mentions_period_close_context"
|
||||
],
|
||||
"properties": {
|
||||
"has_multi_entity_scope": { "type": "boolean" },
|
||||
"asks_for_chain_explanation": { "type": "boolean" },
|
||||
"asks_for_ranking_or_top": { "type": "boolean" },
|
||||
"asks_for_period_summary": { "type": "boolean" },
|
||||
"asks_for_rule_check": { "type": "boolean" },
|
||||
"asks_for_anomaly_scan": { "type": "boolean" },
|
||||
"asks_for_exact_object_trace": { "type": "boolean" },
|
||||
"asks_for_evidence": { "type": "boolean" },
|
||||
"mentions_period_close_context": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"candidate_labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"heavy_analytical",
|
||||
"cross_entity",
|
||||
"drilldown_explain",
|
||||
"rule_based_account_control",
|
||||
"anomaly_probe",
|
||||
"period_close_risk",
|
||||
"ambiguous_human_query",
|
||||
"simple_factual"
|
||||
]
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
},
|
||||
"execution_readiness": {
|
||||
"type": "string",
|
||||
"enum": ["executable", "executable_with_soft_assumptions", "needs_clarification"]
|
||||
},
|
||||
"clarification_reason": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"soft_assumption_used": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["period_from_session_context", "company_scope_defaulted", "problem_scan_mode_enabled"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"discarded_fragments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["raw_fragment_text", "reason"],
|
||||
"properties": {
|
||||
"raw_fragment_text": { "type": "string", "minLength": 1 },
|
||||
"reason": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"global_notes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["needs_clarification", "clarification_reason"],
|
||||
"properties": {
|
||||
"needs_clarification": { "type": "boolean" },
|
||||
"clarification_reason": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "normalized_query_v2_0_2",
|
||||
"title": "Normalized Query V2.0.2",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"user_message_raw",
|
||||
"message_in_scope",
|
||||
"scope_confidence",
|
||||
"contains_multiple_tasks",
|
||||
"fragments",
|
||||
"discarded_fragments",
|
||||
"global_notes"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "normalized_query_v2_0_2"
|
||||
},
|
||||
"user_message_raw": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"message_in_scope": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"scope_confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
},
|
||||
"contains_multiple_tasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fragments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"fragment_id",
|
||||
"raw_fragment_text",
|
||||
"normalized_fragment_text",
|
||||
"domain_relevance",
|
||||
"business_scope",
|
||||
"entity_hints",
|
||||
"account_hints",
|
||||
"document_hints",
|
||||
"register_hints",
|
||||
"time_scope",
|
||||
"flags",
|
||||
"candidate_labels",
|
||||
"confidence",
|
||||
"execution_readiness",
|
||||
"clarification_reason",
|
||||
"soft_assumption_used",
|
||||
"route_status",
|
||||
"no_route_reason"
|
||||
],
|
||||
"properties": {
|
||||
"fragment_id": { "type": "string", "minLength": 1 },
|
||||
"raw_fragment_text": { "type": "string", "minLength": 1 },
|
||||
"normalized_fragment_text": { "type": "string", "minLength": 1 },
|
||||
"domain_relevance": {
|
||||
"type": "string",
|
||||
"enum": ["in_scope", "out_of_scope", "unclear"]
|
||||
},
|
||||
"business_scope": {
|
||||
"type": "string",
|
||||
"enum": ["company_specific_accounting", "generic_accounting", "offtopic", "unclear"]
|
||||
},
|
||||
"entity_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"account_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"document_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"register_hints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"time_scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "value", "confidence"],
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["explicit", "inferred", "missing"] },
|
||||
"value": { "type": ["string", "null"] },
|
||||
"confidence": { "type": "string", "enum": ["high", "medium", "low"] }
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"has_multi_entity_scope",
|
||||
"asks_for_chain_explanation",
|
||||
"asks_for_ranking_or_top",
|
||||
"asks_for_period_summary",
|
||||
"asks_for_rule_check",
|
||||
"asks_for_anomaly_scan",
|
||||
"asks_for_exact_object_trace",
|
||||
"asks_for_evidence",
|
||||
"mentions_period_close_context"
|
||||
],
|
||||
"properties": {
|
||||
"has_multi_entity_scope": { "type": "boolean" },
|
||||
"asks_for_chain_explanation": { "type": "boolean" },
|
||||
"asks_for_ranking_or_top": { "type": "boolean" },
|
||||
"asks_for_period_summary": { "type": "boolean" },
|
||||
"asks_for_rule_check": { "type": "boolean" },
|
||||
"asks_for_anomaly_scan": { "type": "boolean" },
|
||||
"asks_for_exact_object_trace": { "type": "boolean" },
|
||||
"asks_for_evidence": { "type": "boolean" },
|
||||
"mentions_period_close_context": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"candidate_labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"heavy_analytical",
|
||||
"cross_entity",
|
||||
"drilldown_explain",
|
||||
"rule_based_account_control",
|
||||
"anomaly_probe",
|
||||
"period_close_risk",
|
||||
"ambiguous_human_query",
|
||||
"simple_factual"
|
||||
]
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
},
|
||||
"execution_readiness": {
|
||||
"type": "string",
|
||||
"enum": ["executable", "executable_with_soft_assumptions", "needs_clarification", "no_route"]
|
||||
},
|
||||
"clarification_reason": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"soft_assumption_used": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["period_from_session_context", "company_scope_defaulted", "problem_scan_mode_enabled"]
|
||||
}
|
||||
},
|
||||
"route_status": {
|
||||
"type": "string",
|
||||
"enum": ["routed", "no_route"]
|
||||
},
|
||||
"no_route_reason": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["out_of_scope", "insufficient_specificity", "missing_mapping", "unsupported_fragment_type", null]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"discarded_fragments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["raw_fragment_text", "reason"],
|
||||
"properties": {
|
||||
"raw_fragment_text": { "type": "string", "minLength": 1 },
|
||||
"reason": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"global_notes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["needs_clarification", "clarification_reason"],
|
||||
"properties": {
|
||||
"needs_clarification": { "type": "boolean" },
|
||||
"clarification_reason": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import "dotenv/config";
|
||||
import cors from "cors";
|
||||
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 { buildEvalRouter } from "./routes/eval";
|
||||
import { buildHistoryRouter } from "./routes/history";
|
||||
import { buildNormalizeRouter } from "./routes/normalize";
|
||||
import { buildPresetsRouter } from "./routes/presets";
|
||||
import { buildTestConnectionRouter } from "./routes/testConnection";
|
||||
import { InMemoryRuntimeAdapter } from "./runtime/inMemoryRuntimeAdapter";
|
||||
import { AssistantService } from "./services/assistantService";
|
||||
import { AssistantSessionStore } from "./services/assistantSessionStore";
|
||||
import { EvalService } from "./services/evalService";
|
||||
import { NormalizerService } from "./services/normalizerService";
|
||||
import { OpenAIResponsesClient } from "./services/openaiResponsesClient";
|
||||
import type { AppServices } from "./serverContext";
|
||||
import { ensureDir } from "./utils/files";
|
||||
import { errorMiddleware, ok } from "./utils/http";
|
||||
import { logJson } from "./utils/log";
|
||||
|
||||
export function createApp(): express.Express {
|
||||
ensureDir(TRACES_DIR);
|
||||
ensureDir(PRESETS_DIR);
|
||||
ensureDir(EVAL_CASES_DIR);
|
||||
ensureDir(REPORTS_DIR);
|
||||
ensureDir(ASSISTANT_SESSIONS_DIR);
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ type: ["application/json", "application/*+json"], limit: "2mb" }));
|
||||
|
||||
const openaiClient = new OpenAIResponsesClient();
|
||||
const normalizerService = new NormalizerService(openaiClient);
|
||||
const evalService = new EvalService(normalizerService);
|
||||
const assistantSessionStore = new AssistantSessionStore();
|
||||
const assistantService = new AssistantService(normalizerService, assistantSessionStore);
|
||||
const runtimeAdapter = new InMemoryRuntimeAdapter();
|
||||
|
||||
const services: AppServices = {
|
||||
normalizerService,
|
||||
evalService,
|
||||
assistantService,
|
||||
runtimeAdapter
|
||||
};
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
ok(res, {
|
||||
ok: true,
|
||||
service: "llm-normalizer-backend",
|
||||
status: "RUNNING",
|
||||
timezone: TIMEZONE,
|
||||
now: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
app.use(buildTestConnectionRouter(openaiClient));
|
||||
app.use(buildNormalizeRouter(services));
|
||||
app.use(buildEvalRouter(services));
|
||||
app.use(buildAssistantRouter(services));
|
||||
app.use(buildHistoryRouter());
|
||||
app.use(buildPresetsRouter());
|
||||
app.use(buildAccountingAgentRouter(services));
|
||||
app.use(errorMiddleware);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const app = createApp();
|
||||
app.listen(PORT, () => {
|
||||
logJson({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
service: "llm_normalizer_backend",
|
||||
message: `Backend started on http://localhost:${PORT}`
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { EvalService } from "./services/evalService";
|
||||
import { NormalizerService } from "./services/normalizerService";
|
||||
import { AssistantService } from "./services/assistantService";
|
||||
import type { RuntimeAdapter } from "./runtime/runtimeAdapter";
|
||||
|
||||
export interface AppServices {
|
||||
normalizerService: NormalizerService;
|
||||
evalService: EvalService;
|
||||
assistantService: AssistantService;
|
||||
runtimeAdapter: RuntimeAdapter;
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
import type {
|
||||
AssistantFallbackType,
|
||||
AssistantReplyType,
|
||||
AnswerGroundingCheck,
|
||||
AssistantRequirement,
|
||||
RequirementCoverageReport,
|
||||
UnifiedRetrievalResult
|
||||
} from "../types/assistant";
|
||||
import type { RouteHintSummary } from "../types/normalizer";
|
||||
import type { AnswerStructureV11, EvidenceConfidence, EvidenceItem, EvidenceLimitationReasonCode } from "../types/stage1Contracts";
|
||||
|
||||
interface ComposeAnswerInput {
|
||||
userMessage: string;
|
||||
routeSummary: RouteHintSummary | null;
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
requirements: AssistantRequirement[];
|
||||
coverageReport: RequirementCoverageReport;
|
||||
groundingCheck: AnswerGroundingCheck;
|
||||
enableAnswerPolicyV11?: boolean;
|
||||
}
|
||||
|
||||
interface ComposeAnswerOutput {
|
||||
assistant_reply: string;
|
||||
fallback_type: AssistantFallbackType;
|
||||
reply_type: AssistantReplyType;
|
||||
answer_structure_v11?: AnswerStructureV11;
|
||||
}
|
||||
|
||||
function fallbackFromSummary(routeSummary: RouteHintSummary | null): AssistantFallbackType {
|
||||
if (!routeSummary || routeSummary.mode !== "deterministic_v2") {
|
||||
return "none";
|
||||
}
|
||||
return routeSummary.fallback.type as AssistantFallbackType;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[], limit = 6): string[] {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).slice(0, limit);
|
||||
}
|
||||
|
||||
function formatList(items: string[]): string {
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return items.map((item) => `- ${item}`).join("\n");
|
||||
}
|
||||
|
||||
function extractTopFacts(results: UnifiedRetrievalResult[]): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const result of results.filter((item) => item.status === "ok").slice(0, 3)) {
|
||||
if (result.result_type === "chain") {
|
||||
const top = result.items.slice(0, 3).map((item) => {
|
||||
const counterparty = String(item.counterparty_id ?? "не указан");
|
||||
const operations = String(item.operations_count ?? "0");
|
||||
const docs = String(item.document_refs_count ?? "0");
|
||||
return `Контрагент ${counterparty}: операций ${operations}, документов в связке ${docs}.`;
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
if (result.result_type === "ranking") {
|
||||
const top = result.items
|
||||
.slice(0, 5)
|
||||
.map((item) => `${item.rank ?? "•"}. ${String(item.entity ?? "Сущность")} — ${String(item.records_count ?? 0)}.`);
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
if (result.result_type === "list") {
|
||||
const top = result.items.slice(0, 5).map((item) => {
|
||||
if (item.risk_score !== undefined) {
|
||||
return `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}) — риск ${String(item.risk_score)}.`;
|
||||
}
|
||||
return `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`;
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
const top = result.items
|
||||
.slice(0, 3)
|
||||
.map((item) => `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`);
|
||||
lines.push(...top);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function extractWhyIncluded(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.why_included));
|
||||
}
|
||||
|
||||
function extractSelectionReasons(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.selection_reason));
|
||||
}
|
||||
|
||||
function extractRiskFactors(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.risk_factors));
|
||||
}
|
||||
|
||||
function extractBusinessInterpretation(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.business_interpretation));
|
||||
}
|
||||
|
||||
function extractLimitations(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.limitations));
|
||||
}
|
||||
|
||||
function summaryValue(result: UnifiedRetrievalResult, key: string): unknown {
|
||||
const summary = result.summary ?? {};
|
||||
return Object.prototype.hasOwnProperty.call(summary, key) ? summary[key] : undefined;
|
||||
}
|
||||
|
||||
function summaryBoolean(result: UnifiedRetrievalResult, key: string): boolean {
|
||||
return summaryValue(result, key) === true;
|
||||
}
|
||||
|
||||
function summaryString(result: UnifiedRetrievalResult, key: string): string | null {
|
||||
const value = summaryValue(result, key);
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function suggestNextStep(requirements: AssistantRequirement[], coverage: RequirementCoverageReport): string[] {
|
||||
const next: string[] = [];
|
||||
if (coverage.clarification_needed_for.length > 0) {
|
||||
next.push("Уточните период, счет, документ или контрагента для требований: " + coverage.clarification_needed_for.join(", ") + ".");
|
||||
}
|
||||
if (coverage.requirements_uncovered.length > 0) {
|
||||
next.push("Проверьте непокрытые требования: " + coverage.requirements_uncovered.join(", ") + ".");
|
||||
}
|
||||
if (coverage.out_of_scope_requirements.length > 0) {
|
||||
next.push("Часть запроса вне текущего учетного контура: " + coverage.out_of_scope_requirements.join(", ") + ".");
|
||||
}
|
||||
if (next.length === 0 && requirements.length > 0) {
|
||||
next.push("Следующим шагом можно открыть технический разбор и углубить проверку по выбранным объектам.");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
interface PolicySignals {
|
||||
broad_query_detected: boolean;
|
||||
broad_result_flag: boolean;
|
||||
minimum_evidence_failed: boolean;
|
||||
degraded_to: "partial" | "clarification" | null;
|
||||
narrowing_strength: "weak" | "medium" | "strong" | null;
|
||||
}
|
||||
|
||||
type PolicyMode =
|
||||
| "focused_grounded"
|
||||
| "broad_partial"
|
||||
| "clarification_required"
|
||||
| "out_of_scope"
|
||||
| "route_mismatch"
|
||||
| "empty"
|
||||
| "no_grounded"
|
||||
| "backend_error";
|
||||
|
||||
interface PolicyDecision {
|
||||
mode: PolicyMode;
|
||||
fallback_type: AssistantFallbackType;
|
||||
reply_type: AssistantReplyType;
|
||||
}
|
||||
|
||||
interface MissingAnchors {
|
||||
period: boolean;
|
||||
account: boolean;
|
||||
documentOrObject: boolean;
|
||||
counterparty: boolean;
|
||||
anomalyType: boolean;
|
||||
}
|
||||
|
||||
function flattenEvidence(results: UnifiedRetrievalResult[]): EvidenceItem[] {
|
||||
return results.flatMap((item) => item.evidence);
|
||||
}
|
||||
|
||||
function buildClaimEvidenceLinks(results: UnifiedRetrievalResult[]): NonNullable<AnswerStructureV11["evidence_block"]["claim_evidence_links"]> {
|
||||
const byClaim = new Map<string, string[]>();
|
||||
for (const evidence of flattenEvidence(results)) {
|
||||
const claimRef = String(evidence.claim_ref ?? "").trim();
|
||||
const evidenceId = String(evidence.evidence_id ?? "").trim();
|
||||
if (!claimRef || !evidenceId) {
|
||||
continue;
|
||||
}
|
||||
const current = byClaim.get(claimRef) ?? [];
|
||||
current.push(evidenceId);
|
||||
byClaim.set(claimRef, current);
|
||||
}
|
||||
return Array.from(byClaim.entries())
|
||||
.slice(0, 10)
|
||||
.map(([claim_ref, evidenceIds]) => ({
|
||||
claim_ref,
|
||||
evidence_ids: uniqueStrings(evidenceIds, 10)
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregatePolicySignals(results: UnifiedRetrievalResult[]): PolicySignals {
|
||||
const broad_query_detected = results.some((item) => summaryBoolean(item, "broad_query_detected"));
|
||||
const broad_result_flag = results.some((item) => summaryBoolean(item, "broad_result_flag"));
|
||||
const minimum_evidence_failed = results.some((item) => summaryBoolean(item, "minimum_evidence_failed"));
|
||||
|
||||
let degraded_to: PolicySignals["degraded_to"] = null;
|
||||
for (const result of results) {
|
||||
const degraded = summaryString(result, "degraded_to");
|
||||
if (degraded === "clarification") {
|
||||
degraded_to = "clarification";
|
||||
break;
|
||||
}
|
||||
if (degraded === "partial") {
|
||||
degraded_to = "partial";
|
||||
}
|
||||
}
|
||||
|
||||
const narrowingOrder: Record<"weak" | "medium" | "strong", number> = {
|
||||
weak: 0,
|
||||
medium: 1,
|
||||
strong: 2
|
||||
};
|
||||
let narrowing_strength: PolicySignals["narrowing_strength"] = null;
|
||||
for (const result of results) {
|
||||
const value = summaryString(result, "narrowing_strength");
|
||||
if (value !== "weak" && value !== "medium" && value !== "strong") {
|
||||
continue;
|
||||
}
|
||||
if (!narrowing_strength || narrowingOrder[value] < narrowingOrder[narrowing_strength]) {
|
||||
narrowing_strength = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
broad_query_detected,
|
||||
broad_result_flag,
|
||||
minimum_evidence_failed,
|
||||
degraded_to,
|
||||
narrowing_strength
|
||||
};
|
||||
}
|
||||
|
||||
function confidenceToScore(value: EvidenceConfidence): number {
|
||||
if (value === "high") return 3;
|
||||
if (value === "medium") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function aggregateConfidence(results: UnifiedRetrievalResult[], evidenceItems: EvidenceItem[]): EvidenceConfidence {
|
||||
const scores: number[] = [];
|
||||
for (const evidence of evidenceItems) {
|
||||
scores.push(confidenceToScore(evidence.confidence));
|
||||
}
|
||||
for (const result of results) {
|
||||
if (result.status === "error") {
|
||||
continue;
|
||||
}
|
||||
scores.push(confidenceToScore(result.confidence));
|
||||
}
|
||||
if (scores.length === 0) {
|
||||
return "low";
|
||||
}
|
||||
const average = scores.reduce((acc, item) => acc + item, 0) / scores.length;
|
||||
if (average >= 2.6) return "high";
|
||||
if (average >= 1.8) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function collectLimitationReasonCodes(evidenceItems: EvidenceItem[]): EvidenceLimitationReasonCode[] {
|
||||
const codes = evidenceItems
|
||||
.map((item) => item.limitation?.reason_code ?? null)
|
||||
.filter((item): item is EvidenceLimitationReasonCode => Boolean(item));
|
||||
return uniqueStrings(codes, 8) as EvidenceLimitationReasonCode[];
|
||||
}
|
||||
|
||||
function limitationReasonToText(code: EvidenceLimitationReasonCode): string {
|
||||
if (code === "snapshot_only") return "Evidence is snapshot-only and may lag source-of-record.";
|
||||
if (code === "heuristic_inference") return "Part of the conclusion relies on heuristic inference.";
|
||||
if (code === "missing_mechanism") return "Mechanism is unresolved for part of the evidence.";
|
||||
if (code === "weak_source_mapping") return "Source mapping is weak for part of the evidence.";
|
||||
if (code === "insufficient_detail") return "Evidence lacks detail for a strong factual claim.";
|
||||
return "Some evidence limitations remain unresolved.";
|
||||
}
|
||||
|
||||
function detectMissingAnchors(userMessage: string): MissingAnchors {
|
||||
const lower = String(userMessage ?? "").toLowerCase();
|
||||
const hasPeriod = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/.test(lower);
|
||||
const hasAccount = /(?:\bсчет\b|\baccount\b|\bschet\b|\b\d{2}(?:\.\d{2})?\b)/i.test(lower);
|
||||
const hasDocumentOrObject = /(?:документ|invoice|guid|object|obj|#\d+|\bid\b|\bref\b|dokument|doc)/i.test(lower);
|
||||
const hasCounterparty = /(?:контрагент|supplier|buyer|customer|kontragent|postavsh|pokupatel)/i.test(lower);
|
||||
const hasAnomalyType = /(?:аномал|risk|отклон|разрыв|mismatch|duplicate|tail|цепочк|anomali|hvost)/i.test(lower);
|
||||
|
||||
return {
|
||||
period: !hasPeriod,
|
||||
account: !hasAccount,
|
||||
documentOrObject: !hasDocumentOrObject,
|
||||
counterparty: !hasCounterparty,
|
||||
anomalyType: !hasAnomalyType
|
||||
};
|
||||
}
|
||||
|
||||
function buildClarificationQuestions(input: {
|
||||
mode: PolicyMode;
|
||||
missingAnchors: MissingAnchors;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
policySignals: PolicySignals;
|
||||
}): string[] {
|
||||
const questions: string[] = [];
|
||||
const shouldAsk = input.mode === "clarification_required" || input.coverageReport.clarification_needed_for.length > 0;
|
||||
if (!shouldAsk) {
|
||||
return questions;
|
||||
}
|
||||
|
||||
if (input.missingAnchors.period) {
|
||||
questions.push("Уточните период проверки (например, 2020-06).");
|
||||
}
|
||||
if (input.missingAnchors.account) {
|
||||
questions.push("Уточните счет или группу счетов (например, 19, 60, 62).");
|
||||
}
|
||||
if (input.missingAnchors.documentOrObject) {
|
||||
questions.push("Укажите документ/GUID/конкретный объект для трассировки.");
|
||||
}
|
||||
if (input.missingAnchors.counterparty) {
|
||||
questions.push("Укажите контрагента или группу контрагентов.");
|
||||
}
|
||||
if (input.policySignals.broad_query_detected && input.missingAnchors.anomalyType) {
|
||||
questions.push("Уточните тип отклонения: разрыв цепочки, неверный документ или аномальный риск.");
|
||||
}
|
||||
if (input.coverageReport.clarification_needed_for.length > 0) {
|
||||
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
|
||||
}
|
||||
|
||||
return uniqueStrings(questions, 6);
|
||||
}
|
||||
|
||||
function buildRecommendedActions(input: {
|
||||
mode: PolicyMode;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
policySignals: PolicySignals;
|
||||
limitationReasonCodes: EvidenceLimitationReasonCode[];
|
||||
sourceRefs: string[];
|
||||
}): string[] {
|
||||
const actions: string[] = [];
|
||||
if (input.mode === "focused_grounded") {
|
||||
actions.push("Проверьте 1-2 ключевые записи по source_ref и зафиксируйте итог в рабочем файле проверки.");
|
||||
}
|
||||
if (input.mode === "broad_partial") {
|
||||
actions.push("Сузьте запрос до периода + счета или периода + документа и повторите проверку.");
|
||||
}
|
||||
if (input.mode === "clarification_required") {
|
||||
actions.push("Дайте недостающие якоря (период/счет/объект), иначе сильный factual вывод невозможен.");
|
||||
}
|
||||
if (input.coverageReport.requirements_uncovered.length > 0) {
|
||||
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
|
||||
}
|
||||
if (input.coverageReport.requirements_partially_covered.length > 0) {
|
||||
actions.push(`Доуточните частично покрытые требования: ${input.coverageReport.requirements_partially_covered.join(", ")}.`);
|
||||
}
|
||||
if (input.policySignals.broad_query_detected && input.policySignals.narrowing_strength !== "strong") {
|
||||
actions.push("Добавьте более узкий контекст: тип отклонения, группу документов и бизнес-участок.");
|
||||
}
|
||||
if (input.limitationReasonCodes.includes("snapshot_only")) {
|
||||
actions.push("Сверьте критичные выводы с live source-of-record в 1C.");
|
||||
}
|
||||
if (input.limitationReasonCodes.includes("weak_source_mapping")) {
|
||||
actions.push("Проверьте source mapping для связей document/register по указанным ref.");
|
||||
}
|
||||
if (input.sourceRefs.length > 0) {
|
||||
actions.push(`Начните проверку с source_ref: ${input.sourceRefs.slice(0, 2).join(", ")}.`);
|
||||
}
|
||||
|
||||
return uniqueStrings(actions, 6);
|
||||
}
|
||||
|
||||
function firstMeaningfulFact(results: UnifiedRetrievalResult[]): string | null {
|
||||
const facts = extractTopFacts(results);
|
||||
return facts.length > 0 ? facts[0] : null;
|
||||
}
|
||||
|
||||
function buildPolicyDecision(input: {
|
||||
fallbackType: AssistantFallbackType;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
groundingCheck: AnswerGroundingCheck;
|
||||
okResults: UnifiedRetrievalResult[];
|
||||
partialResults: UnifiedRetrievalResult[];
|
||||
emptyResults: UnifiedRetrievalResult[];
|
||||
errorResults: UnifiedRetrievalResult[];
|
||||
hasSupport: boolean;
|
||||
focusedStrong: boolean;
|
||||
policySignals: PolicySignals;
|
||||
}): PolicyDecision {
|
||||
const hasCoverageGaps =
|
||||
input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0;
|
||||
|
||||
if (input.fallbackType === "out_of_scope" && input.coverageReport.requirements_covered === 0) {
|
||||
return {
|
||||
mode: "out_of_scope",
|
||||
fallback_type: "out_of_scope",
|
||||
reply_type: "out_of_scope"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "route_mismatch_blocked") {
|
||||
return {
|
||||
mode: "route_mismatch",
|
||||
fallback_type: "partial",
|
||||
reply_type: "route_mismatch_blocked"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(input.policySignals.degraded_to === "clarification" && input.policySignals.minimum_evidence_failed) ||
|
||||
(input.fallbackType === "clarification" && !input.hasSupport) ||
|
||||
(input.groundingCheck.status === "no_grounded_answer" && !input.hasSupport)
|
||||
) {
|
||||
return {
|
||||
mode: "clarification_required",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.errorResults.length > 0 && input.okResults.length === 0 && input.partialResults.length === 0) {
|
||||
return {
|
||||
mode: "backend_error",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.okResults.length === 0 && input.partialResults.length === 0 && input.emptyResults.length > 0) {
|
||||
return {
|
||||
mode: "empty",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "empty_but_valid"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "no_grounded_answer" && input.okResults.length === 0 && input.partialResults.length === 0) {
|
||||
return {
|
||||
mode: "no_grounded",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "no_grounded_answer"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.focusedStrong &&
|
||||
!input.policySignals.broad_query_detected &&
|
||||
!input.policySignals.minimum_evidence_failed &&
|
||||
!hasCoverageGaps
|
||||
) {
|
||||
return {
|
||||
mode: "focused_grounded",
|
||||
fallback_type: "none",
|
||||
reply_type: "factual_with_explanation"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.okResults.length > 0 ||
|
||||
input.partialResults.length > 0 ||
|
||||
hasCoverageGaps ||
|
||||
input.policySignals.minimum_evidence_failed ||
|
||||
input.policySignals.broad_result_flag ||
|
||||
input.groundingCheck.status === "partial"
|
||||
) {
|
||||
return {
|
||||
mode: "broad_partial",
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "backend_error",
|
||||
fallback_type: "unknown",
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnswerSummary(mode: PolicyMode): string {
|
||||
if (mode === "focused_grounded") return "Сформирован прямой ответ на основе подтвержденной опоры.";
|
||||
if (mode === "broad_partial") return "Вывод ограничен: есть частичная опора, но не полный coverage.";
|
||||
if (mode === "clarification_required") return "Нужны уточнения: без сужения strong factual вывод ненадежен.";
|
||||
if (mode === "out_of_scope") return "Запрос вне доступного учетного контура.";
|
||||
if (mode === "route_mismatch") return "Результат маршрута не совпал с предметом вопроса.";
|
||||
if (mode === "empty") return "В текущем срезе данных релевантные записи не обнаружены.";
|
||||
if (mode === "no_grounded") return "Недостаточно опоры для обоснованного ответа.";
|
||||
return "Не удалось собрать обоснованный ответ по текущему запросу.";
|
||||
}
|
||||
|
||||
function buildDirectAnswer(input: {
|
||||
mode: PolicyMode;
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
policySignals: PolicySignals;
|
||||
}): string {
|
||||
const topFact = firstMeaningfulFact(input.retrievalResults);
|
||||
if (input.mode === "focused_grounded") {
|
||||
return topFact ?? "Подтвержденный результат получен; можно продолжать предметную проверку без деградации.";
|
||||
}
|
||||
if (input.mode === "broad_partial") {
|
||||
if (topFact) {
|
||||
return `Доступен ограниченный подтвержденный фрагмент: ${topFact}`;
|
||||
}
|
||||
return "Есть только ограниченная опора; вывод дан в частичном режиме без ложной точности.";
|
||||
}
|
||||
if (input.mode === "clarification_required") {
|
||||
return "Текущий запрос слишком широкий или недоопределен; надежный factual вывод пока невозможен.";
|
||||
}
|
||||
if (input.mode === "out_of_scope") {
|
||||
return "Могу отвечать только в пределах данных доступного учетного контура.";
|
||||
}
|
||||
if (input.mode === "route_mismatch") {
|
||||
return "Предмет результата не совпал с предметом вопроса; требуется уточнение фокуса.";
|
||||
}
|
||||
if (input.mode === "empty") {
|
||||
return "В текущем срезе данных проблемные записи по заданному условию не найдены.";
|
||||
}
|
||||
if (input.mode === "no_grounded") {
|
||||
return "Недостаточно подтвержденной опоры для ответа в требуемой точности.";
|
||||
}
|
||||
if (input.policySignals.minimum_evidence_failed) {
|
||||
return "Маршрут отработал, но минимальная evidence-опора не пройдена.";
|
||||
}
|
||||
return "Не удалось сформировать обоснованный ответ; нужно уточнение запроса.";
|
||||
}
|
||||
|
||||
function renderPolicyReply(structure: AnswerStructureV11): string {
|
||||
const mechanismLines: string[] = [`status=${structure.mechanism_block.status}`];
|
||||
if (structure.mechanism_block.mechanism_notes.length > 0) {
|
||||
mechanismLines.push(...structure.mechanism_block.mechanism_notes.map((item) => `note: ${item}`));
|
||||
}
|
||||
if (structure.mechanism_block.limitation_reason_codes.length > 0) {
|
||||
mechanismLines.push(`limitation_codes: ${structure.mechanism_block.limitation_reason_codes.join(", ")}`);
|
||||
}
|
||||
if (structure.mechanism_block.status === "unresolved" && structure.mechanism_block.mechanism_notes.length === 0) {
|
||||
mechanismLines.push("mechanism_note is intentionally omitted due to weak or missing mechanism evidence");
|
||||
}
|
||||
|
||||
const evidenceLines: string[] = [
|
||||
`coverage=${structure.evidence_block.coverage_note}`,
|
||||
`evidence_ids=${structure.evidence_block.evidence_ids.length > 0 ? structure.evidence_block.evidence_ids.join(", ") : "none"}`
|
||||
];
|
||||
if (Array.isArray(structure.evidence_block.source_refs) && structure.evidence_block.source_refs.length > 0) {
|
||||
evidenceLines.push(`source_refs=${structure.evidence_block.source_refs.join(", ")}`);
|
||||
}
|
||||
if (Array.isArray(structure.evidence_block.claim_evidence_links) && structure.evidence_block.claim_evidence_links.length > 0) {
|
||||
const compactLinks = structure.evidence_block.claim_evidence_links
|
||||
.slice(0, 4)
|
||||
.map((item) => `${item.claim_ref}:${item.evidence_ids.join("|")}`);
|
||||
evidenceLines.push(`claim_evidence_links=${compactLinks.join("; ")}`);
|
||||
}
|
||||
|
||||
const uncertaintyLines = [
|
||||
...structure.uncertainty_block.open_uncertainties.map((item) => `open: ${item}`),
|
||||
...structure.uncertainty_block.limitations.map((item) => `limit: ${item}`)
|
||||
];
|
||||
if (uncertaintyLines.length === 0) {
|
||||
uncertaintyLines.push("No material uncertainty detected in current scoped answer.");
|
||||
}
|
||||
|
||||
const nextStepLines = [
|
||||
...structure.next_step_block.recommended_actions.map((item) => `action: ${item}`),
|
||||
...structure.next_step_block.clarification_questions.map((item) => `clarify: ${item}`)
|
||||
];
|
||||
if (nextStepLines.length === 0) {
|
||||
nextStepLines.push("No additional action is required for this scoped answer.");
|
||||
}
|
||||
|
||||
return [
|
||||
`Answer summary: ${structure.answer_summary}`,
|
||||
`Direct answer:\n${structure.direct_answer}`,
|
||||
`Mechanism block:\n${formatList(mechanismLines)}`,
|
||||
`Evidence block:\n${formatList(evidenceLines)}`,
|
||||
`Uncertainty block:\n${formatList(uncertaintyLines)}`,
|
||||
`Next step block:\n${formatList(nextStepLines)}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput {
|
||||
const fallbackType = fallbackFromSummary(input.routeSummary);
|
||||
const okResults = input.retrievalResults.filter((item) => item.status === "ok");
|
||||
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
|
||||
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
|
||||
const errorResults = input.retrievalResults.filter((item) => item.status === "error");
|
||||
const evidenceItems = flattenEvidence(input.retrievalResults);
|
||||
const policySignals = aggregatePolicySignals(input.retrievalResults);
|
||||
const limitationReasonCodes = collectLimitationReasonCodes(evidenceItems);
|
||||
const sourceRefs = uniqueStrings(
|
||||
evidenceItems
|
||||
.map((item) => item.source_ref?.canonical_ref)
|
||||
.filter((item): item is string => typeof item === "string" && item.trim().length > 0),
|
||||
8
|
||||
);
|
||||
const mechanismNotes = uniqueStrings(
|
||||
evidenceItems
|
||||
.map((item) => item.mechanism_note)
|
||||
.filter((item): item is string => typeof item === "string" && item.trim().length > 0),
|
||||
6
|
||||
);
|
||||
const claimEvidenceLinks = buildClaimEvidenceLinks(input.retrievalResults);
|
||||
const aggregateEvidenceConfidence = aggregateConfidence(input.retrievalResults, evidenceItems);
|
||||
const hasSupport =
|
||||
okResults.length > 0 ||
|
||||
partialResults.length > 0 ||
|
||||
evidenceItems.length > 0 ||
|
||||
input.retrievalResults.some((item) => item.items.length > 0);
|
||||
const hasCoverageGaps =
|
||||
input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0;
|
||||
const hasCriticalEvidenceLimitation =
|
||||
limitationReasonCodes.includes("weak_source_mapping") ||
|
||||
limitationReasonCodes.includes("insufficient_detail");
|
||||
const hasNonLowRouteConfidence = input.retrievalResults.some(
|
||||
(item) => item.status === "ok" && item.confidence !== "low"
|
||||
);
|
||||
const focusedStrong =
|
||||
okResults.length > 0 &&
|
||||
input.groundingCheck.status === "grounded" &&
|
||||
!hasCoverageGaps &&
|
||||
!policySignals.broad_query_detected &&
|
||||
!policySignals.broad_result_flag &&
|
||||
!policySignals.minimum_evidence_failed &&
|
||||
!hasCriticalEvidenceLimitation &&
|
||||
(aggregateEvidenceConfidence !== "low" || hasNonLowRouteConfidence);
|
||||
|
||||
const decision = buildPolicyDecision({
|
||||
fallbackType,
|
||||
coverageReport: input.coverageReport,
|
||||
groundingCheck: input.groundingCheck,
|
||||
okResults,
|
||||
partialResults,
|
||||
emptyResults,
|
||||
errorResults,
|
||||
hasSupport,
|
||||
focusedStrong,
|
||||
policySignals
|
||||
});
|
||||
|
||||
const missingAnchors = detectMissingAnchors(input.userMessage);
|
||||
const clarificationQuestions = buildClarificationQuestions({
|
||||
mode: decision.mode,
|
||||
missingAnchors,
|
||||
coverageReport: input.coverageReport,
|
||||
policySignals
|
||||
});
|
||||
const recommendedActions = buildRecommendedActions({
|
||||
mode: decision.mode,
|
||||
coverageReport: input.coverageReport,
|
||||
policySignals,
|
||||
limitationReasonCodes,
|
||||
sourceRefs
|
||||
});
|
||||
|
||||
const limitations = uniqueStrings(
|
||||
[
|
||||
...limitationReasonCodes.map((code) => limitationReasonToText(code)),
|
||||
...extractLimitations(input.retrievalResults),
|
||||
...input.groundingCheck.reasons,
|
||||
...(policySignals.minimum_evidence_failed ? ["Minimum evidence gate failed for current scope."] : []),
|
||||
...(policySignals.broad_query_detected && policySignals.narrowing_strength === "weak"
|
||||
? ["Broad query remains weakly narrowed; precision is intentionally limited."]
|
||||
: [])
|
||||
],
|
||||
10
|
||||
);
|
||||
const openUncertainties = uniqueStrings(
|
||||
[
|
||||
...input.groundingCheck.missing_requirements,
|
||||
...(decision.mode === "clarification_required" && missingAnchors.period ? ["missing_anchor:period"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.account ? ["missing_anchor:account"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.documentOrObject ? ["missing_anchor:document_or_object"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.counterparty ? ["missing_anchor:counterparty"] : [])
|
||||
],
|
||||
8
|
||||
);
|
||||
|
||||
const mechanismStatus: AnswerStructureV11["mechanism_block"]["status"] =
|
||||
mechanismNotes.length === 0
|
||||
? "unresolved"
|
||||
: limitationReasonCodes.includes("missing_mechanism") || limitationReasonCodes.includes("heuristic_inference")
|
||||
? "limited"
|
||||
: "grounded";
|
||||
|
||||
const answerStructure: AnswerStructureV11 = {
|
||||
schema_version: "answer_structure_v1_1",
|
||||
answer_summary: buildAnswerSummary(decision.mode),
|
||||
direct_answer: buildDirectAnswer({
|
||||
mode: decision.mode,
|
||||
retrievalResults: input.retrievalResults,
|
||||
policySignals
|
||||
}),
|
||||
mechanism_block: {
|
||||
status: mechanismStatus,
|
||||
mechanism_notes: mechanismNotes,
|
||||
limitation_reason_codes: limitationReasonCodes
|
||||
},
|
||||
evidence_block: {
|
||||
evidence_ids: uniqueStrings(evidenceItems.map((item) => item.evidence_id), 10),
|
||||
source_refs: sourceRefs,
|
||||
mechanism_notes: mechanismNotes,
|
||||
coverage_note:
|
||||
input.coverageReport.requirements_total > 0 &&
|
||||
input.coverageReport.requirements_total === input.coverageReport.requirements_covered &&
|
||||
input.coverageReport.requirements_uncovered.length === 0 &&
|
||||
input.coverageReport.requirements_partially_covered.length === 0
|
||||
? "coverage_full_or_near_full"
|
||||
: "coverage_partial_or_limited",
|
||||
...(claimEvidenceLinks.length > 0
|
||||
? {
|
||||
claim_evidence_links: claimEvidenceLinks
|
||||
}
|
||||
: {})
|
||||
},
|
||||
uncertainty_block: {
|
||||
open_uncertainties: openUncertainties,
|
||||
limitations
|
||||
},
|
||||
next_step_block: {
|
||||
recommended_actions: recommendedActions,
|
||||
clarification_questions: clarificationQuestions
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
assistant_reply: renderPolicyReply(answerStructure),
|
||||
fallback_type: decision.fallback_type,
|
||||
reply_type: decision.reply_type,
|
||||
answer_structure_v11: answerStructure
|
||||
};
|
||||
}
|
||||
|
||||
function composeExplainableAnswer(input: ComposeAnswerInput, scopeLabel: "full" | "partial"): string {
|
||||
const facts = extractTopFacts(input.retrievalResults);
|
||||
const whyIncluded = extractWhyIncluded(input.retrievalResults);
|
||||
const selectionReasons = extractSelectionReasons(input.retrievalResults);
|
||||
const riskFactors = extractRiskFactors(input.retrievalResults);
|
||||
const interpretation = extractBusinessInterpretation(input.retrievalResults);
|
||||
const limitations = uniqueStrings([...extractLimitations(input.retrievalResults), ...input.groundingCheck.reasons]);
|
||||
const nextSteps = suggestNextStep(input.requirements, input.coverageReport);
|
||||
|
||||
const lead =
|
||||
scopeLabel === "full"
|
||||
? "Итог: запрос обработан по предмету, найденные объекты подтверждены данными контура."
|
||||
: "Итог: запрос обработан частично, ниже подтвержденная часть и ограничения.";
|
||||
|
||||
return [
|
||||
lead,
|
||||
facts.length > 0 ? "Подтвержденные результаты:\n" + formatList(facts) : "",
|
||||
whyIncluded.length > 0 ? "Почему это попало в ответ:\n" + formatList(whyIncluded) : "",
|
||||
selectionReasons.length > 0 ? "Основание отбора:\n" + formatList(selectionReasons) : "",
|
||||
riskFactors.length > 0 ? "Подтверждающие признаки:\n" + formatList(riskFactors) : "",
|
||||
interpretation.length > 0 ? "Практический смысл:\n" + formatList(interpretation) : "",
|
||||
limitations.length > 0 ? "Ограничения:\n" + formatList(limitations) : "",
|
||||
nextSteps.length > 0 ? "Что проверить дальше:\n" + formatList(nextSteps) : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswerOutput {
|
||||
if (input.enableAnswerPolicyV11) {
|
||||
return composeAssistantAnswerV11(input);
|
||||
}
|
||||
|
||||
const fallbackType = fallbackFromSummary(input.routeSummary);
|
||||
const okResults = input.retrievalResults.filter((item) => item.status === "ok");
|
||||
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
|
||||
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
|
||||
const errorResults = input.retrievalResults.filter((item) => item.status === "error");
|
||||
const hasBroadMinimumEvidenceSignal = input.retrievalResults.some(
|
||||
(item) => summaryBoolean(item, "broad_guard_applied") && summaryBoolean(item, "minimum_evidence_failed")
|
||||
);
|
||||
const hasBroadClarificationSignal = input.retrievalResults.some(
|
||||
(item) =>
|
||||
summaryBoolean(item, "broad_guard_applied") &&
|
||||
summaryBoolean(item, "minimum_evidence_failed") &&
|
||||
summaryString(item, "degraded_to") === "clarification"
|
||||
);
|
||||
|
||||
if (fallbackType === "out_of_scope" && input.coverageReport.requirements_covered === 0) {
|
||||
return {
|
||||
assistant_reply:
|
||||
"Я могу отвечать только по данным вашей учетной базы. Этот запрос выходит за рамки доступного контура.",
|
||||
fallback_type: "out_of_scope",
|
||||
reply_type: "out_of_scope"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "route_mismatch_blocked") {
|
||||
return {
|
||||
assistant_reply: [
|
||||
"Не отправляю финальный ответ, потому что предмет результата не совпал с предметом вопроса.",
|
||||
"Уточните формулировку (например, нужный счет/участок учета), и я выполню повторный проход."
|
||||
].join("\n\n"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "route_mismatch_blocked"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "no_grounded_answer" && okResults.length === 0 && !hasBroadMinimumEvidenceSignal) {
|
||||
return {
|
||||
assistant_reply:
|
||||
"Пока не удалось собрать предметно подтвержденный ответ по вашему вопросу. Нужны дополнительные уточнения по периоду или объекту проверки.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "no_grounded_answer"
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBroadClarificationSignal && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply:
|
||||
"Запрос слишком широкий для надежного вывода по текущей опоре. Уточните период, участок учета или объект проверки, после чего я дам предметный результат.",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
|
||||
if (fallbackType === "clarification" && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: "Уточните, пожалуйста, период, счет, документ или контрагента, чтобы закрыть все части вопроса корректно.",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
|
||||
if (errorResults.length > 0 && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: "Не удалось получить данные из контура. Попробуйте повторить запрос или уточнить формулировку.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
|
||||
if (partialResults.length > 0 && okResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "partial"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
|
||||
if (okResults.length === 0 && partialResults.length === 0 && emptyResults.length > 0) {
|
||||
return {
|
||||
assistant_reply: "По заданному условию в текущем срезе данных явных проблемных записей не найдено.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "empty_but_valid"
|
||||
};
|
||||
}
|
||||
|
||||
const hasPartialCoverage =
|
||||
input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0 ||
|
||||
input.groundingCheck.status === "partial" ||
|
||||
errorResults.length > 0;
|
||||
|
||||
if (okResults.length > 0 && hasPartialCoverage) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "partial"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
|
||||
if (okResults.length > 0) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "full"),
|
||||
fallback_type: "none",
|
||||
reply_type: "factual_with_explanation"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
assistant_reply: "По текущему запросу не удалось построить обоснованный ответ. Уточните формулировку и попробуйте снова.",
|
||||
fallback_type: "unknown",
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
import path from "path";
|
||||
import { ASSISTANT_SESSIONS_DIR } from "../config";
|
||||
import type { AssistantConversationItem, AssistantReplyType, AssistantSessionState } from "../types/assistant";
|
||||
import { ensureDir, writeJsonFile } from "../utils/files";
|
||||
|
||||
interface AssistantTurnLogRecord {
|
||||
turn_id: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
human_block: string;
|
||||
human_readable: {
|
||||
question_raw: string;
|
||||
question_understood: string;
|
||||
decomposition: string[];
|
||||
answer: string;
|
||||
reply_type: AssistantReplyType | null;
|
||||
};
|
||||
technical_json: {
|
||||
trace_id: string | null;
|
||||
user_message: AssistantConversationItem;
|
||||
assistant_message: AssistantConversationItem;
|
||||
debug: AssistantConversationItem["debug"];
|
||||
};
|
||||
}
|
||||
|
||||
interface AssistantSessionLogRecord {
|
||||
schema_version: "assistant_session_log_v1";
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
updated_at: string;
|
||||
counters: {
|
||||
total_messages: number;
|
||||
user_messages: number;
|
||||
assistant_messages: number;
|
||||
};
|
||||
trace_ids: string[];
|
||||
reply_types: AssistantReplyType[];
|
||||
investigation_state: AssistantSessionState["investigation_state"];
|
||||
turns: AssistantTurnLogRecord[];
|
||||
conversation: AssistantConversationItem[];
|
||||
last_assistant: {
|
||||
message_id: string | null;
|
||||
reply_type: AssistantReplyType | null;
|
||||
trace_id: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function unique(values: Array<string | null>): string[] {
|
||||
return Array.from(new Set(values.filter((item): item is string => typeof item === "string" && item.length > 0)));
|
||||
}
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function extractFragments(assistantItem: AssistantConversationItem): Array<Record<string, unknown>> {
|
||||
if (!assistantItem.debug || !Array.isArray(assistantItem.debug.fragments)) {
|
||||
return [];
|
||||
}
|
||||
return assistantItem.debug.fragments
|
||||
.map((item) => toObject(item))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
}
|
||||
|
||||
function extractNormalizedQuestion(userText: string, assistantItem: AssistantConversationItem): string {
|
||||
const normalized = toObject(assistantItem.debug?.normalized);
|
||||
if (normalized) {
|
||||
const fromUserMessageRaw = toStringOrNull(normalized.user_message_raw);
|
||||
if (fromUserMessageRaw) return fromUserMessageRaw;
|
||||
const fromUserQuestionRaw = toStringOrNull(normalized.user_question_raw);
|
||||
if (fromUserQuestionRaw) return fromUserQuestionRaw;
|
||||
const fromNormalizedQuestion = toStringOrNull(normalized.normalized_question);
|
||||
if (fromNormalizedQuestion) return fromNormalizedQuestion;
|
||||
}
|
||||
|
||||
const fragments = extractFragments(assistantItem);
|
||||
if (fragments.length > 0) {
|
||||
const joined = fragments
|
||||
.map((fragment) => toStringOrNull(fragment.normalized_fragment_text) ?? toStringOrNull(fragment.raw_fragment_text))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join(" | ");
|
||||
if (joined) {
|
||||
return joined;
|
||||
}
|
||||
}
|
||||
|
||||
return userText;
|
||||
}
|
||||
|
||||
function buildRouteLookup(assistantItem: AssistantConversationItem): Map<string, Record<string, unknown>> {
|
||||
const output = new Map<string, Record<string, unknown>>();
|
||||
if (!assistantItem.debug || !Array.isArray(assistantItem.debug.routes)) {
|
||||
return output;
|
||||
}
|
||||
for (const route of assistantItem.debug.routes) {
|
||||
const routeObject = toObject(route);
|
||||
if (!routeObject) continue;
|
||||
const fragmentId = toStringOrNull(routeObject.fragment_id);
|
||||
if (!fragmentId) continue;
|
||||
output.set(fragmentId, routeObject);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildDecompositionLines(assistantItem: AssistantConversationItem): string[] {
|
||||
const fragments = extractFragments(assistantItem);
|
||||
if (fragments.length === 0) {
|
||||
return ["Фрагменты декомпозиции не выделены."];
|
||||
}
|
||||
|
||||
const routeLookup = buildRouteLookup(assistantItem);
|
||||
|
||||
return fragments.map((fragment, index) => {
|
||||
const fragmentId = toStringOrNull(fragment.fragment_id) ?? `F${index + 1}`;
|
||||
const fragmentText =
|
||||
toStringOrNull(fragment.normalized_fragment_text) ??
|
||||
toStringOrNull(fragment.raw_fragment_text) ??
|
||||
"текст фрагмента отсутствует";
|
||||
const executionReadiness = toStringOrNull(fragment.execution_readiness);
|
||||
const routeStatus = toStringOrNull(fragment.route_status);
|
||||
|
||||
const routeObject = routeLookup.get(fragmentId);
|
||||
const route = toStringOrNull(routeObject?.route);
|
||||
const noRouteReason =
|
||||
toStringOrNull(fragment.no_route_reason) ?? toStringOrNull(routeObject?.no_route_reason);
|
||||
|
||||
const parts = [`${fragmentId}: ${fragmentText}`];
|
||||
if (executionReadiness) parts.push(`execution_readiness=${executionReadiness}`);
|
||||
if (routeStatus) parts.push(`route_status=${routeStatus}`);
|
||||
if (route) parts.push(`route=${route}`);
|
||||
if (noRouteReason) parts.push(`no_route_reason=${noRouteReason}`);
|
||||
|
||||
return parts.join("; ");
|
||||
});
|
||||
}
|
||||
|
||||
function toHumanBlock(input: {
|
||||
questionRaw: string;
|
||||
questionUnderstood: string;
|
||||
decomposition: string[];
|
||||
answer: string;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Вопрос: ${input.questionRaw}`);
|
||||
lines.push(`Понято как: ${input.questionUnderstood}`);
|
||||
lines.push("Декомпозиция:");
|
||||
lines.push(...input.decomposition.map((item) => `- ${item}`));
|
||||
lines.push(`Ответ: ${input.answer}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildTurns(items: AssistantConversationItem[]): AssistantTurnLogRecord[] {
|
||||
const turns: AssistantTurnLogRecord[] = [];
|
||||
const pendingUsers: AssistantConversationItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.role === "user") {
|
||||
pendingUsers.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairedUser = pendingUsers.shift();
|
||||
if (!pairedUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const questionRaw = pairedUser.text;
|
||||
const questionUnderstood = extractNormalizedQuestion(questionRaw, item);
|
||||
const decomposition = buildDecompositionLines(item);
|
||||
const answer = item.text;
|
||||
|
||||
turns.push({
|
||||
turn_id: `turn-${turns.length + 1}`,
|
||||
started_at: pairedUser.created_at ?? null,
|
||||
completed_at: item.created_at ?? null,
|
||||
human_block: toHumanBlock({
|
||||
questionRaw,
|
||||
questionUnderstood,
|
||||
decomposition,
|
||||
answer
|
||||
}),
|
||||
human_readable: {
|
||||
question_raw: questionRaw,
|
||||
question_understood: questionUnderstood,
|
||||
decomposition,
|
||||
answer,
|
||||
reply_type: item.reply_type
|
||||
},
|
||||
technical_json: {
|
||||
trace_id: item.trace_id,
|
||||
user_message: pairedUser,
|
||||
assistant_message: item,
|
||||
debug: item.debug
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
export class AssistantSessionLogger {
|
||||
constructor(private readonly rootDir: string = ASSISTANT_SESSIONS_DIR) {}
|
||||
|
||||
public persistSession(session: AssistantSessionState): void {
|
||||
ensureDir(this.rootDir);
|
||||
const filePath = path.resolve(this.rootDir, `${session.session_id}.json`);
|
||||
|
||||
const startedAt = session.items[0]?.created_at ?? session.updated_at;
|
||||
const userMessages = session.items.filter((item) => item.role === "user").length;
|
||||
const assistantMessages = session.items.filter((item) => item.role === "assistant").length;
|
||||
const assistantItems = session.items.filter((item) => item.role === "assistant");
|
||||
const lastAssistant = assistantItems.length > 0 ? assistantItems[assistantItems.length - 1] : null;
|
||||
|
||||
const traceIds = unique(session.items.map((item) => item.trace_id));
|
||||
const replyTypes = Array.from(
|
||||
new Set(
|
||||
session.items
|
||||
.map((item) => item.reply_type)
|
||||
.filter((item): item is AssistantReplyType => typeof item === "string" && item.length > 0)
|
||||
)
|
||||
);
|
||||
const turns = buildTurns(session.items);
|
||||
|
||||
const record: AssistantSessionLogRecord = {
|
||||
schema_version: "assistant_session_log_v1",
|
||||
session_id: session.session_id,
|
||||
started_at: startedAt,
|
||||
updated_at: session.updated_at,
|
||||
counters: {
|
||||
total_messages: session.items.length,
|
||||
user_messages: userMessages,
|
||||
assistant_messages: assistantMessages
|
||||
},
|
||||
trace_ids: traceIds,
|
||||
reply_types: replyTypes,
|
||||
investigation_state: session.investigation_state,
|
||||
turns,
|
||||
conversation: session.items,
|
||||
last_assistant: {
|
||||
message_id: lastAssistant?.message_id ?? null,
|
||||
reply_type: lastAssistant?.reply_type ?? null,
|
||||
trace_id: lastAssistant?.trace_id ?? null,
|
||||
created_at: lastAssistant?.created_at ?? null
|
||||
}
|
||||
};
|
||||
|
||||
writeJsonFile(filePath, record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { AssistantConversationItem, AssistantSessionState } from "../types/assistant";
|
||||
import type { InvestigationState } from "../types/stage1Contracts";
|
||||
import { FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 } from "../config";
|
||||
import { cloneInvestigationState, createEmptyInvestigationState } from "./investigationState";
|
||||
|
||||
const MAX_ITEMS_PER_SESSION = 200;
|
||||
|
||||
function cloneItem(item: AssistantConversationItem): AssistantConversationItem {
|
||||
return {
|
||||
...item,
|
||||
debug: item.debug ? { ...item.debug } : null
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSession(state: AssistantSessionState): AssistantSessionState {
|
||||
return {
|
||||
session_id: state.session_id,
|
||||
updated_at: state.updated_at,
|
||||
items: state.items.map(cloneItem),
|
||||
investigation_state: cloneInvestigationState(state.investigation_state)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionShape(state: AssistantSessionState): AssistantSessionState {
|
||||
const legacy = state as AssistantSessionState & {
|
||||
investigation_state?: InvestigationState | null;
|
||||
items?: AssistantConversationItem[];
|
||||
updated_at?: string;
|
||||
};
|
||||
const normalizedItems = Array.isArray(legacy.items) ? legacy.items : [];
|
||||
const investigationState =
|
||||
FEATURE_ASSISTANT_INVESTIGATION_STATE_V1
|
||||
? legacy.investigation_state ?? createEmptyInvestigationState(state.session_id)
|
||||
: legacy.investigation_state ?? null;
|
||||
|
||||
state.items = normalizedItems;
|
||||
state.updated_at = typeof legacy.updated_at === "string" && legacy.updated_at.trim() ? legacy.updated_at : new Date().toISOString();
|
||||
state.investigation_state = investigationState;
|
||||
return state;
|
||||
}
|
||||
|
||||
export class AssistantSessionStore {
|
||||
private readonly sessions = new Map<string, AssistantSessionState>();
|
||||
|
||||
public ensureSession(sessionId?: string): AssistantSessionState {
|
||||
const resolvedId = (sessionId ?? "").trim() || `asst-${nanoid(10)}`;
|
||||
const existing = this.sessions.get(resolvedId);
|
||||
if (existing) {
|
||||
return cloneSession(normalizeSessionShape(existing));
|
||||
}
|
||||
const created: AssistantSessionState = {
|
||||
session_id: resolvedId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(resolvedId) : null
|
||||
};
|
||||
this.sessions.set(resolvedId, created);
|
||||
return cloneSession(created);
|
||||
}
|
||||
|
||||
public appendItem(sessionId: string, item: AssistantConversationItem): AssistantConversationItem {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
session.items.push(item);
|
||||
if (session.items.length > MAX_ITEMS_PER_SESSION) {
|
||||
session.items = session.items.slice(session.items.length - MAX_ITEMS_PER_SESSION);
|
||||
}
|
||||
session.updated_at = new Date().toISOString();
|
||||
return cloneItem(item);
|
||||
}
|
||||
|
||||
public getSession(sessionId: string): AssistantSessionState | null {
|
||||
const found = this.sessions.get(sessionId);
|
||||
return found ? cloneSession(normalizeSessionShape(found)) : null;
|
||||
}
|
||||
|
||||
public setInvestigationState(sessionId: string, state: InvestigationState | null): InvestigationState | null {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
session.investigation_state = cloneInvestigationState(state);
|
||||
session.updated_at = new Date().toISOString();
|
||||
return cloneInvestigationState(session.investigation_state);
|
||||
}
|
||||
|
||||
private ensureMutableSession(sessionId: string): AssistantSessionState {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (existing) {
|
||||
return normalizeSessionShape(existing);
|
||||
}
|
||||
const created: AssistantSessionState = {
|
||||
session_id: sessionId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(sessionId) : null
|
||||
};
|
||||
this.sessions.set(sessionId, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
AssistantRequirement,
|
||||
RequirementCoverageReport,
|
||||
UnifiedRetrievalResult
|
||||
} from "../types/assistant";
|
||||
import type { RouteHintSummary } from "../types/normalizer";
|
||||
import type {
|
||||
InvestigationLastAnswerMode,
|
||||
InvestigationNarrowingStatus,
|
||||
InvestigationState
|
||||
} from "../types/stage1Contracts";
|
||||
import {
|
||||
INVESTIGATION_MAX_EVIDENCE_REFS,
|
||||
INVESTIGATION_MAX_PRIMARY_ACCOUNTS,
|
||||
INVESTIGATION_MAX_REQUIREMENT_LINKS,
|
||||
INVESTIGATION_MAX_UNCERTAINTIES,
|
||||
INVESTIGATION_STATE_SCHEMA_VERSION
|
||||
} from "../types/stage1Contracts";
|
||||
|
||||
interface UpdateInvestigationStateInput {
|
||||
previous: InvestigationState;
|
||||
timestamp: string;
|
||||
questionId: string;
|
||||
userMessage: string;
|
||||
routeSummary: RouteHintSummary | null;
|
||||
requirements: AssistantRequirement[];
|
||||
coverageReport: RequirementCoverageReport;
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
replyType: InvestigationLastAnswerMode;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function capStrings(values: string[], max: number): string[] {
|
||||
return uniqueStrings(values).slice(0, max);
|
||||
}
|
||||
|
||||
function detectAccounts(text: string): string[] {
|
||||
return capStrings(text.match(/\b\d{2}(?:\.\d{2})?\b/g) ?? [], INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
|
||||
}
|
||||
|
||||
function detectPeriod(text: string): string | null {
|
||||
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
|
||||
if (monthly) return `${monthly[1]}-${monthly[2]}`;
|
||||
const yearly = text.match(/\b(20\d{2})\b/);
|
||||
if (yearly) return yearly[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveDomain(routeSummary: RouteHintSummary | null): string | null {
|
||||
if (!routeSummary) return null;
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return routeSummary.route_hint;
|
||||
}
|
||||
const routes = routeSummary.decisions.map((item) => item.route).filter((route) => route !== "no_route");
|
||||
const uniqueRoutes = uniqueStrings(routes);
|
||||
if (uniqueRoutes.length === 0) {
|
||||
return "no_route";
|
||||
}
|
||||
return uniqueRoutes.join(",");
|
||||
}
|
||||
|
||||
function deriveNarrowingStatus(
|
||||
routeSummary: RouteHintSummary | null,
|
||||
coverageReport: RequirementCoverageReport
|
||||
): InvestigationNarrowingStatus {
|
||||
if (!routeSummary) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return "not_needed";
|
||||
}
|
||||
|
||||
if (routeSummary.fallback.type === "clarification" || coverageReport.clarification_needed_for.length > 0) {
|
||||
return "needs_clarification";
|
||||
}
|
||||
|
||||
const hasNoRoute = routeSummary.decisions.some((item) => item.route === "no_route");
|
||||
if (hasNoRoute) {
|
||||
return "broad_guarded";
|
||||
}
|
||||
|
||||
return routeSummary.decisions.length > 1 ? "applied" : "not_needed";
|
||||
}
|
||||
|
||||
function deriveQueryModeHint(routeSummary: RouteHintSummary | null): InvestigationState["query_mode_hint"] {
|
||||
if (!routeSummary) {
|
||||
return "investigation_candidate";
|
||||
}
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return "direct_answer";
|
||||
}
|
||||
return routeSummary.fallback.type === "none" ? "direct_answer" : "investigation_candidate";
|
||||
}
|
||||
|
||||
function collectEvidenceRefs(retrievalResults: UnifiedRetrievalResult[]): string[] {
|
||||
const refs = retrievalResults.flatMap((result) => result.evidence.map((item) => item.evidence_id));
|
||||
return capStrings(refs, INVESTIGATION_MAX_EVIDENCE_REFS);
|
||||
}
|
||||
|
||||
function collectOpenUncertainties(
|
||||
coverageReport: RequirementCoverageReport,
|
||||
retrievalResults: UnifiedRetrievalResult[]
|
||||
): string[] {
|
||||
const requirementNotes = [
|
||||
...coverageReport.requirements_uncovered.map((item) => `uncovered:${item}`),
|
||||
...coverageReport.requirements_partially_covered.map((item) => `partial:${item}`),
|
||||
...coverageReport.clarification_needed_for.map((item) => `clarify:${item}`),
|
||||
...coverageReport.out_of_scope_requirements.map((item) => `out_of_scope:${item}`)
|
||||
];
|
||||
const limitationNotes = retrievalResults.flatMap((result) => result.limitations).slice(0, 6);
|
||||
return capStrings([...requirementNotes, ...limitationNotes], INVESTIGATION_MAX_UNCERTAINTIES);
|
||||
}
|
||||
|
||||
export function cloneInvestigationState(state: InvestigationState | null): InvestigationState | null {
|
||||
if (!state) return null;
|
||||
return {
|
||||
...state,
|
||||
focus: {
|
||||
...state.focus,
|
||||
primary_accounts: [...state.focus.primary_accounts]
|
||||
},
|
||||
evidence_refs: [...state.evidence_refs],
|
||||
open_uncertainties: [...state.open_uncertainties],
|
||||
followup_context: state.followup_context
|
||||
? {
|
||||
...state.followup_context,
|
||||
referenced_requirement_ids: [...state.followup_context.referenced_requirement_ids]
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyInvestigationState(sessionId: string, timestamp = new Date().toISOString()): InvestigationState {
|
||||
return {
|
||||
schema_version: INVESTIGATION_STATE_SCHEMA_VERSION,
|
||||
session_id: sessionId,
|
||||
status: "idle",
|
||||
turn_index: 0,
|
||||
updated_at: timestamp,
|
||||
question_id: null,
|
||||
focus: {
|
||||
domain: null,
|
||||
period: null,
|
||||
primary_accounts: [],
|
||||
active_query_subject: null
|
||||
},
|
||||
narrowing_status: "unknown",
|
||||
evidence_refs: [],
|
||||
open_uncertainties: [],
|
||||
last_answer_mode: null,
|
||||
followup_context: null,
|
||||
query_mode_hint: "direct_answer"
|
||||
};
|
||||
}
|
||||
|
||||
export function updateInvestigationState(input: UpdateInvestigationStateInput): InvestigationState {
|
||||
const previous = input.previous;
|
||||
const focusFromMessage = capStrings(detectAccounts(input.userMessage), INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
|
||||
const requirementIds = capStrings(
|
||||
input.requirements.map((item) => item.requirement_id),
|
||||
INVESTIGATION_MAX_REQUIREMENT_LINKS
|
||||
);
|
||||
const mainRequirement = input.requirements[0]?.requirement_text ?? input.userMessage;
|
||||
|
||||
return {
|
||||
schema_version: INVESTIGATION_STATE_SCHEMA_VERSION,
|
||||
session_id: previous.session_id,
|
||||
status: "active",
|
||||
turn_index: previous.turn_index + 1,
|
||||
updated_at: input.timestamp,
|
||||
question_id: input.questionId,
|
||||
focus: {
|
||||
domain: deriveDomain(input.routeSummary) ?? previous.focus.domain,
|
||||
period: detectPeriod(input.userMessage) ?? previous.focus.period,
|
||||
primary_accounts: capStrings(
|
||||
[...focusFromMessage, ...previous.focus.primary_accounts],
|
||||
INVESTIGATION_MAX_PRIMARY_ACCOUNTS
|
||||
),
|
||||
active_query_subject: mainRequirement.slice(0, 180)
|
||||
},
|
||||
narrowing_status: deriveNarrowingStatus(input.routeSummary, input.coverageReport),
|
||||
evidence_refs: capStrings(
|
||||
[...collectEvidenceRefs(input.retrievalResults), ...previous.evidence_refs],
|
||||
INVESTIGATION_MAX_EVIDENCE_REFS
|
||||
),
|
||||
open_uncertainties: collectOpenUncertainties(input.coverageReport, input.retrievalResults),
|
||||
last_answer_mode: input.replyType,
|
||||
followup_context: {
|
||||
previous_question_id: previous.question_id,
|
||||
last_user_message: input.userMessage.slice(0, 240),
|
||||
referenced_requirement_ids: requirementIds
|
||||
},
|
||||
query_mode_hint: deriveQueryModeHint(input.routeSummary)
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config";
|
||||
import { ApiError } from "../utils/http";
|
||||
|
||||
export interface OpenAIRequestConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
temperature?: number;
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
export interface OpenAIResponseEnvelope {
|
||||
raw: unknown;
|
||||
outputText: string;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
function extractUsage(raw: Record<string, unknown>): {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
} {
|
||||
const usage = (raw.usage ?? {}) as Record<string, unknown>;
|
||||
const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? usage.completion_tokens ?? 0);
|
||||
const total = Number(usage.total_tokens ?? input + output);
|
||||
return {
|
||||
input_tokens: Number.isFinite(input) ? input : 0,
|
||||
output_tokens: Number.isFinite(output) ? output : 0,
|
||||
total_tokens: Number.isFinite(total) ? total : 0
|
||||
};
|
||||
}
|
||||
|
||||
function extractOutputText(raw: Record<string, unknown>): string {
|
||||
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
||||
return raw.output_text;
|
||||
}
|
||||
|
||||
const output = raw.output;
|
||||
if (Array.isArray(output)) {
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const content = (item as Record<string, unknown>).content;
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
for (const c of content) {
|
||||
if (!c || typeof c !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = c as Record<string, unknown>;
|
||||
if (typeof block.text === "string" && block.text.trim()) {
|
||||
return block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = raw.response;
|
||||
if (response && typeof response === "object") {
|
||||
const nested = response as Record<string, unknown>;
|
||||
if (typeof nested.output_text === "string" && nested.output_text.trim().length > 0) {
|
||||
return nested.output_text;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
|
||||
}
|
||||
|
||||
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
|
||||
const schemaFile =
|
||||
schemaVersion === "v1"
|
||||
? "normalized_query_v1.json"
|
||||
: schemaVersion === "v2_0_1"
|
||||
? "normalized_query_v2_0_1.json"
|
||||
: schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2.json"
|
||||
: "normalized_query_v2.json";
|
||||
const schemaPath = path.resolve(SCHEMAS_DIR, schemaFile);
|
||||
return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class OpenAIResponsesClient {
|
||||
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
|
||||
const payload = {
|
||||
model: config.model,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "ping" }]
|
||||
}
|
||||
],
|
||||
max_output_tokens: 16
|
||||
};
|
||||
await this.post(config, payload);
|
||||
return { ok: true, model: config.model };
|
||||
}
|
||||
|
||||
public async normalize(
|
||||
config: OpenAIRequestConfig,
|
||||
prompt: {
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
userQuestion: string;
|
||||
schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2";
|
||||
controlledRetryInstruction?: string;
|
||||
}
|
||||
): Promise<OpenAIResponseEnvelope> {
|
||||
const schema = loadSchemaForTransport(prompt.schemaVersion);
|
||||
const schemaName =
|
||||
prompt.schemaVersion === "v1"
|
||||
? "normalized_query_v1"
|
||||
: prompt.schemaVersion === "v2_0_1"
|
||||
? "normalized_query_v2_0_1"
|
||||
: prompt.schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2"
|
||||
: "normalized_query_v2";
|
||||
|
||||
const developerPrompt = prompt.controlledRetryInstruction
|
||||
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
||||
: prompt.developerPrompt;
|
||||
|
||||
const payload = {
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0,
|
||||
max_output_tokens: config.maxOutputTokens ?? 700,
|
||||
input: [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: prompt.systemPrompt }]
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
content: [{ type: "input_text", text: developerPrompt }]
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: schemaName,
|
||||
strict: true,
|
||||
schema
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const raw = await this.post(config, payload);
|
||||
const outputText = extractOutputText(raw);
|
||||
return {
|
||||
raw,
|
||||
outputText,
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
private async post(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (!config.apiKey || config.apiKey.trim().length < 10) {
|
||||
throw new ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
|
||||
}
|
||||
|
||||
const url = `${(config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorObj = (data.error ?? {}) as Record<string, unknown>;
|
||||
throw new ApiError(
|
||||
"OPENAI_REQUEST_FAILED",
|
||||
String(errorObj.message ?? `OpenAI request failed with status ${response.status}`),
|
||||
response.status,
|
||||
{
|
||||
status: response.status,
|
||||
type: errorObj.type ?? null,
|
||||
code: errorObj.code ?? null
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { DEFAULT_PROMPT_VERSION, PROMPTS_DIR } from "../config";
|
||||
import type { PromptBundle, PromptPreset, PromptVersion } from "../types/preset";
|
||||
|
||||
function readPromptFile(relativePath: string): string {
|
||||
const filePath = path.resolve(PROMPTS_DIR, relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Prompt file not found: ${filePath}`);
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf-8").trim();
|
||||
}
|
||||
|
||||
interface BuiltinPromptPresetDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
promptVersion: PromptVersion;
|
||||
schemaNotes: string;
|
||||
files: {
|
||||
system: string;
|
||||
developer: string;
|
||||
domain: string;
|
||||
fewshot: string;
|
||||
};
|
||||
}
|
||||
|
||||
const BUILTIN_PROMPT_PRESETS: Record<PromptVersion, BuiltinPromptPresetDefinition> = {
|
||||
normalizer_v1: {
|
||||
id: "default-normalizer-v1",
|
||||
name: "Стандартный пресет NDC v1",
|
||||
promptVersion: "normalizer_v1",
|
||||
schemaNotes: "Используется схема normalized_query_v1. Строго соблюдать enum/required поля.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "default.txt"),
|
||||
domain: path.join("domain", "default.txt"),
|
||||
fewshot: path.join("fewshot", "default.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1: {
|
||||
id: "default-normalizer-v1_1",
|
||||
name: "Стандартный пресет NDC v1.1",
|
||||
promptVersion: "normalizer_v1_1",
|
||||
schemaNotes:
|
||||
"v1.1: усиленная taxonomy intent/route и confidence policy. Используется схема normalized_query_v1 без дополнительных полей.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_1: {
|
||||
id: "default-normalizer-v1_1_1",
|
||||
name: "Стандартный пресет NDC v1.1.1",
|
||||
promptVersion: "normalizer_v1_1_1",
|
||||
schemaNotes:
|
||||
"v1.1.1: surgical patch для period_close_risk, exact drilldown requires и anomaly route escalation. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_2: {
|
||||
id: "default-normalizer-v1_1_2",
|
||||
name: "Стандартный пресет NDC v1.1.2",
|
||||
promptVersion: "normalizer_v1_1_2",
|
||||
schemaNotes:
|
||||
"v1.1.2: точечный patch границы heavy_analytical vs period_close_risk + confidence guard на boundary кейсах. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1_2.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1_2.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_2_1: {
|
||||
id: "default-normalizer-v1_1_2_1",
|
||||
name: "Стандартный пресет NDC v1.1.2.1",
|
||||
promptVersion: "normalizer_v1_1_2_1",
|
||||
schemaNotes:
|
||||
"v1.1.2.1: stable prompt baseline v1.1.2 + accounting-review phrasing anchors for 30-case validation pack. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1_2_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1_2_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2: {
|
||||
id: "default-normalizer-v2",
|
||||
name: "Стандартный пресет NDC v2",
|
||||
promptVersion: "normalizer_v2",
|
||||
schemaNotes:
|
||||
"v2: decomposition-first pre-router. LLM returns fragments + scope + flags; deterministic routing happens in code. Схема normalized_query_v2.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v2.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_v2.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2_0_1: {
|
||||
id: "default-normalizer-v2_0_1",
|
||||
name: "Стандартный пресет NDC v2.0.1",
|
||||
promptVersion: "normalizer_v2_0_1",
|
||||
schemaNotes:
|
||||
"v2.0.1: clarification-threshold policy. Вопросы в контуре и с понятным route должны исполняться без лишних уточнений. Схема normalized_query_v2_0_1.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v2_0_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_v2_0_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2_0_2: {
|
||||
id: "default-normalizer-v2_0_2",
|
||||
name: "Стандартный пресет NDC v2.0.2",
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
schemaNotes:
|
||||
"v2.0.2: execution-state hardening + explicit route_status/no_route_reason. Схема normalized_query_v2_0_2.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v2_0_2.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_v2_0_2.txt")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isPromptVersion(value: string | undefined): value is PromptVersion {
|
||||
return (
|
||||
value === "normalizer_v1" ||
|
||||
value === "normalizer_v1_1" ||
|
||||
value === "normalizer_v1_1_1" ||
|
||||
value === "normalizer_v1_1_2" ||
|
||||
value === "normalizer_v1_1_2_1" ||
|
||||
value === "normalizer_v2" ||
|
||||
value === "normalizer_v2_0_1" ||
|
||||
value === "normalizer_v2_0_2"
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePromptVersion(requested?: string): PromptVersion {
|
||||
if (isPromptVersion(requested)) {
|
||||
return requested;
|
||||
}
|
||||
if (isPromptVersion(DEFAULT_PROMPT_VERSION)) {
|
||||
return DEFAULT_PROMPT_VERSION;
|
||||
}
|
||||
return "normalizer_v2_0_2";
|
||||
}
|
||||
|
||||
function loadBuiltinPreset(promptVersion: PromptVersion): PromptPreset {
|
||||
const now = new Date().toISOString();
|
||||
const definition = BUILTIN_PROMPT_PRESETS[promptVersion];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
prompt_version: definition.promptVersion,
|
||||
systemPrompt: readPromptFile(definition.files.system),
|
||||
developerPrompt: readPromptFile(definition.files.developer),
|
||||
domainPrompt: readPromptFile(definition.files.domain),
|
||||
schemaNotes: definition.schemaNotes,
|
||||
fewShotExamples: readPromptFile(definition.files.fewshot)
|
||||
};
|
||||
}
|
||||
|
||||
export function listBuiltinPromptPresets(): PromptPreset[] {
|
||||
return (Object.keys(BUILTIN_PROMPT_PRESETS) as PromptVersion[]).map((version) => loadBuiltinPreset(version));
|
||||
}
|
||||
|
||||
export function loadDefaultPrompts(promptVersion?: string): PromptPreset {
|
||||
return loadBuiltinPreset(resolvePromptVersion(promptVersion));
|
||||
}
|
||||
|
||||
export function buildPromptBundle(input: {
|
||||
promptVersion?: string;
|
||||
systemPrompt?: string;
|
||||
developerPrompt?: string;
|
||||
domainPrompt?: string;
|
||||
schemaNotes?: string;
|
||||
fewShotExamples?: string;
|
||||
}): PromptBundle {
|
||||
const selectedPromptVersion = resolvePromptVersion(input.promptVersion);
|
||||
const defaults = loadDefaultPrompts(selectedPromptVersion);
|
||||
const systemPrompt = (input.systemPrompt ?? defaults.systemPrompt).trim();
|
||||
const developerPrompt = (input.developerPrompt ?? defaults.developerPrompt).trim();
|
||||
const domainPrompt = (input.domainPrompt ?? defaults.domainPrompt).trim();
|
||||
const schemaNotes = (input.schemaNotes ?? defaults.schemaNotes ?? "").trim();
|
||||
const fewShotExamples = (input.fewShotExamples ?? defaults.fewShotExamples ?? "").trim();
|
||||
const prompt_version = (input.promptVersion ?? defaults.prompt_version).trim() || selectedPromptVersion;
|
||||
|
||||
const sections = [developerPrompt, `Schema notes:\n${schemaNotes}`];
|
||||
if (fewShotExamples) {
|
||||
sections.push(`Few-shot examples:\n${fewShotExamples}`);
|
||||
}
|
||||
|
||||
return {
|
||||
prompt_version,
|
||||
systemPrompt,
|
||||
developerPrompt,
|
||||
domainPrompt,
|
||||
schemaNotes,
|
||||
fewShotExamples,
|
||||
combinedDeveloperPrompt: sections.join("\n\n")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import type {
|
||||
RetrievalConfidence,
|
||||
RetrievalResultStatus,
|
||||
RetrievalResultType,
|
||||
UnifiedRetrievalResult
|
||||
} from "../types/assistant";
|
||||
import { FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 } from "../config";
|
||||
import { EVIDENCE_SOURCE_REF_SCHEMA_VERSION } from "../types/stage1Contracts";
|
||||
import type {
|
||||
EvidenceConfidence,
|
||||
EvidenceItem,
|
||||
EvidenceLimitationReasonCode,
|
||||
EvidenceKind,
|
||||
EvidencePointer,
|
||||
EvidenceSourceRef
|
||||
} from "../types/stage1Contracts";
|
||||
|
||||
interface RawRetrievalResult {
|
||||
status?: string;
|
||||
result_type?: string;
|
||||
items?: unknown;
|
||||
summary?: unknown;
|
||||
evidence?: unknown;
|
||||
why_included?: unknown;
|
||||
selection_reason?: unknown;
|
||||
risk_factors?: unknown;
|
||||
business_interpretation?: unknown;
|
||||
confidence?: unknown;
|
||||
limitations?: unknown;
|
||||
errors?: unknown;
|
||||
}
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: string | undefined): RetrievalResultStatus {
|
||||
if (value === "ok" || value === "empty" || value === "partial" || value === "error") {
|
||||
return value;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function normalizeResultType(value: string | undefined): RetrievalResultType {
|
||||
if (value === "list" || value === "summary" || value === "object" || value === "chain" || value === "ranking") {
|
||||
return value;
|
||||
}
|
||||
return "summary";
|
||||
}
|
||||
|
||||
function normalizeObjectArray(value: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
}
|
||||
|
||||
function normalizeSummary(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeErrors(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
function normalizeConfidence(value: unknown): RetrievalConfidence {
|
||||
if (value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
|
||||
function parseEvidenceConfidence(value: unknown): EvidenceConfidence | null {
|
||||
if (value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEvidenceNamespace(value: unknown): EvidencePointer["source"]["namespace"] {
|
||||
const normalized = toStringOrNull(value)?.toLowerCase();
|
||||
if (!normalized) return "unknown";
|
||||
if (normalized === "snapshot_2020" || normalized === "snapshot") return "snapshot_2020";
|
||||
if (normalized === "assistant_derived" || normalized === "derived") return "assistant_derived";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function inferEvidenceKind(item: Record<string, unknown>): EvidenceKind {
|
||||
if (item.mechanism_of_failure !== undefined || item.failed_expected_edge !== undefined || item.expected_next_step !== undefined) {
|
||||
return "mechanism_link";
|
||||
}
|
||||
if (item.risk_score !== undefined || item.zero_guid_values !== undefined || item.unknown_link_count !== undefined) {
|
||||
return "anomaly_signal";
|
||||
}
|
||||
if (item.records_count !== undefined || item.operations_count !== undefined || item.document_refs_count !== undefined) {
|
||||
return "aggregation";
|
||||
}
|
||||
if (item.limitation !== undefined || item.is_snapshot_limited !== undefined) {
|
||||
return "limitation_note";
|
||||
}
|
||||
return "factual_anchor";
|
||||
}
|
||||
|
||||
function inferMechanismNoteLegacy(kind: EvidenceKind, item: Record<string, unknown>): string {
|
||||
const explicit = toStringOrNull(item.mechanism_note);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (kind === "mechanism_link") {
|
||||
const failure = toStringOrNull(item.mechanism_of_failure);
|
||||
if (failure) return failure;
|
||||
return "Mechanism link inferred from retrieval evidence.";
|
||||
}
|
||||
if (kind === "anomaly_signal") {
|
||||
return "Anomaly signal inferred from risk-oriented fields.";
|
||||
}
|
||||
if (kind === "aggregation") {
|
||||
return "Aggregated evidence item.";
|
||||
}
|
||||
if (kind === "limitation_note") {
|
||||
return "Evidence includes explicit limitation hints.";
|
||||
}
|
||||
return "Factual evidence anchor.";
|
||||
}
|
||||
|
||||
interface MechanismNoteResolution {
|
||||
note: string | null;
|
||||
reliable: boolean;
|
||||
}
|
||||
|
||||
function resolveMechanismNote(kind: EvidenceKind, item: Record<string, unknown>): MechanismNoteResolution {
|
||||
const explicit = toStringOrNull(item.mechanism_note);
|
||||
if (explicit) {
|
||||
return {
|
||||
note: explicit,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "mechanism_link") {
|
||||
const failure = toStringOrNull(item.mechanism_of_failure);
|
||||
if (failure) {
|
||||
return {
|
||||
note: failure,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
const failedEdge = toStringOrNull(item.failed_expected_edge);
|
||||
const expectedNext = toStringOrNull(item.expected_next_step);
|
||||
const composed = [failedEdge ? `failed_edge=${failedEdge}` : null, expectedNext ? `expected_next_step=${expectedNext}` : null]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join("; ");
|
||||
if (composed) {
|
||||
return {
|
||||
note: composed,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return {
|
||||
note: inferMechanismNoteLegacy(kind, item),
|
||||
reliable: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
note: null,
|
||||
reliable: false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvidenceSourceType(value: unknown, record: Record<string, unknown>): EvidenceItem["source_type"] {
|
||||
const normalized = toStringOrNull(value);
|
||||
if (normalized === "retrieval_item" || normalized === "retrieval_summary" || normalized === "derived") {
|
||||
return normalized;
|
||||
}
|
||||
if (record.records_count !== undefined || record.operations_count !== undefined || record.document_refs_count !== undefined) {
|
||||
return "retrieval_summary";
|
||||
}
|
||||
return "retrieval_item";
|
||||
}
|
||||
|
||||
function readPointer(record: Record<string, unknown>): Record<string, unknown> {
|
||||
const pointer = toObject(record.pointer);
|
||||
return pointer ?? {};
|
||||
}
|
||||
|
||||
interface NormalizedPointerResult {
|
||||
pointer: EvidencePointer;
|
||||
fallback_source_namespace: boolean;
|
||||
fallback_source_entity: boolean;
|
||||
fallback_source_id: boolean;
|
||||
}
|
||||
|
||||
function normalizeEvidencePointer(
|
||||
fragmentId: string,
|
||||
route: string,
|
||||
record: Record<string, unknown>,
|
||||
index: number
|
||||
): NormalizedPointerResult {
|
||||
const pointer = readPointer(record);
|
||||
const source = toObject(pointer.source);
|
||||
const locator = toObject(pointer.locator);
|
||||
|
||||
const sourceEntityCandidate = toStringOrNull(source?.entity) ?? toStringOrNull(record.source_entity);
|
||||
const sourceEntity = sourceEntityCandidate ?? "unknown_entity";
|
||||
const sourceIdCandidate = toStringOrNull(source?.id) ?? toStringOrNull(record.source_id);
|
||||
const sourceId = sourceIdCandidate ?? `${route}:${fragmentId}:${index + 1}`;
|
||||
const period = toStringOrNull(source?.period) ?? toStringOrNull(record.period);
|
||||
const namespace = normalizeEvidenceNamespace(source?.namespace ?? record.source_namespace);
|
||||
|
||||
return {
|
||||
pointer: {
|
||||
fragment_id: toStringOrNull(pointer.fragment_id) ?? fragmentId,
|
||||
route: toStringOrNull(pointer.route) ?? route,
|
||||
source: {
|
||||
namespace,
|
||||
entity: sourceEntity,
|
||||
id: sourceId,
|
||||
period
|
||||
},
|
||||
locator: {
|
||||
field_path: toStringOrNull(locator?.field_path) ?? toStringOrNull(record.field_path),
|
||||
item_index: toNumberOrNull(locator?.item_index) ?? index
|
||||
}
|
||||
},
|
||||
fallback_source_namespace: namespace === "unknown",
|
||||
fallback_source_entity: sourceEntityCandidate === null,
|
||||
fallback_source_id: sourceIdCandidate === null
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalizeSourceRefPart(value: string | null): string {
|
||||
return encodeURIComponent((value ?? "none").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function buildSourceRef(pointer: EvidencePointer): EvidenceSourceRef {
|
||||
return {
|
||||
schema_version: EVIDENCE_SOURCE_REF_SCHEMA_VERSION,
|
||||
namespace: pointer.source.namespace,
|
||||
entity: pointer.source.entity,
|
||||
id: pointer.source.id,
|
||||
period: pointer.source.period,
|
||||
canonical_ref: [
|
||||
EVIDENCE_SOURCE_REF_SCHEMA_VERSION,
|
||||
canonicalizeSourceRefPart(pointer.source.namespace),
|
||||
canonicalizeSourceRefPart(pointer.source.entity),
|
||||
canonicalizeSourceRefPart(pointer.source.id),
|
||||
canonicalizeSourceRefPart(pointer.source.period)
|
||||
].join("|")
|
||||
};
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.trim().toLowerCase();
|
||||
return lowered === "true" || lowered === "1" || lowered === "yes";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function limitationCodeFromText(text: string): EvidenceLimitationReasonCode {
|
||||
const lower = text.toLowerCase();
|
||||
if (/(snapshot|read-only|read only)/i.test(lower)) {
|
||||
return "snapshot_only";
|
||||
}
|
||||
if (/heuristic/i.test(lower)) {
|
||||
return "heuristic_inference";
|
||||
}
|
||||
if (/mechanism/i.test(lower)) {
|
||||
return "missing_mechanism";
|
||||
}
|
||||
if (/(guid|detail|specific)/i.test(lower)) {
|
||||
return "insufficient_detail";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
interface LimitationResolutionInput {
|
||||
record: Record<string, unknown>;
|
||||
sourceType: EvidenceItem["source_type"];
|
||||
evidenceKind: EvidenceKind;
|
||||
mechanismReliable: boolean;
|
||||
mechanismExpected: boolean;
|
||||
pointerWeak: boolean;
|
||||
}
|
||||
|
||||
function resolveEvidenceLimitation(input: LimitationResolutionInput): EvidenceItem["limitation"] {
|
||||
const explicitLimitation = toStringOrNull(input.record.limitation);
|
||||
if (explicitLimitation) {
|
||||
return {
|
||||
reason_code: limitationCodeFromText(explicitLimitation),
|
||||
note: explicitLimitation
|
||||
};
|
||||
}
|
||||
if (toBoolean(input.record.is_snapshot_limited)) {
|
||||
return {
|
||||
reason_code: "snapshot_only",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (!FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return null;
|
||||
}
|
||||
if (input.mechanismExpected && !input.mechanismReliable) {
|
||||
return {
|
||||
reason_code: "missing_mechanism",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.pointerWeak) {
|
||||
return {
|
||||
reason_code: "weak_source_mapping",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.sourceType === "derived") {
|
||||
return {
|
||||
reason_code: "heuristic_inference",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.evidenceKind === "limitation_note") {
|
||||
return {
|
||||
reason_code: "unknown",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function downgradeConfidence(value: EvidenceConfidence): EvidenceConfidence {
|
||||
if (value === "high") return "medium";
|
||||
if (value === "medium") return "low";
|
||||
return "low";
|
||||
}
|
||||
|
||||
interface ConfidenceResolutionInput {
|
||||
explicitConfidence: EvidenceConfidence | null;
|
||||
sourceType: EvidenceItem["source_type"];
|
||||
mechanismReliable: boolean;
|
||||
mechanismExpected: boolean;
|
||||
limitation: EvidenceItem["limitation"];
|
||||
pointerWeak: boolean;
|
||||
}
|
||||
|
||||
function resolveEvidenceConfidence(input: ConfidenceResolutionInput): EvidenceConfidence {
|
||||
if (!FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return input.explicitConfidence ?? "medium";
|
||||
}
|
||||
|
||||
let confidence: EvidenceConfidence = input.explicitConfidence ?? (input.sourceType === "retrieval_item" ? "medium" : "low");
|
||||
|
||||
if (input.limitation?.reason_code === "missing_mechanism" || input.limitation?.reason_code === "weak_source_mapping") {
|
||||
confidence = downgradeConfidence(confidence);
|
||||
}
|
||||
if (input.sourceType === "derived" && !input.explicitConfidence) {
|
||||
confidence = "low";
|
||||
}
|
||||
if (input.mechanismExpected && !input.mechanismReliable) {
|
||||
confidence = "low";
|
||||
}
|
||||
if (input.pointerWeak) {
|
||||
confidence = "low";
|
||||
}
|
||||
|
||||
return confidence;
|
||||
}
|
||||
|
||||
function normalizeEvidenceItems(
|
||||
fragmentId: string,
|
||||
requirementIds: string[],
|
||||
route: string,
|
||||
value: unknown
|
||||
): EvidenceItem[] {
|
||||
const records = normalizeObjectArray(value);
|
||||
return records.map((record, index) => {
|
||||
const evidenceId = toStringOrNull(record.evidence_id) ?? `ev-${fragmentId}-${index + 1}`;
|
||||
const claimRef =
|
||||
toStringOrNull(record.claim_ref) ??
|
||||
(requirementIds[0] ? `requirement:${requirementIds[0]}` : `fragment:${fragmentId}`);
|
||||
const evidenceKind = inferEvidenceKind(record);
|
||||
const sourceType = normalizeEvidenceSourceType(record.source_type, record);
|
||||
const pointerResult = normalizeEvidencePointer(fragmentId, route, record, index);
|
||||
const mechanism = resolveMechanismNote(evidenceKind, record);
|
||||
const mechanismExpected = evidenceKind === "mechanism_link" || evidenceKind === "anomaly_signal" || evidenceKind === "aggregation";
|
||||
const pointerWeak =
|
||||
pointerResult.fallback_source_namespace || pointerResult.fallback_source_entity || pointerResult.fallback_source_id;
|
||||
const limitation = resolveEvidenceLimitation({
|
||||
record,
|
||||
sourceType,
|
||||
evidenceKind,
|
||||
mechanismReliable: mechanism.reliable,
|
||||
mechanismExpected,
|
||||
pointerWeak
|
||||
});
|
||||
const confidence = resolveEvidenceConfidence({
|
||||
explicitConfidence: parseEvidenceConfidence(record.confidence),
|
||||
sourceType,
|
||||
mechanismReliable: mechanism.reliable,
|
||||
mechanismExpected,
|
||||
limitation,
|
||||
pointerWeak
|
||||
});
|
||||
|
||||
return {
|
||||
evidence_id: evidenceId,
|
||||
claim_ref: claimRef,
|
||||
source_type: sourceType,
|
||||
source_ref: buildSourceRef(pointerResult.pointer),
|
||||
pointer: pointerResult.pointer,
|
||||
evidence_kind: evidenceKind,
|
||||
mechanism_note: mechanism.note,
|
||||
confidence,
|
||||
limitation,
|
||||
payload: record
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRetrievalResult(
|
||||
fragmentId: string,
|
||||
requirementIds: string[],
|
||||
route: string,
|
||||
raw: RawRetrievalResult
|
||||
): UnifiedRetrievalResult {
|
||||
return {
|
||||
fragment_id: fragmentId,
|
||||
requirement_ids: requirementIds,
|
||||
route,
|
||||
status: normalizeStatus(raw.status),
|
||||
result_type: normalizeResultType(raw.result_type),
|
||||
items: normalizeObjectArray(raw.items),
|
||||
summary: normalizeSummary(raw.summary),
|
||||
evidence: normalizeEvidenceItems(fragmentId, requirementIds, route, raw.evidence),
|
||||
why_included: normalizeStringArray(raw.why_included),
|
||||
selection_reason: normalizeStringArray(raw.selection_reason),
|
||||
risk_factors: normalizeStringArray(raw.risk_factors),
|
||||
business_interpretation: normalizeStringArray(raw.business_interpretation),
|
||||
confidence: normalizeConfidence(raw.confidence),
|
||||
limitations: normalizeStringArray(raw.limitations),
|
||||
errors: normalizeErrors(raw.errors)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import type {
|
||||
NoRouteReason,
|
||||
NormalizedPayload,
|
||||
NormalizedQueryV1,
|
||||
NormalizedQueryV2,
|
||||
NormalizedQueryV2_0_1,
|
||||
NormalizedQueryV2_0_2,
|
||||
RouteDecisionV2,
|
||||
RouteHintSummary,
|
||||
RouteHintSummaryV1,
|
||||
RouteHintSummaryV2,
|
||||
RouteStatus,
|
||||
SoftAssumption
|
||||
} from "../types/normalizer";
|
||||
|
||||
function toRouteHintSummaryV1(normalized: NormalizedQueryV1): RouteHintSummaryV1 {
|
||||
return {
|
||||
mode: "legacy_v1",
|
||||
intent_class: normalized.intent_class,
|
||||
route_hint: normalized.route_hint,
|
||||
confidence: normalized.confidence.route_hint,
|
||||
decision_flags: {
|
||||
needs_cross_entity_join: normalized.requires.needs_cross_entity_join,
|
||||
needs_causal_chain: normalized.requires.needs_causal_chain,
|
||||
needs_exact_object_trace: normalized.requires.needs_exact_object_trace,
|
||||
needs_ranking: normalized.requires.needs_ranking,
|
||||
needs_anomaly_summary: normalized.requires.needs_anomaly_summary,
|
||||
needs_runtime_truth: normalized.requires.needs_runtime_truth,
|
||||
needs_period_cut: normalized.requires.needs_period_cut,
|
||||
needs_evidence: normalized.requires.needs_evidence
|
||||
},
|
||||
period_scope: normalized.period_scope,
|
||||
entities: {
|
||||
domain_entities: normalized.domain_entities,
|
||||
accounts_mentioned: normalized.accounts_mentioned,
|
||||
documents_mentioned: normalized.documents_mentioned,
|
||||
registers_mentioned: normalized.registers_mentioned
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type V2Family = NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
|
||||
type V2FamilyFragment = V2Family["fragments"][number];
|
||||
|
||||
function reasonForNoRoute(noRouteReason: NoRouteReason | null | undefined): string {
|
||||
if (noRouteReason === "out_of_scope") {
|
||||
return "Fragment is out-of-scope for company-specific accounting contour.";
|
||||
}
|
||||
if (noRouteReason === "missing_mapping") {
|
||||
return "Fragment is in-scope but route mapping is currently missing.";
|
||||
}
|
||||
if (noRouteReason === "unsupported_fragment_type") {
|
||||
return "Fragment type is not supported by the current deterministic route map.";
|
||||
}
|
||||
return "Fragment requires clarification or is too underspecified for safe routing.";
|
||||
}
|
||||
|
||||
function explicitRouteStatus(fragment: V2FamilyFragment): RouteStatus | null {
|
||||
return "route_status" in fragment ? fragment.route_status : null;
|
||||
}
|
||||
|
||||
function explicitNoRouteReason(fragment: V2FamilyFragment): NoRouteReason | null {
|
||||
return "no_route_reason" in fragment ? fragment.no_route_reason : null;
|
||||
}
|
||||
|
||||
function executionReadiness(fragment: V2FamilyFragment): RouteDecisionV2["execution_readiness"] {
|
||||
return "execution_readiness" in fragment ? fragment.execution_readiness : null;
|
||||
}
|
||||
|
||||
function clarificationReason(fragment: V2FamilyFragment): string | null {
|
||||
return "clarification_reason" in fragment ? fragment.clarification_reason : null;
|
||||
}
|
||||
|
||||
function softAssumptions(fragment: V2FamilyFragment): SoftAssumption[] {
|
||||
return "soft_assumption_used" in fragment ? fragment.soft_assumption_used : [];
|
||||
}
|
||||
|
||||
function buildNoRouteDecision(fragment: V2FamilyFragment, noRouteReason: NoRouteReason | null): RouteDecisionV2 {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: executionReadiness(fragment),
|
||||
clarification_reason: clarificationReason(fragment),
|
||||
soft_assumption_used: softAssumptions(fragment),
|
||||
route_status: "no_route",
|
||||
no_route_reason: noRouteReason ?? "insufficient_specificity",
|
||||
route: "no_route",
|
||||
reason: reasonForNoRoute(noRouteReason)
|
||||
};
|
||||
}
|
||||
|
||||
function decideRouteForFragment(fragment: V2FamilyFragment): RouteDecisionV2 {
|
||||
const status = explicitRouteStatus(fragment);
|
||||
const noRouteReason = explicitNoRouteReason(fragment);
|
||||
const readiness = executionReadiness(fragment);
|
||||
const clarification = clarificationReason(fragment);
|
||||
const soft = softAssumptions(fragment);
|
||||
|
||||
if (status === "no_route") {
|
||||
return buildNoRouteDecision(fragment, noRouteReason);
|
||||
}
|
||||
|
||||
if (readiness === "needs_clarification" || readiness === "no_route") {
|
||||
return buildNoRouteDecision(fragment, noRouteReason ?? "insufficient_specificity");
|
||||
}
|
||||
|
||||
if (fragment.domain_relevance !== "in_scope") {
|
||||
return buildNoRouteDecision(fragment, "out_of_scope");
|
||||
}
|
||||
|
||||
if (fragment.flags.asks_for_exact_object_trace) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "live_mcp_drilldown",
|
||||
reason: "Exact object trace requested."
|
||||
};
|
||||
}
|
||||
|
||||
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "batch_refresh_then_store",
|
||||
reason: "Ranking/summary semantics require batch analytical route."
|
||||
};
|
||||
}
|
||||
|
||||
if (fragment.flags.has_multi_entity_scope && fragment.flags.asks_for_chain_explanation) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "hybrid_store_plus_live",
|
||||
reason: "Multi-entity causal chain requested."
|
||||
};
|
||||
}
|
||||
|
||||
if (fragment.flags.asks_for_rule_check && !fragment.flags.asks_for_chain_explanation) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_feature_risk",
|
||||
reason: "Rule-control check without causal decomposition."
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
fragment.flags.asks_for_anomaly_scan &&
|
||||
!fragment.flags.asks_for_ranking_or_top &&
|
||||
!(fragment.flags.has_multi_entity_scope && fragment.flags.asks_for_chain_explanation)
|
||||
) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_feature_risk",
|
||||
reason: "Anomaly scan without heavy ranking or causal chain."
|
||||
};
|
||||
}
|
||||
|
||||
if (status === "routed") {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_canonical",
|
||||
reason: "Routed fragment without deep analytical or causal signals."
|
||||
};
|
||||
}
|
||||
|
||||
return buildNoRouteDecision(fragment, "missing_mapping");
|
||||
}
|
||||
|
||||
function fallbackMessageFor(type: RouteHintSummaryV2["fallback"]["type"]): string | null {
|
||||
if (type === "out_of_scope") {
|
||||
return "Я работаю только с данными и бухгалтерским контуром текущей компании. Запрос вне доступной предметной области.";
|
||||
}
|
||||
if (type === "clarification") {
|
||||
return "Могу проверить это в контуре компании, но нужно уточнить период, документ, счет или участок учета.";
|
||||
}
|
||||
if (type === "partial") {
|
||||
return "Обработаю только часть запроса, которая относится к данным компании. Остальное выходит за пределы доступного контура.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function simulateDeterministicRouting(normalized: V2Family): RouteHintSummaryV2 {
|
||||
const decisions = normalized.fragments.map((fragment) => decideRouteForFragment(fragment));
|
||||
const inScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope").length;
|
||||
const outOfScopeCount = decisions.filter((item) => item.domain_relevance === "out_of_scope").length;
|
||||
const routedInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.route !== "no_route").length;
|
||||
const clarificationInScopeCount = decisions.filter(
|
||||
(item) => item.domain_relevance === "in_scope" && item.execution_readiness === "needs_clarification"
|
||||
).length;
|
||||
const noRouteInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.route === "no_route").length;
|
||||
|
||||
let fallbackType: RouteHintSummaryV2["fallback"]["type"] = "none";
|
||||
if (!normalized.message_in_scope || inScopeCount === 0) {
|
||||
fallbackType = "out_of_scope";
|
||||
} else if (routedInScopeCount === 0 && clarificationInScopeCount > 0) {
|
||||
fallbackType = "clarification";
|
||||
} else if (routedInScopeCount === 0 && noRouteInScopeCount > 0) {
|
||||
fallbackType = "clarification";
|
||||
} else if ((inScopeCount > 0 && outOfScopeCount > 0) || (routedInScopeCount > 0 && noRouteInScopeCount > 0)) {
|
||||
fallbackType = "partial";
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: normalized.message_in_scope,
|
||||
scope_confidence: normalized.scope_confidence,
|
||||
planner: {
|
||||
total_fragments: normalized.fragments.length,
|
||||
in_scope_fragments: inScopeCount,
|
||||
out_of_scope_fragments: outOfScopeCount,
|
||||
discarded_fragments: normalized.discarded_fragments.length,
|
||||
contains_multiple_tasks: normalized.contains_multiple_tasks
|
||||
},
|
||||
decisions,
|
||||
fallback: {
|
||||
type: fallbackType,
|
||||
message: fallbackMessageFor(fallbackType)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function toRouteHintSummary(normalized: NormalizedPayload): RouteHintSummary {
|
||||
if (
|
||||
normalized.schema_version === "normalized_query_v2" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_1" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_2"
|
||||
) {
|
||||
return simulateDeterministicRouting(normalized);
|
||||
}
|
||||
return toRouteHintSummaryV1(normalized);
|
||||
}
|
||||
|
||||
export function toRouterInput(normalized: NormalizedPayload): Record<string, unknown> {
|
||||
if (
|
||||
normalized.schema_version === "normalized_query_v2" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_1" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_2"
|
||||
) {
|
||||
return {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: normalized.message_in_scope,
|
||||
scope_confidence: normalized.scope_confidence,
|
||||
contains_multiple_tasks: normalized.contains_multiple_tasks,
|
||||
fragments: normalized.fragments.map((fragment) => ({
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
execution_readiness: "execution_readiness" in fragment ? fragment.execution_readiness : null,
|
||||
clarification_reason: "clarification_reason" in fragment ? fragment.clarification_reason : null,
|
||||
soft_assumption_used: "soft_assumption_used" in fragment ? fragment.soft_assumption_used : [],
|
||||
route_status: "route_status" in fragment ? fragment.route_status : null,
|
||||
no_route_reason: "no_route_reason" in fragment ? fragment.no_route_reason : null,
|
||||
flags: fragment.flags,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
confidence: fragment.confidence
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "legacy_v1",
|
||||
intent_class: normalized.intent_class,
|
||||
decision_flags: {
|
||||
needs_cross_entity_join: normalized.requires.needs_cross_entity_join,
|
||||
needs_causal_chain: normalized.requires.needs_causal_chain,
|
||||
needs_exact_object_trace: normalized.requires.needs_exact_object_trace,
|
||||
needs_ranking: normalized.requires.needs_ranking,
|
||||
needs_anomaly_summary: normalized.requires.needs_anomaly_summary,
|
||||
needs_runtime_truth: normalized.requires.needs_runtime_truth
|
||||
},
|
||||
route_hint: normalized.route_hint,
|
||||
confidence: normalized.confidence.overall,
|
||||
entities: {
|
||||
domain_entities: normalized.domain_entities,
|
||||
accounts_mentioned: normalized.accounts_mentioned,
|
||||
documents_mentioned: normalized.documents_mentioned,
|
||||
registers_mentioned: normalized.registers_mentioned
|
||||
},
|
||||
period_scope: normalized.period_scope
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020";
|
||||
import { SCHEMAS_DIR } from "../config";
|
||||
import type { NormalizedPayload, ValidationResult } from "../types/normalizer";
|
||||
|
||||
type SchemaVersion = "v1" | "v2" | "v2_0_1" | "v2_0_2";
|
||||
|
||||
const validators = new Map<SchemaVersion, ValidateFunction>();
|
||||
|
||||
function schemaPath(version: SchemaVersion): string {
|
||||
if (version === "v1") {
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v1.json");
|
||||
}
|
||||
if (version === "v2_0_1") {
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v2_0_1.json");
|
||||
}
|
||||
if (version === "v2_0_2") {
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v2_0_2.json");
|
||||
}
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v2.json");
|
||||
}
|
||||
|
||||
function loadValidator(version: SchemaVersion): ValidateFunction {
|
||||
const cached = validators.get(version);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const raw = fs.readFileSync(schemaPath(version), "utf-8");
|
||||
const schema = JSON.parse(raw);
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
const compiled = ajv.compile(schema);
|
||||
validators.set(version, compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function normalizeAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
|
||||
if (!errors || errors.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return errors.map((item) => `${item.instancePath || "/"} ${item.message ?? "validation error"}`.trim());
|
||||
}
|
||||
|
||||
export function validateNormalized(payload: unknown, schemaVersion: SchemaVersion = "v1"): ValidationResult {
|
||||
const check = loadValidator(schemaVersion);
|
||||
const passed = check(payload);
|
||||
return {
|
||||
passed: Boolean(passed),
|
||||
errors: passed ? [] : normalizeAjvErrors(check.errors)
|
||||
};
|
||||
}
|
||||
|
||||
export function assertNormalized(payload: unknown, schemaVersion: SchemaVersion = "v1"): NormalizedPayload {
|
||||
const validation = validateNormalized(payload, schemaVersion);
|
||||
if (!validation.passed) {
|
||||
throw new Error(`Invalid normalized JSON: ${validation.errors.join("; ")}`);
|
||||
}
|
||||
return payload as NormalizedPayload;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { EVAL_CASES_DIR, PRESETS_DIR, TRACES_DIR } from "../config";
|
||||
import { ensureDir, writeJsonFile } from "../utils/files";
|
||||
import type { PromptPreset } from "../types/preset";
|
||||
|
||||
export interface TraceRecord {
|
||||
trace_id: string;
|
||||
timestamp: string;
|
||||
model: string;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
case_id?: string;
|
||||
user_question_raw: string;
|
||||
context: Record<string, unknown>;
|
||||
request_payload_redacted: Record<string, unknown>;
|
||||
raw_model_response: unknown;
|
||||
parsed_normalized_json: unknown;
|
||||
validation_result: {
|
||||
passed: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
route_hint_summary?: unknown;
|
||||
route_hint: string | null;
|
||||
confidence: string | null;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
latency_ms: number;
|
||||
expected_route?: string;
|
||||
eval_label?: string;
|
||||
eval_mode?: string;
|
||||
request_count_for_case: number;
|
||||
}
|
||||
|
||||
export interface HistoryListItem {
|
||||
trace_id: string;
|
||||
timestamp: string;
|
||||
model: string;
|
||||
question_short: string;
|
||||
confidence: string | null;
|
||||
validation_passed: boolean;
|
||||
route_hint: string | null;
|
||||
save_status: "saved";
|
||||
}
|
||||
|
||||
function redactSecrets(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const output = { ...payload };
|
||||
delete output.apiKey;
|
||||
return output;
|
||||
}
|
||||
|
||||
export function saveTrace(record: TraceRecord): void {
|
||||
ensureDir(TRACES_DIR);
|
||||
const target = path.resolve(TRACES_DIR, `${record.trace_id}.json`);
|
||||
writeJsonFile(target, record);
|
||||
}
|
||||
|
||||
export function listTraces(limit = 100): HistoryListItem[] {
|
||||
ensureDir(TRACES_DIR);
|
||||
const files = fs
|
||||
.readdirSync(TRACES_DIR)
|
||||
.filter((item) => item.endsWith(".json"))
|
||||
.sort((a, b) => {
|
||||
const pa = path.resolve(TRACES_DIR, a);
|
||||
const pb = path.resolve(TRACES_DIR, b);
|
||||
return fs.statSync(pb).mtimeMs - fs.statSync(pa).mtimeMs;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return files.map((fileName) => {
|
||||
const raw = fs.readFileSync(path.resolve(TRACES_DIR, fileName), "utf-8");
|
||||
const item = JSON.parse(raw) as TraceRecord;
|
||||
return {
|
||||
trace_id: item.trace_id,
|
||||
timestamp: item.timestamp,
|
||||
model: item.model,
|
||||
question_short: item.user_question_raw.slice(0, 110),
|
||||
confidence: item.confidence,
|
||||
validation_passed: item.validation_result.passed,
|
||||
route_hint: item.route_hint,
|
||||
save_status: "saved"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getTrace(traceId: string): TraceRecord | null {
|
||||
ensureDir(TRACES_DIR);
|
||||
const target = path.resolve(TRACES_DIR, `${traceId}.json`);
|
||||
if (!fs.existsSync(target)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(target, "utf-8");
|
||||
return JSON.parse(raw) as TraceRecord;
|
||||
}
|
||||
|
||||
export function savePreset(preset: PromptPreset): void {
|
||||
ensureDir(PRESETS_DIR);
|
||||
writeJsonFile(path.resolve(PRESETS_DIR, `${preset.id}.json`), preset);
|
||||
}
|
||||
|
||||
export function listPresets(): PromptPreset[] {
|
||||
ensureDir(PRESETS_DIR);
|
||||
return fs
|
||||
.readdirSync(PRESETS_DIR)
|
||||
.filter((item) => item.endsWith(".json"))
|
||||
.map((fileName) => {
|
||||
const raw = fs.readFileSync(path.resolve(PRESETS_DIR, fileName), "utf-8");
|
||||
return JSON.parse(raw) as PromptPreset;
|
||||
})
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
export function saveEvalCase(casePayload: Record<string, unknown>): string {
|
||||
ensureDir(EVAL_CASES_DIR);
|
||||
const id = String(casePayload.case_id ?? `NQ-${Date.now()}`);
|
||||
writeJsonFile(path.resolve(EVAL_CASES_DIR, `${id}.json`), casePayload);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function redactRequestPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
return redactSecrets(payload);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type AgentStatus = "NONE" | "QUEUED" | "RUNNING" | "DONE" | "ERROR" | "STALE" | "CANCELLED";
|
||||
|
||||
export interface RunRecord {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
status: AgentStatus;
|
||||
initiator: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
taskId: string;
|
||||
runId: string;
|
||||
status: AgentStatus;
|
||||
payload: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
source: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TraceEvent {
|
||||
timestamp: string;
|
||||
level: "info" | "warn" | "error";
|
||||
service: string;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
taskId: string | null;
|
||||
eventType: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { NormalizeRequestPayload, NormalizeResponsePayload, RouteHintSummary } from "./normalizer";
|
||||
import type { AnswerStructureV11, EvidenceItem, InvestigationState } from "./stage1Contracts";
|
||||
|
||||
export type AssistantFallbackType = "none" | "out_of_scope" | "clarification" | "partial" | "unknown";
|
||||
export type AssistantReplyType =
|
||||
| "factual"
|
||||
| "factual_with_explanation"
|
||||
| "partial_coverage"
|
||||
| "clarification_required"
|
||||
| "out_of_scope"
|
||||
| "empty_but_valid"
|
||||
| "no_grounded_answer"
|
||||
| "route_mismatch_blocked"
|
||||
| "backend_error";
|
||||
export type RetrievalResultStatus = "ok" | "empty" | "partial" | "error";
|
||||
export type RetrievalResultType = "list" | "summary" | "object" | "chain" | "ranking";
|
||||
export type RetrievalConfidence = "high" | "medium" | "low";
|
||||
|
||||
export interface AssistantRequirement {
|
||||
requirement_id: string;
|
||||
source_fragment_id: string | null;
|
||||
requirement_text: string;
|
||||
subject_tokens: string[];
|
||||
status: "covered" | "partially_covered" | "uncovered" | "clarification_needed" | "out_of_scope";
|
||||
route: string | null;
|
||||
}
|
||||
|
||||
export interface RequirementCoverageReport {
|
||||
requirements_total: number;
|
||||
requirements_covered: number;
|
||||
requirements_uncovered: string[];
|
||||
requirements_partially_covered: string[];
|
||||
clarification_needed_for: string[];
|
||||
out_of_scope_requirements: string[];
|
||||
}
|
||||
|
||||
export interface AnswerGroundingCheck {
|
||||
status: "grounded" | "partial" | "no_grounded_answer" | "route_mismatch_blocked";
|
||||
route_subject_match: boolean;
|
||||
missing_requirements: string[];
|
||||
reasons: string[];
|
||||
why_included_summary: string[];
|
||||
selection_reason_summary: string[];
|
||||
}
|
||||
|
||||
export interface FollowupStateUsageDebug {
|
||||
applied: true;
|
||||
reason: string;
|
||||
state_turn_index: number;
|
||||
context_patch: {
|
||||
period_hint_from_state: boolean;
|
||||
expected_route_from_state: boolean;
|
||||
business_context_from_state: boolean;
|
||||
question_augmented: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssistantMessageRequestPayload {
|
||||
session_id?: string;
|
||||
user_message?: string;
|
||||
message?: string;
|
||||
mode?: "assistant" | string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
temperature?: number;
|
||||
maxOutputTokens?: number;
|
||||
promptVersion?: string;
|
||||
systemPrompt?: string;
|
||||
developerPrompt?: string;
|
||||
domainPrompt?: string;
|
||||
fewShotExamples?: string;
|
||||
context?: NormalizeRequestPayload["context"];
|
||||
useMock?: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedRetrievalResult {
|
||||
fragment_id: string;
|
||||
requirement_ids: string[];
|
||||
route: string;
|
||||
status: RetrievalResultStatus;
|
||||
result_type: RetrievalResultType;
|
||||
items: Array<Record<string, unknown>>;
|
||||
summary: Record<string, unknown>;
|
||||
evidence: EvidenceItem[];
|
||||
why_included: string[];
|
||||
selection_reason: string[];
|
||||
risk_factors: string[];
|
||||
business_interpretation: string[];
|
||||
confidence: RetrievalConfidence;
|
||||
limitations: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AssistantDebugPayload {
|
||||
trace_id: string;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
fallback_type: AssistantFallbackType;
|
||||
route_summary: RouteHintSummary | null;
|
||||
fragments: unknown[];
|
||||
requirements_extracted: AssistantRequirement[];
|
||||
coverage_report: RequirementCoverageReport;
|
||||
routes: Array<Record<string, unknown>>;
|
||||
retrieval_status: Array<{
|
||||
fragment_id: string;
|
||||
requirement_ids: string[];
|
||||
route: string;
|
||||
status: RetrievalResultStatus;
|
||||
result_type: RetrievalResultType;
|
||||
}>;
|
||||
retrieval_results: UnifiedRetrievalResult[];
|
||||
answer_grounding_check: AnswerGroundingCheck;
|
||||
dropped_intent_segments: string[];
|
||||
followup_state_usage?: FollowupStateUsageDebug;
|
||||
answer_structure_v11: AnswerStructureV11 | null;
|
||||
investigation_state_snapshot: InvestigationState | null;
|
||||
normalized: NormalizeResponsePayload["normalized"];
|
||||
}
|
||||
|
||||
export interface AssistantConversationItem {
|
||||
message_id: string;
|
||||
session_id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
reply_type: AssistantReplyType | null;
|
||||
created_at: string;
|
||||
trace_id: string | null;
|
||||
debug: AssistantDebugPayload | null;
|
||||
}
|
||||
|
||||
export interface AssistantSessionState {
|
||||
session_id: string;
|
||||
updated_at: string;
|
||||
items: AssistantConversationItem[];
|
||||
investigation_state: InvestigationState | null;
|
||||
}
|
||||
|
||||
export interface AssistantMessageResponsePayload {
|
||||
ok: true;
|
||||
session_id: string;
|
||||
assistant_reply: string;
|
||||
reply_type: AssistantReplyType;
|
||||
conversation_item: AssistantConversationItem;
|
||||
debug: AssistantDebugPayload;
|
||||
conversation: AssistantConversationItem[];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AssistantEvalBroadnessLevel, AssistantEvalQuestionType } from "./stage1Contracts";
|
||||
|
||||
export type EvalTarget = "normalizer" | "assistant_stage1";
|
||||
|
||||
export interface AssistantStage1SuiteCaseTurn {
|
||||
user_message: string;
|
||||
}
|
||||
|
||||
export interface AssistantStage1ExpectedHints {
|
||||
expected_reply_type?: string;
|
||||
expected_degraded_to?: "partial" | "clarification" | null;
|
||||
}
|
||||
|
||||
export interface AssistantStage1SuiteCase {
|
||||
case_id: string;
|
||||
scenario_tag: string;
|
||||
question_type: AssistantEvalQuestionType;
|
||||
broadness_level: AssistantEvalBroadnessLevel;
|
||||
turns: AssistantStage1SuiteCaseTurn[];
|
||||
expected_hints?: AssistantStage1ExpectedHints;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface AssistantStage1SuiteFile {
|
||||
suite_id: string;
|
||||
suite_version: string;
|
||||
schema_version?: string;
|
||||
scenario_count: number;
|
||||
case_ids: string[];
|
||||
cases: AssistantStage1SuiteCase[];
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
export type ConfidenceLevel = "high" | "medium" | "low";
|
||||
|
||||
export type IntentClass =
|
||||
| "heavy_analytical"
|
||||
| "cross_entity"
|
||||
| "drilldown_explain"
|
||||
| "rule_based_account_control"
|
||||
| "anomaly_probe"
|
||||
| "period_close_risk"
|
||||
| "ambiguous_human_query"
|
||||
| "simple_factual";
|
||||
|
||||
export type RouteHint =
|
||||
| "store_canonical"
|
||||
| "store_feature_risk"
|
||||
| "hybrid_store_plus_live"
|
||||
| "live_mcp_drilldown"
|
||||
| "batch_refresh_then_store";
|
||||
|
||||
export type DeterministicRouteHint = RouteHint | "no_route";
|
||||
|
||||
export type PromptVersion =
|
||||
| "normalizer_v1"
|
||||
| "normalizer_v1_1"
|
||||
| "normalizer_v1_1_1"
|
||||
| "normalizer_v1_1_2"
|
||||
| "normalizer_v1_1_2_1"
|
||||
| "normalizer_v2"
|
||||
| "normalizer_v2_0_1"
|
||||
| "normalizer_v2_0_2";
|
||||
|
||||
export type EvalRunMode = "standard" | "single-pass-strict";
|
||||
|
||||
export interface NormalizedQueryV1 {
|
||||
schema_version: "normalized_query_v1";
|
||||
user_question_raw: string;
|
||||
normalized_question: string;
|
||||
intent_class: IntentClass;
|
||||
business_problem_type: string;
|
||||
domain_entities: string[];
|
||||
accounts_mentioned: string[];
|
||||
documents_mentioned: string[];
|
||||
registers_mentioned: string[];
|
||||
period_scope: {
|
||||
type: "explicit" | "inferred" | "missing";
|
||||
value: string | null;
|
||||
confidence: ConfidenceLevel;
|
||||
};
|
||||
requires: {
|
||||
needs_cross_entity_join: boolean;
|
||||
needs_causal_chain: boolean;
|
||||
needs_exact_object_trace: boolean;
|
||||
needs_ranking: boolean;
|
||||
needs_anomaly_summary: boolean;
|
||||
needs_runtime_truth: boolean;
|
||||
needs_period_cut: boolean;
|
||||
needs_evidence: boolean;
|
||||
};
|
||||
expected_output_shape:
|
||||
| "ranked_list"
|
||||
| "evidence_chain"
|
||||
| "anomaly_summary"
|
||||
| "point_answer"
|
||||
| "reconciliation_report"
|
||||
| "prioritized_review_list";
|
||||
route_hint: RouteHint;
|
||||
ambiguities: Array<{
|
||||
field: string;
|
||||
reason: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
}>;
|
||||
confidence: {
|
||||
overall: ConfidenceLevel;
|
||||
intent_class: ConfidenceLevel;
|
||||
route_hint: ConfidenceLevel;
|
||||
};
|
||||
}
|
||||
|
||||
export type V2DomainRelevance = "in_scope" | "out_of_scope" | "unclear";
|
||||
export type V2BusinessScope = "company_specific_accounting" | "generic_accounting" | "offtopic" | "unclear";
|
||||
export type ExecutionReadiness = "executable" | "executable_with_soft_assumptions" | "needs_clarification" | "no_route";
|
||||
export type SoftAssumption =
|
||||
| "period_from_session_context"
|
||||
| "company_scope_defaulted"
|
||||
| "problem_scan_mode_enabled";
|
||||
export type RouteStatus = "routed" | "no_route";
|
||||
export type NoRouteReason = "out_of_scope" | "insufficient_specificity" | "missing_mapping" | "unsupported_fragment_type";
|
||||
|
||||
export interface NormalizedFragmentV2 {
|
||||
fragment_id: string;
|
||||
raw_fragment_text: string;
|
||||
normalized_fragment_text: string;
|
||||
domain_relevance: V2DomainRelevance;
|
||||
business_scope: V2BusinessScope;
|
||||
entity_hints: string[];
|
||||
account_hints: string[];
|
||||
document_hints: string[];
|
||||
register_hints: string[];
|
||||
time_scope: {
|
||||
type: "explicit" | "inferred" | "missing";
|
||||
value: string | null;
|
||||
confidence: ConfidenceLevel;
|
||||
};
|
||||
flags: {
|
||||
has_multi_entity_scope: boolean;
|
||||
asks_for_chain_explanation: boolean;
|
||||
asks_for_ranking_or_top: boolean;
|
||||
asks_for_period_summary: boolean;
|
||||
asks_for_rule_check: boolean;
|
||||
asks_for_anomaly_scan: boolean;
|
||||
asks_for_exact_object_trace: boolean;
|
||||
asks_for_evidence: boolean;
|
||||
mentions_period_close_context: boolean;
|
||||
};
|
||||
candidate_labels: IntentClass[];
|
||||
confidence: ConfidenceLevel;
|
||||
}
|
||||
|
||||
export interface NormalizedFragmentV2_0_1 extends NormalizedFragmentV2 {
|
||||
execution_readiness: ExecutionReadiness;
|
||||
clarification_reason: string | null;
|
||||
soft_assumption_used: SoftAssumption[];
|
||||
}
|
||||
|
||||
export interface NormalizedFragmentV2_0_2 extends NormalizedFragmentV2_0_1 {
|
||||
route_status: RouteStatus;
|
||||
no_route_reason: NoRouteReason | null;
|
||||
}
|
||||
|
||||
export interface DiscardedFragmentV2 {
|
||||
raw_fragment_text: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface NormalizedQueryV2 {
|
||||
schema_version: "normalized_query_v2";
|
||||
user_message_raw: string;
|
||||
message_in_scope: boolean;
|
||||
scope_confidence: ConfidenceLevel;
|
||||
contains_multiple_tasks: boolean;
|
||||
fragments: NormalizedFragmentV2[];
|
||||
discarded_fragments: DiscardedFragmentV2[];
|
||||
global_notes: {
|
||||
needs_clarification: boolean;
|
||||
clarification_reason: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NormalizedQueryV2_0_1 {
|
||||
schema_version: "normalized_query_v2_0_1";
|
||||
user_message_raw: string;
|
||||
message_in_scope: boolean;
|
||||
scope_confidence: ConfidenceLevel;
|
||||
contains_multiple_tasks: boolean;
|
||||
fragments: NormalizedFragmentV2_0_1[];
|
||||
discarded_fragments: DiscardedFragmentV2[];
|
||||
global_notes: {
|
||||
needs_clarification: boolean;
|
||||
clarification_reason: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NormalizedQueryV2_0_2 {
|
||||
schema_version: "normalized_query_v2_0_2";
|
||||
user_message_raw: string;
|
||||
message_in_scope: boolean;
|
||||
scope_confidence: ConfidenceLevel;
|
||||
contains_multiple_tasks: boolean;
|
||||
fragments: NormalizedFragmentV2_0_2[];
|
||||
discarded_fragments: DiscardedFragmentV2[];
|
||||
global_notes: {
|
||||
needs_clarification: boolean;
|
||||
clarification_reason: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RouteHintSummaryV1 {
|
||||
mode: "legacy_v1";
|
||||
intent_class: IntentClass;
|
||||
route_hint: RouteHint;
|
||||
confidence: ConfidenceLevel;
|
||||
decision_flags: {
|
||||
needs_cross_entity_join: boolean;
|
||||
needs_causal_chain: boolean;
|
||||
needs_exact_object_trace: boolean;
|
||||
needs_ranking: boolean;
|
||||
needs_anomaly_summary: boolean;
|
||||
needs_runtime_truth: boolean;
|
||||
needs_period_cut: boolean;
|
||||
needs_evidence: boolean;
|
||||
};
|
||||
period_scope: NormalizedQueryV1["period_scope"];
|
||||
entities: {
|
||||
domain_entities: string[];
|
||||
accounts_mentioned: string[];
|
||||
documents_mentioned: string[];
|
||||
registers_mentioned: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface RouteDecisionV2 {
|
||||
fragment_id: string;
|
||||
domain_relevance: V2DomainRelevance;
|
||||
business_scope: V2BusinessScope;
|
||||
candidate_labels: IntentClass[];
|
||||
decision_flags: NormalizedFragmentV2["flags"];
|
||||
execution_readiness?: ExecutionReadiness | null;
|
||||
clarification_reason?: string | null;
|
||||
soft_assumption_used?: SoftAssumption[];
|
||||
route_status?: RouteStatus | null;
|
||||
no_route_reason?: NoRouteReason | null;
|
||||
route: DeterministicRouteHint;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RouteHintSummaryV2 {
|
||||
mode: "deterministic_v2";
|
||||
message_in_scope: boolean;
|
||||
scope_confidence: ConfidenceLevel;
|
||||
planner: {
|
||||
total_fragments: number;
|
||||
in_scope_fragments: number;
|
||||
out_of_scope_fragments: number;
|
||||
discarded_fragments: number;
|
||||
contains_multiple_tasks: boolean;
|
||||
};
|
||||
decisions: RouteDecisionV2[];
|
||||
fallback: {
|
||||
type: "none" | "out_of_scope" | "clarification" | "partial";
|
||||
message: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type RouteHintSummary = RouteHintSummaryV1 | RouteHintSummaryV2;
|
||||
export type NormalizedPayload = NormalizedQueryV1 | NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
|
||||
|
||||
export interface NormalizeRequestPayload {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
temperature?: number;
|
||||
maxOutputTokens?: number;
|
||||
promptVersion?: PromptVersion | string;
|
||||
systemPrompt?: string;
|
||||
developerPrompt?: string;
|
||||
domainPrompt?: string;
|
||||
schemaVersion?: string;
|
||||
userQuestion: string;
|
||||
context?: {
|
||||
period_hint?: string;
|
||||
business_context?: string;
|
||||
expected_route?: RouteHint;
|
||||
eval_label?: string;
|
||||
case_id?: string;
|
||||
eval_mode?: EvalRunMode;
|
||||
};
|
||||
fewShotExamples?: string;
|
||||
saveAsTestCase?: boolean;
|
||||
useMock?: boolean;
|
||||
retryPolicy?: "default" | "single-pass-strict";
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
passed: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface NormalizeResponsePayload {
|
||||
trace_id: string;
|
||||
ok: boolean;
|
||||
normalized: NormalizedPayload | null;
|
||||
route_hint_summary: RouteHintSummary | null;
|
||||
raw_model_output: unknown;
|
||||
validation: ValidationResult;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
latency_ms: number;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
request_count_for_case: number;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type PromptVersion =
|
||||
| "normalizer_v1"
|
||||
| "normalizer_v1_1"
|
||||
| "normalizer_v1_1_1"
|
||||
| "normalizer_v1_1_2"
|
||||
| "normalizer_v1_1_2_1"
|
||||
| "normalizer_v2"
|
||||
| "normalizer_v2_0_1"
|
||||
| "normalizer_v2_0_2";
|
||||
|
||||
export interface PromptPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
prompt_version: PromptVersion | string;
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
schemaNotes?: string;
|
||||
fewShotExamples?: string;
|
||||
}
|
||||
|
||||
export interface PromptBundle {
|
||||
prompt_version: PromptVersion | string;
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
schemaNotes: string;
|
||||
fewShotExamples: string;
|
||||
combinedDeveloperPrompt: string;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
export const INVESTIGATION_STATE_SCHEMA_VERSION = "investigation_state_v1" as const;
|
||||
export const ANSWER_STRUCTURE_SCHEMA_VERSION = "answer_structure_v1_1" as const;
|
||||
export const ASSISTANT_EVAL_RECORD_SCHEMA_VERSION = "assistant_eval_record_v0_1" as const;
|
||||
export const EVIDENCE_SOURCE_REF_SCHEMA_VERSION = "evidence_source_ref_v1" as const;
|
||||
|
||||
export const INVESTIGATION_MAX_EVIDENCE_REFS = 24;
|
||||
export const INVESTIGATION_MAX_UNCERTAINTIES = 12;
|
||||
export const INVESTIGATION_MAX_PRIMARY_ACCOUNTS = 8;
|
||||
export const INVESTIGATION_MAX_REQUIREMENT_LINKS = 8;
|
||||
|
||||
export type InvestigationNarrowingStatus = "unknown" | "not_needed" | "applied" | "needs_clarification" | "broad_guarded";
|
||||
export type InvestigationQueryModeHint = "direct_answer" | "investigation_candidate";
|
||||
export type InvestigationLastAnswerMode =
|
||||
| "factual"
|
||||
| "factual_with_explanation"
|
||||
| "partial_coverage"
|
||||
| "clarification_required"
|
||||
| "out_of_scope"
|
||||
| "empty_but_valid"
|
||||
| "no_grounded_answer"
|
||||
| "route_mismatch_blocked"
|
||||
| "backend_error"
|
||||
| null;
|
||||
|
||||
export interface InvestigationStateFocus {
|
||||
domain: string | null;
|
||||
period: string | null;
|
||||
primary_accounts: string[];
|
||||
active_query_subject: string | null;
|
||||
}
|
||||
|
||||
export interface InvestigationFollowupContext {
|
||||
previous_question_id: string | null;
|
||||
last_user_message: string;
|
||||
referenced_requirement_ids: string[];
|
||||
}
|
||||
|
||||
export interface InvestigationState {
|
||||
schema_version: typeof INVESTIGATION_STATE_SCHEMA_VERSION;
|
||||
session_id: string;
|
||||
status: "idle" | "active";
|
||||
turn_index: number;
|
||||
updated_at: string;
|
||||
question_id: string | null;
|
||||
focus: InvestigationStateFocus;
|
||||
narrowing_status: InvestigationNarrowingStatus;
|
||||
evidence_refs: string[];
|
||||
open_uncertainties: string[];
|
||||
last_answer_mode: InvestigationLastAnswerMode;
|
||||
followup_context: InvestigationFollowupContext | null;
|
||||
query_mode_hint: InvestigationQueryModeHint;
|
||||
}
|
||||
|
||||
export type EvidenceSourceNamespace = "snapshot_2020" | "assistant_derived" | "unknown";
|
||||
|
||||
export interface EvidencePointer {
|
||||
fragment_id: string;
|
||||
route: string;
|
||||
source: {
|
||||
namespace: EvidenceSourceNamespace;
|
||||
entity: string;
|
||||
id: string;
|
||||
period: string | null;
|
||||
};
|
||||
locator: {
|
||||
field_path: string | null;
|
||||
item_index: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EvidenceSourceRef {
|
||||
schema_version: typeof EVIDENCE_SOURCE_REF_SCHEMA_VERSION;
|
||||
namespace: EvidenceSourceNamespace;
|
||||
entity: string;
|
||||
id: string;
|
||||
period: string | null;
|
||||
canonical_ref: string;
|
||||
}
|
||||
|
||||
export type EvidenceKind = "factual_anchor" | "aggregation" | "anomaly_signal" | "mechanism_link" | "limitation_note";
|
||||
export type EvidenceConfidence = "high" | "medium" | "low";
|
||||
export type EvidenceLimitationReasonCode =
|
||||
| "snapshot_only"
|
||||
| "heuristic_inference"
|
||||
| "missing_mechanism"
|
||||
| "weak_source_mapping"
|
||||
| "insufficient_detail"
|
||||
| "unknown";
|
||||
|
||||
export interface EvidenceLimitation {
|
||||
reason_code: EvidenceLimitationReasonCode;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
evidence_id: string;
|
||||
claim_ref: string;
|
||||
source_type: "retrieval_item" | "retrieval_summary" | "derived";
|
||||
source_ref: EvidenceSourceRef;
|
||||
pointer: EvidencePointer;
|
||||
evidence_kind: EvidenceKind;
|
||||
mechanism_note: string | null;
|
||||
confidence: EvidenceConfidence;
|
||||
limitation: EvidenceLimitation | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AnswerStructureV11 {
|
||||
schema_version: typeof ANSWER_STRUCTURE_SCHEMA_VERSION;
|
||||
answer_summary: string;
|
||||
direct_answer: string;
|
||||
mechanism_block: {
|
||||
status: "grounded" | "limited" | "unresolved";
|
||||
mechanism_notes: string[];
|
||||
limitation_reason_codes: EvidenceLimitationReasonCode[];
|
||||
};
|
||||
evidence_block: {
|
||||
evidence_ids: string[];
|
||||
source_refs?: string[];
|
||||
mechanism_notes: string[];
|
||||
coverage_note: string;
|
||||
claim_evidence_links?: Array<{
|
||||
claim_ref: string;
|
||||
evidence_ids: string[];
|
||||
}>;
|
||||
};
|
||||
uncertainty_block: {
|
||||
open_uncertainties: string[];
|
||||
limitations: string[];
|
||||
};
|
||||
next_step_block: {
|
||||
recommended_actions: string[];
|
||||
clarification_questions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type AssistantEvalQuestionType = "direct" | "broad" | "followup" | "multi_intent" | "clarification" | "out_of_scope";
|
||||
export type AssistantEvalBroadnessLevel = "low" | "medium" | "high";
|
||||
export type AssistantEvalNarrowingResult = "not_required" | "applied" | "clarification_requested" | "failed";
|
||||
|
||||
export interface AssistantEvalMetricVector {
|
||||
retrieval_differentiation_rate: number | null;
|
||||
generic_explanation_rate: number | null;
|
||||
accountant_actionability_score: number | null;
|
||||
false_confidence_rate: number | null;
|
||||
broad_answer_rate: number | null;
|
||||
mechanism_specificity_score: number | null;
|
||||
followup_context_retention_score: number | null;
|
||||
}
|
||||
|
||||
export interface AssistantEvalRecord {
|
||||
schema_version: typeof ASSISTANT_EVAL_RECORD_SCHEMA_VERSION;
|
||||
created_at: string;
|
||||
case_id: string;
|
||||
scenario_tag?: string;
|
||||
session_id: string | null;
|
||||
trace_id: string | null;
|
||||
question_type: AssistantEvalQuestionType;
|
||||
broadness_level: AssistantEvalBroadnessLevel;
|
||||
narrowing_result: AssistantEvalNarrowingResult;
|
||||
evidence_quality_score: number | null;
|
||||
genericness_score: number | null;
|
||||
accountant_usefulness_score: number | null;
|
||||
accountant_metrics: AssistantEvalMetricVector;
|
||||
raw_signals?: Record<string, unknown>;
|
||||
metric_subscores?: Partial<Record<keyof AssistantEvalMetricVector, number | null>>;
|
||||
limitations?: string[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export type AccountantMetricName = keyof AssistantEvalMetricVector;
|
||||
export type AccountantMetricRubricScore = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export interface AccountantMetricRubricBand {
|
||||
score: AccountantMetricRubricScore;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const ACCOUNTANT_SCORING_RUBRIC_V01: Record<AccountantMetricName, AccountantMetricRubricBand[]> = {
|
||||
retrieval_differentiation_rate: [
|
||||
{ score: 0, label: "No Differentiation", description: "Ответы почти одинаковые для разных кейсов." },
|
||||
{ score: 3, label: "Partial Differentiation", description: "Различия есть, но по механизмам недостаточно стабильны." },
|
||||
{ score: 5, label: "Strong Differentiation", description: "Ответы устойчиво различаются по предмету и механизму." }
|
||||
],
|
||||
generic_explanation_rate: [
|
||||
{ score: 0, label: "Mostly Generic", description: "Преобладают общие объяснения без локальной опоры." },
|
||||
{ score: 3, label: "Mixed", description: "Есть и предметные, и общие блоки объяснения." },
|
||||
{ score: 5, label: "Mostly Specific", description: "Объяснение в основном case-specific и операбельно." }
|
||||
],
|
||||
accountant_actionability_score: [
|
||||
{ score: 0, label: "Not Actionable", description: "Бухгалтер не получает понятного следующего шага." },
|
||||
{ score: 3, label: "Partially Actionable", description: "Следующий шаг есть, но недостаточно конкретен." },
|
||||
{ score: 5, label: "Actionable", description: "Есть конкретные проверяемые действия и приоритет." }
|
||||
],
|
||||
false_confidence_rate: [
|
||||
{ score: 0, label: "High False Confidence", description: "Часто дается уверенный тон при слабой опоре." },
|
||||
{ score: 3, label: "Moderate False Confidence", description: "Периодически встречается избыточная уверенность." },
|
||||
{ score: 5, label: "Low False Confidence", description: "Неопределенность обозначается честно и вовремя." }
|
||||
],
|
||||
broad_answer_rate: [
|
||||
{ score: 0, label: "Broad by Default", description: "Часто даются широкие ответы без controlled narrowing." },
|
||||
{ score: 3, label: "Partially Controlled", description: "Broad-ответы периодически сужаются, но не всегда." },
|
||||
{ score: 5, label: "Controlled", description: "Broad-ответы редки и сопровождаются корректным сужением." }
|
||||
],
|
||||
mechanism_specificity_score: [
|
||||
{ score: 0, label: "No Mechanism", description: "Есть только лейблы без механики поломки." },
|
||||
{ score: 3, label: "Partial Mechanism", description: "Механизм описан частично, без полной связки." },
|
||||
{ score: 5, label: "Mechanism-Aware", description: "Механизм поломки и опорные объекты связаны явно." }
|
||||
],
|
||||
followup_context_retention_score: [
|
||||
{ score: 0, label: "Context Lost", description: "Follow-up теряет фокус текущего разбора." },
|
||||
{ score: 3, label: "Context Partial", description: "Фокус удерживается частично, с дрейфом." },
|
||||
{ score: 5, label: "Context Retained", description: "Follow-up устойчиво держит предмет и ограничения." }
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from "fs";
|
||||
|
||||
export function ensureDir(path: string): void {
|
||||
if (!fs.existsSync(path)) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
try {
|
||||
const raw = fs.readFileSync(path, "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonFile(path: string, value: unknown): void {
|
||||
fs.writeFileSync(path, JSON.stringify(value, null, 2), "utf-8");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly code: string;
|
||||
public readonly status: number;
|
||||
public readonly details?: unknown;
|
||||
|
||||
constructor(code: string, message: string, status = 400, details?: unknown) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function ok<T>(res: Response, payload: T): Response<T> {
|
||||
return res.status(200).json(payload);
|
||||
}
|
||||
|
||||
export function created<T>(res: Response, payload: T): Response<T> {
|
||||
return res.status(201).json(payload);
|
||||
}
|
||||
|
||||
export function errorMiddleware(err: unknown, _req: Request, res: Response, _next: NextFunction): void {
|
||||
if (err instanceof ApiError) {
|
||||
res.status(err.status).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: err.details ?? null
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = err instanceof Error ? err.message : "Unknown error";
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: fallback,
|
||||
details: null
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface JsonLogEntry {
|
||||
timestamp: string;
|
||||
level: "info" | "warn" | "error";
|
||||
service: string;
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
taskId?: string;
|
||||
eventType?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
const REDACT_KEYS = new Set(["apiKey", "authorization", "Authorization", "openai_api_key", "OPENAI_API_KEY"]);
|
||||
|
||||
function redactObject(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(redactObject);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const source = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, field] of Object.entries(source)) {
|
||||
if (REDACT_KEYS.has(key)) {
|
||||
out[key] = "***REDACTED***";
|
||||
} else {
|
||||
out[key] = redactObject(field);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function logJson(entry: JsonLogEntry): void {
|
||||
const safe = {
|
||||
...entry,
|
||||
details: redactObject(entry.details)
|
||||
};
|
||||
// Structured JSON logs for diagnostics/trace aggregation.
|
||||
process.stdout.write(JSON.stringify(safe) + "\n");
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { composeAssistantAnswer } from "../src/services/answerComposer";
|
||||
import type { UnifiedRetrievalResult } from "../src/types/assistant";
|
||||
|
||||
const FLAG_KEYS = [
|
||||
"FEATURE_ASSISTANT_ANSWER_POLICY_V11",
|
||||
"FEATURE_ASSISTANT_BROAD_GUARD_V1",
|
||||
"FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1",
|
||||
"FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1"
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(
|
||||
FLAG_KEYS.map((key) => [key, process.env[key]])
|
||||
);
|
||||
|
||||
function restoreFlags(): void {
|
||||
for (const key of FLAG_KEYS) {
|
||||
const original = ORIGINAL_FLAGS[key];
|
||||
if (original === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createAppWithFlags(flags: {
|
||||
answerPolicy: "0" | "1";
|
||||
broad: "0" | "1";
|
||||
evidenceGate: "0" | "1";
|
||||
antiGeneric: "0" | "1";
|
||||
}) {
|
||||
process.env.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = flags.answerPolicy;
|
||||
process.env.FEATURE_ASSISTANT_BROAD_GUARD_V1 = flags.broad;
|
||||
process.env.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = flags.evidenceGate;
|
||||
process.env.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = flags.antiGeneric;
|
||||
vi.resetModules();
|
||||
const { createApp } = await import("../src/server");
|
||||
return createApp();
|
||||
}
|
||||
|
||||
function firstRoutedResult(body: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const retrieval = Array.isArray((body.debug as { retrieval_results?: unknown[] } | undefined)?.retrieval_results)
|
||||
? ((body.debug as { retrieval_results?: unknown[] }).retrieval_results as Record<string, unknown>[])
|
||||
: [];
|
||||
return retrieval.find((item) => String(item.route ?? "") !== "no_route") ?? null;
|
||||
}
|
||||
|
||||
describe.sequential("assistant answer policy v1.1", () => {
|
||||
afterEach(() => {
|
||||
restoreFlags();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("keeps focused grounded answer direct and useful", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
answerPolicy: "1",
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Проверь счет 97 за 2020-06 по документам и выдели отклонения."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.body.assistant_reply)).toContain("Answer summary:");
|
||||
expect(String(response.body.assistant_reply)).toContain("Direct answer:");
|
||||
expect(String(response.body.assistant_reply)).toContain("Mechanism block:");
|
||||
|
||||
const structure = response.body.debug?.answer_structure_v11;
|
||||
expect(structure?.mechanism_block).toBeTruthy();
|
||||
expect(["grounded", "limited", "unresolved"]).toContain(structure?.mechanism_block?.status);
|
||||
|
||||
const routed = firstRoutedResult(response.body);
|
||||
const summary = (routed?.summary as Record<string, unknown>) ?? {};
|
||||
expect(summary.minimum_evidence_failed).not.toBe(true);
|
||||
});
|
||||
|
||||
it("renders broad partial answer with explicit limitations and concrete next steps", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
answerPolicy: "1",
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Покажи в целом общую картину и топ рисков по документам за июнь 2020."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.reply_type).toBe("partial_coverage");
|
||||
expect(String(response.body.assistant_reply)).toContain("Uncertainty block:");
|
||||
expect(String(response.body.assistant_reply)).toContain("Next step block:");
|
||||
|
||||
const structure = response.body.debug?.answer_structure_v11;
|
||||
expect(structure?.answer_summary).toContain("частич");
|
||||
expect(Array.isArray(structure?.uncertainty_block?.limitations)).toBe(true);
|
||||
expect(structure?.uncertainty_block?.limitations?.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(structure?.next_step_block?.recommended_actions)).toBe(true);
|
||||
expect(structure?.next_step_block?.recommended_actions?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses domain-specific clarification prompts when support is insufficient", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
answerPolicy: "1",
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Что не так по документ #123?"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.reply_type).toBe("clarification_required");
|
||||
|
||||
const structure = response.body.debug?.answer_structure_v11;
|
||||
const clarifications = structure?.next_step_block?.clarification_questions ?? [];
|
||||
expect(Array.isArray(clarifications)).toBe(true);
|
||||
expect(clarifications.length).toBeGreaterThan(0);
|
||||
expect(clarifications.some((item: string) => /период|счет|документ|контрагент/i.test(String(item)))).toBe(true);
|
||||
expect(String(response.body.assistant_reply)).toContain("clarify:");
|
||||
});
|
||||
|
||||
it("does not fabricate mechanism when mechanism_note is unresolved", () => {
|
||||
const retrievalResult: UnifiedRetrievalResult = {
|
||||
fragment_id: "F1",
|
||||
requirement_ids: ["R1"],
|
||||
route: "store_feature_risk",
|
||||
status: "ok",
|
||||
result_type: "list",
|
||||
items: [{ source_entity: "Document", source_id: "doc-weak-1" }],
|
||||
summary: {
|
||||
broad_query_detected: false,
|
||||
broad_result_flag: false,
|
||||
minimum_evidence_failed: false,
|
||||
narrowing_strength: "strong"
|
||||
},
|
||||
evidence: [
|
||||
{
|
||||
evidence_id: "ev-weak",
|
||||
claim_ref: "requirement:R1",
|
||||
source_type: "retrieval_item",
|
||||
source_ref: {
|
||||
schema_version: "evidence_source_ref_v1",
|
||||
namespace: "snapshot_2020",
|
||||
entity: "document",
|
||||
id: "doc-weak-1",
|
||||
period: "2020-06",
|
||||
canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|doc-weak-1|2020-06"
|
||||
},
|
||||
pointer: {
|
||||
fragment_id: "F1",
|
||||
route: "store_feature_risk",
|
||||
source: {
|
||||
namespace: "snapshot_2020",
|
||||
entity: "document",
|
||||
id: "doc-weak-1",
|
||||
period: "2020-06"
|
||||
},
|
||||
locator: {
|
||||
field_path: "risk_score",
|
||||
item_index: 0
|
||||
}
|
||||
},
|
||||
evidence_kind: "anomaly_signal",
|
||||
mechanism_note: null,
|
||||
confidence: "low",
|
||||
limitation: {
|
||||
reason_code: "missing_mechanism",
|
||||
note: "Mechanism could not be resolved."
|
||||
},
|
||||
payload: {
|
||||
risk_score: 1
|
||||
}
|
||||
}
|
||||
],
|
||||
why_included: ["synthetic-test"],
|
||||
selection_reason: ["synthetic-test"],
|
||||
risk_factors: [],
|
||||
business_interpretation: [],
|
||||
confidence: "low",
|
||||
limitations: ["Weak mechanism evidence."],
|
||||
errors: []
|
||||
};
|
||||
|
||||
const output = composeAssistantAnswer({
|
||||
userMessage: "Проверь риск по документу doc-weak-1 за 2020-06.",
|
||||
routeSummary: {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "high",
|
||||
planner: {
|
||||
total_fragments: 1,
|
||||
in_scope_fragments: 1,
|
||||
out_of_scope_fragments: 0,
|
||||
discarded_fragments: 0,
|
||||
contains_multiple_tasks: false
|
||||
},
|
||||
decisions: [],
|
||||
fallback: {
|
||||
type: "none",
|
||||
message: null
|
||||
}
|
||||
},
|
||||
retrievalResults: [retrievalResult],
|
||||
requirements: [
|
||||
{
|
||||
requirement_id: "R1",
|
||||
source_fragment_id: "F1",
|
||||
requirement_text: "Проверить риск документа",
|
||||
subject_tokens: ["документ"],
|
||||
status: "covered",
|
||||
route: "store_feature_risk"
|
||||
}
|
||||
],
|
||||
coverageReport: {
|
||||
requirements_total: 1,
|
||||
requirements_covered: 1,
|
||||
requirements_uncovered: [],
|
||||
requirements_partially_covered: [],
|
||||
clarification_needed_for: [],
|
||||
out_of_scope_requirements: []
|
||||
},
|
||||
groundingCheck: {
|
||||
status: "grounded",
|
||||
route_subject_match: true,
|
||||
missing_requirements: [],
|
||||
reasons: [],
|
||||
why_included_summary: ["synthetic-test"],
|
||||
selection_reason_summary: ["synthetic-test"]
|
||||
},
|
||||
enableAnswerPolicyV11: true
|
||||
});
|
||||
|
||||
expect(output.answer_structure_v11?.mechanism_block?.status).toBe("unresolved");
|
||||
expect(output.answer_structure_v11?.mechanism_block?.mechanism_notes).toEqual([]);
|
||||
expect(output.answer_structure_v11?.mechanism_block?.limitation_reason_codes).toContain("missing_mechanism");
|
||||
expect(output.assistant_reply).toContain("mechanism_note is intentionally omitted");
|
||||
});
|
||||
|
||||
it("preserves legacy reply path when policy flag is OFF", async () => {
|
||||
const appLegacy = await createAppWithFlags({
|
||||
answerPolicy: "0",
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const legacy = await request(appLegacy).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Проверь счет 97 за 2020-06 по документам и выдели отклонения."
|
||||
});
|
||||
|
||||
expect(legacy.status).toBe(200);
|
||||
expect(String(legacy.body.assistant_reply)).not.toContain("Answer summary:");
|
||||
|
||||
const appPolicy = await createAppWithFlags({
|
||||
answerPolicy: "1",
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const policy = await request(appPolicy).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Проверь счет 97 за 2020-06 по документам и выдели отклонения."
|
||||
});
|
||||
|
||||
expect(policy.status).toBe(200);
|
||||
expect(String(policy.body.assistant_reply)).toContain("Answer summary:");
|
||||
expect(String(policy.body.assistant_reply)).not.toBe(String(legacy.body.assistant_reply));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const FLAG_KEYS = [
|
||||
"FEATURE_ASSISTANT_BROAD_GUARD_V1",
|
||||
"FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1",
|
||||
"FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1"
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(
|
||||
FLAG_KEYS.map((key) => [key, process.env[key]])
|
||||
);
|
||||
|
||||
function restoreFlags(): void {
|
||||
for (const key of FLAG_KEYS) {
|
||||
const original = ORIGINAL_FLAGS[key];
|
||||
if (original === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createAppWithFlags(flags: {
|
||||
broad: "0" | "1";
|
||||
evidenceGate: "0" | "1";
|
||||
antiGeneric: "0" | "1";
|
||||
}) {
|
||||
process.env.FEATURE_ASSISTANT_BROAD_GUARD_V1 = flags.broad;
|
||||
process.env.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = flags.evidenceGate;
|
||||
process.env.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = flags.antiGeneric;
|
||||
vi.resetModules();
|
||||
const { createApp } = await import("../src/server");
|
||||
return createApp();
|
||||
}
|
||||
|
||||
function firstRoutedResult(body: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const retrieval = Array.isArray((body.debug as { retrieval_results?: unknown[] } | undefined)?.retrieval_results)
|
||||
? ((body.debug as { retrieval_results?: unknown[] }).retrieval_results as Record<string, unknown>[])
|
||||
: [];
|
||||
return retrieval.find((item) => String(item.route ?? "") !== "no_route") ?? null;
|
||||
}
|
||||
|
||||
describe.sequential("assistant broad guard", () => {
|
||||
afterEach(() => {
|
||||
restoreFlags();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("keeps focused queries from degrading under broad guard", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Проверь НДС по счету 19 за 2020-06 и рискованные записи по документам."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const routed = firstRoutedResult(response.body);
|
||||
expect(routed).toBeTruthy();
|
||||
|
||||
const summary = (routed?.summary as Record<string, unknown>) ?? {};
|
||||
expect(summary.broad_guard_applied).toBe(false);
|
||||
expect(summary.minimum_evidence_failed).toBe(false);
|
||||
expect(response.body.reply_type).not.toBe("clarification_required");
|
||||
});
|
||||
|
||||
it("degrades broad ranking output to partial instead of deceptively strong factual", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Покажи в целом общую картину и топ рисков по документам за июнь 2020."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const routed = firstRoutedResult(response.body);
|
||||
expect(routed).toBeTruthy();
|
||||
expect(routed?.route).toBe("batch_refresh_then_store");
|
||||
|
||||
const summary = (routed?.summary as Record<string, unknown>) ?? {};
|
||||
expect(summary.broad_guard_applied).toBe(true);
|
||||
expect(summary.minimum_evidence_failed).toBe(true);
|
||||
expect(summary.anti_generic_guard_applied).toBe(true);
|
||||
expect(summary.broad_result_flag).toBe(true);
|
||||
expect(["partial_coverage", "clarification_required"]).toContain(String(response.body.reply_type));
|
||||
expect(response.body.reply_type).not.toBe("factual_with_explanation");
|
||||
});
|
||||
|
||||
it("returns clarification when broad query has insufficient support", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
broad: "1",
|
||||
evidenceGate: "1",
|
||||
antiGeneric: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Что не так по документ #123?"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const routed = firstRoutedResult(response.body);
|
||||
expect(routed).toBeTruthy();
|
||||
expect(routed?.route).toBe("live_mcp_drilldown");
|
||||
|
||||
const summary = (routed?.summary as Record<string, unknown>) ?? {};
|
||||
expect(summary.broad_guard_applied).toBe(true);
|
||||
expect(summary.minimum_evidence_failed).toBe(true);
|
||||
expect(summary.broad_result_flag).toBe(true);
|
||||
expect(summary.degraded_to).toBe("clarification");
|
||||
expect(response.body.reply_type).toBe("clarification_required");
|
||||
});
|
||||
|
||||
it("supports legacy behavior when broad guard flags are OFF", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
broad: "0",
|
||||
evidenceGate: "0",
|
||||
antiGeneric: "0"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Покажи в целом общую картину и топ рисков по документам за июнь 2020."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const routed = firstRoutedResult(response.body);
|
||||
expect(routed).toBeTruthy();
|
||||
|
||||
const summary = (routed?.summary as Record<string, unknown>) ?? {};
|
||||
expect(summary.broad_guard_applied).toBeUndefined();
|
||||
expect(summary.minimum_evidence_failed).toBeUndefined();
|
||||
expect(summary.anti_generic_guard_applied).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RouteHintSummary } from "../src/types/normalizer";
|
||||
import type { UnifiedRetrievalResult } from "../src/types/assistant";
|
||||
import {
|
||||
ACCOUNTANT_SCORING_RUBRIC_V01,
|
||||
INVESTIGATION_MAX_EVIDENCE_REFS,
|
||||
INVESTIGATION_MAX_UNCERTAINTIES
|
||||
} from "../src/types/stage1Contracts";
|
||||
import { createEmptyInvestigationState, updateInvestigationState } from "../src/services/investigationState";
|
||||
|
||||
function buildRouteSummary(): RouteHintSummary {
|
||||
return {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "high",
|
||||
planner: {
|
||||
total_fragments: 1,
|
||||
in_scope_fragments: 1,
|
||||
out_of_scope_fragments: 0,
|
||||
discarded_fragments: 0,
|
||||
contains_multiple_tasks: false
|
||||
},
|
||||
decisions: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
candidate_labels: ["anomaly_probe"],
|
||||
decision_flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: true,
|
||||
asks_for_anomaly_scan: true,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: true,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
route: "store_feature_risk",
|
||||
reason: "test-route"
|
||||
}
|
||||
],
|
||||
fallback: {
|
||||
type: "none",
|
||||
message: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildRetrievalResult(evidenceCount: number): UnifiedRetrievalResult {
|
||||
return {
|
||||
fragment_id: "F1",
|
||||
requirement_ids: ["R1"],
|
||||
route: "store_feature_risk",
|
||||
status: "ok",
|
||||
result_type: "list",
|
||||
items: [],
|
||||
summary: {},
|
||||
evidence: Array.from({ length: evidenceCount }, (_, index) => ({
|
||||
evidence_id: `ev-${index + 1}`,
|
||||
claim_ref: "requirement:R1",
|
||||
source_type: "retrieval_item",
|
||||
source_ref: {
|
||||
schema_version: "evidence_source_ref_v1",
|
||||
namespace: "snapshot_2020",
|
||||
entity: "document",
|
||||
id: `doc-${index + 1}`,
|
||||
period: "2020-06",
|
||||
canonical_ref: `evidence_source_ref_v1|snapshot_2020|document|doc-${index + 1}|2020-06`
|
||||
},
|
||||
pointer: {
|
||||
fragment_id: "F1",
|
||||
route: "store_feature_risk",
|
||||
source: {
|
||||
namespace: "snapshot_2020",
|
||||
entity: "document",
|
||||
id: `doc-${index + 1}`,
|
||||
period: "2020-06"
|
||||
},
|
||||
locator: {
|
||||
field_path: "risk_score",
|
||||
item_index: index
|
||||
}
|
||||
},
|
||||
evidence_kind: "anomaly_signal",
|
||||
mechanism_note: "Risk signal",
|
||||
confidence: "medium",
|
||||
limitation: null,
|
||||
payload: { risk_score: 2 }
|
||||
})),
|
||||
why_included: [],
|
||||
selection_reason: [],
|
||||
risk_factors: [],
|
||||
business_interpretation: [],
|
||||
confidence: "high",
|
||||
limitations: ["Need period clarification"],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("stage1 contract scaffolding", () => {
|
||||
it("provides rubric v0.1 for accountant-facing metrics", () => {
|
||||
const metricNames = Object.keys(ACCOUNTANT_SCORING_RUBRIC_V01);
|
||||
expect(metricNames).toEqual([
|
||||
"retrieval_differentiation_rate",
|
||||
"generic_explanation_rate",
|
||||
"accountant_actionability_score",
|
||||
"false_confidence_rate",
|
||||
"broad_answer_rate",
|
||||
"mechanism_specificity_score",
|
||||
"followup_context_retention_score"
|
||||
]);
|
||||
for (const metric of metricNames) {
|
||||
const bands = ACCOUNTANT_SCORING_RUBRIC_V01[metric as keyof typeof ACCOUNTANT_SCORING_RUBRIC_V01];
|
||||
expect(bands.some((item) => item.score === 0)).toBe(true);
|
||||
expect(bands.some((item) => item.score === 5)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("updates investigation_state with bounded fields", () => {
|
||||
const initial = createEmptyInvestigationState("asst-contract-test", "2026-03-25T10:00:00.000Z");
|
||||
const updated = updateInvestigationState({
|
||||
previous: initial,
|
||||
timestamp: "2026-03-25T10:01:00.000Z",
|
||||
questionId: "msg-1",
|
||||
userMessage: "Prover schet 97 za 2020-06 i podsveti risk.",
|
||||
routeSummary: buildRouteSummary(),
|
||||
requirements: [
|
||||
{
|
||||
requirement_id: "R1",
|
||||
source_fragment_id: "F1",
|
||||
requirement_text: "Проверить счет 97",
|
||||
subject_tokens: ["счет_97"],
|
||||
status: "covered",
|
||||
route: "store_feature_risk"
|
||||
}
|
||||
],
|
||||
coverageReport: {
|
||||
requirements_total: 1,
|
||||
requirements_covered: 1,
|
||||
requirements_uncovered: [],
|
||||
requirements_partially_covered: [],
|
||||
clarification_needed_for: [],
|
||||
out_of_scope_requirements: []
|
||||
},
|
||||
retrievalResults: [buildRetrievalResult(40)],
|
||||
replyType: "factual_with_explanation"
|
||||
});
|
||||
|
||||
expect(updated.turn_index).toBe(1);
|
||||
expect(updated.status).toBe("active");
|
||||
expect(updated.focus.period).toBe("2020-06");
|
||||
expect(updated.focus.primary_accounts).toContain("97");
|
||||
expect(updated.evidence_refs.length).toBeLessThanOrEqual(INVESTIGATION_MAX_EVIDENCE_REFS);
|
||||
expect(updated.open_uncertainties.length).toBeLessThanOrEqual(INVESTIGATION_MAX_UNCERTAINTIES);
|
||||
expect(updated.query_mode_hint).toBe("direct_answer");
|
||||
expect(updated.followup_context?.referenced_requirement_ids).toEqual(["R1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ASSISTANT_SESSIONS_DIR } from "../src/config";
|
||||
import { createApp } from "../src/server";
|
||||
|
||||
describe("assistant mode API", () => {
|
||||
it("processes message and returns assistant response with debug payload", async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Prover schet 97 i podsveti riskovye zony."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.ok).toBe(true);
|
||||
expect(typeof response.body.session_id).toBe("string");
|
||||
expect(typeof response.body.assistant_reply).toBe("string");
|
||||
expect(typeof response.body.reply_type).toBe("string");
|
||||
expect(response.body.conversation_item?.role).toBe("assistant");
|
||||
expect(response.body.conversation_item?.reply_type).toBe(response.body.reply_type);
|
||||
expect(response.body.debug?.trace_id).toBeTypeOf("string");
|
||||
expect(Array.isArray(response.body.debug?.routes)).toBe(true);
|
||||
expect(Array.isArray(response.body.debug?.requirements_extracted)).toBe(true);
|
||||
expect(typeof response.body.debug?.coverage_report?.requirements_total).toBe("number");
|
||||
expect(typeof response.body.debug?.answer_grounding_check?.status).toBe("string");
|
||||
expect(Array.isArray(response.body.debug?.retrieval_status)).toBe(true);
|
||||
expect(Array.isArray(response.body.debug?.retrieval_results)).toBe(true);
|
||||
expect(Array.isArray(response.body.conversation)).toBe(true);
|
||||
expect(response.body.conversation.length).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps session-scoped history and returns it via session endpoint", async () => {
|
||||
const app = createApp();
|
||||
const first = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Sdelai proverku po postavshchikam."
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
const sessionId = String(first.body.session_id);
|
||||
|
||||
const second = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Dobav proverku po periodu 2020-06."
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.session_id).toBe(sessionId);
|
||||
|
||||
const session = await request(app).get(`/api/assistant/session/${sessionId}`);
|
||||
expect(session.status).toBe(200);
|
||||
expect(session.body.ok).toBe(true);
|
||||
expect(session.body.session?.session_id).toBe(sessionId);
|
||||
expect(Array.isArray(session.body.session?.items)).toBe(true);
|
||||
expect(session.body.session.items.length).toBe(4);
|
||||
});
|
||||
|
||||
it("executes factual retrieval for routed fragments", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const riskResponse = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Проверь НДС и рискованные записи по документам."
|
||||
});
|
||||
|
||||
expect(riskResponse.status).toBe(200);
|
||||
expect(Array.isArray(riskResponse.body.debug?.retrieval_results)).toBe(true);
|
||||
expect(riskResponse.body.debug.retrieval_results.length).toBeGreaterThan(0);
|
||||
expect(riskResponse.body.debug.retrieval_results.some((item: { route?: string }) => item.route === "store_feature_risk")).toBe(true);
|
||||
expect(riskResponse.body.debug.retrieval_results.some((item: { status?: string }) => item.status === "ok")).toBe(true);
|
||||
expect(typeof riskResponse.body.reply_type).toBe("string");
|
||||
expect(["factual_with_explanation", "partial_coverage"]).toContain(riskResponse.body.reply_type);
|
||||
expect(String(riskResponse.body.assistant_reply)).toContain("Почему это попало в ответ");
|
||||
|
||||
const chainResponse = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Разложи цепочку документов и оплат по контрагентам."
|
||||
});
|
||||
|
||||
expect(chainResponse.status).toBe(200);
|
||||
expect(Array.isArray(chainResponse.body.debug?.retrieval_results)).toBe(true);
|
||||
expect(chainResponse.body.debug.retrieval_results.some((item: { route?: string }) => item.route === "hybrid_store_plus_live")).toBe(true);
|
||||
const answerStructure = chainResponse.body.debug?.answer_structure_v11;
|
||||
const evidenceBlock = answerStructure?.evidence_block;
|
||||
if (Array.isArray(evidenceBlock?.evidence_ids) && evidenceBlock.evidence_ids.length > 0) {
|
||||
expect(Array.isArray(evidenceBlock.claim_evidence_links)).toBe(true);
|
||||
expect(typeof evidenceBlock.claim_evidence_links[0]?.claim_ref).toBe("string");
|
||||
expect(Array.isArray(evidenceBlock.claim_evidence_links[0]?.evidence_ids)).toBe(true);
|
||||
}
|
||||
expect(String(chainResponse.body.assistant_reply)).toContain("Основание отбора");
|
||||
});
|
||||
|
||||
it("keeps in-domain translit queries in scope and routed", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Prover schet 60 za 2020-06, gde taili postavshikov i kakie dokumenty ne zakryvayut oplaty."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.reply_type).not.toBe("out_of_scope");
|
||||
expect(response.body.debug?.route_summary?.message_in_scope).toBe(true);
|
||||
expect(Array.isArray(response.body.debug?.routes)).toBe(true);
|
||||
expect(response.body.debug?.routes.some((item: { route?: string }) => item.route !== "no_route")).toBe(true);
|
||||
expect(Array.isArray(response.body.debug?.retrieval_results)).toBe(true);
|
||||
expect(response.body.debug?.retrieval_results.some((item: { status?: string }) => item.status === "ok")).toBe(true);
|
||||
});
|
||||
|
||||
it("avoids false route mismatch when supported evidence exists for bounded answer", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message:
|
||||
"Покажи хвосты поставщиков по счету 60 за 2020-06 и выдели, где проблема уже похожа на разрыв цепочки документов."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.reply_type).not.toBe("route_mismatch_blocked");
|
||||
expect(response.body.debug?.answer_grounding_check?.status).not.toBe("route_mismatch_blocked");
|
||||
expect(["partial", "grounded"]).toContain(String(response.body.debug?.answer_grounding_check?.status));
|
||||
expect(response.body.reply_type).toBe("partial_coverage");
|
||||
});
|
||||
|
||||
it("blocks answer when critical domain token is not grounded", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Проверь основные средства и рискованные записи."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.reply_type).toBe("route_mismatch_blocked");
|
||||
expect(response.body.debug?.answer_grounding_check?.status).toBe("route_mismatch_blocked");
|
||||
expect(response.body.debug?.answer_grounding_check?.route_subject_match).toBe(false);
|
||||
expect(Array.isArray(response.body.debug?.answer_grounding_check?.reasons)).toBe(true);
|
||||
expect(String(response.body.assistant_reply)).toContain("предмет результата не совпал");
|
||||
});
|
||||
|
||||
it("applies semantic narrowing profile for hybrid retrieval without GUID", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const first = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Разложи цепочку по 51 и 60 счетам: где закрытие не тем документом."
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const second = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Разложи цепочку по банку: где выписка, документ и проводка живут отдельно и повторяется паттерн."
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
|
||||
const firstHybrid = (first.body.debug?.retrieval_results ?? []).find((item: { route?: string }) => item.route === "hybrid_store_plus_live");
|
||||
const secondHybrid = (second.body.debug?.retrieval_results ?? []).find((item: { route?: string }) => item.route === "hybrid_store_plus_live");
|
||||
|
||||
expect(firstHybrid).toBeTruthy();
|
||||
expect(secondHybrid).toBeTruthy();
|
||||
|
||||
const firstSummary = (firstHybrid as { summary?: Record<string, unknown> }).summary ?? {};
|
||||
const secondSummary = (secondHybrid as { summary?: Record<string, unknown> }).summary ?? {};
|
||||
|
||||
expect(firstSummary.semantic_narrowing_applied).toBe(true);
|
||||
expect(typeof firstSummary.source_records).toBe("number");
|
||||
expect(typeof firstSummary.filtered_records_after_narrowing).toBe("number");
|
||||
expect(Number(firstSummary.filtered_records_after_narrowing)).toBeLessThan(Number(firstSummary.source_records));
|
||||
|
||||
const firstProfile = firstSummary.semantic_profile as Record<string, unknown>;
|
||||
const secondProfile = secondSummary.semantic_profile as Record<string, unknown>;
|
||||
expect(firstProfile).toBeTruthy();
|
||||
expect(secondProfile).toBeTruthy();
|
||||
|
||||
expect(Array.isArray(firstProfile.account_scope)).toBe(true);
|
||||
expect((firstProfile.account_scope as string[]).includes("51")).toBe(true);
|
||||
expect((firstProfile.account_scope as string[]).includes("60")).toBe(true);
|
||||
|
||||
expect(Array.isArray(firstProfile.anomaly_patterns)).toBe(true);
|
||||
expect(Array.isArray(secondProfile.anomaly_patterns)).toBe(true);
|
||||
expect((firstProfile.anomaly_patterns as string[]).includes("wrong_document_type")).toBe(true);
|
||||
expect((secondProfile.anomaly_patterns as string[]).includes("repeated_anomaly")).toBe(true);
|
||||
});
|
||||
|
||||
it("writes one persistent JSON log file per session", async () => {
|
||||
const app = createApp();
|
||||
const sessionId = `asst-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const first = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Проверь НДС."
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const second = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
message: "Разложи цепочку документов по контрагентам."
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
|
||||
const logPath = path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`);
|
||||
expect(fs.existsSync(logPath)).toBe(true);
|
||||
|
||||
const logPayload = JSON.parse(fs.readFileSync(logPath, "utf-8")) as {
|
||||
schema_version: string;
|
||||
session_id: string;
|
||||
counters: {
|
||||
total_messages: number;
|
||||
user_messages: number;
|
||||
assistant_messages: number;
|
||||
};
|
||||
turns: Array<{
|
||||
human_block: string;
|
||||
human_readable: {
|
||||
question_raw: string;
|
||||
question_understood: string;
|
||||
decomposition: string[];
|
||||
answer: string;
|
||||
};
|
||||
}>;
|
||||
conversation: unknown[];
|
||||
};
|
||||
|
||||
expect(logPayload.schema_version).toBe("assistant_session_log_v1");
|
||||
expect(logPayload.session_id).toBe(sessionId);
|
||||
expect(logPayload.counters.total_messages).toBe(4);
|
||||
expect(logPayload.counters.user_messages).toBe(2);
|
||||
expect(logPayload.counters.assistant_messages).toBe(2);
|
||||
expect(Array.isArray(logPayload.turns)).toBe(true);
|
||||
expect(logPayload.turns.length).toBe(2);
|
||||
expect(logPayload.turns[0].human_block).toContain("Вопрос:");
|
||||
expect(logPayload.turns[0].human_block).toContain("Понято как:");
|
||||
expect(logPayload.turns[0].human_block).toContain("Декомпозиция:");
|
||||
expect(logPayload.turns[0].human_block).toContain("Ответ:");
|
||||
expect(Array.isArray(logPayload.turns[0].human_readable.decomposition)).toBe(true);
|
||||
expect(Array.isArray(logPayload.conversation)).toBe(true);
|
||||
expect(logPayload.conversation.length).toBe(4);
|
||||
|
||||
const sameSessionFiles = fs.readdirSync(ASSISTANT_SESSIONS_DIR).filter((item) => item === `${sessionId}.json`);
|
||||
expect(sameSessionFiles.length).toBe(1);
|
||||
|
||||
fs.unlinkSync(logPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const FLAG_KEYS = [
|
||||
"FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1",
|
||||
"FEATURE_ASSISTANT_ANSWER_POLICY_V11",
|
||||
"FEATURE_ASSISTANT_BROAD_GUARD_V1",
|
||||
"FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1",
|
||||
"FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1",
|
||||
"FEATURE_ASSISTANT_INVESTIGATION_STATE_V1",
|
||||
"FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1"
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(
|
||||
FLAG_KEYS.map((key) => [key, process.env[key]])
|
||||
);
|
||||
|
||||
function restoreFlags(): void {
|
||||
for (const key of FLAG_KEYS) {
|
||||
const original = ORIGINAL_FLAGS[key];
|
||||
if (original === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createAppWithFlags(flags: {
|
||||
accountantEval: "0" | "1";
|
||||
answerPolicy: "0" | "1";
|
||||
}): Promise<import("express").Express> {
|
||||
process.env.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = flags.accountantEval;
|
||||
process.env.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = flags.answerPolicy;
|
||||
process.env.FEATURE_ASSISTANT_BROAD_GUARD_V1 = "1";
|
||||
process.env.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = "1";
|
||||
process.env.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = "1";
|
||||
process.env.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = "1";
|
||||
process.env.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = "1";
|
||||
vi.resetModules();
|
||||
const { createApp } = await import("../src/server");
|
||||
return createApp();
|
||||
}
|
||||
|
||||
describe.sequential("assistant Stage 1 eval harness", () => {
|
||||
afterEach(() => {
|
||||
restoreFlags();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("runs assistant_stage1 harness and returns raw metrics + rubric bands", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
accountantEval: "1",
|
||||
answerPolicy: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/eval/run").send({
|
||||
eval_target: "assistant_stage1",
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
caseSetFile: "assistant_stage1_canonical_v0_1.json",
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.ok).toBe(true);
|
||||
expect(response.body.report?.eval_target).toBe("assistant_stage1");
|
||||
expect(response.body.report?.metrics?.raw).toBeTruthy();
|
||||
const rawMetricKeys = Object.keys(response.body.report?.metrics?.raw ?? {});
|
||||
expect(rawMetricKeys).toEqual([
|
||||
"retrieval_differentiation_rate",
|
||||
"generic_explanation_rate",
|
||||
"accountant_actionability_score",
|
||||
"false_confidence_rate",
|
||||
"broad_answer_rate",
|
||||
"mechanism_specificity_score",
|
||||
"followup_context_retention_score"
|
||||
]);
|
||||
expect(response.body.report?.rubric_bands?.generic_explanation_rate).toBeTruthy();
|
||||
expect(response.body.report?.feature_profile_snapshot).toBeTruthy();
|
||||
expect(response.body.report?.code_version).toBeTruthy();
|
||||
expect(typeof response.body.report?.run_timestamp).toBe("string");
|
||||
expect(Array.isArray(response.body.report?.results)).toBe(true);
|
||||
expect(response.body.report?.results?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("loads canonical suite metadata and keeps it stable", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
accountantEval: "1",
|
||||
answerPolicy: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/eval/run").send({
|
||||
eval_target: "assistant_stage1",
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
caseSetFile: "assistant_stage1_canonical_v0_1.json",
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.report?.suite_id).toBe("assistant_stage1_canonical");
|
||||
expect(response.body.report?.suite_version).toBe("0.1.0");
|
||||
expect(response.body.report?.scenario_count).toBe(9);
|
||||
expect(Array.isArray(response.body.report?.case_ids)).toBe(true);
|
||||
expect(response.body.report?.case_ids?.length).toBe(9);
|
||||
});
|
||||
|
||||
it("handles follow-up cases as dedicated subset", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
accountantEval: "1",
|
||||
answerPolicy: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/eval/run").send({
|
||||
eval_target: "assistant_stage1",
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
caseSetFile: "assistant_stage1_canonical_v0_1.json",
|
||||
caseIds: ["S1-FOLLOWUP-INVESTIGATION", "S1-60-SUPPLIER-TAILS"],
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.report?.subsets?.followup_cases_total).toBeGreaterThan(0);
|
||||
expect(response.body.report?.metrics?.raw?.followup_context_retention_score).not.toBeNull();
|
||||
});
|
||||
|
||||
it("builds comparison artifact from baseline and current runs", async () => {
|
||||
const baselineApp = await createAppWithFlags({
|
||||
accountantEval: "1",
|
||||
answerPolicy: "0"
|
||||
});
|
||||
const baseline = await request(baselineApp).post("/api/eval/run").send({
|
||||
eval_target: "assistant_stage1",
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
caseSetFile: "assistant_stage1_canonical_v0_1.json",
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
expect(baseline.status).toBe(200);
|
||||
const baselinePath = String(baseline.body.report?.artifacts?.run_report_json_path ?? "");
|
||||
expect(baselinePath.length).toBeGreaterThan(0);
|
||||
|
||||
const currentApp = await createAppWithFlags({
|
||||
accountantEval: "1",
|
||||
answerPolicy: "1"
|
||||
});
|
||||
const current = await request(currentApp).post("/api/eval/run").send({
|
||||
eval_target: "assistant_stage1",
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
caseSetFile: "assistant_stage1_canonical_v0_1.json",
|
||||
compare_with_report_file: baselinePath,
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
expect(current.status).toBe(200);
|
||||
expect(current.body.report?.comparison).toBeTruthy();
|
||||
expect(current.body.report?.comparison?.metric_deltas).toBeTruthy();
|
||||
expect(current.body.report?.comparison?.artifacts?.comparison_report_json_path).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps legacy eval path unchanged by default", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
accountantEval: "1",
|
||||
answerPolicy: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/eval/run").send({
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
rawQuestions: "Проверь счет 60 за июнь 2020; Покажи риски по счету 97",
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.report?.eval_target).toBeUndefined();
|
||||
expect(response.body.report?.metrics?.schema_validation_pass_rate).not.toBeUndefined();
|
||||
expect(response.body.report?.metrics?.route_resolution_accuracy).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects accountant eval feature flag OFF/ON", async () => {
|
||||
const appOff = await createAppWithFlags({
|
||||
accountantEval: "0",
|
||||
answerPolicy: "1"
|
||||
});
|
||||
const offResponse = await request(appOff).post("/api/eval/run").send({
|
||||
eval_target: "assistant_stage1",
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
caseSetFile: "assistant_stage1_canonical_v0_1.json",
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
expect(offResponse.status).toBe(409);
|
||||
expect(offResponse.body?.error?.code).toBe("ASSISTANT_STAGE1_EVAL_DISABLED");
|
||||
|
||||
const appOn = await createAppWithFlags({
|
||||
accountantEval: "1",
|
||||
answerPolicy: "1"
|
||||
});
|
||||
const onResponse = await request(appOn).post("/api/eval/run").send({
|
||||
eval_target: "assistant_stage1",
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
caseSetFile: "assistant_stage1_canonical_v0_1.json",
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2"
|
||||
}
|
||||
});
|
||||
expect(onResponse.status).toBe(200);
|
||||
expect(onResponse.body.report?.eval_target).toBe("assistant_stage1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const FLAG_KEYS = [
|
||||
"FEATURE_ASSISTANT_INVESTIGATION_STATE_V1",
|
||||
"FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1",
|
||||
"FEATURE_ASSISTANT_CONTRACTS_V11"
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(
|
||||
FLAG_KEYS.map((key) => [key, process.env[key]])
|
||||
);
|
||||
|
||||
function restoreFlags(): void {
|
||||
for (const key of FLAG_KEYS) {
|
||||
const original = ORIGINAL_FLAGS[key];
|
||||
if (original === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createAppWithFlags(flags: {
|
||||
state: "0" | "1";
|
||||
binding: "0" | "1";
|
||||
contracts?: "0" | "1";
|
||||
}) {
|
||||
process.env.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = flags.state;
|
||||
process.env.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = flags.binding;
|
||||
process.env.FEATURE_ASSISTANT_CONTRACTS_V11 = flags.contracts ?? "1";
|
||||
vi.resetModules();
|
||||
const { createApp } = await import("../src/server");
|
||||
return createApp();
|
||||
}
|
||||
|
||||
describe.sequential("assistant follow-up state binding", () => {
|
||||
afterEach(() => {
|
||||
restoreFlags();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("applies investigation_state binding in follow-up flow when flags are ON", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
state: "1",
|
||||
binding: "1"
|
||||
});
|
||||
const sessionId = `asst-wave2-on-${Date.now()}`;
|
||||
|
||||
const first = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Разложи цепочку документов по контрагентам."
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.debug?.followup_state_usage).toBeUndefined();
|
||||
expect(first.body.debug?.investigation_state_snapshot?.turn_index).toBe(1);
|
||||
|
||||
const second = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "И по периоду 2020-06."
|
||||
});
|
||||
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.debug?.followup_state_usage?.applied).toBe(true);
|
||||
expect(second.body.debug?.followup_state_usage?.context_patch?.business_context_from_state).toBe(true);
|
||||
expect(second.body.debug?.followup_state_usage?.state_turn_index).toBe(1);
|
||||
expect(
|
||||
(second.body.debug?.routes ?? []).some((item: { route?: string }) => item.route && item.route !== "no_route")
|
||||
).toBe(true);
|
||||
expect(second.body.debug?.investigation_state_snapshot?.turn_index).toBe(2);
|
||||
});
|
||||
|
||||
it("does not apply follow-up binding when binding flag is OFF", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
state: "1",
|
||||
binding: "0"
|
||||
});
|
||||
const sessionId = `asst-wave2-off-${Date.now()}`;
|
||||
|
||||
const first = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Разложи цепочку документов по контрагентам."
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const second = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "И по периоду 2020-06."
|
||||
});
|
||||
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.debug?.followup_state_usage).toBeUndefined();
|
||||
expect((second.body.debug?.routes ?? []).every((item: { route?: string }) => item.route === "no_route")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps legacy-like behavior when investigation state flag is OFF", async () => {
|
||||
const app = await createAppWithFlags({
|
||||
state: "0",
|
||||
binding: "1"
|
||||
});
|
||||
|
||||
const response = await request(app).post("/api/assistant/message").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "И по периоду 2020-06."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.debug?.investigation_state_snapshot).toBeNull();
|
||||
expect(response.body.debug?.followup_state_usage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "../src/server";
|
||||
|
||||
describe("POST /api/eval/run", () => {
|
||||
it("runs v2 eval using inline rawQuestions batch", async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).post("/api/eval/run").send({
|
||||
normalizeConfig: {
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
useMock: true
|
||||
},
|
||||
useMock: true,
|
||||
mode: "single-pass-strict",
|
||||
rawQuestions:
|
||||
"Проверь хвосты по поставщикам и разложи цепочку; Как вообще по ФСБУ; Покажи топ рисков за июнь 2020"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.ok).toBe(true);
|
||||
expect(response.body.report?.schema_version).toBe("v2_0_2");
|
||||
expect(response.body.report?.cases_total).toBe(3);
|
||||
expect(typeof response.body.report?.metrics?.schema_validation_pass_rate).toBe("number");
|
||||
expect(response.body.report?.metrics?.route_resolution_accuracy).not.toBeUndefined();
|
||||
expect(response.body.report?.metrics?.execution_state_consistency_rate).not.toBeUndefined();
|
||||
expect(Array.isArray(response.body.report?.results)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { NormalizedQueryV1, NormalizedQueryV2, NormalizedQueryV2_0_1, NormalizedQueryV2_0_2 } from "../src/types/normalizer";
|
||||
|
||||
export function normalizedFixture(): NormalizedQueryV1 {
|
||||
return {
|
||||
schema_version: "normalized_query_v1",
|
||||
user_question_raw: "По каким поставщикам не бьются взаиморасчеты?",
|
||||
normalized_question: "Показать поставщиков с расхождениями по взаиморасчетам.",
|
||||
intent_class: "cross_entity",
|
||||
business_problem_type: "reconciliation",
|
||||
domain_entities: ["контрагент", "документ", "проводка"],
|
||||
accounts_mentioned: ["60"],
|
||||
documents_mentioned: ["поступление", "списание"],
|
||||
registers_mentioned: ["взаиморасчеты"],
|
||||
period_scope: {
|
||||
type: "inferred",
|
||||
value: "2020-06",
|
||||
confidence: "medium"
|
||||
},
|
||||
requires: {
|
||||
needs_cross_entity_join: true,
|
||||
needs_causal_chain: true,
|
||||
needs_exact_object_trace: false,
|
||||
needs_ranking: false,
|
||||
needs_anomaly_summary: false,
|
||||
needs_runtime_truth: false,
|
||||
needs_period_cut: true,
|
||||
needs_evidence: true
|
||||
},
|
||||
expected_output_shape: "reconciliation_report",
|
||||
route_hint: "hybrid_store_plus_live",
|
||||
ambiguities: [],
|
||||
confidence: {
|
||||
overall: "medium",
|
||||
intent_class: "high",
|
||||
route_hint: "medium"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizedFixtureV2(): NormalizedQueryV2 {
|
||||
return {
|
||||
schema_version: "normalized_query_v2",
|
||||
user_message_raw: "Проверь по поставщикам хвосты и отдельно скажи, что не относится к данным компании.",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: true,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "Проверь по поставщикам хвосты",
|
||||
normalized_fragment_text: "Проверить хвосты по поставщикам",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: ["поставщик", "взаиморасчеты"],
|
||||
account_hints: ["60"],
|
||||
document_hints: ["документ"],
|
||||
register_hints: ["остатки"],
|
||||
time_scope: {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: true,
|
||||
asks_for_chain_explanation: true,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: true,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: true,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["cross_entity", "anomaly_probe"],
|
||||
confidence: "medium"
|
||||
}
|
||||
],
|
||||
discarded_fragments: [
|
||||
{
|
||||
raw_fragment_text: "короче",
|
||||
reason: "noise_or_too_short"
|
||||
}
|
||||
],
|
||||
global_notes: {
|
||||
needs_clarification: true,
|
||||
clarification_reason: "Не указан период."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizedFixtureV2_0_1(): NormalizedQueryV2_0_1 {
|
||||
return {
|
||||
schema_version: "normalized_query_v2_0_1",
|
||||
user_message_raw: "Проверь, что висит по 97 и где есть подозрительные хвосты.",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "high",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "Проверь, что висит по 97 и где есть подозрительные хвосты",
|
||||
normalized_fragment_text: "Проверить зависшие записи по 97 и подозрительные хвосты",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: ["рбп"],
|
||||
account_hints: ["97"],
|
||||
document_hints: [],
|
||||
register_hints: ["остатки"],
|
||||
time_scope: {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: true,
|
||||
asks_for_anomaly_scan: true,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["rule_based_account_control", "anomaly_probe"],
|
||||
confidence: "high",
|
||||
execution_readiness: "executable_with_soft_assumptions",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: ["problem_scan_mode_enabled"]
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizedFixtureV2_0_2(): NormalizedQueryV2_0_2 {
|
||||
return {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "Проверь зависшие истории по 97 и подсвети рискованные участки.",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "high",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "Проверь зависшие истории по 97 и подсвети рискованные участки",
|
||||
normalized_fragment_text: "Проверить зависшие истории по 97 и рискованные участки",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: ["рбп"],
|
||||
account_hints: ["97"],
|
||||
document_hints: [],
|
||||
register_hints: ["остатки"],
|
||||
time_scope: {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: true,
|
||||
asks_for_anomaly_scan: true,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["rule_based_account_control", "anomaly_probe"],
|
||||
confidence: "high",
|
||||
execution_readiness: "executable_with_soft_assumptions",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: ["problem_scan_mode_enabled"],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "../src/server";
|
||||
import { INVESTIGATION_MAX_EVIDENCE_REFS, INVESTIGATION_MAX_UNCERTAINTIES } from "../src/types/stage1Contracts";
|
||||
|
||||
describe("investigation_state flow scaffolding", () => {
|
||||
it("keeps bounded investigation_state across follow-up turns", async () => {
|
||||
const app = createApp();
|
||||
const sessionId = `asst-wave1-${Date.now()}`;
|
||||
|
||||
const first = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Prover schet 97 i riskovye zony za 2020-06."
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.debug?.investigation_state_snapshot?.schema_version).toBe("investigation_state_v1");
|
||||
expect(first.body.debug?.answer_structure_v11?.schema_version).toBe("answer_structure_v1_1");
|
||||
expect(first.body.debug?.followup_state_usage).toBeUndefined();
|
||||
|
||||
const evidenceResult = (first.body.debug?.retrieval_results ?? []).find(
|
||||
(item: { evidence?: unknown[] }) => Array.isArray(item.evidence) && item.evidence.length > 0
|
||||
) as { evidence?: Array<{ pointer?: { source?: { entity?: string } } }> } | undefined;
|
||||
|
||||
if (evidenceResult?.evidence?.length) {
|
||||
expect(typeof evidenceResult.evidence[0].pointer?.source?.entity).toBe("string");
|
||||
}
|
||||
|
||||
const second = await request(app).post("/api/assistant/message").send({
|
||||
session_id: sessionId,
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
user_message: "Dobav proverku po postavshchikam i utochni nezakrytye trebovaniya."
|
||||
});
|
||||
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.debug?.investigation_state_snapshot?.turn_index).toBe(2);
|
||||
expect(second.body.debug?.followup_state_usage?.applied).toBe(true);
|
||||
|
||||
const sessionResponse = await request(app).get(`/api/assistant/session/${sessionId}`);
|
||||
expect(sessionResponse.status).toBe(200);
|
||||
|
||||
const investigationState = sessionResponse.body.session?.investigation_state;
|
||||
expect(investigationState).toBeTruthy();
|
||||
expect(investigationState.turn_index).toBe(2);
|
||||
expect(Array.isArray(investigationState.evidence_refs)).toBe(true);
|
||||
expect(Array.isArray(investigationState.open_uncertainties)).toBe(true);
|
||||
expect(investigationState.evidence_refs.length).toBeLessThanOrEqual(INVESTIGATION_MAX_EVIDENCE_REFS);
|
||||
expect(investigationState.open_uncertainties.length).toBeLessThanOrEqual(INVESTIGATION_MAX_UNCERTAINTIES);
|
||||
expect(typeof investigationState.question_id).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "../src/server";
|
||||
|
||||
describe("POST /api/normalize", () => {
|
||||
it("returns normalized v1 payload in mock mode", async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).post("/api/normalize").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v1_1_2_1",
|
||||
userQuestion: "По каким поставщикам не бьются взаиморасчеты по 60 счету?"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.trace_id).toBeTypeOf("string");
|
||||
expect(response.body.schema_version).toBe("v1");
|
||||
expect(response.body.validation?.passed).toBe(true);
|
||||
expect(response.body.normalized?.schema_version).toBe("normalized_query_v1");
|
||||
});
|
||||
|
||||
it("returns normalized v2 payload in mock mode", async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).post("/api/normalize").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2",
|
||||
userQuestion: "Проверь хвосты по поставщикам и отдельно все, что не относится к данным компании."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.trace_id).toBeTypeOf("string");
|
||||
expect(response.body.schema_version).toBe("v2");
|
||||
expect(response.body.validation?.passed).toBe(true);
|
||||
expect(response.body.normalized?.schema_version).toBe("normalized_query_v2");
|
||||
expect(Array.isArray(response.body.normalized?.fragments)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns normalized v2.0.1 payload in mock mode with execution_readiness", async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).post("/api/normalize").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_1",
|
||||
userQuestion: "Покажи, что висит по 97 и что выглядит подозрительно."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.trace_id).toBeTypeOf("string");
|
||||
expect(response.body.schema_version).toBe("v2_0_1");
|
||||
expect(response.body.validation?.passed).toBe(true);
|
||||
expect(response.body.normalized?.schema_version).toBe("normalized_query_v2_0_1");
|
||||
expect(Array.isArray(response.body.normalized?.fragments)).toBe(true);
|
||||
expect(response.body.normalized?.fragments?.[0]?.execution_readiness).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("returns normalized v2.0.2 payload in mock mode with route_status and no_route_reason", async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).post("/api/normalize").send({
|
||||
useMock: true,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
userQuestion: "Проверь 97 и покажи, где логика учета выглядит подозрительно."
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.trace_id).toBeTypeOf("string");
|
||||
expect(response.body.schema_version).toBe("v2_0_2");
|
||||
expect(response.body.validation?.passed).toBe(true);
|
||||
expect(response.body.normalized?.schema_version).toBe("normalized_query_v2_0_2");
|
||||
expect(Array.isArray(response.body.normalized?.fragments)).toBe(true);
|
||||
expect(response.body.normalized?.fragments?.[0]?.route_status).toBeTypeOf("string");
|
||||
expect(response.body.normalized?.fragments?.[0]?.no_route_reason).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPromptBundle, listBuiltinPromptPresets, loadDefaultPrompts } from "../src/services/promptBuilder";
|
||||
|
||||
describe("promptBuilder", () => {
|
||||
it("loads default prompts", () => {
|
||||
const defaults = loadDefaultPrompts();
|
||||
expect(defaults.systemPrompt.length).toBeGreaterThan(20);
|
||||
expect(defaults.developerPrompt.length).toBeGreaterThan(20);
|
||||
expect(defaults.domainPrompt.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("exposes v1, v1.1, v1.1.1, v1.1.2, v1.1.2.1, v2, v2.0.1 and v2.0.2 builtin presets", () => {
|
||||
const presets = listBuiltinPromptPresets();
|
||||
const versions = presets.map((item) => item.prompt_version);
|
||||
expect(versions).toContain("normalizer_v1");
|
||||
expect(versions).toContain("normalizer_v1_1");
|
||||
expect(versions).toContain("normalizer_v1_1_1");
|
||||
expect(versions).toContain("normalizer_v1_1_2");
|
||||
expect(versions).toContain("normalizer_v1_1_2_1");
|
||||
expect(versions).toContain("normalizer_v2");
|
||||
expect(versions).toContain("normalizer_v2_0_1");
|
||||
expect(versions).toContain("normalizer_v2_0_2");
|
||||
});
|
||||
|
||||
it("merges user prompt values", () => {
|
||||
const bundle = buildPromptBundle({
|
||||
systemPrompt: "S",
|
||||
developerPrompt: "D",
|
||||
domainPrompt: "N",
|
||||
schemaNotes: "schema",
|
||||
fewShotExamples: "fewshot"
|
||||
});
|
||||
expect(bundle.systemPrompt).toBe("S");
|
||||
expect(bundle.developerPrompt).toBe("D");
|
||||
expect(bundle.domainPrompt).toBe("N");
|
||||
expect(bundle.combinedDeveloperPrompt.includes("fewshot")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ENRICHMENT_FLAG = "FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1";
|
||||
const ORIGINAL_FLAG_VALUE = process.env[ENRICHMENT_FLAG];
|
||||
|
||||
function restoreFlag(): void {
|
||||
if (ORIGINAL_FLAG_VALUE === undefined) {
|
||||
delete process.env[ENRICHMENT_FLAG];
|
||||
} else {
|
||||
process.env[ENRICHMENT_FLAG] = ORIGINAL_FLAG_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeSingleEvidence(flagValue: "0" | "1", evidenceRecord: Record<string, unknown>) {
|
||||
process.env[ENRICHMENT_FLAG] = flagValue;
|
||||
vi.resetModules();
|
||||
const { normalizeRetrievalResult } = await import("../src/services/retrievalResultNormalizer");
|
||||
return normalizeRetrievalResult("F1", ["R1"], "store_feature_risk", {
|
||||
status: "ok",
|
||||
result_type: "list",
|
||||
items: [],
|
||||
summary: {},
|
||||
evidence: [evidenceRecord],
|
||||
why_included: [],
|
||||
selection_reason: [],
|
||||
risk_factors: [],
|
||||
business_interpretation: [],
|
||||
confidence: "medium",
|
||||
limitations: [],
|
||||
errors: []
|
||||
});
|
||||
}
|
||||
|
||||
describe.sequential("retrieval evidence enrichment", () => {
|
||||
afterEach(() => {
|
||||
restoreFlag();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("builds deterministic canonical source_ref from pointer/source", async () => {
|
||||
const rawEvidence = {
|
||||
evidence_id: "ev-1",
|
||||
claim_ref: "requirement:R1",
|
||||
source_type: "retrieval_item",
|
||||
pointer: {
|
||||
fragment_id: "F1",
|
||||
route: "store_feature_risk",
|
||||
source: {
|
||||
namespace: "snapshot_2020",
|
||||
entity: "Document",
|
||||
id: "DOC-42",
|
||||
period: "2020-06"
|
||||
},
|
||||
locator: {
|
||||
field_path: "risk_score",
|
||||
item_index: 0
|
||||
}
|
||||
},
|
||||
risk_score: 3
|
||||
};
|
||||
|
||||
const first = await normalizeSingleEvidence("1", rawEvidence);
|
||||
const second = await normalizeSingleEvidence("1", rawEvidence);
|
||||
|
||||
expect(first.evidence[0].source_ref.canonical_ref).toBe(second.evidence[0].source_ref.canonical_ref);
|
||||
expect(first.evidence[0].source_ref.schema_version).toBe("evidence_source_ref_v1");
|
||||
expect(first.evidence[0].source_ref.namespace).toBe("snapshot_2020");
|
||||
expect(first.evidence[0].source_ref.entity).toBe("Document");
|
||||
expect(first.evidence[0].source_ref.id).toBe("DOC-42");
|
||||
});
|
||||
|
||||
it("uses honest weak-evidence fallback when mechanism is not reliable", async () => {
|
||||
const result = await normalizeSingleEvidence("1", {
|
||||
evidence_id: "ev-weak",
|
||||
source_entity: "DocumentJournal",
|
||||
source_id: "doc-weak-1",
|
||||
risk_score: 2
|
||||
});
|
||||
|
||||
expect(result.evidence[0].mechanism_note).toBeNull();
|
||||
expect(result.evidence[0].limitation?.reason_code).toBe("missing_mechanism");
|
||||
expect(result.evidence[0].confidence).toBe("low");
|
||||
});
|
||||
|
||||
it("maps explicit limitation to reason-coded structure", async () => {
|
||||
const result = await normalizeSingleEvidence("1", {
|
||||
evidence_id: "ev-limited",
|
||||
source_entity: "DocumentJournal",
|
||||
source_id: "doc-limited-1",
|
||||
limitation: "Snapshot-only evidence."
|
||||
});
|
||||
|
||||
expect(result.evidence[0].limitation?.reason_code).toBe("snapshot_only");
|
||||
expect(result.evidence[0].limitation?.note).toBe("Snapshot-only evidence.");
|
||||
});
|
||||
|
||||
it("keeps legacy inferred mechanism when enrichment flag is OFF", async () => {
|
||||
const result = await normalizeSingleEvidence("0", {
|
||||
evidence_id: "ev-legacy",
|
||||
source_entity: "DocumentJournal",
|
||||
source_id: "doc-legacy-1",
|
||||
risk_score: 2
|
||||
});
|
||||
|
||||
expect(typeof result.evidence[0].mechanism_note).toBe("string");
|
||||
expect(result.evidence[0].mechanism_note).toContain("Anomaly signal inferred");
|
||||
expect(result.evidence[0].limitation).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toRouteHintSummary, toRouterInput } from "../src/services/routeHintAdapter";
|
||||
import { normalizedFixture, normalizedFixtureV2, normalizedFixtureV2_0_1, normalizedFixtureV2_0_2 } from "./fixtures";
|
||||
|
||||
describe("routeHintAdapter", () => {
|
||||
it("builds v1 route hint summary", () => {
|
||||
const summary = toRouteHintSummary(normalizedFixture());
|
||||
expect(summary.mode).toBe("legacy_v1");
|
||||
if (summary.mode !== "legacy_v1") {
|
||||
throw new Error("Expected legacy_v1 summary");
|
||||
}
|
||||
expect(summary.route_hint).toBe("hybrid_store_plus_live");
|
||||
expect(summary.decision_flags.needs_cross_entity_join).toBe(true);
|
||||
expect(summary.entities.accounts_mentioned).toEqual(["60"]);
|
||||
});
|
||||
|
||||
it("builds v2 deterministic route simulation", () => {
|
||||
const summary = toRouteHintSummary(normalizedFixtureV2());
|
||||
expect(summary.mode).toBe("deterministic_v2");
|
||||
if (summary.mode !== "deterministic_v2") {
|
||||
throw new Error("Expected deterministic_v2 summary");
|
||||
}
|
||||
expect(summary.planner.total_fragments).toBe(1);
|
||||
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
|
||||
});
|
||||
|
||||
it("builds router input contract for v1", () => {
|
||||
const routerInput = toRouterInput(normalizedFixture());
|
||||
expect(routerInput.route_hint).toBe("hybrid_store_plus_live");
|
||||
expect(routerInput.intent_class).toBe("cross_entity");
|
||||
});
|
||||
|
||||
it("keeps v2.0.1 soft assumptions executable in deterministic routing", () => {
|
||||
const summary = toRouteHintSummary(normalizedFixtureV2_0_1());
|
||||
expect(summary.mode).toBe("deterministic_v2");
|
||||
if (summary.mode !== "deterministic_v2") {
|
||||
throw new Error("Expected deterministic_v2 summary");
|
||||
}
|
||||
expect(summary.fallback.type).toBe("none");
|
||||
expect(summary.decisions[0]?.execution_readiness).toBe("executable_with_soft_assumptions");
|
||||
expect(summary.decisions[0]?.route).toBe("store_feature_risk");
|
||||
});
|
||||
|
||||
it("uses explicit v2.0.2 route_status/no_route_reason contract", () => {
|
||||
const summary = toRouteHintSummary(normalizedFixtureV2_0_2());
|
||||
expect(summary.mode).toBe("deterministic_v2");
|
||||
if (summary.mode !== "deterministic_v2") {
|
||||
throw new Error("Expected deterministic_v2 summary");
|
||||
}
|
||||
expect(summary.decisions[0]?.route_status).toBe("routed");
|
||||
expect(summary.decisions[0]?.no_route_reason).toBeNull();
|
||||
expect(summary.decisions[0]?.route).toBe("store_feature_risk");
|
||||
|
||||
const routerInput = toRouterInput(normalizedFixtureV2_0_2());
|
||||
const first = (routerInput.fragments as Array<Record<string, unknown>>)[0];
|
||||
expect(first.route_status).toBe("routed");
|
||||
expect(first.no_route_reason).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateNormalized } from "../src/services/schemaValidator";
|
||||
import { normalizedFixture, normalizedFixtureV2, normalizedFixtureV2_0_1, normalizedFixtureV2_0_2 } from "./fixtures";
|
||||
|
||||
describe("schemaValidator", () => {
|
||||
it("passes valid normalized payload", () => {
|
||||
const result = validateNormalized(normalizedFixture(), "v1");
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails invalid payload", () => {
|
||||
const invalid = { ...normalizedFixture(), route_hint: "unknown_route" };
|
||||
const result = validateNormalized(invalid, "v1");
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes valid normalized v2 payload", () => {
|
||||
const result = validateNormalized(normalizedFixtureV2(), "v2");
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails invalid v2 payload", () => {
|
||||
const invalid = { ...normalizedFixtureV2(), schema_version: "normalized_query_v1" };
|
||||
const result = validateNormalized(invalid, "v2");
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes valid normalized v2.0.1 payload", () => {
|
||||
const result = validateNormalized(normalizedFixtureV2_0_1(), "v2_0_1");
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes valid normalized v2.0.2 payload", () => {
|
||||
const result = validateNormalized(normalizedFixtureV2_0_2(), "v2_0_2");
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||
|
||||
describe("assistant session backward compatibility", () => {
|
||||
it("lazy-upgrades legacy session objects without investigation_state", () => {
|
||||
const store = new AssistantSessionStore();
|
||||
const sessionsMap = (store as unknown as { sessions: Map<string, unknown> }).sessions;
|
||||
const sessionId = "legacy-session-1";
|
||||
|
||||
sessionsMap.set(sessionId, {
|
||||
session_id: sessionId,
|
||||
updated_at: "2026-03-25T10:00:00.000Z",
|
||||
items: []
|
||||
});
|
||||
|
||||
const session = store.getSession(sessionId);
|
||||
expect(session).toBeTruthy();
|
||||
expect(session?.session_id).toBe(sessionId);
|
||||
expect(Array.isArray(session?.items)).toBe(true);
|
||||
expect(session?.investigation_state?.schema_version).toBe("investigation_state_v1");
|
||||
});
|
||||
|
||||
it("normalizes malformed legacy sessions with missing items array", () => {
|
||||
const store = new AssistantSessionStore();
|
||||
const sessionsMap = (store as unknown as { sessions: Map<string, unknown> }).sessions;
|
||||
const sessionId = "legacy-session-2";
|
||||
|
||||
sessionsMap.set(sessionId, {
|
||||
session_id: sessionId
|
||||
});
|
||||
|
||||
const ensured = store.ensureSession(sessionId);
|
||||
expect(ensured.session_id).toBe(sessionId);
|
||||
expect(Array.isArray(ensured.items)).toBe(true);
|
||||
expect(ensured.items.length).toBe(0);
|
||||
expect(ensured.investigation_state?.schema_version).toBe("investigation_state_v1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user