@nebutra/support-deflector 0.1.2 → 0.1.4
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/CHANGELOG.md +28 -0
- package/LICENSE +21 -676
- package/package.json +21 -11
- package/.nebutra/debug/content-store.jsonl +0 -3
- package/.nebutra/debug/event-log.jsonl +0 -1
- package/.nebutra/debug/support-deflector.jsonl +0 -1
- package/.turbo/turbo-build.log +0 -22
- package/.turbo/turbo-test.log +0 -15
- package/.turbo/turbo-typecheck.log +0 -4
- package/examples/classify.ts +0 -15
- package/examples/decision.ts +0 -25
- package/examples/quickstart.ts +0 -33
- package/plays/ticket_triage/SKILL.md +0 -36
- package/src/cli.ts +0 -50
- package/src/index.test.ts +0 -106
- package/src/index.ts +0 -271
- package/tsconfig.json +0 -9
- package/tsup.config.ts +0 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/support-deflector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Confidence-gated customer support Play package for founder-owned support automation",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,21 +23,25 @@
|
|
|
23
23
|
"category": "ai",
|
|
24
24
|
"summary": "Support Play product that triages tickets into auto-answer, suggest, or escalate decisions"
|
|
25
25
|
},
|
|
26
|
-
"main": "./
|
|
27
|
-
"types": "./
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
28
|
"exports": {
|
|
29
|
-
".":
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
30
34
|
},
|
|
31
35
|
"dependencies": {
|
|
32
|
-
"@nebutra/
|
|
33
|
-
"@nebutra/
|
|
34
|
-
"@nebutra/
|
|
35
|
-
"@nebutra/
|
|
36
|
-
"@nebutra/
|
|
37
|
-
"@nebutra/
|
|
36
|
+
"@nebutra/content-store": "0.1.4",
|
|
37
|
+
"@nebutra/event-log": "0.1.4",
|
|
38
|
+
"@nebutra/capability-kit": "0.2.4",
|
|
39
|
+
"@nebutra/play-loader": "0.1.3",
|
|
40
|
+
"@nebutra/errors": "0.1.2",
|
|
41
|
+
"@nebutra/generation-context": "0.1.3"
|
|
38
42
|
},
|
|
39
43
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^
|
|
44
|
+
"@types/node": "^25.9.1",
|
|
41
45
|
"tsup": "^8.5.1",
|
|
42
46
|
"tsx": "^4.21.0",
|
|
43
47
|
"typescript": "^5.9.3",
|
|
@@ -55,6 +59,12 @@
|
|
|
55
59
|
"publishConfig": {
|
|
56
60
|
"access": "public"
|
|
57
61
|
},
|
|
62
|
+
"files": [
|
|
63
|
+
"dist",
|
|
64
|
+
"README.md",
|
|
65
|
+
"LICENSE",
|
|
66
|
+
"CHANGELOG.md"
|
|
67
|
+
],
|
|
58
68
|
"scripts": {
|
|
59
69
|
"build": "tsup",
|
|
60
70
|
"test": "vitest run",
|
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
{"at":"2026-05-19T08:15:24.961Z","type":"reindex","tenantId":"tenant_a","files":0}
|
|
2
|
-
{"at":"2026-05-19T08:15:25.012Z","type":"write","tenantId":"tenant_a","path":"support/tickets/ticket_1.json"}
|
|
3
|
-
{"at":"2026-05-19T08:15:25.135Z","type":"reindex","tenantId":"tenant_a","files":0}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"at":"2026-05-19T08:15:25.029Z","traceId":"support_ticket_ticket_1_mpccyfyt","kind":"content_write","affected":["support/tickets/ticket_1.json"],"parent":null,"snapshot":{"support/tickets/ticket_1.json":"{\n \"ticket\": {\n \"id\": \"ticket_1\",\n \"tenantId\": \"tenant_a\",\n \"customer\": {\n \"id\": \"customer_1\",\n \"email\": \"a@example.com\",\n \"plan\": \"free\"\n },\n \"subject\": \"How do refunds work?\",\n \"body\": \"Can I get a refund if I cancel this week?\"\n },\n \"decision\": {\n \"play\": \"ticket_triage\",\n \"action\": \"auto-answer\",\n \"confidence\": 0.9,\n \"classification\": {\n \"category\": \"billing\",\n \"sentiment\": \"neutral\",\n \"highValue\": false,\n \"complaint\": false\n },\n \"reply\": {\n \"subject\": \"Re: How do refunds work?\",\n \"body\": \"Refunds are available within 14 days. If you want, reply here and we will help with the next step.\",\n \"citations\": [\n \"kb_refund\"\n ]\n }\n }\n}\n"},"id":"85337bfc5848725ef392012dc2a6e07585d8bcb6e352e97b5abbae05c60cdd71","tenantId":"tenant_a","summary":"content_write touched 1 path(s)","objectHashes":["e79f9ed0f7be6b3229b5eff19a9f5e242fb2575fe56c38dfdda06bf56d882706"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"at":"2026-05-19T08:15:25.043Z","type":"ticket_decision","tenantId":"tenant_a","ticketId":"ticket_1","action":"auto-answer","eventId":"85337bfc5848725ef392012dc2a6e07585d8bcb6e352e97b5abbae05c60cdd71"}
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @nebutra/support-deflector@0.1.2 build /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/ai/support-deflector
|
|
3
|
-
> tsup
|
|
4
|
-
|
|
5
|
-
[34mCLI[39m Building entry: src/cli.ts, src/index.ts
|
|
6
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
7
|
-
[34mCLI[39m tsup v8.5.1
|
|
8
|
-
[34mCLI[39m Using tsup config: /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/ai/support-deflector/tsup.config.ts
|
|
9
|
-
[34mCLI[39m Target: es2022
|
|
10
|
-
[34mCLI[39m Cleaning output folder
|
|
11
|
-
[34mESM[39m Build start
|
|
12
|
-
[32mESM[39m [1mdist/cli.js [22m[32m1.65 KB[39m
|
|
13
|
-
[32mESM[39m [1mdist/index.js [22m[32m248.00 B[39m
|
|
14
|
-
[32mESM[39m [1mdist/chunk-EBB2GRYR.js [22m[32m5.97 KB[39m
|
|
15
|
-
[32mESM[39m [1mdist/cli.js.map [22m[32m2.80 KB[39m
|
|
16
|
-
[32mESM[39m [1mdist/index.js.map [22m[32m71.00 B[39m
|
|
17
|
-
[32mESM[39m [1mdist/chunk-EBB2GRYR.js.map [22m[32m13.22 KB[39m
|
|
18
|
-
[32mESM[39m ⚡️ Build success in 89ms
|
|
19
|
-
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in 9998ms
|
|
21
|
-
[32mDTS[39m [1mdist/cli.d.ts [22m[32m13.00 B[39m
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m3.08 KB[39m
|
package/.turbo/turbo-test.log
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @nebutra/support-deflector@0.1.2 test /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/ai/support-deflector
|
|
3
|
-
> vitest run
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
[1m[30m[46m RUN [49m[39m[22m [36mv4.1.4 [39m[90m/home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/ai/support-deflector[39m
|
|
7
|
-
|
|
8
|
-
[32m✓[39m src/index.test.ts [2m([22m[2m5 tests[22m[2m)[22m[33m 517[2mms[22m[39m
|
|
9
|
-
[33m[2m✓[22m[39m runs ticket_triage and records the decision [33m 318[2mms[22m[39m
|
|
10
|
-
|
|
11
|
-
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
12
|
-
[2m Tests [22m [1m[32m5 passed[39m[22m[90m (5)[39m
|
|
13
|
-
[2m Start at [22m 08:15:22
|
|
14
|
-
[2m Duration [22m 3.08s[2m (transform 1.25s, setup 0ms, import 1.68s, tests 517ms, environment 0ms)[22m
|
|
15
|
-
|
package/examples/classify.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { classifyTicket } from "../src/index";
|
|
2
|
-
|
|
3
|
-
process.stdout.write(
|
|
4
|
-
`${JSON.stringify(
|
|
5
|
-
classifyTicket({
|
|
6
|
-
id: "ticket_1",
|
|
7
|
-
tenantId: "local",
|
|
8
|
-
customer: { id: "customer_1", email: "a@example.com", plan: "enterprise" },
|
|
9
|
-
subject: "Production bug",
|
|
10
|
-
body: "I am angry because this broke production.",
|
|
11
|
-
}),
|
|
12
|
-
null,
|
|
13
|
-
2,
|
|
14
|
-
)}\n`,
|
|
15
|
-
);
|
package/examples/decision.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { decideTicket } from "../src/index";
|
|
2
|
-
|
|
3
|
-
process.stdout.write(
|
|
4
|
-
`${JSON.stringify(
|
|
5
|
-
decideTicket(
|
|
6
|
-
{
|
|
7
|
-
id: "ticket_1",
|
|
8
|
-
tenantId: "local",
|
|
9
|
-
customer: { id: "customer_1", email: "a@example.com", plan: "free" },
|
|
10
|
-
subject: "Refund",
|
|
11
|
-
body: "How do refunds work?",
|
|
12
|
-
},
|
|
13
|
-
[
|
|
14
|
-
{
|
|
15
|
-
id: "kb_refund",
|
|
16
|
-
title: "Refund policy",
|
|
17
|
-
body: "Refunds are available within 14 days.",
|
|
18
|
-
},
|
|
19
|
-
],
|
|
20
|
-
{ autoReplyThreshold: 0.82 },
|
|
21
|
-
),
|
|
22
|
-
null,
|
|
23
|
-
2,
|
|
24
|
-
)}\n`,
|
|
25
|
-
);
|
package/examples/quickstart.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { SupportDeflector } from "../src/index";
|
|
2
|
-
|
|
3
|
-
const support = await SupportDeflector.open(".nebutra/support-deflector-example", {
|
|
4
|
-
tenantId: "local",
|
|
5
|
-
});
|
|
6
|
-
|
|
7
|
-
try {
|
|
8
|
-
process.stdout.write(
|
|
9
|
-
`${JSON.stringify(
|
|
10
|
-
await support.handleTicket({
|
|
11
|
-
ticket: {
|
|
12
|
-
id: "ticket_demo",
|
|
13
|
-
tenantId: "local",
|
|
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
|
-
},
|
|
18
|
-
articles: [
|
|
19
|
-
{
|
|
20
|
-
id: "kb_refund",
|
|
21
|
-
title: "Refund policy",
|
|
22
|
-
body: "Refunds are available within 14 days. Contact support with your account email.",
|
|
23
|
-
},
|
|
24
|
-
],
|
|
25
|
-
policy: { autoReplyThreshold: 0.82 },
|
|
26
|
-
}),
|
|
27
|
-
null,
|
|
28
|
-
2,
|
|
29
|
-
)}\n`,
|
|
30
|
-
);
|
|
31
|
-
} finally {
|
|
32
|
-
await support.close();
|
|
33
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ticket_triage
|
|
3
|
-
kind: play
|
|
4
|
-
version: 1.0.0
|
|
5
|
-
description: Triage a support ticket into auto-answer, suggest-answer, or escalate
|
|
6
|
-
inputs:
|
|
7
|
-
ticket: { type: object }
|
|
8
|
-
support_policy: { type: object }
|
|
9
|
-
outputs:
|
|
10
|
-
decision: { type: file, path: support/tickets/ticket.json, mime: application/json }
|
|
11
|
-
budget:
|
|
12
|
-
duration_s: 30
|
|
13
|
-
cost_usd: 0.2
|
|
14
|
-
required_skills:
|
|
15
|
-
- knowledge_base.search
|
|
16
|
-
- content_store.write
|
|
17
|
-
- event_log.commit
|
|
18
|
-
sub_agents:
|
|
19
|
-
- role: support_triager
|
|
20
|
-
allowed_skills: [knowledge_base.search, content_store.write]
|
|
21
|
-
- role: escalation_summarizer
|
|
22
|
-
allowed_skills: [content_store.write]
|
|
23
|
-
depends_on_plays: []
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
## What this play does
|
|
27
|
-
|
|
28
|
-
Classifies a new customer-support ticket, finds relevant knowledge, then returns
|
|
29
|
-
a confidence-gated decision.
|
|
30
|
-
|
|
31
|
-
## Rules
|
|
32
|
-
|
|
33
|
-
- Escalate angry, complaint, or high-value customer tickets.
|
|
34
|
-
- Auto-answer only when confidence exceeds policy threshold.
|
|
35
|
-
- Keep the founder in control of medium-confidence replies.
|
|
36
|
-
- Redact personally sensitive data before trace-store emission.
|
package/src/cli.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { readSupportDeflectorDebug, SupportDeflector } from "./index";
|
|
2
|
-
|
|
3
|
-
const command = process.argv[2] ?? "doctor";
|
|
4
|
-
const root = process.env.SUPPORT_DEFLECTOR_ROOT ?? ".nebutra/support-deflector";
|
|
5
|
-
const tenantId = process.env.NEBUTRA_TENANT_ID ?? "local";
|
|
6
|
-
|
|
7
|
-
if (command === "doctor") {
|
|
8
|
-
const support = await SupportDeflector.open(root, { tenantId });
|
|
9
|
-
try {
|
|
10
|
-
process.stdout.write(`${JSON.stringify(await support.doctor(), null, 2)}\n`);
|
|
11
|
-
} finally {
|
|
12
|
-
await support.close();
|
|
13
|
-
}
|
|
14
|
-
} else if (command === "quickstart") {
|
|
15
|
-
const support = await SupportDeflector.open(root, { tenantId });
|
|
16
|
-
try {
|
|
17
|
-
process.stdout.write(
|
|
18
|
-
`${JSON.stringify(
|
|
19
|
-
await support.handleTicket({
|
|
20
|
-
ticket: {
|
|
21
|
-
id: "ticket_demo",
|
|
22
|
-
tenantId,
|
|
23
|
-
customer: { id: "customer_demo", email: "demo@example.com", plan: "free" },
|
|
24
|
-
subject: "How do refunds work?",
|
|
25
|
-
body: "Can I get a refund if I cancel this week?",
|
|
26
|
-
},
|
|
27
|
-
articles: [
|
|
28
|
-
{
|
|
29
|
-
id: "kb_refund",
|
|
30
|
-
title: "Refund policy",
|
|
31
|
-
body: "Refunds are available within 14 days. Contact support with your account email.",
|
|
32
|
-
},
|
|
33
|
-
],
|
|
34
|
-
policy: { autoReplyThreshold: 0.82 },
|
|
35
|
-
}),
|
|
36
|
-
null,
|
|
37
|
-
2,
|
|
38
|
-
)}\n`,
|
|
39
|
-
);
|
|
40
|
-
} finally {
|
|
41
|
-
await support.close();
|
|
42
|
-
}
|
|
43
|
-
} else if (command === "debug") {
|
|
44
|
-
process.stdout.write(
|
|
45
|
-
`${JSON.stringify({ capability: "support-deflector", entries: await readSupportDeflectorDebug() }, null, 2)}\n`,
|
|
46
|
-
);
|
|
47
|
-
} else {
|
|
48
|
-
process.stderr.write(`Unknown support-deflector command: ${command}\n`);
|
|
49
|
-
process.exitCode = 1;
|
|
50
|
-
}
|
package/src/index.test.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { requireCapabilityTenant } from "@nebutra/capability-kit";
|
|
4
|
-
import { appendCapabilityDebug, readCapabilityDebug } from "@nebutra/capability-kit/debug";
|
|
5
|
-
import { ContentStore } from "@nebutra/content-store";
|
|
6
|
-
import { CapabilityError } from "@nebutra/errors";
|
|
7
|
-
import { EventLog } from "@nebutra/event-log";
|
|
8
|
-
import { assetId } from "@nebutra/generation-context";
|
|
9
|
-
|
|
10
|
-
export type TicketCategory = "billing" | "bug" | "how_to" | "sales" | "other";
|
|
11
|
-
export type TicketSentiment = "neutral" | "angry" | "positive";
|
|
12
|
-
export type SupportAction = "auto-answer" | "suggest-answer" | "escalate";
|
|
13
|
-
|
|
14
|
-
export interface SupportTicket {
|
|
15
|
-
readonly id: string;
|
|
16
|
-
readonly tenantId?: string;
|
|
17
|
-
readonly customer: {
|
|
18
|
-
readonly id: string;
|
|
19
|
-
readonly email: string;
|
|
20
|
-
readonly plan?: string;
|
|
21
|
-
};
|
|
22
|
-
readonly subject: string;
|
|
23
|
-
readonly body: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface KnowledgeArticle {
|
|
27
|
-
readonly id: string;
|
|
28
|
-
readonly title: string;
|
|
29
|
-
readonly body: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface TicketClassification {
|
|
33
|
-
readonly category: TicketCategory;
|
|
34
|
-
readonly sentiment: TicketSentiment;
|
|
35
|
-
readonly highValue: boolean;
|
|
36
|
-
readonly complaint: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface SupportPolicy {
|
|
40
|
-
readonly autoReplyThreshold: number;
|
|
41
|
-
readonly escalateOnComplaint?: boolean;
|
|
42
|
-
readonly escalateOnHighValueCustomer?: boolean;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface SupportReply {
|
|
46
|
-
readonly subject: string;
|
|
47
|
-
readonly body: string;
|
|
48
|
-
readonly citations: readonly string[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface SupportDecision {
|
|
52
|
-
readonly play: "ticket_triage";
|
|
53
|
-
readonly action: SupportAction;
|
|
54
|
-
readonly confidence: number;
|
|
55
|
-
readonly classification: TicketClassification;
|
|
56
|
-
readonly reply?: SupportReply;
|
|
57
|
-
readonly escalationSummary?: string;
|
|
58
|
-
readonly eventId?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface HandleTicketInput {
|
|
62
|
-
readonly ticket: SupportTicket;
|
|
63
|
-
readonly articles: readonly KnowledgeArticle[];
|
|
64
|
-
readonly policy: SupportPolicy;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface SupportDoctorReport {
|
|
68
|
-
readonly capability: "support-deflector";
|
|
69
|
-
readonly ok: boolean;
|
|
70
|
-
readonly checkedAt: string;
|
|
71
|
-
readonly plays: readonly string[];
|
|
72
|
-
readonly mode: "confidence-gated";
|
|
73
|
-
readonly channels: readonly { readonly provider: string; readonly ok: boolean }[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface SupportDeflectorOptions {
|
|
77
|
-
readonly tenantId?: string;
|
|
78
|
-
readonly root?: string;
|
|
79
|
-
readonly debugRoot?: string;
|
|
80
|
-
readonly contentStore?: ContentStore;
|
|
81
|
-
readonly eventLog?: EventLog;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function includesAny(text: string, terms: readonly string[]): boolean {
|
|
85
|
-
return terms.some((term) => text.includes(term));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function classifyTicket(ticket: SupportTicket): TicketClassification {
|
|
89
|
-
const text = `${ticket.subject} ${ticket.body}`.toLowerCase();
|
|
90
|
-
const category: TicketCategory = includesAny(text, ["bug", "broke", "error", "production"])
|
|
91
|
-
? "bug"
|
|
92
|
-
: includesAny(text, ["refund", "invoice", "billing", "cancel"])
|
|
93
|
-
? "billing"
|
|
94
|
-
: includesAny(text, ["how", "setup", "configure"])
|
|
95
|
-
? "how_to"
|
|
96
|
-
: includesAny(text, ["pricing", "demo", "sales"])
|
|
97
|
-
? "sales"
|
|
98
|
-
: "other";
|
|
99
|
-
const angry = includesAny(text, ["angry", "furious", "broken", "terrible", "lawsuit"]);
|
|
100
|
-
const positive = includesAny(text, ["thanks", "great", "love"]);
|
|
101
|
-
const highValue = ticket.customer.plan === "enterprise" || includesAny(text, ["enterprise"]);
|
|
102
|
-
return {
|
|
103
|
-
category,
|
|
104
|
-
sentiment: angry ? "angry" : positive ? "positive" : "neutral",
|
|
105
|
-
highValue,
|
|
106
|
-
complaint: angry || includesAny(text, ["complaint", "refund now", "unacceptable"]),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function matchArticle(
|
|
111
|
-
ticket: SupportTicket,
|
|
112
|
-
articles: readonly KnowledgeArticle[],
|
|
113
|
-
): KnowledgeArticle | undefined {
|
|
114
|
-
const text = `${ticket.subject} ${ticket.body}`.toLowerCase();
|
|
115
|
-
return (
|
|
116
|
-
articles.find((article) =>
|
|
117
|
-
article.title
|
|
118
|
-
.toLowerCase()
|
|
119
|
-
.split(/\s+/)
|
|
120
|
-
.filter((term) => term.length > 3)
|
|
121
|
-
.some((term) => text.includes(term)),
|
|
122
|
-
) ??
|
|
123
|
-
articles.find((article) =>
|
|
124
|
-
article.body
|
|
125
|
-
.toLowerCase()
|
|
126
|
-
.split(/\W+/)
|
|
127
|
-
.filter((term) => term.length > 6)
|
|
128
|
-
.some((term) => text.includes(term)),
|
|
129
|
-
)
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function synthesizeReply(ticket: SupportTicket, article: KnowledgeArticle): SupportReply {
|
|
134
|
-
const firstSentence = article.body.split(".")[0]?.trim() ?? article.body;
|
|
135
|
-
return {
|
|
136
|
-
subject: `Re: ${ticket.subject}`,
|
|
137
|
-
body: `${firstSentence}. If you want, reply here and we will help with the next step.`,
|
|
138
|
-
citations: [article.id],
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function decideTicket(
|
|
143
|
-
ticket: SupportTicket,
|
|
144
|
-
articles: readonly KnowledgeArticle[],
|
|
145
|
-
policy: SupportPolicy,
|
|
146
|
-
): SupportDecision {
|
|
147
|
-
const classification = classifyTicket(ticket);
|
|
148
|
-
const article = matchArticle(ticket, articles);
|
|
149
|
-
const confidence = article ? (classification.category === "other" ? 0.72 : 0.9) : 0.35;
|
|
150
|
-
const shouldEscalate =
|
|
151
|
-
(policy.escalateOnComplaint ?? true) && classification.complaint
|
|
152
|
-
? true
|
|
153
|
-
: (policy.escalateOnHighValueCustomer ?? true) && classification.highValue;
|
|
154
|
-
if (shouldEscalate) {
|
|
155
|
-
return {
|
|
156
|
-
play: "ticket_triage",
|
|
157
|
-
action: "escalate",
|
|
158
|
-
confidence,
|
|
159
|
-
classification,
|
|
160
|
-
...(article ? { reply: synthesizeReply(ticket, article) } : {}),
|
|
161
|
-
escalationSummary: `${ticket.customer.email} needs founder attention for ${classification.category}.`,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
if (article && confidence >= policy.autoReplyThreshold) {
|
|
165
|
-
return {
|
|
166
|
-
play: "ticket_triage",
|
|
167
|
-
action: "auto-answer",
|
|
168
|
-
confidence,
|
|
169
|
-
classification,
|
|
170
|
-
reply: synthesizeReply(ticket, article),
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
return {
|
|
174
|
-
play: "ticket_triage",
|
|
175
|
-
action: "suggest-answer",
|
|
176
|
-
confidence,
|
|
177
|
-
classification,
|
|
178
|
-
...(article ? { reply: synthesizeReply(ticket, article) } : {}),
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export class SupportDeflector {
|
|
183
|
-
readonly #tenantId: string | undefined;
|
|
184
|
-
readonly #debugRoot: string;
|
|
185
|
-
readonly #contentStore: ContentStore;
|
|
186
|
-
readonly #eventLog: EventLog;
|
|
187
|
-
|
|
188
|
-
private constructor(
|
|
189
|
-
options: SupportDeflectorOptions & { contentStore: ContentStore; eventLog: EventLog },
|
|
190
|
-
) {
|
|
191
|
-
this.#tenantId = options.tenantId;
|
|
192
|
-
this.#debugRoot = options.debugRoot ?? process.cwd();
|
|
193
|
-
this.#contentStore = options.contentStore;
|
|
194
|
-
this.#eventLog = options.eventLog;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
static async open(
|
|
198
|
-
root = ".nebutra/support-deflector",
|
|
199
|
-
options: Omit<SupportDeflectorOptions, "root" | "contentStore" | "eventLog"> = {},
|
|
200
|
-
): Promise<SupportDeflector> {
|
|
201
|
-
const tenantId = options.tenantId ?? "local";
|
|
202
|
-
await mkdir(root, { recursive: true });
|
|
203
|
-
const contentStore = await ContentStore.open(join(root, "content"), { tenantId });
|
|
204
|
-
const eventLog = await EventLog.open(join(root, "event-log"), { tenantId });
|
|
205
|
-
return new SupportDeflector({ ...options, tenantId, root, contentStore, eventLog });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async handleTicket(input: HandleTicketInput): Promise<SupportDecision> {
|
|
209
|
-
const tenantId = requireCapabilityTenant({
|
|
210
|
-
explicit: input.ticket.tenantId,
|
|
211
|
-
fallback: this.#tenantId,
|
|
212
|
-
onMissing: () =>
|
|
213
|
-
new CapabilityError("support-deflector", "Support Deflector requires tenant context", {
|
|
214
|
-
suggestion: "Pass tenantId with the ticket or construct SupportDeflector with tenantId.",
|
|
215
|
-
statusCode: 400,
|
|
216
|
-
}),
|
|
217
|
-
});
|
|
218
|
-
const decision = decideTicket(input.ticket, input.articles, input.policy);
|
|
219
|
-
const artifactPath = `support/tickets/${input.ticket.id}.json`;
|
|
220
|
-
const content = `${JSON.stringify({ ticket: input.ticket, decision }, null, 2)}\n`;
|
|
221
|
-
await this.#contentStore.write(artifactPath, content);
|
|
222
|
-
const eventId = await this.#eventLog.commit({
|
|
223
|
-
traceId: assetId("support_ticket", input.ticket.id),
|
|
224
|
-
kind: "content_write",
|
|
225
|
-
affected: [artifactPath],
|
|
226
|
-
parent: null,
|
|
227
|
-
snapshot: { [artifactPath]: content },
|
|
228
|
-
});
|
|
229
|
-
await this.#debug({
|
|
230
|
-
type: "ticket_decision",
|
|
231
|
-
tenantId,
|
|
232
|
-
ticketId: input.ticket.id,
|
|
233
|
-
action: decision.action,
|
|
234
|
-
eventId,
|
|
235
|
-
});
|
|
236
|
-
return { ...decision, eventId };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async doctor(): Promise<SupportDoctorReport> {
|
|
240
|
-
return {
|
|
241
|
-
capability: "support-deflector",
|
|
242
|
-
ok: true,
|
|
243
|
-
checkedAt: new Date().toISOString(),
|
|
244
|
-
plays: ["ticket_triage"],
|
|
245
|
-
mode: "confidence-gated",
|
|
246
|
-
channels: [
|
|
247
|
-
{ provider: "local-ticket", ok: true },
|
|
248
|
-
{ provider: "chatwoot-bridge", ok: false },
|
|
249
|
-
{ provider: "email-bridge", ok: false },
|
|
250
|
-
],
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async close(): Promise<void> {
|
|
255
|
-
await this.#contentStore.close();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async #debug(entry: Record<string, unknown>): Promise<void> {
|
|
259
|
-
await mkdir(dirname(join(this.#debugRoot, ".nebutra", "debug", "support-deflector.jsonl")), {
|
|
260
|
-
recursive: true,
|
|
261
|
-
});
|
|
262
|
-
await appendCapabilityDebug("support-deflector", entry, { root: this.#debugRoot });
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export async function readSupportDeflectorDebug(
|
|
267
|
-
root = process.cwd(),
|
|
268
|
-
limit = 20,
|
|
269
|
-
): Promise<unknown[]> {
|
|
270
|
-
return readCapabilityDebug("support-deflector", { root, limit });
|
|
271
|
-
}
|
package/tsconfig.json
DELETED
package/tsup.config.ts
DELETED