feat: add scoped context payload and rollbackable document changes

This commit is contained in:
Ruslan Bakiev
2026-02-21 16:27:09 +07:00
parent 052f37d0ec
commit fa1231df37
5 changed files with 678 additions and 13 deletions

View File

@@ -42,6 +42,31 @@ export type AgentTraceEvent = {
};
};
export type PilotContextPayload = {
scopes: Array<"summary" | "deal" | "message" | "calendar">;
summary?: {
contactId: string;
name: string;
};
deal?: {
dealId: string;
title: string;
contact: string;
};
message?: {
contactId?: string;
contact?: string;
intent: "add_message_or_reminder";
};
calendar?: {
view: "day" | "week" | "month" | "year" | "agenda";
period: string;
selectedDateKey: string;
focusedEventId?: string;
eventIds: string[];
};
};
function normalize(s: string) {
return s.trim().toLowerCase();
}
@@ -97,6 +122,7 @@ export async function runCrmAgentFor(
teamId: string;
userId: string;
userText: string;
contextPayload?: PilotContextPayload | null;
requestId?: string;
conversationId?: string;
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import type { AgentReply, AgentTraceEvent } from "./crmAgent";
import type { AgentReply, AgentTraceEvent, PilotContextPayload } from "./crmAgent";
import { prisma } from "../utils/prisma";
import { ensureDataset } from "../dataset/exporter";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
@@ -315,6 +315,7 @@ export async function runLangGraphCrmAgentFor(input: {
teamId: string;
userId: string;
userText: string;
contextPayload?: PilotContextPayload | null;
requestId?: string;
conversationId?: string;
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
@@ -439,6 +440,10 @@ export async function runLangGraphCrmAgentFor(input: {
"getUserCalendarWindow",
"updateContactSummary",
"createUserCalendarEvent",
"getWorkspaceDocumentsList",
"getWorkspaceDocument",
"updateWorkspaceDocument",
"createWorkspaceDocument",
]),
// read actions
query: z.string().optional(),
@@ -460,12 +465,20 @@ export async function runLangGraphCrmAgentFor(input: {
end: z.string().optional(),
note: z.string().optional(),
archived: z.boolean().optional(),
// workspace document actions
documentId: z.string().optional(),
documentType: z.enum(["Regulation", "Playbook", "Policy", "Template"]).optional(),
owner: z.string().optional(),
scope: z.string().optional(),
body: z.string().optional(),
});
const readActionNames = new Set([
"getContactsList",
"getContactSnapshot",
"getUserCalendarWindow",
"getWorkspaceDocumentsList",
"getWorkspaceDocument",
]);
const readToolCache = new Map<string, string>();
const invalidateReadCache = () => {
@@ -854,6 +867,186 @@ export async function runLangGraphCrmAgentFor(input: {
}, null, 2);
}
if (raw.action === "getWorkspaceDocumentsList") {
const q = (raw.query ?? "").trim();
const limit = Math.max(1, Math.min(raw.limit ?? 30, 200));
const offset = Math.max(0, raw.offset ?? 0);
const where = {
teamId: input.teamId,
...(raw.documentType ? { type: raw.documentType } : {}),
...(q
? {
OR: [
{ title: { contains: q } },
{ owner: { contains: q } },
{ scope: { contains: q } },
{ summary: { contains: q } },
{ body: { contains: q } },
],
}
: {}),
};
const [total, items] = await Promise.all([
prisma.workspaceDocument.count({ where }),
prisma.workspaceDocument.findMany({
where,
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
skip: offset,
take: limit,
select: {
id: true,
title: true,
type: true,
owner: true,
scope: true,
summary: true,
updatedAt: true,
},
}),
]);
return cacheReadResult(
JSON.stringify(
{
items: items.map((d) => ({
id: d.id,
title: d.title,
type: d.type,
owner: d.owner,
scope: d.scope,
summary: d.summary,
updatedAt: d.updatedAt.toISOString(),
})),
pagination: {
offset,
limit,
returned: items.length,
total,
hasMore: offset + items.length < total,
nextOffset: offset + items.length,
},
},
null,
2,
),
);
}
if (raw.action === "getWorkspaceDocument") {
const documentId = (raw.documentId ?? "").trim();
if (!documentId) throw new Error("documentId is required");
const doc = await prisma.workspaceDocument.findFirst({
where: { id: documentId, teamId: input.teamId },
select: {
id: true,
title: true,
type: true,
owner: true,
scope: true,
summary: true,
body: true,
createdAt: true,
updatedAt: true,
},
});
if (!doc) throw new Error("document not found");
return cacheReadResult(
JSON.stringify(
{
document: {
id: doc.id,
title: doc.title,
type: doc.type,
owner: doc.owner,
scope: doc.scope,
summary: doc.summary,
body: doc.body,
createdAt: doc.createdAt.toISOString(),
updatedAt: doc.updatedAt.toISOString(),
},
},
null,
2,
),
);
}
if (raw.action === "updateWorkspaceDocument") {
const documentId = (raw.documentId ?? "").trim();
if (!documentId) throw new Error("documentId is required");
const existing = await prisma.workspaceDocument.findFirst({
where: { id: documentId, teamId: input.teamId },
select: { id: true, title: true },
});
if (!existing) throw new Error("document not found");
const title = raw.title?.trim();
const owner = raw.owner?.trim();
const scope = raw.scope?.trim();
const summary = raw.summary?.trim();
const body = raw.body?.trim();
if (!title && !owner && !scope && !summary && !body && !raw.documentType) {
throw new Error("at least one field is required to update");
}
await prisma.workspaceDocument.update({
where: { id: existing.id },
data: {
...(title ? { title } : {}),
...(raw.documentType ? { type: raw.documentType } : {}),
...(owner ? { owner } : {}),
...(scope ? { scope } : {}),
...(summary ? { summary } : {}),
...(body ? { body } : {}),
},
});
invalidateReadCache();
dbWrites.push({
kind: "updateWorkspaceDocument",
detail: `${existing.title}: document updated`,
});
return JSON.stringify({ ok: true, applied: 1, documentId: existing.id }, null, 2);
}
if (raw.action === "createWorkspaceDocument") {
const title = (raw.title ?? "").trim();
const documentType = raw.documentType;
const owner = (raw.owner ?? "").trim();
const scope = (raw.scope ?? "").trim();
const summary = (raw.summary ?? "").trim();
const body = (raw.body ?? raw.content ?? "").trim();
if (!title) throw new Error("title is required");
if (!documentType) throw new Error("documentType is required");
if (!owner) throw new Error("owner is required");
if (!scope) throw new Error("scope is required");
if (!summary) throw new Error("summary is required");
if (!body) throw new Error("body is required");
const created = await prisma.workspaceDocument.create({
data: {
teamId: input.teamId,
title,
type: documentType,
owner,
scope,
summary,
body,
},
select: { id: true, title: true },
});
invalidateReadCache();
dbWrites.push({
kind: "createWorkspaceDocument",
detail: `created document ${created.title}`,
});
return JSON.stringify({ ok: true, applied: 1, documentId: created.id }, null, 2);
}
return JSON.stringify({ ok: false, error: "unknown action" });
};
@@ -891,13 +1084,24 @@ export async function runLangGraphCrmAgentFor(input: {
{
name: "crm",
description:
"CRM tool with exactly five actions: getContactsList, getContactSnapshot, getUserCalendarWindow, updateContactSummary, createUserCalendarEvent.",
"CRM tool with actions for contacts, calendar, and workspace documents.",
schema: CrmToolSchema,
},
);
const snapshot = await buildCrmSnapshot({ teamId: input.teamId });
const scopedContext = input.contextPayload ?? null;
const focusedContact =
scopedContext?.summary?.name ||
scopedContext?.deal?.contact ||
scopedContext?.message?.contact ||
undefined;
const snapshot = await buildCrmSnapshot({
teamId: input.teamId,
...(focusedContact ? { contact: focusedContact } : {}),
});
const snapshotJson = JSON.stringify(snapshot, null, 2);
const scopedContextJson = JSON.stringify(scopedContext, null, 2);
const model = new ChatOpenAI({
apiKey: llmApiKey,
@@ -939,6 +1143,7 @@ export async function runLangGraphCrmAgentFor(input: {
userId: input.userId,
requestId: input.requestId ?? null,
conversationId: input.conversationId ?? null,
contextScopes: scopedContext?.scopes ?? [],
},
tags: ["clientsflow", "crm-agent", "langgraph"],
});
@@ -951,12 +1156,18 @@ export async function runLangGraphCrmAgentFor(input: {
"- Be concrete and complete. Do not cut important details in the final answer.",
"- Work in short iterative cycles. Do not stop after the first thought if the task needs more than one action.",
"- You are given a structured CRM JSON snapshot as baseline context.",
"- Only use these actions: crm.getContactsList, crm.getContactSnapshot, crm.getUserCalendarWindow, crm.updateContactSummary, crm.createUserCalendarEvent.",
"- If Scoped Context Payload JSON is provided, treat it as primary context from the UI selection.",
"- Prefer entities from scoped context and avoid unrelated entities unless explicitly asked.",
"- Only use these actions: crm.getContactsList, crm.getContactSnapshot, crm.getUserCalendarWindow, crm.updateContactSummary, crm.createUserCalendarEvent, crm.getWorkspaceDocumentsList, crm.getWorkspaceDocument, crm.updateWorkspaceDocument, crm.createWorkspaceDocument.",
"- Use crm.getContactsList first to choose contacts, then crm.getContactSnapshot for deep context, then crm.getUserCalendarWindow for schedule validation.",
"- For policy/regulation requests, first call crm.getWorkspaceDocumentsList, then crm.getWorkspaceDocument before drafting an answer or applying edits.",
"- Avoid repeating identical read calls with the same arguments.",
"- Write actions are immediate DB changes. Do not mention staging or commit.",
"- Do not claim you sent an external message; you can only create CRM records.",
"",
"Scoped Context Payload JSON:",
scopedContextJson,
"",
"CRM Snapshot JSON:",
snapshotJson,
].join("\n");

View File

@@ -4,6 +4,7 @@ import { getAuthContext } from "../utils/auth";
import { prisma } from "../utils/prisma";
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
import type { PilotContextPayload } from "../agent/crmAgent";
import type { ChangeSet } from "../utils/changeSet";
function extractMessageText(message: any): string {
@@ -25,6 +26,69 @@ function getLastUserText(messages: any[]): string {
return "";
}
function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
if (!raw || typeof raw !== "object") return null;
const item = raw as Record<string, any>;
const scopesRaw = Array.isArray(item.scopes) ? item.scopes : [];
const scopes = scopesRaw
.map((scope) => String(scope))
.filter((scope) => scope === "summary" || scope === "deal" || scope === "message" || scope === "calendar") as PilotContextPayload["scopes"];
if (!scopes.length) return null;
const payload: PilotContextPayload = { scopes };
if (item.summary && typeof item.summary === "object") {
const contactId = String(item.summary.contactId ?? "").trim();
const name = String(item.summary.name ?? "").trim();
if (contactId && name) payload.summary = { contactId, name };
}
if (item.deal && typeof item.deal === "object") {
const dealId = String(item.deal.dealId ?? "").trim();
const title = String(item.deal.title ?? "").trim();
const contact = String(item.deal.contact ?? "").trim();
if (dealId && title && contact) payload.deal = { dealId, title, contact };
}
if (item.message && typeof item.message === "object") {
const contactId = String(item.message.contactId ?? "").trim();
const contact = String(item.message.contact ?? "").trim();
const intent = String(item.message.intent ?? "").trim();
if (intent === "add_message_or_reminder") {
payload.message = {
...(contactId ? { contactId } : {}),
...(contact ? { contact } : {}),
intent: "add_message_or_reminder",
};
}
}
if (item.calendar && typeof item.calendar === "object") {
const view = String(item.calendar.view ?? "").trim();
const period = String(item.calendar.period ?? "").trim();
const selectedDateKey = String(item.calendar.selectedDateKey ?? "").trim();
const focusedEventId = String(item.calendar.focusedEventId ?? "").trim();
const eventIds = Array.isArray(item.calendar.eventIds)
? item.calendar.eventIds.map((id: any) => String(id ?? "").trim()).filter(Boolean)
: [];
if (
(view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") &&
period &&
selectedDateKey
) {
payload.calendar = {
view,
period,
selectedDateKey,
...(focusedEventId ? { focusedEventId } : {}),
eventIds,
};
}
}
return payload;
}
function humanizeTraceText(trace: AgentTraceEvent): string {
if (trace.toolRun?.name) {
return `Использую инструмент: ${trace.toolRun.name}`;
@@ -62,9 +126,10 @@ function renderChangeSetSummary(changeSet: ChangeSet): string {
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const body = await readBody<{ messages?: any[] }>(event);
const body = await readBody<{ messages?: any[]; contextPayload?: unknown }>(event);
const messages = Array.isArray(body?.messages) ? body.messages : [];
const userText = getLastUserText(messages);
const contextPayload = sanitizeContextPayload(body?.contextPayload);
if (!userText) {
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
@@ -94,6 +159,7 @@ export default defineEventHandler(async (event) => {
teamId: auth.teamId,
userId: auth.userId,
userText,
contextPayload,
requestId,
conversationId: auth.conversationId,
onTrace: async (trace: AgentTraceEvent) => {

View File

@@ -41,16 +41,28 @@ type DealSnapshotRow = {
summary: string | null;
};
type WorkspaceDocumentSnapshotRow = {
id: string;
teamId: string;
title: string;
type: string;
owner: string;
scope: string;
summary: string;
body: string;
};
export type SnapshotState = {
calendarById: Map<string, CalendarSnapshotRow>;
noteByContactId: Map<string, ContactNoteSnapshotRow>;
messageById: Map<string, MessageSnapshotRow>;
dealById: Map<string, DealSnapshotRow>;
documentById: Map<string, WorkspaceDocumentSnapshotRow>;
};
export type ChangeItem = {
id: string;
entity: "calendar_event" | "contact_note" | "message" | "deal";
entity: "calendar_event" | "contact_note" | "message" | "deal" | "workspace_document";
entityId: string | null;
action: "created" | "updated" | "deleted";
title: string;
@@ -65,7 +77,9 @@ type UndoOp =
| { kind: "delete_contact_message"; id: string }
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
| { kind: "restore_contact_note"; contactId: string; content: string | null }
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null };
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null }
| { kind: "delete_workspace_document"; id: string }
| { kind: "restore_workspace_document"; data: WorkspaceDocumentSnapshotRow };
export type ChangeSet = {
id: string;
@@ -95,8 +109,12 @@ function toDealText(row: DealSnapshotRow) {
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
}
function toWorkspaceDocumentText(row: WorkspaceDocumentSnapshotRow) {
return `${row.title} · ${row.type} · ${row.owner} · ${row.scope} · ${row.summary}`;
}
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
const [calendar, notes, messages, deals] = await Promise.all([
const [calendar, notes, messages, deals, documents] = await Promise.all([
prisma.calendarEvent.findMany({
where: { teamId },
select: {
@@ -129,6 +147,20 @@ export async function captureSnapshot(prisma: PrismaClient, teamId: string): Pro
include: { contact: { select: { name: true } } },
take: 4000,
}),
prisma.workspaceDocument.findMany({
where: { teamId },
select: {
id: true,
teamId: true,
title: true,
type: true,
owner: true,
scope: true,
summary: true,
body: true,
},
take: 4000,
}),
]);
return {
@@ -188,6 +220,21 @@ export async function captureSnapshot(prisma: PrismaClient, teamId: string): Pro
},
]),
),
documentById: new Map(
documents.map((row) => [
row.id,
{
id: row.id,
teamId: row.teamId,
title: row.title,
type: row.type,
owner: row.owner,
scope: row.scope,
summary: row.summary,
body: row.body,
},
]),
),
};
}
@@ -326,6 +373,53 @@ export function buildChangeSet(before: SnapshotState, after: SnapshotState): Cha
}
}
for (const [id, row] of after.documentById) {
const prev = before.documentById.get(id);
if (!prev) {
pushItem({
entity: "workspace_document",
entityId: row.id,
action: "created",
title: `Document created: ${row.title}`,
before: "",
after: toWorkspaceDocumentText(row),
undo: [{ kind: "delete_workspace_document", id }],
});
continue;
}
if (
prev.title !== row.title ||
prev.type !== row.type ||
prev.owner !== row.owner ||
prev.scope !== row.scope ||
prev.summary !== row.summary ||
prev.body !== row.body
) {
pushItem({
entity: "workspace_document",
entityId: row.id,
action: "updated",
title: `Document updated: ${row.title}`,
before: toWorkspaceDocumentText(prev),
after: toWorkspaceDocumentText(row),
undo: [{ kind: "restore_workspace_document", data: prev }],
});
}
}
for (const [id, row] of before.documentById) {
if (after.documentById.has(id)) continue;
pushItem({
entity: "workspace_document",
entityId: row.id,
action: "deleted",
title: `Document deleted: ${row.title}`,
before: toWorkspaceDocumentText(row),
after: "",
undo: [{ kind: "restore_workspace_document", data: row }],
});
}
if (items.length === 0) return null;
const created = items.filter((x) => x.action === "created").length;
@@ -440,6 +534,38 @@ async function applyUndoOps(prisma: PrismaClient, teamId: string, undoOps: UndoO
summary: op.summary,
},
});
continue;
}
if (op.kind === "delete_workspace_document") {
await tx.workspaceDocument.deleteMany({ where: { id: op.id, teamId } });
continue;
}
if (op.kind === "restore_workspace_document") {
const row = op.data;
await tx.workspaceDocument.upsert({
where: { id: row.id },
update: {
teamId: row.teamId,
title: row.title,
type: row.type as any,
owner: row.owner,
scope: row.scope,
summary: row.summary,
body: row.body,
},
create: {
id: row.id,
teamId: row.teamId,
title: row.title,
type: row.type as any,
owner: row.owner,
scope: row.scope,
summary: row.summary,
body: row.body,
},
});
}
}
});