- Копия 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
193 lines
11 KiB
TypeScript
193 lines
11 KiB
TypeScript
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));
|
||
}
|