Merge branch 'feat/phase-3-rag-foundation'
This commit is contained in:
commit
a493d11c76
18 changed files with 1531 additions and 27 deletions
119
README.md
119
README.md
|
|
@ -15,7 +15,7 @@ The project emphasizes measurable behavior, reproducible development, automated
|
|||
- Automate testing and deployment.
|
||||
- Document architectural decisions and operational procedures.
|
||||
|
||||
These goals will be implemented incrementally.
|
||||
These goals are implemented incrementally.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
|
|
@ -25,13 +25,15 @@ These goals will be implemented incrementally.
|
|||
- **Language:** TypeScript
|
||||
- **API framework:** Fastify
|
||||
- **Request validation:** Zod
|
||||
- **Local inference:** Ollama
|
||||
- **Local inference and embeddings:** Ollama
|
||||
- **Generation model:** `qwen3:8b`
|
||||
- **Embedding model:** `nomic-embed-text`
|
||||
- **Database and vector search:** PostgreSQL with pgvector
|
||||
- **Testing:** Node.js test runner with `tsx`
|
||||
|
||||
**Planned**
|
||||
|
||||
- **Database and retrieval:** PostgreSQL with pgvector
|
||||
- **Containers:** Docker and Docker Compose
|
||||
- **Containers:** Docker and Docker Compose production configuration
|
||||
- **Observability:** OpenTelemetry and compatible monitoring tools
|
||||
- **CI/CD:** Automated build, test, and deployment workflows
|
||||
- **Evaluation:** Performance benchmarks and retrieval-quality tests
|
||||
|
|
@ -40,20 +42,52 @@ Technologies will be introduced as project requirements justify them.
|
|||
|
||||
## Current Status
|
||||
|
||||
**Status:** Initial API foundation and local inference integration implemented.
|
||||
**Status:** API foundation, local inference, document ingestion, semantic retrieval, and RAG question answering implemented.
|
||||
|
||||
The API currently provides:
|
||||
### API Endpoints
|
||||
|
||||
- `GET /health` — reports API health.
|
||||
- `GET /ready` — checks whether Ollama is available.
|
||||
- `POST /echo` — demonstrates request validation and API behavior.
|
||||
- `POST /generate` — sends a prompt to a local Ollama model and returns generated text.
|
||||
- Centralized error handling and consistent responses for unknown routes.
|
||||
- Automated tests for API behavior, validation, inference responses, and failure handling.
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /health` | Reports API health. |
|
||||
| `GET /ready` | Checks whether Ollama is available. |
|
||||
| `POST /echo` | Demonstrates request validation and API behavior. |
|
||||
| `POST /generate` | Sends a prompt to a local Ollama model and returns generated text. |
|
||||
| `POST /documents` | Ingests a document, splits it into chunks, generates embeddings, and persists the document and chunks. |
|
||||
| `POST /search` | Retrieves relevant document chunks using semantic similarity search. |
|
||||
| `POST /ask` | Retrieves relevant context, generates an answer using Ollama, and returns source attribution. |
|
||||
|
||||
The inference client currently uses the `qwen3:8b` model and connects to Ollama at `http://127.0.0.1:11434`.
|
||||
The API includes centralized error handling, input validation, and consistent responses for unknown routes.
|
||||
|
||||
**Not yet implemented:** RAG, PostgreSQL persistence, authentication, production deployment, metrics, distributed tracing, and CI/CD.
|
||||
### Retrieval-Augmented Generation
|
||||
|
||||
The implemented RAG pipeline:
|
||||
|
||||
1. Accepts a document for ingestion.
|
||||
2. Splits document content into overlapping chunks.
|
||||
3. Generates embeddings using Ollama.
|
||||
4. Stores document metadata, chunks, and embeddings in PostgreSQL with pgvector.
|
||||
5. Embeds incoming search queries and retrieves similar chunks.
|
||||
6. Filters retrieved results using a provisional cosine-similarity threshold of `0.5`.
|
||||
7. Passes relevant context to the generation model.
|
||||
8. Returns an answer and its supporting source information.
|
||||
|
||||
The answer-generation service returns a fallback response when no retrieved results meet the similarity threshold. Responses include source document IDs, titles, chunk content, and similarity scores.
|
||||
|
||||
The similarity threshold is an initial heuristic, not a validated universal cutoff. Retrieval quality and answer grounding require further evaluation.
|
||||
|
||||
### Current Limitations
|
||||
|
||||
The following capabilities remain future work:
|
||||
|
||||
- Authentication and authorization
|
||||
- Production deployment
|
||||
- Metrics and distributed tracing
|
||||
- CI/CD
|
||||
- Systematic retrieval and answer-quality evaluation
|
||||
- Performance benchmarking
|
||||
- Production-grade timeout, retry, and recovery policies
|
||||
|
||||
The current implementation is a local development foundation, not a production-ready public service.
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
@ -61,7 +95,8 @@ The inference client currently uses the `qwen3:8b` model and connects to Ollama
|
|||
|
||||
- Node.js and npm
|
||||
- Git
|
||||
- [Ollama](https://ollama.com/) for local inference
|
||||
- [Ollama](https://ollama.com/)
|
||||
- Docker and Docker Compose for the PostgreSQL development environment
|
||||
|
||||
### Installation
|
||||
|
||||
|
|
@ -78,12 +113,17 @@ Install dependencies:
|
|||
npm ci
|
||||
```
|
||||
|
||||
Ensure Ollama is running and the configured model is available:
|
||||
Ensure Ollama is running and the required models are available:
|
||||
|
||||
```bash
|
||||
ollama pull qwen3:8b
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
Start the PostgreSQL development database using the repository's Docker Compose configuration. Configure the required database connection and password in your local `.env` file, following the configuration expected by the application.
|
||||
|
||||
Do not commit `.env` or other files containing secrets.
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server:
|
||||
|
|
@ -114,6 +154,36 @@ curl -X POST http://127.0.0.1:3000/generate \
|
|||
-d '{"message":"Explain what a REST API does in one sentence."}'
|
||||
```
|
||||
|
||||
Ingest a document:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3000/documents \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "RAG Basics",
|
||||
"source": "manual-test",
|
||||
"content": "Retrieval augmented generation retrieves relevant information from a knowledge base and uses it to help generate a response."
|
||||
}'
|
||||
```
|
||||
|
||||
Search the document collection:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3000/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"How does retrieval augmented generation use a knowledge base?"}'
|
||||
```
|
||||
|
||||
Ask a question using retrieved context:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3000/ask \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"question":"How does retrieval augmented generation use a knowledge base?"}'
|
||||
```
|
||||
|
||||
The `/ask` response contains an `answer` and a `sources` array. If no retrieved chunk meets the current relevance threshold, the service returns its fallback answer with an empty sources array.
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
|
|
@ -125,24 +195,29 @@ curl -X POST http://127.0.0.1:3000/generate \
|
|||
|
||||
## Architecture
|
||||
|
||||
The current architecture consists of a TypeScript/Fastify HTTP API and a local Ollama inference service.
|
||||
The current architecture consists of a TypeScript/Fastify API, a local Ollama inference service, and a PostgreSQL database with pgvector.
|
||||
|
||||
The API validates incoming requests, delegates generation to Ollama, and returns generated text. Readiness checks verify the inference service is reachable, while error handling prevents internal exception details from being returned in API responses.
|
||||
The API validates incoming requests and delegates inference, ingestion, retrieval, and answer generation to separate application modules. Document ingestion persists embeddings for later retrieval. The answering service uses retrieved context to construct a prompt and returns source attribution with the generated answer.
|
||||
|
||||
Future iterations will introduce document ingestion, retrieval, persistence, security controls, observability, and deployment automation.
|
||||
The application uses dependency injection to test API routes and answer-generation behavior without requiring live inference or database dependencies in every unit test.
|
||||
|
||||
Future iterations will focus on deployment, security controls, operational visibility, evaluation, and reliability.
|
||||
|
||||
Architecture diagrams and significant design decisions will be maintained alongside the code.
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
The automated test suite currently covers:
|
||||
The automated test suite covers:
|
||||
|
||||
- API health and readiness behavior
|
||||
- Request validation and input limits
|
||||
- Successful inference responses
|
||||
- Inference failure handling
|
||||
- Document ingestion and search routes
|
||||
- Answer generation and source attribution
|
||||
- Empty retrieval and low-similarity filtering
|
||||
- Dependency failure handling
|
||||
- Unknown routes and generic server errors
|
||||
- Ollama client response handling
|
||||
|
||||
Run the test suite:
|
||||
|
||||
|
|
@ -156,7 +231,7 @@ Verify the production build:
|
|||
npm run build
|
||||
```
|
||||
|
||||
Performance benchmarks, retrieval evaluations, security regression tests, and recovery tests remain planned work.
|
||||
The current implementation has been manually exercised end to end with relevant and unrelated questions. Further work will add a reproducible evaluation dataset, retrieval-quality measurements, performance benchmarks, security regression tests, and recovery tests.
|
||||
|
||||
Performance and quality claims will be backed by reproducible measurements.
|
||||
|
||||
|
|
|
|||
19
compose.yaml
Normal file
19
compose.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_DB: local_ai
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app -d local_ai"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
23
db/migrations/001_create_documents.sql
Normal file
23
db/migrations/001_create_documents.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
CREATE TABLE documents (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE document_chunks (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL CHECK (chunk_index >= 0),
|
||||
content TEXT NOT NULL,
|
||||
embedding vector(768) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (document_id, chunk_index)
|
||||
);
|
||||
|
||||
CREATE INDEX document_chunks_document_id_idx
|
||||
ON document_chunks (document_id);
|
||||
3
db/migrations/002_add-document-source-metadata.sql
Normal file
3
db/migrations/002_add-document-source-metadata.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- Up Migration
|
||||
|
||||
-- Down Migration
|
||||
436
package-lock.json
generated
436
package-lock.json
generated
|
|
@ -10,10 +10,13 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fastify": "^5.12.7",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
"pg": "^8.23.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/pg": "^8.23.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
|
|
@ -597,12 +600,24 @@
|
|||
"version": "26.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
|
||||
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
|
|
@ -998,6 +1013,30 @@
|
|||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
|
||||
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
|
|
@ -1027,6 +1066,41 @@
|
|||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
|
|
@ -1049,6 +1123,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
|
|
@ -1091,6 +1171,15 @@
|
|||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||
|
|
@ -1223,6 +1312,44 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
|
||||
|
|
@ -1232,6 +1359,15 @@
|
|||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz",
|
||||
|
|
@ -1279,6 +1415,65 @@
|
|||
"set-cookie-parser": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pg-migrate": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-9.0.0.tgz",
|
||||
"integrity": "sha512-lp5+UZx1KKgOz/y0h6BYGPuJ5wVlZTgiBPGXCGoX6Bs6X9LtiIhrI4V2yW4mPwnJBfptVq0KtLQtXHcoEKOyig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glob": "~13.0.0",
|
||||
"jiti": "~2.7.0",
|
||||
"yargs": "~18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-pg-migrate": "bin/node-pg-migrate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/pg": ">=6.0.0 <9.0.0",
|
||||
"pg": ">=4.3.0 <9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/pg": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
|
|
@ -1288,6 +1483,111 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.1",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.1",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz",
|
||||
|
|
@ -1325,6 +1625,45 @@
|
|||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
|
||||
|
|
@ -1470,6 +1809,38 @@
|
|||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
|
|
@ -1549,9 +1920,70 @@
|
|||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "tsx --test src/**/*.test.ts",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"dev": "node --env-file=.env --import tsx/esm --watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js"
|
||||
"start": "node --env-file=.env dist/server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
@ -15,10 +15,13 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"fastify": "^5.12.7",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
"pg": "^8.23.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/pg": "^8.23.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
|
|
|
|||
161
src/answer.test.ts
Normal file
161
src/answer.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { answerQuestion } from "./answer.js";
|
||||
import type { SearchResult } from "./search.js";
|
||||
import { generateColumnString } from "node-pg-migrate/operations/indexes/shared";
|
||||
|
||||
const sampleResults: SearchResult[] = [
|
||||
{
|
||||
documentId: "1",
|
||||
title: "RAG Basics",
|
||||
content:
|
||||
"Retrieval augmented generation uses retrieved information to help generated a response.",
|
||||
similarity: 0.88,
|
||||
chunkIndex: 0,
|
||||
},
|
||||
];
|
||||
|
||||
describe("answerQuestion", () => {
|
||||
it("rejects an empty question", async () => {
|
||||
await assert.rejects(answerQuestion(" "), {
|
||||
message: "Question cannot be empty",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a fallback answer when no relevant documents are found", async () => {
|
||||
let generatorCalled = false;
|
||||
|
||||
const result = await answerQuestion("What is RAG?", {
|
||||
search: async () => [],
|
||||
generate: async () => {
|
||||
generatorCalled = true;
|
||||
return "This should not be called.";
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.answer,
|
||||
"I couldn't find relevant information in the available documents.",
|
||||
);
|
||||
assert.deepEqual(result.sources, []);
|
||||
assert.equal(generatorCalled, false);
|
||||
});
|
||||
|
||||
it("generates and answer using retrieved context and returns its sources", async () => {
|
||||
let receivedQuery = "";
|
||||
let receivedLimit = 0;
|
||||
let receivedPrompt = "";
|
||||
|
||||
const result = await answerQuestion(" What is RAG? ", {
|
||||
search: async (query, limit) => {
|
||||
((receivedQuery = query), (receivedLimit = limit ?? 0));
|
||||
return sampleResults;
|
||||
},
|
||||
generate: async (prompt) => {
|
||||
receivedPrompt = prompt;
|
||||
return "RAG uses retrieved information to generate a response.";
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(receivedQuery, "What is RAG?");
|
||||
assert.equal(receivedLimit, 5);
|
||||
assert.match(receivedPrompt, /RAG Basics/);
|
||||
assert.match(
|
||||
receivedPrompt,
|
||||
/Retrieval augmented generation uses retrieved information/,
|
||||
);
|
||||
assert.match(receivedPrompt, /What is RAG\?/);
|
||||
|
||||
assert.equal(
|
||||
result.answer,
|
||||
"RAG uses retrieved information to generate a response.",
|
||||
);
|
||||
assert.deepEqual(result.sources, [
|
||||
{
|
||||
documentId: "1",
|
||||
title: "RAG Basics",
|
||||
content: sampleResults[0].content,
|
||||
similarity: 0.88,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("propagates search errors", async () => {
|
||||
await assert.rejects(
|
||||
answerQuestion("What is RAG?", {
|
||||
search: async () => {
|
||||
throw new Error("Search failed");
|
||||
},
|
||||
generate: async () => "Should not be called.",
|
||||
}),
|
||||
{ message: "Search failed" },
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates generation errors", async () => {
|
||||
await assert.rejects(
|
||||
answerQuestion("What is RAG?", {
|
||||
search: async () => sampleResults,
|
||||
generate: async () => {
|
||||
throw new Error("Generation failed");
|
||||
},
|
||||
}),
|
||||
{ message: "Generation failed" },
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes results below the similarity threshold", async () => {
|
||||
const result = await answerQuestion("What is the capital of France?", {
|
||||
search: async () => [
|
||||
{
|
||||
documentId: "1",
|
||||
chunkIndex: 0,
|
||||
title: "RAG Basics",
|
||||
content: "Retrieval augmented generation uses retrieved information.",
|
||||
similarity: 0.308,
|
||||
},
|
||||
],
|
||||
generate: async () => {
|
||||
assert.fail("Generator should not be called for irrelevant results");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.answer,
|
||||
"I couldn't find relevant information in the available documents.",
|
||||
);
|
||||
assert.deepEqual(result.sources, []);
|
||||
});
|
||||
|
||||
it("uses only relevant results for context and source attribution", async () => {
|
||||
let receivedPrompt = "";
|
||||
|
||||
const result = await answerQuestion("How does RAG work?", {
|
||||
search: async () => [
|
||||
{
|
||||
documentId: "1",
|
||||
chunkIndex: 0,
|
||||
title: "Relevant document",
|
||||
content: "RAG retrieves information to help generate responses.",
|
||||
similarity: 0.88,
|
||||
},
|
||||
{
|
||||
documentId: "2",
|
||||
chunkIndex: 0,
|
||||
title: "Irrelevant document",
|
||||
content: "Paris is the capital of France.",
|
||||
similarity: 0.308,
|
||||
},
|
||||
],
|
||||
generate: async (prompt) => {
|
||||
receivedPrompt = prompt;
|
||||
return "RAG retrieves information to help generate responses.";
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(receivedPrompt, /Relevant document/);
|
||||
assert.doesNotMatch(receivedPrompt, /Irrelevant document/);
|
||||
assert.equal(result.sources.length, 1);
|
||||
assert.equal(result.sources[0].documentId, "1");
|
||||
});
|
||||
});
|
||||
74
src/answer.ts
Normal file
74
src/answer.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { generateResponse } from "./ollama.js";
|
||||
import { searchDocuments, type SearchResult } from "./search.js";
|
||||
|
||||
export interface AnswerResult {
|
||||
answer: string;
|
||||
sources: {
|
||||
documentId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
similarity: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface AnswerDependencies {
|
||||
search: (query: string, limit?: number) => Promise<SearchResult[]>;
|
||||
generate: (prompt: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const defaultDependencies: AnswerDependencies = {
|
||||
search: searchDocuments,
|
||||
generate: generateResponse,
|
||||
};
|
||||
|
||||
export const answerQuestion = async (
|
||||
question: string,
|
||||
dependencies: AnswerDependencies = defaultDependencies,
|
||||
): Promise<AnswerResult> => {
|
||||
const normalizedQuestion = question.trim();
|
||||
|
||||
if (!normalizedQuestion) {
|
||||
throw new Error("Question cannot be empty");
|
||||
}
|
||||
|
||||
const results = await dependencies.search(normalizedQuestion, 5);
|
||||
const relevantResults = results.filter((result) => result.similarity >= 0.5);
|
||||
|
||||
if (relevantResults.length === 0) {
|
||||
return {
|
||||
answer:
|
||||
"I couldn't find relevant information in the available documents.",
|
||||
sources: [],
|
||||
};
|
||||
}
|
||||
|
||||
const context = relevantResults
|
||||
.map(
|
||||
(result, index) =>
|
||||
`[Source ${index + 1}: ${result.title}]\n${result.content}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
const prompt = `Answer the question using only the context provided below.
|
||||
If the context does not contain the answer, say that you don't have enough information.
|
||||
Do not invent facts or claim that the context supports something it does not.
|
||||
|
||||
Context:
|
||||
${context}
|
||||
|
||||
Question: ${normalizedQuestion}
|
||||
|
||||
Answer:`;
|
||||
|
||||
const answer = await dependencies.generate(prompt);
|
||||
|
||||
return {
|
||||
answer,
|
||||
sources: relevantResults.map((result) => ({
|
||||
documentId: result.documentId,
|
||||
title: result.title,
|
||||
content: result.content,
|
||||
similarity: result.similarity,
|
||||
})),
|
||||
};
|
||||
};
|
||||
48
src/chunking.test.ts
Normal file
48
src/chunking.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { chunkText } from "./chunking.js";
|
||||
|
||||
describe("chunkText", () => {
|
||||
it("returns no chunks for empty or whitespace-only text", () => {
|
||||
assert.deepEqual(chunkText(""), []);
|
||||
assert.deepEqual(chunkText(" \n\t "), []);
|
||||
});
|
||||
|
||||
it("returns one chunk when text fits within the chunk size", () => {
|
||||
assert.deepEqual(chunkText("Hello world"), [
|
||||
{ chunkIndex: 0, content: "Hello world" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits text into chunks with the configured overlap", () => {
|
||||
const chunks = chunkText("abcdefghijkl", {
|
||||
chunkSize: 6,
|
||||
overlap: 2,
|
||||
});
|
||||
|
||||
assert.deepEqual(chunks, [
|
||||
{ chunkIndex: 0, content: "abcdef" },
|
||||
{ chunkIndex: 1, content: "efghij" },
|
||||
{ chunkIndex: 2, content: "ijkl" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns sequential chunk indices", () => {
|
||||
const chunks = chunkText("abcdefghijkl", {
|
||||
chunkSize: 4,
|
||||
overlap: 1,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
chunks.map((chunk) => chunk.chunkIndex),
|
||||
[0, 1, 2, 3],
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid chunk sizes", () => {
|
||||
assert.throws(() => chunkText("text", { chunkSize: 0 }));
|
||||
assert.throws(() => chunkText("text", { chunkSize: -1 }));
|
||||
assert.throws(() => chunkText("text", { chunkSize: 1.5 }));
|
||||
});
|
||||
});
|
||||
54
src/chunking.ts
Normal file
54
src/chunking.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { error } from "node:console";
|
||||
|
||||
export interface TextChunk {
|
||||
chunkIndex: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ChunkingOptions {
|
||||
chunkSize?: number;
|
||||
overlap?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 1_000;
|
||||
const DEFAULT_OVERLAP = 200;
|
||||
|
||||
export const chunkText = (
|
||||
text: string,
|
||||
options: ChunkingOptions = {},
|
||||
): TextChunk[] => {
|
||||
const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
||||
const overlap = options.overlap ?? DEFAULT_OVERLAP;
|
||||
|
||||
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
|
||||
throw new Error("Chunk size must be a positive integer");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(overlap) || overlap < 0 || overlap >= chunkSize) {
|
||||
throw new Error(
|
||||
"Overlap must be a non-negative integer smaller than chunk size",
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedText = text.trim();
|
||||
|
||||
if (!normalizedText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunks: TextChunk[] = [];
|
||||
const step = chunkSize - overlap;
|
||||
|
||||
for (let start = 0; start < normalizedText.length; start += step) {
|
||||
const content = normalizedText.slice(start, start + chunkSize).trim();
|
||||
|
||||
if (content) {
|
||||
chunks.push({
|
||||
chunkIndex: chunks.length,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
};
|
||||
14
src/db.ts
Normal file
14
src/db.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
idleTimeoutMillis: 30_000,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
pool.on("error", (error) => {
|
||||
console.error("Unexpected PostgreSQL pool error:", error);
|
||||
});
|
||||
74
src/document-repository.ts
Normal file
74
src/document-repository.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { pool } from "./db.js";
|
||||
import type { TextChunk } from "./chunking.js";
|
||||
|
||||
export interface NewDocument {
|
||||
title: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface StoredChunk extends TextChunk {
|
||||
embedding: number[];
|
||||
}
|
||||
|
||||
export const createDocument = async (
|
||||
document: NewDocument,
|
||||
): Promise<string> => {
|
||||
const result = await pool.query<{ id: string }>(
|
||||
`INSERT INTO documents (title, source, status)
|
||||
VALUES ($1, $2, 'processing')
|
||||
RETURNING id`,
|
||||
[document.title, document.source ?? null],
|
||||
);
|
||||
|
||||
const id = result.rows[0]?.id;
|
||||
|
||||
if (!id) {
|
||||
throw new Error("Failed to create document");
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
export const storeDocumentChunks = async (
|
||||
documentId: string,
|
||||
chunks: StoredChunk[],
|
||||
): Promise<void> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await client.query(
|
||||
`INSERT INTO document_chunks
|
||||
(document_id, chunk_index, content, embedding)
|
||||
VALUES ($1, $2, $3, $4::vector)`,
|
||||
[
|
||||
documentId,
|
||||
chunk.chunkIndex,
|
||||
chunk.content,
|
||||
`[${chunk.embedding.join(",")}]`,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE documents
|
||||
SET status = 'completed'
|
||||
WHERE id = $1`,
|
||||
[documentId],
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
// Preserve the original error.
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
37
src/ingestion.ts
Normal file
37
src/ingestion.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { chunkText } from "./chunking.js";
|
||||
import { createDocument, storeDocumentChunks } from "./document-repository.js";
|
||||
import type { NewDocument } from "./document-repository.js";
|
||||
import { generateEmbedding } from "./ollama.js";
|
||||
|
||||
export const ingestDocument = async (
|
||||
document: NewDocument & { content: string },
|
||||
): Promise<{ documentId: string; chunkCount: number }> => {
|
||||
const chunks = chunkText(document.content);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
throw new Error("Document content cannot be empty");
|
||||
}
|
||||
|
||||
const documentId = await createDocument({
|
||||
title: document.title,
|
||||
source: document.source,
|
||||
});
|
||||
|
||||
try {
|
||||
const embeddedChunks = await Promise.all(
|
||||
chunks.map(async (chunk) => ({
|
||||
...chunk,
|
||||
embedding: await generateEmbedding(chunk.content),
|
||||
})),
|
||||
);
|
||||
|
||||
await storeDocumentChunks(documentId, embeddedChunks);
|
||||
|
||||
return {
|
||||
documentId,
|
||||
chunkCount: embeddedChunks.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,9 +1,40 @@
|
|||
const OLLAMA_BASE_URL = "http://127.0.0.1:11434";
|
||||
|
||||
interface OllamaEmbeddingResponse {
|
||||
embeddings: number[][];
|
||||
}
|
||||
|
||||
interface OllamaGenerateResponse {
|
||||
response: string;
|
||||
}
|
||||
|
||||
export const generateEmbedding = async (text: string): Promise<number[]> => {
|
||||
const response = await fetch(`${OLLAMA_BASE_URL}/api/embed`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "nomic-embed-text",
|
||||
input: text,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama embedding failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as OllamaEmbeddingResponse;
|
||||
const embedding = result.embeddings[0];
|
||||
|
||||
if (!embedding || embedding.length !== 768) {
|
||||
throw new Error("Ollama returned an invalid embedding");
|
||||
}
|
||||
|
||||
return embedding;
|
||||
};
|
||||
|
||||
export const generateResponse = async (prompt: string): Promise<string> => {
|
||||
const response = await fetch(`${OLLAMA_BASE_URL}/api/generate`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
59
src/search.test.ts
Normal file
59
src/search.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { searchDocuments } from "./search.js";
|
||||
|
||||
describe("searchDocuments", () => {
|
||||
it("rejects an empty query", async () => {
|
||||
await assert.rejects(
|
||||
searchDocuments(" "),
|
||||
/Search query cannot be empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it("embeds the query and returns matching chunks", async () => {
|
||||
let embeddedText = "";
|
||||
let executedSql = "";
|
||||
let queryValues: unknown[] = [];
|
||||
|
||||
const expectedResults = [
|
||||
{
|
||||
documentId: "1",
|
||||
title: "RAG smoke test",
|
||||
content: "Relevant knowledge base content.",
|
||||
chunkIndex: 0,
|
||||
similarity: 0.92,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await searchDocuments(" test query ", 3, {
|
||||
embed: async (text) => {
|
||||
embeddedText = text;
|
||||
return [0.1, 0.2, 0.3];
|
||||
},
|
||||
query: async (sql, values) => {
|
||||
executedSql = sql;
|
||||
queryValues = values;
|
||||
return { rows: expectedResults };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(embeddedText, "test query");
|
||||
assert.match(executedSql, /ORDER BY dc\.embedding <=> \$1::vector/);
|
||||
assert.match(executedSql, /d\.status = 'completed'/);
|
||||
assert.deepEqual(queryValues, ["[0.1,0.2,0.3]", 3]);
|
||||
assert.deepEqual(results, expectedResults);
|
||||
});
|
||||
|
||||
it("propagates embedding errors", async () => {
|
||||
await assert.rejects(
|
||||
searchDocuments("test query", 5, {
|
||||
embed: async () => {
|
||||
throw new Error("Embedding unavailable");
|
||||
},
|
||||
query: async () => ({ rows: [] }),
|
||||
}),
|
||||
/Embedding unavailable/,
|
||||
);
|
||||
});
|
||||
});
|
||||
56
src/search.ts
Normal file
56
src/search.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { pool } from "./db.js";
|
||||
import { generateEmbedding } from "./ollama.js";
|
||||
|
||||
export interface SearchResult {
|
||||
documentId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
chunkIndex: number;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
type SearchDependencies = {
|
||||
embed: (text: string) => Promise<number[]>;
|
||||
query: (sql: string, values: unknown[]) => Promise<{ rows: SearchResult[] }>;
|
||||
};
|
||||
|
||||
const defaultDependencies: SearchDependencies = {
|
||||
embed: generateEmbedding,
|
||||
query: (sql, values) => pool.query<SearchResult>(sql, values),
|
||||
};
|
||||
|
||||
export const searchDocuments = async (
|
||||
query: string,
|
||||
limit = 5,
|
||||
dependencies: SearchDependencies = defaultDependencies,
|
||||
): Promise<SearchResult[]> => {
|
||||
const normalizedQuery = query.trim();
|
||||
|
||||
if (!normalizedQuery) {
|
||||
throw new Error("Search query cannot be empty");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
|
||||
throw new Error("Search limit must be an integer between 1 and 20");
|
||||
}
|
||||
|
||||
const embedding = await dependencies.embed(normalizedQuery);
|
||||
const vector = `[${embedding.join(",")}]`;
|
||||
|
||||
const result = await dependencies.query(
|
||||
`SELECT
|
||||
d.id::text AS "documentId",
|
||||
d.title,
|
||||
dc.content,
|
||||
dc.chunk_index AS "chunkIndex",
|
||||
(1 - (dc.embedding <=> $1::vector))::float8 AS similarity
|
||||
FROM document_chunks dc
|
||||
JOIN documents d ON d.id = dc.document_id
|
||||
WHERE d.status = 'completed'
|
||||
ORDER BY dc.embedding <=> $1::vector
|
||||
LIMIT $2`,
|
||||
[vector, limit],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
|
@ -2,7 +2,8 @@ import assert from "node:assert";
|
|||
import { after, describe, it } from "node:test";
|
||||
|
||||
import { buildApp } from "./server.js";
|
||||
import { setServers } from "node:dns";
|
||||
import type { AnswerResult } from "./answer.js";
|
||||
import { error } from "node:console";
|
||||
|
||||
describe("API endpoints", () => {
|
||||
const app = buildApp();
|
||||
|
|
@ -212,4 +213,259 @@ describe("API endpoints", () => {
|
|||
});
|
||||
assert.equal(response.body.includes("Sensitive internal detail"), false);
|
||||
});
|
||||
|
||||
it("POST /documents ingests a valid document", async () => {
|
||||
const app = buildApp(undefined, undefined, async (document) => {
|
||||
assert.equal(document.title, "Test document");
|
||||
assert.equal(document.source, "unit-test");
|
||||
assert.equal(document.content, "Document content.");
|
||||
return { documentId: "42", chunkCount: 1 };
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/documents",
|
||||
payload: {
|
||||
title: "Test document",
|
||||
source: "unit-test",
|
||||
content: "Document content.",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.deepEqual(response.json(), {
|
||||
documentId: "42",
|
||||
chunkCount: 1,
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST / documents rejects invalid input", async () => {
|
||||
const app = buildApp(undefined, undefined, async () => {
|
||||
throw new Error("Ingestions should not run");
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/documents",
|
||||
payload: {
|
||||
title: " ",
|
||||
content: "Document content.",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: "Invalid document",
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /documents returns 503 when ingestion fails", async () => {
|
||||
const app = buildApp(undefined, undefined, async () => {
|
||||
throw new Error("Database connection details");
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/documents",
|
||||
payload: {
|
||||
title: "Test document",
|
||||
content: "Document content.",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: "Document ingestion failed",
|
||||
});
|
||||
assert.equal(
|
||||
response.body.includes("Database connection details"),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /search returns matching results", async () => {
|
||||
const expectedResults = [
|
||||
{
|
||||
documentId: "1",
|
||||
title: "RAG smoke test",
|
||||
content: "Relevant knowledge base content.",
|
||||
chunkIndex: 0,
|
||||
similarity: 0.92,
|
||||
},
|
||||
];
|
||||
|
||||
const app = buildApp(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
async (query, limit) => {
|
||||
assert.equal(query, "knowledge base search");
|
||||
assert.equal(limit, 3);
|
||||
return expectedResults;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/search",
|
||||
payload: {
|
||||
query: "knowledge base search",
|
||||
limit: 3,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json(), { results: expectedResults });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /search rejects invalid input", async () => {
|
||||
const app = buildApp(undefined, undefined, undefined, async () => {
|
||||
throw new Error("Search should not run");
|
||||
});
|
||||
|
||||
try {
|
||||
for (const payload of [
|
||||
{ query: " " },
|
||||
{ query: "valid query", limit: 0 },
|
||||
{ query: "valid query", limit: 21 },
|
||||
{ query: "valid query", limit: 1.5 },
|
||||
{ query: 123 },
|
||||
]) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/search",
|
||||
payload,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: "Invalid search request",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /search returns 503 when search fails", async () => {
|
||||
const app = buildApp(undefined, undefined, undefined, async () => {
|
||||
throw new Error("Internal database connection details");
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/search",
|
||||
payload: { query: "knowledge base search" },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: "Document search failed",
|
||||
});
|
||||
assert.equal(
|
||||
response.body.includes("Internal database connection details"),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /ask", () => {
|
||||
it("returns an answer with source attribution", async () => {
|
||||
const expected: AnswerResult = {
|
||||
answer: "RAG uses retrieved information to generate a response.",
|
||||
sources: [
|
||||
{
|
||||
documentId: "1",
|
||||
title: "RAG Basics",
|
||||
content: "Retrieved information helps generate a response",
|
||||
similarity: 0.88,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const app = buildApp(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
async (question) => {
|
||||
assert.equal(question, "What is RAG?");
|
||||
return expected;
|
||||
},
|
||||
);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/ask",
|
||||
payload: { question: "What is RAG?" },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json(), expected);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects an invalid question", async () => {
|
||||
const app = buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/ask",
|
||||
payload: { question: " " },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: "Invalid question",
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns 503 when answering fails", async () => {
|
||||
const app = buildApp(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
async () => {
|
||||
throw new Error("Sensitive internal detail");
|
||||
},
|
||||
);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/ask",
|
||||
payload: { question: "What is RAG?" },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: "Question answering failed",
|
||||
});
|
||||
assert.equal(response.body.includes("Sensitive internal detail"), false);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,36 @@
|
|||
import Fastify, { type FastifyError } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { answerQuestion as answerQuestionDefault } from "./answer.js";
|
||||
import { ingestDocument as ingestDocumentDefault } from "./ingestion.js";
|
||||
import { checkOllamaHealth, generateResponse } from "./ollama.js";
|
||||
import { searchDocuments as searchDocumentsDefault } from "./search.js";
|
||||
|
||||
const askSchema = z.object({
|
||||
question: z.string().trim().min(1).max(2_000),
|
||||
});
|
||||
|
||||
const echoSchema = z.object({
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
const documentSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
source: z.string().trim().max(2_000).optional(),
|
||||
content: z.string().trim().min(1).max(100_000),
|
||||
});
|
||||
|
||||
const searchSchema = z.object({
|
||||
query: z.string().trim().min(1).max(2_000),
|
||||
limit: z.number().int().min(1).max(20).optional(),
|
||||
});
|
||||
|
||||
export const buildApp = (
|
||||
generate = generateResponse,
|
||||
checkHealth = checkOllamaHealth,
|
||||
ingest = ingestDocumentDefault,
|
||||
search = searchDocumentsDefault,
|
||||
answer = answerQuestionDefault,
|
||||
) => {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
|
|
@ -59,6 +80,49 @@ export const buildApp = (
|
|||
};
|
||||
});
|
||||
|
||||
app.post("/ask", async (request, reply) => {
|
||||
const result = askSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.code(400).send({
|
||||
error: "Invalid question",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await answer(result.data.question);
|
||||
return response;
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Question answering failed");
|
||||
|
||||
return reply.code(503).send({
|
||||
error: "Question answering failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/documents", async (request, reply) => {
|
||||
const result = documentSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.code(400).send({
|
||||
error: "Invalid document",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await ingest(result.data);
|
||||
|
||||
return reply.code(201).send(document);
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Document ingestion failed");
|
||||
|
||||
return reply.code(503).send({
|
||||
error: "Document ingestion failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/echo", async (request, reply) => {
|
||||
const result = echoSchema.safeParse(request.body);
|
||||
|
||||
|
|
@ -95,6 +159,27 @@ export const buildApp = (
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/search", async (request, reply) => {
|
||||
const result = searchSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.code(400).send({
|
||||
error: "Invalid search request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await search(result.data.query, result.data.limit);
|
||||
return { results };
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Document search failed");
|
||||
|
||||
return reply.code(503).send({
|
||||
error: "Document search failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/test-error", async () => {
|
||||
throw new Error("Sensitive internal detail");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue