feat: add scoped context payload and rollbackable document changes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user