Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The `Dockerfile` is multi-stage — stage one compiles the PyO3 wheel with the R
- **No `ANTHROPIC_API_KEY`.** It runs in mock mode: answers are extractive, tagged with a visible `mock` badge. Nothing calls Claude, so there's no key on a public endpoint and no spend to burn. Retrieval — the HNSW index, which is the point of the project — is fully real.
- **The hashed fallback embedder**, not `sentence-transformers` (which would drag `torch` into the image). That means retrieval matches on *term overlap, not meaning*. Don't mistake the demo for semantic search; install `sentence-transformers` and set `RAG_EMBEDDER=model` for that. `GET /stats` reports which backend is live so you never have to guess.
- **Cold starts.** Scaled to zero, the first request after an idle period waits a second or two for the machine to wake.
- **A live document workspace.** The UI accepts Markdown or plain text, lists every indexed document, and shows the active corpus, embedding, HNSW, relevance, and generation configuration. Documents are held in memory and disappear when the service starts fresh.
- **A visible, locked document workspace.** The UI lists every indexed document and shows the active corpus, embedding, HNSW, relevance, generation, and upload configuration. Public uploads are disabled by default; enabling them is an explicit deployment choice. Documents are held in memory and disappear when the service starts fresh.

## Quick start

Expand Down Expand Up @@ -82,8 +82,8 @@ idx.search(query, k=10) # -> [(id, distance), ...] closest first
```sh
# terminal 1 — the Python service (reuses the bindings venv)
cd service && pip install -r requirements.txt
uvicorn rag_service.app:app --port 8000
python scripts/seed.py # upload the sample corpus
RAG_UPLOADS_ENABLED=1 uvicorn rag_service.app:app --port 8000
python scripts/seed.py # uploads require the flag above

# terminal 2 — the Next.js UI
cd app && npm install
Expand All @@ -105,10 +105,37 @@ The HNSW index remains real in every mode. Its live document count, chunk count,
vector dimension, metric, `M`, `ef_construction`, and relevance floor are
visible alongside the document library.

To add material from the UI, select a `.md`, `.markdown`, or `.txt` file
(or paste text), give it a title, and choose **Add to index**. The browser reads
the file as text and sends the title and content to the existing document API;
no file is stored on disk.
The upload panel remains visible when uploads are disabled, but its controls
are locked and explain how to opt in. To add material locally, start both Fly.io
and Vercel-compatible environments with `RAG_UPLOADS_ENABLED=1`, then select a
`.md`, `.markdown`, or `.txt` file (or paste text), give it a title, and
choose **Add to index**. The browser reads the file as text; no file is stored
on disk. `scripts/seed.py` uses the same protected upload endpoint and
therefore also requires uploads to be enabled. Startup seeding is unaffected
because it inserts directly into the in-process store.

### Public API hardening

| Variable | Default | Behavior |
|----------|---------|----------|
| `RAG_UPLOADS_ENABLED` | `0` | Enables `POST /documents` only when explicitly set to `1`, `true`, `yes`, or `on`. |
| `RAG_MAX_TITLE_CHARS` | `200` | Maximum document title length. |
| `RAG_MAX_DOCUMENT_CHARS` | `1000000` | Maximum document text length. |
| `RAG_MAX_QUESTION_CHARS` | `2000` | Maximum query length. |
| `RAG_MAX_REQUEST_BYTES` | `4194304` | Maximum HTTP request body size (4 MiB), enforced before JSON parsing. |
| `RAG_CLAUDE_TIMEOUT_SECONDS` | `30` | Claude request timeout; automatic SDK retries are disabled. |
| `RAG_CORS_ORIGINS` | empty | Exact comma-separated allowed origins. Empty installs no CORS middleware; `*` restores wildcard access explicitly. |

Chunking accepts `max_words` from 1 through 1000 and `overlap` from 0
through 999, with overlap strictly smaller than the chunk size. Query `k`
remains 1 through 50 and `ef_search` is limited to 1 through 2000. Oversized
bodies return `413`, invalid fields return `422`, disabled uploads return
`403`, and Claude timeouts return `504`. The Next.js same-origin proxy
preserves these status codes and service messages.

If deployment-specific limits are changed, set the same `RAG_MAX_*` values in
both the service environment (Fly.io) and the Next.js environment (Vercel) so
the UI, proxy, and backend enforce one contract.

## Roadmap

Expand Down Expand Up @@ -160,7 +187,7 @@ This section grows as the project does; each phase documents the trade-offs it m
|-------|---------|-------|
| Rust engine | `cargo test` | 24 (unit + seeded recall vs. brute force + doctest) |
| Python bindings + helpers | `pytest` in `bindings/` | 21 (FFI surface, atomic batches, validation, chunking, embeddings, E2E retrieval) |
| RAG service | `PYTHONPATH=. pytest` in `service/` | 17 (keyless E2E, relevance filtering, citation parsing, startup seeding) |
| RAG service | `PYTHONPATH=. pytest` in `service/` | 36 (keyless E2E, hardening limits, CORS, Claude timeout, relevance filtering, citation parsing, startup seeding) |
| Next.js app | `npm run build` | type-checked production build |

CI (`.github/workflows/ci.yml`) runs all four on every push, in parallel jobs: `cargo fmt --check` + `cargo clippy -- -D warnings` + `cargo test`, the bindings suite, the service suite, and the app build.
Expand Down
26 changes: 17 additions & 9 deletions app/app/api/documents/route.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
import { NextResponse } from "next/server";
import { addDocument, listDocuments } from "@/app/lib/rag";
import { INPUT_LIMITS, readLimitedJson } from "@/app/lib/limits";
import { apiErrorResponse } from "@/app/lib/api-errors";

export async function GET() {
try {
return NextResponse.json(await listDocuments());
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "unknown error" },
{ status: 502 },
);
return apiErrorResponse(err);
}
}

export async function POST(req: Request) {
try {
const body = (await req.json()) as { title?: unknown; text?: unknown };
const body = await readLimitedJson<{ title?: unknown; text?: unknown }>(req);
if (typeof body.title !== "string" || !body.title.trim()) {
return NextResponse.json({ error: "title is required" }, { status: 400 });
}
if (body.title.trim().length > INPUT_LIMITS.titleChars) {
return NextResponse.json(
{ error: `title must be at most ${INPUT_LIMITS.titleChars} characters` },
{ status: 422 },
);
}
if (typeof body.text !== "string" || !body.text.trim()) {
return NextResponse.json({ error: "document text is required" }, { status: 400 });
}
if (body.text.length > INPUT_LIMITS.documentChars) {
return NextResponse.json(
{ error: `document text must be at most ${INPUT_LIMITS.documentChars} characters` },
{ status: 422 },
);
}

return NextResponse.json(await addDocument(body.title.trim(), body.text));
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "unknown error" },
{ status: 502 },
);
return apiErrorResponse(err);
}
}
23 changes: 16 additions & 7 deletions app/app/api/query/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { NextResponse } from "next/server";
import { query } from "@/app/lib/rag";
import { INPUT_LIMITS, readLimitedJson } from "@/app/lib/limits";
import { apiErrorResponse } from "@/app/lib/api-errors";

export async function POST(req: Request) {
try {
const { question, k } = await req.json();
if (!question || typeof question !== "string") {
const body = await readLimitedJson<{ question?: unknown; k?: unknown }>(req);
if (typeof body.question !== "string" || !body.question.trim()) {
return NextResponse.json({ error: "question is required" }, { status: 400 });
}
const result = await query(question, k ?? 5);
if (body.question.trim().length > INPUT_LIMITS.questionChars) {
return NextResponse.json(
{ error: `question must be at most ${INPUT_LIMITS.questionChars} characters` },
{ status: 422 },
);
}

const result = await query(
body.question.trim(),
typeof body.k === "number" ? body.k : 5,
);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "unknown error" },
{ status: 502 },
);
return apiErrorResponse(err);
}
}
6 changes: 2 additions & 4 deletions app/app/api/stats/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { NextResponse } from "next/server";
import { getStats } from "@/app/lib/rag";
import { apiErrorResponse } from "@/app/lib/api-errors";

export async function GET() {
try {
return NextResponse.json(await getStats());
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "unknown error" },
{ status: 502 },
);
return apiErrorResponse(err);
}
}
62 changes: 62 additions & 0 deletions app/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -709,3 +709,65 @@ button:disabled {
width: 100%;
}
}


.upload-panel.locked {
border-color: rgba(247, 200, 115, 0.3);
background: rgba(23, 24, 29, 0.9);
}

.upload-status {
border: 1px solid currentColor;
border-radius: 999px;
padding: 4px 9px;
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.06em;
text-transform: uppercase;
}

.upload-status.enabled {
color: var(--green);
}

.upload-status.locked {
color: var(--amber);
}

.upload-lock-notice {
display: grid;
gap: 4px;
margin-bottom: 16px;
padding: 12px 13px;
border: 1px solid rgba(247, 200, 115, 0.3);
border-radius: 10px;
color: #f4d79b;
background: rgba(92, 67, 25, 0.2);
font-size: 0.76rem;
}

.upload-lock-notice span {
color: var(--muted-strong);
}

.upload-lock-notice code {
color: var(--amber);
}

.character-count {
display: block;
margin-top: 5px;
color: #718096;
font-size: 0.68rem;
text-align: right;
}

input:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.58;
}

.file-input:disabled::file-selector-button {
cursor: not-allowed;
}
14 changes: 14 additions & 0 deletions app/app/lib/api-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { InputRequestError } from "./limits";
import { RagServiceError } from "./rag";

export function apiErrorResponse(error: unknown) {
const status =
error instanceof InputRequestError || error instanceof RagServiceError
? error.status
: 502;
return NextResponse.json(
{ error: error instanceof Error ? error.message : "unknown error" },
{ status },
);
}
43 changes: 43 additions & 0 deletions app/app/lib/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024;

function positiveInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}

export const INPUT_LIMITS = {
titleChars: positiveInt("RAG_MAX_TITLE_CHARS", 200),
documentChars: positiveInt("RAG_MAX_DOCUMENT_CHARS", 1_000_000),
questionChars: positiveInt("RAG_MAX_QUESTION_CHARS", 2_000),
requestBytes: positiveInt("RAG_MAX_REQUEST_BYTES", DEFAULT_MAX_REQUEST_BYTES),
} as const;

export class InputRequestError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = "InputRequestError";
}
}

export async function readLimitedJson<T>(request: Request): Promise<T> {
const declaredLength = Number(request.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > INPUT_LIMITS.requestBytes) {
throw new InputRequestError("request body is too large", 413);
}

const bytes = await request.arrayBuffer();
if (bytes.byteLength > INPUT_LIMITS.requestBytes) {
throw new InputRequestError("request body is too large", 413);
}

try {
return JSON.parse(new TextDecoder().decode(bytes)) as T;
} catch {
throw new InputRequestError("request body must be valid JSON", 400);
}
}
51 changes: 46 additions & 5 deletions app/app/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,60 @@ export interface Stats {
ef_construction: number;
embedder: string;
generation: "mock" | "claude";
uploads_enabled: boolean;
limits: {
title_chars: number;
document_chars: number;
question_chars: number;
request_bytes: number;
};
}

export class RagServiceError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = "RagServiceError";
}
}

function errorMessage(body: string, status: number): string {
try {
const parsed = JSON.parse(body) as { detail?: unknown; error?: unknown };
if (typeof parsed.detail === "string") return parsed.detail;
if (typeof parsed.error === "string") return parsed.error;
if (Array.isArray(parsed.detail)) {
const messages = parsed.detail
.map((item) =>
typeof item === "object" &&
item !== null &&
"msg" in item &&
typeof item.msg === "string"
? item.msg
: null,
)
.filter((message): message is string => message !== null);
if (messages.length) return messages.join("; ");
}
} catch {
// Preserve a non-JSON service response below.
}
return body || `RAG service request failed with status ${status}`;
}

async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${SERVICE_URL}${path}`, {
const response = await fetch(`${SERVICE_URL}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
cache: "no-store",
});
if (!res.ok) {
const body = await res.text();
throw new Error(`RAG service ${res.status}: ${body}`);
if (!response.ok) {
const body = await response.text();
throw new RagServiceError(errorMessage(body, response.status), response.status);
}
return res.json() as Promise<T>;
return response.json() as Promise<T>;
}

export function query(question: string, k = 5): Promise<QueryResult> {
Expand Down
Loading
Loading