АРЧ - UI и domain loop: синхронизировать локальную LLM-модель через shared config + КВИН 3

This commit is contained in:
dctouch 2026-04-15 09:15:35 +03:00
parent bc381c012e
commit 8866176be6
24 changed files with 801 additions and 73 deletions

View File

@ -41,8 +41,8 @@
"buyer_candidate": "Департамент капитального ремонта города Москвы" "buyer_candidate": "Департамент капитального ремонта города Москвы"
}, },
"question_pool": { "question_pool": {
"total_questions": 30, "total_questions": 31,
"core_questions_total": 21, "core_questions_total": 22,
"followup_checkpoints_total": 9, "followup_checkpoints_total": 9,
"questions": [ "questions": [
{ {
@ -279,6 +279,15 @@
"wording_family": "ui_selected_object_colloquial", "wording_family": "ui_selected_object_colloquial",
"semantic_goal": "проверить разговорный selected-object follow-up про поставщика без слова `поставщик`" "semantic_goal": "проверить разговорный selected-object follow-up про поставщика без слова `поставщик`"
}, },
{
"question_id": "Q31",
"text": "По выбранному объекту \"...\": у кого куплено",
"layer": "selected_item_provenance",
"node_id": "N03_selected_item_supplier",
"role": "critical_child",
"wording_family": "ui_selected_object_colloquial",
"semantic_goal": "проверить selected-object follow-up с краткой страдательной формой `у кого куплено`, который должен оставаться в supplier provenance и наследовать историческую дату root-среза"
},
{ {
"question_id": "Q29", "question_id": "Q29",
"text": "По выбранному объекту \"...\": где мы купили это", "text": "По выбранному объекту \"...\": где мы купили это",
@ -1032,6 +1041,51 @@
"organization_scope" "organization_scope"
] ]
}, },
{
"step_id": "step_03bb_selected_item_supplier_u_kogo_kupleno",
"question_id": "Q31",
"node_id": "N03_selected_item_supplier",
"node_role": "critical_child",
"paraphrase_family": "ui_selected_object_colloquial",
"title": "Selected item supplier passive colloquial wording",
"question": "По выбранному объекту \"{{bindings.focus_item_historical}}\": у кого куплено",
"depends_on": [
"step_01_snapshot_historical"
],
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "inventory_purchase_provenance_for_item",
"forbidden_capabilities": [
"confirmed_inventory_on_hand_as_of_date"
],
"forbidden_recipes": [
"address_inventory_on_hand_as_of_date_v1"
],
"required_state_objects": [
"focus_object"
],
"required_carryover_invariants": [
"selected_object",
"focus_object",
"date_scope",
"warehouse_scope",
"organization_scope"
],
"required_filters": {
"as_of_date": "2019-03-31",
"period_from": "2019-03-01",
"period_to": "2019-03-31"
},
"invariant_severity": {
"wrong_as_of_date": "P0",
"wrong_period_from": "P0",
"wrong_period_to": "P0",
"forbidden_route_selected": "P0",
"focus_object_missing": "P0"
}
},
{ {
"step_id": "step_03c_selected_item_supplier_gde_my_kupili", "step_id": "step_03c_selected_item_supplier_gde_my_kupili",
"question_id": "Q29", "question_id": "Q29",
@ -1852,7 +1906,7 @@
}, },
{ {
"pattern_id": "F12b_selected_object_supplier_u_kogo_kupili_misroute", "pattern_id": "F12b_selected_object_supplier_u_kogo_kupili_misroute",
"symptom": "selected-object follow-up such as `у кого купили` stays on the root stock snapshot instead of switching to selected-item provenance", "symptom": "selected-object follow-up such as `у кого купили` or `у кого куплено` stays on the root stock snapshot instead of switching to selected-item provenance",
"defect_class": "followup_action_resolution_gap" "defect_class": "followup_action_resolution_gap"
}, },
{ {

View File

@ -4,7 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1 = exports.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = exports.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = exports.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 = exports.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0; exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1 = exports.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = exports.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = exports.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 = exports.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = void 0; exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.SHARED_LLM_CONNECTION_FILE = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = void 0;
const path_1 = __importDefault(require("path")); const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, ".."); exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, ".."); exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@ -90,6 +90,7 @@ exports.AUTORUN_ANNOTATIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "auto
exports.AUTORUN_ANNOTATIONS_FILE = path_1.default.resolve(exports.AUTORUN_ANNOTATIONS_DIR, "annotations.json"); exports.AUTORUN_ANNOTATIONS_FILE = path_1.default.resolve(exports.AUTORUN_ANNOTATIONS_DIR, "annotations.json");
exports.AUTORUN_GENERATOR_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_generators"); exports.AUTORUN_GENERATOR_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_generators");
exports.AUTORUN_GENERATOR_HISTORY_FILE = path_1.default.resolve(exports.AUTORUN_GENERATOR_DIR, "history.json"); exports.AUTORUN_GENERATOR_HISTORY_FILE = path_1.default.resolve(exports.AUTORUN_GENERATOR_DIR, "history.json");
exports.SHARED_LLM_CONNECTION_FILE = path_1.default.resolve(exports.DATA_DIR, "shared_llm_connection.json");
exports.PROMPTS_DIR = path_1.default.resolve(exports.MODULE_ROOT, "prompts"); exports.PROMPTS_DIR = path_1.default.resolve(exports.MODULE_ROOT, "prompts");
exports.REPORTS_DIR = path_1.default.resolve(exports.MODULE_ROOT, "reports"); 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.EVAL_DATASETS_DIR = path_1.default.resolve(exports.MODULE_ROOT, "eval_cases");

View File

@ -0,0 +1,107 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildSharedLlmConfigRouter = buildSharedLlmConfigRouter;
const express_1 = require("express");
const config_1 = require("../config");
const http_1 = require("../utils/http");
const files_1 = require("../utils/files");
const path_1 = __importDefault(require("path"));
function sanitizeString(value, fallback) {
if (typeof value !== "string") {
return fallback;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
function sanitizeNumber(value, fallback) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
}
function buildFallbackRecord() {
return {
schema_version: "shared_llm_connection_v1",
updated_at: "",
connection: {
llmProvider: "local",
model: "qwen2.5-14b-instruct-1m",
baseUrl: "http://127.0.0.1:1234/v1",
temperature: 0,
maxOutputTokens: 900
}
};
}
function loadSharedRecord() {
const fallback = buildFallbackRecord();
const parsed = (0, files_1.readJsonFile)(config_1.SHARED_LLM_CONNECTION_FILE, null);
if (!parsed || typeof parsed !== "object" || !("connection" in parsed) || !parsed.connection) {
return null;
}
const connection = parsed.connection;
return {
schema_version: "shared_llm_connection_v1",
updated_at: sanitizeString(parsed.updated_at, ""),
connection: {
llmProvider: connection.llmProvider === "local" ? "local" : "openai",
model: sanitizeString(connection.model, fallback.connection.model),
baseUrl: sanitizeString(connection.baseUrl, fallback.connection.baseUrl),
temperature: sanitizeNumber(connection.temperature, fallback.connection.temperature),
maxOutputTokens: Math.max(1, Math.trunc(sanitizeNumber(connection.maxOutputTokens, fallback.connection.maxOutputTokens)))
}
};
}
function buildSharedLlmConfigRouter() {
const router = (0, express_1.Router)();
router.get("/api/llm/shared-connection", (_req, res, next) => {
try {
const record = loadSharedRecord();
(0, http_1.ok)(res, {
ok: true,
connection: record?.connection ?? null,
updated_at: record?.updated_at ?? null,
exists: Boolean(record)
});
}
catch (error) {
next(error);
}
});
router.post("/api/llm/shared-connection", (req, res, next) => {
try {
const body = (req.body ?? {});
const fallback = buildFallbackRecord();
const record = {
schema_version: "shared_llm_connection_v1",
updated_at: new Date().toISOString(),
connection: {
llmProvider: body.llmProvider === "local" ? "local" : "openai",
model: sanitizeString(body.model, fallback.connection.model),
baseUrl: sanitizeString(body.baseUrl, fallback.connection.baseUrl),
temperature: sanitizeNumber(body.temperature, fallback.connection.temperature),
maxOutputTokens: Math.max(1, Math.trunc(sanitizeNumber(body.maxOutputTokens, fallback.connection.maxOutputTokens)))
}
};
(0, files_1.ensureDir)(path_1.default.dirname(config_1.SHARED_LLM_CONNECTION_FILE));
(0, files_1.writeJsonFile)(config_1.SHARED_LLM_CONNECTION_FILE, record);
(0, http_1.ok)(res, {
ok: true,
connection: record.connection,
updated_at: record.updated_at
});
}
catch (error) {
next(error);
}
});
return router;
}

View File

@ -15,6 +15,7 @@ const eval_1 = require("./routes/eval");
const history_1 = require("./routes/history"); const history_1 = require("./routes/history");
const normalize_1 = require("./routes/normalize"); const normalize_1 = require("./routes/normalize");
const presets_1 = require("./routes/presets"); const presets_1 = require("./routes/presets");
const sharedLlmConfig_1 = require("./routes/sharedLlmConfig");
const testConnection_1 = require("./routes/testConnection"); const testConnection_1 = require("./routes/testConnection");
const inMemoryRuntimeAdapter_1 = require("./runtime/inMemoryRuntimeAdapter"); const inMemoryRuntimeAdapter_1 = require("./runtime/inMemoryRuntimeAdapter");
const assistantService_1 = require("./services/assistantService"); const assistantService_1 = require("./services/assistantService");
@ -59,6 +60,7 @@ function createApp() {
}); });
}); });
app.use((0, testConnection_1.buildTestConnectionRouter)(openaiClient)); app.use((0, testConnection_1.buildTestConnectionRouter)(openaiClient));
app.use((0, sharedLlmConfig_1.buildSharedLlmConfigRouter)());
app.use((0, normalize_1.buildNormalizeRouter)(services)); app.use((0, normalize_1.buildNormalizeRouter)(services));
app.use((0, eval_1.buildEvalRouter)(services)); app.use((0, eval_1.buildEvalRouter)(services));
app.use((0, assistant_1.buildAssistantRouter)(services)); app.use((0, assistant_1.buildAssistantRouter)(services));

