aitable-workflow-cli 0.1.23 → 0.1.24

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.
Files changed (77) hide show
  1. package/README.md +1 -6
  2. package/dist/{chunk-IGB6WDLR.js → chunk-UNPV6DTX.js} +1 -1
  3. package/dist/cli.js +47 -47
  4. package/dist/config-set-FBOQBRSJ.js +13 -0
  5. package/dist/{config-ui-server-CORDAOYI.js → config-ui-server-IUSACAOT.js} +1 -1
  6. package/package.json +3 -3
  7. package/dist/config-set-EGLLIMVJ.js +0 -13
  8. package/templates/support-qa/.env.example +0 -16
  9. package/templates/support-qa/aitable-workflow.config.yml +0 -46
  10. package/templates/support-qa/coding-agents.config.json +0 -18
  11. package/templates/support-qa/lib/__tests__/declarative-matcher.test.ts +0 -240
  12. package/templates/support-qa/lib/__tests__/dingtalk-group-membership.test.ts +0 -144
  13. package/templates/support-qa/lib/__tests__/memory-events.test.ts +0 -90
  14. package/templates/support-qa/lib/__tests__/sandbox-agent.test.ts +0 -382
  15. package/templates/support-qa/lib/__tests__/thread-adapter.test.ts +0 -242
  16. package/templates/support-qa/lib/__tests__/thread-observe-step.test.ts +0 -391
  17. package/templates/support-qa/lib/__tests__/thread-observer-handler.test.ts +0 -274
  18. package/templates/support-qa/lib/action-dispatch.ts +0 -262
  19. package/templates/support-qa/lib/contract.ts +0 -253
  20. package/templates/support-qa/lib/declarative-matcher.ts +0 -398
  21. package/templates/support-qa/lib/dingtalk-group-membership.ts +0 -158
  22. package/templates/support-qa/lib/guards.ts +0 -18
  23. package/templates/support-qa/lib/memory-events.ts +0 -113
  24. package/templates/support-qa/lib/owner-resolver.ts +0 -94
  25. package/templates/support-qa/lib/preflight.ts +0 -294
  26. package/templates/support-qa/lib/profile.ts +0 -51
  27. package/templates/support-qa/lib/sandbox-agent.ts +0 -653
  28. package/templates/support-qa/lib/thread-adapter.ts +0 -281
  29. package/templates/support-qa/lib/thread-digest.ts +0 -144
  30. package/templates/support-qa/npmrc +0 -1
  31. package/templates/support-qa/package.json +0 -27
  32. package/templates/support-qa/profiles/aitable/profile/escalation/contacts.json +0 -37
  33. package/templates/support-qa/profiles/aitable/profile/escalation/owner-map.json +0 -18
  34. package/templates/support-qa/profiles/aitable/profile/fastpath/known-answers.yml +0 -561
  35. package/templates/support-qa/profiles/aitable/profile/profile.yml +0 -219
  36. package/templates/support-qa/profiles/aitable/profile/prompts/kb-editor.md +0 -3
  37. package/templates/support-qa/profiles/aitable/profile/prompts/persona.md +0 -49
  38. package/templates/support-qa/profiles/aitable/profile/prompts/troubleshooter.md +0 -5
  39. package/templates/support-qa/profiles/aitable/profile/troubleshoot/classifiers.yml +0 -36
  40. package/templates/support-qa/profiles/aitable/skills/troubleshooting/SKILL.md +0 -58
  41. package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-import-troubleshooter/SKILL.md +0 -485
  42. package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-openapi-troubleshooter/SKILL.md +0 -183
  43. package/templates/support-qa/profiles/aitable/skills/troubleshooting/import-openapi-e2e-troubleshooter/SKILL.md +0 -459
  44. package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-datasource-troubleshooter/SKILL.md +0 -1286
  45. package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-field-troubleshooter/SKILL.md +0 -673
  46. package/templates/support-qa/profiles/aitable/skills/troubleshooting/spreadsheet-datasync-troubleshooter/SKILL.md +0 -322
  47. package/templates/support-qa/profiles/default/profile/fastpath/known-answers.yml +0 -32
  48. package/templates/support-qa/profiles/default/profile/profile.yml +0 -217
  49. package/templates/support-qa/profiles/default/profile/troubleshoot/classifiers.yml +0 -37
  50. package/templates/support-qa/profiles/default/wiki/support//345/270/270/350/247/201/351/227/256/351/242/230.md +0 -10
  51. package/templates/support-qa/tsconfig.json +0 -16
  52. package/templates/support-qa/vitest.config.ts +0 -9
  53. package/templates/support-qa/workflows/admin-kb-update/recipes/kb-refine.recipe.ts +0 -137
  54. package/templates/support-qa/workflows/admin-kb-update/recipes/kb_sync_handler.recipe.ts +0 -119
  55. package/templates/support-qa/workflows/admin-kb-update/workflow.yml +0 -96
  56. package/templates/support-qa/workflows/config-sync/recipes/config_sync.recipe.ts +0 -668
  57. package/templates/support-qa/workflows/config-sync/recipes/config_sync_handler.recipe.ts +0 -16
  58. package/templates/support-qa/workflows/config-sync/workflow.yml +0 -62
  59. package/templates/support-qa/workflows/daily-review/recipes/daily_review.recipe.ts +0 -602
  60. package/templates/support-qa/workflows/daily-review/recipes/daily_review_handler.recipe.ts +0 -26
  61. package/templates/support-qa/workflows/daily-review/workflow.yml +0 -65
  62. package/templates/support-qa/workflows/group-qa/recipes/answer.recipe.ts +0 -231
  63. package/templates/support-qa/workflows/group-qa/recipes/conversation_handler.recipe.ts +0 -394
  64. package/templates/support-qa/workflows/group-qa/recipes/fastpath.recipe.ts +0 -72
  65. package/templates/support-qa/workflows/group-qa/recipes/on-action.recipe.ts +0 -225
  66. package/templates/support-qa/workflows/group-qa/recipes/reply-to-group.recipe.ts +0 -202
  67. package/templates/support-qa/workflows/group-qa/recipes/troubleshoot.recipe.ts +0 -273
  68. package/templates/support-qa/workflows/group-qa/workflow.yml +0 -268
  69. package/templates/support-qa/workflows/thread-observer/README.md +0 -96
  70. package/templates/support-qa/workflows/thread-observer/recipes/thread_observe.recipe.ts +0 -415
  71. package/templates/support-qa/workflows/thread-observer/recipes/thread_observer_handler.recipe.ts +0 -213
  72. package/templates/support-qa/workflows/thread-observer/workflow.yml +0 -185
  73. package/templates/support-qa/workspaces/daily-review/AGENTS.md +0 -233
  74. package/templates/support-qa/workspaces/kb-editor/AGENTS.md +0 -32
  75. package/templates/support-qa/workspaces/support/AGENTS.md +0 -156
  76. package/templates/support-qa/workspaces/thread-observer/AGENTS.md +0 -156
  77. package/templates/support-qa/workspaces/troubleshooter/AGENTS.md +0 -71
