Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ UI_INTERNAL_URL=http://127.0.0.1:3000
ENABLE_GENUI=1
# Optional: absolute path or path relative to the project root.
# RAG_RERANKER_DIR=data/models/hub/models--BAAI--bge-reranker-base/snapshots/2cfc18c9415c912f9d8155881c133215df768a70
# Set RAG_API_URL in frontend/.env.local to change the Next.js upstream (default :8000).
# Set RAG_API_URL=http://127.0.0.1:8000/ask in frontend/.env.local for a different upstream.

# Optional private local diagnostics with bounded retention.
# RAG_TRACE_DIR=data/app/traces
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,13 @@
.DS_Store
.env*
!/.env.example

# V1 operational lifecycle and acceptance checks.
!/rag_app/indexes.py
!/rag_app/observability.py
!/scripts/manage_index.py
!/tests/test_index_lifecycle.py
!/tests/test_observability.py
!/tests/test_v1_acceptance.py
!/frontend/src/lib/request-origin.ts
!/evaluations/results/v1_acceptance.json
74 changes: 71 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ This is a code-only checkout, not a bundled dataset or one-command model downloa

`RAG_RERANKER_DIR` can override the reranker location with an absolute path or a path relative to the project root. Serving validates local assets and does not silently download missing weights.

The current importer deliberately targets the audited snapshot: **28,482 articles, 99,697 document chunks and 120 trailing query vectors**. Query vectors are excluded from the Qdrant collection. A different corpus/chunk build needs a reviewed importer/configuration update, not relaxed integrity checks.
The current importer deliberately targets the audited snapshot: **28,482 articles, 99,697 document chunks and 120 trailing query vectors**. Query vectors are excluded from the Qdrant collection. Use the versioned update workflow below for subsequent document changes. Changes to the embedding or chunking policy require a separately prepared index.

Preparation code is included in `scripts/audit_techqa.py`, `baseline.py`, `chunking.py`, `ingest_html.py` and `eval_embeddings.py`. Rebuilding requires the original release, tokenizer manifests and the pinned model access; embedding preparation uses a CUDA runtime. It is separate from the local serving setup.

Expand Down Expand Up @@ -173,9 +173,77 @@ npm ci
npm run dev -- --hostname 127.0.0.1
```

Open `http://127.0.0.1:3000`. The example `APP_ORIGIN` matches that address. For another origin, update it explicitly. The frontend proxies to `http://127.0.0.1:8000`; set `RAG_API_URL` in `frontend/.env.local` to change the upstream.
Open `http://127.0.0.1:3000`. The example `APP_ORIGIN` matches that address. For another origin, update it explicitly. The frontend proxies to `http://127.0.0.1:8000`; set the full `/ask` URL in `RAG_API_URL` inside `frontend/.env.local` to change the upstream.

`GET /health/live` checks the API process. `GET /health/ready` checks key/source/index readiness. The first request loads local models, so cold-start latency differs from subsequent requests.
`GET /health/live` checks the API process. `GET /health/ready` checks the key, pinned model assets, source hash and active index identity/count. The first request loads local models, so cold-start latency differs from subsequent requests.

## V1 acceptance and operation

The local V1 uses one API worker and Docker Qdrant. Its [redacted acceptance results](evaluations/results/v1_acceptance.json) record the checks and their scope.

| Check | Observation |
|---|---|
| Saved training-context replay | **80/97 → 83/97**, three gains and zero losses, using a reference-word coverage proxy |
| Changed-document drill | **2 chunks embedded, 99,695 vectors reused** across add/update/delete, followed by a zero-embedding repeat and successful rollback |
| Backup recovery | **28,482 documents / 99,697 vectors** validated, with identical exact-search top-ten results for three saved query vectors |
| Verifier calibration | **4/4** targeted canaries passed |
| Four final targeted questions | One answered, one clarification, two withheld |
| Live acceptance usage | **15/15 HTTP 200**, **62,248 reported tokens** across initial checks, canaries and the final generation pass |

The replay uses training tuning questions and a text-coverage proxy. Final generation reused the newly selected contexts. These measurements support the targeted fixes. Full held-out answer quality remains a later milestone. Q046/Q037 still withhold, and historical RAGAS review flags remain visible.

Browser acceptance uses the existing controlled GenUI fixture. It checks rendering, excerpts, structured Run details, regeneration and session clearing. The API suite also covers the storage/search/verification handoff, session isolation, exact cache invalidation after an index change and bounded concurrent admission.

