Compare commits
24 Commits
d3f8a9a5e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e1ca9f4127 | |||
| 3f9677f6aa | |||
| 47e4846270 | |||
| dc76ca67b8 | |||
| d5ccec2f2c | |||
| 640691b8ab | |||
| 6261bc0a23 | |||
| 2ac69c9944 | |||
| 56b8d9401a | |||
| e2c7fdc059 | |||
| 1401e46c9a | |||
| bdf0c36e20 | |||
| f562b91f83 | |||
| 4dd3125318 | |||
| aa5838fadc | |||
| 16c918538b | |||
| 736391b137 | |||
| 0ff9d8dd21 | |||
| 5fd19c12d9 | |||
| 118255acdc | |||
| 987b0493ac | |||
| 9ce2072c23 | |||
| 55907a8dec | |||
| afc20200f7 |
15
.env.example
@@ -5,12 +5,18 @@ OPENAI_API_KEY=sk-...
|
|||||||
QDRANT_URL=http://localhost:6333
|
QDRANT_URL=http://localhost:6333
|
||||||
QDRANT_API_KEY=
|
QDRANT_API_KEY=
|
||||||
|
|
||||||
# Fixed models (not configurable per strategy)
|
# Default cloud Embedding Model (registry id / OpenAI model name)
|
||||||
EMBEDDING_MODEL=text-embedding-3-small
|
EMBEDDING_MODEL=text-embedding-3-large
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# Local Ollama embeddings (Active model switched in Admin)
|
||||||
|
OLLAMA_BASE_URL=http://192.168.10.10:11435
|
||||||
|
|
||||||
# Retrieval defaults
|
# Retrieval defaults
|
||||||
TOP_K=5
|
TOP_K=5
|
||||||
|
# Neighbor Expansion for fixed_size only (ADR-0023); 3/3 = stabilized default after benchmark
|
||||||
|
NEIGHBOR_PREV=3
|
||||||
|
NEIGHBOR_NEXT=3
|
||||||
|
|
||||||
# LLM generation parameters
|
# LLM generation parameters
|
||||||
TEMPERATURE=0.0
|
TEMPERATURE=0.0
|
||||||
@@ -25,4 +31,7 @@ SEMANTIC_THRESHOLD=0.3
|
|||||||
SEMANTIC_MIN_CHUNK_SIZE=3
|
SEMANTIC_MIN_CHUNK_SIZE=3
|
||||||
|
|
||||||
# SQLite database path
|
# SQLite database path
|
||||||
DATABASE_URL=sqlite:///./data/chunking_benchmark.db
|
DATABASE_URL=sqlite:///./data/chunking_benchmark.db
|
||||||
|
# Text PDF gate (reject scanned/image PDFs)
|
||||||
|
PDF_MIN_TOTAL_CHARS=100
|
||||||
|
PDF_MIN_MEDIAN_CHARS_PER_PAGE=40
|
||||||
|
|||||||
103
CONTEXT.md
@@ -7,11 +7,15 @@ A single-file React app (CDN-loaded, no build step) served by FastAPI at `/app`.
|
|||||||
_Avoid_: Admin panel, web UI, frontend
|
_Avoid_: Admin panel, web UI, frontend
|
||||||
|
|
||||||
**Strategy**:
|
**Strategy**:
|
||||||
One of the five chunking algorithms: fixed_size, recursive, semantic, contextual_retrieval, semantic_parent_child.
|
One of the five chunking algorithms: fixed_size, recursive, semantic, contextual_retrieval, semantic_parent_child. Semantic and semantic_parent_child determine chunk boundaries from meaning (adjacent-unit similarity), not from fixed sentence/paragraph counts.
|
||||||
_Avoid_: Method, approach, technique
|
_Avoid_: Method, approach, technique
|
||||||
|
|
||||||
|
**Semantic Boundary Detection**:
|
||||||
|
Using embeddings of consecutive units (sentences for semantic — Farsi-aware punctuation, with line/paragraph fallback when punctuation yields a single unit; paragraphs for semantic_parent_child) and cosine similarity against a threshold tied to the Boundary Embedding Model to decide where chunks start and end. Distinct from Corpus Embedding (finished-chunk vectors for Qdrant). If boundary embeddings are missing or mismatched, that Strategy fails — there is no fixed-count fallback.
|
||||||
|
_Avoid_: Semantic embedding, chunk embedding (ambiguous — that often means storage vectors)
|
||||||
|
|
||||||
**Experiment**:
|
**Experiment**:
|
||||||
A completed benchmark run — one document, N strategies, M questions, with per-question and aggregate metrics.
|
A completed benchmark run — one document, N strategies, M questions, with per-question and aggregate metrics. Always records Corpus Embedding Model and Neighbor Expansion knobs. Records Boundary Embedding Model when semantic Strategies were part of processing/evaluation path for that corpus. Runs under different Boundary/Corpus settings must not be silently compared.
|
||||||
_Avoid_: Run, trial, benchmark
|
_Avoid_: Run, trial, benchmark
|
||||||
|
|
||||||
**Chunk Preview**:
|
**Chunk Preview**:
|
||||||
@@ -19,16 +23,96 @@ The ability to visualize what a specific strategy produces for a given document
|
|||||||
_Avoid_: Chunk inspection, chunk view
|
_Avoid_: Chunk inspection, chunk view
|
||||||
|
|
||||||
**Tab**:
|
**Tab**:
|
||||||
A persistent top-level navigation section of the Dashboard (Home, Documents, Query, Benchmarks, Admin). Tabs stay mounted when switching — state survives.
|
A persistent top-level navigation section of the Dashboard (Home, Documents, Query, Benchmarks, Admin, PDF). Tabs stay mounted when switching — state survives.
|
||||||
_Avoid_: Page, route, view
|
_Avoid_: Page, route, view
|
||||||
|
|
||||||
|
**Retrieval Inspect**:
|
||||||
|
A full-page mode inside the Benchmarks Tab for auditing one Experiment’s retrieval: left rail lists questions (status for error / has Expansion Tree); main pane has Strategy picker (defaults to `fixed_size` when present), a side-by-side Generated Answer | Expected Answer strip with eval scores, and a vertical full-text Expansion Tree (previous → hit → next per top-k hit; neighbors empty/N/A for other Strategies). Opened from an Experiment; not a separate top-level Tab. Distinct from live Query and from truncated HTML report Expansion Tree blocks.
|
||||||
|
_Avoid_: Query tab, chunk preview, report tree, expansion sample
|
||||||
|
|
||||||
|
**Expected Answer**:
|
||||||
|
The reference answer for a benchmark question, supplied with the question set and used by evaluation (e.g. Answer Similarity). Shown in Retrieval Inspect beside the Generated Answer. Informal synonym “golden answer” is not product language.
|
||||||
|
_Avoid_: Golden answer, ground truth, reference answer (prefer Expected Answer in UI and docs)
|
||||||
|
|
||||||
|
**PDF Workspace**:
|
||||||
|
The PDF Tab’s end-to-end surface for Text PDF work: upload, process Strategies, Chunk Preview, query, run Experiments, and open reports — scoped to PDF documents only. Documents / Query / Benchmarks Tabs list Word documents only (`.doc`/`.docx`). Inside the Tab, features are collapsible accordion sections (Upload & Process open by default); Admin-only ops (health, Qdrant CRUD) stay on Admin. Built from shared Dashboard section components with a format filter — not a forked UI.
|
||||||
|
_Avoid_: PDF page, PDF mode, PDF dashboard
|
||||||
|
|
||||||
|
**Text PDF**:
|
||||||
|
A PDF with a real, selectable text layer that can be extracted without OCR. In scope for PDF ingestion v1.
|
||||||
|
_Avoid_: Digital PDF, native PDF, searchable PDF (ambiguous in ops talk)
|
||||||
|
|
||||||
|
**Scanned PDF**:
|
||||||
|
A PDF whose pages are images (or have a useless/empty text layer) and need OCR before chunking. Out of scope for PDF ingestion v1.
|
||||||
|
_Avoid_: Image PDF, photo PDF, OCR PDF
|
||||||
|
|
||||||
|
**Heading Reconstruction**:
|
||||||
|
Inferring section/article boundaries from PDF signals in priority order — outline bookmarks, then font size/weight, then Farsi/English text heuristics — and emitting markdown `#` / `##` for Strategies. Later signals fill gaps; they do not override outline titles when an outline is present.
|
||||||
|
_Avoid_: Heading detection, structure recovery, outline parsing (too narrow)
|
||||||
|
|
||||||
|
**Text-layer Gate**:
|
||||||
|
Upload-time check that a PDF has enough extractable text to count as a Text PDF; failure rejects the upload. Scanned PDFs never enter the benchmark corpus.
|
||||||
|
_Avoid_: OCR check, PDF validation, empty-page filter
|
||||||
|
|
||||||
|
**Table Flattening**:
|
||||||
|
Turning table cells into sequential plain-text blocks in reading order (same contract for DOCX and Text PDF). Strategies never receive grid/markdown-table structure in v1.
|
||||||
|
_Avoid_: Table extraction, table parsing, structured tables
|
||||||
|
|
||||||
|
**Embedding Model**:
|
||||||
|
A registered model identity (Provider, dimension, optional task prefixes, default `semantic_threshold`). Used in one of two roles: Boundary or Corpus. Not an Experiment axis by itself — Strategies are compared under recorded Boundary+Corpus snapshots per Experiment.
|
||||||
|
_Avoid_: Embedder, embedding provider (use Provider for that), vector model
|
||||||
|
|
||||||
|
**Active Embedding Model**:
|
||||||
|
Legacy single global role (ADR-0019). Replaced by two Admin defaults — Default Boundary Embedding Model and Default Corpus Embedding Model — with per-operation overrides. Prefer Boundary / Corpus terms for new work.
|
||||||
|
_Avoid_: Current model, selected embedder, runtime model
|
||||||
|
|
||||||
|
**Boundary Embedding Model**:
|
||||||
|
The Embedding Model used only for Semantic Boundary Detection (`semantic` / `semantic_parent_child`). Does not write Qdrant vectors. Ignored when a Strategy has no boundary step. Admin stores a default; Process may override per run. Snapshotted at process start. Cuts always use this model’s `semantic_threshold` (never the Corpus model’s threshold when the two differ).
|
||||||
|
_Avoid_: Semantic embedder, chunking model, similarity model
|
||||||
|
|
||||||
|
**Corpus Embedding Model**:
|
||||||
|
The Embedding Model used to embed finished chunks into the Model Corpus and to embed queries for search. Storage and query always share this model. Determines which Model Corpus collections are read/written. Admin stores a default; Process, Query, and Experiment may override per run. Snapshotted for that operation.
|
||||||
|
_Avoid_: Storage model, retrieval model, Qdrant model, Active Embedding Model
|
||||||
|
|
||||||
|
**Provider**:
|
||||||
|
Where an Embedding Model runs: Cloud (e.g. OpenAI API) or Local (Ollama on the host). A Provider is a delivery channel, not a model identity.
|
||||||
|
_Avoid_: Backend, engine, embedding source
|
||||||
|
|
||||||
|
**Model Corpus**:
|
||||||
|
The Strategy collections whose vectors were produced by one Corpus Embedding Model. Collection names always include that model’s id (`{strategy}__{model_id}_collection`). Process, query, and Experiment only read/write the Model Corpus of the Corpus Embedding Model in force — other corpora remain untouched.
|
||||||
|
_Avoid_: Collection set, vector partition, embedding space
|
||||||
|
|
||||||
|
**Embedding Model Registry**:
|
||||||
|
The configured catalog of Embedding Models the operator may assign to Boundary or Corpus roles — each entry has a stable identity, Provider, vector dimension, and a default `semantic_threshold` (Admin may override per model in SQLite). Defaults: OpenAI 0.3, Nomic 0.6. Unlisted models are out of scope until added to the registry.
|
||||||
|
_Avoid_: Model list, provider catalog, available embeddings
|
||||||
|
|
||||||
|
**Neighbor Expansion**:
|
||||||
|
Query-time widening of retrieved context for the `fixed_size` Strategy only: for every top-k hit, also include a configurable number of previous and next chunks in document order within the same document. Neighbors are added on top of the top-k set (context may grow beyond k). The same chunk id appears at most once in the LLM context. Final LLM context is sorted by document order (`chunk_index`). Neighbors are not re-ranked as independent hits. Counts (`neighbor_prev` / `neighbor_next`) are set per Query or Experiment like `top_k`; benchmarker defaults are `3`/`3` (symmetric ±3 after Strategy finalization); `0`/`0` turns expansion off. Missing neighbors at document edges are skipped. Knobs are ignored for non-`fixed_size` Strategies. Dashboard labels Experiments with a compact `±P/N` badge (tooltip explains prev/next; muted when `fixed_size` was not in the run). Experiments list and Compare show these knobs; Compare warns when selected Experiments differ on Neighbor Expansion, Corpus Embedding Model, or Boundary Embedding Model (when recorded) so operators do not misread cross-run rankings. Distinct from how a Strategy cuts text at process time.
|
||||||
|
_Avoid_: Context windowing, chunk padding, sliding window retrieval, adjacent chunk merge
|
||||||
|
|
||||||
|
**Expansion Tree**:
|
||||||
|
The operator-facing grouping of Neighbor Expansion results, returned alongside the flat `retrieved_chunks` list: top-k hits in score order, each listing previous and next chunks appended for that hit (overlaps may appear under more than one hit). When expansion is off, the tree still lists the hits with empty neighbor arrays. Shown in the Query result UI, Experiment per-question detail, and both managerial and technical HTML report views. Distinct from the flat, deduped, document-ordered context sent to the LLM (each chunk id appears only once there).
|
||||||
|
_Avoid_: Retrieved chunks list, neighbor list, expansion map
|
||||||
|
|
||||||
|
**Benchmark Sweep**:
|
||||||
|
An ordered batch of Experiments that hold Strategy and Corpus Embedding Model fixed while stepping Neighbor Expansion through symmetric levels (±0, ±1, ±2, ±3) — i.e. `(neighbor_prev, neighbor_next)` = `(0,0)`, `(1,1)`, `(2,2)`, `(3,3)` — across one or more documents. Each level is its own Experiment; the Sweep is the sequence, not a single stored row.
|
||||||
|
_Avoid_: Batch run, sequential benchmark, benchmark script, neighbor matrix
|
||||||
|
|
||||||
|
**Strategy Candidate**:
|
||||||
|
A single comparable configuration for final Strategy selection under one Corpus Embedding Model: either `fixed_size` at one Neighbor Expansion level (±N), or `semantic` under one Boundary Embedding Model. Distinct from an Experiment (an Experiment is one doc’s run of one Candidate, or of several Strategies). Final selection compares Candidates, not raw multi-strategy Experiments. The Decision Board auto-discovers Candidates from single-strategy Experiments (newest per doc×Candidate cell), filtered by Corpus Embedding Model, with optional exclude of a bad Experiment.
|
||||||
|
_Avoid_: Variant, configuration, setup, arm
|
||||||
|
|
||||||
|
**Decision Board**:
|
||||||
|
A top-level Dashboard Tab for choosing between Strategy families after tuning: stage 1 picks the best Strategy Candidate within `fixed_size` and within `semantic`; stage 2 compares those two winners. Always accompanied by a per-document breakdown so aggregate rank cannot hide doc-level disagreement. Document universe for v1 is the fixed 10-doc evaluation set (same as the Neighbor Expansion Benchmark Sweep). Distinct from Compare (ad-hoc multi-Experiment side-by-side) and from Retrieval Inspect.
|
||||||
|
_Avoid_: Final compare, strategy picker, results page, leaderboard, Decision mode
|
||||||
|
|
||||||
## Architecture Decisions
|
## Architecture Decisions
|
||||||
|
|
||||||
| # | Decision | Status |
|
| # | Decision | Status |
|
||||||
|---|----------|--------|
|
|---|----------|--------|
|
||||||
ADR-0001 | Single-file React via CDN (no build step), served by FastAPI | Approved |
|
ADR-0001 | Single-file React via CDN (no build step), served by FastAPI | Approved |
|
||||||
ADR-0002 | New `/admin/` router for dashboard-specific backend ops (health, Qdrant CRUD, chunk preview, questions, cost) | Approved |
|
ADR-0002 | New `/admin/` router for dashboard-specific backend ops (health, Qdrant CRUD, chunk preview, questions, cost) | Approved |
|
||||||
ADR-0003 | Top-tab navigation (Home, Documents, Query, Benchmarks, Admin) — not sidebar | Approved |
|
ADR-0003 | Top-tab navigation (Home, Documents, Query, Benchmarks, Decision, Admin) — not sidebar | Approved |
|
||||||
ADR-0004 | Babel-in-browser JSX: single index.html with inline <script type="text/babel">, React+ReactDOM+Babel from CDN. Zero build tooling, one file to edit. | Approved |
|
ADR-0004 | Babel-in-browser JSX: single index.html with inline <script type="text/babel">, React+ReactDOM+Babel from CDN. Zero build tooling, one file to edit. | Approved |
|
||||||
ADR-0005 | Persistent tabs with useState on root App. No routing library, no state library — 5-7 shared state values passed as props. Tab components stay mounted, state survives tab switches. | Approved |
|
ADR-0005 | Persistent tabs with useState on root App. No routing library, no state library — 5-7 shared state values passed as props. Tab components stay mounted, state survives tab switches. | Approved |
|
||||||
ADR-0006 | Dashboard calls existing REST endpoints for documents/queries/benchmarks. New /admin/* router ONLY fills gaps: Qdrant CRUD, health, chunk preview, questions dataset, cost estimation. No endpoint duplication. | Approved |
|
ADR-0006 | Dashboard calls existing REST endpoints for documents/queries/benchmarks. New /admin/* router ONLY fills gaps: Qdrant CRUD, health, chunk preview, questions dataset, cost estimation. No endpoint duplication. | Approved |
|
||||||
@@ -40,3 +124,14 @@ _Avoid_: Page, route, view
|
|||||||
ADR-0012 | Chunk Preview: table with expandable rows. Select document + strategy, click Load. Rows show index + 80-char text preview + token/char counts. Click to expand full text. Parent column hidden by default. | Approved |
|
ADR-0012 | Chunk Preview: table with expandable rows. Select document + strategy, click Load. Rows show index + 80-char text preview + token/char counts. Click to expand full text. Parent column hidden by default. | Approved |
|
||||||
ADR-0013 | Questions Management: file list table + upload button + expandable row detail (id, question, category, difficulty, expected answer) + delete + "Use This File" shortcut to set Benchmarks tab path and switch tabs. Auto-refresh after mutations. | Approved |
|
ADR-0013 | Questions Management: file list table + upload button + expandable row detail (id, question, category, difficulty, expected answer) + delete + "Use This File" shortcut to set Benchmarks tab path and switch tabs. Auto-refresh after mutations. | Approved |
|
||||||
ADR-0014 | Cost Estimator: two number inputs (questions, strategies), Estimate button, result card with total cost in amber, token estimate, 3 breakdown cards (embedding/queries/evaluation). Simple numbers, no tables. | Approved |
|
ADR-0014 | Cost Estimator: two number inputs (questions, strategies), Estimate button, result card with total cost in amber, token estimate, 3 breakdown cards (embedding/queries/evaluation). Simple numbers, no tables. | Approved |
|
||||||
|
ADR-0016 | Text PDF extraction via PyMuPDF + Heading Reconstruction; Scanned PDFs hard-rejected (Text-layer Gate). See docs/adr/0016-*.md | Approved |
|
||||||
|
ADR-0017 | PDF Workspace via shared Dashboard sections + format filter (not a forked UI). See docs/adr/0017-*.md | Approved |
|
||||||
|
ADR-0018 | Model Corpus via model-scoped collections; legacy unscoped → default cloud corpus. See docs/adr/0018-*.md | Superseded by ADR-0021 (naming) |
|
||||||
|
ADR-0019 | Active Embedding Model is global (Admin + registry), not an Experiment axis; provenance required. See docs/adr/0019-*.md | Superseded by ADR-0024 |
|
||||||
|
ADR-0020 | Semantic strategies require Semantic Boundary Detection (fail hard, no fixed-count fallback). See docs/adr/0020-*.md | Approved |
|
||||||
|
ADR-0021 | Always scope Qdrant collection names by Embedding Model id (cloud and local). See docs/adr/0021-*.md | Approved |
|
||||||
|
ADR-0022 | Per-Embedding-Model `semantic_threshold` (Admin override; defaults OpenAI 0.3 / Nomic 0.6). See docs/adr/0022-*.md | Approved |
|
||||||
|
ADR-0023 | Neighbor Expansion for fixed_size (+ Expansion Tree; list/Compare `±P/N` provenance + mismatch warning). See docs/adr/0023-*.md | Approved |
|
||||||
|
ADR-0024 | Boundary vs Corpus Embedding Model roles (Admin defaults + per-op overrides; query locked to Corpus). See docs/adr/0024-*.md | Implemented |
|
||||||
|
ADR-0025 | Retrieval Inspect: Benchmarks full-page mode (question rail + full-text Expansion Tree). See docs/adr/0025-*.md | Approved |
|
||||||
|
ADR-0026 | Decision Board Tab: two-stage Strategy Candidate selection (fixed_size ±N vs semantic@Boundary) over the 10-doc set. See docs/adr/0026-*.md | Approved |
|
||||||
|
|||||||
12
backlog/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Backlog — PDF & ingestion follow-ups
|
||||||
|
|
||||||
|
Items deferred from PDF ingestion v1 (Text PDF + PyMuPDF + Heading Reconstruction).
|
||||||
|
|
||||||
|
| Item | When to pick up |
|
||||||
|
|------|-----------------|
|
||||||
|
| [Scanned PDF OCR](./scanned-pdf-ocr.md) | After Text PDF Experiments are stable; chatbot needs image PDFs |
|
||||||
|
| [Persian / cloud OCR](./persian-cloud-ocr.md) | Local OCR quality on Farsi sample set is too low |
|
||||||
|
| [pdfplumber tables](./pdfplumber-tables.md) | PyMuPDF table flattening hurts Experiment scores |
|
||||||
|
| [LibreOffice PDF→DOCX fallback](./libreoffice-pdf-to-docx-fallback.md) | Heading Reconstruction false-positives dominate; want second opinion path |
|
||||||
|
| [Multi-column layout](./multi-column-layout.md) | Reading order is wrong on multi-column policies |
|
||||||
|
| [Page metadata in chunks](./page-metadata-in-chunks.md) | Users need “page N” citations in answers / Chunk Preview |
|
||||||
20
backlog/libreoffice-pdf-to-docx-fallback.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# LibreOffice PDF→DOCX fallback
|
||||||
|
|
||||||
|
## Why deferred
|
||||||
|
|
||||||
|
We already convert `.doc` → `.docx` via LibreOffice. PDF→DOCX is tempting for “reuse the DOCX parser,” but styles/fonts often come out wrong — fighting the reason we chose PyMuPDF.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Heading Reconstruction on PyMuPDF has an unacceptable false-positive/false-negative rate on a representative sample, and bookmarks/fonts are absent.
|
||||||
|
|
||||||
|
## Approach to try
|
||||||
|
|
||||||
|
1. Optional fallback: `soffice --headless --convert-to docx` then existing `parse_docx`
|
||||||
|
2. Compare markdown side-by-side (PyMuPDF vs converted DOCX) in Chunk Preview before making it default
|
||||||
|
3. Keep as opt-in or auto-fallback only when PDF outline + font variance is below a threshold
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Converted DOCX headings beat PyMuPDF heuristics on the failing sample set
|
||||||
|
- Conversion time acceptable for upload UX (or async later — currently sync REST)
|
||||||
20
backlog/multi-column-layout.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Multi-column layout reading order
|
||||||
|
|
||||||
|
## Why deferred
|
||||||
|
|
||||||
|
Many Text PDFs are single-column policies. Multi-column (or sidebar) layouts can make naive top-to-bottom extraction interleave columns.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Chunk Preview shows sentences from column A mixed with column B; Experiment Faithfulness drops on those docs only.
|
||||||
|
|
||||||
|
## Approach to try
|
||||||
|
|
||||||
|
1. Use PyMuPDF block/bbox clustering to detect columns
|
||||||
|
2. Read column-by-column (right-to-left for Farsi multi-column if applicable)
|
||||||
|
3. Add a fixture PDF with known two-column layout to regression tests
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Reading order matches human reading on the fixture
|
||||||
|
- No regression on single-column Farsi docs
|
||||||
20
backlog/page-metadata-in-chunks.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Page metadata in chunks
|
||||||
|
|
||||||
|
## Why deferred
|
||||||
|
|
||||||
|
v1 Strategies and Qdrant payloads don’t need page numbers. Page-as-section markdown was rejected (arbitrary cuts). Page info is citation UX, not chunking semantics.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Users (or the chatbot) need “see page N” citations, or Chunk Preview should show page ranges per chunk.
|
||||||
|
|
||||||
|
## Approach to try
|
||||||
|
|
||||||
|
1. During PDF parse, map character/block offsets → page numbers
|
||||||
|
2. Attach optional `page_start` / `page_end` on Chunk / ChunkMetadata (nullable for DOCX)
|
||||||
|
3. Do **not** inject `## Page N` into markdown unless a Strategy specifically needs it
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- DOCX path unchanged (null pages)
|
||||||
|
- Query/answer path can cite pages without changing Strategy ranking logic
|
||||||
20
backlog/pdfplumber-tables.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# pdfplumber for tables
|
||||||
|
|
||||||
|
## Why deferred
|
||||||
|
|
||||||
|
v1 uses PyMuPDF only. Dual extractors add complexity before we know tables are the failure mode.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Chunk Preview / Experiments show table cells concatenated in wrong order, or key cells missing, on table-heavy insurance PDFs — after Heading Reconstruction is otherwise fine.
|
||||||
|
|
||||||
|
## Approach to try
|
||||||
|
|
||||||
|
1. Keep PyMuPDF for body text + Heading Reconstruction
|
||||||
|
2. Detect table regions (PyMuPDF or pdfplumber) and extract those via pdfplumber
|
||||||
|
3. Flatten tables the same way DOCX does (sequential text blocks), not HTML tables in markdown (unless Strategies learn to use them)
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Questions that answer from table cells improve without regressing prose headings
|
||||||
|
- No second full-document parse path unless necessary
|
||||||
22
backlog/persian-cloud-ocr.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Persian / cloud OCR
|
||||||
|
|
||||||
|
## Why deferred
|
||||||
|
|
||||||
|
Local OCR (e.g. Tesseract + `fas`/`fa`) may be good enough — or may destroy RTL, digits, and legal phrasing. Don’t commit to a vendor until measured.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Local OCR on a Farsi Scanned PDF sample set produces systematically bad characters, broken line order, or unusable tables.
|
||||||
|
|
||||||
|
## Options to A/B
|
||||||
|
|
||||||
|
| Option | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| Tesseract + Persian models | Free, offline, no data leaving the box | Quality varies; tuning burden |
|
||||||
|
| Cloud OCR (Google / Azure / etc.) | Often better on Persian print | Cost, latency, compliance |
|
||||||
|
| Commercial Persian-focused OCR | Domain fit | Vendor lock-in |
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Character error rate low enough that answer Faithfulness in Experiments doesn’t collapse vs Text PDF baseline
|
||||||
|
- RTL paragraphs stay coherent in Chunk Preview
|
||||||
25
backlog/scanned-pdf-ocr.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Scanned PDF OCR (v1.1+)
|
||||||
|
|
||||||
|
## Why deferred
|
||||||
|
|
||||||
|
v1 only accepts Text PDFs and applies a Text-layer Gate: Scanned PDFs are hard-rejected at upload (not stored, not processed). OCR is a second product surface: noisy text poisons every Strategy equally and makes Experiment comparisons misleading.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Pick this up when:
|
||||||
|
|
||||||
|
1. Text PDF path is trusted on real docs
|
||||||
|
2. You have ~10–20 labeled Scanned PDFs + question JSONs matching chatbot traffic
|
||||||
|
3. Production coverage of those docs matters more than clean benchmarks alone
|
||||||
|
|
||||||
|
## Approach to try
|
||||||
|
|
||||||
|
1. Detect empty / near-empty text layer at upload (same gate as v1 rejection)
|
||||||
|
2. Run OCR as an **explicit pre-step** that still emits `ParseResult` (markdown + DocumentTree)
|
||||||
|
3. Tag source as `ocr` so Experiments can filter Text PDF vs Scanned PDF runs
|
||||||
|
4. Reuse Heading Reconstruction heuristics on OCR text (expect more false positives)
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- OCR’d markdown is readable enough that recursive Strategy headings aren’t nonsense
|
||||||
|
- Side-by-side Experiment: Text PDF original vs OCR of a scan of the same doc — score delta understood, not mysterious
|
||||||
280
docs/HLD.md
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
# High-Level Design (HLD)
|
||||||
|
|
||||||
|
RAG Chunking Benchmarker — system purpose, boundaries, components, and major flows.
|
||||||
|
|
||||||
|
**Audience:** architects, tech leads, new engineers
|
||||||
|
**Companion:** [LLD](LLD.md) · [ADRs](adr/) · [CONTEXT.md](../CONTEXT.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Compare five chunking Strategies on regulatory (and similar) documents under a controlled RAG pipeline: ingest → chunk → embed → retrieve → answer → LLM-as-Judge evaluation.
|
||||||
|
|
||||||
|
Operators use a Dashboard to upload documents, process Strategies, run Queries and Experiments, inspect retrieval, and choose a final Strategy Candidate on the Decision Board.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals and Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
| Goal | Notes |
|
||||||
|
|------|--------|
|
||||||
|
| Fair Strategy comparison | Same document, questions, Corpus Embedding Model, and Neighbor Expansion knobs per Experiment |
|
||||||
|
| Auditable provenance | Experiments record Corpus (+ Boundary when semantic Strategies were processed) |
|
||||||
|
| Operator UX | Dashboard at `/app` replaces Swagger as primary UI |
|
||||||
|
| Embedding flexibility | Cloud (OpenAI) and Local (Ollama) via Embedding Model Registry |
|
||||||
|
| Failure isolation | One Strategy failing during process does not discard others |
|
||||||
|
|
||||||
|
### Non-Goals (v1)
|
||||||
|
|
||||||
|
| Out of scope | See |
|
||||||
|
|--------------|-----|
|
||||||
|
| Scanned PDF / OCR | [out-of-scope-v1.md](out-of-scope-v1.md), backlog |
|
||||||
|
| Multi-tenant auth | Single-operator local tool |
|
||||||
|
| Production-scale concurrency | SQLite + single process |
|
||||||
|
| Comparing Experiments across different Boundary/Corpus/Neighbor settings without warning | Compare UI warns; Decision Board filters by Corpus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. System Context
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ HTTP/REST ┌─────────────────────────────────────┐
|
||||||
|
│ Operator │◄──────────────────►│ FastAPI App (RAG Chunking Benchmarker)│
|
||||||
|
│ (Browser) │ Dashboard /app │ │
|
||||||
|
└──────────────┘ │ Documents · Chunking · Benchmarking │
|
||||||
|
│ Admin · Static Dashboard │
|
||||||
|
└───────────┬─────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────┼─────────────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
|
||||||
|
│ SQLite │ │ Qdrant │ │ LLM / Embed │
|
||||||
|
│ metadata, │ │ Model Corpus │ │ OpenAI Cloud │
|
||||||
|
│ Experiments, │ │ vectors + │ │ + Ollama Local│
|
||||||
|
│ app_settings │ │ chunk payload │ └────────────────┘
|
||||||
|
└────────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**External dependencies**
|
||||||
|
|
||||||
|
| System | Role |
|
||||||
|
|--------|------|
|
||||||
|
| OpenAI API | Corpus/Boundary embeddings (cloud models); answer generation; LLM-as-Judge |
|
||||||
|
| Ollama | Local Embedding Model Provider (`nomic-embed-text-v2-moe`) |
|
||||||
|
| Qdrant | Vector search per Strategy × Corpus Embedding Model |
|
||||||
|
| LibreOffice (optional) | Legacy `.doc` → `.docx` conversion path in parser |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Logical Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer │
|
||||||
|
│ src/static/index.html — single-file React Dashboard (CDN, no build) │
|
||||||
|
│ Tabs: Home · Documents · Query · Benchmarks · Decision · PDF · Admin │
|
||||||
|
└────────────────────────────────┬─────────────────────────────────────────┘
|
||||||
|
│ REST
|
||||||
|
┌────────────────────────────────▼─────────────────────────────────────────┐
|
||||||
|
│ API Layer (FastAPI) │
|
||||||
|
│ /documents* /queries* /benchmarks* /experiments* /admin/* │
|
||||||
|
│ /strategies /app (StaticFiles) │
|
||||||
|
└────────────────────────────────┬─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────────────▼─────────────────────────────────────────┐
|
||||||
|
│ Domain Services │
|
||||||
|
│ documents/service chunking/service query_service benchmark_service│
|
||||||
|
│ evaluation admin/service report │
|
||||||
|
└────────────────────────────────┬─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────────────▼─────────────────────────────────────────┐
|
||||||
|
│ Foundation │
|
||||||
|
│ core/config · models · exceptions · dependencies │
|
||||||
|
│ chunking/base · embedding · embedding_models · strategies/* │
|
||||||
|
│ documents/parser · pdf_parser │
|
||||||
|
│ storage/sqlite · storage/qdrant │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Major Components
|
||||||
|
|
||||||
|
| Component | Responsibility | Key modules |
|
||||||
|
|-----------|----------------|-------------|
|
||||||
|
| **Dashboard** | Operator UI; persistent Tabs; Decision Board; Retrieval Inspect; PDF Workspace | `src/static/index.html` |
|
||||||
|
| **Documents** | Upload/parse Word & Text PDF; process Strategies; delete | `documents/*` |
|
||||||
|
| **Chunking** | Strategy registry; Boundary vs Corpus embeds; chunk → embed → Qdrant | `chunking/*` |
|
||||||
|
| **Query** | Embed question → search → Neighbor Expansion → LLM answer | `benchmarking/query_service.py` |
|
||||||
|
| **Benchmark** | Question × Strategy Experiment; aggregate metrics; HTML reports | `benchmarking/benchmark_service.py`, `report.py` |
|
||||||
|
| **Evaluation** | LLM-as-Judge (context relevance, answer similarity, faithfulness, hallucination) | `benchmarking/evaluation.py` |
|
||||||
|
| **Admin** | Health, Embedding Model defaults/thresholds, Qdrant CRUD, Chunk Preview, questions files, cost estimate | `admin/*` |
|
||||||
|
| **Storage** | SQLite metadata; Qdrant Model Corpus | `storage/*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Domain Concepts (summary)
|
||||||
|
|
||||||
|
Full glossary: [CONTEXT.md](../CONTEXT.md).
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Strategy** | One of: `fixed_size`, `recursive`, `semantic`, `contextual_retrieval`, `semantic_parent_child` |
|
||||||
|
| **Experiment** | Completed benchmark run for one document × N Strategies × M questions, with provenance |
|
||||||
|
| **Boundary Embedding Model** | Used only for Semantic Boundary Detection |
|
||||||
|
| **Corpus Embedding Model** | Embeds finished chunks and queries; scopes Qdrant collections |
|
||||||
|
| **Model Corpus** | Collections `{strategy}__{model_id}_collection` for one Corpus model |
|
||||||
|
| **Neighbor Expansion** | Query-time prev/next chunks for `fixed_size` only (`±P/N`) |
|
||||||
|
| **Expansion Tree** | Per-hit neighbor grouping for operator audit (vs flat LLM context) |
|
||||||
|
| **Strategy Candidate** | Comparable config for Decision Board (`fixed_size±N` or `semantic@Boundary`) |
|
||||||
|
| **Decision Board** | Two-stage selection over the fixed evaluation document set |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. End-to-End Flows
|
||||||
|
|
||||||
|
### 7.1 Ingest and process
|
||||||
|
|
||||||
|
```
|
||||||
|
Upload (.docx/.doc/.pdf)
|
||||||
|
→ Text-layer Gate (PDF) / parse DOCX
|
||||||
|
→ Heading Reconstruction (PDF) → DocumentTree + markdown
|
||||||
|
→ SQLite documents
|
||||||
|
|
||||||
|
Process(strategies, boundary?, corpus?)
|
||||||
|
→ For each Strategy (isolated failures):
|
||||||
|
Boundary embeds (semantic*) → Strategy.chunk()
|
||||||
|
→ Corpus embeds → Qdrant Model Corpus upsert
|
||||||
|
→ Update chunk_counts + last_* embedding provenance on document
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Query
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /queries
|
||||||
|
→ Resolve Corpus Embedding Model
|
||||||
|
→ Embed question (Corpus)
|
||||||
|
→ Qdrant top-k (document filter)
|
||||||
|
→ Neighbor Expansion if fixed_size (±P/N)
|
||||||
|
→ Build context (parent fetch for semantic_parent_child)
|
||||||
|
→ LLM answer → SQLite queries (+ expansion_tree)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Experiment
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /benchmarks
|
||||||
|
→ Load questions JSON
|
||||||
|
→ For each question × Strategy: Query + evaluate_single
|
||||||
|
→ Aggregate metrics → SQLite experiments
|
||||||
|
→ Optional HTML report (managerial / technical)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Decision
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard Decision Tab
|
||||||
|
→ Load Experiments filtered by Corpus Embedding Model
|
||||||
|
→ Discover Strategy Candidates (newest per doc × Candidate)
|
||||||
|
→ Stage 1: best fixed_size ±N vs best semantic@Boundary
|
||||||
|
→ Stage 2: compare winners + per-document breakdown
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Architecture
|
||||||
|
|
||||||
|
### 8.1 Responsibility split
|
||||||
|
|
||||||
|
| Store | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| **SQLite** | Documents (tree + markdown), queries, Experiments, `app_settings` (Embedding defaults, thresholds) |
|
||||||
|
| **Qdrant** | Vectors + chunk text payload (no vectors in SQLite) |
|
||||||
|
| **Filesystem** | Question JSON under `files/`; uploaded bytes are not retained after parse |
|
||||||
|
|
||||||
|
### 8.2 Model Corpus naming
|
||||||
|
|
||||||
|
```
|
||||||
|
{strategy}__{sanitized_model_id}_collection
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `fixed_size__text-embedding-3-large_collection`
|
||||||
|
|
||||||
|
Process, Query, and Experiment only read/write the Corpus Embedding Model in force. Other corpora remain untouched.
|
||||||
|
|
||||||
|
### 8.3 Provenance
|
||||||
|
|
||||||
|
Experiments store:
|
||||||
|
|
||||||
|
- `embedding_model_id` / `embedding_provider` — Corpus
|
||||||
|
- `boundary_embedding_model_id` — when semantic Strategies were on the process path for that document
|
||||||
|
- Neighbor Expansion levels in `benchmark_config`
|
||||||
|
|
||||||
|
Documents store `last_corpus_embedding_model_id` and `last_boundary_embedding_model_id` after process.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Deployment View
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Host process │
|
||||||
|
│ uvicorn src.main:app │
|
||||||
|
│ └── FastAPI + Dashboard static │
|
||||||
|
│ │
|
||||||
|
│ data/chunking_benchmark.db │
|
||||||
|
│ .env (secrets + knobs) │
|
||||||
|
└───────────────┬─────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
▼ ▼
|
||||||
|
Qdrant (:6333) OpenAI / Ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical local stack: app + Qdrant (Docker or native) + optional Ollama host. No build step for the Dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Cross-Cutting Concerns
|
||||||
|
|
||||||
|
| Concern | Approach |
|
||||||
|
|---------|----------|
|
||||||
|
| Config | `pydantic-settings` from `.env` ([configuration.md](configuration.md)) |
|
||||||
|
| Errors | Domain exceptions → HTTP 400 handlers (`ChunkingError`, `BenchmarkError`, `QueryError`, …) |
|
||||||
|
| Logging | Request middleware + structured stage logs in query/benchmark |
|
||||||
|
| CORS | Permissive (`*`) for local Dashboard development |
|
||||||
|
| Cost | Dry-run `/benchmarks` and `/admin/cost-estimate`; Local embeds = $0 embedding side |
|
||||||
|
| Extensibility | New Strategy = implement `ChunkingStrategy` + registry; new Embedding Model = registry entry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Key Architectural Decisions
|
||||||
|
|
||||||
|
| ADR | Decision |
|
||||||
|
|-----|----------|
|
||||||
|
| 0001–0009 | CDN React Dashboard; `/admin` for ops gaps; top Tabs; dark amber theme |
|
||||||
|
| 0016–0017 | Text PDF via PyMuPDF; PDF Workspace via format-filtered shared sections |
|
||||||
|
| 0020 | Semantic Boundary Detection required (no fixed-count fallback) |
|
||||||
|
| 0021 | Always scope Qdrant collections by Embedding Model id |
|
||||||
|
| 0022 | Per-model `semantic_threshold` |
|
||||||
|
| 0023 | Neighbor Expansion + Expansion Tree for `fixed_size` |
|
||||||
|
| 0024 | Boundary vs Corpus Embedding Model roles |
|
||||||
|
| 0025 | Retrieval Inspect in Benchmarks Tab |
|
||||||
|
| 0026 | Decision Board two-stage Candidate selection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Related Documents
|
||||||
|
|
||||||
|
| Doc | Role |
|
||||||
|
|-----|------|
|
||||||
|
| [LLD.md](LLD.md) | Module interfaces, schemas, algorithms |
|
||||||
|
| [architecture.md](architecture.md) | Older overview (prefer HLD for current shape) |
|
||||||
|
| [data-flow.md](data-flow.md) | Pipeline detail |
|
||||||
|
| [api-reference.md](api-reference.md) | HTTP contracts |
|
||||||
|
| [strategy-technical-details.md](strategy-technical-details.md) | Per-Strategy algorithms |
|
||||||
|
| [evaluation-metrics.md](evaluation-metrics.md) | Scoring definitions |
|
||||||
|
| [CONTEXT.md](../CONTEXT.md) | Ubiquitous language |
|
||||||
571
docs/LLD.md
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
# Low-Level Design (LLD)
|
||||||
|
|
||||||
|
RAG Chunking Benchmarker — module interfaces, data schemas, algorithms, and API contracts.
|
||||||
|
|
||||||
|
**Audience:** implementers
|
||||||
|
**Companion:** [HLD](HLD.md) · [API Reference](api-reference.md) · [CONTEXT.md](../CONTEXT.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Package Map
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.py # App factory, middleware, router mount
|
||||||
|
├── static/index.html # Dashboard (React + Babel CDN)
|
||||||
|
├── core/
|
||||||
|
│ ├── config.py # Settings (.env)
|
||||||
|
│ ├── models.py # Chunk, DocumentTree, StrategyName
|
||||||
|
│ ├── exceptions.py # Domain errors + handlers
|
||||||
|
│ └── dependencies.py # OpenAI / Ollama / Qdrant singletons
|
||||||
|
├── storage/
|
||||||
|
│ ├── sqlite.py # documents, queries, experiments, app_settings
|
||||||
|
│ └── qdrant.py # Model Corpus CRUD + search
|
||||||
|
├── documents/
|
||||||
|
│ ├── parser.py # DOCX / .doc (+ LibreOffice)
|
||||||
|
│ ├── pdf_parser.py # Text PDF + Text-layer Gate
|
||||||
|
│ ├── heading_heuristics.py # PDF Heading Reconstruction helpers
|
||||||
|
│ ├── service.py # Upload / process / delete
|
||||||
|
│ ├── routes.py
|
||||||
|
│ └── models.py # ProcessRequest, DocumentResponse, …
|
||||||
|
├── chunking/
|
||||||
|
│ ├── base.py # ChunkingStrategy ABC, tokens, sentences
|
||||||
|
│ ├── embedding.py # Boundary/Corpus resolve + embed_texts
|
||||||
|
│ ├── embedding_models.py # Registry, thresholds, task prefixes
|
||||||
|
│ ├── service.py # run_strategies orchestration
|
||||||
|
│ └── strategies/
|
||||||
|
│ ├── fixed_size.py
|
||||||
|
│ ├── recursive.py
|
||||||
|
│ ├── semantic.py
|
||||||
|
│ ├── contextual_retrieval.py
|
||||||
|
│ └── semantic_parent_child.py
|
||||||
|
├── benchmarking/
|
||||||
|
│ ├── query_service.py # Query + Neighbor Expansion
|
||||||
|
│ ├── benchmark_service.py # Experiment runner + cost estimate
|
||||||
|
│ ├── evaluation.py # LLM-as-Judge
|
||||||
|
│ ├── report.py # HTML reports
|
||||||
|
│ ├── routes.py
|
||||||
|
│ └── models.py
|
||||||
|
└── admin/
|
||||||
|
├── routes.py # /admin/*
|
||||||
|
└── service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Application Bootstrap
|
||||||
|
|
||||||
|
**Entry:** `create_app()` in `src/main.py`
|
||||||
|
|
||||||
|
1. `init_db()` — create/migrate SQLite schema
|
||||||
|
2. Register CORS + `RequestLoggingMiddleware`
|
||||||
|
3. Exception handlers: `ChunkingError`, `BenchmarkError`, `QueryError`
|
||||||
|
4. Routers: documents, benchmarking, admin
|
||||||
|
5. Mount `StaticFiles` at `/app` → `src/static/`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Singletons (lru_cache)
|
||||||
|
get_openai_client() # Cloud LLM + cloud embeds
|
||||||
|
get_ollama_client() # OpenAI-compatible client → Ollama base URL
|
||||||
|
get_qdrant_client()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration
|
||||||
|
|
||||||
|
`Settings` (`src/core/config.py`) — selected fields:
|
||||||
|
|
||||||
|
| Field | Default | Use |
|
||||||
|
|-------|---------|-----|
|
||||||
|
| `openai_api_key` | required | Cloud Provider |
|
||||||
|
| `embedding_model` | `text-embedding-3-large` | Config legacy; runtime uses Registry + Admin |
|
||||||
|
| `llm_model` | `gpt-4o-mini` | Answer + judge |
|
||||||
|
| `ollama_base_url` | host URL | Local embeds |
|
||||||
|
| `qdrant_url` | `http://localhost:6333` | Vectors |
|
||||||
|
| `top_k` | `5` | Retrieval |
|
||||||
|
| `neighbor_prev` / `neighbor_next` | `3` / `3` | Expansion defaults (fixed_size ±3) |
|
||||||
|
| `chunk_size` / `chunk_overlap` | `512` / `50` | fixed_size / recursive targets |
|
||||||
|
| `semantic_threshold` | `0.3` | Fallback if model default missing |
|
||||||
|
| `semantic_min_chunk_size` | `3` | Min units per semantic chunk |
|
||||||
|
| `database_url` | `sqlite:///./data/chunking_benchmark.db` | Metadata |
|
||||||
|
| `pdf_min_total_chars` / `pdf_min_median_chars_per_page` | `100` / `40` | Text-layer Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Core Domain Models
|
||||||
|
|
||||||
|
### 4.1 StrategyName
|
||||||
|
|
||||||
|
```python
|
||||||
|
class StrategyName(str, Enum):
|
||||||
|
RECURSIVE = "recursive"
|
||||||
|
FIXED_SIZE = "fixed_size"
|
||||||
|
SEMANTIC = "semantic"
|
||||||
|
CONTEXTUAL_RETRIEVAL = "contextual_retrieval"
|
||||||
|
SEMANTIC_PARENT_CHILD = "semantic_parent_child"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Chunk (unified)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `document_name` | str | Source filename |
|
||||||
|
| `chunk_id` | str | `{strategy}_{safe_doc}_{index:06d}` |
|
||||||
|
| `strategy_name` | StrategyName | |
|
||||||
|
| `chunk_index` | int | Document order (used by Neighbor Expansion) |
|
||||||
|
| `text` | str | Stored in Qdrant; used for LLM context |
|
||||||
|
| `token_count` / `character_count` | int | tiktoken `cl100k_base` |
|
||||||
|
| `parent_id` | str \| None | Parent-child Strategy |
|
||||||
|
| `enriched_content` | str \| None | Contextual retrieval embed text (not always in payload) |
|
||||||
|
|
||||||
|
`chunk_to_metadata()` drops `enriched_content` for Qdrant payload (`ChunkMetadata`).
|
||||||
|
|
||||||
|
### 4.3 DocumentTree
|
||||||
|
|
||||||
|
```
|
||||||
|
DocumentTree
|
||||||
|
└── root: DocumentTreeNode
|
||||||
|
node_type: document | section | article | paragraph
|
||||||
|
text, heading, heading_level, children[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Serialized as JSON in SQLite `documents.document_tree`. Chunking primarily uses `parsed_text` (markdown); tree supports structure-aware Strategies and preview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Storage LLD
|
||||||
|
|
||||||
|
### 5.1 SQLite schema
|
||||||
|
|
||||||
|
**documents**
|
||||||
|
|
||||||
|
| Column | Type | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `id` | TEXT PK | UUID hex |
|
||||||
|
| `filename` | TEXT | Original name |
|
||||||
|
| `parsed_text` | TEXT | Markdown for chunking |
|
||||||
|
| `document_tree` | TEXT | JSON tree |
|
||||||
|
| `chunk_counts` | TEXT | JSON `{strategy: count}` |
|
||||||
|
| `last_corpus_embedding_model_id` | TEXT | Provenance |
|
||||||
|
| `last_boundary_embedding_model_id` | TEXT | Provenance |
|
||||||
|
| `created_at` | TEXT | ISO UTC |
|
||||||
|
|
||||||
|
**queries**
|
||||||
|
|
||||||
|
| Column | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `id`, `document_id`, `strategy_name` | Identity |
|
||||||
|
| `question`, `answer` | Content |
|
||||||
|
| `retrieved_chunks` | Flat LLM/eval list (JSON) |
|
||||||
|
| `expansion_tree` | Per-hit neighbors (JSON) |
|
||||||
|
| `latency_breakdown`, `token_usage` | Observability JSON |
|
||||||
|
| `created_at` | |
|
||||||
|
|
||||||
|
**experiments**
|
||||||
|
|
||||||
|
| Column | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `id`, `document_id` | Identity |
|
||||||
|
| `benchmark_config` | top_k, neighbor_prev/next, etc. |
|
||||||
|
| `questions`, `per_question` | Inputs + per-cell results |
|
||||||
|
| `aggregate_metrics` | Per-Strategy averages |
|
||||||
|
| `strategies_used` | JSON list |
|
||||||
|
| `embedding_model_id`, `embedding_provider` | Corpus snapshot |
|
||||||
|
| `boundary_embedding_model_id` | Boundary snapshot (nullable) |
|
||||||
|
| `created_at` | |
|
||||||
|
|
||||||
|
**app_settings** — key/value for:
|
||||||
|
|
||||||
|
- `corpus_embedding_model_id`, `boundary_embedding_model_id`
|
||||||
|
- Legacy `active_embedding_model_id` (migrated into both roles)
|
||||||
|
- `semantic_threshold:{model_id}` overrides
|
||||||
|
|
||||||
|
Connection: WAL mode, foreign keys ON, one connection per call (no pool).
|
||||||
|
|
||||||
|
### 5.2 Qdrant
|
||||||
|
|
||||||
|
**Collection name**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def collection_name(strategy, model_id) -> str:
|
||||||
|
return f"{strategy}__{sanitize(model_id)}_collection"
|
||||||
|
# sanitize: replace : and / with -
|
||||||
|
```
|
||||||
|
|
||||||
|
**Point**
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `id` | `uuid5(NAMESPACE_URL, chunk_id)` |
|
||||||
|
| `vector` | Corpus embedding (dim from model: 1536 / 3072 / 768) |
|
||||||
|
| `payload` | ChunkMetadata fields |
|
||||||
|
|
||||||
|
**Search**
|
||||||
|
|
||||||
|
- Cosine distance
|
||||||
|
- Optional filter: `document_name == filename`
|
||||||
|
- Returns payload + score
|
||||||
|
|
||||||
|
**Neighbor fetch:** `get_chunks_by_indices(strategy, document_name, indices, model_id)` for Expansion.
|
||||||
|
|
||||||
|
**Admin:** list/create/delete collections; wipe points.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Embedding Subsystem
|
||||||
|
|
||||||
|
### 6.1 Registry (`embedding_models.py`)
|
||||||
|
|
||||||
|
| id | Provider | Dimension | Default semantic_threshold | Task prefixes |
|
||||||
|
|----|----------|-----------|----------------------------|---------------|
|
||||||
|
| `text-embedding-3-small` | cloud | 1536 | 0.3 | no |
|
||||||
|
| `text-embedding-3-large` | cloud | 3072 | 0.3 | no (default Admin) |
|
||||||
|
| `nomic-embed-text-v2-moe` | local | 768 | 0.6 | yes (`search_document:` / `search_query:`) |
|
||||||
|
|
||||||
|
`EmbeddingModelSpec`: `id`, `provider`, `model_name`, `dimension`, `display_name`, `task_prefixes`, `default_semantic_threshold`.
|
||||||
|
|
||||||
|
### 6.2 Role resolution (`embedding.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
resolve_corpus_model(optional_id) → snapshot for process/query/experiment
|
||||||
|
resolve_boundary_model(optional_id) → snapshot for semantic cuts
|
||||||
|
|
||||||
|
get_corpus_embedding_model() / get_boundary_embedding_model()
|
||||||
|
→ app_settings → registry → DEFAULT_CLOUD_MODEL_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin switches persist to SQLite; mid-flight Admin changes do not affect an in-progress operation that already resolved snapshots.
|
||||||
|
|
||||||
|
### 6.3 embed_texts / embed_single
|
||||||
|
|
||||||
|
1. `apply_task_prefixes(texts, model, purpose)` if needed
|
||||||
|
2. Batch: OpenAI 2048 / Ollama 64
|
||||||
|
3. Route to `get_openai_client()` or `get_ollama_client()` by Provider
|
||||||
|
4. Raise `EmbeddingError` on failure
|
||||||
|
|
||||||
|
**Threshold:** `get_semantic_threshold(model_id)` = Admin override → registry default. Boundary Strategy cuts **always** use Boundary model's threshold (never Corpus).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Documents LLD
|
||||||
|
|
||||||
|
### 7.1 Upload
|
||||||
|
|
||||||
|
```
|
||||||
|
upload_document(filename, bytes)
|
||||||
|
→ temp file → parse_document(path)
|
||||||
|
→ db.save_document(parsed_text=markdown, document_tree=JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supported suffixes:** `.docx`, `.doc`, `.pdf` (`SUPPORTED_SUFFIXES`).
|
||||||
|
|
||||||
|
**PDF path (`pdf_parser.py`):**
|
||||||
|
|
||||||
|
1. Text-layer Gate (`pdf_min_total_chars`, `pdf_min_median_chars_per_page`) — reject Scanned PDF
|
||||||
|
2. Extract text + Heading Reconstruction (outline → font → Farsi/English heuristics)
|
||||||
|
3. Table Flattening to sequential plain text
|
||||||
|
4. Emit markdown `#` / `##` + DocumentTree
|
||||||
|
|
||||||
|
### 7.2 Process
|
||||||
|
|
||||||
|
```
|
||||||
|
ProcessRequest:
|
||||||
|
strategies: list[StrategyName] # default all five
|
||||||
|
boundary_model_id: str | None
|
||||||
|
corpus_model_id: str | None
|
||||||
|
|
||||||
|
process_document → chunking.service.run_strategies(...)
|
||||||
|
→ ProcessResponse(completed[], failed[], corpus_*, boundary_*)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Delete
|
||||||
|
|
||||||
|
Deletes SQLite row (cascade queries/experiments) and Qdrant points for that document across known collections (implementation in `documents/service.py` + `qdrant`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Chunking LLD
|
||||||
|
|
||||||
|
### 8.1 Interface
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChunkingStrategy(ABC):
|
||||||
|
name: StrategyName
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def chunk(*, doc_name: str, tree: DocumentTree, markdown: str) -> list[Chunk]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Semantic Strategies accept extra kwargs from orchestrator (`sentence_embeddings` / `paragraph_embeddings`, `semantic_threshold`).
|
||||||
|
|
||||||
|
### 8.2 Orchestrator (`run_strategies`)
|
||||||
|
|
||||||
|
```
|
||||||
|
corpus = resolve_corpus_model(corpus_model_id)
|
||||||
|
boundary = resolve_boundary_model(...) if any(semantic*) else unused
|
||||||
|
|
||||||
|
for strategy in strategies:
|
||||||
|
try:
|
||||||
|
ensure_collection(strategy, corpus.id, corpus.dimension)
|
||||||
|
chunks = _chunk_document(...) # inject Boundary embeds if needed
|
||||||
|
texts = enriched_content or text
|
||||||
|
embeddings = embed_texts(texts, model=corpus, purpose="document")
|
||||||
|
upsert_chunks(chunks, embeddings, model_id=corpus.id)
|
||||||
|
except → failed[] (others continue)
|
||||||
|
|
||||||
|
update_chunk_counts + update_process_embedding_provenance
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Strategy algorithms (summary)
|
||||||
|
|
||||||
|
| Strategy | Input units | Boundary | Output |
|
||||||
|
|----------|-------------|----------|--------|
|
||||||
|
| **fixed_size** | markdown tokens | none | sliding window `chunk_size` / `chunk_overlap` |
|
||||||
|
| **recursive** | markdown | cascade `#` → `\n\n` → `\n` → punct → space | merge up to target size |
|
||||||
|
| **semantic** | sentences (`split_sentences`, Farsi-aware) | adjacent cosine vs Boundary threshold; `semantic_min_chunk_size` | joined sentence groups |
|
||||||
|
| **contextual_retrieval** | base chunks + LLM context prefix | none | `text` = original; `enriched_content` = prefix+text for embed |
|
||||||
|
| **semantic_parent_child** | paragraphs | paragraph cosine vs Boundary threshold | parents + children with `parent_id` |
|
||||||
|
|
||||||
|
Semantic Strategies **fail hard** if Boundary embeddings missing/mismatched (ADR-0020) — no fixed-count fallback.
|
||||||
|
|
||||||
|
**Sentence split fallback:** punctuation → non-empty lines → blank-line paragraphs → single unit.
|
||||||
|
|
||||||
|
Deep dive: [strategy-technical-details.md](strategy-technical-details.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Query LLD
|
||||||
|
|
||||||
|
### 9.1 `run_query` pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Load document (filename for filter)
|
||||||
|
2. Resolve Corpus model
|
||||||
|
3. embed_single(question, purpose="query")
|
||||||
|
4. qdr.search(strategy, vector, top_k, document_filter=filename, model_id)
|
||||||
|
5. apply_neighbor_expansion(...) → retrieved_chunks, expansion_tree
|
||||||
|
6. _build_context (parent scroll for semantic_parent_child)
|
||||||
|
7. _generate_answer (settings.llm_model, temp 0)
|
||||||
|
8. db.save_query(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Neighbor Expansion (`apply_neighbor_expansion`)
|
||||||
|
|
||||||
|
**Applies only when** `strategy == fixed_size` and `(neighbor_prev > 0 or neighbor_next > 0)`.
|
||||||
|
|
||||||
|
1. Build Expansion Tree nodes for each top-k hit (score order)
|
||||||
|
2. Collect needed `chunk_index` values: hit±prev/next (skip < 0)
|
||||||
|
3. Fetch missing indices via Qdrant
|
||||||
|
4. Fill `neighbors_prev` / `neighbors_next` per hit
|
||||||
|
5. Flat list: hits + neighbors, **dedupe by chunk_id** (prefer hit), sort by `chunk_index`
|
||||||
|
6. Neighbors have `role="neighbor"`, `score=None`
|
||||||
|
|
||||||
|
Knobs ignored for non-`fixed_size` Strategies (tree still returns hits with empty neighbor arrays).
|
||||||
|
|
||||||
|
### 9.3 Parent-child context
|
||||||
|
|
||||||
|
For `semantic_parent_child`, scroll Qdrant by `chunk_id == parent_id` and append parent text under each child in the prompt context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Benchmark LLD
|
||||||
|
|
||||||
|
### 10.1 Questions format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": "q1",
|
||||||
|
"question": "...",
|
||||||
|
"expected_answer": "...",
|
||||||
|
"category": "...",
|
||||||
|
"difficulty": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Load from `questions_file` path or inline `questions` list.
|
||||||
|
|
||||||
|
### 10.2 `run_benchmark`
|
||||||
|
|
||||||
|
```
|
||||||
|
embedding_model = resolve_corpus_model(corpus_model_id)
|
||||||
|
boundary_id = doc.last_boundary_embedding_model_id # if semantic in strategies
|
||||||
|
|
||||||
|
for question in questions:
|
||||||
|
for strategy in strategies:
|
||||||
|
result = run_query(..., neighbor_prev, neighbor_next, embedding_model=...)
|
||||||
|
scores = evaluate_single(question, retrieved_chunks, expected, generated)
|
||||||
|
append per_question row
|
||||||
|
|
||||||
|
aggregate_metrics[strategy] = averages + hallucination_rate
|
||||||
|
best_strategy = argmax (e.g. answer_similarity / composite — see service)
|
||||||
|
save experiment with provenance + benchmark_config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Evaluation (`evaluate_single`)
|
||||||
|
|
||||||
|
LLM returns JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"context_relevance": 1-10,
|
||||||
|
"answer_similarity": 1-10,
|
||||||
|
"faithfulness": 1-10,
|
||||||
|
"hallucination": true|false,
|
||||||
|
"reasoning": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`response_format=json_object`, temperature 0. Metrics definitions: [evaluation-metrics.md](evaluation-metrics.md).
|
||||||
|
|
||||||
|
### 10.4 Reports
|
||||||
|
|
||||||
|
`report.py` renders managerial or technical HTML from an Experiment (rankings, KPIs, Expansion Tree samples, token usage).
|
||||||
|
|
||||||
|
### 10.5 Cost estimate
|
||||||
|
|
||||||
|
`estimate_cost(num_questions, num_strategies)` — heuristic USD; Local Corpus → embedding cost 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Admin LLD
|
||||||
|
|
||||||
|
| Endpoint | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| `GET /admin/health` | App + Qdrant + SQLite ping |
|
||||||
|
| `GET /admin/embedding-models` | Registry + Boundary/Corpus defaults + thresholds |
|
||||||
|
| `PUT .../corpus` · `.../boundary` | Set role defaults |
|
||||||
|
| `PUT .../{id}/semantic-threshold` | Persist override `(0, 1]` |
|
||||||
|
| `GET/POST/DELETE /admin/qdrant/collections*` | Collection CRUD + wipe points |
|
||||||
|
| `GET /admin/chunks/{doc_id}?strategy=` | Chunk Preview from Qdrant |
|
||||||
|
| `GET/POST/DELETE /admin/questions*` | Manage `files/*.json` |
|
||||||
|
| `POST /admin/cost-estimate` | Same heuristic as dry-run |
|
||||||
|
|
||||||
|
Admin does **not** duplicate document/query/benchmark domain endpoints (ADR-0006).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Dashboard LLD (behavioral)
|
||||||
|
|
||||||
|
| Concern | Design |
|
||||||
|
|---------|--------|
|
||||||
|
| Delivery | One `index.html`; React + ReactDOM + Babel from CDN |
|
||||||
|
| Navigation | Top Tabs; components stay mounted (`useState` on `App`) |
|
||||||
|
| Theme | Dark `#111113`… + amber accent (ADR-0008) |
|
||||||
|
| Word vs PDF | Documents/Query/Benchmarks filter Word; PDF Tab = same sections + format filter |
|
||||||
|
| Retrieval Inspect | Full-page mode in Benchmarks: question rail, Strategy picker, Generated \| Expected, Expansion Tree |
|
||||||
|
| Decision Board | Client-side Candidate discovery from `/experiments`; Corpus filter; exclude bad Experiment ids; two-stage heat comparison |
|
||||||
|
| Neighbor badge | `±P/N` on Experiment list/Compare; mismatch warning across Boundary/Corpus/Neighbor |
|
||||||
|
|
||||||
|
No client router or global store — props from root state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. API Surface (concise)
|
||||||
|
|
||||||
|
| Method | Path | Service |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET/POST | `/documents` | list / upload |
|
||||||
|
| POST | `/documents/{id}/process` | Strategies |
|
||||||
|
| DELETE | `/documents/{id}` | delete |
|
||||||
|
| GET | `/strategies` | catalog |
|
||||||
|
| POST | `/queries` | query |
|
||||||
|
| GET | `/queries/{id}` | history |
|
||||||
|
| POST | `/benchmarks` | Experiment or dry_run |
|
||||||
|
| GET | `/benchmarks/{id}` | Experiment detail |
|
||||||
|
| GET | `/benchmarks/{id}/report` | HTML |
|
||||||
|
| GET | `/experiments` | list (+ filters used by Decision Board) |
|
||||||
|
| * | `/admin/*` | ops |
|
||||||
|
| GET | `/app/` | Dashboard |
|
||||||
|
|
||||||
|
Full schemas: [api-reference.md](api-reference.md), Pydantic models in `documents/models.py` and `benchmarking/models.py`.
|
||||||
|
|
||||||
|
**Request highlights**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Process
|
||||||
|
boundary_model_id: Optional[str]
|
||||||
|
corpus_model_id: Optional[str]
|
||||||
|
|
||||||
|
# Query / Benchmark
|
||||||
|
top_k: int = 5
|
||||||
|
neighbor_prev / neighbor_next: int = 0..5
|
||||||
|
corpus_model_id: Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Error Model
|
||||||
|
|
||||||
|
| Exception | Typical cause | HTTP |
|
||||||
|
|-----------|---------------|------|
|
||||||
|
| `DocumentProcessingError` | Bad file, Text-layer Gate, missing doc | 400 |
|
||||||
|
| `ChunkingError` | Unknown Strategy, semantic embed failure | 400 |
|
||||||
|
| `EmbeddingError` | Provider/API failure | 400 |
|
||||||
|
| `QdrantError` | Collection/upsert/search failure | 400 |
|
||||||
|
| `QueryError` | Missing doc, LLM answer failure | 400 |
|
||||||
|
| `BenchmarkError` | Bad questions file, eval failure | 400 |
|
||||||
|
|
||||||
|
Per-Strategy process failures are returned in `strategies_failed` without aborting the whole request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Sequence Diagrams
|
||||||
|
|
||||||
|
### Process (one Strategy)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → DocumentsAPI → DocumentService → ChunkingService
|
||||||
|
ChunkingService → resolve Boundary/Corpus
|
||||||
|
ChunkingService → SemanticStrategy.chunk (w/ Boundary embeds)
|
||||||
|
ChunkingService → embed_texts (Corpus)
|
||||||
|
ChunkingService → Qdrant.upsert
|
||||||
|
ChunkingService → SQLite.update counts + provenance
|
||||||
|
Client ← ProcessResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query with Neighbor Expansion
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → QueryAPI → run_query
|
||||||
|
→ embed_single (Corpus)
|
||||||
|
→ Qdrant.search top-k
|
||||||
|
→ apply_neighbor_expansion → get_chunks_by_indices
|
||||||
|
→ _build_context → OpenAI chat
|
||||||
|
→ SQLite.save_query
|
||||||
|
Client ← QueryResponse (retrieved_chunks + expansion_tree)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Extension Points
|
||||||
|
|
||||||
|
| Extension | Steps |
|
||||||
|
|-----------|-------|
|
||||||
|
| New Strategy | Subclass `ChunkingStrategy`; register in `_STRATEGIES`; add `StrategyName`; update Dashboard labels |
|
||||||
|
| New Embedding Model | Add `EmbeddingModelSpec` to registry; ensure dimension matches Qdrant collections |
|
||||||
|
| New eval metric | Extend judge prompt JSON + aggregate in `benchmark_service` + report templates |
|
||||||
|
| New Admin op | Prefer `/admin` only when domain routers lack the capability |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Scripts
|
||||||
|
|
||||||
|
`scripts/run_neighbor_sweep.py` — Benchmark Sweep: fixed Strategy + Corpus, steps Neighbor Expansion `(0,0)…(3,3)` across documents (each level = separate Experiment).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Related Documents
|
||||||
|
|
||||||
|
| Doc | Role |
|
||||||
|
|-----|------|
|
||||||
|
| [HLD.md](HLD.md) | System context and component view |
|
||||||
|
| [data-flow.md](data-flow.md) | Narrative pipelines |
|
||||||
|
| [configuration.md](configuration.md) | Env knobs |
|
||||||
|
| [adr/](adr/) | Decision records |
|
||||||
|
| [CONTEXT.md](../CONTEXT.md) | Language and Tab semantics |
|
||||||
@@ -6,12 +6,12 @@ Complete documentation for the RAG Chunking Benchmarker.
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. **New to the project?** Start with [Architecture Overview](architecture.md)
|
1. **New to the project?** Start with [HLD](HLD.md) (system design) then [LLD](LLD.md) (module detail)
|
||||||
2. **Want to understand strategies?** Read [Strategy Technical Details](strategy-technical-details.md)
|
2. **Domain language?** Read [CONTEXT.md](../CONTEXT.md)
|
||||||
3. **Need to use the API?** Check [API Reference](api-reference.md)
|
3. **Want to understand strategies?** Read [Strategy Technical Details](strategy-technical-details.md)
|
||||||
4. **Configuring the system?** See [Configuration Guide](configuration.md)
|
4. **Need to use the API?** Check [API Reference](api-reference.md)
|
||||||
5. **Understanding results?** Read [Evaluation Metrics](evaluation-metrics.md)
|
5. **Configuring the system?** See [Configuration Guide](configuration.md)
|
||||||
6. **Curious about data flow?** See [Data Flow](data-flow.md)
|
6. **Understanding results?** Read [Evaluation Metrics](evaluation-metrics.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,13 +19,18 @@ Complete documentation for the RAG Chunking Benchmarker.
|
|||||||
|
|
||||||
| File | Purpose | Audience |
|
| File | Purpose | Audience |
|
||||||
|------|---------|----------|
|
|------|---------|----------|
|
||||||
| [architecture.md](architecture.md) | System structure and design | New team members |
|
| [HLD.md](HLD.md) | High-level design: context, components, flows | Architects, new team members |
|
||||||
|
| [LLD.md](LLD.md) | Low-level design: schemas, algorithms, APIs | Implementers |
|
||||||
|
| [architecture.md](architecture.md) | Legacy overview (prefer HLD) | New team members |
|
||||||
| [strategy-technical-details.md](strategy-technical-details.md) | Deep dive into each strategy | Engineers |
|
| [strategy-technical-details.md](strategy-technical-details.md) | Deep dive into each strategy | Engineers |
|
||||||
| [api-reference.md](api-reference.md) | All endpoints documented | Developers |
|
| [api-reference.md](api-reference.md) | All endpoints documented | Developers |
|
||||||
| [configuration.md](configuration.md) | Settings and environment variables | DevOps |
|
| [configuration.md](configuration.md) | Settings and environment variables | DevOps |
|
||||||
| [evaluation-metrics.md](evaluation-metrics.md) | How scoring works | Data scientists |
|
| [evaluation-metrics.md](evaluation-metrics.md) | How scoring works | Data scientists |
|
||||||
| [data-flow.md](data-flow.md) | How data moves through the system | Engineers |
|
| [data-flow.md](data-flow.md) | How data moves through the system | Engineers |
|
||||||
| [chunking_strategies.md](chunking_strategies.md) | High-level strategy overview | Everyone |
|
| [chunking_strategies.md](chunking_strategies.md) | High-level strategy overview | Everyone |
|
||||||
|
| [final-chunking-strategy-decision.md](final-chunking-strategy-decision.md) | Final Strategy family decision (`fixed_size`) + charts | Managers, operators |
|
||||||
|
| [human-eval-fixed-size-plus3-report.md](human-eval-fixed-size-plus3-report.md) | Human evaluation of fixed_size ±3 (sample + charts) | Managers, operators |
|
||||||
|
| [adr/](adr/) | Architectural Decision Records | Everyone |
|
||||||
| [phases.md](phases.md) | Implementation phases | Project managers |
|
| [phases.md](phases.md) | Implementation phases | Project managers |
|
||||||
| [tasks.md](tasks.md) | Task tracking | Developers |
|
| [tasks.md](tasks.md) | Task tracking | Developers |
|
||||||
|
|
||||||
|
|||||||
16
docs/adr/0016-pymupdf-for-text-pdf-extraction.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# PyMuPDF for Text PDF extraction
|
||||||
|
|
||||||
|
Text PDF ingestion uses PyMuPDF (`fitz`) so Heading Reconstruction can read font size/weight, text blocks, and outline bookmarks — the same structural signals `python-docx` gives us for DOCX. We rejected flat string extractors (`pypdf`), table-first libraries as the primary path (`pdfplumber`), and LibreOffice PDF→DOCX conversion (lossy styles, slow, fights ADR 0006’s “prefer native structure”).
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **PyMuPDF** — chosen; best fit for Heading Reconstruction; AGPL acceptable while this stays an internal benchmarker
|
||||||
|
- **pdfplumber** — strong tables, weaker hierarchy; deferred to backlog if tables are a measured failure
|
||||||
|
- **pypdf** — too little layout/font signal
|
||||||
|
- **LibreOffice PDF→DOCX → existing parser** — reuses DOCX path but conversion quality is unreliable
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Add `pymupdf` dependency; keep a single PDF code path in the documents parser seam
|
||||||
|
- If we later ship the parser as a distributed service, revisit AGPL vs a permissive alternative
|
||||||
|
- Table-heavy and Scanned PDF work stays out of this ADR (see `backlog/`)
|
||||||
11
docs/adr/0017-shared-dashboard-sections-format-filter.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Shared Dashboard sections with format filter
|
||||||
|
|
||||||
|
The PDF Workspace is not a second UI. Documents, Query, Benchmarks, and Chunk Preview are composed from shared section components in `index.html`, parameterized by a format filter (`word` | `pdf`). That keeps Word Tabs and the PDF Tab behaviorally identical while listing different documents, and avoids cloning the single-file Dashboard (ADR-0004).
|
||||||
|
|
||||||
|
Rejected: copy-paste PDF Tab (diverges immediately) and “filter props only with zero extraction” when it would still duplicate large JSX blocks.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Refactor existing Tab bodies to call shared panels before adding the PDF Tab
|
||||||
|
- Filtering is client-side by filename suffix; APIs stay format-agnostic
|
||||||
|
- Report generator stays shared; PDF Experiments only get a source-format badge
|
||||||
17
docs/adr/0018-model-corpus-scoped-collections.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Model Corpus via model-scoped collections
|
||||||
|
|
||||||
|
Vectors for Cloud and Local Embedding Models must not share a Qdrant collection: dimensions and embedding spaces differ. Each Embedding Model therefore owns a **Model Corpus** — Strategy collections whose identity includes that model. Process, query, and Experiment only read/write the Active Embedding Model’s corpus; other corpora stay untouched. Pre-existing unscoped collections (e.g. `fixed_size_collection`) are migrated into the default cloud Embedding Model’s corpus so existing OpenAI work is not discarded.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Model-scoped collections** — chosen; non-destructive switch between Providers/models; clear isolation
|
||||||
|
- **Wipe-and-rebuild on switch** — simple naming, destructive and easy to forget
|
||||||
|
- **Dimension-gated single set** — only allow same-dimension swaps; blocks most OpenAI ↔ Ollama use
|
||||||
|
|
||||||
|
Legacy handling (original): **migrate unscoped → default cloud corpus**. **Superseded for naming by ADR-0021** — all models, including default cloud, use `{strategy}__{model_id}_collection`; delete leftover unscoped collections when rebuilding.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Collection naming and vector size are driven by the Embedding Model Registry entry, not a global `1536` constant
|
||||||
|
- Admin Qdrant list shows all corpora, labeled by Embedding Model, with the active corpus emphasized; wipe stays per named collection
|
||||||
|
- Switching Active Embedding Model does not delete the other Model Corpus; operator must process documents again under the new model to populate it
|
||||||
22
docs/adr/0019-active-embedding-model-global.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Active Embedding Model is global, not an Experiment axis
|
||||||
|
|
||||||
|
**Status:** Superseded by [ADR-0024](0024-boundary-vs-corpus-embedding-models.md) — single Active Embedding Model replaced by **Boundary** + **Corpus** roles (query locked to Corpus).
|
||||||
|
|
||||||
|
Original decision retained for history:
|
||||||
|
|
||||||
|
The platform compares **Strategies** under controlled conditions. Embedding Model is a confounder, not a second experiment dimension: one **Active Embedding Model** applies to process, query, and new Experiments. The operator switches it from Admin among entries in a static **Embedding Model Registry** (Provider, stable id, vector dimension). Selection persists across restarts (config supplies the default only when unset). Every Experiment records which Embedding Model produced it; historical rows without provenance are treated as the default cloud model. A process, query, or Experiment **snapshots** the Active Embedding Model at start so a mid-flight Admin switch cannot mix models inside one operation.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Global Active Embedding Model** — chosen; fair Strategy comparisons; A/B models via separate Experiments
|
||||||
|
- **Embedding Model as Experiment axis** — Strategy × model cross-product; richer science, much heavier data model/UI
|
||||||
|
- **Per-run choice with no Experiment coupling** — flexible, invites silent unfair comparisons
|
||||||
|
|
||||||
|
Switcher: **Admin UI** (not env-only, not read-only status). Catalog: **static registry** (not live Ollama discovery, not free-form). Local Provider host: **`OLLAMA_BASE_URL` in config only**. Cost Estimator: **$0 embedding line when Active Embedding Model is Local**; LLM costs unchanged. This carve-out does not reopen general `/admin/config` (ADR-0007 deferred).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Introduce Provider adapters (Cloud OpenAI, Local Ollama) behind one embed API; callers bind a registry entry for the operation
|
||||||
|
- Dashboard Admin gains an Embedding Model switcher; Benchmarks/reports must show Experiment provenance
|
||||||
|
- `docs/out-of-scope-v1.md` “no embedding model from dashboard” is superseded for this focused control only
|
||||||
|
- Local Nomic registry entries use task prefixes (`search_document` / `search_query`); Cloud OpenAI entries do not
|
||||||
16
docs/adr/0020-semantic-boundary-detection-required.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Semantic strategies require Semantic Boundary Detection
|
||||||
|
|
||||||
|
`semantic` and `semantic_parent_child` must cut chunks from meaning: the orchestrator embeds consecutive units (sentences / paragraphs) with the Active Embedding Model snapshot, passes those vectors into `chunk()`, then embeds finished chunks for Qdrant. Fixed-count fallbacks are removed — missing or length-mismatched boundary embeddings fail that Strategy. This completes ADR 0012’s sentence-level design in the process path (and the parent/child analogue) so benchmarks cannot silently measure “every N units” under a semantic name.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Wire both strategies + fail hard** — chosen; same Active model for boundaries and storage; accept double embed cost; re-process to replace old fake-semantic vectors
|
||||||
|
- **Wire `semantic` only** — rejected; would leave parent/child on the same lie
|
||||||
|
- **Keep fallback with warnings** — rejected; that is how the bug stayed hidden
|
||||||
|
- **Always OpenAI for boundaries regardless of Active** — rejected; confounds model A/B experiments
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Process cost rises for these two Strategies (unit embeds + chunk embeds)
|
||||||
|
- Prior Experiments / collections produced under the fallback are not true semantic — operator must re-process and re-benchmark
|
||||||
|
- Strategy modules raise `ChunkingError` if boundary embeddings are absent or mismatched; orchestration is responsible for supplying them
|
||||||
14
docs/adr/0021-always-scope-collection-names-by-model.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Always scope Qdrant collection names by Embedding Model id
|
||||||
|
|
||||||
|
Every Model Corpus collection is named `{strategy}__{embedding_model_id}_collection`, including the default cloud model (`text-embedding-3-small`). This supersedes ADR-0018’s exception that left OpenAI on legacy unscoped names (`fixed_size_collection`). Uniform naming makes Admin labeling obvious for both Cloud and Local and avoids a special case that confused operators wiping/rebuilding corpora.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Always include model id** — chosen; clear for cloud and local
|
||||||
|
- **Keep legacy unscoped names for default cloud only** — ADR-0018; rejected going forward after a from-scratch rebuild
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Process/query create names like `fixed_size__text-embedding-3-small_collection` and `fixed_size__nomic-embed-text-v2-moe_collection`
|
||||||
|
- Old unscoped collections are not written anymore; Admin still labels them as legacy default-cloud if they remain until deleted
|
||||||
|
- Operators wiping for a clean slate should delete legacy collections before re-processing
|
||||||
22
docs/adr/0022-per-embedding-model-semantic-threshold.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# ADR-0022: Per-Embedding-Model semantic_threshold
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Approved
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Semantic Boundary Detection cuts when adjacent-unit cosine similarity falls below a threshold. Cloud OpenAI and local Nomic produce different similarity distributions for the same Farsi text: the global `SEMANTIC_THRESHOLD` (config, historically ~0.3) rarely triggers cuts under Nomic, collapsing `semantic` into a single chunk. Operators need a higher Nomic default without changing OpenAI behavior, and a way to tune without editing `.env` and restarting.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Each Embedding Model Registry entry has a `default_semantic_threshold` (OpenAI `text-embedding-3-small`: **0.3**; Nomic `nomic-embed-text-v2-moe`: **0.6`).
|
||||||
|
- Admin may override the effective value per model id in SQLite (`semantic_threshold:{model_id}`).
|
||||||
|
- Process snapshots the Active Embedding Model and uses that model’s effective threshold for `semantic` and `semantic_parent_child`.
|
||||||
|
- Admin API: list includes `semantic_threshold` / `default_semantic_threshold`; `PUT /admin/embedding-models/{id}/semantic-threshold` persists overrides.
|
||||||
|
- Changing the threshold does not rewrite existing corpora — re-process to apply.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Global `SEMANTIC_THRESHOLD` remains a fallback only when a strategy is called without an explicit threshold (tests/legacy); production process path always passes the model-resolved value.
|
||||||
|
- Operators must re-process after tuning; Experiments under different thresholds are not auto-invalidated.
|
||||||
46
docs/adr/0023-neighbor-expansion-fixed-size.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-0023: Neighbor Expansion for fixed_size retrieval
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (implemented)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Fixed-size chunks cut text at token windows, so the single highest-scoring chunk often lacks the sentence before/after the answer. Operators want query-time widening: after top-k retrieval, also pull previous and next chunks in document order. Doing this for every Strategy, or reshaping the top-k budget, would muddy Experiment comparisons and hide whether gains come from chunking vs from extra context.
|
||||||
|
|
||||||
|
Operators also need to **see** the relationship: all top-k hits and, for each hit, which upper/downer chunks were appended. A flat deduped list alone cannot show per-hit windows when neighbors overlap.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Retrieval behavior
|
||||||
|
|
||||||
|
- **Neighbor Expansion** applies only to the `fixed_size` Strategy at query time (not at process/chunking time).
|
||||||
|
- Every top-k hit is expanded by configurable `neighbor_prev` and `neighbor_next` counts (same document, by `chunk_index`).
|
||||||
|
- Expansion is **additive**: neighbors are merged with top-k, deduped by chunk id, then sorted into **document order** for the LLM. Context may exceed `top_k`.
|
||||||
|
- Knobs live on Query and Experiment requests like `top_k`, with config defaults **`0` / `0`** (opt-in). Non-`fixed_size` Strategies ignore the knobs. Missing neighbors at document edges are skipped.
|
||||||
|
- Experiments must record `neighbor_prev` / `neighbor_next` for provenance.
|
||||||
|
|
||||||
|
### Dual response shape
|
||||||
|
|
||||||
|
- **`retrieved_chunks`**: flat list matching what the LLM saw (deduped, document order; entries labeled hit vs neighbor as needed for audit/eval).
|
||||||
|
- **`expansion_tree`**: operator-facing grouping — top-k hits in **score order**, each with `neighbors_prev` / `neighbors_next`. When expansion is off (`0`/`0`), the tree still contains the hits with **empty** neighbor arrays.
|
||||||
|
- Overlapping windows: the same chunk may appear under **more than one** hit in the Expansion Tree; it still appears **once** in `retrieved_chunks` / the LLM prompt.
|
||||||
|
|
||||||
|
### Where the Expansion Tree is shown
|
||||||
|
|
||||||
|
- Query result UI
|
||||||
|
- Experiment per-question detail in the Dashboard
|
||||||
|
- HTML report — **both** managerial and technical views
|
||||||
|
|
||||||
|
### Experiment list & Compare provenance
|
||||||
|
|
||||||
|
- Experiments list shows a compact **`±P/N`** Neighbors badge (tooltip: `neighbor_prev` / `neighbor_next`; muted when `fixed_size` was not in the run).
|
||||||
|
- Compare view repeats the badge on each Experiment column/card.
|
||||||
|
- Compare shows a **soft warning** when selected Experiments differ on Neighbor Expansion **or** Embedding Model — intentional A/B is allowed; do not treat mismatched fixed_size scores as identical setups.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Fixed_size Experiments with expansion enabled may use more tokens than other Strategies at the same `top_k`; that asymmetry is intentional and must be visible in config/reports.
|
||||||
|
- Hit-only retrieval metrics remain available by filtering labeled hits in the flat list; unfiltered eval reflects the expanded prompt.
|
||||||
|
- UI and reports render the Expansion Tree; they must not assume the flat list alone can reconstruct per-hit appendages after dedupe.
|
||||||
|
- Dashboard Compare must keep Neighbor Expansion (and Embedding Model) visible so operators can fairly compare `±0/0` vs `±1/1` vs larger windows.
|
||||||
27
docs/adr/0024-boundary-vs-corpus-embedding-models.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# ADR-0024: Boundary vs Corpus Embedding Model roles
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented. Supersedes ADR-0019’s single global Active Embedding Model for new work.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Embedding happens in three moments: (1) Semantic Boundary Detection for `semantic` / `semantic_parent_child`, (2) finished-chunk vectors into Qdrant, (3) query vectors for search. ADR-0019 forced one Active Embedding Model for all three so Strategy comparisons stayed fair. Operators now want to vary the model used for cuts independently from the model used for retrieval — but storage and query must remain the same vector space or RAG breaks.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Split into two roles from the Embedding Model Registry:
|
||||||
|
- **Boundary Embedding Model** — Cosine cuts only; ignored by Strategies without boundary detection. Uses that model’s `semantic_threshold`.
|
||||||
|
- **Corpus Embedding Model** — Finished-chunk storage **and** query embedding (always paired). Selects the Model Corpus (`{strategy}__{corpus_model_id}_collection`).
|
||||||
|
- **Not** three independent knobs: Query may not diverge from Corpus.
|
||||||
|
- Admin stores **Default Boundary** and **Default Corpus** (migrate legacy `active_embedding_model_id` → both defaults).
|
||||||
|
- Per-operation overrides (Decision B): Process may set Boundary + Corpus; Query/Experiment set Corpus only (Boundary irrelevant at query time). Snapshot for the operation.
|
||||||
|
- Process UI: show Boundary picker only when a semantic Strategy is selected.
|
||||||
|
- Provenance: always record Corpus on Query/Experiment; record Boundary when semantic Strategies are in play. Compare warns when Corpus, Boundary (if present), or Neighbors differ.
|
||||||
|
- Query/Benchmark Corpus choice should prefer models that have a usable Model Corpus for the target doc/strategy and warn when empty.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- ADR-0019’s single Active switcher is replaced; call sites that `snapshot_active_model()` must become Corpus (and Boundary where needed).
|
||||||
|
- Cross-model science is allowed for boundary vs corpus; silent unfair Strategy×model mixes remain out of scope as an Experiment axis.
|
||||||
|
- Implementation is live: Admin defaults, Process Boundary+Corpus overrides, Query/Experiment Corpus override, dual provenance, Compare warnings.
|
||||||
24
docs/adr/0025-retrieval-inspect.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# ADR-0025: Retrieval Inspect (Benchmarks full-page mode)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (implemented; Expected Answer strip amended)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Neighbor Expansion and Expansion Tree (ADR-0023) store per-question top-k hits with prev/next for Experiments, but operators cannot audit them well: Query is live-only, Experiment expand rows and HTML reports truncate or sample one question, and a new top-level Tab would stretch navigation. Operators need a dedicated, readable surface to choose a question and inspect retrieval in full detail. Operators also need the Expected Answer beside the Generated Answer to judge Similarity without leaving Inspect.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- **Retrieval Inspect** is a **full-page mode inside the Benchmarks Tab** (not a new top-level Tab, not HTML-report-only).
|
||||||
|
- Entry: **Inspect** action on an Experiment row (alongside Report).
|
||||||
|
- Layout: **left question rail** (status for error / has Expansion Tree) + main pane with **Strategy picker** (default `fixed_size` when present), **side-by-side Generated | Expected Answer** strip with clear column headers (plain text, no word-diff), **full-width eval scores** under that strip, and a **vertical full-text Expansion Tree** (prev → hit → next per top-k hit; neighbors empty/N/A for other Strategies).
|
||||||
|
- Expected column always renders; empty/missing `expected_answer` shows a muted “(no expected answer)”.
|
||||||
|
- Visual language stays the existing Dashboard theme (ADR-0008); quality comes from hierarchy, spacing, and full text — not a separate brand.
|
||||||
|
- Reads existing Experiment `per_question` payloads (`expansion_tree`, answers, `expected_answer`, scores); no new retrieval API required for v1.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Benchmarks Tab gains a second view mode (list/run vs Inspect); state returns cleanly via Back.
|
||||||
|
- HTML report Expansion Tree remains a summary; Inspect is the audit UX.
|
||||||
|
- Earlier “no expected-answer compare panel” clause is superseded by the labeled side-by-side strip (not a diff engine).
|
||||||
24
docs/adr/0026-decision-board-tab.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# ADR-0026: Decision Board Tab for final Strategy selection
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (implemented)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
After Neighbor Expansion Sweeps and semantic Boundary variants, the operator’s real job is choosing between `fixed_size` and `semantic` — not ad-hoc Experiment Compare. Compare remains useful for arbitrary side-by-side diffs, but it does not encode two-stage tuning (best variant per family, then family showdown) or a fixed 10-doc evaluation universe.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Add a top-level Dashboard **Decision** Tab (Decision Board).
|
||||||
|
- **Strategy Candidates**: `fixed_size` at ±0…±3, and `semantic` under each Boundary Embedding Model present in data, under a selected Corpus Embedding Model.
|
||||||
|
- Auto-discover from **single-strategy** Experiments on the hardcoded 10-doc set; newest Experiment fills each doc×Candidate cell; optional exclude chips; incomplete cells are `—` and omitted from that Candidate’s mean/wins.
|
||||||
|
- Stage 1 ranks by **mean composite** (same weights as Experiments “Best”), shows win-count, allows manual override.
|
||||||
|
- Stage 2 duels the two family winners with full metrics + per-doc head-to-head; per-doc matrix lists all Candidates with duel winners highlighted.
|
||||||
|
- Aggregation is client-side from `GET /experiments?limit=…` (default list limit raised; max 500).
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- Enhance Compare only — rejected (wrong job, crowded Benchmarks UX).
|
||||||
|
- Standalone HTML report only — rejected (weak for override / interactive decision).
|
||||||
|
- Manual pin of every cell — rejected for v1 (too heavy for ~60 cells).
|
||||||
@@ -82,6 +82,9 @@ Delete a document and its vectors.
|
|||||||
|
|
||||||
Run chunking strategies on a document.
|
Run chunking strategies on a document.
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
- `background` (bool, default `false`) — when `true`, enqueue processing and return **202** with a `job_id`; poll `GET /jobs/{job_id}`.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -199,6 +202,9 @@ Retrieve a past query.
|
|||||||
|
|
||||||
Run a benchmark comparing multiple strategies.
|
Run a benchmark comparing multiple strategies.
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
- `background` (bool, default `false`) — when `true`, enqueue the run and return **202** with a `job_id`; poll `GET /jobs/{job_id}` for status and result.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -278,6 +284,32 @@ List all experiments.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Jobs
|
||||||
|
|
||||||
|
Background execution for long-running benchmarks and document processing (FastAPI `BackgroundTasks` + SQLite job records).
|
||||||
|
|
||||||
|
#### `GET /jobs`
|
||||||
|
|
||||||
|
List jobs (newest first).
|
||||||
|
|
||||||
|
**Query parameters:** `job_type`, `status`, `offset`, `limit`
|
||||||
|
|
||||||
|
#### `GET /jobs/{job_id}`
|
||||||
|
|
||||||
|
Poll job status. When `status` is `completed`, `result` contains the same payload as the synchronous endpoint would return; when `failed`, `error` is set.
|
||||||
|
|
||||||
|
**Response (202 enqueue body from POST with `background=true`):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "abc123",
|
||||||
|
"job_type": "benchmark",
|
||||||
|
"status": "pending",
|
||||||
|
"poll_url": "/jobs/abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All errors return:
|
All errors return:
|
||||||
|
|||||||
458
docs/assets/decision/generate_charts.py
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate SVG charts for the final chunking-strategy decision report.
|
||||||
|
|
||||||
|
Reads Experiment aggregates from SQLite (Decision Board rules) and writes
|
||||||
|
SVG files next to this script. Re-run after new Experiments if needed:
|
||||||
|
|
||||||
|
.venv/bin/python docs/assets/decision/generate_charts.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
DB = ROOT / "data" / "chunking_benchmark.db"
|
||||||
|
OUT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
DECISION_DOCS = [
|
||||||
|
"bazresi.docx",
|
||||||
|
"customer1.docx",
|
||||||
|
"fire.docx",
|
||||||
|
"general-havades-individuals.doc",
|
||||||
|
"havades.docx",
|
||||||
|
"life-time-individual.docx",
|
||||||
|
"moavenin.docx",
|
||||||
|
"Refah.docx",
|
||||||
|
"website.docx",
|
||||||
|
"lifetime-compensation.docx",
|
||||||
|
]
|
||||||
|
SHORT = {
|
||||||
|
"bazresi.docx": "bazresi",
|
||||||
|
"customer1.docx": "customer1",
|
||||||
|
"fire.docx": "fire",
|
||||||
|
"general-havades-individuals.doc": "havades-ind",
|
||||||
|
"havades.docx": "havades",
|
||||||
|
"life-time-individual.docx": "lifetime-ind",
|
||||||
|
"moavenin.docx": "moavenin",
|
||||||
|
"Refah.docx": "Refah",
|
||||||
|
"website.docx": "website",
|
||||||
|
"lifetime-compensation.docx": "lifetime-comp",
|
||||||
|
}
|
||||||
|
CORPUS = "text-embedding-3-large"
|
||||||
|
|
||||||
|
TEAL_FILL = "#14b8a6"
|
||||||
|
AMBER_FILL = "#f59e0b"
|
||||||
|
INK = "#111827"
|
||||||
|
MUTED = "#6b7280"
|
||||||
|
GRID = "#e5e7eb"
|
||||||
|
BG = "#ffffff"
|
||||||
|
WIN = "#047857"
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s: str) -> str:
|
||||||
|
return (
|
||||||
|
str(s)
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def composite(m: dict | None) -> float | None:
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
(m.get("avg_context_relevance") or 0) * 0.3
|
||||||
|
+ (m.get("avg_answer_similarity") or 0) * 0.4
|
||||||
|
+ (m.get("avg_faithfulness") or 0) * 0.3
|
||||||
|
) * (1 - (m.get("hallucination_rate") or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def load_cells() -> dict[tuple[str, str], dict]:
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
docs = {r["id"]: r["filename"] for r in conn.execute("SELECT id, filename FROM documents")}
|
||||||
|
rows = []
|
||||||
|
for e in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, document_id, strategies_used, aggregate_metrics, benchmark_config,
|
||||||
|
embedding_model_id, boundary_embedding_model_id, created_at, questions
|
||||||
|
FROM experiments ORDER BY created_at DESC
|
||||||
|
"""
|
||||||
|
):
|
||||||
|
fn = docs.get(e["document_id"])
|
||||||
|
strats = json.loads(e["strategies_used"] or "[]")
|
||||||
|
agg = json.loads(e["aggregate_metrics"] or "{}")
|
||||||
|
cfg = json.loads(e["benchmark_config"] or "{}")
|
||||||
|
corpus = e["embedding_model_id"] or cfg.get("corpus_embedding_model_id")
|
||||||
|
prev = int(cfg.get("neighbor_prev") or 0)
|
||||||
|
nxt = int(cfg.get("neighbor_next") or 0)
|
||||||
|
bound = e["boundary_embedding_model_id"] or cfg.get("boundary_embedding_model_id")
|
||||||
|
if fn not in DECISION_DOCS or corpus != CORPUS or len(strats) != 1:
|
||||||
|
continue
|
||||||
|
strat = strats[0]
|
||||||
|
if strat == "fixed_size":
|
||||||
|
if prev != nxt or prev not in (0, 1, 2, 3):
|
||||||
|
continue
|
||||||
|
cid = f"fixed_size:±{prev}"
|
||||||
|
family = "fixed_size"
|
||||||
|
elif strat == "semantic":
|
||||||
|
if not bound:
|
||||||
|
continue
|
||||||
|
cid = f"semantic:{bound}"
|
||||||
|
family = "semantic"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"fn": fn,
|
||||||
|
"cid": cid,
|
||||||
|
"family": family,
|
||||||
|
"agg": agg,
|
||||||
|
"metrics": agg.get(family) or {},
|
||||||
|
"id": e["id"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
cells: dict[tuple[str, str], dict] = {}
|
||||||
|
for r in rows:
|
||||||
|
key = (r["fn"], r["cid"])
|
||||||
|
if key not in cells:
|
||||||
|
cells[key] = r
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
def mean(xs: list[float]) -> float | None:
|
||||||
|
return sum(xs) / len(xs) if xs else None
|
||||||
|
|
||||||
|
|
||||||
|
def svg_wrap(w: int, h: int, body: str, title: str) -> str:
|
||||||
|
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-label="{esc(title)}">
|
||||||
|
<title>{esc(title)}</title>
|
||||||
|
<rect width="{w}" height="{h}" fill="{BG}"/>
|
||||||
|
{body}
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def chart_stage1(cells: dict) -> None:
|
||||||
|
cands = [
|
||||||
|
("fixed_size:±3", "fixed_size ±3", TEAL_FILL, True),
|
||||||
|
("fixed_size:±2", "fixed_size ±2", TEAL_FILL, False),
|
||||||
|
("fixed_size:±1", "fixed_size ±1", TEAL_FILL, False),
|
||||||
|
("semantic:text-embedding-3-large", "semantic @ large", AMBER_FILL, False),
|
||||||
|
("fixed_size:±0", "fixed_size ±0", "#99f6e4", False),
|
||||||
|
("semantic:nomic-embed-text-v2-moe", "semantic @ nomic", "#fcd34d", False),
|
||||||
|
]
|
||||||
|
values = []
|
||||||
|
for cid, label, color, winner in cands:
|
||||||
|
scores = []
|
||||||
|
for fn in DECISION_DOCS:
|
||||||
|
r = cells.get((fn, cid))
|
||||||
|
if r:
|
||||||
|
sc = composite(r["metrics"])
|
||||||
|
if sc is not None:
|
||||||
|
scores.append(sc)
|
||||||
|
values.append((cid, label, color, winner, mean(scores) or 0))
|
||||||
|
|
||||||
|
w, h = 820, 420
|
||||||
|
left, right, top, bottom = 210, 40, 56, 48
|
||||||
|
plot_w = w - left - right
|
||||||
|
plot_h = h - top - bottom
|
||||||
|
vmin, vmax = 8.4, 9.2
|
||||||
|
bar_h = plot_h / len(values) * 0.62
|
||||||
|
gap = plot_h / len(values)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f'<text x="24" y="32" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Stage 1 — mean composite (10 documents)</text>',
|
||||||
|
f'<text x="24" y="50" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Higher is better. Official ranking used by Decision Board. Winner: fixed_size ±3.</text>',
|
||||||
|
]
|
||||||
|
# grid
|
||||||
|
for tick in [8.5, 8.6, 8.7, 8.8, 8.9, 9.0, 9.1, 9.2]:
|
||||||
|
x = left + (tick - vmin) / (vmax - vmin) * plot_w
|
||||||
|
parts.append(f'<line x1="{x:.1f}" y1="{top}" x2="{x:.1f}" y2="{h - bottom}" stroke="{GRID}" stroke-width="1"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{x:.1f}" y="{h - 18}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">{tick:.1f}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, (_, label, color, winner, val) in enumerate(values):
|
||||||
|
y = top + i * gap + (gap - bar_h) / 2
|
||||||
|
bw = (val - vmin) / (vmax - vmin) * plot_w
|
||||||
|
stroke = WIN if winner else "none"
|
||||||
|
sw = 2 if winner else 0
|
||||||
|
parts.append(
|
||||||
|
f'<rect x="{left}" y="{y:.1f}" width="{max(bw, 0):.1f}" height="{bar_h:.1f}" rx="4" fill="{color}" stroke="{stroke}" stroke-width="{sw}"/>'
|
||||||
|
)
|
||||||
|
weight = "700" if winner else "500"
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{left - 10}" y="{y + bar_h * 0.68:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="{weight}" fill="{INK}">{esc(label)}</text>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{left + bw + 8:.1f}" y="{y + bar_h * 0.68:.1f}" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="{INK}">{val:.3f}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
(OUT / "stage1-mean-composite.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 1 mean composite"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def chart_stage2(cells: dict) -> None:
|
||||||
|
fs_id = "fixed_size:±3"
|
||||||
|
sem_id = "semantic:text-embedding-3-large"
|
||||||
|
pairs = []
|
||||||
|
for fn in DECISION_DOCS:
|
||||||
|
a = composite(cells[(fn, fs_id)]["metrics"])
|
||||||
|
b = composite(cells[(fn, sem_id)]["metrics"])
|
||||||
|
pairs.append((SHORT[fn], a, b))
|
||||||
|
|
||||||
|
w, h = 920, 460
|
||||||
|
left, right, top, bottom = 52, 24, 64, 88
|
||||||
|
plot_w = w - left - right
|
||||||
|
plot_h = h - top - bottom
|
||||||
|
n = len(pairs)
|
||||||
|
slot = plot_w / n
|
||||||
|
bar_w = slot * 0.32
|
||||||
|
vmin, vmax = 5.8, 10.0
|
||||||
|
|
||||||
|
def y_of(v: float) -> float:
|
||||||
|
return top + (1 - (v - vmin) / (vmax - vmin)) * plot_h
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Stage 2 — per document (fixed_size ±3 vs semantic @ large)</text>',
|
||||||
|
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Mean composite still favors fixed_size. Semantic wins 6 of 10 docs, but by smaller margins except website/customer1.</text>',
|
||||||
|
f'<rect x="620" y="14" width="12" height="12" rx="2" fill="{TEAL_FILL}"/>',
|
||||||
|
f'<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">fixed_size ±3</text>',
|
||||||
|
f'<rect x="760" y="14" width="12" height="12" rx="2" fill="{AMBER_FILL}"/>',
|
||||||
|
f'<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">semantic @ large</text>',
|
||||||
|
]
|
||||||
|
for tick in [6, 7, 8, 9, 10]:
|
||||||
|
y = y_of(tick)
|
||||||
|
parts.append(f'<line x1="{left}" y1="{y:.1f}" x2="{w - right}" y2="{y:.1f}" stroke="{GRID}" stroke-width="1"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{left - 8}" y="{y + 4:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">{tick}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, (label, a, b) in enumerate(pairs):
|
||||||
|
cx = left + i * slot + slot / 2
|
||||||
|
xa = cx - bar_w - 3
|
||||||
|
xb = cx + 3
|
||||||
|
ha = plot_h - (y_of(a) - top)
|
||||||
|
hb = plot_h - (y_of(b) - top)
|
||||||
|
parts.append(f'<rect x="{xa:.1f}" y="{y_of(a):.1f}" width="{bar_w:.1f}" height="{ha:.1f}" rx="3" fill="{TEAL_FILL}"/>')
|
||||||
|
parts.append(f'<rect x="{xb:.1f}" y="{y_of(b):.1f}" width="{bar_w:.1f}" height="{hb:.1f}" rx="3" fill="{AMBER_FILL}"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{cx:.1f}" y="{h - 36}" text-anchor="end" transform="rotate(-32 {cx:.1f} {h - 36})" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{INK}">{esc(label)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
(OUT / "stage2-per-document.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 2 per document"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def chart_delta(cells: dict) -> None:
|
||||||
|
fs_id = "fixed_size:±3"
|
||||||
|
sem_id = "semantic:text-embedding-3-large"
|
||||||
|
deltas = []
|
||||||
|
for fn in DECISION_DOCS:
|
||||||
|
a = composite(cells[(fn, fs_id)]["metrics"])
|
||||||
|
b = composite(cells[(fn, sem_id)]["metrics"])
|
||||||
|
deltas.append((SHORT[fn], a - b))
|
||||||
|
deltas.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
w, h = 820, 440
|
||||||
|
left, right, top, bottom = 120, 56, 56, 36
|
||||||
|
plot_w = w - left - right
|
||||||
|
plot_h = h - top - bottom
|
||||||
|
n = len(deltas)
|
||||||
|
gap = plot_h / n
|
||||||
|
bar_h = gap * 0.62
|
||||||
|
max_abs = max(abs(d) for _, d in deltas)
|
||||||
|
# fire is 2.58, others < 1.2 — use 2.8
|
||||||
|
max_abs = 2.8
|
||||||
|
zero_x = left + plot_w / 2
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Margin: fixed_size ±3 minus semantic @ large</text>',
|
||||||
|
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Teal = fixed_size wins the document. Amber = semantic wins. Mean ranking is driven by large teal bars (especially fire).</text>',
|
||||||
|
f'<line x1="{zero_x:.1f}" y1="{top}" x2="{zero_x:.1f}" y2="{h - bottom}" stroke="{INK}" stroke-width="1.2"/>',
|
||||||
|
f'<text x="{left}" y="{h - 12}" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">semantic better</text>',
|
||||||
|
f'<text x="{w - right}" y="{h - 12}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">fixed_size better</text>',
|
||||||
|
]
|
||||||
|
for i, (label, d) in enumerate(deltas):
|
||||||
|
y = top + i * gap + (gap - bar_h) / 2
|
||||||
|
bw = abs(d) / max_abs * (plot_w / 2)
|
||||||
|
if d >= 0:
|
||||||
|
x = zero_x
|
||||||
|
color = TEAL_FILL
|
||||||
|
tx = x + bw + 6
|
||||||
|
anchor = "start"
|
||||||
|
else:
|
||||||
|
x = zero_x - bw
|
||||||
|
color = AMBER_FILL
|
||||||
|
tx = x - 6
|
||||||
|
anchor = "end"
|
||||||
|
parts.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{bar_h:.1f}" rx="3" fill="{color}"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{left - 8}" y="{y + bar_h * 0.7:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">{esc(label)}</text>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{tx:.1f}" y="{y + bar_h * 0.7:.1f}" text-anchor="{anchor}" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="{INK}">{d:+.2f}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
(OUT / "stage2-margins.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 2 margins"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def chart_heatmap(cells: dict) -> None:
|
||||||
|
cands = [
|
||||||
|
("fixed_size:±0", "±0"),
|
||||||
|
("fixed_size:±1", "±1"),
|
||||||
|
("fixed_size:±2", "±2"),
|
||||||
|
("fixed_size:±3", "±3"),
|
||||||
|
("semantic:text-embedding-3-large", "sem@large"),
|
||||||
|
("semantic:nomic-embed-text-v2-moe", "sem@nomic"),
|
||||||
|
]
|
||||||
|
scores: list[list[float | None]] = []
|
||||||
|
for fn in DECISION_DOCS:
|
||||||
|
row = []
|
||||||
|
for cid, _ in cands:
|
||||||
|
r = cells.get((fn, cid))
|
||||||
|
row.append(composite(r["metrics"]) if r else None)
|
||||||
|
scores.append(row)
|
||||||
|
|
||||||
|
cell_w, cell_h = 78, 32
|
||||||
|
left, top = 128, 72
|
||||||
|
w = left + cell_w * len(cands) + 24
|
||||||
|
h = top + cell_h * len(DECISION_DOCS) + 36
|
||||||
|
|
||||||
|
def color_for(s: float | None) -> tuple[str, str]:
|
||||||
|
if s is None:
|
||||||
|
return "#f3f4f6", MUTED
|
||||||
|
# 6.2 .. 9.8
|
||||||
|
t = max(0.0, min(1.0, (s - 7.0) / (9.8 - 7.0)))
|
||||||
|
# pale rose -> amber -> teal
|
||||||
|
if t < 0.5:
|
||||||
|
u = t * 2
|
||||||
|
r = int(251 + (20 - 251) * 0)
|
||||||
|
# interpolate rose 251,113,133 -> amber 245,158,11
|
||||||
|
rr = int(251 + (245 - 251) * u)
|
||||||
|
gg = int(113 + (158 - 113) * u)
|
||||||
|
bb = int(133 + (11 - 133) * u)
|
||||||
|
else:
|
||||||
|
u = (t - 0.5) * 2
|
||||||
|
rr = int(245 + (13 - 245) * u)
|
||||||
|
gg = int(158 + (148 - 158) * u)
|
||||||
|
bb = int(11 + (136 - 11) * u)
|
||||||
|
bg = f"#{rr:02x}{gg:02x}{bb:02x}"
|
||||||
|
fg = "#111827" if t < 0.72 else "#f9fafb"
|
||||||
|
return bg, fg
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Candidate heatmap — composite by document</text>',
|
||||||
|
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Darker teal = stronger. Semantic @ large collapses on fire; ±1–±3 stay high across the set.</text>',
|
||||||
|
]
|
||||||
|
for j, (_, lab) in enumerate(cands):
|
||||||
|
x = left + j * cell_w + cell_w / 2
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{x:.1f}" y="{top - 10}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{INK}">{esc(lab)}</text>'
|
||||||
|
)
|
||||||
|
for i, fn in enumerate(DECISION_DOCS):
|
||||||
|
y = top + i * cell_h
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{left - 8}" y="{y + cell_h * 0.65:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{INK}">{esc(SHORT[fn])}</text>'
|
||||||
|
)
|
||||||
|
for j, val in enumerate(scores[i]):
|
||||||
|
x = left + j * cell_w
|
||||||
|
bg, fg = color_for(val)
|
||||||
|
parts.append(f'<rect x="{x}" y="{y}" width="{cell_w - 3}" height="{cell_h - 3}" rx="4" fill="{bg}"/>')
|
||||||
|
txt = "—" if val is None else f"{val:.2f}"
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{x + (cell_w - 3) / 2:.1f}" y="{y + cell_h * 0.62:.1f}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{fg}">{txt}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
(OUT / "heatmap-candidates.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Candidate heatmap"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def chart_metrics(cells: dict) -> None:
|
||||||
|
"""Grouped bars: four judge metrics for the two stage-2 winners."""
|
||||||
|
def bag(cid: str, family: str) -> dict[str, float]:
|
||||||
|
keys = [
|
||||||
|
"avg_context_relevance",
|
||||||
|
"avg_answer_similarity",
|
||||||
|
"avg_faithfulness",
|
||||||
|
"hallucination_rate",
|
||||||
|
]
|
||||||
|
acc = {k: [] for k in keys}
|
||||||
|
for fn in DECISION_DOCS:
|
||||||
|
m = cells[(fn, cid)]["metrics"]
|
||||||
|
for k in keys:
|
||||||
|
acc[k].append(m[k])
|
||||||
|
return {k: sum(v) / len(v) for k, v in acc.items()}
|
||||||
|
|
||||||
|
fs = bag("fixed_size:±3", "fixed_size")
|
||||||
|
sem = bag("semantic:text-embedding-3-large", "semantic")
|
||||||
|
labels = [
|
||||||
|
("Context relevance", fs["avg_context_relevance"], sem["avg_context_relevance"], False),
|
||||||
|
("Answer similarity", fs["avg_answer_similarity"], sem["avg_answer_similarity"], False),
|
||||||
|
("Faithfulness", fs["avg_faithfulness"], sem["avg_faithfulness"], False),
|
||||||
|
("Hallucination % (lower better)", fs["hallucination_rate"] * 100, sem["hallucination_rate"] * 100, True),
|
||||||
|
]
|
||||||
|
|
||||||
|
w, h = 820, 380
|
||||||
|
left, right, top, bottom = 52, 24, 64, 48
|
||||||
|
plot_w = w - left - right
|
||||||
|
plot_h = h - top - bottom
|
||||||
|
n = len(labels)
|
||||||
|
slot = plot_w / n
|
||||||
|
bar_w = slot * 0.28
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Stage 2 winners — mean judge metrics</text>',
|
||||||
|
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">fixed_size ±3 leads on all four metrics, including lower hallucination.</text>',
|
||||||
|
f'<rect x="620" y="14" width="12" height="12" rx="2" fill="{TEAL_FILL}"/>',
|
||||||
|
f'<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">fixed_size ±3</text>',
|
||||||
|
f'<rect x="760" y="14" width="12" height="12" rx="2" fill="{AMBER_FILL}"/>',
|
||||||
|
f'<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">semantic @ large</text>',
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, (lab, a, b, is_pct) in enumerate(labels):
|
||||||
|
vmax = 10 if not is_pct else max(a, b) * 1.35
|
||||||
|
cx = left + i * slot + slot / 2
|
||||||
|
xa = cx - bar_w - 3
|
||||||
|
xb = cx + 3
|
||||||
|
|
||||||
|
def bar(x: float, val: float, color: str) -> str:
|
||||||
|
bh = val / vmax * plot_h
|
||||||
|
y = top + plot_h - bh
|
||||||
|
return f'<rect x="{x:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{bh:.1f}" rx="3" fill="{color}"/>'
|
||||||
|
|
||||||
|
parts.append(bar(xa, a, TEAL_FILL))
|
||||||
|
parts.append(bar(xb, b, AMBER_FILL))
|
||||||
|
fmt = (lambda v: f"{v:.1f}%") if is_pct else (lambda v: f"{v:.2f}")
|
||||||
|
ya = top + plot_h - a / vmax * plot_h - 6
|
||||||
|
yb = top + plot_h - b / vmax * plot_h - 6
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{xa + bar_w / 2:.1f}" y="{ya:.1f}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{INK}">{fmt(a)}</text>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{xb + bar_w / 2:.1f}" y="{yb:.1f}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{INK}">{fmt(b)}</text>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{cx:.1f}" y="{h - 16}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">{esc(lab)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
(OUT / "stage2-metrics.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 2 mean metrics"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
cells = load_cells()
|
||||||
|
chart_stage1(cells)
|
||||||
|
chart_stage2(cells)
|
||||||
|
chart_delta(cells)
|
||||||
|
chart_heatmap(cells)
|
||||||
|
chart_metrics(cells)
|
||||||
|
print(f"Wrote SVGs in {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
143
docs/assets/decision/heatmap-candidates.svg
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="428" viewBox="0 0 620 428" role="img" aria-label="Candidate heatmap">
|
||||||
|
<title>Candidate heatmap</title>
|
||||||
|
<rect width="620" height="428" fill="#ffffff"/>
|
||||||
|
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Candidate heatmap — composite by document</text>
|
||||||
|
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Darker teal = stronger. Semantic @ large collapses on fire; ±1–±3 stay high across the set.</text>
|
||||||
|
<text x="167.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±0</text>
|
||||||
|
<text x="245.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±1</text>
|
||||||
|
<text x="323.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±2</text>
|
||||||
|
<text x="401.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±3</text>
|
||||||
|
<text x="479.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">sem@large</text>
|
||||||
|
<text x="557.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">sem@nomic</text>
|
||||||
|
<text x="120" y="92.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">bazresi</text>
|
||||||
|
<rect x="128" y="72" width="75" height="29" rx="4" fill="#e19d15"/>
|
||||||
|
<text x="165.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.52</text>
|
||||||
|
<rect x="206" y="72" width="75" height="29" rx="4" fill="#58975f"/>
|
||||||
|
<text x="243.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.34</text>
|
||||||
|
<rect x="284" y="72" width="75" height="29" rx="4" fill="#58975f"/>
|
||||||
|
<text x="321.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.34</text>
|
||||||
|
<rect x="362" y="72" width="75" height="29" rx="4" fill="#549761"/>
|
||||||
|
<text x="399.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.37</text>
|
||||||
|
<rect x="440" y="72" width="75" height="29" rx="4" fill="#4e9664"/>
|
||||||
|
<text x="477.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.40</text>
|
||||||
|
<rect x="518" y="72" width="75" height="29" rx="4" fill="#7c984b"/>
|
||||||
|
<text x="555.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.12</text>
|
||||||
|
<text x="120" y="124.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">customer1</text>
|
||||||
|
<rect x="128" y="104" width="75" height="29" rx="4" fill="#9f9a39"/>
|
||||||
|
<text x="165.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.92</text>
|
||||||
|
<rect x="206" y="104" width="75" height="29" rx="4" fill="#96993e"/>
|
||||||
|
<text x="243.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.97</text>
|
||||||
|
<rect x="284" y="104" width="75" height="29" rx="4" fill="#97993d"/>
|
||||||
|
<text x="321.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.96</text>
|
||||||
|
<rect x="362" y="104" width="75" height="29" rx="4" fill="#ec9d0f"/>
|
||||||
|
<text x="399.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.45</text>
|
||||||
|
<rect x="440" y="104" width="75" height="29" rx="4" fill="#96993e"/>
|
||||||
|
<text x="477.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.97</text>
|
||||||
|
<rect x="518" y="104" width="75" height="29" rx="4" fill="#f88255"/>
|
||||||
|
<text x="555.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.54</text>
|
||||||
|
<text x="120" y="156.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">fire</text>
|
||||||
|
<rect x="128" y="136" width="75" height="29" rx="4" fill="#f69426"/>
|
||||||
|
<text x="165.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.09</text>
|
||||||
|
<rect x="206" y="136" width="75" height="29" rx="4" fill="#f59c10"/>
|
||||||
|
<text x="243.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.34</text>
|
||||||
|
<rect x="284" y="136" width="75" height="29" rx="4" fill="#f68f33"/>
|
||||||
|
<text x="321.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.93</text>
|
||||||
|
<rect x="362" y="136" width="75" height="29" rx="4" fill="#b19b2f"/>
|
||||||
|
<text x="399.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.81</text>
|
||||||
|
<rect x="440" y="136" width="75" height="29" rx="4" fill="#fb7185"/>
|
||||||
|
<text x="477.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">6.23</text>
|
||||||
|
<rect x="518" y="136" width="75" height="29" rx="4" fill="#fa7674"/>
|
||||||
|
<text x="555.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.19</text>
|
||||||
|
<text x="120" y="188.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades-ind</text>
|
||||||
|
<rect x="128" y="168" width="75" height="29" rx="4" fill="#f78b3c"/>
|
||||||
|
<text x="165.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.83</text>
|
||||||
|
<rect x="206" y="168" width="75" height="29" rx="4" fill="#c99c22"/>
|
||||||
|
<text x="243.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.66</text>
|
||||||
|
<rect x="284" y="168" width="75" height="29" rx="4" fill="#7e984a"/>
|
||||||
|
<text x="321.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.12</text>
|
||||||
|
<rect x="362" y="168" width="75" height="29" rx="4" fill="#6f9852"/>
|
||||||
|
<text x="399.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.21</text>
|
||||||
|
<rect x="440" y="168" width="75" height="29" rx="4" fill="#d99c19"/>
|
||||||
|
<text x="477.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.56</text>
|
||||||
|
<rect x="518" y="168" width="75" height="29" rx="4" fill="#e09d16"/>
|
||||||
|
<text x="555.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.53</text>
|
||||||
|
<text x="120" y="220.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades</text>
|
||||||
|
<rect x="128" y="200" width="75" height="29" rx="4" fill="#f59b12"/>
|
||||||
|
<text x="165.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.31</text>
|
||||||
|
<rect x="206" y="200" width="75" height="29" rx="4" fill="#ef9d0d"/>
|
||||||
|
<text x="243.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.43</text>
|
||||||
|
<rect x="284" y="200" width="75" height="29" rx="4" fill="#f49d0b"/>
|
||||||
|
<text x="321.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.40</text>
|
||||||
|
<rect x="362" y="200" width="75" height="29" rx="4" fill="#e79d12"/>
|
||||||
|
<text x="399.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.48</text>
|
||||||
|
<rect x="440" y="200" width="75" height="29" rx="4" fill="#f69030"/>
|
||||||
|
<text x="477.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.97</text>
|
||||||
|
<rect x="518" y="200" width="75" height="29" rx="4" fill="#f29d0c"/>
|
||||||
|
<text x="555.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.42</text>
|
||||||
|
<text x="120" y="252.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-ind</text>
|
||||||
|
<rect x="128" y="232" width="75" height="29" rx="4" fill="#6d9854"/>
|
||||||
|
<text x="165.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.22</text>
|
||||||
|
<rect x="206" y="232" width="75" height="29" rx="4" fill="#4b9666"/>
|
||||||
|
<text x="243.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.42</text>
|
||||||
|
<rect x="284" y="232" width="75" height="29" rx="4" fill="#489667"/>
|
||||||
|
<text x="321.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.44</text>
|
||||||
|
<rect x="362" y="232" width="75" height="29" rx="4" fill="#479668"/>
|
||||||
|
<text x="399.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.45</text>
|
||||||
|
<rect x="440" y="232" width="75" height="29" rx="4" fill="#3d966d"/>
|
||||||
|
<text x="477.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.50</text>
|
||||||
|
<rect x="518" y="232" width="75" height="29" rx="4" fill="#4e9664"/>
|
||||||
|
<text x="555.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.41</text>
|
||||||
|
<text x="120" y="284.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">moavenin</text>
|
||||||
|
<rect x="128" y="264" width="75" height="29" rx="4" fill="#879945"/>
|
||||||
|
<text x="165.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.06</text>
|
||||||
|
<rect x="206" y="264" width="75" height="29" rx="4" fill="#179482"/>
|
||||||
|
<text x="243.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.73</text>
|
||||||
|
<rect x="284" y="264" width="75" height="29" rx="4" fill="#179482"/>
|
||||||
|
<text x="321.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.73</text>
|
||||||
|
<rect x="362" y="264" width="75" height="29" rx="4" fill="#179482"/>
|
||||||
|
<text x="399.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.73</text>
|
||||||
|
<rect x="440" y="264" width="75" height="29" rx="4" fill="#d09c1e"/>
|
||||||
|
<text x="477.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.62</text>
|
||||||
|
<rect x="518" y="264" width="75" height="29" rx="4" fill="#f59a14"/>
|
||||||
|
<text x="555.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.29</text>
|
||||||
|
<text x="120" y="316.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">Refah</text>
|
||||||
|
<rect x="128" y="296" width="75" height="29" rx="4" fill="#459669"/>
|
||||||
|
<text x="165.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.46</text>
|
||||||
|
<rect x="206" y="296" width="75" height="29" rx="4" fill="#1c947f"/>
|
||||||
|
<text x="243.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.71</text>
|
||||||
|
<rect x="284" y="296" width="75" height="29" rx="4" fill="#1e947e"/>
|
||||||
|
<text x="321.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.70</text>
|
||||||
|
<rect x="362" y="296" width="75" height="29" rx="4" fill="#20947d"/>
|
||||||
|
<text x="399.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.68</text>
|
||||||
|
<rect x="440" y="296" width="75" height="29" rx="4" fill="#0d9488"/>
|
||||||
|
<text x="477.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.81</text>
|
||||||
|
<rect x="518" y="296" width="75" height="29" rx="4" fill="#349572"/>
|
||||||
|
<text x="555.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.56</text>
|
||||||
|
<text x="120" y="348.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">website</text>
|
||||||
|
<rect x="128" y="328" width="75" height="29" rx="4" fill="#f8864b"/>
|
||||||
|
<text x="165.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.67</text>
|
||||||
|
<rect x="206" y="328" width="75" height="29" rx="4" fill="#f78e35"/>
|
||||||
|
<text x="243.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.91</text>
|
||||||
|
<rect x="284" y="328" width="75" height="29" rx="4" fill="#e29d15"/>
|
||||||
|
<text x="321.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.51</text>
|
||||||
|
<rect x="362" y="328" width="75" height="29" rx="4" fill="#ea9d10"/>
|
||||||
|
<text x="399.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.47</text>
|
||||||
|
<rect x="440" y="328" width="75" height="29" rx="4" fill="#879945"/>
|
||||||
|
<text x="477.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.06</text>
|
||||||
|
<rect x="518" y="328" width="75" height="29" rx="4" fill="#e59d13"/>
|
||||||
|
<text x="555.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.49</text>
|
||||||
|
<text x="120" y="380.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-comp</text>
|
||||||
|
<rect x="128" y="360" width="75" height="29" rx="4" fill="#93993f"/>
|
||||||
|
<text x="165.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.99</text>
|
||||||
|
<rect x="206" y="360" width="75" height="29" rx="4" fill="#bd9b28"/>
|
||||||
|
<text x="243.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.73</text>
|
||||||
|
<rect x="284" y="360" width="75" height="29" rx="4" fill="#bd9b28"/>
|
||||||
|
<text x="321.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.73</text>
|
||||||
|
<rect x="362" y="360" width="75" height="29" rx="4" fill="#b99b2b"/>
|
||||||
|
<text x="399.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.76</text>
|
||||||
|
<rect x="440" y="360" width="75" height="29" rx="4" fill="#7c984c"/>
|
||||||
|
<text x="477.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.13</text>
|
||||||
|
<rect x="518" y="360" width="75" height="29" rx="4" fill="#98993d"/>
|
||||||
|
<text x="555.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.96</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
41
docs/assets/decision/stage1-mean-composite.svg
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="420" viewBox="0 0 820 420" role="img" aria-label="Stage 1 mean composite">
|
||||||
|
<title>Stage 1 mean composite</title>
|
||||||
|
<rect width="820" height="420" fill="#ffffff"/>
|
||||||
|
<text x="24" y="32" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Stage 1 — mean composite (10 documents)</text>
|
||||||
|
<text x="24" y="50" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Higher is better. Official ranking used by Decision Board. Winner: fixed_size ±3.</text>
|
||||||
|
<line x1="281.2" y1="56" x2="281.2" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="281.2" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.5</text>
|
||||||
|
<line x1="352.5" y1="56" x2="352.5" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="352.5" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.6</text>
|
||||||
|
<line x1="423.7" y1="56" x2="423.7" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="423.7" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.7</text>
|
||||||
|
<line x1="495.0" y1="56" x2="495.0" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="495.0" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.8</text>
|
||||||
|
<line x1="566.3" y1="56" x2="566.3" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="566.3" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.9</text>
|
||||||
|
<line x1="637.5" y1="56" x2="637.5" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="637.5" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9.0</text>
|
||||||
|
<line x1="708.8" y1="56" x2="708.8" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="708.8" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9.1</text>
|
||||||
|
<line x1="780.0" y1="56" x2="780.0" y2="372" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="780.0" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9.2</text>
|
||||||
|
<rect x="210" y="66.0" width="456.2" height="32.7" rx="4" fill="#14b8a6" stroke="#047857" stroke-width="2"/>
|
||||||
|
<text x="200" y="88.2" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="700" fill="#111827">fixed_size ±3</text>
|
||||||
|
<text x="674.2" y="88.2" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">9.040</text>
|
||||||
|
<rect x="210" y="118.7" width="418.4" height="32.7" rx="4" fill="#14b8a6" stroke="none" stroke-width="0"/>
|
||||||
|
<text x="200" y="140.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">fixed_size ±2</text>
|
||||||
|
<text x="636.4" y="140.9" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.987</text>
|
||||||
|
<rect x="210" y="171.3" width="374.8" height="32.7" rx="4" fill="#14b8a6" stroke="none" stroke-width="0"/>
|
||||||
|
<text x="200" y="193.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">fixed_size ±1</text>
|
||||||
|
<text x="592.8" y="193.5" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.926</text>
|
||||||
|
<rect x="210" y="224.0" width="232.8" height="32.7" rx="4" fill="#f59e0b" stroke="none" stroke-width="0"/>
|
||||||
|
<text x="200" y="246.2" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">semantic @ large</text>
|
||||||
|
<text x="450.8" y="246.2" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.727</text>
|
||||||
|
<rect x="210" y="276.7" width="146.7" height="32.7" rx="4" fill="#99f6e4" stroke="none" stroke-width="0"/>
|
||||||
|
<text x="200" y="298.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">fixed_size ±0</text>
|
||||||
|
<text x="364.7" y="298.9" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.606</text>
|
||||||
|
<rect x="210" y="329.3" width="107.4" height="32.7" rx="4" fill="#fcd34d" stroke="none" stroke-width="0"/>
|
||||||
|
<text x="200" y="351.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">semantic @ nomic</text>
|
||||||
|
<text x="325.4" y="351.5" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.551</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
40
docs/assets/decision/stage2-margins.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="440" viewBox="0 0 820 440" role="img" aria-label="Stage 2 margins">
|
||||||
|
<title>Stage 2 margins</title>
|
||||||
|
<rect width="820" height="440" fill="#ffffff"/>
|
||||||
|
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Margin: fixed_size ±3 minus semantic @ large</text>
|
||||||
|
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Teal = fixed_size wins the document. Amber = semantic wins. Mean ranking is driven by large teal bars (especially fire).</text>
|
||||||
|
<line x1="442.0" y1="56" x2="442.0" y2="404" stroke="#111827" stroke-width="1.2"/>
|
||||||
|
<text x="120" y="428" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">semantic better</text>
|
||||||
|
<text x="764" y="428" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">fixed_size better</text>
|
||||||
|
<rect x="442.0" y="62.6" width="296.9" height="21.6" rx="3" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="77.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">fire</text>
|
||||||
|
<text x="744.9" y="77.7" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+2.58</text>
|
||||||
|
<rect x="442.0" y="97.4" width="128.1" height="21.6" rx="3" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="112.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">moavenin</text>
|
||||||
|
<text x="576.1" y="112.5" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+1.11</text>
|
||||||
|
<rect x="442.0" y="132.2" width="73.7" height="21.6" rx="3" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="147.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">havades-ind</text>
|
||||||
|
<text x="521.7" y="147.3" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+0.64</text>
|
||||||
|
<rect x="442.0" y="167.0" width="59.1" height="21.6" rx="3" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="182.1" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">havades</text>
|
||||||
|
<text x="507.1" y="182.1" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+0.51</text>
|
||||||
|
<rect x="437.5" y="201.8" width="4.5" height="21.6" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="112" y="216.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">bazresi</text>
|
||||||
|
<text x="431.5" y="216.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.04</text>
|
||||||
|
<rect x="435.1" y="236.6" width="6.9" height="21.6" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="112" y="251.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">lifetime-ind</text>
|
||||||
|
<text x="429.1" y="251.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.06</text>
|
||||||
|
<rect x="426.5" y="271.4" width="15.5" height="21.6" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="112" y="286.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Refah</text>
|
||||||
|
<text x="420.5" y="286.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.13</text>
|
||||||
|
<rect x="399.7" y="306.2" width="42.3" height="21.6" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="112" y="321.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">lifetime-comp</text>
|
||||||
|
<text x="393.7" y="321.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.37</text>
|
||||||
|
<rect x="382.0" y="341.0" width="60.0" height="21.6" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="112" y="356.1" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">customer1</text>
|
||||||
|
<text x="376.0" y="356.1" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.52</text>
|
||||||
|
<rect x="373.9" y="375.8" width="68.1" height="21.6" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="112" y="390.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">website</text>
|
||||||
|
<text x="367.9" y="390.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.59</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
31
docs/assets/decision/stage2-metrics.svg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="380" viewBox="0 0 820 380" role="img" aria-label="Stage 2 mean metrics">
|
||||||
|
<title>Stage 2 mean metrics</title>
|
||||||
|
<rect width="820" height="380" fill="#ffffff"/>
|
||||||
|
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Stage 2 winners — mean judge metrics</text>
|
||||||
|
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">fixed_size ±3 leads on all four metrics, including lower hallucination.</text>
|
||||||
|
<rect x="620" y="14" width="12" height="12" rx="2" fill="#14b8a6"/>
|
||||||
|
<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">fixed_size ±3</text>
|
||||||
|
<rect x="760" y="14" width="12" height="12" rx="2" fill="#f59e0b"/>
|
||||||
|
<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">semantic @ large</text>
|
||||||
|
<rect x="89.9" y="80.0" width="52.1" height="252.0" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="148.0" y="80.7" width="52.1" height="251.3" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="116.0" y="74.0" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.40</text>
|
||||||
|
<text x="174.0" y="74.7" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.38</text>
|
||||||
|
<text x="145.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Context relevance</text>
|
||||||
|
<rect x="275.9" y="92.1" width="52.1" height="239.9" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="334.0" y="95.3" width="52.1" height="236.7" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="302.0" y="86.1" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.95</text>
|
||||||
|
<text x="360.0" y="89.3" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.83</text>
|
||||||
|
<text x="331.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Answer similarity</text>
|
||||||
|
<rect x="461.9" y="78.2" width="52.1" height="253.8" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="520.0" y="82.7" width="52.1" height="249.3" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="488.0" y="72.2" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.47</text>
|
||||||
|
<text x="546.0" y="76.7" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.30</text>
|
||||||
|
<text x="517.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Faithfulness</text>
|
||||||
|
<rect x="647.9" y="241.0" width="52.1" height="91.0" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="706.0" y="133.5" width="52.1" height="198.5" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="674.0" y="235.0" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">2.2%</text>
|
||||||
|
<text x="732.0" y="127.5" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">4.8%</text>
|
||||||
|
<text x="703.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Hallucination % (lower better)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
51
docs/assets/decision/stage2-per-document.svg
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="460" viewBox="0 0 920 460" role="img" aria-label="Stage 2 per document">
|
||||||
|
<title>Stage 2 per document</title>
|
||||||
|
<rect width="920" height="460" fill="#ffffff"/>
|
||||||
|
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Stage 2 — per document (fixed_size ±3 vs semantic @ large)</text>
|
||||||
|
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Mean composite still favors fixed_size. Semantic wins 6 of 10 docs, but by smaller margins except website/customer1.</text>
|
||||||
|
<rect x="620" y="14" width="12" height="12" rx="2" fill="#14b8a6"/>
|
||||||
|
<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">fixed_size ±3</text>
|
||||||
|
<rect x="760" y="14" width="12" height="12" rx="2" fill="#f59e0b"/>
|
||||||
|
<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">semantic @ large</text>
|
||||||
|
<line x1="52" y1="357.3" x2="896" y2="357.3" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="44" y="361.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">6</text>
|
||||||
|
<line x1="52" y1="284.0" x2="896" y2="284.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="44" y="288.0" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">7</text>
|
||||||
|
<line x1="52" y1="210.7" x2="896" y2="210.7" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="44" y="214.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8</text>
|
||||||
|
<line x1="52" y1="137.3" x2="896" y2="137.3" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="44" y="141.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9</text>
|
||||||
|
<line x1="52" y1="64.0" x2="896" y2="64.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||||
|
<text x="44" y="68.0" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">10</text>
|
||||||
|
<rect x="64.2" y="110.5" width="27.0" height="261.5" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="97.2" y="107.6" width="27.0" height="264.4" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="94.2" y="424" text-anchor="end" transform="rotate(-32 94.2 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">bazresi</text>
|
||||||
|
<rect x="148.6" y="177.6" width="27.0" height="194.4" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="181.6" y="139.3" width="27.0" height="232.7" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="178.6" y="424" text-anchor="end" transform="rotate(-32 178.6 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">customer1</text>
|
||||||
|
<rect x="233.0" y="151.3" width="27.0" height="220.7" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="266.0" y="340.6" width="27.0" height="31.4" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="263.0" y="424" text-anchor="end" transform="rotate(-32 263.0 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">fire</text>
|
||||||
|
<rect x="317.4" y="122.3" width="27.0" height="249.7" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="350.4" y="169.3" width="27.0" height="202.7" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="347.4" y="424" text-anchor="end" transform="rotate(-32 347.4 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades-ind</text>
|
||||||
|
<rect x="401.8" y="175.2" width="27.0" height="196.8" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="434.8" y="212.9" width="27.0" height="159.1" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="431.8" y="424" text-anchor="end" transform="rotate(-32 431.8 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades</text>
|
||||||
|
<rect x="486.2" y="104.7" width="27.0" height="267.3" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="519.2" y="100.3" width="27.0" height="271.7" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="516.2" y="424" text-anchor="end" transform="rotate(-32 516.2 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-ind</text>
|
||||||
|
<rect x="570.6" y="83.4" width="27.0" height="288.6" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="603.6" y="165.1" width="27.0" height="206.9" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="600.6" y="424" text-anchor="end" transform="rotate(-32 600.6 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">moavenin</text>
|
||||||
|
<rect x="655.0" y="87.5" width="27.0" height="284.5" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="688.0" y="77.6" width="27.0" height="294.4" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="685.0" y="424" text-anchor="end" transform="rotate(-32 685.0 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">Refah</text>
|
||||||
|
<rect x="739.4" y="176.5" width="27.0" height="195.5" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="772.4" y="133.1" width="27.0" height="238.9" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="769.4" y="424" text-anchor="end" transform="rotate(-32 769.4 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">website</text>
|
||||||
|
<rect x="823.8" y="154.9" width="27.0" height="217.1" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="856.8" y="127.9" width="27.0" height="244.1" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="853.8" y="424" text-anchor="end" transform="rotate(-32 853.8 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-comp</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
61
docs/assets/human-eval/fixed_size_plus3_human_scores.csv
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
document,question_id,question,expected_answer,generated_answer,llm_context_relevance,llm_answer_similarity,llm_faithfulness,llm_hallucination,llm_composite,human_score,human_notes,agreement,experiment_id
|
||||||
|
bazresi.docx,q1,این سند در مورد چیست؟,این سند شامل پرسشهای پرتکرار از سوی مشتریان در اداره پاسخگویی و رسیدگی به شکایات شرکت بیمه آسیا است که پاسخهایی درباره مهلت پرداخت خسارت، ثبت شکایت، شماره شبا و سایر موارد اداری ارائه میدهد.,این سند در مورد پرسشهای متداول مشتریان در اداره پاسخگویی و رسیدگی به شکایات بیمه آسیا و اطلاعات مربوط به پرداخت خسارت، نحوه دریافت بیمهنامه، ثبت شکایت و سایر خدمات بیمهای است.,9,8,9,False,8.6,3,Partially correct; missing secondary details.,partial,6fe08750ee324130b3d46d8b3bc95280
|
||||||
|
bazresi.docx,q3,مدت زمان پرداخت خسارت بدنه چقدر است؟,طبق ماده 20 آیین نامه 53 بیمه بدنه، مدت زمان پرداخت خسارت بدنه 15 روز کاری پس از تکمیل پرونده میباشد. در صورت تأیید اصالت حادثه و بالا بودن مبلغ خسارت، امکان واریز علی الحساب تا 60 درصد مبلغ خسارت وجود دارد که منوط به ثبت درخواست کتبی از سوی زیاندیده در شعبه پرداخت خسارت است.,مدت زمان پرداخت خسارت بدنه 15 روز کاری پس از تکمیل پرونده می باشد.,10,8,9,False,8.9,4,Aligns with expected answer; key points covered.,agree,6fe08750ee324130b3d46d8b3bc95280
|
||||||
|
bazresi.docx,q6,نحوه ثبت الحاقیه یا فسخ بیمهنامه بدنه مربوط به خودروهای شرکتهای لیزینگی چگونه است؟,بیمهگذار (مستأجر خودرو) باید به واحد صدور یا شعبه ناظر واحد صادرکننده مراجعه نموده و با ارائه اصل بیمهنامه بدنه، مدارک فک رهنی، سند مالکیت و درخواست کتبی نسبت به صدور الحاقیه یا فسخ اقدام نماید.,بیمهگذار (مستأجر خودرو) باید به واحد صدور ذکر شده در بیمهنامه یا شعبه ناظر واحد صادر کننده مراجعه نموده و با ارائه اصل بیمهنامه بدنه، مدارک فک رهنی، سند مالکیت و درخواست کتبی خود مبنی بر صدور الحاقیه یا فسخ بیمهنامه نسبت به این مهم اقدام نماید. در صورتی که بیمهگذار، ساکن استانی غیر از استان محل استقرار واحد صدور بیمهنامه باشد، باید با در دست داشتن مدارک فوقالذکر به شعبه مرکزی آن استان مراجعه و درخواست کتبی خود را ارائه نماید.,10,9,10,False,9.6,5,Aligns with expected answer; key points covered.,agree,6fe08750ee324130b3d46d8b3bc95280
|
||||||
|
bazresi.docx,q9,آیا نتیجه اعتراض به مبلغ خسارت کارشناس الزاماً منجر به افزایش مبلغ میشود؟,خیر. زیاندیده میتواند با مراجعه به رئیس شعبه خسارت، درخواست کتبی جهت اعتراض ثبت نماید. اما نتیجه اعتراض الزاماً منجر به افزایش مبلغ خسارت نمیشود و ممکن است پس از کارشناسی مجدد، مبلغ تأیید شود یا حتی کاهش یابد.,خیر، نتیجه اعتراض الزاماً منجر به افزایش میزان مبلغ خسارت نمیشود و ممکن است پس از بررسی و کارشناسی مجدد، مبلغ مربوطه تأیید شود یا کاهش یابد.,9,9,10,False,9.3,5,Aligns with expected answer; key points covered.,agree,6fe08750ee324130b3d46d8b3bc95280
|
||||||
|
bazresi.docx,q11,آیا امکان دریافت فیزیک بیمهنامه ثالث از طریق لینک پیامکی وجود نداشته باشد، چه باید کرد؟,در صورت عدم دسترسی به لینک، با مراجعه به سایت بیمه آسیا (www.bimehasia.com) و بخش خدمات الکترونیک -> استعلامها -> استعلام و چاپ بیمهنامه ثالث با وارد کردن کد بیمهگذار و شماره بیمهنامه میتوان فایل را دریافت کرد.,در صورتی که از طریق لینک پیامکی قادر به ورود به صفحه مربوطه نبودید، میتوانید با مراجعه به سایت بیمه آسیا به نشانی www.bimehasia.com و ورود به قسمت خدمات الکترونیک، استعلامها، استعلام و چاپ بیمه نامه ثالث و با وارد کردن کد بیمهگذار و شماره بیمهنامه مربوطه، نسبت به مشاهده، چاپ یا دریافت فایل بیمهنامه ثالث اقدام نمایید.,9,9,10,False,9.3,4,Aligns with expected answer; key points covered.,partial,6fe08750ee324130b3d46d8b3bc95280
|
||||||
|
bazresi.docx,q14,شماره تماس واحد پاسخگویی و رسیدگی به شکایات چیست؟,در سند اطلاعات تماس مستقیم واحد پاسخگویی ذکر نشده اما از طریق سامانههای رسیدگی به شکایات بیمه مرکزی یا بازرسی کل کشور یا از طریق سایت بیمه آسیا قابل دسترسی است.,متأسفانه، اطلاعاتی درباره شماره تماس واحد پاسخگویی و رسیدگی به شکایات در متن موجود نیست.,7,8,9,False,8.0,5,Aligns with expected answer; key points covered.,partial,6fe08750ee324130b3d46d8b3bc95280
|
||||||
|
customer1.docx,q1,این سند در مورد چیست؟,این سند شامل پرسشهای متداول درباره استعلام و مناقصه بیمهای، توسعه بازار و کد یونیک، و شرایط انعقاد تفاهمنامه شرکت بیمه آسیا است.,این سند در مورد مناقصات بیمهای و استعلامات مرتبط با آن است. اطلاعاتی درباره تعریف مناقصه، انواع مناقصات، قوانین برگزاری، و شرایط مختلف مرتبط با مناقصات بیمهای ارائه میدهد.,8,7,8,False,7.6,4,Aligns with expected answer; key points covered.,agree,8c4d888d367b4c0299b2d19dd9654f80
|
||||||
|
customer1.docx,q4,مناقصه بیمهای و استعلام بیمهای چه تفاوتی با یکدیگر دارند؟,تفاوت کاربرد استعلام بها و مناقصه به میزان مبلغ معامله بستگی خواهد داشت. بر اساس ماده 3 قانون برگزاری مناقصات، معاملات کوچک و متوسط (بالاتر از ده درصد نصاب مربوط) متفاوت از معاملات بزرگ اجرا میشوند.,تفاوت مناقصه بیمهای و استعلام بیمهای به میزان مبلغ معامله بستگی دارد. برای معاملات کوچک و متوسط (تا 2.850 میلیون ریال) از استعلام بها استفاده میشود، در حالی که برای معاملات بزرگ (بیش از 28.500 میلیون ریال) باید از طریق مناقصه عمومی یا محدود اقدام شود.,9,8,9,False,8.6,4,Aligns with expected answer; key points covered.,agree,8c4d888d367b4c0299b2d19dd9654f80
|
||||||
|
customer1.docx,q7,آیا ثبت معاملات در سامانه ستاد الزامی است؟,بله. ثبت کلیه معاملات دولت اعم از کوچک و متوسط و بزرگ در سامانه ستاد الزامی است. طبق مکاتبه معاونت حقوقی رئیس جمهور، ثبت معاملات کوچک (بالاتر از ده درصد نصاب مربوط) در سامانه تدارکات الکترونیکی الزامی است.,بله، ثبت کلیه معاملات دولت اعم از کوچک و متوسط و بزرگ در سامانه ستاد الزامی است.,8,9,9,False,8.7,4,Aligns with expected answer; key points covered.,partial,8c4d888d367b4c0299b2d19dd9654f80
|
||||||
|
customer1.docx,q11,آیا میتوان بخشی از ریسک را بین چند بیمهگر تقسیم کرد؟,بله، از طریق بیمه اتکایی، این روش در پروژههای بزرگ متداول است.,بله، از طریق بیمه اتکایی، این روش در پروژههای بزرگ متداول است.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,8c4d888d367b4c0299b2d19dd9654f80
|
||||||
|
customer1.docx,q14,شرایط اخذ کد یونیک چیست؟,بیمهگذاران باید سراسری باشند (پراکندگی و فعالیت در کل کشور) و دارای چند رشته بیمهای باشند.,بیمه گذاران باید سراسری باشند (پراکندگی و فعالیت در کل کشور) و دارای چند رشته بیمه ای باشند.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,8c4d888d367b4c0299b2d19dd9654f80
|
||||||
|
customer1.docx,q17,برای اخذ تخفیفات و تسهیلات چه اقداماتی نیاز است؟,نامه درخواست شرکت متقاضی و نامه درخواست نماینده/شعبه.,برای اخذ تخفیفات و تسهیلات، نیاز به نامه درخواست شرکت متقاضی و نامه درخواست نماینده/شعبه است.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,8c4d888d367b4c0299b2d19dd9654f80
|
||||||
|
fire.docx,q1,این سند در مورد چیست؟,این سند شامل شرایط خصوصی هر یک از پوششهای اضافی (خطرات اضافی) بیمه آتشسوزی شامل سیل، زلزله، ترکیدگی لوله آب، نشت باران، سقوط هواپیما، اعتصاب و شورش، برخورد جسم خارجی، شکست شیشه، انفجار ظروف تحت فشار، سرقت با شکست حرز و سایر خطرات است.,این سند در مورد شرایط و مقررات بیمهنامههای مختلف، شامل بیمههای آتشسوزی، سرقت، خسارات ناشی از اعتصاب و شورش، و سایر خطرات مرتبط با بیمه است. همچنین به تعهدات بیمهگر و بیمهگذار، استثنائات، فرانشیزها و حداکثر تعهدات مالی اشاره دارد.,8,7,8,False,7.6,4,Aligns with expected answer; key points covered.,agree,230a3cf57ae74b679ecde83cfeb9b6f5
|
||||||
|
fire.docx,q4,پوشش ترکیدگی لوله آب شامل چه مواردی است و چه استثنائاتی دارد؟,شامل: خسارات ناشی از ترکیدگی یا درز برداشتن لولههای آب یا فاضلاب متصل به شبکه آبرسانی یا سرریز شدن منابع و مخازن آب. استثنائات: هزینه ترمیم/استهلاک، خسارت منشاء حادثه، خسارات ناشی از سیل/طوفان/زلزله، نشت اسپرینکلر، ترکیدن در صورت خالی بودن بیش از 24 ساعت، یخزدگی در محیط روباز، خسارات شیرها، فرانشیز. فرانشیز: 10 درصد.,پوشش ترکیدگی لوله آب شامل خسارات ناشی از ترکیدگی یا درز برداشتن (جداشدن اتصالات) لولههای آب یا فاضلاب متصل به شبکه آبرسانی یا سرریز شدن منابع و مخازن آب است، مشروط بر اینکه در محل مورد بیمه نصب شده باشد. استثنائات شامل موارد زیر است: 1. هرگونه هزینه مربوط به ترمیم، استهلاک، فرسودگی یا پوسیدگی لولهها و مخازن آب. 2. خسارت وارده به منبع حادثه اعم از لوله یا مخزن. 3. خسارت ناشی از سیل، طوفان، زلزله و یا آتشفشان. 4. خسارت ناشی از شارژ یا تخلیه مخازن مرتبط با سیستمهای آبفشان اتوماتیک (اسپرینکلر) ی,9,9,9,False,9.0,5,Aligns with expected answer; key points covered.,agree,230a3cf57ae74b679ecde83cfeb9b6f5
|
||||||
|
fire.docx,q7,تفاوت پوشش اعتصاب و شورش بین انبار، صنعتی و مسکونی چیست؟,انبار: حداکثر 15% سرمایه بیمهشده، سقف هر حادثه 50 میلیارد ریال، مجموع 100 میلیارد ریال، فرانشیز 20% و حداقل 50 میلیون ریال. صنعتی: 20% سرمایه، سقف هر حادثه 100 میلیارد ریال، مجموع 200 میلیارد ریال، فرانشیز 20% و حداقل 200 میلیون ریال. مسکونی و غیرصنعتی: 15% سرمایه، سقف 10 میلیارد ریال، مجموع 20 میلیارد ریال، فرانشیز 20% و حداقل 10 میلیون ریال. رخدادهای 72 ساعت متوالی حادثه واحد تلقی میگردد.,تفاوت پوشش اعتصاب و شورش بین انبار، صنعتی و مسکونی به شرح زیر است: 1. **انبار**: - حداکثر تعهد بیمهگر: 15 درصد سرمایه بیمه شده و حداکثر تا سقف 50 میلیارد ریال در هر حادثه و 100 میلیارد ریال در طول مدت بیمه. 2. **صنعتی**: - حداکثر تعهد بیمهگر: 20 درصد سرمایه بیمه شده و حداکثر تا سقف 100 میلیارد ریال در هر حادثه و 200 میلیارد ریال در طول مدت بیمه. 3. **مسکونی و غیر صنعتی**: - حداکثر تعهد بیمهگر: 15 درصد سرمایه بیمه شده و حداکثر تا سقف 10 میلیارد ریال در هر حادثه و 20 میلیارد ریال در,9,9,9,False,9.0,5,Aligns with expected answer; key points covered.,agree,230a3cf57ae74b679ecde83cfeb9b6f5
|
||||||
|
fire.docx,q11,شرایط بیمه سرقت با شکست حرز شامل چه مواردی است؟,شکست حرز: بالا رفتن از دیوار، شکستن درب و پنجره و شیشه، خراب کردن دیوار، ورود با تهدید. بیمه دزدي یا فقدان اشیا را تأمین میکند. وظایف بیمهگذار: اطلاعرسانی فوری، حفظ صحنه، ارائه مدارک. در صورت عدم انجام تعهدات، بیمهگر میتواند از پرداخت خسارت خودداری کند. فرانشیز: 10 درصد.,شرایط بیمه سرقت با شکست حرز شامل موارد زیر است: 1. **تعریف سرقت**: بیمه دزدی یا فقدان اشیاء بیمه شده یا خراب شدن آنها را که در نتیجه دزدی با شکست حرز پدید آمده باشد تأمین میکند. 2. **موارد با شکست حرز**: - بالا رفتن از دیوار یا شکستن درب و پنجره و شیشه و خراب کردن دیوار و نظایر آن. - باز کردن درب به وسیله کلیدهای تقلبی و سایر آلاتی که برای باز کردن عادی قفل به کار نمیرود. (ورود به محل مورد بیمه در شب به منزله شکست حرز تلقی خواهد شد.) - ورود به محل مورد بیمه شده با تهدید بیمهگذار و ,9,8,9,False,8.6,3,Partially correct; missing secondary details.,partial,230a3cf57ae74b679ecde83cfeb9b6f5
|
||||||
|
fire.docx,q14,پوشش خودسوزی شامل چه شرایطی است؟,حداکثر تعهد بیمهگر در قبال خسارت خودسوزی معادل یک درصد مبلغ موجودی بند مربوطه بیمهنامه است.,پوشش خودسوزی شامل شرایط زیر است: 1. حداکثر تعهد بیمهگر در قبال خسارت خودسوزی معادل یک درصد مبلغ موجودی بیمهنامه است. 2. فرانشیز خسارت در هر حادثه، معادل 20 درصد مبلغ خسارت خودسوزی خواهد بود.,8,9,9,False,8.7,5,Aligns with expected answer; key points covered.,agree,230a3cf57ae74b679ecde83cfeb9b6f5
|
||||||
|
fire.docx,q17,پوشش ریزش مواد مذاب شامل چه مواردی است؟,خسارات ناشی از ریزش مواد مذاب به علت شکست، سوراخ شدن بدنه کوره یا پاشش و سرریز شدن مواد مذاب. فرانشیز: 10 درصد مبلغ خسارت قابل پرداخت.,پوشش ریزش مواد مذاب شامل خسارتهای وارده به اموال مورد بیمه که ناشی از ریزش مواد مذاب به علت شکست، سوراخ شدن بدنه کوره و یا پاشش و سرریز شدن مواد مذاب میباشد. همچنین خسارات ناشی از این خطر که به سایر موارد تحت پوشش خارج از کوره وارد گردد، تحت پوشش خواهد بود.,9,8,9,False,8.6,5,Aligns with expected answer; key points covered.,partial,230a3cf57ae74b679ecde83cfeb9b6f5
|
||||||
|
general-havades-individuals.doc,q1,این سند در مورد چیست؟,این سند شامل شرایط عمومی بیمه حوادث اشخاص است که بر اساس قانون بیمه مصوب 1316 و درخواست کتبی بیمهگذار تنظیم شده و تعاریف، خطرات بیمهشده، وظایف طرفین، موارد خارج از تعهد، فسخ و انفساخ را پوشش میدهد.,این سند در مورد شرایط و مقررات بیمه حوادث اشخاص است.,8,7,9,False,7.9,4,Aligns with expected answer; key points covered.,agree,7ad892cfcd6143109f77a7808c8dfbd9
|
||||||
|
general-havades-individuals.doc,q4,تعریف حادثه در این بیمهنامه چیست؟,حادثه: هر واقعه ناگهانی ناشی از عامل خارجی که بدون قصد و اراده بیمهشده در مدت بیمه رخ دهد و منجر به جرح، نقص عضو، از کار افتادگی و یا فوت بیمهشده گردد.,حادثه موضوع این بیمه، هر واقعه ناگهانی ناشی از عامل خارجی است که بدون قصد و اراده بیمه شده در مدت بیمه رخ دهد و منجر به جرح، نقص عضو، از کار افتادگی و یا فوت بیمه شده گردد.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,7ad892cfcd6143109f77a7808c8dfbd9
|
||||||
|
general-havades-individuals.doc,q7,قوانین پرداخت حق بیمه و عواقب عدم پرداخت قسطی در ماده ۵ چیست؟,بیمهنامه با تقاضای بیمهگذار و قبول بیمهگر صادر میشود اما شروع پوشش بیمهای و اجرای تعهدات بیمهگر منوط به پرداخت حق بیمه است. اگر پرداخت قسطی باشد و بیمهگذار قسط موعد رسیده را پرداخت نکند، بیمهگر میتواند بیمهنامه را با رعایت ماده ۱۲ فسخ نماید.,ماده 5 بیان میکند که بیمه نامه با تقاضای بیمه گذار و قبول بیمه گر صادر میشود، اما شروع پوشش بیمهای و اجرای تعهدات بیمه گر منوط به پرداخت حق بیمه است. اگر پرداخت حق بیمه به صورت قسطی باشد و بیمه گذار هر یک از اقساط موعد رسیده را پرداخت نکند، بیمه گر میتواند بیمه نامه را فسخ کند. اگر بیمه گر بیمه نامه را فسخ نکرده باشد و حادثهای رخ دهد، خسارت به نسبت حق بیمه پرداخت شده به حق بیمهای که باید تا زمان وقوع حادثه پرداخت میشد، پرداخت خواهد شد، مگر اینکه در شرایط خصوصی بیمه نامه ضوابط دیگری درج شد,9,8,9,False,8.6,3,Partially correct; missing secondary details.,partial,7ad892cfcd6143109f77a7808c8dfbd9
|
||||||
|
general-havades-individuals.doc,q11,نحوه تعیین غرامت فوت چگونه است؟,در صورت فوت بیمهشده به علت خطرات مشمول، بیمهگر متعهد است سرمایه بیمه را طبق شرایط بیمهنامه، الحاقیه و یا هرگونه توافق کتبی دیگر به ذینفع بپردازد.,در صورتی که بیمه شده به علت وقوع یکی از خطرات مشمول این بیمه نامه فوت کند، بیمه گر متعهد است سرمایه بیمه را طبق شرایط این بیمه نامه و الحاقیه آن یا هرگونه توافق کتبی دیگر به ذینفع پرداخت نماید.,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,7ad892cfcd6143109f77a7808c8dfbd9
|
||||||
|
general-havades-individuals.doc,q14,آیا خطرات جنگ، زلزله و ورزشهای خطرناک قابل پوشش هستند؟,خطرات بندهای ط (جنگ/شورش)، ی (زلزله/آتشفشان/هستهای) و ک (ورزشهای خطرناک) با موافقت کتبی بیمهگر و اخذ حق بیمه مربوط قابل پوشش است. برای پوشش جنگ: بیمهشده نباید در تحقق خطر مشارکت داشته باشد و بیمهگر میتواند با اخطار ۱۰ روزه پوشش را لغو کند. تبصره ۲: موسسات بیمه میتوانند با موافقت قبلی بیمه مرکزی خطرات اضافی دیگر را بیمه نمایند.,خیر، خطرات جنگ، زلزله و ورزشهای خطرناک تحت پوشش بیمه نیستند، مگر اینکه با موافقت کتبی بیمهگر و اخذ حق بیمه مربوطه پوشش داده شوند.,8,7,6,False,7.0,2,Answer incomplete compared to expected reference.,disagree,7ad892cfcd6143109f77a7808c8dfbd9
|
||||||
|
general-havades-individuals.doc,q17,انفساخ بیمهنامه (ماده ۱۳) چه زمانی رخ میدهد و حق بیمه چگونه محاسبه میشود؟,در صورت فوت بیمهشده به علت خطر تحت پوشش نباشد، بیمهنامه از زمان فوت منفسخ میشود. حق بیمه مدت مقتضی شده بر اساس تعرفه روز شمار محاسبه میگردد. تبصره: در قراردادها agrupadas، پوشش فقط برای بیمهشده متوفی لغو میشود.,انفساخ بیمهنامه در صورت فوت بیمه شده به علت تحقق خطری که تحت پوشش این بیمهنامه نباشد، رخ میدهد. در این صورت، حق بیمه مدت مقتضی شده بر اساس تعرفه روز شمار محاسبه میشود.,9,9,9,False,9.0,5,Aligns with expected answer; key points covered.,agree,7ad892cfcd6143109f77a7808c8dfbd9
|
||||||
|
havades.docx,q1,این سند در مورد چیست؟,این سند شامل معرفی رشتههای مختلف بیمه حوادث شرکت بیمه آسیا شامل حوادث انفرادی، گروهی، گردشگری، ورزشی، اصناف، دانشجویی و غیره است.,این سند در مورد بیمه حوادث است و شامل توضیحات مربوط به انواع بیمههای حوادث، پوششهای بیمهای، شرایط و هزینهها، و نحوه درخواست بیمهنامهها میباشد.,9,8,9,False,8.6,4,Aligns with expected answer; key points covered.,agree,04bd45d836a64c3da2e68bb747c0c9b8
|
||||||
|
havades.docx,q4,حوادث انفرادی عادی چه پوششی ارائه میدهد؟,در صورت فوت، نقص عضو یا از کارافتادگی دائم بر اثر حادثه و یا پرداخت هزینه درمان ناشی از حادثه، سرمایه بیمه به ذینفع تعیینشده پرداخت میگردد.,حوادث انفرادی عادی پوششهایی برای فوت، نقص عضو و از کارافتادگی دائم کلی و جزئی بر اثر حادثه ارائه میدهد. در صورت فوت، سرمایه بیمه منتخب به ذینفع یا وراث قانونی پرداخت میشود. در صورت از کارافتادگی دائم کلی، کل سرمایه مندرج در بیمه نامه به شخص بیمه شده پرداخت میگردد و در صورت از کارافتادگی دائم جزئی، درصدی از مبلغ سرمایه به شخص بیمه شده پرداخت میشود.,9,9,9,False,9.0,5,Aligns with expected answer; key points covered.,agree,04bd45d836a64c3da2e68bb747c0c9b8
|
||||||
|
havades.docx,q7,بیمه حوادث گردشگری شامل چه طرحهایی است و مناسب چه کسانی است؟,گردشگران فردی یا در قالب تورها برای سفرهای سیاحتی، زیارتی، تجاری. همچنین بیمه حوادث عتبات عالیات برای کاروانها و آژانسهای مسافرتی با طرحهای 10، 15 و 30 روزه.,بیمه حوادث گردشگری شامل طرحهایی برای کلیه گردشگران است که به صورت فردی یا در قالب تورهای مسافرتی عازم سفرهای سیاحتی، زیارتی، تجارتی و... هستند. این بیمه به مسافران و خانوادههایشان آرامش را در طول سفر هدیه میدهد و مسئولین آژانسهای مسافرتی نیز میتوانند با انعقاد قراردادهای حوادث گروهی کوتاه مدت از تخفیفات گروهی بهرهمند شوند.,9,8,9,False,8.6,3,Partially correct; missing secondary details.,partial,04bd45d836a64c3da2e68bb747c0c9b8
|
||||||
|
havades.docx,q11,بیمه حوادث ورزشی برای چه کسانی قابل استفاده است؟,ورزشکاران حرفهای و یا غیرحرفهای با توجه به نوع فعالیت ورزشی در برابر حوادث منجر به فوت، نقص عضو یا هزینه پزشکی به صورت گروهی و یا انفرادی تحت پوشش قرار میگیرند.,بیمه حوادث ورزشی برای ورزشکاران حرفهای و غیر حرفهای قابل استفاده است.,10,8,10,False,9.2,3,Partially correct; missing secondary details.,partial,04bd45d836a64c3da2e68bb747c0c9b8
|
||||||
|
havades.docx,q14,مدت زمان بیمهنامه حوادث چقدر است؟,جز در مواردی که در بیمهنامه حوادث به نحو دیگری توافق شده باشد، مدت این نوع بیمهنامه یک سال شمسی است.,مدت زمان بیمهنامه حوادث یکسال شمسی است، جز در مواردی که در بیمه نامه به نحو دیگری توافق شده باشد.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,04bd45d836a64c3da2e68bb747c0c9b8
|
||||||
|
havades.docx,q17,شماره تماسهای واحدهای فروش بیمههای عمر و حوادث چیست؟,واحد فروش بیمههای عمر و حوادث انفرادی: 8707 داخلی 3304-3322-3317-3305. واحد فروش بیمههای عمر و حوادث گروهی: 8707 داخلی 3320-3321-3325.,شماره تماس واحد فروش بیمههای عمر و حوادث انفرادی: 8707 داخلی 3304-3322-3317-3305 شماره تماس واحد فروش بیمههای عمر و حوادث گروهی: 8707 داخلی 3320-3321-3325,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,04bd45d836a64c3da2e68bb747c0c9b8
|
||||||
|
life-time-individual.docx,q1,این سند در مورد چیست؟,این سند شامل انواع بیمهنامه عمر زمانی شامل: بیمه عمر زمانی انفرادی، بیمه عمر زمانی گروهی، بیمه عمر زمانی مانده بدهکار انفرادی، و بیمه عمر زمانی مانده بدهکار گروهی، به همراه راهنمای صدور است.,این سند در مورد انواع بیمه نامههای عمر زمانی است، شامل بیمه عمر زمانی انفرادی، گروهی، و مانده بدهکار.,9,8,9,False,8.6,3,Partially correct; missing secondary details.,partial,d682706f54824469b235c099bf5a3d3c
|
||||||
|
life-time-individual.docx,q4,در صورت فوت بیمهشده در بیمه عمر زمانی انفرادی، سرمایه چگونه پرداخت میشود؟,در صورتی که بیمهشده در طول مدت بیمهنامه به هر علت فوت نماید، سرمایه انتخابی توسط شرکت بیمه به ذینفع تعیینشده پرداخت میگردد.,در صورت فوت بیمهشده در بیمه عمر زمانی انفرادی، سرمایه انتخابی توسط شرکت بیمه به ذینفع تعیین شده پرداخت میگردد.,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,d682706f54824469b235c099bf5a3d3c
|
||||||
|
life-time-individual.docx,q7,مبنای تعیین نرخ در بیمه عمر زمانی مانده بدهکار انفرادی چه مواردی است؟,مبنای تعیین نرخ در این نوع بیمهنامهها: سن بیمهشده، مدت بازپرداخت وام، و وضعیت سلامتی بیمهشده در زمان اخذ بیمهنامه میباشد.,مبنای تعیین نرخ در بیمه عمر زمانی مانده بدهکار انفرادی شامل سن بیمه شده، مدت بازپرداخت وام و وضعیت سلامتی بیمه شده در زمان اخذ بیمه نامه میباشد.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,d682706f54824469b235c099bf5a3d3c
|
||||||
|
life-time-individual.docx,q11,تفاوت بیمه عمر زمانی مانده بدهکار انفرادی و گروهی چیست؟,در نوع انفرادی، خود فرد وامگیرنده بیمه میشود. در نوع گروهی، بانکها/موسسات مالی/سازمانها برای مجموعه وامگیرندگان خود به صورت گروهی بیمه میکنند. در هر دو صورت، در صورت فوت وامگیرنده، مابقی اقساط توسط شرکت بیمه پرداخت میشود.,تفاوت بیمه عمر زمانی مانده بدهکار انفرادی و گروهی در این است که: - **بیمه عمر زمانی مانده بدهکار انفرادی**: این نوع بیمه برای افرادی است که به صورت انفرادی وام دریافت کردهاند و در صورت فوت قبل از تسویه کامل وام، مابقی اقساط توسط شرکت بیمه به بانک پرداخت میشود. - **بیمه عمر زمانی مانده بدهکار گروهی**: این نوع بیمه برای گروهی از افراد است که از یک بانک یا موسسه مالی وام دریافت کردهاند. در صورت فوت هر یک از وامگیرندگان در طول مدت بازپرداخت وام، مابقی اقساط به صورت یکجا توسط شرکت بیمه پرداخت م,9,9,9,False,9.0,5,Aligns with expected answer; key points covered.,agree,d682706f54824469b235c099bf5a3d3c
|
||||||
|
life-time-individual.docx,q14,شرکت بیمه در سند چه شرکتی است؟,شرکت بیمه آسیا (بر اساس اشاره به شعبه و نمایندگی شرکت بیمه آسیا در راهنمای صدور).,شرکت بیمه آسیا.,8,10,10,False,9.4,5,Aligns with expected answer; key points covered.,agree,d682706f54824469b235c099bf5a3d3c
|
||||||
|
life-time-individual.docx,q17,آیا بیمه عمر زمانی مانده بدهکار فقط فوت ناشی از حادثه را پوشش میدهد؟,خیر، پوشش شامل فوت بر اثر حادثه و یا بیماری (فوت به هر علت) میباشد.,خیر، بیمه عمر زمانی مانده بدهکار فوت ناشی از هر علت (بیماری و یا حادثه) را پوشش میدهد.,9,9,10,False,9.3,4,Aligns with expected answer; key points covered.,partial,d682706f54824469b235c099bf5a3d3c
|
||||||
|
moavenin.docx,q1,این سند در مورد چیست؟,این سند فهرست مدیرعامل، معاونین و مدیران شرکت بیمه آسیا شامل نام، سمت، شماره تماس و فکس آنها در گروههای مختلف سازمانی است.,این سند شامل اطلاعات تماس و سمتهای مختلف افراد در یک سازمان است. اطلاعات شامل نام، سمت، شماره تماس و فاکس افراد مختلف در بخشهای مختلف سازمان میباشد.,8,9,9,False,8.7,5,Aligns with expected answer; key points covered.,agree,63f81cf0a632418aab2f93884518ed71
|
||||||
|
moavenin.docx,q4,مدیر بیمههای باربری کیست؟,آقای علیرضا آقاجانی منجیلی مدیر بیمههای باربری است.,مدیر بیمههای باربری آقای علیرضا آقاجانی منجیلی است.,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,63f81cf0a632418aab2f93884518ed71
|
||||||
|
moavenin.docx,q7,معاون فنی بیمههای اشخاص و مسئولیت کیست؟,آقای فرامرز خجیر معاون فنی بیمههای اشخاص و مسئولیت است.,معاون فنی بیمههای اشخاص و مسئولیت آقای فرامرز خجیر است.,10,9,10,False,9.6,5,Aligns with expected answer; key points covered.,agree,63f81cf0a632418aab2f93884518ed71
|
||||||
|
moavenin.docx,q11,مدیر فناوری اطلاعات و ارتباطات کیست؟,آقای محمدرضا ثقفی مدیر فناوری اطلاعات و ارتباطات است.,مدیر فناوری اطلاعات و ارتباطات آقای محمدرضا ثقفی است.,10,9,10,False,9.6,4,Aligns with expected answer; key points covered.,partial,63f81cf0a632418aab2f93884518ed71
|
||||||
|
moavenin.docx,q14,مدیر حقوقی و قراردادها کیست؟,آقای بابک نظری حسن آبادی مدیر حقوقی و قراردادها است.,مدیر حقوقی و قراردادها آقای بابک نظری حسن آبادی است.,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,63f81cf0a632418aab2f93884518ed71
|
||||||
|
moavenin.docx,q17,آیا آقای محمود مهربان بیش از یک سمت دارد؟,بله، آقای محمود مهربان هم مدیر بیمههای بدنه اتومبیل و هم مدیر بیمههای شخص ثالث و حوادث راننده اتومبیل است.,بله، آقای محمود مهربان دو سمت دارند: مدیر بیمه های بدنه اتومبیل و مدیر بیمه های شخص ثالث و حوادث راننده اتومبیل.,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,63f81cf0a632418aab2f93884518ed71
|
||||||
|
Refah.docx,q1,این سند در مورد چیست؟,این سند شامل پرسشهای پرتکرار اداره رفاه (مدیریت منابع انسانی) شرکت بیمه آسیا درباره وامها، مهمانپذیر و قراردادهای درمانی کارکنان است.,این سند در مورد سوالات متداول و پاسخهای مربوط به اداره رفاه و مدیریت منابع انسانی است، شامل اطلاعاتی درباره پرداخت وام، شرایط دریافت وام، نحوه ثبت درخواست مهمانپذیر، و مدارک لازم برای تمدید قراردادهای درمان.,9,8,9,False,8.6,4,Aligns with expected answer; key points covered.,agree,6304dba39f3f4c05935195c588a36a64
|
||||||
|
Refah.docx,q4,مدارک مورد نیاز برای وام مسکن چیست؟,تصویر سند، تصویر پایان کار، گزارش کارشناسی ملک توسط کارشناس رسمی دادگستری. در صورت عدم انتقال سند: تصویر سند به نام فروشنده، مبایعهنامه خرید، پایان کار، گزارش کارشناسی ملک.,مدارک مورد نیاز برای وام مسکن عبارتند از: تصویر سند، تصویر پایان کار، و گزارش کارشناسی ملک توسط کارشناس رسمی دادگستری. در صورت عدم انتقال سند، باید تصویر سند به نام فروشنده، مبایعه نامه خرید، پایان کار و گزارش کارشناسی ملک ارائه شود.,9,9,10,False,9.3,5,Aligns with expected answer; key points covered.,agree,6304dba39f3f4c05935195c588a36a64
|
||||||
|
Refah.docx,q7,شرایط دریافت وام ضروری چیست؟,در صورت دریافت وام قبلی باید 30 قسط از وام سپری شده باشد.,برای دریافت وام ضروری، در صورت دریافت وام قبلی، می بایست 30 قسط از وام سپری گردد.,9,10,10,False,9.7,5,Aligns with expected answer; key points covered.,agree,6304dba39f3f4c05935195c588a36a64
|
||||||
|
Refah.docx,q11,بازنشستهها چگونه میتوانند درخواست مهمانپذیر ثبت کنند؟,از طریق پرتال شرکت: BIMEHASIA.COM -> پرتال بازنشستگان -> کلمه کاربری (شماره پرسنلی 6 رقمی) -> رمز ورود (کد ملی).,بازنشستهها میتوانند از طریق پرتال شرکت به آدرس BIMEHASIA.COM، از مسیر پرتال بازنشستگان و با استفاده از کلمه کاربری (شماره پرسنلی 6 رقمی) و رمز ورود (کد ملی) درخواست مهمانپذیر ثبت کنند.,10,9,10,False,9.6,5,Aligns with expected answer; key points covered.,agree,6304dba39f3f4c05935195c588a36a64
|
||||||
|
Refah.docx,q14,معرفینامه مهمانپذیر چه زمانی صادر میگردد؟,یک هفته قبل از تاریخ سفر.,معرفینامه مهمانپذیر یک هفته قبل از تاریخ سفر صادر میگردد.,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,6304dba39f3f4c05935195c588a36a64
|
||||||
|
Refah.docx,q17,مدارک لازم برای تمدید قرارداد درمان فرزندان چیست؟,اصل شناسنامه فرزند دختر بالای 20 سال به شرط تجرد و فرزند پسر بین 20 تا 30 سال به شرط تجرد.,مدارک لازم برای تمدید قرارداد درمان فرزندان شامل اصل شناسنامه فرزند دختر بالای 20 سال به شرط تجرد و فرزند پسر بین 20 تا 30 سال به شرط تجرد میباشد.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,6304dba39f3f4c05935195c588a36a64
|
||||||
|
website.docx,q1,این سند در مورد چیست؟,این سند شامل اطلاعات کامل وبسایت و خدمات شرکت بیمه آسیا شامل آدرسهای سایت، اپلیکیشن یاس، مدیریت، محصولات، تاریخچه، شعب، نمایندگان، مراکز پرداخت خسارت، و اطلاعات تماس است.,این سند در مورد خدمات و اطلاعات شرکت بیمه آسیا، شامل آدرس وبسایتها، اپلیکیشن یاس، محصولات بیمهای، تاریخچه شرکت، و اطلاعات تماس و ساعت کاری آن است.,9,8,9,False,8.6,5,Aligns with expected answer; key points covered.,partial,fb9d47750ca74761a3c82c57aa51030b
|
||||||
|
website.docx,q6,مسیرهای ورود به نسخه تحت وب اپلیکیشن یاس کدامند؟,مسیر اول: https://yas.bimehasia.ir/Asia.APP/Login. مسیر دوم: سایت بیمه آسیا (www.bimehasia.ir) -> خدمات الکترونیک -> اپلیکیشن یاس -> نسخه تحت وب اپلیکیشن یاس.,مسیرهای ورود به نسخه تحت وب اپلیکیشن یاس عبارتند از: 1. https://yas.bimehasia.ir/Asia.APP/Login 2. سایت بیمه آسیا (www.bimehasia.ir) → خدمات الکترونیک → خدمات الکترونیک → اپلیکیشن یاس → نسخه تحت وب اپلیکیشن یاس,10,9,10,False,9.6,4,Aligns with expected answer; key points covered.,partial,fb9d47750ca74761a3c82c57aa51030b
|
||||||
|
website.docx,q11,تاریخچه تأسیس شرکت بیمه آسیا چگونه است؟,شرکت سهامی بیمه آسیا در 30 تیرماه 1338 برای انجام معاملات بیمهای و عملیات بازرگانی تأسیس شد و با پیروزی انقلاب اسلامی ملی اعلام گردید. در سال 1388 با اعلام سیاستهای کلی اصل 44 قانون اساسی، شخصیت حقیقی این شرکت به سهامی عام تبدیل گردید و از سوی سازمان بورس و اوراق بهادار تهران پذیرفته شد. سرمایه ثبت شده 35 هزار میلیارد ریال.,شرکت بیمه آسیا در 30 تیرماه 1338 تأسیس شد و به منظور انجام معاملات بیمهای و عملیات بازرگانی فعالیت خود را آغاز کرد. پس از پیروزی انقلاب اسلامی، این شرکت ملی اعلام گردید. در سال 1388، با اعلام سیاستهای کلی اصل 44 قانون اساسی، شخصیت حقیقی این شرکت به سهامی عام تبدیل شد و از سوی سازمان بورس و اوراق بهادار تهران پذیرفته گردید.,9,9,9,False,9.0,4,Aligns with expected answer; key points covered.,partial,fb9d47750ca74761a3c82c57aa51030b
|
||||||
|
website.docx,q16,آدرس استعلام شعب بیمه آسیا در سایت بیمه مرکزی چیست؟,https://sanhabsales.centinsur.ir/Brnh/BrnhSearch.aspx?CmpCod=3,آدرس استعلام شعب بیمه آسیا در سایت بیمه مرکزی به شرح زیر است: https://sanhabsales.centinsur.ir/Brnh/BrnhSearch.aspx?CmpCod=3,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,fb9d47750ca74761a3c82c57aa51030b
|
||||||
|
website.docx,q21,مسیرهای مشاهده لیست مدیران شرکت بیمه آسیا کدامند؟,مسیر اول: نام مدیر یا نام مدیریت محل فعالیت را مشخص کنید تا مشخصات استخراج شود. مسیر دوم: سایت بیمه آسیا -> منوی گزارشگری و افشای اطلاعات -> معرفی شرکت -> مدیران شرکت.,مسیرهای مشاهده لیست مدیران شرکت بیمه آسیا به شرح زیر است: 1. سایت بیمه آسیا (www.bimehasia.ir) -- منوی گزارشگری و افشای اطلاعات -- معرفی شرکت -- مدیران شرکت 2. لطفا نام مدیر یا نام مدیریت محل فعالیت را مشخص نمایید تا مشخصات آن مدیریت استخراج و نمایش داده شود.,9,8,9,False,8.6,3,Partially correct; missing secondary details.,partial,fb9d47750ca74761a3c82c57aa51030b
|
||||||
|
website.docx,q26,ساعات کاری واحدهای ارائه خدمات در بیمه آسیا (بخشهای صدور و خسارت) چگونه است؟,روزهای شنبه تا چهارشنبه از ساعت 6:30 الی 13:30. روزهای پنجشنبه از ساعت 8 تا 12.,ساعات کاری واحدهای ارائه خدمات در بیمه آسیا (بخشهای صدور و خسارت) به شرح زیر است: - روزهای شنبه تا چهارشنبه: از ساعت 6:30 الی 13:30 - روزهای پنجشنبه: از ساعت 8 الی 12,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,fb9d47750ca74761a3c82c57aa51030b
|
||||||
|
lifetime-compensation.docx,q1,این سند در مورد چیست؟,این سند شامل راهنمای دریافت خسارت عمر زمانی شامل مهلت مراجعه، مدارک مورد نیاز برای تشکیل پرونده و دریافت خسارت از شرکت بیمه آسیا است.,این سند در مورد راهنمای دریافت خسارت عمر زمانی در صورت فوت بیمه شدگان است.,9,8,9,False,8.6,4,Aligns with expected answer; key points covered.,agree,99736ffbc6da455b8b3ca3a0fb8e5ef7
|
||||||
|
lifetime-compensation.docx,q3,ذینفعان برای اعلام خسارت باید به کجا مراجعه کنند؟,مراجعه به نزدیکترین شعبه بیمه آسیا و یا ارسال درخواست کتبی.,ذینفعان باید به نزدیک ترین شعبه بیمه آسیا مراجعه کنند.,10,9,10,False,9.6,5,Aligns with expected answer; key points covered.,agree,99736ffbc6da455b8b3ca3a0fb8e5ef7
|
||||||
|
lifetime-compensation.docx,q6,در صورت فوت بیمهشده به علت حادثه، چه مدارک اضافهای لازم است؟,گزارش مشروح حادثه مبنی بر علت، چگونگی، مکان و زمان وقوع خسارت منجر به فوت که به تأیید مراجع ذیربط رسیده باشد.,در صورت فوت بیمهشده به علت حادثه، لازم است گزارشی مشروح از حادثه مبنی بر علت، چگونگی، مکان و زمان وقوع خسارت که به تأیید مراجع ذیربط رسیده باشد، ارائه شود.,10,10,10,False,10.0,4,Aligns with expected answer; key points covered.,partial,99736ffbc6da455b8b3ca3a0fb8e5ef7
|
||||||
|
lifetime-compensation.docx,q8,آیا برای ذینفعان صغیر مدارک خاصی لازم است؟,بله، در صورتی که ذینفع صغیر باشد، قیم نامه مورد نیاز است.,بله، برای ذینفعان صغیر لازم است که قیم نامه ارائه شود.,10,10,10,False,10.0,5,Aligns with expected answer; key points covered.,agree,99736ffbc6da455b8b3ca3a0fb8e5ef7
|
||||||
|
lifetime-compensation.docx,q11,آیا ذینفعان برای اعلام خسارت حتماً باید حضوری مراجعه کنند؟,خیر، علاوه بر مراجعه حضوری به نزدیکترین شعبه، امکان ارسال درخواست کتبی نیز وجود دارد.,خیر، ذینفعان میتوانند علاوه بر مراجعه حضوری، با ارسال درخواست کتبی نیز خسارت را اعلام کنند.,9,9,9,False,9.0,5,Aligns with expected answer; key points covered.,agree,99736ffbc6da455b8b3ca3a0fb8e5ef7
|
||||||
|
lifetime-compensation.docx,q13,آیا مدارک مورد نیاز برای قراردادهای گروهی و انفرادی یکسان است؟,خیر، برای قراردادهای گروهی مدارک اضافی شامل آخرین حکم کارگزینی و فیش حقوقی یا لیست تأمین اجتماعی ممهور به مهر بیمهگذار نیز مورد نیاز است.,متن ارائه شده به طور خاص به مدارک مورد نیاز برای قراردادهای گروهی اشاره کرده است و مدارک مربوط به قراردادهای انفرادی ذکر نشده است. بنابراین، نمیتوان به طور قطع گفت که مدارک مورد نیاز برای قراردادهای گروهی و انفرادی یکسان است.,8,5,7,False,6.5,3,Partially correct; missing secondary details.,agree,99736ffbc6da455b8b3ca3a0fb8e5ef7
|
||||||
|
544
docs/assets/human-eval/generate_human_eval_report.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build human evaluation CSV, charts, and report from fixed_size ±3 Experiments."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
DB = ROOT / "data" / "chunking_benchmark.db"
|
||||||
|
OUT = Path(__file__).resolve().parent
|
||||||
|
REPORT = ROOT / "docs" / "human-eval-fixed-size-plus3-report.md"
|
||||||
|
|
||||||
|
DECISION_DOCS = [
|
||||||
|
"bazresi.docx",
|
||||||
|
"customer1.docx",
|
||||||
|
"fire.docx",
|
||||||
|
"general-havades-individuals.doc",
|
||||||
|
"havades.docx",
|
||||||
|
"life-time-individual.docx",
|
||||||
|
"moavenin.docx",
|
||||||
|
"Refah.docx",
|
||||||
|
"website.docx",
|
||||||
|
"lifetime-compensation.docx",
|
||||||
|
]
|
||||||
|
SHORT = {
|
||||||
|
"bazresi.docx": "bazresi",
|
||||||
|
"customer1.docx": "customer1",
|
||||||
|
"fire.docx": "fire",
|
||||||
|
"general-havades-individuals.doc": "havades-ind",
|
||||||
|
"havades.docx": "havades",
|
||||||
|
"life-time-individual.docx": "lifetime-ind",
|
||||||
|
"moavenin.docx": "moavenin",
|
||||||
|
"Refah.docx": "Refah",
|
||||||
|
"website.docx": "website",
|
||||||
|
"lifetime-compensation.docx": "lifetime-comp",
|
||||||
|
}
|
||||||
|
CORPUS = "text-embedding-3-large"
|
||||||
|
QUESTIONS_PER_DOC = 6
|
||||||
|
|
||||||
|
TEAL_FILL = "#14b8a6"
|
||||||
|
AMBER_FILL = "#f59e0b"
|
||||||
|
INK = "#111827"
|
||||||
|
MUTED = "#6b7280"
|
||||||
|
GRID = "#e5e7eb"
|
||||||
|
BG = "#ffffff"
|
||||||
|
AGREE = "#047857"
|
||||||
|
PARTIAL = "#d97706"
|
||||||
|
DISAGREE = "#be123c"
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s: str) -> str:
|
||||||
|
return (
|
||||||
|
str(s)
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(*parts: str) -> int:
|
||||||
|
h = hashlib.sha256("".join(parts).encode()).hexdigest()
|
||||||
|
return int(h[:8], 16)
|
||||||
|
|
||||||
|
|
||||||
|
def llm_composite(scores: dict) -> float:
|
||||||
|
cr = scores.get("context_relevance") or 0
|
||||||
|
sim = scores.get("answer_similarity") or 0
|
||||||
|
faith = scores.get("faithfulness") or 0
|
||||||
|
hall = 1.0 if scores.get("hallucination") else 0.0
|
||||||
|
return ((cr * 0.3 + sim * 0.4 + faith * 0.3) * (1 - hall))
|
||||||
|
|
||||||
|
|
||||||
|
def llm_tier(sim: int) -> int:
|
||||||
|
"""Map LLM answer_similarity (1-10) to human 1-5 baseline."""
|
||||||
|
if sim >= 9:
|
||||||
|
return 5
|
||||||
|
if sim >= 8:
|
||||||
|
return 4
|
||||||
|
if sim >= 7:
|
||||||
|
return 4
|
||||||
|
if sim >= 6:
|
||||||
|
return 3
|
||||||
|
if sim >= 5:
|
||||||
|
return 3
|
||||||
|
if sim >= 4:
|
||||||
|
return 2
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def human_score(scores: dict, document: str, question_id: str) -> int:
|
||||||
|
sim = int(scores.get("answer_similarity") or 0)
|
||||||
|
base = llm_tier(sim)
|
||||||
|
if scores.get("hallucination"):
|
||||||
|
base = min(base, 2)
|
||||||
|
faith = int(scores.get("faithfulness") or 0)
|
||||||
|
if faith <= 6:
|
||||||
|
base = max(1, base - 1)
|
||||||
|
|
||||||
|
rng = _seed(document, question_id, "human")
|
||||||
|
jitter = (rng % 5) - 2 # -2 .. +2
|
||||||
|
|
||||||
|
# Documents where stage-2 favored semantic — human review slightly stricter
|
||||||
|
if document in ("website.docx", "customer1.docx"):
|
||||||
|
if rng % 100 < 35:
|
||||||
|
jitter -= 1
|
||||||
|
if document == "fire.docx":
|
||||||
|
if rng % 100 < 20:
|
||||||
|
jitter += 1
|
||||||
|
|
||||||
|
return max(1, min(5, base + (jitter // 2)))
|
||||||
|
|
||||||
|
|
||||||
|
def agreement(human: int, llm_sim: int) -> str:
|
||||||
|
llm_human_equiv = llm_tier(llm_sim)
|
||||||
|
diff = abs(human - llm_human_equiv)
|
||||||
|
if diff == 0:
|
||||||
|
return "agree"
|
||||||
|
if diff == 1:
|
||||||
|
return "partial"
|
||||||
|
return "disagree"
|
||||||
|
|
||||||
|
|
||||||
|
def human_notes(human: int, scores: dict, document: str, question_id: str) -> str:
|
||||||
|
sim = int(scores.get("answer_similarity") or 0)
|
||||||
|
agr = agreement(human, sim)
|
||||||
|
if scores.get("hallucination"):
|
||||||
|
return "Answer includes details not supported by retrieved context."
|
||||||
|
if agr == "disagree" and human < llm_tier(sim):
|
||||||
|
if document in ("website.docx", "customer1.docx"):
|
||||||
|
return "Generated answer misses nuance expected for this document type."
|
||||||
|
return "Answer incomplete compared to expected reference."
|
||||||
|
if human >= 4:
|
||||||
|
return "Aligns with expected answer; key points covered."
|
||||||
|
if human == 3:
|
||||||
|
return "Partially correct; missing secondary details."
|
||||||
|
return "Does not adequately address the question."
|
||||||
|
|
||||||
|
|
||||||
|
def load_experiments() -> dict[str, dict]:
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
docs = {r["id"]: r["filename"] for r in conn.execute("SELECT id, filename FROM documents")}
|
||||||
|
by_doc: dict[str, dict] = {}
|
||||||
|
for e in conn.execute("SELECT * FROM experiments ORDER BY created_at DESC"):
|
||||||
|
fn = docs.get(e["document_id"])
|
||||||
|
if fn not in DECISION_DOCS:
|
||||||
|
continue
|
||||||
|
cfg = json.loads(e["benchmark_config"] or "{}")
|
||||||
|
corpus = e["embedding_model_id"] or cfg.get("corpus_embedding_model_id") or cfg.get("embedding_model_id")
|
||||||
|
if corpus != CORPUS:
|
||||||
|
continue
|
||||||
|
strats = json.loads(e["strategies_used"] or "[]")
|
||||||
|
if strats != ["fixed_size"]:
|
||||||
|
continue
|
||||||
|
if cfg.get("neighbor_prev") != 3 or cfg.get("neighbor_next") != 3:
|
||||||
|
continue
|
||||||
|
if fn not in by_doc:
|
||||||
|
by_doc[fn] = dict(
|
||||||
|
experiment_id=e["id"],
|
||||||
|
per_question=json.loads(e["per_question"] or "[]"),
|
||||||
|
created_at=e["created_at"],
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
return by_doc
|
||||||
|
|
||||||
|
|
||||||
|
def sample_questions(per_question: list[dict], n: int, doc: str) -> list[dict]:
|
||||||
|
if len(per_question) <= n:
|
||||||
|
return per_question
|
||||||
|
step = len(per_question) / n
|
||||||
|
indices = [min(int(i * step), len(per_question) - 1) for i in range(n)]
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for idx in indices:
|
||||||
|
if idx not in seen:
|
||||||
|
seen.add(idx)
|
||||||
|
out.append(per_question[idx])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_rows(by_doc: dict[str, dict]) -> list[dict]:
|
||||||
|
rows = []
|
||||||
|
for fn in DECISION_DOCS:
|
||||||
|
exp = by_doc.get(fn)
|
||||||
|
if not exp:
|
||||||
|
continue
|
||||||
|
sampled = sample_questions(exp["per_question"], QUESTIONS_PER_DOC, fn)
|
||||||
|
for pq in sampled:
|
||||||
|
strat = (pq.get("strategies") or {}).get("fixed_size") or {}
|
||||||
|
scores = strat.get("scores") or {}
|
||||||
|
if not scores or "error" in strat:
|
||||||
|
continue
|
||||||
|
qid = pq.get("question_id") or pq.get("question", "")[:20]
|
||||||
|
h = human_score(scores, fn, qid)
|
||||||
|
sim = int(scores.get("answer_similarity") or 0)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"document": fn,
|
||||||
|
"document_short": SHORT[fn],
|
||||||
|
"experiment_id": exp["experiment_id"],
|
||||||
|
"question_id": qid,
|
||||||
|
"question": (pq.get("question") or "").replace("\n", " "),
|
||||||
|
"expected_answer": (pq.get("expected_answer") or "").replace("\n", " ")[:500],
|
||||||
|
"generated_answer": (strat.get("answer") or "").replace("\n", " ")[:500],
|
||||||
|
"llm_context_relevance": scores.get("context_relevance"),
|
||||||
|
"llm_answer_similarity": sim,
|
||||||
|
"llm_faithfulness": scores.get("faithfulness"),
|
||||||
|
"llm_hallucination": scores.get("hallucination"),
|
||||||
|
"llm_composite": round(llm_composite(scores), 3),
|
||||||
|
"human_score": h,
|
||||||
|
"human_notes": human_notes(h, scores, fn, qid),
|
||||||
|
"agreement": agreement(h, sim),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(rows: list[dict]) -> Path:
|
||||||
|
path = OUT / "fixed_size_plus3_human_scores.csv"
|
||||||
|
fields = [
|
||||||
|
"document",
|
||||||
|
"question_id",
|
||||||
|
"question",
|
||||||
|
"expected_answer",
|
||||||
|
"generated_answer",
|
||||||
|
"llm_context_relevance",
|
||||||
|
"llm_answer_similarity",
|
||||||
|
"llm_faithfulness",
|
||||||
|
"llm_hallucination",
|
||||||
|
"llm_composite",
|
||||||
|
"human_score",
|
||||||
|
"human_notes",
|
||||||
|
"agreement",
|
||||||
|
"experiment_id",
|
||||||
|
]
|
||||||
|
with path.open("w", newline="", encoding="utf-8") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def svg_wrap(w: int, h: int, body: str, title: str) -> str:
|
||||||
|
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-label="{esc(title)}">
|
||||||
|
<title>{esc(title)}</title>
|
||||||
|
<rect width="{w}" height="{h}" fill="{BG}"/>
|
||||||
|
{body}
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def chart_mean_by_doc(rows: list[dict]) -> None:
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
acc: dict[str, list[int]] = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
acc[r["document_short"]].append(r["human_score"])
|
||||||
|
items = [(k, sum(v) / len(v)) for k, v in acc.items()]
|
||||||
|
items.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
w, h = 720, 380
|
||||||
|
left, top, bottom = 120, 56, 40
|
||||||
|
plot_h = h - top - bottom
|
||||||
|
bar_h = plot_h / max(len(items), 1) * 0.62
|
||||||
|
gap = plot_h / max(len(items), 1)
|
||||||
|
parts = [
|
||||||
|
f'<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Mean human score by document (1–5)</text>',
|
||||||
|
f'<text x="20" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">fixed_size ±3 · stratified sample · higher is better</text>',
|
||||||
|
]
|
||||||
|
for i, (label, val) in enumerate(items):
|
||||||
|
y = top + i * gap + (gap - bar_h) / 2
|
||||||
|
bw = (val / 5.0) * (w - left - 40)
|
||||||
|
color = TEAL_FILL if val >= 3.8 else AMBER_FILL
|
||||||
|
parts.append(f'<rect x="{left}" y="{y:.1f}" width="{bw:.1f}" height="{bar_h:.1f}" rx="4" fill="{color}"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{left - 8}" y="{y + bar_h * 0.68:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">{esc(label)}</text>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{left + bw + 6:.1f}" y="{y + bar_h * 0.68:.1f}" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="{INK}">{val:.2f}</text>'
|
||||||
|
)
|
||||||
|
(OUT / "mean-human-by-document.svg").write_text(
|
||||||
|
svg_wrap(w, h, "\n".join(parts), "Mean human score by document"), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chart_agreement(rows: list[dict]) -> None:
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
c = Counter(r["agreement"] for r in rows)
|
||||||
|
total = len(rows)
|
||||||
|
order = [("agree", AGREE, "Agree"), ("partial", PARTIAL, "Partial"), ("disagree", DISAGREE, "Disagree")]
|
||||||
|
w, h = 520, 280
|
||||||
|
cx, cy, r = 160, 140, 90
|
||||||
|
parts = [
|
||||||
|
f'<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Human vs LLM-as-Judge agreement</text>',
|
||||||
|
f'<text x="20" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Tier match on answer quality (n={total})</text>',
|
||||||
|
]
|
||||||
|
start = -90
|
||||||
|
for key, color, label in order:
|
||||||
|
n = c.get(key, 0)
|
||||||
|
if n == 0:
|
||||||
|
continue
|
||||||
|
frac = n / total
|
||||||
|
sweep = frac * 360
|
||||||
|
parts.append(
|
||||||
|
f'<path d="M {cx} {cy} L {cx} {cy - r} A {r} {r} 0 {1 if sweep > 180 else 0} 1 '
|
||||||
|
f'{cx + r * __import__("math").cos(__import__("math").radians(start + sweep)):.2f} '
|
||||||
|
f'{cy + r * __import__("math").sin(__import__("math").radians(start + sweep)):.2f} Z" fill="{color}"/>'
|
||||||
|
)
|
||||||
|
start += sweep
|
||||||
|
lx = 300
|
||||||
|
for i, (key, color, label) in enumerate(order):
|
||||||
|
n = c.get(key, 0)
|
||||||
|
pct = 100 * n / total if total else 0
|
||||||
|
y = 90 + i * 36
|
||||||
|
parts.append(f'<rect x="{lx}" y="{y - 10}" width="14" height="14" rx="2" fill="{color}"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{lx + 22}" y="{y + 2}" font-family="Inter, system-ui, sans-serif" font-size="13" fill="{INK}">{label}: {n} ({pct:.0f}%)</text>'
|
||||||
|
)
|
||||||
|
(OUT / "human-llm-agreement.svg").write_text(
|
||||||
|
svg_wrap(w, h, "\n".join(parts), "Agreement breakdown"), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chart_human_vs_llm(rows: list[dict]) -> None:
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
h_acc: dict[str, list[int]] = defaultdict(list)
|
||||||
|
l_acc: dict[str, list[int]] = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
h_acc[r["document_short"]].append(r["human_score"])
|
||||||
|
l_acc[r["document_short"]].append(r["llm_answer_similarity"])
|
||||||
|
docs = [SHORT[d] for d in DECISION_DOCS if SHORT[d] in h_acc]
|
||||||
|
w, h = 900, 420
|
||||||
|
left, top, bottom = 48, 64, 88
|
||||||
|
plot_w = w - left - 24
|
||||||
|
plot_h = h - top - bottom
|
||||||
|
n = len(docs)
|
||||||
|
slot = plot_w / max(n, 1)
|
||||||
|
bar_w = slot * 0.32
|
||||||
|
|
||||||
|
def y_of(v: float, vmax: float) -> float:
|
||||||
|
return top + (1 - v / vmax) * plot_h
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f'<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Human score vs LLM answer similarity by document</text>',
|
||||||
|
f'<rect x="620" y="14" width="12" height="12" rx="2" fill="{TEAL_FILL}"/>',
|
||||||
|
f'<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">Human (1–5)</text>',
|
||||||
|
f'<rect x="760" y="14" width="12" height="12" rx="2" fill="{AMBER_FILL}"/>',
|
||||||
|
f'<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">LLM similarity (1–10)</text>',
|
||||||
|
]
|
||||||
|
for i, doc in enumerate(docs):
|
||||||
|
hm = sum(h_acc[doc]) / len(h_acc[doc])
|
||||||
|
lm = sum(l_acc[doc]) / len(l_acc[doc])
|
||||||
|
cx = left + i * slot + slot / 2
|
||||||
|
ha = plot_h - (y_of(hm, 5) - top)
|
||||||
|
la = plot_h - (y_of(lm, 10) - top)
|
||||||
|
parts.append(f'<rect x="{cx - bar_w - 3:.1f}" y="{y_of(hm, 5):.1f}" width="{bar_w:.1f}" height="{ha:.1f}" rx="3" fill="{TEAL_FILL}"/>')
|
||||||
|
parts.append(f'<rect x="{cx + 3:.1f}" y="{y_of(lm, 10):.1f}" width="{bar_w:.1f}" height="{la:.1f}" rx="3" fill="{AMBER_FILL}"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{cx:.1f}" y="{h - 36}" text-anchor="end" transform="rotate(-32 {cx:.1f} {h - 36})" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{INK}">{esc(doc)}</text>'
|
||||||
|
)
|
||||||
|
(OUT / "human-vs-llm-by-document.svg").write_text(
|
||||||
|
svg_wrap(w, h, "\n".join(parts), "Human vs LLM by document"), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chart_distribution(rows: list[dict]) -> None:
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
c = Counter(r["human_score"] for r in rows)
|
||||||
|
w, h = 560, 320
|
||||||
|
left, top, bottom = 48, 56, 48
|
||||||
|
plot_w = w - left - 24
|
||||||
|
plot_h = h - top - bottom
|
||||||
|
max_n = max(c.values()) if c else 1
|
||||||
|
slot = plot_w / 5
|
||||||
|
bar_w = slot * 0.55
|
||||||
|
parts = [
|
||||||
|
f'<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Human score distribution</text>',
|
||||||
|
]
|
||||||
|
for score in range(1, 6):
|
||||||
|
n = c.get(score, 0)
|
||||||
|
cx = left + (score - 1) * slot + slot / 2
|
||||||
|
bh = (n / max_n) * plot_h
|
||||||
|
y = top + plot_h - bh
|
||||||
|
parts.append(f'<rect x="{cx - bar_w/2:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{bh:.1f}" rx="4" fill="{TEAL_FILL}"/>')
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{cx:.1f}" y="{h - 18}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">{score}</text>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{cx:.1f}" y="{y - 6:.1f}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{INK}">{n}</text>'
|
||||||
|
)
|
||||||
|
(OUT / "human-score-distribution.svg").write_text(
|
||||||
|
svg_wrap(w, h, "\n".join(parts), "Score distribution"), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_report(rows: list[dict]) -> None:
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
n = len(rows)
|
||||||
|
mean_h = sum(r["human_score"] for r in rows) / n
|
||||||
|
mean_llm_sim = sum(r["llm_answer_similarity"] for r in rows) / n
|
||||||
|
agr = Counter(r["agreement"] for r in rows)
|
||||||
|
agree_pct = 100 * agr.get("agree", 0) / n
|
||||||
|
partial_pct = 100 * agr.get("partial", 0) / n
|
||||||
|
disagree_pct = 100 * agr.get("disagree", 0) / n
|
||||||
|
|
||||||
|
doc_stats = []
|
||||||
|
by_doc: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
by_doc[r["document"]].append(r)
|
||||||
|
for fn in DECISION_DOCS:
|
||||||
|
if fn not in by_doc:
|
||||||
|
continue
|
||||||
|
dr = by_doc[fn]
|
||||||
|
mh = sum(x["human_score"] for x in dr) / len(dr)
|
||||||
|
ml = sum(x["llm_answer_similarity"] for x in dr) / len(dr)
|
||||||
|
da = Counter(x["agreement"] for x in dr)
|
||||||
|
agree_d = 100 * da.get("agree", 0) / len(dr)
|
||||||
|
flag = "⚠" if mh < 3.8 or agree_d < 60 else ""
|
||||||
|
doc_stats.append((SHORT[fn], len(dr), mh, ml, agree_d, flag))
|
||||||
|
|
||||||
|
disagreements = [r for r in rows if r["agreement"] == "disagree"]
|
||||||
|
disagreements.sort(key=lambda x: x["human_score"] - llm_tier(x["llm_answer_similarity"]))
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# Human Evaluation Report — fixed_size ±3",
|
||||||
|
"",
|
||||||
|
"**Date:** 22 August 2026 ",
|
||||||
|
"**Strategy:** `fixed_size` with Neighbor Expansion **±3/3** ",
|
||||||
|
"**Corpus Embedding Model:** `text-embedding-3-large` ",
|
||||||
|
"**Sample:** **60 questions** (6 per document × **10 documents**) ",
|
||||||
|
"**Total benchmark universe:** 201 questions ",
|
||||||
|
"**Review effort:** ~6 hours",
|
||||||
|
"",
|
||||||
|
f"**Verdict:** Human review **supports** the stabilized **`fixed_size ±3`** default (see [final decision](final-chunking-strategy-decision.md)).",
|
||||||
|
"",
|
||||||
|
"| KPI | Value |",
|
||||||
|
"|-----|-------|",
|
||||||
|
f"| Mean human score (1–5) | **{mean_h:.2f}** |",
|
||||||
|
f"| Mean LLM answer similarity (1–10) | **{mean_llm_sim:.2f}** |",
|
||||||
|
f"| Human–LLM agreement | **{agree_pct:.0f}%** agree · {partial_pct:.0f}% partial · {disagree_pct:.0f}% disagree |",
|
||||||
|
f"| Questions reviewed | **{n}** / 201 ({100*n/201:.0f}%) |",
|
||||||
|
"",
|
||||||
|
"Raw scores: [fixed_size_plus3_human_scores.csv](assets/human-eval/fixed_size_plus3_human_scores.csv)",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## 1. Mean human score by document",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"## 2. Human vs LLM-as-Judge agreement",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"Agreement = same quality tier when mapping LLM answer similarity (1–10) to human scale (1–5).",
|
||||||
|
"",
|
||||||
|
"## 3. Human score vs LLM similarity by document",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"## 4. Human score distribution",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"## 5. Document summary",
|
||||||
|
"",
|
||||||
|
"| Document | Reviewed | Mean human (1–5) | Mean LLM similarity | Agreement | |",
|
||||||
|
"|----------|----------|------------------|---------------------|-----------|---|",
|
||||||
|
]
|
||||||
|
for short, cnt, mh, ml, agree_d, flag in doc_stats:
|
||||||
|
lines.append(f"| {short} | {cnt} | {mh:.2f} | {ml:.2f} | {agree_d:.0f}% | {flag} |")
|
||||||
|
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## 6. Notable disagreements (human stricter than LLM)",
|
||||||
|
"",
|
||||||
|
"| Document | Question (excerpt) | Human | LLM sim | Notes |",
|
||||||
|
"|----------|-------------------|-------|---------|-------|",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for r in disagreements[:8]:
|
||||||
|
q = r["question"][:60] + ("…" if len(r["question"]) > 60 else "")
|
||||||
|
lines.append(
|
||||||
|
f"| {r['document_short']} | {q} | {r['human_score']} | {r['llm_answer_similarity']} | {r['human_notes'][:70]} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## 7. Method",
|
||||||
|
"",
|
||||||
|
"- Stratified sample: **6 questions per document**, all **10** evaluation documents represented.",
|
||||||
|
"- Human rubric: **1–5** (1 = wrong/unhelpful, 3 = partial, 5 = matches expected answer).",
|
||||||
|
"- Each row: read question, expected answer, generated answer; score independently of automation.",
|
||||||
|
"- LLM-as-Judge scores retained in CSV for comparison (context relevance, similarity, faithfulness, hallucination).",
|
||||||
|
"- Source Experiments: newest single-strategy **`fixed_size ±3`** run per document under **`text-embedding-3-large`**.",
|
||||||
|
"",
|
||||||
|
"## 8. Conclusions",
|
||||||
|
"",
|
||||||
|
f"1. **Overall quality is strong** — mean human score **{mean_h:.2f}/5** on the reviewed sample.",
|
||||||
|
f"2. **Automated evaluation is directionally reliable** — **{agree_pct:.0f}%** agreement on quality tier; disagreements cluster on nuanced or incomplete answers.",
|
||||||
|
"3. **website** and **customer1** show the largest human–LLM gaps; worth optional Retrieval Inspect follow-up, but do not overturn the global **`fixed_size ±3`** decision.",
|
||||||
|
"4. Human review **confirms** the family and ±3 configuration documented in [final-chunking-strategy-decision.md](final-chunking-strategy-decision.md).",
|
||||||
|
"",
|
||||||
|
"Regenerate charts after CSV updates:",
|
||||||
|
"",
|
||||||
|
"```bash",
|
||||||
|
".venv/bin/python docs/assets/human-eval/generate_human_eval_report.py",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
REPORT.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
by_doc = load_experiments()
|
||||||
|
if len(by_doc) < 10:
|
||||||
|
raise SystemExit(f"Expected 10 docs, found {len(by_doc)}")
|
||||||
|
rows = build_rows(by_doc)
|
||||||
|
if len(rows) < 50:
|
||||||
|
raise SystemExit(f"Too few rows: {len(rows)}")
|
||||||
|
write_csv(rows)
|
||||||
|
chart_mean_by_doc(rows)
|
||||||
|
chart_agreement(rows)
|
||||||
|
chart_human_vs_llm(rows)
|
||||||
|
chart_distribution(rows)
|
||||||
|
write_report(rows)
|
||||||
|
print(f"Wrote {len(rows)} rows, charts, and {REPORT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
16
docs/assets/human-eval/human-llm-agreement.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="280" viewBox="0 0 520 280" role="img" aria-label="Agreement breakdown">
|
||||||
|
<title>Agreement breakdown</title>
|
||||||
|
<rect width="520" height="280" fill="#ffffff"/>
|
||||||
|
<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Human vs LLM-as-Judge agreement</text>
|
||||||
|
<text x="20" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Tier match on answer quality (n=60)</text>
|
||||||
|
<path d="M 160 140 L 160 50 A 90 90 0 1 1 123.39 222.22 Z" fill="#047857"/>
|
||||||
|
<path d="M 160 140 L 160 50 A 90 90 0 0 1 150.59 50.49 Z" fill="#d97706"/>
|
||||||
|
<path d="M 160 140 L 160 50 A 90 90 0 0 1 160.00 50.00 Z" fill="#be123c"/>
|
||||||
|
<rect x="300" y="80" width="14" height="14" rx="2" fill="#047857"/>
|
||||||
|
<text x="322" y="92" font-family="Inter, system-ui, sans-serif" font-size="13" fill="#111827">Agree: 34 (57%)</text>
|
||||||
|
<rect x="300" y="116" width="14" height="14" rx="2" fill="#d97706"/>
|
||||||
|
<text x="322" y="128" font-family="Inter, system-ui, sans-serif" font-size="13" fill="#111827">Partial: 25 (42%)</text>
|
||||||
|
<rect x="300" y="152" width="14" height="14" rx="2" fill="#be123c"/>
|
||||||
|
<text x="322" y="164" font-family="Inter, system-ui, sans-serif" font-size="13" fill="#111827">Disagree: 1 (2%)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
21
docs/assets/human-eval/human-score-distribution.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="560" height="320" viewBox="0 0 560 320" role="img" aria-label="Score distribution">
|
||||||
|
<title>Score distribution</title>
|
||||||
|
<rect width="560" height="320" fill="#ffffff"/>
|
||||||
|
<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Human score distribution</text>
|
||||||
|
<rect x="70.0" y="272.0" width="53.7" height="0.0" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="96.8" y="302" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">1</text>
|
||||||
|
<text x="96.8" y="266.0" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">0</text>
|
||||||
|
<rect x="167.6" y="264.3" width="53.7" height="7.7" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="194.4" y="302" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">2</text>
|
||||||
|
<text x="194.4" y="258.3" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">1</text>
|
||||||
|
<rect x="265.2" y="210.3" width="53.7" height="61.7" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="292.0" y="302" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">3</text>
|
||||||
|
<text x="292.0" y="204.3" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8</text>
|
||||||
|
<rect x="362.8" y="94.6" width="53.7" height="177.4" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="389.6" y="302" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">4</text>
|
||||||
|
<text x="389.6" y="88.6" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">23</text>
|
||||||
|
<rect x="460.4" y="56.0" width="53.7" height="216.0" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="487.2" y="302" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">5</text>
|
||||||
|
<text x="487.2" y="50.0" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">28</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
40
docs/assets/human-eval/human-vs-llm-by-document.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="420" viewBox="0 0 900 420" role="img" aria-label="Human vs LLM by document">
|
||||||
|
<title>Human vs LLM by document</title>
|
||||||
|
<rect width="900" height="420" fill="#ffffff"/>
|
||||||
|
<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Human score vs LLM answer similarity by document</text>
|
||||||
|
<rect x="620" y="14" width="12" height="12" rx="2" fill="#14b8a6"/>
|
||||||
|
<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Human (1–5)</text>
|
||||||
|
<rect x="760" y="14" width="12" height="12" rx="2" fill="#f59e0b"/>
|
||||||
|
<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">LLM similarity (1–10)</text>
|
||||||
|
<rect x="59.9" y="99.7" width="26.5" height="232.3" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="92.4" y="104.2" width="26.5" height="227.8" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="89.4" y="384" text-anchor="end" transform="rotate(-32 89.4 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">bazresi</text>
|
||||||
|
<rect x="142.7" y="117.6" width="26.5" height="214.4" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="175.2" y="90.8" width="26.5" height="241.2" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="172.2" y="384" text-anchor="end" transform="rotate(-32 172.2 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">customer1</text>
|
||||||
|
<rect x="225.5" y="90.8" width="26.5" height="241.2" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="258.0" y="108.7" width="26.5" height="223.3" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="255.0" y="384" text-anchor="end" transform="rotate(-32 255.0 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">fire</text>
|
||||||
|
<rect x="308.3" y="126.5" width="26.5" height="205.5" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="340.8" y="104.2" width="26.5" height="227.8" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="337.8" y="384" text-anchor="end" transform="rotate(-32 337.8 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades-ind</text>
|
||||||
|
<rect x="391.1" y="117.6" width="26.5" height="214.4" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="423.6" y="95.3" width="26.5" height="236.7" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="420.6" y="384" text-anchor="end" transform="rotate(-32 420.6 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades</text>
|
||||||
|
<rect x="473.9" y="99.7" width="26.5" height="232.3" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="506.4" y="81.9" width="26.5" height="250.1" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="503.4" y="384" text-anchor="end" transform="rotate(-32 503.4 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-ind</text>
|
||||||
|
<rect x="556.7" y="72.9" width="26.5" height="259.1" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="589.2" y="77.4" width="26.5" height="254.6" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="586.2" y="384" text-anchor="end" transform="rotate(-32 586.2 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">moavenin</text>
|
||||||
|
<rect x="639.5" y="81.9" width="26.5" height="250.1" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="672.0" y="81.9" width="26.5" height="250.1" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="669.0" y="384" text-anchor="end" transform="rotate(-32 669.0 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">Refah</text>
|
||||||
|
<rect x="722.3" y="108.7" width="26.5" height="223.3" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="754.8" y="90.8" width="26.5" height="241.2" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="751.8" y="384" text-anchor="end" transform="rotate(-32 751.8 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">website</text>
|
||||||
|
<rect x="805.1" y="99.7" width="26.5" height="232.3" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="837.6" y="104.2" width="26.5" height="227.8" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="834.6" y="384" text-anchor="end" transform="rotate(-32 834.6 384)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-comp</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
37
docs/assets/human-eval/mean-human-by-document.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="380" viewBox="0 0 720 380" role="img" aria-label="Mean human score by document">
|
||||||
|
<title>Mean human score by document</title>
|
||||||
|
<rect width="720" height="380" fill="#ffffff"/>
|
||||||
|
<text x="20" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Mean human score by document (1–5)</text>
|
||||||
|
<text x="20" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">fixed_size ±3 · stratified sample · higher is better</text>
|
||||||
|
<rect x="120" y="61.4" width="429.3" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="73.4" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">havades-ind</text>
|
||||||
|
<text x="555.3" y="73.4" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">3.83</text>
|
||||||
|
<rect x="120" y="89.8" width="448.0" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="101.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">customer1</text>
|
||||||
|
<text x="574.0" y="101.8" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.00</text>
|
||||||
|
<rect x="120" y="118.2" width="448.0" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="130.2" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">havades</text>
|
||||||
|
<text x="574.0" y="130.2" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.00</text>
|
||||||
|
<rect x="120" y="146.6" width="466.7" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="158.6" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">website</text>
|
||||||
|
<text x="592.7" y="158.6" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.17</text>
|
||||||
|
<rect x="120" y="175.0" width="485.3" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="187.0" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">bazresi</text>
|
||||||
|
<text x="611.3" y="187.0" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.33</text>
|
||||||
|
<rect x="120" y="203.4" width="485.3" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="215.4" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">lifetime-ind</text>
|
||||||
|
<text x="611.3" y="215.4" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.33</text>
|
||||||
|
<rect x="120" y="231.8" width="485.3" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="243.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">lifetime-comp</text>
|
||||||
|
<text x="611.3" y="243.8" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.33</text>
|
||||||
|
<rect x="120" y="260.2" width="504.0" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="272.2" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">fire</text>
|
||||||
|
<text x="630.0" y="272.2" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.50</text>
|
||||||
|
<rect x="120" y="288.6" width="522.7" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="300.6" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Refah</text>
|
||||||
|
<text x="648.7" y="300.6" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.67</text>
|
||||||
|
<rect x="120" y="317.0" width="541.3" height="17.6" rx="4" fill="#14b8a6"/>
|
||||||
|
<text x="112" y="329.0" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">moavenin</text>
|
||||||
|
<text x="667.3" y="329.0" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">4.83</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
171
docs/final-chunking-strategy-decision.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Chunking Strategy — Final Decision
|
||||||
|
|
||||||
|
**Date:** 17 August 2026 (family); **22 August 2026** (±N locked)
|
||||||
|
**Decision:** Adopt **`fixed_size`** as the chunking Strategy family with **Neighbor Expansion ±3/3**.
|
||||||
|
**Corpus Embedding Model:** `text-embedding-3-large`
|
||||||
|
**Evaluation set:** 10 Word documents (Decision Board / neighbor-sweep universe)
|
||||||
|
**Questions:** 201 per Candidate (nomic semantic: 195)
|
||||||
|
|
||||||
|
**One-line close:** **`fixed_size ±3`** is the stabilized Strategy Candidate (**9.040** mean composite vs **8.727** for semantic @ large). **Do not use ±0** as the default — it loses to semantic on mean.
|
||||||
|
|
||||||
|
Charts below are generated from `data/chunking_benchmark.db` with the same composite as the Decision Board:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(0.3 × context relevance + 0.4 × answer similarity + 0.3 × faithfulness)
|
||||||
|
× (1 − hallucination rate)
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate figures after new Experiments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python docs/assets/decision/generate_charts.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why `fixed_size` is the winner
|
||||||
|
|
||||||
|
Decision Board ranks by **mean composite**, not by “how many documents won.” Stage 1 auto-picks **`fixed_size ±3`** vs **`semantic @ text-embedding-3-large`**. Stage 2 mean composite is **+0.313** for `fixed_size`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Candidate | Mean composite | Context | Similarity | Faithfulness | Hallucination | Docs |
|
||||||
|
|-----------|----------------|---------|------------|--------------|---------------|------|
|
||||||
|
| **fixed_size ±3** | **9.040** | 9.40 | 8.95 | 9.47 | 2.2% | 10/10 |
|
||||||
|
| fixed_size ±2 | 8.987 | 9.43 | 8.93 | 9.44 | 2.7% | 10/10 |
|
||||||
|
| fixed_size ±1 | 8.926 | 9.38 | 8.89 | 9.42 | 3.0% | 10/10 |
|
||||||
|
| **semantic @ text-embedding-3-large** | **8.727** | 9.38 | 8.83 | 9.30 | 4.8% | 10/10 |
|
||||||
|
| fixed_size ±0 | 8.606 | 9.18 | 8.79 | 9.25 | 4.9% | 10/10 |
|
||||||
|
| semantic @ nomic-embed-text-v2-moe | 8.551 | 9.19 | 8.65 | 9.14 | 4.7% | 10/10 |
|
||||||
|
|
||||||
|
`fixed_size ±1`, `±2`, and `±3` all beat the best semantic Candidate on **mean**. **`±0` (no Neighbor Expansion) does not** (8.606 vs 8.727). Shipping `fixed_size` without expansion would weaken this family decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Judge metrics (stage 2 winners)
|
||||||
|
|
||||||
|
`fixed_size ±3` leads on every judge metric, including **lower hallucination**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Per-document showdown
|
||||||
|
|
||||||
|
Mean ranking still favors `fixed_size`. **Head-to-head at ±3: semantic 6 / `fixed_size` 4.** Semantic’s six wins are mostly **small**; `fixed_size`’s four wins are **larger**, especially **fire**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Document | Questions | fixed_size ±3 | semantic @ large | Winner |
|
||||||
|
|----------|-----------|---------------|------------------|--------|
|
||||||
|
| fire.docx | 20 | **8.810** | 6.228 | **fixed_size** (+2.58) |
|
||||||
|
| moavenin.docx | 20 | **9.735** | 8.621 | **fixed_size** (+1.11) |
|
||||||
|
| general-havades-individuals.doc | 20 | **9.205** | 8.564 | **fixed_size** (+0.64) |
|
||||||
|
| havades.docx | 20 | **8.483** | 7.969 | **fixed_size** (+0.51) |
|
||||||
|
| bazresi.docx | 16 | 9.366 | **9.405** | semantic (−0.04) |
|
||||||
|
| life-time-individual.docx | 20 | 9.445 | **9.505** | semantic (−0.06) |
|
||||||
|
| Refah.docx | 20 | 9.680 | **9.815** | semantic (−0.14) |
|
||||||
|
| lifetime-compensation.docx | 15 | 8.761 | **9.129** | semantic (−0.37) |
|
||||||
|
| customer1.docx | 20 | 8.451 | **8.973** | semantic (−0.52) |
|
||||||
|
| website.docx | 30 | 8.466 | **9.058** | semantic (−0.59) |
|
||||||
|
|
||||||
|
Win-count would pick semantic. **Official product ranking (mean composite) picks `fixed_size`.** This memo follows the Decision Board rule (ADR-0026).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Full Candidate heatmap
|
||||||
|
|
||||||
|
Every cell is a newest single-strategy Experiment under `text-embedding-3-large`. Semantic @ large **collapses on fire**; `fixed_size ±1…±3` stay high across the set.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Best `fixed_size` ±N **by document** (does not change the family call):
|
||||||
|
|
||||||
|
| ±N | Documents where it is the best fixed_size cell |
|
||||||
|
|----|------------------------------------------------|
|
||||||
|
| ±3 | bazresi, fire, general-havades-individuals, havades, life-time-individual (5) |
|
||||||
|
| ±1 | customer1, moavenin, Refah (3) |
|
||||||
|
| ±2 | website (1) |
|
||||||
|
| ±0 | lifetime-compensation (1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Method
|
||||||
|
|
||||||
|
- Corpus Embedding Model locked to **`text-embedding-3-large`**
|
||||||
|
- Newest **single-strategy** Experiment per document × Candidate (Decision Board cells)
|
||||||
|
- Retrieval `top_k = 5`
|
||||||
|
- Stage 1: best `fixed_size` ±N vs best `semantic@Boundary`
|
||||||
|
- Stage 2: mean composite + per-document breakdown
|
||||||
|
- Experiments dated **9–10 August 2026**
|
||||||
|
- `logs/neighbor_sweep.log` recorded mid-run connection errors on some units; **SQLite now has a full 10×4 `fixed_size` grid** — treat the database as source of truth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Excluded strategies
|
||||||
|
|
||||||
|
No comparable **10-doc, single-strategy, `text-embedding-3-large`** Experiments exist for the three Strategies below. They are not Decision Board Candidates (ADR-0026).
|
||||||
|
|
||||||
|
| Strategy | Why it is not the winner |
|
||||||
|
|----------|--------------------------|
|
||||||
|
| **recursive** | Not on the Decision Board grid. Informal PDF / `text-embedding-3-small` multi-strategy runs (not comparable to this close-out) were mixed; recursive **does not get Neighbor Expansion** (expansion is `fixed_size` only). |
|
||||||
|
| **contextual_retrieval** | Extra LLM call per chunk at process time. Same informal PDF/`small` runs scored **below** recursive and `fixed_size`. |
|
||||||
|
| **semantic_parent_child** | Boundary-detection cost plus parent fetch at query. Informal PDF/`small` composites were **much worse** (~1.8–2.8). Fail-hard if Boundary embeds mismatch (ADR-0020). |
|
||||||
|
|
||||||
|
One leftover five-strategy Experiment on `customer1` under large is **invalid** (four Strategies scored 0 / errors) and was ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Binding decision
|
||||||
|
|
||||||
|
**Use `fixed_size` for chunking** under **`text-embedding-3-large`.**
|
||||||
|
|
||||||
|
**Use Neighbor Expansion `neighbor_prev=3`, `neighbor_next=3`** (symmetric **±3/3**) for Query and Experiment defaults when running `fixed_size`.
|
||||||
|
|
||||||
|
**Do not use `semantic` as the default Strategy** on this evaluation universe.
|
||||||
|
|
||||||
|
**Do not default to ±0.** Without expansion, semantic @ large beats `fixed_size` on mean composite (8.727 vs 8.606).
|
||||||
|
|
||||||
|
Operational defaults are set in `src/core/config.py`, `.env.example`, and the Dashboard Query/Benchmarks forms. Override per request is still supported.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Addendum — why ±3 (22 August 2026)
|
||||||
|
|
||||||
|
Stage 1 ranked all `fixed_size` Candidates on the 10-doc grid:
|
||||||
|
|
||||||
|
| ±N | Mean composite | vs semantic @ large |
|
||||||
|
|----|----------------|---------------------|
|
||||||
|
| ±0 | 8.606 | **loses** (−0.121) |
|
||||||
|
| ±1 | 8.926 | wins (+0.199) |
|
||||||
|
| ±2 | 8.987 | wins (+0.260) |
|
||||||
|
| **±3** | **9.040** | **wins (+0.313)** |
|
||||||
|
|
||||||
|
**±3** is the stage 1 auto-pick and the highest mean composite. **±1** and **±2** are close; **±0** is ruled out.
|
||||||
|
|
||||||
|
Per-document best ±N varies (±3 wins on 5 docs, ±1 on 3, ±2 on 1, ±0 on 1). The **global** default is still **±3** because Decision Board ranks by mean composite across the full set, not by win-count among ±N levels.
|
||||||
|
|
||||||
|
**Caveats kept from stage 2:** semantic still wins head-to-head on 6/10 docs vs ±3 (website, customer1, etc.), but mean composite and judge metrics favor **`fixed_size ±3`**. Retrieval Inspect on outlier docs remains optional follow-up.
|
||||||
|
|
||||||
|
**Production RAG outside this repo** is not changed by this addendum — only benchmarker defaults and this record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Evidence (stage 2 cells)
|
||||||
|
|
||||||
|
| Document | `fixed_size ±3` experiment id | `semantic @ large` experiment id |
|
||||||
|
|----------|-------------------------------|----------------------------------|
|
||||||
|
| bazresi.docx | `6fe08750ee324130b3d46d8b3bc95280` | `6263c4f9e63347cea9eb19f4d36309c0` |
|
||||||
|
| customer1.docx | `8c4d888d367b4c0299b2d19dd9654f80` | `0c4572e80902408796f0dd11776e8e55` |
|
||||||
|
| fire.docx | `230a3cf57ae74b679ecde83cfeb9b6f5` | `414162efc314406db082ba8537c2423f` |
|
||||||
|
| general-havades-individuals.doc | `7ad892cfcd6143109f77a7808c8dfbd9` | `14fa4f872373446c8b042fc8e84e6983` |
|
||||||
|
| havades.docx | `04bd45d836a64c3da2e68bb747c0c9b8` | `232238b4d3304243b82b02326ef64617` |
|
||||||
|
| life-time-individual.docx | `d682706f54824469b235c099bf5a3d3c` | `bc7d9f1fbe3540c097a5f8cc1a1c44bb` |
|
||||||
|
| moavenin.docx | `63f81cf0a632418aab2f93884518ed71` | `4e01960b5db14e81975a4fbc86c4e10c` |
|
||||||
|
| Refah.docx | `6304dba39f3f4c05935195c588a36a64` | `b5c630c45615451a93d97d80dee361df` |
|
||||||
|
| website.docx | `fb9d47750ca74761a3c82c57aa51030b` | `53256897cfb74dd7a6508e750ff1ba63` |
|
||||||
|
| lifetime-compensation.docx | `99736ffbc6da455b8b3ca3a0fb8e5ef7` | `e51c2b57e7364dfa97e5429f615d6e4e` |
|
||||||
|
|
||||||
|
HTML reports: `GET /benchmarks/{id}/report`. Dashboard: Decision Tab, Corpus = `text-embedding-3-large`.
|
||||||
81
docs/human-eval-fixed-size-plus3-report.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Human Evaluation Report — fixed_size ±3
|
||||||
|
|
||||||
|
**Date:** 22 August 2026
|
||||||
|
**Strategy:** `fixed_size` with Neighbor Expansion **±3/3**
|
||||||
|
**Corpus Embedding Model:** `text-embedding-3-large`
|
||||||
|
**Sample:** **60 questions** (6 per document × **10 documents**)
|
||||||
|
**Total benchmark universe:** 201 questions
|
||||||
|
**Review effort:** ~6 hours
|
||||||
|
|
||||||
|
**Verdict:** Human review **supports** the stabilized **`fixed_size ±3`** default (see [final decision](final-chunking-strategy-decision.md)).
|
||||||
|
|
||||||
|
| KPI | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| Mean human score (1–5) | **4.30** |
|
||||||
|
| Mean LLM answer similarity (1–10) | **8.88** |
|
||||||
|
| Human–LLM agreement | **57%** agree · 42% partial · 2% disagree |
|
||||||
|
| Questions reviewed | **60** / 201 (30%) |
|
||||||
|
|
||||||
|
Raw scores: [fixed_size_plus3_human_scores.csv](assets/human-eval/fixed_size_plus3_human_scores.csv)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mean human score by document
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 2. Human vs LLM-as-Judge agreement
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Agreement = same quality tier when mapping LLM answer similarity (1–10) to human scale (1–5).
|
||||||
|
|
||||||
|
## 3. Human score vs LLM similarity by document
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 4. Human score distribution
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 5. Document summary
|
||||||
|
|
||||||
|
| Document | Reviewed | Mean human (1–5) | Mean LLM similarity | Agreement | |
|
||||||
|
|----------|----------|------------------|---------------------|-----------|---|
|
||||||
|
| bazresi | 6 | 4.33 | 8.50 | 50% | ⚠ |
|
||||||
|
| customer1 | 6 | 4.00 | 9.00 | 33% | ⚠ |
|
||||||
|
| fire | 6 | 4.50 | 8.33 | 67% | |
|
||||||
|
| havades-ind | 6 | 3.83 | 8.50 | 50% | ⚠ |
|
||||||
|
| havades | 6 | 4.00 | 8.83 | 50% | ⚠ |
|
||||||
|
| lifetime-ind | 6 | 4.33 | 9.33 | 50% | ⚠ |
|
||||||
|
| moavenin | 6 | 4.83 | 9.50 | 83% | |
|
||||||
|
| Refah | 6 | 4.67 | 9.33 | 83% | |
|
||||||
|
| website | 6 | 4.17 | 9.00 | 17% | ⚠ |
|
||||||
|
| lifetime-comp | 6 | 4.33 | 8.50 | 83% | |
|
||||||
|
|
||||||
|
## 6. Notable disagreements (human stricter than LLM)
|
||||||
|
|
||||||
|
| Document | Question (excerpt) | Human | LLM sim | Notes |
|
||||||
|
|----------|-------------------|-------|---------|-------|
|
||||||
|
| havades-ind | آیا خطرات جنگ، زلزله و ورزشهای خطرناک قابل پوشش هستند؟ | 2 | 7 | Answer incomplete compared to expected reference. |
|
||||||
|
|
||||||
|
## 7. Method
|
||||||
|
|
||||||
|
- Stratified sample: **6 questions per document**, all **10** evaluation documents represented.
|
||||||
|
- Human rubric: **1–5** (1 = wrong/unhelpful, 3 = partial, 5 = matches expected answer).
|
||||||
|
- Each row: read question, expected answer, generated answer; score independently of automation.
|
||||||
|
- LLM-as-Judge scores retained in CSV for comparison (context relevance, similarity, faithfulness, hallucination).
|
||||||
|
- Source Experiments: newest single-strategy **`fixed_size ±3`** run per document under **`text-embedding-3-large`**.
|
||||||
|
|
||||||
|
## 8. Conclusions
|
||||||
|
|
||||||
|
1. **Overall quality is strong** — mean human score **4.30/5** on the reviewed sample.
|
||||||
|
2. **Automated evaluation is directionally reliable** — **57%** agreement on quality tier; disagreements cluster on nuanced or incomplete answers.
|
||||||
|
3. **website** and **customer1** show the largest human–LLM gaps; worth optional Retrieval Inspect follow-up, but do not overturn the global **`fixed_size ±3`** decision.
|
||||||
|
4. Human review **confirms** the family and ±3 configuration documented in [final-chunking-strategy-decision.md](final-chunking-strategy-decision.md).
|
||||||
|
|
||||||
|
Regenerate charts after CSV updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python docs/assets/human-eval/generate_human_eval_report.py
|
||||||
|
```
|
||||||
@@ -12,7 +12,9 @@ No built-in export of experiment results, query logs, or benchmark comparisons t
|
|||||||
|
|
||||||
## Custom Chunking Parameters from the UI
|
## Custom Chunking Parameters from the UI
|
||||||
|
|
||||||
You cannot change `chunk_size`, `chunk_overlap`, `semantic_threshold`, or `semantic_min_chunk_size` from the dashboard. These are hardcoded in `src/core/config.py` with defaults (512 tokens, 50 overlap, 0.3 threshold). Changing them requires editing the config and restarting the server. The UI uses whatever the server has configured.
|
You cannot change `chunk_size`, `chunk_overlap`, or `semantic_min_chunk_size` from the dashboard. Those remain in `src/core/config.py` (defaults: 512 tokens, 50 overlap, min chunk size from config). Changing them requires editing the config and restarting the server.
|
||||||
|
|
||||||
|
**Exception:** per-Embedding-Model `semantic_threshold` is Admin-configurable (registry defaults OpenAI 0.3 / Nomic 0.6; overrides in SQLite). Re-process after changing it. Other Strategy knobs stay out of the UI.
|
||||||
|
|
||||||
## Streaming Responses
|
## Streaming Responses
|
||||||
|
|
||||||
@@ -43,4 +45,4 @@ No full-text search or filtering within the dashboard. You cannot search for a s
|
|||||||
|
|
||||||
## Configuration Management from the UI
|
## Configuration Management from the UI
|
||||||
|
|
||||||
You cannot view or edit the server configuration (OpenAI model, temperature, max_tokens, embedding model, Qdrant URL, database path) from the dashboard. The `/admin/config` endpoint was deferred to V1.1. All configuration is managed via the `.env` file and `src/core/config.py`.
|
You cannot view or edit arbitrary server configuration (LLM model, temperature, max_tokens, Qdrant URL, database path, Ollama base URL) from the dashboard. The general `/admin/config` endpoint was deferred to V1.1. **Exception (ADR-0019):** Active Embedding Model may be switched in Admin among entries in the static Embedding Model Registry. Ollama host URL remains env-only (`OLLAMA_BASE_URL`).
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Status legend: `DONE` `IN_PROGRESS` `TODO`
|
|||||||
| 17 | Create question-answer evaluation dataset from insurance regulation document | DONE | 12 |
|
| 17 | Create question-answer evaluation dataset from insurance regulation document | DONE | 12 |
|
||||||
| 18 | Implement HTML benchmark report generation system | DONE | 14 |
|
| 18 | Implement HTML benchmark report generation system | DONE | 14 |
|
||||||
| 19 | Design HTML report structure for experiment comparison and visualization | DONE | 14 |
|
| 19 | Design HTML report structure for experiment comparison and visualization | DONE | 14 |
|
||||||
| 20 | Create background processing jobs for document ingestion and benchmarking | TODO | 12 |
|
| 20 | Create background processing jobs for document ingestion and benchmarking | DONE | 12 |
|
||||||
|
|
||||||
## Phase 5 — Wiring + Verification
|
## Phase 5 — Wiring + Verification
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"pydantic>=2.5.0",
|
"pydantic>=2.5.0",
|
||||||
"pydantic-settings>=2.1.0",
|
"pydantic-settings>=2.1.0",
|
||||||
"python-docx>=1.0.0",
|
"python-docx>=1.0.0",
|
||||||
|
"pymupdf>=1.24.0",
|
||||||
"openai>=1.6.0",
|
"openai>=1.6.0",
|
||||||
"qdrant-client>=1.7.0",
|
"qdrant-client>=1.7.0",
|
||||||
"tiktoken>=0.5.0",
|
"tiktoken>=0.5.0",
|
||||||
|
|||||||
374
scripts/run_neighbor_sweep.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run a Benchmark Sweep: fixed_size × text-embedding-3-large × Neighbor ±0..±3.
|
||||||
|
|
||||||
|
Calls the running FastAPI app over HTTP (same path as the Dashboard).
|
||||||
|
Doc-major order; gap-fills Process (fixed_size only) when SQLite provenance
|
||||||
|
says the Model Corpus is not ready; retries each unit twice on failure.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
.venv/bin/python scripts/run_neighbor_sweep.py
|
||||||
|
.venv/bin/python scripts/run_neighbor_sweep.py --base-url http://127.0.0.1:8000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
CORPUS_MODEL_ID = "text-embedding-3-large"
|
||||||
|
STRATEGY = "fixed_size"
|
||||||
|
TOP_K = 5
|
||||||
|
NEIGHBOR_LEVELS = (0, 1, 2, 3)
|
||||||
|
MAX_ATTEMPTS = 3 # 1 try + 2 retries
|
||||||
|
DEFAULT_BASE_URL = "http://127.0.0.1:8000"
|
||||||
|
# Benchmarks with many questions can take a long time (LLM + eval per Q).
|
||||||
|
REQUEST_TIMEOUT_S = 60 * 60 * 3 # 3 hours
|
||||||
|
|
||||||
|
# App-truth document ↔ questions map (filenames as stored in the app).
|
||||||
|
SWEEP_PAIRS: list[tuple[str, str]] = [
|
||||||
|
("bazresi.docx", "files/bazresi.json"),
|
||||||
|
("customer1.docx", "files/customer1.json"),
|
||||||
|
("fire.docx", "files/fire.json"),
|
||||||
|
("general-havades-individuals.doc", "files/general-havades-individuals.json"),
|
||||||
|
("havades.docx", "files/havades.json"),
|
||||||
|
("life-time-individual.docx", "files/life-time-individual.json"),
|
||||||
|
("moavenin.docx", "files/moavenin.json"),
|
||||||
|
("Refah.docx", "files/refah.json"),
|
||||||
|
("website.docx", "files/website.json"),
|
||||||
|
("lifetime-compensation.docx", "files/lifetime-compensation.json"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UnitResult:
|
||||||
|
document: str
|
||||||
|
neighbor: int
|
||||||
|
kind: str # "process" | "experiment"
|
||||||
|
ok: bool
|
||||||
|
detail: str
|
||||||
|
experiment_id: str | None = None
|
||||||
|
attempts: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SweepState:
|
||||||
|
results: list[UnitResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed(self) -> list[UnitResult]:
|
||||||
|
return [r for r in self.results if not r.ok]
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def is_ready(doc: dict[str, Any]) -> bool:
|
||||||
|
corpus = doc.get("last_corpus_embedding_model_id")
|
||||||
|
counts = doc.get("chunk_counts") or {}
|
||||||
|
fixed = int(counts.get("fixed_size") or 0)
|
||||||
|
return corpus == CORPUS_MODEL_ID and fixed > 0
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_documents(client: httpx.Client) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Return filename → document dict (paginated)."""
|
||||||
|
by_name: dict[str, dict[str, Any]] = {}
|
||||||
|
offset = 0
|
||||||
|
limit = 100
|
||||||
|
while True:
|
||||||
|
r = client.get("/documents", params={"offset": offset, "limit": limit})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
items = data.get("items") or []
|
||||||
|
for doc in items:
|
||||||
|
by_name[doc["filename"]] = doc
|
||||||
|
total = int(data.get("total") or 0)
|
||||||
|
offset += len(items)
|
||||||
|
if offset >= total or not items:
|
||||||
|
break
|
||||||
|
return by_name
|
||||||
|
|
||||||
|
|
||||||
|
def error_detail(exc: BaseException) -> str:
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = exc.response.text[:500]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return f"HTTP {exc.response.status_code}: {body or exc}"
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def with_retries(
|
||||||
|
label: str,
|
||||||
|
fn,
|
||||||
|
*,
|
||||||
|
state: SweepState,
|
||||||
|
document: str,
|
||||||
|
neighbor: int,
|
||||||
|
kind: str,
|
||||||
|
) -> Any | None:
|
||||||
|
last_err = ""
|
||||||
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
log(f" [{kind}] {label} (attempt {attempt}/{MAX_ATTEMPTS})")
|
||||||
|
value = fn()
|
||||||
|
return value
|
||||||
|
except Exception as exc:
|
||||||
|
last_err = error_detail(exc)
|
||||||
|
log(f" ! failed attempt {attempt}/{MAX_ATTEMPTS}: {last_err}")
|
||||||
|
if attempt < MAX_ATTEMPTS:
|
||||||
|
time.sleep(min(5 * attempt, 15))
|
||||||
|
state.results.append(
|
||||||
|
UnitResult(
|
||||||
|
document=document,
|
||||||
|
neighbor=neighbor,
|
||||||
|
kind=kind,
|
||||||
|
ok=False,
|
||||||
|
detail=last_err,
|
||||||
|
attempts=MAX_ATTEMPTS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_corpus(
|
||||||
|
client: httpx.Client,
|
||||||
|
doc: dict[str, Any],
|
||||||
|
*,
|
||||||
|
state: SweepState,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Process fixed_size under CORPUS_MODEL_ID if not ready. Returns updated doc or None."""
|
||||||
|
filename = doc["filename"]
|
||||||
|
if is_ready(doc):
|
||||||
|
log(f" corpus ready ({CORPUS_MODEL_ID}, fixed_size={doc['chunk_counts'].get('fixed_size')})")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
log(
|
||||||
|
f" corpus not ready "
|
||||||
|
f"(last_corpus={doc.get('last_corpus_embedding_model_id')!r}, "
|
||||||
|
f"fixed_size={ (doc.get('chunk_counts') or {}).get('fixed_size') }) — processing"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process() -> dict[str, Any]:
|
||||||
|
r = client.post(
|
||||||
|
f"/documents/{doc['id']}/process",
|
||||||
|
json={
|
||||||
|
"strategies": [STRATEGY],
|
||||||
|
"corpus_model_id": CORPUS_MODEL_ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
body = r.json()
|
||||||
|
failed = body.get("strategies_failed") or []
|
||||||
|
if failed:
|
||||||
|
raise RuntimeError(f"process strategies_failed: {json.dumps(failed)}")
|
||||||
|
# Refresh document record
|
||||||
|
docs = fetch_documents(client)
|
||||||
|
updated = docs.get(filename)
|
||||||
|
if updated is None:
|
||||||
|
raise RuntimeError(f"document disappeared after process: {filename}")
|
||||||
|
if not is_ready(updated):
|
||||||
|
raise RuntimeError(
|
||||||
|
"process finished but provenance still not ready: "
|
||||||
|
f"last_corpus={updated.get('last_corpus_embedding_model_id')!r}, "
|
||||||
|
f"chunk_counts={updated.get('chunk_counts')}"
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
result = with_retries(
|
||||||
|
f"process {filename}",
|
||||||
|
_process,
|
||||||
|
state=state,
|
||||||
|
document=filename,
|
||||||
|
neighbor=-1,
|
||||||
|
kind="process",
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
state.results.append(
|
||||||
|
UnitResult(
|
||||||
|
document=filename,
|
||||||
|
neighbor=-1,
|
||||||
|
kind="process",
|
||||||
|
ok=True,
|
||||||
|
detail="gap-filled fixed_size",
|
||||||
|
attempts=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run_experiment(
|
||||||
|
client: httpx.Client,
|
||||||
|
*,
|
||||||
|
doc: dict[str, Any],
|
||||||
|
questions_file: str,
|
||||||
|
neighbor: int,
|
||||||
|
state: SweepState,
|
||||||
|
) -> None:
|
||||||
|
filename = doc["filename"]
|
||||||
|
|
||||||
|
def _bench() -> dict[str, Any]:
|
||||||
|
r = client.post(
|
||||||
|
"/benchmarks",
|
||||||
|
json={
|
||||||
|
"document_id": doc["id"],
|
||||||
|
"strategies": [STRATEGY],
|
||||||
|
"questions_file": questions_file,
|
||||||
|
"top_k": TOP_K,
|
||||||
|
"neighbor_prev": neighbor,
|
||||||
|
"neighbor_next": neighbor,
|
||||||
|
"corpus_model_id": CORPUS_MODEL_ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
body = with_retries(
|
||||||
|
f"±{neighbor}/{neighbor} on {filename}",
|
||||||
|
_bench,
|
||||||
|
state=state,
|
||||||
|
document=filename,
|
||||||
|
neighbor=neighbor,
|
||||||
|
kind="experiment",
|
||||||
|
)
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
exp_id = body.get("experiment_id", "")
|
||||||
|
state.results.append(
|
||||||
|
UnitResult(
|
||||||
|
document=filename,
|
||||||
|
neighbor=neighbor,
|
||||||
|
kind="experiment",
|
||||||
|
ok=True,
|
||||||
|
detail=(
|
||||||
|
f"best={body.get('best_strategy')} "
|
||||||
|
f"latency={body.get('total_latency_seconds')}s "
|
||||||
|
f"cost=${body.get('estimated_cost_usd')}"
|
||||||
|
),
|
||||||
|
experiment_id=exp_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(f" ok experiment_id={exp_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_summary(state: SweepState) -> None:
|
||||||
|
log("")
|
||||||
|
log("=" * 88)
|
||||||
|
log("BENCHMARK SWEEP SUMMARY")
|
||||||
|
log("=" * 88)
|
||||||
|
log(
|
||||||
|
f"{'document':<40} {'±N':>4} {'kind':<11} {'status':<6} detail"
|
||||||
|
)
|
||||||
|
log("-" * 88)
|
||||||
|
for r in state.results:
|
||||||
|
n = "—" if r.neighbor < 0 else f"±{r.neighbor}"
|
||||||
|
status = "ok" if r.ok else "FAIL"
|
||||||
|
detail = r.experiment_id or r.detail
|
||||||
|
if not r.ok and r.experiment_id is None:
|
||||||
|
detail = r.detail
|
||||||
|
elif r.ok and r.experiment_id:
|
||||||
|
detail = f"id={r.experiment_id} {r.detail}"
|
||||||
|
log(f"{r.document:<40} {n:>4} {r.kind:<11} {status:<6} {detail}")
|
||||||
|
|
||||||
|
failed = state.failed
|
||||||
|
log("-" * 88)
|
||||||
|
exp_ok = sum(1 for r in state.results if r.kind == "experiment" and r.ok)
|
||||||
|
exp_fail = sum(1 for r in state.results if r.kind == "experiment" and not r.ok)
|
||||||
|
log(f"Experiments: {exp_ok} ok, {exp_fail} failed")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
log("")
|
||||||
|
log("Manual redo hints (failed units):")
|
||||||
|
for r in failed:
|
||||||
|
if r.kind == "process":
|
||||||
|
log(
|
||||||
|
f" • Process {r.document}: "
|
||||||
|
f'POST /documents/{{id}}/process '
|
||||||
|
f'{{"strategies":["{STRATEGY}"],"corpus_model_id":"{CORPUS_MODEL_ID}"}}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log(
|
||||||
|
f" • Experiment {r.document} ±{r.neighbor}: "
|
||||||
|
f'POST /benchmarks with strategies=["{STRATEGY}"], '
|
||||||
|
f"neighbor_prev={r.neighbor}, neighbor_next={r.neighbor}, "
|
||||||
|
f"corpus_model_id={CORPUS_MODEL_ID}, questions_file from SWEEP_PAIRS"
|
||||||
|
)
|
||||||
|
log(f" error: {r.detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Run fixed_size Neighbor Expansion Benchmark Sweep")
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-url",
|
||||||
|
default=DEFAULT_BASE_URL,
|
||||||
|
help=f"FastAPI base URL (default: {DEFAULT_BASE_URL})",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
base_url = args.base_url.rstrip("/")
|
||||||
|
|
||||||
|
state = SweepState()
|
||||||
|
log(f"Benchmark Sweep → {base_url}")
|
||||||
|
log(f"Strategy={STRATEGY} corpus={CORPUS_MODEL_ID} top_k={TOP_K} neighbors={list(NEIGHBOR_LEVELS)}")
|
||||||
|
log(f"Documents: {len(SWEEP_PAIRS)}")
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(REQUEST_TIMEOUT_S, connect=30.0)
|
||||||
|
with httpx.Client(base_url=base_url, timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
health = client.get("/admin/health")
|
||||||
|
health.raise_for_status()
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Cannot reach app at {base_url}: {error_detail(exc)}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
docs = fetch_documents(client)
|
||||||
|
log(f"Loaded {len(docs)} documents from app")
|
||||||
|
|
||||||
|
for filename, questions_file in SWEEP_PAIRS:
|
||||||
|
log("")
|
||||||
|
log(f"=== {filename} ({questions_file}) ===")
|
||||||
|
doc = docs.get(filename)
|
||||||
|
if doc is None:
|
||||||
|
log(f" ! document not found in app — skipping")
|
||||||
|
state.results.append(
|
||||||
|
UnitResult(
|
||||||
|
document=filename,
|
||||||
|
neighbor=-1,
|
||||||
|
kind="process",
|
||||||
|
ok=False,
|
||||||
|
detail="document not found in GET /documents",
|
||||||
|
attempts=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated = ensure_corpus(client, doc, state=state)
|
||||||
|
if updated is None:
|
||||||
|
log(f" ! skipping experiments for {filename} (process failed)")
|
||||||
|
continue
|
||||||
|
docs[filename] = updated
|
||||||
|
|
||||||
|
for n in NEIGHBOR_LEVELS:
|
||||||
|
run_experiment(
|
||||||
|
client,
|
||||||
|
doc=updated,
|
||||||
|
questions_file=questions_file,
|
||||||
|
neighbor=n,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
print_summary(state)
|
||||||
|
return 1 if state.failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
"""Admin API routes — system health, Qdrant management, chunk preview, questions, cost."""
|
"""Admin API routes — system health, Qdrant management, chunk preview, questions, cost."""
|
||||||
|
|
||||||
import os
|
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||||
from fastapi import APIRouter, UploadFile, File
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from src.admin import service
|
from src.admin import service
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin")
|
router = APIRouter(prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveEmbeddingModelBody(BaseModel):
|
||||||
|
id: str = Field(..., description="Registry Embedding Model id")
|
||||||
|
|
||||||
|
|
||||||
|
class RoleEmbeddingModelBody(BaseModel):
|
||||||
|
id: str = Field(..., description="Registry Embedding Model id for Boundary or Corpus role")
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticThresholdBody(BaseModel):
|
||||||
|
semantic_threshold: float = Field(
|
||||||
|
...,
|
||||||
|
gt=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description="Cosine similarity cutoff for Semantic Boundary Detection",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Health ──────────────────────────────────────────────────
|
# ── Health ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
@@ -15,6 +33,50 @@ async def health_check():
|
|||||||
return service.get_health()
|
return service.get_health()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Embedding Models ────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/embedding-models")
|
||||||
|
async def list_embedding_models():
|
||||||
|
"""List Embedding Model Registry and Boundary/Corpus defaults."""
|
||||||
|
return service.list_embedding_models()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/embedding-models/active")
|
||||||
|
async def set_active_embedding_model(body: ActiveEmbeddingModelBody):
|
||||||
|
"""Legacy: set Default Corpus Embedding Model."""
|
||||||
|
result = service.set_corpus_embedding_model(body.id)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return {**result, "active_id": result.get("corpus_id")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/embedding-models/corpus")
|
||||||
|
async def set_corpus_embedding_model(body: RoleEmbeddingModelBody):
|
||||||
|
"""Set Default Corpus Embedding Model (storage + query)."""
|
||||||
|
result = service.set_corpus_embedding_model(body.id)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/embedding-models/boundary")
|
||||||
|
async def set_boundary_embedding_model(body: RoleEmbeddingModelBody):
|
||||||
|
"""Set Default Boundary Embedding Model (semantic cuts)."""
|
||||||
|
result = service.set_boundary_embedding_model(body.id)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/embedding-models/{model_id}/semantic-threshold")
|
||||||
|
async def update_semantic_threshold(model_id: str, body: SemanticThresholdBody):
|
||||||
|
"""Set per-Embedding-Model semantic_threshold (Admin override in SQLite)."""
|
||||||
|
result = service.update_semantic_threshold(model_id, body.semantic_threshold)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ── Qdrant Collections ─────────────────────────────────────
|
# ── Qdrant Collections ─────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/qdrant/collections")
|
@router.get("/qdrant/collections")
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ QUESTIONS_DIR = PROJECT_ROOT / "files"
|
|||||||
# ── Health ──────────────────────────────────────────────────
|
# ── Health ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_health() -> dict[str, Any]:
|
def get_health() -> dict[str, Any]:
|
||||||
"""Check server, Qdrant, and SQLite status."""
|
"""Check server, Qdrant, SQLite, OpenAI, and Ollama status."""
|
||||||
|
from src.chunking.embedding import (
|
||||||
|
get_boundary_embedding_model,
|
||||||
|
get_corpus_embedding_model,
|
||||||
|
)
|
||||||
|
|
||||||
result: dict[str, Any] = {"status": "ok"}
|
result: dict[str, Any] = {"status": "ok"}
|
||||||
|
|
||||||
# Check Qdrant
|
# Check Qdrant
|
||||||
@@ -51,57 +56,228 @@ def get_health() -> dict[str, Any]:
|
|||||||
|
|
||||||
# Check OpenAI
|
# Check OpenAI
|
||||||
try:
|
try:
|
||||||
client = get_openai_client()
|
get_openai_client()
|
||||||
# Just check the client exists; don't make a real API call
|
|
||||||
result["openai_configured"] = bool(settings.openai_api_key)
|
result["openai_configured"] = bool(settings.openai_api_key)
|
||||||
except Exception:
|
except Exception:
|
||||||
result["openai_configured"] = False
|
result["openai_configured"] = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
corpus = get_corpus_embedding_model()
|
||||||
|
boundary = get_boundary_embedding_model()
|
||||||
|
result["corpus_embedding_model"] = {
|
||||||
|
"id": corpus.id,
|
||||||
|
"provider": corpus.provider.value,
|
||||||
|
"model_name": corpus.model_name,
|
||||||
|
"dimension": corpus.dimension,
|
||||||
|
"display_name": corpus.display_name,
|
||||||
|
}
|
||||||
|
result["boundary_embedding_model"] = {
|
||||||
|
"id": boundary.id,
|
||||||
|
"provider": boundary.provider.value,
|
||||||
|
"model_name": boundary.model_name,
|
||||||
|
"dimension": boundary.dimension,
|
||||||
|
"display_name": boundary.display_name,
|
||||||
|
}
|
||||||
|
# Legacy alias for older Dashboard code
|
||||||
|
result["active_embedding_model"] = result["corpus_embedding_model"]
|
||||||
|
except Exception as exc:
|
||||||
|
result["corpus_embedding_model"] = None
|
||||||
|
result["boundary_embedding_model"] = None
|
||||||
|
result["active_embedding_model"] = None
|
||||||
|
result["active_embedding_error"] = str(exc)
|
||||||
|
|
||||||
|
result["ollama_base_url"] = settings.ollama_base_url
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
url = settings.ollama_base_url.rstrip("/") + "/api/tags"
|
||||||
|
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||||
|
result["ollama_reachable"] = resp.status == 200
|
||||||
|
except Exception as exc:
|
||||||
|
result["ollama_reachable"] = False
|
||||||
|
result["ollama_error"] = str(exc)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Embedding Models ────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_embedding_models() -> dict[str, Any]:
|
||||||
|
"""List registry entries and Boundary/Corpus defaults."""
|
||||||
|
from src.chunking.embedding import (
|
||||||
|
get_boundary_embedding_model,
|
||||||
|
get_corpus_embedding_model,
|
||||||
|
)
|
||||||
|
from src.chunking.embedding_models import get_semantic_threshold, list_models
|
||||||
|
|
||||||
|
corpus = get_corpus_embedding_model()
|
||||||
|
boundary = get_boundary_embedding_model()
|
||||||
|
models = []
|
||||||
|
for m in list_models():
|
||||||
|
models.append({
|
||||||
|
"id": m.id,
|
||||||
|
"provider": m.provider.value,
|
||||||
|
"model_name": m.model_name,
|
||||||
|
"dimension": m.dimension,
|
||||||
|
"display_name": m.display_name,
|
||||||
|
"task_prefixes": m.task_prefixes,
|
||||||
|
"default_semantic_threshold": m.default_semantic_threshold,
|
||||||
|
"semantic_threshold": get_semantic_threshold(m.id),
|
||||||
|
"is_corpus_default": m.id == corpus.id,
|
||||||
|
"is_boundary_default": m.id == boundary.id,
|
||||||
|
"is_active": m.id == corpus.id, # legacy
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"corpus_id": corpus.id,
|
||||||
|
"boundary_id": boundary.id,
|
||||||
|
"active_id": corpus.id, # legacy
|
||||||
|
"models": models,
|
||||||
|
"ollama_base_url": settings.ollama_base_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_embedding_model(model_id: str) -> dict[str, Any]:
|
||||||
|
"""Legacy: set Corpus default."""
|
||||||
|
return set_corpus_embedding_model(model_id)
|
||||||
|
|
||||||
|
|
||||||
|
def set_corpus_embedding_model(model_id: str) -> dict[str, Any]:
|
||||||
|
"""Switch the Default Corpus Embedding Model."""
|
||||||
|
from src.chunking.embedding import set_corpus_embedding_model as set_corpus
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = set_corpus(model_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"corpus_id": model.id,
|
||||||
|
"model": {
|
||||||
|
"id": model.id,
|
||||||
|
"provider": model.provider.value,
|
||||||
|
"model_name": model.model_name,
|
||||||
|
"dimension": model.dimension,
|
||||||
|
"display_name": model.display_name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_boundary_embedding_model(model_id: str) -> dict[str, Any]:
|
||||||
|
"""Switch the Default Boundary Embedding Model."""
|
||||||
|
from src.chunking.embedding import set_boundary_embedding_model as set_boundary
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = set_boundary(model_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"boundary_id": model.id,
|
||||||
|
"model": {
|
||||||
|
"id": model.id,
|
||||||
|
"provider": model.provider.value,
|
||||||
|
"model_name": model.model_name,
|
||||||
|
"dimension": model.dimension,
|
||||||
|
"display_name": model.display_name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_semantic_threshold(model_id: str, threshold: float) -> dict[str, Any]:
|
||||||
|
"""Persist Admin override of semantic_threshold for a registry Embedding Model."""
|
||||||
|
from src.chunking.embedding_models import (
|
||||||
|
get_model,
|
||||||
|
get_semantic_threshold,
|
||||||
|
set_semantic_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = get_model(model_id)
|
||||||
|
value = set_semantic_threshold(model_id, threshold)
|
||||||
|
except KeyError as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": model.id,
|
||||||
|
"semantic_threshold": value,
|
||||||
|
"default_semantic_threshold": model.default_semantic_threshold,
|
||||||
|
"effective": get_semantic_threshold(model.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Qdrant Collections ─────────────────────────────────────
|
# ── Qdrant Collections ─────────────────────────────────────
|
||||||
|
|
||||||
def list_qdrant_collections() -> dict[str, Any]:
|
def list_qdrant_collections() -> dict[str, Any]:
|
||||||
"""List all Qdrant collections with their point counts."""
|
"""List all Qdrant collections with point counts and Embedding Model labels."""
|
||||||
|
from src.chunking.embedding import get_active_embedding_model
|
||||||
|
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
collections_data = client.get_collections().collections
|
collections_data = client.get_collections().collections
|
||||||
|
active = get_active_embedding_model()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for col in collections_data:
|
for col in collections_data:
|
||||||
|
meta = qdrant_store.parse_collection_meta(col.name)
|
||||||
try:
|
try:
|
||||||
info = client.get_collection(collection_name=col.name)
|
info = client.get_collection(collection_name=col.name)
|
||||||
result.append({
|
points = info.points_count or 0
|
||||||
"name": col.name,
|
|
||||||
"points_count": info.points_count or 0,
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result.append({
|
points = None
|
||||||
"name": col.name,
|
err = str(exc)
|
||||||
"points_count": None,
|
else:
|
||||||
"error": str(exc),
|
err = None
|
||||||
})
|
|
||||||
|
|
||||||
return {"collections": result}
|
entry = {
|
||||||
|
"name": col.name,
|
||||||
|
"points_count": points,
|
||||||
|
"strategy": meta.get("strategy"),
|
||||||
|
"embedding_model_id": meta.get("embedding_model_id"),
|
||||||
|
"is_legacy": meta.get("is_legacy", False),
|
||||||
|
"is_active_corpus": meta.get("embedding_model_id") == active.id,
|
||||||
|
}
|
||||||
|
if err:
|
||||||
|
entry["error"] = err
|
||||||
|
result.append(entry)
|
||||||
|
|
||||||
|
# Active Model Corpus first
|
||||||
|
result.sort(key=lambda c: (not c.get("is_active_corpus", False), c["name"]))
|
||||||
|
return {
|
||||||
|
"collections": result,
|
||||||
|
"active_embedding_model_id": active.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_qdrant_collection(collection_name: str) -> dict[str, Any]:
|
def create_qdrant_collection(collection_name: str) -> dict[str, Any]:
|
||||||
"""Create a new Qdrant collection."""
|
"""Create a new Qdrant collection using Active Embedding Model dimension."""
|
||||||
|
from src.chunking.embedding import get_active_embedding_model
|
||||||
|
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
|
active = get_active_embedding_model()
|
||||||
|
|
||||||
existing = [c.name for c in client.get_collections().collections]
|
existing = [c.name for c in client.get_collections().collections]
|
||||||
if collection_name in existing:
|
if collection_name in existing:
|
||||||
return {"created": False, "message": f"Collection '{collection_name}' already exists"}
|
return {"created": False, "message": f"Collection '{collection_name}' already exists"}
|
||||||
|
|
||||||
|
meta = qdrant_store.parse_collection_meta(collection_name)
|
||||||
|
dimension = active.dimension
|
||||||
|
if meta.get("embedding_model_id"):
|
||||||
|
try:
|
||||||
|
from src.chunking.embedding_models import get_model
|
||||||
|
dimension = get_model(meta["embedding_model_id"]).dimension
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
client.create_collection(
|
client.create_collection(
|
||||||
collection_name=collection_name,
|
collection_name=collection_name,
|
||||||
vectors_config=VectorParams(
|
vectors_config=VectorParams(
|
||||||
size=qdrant_store.VECTOR_DIMENSION,
|
size=dimension,
|
||||||
distance=Distance.COSINE,
|
distance=Distance.COSINE,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logger.info("Created Qdrant collection: %s", collection_name)
|
logger.info("Created Qdrant collection: %s (dim=%d)", collection_name, dimension)
|
||||||
return {"created": True, "collection": collection_name}
|
return {"created": True, "collection": collection_name, "dimension": dimension}
|
||||||
|
|
||||||
|
|
||||||
def delete_qdrant_collection(collection_name: str) -> dict[str, Any]:
|
def delete_qdrant_collection(collection_name: str) -> dict[str, Any]:
|
||||||
@@ -134,8 +310,11 @@ def wipe_qdrant_collection_points(collection_name: str) -> dict[str, Any]:
|
|||||||
# ── Chunk Preview ───────────────────────────────────────────
|
# ── Chunk Preview ───────────────────────────────────────────
|
||||||
|
|
||||||
def preview_chunks(doc_id: str, strategy: str | None = None) -> dict[str, Any]:
|
def preview_chunks(doc_id: str, strategy: str | None = None) -> dict[str, Any]:
|
||||||
"""Preview chunks for a document. Uses Qdrant scroll to fetch chunks with payload."""
|
"""Preview chunks for a document from the Active Embedding Model's corpus."""
|
||||||
|
from src.chunking.embedding import get_active_embedding_model
|
||||||
|
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
|
active = get_active_embedding_model()
|
||||||
|
|
||||||
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
||||||
|
|
||||||
@@ -155,12 +334,12 @@ def preview_chunks(doc_id: str, strategy: str | None = None) -> dict[str, Any]:
|
|||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
for strat_name in strategies_to_search:
|
for strat_name in strategies_to_search:
|
||||||
col_name = f"{strat_name}_collection"
|
col_name = qdrant_store.collection_name(strat_name, active.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = [c.name for c in client.get_collections().collections]
|
existing = [c.name for c in client.get_collections().collections]
|
||||||
if col_name not in existing:
|
if col_name not in existing:
|
||||||
results[strat_name] = {"chunks": [], "count": 0}
|
results[strat_name] = {"chunks": [], "count": 0, "collection": col_name}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
scroll_filter = Filter(
|
scroll_filter = Filter(
|
||||||
@@ -188,12 +367,21 @@ def preview_chunks(doc_id: str, strategy: str | None = None) -> dict[str, Any]:
|
|||||||
|
|
||||||
# Sort by chunk_index
|
# Sort by chunk_index
|
||||||
chunks.sort(key=lambda c: c.get("chunk_index") or 0)
|
chunks.sort(key=lambda c: c.get("chunk_index") or 0)
|
||||||
results[strat_name] = {"chunks": chunks, "count": len(chunks)}
|
results[strat_name] = {
|
||||||
|
"chunks": chunks,
|
||||||
|
"count": len(chunks),
|
||||||
|
"collection": col_name,
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
results[strat_name] = {"error": str(exc), "chunks": [], "count": 0}
|
results[strat_name] = {"error": str(exc), "chunks": [], "count": 0}
|
||||||
|
|
||||||
return {"document_id": doc_id, "filename": doc_name, "strategies": results}
|
return {
|
||||||
|
"document_id": doc_id,
|
||||||
|
"filename": doc_name,
|
||||||
|
"embedding_model_id": active.id,
|
||||||
|
"strategies": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Questions Dataset ───────────────────────────────────────
|
# ── Questions Dataset ───────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any
|
|||||||
|
|
||||||
from src.benchmarking.evaluation import evaluate_single
|
from src.benchmarking.evaluation import evaluate_single
|
||||||
from src.benchmarking.query_service import run_query
|
from src.benchmarking.query_service import run_query
|
||||||
|
from src.chunking.embedding_models import Provider
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.core.exceptions import BenchmarkError
|
from src.core.exceptions import BenchmarkError
|
||||||
from src.core.models import StrategyName
|
from src.core.models import StrategyName
|
||||||
@@ -56,6 +57,26 @@ def load_questions(file_path: str | Path) -> list[dict]:
|
|||||||
raise BenchmarkError(f"Invalid JSON in questions file: {exc}") from exc
|
raise BenchmarkError(f"Invalid JSON in questions file: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_benchmark_questions(
|
||||||
|
*,
|
||||||
|
questions: list[dict] | None = None,
|
||||||
|
questions_file: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Resolve and validate benchmark questions from inline list or file."""
|
||||||
|
if questions_file:
|
||||||
|
return load_questions(questions_file)
|
||||||
|
if questions and len(questions) > 0:
|
||||||
|
valid_questions = [
|
||||||
|
q for q in questions if isinstance(q, dict) and "question" in q
|
||||||
|
]
|
||||||
|
if not valid_questions:
|
||||||
|
raise BenchmarkError(
|
||||||
|
"Invalid questions: each question must have a 'question' field"
|
||||||
|
)
|
||||||
|
return valid_questions
|
||||||
|
raise BenchmarkError("Either 'questions' or 'questions_file' must be provided")
|
||||||
|
|
||||||
|
|
||||||
def load_questions_from_string(questions_json: str) -> list[dict]:
|
def load_questions_from_string(questions_json: str) -> list[dict]:
|
||||||
"""Load questions from a JSON string.
|
"""Load questions from a JSON string.
|
||||||
|
|
||||||
@@ -87,15 +108,16 @@ def estimate_cost(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Estimate the cost of running a benchmark.
|
"""Estimate the cost of running a benchmark.
|
||||||
|
|
||||||
Args:
|
Local Embedding Models contribute $0 embedding cost (ADR-0019).
|
||||||
num_questions: Number of questions
|
|
||||||
num_strategies: Number of strategies
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cost estimate dict
|
|
||||||
"""
|
"""
|
||||||
|
from src.chunking.embedding import get_corpus_embedding_model
|
||||||
|
|
||||||
|
active = get_corpus_embedding_model()
|
||||||
|
|
||||||
# Rough estimates based on GPT-4o-mini pricing
|
# Rough estimates based on GPT-4o-mini pricing
|
||||||
embedding_cost_per_call = 0.0001
|
embedding_cost_per_call = (
|
||||||
|
0.0 if active.provider == Provider.LOCAL else 0.0001
|
||||||
|
)
|
||||||
query_cost_per_call = 0.001
|
query_cost_per_call = 0.001
|
||||||
evaluation_cost_per_call = 0.001
|
evaluation_cost_per_call = 0.001
|
||||||
|
|
||||||
@@ -118,6 +140,8 @@ def estimate_cost(
|
|||||||
"num_strategies": num_strategies,
|
"num_strategies": num_strategies,
|
||||||
"total_queries": total_queries,
|
"total_queries": total_queries,
|
||||||
"total_evaluations": total_evaluations,
|
"total_evaluations": total_evaluations,
|
||||||
|
"embedding_model_id": active.id,
|
||||||
|
"embedding_provider": active.provider.value,
|
||||||
"estimated_tokens": {
|
"estimated_tokens": {
|
||||||
"input": total_input_tokens,
|
"input": total_input_tokens,
|
||||||
"output": total_output_tokens,
|
"output": total_output_tokens,
|
||||||
@@ -139,6 +163,9 @@ def run_benchmark(
|
|||||||
strategies: list[StrategyName],
|
strategies: list[StrategyName],
|
||||||
questions: list[dict],
|
questions: list[dict],
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
|
neighbor_prev: int = 0,
|
||||||
|
neighbor_next: int = 0,
|
||||||
|
corpus_model_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a full benchmark across questions and strategies.
|
"""Run a full benchmark across questions and strategies.
|
||||||
|
|
||||||
@@ -147,16 +174,40 @@ def run_benchmark(
|
|||||||
strategies: List of strategies to test
|
strategies: List of strategies to test
|
||||||
questions: List of question dicts
|
questions: List of question dicts
|
||||||
top_k: Number of chunks to retrieve per query
|
top_k: Number of chunks to retrieve per query
|
||||||
|
neighbor_prev: Neighbor Expansion prev count (fixed_size only)
|
||||||
|
neighbor_next: Neighbor Expansion next count (fixed_size only)
|
||||||
|
corpus_model_id: Corpus Embedding Model (default Admin Corpus)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete benchmark results
|
Complete benchmark results
|
||||||
"""
|
"""
|
||||||
|
from src.chunking.embedding import resolve_corpus_model
|
||||||
|
|
||||||
t_start = time.time()
|
t_start = time.time()
|
||||||
|
embedding_model = resolve_corpus_model(corpus_model_id)
|
||||||
|
doc = db.get_document(document_id)
|
||||||
|
boundary_id = None
|
||||||
|
if doc and any(
|
||||||
|
s.value in ("semantic", "semantic_parent_child") for s in strategies
|
||||||
|
):
|
||||||
|
boundary_id = doc.get("last_boundary_embedding_model_id")
|
||||||
|
|
||||||
logger.info("=" * 80)
|
logger.info("=" * 80)
|
||||||
logger.info("[BENCHMARK] Starting benchmark")
|
logger.info("[BENCHMARK] Starting benchmark")
|
||||||
logger.info("[BENCHMARK] Document: %s", document_id)
|
logger.info("[BENCHMARK] Document: %s", document_id)
|
||||||
logger.info("[BENCHMARK] Strategies: %s", [s.value for s in strategies])
|
logger.info("[BENCHMARK] Strategies: %s", [s.value for s in strategies])
|
||||||
logger.info("[BENCHMARK] Questions: %d", len(questions))
|
logger.info("[BENCHMARK] Questions: %d", len(questions))
|
||||||
|
logger.info(
|
||||||
|
"[BENCHMARK] Corpus Embedding Model: %s (%s)",
|
||||||
|
embedding_model.id,
|
||||||
|
embedding_model.provider.value,
|
||||||
|
)
|
||||||
|
logger.info("[BENCHMARK] Boundary (from last process): %s", boundary_id or "—")
|
||||||
|
logger.info(
|
||||||
|
"[BENCHMARK] Neighbor Expansion: prev=%d next=%d",
|
||||||
|
neighbor_prev,
|
||||||
|
neighbor_next,
|
||||||
|
)
|
||||||
|
|
||||||
per_question_results = []
|
per_question_results = []
|
||||||
total_cost = 0.0
|
total_cost = 0.0
|
||||||
@@ -186,6 +237,9 @@ def run_benchmark(
|
|||||||
strategy_name=strategy,
|
strategy_name=strategy,
|
||||||
question=question_text,
|
question=question_text,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
|
neighbor_prev=neighbor_prev,
|
||||||
|
neighbor_next=neighbor_next,
|
||||||
|
embedding_model=embedding_model,
|
||||||
)
|
)
|
||||||
t_query = time.time() - t0
|
t_query = time.time() - t0
|
||||||
|
|
||||||
@@ -206,6 +260,7 @@ def run_benchmark(
|
|||||||
question_results["strategies"][strategy.value] = {
|
question_results["strategies"][strategy.value] = {
|
||||||
"answer": query_result["answer"],
|
"answer": query_result["answer"],
|
||||||
"retrieved_chunks": query_result["retrieved_chunks"],
|
"retrieved_chunks": query_result["retrieved_chunks"],
|
||||||
|
"expansion_tree": query_result.get("expansion_tree") or [],
|
||||||
"scores": eval_scores,
|
"scores": eval_scores,
|
||||||
"latency": {
|
"latency": {
|
||||||
"query_seconds": round(t_query, 3),
|
"query_seconds": round(t_query, 3),
|
||||||
@@ -252,11 +307,20 @@ def run_benchmark(
|
|||||||
"strategies": [s.value for s in strategies],
|
"strategies": [s.value for s in strategies],
|
||||||
"num_questions": len(questions),
|
"num_questions": len(questions),
|
||||||
"top_k": top_k,
|
"top_k": top_k,
|
||||||
|
"neighbor_prev": neighbor_prev,
|
||||||
|
"neighbor_next": neighbor_next,
|
||||||
|
"embedding_model_id": embedding_model.id,
|
||||||
|
"corpus_embedding_model_id": embedding_model.id,
|
||||||
|
"embedding_provider": embedding_model.provider.value,
|
||||||
|
"boundary_embedding_model_id": boundary_id,
|
||||||
},
|
},
|
||||||
questions=questions,
|
questions=questions,
|
||||||
per_question=per_question_results,
|
per_question=per_question_results,
|
||||||
aggregate_metrics=aggregate,
|
aggregate_metrics=aggregate,
|
||||||
strategies_used=[s.value for s in strategies],
|
strategies_used=[s.value for s in strategies],
|
||||||
|
embedding_model_id=embedding_model.id,
|
||||||
|
embedding_provider=embedding_model.provider.value,
|
||||||
|
boundary_embedding_model_id=boundary_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("[BENCHMARK] Completed in %.1fs", t_total)
|
logger.info("[BENCHMARK] Completed in %.1fs", t_total)
|
||||||
@@ -267,6 +331,8 @@ def run_benchmark(
|
|||||||
"experiment_id": experiment["id"],
|
"experiment_id": experiment["id"],
|
||||||
"document_id": document_id,
|
"document_id": document_id,
|
||||||
"strategies_used": [s.value for s in strategies],
|
"strategies_used": [s.value for s in strategies],
|
||||||
|
"embedding_model_id": embedding_model.id,
|
||||||
|
"embedding_provider": embedding_model.provider.value,
|
||||||
"questions_count": len(questions),
|
"questions_count": len(questions),
|
||||||
"aggregate_metrics": aggregate,
|
"aggregate_metrics": aggregate,
|
||||||
"best_strategy": best_strategy,
|
"best_strategy": best_strategy,
|
||||||
@@ -353,6 +419,11 @@ def get_experiment(experiment_id: str) -> dict[str, Any] | None:
|
|||||||
return db.get_experiment(experiment_id)
|
return db.get_experiment(experiment_id)
|
||||||
|
|
||||||
|
|
||||||
def list_experiments(document_id: str | None = None) -> dict[str, Any]:
|
def list_experiments(
|
||||||
|
document_id: str | None = None,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""List experiments, optionally filtered by document."""
|
"""List experiments, optionally filtered by document."""
|
||||||
return db.list_experiments(document_id=document_id)
|
return db.list_experiments(document_id=document_id, offset=offset, limit=limit)
|
||||||
|
|||||||
@@ -66,9 +66,14 @@ def _build_context_for_evaluation(retrieved_chunks: list[dict]) -> str:
|
|||||||
"""Build a readable context string from retrieved chunks."""
|
"""Build a readable context string from retrieved chunks."""
|
||||||
parts = []
|
parts = []
|
||||||
for i, chunk in enumerate(retrieved_chunks, 1):
|
for i, chunk in enumerate(retrieved_chunks, 1):
|
||||||
score = chunk.get("score", 0)
|
score = chunk.get("score")
|
||||||
text = chunk.get("text", "")
|
text = chunk.get("text", "")
|
||||||
parts.append(f"[Chunk {i} (score: {score:.3f})]\n{text}")
|
role = chunk.get("role") or ("neighbor" if score is None else "hit")
|
||||||
|
if isinstance(score, (int, float)):
|
||||||
|
header = f"[Chunk {i} (score: {score:.3f}, {role})]"
|
||||||
|
else:
|
||||||
|
header = f"[Chunk {i} (score: —, {role})]"
|
||||||
|
parts.append(f"{header}\n{text}")
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.core.config import settings
|
||||||
from src.core.models import StrategyName
|
from src.core.models import StrategyName
|
||||||
|
|
||||||
|
|
||||||
@@ -15,16 +16,51 @@ class QueryRequest(BaseModel):
|
|||||||
strategy: StrategyName = Field(description="Chunking strategy to use")
|
strategy: StrategyName = Field(description="Chunking strategy to use")
|
||||||
question: str = Field(description="Question to ask", min_length=1)
|
question: str = Field(description="Question to ask", min_length=1)
|
||||||
top_k: int = Field(default=5, description="Number of chunks to retrieve", ge=1, le=20)
|
top_k: int = Field(default=5, description="Number of chunks to retrieve", ge=1, le=20)
|
||||||
|
neighbor_prev: int = Field(
|
||||||
|
default_factory=lambda: settings.neighbor_prev,
|
||||||
|
description="Neighbor Expansion: previous chunks per hit (fixed_size only)",
|
||||||
|
ge=0,
|
||||||
|
le=5,
|
||||||
|
)
|
||||||
|
neighbor_next: int = Field(
|
||||||
|
default_factory=lambda: settings.neighbor_next,
|
||||||
|
description="Neighbor Expansion: next chunks per hit (fixed_size only)",
|
||||||
|
ge=0,
|
||||||
|
le=5,
|
||||||
|
)
|
||||||
|
corpus_model_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Corpus Embedding Model id (query + search); default = Admin Corpus",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Response ───────────────────────────────────────────────────────
|
# ── Response ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
class RetrievedChunk(BaseModel):
|
class RetrievedChunk(BaseModel):
|
||||||
"""A single retrieved chunk with its similarity score."""
|
"""A single chunk in the flat LLM/eval context (ADR-0023)."""
|
||||||
|
chunk_id: str
|
||||||
|
score: Optional[float] = None
|
||||||
|
text: str
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
chunk_index: Optional[int] = None
|
||||||
|
role: Optional[str] = None # "hit" | "neighbor"
|
||||||
|
|
||||||
|
|
||||||
|
class ExpansionNeighbor(BaseModel):
|
||||||
|
"""A neighbor chunk in the Expansion Tree."""
|
||||||
|
chunk_id: str
|
||||||
|
text: str
|
||||||
|
chunk_index: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpansionTreeNode(BaseModel):
|
||||||
|
"""One top-k hit with its per-hit Neighbor Expansion window."""
|
||||||
chunk_id: str
|
chunk_id: str
|
||||||
score: float
|
score: float
|
||||||
text: str
|
text: str
|
||||||
parent_id: Optional[str] = None
|
chunk_index: Optional[int] = None
|
||||||
|
neighbors_prev: list[ExpansionNeighbor] = Field(default_factory=list)
|
||||||
|
neighbors_next: list[ExpansionNeighbor] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class QueryResponse(BaseModel):
|
class QueryResponse(BaseModel):
|
||||||
@@ -35,6 +71,9 @@ class QueryResponse(BaseModel):
|
|||||||
question: str
|
question: str
|
||||||
answer: str
|
answer: str
|
||||||
retrieved_chunks: list[RetrievedChunk]
|
retrieved_chunks: list[RetrievedChunk]
|
||||||
|
expansion_tree: list[ExpansionTreeNode] = Field(default_factory=list)
|
||||||
|
neighbor_prev: int = 0
|
||||||
|
neighbor_next: int = 0
|
||||||
latency_breakdown: dict[str, float]
|
latency_breakdown: dict[str, float]
|
||||||
token_usage: dict[str, int]
|
token_usage: dict[str, int]
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -48,6 +87,7 @@ class QueryDetailResponse(BaseModel):
|
|||||||
question: str
|
question: str
|
||||||
answer: str
|
answer: str
|
||||||
retrieved_chunks: list[dict]
|
retrieved_chunks: list[dict]
|
||||||
|
expansion_tree: list[dict] = Field(default_factory=list)
|
||||||
latency_breakdown: dict[str, float]
|
latency_breakdown: dict[str, float]
|
||||||
token_usage: dict[str, int]
|
token_usage: dict[str, int]
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -78,6 +118,22 @@ class BenchmarkRequest(BaseModel):
|
|||||||
description="Path to questions JSON file (relative to project root)",
|
description="Path to questions JSON file (relative to project root)",
|
||||||
)
|
)
|
||||||
top_k: int = Field(default=5, description="Number of chunks to retrieve", ge=1, le=20)
|
top_k: int = Field(default=5, description="Number of chunks to retrieve", ge=1, le=20)
|
||||||
|
neighbor_prev: int = Field(
|
||||||
|
default_factory=lambda: settings.neighbor_prev,
|
||||||
|
description="Neighbor Expansion: previous chunks per hit (fixed_size only)",
|
||||||
|
ge=0,
|
||||||
|
le=5,
|
||||||
|
)
|
||||||
|
neighbor_next: int = Field(
|
||||||
|
default_factory=lambda: settings.neighbor_next,
|
||||||
|
description="Neighbor Expansion: next chunks per hit (fixed_size only)",
|
||||||
|
ge=0,
|
||||||
|
le=5,
|
||||||
|
)
|
||||||
|
corpus_model_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Corpus Embedding Model id; default = Admin Corpus",
|
||||||
|
)
|
||||||
dry_run: bool = Field(default=False, description="Only return cost estimate, don't run benchmark")
|
dry_run: bool = Field(default=False, description="Only return cost estimate, don't run benchmark")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ from typing import Any
|
|||||||
|
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
from src.chunking.embedding import embed_single
|
from src.chunking.embedding import embed_single, resolve_corpus_model
|
||||||
|
from src.chunking.embedding_models import EmbeddingModelSpec
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.core.dependencies import get_openai_client
|
from src.core.dependencies import get_openai_client, get_qdrant_client
|
||||||
from src.core.exceptions import QueryError
|
from src.core.exceptions import QueryError
|
||||||
from src.core.models import StrategyName
|
from src.core.models import StrategyName
|
||||||
from src.storage import qdrant as qdr
|
from src.storage import qdrant as qdr
|
||||||
@@ -41,6 +42,8 @@ def _fetch_parent_chunks(
|
|||||||
child_hits: list[dict],
|
child_hits: list[dict],
|
||||||
strategy: StrategyName,
|
strategy: StrategyName,
|
||||||
document_name: str,
|
document_name: str,
|
||||||
|
*,
|
||||||
|
model_id: str,
|
||||||
) -> dict[str, dict]:
|
) -> dict[str, dict]:
|
||||||
"""Fetch parent chunks for child hits in semantic_parent_child strategy.
|
"""Fetch parent chunks for child hits in semantic_parent_child strategy.
|
||||||
|
|
||||||
@@ -64,11 +67,10 @@ def _fetch_parent_chunks(
|
|||||||
# Search for parent chunks by their IDs
|
# Search for parent chunks by their IDs
|
||||||
parents = {}
|
parents = {}
|
||||||
for parent_id in parent_ids:
|
for parent_id in parent_ids:
|
||||||
# Use Qdrant scroll to find the parent chunk
|
|
||||||
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
||||||
|
|
||||||
client = qdr.get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = qdr.collection_name(strategy)
|
name = qdr.collection_name(strategy, model_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = client.scroll(
|
results = client.scroll(
|
||||||
@@ -103,6 +105,8 @@ def _build_context(
|
|||||||
hits: list[dict],
|
hits: list[dict],
|
||||||
strategy: StrategyName,
|
strategy: StrategyName,
|
||||||
document_name: str,
|
document_name: str,
|
||||||
|
*,
|
||||||
|
model_id: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build context string from retrieved chunks.
|
"""Build context string from retrieved chunks.
|
||||||
|
|
||||||
@@ -115,7 +119,9 @@ def _build_context(
|
|||||||
|
|
||||||
if strategy == StrategyName.SEMANTIC_PARENT_CHILD:
|
if strategy == StrategyName.SEMANTIC_PARENT_CHILD:
|
||||||
# Fetch parent chunks
|
# Fetch parent chunks
|
||||||
parents = _fetch_parent_chunks(hits, strategy, document_name)
|
parents = _fetch_parent_chunks(
|
||||||
|
hits, strategy, document_name, model_id=model_id
|
||||||
|
)
|
||||||
|
|
||||||
for i, hit in enumerate(hits, 1):
|
for i, hit in enumerate(hits, 1):
|
||||||
payload = hit.get("payload", {})
|
payload = hit.get("payload", {})
|
||||||
@@ -124,7 +130,10 @@ def _build_context(
|
|||||||
parent_id = payload.get("parent_id")
|
parent_id = payload.get("parent_id")
|
||||||
|
|
||||||
# Add child chunk
|
# Add child chunk
|
||||||
context_parts.append(f"[Chunk {i} (score: {score:.3f})]")
|
if isinstance(score, (int, float)):
|
||||||
|
context_parts.append(f"[Chunk {i} (score: {score:.3f})]")
|
||||||
|
else:
|
||||||
|
context_parts.append(f"[Chunk {i} (score: —)]")
|
||||||
context_parts.append(chunk_text)
|
context_parts.append(chunk_text)
|
||||||
|
|
||||||
# Add parent context if available
|
# Add parent context if available
|
||||||
@@ -143,10 +152,18 @@ def _build_context(
|
|||||||
score = hit.get("score", 0)
|
score = hit.get("score", 0)
|
||||||
chunk_text = payload.get("text", "")
|
chunk_text = payload.get("text", "")
|
||||||
|
|
||||||
logger.info("[CONTEXT] Chunk %d: score=%.3f, text_len=%d, chunk_id=%s",
|
logger.info(
|
||||||
i, score, len(chunk_text), hit.get("chunk_id", "unknown"))
|
"[CONTEXT] Chunk %d: score=%s, text_len=%d, chunk_id=%s",
|
||||||
|
i,
|
||||||
|
f"{score:.3f}" if isinstance(score, (int, float)) else "—",
|
||||||
|
len(chunk_text),
|
||||||
|
hit.get("chunk_id", "unknown"),
|
||||||
|
)
|
||||||
|
|
||||||
context_parts.append(f"[Chunk {i} (score: {score:.3f})]")
|
if isinstance(score, (int, float)):
|
||||||
|
context_parts.append(f"[Chunk {i} (score: {score:.3f})]")
|
||||||
|
else:
|
||||||
|
context_parts.append(f"[Chunk {i} (score: —)]")
|
||||||
context_parts.append(chunk_text)
|
context_parts.append(chunk_text)
|
||||||
context_parts.append("")
|
context_parts.append("")
|
||||||
|
|
||||||
@@ -201,6 +218,170 @@ def _generate_answer(
|
|||||||
raise QueryError(f"Answer generation failed: {exc}") from exc
|
raise QueryError(f"Answer generation failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _hit_to_chunk_dict(hit: dict, *, role: str) -> dict[str, Any]:
|
||||||
|
payload = hit.get("payload") or {}
|
||||||
|
score = hit.get("score")
|
||||||
|
return {
|
||||||
|
"chunk_id": payload.get("chunk_id", hit.get("chunk_id")),
|
||||||
|
"score": score if role == "hit" else None,
|
||||||
|
"text": payload.get("text", ""),
|
||||||
|
"parent_id": payload.get("parent_id"),
|
||||||
|
"chunk_index": payload.get("chunk_index"),
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _neighbor_brief(entry: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = entry.get("payload") or {}
|
||||||
|
return {
|
||||||
|
"chunk_id": payload.get("chunk_id", entry.get("chunk_id")),
|
||||||
|
"text": payload.get("text", ""),
|
||||||
|
"chunk_index": payload.get("chunk_index"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_neighbor_expansion(
|
||||||
|
hits: list[dict],
|
||||||
|
*,
|
||||||
|
strategy: StrategyName,
|
||||||
|
document_name: str,
|
||||||
|
model_id: str,
|
||||||
|
neighbor_prev: int,
|
||||||
|
neighbor_next: int,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Build flat retrieved_chunks + Expansion Tree (ADR-0023).
|
||||||
|
|
||||||
|
Expansion runs only for fixed_size when prev/next > 0. Otherwise the tree
|
||||||
|
lists hits with empty neighbor arrays and the flat list is the top-k hits.
|
||||||
|
"""
|
||||||
|
expansion_tree: list[dict[str, Any]] = []
|
||||||
|
for hit in hits:
|
||||||
|
payload = hit.get("payload") or {}
|
||||||
|
expansion_tree.append({
|
||||||
|
"chunk_id": payload.get("chunk_id", hit.get("chunk_id")),
|
||||||
|
"score": hit.get("score", 0),
|
||||||
|
"text": payload.get("text", ""),
|
||||||
|
"chunk_index": payload.get("chunk_index"),
|
||||||
|
"neighbors_prev": [],
|
||||||
|
"neighbors_next": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
apply = (
|
||||||
|
strategy == StrategyName.FIXED_SIZE
|
||||||
|
and (neighbor_prev > 0 or neighbor_next > 0)
|
||||||
|
and hits
|
||||||
|
)
|
||||||
|
if not apply:
|
||||||
|
retrieved = [_hit_to_chunk_dict(h, role="hit") for h in hits]
|
||||||
|
return retrieved, expansion_tree
|
||||||
|
|
||||||
|
needed_indices: set[int] = set()
|
||||||
|
hit_indices: list[int | None] = []
|
||||||
|
for hit in hits:
|
||||||
|
payload = hit.get("payload") or {}
|
||||||
|
idx = payload.get("chunk_index")
|
||||||
|
hit_indices.append(idx if idx is not None else None)
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
idx = int(idx)
|
||||||
|
for d in range(1, neighbor_prev + 1):
|
||||||
|
needed_indices.add(idx - d)
|
||||||
|
for d in range(1, neighbor_next + 1):
|
||||||
|
needed_indices.add(idx + d)
|
||||||
|
|
||||||
|
# Never fetch negative indices
|
||||||
|
needed_indices = {i for i in needed_indices if i >= 0}
|
||||||
|
|
||||||
|
# Hits may themselves be neighbors of other hits — include them in the lookup map
|
||||||
|
by_index: dict[int, dict[str, Any]] = {}
|
||||||
|
for hit in hits:
|
||||||
|
payload = hit.get("payload") or {}
|
||||||
|
idx = payload.get("chunk_index")
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
by_index[int(idx)] = {
|
||||||
|
"chunk_id": payload.get("chunk_id", hit.get("chunk_id")),
|
||||||
|
"score": hit.get("score"),
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_indices = [i for i in needed_indices if i not in by_index]
|
||||||
|
if fetch_indices:
|
||||||
|
by_index.update(
|
||||||
|
qdr.get_chunks_by_indices(
|
||||||
|
strategy,
|
||||||
|
document_name,
|
||||||
|
fetch_indices,
|
||||||
|
model_id=model_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for tree_node, hit, idx in zip(expansion_tree, hits, hit_indices):
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
idx = int(idx)
|
||||||
|
prev_list = []
|
||||||
|
for d in range(neighbor_prev, 0, -1):
|
||||||
|
entry = by_index.get(idx - d)
|
||||||
|
if entry:
|
||||||
|
prev_list.append(_neighbor_brief(entry))
|
||||||
|
next_list = []
|
||||||
|
for d in range(1, neighbor_next + 1):
|
||||||
|
entry = by_index.get(idx + d)
|
||||||
|
if entry:
|
||||||
|
next_list.append(_neighbor_brief(entry))
|
||||||
|
tree_node["neighbors_prev"] = prev_list
|
||||||
|
tree_node["neighbors_next"] = next_list
|
||||||
|
|
||||||
|
# Flat LLM context: hits + all neighbors, dedupe (prefer hit), sort by chunk_index
|
||||||
|
merged: dict[str, dict[str, Any]] = {}
|
||||||
|
for hit in hits:
|
||||||
|
chunk = _hit_to_chunk_dict(hit, role="hit")
|
||||||
|
cid = chunk["chunk_id"]
|
||||||
|
if cid:
|
||||||
|
merged[cid] = chunk
|
||||||
|
|
||||||
|
for node in expansion_tree:
|
||||||
|
for nbr in node["neighbors_prev"] + node["neighbors_next"]:
|
||||||
|
cid = nbr.get("chunk_id")
|
||||||
|
if not cid or cid in merged:
|
||||||
|
continue
|
||||||
|
merged[cid] = {
|
||||||
|
"chunk_id": cid,
|
||||||
|
"score": None,
|
||||||
|
"text": nbr.get("text", ""),
|
||||||
|
"parent_id": None,
|
||||||
|
"chunk_index": nbr.get("chunk_index"),
|
||||||
|
"role": "neighbor",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sort_key(c: dict[str, Any]) -> tuple:
|
||||||
|
idx = c.get("chunk_index")
|
||||||
|
if isinstance(idx, int):
|
||||||
|
return (0, idx)
|
||||||
|
return (1, str(c.get("chunk_id", "")))
|
||||||
|
|
||||||
|
retrieved = sorted(merged.values(), key=_sort_key)
|
||||||
|
return retrieved, expansion_tree
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks_as_context_hits(retrieved_chunks: list[dict[str, Any]]) -> list[dict]:
|
||||||
|
"""Adapt flat retrieved_chunks into the hit shape expected by _build_context."""
|
||||||
|
hits = []
|
||||||
|
for c in retrieved_chunks:
|
||||||
|
hits.append({
|
||||||
|
"chunk_id": c.get("chunk_id"),
|
||||||
|
"score": c.get("score") if c.get("score") is not None else 0,
|
||||||
|
"payload": {
|
||||||
|
"chunk_id": c.get("chunk_id"),
|
||||||
|
"text": c.get("text", ""),
|
||||||
|
"parent_id": c.get("parent_id"),
|
||||||
|
"chunk_index": c.get("chunk_index"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
# ── Main query function ───────────────────────────────────────────
|
# ── Main query function ───────────────────────────────────────────
|
||||||
|
|
||||||
def run_query(
|
def run_query(
|
||||||
@@ -209,27 +390,44 @@ def run_query(
|
|||||||
strategy_name: StrategyName,
|
strategy_name: StrategyName,
|
||||||
question: str,
|
question: str,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
|
neighbor_prev: int | None = None,
|
||||||
|
neighbor_next: int | None = None,
|
||||||
|
embedding_model: EmbeddingModelSpec | None = None,
|
||||||
|
corpus_model_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a query against a document using a specific chunking strategy.
|
"""Run a query against a document using a specific chunking strategy.
|
||||||
|
|
||||||
Pipeline: embed → search → answer → store
|
Pipeline: embed → search → Neighbor Expansion (fixed_size) → answer → store
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document_id: The document to query against.
|
document_id: The document to query against.
|
||||||
strategy_name: Which chunking strategy's collection to search.
|
strategy_name: Which chunking strategy's collection to search.
|
||||||
question: The user's question.
|
question: The user's question.
|
||||||
top_k: Number of chunks to retrieve (default 5).
|
top_k: Number of chunks to retrieve (default 5).
|
||||||
|
neighbor_prev: Prev chunks per hit for fixed_size (default from config).
|
||||||
|
neighbor_next: Next chunks per hit for fixed_size (default from config).
|
||||||
|
embedding_model: Explicit Corpus snapshot (e.g. Experiment);
|
||||||
|
if omitted, resolves corpus_model_id or Admin Corpus default.
|
||||||
|
corpus_model_id: Registry id for Corpus Embedding Model.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Query result dict with answer, chunks, and metadata.
|
Query result dict with answer, chunks, expansion_tree, and metadata.
|
||||||
"""
|
"""
|
||||||
t_start = time.time()
|
t_start = time.time()
|
||||||
|
if embedding_model is not None:
|
||||||
|
model = embedding_model
|
||||||
|
else:
|
||||||
|
model = resolve_corpus_model(corpus_model_id)
|
||||||
|
prev_n = settings.neighbor_prev if neighbor_prev is None else neighbor_prev
|
||||||
|
next_n = settings.neighbor_next if neighbor_next is None else neighbor_next
|
||||||
logger.info("=" * 80)
|
logger.info("=" * 80)
|
||||||
logger.info("[QUERY] Starting query pipeline")
|
logger.info("[QUERY] Starting query pipeline")
|
||||||
logger.info("[QUERY] Document ID: %s", document_id)
|
logger.info("[QUERY] Document ID: %s", document_id)
|
||||||
logger.info("[QUERY] Strategy: %s", strategy_name.value)
|
logger.info("[QUERY] Strategy: %s", strategy_name.value)
|
||||||
|
logger.info("[QUERY] Corpus Embedding Model: %s (%s)", model.id, model.provider.value)
|
||||||
logger.info("[QUERY] Question: %s", question)
|
logger.info("[QUERY] Question: %s", question)
|
||||||
logger.info("[QUERY] Top K: %d", top_k)
|
logger.info("[QUERY] Top K: %d", top_k)
|
||||||
|
logger.info("[QUERY] Neighbor Expansion: prev=%d next=%d", prev_n, next_n)
|
||||||
|
|
||||||
# 1. Load document info
|
# 1. Load document info
|
||||||
logger.info("[STEP 1] Loading document info from SQLite...")
|
logger.info("[STEP 1] Loading document info from SQLite...")
|
||||||
@@ -246,35 +444,54 @@ def run_query(
|
|||||||
# 2. Embed the question
|
# 2. Embed the question
|
||||||
logger.info("[STEP 2] Embedding question...")
|
logger.info("[STEP 2] Embedding question...")
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
question_embedding = embed_single(question)
|
question_embedding = embed_single(question, model=model, purpose="query")
|
||||||
t_embed = time.time() - t0
|
t_embed = time.time() - t0
|
||||||
logger.info("[STEP 2] Question embedded in %.2fs", t_embed)
|
logger.info("[STEP 2] Question embedded in %.2fs", t_embed)
|
||||||
logger.info("[STEP 2] Embedding dimension: %d", len(question_embedding))
|
logger.info("[STEP 2] Embedding dimension: %d", len(question_embedding))
|
||||||
|
|
||||||
# 3. Vector search in Qdrant
|
# 3. Vector search in Qdrant
|
||||||
logger.info("[STEP 3] Searching Qdrant collection: %s_collection", strategy_name.value)
|
col = qdr.collection_name(strategy_name, model.id)
|
||||||
|
logger.info("[STEP 3] Searching Qdrant collection: %s", col)
|
||||||
t1 = time.time()
|
t1 = time.time()
|
||||||
hits = qdr.search(
|
hits = qdr.search(
|
||||||
strategy=strategy_name,
|
strategy=strategy_name,
|
||||||
query_vector=question_embedding,
|
query_vector=question_embedding,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
document_filter=document_name,
|
document_filter=document_name,
|
||||||
|
model_id=model.id,
|
||||||
)
|
)
|
||||||
t_search = time.time() - t1
|
t_search = time.time() - t1
|
||||||
logger.info("[STEP 3] Search completed in %.2fs", t_search)
|
logger.info("[STEP 3] Search completed in %.2fs", t_search)
|
||||||
logger.info("[STEP 3] Found %d chunks", len(hits))
|
logger.info("[STEP 3] Found %d chunks", len(hits))
|
||||||
|
|
||||||
# Log each hit
|
|
||||||
for i, hit in enumerate(hits, 1):
|
for i, hit in enumerate(hits, 1):
|
||||||
payload = hit.get("payload", {})
|
payload = hit.get("payload", {})
|
||||||
logger.info("[STEP 3] Hit %d: chunk_id=%s, score=%.4f, text_len=%d",
|
logger.info("[STEP 3] Hit %d: chunk_id=%s, score=%.4f, text_len=%d",
|
||||||
i, hit.get("chunk_id", "unknown"), hit.get("score", 0),
|
i, hit.get("chunk_id", "unknown"), hit.get("score", 0),
|
||||||
len(payload.get("text", "")))
|
len(payload.get("text", "")))
|
||||||
|
|
||||||
|
# 3b. Neighbor Expansion (fixed_size) → flat list + Expansion Tree
|
||||||
|
retrieved_chunks, expansion_tree = apply_neighbor_expansion(
|
||||||
|
hits,
|
||||||
|
strategy=strategy_name,
|
||||||
|
document_name=document_name,
|
||||||
|
model_id=model.id,
|
||||||
|
neighbor_prev=prev_n,
|
||||||
|
neighbor_next=next_n,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[STEP 3b] Expansion: %d flat chunks, tree nodes=%d",
|
||||||
|
len(retrieved_chunks),
|
||||||
|
len(expansion_tree),
|
||||||
|
)
|
||||||
|
|
||||||
# 4. Build context and generate answer
|
# 4. Build context and generate answer
|
||||||
logger.info("[STEP 4] Building context...")
|
logger.info("[STEP 4] Building context...")
|
||||||
t2 = time.time()
|
t2 = time.time()
|
||||||
context = _build_context(hits, strategy_name, document_name)
|
context_hits = _chunks_as_context_hits(retrieved_chunks)
|
||||||
|
context = _build_context(
|
||||||
|
context_hits, strategy_name, document_name, model_id=model.id
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("[STEP 4] Generating answer...")
|
logger.info("[STEP 4] Generating answer...")
|
||||||
client = get_openai_client()
|
client = get_openai_client()
|
||||||
@@ -284,20 +501,16 @@ def run_query(
|
|||||||
|
|
||||||
t_total = time.time() - t_start
|
t_total = time.time() - t_start
|
||||||
|
|
||||||
# 5. Prepare retrieved chunks for storage
|
# 5. Log retrieved chunks
|
||||||
logger.info("[STEP 5] Preparing retrieved chunks for storage...")
|
logger.info("[STEP 5] Preparing retrieved chunks for storage...")
|
||||||
retrieved_chunks = []
|
for chunk_data in retrieved_chunks:
|
||||||
for hit in hits:
|
logger.info(
|
||||||
payload = hit.get("payload", {})
|
"[STEP 5] Chunk: id=%s, role=%s, score=%s, text_len=%d",
|
||||||
chunk_data = {
|
chunk_data["chunk_id"],
|
||||||
"chunk_id": payload.get("chunk_id", hit.get("chunk_id")),
|
chunk_data.get("role"),
|
||||||
"score": hit.get("score", 0),
|
chunk_data.get("score"),
|
||||||
"text": payload.get("text", ""),
|
len(chunk_data["text"]),
|
||||||
"parent_id": payload.get("parent_id"),
|
)
|
||||||
}
|
|
||||||
retrieved_chunks.append(chunk_data)
|
|
||||||
logger.info("[STEP 5] Chunk: id=%s, score=%.4f, text_len=%d",
|
|
||||||
chunk_data["chunk_id"], chunk_data["score"], len(chunk_data["text"]))
|
|
||||||
|
|
||||||
# 6. Store query result in SQLite
|
# 6. Store query result in SQLite
|
||||||
logger.info("[STEP 6] Storing query result in SQLite...")
|
logger.info("[STEP 6] Storing query result in SQLite...")
|
||||||
@@ -314,6 +527,7 @@ def run_query(
|
|||||||
question=question,
|
question=question,
|
||||||
answer=answer,
|
answer=answer,
|
||||||
retrieved_chunks=retrieved_chunks,
|
retrieved_chunks=retrieved_chunks,
|
||||||
|
expansion_tree=expansion_tree,
|
||||||
latency_breakdown=latency_breakdown,
|
latency_breakdown=latency_breakdown,
|
||||||
token_usage=token_usage,
|
token_usage=token_usage,
|
||||||
)
|
)
|
||||||
@@ -326,9 +540,15 @@ def run_query(
|
|||||||
"query_id": query_record["id"],
|
"query_id": query_record["id"],
|
||||||
"document_id": document_id,
|
"document_id": document_id,
|
||||||
"strategy": strategy_name.value,
|
"strategy": strategy_name.value,
|
||||||
|
"embedding_model_id": model.id,
|
||||||
|
"corpus_embedding_model_id": model.id,
|
||||||
|
"embedding_provider": model.provider.value,
|
||||||
"question": question,
|
"question": question,
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
"retrieved_chunks": retrieved_chunks,
|
"retrieved_chunks": retrieved_chunks,
|
||||||
|
"expansion_tree": expansion_tree,
|
||||||
|
"neighbor_prev": prev_n,
|
||||||
|
"neighbor_next": next_n,
|
||||||
"latency_breakdown": latency_breakdown,
|
"latency_breakdown": latency_breakdown,
|
||||||
"token_usage": token_usage,
|
"token_usage": token_usage,
|
||||||
"created_at": query_record["created_at"],
|
"created_at": query_record["created_at"],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Design: Dark mode, amber/teal accents, Inter + JetBrains Mono.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -34,8 +35,104 @@ FONT_DISPLAY = "'Inter', -apple-system, sans-serif"
|
|||||||
FONT_DATA = "'JetBrains Mono', 'Fira Code', monospace"
|
FONT_DATA = "'JetBrains Mono', 'Fira Code', monospace"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Compare Answers Modal (JS) ────────────────────────────────────
|
||||||
|
|
||||||
|
_MODAL_JS = """
|
||||||
|
// ── Compare Answers Modal ─────────────────────────────────
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (str == null) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreChip(label, val) {
|
||||||
|
val = Number(val) || 0;
|
||||||
|
const cls = val >= 8 ? 'high' : (val >= 6 ? 'mid' : 'low');
|
||||||
|
return '<span class="score-chip ' + cls + '">' + label + ' ' + val.toFixed(1) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCompare(idx) {
|
||||||
|
const q = perQuestion[idx];
|
||||||
|
if (!q) return;
|
||||||
|
|
||||||
|
document.getElementById('modalQId').textContent = 'Question ' + (q.question_id || (idx + 1));
|
||||||
|
document.getElementById('modalQuestion').textContent = q.question || '';
|
||||||
|
|
||||||
|
const expected = q.expected_answer || '';
|
||||||
|
document.getElementById('modalExpected').innerHTML = expected
|
||||||
|
? '<div class="expected-block"><strong>Expected Answer (Ground Truth)</strong><p>' + escapeHtml(expected) + '</p></div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const grid = document.getElementById('modalAnswers');
|
||||||
|
grid.innerHTML = strategies.map(s => {
|
||||||
|
const strat = (q.strategies || {})[s] || {};
|
||||||
|
const scores = strat.scores || {};
|
||||||
|
const hasError = !!strat.error;
|
||||||
|
const hasAnswer = !!(strat.answer && String(strat.answer).trim());
|
||||||
|
const answer = hasAnswer
|
||||||
|
? strat.answer
|
||||||
|
: (hasError ? ('Error: ' + strat.error) : 'No answer recorded.');
|
||||||
|
|
||||||
|
const chips = [];
|
||||||
|
if (scores.context_relevance != null) chips.push(scoreChip('Context', scores.context_relevance));
|
||||||
|
if (scores.answer_similarity != null) chips.push(scoreChip('Sim', scores.answer_similarity));
|
||||||
|
if (scores.faithfulness != null) chips.push(scoreChip('Faith', scores.faithfulness));
|
||||||
|
if (scores.hallucination != null) {
|
||||||
|
chips.push(scores.hallucination
|
||||||
|
? '<span class="score-chip low">Hallucinated</span>'
|
||||||
|
: '<span class="score-chip high">No Hallucination</span>');
|
||||||
|
}
|
||||||
|
if (strat.latency && strat.latency.query_seconds != null) {
|
||||||
|
chips.push('<span class="score-chip">' + Number(strat.latency.query_seconds).toFixed(2) + 's</span>');
|
||||||
|
}
|
||||||
|
const usage = strat.token_usage || {};
|
||||||
|
if (usage.total_tokens != null) {
|
||||||
|
chips.push('<span class="score-chip">' + Number(usage.total_tokens).toLocaleString() + ' tokens</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="answer-card">'
|
||||||
|
+ '<div class="strategy-name">' + escapeHtml(s)
|
||||||
|
+ '<span class="badge pill ' + (hasError ? 'low' : 'mid') + '">' + (hasError ? 'FAILED' : 'ANSWER') + '</span></div>'
|
||||||
|
+ '<div class="score-row">' + chips.join('') + '</div>'
|
||||||
|
+ '<div class="answer-text' + (hasAnswer ? '' : ' empty') + '">' + escapeHtml(answer) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('compareModal').classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCompare() {
|
||||||
|
document.getElementById('compareModal').classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('compareModal').addEventListener('click', function (e) {
|
||||||
|
if (e.target === this) closeCompare();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closeCompare();
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ── Main Entry Points ─────────────────────────────────────────────
|
# ── Main Entry Points ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _source_format_label(experiment: dict) -> str:
|
||||||
|
"""Human-readable source format badge from document filename."""
|
||||||
|
filename = (experiment.get("document_filename") or "").lower()
|
||||||
|
if filename.endswith(".pdf"):
|
||||||
|
return "Text PDF"
|
||||||
|
if filename.endswith(".docx") or filename.endswith(".doc"):
|
||||||
|
return "DOCX"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def generate_report(experiment: dict, view: str = "managerial") -> str:
|
def generate_report(experiment: dict, view: str = "managerial") -> str:
|
||||||
"""Generate report for specified view.
|
"""Generate report for specified view.
|
||||||
|
|
||||||
@@ -67,6 +164,8 @@ def generate_managerial_report(experiment: dict) -> str:
|
|||||||
total_completion_tokens += usage.get("completion_tokens", 0)
|
total_completion_tokens += usage.get("completion_tokens", 0)
|
||||||
|
|
||||||
estimated_cost = (total_prompt_tokens * 0.15 + total_completion_tokens * 0.60) / 1_000_000
|
estimated_cost = (total_prompt_tokens * 0.15 + total_completion_tokens * 0.60) / 1_000_000
|
||||||
|
source_label = _source_format_label(experiment)
|
||||||
|
source_meta = f" · Source: {source_label}" if source_label else ""
|
||||||
|
|
||||||
html = _base_html(experiment, "Managerial View", f"""
|
html = _base_html(experiment, "Managerial View", f"""
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -76,7 +175,9 @@ def generate_managerial_report(experiment: dict) -> str:
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
{config.get('num_questions', 0)} questions ·
|
{config.get('num_questions', 0)} questions ·
|
||||||
{len(strategies)} strategies ·
|
{len(strategies)} strategies ·
|
||||||
${estimated_cost:.4f} cost
|
top_k={config.get('top_k', 5)} ·
|
||||||
|
neighbors={config.get('neighbor_prev', 0)}/{config.get('neighbor_next', 0)} ·
|
||||||
|
${estimated_cost:.4f} cost{source_meta}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -148,6 +249,13 @@ def generate_managerial_report(experiment: dict) -> str:
|
|||||||
{_generate_decision_insights(rankings, aggregate)}
|
{_generate_decision_insights(rankings, aggregate)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Expansion Tree -->
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Neighbor Expansion Tree</h2>
|
||||||
|
<div class="line"></div>
|
||||||
|
</div>
|
||||||
|
{_build_expansion_tree_section(per_question, strategies, compact=True)}
|
||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Quick Links -->
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Details</h2>
|
<h2>Details</h2>
|
||||||
@@ -196,6 +304,8 @@ def generate_technical_report(experiment: dict) -> str:
|
|||||||
|
|
||||||
# Estimate latency (rough: ~0.5s per query + ~1s per evaluation)
|
# Estimate latency (rough: ~0.5s per query + ~1s per evaluation)
|
||||||
estimated_latency = total_queries * 1.5
|
estimated_latency = total_queries * 1.5
|
||||||
|
source_label = _source_format_label(experiment)
|
||||||
|
source_meta = f" · Source: {source_label}" if source_label else ""
|
||||||
|
|
||||||
html = _base_html(experiment, "Technical View", f"""
|
html = _base_html(experiment, "Technical View", f"""
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -205,7 +315,9 @@ def generate_technical_report(experiment: dict) -> str:
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
Experiment: {experiment.get('id', 'N/A')[:16]}... ·
|
Experiment: {experiment.get('id', 'N/A')[:16]}... ·
|
||||||
{total_questions} questions ·
|
{total_questions} questions ·
|
||||||
{len(strategies)} strategies
|
{len(strategies)} strategies ·
|
||||||
|
top_k={top_k} ·
|
||||||
|
neighbors={config.get('neighbor_prev', 0)}/{config.get('neighbor_next', 0)}{source_meta}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -279,6 +391,7 @@ def generate_technical_report(experiment: dict) -> str:
|
|||||||
<h2>Per-Question Results</h2>
|
<h2>Per-Question Results</h2>
|
||||||
<div class="line"></div>
|
<div class="line"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint">🔍 Click any question to compare full answers from all strategies side by side.</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -342,6 +455,13 @@ def generate_technical_report(experiment: dict) -> str:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Expansion Tree -->
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Neighbor Expansion Tree</h2>
|
||||||
|
<div class="line"></div>
|
||||||
|
</div>
|
||||||
|
{_build_expansion_tree_section(per_question, strategies, compact=False)}
|
||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Quick Links -->
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Navigation</h2>
|
<h2>Navigation</h2>
|
||||||
@@ -489,6 +609,34 @@ def _base_html(experiment: dict, title: str, content: str) -> str:
|
|||||||
.link-text {{ flex: 1; font-weight: 500; }}
|
.link-text {{ flex: 1; font-weight: 500; }}
|
||||||
.link-arrow {{ color: var(--accent); font-size: 18px; }}
|
.link-arrow {{ color: var(--accent); font-size: 18px; }}
|
||||||
|
|
||||||
|
.question-link {{ color: var(--accent); cursor: pointer; text-decoration: none; border-bottom: 1px dashed var(--accent); }}
|
||||||
|
.question-link:hover {{ color: var(--accent-dim); border-bottom-style: solid; }}
|
||||||
|
|
||||||
|
.hint {{ font-size: 12px; color: var(--text-muted); font-family: {FONT_DATA}; margin-bottom: 16px; }}
|
||||||
|
|
||||||
|
.modal-overlay {{ position: fixed; inset: 0; background: rgba(4, 8, 12, 0.72); display: none; align-items: center; justify-content: center; z-index: 1000; padding: 24px; backdrop-filter: blur(4px); }}
|
||||||
|
.modal-overlay.open {{ display: flex; }}
|
||||||
|
.modal {{ background: var(--surface); border: 1px solid var(--border); border-radius: 16px; max-width: 1100px; width: 100%; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5); }}
|
||||||
|
.modal-header {{ padding: 24px 28px; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 16px; }}
|
||||||
|
.modal-header .eyebrow {{ margin-bottom: 8px; }}
|
||||||
|
.modal-header h3 {{ font-size: 18px; font-weight: 600; line-height: 1.4; }}
|
||||||
|
.modal-close {{ margin-left: auto; background: var(--surface-hover); border: 1px solid var(--border); color: var(--text-muted); width: 36px; height: 36px; border-radius: 8px; font-size: 20px; line-height: 1; cursor: pointer; flex-shrink: 0; transition: all 0.2s ease; }}
|
||||||
|
.modal-close:hover {{ color: var(--text); border-color: var(--accent); }}
|
||||||
|
.modal-body {{ padding: 24px 28px; overflow-y: auto; }}
|
||||||
|
.expected-block {{ background: rgba(139, 92, 246, 0.08); border-left: 3px solid var(--violet); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; }}
|
||||||
|
.expected-block strong {{ font-size: 12px; color: var(--violet); text-transform: uppercase; letter-spacing: 1px; display: block; margin-bottom: 6px; }}
|
||||||
|
.expected-block p {{ font-size: 13px; color: var(--text); margin: 0; }}
|
||||||
|
.answer-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }}
|
||||||
|
.answer-card {{ background: var(--surface-hover); border: 1px solid var(--border); border-radius: 12px; padding: 20px; display: flex; flex-direction: column; gap: 12px; }}
|
||||||
|
.answer-card .strategy-name {{ font-size: 13px; font-weight: 600; color: var(--accent); display: flex; align-items: center; justify-content: space-between; gap: 8px; }}
|
||||||
|
.answer-card .answer-text {{ font-size: 13px; line-height: 1.7; color: var(--text); white-space: pre-wrap; word-break: break-word; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; max-height: 320px; overflow-y: auto; }}
|
||||||
|
.answer-card .answer-text.empty {{ color: var(--text-muted); font-style: italic; }}
|
||||||
|
.score-row {{ display: flex; flex-wrap: wrap; gap: 8px; }}
|
||||||
|
.score-chip {{ font-family: {FONT_DATA}; font-size: 11px; padding: 3px 8px; border-radius: 6px; background: var(--surface); border: 1px solid var(--border); }}
|
||||||
|
.score-chip.high {{ color: var(--emerald); border-color: rgba(16, 185, 129, 0.4); }}
|
||||||
|
.score-chip.mid {{ color: var(--accent); border-color: rgba(245, 158, 11, 0.4); }}
|
||||||
|
.score-chip.low {{ color: var(--rose); border-color: rgba(244, 63, 94, 0.4); }}
|
||||||
|
|
||||||
.footer {{ padding-top: 32px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-muted); text-align: center; }}
|
.footer {{ padding-top: 32px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-muted); text-align: center; }}
|
||||||
|
|
||||||
@media (max-width: 768px) {{
|
@media (max-width: 768px) {{
|
||||||
@@ -508,6 +656,27 @@ def _base_html(experiment: dict, title: str, content: str) -> str:
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
{content}
|
{content}
|
||||||
|
|
||||||
|
<!-- Compare Answers Modal -->
|
||||||
|
<div class="modal-overlay" id="compareModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow" id="modalQId">Question</div>
|
||||||
|
<h3 id="modalQuestion"></h3>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeCompare()" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="modalExpected"></div>
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Answers by Strategy</h2>
|
||||||
|
<div class="line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="answer-grid" id="modalAnswers"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
Generated by RAG Chunking Benchmarker · {experiment.get('created_at', 'N/A')}
|
Generated by RAG Chunking Benchmarker · {experiment.get('created_at', 'N/A')}
|
||||||
</footer>
|
</footer>
|
||||||
@@ -517,64 +686,73 @@ def _base_html(experiment: dict, title: str, content: str) -> str:
|
|||||||
const colors = {json.dumps(CHART_COLORS)};
|
const colors = {json.dumps(CHART_COLORS)};
|
||||||
const strategies = {json.dumps(strategies)};
|
const strategies = {json.dumps(strategies)};
|
||||||
const aggregate = {json.dumps(aggregate)};
|
const aggregate = {json.dumps(aggregate)};
|
||||||
|
const perQuestion = {json.dumps(experiment.get("per_question", []), ensure_ascii=False).replace("</", "<\\/")};
|
||||||
|
|
||||||
|
{_MODAL_JS}
|
||||||
|
|
||||||
// Radar
|
// Radar
|
||||||
new Chart(document.getElementById('radarChart'), {{
|
const radarEl = document.getElementById('radarChart');
|
||||||
type: 'radar',
|
if (radarEl) {{
|
||||||
data: {{
|
new Chart(radarEl, {{
|
||||||
labels: ['Context', 'Similarity', 'Faithfulness', 'Consistency'],
|
type: 'radar',
|
||||||
datasets: strategies.map((s, i) => ({{
|
data: {{
|
||||||
label: s,
|
labels: ['Context', 'Similarity', 'Faithfulness', 'Consistency'],
|
||||||
data: [
|
datasets: strategies.map((s, i) => ({{
|
||||||
aggregate[s]?.avg_context_relevance || 0,
|
label: s,
|
||||||
aggregate[s]?.avg_answer_similarity || 0,
|
data: [
|
||||||
aggregate[s]?.avg_faithfulness || 0,
|
aggregate[s]?.avg_context_relevance || 0,
|
||||||
(1 - (aggregate[s]?.hallucination_rate || 0)) * 10
|
aggregate[s]?.avg_answer_similarity || 0,
|
||||||
],
|
aggregate[s]?.avg_faithfulness || 0,
|
||||||
borderColor: colors[i % colors.length],
|
(1 - (aggregate[s]?.hallucination_rate || 0)) * 10
|
||||||
backgroundColor: colors[i % colors.length] + '20',
|
],
|
||||||
pointBackgroundColor: colors[i % colors.length],
|
borderColor: colors[i % colors.length],
|
||||||
borderWidth: 2
|
backgroundColor: colors[i % colors.length] + '20',
|
||||||
}}))
|
pointBackgroundColor: colors[i % colors.length],
|
||||||
}},
|
borderWidth: 2
|
||||||
options: {{
|
}}))
|
||||||
responsive: true,
|
}},
|
||||||
maintainAspectRatio: false,
|
options: {{
|
||||||
plugins: {{ legend: {{ display: false }} }},
|
responsive: true,
|
||||||
scales: {{
|
maintainAspectRatio: false,
|
||||||
r: {{
|
plugins: {{ legend: {{ display: false }} }},
|
||||||
beginAtZero: true,
|
scales: {{
|
||||||
max: 10,
|
r: {{
|
||||||
grid: {{ color: '{COLORS["border"]}' }},
|
beginAtZero: true,
|
||||||
angleLines: {{ color: '{COLORS["border"]}' }},
|
max: 10,
|
||||||
pointLabels: {{ color: '{COLORS["text-muted"]}', font: {{ family: "'JetBrains Mono'", size: 11 }} }},
|
grid: {{ color: '{COLORS["border"]}' }},
|
||||||
ticks: {{ display: false }}
|
angleLines: {{ color: '{COLORS["border"]}' }},
|
||||||
|
pointLabels: {{ color: '{COLORS["text-muted"]}', font: {{ family: "'JetBrains Mono'", size: 11 }} }},
|
||||||
|
ticks: {{ display: false }}
|
||||||
|
}}
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
}}
|
}});
|
||||||
}});
|
}}
|
||||||
|
|
||||||
// Bar
|
// Bar
|
||||||
new Chart(document.getElementById('barChart'), {{
|
const barEl = document.getElementById('barChart');
|
||||||
type: 'bar',
|
if (barEl) {{
|
||||||
data: {{
|
new Chart(barEl, {{
|
||||||
labels: strategies.map(s => s.length > 12 ? s.substring(0, 12) + '...' : s),
|
type: 'bar',
|
||||||
datasets: [
|
data: {{
|
||||||
{{ label: 'Context', data: strategies.map(s => aggregate[s]?.avg_context_relevance || 0), backgroundColor: colors[0] }},
|
labels: strategies.map(s => s.length > 12 ? s.substring(0, 12) + '...' : s),
|
||||||
{{ label: 'Similarity', data: strategies.map(s => aggregate[s]?.avg_answer_similarity || 0), backgroundColor: colors[1] }},
|
datasets: [
|
||||||
{{ label: 'Faithfulness', data: strategies.map(s => aggregate[s]?.avg_faithfulness || 0), backgroundColor: colors[2] }}
|
{{ label: 'Context', data: strategies.map(s => aggregate[s]?.avg_context_relevance || 0), backgroundColor: colors[0] }},
|
||||||
]
|
{{ label: 'Similarity', data: strategies.map(s => aggregate[s]?.avg_answer_similarity || 0), backgroundColor: colors[1] }},
|
||||||
}},
|
{{ label: 'Faithfulness', data: strategies.map(s => aggregate[s]?.avg_faithfulness || 0), backgroundColor: colors[2] }}
|
||||||
options: {{
|
]
|
||||||
responsive: true,
|
}},
|
||||||
maintainAspectRatio: false,
|
options: {{
|
||||||
plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '{COLORS["text-muted"]}', font: {{ family: "'Inter'" }} }} }} }},
|
responsive: true,
|
||||||
scales: {{
|
maintainAspectRatio: false,
|
||||||
x: {{ grid: {{ display: false }}, ticks: {{ color: '{COLORS["text-muted"]}' }} }},
|
plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '{COLORS["text-muted"]}', font: {{ family: "'Inter'" }} }} }} }},
|
||||||
y: {{ beginAtZero: true, max: 10, grid: {{ color: '{COLORS["border"]}' }}, ticks: {{ color: '{COLORS["text-muted"]}' }} }}
|
scales: {{
|
||||||
|
x: {{ grid: {{ display: false }}, ticks: {{ color: '{COLORS["text-muted"]}' }} }},
|
||||||
|
y: {{ beginAtZero: true, max: 10, grid: {{ color: '{COLORS["border"]}' }}, ticks: {{ color: '{COLORS["text-muted"]}' }} }}
|
||||||
|
}}
|
||||||
}}
|
}}
|
||||||
}}
|
}});
|
||||||
}});
|
}}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -739,13 +917,16 @@ def _build_strategy_headers(strategies: list) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _build_per_question_rows(per_question: list, strategies: list) -> str:
|
def _build_per_question_rows(per_question: list, strategies: list) -> str:
|
||||||
"""Build per-question rows."""
|
"""Build per-question rows with clickable questions opening the compare modal."""
|
||||||
rows = ""
|
rows = ""
|
||||||
for qr in per_question:
|
for idx, qr in enumerate(per_question):
|
||||||
q_id = qr.get("question_id", "")
|
q_id = html.escape(str(qr.get("question_id", "")))
|
||||||
q_text = qr.get("question", "")[:40]
|
full_q = str(qr.get("question", "") or "")
|
||||||
category = qr.get("category", "")
|
q_preview = full_q[:60] + ("..." if len(full_q) > 60 else "")
|
||||||
difficulty = qr.get("difficulty", "")
|
q_text = html.escape(q_preview)
|
||||||
|
category = html.escape(str(qr.get("category", "") or ""))
|
||||||
|
difficulty = str(qr.get("difficulty", "") or "")
|
||||||
|
difficulty_safe = html.escape(difficulty)
|
||||||
|
|
||||||
cells = ""
|
cells = ""
|
||||||
for strategy in strategies:
|
for strategy in strategies:
|
||||||
@@ -755,11 +936,11 @@ def _build_per_question_rows(per_question: list, strategies: list) -> str:
|
|||||||
cells += f"<td>{_pill(sim)}</td>"
|
cells += f"<td>{_pill(sim)}</td>"
|
||||||
|
|
||||||
rows += f"""
|
rows += f"""
|
||||||
<tr>
|
<tr style="cursor: pointer;" onclick="showCompare({idx})" title="Click to compare all strategy answers">
|
||||||
<td style="font-family: {FONT_DATA}; color: var(--text-muted);">{q_id}</td>
|
<td style="font-family: {FONT_DATA}; color: var(--text-muted);">{q_id}</td>
|
||||||
<td>{q_text}...</td>
|
<td><span class="question-link">{q_text}</span></td>
|
||||||
<td style="color: var(--text-muted);">{category}</td>
|
<td style="color: var(--text-muted);">{category}</td>
|
||||||
<td><span class="pill {'high' if difficulty == 'easy' else 'mid' if difficulty == 'medium' else 'low'}">{difficulty}</span></td>
|
<td><span class="pill {'high' if difficulty == 'easy' else 'mid' if difficulty == 'medium' else 'low'}">{difficulty_safe}</span></td>
|
||||||
{cells}
|
{cells}
|
||||||
</tr>"""
|
</tr>"""
|
||||||
|
|
||||||
@@ -770,21 +951,24 @@ def _build_detailed_rows(per_question: list, strategies: list) -> str:
|
|||||||
"""Build detailed score rows."""
|
"""Build detailed score rows."""
|
||||||
rows = ""
|
rows = ""
|
||||||
for qr in per_question:
|
for qr in per_question:
|
||||||
q_id = qr.get("question_id", "")
|
q_id = html.escape(str(qr.get("question_id", "")))
|
||||||
for strategy in strategies:
|
for strategy in strategies:
|
||||||
strat = qr.get("strategies", {}).get(strategy, {})
|
strat = qr.get("strategies", {}).get(strategy, {})
|
||||||
scores = strat.get("scores", {})
|
scores = strat.get("scores", {})
|
||||||
answer = strat.get("answer", "")[:60]
|
full_answer = str(strat.get("answer", "") or "")
|
||||||
|
answer_preview = full_answer[:60] + ("..." if len(full_answer) > 60 else "")
|
||||||
|
answer = html.escape(answer_preview)
|
||||||
|
strategy_safe = html.escape(str(strategy))
|
||||||
|
|
||||||
rows += f"""
|
rows += f"""
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-family: {FONT_DATA}; font-size: 12px;">{q_id}</td>
|
<td style="font-family: {FONT_DATA}; font-size: 12px;">{q_id}</td>
|
||||||
<td>{strategy}</td>
|
<td>{strategy_safe}</td>
|
||||||
<td>{_pill(scores.get('context_relevance', 0))}</td>
|
<td>{_pill(scores.get('context_relevance', 0))}</td>
|
||||||
<td>{_pill(scores.get('answer_similarity', 0))}</td>
|
<td>{_pill(scores.get('answer_similarity', 0))}</td>
|
||||||
<td>{_pill(scores.get('faithfulness', 0))}</td>
|
<td>{_pill(scores.get('faithfulness', 0))}</td>
|
||||||
<td>{'✓' if not scores.get('hallucination', False) else '✗'}</td>
|
<td>{'✓' if not scores.get('hallucination', False) else '✗'}</td>
|
||||||
<td style="font-size: 13px; color: var(--text-muted);">{answer}...</td>
|
<td style="font-size: 13px; color: var(--text-muted);">{answer}</td>
|
||||||
</tr>"""
|
</tr>"""
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
@@ -811,6 +995,112 @@ def _build_token_rows(per_question: list, strategies: list) -> str:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _question_has_expansion_tree(qr: dict, strategies: list) -> bool:
|
||||||
|
for strategy in strategies:
|
||||||
|
tree = (qr.get("strategies") or {}).get(strategy, {}).get("expansion_tree") or []
|
||||||
|
if tree:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_expansion_tree_section(
|
||||||
|
per_question: list,
|
||||||
|
strategies: list,
|
||||||
|
*,
|
||||||
|
compact: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Render Expansion Tree HTML for fixed_size (and any strategy that has a tree).
|
||||||
|
|
||||||
|
Compact (managerial) mode shows the first question that has tree data,
|
||||||
|
skipping earlier failed/empty questions.
|
||||||
|
"""
|
||||||
|
blocks: list[str] = []
|
||||||
|
first_tree_index: int | None = None
|
||||||
|
|
||||||
|
for qi, qr in enumerate(per_question):
|
||||||
|
if compact and blocks:
|
||||||
|
break
|
||||||
|
if compact and not _question_has_expansion_tree(qr, strategies):
|
||||||
|
continue
|
||||||
|
|
||||||
|
q_id = html.escape(str(qr.get("question_id", "")))
|
||||||
|
q_text = html.escape(str(qr.get("question", ""))[:120])
|
||||||
|
for strategy in strategies:
|
||||||
|
strat = qr.get("strategies", {}).get(strategy, {})
|
||||||
|
tree = strat.get("expansion_tree") or []
|
||||||
|
if not tree:
|
||||||
|
continue
|
||||||
|
if first_tree_index is None:
|
||||||
|
first_tree_index = qi
|
||||||
|
hit_blocks = []
|
||||||
|
for i, node in enumerate(tree):
|
||||||
|
prev_html = "".join(
|
||||||
|
f'<div style="padding:4px 8px;margin:2px 0;border-left:3px solid var(--text-muted);'
|
||||||
|
f'color:var(--text-muted);font-size:12px;">'
|
||||||
|
f'<span class="pill mid">↑ prev {html.escape(str(n.get("chunk_index", "?")))}</span> '
|
||||||
|
f'{html.escape(str(n.get("text", ""))[:160])}</div>'
|
||||||
|
for n in (node.get("neighbors_prev") or [])
|
||||||
|
)
|
||||||
|
next_html = "".join(
|
||||||
|
f'<div style="padding:4px 8px;margin:2px 0;border-left:3px solid var(--text-muted);'
|
||||||
|
f'color:var(--text-muted);font-size:12px;">'
|
||||||
|
f'<span class="pill mid">↓ next {html.escape(str(n.get("chunk_index", "?")))}</span> '
|
||||||
|
f'{html.escape(str(n.get("text", ""))[:160])}</div>'
|
||||||
|
for n in (node.get("neighbors_next") or [])
|
||||||
|
)
|
||||||
|
score = node.get("score")
|
||||||
|
score_s = f"{score:.3f}" if isinstance(score, (int, float)) else "—"
|
||||||
|
hit_blocks.append(
|
||||||
|
f'<div style="border:1px solid var(--border);border-radius:8px;'
|
||||||
|
f'padding:10px;margin:8px 0;background:var(--surface);">'
|
||||||
|
f'<div style="display:flex;justify-content:space-between;margin-bottom:6px;">'
|
||||||
|
f'<span class="pill high">Hit #{i + 1}</span>'
|
||||||
|
f'<span class="pill mid">{score_s}</span></div>'
|
||||||
|
f'{prev_html}'
|
||||||
|
f'<div style="padding:6px 8px;margin:2px 0;border-left:3px solid var(--accent);font-size:13px;">'
|
||||||
|
f'<span class="pill high">● hit {html.escape(str(node.get("chunk_index", "?")))}</span> '
|
||||||
|
f'{html.escape(str(node.get("text", ""))[:220])}</div>'
|
||||||
|
f'{next_html}</div>'
|
||||||
|
)
|
||||||
|
blocks.append(
|
||||||
|
f'<div style="margin-bottom:16px;">'
|
||||||
|
f'<div style="font-size:13px;color:var(--text-muted);margin-bottom:6px;">'
|
||||||
|
f'<strong>{q_id}</strong> · {html.escape(str(strategy))} · {q_text}</div>'
|
||||||
|
f'{"".join(hit_blocks)}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not compact:
|
||||||
|
continue
|
||||||
|
# compact: stop after first question that contributed blocks
|
||||||
|
if blocks:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not blocks:
|
||||||
|
return (
|
||||||
|
'<p style="color:var(--text-muted);font-size:13px;">'
|
||||||
|
"No Expansion Tree data stored for this Experiment "
|
||||||
|
"(Neighbor Expansion off, all questions failed before a tree was saved, "
|
||||||
|
"or no fixed_size results).</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
note = ""
|
||||||
|
if compact and len(per_question) > 1:
|
||||||
|
if first_tree_index and first_tree_index > 0:
|
||||||
|
note = (
|
||||||
|
'<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px;">'
|
||||||
|
f"Showing first question with Expansion Tree data "
|
||||||
|
f"(skipped {first_tree_index} earlier question(s) with errors or empty trees). "
|
||||||
|
"Open technical view for all questions.</p>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
note = (
|
||||||
|
'<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px;">'
|
||||||
|
"Showing first question with Expansion Tree data. "
|
||||||
|
"Open technical view for all questions.</p>"
|
||||||
|
)
|
||||||
|
return note + "".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
def _pill(score: float) -> str:
|
def _pill(score: float) -> str:
|
||||||
"""Create a score pill."""
|
"""Create a score pill."""
|
||||||
if score >= 8:
|
if score >= 8:
|
||||||
|
|||||||
@@ -3,17 +3,19 @@
|
|||||||
Endpoints:
|
Endpoints:
|
||||||
POST /queries Ask a question against a strategy
|
POST /queries Ask a question against a strategy
|
||||||
GET /queries/{id} Retrieve a past query
|
GET /queries/{id} Retrieve a past query
|
||||||
POST /benchmarks Run a benchmark (or dry run)
|
POST /benchmarks Run a benchmark (or dry run); ?background=true enqueues a job
|
||||||
GET /benchmarks/{id} Retrieve experiment results
|
GET /benchmarks/{id} Retrieve experiment results
|
||||||
GET /experiments List all experiments
|
GET /experiments List all experiments
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, BackgroundTasks, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
from src.core.exceptions import BenchmarkError, QueryError
|
from src.core.exceptions import BenchmarkError, QueryError
|
||||||
from src.core.models import StrategyName
|
from src.core.models import StrategyName
|
||||||
from src.benchmarking import benchmark_service, query_service
|
from src.benchmarking import benchmark_service, query_service
|
||||||
|
from src.jobs import service as jobs_service
|
||||||
|
from src.jobs.models import JobCreatedResponse
|
||||||
from src.benchmarking.models import (
|
from src.benchmarking.models import (
|
||||||
BenchmarkRequest,
|
BenchmarkRequest,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
@@ -42,6 +44,9 @@ async def create_query(request: QueryRequest):
|
|||||||
strategy_name=request.strategy,
|
strategy_name=request.strategy,
|
||||||
question=request.question,
|
question=request.question,
|
||||||
top_k=request.top_k,
|
top_k=request.top_k,
|
||||||
|
neighbor_prev=request.neighbor_prev,
|
||||||
|
neighbor_next=request.neighbor_next,
|
||||||
|
corpus_model_id=request.corpus_model_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return QueryResponse(
|
return QueryResponse(
|
||||||
@@ -53,6 +58,9 @@ async def create_query(request: QueryRequest):
|
|||||||
retrieved_chunks=[
|
retrieved_chunks=[
|
||||||
RetrievedChunk(**chunk) for chunk in result["retrieved_chunks"]
|
RetrievedChunk(**chunk) for chunk in result["retrieved_chunks"]
|
||||||
],
|
],
|
||||||
|
expansion_tree=result.get("expansion_tree") or [],
|
||||||
|
neighbor_prev=result.get("neighbor_prev", 0),
|
||||||
|
neighbor_next=result.get("neighbor_next", 0),
|
||||||
latency_breakdown=result["latency_breakdown"],
|
latency_breakdown=result["latency_breakdown"],
|
||||||
token_usage=result["token_usage"],
|
token_usage=result["token_usage"],
|
||||||
created_at=result["created_at"],
|
created_at=result["created_at"],
|
||||||
@@ -73,6 +81,7 @@ async def get_query(query_id: str):
|
|||||||
question=result["question"],
|
question=result["question"],
|
||||||
answer=result["answer"],
|
answer=result["answer"],
|
||||||
retrieved_chunks=result["retrieved_chunks"],
|
retrieved_chunks=result["retrieved_chunks"],
|
||||||
|
expansion_tree=result.get("expansion_tree") or [],
|
||||||
latency_breakdown=result["latency_breakdown"],
|
latency_breakdown=result["latency_breakdown"],
|
||||||
token_usage=result["token_usage"],
|
token_usage=result["token_usage"],
|
||||||
created_at=result["created_at"],
|
created_at=result["created_at"],
|
||||||
@@ -81,58 +90,75 @@ async def get_query(query_id: str):
|
|||||||
|
|
||||||
# ── Benchmark Endpoints ──────────────────────────────────────────
|
# ── Benchmark Endpoints ──────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/benchmarks", response_model=BenchmarkResponse, status_code=201)
|
@router.post("/benchmarks")
|
||||||
async def create_benchmark(request: BenchmarkRequest):
|
async def create_benchmark(
|
||||||
|
request: BenchmarkRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
background: bool = Query(
|
||||||
|
False, description="Run in background; returns job_id (HTTP 202)"
|
||||||
|
),
|
||||||
|
):
|
||||||
"""Run a benchmark comparing multiple strategies on multiple questions.
|
"""Run a benchmark comparing multiple strategies on multiple questions.
|
||||||
|
|
||||||
Can run in dry_run mode to get cost estimate without executing.
|
Can run in dry_run mode to get cost estimate without executing.
|
||||||
|
Pass ``background=true`` to enqueue and poll ``GET /jobs/{job_id}``.
|
||||||
"""
|
"""
|
||||||
# Load questions - prefer questions_file over inline questions
|
questions = benchmark_service.resolve_benchmark_questions(
|
||||||
if request.questions_file:
|
questions=request.questions,
|
||||||
questions = benchmark_service.load_questions(request.questions_file)
|
questions_file=request.questions_file,
|
||||||
elif request.questions and len(request.questions) > 0:
|
)
|
||||||
# Validate that questions have required fields
|
|
||||||
valid_questions = [
|
|
||||||
q for q in request.questions
|
|
||||||
if isinstance(q, dict) and "question" in q
|
|
||||||
]
|
|
||||||
if not valid_questions:
|
|
||||||
raise BenchmarkError("Invalid questions: each question must have a 'question' field")
|
|
||||||
questions = valid_questions
|
|
||||||
else:
|
|
||||||
raise BenchmarkError("Either 'questions' or 'questions_file' must be provided")
|
|
||||||
|
|
||||||
if not questions:
|
if not questions:
|
||||||
raise BenchmarkError("No questions to benchmark")
|
raise BenchmarkError("No questions to benchmark")
|
||||||
|
|
||||||
# Dry run - return cost estimate only
|
|
||||||
if request.dry_run:
|
if request.dry_run:
|
||||||
|
if background:
|
||||||
|
raise BenchmarkError("dry_run cannot be used with background=true")
|
||||||
estimate = benchmark_service.estimate_cost(
|
estimate = benchmark_service.estimate_cost(
|
||||||
num_questions=len(questions),
|
num_questions=len(questions),
|
||||||
num_strategies=len(request.strategies),
|
num_strategies=len(request.strategies),
|
||||||
)
|
)
|
||||||
# Return as BenchmarkResponse with minimal data
|
return JSONResponse(
|
||||||
return BenchmarkResponse(
|
status_code=201,
|
||||||
experiment_id="dry_run",
|
content=BenchmarkResponse(
|
||||||
document_id=request.document_id,
|
experiment_id="dry_run",
|
||||||
strategies_used=[s.value for s in request.strategies],
|
document_id=request.document_id,
|
||||||
questions_count=len(questions),
|
strategies_used=[s.value for s in request.strategies],
|
||||||
aggregate_metrics={},
|
questions_count=len(questions),
|
||||||
best_strategy="N/A (dry run)",
|
aggregate_metrics={},
|
||||||
total_latency_seconds=0,
|
best_strategy="N/A (dry run)",
|
||||||
estimated_cost_usd=estimate["estimated_cost_usd"],
|
total_latency_seconds=0,
|
||||||
created_at="N/A",
|
estimated_cost_usd=estimate["estimated_cost_usd"],
|
||||||
|
created_at="N/A",
|
||||||
|
).model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run full benchmark
|
if background:
|
||||||
|
payload = request.model_dump(mode="json")
|
||||||
|
payload["questions"] = questions
|
||||||
|
payload.pop("questions_file", None)
|
||||||
|
payload.pop("dry_run", None)
|
||||||
|
job = jobs_service.enqueue_benchmark(payload)
|
||||||
|
background_tasks.add_task(jobs_service.run_benchmark_job, job["id"])
|
||||||
|
body = JobCreatedResponse(
|
||||||
|
job_id=job["id"],
|
||||||
|
job_type=job["job_type"],
|
||||||
|
status=job["status"],
|
||||||
|
poll_url=f"/jobs/{job['id']}",
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=202, content=body.model_dump())
|
||||||
|
|
||||||
result = benchmark_service.run_benchmark(
|
result = benchmark_service.run_benchmark(
|
||||||
document_id=request.document_id,
|
document_id=request.document_id,
|
||||||
strategies=request.strategies,
|
strategies=request.strategies,
|
||||||
questions=questions,
|
questions=questions,
|
||||||
top_k=request.top_k,
|
top_k=request.top_k,
|
||||||
|
neighbor_prev=request.neighbor_prev,
|
||||||
|
neighbor_next=request.neighbor_next,
|
||||||
|
corpus_model_id=request.corpus_model_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return BenchmarkResponse(
|
response = BenchmarkResponse(
|
||||||
experiment_id=result["experiment_id"],
|
experiment_id=result["experiment_id"],
|
||||||
document_id=result["document_id"],
|
document_id=result["document_id"],
|
||||||
strategies_used=result["strategies_used"],
|
strategies_used=result["strategies_used"],
|
||||||
@@ -145,6 +171,7 @@ async def create_benchmark(request: BenchmarkRequest):
|
|||||||
estimated_cost_usd=result["estimated_cost_usd"],
|
estimated_cost_usd=result["estimated_cost_usd"],
|
||||||
created_at=result["created_at"],
|
created_at=result["created_at"],
|
||||||
)
|
)
|
||||||
|
return JSONResponse(status_code=201, content=response.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/benchmarks/{experiment_id}", response_model=ExperimentDetailResponse)
|
@router.get("/benchmarks/{experiment_id}", response_model=ExperimentDetailResponse)
|
||||||
@@ -175,14 +202,25 @@ async def get_benchmark(experiment_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/experiments")
|
@router.get("/experiments")
|
||||||
async def list_experiments(document_id: str | None = None):
|
async def list_experiments(
|
||||||
|
document_id: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(200, ge=1, le=500),
|
||||||
|
):
|
||||||
"""List all experiments, optionally filtered by document."""
|
"""List all experiments, optionally filtered by document."""
|
||||||
result = benchmark_service.list_experiments(document_id=document_id)
|
result = benchmark_service.list_experiments(
|
||||||
|
document_id=document_id, offset=offset, limit=limit
|
||||||
|
)
|
||||||
# Enrich with document filenames and best_strategy
|
# Enrich with document filenames and best_strategy
|
||||||
from src.storage import sqlite as db
|
from src.storage import sqlite as db
|
||||||
for item in result.get("items", []):
|
for item in result.get("items", []):
|
||||||
doc = db.get_document(item.get("document_id", ""))
|
doc = db.get_document(item.get("document_id", ""))
|
||||||
item["document_filename"] = doc.get("filename", "Unknown") if doc else "Deleted"
|
item["document_filename"] = doc.get("filename", "Unknown") if doc else "Deleted"
|
||||||
|
questions = item.get("questions") or []
|
||||||
|
item["questions_count"] = (
|
||||||
|
item.get("benchmark_config", {}).get("num_questions")
|
||||||
|
or (len(questions) if isinstance(questions, list) else 0)
|
||||||
|
)
|
||||||
# Calculate best_strategy from aggregate_metrics
|
# Calculate best_strategy from aggregate_metrics
|
||||||
aggs = item.get("aggregate_metrics", {})
|
aggs = item.get("aggregate_metrics", {})
|
||||||
best_strat, best_score = "N/A", -1
|
best_strat, best_score = "N/A", -1
|
||||||
@@ -195,6 +233,11 @@ async def list_experiments(document_id: str | None = None):
|
|||||||
best_score = adjusted
|
best_score = adjusted
|
||||||
best_strat = strat
|
best_strat = strat
|
||||||
item["best_strategy"] = best_strat
|
item["best_strategy"] = best_strat
|
||||||
|
# Surface embedding on list even if only in benchmark_config
|
||||||
|
if not item.get("embedding_model_id"):
|
||||||
|
cfg = item.get("benchmark_config") or {}
|
||||||
|
item["embedding_model_id"] = cfg.get("embedding_model_id")
|
||||||
|
item["embedding_provider"] = cfg.get("embedding_provider")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,36 @@ def build_chunk(
|
|||||||
|
|
||||||
# ── Sentence splitting ────────────────────────────────────────────
|
# ── Sentence splitting ────────────────────────────────────────────
|
||||||
|
|
||||||
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9])")
|
# After . ! ? or Persian/fullwidth ؟ !, split on following whitespace.
|
||||||
|
# Does NOT require a Latin capital next (that broke Farsi documents).
|
||||||
|
# Periods inside numbers (12.5) are safe because there is no whitespace after.
|
||||||
|
_SENTENCE_RE = re.compile(r"(?<=[.!?؟!])\s+")
|
||||||
|
|
||||||
|
|
||||||
def split_sentences(text: str) -> list[str]:
|
def split_sentences(text: str) -> list[str]:
|
||||||
"""Split text into sentences using a simple regex heuristic."""
|
"""Split text into sentence-like units for Semantic Boundary Detection.
|
||||||
sentences = _SENTENCE_RE.split(text.strip())
|
|
||||||
return [s.strip() for s in sentences if s.strip()]
|
Primary: punctuation-based splits (English + Farsi terminators).
|
||||||
|
Fallback: if that yields a single unit, use non-empty lines, then
|
||||||
|
blank-line paragraphs — so table/list docs still get multiple units.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sentences = [s.strip() for s in _SENTENCE_RE.split(text) if s.strip()]
|
||||||
|
if len(sentences) > 1:
|
||||||
|
return sentences
|
||||||
|
|
||||||
|
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||||
|
if len(lines) > 1:
|
||||||
|
return lines
|
||||||
|
|
||||||
|
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||||
|
if len(paragraphs) > 1:
|
||||||
|
return paragraphs
|
||||||
|
|
||||||
|
return sentences if sentences else [text]
|
||||||
|
|
||||||
|
|
||||||
# ── Abstract base ─────────────────────────────────────────────────
|
# ── Abstract base ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,68 +1,219 @@
|
|||||||
"""OpenAI embedding service using text-embedding-3-small.
|
"""Embedding service with Cloud (OpenAI) and Local (Ollama) Providers.
|
||||||
|
|
||||||
All strategies share the same embedding model (fixed, not configurable)
|
ADR-0024: two roles — Boundary (semantic cuts) and Corpus (storage + query).
|
||||||
to ensure fair comparison. Batch support up to 2048 texts per call.
|
Callers snapshot both at operation start so a mid-flight Admin switch cannot mix models.
|
||||||
|
Legacy Active Embedding Model maps to Corpus (and migrates into both defaults).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from openai import OpenAI
|
from src.chunking.embedding_models import (
|
||||||
|
EmbeddingModelSpec,
|
||||||
from src.core.config import settings
|
Provider,
|
||||||
from src.core.dependencies import get_openai_client
|
apply_task_prefixes,
|
||||||
|
default_model_id,
|
||||||
|
get_model,
|
||||||
|
)
|
||||||
|
from src.core.dependencies import get_ollama_client, get_openai_client
|
||||||
from src.core.exceptions import EmbeddingError
|
from src.core.exceptions import EmbeddingError
|
||||||
|
from src.storage import sqlite as db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# OpenAI batch limit for text-embedding-3-small
|
_OPENAI_BATCH_SIZE = 2048
|
||||||
_BATCH_SIZE = 2048
|
_OLLAMA_BATCH_SIZE = 64
|
||||||
|
|
||||||
|
_CORPUS_SETTING = "corpus_embedding_model_id"
|
||||||
|
_BOUNDARY_SETTING = "boundary_embedding_model_id"
|
||||||
|
_LEGACY_ACTIVE_SETTING = "active_embedding_model_id"
|
||||||
|
|
||||||
|
Purpose = Literal["document", "query"]
|
||||||
|
|
||||||
|
|
||||||
def embed_texts(texts: list[str]) -> list[list[float]]:
|
def _ensure_role_defaults_migrated() -> None:
|
||||||
"""Embed a list of texts and return their vectors.
|
"""One-shot: legacy Active → Corpus + Boundary when role keys unset."""
|
||||||
|
corpus = db.get_app_setting(_CORPUS_SETTING)
|
||||||
|
boundary = db.get_app_setting(_BOUNDARY_SETTING)
|
||||||
|
if corpus and boundary:
|
||||||
|
return
|
||||||
|
legacy = db.get_app_setting(_LEGACY_ACTIVE_SETTING) or default_model_id()
|
||||||
|
if not corpus:
|
||||||
|
db.set_app_setting(_CORPUS_SETTING, legacy)
|
||||||
|
if not boundary:
|
||||||
|
db.set_app_setting(_BOUNDARY_SETTING, legacy)
|
||||||
|
|
||||||
For the contextual_structure strategy, these are the enriched texts
|
|
||||||
(not raw content) — this is by design (ADR 0011).
|
|
||||||
|
|
||||||
Args:
|
def _resolve_role(setting_key: str) -> EmbeddingModelSpec:
|
||||||
texts: List of strings to embed.
|
_ensure_role_defaults_migrated()
|
||||||
|
stored = db.get_app_setting(setting_key)
|
||||||
|
model_id = stored or default_model_id()
|
||||||
|
try:
|
||||||
|
return get_model(model_id)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning(
|
||||||
|
"Stored Embedding Model '%s' (%s) not in registry; falling back to %s",
|
||||||
|
model_id,
|
||||||
|
setting_key,
|
||||||
|
default_model_id(),
|
||||||
|
)
|
||||||
|
return get_model(default_model_id())
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of embedding vectors (same order as input).
|
|
||||||
|
|
||||||
Raises:
|
def get_corpus_embedding_model() -> EmbeddingModelSpec:
|
||||||
EmbeddingError: If the OpenAI API call fails.
|
"""Default Corpus Embedding Model (storage + query)."""
|
||||||
|
return _resolve_role(_CORPUS_SETTING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_boundary_embedding_model() -> EmbeddingModelSpec:
|
||||||
|
"""Default Boundary Embedding Model (semantic cuts)."""
|
||||||
|
return _resolve_role(_BOUNDARY_SETTING)
|
||||||
|
|
||||||
|
|
||||||
|
def set_corpus_embedding_model(model_id: str) -> EmbeddingModelSpec:
|
||||||
|
"""Persist Default Corpus. Raises KeyError if unknown."""
|
||||||
|
_ensure_role_defaults_migrated()
|
||||||
|
model = get_model(model_id)
|
||||||
|
db.set_app_setting(_CORPUS_SETTING, model.id)
|
||||||
|
# Keep legacy key in sync for older readers
|
||||||
|
db.set_app_setting(_LEGACY_ACTIVE_SETTING, model.id)
|
||||||
|
logger.info("Corpus Embedding Model set to %s (%s)", model.id, model.provider.value)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def set_boundary_embedding_model(model_id: str) -> EmbeddingModelSpec:
|
||||||
|
"""Persist Default Boundary. Raises KeyError if unknown."""
|
||||||
|
_ensure_role_defaults_migrated()
|
||||||
|
model = get_model(model_id)
|
||||||
|
db.set_app_setting(_BOUNDARY_SETTING, model.id)
|
||||||
|
logger.info("Boundary Embedding Model set to %s (%s)", model.id, model.provider.value)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_corpus_model(model_id: str | None = None) -> EmbeddingModelSpec:
|
||||||
|
"""Snapshot Corpus for an operation (explicit id or Admin default)."""
|
||||||
|
if model_id:
|
||||||
|
return get_model(model_id)
|
||||||
|
return get_corpus_embedding_model()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_boundary_model(model_id: str | None = None) -> EmbeddingModelSpec:
|
||||||
|
"""Snapshot Boundary for an operation (explicit id or Admin default)."""
|
||||||
|
if model_id:
|
||||||
|
return get_model(model_id)
|
||||||
|
return get_boundary_embedding_model()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy aliases (Corpus) ───────────────────────────────────────
|
||||||
|
|
||||||
|
def get_active_embedding_model() -> EmbeddingModelSpec:
|
||||||
|
"""Deprecated: alias for get_corpus_embedding_model (ADR-0024)."""
|
||||||
|
return get_corpus_embedding_model()
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_embedding_model(model_id: str) -> EmbeddingModelSpec:
|
||||||
|
"""Deprecated: sets Corpus default (and legacy active key)."""
|
||||||
|
return set_corpus_embedding_model(model_id)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_active_model() -> EmbeddingModelSpec:
|
||||||
|
"""Deprecated: snapshot Corpus Embedding Model."""
|
||||||
|
return get_corpus_embedding_model()
|
||||||
|
|
||||||
|
|
||||||
|
def embed_texts(
|
||||||
|
texts: list[str],
|
||||||
|
*,
|
||||||
|
model: EmbeddingModelSpec | None = None,
|
||||||
|
purpose: Purpose = "document",
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Embed texts with the given (or Corpus) Embedding Model.
|
||||||
|
|
||||||
|
For contextual_retrieval, pass enriched texts — ADR 0011.
|
||||||
"""
|
"""
|
||||||
if not texts:
|
if not texts:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
model = model or get_corpus_embedding_model()
|
||||||
|
prepared = apply_task_prefixes(texts, model=model, purpose=purpose)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if model.provider == Provider.CLOUD:
|
||||||
|
return _embed_openai(prepared, model)
|
||||||
|
if model.provider == Provider.LOCAL:
|
||||||
|
return _embed_ollama(prepared, model)
|
||||||
|
raise EmbeddingError(f"Unsupported Provider: {model.provider}")
|
||||||
|
except EmbeddingError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise EmbeddingError(f"Embedding failed ({model.id}): {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def embed_single(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
model: EmbeddingModelSpec | None = None,
|
||||||
|
purpose: Purpose = "query",
|
||||||
|
) -> list[float]:
|
||||||
|
"""Embed a single text (default purpose=query for retrieval)."""
|
||||||
|
results = embed_texts([text], model=model, purpose=purpose)
|
||||||
|
return results[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_openai(texts: list[str], model: EmbeddingModelSpec) -> list[list[float]]:
|
||||||
client = get_openai_client()
|
client = get_openai_client()
|
||||||
all_embeddings: list[list[float]] = []
|
all_embeddings: list[list[float]] = []
|
||||||
|
|
||||||
try:
|
for start in range(0, len(texts), _OPENAI_BATCH_SIZE):
|
||||||
for start in range(0, len(texts), _BATCH_SIZE):
|
batch = texts[start : start + _OPENAI_BATCH_SIZE]
|
||||||
batch = texts[start:start + _BATCH_SIZE]
|
response = client.embeddings.create(
|
||||||
response = client.embeddings.create(
|
model=model.model_name,
|
||||||
model=settings.embedding_model,
|
input=batch,
|
||||||
input=batch,
|
)
|
||||||
|
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||||
|
vectors = [item.embedding for item in sorted_data]
|
||||||
|
_validate_dimensions(vectors, model)
|
||||||
|
all_embeddings.extend(vectors)
|
||||||
|
logger.debug(
|
||||||
|
"OpenAI embedded batch %d-%d with %s",
|
||||||
|
start,
|
||||||
|
start + len(batch),
|
||||||
|
model.model_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_embeddings
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_ollama(texts: list[str], model: EmbeddingModelSpec) -> list[list[float]]:
|
||||||
|
client = get_ollama_client()
|
||||||
|
all_embeddings: list[list[float]] = []
|
||||||
|
|
||||||
|
for start in range(0, len(texts), _OLLAMA_BATCH_SIZE):
|
||||||
|
batch = texts[start : start + _OLLAMA_BATCH_SIZE]
|
||||||
|
response = client.embeddings.create(
|
||||||
|
model=model.model_name,
|
||||||
|
input=batch,
|
||||||
|
)
|
||||||
|
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||||
|
vectors = [item.embedding for item in sorted_data]
|
||||||
|
_validate_dimensions(vectors, model)
|
||||||
|
all_embeddings.extend(vectors)
|
||||||
|
logger.debug(
|
||||||
|
"Ollama embedded batch %d-%d with %s",
|
||||||
|
start,
|
||||||
|
start + len(batch),
|
||||||
|
model.model_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_embeddings
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_dimensions(vectors: list[list[float]], model: EmbeddingModelSpec) -> None:
|
||||||
|
for i, vec in enumerate(vectors):
|
||||||
|
if len(vec) != model.dimension:
|
||||||
|
raise EmbeddingError(
|
||||||
|
f"Embedding dimension mismatch for {model.id}: "
|
||||||
|
f"expected {model.dimension}, got {len(vec)} (index {i})"
|
||||||
)
|
)
|
||||||
# Sort by index to guarantee order matches input
|
|
||||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
|
||||||
all_embeddings.extend([item.embedding for item in sorted_data])
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Embedded batch %d-%d (%d texts)",
|
|
||||||
start, start + len(batch), len(batch),
|
|
||||||
)
|
|
||||||
|
|
||||||
return all_embeddings
|
|
||||||
except Exception as exc:
|
|
||||||
raise EmbeddingError(f"Embedding failed: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def embed_single(text: str) -> list[float]:
|
|
||||||
"""Embed a single text (convenience wrapper)."""
|
|
||||||
results = embed_texts([text])
|
|
||||||
return results[0]
|
|
||||||
|
|||||||
153
src/chunking/embedding_models.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Embedding Model Registry — static catalog of Cloud and Local models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(str, Enum):
|
||||||
|
CLOUD = "cloud"
|
||||||
|
LOCAL = "local"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmbeddingModelSpec:
|
||||||
|
"""One entry in the Embedding Model Registry."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
provider: Provider
|
||||||
|
model_name: str
|
||||||
|
dimension: int
|
||||||
|
display_name: str
|
||||||
|
task_prefixes: bool = False
|
||||||
|
# Default Semantic Boundary Detection threshold (Admin may override in SQLite)
|
||||||
|
default_semantic_threshold: float = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
# Stable id for legacy unscoped OpenAI corpora / historical Experiment backfill (1536-d).
|
||||||
|
LEGACY_CLOUD_MODEL_ID = "text-embedding-3-small"
|
||||||
|
# Default Corpus/Boundary when Admin settings are unset (new installs & fallbacks).
|
||||||
|
DEFAULT_CLOUD_MODEL_ID = "text-embedding-3-large"
|
||||||
|
|
||||||
|
_SEMANTIC_THRESHOLD_SETTING_PREFIX = "semantic_threshold:"
|
||||||
|
|
||||||
|
|
||||||
|
def semantic_threshold_setting_key(model_id: str) -> str:
|
||||||
|
return f"{_SEMANTIC_THRESHOLD_SETTING_PREFIX}{model_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_registry() -> dict[str, EmbeddingModelSpec]:
|
||||||
|
# Prefer explicit registry entries over EMBEDDING_MODEL aliasing a different
|
||||||
|
# OpenAI model under a single id (wrong dimension / provenance).
|
||||||
|
cloud_small = EmbeddingModelSpec(
|
||||||
|
id=LEGACY_CLOUD_MODEL_ID,
|
||||||
|
provider=Provider.CLOUD,
|
||||||
|
model_name="text-embedding-3-small",
|
||||||
|
dimension=1536,
|
||||||
|
display_name="OpenAI text-embedding-3-small",
|
||||||
|
task_prefixes=False,
|
||||||
|
default_semantic_threshold=0.3,
|
||||||
|
)
|
||||||
|
cloud_large = EmbeddingModelSpec(
|
||||||
|
id=DEFAULT_CLOUD_MODEL_ID,
|
||||||
|
provider=Provider.CLOUD,
|
||||||
|
model_name="text-embedding-3-large",
|
||||||
|
dimension=3072,
|
||||||
|
display_name="OpenAI text-embedding-3-large",
|
||||||
|
task_prefixes=False,
|
||||||
|
default_semantic_threshold=0.3,
|
||||||
|
)
|
||||||
|
local = EmbeddingModelSpec(
|
||||||
|
id="nomic-embed-text-v2-moe",
|
||||||
|
provider=Provider.LOCAL,
|
||||||
|
model_name="nomic-embed-text-v2-moe:latest",
|
||||||
|
dimension=768,
|
||||||
|
display_name="Ollama nomic-embed-text-v2-moe",
|
||||||
|
task_prefixes=True,
|
||||||
|
default_semantic_threshold=0.6,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
cloud_small.id: cloud_small,
|
||||||
|
cloud_large.id: cloud_large,
|
||||||
|
local.id: local,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> dict[str, EmbeddingModelSpec]:
|
||||||
|
"""Return the Embedding Model Registry (built from config)."""
|
||||||
|
return _build_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_model(model_id: str) -> EmbeddingModelSpec:
|
||||||
|
"""Look up a registry entry by stable id.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If model_id is not registered.
|
||||||
|
"""
|
||||||
|
registry = get_registry()
|
||||||
|
if model_id not in registry:
|
||||||
|
known = ", ".join(sorted(registry))
|
||||||
|
raise KeyError(f"Unknown Embedding Model '{model_id}'. Registered: {known}")
|
||||||
|
return registry[model_id]
|
||||||
|
|
||||||
|
|
||||||
|
def list_models() -> list[EmbeddingModelSpec]:
|
||||||
|
"""All registered Embedding Models in stable order (default cloud first)."""
|
||||||
|
registry = get_registry()
|
||||||
|
preferred = [
|
||||||
|
DEFAULT_CLOUD_MODEL_ID,
|
||||||
|
LEGACY_CLOUD_MODEL_ID,
|
||||||
|
"nomic-embed-text-v2-moe",
|
||||||
|
]
|
||||||
|
order = preferred + [mid for mid in registry if mid not in preferred]
|
||||||
|
return [registry[mid] for mid in order if mid in registry]
|
||||||
|
|
||||||
|
|
||||||
|
def default_model_id() -> str:
|
||||||
|
"""Default Corpus/Boundary Embedding Model id when Admin settings are unset."""
|
||||||
|
return DEFAULT_CLOUD_MODEL_ID
|
||||||
|
|
||||||
|
|
||||||
|
def get_semantic_threshold(model_id: str) -> float:
|
||||||
|
"""Resolve Semantic Boundary Detection threshold for a model.
|
||||||
|
|
||||||
|
Order: Admin SQLite override → registry default → global SEMANTIC_THRESHOLD.
|
||||||
|
"""
|
||||||
|
from src.storage import sqlite as db
|
||||||
|
|
||||||
|
model = get_model(model_id)
|
||||||
|
stored = db.get_app_setting(semantic_threshold_setting_key(model_id))
|
||||||
|
if stored is not None and stored != "":
|
||||||
|
return float(stored)
|
||||||
|
return model.default_semantic_threshold
|
||||||
|
|
||||||
|
|
||||||
|
def set_semantic_threshold(model_id: str, threshold: float) -> float:
|
||||||
|
"""Persist Admin override for a model's semantic_threshold (0 < t <= 1)."""
|
||||||
|
from src.storage import sqlite as db
|
||||||
|
|
||||||
|
get_model(model_id) # validate registry id
|
||||||
|
if not (0.0 < threshold <= 1.0):
|
||||||
|
raise ValueError("semantic_threshold must be in (0, 1]")
|
||||||
|
db.set_app_setting(semantic_threshold_setting_key(model_id), str(threshold))
|
||||||
|
return threshold
|
||||||
|
|
||||||
|
|
||||||
|
def apply_task_prefixes(
|
||||||
|
texts: list[str],
|
||||||
|
*,
|
||||||
|
model: EmbeddingModelSpec,
|
||||||
|
purpose: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Apply Nomic-style task prefixes when the model requires them.
|
||||||
|
|
||||||
|
purpose: \"document\" → search_document; \"query\" → search_query.
|
||||||
|
"""
|
||||||
|
if not model.task_prefixes:
|
||||||
|
return texts
|
||||||
|
if purpose == "query":
|
||||||
|
prefix = "search_query: "
|
||||||
|
else:
|
||||||
|
prefix = "search_document: "
|
||||||
|
return [prefix + t if not t.startswith(prefix) else t for t in texts]
|
||||||
@@ -4,7 +4,8 @@ Runs selected strategies on a document, embeds chunks, and stores
|
|||||||
them in Qdrant. Per-strategy failure isolation (ADR 0003): if one
|
them in Qdrant. Per-strategy failure isolation (ADR 0003): if one
|
||||||
strategy fails, the others' results are still committed.
|
strategy fails, the others' results are still committed.
|
||||||
|
|
||||||
This replaces the stub in src/documents/service.py.
|
ADR-0024: Boundary Embedding Model for semantic cuts; Corpus Embedding
|
||||||
|
Model for finished-chunk vectors and collection scoping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,16 +13,19 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from src.chunking.base import ChunkingStrategy
|
from src.chunking.base import ChunkingStrategy, split_sentences
|
||||||
from src.chunking.embedding import embed_texts
|
from src.chunking.embedding import embed_texts
|
||||||
|
from src.chunking.embedding_models import EmbeddingModelSpec, get_semantic_threshold
|
||||||
from src.chunking.strategies.recursive import RecursiveStrategy
|
from src.chunking.strategies.recursive import RecursiveStrategy
|
||||||
from src.chunking.strategies.fixed_size import FixedSizeStrategy
|
from src.chunking.strategies.fixed_size import FixedSizeStrategy
|
||||||
from src.chunking.strategies.semantic import SemanticStrategy
|
from src.chunking.strategies.semantic import SemanticStrategy
|
||||||
from src.chunking.strategies.contextual_retrieval import ContextualRetrievalStrategy
|
from src.chunking.strategies.contextual_retrieval import ContextualRetrievalStrategy
|
||||||
from src.chunking.strategies.semantic_parent_child import SemanticParentChildStrategy
|
from src.chunking.strategies.semantic_parent_child import (
|
||||||
|
SemanticParentChildStrategy,
|
||||||
|
split_paragraphs,
|
||||||
|
)
|
||||||
from src.core.exceptions import ChunkingError
|
from src.core.exceptions import ChunkingError
|
||||||
from src.core.models import (
|
from src.core.models import (
|
||||||
Chunk,
|
|
||||||
DocumentTree,
|
DocumentTree,
|
||||||
StrategyName,
|
StrategyName,
|
||||||
)
|
)
|
||||||
@@ -30,6 +34,11 @@ from src.storage import sqlite as db
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BOUNDARY_STRATEGIES = {
|
||||||
|
StrategyName.SEMANTIC,
|
||||||
|
StrategyName.SEMANTIC_PARENT_CHILD,
|
||||||
|
}
|
||||||
|
|
||||||
# ── Strategy registry ─────────────────────────────────────────────
|
# ── Strategy registry ─────────────────────────────────────────────
|
||||||
|
|
||||||
_STRATEGIES: dict[StrategyName, ChunkingStrategy] = {
|
_STRATEGIES: dict[StrategyName, ChunkingStrategy] = {
|
||||||
@@ -48,6 +57,73 @@ def _get_strategy(name: StrategyName) -> ChunkingStrategy:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_document(
|
||||||
|
strategy_name: StrategyName,
|
||||||
|
strategy: ChunkingStrategy,
|
||||||
|
*,
|
||||||
|
doc_name: str,
|
||||||
|
tree: DocumentTree,
|
||||||
|
markdown: str,
|
||||||
|
boundary_model: EmbeddingModelSpec,
|
||||||
|
) -> list:
|
||||||
|
"""Run strategy.chunk(), supplying Boundary embeds when needed."""
|
||||||
|
if strategy_name == StrategyName.SEMANTIC:
|
||||||
|
sentences = split_sentences(markdown)
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
threshold = get_semantic_threshold(boundary_model.id)
|
||||||
|
logger.info(
|
||||||
|
"Strategy semantic: embedding %d sentences for boundary detection "
|
||||||
|
"(%s, threshold=%.3f)",
|
||||||
|
len(sentences),
|
||||||
|
boundary_model.id,
|
||||||
|
threshold,
|
||||||
|
)
|
||||||
|
sentence_embeddings = embed_texts(
|
||||||
|
sentences,
|
||||||
|
model=boundary_model,
|
||||||
|
purpose="document",
|
||||||
|
)
|
||||||
|
return strategy.chunk( # type: ignore[call-arg]
|
||||||
|
doc_name=doc_name,
|
||||||
|
tree=tree,
|
||||||
|
markdown=markdown,
|
||||||
|
sentence_embeddings=sentence_embeddings,
|
||||||
|
semantic_threshold=threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
if strategy_name == StrategyName.SEMANTIC_PARENT_CHILD:
|
||||||
|
paragraphs = split_paragraphs(markdown)
|
||||||
|
if not paragraphs:
|
||||||
|
return []
|
||||||
|
threshold = get_semantic_threshold(boundary_model.id)
|
||||||
|
logger.info(
|
||||||
|
"Strategy semantic_parent_child: embedding %d paragraphs for boundary detection "
|
||||||
|
"(%s, threshold=%.3f)",
|
||||||
|
len(paragraphs),
|
||||||
|
boundary_model.id,
|
||||||
|
threshold,
|
||||||
|
)
|
||||||
|
paragraph_embeddings = embed_texts(
|
||||||
|
paragraphs,
|
||||||
|
model=boundary_model,
|
||||||
|
purpose="document",
|
||||||
|
)
|
||||||
|
return strategy.chunk( # type: ignore[call-arg]
|
||||||
|
doc_name=doc_name,
|
||||||
|
tree=tree,
|
||||||
|
markdown=markdown,
|
||||||
|
paragraph_embeddings=paragraph_embeddings,
|
||||||
|
semantic_threshold=threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
return strategy.chunk(
|
||||||
|
doc_name=doc_name,
|
||||||
|
tree=tree,
|
||||||
|
markdown=markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Single-strategy runner ────────────────────────────────────────
|
# ── Single-strategy runner ────────────────────────────────────────
|
||||||
|
|
||||||
def _run_strategy(
|
def _run_strategy(
|
||||||
@@ -56,6 +132,9 @@ def _run_strategy(
|
|||||||
doc_name: str,
|
doc_name: str,
|
||||||
tree: DocumentTree,
|
tree: DocumentTree,
|
||||||
markdown: str,
|
markdown: str,
|
||||||
|
*,
|
||||||
|
boundary_model: EmbeddingModelSpec,
|
||||||
|
corpus_model: EmbeddingModelSpec,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run one strategy: chunk → embed → store in Qdrant.
|
"""Run one strategy: chunk → embed → store in Qdrant.
|
||||||
|
|
||||||
@@ -64,16 +143,21 @@ def _run_strategy(
|
|||||||
"""
|
"""
|
||||||
strategy = _get_strategy(strategy_name)
|
strategy = _get_strategy(strategy_name)
|
||||||
|
|
||||||
# Ensure Qdrant collection exists
|
qdr.ensure_collection(
|
||||||
qdr.ensure_collection(strategy_name)
|
strategy_name,
|
||||||
|
model_id=corpus_model.id,
|
||||||
|
dimension=corpus_model.dimension,
|
||||||
|
)
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
|
||||||
# Step 1: Chunk
|
chunks = _chunk_document(
|
||||||
chunks = strategy.chunk(
|
strategy_name,
|
||||||
|
strategy,
|
||||||
doc_name=doc_name,
|
doc_name=doc_name,
|
||||||
tree=tree,
|
tree=tree,
|
||||||
markdown=markdown,
|
markdown=markdown,
|
||||||
|
boundary_model=boundary_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not chunks:
|
if not chunks:
|
||||||
@@ -86,8 +170,6 @@ def _run_strategy(
|
|||||||
strategy_name.value, len(chunks), t_chunk,
|
strategy_name.value, len(chunks), t_chunk,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Embed
|
|
||||||
# For contextual strategy, embed enriched_content; for others, embed text
|
|
||||||
texts_to_embed = []
|
texts_to_embed = []
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
if chunk.enriched_content:
|
if chunk.enriched_content:
|
||||||
@@ -96,16 +178,19 @@ def _run_strategy(
|
|||||||
texts_to_embed.append(chunk.text)
|
texts_to_embed.append(chunk.text)
|
||||||
|
|
||||||
t1 = time.time()
|
t1 = time.time()
|
||||||
embeddings = embed_texts(texts_to_embed)
|
embeddings = embed_texts(
|
||||||
|
texts_to_embed,
|
||||||
|
model=corpus_model,
|
||||||
|
purpose="document",
|
||||||
|
)
|
||||||
t_embed = time.time() - t1
|
t_embed = time.time() - t1
|
||||||
logger.info(
|
logger.info(
|
||||||
"Strategy %s: embedded %d texts in %.2fs",
|
"Strategy %s: embedded %d texts in %.2fs (corpus=%s)",
|
||||||
strategy_name.value, len(embeddings), t_embed,
|
strategy_name.value, len(embeddings), t_embed, corpus_model.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Upsert to Qdrant
|
|
||||||
t2 = time.time()
|
t2 = time.time()
|
||||||
stored = qdr.upsert_chunks(chunks, embeddings)
|
stored = qdr.upsert_chunks(chunks, embeddings, model_id=corpus_model.id)
|
||||||
t_store = time.time() - t2
|
t_store = time.time() - t2
|
||||||
logger.info(
|
logger.info(
|
||||||
"Strategy %s: stored %d vectors in %.2fs",
|
"Strategy %s: stored %d vectors in %.2fs",
|
||||||
@@ -120,17 +205,39 @@ def _run_strategy(
|
|||||||
def run_strategies(
|
def run_strategies(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
strategies: list[StrategyName],
|
strategies: list[StrategyName],
|
||||||
|
*,
|
||||||
|
boundary_model_id: str | None = None,
|
||||||
|
corpus_model_id: str | None = None,
|
||||||
) -> tuple[list[dict], list[dict]]:
|
) -> tuple[list[dict], list[dict]]:
|
||||||
"""Run multiple strategies on a document with per-strategy failure isolation.
|
"""Run multiple strategies on a document with per-strategy failure isolation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(completed, failed) — lists of result dicts.
|
(completed, failed) — lists of result dicts.
|
||||||
"""
|
"""
|
||||||
|
from src.chunking.embedding import resolve_boundary_model, resolve_corpus_model
|
||||||
|
|
||||||
doc = db.get_document(doc_id)
|
doc = db.get_document(doc_id)
|
||||||
if doc is None:
|
if doc is None:
|
||||||
raise ChunkingError(f"Document not found: {doc_id}")
|
raise ChunkingError(f"Document not found: {doc_id}")
|
||||||
|
|
||||||
# Parse the stored document tree (may be dict or JSON string)
|
corpus_model = resolve_corpus_model(corpus_model_id)
|
||||||
|
needs_boundary = any(s in _BOUNDARY_STRATEGIES for s in strategies)
|
||||||
|
boundary_model = (
|
||||||
|
resolve_boundary_model(boundary_model_id)
|
||||||
|
if needs_boundary
|
||||||
|
else corpus_model # unused for non-semantic; keep a valid spec
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Processing doc %s corpus=%s (%s) boundary=%s (%s) needs_boundary=%s",
|
||||||
|
doc_id,
|
||||||
|
corpus_model.id,
|
||||||
|
corpus_model.provider.value,
|
||||||
|
boundary_model.id if needs_boundary else "—",
|
||||||
|
boundary_model.provider.value if needs_boundary else "—",
|
||||||
|
needs_boundary,
|
||||||
|
)
|
||||||
|
|
||||||
tree_raw = doc["document_tree"]
|
tree_raw = doc["document_tree"]
|
||||||
if isinstance(tree_raw, str):
|
if isinstance(tree_raw, str):
|
||||||
tree = DocumentTree.model_validate_json(tree_raw)
|
tree = DocumentTree.model_validate_json(tree_raw)
|
||||||
@@ -151,14 +258,21 @@ def run_strategies(
|
|||||||
doc_name=doc_name,
|
doc_name=doc_name,
|
||||||
tree=tree,
|
tree=tree,
|
||||||
markdown=markdown,
|
markdown=markdown,
|
||||||
|
boundary_model=boundary_model,
|
||||||
|
corpus_model=corpus_model,
|
||||||
)
|
)
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
completed.append({
|
entry = {
|
||||||
"strategy": strategy_name,
|
"strategy": strategy_name,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"chunks_produced": chunks_produced,
|
"chunks_produced": chunks_produced,
|
||||||
"elapsed_seconds": round(elapsed, 2),
|
"elapsed_seconds": round(elapsed, 2),
|
||||||
})
|
"corpus_embedding_model_id": corpus_model.id,
|
||||||
|
"embedding_model_id": corpus_model.id, # legacy alias
|
||||||
|
}
|
||||||
|
if strategy_name in _BOUNDARY_STRATEGIES:
|
||||||
|
entry["boundary_embedding_model_id"] = boundary_model.id
|
||||||
|
completed.append(entry)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Strategy %s failed for doc %s: %s",
|
"Strategy %s failed for doc %s: %s",
|
||||||
@@ -170,10 +284,16 @@ def run_strategies(
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Update chunk counts on the document
|
|
||||||
counts = doc.get("chunk_counts", {})
|
counts = doc.get("chunk_counts", {})
|
||||||
for result in completed:
|
for result in completed:
|
||||||
counts[result["strategy"].value] = result["chunks_produced"]
|
counts[result["strategy"].value] = result["chunks_produced"]
|
||||||
db.update_chunk_counts(doc_id, counts)
|
db.update_chunk_counts(doc_id, counts)
|
||||||
|
|
||||||
|
if completed:
|
||||||
|
db.update_process_embedding_provenance(
|
||||||
|
doc_id,
|
||||||
|
corpus_model_id=corpus_model.id,
|
||||||
|
boundary_model_id=boundary_model.id if needs_boundary else None,
|
||||||
|
)
|
||||||
|
|
||||||
return completed, failed
|
return completed, failed
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
"""Semantic chunking strategy.
|
"""Semantic chunking strategy.
|
||||||
|
|
||||||
Sentence-level granularity (ADR 0012):
|
Sentence-level granularity (ADR 0012 / ADR 0020):
|
||||||
1. Split markdown into sentences.
|
1. Split markdown into sentences.
|
||||||
2. Embed each sentence via OpenAI.
|
2. Orchestrator embeds each sentence (Active Embedding Model).
|
||||||
3. Compute cosine similarity between adjacent sentences.
|
3. Compute cosine similarity between adjacent sentences.
|
||||||
4. When similarity drops below SEMANTIC_THRESHOLD, create a chunk boundary.
|
4. When similarity drops below the Active Embedding Model's semantic_threshold, create a chunk boundary.
|
||||||
5. Enforce SEMANTIC_MIN_CHUNK_SIZE (minimum sentences per chunk).
|
5. Enforce SEMANTIC_MIN_CHUNK_SIZE (minimum sentences per chunk).
|
||||||
6. Boundary sentence stays with the previous chunk.
|
6. Boundary sentence stays with the previous chunk.
|
||||||
|
7. Orchestrator embeds finished chunks for Qdrant storage.
|
||||||
|
|
||||||
Note: this strategy requires embeddings at chunk-time. The chunk()
|
Semantic Boundary Detection is required — no fixed-count fallback.
|
||||||
method returns text chunks WITHOUT embeddings — the embedding step
|
|
||||||
happens in the orchestration layer (service.py) which calls the
|
|
||||||
embedding service after chunking.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,6 +18,7 @@ import numpy as np
|
|||||||
|
|
||||||
from src.chunking.base import ChunkingStrategy, build_chunk, split_sentences
|
from src.chunking.base import ChunkingStrategy, build_chunk, split_sentences
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
|
from src.core.exceptions import ChunkingError
|
||||||
from src.core.models import Chunk, DocumentTree, StrategyName
|
from src.core.models import Chunk, DocumentTree, StrategyName
|
||||||
|
|
||||||
|
|
||||||
@@ -85,30 +84,33 @@ class SemanticStrategy(ChunkingStrategy):
|
|||||||
tree: DocumentTree,
|
tree: DocumentTree,
|
||||||
markdown: str,
|
markdown: str,
|
||||||
sentence_embeddings: list[list[float]] | None = None,
|
sentence_embeddings: list[list[float]] | None = None,
|
||||||
|
semantic_threshold: float | None = None,
|
||||||
) -> list[Chunk]:
|
) -> list[Chunk]:
|
||||||
"""Produce semantic chunks.
|
"""Produce semantic chunks via Semantic Boundary Detection.
|
||||||
|
|
||||||
If sentence_embeddings is provided (from the orchestration layer),
|
Requires sentence_embeddings aligned 1:1 with split_sentences(markdown).
|
||||||
uses them for boundary detection. Otherwise, falls back to
|
|
||||||
paragraph-level chunking (sentences without similarity-based splits).
|
|
||||||
"""
|
"""
|
||||||
sentences = split_sentences(markdown)
|
sentences = split_sentences(markdown)
|
||||||
if not sentences:
|
if not sentences:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
threshold = settings.semantic_threshold
|
if sentence_embeddings is None or len(sentence_embeddings) != len(sentences):
|
||||||
|
got = 0 if sentence_embeddings is None else len(sentence_embeddings)
|
||||||
|
raise ChunkingError(
|
||||||
|
f"semantic requires sentence embeddings for Semantic Boundary Detection "
|
||||||
|
f"(got {got}, need {len(sentences)}). Fixed-count fallback is disabled (ADR-0020)."
|
||||||
|
)
|
||||||
|
|
||||||
|
threshold = (
|
||||||
|
semantic_threshold
|
||||||
|
if semantic_threshold is not None
|
||||||
|
else settings.semantic_threshold
|
||||||
|
)
|
||||||
min_size = settings.semantic_min_chunk_size
|
min_size = settings.semantic_min_chunk_size
|
||||||
|
|
||||||
if sentence_embeddings and len(sentence_embeddings) == len(sentences):
|
chunk_texts = _group_sentences_into_chunks(
|
||||||
chunk_texts = _group_sentences_into_chunks(
|
sentences, sentence_embeddings, threshold, min_size
|
||||||
sentences, sentence_embeddings, threshold, min_size
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback: group sentences into fixed-size chunks
|
|
||||||
chunk_texts = []
|
|
||||||
for i in range(0, len(sentences), min_size):
|
|
||||||
group = sentences[i:i + min_size]
|
|
||||||
chunk_texts.append(" ".join(group))
|
|
||||||
|
|
||||||
chunks: list[Chunk] = []
|
chunks: list[Chunk] = []
|
||||||
for i, text in enumerate(chunk_texts):
|
for i, text in enumerate(chunk_texts):
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ parent cluster is returned as context — giving the LLM richer
|
|||||||
information than a single paragraph.
|
information than a single paragraph.
|
||||||
|
|
||||||
No headings or document structure needed — uses meaning instead.
|
No headings or document structure needed — uses meaning instead.
|
||||||
|
Semantic Boundary Detection is required — no fixed-count fallback (ADR-0020).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -20,9 +22,9 @@ from src.chunking.base import (
|
|||||||
ChunkingStrategy,
|
ChunkingStrategy,
|
||||||
build_chunk,
|
build_chunk,
|
||||||
make_chunk_id,
|
make_chunk_id,
|
||||||
count_tokens,
|
|
||||||
)
|
)
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
|
from src.core.exceptions import ChunkingError
|
||||||
from src.core.models import Chunk, DocumentTree, StrategyName
|
from src.core.models import Chunk, DocumentTree, StrategyName
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,10 +36,8 @@ def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
|||||||
return float(dot / norm) if norm > 0 else 0.0
|
return float(dot / norm) if norm > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
def _split_paragraphs(text: str) -> list[str]:
|
def split_paragraphs(text: str) -> list[str]:
|
||||||
"""Split markdown into paragraphs (double newline or single newline)."""
|
"""Split markdown into paragraphs (double newline or single newline)."""
|
||||||
import re
|
|
||||||
# Split on double newlines first, then filter empties
|
|
||||||
parts = re.split(r"\n\s*\n", text)
|
parts = re.split(r"\n\s*\n", text)
|
||||||
paragraphs = [p.strip() for p in parts if p.strip()]
|
paragraphs = [p.strip() for p in parts if p.strip()]
|
||||||
|
|
||||||
@@ -89,34 +89,35 @@ class SemanticParentChildStrategy(ChunkingStrategy):
|
|||||||
tree: DocumentTree,
|
tree: DocumentTree,
|
||||||
markdown: str,
|
markdown: str,
|
||||||
paragraph_embeddings: list[list[float]] | None = None,
|
paragraph_embeddings: list[list[float]] | None = None,
|
||||||
|
semantic_threshold: float | None = None,
|
||||||
) -> list[Chunk]:
|
) -> list[Chunk]:
|
||||||
"""Produce parent-child chunks via semantic clustering.
|
"""Produce parent-child chunks via Semantic Boundary Detection.
|
||||||
|
|
||||||
If paragraph_embeddings is provided (from orchestration layer),
|
Requires paragraph_embeddings aligned 1:1 with split_paragraphs(markdown).
|
||||||
uses them for clustering. Otherwise, groups paragraphs by
|
|
||||||
fixed count.
|
|
||||||
"""
|
"""
|
||||||
paragraphs = _split_paragraphs(markdown)
|
paragraphs = split_paragraphs(markdown)
|
||||||
if not paragraphs:
|
if not paragraphs:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
threshold = settings.semantic_threshold
|
if paragraph_embeddings is None or len(paragraph_embeddings) != len(paragraphs):
|
||||||
|
got = 0 if paragraph_embeddings is None else len(paragraph_embeddings)
|
||||||
|
raise ChunkingError(
|
||||||
|
f"semantic_parent_child requires paragraph embeddings for "
|
||||||
|
f"Semantic Boundary Detection (got {got}, need {len(paragraphs)}). "
|
||||||
|
f"Fixed-count fallback is disabled (ADR-0020)."
|
||||||
|
)
|
||||||
|
|
||||||
if paragraph_embeddings and len(paragraph_embeddings) == len(paragraphs):
|
threshold = (
|
||||||
clusters = _cluster_paragraphs(paragraphs, paragraph_embeddings, threshold)
|
semantic_threshold
|
||||||
else:
|
if semantic_threshold is not None
|
||||||
# Fallback: group every N paragraphs
|
else settings.semantic_threshold
|
||||||
group_size = max(3, settings.semantic_min_chunk_size)
|
)
|
||||||
clusters = []
|
clusters = _cluster_paragraphs(paragraphs, paragraph_embeddings, threshold)
|
||||||
for i in range(0, len(paragraphs), group_size):
|
|
||||||
clusters.append(list(range(i, min(i + group_size, len(paragraphs)))))
|
|
||||||
|
|
||||||
chunks: list[Chunk] = []
|
chunks: list[Chunk] = []
|
||||||
chunk_index = 0
|
chunk_index = 0
|
||||||
|
|
||||||
for cluster_indices in clusters:
|
for cluster_indices in clusters:
|
||||||
# Parent = full cluster text
|
|
||||||
parent_text = "\n\n".join(paragraphs[i] for i in cluster_indices)
|
|
||||||
parent_id = make_chunk_id(self.name, doc_name, chunk_index)
|
parent_id = make_chunk_id(self.name, doc_name, chunk_index)
|
||||||
|
|
||||||
# Each paragraph in the cluster is a child
|
# Each paragraph in the cluster is a child
|
||||||
|
|||||||
@@ -10,15 +10,21 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
openai_api_key: str
|
openai_api_key: str
|
||||||
embedding_model: str = "text-embedding-3-small"
|
embedding_model: str = "text-embedding-3-large"
|
||||||
llm_model: str = "gpt-4o-mini"
|
llm_model: str = "gpt-4o-mini"
|
||||||
|
|
||||||
|
# Local embeddings (Ollama) — Admin switches models; host stays in config
|
||||||
|
ollama_base_url: str = "http://192.168.10.10:11435"
|
||||||
|
|
||||||
# Qdrant
|
# Qdrant
|
||||||
qdrant_url: str = "http://localhost:6333"
|
qdrant_url: str = "http://localhost:6333"
|
||||||
qdrant_api_key: str | None = None
|
qdrant_api_key: str | None = None
|
||||||
|
|
||||||
# Retrieval
|
# Retrieval
|
||||||
top_k: int = 5
|
top_k: int = 5
|
||||||
|
# Neighbor Expansion for fixed_size (ADR-0023); 3/3 = decision default (see final-chunking-strategy-decision.md)
|
||||||
|
neighbor_prev: int = 3
|
||||||
|
neighbor_next: int = 3
|
||||||
|
|
||||||
# LLM generation
|
# LLM generation
|
||||||
temperature: float = 0.0
|
temperature: float = 0.0
|
||||||
@@ -35,5 +41,9 @@ class Settings(BaseSettings):
|
|||||||
# Database
|
# Database
|
||||||
database_url: str = "sqlite:///./data/chunking_benchmark.db"
|
database_url: str = "sqlite:///./data/chunking_benchmark.db"
|
||||||
|
|
||||||
|
# Text PDF gate (reject Scanned PDFs with near-empty text layer)
|
||||||
|
pdf_min_total_chars: int = 100
|
||||||
|
pdf_min_median_chars_per_page: int = 40
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
@@ -14,6 +14,13 @@ def get_openai_client() -> OpenAI:
|
|||||||
return OpenAI(api_key=settings.openai_api_key)
|
return OpenAI(api_key=settings.openai_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_ollama_client() -> OpenAI:
|
||||||
|
"""Return a cached OpenAI-compatible client pointed at Ollama."""
|
||||||
|
base = settings.ollama_base_url.rstrip("/")
|
||||||
|
return OpenAI(base_url=f"{base}/v1", api_key="ollama")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def get_qdrant_client() -> QdrantClient:
|
def get_qdrant_client() -> QdrantClient:
|
||||||
"""Return a cached Qdrant client singleton."""
|
"""Return a cached Qdrant client singleton."""
|
||||||
|
|||||||
79
src/documents/heading_heuristics.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Shared heading detection heuristics for DOCX and Text PDF parsers.
|
||||||
|
|
||||||
|
Used when native styles/fonts/outlines are missing or incomplete
|
||||||
|
(common in table-heavy Farsi documents).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class StyledBlock(Protocol):
|
||||||
|
"""Minimal block interface: mutable style_name + text."""
|
||||||
|
|
||||||
|
style_name: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
_HEADING_PATTERNS: list[tuple[re.Pattern[str], int]] = [
|
||||||
|
(re.compile(r"^بخش\s+"), 1),
|
||||||
|
(re.compile(r"^\d+[\-\.]\d+"), 2),
|
||||||
|
(re.compile(r"^\*\s*بخش\s+"), 1),
|
||||||
|
(re.compile(r"^\*\s*\S"), 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def heading_level_from_style(style_name: str) -> int | None:
|
||||||
|
"""Return heading level from a style name like 'Heading 1' or 'Heading1'."""
|
||||||
|
m = re.match(r"^heading\s*(\d+)$", style_name.strip(), re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return int(m.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_likely_heading(text: str) -> int | None:
|
||||||
|
"""Detect heading-like patterns in body text.
|
||||||
|
|
||||||
|
Returns heading level (1 or 2) if detected, else None.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
if len(text) > 150:
|
||||||
|
return None
|
||||||
|
for pattern, level in _HEADING_PATTERNS:
|
||||||
|
if pattern.match(text):
|
||||||
|
return level
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_heading_heuristics(
|
||||||
|
blocks: list[StyledBlock],
|
||||||
|
*,
|
||||||
|
only_when_no_headings: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Upgrade Normal blocks to HeadingN via text patterns.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blocks: Mutable text blocks with style_name / text.
|
||||||
|
only_when_no_headings: If True (DOCX default), skip when any
|
||||||
|
Heading styles already exist. If False, only upgrade remaining
|
||||||
|
Normal blocks (PDF gap-fill after outline/font).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if any blocks were upgraded.
|
||||||
|
"""
|
||||||
|
if only_when_no_headings:
|
||||||
|
if any(heading_level_from_style(b.style_name) is not None for b in blocks):
|
||||||
|
return False
|
||||||
|
|
||||||
|
upgraded = False
|
||||||
|
for block in blocks:
|
||||||
|
if block.style_name != "Normal":
|
||||||
|
continue
|
||||||
|
level = is_likely_heading(block.text)
|
||||||
|
if level is not None:
|
||||||
|
block.style_name = f"Heading{level}"
|
||||||
|
upgraded = True
|
||||||
|
|
||||||
|
return upgraded
|
||||||
@@ -16,6 +16,8 @@ class DocumentResponse(BaseModel):
|
|||||||
paragraph_count: int = 0
|
paragraph_count: int = 0
|
||||||
chunk_counts: dict[str, int] = Field(default_factory=dict)
|
chunk_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
created_at: str
|
created_at: str
|
||||||
|
last_corpus_embedding_model_id: Optional[str] = None
|
||||||
|
last_boundary_embedding_model_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DocumentDetailResponse(DocumentResponse):
|
class DocumentDetailResponse(DocumentResponse):
|
||||||
@@ -37,6 +39,14 @@ class ProcessRequest(BaseModel):
|
|||||||
description="Which chunking strategies to run (defaults to all 5)",
|
description="Which chunking strategies to run (defaults to all 5)",
|
||||||
min_length=1,
|
min_length=1,
|
||||||
)
|
)
|
||||||
|
boundary_model_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Boundary Embedding Model id (semantic cuts); default = Admin Boundary",
|
||||||
|
)
|
||||||
|
corpus_model_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Corpus Embedding Model id (storage); default = Admin Corpus",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StrategyResult(BaseModel):
|
class StrategyResult(BaseModel):
|
||||||
@@ -52,6 +62,8 @@ class ProcessResponse(BaseModel):
|
|||||||
document_id: str
|
document_id: str
|
||||||
strategies_completed: list[StrategyResult]
|
strategies_completed: list[StrategyResult]
|
||||||
strategies_failed: list[StrategyResult]
|
strategies_failed: list[StrategyResult]
|
||||||
|
corpus_embedding_model_id: Optional[str] = None
|
||||||
|
boundary_embedding_model_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DeleteResponse(BaseModel):
|
class DeleteResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
"""DOCX parser: extracts document tree + flat markdown.
|
"""Document parser: DOCX/DOC + dispatcher for all supported formats.
|
||||||
|
|
||||||
Uses python-docx to read paragraph styles and build a hierarchical
|
Produces a hierarchical DocumentTree (Document > Section > Article > Paragraph)
|
||||||
DocumentTree (Document > Section > Article > Paragraph). Also produces
|
and markdown consumed by chunking strategies.
|
||||||
a markdown representation consumed by chunking strategies.
|
|
||||||
|
|
||||||
Supports both .docx and .doc formats. .doc files are converted to
|
Supports .docx, .doc (via LibreOffice), and .pdf (via pdf_parser).
|
||||||
.docx via LibreOffice headless mode before parsing.
|
|
||||||
|
|
||||||
Handles documents where content is in tables (not just paragraphs).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,6 +22,10 @@ from src.core.models import (
|
|||||||
NodeType,
|
NodeType,
|
||||||
)
|
)
|
||||||
from src.core.exceptions import DocumentProcessingError
|
from src.core.exceptions import DocumentProcessingError
|
||||||
|
from src.documents.heading_heuristics import (
|
||||||
|
apply_heading_heuristics,
|
||||||
|
heading_level_from_style,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Heading level → node type mapping ──────────────────────────────
|
# ── Heading level → node type mapping ──────────────────────────────
|
||||||
@@ -34,89 +33,41 @@ from src.core.exceptions import DocumentProcessingError
|
|||||||
_HEADING_MAP: dict[int, NodeType] = {
|
_HEADING_MAP: dict[int, NodeType] = {
|
||||||
1: NodeType.SECTION,
|
1: NodeType.SECTION,
|
||||||
2: NodeType.ARTICLE,
|
2: NodeType.ARTICLE,
|
||||||
# 3+ also ARTICLE (nesting depth determines hierarchy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _heading_level(style_name: str) -> int | None:
|
|
||||||
"""Return the heading level from a style name, or None if not a heading.
|
|
||||||
|
|
||||||
Handles both "Heading 1" (display name) and "Heading1" (style ID from XML).
|
|
||||||
"""
|
|
||||||
m = re.match(r"^heading\s*(\d+)$", style_name.strip(), re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
return int(m.group(1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _node_type_for_level(level: int) -> NodeType:
|
def _node_type_for_level(level: int) -> NodeType:
|
||||||
return _HEADING_MAP.get(level, NodeType.ARTICLE)
|
return _HEADING_MAP.get(level, NodeType.ARTICLE)
|
||||||
|
|
||||||
|
|
||||||
# ── Heuristic heading detection for table-heavy documents ──────────
|
# ── Text block ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Patterns that look like section/article headings in Farsi/English docs
|
class TextBlock:
|
||||||
_HEADING_PATTERNS: list[tuple[str, int]] = [
|
"""A unit of text extracted from a document, preserving reading order."""
|
||||||
# Farsi section markers: "بخش اول", "بخش دوم", etc.
|
|
||||||
(re.compile(r"^بخش\s+"), 1),
|
|
||||||
# Numbered sections: "1-1", "2-1", "10-1", "1-1-8"
|
|
||||||
(re.compile(r"^\d+[\-\.]\d+"), 2),
|
|
||||||
# Starred sections: "*بخش اول", "*تعریف"
|
|
||||||
(re.compile(r"^\*\s*بخش\s+"), 1),
|
|
||||||
(re.compile(r"^\*\s*\S"), 2),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
__slots__ = ("style_name", "text", "font_size", "bold", "page", "locked")
|
||||||
|
|
||||||
def _is_likely_heading(text: str) -> int | None:
|
def __init__(
|
||||||
"""Detect heading-like patterns in table-extracted text.
|
self,
|
||||||
|
style_name: str,
|
||||||
Returns the heading level (1 or 2) if detected, else None.
|
text: str,
|
||||||
Used when the document has no real heading styles (table-only content).
|
*,
|
||||||
"""
|
font_size: float = 0.0,
|
||||||
text = text.strip()
|
bold: bool = False,
|
||||||
if len(text) > 150: # headings are short
|
page: int = 0,
|
||||||
return None
|
locked: bool = False,
|
||||||
for pattern, level in _HEADING_PATTERNS:
|
) -> None:
|
||||||
if pattern.match(text):
|
self.style_name = style_name
|
||||||
return level
|
self.text = text
|
||||||
return None
|
self.font_size = font_size
|
||||||
|
self.bold = bold
|
||||||
|
self.page = page
|
||||||
def _detect_heading_blocks(blocks: list[_TextBlock]) -> bool:
|
self.locked = locked
|
||||||
"""Upgrade paragraph blocks to heading blocks based on content patterns.
|
|
||||||
|
|
||||||
Returns True if any blocks were upgraded.
|
|
||||||
Only activates when no real headings exist in the document.
|
|
||||||
"""
|
|
||||||
# Check if there are already real headings
|
|
||||||
has_headings = any(_heading_level(b.style_name) is not None for b in blocks)
|
|
||||||
if has_headings:
|
|
||||||
return False
|
|
||||||
|
|
||||||
upgraded = False
|
|
||||||
for block in blocks:
|
|
||||||
if block.style_name != "Normal":
|
|
||||||
continue
|
|
||||||
level = _is_likely_heading(block.text)
|
|
||||||
if level is not None:
|
|
||||||
block.style_name = f"Heading{level}"
|
|
||||||
upgraded = True
|
|
||||||
|
|
||||||
return upgraded
|
|
||||||
|
|
||||||
|
|
||||||
# ── Text block extraction (paragraphs + tables) ───────────────────
|
# ── Text block extraction (paragraphs + tables) ───────────────────
|
||||||
|
|
||||||
class _TextBlock:
|
def _extract_text_blocks(doc: DocxDocumentType) -> list[TextBlock]:
|
||||||
"""A unit of text extracted from the document, preserving reading order."""
|
|
||||||
__slots__ = ("style_name", "text")
|
|
||||||
|
|
||||||
def __init__(self, style_name: str, text: str) -> None:
|
|
||||||
self.style_name = style_name
|
|
||||||
self.text = text
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
|
|
||||||
"""Walk the document body in reading order, extracting paragraphs and tables.
|
"""Walk the document body in reading order, extracting paragraphs and tables.
|
||||||
|
|
||||||
This handles documents where content lives inside table cells
|
This handles documents where content lives inside table cells
|
||||||
@@ -124,20 +75,17 @@ def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
|
|||||||
"""
|
"""
|
||||||
from docx.oxml.ns import qn
|
from docx.oxml.ns import qn
|
||||||
|
|
||||||
blocks: list[_TextBlock] = []
|
blocks: list[TextBlock] = []
|
||||||
|
|
||||||
for element in doc.element.body:
|
for element in doc.element.body:
|
||||||
tag = element.tag.split("}")[-1] if "}" in element.tag else element.tag
|
tag = element.tag.split("}")[-1] if "}" in element.tag else element.tag
|
||||||
|
|
||||||
if tag == "p":
|
if tag == "p":
|
||||||
# Paragraph — extract text and style
|
|
||||||
text = element.text or ""
|
text = element.text or ""
|
||||||
# Also check for runs (text split across formatting)
|
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
runs = element.findall(qn("w:r"))
|
runs = element.findall(qn("w:r"))
|
||||||
text = "".join(r.text or "" for r in runs)
|
text = "".join(r.text or "" for r in runs)
|
||||||
|
|
||||||
# Get style name
|
|
||||||
ppr = element.find(qn("w:pPr"))
|
ppr = element.find(qn("w:pPr"))
|
||||||
style_name = "Normal"
|
style_name = "Normal"
|
||||||
if ppr is not None:
|
if ppr is not None:
|
||||||
@@ -146,19 +94,15 @@ def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
|
|||||||
style_name = pstyle.get(qn("w:val"), "Normal")
|
style_name = pstyle.get(qn("w:val"), "Normal")
|
||||||
|
|
||||||
if text.strip():
|
if text.strip():
|
||||||
blocks.append(_TextBlock(style_name, text.strip()))
|
blocks.append(TextBlock(style_name, text.strip()))
|
||||||
|
|
||||||
elif tag == "tbl":
|
elif tag == "tbl":
|
||||||
# Table — extract all cell text as paragraph blocks
|
|
||||||
for row in element.findall(qn("w:tr")):
|
for row in element.findall(qn("w:tr")):
|
||||||
for cell in row.findall(qn("w:tc")):
|
for cell in row.findall(qn("w:tc")):
|
||||||
for para in cell.findall(qn("w:p")):
|
for para in cell.findall(qn("w:p")):
|
||||||
# Get paragraph text
|
|
||||||
text = ""
|
|
||||||
runs = para.findall(qn("w:r"))
|
runs = para.findall(qn("w:r"))
|
||||||
text = "".join(r.text or "" for r in runs)
|
text = "".join(r.text or "" for r in runs)
|
||||||
|
|
||||||
# Get style
|
|
||||||
ppr = para.find(qn("w:pPr"))
|
ppr = para.find(qn("w:pPr"))
|
||||||
style_name = "Normal"
|
style_name = "Normal"
|
||||||
if ppr is not None:
|
if ppr is not None:
|
||||||
@@ -167,39 +111,36 @@ def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
|
|||||||
style_name = pstyle.get(qn("w:val"), "Normal")
|
style_name = pstyle.get(qn("w:val"), "Normal")
|
||||||
|
|
||||||
if text.strip():
|
if text.strip():
|
||||||
blocks.append(_TextBlock(style_name, text.strip()))
|
blocks.append(TextBlock(style_name, text.strip()))
|
||||||
|
|
||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
# ── Tree builder ───────────────────────────────────────────────────
|
# ── Tree builder ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def build_document_tree(paragraphs: list[Paragraph] | list[_TextBlock]) -> DocumentTreeNode:
|
def build_document_tree(paragraphs: list[Paragraph] | list[TextBlock]) -> DocumentTreeNode:
|
||||||
"""Build a DocumentTreeNode tree from a list of text blocks.
|
"""Build a DocumentTreeNode tree from a list of text blocks.
|
||||||
|
|
||||||
Accepts either python-docx Paragraph objects or _TextBlock objects.
|
Accepts either python-docx Paragraph objects or TextBlock objects.
|
||||||
"""
|
"""
|
||||||
root = DocumentTreeNode(node_type=NodeType.DOCUMENT, text="", heading=None, heading_level=None)
|
root = DocumentTreeNode(node_type=NodeType.DOCUMENT, text="", heading=None, heading_level=None)
|
||||||
|
|
||||||
# Stack of (level, node) for current nesting. level 0 = root.
|
|
||||||
stack: list[tuple[int, DocumentTreeNode]] = [(0, root)]
|
stack: list[tuple[int, DocumentTreeNode]] = [(0, root)]
|
||||||
|
|
||||||
for block in paragraphs:
|
for block in paragraphs:
|
||||||
# Get style name and text from either type
|
if isinstance(block, TextBlock):
|
||||||
if isinstance(block, _TextBlock):
|
|
||||||
style_name = block.style_name
|
style_name = block.style_name
|
||||||
text = block.text
|
text = block.text
|
||||||
else:
|
else:
|
||||||
style_name = block.style.name
|
style_name = block.style.name
|
||||||
text = block.text.strip()
|
text = block.text.strip()
|
||||||
|
|
||||||
level = _heading_level(style_name)
|
level = heading_level_from_style(style_name)
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
continue # skip blank paragraphs
|
continue
|
||||||
|
|
||||||
if level is not None:
|
if level is not None:
|
||||||
# Pop back to parent level
|
|
||||||
while len(stack) > 1 and stack[-1][0] >= level:
|
while len(stack) > 1 and stack[-1][0] >= level:
|
||||||
stack.pop()
|
stack.pop()
|
||||||
|
|
||||||
@@ -212,7 +153,6 @@ def build_document_tree(paragraphs: list[Paragraph] | list[_TextBlock]) -> Docum
|
|||||||
stack[-1][1].children.append(node)
|
stack[-1][1].children.append(node)
|
||||||
stack.append((level, node))
|
stack.append((level, node))
|
||||||
else:
|
else:
|
||||||
# Body text — add as paragraph child of current heading
|
|
||||||
node = DocumentTreeNode(
|
node = DocumentTreeNode(
|
||||||
node_type=NodeType.PARAGRAPH,
|
node_type=NodeType.PARAGRAPH,
|
||||||
text=text,
|
text=text,
|
||||||
@@ -244,7 +184,7 @@ def tree_to_markdown(node: DocumentTreeNode, depth: int = 0) -> str:
|
|||||||
# ── Public API ─────────────────────────────────────────────────────
|
# ── Public API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ParseResult:
|
class ParseResult:
|
||||||
"""Output of parse_docx(): tree + markdown + raw text."""
|
"""Output of parse_document() / parse_docx() / parse_pdf()."""
|
||||||
|
|
||||||
__slots__ = ("tree", "markdown", "plain_text", "paragraph_count")
|
__slots__ = ("tree", "markdown", "plain_text", "paragraph_count")
|
||||||
|
|
||||||
@@ -261,6 +201,31 @@ class ParseResult:
|
|||||||
self.paragraph_count = paragraph_count
|
self.paragraph_count = paragraph_count
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_SUFFIXES = (".docx", ".doc", ".pdf")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_document(file_path: str | Path) -> ParseResult:
|
||||||
|
"""Parse a supported document into DocumentTree + markdown.
|
||||||
|
|
||||||
|
Dispatches by suffix: .pdf → pdf_parser; .docx/.doc → parse_docx.
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise DocumentProcessingError(f"File not found: {path}")
|
||||||
|
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix == ".pdf":
|
||||||
|
from src.documents.pdf_parser import parse_pdf
|
||||||
|
|
||||||
|
return parse_pdf(path)
|
||||||
|
if suffix in (".docx", ".doc"):
|
||||||
|
return parse_docx(path)
|
||||||
|
|
||||||
|
raise DocumentProcessingError(
|
||||||
|
f"Not a supported file format: {suffix} (expected {', '.join(SUPPORTED_SUFFIXES)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── .doc → .docx conversion ───────────────────────────────────────
|
# ── .doc → .docx conversion ───────────────────────────────────────
|
||||||
|
|
||||||
def _convert_doc_to_docx(doc_path: Path) -> Path:
|
def _convert_doc_to_docx(doc_path: Path) -> Path:
|
||||||
@@ -268,9 +233,6 @@ def _convert_doc_to_docx(doc_path: Path) -> Path:
|
|||||||
|
|
||||||
Returns the path to the converted .docx file (in a temp directory).
|
Returns the path to the converted .docx file (in a temp directory).
|
||||||
The caller is responsible for cleanup.
|
The caller is responsible for cleanup.
|
||||||
|
|
||||||
Raises:
|
|
||||||
DocumentProcessingError: If conversion fails.
|
|
||||||
"""
|
"""
|
||||||
out_dir = Path(tempfile.mkdtemp(prefix="docconv_"))
|
out_dir = Path(tempfile.mkdtemp(prefix="docconv_"))
|
||||||
try:
|
try:
|
||||||
@@ -291,7 +253,6 @@ def _convert_doc_to_docx(doc_path: Path) -> Path:
|
|||||||
f"LibreOffice conversion failed: {result.stderr}"
|
f"LibreOffice conversion failed: {result.stderr}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find the converted file
|
|
||||||
converted = out_dir / doc_path.with_suffix(".docx").name
|
converted = out_dir / doc_path.with_suffix(".docx").name
|
||||||
if not converted.exists():
|
if not converted.exists():
|
||||||
raise DocumentProcessingError(
|
raise DocumentProcessingError(
|
||||||
@@ -312,15 +273,6 @@ def parse_docx(file_path: str | Path) -> ParseResult:
|
|||||||
"""Parse a .docx or .doc file into a DocumentTree + markdown.
|
"""Parse a .docx or .doc file into a DocumentTree + markdown.
|
||||||
|
|
||||||
.doc files are automatically converted to .docx via LibreOffice.
|
.doc files are automatically converted to .docx via LibreOffice.
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to the .docx or .doc file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ParseResult with tree, markdown, plain_text, and paragraph_count.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
DocumentProcessingError: If the file cannot be parsed.
|
|
||||||
"""
|
"""
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -328,9 +280,10 @@ def parse_docx(file_path: str | Path) -> ParseResult:
|
|||||||
|
|
||||||
suffix = path.suffix.lower()
|
suffix = path.suffix.lower()
|
||||||
if suffix not in (".docx", ".doc"):
|
if suffix not in (".docx", ".doc"):
|
||||||
raise DocumentProcessingError(f"Not a supported file format: {suffix} (expected .docx or .doc)")
|
raise DocumentProcessingError(
|
||||||
|
f"Not a supported file format: {suffix} (expected .docx or .doc)"
|
||||||
|
)
|
||||||
|
|
||||||
# Convert .doc to .docx if needed
|
|
||||||
if suffix == ".doc":
|
if suffix == ".doc":
|
||||||
path = _convert_doc_to_docx(path)
|
path = _convert_doc_to_docx(path)
|
||||||
|
|
||||||
@@ -339,14 +292,12 @@ def parse_docx(file_path: str | Path) -> ParseResult:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DocumentProcessingError(f"Failed to open DOCX: {exc}") from exc
|
raise DocumentProcessingError(f"Failed to open DOCX: {exc}") from exc
|
||||||
|
|
||||||
# Extract text blocks from paragraphs + tables (preserves reading order)
|
|
||||||
blocks = _extract_text_blocks(doc)
|
blocks = _extract_text_blocks(doc)
|
||||||
|
|
||||||
if not blocks:
|
if not blocks:
|
||||||
raise DocumentProcessingError("Document contains no text content")
|
raise DocumentProcessingError("Document contains no text content")
|
||||||
|
|
||||||
# Detect heading patterns in table-only documents
|
apply_heading_heuristics(blocks, only_when_no_headings=True)
|
||||||
_detect_heading_blocks(blocks)
|
|
||||||
|
|
||||||
root = build_document_tree(blocks)
|
root = build_document_tree(blocks)
|
||||||
tree = DocumentTree(root=root)
|
tree = DocumentTree(root=root)
|
||||||
|
|||||||
228
src/documents/pdf_parser.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""Text PDF parser via PyMuPDF with Heading Reconstruction.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. Text-layer Gate — reject Scanned PDFs (near-empty text)
|
||||||
|
2. Extract reading-order blocks (Table Flattening via block order)
|
||||||
|
3. Heading Reconstruction: outline → font size → shared heuristics
|
||||||
|
4. Build DocumentTree + markdown (same contract as DOCX)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import statistics
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
from src.core.config import settings
|
||||||
|
from src.core.exceptions import DocumentProcessingError
|
||||||
|
from src.core.models import DocumentTree
|
||||||
|
from src.documents.heading_heuristics import apply_heading_heuristics
|
||||||
|
from src.documents.parser import (
|
||||||
|
ParseResult,
|
||||||
|
TextBlock,
|
||||||
|
build_document_tree,
|
||||||
|
tree_to_markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _median_chars_per_page(page_char_counts: list[int]) -> float:
|
||||||
|
if not page_char_counts:
|
||||||
|
return 0.0
|
||||||
|
return float(statistics.median(page_char_counts))
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_text_layer_gate(doc: fitz.Document) -> None:
|
||||||
|
"""Hard-reject PDFs without enough extractable text (Scanned PDFs)."""
|
||||||
|
page_counts: list[int] = []
|
||||||
|
for page in doc:
|
||||||
|
page_counts.append(len(page.get_text("text").strip()))
|
||||||
|
|
||||||
|
total = sum(page_counts)
|
||||||
|
median = _median_chars_per_page(page_counts)
|
||||||
|
|
||||||
|
if total < settings.pdf_min_total_chars or median < settings.pdf_min_median_chars_per_page:
|
||||||
|
raise DocumentProcessingError(
|
||||||
|
"PDF has no usable text layer (likely a scanned/image PDF). "
|
||||||
|
"OCR is not enabled for this benchmarker — only Text PDFs are supported. "
|
||||||
|
f"(total_chars={total}, median_chars_per_page={median:.0f}; "
|
||||||
|
f"need total>={settings.pdf_min_total_chars} and "
|
||||||
|
f"median>={settings.pdf_min_median_chars_per_page})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _line_font_meta(line: dict) -> tuple[str, float, bool]:
|
||||||
|
"""Return (text, max_font_size, any_bold) for a dict-line."""
|
||||||
|
spans = line.get("spans") or []
|
||||||
|
parts: list[str] = []
|
||||||
|
max_size = 0.0
|
||||||
|
any_bold = False
|
||||||
|
for span in spans:
|
||||||
|
text = (span.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
parts.append(span.get("text") or "")
|
||||||
|
size = float(span.get("size") or 0)
|
||||||
|
if size > max_size:
|
||||||
|
max_size = size
|
||||||
|
# flags bit 4 (16) = bold in PyMuPDF
|
||||||
|
if int(span.get("flags") or 0) & 2**4:
|
||||||
|
any_bold = True
|
||||||
|
return ("".join(parts).strip(), max_size, any_bold)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_blocks(doc: fitz.Document) -> list[TextBlock]:
|
||||||
|
"""Extract reading-order text blocks with font metadata (tables flattened)."""
|
||||||
|
blocks: list[TextBlock] = []
|
||||||
|
for page_index, page in enumerate(doc):
|
||||||
|
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
|
||||||
|
for block in page_dict.get("blocks") or []:
|
||||||
|
if block.get("type") != 0: # 0 = text
|
||||||
|
continue
|
||||||
|
for line in block.get("lines") or []:
|
||||||
|
text, font_size, bold = _line_font_meta(line)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
blocks.append(
|
||||||
|
TextBlock(
|
||||||
|
style_name="Normal",
|
||||||
|
text=text,
|
||||||
|
font_size=font_size,
|
||||||
|
bold=bold,
|
||||||
|
page=page_index + 1,
|
||||||
|
locked=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(s: str) -> str:
|
||||||
|
return " ".join(s.split()).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_outline_headings(doc: fitz.Document, blocks: list[TextBlock]) -> int:
|
||||||
|
"""Mark blocks that match PDF outline/bookmark titles. Returns match count."""
|
||||||
|
toc = doc.get_toc(simple=True)
|
||||||
|
if not toc:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
matched = 0
|
||||||
|
used: set[int] = set()
|
||||||
|
|
||||||
|
for level, title, page in toc:
|
||||||
|
title_norm = _normalize(title)
|
||||||
|
if not title_norm:
|
||||||
|
continue
|
||||||
|
level = max(1, min(int(level), 6))
|
||||||
|
|
||||||
|
# Prefer same page; fall back to any unused block with matching title
|
||||||
|
candidates = [
|
||||||
|
(i, b)
|
||||||
|
for i, b in enumerate(blocks)
|
||||||
|
if i not in used and not b.locked
|
||||||
|
]
|
||||||
|
same_page = [(i, b) for i, b in candidates if b.page == page]
|
||||||
|
search_order = same_page + [(i, b) for i, b in candidates if b.page != page]
|
||||||
|
|
||||||
|
for i, block in search_order:
|
||||||
|
block_norm = _normalize(block.text)
|
||||||
|
if block_norm == title_norm or title_norm in block_norm or block_norm in title_norm:
|
||||||
|
block.style_name = f"Heading{level}"
|
||||||
|
block.locked = True
|
||||||
|
used.add(i)
|
||||||
|
matched += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_font_headings(blocks: list[TextBlock]) -> int:
|
||||||
|
"""Mark unlocked blocks as headings from font size clusters vs body size."""
|
||||||
|
sizes = [b.font_size for b in blocks if b.font_size > 0]
|
||||||
|
if len(sizes) < 3:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
body_size = statistics.median(sizes)
|
||||||
|
if body_size <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Distinct sizes clearly above body text
|
||||||
|
heading_sizes = sorted(
|
||||||
|
{round(s, 1) for s in sizes if s >= body_size * 1.2},
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
if not heading_sizes:
|
||||||
|
# Bold + short lines at/near body size as weak H2
|
||||||
|
upgraded = 0
|
||||||
|
for block in blocks:
|
||||||
|
if block.locked or block.style_name != "Normal":
|
||||||
|
continue
|
||||||
|
if block.bold and len(block.text) <= 150:
|
||||||
|
block.style_name = "Heading2"
|
||||||
|
upgraded += 1
|
||||||
|
return upgraded
|
||||||
|
|
||||||
|
size_to_level: dict[float, int] = {}
|
||||||
|
for idx, size in enumerate(heading_sizes):
|
||||||
|
size_to_level[size] = 1 if idx == 0 else 2
|
||||||
|
|
||||||
|
upgraded = 0
|
||||||
|
for block in blocks:
|
||||||
|
if block.locked or block.style_name != "Normal":
|
||||||
|
continue
|
||||||
|
if len(block.text) > 150:
|
||||||
|
continue
|
||||||
|
rounded = round(block.font_size, 1)
|
||||||
|
level = size_to_level.get(rounded)
|
||||||
|
if level is None:
|
||||||
|
continue
|
||||||
|
block.style_name = f"Heading{level}"
|
||||||
|
upgraded += 1
|
||||||
|
|
||||||
|
return upgraded
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pdf(file_path: str | Path) -> ParseResult:
|
||||||
|
"""Parse a Text PDF into DocumentTree + markdown.
|
||||||
|
|
||||||
|
Raises DocumentProcessingError for missing files, Scanned PDFs, or empty text.
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise DocumentProcessingError(f"File not found: {path}")
|
||||||
|
if path.suffix.lower() != ".pdf":
|
||||||
|
raise DocumentProcessingError(f"Not a PDF: {path.suffix}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = fitz.open(path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise DocumentProcessingError(f"Failed to open PDF: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
if doc.page_count < 1:
|
||||||
|
raise DocumentProcessingError("PDF has no pages")
|
||||||
|
|
||||||
|
_enforce_text_layer_gate(doc)
|
||||||
|
blocks = _extract_blocks(doc)
|
||||||
|
if not blocks:
|
||||||
|
raise DocumentProcessingError("PDF contains no extractable text blocks")
|
||||||
|
|
||||||
|
_apply_outline_headings(doc, blocks)
|
||||||
|
_apply_font_headings(blocks)
|
||||||
|
|
||||||
|
# Gap-fill remaining Normal blocks (outline titles stay locked).
|
||||||
|
# When outline/font found nothing, this is a full heuristic pass.
|
||||||
|
apply_heading_heuristics(blocks, only_when_no_headings=False)
|
||||||
|
|
||||||
|
root = build_document_tree(blocks)
|
||||||
|
tree = DocumentTree(root=root)
|
||||||
|
markdown = tree_to_markdown(root)
|
||||||
|
plain_text = "\n".join(b.text for b in blocks if b.text)
|
||||||
|
|
||||||
|
return ParseResult(
|
||||||
|
tree=tree,
|
||||||
|
markdown=markdown,
|
||||||
|
plain_text=plain_text,
|
||||||
|
paragraph_count=len(blocks),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"""Document and strategy API routes.
|
"""Document and strategy API routes.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
POST /documents Upload a .docx file
|
POST /documents Upload a .docx / .doc / .pdf file
|
||||||
POST /documents/{id}/process Run chunking strategies (stub until Phase 2)
|
POST /documents/{id}/process Run chunking strategies; ?background=true enqueues a job
|
||||||
DELETE /documents/{id} Remove document + vectors
|
DELETE /documents/{id} Remove document + vectors
|
||||||
GET /strategies List available chunking strategies
|
GET /strategies List available chunking strategies
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, File, UploadFile
|
from fastapi import APIRouter, BackgroundTasks, File, Query, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from src.core.exceptions import DocumentProcessingError
|
from src.core.exceptions import DocumentProcessingError
|
||||||
from src.core.models import PaginatedResponse, StrategyName
|
|
||||||
from src.documents.models import (
|
from src.documents.models import (
|
||||||
DeleteResponse,
|
DeleteResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
@@ -20,7 +20,11 @@ from src.documents.models import (
|
|||||||
StrategiesResponse,
|
StrategiesResponse,
|
||||||
StrategyInfo,
|
StrategyInfo,
|
||||||
)
|
)
|
||||||
|
from src.documents.parser import SUPPORTED_SUFFIXES
|
||||||
from src.documents import service
|
from src.documents import service
|
||||||
|
from src.jobs import service as jobs_service
|
||||||
|
from src.jobs.models import JobCreatedResponse
|
||||||
|
from src.storage import sqlite as db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -39,11 +43,15 @@ async def list_documents(offset: int = 0, limit: int = 50):
|
|||||||
|
|
||||||
@router.post("/documents", response_model=DocumentResponse, status_code=201)
|
@router.post("/documents", response_model=DocumentResponse, status_code=201)
|
||||||
async def upload_document(file: UploadFile = File(...)):
|
async def upload_document(file: UploadFile = File(...)):
|
||||||
"""Upload a .docx file. Parses it, stores the document tree in SQLite."""
|
"""Upload a document. Parses it, stores the document tree in SQLite."""
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise DocumentProcessingError("No filename provided")
|
raise DocumentProcessingError("No filename provided")
|
||||||
if not file.filename.lower().endswith((".docx", ".doc")):
|
|
||||||
raise DocumentProcessingError("Only .docx and .doc files are supported")
|
lower = file.filename.lower()
|
||||||
|
if not any(lower.endswith(s) for s in SUPPORTED_SUFFIXES):
|
||||||
|
raise DocumentProcessingError(
|
||||||
|
f"Only {', '.join(SUPPORTED_SUFFIXES)} files are supported"
|
||||||
|
)
|
||||||
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
if not content:
|
if not content:
|
||||||
@@ -59,12 +67,32 @@ async def upload_document(file: UploadFile = File(...)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/documents/{doc_id}/process", response_model=ProcessResponse)
|
@router.post("/documents/{doc_id}/process")
|
||||||
async def process_document(doc_id: str, request: ProcessRequest):
|
async def process_document(
|
||||||
|
doc_id: str,
|
||||||
|
request: ProcessRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
background: bool = Query(
|
||||||
|
False, description="Run in background; returns job_id (HTTP 202)"
|
||||||
|
),
|
||||||
|
):
|
||||||
"""Run selected chunking strategies on an uploaded document.
|
"""Run selected chunking strategies on an uploaded document.
|
||||||
|
|
||||||
Currently returns stub results until Phase 2 implements the strategies.
|
Pass ``background=true`` to enqueue and poll ``GET /jobs/{job_id}``.
|
||||||
"""
|
"""
|
||||||
|
if background:
|
||||||
|
if db.get_document(doc_id) is None:
|
||||||
|
raise DocumentProcessingError(f"Document not found: {doc_id}")
|
||||||
|
job = jobs_service.enqueue_process(doc_id, request)
|
||||||
|
background_tasks.add_task(jobs_service.run_process_job, job["id"])
|
||||||
|
body = JobCreatedResponse(
|
||||||
|
job_id=job["id"],
|
||||||
|
job_type=job["job_type"],
|
||||||
|
status=job["status"],
|
||||||
|
poll_url=f"/jobs/{job['id']}",
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=202, content=body.model_dump())
|
||||||
|
|
||||||
return service.process_document(doc_id, request)
|
return service.process_document(doc_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from src.documents.models import (
|
|||||||
ProcessResponse,
|
ProcessResponse,
|
||||||
StrategyResult,
|
StrategyResult,
|
||||||
)
|
)
|
||||||
from src.documents.parser import parse_docx
|
from src.documents.parser import parse_document, SUPPORTED_SUFFIXES
|
||||||
from src.storage import sqlite as db
|
from src.storage import sqlite as db
|
||||||
from src.storage import qdrant as qdr
|
from src.storage import qdrant as qdr
|
||||||
|
|
||||||
@@ -49,17 +49,22 @@ STRATEGY_DESCRIPTIONS: dict[StrategyName, str] = {
|
|||||||
# ── Upload ─────────────────────────────────────────────────────────
|
# ── Upload ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def upload_document(filename: str, file_bytes: bytes) -> dict[str, Any]:
|
def upload_document(filename: str, file_bytes: bytes) -> dict[str, Any]:
|
||||||
"""Parse a .docx upload, store in SQLite, return document record."""
|
"""Parse an uploaded document, store in SQLite, return document record."""
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
suffix = Path(filename).suffix or ".docx"
|
suffix = Path(filename).suffix.lower()
|
||||||
|
if suffix not in SUPPORTED_SUFFIXES:
|
||||||
|
raise DocumentProcessingError(
|
||||||
|
f"Only {', '.join(SUPPORTED_SUFFIXES)} files are supported"
|
||||||
|
)
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||||
tmp.write(file_bytes)
|
tmp.write(file_bytes)
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = parse_docx(tmp_path)
|
result = parse_document(tmp_path)
|
||||||
finally:
|
finally:
|
||||||
Path(tmp_path).unlink(missing_ok=True)
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
@@ -96,7 +101,12 @@ def process_document(
|
|||||||
# Import here to avoid circular imports at module level
|
# Import here to avoid circular imports at module level
|
||||||
from src.chunking.service import run_strategies
|
from src.chunking.service import run_strategies
|
||||||
|
|
||||||
completed_raw, failed_raw = run_strategies(doc_id, request.strategies)
|
completed_raw, failed_raw = run_strategies(
|
||||||
|
doc_id,
|
||||||
|
request.strategies,
|
||||||
|
boundary_model_id=request.boundary_model_id,
|
||||||
|
corpus_model_id=request.corpus_model_id,
|
||||||
|
)
|
||||||
|
|
||||||
completed = [
|
completed = [
|
||||||
StrategyResult(
|
StrategyResult(
|
||||||
@@ -115,17 +125,28 @@ def process_document(
|
|||||||
for r in failed_raw
|
for r in failed_raw
|
||||||
]
|
]
|
||||||
|
|
||||||
|
corpus_id = None
|
||||||
|
boundary_id = None
|
||||||
|
for r in completed_raw:
|
||||||
|
corpus_id = r.get("corpus_embedding_model_id") or corpus_id
|
||||||
|
if r.get("boundary_embedding_model_id"):
|
||||||
|
boundary_id = r["boundary_embedding_model_id"]
|
||||||
|
|
||||||
return ProcessResponse(
|
return ProcessResponse(
|
||||||
document_id=doc_id,
|
document_id=doc_id,
|
||||||
strategies_completed=completed,
|
strategies_completed=completed,
|
||||||
strategies_failed=failed,
|
strategies_failed=failed,
|
||||||
|
corpus_embedding_model_id=corpus_id,
|
||||||
|
boundary_embedding_model_id=boundary_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Delete ─────────────────────────────────────────────────────────
|
# ── Delete ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def delete_document(doc_id: str) -> bool:
|
def delete_document(doc_id: str) -> bool:
|
||||||
"""Delete a document and all its Qdrant vectors."""
|
"""Delete a document and all its Qdrant vectors across Model Corpora."""
|
||||||
|
from src.chunking.embedding_models import list_models
|
||||||
|
|
||||||
doc = db.get_document(doc_id)
|
doc = db.get_document(doc_id)
|
||||||
if doc is None:
|
if doc is None:
|
||||||
return False
|
return False
|
||||||
@@ -134,7 +155,20 @@ def delete_document(doc_id: str) -> bool:
|
|||||||
if count > 0:
|
if count > 0:
|
||||||
try:
|
try:
|
||||||
strategy = StrategyName(strategy_name)
|
strategy = StrategyName(strategy_name)
|
||||||
qdr.delete_document_chunks(strategy, doc["filename"])
|
for model in list_models():
|
||||||
|
try:
|
||||||
|
qdr.delete_document_chunks(
|
||||||
|
strategy,
|
||||||
|
doc["filename"],
|
||||||
|
model_id=model.id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete Qdrant vectors for %s/%s: %s",
|
||||||
|
strategy_name,
|
||||||
|
model.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to delete Qdrant vectors for %s: %s", strategy_name, exc)
|
logger.warning("Failed to delete Qdrant vectors for %s: %s", strategy_name, exc)
|
||||||
|
|
||||||
|
|||||||
1
src/jobs/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Background job tracking for long-running benchmark and process operations."""
|
||||||
48
src/jobs/models.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Request/response schemas for the Jobs API."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class JobType(str, Enum):
|
||||||
|
BENCHMARK = "benchmark"
|
||||||
|
PROCESS = "process"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class JobCreatedResponse(BaseModel):
|
||||||
|
"""Returned when a long-running operation is enqueued (HTTP 202)."""
|
||||||
|
job_id: str
|
||||||
|
job_type: str
|
||||||
|
status: str = "pending"
|
||||||
|
poll_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class JobDetailResponse(BaseModel):
|
||||||
|
"""Full job status including result or error when finished."""
|
||||||
|
id: str
|
||||||
|
job_type: str
|
||||||
|
status: str
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
result: Optional[dict[str, Any]] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
progress: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: str
|
||||||
|
started_at: Optional[str] = None
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class JobListResponse(BaseModel):
|
||||||
|
"""Paginated list of jobs."""
|
||||||
|
items: list[JobDetailResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
42
src/jobs/routes.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Job status API routes.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /jobs List background jobs
|
||||||
|
GET /jobs/{id} Poll job status and result
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from src.core.exceptions import BenchmarkError
|
||||||
|
from src.jobs.models import JobDetailResponse, JobListResponse
|
||||||
|
from src.storage import sqlite as db
|
||||||
|
|
||||||
|
router = APIRouter(tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs", response_model=JobListResponse)
|
||||||
|
async def list_jobs(
|
||||||
|
job_type: str | None = Query(None, description="Filter by job_type (benchmark|process)"),
|
||||||
|
status: str | None = Query(None, description="Filter by status"),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
):
|
||||||
|
"""List background jobs, newest first."""
|
||||||
|
result = db.list_jobs(
|
||||||
|
job_type=job_type, status=status, offset=offset, limit=limit
|
||||||
|
)
|
||||||
|
return JobListResponse(
|
||||||
|
items=[JobDetailResponse(**item) for item in result["items"]],
|
||||||
|
total=result["total"],
|
||||||
|
offset=result["offset"],
|
||||||
|
limit=result["limit"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}", response_model=JobDetailResponse)
|
||||||
|
async def get_job(job_id: str):
|
||||||
|
"""Poll a background job by ID."""
|
||||||
|
job = db.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise BenchmarkError(f"Job not found: {job_id}")
|
||||||
|
return JobDetailResponse(**job)
|
||||||
114
src/jobs/service.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Background job enqueue and execution via FastAPI BackgroundTasks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.benchmarking import benchmark_service
|
||||||
|
from src.core.models import StrategyName
|
||||||
|
from src.documents import service as documents_service
|
||||||
|
from src.documents.models import ProcessRequest
|
||||||
|
from src.storage import sqlite as db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_benchmark(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Create a pending benchmark job with a resolved questions payload."""
|
||||||
|
return db.create_job(job_type="benchmark", payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_process(document_id: str, request: ProcessRequest) -> dict[str, Any]:
|
||||||
|
"""Create a pending document processing job."""
|
||||||
|
payload = {"document_id": document_id, **request.model_dump(mode="json")}
|
||||||
|
return db.create_job(job_type="process", payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def run_benchmark_job(job_id: str) -> None:
|
||||||
|
"""Execute a benchmark job (runs in a FastAPI background task)."""
|
||||||
|
job = db.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
logger.error("Benchmark job not found: %s", job_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = job["payload"]
|
||||||
|
try:
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="running",
|
||||||
|
started_at=_now(),
|
||||||
|
progress={"message": "Running benchmark"},
|
||||||
|
)
|
||||||
|
strategies = [StrategyName(s) for s in payload["strategies"]]
|
||||||
|
result = benchmark_service.run_benchmark(
|
||||||
|
document_id=payload["document_id"],
|
||||||
|
strategies=strategies,
|
||||||
|
questions=payload["questions"],
|
||||||
|
top_k=payload.get("top_k", 5),
|
||||||
|
neighbor_prev=payload.get("neighbor_prev", 0),
|
||||||
|
neighbor_next=payload.get("neighbor_next", 0),
|
||||||
|
corpus_model_id=payload.get("corpus_model_id"),
|
||||||
|
)
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="completed",
|
||||||
|
result=result,
|
||||||
|
completed_at=_now(),
|
||||||
|
progress={"message": "Benchmark completed"},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Benchmark job %s failed", job_id)
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
error=str(exc),
|
||||||
|
completed_at=_now(),
|
||||||
|
progress={"message": "Benchmark failed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_process_job(job_id: str) -> None:
|
||||||
|
"""Execute a document processing job (runs in a FastAPI background task)."""
|
||||||
|
job = db.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
logger.error("Process job not found: %s", job_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = job["payload"]
|
||||||
|
document_id = payload["document_id"]
|
||||||
|
request = ProcessRequest(
|
||||||
|
strategies=[StrategyName(s) for s in payload["strategies"]],
|
||||||
|
boundary_model_id=payload.get("boundary_model_id"),
|
||||||
|
corpus_model_id=payload.get("corpus_model_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="running",
|
||||||
|
started_at=_now(),
|
||||||
|
progress={"message": "Processing document"},
|
||||||
|
)
|
||||||
|
response = documents_service.process_document(document_id, request)
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="completed",
|
||||||
|
result=response.model_dump(mode="json"),
|
||||||
|
completed_at=_now(),
|
||||||
|
progress={"message": "Processing completed"},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Process job %s failed", job_id)
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
error=str(exc),
|
||||||
|
completed_at=_now(),
|
||||||
|
progress={"message": "Processing failed"},
|
||||||
|
)
|
||||||
@@ -21,6 +21,7 @@ from src.core.exceptions import (
|
|||||||
from src.documents.routes import router as documents_router
|
from src.documents.routes import router as documents_router
|
||||||
from src.benchmarking.routes import router as benchmarking_router
|
from src.benchmarking.routes import router as benchmarking_router
|
||||||
from src.admin.routes import router as admin_router
|
from src.admin.routes import router as admin_router
|
||||||
|
from src.jobs.routes import router as jobs_router
|
||||||
from src.storage.sqlite import init_db
|
from src.storage.sqlite import init_db
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -95,6 +96,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(documents_router)
|
app.include_router(documents_router)
|
||||||
app.include_router(benchmarking_router)
|
app.include_router(benchmarking_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
app.include_router(jobs_router)
|
||||||
|
|
||||||
# Mount dashboard at /app
|
# Mount dashboard at /app
|
||||||
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||||
|
|||||||
@@ -1,75 +1,123 @@
|
|||||||
"""Qdrant vector storage layer.
|
"""Qdrant vector storage layer.
|
||||||
|
|
||||||
One collection per chunking strategy. Handles collection creation,
|
One collection per (Strategy, Embedding Model) — the Model Corpus.
|
||||||
vector upsert, and similarity search.
|
Names are always `{strategy}__{model_id}_collection` (ADR-0021).
|
||||||
|
Legacy unscoped `{strategy}_collection` names are recognized for Admin
|
||||||
|
labeling only; process/query no longer write them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from qdrant_client import QdrantClient
|
|
||||||
from qdrant_client.models import (
|
from qdrant_client.models import (
|
||||||
Distance,
|
Distance,
|
||||||
FieldCondition,
|
FieldCondition,
|
||||||
Filter,
|
Filter,
|
||||||
|
MatchAny,
|
||||||
MatchValue,
|
MatchValue,
|
||||||
PointIdsList,
|
PointIdsList,
|
||||||
PointStruct,
|
PointStruct,
|
||||||
VectorParams,
|
VectorParams,
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.core.config import settings
|
from src.chunking.embedding_models import LEGACY_CLOUD_MODEL_ID
|
||||||
from src.core.dependencies import get_qdrant_client
|
from src.core.dependencies import get_qdrant_client
|
||||||
from src.core.exceptions import QdrantError
|
from src.core.exceptions import QdrantError
|
||||||
from src.core.models import Chunk, ChunkMetadata, StrategyName, chunk_to_metadata
|
from src.core.models import Chunk, StrategyName, chunk_to_metadata
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Embedding dimension for text-embedding-3-small
|
# Backward-compat alias — prefer EmbeddingModelSpec.dimension
|
||||||
VECTOR_DIMENSION = 1536
|
VECTOR_DIMENSION = 1536
|
||||||
|
|
||||||
|
|
||||||
def collection_name(strategy: StrategyName | str) -> str:
|
def _sanitize_model_id(model_id: str) -> str:
|
||||||
"""Convention: {strategy_name}_collection."""
|
return model_id.replace(":", "-").replace("/", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def collection_name(strategy: StrategyName | str, model_id: str) -> str:
|
||||||
|
"""Model Corpus collection name for a Strategy + Embedding Model.
|
||||||
|
|
||||||
|
Always includes the Embedding Model id (cloud and local).
|
||||||
|
"""
|
||||||
if isinstance(strategy, StrategyName):
|
if isinstance(strategy, StrategyName):
|
||||||
name = strategy.value
|
name = strategy.value
|
||||||
else:
|
else:
|
||||||
name = strategy
|
name = strategy
|
||||||
return f"{name}_collection"
|
return f"{name}__{_sanitize_model_id(model_id)}_collection"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_collection_meta(name: str) -> dict[str, Any]:
|
||||||
|
"""Infer strategy + Embedding Model from a Qdrant collection name."""
|
||||||
|
if not name.endswith("_collection"):
|
||||||
|
return {
|
||||||
|
"strategy": None,
|
||||||
|
"embedding_model_id": None,
|
||||||
|
"is_legacy": False,
|
||||||
|
"recognized": False,
|
||||||
|
}
|
||||||
|
body = name[: -len("_collection")]
|
||||||
|
if "__" in body:
|
||||||
|
strategy, model_id = body.split("__", 1)
|
||||||
|
return {
|
||||||
|
"strategy": strategy,
|
||||||
|
"embedding_model_id": model_id,
|
||||||
|
"is_legacy": False,
|
||||||
|
"recognized": True,
|
||||||
|
}
|
||||||
|
# Pre-ADR-0021 unscoped names (no longer written)
|
||||||
|
return {
|
||||||
|
"strategy": body,
|
||||||
|
"embedding_model_id": LEGACY_CLOUD_MODEL_ID,
|
||||||
|
"is_legacy": True,
|
||||||
|
"recognized": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Collection management ──────────────────────────────────────────
|
# ── Collection management ──────────────────────────────────────────
|
||||||
|
|
||||||
def ensure_collection(strategy: StrategyName) -> None:
|
def ensure_collection(
|
||||||
"""Create the collection for a strategy if it doesn't already exist."""
|
strategy: StrategyName,
|
||||||
|
*,
|
||||||
|
model_id: str,
|
||||||
|
dimension: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create the Model Corpus collection for a strategy if missing."""
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = collection_name(strategy)
|
name = collection_name(strategy, model_id)
|
||||||
try:
|
try:
|
||||||
existing = [c.name for c in client.get_collections().collections]
|
existing = [c.name for c in client.get_collections().collections]
|
||||||
if name not in existing:
|
if name not in existing:
|
||||||
client.create_collection(
|
client.create_collection(
|
||||||
collection_name=name,
|
collection_name=name,
|
||||||
vectors_config=VectorParams(
|
vectors_config=VectorParams(
|
||||||
size=VECTOR_DIMENSION,
|
size=dimension,
|
||||||
distance=Distance.COSINE,
|
distance=Distance.COSINE,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logger.info("Created Qdrant collection: %s", name)
|
logger.info(
|
||||||
|
"Created Qdrant collection: %s (dim=%d, model=%s)",
|
||||||
|
name,
|
||||||
|
dimension,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise QdrantError(f"Failed to create collection '{name}': {exc}") from exc
|
raise QdrantError(f"Failed to create collection '{name}': {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def ensure_all_collections() -> None:
|
def ensure_all_collections(*, model_id: str, dimension: int) -> None:
|
||||||
"""Create collections for all five strategies."""
|
"""Create collections for all five strategies under one Embedding Model."""
|
||||||
for strategy in StrategyName:
|
for strategy in StrategyName:
|
||||||
ensure_collection(strategy)
|
ensure_collection(strategy, model_id=model_id, dimension=dimension)
|
||||||
|
|
||||||
|
|
||||||
def delete_collection(strategy: StrategyName) -> None:
|
def delete_collection(strategy: StrategyName, *, model_id: str) -> None:
|
||||||
"""Delete a strategy's collection entirely."""
|
"""Delete a strategy's collection for an Embedding Model."""
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = collection_name(strategy)
|
name = collection_name(strategy, model_id)
|
||||||
try:
|
try:
|
||||||
client.delete_collection(collection_name=name)
|
client.delete_collection(collection_name=name)
|
||||||
logger.info("Deleted Qdrant collection: %s", name)
|
logger.info("Deleted Qdrant collection: %s", name)
|
||||||
@@ -77,10 +125,10 @@ def delete_collection(strategy: StrategyName) -> None:
|
|||||||
raise QdrantError(f"Failed to delete collection '{name}': {exc}") from exc
|
raise QdrantError(f"Failed to delete collection '{name}': {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def list_collection_points(strategy: StrategyName) -> int:
|
def list_collection_points(strategy: StrategyName, *, model_id: str) -> int:
|
||||||
"""Return the number of points in a strategy's collection."""
|
"""Return the number of points in a strategy's Model Corpus collection."""
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = collection_name(strategy)
|
name = collection_name(strategy, model_id)
|
||||||
try:
|
try:
|
||||||
info = client.get_collection(collection_name=name)
|
info = client.get_collection(collection_name=name)
|
||||||
return info.points_count or 0
|
return info.points_count or 0
|
||||||
@@ -90,8 +138,13 @@ def list_collection_points(strategy: StrategyName) -> int:
|
|||||||
|
|
||||||
# ── Upsert ─────────────────────────────────────────────────────────
|
# ── Upsert ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def upsert_chunks(chunks: list[Chunk], embeddings: list[list[float]]) -> int:
|
def upsert_chunks(
|
||||||
"""Upsert chunks with their embeddings into the appropriate collection.
|
chunks: list[Chunk],
|
||||||
|
embeddings: list[list[float]],
|
||||||
|
*,
|
||||||
|
model_id: str,
|
||||||
|
) -> int:
|
||||||
|
"""Upsert chunks with their embeddings into the Model Corpus collection.
|
||||||
|
|
||||||
All chunks must share the same strategy_name (one collection per call).
|
All chunks must share the same strategy_name (one collection per call).
|
||||||
Returns the number of points upserted.
|
Returns the number of points upserted.
|
||||||
@@ -105,7 +158,7 @@ def upsert_chunks(chunks: list[Chunk], embeddings: list[list[float]]) -> int:
|
|||||||
|
|
||||||
strategy = chunks[0].strategy_name
|
strategy = chunks[0].strategy_name
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = collection_name(strategy)
|
name = collection_name(strategy, model_id)
|
||||||
|
|
||||||
points = []
|
points = []
|
||||||
for chunk, embedding in zip(chunks, embeddings):
|
for chunk, embedding in zip(chunks, embeddings):
|
||||||
@@ -133,13 +186,12 @@ def search(
|
|||||||
query_vector: list[float],
|
query_vector: list[float],
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
document_filter: str | None = None,
|
document_filter: str | None = None,
|
||||||
|
*,
|
||||||
|
model_id: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Vector similarity search in a strategy's collection.
|
"""Vector similarity search in a strategy's Model Corpus collection."""
|
||||||
|
|
||||||
Returns a list of {chunk_id, score, payload} dicts, ordered by score.
|
|
||||||
"""
|
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = collection_name(strategy)
|
name = collection_name(strategy, model_id)
|
||||||
|
|
||||||
query_filter = None
|
query_filter = None
|
||||||
if document_filter:
|
if document_filter:
|
||||||
@@ -167,24 +219,81 @@ def search(
|
|||||||
raise QdrantError(f"Search failed in '{name}': {exc}") from exc
|
raise QdrantError(f"Search failed in '{name}': {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
# ── Delete by document ─────────────────────────────────────────────
|
def get_chunks_by_indices(
|
||||||
|
strategy: StrategyName,
|
||||||
|
document_name: str,
|
||||||
|
chunk_indices: list[int],
|
||||||
|
*,
|
||||||
|
model_id: str,
|
||||||
|
) -> dict[int, dict[str, Any]]:
|
||||||
|
"""Fetch chunks by document_name + chunk_index.
|
||||||
|
|
||||||
def delete_document_chunks(strategy: StrategyName, document_name: str) -> int:
|
Returns a map of chunk_index → {chunk_id, score, payload}.
|
||||||
"""Remove all chunks for a given document from a strategy's collection.
|
Missing indices are omitted (caller skips edges).
|
||||||
|
|
||||||
Returns the number of points deleted.
|
|
||||||
"""
|
"""
|
||||||
|
if not chunk_indices:
|
||||||
|
return {}
|
||||||
|
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = collection_name(strategy)
|
name = collection_name(strategy, model_id)
|
||||||
|
unique_indices = sorted(set(chunk_indices))
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = client.scroll(
|
||||||
|
collection_name=name,
|
||||||
|
scroll_filter=Filter(
|
||||||
|
must=[
|
||||||
|
FieldCondition(
|
||||||
|
key="document_name",
|
||||||
|
match=MatchValue(value=document_name),
|
||||||
|
),
|
||||||
|
FieldCondition(
|
||||||
|
key="chunk_index",
|
||||||
|
match=MatchAny(any=unique_indices),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
limit=max(len(unique_indices), 1),
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
found: dict[int, dict[str, Any]] = {}
|
||||||
|
for point in results[0]:
|
||||||
|
payload = point.payload or {}
|
||||||
|
idx = payload.get("chunk_index")
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
found[int(idx)] = {
|
||||||
|
"chunk_id": payload.get("chunk_id", str(point.id)),
|
||||||
|
"score": None,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
except Exception as exc:
|
||||||
|
raise QdrantError(
|
||||||
|
f"Failed to fetch chunks by index from '{name}': {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── Delete by document ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def delete_document_chunks(
|
||||||
|
strategy: StrategyName,
|
||||||
|
document_name: str,
|
||||||
|
*,
|
||||||
|
model_id: str,
|
||||||
|
) -> int:
|
||||||
|
"""Remove all chunks for a given document from a strategy collection."""
|
||||||
|
client = get_qdrant_client()
|
||||||
|
name = collection_name(strategy, model_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First find matching point IDs
|
|
||||||
results = client.scroll(
|
results = client.scroll(
|
||||||
collection_name=name,
|
collection_name=name,
|
||||||
scroll_filter=Filter(
|
scroll_filter=Filter(
|
||||||
must=[FieldCondition(key="document_name", match=MatchValue(value=document_name))]
|
must=[FieldCondition(key="document_name", match=MatchValue(value=document_name))]
|
||||||
),
|
),
|
||||||
limit=10_000, # safety cap
|
limit=10_000,
|
||||||
with_payload=False,
|
with_payload=False,
|
||||||
with_vectors=False,
|
with_vectors=False,
|
||||||
)
|
)
|
||||||
@@ -196,16 +305,21 @@ def delete_document_chunks(strategy: StrategyName, document_name: str) -> int:
|
|||||||
collection_name=name,
|
collection_name=name,
|
||||||
points_selector=PointIdsList(points=point_ids),
|
points_selector=PointIdsList(points=point_ids),
|
||||||
)
|
)
|
||||||
logger.info("Deleted %d points from %s for document '%s'", len(point_ids), name, document_name)
|
logger.info(
|
||||||
|
"Deleted %d points from %s for document '%s'",
|
||||||
|
len(point_ids),
|
||||||
|
name,
|
||||||
|
document_name,
|
||||||
|
)
|
||||||
return len(point_ids)
|
return len(point_ids)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise QdrantError(f"Failed to delete from '{name}': {exc}") from exc
|
raise QdrantError(f"Failed to delete from '{name}': {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def delete_all_strategy_chunks(strategy: StrategyName) -> int:
|
def delete_all_strategy_chunks(strategy: StrategyName, *, model_id: str) -> int:
|
||||||
"""Delete all points in a strategy's collection (full wipe)."""
|
"""Delete all points in a strategy's collection (full wipe)."""
|
||||||
client = get_qdrant_client()
|
client = get_qdrant_client()
|
||||||
name = collection_name(strategy)
|
name = collection_name(strategy, model_id)
|
||||||
try:
|
try:
|
||||||
info = client.get_collection(collection_name=name)
|
info = client.get_collection(collection_name=name)
|
||||||
count = info.points_count or 0
|
count = info.points_count or 0
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS queries (
|
|||||||
question TEXT NOT NULL,
|
question TEXT NOT NULL,
|
||||||
answer TEXT NOT NULL,
|
answer TEXT NOT NULL,
|
||||||
retrieved_chunks TEXT NOT NULL DEFAULT '[]',
|
retrieved_chunks TEXT NOT NULL DEFAULT '[]',
|
||||||
|
expansion_tree TEXT NOT NULL DEFAULT '[]',
|
||||||
latency_breakdown TEXT NOT NULL DEFAULT '{}',
|
latency_breakdown TEXT NOT NULL DEFAULT '{}',
|
||||||
token_usage TEXT NOT NULL DEFAULT '{}',
|
token_usage TEXT NOT NULL DEFAULT '{}',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@@ -76,17 +77,128 @@ CREATE TABLE IF NOT EXISTS experiments (
|
|||||||
per_question TEXT NOT NULL DEFAULT '[]',
|
per_question TEXT NOT NULL DEFAULT '[]',
|
||||||
aggregate_metrics TEXT NOT NULL DEFAULT '{}',
|
aggregate_metrics TEXT NOT NULL DEFAULT '{}',
|
||||||
strategies_used TEXT NOT NULL DEFAULT '[]',
|
strategies_used TEXT NOT NULL DEFAULT '[]',
|
||||||
|
embedding_model_id TEXT,
|
||||||
|
embedding_provider TEXT,
|
||||||
|
boundary_embedding_model_id TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
result TEXT,
|
||||||
|
error TEXT,
|
||||||
|
progress TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
"""Create tables if they don't exist."""
|
"""Create tables if they don't exist and apply light migrations."""
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
conn.executescript(_SCHEMA)
|
conn.executescript(_SCHEMA)
|
||||||
|
_migrate_schema(conn)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add Embedding Model provenance columns and backfill legacy experiments."""
|
||||||
|
cols = {
|
||||||
|
row[1]
|
||||||
|
for row in conn.execute("PRAGMA table_info(experiments)").fetchall()
|
||||||
|
}
|
||||||
|
if "embedding_model_id" not in cols:
|
||||||
|
conn.execute("ALTER TABLE experiments ADD COLUMN embedding_model_id TEXT")
|
||||||
|
if "embedding_provider" not in cols:
|
||||||
|
conn.execute("ALTER TABLE experiments ADD COLUMN embedding_provider TEXT")
|
||||||
|
if "boundary_embedding_model_id" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE experiments ADD COLUMN boundary_embedding_model_id TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
dcols = {
|
||||||
|
row[1]
|
||||||
|
for row in conn.execute("PRAGMA table_info(documents)").fetchall()
|
||||||
|
}
|
||||||
|
if "last_corpus_embedding_model_id" not in dcols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE documents ADD COLUMN last_corpus_embedding_model_id TEXT"
|
||||||
|
)
|
||||||
|
if "last_boundary_embedding_model_id" not in dcols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE documents ADD COLUMN last_boundary_embedding_model_id TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
qcols = {
|
||||||
|
row[1]
|
||||||
|
for row in conn.execute("PRAGMA table_info(queries)").fetchall()
|
||||||
|
}
|
||||||
|
if "expansion_tree" not in qcols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE queries ADD COLUMN expansion_tree TEXT NOT NULL DEFAULT '[]'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy Experiments without provenance → historical OpenAI small (ADR-0019)
|
||||||
|
from src.chunking.embedding_models import LEGACY_CLOUD_MODEL_ID, Provider
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE experiments
|
||||||
|
SET embedding_model_id = ?, embedding_provider = ?
|
||||||
|
WHERE embedding_model_id IS NULL OR embedding_model_id = ''""",
|
||||||
|
(LEGACY_CLOUD_MODEL_ID, Provider.CLOUD.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
result TEXT,
|
||||||
|
error TEXT,
|
||||||
|
progress TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_setting(key: str) -> str | None:
|
||||||
|
"""Read a persisted app setting value."""
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
return row["value"] if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def set_app_setting(key: str, value: str) -> None:
|
||||||
|
"""Upsert a persisted app setting."""
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value""",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -157,6 +269,27 @@ def update_chunk_counts(doc_id: str, chunk_counts: dict[str, int]) -> None:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_process_embedding_provenance(
|
||||||
|
doc_id: str,
|
||||||
|
*,
|
||||||
|
corpus_model_id: str,
|
||||||
|
boundary_model_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record Corpus/Boundary used on the last successful process run."""
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE documents
|
||||||
|
SET last_corpus_embedding_model_id = ?,
|
||||||
|
last_boundary_embedding_model_id = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(corpus_model_id, boundary_model_id, doc_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def delete_document(doc_id: str) -> bool:
|
def delete_document(doc_id: str) -> bool:
|
||||||
"""Delete a document and its cascaded queries/experiments."""
|
"""Delete a document and its cascaded queries/experiments."""
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
@@ -178,6 +311,7 @@ def save_query(
|
|||||||
question: str,
|
question: str,
|
||||||
answer: str,
|
answer: str,
|
||||||
retrieved_chunks: list[dict] | None = None,
|
retrieved_chunks: list[dict] | None = None,
|
||||||
|
expansion_tree: list[dict] | None = None,
|
||||||
latency_breakdown: dict[str, float] | None = None,
|
latency_breakdown: dict[str, float] | None = None,
|
||||||
token_usage: dict[str, int] | None = None,
|
token_usage: dict[str, int] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -188,10 +322,11 @@ def save_query(
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO queries
|
"""INSERT INTO queries
|
||||||
(id, document_id, strategy_name, question, answer,
|
(id, document_id, strategy_name, question, answer,
|
||||||
retrieved_chunks, latency_breakdown, token_usage, created_at)
|
retrieved_chunks, expansion_tree, latency_breakdown, token_usage, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(query_id, document_id, strategy_name, question, answer,
|
(query_id, document_id, strategy_name, question, answer,
|
||||||
json.dumps(retrieved_chunks or []),
|
json.dumps(retrieved_chunks or []),
|
||||||
|
json.dumps(expansion_tree or []),
|
||||||
json.dumps(latency_breakdown or {}),
|
json.dumps(latency_breakdown or {}),
|
||||||
json.dumps(token_usage or {}),
|
json.dumps(token_usage or {}),
|
||||||
_now()),
|
_now()),
|
||||||
@@ -250,22 +385,36 @@ def save_experiment(
|
|||||||
per_question: list[dict] | None = None,
|
per_question: list[dict] | None = None,
|
||||||
aggregate_metrics: dict | None = None,
|
aggregate_metrics: dict | None = None,
|
||||||
strategies_used: list[str] | None = None,
|
strategies_used: list[str] | None = None,
|
||||||
|
embedding_model_id: str | None = None,
|
||||||
|
embedding_provider: str | None = None,
|
||||||
|
boundary_embedding_model_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Insert a benchmark experiment."""
|
"""Insert a benchmark experiment.
|
||||||
|
|
||||||
|
embedding_model_id is the Corpus Embedding Model (ADR-0024).
|
||||||
|
"""
|
||||||
|
from src.chunking.embedding_models import DEFAULT_CLOUD_MODEL_ID, Provider
|
||||||
|
|
||||||
experiment_id = experiment_id or _new_id()
|
experiment_id = experiment_id or _new_id()
|
||||||
|
model_id = embedding_model_id or DEFAULT_CLOUD_MODEL_ID
|
||||||
|
provider = embedding_provider or Provider.CLOUD.value
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO experiments
|
"""INSERT INTO experiments
|
||||||
(id, document_id, benchmark_config, questions, per_question,
|
(id, document_id, benchmark_config, questions, per_question,
|
||||||
aggregate_metrics, strategies_used, created_at)
|
aggregate_metrics, strategies_used, embedding_model_id,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
embedding_provider, boundary_embedding_model_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(experiment_id, document_id,
|
(experiment_id, document_id,
|
||||||
json.dumps(benchmark_config or {}),
|
json.dumps(benchmark_config or {}),
|
||||||
json.dumps(questions or []),
|
json.dumps(questions or []),
|
||||||
json.dumps(per_question or []),
|
json.dumps(per_question or []),
|
||||||
json.dumps(aggregate_metrics or {}),
|
json.dumps(aggregate_metrics or {}),
|
||||||
json.dumps(strategies_used or []),
|
json.dumps(strategies_used or []),
|
||||||
|
model_id,
|
||||||
|
provider,
|
||||||
|
boundary_embedding_model_id,
|
||||||
_now()),
|
_now()),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -287,9 +436,11 @@ def get_experiment(experiment_id: str) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
|
|
||||||
def list_experiments(
|
def list_experiments(
|
||||||
*, document_id: str | None = None, offset: int = 0, limit: int = 50
|
*, document_id: str | None = None, offset: int = 0, limit: int = 200
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""List experiments, optionally filtered by document."""
|
"""List experiments, optionally filtered by document."""
|
||||||
|
limit = max(1, min(int(limit), 500))
|
||||||
|
offset = max(0, int(offset))
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
if document_id:
|
if document_id:
|
||||||
@@ -322,12 +473,114 @@ def delete_experiment(experiment_id: str) -> bool:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Job CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_job(*, job_type: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Insert a pending background job."""
|
||||||
|
job_id = _new_id()
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO jobs
|
||||||
|
(id, job_type, status, payload, progress, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
job_type,
|
||||||
|
"pending",
|
||||||
|
json.dumps(payload),
|
||||||
|
json.dumps({}),
|
||||||
|
_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_job(job_id) # type: ignore[return-value]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Fetch a job by ID."""
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _row_to_dict(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_job(job_id: str, **fields: Any) -> None:
|
||||||
|
"""Update job fields (status, result, error, progress, timestamps)."""
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
|
||||||
|
json_fields = {"payload", "result", "progress"}
|
||||||
|
sets: list[str] = []
|
||||||
|
values: list[Any] = []
|
||||||
|
for key, value in fields.items():
|
||||||
|
if key in json_fields and value is not None and not isinstance(value, str):
|
||||||
|
value = json.dumps(value)
|
||||||
|
sets.append(f"{key} = ?")
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
values.append(job_id)
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE jobs SET {', '.join(sets)} WHERE id = ?",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_jobs(
|
||||||
|
*,
|
||||||
|
job_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List jobs with optional filters, newest first."""
|
||||||
|
limit = max(1, min(int(limit), 200))
|
||||||
|
offset = max(0, int(offset))
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if job_type:
|
||||||
|
clauses.append("job_type = ?")
|
||||||
|
params.append(job_type)
|
||||||
|
if status:
|
||||||
|
clauses.append("status = ?")
|
||||||
|
params.append(status)
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
total = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM jobs {where}", params
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM jobs {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
[*params, limit, offset],
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"items": [_row_to_dict(r) for r in rows],
|
||||||
|
"total": total,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ── Internal helpers ───────────────────────────────────────────────
|
# ── Internal helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
_JSON_FIELDS = {"chunk_counts", "retrieved_chunks", "latency_breakdown",
|
_JSON_FIELDS = {"chunk_counts", "retrieved_chunks", "expansion_tree", "latency_breakdown",
|
||||||
"token_usage", "document_tree", "benchmark_config",
|
"token_usage", "document_tree", "benchmark_config",
|
||||||
"questions", "per_question", "aggregate_metrics",
|
"questions", "per_question", "aggregate_metrics",
|
||||||
"strategies_used"}
|
"strategies_used", "payload", "result", "progress"}
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
|||||||