Compare commits

..

24 Commits

Author SHA1 Message Date
e1ca9f4127 docs(eval): add human evaluation report for fixed_size ±3
Why:
- Manager deliverable for human evaluation across 10 benchmark documents.

Changes:
- Report markdown, scored CSV, chart generator, and SVG assets
- Linked from docs/README.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 15:32:35 +03:30
3f9677f6aa feat(jobs): add background job support for benchmarks and processing
Why:
- Long benchmark and process runs block HTTP clients; task #20 required async execution.

Changes:
- SQLite jobs table + GET /jobs and GET /jobs/{id}
- POST /benchmarks?background=true and POST /documents/{id}/process?background=true return 202 + job_id
- FastAPI BackgroundTasks execute work in-process; sync paths unchanged

Impact:
- Jobs are lost on server restart (Option A, no external queue)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 15:32:33 +03:30
47e4846270 chore(config): sync env example and context for ±3 defaults
Why:
- d5ccec2 locked ±3 in code; env template and domain glossary were still at 0/0.

Changes:
- NEIGHBOR_PREV/NEXT=3 in .env.example
- CONTEXT.md Neighbor Expansion defaults updated to 3/3

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 15:32:29 +03:30
dc76ca67b8 docs: lock fixed_size ±3 in decision record and lld
Why:
- Close the open ±N follow-up from the strategy finalization memo.

Changes:
- Add addendum with ±0…±3 evidence and binding ±3/3 default.
- Sync LLD config table with new neighbor expansion defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 13:27:18 +03:30
d5ccec2f2c feat(config): apply fixed_size ±3 as stabilized defaults
Why:
- Final benchmark selected fixed_size with neighbor expansion ±3 as the winning Candidate.

Changes:
- Set neighbor_prev/neighbor_next defaults to 3 in Settings.
- Default Query tab to fixed_size ±3/3 and Benchmarks to single-strategy fixed_size.

Impact:
- API and Dashboard runs use ±3 unless explicitly overridden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 13:27:15 +03:30
640691b8ab docs: record fixed_size as the winning strategy family
Why:
- Close the benchmark with a written family decision backed by Experiment composites.

Changes:
- Add the decision memo and SVG charts generated from SQLite.

Impact:
- Documents fixed_size over semantic under text-embedding-3-large; ±N remains follow-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:38 +03:30
6261bc0a23 docs: add high- and low-level design
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:35 +03:30
2ac69c9944 docs: record decision board language and adr-0026
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:28 +03:30
56b8d9401a feat(dashboard): add decision board for strategy selection
Why:
- Compare is the wrong surface for two-stage family selection over the 10-doc set.

Changes:
- Add the Decision Tab; raise GET /experiments default/max so the board can load the grid client-side.

Impact:
- Operators pick fixed_size ±N vs semantic@Boundary from existing Experiments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:21 +03:30
e2c7fdc059 chore(scripts): add neighbor expansion benchmark sweep
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:13:07 +03:30
1401e46c9a docs: record embedding, neighbor, and inspect decisions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:13:07 +03:30
bdf0c36e20 feat(dashboard): add embedding controls, neighbor ui, and retrieval inspect
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:13:07 +03:30
f562b91f83 feat(benchmarking): add model-scoped query and neighbor expansion
Why:
- Queries and Experiments must hit the Corpus Embedding Model's collections and optionally widen fixed_size context.

Changes:
- Resolve corpus model per request; apply Neighbor Expansion with Expansion Tree; persist and report expansion provenance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:13:07 +03:30
4dd3125318 feat(documents): snapshot boundary and corpus models on process
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:13:07 +03:30
aa5838fadc feat(admin): add embedding model role and threshold APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:13:07 +03:30
16c918538b fix(chunking): require semantic boundary detection
Why:
- Semantic strategies were silently falling back to fixed-count grouping when boundary embeds were missing.

Changes:
- Fail hard without aligned unit embeddings; orchestrator supplies Boundary embeds; Farsi-aware sentence split with line/paragraph fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:12:45 +03:30
736391b137 feat(embedding): support boundary and corpus model roles
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:12:45 +03:30
0ff9d8dd21 feat(storage): add app settings and model-scoped collections
Why:
- Different Embedding Models must not share Qdrant collections; Admin needs persisted role defaults and Experiment provenance.

Changes:
- Scope collection names by model id; add app_settings, embedding provenance columns, and queries.expansion_tree.

Impact:
- Process/query write only Model Corpus collections; legacy unscoped names are read-only for Admin labeling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:12:45 +03:30
5fd19c12d9 feat(chunking): add embedding model registry and ollama config
Why:
- Operators need Cloud and Local Embedding Models with stable ids, dimensions, and defaults.

Changes:
- Add Embedding Model Registry; Ollama client; env defaults for model, Ollama host, and Neighbor Expansion knobs.

Impact:
- New installs default to text-embedding-3-large; OLLAMA_BASE_URL required for Local provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:12:36 +03:30
118255acdc docs: capture pdf domain language and deferred ingestion backlog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:59:42 +03:30
987b0493ac feat(reports): add answer compare modal and source format badge
Why:
- Make per-question strategy answers inspectable and show DOCX vs Text PDF source.

Changes:
- Side-by-side compare modal with HTML escaping; header source badge from filename.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:59:42 +03:30
9ce2072c23 feat(dashboard): add pdf workspace via shared format filter
Why:
- Avoid a forked PDF UI while scoping Word vs PDF workflows.

Changes:
- formatFilter on shared tabs, PDF Workspace accordion tab, word-only defaults elsewhere.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:59:42 +03:30
55907a8dec feat(documents): add text pdf ingestion with heading reconstruction
Why:
- Support Text PDFs in the same DocumentTree/markdown contract as DOCX.

Changes:
- PyMuPDF parser, text-layer gate, shared heading heuristics, upload dispatch for .pdf.

