feat(agent): optimize crm tool flow and reduce context bloat
This commit is contained in:
@@ -473,9 +473,26 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stableStringify(value: unknown): string {
|
||||||
|
const walk = (input: unknown): unknown => {
|
||||||
|
if (Array.isArray(input)) return input.map(walk);
|
||||||
|
if (!input || typeof input !== "object") return input;
|
||||||
|
const obj = input as Record<string, unknown>;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(obj)
|
||||||
|
.sort()
|
||||||
|
.map((key) => [key, walk(obj[key])]),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return JSON.stringify(walk(value));
|
||||||
|
}
|
||||||
|
|
||||||
const CrmToolSchema = z.object({
|
const CrmToolSchema = z.object({
|
||||||
action: z.enum([
|
action: z.enum([
|
||||||
"get_snapshot",
|
"get_snapshot",
|
||||||
|
"list_contacts_digest",
|
||||||
|
"get_contact_snapshot",
|
||||||
|
"get_calendar_window",
|
||||||
"query_contacts",
|
"query_contacts",
|
||||||
"query_deals",
|
"query_deals",
|
||||||
"query_events",
|
"query_events",
|
||||||
@@ -484,6 +501,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
"commit_changes",
|
"commit_changes",
|
||||||
"update_contact_note",
|
"update_contact_note",
|
||||||
"create_event",
|
"create_event",
|
||||||
|
"create_events_batch",
|
||||||
"create_message",
|
"create_message",
|
||||||
"update_deal_stage",
|
"update_deal_stage",
|
||||||
]),
|
]),
|
||||||
@@ -493,9 +511,15 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
to: z.string().optional(),
|
to: z.string().optional(),
|
||||||
limit: z.number().int().optional(),
|
limit: z.number().int().optional(),
|
||||||
|
offset: z.number().int().min(0).optional(),
|
||||||
mode: z.enum(["stage", "apply"]).optional(),
|
mode: z.enum(["stage", "apply"]).optional(),
|
||||||
|
includeArchived: z.boolean().optional(),
|
||||||
|
messagesLimit: z.number().int().optional(),
|
||||||
|
eventsLimit: z.number().int().optional(),
|
||||||
|
dealsLimit: z.number().int().optional(),
|
||||||
// writes
|
// writes
|
||||||
contact: z.string().optional(),
|
contact: z.string().optional(),
|
||||||
|
contactId: z.string().optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
start: z.string().optional(),
|
start: z.string().optional(),
|
||||||
@@ -510,6 +534,19 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
durationSec: z.number().int().optional(),
|
durationSec: z.number().int().optional(),
|
||||||
transcript: z.array(z.string()).optional(),
|
transcript: z.array(z.string()).optional(),
|
||||||
dealId: z.string().optional(),
|
dealId: z.string().optional(),
|
||||||
|
events: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
contact: z.string().optional(),
|
||||||
|
contactId: z.string().optional(),
|
||||||
|
title: z.string(),
|
||||||
|
start: z.string(),
|
||||||
|
end: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
archived: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyPendingChanges = async () => {
|
const applyPendingChanges = async () => {
|
||||||
@@ -584,6 +621,20 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
return { ok: true, applied: applied.length, changes: applied };
|
return { ok: true, applied: applied.length, changes: applied };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readActionNames = new Set([
|
||||||
|
"get_snapshot",
|
||||||
|
"list_contacts_digest",
|
||||||
|
"get_contact_snapshot",
|
||||||
|
"get_calendar_window",
|
||||||
|
"query_contacts",
|
||||||
|
"query_deals",
|
||||||
|
"query_events",
|
||||||
|
]);
|
||||||
|
const readToolCache = new Map<string, string>();
|
||||||
|
const invalidateReadCache = () => {
|
||||||
|
readToolCache.clear();
|
||||||
|
};
|
||||||
|
|
||||||
const crmTool = tool(
|
const crmTool = tool(
|
||||||
async (rawInput: unknown) => {
|
async (rawInput: unknown) => {
|
||||||
const raw = CrmToolSchema.parse(rawInput);
|
const raw = CrmToolSchema.parse(rawInput);
|
||||||
@@ -593,17 +644,332 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
await emitTrace({ text: `Использую инструмент: ${toolName}` });
|
await emitTrace({ text: `Использую инструмент: ${toolName}` });
|
||||||
|
|
||||||
const executeAction = async () => {
|
const executeAction = async () => {
|
||||||
|
const readCacheKey = readActionNames.has(raw.action) ? `${raw.action}:${stableStringify(raw)}` : "";
|
||||||
|
const cacheReadResult = (result: string) => {
|
||||||
|
if (readCacheKey) {
|
||||||
|
readToolCache.set(readCacheKey, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (readCacheKey && readToolCache.has(readCacheKey)) {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
cached: true,
|
||||||
|
action: raw.action,
|
||||||
|
note: "Identical read query was already returned earlier in this request. Reuse previous tool output.",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromValue = raw.from ?? raw.start;
|
||||||
|
const toValue = raw.to ?? raw.end;
|
||||||
|
|
||||||
if (raw.action === "get_snapshot") {
|
if (raw.action === "get_snapshot") {
|
||||||
const snapshot = await buildCrmSnapshot({
|
const snapshot = await buildCrmSnapshot({
|
||||||
teamId: input.teamId,
|
teamId: input.teamId,
|
||||||
contact: raw.contact,
|
contact: raw.contact,
|
||||||
contactsLimit: raw.limit,
|
contactsLimit: raw.limit,
|
||||||
});
|
});
|
||||||
return JSON.stringify(snapshot, null, 2);
|
return cacheReadResult(JSON.stringify(snapshot, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "list_contacts_digest") {
|
||||||
|
const q = (raw.query ?? "").trim();
|
||||||
|
const limit = Math.max(1, Math.min(raw.limit ?? 50, 200));
|
||||||
|
const offset = Math.max(0, raw.offset ?? 0);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
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" }, { id: "asc" }],
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
note: { select: { content: true, updatedAt: true } },
|
||||||
|
messages: {
|
||||||
|
select: { occurredAt: true, channel: true, direction: true, kind: true, content: true },
|
||||||
|
orderBy: { occurredAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
select: { id: true, title: true, startsAt: true, endsAt: true, isArchived: true },
|
||||||
|
where: {
|
||||||
|
startsAt: { gte: now },
|
||||||
|
...(raw.includeArchived ? {} : { isArchived: false }),
|
||||||
|
},
|
||||||
|
orderBy: { startsAt: "asc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
deals: {
|
||||||
|
select: { id: true, stage: true, title: true, amount: true, updatedAt: true, nextStep: true, summary: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { messages: true, events: true, deals: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return cacheReadResult(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
items: items.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
company: c.company,
|
||||||
|
country: c.country,
|
||||||
|
location: c.location,
|
||||||
|
email: c.email,
|
||||||
|
phone: c.phone,
|
||||||
|
summary: c.note?.content ?? null,
|
||||||
|
lastMessage: c.messages[0]
|
||||||
|
? {
|
||||||
|
occurredAt: c.messages[0].occurredAt.toISOString(),
|
||||||
|
channel: c.messages[0].channel,
|
||||||
|
direction: c.messages[0].direction,
|
||||||
|
kind: c.messages[0].kind,
|
||||||
|
content: c.messages[0].content,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
nextEvent: c.events[0]
|
||||||
|
? {
|
||||||
|
id: c.events[0].id,
|
||||||
|
title: c.events[0].title,
|
||||||
|
startsAt: c.events[0].startsAt.toISOString(),
|
||||||
|
endsAt: (c.events[0].endsAt ?? c.events[0].startsAt).toISOString(),
|
||||||
|
isArchived: c.events[0].isArchived,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
latestDeal: c.deals[0] ?? null,
|
||||||
|
counts: c._count,
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
returned: items.length,
|
||||||
|
hasMore: items.length === limit,
|
||||||
|
nextOffset: offset + items.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "get_contact_snapshot") {
|
||||||
|
const contactRef = (raw.contact ?? "").trim();
|
||||||
|
const contactId = (raw.contactId ?? "").trim();
|
||||||
|
const messagesLimit = Math.max(1, Math.min(raw.messagesLimit ?? 20, 100));
|
||||||
|
const eventsLimit = Math.max(1, Math.min(raw.eventsLimit ?? 20, 100));
|
||||||
|
const dealsLimit = Math.max(1, Math.min(raw.dealsLimit ?? 5, 20));
|
||||||
|
|
||||||
|
let target: { id: string; name: string } | null = null;
|
||||||
|
if (contactId) {
|
||||||
|
target = await prisma.contact.findFirst({
|
||||||
|
where: { id: contactId, teamId: input.teamId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!target && contactRef) {
|
||||||
|
target = await resolveContact(input.teamId, contactRef);
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("contact/contactId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = fromValue ? new Date(fromValue) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const to = toValue ? new Date(toValue) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||||
|
throw new Error("from/to range is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await prisma.contact.findFirst({
|
||||||
|
where: { id: target.id, teamId: input.teamId },
|
||||||
|
include: {
|
||||||
|
note: { select: { content: true, updatedAt: true } },
|
||||||
|
messages: {
|
||||||
|
select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true },
|
||||||
|
orderBy: { occurredAt: "desc" },
|
||||||
|
take: messagesLimit,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
select: { id: true, title: true, startsAt: true, endsAt: true, note: true, isArchived: true },
|
||||||
|
where: {
|
||||||
|
startsAt: { gte: from, lte: to },
|
||||||
|
...(raw.includeArchived ? {} : { isArchived: false }),
|
||||||
|
},
|
||||||
|
orderBy: { startsAt: "asc" },
|
||||||
|
take: eventsLimit,
|
||||||
|
},
|
||||||
|
deals: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
stage: true,
|
||||||
|
amount: true,
|
||||||
|
nextStep: true,
|
||||||
|
summary: true,
|
||||||
|
currentStepId: true,
|
||||||
|
updatedAt: true,
|
||||||
|
steps: {
|
||||||
|
select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true },
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: dealsLimit,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { messages: true, events: true, deals: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!contact) throw new Error("contact not found");
|
||||||
|
|
||||||
|
return cacheReadResult(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
contact: {
|
||||||
|
id: contact.id,
|
||||||
|
name: contact.name,
|
||||||
|
company: contact.company,
|
||||||
|
country: contact.country,
|
||||||
|
location: contact.location,
|
||||||
|
email: contact.email,
|
||||||
|
phone: contact.phone,
|
||||||
|
updatedAt: contact.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
summary: contact.note?.content ?? null,
|
||||||
|
note: contact.note
|
||||||
|
? {
|
||||||
|
content: contact.note.content,
|
||||||
|
updatedAt: contact.note.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
messages: contact.messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
occurredAt: m.occurredAt.toISOString(),
|
||||||
|
channel: m.channel,
|
||||||
|
direction: m.direction,
|
||||||
|
kind: m.kind,
|
||||||
|
content: m.content,
|
||||||
|
durationSec: m.durationSec,
|
||||||
|
transcript: m.transcriptJson,
|
||||||
|
})),
|
||||||
|
events: contact.events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
startsAt: e.startsAt.toISOString(),
|
||||||
|
endsAt: (e.endsAt ?? e.startsAt).toISOString(),
|
||||||
|
note: e.note,
|
||||||
|
isArchived: e.isArchived,
|
||||||
|
})),
|
||||||
|
deals: contact.deals.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
stage: d.stage,
|
||||||
|
amount: d.amount,
|
||||||
|
nextStep: d.nextStep,
|
||||||
|
summary: d.summary,
|
||||||
|
currentStepId: d.currentStepId,
|
||||||
|
updatedAt: d.updatedAt.toISOString(),
|
||||||
|
steps: d.steps.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
status: s.status,
|
||||||
|
dueAt: s.dueAt ? s.dueAt.toISOString() : null,
|
||||||
|
order: s.order,
|
||||||
|
completedAt: s.completedAt ? s.completedAt.toISOString() : null,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
counts: contact._count,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "get_calendar_window") {
|
||||||
|
const from = fromValue ? new Date(fromValue) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const to = toValue ? new Date(toValue) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||||
|
throw new Error("from/to range is invalid");
|
||||||
|
}
|
||||||
|
const limit = Math.max(1, Math.min(raw.limit ?? 100, 500));
|
||||||
|
const offset = Math.max(0, raw.offset ?? 0);
|
||||||
|
const where = {
|
||||||
|
teamId: input.teamId,
|
||||||
|
startsAt: { gte: from, lte: to },
|
||||||
|
...(raw.includeArchived ? {} : { isArchived: false }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
prisma.calendarEvent.count({ where }),
|
||||||
|
prisma.calendarEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { startsAt: "asc" },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
include: { contact: { select: { id: true, name: true, company: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return cacheReadResult(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
window: { from: from.toISOString(), to: to.toISOString() },
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
returned: items.length,
|
||||||
|
total,
|
||||||
|
hasMore: offset + items.length < total,
|
||||||
|
nextOffset: offset + items.length,
|
||||||
|
},
|
||||||
|
items: items.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
startsAt: e.startsAt.toISOString(),
|
||||||
|
endsAt: (e.endsAt ?? e.startsAt).toISOString(),
|
||||||
|
note: e.note,
|
||||||
|
isArchived: e.isArchived,
|
||||||
|
contact: e.contact
|
||||||
|
? {
|
||||||
|
id: e.contact.id,
|
||||||
|
name: e.contact.name,
|
||||||
|
company: e.contact.company,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.action === "query_contacts") {
|
if (raw.action === "query_contacts") {
|
||||||
const q = (raw.query ?? "").trim();
|
const q = (raw.query ?? "").trim();
|
||||||
|
const offset = Math.max(0, raw.offset ?? 0);
|
||||||
|
const limit = Math.max(1, Math.min(raw.limit ?? 20, 100));
|
||||||
const items = await prisma.contact.findMany({
|
const items = await prisma.contact.findMany({
|
||||||
where: {
|
where: {
|
||||||
teamId: input.teamId,
|
teamId: input.teamId,
|
||||||
@@ -619,11 +985,14 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: Math.max(1, Math.min(raw.limit ?? 20, 100)),
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
include: { note: { select: { content: true, updatedAt: true } } },
|
include: { note: { select: { content: true, updatedAt: true } } },
|
||||||
});
|
});
|
||||||
return JSON.stringify(
|
return cacheReadResult(
|
||||||
items.map((c) => ({
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
items: items.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
company: c.company,
|
company: c.company,
|
||||||
@@ -633,31 +1002,61 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
phone: c.phone,
|
phone: c.phone,
|
||||||
note: c.note?.content ?? null,
|
note: c.note?.content ?? null,
|
||||||
})),
|
})),
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
returned: items.length,
|
||||||
|
hasMore: items.length === limit,
|
||||||
|
nextOffset: offset + items.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.action === "query_deals") {
|
if (raw.action === "query_deals") {
|
||||||
|
const offset = Math.max(0, raw.offset ?? 0);
|
||||||
|
const limit = Math.max(1, Math.min(raw.limit ?? 20, 100));
|
||||||
|
const updatedAt: { gte?: Date; lte?: Date } = {};
|
||||||
|
if (fromValue) {
|
||||||
|
const from = new Date(fromValue);
|
||||||
|
if (!Number.isNaN(from.getTime())) updatedAt.gte = from;
|
||||||
|
}
|
||||||
|
if (toValue) {
|
||||||
|
const to = new Date(toValue);
|
||||||
|
if (!Number.isNaN(to.getTime())) updatedAt.lte = to;
|
||||||
|
}
|
||||||
const items = await prisma.deal.findMany({
|
const items = await prisma.deal.findMany({
|
||||||
where: { teamId: input.teamId, ...(raw.stage ? { stage: raw.stage } : {}) },
|
where: {
|
||||||
|
teamId: input.teamId,
|
||||||
|
...(raw.stage ? { stage: raw.stage } : {}),
|
||||||
|
...(updatedAt.gte || updatedAt.lte ? { updatedAt } : {}),
|
||||||
|
},
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: Math.max(1, Math.min(raw.limit ?? 20, 100)),
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
contact: { select: { name: true, company: true } },
|
contact: { select: { name: true, company: true } },
|
||||||
steps: { select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
|
steps: {
|
||||||
|
select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true },
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return JSON.stringify(
|
return cacheReadResult(
|
||||||
items.map((d) => ({
|
JSON.stringify(
|
||||||
id: d.id,
|
{
|
||||||
title: d.title,
|
items: items.map((c) => ({
|
||||||
stage: d.stage,
|
id: c.id,
|
||||||
amount: d.amount,
|
title: c.title,
|
||||||
nextStep: d.nextStep,
|
stage: c.stage,
|
||||||
summary: d.summary,
|
amount: c.amount,
|
||||||
currentStepId: d.currentStepId,
|
nextStep: c.nextStep,
|
||||||
steps: d.steps.map((s) => ({
|
summary: c.summary,
|
||||||
|
currentStepId: c.currentStepId,
|
||||||
|
steps: c.steps.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
@@ -665,34 +1064,66 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
order: s.order,
|
order: s.order,
|
||||||
completedAt: s.completedAt ? s.completedAt.toISOString() : null,
|
completedAt: s.completedAt ? s.completedAt.toISOString() : null,
|
||||||
})),
|
})),
|
||||||
contact: d.contact.name,
|
contact: c.contact.name,
|
||||||
company: d.contact.company,
|
company: c.contact.company,
|
||||||
})),
|
})),
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
returned: items.length,
|
||||||
|
hasMore: items.length === limit,
|
||||||
|
nextOffset: offset + items.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.action === "query_events") {
|
if (raw.action === "query_events") {
|
||||||
const from = raw.from ? new Date(raw.from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const from = fromValue ? new Date(fromValue) : 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 to = toValue ? new Date(toValue) : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||||
|
throw new Error("from/to range is invalid");
|
||||||
|
}
|
||||||
|
const offset = Math.max(0, raw.offset ?? 0);
|
||||||
|
const limit = Math.max(1, Math.min(raw.limit ?? 100, 500));
|
||||||
const items = await prisma.calendarEvent.findMany({
|
const items = await prisma.calendarEvent.findMany({
|
||||||
where: { teamId: input.teamId, startsAt: { gte: from, lte: to } },
|
where: {
|
||||||
|
teamId: input.teamId,
|
||||||
|
startsAt: { gte: from, lte: to },
|
||||||
|
...(raw.title ? { title: { contains: raw.title } } : {}),
|
||||||
|
...(raw.includeArchived ? {} : { isArchived: false }),
|
||||||
|
},
|
||||||
orderBy: { startsAt: "asc" },
|
orderBy: { startsAt: "asc" },
|
||||||
take: Math.max(1, Math.min(raw.limit ?? 100, 500)),
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
include: { contact: { select: { name: true } } },
|
include: { contact: { select: { name: true } } },
|
||||||
});
|
});
|
||||||
return JSON.stringify(
|
return cacheReadResult(
|
||||||
items.map((e) => ({
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
items: items.map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
title: e.title,
|
title: e.title,
|
||||||
startsAt: e.startsAt.toISOString(),
|
startsAt: e.startsAt.toISOString(),
|
||||||
endsAt: (e.endsAt ?? e.startsAt).toISOString(),
|
endsAt: (e.endsAt ?? e.startsAt).toISOString(),
|
||||||
note: e.note,
|
note: e.note,
|
||||||
|
isArchived: e.isArchived,
|
||||||
contact: e.contact?.name ?? null,
|
contact: e.contact?.name ?? null,
|
||||||
})),
|
})),
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
returned: items.length,
|
||||||
|
hasMore: items.length === limit,
|
||||||
|
nextOffset: offset + items.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,11 +1145,13 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
if (raw.action === "discard_changes") {
|
if (raw.action === "discard_changes") {
|
||||||
const discarded = pendingChanges.length;
|
const discarded = pendingChanges.length;
|
||||||
pendingChanges.splice(0, pendingChanges.length);
|
pendingChanges.splice(0, pendingChanges.length);
|
||||||
|
invalidateReadCache();
|
||||||
return JSON.stringify({ ok: true, discarded }, null, 2);
|
return JSON.stringify({ ok: true, discarded }, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.action === "commit_changes") {
|
if (raw.action === "commit_changes") {
|
||||||
const committed = await applyPendingChanges();
|
const committed = await applyPendingChanges();
|
||||||
|
invalidateReadCache();
|
||||||
return JSON.stringify(committed, null, 2);
|
return JSON.stringify(committed, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,9 +1172,11 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
contactName: contact.name,
|
contactName: contact.name,
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
|
invalidateReadCache();
|
||||||
|
|
||||||
if (raw.mode === "apply") {
|
if (raw.mode === "apply") {
|
||||||
const committed = await applyPendingChanges();
|
const committed = await applyPendingChanges();
|
||||||
|
invalidateReadCache();
|
||||||
return JSON.stringify(committed, null, 2);
|
return JSON.stringify(committed, null, 2);
|
||||||
}
|
}
|
||||||
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
@@ -755,9 +1190,15 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
|
|
||||||
const end = raw.end ? new Date(raw.end) : null;
|
const end = raw.end ? new Date(raw.end) : null;
|
||||||
const contactName = (raw.contact ?? "").trim();
|
const contactName = (raw.contact ?? "").trim();
|
||||||
const contact = contactName
|
const contactId = (raw.contactId ?? "").trim();
|
||||||
? await resolveContact(input.teamId, contactName)
|
const contact =
|
||||||
: null;
|
(contactId
|
||||||
|
? await prisma.contact.findFirst({
|
||||||
|
where: { id: contactId, teamId: input.teamId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
: null) ||
|
||||||
|
(contactName ? await resolveContact(input.teamId, contactName) : null);
|
||||||
|
|
||||||
pendingChanges.push({
|
pendingChanges.push({
|
||||||
id: makeId("chg"),
|
id: makeId("chg"),
|
||||||
@@ -771,14 +1212,63 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
note: (raw.note ?? "").trim() || null,
|
note: (raw.note ?? "").trim() || null,
|
||||||
isArchived: Boolean(raw.archived),
|
isArchived: Boolean(raw.archived),
|
||||||
});
|
});
|
||||||
|
invalidateReadCache();
|
||||||
|
|
||||||
if (raw.mode === "apply") {
|
if (raw.mode === "apply") {
|
||||||
const committed = await applyPendingChanges();
|
const committed = await applyPendingChanges();
|
||||||
|
invalidateReadCache();
|
||||||
return JSON.stringify(committed, null, 2);
|
return JSON.stringify(committed, null, 2);
|
||||||
}
|
}
|
||||||
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw.action === "create_events_batch") {
|
||||||
|
const batch = Array.isArray(raw.events) ? raw.events : [];
|
||||||
|
if (!batch.length) throw new Error("events is required");
|
||||||
|
const capped = batch.slice(0, 200);
|
||||||
|
|
||||||
|
for (const item of capped) {
|
||||||
|
const title = (item.title ?? "").trim();
|
||||||
|
const start = item.start ? new Date(item.start) : null;
|
||||||
|
if (!title) throw new Error("events[].title is required");
|
||||||
|
if (!start || Number.isNaN(start.getTime())) throw new Error("events[].start is invalid");
|
||||||
|
|
||||||
|
const end = item.end ? new Date(item.end) : null;
|
||||||
|
const contactId = (item.contactId ?? "").trim();
|
||||||
|
const contactName = (item.contact ?? "").trim();
|
||||||
|
const contact =
|
||||||
|
(contactId
|
||||||
|
? await prisma.contact.findFirst({
|
||||||
|
where: { id: contactId, teamId: input.teamId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
: null) ||
|
||||||
|
(contactName ? await resolveContact(input.teamId, contactName) : null);
|
||||||
|
if (contactId && !contact) throw new Error(`contact not found: ${contactId}`);
|
||||||
|
|
||||||
|
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: (item.note ?? "").trim() || null,
|
||||||
|
isArchived: Boolean(item.archived),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
invalidateReadCache();
|
||||||
|
|
||||||
|
if (raw.mode === "apply") {
|
||||||
|
const committed = await applyPendingChanges();
|
||||||
|
invalidateReadCache();
|
||||||
|
return JSON.stringify(committed, null, 2);
|
||||||
|
}
|
||||||
|
return JSON.stringify({ ok: true, staged: capped.length, pending: pendingChanges.length }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.action === "create_message") {
|
if (raw.action === "create_message") {
|
||||||
const contactName = (raw.contact ?? "").trim();
|
const contactName = (raw.contact ?? "").trim();
|
||||||
const text = (raw.text ?? "").trim();
|
const text = (raw.text ?? "").trim();
|
||||||
@@ -805,9 +1295,11 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null,
|
durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null,
|
||||||
transcript: Array.isArray(raw.transcript) ? raw.transcript : null,
|
transcript: Array.isArray(raw.transcript) ? raw.transcript : null,
|
||||||
});
|
});
|
||||||
|
invalidateReadCache();
|
||||||
|
|
||||||
if (raw.mode === "apply") {
|
if (raw.mode === "apply") {
|
||||||
const committed = await applyPendingChanges();
|
const committed = await applyPendingChanges();
|
||||||
|
invalidateReadCache();
|
||||||
return JSON.stringify(committed, null, 2);
|
return JSON.stringify(committed, null, 2);
|
||||||
}
|
}
|
||||||
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
@@ -833,9 +1325,11 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
dealTitle: deal.title,
|
dealTitle: deal.title,
|
||||||
stage,
|
stage,
|
||||||
});
|
});
|
||||||
|
invalidateReadCache();
|
||||||
|
|
||||||
if (raw.mode === "apply") {
|
if (raw.mode === "apply") {
|
||||||
const committed = await applyPendingChanges();
|
const committed = await applyPendingChanges();
|
||||||
|
invalidateReadCache();
|
||||||
return JSON.stringify(committed, null, 2);
|
return JSON.stringify(committed, null, 2);
|
||||||
}
|
}
|
||||||
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
@@ -935,10 +1429,16 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
const system = [
|
const system = [
|
||||||
"You are Pilot, a CRM assistant.",
|
"You are Pilot, a CRM assistant.",
|
||||||
"Rules:",
|
"Rules:",
|
||||||
"- Be concrete and concise.",
|
"- 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.",
|
"- 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.",
|
"- You are given a structured CRM JSON snapshot as baseline context.",
|
||||||
"- If you need fresher or narrower data, call crm.get_snapshot/query_* tools.",
|
"- Prefer this data flow to keep context small:",
|
||||||
|
" 1) crm.list_contacts_digest for the roster and prioritization.",
|
||||||
|
" 2) crm.get_contact_snapshot for one focused contact (messages/events/deals/summary in one call).",
|
||||||
|
" 3) crm.get_calendar_window for calendar constraints.",
|
||||||
|
"- Use crm.query_* only for narrow follow-up filters.",
|
||||||
|
"- Avoid repeating identical read calls with the same arguments.",
|
||||||
|
"- If creating many events, prefer crm.create_events_batch instead of many crm.create_event calls.",
|
||||||
"- For changes, stage first with mode=stage. Commit only when user asks to execute.",
|
"- 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.",
|
"- 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.",
|
"- Use pending_changes and commit_changes to control staged updates.",
|
||||||
|
|||||||
Reference in New Issue
Block a user