Spaces:
Running
Running
Commit ·
03bed0b
1
Parent(s): b0af996
Upgrade RAGForge to v1.7 evaluation correctness and adaptive efficiency
Browse files- README.md +16 -1
- SECURITY.md +4 -0
- docs/ARCHITECTURE_API.md +9 -1
- docs/EVALUATION.md +10 -1
- docs/FEATURE_MATRIX.md +5 -1
- docs/MIGRATION_1.7.md +19 -0
- docs/QUERY_PLANNING.md +4 -0
- docs/RESUME_BULLETS.md +2 -0
- docs/SOURCES.md +4 -0
- docs/UX_LIFECYCLE.md +8 -0
- docs/architecture.mmd +2 -0
- evals/README.md +4 -0
- evals/demo_benchmark.json +2 -2
- pyproject.toml +1 -1
- src/ragforge/__init__.py +1 -1
- src/ragforge/api.py +4 -2
- src/ragforge/citations.py +82 -30
- src/ragforge/eval_metrics.py +90 -5
- src/ragforge/evaluation.py +94 -6
- src/ragforge/pipeline.py +42 -6
- src/ragforge/ui.py +96 -46
- src/ragforge/workspace.py +11 -0
- tests/test_citations.py +19 -0
- tests/test_evaluation_assets.py +3 -3
- tests/test_ui_copy.py +2 -2
- tests/test_v16_features.py +1 -1
- tests/test_v17_features.py +86 -0
README.md
CHANGED
|
@@ -10,12 +10,27 @@ pinned: false
|
|
| 10 |
|
| 11 |
# RAGForge
|
| 12 |
|
| 13 |
-
**RAGForge v1.
|
| 14 |
|
| 15 |
RAGForge combines hybrid document retrieval, source-level/hierarchical retrieval, semantic query planning, corrective RAG, Self-RAG-style verification, Text2SQL and an “Ask-the-Web” research path in one CPU-friendly application. The default LLM is **Google Gemini 3.5 Flash-Lite**; the UI also exposes Gemini 3.1 Flash-Lite and stronger Flash models.
|
| 16 |
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
## What is new in v1.6
|
| 20 |
|
| 21 |
v1.6 moves RAGForge from a mostly saturated demo benchmark into harder evaluation and analytical synthesis. The goal is to make the next improvements measurable rather than simply adding more RAG components.
|
|
|
|
| 10 |
|
| 11 |
# RAGForge
|
| 12 |
|
| 13 |
+
**RAGForge v1.7 - a production-style, portfolio-ready agentic RAG and analytical synthesis system for Hugging Face Spaces.**
|
| 14 |
|
| 15 |
RAGForge combines hybrid document retrieval, source-level/hierarchical retrieval, semantic query planning, corrective RAG, Self-RAG-style verification, Text2SQL and an “Ask-the-Web” research path in one CPU-friendly application. The default LLM is **Google Gemini 3.5 Flash-Lite**; the UI also exposes Gemini 3.1 Flash-Lite and stronger Flash models.
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
+
## What is new in v1.7
|
| 20 |
+
|
| 21 |
+
v1.7 is an evidence-driven correctness, provenance and efficiency release based on the first full v1.6 Hard Mode/profile benchmark. It fixes evaluator/UI artifacts before changing retrieval behavior and turns measured profile/reranker results into explicit runtime policy.
|
| 22 |
+
|
| 23 |
+
- **Markdown-safe source cards** - retrieved Markdown is rendered as escaped plain text inside uniform source cards, so a snippet beginning with `#` can no longer become a giant UI heading.
|
| 24 |
+
- **Graphical latency waterfall** - Pipeline Inspector replaces ASCII `#####` bars with proportional HTML latency bars and exposes the reranker decision reason plus grounded-absence state.
|
| 25 |
+
- **Grounded absence handling** - answers such as “the retrieved policy does not mention a dispute fee” are treated as calibrated no-answer responses instead of low-confidence hallucinations. This avoids unnecessary revise calls and gives Hard Mode a robust missing-answer matcher.
|
| 26 |
+
- **Markdown-aware citation coverage** - numbered/bulleted claims count even when short, while generic list introductions/headings do not. This fixes false 0% coverage for answers such as the four NIST AI RMF functions.
|
| 27 |
+
- **Table-source validity for overviews** - global corpus overviews surface deterministic DuckDB table evidence as `[T#]` sources, so structured claims can be cited validly instead of referring to a table ID absent from the source list.
|
| 28 |
+
- **Fresh-vs-saved evaluation provenance** - each saved report gets a run ID and server-boot ID. Fresh execution messages are no longer overwritten by the saved-run selector, and saved-run loading is triggered only by explicit user input.
|
| 29 |
+
- **Profile-policy summary** - optional Fast/Balanced/Agentic benchmarking now produces aggregated profile metrics and an evidence-scoped recommendation rather than only six raw rows.
|
| 30 |
+
- **Context-efficiency diagnostics** - evaluation now calls out the case where Recall@5 is excellent but Precision@5 is low, separating “found the right source” from “sent too many distractors to generation”.
|
| 31 |
+
- **Small-corpus reranker policy tightened** - because v1.6 showed identical source and chunk metrics with/without reranking while adding multi-second latency, the cross-encoder is skipped even in Agentic on small corpora. It remains available for larger corpora.
|
| 32 |
+
- **Diagnostic readability** - each `Next:` recommendation starts on its own line.
|
| 33 |
+
|
| 34 |
## What is new in v1.6
|
| 35 |
|
| 36 |
v1.6 moves RAGForge from a mostly saturated demo benchmark into harder evaluation and analytical synthesis. The goal is to make the next improvements measurable rather than simply adding more RAG components.
|
SECURITY.md
CHANGED
|
@@ -50,3 +50,7 @@ sanitized from a fixed UI table label and evaluation depth; user-supplied paths
|
|
| 50 |
- The existing Text2SQL path still validates model-authored SQL as a single read-only `SELECT`/CTE before execution.
|
| 51 |
- Semantic citation attribution uses the already-loaded local embedding model only as a high-threshold fallback. It can attach a citation label, but it cannot change source text, execute instructions, or grant retrieved content instruction priority.
|
| 52 |
- Hard Mode includes an explicit prompt-injection detector case so regressions in stored-instruction detection remain visible in the evaluation report.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
- The existing Text2SQL path still validates model-authored SQL as a single read-only `SELECT`/CTE before execution.
|
| 51 |
- Semantic citation attribution uses the already-loaded local embedding model only as a high-threshold fallback. It can attach a citation label, but it cannot change source text, execute instructions, or grant retrieved content instruction priority.
|
| 52 |
- Hard Mode includes an explicit prompt-injection detector case so regressions in stored-instruction detection remain visible in the evaluation report.
|
| 53 |
+
|
| 54 |
+
## Evaluation provenance (v1.7)
|
| 55 |
+
|
| 56 |
+
Saved evaluation metadata contains only operational provenance (short run ID, server-boot ID, model, benchmark version, corpus version and timestamp). It does not add user document content to browser storage. The opaque browser session ID remains the only client-persisted workspace identifier.
|
docs/ARCHITECTURE_API.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# Architecture and API - v1.
|
| 2 |
|
| 3 |
## Runtime architecture
|
| 4 |
|
|
@@ -130,3 +130,11 @@ new network API: it materializes CSV/TSV/Markdown files inside the current ephem
|
|
| 130 |
## v1.6 evaluation observability
|
| 131 |
|
| 132 |
The UI exposes Hard Mode, optional profile comparison, node-latency summaries and timestamped evaluation history. `GET /api/v1/evaluation/history/{session_id}` exposes the same archived-run metadata to API clients. The normal latest-run endpoints remain unchanged.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture and API - v1.7
|
| 2 |
|
| 3 |
## Runtime architecture
|
| 4 |
|
|
|
|
| 130 |
## v1.6 evaluation observability
|
| 131 |
|
| 132 |
The UI exposes Hard Mode, optional profile comparison, node-latency summaries and timestamped evaluation history. `GET /api/v1/evaluation/history/{session_id}` exposes the same archived-run metadata to API clients. The normal latest-run endpoints remain unchanged.
|
| 133 |
+
|
| 134 |
+
## v1.7 runtime provenance and calibrated absence
|
| 135 |
+
|
| 136 |
+
Evaluation cache metadata includes a short run ID and server-boot ID. These fields make it possible to distinguish a fresh benchmark from a saved report in the UI/API without relying on ambiguous status text. `GET /api/v1/evaluation/saved/{session_id}` includes this provenance in its inventory.
|
| 137 |
+
|
| 138 |
+
The verify path now recognizes a grounded absence answer: an evidence-cited statement that the requested fact is not present in the selected sources. This state skips the normal low-confidence revise branch, preventing a second generation call whose only purpose would be to restate the same absence.
|
| 139 |
+
|
| 140 |
+
For corpus overviews, structured tables are surfaced as deterministic `[T#]` evidence alongside the source-balanced document set. Analytical synthesis continues to use the same table evidence path.
|
docs/EVALUATION.md
CHANGED
|
@@ -1,4 +1,13 @@
|
|
| 1 |
-
# RAGForge evaluation - v1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
## v1.6 hard-mode and analytical evaluation
|
| 4 |
|
|
|
|
| 1 |
+
# RAGForge evaluation - v1.7
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
## v1.7 evaluator correctness and provenance
|
| 5 |
+
|
| 6 |
+
v1.7 changes benchmark semantics where the v1.6 report exposed evaluator artifacts rather than RAG failures. Citation coverage is Markdown-aware: short numbered/bulleted factual items count, headings and generic list introductions do not. Missing-answer cases use a grounded-absence matcher that accepts natural uncertainty language instead of a single canned phrase. Global overviews include table evidence so `[T#]` citations are part of the actual returned source set.
|
| 7 |
+
|
| 8 |
+
Saved reports now carry a `run_id` and `server_boot_id`. A fresh run is reported as fresh, while loading/reusing a saved run explicitly reports zero new Gemini requests. Programmatic updates to the saved-run selector no longer trigger a second UI callback that can overwrite fresh-run status.
|
| 9 |
+
|
| 10 |
+
Profile benchmarking also emits `profile_summary` rows (accuracy, citation quality, median latency, LLM-call count and reranker rate) plus an evidence-scoped profile recommendation. Evaluation diagnostics additionally surface context-efficiency when recall is high but source precision is low, and identify the dominant node-latency bottleneck.
|
| 11 |
|
| 12 |
## v1.6 hard-mode and analytical evaluation
|
| 13 |
|
docs/FEATURE_MATRIX.md
CHANGED
|
@@ -39,7 +39,7 @@
|
|
| 39 |
| Caching | TTL result cache keyed by session/config/corpus version | latency/quota reduction without stale cross-corpus answers |
|
| 40 |
| Rate limiting | sliding-window per IP | protects a public shared model key |
|
| 41 |
| Observability | Prometheus + semantic plan/evidence/correction/node trace + node time/estimated LLM calls/web/correction/reranker/citation-repair flags | makes agent decisions and efficiency inspectable |
|
| 42 |
-
| Evaluation | transparent v1.
|
| 43 |
| Saved evaluation history | latest Quick/Standard/Deep cache plus timestamped archived runs and deltas | allows instant run switching and lightweight within-workspace regression tracking without consuming Gemini quota again |
|
| 44 |
| Evaluation report serialization | JSON-safe normalization at save/load/UI/API boundaries + formatted raw JSON view | prevents `root={...}` wrapper leakage and keeps fresh/restored reports identical |
|
| 45 |
| Evaluation table export | CSV/TSV/Markdown export for every benchmark table | makes Text2SQL/planner/QA/overview/ablation/abstention/comparison results easy to copy or download |
|
|
@@ -67,3 +67,7 @@ The demo uses embedded Qdrant, in-memory DuckDB, deterministic source profiles,
|
|
| 67 |
|
| 68 |
| Profile benchmark | optional Fast/Balanced/Agentic labeled comparison | quantifies whether additional planning, reranking and verification cost is justified for representative tasks |
|
| 69 |
| Hard-mode robustness | paraphrase/distractor/missing/multi-hop/insight/SQL/local-freshness/injection cases | prevents a near-perfect easy benchmark from becoming meaningless as a regression signal |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
| Caching | TTL result cache keyed by session/config/corpus version | latency/quota reduction without stale cross-corpus answers |
|
| 40 |
| Rate limiting | sliding-window per IP | protects a public shared model key |
|
| 41 |
| Observability | Prometheus + semantic plan/evidence/correction/node trace + node time/estimated LLM calls/web/correction/reranker/citation-repair flags | makes agent decisions and efficiency inspectable |
|
| 42 |
+
| Evaluation | transparent v1.7 benchmark + Markdown-aware citation claims + grounded-absence robustness + source/chunk ranking metrics + hard-mode + planner/web policy + typed Text2SQL + pacing-aware latency + calibrated Deep judge | makes the benchmark harder as the original demo saturates and separates retrieval, orchestration, analysis and generation failures |
|
| 43 |
| Saved evaluation history | latest Quick/Standard/Deep cache plus timestamped archived runs and deltas | allows instant run switching and lightweight within-workspace regression tracking without consuming Gemini quota again |
|
| 44 |
| Evaluation report serialization | JSON-safe normalization at save/load/UI/API boundaries + formatted raw JSON view | prevents `root={...}` wrapper leakage and keeps fresh/restored reports identical |
|
| 45 |
| Evaluation table export | CSV/TSV/Markdown export for every benchmark table | makes Text2SQL/planner/QA/overview/ablation/abstention/comparison results easy to copy or download |
|
|
|
|
| 67 |
|
| 68 |
| Profile benchmark | optional Fast/Balanced/Agentic labeled comparison | quantifies whether additional planning, reranking and verification cost is justified for representative tasks |
|
| 69 |
| Hard-mode robustness | paraphrase/distractor/missing/multi-hop/insight/SQL/local-freshness/injection cases | prevents a near-perfect easy benchmark from becoming meaningless as a regression signal |
|
| 70 |
+
|
| 71 |
+
| Evaluation provenance | run ID + server-boot ID + fresh/reused status | makes cache/rebuild behavior auditable and prevents fresh runs from being mistaken for saved reuse |
|
| 72 |
+
| Context-efficiency diagnostics | source Precision@5 beside Recall@5 + targeted recommendations | exposes distractor-heavy context without sacrificing overview/synthesis breadth prematurely |
|
| 73 |
+
| Grounded absence | evidence-cited missing-information answers skip unnecessary revise | rewards calibrated uncertainty and reduces extra model calls on unanswerable local questions |
|
docs/MIGRATION_1.7.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Migration to RAGForge v1.7
|
| 2 |
+
|
| 3 |
+
v1.7 is a correctness, provenance and measured-efficiency release over v1.6. Runtime dependency pins are unchanged.
|
| 4 |
+
|
| 5 |
+
## Highlights
|
| 6 |
+
|
| 7 |
+
- escaped uniform source cards;
|
| 8 |
+
- graphical node-latency waterfall;
|
| 9 |
+
- Markdown-aware citation coverage;
|
| 10 |
+
- grounded missing-information answers and no unnecessary revise call;
|
| 11 |
+
- valid `[T#]` evidence on corpus overviews;
|
| 12 |
+
- evaluation run IDs/server-boot provenance and user-input-only saved-run switching;
|
| 13 |
+
- profile-policy and context-efficiency diagnostics;
|
| 14 |
+
- small-corpus reranker skip extended to Agentic after source+chunk ablation showed no gain;
|
| 15 |
+
- benchmark version `1.7`.
|
| 16 |
+
|
| 17 |
+
Because citation/missing-answer scoring semantics changed, v1.6 saved reports remain historical and are not eligible for v1.7 automatic benchmark reuse.
|
| 18 |
+
|
| 19 |
+
Apply the patch, rebuild the Space, index the demo corpus, run Quick first, then Standard. A fresh run should say `Fresh ... evaluation complete`, while an explicit saved-run load should say `Loaded saved ... run`.
|
docs/QUERY_PLANNING.md
CHANGED
|
@@ -142,3 +142,7 @@ The `retrieve`/`web` trace records `reranker_used` and `reranker_reason`. The ex
|
|
| 142 |
- “Compare our NIST document with the latest online guidance” remains `comparison -> hierarchical` with mixed/web relevance.
|
| 143 |
|
| 144 |
The planner is explicitly told that a collection-wide insight question should stay local/analytical even when structured tables are present; the existence of a table alone does not force SQL routing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
- “Compare our NIST document with the latest online guidance” remains `comparison -> hierarchical` with mixed/web relevance.
|
| 143 |
|
| 144 |
The planner is explicitly told that a collection-wide insight question should stay local/analytical even when structured tables are present; the existence of a table alone does not force SQL routing.
|
| 145 |
+
|
| 146 |
+
## v1.7 grounded absence behavior
|
| 147 |
+
|
| 148 |
+
A corpus-scoped fact lookup can legitimately conclude that the indexed evidence does not state the requested fact. v1.7 treats an evidence-cited absence statement as calibrated uncertainty, not as a hallucination signal. This does not change routing to the web: web fallback still requires semantic relevance/permission. The grounded-absence state simply prevents an unnecessary answer-revision call when the model has already answered conservatively from the local evidence.
|
docs/RESUME_BULLETS.md
CHANGED
|
@@ -25,3 +25,5 @@
|
|
| 25 |
|
| 26 |
- Added JSON-safe evaluation persistence and copy/export tooling for all benchmark result tables, keeping saved Quick/Standard/Deep reports directly comparable without rerunning model calls.
|
| 27 |
- Hardened zero-call citation post-processing with grouped-citation normalization, duplicate-tail cleanup and conservative preamble skipping to improve citation usability without increasing inference cost.
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
- Added JSON-safe evaluation persistence and copy/export tooling for all benchmark result tables, keeping saved Quick/Standard/Deep reports directly comparable without rerunning model calls.
|
| 27 |
- Hardened zero-call citation post-processing with grouped-citation normalization, duplicate-tail cleanup and conservative preamble skipping to improve citation usability without increasing inference cost.
|
| 28 |
+
|
| 29 |
+
- Built an evaluation-driven RAG quality loop (v1.7) that corrected Markdown-aware citation scoring and missing-answer evaluation, added run-level cache provenance, and converted source/chunk ablations plus Fast/Balanced/Agentic benchmarks into adaptive reranker/profile policy diagnostics.
|
docs/SOURCES.md
CHANGED
|
@@ -63,3 +63,7 @@ The adaptive reranker policy and incremental evaluation design are internal engi
|
|
| 63 |
## v1.6 evaluation-driven policy
|
| 64 |
|
| 65 |
Insight synthesis, hard-mode benchmark cases, table citation semantics, chunk-level reranker labels, profile comparison and evaluation-history deltas are RAGForge-specific engineering additions derived from the project's own observed evaluation gaps. They are not claims that one routing taxonomy or benchmark design is universally optimal.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
## v1.6 evaluation-driven policy
|
| 64 |
|
| 65 |
Insight synthesis, hard-mode benchmark cases, table citation semantics, chunk-level reranker labels, profile comparison and evaluation-history deltas are RAGForge-specific engineering additions derived from the project's own observed evaluation gaps. They are not claims that one routing taxonomy or benchmark design is universally optimal.
|
| 66 |
+
|
| 67 |
+
## v1.7 evaluation-policy note
|
| 68 |
+
|
| 69 |
+
v1.7 does not add a new external dependency or benchmark dataset. The new policies are derived from RAGForge's own auditable v1.6 run data: perfect source/chunk ablation quality with large reranker latency, a false-negative missing-answer case, and Markdown citation-coverage artifacts.
|
docs/UX_LIFECYCLE.md
CHANGED
|
@@ -84,3 +84,11 @@ and download file are derived from the selected saved run; no Gemini request is
|
|
| 84 |
Saving an evaluation still updates the latest Quick/Standard/Deep cache, but v1.6 also writes a timestamped historical copy inside the workspace. History is read-only and consumes no model quota. It is ephemeral with the Space container, just like the corpus.
|
| 85 |
|
| 86 |
The query inspector adds a text-based node-latency waterfall. This uses existing LangGraph trace timings and introduces no plotting dependency or extra model call. Structured evidence produced by analytical synthesis appears in the source panel as `[T#]` table cards with row count, schema and a bounded preview.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
Saving an evaluation still updates the latest Quick/Standard/Deep cache, but v1.6 also writes a timestamped historical copy inside the workspace. History is read-only and consumes no model quota. It is ephemeral with the Space container, just like the corpus.
|
| 85 |
|
| 86 |
The query inspector adds a text-based node-latency waterfall. This uses existing LangGraph trace timings and introduces no plotting dependency or extra model call. Structured evidence produced by analytical synthesis appears in the source panel as `[T#]` table cards with row count, schema and a bounded preview.
|
| 87 |
+
|
| 88 |
+
## v1.7 source, trace and saved-run UX
|
| 89 |
+
|
| 90 |
+
Source snippets are rendered as escaped plain text inside uniform source cards. Source-controlled Markdown therefore cannot change font size or create headings in the Sources accordion.
|
| 91 |
+
|
| 92 |
+
The Pipeline Inspector latency waterfall is now a proportional graphical bar display rather than a row of `#` characters. The same section exposes the adaptive reranker decision and whether the response is a grounded absence answer.
|
| 93 |
+
|
| 94 |
+
Saved evaluation switching is bound to user input rather than generic change events. A fresh benchmark can update the selected depth without immediately reloading itself and overwriting the completion message. Fresh and reused statuses include the saved run ID; evaluation metadata also records the server boot that produced the report.
|
docs/architecture.mmd
CHANGED
|
@@ -66,3 +66,5 @@ flowchart TD
|
|
| 66 |
EH -. compatible Standard baseline .-> EJ
|
| 67 |
|
| 68 |
API[FastAPI /docs + OpenAPI + Prometheus] -. live introspection .-> UI
|
|
|
|
|
|
|
|
|
| 66 |
EH -. compatible Standard baseline .-> EJ
|
| 67 |
|
| 68 |
API[FastAPI /docs + OpenAPI + Prometheus] -. live introspection .-> UI
|
| 69 |
+
|
| 70 |
+
%% v1.7: evidence-cited grounded-absence answers bypass the low-confidence revise loop.
|
evals/README.md
CHANGED
|
@@ -38,3 +38,7 @@ The `hard_mode_cases` section intentionally targets failure modes that the origi
|
|
| 38 |
Selected QA cases also define `chunk_must_contain`. These labels are used only for chunk-level reranker Hit@1/MRR; unlabeled cases are excluded from those averages.
|
| 39 |
|
| 40 |
The optional profile benchmark is not stored as a separate question set: it reuses a focused QA case and a cross-document case across Fast, Balanced and Agentic so the comparison is controlled.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
Selected QA cases also define `chunk_must_contain`. These labels are used only for chunk-level reranker Hit@1/MRR; unlabeled cases are excluded from those averages.
|
| 39 |
|
| 40 |
The optional profile benchmark is not stored as a separate question set: it reuses a focused QA case and a cross-document case across Fast, Balanced and Agentic so the comparison is controlled.
|
| 41 |
+
|
| 42 |
+
## v1.7 scoring corrections
|
| 43 |
+
|
| 44 |
+
Citation coverage is Markdown-aware, missing-answer cases accept natural grounded-absence language, and global overview table citations are evaluated against actual `[T#]` source records. Reports also include run/server provenance and optional aggregated profile-policy summaries. Benchmark version `1.7` intentionally prevents automatic reuse of v1.6 reports under the changed scoring semantics.
|
evals/demo_benchmark.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
-
"version": "1.
|
| 3 |
-
"description": "
|
| 4 |
"qa_cases": [
|
| 5 |
{
|
| 6 |
"id": "qa_acme_sev1_ack",
|
|
|
|
| 1 |
{
|
| 2 |
+
"version": "1.7",
|
| 3 |
+
"description": "RAGForge v1.7 demo benchmark with Markdown-aware citation claims, grounded-absence robustness, analytical synthesis, table evidence, hard-mode cases, and source/chunk retrieval ablations.",
|
| 4 |
"qa_cases": [
|
| 5 |
{
|
| 6 |
"id": "qa_acme_sev1_ack",
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "ragforge"
|
| 7 |
-
version = "1.
|
| 8 |
description = "Production-style agentic RAG demo for Hugging Face Spaces"
|
| 9 |
requires-python = ">=3.11"
|
| 10 |
dependencies = []
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "ragforge"
|
| 7 |
+
version = "1.7.0"
|
| 8 |
description = "Production-style agentic RAG demo for Hugging Face Spaces"
|
| 9 |
requires-python = ">=3.11"
|
| 10 |
dependencies = []
|
src/ragforge/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
"""RAGForge: production-style agentic retrieval augmented generation demo."""
|
| 2 |
|
| 3 |
-
__version__ = "1.
|
|
|
|
| 1 |
"""RAGForge: production-style agentic retrieval augmented generation demo."""
|
| 2 |
|
| 3 |
+
__version__ = "1.7.0"
|
src/ragforge/api.py
CHANGED
|
@@ -29,7 +29,7 @@ def _auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
|
| 29 |
|
| 30 |
|
| 31 |
def create_api() -> FastAPI:
|
| 32 |
-
app = FastAPI(title="RAGForge API", version="1.
|
| 33 |
|
| 34 |
@app.get("/api/health")
|
| 35 |
def health():
|
|
@@ -54,7 +54,9 @@ def create_api() -> FastAPI:
|
|
| 54 |
"saved-evaluation-history", "incremental-deep-evaluation", "typed-text2sql-evaluation",
|
| 55 |
"adaptive-reranking", "semantic-citation-attribution", "insight-synthesis",
|
| 56 |
"table-citations", "hard-mode-evaluation", "chunk-level-reranker-ablation",
|
| 57 |
-
"optional-profile-benchmark", "node-latency-observability", "evaluation-run-history"
|
|
|
|
|
|
|
| 58 |
],
|
| 59 |
}
|
| 60 |
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def create_api() -> FastAPI:
|
| 32 |
+
app = FastAPI(title="RAGForge API", version="1.7.0")
|
| 33 |
|
| 34 |
@app.get("/api/health")
|
| 35 |
def health():
|
|
|
|
| 54 |
"saved-evaluation-history", "incremental-deep-evaluation", "typed-text2sql-evaluation",
|
| 55 |
"adaptive-reranking", "semantic-citation-attribution", "insight-synthesis",
|
| 56 |
"table-citations", "hard-mode-evaluation", "chunk-level-reranker-ablation",
|
| 57 |
+
"optional-profile-benchmark", "node-latency-observability", "evaluation-run-history",
|
| 58 |
+
"grounded-absence-handling", "markdown-aware-citation-coverage", "evaluation-run-provenance",
|
| 59 |
+
"profile-policy-diagnostics", "context-efficiency-diagnostics"
|
| 60 |
],
|
| 61 |
}
|
| 62 |
|
src/ragforge/citations.py
CHANGED
|
@@ -47,7 +47,12 @@ def repair_missing_citations(
|
|
| 47 |
*,
|
| 48 |
semantic_support: bool = True,
|
| 49 |
) -> tuple[str, int]:
|
| 50 |
-
"""Attach citations only when an uncited factual unit clearly matches evidence.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
if not answer or not sources:
|
| 52 |
return answer, 0
|
| 53 |
answer = normalize_citation_syntax(answer)
|
|
@@ -65,12 +70,14 @@ def repair_missing_citations(
|
|
| 65 |
}
|
| 66 |
|
| 67 |
evidence: list[tuple[str, set[str]]] = []
|
|
|
|
| 68 |
for source in sources:
|
| 69 |
sid = str(source.get("id", ""))
|
| 70 |
if not re.fullmatch(r"(?:D|W|T)\d+", sid):
|
| 71 |
continue
|
| 72 |
text = f"{source.get('title', '')} {source.get('snippet', '')}"
|
| 73 |
evidence.append((sid, toks(text)))
|
|
|
|
| 74 |
if not evidence:
|
| 75 |
return answer, 0
|
| 76 |
|
|
@@ -82,36 +89,20 @@ def repair_missing_citations(
|
|
| 82 |
from .retrieval import ModelRegistry
|
| 83 |
|
| 84 |
semantic_ids = [sid for sid, _ in evidence]
|
| 85 |
-
texts = [
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
texts.append(f"{source.get('title', '')} {source.get('snippet', '')}"[:2400])
|
| 90 |
semantic_vectors = np.asarray(list(ModelRegistry.embedding().passage_embed(texts)), dtype=float)
|
| 91 |
norms = np.linalg.norm(semantic_vectors, axis=1, keepdims=True) + 1e-9
|
| 92 |
semantic_vectors = semantic_vectors / norms
|
| 93 |
except Exception:
|
| 94 |
semantic_vectors = None
|
| 95 |
|
| 96 |
-
|
| 97 |
-
out: list[str] = []
|
| 98 |
-
for line in answer.splitlines():
|
| 99 |
-
stripped = line.strip()
|
| 100 |
-
plain = re.sub(r"[`*_#>-]", "", stripped).strip()
|
| 101 |
-
if (
|
| 102 |
-
not stripped
|
| 103 |
-
or re.search(r"\[(?:D|W|T)\d+\]", line)
|
| 104 |
-
or stripped.startswith("```")
|
| 105 |
-
or stripped.endswith(":")
|
| 106 |
-
or len(plain) < 24
|
| 107 |
-
):
|
| 108 |
-
out.append(line)
|
| 109 |
-
continue
|
| 110 |
-
|
| 111 |
unit_tokens = toks(plain)
|
| 112 |
if not unit_tokens:
|
| 113 |
-
|
| 114 |
-
continue
|
| 115 |
ranked: list[tuple[int, float, str]] = []
|
| 116 |
for sid, source_tokens in evidence:
|
| 117 |
overlap = len(unit_tokens & source_tokens)
|
|
@@ -125,7 +116,11 @@ def repair_missing_citations(
|
|
| 125 |
selected_ids = [best_sid]
|
| 126 |
if len(ranked) > 1:
|
| 127 |
second_overlap, second_support, second_sid = ranked[1]
|
| 128 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
selected_ids.append(second_sid)
|
| 130 |
elif semantic_vectors is not None and semantic_ids:
|
| 131 |
try:
|
|
@@ -139,16 +134,73 @@ def repair_missing_citations(
|
|
| 139 |
best_idx = int(order[0])
|
| 140 |
best_sem = float(sims[best_idx])
|
| 141 |
second_sem = float(sims[int(order[1])]) if len(order) > 1 else -1.0
|
| 142 |
-
# Conservative attribution: require high semantic similarity and
|
| 143 |
-
# a margin over the next source. This avoids citation decoration.
|
| 144 |
if best_sem >= 0.68 and (best_sem - second_sem >= 0.055 or best_sem >= 0.78):
|
| 145 |
selected_ids = [semantic_ids[best_idx]]
|
| 146 |
except Exception:
|
| 147 |
selected_ids = []
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
out.append(line)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return normalize_citation_syntax("\n".join(out)), repaired
|
|
|
|
|
|
| 47 |
*,
|
| 48 |
semantic_support: bool = True,
|
| 49 |
) -> tuple[str, int]:
|
| 50 |
+
"""Attach citations only when an uncited factual unit clearly matches evidence.
|
| 51 |
+
|
| 52 |
+
v1.7 repairs prose at sentence granularity. A paragraph that already has a
|
| 53 |
+
citation in sentence one must not cause sentence two to be treated as cited.
|
| 54 |
+
Bullets remain whole units so list formatting is preserved.
|
| 55 |
+
"""
|
| 56 |
if not answer or not sources:
|
| 57 |
return answer, 0
|
| 58 |
answer = normalize_citation_syntax(answer)
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
evidence: list[tuple[str, set[str]]] = []
|
| 73 |
+
by_id: dict[str, dict[str, Any]] = {}
|
| 74 |
for source in sources:
|
| 75 |
sid = str(source.get("id", ""))
|
| 76 |
if not re.fullmatch(r"(?:D|W|T)\d+", sid):
|
| 77 |
continue
|
| 78 |
text = f"{source.get('title', '')} {source.get('snippet', '')}"
|
| 79 |
evidence.append((sid, toks(text)))
|
| 80 |
+
by_id[sid] = source
|
| 81 |
if not evidence:
|
| 82 |
return answer, 0
|
| 83 |
|
|
|
|
| 89 |
from .retrieval import ModelRegistry
|
| 90 |
|
| 91 |
semantic_ids = [sid for sid, _ in evidence]
|
| 92 |
+
texts = [
|
| 93 |
+
f"{by_id[sid].get('title', '')} {by_id[sid].get('snippet', '')}"[:2400]
|
| 94 |
+
for sid in semantic_ids
|
| 95 |
+
]
|
|
|
|
| 96 |
semantic_vectors = np.asarray(list(ModelRegistry.embedding().passage_embed(texts)), dtype=float)
|
| 97 |
norms = np.linalg.norm(semantic_vectors, axis=1, keepdims=True) + 1e-9
|
| 98 |
semantic_vectors = semantic_vectors / norms
|
| 99 |
except Exception:
|
| 100 |
semantic_vectors = None
|
| 101 |
|
| 102 |
+
def choose_ids(plain: str) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
unit_tokens = toks(plain)
|
| 104 |
if not unit_tokens:
|
| 105 |
+
return []
|
|
|
|
| 106 |
ranked: list[tuple[int, float, str]] = []
|
| 107 |
for sid, source_tokens in evidence:
|
| 108 |
overlap = len(unit_tokens & source_tokens)
|
|
|
|
| 116 |
selected_ids = [best_sid]
|
| 117 |
if len(ranked) > 1:
|
| 118 |
second_overlap, second_support, second_sid = ranked[1]
|
| 119 |
+
if (
|
| 120 |
+
second_overlap >= 2
|
| 121 |
+
and second_support >= 0.20
|
| 122 |
+
and second_support >= best_score * 0.65
|
| 123 |
+
):
|
| 124 |
selected_ids.append(second_sid)
|
| 125 |
elif semantic_vectors is not None and semantic_ids:
|
| 126 |
try:
|
|
|
|
| 134 |
best_idx = int(order[0])
|
| 135 |
best_sem = float(sims[best_idx])
|
| 136 |
second_sem = float(sims[int(order[1])]) if len(order) > 1 else -1.0
|
|
|
|
|
|
|
| 137 |
if best_sem >= 0.68 and (best_sem - second_sem >= 0.055 or best_sem >= 0.78):
|
| 138 |
selected_ids = [semantic_ids[best_idx]]
|
| 139 |
except Exception:
|
| 140 |
selected_ids = []
|
| 141 |
+
return selected_ids
|
| 142 |
|
| 143 |
+
def repair_unit(unit: str) -> tuple[str, int]:
|
| 144 |
+
stripped = unit.strip()
|
| 145 |
+
plain = re.sub(r"[`*_#>-]", "", stripped).strip()
|
| 146 |
+
if (
|
| 147 |
+
not stripped
|
| 148 |
+
or re.search(r"\[(?:D|W|T)\d+\]", unit)
|
| 149 |
+
or stripped.startswith("```")
|
| 150 |
+
or stripped.endswith(":")
|
| 151 |
+
or len(plain) < 24
|
| 152 |
+
):
|
| 153 |
+
return unit, 0
|
| 154 |
+
selected_ids = choose_ids(plain)
|
| 155 |
+
if not selected_ids:
|
| 156 |
+
return unit, 0
|
| 157 |
+
citation_text = " ".join(f"[{sid}]" for sid in selected_ids)
|
| 158 |
+
trimmed = unit.rstrip()
|
| 159 |
+
terminal = trimmed[-1] if trimmed and trimmed[-1] in ".!?" else ""
|
| 160 |
+
if terminal:
|
| 161 |
+
trimmed = trimmed[:-1].rstrip()
|
| 162 |
+
return f"{trimmed} {citation_text}{terminal}", len(selected_ids)
|
| 163 |
+
return f"{trimmed} {citation_text}", len(selected_ids)
|
| 164 |
+
|
| 165 |
+
repaired = 0
|
| 166 |
+
out: list[str] = []
|
| 167 |
+
for line in answer.splitlines():
|
| 168 |
+
stripped = line.strip()
|
| 169 |
+
if not stripped or stripped.startswith("```") or stripped.endswith(":"):
|
| 170 |
out.append(line)
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
# Keep list items intact. The evaluator also treats one list item as one
|
| 174 |
+
# factual unit, so this preserves readable Markdown and avoids citation
|
| 175 |
+
# decoration on every short clause inside a bullet.
|
| 176 |
+
if re.match(r"^\s*(?:[-*+]\s+|\d+[.)]\s+)", line):
|
| 177 |
+
repaired_line, count = repair_unit(line)
|
| 178 |
+
out.append(repaired_line)
|
| 179 |
+
repaired += count
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
# Move a citation written after sentence punctuation back onto that
|
| 183 |
+
# sentence, then protect common abbreviations before splitting.
|
| 184 |
+
split_text = re.sub(
|
| 185 |
+
r"([.!?])\s+((?:\[(?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*\]\s*)+)",
|
| 186 |
+
r" \2\1 ",
|
| 187 |
+
line,
|
| 188 |
+
)
|
| 189 |
+
protected = (
|
| 190 |
+
split_text.replace("vs.", "vs<prd>")
|
| 191 |
+
.replace("e.g.", "e<prd>g<prd>")
|
| 192 |
+
.replace("i.e.", "i<prd>e<prd>")
|
| 193 |
+
.replace("etc.", "etc<prd>")
|
| 194 |
+
)
|
| 195 |
+
units = [part.strip().replace("<prd>", ".") for part in re.split(r"(?<=[.!?])\s+", protected)]
|
| 196 |
+
repaired_units: list[str] = []
|
| 197 |
+
for unit in units:
|
| 198 |
+
if not unit:
|
| 199 |
+
continue
|
| 200 |
+
repaired_unit, count = repair_unit(unit)
|
| 201 |
+
repaired_units.append(repaired_unit)
|
| 202 |
+
repaired += count
|
| 203 |
+
out.append(" ".join(repaired_units) if repaired_units else line)
|
| 204 |
+
|
| 205 |
return normalize_citation_syntax("\n".join(out)), repaired
|
| 206 |
+
|
src/ragforge/eval_metrics.py
CHANGED
|
@@ -52,6 +52,93 @@ def answer_key_match(answer: str, case: dict[str, Any]) -> bool:
|
|
| 52 |
return bool(expected_all or expected_any)
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def scalar_value_match(observed: Any, expected: Any) -> bool:
|
| 56 |
"""Compare tabular scalar values without relying on Markdown rendering.
|
| 57 |
|
|
@@ -158,15 +245,13 @@ def citation_metrics(answer: str, result_sources: list[dict[str, Any]]) -> dict[
|
|
| 158 |
valid = sum(1 for citation in cited if citation in valid_ids)
|
| 159 |
validity = safe_div(valid, len(cited)) if cited else 0.0
|
| 160 |
|
| 161 |
-
units =
|
| 162 |
-
unit.strip()
|
| 163 |
-
for unit in re.split(r"(?<=[.!?])\s+|\n+", answer or "")
|
| 164 |
-
if len(re.sub(r"[`*_#>-]", "", unit).strip()) >= 24
|
| 165 |
-
]
|
| 166 |
cited_units = sum(1 for unit in units if extract_citation_ids(unit))
|
| 167 |
coverage = safe_div(cited_units, len(units)) if units else 0.0
|
| 168 |
return {
|
| 169 |
"citation_count": len(cited),
|
| 170 |
"citation_validity": validity,
|
| 171 |
"citation_coverage": coverage,
|
|
|
|
|
|
|
| 172 |
}
|
|
|
|
| 52 |
return bool(expected_all or expected_any)
|
| 53 |
|
| 54 |
|
| 55 |
+
|
| 56 |
+
def missing_answer_match(answer: str, case: dict[str, Any] | None = None) -> bool:
|
| 57 |
+
"""Recognize a grounded "not present in the evidence" answer.
|
| 58 |
+
|
| 59 |
+
Missing-answer evaluation should reward calibrated uncertainty, not require a
|
| 60 |
+
single canned phrase. The matcher therefore accepts benchmark-specific cues
|
| 61 |
+
plus a conservative generic vocabulary for absence/insufficiency.
|
| 62 |
+
"""
|
| 63 |
+
text = re.sub(r"\s+", " ", (answer or "").strip().casefold())
|
| 64 |
+
if not text:
|
| 65 |
+
return False
|
| 66 |
+
case = case or {}
|
| 67 |
+
expected = [str(x).casefold() for x in case.get("expected_missing_any", [])]
|
| 68 |
+
generic = [
|
| 69 |
+
"not specified", "does not specify", "doesn't specify",
|
| 70 |
+
"not provided", "does not provide", "doesn't provide",
|
| 71 |
+
"does not mention", "doesn't mention", "do not mention", "not mentioned",
|
| 72 |
+
"does not contain", "doesn't contain", "no information", "no fee information",
|
| 73 |
+
"insufficient to answer", "insufficient evidence", "cannot determine", "can't determine",
|
| 74 |
+
"not stated", "not available in", "not present in", "no evidence of",
|
| 75 |
+
]
|
| 76 |
+
return any(cue in text for cue in [*expected, *generic] if cue)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def substantive_claim_units(answer: str) -> list[str]:
|
| 80 |
+
"""Extract Markdown-aware factual units for citation coverage.
|
| 81 |
+
|
| 82 |
+
Headings and generic list introductions are presentation structure, not
|
| 83 |
+
factual claims. Short numbered/bulleted values are factual units even when
|
| 84 |
+
they are much shorter than prose sentences.
|
| 85 |
+
"""
|
| 86 |
+
units: list[str] = []
|
| 87 |
+
in_code = False
|
| 88 |
+
for raw_line in (answer or "").splitlines():
|
| 89 |
+
line = raw_line.strip()
|
| 90 |
+
if line.startswith("```"):
|
| 91 |
+
in_code = not in_code
|
| 92 |
+
continue
|
| 93 |
+
if in_code or not line:
|
| 94 |
+
continue
|
| 95 |
+
if re.match(r"^#{1,6}\s+", line):
|
| 96 |
+
continue
|
| 97 |
+
|
| 98 |
+
is_list = bool(re.match(r"^(?:[-*+]\s+|\d+[.)]\s+)", line))
|
| 99 |
+
content = re.sub(r"^(?:[-*+]\s+|\d+[.)]\s+)", "", line).strip()
|
| 100 |
+
plain = re.sub(r"\[(?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*\]", "", content)
|
| 101 |
+
plain = re.sub(r"[`*_#>]", "", plain).strip()
|
| 102 |
+
words = re.findall(r"[A-Za-z0-9][A-Za-z0-9_.%$+-]*", plain)
|
| 103 |
+
|
| 104 |
+
# Preambles such as "The following documents contain:" introduce the
|
| 105 |
+
# claims in following bullets and should not depress citation coverage.
|
| 106 |
+
if not is_list and plain.endswith(":"):
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
if is_list:
|
| 110 |
+
if len(words) >= 1 and len(plain) >= 3:
|
| 111 |
+
units.append(content)
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
# Split long prose lines into sentence-level claims. Citations are often
|
| 115 |
+
# written after punctuation (``claim. [D1]``); move that citation tail
|
| 116 |
+
# onto the claim before splitting. Protect common abbreviations such as
|
| 117 |
+
# ``vs.`` so they do not become fake uncited sentence fragments.
|
| 118 |
+
split_text = re.sub(
|
| 119 |
+
r"([.!?])\s+((?:\[(?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*\]\s*)+)",
|
| 120 |
+
r" \2\1 ",
|
| 121 |
+
content,
|
| 122 |
+
)
|
| 123 |
+
protected = (
|
| 124 |
+
split_text.replace("vs.", "vs<prd>")
|
| 125 |
+
.replace("e.g.", "e<prd>g<prd>")
|
| 126 |
+
.replace("i.e.", "i<prd>e<prd>")
|
| 127 |
+
.replace("etc.", "etc<prd>")
|
| 128 |
+
)
|
| 129 |
+
segments = [
|
| 130 |
+
seg.strip().replace("<prd>", ".")
|
| 131 |
+
for seg in re.split(r"(?<=[.!?])\s+", protected)
|
| 132 |
+
if seg.strip()
|
| 133 |
+
]
|
| 134 |
+
for segment in segments:
|
| 135 |
+
segment_plain = re.sub(r"\[(?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*\]", "", segment)
|
| 136 |
+
segment_plain = re.sub(r"[`*_#>]", "", segment_plain).strip()
|
| 137 |
+
seg_words = re.findall(r"[A-Za-z0-9][A-Za-z0-9_.%$+-]*", segment_plain)
|
| 138 |
+
if len(segment_plain) >= 24 or len(seg_words) >= 5:
|
| 139 |
+
units.append(segment)
|
| 140 |
+
return units
|
| 141 |
+
|
| 142 |
def scalar_value_match(observed: Any, expected: Any) -> bool:
|
| 143 |
"""Compare tabular scalar values without relying on Markdown rendering.
|
| 144 |
|
|
|
|
| 245 |
valid = sum(1 for citation in cited if citation in valid_ids)
|
| 246 |
validity = safe_div(valid, len(cited)) if cited else 0.0
|
| 247 |
|
| 248 |
+
units = substantive_claim_units(answer or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
cited_units = sum(1 for unit in units if extract_citation_ids(unit))
|
| 250 |
coverage = safe_div(cited_units, len(units)) if units else 0.0
|
| 251 |
return {
|
| 252 |
"citation_count": len(cited),
|
| 253 |
"citation_validity": validity,
|
| 254 |
"citation_coverage": coverage,
|
| 255 |
+
"substantive_units": len(units),
|
| 256 |
+
"cited_units": cited_units,
|
| 257 |
}
|
src/ragforge/evaluation.py
CHANGED
|
@@ -11,6 +11,7 @@ from .eval_metrics import (
|
|
| 11 |
answer_key_match,
|
| 12 |
citation_metrics,
|
| 13 |
mean,
|
|
|
|
| 14 |
percentile,
|
| 15 |
safe_div,
|
| 16 |
scalar_value_match,
|
|
@@ -607,15 +608,14 @@ def _hard_mode_eval(
|
|
| 607 |
returned = _document_sources(result.sources, 5)
|
| 608 |
retrieval = source_metrics(returned, case.get("relevant_sources", [])) if case.get("relevant_sources") else {}
|
| 609 |
if kind == "missing":
|
| 610 |
-
|
| 611 |
-
passed = any(term.lower() in answer_l for term in case.get("expected_missing_any", []))
|
| 612 |
elif kind == "insight":
|
| 613 |
table_cited = any(str(src.get("id", "")).startswith("T") for src in result.sources) and "[T" in result.answer
|
| 614 |
evidence = result.trace.get("evidence", {})
|
| 615 |
passed = plan.get("task_type") == case.get("expected_task") and plan.get("retrieval_strategy") == case.get("expected_strategy") and float(evidence.get("source_coverage", 0.0) or 0.0) >= float(case.get("min_source_coverage", 0.0)) and (table_cited or not case.get("requires_table_citation"))
|
| 616 |
else:
|
| 617 |
passed = answer_key_match(result.answer, case) and float(retrieval.get("source_recall@5", 1.0)) >= 1.0
|
| 618 |
-
row.update({"route":plan.get("route"),"task":plan.get("task_type"),"strategy":plan.get("retrieval_strategy"),"answer_key_match":answer_key_match(result.answer,case) if kind=="qa" else None,"citation_validity":round(float(citations["citation_validity"]),3),"citation_coverage":round(float(citations["citation_coverage"]),3),"source_recall@5":round(float(retrieval.get("source_recall@5",1.0)),3),"latency_ms":round(max(0.0,wall-pace),1),"pass":passed,"gemini_calls":int(result.trace.get("metrics",{}).get("llm_calls_estimate",0) or 0),"_answer":result.answer,"_sources":result.sources,"_node_times":_trace_node_times(result.trace)})
|
| 619 |
rows.append(row)
|
| 620 |
return rows
|
| 621 |
|
|
@@ -651,12 +651,53 @@ def _profile_benchmark(
|
|
| 651 |
return rows
|
| 652 |
|
| 653 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
def _diagnostics(
|
| 655 |
summary: dict[str, Any],
|
| 656 |
ablation_rows: list[dict[str, Any]],
|
| 657 |
planner_rows: list[dict[str, Any]],
|
| 658 |
sql_rows: list[dict[str, Any]],
|
| 659 |
hard_rows: list[dict[str, Any]] | None = None,
|
|
|
|
|
|
|
| 660 |
) -> list[dict[str, str]]:
|
| 661 |
findings: list[dict[str, str]] = []
|
| 662 |
|
|
@@ -673,6 +714,22 @@ def _diagnostics(
|
|
| 673 |
}
|
| 674 |
)
|
| 675 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
if summary.get("citation_coverage", 1.0) < 0.90:
|
| 677 |
findings.append(
|
| 678 |
{
|
|
@@ -731,6 +788,29 @@ def _diagnostics(
|
|
| 731 |
"recommendation": "Consider disabling reranking in Fast mode/small corpora, while retaining it for harder or larger corpora where chunk-level quality may improve.",
|
| 732 |
}
|
| 733 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
if not findings:
|
| 735 |
findings.append(
|
| 736 |
{
|
|
@@ -819,6 +899,8 @@ def _deep_from_standard_cache(
|
|
| 819 |
report.get("semantic_planner", []),
|
| 820 |
report.get("text2sql", []),
|
| 821 |
report.get("hard_mode", []),
|
|
|
|
|
|
|
| 822 |
)
|
| 823 |
report.setdefault("methodology", {})["evaluation_cache"] = (
|
| 824 |
"Deep reused the current cached Standard deterministic baseline and issued only sampled judge calls."
|
|
@@ -994,10 +1076,15 @@ def run_demo_eval(
|
|
| 994 |
**{key: round(value, 3) for key, value in judge_summary.items()},
|
| 995 |
}
|
| 996 |
|
| 997 |
-
diagnostics = _diagnostics(summary, ablation_rows, planner_rows, sql_rows, hard_rows)
|
| 998 |
-
summary["evaluation_wall_ms"] = round((time.perf_counter() - wall_started) * 1000, 1)
|
| 999 |
-
|
| 1000 |
node_latency_rows = _node_latency_summary(qa_rows + overview_rows + hard_rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
progress(1.0, "Evaluation complete")
|
| 1002 |
return {
|
| 1003 |
"summary": summary,
|
|
@@ -1010,6 +1097,7 @@ def run_demo_eval(
|
|
| 1010 |
"retrieval_ablation": ablation_rows,
|
| 1011 |
"hard_mode": hard_rows,
|
| 1012 |
"profile_benchmark": profile_rows,
|
|
|
|
| 1013 |
"node_latency": node_latency_rows,
|
| 1014 |
"methodology": {
|
| 1015 |
"deterministic": (
|
|
|
|
| 11 |
answer_key_match,
|
| 12 |
citation_metrics,
|
| 13 |
mean,
|
| 14 |
+
missing_answer_match,
|
| 15 |
percentile,
|
| 16 |
safe_div,
|
| 17 |
scalar_value_match,
|
|
|
|
| 608 |
returned = _document_sources(result.sources, 5)
|
| 609 |
retrieval = source_metrics(returned, case.get("relevant_sources", [])) if case.get("relevant_sources") else {}
|
| 610 |
if kind == "missing":
|
| 611 |
+
passed = missing_answer_match(result.answer, case)
|
|
|
|
| 612 |
elif kind == "insight":
|
| 613 |
table_cited = any(str(src.get("id", "")).startswith("T") for src in result.sources) and "[T" in result.answer
|
| 614 |
evidence = result.trace.get("evidence", {})
|
| 615 |
passed = plan.get("task_type") == case.get("expected_task") and plan.get("retrieval_strategy") == case.get("expected_strategy") and float(evidence.get("source_coverage", 0.0) or 0.0) >= float(case.get("min_source_coverage", 0.0)) and (table_cited or not case.get("requires_table_citation"))
|
| 616 |
else:
|
| 617 |
passed = answer_key_match(result.answer, case) and float(retrieval.get("source_recall@5", 1.0)) >= 1.0
|
| 618 |
+
row.update({"route":plan.get("route"),"task":plan.get("task_type"),"strategy":plan.get("retrieval_strategy"),"answer_key_match":answer_key_match(result.answer,case) if kind=="qa" else None,"missing_answer_match":missing_answer_match(result.answer, case) if kind=="missing" else None,"citation_validity":round(float(citations["citation_validity"]),3),"citation_coverage":round(float(citations["citation_coverage"]),3),"source_recall@5":round(float(retrieval.get("source_recall@5",1.0)),3),"latency_ms":round(max(0.0,wall-pace),1),"pass":passed,"gemini_calls":int(result.trace.get("metrics",{}).get("llm_calls_estimate",0) or 0),"_answer":result.answer,"_sources":result.sources,"_node_times":_trace_node_times(result.trace)})
|
| 619 |
rows.append(row)
|
| 620 |
return rows
|
| 621 |
|
|
|
|
| 651 |
return rows
|
| 652 |
|
| 653 |
|
| 654 |
+
def _profile_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 655 |
+
summaries: list[dict[str, Any]] = []
|
| 656 |
+
for profile in ("Fast", "Balanced", "Agentic"):
|
| 657 |
+
group = [row for row in rows if row.get("profile") == profile]
|
| 658 |
+
if not group:
|
| 659 |
+
continue
|
| 660 |
+
summaries.append({
|
| 661 |
+
"profile": profile,
|
| 662 |
+
"answer_accuracy": round(mean([float(bool(row.get("answer_key_match"))) for row in group]), 3),
|
| 663 |
+
"citation_validity": round(mean([float(row.get("citation_validity", 0.0) or 0.0) for row in group]), 3),
|
| 664 |
+
"citation_coverage": round(mean([float(row.get("citation_coverage", 0.0) or 0.0) for row in group]), 3),
|
| 665 |
+
"median_latency_ms": round(percentile([float(row.get("latency_ms", 0.0) or 0.0) for row in group], 0.5), 1),
|
| 666 |
+
"mean_llm_calls": round(mean([float(row.get("llm_calls_estimate", 0.0) or 0.0) for row in group]), 2),
|
| 667 |
+
"reranker_rate": round(mean([float(bool(row.get("reranker_used"))) for row in group]), 3),
|
| 668 |
+
"cases": len(group),
|
| 669 |
+
})
|
| 670 |
+
return summaries
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
def _profile_recommendation(profile_summary: list[dict[str, Any]]) -> str:
|
| 674 |
+
if not profile_summary:
|
| 675 |
+
return ""
|
| 676 |
+
by_name = {row["profile"]: row for row in profile_summary}
|
| 677 |
+
fast = by_name.get("Fast")
|
| 678 |
+
balanced = by_name.get("Balanced")
|
| 679 |
+
agentic = by_name.get("Agentic")
|
| 680 |
+
if fast and balanced:
|
| 681 |
+
quality_close = (
|
| 682 |
+
float(fast.get("answer_accuracy", 0.0)) >= float(balanced.get("answer_accuracy", 0.0)) - 0.01
|
| 683 |
+
and float(fast.get("citation_coverage", 0.0)) >= float(balanced.get("citation_coverage", 0.0)) - 0.05
|
| 684 |
+
)
|
| 685 |
+
faster = float(fast.get("median_latency_ms", 0.0) or 0.0) < float(balanced.get("median_latency_ms", 0.0) or 0.0)
|
| 686 |
+
if quality_close and faster:
|
| 687 |
+
return "Fast matched Balanced quality on the sampled explicit-Documents cases with lower median latency. Keep Balanced as the general Auto default, but prefer Fast for simple local lookups."
|
| 688 |
+
if agentic and balanced and float(agentic.get("median_latency_ms", 0.0) or 0.0) > 2 * max(1.0, float(balanced.get("median_latency_ms", 0.0) or 0.0)):
|
| 689 |
+
return "Agentic was materially slower than Balanced on the sampled cases. Reserve Agentic for difficult or low-confidence work rather than routine lookups."
|
| 690 |
+
return "Profile differences were not large enough on this sample to justify changing the default execution policy."
|
| 691 |
+
|
| 692 |
+
|
| 693 |
def _diagnostics(
|
| 694 |
summary: dict[str, Any],
|
| 695 |
ablation_rows: list[dict[str, Any]],
|
| 696 |
planner_rows: list[dict[str, Any]],
|
| 697 |
sql_rows: list[dict[str, Any]],
|
| 698 |
hard_rows: list[dict[str, Any]] | None = None,
|
| 699 |
+
profile_summary: list[dict[str, Any]] | None = None,
|
| 700 |
+
node_latency_rows: list[dict[str, Any]] | None = None,
|
| 701 |
) -> list[dict[str, str]]:
|
| 702 |
findings: list[dict[str, str]] = []
|
| 703 |
|
|
|
|
| 714 |
}
|
| 715 |
)
|
| 716 |
|
| 717 |
+
if (
|
| 718 |
+
summary.get("source_recall@5", 0.0) >= 0.95
|
| 719 |
+
and summary.get("source_precision@5", 1.0) < 0.60
|
| 720 |
+
):
|
| 721 |
+
findings.append(
|
| 722 |
+
{
|
| 723 |
+
"severity": "info",
|
| 724 |
+
"area": "context efficiency",
|
| 725 |
+
"finding": (
|
| 726 |
+
f"Source Recall@5 is {float(summary.get('source_recall@5', 0.0)):.0%} while source Precision@5 is "
|
| 727 |
+
f"{float(summary.get('source_precision@5', 0.0)):.0%}; the correct source is consistently present, but extra distractor sources are also entering the context."
|
| 728 |
+
),
|
| 729 |
+
"recommendation": "Treat this as a context-budget signal. Test conservative focused-query pruning before reducing top-k globally, because overview and synthesis tasks still need breadth.",
|
| 730 |
+
}
|
| 731 |
+
)
|
| 732 |
+
|
| 733 |
if summary.get("citation_coverage", 1.0) < 0.90:
|
| 734 |
findings.append(
|
| 735 |
{
|
|
|
|
| 788 |
"recommendation": "Consider disabling reranking in Fast mode/small corpora, while retaining it for harder or larger corpora where chunk-level quality may improve.",
|
| 789 |
}
|
| 790 |
)
|
| 791 |
+
profile_summary = profile_summary or []
|
| 792 |
+
recommendation = _profile_recommendation(profile_summary)
|
| 793 |
+
if recommendation:
|
| 794 |
+
findings.append({
|
| 795 |
+
"severity": "info",
|
| 796 |
+
"area": "profile policy",
|
| 797 |
+
"finding": recommendation,
|
| 798 |
+
"recommendation": "Use the profile benchmark as local evidence only; repeat it on larger user corpora before making a global policy claim.",
|
| 799 |
+
})
|
| 800 |
+
|
| 801 |
+
node_latency_rows = node_latency_rows or []
|
| 802 |
+
if node_latency_rows:
|
| 803 |
+
dominant = max(node_latency_rows, key=lambda row: float(row.get("mean_ms", 0.0) or 0.0))
|
| 804 |
+
total_mean = sum(float(row.get("mean_ms", 0.0) or 0.0) for row in node_latency_rows)
|
| 805 |
+
share = safe_div(float(dominant.get("mean_ms", 0.0) or 0.0), total_mean)
|
| 806 |
+
if share >= 0.60:
|
| 807 |
+
findings.append({
|
| 808 |
+
"severity": "info",
|
| 809 |
+
"area": "latency",
|
| 810 |
+
"finding": f"{dominant.get('node', 'generation')} dominates mean node time at approximately {share:.0%} of measured pipeline-node latency.",
|
| 811 |
+
"recommendation": "Prioritize model/generation efficiency before micro-optimizing millisecond-scale retrieval stages.",
|
| 812 |
+
})
|
| 813 |
+
|
| 814 |
if not findings:
|
| 815 |
findings.append(
|
| 816 |
{
|
|
|
|
| 899 |
report.get("semantic_planner", []),
|
| 900 |
report.get("text2sql", []),
|
| 901 |
report.get("hard_mode", []),
|
| 902 |
+
report.get("profile_summary", []),
|
| 903 |
+
report.get("node_latency", []),
|
| 904 |
)
|
| 905 |
report.setdefault("methodology", {})["evaluation_cache"] = (
|
| 906 |
"Deep reused the current cached Standard deterministic baseline and issued only sampled judge calls."
|
|
|
|
| 1076 |
**{key: round(value, 3) for key, value in judge_summary.items()},
|
| 1077 |
}
|
| 1078 |
|
|
|
|
|
|
|
|
|
|
| 1079 |
node_latency_rows = _node_latency_summary(qa_rows + overview_rows + hard_rows)
|
| 1080 |
+
profile_summary_rows = _profile_summary(profile_rows)
|
| 1081 |
+
profile_recommendation = _profile_recommendation(profile_summary_rows)
|
| 1082 |
+
if profile_recommendation:
|
| 1083 |
+
summary["profile_recommendation"] = profile_recommendation
|
| 1084 |
+
diagnostics = _diagnostics(
|
| 1085 |
+
summary, ablation_rows, planner_rows, sql_rows, hard_rows, profile_summary_rows, node_latency_rows
|
| 1086 |
+
)
|
| 1087 |
+
summary["evaluation_wall_ms"] = round((time.perf_counter() - wall_started) * 1000, 1)
|
| 1088 |
progress(1.0, "Evaluation complete")
|
| 1089 |
return {
|
| 1090 |
"summary": summary,
|
|
|
|
| 1097 |
"retrieval_ablation": ablation_rows,
|
| 1098 |
"hard_mode": hard_rows,
|
| 1099 |
"profile_benchmark": profile_rows,
|
| 1100 |
+
"profile_summary": profile_summary_rows,
|
| 1101 |
"node_latency": node_latency_rows,
|
| 1102 |
"methodology": {
|
| 1103 |
"deterministic": (
|
src/ragforge/pipeline.py
CHANGED
|
@@ -45,6 +45,7 @@ class GraphState(TypedDict, total=False):
|
|
| 45 |
abstain_reason: str
|
| 46 |
evidence: EvidenceAssessment
|
| 47 |
trace: dict[str, Any]
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
class RAGEngine:
|
|
@@ -104,7 +105,11 @@ class RAGEngine:
|
|
| 104 |
graph.add_edge("generate", "verify")
|
| 105 |
graph.add_conditional_edges(
|
| 106 |
"verify",
|
| 107 |
-
lambda s: "revise" if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
{"revise": "revise", "end": END},
|
| 109 |
)
|
| 110 |
graph.add_edge("revise", "verify")
|
|
@@ -184,7 +189,7 @@ class RAGEngine:
|
|
| 184 |
"q": query,
|
| 185 |
"c": config.model_dump(),
|
| 186 |
"v": self.workspace.version,
|
| 187 |
-
"pipeline":
|
| 188 |
},
|
| 189 |
sort_keys=True,
|
| 190 |
)
|
|
@@ -209,13 +214,18 @@ class RAGEngine:
|
|
| 209 |
chunk_count = len(self.workspace.chunks)
|
| 210 |
if plan.retrieval_strategy == "global":
|
| 211 |
return False, "global_source_profiles_already_balance_sources"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
if cfg.profile == "Agentic":
|
| 213 |
-
return True, "
|
| 214 |
if plan.task_type in {"comparison", "cross_document_synthesis", "insight_synthesis"}:
|
| 215 |
-
return True, "
|
| 216 |
if chunk_count >= 250 or source_count >= 10:
|
| 217 |
return True, "larger_corpus"
|
| 218 |
-
return False, "
|
| 219 |
|
| 220 |
@staticmethod
|
| 221 |
def _normalize_citation_syntax(answer: str) -> str:
|
|
@@ -245,6 +255,7 @@ class RAGEngine:
|
|
| 245 |
"reranker_used": any(bool(n.get("reranker_used", False)) for n in nodes),
|
| 246 |
"citation_repairs": sum(int(n.get("citation_repairs", 0) or 0) for n in nodes),
|
| 247 |
"table_evidence_used": any(int(n.get("table_sources", 0) or 0) > 0 for n in nodes),
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
def _record(self, state: GraphState, node: str, started: float, **extra: Any) -> None:
|
|
@@ -553,6 +564,13 @@ class RAGEngine:
|
|
| 553 |
elif strategy == "global":
|
| 554 |
state["doc_hits"] = self.workspace.global_evidence(query, cfg.top_k, use_reranker)
|
| 555 |
state["selected_sources"] = list(dict.fromkeys(h.chunk.source for h in state["doc_hits"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
elif strategy == "hierarchical":
|
| 557 |
queries = list(state.get("document_queries") or [query])
|
| 558 |
if state.get("hyde"):
|
|
@@ -988,6 +1006,8 @@ Requirements:
|
|
| 988 |
evidence.score if evidence else None,
|
| 989 |
)
|
| 990 |
state["confidence"] = min(float(state.get("confidence", local)), local)
|
|
|
|
|
|
|
| 991 |
self_rag_used = False
|
| 992 |
if cfg.profile == "Agentic" and cfg.use_self_rag and state.get("context"):
|
| 993 |
try:
|
|
@@ -1003,6 +1023,7 @@ Requirements:
|
|
| 1003 |
t,
|
| 1004 |
confidence=round(state["confidence"], 3),
|
| 1005 |
self_rag=self_rag_used,
|
|
|
|
| 1006 |
llm_calls=int(self_rag_used),
|
| 1007 |
)
|
| 1008 |
return state
|
|
@@ -1103,6 +1124,18 @@ Return only the revised answer."""
|
|
| 1103 |
)
|
| 1104 |
return sources
|
| 1105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
@staticmethod
|
| 1107 |
def _local_confidence(
|
| 1108 |
answer: str,
|
|
@@ -1116,7 +1149,10 @@ Return only the revised answer."""
|
|
| 1116 |
citation_count = len(re.findall(r"\[(?:D|W|T)\d+\]", answer))
|
| 1117 |
citation_factor = min(1.0, citation_count / max(1, min(3, sources)))
|
| 1118 |
evidence = evidence_score if evidence_score is not None else RAGEngine._evidence_strength(doc_hits, web_hits)
|
| 1119 |
-
|
|
|
|
|
|
|
|
|
|
| 1120 |
return max(0.05, min(0.98, 0.30 + 0.35 * citation_factor + 0.35 * evidence - unsupported_language))
|
| 1121 |
|
| 1122 |
@staticmethod
|
|
|
|
| 45 |
abstain_reason: str
|
| 46 |
evidence: EvidenceAssessment
|
| 47 |
trace: dict[str, Any]
|
| 48 |
+
grounded_absence: bool
|
| 49 |
|
| 50 |
|
| 51 |
class RAGEngine:
|
|
|
|
| 105 |
graph.add_edge("generate", "verify")
|
| 106 |
graph.add_conditional_edges(
|
| 107 |
"verify",
|
| 108 |
+
lambda s: "revise" if (
|
| 109 |
+
s.get("attempts", 0) < 1
|
| 110 |
+
and s.get("confidence", 1.0) < 0.58
|
| 111 |
+
and not s.get("grounded_absence", False)
|
| 112 |
+
) else "end",
|
| 113 |
{"revise": "revise", "end": END},
|
| 114 |
)
|
| 115 |
graph.add_edge("revise", "verify")
|
|
|
|
| 189 |
"q": query,
|
| 190 |
"c": config.model_dump(),
|
| 191 |
"v": self.workspace.version,
|
| 192 |
+
"pipeline": 8,
|
| 193 |
},
|
| 194 |
sort_keys=True,
|
| 195 |
)
|
|
|
|
| 214 |
chunk_count = len(self.workspace.chunks)
|
| 215 |
if plan.retrieval_strategy == "global":
|
| 216 |
return False, "global_source_profiles_already_balance_sources"
|
| 217 |
+
# The v1.6 source- and chunk-level ablation showed no ranking gain on
|
| 218 |
+
# the bundled five-source/90-chunk corpus while reranking added seconds.
|
| 219 |
+
# Treat the reranker as a large/hard-corpus capability, not a profile tax.
|
| 220 |
+
if chunk_count < 250 and source_count < 10:
|
| 221 |
+
return False, "small_corpus_source_and_chunk_benchmark_no_gain"
|
| 222 |
if cfg.profile == "Agentic":
|
| 223 |
+
return True, "agentic_larger_corpus"
|
| 224 |
if plan.task_type in {"comparison", "cross_document_synthesis", "insight_synthesis"}:
|
| 225 |
+
return True, "multi_source_reasoning_larger_corpus"
|
| 226 |
if chunk_count >= 250 or source_count >= 10:
|
| 227 |
return True, "larger_corpus"
|
| 228 |
+
return False, "adaptive_skip"
|
| 229 |
|
| 230 |
@staticmethod
|
| 231 |
def _normalize_citation_syntax(answer: str) -> str:
|
|
|
|
| 255 |
"reranker_used": any(bool(n.get("reranker_used", False)) for n in nodes),
|
| 256 |
"citation_repairs": sum(int(n.get("citation_repairs", 0) or 0) for n in nodes),
|
| 257 |
"table_evidence_used": any(int(n.get("table_sources", 0) or 0) > 0 for n in nodes),
|
| 258 |
+
"grounded_absence": any(bool(n.get("grounded_absence", False)) for n in nodes),
|
| 259 |
}
|
| 260 |
|
| 261 |
def _record(self, state: GraphState, node: str, started: float, **extra: Any) -> None:
|
|
|
|
| 564 |
elif strategy == "global":
|
| 565 |
state["doc_hits"] = self.workspace.global_evidence(query, cfg.top_k, use_reranker)
|
| 566 |
state["selected_sources"] = list(dict.fromkeys(h.chunk.source for h in state["doc_hits"]))
|
| 567 |
+
# Corpus overviews may summarize structured sources too. Surface the
|
| 568 |
+
# deterministic table evidence explicitly so [T#] citations are
|
| 569 |
+
# valid and numeric/table claims do not rely on manifest metadata.
|
| 570 |
+
if plan.task_type == "overview" and self.workspace.sql.tables:
|
| 571 |
+
structured_context, table_sources = self.workspace.sql.analytics_context(max_rows=12)
|
| 572 |
+
state["structured_context"] = structured_context
|
| 573 |
+
state["table_sources"] = table_sources
|
| 574 |
elif strategy == "hierarchical":
|
| 575 |
queries = list(state.get("document_queries") or [query])
|
| 576 |
if state.get("hyde"):
|
|
|
|
| 1006 |
evidence.score if evidence else None,
|
| 1007 |
)
|
| 1008 |
state["confidence"] = min(float(state.get("confidence", local)), local)
|
| 1009 |
+
grounded_absence = self._looks_like_grounded_absence(answer)
|
| 1010 |
+
state["grounded_absence"] = grounded_absence
|
| 1011 |
self_rag_used = False
|
| 1012 |
if cfg.profile == "Agentic" and cfg.use_self_rag and state.get("context"):
|
| 1013 |
try:
|
|
|
|
| 1023 |
t,
|
| 1024 |
confidence=round(state["confidence"], 3),
|
| 1025 |
self_rag=self_rag_used,
|
| 1026 |
+
grounded_absence=grounded_absence,
|
| 1027 |
llm_calls=int(self_rag_used),
|
| 1028 |
)
|
| 1029 |
return state
|
|
|
|
| 1124 |
)
|
| 1125 |
return sources
|
| 1126 |
|
| 1127 |
+
@staticmethod
|
| 1128 |
+
def _looks_like_grounded_absence(answer: str) -> bool:
|
| 1129 |
+
text = re.sub(r"\s+", " ", (answer or "").casefold())
|
| 1130 |
+
cues = (
|
| 1131 |
+
"not specified", "does not specify", "doesn't specify", "not provided",
|
| 1132 |
+
"does not mention", "doesn't mention", "do not mention", "not mentioned",
|
| 1133 |
+
"does not contain", "no information", "insufficient to answer",
|
| 1134 |
+
"insufficient evidence", "cannot determine", "can't determine",
|
| 1135 |
+
"not stated", "not present in",
|
| 1136 |
+
)
|
| 1137 |
+
return bool(re.search(r"\[(?:D|W|T)\d+\]", answer or "")) and any(cue in text for cue in cues)
|
| 1138 |
+
|
| 1139 |
@staticmethod
|
| 1140 |
def _local_confidence(
|
| 1141 |
answer: str,
|
|
|
|
| 1149 |
citation_count = len(re.findall(r"\[(?:D|W|T)\d+\]", answer))
|
| 1150 |
citation_factor = min(1.0, citation_count / max(1, min(3, sources)))
|
| 1151 |
evidence = evidence_score if evidence_score is not None else RAGEngine._evidence_strength(doc_hits, web_hits)
|
| 1152 |
+
cautious = "don't have enough" in answer.lower() or "insufficient" in answer.lower()
|
| 1153 |
+
# Calibrated uncertainty is not a hallucination signal when the answer
|
| 1154 |
+
# explicitly grounds the absence claim in retrieved evidence.
|
| 1155 |
+
unsupported_language = 0.25 if cautious and not RAGEngine._looks_like_grounded_absence(answer) else 0.0
|
| 1156 |
return max(0.05, min(0.98, 0.30 + 0.35 * citation_factor + 0.35 * evidence - unsupported_language))
|
| 1157 |
|
| 1158 |
@staticmethod
|
src/ragforge/ui.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import re
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Any
|
|
@@ -24,6 +25,16 @@ CSS = """
|
|
| 24 |
.muted {opacity: .75;}
|
| 25 |
.status-ready {padding: 8px 10px; border-radius: 8px;}
|
| 26 |
.status-line {padding: 8px 10px; border: 1px solid rgba(128,128,128,.25); border-radius: 8px; margin: 6px 0;}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
| 28 |
|
| 29 |
|
|
@@ -39,7 +50,7 @@ def _ensure_session(session_id: str | None) -> tuple[str, Any]:
|
|
| 39 |
def _ui_text(text: str) -> str:
|
| 40 |
# Keep UI punctuation visually compact even when model/source text contains
|
| 41 |
# typographic dash glyphs. The underlying retrieved evidence is unchanged.
|
| 42 |
-
return (text or "").replace("\u2014", "-").replace("\u2013", "-")
|
| 43 |
|
| 44 |
|
| 45 |
def _truncate_preview(text: str, limit: int = 420) -> str:
|
|
@@ -64,48 +75,67 @@ def _corpus_markdown(summary, prefix: str | None = None) -> str:
|
|
| 64 |
|
| 65 |
|
| 66 |
def _sources_markdown(sources: list[dict[str, Any]]) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
if not sources:
|
| 68 |
return "*No sources returned.*"
|
| 69 |
blocks: list[str] = []
|
| 70 |
-
for
|
| 71 |
-
sid =
|
| 72 |
-
title =
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
else:
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
)
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
def _latency_waterfall(trace: dict[str, Any]) -> str:
|
| 99 |
-
|
|
|
|
| 100 |
if not nodes:
|
| 101 |
return "*No node timings available.*"
|
| 102 |
max_ms = max(float(n.get("ms", 0.0) or 0.0) for n in nodes) or 1.0
|
| 103 |
-
|
| 104 |
for node in nodes:
|
| 105 |
ms = float(node.get("ms", 0.0) or 0.0)
|
| 106 |
-
width = max(1, min(
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
def _inspector_markdown(trace: dict[str, Any]) -> str:
|
|
@@ -116,11 +146,13 @@ def _inspector_markdown(trace: dict[str, Any]) -> str:
|
|
| 116 |
evidence = trace.get("evidence", {})
|
| 117 |
nodes = [n.get("node") for n in trace.get("nodes", []) if n.get("node")]
|
| 118 |
metrics = trace.get("metrics", {})
|
|
|
|
| 119 |
coverage = float(evidence.get("source_coverage", 0.0) or 0.0)
|
|
|
|
| 120 |
return (
|
| 121 |
"**Workspace** \n"
|
| 122 |
f"{workspace.get('sources', 0)} sources - {workspace.get('chunks', 0)} chunks - "
|
| 123 |
-
f"{
|
| 124 |
"**Semantic plan** \n"
|
| 125 |
f"Route: `{plan.get('route', '-')}` - scope: `{plan.get('knowledge_scope', '-')}` - "
|
| 126 |
f"task: `{plan.get('task_type', '-')}` - strategy: `{plan.get('retrieval_strategy', '-')}` - "
|
|
@@ -134,7 +166,9 @@ def _inspector_markdown(trace: dict[str, Any]) -> str:
|
|
| 134 |
f"web used: `{bool(metrics.get('web_used', False))}` - "
|
| 135 |
f"correction used: `{bool(metrics.get('correction_used', False))}` - "
|
| 136 |
f"reranker used: `{bool(metrics.get('reranker_used', False))}` - "
|
| 137 |
-
f"citation repairs: `{int(metrics.get('citation_repairs', 0) or 0)}`
|
|
|
|
|
|
|
| 138 |
f"**Execution path** \n`{' -> '.join(nodes) if nodes else '-'}`\n\n"
|
| 139 |
"**Node latency waterfall** \n" + _latency_waterfall(trace)
|
| 140 |
)
|
|
@@ -149,6 +183,7 @@ def _eval_summary_markdown(report: dict[str, Any]) -> str:
|
|
| 149 |
"",
|
| 150 |
f"**Deterministic quality:** `{float(summary.get('deterministic_quality_score', 0.0)):.3f}` - "
|
| 151 |
f"**answer accuracy:** `{float(summary.get('answer_accuracy', 0.0)):.0%}` - "
|
|
|
|
| 152 |
f"**source Recall@5:** `{float(summary.get('source_recall@5', 0.0)):.0%}` - "
|
| 153 |
f"**Hit@1:** `{float(summary.get('source_hit@1', 0.0)):.0%}` - "
|
| 154 |
f"**MRR:** `{float(summary.get('source_mrr', 0.0)):.3f}` - "
|
|
@@ -201,6 +236,8 @@ def _eval_summary_markdown(report: dict[str, Any]) -> str:
|
|
| 201 |
"",
|
| 202 |
f"**Profile benchmark:** `{int(summary.get('profile_benchmark_cases', 0) or 0)}` profile/case runs included.",
|
| 203 |
])
|
|
|
|
|
|
|
| 204 |
if summary.get("reused_standard_baseline"):
|
| 205 |
lines += [
|
| 206 |
"",
|
|
@@ -218,8 +255,8 @@ def _eval_diagnostics_markdown(report: dict[str, Any]) -> str:
|
|
| 218 |
for row in diagnostics:
|
| 219 |
severity = str(row.get("severity", "info")).upper()
|
| 220 |
lines.append(
|
| 221 |
-
f"- **{severity} - {row.get('area', 'benchmark')}:** {row.get('finding', '')}
|
| 222 |
-
f"**Next:** {row.get('recommendation', '')}"
|
| 223 |
)
|
| 224 |
return "\n".join(lines)
|
| 225 |
|
|
@@ -270,7 +307,7 @@ def _architecture_snapshot(session_id: str | None) -> tuple[str, str, str, dict[
|
|
| 270 |
settings = get_settings()
|
| 271 |
stats = ws.stats()
|
| 272 |
runtime_json = {
|
| 273 |
-
"ragforge_version": "1.
|
| 274 |
"workspace": stats,
|
| 275 |
"models": {
|
| 276 |
"generation": settings.default_model,
|
|
@@ -291,7 +328,7 @@ def _architecture_snapshot(session_id: str | None) -> tuple[str, str, str, dict[
|
|
| 291 |
}
|
| 292 |
runtime = (
|
| 293 |
"### Live runtime\n"
|
| 294 |
-
f"**RAGForge:** `v1.
|
| 295 |
f"**Corpus:** `{stats['sources']}` sources - `{stats['chunks']}` chunks - "
|
| 296 |
f"`{stats['source_profiles']}` source profiles - `{stats['tables']}` tables - "
|
| 297 |
f"corpus version `{stats['version']}`\n\n"
|
|
@@ -343,6 +380,7 @@ EVAL_TABLE_KEYS = {
|
|
| 343 |
"Retrieval ablation": "retrieval_ablation",
|
| 344 |
"Hard mode": "hard_mode",
|
| 345 |
"Profile benchmark": "profile_benchmark",
|
|
|
|
| 346 |
"Node latency": "node_latency",
|
| 347 |
"Abstention": "abstention",
|
| 348 |
"Compare saved runs": "__compare__",
|
|
@@ -392,6 +430,7 @@ def _eval_comparison_frame(ws) -> pd.DataFrame:
|
|
| 392 |
"pacing_wait_s": round(float(summary.get("pacing_sleep_ms", 0.0) or 0.0) / 1000, 1),
|
| 393 |
"deep_judge_overall": summary.get("judge_overall", ""),
|
| 394 |
"current_corpus": item.get("current_corpus", False),
|
|
|
|
| 395 |
"saved_at": item.get("saved_at", ""),
|
| 396 |
}
|
| 397 |
)
|
|
@@ -405,10 +444,19 @@ def _saved_eval_status(ws) -> str:
|
|
| 405 |
parts = []
|
| 406 |
for item in inventory:
|
| 407 |
stale = "" if item.get("current_corpus") else " (stale corpus)"
|
| 408 |
-
|
|
|
|
| 409 |
return "**Saved runs:** " + " - ".join(parts)
|
| 410 |
|
| 411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
def build_ui() -> gr.Blocks:
|
| 413 |
settings = get_settings()
|
| 414 |
with gr.Blocks(css=CSS, title="RAGForge") as demo:
|
|
@@ -480,7 +528,7 @@ def build_ui() -> gr.Blocks:
|
|
| 480 |
ask_btn = gr.Button("Ask", variant="primary")
|
| 481 |
query_status = gr.Markdown("Ready.", elem_classes=["status-line"])
|
| 482 |
with gr.Accordion("Sources", open=True):
|
| 483 |
-
source_view = gr.Markdown("*Sources appear here.*")
|
| 484 |
with gr.Accordion("Pipeline inspector", open=False):
|
| 485 |
inspector_summary = gr.Markdown(
|
| 486 |
"*Run a query to inspect routing, retrieval and evidence decisions.*"
|
|
@@ -895,10 +943,7 @@ def build_ui() -> gr.Blocks:
|
|
| 895 |
)
|
| 896 |
meta = report.get("evaluation_cache", {})
|
| 897 |
stale = int(meta.get("workspace_version", -1)) != int(ws.version)
|
| 898 |
-
status = (
|
| 899 |
-
f"**Loaded saved {level} evaluation.** "
|
| 900 |
-
+ ("This result belongs to an older corpus version." if stale else "No Gemini requests were used.")
|
| 901 |
-
)
|
| 902 |
outputs = _evaluation_outputs(ws, report, status)
|
| 903 |
return (sid, outputs[-1], *outputs[:-1])
|
| 904 |
|
|
@@ -921,7 +966,7 @@ def build_ui() -> gr.Blocks:
|
|
| 921 |
outputs = _evaluation_outputs(
|
| 922 |
ws,
|
| 923 |
cached,
|
| 924 |
-
|
| 925 |
)
|
| 926 |
return (
|
| 927 |
sid,
|
|
@@ -964,10 +1009,15 @@ def build_ui() -> gr.Blocks:
|
|
| 964 |
if report.get("summary", {}).get("reused_standard_baseline")
|
| 965 |
else ""
|
| 966 |
)
|
|
|
|
|
|
|
|
|
|
| 967 |
outputs = _evaluation_outputs(
|
| 968 |
ws,
|
| 969 |
report,
|
| 970 |
-
f"**
|
|
|
|
|
|
|
| 971 |
)
|
| 972 |
return (
|
| 973 |
sid,
|
|
@@ -1057,7 +1107,7 @@ def build_ui() -> gr.Blocks:
|
|
| 1057 |
show_progress="hidden",
|
| 1058 |
)
|
| 1059 |
|
| 1060 |
-
eval_saved_level.
|
| 1061 |
load_saved_eval,
|
| 1062 |
[session_state, eval_saved_level],
|
| 1063 |
[
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import html
|
| 4 |
import re
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Any
|
|
|
|
| 25 |
.muted {opacity: .75;}
|
| 26 |
.status-ready {padding: 8px 10px; border-radius: 8px;}
|
| 27 |
.status-line {padding: 8px 10px; border: 1px solid rgba(128,128,128,.25); border-radius: 8px; margin: 6px 0;}
|
| 28 |
+
#source-panel .source-card {padding: 4px 0;}
|
| 29 |
+
#source-panel .source-title {font-size: 1rem; font-weight: 650; line-height: 1.35;}
|
| 30 |
+
#source-panel .source-meta {font-size: .88rem; opacity: .78; line-height: 1.4; margin-top: 2px;}
|
| 31 |
+
#source-panel .source-snippet {font-size: .95rem; line-height: 1.5; margin-top: 8px; white-space: normal;}
|
| 32 |
+
.latency-waterfall {display: grid; gap: 7px; margin-top: 8px;}
|
| 33 |
+
.latency-row {display: grid; grid-template-columns: minmax(72px, 110px) 1fr minmax(70px, 90px); gap: 8px; align-items: center;}
|
| 34 |
+
.latency-label {font-size: .88rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;}
|
| 35 |
+
.latency-track {height: 9px; border-radius: 999px; background: rgba(128,128,128,.18); overflow: hidden;}
|
| 36 |
+
.latency-fill {height: 100%; min-width: 2px; border-radius: 999px; background: var(--primary-500, currentColor);}
|
| 37 |
+
.latency-time {font-size: .84rem; text-align: right; opacity: .8;}
|
| 38 |
"""
|
| 39 |
|
| 40 |
|
|
|
|
| 50 |
def _ui_text(text: str) -> str:
|
| 51 |
# Keep UI punctuation visually compact even when model/source text contains
|
| 52 |
# typographic dash glyphs. The underlying retrieved evidence is unchanged.
|
| 53 |
+
return (text or "").replace("\u2014", " - ").replace("\u2013", " - ")
|
| 54 |
|
| 55 |
|
| 56 |
def _truncate_preview(text: str, limit: int = 420) -> str:
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
def _sources_markdown(sources: list[dict[str, Any]]) -> str:
|
| 78 |
+
"""Render source cards with plain-text snippets and uniform typography.
|
| 79 |
+
|
| 80 |
+
Retrieved Markdown headings such as ``# Acme Cloud`` must never become UI
|
| 81 |
+
headings inside the source panel. All source-controlled text is HTML-escaped
|
| 82 |
+
before rendering, while the surrounding card markup owns the typography.
|
| 83 |
+
"""
|
| 84 |
if not sources:
|
| 85 |
return "*No sources returned.*"
|
| 86 |
blocks: list[str] = []
|
| 87 |
+
for source in sources:
|
| 88 |
+
sid = html.escape(str(source.get("id", "?")))
|
| 89 |
+
title = html.escape(_ui_text(str(source.get("title", "Source"))))
|
| 90 |
+
preview = html.escape(_truncate_preview(str(source.get("snippet", ""))))
|
| 91 |
+
source_type = str(source.get("type", "document"))
|
| 92 |
+
page = source.get("page")
|
| 93 |
+
page_text = f" - page {html.escape(str(page))}" if page else ""
|
| 94 |
+
|
| 95 |
+
meta: list[str] = []
|
| 96 |
+
if source_type == "web" and source.get("url"):
|
| 97 |
+
url = html.escape(str(source.get("url")), quote=True)
|
| 98 |
+
meta.append(f'<a href="{url}" target="_blank" rel="noopener noreferrer">Open web source</a>')
|
| 99 |
+
meta.append(f"Retrieval rank: #{html.escape(str(source.get('rank', '-')))}")
|
| 100 |
+
elif source_type in {"sql", "table"}:
|
| 101 |
+
meta.append(f"Rows: {html.escape(str(source.get('rows', '-')))}")
|
| 102 |
+
if source.get("schema"):
|
| 103 |
+
meta.append("Schema: " + html.escape(_ui_text(str(source.get("schema")))) )
|
| 104 |
else:
|
| 105 |
+
meta.append(f"Retrieval rank: #{html.escape(str(source.get('rank', '-')))}")
|
| 106 |
+
meta.append(f"hybrid signal: {html.escape(str(source.get('retrieval_signal', '-')))}")
|
| 107 |
+
|
| 108 |
+
blocks.append(
|
| 109 |
+
'<div class="source-card">'
|
| 110 |
+
f'<div class="source-title">[{sid}] {title}{page_text}</div>'
|
| 111 |
+
f'<div class="source-meta">{" - ".join(meta)}</div>'
|
| 112 |
+
+ (f'<div class="source-snippet">{preview}</div>' if preview else '')
|
| 113 |
+
+ '</div>'
|
| 114 |
+
)
|
| 115 |
+
return '<hr>'.join(blocks)
|
| 116 |
|
| 117 |
|
| 118 |
def _latency_waterfall(trace: dict[str, Any]) -> str:
|
| 119 |
+
"""Render a compact proportional latency bar instead of ASCII hashes."""
|
| 120 |
+
nodes = [n for n in trace.get("nodes", []) if float(n.get("ms", 0.0) or 0.0) >= 0.0]
|
| 121 |
if not nodes:
|
| 122 |
return "*No node timings available.*"
|
| 123 |
max_ms = max(float(n.get("ms", 0.0) or 0.0) for n in nodes) or 1.0
|
| 124 |
+
rows: list[str] = []
|
| 125 |
for node in nodes:
|
| 126 |
ms = float(node.get("ms", 0.0) or 0.0)
|
| 127 |
+
width = 0.0 if ms <= 0 else max(1.5, min(100.0, 100.0 * ms / max_ms))
|
| 128 |
+
name = html.escape(str(node.get("node", "-")))
|
| 129 |
+
rows.append(
|
| 130 |
+
'<div class="latency-row">'
|
| 131 |
+
f'<div class="latency-label">{name}</div>'
|
| 132 |
+
'<div class="latency-track">'
|
| 133 |
+
f'<div class="latency-fill" style="width:{width:.1f}%"></div>'
|
| 134 |
+
'</div>'
|
| 135 |
+
f'<div class="latency-time">{ms:.0f} ms</div>'
|
| 136 |
+
'</div>'
|
| 137 |
+
)
|
| 138 |
+
return '<div class="latency-waterfall">' + ''.join(rows) + '</div>'
|
| 139 |
|
| 140 |
|
| 141 |
def _inspector_markdown(trace: dict[str, Any]) -> str:
|
|
|
|
| 146 |
evidence = trace.get("evidence", {})
|
| 147 |
nodes = [n.get("node") for n in trace.get("nodes", []) if n.get("node")]
|
| 148 |
metrics = trace.get("metrics", {})
|
| 149 |
+
retrieve_node = next((node for node in trace.get("nodes", []) if node.get("node") == "retrieve"), {})
|
| 150 |
coverage = float(evidence.get("source_coverage", 0.0) or 0.0)
|
| 151 |
+
table_count = int(workspace.get("tables", 0) or 0)
|
| 152 |
return (
|
| 153 |
"**Workspace** \n"
|
| 154 |
f"{workspace.get('sources', 0)} sources - {workspace.get('chunks', 0)} chunks - "
|
| 155 |
+
f"{table_count} {'table' if table_count == 1 else 'tables'} - version {workspace.get('version', 0)}\n\n"
|
| 156 |
"**Semantic plan** \n"
|
| 157 |
f"Route: `{plan.get('route', '-')}` - scope: `{plan.get('knowledge_scope', '-')}` - "
|
| 158 |
f"task: `{plan.get('task_type', '-')}` - strategy: `{plan.get('retrieval_strategy', '-')}` - "
|
|
|
|
| 166 |
f"web used: `{bool(metrics.get('web_used', False))}` - "
|
| 167 |
f"correction used: `{bool(metrics.get('correction_used', False))}` - "
|
| 168 |
f"reranker used: `{bool(metrics.get('reranker_used', False))}` - "
|
| 169 |
+
f"citation repairs: `{int(metrics.get('citation_repairs', 0) or 0)}` - "
|
| 170 |
+
f"grounded absence: `{bool(metrics.get('grounded_absence', False))}` \n"
|
| 171 |
+
f"Reranker decision: `{retrieve_node.get('reranker_reason', '-')}`\n\n"
|
| 172 |
f"**Execution path** \n`{' -> '.join(nodes) if nodes else '-'}`\n\n"
|
| 173 |
"**Node latency waterfall** \n" + _latency_waterfall(trace)
|
| 174 |
)
|
|
|
|
| 183 |
"",
|
| 184 |
f"**Deterministic quality:** `{float(summary.get('deterministic_quality_score', 0.0)):.3f}` - "
|
| 185 |
f"**answer accuracy:** `{float(summary.get('answer_accuracy', 0.0)):.0%}` - "
|
| 186 |
+
f"**source Precision@5:** `{float(summary.get('source_precision@5', 0.0)):.0%}` - "
|
| 187 |
f"**source Recall@5:** `{float(summary.get('source_recall@5', 0.0)):.0%}` - "
|
| 188 |
f"**Hit@1:** `{float(summary.get('source_hit@1', 0.0)):.0%}` - "
|
| 189 |
f"**MRR:** `{float(summary.get('source_mrr', 0.0)):.3f}` - "
|
|
|
|
| 236 |
"",
|
| 237 |
f"**Profile benchmark:** `{int(summary.get('profile_benchmark_cases', 0) or 0)}` profile/case runs included.",
|
| 238 |
])
|
| 239 |
+
if summary.get("profile_recommendation"):
|
| 240 |
+
lines.extend(["", f"**Profile policy:** {summary.get('profile_recommendation')}"])
|
| 241 |
if summary.get("reused_standard_baseline"):
|
| 242 |
lines += [
|
| 243 |
"",
|
|
|
|
| 255 |
for row in diagnostics:
|
| 256 |
severity = str(row.get("severity", "info")).upper()
|
| 257 |
lines.append(
|
| 258 |
+
f"- **{severity} - {row.get('area', 'benchmark')}:** {row.get('finding', '')} \n"
|
| 259 |
+
f" **Next:** {row.get('recommendation', '')}"
|
| 260 |
)
|
| 261 |
return "\n".join(lines)
|
| 262 |
|
|
|
|
| 307 |
settings = get_settings()
|
| 308 |
stats = ws.stats()
|
| 309 |
runtime_json = {
|
| 310 |
+
"ragforge_version": "1.7.0",
|
| 311 |
"workspace": stats,
|
| 312 |
"models": {
|
| 313 |
"generation": settings.default_model,
|
|
|
|
| 328 |
}
|
| 329 |
runtime = (
|
| 330 |
"### Live runtime\n"
|
| 331 |
+
f"**RAGForge:** `v1.7.0` - **workspace:** `{sid[:12]}...` - **status:** `{stats['status']}`\n\n"
|
| 332 |
f"**Corpus:** `{stats['sources']}` sources - `{stats['chunks']}` chunks - "
|
| 333 |
f"`{stats['source_profiles']}` source profiles - `{stats['tables']}` tables - "
|
| 334 |
f"corpus version `{stats['version']}`\n\n"
|
|
|
|
| 380 |
"Retrieval ablation": "retrieval_ablation",
|
| 381 |
"Hard mode": "hard_mode",
|
| 382 |
"Profile benchmark": "profile_benchmark",
|
| 383 |
+
"Profile summary": "profile_summary",
|
| 384 |
"Node latency": "node_latency",
|
| 385 |
"Abstention": "abstention",
|
| 386 |
"Compare saved runs": "__compare__",
|
|
|
|
| 430 |
"pacing_wait_s": round(float(summary.get("pacing_sleep_ms", 0.0) or 0.0) / 1000, 1),
|
| 431 |
"deep_judge_overall": summary.get("judge_overall", ""),
|
| 432 |
"current_corpus": item.get("current_corpus", False),
|
| 433 |
+
"run_id": item.get("run_id", ""),
|
| 434 |
"saved_at": item.get("saved_at", ""),
|
| 435 |
}
|
| 436 |
)
|
|
|
|
| 444 |
parts = []
|
| 445 |
for item in inventory:
|
| 446 |
stale = "" if item.get("current_corpus") else " (stale corpus)"
|
| 447 |
+
run_id = item.get("run_id") or "legacy"
|
| 448 |
+
parts.append(f"`{item['level']}` grade {item.get('grade', '-')} - run `{run_id}`{stale}")
|
| 449 |
return "**Saved runs:** " + " - ".join(parts)
|
| 450 |
|
| 451 |
|
| 452 |
+
def _saved_eval_message(level: str, report: dict[str, Any], *, stale: bool = False) -> str:
|
| 453 |
+
meta = report.get("evaluation_cache", {}) if report else {}
|
| 454 |
+
run_id = meta.get("run_id") or "legacy"
|
| 455 |
+
saved_at = meta.get("saved_at") or "unknown time"
|
| 456 |
+
suffix = "This result belongs to an older corpus version." if stale else "0 new Gemini requests were used."
|
| 457 |
+
return f"**Loaded saved {level} run `{run_id}` from {saved_at}.** {suffix}"
|
| 458 |
+
|
| 459 |
+
|
| 460 |
def build_ui() -> gr.Blocks:
|
| 461 |
settings = get_settings()
|
| 462 |
with gr.Blocks(css=CSS, title="RAGForge") as demo:
|
|
|
|
| 528 |
ask_btn = gr.Button("Ask", variant="primary")
|
| 529 |
query_status = gr.Markdown("Ready.", elem_classes=["status-line"])
|
| 530 |
with gr.Accordion("Sources", open=True):
|
| 531 |
+
source_view = gr.Markdown("*Sources appear here.*", elem_id="source-panel")
|
| 532 |
with gr.Accordion("Pipeline inspector", open=False):
|
| 533 |
inspector_summary = gr.Markdown(
|
| 534 |
"*Run a query to inspect routing, retrieval and evidence decisions.*"
|
|
|
|
| 943 |
)
|
| 944 |
meta = report.get("evaluation_cache", {})
|
| 945 |
stale = int(meta.get("workspace_version", -1)) != int(ws.version)
|
| 946 |
+
status = _saved_eval_message(level, report, stale=stale)
|
|
|
|
|
|
|
|
|
|
| 947 |
outputs = _evaluation_outputs(ws, report, status)
|
| 948 |
return (sid, outputs[-1], *outputs[:-1])
|
| 949 |
|
|
|
|
| 966 |
outputs = _evaluation_outputs(
|
| 967 |
ws,
|
| 968 |
cached,
|
| 969 |
+
_saved_eval_message(level, cached, stale=False),
|
| 970 |
)
|
| 971 |
return (
|
| 972 |
sid,
|
|
|
|
| 1009 |
if report.get("summary", {}).get("reused_standard_baseline")
|
| 1010 |
else ""
|
| 1011 |
)
|
| 1012 |
+
meta = report.get("evaluation_cache", {})
|
| 1013 |
+
run_id = meta.get("run_id") or "unknown"
|
| 1014 |
+
requests = int(report.get("summary", {}).get("gemini_requests", 0) or 0)
|
| 1015 |
outputs = _evaluation_outputs(
|
| 1016 |
ws,
|
| 1017 |
report,
|
| 1018 |
+
f"**Fresh {level} evaluation complete - run `{run_id}`.** "
|
| 1019 |
+
f"This execution issued {requests} Gemini request(s) and was saved for reuse."
|
| 1020 |
+
f"{skipped_note}{incremental_note}",
|
| 1021 |
)
|
| 1022 |
return (
|
| 1023 |
sid,
|
|
|
|
| 1107 |
show_progress="hidden",
|
| 1108 |
)
|
| 1109 |
|
| 1110 |
+
eval_saved_level.input(
|
| 1111 |
load_saved_eval,
|
| 1112 |
[session_state, eval_saved_level],
|
| 1113 |
[
|
src/ragforge/workspace.py
CHANGED
|
@@ -21,6 +21,9 @@ from .schemas import CorpusSummary, Document, SearchHit, SourceProfile
|
|
| 21 |
from .sql_agent import SQLWorkspace
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
class Workspace:
|
| 25 |
def __init__(self, session_id: str):
|
| 26 |
self.session_id = session_id
|
|
@@ -111,6 +114,7 @@ class Workspace:
|
|
| 111 |
"table_names": list(self.sql.tables),
|
| 112 |
"saved_evaluations": sorted(self.evaluation_reports),
|
| 113 |
"status": "empty" if self.is_empty else "ready",
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
@property
|
|
@@ -148,6 +152,8 @@ class Workspace:
|
|
| 148 |
"benchmark_version": benchmark_version,
|
| 149 |
"workspace_version": self.version,
|
| 150 |
"saved_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
self.evaluation_reports[level] = saved
|
| 153 |
try:
|
|
@@ -211,6 +217,9 @@ class Workspace:
|
|
| 211 |
"workspace_version": meta.get("workspace_version"),
|
| 212 |
"current_corpus": int(meta.get("workspace_version", -1)) == int(self.version),
|
| 213 |
"saved_at": meta.get("saved_at", ""),
|
|
|
|
|
|
|
|
|
|
| 214 |
}
|
| 215 |
)
|
| 216 |
return rows
|
|
@@ -240,6 +249,8 @@ class Workspace:
|
|
| 240 |
"hard_mode_pass": summary.get("hard_mode_pass_rate"),
|
| 241 |
"p50_ms": summary.get("latency_p50_ms"),
|
| 242 |
"gemini_requests": summary.get("gemini_requests"),
|
|
|
|
|
|
|
| 243 |
}
|
| 244 |
prev = previous_by_level.get(level)
|
| 245 |
if prev:
|
|
|
|
| 21 |
from .sql_agent import SQLWorkspace
|
| 22 |
|
| 23 |
|
| 24 |
+
SERVER_BOOT_ID = uuid.uuid4().hex[:12]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
class Workspace:
|
| 28 |
def __init__(self, session_id: str):
|
| 29 |
self.session_id = session_id
|
|
|
|
| 114 |
"table_names": list(self.sql.tables),
|
| 115 |
"saved_evaluations": sorted(self.evaluation_reports),
|
| 116 |
"status": "empty" if self.is_empty else "ready",
|
| 117 |
+
"server_boot_id": SERVER_BOOT_ID,
|
| 118 |
}
|
| 119 |
|
| 120 |
@property
|
|
|
|
| 152 |
"benchmark_version": benchmark_version,
|
| 153 |
"workspace_version": self.version,
|
| 154 |
"saved_at": datetime.now(timezone.utc).isoformat(),
|
| 155 |
+
"run_id": uuid.uuid4().hex[:12],
|
| 156 |
+
"server_boot_id": SERVER_BOOT_ID,
|
| 157 |
}
|
| 158 |
self.evaluation_reports[level] = saved
|
| 159 |
try:
|
|
|
|
| 217 |
"workspace_version": meta.get("workspace_version"),
|
| 218 |
"current_corpus": int(meta.get("workspace_version", -1)) == int(self.version),
|
| 219 |
"saved_at": meta.get("saved_at", ""),
|
| 220 |
+
"run_id": meta.get("run_id", ""),
|
| 221 |
+
"server_boot_id": meta.get("server_boot_id", ""),
|
| 222 |
+
"current_server": meta.get("server_boot_id") in {None, "", SERVER_BOOT_ID},
|
| 223 |
}
|
| 224 |
)
|
| 225 |
return rows
|
|
|
|
| 249 |
"hard_mode_pass": summary.get("hard_mode_pass_rate"),
|
| 250 |
"p50_ms": summary.get("latency_p50_ms"),
|
| 251 |
"gemini_requests": summary.get("gemini_requests"),
|
| 252 |
+
"run_id": meta.get("run_id", ""),
|
| 253 |
+
"server_boot_id": meta.get("server_boot_id", ""),
|
| 254 |
}
|
| 255 |
prev = previous_by_level.get(level)
|
| 256 |
if prev:
|
tests/test_citations.py
CHANGED
|
@@ -32,3 +32,22 @@ def test_repair_adds_supported_citation_to_factual_bullet():
|
|
| 32 |
repaired, count = repair_missing_citations(answer, sources)
|
| 33 |
assert "[D1]" in repaired
|
| 34 |
assert count == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
repaired, count = repair_missing_citations(answer, sources)
|
| 33 |
assert "[D1]" in repaired
|
| 34 |
assert count == 1
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_repair_missing_citations_repairs_uncited_sentence_inside_cited_paragraph():
|
| 38 |
+
from ragforge.citations import repair_missing_citations
|
| 39 |
+
|
| 40 |
+
answer = (
|
| 41 |
+
"Support tiers [T1]: Starter costs 0. "
|
| 42 |
+
"Intermediate tiers include Team at 49 and Business at 199. "
|
| 43 |
+
"Enterprise costs 799 [T1]."
|
| 44 |
+
)
|
| 45 |
+
sources = [{
|
| 46 |
+
"id": "T1",
|
| 47 |
+
"type": "table",
|
| 48 |
+
"title": "support_matrix",
|
| 49 |
+
"snippet": "Starter 0 Team 49 Business 199 Enterprise 799",
|
| 50 |
+
}]
|
| 51 |
+
repaired, count = repair_missing_citations(answer, sources, semantic_support=False)
|
| 52 |
+
assert "Intermediate tiers include Team at 49 and Business at 199 [T1]." in repaired
|
| 53 |
+
assert count >= 1
|
tests/test_evaluation_assets.py
CHANGED
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
| 5 |
def test_demo_benchmark_is_multilayer_and_auditable():
|
| 6 |
path = Path("evals/demo_benchmark.json")
|
| 7 |
data = json.loads(path.read_text(encoding="utf-8"))
|
| 8 |
-
assert data["version"] == "1.
|
| 9 |
assert len(data["qa_cases"]) >= 9
|
| 10 |
assert len(data["planner_cases"]) >= 10
|
| 11 |
assert len(data["overview_cases"]) >= 2
|
|
@@ -49,7 +49,7 @@ def test_demo_evaluation_and_introspection_are_available_through_api():
|
|
| 49 |
assert "/api/v1/session/{session_id}" in text
|
| 50 |
assert "/api/v1/evaluation/saved/{session_id}" in text
|
| 51 |
assert "/api/v1/evaluation/saved/{session_id}/{level}" in text
|
| 52 |
-
assert 'version="1.
|
| 53 |
|
| 54 |
|
| 55 |
def test_v15_evaluation_cache_and_incremental_deep_are_present():
|
|
@@ -99,7 +99,7 @@ def test_v15_text2sql_cases_have_typed_expected_values():
|
|
| 99 |
def test_v15_pipeline_contains_adaptive_reranking_and_citation_repair():
|
| 100 |
text = Path("src/ragforge/pipeline.py").read_text(encoding="utf-8")
|
| 101 |
assert "_reranker_decision" in text
|
| 102 |
-
assert "
|
| 103 |
assert "_repair_missing_citations" in text
|
| 104 |
assert "citation_repairs" in text
|
| 105 |
|
|
|
|
| 5 |
def test_demo_benchmark_is_multilayer_and_auditable():
|
| 6 |
path = Path("evals/demo_benchmark.json")
|
| 7 |
data = json.loads(path.read_text(encoding="utf-8"))
|
| 8 |
+
assert data["version"] == "1.7"
|
| 9 |
assert len(data["qa_cases"]) >= 9
|
| 10 |
assert len(data["planner_cases"]) >= 10
|
| 11 |
assert len(data["overview_cases"]) >= 2
|
|
|
|
| 49 |
assert "/api/v1/session/{session_id}" in text
|
| 50 |
assert "/api/v1/evaluation/saved/{session_id}" in text
|
| 51 |
assert "/api/v1/evaluation/saved/{session_id}/{level}" in text
|
| 52 |
+
assert 'version="1.7.0"' in text
|
| 53 |
|
| 54 |
|
| 55 |
def test_v15_evaluation_cache_and_incremental_deep_are_present():
|
|
|
|
| 99 |
def test_v15_pipeline_contains_adaptive_reranking_and_citation_repair():
|
| 100 |
text = Path("src/ragforge/pipeline.py").read_text(encoding="utf-8")
|
| 101 |
assert "_reranker_decision" in text
|
| 102 |
+
assert "small_corpus_source_and_chunk_benchmark_no_gain" in text
|
| 103 |
assert "_repair_missing_citations" in text
|
| 104 |
assert "citation_repairs" in text
|
| 105 |
|
tests/test_ui_copy.py
CHANGED
|
@@ -54,7 +54,7 @@ def test_ui_has_quota_safe_evaluation_controls_and_score_card_spacing():
|
|
| 54 |
|
| 55 |
def test_architecture_snapshot_returns_complete_runtime_payload():
|
| 56 |
text = Path("src/ragforge/ui.py").read_text(encoding="utf-8")
|
| 57 |
-
assert '"ragforge_version": "1.
|
| 58 |
assert "return sid, runtime, curl, runtime_json" in text
|
| 59 |
assert "curl = f\ndef _eval_frame" not in text
|
| 60 |
|
|
@@ -63,7 +63,7 @@ def test_ui_can_switch_saved_evaluations_without_rerunning():
|
|
| 63 |
text = Path("src/ragforge/ui.py").read_text(encoding="utf-8")
|
| 64 |
assert "View saved evaluation" in text
|
| 65 |
assert "Refresh saved runs" in text
|
| 66 |
-
assert "
|
| 67 |
|
| 68 |
|
| 69 |
def test_ui_exposes_copyable_evaluation_exports():
|
|
|
|
| 54 |
|
| 55 |
def test_architecture_snapshot_returns_complete_runtime_payload():
|
| 56 |
text = Path("src/ragforge/ui.py").read_text(encoding="utf-8")
|
| 57 |
+
assert '"ragforge_version": "1.7.0"' in text
|
| 58 |
assert "return sid, runtime, curl, runtime_json" in text
|
| 59 |
assert "curl = f\ndef _eval_frame" not in text
|
| 60 |
|
|
|
|
| 63 |
text = Path("src/ragforge/ui.py").read_text(encoding="utf-8")
|
| 64 |
assert "View saved evaluation" in text
|
| 65 |
assert "Refresh saved runs" in text
|
| 66 |
+
assert "0 new Gemini requests were used" in text
|
| 67 |
|
| 68 |
|
| 69 |
def test_ui_exposes_copyable_evaluation_exports():
|
tests/test_v16_features.py
CHANGED
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
| 4 |
|
| 5 |
def test_v16_benchmark_has_hard_mode_and_insight_plans():
|
| 6 |
data = json.loads(Path("evals/demo_benchmark.json").read_text(encoding="utf-8"))
|
| 7 |
-
assert data["version"] == "1.
|
| 8 |
assert len(data.get("hard_mode_cases", [])) >= 8
|
| 9 |
insight = [c for c in data["planner_cases"] if c.get("task") == "insight_synthesis"]
|
| 10 |
assert insight
|
|
|
|
| 4 |
|
| 5 |
def test_v16_benchmark_has_hard_mode_and_insight_plans():
|
| 6 |
data = json.loads(Path("evals/demo_benchmark.json").read_text(encoding="utf-8"))
|
| 7 |
+
assert data["version"] == "1.7"
|
| 8 |
assert len(data.get("hard_mode_cases", [])) >= 8
|
| 9 |
insight = [c for c in data["planner_cases"] if c.get("task") == "insight_synthesis"]
|
| 10 |
assert insight
|
tests/test_v17_features.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from ragforge.eval_metrics import citation_metrics, missing_answer_match
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_v17_markdown_aware_citation_coverage_counts_short_numbered_items():
|
| 7 |
+
answer = (
|
| 8 |
+
"Based on the provided documents, the four core functions are:\n\n"
|
| 9 |
+
"1. **GOVERN** [D1] [D2]\n"
|
| 10 |
+
"2. **MAP** [D1] [D2]\n"
|
| 11 |
+
"3. **MEASURE** [D1] [D2]\n"
|
| 12 |
+
"4. **MANAGE** [D1] [D2]"
|
| 13 |
+
)
|
| 14 |
+
metrics = citation_metrics(answer, [{"id": "D1"}, {"id": "D2"}])
|
| 15 |
+
assert metrics["citation_validity"] == 1.0
|
| 16 |
+
assert metrics["citation_coverage"] == 1.0
|
| 17 |
+
assert metrics["substantive_units"] == 4
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_v17_citation_coverage_ignores_generic_list_preamble():
|
| 21 |
+
answer = (
|
| 22 |
+
"Based on the demo documents, escalation guidance is contained in:\n\n"
|
| 23 |
+
"* **acme_cloud_runbook.md:** Incident escalation guidance [D1].\n"
|
| 24 |
+
"* **orbitpay_policy.txt:** Security escalation guidance [D2]."
|
| 25 |
+
)
|
| 26 |
+
metrics = citation_metrics(answer, [{"id": "D1"}, {"id": "D2"}])
|
| 27 |
+
assert metrics["citation_coverage"] == 1.0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_v17_grounded_missing_answer_matches_natural_absence_language():
|
| 31 |
+
answer = (
|
| 32 |
+
"Based on the provided evidence, the retrieved documents do not mention any fee "
|
| 33 |
+
"charged by OrbitPay for opening a card dispute [D1]. Therefore, the supplied "
|
| 34 |
+
"context is insufficient to answer the question."
|
| 35 |
+
)
|
| 36 |
+
case = {"expected_missing_any": ["not specified", "does not contain"]}
|
| 37 |
+
assert missing_answer_match(answer, case)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_v17_ui_uses_plain_source_cards_and_graphical_latency_bars():
|
| 41 |
+
text = Path("src/ragforge/ui.py").read_text(encoding="utf-8")
|
| 42 |
+
assert 'elem_id="source-panel"' in text
|
| 43 |
+
assert "html.escape(_truncate_preview" in text
|
| 44 |
+
assert "latency-track" in text
|
| 45 |
+
assert "latency-fill" in text
|
| 46 |
+
assert "'#' * width" not in text
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_v17_saved_run_switching_is_user_input_only_and_has_provenance():
|
| 50 |
+
ui = Path("src/ragforge/ui.py").read_text(encoding="utf-8")
|
| 51 |
+
workspace = Path("src/ragforge/workspace.py").read_text(encoding="utf-8")
|
| 52 |
+
assert "eval_saved_level.input(" in ui
|
| 53 |
+
assert "eval_saved_level.change(" not in ui
|
| 54 |
+
assert "Fresh {level} evaluation complete" in ui
|
| 55 |
+
assert "run_id" in workspace
|
| 56 |
+
assert "SERVER_BOOT_ID" in workspace
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_v17_global_overviews_surface_table_evidence():
|
| 60 |
+
text = Path("src/ragforge/pipeline.py").read_text(encoding="utf-8")
|
| 61 |
+
assert 'plan.task_type == "overview" and self.workspace.sql.tables' in text
|
| 62 |
+
assert "analytics_context(max_rows=12)" in text
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_v17_grounded_absence_skips_revise_path():
|
| 66 |
+
text = Path("src/ragforge/pipeline.py").read_text(encoding="utf-8")
|
| 67 |
+
assert "grounded_absence" in text
|
| 68 |
+
assert "and not s.get(\"grounded_absence\", False)" in text
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_v17_diagnostics_put_next_on_following_line():
|
| 72 |
+
text = Path("src/ragforge/ui.py").read_text(encoding="utf-8")
|
| 73 |
+
assert ' **Next:**' in text
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_v17_citation_coverage_attaches_citation_after_sentence_punctuation():
|
| 77 |
+
answer = "The corpus does not provide complete financial audits. [D1]"
|
| 78 |
+
metrics = citation_metrics(answer, [{"id": "D1"}])
|
| 79 |
+
assert metrics["citation_coverage"] == 1.0
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_v17_citation_coverage_does_not_split_vs_abbreviation():
|
| 83 |
+
answer = "Operational strictness vs. flexibility is a notable contrast [D1]."
|
| 84 |
+
metrics = citation_metrics(answer, [{"id": "D1"}])
|
| 85 |
+
assert metrics["citation_coverage"] == 1.0
|
| 86 |
+
assert metrics["substantive_units"] == 1
|