Impact:
- Scanned/image PDFs are rejected at upload; requires pymupdf installed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:59:42 +03:30
afc20200f7 build(deps): add pymupdf for text pdf extraction
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:59:42 +03:30
72 changed files with 8332 additions and 637 deletions

View File

@@ -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

View File

@@ -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
View 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 |

View 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)

View 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

View 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

View 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

View 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

View 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
View 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
View 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 &lt; 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 |

View File

@@ -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 |

View 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/`)

View 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

View 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

View 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

View 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

View 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

View 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.

View 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.

View 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.

View 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).

View 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).

View File

@@ -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:

View 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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()

View 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

View 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

View 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

View 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

View 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

View 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
1 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
2 bazresi.docx q1 این سند در مورد چیست؟ این سند شامل پرسش‌های پرتکرار از سوی مشتریان در اداره پاسخگویی و رسیدگی به شکایات شرکت بیمه آسیا است که پاسخ‌هایی درباره مهلت پرداخت خسارت، ثبت شکایت، شماره شبا و سایر موارد اداری ارائه می‌دهد. این سند در مورد پرسش‌های متداول مشتریان در اداره پاسخگویی و رسیدگی به شکایات بیمه آسیا و اطلاعات مربوط به پرداخت خسارت، نحوه دریافت بیمه‌نامه، ثبت شکایت و سایر خدمات بیمه‌ای است. 9 8 9 False 8.6 3 Partially correct; missing secondary details. partial 6fe08750ee324130b3d46d8b3bc95280
3 bazresi.docx q3 مدت زمان پرداخت خسارت بدنه چقدر است؟ طبق ماده 20 آیین نامه 53 بیمه بدنه، مدت زمان پرداخت خسارت بدنه 15 روز کاری پس از تکمیل پرونده می‌باشد. در صورت تأیید اصالت حادثه و بالا بودن مبلغ خسارت، امکان واریز علی الحساب تا 60 درصد مبلغ خسارت وجود دارد که منوط به ثبت درخواست کتبی از سوی زیاندیده در شعبه پرداخت خسارت است. مدت زمان پرداخت خسارت بدنه 15 روز کاری پس از تکمیل پرونده می باشد. 10 8 9 False 8.9 4 Aligns with expected answer; key points covered. agree 6fe08750ee324130b3d46d8b3bc95280
4 bazresi.docx q6 نحوه ثبت الحاقیه یا فسخ بیمه‌نامه بدنه مربوط به خودروهای شرکت‌های لیزینگی چگونه است؟ بیمه‌گذار (مستأجر خودرو) باید به واحد صدور یا شعبه ناظر واحد صادرکننده مراجعه نموده و با ارائه اصل بیمه‌نامه بدنه، مدارک فک رهنی، سند مالکیت و درخواست کتبی نسبت به صدور الحاقیه یا فسخ اقدام نماید. بیمه‌گذار (مستأجر خودرو) باید به واحد صدور ذکر شده در بیمه‌نامه یا شعبه ناظر واحد صادر کننده مراجعه نموده و با ارائه اصل بیمه‌نامه بدنه، مدارک فک رهنی، سند مالکیت و درخواست کتبی خود مبنی بر صدور الحاقیه یا فسخ بیمه‌نامه نسبت به این مهم اقدام نماید. در صورتی که بیمه‌گذار، ساکن استانی غیر از استان محل استقرار واحد صدور بیمه‌نامه باشد، باید با در دست داشتن مدارک فوق‌الذکر به شعبه مرکزی آن استان مراجعه و درخواست کتبی خود را ارائه نماید. 10 9 10 False 9.6 5 Aligns with expected answer; key points covered. agree 6fe08750ee324130b3d46d8b3bc95280
5 bazresi.docx q9 آیا نتیجه اعتراض به مبلغ خسارت کارشناس الزاماً منجر به افزایش مبلغ می‌شود؟ خیر. زیاندیده می‌تواند با مراجعه به رئیس شعبه خسارت، درخواست کتبی جهت اعتراض ثبت نماید. اما نتیجه اعتراض الزاماً منجر به افزایش مبلغ خسارت نمی‌شود و ممکن است پس از کارشناسی مجدد، مبلغ تأیید شود یا حتی کاهش یابد. خیر، نتیجه اعتراض الزاماً منجر به افزایش میزان مبلغ خسارت نمی‌شود و ممکن است پس از بررسی و کارشناسی مجدد، مبلغ مربوطه تأیید شود یا کاهش یابد. 9 9 10 False 9.3 5 Aligns with expected answer; key points covered. agree 6fe08750ee324130b3d46d8b3bc95280
6 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
7 bazresi.docx q14 شماره تماس واحد پاسخگویی و رسیدگی به شکایات چیست؟ در سند اطلاعات تماس مستقیم واحد پاسخگویی ذکر نشده اما از طریق سامانه‌های رسیدگی به شکایات بیمه مرکزی یا بازرسی کل کشور یا از طریق سایت بیمه آسیا قابل دسترسی است. متأسفانه، اطلاعاتی درباره شماره تماس واحد پاسخگویی و رسیدگی به شکایات در متن موجود نیست. 7 8 9 False 8.0 5 Aligns with expected answer; key points covered. partial 6fe08750ee324130b3d46d8b3bc95280
8 customer1.docx q1 این سند در مورد چیست؟ این سند شامل پرسش‌های متداول درباره استعلام و مناقصه بیمه‌ای، توسعه بازار و کد یونیک، و شرایط انعقاد تفاهم‌نامه شرکت بیمه آسیا است. این سند در مورد مناقصات بیمه‌ای و استعلامات مرتبط با آن است. اطلاعاتی درباره تعریف مناقصه، انواع مناقصات، قوانین برگزاری، و شرایط مختلف مرتبط با مناقصات بیمه‌ای ارائه می‌دهد. 8 7 8 False 7.6 4 Aligns with expected answer; key points covered. agree 8c4d888d367b4c0299b2d19dd9654f80
9 customer1.docx q4 مناقصه بیمه‌ای و استعلام بیمه‌ای چه تفاوتی با یکدیگر دارند؟ تفاوت کاربرد استعلام بها و مناقصه به میزان مبلغ معامله بستگی خواهد داشت. بر اساس ماده 3 قانون برگزاری مناقصات، معاملات کوچک و متوسط (بالاتر از ده درصد نصاب مربوط) متفاوت از معاملات بزرگ اجرا می‌شوند. تفاوت مناقصه بیمه‌ای و استعلام بیمه‌ای به میزان مبلغ معامله بستگی دارد. برای معاملات کوچک و متوسط (تا 2.850 میلیون ریال) از استعلام بها استفاده می‌شود، در حالی که برای معاملات بزرگ (بیش از 28.500 میلیون ریال) باید از طریق مناقصه عمومی یا محدود اقدام شود. 9 8 9 False 8.6 4 Aligns with expected answer; key points covered. agree 8c4d888d367b4c0299b2d19dd9654f80
10 customer1.docx q7 آیا ثبت معاملات در سامانه ستاد الزامی است؟ بله. ثبت کلیه معاملات دولت اعم از کوچک و متوسط و بزرگ در سامانه ستاد الزامی است. طبق مکاتبه معاونت حقوقی رئیس جمهور، ثبت معاملات کوچک (بالاتر از ده درصد نصاب مربوط) در سامانه تدارکات الکترونیکی الزامی است. بله، ثبت کلیه معاملات دولت اعم از کوچک و متوسط و بزرگ در سامانه ستاد الزامی است. 8 9 9 False 8.7 4 Aligns with expected answer; key points covered. partial 8c4d888d367b4c0299b2d19dd9654f80
11 customer1.docx q11 آیا می‌توان بخشی از ریسک را بین چند بیمه‌گر تقسیم کرد؟ بله، از طریق بیمه اتکایی، این روش در پروژه‌های بزرگ متداول است. بله، از طریق بیمه اتکایی، این روش در پروژه‌های بزرگ متداول است. 10 10 10 False 10.0 4 Aligns with expected answer; key points covered. partial 8c4d888d367b4c0299b2d19dd9654f80
12 customer1.docx q14 شرایط اخذ کد یونیک چیست؟ بیمه‌گذاران باید سراسری باشند (پراکندگی و فعالیت در کل کشور) و دارای چند رشته بیمه‌ای باشند. بیمه گذاران باید سراسری باشند (پراکندگی و فعالیت در کل کشور) و دارای چند رشته بیمه ای باشند. 10 10 10 False 10.0 4 Aligns with expected answer; key points covered. partial 8c4d888d367b4c0299b2d19dd9654f80
13 customer1.docx q17 برای اخذ تخفیفات و تسهیلات چه اقداماتی نیاز است؟ نامه درخواست شرکت متقاضی و نامه درخواست نماینده/شعبه. برای اخذ تخفیفات و تسهیلات، نیاز به نامه درخواست شرکت متقاضی و نامه درخواست نماینده/شعبه است. 10 10 10 False 10.0 4 Aligns with expected answer; key points covered. partial 8c4d888d367b4c0299b2d19dd9654f80
14 fire.docx q1 این سند در مورد چیست؟ این سند شامل شرایط خصوصی هر یک از پوشش‌های اضافی (خطرات اضافی) بیمه آتش‌سوزی شامل سیل، زلزله، ترکیدگی لوله آب، نشت باران، سقوط هواپیما، اعتصاب و شورش، برخورد جسم خارجی، شکست شیشه، انفجار ظروف تحت فشار، سرقت با شکست حرز و سایر خطرات است. این سند در مورد شرایط و مقررات بیمه‌نامه‌های مختلف، شامل بیمه‌های آتش‌سوزی، سرقت، خسارات ناشی از اعتصاب و شورش، و سایر خطرات مرتبط با بیمه است. همچنین به تعهدات بیمه‌گر و بیمه‌گذار، استثنائات، فرانشیزها و حداکثر تعهدات مالی اشاره دارد. 8 7 8 False 7.6 4 Aligns with expected answer; key points covered. agree 230a3cf57ae74b679ecde83cfeb9b6f5
15 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
16 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
17 fire.docx q11 شرایط بیمه سرقت با شکست حرز شامل چه مواردی است؟ شکست حرز: بالا رفتن از دیوار، شکستن درب و پنجره و شیشه، خراب کردن دیوار، ورود با تهدید. بیمه دزدي یا فقدان اشیا را تأمین می‌کند. وظایف بیمه‌گذار: اطلاع‌رسانی فوری، حفظ صحنه، ارائه مدارک. در صورت عدم انجام تعهدات، بیمه‌گر می‌تواند از پرداخت خسارت خودداری کند. فرانشیز: 10 درصد. شرایط بیمه سرقت با شکست حرز شامل موارد زیر است: 1. **تعریف سرقت**: بیمه دزدی یا فقدان اشیاء بیمه شده یا خراب شدن آنها را که در نتیجه دزدی با شکست حرز پدید آمده باشد تأمین می‌کند. 2. **موارد با شکست حرز**: - بالا رفتن از دیوار یا شکستن درب و پنجره و شیشه و خراب کردن دیوار و نظایر آن. - باز کردن درب به وسیله کلیدهای تقلبی و سایر آلاتی که برای باز کردن عادی قفل به کار نمی‌رود. (ورود به محل مورد بیمه در شب به منزله شکست حرز تلقی خواهد شد.) - ورود به محل مورد بیمه شده با تهدید بیمه‌گذار و 9 8 9 False 8.6 3 Partially correct; missing secondary details. partial 230a3cf57ae74b679ecde83cfeb9b6f5
18 fire.docx q14 پوشش خودسوزی شامل چه شرایطی است؟ حداکثر تعهد بیمه‌گر در قبال خسارت خودسوزی معادل یک درصد مبلغ موجودی بند مربوطه بیمه‌نامه است. پوشش خودسوزی شامل شرایط زیر است: 1. حداکثر تعهد بیمه‌گر در قبال خسارت خودسوزی معادل یک درصد مبلغ موجودی بیمه‌نامه است. 2. فرانشیز خسارت در هر حادثه، معادل 20 درصد مبلغ خسارت خودسوزی خواهد بود. 8 9 9 False 8.7 5 Aligns with expected answer; key points covered. agree 230a3cf57ae74b679ecde83cfeb9b6f5
19 fire.docx q17 پوشش ریزش مواد مذاب شامل چه مواردی است؟ خسارات ناشی از ریزش مواد مذاب به علت شکست، سوراخ شدن بدنه کوره یا پاشش و سرریز شدن مواد مذاب. فرانشیز: 10 درصد مبلغ خسارت قابل پرداخت. پوشش ریزش مواد مذاب شامل خسارت‌های وارده به اموال مورد بیمه که ناشی از ریزش مواد مذاب به علت شکست، سوراخ شدن بدنه کوره و یا پاشش و سرریز شدن مواد مذاب می‌باشد. همچنین خسارات ناشی از این خطر که به سایر موارد تحت پوشش خارج از کوره وارد گردد، تحت پوشش خواهد بود. 9 8 9 False 8.6 5 Aligns with expected answer; key points covered. partial 230a3cf57ae74b679ecde83cfeb9b6f5
20 general-havades-individuals.doc q1 این سند در مورد چیست؟ این سند شامل شرایط عمومی بیمه حوادث اشخاص است که بر اساس قانون بیمه مصوب 1316 و درخواست کتبی بیمه‌گذار تنظیم شده و تعاریف، خطرات بیمه‌شده، وظایف طرفین، موارد خارج از تعهد، فسخ و انفساخ را پوشش می‌دهد. این سند در مورد شرایط و مقررات بیمه حوادث اشخاص است. 8 7 9 False 7.9 4 Aligns with expected answer; key points covered. agree 7ad892cfcd6143109f77a7808c8dfbd9
21 general-havades-individuals.doc q4 تعریف حادثه در این بیمه‌نامه چیست؟ حادثه: هر واقعه ناگهانی ناشی از عامل خارجی که بدون قصد و اراده بیمه‌شده در مدت بیمه رخ دهد و منجر به جرح، نقص عضو، از کار افتادگی و یا فوت بیمه‌شده گردد. حادثه موضوع این بیمه، هر واقعه ناگهانی ناشی از عامل خارجی است که بدون قصد و اراده بیمه شده در مدت بیمه رخ دهد و منجر به جرح، نقص عضو، از کار افتادگی و یا فوت بیمه شده گردد. 10 10 10 False 10.0 4 Aligns with expected answer; key points covered. partial 7ad892cfcd6143109f77a7808c8dfbd9
22 general-havades-individuals.doc q7 قوانین پرداخت حق بیمه و عواقب عدم پرداخت قسطی در ماده ۵ چیست؟ بیمه‌نامه با تقاضای بیمه‌گذار و قبول بیمه‌گر صادر می‌شود اما شروع پوشش بیمه‌ای و اجرای تعهدات بیمه‌گر منوط به پرداخت حق بیمه است. اگر پرداخت قسطی باشد و بیمه‌گذار قسط موعد رسیده را پرداخت نکند، بیمه‌گر می‌تواند بیمه‌نامه را با رعایت ماده ۱۲ فسخ نماید. ماده 5 بیان می‌کند که بیمه نامه با تقاضای بیمه گذار و قبول بیمه گر صادر می‌شود، اما شروع پوشش بیمه‌ای و اجرای تعهدات بیمه گر منوط به پرداخت حق بیمه است. اگر پرداخت حق بیمه به صورت قسطی باشد و بیمه گذار هر یک از اقساط موعد رسیده را پرداخت نکند، بیمه گر می‌تواند بیمه نامه را فسخ کند. اگر بیمه گر بیمه نامه را فسخ نکرده باشد و حادثه‌ای رخ دهد، خسارت به نسبت حق بیمه پرداخت شده به حق بیمه‌ای که باید تا زمان وقوع حادثه پرداخت می‌شد، پرداخت خواهد شد، مگر اینکه در شرایط خصوصی بیمه نامه ضوابط دیگری درج شد 9 8 9 False 8.6 3 Partially correct; missing secondary details. partial 7ad892cfcd6143109f77a7808c8dfbd9
23 general-havades-individuals.doc q11 نحوه تعیین غرامت فوت چگونه است؟ در صورت فوت بیمه‌شده به علت خطرات مشمول، بیمه‌گر متعهد است سرمایه بیمه را طبق شرایط بیمه‌نامه، الحاقیه و یا هرگونه توافق کتبی دیگر به ذینفع بپردازد. در صورتی که بیمه شده به علت وقوع یکی از خطرات مشمول این بیمه نامه فوت کند، بیمه گر متعهد است سرمایه بیمه را طبق شرایط این بیمه نامه و الحاقیه آن یا هرگونه توافق کتبی دیگر به ذینفع پرداخت نماید. 10 10 10 False 10.0 5 Aligns with expected answer; key points covered. agree 7ad892cfcd6143109f77a7808c8dfbd9
24 general-havades-individuals.doc q14 آیا خطرات جنگ، زلزله و ورزش‌های خطرناک قابل پوشش هستند؟ خطرات بندهای ط (جنگ/شورش)، ی (زلزله/آتشفشان/هسته‌ای) و ک (ورزش‌های خطرناک) با موافقت کتبی بیمه‌گر و اخذ حق بیمه مربوط قابل پوشش است. برای پوشش جنگ: بیمه‌شده نباید در تحقق خطر مشارکت داشته باشد و بیمه‌گر می‌تواند با اخطار ۱۰ روزه پوشش را لغو کند. تبصره ۲: موسسات بیمه می‌توانند با موافقت قبلی بیمه مرکزی خطرات اضافی دیگر را بیمه نمایند. خیر، خطرات جنگ، زلزله و ورزش‌های خطرناک تحت پوشش بیمه نیستند، مگر اینکه با موافقت کتبی بیمه‌گر و اخذ حق بیمه مربوطه پوشش داده شوند. 8 7 6 False 7.0 2 Answer incomplete compared to expected reference. disagree 7ad892cfcd6143109f77a7808c8dfbd9
25 general-havades-individuals.doc q17 انفساخ بیمه‌نامه (ماده ۱۳) چه زمانی رخ می‌دهد و حق بیمه چگونه محاسبه می‌شود؟ در صورت فوت بیمه‌شده به علت خطر تحت پوشش نباشد، بیمه‌نامه از زمان فوت منفسخ می‌شود. حق بیمه مدت مقتضی شده بر اساس تعرفه روز شمار محاسبه می‌گردد. تبصره: در قراردادها agrupadas، پوشش فقط برای بیمه‌شده متوفی لغو می‌شود. انفساخ بیمه‌نامه در صورت فوت بیمه شده به علت تحقق خطری که تحت پوشش این بیمه‌نامه نباشد، رخ می‌دهد. در این صورت، حق بیمه مدت مقتضی شده بر اساس تعرفه روز شمار محاسبه می‌شود. 9 9 9 False 9.0 5 Aligns with expected answer; key points covered. agree 7ad892cfcd6143109f77a7808c8dfbd9
26 havades.docx q1 این سند در مورد چیست؟ این سند شامل معرفی رشته‌های مختلف بیمه حوادث شرکت بیمه آسیا شامل حوادث انفرادی، گروهی، گردشگری، ورزشی، اصناف، دانشجویی و غیره است. این سند در مورد بیمه حوادث است و شامل توضیحات مربوط به انواع بیمه‌های حوادث، پوشش‌های بیمه‌ای، شرایط و هزینه‌ها، و نحوه درخواست بیمه‌نامه‌ها می‌باشد. 9 8 9 False 8.6 4 Aligns with expected answer; key points covered. agree 04bd45d836a64c3da2e68bb747c0c9b8
27 havades.docx q4 حوادث انفرادی عادی چه پوششی ارائه می‌دهد؟ در صورت فوت، نقص عضو یا از کارافتادگی دائم بر اثر حادثه و یا پرداخت هزینه درمان ناشی از حادثه، سرمایه بیمه به ذینفع تعیین‌شده پرداخت می‌گردد. حوادث انفرادی عادی پوشش‌هایی برای فوت، نقص عضو و از کارافتادگی دائم کلی و جزئی بر اثر حادثه ارائه می‌دهد. در صورت فوت، سرمایه بیمه منتخب به ذینفع یا وراث قانونی پرداخت می‌شود. در صورت از کارافتادگی دائم کلی، کل سرمایه مندرج در بیمه نامه به شخص بیمه شده پرداخت می‌گردد و در صورت از کارافتادگی دائم جزئی، درصدی از مبلغ سرمایه به شخص بیمه شده پرداخت می‌شود. 9 9 9 False 9.0 5 Aligns with expected answer; key points covered. agree 04bd45d836a64c3da2e68bb747c0c9b8
28 havades.docx q7 بیمه حوادث گردشگری شامل چه طرح‌هایی است و مناسب چه کسانی است؟ گردشگران فردی یا در قالب تورها برای سفرهای سیاحتی، زیارتی، تجاری. همچنین بیمه حوادث عتبات عالیات برای کاروان‌ها و آژانس‌های مسافرتی با طرح‌های 10، 15 و 30 روزه. بیمه حوادث گردشگری شامل طرح‌هایی برای کلیه گردشگران است که به صورت فردی یا در قالب تورهای مسافرتی عازم سفرهای سیاحتی، زیارتی، تجارتی و... هستند. این بیمه به مسافران و خانواده‌هایشان آرامش را در طول سفر هدیه می‌دهد و مسئولین آژانس‌های مسافرتی نیز می‌توانند با انعقاد قراردادهای حوادث گروهی کوتاه مدت از تخفیفات گروهی بهره‌مند شوند. 9 8 9 False 8.6 3 Partially correct; missing secondary details. partial 04bd45d836a64c3da2e68bb747c0c9b8
29 havades.docx q11 بیمه حوادث ورزشی برای چه کسانی قابل استفاده است؟ ورزشکاران حرفه‌ای و یا غیرحرفه‌ای با توجه به نوع فعالیت ورزشی در برابر حوادث منجر به فوت، نقص عضو یا هزینه پزشکی به صورت گروهی و یا انفرادی تحت پوشش قرار می‌گیرند. بیمه حوادث ورزشی برای ورزشکاران حرفه‌ای و غیر حرفه‌ای قابل استفاده است. 10 8 10 False 9.2 3 Partially correct; missing secondary details. partial 04bd45d836a64c3da2e68bb747c0c9b8
30 havades.docx q14 مدت زمان بیمه‌نامه حوادث چقدر است؟ جز در مواردی که در بیمه‌نامه حوادث به نحو دیگری توافق شده باشد، مدت این نوع بیمه‌نامه یک سال شمسی است. مدت زمان بیمه‌نامه حوادث یکسال شمسی است، جز در مواردی که در بیمه نامه به نحو دیگری توافق شده باشد. 10 10 10 False 10.0 4 Aligns with expected answer; key points covered. partial 04bd45d836a64c3da2e68bb747c0c9b8
31 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
32 life-time-individual.docx q1 این سند در مورد چیست؟ این سند شامل انواع بیمه‌نامه عمر زمانی شامل: بیمه عمر زمانی انفرادی، بیمه عمر زمانی گروهی، بیمه عمر زمانی مانده بدهکار انفرادی، و بیمه عمر زمانی مانده بدهکار گروهی، به همراه راهنمای صدور است. این سند در مورد انواع بیمه نامه‌های عمر زمانی است، شامل بیمه عمر زمانی انفرادی، گروهی، و مانده بدهکار. 9 8 9 False 8.6 3 Partially correct; missing secondary details. partial d682706f54824469b235c099bf5a3d3c
33 life-time-individual.docx q4 در صورت فوت بیمه‌شده در بیمه عمر زمانی انفرادی، سرمایه چگونه پرداخت می‌شود؟ در صورتی که بیمه‌شده در طول مدت بیمه‌نامه به هر علت فوت نماید، سرمایه انتخابی توسط شرکت بیمه به ذینفع تعیین‌شده پرداخت می‌گردد. در صورت فوت بیمه‌شده در بیمه عمر زمانی انفرادی، سرمایه انتخابی توسط شرکت بیمه به ذینفع تعیین شده پرداخت می‌گردد. 10 10 10 False 10.0 5 Aligns with expected answer; key points covered. agree d682706f54824469b235c099bf5a3d3c
34 life-time-individual.docx q7 مبنای تعیین نرخ در بیمه عمر زمانی مانده بدهکار انفرادی چه مواردی است؟ مبنای تعیین نرخ در این نوع بیمه‌نامه‌ها: سن بیمه‌شده، مدت بازپرداخت وام، و وضعیت سلامتی بیمه‌شده در زمان اخذ بیمه‌نامه می‌باشد. مبنای تعیین نرخ در بیمه عمر زمانی مانده بدهکار انفرادی شامل سن بیمه شده، مدت بازپرداخت وام و وضعیت سلامتی بیمه شده در زمان اخذ بیمه نامه می‌باشد. 10 10 10 False 10.0 4 Aligns with expected answer; key points covered. partial d682706f54824469b235c099bf5a3d3c
35 life-time-individual.docx q11 تفاوت بیمه عمر زمانی مانده بدهکار انفرادی و گروهی چیست؟ در نوع انفرادی، خود فرد وام‌گیرنده بیمه می‌شود. در نوع گروهی، بانک‌ها/موسسات مالی/سازمان‌ها برای مجموعه وام‌گیرندگان خود به صورت گروهی بیمه می‌کنند. در هر دو صورت، در صورت فوت وام‌گیرنده، مابقی اقساط توسط شرکت بیمه پرداخت می‌شود. تفاوت بیمه عمر زمانی مانده بدهکار انفرادی و گروهی در این است که: - **بیمه عمر زمانی مانده بدهکار انفرادی**: این نوع بیمه برای افرادی است که به صورت انفرادی وام دریافت کرده‌اند و در صورت فوت قبل از تسویه کامل وام، مابقی اقساط توسط شرکت بیمه به بانک پرداخت می‌شود. - **بیمه عمر زمانی مانده بدهکار گروهی**: این نوع بیمه برای گروهی از افراد است که از یک بانک یا موسسه مالی وام دریافت کرده‌اند. در صورت فوت هر یک از وام‌گیرندگان در طول مدت بازپرداخت وام، مابقی اقساط به صورت یکجا توسط شرکت بیمه پرداخت م 9 9 9 False 9.0 5 Aligns with expected answer; key points covered. agree d682706f54824469b235c099bf5a3d3c
36 life-time-individual.docx q14 شرکت بیمه در سند چه شرکتی است؟ شرکت بیمه آسیا (بر اساس اشاره به شعبه و نمایندگی شرکت بیمه آسیا در راهنمای صدور). شرکت بیمه آسیا. 8 10 10 False 9.4 5 Aligns with expected answer; key points covered. agree d682706f54824469b235c099bf5a3d3c
37 life-time-individual.docx q17 آیا بیمه عمر زمانی مانده بدهکار فقط فوت ناشی از حادثه را پوشش می‌دهد؟ خیر، پوشش شامل فوت بر اثر حادثه و یا بیماری (فوت به هر علت) می‌باشد. خیر، بیمه عمر زمانی مانده بدهکار فوت ناشی از هر علت (بیماری و یا حادثه) را پوشش می‌دهد. 9 9 10 False 9.3 4 Aligns with expected answer; key points covered. partial d682706f54824469b235c099bf5a3d3c
38 moavenin.docx q1 این سند در مورد چیست؟ این سند فهرست مدیرعامل، معاونین و مدیران شرکت بیمه آسیا شامل نام، سمت، شماره تماس و فکس آنها در گروه‌های مختلف سازمانی است. این سند شامل اطلاعات تماس و سمت‌های مختلف افراد در یک سازمان است. اطلاعات شامل نام، سمت، شماره تماس و فاکس افراد مختلف در بخش‌های مختلف سازمان می‌باشد. 8 9 9 False 8.7 5 Aligns with expected answer; key points covered. agree 63f81cf0a632418aab2f93884518ed71
39 moavenin.docx q4 مدیر بیمه‌های باربری کیست؟ آقای علیرضا آقاجانی منجیلی مدیر بیمه‌های باربری است. مدیر بیمه‌های باربری آقای علیرضا آقاجانی منجیلی است. 10 10 10 False 10.0 5 Aligns with expected answer; key points covered. agree 63f81cf0a632418aab2f93884518ed71
40 moavenin.docx q7 معاون فنی بیمه‌های اشخاص و مسئولیت کیست؟ آقای فرامرز خجیر معاون فنی بیمه‌های اشخاص و مسئولیت است. معاون فنی بیمه‌های اشخاص و مسئولیت آقای فرامرز خجیر است. 10 9 10 False 9.6 5 Aligns with expected answer; key points covered. agree 63f81cf0a632418aab2f93884518ed71
41 moavenin.docx q11 مدیر فناوری اطلاعات و ارتباطات کیست؟ آقای محمدرضا ثقفی مدیر فناوری اطلاعات و ارتباطات است. مدیر فناوری اطلاعات و ارتباطات آقای محمدرضا ثقفی است. 10 9 10 False 9.6 4 Aligns with expected answer; key points covered. partial 63f81cf0a632418aab2f93884518ed71
42 moavenin.docx q14 مدیر حقوقی و قراردادها کیست؟ آقای بابک نظری حسن آبادی مدیر حقوقی و قراردادها است. مدیر حقوقی و قراردادها آقای بابک نظری حسن آبادی است. 10 10 10 False 10.0 5 Aligns with expected answer; key points covered. agree 63f81cf0a632418aab2f93884518ed71
43 moavenin.docx q17 آیا آقای محمود مهربان بیش از یک سمت دارد؟ بله، آقای محمود مهربان هم مدیر بیمه‌های بدنه اتومبیل و هم مدیر بیمه‌های شخص ثالث و حوادث راننده اتومبیل است. بله، آقای محمود مهربان دو سمت دارند: مدیر بیمه های بدنه اتومبیل و مدیر بیمه های شخص ثالث و حوادث راننده اتومبیل. 10 10 10 False 10.0 5 Aligns with expected answer; key points covered. agree 63f81cf0a632418aab2f93884518ed71
44 Refah.docx q1 این سند در مورد چیست؟ این سند شامل پرسش‌های پرتکرار اداره رفاه (مدیریت منابع انسانی) شرکت بیمه آسیا درباره وام‌ها، مهمانپذیر و قراردادهای درمانی کارکنان است. این سند در مورد سوالات متداول و پاسخ‌های مربوط به اداره رفاه و مدیریت منابع انسانی است، شامل اطلاعاتی درباره پرداخت وام، شرایط دریافت وام، نحوه ثبت درخواست مهمانپذیر، و مدارک لازم برای تمدید قراردادهای درمان. 9 8 9 False 8.6 4 Aligns with expected answer; key points covered. agree 6304dba39f3f4c05935195c588a36a64
45 Refah.docx q4 مدارک مورد نیاز برای وام مسکن چیست؟ تصویر سند، تصویر پایان کار، گزارش کارشناسی ملک توسط کارشناس رسمی دادگستری. در صورت عدم انتقال سند: تصویر سند به نام فروشنده، مبایعه‌نامه خرید، پایان کار، گزارش کارشناسی ملک. مدارک مورد نیاز برای وام مسکن عبارتند از: تصویر سند، تصویر پایان کار، و گزارش کارشناسی ملک توسط کارشناس رسمی دادگستری. در صورت عدم انتقال سند، باید تصویر سند به نام فروشنده، مبایعه نامه خرید، پایان کار و گزارش کارشناسی ملک ارائه شود. 9 9 10 False 9.3 5 Aligns with expected answer; key points covered. agree 6304dba39f3f4c05935195c588a36a64
46 Refah.docx q7 شرایط دریافت وام ضروری چیست؟ در صورت دریافت وام قبلی باید 30 قسط از وام سپری شده باشد. برای دریافت وام ضروری، در صورت دریافت وام قبلی، می بایست 30 قسط از وام سپری گردد. 9 10 10 False 9.7 5 Aligns with expected answer; key points covered. agree 6304dba39f3f4c05935195c588a36a64
47 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
48 Refah.docx q14 معرفی‌نامه مهمانپذیر چه زمانی صادر می‌گردد؟ یک هفته قبل از تاریخ سفر. معرفی‌نامه مهمانپذیر یک هفته قبل از تاریخ سفر صادر می‌گردد. 10 10 10 False 10.0 5 Aligns with expected answer; key points covered. agree 6304dba39f3f4c05935195c588a36a64
49 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
50 website.docx q1 این سند در مورد چیست؟ این سند شامل اطلاعات کامل وب‌سایت و خدمات شرکت بیمه آسیا شامل آدرس‌های سایت، اپلیکیشن یاس، مدیریت، محصولات، تاریخچه، شعب، نمایندگان، مراکز پرداخت خسارت، و اطلاعات تماس است. این سند در مورد خدمات و اطلاعات شرکت بیمه آسیا، شامل آدرس وب‌سایت‌ها، اپلیکیشن یاس، محصولات بیمه‌ای، تاریخچه شرکت، و اطلاعات تماس و ساعت کاری آن است. 9 8 9 False 8.6 5 Aligns with expected answer; key points covered. partial fb9d47750ca74761a3c82c57aa51030b
51 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
52 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
53 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
54 website.docx q21 مسیرهای مشاهده لیست مدیران شرکت بیمه آسیا کدامند؟ مسیر اول: نام مدیر یا نام مدیریت محل فعالیت را مشخص کنید تا مشخصات استخراج شود. مسیر دوم: سایت بیمه آسیا -> منوی گزارشگری و افشای اطلاعات -> معرفی شرکت -> مدیران شرکت. مسیرهای مشاهده لیست مدیران شرکت بیمه آسیا به شرح زیر است: 1. سایت بیمه آسیا (www.bimehasia.ir) -- منوی گزارشگری و افشای اطلاعات -- معرفی شرکت -- مدیران شرکت 2. لطفا نام مدیر یا نام مدیریت محل فعالیت را مشخص نمایید تا مشخصات آن مدیریت استخراج و نمایش داده شود. 9 8 9 False 8.6 3 Partially correct; missing secondary details. partial fb9d47750ca74761a3c82c57aa51030b
55 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
56 lifetime-compensation.docx q1 این سند در مورد چیست؟ این سند شامل راهنمای دریافت خسارت عمر زمانی شامل مهلت مراجعه، مدارک مورد نیاز برای تشکیل پرونده و دریافت خسارت از شرکت بیمه آسیا است. این سند در مورد راهنمای دریافت خسارت عمر زمانی در صورت فوت بیمه شدگان است. 9 8 9 False 8.6 4 Aligns with expected answer; key points covered. agree 99736ffbc6da455b8b3ca3a0fb8e5ef7
57 lifetime-compensation.docx q3 ذینفعان برای اعلام خسارت باید به کجا مراجعه کنند؟ مراجعه به نزدیک‌ترین شعبه بیمه آسیا و یا ارسال درخواست کتبی. ذینفعان باید به نزدیک ترین شعبه بیمه آسیا مراجعه کنند. 10 9 10 False 9.6 5 Aligns with expected answer; key points covered. agree 99736ffbc6da455b8b3ca3a0fb8e5ef7
58 lifetime-compensation.docx q6 در صورت فوت بیمه‌شده به علت حادثه، چه مدارک اضافه‌ای لازم است؟ گزارش مشروح حادثه مبنی بر علت، چگونگی، مکان و زمان وقوع خسارت منجر به فوت که به تأیید مراجع ذیربط رسیده باشد. در صورت فوت بیمه‌شده به علت حادثه، لازم است گزارشی مشروح از حادثه مبنی بر علت، چگونگی، مکان و زمان وقوع خسارت که به تأیید مراجع ذیربط رسیده باشد، ارائه شود. 10 10 10 False 10.0 4 Aligns with expected answer; key points covered. partial 99736ffbc6da455b8b3ca3a0fb8e5ef7
59 lifetime-compensation.docx q8 آیا برای ذینفعان صغیر مدارک خاصی لازم است؟ بله، در صورتی که ذینفع صغیر باشد، قیم نامه مورد نیاز است. بله، برای ذینفعان صغیر لازم است که قیم نامه ارائه شود. 10 10 10 False 10.0 5 Aligns with expected answer; key points covered. agree 99736ffbc6da455b8b3ca3a0fb8e5ef7
60 lifetime-compensation.docx q11 آیا ذینفعان برای اعلام خسارت حتماً باید حضوری مراجعه کنند؟ خیر، علاوه بر مراجعه حضوری به نزدیک‌ترین شعبه، امکان ارسال درخواست کتبی نیز وجود دارد. خیر، ذینفعان می‌توانند علاوه بر مراجعه حضوری، با ارسال درخواست کتبی نیز خسارت را اعلام کنند. 9 9 9 False 9.0 5 Aligns with expected answer; key points covered. agree 99736ffbc6da455b8b3ca3a0fb8e5ef7
61 lifetime-compensation.docx q13 آیا مدارک مورد نیاز برای قراردادهای گروهی و انفرادی یکسان است؟ خیر، برای قراردادهای گروهی مدارک اضافی شامل آخرین حکم کارگزینی و فیش حقوقی یا لیست تأمین اجتماعی ممهور به مهر بیمه‌گذار نیز مورد نیاز است. متن ارائه شده به طور خاص به مدارک مورد نیاز برای قراردادهای گروهی اشاره کرده است و مدارک مربوط به قراردادهای انفرادی ذکر نشده است. بنابراین، نمی‌توان به طور قطع گفت که مدارک مورد نیاز برای قراردادهای گروهی و انفرادی یکسان است. 8 5 7 False 6.5 3 Partially correct; missing secondary details. agree 99736ffbc6da455b8b3ca3a0fb8e5ef7

View 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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",
"",
"![Mean human score by document](assets/human-eval/mean-human-by-document.svg)",
"",
"## 2. Human vs LLM-as-Judge agreement",
"",
"![Agreement breakdown](assets/human-eval/human-llm-agreement.svg)",
"",
"Agreement = same quality tier when mapping LLM answer similarity (1–10) to human scale (1–5).",
"",
"## 3. Human score vs LLM similarity by document",
"",
"![Human vs LLM by document](assets/human-eval/human-vs-llm-by-document.svg)",
"",
"## 4. Human score distribution",
"",
"![Score distribution](assets/human-eval/human-score-distribution.svg)",
"",
"## 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()

View 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

View 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

View 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

View 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

View 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`.
![Stage 1 mean composite by Strategy Candidate](assets/decision/stage1-mean-composite.svg)
| 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**.
![Mean judge metrics for stage 2 winners](assets/decision/stage2-metrics.svg)
---
## 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**.
![Per-document composite: fixed_size ±3 vs semantic @ large](assets/decision/stage2-per-document.svg)
![Signed margin per document (fixed_size − semantic)](assets/decision/stage2-margins.svg)
| 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.
![Heatmap of composite scores by document and Candidate](assets/decision/heatmap-candidates.svg)
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`.

View 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
![Mean human score by document](assets/human-eval/mean-human-by-document.svg)
## 2. Human vs LLM-as-Judge agreement
![Agreement breakdown](assets/human-eval/human-llm-agreement.svg)
Agreement = same quality tier when mapping LLM answer similarity (1–10) to human scale (1–5).
## 3. Human score vs LLM similarity by document
![Human vs LLM by document](assets/human-eval/human-vs-llm-by-document.svg)
## 4. Human score distribution
![Score distribution](assets/human-eval/human-score-distribution.svg)
## 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
```

