Files
clientsflow/Frontend/server/agent/crmAgent.ts
2026-02-18 13:56:35 +07:00

202 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[];
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<ContactIndexRow[]> {
throw new Error("readContactIndex now requires dataset root");
}
async function readContactIndexFrom(root: string): Promise<ContactIndexRow[]> {
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<number> {
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<any[]> {
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<AgentReply> {
throw new Error("runCrmAgent now requires auth context");
}
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 });
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"],
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"],
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 (по необходимости)"],
text:
"Ок. Скажи, что нужно сделать.\n" +
"Примеры:\n" +
"- \"покажи 10 лучших клиентов\"\n" +
"- \"чем мне сегодня заняться\"\n" +
"- \"составь план касаний на неделю\"\n",
};
}
export async function persistChatMessage(input: {
role: ChatRole;
text: string;
plan?: string[];
tools?: string[];
teamId: string;
conversationId: string;
authorUserId?: string | null;
}) {
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: input.plan || input.tools ? ({ steps: input.plan ?? [], tools: input.tools ?? [] } as any) : undefined,
};
return prisma.chatMessage.create({ data });
}