АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе
This commit is contained in:
@@ -95,6 +95,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
|
||||
true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Router } from "express";
|
||||
import { NextFunction, Request, Response, Router } from "express";
|
||||
import { DEFAULT_MODEL, DEFAULT_OPENAI_BASE_URL } from "../config";
|
||||
import { OpenAIResponsesClient } from "../services/openaiResponsesClient";
|
||||
import { ok } from "../utils/http";
|
||||
@@ -6,23 +6,76 @@ import { ok } from "../utils/http";
|
||||
export function buildTestConnectionRouter(client: OpenAIResponsesClient): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/openai/test-connection", async (req, res, next) => {
|
||||
const handler = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const llmProvider = body.llmProvider === "local" ? "local" : "openai";
|
||||
const model = String(body.model ?? DEFAULT_MODEL);
|
||||
const baseUrl = String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL);
|
||||
const apiKey = String(body.apiKey ?? process.env.OPENAI_API_KEY ?? "");
|
||||
const result = await client.testConnection({
|
||||
llmProvider,
|
||||
apiKey,
|
||||
model,
|
||||
baseUrl
|
||||
});
|
||||
|
||||
let modelFound: boolean | null = null;
|
||||
let modelsCount: number | null = null;
|
||||
if (llmProvider === "local") {
|
||||
try {
|
||||
const models = await client.listModels({
|
||||
llmProvider,
|
||||
apiKey,
|
||||
model,
|
||||
baseUrl
|
||||
});
|
||||
modelsCount = models.length;
|
||||
modelFound = models.includes(model);
|
||||
} catch {
|
||||
modelFound = null;
|
||||
modelsCount = null;
|
||||
}
|
||||
}
|
||||
|
||||
ok(res, {
|
||||
ok: true,
|
||||
provider: llmProvider,
|
||||
model: result.model,
|
||||
model_found: modelFound,
|
||||
models_count: modelsCount,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const listModelsHandler = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const models = await client.listModels({
|
||||
llmProvider: body.llmProvider === "local" ? "local" : "openai",
|
||||
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,
|
||||
models,
|
||||
count: models.length,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
router.post("/api/llm/test-connection", handler);
|
||||
router.post("/api/llm/models", listModelsHandler);
|
||||
// Backward-compatible route for old frontend builds.
|
||||
router.post("/api/openai/test-connection", handler);
|
||||
router.post("/api/openai/models", listModelsHandler);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,24 @@
|
||||
|
||||
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
|
||||
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
|
||||
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|by\s+counterparty|counterparty)\s+([^\r\n,.;:]+)/i;
|
||||
const COUNTERPARTY_PATTERN =
|
||||
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
|
||||
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
|
||||
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
|
||||
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
|
||||
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||
const PERIOD_RANGE_PATTERN_2 =
|
||||
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||
const YEAR_RANGE_PATTERN =
|
||||
/(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
|
||||
const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|до|to|по)\s*\b(20\d{2})\b/iu;
|
||||
const YEAR_PERIOD_PATTERN =
|
||||
/(?:за|for)\s*(20\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year)?/iu;
|
||||
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year)(?=$|[\s,.;:!?()\-])/iu;
|
||||
const YEAR_PERIOD_ANY_PATTERN =
|
||||
/(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_PATTERN = /(?:за|for)\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})/i;
|
||||
const MONTH_PERIOD_NAME_PATTERN = /(?:за|for)\s+([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?/iu;
|
||||
|
||||
function toIsoDate(year: number, month: number, day: number): string | null {
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
@@ -68,6 +79,57 @@ function parseDateToken(token: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveMonthByName(rawMonthName: string): number | undefined {
|
||||
const token = String(rawMonthName ?? "").trim().toLowerCase();
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/^янв|^january|^jan/.test(token)) return 1;
|
||||
if (/^фев|^february|^feb/.test(token)) return 2;
|
||||
if (/^мар|^march|^mar/.test(token)) return 3;
|
||||
if (/^апр|^april|^apr/.test(token)) return 4;
|
||||
if (/^ма[йя]|^may/.test(token)) return 5;
|
||||
if (/^июн|^june|^jun/.test(token)) return 6;
|
||||
if (/^июл|^july|^jul/.test(token)) return 7;
|
||||
if (/^авг|^august|^aug/.test(token)) return 8;
|
||||
if (/^сен|^сент|^september|^sep/.test(token)) return 9;
|
||||
if (/^окт|^october|^oct/.test(token)) return 10;
|
||||
if (/^ноя|^november|^nov/.test(token)) return 11;
|
||||
if (/^дек|^december|^dec/.test(token)) return 12;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
|
||||
const numericMatch = text.match(MONTH_PERIOD_NUMERIC_PATTERN);
|
||||
if (numericMatch) {
|
||||
const month = Number(numericMatch[1]);
|
||||
const year = Number(numericMatch[2]);
|
||||
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return {
|
||||
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
|
||||
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const byNameMatch = text.match(MONTH_PERIOD_NAME_PATTERN);
|
||||
if (byNameMatch) {
|
||||
const month = resolveMonthByName(String(byNameMatch[1]));
|
||||
const year = Number(byNameMatch[2]);
|
||||
if (month && year >= 2000 && year <= 2099) {
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return {
|
||||
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
|
||||
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function extractPeriodRange(text: string): { period_from?: string; period_to?: string } {
|
||||
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
|
||||
if (!directMatch) {
|
||||
@@ -81,6 +143,70 @@ function extractPeriodRange(text: string): { period_from?: string; period_to?: s
|
||||
};
|
||||
}
|
||||
|
||||
function extractYearPeriod(text: string): { period_from?: string; period_to?: string } {
|
||||
const match = text.match(YEAR_PERIOD_PATTERN);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
if (!Number.isFinite(year) || year < 2000 || year > 2099) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
period_from: `${year}-01-01`,
|
||||
period_to: `${year}-12-31`
|
||||
};
|
||||
}
|
||||
|
||||
const relaxedYearMatch = text.match(YEAR_PERIOD_ANY_PATTERN);
|
||||
if (relaxedYearMatch) {
|
||||
const year = Number(relaxedYearMatch[1]);
|
||||
if (Number.isFinite(year) && year >= 2000 && year <= 2099) {
|
||||
return {
|
||||
period_from: `${year}-01-01`,
|
||||
period_to: `${year}-12-31`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const shortYearMatch = text.match(YEAR_PERIOD_SHORT_PATTERN);
|
||||
if (!shortYearMatch) {
|
||||
return {};
|
||||
}
|
||||
const shortYear = Number(shortYearMatch[1]);
|
||||
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
|
||||
return {};
|
||||
}
|
||||
const year = 2000 + shortYear;
|
||||
return {
|
||||
period_from: `${year}-01-01`,
|
||||
period_to: `${year}-12-31`
|
||||
};
|
||||
}
|
||||
|
||||
function extractYearRangePeriod(text: string): { period_from?: string; period_to?: string } {
|
||||
const match = text.match(YEAR_RANGE_PATTERN) ?? text.match(YEAR_RANGE_LOOSE_PATTERN);
|
||||
if (!match) {
|
||||
return {};
|
||||
}
|
||||
const leftYear = Number(match[1]);
|
||||
const rightYear = Number(match[2]);
|
||||
if (
|
||||
!Number.isFinite(leftYear) ||
|
||||
!Number.isFinite(rightYear) ||
|
||||
leftYear < 2000 ||
|
||||
leftYear > 2099 ||
|
||||
rightYear < 2000 ||
|
||||
rightYear > 2099
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const fromYear = Math.min(leftYear, rightYear);
|
||||
const toYear = Math.max(leftYear, rightYear);
|
||||
return {
|
||||
period_from: `${fromYear}-01-01`,
|
||||
period_to: `${toYear}-12-31`
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupAnchorValue(value: string): string {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized) {
|
||||
@@ -95,11 +221,13 @@ function cleanupAnchorValue(value: string): string {
|
||||
return normalized.replace(periodTailPattern, "").trim();
|
||||
}
|
||||
|
||||
const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu;
|
||||
const allTimeTailPattern =
|
||||
/\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
|
||||
if (allTimeTailPattern.test(normalized)) {
|
||||
return normalized.replace(allTimeTailPattern, "").trim();
|
||||
}
|
||||
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu;
|
||||
const allTimeTailPatternEn =
|
||||
/\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
|
||||
if (allTimeTailPatternEn.test(normalized)) {
|
||||
return normalized.replace(allTimeTailPatternEn, "").trim();
|
||||
}
|
||||
@@ -112,7 +240,203 @@ function cleanupAnchorValue(value: string): string {
|
||||
|
||||
function hasAllTimeHint(text: string): boolean {
|
||||
const value = String(text ?? "");
|
||||
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
|
||||
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
|
||||
}
|
||||
|
||||
function extractLooseByAnchorValue(text: string): string | undefined {
|
||||
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const token = String(match[1] ?? "").trim();
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = token.toLowerCase();
|
||||
const stopWords = new Set([
|
||||
"контрагенту",
|
||||
"контрагента",
|
||||
"контре",
|
||||
"компании",
|
||||
"компанию",
|
||||
"организации",
|
||||
"организацию",
|
||||
"поставщику",
|
||||
"поставщика",
|
||||
"клиенту",
|
||||
"клиента",
|
||||
"покупателю",
|
||||
"покупателя",
|
||||
"партнеру",
|
||||
"партнера",
|
||||
"договору",
|
||||
"договора",
|
||||
"счету",
|
||||
"счёту",
|
||||
"дате",
|
||||
"периоду",
|
||||
"период",
|
||||
"документам",
|
||||
"докам",
|
||||
"взаиморасчетам",
|
||||
"взаиморасчётам"
|
||||
]);
|
||||
if (stopWords.has(lowered)) {
|
||||
return undefined;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
const token = String(rawToken ?? "").trim();
|
||||
const lowered = token.toLowerCase();
|
||||
if (!token || token.length < 2) {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(lowered)) {
|
||||
return false;
|
||||
}
|
||||
if (/^(?:19|20)\d{2}$/.test(lowered)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stopWords = new Set([
|
||||
"за",
|
||||
"с",
|
||||
"по",
|
||||
"на",
|
||||
"и",
|
||||
"или",
|
||||
"док",
|
||||
"доки",
|
||||
"документ",
|
||||
"документы",
|
||||
"документов",
|
||||
"банк",
|
||||
"банковские",
|
||||
"операции",
|
||||
"платежи",
|
||||
"платеж",
|
||||
"платёж",
|
||||
"контрагент",
|
||||
"контрагенту",
|
||||
"контрагента",
|
||||
"компания",
|
||||
"компании",
|
||||
"организация",
|
||||
"организации",
|
||||
"год",
|
||||
"года",
|
||||
"г",
|
||||
"плс",
|
||||
"pls",
|
||||
"пж",
|
||||
"пжлст",
|
||||
"пожалуйста",
|
||||
"бля",
|
||||
"блять",
|
||||
"епт",
|
||||
"ёпт",
|
||||
"епта",
|
||||
"нах",
|
||||
"нахуй",
|
||||
"покеж",
|
||||
"покажи",
|
||||
"выведи"
|
||||
]);
|
||||
return !stopWords.has(lowered);
|
||||
}
|
||||
|
||||
function hasDocsOrBankSignal(text: string): boolean {
|
||||
const lowered = String(text ?? "").toLowerCase();
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||
lowered
|
||||
);
|
||||
}
|
||||
|
||||
function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefined {
|
||||
if (!hasDocsOrBankSignal(text)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokens = String(text ?? "")
|
||||
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const monthTokens = [
|
||||
"янв",
|
||||
"фев",
|
||||
"мар",
|
||||
"апр",
|
||||
"май",
|
||||
"июн",
|
||||
"июл",
|
||||
"авг",
|
||||
"сен",
|
||||
"сент",
|
||||
"окт",
|
||||
"ноя",
|
||||
"дек",
|
||||
"january",
|
||||
"february",
|
||||
"march",
|
||||
"april",
|
||||
"may",
|
||||
"june",
|
||||
"july",
|
||||
"august",
|
||||
"september",
|
||||
"october",
|
||||
"november",
|
||||
"december"
|
||||
];
|
||||
for (const token of tokens) {
|
||||
const lowered = token.toLowerCase();
|
||||
if (!isLikelyCounterpartyToken(lowered)) {
|
||||
continue;
|
||||
}
|
||||
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
|
||||
continue;
|
||||
}
|
||||
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
|
||||
continue;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractImplicitCounterpartyValue(text: string): string | undefined {
|
||||
const input = String(text ?? "");
|
||||
const beforeDocsMatch = input.match(
|
||||
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
|
||||
);
|
||||
if (beforeDocsMatch) {
|
||||
const candidate = String(beforeDocsMatch[1] ?? "").trim();
|
||||
if (isLikelyCounterpartyToken(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const afterDocsMatch = input.match(
|
||||
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
|
||||
);
|
||||
if (afterDocsMatch) {
|
||||
const candidate = String(afterDocsMatch[1] ?? "").trim();
|
||||
if (isLikelyCounterpartyToken(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shiftDaysIso(baseIso: string, deltaDays: number): string {
|
||||
@@ -156,6 +480,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
if (counterpartyMatch) {
|
||||
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
||||
}
|
||||
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||
const fallbackCounterparty = extractLooseByAnchorValue(text);
|
||||
if (fallbackCounterparty) {
|
||||
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
|
||||
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
|
||||
}
|
||||
}
|
||||
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||
const implicitCounterparty = extractImplicitCounterpartyValue(text);
|
||||
if (implicitCounterparty) {
|
||||
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
|
||||
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
|
||||
}
|
||||
}
|
||||
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
|
||||
if (heuristicCounterparty) {
|
||||
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
|
||||
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
|
||||
}
|
||||
}
|
||||
|
||||
const contractMatch = text.match(CONTRACT_PATTERN);
|
||||
if (contractMatch) {
|
||||
@@ -170,6 +515,33 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
filters.period_to = periodRange.period_to;
|
||||
}
|
||||
|
||||
if (!filters.period_from && !filters.period_to) {
|
||||
const monthPeriod = extractMonthPeriod(text);
|
||||
if (monthPeriod.period_from && monthPeriod.period_to) {
|
||||
filters.period_from = monthPeriod.period_from;
|
||||
filters.period_to = monthPeriod.period_to;
|
||||
warnings.push("period_derived_from_month_phrase");
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.period_from && !filters.period_to) {
|
||||
const yearRangePeriod = extractYearRangePeriod(text);
|
||||
if (yearRangePeriod.period_from && yearRangePeriod.period_to) {
|
||||
filters.period_from = yearRangePeriod.period_from;
|
||||
filters.period_to = yearRangePeriod.period_to;
|
||||
warnings.push("period_derived_from_year_range_phrase");
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.period_from && !filters.period_to) {
|
||||
const yearPeriod = extractYearPeriod(text);
|
||||
if (yearPeriod.period_from && yearPeriod.period_to) {
|
||||
filters.period_from = yearPeriod.period_from;
|
||||
filters.period_to = yearPeriod.period_to;
|
||||
warnings.push("period_derived_from_year_phrase");
|
||||
}
|
||||
}
|
||||
|
||||
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
|
||||
if (!filters.period_from && !filters.period_to) {
|
||||
const asOfDate = extractAsOfDate(text);
|
||||
|
||||
@@ -67,9 +67,20 @@ const OPEN_ITEMS_HINTS = [
|
||||
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
|
||||
"documents by counterparty",
|
||||
"docs by counterparty",
|
||||
"documents by company",
|
||||
"documents by supplier",
|
||||
"documents by customer",
|
||||
"documents by client",
|
||||
"documents by partner",
|
||||
"show documents by counterparty",
|
||||
"list documents by counterparty",
|
||||
"документы по",
|
||||
"доступные документы",
|
||||
"список документов",
|
||||
"документ",
|
||||
"доки",
|
||||
"доки по",
|
||||
"док по",
|
||||
"по контрагент"
|
||||
];
|
||||
|
||||
@@ -77,16 +88,202 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
|
||||
"bank operations by counterparty",
|
||||
"bank payments by counterparty",
|
||||
"payment orders by counterparty",
|
||||
"bank operations by company",
|
||||
"bank operations by supplier",
|
||||
"bank operations by customer",
|
||||
"show bank operations by counterparty",
|
||||
"bank ops",
|
||||
"transactions by counterparty",
|
||||
"банков",
|
||||
"выписк",
|
||||
"платеж"
|
||||
"платеж",
|
||||
"платёж",
|
||||
"оплат",
|
||||
"списан",
|
||||
"поступлен",
|
||||
"движени"
|
||||
];
|
||||
|
||||
function hasAny(text: string, patterns: string[]): boolean {
|
||||
return patterns.some((item) => text.includes(item));
|
||||
}
|
||||
|
||||
function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
const token = String(rawToken ?? "").trim().toLowerCase();
|
||||
if (!token || token.length < 2) {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(token)) {
|
||||
return false;
|
||||
}
|
||||
if (/^(?:19|20)\d{2}$/.test(token)) {
|
||||
return false;
|
||||
}
|
||||
const stopWords = new Set([
|
||||
"за",
|
||||
"с",
|
||||
"по",
|
||||
"на",
|
||||
"и",
|
||||
"или",
|
||||
"док",
|
||||
"доки",
|
||||
"доки?",
|
||||
"документ",
|
||||
"документы",
|
||||
"документов",
|
||||
"банк",
|
||||
"банковские",
|
||||
"операции",
|
||||
"платежи",
|
||||
"платеж",
|
||||
"платёж",
|
||||
"контрагент",
|
||||
"контрагенту",
|
||||
"контрагента",
|
||||
"компания",
|
||||
"компании",
|
||||
"организация",
|
||||
"организации",
|
||||
"год",
|
||||
"года",
|
||||
"г",
|
||||
"плс",
|
||||
"pls",
|
||||
"пж",
|
||||
"пжлст",
|
||||
"пожалуйста",
|
||||
"бля",
|
||||
"блять",
|
||||
"епт",
|
||||
"ёпт",
|
||||
"епта",
|
||||
"нах",
|
||||
"нахуй"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
|
||||
function hasPartyAnchorMention(text: string): boolean {
|
||||
return (
|
||||
text.includes("контраг") ||
|
||||
text.includes("контра") ||
|
||||
text.includes("counterparty") ||
|
||||
text.includes("компан") ||
|
||||
text.includes("company") ||
|
||||
text.includes("организац") ||
|
||||
text.includes("supplier") ||
|
||||
text.includes("vendor") ||
|
||||
text.includes("customer") ||
|
||||
text.includes("client") ||
|
||||
text.includes("partner") ||
|
||||
text.includes("поставщик") ||
|
||||
text.includes("клиент") ||
|
||||
text.includes("покупател") ||
|
||||
text.includes("партнер")
|
||||
);
|
||||
}
|
||||
|
||||
function hasLooseByAnchorMention(text: string): boolean {
|
||||
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const token = String(match[1] ?? "").toLowerCase();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const stopWords = new Set([
|
||||
"контрагенту",
|
||||
"контрагента",
|
||||
"контре",
|
||||
"компании",
|
||||
"компанию",
|
||||
"организации",
|
||||
"организацию",
|
||||
"поставщику",
|
||||
"поставщика",
|
||||
"клиенту",
|
||||
"клиента",
|
||||
"покупателю",
|
||||
"покупателя",
|
||||
"партнеру",
|
||||
"партнера",
|
||||
"договору",
|
||||
"договора",
|
||||
"счету",
|
||||
"счёту",
|
||||
"дате",
|
||||
"периоду",
|
||||
"период",
|
||||
"документам",
|
||||
"докам"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
|
||||
function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean {
|
||||
const beforeDocsMatch = text.match(
|
||||
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
|
||||
);
|
||||
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const afterDocsMatch = text.match(
|
||||
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
|
||||
);
|
||||
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDocsOrBankSignal(text: string): boolean {
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function hasHeuristicCounterpartyAnchor(text: string): boolean {
|
||||
if (!hasDocsOrBankSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
const tokens = String(text ?? "")
|
||||
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
for (const token of tokens) {
|
||||
const lowered = token.toLowerCase();
|
||||
if (!isLikelyCounterpartyToken(lowered)) {
|
||||
continue;
|
||||
}
|
||||
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
|
||||
continue;
|
||||
}
|
||||
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasGenericAddressLookupSignal(text: string): boolean {
|
||||
return (
|
||||
/\bесть\b/iu.test(text) ||
|
||||
/\bпокажи\b/iu.test(text) ||
|
||||
/\bвыведи\b/iu.test(text) ||
|
||||
/\bкакие\b/iu.test(text) ||
|
||||
/\bчто(?:-|\s)?то\b/iu.test(text) ||
|
||||
/за\s+любой\s+период/iu.test(text) ||
|
||||
/за\s+вс[её]\s+время/iu.test(text) ||
|
||||
/for\s+all\s+time/iu.test(text) ||
|
||||
/all\s+time/iu.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function hasAccountNumberAnchor(text: string): boolean {
|
||||
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
||||
}
|
||||
@@ -128,7 +325,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
|
||||
if (
|
||||
hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
|
||||
(text.includes("контраг") || text.includes("counterparty"))
|
||||
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))
|
||||
) {
|
||||
return {
|
||||
intent: "bank_operations_by_counterparty",
|
||||
@@ -139,7 +336,10 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
|
||||
if (
|
||||
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
|
||||
(text.includes("контраг") || text.includes("counterparty"))
|
||||
(hasPartyAnchorMention(text) ||
|
||||
hasLooseByAnchorMention(text) ||
|
||||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
|
||||
hasHeuristicCounterpartyAnchor(text))
|
||||
) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
@@ -148,6 +348,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "low",
|
||||
reasons: ["generic_lookup_with_loose_anchor_fallback"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
ASSISTANT_MCP_PROXY_URL,
|
||||
ASSISTANT_MCP_TIMEOUT_MS
|
||||
} from "../config";
|
||||
import iconv from "iconv-lite";
|
||||
|
||||
interface McpExecuteQueryResponse {
|
||||
success?: unknown;
|
||||
@@ -36,8 +37,81 @@ function parseFiniteNumber(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function textMojibakeScore(value: string): number {
|
||||
const source = String(value ?? "");
|
||||
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
|
||||
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
|
||||
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length;
|
||||
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
|
||||
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2;
|
||||
}
|
||||
|
||||
function looksLikeMojibake(value: string): boolean {
|
||||
const source = String(value ?? "");
|
||||
if (!source.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
|
||||
return true;
|
||||
}
|
||||
return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
|
||||
}
|
||||
|
||||
function decodeUtf8FromWin1251Mojibake(value: string): string {
|
||||
if (!looksLikeMojibake(value)) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const bytes = iconv.encode(value, "win1251");
|
||||
const decoded = bytes.toString("utf8");
|
||||
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8FromLatin1Mojibake(value: string): string {
|
||||
if (!looksLikeMojibake(value)) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const decoded = Buffer.from(value, "latin1").toString("utf8");
|
||||
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMojibakeString(value: string): string {
|
||||
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
|
||||
return decodeUtf8FromLatin1Mojibake(fromWin1251);
|
||||
}
|
||||
|
||||
function normalizeMojibakeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return normalizeMojibakeString(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeMojibakeValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const source = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(source)) {
|
||||
const repairedKey = normalizeMojibakeString(key);
|
||||
normalized[repairedKey] = normalizeMojibakeValue(raw);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMojibakeRows(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
||||
return rows.map((row) => normalizeMojibakeValue(row) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function parseRowsFromTextTable(source: string): Array<Record<string, unknown>> {
|
||||
const normalized = String(source ?? "").replace(/\r/g, "").trim();
|
||||
const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
@@ -111,7 +185,7 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
return normalizeMojibakeRows(rows);
|
||||
}
|
||||
|
||||
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
||||
@@ -133,9 +207,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
||||
}
|
||||
|
||||
if (Array.isArray(source.data)) {
|
||||
const rows = source.data
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
const rows = normalizeMojibakeRows(
|
||||
source.data
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => item !== null)
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
rows,
|
||||
@@ -152,9 +228,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
||||
}
|
||||
|
||||
if (source.data && typeof source.data === "object" && Array.isArray((source.data as { rows?: unknown }).rows)) {
|
||||
const rows = ((source.data as { rows: unknown[] }).rows ?? [])
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
const rows = normalizeMojibakeRows(
|
||||
((source.data as { rows: unknown[] }).rows ?? [])
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => item !== null)
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
rows,
|
||||
|
||||
@@ -27,6 +27,13 @@ const ADDRESS_ACTION_TOKENS = [
|
||||
const ADDRESS_ENTITY_TOKENS = [
|
||||
"counterparty",
|
||||
"counterparties",
|
||||
"company",
|
||||
"organization",
|
||||
"supplier",
|
||||
"vendor",
|
||||
"customer",
|
||||
"client",
|
||||
"partner",
|
||||
"contract",
|
||||
"contracts",
|
||||
"account",
|
||||
@@ -42,10 +49,22 @@ const ADDRESS_ENTITY_TOKENS = [
|
||||
"owes",
|
||||
"owed",
|
||||
"контрагент",
|
||||
"контра",
|
||||
"компан",
|
||||
"организац",
|
||||
"поставщик",
|
||||
"клиент",
|
||||
"покупател",
|
||||
"партнер",
|
||||
"банк",
|
||||
"выписк",
|
||||
"операц",
|
||||
"договор",
|
||||
"счет",
|
||||
"счёт",
|
||||
"документ",
|
||||
"доки",
|
||||
"док",
|
||||
"остаток",
|
||||
"дебитор",
|
||||
"кредитор",
|
||||
@@ -73,6 +92,56 @@ const DEEP_REASONING_TOKENS = [
|
||||
"ошибк"
|
||||
];
|
||||
|
||||
function hasLooseByAnchorMention(text: string): boolean {
|
||||
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const token = String(match[1] ?? "").toLowerCase();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const stopWords = new Set([
|
||||
"контрагенту",
|
||||
"контрагента",
|
||||
"контре",
|
||||
"компании",
|
||||
"компанию",
|
||||
"организации",
|
||||
"организацию",
|
||||
"поставщику",
|
||||
"поставщика",
|
||||
"клиенту",
|
||||
"клиента",
|
||||
"покупателю",
|
||||
"покупателя",
|
||||
"партнеру",
|
||||
"партнера",
|
||||
"договору",
|
||||
"договора",
|
||||
"счету",
|
||||
"счёту",
|
||||
"дате",
|
||||
"периоду",
|
||||
"период",
|
||||
"документам",
|
||||
"докам",
|
||||
"взаиморасчетам",
|
||||
"взаиморасчётам"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
|
||||
function hasAddressFollowupSignal(text: string): boolean {
|
||||
if (/(?:за\s+любой\s+период|за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:\bесть\s+что(?:-|\s)?то\b|\bесть\s+ли\b|\bчто\s+есть\b)/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnyToken(text: string, tokens: string[]): boolean {
|
||||
return tokens.some((token) => text.includes(token));
|
||||
}
|
||||
@@ -90,6 +159,8 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
|
||||
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
|
||||
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
|
||||
const hasLooseByAnchor = hasLooseByAnchorMention(text);
|
||||
const hasFollowupSignal = hasAddressFollowupSignal(text);
|
||||
|
||||
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
|
||||
return {
|
||||
@@ -99,6 +170,14 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
confidence: "medium",
|
||||
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAddressEntity && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
|
||||
@@ -13,12 +13,11 @@ import type {
|
||||
AddressResponseType,
|
||||
AddressRuntimeReadiness
|
||||
} from "../types/addressQuery";
|
||||
import { detectAddressQuestionMode } from "./addressQueryClassifier";
|
||||
import { classifyAddressQueryShape } from "./addressQueryShapeClassifier";
|
||||
import { resolveAddressIntent } from "./addressIntentResolver";
|
||||
import { extractAddressFilters } from "./addressFilterExtractor";
|
||||
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
|
||||
import { executeAddressMcpQuery } from "./addressMcpClient";
|
||||
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
|
||||
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
|
||||
import { composeFactualReply, contractCandidatesFromRows, inferReplyType } from "./address_runtime/composeStage";
|
||||
|
||||
interface NormalizedAddressRow {
|
||||
period: string | null;
|
||||
@@ -29,6 +28,10 @@ interface NormalizedAddressRow {
|
||||
analytics: string[];
|
||||
}
|
||||
|
||||
interface AddressTryHandleOptions {
|
||||
followupContext?: AddressFollowupContext | null;
|
||||
}
|
||||
|
||||
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
|
||||
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
|
||||
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||
@@ -388,21 +391,65 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
|
||||
return rows;
|
||||
}
|
||||
|
||||
function formatTopRows(rows: NormalizedAddressRow[], limit = 6): string[] {
|
||||
return rows.slice(0, limit).map((row, index) => {
|
||||
const period = row.period ?? "дата не указана";
|
||||
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
|
||||
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
|
||||
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
|
||||
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
||||
});
|
||||
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
|
||||
return (
|
||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
|
||||
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
|
||||
return "factual";
|
||||
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
|
||||
if (!hasExplicitPeriodWindow(filters)) {
|
||||
return false;
|
||||
}
|
||||
return "partial_coverage";
|
||||
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
|
||||
}
|
||||
|
||||
function toIsoDatePrefix(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveObservedPeriodWindow(rows: NormalizedAddressRow[]): { period_from: string | null; period_to: string | null } {
|
||||
const dates = rows
|
||||
.map((row) => toIsoDatePrefix(row.period))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.sort();
|
||||
if (dates.length === 0) {
|
||||
return {
|
||||
period_from: null,
|
||||
period_to: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
period_from: dates[0],
|
||||
period_to: dates[dates.length - 1]
|
||||
};
|
||||
}
|
||||
|
||||
function composeAutoBroadenedPeriodPrefix(
|
||||
requested: AddressFilterSet,
|
||||
observed: { period_from: string | null; period_to: string | null }
|
||||
): string {
|
||||
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
|
||||
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
|
||||
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
|
||||
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
|
||||
}
|
||||
if (requestedFrom && requestedTo) {
|
||||
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
|
||||
}
|
||||
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
|
||||
}
|
||||
|
||||
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
|
||||
@@ -418,14 +465,6 @@ function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCatego
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
interface AnchorResolutionDebug {
|
||||
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||
anchor_value_raw: string | null;
|
||||
anchor_value_resolved: string | null;
|
||||
resolver_confidence: "high" | "medium" | "low" | null;
|
||||
ambiguity_count: number;
|
||||
}
|
||||
|
||||
interface RowStageDiagnostics {
|
||||
rawRowKeysSample: string[];
|
||||
materializationDropReason:
|
||||
@@ -580,99 +619,6 @@ function toLegacyMcpStatus(
|
||||
return status;
|
||||
}
|
||||
|
||||
function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
|
||||
const account = typeof filters.account === "string" ? filters.account.trim() : "";
|
||||
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
||||
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
||||
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
||||
|
||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||
if (account) {
|
||||
return {
|
||||
anchor_type: "account",
|
||||
anchor_value_raw: account,
|
||||
anchor_value_resolved: account,
|
||||
resolver_confidence: "high",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (counterparty) {
|
||||
return {
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_raw: counterparty,
|
||||
anchor_value_resolved: counterparty,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (contract) {
|
||||
return {
|
||||
anchor_type: "contract",
|
||||
anchor_value_raw: contract,
|
||||
anchor_value_resolved: contract,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (documentRef) {
|
||||
return {
|
||||
anchor_type: "document_ref",
|
||||
anchor_value_raw: documentRef,
|
||||
anchor_value_resolved: documentRef,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anchor_type: "unknown",
|
||||
anchor_value_raw: null,
|
||||
anchor_value_resolved: null,
|
||||
resolver_confidence: "low",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: NormalizedAddressRow[]): AnchorResolutionDebug {
|
||||
if (rows.length === 0) {
|
||||
return anchor;
|
||||
}
|
||||
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
|
||||
return anchor;
|
||||
}
|
||||
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
|
||||
if (!needleRaw) {
|
||||
return anchor;
|
||||
}
|
||||
const candidates = uniqueStrings(
|
||||
rows
|
||||
.flatMap((row) => row.analytics)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return anchor;
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
return {
|
||||
...anchor,
|
||||
anchor_value_resolved: candidates[0],
|
||||
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
return {
|
||||
...anchor,
|
||||
anchor_value_resolved: candidates[0],
|
||||
resolver_confidence: "low",
|
||||
ambiguity_count: candidates.length - 1
|
||||
};
|
||||
}
|
||||
|
||||
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
|
||||
const heading =
|
||||
category === "empty_match"
|
||||
@@ -777,137 +723,20 @@ function buildLimitedExecutionResult(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function contractCandidatesFromRows(rows: NormalizedAddressRow[]): string[] {
|
||||
const candidates: string[] = [];
|
||||
for (const row of rows) {
|
||||
for (const token of [row.registrator, ...row.analytics]) {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (/договор|contract|дог\./i.test(normalized)) {
|
||||
candidates.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
|
||||
function composeFactualReply(intent: AddressIntent, rows: NormalizedAddressRow[]): { responseType: AddressResponseType; text: string } {
|
||||
if (intent === "account_balance_snapshot") {
|
||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const lines = [
|
||||
"Адресный срез по счету собран (по движениям live MCP).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||
...formatTopRows(rows, 4)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "documents_forming_balance") {
|
||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const lines = [
|
||||
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
|
||||
`Документных строк отобрано: ${rows.length}.`,
|
||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||
...formatTopRows(rows, 8),
|
||||
"Можно уточнить выборку по контрагенту, договору или периоду."
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "list_open_contracts") {
|
||||
const contracts = contractCandidatesFromRows(rows);
|
||||
const lines = [
|
||||
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
|
||||
`Строк движения: ${rows.length}.`,
|
||||
`Договорных кандидатов: ${contracts.length}.`
|
||||
];
|
||||
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const lines = [
|
||||
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 6)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
const lines = [
|
||||
"Собран список документов по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 8)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "bank_operations_by_counterparty") {
|
||||
const lines = [
|
||||
"Собран список банковских операций по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 8)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
intent === "list_payables_counterparties"
|
||||
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
|
||||
: intent === "list_receivables_counterparties"
|
||||
? "Срез требований (receivables) собран по движениям с account scope 62/76."
|
||||
: "Срез адресного запроса собран.";
|
||||
|
||||
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
export class AddressQueryService {
|
||||
public async tryHandle(userMessage: string): Promise<AddressExecutionResult | null> {
|
||||
public async tryHandle(userMessage: string, options: AddressTryHandleOptions = {}): Promise<AddressExecutionResult | null> {
|
||||
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = detectAddressQuestionMode(userMessage);
|
||||
if (mode.mode !== "address_query") {
|
||||
const followupContext = options.followupContext ?? null;
|
||||
const decompose = runAddressDecomposeStage(userMessage, followupContext);
|
||||
if (!decompose) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shape = classifyAddressQueryShape(userMessage);
|
||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intent = resolveAddressIntent(userMessage);
|
||||
const filters = extractAddressFilters(userMessage, intent.intent);
|
||||
const { mode, shape, intent, filters, baseReasons } = decompose;
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
|
||||
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
|
||||
|
||||
if (intent.intent === "unknown") {
|
||||
return buildLimitedExecutionResult({
|
||||
@@ -1130,6 +959,112 @@ export class AddressQueryService {
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
|
||||
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
|
||||
delete autoBroadenedFilters.period_from;
|
||||
delete autoBroadenedFilters.period_to;
|
||||
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
|
||||
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
||||
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
||||
const broadenedMcp = await executeAddressMcpQuery({
|
||||
query: broadenedPlan.query,
|
||||
limit: broadenedPlan.limit
|
||||
});
|
||||
if (!broadenedMcp.error) {
|
||||
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
|
||||
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
|
||||
const broadenedAccountScopeFallbackApplied =
|
||||
broadenedPlan.account_scope_mode === "preferred" &&
|
||||
broadenedPlan.account_scope.length > 0 &&
|
||||
broadenedRawRows.length > 0 &&
|
||||
broadenedScopedRows.length === 0;
|
||||
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
|
||||
let broadenedAnchor = resolvePrimaryAnchor(intent.intent, autoBroadenedFilters);
|
||||
broadenedAnchor = refineAnchorFromRows(broadenedAnchor, broadenedNormalizedRows);
|
||||
const broadenedFiltersForMatching: AddressFilterSet =
|
||||
broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
|
||||
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
|
||||
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
|
||||
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
|
||||
: autoBroadenedFilters;
|
||||
const broadenedAccountScopeAudit = buildAccountScopeAudit({
|
||||
intent: intent.intent,
|
||||
filters: broadenedFiltersForMatching,
|
||||
accountScope: broadenedPlan.account_scope,
|
||||
rowsBeforeScope: broadenedRawRows.length,
|
||||
rowsAfterScope: broadenedNormalizedRows.length
|
||||
});
|
||||
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
|
||||
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
|
||||
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
|
||||
if (broadenedFilteredRows.length > 0) {
|
||||
const broadenedRowDiagnostics = deriveRowStageDiagnostics(
|
||||
broadenedMcp.raw_rows,
|
||||
broadenedNormalizedRows.length,
|
||||
broadenedNormalizedRows.length
|
||||
);
|
||||
const broadenedStageStatus = deriveMcpStageStatus({
|
||||
rawRowsReceived: broadenedMcp.raw_rows.length,
|
||||
rowsMaterialized: broadenedNormalizedRows.length,
|
||||
rowsAnchorMatched: broadenedRowsByAnchor.length,
|
||||
rowsMatched: broadenedFilteredRows.length
|
||||
});
|
||||
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
|
||||
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
|
||||
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows);
|
||||
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
|
||||
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
|
||||
reply_type: inferReplyType(broadenedFactual.responseType),
|
||||
response_type: broadenedFactual.responseType,
|
||||
debug: {
|
||||
detected_mode: mode.mode,
|
||||
detected_mode_confidence: mode.confidence,
|
||||
query_shape: shape.shape,
|
||||
query_shape_confidence: shape.confidence,
|
||||
detected_intent: intent.intent,
|
||||
detected_intent_confidence: intent.confidence,
|
||||
extracted_filters: filters.extracted_filters,
|
||||
missing_required_filters: [],
|
||||
selected_recipe: broadenedSelection.selected_recipe.recipe_id,
|
||||
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
|
||||
account_scope_mode: broadenedPlan.account_scope_mode,
|
||||
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
|
||||
anchor_type: broadenedAnchor.anchor_type,
|
||||
anchor_value_raw: broadenedAnchor.anchor_value_raw,
|
||||
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
|
||||
resolver_confidence: broadenedAnchor.resolver_confidence,
|
||||
ambiguity_count: broadenedAnchor.ambiguity_count,
|
||||
match_failure_stage: "none",
|
||||
match_failure_reason: null,
|
||||
mcp_call_status: broadenedStageStatus,
|
||||
rows_fetched: broadenedMcp.fetched_rows,
|
||||
raw_rows_received: broadenedMcp.raw_rows.length,
|
||||
rows_after_account_scope: broadenedNormalizedRows.length,
|
||||
rows_after_recipe_filter: broadenedRowsByAnchor.length,
|
||||
rows_materialized: broadenedNormalizedRows.length,
|
||||
rows_matched: broadenedFilteredRows.length,
|
||||
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
|
||||
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
|
||||
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
|
||||
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
|
||||
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
|
||||
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
|
||||
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
|
||||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
limited_reason_category: null,
|
||||
response_type: broadenedFactual.responseType,
|
||||
limitations: broadenedLimitations,
|
||||
reasons: broadenedReasons
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
|
||||
const hadAnchorMatchedRows = filterByAnchors.length > 0;
|
||||
|
||||
@@ -128,6 +128,9 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const ADDRESS_MAX_LIMIT_DEFAULT = 200;
|
||||
const ADDRESS_MAX_LIMIT_EXTENDED = 1000;
|
||||
|
||||
export interface AddressRecipeExecutionPlan {
|
||||
recipe: AddressRecipeDefinition;
|
||||
query: string;
|
||||
@@ -196,6 +199,13 @@ function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): bool
|
||||
return !hasPeriod;
|
||||
}
|
||||
|
||||
function maxLimitForIntent(intent: AddressIntent): number {
|
||||
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
|
||||
return ADDRESS_MAX_LIMIT_EXTENDED;
|
||||
}
|
||||
return ADDRESS_MAX_LIMIT_DEFAULT;
|
||||
}
|
||||
|
||||
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
|
||||
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
|
||||
if (!recipe) {
|
||||
@@ -222,16 +232,21 @@ export function buildAddressRecipePlan(
|
||||
recipe: AddressRecipeDefinition,
|
||||
filters: AddressFilterSet
|
||||
): AddressRecipeExecutionPlan {
|
||||
const maxLimit = maxLimitForIntent(recipe.intent);
|
||||
const baseLimit =
|
||||
typeof filters.limit === "number" && Number.isFinite(filters.limit)
|
||||
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
|
||||
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
|
||||
: recipe.default_limit;
|
||||
const boostedLimit =
|
||||
(recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
|
||||
shouldBoostLimitForAllTimeCounterparty(filters)
|
||||
? Math.max(baseLimit, 200)
|
||||
: baseLimit;
|
||||
const resolvedLimit = Math.max(1, Math.min(200, boostedLimit));
|
||||
? Math.max(baseLimit, maxLimit)
|
||||
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
|
||||
typeof filters.account === "string" &&
|
||||
filters.account.trim().length > 0
|
||||
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
|
||||
: baseLimit;
|
||||
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
|
||||
|
||||
const accountScope =
|
||||
(recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { AddressIntent, AddressResponseType } from "../../types/addressQuery";
|
||||
|
||||
export interface ComposeStageRow {
|
||||
period: string | null;
|
||||
registrator: string;
|
||||
account_dt: string | null;
|
||||
account_kt: string | null;
|
||||
amount: number | null;
|
||||
analytics: string[];
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
|
||||
return rows.slice(0, limit).map((row, index) => {
|
||||
const period = row.period ?? "дата не указана";
|
||||
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
|
||||
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
|
||||
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
|
||||
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
|
||||
const candidates: string[] = [];
|
||||
for (const row of rows) {
|
||||
for (const token of [row.registrator, ...row.analytics]) {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (/договор|contract|дог\./i.test(normalized)) {
|
||||
candidates.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
|
||||
export function composeFactualReply(
|
||||
intent: AddressIntent,
|
||||
rows: ComposeStageRow[]
|
||||
): { responseType: AddressResponseType; text: string } {
|
||||
if (intent === "account_balance_snapshot") {
|
||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const lines = [
|
||||
"Адресный срез по счету собран (по движениям live MCP).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||
...formatTopRows(rows, 4)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "documents_forming_balance") {
|
||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const lines = [
|
||||
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
|
||||
`Документных строк отобрано: ${rows.length}.`,
|
||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||
...formatTopRows(rows, 8),
|
||||
"Можно уточнить выборку по контрагенту, договору или периоду."
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "list_open_contracts") {
|
||||
const contracts = contractCandidatesFromRows(rows);
|
||||
const lines = [
|
||||
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
|
||||
`Строк движения: ${rows.length}.`,
|
||||
`Договорных кандидатов: ${contracts.length}.`
|
||||
];
|
||||
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const lines = [
|
||||
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 6)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
const lines = [
|
||||
"Собран список документов по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "bank_operations_by_counterparty") {
|
||||
const lines = [
|
||||
"Собран список банковских операций по контрагенту (live address lane).",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
intent === "list_payables_counterparties"
|
||||
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
|
||||
: intent === "list_receivables_counterparties"
|
||||
? "Срез требований (receivables) собран по движениям с account scope 62/76."
|
||||
: "Срез адресного запроса собран.";
|
||||
|
||||
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
export function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
|
||||
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
|
||||
return "factual";
|
||||
}
|
||||
return "partial_coverage";
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
AddressFilterSet,
|
||||
AddressIntent,
|
||||
AddressIntentResolution,
|
||||
AddressModeDetection,
|
||||
AddressQueryShapeDetection
|
||||
} from "../../types/addressQuery";
|
||||
import { detectAddressQuestionMode } from "../addressQueryClassifier";
|
||||
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
|
||||
import { resolveAddressIntent } from "../addressIntentResolver";
|
||||
import { extractAddressFilters } from "../addressFilterExtractor";
|
||||
|
||||
export interface AddressFollowupContext {
|
||||
previous_intent?: AddressIntent;
|
||||
previous_filters?: AddressFilterSet;
|
||||
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||
previous_anchor_value?: string | null;
|
||||
}
|
||||
|
||||
export interface AddressDecomposeStageResult {
|
||||
mode: AddressModeDetection;
|
||||
shape: AddressQueryShapeDetection;
|
||||
intent: AddressIntentResolution;
|
||||
filters: {
|
||||
extracted_filters: AddressFilterSet;
|
||||
missing_required_filters: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
baseReasons: string[];
|
||||
}
|
||||
|
||||
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
|
||||
return (
|
||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function hasAllTimeHint(text: string): boolean {
|
||||
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(
|
||||
String(text ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAddressFollowupContextSignal(text: string): boolean {
|
||||
const normalized = String(text ?? "").trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (hasAllTimeHint(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that)/iu.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return normalized.split(/\s+/).filter(Boolean).length <= 8;
|
||||
}
|
||||
|
||||
function mergeFollowupFilters(
|
||||
current: AddressFilterSet,
|
||||
intent: AddressIntent,
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): { filters: AddressFilterSet; reasons: string[] } {
|
||||
const merged: AddressFilterSet = { ...current };
|
||||
const reasons: string[] = [];
|
||||
if (!followupContext) {
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
const previous = followupContext.previous_filters ?? {};
|
||||
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
|
||||
const previousCounterparty = toNonEmptyString(previous.counterparty);
|
||||
const previousContract = toNonEmptyString(previous.contract);
|
||||
const previousAccount = toNonEmptyString(previous.account);
|
||||
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||
|
||||
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
|
||||
if (!toNonEmptyString(merged.counterparty)) {
|
||||
const inheritedCounterparty =
|
||||
previousCounterparty ??
|
||||
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||
if (inheritedCounterparty) {
|
||||
merged.counterparty = inheritedCounterparty;
|
||||
reasons.push("counterparty_from_followup_context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||
if (!toNonEmptyString(merged.account)) {
|
||||
const inheritedAccount =
|
||||
previousAccount ??
|
||||
(followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
|
||||
if (inheritedAccount) {
|
||||
merged.account = inheritedAccount;
|
||||
reasons.push("account_from_followup_context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
|
||||
if (!toNonEmptyString(merged.contract)) {
|
||||
const inheritedContract =
|
||||
previousContract ??
|
||||
(followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||
if (inheritedContract) {
|
||||
merged.contract = inheritedContract;
|
||||
reasons.push("contract_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (!toNonEmptyString(merged.counterparty)) {
|
||||
const inheritedCounterparty =
|
||||
previousCounterparty ??
|
||||
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||
if (inheritedCounterparty) {
|
||||
merged.counterparty = inheritedCounterparty;
|
||||
reasons.push("counterparty_from_followup_context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allTimeRequested) {
|
||||
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
|
||||
delete merged.period_from;
|
||||
delete merged.period_to;
|
||||
reasons.push("period_cleared_by_all_time_followup");
|
||||
}
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
const currentHasPeriod = hasExplicitPeriodWindow(merged);
|
||||
const previousHasPeriod = hasExplicitPeriodWindow(previous);
|
||||
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
|
||||
if (toNonEmptyString(previous.period_from)) {
|
||||
merged.period_from = previous.period_from;
|
||||
}
|
||||
if (toNonEmptyString(previous.period_to)) {
|
||||
merged.period_to = previous.period_to;
|
||||
}
|
||||
reasons.push("period_from_followup_context");
|
||||
}
|
||||
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFilterSet): string[] {
|
||||
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
|
||||
account_balance_snapshot: ["account", "as_of_date"],
|
||||
documents_forming_balance: ["account", "as_of_date"],
|
||||
list_documents_by_counterparty: ["counterparty"],
|
||||
bank_operations_by_counterparty: ["counterparty"]
|
||||
};
|
||||
const required = requiredByIntent[intent] ?? [];
|
||||
return required.filter((key) => {
|
||||
const value = filters[key];
|
||||
return value === undefined || value === null || String(value).trim() === "";
|
||||
});
|
||||
}
|
||||
|
||||
function deriveIntentWithFollowupContext(
|
||||
detectedIntent: AddressIntentResolution,
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): AddressIntentResolution {
|
||||
if (!followupContext || !followupContext.previous_intent) {
|
||||
return detectedIntent;
|
||||
}
|
||||
if (detectedIntent.intent !== "unknown") {
|
||||
return detectedIntent;
|
||||
}
|
||||
if (!hasAddressFollowupContextSignal(userMessage)) {
|
||||
return detectedIntent;
|
||||
}
|
||||
return {
|
||||
intent: followupContext.previous_intent,
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
|
||||
};
|
||||
}
|
||||
|
||||
export function runAddressDecomposeStage(
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): AddressDecomposeStageResult | null {
|
||||
const detectedMode = detectAddressQuestionMode(userMessage);
|
||||
const mode =
|
||||
detectedMode.mode === "address_query"
|
||||
? detectedMode
|
||||
: followupContext && hasAddressFollowupContextSignal(userMessage)
|
||||
? {
|
||||
mode: "address_query" as const,
|
||||
confidence: "medium" as const,
|
||||
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
|
||||
}
|
||||
: detectedMode;
|
||||
if (mode.mode !== "address_query") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shape = classifyAddressQueryShape(userMessage);
|
||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detectedIntent = resolveAddressIntent(userMessage);
|
||||
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
|
||||
const extractedFilters = extractAddressFilters(userMessage, intent.intent);
|
||||
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
|
||||
const filters = {
|
||||
extracted_filters: followupMerged.filters,
|
||||
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
|
||||
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
|
||||
};
|
||||
const followupContextApplied =
|
||||
Boolean(followupContext) &&
|
||||
(mode.reasons.includes("address_mode_from_followup_context") ||
|
||||
intent.reasons.includes("intent_from_followup_context") ||
|
||||
followupMerged.reasons.length > 0);
|
||||
const baseReasons = [
|
||||
...mode.reasons,
|
||||
...shape.reasons,
|
||||
...intent.reasons,
|
||||
...followupMerged.reasons,
|
||||
...(followupContextApplied ? ["address_followup_context_applied"] : [])
|
||||
];
|
||||
|
||||
return {
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
filters,
|
||||
baseReasons
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { AddressFilterSet, AddressIntent } from "../../types/addressQuery";
|
||||
|
||||
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||
"ооо",
|
||||
"ао",
|
||||
"зао",
|
||||
"ип",
|
||||
"llc",
|
||||
"ltd",
|
||||
"company",
|
||||
"компания",
|
||||
"контрагент",
|
||||
"counterparty",
|
||||
"по",
|
||||
"by"
|
||||
]);
|
||||
|
||||
export interface AnchorResolutionDebug {
|
||||
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||
anchor_value_raw: string | null;
|
||||
anchor_value_resolved: string | null;
|
||||
resolver_confidence: "high" | "medium" | "low" | null;
|
||||
ambiguity_count: number;
|
||||
}
|
||||
|
||||
export interface ResolveStageRow {
|
||||
registrator: string;
|
||||
account_dt: string | null;
|
||||
account_kt: string | null;
|
||||
analytics: string[];
|
||||
}
|
||||
|
||||
function transliterateCyrillicToLatin(value: string): string {
|
||||
const map: Record<string, string> = {
|
||||
а: "a",
|
||||
б: "b",
|
||||
в: "v",
|
||||
г: "g",
|
||||
д: "d",
|
||||
е: "e",
|
||||
ё: "e",
|
||||
ж: "zh",
|
||||
з: "z",
|
||||
и: "i",
|
||||
й: "y",
|
||||
к: "k",
|
||||
л: "l",
|
||||
м: "m",
|
||||
н: "n",
|
||||
о: "o",
|
||||
п: "p",
|
||||
р: "r",
|
||||
с: "s",
|
||||
т: "t",
|
||||
у: "u",
|
||||
ф: "f",
|
||||
х: "h",
|
||||
ц: "ts",
|
||||
ч: "ch",
|
||||
ш: "sh",
|
||||
щ: "sch",
|
||||
ъ: "",
|
||||
ы: "y",
|
||||
ь: "",
|
||||
э: "e",
|
||||
ю: "yu",
|
||||
я: "ya"
|
||||
};
|
||||
let out = "";
|
||||
for (const char of String(value ?? "").toLowerCase()) {
|
||||
out += map[char] ?? char;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[^a-zа-я0-9]+/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tokenizeAnchor(value: string): string[] {
|
||||
return normalizeSearchText(value)
|
||||
.split(" ")
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
|
||||
}
|
||||
|
||||
function matchesAnchorText(searchable: string, anchor: string): boolean {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
const tokens = tokenizeAnchor(anchor);
|
||||
if (tokens.length === 0) {
|
||||
const direct = normalizeSearchText(anchor);
|
||||
if (!direct) {
|
||||
return false;
|
||||
}
|
||||
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
|
||||
}
|
||||
return tokens.every((token) => {
|
||||
const tokenLatin = transliterateCyrillicToLatin(token);
|
||||
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
|
||||
const account = typeof filters.account === "string" ? filters.account.trim() : "";
|
||||
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
||||
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
||||
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
||||
|
||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||
if (account) {
|
||||
return {
|
||||
anchor_type: "account",
|
||||
anchor_value_raw: account,
|
||||
anchor_value_resolved: account,
|
||||
resolver_confidence: "high",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (counterparty) {
|
||||
return {
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_raw: counterparty,
|
||||
anchor_value_resolved: counterparty,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (contract) {
|
||||
return {
|
||||
anchor_type: "contract",
|
||||
anchor_value_raw: contract,
|
||||
anchor_value_resolved: contract,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (documentRef) {
|
||||
return {
|
||||
anchor_type: "document_ref",
|
||||
anchor_value_raw: documentRef,
|
||||
anchor_value_resolved: documentRef,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anchor_type: "unknown",
|
||||
anchor_value_raw: null,
|
||||
anchor_value_resolved: null,
|
||||
resolver_confidence: "low",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: ResolveStageRow[]): AnchorResolutionDebug {
|
||||
if (rows.length === 0) {
|
||||
return anchor;
|
||||
}
|
||||
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
|
||||
return anchor;
|
||||
}
|
||||
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
|
||||
if (!needleRaw) {
|
||||
return anchor;
|
||||
}
|
||||
const candidates = uniqueStrings(
|
||||
rows
|
||||
.flatMap((row) => row.analytics)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return anchor;
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
return {
|
||||
...anchor,
|
||||
anchor_value_resolved: candidates[0],
|
||||
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
return {
|
||||
...anchor,
|
||||
anchor_value_resolved: candidates[0],
|
||||
resolver_confidence: "low",
|
||||
ambiguity_count: candidates.length - 1
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1693,8 +1693,9 @@ function buildAddressCoverageReport() {
|
||||
out_of_scope_requirements: []
|
||||
};
|
||||
}
|
||||
function buildAddressDebugPayload(addressDebug) {
|
||||
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
|
||||
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
|
||||
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
|
||||
return {
|
||||
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
|
||||
prompt_version: "address_query_runtime_v1",
|
||||
@@ -1752,12 +1753,204 @@ function buildAddressDebugPayload(addressDebug) {
|
||||
runtime_readiness: addressDebug.runtime_readiness,
|
||||
limited_reason_category: addressDebug.limited_reason_category,
|
||||
response_type: addressDebug.response_type,
|
||||
execution_lane: "address_query",
|
||||
llm_decomposition_applied: Boolean(llmMeta?.applied),
|
||||
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
|
||||
llm_provider_used: llmMeta?.provider ?? null,
|
||||
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
|
||||
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
|
||||
llm_decomposition_reason: llmMeta?.reason ?? null,
|
||||
answer_structure_v11: null,
|
||||
investigation_state_snapshot: null,
|
||||
normalized: null,
|
||||
normalizer_output: null
|
||||
normalizer_output: llmMeta?.traceId
|
||||
? {
|
||||
trace_id: llmMeta.traceId,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
applied: Boolean(llmMeta?.applied),
|
||||
effective_message: llmMeta?.effectiveMessage ?? null
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
function toNonEmptyString(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
function readAddressFilterString(addressDebug, key) {
|
||||
const filters = addressDebug?.extracted_filters;
|
||||
if (!filters || typeof filters !== "object") {
|
||||
return null;
|
||||
}
|
||||
return toNonEmptyString(filters[key]);
|
||||
}
|
||||
function findLastAddressAssistantDebug(items) {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug) {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
|
||||
return debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function hasAddressFollowupContextSignal(userMessage) {
|
||||
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
if (hasReferentialPointer(text)) {
|
||||
return true;
|
||||
}
|
||||
const shortFollowup = countTokens(text) <= 8;
|
||||
if (shortFollowup && hasFollowupMarker(text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function resolveAddressFollowupCarryoverContext(userMessage, items) {
|
||||
if (!hasAddressFollowupContextSignal(userMessage)) {
|
||||
return null;
|
||||
}
|
||||
const previousAddressDebug = findLastAddressAssistantDebug(items);
|
||||
if (!previousAddressDebug) {
|
||||
return null;
|
||||
}
|
||||
const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
|
||||
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
||||
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
||||
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
||||
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
||||
readAddressFilterString(previousAddressDebug, "account") ??
|
||||
readAddressFilterString(previousAddressDebug, "contract");
|
||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||
? { ...previousFiltersRaw }
|
||||
: {};
|
||||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
followupContext: {
|
||||
previous_intent: previousIntent ?? undefined,
|
||||
previous_filters: previousFilters,
|
||||
previous_anchor_type: previousAnchorType ?? undefined,
|
||||
previous_anchor_value: previousAnchor
|
||||
},
|
||||
previousAddressIntent: previousIntent,
|
||||
previousAddressAnchor: previousAnchor
|
||||
};
|
||||
}
|
||||
function isAddressLlmPreDecomposeCandidate(userMessage) {
|
||||
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|банк|выписк|платеж|оплат|поступлен|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?)/i.test(text);
|
||||
}
|
||||
function extractAddressQuestionFromNormalized(normalized) {
|
||||
if (!normalized || typeof normalized !== "object") {
|
||||
return null;
|
||||
}
|
||||
const source = normalized;
|
||||
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
|
||||
for (const item of fragments) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const fragment = item;
|
||||
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
|
||||
if (domainRelevance === "out_of_scope") {
|
||||
continue;
|
||||
}
|
||||
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
|
||||
if (readiness === "no_route") {
|
||||
continue;
|
||||
}
|
||||
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
|
||||
const rawText = toNonEmptyString(fragment.raw_fragment_text);
|
||||
const candidate = compactWhitespace(normalizedText ?? rawText ?? "");
|
||||
if (candidate.length >= 3 && candidate.length <= 500) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
|
||||
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
|
||||
const baseMeta = {
|
||||
attempted: false,
|
||||
applied: false,
|
||||
provider,
|
||||
traceId: null,
|
||||
effectiveMessage: userMessage,
|
||||
reason: "not_attempted"
|
||||
};
|
||||
if (Boolean(payload?.useMock)) {
|
||||
return {
|
||||
...baseMeta,
|
||||
reason: "skipped_in_mock"
|
||||
};
|
||||
}
|
||||
if (!isAddressLlmPreDecomposeCandidate(userMessage)) {
|
||||
return {
|
||||
...baseMeta,
|
||||
reason: "not_address_like"
|
||||
};
|
||||
}
|
||||
const normalizePayload = {
|
||||
llmProvider: payload?.llmProvider,
|
||||
apiKey: payload?.apiKey,
|
||||
model: payload?.model,
|
||||
baseUrl: payload?.baseUrl,
|
||||
temperature: 0,
|
||||
maxOutputTokens: payload?.maxOutputTokens,
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
userQuestion: userMessage,
|
||||
context: payload?.context,
|
||||
useMock: Boolean(payload?.useMock),
|
||||
retryPolicy: "single-pass-strict"
|
||||
};
|
||||
try {
|
||||
const normalized = await normalizerService.normalize(normalizePayload);
|
||||
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
|
||||
if (!normalized?.ok || !candidate) {
|
||||
return {
|
||||
...baseMeta,
|
||||
attempted: true,
|
||||
traceId: normalized?.trace_id ?? null,
|
||||
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
|
||||
};
|
||||
}
|
||||
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||
const candidateCompact = compactWhitespace(candidate.toLowerCase());
|
||||
const applied = sourceCompact !== candidateCompact;
|
||||
return {
|
||||
attempted: true,
|
||||
applied,
|
||||
provider,
|
||||
traceId: normalized?.trace_id ?? null,
|
||||
effectiveMessage: applied ? candidate : userMessage,
|
||||
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
...baseMeta,
|
||||
attempted: true,
|
||||
reason: `error:${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
export class AssistantService {
|
||||
normalizerService;
|
||||
sessions;
|
||||
@@ -1789,80 +1982,112 @@ export class AssistantService {
|
||||
debug: null
|
||||
};
|
||||
this.sessions.appendItem(sessionId, userItem);
|
||||
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||
const addressLane = await this.addressQueryService.tryHandle(userMessage);
|
||||
if (addressLane?.handled) {
|
||||
const debug = buildAddressDebugPayload(addressLane.debug);
|
||||
const assistantItem = {
|
||||
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: addressLane.reply_text,
|
||||
reply_type: addressLane.reply_type,
|
||||
created_at: new Date().toISOString(),
|
||||
trace_id: debug.trace_id,
|
||||
debug
|
||||
};
|
||||
this.sessions.appendItem(sessionId, assistantItem);
|
||||
const current = this.sessions.getSession(sessionId);
|
||||
if (current) {
|
||||
this.sessionLogger.persistSession(current);
|
||||
}
|
||||
const conversation = cloneItems(current?.items ?? []);
|
||||
(0, log_1.logJson)({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
service: "assistant_loop",
|
||||
message: "assistant_message_processed",
|
||||
sessionId,
|
||||
eventType: "assistant_message_address",
|
||||
details: {
|
||||
session_id: sessionId,
|
||||
message_id: assistantItem.message_id,
|
||||
user_message: userMessage,
|
||||
detected_mode: addressLane.debug.detected_mode,
|
||||
query_shape: addressLane.debug.query_shape,
|
||||
detected_intent: addressLane.debug.detected_intent,
|
||||
extracted_filters: addressLane.debug.extracted_filters,
|
||||
selected_recipe: addressLane.debug.selected_recipe,
|
||||
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
|
||||
account_scope_mode: addressLane.debug.account_scope_mode,
|
||||
account_scope_fallback_applied: addressLane.debug.account_scope_fallback_applied,
|
||||
anchor_type: addressLane.debug.anchor_type,
|
||||
resolver_confidence: addressLane.debug.resolver_confidence,
|
||||
match_failure_stage: addressLane.debug.match_failure_stage,
|
||||
match_failure_reason: addressLane.debug.match_failure_reason,
|
||||
mcp_call_status: addressLane.debug.mcp_call_status,
|
||||
rows_fetched: addressLane.debug.rows_fetched,
|
||||
raw_rows_received: addressLane.debug.raw_rows_received,
|
||||
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
|
||||
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
|
||||
rows_materialized: addressLane.debug.rows_materialized,
|
||||
rows_matched: addressLane.debug.rows_matched,
|
||||
materialization_drop_reason: addressLane.debug.materialization_drop_reason,
|
||||
account_token_raw: addressLane.debug.account_token_raw,
|
||||
account_token_normalized: addressLane.debug.account_token_normalized,
|
||||
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
|
||||
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
|
||||
account_scope_drop_reason: addressLane.debug.account_scope_drop_reason,
|
||||
runtime_readiness: addressLane.debug.runtime_readiness,
|
||||
limited_reason_category: addressLane.debug.limited_reason_category,
|
||||
response_type: addressLane.debug.response_type,
|
||||
limitations: addressLane.debug.limitations,
|
||||
assistant_reply: assistantItem.text,
|
||||
reply_type: assistantItem.reply_type,
|
||||
trace_id: assistantItem.trace_id
|
||||
}
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
|
||||
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
|
||||
const assistantItem = {
|
||||
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: addressLane.reply_text,
|
||||
reply_type: addressLane.reply_type,
|
||||
created_at: new Date().toISOString(),
|
||||
trace_id: debug.trace_id,
|
||||
debug
|
||||
};
|
||||
this.sessions.appendItem(sessionId, assistantItem);
|
||||
const current = this.sessions.getSession(sessionId);
|
||||
if (current) {
|
||||
this.sessionLogger.persistSession(current);
|
||||
}
|
||||
const conversation = cloneItems(current?.items ?? []);
|
||||
(0, log_1.logJson)({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
service: "assistant_loop",
|
||||
message: "assistant_message_processed",
|
||||
sessionId,
|
||||
eventType: "assistant_message_address",
|
||||
details: {
|
||||
session_id: sessionId,
|
||||
message_id: assistantItem.message_id,
|
||||
user_message: userMessage,
|
||||
effective_address_user_message: effectiveAddressUserMessage,
|
||||
address_followup_context_applied: Boolean(carryoverMeta),
|
||||
address_followup_context_previous_intent: carryoverMeta?.previousAddressIntent ?? null,
|
||||
address_followup_context_previous_anchor: carryoverMeta?.previousAddressAnchor ?? null,
|
||||
address_llm_predecompose_attempted: Boolean(llmPreDecomposeMeta?.attempted),
|
||||
address_llm_predecompose_applied: Boolean(llmPreDecomposeMeta?.applied),
|
||||
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
|
||||
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
|
||||
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
|
||||
detected_mode: addressLane.debug.detected_mode,
|
||||
query_shape: addressLane.debug.query_shape,
|
||||
detected_intent: addressLane.debug.detected_intent,
|
||||
extracted_filters: addressLane.debug.extracted_filters,
|
||||
selected_recipe: addressLane.debug.selected_recipe,
|
||||
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
|
||||
account_scope_mode: addressLane.debug.account_scope_mode,
|
||||
account_scope_fallback_applied: addressLane.debug.account_scope_fallback_applied,
|
||||
anchor_type: addressLane.debug.anchor_type,
|
||||
resolver_confidence: addressLane.debug.resolver_confidence,
|
||||
match_failure_stage: addressLane.debug.match_failure_stage,
|
||||
match_failure_reason: addressLane.debug.match_failure_reason,
|
||||
mcp_call_status: addressLane.debug.mcp_call_status,
|
||||
rows_fetched: addressLane.debug.rows_fetched,
|
||||
raw_rows_received: addressLane.debug.raw_rows_received,
|
||||
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
|
||||
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
|
||||
rows_materialized: addressLane.debug.rows_materialized,
|
||||
rows_matched: addressLane.debug.rows_matched,
|
||||
materialization_drop_reason: addressLane.debug.materialization_drop_reason,
|
||||
account_token_raw: addressLane.debug.account_token_raw,
|
||||
account_token_normalized: addressLane.debug.account_token_normalized,
|
||||
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
|
||||
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
|
||||
account_scope_drop_reason: addressLane.debug.account_scope_drop_reason,
|
||||
runtime_readiness: addressLane.debug.runtime_readiness,
|
||||
limited_reason_category: addressLane.debug.limited_reason_category,
|
||||
response_type: addressLane.debug.response_type,
|
||||
limitations: addressLane.debug.limitations,
|
||||
assistant_reply: assistantItem.text,
|
||||
reply_type: assistantItem.reply_type,
|
||||
conversation_item: assistantItem,
|
||||
debug,
|
||||
conversation
|
||||
trace_id: assistantItem.trace_id
|
||||
}
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
session_id: sessionId,
|
||||
assistant_reply: assistantItem.text,
|
||||
reply_type: assistantItem.reply_type,
|
||||
conversation_item: assistantItem,
|
||||
debug,
|
||||
conversation
|
||||
};
|
||||
};
|
||||
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
|
||||
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
|
||||
: {
|
||||
attempted: false,
|
||||
applied: false,
|
||||
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
|
||||
traceId: null,
|
||||
effectiveMessage: userMessage,
|
||||
reason: "disabled_by_feature_flag"
|
||||
};
|
||||
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
|
||||
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
|
||||
if (primaryAddressLane?.handled) {
|
||||
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose);
|
||||
}
|
||||
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
|
||||
if (carryover?.followupContext) {
|
||||
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
|
||||
followupContext: carryover.followupContext
|
||||
});
|
||||
if (contextualAddressLane?.handled) {
|
||||
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose);
|
||||
}
|
||||
}
|
||||
}
|
||||
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
|
||||
@@ -1879,12 +2104,13 @@ export class AssistantService {
|
||||
usage: null
|
||||
};
|
||||
const normalizePayload = {
|
||||
llmProvider: payload.llmProvider,
|
||||
apiKey: payload.apiKey,
|
||||
model: payload.model,
|
||||
baseUrl: payload.baseUrl,
|
||||
temperature: payload.temperature,
|
||||
maxOutputTokens: payload.maxOutputTokens,
|
||||
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
|
||||
promptVersion: payload.promptVersion ?? "address_query_runtime_v1",
|
||||
systemPrompt: payload.systemPrompt,
|
||||
developerPrompt: payload.developerPrompt,
|
||||
domainPrompt: payload.domainPrompt,
|
||||
|
||||
@@ -1036,6 +1036,7 @@ export class NormalizerService {
|
||||
public async normalize(payload: NormalizeRequestPayload): Promise<NormalizeResponsePayload> {
|
||||
const traceId = nanoid(14);
|
||||
const startedAt = Date.now();
|
||||
const llmProvider = payload.llmProvider === "local" ? "local" : "openai";
|
||||
const model = payload.model ?? DEFAULT_MODEL;
|
||||
const baseUrl = payload.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
|
||||
const temperature = payload.temperature ?? DEFAULT_TEMPERATURE;
|
||||
@@ -1072,6 +1073,7 @@ export class NormalizerService {
|
||||
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
|
||||
const firstTry = await this.openaiClient.normalize(
|
||||
{
|
||||
llmProvider,
|
||||
apiKey: String(apiKey ?? ""),
|
||||
model,
|
||||
baseUrl,
|
||||
@@ -1118,6 +1120,7 @@ export class NormalizerService {
|
||||
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
|
||||
const retry = await this.openaiClient.normalize(
|
||||
{
|
||||
llmProvider,
|
||||
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||
model,
|
||||
baseUrl,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config";
|
||||
import type { LlmProvider } from "../types/normalizer";
|
||||
import { ApiError } from "../utils/http";
|
||||
|
||||
export interface OpenAIRequestConfig {
|
||||
llmProvider?: LlmProvider;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
@@ -21,6 +23,22 @@ export interface OpenAIResponseEnvelope {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProvider(config: OpenAIRequestConfig): LlmProvider {
|
||||
return config.llmProvider === "local" ? "local" : "openai";
|
||||
}
|
||||
|
||||
function resolveApiKey(config: OpenAIRequestConfig): string {
|
||||
const candidate = String(config.apiKey ?? "").trim();
|
||||
if (candidate.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
if (resolveProvider(config) === "local") {
|
||||
// Local OpenAI-compatible servers often accept any token.
|
||||
return "local-dev-token";
|
||||
}
|
||||
throw new ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400);
|
||||
}
|
||||
|
||||
function extractUsage(raw: Record<string, unknown>): {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -37,7 +55,7 @@ function extractUsage(raw: Record<string, unknown>): {
|
||||
};
|
||||
}
|
||||
|
||||
function extractOutputText(raw: Record<string, unknown>): string {
|
||||
function extractOutputTextFromResponses(raw: Record<string, unknown>): string {
|
||||
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
||||
return raw.output_text;
|
||||
}
|
||||
@@ -72,7 +90,58 @@ function extractOutputText(raw: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw);
|
||||
}
|
||||
|
||||
function extractOutputTextFromChatCompletions(raw: Record<string, unknown>): string {
|
||||
const choices = raw.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw);
|
||||
}
|
||||
const first = choices[0];
|
||||
if (!first || typeof first !== "object") {
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw);
|
||||
}
|
||||
const message = (first as Record<string, unknown>).message;
|
||||
if (!message || typeof message !== "object") {
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw);
|
||||
}
|
||||
const content = (message as Record<string, unknown>).content;
|
||||
if (typeof content === "string" && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const textParts = content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return "";
|
||||
}
|
||||
const block = item as Record<string, unknown>;
|
||||
return typeof block.text === "string" ? block.text : "";
|
||||
})
|
||||
.filter((item) => item.trim().length > 0);
|
||||
if (textParts.length > 0) {
|
||||
return textParts.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw);
|
||||
}
|
||||
|
||||
function shouldFallbackToChatCompletions(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return false;
|
||||
}
|
||||
if (error.code !== "OPENAI_REQUEST_FAILED") {
|
||||
return false;
|
||||
}
|
||||
const details = (error.details ?? {}) as Record<string, unknown>;
|
||||
const status = Number(details.status ?? 0);
|
||||
if ([404, 405, 501].includes(status)) {
|
||||
return true;
|
||||
}
|
||||
const message = String(error.message ?? "").toLowerCase();
|
||||
return message.includes("/responses") || message.includes("responses");
|
||||
}
|
||||
|
||||
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
|
||||
@@ -83,24 +152,62 @@ function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"
|
||||
? "normalized_query_v2_0_1.json"
|
||||
: schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2.json"
|
||||
: "normalized_query_v2.json";
|
||||
: "normalized_query_v2.json";
|
||||
const schemaPath = path.resolve(SCHEMAS_DIR, schemaFile);
|
||||
return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
|
||||
const base = (config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
|
||||
const provider = resolveProvider(config);
|
||||
if (provider !== "local") {
|
||||
return [base];
|
||||
}
|
||||
const hasVersionSuffix = /\/v\d+$/i.test(base);
|
||||
if (hasVersionSuffix) {
|
||||
return [base];
|
||||
}
|
||||
return Array.from(new Set([base, `${base}/v1`]));
|
||||
}
|
||||
|
||||
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" }]
|
||||
public async listModels(config: OpenAIRequestConfig): Promise<string[]> {
|
||||
const payload = await this.getModels(config);
|
||||
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||
const ids = data
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return "";
|
||||
}
|
||||
],
|
||||
return String((item as Record<string, unknown>).id ?? "").trim();
|
||||
})
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
|
||||
const provider = resolveProvider(config);
|
||||
if (provider === "local") {
|
||||
try {
|
||||
await this.getModels(config);
|
||||
} catch {
|
||||
// Some local providers do not expose /models consistently; fallback to a tiny chat call.
|
||||
await this.postChatCompletions(config, {
|
||||
model: config.model,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 4,
|
||||
temperature: 0
|
||||
});
|
||||
}
|
||||
return { ok: true, model: config.model };
|
||||
}
|
||||
|
||||
await this.postResponses(config, {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -123,13 +230,13 @@ export class OpenAIResponsesClient {
|
||||
? "normalized_query_v2_0_1"
|
||||
: prompt.schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2"
|
||||
: "normalized_query_v2";
|
||||
: "normalized_query_v2";
|
||||
|
||||
const developerPrompt = prompt.controlledRetryInstruction
|
||||
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
||||
: prompt.developerPrompt;
|
||||
|
||||
const payload = {
|
||||
const responsesPayload = {
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0,
|
||||
max_output_tokens: config.maxOutputTokens ?? 700,
|
||||
@@ -147,7 +254,7 @@ export class OpenAIResponsesClient {
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
|
||||
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -162,52 +269,157 @@ export class OpenAIResponsesClient {
|
||||
}
|
||||
};
|
||||
|
||||
const raw = await this.post(config, payload);
|
||||
const outputText = extractOutputText(raw);
|
||||
const provider = resolveProvider(config);
|
||||
if (provider === "openai") {
|
||||
const raw = await this.postResponses(config, responsesPayload);
|
||||
return {
|
||||
raw,
|
||||
outputText: extractOutputTextFromResponses(raw),
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
// local provider: prefer /responses if available, fallback to /chat/completions
|
||||
try {
|
||||
const raw = await this.postResponses(config, responsesPayload);
|
||||
return {
|
||||
raw,
|
||||
outputText: extractOutputTextFromResponses(raw),
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToChatCompletions(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const chatPayload = {
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0,
|
||||
max_tokens: config.maxOutputTokens ?? 700,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `${prompt.systemPrompt}\n\n${developerPrompt}`
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
`${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` +
|
||||
`Return only JSON that matches schema: ${schemaName}.`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const raw = await this.postChatCompletions(config, chatPayload);
|
||||
return {
|
||||
raw,
|
||||
outputText,
|
||||
outputText: extractOutputTextFromChatCompletions(raw),
|
||||
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);
|
||||
private async getModels(config: OpenAIRequestConfig): Promise<Record<string, unknown>> {
|
||||
return this.requestJson(config, "/models", "GET");
|
||||
}
|
||||
|
||||
private async postResponses(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return this.requestJson(config, "/responses", "POST", payload);
|
||||
}
|
||||
|
||||
private async postChatCompletions(
|
||||
config: OpenAIRequestConfig,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return this.requestJson(config, "/chat/completions", "POST", payload);
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
config: OpenAIRequestConfig,
|
||||
routePath: string,
|
||||
method: "GET" | "POST",
|
||||
payload?: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const apiKey = resolveApiKey(config);
|
||||
const baseCandidates = buildBaseUrlCandidates(config);
|
||||
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
|
||||
let lastNetworkError: unknown = null;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
};
|
||||
if (method === "POST") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
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
|
||||
for (let index = 0; index < baseCandidates.length; index += 1) {
|
||||
const base = baseCandidates[index];
|
||||
const isLastCandidate = index === baseCandidates.length - 1;
|
||||
const url = `${base}${routePath}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
|
||||
});
|
||||
} catch (error) {
|
||||
lastNetworkError = error;
|
||||
if (!isLastCandidate) {
|
||||
continue;
|
||||
}
|
||||
);
|
||||
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
|
||||
route: routePath,
|
||||
url,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let data: Record<string, unknown> = {};
|
||||
if (text.trim().length > 0) {
|
||||
try {
|
||||
data = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
|
||||
continue;
|
||||
}
|
||||
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, {
|
||||
route: routePath,
|
||||
url,
|
||||
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 ?? `Model endpoint failed: ${response.status}`),
|
||||
response.status,
|
||||
{
|
||||
route: routePath,
|
||||
url,
|
||||
status: response.status,
|
||||
type: errorObj.type ?? null,
|
||||
code: errorObj.code ?? null
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
return data;
|
||||
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
|
||||
route: routePath,
|
||||
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ export interface AssistantMessageRequestPayload {
|
||||
user_message?: string;
|
||||
message?: string;
|
||||
mode?: "assistant" | string;
|
||||
llmProvider?: NormalizeRequestPayload["llmProvider"];
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
@@ -370,6 +371,13 @@ export interface AssistantDebugPayload {
|
||||
runtime_readiness?: "LIVE_QUERYABLE" | "LIVE_QUERYABLE_WITH_LIMITS" | "REQUIRES_SPECIALIZED_RECIPE" | "DEEP_ONLY" | "UNKNOWN";
|
||||
limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
|
||||
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
|
||||
execution_lane?: "address_query" | "deep_analysis";
|
||||
llm_decomposition_applied?: boolean;
|
||||
llm_decomposition_attempted?: boolean;
|
||||
llm_provider_used?: "openai" | "local" | null;
|
||||
llm_decomposition_trace_id?: string | null;
|
||||
llm_decomposition_effective_message?: string | null;
|
||||
llm_decomposition_reason?: string | null;
|
||||
business_scope_raw?: string[];
|
||||
business_scope_resolved?: string[];
|
||||
company_grounding_applied?: boolean;
|
||||
|
||||
@@ -30,6 +30,7 @@ export type PromptVersion =
|
||||
| "normalizer_v2_0_2";
|
||||
|
||||
export type EvalRunMode = "standard" | "single-pass-strict";
|
||||
export type LlmProvider = "openai" | "local";
|
||||
|
||||
export interface NormalizedQueryV1 {
|
||||
schema_version: "normalized_query_v1";
|
||||
@@ -235,6 +236,7 @@ export type RouteHintSummary = RouteHintSummaryV1 | RouteHintSummaryV2;
|
||||
export type NormalizedPayload = NormalizedQueryV1 | NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
|
||||
|
||||
export interface NormalizeRequestPayload {
|
||||
llmProvider?: LlmProvider;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
|
||||
Reference in New Issue
Block a user