@@ -1,391 +0,0 @@
1
- /**
2
- * thread 抽取的 schema 与 observe step 的门槛行为。
3
- *
4
- * 门槛顺序是本文件的重点,它决定了「哪些 thread 会白烧一次 LLM」和「哪些服务空白会被
5
- * 悄悄抹掉」:
6
- * ① 无真人响应 → 写观测列 + SLA状态,**不调 LLM**(但**不丢弃**:这类 thread 往往正是
7
- * 没人管的问题,丢掉就在表里看不见了);
8
- * ② 观察行不存在 → 有限重试,达上限后兜底建行(bot-only thread 例外,按设计跳过);
9
- * ③ 抽取失败/校验失败 → backoff 顺延,连续失败达上限才丢弃。
10
- */
11
- import { beforeEach, describe, expect, it, vi } from "vitest";
12
- import {
13
- THREAD_CONTRACT_VERSION,
14
- _resetDigestQueues,
15
- _resetThreadStoreRegistry,
16
- getThreadStore,
17
- type NormalizedMessage,
18
- type ThreadSnapshot,
19
- } from "aitable-workflow-core";
20
- import step from "../../workflows/thread-observer/recipes/thread_observe.recipe.js";
21
- import { parseThreadDigest, renderConclusion, typeToColumn } from "../thread-digest.js";
22
-
23
- const CID = "cidTEST==";
24
- const T0 = Date.parse("2026-08-11T09:54:00+08:00");
25
- const MIN = 60_000;
26
-
27
- beforeEach(() => {
28
- _resetThreadStoreRegistry();
29
- _resetDigestQueues();
30
- });
31
-
32
- // ─── schema ─────────────────────────────────────────────
33
-
34
- describe("parseThreadDigest", () => {
35
- const ok = {
36
- type: "kb_gap",
37
- module: "表单",
38
- standardQuestion: "手机端表单是否支持字段联动",
39
- conclusion: "手机端暂不支持字段联动,改用表单视图。",
40
- confidence: "high",
41
- };
42
-
43
- it("合法输出被接受", () => {
44
- expect(parseThreadDigest(ok)?.type).toBe("kb_gap");
45
- });
46
-
47
- it("kb_gap/requirement/bug 结论为空被拒(避免表里留一行没内容的记录)", () => {
48
- expect(parseThreadDigest({ ...ok, conclusion: " " })).toBeNull();
49
- expect(parseThreadDigest({ ...ok, type: "requirement", conclusion: "" })).toBeNull();
50
- expect(parseThreadDigest({ ...ok, type: "bug", conclusion: "" })).toBeNull();
51
- });
52
-
53
- it("answered/chitchat 允许空结论", () => {
54
- expect(parseThreadDigest({ ...ok, type: "chitchat", conclusion: "" })?.type).toBe("chitchat");
55
- });
56
-
57
- it("confidence 非 high 却不给理由被拒(「我不确定」不带理由对复核没价值)", () => {
58
- expect(parseThreadDigest({ ...ok, confidence: "low" })).toBeNull();
59
- expect(parseThreadDigest({ ...ok, confidence: "low", confidenceReason: "无人给出结论" })?.confidence).toBe("low");
60
- });
61
-
62
- it("非法 type 被拒,不静默透传", () => {
63
- expect(parseThreadDigest({ ...ok, type: "unknown_type" })).toBeNull();
64
- });
65
-
66
- it("空数组字段不写进结果", () => {
67
- const r = parseThreadDigest({ ...ok, amendments: [], evidenceMessageIds: [] });
68
- expect(r && "amendments" in r).toBe(false);
69
- expect(r && "evidenceMessageIds" in r).toBe(false);
70
- });
71
- });
72
-
73
- describe("renderConclusion:amendments 合写一列但不丢", () => {
74
- it("有纠正时结论与更正分段,模型不会把被推翻的说法当最终结论", () => {
75
- const out = renderConclusion({
76
- type: "kb_gap",
77
- standardQuestion: "q",
78
- conclusion: "手机端暂不支持字段联动。",
79
- amendments: ["机器人早期说「在字段设置里打开」不成立。"],
80
- evidenceMessageIds: ["msgC"],
81
- confidence: "high",
82
- });
83
- expect(out).toContain("手机端暂不支持字段联动。");
84
- expect(out).toContain("【过程更正】");
85
- expect(out).toContain("【证据】msgC");
86
- });
87
-
88
- it("无纠正无证据时只有结论正文,不留空段落", () => {
89
- expect(renderConclusion({ type: "answered", standardQuestion: "q", conclusion: "已解决。", confidence: "high" })).toBe("已解决。");
90
- });
91
- });
92
-
93
- describe("typeToColumn", () => {
94
- it("英文枚举映射成表里的中文选项,kb_gap 保留原样与主链口径对齐", () => {
95
- expect(typeToColumn("kb_gap")).toBe("kb_gap");
96
- expect(typeToColumn("requirement")).toBe("需求");
97
- expect(typeToColumn("answered")).toBe("已答");
98
- expect(typeToColumn("chitchat")).toBe("闲聊");
99
- });
100
- });
101
-
102
- // ─── step 门槛 ───────────────────────────────────────────
103
-
104
- const CONFIG_DIR = "/tmp/observer-step-test";
105
-
106
- function message(over: Partial<NormalizedMessage> & { openMessageId: string }): NormalizedMessage {
107
- return {
108
- conversationId: CID,
109
- groupName: "产品交流群",
110
- senderName: "用户甲",
111
- senderOpenDingTalkId: "openA",
112
- createTime: T0,
113
- content: "这单怎么还没成功",
114
- images: [],
115
- role: "unknown",
116
- ...over,
117
- };
118
- }
119
-
120
- /** 造一个 store 里已收敛的 thread:喂消息 → 让 step 自己 drain。 */
121
- function seedThread(messages: NormalizedMessage[], roster: Record<string, unknown> = {}): void {
122
- const store = getThreadStore(CONFIG_DIR);
123
- for (const m of messages) store.onMessage(m, roster);
124
- }
125
-
126
- type Updated = { recordId: string; fields: Record<string, unknown> };
127
-
128
- function makeCtx(opts: {
129
- rowIds?: string[];
130
- agentResponse?: string;
131
- agentThrows?: boolean;
132
- config?: Record<string, unknown>;
133
- }) {
134
- const updated: Updated[] = [];
135
- const created: Array<Record<string, unknown>> = [];
136
- const prompts: string[] = [];
137
- const logs: string[] = [];
138
- const ctx = {
139
- configDir: CONFIG_DIR,
140
- trackerRef: { baseId: "b", tableId: "t" },
141
- log: (m: string) => logs.push(m),
142
- getGlobalVar: () => undefined,
143
- config: {
144
- bots: [{ botNick: "答疑助理" }],
145
- answerers: [{ nick: "答疑甲", openDingTalkId: "openB" }],
146
- module_owners: [{ module: "表单" }],
147
- ...opts.config,
148
- },
149
- toolkit: {
150
- table: {
151
- findRowIds: vi.fn().mockResolvedValue(opts.rowIds ?? ["rec1"]),
152
- updateRecordIn: vi.fn().mockImplementation((_ref: unknown, recordId: string, fields: Record<string, unknown>) => {
153
- updated.push({ recordId, fields });
154
- return Promise.resolve();
155
- }),
156
- createRecordIn: vi.fn().mockImplementation((_ref: unknown, fields: Record<string, unknown>) => {
157
- created.push(fields);
158
- return Promise.resolve({ recordId: "recNew" });
159
- }),
160
- },
161
- coding: {
162
- run: vi.fn().mockImplementation((prompt: string) => {
163
- prompts.push(prompt);
164
- if (opts.agentThrows) throw new Error("agent 挂了");
165
- return Promise.resolve({ response: opts.agentResponse ?? "" });
166
- }),
167
- },
168
- },
169
- } as unknown as Parameters<typeof step.execute>[0];
170
- return { ctx, updated, created, prompts, logs };
171
- }
172
-
173
- const GOOD_RESPONSE = JSON.stringify({
174
- type: "kb_gap",
175
- module: "表单",
176
- standardQuestion: "手机端表单是否支持字段联动",
177
- conclusion: "手机端暂不支持字段联动,改用表单视图。",
178
- evidenceMessageIds: ["a1"],
179
- confidence: "high",
180
- });
181
-
182
- describe("空转零成本", () => {
183
- it("队列空时直接 tracker:false —— 无 LLM、无表写", async () => {
184
- const { ctx, updated } = makeCtx({});
185
- const r = await step.execute(ctx);
186
- expect(r.tracker).toBe(false);
187
- expect(updated).toHaveLength(0);
188
- expect(ctx.toolkit.coding.run).not.toHaveBeenCalled();
189
- });
190
- });
191
-
192
- describe("门槛1:无真人响应 → 写观测列但不抽取", () => {
193
- it("只有用户提问的 thread:写 SLA状态=无真人响应、观察状态=skipped,不调 LLM", async () => {
194
- seedThread([message({ openMessageId: "m1" })]);
195
- const { ctx, updated } = makeCtx({});
196
- await step.execute(ctx);
197
-
198
- expect(ctx.toolkit.coding.run).not.toHaveBeenCalled();
199
- expect(updated).toHaveLength(1);
200
- const f = updated[0]!.fields;
201
- expect(f["SLA状态"]).toBe("无真人响应");
202
- expect(f["观察状态"]).toBe("skipped");
203
- expect(f["跳过原因"]).toBe("no_answerer");
204
- // 关键:行还在,服务空白没有被抹掉
205
- expect(f["线程消息数"]).toBe(1);
206
- expect(String(f["thread render"])).toContain("这单怎么还没成功");
207
- });
208
-
209
- it("名单缺失时 SLA状态 写「无管理员名单」而不是「无真人响应」(两种原因要分得清)", async () => {
210
- seedThread([message({ openMessageId: "m1" })]);
211
- const { ctx, updated } = makeCtx({ config: { bots: [], answerers: [] } });
212
- await step.execute(ctx);
213
- expect(updated[0]!.fields["SLA状态"]).toBe("无管理员名单");
214
- expect(updated[0]!.fields["跳过原因"]).toBe("missing_bots");
215
- });
216
- });
217
-
218
- describe("有真人响应 → 交 agent 抽取", () => {
219
- const withAnswer = (): void =>
220
- seedThread(
221
- [
222
- message({ openMessageId: "m1" }),
223
- message({
224
- openMessageId: "a1",
225
- quotedMessageId: "m1",
226
- senderName: "答疑甲",
227
- senderOpenDingTalkId: "openB",
228
- createTime: T0 + 12 * MIN,
229
- content: "手机端暂不支持联动,用表单视图替代",
230
- }),
231
- ],
232
- { answererNames: ["答疑甲"], botNames: ["答疑助理"] },
233
- );
234
-
235
- it("写齐 SLA 三列 + 抽取四列,观察状态=digested", async () => {
236
- withAnswer();
237
- const { ctx, updated } = makeCtx({ agentResponse: GOOD_RESPONSE });
238
- await step.execute(ctx);
239
-
240
- expect(updated).toHaveLength(1);
241
- const f = updated[0]!.fields;
242
- expect(f["SLA状态"]).toBe("命中");
243
- expect(f["首次响应人"]).toBe("答疑甲");
244
- expect(f["首次响应时长分钟"]).toBe(12);
245
- expect(f["观察状态"]).toBe("digested");
246
- expect(f["问题类型"]).toBe("kb_gap");
247
- expect(f["结论模块"]).toBe("表单");
248
- expect(String(f["本次结论"])).toContain("手机端暂不支持字段联动");
249
- expect(f["答疑人员消息数"]).toBe(1);
250
- });
251
-
252
- it("prompt 把 thread 用 <thread> 框起来并给出模块字典与 SLA(防注入 + 口径同源)", async () => {
253
- withAnswer();
254
- const { ctx, prompts } = makeCtx({ agentResponse: GOOD_RESPONSE });
255
- await step.execute(ctx);
256
-
257
- const p = prompts[0]!;
258
- expect(p).toContain("必须先读取并遵循本工作目录下的 AGENTS.md");
259
- expect(p).toContain("<thread>");
260
- expect(p).toContain("</thread>");
261
- expect(p).toContain("待分析的数据");
262
- expect(p).toContain("表单"); // 模块字典
263
- expect(p).toContain("首次响应人=答疑甲");
264
- expect(p).toContain("只输出一个严格 JSON");
265
- });
266
-
267
- it("agent 输出带 ```json 围栏也能解析(同一模型不同轮次包装不一样)", async () => {
268
- withAnswer();
269
- const { ctx, updated } = makeCtx({ agentResponse: `说明文字\n\`\`\`json\n${GOOD_RESPONSE}\n\`\`\`` });
270
- await step.execute(ctx);
271
- expect(updated[0]!.fields["问题类型"]).toBe("kb_gap");
272
- });
273
-
274
- it("digest 低置信只**追加**到归并疑点,绝不覆盖归并置信度", async () => {
275
- withAnswer();
276
- const lowConf = JSON.stringify({
277
- type: "kb_gap",
278
- standardQuestion: "q",
279
- conclusion: "可能是这样",
280
- confidence: "low",
281
- confidenceReason: "真人只说了在排查",
282
- });
283
- const { ctx, updated } = makeCtx({ agentResponse: lowConf });
284
- await step.execute(ctx);
285
- const f = updated[0]!.fields;
286
- expect(String(f["归并疑点"])).toContain("digest_low_confidence");
287
- // 归并本身是确定性 quoted 命中,仍应是 high —— 两类问题必须分得开
288
- expect(f["归并置信度"]).toBe("high");
289
- });
290
-
291
- it("agent 抛错 → 不写表、留在队列 backoff 重试,不丢数据", async () => {
292
- withAnswer();
293
- const { ctx, updated, logs } = makeCtx({ agentThrows: true });
294
- const r = await step.execute(ctx);
295
- expect(updated).toHaveLength(0);
296
- expect(r.tracker).toBe(false);
297
- expect(logs.join("\n")).toContain("agent 抽取失败");
298
- });
299
-
300
- it("输出不合 schema → 同样走失败重试,不写半截数据", async () => {
301
- withAnswer();
302
- const { ctx, updated, logs } = makeCtx({ agentResponse: JSON.stringify({ type: "kb_gap", conclusion: "" }) });
303
- await step.execute(ctx);
304
- expect(updated).toHaveLength(0);
305
- expect(logs.join("\n")).toContain("校验失败");
306
- });
307
- });
308
-
309
- describe("门槛2:观察行缺失", () => {
310
- it("首次找不到行 → 保留重试,不建行也不抽取", async () => {
311
- seedThread([message({ openMessageId: "m1" })]);
312
- const { ctx, updated, created, logs } = makeCtx({ rowIds: [] });
313
- await step.execute(ctx);
314
- expect(updated).toHaveLength(0);
315
- expect(created).toHaveLength(0);
316
- expect(logs.join("\n")).toContain("观察行尚未建立");
317
- });
318
-
319
- it("重试达上限后兜底建行 —— 绝不让 thread 凭空消失", async () => {
320
- seedThread([message({ openMessageId: "m1" })]);
321
- const { ctx, created } = makeCtx({ rowIds: [], config: { max_root_retry: 1 } });
322
- await step.execute(ctx); // 第 1 轮:attempts 0 → 保留
323
- await step.execute(ctx); // 第 2 轮:attempts 1 已达上限 → 兜底建行
324
- expect(created).toHaveLength(1);
325
- expect(created[0]!["线程ID"]).toBeDefined();
326
- expect(String(created[0]!["跳过原因"])).toContain("no_root_row");
327
- });
328
-
329
- it("bot-only thread 无行属正常 → 直接丢弃,不兜底建行(否则机器人自言自语变成用户问题)", async () => {
330
- seedThread([message({ openMessageId: "b1", senderName: "答疑助理", role: "bot" })]);
331
- const { ctx, created, logs } = makeCtx({ rowIds: [], config: { max_root_retry: 1 } });
332
- await step.execute(ctx);
333
- await step.execute(ctx);
334
- expect(created).toHaveLength(0);
335
- expect(logs.join("\n")).toContain("bot-only thread");
336
- });
337
- });
338
-
339
- describe("每轮上限", () => {
340
- it("超过 max_per_round 的 thread 留到下一轮,不拖慢当前链路", async () => {
341
- // 两条各自有真人响应的 thread(引用命中确保不误合)
342
- for (const [q, a] of [["q1", "a1"], ["q2", "a2"]] as const) {
343
- seedThread(
344
- [
345
- message({ openMessageId: q, senderName: q === "q1" ? "用户甲" : "用户乙", senderOpenDingTalkId: q === "q1" ? "openA" : "openZ" }),
346
- message({
347
- openMessageId: a,
348
- quotedMessageId: q,
349
- senderName: "答疑甲",
350
- senderOpenDingTalkId: "openB",
351
- createTime: T0 + 5 * MIN,
352
- content: "回答",
353
- }),
354
- ],
355
- { answererNames: ["答疑甲"], botNames: ["答疑助理"] },
356
- );
357
- }
358
- const { ctx, updated, logs } = makeCtx({ agentResponse: GOOD_RESPONSE, config: { max_per_round: 1 } });
359
- await step.execute(ctx);
360
- expect(updated).toHaveLength(1);
361
- expect(logs.join("\n") + JSON.stringify(updated)).toBeTruthy();
362
- // 第二条仍在队列,下一轮处理
363
- const second = await step.execute(ctx);
364
- expect(second.tracker).toBe(false);
365
- });
366
- });
367
-
368
- describe("ThreadSnapshot 直接消费(不经 store)", () => {
369
- it("snapshot 的 review 段能原样落表,供复核池筛选", () => {
370
- const snap: ThreadSnapshot = {
371
- contractVersion: THREAD_CONTRACT_VERSION,
372
- threadId: "t-x",
373
- conversationId: CID,
374
- groupName: "产品交流群",
375
- rootMsgId: "m1",
376
- messages: [message({ openMessageId: "m1" })],
377
- edges: [],
378
- participants: [],
379
- openedAt: T0,
380
- review: {
381
- reason: "same_sender_window",
382
- confidence: "low",
383
- warnings: ["heuristic_merge", "same_sender_window"],
384
- candidateThreadIds: ["t-other"],
385
- needsReview: true,
386
- },
387
- };
388
- expect(snap.review.needsReview).toBe(true);
389
- expect(snap.review.warnings.join(", ")).toBe("heuristic_merge, same_sender_window");
390
- });
391
- });
@@ -1,274 +0,0 @@
1
- /**
2
- * Thread 观察器 handler 的行为断言。
3
- *
4
- * 四条硬口径,逐条钉死(它们是「旁路观察」这个定位的全部内容):
5
- * ① **采集 ≠ 落表**:bot / answerer / 闲聊消息全都进 ThreadStore 与日志,但观察表是
6
- * 「一条 thread 一行」,不是一条消息一行;
7
- * ② **bot 开的 thread 不建行**:它不是用户问题,建行只会污染观察表;
8
- * ③ **不配日志目录就完全不碰文件系统**(配置即授权);
9
- * ④ **归并 trace 落进复核字段**,且只在首次判定可疑时置「待复核」。
10
- */
11
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
12
- import os from "node:os";
13
- import path from "node:path";
14
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
- import { _resetThreadStoreRegistry, threadLogFileName } from "aitable-workflow-core";
16
- import handler from "../../workflows/thread-observer/recipes/thread_observer_handler.recipe.js";
17
-
18
- const CID = "cidBSbNVEqwrLzTw6Pp3YqpeA==";
19
- const roots: string[] = [];
20
-
21
- beforeEach(() => {
22
- // handler 用 per-configDir 共享单例,测试间必须重置,否则 thread 状态跨用例泄露
23
- _resetThreadStoreRegistry();
24
- });
25
-
26
- afterEach(() => {
27
- for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true });
28
- });
29
-
30
- function tmpRoot(): string {
31
- const root = mkdtempSync(path.join(os.tmpdir(), "observer-"));
32
- roots.push(root);
33
- return root;
34
- }
35
-
36
- type Pushed = { action: string; fields: Record<string, unknown> };
37
-
38
- function makeCtx(config: Record<string, unknown>, configDir = `dir-${Math.random()}`) {
39
- const pushed: Pushed[] = [];
40
- const logs: string[] = [];
41
- const ctx = {
42
- config,
43
- configDir,
44
- log: (m: string) => logs.push(m),
45
- pushEvent: (e: Pushed) => pushed.push(e),
46
- getGlobalVar: () => undefined,
47
- toolkit: { memory: { append: vi.fn() }, table: {} },
48
- } as unknown as Parameters<typeof handler.create>[0];
49
- return { ctx, pushed, logs };
50
- }
51
-
52
- /** 造一个 dingtalk_message 源形态的 event(messages 数组是关键,content 只是拼接产物)。 */
53
- function groupEvent(messages: Array<Record<string, unknown>>): never {
54
- return {
55
- type: "message_received",
56
- payload: {
57
- conversationId: CID,
58
- messages,
59
- content: messages.map((m) => String(m.content ?? "")).join("\n"),
60
- },
61
- timestamp: new Date().toISOString(),
62
- } as never;
63
- }
64
-
65
- const msg = (over: Record<string, unknown>): Record<string, unknown> => ({
66
- senderName: "用户甲",
67
- senderOpenDingTalkId: "openA",
68
- createTime: "2026-08-11T09:54:00+08:00",
69
- content: "这单怎么还没成功",
70
- ...over,
71
- });
72
-
73
- /** 读出日志行(扫全部日期目录:跨零点跑测试时写入日与消息日可能不同天)。 */
74
- function readLines(root: string): Array<Record<string, unknown>> {
75
- const out: Array<Record<string, unknown>> = [];
76
- if (!existsSync(root)) return out;
77
- for (const day of readdirSync(root)) {
78
- const file = path.join(root, day, threadLogFileName(CID));
79
- if (!existsSync(file)) continue;
80
- for (const raw of readFileSync(file, "utf8").split("\n")) {
81
- if (raw.trim()) out.push(JSON.parse(raw) as Record<string, unknown>);
82
- }
83
- }
84
- return out;
85
- }
86
-
87
- const ROSTER_CONFIG = {
88
- bots: [{ groupId: CID, botNick: "答疑助理" }],
89
- answerers: [{ nick: "答疑甲", openDingTalkId: "openB" }],
90
- group_configs: [{ groupId: CID, groupName: "产品交流群" }],
91
- };
92
-
93
- describe("建行口径:一条 thread 一行", () => {
94
- it("用户消息开 thread → 建一行,带齐溯源列", () => {
95
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
96
- handler.create(ctx).onEvent(groupEvent([msg({ messageId: "m1" })]));
97
-
98
- expect(pushed).toHaveLength(1);
99
- const f = pushed[0]!.fields;
100
- expect(pushed[0]!.action).toBe("create_record");
101
- expect(String(f["线程ID"])).toMatch(/^t-/);
102
- expect(f["根消息ID"]).toBe("m1");
103
- expect(f["群ID"]).toBe(CID);
104
- expect(f["群名"]).toBe("产品交流群");
105
- expect(f["提问人"]).toBe("用户甲");
106
- expect(f["用户原文"]).toBe("这单怎么还没成功");
107
- expect(f["观察状态"]).toBe("open");
108
- expect(f["角色判定状态"]).toBe("ok");
109
- expect(f["标题"]).toBe("用户甲: 这单怎么还没成功");
110
- });
111
-
112
- it("同 thread 的后续消息不重复建行(一条 thread 一行,不是一条消息一行)", () => {
113
- const { ctx, pushed } = makeCtx({ ...ROSTER_CONFIG, thread_enable_mention_merge: true });
114
- const h = handler.create(ctx);
115
- h.onEvent(groupEvent([msg({ messageId: "m1" })]));
116
- // 引用回复:确定性归并信号,必然并入同一 thread
117
- h.onEvent(
118
- groupEvent([
119
- msg({ messageId: "m2", quotedMessageId: "m1", senderName: "答疑甲", senderOpenDingTalkId: "openB", content: "我看看", createTime: "2026-08-11T09:57:00+08:00" }),
120
- ]),
121
- );
122
- expect(pushed).toHaveLength(1);
123
- });
124
-
125
- it("不同用户的两个问题各建一行", () => {
126
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
127
- const h = handler.create(ctx);
128
- h.onEvent(groupEvent([msg({ messageId: "m1" })]));
129
- h.onEvent(
130
- groupEvent([msg({ messageId: "n1", senderName: "用户乙", senderOpenDingTalkId: "openZ", content: "另一个问题", createTime: "2026-08-11T10:30:00+08:00" })]),
131
- );
132
- expect(pushed).toHaveLength(2);
133
- expect(pushed[0]!.fields["线程ID"]).not.toBe(pushed[1]!.fields["线程ID"]);
134
- });
135
-
136
- it("bot 开的 thread 只采集不建行(机器人自言自语不是用户问题)", () => {
137
- const { ctx, pushed, logs } = makeCtx(ROSTER_CONFIG);
138
- handler.create(ctx).onEvent(groupEvent([msg({ messageId: "b1", senderName: "答疑助理", senderOpenDingTalkId: "openBot", content: "有人需要帮助吗" })]));
139
- expect(pushed).toHaveLength(0);
140
- expect(logs.join("\n")).toContain("只采集不建行");
141
- });
142
-
143
- it("答疑人员开的 thread 也建行(可能是他在代问或自己提问,观察表要能看到)", () => {
144
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
145
- handler.create(ctx).onEvent(
146
- groupEvent([msg({ messageId: "a1", senderName: "答疑甲", senderOpenDingTalkId: "openB", content: "我先记一下这个问题" })]),
147
- );
148
- expect(pushed).toHaveLength(1);
149
- });
150
-
151
- it("无 openMessageId / 空内容的消息既不进 thread 也不建行", () => {
152
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
153
- const h = handler.create(ctx);
154
- h.onEvent(groupEvent([msg({ content: "没有 id" })]));
155
- h.onEvent(groupEvent([msg({ messageId: "m9", content: " " })]));
156
- expect(pushed).toHaveLength(0);
157
- });
158
- });
159
-
160
- describe("归并 trace → 复核字段", () => {
161
- it("高置信新建:写 reason/置信度,不置待复核", () => {
162
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
163
- handler.create(ctx).onEvent(groupEvent([msg({ messageId: "m1" })]));
164
- const f = pushed[0]!.fields;
165
- expect(f["归并策略"]).toBe("new_thread");
166
- expect(f["归并置信度"]).toBe("high");
167
- expect(f["归并疑点"]).toBe("");
168
- expect("复核状态" in f).toBe(false);
169
- });
170
-
171
- it("名单缺失 → 角色判定状态与疑点齐出,且进复核池", () => {
172
- const { ctx, pushed } = makeCtx({}); // 完全不配名单,模拟首轮 config-sync 前
173
- handler.create(ctx).onEvent(groupEvent([msg({ messageId: "m1" })]));
174
- const f = pushed[0]!.fields;
175
- expect(f["角色判定状态"]).toBe("missing_bots");
176
- expect(String(f["归并疑点"])).toContain("missing_answerers");
177
- expect(String(f["归并疑点"])).toContain("missing_bots");
178
- expect(f["复核状态"]).toBe("待复核");
179
- // 名单问题不该拉低归并置信度:threadId 本身没归错
180
- expect(f["归并置信度"]).toBe("high");
181
- });
182
-
183
- it("引用锚点缺失 → low + 待复核(知道是回复但接不上,属漏合风险)", () => {
184
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
185
- handler.create(ctx).onEvent(groupEvent([msg({ messageId: "m2", quotedMessageId: "从未见过的消息" })]));
186
- const f = pushed[0]!.fields;
187
- expect(f["归并置信度"]).toBe("low");
188
- expect(String(f["归并疑点"])).toContain("quoted_parent_missing");
189
- expect(f["复核状态"]).toBe("待复核");
190
- });
191
-
192
- it("启发式归并打开时,被并入的消息不会再建行,但疑点已记在 thread 上", () => {
193
- // 启发式合并的消息不是新 thread,故不建行;它的 low 置信在收敛时由 step 刷进表
194
- const { ctx, pushed } = makeCtx({ ...ROSTER_CONFIG, thread_enable_heuristic_merge: true });
195
- const h = handler.create(ctx);
196
- h.onEvent(groupEvent([msg({ messageId: "m1" })]));
197
- h.onEvent(groupEvent([msg({ messageId: "m2", content: "补充一下", createTime: "2026-08-11T09:55:00+08:00" })]));
198
- expect(pushed).toHaveLength(1);
199
- });
200
- });
201
-
202
- describe("append-only 日志", () => {
203
- it("bot / answerer 消息都落日志(否则 thread 永远拼不出问答对)", () => {
204
- const root = tmpRoot();
205
- const { ctx, pushed } = makeCtx({ ...ROSTER_CONFIG, thread_log_dir: root });
206
- const h = handler.create(ctx);
207
- h.onEvent(groupEvent([msg({ messageId: "m1" })]));
208
- h.onEvent(groupEvent([msg({ messageId: "b1", senderName: "答疑助理", content: "已收到", createTime: "2026-08-11T09:55:00+08:00" })]));
209
- h.onEvent(
210
- groupEvent([msg({ messageId: "a1", senderName: "答疑甲", senderOpenDingTalkId: "openB", content: "我看看", createTime: "2026-08-11T09:56:00+08:00" })]),
211
- );
212
-
213
- const lines = readLines(root);
214
- expect(lines.map((l) => l.identity)).toEqual(["user", "bot", "answerer"]);
215
- // 但只有 user 与 answerer 开的 thread 建了行,bot 那条没有
216
- expect(pushed).toHaveLength(2);
217
- });
218
-
219
- it("日志逐条落盘并带 threadId 与引用关系,便于离线比对归并结果", () => {
220
- const root = tmpRoot();
221
- const { ctx } = makeCtx({ ...ROSTER_CONFIG, thread_log_dir: root });
222
- handler.create(ctx).onEvent(
223
- groupEvent([
224
- msg({ messageId: "m1" }),
225
- msg({ messageId: "m2", quotedMessageId: "m1", content: "有备注的", createTime: "2026-08-11T09:55:00+08:00" }),
226
- ]),
227
- );
228
- const lines = readLines(root);
229
- expect(lines).toHaveLength(2);
230
- expect(String(lines[0]!.threadId)).toMatch(/^t-/);
231
- expect(lines[1]!.quotedMessageId).toBe("m1");
232
- // 同一 thread(引用命中)→ 两行 threadId 相同
233
- expect(lines[1]!.threadId).toBe(lines[0]!.threadId);
234
- });
235
-
236
- it("不配 thread_log_dir → 完全不创建任何目录(配置即授权),但归并照旧", () => {
237
- const root = tmpRoot();
238
- const before = readdirSync(root);
239
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
240
- handler.create(ctx).onEvent(groupEvent([msg({ messageId: "m1" })]));
241
- expect(readdirSync(root)).toEqual(before);
242
- // 归并是纯内存无副作用的,绑在日志开关上会让线程ID 无缘无故为空
243
- expect(String(pushed[0]!.fields["线程ID"])).toMatch(/^t-/);
244
- });
245
- });
246
-
247
- describe("旁路定位:绝不动主链", () => {
248
- it("handler 只 pushEvent 建行,不调用任何 messaging / 不写别的表", () => {
249
- const sent: unknown[] = [];
250
- const { ctx, pushed } = makeCtx(ROSTER_CONFIG);
251
- // 往 toolkit 上放探针:一旦 handler 试图发消息或写表,用例立刻红
252
- (ctx as unknown as { toolkit: Record<string, unknown> }).toolkit = {
253
- messaging: {
254
- replyToConversation: () => {
255
- sent.push("reply");
256
- return Promise.resolve();
257
- },
258
- },
259
- table: {
260
- createRecordIn: () => {
261
- sent.push("createRecordIn");
262
- return Promise.resolve({ recordId: "x" });
263
- },
264
- updateRecordIn: () => {
265
- sent.push("updateRecordIn");
266
- return Promise.resolve();
267
- },
268
- },
269
- };
270
- handler.create(ctx).onEvent(groupEvent([msg({ messageId: "m1" })]));
271
- expect(sent).toEqual([]);
272
- expect(pushed).toHaveLength(1);
273
- });
274
- });