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,398 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { parse as parseYaml } from "yaml";
4
-
5
- /**
6
- * aitable-workflow — 声明式规则匹配引擎
7
- *
8
- * 把「命中哪条规则」从代码里的硬编码正则收敛为 YAML 声明。两个 preset 共用同一内核:
9
- *
10
- * loadReplyRules 确定性秒回(命中即固定话术):safety 关键词兜底 + rules[template|contains|regex]
11
- * loadClassifierRules 问题分类(命中即打标签并抽取证据):classifiers[patterns, require_all, extract]
12
- *
13
- * 前者本质是后者的特例(无 require_all、无 extract、命中即回复),故合为一个模块。
14
- *
15
- * ─── 硬不变量(务必保留,都是踩过的坑)───
16
- *
17
- * 1. **单向匹配**:一律「消息包含/匹配规则模式」,绝不反向(规则模式包含消息)。
18
- * 双向 includes 会让短消息错误命中长规则——这是早期 known-error-match
19
- * `errorMessage.includes(k) || k.includes(errorMessage)` 的实际 bug。
20
- * 2. **只看用户本轮新话**:普通规则匹配前剥离钉钉「引用消息:」块。用户带引用追问时,
21
- * 引用块里往往完整包含上一轮报错与机器人回复,不剥离就会复读同一段话术。
22
- * 3. **safety 看全文**:安全兜底必须对含引用的原文匹配,防注入/敏感内容藏在引用块里绕过。
23
- * 4. **优雅关闭**:未配置、文件缺失、解析失败 → config=null(调用方透传),不阻断主环。
24
- * 单条规则非法只跳该条并记 issue,不整档失效。
25
- * 5. **校验单一事实源**:合法性只由本文件的 normalize 逐条判定并产出 issues,
26
- * 不再另外维护一份 zod schema(两套规则双写必然漂移)。
27
- */
28
-
29
- // ─── 匹配条件 ────────────────────────────────────────────
30
-
31
- export type MatchPattern = {
32
- contains?: string;
33
- regex?: string;
34
- /** 加载期预编译(与校验同一实例),避免逐消息重编 */
35
- compiledRegex?: RegExp;
36
- };
37
-
38
- /** 钉钉「引用消息:」标记:其后内容是被引用的历史消息,不属于用户本轮诉求。
39
- * 含半角/全角冒号两种变体(不同客户端文案不一致)。 */
40
- const QUOTE_MARKERS = ["引用消息:", "引用消息:"];
41
-
42
- /**
43
- * 剥离引用块,只保留用户本轮新话。
44
- * 边界:剥离后为空(用户只转发引用、自己没打字)→ 返回原文。
45
- */
46
- export function stripQuotedBlock(message: string): string {
47
- const msg = message ?? "";
48
- let cut = msg.length;
49
- for (const marker of QUOTE_MARKERS) {
50
- const i = msg.indexOf(marker);
51
- if (i >= 0 && i < cut) cut = i;
52
- }
53
- if (cut >= msg.length) return msg;
54
- const head = msg.slice(0, cut).trim();
55
- return head || msg;
56
- }
57
-
58
- /** 空白归一(template 全等匹配用) */
59
- function normalizeWs(s: string): string {
60
- return s.replace(/\s+/g, " ").trim();
61
- }
62
-
63
- /** 单向测试:消息包含/匹配该 pattern */
64
- function testPattern(p: MatchPattern, message: string): boolean {
65
- if (p.contains !== undefined) return message.includes(p.contains);
66
- if (p.compiledRegex) return p.compiledRegex.test(message);
67
- return false;
68
- }
69
-
70
- /** 归一化 patterns 列表:每条必须且只能有 contains/regex 之一 */
71
- function normalizePatterns(raw: unknown, label: string, issues: string[]): MatchPattern[] {
72
- const out: MatchPattern[] = [];
73
- for (const p of Array.isArray(raw) ? raw : []) {
74
- if (!p || typeof p !== "object") continue;
75
- const rec = p as Record<string, unknown>;
76
- const hasContains = typeof rec.contains === "string" && rec.contains.trim();
77
- const hasRegex = typeof rec.regex === "string" && String(rec.regex).trim();
78
- if (Boolean(hasContains) === Boolean(hasRegex)) {
79
- issues.push(`${label} 每条 pattern 必须且只能有 contains/regex 之一`);
80
- continue;
81
- }
82
- if (hasContains) {
83
- out.push({ contains: String(rec.contains) });
84
- } else {
85
- const rx = String(rec.regex);
86
- try {
87
- out.push({ regex: rx, compiledRegex: new RegExp(rx) });
88
- } catch {
89
- issues.push(`${label} 非法正则: ${rx}`);
90
- }
91
- }
92
- }
93
- return out;
94
- }
95
-
96
- /** 按绝对路径缓存加载结果(每种 preset 一张表,避免 key 冲突) */
97
- function makeLoader<T>(
98
- subdir: string,
99
- parse: (raw: Record<string, unknown>, issues: string[]) => T | null,
100
- labels: { missing: string; parseFail: string },
101
- ) {
102
- const cache = new Map<string, { config: T | null; issues: string[] }>();
103
- return (configDir: string, rel: string | undefined): { config: T | null; issues: string[] } => {
104
- const trimmed = String(rel ?? "").trim();
105
- if (!trimmed) return { config: null, issues: [] }; // 未配置 → 关闭
106
- const abs = path.resolve(configDir, subdir, trimmed);
107
- const hit = cache.get(abs);
108
- if (hit) return hit;
109
-
110
- let res: { config: T | null; issues: string[] };
111
- if (!existsSync(abs)) {
112
- // 文件缺失 → 优雅关闭(不阻断主环);preflight 另行告警
113
- res = { config: null, issues: [`${labels.missing}: ${subdir}/${trimmed}`] };
114
- } else {
115
- try {
116
- const raw = (parseYaml(readFileSync(abs, "utf8")) ?? {}) as Record<string, unknown>;
117
- const issues: string[] = [];
118
- res = { config: parse(raw, issues), issues };
119
- } catch (err) {
120
- res = {
121
- config: null,
122
- issues: [`${labels.parseFail} ${subdir}/${trimmed}: ${err instanceof Error ? err.message : String(err)}`],
123
- };
124
- }
125
- }
126
- cache.set(abs, res);
127
- return res;
128
- };
129
- }
130
-
131
- // ─── preset 1:确定性秒回规则 ─────────────────────────────
132
-
133
- export type ReplyRule = {
134
- /** 规则标识(报错/日志文案含此 key,便于排障与追溯) */
135
- key: string;
136
- template?: string;
137
- contains?: string;
138
- regex?: string;
139
- compiledRegex?: RegExp;
140
- reply: string;
141
- };
142
-
143
- export type SafetyRule = { keywords: string[]; reply: string };
144
-
145
- export type ReplyRulesConfig = {
146
- safety?: SafetyRule;
147
- rules: ReplyRule[];
148
- };
149
-
150
- export type ReplyMatch = {
151
- key: string;
152
- reply: string;
153
- kind: "safety" | "template" | "contains" | "regex";
154
- };
155
-
156
- function parseReplyRules(raw: Record<string, unknown>, issues: string[]): ReplyRulesConfig {
157
- const rules: ReplyRule[] = [];
158
- const rawRules = Array.isArray(raw.rules) ? raw.rules : [];
159
- for (let i = 0; i < rawRules.length; i++) {
160
- const r = rawRules[i] as Record<string, unknown> | null;
161
- if (!r || typeof r !== "object") {
162
- issues.push(`rules[${i}] 不是对象`);
163
- continue;
164
- }
165
- const key = String(r.key ?? "").trim();
166
- const reply = String(r.reply ?? "").trim();
167
- if (!key) {
168
- issues.push(`rules[${i}] 缺少 key`);
169
- continue;
170
- }
171
- if (!reply) {
172
- issues.push(`rules[${key}] 缺少 reply`);
173
- continue;
174
- }
175
- const present = (["template", "contains", "regex"] as const).filter(
176
- (k) => typeof r[k] === "string" && String(r[k]).trim(),
177
- );
178
- if (present.length !== 1) {
179
- issues.push(`rules[${key}] 必须且只能有 template/contains/regex 之一(当前 ${present.length} 个)`);
180
- continue;
181
- }
182
- const rule: ReplyRule = { key, reply };
183
- if (typeof r.template === "string") rule.template = r.template;
184
- if (typeof r.contains === "string") rule.contains = r.contains;
185
- if (typeof r.regex === "string") {
186
- try {
187
- rule.compiledRegex = new RegExp(r.regex);
188
- } catch {
189
- issues.push(`rules[${key}] 非法正则: ${r.regex}`);
190
- continue;
191
- }
192
- rule.regex = r.regex;
193
- }
194
- rules.push(rule);
195
- }
196
-
197
- let safety: SafetyRule | undefined;
198
- const rawSafety = raw.safety as Record<string, unknown> | undefined;
199
- if (rawSafety && typeof rawSafety === "object") {
200
- const keywords = Array.isArray(rawSafety.keywords)
201
- ? rawSafety.keywords.map((k) => String(k)).filter(Boolean)
202
- : [];
203
- const reply = String(rawSafety.reply ?? "").trim();
204
- if (keywords.length > 0 && reply) safety = { keywords, reply };
205
- else issues.push("safety 需同时提供非空 keywords 与 reply(否则忽略)");
206
- }
207
-
208
- return { safety, rules };
209
- }
210
-
211
- /** 加载秒回规则(相对 `<configDir>/profile/` 解析),按绝对路径缓存 */
212
- export const loadReplyRules = makeLoader<ReplyRulesConfig>(
213
- "profile",
214
- parseReplyRules,
215
- { missing: "规则文件不存在", parseFail: "规则解析失败" },
216
- );
217
-
218
- /**
219
- * 匹配秒回规则(单向):安全兜底优先,其次规则按声明顺序。
220
- *
221
- * safety 看全文(敏感内容/注入藏在引用块里也要拦);普通规则只看剥离引用后的用户新话。
222
- */
223
- export function matchReplyRules(config: ReplyRulesConfig | null, message: string): ReplyMatch | null {
224
- if (!config) return null;
225
- const msg = message ?? "";
226
- if (!msg.trim()) return null;
227
-
228
- if (config.safety) {
229
- for (const kw of config.safety.keywords) {
230
- if (kw && msg.includes(kw)) return { key: "safety", reply: config.safety.reply, kind: "safety" };
231
- }
232
- }
233
-
234
- const own = stripQuotedBlock(msg);
235
- if (!own.trim()) return null;
236
-
237
- for (const rule of config.rules) {
238
- if (rule.template !== undefined) {
239
- if (normalizeWs(own) === normalizeWs(rule.template)) {
240
- return { key: rule.key, reply: rule.reply, kind: "template" };
241
- }
242
- } else if (rule.contains !== undefined) {
243
- if (own.includes(rule.contains)) return { key: rule.key, reply: rule.reply, kind: "contains" }; // 单向
244
- } else if (rule.regex !== undefined) {
245
- if (rule.compiledRegex?.test(own)) return { key: rule.key, reply: rule.reply, kind: "regex" };
246
- }
247
- }
248
- return null;
249
- }
250
-
251
- // ─── preset 2:问题分类器 ─────────────────────────────────
252
-
253
- export type ClassifierRule = {
254
- issueType: string;
255
- /** 可选:命中时透传的路由模块名 */
256
- module?: string;
257
- reason: string;
258
- patterns: MatchPattern[];
259
- /** 默认 false(任一命中);true 时需全部命中 */
260
- requireAll: boolean;
261
- /** 字段名 → 正则(取第一个捕获组);未命中该字段填 null */
262
- extract: Record<string, string>;
263
- };
264
-
265
- export type ClassifierRulesConfig = { classifiers: ClassifierRule[] };
266
-
267
- export type ClassifyMatch = {
268
- issueType: string;
269
- module?: string;
270
- reason: string;
271
- extracted: Record<string, string | null>;
272
- };
273
-
274
- function parseClassifierRules(raw: Record<string, unknown>, issues: string[]): ClassifierRulesConfig | null {
275
- const out: ClassifierRule[] = [];
276
- const rawList = Array.isArray(raw.classifiers) ? raw.classifiers : [];
277
- for (let i = 0; i < rawList.length; i++) {
278
- const c = rawList[i] as Record<string, unknown> | null;
279
- if (!c || typeof c !== "object") {
280
- issues.push(`classifiers[${i}] 不是对象`);
281
- continue;
282
- }
283
- const issueType = String(c.issueType ?? "").trim();
284
- if (!issueType) {
285
- issues.push(`classifiers[${i}] 缺少 issueType`);
286
- continue;
287
- }
288
- const patterns = normalizePatterns(c.patterns, `classifiers[${issueType}]`, issues);
289
- if (patterns.length === 0) {
290
- issues.push(`classifiers[${issueType}] 无有效 patterns`);
291
- continue;
292
- }
293
- const extract: Record<string, string> = {};
294
- const rawExtract = c.extract as Record<string, unknown> | undefined;
295
- if (rawExtract && typeof rawExtract === "object") {
296
- for (const [field, rx] of Object.entries(rawExtract)) {
297
- const pattern = String(rx ?? "");
298
- try {
299
- new RegExp(pattern);
300
- extract[field] = pattern;
301
- } catch {
302
- issues.push(`classifiers[${issueType}].extract.${field} 非法正则: ${pattern}`);
303
- }
304
- }
305
- }
306
- out.push({
307
- issueType,
308
- ...(String(c.module ?? "").trim() ? { module: String(c.module).trim() } : {}),
309
- reason: String(c.reason ?? `命中 ${issueType} 分类规则`).trim(),
310
- patterns,
311
- requireAll: c.require_all === true,
312
- extract,
313
- });
314
- }
315
- // 无有效分类器 = 关闭(交给下游语义处理)
316
- return out.length > 0 ? { classifiers: out } : null;
317
- }
318
-
319
- /** 加载分类器(相对 `<configDir>/profile/` 解析),按绝对路径缓存 */
320
- export const loadClassifierRules = makeLoader<ClassifierRulesConfig>(
321
- "profile",
322
- parseClassifierRules,
323
- { missing: "分类器文件不存在", parseFail: "分类器解析失败" },
324
- );
325
-
326
- function extractFields(extract: Record<string, string>, message: string): Record<string, string | null> {
327
- const out: Record<string, string | null> = {};
328
- for (const [field, rx] of Object.entries(extract)) {
329
- try {
330
- const m = message.match(new RegExp(rx));
331
- out[field] = m?.[1] ?? m?.[0] ?? null;
332
- } catch {
333
- out[field] = null;
334
- }
335
- }
336
- return out;
337
- }
338
-
339
- /**
340
- * 分类当前消息:按声明顺序返回第一个满足的分类器
341
- * (require_all → 全部 pattern 命中;否则任一命中)。无命中返回 null。
342
- */
343
- export function classifyMessage(config: ClassifierRulesConfig | null, message: string): ClassifyMatch | null {
344
- if (!config) return null;
345
- const msg = message ?? "";
346
- if (!msg.trim()) return null;
347
- for (const c of config.classifiers) {
348
- const ok = c.requireAll
349
- ? c.patterns.every((p) => testPattern(p, msg))
350
- : c.patterns.some((p) => testPattern(p, msg));
351
- if (ok) {
352
- return {
353
- issueType: c.issueType,
354
- ...(c.module ? { module: c.module } : {}),
355
- reason: c.reason,
356
- extracted: extractFields(c.extract, msg),
357
- };
358
- }
359
- }
360
- return null;
361
- }
362
-
363
- // ─── 整档 / 按群开关 ─────────────────────────────────────
364
-
365
- /**
366
- * 某个可选能力是否整档启用:所需目录(如 skills_dir)已配置且存在。
367
- * 未配置或目录缺失 → 关闭。路径相对实例根解析。
368
- */
369
- export function isCapabilityEnabled(configDir: string, dirRel: string | undefined): boolean {
370
- const rel = String(dirRel ?? "").trim();
371
- if (!rel) return false;
372
- return existsSync(path.resolve(configDir, rel));
373
- }
374
-
375
- type GroupToggleRow = Record<string, unknown>;
376
- const TRUE_VALUES = new Set(["true", "1", "yes", "on", "是", "开启", "启用", "已勾选"]);
377
-
378
- function explicitTrue(value: unknown): boolean {
379
- if (value === true || value === 1) return true;
380
- return typeof value === "string" && TRUE_VALUES.has(value.trim().toLowerCase());
381
- }
382
-
383
- /**
384
- * 按群开关:仅当群配置快照里该群的开关字段**明确为真**才启用。
385
- * 群缺失、字段缺失、未知值一律关闭(宁可不做,不可乱做)。
386
- */
387
- export function isGroupToggleEnabled(
388
- raw: unknown,
389
- conversationId: string,
390
- field: string,
391
- idField = "groupId",
392
- ): boolean {
393
- if (!conversationId || !Array.isArray(raw)) return false;
394
- const group = (raw as GroupToggleRow[]).find(
395
- (g) => String(g?.[idField] ?? "").trim() === conversationId,
396
- );
397
- return explicitTrue(group?.[field]);
398
- }
@@ -1,158 +0,0 @@
1
- import { DwsClient, logger } from "aitable-workflow-core";
2
-
3
- /**
4
- * aitable-workflow — 钉钉群成员在群判定(能不能真实 @ 到这个人)
5
- *
6
- * 用途:升级找人时要在群里 @ 负责人,但负责人未必在该群。@ 一个不在群的人钉钉不会
7
- * 真正提醒到他,还会在消息里留下无意义的数字工号。故发送前先判定:
8
- * - 在群 → { mode: "at", userId },由发送层配合 mentionUserIds 真实 @;
9
- * - 判不了 → { mode: "name" },调用方只渲染花名,绝不写假 @、绝不出数字 uid。
10
- *
11
- * 硬不变量:**任何失败都降级为 `{ mode: "name" }`,绝不向主流程抛错。**
12
- * 参数缺失、命令失败、超时、响应解析失败、人不在群——一律 name。找人是加分项,
13
- * 不能因为它挂掉而让整条答疑链路失败。
14
- *
15
- * 命令统一走 DwsClient(而非自建 spawn):拿到退避重试(消化 dws 后端偶发 TIMEOUT /
16
- * 伪 AUTH_PERMISSION_DENIED)与连续失败升级告警。降级时打一条 debug/warn,不再无声失败。
17
- *
18
- * 结果按 (conversationId, userId, openDingTalkId) 缓存:TTL 10 分钟、上限 500 条、
19
- * Map 插入顺序作轻量 LRU。群成员变动不频繁,缓存显著削减 dws 调用。
20
- */
21
-
22
- export type GroupMention =
23
- | { mode: "at"; userId: string }
24
- | { mode: "name" };
25
-
26
- /** 待判定的联系人:userId 用于真实 @,openDingTalkId 用于群成员查询 */
27
- export type MentionCandidate = {
28
- userId: string;
29
- openDingTalkId?: string;
30
- };
31
-
32
- export type GroupMembershipDeps = {
33
- /** 注入 dws 执行器(测试用);缺省新建 DwsClient */
34
- dws?: Pick<DwsClient, "runJson">;
35
- /** dws 可执行文件名(对齐 settings.tracker.dws_bin),缺省 "dws" */
36
- dwsBin?: string;
37
- /** 群归属企业 corpId;Webhook 出站必须显式传入 */
38
- corpId?: string;
39
- /** 注入时钟(测试用) */
40
- now?: () => number;
41
- };
42
-
43
- type CacheEntry = { value: GroupMention; expiresAt: number };
44
-
45
- const DWS_TIMEOUT_MS = 8_000;
46
- const CACHE_TTL_MS = 10 * 60_000;
47
- const CACHE_MAX = 500;
48
-
49
- const memberCache = new Map<string, CacheEntry>();
50
-
51
- function getCached(key: string, now: number): GroupMention | undefined {
52
- const hit = memberCache.get(key);
53
- if (!hit) return undefined;
54
- if (hit.expiresAt <= now) {
55
- memberCache.delete(key);
56
- return undefined;
57
- }
58
- // Map 插入顺序作为轻量 LRU;命中后移到队尾
59
- memberCache.delete(key);
60
- memberCache.set(key, hit);
61
- return hit.value;
62
- }
63
-
64
- function setCached(key: string, value: GroupMention, expiresAt: number): void {
65
- memberCache.delete(key);
66
- memberCache.set(key, { value, expiresAt });
67
- while (memberCache.size > CACHE_MAX) {
68
- const oldest = memberCache.keys().next().value as string | undefined;
69
- if (oldest === undefined) break;
70
- memberCache.delete(oldest);
71
- }
72
- }
73
-
74
- /** 从 dws 响应里取 result.members 数组;结构不符一律视为空 */
75
- function memberList(payload: unknown): Array<Record<string, unknown>> {
76
- if (!payload || typeof payload !== "object") return [];
77
- const result = (payload as Record<string, unknown>).result;
78
- if (!result || typeof result !== "object" || Array.isArray(result)) return [];
79
- const members = (result as Record<string, unknown>).members;
80
- return Array.isArray(members)
81
- ? members.filter(
82
- (item): item is Record<string, unknown> =>
83
- Boolean(item) && typeof item === "object" && !Array.isArray(item),
84
- )
85
- : [];
86
- }
87
-
88
- /** 兼容两种大小写拼写(不同 dws 版本返回不一致) */
89
- function readOpenDingTalkId(item: Record<string, unknown>): string {
90
- const value = item.openDingTalkId ?? item.openDingtalkId;
91
- return typeof value === "string" ? value.trim() : "";
92
- }
93
-
94
- /**
95
- * 判定某联系人能否在目标群被真实 @。
96
- *
97
- * userId / openDingTalkId 由调用方从自己的联系人快照传入(本函数不做联系人解析),
98
- * 这里只做「在不在这个群」的校验。
99
- */
100
- export async function resolveGroupMention(
101
- conversationId: string,
102
- candidate: MentionCandidate,
103
- deps: GroupMembershipDeps = {},
104
- ): Promise<GroupMention> {
105
- const cid = String(conversationId ?? "").trim();
106
- const userId = String(candidate?.userId ?? "").trim();
107
- const openDingTalkId = String(candidate?.openDingTalkId ?? "").trim();
108
- const corpId = String(deps.corpId ?? "").trim();
109
- const fallback: GroupMention = { mode: "name" };
110
-
111
- if (!cid || !userId || !openDingTalkId) {
112
- logger.debug("group-membership", "参数不足,降级为花名", {
113
- hasCid: Boolean(cid), hasUserId: Boolean(userId), hasOpenId: Boolean(openDingTalkId),
114
- });
115
- return fallback;
116
- }
117
-
118
- const now = (deps.now ?? Date.now)();
119
- // userId 与群成员可见性均属于企业命名空间,corpId 必须进入缓存键,避免跨企业串值。
120
- const cacheKey = `${corpId}\u0000${cid}\u0000${userId}\u0000${openDingTalkId}`;
121
- const cached = getCached(cacheKey, now);
122
- if (cached !== undefined) return cached;
123
-
124
- const dws = deps.dws ?? new DwsClient({ bin: deps.dwsBin ?? "dws", timeoutMs: DWS_TIMEOUT_MS });
125
-
126
- let result: GroupMention = fallback;
127
- try {
128
- // DwsClient.runJson 自动追加 --format json,这里不再手写
129
- const args = ["chat", "group", "members", "list-by-ids", "--id", cid, "--users", openDingTalkId];
130
- // --profile 是 DWS 的全局认证上下文参数,支持直接传 corpId;它不是群成员接口的业务字段。
131
- if (corpId) args.push("--profile", corpId);
132
- const payload = await dws.runJson(
133
- args,
134
- { timeoutMs: DWS_TIMEOUT_MS },
135
- );
136
- const matched = memberList(payload).some((m) => readOpenDingTalkId(m) === openDingTalkId);
137
- if (matched) {
138
- result = { mode: "at", userId };
139
- } else {
140
- logger.debug("group-membership", "联系人不在该群,降级为花名", { conversationId: cid });
141
- }
142
- } catch (error) {
143
- // 找人是加分项:查询失败不影响主流程,但要留痕(此前是完全静默)
144
- logger.warn("group-membership", "群成员查询失败,降级为花名", {
145
- conversationId: cid,
146
- error: error instanceof Error ? error.message : String(error),
147
- });
148
- result = fallback;
149
- }
150
-
151
- setCached(cacheKey, result, now + CACHE_TTL_MS);
152
- return result;
153
- }
154
-
155
- /** 仅供单测隔离模块级缓存 */
156
- export function resetGroupMembershipCache(): void {
157
- memberCache.clear();
158
- }
@@ -1,18 +0,0 @@
1
- import { createFingerprintWindow } from "aitable-workflow-core";
2
-
3
- /**
4
- * support-qa 出站回声防护(echo guard)
5
- *
6
- * 背景:webhook 发出的机器人回复可能被 dws 轮询以"某个真人"的名义重新拉回
7
- * (真实案例:机器人答案以创建者名义回流,身份匹配失效,被当新消息判定建行)。
8
- * 方案:reply 发送成功后发布内容指纹;conversation_handler 同步比对,
9
- * 命中即按机器人消息处理(只记历史,不建单)。
10
- */
11
-
12
- /** 出站内容指纹窗口:单 part = 回复正文(截断 300) */
13
- export const echoWindow = createFingerprintWindow({
14
- key: "sent_reply_fingerprints",
15
- ttlMs: 10 * 60 * 1000,
16
- capacity: 50,
17
- maxPartLength: 300,
18
- });
@@ -1,113 +0,0 @@
1
- import type { MemoryEntry, MemoryScope } from "aitable-workflow-core";
2
-
3
- /**
4
- * support-qa 记忆事件词表 + 工厂(场景层单一事实源)
5
- *
6
- * 写入方:conversation_handler(user/admin 消息)、answer(agent 决策)、reply(发送结果)。
7
- * 读取方:answer(长期记忆注入)、daily-review(复盘/学习提炼)。
8
- */
9
-
10
- // ─── 记忆事件工厂(从 core 下沉)────────────────────────────
11
-
12
- type MemoryEventsOptions = {
13
- scenario: string;
14
- channel: string;
15
- questionKeyLength?: number;
16
- summaryContentLength?: number;
17
- };
18
-
19
- type MemoryEntryArgs<Kind extends string = string, Role extends string = string> = {
20
- kind: Kind;
21
- content: string;
22
- sender: string;
23
- role: Role;
24
- conversationId: string;
25
- timestamp?: string;
26
- summary?: string;
27
- meta?: Record<string, unknown>;
28
- };
29
-
30
- type MemoryEvents<Kind extends string = string, Role extends string = string> = {
31
- scope: (conversationId?: string) => MemoryScope;
32
- entry: (args: MemoryEntryArgs<Kind, Role>) => MemoryEntry;
33
- questionKey: (content: string) => string;
34
- };
35
-
36
- function createMemoryEvents<Kind extends string = string, Role extends string = string>(
37
- options: MemoryEventsOptions,
38
- ): MemoryEvents<Kind, Role> {
39
- const { scenario, channel } = options;
40
- const questionKeyLength = options.questionKeyLength ?? 120;
41
- const summaryContentLength = options.summaryContentLength ?? 80;
42
-
43
- return {
44
- scope(conversationId?: string): MemoryScope {
45
- return { channel, scenario, ...(conversationId ? { conversationId } : {}) };
46
- },
47
- entry(args: MemoryEntryArgs<Kind, Role>): MemoryEntry {
48
- return {
49
- timestamp: args.timestamp ?? new Date().toISOString(),
50
- kind: args.kind,
51
- content: args.content,
52
- summary: args.summary ?? `${args.sender}: ${args.content.slice(0, summaryContentLength)}`,
53
- meta: { sender: args.sender, role: args.role, conversationId: args.conversationId, ...args.meta },
54
- };
55
- },
56
- questionKey(content: string): string {
57
- return content.replace(/\s+/g, " ").trim().slice(0, questionKeyLength);
58
- },
59
- };
60
- }
61
-
62
- export const SUPPORT_SCENARIO = "support-qa";
63
- export const SUPPORT_CHANNEL = "dingtalk";
64
-
65
- export const SUPPORT_MEMORY_KINDS = {
66
- userMessage: "user_message",
67
- /** 管理员/真人同事在群里的发言(学习通道的原料,勿删) */
68
- adminMessage: "admin_message",
69
- agentDecision: "agent_decision",
70
- agentReplySent: "agent_reply_sent",
71
- /** 升级找人已发群(bug/feature/kb_gap,带 Owner 指引或坦白无据)——不计入普通技术回答数量 */
72
- ownerEscalationSent: "owner_escalation_sent",
73
- /** bug/feature 无法解析 Owner(模块缺失/未知/缺联系人)——诊断用,不计回答 */
74
- ownerResolutionFailed: "owner_resolution_failed",
75
- sendFailed: "send_failed",
76
- /** agent 失败兜底静默(重试后仍失败)——与"判定型静默"分开审计 */
77
- agentFailure: "agent_failure",
78
- } as const;
79
-
80
- export type SupportMemoryKind = (typeof SUPPORT_MEMORY_KINDS)[keyof typeof SUPPORT_MEMORY_KINDS];
81
-
82
- export const SUPPORT_MEMORY_ROLES = {
83
- user: "user",
84
- /** 真人同事/管理员 */
85
- admin: "admin",
86
- agent: "agent",
87
- } as const;
88
-
89
- export type SupportMemoryRole = (typeof SUPPORT_MEMORY_ROLES)[keyof typeof SUPPORT_MEMORY_ROLES];
90
-
91
- /**
92
- * 决策事件(agent_decision)的正文占位。
93
- *
94
- * 决策记的是「意图」——action/module/reason 全在 meta 与 summary 里;话术正文的唯一真相是
95
- * 发送事实(agent_reply_sent / owner_escalation_sent,正文为 on_action 尾注合成后的最终文案)。
96
- * 决策里再存一份合成前的正文前缀,会让同一个答案在记忆/复盘语料里出现两个版本(半成品 + 成品),
97
- * 既浪费 token 又给学习沉淀留下歧义,因此只记占位。
98
- */
99
- export const SUPPORT_DECISION_CONTENT = {
100
- /** 决策要回复:正文见同一 workItem 的发送事实 */
101
- reply: "(已决策回复)",
102
- /** 决策不回复:静默口径的判定锚点(daily-review 旧数据回退仍依赖该字面量) */
103
- silent: "(静默)",
104
- } as const;
105
-
106
- const events = createMemoryEvents<SupportMemoryKind, SupportMemoryRole>({
107
- scenario: SUPPORT_SCENARIO,
108
- channel: SUPPORT_CHANNEL,
109
- });
110
-
111
- export const supportScope = events.scope;
112
- export const supportEntry = events.entry;
113
- export const questionKey = events.questionKey;