import type { AgentReply } from "./crmAgent"; import { prisma } from "../utils/prisma"; import { ensureDataset } from "../dataset/exporter"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; import { ChatOpenAI } from "@langchain/openai"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; function iso(d: Date) { return d.toISOString(); } async function buildCrmSnapshot(input: { teamId: string }) { const now = new Date(); const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const [contacts, upcoming, deals] = await Promise.all([ prisma.contact.findMany({ where: { teamId: input.teamId }, orderBy: { updatedAt: "desc" }, take: 25, include: { messages: { select: { occurredAt: true, channel: true, direction: true }, orderBy: { occurredAt: "desc" }, take: 1 }, deals: { select: { stage: true, amount: true, updatedAt: true }, orderBy: { updatedAt: "desc" }, take: 1 }, }, }), prisma.calendarEvent.findMany({ where: { teamId: input.teamId, startsAt: { gte: now, lte: in7 } }, orderBy: { startsAt: "asc" }, take: 20, include: { contact: { select: { name: true } } }, }), prisma.deal.findMany({ where: { teamId: input.teamId }, orderBy: { updatedAt: "desc" }, take: 20, include: { contact: { select: { name: true, company: true } } }, }), ]); const byStage = new Map(); for (const d of deals) byStage.set(d.stage, (byStage.get(d.stage) ?? 0) + 1); const lines: string[] = []; lines.push(`Snapshot time: ${iso(now)}`); lines.push(`Contacts: ${await prisma.contact.count({ where: { teamId: input.teamId } })}`); lines.push(`Deals: ${await prisma.deal.count({ where: { teamId: input.teamId } })}`); lines.push(`Upcoming events (7d): ${upcoming.length}`); lines.push(""); if (upcoming.length) { lines.push("Upcoming events:"); for (const e of upcoming) { lines.push(`- ${e.startsAt.toISOString()} · ${e.title} · ${e.contact?.name ?? "No contact"}`); } lines.push(""); } if (byStage.size) { lines.push("Deals by stage:"); for (const [stage, n] of [...byStage.entries()].sort((a, b) => b[1] - a[1])) { lines.push(`- ${stage}: ${n}`); } lines.push(""); } if (contacts.length) { lines.push("Recently updated contacts:"); for (const c of contacts.slice(0, 12)) { const last = c.messages[0]?.occurredAt ? c.messages[0].occurredAt.toISOString() : c.updatedAt.toISOString(); const deal = c.deals[0] ? `${c.deals[0].stage}${c.deals[0].amount ? ` $${c.deals[0].amount}` : ""}` : "no deal"; lines.push(`- ${c.name}${c.company ? ` (${c.company})` : ""} · last touch ${last} · ${deal}`); } } return lines.join("\n"); } export async function runLangGraphCrmAgentFor(input: { teamId: string; userId: string; userText: string; }): Promise { if (!process.env.OPENAI_API_KEY) { return { text: "OPENAI_API_KEY не задан. Сейчас включен fallback-агент без LLM.", plan: ["Проверить .env", "Добавить OPENAI_API_KEY", "Перезапустить dev-сервер"], tools: [], }; } // Keep the dataset fresh so the "CRM filesystem" stays in sync with DB. await ensureDataset({ teamId: input.teamId, userId: input.userId }); const toolsUsed: string[] = []; const dbWrites: Array<{ kind: string; detail: string }> = []; const CrmToolSchema = z.object({ action: z.enum([ "query_contacts", "query_deals", "query_events", "update_contact_note", "create_event", "create_message", "update_deal_stage", ]), // queries query: z.string().optional(), stage: z.string().optional(), from: z.string().optional(), to: z.string().optional(), limit: z.number().int().optional(), // writes contact: z.string().optional(), content: z.string().optional(), title: z.string().optional(), start: z.string().optional(), end: z.string().optional(), note: z.string().optional(), status: z.string().optional(), channel: z.enum(["Telegram", "WhatsApp", "Instagram", "Phone", "Email"]).optional(), kind: z.enum(["message", "call"]).optional(), direction: z.enum(["in", "out"]).optional(), text: z.string().optional(), at: z.string().optional(), durationSec: z.number().int().optional(), transcript: z.array(z.string()).optional(), dealId: z.string().optional(), }); const crmTool = tool( async (raw: z.infer) => { toolsUsed.push(`crm:${raw.action}`); if (raw.action === "query_contacts") { const q = (raw.query ?? "").trim(); const items = await prisma.contact.findMany({ where: { teamId: input.teamId, ...(q ? { OR: [ { name: { contains: q } }, { company: { contains: q } }, { email: { contains: q } }, { phone: { contains: q } }, ], } : {}), }, orderBy: { updatedAt: "desc" }, take: Math.max(1, Math.min(raw.limit ?? 20, 100)), include: { note: { select: { content: true, updatedAt: true } } }, }); return JSON.stringify( items.map((c) => ({ id: c.id, name: c.name, company: c.company, country: c.country, location: c.location, email: c.email, phone: c.phone, note: c.note?.content ?? null, })), null, 2, ); } if (raw.action === "query_deals") { const items = await prisma.deal.findMany({ where: { teamId: input.teamId, ...(raw.stage ? { stage: raw.stage } : {}) }, orderBy: { updatedAt: "desc" }, take: Math.max(1, Math.min(raw.limit ?? 20, 100)), include: { contact: { select: { name: true, company: true } } }, }); return JSON.stringify( items.map((d) => ({ id: d.id, title: d.title, stage: d.stage, amount: d.amount, nextStep: d.nextStep, summary: d.summary, contact: d.contact.name, company: d.contact.company, })), null, 2, ); } if (raw.action === "query_events") { const from = raw.from ? new Date(raw.from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const to = raw.to ? new Date(raw.to) : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); const items = await prisma.calendarEvent.findMany({ where: { teamId: input.teamId, startsAt: { gte: from, lte: to } }, orderBy: { startsAt: "asc" }, take: Math.max(1, Math.min(raw.limit ?? 100, 500)), include: { contact: { select: { name: true } } }, }); return JSON.stringify( items.map((e) => ({ id: e.id, title: e.title, startsAt: e.startsAt.toISOString(), endsAt: (e.endsAt ?? e.startsAt).toISOString(), note: e.note, contact: e.contact?.name ?? null, })), null, 2, ); } if (raw.action === "update_contact_note") { const contactName = (raw.contact ?? "").trim(); const content = (raw.content ?? "").trim(); if (!contactName) throw new Error("contact is required"); if (!content) throw new Error("content is required"); const contact = await prisma.contact.findFirst({ where: { teamId: input.teamId, name: contactName }, select: { id: true }, }); if (!contact) throw new Error("contact not found"); await prisma.contactNote.upsert({ where: { contactId: contact.id }, update: { content }, create: { contactId: contact.id, content }, }); dbWrites.push({ kind: "contact_note", detail: `${contactName}: updated` }); return JSON.stringify({ ok: true }); } if (raw.action === "create_event") { const title = (raw.title ?? "").trim(); const start = raw.start ? new Date(raw.start) : null; if (!title) throw new Error("title is required"); if (!start || Number.isNaN(start.getTime())) throw new Error("start is invalid"); const end = raw.end ? new Date(raw.end) : null; const contactName = (raw.contact ?? "").trim(); const contact = contactName ? await prisma.contact.findFirst({ where: { teamId: input.teamId, name: contactName }, select: { id: true } }) : null; const created = await prisma.calendarEvent.create({ data: { teamId: input.teamId, contactId: contact?.id ?? null, title, startsAt: start, endsAt: end && !Number.isNaN(end.getTime()) ? end : null, note: (raw.note ?? "").trim() || null, status: (raw.status ?? "").trim() || null, }, }); dbWrites.push({ kind: "calendar_event", detail: `created ${created.id}` }); return JSON.stringify({ ok: true, id: created.id }); } if (raw.action === "create_message") { const contactName = (raw.contact ?? "").trim(); const text = (raw.text ?? "").trim(); if (!contactName) throw new Error("contact is required"); if (!text) throw new Error("text is required"); const contact = await prisma.contact.findFirst({ where: { teamId: input.teamId, name: contactName }, select: { id: true }, }); if (!contact) throw new Error("contact not found"); const occurredAt = raw.at ? new Date(raw.at) : new Date(); if (Number.isNaN(occurredAt.getTime())) throw new Error("at is invalid"); const created = await prisma.contactMessage.create({ data: { contactId: contact.id, kind: raw.kind === "call" ? "CALL" : "MESSAGE", direction: raw.direction === "in" ? "IN" : "OUT", channel: raw.channel === "Telegram" ? "TELEGRAM" : raw.channel === "WhatsApp" ? "WHATSAPP" : raw.channel === "Instagram" ? "INSTAGRAM" : raw.channel === "Email" ? "EMAIL" : "PHONE", content: text, durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null, transcriptJson: Array.isArray(raw.transcript) ? raw.transcript : null, occurredAt, }, }); dbWrites.push({ kind: "contact_message", detail: `created ${created.id}` }); return JSON.stringify({ ok: true, id: created.id }); } if (raw.action === "update_deal_stage") { const dealId = (raw.dealId ?? "").trim(); const stage = (raw.stage ?? "").trim(); if (!dealId) throw new Error("dealId is required"); if (!stage) throw new Error("stage is required"); const updated = await prisma.deal.updateMany({ where: { id: dealId, teamId: input.teamId }, data: { stage }, }); if (updated.count === 0) throw new Error("deal not found"); dbWrites.push({ kind: "deal", detail: `updated stage for ${dealId}` }); return JSON.stringify({ ok: true }); } return JSON.stringify({ ok: false, error: "unknown action" }); }, { name: "crm", description: "Query and update CRM data (contacts, deals, events, communications). Use this tool for any data you need beyond the snapshot.", schema: CrmToolSchema, }, ); const snapshot = await buildCrmSnapshot({ teamId: input.teamId }); const model = new ChatOpenAI({ apiKey: process.env.OPENAI_API_KEY, model: process.env.OPENAI_MODEL || "gpt-4o-mini", temperature: 0.2, }); const agent = createReactAgent({ llm: model, tools: [crmTool], responseFormat: z.object({ answer: z.string().describe("Final assistant answer for the user."), plan: z.array(z.string()).min(1).max(10).describe("Short plan (3-8 steps)."), }), }); const system = [ "You are Pilot, a CRM assistant.", "Rules:", "- Be concrete and concise.", "- If you need data beyond the snapshot, call the crm tool.", "- If user asks to change CRM, you may do it via the crm tool and then report what changed.", "- Do not claim you sent an external message; you can only create draft messages/events/notes in CRM.", "", "CRM Snapshot:", snapshot, ].join("\n"); const res: any = await agent.invoke( { messages: [ { role: "system", content: system }, { role: "user", content: input.userText }, ], }, { recursionLimit: 30 }, ); const structured = res?.structuredResponse as { answer?: string; plan?: string[] } | undefined; const text = structured?.answer?.trim() || "Готово."; const plan = Array.isArray(structured?.plan) ? structured!.plan : ["Собрать данные", "Сформировать ответ"]; return { text, plan, tools: toolsUsed, dbWrites: dbWrites.length ? dbWrites : undefined }; }