@soimy/dingtalk 3.2.0 → 3.3.0

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.
@@ -0,0 +1,643 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ appendFeedbackEvent,
4
+ appendOutboundReplySnapshot,
5
+ appendReflectionRecord,
6
+ appendSessionLearningNote,
7
+ deleteScopedRule,
8
+ FeedbackKind,
9
+ FeedbackEventRecord,
10
+ getTargetSet,
11
+ listAllScopedRules,
12
+ listActiveSessionLearningNotes,
13
+ listLearnedRules,
14
+ listOutboundReplySnapshots,
15
+ listTargetSets,
16
+ LearnedRuleRecord,
17
+ OutboundReplySnapshot,
18
+ ReflectionCategory,
19
+ ScopedLearnedRuleRecord,
20
+ disableScopedRule,
21
+ listTargetRules,
22
+ TargetSetRecord,
23
+ upsertTargetRule,
24
+ upsertTargetSet,
25
+ upsertLearnedRule,
26
+ } from "./feedback-learning-store";
27
+ import type { DingTalkConfig, MessageContent } from "./types";
28
+
29
+ const NEGATIVE_SIGNAL_PATTERNS: Array<{ pattern: RegExp; category: ReflectionCategory }> = [
30
+ { pattern: /(没看图|没看图片|看图|补发原图|别猜图)/i, category: "missing_image_context" },
31
+ { pattern: /(引用|原文|原消息|别猜|没拿到|没看到)/i, category: "quoted_context_missing" },
32
+ { pattern: /(不是这个意思|理解错|答偏|重新答|重新回答|我问的是)/i, category: "misunderstood_intent" },
33
+ ];
34
+
35
+ function buildRuleInstruction(category: ReflectionCategory): string {
36
+ switch (category) {
37
+ case "missing_image_context":
38
+ return "当用户要求看图/分析图片但当前上下文没有图片本体时,禁止臆测内容,先明确要求用户补发原图。";
39
+ case "quoted_context_missing":
40
+ return "当引用消息正文或附件不可见时,禁止根据上下文臆测引用内容,先说明缺失并请用户补发原文/原文件。";
41
+ case "misunderstood_intent":
42
+ return "当用户明显在纠正上一轮理解时,先复述其真实意图,再给出更直接的修正答案。";
43
+ case "positive_direct_answer":
44
+ return "保持直接、贴题、少绕弯的回答方式。";
45
+ case "generic_negative":
46
+ default:
47
+ return "若用户对上一轮回复不满意,优先缩短答案、减少假设,并先确认关键信息是否完整。";
48
+ }
49
+ }
50
+
51
+ function buildDiagnosis(kind: FeedbackKind, category: ReflectionCategory): string {
52
+ if (kind === "explicit_positive") {
53
+ return "用户通过显式正反馈认可了上一条回复,可以保留当前回答风格。";
54
+ }
55
+ switch (category) {
56
+ case "missing_image_context":
57
+ return "上一条回复很可能在缺少图片本体的情况下尝试分析图片,导致用户不满意。";
58
+ case "quoted_context_missing":
59
+ return "上一条回复很可能在引用正文/附件不可见时做了推断,导致用户不满意。";
60
+ case "misunderstood_intent":
61
+ return "用户在后续消息里明确纠正了上一轮理解,说明回答偏离了真实意图。";
62
+ case "generic_negative":
63
+ default:
64
+ return "用户对上一条回复不满意,但当前证据不足以归到更具体的错误类型。";
65
+ }
66
+ }
67
+
68
+ function inferCategory(params: {
69
+ kind: FeedbackKind;
70
+ signalText?: string;
71
+ snapshot?: OutboundReplySnapshot | null;
72
+ content?: MessageContent;
73
+ }): ReflectionCategory {
74
+ if (params.kind === "explicit_positive") {
75
+ return "positive_direct_answer";
76
+ }
77
+
78
+ const texts = [
79
+ params.signalText || "",
80
+ params.snapshot?.question || "",
81
+ params.snapshot?.answer || "",
82
+ params.content?.text || "",
83
+ ].join("\n");
84
+
85
+ if (params.kind === "explicit_negative") {
86
+ if (/图|图片|截图|看图/.test(params.snapshot?.question || "")) {
87
+ return "missing_image_context";
88
+ }
89
+ if (/引用|原文|原消息/.test(params.snapshot?.question || "")) {
90
+ return "quoted_context_missing";
91
+ }
92
+ }
93
+
94
+ for (const candidate of NEGATIVE_SIGNAL_PATTERNS) {
95
+ if (candidate.pattern.test(texts)) {
96
+ return candidate.category;
97
+ }
98
+ }
99
+ return "generic_negative";
100
+ }
101
+
102
+ function latestSnapshotForTarget(
103
+ storePath: string | undefined,
104
+ accountId: string,
105
+ targetId: string,
106
+ processQueryKey?: string,
107
+ ): OutboundReplySnapshot | null {
108
+ const snapshots = listOutboundReplySnapshots({ storePath, accountId, targetId });
109
+ if (snapshots.length === 0) {
110
+ return null;
111
+ }
112
+ if (processQueryKey) {
113
+ const matched = snapshots.find((snapshot) => snapshot.processQueryKey === processQueryKey);
114
+ if (matched) {
115
+ return matched;
116
+ }
117
+ }
118
+ return snapshots[0] || null;
119
+ }
120
+
121
+ function updateLearnedRule(
122
+ storePath: string | undefined,
123
+ accountId: string,
124
+ category: ReflectionCategory,
125
+ kind: FeedbackKind,
126
+ ): void {
127
+ if (!storePath || kind === "explicit_positive") {
128
+ return;
129
+ }
130
+ const ruleId = `rule_${category}`;
131
+ const existing = listLearnedRules({ storePath, accountId }).find((rule) => rule.ruleId === ruleId);
132
+ const negativeCount = (existing?.negativeCount || 0) + 1;
133
+ const positiveCount = existing?.positiveCount || 0;
134
+ const rule: LearnedRuleRecord = {
135
+ ruleId,
136
+ category,
137
+ instruction: buildRuleInstruction(category),
138
+ negativeCount,
139
+ positiveCount,
140
+ updatedAt: Date.now(),
141
+ enabled: negativeCount >= 2,
142
+ };
143
+ upsertLearnedRule({ storePath, accountId, rule });
144
+ }
145
+
146
+ export function isFeedbackLearningEnabled(config: DingTalkConfig | undefined): boolean {
147
+ const typed = config as (DingTalkConfig & { learningEnabled?: boolean; feedbackLearningEnabled?: boolean }) | undefined;
148
+ return Boolean(typed?.learningEnabled ?? typed?.feedbackLearningEnabled);
149
+ }
150
+
151
+ export function isFeedbackLearningAutoApplyEnabled(config: DingTalkConfig | undefined): boolean {
152
+ const typed = config as (DingTalkConfig & { learningAutoApply?: boolean; feedbackLearningAutoApply?: boolean }) | undefined;
153
+ return Boolean(typed?.learningAutoApply ?? typed?.feedbackLearningAutoApply);
154
+ }
155
+
156
+ export function recordOutboundReplyForLearning(params: {
157
+ enabled: boolean;
158
+ storePath?: string;
159
+ accountId: string;
160
+ targetId: string;
161
+ sessionKey: string;
162
+ question: string;
163
+ answer: string;
164
+ processQueryKey?: string;
165
+ mode?: "card" | "markdown";
166
+ }): void {
167
+ if (!params.enabled || !params.storePath || !params.answer.trim()) {
168
+ return;
169
+ }
170
+ appendOutboundReplySnapshot({
171
+ storePath: params.storePath,
172
+ accountId: params.accountId,
173
+ targetId: params.targetId,
174
+ snapshot: {
175
+ id: randomUUID(),
176
+ targetId: params.targetId,
177
+ sessionKey: params.sessionKey,
178
+ question: params.question,
179
+ answer: params.answer,
180
+ processQueryKey: params.processQueryKey,
181
+ mode: params.mode,
182
+ createdAt: Date.now(),
183
+ },
184
+ });
185
+ }
186
+
187
+ export function recordExplicitFeedbackLearning(params: {
188
+ enabled: boolean;
189
+ autoApply?: boolean;
190
+ storePath?: string;
191
+ accountId: string;
192
+ targetId: string;
193
+ feedbackType: "feedback_up" | "feedback_down";
194
+ userId?: string;
195
+ processQueryKey?: string;
196
+ noteTtlMs?: number;
197
+ }): void {
198
+ if (!params.enabled || !params.storePath) {
199
+ return;
200
+ }
201
+ const kind: FeedbackKind =
202
+ params.feedbackType === "feedback_up" ? "explicit_positive" : "explicit_negative";
203
+ const snapshot = latestSnapshotForTarget(
204
+ params.storePath,
205
+ params.accountId,
206
+ params.targetId,
207
+ params.processQueryKey,
208
+ );
209
+ const event: FeedbackEventRecord = {
210
+ id: randomUUID(),
211
+ kind,
212
+ targetId: params.targetId,
213
+ userId: params.userId,
214
+ processQueryKey: params.processQueryKey,
215
+ createdAt: Date.now(),
216
+ snapshotId: snapshot?.id,
217
+ };
218
+ appendFeedbackEvent({
219
+ storePath: params.storePath,
220
+ accountId: params.accountId,
221
+ targetId: params.targetId,
222
+ event,
223
+ });
224
+
225
+ const category = inferCategory({ kind, snapshot });
226
+ const reflection = {
227
+ id: randomUUID(),
228
+ targetId: params.targetId,
229
+ sourceEventId: event.id,
230
+ kind,
231
+ category,
232
+ diagnosis: buildDiagnosis(kind, category),
233
+ suggestedInstruction: buildRuleInstruction(category),
234
+ question: snapshot?.question,
235
+ answer: snapshot?.answer,
236
+ createdAt: Date.now(),
237
+ };
238
+ appendReflectionRecord({
239
+ storePath: params.storePath,
240
+ accountId: params.accountId,
241
+ targetId: params.targetId,
242
+ reflection,
243
+ });
244
+
245
+ if (params.autoApply && kind !== "explicit_positive") {
246
+ appendSessionLearningNote({
247
+ storePath: params.storePath,
248
+ accountId: params.accountId,
249
+ targetId: params.targetId,
250
+ ttlMs: params.noteTtlMs,
251
+ note: {
252
+ id: randomUUID(),
253
+ targetId: params.targetId,
254
+ instruction: reflection.suggestedInstruction,
255
+ source: kind,
256
+ category,
257
+ createdAt: Date.now(),
258
+ },
259
+ });
260
+ }
261
+ if (params.autoApply) {
262
+ updateLearnedRule(params.storePath, params.accountId, category, kind);
263
+ }
264
+ }
265
+
266
+ export function analyzeImplicitNegativeFeedback(params: {
267
+ enabled: boolean;
268
+ autoApply?: boolean;
269
+ storePath?: string;
270
+ accountId: string;
271
+ targetId: string;
272
+ signalText: string;
273
+ content: MessageContent;
274
+ noteTtlMs?: number;
275
+ }): void {
276
+ if (!params.enabled || !params.storePath) {
277
+ return;
278
+ }
279
+
280
+ const snapshot = latestSnapshotForTarget(params.storePath, params.accountId, params.targetId);
281
+ if (!snapshot) {
282
+ return;
283
+ }
284
+
285
+ const category = inferCategory({
286
+ kind: "implicit_negative",
287
+ signalText: params.signalText,
288
+ snapshot,
289
+ content: params.content,
290
+ });
291
+ if (category === "generic_negative" && !NEGATIVE_SIGNAL_PATTERNS.some((item) => item.pattern.test(params.signalText))) {
292
+ return;
293
+ }
294
+
295
+ const event: FeedbackEventRecord = {
296
+ id: randomUUID(),
297
+ kind: "implicit_negative",
298
+ targetId: params.targetId,
299
+ createdAt: Date.now(),
300
+ signalText: params.signalText,
301
+ snapshotId: snapshot.id,
302
+ sessionKey: snapshot.sessionKey,
303
+ };
304
+ appendFeedbackEvent({
305
+ storePath: params.storePath,
306
+ accountId: params.accountId,
307
+ targetId: params.targetId,
308
+ event,
309
+ });
310
+
311
+ const reflection = {
312
+ id: randomUUID(),
313
+ targetId: params.targetId,
314
+ sourceEventId: event.id,
315
+ kind: "implicit_negative" as const,
316
+ category,
317
+ diagnosis: buildDiagnosis("implicit_negative", category),
318
+ suggestedInstruction: buildRuleInstruction(category),
319
+ question: snapshot.question,
320
+ answer: snapshot.answer,
321
+ createdAt: Date.now(),
322
+ };
323
+ appendReflectionRecord({
324
+ storePath: params.storePath,
325
+ accountId: params.accountId,
326
+ targetId: params.targetId,
327
+ reflection,
328
+ });
329
+ if (params.autoApply) {
330
+ appendSessionLearningNote({
331
+ storePath: params.storePath,
332
+ accountId: params.accountId,
333
+ targetId: params.targetId,
334
+ ttlMs: params.noteTtlMs,
335
+ note: {
336
+ id: randomUUID(),
337
+ targetId: params.targetId,
338
+ instruction: reflection.suggestedInstruction,
339
+ source: "implicit_negative",
340
+ category,
341
+ createdAt: Date.now(),
342
+ },
343
+ });
344
+ updateLearnedRule(params.storePath, params.accountId, category, "implicit_negative");
345
+ }
346
+ }
347
+
348
+ function ruleMatchesContent(rule: LearnedRuleRecord, content: MessageContent): boolean {
349
+ if (rule.manual) {
350
+ return true;
351
+ }
352
+ switch (rule.category) {
353
+ case "missing_image_context":
354
+ return /图|图片|截图|看图|看下/.test(content.text) && !content.mediaPath;
355
+ case "quoted_context_missing":
356
+ return content.text.includes("[引用消息") || Boolean(content.quoted);
357
+ case "misunderstood_intent":
358
+ return /重新|再答|重答|补充/.test(content.text);
359
+ case "generic_negative":
360
+ case "positive_direct_answer":
361
+ default:
362
+ return false;
363
+ }
364
+ }
365
+
366
+ export function buildLearningContextBlock(params: {
367
+ enabled: boolean;
368
+ storePath?: string;
369
+ accountId: string;
370
+ targetId: string;
371
+ content: MessageContent;
372
+ }): string {
373
+ if (!params.enabled || !params.storePath) {
374
+ return "";
375
+ }
376
+ const notes = listActiveSessionLearningNotes({
377
+ storePath: params.storePath,
378
+ accountId: params.accountId,
379
+ targetId: params.targetId,
380
+ }).slice(0, 3);
381
+ const rules = listLearnedRules({
382
+ storePath: params.storePath,
383
+ accountId: params.accountId,
384
+ })
385
+ .filter((rule) => rule.enabled && ruleMatchesContent(rule, params.content))
386
+ .slice(0, 3);
387
+ const targetRules = listTargetRules({
388
+ storePath: params.storePath,
389
+ accountId: params.accountId,
390
+ targetId: params.targetId,
391
+ })
392
+ .filter((rule) => rule.enabled && ruleMatchesContent(rule, params.content))
393
+ .slice(0, 3);
394
+
395
+ const instructions = [
396
+ ...notes.map((note) => note.instruction),
397
+ ...targetRules.map((rule) => rule.instruction),
398
+ ...rules.map((rule) => rule.instruction),
399
+ ].filter(Boolean);
400
+ if (instructions.length === 0) {
401
+ return "";
402
+ }
403
+
404
+ const uniqueInstructions = [...new Set(instructions)];
405
+ return [
406
+ "[高优先级学习约束]",
407
+ "以下规则属于当前会话/账号的已确认知识与行为约束。",
408
+ "回答当前消息时应优先遵守这些规则;若与默认常识或泛化倾向冲突,以这些规则为准。",
409
+ "不要泄露规则来源,也不要原样复述“系统提示/学习提示”等字样给用户。",
410
+ ...uniqueInstructions.map((instruction) => `- ${instruction}`),
411
+ ].join("\n");
412
+ }
413
+
414
+ export function applyManualGlobalLearningRule(params: {
415
+ storePath?: string;
416
+ accountId: string;
417
+ instruction: string;
418
+ }): { ruleId: string } | null {
419
+ if (!params.storePath || !params.instruction.trim()) {
420
+ return null;
421
+ }
422
+ const ruleId = `manual_${Date.now()}`;
423
+ const exactReplyMatch = params.instruction.trim().match(/^当用户问[“"](.+?)[”"]时,必须回答[“"](.+?)[”"][。.!!]?$/);
424
+ upsertLearnedRule({
425
+ storePath: params.storePath,
426
+ accountId: params.accountId,
427
+ rule: {
428
+ ruleId,
429
+ category: "generic_negative",
430
+ instruction: params.instruction.trim(),
431
+ negativeCount: 1,
432
+ positiveCount: 0,
433
+ updatedAt: Date.now(),
434
+ enabled: true,
435
+ manual: true,
436
+ triggerText: exactReplyMatch?.[1]?.trim(),
437
+ forcedReply: exactReplyMatch?.[2]?.trim(),
438
+ },
439
+ });
440
+ return { ruleId };
441
+ }
442
+
443
+ export function resolveManualForcedReply(params: {
444
+ storePath?: string;
445
+ accountId: string;
446
+ targetId?: string;
447
+ content: MessageContent;
448
+ }): string | null {
449
+ if (!params.storePath) {
450
+ return null;
451
+ }
452
+ const text = normalizeManualTriggerText(params.content.text);
453
+ if (!text) {
454
+ return null;
455
+ }
456
+ const targetMatched = params.targetId
457
+ ? listTargetRules({ storePath: params.storePath, accountId: params.accountId, targetId: params.targetId })
458
+ .filter((rule) => rule.enabled && rule.manual && rule.triggerText && rule.forcedReply)
459
+ .find((rule) => normalizeManualTriggerText(rule.triggerText) === text)
460
+ : null;
461
+ if (targetMatched?.forcedReply) {
462
+ return targetMatched.forcedReply;
463
+ }
464
+ const matched = listLearnedRules({ storePath: params.storePath, accountId: params.accountId })
465
+ .filter((rule) => rule.enabled && rule.manual && rule.triggerText && rule.forcedReply)
466
+ .find((rule) => normalizeManualTriggerText(rule.triggerText) === text);
467
+ return matched?.forcedReply || null;
468
+ }
469
+
470
+ export function applyManualSessionLearningNote(params: {
471
+ storePath?: string;
472
+ accountId: string;
473
+ targetId: string;
474
+ instruction: string;
475
+ noteTtlMs?: number;
476
+ }): boolean {
477
+ if (!params.storePath || !params.instruction.trim()) {
478
+ return false;
479
+ }
480
+ appendSessionLearningNote({
481
+ storePath: params.storePath,
482
+ accountId: params.accountId,
483
+ targetId: params.targetId,
484
+ ttlMs: params.noteTtlMs,
485
+ note: {
486
+ id: randomUUID(),
487
+ targetId: params.targetId,
488
+ instruction: params.instruction.trim(),
489
+ source: "implicit_negative",
490
+ category: "generic_negative",
491
+ createdAt: Date.now(),
492
+ },
493
+ });
494
+ return true;
495
+ }
496
+
497
+ export function applyManualTargetLearningRule(params: {
498
+ storePath?: string;
499
+ accountId: string;
500
+ targetId: string;
501
+ instruction: string;
502
+ }): { ruleId: string } | null {
503
+ if (!params.storePath || !params.targetId.trim() || !params.instruction.trim()) {
504
+ return null;
505
+ }
506
+ const ruleId = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
507
+ const exactReplyMatch = params.instruction.trim().match(/^当用户问[“"](.+?)[”"]时,必须回答[“"](.+?)[”"][。.!!]?$/);
508
+ upsertTargetRule({
509
+ storePath: params.storePath,
510
+ accountId: params.accountId,
511
+ targetId: params.targetId,
512
+ rule: {
513
+ ruleId,
514
+ category: "generic_negative",
515
+ instruction: params.instruction.trim(),
516
+ negativeCount: 1,
517
+ positiveCount: 0,
518
+ updatedAt: Date.now(),
519
+ enabled: true,
520
+ manual: true,
521
+ triggerText: exactReplyMatch?.[1]?.trim(),
522
+ forcedReply: exactReplyMatch?.[2]?.trim(),
523
+ },
524
+ });
525
+ return { ruleId };
526
+ }
527
+
528
+ export function applyManualTargetsLearningRule(params: {
529
+ storePath?: string;
530
+ accountId: string;
531
+ targetIds: string[];
532
+ instruction: string;
533
+ }): Array<{ targetId: string; ruleId: string }> {
534
+ if (!params.storePath) {
535
+ return [];
536
+ }
537
+ return params.targetIds
538
+ .map((targetId) => applyManualTargetLearningRule({
539
+ storePath: params.storePath,
540
+ accountId: params.accountId,
541
+ targetId,
542
+ instruction: params.instruction,
543
+ }))
544
+ .map((result, index) => result ? { targetId: params.targetIds[index], ruleId: result.ruleId } : null)
545
+ .filter(Boolean) as Array<{ targetId: string; ruleId: string }>;
546
+ }
547
+
548
+ export function disableManualRule(params: {
549
+ storePath?: string;
550
+ accountId: string;
551
+ ruleId: string;
552
+ }): { existed: boolean; scope?: "global" | "target"; targetId?: string } {
553
+ return disableScopedRule(params);
554
+ }
555
+
556
+ export function deleteManualRule(params: {
557
+ storePath?: string;
558
+ accountId: string;
559
+ ruleId: string;
560
+ }): { existed: boolean; scope?: "global" | "target"; targetId?: string } {
561
+ return deleteScopedRule(params);
562
+ }
563
+
564
+ export function createOrUpdateTargetSet(params: {
565
+ storePath?: string;
566
+ accountId: string;
567
+ name: string;
568
+ targetIds: string[];
569
+ }): boolean {
570
+ if (!params.storePath || !params.name.trim() || params.targetIds.length === 0) {
571
+ return false;
572
+ }
573
+ upsertTargetSet(params);
574
+ return true;
575
+ }
576
+
577
+ export function listLearningTargetSets(params: {
578
+ storePath?: string;
579
+ accountId: string;
580
+ }): TargetSetRecord[] {
581
+ return listTargetSets(params);
582
+ }
583
+
584
+ export function applyTargetSetLearningRule(params: {
585
+ storePath?: string;
586
+ accountId: string;
587
+ name: string;
588
+ instruction: string;
589
+ }): Array<{ targetId: string; ruleId: string }> {
590
+ if (!params.storePath) {
591
+ return [];
592
+ }
593
+ const targetSet = getTargetSet({
594
+ storePath: params.storePath,
595
+ accountId: params.accountId,
596
+ name: params.name,
597
+ });
598
+ if (!targetSet) {
599
+ return [];
600
+ }
601
+ return applyManualTargetsLearningRule({
602
+ storePath: params.storePath,
603
+ accountId: params.accountId,
604
+ targetIds: targetSet.targetIds,
605
+ instruction: params.instruction,
606
+ });
607
+ }
608
+
609
+ export function listScopedLearningRules(params: {
610
+ storePath?: string;
611
+ accountId: string;
612
+ }): ScopedLearnedRuleRecord[] {
613
+ return listAllScopedRules(params);
614
+ }
615
+
616
+ export function normalizeManualTriggerText(input: string | undefined): string {
617
+ return stripLeadingInvisibleChars(String(input || ""))
618
+ .trim()
619
+ .replace(/[。.!!??]+$/g, "")
620
+ .replace(/\s+/g, " ")
621
+ .toLowerCase();
622
+ }
623
+
624
+ function stripLeadingInvisibleChars(value: string): string {
625
+ let index = 0;
626
+ while (index < value.length) {
627
+ const codePoint = value.codePointAt(index);
628
+ if (
629
+ codePoint === undefined ||
630
+ !(
631
+ (codePoint >= 0x00 && codePoint <= 0x1f) ||
632
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
633
+ (codePoint >= 0x200b && codePoint <= 0x200f) ||
634
+ codePoint === 0x2060 ||
635
+ codePoint === 0xfeff
636
+ )
637
+ ) {
638
+ break;
639
+ }
640
+ index += codePoint > 0xffff ? 2 : 1;
641
+ }
642
+ return value.slice(index);
643
+ }