View File

@ -1345,7 +1345,7 @@ function hasSelectedObjectInventoryCue(text) {
} }
function hasSelectedObjectInventoryProvenanceSignal(text) { function hasSelectedObjectInventoryProvenanceSignal(text) {
return (hasSelectedObjectInventoryCue(text) && return (hasSelectedObjectInventoryCue(text) &&
/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(text)); /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(text));
} }
function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) {
return (hasSelectedObjectInventoryCue(text) && return (hasSelectedObjectInventoryCue(text) &&
@ -1353,7 +1353,7 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) {
} }
function hasInventoryProvenanceSignalV2(text) { function hasInventoryProvenanceSignalV2(text) {
const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text);
const hasSupplierCue = /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(text); const hasSupplierCue = /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(text);
const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text); const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text);
return hasItemCue && hasSupplierCue && hasPurchaseCue; return hasItemCue && hasSupplierCue && hasPurchaseCue;
} }

View File

@ -269,7 +269,7 @@ function hasSelectedObjectInventoryFollowupSignal(text) {
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) { if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
return false; return false;
} }
return /(?:у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(text); return /(?:у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(text);
} }
function hasDocsOrBankSignal(text) { function hasDocsOrBankSignal(text) {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text); return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);

View File

@ -25,7 +25,7 @@ function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? "")); return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
} }
function hasExplicitPeriodLiteral(text) { function hasExplicitPeriodLiteral(text) {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? "")); return /(?:^|[^\d*×xх])((?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?)(?=$|[^\d*×xх])/iu.test(String(text ?? ""));
} }
function hasExplicitCurrentDateHint(text) { function hasExplicitCurrentDateHint(text) {
return /(?:на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодня|сегодня|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)/iu.test(String(text ?? "")); return /(?:на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодня|сегодня|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)/iu.test(String(text ?? ""));
@ -257,7 +257,7 @@ function hasSelectedObjectInventorySignal(text) {
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? "")); return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
} }
function hasInventorySupplierFollowupCue(text) { function hasInventorySupplierFollowupCue(text) {
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(String(text ?? "")); return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(String(text ?? ""));
} }
function hasInventoryPurchaseDocumentsFollowupCue(text) { function hasInventoryPurchaseDocumentsFollowupCue(text) {
return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(String(text ?? "")); return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(String(text ?? ""));

View File

@ -188,6 +188,7 @@ export const AUTORUN_ANNOTATIONS_DIR = path.resolve(DATA_DIR, "autorun_annotatio
export const AUTORUN_ANNOTATIONS_FILE = path.resolve(AUTORUN_ANNOTATIONS_DIR, "annotations.json"); export const AUTORUN_ANNOTATIONS_FILE = path.resolve(AUTORUN_ANNOTATIONS_DIR, "annotations.json");
export const AUTORUN_GENERATOR_DIR = path.resolve(DATA_DIR, "autorun_generators"); export const AUTORUN_GENERATOR_DIR = path.resolve(DATA_DIR, "autorun_generators");
export const AUTORUN_GENERATOR_HISTORY_FILE = path.resolve(AUTORUN_GENERATOR_DIR, "history.json"); export const AUTORUN_GENERATOR_HISTORY_FILE = path.resolve(AUTORUN_GENERATOR_DIR, "history.json");
export const SHARED_LLM_CONNECTION_FILE = path.resolve(DATA_DIR, "shared_llm_connection.json");
export const PROMPTS_DIR = path.resolve(MODULE_ROOT, "prompts"); export const PROMPTS_DIR = path.resolve(MODULE_ROOT, "prompts");
export const REPORTS_DIR = path.resolve(MODULE_ROOT, "reports"); export const REPORTS_DIR = path.resolve(MODULE_ROOT, "reports");

View File

@ -0,0 +1,124 @@
import { NextFunction, Request, Response, Router } from "express";
import { SHARED_LLM_CONNECTION_FILE } from "../config";
import { ok } from "../utils/http";
import { ensureDir, readJsonFile, writeJsonFile } from "../utils/files";
import path from "path";
type LlmProvider = "openai" | "local";
interface SharedLlmConnectionRecord {
schema_version: "shared_llm_connection_v1";
updated_at: string;
connection: {
llmProvider: LlmProvider;
model: string;
baseUrl: string;
temperature: number;
maxOutputTokens: number;
};
}
function sanitizeString(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
function sanitizeNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
}
function buildFallbackRecord(): SharedLlmConnectionRecord {
return {
schema_version: "shared_llm_connection_v1",
updated_at: "",
connection: {
llmProvider: "local",
model: "qwen2.5-14b-instruct-1m",
baseUrl: "http://127.0.0.1:1234/v1",
temperature: 0,
maxOutputTokens: 900
}
};
}
function loadSharedRecord(): SharedLlmConnectionRecord | null {
const fallback = buildFallbackRecord();
const parsed = readJsonFile<SharedLlmConnectionRecord | null>(SHARED_LLM_CONNECTION_FILE, null);
if (!parsed || typeof parsed !== "object" || !("connection" in parsed) || !parsed.connection) {
return null;
}
const connection = parsed.connection;
return {
schema_version: "shared_llm_connection_v1",
updated_at: sanitizeString(parsed.updated_at, ""),
connection: {
llmProvider: connection.llmProvider === "local" ? "local" : "openai",
model: sanitizeString(connection.model, fallback.connection.model),
baseUrl: sanitizeString(connection.baseUrl, fallback.connection.baseUrl),
temperature: sanitizeNumber(connection.temperature, fallback.connection.temperature),
maxOutputTokens: Math.max(1, Math.trunc(sanitizeNumber(connection.maxOutputTokens, fallback.connection.maxOutputTokens)))
}
};
}
export function buildSharedLlmConfigRouter(): Router {
const router = Router();
router.get("/api/llm/shared-connection", (_req: Request, res: Response, next: NextFunction) => {
try {
const record = loadSharedRecord();
ok(res, {
ok: true,
connection: record?.connection ?? null,
updated_at: record?.updated_at ?? null,
exists: Boolean(record)
});
} catch (error) {
next(error);
}
});
router.post("/api/llm/shared-connection", (req: Request, res: Response, next: NextFunction) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const fallback = buildFallbackRecord();
const record: SharedLlmConnectionRecord = {
schema_version: "shared_llm_connection_v1",
updated_at: new Date().toISOString(),
connection: {
llmProvider: body.llmProvider === "local" ? "local" : "openai",
model: sanitizeString(body.model, fallback.connection.model),
baseUrl: sanitizeString(body.baseUrl, fallback.connection.baseUrl),
temperature: sanitizeNumber(body.temperature, fallback.connection.temperature),
maxOutputTokens: Math.max(
1,
Math.trunc(sanitizeNumber(body.maxOutputTokens, fallback.connection.maxOutputTokens))
)
}
};
ensureDir(path.dirname(SHARED_LLM_CONNECTION_FILE));
writeJsonFile(SHARED_LLM_CONNECTION_FILE, record);
ok(res, {
ok: true,
connection: record.connection,
updated_at: record.updated_at
});
} catch (error) {
next(error);
}
});
return router;
}

View File

@ -20,6 +20,7 @@ import { buildEvalRouter } from "./routes/eval";
import { buildHistoryRouter } from "./routes/history"; import { buildHistoryRouter } from "./routes/history";
import { buildNormalizeRouter } from "./routes/normalize"; import { buildNormalizeRouter } from "./routes/normalize";
import { buildPresetsRouter } from "./routes/presets"; import { buildPresetsRouter } from "./routes/presets";
import { buildSharedLlmConfigRouter } from "./routes/sharedLlmConfig";
import { buildTestConnectionRouter } from "./routes/testConnection"; import { buildTestConnectionRouter } from "./routes/testConnection";
import { InMemoryRuntimeAdapter } from "./runtime/inMemoryRuntimeAdapter"; import { InMemoryRuntimeAdapter } from "./runtime/inMemoryRuntimeAdapter";
import { AssistantService } from "./services/assistantService"; import { AssistantService } from "./services/assistantService";
@ -71,6 +72,7 @@ export function createApp(): express.Express {
}); });
app.use(buildTestConnectionRouter(openaiClient)); app.use(buildTestConnectionRouter(openaiClient));
app.use(buildSharedLlmConfigRouter());
app.use(buildNormalizeRouter(services)); app.use(buildNormalizeRouter(services));
app.use(buildEvalRouter(services)); app.use(buildEvalRouter(services));
app.use(buildAssistantRouter(services)); app.use(buildAssistantRouter(services));

