@nebutra/support-deflector 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nebutra/debug/content-store.jsonl +3 -0
- package/.nebutra/debug/event-log.jsonl +1 -0
- package/.nebutra/debug/support-deflector.jsonl +1 -0
- package/.turbo/turbo-build.log +22 -0
- package/.turbo/turbo-test.log +16 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +676 -0
- package/README.md +14 -0
- package/dist/chunk-OEGPSIYO.js +158 -0
- package/dist/chunk-OEGPSIYO.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +58 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/examples/classify.ts +15 -0
- package/examples/decision.ts +25 -0
- package/examples/quickstart.ts +33 -0
- package/package.json +63 -0
- package/plays/ticket_triage/SKILL.md +36 -0
- package/src/cli.ts +50 -0
- package/src/index.test.ts +106 -0
- package/src/index.ts +273 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parsePlayMarkdown } from "@nebutra/play-loader";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { classifyTicket, decideTicket, readSupportDeflectorDebug, SupportDeflector } from "./index";
|
|
7
|
+
|
|
8
|
+
let root: string | undefined;
|
|
9
|
+
let support: SupportDeflector | undefined;
|
|
10
|
+
|
|
11
|
+
const ticket = {
|
|
12
|
+
id: "ticket_1",
|
|
13
|
+
tenantId: "tenant_a",
|
|
14
|
+
customer: { id: "customer_1", email: "a@example.com", plan: "free" },
|
|
15
|
+
subject: "How do refunds work?",
|
|
16
|
+
body: "Can I get a refund if I cancel this week?",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
const articles = [
|
|
20
|
+
{
|
|
21
|
+
id: "kb_refund",
|
|
22
|
+
title: "Refund policy",
|
|
23
|
+
body: "Refunds are available within 14 days. Contact support and include your account email.",
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
if (support) await support.close();
|
|
29
|
+
if (root) await rm(root, { recursive: true, force: true });
|
|
30
|
+
root = undefined;
|
|
31
|
+
support = undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function open(): Promise<SupportDeflector> {
|
|
35
|
+
root = await mkdtemp(join(tmpdir(), "support-deflector-"));
|
|
36
|
+
support = await SupportDeflector.open(root, { tenantId: "tenant_a" });
|
|
37
|
+
return support;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("support-deflector", () => {
|
|
41
|
+
it("classifies customer tickets conservatively", () => {
|
|
42
|
+
expect(classifyTicket(ticket)).toMatchObject({ category: "billing", sentiment: "neutral" });
|
|
43
|
+
expect(
|
|
44
|
+
classifyTicket({
|
|
45
|
+
...ticket,
|
|
46
|
+
body: "I am angry and this bug broke production for our enterprise team",
|
|
47
|
+
}),
|
|
48
|
+
).toMatchObject({ category: "bug", sentiment: "angry", highValue: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("uses confidence gates to decide auto-answer, suggest, or escalate", () => {
|
|
52
|
+
const auto = decideTicket(ticket, articles, { autoReplyThreshold: 0.82 });
|
|
53
|
+
const escalate = decideTicket(
|
|
54
|
+
{
|
|
55
|
+
...ticket,
|
|
56
|
+
body: "I am angry and this broke production",
|
|
57
|
+
customer: { ...ticket.customer, plan: "enterprise" },
|
|
58
|
+
},
|
|
59
|
+
articles,
|
|
60
|
+
{ autoReplyThreshold: 0.82 },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(auto.action).toBe("auto-answer");
|
|
64
|
+
expect(auto.reply?.body).toContain("Refunds are available within 14 days");
|
|
65
|
+
expect(escalate.action).toBe("escalate");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("runs ticket_triage and records the decision", async () => {
|
|
69
|
+
const runtime = await open();
|
|
70
|
+
|
|
71
|
+
const decision = await runtime.handleTicket({
|
|
72
|
+
ticket,
|
|
73
|
+
articles,
|
|
74
|
+
policy: { autoReplyThreshold: 0.82 },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(decision.play).toBe("ticket_triage");
|
|
78
|
+
expect(decision.action).toBe("auto-answer");
|
|
79
|
+
expect(decision.eventId).toEqual(expect.any(String));
|
|
80
|
+
await expect(readSupportDeflectorDebug(root)).resolves.toEqual(expect.any(Array));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("requires tenant context before persistent operations", async () => {
|
|
84
|
+
const runtime = await open();
|
|
85
|
+
|
|
86
|
+
await expect(
|
|
87
|
+
runtime.handleTicket({
|
|
88
|
+
ticket: { ...ticket, tenantId: "" },
|
|
89
|
+
articles,
|
|
90
|
+
policy: { autoReplyThreshold: 0.82 },
|
|
91
|
+
}),
|
|
92
|
+
).rejects.toMatchObject({
|
|
93
|
+
capability: "support-deflector",
|
|
94
|
+
suggestion: expect.stringContaining("tenantId"),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("keeps ticket triage as SKILL.md instead of a new workflow format", async () => {
|
|
99
|
+
const skill = await readFile(join(process.cwd(), "plays", "ticket_triage", "SKILL.md"), "utf8");
|
|
100
|
+
const play = parsePlayMarkdown(skill);
|
|
101
|
+
|
|
102
|
+
expect(play.meta).toMatchObject({ name: "ticket_triage", kind: "play" });
|
|
103
|
+
expect(play.requiredSkills).toContain("knowledge_base.search");
|
|
104
|
+
expect(play.subAgents.map((agent) => agent.role)).toContain("support_triager");
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { appendCapabilityDebug, readCapabilityDebug } from "@nebutra/capability-kit/debug";
|
|
4
|
+
import { ContentStore } from "@nebutra/content-store";
|
|
5
|
+
import { CapabilityError } from "@nebutra/errors";
|
|
6
|
+
import { EventLog } from "@nebutra/event-log";
|
|
7
|
+
import { assetId } from "@nebutra/generation-context";
|
|
8
|
+
|
|
9
|
+
export type TicketCategory = "billing" | "bug" | "how_to" | "sales" | "other";
|
|
10
|
+
export type TicketSentiment = "neutral" | "angry" | "positive";
|
|
11
|
+
export type SupportAction = "auto-answer" | "suggest-answer" | "escalate";
|
|
12
|
+
|
|
13
|
+
export interface SupportTicket {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly tenantId?: string;
|
|
16
|
+
readonly customer: {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly email: string;
|
|
19
|
+
readonly plan?: string;
|
|
20
|
+
};
|
|
21
|
+
readonly subject: string;
|
|
22
|
+
readonly body: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface KnowledgeArticle {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly title: string;
|
|
28
|
+
readonly body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TicketClassification {
|
|
32
|
+
readonly category: TicketCategory;
|
|
33
|
+
readonly sentiment: TicketSentiment;
|
|
34
|
+
readonly highValue: boolean;
|
|
35
|
+
readonly complaint: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SupportPolicy {
|
|
39
|
+
readonly autoReplyThreshold: number;
|
|
40
|
+
readonly escalateOnComplaint?: boolean;
|
|
41
|
+
readonly escalateOnHighValueCustomer?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SupportReply {
|
|
45
|
+
readonly subject: string;
|
|
46
|
+
readonly body: string;
|
|
47
|
+
readonly citations: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SupportDecision {
|
|
51
|
+
readonly play: "ticket_triage";
|
|
52
|
+
readonly action: SupportAction;
|
|
53
|
+
readonly confidence: number;
|
|
54
|
+
readonly classification: TicketClassification;
|
|
55
|
+
readonly reply?: SupportReply;
|
|
56
|
+
readonly escalationSummary?: string;
|
|
57
|
+
readonly eventId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface HandleTicketInput {
|
|
61
|
+
readonly ticket: SupportTicket;
|
|
62
|
+
readonly articles: readonly KnowledgeArticle[];
|
|
63
|
+
readonly policy: SupportPolicy;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SupportDoctorReport {
|
|
67
|
+
readonly capability: "support-deflector";
|
|
68
|
+
readonly ok: boolean;
|
|
69
|
+
readonly checkedAt: string;
|
|
70
|
+
readonly plays: readonly string[];
|
|
71
|
+
readonly mode: "confidence-gated";
|
|
72
|
+
readonly channels: readonly { readonly provider: string; readonly ok: boolean }[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SupportDeflectorOptions {
|
|
76
|
+
readonly tenantId?: string;
|
|
77
|
+
readonly root?: string;
|
|
78
|
+
readonly debugRoot?: string;
|
|
79
|
+
readonly contentStore?: ContentStore;
|
|
80
|
+
readonly eventLog?: EventLog;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function requireTenant(explicit: string | undefined, fallback: string | undefined): string {
|
|
84
|
+
const tenantId = explicit ?? fallback;
|
|
85
|
+
if (!tenantId?.trim()) {
|
|
86
|
+
throw new CapabilityError("support-deflector", "Support Deflector requires tenant context", {
|
|
87
|
+
suggestion: "Pass tenantId with the ticket or construct SupportDeflector with tenantId.",
|
|
88
|
+
statusCode: 400,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return tenantId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function includesAny(text: string, terms: readonly string[]): boolean {
|
|
95
|
+
return terms.some((term) => text.includes(term));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function classifyTicket(ticket: SupportTicket): TicketClassification {
|
|
99
|
+
const text = `${ticket.subject} ${ticket.body}`.toLowerCase();
|
|
100
|
+
const category: TicketCategory = includesAny(text, ["bug", "broke", "error", "production"])
|
|
101
|
+
? "bug"
|
|
102
|
+
: includesAny(text, ["refund", "invoice", "billing", "cancel"])
|
|
103
|
+
? "billing"
|
|
104
|
+
: includesAny(text, ["how", "setup", "configure"])
|
|
105
|
+
? "how_to"
|
|
106
|
+
: includesAny(text, ["pricing", "demo", "sales"])
|
|
107
|
+
? "sales"
|
|
108
|
+
: "other";
|
|
109
|
+
const angry = includesAny(text, ["angry", "furious", "broken", "terrible", "lawsuit"]);
|
|
110
|
+
const positive = includesAny(text, ["thanks", "great", "love"]);
|
|
111
|
+
const highValue = ticket.customer.plan === "enterprise" || includesAny(text, ["enterprise"]);
|
|
112
|
+
return {
|
|
113
|
+
category,
|
|
114
|
+
sentiment: angry ? "angry" : positive ? "positive" : "neutral",
|
|
115
|
+
highValue,
|
|
116
|
+
complaint: angry || includesAny(text, ["complaint", "refund now", "unacceptable"]),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function matchArticle(
|
|
121
|
+
ticket: SupportTicket,
|
|
122
|
+
articles: readonly KnowledgeArticle[],
|
|
123
|
+
): KnowledgeArticle | undefined {
|
|
124
|
+
const text = `${ticket.subject} ${ticket.body}`.toLowerCase();
|
|
125
|
+
return (
|
|
126
|
+
articles.find((article) =>
|
|
127
|
+
article.title
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.split(/\s+/)
|
|
130
|
+
.filter((term) => term.length > 3)
|
|
131
|
+
.some((term) => text.includes(term)),
|
|
132
|
+
) ??
|
|
133
|
+
articles.find((article) =>
|
|
134
|
+
article.body
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.split(/\W+/)
|
|
137
|
+
.filter((term) => term.length > 6)
|
|
138
|
+
.some((term) => text.includes(term)),
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function synthesizeReply(ticket: SupportTicket, article: KnowledgeArticle): SupportReply {
|
|
144
|
+
const firstSentence = article.body.split(".")[0]?.trim() ?? article.body;
|
|
145
|
+
return {
|
|
146
|
+
subject: `Re: ${ticket.subject}`,
|
|
147
|
+
body: `${firstSentence}. If you want, reply here and we will help with the next step.`,
|
|
148
|
+
citations: [article.id],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function decideTicket(
|
|
153
|
+
ticket: SupportTicket,
|
|
154
|
+
articles: readonly KnowledgeArticle[],
|
|
155
|
+
policy: SupportPolicy,
|
|
156
|
+
): SupportDecision {
|
|
157
|
+
const classification = classifyTicket(ticket);
|
|
158
|
+
const article = matchArticle(ticket, articles);
|
|
159
|
+
const confidence = article ? (classification.category === "other" ? 0.72 : 0.9) : 0.35;
|
|
160
|
+
const shouldEscalate =
|
|
161
|
+
(policy.escalateOnComplaint ?? true) && classification.complaint
|
|
162
|
+
? true
|
|
163
|
+
: (policy.escalateOnHighValueCustomer ?? true) && classification.highValue;
|
|
164
|
+
if (shouldEscalate) {
|
|
165
|
+
return {
|
|
166
|
+
play: "ticket_triage",
|
|
167
|
+
action: "escalate",
|
|
168
|
+
confidence,
|
|
169
|
+
classification,
|
|
170
|
+
...(article ? { reply: synthesizeReply(ticket, article) } : {}),
|
|
171
|
+
escalationSummary: `${ticket.customer.email} needs founder attention for ${classification.category}.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (article && confidence >= policy.autoReplyThreshold) {
|
|
175
|
+
return {
|
|
176
|
+
play: "ticket_triage",
|
|
177
|
+
action: "auto-answer",
|
|
178
|
+
confidence,
|
|
179
|
+
classification,
|
|
180
|
+
reply: synthesizeReply(ticket, article),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
play: "ticket_triage",
|
|
185
|
+
action: "suggest-answer",
|
|
186
|
+
confidence,
|
|
187
|
+
classification,
|
|
188
|
+
...(article ? { reply: synthesizeReply(ticket, article) } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export class SupportDeflector {
|
|
193
|
+
readonly #tenantId: string | undefined;
|
|
194
|
+
readonly #debugRoot: string;
|
|
195
|
+
readonly #contentStore: ContentStore;
|
|
196
|
+
readonly #eventLog: EventLog;
|
|
197
|
+
|
|
198
|
+
private constructor(
|
|
199
|
+
options: SupportDeflectorOptions & { contentStore: ContentStore; eventLog: EventLog },
|
|
200
|
+
) {
|
|
201
|
+
this.#tenantId = options.tenantId;
|
|
202
|
+
this.#debugRoot = options.debugRoot ?? process.cwd();
|
|
203
|
+
this.#contentStore = options.contentStore;
|
|
204
|
+
this.#eventLog = options.eventLog;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
static async open(
|
|
208
|
+
root = ".nebutra/support-deflector",
|
|
209
|
+
options: Omit<SupportDeflectorOptions, "root" | "contentStore" | "eventLog"> = {},
|
|
210
|
+
): Promise<SupportDeflector> {
|
|
211
|
+
const tenantId = options.tenantId ?? "local";
|
|
212
|
+
await mkdir(root, { recursive: true });
|
|
213
|
+
const contentStore = await ContentStore.open(join(root, "content"), { tenantId });
|
|
214
|
+
const eventLog = await EventLog.open(join(root, "event-log"), { tenantId });
|
|
215
|
+
return new SupportDeflector({ ...options, tenantId, root, contentStore, eventLog });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async handleTicket(input: HandleTicketInput): Promise<SupportDecision> {
|
|
219
|
+
const tenantId = requireTenant(input.ticket.tenantId, this.#tenantId);
|
|
220
|
+
const decision = decideTicket(input.ticket, input.articles, input.policy);
|
|
221
|
+
const artifactPath = `support/tickets/${input.ticket.id}.json`;
|
|
222
|
+
const content = `${JSON.stringify({ ticket: input.ticket, decision }, null, 2)}\n`;
|
|
223
|
+
await this.#contentStore.write(artifactPath, content);
|
|
224
|
+
const eventId = await this.#eventLog.commit({
|
|
225
|
+
traceId: assetId("support_ticket", input.ticket.id),
|
|
226
|
+
kind: "content_write",
|
|
227
|
+
affected: [artifactPath],
|
|
228
|
+
parent: null,
|
|
229
|
+
snapshot: { [artifactPath]: content },
|
|
230
|
+
});
|
|
231
|
+
await this.#debug({
|
|
232
|
+
type: "ticket_decision",
|
|
233
|
+
tenantId,
|
|
234
|
+
ticketId: input.ticket.id,
|
|
235
|
+
action: decision.action,
|
|
236
|
+
eventId,
|
|
237
|
+
});
|
|
238
|
+
return { ...decision, eventId };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async doctor(): Promise<SupportDoctorReport> {
|
|
242
|
+
return {
|
|
243
|
+
capability: "support-deflector",
|
|
244
|
+
ok: true,
|
|
245
|
+
checkedAt: new Date().toISOString(),
|
|
246
|
+
plays: ["ticket_triage"],
|
|
247
|
+
mode: "confidence-gated",
|
|
248
|
+
channels: [
|
|
249
|
+
{ provider: "local-ticket", ok: true },
|
|
250
|
+
{ provider: "chatwoot-bridge", ok: false },
|
|
251
|
+
{ provider: "email-bridge", ok: false },
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async close(): Promise<void> {
|
|
257
|
+
await this.#contentStore.close();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async #debug(entry: Record<string, unknown>): Promise<void> {
|
|
261
|
+
await mkdir(dirname(join(this.#debugRoot, ".nebutra", "debug", "support-deflector.jsonl")), {
|
|
262
|
+
recursive: true,
|
|
263
|
+
});
|
|
264
|
+
await appendCapabilityDebug("support-deflector", entry, { root: this.#debugRoot });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function readSupportDeflectorDebug(
|
|
269
|
+
root = process.cwd(),
|
|
270
|
+
limit = 20,
|
|
271
|
+
): Promise<unknown[]> {
|
|
272
|
+
return readCapabilityDebug("support-deflector", { root, limit });
|
|
273
|
+
}
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED