agens-studio 0.1.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,296 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { config } from '../../config.js';
5
+
6
+ /**
7
+ * mimo 视觉识图服务
8
+ * ------------------------------------------------------------------
9
+ * 用 mimo-v2.5(多模态)识别上传图片,按维度输出结构化描述。
10
+ * 供图生图的"保留原图结构"使用。
11
+ *
12
+ * 缓存:按图片文件内容的 hash,同一张图只识一次(存 data/vision_cache.json)。
13
+ * 失败:直接抛错,由上层提示用户重试(不降级,保证"保留"基于真实识别)。
14
+ * ------------------------------------------------------------------
15
+ */
16
+
17
+ let _cache = null;
18
+
19
+ async function loadCache() {
20
+ if (_cache) return _cache;
21
+ try {
22
+ _cache = JSON.parse(await fs.readFile(config.visionCache.file, 'utf8'));
23
+ if (typeof _cache !== 'object' || _cache === null) _cache = {};
24
+ } catch {
25
+ _cache = {};
26
+ }
27
+ return _cache;
28
+ }
29
+
30
+ async function saveCache() {
31
+ if (!_cache) return;
32
+ await fs.writeFile(config.visionCache.file, JSON.stringify(_cache, null, 2), 'utf8');
33
+ }
34
+
35
+ /** 启动时调用 */
36
+ export async function init() {
37
+ await fs.mkdir(config.dirs.data, { recursive: true });
38
+ await loadCache();
39
+ }
40
+
41
+ /** 计算图片 buffer 的内容 hash(缓存 key) */
42
+ function hashOf(buf) {
43
+ return createHash('sha256').update(buf).digest('hex').slice(0, 32);
44
+ }
45
+
46
+ /**
47
+ * 把本地路径转成 base64 data uri + buffer
48
+ * 支持:/uploads/xxx、/assets/images/xxx、/assets/videos/xxx
49
+ */
50
+ async function readLocalImage(localPath) {
51
+ let absPath;
52
+ let isVideo = false;
53
+ if (localPath.startsWith('/uploads/')) {
54
+ absPath = path.join(config.dirs.uploads, path.basename(localPath));
55
+ } else if (localPath.startsWith('/assets/images/')) {
56
+ absPath = path.join(config.dirs.images, path.basename(localPath));
57
+ } else if (localPath.startsWith('/assets/videos/')) {
58
+ absPath = path.join(config.dirs.videos, path.basename(localPath));
59
+ isVideo = true;
60
+ } else {
61
+ throw new Error('只支持本地上传的图片或视频');
62
+ }
63
+ const buf = await fs.readFile(absPath);
64
+ const ext = path.extname(absPath).slice(1).toLowerCase();
65
+ if (isVideo) {
66
+ const mime = `video/${ext || 'mp4'}`;
67
+ return { buf, dataUri: `data:${mime};base64,${buf.toString('base64')}` };
68
+ }
69
+ const mime = `image/${ext === 'jpg' ? 'jpeg' : ext || 'png'}`;
70
+ return { buf, dataUri: `data:${mime};base64,${buf.toString('base64')}` };
71
+ }
72
+
73
+ /**
74
+ * 识图:返回结构化描述 { subject, scene, composition, color, style, keepable }
75
+ * @param {string} imageUrl 本地图片路径(/uploads/xxx 或 /assets/images/xxx)
76
+ * @returns {Promise<{description:object, cached:boolean}>}
77
+ */
78
+ export async function describeImage(imageUrl) {
79
+ const { buf, dataUri } = await readLocalImage(imageUrl);
80
+ const key = hashOf(buf);
81
+
82
+ // 命中缓存
83
+ const cache = await loadCache();
84
+ if (cache[key]) {
85
+ return { description: cache[key], cached: true };
86
+ }
87
+
88
+ // 调 mimo-v2.5 多模态
89
+ const description = await callVision(dataUri);
90
+ cache[key] = description;
91
+ await saveCache();
92
+ return { description, cached: false };
93
+ }
94
+
95
+ /**
96
+ * 调用 mimo-v2.5 识图,要求按维度输出
97
+ */
98
+ async function callVision(dataUri) {
99
+ const cfg = config.mimo;
100
+ const system = [
101
+ '你是图像分析专家。请分析图片,严格按以下 6 个维度输出,每个维度一行,格式为「维度名:内容」。',
102
+ '',
103
+ '维度:',
104
+ '主体:画面核心对象是谁/是什么,什么状态/动作',
105
+ '场景:发生在哪里,背景环境',
106
+ '构图:画面布局(居中/三分法/对称/广角等),主体位置',
107
+ '色调:主色调、光线氛围',
108
+ '风格:写实/动漫/3D/艺术/摄影等',
109
+ '可保留元素:图生图时应该保持不变的元素(主体位置、构图、关键物件)',
110
+ '',
111
+ '要求:',
112
+ '1. 必须输出全部 6 行,缺一不可',
113
+ '2. 每行内容简洁(5-30字),不要解释',
114
+ '3. 不要输出其它任何内容',
115
+ ].join('\n');
116
+
117
+ const res = await fetch(`${cfg.baseUrl}${cfg.endpoint}`, {
118
+ method: 'POST',
119
+ headers: {
120
+ 'Content-Type': 'application/json',
121
+ 'api-key': cfg.apiKey,
122
+ },
123
+ body: JSON.stringify({
124
+ model: cfg.visionModel,
125
+ messages: [
126
+ { role: 'system', content: system },
127
+ {
128
+ role: 'user',
129
+ content: [
130
+ { type: 'text', text: '请分析这张图片' },
131
+ { type: 'image_url', image_url: { url: dataUri } },
132
+ ],
133
+ },
134
+ ],
135
+ max_completion_tokens: 800,
136
+ temperature: 0.3, // 识图要稳定,温度调低
137
+ top_p: 0.9,
138
+ stream: false,
139
+ }),
140
+ });
141
+
142
+ const text = await res.text();
143
+ if (!res.ok) {
144
+ throw new Error(`识图失败 (${res.status}): ${text.slice(0, 200)}`);
145
+ }
146
+ let data;
147
+ try { data = JSON.parse(text); } catch { throw new Error('识图响应不是有效 JSON'); }
148
+ const content = data.choices?.[0]?.message?.content;
149
+ if (!content) throw new Error('识图响应未包含内容');
150
+
151
+ return parseDescription(content);
152
+ }
153
+
154
+ /**
155
+ * 把模型的维度描述文本解析成对象
156
+ * 输入形如:
157
+ * 主体:女研究员手持烧瓶
158
+ * 场景:实验室
159
+ * ...
160
+ */
161
+ function parseDescription(content) {
162
+ const obj = {
163
+ subject: '', scene: '', composition: '', color: '', style: '', keepable: '',
164
+ };
165
+ const map = {
166
+ 主体: 'subject', 场景: 'scene', 构图: 'composition',
167
+ 色调: 'color', 风格: 'style', 可保留元素: 'keepable',
168
+ };
169
+ for (const line of content.split(/\n/)) {
170
+ const m = line.match(/^\s*([^::]+)[::]\s*(.+)$/);
171
+ if (!m) continue;
172
+ const key = map[m[1].trim()];
173
+ if (key) obj[key] = m[2].trim();
174
+ }
175
+ return obj;
176
+ }
177
+
178
+ /** 把描述对象转成给 pro 模型看的纯文本上下文 */
179
+ export function descriptionToText(d) {
180
+ if (!d) return '';
181
+ return [
182
+ d.subject && `主体:${d.subject}`,
183
+ d.scene && `场景:${d.scene}`,
184
+ d.composition && `构图:${d.composition}`,
185
+ d.color && `色调:${d.color}`,
186
+ d.style && `风格:${d.style}`,
187
+ d.keepable && `可保留元素:${d.keepable}`,
188
+ ].filter(Boolean).join(';');
189
+ }
190
+
191
+ /**
192
+ * 复看:看 agnes 生成的图/视频,评价 + 给出改进提示词
193
+ * @param {string} assetPath 生成物的本地路径(/assets/images/xxx.png 或 /assets/videos/xxx.mp4)
194
+ * @param {string} prompt 生成时用的提示词
195
+ * @param {string} mode 生成模式
196
+ * @returns {Promise<{rating, comment, problems, suggestion, improvedPrompt}>}
197
+ */
198
+ export async function reviewImage(assetPath, prompt, mode) {
199
+ const isVideo = assetPath.includes('/assets/videos/') || assetPath.endsWith('.mp4');
200
+ const { dataUri } = await readLocalImage(assetPath);
201
+ const cfg = config.mimo;
202
+
203
+ const mediaLabel = isVideo ? '视频' : '图片';
204
+ const system = [
205
+ `你是 AI ${mediaLabel}质量评审专家。请查看用户用 AI 生成的${mediaLabel},对照原始提示词,给出评价和改进建议。`,
206
+ '',
207
+ isVideo ? '【视频评审重点】画面动态感、运动流畅度、主体一致性、镜头运动合理性、画面质量' : '',
208
+ '【必须按以下格式输出,每项一行】',
209
+ `评价:这条${mediaLabel}的优点(2-3点)`,
210
+ `问题:这条${mediaLabel}的不足之处(2-3点,要具体)`,
211
+ '建议:针对问题,提示词应该怎么改(一句话)',
212
+ '改进提示词:基于原提示词修改后的完整提示词(直接可用,不要解释)',
213
+ '',
214
+ '要求:',
215
+ '1. 必须输出全部 4 行',
216
+ '2. 评价和问题要基于实际看到的画面,不要泛泛而谈',
217
+ '3. 改进提示词必须是完整的、可直接用于重新生成的提示词',
218
+ '4. 改进提示词不要带任何前缀说明,直接输出提示词内容',
219
+ ].filter(Boolean).join('\n');
220
+
221
+ const userText = `原始提示词:${prompt}\n生成模式:${mode}\n请评价这条生成结果并给出改进提示词。`;
222
+
223
+ const res = await fetch(`${cfg.baseUrl}${cfg.endpoint}`, {
224
+ method: 'POST',
225
+ headers: {
226
+ 'Content-Type': 'application/json',
227
+ 'api-key': cfg.apiKey,
228
+ },
229
+ body: JSON.stringify({
230
+ model: cfg.visionModel,
231
+ messages: [
232
+ { role: 'system', content: system },
233
+ {
234
+ role: 'user',
235
+ content: [
236
+ { type: 'text', text: userText },
237
+ isVideo
238
+ ? { type: 'video_url', video_url: { url: dataUri }, fps: 2, media_resolution: 'default' }
239
+ : { type: 'image_url', image_url: { url: dataUri } },
240
+ ],
241
+ },
242
+ ],
243
+ max_completion_tokens: 4096,
244
+ temperature: 0.4,
245
+ top_p: 0.9,
246
+ stream: false,
247
+ }),
248
+ });
249
+
250
+ const text = await res.text();
251
+ if (!res.ok) {
252
+ throw new Error(`复看失败 (${res.status}): ${text.slice(0, 200)}`);
253
+ }
254
+ let data;
255
+ try { data = JSON.parse(text); } catch { throw new Error('复看响应不是有效 JSON'); }
256
+ const content = data.choices?.[0]?.message?.content;
257
+ if (!content) throw new Error('复看响应未包含内容');
258
+
259
+ return parseReview(content);
260
+ }
261
+
262
+ /** 解析复看结果 */
263
+ function parseReview(content) {
264
+ const result = { rating: '', comment: '', suggestion: '', improvedPrompt: '' };
265
+ const lines = content.split(/\n/);
266
+ let currentKey = null;
267
+ let buffer = [];
268
+ const keyMap = { 评价: 'comment', 问题: 'problems', 建议: 'suggestion', '改进提示词': 'improvedPrompt' };
269
+
270
+ for (const line of lines) {
271
+ const m = line.match(/^\s*(评价|问题|建议|改进提示词)[::]\s*(.*)$/);
272
+ if (m) {
273
+ // 保存上一个 key 的内容
274
+ if (currentKey && buffer.length) {
275
+ if (keyMap[currentKey] === 'comment') result.comment = buffer.join(';');
276
+ else if (currentKey === '问题') result.problems = buffer.join(';');
277
+ else if (keyMap[currentKey]) result[keyMap[currentKey]] = buffer.join('\n');
278
+ }
279
+ currentKey = m[1].trim();
280
+ buffer = [m[2]].filter(Boolean);
281
+ } else if (currentKey) {
282
+ buffer.push(line.trim());
283
+ }
284
+ }
285
+ // 保存最后一个
286
+ if (currentKey && buffer.length) {
287
+ if (currentKey === '评价') result.comment = buffer.join(';');
288
+ else if (currentKey === '问题') result.problems = buffer.join(';');
289
+ else if (currentKey === '建议') result.suggestion = buffer.join('\n');
290
+ else if (currentKey === '改进提示词') result.improvedPrompt = buffer.join('\n').trim();
291
+ }
292
+
293
+ // rating:有问题则 needs_improve,否则 good
294
+ result.rating = result.problems && result.problems.length > 5 ? 'needs_improve' : 'good';
295
+ return result;
296
+ }
@@ -0,0 +1,276 @@
1
+ import fs from 'node:fs/promises';
2
+ import { config } from '../../config.js';
3
+
4
+ /**
5
+ * 提示词记忆库
6
+ * ------------------------------------------------------------------
7
+ * 存储「原始输入 → 优化结果 → 用户反馈」的样本,让 mimo 越用越懂用户。
8
+ *
9
+ * 数据结构:data/prompt_memory.json
10
+ * {
11
+ * samples: [{
12
+ * id, kind('positive'|'negative'), original, optimized, mode,
13
+ * rating(-1/0/1), tags[], adoptedCount, createdAt
14
+ * }],
15
+ * globalPrefs: {
16
+ * positive: { favoriteStyle, commonKeywords[] }, // 正向偏好
17
+ * negative: { favoriteStyle, commonKeywords[] } // 反向偏好(用户常排除的元素)
18
+ * }
19
+ * }
20
+ *
21
+ * ⚠️ 正向 / 反向样本完全独立存储与检索,避免互相污染。
22
+ *
23
+ * 评分机制:
24
+ * rating = 1 用户点赞(👍)
25
+ * rating = 0 未反馈(默认)
26
+ * rating = -1 用户点踩(👎)
27
+ * adoptedCount 每次优化结果被「采用」+1
28
+ * ------------------------------------------------------------------
29
+ */
30
+
31
+ let _cache = null;
32
+
33
+ async function load() {
34
+ if (_cache) return _cache;
35
+ try {
36
+ const raw = await fs.readFile(config.promptMemory.file, 'utf8');
37
+ _cache = JSON.parse(raw);
38
+ if (!Array.isArray(_cache.samples)) _cache.samples = [];
39
+ // 兼容旧数据:老样本没有 kind 字段,归为 positive
40
+ _cache.samples.forEach((s) => { if (!s.kind) s.kind = 'positive'; });
41
+ // globalPrefs 升级为区分 positive/negative
42
+ if (!_cache.globalPrefs) _cache.globalPrefs = {};
43
+ if (!_cache.globalPrefs.positive) _cache.globalPrefs.positive = { favoriteStyle: '', commonKeywords: [] };
44
+ if (!_cache.globalPrefs.negative) _cache.globalPrefs.negative = { favoriteStyle: '', commonKeywords: [] };
45
+ // 兼容旧版扁平结构
46
+ if (_cache.globalPrefs.favoriteStyle && !_cache.globalPrefs.positive.favoriteStyle) {
47
+ _cache.globalPrefs.positive = { favoriteStyle: _cache.globalPrefs.favoriteStyle, commonKeywords: _cache.globalPrefs.commonKeywords || [] };
48
+ delete _cache.globalPrefs.favoriteStyle;
49
+ delete _cache.globalPrefs.commonKeywords;
50
+ }
51
+ } catch {
52
+ _cache = {
53
+ samples: [],
54
+ globalPrefs: {
55
+ positive: { favoriteStyle: '', commonKeywords: [] },
56
+ negative: { favoriteStyle: '', commonKeywords: [] },
57
+ },
58
+ };
59
+ }
60
+ return _cache;
61
+ }
62
+
63
+ async function save() {
64
+ if (!_cache) return;
65
+ await fs.writeFile(config.promptMemory.file, JSON.stringify(_cache, null, 2), 'utf8');
66
+ }
67
+
68
+ /** 启动时调用:确保目录就绪 */
69
+ export async function init() {
70
+ await fs.mkdir(config.dirs.data, { recursive: true });
71
+ await load();
72
+ }
73
+
74
+ /**
75
+ * 从一段中文文本提取关键词标签(简单分词,用于匹配相关性)
76
+ * 取 2-6 字的中文片段 + 英文单词
77
+ */
78
+ function extractTags(text) {
79
+ if (!text) return [];
80
+ const tags = new Set();
81
+ // 英文单词
82
+ const en = text.match(/[a-zA-Z]{3,}/g) || [];
83
+ en.forEach((w) => tags.add(w.toLowerCase()));
84
+ // 中文 2-4 字词(简单滑动窗口,够用)
85
+ const cn = text.match(/[\u4e00-\u9fa5]{2,4}/g) || [];
86
+ cn.forEach((w) => tags.add(w));
87
+ return Array.from(tags).slice(0, 12);
88
+ }
89
+
90
+ /**
91
+ * 新增一个样本(优化时调用)
92
+ * @param {object} input
93
+ * @param {'positive'|'negative'} input.kind 正向 / 反向
94
+ * @returns 新建的样本对象(含 id)
95
+ */
96
+ export async function addSample({ kind = 'positive', original, optimized, mode, imageDescription }) {
97
+ const db = await load();
98
+ const sample = {
99
+ id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
100
+ kind,
101
+ original: original || '',
102
+ optimized: optimized || '',
103
+ mode: mode || '',
104
+ rating: 0,
105
+ tags: extractTags(original + ' ' + optimized),
106
+ adoptedCount: 0,
107
+ correction: '', // 点踩原因(用户写的不满)
108
+ rewrites: [], // 重写历史 [{ instruction, result, accepted }]
109
+ imageDescription: imageDescription || '', // 图生图时的识图结果(仅 img2img 有)
110
+ createdAt: Date.now(),
111
+ };
112
+ db.samples.unshift(sample);
113
+ prune(db);
114
+ await save();
115
+ return sample;
116
+ }
117
+
118
+ /**
119
+ * 更新样本:评分 / 采纳计数 / 点踩原因 / 重写记录
120
+ * @param {string} id
121
+ * @param {object} patch { rating?, adoptedDelta?, correction?, rewrite? }
122
+ * - rewrite: { instruction, result } 会追加到 rewrites 并把 optimized 更新为最新结果
123
+ */
124
+ export async function updateSample(id, patch = {}) {
125
+ const db = await load();
126
+ const s = db.samples.find((x) => x.id === id);
127
+ if (!s) return null;
128
+ if (typeof patch.rating === 'number') s.rating = patch.rating;
129
+ if (typeof patch.adoptedDelta === 'number') s.adoptedCount += patch.adoptedDelta;
130
+ if (typeof patch.correction === 'string' && patch.correction.trim()) {
131
+ s.correction = patch.correction.trim();
132
+ }
133
+ if (patch.rewrite) {
134
+ s.rewrites.push({
135
+ instruction: patch.rewrite.instruction || '',
136
+ result: patch.rewrite.result || '',
137
+ accepted: !!patch.rewrite.accepted,
138
+ });
139
+ // 重写后,最新结果作为样本的 optimized(便于检索匹配)
140
+ if (patch.rewrite.result) {
141
+ s.optimized = patch.rewrite.result;
142
+ // 重新提取 tags,覆盖更多关键词
143
+ s.tags = extractTags(s.original + ' ' + s.optimized);
144
+ }
145
+ }
146
+ await save();
147
+ // 评分变化后,尝试更新对应 kind 的全局偏好
148
+ maybeRefreshPrefs(db, s.kind);
149
+ return s;
150
+ }
151
+
152
+ /**
153
+ * 检索与当前输入最相关的 N 条高分样本(喂给 mimo)
154
+ * 只在同一 kind 内检索(正向不混反向)
155
+ */
156
+ export async function getRelevantSamples(input, mode, limit, kind = 'positive') {
157
+ const db = await load();
158
+ const inputTags = new Set(extractTags(input));
159
+ const n = limit || config.promptMemory.feedSamples;
160
+
161
+ // 候选:同 kind、有评分或被采纳过的样本
162
+ const candidates = db.samples.filter(
163
+ (s) => s.kind === kind && s.rating >= 0 && (s.rating > 0 || s.adoptedCount > 0)
164
+ );
165
+
166
+ const scored = candidates.map((s) => {
167
+ const overlap = s.tags.filter((t) => inputTags.has(t)).length;
168
+ const score = overlap * 5 + s.rating * 3 + Math.min(s.adoptedCount, 5);
169
+ return { s, score };
170
+ });
171
+ scored.sort((a, b) => b.score - a.score);
172
+
173
+ // 同类模式优先(mode 匹配加分已在 score 之外,这里做稳定排序的二级条件)
174
+ scored.sort((a, b) => {
175
+ if (a.s.mode === mode && b.s.mode !== mode) return -1;
176
+ if (b.s.mode === mode && a.s.mode !== mode) return 1;
177
+ return b.score - a.score;
178
+ });
179
+
180
+ return scored.slice(0, n).map((x) => x.s);
181
+ }
182
+
183
+ /**
184
+ * 检索相关的「点踩原因 + 重写指令」(反面教材 / 修正要求)
185
+ * 用于告诉 mimo「这类主题用户曾要求避免/修改什么」
186
+ * 返回 [{ original, correction, rewrites:[{instruction}] }]
187
+ */
188
+ export async function getCorrections(input, mode, limit = 3, kind = 'positive') {
189
+ const db = await load();
190
+ const inputTags = new Set(extractTags(input));
191
+ const candidates = db.samples.filter(
192
+ (s) =>
193
+ s.kind === kind &&
194
+ ((s.correction && s.correction.trim()) || (s.rewrites && s.rewrites.length))
195
+ );
196
+ const scored = candidates.map((s) => {
197
+ const overlap = s.tags.filter((t) => inputTags.has(t)).length;
198
+ return { s, score: overlap * 5 };
199
+ });
200
+ scored.sort((a, b) => b.score - a.score);
201
+ scored.sort((a, b) => {
202
+ if (a.s.mode === mode && b.s.mode !== mode) return -1;
203
+ if (b.s.mode === mode && a.s.mode !== mode) return 1;
204
+ return b.score - a.score;
205
+ });
206
+ return scored.slice(0, limit).map((x) => ({
207
+ original: x.s.original,
208
+ correction: x.s.correction || '',
209
+ rewrites: (x.s.rewrites || []).map((r) => r.instruction),
210
+ }));
211
+ }
212
+
213
+ /** 取全局偏好(按 kind 分别返回) */
214
+ export async function getGlobalPrefs(kind = 'positive') {
215
+ const db = await load();
216
+ return db.globalPrefs[kind] || { favoriteStyle: '', commonKeywords: [] };
217
+ }
218
+
219
+ /**
220
+ * 当高分样本足够多时,提炼对应 kind 的全局偏好
221
+ */
222
+ function maybeRefreshPrefs(db, kind) {
223
+ const good = db.samples.filter((s) => s.kind === kind && s.rating >= 1);
224
+ if (good.length < config.promptMemory.minSamplesForPrefs) return;
225
+
226
+ // 统计 optimized 里高频标签
227
+ const freq = new Map();
228
+ for (const s of good) {
229
+ for (const t of s.tags) freq.set(t, (freq.get(t) || 0) + 1);
230
+ }
231
+ const top = Array.from(freq.entries())
232
+ .sort((a, b) => b[1] - a[1])
233
+ .slice(0, 8)
234
+ .map(([k]) => k);
235
+
236
+ db.globalPrefs[kind] = {
237
+ commonKeywords: top,
238
+ favoriteStyle: top.slice(0, 3).join('、'),
239
+ };
240
+ }
241
+
242
+ /**
243
+ * 容量淘汰:超过 maxSamples 时,按 (rating*3 + adoptedCount) 升序删除低价值样本
244
+ */
245
+ function prune(db) {
246
+ const max = config.promptMemory.maxSamples;
247
+ if (db.samples.length <= max) return;
248
+ db.samples.sort((a, b) => b.rating * 3 + b.adoptedCount - (a.rating * 3 + a.adoptedCount));
249
+ db.samples = db.samples.slice(0, max);
250
+ }
251
+
252
+ /** 按 id 取单个样本(重写时用) */
253
+ export async function getSample(id) {
254
+ const db = await load();
255
+ return db.samples.find((s) => s.id === id) || null;
256
+ }
257
+
258
+ /** 调试/管理用:返回统计信息(按 kind 分别统计) */
259
+ export async function stats() {
260
+ const db = await load();
261
+ const pick = (k) => db.samples.filter((s) => s.kind === k);
262
+ return {
263
+ total: db.samples.length,
264
+ positive: {
265
+ total: pick('positive').length,
266
+ liked: pick('positive').filter((s) => s.rating >= 1).length,
267
+ adopted: pick('positive').filter((s) => s.adoptedCount > 0).length,
268
+ },
269
+ negative: {
270
+ total: pick('negative').length,
271
+ liked: pick('negative').filter((s) => s.rating >= 1).length,
272
+ adopted: pick('negative').filter((s) => s.adoptedCount > 0).length,
273
+ },
274
+ prefs: db.globalPrefs,
275
+ };
276
+ }