257 lines
8.9 KiB
TypeScript
257 lines
8.9 KiB
TypeScript
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<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();
|
||
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 });
|
||
}
|