chattercatcher 0.1.18 → 0.1.19

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/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import fs14 from "fs/promises";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "chattercatcher",
11
- version: "0.1.18",
11
+ version: "0.1.19",
12
12
  description: "\u672C\u5730\u4F18\u5148\u7684\u98DE\u4E66/Lark \u5BB6\u5EAD\u7FA4\u77E5\u8BC6\u5E93\u673A\u5668\u4EBA",
13
13
  type: "module",
14
14
  main: "dist/index.js",
@@ -27,7 +27,9 @@ var package_default = {
27
27
  files: [
28
28
  "assets",
29
29
  "dist",
30
- "docs",
30
+ "docs/DEVELOPMENT_PLAN.md",
31
+ "docs/PRD.md",
32
+ "docs/TECHNICAL_ARCHITECTURE.md",
31
33
  "README.md",
32
34
  "AGENTS.md"
33
35
  ],
@@ -478,6 +480,22 @@ function migrateDatabase(database) {
478
480
  CREATE INDEX IF NOT EXISTS message_chunk_embeddings_model_idx
479
481
  ON message_chunk_embeddings(model, dimension);
480
482
 
483
+ CREATE TABLE IF NOT EXISTS qa_logs (
484
+ id TEXT PRIMARY KEY,
485
+ chat_id TEXT,
486
+ question_message_id TEXT,
487
+ question TEXT NOT NULL,
488
+ answer TEXT NOT NULL,
489
+ citations_json TEXT NOT NULL,
490
+ retrieval_debug_json TEXT NOT NULL,
491
+ status TEXT NOT NULL CHECK(status IN ('answered','failed')),
492
+ error TEXT,
493
+ created_at TEXT NOT NULL
494
+ );
495
+
496
+ CREATE INDEX IF NOT EXISTS qa_logs_created_at_idx ON qa_logs(created_at);
497
+ CREATE INDEX IF NOT EXISTS qa_logs_chat_idx ON qa_logs(chat_id, created_at);
498
+
481
499
  CREATE TABLE IF NOT EXISTS file_jobs (
482
500
  id TEXT PRIMARY KEY,
483
501
  source_path TEXT NOT NULL,
@@ -2617,6 +2635,191 @@ async function ensureFeishuBotOpenId(config, secrets, options = {}) {
2617
2635
  // src/feishu/gateway.ts
2618
2636
  import * as lark2 from "@larksuiteoapi/node-sdk";
2619
2637
 
2638
+ // src/gateway/indexing-scheduler.ts
2639
+ function createIndexingScheduler(options) {
2640
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
2641
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
2642
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
2643
+ const logger = options.logger ?? console;
2644
+ const parsed = parseCronSchedule(options.schedule);
2645
+ let timer;
2646
+ let running = false;
2647
+ const runDueNow = async () => {
2648
+ if (!parsed || running || !matchesParsedSchedule(parsed, now())) {
2649
+ return;
2650
+ }
2651
+ running = true;
2652
+ try {
2653
+ await options.work();
2654
+ } catch (error) {
2655
+ const message = error instanceof Error ? error.message : String(error);
2656
+ logger.error(`\u5B9A\u65F6\u6D88\u606F\u7D22\u5F15\u5931\u8D25\uFF1A${message}`);
2657
+ } finally {
2658
+ running = false;
2659
+ }
2660
+ };
2661
+ return {
2662
+ start() {
2663
+ if (!parsed || timer) {
2664
+ return;
2665
+ }
2666
+ timer = setIntervalFn(() => {
2667
+ void runDueNow();
2668
+ }, 6e4);
2669
+ },
2670
+ stop() {
2671
+ if (!timer) {
2672
+ return;
2673
+ }
2674
+ clearIntervalFn(timer);
2675
+ timer = void 0;
2676
+ },
2677
+ runDueNow
2678
+ };
2679
+ }
2680
+ function matchesParsedSchedule(schedule, date) {
2681
+ return schedule.minute(date.getMinutes()) && schedule.hour(date.getHours()) && schedule.dayOfMonth(date.getDate()) && schedule.month(date.getMonth() + 1) && schedule.dayOfWeek(date.getDay());
2682
+ }
2683
+ function parseCronSchedule(schedule) {
2684
+ const fields = schedule.trim().split(/\s+/);
2685
+ if (fields.length !== 5) {
2686
+ return null;
2687
+ }
2688
+ const minute = parseMinuteField(fields[0]);
2689
+ const hour = parseExactOrWildcardField(fields[1], 0, 23);
2690
+ const dayOfMonth = parseExactOrWildcardField(fields[2], 1, 31);
2691
+ const month = parseExactOrWildcardField(fields[3], 1, 12);
2692
+ const dayOfWeek = parseExactOrWildcardField(fields[4], 0, 6);
2693
+ if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
2694
+ return null;
2695
+ }
2696
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
2697
+ }
2698
+ function parseMinuteField(field) {
2699
+ if (field === "*") {
2700
+ return () => true;
2701
+ }
2702
+ const stepMatch = /^\*\/(\d+)$/.exec(field);
2703
+ if (stepMatch) {
2704
+ const step = Number(stepMatch[1]);
2705
+ if (!Number.isInteger(step) || step <= 0 || step > 59) {
2706
+ return null;
2707
+ }
2708
+ return (value) => value % step === 0;
2709
+ }
2710
+ if (field.includes(",")) {
2711
+ const values = field.split(",").map((part) => parseExactNumber(part, 0, 59));
2712
+ if (values.some((value) => value === null)) {
2713
+ return null;
2714
+ }
2715
+ const allowed = new Set(values);
2716
+ return (value) => allowed.has(value);
2717
+ }
2718
+ const exact = parseExactNumber(field, 0, 59);
2719
+ if (exact === null) {
2720
+ return null;
2721
+ }
2722
+ return (value) => value === exact;
2723
+ }
2724
+ function parseExactOrWildcardField(field, min, max) {
2725
+ if (field === "*") {
2726
+ return () => true;
2727
+ }
2728
+ const exact = parseExactNumber(field, min, max);
2729
+ if (exact === null) {
2730
+ return null;
2731
+ }
2732
+ return (value) => value === exact;
2733
+ }
2734
+ function parseExactNumber(field, min, max) {
2735
+ if (!/^\d+$/.test(field)) {
2736
+ return null;
2737
+ }
2738
+ const value = Number(field);
2739
+ if (!Number.isInteger(value) || value < min || value > max) {
2740
+ return null;
2741
+ }
2742
+ return value;
2743
+ }
2744
+
2745
+ // src/rag/indexer.ts
2746
+ async function indexMessageChunks(input2) {
2747
+ const chunks = input2.messageIds ? input2.messages.listMessageChunksByMessageIds(input2.messageIds, input2.limit ?? 1e4) : input2.messages.listAllMessageChunks(input2.limit ?? 1e4);
2748
+ if (chunks.length === 0) {
2749
+ return { chunks: 0, vectors: 0 };
2750
+ }
2751
+ const vectors = await input2.embedding.embedBatch(chunks.map((chunk) => chunk.text));
2752
+ const records = [];
2753
+ for (const [index2, chunk] of chunks.entries()) {
2754
+ const vector = vectors[index2];
2755
+ if (!vector || vector.length === 0) {
2756
+ continue;
2757
+ }
2758
+ records.push({
2759
+ id: chunk.chunkId,
2760
+ vector,
2761
+ evidence: {
2762
+ id: chunk.chunkId,
2763
+ text: chunk.text,
2764
+ score: 1,
2765
+ source: toEvidenceSource3(chunk)
2766
+ }
2767
+ });
2768
+ }
2769
+ await input2.store.upsert(records);
2770
+ return {
2771
+ chunks: chunks.length,
2772
+ vectors: records.length
2773
+ };
2774
+ }
2775
+ function toEvidenceSource3(chunk) {
2776
+ if (chunk.messageType === "file") {
2777
+ return {
2778
+ type: "file",
2779
+ label: chunk.senderName,
2780
+ timestamp: chunk.sentAt
2781
+ };
2782
+ }
2783
+ return {
2784
+ type: "message",
2785
+ label: chunk.chatName,
2786
+ sender: chunk.senderName,
2787
+ timestamp: chunk.sentAt
2788
+ };
2789
+ }
2790
+
2791
+ // src/rag/manual-index.ts
2792
+ async function processMessagesNow(input2) {
2793
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2794
+ if (!hasEmbeddingConfig(input2.config, input2.secrets)) {
2795
+ return {
2796
+ status: "skipped",
2797
+ reason: "Embedding \u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1BSQLite FTS \u5DF2\u5728\u6D88\u606F\u5165\u5E93\u65F6\u5373\u65F6\u66F4\u65B0\u3002",
2798
+ chunks: 0,
2799
+ vectors: 0,
2800
+ startedAt,
2801
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
2802
+ };
2803
+ }
2804
+ const vectorStore = new SqliteVectorStore(input2.database, {
2805
+ model: input2.config.embedding.model
2806
+ });
2807
+ const embedding = input2.embedding ?? createEmbeddingModel(input2.config, input2.secrets);
2808
+ const stats = await indexMessageChunks({
2809
+ messages: new MessageRepository(input2.database),
2810
+ embedding,
2811
+ store: vectorStore,
2812
+ limit: input2.limit
2813
+ });
2814
+ return {
2815
+ status: "completed",
2816
+ chunks: stats.chunks,
2817
+ vectors: stats.vectors,
2818
+ startedAt,
2819
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
2820
+ };
2821
+ }
2822
+
2620
2823
  // src/multimodal/tasks.ts
2621
2824
  import crypto4 from "crypto";
2622
2825
  function nowIso4() {
@@ -3102,6 +3305,108 @@ async function askWithAgenticRag(input2) {
3102
3305
  });
3103
3306
  }
3104
3307
 
3308
+ // src/rag/qa-logs.ts
3309
+ import crypto5 from "crypto";
3310
+ function clampLimit(limit) {
3311
+ return Math.max(1, Math.min(200, Math.trunc(limit)));
3312
+ }
3313
+ var QaLogRepository = class {
3314
+ constructor(database) {
3315
+ this.database = database;
3316
+ }
3317
+ database;
3318
+ create(input2) {
3319
+ const record = {
3320
+ id: `qa_${crypto5.randomUUID()}`,
3321
+ chatId: input2.chatId ?? null,
3322
+ questionMessageId: input2.questionMessageId ?? null,
3323
+ question: input2.question,
3324
+ answer: input2.answer,
3325
+ citations: input2.citations,
3326
+ retrievalDebug: input2.retrievalDebug,
3327
+ status: input2.status,
3328
+ error: input2.error ?? null,
3329
+ createdAt: input2.createdAt
3330
+ };
3331
+ this.database.prepare(
3332
+ `
3333
+ INSERT INTO qa_logs (
3334
+ id,
3335
+ chat_id,
3336
+ question_message_id,
3337
+ question,
3338
+ answer,
3339
+ citations_json,
3340
+ retrieval_debug_json,
3341
+ status,
3342
+ error,
3343
+ created_at
3344
+ )
3345
+ VALUES (
3346
+ @id,
3347
+ @chatId,
3348
+ @questionMessageId,
3349
+ @question,
3350
+ @answer,
3351
+ @citationsJson,
3352
+ @retrievalDebugJson,
3353
+ @status,
3354
+ @error,
3355
+ @createdAt
3356
+ )
3357
+ `
3358
+ ).run({
3359
+ id: record.id,
3360
+ chatId: record.chatId,
3361
+ questionMessageId: record.questionMessageId,
3362
+ question: record.question,
3363
+ answer: record.answer,
3364
+ citationsJson: JSON.stringify(record.citations),
3365
+ retrievalDebugJson: JSON.stringify(record.retrievalDebug),
3366
+ status: record.status,
3367
+ error: record.error,
3368
+ createdAt: record.createdAt
3369
+ });
3370
+ return record;
3371
+ }
3372
+ listRecent(limit) {
3373
+ const rows = this.database.prepare(
3374
+ `
3375
+ SELECT
3376
+ id,
3377
+ chat_id,
3378
+ question_message_id,
3379
+ question,
3380
+ answer,
3381
+ citations_json,
3382
+ retrieval_debug_json,
3383
+ status,
3384
+ error,
3385
+ created_at
3386
+ FROM qa_logs
3387
+ ORDER BY created_at DESC
3388
+ LIMIT ?
3389
+ `
3390
+ ).all(clampLimit(limit));
3391
+ return rows.map((row) => ({
3392
+ id: row.id,
3393
+ chatId: row.chat_id,
3394
+ questionMessageId: row.question_message_id,
3395
+ question: row.question,
3396
+ answer: row.answer,
3397
+ citations: JSON.parse(row.citations_json),
3398
+ retrievalDebug: JSON.parse(row.retrieval_debug_json),
3399
+ status: row.status,
3400
+ error: row.error,
3401
+ createdAt: row.created_at
3402
+ }));
3403
+ }
3404
+ getCount() {
3405
+ const row = this.database.prepare("SELECT COUNT(*) AS count FROM qa_logs").get();
3406
+ return row.count;
3407
+ }
3408
+ };
3409
+
3105
3410
  // src/feishu/question.ts
3106
3411
  function parseTextContent(content) {
3107
3412
  if (!content) {
@@ -3208,6 +3513,7 @@ var FeishuQuestionHandler = class {
3208
3513
  return decision;
3209
3514
  }
3210
3515
  const questionMessageId = payload.event?.message?.message_id;
3516
+ const qaLogs = new QaLogRepository(this.options.database);
3211
3517
  await this.acknowledgeQuestion(decision.chatId, questionMessageId);
3212
3518
  const { tools, close } = await createAgenticRagSearchTools({
3213
3519
  config: this.options.config,
@@ -3223,6 +3529,16 @@ var FeishuQuestionHandler = class {
3223
3529
  tools,
3224
3530
  model: this.options.model
3225
3531
  });
3532
+ qaLogs.create({
3533
+ chatId: decision.chatId,
3534
+ questionMessageId,
3535
+ question: decision.question,
3536
+ answer: result.answer,
3537
+ citations: result.citations,
3538
+ retrievalDebug: { evidenceCount: result.citations.length },
3539
+ status: "answered",
3540
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3541
+ });
3226
3542
  const citations = formatCitations(result.citations);
3227
3543
  const text = citations ? `${result.answer}
3228
3544
 
@@ -3231,6 +3547,17 @@ ${citations}` : result.answer;
3231
3547
  await this.sendResponse(decision.chatId, questionMessageId, text);
3232
3548
  } catch (error) {
3233
3549
  const message = error instanceof Error ? error.message : String(error);
3550
+ qaLogs.create({
3551
+ chatId: decision.chatId,
3552
+ questionMessageId,
3553
+ question: decision.question,
3554
+ answer: `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`,
3555
+ citations: [],
3556
+ retrievalDebug: {},
3557
+ status: "failed",
3558
+ error: message,
3559
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3560
+ });
3234
3561
  await this.sendResponse(decision.chatId, questionMessageId, `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`);
3235
3562
  }
3236
3563
  return decision;
@@ -3452,15 +3779,29 @@ function createFeishuGateway(options) {
3452
3779
  episodeProcessor: options.episodeProcessor,
3453
3780
  imageMultimodalProcessor: options.imageMultimodalProcessor
3454
3781
  });
3782
+ const indexingScheduler = options.indexingScheduler ?? (options.indexingProcessor ? createIndexingScheduler({
3783
+ schedule: options.config.schedules.indexing,
3784
+ work: async () => {
3785
+ await processMessagesNow({
3786
+ config: options.config,
3787
+ secrets: options.secrets,
3788
+ database: options.indexingProcessor.database,
3789
+ limit: 1e4
3790
+ });
3791
+ }
3792
+ }) : void 0);
3455
3793
  return {
3456
3794
  async start() {
3457
3795
  try {
3458
3796
  await wsClient.start({ eventDispatcher });
3797
+ indexingScheduler?.start();
3459
3798
  } catch (error) {
3799
+ indexingScheduler?.stop();
3460
3800
  throw formatGatewayStartError(error);
3461
3801
  }
3462
3802
  },
3463
3803
  stop() {
3804
+ indexingScheduler?.stop();
3464
3805
  wsClient.close({ force: true });
3465
3806
  }
3466
3807
  };
@@ -3532,7 +3873,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
3532
3873
  };
3533
3874
 
3534
3875
  // src/files/ingest.ts
3535
- import crypto5 from "crypto";
3876
+ import crypto6 from "crypto";
3536
3877
  import fs11 from "fs/promises";
3537
3878
  import path13 from "path";
3538
3879
 
@@ -3596,7 +3937,7 @@ function ensureSupportedTextFile(filePath) {
3596
3937
  }
3597
3938
  }
3598
3939
  function stableStoredName(sourcePath, fileName) {
3599
- const digest = crypto5.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
3940
+ const digest = crypto6.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
3600
3941
  return `${digest}-${fileName}`;
3601
3942
  }
3602
3943
  async function ingestLocalFile(input2) {
@@ -4118,84 +4459,6 @@ function createMultimodalModel(config, secrets) {
4118
4459
  });
4119
4460
  }
