@gmickel/gno 0.3.5 → 0.4.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 (53) hide show
  1. package/README.md +64 -1
  2. package/package.json +30 -1
  3. package/src/cli/commands/ask.ts +11 -186
  4. package/src/cli/commands/models/pull.ts +9 -4
  5. package/src/cli/commands/serve.ts +19 -0
  6. package/src/cli/program.ts +28 -0
  7. package/src/llm/registry.ts +3 -1
  8. package/src/pipeline/answer.ts +191 -0
  9. package/src/serve/CLAUDE.md +91 -0
  10. package/src/serve/bunfig.toml +2 -0
  11. package/src/serve/context.ts +181 -0
  12. package/src/serve/index.ts +7 -0
  13. package/src/serve/public/app.tsx +56 -0
  14. package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
  15. package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
  16. package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
  17. package/src/serve/public/components/ai-elements/loader.tsx +96 -0
  18. package/src/serve/public/components/ai-elements/message.tsx +443 -0
  19. package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
  20. package/src/serve/public/components/ai-elements/sources.tsx +75 -0
  21. package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
  22. package/src/serve/public/components/preset-selector.tsx +403 -0
  23. package/src/serve/public/components/ui/badge.tsx +46 -0
  24. package/src/serve/public/components/ui/button-group.tsx +82 -0
  25. package/src/serve/public/components/ui/button.tsx +62 -0
  26. package/src/serve/public/components/ui/card.tsx +92 -0
  27. package/src/serve/public/components/ui/carousel.tsx +244 -0
  28. package/src/serve/public/components/ui/collapsible.tsx +31 -0
  29. package/src/serve/public/components/ui/command.tsx +181 -0
  30. package/src/serve/public/components/ui/dialog.tsx +141 -0
  31. package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
  32. package/src/serve/public/components/ui/hover-card.tsx +42 -0
  33. package/src/serve/public/components/ui/input-group.tsx +167 -0
  34. package/src/serve/public/components/ui/input.tsx +21 -0
  35. package/src/serve/public/components/ui/progress.tsx +28 -0
  36. package/src/serve/public/components/ui/scroll-area.tsx +56 -0
  37. package/src/serve/public/components/ui/select.tsx +188 -0
  38. package/src/serve/public/components/ui/separator.tsx +26 -0
  39. package/src/serve/public/components/ui/table.tsx +114 -0
  40. package/src/serve/public/components/ui/textarea.tsx +18 -0
  41. package/src/serve/public/components/ui/tooltip.tsx +59 -0
  42. package/src/serve/public/globals.css +226 -0
  43. package/src/serve/public/hooks/use-api.ts +112 -0
  44. package/src/serve/public/index.html +13 -0
  45. package/src/serve/public/pages/Ask.tsx +442 -0
  46. package/src/serve/public/pages/Browse.tsx +270 -0
  47. package/src/serve/public/pages/Dashboard.tsx +202 -0
  48. package/src/serve/public/pages/DocView.tsx +302 -0
  49. package/src/serve/public/pages/Search.tsx +335 -0
  50. package/src/serve/routes/api.ts +763 -0
  51. package/src/serve/server.ts +249 -0
  52. package/src/store/sqlite/adapter.ts +47 -0
  53. package/src/store/types.ts +10 -0
