ArchitSharma commited on
Commit
03bed0b
·
1 Parent(s): b0af996

Upgrade RAGForge to v1.7 evaluation correctness and adaptive efficiency

Browse files
README.md CHANGED
@@ -10,12 +10,27 @@ pinned: false
10
 
11
  # RAGForge
12
 
13
- **RAGForge v1.6 - 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.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.6
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.6
 
 
 
 
 
 
 
 
 
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.6 benchmark + source/chunk ranking metrics + hard-mode robustness + citations + planner/web policy + typed Text2SQL + abstention + 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,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.6",
3
- "description": "Hard-mode, transparent multi-layer benchmark for the bundled RAGForge demo corpus, including focused QA, analytical insight synthesis, adversarial routing, missing-answer robustness, chunk-level retrieval labels, Text2SQL and lifecycle abstention.",
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.6.0"
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.6.0"
 
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.6.0")
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
- by_id = {str(source.get("id", "")): source for source in sources}
87
- for sid in semantic_ids:
88
- source = by_id.get(sid, {})
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
- repaired = 0
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
- out.append(line)
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 second_overlap >= 2 and second_support >= 0.20 and second_support >= best_score * 0.65:
 
 
 
 
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
- if selected_ids:
150
- out.append(line.rstrip() + " " + " ".join(f"[{sid}]" for sid in selected_ids))
151
- repaired += len(selected_ids)
152
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- answer_l = result.answer.lower()
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 s.get("attempts", 0) < 1 and s.get("confidence", 1.0) < 0.58 else "end",
 
 
 
 
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": 7,
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, "agentic_profile"
214
  if plan.task_type in {"comparison", "cross_document_synthesis", "insight_synthesis"}:
215
- return True, "multi_source_reasoning"
216
  if chunk_count >= 250 or source_count >= 10:
217
  return True, "larger_corpus"
218
- return False, "small_corpus_no_benchmark_gain"
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
- unsupported_language = 0.25 if "don't have enough" in answer.lower() or "insufficient" in answer.lower() else 0.0
 
 
 
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 s in sources:
71
- sid = s.get("id", "?")
72
- title = s.get("title", "Source")
73
- if s.get("type") == "web" and s.get("url"):
74
- preview = _truncate_preview(s.get("snippet", ""))
75
- body = f"**[{sid}] {title}** \n{s['url']} \nRetrieval rank: `#{s.get('rank', '-')}`"
76
- if preview:
77
- body += f" \n\n{preview}"
78
- blocks.append(body)
79
- elif s.get("type") in {"sql", "table"}:
80
- preview = _truncate_preview(s.get("snippet", ""))
81
- body = f"**[{sid}] {title}** \nRows: `{s.get('rows', '-')}`"
82
- if s.get("schema"):
83
- body += f" \nSchema: `{_ui_text(str(s.get('schema')))}`"
84
- if preview:
85
- body += f" \n\n{preview}"
86
- blocks.append(body)
87
  else:
88
- page = f" - page {s['page']}" if s.get("page") else ""
89
- preview = _truncate_preview(s.get("snippet", ""))
90
- blocks.append(
91
- f"**[{sid}] {title}{page}** \n"
92
- f"Retrieval rank: `#{s.get('rank', '-')}` - hybrid signal: `{s.get('retrieval_signal', '-')}` \n\n"
93
- f"{preview}"
94
- )
95
- return "\n\n---\n\n".join(blocks)
 
 
 
96
 
97
 
98
  def _latency_waterfall(trace: dict[str, Any]) -> str:
99
- nodes = [n for n in trace.get("nodes", []) if float(n.get("ms", 0.0) or 0.0) > 0]
 
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
- lines = []
104
  for node in nodes:
105
  ms = float(node.get("ms", 0.0) or 0.0)
106
- width = max(1, min(18, int(round(18 * ms / max_ms))))
107
- lines.append(f"`{str(node.get('node','-')):<9}` {'#' * width} {ms:.0f} ms")
108
- return " \n".join(lines)
 
 
 
 
 
 
 
 
 
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"{workspace.get('tables', 0)} tables - version {workspace.get('version', 0)}\n\n"
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)}`\n\n"
 
 
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.6.0",
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.6.0` - **workspace:** `{sid[:12]}...` - **status:** `{stats['status']}`\n\n"
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
- parts.append(f"`{item['level']}` grade {item.get('grade', '-')}{stale}")
 
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
- f"**Loaded saved {level} evaluation.** No Gemini requests were used.",
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"**Evaluation complete.** {level} benchmark finished.{skipped_note}{incremental_note}",
 
 
971
  )
972
  return (
973
  sid,
@@ -1057,7 +1107,7 @@ def build_ui() -> gr.Blocks:
1057
  show_progress="hidden",
1058
  )
1059
 
1060
- eval_saved_level.change(
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.6"
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.6.0"' in text
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 "small_corpus_no_benchmark_gain" in text
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.6.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,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 "No Gemini requests were used" in text
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.6"
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