4120
4461
 
4121
- // src/rag/indexer.ts
4122
- async function indexMessageChunks(input2) {
4123
- const chunks = input2.messageIds ? input2.messages.listMessageChunksByMessageIds(input2.messageIds, input2.limit ?? 1e4) : input2.messages.listAllMessageChunks(input2.limit ?? 1e4);
4124
- if (chunks.length === 0) {
4125
- return { chunks: 0, vectors: 0 };
4126
- }
4127
- const vectors = await input2.embedding.embedBatch(chunks.map((chunk) => chunk.text));
4128
- const records = [];
4129
- for (const [index2, chunk] of chunks.entries()) {
4130
- const vector = vectors[index2];
4131
- if (!vector || vector.length === 0) {
4132
- continue;
4133
- }
4134
- records.push({
4135
- id: chunk.chunkId,
4136
- vector,
4137
- evidence: {
4138
- id: chunk.chunkId,
4139
- text: chunk.text,
4140
- score: 1,
4141
- source: toEvidenceSource3(chunk)
4142
- }
4143
- });
4144
- }
4145
- await input2.store.upsert(records);
4146
- return {
4147
- chunks: chunks.length,
4148
- vectors: records.length
4149
- };
4150
- }
4151
- function toEvidenceSource3(chunk) {
4152
- if (chunk.messageType === "file") {
4153
- return {
4154
- type: "file",
4155
- label: chunk.senderName,
4156
- timestamp: chunk.sentAt
4157
- };
4158
- }
4159
- return {
4160
- type: "message",
4161
- label: chunk.chatName,
4162
- sender: chunk.senderName,
4163
- timestamp: chunk.sentAt
4164
- };
4165
- }
4166
-
4167
- // src/rag/manual-index.ts
4168
- async function processMessagesNow(input2) {
4169
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4170
- if (!hasEmbeddingConfig(input2.config, input2.secrets)) {
4171
- return {
4172
- status: "skipped",
4173
- reason: "Embedding \u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1BSQLite FTS \u5DF2\u5728\u6D88\u606F\u5165\u5E93\u65F6\u5373\u65F6\u66F4\u65B0\u3002",
4174
- chunks: 0,
4175
- vectors: 0,
4176
- startedAt,
4177
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
4178
- };
4179
- }
4180
- const vectorStore = new SqliteVectorStore(input2.database, {
4181
- model: input2.config.embedding.model
4182
- });
4183
- const embedding = input2.embedding ?? createEmbeddingModel(input2.config, input2.secrets);
4184
- const stats = await indexMessageChunks({
4185
- messages: new MessageRepository(input2.database),
4186
- embedding,
4187
- store: vectorStore,
4188
- limit: input2.limit
4189
- });
4190
- return {
4191
- status: "completed",
4192
- chunks: stats.chunks,
4193
- vectors: stats.vectors,
4194
- startedAt,
4195
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
4196
- };
4197
- }
4198
-
4199
4462
  // src/rag/qa-service.ts