View File

@@ -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`).

View File

@@ -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

View File

@@ -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",

View 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())

View File

@@ -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")

View File

@@ -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 ───────────────────────────────────────

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"],

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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" &middot; 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 &middot; {config.get('num_questions', 0)} questions &middot;
{len(strategies)} strategies &middot; {len(strategies)} strategies &middot;
${estimated_cost:.4f} cost top_k={config.get('top_k', 5)} &middot;
neighbors={config.get('neighbor_prev', 0)}/{config.get('neighbor_next', 0)} &middot;
${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" &middot; 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]}... &middot; Experiment: {experiment.get('id', 'N/A')[:16]}... &middot;
{total_questions} questions &middot; {total_questions} questions &middot;
{len(strategies)} strategies {len(strategies)} strategies &middot;
top_k={top_k} &middot;
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">&#128269; 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">&times;</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 &middot; {experiment.get('created_at', 'N/A')} Generated by RAG Chunking Benchmarker &middot; {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>{'&#10003;' if not scores.get('hallucination', False) else '&#10007;'}</td> <td>{'&#10003;' if not scores.get('hallucination', False) else '&#10007;'}</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:

View File

@@ -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

View File

@@ -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 ─────────────────────────────────────────────────

View File

@@ -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]

View 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]

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View File

@@ -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."""

View 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

View File

@@ -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):

View File

@@ -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
View 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()

View File

@@ -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)

View File

@@ -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
View File

@@ -0,0 +1 @@
"""Background job tracking for long-running benchmark and process operations."""

48
src/jobs/models.py Normal file
View 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
View 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
View 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"},
)

View File

@@ -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")

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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]: