DB-backed workspace + LangGraph agent
This commit is contained in:
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -74,6 +75,11 @@ export async function runCrmAgent(userText: string): Promise<AgentReply> {
|
||||
export async function runCrmAgentFor(
|
||||
input: { teamId: string; userId: string; userText: string },
|
||||
): Promise<AgentReply> {
|
||||
const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase();
|
||||
if (mode !== "rule" && process.env.OPENAI_API_KEY) {
|
||||
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 });
|
||||
|
||||
375
Frontend/server/agent/langgraphCrmAgent.ts
Normal file
375
Frontend/server/agent/langgraphCrmAgent.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
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<string, number>();
|
||||
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<AgentReply> {
|
||||
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<typeof CrmToolSchema>) => {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user