4200
4463
  async function askWithRag(input2) {
4201
4464
  const evidence = await input2.retriever.retrieve(input2.question);
@@ -4444,6 +4707,10 @@ function buildHtml() {
4444
4707
  <h2>\u4F1A\u8BDD\u8BB0\u5FC6</h2>
4445
4708
  <div id="episodes" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
4446
4709
  </section>
4710
+ <section>
4711
+ <h2>\u95EE\u7B54\u65E5\u5FD7</h2>
4712
+ <div id="qa-logs" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
4713
+ </section>
4447
4714
  </div>
4448
4715
  <aside>
4449
4716
  <section>
@@ -4474,6 +4741,7 @@ function buildHtml() {
4474
4741
  const chats = document.querySelector("#chats");
4475
4742
  const files = document.querySelector("#files");
4476
4743
  const fileJobs = document.querySelector("#file-jobs");
4744
+ const qaLogs = document.querySelector("#qa-logs");
4477
4745
  const processMessages = document.querySelector("#process-messages");
4478
4746
  const actionStatus = document.querySelector("#action-status");
4479
4747
 
@@ -4665,14 +4933,45 @@ function buildHtml() {
4665
4933
  \`;
4666
4934
  }
4667
4935
 
4936
+ function renderQaLogs(items) {
4937
+ if (items.length === 0) {
4938
+ qaLogs.className = "empty";
4939
+ qaLogs.textContent = "\u8FD8\u6CA1\u6709\u95EE\u7B54\u65E5\u5FD7\u3002";
4940
+ return;
4941
+ }
4942
+ qaLogs.className = "";
4943
+ const rows = items.map((item) => {
4944
+ const citationCount = Array.isArray(item.citations) ? item.citations.length : 0;
4945
+ return [
4946
+ '<article class="message-item">',
4947
+ ' <div class="message-meta">',
4948
+ " <span>" + escapeHtml(formatDateTime(item.createdAt)) + "</span>",
4949
+ " <span>" + escapeHtml(item.status) + "</span>",
4950
+ " <span>" + escapeHtml(citationCount) + " \u6761\u5F15\u7528</span>",
4951
+ " </div>",
4952
+ " <div class="message-body"><strong>\u95EE\uFF1A</strong>" + escapeHtml(item.question) + "</div>",
4953
+ " <div class="message-body"><strong>\u7B54\uFF1A</strong>" + escapeHtml(item.answer) + "</div>",
4954
+ "</article>",
4955
+ ].join("
4956
+ ");
4957
+ });
4958
+ qaLogs.innerHTML = [
4959
+ '<div class="message-list">',
4960
+ rows.join(""),
4961
+ "</div>",
4962
+ ].join("
4963
+ ");
4964
+ }
4965
+
4668
4966
  async function load() {
4669
- const [status, recent, episodeList, chatList, fileList, jobList] = await Promise.all([
4967
+ const [status, recent, episodeList, chatList, fileList, jobList, qaLogList] = await Promise.all([
4670
4968
  fetch("/api/status").then((response) => response.json()),
4671
4969
  fetch("/api/messages/recent?limit=20").then((response) => response.json()),
4672
4970
  fetch("/api/episodes?limit=10").then((response) => response.json()),
4673
4971
  fetch("/api/chats").then((response) => response.json()),
4674
4972
  fetch("/api/files").then((response) => response.json()),
4675
4973
  fetch("/api/file-jobs").then((response) => response.json()),
4974
+ fetch("/api/qa-logs?limit=10").then((response) => response.json()),
4676
4975
  ]);
4677
4976
  renderMetrics(status);
4678
4977
  renderMessages(recent.items);
@@ -4680,6 +4979,7 @@ function buildHtml() {
4680
4979
  renderChats(chatList.items);
4681
4980
  renderFiles(fileList.items);
4682
4981
  renderFileJobs(jobList.items);
4982
+ renderQaLogs(qaLogList.items);
4683
4983
  }
4684
4984
 
4685
4985
  async function processNow() {
@@ -4727,6 +5027,7 @@ function createWebApp(config) {
4727
5027
  const messages = new MessageRepository(database);
4728
5028
  const episodes = new EpisodeRepository(database);
4729
5029
  const fileJobs = new FileJobRepository(database);
5030
+ const qaLogs = new QaLogRepository(database);
4730
5031
  app.addHook("onClose", async () => {
4731
5032
  database.close();
4732
5033
  });
@@ -4737,7 +5038,8 @@ function createWebApp(config) {
4737
5038
  chats: messages.getChatCount(),
4738
5039
  messages: messages.getMessageCount(),
4739
5040
  episodes: episodes.getEpisodeCount(),
4740
- files: messages.listFiles(1e3).length
5041
+ files: messages.listFiles(1e3).length,
5042
+ qaLogs: qaLogs.getCount()
4741
5043
  },
4742
5044
  rag: {
4743
5045
  mode: "required",
@@ -4778,6 +5080,12 @@ function createWebApp(config) {
4778
5080
  items: episodes.listRecentEpisodes(limit)
4779
5081
  };
4780
5082
  });
5083
+ app.get("/api/qa-logs", async (request) => {
5084
+ const limit = parseLimit(request.query.limit, 20, 100);
5085
+ return {
5086
+ items: qaLogs.listRecent(limit)
5087
+ };
5088
+ });
4781
5089
  app.post("/api/process/messages", async (_request, reply) => {
4782
5090
  try {
4783
5091
  return await processMessagesNow({
@@ -5013,6 +5321,9 @@ async function startGatewayForegroundCommand() {
5013
5321
  database,
5014
5322
  model: createMultimodalModel(config, secrets)
5015
5323
  } : void 0,
5324
+ indexingProcessor: {
5325
+ database
5326
+ },
5016
5327
  questionHandler: new FeishuQuestionHandler({
5017
5328
  config,
5018
5329
  secrets,