474 lines
13 KiB
TypeScript
474 lines
13 KiB
TypeScript
import {
|
||
ASSISTANT_MCP_CHANNEL,
|
||
ASSISTANT_MCP_PROXY_URL,
|
||
ASSISTANT_MCP_TIMEOUT_MS
|
||
} from "../config";
|
||
import iconv from "iconv-lite";
|
||
|
||
interface McpExecuteQueryResponse {
|
||
success?: unknown;
|
||
data?: unknown;
|
||
error?: unknown;
|
||
}
|
||
|
||
export interface AddressMcpQueryResult {
|
||
ok: boolean;
|
||
rows: Array<Record<string, unknown>>;
|
||
error: string | null;
|
||
}
|
||
|
||
export interface AddressMcpMetadataRowsResult {
|
||
fetched_rows: number;
|
||
raw_rows: Array<Record<string, unknown>>;
|
||
rows: Array<Record<string, unknown>>;
|
||
error: string | null;
|
||
}
|
||
|
||
function toStringValue(value: unknown): string {
|
||
if (value === null || value === undefined) {
|
||
return "";
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
function parseFiniteNumber(value: unknown): number | null {
|
||
if (typeof value === "number" && Number.isFinite(value)) {
|
||
return value;
|
||
}
|
||
if (typeof value === "string") {
|
||
const parsed = Number(value.replace(",", ".").trim());
|
||
if (Number.isFinite(parsed)) {
|
||
return parsed;
|
||
}
|
||
}
|
||
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 = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
|
||
if (!normalized) {
|
||
return [];
|
||
}
|
||
|
||
const headerMatch = normalized.match(/\{([^}]*)\}:/);
|
||
if (!headerMatch) {
|
||
return [];
|
||
}
|
||
|
||
const columns = String(headerMatch[1] ?? "")
|
||
.split(",")
|
||
.map((item) => item.replace(/^"+|"+$/g, "").trim())
|
||
.filter(Boolean);
|
||
|
||
const body = normalized.slice((headerMatch.index ?? 0) + headerMatch[0].length).trim();
|
||
if (!body) {
|
||
return [];
|
||
}
|
||
|
||
const rows: Array<Record<string, unknown>> = [];
|
||
const parseCsvLine = (line: string): string[] => {
|
||
const values: string[] = [];
|
||
let current = "";
|
||
let inQuotes = false;
|
||
for (let index = 0; index < line.length; index += 1) {
|
||
const char = line[index];
|
||
if (char === '"') {
|
||
if (inQuotes && line[index + 1] === '"') {
|
||
current += '"';
|
||
index += 1;
|
||
continue;
|
||
}
|
||
inQuotes = !inQuotes;
|
||
continue;
|
||
}
|
||
if (char === "," && !inQuotes) {
|
||
values.push(current.trim());
|
||
current = "";
|
||
continue;
|
||
}
|
||
current += char;
|
||
}
|
||
values.push(current.trim());
|
||
return values;
|
||
};
|
||
const lines = body
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
|
||
for (const line of lines) {
|
||
const values = parseCsvLine(line);
|
||
|
||
if (values.length === 0) {
|
||
continue;
|
||
}
|
||
|
||
const row: Record<string, unknown> = {};
|
||
for (let index = 0; index < columns.length; index += 1) {
|
||
const key = columns[index] ?? `column_${index + 1}`;
|
||
const raw = values[index] ?? "";
|
||
const parsed = parseFiniteNumber(raw);
|
||
row[key] = parsed ?? raw;
|
||
}
|
||
|
||
if (values[0]) row.Period = values[0];
|
||
if (values[1]) row.Registrator = values[1];
|
||
if (values[2]) row.AccountDt = values[2];
|
||
if (values[3]) row.AccountKt = values[3];
|
||
if (values[4]) row.Amount = parseFiniteNumber(values[4]) ?? values[4];
|
||
rows.push(row);
|
||
}
|
||
|
||
return normalizeMojibakeRows(rows);
|
||
}
|
||
|
||
function parseRowsPayload(
|
||
payload: unknown,
|
||
options: {
|
||
allowSingleObjectRow?: boolean;
|
||
} = {}
|
||
): AddressMcpQueryResult {
|
||
if (!payload || typeof payload !== "object") {
|
||
return {
|
||
ok: false,
|
||
rows: [],
|
||
error: "MCP payload is empty or malformed"
|
||
};
|
||
}
|
||
|
||
const source = payload as McpExecuteQueryResponse;
|
||
if (source.success !== true) {
|
||
return {
|
||
ok: false,
|
||
rows: [],
|
||
error: toStringValue(source.error).trim() || "MCP execute_query returned success=false"
|
||
};
|
||
}
|
||
|
||
if (Array.isArray(source.data)) {
|
||
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,
|
||
error: null
|
||
};
|
||
}
|
||
|
||
if (typeof source.data === "string") {
|
||
return {
|
||
ok: true,
|
||
rows: parseRowsFromTextTable(source.data),
|
||
error: null
|
||
};
|
||
}
|
||
|
||
if (source.data && typeof source.data === "object" && Array.isArray((source.data as { rows?: unknown }).rows)) {
|
||
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,
|
||
error: null
|
||
};
|
||
}
|
||
|
||
if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) {
|
||
return {
|
||
ok: true,
|
||
rows: [normalizeMojibakeValue(source.data) as Record<string, unknown>],
|
||
error: null
|
||
};
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
rows: [],
|
||
error: null
|
||
};
|
||
}
|
||
|
||
function buildMcpUrl(endpoint: string): string {
|
||
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||
const separator = normalizedEndpoint.includes("?") ? "&" : "?";
|
||
return `${ASSISTANT_MCP_PROXY_URL}${normalizedEndpoint}${separator}channel=${encodeURIComponent(ASSISTANT_MCP_CHANNEL)}`;
|
||
}
|
||
|
||
function escapeRegExp(value: string): string {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function filterRowsByAccountScope(
|
||
rows: Array<Record<string, unknown>>,
|
||
accountScope: string[]
|
||
): Array<Record<string, unknown>> {
|
||
if (accountScope.length === 0) {
|
||
return rows;
|
||
}
|
||
const matchers = accountScope.map((account) => new RegExp(`\\b${escapeRegExp(account)}(?:\\.\\d{1,2})?\\b`, "i"));
|
||
return rows.filter((row) => {
|
||
const searchable = Object.values(row)
|
||
.map((item) => String(item ?? ""))
|
||
.join(" ");
|
||
return matchers.some((matcher) => matcher.test(searchable));
|
||
});
|
||
}
|
||
|
||
export async function executeAddressMcpQuery(input: {
|
||
query: string;
|
||
limit: number;
|
||
account_scope?: string[];
|
||
timeout_ms?: number;
|
||
}): Promise<{
|
||
fetched_rows: number;
|
||
matched_rows: number;
|
||
raw_rows: Array<Record<string, unknown>>;
|
||
rows: Array<Record<string, unknown>>;
|
||
error: string | null;
|
||
}> {
|
||
const endpoint = buildMcpUrl("/api/execute_query");
|
||
const controller = new AbortController();
|
||
const resolvedTimeoutMs =
|
||
typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms)
|
||
? Math.max(300, Math.trunc(input.timeout_ms))
|
||
: Math.max(300, ASSISTANT_MCP_TIMEOUT_MS);
|
||
const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs);
|
||
try {
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json; charset=utf-8"
|
||
},
|
||
body: JSON.stringify({
|
||
query: input.query,
|
||
limit: input.limit
|
||
}),
|
||
signal: controller.signal
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
if (!response.ok) {
|
||
return {
|
||
fetched_rows: 0,
|
||
matched_rows: 0,
|
||
raw_rows: [],
|
||
rows: [],
|
||
error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}`
|
||
};
|
||
}
|
||
|
||
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
|
||
const parsed = parseRowsPayload(payload);
|
||
if (!parsed.ok) {
|
||
return {
|
||
fetched_rows: 0,
|
||
matched_rows: 0,
|
||
raw_rows: [],
|
||
rows: [],
|
||
error: parsed.error
|
||
};
|
||
}
|
||
|
||
const filtered = filterRowsByAccountScope(parsed.rows, Array.isArray(input.account_scope) ? input.account_scope : []);
|
||
return {
|
||
fetched_rows: parsed.rows.length,
|
||
matched_rows: filtered.length,
|
||
raw_rows: parsed.rows,
|
||
rows: filtered,
|
||
error: null
|
||
};
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return {
|
||
fetched_rows: 0,
|
||
matched_rows: 0,
|
||
raw_rows: [],
|
||
rows: [],
|
||
error: `MCP fetch failed: ${message}`
|
||
};
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
export async function executeAddressMcpMetadata(input: {
|
||
filter?: string;
|
||
meta_type?: string | string[];
|
||
name_mask?: string;
|
||
limit?: number;
|
||
offset?: number;
|
||
sections?: string[];
|
||
extension_name?: string | null;
|
||
timeout_ms?: number;
|
||
}): Promise<AddressMcpMetadataRowsResult> {
|
||
const endpoint = buildMcpUrl("/api/get_metadata");
|
||
const controller = new AbortController();
|
||
const resolvedTimeoutMs =
|
||
typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms)
|
||
? Math.max(300, Math.trunc(input.timeout_ms))
|
||
: Math.max(300, ASSISTANT_MCP_TIMEOUT_MS);
|
||
const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs);
|
||
try {
|
||
const body: Record<string, unknown> = {};
|
||
if (typeof input.filter === "string" && input.filter.trim().length > 0) {
|
||
body.filter = input.filter.trim();
|
||
}
|
||
if (typeof input.meta_type === "string" && input.meta_type.trim().length > 0) {
|
||
body.meta_type = input.meta_type.trim();
|
||
} else if (Array.isArray(input.meta_type) && input.meta_type.length > 0) {
|
||
const values = input.meta_type
|
||
.map((item) => String(item ?? "").trim())
|
||
.filter((item) => item.length > 0);
|
||
if (values.length > 0) {
|
||
body.meta_type = values;
|
||
}
|
||
}
|
||
if (typeof input.name_mask === "string" && input.name_mask.trim().length > 0) {
|
||
body.name_mask = input.name_mask.trim();
|
||
}
|
||
if (typeof input.limit === "number" && Number.isFinite(input.limit)) {
|
||
body.limit = Math.max(1, Math.min(1000, Math.trunc(input.limit)));
|
||
}
|
||
if (typeof input.offset === "number" && Number.isFinite(input.offset)) {
|
||
body.offset = Math.max(0, Math.min(1_000_000, Math.trunc(input.offset)));
|
||
}
|
||
if (Array.isArray(input.sections) && input.sections.length > 0) {
|
||
const sections = input.sections
|
||
.map((item) => String(item ?? "").trim())
|
||
.filter((item) => item.length > 0);
|
||
if (sections.length > 0) {
|
||
body.sections = sections;
|
||
}
|
||
}
|
||
if (input.extension_name !== undefined) {
|
||
body.extension_name = input.extension_name;
|
||
}
|
||
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json; charset=utf-8"
|
||
},
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
if (!response.ok) {
|
||
return {
|
||
fetched_rows: 0,
|
||
raw_rows: [],
|
||
rows: [],
|
||
error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}`
|
||
};
|
||
}
|
||
|
||
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
|
||
const parsed = parseRowsPayload(payload, { allowSingleObjectRow: true });
|
||
if (!parsed.ok) {
|
||
return {
|
||
fetched_rows: 0,
|
||
raw_rows: [],
|
||
rows: [],
|
||
error: parsed.error
|
||
};
|
||
}
|
||
|
||
return {
|
||
fetched_rows: parsed.rows.length,
|
||
raw_rows: parsed.rows,
|
||
rows: parsed.rows,
|
||
error: null
|
||
};
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return {
|
||
fetched_rows: 0,
|
||
raw_rows: [],
|
||
rows: [],
|
||
error: `MCP fetch failed: ${message}`
|
||
};
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|