259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
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: "settlements",
|
|
group_title: "Долги и расчёты",
|
|
description: "Сальдо, хвосты, незакрытые авансы и аналитика по расчётам",
|
|
risk_level: "high",
|
|
maturity_status: "production_ready",
|
|
supported_operations: ["receivables_confirmed_as_of_date", "open_items_by_counterparty_or_contract"],
|
|
unsupported_operations: ["close_period"],
|
|
required_entities: ["period_or_date"],
|
|
optional_entities: ["organization", "account", "counterparty"],
|
|
typical_queries: ["Кто нам должен на дату?", "Хвосты покажи по счёту 60 за период"],
|
|
related_routes: [],
|
|
safe_alternatives: ["Уточнить период, счёт или организацию"],
|
|
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
|
|
},
|
|
{
|
|
group_code: "cash",
|
|
group_title: "Деньги",
|
|
description: "Остатки и движение по денежным счетам и кассе",
|
|
risk_level: "medium",
|
|
maturity_status: "production_ready",
|
|
supported_operations: ["account_balance_snapshot", "bank_operations_by_counterparty"],
|
|
unsupported_operations: ["post_bank_statement"],
|
|
required_entities: ["date_or_period"],
|
|
optional_entities: ["organization", "account", "counterparty"],
|
|
typical_queries: ["Какой остаток по счёту 51 на дату?", "Покажи движение денег за месяц"],
|
|
related_routes: [],
|
|
safe_alternatives: ["Уточнить счёт или период"],
|
|
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
|
|
},
|
|
{
|
|
group_code: "inventory",
|
|
group_title: "Склад и товары",
|
|
description: "Подтверждённые остатки, происхождение и документы по товарным позициям",
|
|
risk_level: "medium",
|
|
maturity_status: "production_ready",
|
|
supported_operations: [
|
|
"inventory_on_hand_as_of_date",
|
|
"inventory_purchase_provenance_for_item",
|
|
"inventory_purchase_documents_for_item"
|
|
],
|
|
unsupported_operations: ["write_off_inventory"],
|
|
required_entities: ["date_or_period"],
|
|
optional_entities: ["organization", "warehouse", "item"],
|
|
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.filter((group) => group.group_code !== "boundaries").slice(0, 6);
|
|
const groupLines = topGroups.map((group, index) => {
|
|
const examples = group.typical_queries
|
|
.slice(0, 2)
|
|
.map((query) => query.trim())
|
|
.filter((query) => query.length > 0)
|
|
.join("; ");
|
|
return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`;
|
|
});
|
|
|
|
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;
|
|
}
|