### Update documents and roll back

After the initial import, register the existing index once. Adoption validates source membership, payload versions, vector dimensions and norms.

```bash
.venv-ingest/bin/python -m scripts.manage_index adopt
.venv-ingest/bin/python -m scripts.manage_index status
```

An update file maps document IDs to records with `title`, `text` and `metadata`. Optional `content` supplies original HTML for the existing Docling parser. Keep the original body in `text` when providing HTML so exact source matches can be mapped. Changed documents retain their raw input in the source store.

```json
{
"your-document-id": {
"title": "Updated support article",
"text": "The updated article body.",
"metadata": {"productName": "Your product"}
}
}
```

Stage changes into a separate collection, then activate the returned build ID. An unchanged input reuses its existing version. Deletion can be run with `--delete` alone. Policy fingerprints prevent mixing a changed model/parser/chunker configuration with old vectors.

```bash
.venv-ingest/bin/python -m scripts.manage_index stage --updates /path/to/updates.json
.venv-ingest/bin/python -m scripts.manage_index stage --delete document-id-to-remove
.venv-ingest/bin/python -m scripts.manage_index activate BUILD_ID
.venv-ingest/bin/python -m scripts.manage_index validate
.venv-ingest/bin/python -m scripts.manage_index activate PREVIOUS_BUILD_ID
```

Staging copies unchanged vectors to preserve an isolated, recoverable version. It embeds the changed documents only. The serving process picks up activation on its next request. Keep version directories and their Qdrant collections together. Stage each batch from the currently active version. If the activation pointer is lost, serving stops until you explicitly activate a validated build.

### Back up and restore

```bash
.venv-ingest/bin/python -m scripts.manage_index backup /path/to/new-backup-directory
.venv-ingest/bin/python -m scripts.manage_index restore /path/to/backup-directory
.venv-ingest/bin/python -m scripts.manage_index activate RESTORE_ID
```

