NODEDC_1C/llm_normalizer/backend/src/services/capabilitiesRegistry.ts

206 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "fs";
import { ASSISTANT_CAPABILITIES_REGISTRY_FILE } from "../config";
export type CapabilityMaturity = "production_ready" | "partial" | "planned" | "deprecated";
export interface CapabilityGroup {
group_code: string;
group_title: string;
description: string;
risk_level: "low" | "medium" | "high";
maturity_status: CapabilityMaturity;
supported_operations: string[];
unsupported_operations: string[];
required_entities: string[];
optional_entities: string[];
typical_queries: string[];
related_routes: string[];
safe_alternatives: string[];
one_c_hints: string[];
}
export interface CapabilityRegistry {
schema_version: string;
updated_at: string;
assistant_mode: "read_only" | "mixed";
groups: CapabilityGroup[];
}
const FALLBACK_REGISTRY: CapabilityRegistry = {
schema_version: "capabilities_registry_fallback_v1",
updated_at: "2026-04-09T00:00:00.000Z",
assistant_mode: "read_only",
groups: [
{
group_code: "vat",
group_title: "НДС",
description: "Срезы и расчеты НДС на базе данных 1С.",
risk_level: "high",
maturity_status: "partial",
supported_operations: ["vat_period_snapshot", "vat_payable_forecast"],
unsupported_operations: ["submit_tax_declaration"],
required_entities: ["period", "organization"],
optional_entities: ["counterparty"],
typical_queries: ["Сколько НДС к уплате за период?"],
related_routes: [],
safe_alternatives: ["Показать движения по 68/19 за период"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
},
{
group_code: "counterparties",
group_title: "Контрагенты",
description: "Документы, операции, договоры и срезы по контрагентам.",
risk_level: "medium",
maturity_status: "production_ready",
supported_operations: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
unsupported_operations: ["edit_counterparty_card"],
required_entities: ["counterparty_scope_or_contract"],
optional_entities: ["period", "organization"],
typical_queries: ["Покажи документы по контрагенту"],
related_routes: [],
safe_alternatives: ["Уточнить ИНН/наименование контрагента"],
one_c_hints: ["Справочник.Контрагенты"]
},
{
group_code: "boundaries",
group_title: "Ограничения",
description: "Операции, которые ассистент не выполняет.",
risk_level: "high",
maturity_status: "production_ready",
supported_operations: ["explain_boundary", "suggest_safe_next_step"],
unsupported_operations: ["configure_1c", "admin_server_actions", "create_or_post_documents"],
required_entities: [],
optional_entities: [],
typical_queries: ["Можешь настроить 1С?"],
related_routes: [],
safe_alternatives: ["Сформировать план диагностики для 1С/ИТ-админа"],
one_c_hints: []
}
]
};
let cache: { mtimeMs: number; value: CapabilityRegistry } | null = null;
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function toStringSafe(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readRegistryFromFile(): CapabilityRegistry | null {
if (!fs.existsSync(ASSISTANT_CAPABILITIES_REGISTRY_FILE)) return null;
try {
const raw = fs.readFileSync(ASSISTANT_CAPABILITIES_REGISTRY_FILE, "utf-8");
const parsed = JSON.parse(raw) as unknown;
const root = toRecord(parsed);
if (!root) return null;
const groups = toArray(root.groups)
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null)
.map((item) => ({
group_code: toStringSafe(item.group_code) ?? "unknown_group",
group_title: toStringSafe(item.group_title) ?? "Группа",
description: toStringSafe(item.description) ?? "",
risk_level: (toStringSafe(item.risk_level) as "low" | "medium" | "high" | null) ?? "medium",
maturity_status:
(toStringSafe(item.maturity_status) as CapabilityMaturity | null) ??
("partial" as CapabilityMaturity),
supported_operations: toArray(item.supported_operations)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null),
unsupported_operations: toArray(item.unsupported_operations)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null),
required_entities: toArray(item.required_entities)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null),
optional_entities: toArray(item.optional_entities)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null),
typical_queries: toArray(item.typical_queries)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null),
related_routes: toArray(item.related_routes)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null),
safe_alternatives: toArray(item.safe_alternatives)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null),
one_c_hints: toArray(item.one_c_hints)
.map((v) => toStringSafe(v))
.filter((v): v is string => v !== null)
}));
if (groups.length === 0) return null;
return {
schema_version: toStringSafe(root.schema_version) ?? "capabilities_registry_v1",
updated_at: toStringSafe(root.updated_at) ?? new Date().toISOString(),
assistant_mode: (toStringSafe(root.assistant_mode) as "read_only" | "mixed" | null) ?? "read_only",
groups
};
} catch {
return null;
}
}
export function loadCapabilitiesRegistry(): CapabilityRegistry {
try {
const mtimeMs = fs.existsSync(ASSISTANT_CAPABILITIES_REGISTRY_FILE)
? fs.statSync(ASSISTANT_CAPABILITIES_REGISTRY_FILE).mtimeMs
: -1;
if (cache && cache.mtimeMs === mtimeMs) {
return cache.value;
}
const value = readRegistryFromFile() ?? FALLBACK_REGISTRY;
cache = { mtimeMs, value };
return value;
} catch {
return cache?.value ?? FALLBACK_REGISTRY;
}
}
export function buildCapabilityContractReplyFromRegistry(): string {
const registry = loadCapabilitiesRegistry();
const topGroups = registry.groups.slice(0, 6);
const groupLines = topGroups.map((group, index) => {
const ops = group.supported_operations.slice(0, 3).join(", ");
return `${index + 1}. ${group.group_title}: ${group.description}${ops ? ` (например: ${ops})` : ""}.`;
});
return [
"Я ассистент по анализу данных 1С в режиме чтения.",
"Что умею по группам:",
...groupLines,
"Если хотите, раскрою любую группу точечно и дам готовую формулировку запроса.",
"Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере."
].join("\n");
}
export function resolveNearestCapabilityGroup(input: { domain?: string | null; queryClass?: string | null }): CapabilityGroup | null {
const registry = loadCapabilitiesRegistry();
const haystack = `${String(input.domain ?? "")} ${String(input.queryClass ?? "")}`.toLowerCase();
if (!haystack.trim()) return null;
const scoring: Array<{ group: CapabilityGroup; score: number }> = registry.groups.map((group) => {
let score = 0;
const bucket = `${group.group_code} ${group.group_title} ${group.description} ${group.supported_operations.join(" ")}`.toLowerCase();
for (const token of haystack.split(/[\s._/-]+/g).filter(Boolean)) {
if (bucket.includes(token)) score += 1;
}
return { group, score };
});
scoring.sort((a, b) => b.score - a.score);
return scoring[0] && scoring[0].score > 0 ? scoring[0].group : null;
}