import fs from "node:fs/promises"; import path from "node:path"; import type { ChatRole, Prisma } from "@prisma/client"; import { prisma } from "../utils/prisma"; import { datasetRoot } from "../dataset/paths"; import { ensureDataset } from "../dataset/exporter"; import { runLangGraphCrmAgentFor } from "./langgraphCrmAgent"; type ContactIndexRow = { id: string; name: string; company: string | null; lastMessageAt: string | null; nextEventAt: string | null; updatedAt: string; }; export type AgentReply = { text: string; plan: string[]; tools: string[]; thinking?: string[]; toolRuns?: Array<{ name: string; status: "ok" | "error"; input: string; output: string; at: string; }>; dbWrites?: Array<{ kind: string; detail: string }>; }; function normalize(s: string) { return s.trim().toLowerCase(); } function isToday(date: Date) { const now = new Date(); return ( date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() ); } async function readContactIndex(): Promise { throw new Error("readContactIndex now requires dataset root"); } async function readContactIndexFrom(root: string): Promise { const p = path.join(root, "index", "contacts.json"); const raw = await fs.readFile(p, "utf8"); return JSON.parse(raw); } async function countJsonlLines(p: string): Promise { const raw = await fs.readFile(p, "utf8"); if (!raw.trim()) return 0; // cheap line count (JSONL is 1 item per line) return raw.trimEnd().split("\n").length; } async function readJsonl(p: string): Promise { const raw = await fs.readFile(p, "utf8"); if (!raw.trim()) return []; return raw .trimEnd() .split("\n") .filter(Boolean) .map((line) => JSON.parse(line)); } function formatContactLine(c: ContactIndexRow) { const company = c.company ? ` (${c.company})` : ""; const lastAt = c.lastMessageAt ? new Date(c.lastMessageAt).toLocaleString("ru-RU") : "нет"; return `- ${c.name}${company} · последнее: ${lastAt}`; } export async function runCrmAgent(userText: string): Promise { throw new Error("runCrmAgent now requires auth context"); } export async function runCrmAgentFor( input: { teamId: string; userId: string; userText: string }, ): Promise { const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase(); const llmApiKey = process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY; const hasGigachat = Boolean((process.env.GIGACHAT_AUTH_KEY ?? "").trim() && (process.env.GIGACHAT_SCOPE ?? "").trim()); if (mode !== "rule" && (llmApiKey || hasGigachat)) { return runLangGraphCrmAgentFor(input); } await ensureDataset({ teamId: input.teamId, userId: input.userId }); const q = normalize(input.userText); const root = datasetRoot({ teamId: input.teamId, userId: input.userId }); const contacts = await readContactIndexFrom(root); // "10 лучших клиентов" if (q.includes("10 лучших") || (q.includes("топ") && q.includes("клиент"))) { const ranked = await Promise.all( contacts.map(async (c) => { const msgPath = path.join(root, "messages", `${c.id}.jsonl`); const evPath = path.join(root, "events", `${c.id}.jsonl`); const msgCount = await countJsonlLines(msgPath).catch(() => 0); const ev = await readJsonl(evPath).catch(() => []); const todayEvCount = ev.filter((e) => (e?.startsAt ? isToday(new Date(e.startsAt)) : false)).length; const score = msgCount * 2 + todayEvCount * 3; return { c, score }; }), ); ranked.sort((a, b) => b.score - a.score); const top = ranked.slice(0, 10).map((x) => x.c); return { plan: [ "Загрузить индекс контактов из файлового датасета", "Посчитать активность по JSONL (сообщения/события сегодня)", "Отсортировать и показать топ", ], tools: ["read index/contacts.json", "read messages/{contactId}.jsonl", "read events/{contactId}.jsonl"], toolRuns: [ { name: "dataset:index_contacts", status: "ok", input: "index/contacts.json", output: "Loaded contacts index", at: new Date().toISOString(), }, ], text: `Топ-10 по активности (сообщения + события):\n` + top.map(formatContactLine).join("\n") + `\n\nЕсли хочешь, скажи критерий "лучший" (выручка/стадия/вероятность/давность) и я пересчитаю.`, }; } // "чем заняться сегодня" if (q.includes("чем") && (q.includes("сегодня") || q.includes("заняться"))) { const todayEvents: Array<{ who: string; title: string; at: Date; note?: string | null }> = []; for (const c of contacts) { const evPath = path.join(root, "events", `${c.id}.jsonl`); const ev = await readJsonl(evPath).catch(() => []); for (const e of ev) { if (!e?.startsAt) continue; const at = new Date(e.startsAt); if (!isToday(at)) continue; todayEvents.push({ who: c.name, title: e.title ?? "Event", at, note: e.note ?? null }); } } todayEvents.sort((a, b) => a.at.getTime() - b.at.getTime()); const followups = [...contacts] .map((c) => ({ c, last: c.lastMessageAt ? new Date(c.lastMessageAt).getTime() : 0 })) .sort((a, b) => a.last - b.last) .slice(0, 3) .map((x) => x.c); const lines: string[] = []; if (todayEvents.length > 0) { lines.push("Сегодня по календарю:"); for (const e of todayEvents) { const hhmm = e.at.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); lines.push(`- ${hhmm} · ${e.title} · ${e.who}${e.note ? ` · ${e.note}` : ""}`); } } else { lines.push("Сегодня нет запланированных событий в календаре."); } lines.push(""); lines.push("Фокус дня (если нужно добить прогресс):"); for (const c of followups) { lines.push(`- Написать follow-up: ${c.name}${c.company ? ` (${c.company})` : ""}`); } return { plan: [ "Прочитать события на сегодня из файлового датасета", "Найти контакты без свежего касания (по lastMessageAt)", "Сформировать короткий список действий", ], tools: ["read index/contacts.json", "read events/{contactId}.jsonl"], toolRuns: [ { name: "dataset:query_events", status: "ok", input: "events/*.jsonl (today)", output: `Found ${todayEvents.length} events`, at: new Date().toISOString(), }, ], text: lines.join("\n"), }; } // Default: keep it simple, ask for intent + show what the agent can do. return { plan: ["Уточнить цель", "Выбрать данные для анализа", "Предложить план действий и, если нужно, изменения в CRM"], tools: ["read index/contacts.json (по необходимости)", "search messages/events (по необходимости)"], toolRuns: [], text: "Ок. Скажи, что нужно сделать.\n" + "Примеры:\n" + "- \"покажи 10 лучших клиентов\"\n" + "- \"чем мне сегодня заняться\"\n" + "- \"составь план касаний на неделю\"\n", }; } export async function persistChatMessage(input: { role: ChatRole; text: string; plan?: string[]; tools?: string[]; thinking?: string[]; toolRuns?: Array<{ name: string; status: "ok" | "error"; input: string; output: string; at: string; }>; teamId: string; conversationId: string; authorUserId?: string | null; }) { const hasDebugPayload = Boolean( (input.plan && input.plan.length) || (input.tools && input.tools.length) || (input.thinking && input.thinking.length) || (input.toolRuns && input.toolRuns.length), ); const data: Prisma.ChatMessageCreateInput = { team: { connect: { id: input.teamId } }, conversation: { connect: { id: input.conversationId } }, authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined, role: input.role, text: input.text, planJson: hasDebugPayload ? ({ steps: input.plan ?? [], tools: input.tools ?? [], thinking: input.thinking ?? input.plan ?? [], toolRuns: input.toolRuns ?? [], } as any) : undefined, }; return prisma.chatMessage.create({ data }); }