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.
- package/README.md +1 -6
- package/dist/{chunk-IGB6WDLR.js → chunk-UNPV6DTX.js} +1 -1
- package/dist/cli.js +47 -47
- package/dist/config-set-FBOQBRSJ.js +13 -0
- package/dist/{config-ui-server-CORDAOYI.js → config-ui-server-IUSACAOT.js} +1 -1
- package/package.json +3 -3
- package/dist/config-set-EGLLIMVJ.js +0 -13
- package/templates/support-qa/.env.example +0 -16
- package/templates/support-qa/aitable-workflow.config.yml +0 -46
- package/templates/support-qa/coding-agents.config.json +0 -18
- package/templates/support-qa/lib/__tests__/declarative-matcher.test.ts +0 -240
- package/templates/support-qa/lib/__tests__/dingtalk-group-membership.test.ts +0 -144
- package/templates/support-qa/lib/__tests__/memory-events.test.ts +0 -90
- package/templates/support-qa/lib/__tests__/sandbox-agent.test.ts +0 -382
- package/templates/support-qa/lib/__tests__/thread-adapter.test.ts +0 -242
- package/templates/support-qa/lib/__tests__/thread-observe-step.test.ts +0 -391
- package/templates/support-qa/lib/__tests__/thread-observer-handler.test.ts +0 -274
- package/templates/support-qa/lib/action-dispatch.ts +0 -262
- package/templates/support-qa/lib/contract.ts +0 -253
- package/templates/support-qa/lib/declarative-matcher.ts +0 -398
- package/templates/support-qa/lib/dingtalk-group-membership.ts +0 -158
- package/templates/support-qa/lib/guards.ts +0 -18
- package/templates/support-qa/lib/memory-events.ts +0 -113
- package/templates/support-qa/lib/owner-resolver.ts +0 -94
- package/templates/support-qa/lib/preflight.ts +0 -294
- package/templates/support-qa/lib/profile.ts +0 -51
- package/templates/support-qa/lib/sandbox-agent.ts +0 -653
- package/templates/support-qa/lib/thread-adapter.ts +0 -281
- package/templates/support-qa/lib/thread-digest.ts +0 -144
- package/templates/support-qa/npmrc +0 -1
- package/templates/support-qa/package.json +0 -27
- package/templates/support-qa/profiles/aitable/profile/escalation/contacts.json +0 -37
- package/templates/support-qa/profiles/aitable/profile/escalation/owner-map.json +0 -18
- package/templates/support-qa/profiles/aitable/profile/fastpath/known-answers.yml +0 -561
- package/templates/support-qa/profiles/aitable/profile/profile.yml +0 -219
- package/templates/support-qa/profiles/aitable/profile/prompts/kb-editor.md +0 -3
- package/templates/support-qa/profiles/aitable/profile/prompts/persona.md +0 -49
- package/templates/support-qa/profiles/aitable/profile/prompts/troubleshooter.md +0 -5
- package/templates/support-qa/profiles/aitable/profile/troubleshoot/classifiers.yml +0 -36
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/SKILL.md +0 -58
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-import-troubleshooter/SKILL.md +0 -485
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-openapi-troubleshooter/SKILL.md +0 -183
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/import-openapi-e2e-troubleshooter/SKILL.md +0 -459
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-datasource-troubleshooter/SKILL.md +0 -1286
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-field-troubleshooter/SKILL.md +0 -673
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/spreadsheet-datasync-troubleshooter/SKILL.md +0 -322
- package/templates/support-qa/profiles/default/profile/fastpath/known-answers.yml +0 -32
- package/templates/support-qa/profiles/default/profile/profile.yml +0 -217
- package/templates/support-qa/profiles/default/profile/troubleshoot/classifiers.yml +0 -37
- package/templates/support-qa/profiles/default/wiki/support//345/270/270/350/247/201/351/227/256/351/242/230.md +0 -10
- package/templates/support-qa/tsconfig.json +0 -16
- package/templates/support-qa/vitest.config.ts +0 -9
- package/templates/support-qa/workflows/admin-kb-update/recipes/kb-refine.recipe.ts +0 -137
- package/templates/support-qa/workflows/admin-kb-update/recipes/kb_sync_handler.recipe.ts +0 -119
- package/templates/support-qa/workflows/admin-kb-update/workflow.yml +0 -96
- package/templates/support-qa/workflows/config-sync/recipes/config_sync.recipe.ts +0 -668
- package/templates/support-qa/workflows/config-sync/recipes/config_sync_handler.recipe.ts +0 -16
- package/templates/support-qa/workflows/config-sync/workflow.yml +0 -62
- package/templates/support-qa/workflows/daily-review/recipes/daily_review.recipe.ts +0 -602
- package/templates/support-qa/workflows/daily-review/recipes/daily_review_handler.recipe.ts +0 -26
- package/templates/support-qa/workflows/daily-review/workflow.yml +0 -65
- package/templates/support-qa/workflows/group-qa/recipes/answer.recipe.ts +0 -231
- package/templates/support-qa/workflows/group-qa/recipes/conversation_handler.recipe.ts +0 -394
- package/templates/support-qa/workflows/group-qa/recipes/fastpath.recipe.ts +0 -72
- package/templates/support-qa/workflows/group-qa/recipes/on-action.recipe.ts +0 -225
- package/templates/support-qa/workflows/group-qa/recipes/reply-to-group.recipe.ts +0 -202
- package/templates/support-qa/workflows/group-qa/recipes/troubleshoot.recipe.ts +0 -273
- package/templates/support-qa/workflows/group-qa/workflow.yml +0 -268
- package/templates/support-qa/workflows/thread-observer/README.md +0 -96
- package/templates/support-qa/workflows/thread-observer/recipes/thread_observe.recipe.ts +0 -415
- package/templates/support-qa/workflows/thread-observer/recipes/thread_observer_handler.recipe.ts +0 -213
- package/templates/support-qa/workflows/thread-observer/workflow.yml +0 -185
- package/templates/support-qa/workspaces/daily-review/AGENTS.md +0 -233
- package/templates/support-qa/workspaces/kb-editor/AGENTS.md +0 -32
- package/templates/support-qa/workspaces/support/AGENTS.md +0 -156
- package/templates/support-qa/workspaces/thread-observer/AGENTS.md +0 -156
- package/templates/support-qa/workspaces/troubleshooter/AGENTS.md +0 -71
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { EventHandlerRecipeDefinition, HandlerInitContext, HandlerInstance } from "aitable-workflow-core";
|
|
2
|
-
|
|
3
|
-
const recipe: EventHandlerRecipeDefinition = {
|
|
4
|
-
meta: { id: "config_sync_handler", name: "配置同步触发器", description: "cron tick → 建一条配置同步工单" },
|
|
5
|
-
create(ctx: HandlerInitContext): HandlerInstance {
|
|
6
|
-
return {
|
|
7
|
-
onEvent(event) {
|
|
8
|
-
const firedAt = String(event.payload?.firedAt ?? new Date().toISOString());
|
|
9
|
-
ctx.log(`配置同步触发 @ ${firedAt}`);
|
|
10
|
-
ctx.pushEvent({ action: "create_record", fields: { "标题": `配置同步 ${firedAt}` } });
|
|
11
|
-
},
|
|
12
|
-
stop() {},
|
|
13
|
-
};
|
|
14
|
-
},
|
|
15
|
-
};
|
|
16
|
-
export default recipe;
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
meta:
|
|
2
|
-
name: "群/管理员/模块人员配置同步"
|
|
3
|
-
version: "1.0.0"
|
|
4
|
-
domain: config_sync
|
|
5
|
-
description: "定时从固定 AI 表格拉群配置、管理员与功能模块人员,发布到 globalVar(group_configs/admins/module_owners,供 group-qa 活读);不发群"
|
|
6
|
-
|
|
7
|
-
notification:
|
|
8
|
-
channel: "none"
|
|
9
|
-
|
|
10
|
-
tracker:
|
|
11
|
-
label: "群与管理员配置同步留痕表"
|
|
12
|
-
base_id: "" # 与其它工作流同 base;table_id 留空 setup 自动建表回写
|
|
13
|
-
table_id: ""
|
|
14
|
-
states:
|
|
15
|
-
initial: "待处理"
|
|
16
|
-
active: [ "处理中" ]
|
|
17
|
-
terminal: [ "已完成", "已关闭" ]
|
|
18
|
-
blocked: "已阻塞"
|
|
19
|
-
fields:
|
|
20
|
-
state: "状态"
|
|
21
|
-
title: "标题"
|
|
22
|
-
current_step: "当前步骤"
|
|
23
|
-
event_log: "事件日志"
|
|
24
|
-
field_definitions:
|
|
25
|
-
- name: 同步结果
|
|
26
|
-
type: text
|
|
27
|
-
- name: 群数量
|
|
28
|
-
type: text
|
|
29
|
-
- name: 管理员数量
|
|
30
|
-
type: text
|
|
31
|
-
- name: 模块人员数量
|
|
32
|
-
type: text
|
|
33
|
-
views:
|
|
34
|
-
- id: default_grid
|
|
35
|
-
type: Grid
|
|
36
|
-
name: 表格
|
|
37
|
-
field_order: [ 标题, 同步结果, 群数量, 管理员数量, 模块人员数量, 状态, 当前步骤, 事件日志 ]
|
|
38
|
-
description: 配置同步留痕
|
|
39
|
-
|
|
40
|
-
event_sources:
|
|
41
|
-
- name: 定时同步
|
|
42
|
-
type: cron_scheduler
|
|
43
|
-
handler: config_sync_handler
|
|
44
|
-
record_policy: on_demand # 无变更的 tick 不建行(见 config_sync recipe 的 tracker:false)
|
|
45
|
-
|
|
46
|
-
steps:
|
|
47
|
-
- id: config_sync
|
|
48
|
-
name: "拉取并固化群/管理员/模块人员配置"
|
|
49
|
-
recipe: config_sync
|
|
50
|
-
auto: true
|
|
51
|
-
input_mapping: {}
|
|
52
|
-
output_mapping:
|
|
53
|
-
sync_result: "同步结果"
|
|
54
|
-
group_count: "群数量"
|
|
55
|
-
admin_count: "管理员数量"
|
|
56
|
-
owner_count: "模块人员数量"
|
|
57
|
-
|
|
58
|
-
edges:
|
|
59
|
-
- from: __start__
|
|
60
|
-
to: config_sync
|
|
61
|
-
- from: config_sync
|
|
62
|
-
to: __end__
|
|
@@ -1,602 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { type RecipeDefinition, type RecipeContext, type RecipeResult, type TableRef, tableRefConfigSchema, type MemoryScope, type MemoryEntry, isGlobalVarRef, resolveGlobalVarRef, yesterdayRange, normalizeTimezone } from "aitable-workflow-core";
|
|
5
|
-
import { SUPPORT_MEMORY_KINDS, questionKey, supportScope } from "../../../lib/memory-events.js";
|
|
6
|
-
import { KB_STATUS } from "../../../lib/contract.js";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 每日复盘 Step Recipe(记忆叠加版)
|
|
10
|
-
*
|
|
11
|
-
* cron_scheduler 每日凌晨触发(on_demand) → 读取昨日短期记忆 → coding.run 压缩+统计+抽取
|
|
12
|
-
* → memory.compact 写共享长期记忆 → table.upsertRecordIn 写每日统计表
|
|
13
|
-
* → table.createRecordsIn 写产品需求池。
|
|
14
|
-
*
|
|
15
|
-
* 无活动(各群昨日无短期记忆)→ 返回 tracker:false,on_demand 下不建复盘留痕行。
|
|
16
|
-
*
|
|
17
|
-
* 短期记忆按群隔离(含 conversationId),长期记忆产品级共享(不含 conversationId)。
|
|
18
|
-
* coding.run 用 workspaces/daily-review/AGENTS.md 承载全部压缩/统计/抽取规范。
|
|
19
|
-
* 各群消息不内联进 prompt(单群 500 条可达数百 KB,会打爆 agent),而是落盘到工作目录下
|
|
20
|
-
* .today/<序号>-<群ID>.md,prompt 只给文件清单让 agent 自行 Read;coding.run 结束即删除。
|
|
21
|
-
*
|
|
22
|
-
* 群列表适配:群是动态的,由 config-sync 发布到 globalVar(group_configs)。
|
|
23
|
-
* step.config.conversation_ids 二选一:静态数组,或引用 ${globalVar.group_configs}(无隐式回退)。
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
// ─── 配置解析 ──────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
interface ReviewConfig {
|
|
29
|
-
/** 复盘群来源:静态字符串数组,或 "${globalVar.group_configs}" 引用(运行期解析) */
|
|
30
|
-
conversationIds: unknown;
|
|
31
|
-
dailyStatsTable: TableRef | null;
|
|
32
|
-
productBacklogTable: TableRef | null;
|
|
33
|
-
/** 学习候选写入表(= group-qa 群问答表,reference 模式,用户填 base/table) */
|
|
34
|
-
learningCandidateTable: TableRef | null;
|
|
35
|
-
savedMinutesPerReply: number;
|
|
36
|
-
/** 复盘日期计算时区(IANA),应与 cron 的 timezone 一致;默认 Asia/Shanghai */
|
|
37
|
-
timezone: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isTableRefConfigured(raw: unknown): raw is { baseId: string; tableId: string } {
|
|
41
|
-
return !!raw && typeof raw === "object" && typeof (raw as Record<string, unknown>).baseId === "string" && typeof (raw as Record<string, unknown>).tableId === "string";
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** reference 模式表:要求 baseId 与 tableId 均非空(空 = 未配置,跳过而非误写) */
|
|
45
|
-
function configuredTableRef(raw: unknown): TableRef | null {
|
|
46
|
-
if (!raw || typeof raw !== "object") return null;
|
|
47
|
-
const baseId = String((raw as Record<string, unknown>).baseId ?? "").trim();
|
|
48
|
-
const tableId = String((raw as Record<string, unknown>).tableId ?? "").trim();
|
|
49
|
-
return baseId && tableId ? { baseId, tableId } : null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function parseConfig(raw: Record<string, unknown>): ReviewConfig {
|
|
53
|
-
const dst = raw.daily_stats_table as { baseId?: string; tableId?: string } | undefined;
|
|
54
|
-
const pbt = raw.product_backlog_table as { baseId?: string; tableId?: string } | undefined;
|
|
55
|
-
const saved = typeof raw.saved_minutes_per_reply === "number" ? raw.saved_minutes_per_reply : 15;
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
conversationIds: raw.conversation_ids,
|
|
59
|
-
dailyStatsTable: isTableRefConfigured(dst) ? { baseId: dst.baseId, tableId: dst.tableId } : null,
|
|
60
|
-
productBacklogTable: isTableRefConfigured(pbt) ? { baseId: pbt.baseId, tableId: pbt.tableId } : null,
|
|
61
|
-
learningCandidateTable: configuredTableRef(raw.learning_candidate_table),
|
|
62
|
-
savedMinutesPerReply: saved,
|
|
63
|
-
timezone: normalizeTimezone(typeof raw.timezone === "string" ? raw.timezone : undefined),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* 解析复盘群列表:显式二选一,无隐式回退。
|
|
69
|
-
* - 静态字符串数组 → 直接用
|
|
70
|
-
* - "${globalVar.group_configs}" 引用 → 活读并抽取 groupId(支持 [{groupId}] / ["cid"])
|
|
71
|
-
*/
|
|
72
|
-
function resolveConversationIds(raw: unknown, get: (path: string) => unknown): string[] {
|
|
73
|
-
if (isGlobalVarRef(raw)) return extractGroupIds(resolveGlobalVarRef(raw, get));
|
|
74
|
-
if (Array.isArray(raw)) return raw.map((s) => String(s).trim()).filter(Boolean);
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 从群配置数组抽取 groupId([{groupId}] 或 ["cid"]);非数组/空 → 空数组 */
|
|
79
|
-
function extractGroupIds(list: unknown): string[] {
|
|
80
|
-
if (!Array.isArray(list)) return [];
|
|
81
|
-
return list
|
|
82
|
-
.map((g) => (typeof g === "string" ? g : g && typeof g === "object" ? String((g as Record<string, unknown>).groupId ?? "") : ""))
|
|
83
|
-
.map((s) => s.trim())
|
|
84
|
-
.filter(Boolean);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ─── JSON 提取 ─────────────────────────────────────────
|
|
88
|
-
|
|
89
|
-
interface ReviewResult {
|
|
90
|
-
summary: string;
|
|
91
|
-
metrics: DailyReviewMetric[];
|
|
92
|
-
needsBugs: Array<{
|
|
93
|
-
type: string;
|
|
94
|
-
title: string;
|
|
95
|
-
description: string;
|
|
96
|
-
sourceMessage: string;
|
|
97
|
-
sender: string;
|
|
98
|
-
sourceGroup: string;
|
|
99
|
-
priority: string;
|
|
100
|
-
}>;
|
|
101
|
-
/** 学习候选:提问→真人解答→确认 链(learning.from_admin_replies 开启时提炼;否则空) */
|
|
102
|
-
learningCandidates: Array<{
|
|
103
|
-
question: string;
|
|
104
|
-
answer: string;
|
|
105
|
-
sourceGroup: string;
|
|
106
|
-
asker?: string;
|
|
107
|
-
resolver?: string;
|
|
108
|
-
sourceContext?: string;
|
|
109
|
-
}>;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
type DailyReviewMetric = {
|
|
113
|
-
conversationId: string;
|
|
114
|
-
questionCount: number;
|
|
115
|
-
agentAnsweredCount?: number;
|
|
116
|
-
humanAnsweredCount?: number;
|
|
117
|
-
answeredCount: number;
|
|
118
|
-
unansweredCount: number;
|
|
119
|
-
silentCount?: number;
|
|
120
|
-
sendFailedCount?: number;
|
|
121
|
-
kbMissCount?: number;
|
|
122
|
-
demandCount?: number;
|
|
123
|
-
bugCount?: number;
|
|
124
|
-
servedUsers: number;
|
|
125
|
-
avgFirstResponseMinutes?: number;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
function num(value: unknown, fallback = 0): number {
|
|
129
|
-
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function includesAny(text: string, patterns: string[]): boolean {
|
|
133
|
-
return patterns.some((pattern) => text.includes(pattern));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function computeAvgFirstResponseMinutes(entries: MemoryEntry[]): number {
|
|
137
|
-
const responseMinutes: number[] = [];
|
|
138
|
-
const answerKinds = new Set<string>([
|
|
139
|
-
SUPPORT_MEMORY_KINDS.agentReplySent,
|
|
140
|
-
SUPPORT_MEMORY_KINDS.adminMessage,
|
|
141
|
-
]);
|
|
142
|
-
|
|
143
|
-
const sorted = [...entries].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
144
|
-
|
|
145
|
-
// 关联键索引:user_message 正文归一化 → 首次提问时间。agent 侧事件带 meta.question 回指提问,
|
|
146
|
-
// 据此精确配对;纯按时序 FIFO 时,一个被静默的提问会把后续回复错配到它身上。
|
|
147
|
-
const questionFirstTs = new Map<string, number>();
|
|
148
|
-
const pendingQuestionTimes: number[] = [];
|
|
149
|
-
const matchedKeys = new Set<string>();
|
|
150
|
-
|
|
151
|
-
for (const entry of sorted) {
|
|
152
|
-
const ts = Date.parse(entry.timestamp);
|
|
153
|
-
if (!Number.isFinite(ts)) continue;
|
|
154
|
-
if (entry.kind === SUPPORT_MEMORY_KINDS.userMessage) {
|
|
155
|
-
const key = questionKey(entry.content);
|
|
156
|
-
if (key && !questionFirstTs.has(key)) questionFirstTs.set(key, ts);
|
|
157
|
-
pendingQuestionTimes.push(ts);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (!answerKinds.has(entry.kind)) continue;
|
|
161
|
-
|
|
162
|
-
const key = typeof entry.meta?.question === "string" ? questionKey(entry.meta.question) : "";
|
|
163
|
-
const questionTs = key ? questionFirstTs.get(key) : undefined;
|
|
164
|
-
if (questionTs !== undefined) {
|
|
165
|
-
// 同一提问只计首次响应
|
|
166
|
-
if (matchedKeys.has(key)) continue;
|
|
167
|
-
matchedKeys.add(key);
|
|
168
|
-
if (ts >= questionTs) responseMinutes.push((ts - questionTs) / 60_000);
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
// 旧数据(无 meta.question,如真人 admin_message)回退时序配对
|
|
172
|
-
if (pendingQuestionTimes.length === 0) continue;
|
|
173
|
-
const fallbackTs = pendingQuestionTimes.shift()!;
|
|
174
|
-
if (ts >= fallbackTs) responseMinutes.push((ts - fallbackTs) / 60_000);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (responseMinutes.length === 0) return 0;
|
|
178
|
-
const avg = responseMinutes.reduce((sum, value) => sum + value, 0) / responseMinutes.length;
|
|
179
|
-
return Math.round(avg * 100) / 100;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function computeDeterministicMetric(
|
|
183
|
-
conversationId: string,
|
|
184
|
-
entries: MemoryEntry[],
|
|
185
|
-
needsBugs: ReviewResult["needsBugs"],
|
|
186
|
-
): DailyReviewMetric {
|
|
187
|
-
const userMessages = entries.filter((entry) => entry.kind === SUPPORT_MEMORY_KINDS.userMessage);
|
|
188
|
-
const agentAnsweredCount = entries.filter((entry) => entry.kind === SUPPORT_MEMORY_KINDS.agentReplySent).length;
|
|
189
|
-
// 口径说明:support-qa 里真人同事发言以 admin_message 入短期记忆(学习通道原料),
|
|
190
|
-
// 这里用它作为“真人回复”的近似口径(发言≠必然在答题,复盘摘要里由 agent 结合上下文修正)。
|
|
191
|
-
// owner_escalation_sent(bug/feature/kb_gap 升级找人)不计入已回答(spec §13)。
|
|
192
|
-
const humanAnsweredCount = entries.filter((entry) => entry.kind === SUPPORT_MEMORY_KINDS.adminMessage).length;
|
|
193
|
-
const answeredCount = agentAnsweredCount + humanAnsweredCount;
|
|
194
|
-
// 静默口径:新数据用 meta.action(群行为为 silent 的 action 闭集;Bug/Feature 升级不算静默),
|
|
195
|
-
// 旧数据(无 meta.action)回退历史判定
|
|
196
|
-
const silentActions = new Set(["kb_gap", "no_question", "human_handled", "owner_mentioned", "safety", "agent_failure"]);
|
|
197
|
-
const isSilentDecision = (entry: MemoryEntry) =>
|
|
198
|
-
typeof entry.meta?.action === "string" && entry.meta.action
|
|
199
|
-
? silentActions.has(String(entry.meta.action))
|
|
200
|
-
: entry.content.trim() === "(静默)" || entry.meta?.shouldReply !== true;
|
|
201
|
-
const silentDecisions = entries.filter((entry) => {
|
|
202
|
-
if (entry.kind !== SUPPORT_MEMORY_KINDS.agentDecision) return false;
|
|
203
|
-
return isSilentDecision(entry);
|
|
204
|
-
});
|
|
205
|
-
const sendFailedCount = entries.filter((entry) => entry.kind === SUPPORT_MEMORY_KINDS.sendFailed).length;
|
|
206
|
-
// 结构化 action 优先(拍平契约);旧 silenceCode 仅作历史数据回退,关键词再次回退
|
|
207
|
-
const kbMissCount = silentDecisions.filter((entry) => {
|
|
208
|
-
if (typeof entry.meta?.action === "string" && entry.meta.action) return entry.meta.action === "kb_gap";
|
|
209
|
-
if (entry.meta?.silenceCode === "kb_gap") return true;
|
|
210
|
-
if (typeof entry.meta?.silenceCode === "string" && entry.meta.silenceCode) return false;
|
|
211
|
-
const reason = String(entry.meta?.reason ?? entry.content ?? "");
|
|
212
|
-
return includesAny(reason, ["知识库未覆盖", "知识库不可用", "无据", "未找到相关说明", "文档未覆盖"]);
|
|
213
|
-
}).length;
|
|
214
|
-
const servedUsers = new Set(userMessages.map((entry) => String(entry.meta?.sender ?? "")).filter(Boolean)).size;
|
|
215
|
-
const groupNeedsBugs = needsBugs.filter((item) => !item.sourceGroup || item.sourceGroup === conversationId);
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
conversationId,
|
|
219
|
-
questionCount: userMessages.length,
|
|
220
|
-
agentAnsweredCount,
|
|
221
|
-
humanAnsweredCount,
|
|
222
|
-
answeredCount,
|
|
223
|
-
unansweredCount: Math.max(0, userMessages.length - answeredCount),
|
|
224
|
-
silentCount: silentDecisions.length,
|
|
225
|
-
sendFailedCount,
|
|
226
|
-
kbMissCount,
|
|
227
|
-
demandCount: groupNeedsBugs.filter((item) => item.type === "需求").length,
|
|
228
|
-
bugCount: groupNeedsBugs.filter((item) => item.type === "Bug").length,
|
|
229
|
-
servedUsers,
|
|
230
|
-
avgFirstResponseMinutes: computeAvgFirstResponseMinutes(entries),
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function extractReviewResult(raw: string): ReviewResult | null {
|
|
235
|
-
if (!raw) return null;
|
|
236
|
-
let s = raw.trim();
|
|
237
|
-
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
238
|
-
if (fence?.[1]) s = fence[1].trim();
|
|
239
|
-
const start = s.indexOf("{");
|
|
240
|
-
const end = s.lastIndexOf("}");
|
|
241
|
-
if (start === -1 || end === -1 || end <= start) return null;
|
|
242
|
-
try {
|
|
243
|
-
const parsed = JSON.parse(s.slice(start, end + 1));
|
|
244
|
-
if (typeof parsed !== "object" || parsed === null) return null;
|
|
245
|
-
return {
|
|
246
|
-
summary: String(parsed.summary ?? ""),
|
|
247
|
-
metrics: Array.isArray(parsed.metrics) ? parsed.metrics : [],
|
|
248
|
-
needsBugs: Array.isArray(parsed.needsBugs) ? parsed.needsBugs : [],
|
|
249
|
-
learningCandidates: Array.isArray(parsed.learningCandidates) ? parsed.learningCandidates : [],
|
|
250
|
-
};
|
|
251
|
-
} catch {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ─── 消息格式化 ────────────────────────────────────────
|
|
257
|
-
|
|
258
|
-
function formatMessages(conversationId: string, entries: MemoryEntry[]): string {
|
|
259
|
-
const lines = entries.map((e) => {
|
|
260
|
-
const ts = e.timestamp.slice(11, 16); // HH:mm
|
|
261
|
-
const sender = String(e.meta?.sender ?? "未知");
|
|
262
|
-
const role = e.meta?.role ? `/${String(e.meta.role)}` : "";
|
|
263
|
-
// 决策行附 action/module/reason(拍平契约):正文已不含话术副本(见 SUPPORT_DECISION_CONTENT),
|
|
264
|
-
// 判定依据改由 meta.reason 承载,供复盘 agent 聚合 Bug/需求提示(仍需合并同根因/去重/纠错)
|
|
265
|
-
const isDecision = e.kind === SUPPORT_MEMORY_KINDS.agentDecision;
|
|
266
|
-
const decision = isDecision && typeof e.meta?.action === "string"
|
|
267
|
-
? ` action=${String(e.meta.action)}${e.meta?.module ? ` module=${String(e.meta.module)}` : ""}`
|
|
268
|
-
: "";
|
|
269
|
-
const reason = isDecision && typeof e.meta?.reason === "string" && e.meta.reason
|
|
270
|
-
? ` reason=${e.meta.reason.slice(0, 120)}`
|
|
271
|
-
: "";
|
|
272
|
-
return `[${ts}] ${sender}${role} <${e.kind}${decision}>:${reason} ${e.content}`;
|
|
273
|
-
});
|
|
274
|
-
return `### 群: ${conversationId}\n${lines.join("\n")}`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** conversationId 是 base64 风格(含 + / =),归一化为安全文件名;前置序号防归一化后重名 */
|
|
278
|
-
function inboxFileName(conversationId: string, index: number): string {
|
|
279
|
-
const safe = conversationId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 60);
|
|
280
|
-
return `${String(index + 1).padStart(2, "0")}-${safe}.md`;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ─── Recipe 定义 ───────────────────────────────────────
|
|
284
|
-
|
|
285
|
-
const recipe: RecipeDefinition = {
|
|
286
|
-
meta: {
|
|
287
|
-
id: "daily_review",
|
|
288
|
-
name: "每日复盘",
|
|
289
|
-
role: "daily-review",
|
|
290
|
-
description: "读取昨日群短期记忆 → coding.run 压缩/统计/抽取 → 写长期记忆+统计表+需求池",
|
|
291
|
-
version: "1.0.0",
|
|
292
|
-
requiresAgent: true,
|
|
293
|
-
config: z.object({
|
|
294
|
-
conversation_ids: z.union([z.array(z.string()), z.string()]).describe("复盘群来源:静态 conversationId 数组,或引用 ${globalVar.group_configs}(二选一,无隐式回退)"),
|
|
295
|
-
daily_stats_table: tableRefConfigSchema({
|
|
296
|
-
label: "每日统计表",
|
|
297
|
-
description: "每日统计表(upsertRecordIn 写入)",
|
|
298
|
-
access: "write",
|
|
299
|
-
ownership: "owned",
|
|
300
|
-
autoCreate: true,
|
|
301
|
-
defaultBase: "workflow.tracker.base_id",
|
|
302
|
-
setupSchema: {
|
|
303
|
-
fields: [
|
|
304
|
-
{ name: "统计键", type: "text" },
|
|
305
|
-
{ name: "统计日期", type: "text" },
|
|
306
|
-
{ name: "群ID", type: "text" },
|
|
307
|
-
{ name: "提问题数", type: "number" },
|
|
308
|
-
{ name: "Agent回复数", type: "number" },
|
|
309
|
-
{ name: "真人回复数", type: "number" },
|
|
310
|
-
{ name: "已回复数", type: "number" },
|
|
311
|
-
{ name: "未回复数", type: "number" },
|
|
312
|
-
{ name: "静默数", type: "number" },
|
|
313
|
-
{ name: "发送失败数", type: "number" },
|
|
314
|
-
{ name: "知识库未覆盖数", type: "number" },
|
|
315
|
-
{ name: "需求数", type: "number" },
|
|
316
|
-
{ name: "Bug数", type: "number" },
|
|
317
|
-
{ name: "服务用户数", type: "number" },
|
|
318
|
-
{ name: "平均首次响应分钟", type: "number" },
|
|
319
|
-
{ name: "节省工时", type: "number" },
|
|
320
|
-
{ name: "摘要内容", type: "text" },
|
|
321
|
-
],
|
|
322
|
-
},
|
|
323
|
-
}),
|
|
324
|
-
product_backlog_table: tableRefConfigSchema({
|
|
325
|
-
label: "产品需求池",
|
|
326
|
-
description: "产品需求池(createRecordsIn 写入)",
|
|
327
|
-
access: "write",
|
|
328
|
-
ownership: "owned",
|
|
329
|
-
autoCreate: true,
|
|
330
|
-
defaultBase: "workflow.tracker.base_id",
|
|
331
|
-
setupSchema: {
|
|
332
|
-
fields: [
|
|
333
|
-
{ name: "标题", type: "text" },
|
|
334
|
-
{ name: "类型", type: "text" },
|
|
335
|
-
{ name: "描述", type: "text" },
|
|
336
|
-
{ name: "来源消息", type: "text" },
|
|
337
|
-
{ name: "发送人", type: "text" },
|
|
338
|
-
{ name: "来源群", type: "text" },
|
|
339
|
-
{ name: "优先级", type: "text" },
|
|
340
|
-
{ name: "复盘日期", type: "text" },
|
|
341
|
-
{ name: "状态", type: "text" },
|
|
342
|
-
],
|
|
343
|
-
},
|
|
344
|
-
}),
|
|
345
|
-
saved_minutes_per_reply: z.number().optional().describe("每条 Agent 回复节省的人工工时(分钟),默认 15"),
|
|
346
|
-
learning_candidate_table: tableRefConfigSchema({
|
|
347
|
-
label: "学习候选写入表",
|
|
348
|
-
description: "学习候选新建行写入表(引用 group-qa 群问答表);learning.from_admin_replies 开启时生效",
|
|
349
|
-
access: "write",
|
|
350
|
-
ownership: "reference",
|
|
351
|
-
defaultBase: "workflow.tracker.base_id",
|
|
352
|
-
}).optional(),
|
|
353
|
-
timezone: z.string().optional().describe("复盘日期计算时区(IANA,如 Asia/Shanghai),应与 cron 的 timezone 一致;默认 Asia/Shanghai。远端 UTC 机器上必须显式设置以避免\"昨天\"错位"),
|
|
354
|
-
learning: z.object({
|
|
355
|
-
from_admin_replies: z.boolean().describe("是否从管理员回复中提炼学习候选"),
|
|
356
|
-
review: z.enum(["human-approve", "auto"]).describe("审核模式:human-approve(人工确认)或 auto(自动入库)"),
|
|
357
|
-
}).optional().describe("学习通道(L2):提炼真人解答为候选,人工确认入库;省略即关闭"),
|
|
358
|
-
}),
|
|
359
|
-
},
|
|
360
|
-
|
|
361
|
-
async execute(ctx: RecipeContext): Promise<RecipeResult> {
|
|
362
|
-
const config = parseConfig(ctx.config);
|
|
363
|
-
|
|
364
|
-
const { toolkit, log, configDir } = ctx;
|
|
365
|
-
const { memory, table, coding } = toolkit;
|
|
366
|
-
|
|
367
|
-
// 学习通道(config 驱动):from_admin_replies 开启才提炼学习候选
|
|
368
|
-
const learningConfig = ctx.config.learning as { from_admin_replies?: boolean; review?: string } | undefined;
|
|
369
|
-
const learning = {
|
|
370
|
-
enabled: learningConfig?.from_admin_replies === true,
|
|
371
|
-
review: learningConfig?.review === "auto" ? "auto" as const : "human-approve" as const,
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
// 0. 恢复孤儿 processing 文件
|
|
375
|
-
try {
|
|
376
|
-
await memory.recover();
|
|
377
|
-
} catch (err) {
|
|
378
|
-
log(`memory.recover 失败(不影响主流程): ${err}`);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// 1. 解析复盘群列表:显式配置静态数组,或引用 ${globalVar.group_configs}(无隐式空回退)
|
|
382
|
-
const conversationIds = resolveConversationIds(config.conversationIds, ctx.getGlobalVar);
|
|
383
|
-
if (conversationIds.length === 0) {
|
|
384
|
-
return { status: "failed", output: {}, error: "daily-review: conversation_ids 不能为空", retryable: false, logs: [] };
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// 2. 计算昨日时间范围(按配置时区,避免运行环境时区错位)
|
|
388
|
-
const { since, until, dateStr } = yesterdayRange(config.timezone);
|
|
389
|
-
log(`每日复盘开始: 日期=${dateStr}, 时区=${config.timezone}, 群数=${conversationIds.length}`);
|
|
390
|
-
|
|
391
|
-
// 3. 按群读取短期记忆
|
|
392
|
-
const groupEntries = new Map<string, MemoryEntry[]>();
|
|
393
|
-
|
|
394
|
-
for (const convId of conversationIds) {
|
|
395
|
-
const scope = supportScope(convId);
|
|
396
|
-
const entries = await memory.readShortTerm(scope, { since, until, limit: 500 });
|
|
397
|
-
if (entries.length > 0) {
|
|
398
|
-
groupEntries.set(convId, entries);
|
|
399
|
-
log(`群 ${convId}: 读取 ${entries.length} 条短期记忆`);
|
|
400
|
-
} else {
|
|
401
|
-
log(`群 ${convId}: 无短期记忆,跳过`);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (groupEntries.size === 0) {
|
|
406
|
-
log("所有群均无短期记忆,每日复盘结束(不留痕)");
|
|
407
|
-
return { status: "success", output: {}, tracker: false, logs: ["无活动"] };
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// 4. 各群消息落盘到工作目录,prompt 只给路径(内联全部原文会把 prompt 打爆:单群 500 条可达数百 KB)
|
|
411
|
-
const agentWorkDir = path.resolve(configDir, "workspaces/daily-review");
|
|
412
|
-
const inboxDir = path.join(agentWorkDir, ".today");
|
|
413
|
-
const cleanupInbox = () => {
|
|
414
|
-
try {
|
|
415
|
-
fs.rmSync(inboxDir, { recursive: true, force: true });
|
|
416
|
-
} catch (err) {
|
|
417
|
-
log(`清理 .today 失败(不影响主流程): ${err}`);
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
cleanupInbox();
|
|
422
|
-
fs.mkdirSync(inboxDir, { recursive: true });
|
|
423
|
-
const inboxList: string[] = [];
|
|
424
|
-
let inboxIndex = 0;
|
|
425
|
-
for (const [convId, entries] of groupEntries) {
|
|
426
|
-
const fileName = inboxFileName(convId, inboxIndex++);
|
|
427
|
-
fs.writeFileSync(path.join(inboxDir, fileName), formatMessages(convId, entries), "utf8");
|
|
428
|
-
inboxList.push(`- \`.today/${fileName}\` — 群 ${convId},${entries.length} 条`);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const learningNote = learning.enabled
|
|
432
|
-
? `\n## 学习候选提炼(本实例已开启 learning.from_admin_replies)\n额外识别「用户提问 → 真人同事(admin_message)给出解答 → 用户确认/无异议」的问答链,作为知识沉淀候选。仅在真有清晰一问一答链时提取,绝不虚构结论;把原始提问、真人解答原文、来源群、提问人、解答人、溯源(时间/上下文)写入 learningCandidates。\n`
|
|
433
|
-
: "";
|
|
434
|
-
const contractFields = `summary、metrics、needsBugs${learning.enabled ? "、learningCandidates" : ""} 字段`;
|
|
435
|
-
const prompt = `按【本工作目录下 AGENTS.md 的规范】阅读今日所有群的短期记忆消息,完成压缩摘要、统计指标抽取、需求与 Bug 识别。
|
|
436
|
-
|
|
437
|
-
## 今日消息(${dateStr})
|
|
438
|
-
|
|
439
|
-
消息已按群落盘到本工作目录下的 \`.today/\` 目录,每群一个文件。请逐个 Read 下列全部文件后再分析,不要遗漏:
|
|
440
|
-
|
|
441
|
-
${inboxList.join("\n")}
|
|
442
|
-
${learningNote}
|
|
443
|
-
## 输出契约
|
|
444
|
-
|
|
445
|
-
请按 AGENTS.md 的规范输出严格 JSON 对象,包含 ${contractFields}。`;
|
|
446
|
-
|
|
447
|
-
log(`调用 coding agent (workDir=${agentWorkDir}, 群文件=${inboxList.length})...`);
|
|
448
|
-
let result: Awaited<ReturnType<typeof coding.run>>;
|
|
449
|
-
try {
|
|
450
|
-
result = await coding.run(prompt, { workDir: agentWorkDir });
|
|
451
|
-
} finally {
|
|
452
|
-
cleanupInbox();
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (!result.response) {
|
|
456
|
-
log("coding agent 未产出有效响应,每日复盘终止");
|
|
457
|
-
// cron 每日仅触发一次:瞬时故障(agent 拖动/网络)可重试,避免丢掉整天复盘
|
|
458
|
-
return { status: "failed", output: {}, error: "coding agent 未产出有效响应", retryable: true, logs: ["coding agent 无响应"] };
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// 5. 解析 JSON 输出
|
|
462
|
-
const review = extractReviewResult(result.response);
|
|
463
|
-
if (!review) {
|
|
464
|
-
log(`JSON 解析失败,每日复盘终止。原始响应前120字: ${result.response.slice(0, 120)}`);
|
|
465
|
-
// 解析失败多为 agent 单次输出不稳,重试可能恢复
|
|
466
|
-
return { status: "failed", output: {}, error: "复盘 JSON 解析失败", retryable: true, logs: ["JSON 解析失败"] };
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
log(`解析成功: summary=${review.summary.length}字, metrics=${review.metrics.length}组, needsBugs=${review.needsBugs.length}条`);
|
|
470
|
-
const metrics = Array.from(groupEntries.entries()).map(([convId, entries]) =>
|
|
471
|
-
computeDeterministicMetric(convId, entries, review.needsBugs),
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
// 6. 写共享长期记忆(不含 conversationId,所有群共用)
|
|
475
|
-
// sourceScopes 负责从各群读取并 checkpoint 短期记忆,targetScope 只承载共享 long-term.md。
|
|
476
|
-
const sharedScope: MemoryScope = {
|
|
477
|
-
...supportScope(),
|
|
478
|
-
};
|
|
479
|
-
const sourceScopes: MemoryScope[] = Array.from(groupEntries.keys()).map((convId) => supportScope(convId));
|
|
480
|
-
try {
|
|
481
|
-
const compactResult = await memory.compact(sharedScope, {
|
|
482
|
-
summary: review.summary,
|
|
483
|
-
sourceScopes,
|
|
484
|
-
query: { since, until },
|
|
485
|
-
});
|
|
486
|
-
log(`长期记忆写入成功: 压缩 ${compactResult.entryCount} 条短期记忆`);
|
|
487
|
-
} catch (err) {
|
|
488
|
-
log(`memory.compact 失败(不影响后续写表): ${err}`);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// 7. 写每日统计表(按群 upsert)
|
|
492
|
-
if (!config.dailyStatsTable) {
|
|
493
|
-
log("daily_stats_table 未配置,跳过每日统计表写入");
|
|
494
|
-
}
|
|
495
|
-
for (const metric of metrics) {
|
|
496
|
-
if (!config.dailyStatsTable) continue;
|
|
497
|
-
const statsKey = `${dateStr}_${metric.conversationId}`;
|
|
498
|
-
const agentAnsweredCount = num(metric.agentAnsweredCount);
|
|
499
|
-
const humanAnsweredCount = num(metric.humanAnsweredCount);
|
|
500
|
-
const answeredCount = num(metric.answeredCount, agentAnsweredCount + humanAnsweredCount);
|
|
501
|
-
const automatedAnsweredCount = agentAnsweredCount || answeredCount;
|
|
502
|
-
const savedHours = Math.round(
|
|
503
|
-
automatedAnsweredCount * config.savedMinutesPerReply * 100 / 60,
|
|
504
|
-
) / 100;
|
|
505
|
-
|
|
506
|
-
try {
|
|
507
|
-
await table.upsertRecordIn(config.dailyStatsTable, "统计键", {
|
|
508
|
-
"统计键": statsKey,
|
|
509
|
-
"统计日期": dateStr,
|
|
510
|
-
"群ID": metric.conversationId,
|
|
511
|
-
"提问题数": num(metric.questionCount),
|
|
512
|
-
"Agent回复数": agentAnsweredCount,
|
|
513
|
-
"真人回复数": humanAnsweredCount,
|
|
514
|
-
"已回复数": answeredCount,
|
|
515
|
-
"未回复数": num(metric.unansweredCount),
|
|
516
|
-
"静默数": num(metric.silentCount),
|
|
517
|
-
"发送失败数": num(metric.sendFailedCount),
|
|
518
|
-
"知识库未覆盖数": num(metric.kbMissCount),
|
|
519
|
-
"需求数": num(metric.demandCount),
|
|
520
|
-
"Bug数": num(metric.bugCount),
|
|
521
|
-
"服务用户数": num(metric.servedUsers),
|
|
522
|
-
"平均首次响应分钟": num(metric.avgFirstResponseMinutes),
|
|
523
|
-
"节省工时": savedHours,
|
|
524
|
-
"摘要内容": review.summary,
|
|
525
|
-
});
|
|
526
|
-
log(`每日统计表 upsert 成功: ${statsKey} (提问题数=${metric.questionCount}, 节省工时=${savedHours})`);
|
|
527
|
-
} catch (err) {
|
|
528
|
-
log(`每日统计表 upsert 失败: ${statsKey}: ${err}`);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// 8. 写产品需求池(批量 create)
|
|
533
|
-
if (!config.productBacklogTable) {
|
|
534
|
-
log("product_backlog_table 未配置,跳过产品需求池写入");
|
|
535
|
-
} else if (review.needsBugs.length > 0) {
|
|
536
|
-
const rows = review.needsBugs.map((item) => ({
|
|
537
|
-
"标题": String(item.title ?? ""),
|
|
538
|
-
"类型": String(item.type ?? "需求"),
|
|
539
|
-
"描述": String(item.description ?? ""),
|
|
540
|
-
"来源消息": String(item.sourceMessage ?? ""),
|
|
541
|
-
"发送人": String(item.sender ?? ""),
|
|
542
|
-
"来源群": String(item.sourceGroup ?? ""),
|
|
543
|
-
"优先级": String(item.priority ?? "中"),
|
|
544
|
-
"复盘日期": dateStr,
|
|
545
|
-
"状态": "待处理",
|
|
546
|
-
}));
|
|
547
|
-
|
|
548
|
-
try {
|
|
549
|
-
const createResult = await table.createRecordsIn(config.productBacklogTable, rows);
|
|
550
|
-
log(`产品需求池写入成功: ${createResult.recordIds.length} 条`);
|
|
551
|
-
} catch (err) {
|
|
552
|
-
log(`产品需求池写入失败: ${err}`);
|
|
553
|
-
}
|
|
554
|
-
} else {
|
|
555
|
-
log("无需求/Bug 需要写入");
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// 9. 写学习候选(新建行到群问答表;不回填旧行,human-approve→待确认,auto→待沉淀)
|
|
559
|
-
let learningWritten = 0;
|
|
560
|
-
if (learning.enabled && !config.learningCandidateTable) {
|
|
561
|
-
log("learning 已开启但 learning_candidate_table 未配置,跳过学习候选写入");
|
|
562
|
-
} else if (learning.enabled && config.learningCandidateTable && review.learningCandidates.length > 0) {
|
|
563
|
-
const kbStatus = learning.review === "auto" ? KB_STATUS.READY_TO_PERSIST : KB_STATUS.PENDING_CONFIRM;
|
|
564
|
-
const rows = review.learningCandidates
|
|
565
|
-
.filter((c) => String(c.question ?? "").trim() && String(c.answer ?? "").trim())
|
|
566
|
-
.map((c) => ({
|
|
567
|
-
"消息内容": String(c.question ?? ""),
|
|
568
|
-
"发送人": String(c.asker ?? ""),
|
|
569
|
-
"群ID": String(c.sourceGroup ?? ""),
|
|
570
|
-
"对话上下文": `[学习候选·daily-review ${dateStr}] 溯源:${String(c.sourceContext ?? "")}${c.resolver ? `;解答人:${c.resolver}` : ""}`,
|
|
571
|
-
"管理员更新": String(c.answer ?? ""),
|
|
572
|
-
"知识沉淀": kbStatus,
|
|
573
|
-
}));
|
|
574
|
-
if (rows.length > 0) {
|
|
575
|
-
try {
|
|
576
|
-
const r = await table.createRecordsIn(config.learningCandidateTable, rows);
|
|
577
|
-
learningWritten = r.recordIds.length;
|
|
578
|
-
log(`学习候选写入成功: ${learningWritten} 条(知识沉淀=${kbStatus})`);
|
|
579
|
-
} catch (err) {
|
|
580
|
-
log(`学习候选写入失败: ${err}`);
|
|
581
|
-
}
|
|
582
|
-
} else {
|
|
583
|
-
log("学习候选均缺失提问或答案,跳过写入");
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
log("每日复盘完成");
|
|
588
|
-
return {
|
|
589
|
-
status: "success",
|
|
590
|
-
output: {
|
|
591
|
-
review_date: dateStr,
|
|
592
|
-
group_count: String(groupEntries.size),
|
|
593
|
-
needs_bug_count: String(review.needsBugs.length),
|
|
594
|
-
learning_candidate_count: String(learningWritten),
|
|
595
|
-
summary: review.summary,
|
|
596
|
-
},
|
|
597
|
-
logs: [`群 ${groupEntries.size}`, `需求Bug ${review.needsBugs.length}`, `学习候选 ${learningWritten}`],
|
|
598
|
-
};
|
|
599
|
-
},
|
|
600
|
-
};
|
|
601
|
-
|
|
602
|
-
export default recipe;
|