chainlesschain 0.47.9 → 0.51.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.
Files changed (73) hide show
  1. package/bin/chainlesschain.js +0 -0
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/.build-hash +1 -1
  4. package/src/assets/web-panel/assets/{AppLayout-6SPt_8Y_.js → AppLayout-Rvi759IS.js} +1 -1
  5. package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +1 -0
  6. package/src/assets/web-panel/assets/{Dashboard-Br7kCwKJ.js → Dashboard-DBhFxXYQ.js} +2 -2
  7. package/src/assets/web-panel/assets/{index-tN-8TosE.js → index-uL0cZ8N_.js} +2 -2
  8. package/src/assets/web-panel/index.html +2 -2
  9. package/src/commands/codegen.js +303 -0
  10. package/src/commands/collab.js +482 -0
  11. package/src/commands/crosschain.js +382 -0
  12. package/src/commands/dbevo.js +388 -0
  13. package/src/commands/dev.js +411 -0
  14. package/src/commands/federation.js +427 -0
  15. package/src/commands/fusion.js +332 -0
  16. package/src/commands/governance.js +505 -0
  17. package/src/commands/hardening.js +110 -0
  18. package/src/commands/incentive.js +373 -0
  19. package/src/commands/inference.js +304 -0
  20. package/src/commands/infra.js +361 -0
  21. package/src/commands/ipfs.js +392 -0
  22. package/src/commands/kg.js +371 -0
  23. package/src/commands/marketplace.js +326 -0
  24. package/src/commands/mcp.js +97 -18
  25. package/src/commands/multimodal.js +404 -0
  26. package/src/commands/nlprog.js +329 -0
  27. package/src/commands/ops.js +408 -0
  28. package/src/commands/perception.js +385 -0
  29. package/src/commands/pqc.js +34 -0
  30. package/src/commands/privacy.js +345 -0
  31. package/src/commands/quantization.js +280 -0
  32. package/src/commands/recommend.js +336 -0
  33. package/src/commands/reputation.js +349 -0
  34. package/src/commands/runtime.js +500 -0
  35. package/src/commands/sla.js +352 -0
  36. package/src/commands/stress.js +252 -0
  37. package/src/commands/tech.js +268 -0
  38. package/src/commands/tenant.js +576 -0
  39. package/src/commands/trust.js +366 -0
  40. package/src/harness/mcp-client.js +330 -54
  41. package/src/index.js +118 -0
  42. package/src/lib/aiops.js +523 -0
  43. package/src/lib/autonomous-developer.js +524 -0
  44. package/src/lib/code-agent.js +442 -0
  45. package/src/lib/collaboration-governance.js +556 -0
  46. package/src/lib/community-governance.js +649 -0
  47. package/src/lib/content-recommendation.js +600 -0
  48. package/src/lib/cross-chain.js +669 -0
  49. package/src/lib/dbevo.js +669 -0
  50. package/src/lib/decentral-infra.js +445 -0
  51. package/src/lib/federation-hardening.js +587 -0
  52. package/src/lib/hardening-manager.js +409 -0
  53. package/src/lib/inference-network.js +407 -0
  54. package/src/lib/ipfs-storage.js +575 -0
  55. package/src/lib/knowledge-graph.js +530 -0
  56. package/src/lib/mcp-client.js +3 -0
  57. package/src/lib/multimodal.js +725 -0
  58. package/src/lib/nl-programming.js +595 -0
  59. package/src/lib/perception.js +500 -0
  60. package/src/lib/pqc-manager.js +141 -9
  61. package/src/lib/privacy-computing.js +575 -0
  62. package/src/lib/protocol-fusion.js +535 -0
  63. package/src/lib/quantization.js +362 -0
  64. package/src/lib/reputation-optimizer.js +509 -0
  65. package/src/lib/skill-marketplace.js +397 -0
  66. package/src/lib/sla-manager.js +484 -0
  67. package/src/lib/stress-tester.js +383 -0
  68. package/src/lib/tech-learning-engine.js +651 -0
  69. package/src/lib/tenant-saas.js +831 -0
  70. package/src/lib/token-incentive.js +513 -0
  71. package/src/lib/trust-security.js +473 -0
  72. package/src/lib/universal-runtime.js +771 -0
  73. package/src/assets/web-panel/assets/Dashboard-CKeMmCoT.css +0 -1
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Content Recommendation — CLI port of Phase 48 智能内容推荐系统
3
+ * (docs/design/modules/22_智能内容推荐系统.md).
4
+ *
5
+ * Desktop uses LocalRecommender + InterestProfiler with Pinia store,
6
+ * RecommendationsPage.vue tag-cloud, and IPC channels. CLI port ships:
7
+ *
8
+ * - Interest profile CRUD (topic weights + interaction weights + time decay)
9
+ * - Content scoring (topic overlap × weight)
10
+ * - Recommendation generation from content pool
11
+ * - Feedback collection (like / dislike / later)
12
+ * - Recommendation stats per user
13
+ *
14
+ * What does NOT port: Pinia store, RecommendationsPage.vue tag-cloud,
15
+ * real-time refresh, IPC channels.
16
+ */
17
+
18
+ import crypto from "crypto";
19
+
20
+ /* ── Constants ─────────────────────────────────────────────── */
21
+
22
+ export const CONTENT_TYPES = Object.freeze({
23
+ NOTE: Object.freeze({ id: "note", name: "Note", description: "笔记" }),
24
+ POST: Object.freeze({ id: "post", name: "Post", description: "帖子" }),
25
+ ARTICLE: Object.freeze({
26
+ id: "article",
27
+ name: "Article",
28
+ description: "文章",
29
+ }),
30
+ DOCUMENT: Object.freeze({
31
+ id: "document",
32
+ name: "Document",
33
+ description: "文档",
34
+ }),
35
+ });
36
+
37
+ export const RECOMMENDATION_STATUS = Object.freeze({
38
+ PENDING: "pending",
39
+ VIEWED: "viewed",
40
+ DISMISSED: "dismissed",
41
+ });
42
+
43
+ export const FEEDBACK_VALUES = Object.freeze({
44
+ LIKE: "like",
45
+ DISLIKE: "dislike",
46
+ LATER: "later",
47
+ });
48
+
49
+ export const DEFAULT_CONFIG = Object.freeze({
50
+ minScore: 0.3,
51
+ maxBatchSize: 50,
52
+ decayFactor: 0.9,
53
+ defaultLimit: 20,
54
+ });
55
+
56
+ /* ── State ─────────────────────────────────────────────── */
57
+
58
+ let _profiles = new Map();
59
+ let _recommendations = new Map();
60
+ let _userRecs = new Map(); // userId → Set<recId>
61
+ let _seq = 0;
62
+
63
+ function _id() {
64
+ return crypto.randomUUID();
65
+ }
66
+
67
+ function _now() {
68
+ return Date.now();
69
+ }
70
+
71
+ function _strip(row) {
72
+ if (!row) return null;
73
+ const out = {};
74
+ for (const [k, v] of Object.entries(row)) {
75
+ if (k !== "_rowid_" && k !== "rowid") out[k] = v;
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /* ── Schema ────────────────────────────────────────────── */
81
+
82
+ export function ensureRecommendationTables(db) {
83
+ db.exec(`CREATE TABLE IF NOT EXISTS user_interest_profiles (
84
+ id TEXT PRIMARY KEY,
85
+ user_id TEXT UNIQUE,
86
+ topics TEXT,
87
+ interaction_weights TEXT,
88
+ decay_factor REAL DEFAULT 0.9,
89
+ last_updated INTEGER,
90
+ update_count INTEGER DEFAULT 0
91
+ )`);
92
+ db.exec(
93
+ "CREATE INDEX IF NOT EXISTS idx_uip_user ON user_interest_profiles(user_id)",
94
+ );
95
+
96
+ db.exec(`CREATE TABLE IF NOT EXISTS content_recommendations (
97
+ id TEXT PRIMARY KEY,
98
+ user_id TEXT,
99
+ content_id TEXT,
100
+ content_type TEXT,
101
+ title TEXT,
102
+ score REAL,
103
+ reason TEXT,
104
+ source TEXT DEFAULT 'heuristic',
105
+ status TEXT DEFAULT 'pending',
106
+ feedback TEXT,
107
+ created_at INTEGER,
108
+ viewed_at INTEGER
109
+ )`);
110
+ db.exec(
111
+ "CREATE INDEX IF NOT EXISTS idx_cr_user ON content_recommendations(user_id)",
112
+ );
113
+ db.exec(
114
+ "CREATE INDEX IF NOT EXISTS idx_cr_score ON content_recommendations(score DESC)",
115
+ );
116
+ db.exec(
117
+ "CREATE INDEX IF NOT EXISTS idx_cr_status ON content_recommendations(status)",
118
+ );
119
+
120
+ _loadAll(db);
121
+ }
122
+
123
+ function _loadAll(db) {
124
+ _profiles.clear();
125
+ _recommendations.clear();
126
+ _userRecs.clear();
127
+ _seq = 0;
128
+
129
+ try {
130
+ const profiles = db.prepare("SELECT * FROM user_interest_profiles").all();
131
+ for (const row of profiles) {
132
+ const p = _strip(row);
133
+ p.topics = _parseJson(p.topics, {});
134
+ p.interaction_weights = _parseJson(p.interaction_weights, {});
135
+ _profiles.set(p.user_id, p);
136
+ }
137
+ } catch (_e) {
138
+ /* table may not exist yet */
139
+ }
140
+
141
+ try {
142
+ const recs = db.prepare("SELECT * FROM content_recommendations").all();
143
+ for (const row of recs) {
144
+ const r = _strip(row);
145
+ _recommendations.set(r.id, r);
146
+ if (!_userRecs.has(r.user_id)) _userRecs.set(r.user_id, new Set());
147
+ _userRecs.get(r.user_id).add(r.id);
148
+ }
149
+ } catch (_e) {
150
+ /* table may not exist yet */
151
+ }
152
+ }
153
+
154
+ function _parseJson(str, fallback) {
155
+ if (!str) return fallback;
156
+ try {
157
+ return JSON.parse(str);
158
+ } catch (_e) {
159
+ return fallback;
160
+ }
161
+ }
162
+
163
+ /* ── Interest Profile ──────────────────────────────────── */
164
+
165
+ export function getProfile(db, userId) {
166
+ const p = _profiles.get(userId);
167
+ if (!p) return null;
168
+ return { ...p };
169
+ }
170
+
171
+ export function createProfile(
172
+ db,
173
+ userId,
174
+ { topics = {}, interactionWeights = {} } = {},
175
+ ) {
176
+ if (_profiles.has(userId)) {
177
+ return { profileId: null, reason: "profile_exists" };
178
+ }
179
+ const id = _id();
180
+ const now = _now();
181
+ const profile = {
182
+ id,
183
+ user_id: userId,
184
+ topics,
185
+ interaction_weights: interactionWeights,
186
+ decay_factor: DEFAULT_CONFIG.decayFactor,
187
+ last_updated: now,
188
+ update_count: 0,
189
+ };
190
+
191
+ db.prepare(
192
+ `INSERT INTO user_interest_profiles (id, user_id, topics, interaction_weights, decay_factor, last_updated, update_count)
193
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
194
+ ).run(
195
+ id,
196
+ userId,
197
+ JSON.stringify(topics),
198
+ JSON.stringify(interactionWeights),
199
+ profile.decay_factor,
200
+ now,
201
+ 0,
202
+ );
203
+
204
+ _profiles.set(userId, profile);
205
+ return { profileId: id };
206
+ }
207
+
208
+ export function updateProfile(
209
+ db,
210
+ userId,
211
+ { topics, interactionWeights, decayFactor } = {},
212
+ ) {
213
+ const p = _profiles.get(userId);
214
+ if (!p) return { updated: false, reason: "not_found" };
215
+
216
+ if (topics !== undefined) p.topics = topics;
217
+ if (interactionWeights !== undefined)
218
+ p.interaction_weights = interactionWeights;
219
+ if (decayFactor !== undefined) p.decay_factor = decayFactor;
220
+ p.last_updated = _now();
221
+ p.update_count += 1;
222
+
223
+ db.prepare(
224
+ `UPDATE user_interest_profiles SET topics = ?, interaction_weights = ?, decay_factor = ?, last_updated = ?, update_count = ?
225
+ WHERE user_id = ?`,
226
+ ).run(
227
+ JSON.stringify(p.topics),
228
+ JSON.stringify(p.interaction_weights),
229
+ p.decay_factor,
230
+ p.last_updated,
231
+ p.update_count,
232
+ userId,
233
+ );
234
+
235
+ return { updated: true };
236
+ }
237
+
238
+ export function deleteProfile(db, userId) {
239
+ const p = _profiles.get(userId);
240
+ if (!p) return { deleted: false, reason: "not_found" };
241
+
242
+ db.prepare("DELETE FROM user_interest_profiles WHERE user_id = ?").run(
243
+ userId,
244
+ );
245
+ _profiles.delete(userId);
246
+ return { deleted: true };
247
+ }
248
+
249
+ export function listProfiles(db, { limit = 50 } = {}) {
250
+ return [..._profiles.values()]
251
+ .sort((a, b) => b.last_updated - a.last_updated)
252
+ .slice(0, limit)
253
+ .map((p) => ({ ...p }));
254
+ }
255
+
256
+ /* ── Topic Decay ───────────────────────────────────────── */
257
+
258
+ export function applyDecay(db, userId) {
259
+ const p = _profiles.get(userId);
260
+ if (!p) return { applied: false, reason: "not_found" };
261
+
262
+ const factor = p.decay_factor;
263
+ const decayed = {};
264
+ for (const [topic, weight] of Object.entries(p.topics)) {
265
+ const newWeight = weight * factor;
266
+ if (newWeight >= 0.01) decayed[topic] = Math.round(newWeight * 1000) / 1000;
267
+ }
268
+ p.topics = decayed;
269
+ p.last_updated = _now();
270
+
271
+ db.prepare(
272
+ "UPDATE user_interest_profiles SET topics = ?, last_updated = ? WHERE user_id = ?",
273
+ ).run(JSON.stringify(decayed), p.last_updated, userId);
274
+
275
+ return { applied: true, topicCount: Object.keys(decayed).length };
276
+ }
277
+
278
+ /* ── Content Scoring ───────────────────────────────────── */
279
+
280
+ export function scoreContent(profile, content) {
281
+ if (!profile || !profile.topics || Object.keys(profile.topics).length === 0) {
282
+ return 0;
283
+ }
284
+
285
+ const contentTopics = content.topics || [];
286
+ const contentText = (
287
+ (content.title || "") +
288
+ " " +
289
+ (content.description || "")
290
+ ).toLowerCase();
291
+
292
+ let score = 0;
293
+ let maxWeight = 0;
294
+
295
+ for (const [topic, weight] of Object.entries(profile.topics)) {
296
+ if (weight > maxWeight) maxWeight = weight;
297
+ const topicLower = topic.toLowerCase();
298
+ if (
299
+ contentTopics.includes(topicLower) ||
300
+ contentText.includes(topicLower)
301
+ ) {
302
+ score += weight;
303
+ }
304
+ }
305
+
306
+ if (maxWeight === 0) return 0;
307
+
308
+ // Interaction weight boost
309
+ const iw = profile.interaction_weights || {};
310
+ const typeBoost = iw[content.content_type] || 0;
311
+ score += typeBoost * 0.2;
312
+
313
+ // Normalize to [0, 1]
314
+ const topicCount = Object.keys(profile.topics).length;
315
+ const normalized = Math.min(1, score / (maxWeight * Math.min(topicCount, 3)));
316
+
317
+ return Math.round(normalized * 1000) / 1000;
318
+ }
319
+
320
+ /* ── Recommendation Generation ─────────────────────────── */
321
+
322
+ export function generateRecommendations(
323
+ db,
324
+ userId,
325
+ contentPool,
326
+ { limit, minScore } = {},
327
+ ) {
328
+ const effectiveLimit = limit || DEFAULT_CONFIG.defaultLimit;
329
+ const effectiveMinScore = minScore ?? DEFAULT_CONFIG.minScore;
330
+
331
+ const profile = _profiles.get(userId);
332
+ if (!profile) return { generated: 0, reason: "no_profile" };
333
+
334
+ const scored = [];
335
+ for (const content of contentPool) {
336
+ const s = scoreContent(profile, content);
337
+ if (s >= effectiveMinScore) {
338
+ scored.push({ content, score: s });
339
+ }
340
+ }
341
+
342
+ scored.sort((a, b) => b.score - a.score);
343
+ const topN = scored.slice(
344
+ 0,
345
+ Math.min(effectiveLimit, DEFAULT_CONFIG.maxBatchSize),
346
+ );
347
+
348
+ const now = _now();
349
+ const ids = [];
350
+
351
+ for (const { content, score } of topN) {
352
+ const id = _id();
353
+ const reason = _buildReason(profile, content);
354
+ const rec = {
355
+ id,
356
+ user_id: userId,
357
+ content_id: content.id || content.content_id,
358
+ content_type: content.content_type || "note",
359
+ title: content.title || "",
360
+ score,
361
+ reason,
362
+ source: "heuristic",
363
+ status: "pending",
364
+ feedback: null,
365
+ created_at: now,
366
+ viewed_at: null,
367
+ };
368
+
369
+ db.prepare(
370
+ `INSERT OR REPLACE INTO content_recommendations
371
+ (id, user_id, content_id, content_type, title, score, reason, source, status, feedback, created_at, viewed_at)
372
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
373
+ ).run(
374
+ id,
375
+ userId,
376
+ rec.content_id,
377
+ rec.content_type,
378
+ rec.title,
379
+ score,
380
+ reason,
381
+ "heuristic",
382
+ "pending",
383
+ null,
384
+ now,
385
+ null,
386
+ );
387
+
388
+ _recommendations.set(id, rec);
389
+ if (!_userRecs.has(userId)) _userRecs.set(userId, new Set());
390
+ _userRecs.get(userId).add(id);
391
+ ids.push(id);
392
+ }
393
+
394
+ return { generated: ids.length, ids };
395
+ }
396
+
397
+ function _buildReason(profile, content) {
398
+ const matched = [];
399
+ const contentTopics = content.topics || [];
400
+ const contentText = (
401
+ (content.title || "") +
402
+ " " +
403
+ (content.description || "")
404
+ ).toLowerCase();
405
+
406
+ for (const topic of Object.keys(profile.topics)) {
407
+ if (
408
+ contentTopics.includes(topic.toLowerCase()) ||
409
+ contentText.includes(topic.toLowerCase())
410
+ ) {
411
+ matched.push(topic);
412
+ }
413
+ }
414
+
415
+ if (matched.length > 0)
416
+ return `Matches interests: ${matched.slice(0, 3).join(", ")}`;
417
+ return "General recommendation";
418
+ }
419
+
420
+ /* ── Recommendation CRUD ──────────────────────────────── */
421
+
422
+ export function getRecommendation(db, recId) {
423
+ const r = _recommendations.get(recId);
424
+ return r ? { ...r } : null;
425
+ }
426
+
427
+ export function listRecommendations(
428
+ db,
429
+ userId,
430
+ { status, contentType, limit = 50, minScore } = {},
431
+ ) {
432
+ const recIds = _userRecs.get(userId);
433
+ if (!recIds) return [];
434
+
435
+ let recs = [...recIds].map((id) => _recommendations.get(id)).filter(Boolean);
436
+
437
+ if (status) recs = recs.filter((r) => r.status === status);
438
+ if (contentType) recs = recs.filter((r) => r.content_type === contentType);
439
+ if (minScore !== undefined) recs = recs.filter((r) => r.score >= minScore);
440
+
441
+ return recs
442
+ .sort((a, b) => b.score - a.score)
443
+ .slice(0, limit)
444
+ .map((r) => ({ ...r }));
445
+ }
446
+
447
+ export function markViewed(db, recId) {
448
+ const r = _recommendations.get(recId);
449
+ if (!r) return { marked: false, reason: "not_found" };
450
+ if (r.status === "viewed") return { marked: false, reason: "already_viewed" };
451
+
452
+ r.status = "viewed";
453
+ r.viewed_at = _now();
454
+
455
+ db.prepare(
456
+ "UPDATE content_recommendations SET status = ?, viewed_at = ? WHERE id = ?",
457
+ ).run("viewed", r.viewed_at, recId);
458
+
459
+ return { marked: true };
460
+ }
461
+
462
+ export function provideFeedback(db, recId, feedback) {
463
+ const validFeedback = Object.values(FEEDBACK_VALUES);
464
+ if (!validFeedback.includes(feedback)) {
465
+ return { recorded: false, reason: "invalid_feedback" };
466
+ }
467
+
468
+ const r = _recommendations.get(recId);
469
+ if (!r) return { recorded: false, reason: "not_found" };
470
+
471
+ r.feedback = feedback;
472
+ if (feedback === "dislike") r.status = "dismissed";
473
+ if (r.status === "pending") {
474
+ r.status = "viewed";
475
+ r.viewed_at = r.viewed_at || _now();
476
+ }
477
+
478
+ db.prepare(
479
+ "UPDATE content_recommendations SET feedback = ?, status = ?, viewed_at = ? WHERE id = ?",
480
+ ).run(r.feedback, r.status, r.viewed_at, recId);
481
+
482
+ return { recorded: true };
483
+ }
484
+
485
+ export function dismissRecommendation(db, recId) {
486
+ const r = _recommendations.get(recId);
487
+ if (!r) return { dismissed: false, reason: "not_found" };
488
+
489
+ r.status = "dismissed";
490
+ db.prepare("UPDATE content_recommendations SET status = ? WHERE id = ?").run(
491
+ "dismissed",
492
+ recId,
493
+ );
494
+ return { dismissed: true };
495
+ }
496
+
497
+ /* ── Stats ─────────────────────────────────────────────── */
498
+
499
+ export function getRecommendationStats(db, userId) {
500
+ const recIds = _userRecs.get(userId);
501
+ if (!recIds || recIds.size === 0) {
502
+ return {
503
+ total: 0,
504
+ pending: 0,
505
+ viewed: 0,
506
+ dismissed: 0,
507
+ feedbackCount: 0,
508
+ feedbackRate: 0,
509
+ avgScore: 0,
510
+ };
511
+ }
512
+
513
+ let total = 0;
514
+ let pending = 0;
515
+ let viewed = 0;
516
+ let dismissed = 0;
517
+ let feedbackCount = 0;
518
+ let scoreSum = 0;
519
+
520
+ for (const id of recIds) {
521
+ const r = _recommendations.get(id);
522
+ if (!r) continue;
523
+ total++;
524
+ scoreSum += r.score;
525
+ if (r.status === "pending") pending++;
526
+ else if (r.status === "viewed") viewed++;
527
+ else if (r.status === "dismissed") dismissed++;
528
+ if (r.feedback) feedbackCount++;
529
+ }
530
+
531
+ return {
532
+ total,
533
+ pending,
534
+ viewed,
535
+ dismissed,
536
+ feedbackCount,
537
+ feedbackRate:
538
+ total > 0 ? Math.round((feedbackCount / total) * 1000) / 1000 : 0,
539
+ avgScore: total > 0 ? Math.round((scoreSum / total) * 1000) / 1000 : 0,
540
+ };
541
+ }
542
+
543
+ /* ── Profile-Based Insights ────────────────────────────── */
544
+
545
+ export function getTopInterests(db, userId, { limit = 10 } = {}) {
546
+ const p = _profiles.get(userId);
547
+ if (!p) return [];
548
+
549
+ return Object.entries(p.topics)
550
+ .sort(([, a], [, b]) => b - a)
551
+ .slice(0, limit)
552
+ .map(([topic, weight]) => ({ topic, weight }));
553
+ }
554
+
555
+ export function suggestTopics(db, userId) {
556
+ const p = _profiles.get(userId);
557
+ if (!p) return [];
558
+
559
+ const recIds = _userRecs.get(userId) || new Set();
560
+ const feedbackTopics = {};
561
+
562
+ for (const id of recIds) {
563
+ const r = _recommendations.get(id);
564
+ if (!r || !r.feedback) continue;
565
+ const reason = r.reason || "";
566
+ const matches = reason.match(/Matches interests: (.+)/);
567
+ if (matches) {
568
+ for (const t of matches[1].split(", ")) {
569
+ if (!feedbackTopics[t]) feedbackTopics[t] = { like: 0, dislike: 0 };
570
+ if (r.feedback === "like") feedbackTopics[t].like++;
571
+ if (r.feedback === "dislike") feedbackTopics[t].dislike++;
572
+ }
573
+ }
574
+ }
575
+
576
+ const suggestions = [];
577
+ for (const [topic, counts] of Object.entries(feedbackTopics)) {
578
+ if (counts.like > counts.dislike) {
579
+ const boost = (counts.like - counts.dislike) * 0.1;
580
+ suggestions.push({
581
+ topic,
582
+ action: "boost",
583
+ amount: Math.round(boost * 1000) / 1000,
584
+ });
585
+ } else if (counts.dislike > counts.like) {
586
+ suggestions.push({ topic, action: "reduce", amount: 0.1 });
587
+ }
588
+ }
589
+
590
+ return suggestions;
591
+ }
592
+
593
+ /* ── Reset (tests) ─────────────────────────────────────── */
594
+
595
+ export function _resetState() {
596
+ _profiles.clear();
597
+ _recommendations.clear();
598
+ _userRecs.clear();
599
+ _seq = 0;
600
+ }