View File

@ -1612,7 +1612,7 @@ function hasSelectedObjectInventoryCue(text: string): boolean {
function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean { function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean {
return ( return (
hasSelectedObjectInventoryCue(text) && hasSelectedObjectInventoryCue(text) &&
/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test( /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(
text text
) )
); );
@ -1630,7 +1630,7 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea
function hasInventoryProvenanceSignalV2(text: string): boolean { function hasInventoryProvenanceSignalV2(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text);
const hasSupplierCue = const hasSupplierCue =
/(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test( /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(
text text
); );
const hasPurchaseCue = const hasPurchaseCue =

View File

@ -278,7 +278,7 @@ function hasSelectedObjectInventoryFollowupSignal(text: string): boolean {
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) { if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
return false; return false;
} }
return /(?:у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test( return /(?:у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(
text text
); );
} }

View File

@ -59,7 +59,7 @@ function hasSameDateHint(text: string): boolean {
} }
function hasExplicitPeriodLiteral(text: string): boolean { function hasExplicitPeriodLiteral(text: string): boolean {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? "")); return /(?:^|[^\d*×xх])((?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?)(?=$|[^\d*×xх])/iu.test(String(text ?? ""));
} }
function hasExplicitCurrentDateHint(text: string): boolean { function hasExplicitCurrentDateHint(text: string): boolean {
@ -323,7 +323,7 @@ function hasSelectedObjectInventorySignal(text: string): boolean {
} }
function hasInventorySupplierFollowupCue(text: string): boolean { function hasInventorySupplierFollowupCue(text: string): boolean {
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test( return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(
String(text ?? "") String(text ?? "")
); );
} }

View File

@ -245,6 +245,60 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
}); });
it("handles selected-object colloquial supplier wording 'у кого куплено' as provenance follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2020-06-18T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000101 от 18.06.2020 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 498472.5,
SubcontoDt1: "Конструкция трансформер рабочей станции 1300*900*2000",
SubcontoDt3: "Основной склад",
SubcontoKt1: "ООО \\Гамма-мебель\\",
SubcontoKt2: "Договор поставки № 11 от 15.06.2020",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Конструкция трансформер рабочей станции 1300*900*2000": у кого куплено',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2020-06-30",
period_from: "2020-06-01",
period_to: "2020-06-30",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
}
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Конструкция трансформер рабочей станции 1300*900*2000");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-06-30");
expect(result?.debug.extracted_filters?.period_from).toBe("2020-06-01");
expect(result?.debug.extracted_filters?.period_to).toBe("2020-06-30");
expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_provenance_for_item");
expect(result?.debug.capability_route_mode).toBe("exact");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\");
});
it("handles selected-object wording 'где мы купили это' as provenance follow-up", async () => { it("handles selected-object wording 'где мы купили это' as provenance follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({ executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1, fetched_rows: 1,

View File

@ -373,6 +373,112 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled(); expect(normalizerService.normalize).not.toHaveBeenCalled();
}); });
it("keeps historical stock date window for selected-object supplier wording 'у кого куплено'", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const rootMessage = 'какие у нас остатки на складе на июнь 2020';
const followupMessage = 'По выбранному объекту "Конструкция трансформер рабочей станции 1300*900*2000": у кого куплено';
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === rootMessage) {
return {
handled: true,
reply_text:
"На 30.06.2020 на складе подтверждено 12 позиций с остатком на 755.392,33 ₽.\n1. Конструкция трансформер рабочей станции 1300*900*2000 | склад: Основной склад",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
as_of_date: "2020-06-30",
period_from: "2020-06-01",
period_to: "2020-06-30",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
capability_id: "confirmed_inventory_on_hand_as_of_date",
capability_route_mode: "exact",
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "address_entity_detected"]
}
};
}
return {
handled: true,
reply_text:
"Поставщик по позиции Конструкция трансформер рабочей станции 1300*900*2000: ООО \\Гамма-мебель\\.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
detected_intent_confidence: "medium",
extracted_filters: {
item: "Конструкция трансформер рабочей станции 1300*900*2000",
as_of_date: "2020-06-30",
period_from: "2020-06-01",
period_to: "2020-06-30"
},
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
capability_id: "inventory_inventory_purchase_provenance_for_item",
capability_route_mode: "exact",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
},
...(options?.followupContext ? {} : {})
};
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-u-kogo-kupleno-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: rootMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-06-30");
expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2020-06-01");
expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2020-06-30");
expect(calls[1].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => { it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => {
const calls: Array<{ message: string; options?: any }> = []; const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020"; const firstMessage = "покажи документы по свк за 2020";

View File

@ -0,0 +1,11 @@
{
"schema_version": "shared_llm_connection_v1",
"updated_at": "2026-04-15T06:12:46.714Z",
"connection": {
"llmProvider": "local",
"model": "unsloth/qwen3-30b-a3b-instruct-2507",
"baseUrl": "http://127.0.0.1:1234/v1",
"temperature": 0.8,
"maxOutputTokens": 900
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title> <title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-B9mz4jx4.js"></script> <script type="module" crossorigin src="/assets/index-Qq5WpuqR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dcuz1nX5.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dcuz1nX5.css">
</head> </head>
<body> <body>

View File

@ -153,6 +153,7 @@ export default function App() {
}); });
const presetAutoloadDoneRef = useRef(false); const presetAutoloadDoneRef = useRef(false);
const skipPresetAutoloadRef = useRef(false); const skipPresetAutoloadRef = useRef(false);
const sharedConnectionSyncReadyRef = useRef(false);
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@ -192,22 +193,45 @@ export default function App() {
} }
useEffect(() => { useEffect(() => {
const cached = localStorage.getItem(SESSION_CONFIG_KEY); const bootstrapSharedConnection = async () => {
if (cached) { const cached = localStorage.getItem(SESSION_CONFIG_KEY);
try { if (cached) {
const parsed = JSON.parse(cached) as Partial<ConnectionState>; try {
setConnection((prev) => ({ const parsed = JSON.parse(cached) as Partial<ConnectionState>;
...prev, setConnection((prev) => ({
llmProvider: parsed.llmProvider === "local" ? "local" : "openai", ...prev,
model: parsed.model ?? prev.model, llmProvider: parsed.llmProvider === "local" ? "local" : "openai",
baseUrl: parsed.baseUrl ?? prev.baseUrl, model: parsed.model ?? prev.model,
temperature: parsed.temperature ?? prev.temperature, baseUrl: parsed.baseUrl ?? prev.baseUrl,
maxOutputTokens: parsed.maxOutputTokens ?? prev.maxOutputTokens temperature: parsed.temperature ?? prev.temperature,
})); maxOutputTokens: parsed.maxOutputTokens ?? prev.maxOutputTokens
} catch { }));
// ignore broken local cache } catch {
// ignore broken local cache
}
} }
}
try {
const payload = await apiClient.loadSharedConnectionConfig();
if (payload.connection && payload.connection.llmProvider === "local") {
setConnection((prev) => ({
...prev,
llmProvider: "local",
model: payload.connection?.model ?? prev.model,
baseUrl: payload.connection?.baseUrl ?? prev.baseUrl,
temperature: payload.connection?.temperature ?? prev.temperature,
maxOutputTokens: payload.connection?.maxOutputTokens ?? prev.maxOutputTokens
}));
log(`Shared local LLM config loaded: ${payload.connection.model}`);
}
} catch (error) {
log(`Shared local config load error: ${error instanceof Error ? error.message : String(error)}`);
} finally {
sharedConnectionSyncReadyRef.current = true;
}
};
void bootstrapSharedConnection();
const cachedAutorunsLayout = localStorage.getItem(AUTORUNS_LAYOUT_CONFIG_KEY); const cachedAutorunsLayout = localStorage.getItem(AUTORUNS_LAYOUT_CONFIG_KEY);
if (cachedAutorunsLayout) { if (cachedAutorunsLayout) {
@ -304,6 +328,23 @@ export default function App() {
void refreshRuns(); void refreshRuns();
}, []); }, []);
useEffect(() => {
if (!sharedConnectionSyncReadyRef.current) {
return;
}
if (connection.llmProvider !== "local") {
return;
}
const timer = window.setTimeout(() => {
void apiClient
.saveSharedConnectionConfig(connection)
.catch((error) =>
log(`Shared local config sync error: ${error instanceof Error ? error.message : String(error)}`)
);
}, 250);
return () => window.clearTimeout(timer);
}, [connection.baseUrl, connection.llmProvider, connection.maxOutputTokens, connection.model, connection.temperature]);
async function refreshHistory() { async function refreshHistory() {
try { try {
const payload = await apiClient.loadHistory(); const payload = await apiClient.loadHistory();
@ -369,6 +410,17 @@ export default function App() {
maxOutputTokens: connection.maxOutputTokens maxOutputTokens: connection.maxOutputTokens
}) })
); );
if (connection.llmProvider === "local") {
void apiClient
.saveSharedConnectionConfig(connection)
.then(() => {
log("Local config saved and synced to shared agent config (without API key).");
})
.catch((error) => {
log(`Local config saved, but shared sync failed: ${error instanceof Error ? error.message : String(error)}`);
});
return;
}
log("Local config saved (without API key)."); log("Local config saved (without API key).");
} }

View File

@ -41,6 +41,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
export const apiClient = { export const apiClient = {
async loadSharedConnectionConfig(): Promise<{
ok: boolean;
connection: Omit<ConnectionState, "apiKey"> | null;
updated_at: string | null;
exists: boolean;
}> {
return request("/llm/shared-connection");
},
async saveSharedConnectionConfig(connection: ConnectionState): Promise<{
ok: boolean;
connection: Omit<ConnectionState, "apiKey">;
updated_at: string;
}> {
return request("/llm/shared-connection", {
method: "POST",
body: JSON.stringify({
llmProvider: connection.llmProvider,
model: connection.model,
baseUrl: connection.baseUrl,
temperature: connection.temperature,
maxOutputTokens: connection.maxOutputTokens
})
});
},
async listModels(connection: ConnectionState): Promise<{ ok: boolean; models: string[]; count: number; timestamp: string }> { async listModels(connection: ConnectionState): Promise<{ ok: boolean; models: string[]; count: number; timestamp: string }> {
return request("/llm/models", { return request("/llm/models", {
method: "POST", method: "POST",

View File

@ -2,6 +2,57 @@ import { useEffect, useState } from "react";
import { PanelFrame } from "./PanelFrame"; import { PanelFrame } from "./PanelFrame";
import type { ConnectionState } from "../state/types"; import type { ConnectionState } from "../state/types";
const LOCAL_BASE_URL = "http://127.0.0.1:1234/v1";
const OPENAI_BASE_URL = "https://api.openai.com/v1";
const LOCAL_QWEN25_MODEL = "qwen2.5-14b-instruct-1m";
const LOCAL_QWEN3_MODEL = "unsloth/qwen3-30b-a3b-instruct-2507";
type ProviderPreset = "openai" | "local_qwen25" | "local_qwen3" | "local_custom";
interface ModelOptionDescriptor {
value: string;
label: string;
}
const BUILTIN_LOCAL_MODEL_OPTIONS: ModelOptionDescriptor[] = [
{
value: LOCAL_QWEN25_MODEL,
label: "Qwen2.5 14B Instruct 1M"
},
{
value: LOCAL_QWEN3_MODEL,
label: "Qwen3 30B A3B Instruct 2507"
}
];
function deriveProviderPreset(value: ConnectionState): ProviderPreset {
if (value.llmProvider !== "local") {
return "openai";
}
if (value.model === LOCAL_QWEN3_MODEL) {
return "local_qwen3";
}
if (value.model === LOCAL_QWEN25_MODEL) {
return "local_qwen25";
}
return "local_custom";
}
function buildModelOptions(modelOptions: string[], isLocal: boolean): ModelOptionDescriptor[] {
const merged = new Map<string, ModelOptionDescriptor>();
if (isLocal) {
for (const option of BUILTIN_LOCAL_MODEL_OPTIONS) {
merged.set(option.value, option);
}
}
for (const modelId of modelOptions) {
if (!merged.has(modelId)) {
merged.set(modelId, { value: modelId, label: modelId });
}
}
return Array.from(merged.values());
}
interface ConnectionPanelProps { interface ConnectionPanelProps {
value: ConnectionState; value: ConnectionState;
modelOptions: string[]; modelOptions: string[];
@ -26,7 +77,9 @@ export function ConnectionPanel({
busy busy
}: ConnectionPanelProps) { }: ConnectionPanelProps) {
const isLocal = value.llmProvider === "local"; const isLocal = value.llmProvider === "local";
const modelInCatalog = modelOptions.includes(value.model); const providerPreset = deriveProviderPreset(value);
const availableModelOptions = buildModelOptions(modelOptions, isLocal);
const modelInCatalog = availableModelOptions.some((option) => option.value === value.model);
const [temperatureInput, setTemperatureInput] = useState(String(value.temperature)); const [temperatureInput, setTemperatureInput] = useState(String(value.temperature));
const [maxOutputTokensInput, setMaxOutputTokensInput] = useState(String(value.maxOutputTokens)); const [maxOutputTokensInput, setMaxOutputTokensInput] = useState(String(value.maxOutputTokens));
@ -78,18 +131,47 @@ export function ConnectionPanel({
<label> <label>
Provider Provider
<select <select
value={value.llmProvider} value={providerPreset}
onChange={(event) => { onChange={(event) => {
const nextProvider = event.target.value === "local" ? "local" : "openai"; const selected = event.target.value as ProviderPreset;
if (selected === "openai") {
onChange({
...value,
llmProvider: "openai",
baseUrl: OPENAI_BASE_URL
});
return;
}
if (selected === "local_qwen25") {
onChange({
...value,
llmProvider: "local",
model: LOCAL_QWEN25_MODEL,
baseUrl: LOCAL_BASE_URL
});
return;
}
if (selected === "local_qwen3") {
onChange({
...value,
llmProvider: "local",
model: LOCAL_QWEN3_MODEL,
baseUrl: LOCAL_BASE_URL
});
return;
}
onChange({ onChange({
...value, ...value,
llmProvider: nextProvider, llmProvider: "local",
baseUrl: nextProvider === "local" ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1" model: value.llmProvider === "local" ? value.model : LOCAL_QWEN25_MODEL,
baseUrl: LOCAL_BASE_URL
}); });
}} }}
> >
<option value="openai">OpenAI (token)</option> <option value="openai">OpenAI (token)</option>
<option value="local">Local (LM Studio / OpenAI-compatible)</option> <option value="local_qwen25">Qwen2.5 14B Instruct 1M (Local LM Studio)</option>
<option value="local_qwen3">Qwen3 30B A3B Instruct 2507 (Local LM Studio)</option>
<option value="local_custom">Local custom (LM Studio / OpenAI-compatible)</option>
</select> </select>
</label> </label>
@ -106,20 +188,20 @@ export function ConnectionPanel({
}} }}
> >
<option value="__manual__">Manual input</option> <option value="__manual__">Manual input</option>
{modelOptions.map((modelId) => ( {availableModelOptions.map((option) => (
<option key={modelId} value={modelId}> <option key={option.value} value={option.value}>
{modelId} {option.label}
</option> </option>
))} ))}
</select> </select>
</label> </label>
<label> <label>
Model ID (manual) Model ID (manual / current)
<input <input
value={value.model} value={value.model}
onChange={(event) => onChange({ ...value, model: event.target.value })} onChange={(event) => onChange({ ...value, model: event.target.value })}
placeholder="qwen2.5-14b-instruct or lmstudio loaded model id" placeholder="qwen2.5-14b-instruct-1m or unsloth/qwen3-30b-a3b-instruct-2507"
/> />
</label> </label>
@ -140,7 +222,7 @@ export function ConnectionPanel({
<input <input
value={value.baseUrl} value={value.baseUrl}
onChange={(event) => onChange({ ...value, baseUrl: event.target.value })} onChange={(event) => onChange({ ...value, baseUrl: event.target.value })}
placeholder={isLocal ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1"} placeholder={isLocal ? LOCAL_BASE_URL : OPENAI_BASE_URL}
/> />
</label> </label>

View File

@ -19,14 +19,15 @@ DEFAULT_ARTIFACTS_ROOT = REPO_ROOT / "artifacts" / "domain_runs"
DEFAULT_SESSIONS_DIR = REPO_ROOT / "llm_normalizer" / "data" / "assistant_sessions" DEFAULT_SESSIONS_DIR = REPO_ROOT / "llm_normalizer" / "data" / "assistant_sessions"
DEFAULT_REPORTS_DIR = REPO_ROOT / "llm_normalizer" / "reports" DEFAULT_REPORTS_DIR = REPO_ROOT / "llm_normalizer" / "reports"
DEFAULT_LOOP_SCHEMA_DIR = REPO_ROOT / "docs" / "orchestration" / "schemas" DEFAULT_LOOP_SCHEMA_DIR = REPO_ROOT / "docs" / "orchestration" / "schemas"
SHARED_LLM_CONNECTION_CONFIG = REPO_ROOT / "llm_normalizer" / "data" / "shared_llm_connection.json"
DEFAULT_BACKEND_URL = "http://127.0.0.1:8787" DEFAULT_BACKEND_URL = "http://127.0.0.1:8787"
DEFAULT_PROMPT_VERSION = "address_query_runtime_v1" DEFAULT_PROMPT_VERSION = "address_query_runtime_v1"
DEFAULT_LLM_PROVIDER = "local" BASE_DEFAULT_LLM_PROVIDER = "local"
DEFAULT_LLM_MODEL = "qwen2.5-14b-instruct-1m" BASE_DEFAULT_LLM_MODEL = "qwen2.5-14b-instruct-1m"
DEFAULT_LLM_BASE_URL = "http://127.0.0.1:1234/v1" BASE_DEFAULT_LLM_BASE_URL = "http://127.0.0.1:1234/v1"
DEFAULT_LLM_API_KEY = "" DEFAULT_LLM_API_KEY = ""
DEFAULT_TEMPERATURE = 0.0 BASE_DEFAULT_TEMPERATURE = 0.0
DEFAULT_MAX_OUTPUT_TOKENS = 900 BASE_DEFAULT_MAX_OUTPUT_TOKENS = 900
TECH_SECTION_HEADER = "### technical_debug_payload_json" TECH_SECTION_HEADER = "### technical_debug_payload_json"
SCENARIO_MANIFEST_SCHEMA_VERSION = "domain_scenario_manifest_v1" SCENARIO_MANIFEST_SCHEMA_VERSION = "domain_scenario_manifest_v1"
SCENARIO_STATE_SCHEMA_VERSION = "domain_scenario_state_v1" SCENARIO_STATE_SCHEMA_VERSION = "domain_scenario_state_v1"
@ -35,6 +36,51 @@ SCENARIO_PACK_SCHEMA_VERSION = "domain_scenario_pack_v1"
ACTIVE_DOMAIN_CONTRACT_SCHEMA_VERSION = "active_domain_contract_v1" ACTIVE_DOMAIN_CONTRACT_SCHEMA_VERSION = "active_domain_contract_v1"
AUTONOMOUS_LOOP_SCHEMA_VERSION = "domain_autonomous_loop_v1" AUTONOMOUS_LOOP_SCHEMA_VERSION = "domain_autonomous_loop_v1"
def load_shared_local_llm_defaults(config_path: Path | None = None) -> dict[str, Any]:
defaults: dict[str, Any] = {
"llm_provider": BASE_DEFAULT_LLM_PROVIDER,
"llm_model": BASE_DEFAULT_LLM_MODEL,
"llm_base_url": BASE_DEFAULT_LLM_BASE_URL,
"temperature": BASE_DEFAULT_TEMPERATURE,
"max_output_tokens": BASE_DEFAULT_MAX_OUTPUT_TOKENS,
}
target = config_path or SHARED_LLM_CONNECTION_CONFIG
if not target.exists():
return defaults
try:
raw = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return defaults
connection = raw.get("connection")
if not isinstance(connection, dict):
return defaults
if str(connection.get("llmProvider") or "").strip().lower() != "local":
return defaults
model = str(connection.get("model") or "").strip()
base_url = str(connection.get("baseUrl") or "").strip()
temperature = connection.get("temperature")
max_output_tokens = connection.get("maxOutputTokens")
if model:
defaults["llm_model"] = model
if base_url:
defaults["llm_base_url"] = base_url
if isinstance(temperature, (int, float)) and not isinstance(temperature, bool):
defaults["temperature"] = float(temperature)
if isinstance(max_output_tokens, (int, float)) and not isinstance(max_output_tokens, bool):
defaults["max_output_tokens"] = max(1, int(max_output_tokens))
return defaults
SHARED_LLM_DEFAULTS = load_shared_local_llm_defaults()
DEFAULT_LLM_PROVIDER = str(SHARED_LLM_DEFAULTS["llm_provider"])
DEFAULT_LLM_MODEL = str(SHARED_LLM_DEFAULTS["llm_model"])
DEFAULT_LLM_BASE_URL = str(SHARED_LLM_DEFAULTS["llm_base_url"])
DEFAULT_TEMPERATURE = float(SHARED_LLM_DEFAULTS["temperature"])
DEFAULT_MAX_OUTPUT_TOKENS = int(SHARED_LLM_DEFAULTS["max_output_tokens"])
TOP_LEVEL_NOISE_PATTERNS = ( TOP_LEVEL_NOISE_PATTERNS = (
re.compile(r"^(?:status|статус(?: результата)?)\b", re.IGNORECASE), re.compile(r"^(?:status|статус(?: результата)?)\b", re.IGNORECASE),
re.compile(r"^(?:что учтено|сводка)\b", re.IGNORECASE), re.compile(r"^(?:что учтено|сводка)\b", re.IGNORECASE),

View File

@ -17,6 +17,7 @@ from scripts.domain_case_loop import (
evaluate_analyst_gate, evaluate_analyst_gate,
evaluate_deterministic_loop_gate, evaluate_deterministic_loop_gate,
load_scenario_pack, load_scenario_pack,
load_shared_local_llm_defaults,
merge_scenario_date_scope, merge_scenario_date_scope,
select_primary_repair_focus, select_primary_repair_focus,
restore_line_collapsed_files_from_snapshot, restore_line_collapsed_files_from_snapshot,
@ -66,6 +67,65 @@ def test_merge_scenario_date_scope_preserves_historical_anchor_on_followup() ->
assert merged["source"] == "current_analysis" assert merged["source"] == "current_analysis"
def test_load_shared_local_llm_defaults_uses_ui_selected_local_model(tmp_path) -> None:
config_path = tmp_path / "shared_llm_connection.json"
config_path.write_text(
json.dumps(
{
"schema_version": "shared_llm_connection_v1",
"updated_at": "2026-04-15T06:00:00Z",
"connection": {
"llmProvider": "local",
"model": "unsloth/qwen3-30b-a3b-instruct-2507",
"baseUrl": "http://127.0.0.1:1234/v1",
"temperature": 0.2,
"maxOutputTokens": 1200,
},
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
defaults = load_shared_local_llm_defaults(config_path)
assert defaults["llm_provider"] == "local"
assert defaults["llm_model"] == "unsloth/qwen3-30b-a3b-instruct-2507"
assert defaults["llm_base_url"] == "http://127.0.0.1:1234/v1"
assert defaults["temperature"] == 0.2
assert defaults["max_output_tokens"] == 1200
def test_load_shared_local_llm_defaults_ignores_non_local_provider(tmp_path) -> None:
config_path = tmp_path / "shared_llm_connection.json"
config_path.write_text(
json.dumps(
{
"schema_version": "shared_llm_connection_v1",
"updated_at": "2026-04-15T06:00:00Z",
"connection": {
"llmProvider": "openai",
"model": "gpt-4o-mini",
"baseUrl": "https://api.openai.com/v1",
"temperature": 0,
"maxOutputTokens": 700,
},
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
defaults = load_shared_local_llm_defaults(config_path)
assert defaults["llm_provider"] == "local"
assert defaults["llm_model"] == "qwen2.5-14b-instruct-1m"
def test_load_scenario_pack_accepts_active_domain_contract(tmp_path) -> None: def test_load_scenario_pack_accepts_active_domain_contract(tmp_path) -> None:
manifest_path = tmp_path / "active_domain_contract.json" manifest_path = tmp_path / "active_domain_contract.json"
manifest_path.write_text( manifest_path.write_text(