import fs from "node:fs/promises"; import path from "node:path"; import type { ChatRole, Prisma } from "../generated/prisma/client"; import { prisma } from "../utils/prisma"; import { datasetRoot } from "../dataset/paths"; import { ensureDataset } from "../dataset/exporter"; import { runLangGraphCrmAgentFor } from "./langgraphCrmAgent"; import type { ChangeSet } from "../utils/changeSet"; type ContactIndexRow = { id: string; name: string; 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 }>; }; export type AgentTraceEvent = { text: string; toolRun?: { name: string; status: "ok" | "error"; input: string; output: string; at: string; }; }; export type PilotContextPayload = { scopes: Array<"summary" | "deal" | "message" | "calendar">; summary?: { contactId: string; name: string; }; deal?: { dealId: string; title: string; contact: string; }; message?: { contactId?: string; contact?: string; intent: "add_message_or_reminder"; }; calendar?: { view: "day" | "week" | "month" | "year" | "agenda"; period: string; selectedDateKey: string; focusedEventId?: string; eventIds: 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 lastAt = c.lastMessageAt ? new Date(c.lastMessageAt).toLocaleString("ru-RU") : "нет"; return `- ${c.name} · последнее: ${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; contextPayload?: PilotContextPayload | null; requestId?: string; conversationId?: string; onTrace?: (event: AgentTraceEvent) => Promise | void; }, ): Promise { const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase(); const llmApiKey = process.env.OPENROUTER_API_KEY || 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") { return runLangGraphCrmAgentFor(input); } if (!llmApiKey && !hasGigachat) { throw new Error("LLM API key is not configured. Set OPENROUTER_API_KEY or GIGACHAT_AUTH_KEY/GIGACHAT_SCOPE."); } 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}`); } 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"), }; } throw new Error( "Rule mode supports only structured built-in queries. Use a supported query or switch to langgraph mode with a configured LLM API key.", ); } export async function persistAiMessage(input: { role: ChatRole; text: string; plan?: string[]; tools?: string[]; thinking?: string[]; toolRuns?: Array<{ name: string; status: "ok" | "error"; input: string; output: string; at: string; }>; changeSet?: ChangeSet | null; requestId?: string; eventType?: "user" | "trace" | "assistant" | "note"; phase?: "pending" | "running" | "final" | "error"; transient?: boolean; messageKind?: "change_set_summary"; teamId: string; conversationId: string; authorUserId?: string | null; }) { const hasStoredPayload = Boolean(input.changeSet || input.messageKind); const data: Prisma.AiMessageCreateInput = { 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: hasStoredPayload ? ({ messageKind: input.messageKind ?? null, changeSet: input.changeSet ?? null, } as any) : undefined, }; return prisma.aiMessage.create({ data }); }