Files
iistwin/server/finance-di2/ai-config.ts
Ильяс Султанов 254950f151 DI2 внедрение, таск 1: серверная интеграция Data-Insight2 (server/finance-di2)
- Копия DI2-сервера в server/finance-di2/ с правками: schema/db-client/cache/table-config/override-tables/google-auth/audit-agent/routes
- db-client — обёртка над существующим пулом server/finance/db-client (второй пул не создаётся, добавлен экспорт isConnectionError)
- ai-config.ts — чистое IO конфигов из ai-agent.ts без Telegram-поллинга; роуты /api/ai/toggle и /api/ai/status удалены, без compression/startAuditScheduler/autoStartIfEnabled
- Обёртка registerDi2Routes с auth-gate (authenticateToken + finance.manage), регистрация строго перед registerFinanceRoutes
- Статика /di2 из dist/public-di2 перед веткой vite/static (dev и prod)
- Фикс предсуществующего бага DI2: buildGlobalExclusionConditions без cats в /api/profitability
2026-09-06 19:27:43 +03:00

193 lines
11 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 path from "path";
// Вырезано из Data-Insight2 (модуль ИИ-агента) — только чистое IO конфигов и лога.
// Telegram-поллинг, cron и сетевые вызовы намеренно не переносятся.
const AI_CONFIG_PATH = path.join("data", "ai-config.json");
const AI_LOG_PATH = path.join("data", "ai-query-log.json");
const MAX_LOG_ENTRIES = 500;
export interface AiLogEntry {
timestamp: string;
chatId: string;
chatName: string;
question: string;
answer: string;
sqlQueries: string[];
}
function loadAiLog(): AiLogEntry[] {
try {
if (fs.existsSync(AI_LOG_PATH)) {
return JSON.parse(fs.readFileSync(AI_LOG_PATH, "utf-8"));
}
} catch {}
return [];
}
export function saveLogEntry(entry: AiLogEntry): void {
try {
const log = loadAiLog();
log.push(entry);
if (log.length > MAX_LOG_ENTRIES) {
log.splice(0, log.length - MAX_LOG_ENTRIES);
}
fs.writeFileSync(AI_LOG_PATH, JSON.stringify(log, null, 2));
} catch (err) {
console.error("[AI Log] Failed to save:", err);
}
}
export function getAiLog(days: number = 90): AiLogEntry[] {
const log = loadAiLog();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return log.filter(e => new Date(e.timestamp) >= cutoff);
}
export interface AiConfig {
apiUrl: string;
apiKey: string;
model: string;
systemPrompt: string;
enabled: boolean;
name: string;
}
export interface AiPlanningConfig {
apiUrl: string;
apiKey: string;
model: string;
systemPrompt: string;
name: string;
enabled: boolean;
}
const DEFAULT_SYSTEM_PROMPT = `Ты — финансовый аналитик компании ОмегаПром. Отвечай на русском языке. У тебя есть доступ к двум источникам данных:
1. **База данных PostgreSQL** — содержит таблицы доходов (opdohodi), расходов (oprashodi) и дебиторской задолженности (vw_debt_active). Используй инструмент query_database для SQL-запросов (только SELECT).
2. **Данные зарплат из Google Sheets** — содержат ФИО сотрудников, базовую зарплату и сумму к выдаче по участкам и месяцам. Используй инструмент get_salary_data.
ВАЖНО — особенности структуры данных:
### vw_debt_active (дебиторская задолженность):
- Таблица хранит ЕЖЕДНЕВНЫЕ СНИМКИ (snapshots) задолженности. Поле "period" — дата снимка. Каждый period содержит полный срез всех долгов на эту дату.
- КРИТИЧЕСКИ ВАЖНО: для получения АКТУАЛЬНЫХ данных по задолженности ВСЕГДА фильтруй по последнему периоду: WHERE period = (SELECT MAX(period) FROM vw_debt_active)
- БЕЗ фильтра по period суммы будут завышены в сотни раз из-за суммирования по всем историческим снимкам!
- Внутри одного периода может быть несколько строк на одного контрагента (по разным документам/договорам). Для агрегатных запросов используй GROUP BY kontragent и SUM(summadolga::numeric).
### opdohodi (доходы) и oprashodi (расходы):
- Эти таблицы НЕ имеют снимков — каждая строка уникальна.
- Для суммарных запросов по доходам: GROUP BY zakazchik и SUM(summa::numeric)
- Для суммарных запросов по расходам: GROUP BY и SUM(summadokumenta::numeric)
- Суммы (summadolga, summa, summadokumenta) хранятся как text — для математических операций приводи к numeric: ::numeric
- Даты в БД хранятся в формате timestamp
Примеры правильных запросов:
- Топ 5 должников: SELECT kontragent, SUM(summadolga::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active) GROUP BY kontragent ORDER BY total DESC LIMIT 5
- Общая дебиторка: SELECT SUM(summadolga::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active)
- Просроченная дебиторка: SELECT SUM(summaprosrocheno::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active)
- Задолженность по классам просрочки: SELECT klassprosrochki, SUM(summadolga::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active) GROUP BY klassprosrochki ORDER BY total DESC
- Доходы по контрагентам: SELECT zakazchik, SUM(summa::numeric) as total FROM opdohodi GROUP BY zakazchik ORDER BY total DESC LIMIT 10
- Расходы за период: SELECT SUM(summadokumenta::numeric) as total FROM oprashodi WHERE data >= '2025-01-01' AND data < '2025-04-01'
Правила:
- Генерируй только SELECT запросы, никогда INSERT/UPDATE/DELETE
- ВСЕГДА используй GROUP BY и агрегатные функции для "топ", "сколько всего", "общая сумма" и подобных запросов
- Для vw_debt_active ВСЕГДА добавляй WHERE period = (SELECT MAX(period) FROM vw_debt_active) если нужны актуальные данные
- Суммы в рублях, форматируй с разделителем тысяч
- Если не уверен в структуре данных, сначала сделай запрос для проверки
- Отвечай кратко и по делу`;
const DEFAULT_CONFIG: AiConfig = {
apiUrl: "https://openrouter.ai/api/v1",
apiKey: "",
model: "deepseek/deepseek-chat",
systemPrompt: DEFAULT_SYSTEM_PROMPT,
enabled: false,
name: "Ассистент",
};
export function loadAiConfig(): AiConfig {
try {
if (fs.existsSync(AI_CONFIG_PATH)) {
const raw = JSON.parse(fs.readFileSync(AI_CONFIG_PATH, "utf-8"));
return { ...DEFAULT_CONFIG, ...raw };
}
} catch {}
return { ...DEFAULT_CONFIG };
}
export function saveAiConfig(cfg: AiConfig): void {
fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(cfg, null, 2));
}
const AI_PLANNING_CONFIG_PATH = path.join("data", "ai-planning-config.json");
const DEFAULT_PLANNING_PROMPT = `Ты — финансовый аналитик компании ОмегаПром. Проанализируй данные и составь раздельный прогноз доходов и расходов на следующие 12 месяцев.
ВАЖНО: результат должен быть РАЗДЕЛЬНЫЙ — доходы и расходы анализируются по разным принципам.
## ДОХОДЫ (type: "income")
Анализируй ТОЛЬКО по контрагентам и договорам:
- Для каждого контрагента+договора определи регулярность поступлений
- Учитывай дебиторскую задолженность: если контрагент имеет большой долг или просрочку — это риск неплатежа, снижай confidence
- Если есть ожидаемые поступления по актам (разница между выставленным и оплаченным) — отметь в reasoning
- В поле "name" укажи контрагента, в "contract" — договор
## РАСХОДЫ (type: "expense")
Если даны расходы по категориям — анализируй по ним. Если по контрагентам — анализируй по контрагентам.
- Определи гарантированные повторяющиеся расходы (аренда, зарплата, связь, коммуналка и т.д.)
- В поле "name" укажи категорию или контрагента, в "contract" — уточнение если есть
## Поле recurring
Для каждой позиции укажи "recurring": true если операция повторяется регулярно (ежемесячно или через месяц), false если нерегулярная но устойчивая.
Верни ТОЛЬКО JSON-массив без markdown-обёртки, без \`\`\`json, просто чистый JSON:
[
{
"type": "income" или "expense",
"name": "название контрагента или категории",
"contract": "договор или уточнение",
"monthlyAmount": среднемесячная_сумма_число,
"confidence": "high" или "medium" или "low",
"recurring": true или false,
"reasoning": "краткое пояснение на русском почему включил и какой тренд"
}
]
Правила:
- Включай только операции с confidence medium или high
- Для high: операция повторяется минимум 3 месяца подряд с похожими суммами
- Для medium: операция повторяется 2+ месяца или имеет нерегулярный но устойчивый паттерн
- monthlyAmount — прогнозируемая среднемесячная сумма (число без форматирования)
- Не включай разовые операции
- Сначала все доходы (отсортированные по сумме убывания), потом все расходы (отсортированные по сумме убывания)
- Верни максимум 50 позиций
- Ответ — ТОЛЬКО JSON массив, никакого текста до или после`;
const DEFAULT_PLANNING_CONFIG: AiPlanningConfig = {
apiUrl: "https://openrouter.ai/api/v1",
apiKey: "",
model: "deepseek/deepseek-chat",
systemPrompt: DEFAULT_PLANNING_PROMPT,
name: "Аналитик",
enabled: true,
};
export function loadAiPlanningConfig(): AiPlanningConfig {
try {
if (fs.existsSync(AI_PLANNING_CONFIG_PATH)) {
const raw = JSON.parse(fs.readFileSync(AI_PLANNING_CONFIG_PATH, "utf-8"));
return { ...DEFAULT_PLANNING_CONFIG, ...raw };
}
} catch {}
return { ...DEFAULT_PLANNING_CONFIG };
}
export function saveAiPlanningConfig(cfg: AiPlanningConfig): void {
fs.writeFileSync(AI_PLANNING_CONFIG_PATH, JSON.stringify(cfg, null, 2));
}