package/README.md CHANGED
@@ -16,6 +16,8 @@ GNO is a local knowledge engine for privacy-conscious developers and AI agents.
16
16
  - [Quick Start](#quick-start)
17
17
  - [Installation](#installation)
18
18
  - [Search Modes](#search-modes)
19
+ - [Web UI](#web-ui)
20
+ - [REST API](#rest-api)
19
21
  - [Agent Integration](#agent-integration)
20
22
  - [How It Works](#how-it-works)
21
23
  - [Features](#features)
@@ -113,6 +115,65 @@ Output formats: `--json`, `--files`, `--csv`, `--md`, `--xml`
113
115
 
114
116
  ---
115
117
 
118
+ ## Web UI
119
+
120
+ Visual dashboard for search, browsing, and AI answers—right in your browser.
121
+
122
+ ```bash
123
+ gno serve # Start on port 3000
124
+ gno serve --port 8080 # Custom port
125
+ ```
126
+
127
+ Open `http://localhost:3000` to:
128
+
129
+ - **Search** — BM25, vector, or hybrid modes with visual results
130
+ - **Browse** — Paginated document list, filter by collection
131
+ - **Ask** — AI-powered Q&A with citations
132
+ - **Switch presets** — Change models live without restart
133
+
134
+ Everything runs locally. No cloud, no accounts, no data leaving your machine.
135
+
136
+ > **Detailed docs**: [Web UI Guide](https://gno.sh/docs/WEB-UI/)
137
+
138
+ ---
139
+
140
+ ## REST API
141
+
142
+ Programmatic access to all GNO features via HTTP.
143
+
144
+ ```bash
145
+ # Hybrid search
146
+ curl -X POST http://localhost:3000/api/query \
147
+ -H "Content-Type: application/json" \
148
+ -d '{"query": "authentication patterns", "limit": 10}'
149
+
150
+ # AI answer
151
+ curl -X POST http://localhost:3000/api/ask \
152
+ -H "Content-Type: application/json" \
153
+ -d '{"query": "What is our deployment process?"}'
154
+
155
+ # Index status
156
+ curl http://localhost:3000/api/status
157
+ ```
158
+
159
+ | Endpoint | Method | Description |
160
+ |:---------|:-------|:------------|
161
+ | `/api/query` | POST | Hybrid search (recommended) |
162
+ | `/api/search` | POST | BM25 keyword search |
163
+ | `/api/ask` | POST | AI-powered Q&A |
164
+ | `/api/docs` | GET | List documents |
165
+ | `/api/doc` | GET | Get document content |
166
+ | `/api/status` | GET | Index statistics |
167
+ | `/api/presets` | GET/POST | Model preset management |
168
+ | `/api/models/pull` | POST | Download models |
169
+ | `/api/models/status` | GET | Download progress |
170
+
171
+ No authentication. No rate limits. Build custom tools, automate workflows, integrate with any language.
172
+
173
+ > **Full reference**: [API Documentation](https://gno.sh/docs/API/)
174
+
175
+ ---
176
+
116
177
  ## Agent Integration
117
178
 
118
179
  ### MCP Server
@@ -183,6 +244,8 @@ graph TD
183
244
  | Feature | Description |
184
245
  |:--------|:------------|
185
246
  | **Hybrid Search** | BM25 + vector + RRF fusion + cross-encoder reranking |
247
+ | **Web UI** | Visual dashboard for search, browse, and AI Q&A |
248
+ | **REST API** | HTTP API for custom tools and integrations |
186
249
  | **Multi-Format** | Markdown, PDF, DOCX, XLSX, PPTX, plain text |
187
250
  | **Local LLM** | AI answers via llama.cpp—no API keys |
188
251
  | **Privacy First** | 100% offline, zero telemetry, your data stays yours |
@@ -224,7 +287,7 @@ gno models pull --all
224
287
 
225
288
  ```
226
289
  ┌─────────────────────────────────────────────────┐
227
- GNO CLI / MCP
290
+ GNO CLI / MCP / Web UI / API
228
291
  ├─────────────────────────────────────────────────┤
229
292
  │ Ports: Converter, Store, Embedding, Rerank │
230
293
  ├─────────────────────────────────────────────────┤
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "search",
@@ -56,6 +56,8 @@
56
56
  "website:dev": "cd website && make serve",
57
57
  "website:build": "cd website && make build",
58
58
  "website:demos": "cd website/demos && ./build-demos.sh",
59
+ "serve": "bun src/index.ts serve",
60
+ "serve:dev": "NODE_ENV=development bun --hot src/index.ts serve",
59
61
  "version:patch": "npm version patch --no-git-tag-version",
60
62
  "version:minor": "npm version minor --no-git-tag-version",
61
63
  "version:major": "npm version major --no-git-tag-version",
@@ -65,18 +67,44 @@
65
67
  },
66
68
  "dependencies": {
67
69
  "@modelcontextprotocol/sdk": "^1.25.1",
70
+ "@radix-ui/react-collapsible": "^1.1.12",
71
+ "@radix-ui/react-dialog": "^1.1.15",
72
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
73
+ "@radix-ui/react-hover-card": "^1.1.15",
74
+ "@radix-ui/react-progress": "^1.1.8",
75
+ "@radix-ui/react-scroll-area": "^1.2.10",
76
+ "@radix-ui/react-select": "^2.2.6",
77
+ "@radix-ui/react-separator": "^1.1.8",
78
+ "@radix-ui/react-slot": "^1.2.4",
79
+ "@radix-ui/react-tooltip": "^1.2.8",
80
+ "ai": "^6.0.5",
81
+ "bun-plugin-tailwind": "^0.1.2",
82
+ "class-variance-authority": "^0.7.1",
83
+ "clsx": "^2.1.1",
84
+ "cmdk": "^1.1.1",
68
85
  "commander": "^14.0.2",
86
+ "embla-carousel-react": "^8.6.0",
69
87
  "franc": "^6.2.0",
88
+ "lucide-react": "^0.562.0",
70
89
  "markitdown-ts": "^0.0.8",
90
+ "nanoid": "^5.1.6",
71
91
  "node-llama-cpp": "^3.14.5",
72
92
  "officeparser": "^5.2.2",
73
93
  "picocolors": "^1.1.1",
94
+ "react": "^19.2.3",
95
+ "react-dom": "^19.2.3",
96
+ "shiki": "^3.20.0",
74
97
  "sqlite-vec": "^0.1.7-alpha.2",
98
+ "streamdown": "^1.6.10",
99
+ "tailwind-merge": "^3.4.0",
100
+ "use-stick-to-bottom": "^1.1.1",
75
101
  "zod": "^4.2.1"
76
102
  },
77
103
  "devDependencies": {
78
104
  "@biomejs/biome": "2.3.10",
79
105
  "@types/bun": "latest",
106
+ "@types/react": "^19.2.7",
107
+ "@types/react-dom": "^19.2.3",
80
108
  "@typescript/native-preview": "^7.0.0-dev.20251215.1",
81
109
  "ajv": "^8.17.1",
82
110
  "ajv-formats": "^3.0.1",
@@ -87,6 +115,7 @@
87
115
  "oxlint-tsgolint": "^0.10.0",
88
116
  "pdf-lib": "^1.17.1",
89
117
  "pptxgenjs": "^4.0.1",
118
+ "tailwindcss": "^4.1.18",
90
119
  "ultracite": "^6.5.0"
91
120
  },
92
121
  "peerDependencies": {
@@ -12,13 +12,12 @@ import type {
12
12
  GenerationPort,
13
13
  RerankPort,
14
14
  } from '../../llm/types';
15
+ import {
16
+ generateGroundedAnswer,
17
+ processAnswerResult,
18
+ } from '../../pipeline/answer';
15
19
  import { type HybridSearchDeps, searchHybrid } from '../../pipeline/hybrid';
16
- import type {
17
- AskOptions,
18
- AskResult,
19
- Citation,
20
- SearchResult,
21
- } from '../../pipeline/types';
20
+ import type { AskOptions, AskResult, Citation } from '../../pipeline/types';
22
21
  import {
23
22
  createVectorIndexPort,
24
23
  type VectorIndexPort,
@@ -50,163 +49,6 @@ export type AskCommandResult =
50
49
  | { success: true; data: AskResult }
51
50
  | { success: false; error: string };
52
51
 
53
- // ─────────────────────────────────────────────────────────────────────────────
54
- // Grounded Answer Generation
55
- // ─────────────────────────────────────────────────────────────────────────────
56
-
57
- const ANSWER_PROMPT = `You are answering a question using ONLY the provided context blocks.
58
-
59
- Rules you MUST follow:
60
- 1) Use ONLY facts stated in the context blocks. Do NOT use outside knowledge.
61
- 2) Every factual statement must include an inline citation like [1] or [2] referring to a context block.
62
- 3) If the context does not contain enough information to answer, reply EXACTLY:
63
- "I don't have enough information in the provided sources to answer this question."
64
- 4) Do not cite sources you did not use. Do not invent citation numbers.
65
-
66
- Question: {query}
67
-
68
- Context blocks:
69
- {context}
70
-
71
- Write a concise answer (1-3 paragraphs).`;
72
-
73
- /** Abstention message when LLM cannot ground answer */
74
- const ABSTENTION_MESSAGE =
75
- "I don't have enough information in the provided sources to answer this question.";
76
-
77
- // Max characters per snippet to avoid blowing up prompt size
78
- const MAX_SNIPPET_CHARS = 1500;
79
- // Max number of sources to include in context
80
- const MAX_CONTEXT_SOURCES = 5;
81
-
82
- /**
83
- * Extract VALID citation numbers from answer text.
84
- * Only returns numbers in range [1, maxCitation].
85
- * @param answer Answer text to parse
86
- * @param maxCitation Maximum valid citation number
87
- * @returns Sorted unique valid citation numbers (1-indexed)
88
- */
89
- function extractValidCitationNumbers(
90
- answer: string,
91
- maxCitation: number
92
- ): number[] {
93
- const nums = new Set<number>();
94
- // Use fresh regex to avoid lastIndex issues
95
- const re = /\[(\d+)\]/g;
96
- const matches = answer.matchAll(re);
97
- for (const match of matches) {
98
- const n = Number(match[1]);
99
- // Only accept valid citation numbers in range [1, maxCitation]
100
- if (Number.isInteger(n) && n >= 1 && n <= maxCitation) {
101
- nums.add(n);
102
- }
103
- }
104
- return [...nums].sort((a, b) => a - b);
105
- }
106
-
107
- /**
108
- * Filter citations to only those actually referenced in the answer.
109
- * @param citations All citations provided to LLM
110
- * @param validUsedNumbers Valid 1-indexed citation numbers from answer
111
- */
112
- function filterCitationsByUse(
113
- citations: Citation[],
114
- validUsedNumbers: number[]
115
- ): Citation[] {
116
- const usedSet = new Set(validUsedNumbers);
117
- return citations.filter((_, idx) => usedSet.has(idx + 1));
118
- }
119
-
120
- /**
121
- * Renumber citations in answer text to match filtered citations.
122
- * E.g., if answer uses [2] and [5], renumber to [1] and [2].
123
- * Invalid citations (not in validUsedNumbers) are removed.
124
- */
125
- function renumberAnswerCitations(
126
- answer: string,
127
- validUsedNumbers: number[]
128
- ): string {
129
- // Build mapping: old number -> new number (1-indexed)
130
- const mapping = new Map<number, number>();
131
- for (let i = 0; i < validUsedNumbers.length; i++) {
132
- const oldNum = validUsedNumbers[i];
133
- if (oldNum !== undefined) {
134
- mapping.set(oldNum, i + 1);
135
- }
136
- }
137
-
138
- // Use fresh regex to avoid lastIndex issues
139
- const re = /\[(\d+)\]/g;
140
- // Replace valid [n] with renumbered [m], remove invalid citations
141
- const replaced = answer.replace(re, (_match, numStr: string) => {
142
- const oldNum = Number(numStr);
143
- const newNum = mapping.get(oldNum);
144
- // If not in mapping, remove the citation entirely
145
- return newNum !== undefined ? `[${newNum}]` : '';
146
- });
147
-
148
- // Clean up whitespace artifacts from removed citations
149
- // e.g., "See [99] for" → "See for" → "See for"
150
- return replaced.replace(/ {2,}/g, ' ').trim();
151
- }
152
-
153
- async function generateGroundedAnswer(
154
- genPort: GenerationPort,
155
- query: string,
156
- results: SearchResult[],
157
- maxTokens: number
158
- ): Promise<{ answer: string; citations: Citation[] } | null> {
159
- // Build context from top results with bounded snippet sizes
160
- const contextParts: string[] = [];
161
- const citations: Citation[] = [];
162
-
163
- // Track citation index separately to ensure it matches context blocks exactly
164
- let citationIndex = 0;
165
-
166
- for (const r of results.slice(0, MAX_CONTEXT_SOURCES)) {
167
- // Skip results with empty snippets
168
- if (!r.snippet || r.snippet.trim().length === 0) {
169
- continue;
170
- }
171
-
172
- // Cap snippet length to avoid prompt blowup
173
- const snippet =
174
- r.snippet.length > MAX_SNIPPET_CHARS
175
- ? `${r.snippet.slice(0, MAX_SNIPPET_CHARS)}...`
176
- : r.snippet;
177
-
178
- citationIndex += 1;
179
- contextParts.push(`[${citationIndex}] ${snippet}`);
180
- citations.push({
181
- docid: r.docid,
182
- uri: r.uri,
183
- startLine: r.snippetRange?.startLine,
184
- endLine: r.snippetRange?.endLine,
185
- });
186
- }
187
-
188
- // If no valid context, can't generate answer
189
- if (contextParts.length === 0) {
190
- return null;
191
- }
192
-
193
- const prompt = ANSWER_PROMPT.replace('{query}', query).replace(
194
- '{context}',
195
- contextParts.join('\n\n')
196
- );
197
-
198
- const result = await genPort.generate(prompt, {
199
- temperature: 0,
200
- maxTokens,
201
- });
202
-
203
- if (!result.ok) {
204
- return null;
205
- }
206
-
207
- return { answer: result.value, citations };
208
- }
209
-
210
52
  // ─────────────────────────────────────────────────────────────────────────────
211
53
  // Command Implementation
212
54
  // ─────────────────────────────────────────────────────────────────────────────
@@ -327,7 +169,7 @@ export async function ask(
327
169
 
328
170
  if (shouldGenerateAnswer && genPort) {
329
171
  const maxTokens = options.maxAnswerTokens ?? 512;
330
- const answerResult = await generateGroundedAnswer(
172
+ const rawResult = await generateGroundedAnswer(
331
173
  genPort,
332
174
  query,
333
175
  results,
@@ -335,7 +177,7 @@ export async function ask(
335
177
  );
336
178
 
337
179
  // Fail loudly if generation was requested but failed
338
- if (!answerResult) {
180
+ if (!rawResult) {
339
181
  return {
340
182
  success: false,
341
183
  error:
@@ -343,27 +185,10 @@ export async function ask(
343
185
  };
344
186
  }
345
187
 
346
- // Extract only VALID citation numbers (in range 1..citations.length)
347
- const maxCitation = answerResult.citations.length;
348
- const validUsedNums = extractValidCitationNumbers(
349
- answerResult.answer,
350
- maxCitation
351
- );
352
- const filteredCitations = filterCitationsByUse(
353
- answerResult.citations,
354
- validUsedNums
355
- );
356
-
357
- // Abstention guard: if no valid citations, LLM didn't ground the answer
358
- if (validUsedNums.length === 0 || filteredCitations.length === 0) {
359
- answer = ABSTENTION_MESSAGE;
360
- citations = [];
361
- } else {
362
- // Renumber citations in answer to match filtered list (e.g., [2],[5] -> [1],[2])
363
- // Invalid citations are removed from the answer text
364
- answer = renumberAnswerCitations(answerResult.answer, validUsedNums);
365
- citations = filteredCitations;
366
- }
188
+ // Process answer: extract valid citations, filter, renumber
189
+ const processed = processAnswerResult(rawResult);
190
+ answer = processed.answer;
191
+ citations = processed.citations;
367
192
  answerGenerated = true;
368
193
  }
369
194
 
@@ -18,6 +18,8 @@ import type { DownloadProgress, ModelType } from '../../../llm/types';
18
18
  export interface ModelsPullOptions {
19
19
  /** Override config path */
20
20
  configPath?: string;
21
+ /** Override config object (takes precedence over configPath) */
22
+ config?: import('../../../config/types').Config;
21
23
  /** Pull all models */
22
24
  all?: boolean;
23
25
  /** Pull embedding model */
@@ -81,10 +83,13 @@ function getTypesToPull(options: ModelsPullOptions): ModelType[] {
81
83
  export async function modelsPull(
82
84
  options: ModelsPullOptions = {}
83
85
  ): Promise<ModelsPullResult> {
84
- // Load config (use defaults if not initialized)
85
- const { createDefaultConfig } = await import('../../../config');
86
- const configResult = await loadConfig(options.configPath);
87
- const config = configResult.ok ? configResult.value : createDefaultConfig();
86
+ // Use provided config, or load from disk (use defaults if not initialized)
87
+ let config = options.config;
88
+ if (!config) {
89
+ const { createDefaultConfig } = await import('../../../config');
90
+ const configResult = await loadConfig(options.configPath);
91
+ config = configResult.ok ? configResult.value : createDefaultConfig();
92
+ }
88
93
 
89
94
  const preset = getActivePreset(config);
90
95
  const cache = new ModelCache(getModelsCachePath());
@@ -0,0 +1,19 @@
1
+ /**
2
+ * gno serve command implementation.
3
+ * Start web UI server.
4
+ *
5
+ * @module src/cli/commands/serve
6
+ */
7
+
8
+ export type { ServeOptions, ServeResult } from '../../serve';
9
+
10
+ /**
11
+ * Execute gno serve command.
12
+ * Server runs until SIGINT/SIGTERM.
13
+ */
14
+ export async function serve(
15
+ options: import('../../serve').ServeOptions = {}
16
+ ): Promise<import('../../serve').ServeResult> {
17
+ const { startServer } = await import('../../serve');
18
+ return startServer(options);
19
+ }
@@ -149,6 +149,7 @@ export function createProgram(): Command {
149
149
  wireRetrievalCommands(program);
150
150
  wireMcpCommand(program);
151
151
  wireSkillCommands(program);
152
+ wireServeCommand(program);
152
153
 
153
154
  // Add docs/support links to help footer
154
155
  program.addHelpText(
@@ -1328,3 +1329,30 @@ function wireSkillCommands(program: Command): void {
1328
1329
  });
1329
1330
  });
1330
1331
  }
1332
+
1333
+ // ─────────────────────────────────────────────────────────────────────────────
1334
+ // Serve Command (web UI)
1335
+ // ─────────────────────────────────────────────────────────────────────────────
1336
+
1337
+ function wireServeCommand(program: Command): void {
1338
+ program
1339
+ .command('serve')
1340
+ .description('Start web UI server')
1341
+ .option('-p, --port <num>', 'port to listen on', '3000')
1342
+ .action(async (cmdOpts: Record<string, unknown>) => {
1343
+ const globals = getGlobals();
1344
+ const port = parsePositiveInt('port', cmdOpts.port);
1345
+
1346
+ const { serve } = await import('./commands/serve.js');
1347
+ const result = await serve({
1348
+ port,
1349
+ configPath: globals.config,
1350
+ index: globals.index,
1351
+ });
1352
+
1353
+ if (!result.success) {
1354
+ throw new CliError('RUNTIME', result.error ?? 'Server failed to start');
1355
+ }
1356
+ // Server runs until SIGINT/SIGTERM - no output needed here
1357
+ });
1358
+ }
@@ -19,7 +19,9 @@ import type { ModelType } from './types';
19
19
  export function getModelConfig(config: Config): ModelConfig {
20
20
  return {
21
21
  activePreset: config.models?.activePreset ?? 'balanced',
22
- presets: config.models?.presets ?? DEFAULT_MODEL_PRESETS,
22
+ presets: config.models?.presets?.length
23
+ ? config.models.presets
24
+ : DEFAULT_MODEL_PRESETS,
23
25
  loadTimeout: config.models?.loadTimeout ?? 60_000,
24
26
  inferenceTimeout: config.models?.inferenceTimeout ?? 30_000,
25
27
  warmModelTtl: config.models?.warmModelTtl ?? 300_000,
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Grounded answer generation.
3
+ * Shared between CLI ask command and web API.
4
+ *
5
+ * @module src/pipeline/answer
6
+ */
7
+
8
+ import type { GenerationPort } from '../llm/types';
9
+ import type { Citation, SearchResult } from './types';
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Constants
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ const ANSWER_PROMPT = `You are answering a question using ONLY the provided context blocks.
16
+
17
+ Rules you MUST follow:
18
+ 1) Use ONLY facts stated in the context blocks. Do NOT use outside knowledge.
19
+ 2) Every factual statement must include an inline citation like [1] or [2] referring to a context block.
20
+ 3) If the context does not contain enough information to answer, reply EXACTLY:
21
+ "I don't have enough information in the provided sources to answer this question."
22
+ 4) Do not cite sources you did not use. Do not invent citation numbers.
23
+
24
+ Question: {query}
25
+
26
+ Context blocks:
27
+ {context}
28
+
29
+ Write a concise answer (1-3 paragraphs).`;
30
+
31
+ /** Abstention message when LLM cannot ground answer */
32
+ export const ABSTENTION_MESSAGE =
33
+ "I don't have enough information in the provided sources to answer this question.";
34
+
35
+ /** Max characters per snippet to avoid blowing up prompt size */
36
+ const MAX_SNIPPET_CHARS = 1500;
37
+
38
+ /** Max number of sources to include in context */
39
+ const MAX_CONTEXT_SOURCES = 5;
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Citation Processing
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Extract VALID citation numbers from answer text.
47
+ * Only returns numbers in range [1, maxCitation].
48
+ */
49
+ export function extractValidCitationNumbers(
50
+ answer: string,
51
+ maxCitation: number
52
+ ): number[] {
53
+ const nums = new Set<number>();
54
+ const re = /\[(\d+)\]/g;
55
+ const matches = answer.matchAll(re);
56
+ for (const match of matches) {
57
+ const n = Number(match[1]);
58
+ if (Number.isInteger(n) && n >= 1 && n <= maxCitation) {
59
+ nums.add(n);
60
+ }
61
+ }
62
+ return [...nums].sort((a, b) => a - b);
63
+ }
64
+
65
+ /**
66
+ * Filter citations to only those actually referenced in the answer.
67
+ */
68
+ export function filterCitationsByUse(
69
+ citations: Citation[],
70
+ validUsedNumbers: number[]
71
+ ): Citation[] {
72
+ const usedSet = new Set(validUsedNumbers);
73
+ return citations.filter((_, idx) => usedSet.has(idx + 1));
74
+ }
75
+
76
+ /**
77
+ * Renumber citations in answer text to match filtered citations.
78
+ * E.g., if answer uses [2] and [5], renumber to [1] and [2].
79
+ * Invalid citations (not in validUsedNumbers) are removed.
80
+ */
81
+ export function renumberAnswerCitations(
82
+ answer: string,
83
+ validUsedNumbers: number[]
84
+ ): string {
85
+ const mapping = new Map<number, number>();
86
+ for (let i = 0; i < validUsedNumbers.length; i++) {
87
+ const oldNum = validUsedNumbers[i];
88
+ if (oldNum !== undefined) {
89
+ mapping.set(oldNum, i + 1);
90
+ }
91
+ }
92
+
93
+ const re = /\[(\d+)\]/g;
94
+ const replaced = answer.replace(re, (_match, numStr: string) => {
95
+ const oldNum = Number(numStr);
96
+ const newNum = mapping.get(oldNum);
97
+ return newNum !== undefined ? `[${newNum}]` : '';
98
+ });
99
+
100
+ return replaced.replace(/ {2,}/g, ' ').trim();
101
+ }
102
+
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+ // Answer Generation
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+
107
+ export interface AnswerGenerationResult {
108
+ answer: string;
109
+ citations: Citation[];
110
+ }
111
+
112
+ /**
113
+ * Generate a grounded answer from search results.
114
+ * Returns null if no valid context or generation fails.
115
+ */
116
+ export async function generateGroundedAnswer(
117
+ genPort: GenerationPort,
118
+ query: string,
119
+ results: SearchResult[],
120
+ maxTokens: number
121
+ ): Promise<AnswerGenerationResult | null> {
122
+ const contextParts: string[] = [];
123
+ const citations: Citation[] = [];
124
+ let citationIndex = 0;
125
+
126
+ for (const r of results.slice(0, MAX_CONTEXT_SOURCES)) {
127
+ if (!r.snippet || r.snippet.trim().length === 0) {
128
+ continue;
129
+ }
130
+
131
+ const snippet =
132
+ r.snippet.length > MAX_SNIPPET_CHARS
133
+ ? `${r.snippet.slice(0, MAX_SNIPPET_CHARS)}...`
134
+ : r.snippet;
135
+
136
+ citationIndex += 1;
137
+ contextParts.push(`[${citationIndex}] ${snippet}`);
138
+ citations.push({
139
+ docid: r.docid,
140
+ uri: r.uri,
141
+ startLine: r.snippetRange?.startLine,
142
+ endLine: r.snippetRange?.endLine,
143
+ });
144
+ }
145
+
146
+ if (contextParts.length === 0) {
147
+ return null;
148
+ }
149
+
150
+ const prompt = ANSWER_PROMPT.replace('{query}', query).replace(
151
+ '{context}',
152
+ contextParts.join('\n\n')
153
+ );
154
+
155
+ const result = await genPort.generate(prompt, {
156
+ temperature: 0,
157
+ maxTokens,
158
+ });
159
+
160
+ if (!result.ok) {
161
+ return null;
162
+ }
163
+
164
+ return { answer: result.value, citations };
165
+ }
166
+
167
+ /**
168
+ * Process raw answer result into final answer with cleaned citations.
169
+ * Extracts valid citations, filters unused ones, and renumbers.
170
+ */
171
+ export function processAnswerResult(rawResult: AnswerGenerationResult): {
172
+ answer: string;
173
+ citations: Citation[];
174
+ } {
175
+ const maxCitation = rawResult.citations.length;
176
+ const validUsedNums = extractValidCitationNumbers(
177
+ rawResult.answer,
178
+ maxCitation
179
+ );
180
+ const filteredCitations = filterCitationsByUse(
181
+ rawResult.citations,
182
+ validUsedNums
183
+ );
184
+
185
+ if (validUsedNums.length === 0 || filteredCitations.length === 0) {
186
+ return { answer: ABSTENTION_MESSAGE, citations: [] };
187
+ }
188
+
189
+ const answer = renumberAnswerCitations(rawResult.answer, validUsedNums);
190
+ return { answer, citations: filteredCitations };
191
+ }