The backup contains a native Qdrant snapshot, matching SQLite sources and checksums. Restore creates a separate collection and returns `restore_id`. Activation validates the pair before changing the local pointer. This workflow is tested on a single-node Docker Qdrant. See the [Qdrant snapshot documentation](https://qdrant.tech/documentation/snapshots/) for the underlying snapshot operations.

### Inspect runs and operating bounds

The local service admits five requests, processes one at a time, waits up to 30 seconds for the worker and bounds each request at 180 seconds. Provider quota waiting contributes to response time. Controlled concurrency tests exercise these bounds. Public traffic capacity needs a separate load measurement.

Run details displays stage times, model/index/prompt identities, cache hits, token usage and HTTP attempts. Local metrics are content-free, capped at 100 MiB and seven days. Retrieval caches retain up to 2,048 query vectors and 50,000 pair scores. Existing answer-cache/session limits apply as well.

Set `RAG_TRACE_DIR` in the backend environment to opt into private local JSON traces containing the question, candidates, selected passages and generation decisions. Traces use owner-only file permissions and retain up to 50 files, 128 MiB and seven days. Keep that directory outside public hosting and Git. Gold references remain in offline evaluation artifacts.

## Run RAGAS offline

Expand Down
139 changes: 139 additions & 0 deletions evaluations/results/v1_acceptance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
{
"date": "2026-11-10",
"scope": "Local Mac V1, single API worker, local Docker Qdrant. Training tuning and targeted acceptance checks.",
"context_replay": {
"n": 97,
"before": 80,
"after": 83,
"gains": 3,
"losses": 0,
"measure": "At least 90% longest contiguous normalized reference-word-run coverage within the annotated article. Saved candidates and reranker scores. References used only for scoring."
},
"lifecycle": {
"documents": 28482,
"chunks": 99697,
"embedded_changed_chunks": 2,
"reused_vectors": 99695,
"add_update_delete": true,
"unchanged_update_embeds_zero": true,
"activation_and_rollback": true,
"restored_full_membership_validated": true,
"restored_exact_top10_parity_queries": 3,
"missing_activation_fails_closed": true,
"explicit_activation_recovers_missing_pointer": true
},
"verifier_canaries": {
"passed": 4,
"total": 4,
"cases": [
{
"id": "negative_feature",
"pass": true
},
{
"id": "wrong_version",
"pass": true
},
{
"id": "table_exclusion",
"pass": true
},
{
"id": "missing_required_step",
"pass": true
}
]
},
"live_targeted_cases": [
{
"question_id": "TRAIN_Q063",
"outcome": "answered",
"generation_seconds": 110.09,
"stages": [
"write",
"repair",
"verify"
]
},
{
"question_id": "TRAIN_Q046",
"outcome": "insufficient_evidence",
"generation_seconds": 43.92,
"stages": [
"write"
]
},
{
"question_id": "TRAIN_Q019",
"outcome": "needs_clarification",
"generation_seconds": 44.34,
"stages": [
"write"
]
},
{
"question_id": "TRAIN_Q037",
"outcome": "insufficient_evidence",
"generation_seconds": 46.18,
"stages": [
"write"
]
}
],
"live_usage": {
"http_attempts": 15,
"http_200": 15,
"reported_tokens": 62248,
"attempt_cap": 18,
"token_cap": 70000,
"includes": "First live pass, four canaries, and final-prompt generation on the four saved new contexts."
},
"browser": {
"controlled_response_fixture": true,
"genui": true,
"source_excerpt": true,
"structured_run_details": true,
"regeneration": true,
"session_clear": true,
"console_errors_after_fix": 0
},
"operating_bounds": {
"request_deadline_seconds": 180,
"queue_wait_seconds": 30,
"admitted_requests": 5,
"session_idle_seconds": 1800,
"query_cache_entries": 2048,
"pair_cache_entries": 50000,
"metrics_max_bytes": 104857600,
"metrics_retention_days": 7,
"opt_in_private_trace_files": 50,
"opt_in_private_trace_max_bytes": 134217728
},
"limits": [
"Targeted cases and canaries do not establish full answer accuracy.",
"Q046 and Q037 still withhold. Q019 asks for clarification.",
"Browser rendering uses a controlled fixture. Live checks cover local retrieval and separate generation checks.",
"Admission is checked with controlled concurrent requests. Broader traffic/load benchmarking remains a later milestone.",
"Historical RAGAS scores retain their judge review flags. Held-out evaluation remains reserved."
],
"verification": {
"local_tests": {
"passed": 141,
"skipped": 1,
"subtests": 3
},
"clean_public_copy": {
"passed": 126,
"skipped": 1,
"subtests": 3
},
"focused_ragas_environment": {
"passed": 9
},
"frontend_contract_tests": 8,
"typecheck": true,
"lint": true,
"production_build": true,
"test_count_note": "Suites overlap. The skipped local test passes in the separate RAGAS environment."
}
}
4 changes: 2 additions & 2 deletions frontend/src/app/api/ask/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { isAllowedOrigin } from "@/lib/request-origin";
const MAX_QUESTION_LENGTH = 8_000;

export async function POST(request: Request) {
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) return Response.json({ message: "Cross-origin requests are not allowed." }, { status: 403 });
if (!isAllowedOrigin(request)) return Response.json({ message: "Cross-origin requests are not allowed." }, { status: 403 });
if (Number(request.headers.get("content-length") || 0) > 64_000) return Response.json({ message: "Request body is too large." }, { status: 413 });
let input: unknown;
try {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/api/session/end/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { isAllowedOrigin } from "@/lib/request-origin";
export async function POST(request: Request) {
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) return Response.json({ message: "Cross-origin requests are not allowed." }, { status: 403 });
if (!isAllowedOrigin(request)) return Response.json({ message: "Cross-origin requests are not allowed." }, { status: 403 });
try {
const upstream = await fetch("http://127.0.0.1:8000/session/end", {
const upstream = await fetch(new URL("session/end", process.env.RAG_API_URL || "http://127.0.0.1:8000/ask"), {
method: "POST",
headers: request.headers.get("cookie") ? { cookie: request.headers.get("cookie")! } : {},
cache: "no-store",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/api/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export async function GET() {
try {
const response = await fetch("http://127.0.0.1:8000/health/ready", { cache: "no-store" });
const upstream = new URL("health/ready", process.env.RAG_API_URL || "http://127.0.0.1:8000/ask");
const response = await fetch(upstream, { cache: "no-store", signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error("Readiness check failed");
const body = await response.json() as { presentation_status?: unknown; presentation?: unknown };
const presentation = body.presentation_status ?? body.presentation;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/api/ui-validate/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { isAllowedOrigin } from "@/lib/request-origin";
import { inspectUi, type VerifiedSource } from "@/lib/verified-ui-contract";

const MAX_BODY = 128_000;
const MAX_SOURCES = 32;

export async function POST(request: Request) {
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) return Response.json({ valid: false, message: "Cross-origin requests are not allowed." }, { status: 403 });
if (!isAllowedOrigin(request)) return Response.json({ valid: false, message: "Cross-origin requests are not allowed." }, { status: 403 });
const length = Number(request.headers.get("content-length") || 0);
if (length > MAX_BODY) return Response.json({ valid: false, message: "UI payload is too large." }, { status: 413 });
let raw: string;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type FinalEvent = {
answer?: string;
sources?: VerifiedSource[];
openui?: string;
metrics?: Record<string, string | number | boolean | null>;
metrics?: Record<string, unknown>;
};
type Result = FinalEvent & { question: string; runId: number; genUiValid?: boolean };
type ServiceStatus = { api: string; presentation_status: string };
Expand Down Expand Up @@ -365,7 +365,7 @@ export default function Workspace() {
return <li key={source.id} id={`source-${encodeURIComponent(source.id)}`}><span className="source-index">{String(index + 1).padStart(2, "0")}</span><div className="source-details"><div className="source-title-row">{href ? <a href={href} target="_blank" rel="noreferrer">{source.title}</a> : <strong>{source.title}</strong>}{href && <span className="external-mark" aria-hidden="true">↗</span>}</div><p className="source-location">{source.document_id ? `Document ${source.document_id}` : "Knowledge base excerpt"}{source.chunk_id ? ` · ${source.chunk_id}` : ""}</p><details><summary>View excerpt</summary><blockquote>{source.text}</blockquote></details></div></li>;
})}</ol>
) : <div className="sources-empty"><span aria-hidden="true">⌕</span><p>Sources are attached to verified answers so you can inspect the evidence.</p></div>}
{result?.metrics && <details className="run-metrics"><summary>Run details</summary><dl>{Object.entries(result.metrics).map(([key, value]) => <div key={key}><dt>{label(key)}</dt><dd>{String(value ?? "—")}</dd></div>)}</dl></details>}
{result?.metrics && <details className="run-metrics"><summary>Run details</summary><dl>{Object.entries(result.metrics).map(([key, value]) => <div key={key}><dt>{label(key)}</dt><dd>{value !== null && typeof value === "object" ? JSON.stringify(value) : String(value ?? "—")}</dd></div>)}</dl></details>}
</aside>
</div>
</section>
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/lib/request-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Next may normalize request.url to localhost even when the browser uses 127.0.0.1. */
export function isAllowedOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
if (!origin) return true;
try {
const expected = new URL(process.env.APP_ORIGIN || request.url);
if (!process.env.APP_ORIGIN) expected.host = request.headers.get("host") || expected.host;
return new URL(origin).origin === expected.origin;
} catch {
return false;
}
}
21 changes: 21 additions & 0 deletions frontend/tests/genui-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,24 @@ test('prompt selects structures by content and disallows invented operational fa
assert.ok(prompt.includes('do not fabricate extra sections'));
assert.ok(prompt.includes('must not be hidden'));
});


test('browser origin follows the request host when Next normalizes localhost',async()=>{
const {isAllowedOrigin}=await import('../src/lib/request-origin.ts');
const original=process.env.APP_ORIGIN;
delete process.env.APP_ORIGIN;
try {
const request=(origin,host='127.0.0.1:3000')=>new Request('http://localhost:3000/api/ui-validate',{headers:{origin,host}});
assert.equal(isAllowedOrigin(request('http://127.0.0.1:3000')),true);
assert.equal(isAllowedOrigin(request('https://evil.example')),false);
assert.equal(isAllowedOrigin(request('https://127.0.0.1:3000')),false);
assert.equal(isAllowedOrigin(request('invalid-origin')),false);
assert.equal(isAllowedOrigin(new Request('http://localhost:3000/api/ui-validate')),true);
process.env.APP_ORIGIN='https://demo.example';
assert.equal(isAllowedOrigin(request('http://127.0.0.1:3000')),false);
assert.equal(isAllowedOrigin(request('https://demo.example')),true);
} finally {
if(original===undefined)delete process.env.APP_ORIGIN;
else process.env.APP_ORIGIN=original;
}
});
Loading
Loading