Files
clientsflow/Frontend/server/agent/langgraphCrmAgent.ts

846 lines
28 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 { randomUUID } from "node:crypto";
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();
}
type GigachatTokenCache = {
token: string;
expiresAtSec: number;
};
let gigachatTokenCache: GigachatTokenCache | null = null;
function normalizeAuthHeader(authKey: string, scheme: "Basic" | "Bearer") {
const key = authKey.trim();
if (!key) return "";
if (key.startsWith("Basic ") || key.startsWith("Bearer ")) return key;
return `${scheme} ${key}`;
}
async function requestGigachatToken(input: {
authKey: string;
scope: string;
oauthUrl: string;
authScheme: "Basic" | "Bearer";
}) {
const body = new URLSearchParams();
body.set("scope", input.scope);
const res = await fetch(input.oauthUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
RqUID: randomUUID(),
Authorization: normalizeAuthHeader(input.authKey, input.authScheme),
},
body: body.toString(),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`GigaChat oauth failed: ${res.status} ${text.slice(0, 240)}`);
}
const payload = (await res.json()) as { access_token?: string; expires_at?: number };
if (!payload?.access_token) {
throw new Error("GigaChat oauth failed: access_token is missing");
}
return {
token: payload.access_token,
expiresAtSec: typeof payload.expires_at === "number" ? payload.expires_at : Math.floor(Date.now() / 1000) + 25 * 60,
};
}
async function getGigachatAccessToken(input: {
authKey: string;
scope: string;
oauthUrl: string;
}) {
const nowSec = Math.floor(Date.now() / 1000);
if (gigachatTokenCache && gigachatTokenCache.expiresAtSec - nowSec > 60) {
return gigachatTokenCache.token;
}
try {
const token = await requestGigachatToken({ ...input, authScheme: "Basic" });
gigachatTokenCache = token;
return token.token;
} catch {
const token = await requestGigachatToken({ ...input, authScheme: "Bearer" });
gigachatTokenCache = token;
return token.token;
}
}
type SnapshotOptions = {
teamId: string;
contact?: string;
contactsLimit?: number;
};
type PendingChange =
| {
id: string;
type: "update_contact_note";
createdAt: string;
contactId: string;
contactName: string;
content: string;
}
| {
id: string;
type: "create_event";
createdAt: string;
contactId: string | null;
contactName: string | null;
title: string;
start: string;
end: string | null;
note: string | null;
status: string | null;
}
| {
id: string;
type: "create_message";
createdAt: string;
contactId: string;
contactName: string;
text: string;
kind: "message" | "call";
direction: "in" | "out";
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
at: string;
durationSec: number | null;
transcript: string[] | null;
}
| {
id: string;
type: "update_deal_stage";
createdAt: string;
dealId: string;
stage: string;
dealTitle: string;
};
function makeId(prefix: string) {
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
}
function toChannel(channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email") {
if (channel === "Telegram") return "TELEGRAM" as const;
if (channel === "WhatsApp") return "WHATSAPP" as const;
if (channel === "Instagram") return "INSTAGRAM" as const;
if (channel === "Email") return "EMAIL" as const;
return "PHONE" as const;
}
async function resolveContact(teamId: string, contactRef: string) {
const contact = contactRef.trim();
if (!contact) return null;
const exact = await prisma.contact.findFirst({
where: { teamId, OR: [{ id: contact }, { name: contact }] },
select: { id: true, name: true },
});
if (exact) return exact;
return prisma.contact.findFirst({
where: { teamId, name: { contains: contact } },
orderBy: { updatedAt: "desc" },
select: { id: true, name: true },
});
}
async function buildCrmSnapshot(input: SnapshotOptions) {
const now = new Date();
const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const contactLimit = Math.max(1, Math.min(input.contactsLimit ?? 25, 80));
const selectedContact = input.contact ? await resolveContact(input.teamId, input.contact) : null;
const contactWhere = selectedContact ? { teamId: input.teamId, id: selectedContact.id } : { teamId: input.teamId };
const [contacts, upcoming, deals, docs, totalContacts, totalDeals, totalEvents] = await Promise.all([
prisma.contact.findMany({
where: contactWhere,
orderBy: { updatedAt: "desc" },
take: selectedContact ? 1 : contactLimit,
include: {
note: { select: { content: true, updatedAt: true } },
messages: {
select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true },
orderBy: { occurredAt: "desc" },
take: 4,
},
events: {
select: { id: true, title: true, startsAt: true, endsAt: true, status: true },
orderBy: { startsAt: "asc" },
where: { startsAt: { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) } },
take: 4,
},
deals: {
select: { id: true, title: true, stage: true, amount: true, updatedAt: true, nextStep: true, summary: true },
orderBy: { updatedAt: "desc" },
take: 3,
},
pins: {
select: { id: true, text: true, updatedAt: true },
orderBy: { updatedAt: "desc" },
take: 3,
},
},
}),
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 } } },
}),
prisma.workspaceDocument.findMany({
where: { teamId: input.teamId },
orderBy: { updatedAt: "desc" },
take: 12,
select: { id: true, type: true, title: true, summary: true, updatedAt: true, owner: true, scope: true },
}),
prisma.contact.count({ where: { teamId: input.teamId } }),
prisma.deal.count({ where: { teamId: input.teamId } }),
prisma.calendarEvent.count({ where: { teamId: input.teamId, startsAt: { gte: now } } }),
]);
const byStage = new Map<string, number>();
for (const d of deals) byStage.set(d.stage, (byStage.get(d.stage) ?? 0) + 1);
return {
meta: {
generatedAt: iso(now),
focusContactId: selectedContact?.id ?? null,
contactsIncluded: contacts.length,
mode: selectedContact ? "focused" : "team",
},
totals: {
contacts: totalContacts,
deals: totalDeals,
upcomingEvents: totalEvents,
},
stats: {
dealsByStage: [...byStage.entries()]
.sort((a, b) => b[1] - a[1])
.map(([stage, count]) => ({ stage, count })),
},
upcomingEvents: upcoming.map((e) => ({
id: e.id,
title: e.title,
startsAt: iso(e.startsAt),
endsAt: iso(e.endsAt ?? e.startsAt),
status: e.status,
note: e.note,
contact: e.contact?.name ?? null,
})),
deals: deals.map((d) => ({
id: d.id,
title: d.title,
stage: d.stage,
amount: d.amount,
nextStep: d.nextStep,
summary: d.summary,
updatedAt: iso(d.updatedAt),
contact: {
name: d.contact.name,
company: d.contact.company,
},
})),
contacts: contacts.map((c) => ({
id: c.id,
name: c.name,
company: c.company,
country: c.country,
location: c.location,
email: c.email,
phone: c.phone,
avatarUrl: c.avatarUrl,
updatedAt: iso(c.updatedAt),
summary: c.note?.content ?? null,
summaryUpdatedAt: c.note?.updatedAt ? iso(c.note.updatedAt) : null,
latestMessages: c.messages.map((m) => ({
id: m.id,
occurredAt: iso(m.occurredAt),
channel: m.channel,
direction: m.direction,
kind: m.kind,
content: m.content,
})),
latestEvents: c.events.map((e) => ({
id: e.id,
title: e.title,
startsAt: iso(e.startsAt),
endsAt: iso(e.endsAt ?? e.startsAt),
status: e.status,
})),
deals: c.deals.map((d) => ({
id: d.id,
title: d.title,
stage: d.stage,
amount: d.amount,
nextStep: d.nextStep,
summary: d.summary,
updatedAt: iso(d.updatedAt),
})),
pins: c.pins.map((p) => ({
id: p.id,
text: p.text,
updatedAt: iso(p.updatedAt),
})),
})),
documents: docs.map((d) => ({
id: d.id,
type: d.type,
title: d.title,
summary: d.summary,
owner: d.owner,
scope: d.scope,
updatedAt: iso(d.updatedAt),
})),
};
}
export async function runLangGraphCrmAgentFor(input: {
teamId: string;
userId: string;
userText: string;
}): Promise<AgentReply> {
const genericApiKey =
process.env.LLM_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.DASHSCOPE_API_KEY ||
process.env.QWEN_API_KEY;
const genericBaseURL =
process.env.LLM_BASE_URL ||
process.env.OPENAI_BASE_URL ||
process.env.DASHSCOPE_BASE_URL ||
process.env.QWEN_BASE_URL;
const genericModel =
process.env.LLM_MODEL ||
process.env.OPENAI_MODEL ||
process.env.DASHSCOPE_MODEL ||
process.env.QWEN_MODEL ||
"gpt-4o-mini";
const gigachatAuthKey = (process.env.GIGACHAT_AUTH_KEY ?? "").trim();
const gigachatModel = (process.env.GIGACHAT_MODEL ?? "").trim();
const gigachatScope = (process.env.GIGACHAT_SCOPE ?? "").trim();
const gigachatOauthUrl = (process.env.GIGACHAT_OAUTH_URL ?? "https://ngw.devices.sberbank.ru:9443/api/v2/oauth").trim();
const gigachatBaseUrl = (process.env.GIGACHAT_BASE_URL ?? "https://gigachat.devices.sberbank.ru/api/v1").trim();
const useGigachat = Boolean(gigachatAuthKey && gigachatScope);
let llmApiKey = genericApiKey;
let llmBaseURL = genericBaseURL;
let llmModel = genericModel;
if (useGigachat) {
try {
llmApiKey = await getGigachatAccessToken({
authKey: gigachatAuthKey,
scope: gigachatScope,
oauthUrl: gigachatOauthUrl,
});
llmBaseURL = gigachatBaseUrl;
llmModel = gigachatModel || "GigaChat-2-Max";
} catch (e: any) {
return {
text: `Не удалось получить токен GigaChat: ${String(e?.message || e)}`,
plan: ["Проверить GIGACHAT_AUTH_KEY", "Проверить GIGACHAT_SCOPE", "Проверить сетевой доступ до OAuth endpoint и перезапустить dev-сервер"],
tools: [],
thinking: ["Провайдер GigaChat настроен, но OAuth не прошел."],
toolRuns: [],
};
}
}
if (!llmApiKey) {
return {
text: "LLM API key не задан. Сейчас включен fallback-агент без LLM.",
plan: ["Проверить .env", "Добавить LLM_API_KEY (или OPENAI_API_KEY / DASHSCOPE_API_KEY / QWEN_API_KEY / GIGACHAT_AUTH_KEY+GIGACHAT_SCOPE)", "Перезапустить dev-сервер"],
tools: [],
thinking: ["LLM недоступна, возвращен fallback-ответ."],
toolRuns: [],
};
}
// 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 toolRuns: NonNullable<AgentReply["toolRuns"]> = [];
const pendingChanges: PendingChange[] = [];
function compact(value: unknown, max = 240) {
const text = typeof value === "string" ? value : JSON.stringify(value);
if (!text) return "";
return text.length > max ? `${text.slice(0, max)}...` : text;
}
const CrmToolSchema = z.object({
action: z.enum([
"get_snapshot",
"query_contacts",
"query_deals",
"query_events",
"pending_changes",
"discard_changes",
"commit_changes",
"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(),
mode: z.enum(["stage", "apply"]).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 applyPendingChanges = async () => {
const queue = pendingChanges.splice(0, pendingChanges.length);
if (!queue.length) {
return { ok: true, applied: 0, changes: [] as Array<{ id: string; type: string; detail: string }> };
}
const applied: Array<{ id: string; type: string; detail: string }> = [];
await prisma.$transaction(async (tx) => {
for (const change of queue) {
if (change.type === "update_contact_note") {
await tx.contactNote.upsert({
where: { contactId: change.contactId },
update: { content: change.content },
create: { contactId: change.contactId, content: change.content },
});
applied.push({ id: change.id, type: change.type, detail: `${change.contactName}: summary updated` });
continue;
}
if (change.type === "create_event") {
const created = await tx.calendarEvent.create({
data: {
teamId: input.teamId,
contactId: change.contactId,
title: change.title,
startsAt: new Date(change.start),
endsAt: change.end ? new Date(change.end) : null,
note: change.note,
status: change.status,
},
});
applied.push({ id: change.id, type: change.type, detail: `created event ${created.id}` });
continue;
}
if (change.type === "create_message") {
const created = await tx.contactMessage.create({
data: {
contactId: change.contactId,
kind: change.kind === "call" ? "CALL" : "MESSAGE",
direction: change.direction === "in" ? "IN" : "OUT",
channel: toChannel(change.channel),
content: change.text,
durationSec: change.durationSec,
transcriptJson: Array.isArray(change.transcript) ? change.transcript : null,
occurredAt: new Date(change.at),
},
});
applied.push({ id: change.id, type: change.type, detail: `created message ${created.id}` });
continue;
}
if (change.type === "update_deal_stage") {
const updated = await tx.deal.updateMany({
where: { id: change.dealId, teamId: input.teamId },
data: { stage: change.stage },
});
if (updated.count === 0) throw new Error(`deal not found: ${change.dealId}`);
applied.push({ id: change.id, type: change.type, detail: `${change.dealTitle}: stage -> ${change.stage}` });
continue;
}
}
});
for (const item of applied) {
dbWrites.push({ kind: item.type, detail: item.detail });
}
return { ok: true, applied: applied.length, changes: applied };
};
const crmTool = tool(
async (raw: z.infer<typeof CrmToolSchema>) => {
const toolName = `crm:${raw.action}`;
const startedAt = new Date().toISOString();
toolsUsed.push(toolName);
const executeAction = async () => {
if (raw.action === "get_snapshot") {
const snapshot = await buildCrmSnapshot({
teamId: input.teamId,
contact: raw.contact,
contactsLimit: raw.limit,
});
return JSON.stringify(snapshot, null, 2);
}
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 === "pending_changes") {
return JSON.stringify(
{
count: pendingChanges.length,
items: pendingChanges.map((item) => ({
id: item.id,
type: item.type,
createdAt: item.createdAt,
})),
},
null,
2,
);
}
if (raw.action === "discard_changes") {
const discarded = pendingChanges.length;
pendingChanges.splice(0, pendingChanges.length);
return JSON.stringify({ ok: true, discarded }, null, 2);
}
if (raw.action === "commit_changes") {
const committed = await applyPendingChanges();
return JSON.stringify(committed, 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 resolveContact(input.teamId, contactName);
if (!contact) throw new Error("contact not found");
pendingChanges.push({
id: makeId("chg"),
type: "update_contact_note",
createdAt: iso(new Date()),
contactId: contact.id,
contactName: contact.name,
content,
});
if (raw.mode === "apply") {
const committed = await applyPendingChanges();
return JSON.stringify(committed, null, 2);
}
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
}
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 resolveContact(input.teamId, contactName)
: null;
pendingChanges.push({
id: makeId("chg"),
type: "create_event",
createdAt: iso(new Date()),
contactId: contact?.id ?? null,
contactName: contact?.name ?? null,
title,
start: iso(start),
end: end && !Number.isNaN(end.getTime()) ? iso(end) : null,
note: (raw.note ?? "").trim() || null,
status: (raw.status ?? "").trim() || null,
});
if (raw.mode === "apply") {
const committed = await applyPendingChanges();
return JSON.stringify(committed, null, 2);
}
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
}
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 resolveContact(input.teamId, contactName);
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");
pendingChanges.push({
id: makeId("chg"),
type: "create_message",
createdAt: iso(new Date()),
contactId: contact.id,
contactName: contact.name,
kind: raw.kind === "call" ? "call" : "message",
direction: raw.direction === "in" ? "in" : "out",
channel: raw.channel ?? "Phone",
text,
at: iso(occurredAt),
durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null,
transcript: Array.isArray(raw.transcript) ? raw.transcript : null,
});
if (raw.mode === "apply") {
const committed = await applyPendingChanges();
return JSON.stringify(committed, null, 2);
}
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
}
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 deal = await prisma.deal.findFirst({
where: { id: dealId, teamId: input.teamId },
select: { id: true, title: true },
});
if (!deal) throw new Error("deal not found");
pendingChanges.push({
id: makeId("chg"),
type: "update_deal_stage",
createdAt: iso(new Date()),
dealId: deal.id,
dealTitle: deal.title,
stage,
});
if (raw.mode === "apply") {
const committed = await applyPendingChanges();
return JSON.stringify(committed, null, 2);
}
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
}
return JSON.stringify({ ok: false, error: "unknown action" });
};
try {
const result = await executeAction();
toolRuns.push({
name: toolName,
status: "ok",
input: compact(raw),
output: compact(result),
at: startedAt,
});
return result;
} catch (error: any) {
toolRuns.push({
name: toolName,
status: "error",
input: compact(raw),
output: compact(error?.message || String(error)),
at: startedAt,
});
throw error;
}
},
{
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 snapshotJson = JSON.stringify(snapshot, null, 2);
const model = new ChatOpenAI({
apiKey: llmApiKey,
model: llmModel,
temperature: 0.2,
...(llmBaseURL
? {
configuration: {
baseURL: llmBaseURL,
},
}
: {}),
});
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.",
"- You are given a structured CRM JSON snapshot as baseline context.",
"- If you need fresher or narrower data, call crm.get_snapshot/query_* tools.",
"- For changes, stage first with mode=stage. Commit only when user asks to execute.",
"- You can apply immediately with mode=apply only if user explicitly asked to do it now.",
"- Use pending_changes and commit_changes to control staged updates.",
"- Do not claim you sent an external message; you can only create CRM records.",
"",
"CRM Snapshot JSON:",
snapshotJson,
].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,
thinking: plan,
tools: toolsUsed,
toolRuns,
dbWrites: dbWrites.length ? dbWrites : undefined,
};
}