Files
chunking_strategies_evaluation/src/benchmarking/routes.py
Mahdi Bazrafshan 56b8d9401a feat(dashboard): add decision board for strategy selection
Why:
- Compare is the wrong surface for two-stage family selection over the 10-doc set.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:21 +03:30

394 lines
15 KiB
Python

"""Query and Benchmark API routes.
Endpoints:
POST /queries Ask a question against a strategy
GET /queries/{id} Retrieve a past query
POST /benchmarks Run a benchmark (or dry run)
GET /benchmarks/{id} Retrieve experiment results
GET /experiments List all experiments
"""
from fastapi import APIRouter, Query
from fastapi.responses import HTMLResponse
from src.core.exceptions import BenchmarkError, QueryError
from src.core.models import StrategyName
from src.benchmarking import benchmark_service, query_service
from src.benchmarking.models import (
BenchmarkRequest,
BenchmarkResponse,
CostEstimate,
ExperimentDetailResponse,
QueryRequest,
QueryResponse,
QueryDetailResponse,
RetrievedChunk,
StrategyMetrics,
)
router = APIRouter()
# ── Query Endpoints ───────────────────────────────────────────────
@router.post("/queries", response_model=QueryResponse, status_code=201)
async def create_query(request: QueryRequest):
"""Ask a question against a document using a specific chunking strategy.
Pipeline: embed question → vector search → generate answer → store result.
"""
result = query_service.run_query(
document_id=request.document_id,
strategy_name=request.strategy,
question=request.question,
top_k=request.top_k,
neighbor_prev=request.neighbor_prev,
neighbor_next=request.neighbor_next,
corpus_model_id=request.corpus_model_id,
)
return QueryResponse(
query_id=result["query_id"],
document_id=result["document_id"],
strategy=result["strategy"],
question=result["question"],
answer=result["answer"],
retrieved_chunks=[
RetrievedChunk(**chunk) for chunk in result["retrieved_chunks"]
],
expansion_tree=result.get("expansion_tree") or [],
neighbor_prev=result.get("neighbor_prev", 0),
neighbor_next=result.get("neighbor_next", 0),
latency_breakdown=result["latency_breakdown"],
token_usage=result["token_usage"],
created_at=result["created_at"],
)
@router.get("/queries/{query_id}", response_model=QueryDetailResponse)
async def get_query(query_id: str):
"""Retrieve a past query by ID."""
result = query_service.get_query(query_id)
if result is None:
raise QueryError(f"Query not found: {query_id}")
return QueryDetailResponse(
id=result["id"],
document_id=result["document_id"],
strategy_name=result["strategy_name"],
question=result["question"],
answer=result["answer"],
retrieved_chunks=result["retrieved_chunks"],
expansion_tree=result.get("expansion_tree") or [],
latency_breakdown=result["latency_breakdown"],
token_usage=result["token_usage"],
created_at=result["created_at"],
)
# ── Benchmark Endpoints ──────────────────────────────────────────
@router.post("/benchmarks", response_model=BenchmarkResponse, status_code=201)
async def create_benchmark(request: BenchmarkRequest):
"""Run a benchmark comparing multiple strategies on multiple questions.
Can run in dry_run mode to get cost estimate without executing.
"""
# Load questions - prefer questions_file over inline questions
if request.questions_file:
questions = benchmark_service.load_questions(request.questions_file)
elif request.questions and len(request.questions) > 0:
# Validate that questions have required fields
valid_questions = [
q for q in request.questions
if isinstance(q, dict) and "question" in q
]
if not valid_questions:
raise BenchmarkError("Invalid questions: each question must have a 'question' field")
questions = valid_questions
else:
raise BenchmarkError("Either 'questions' or 'questions_file' must be provided")
if not questions:
raise BenchmarkError("No questions to benchmark")
# Dry run - return cost estimate only
if request.dry_run:
estimate = benchmark_service.estimate_cost(
num_questions=len(questions),
num_strategies=len(request.strategies),
)
# Return as BenchmarkResponse with minimal data
return BenchmarkResponse(
experiment_id="dry_run",
document_id=request.document_id,
strategies_used=[s.value for s in request.strategies],
questions_count=len(questions),
aggregate_metrics={},
best_strategy="N/A (dry run)",
total_latency_seconds=0,
estimated_cost_usd=estimate["estimated_cost_usd"],
created_at="N/A",
)
# Run full benchmark
result = benchmark_service.run_benchmark(
document_id=request.document_id,
strategies=request.strategies,
questions=questions,
top_k=request.top_k,
neighbor_prev=request.neighbor_prev,
neighbor_next=request.neighbor_next,
corpus_model_id=request.corpus_model_id,
)
return BenchmarkResponse(
experiment_id=result["experiment_id"],
document_id=result["document_id"],
strategies_used=result["strategies_used"],
questions_count=result["questions_count"],
aggregate_metrics={
k: StrategyMetrics(**v) for k, v in result["aggregate_metrics"].items()
},
best_strategy=result["best_strategy"],
total_latency_seconds=result["total_latency_seconds"],
estimated_cost_usd=result["estimated_cost_usd"],
created_at=result["created_at"],
)
@router.get("/benchmarks/{experiment_id}", response_model=ExperimentDetailResponse)
async def get_benchmark(experiment_id: str):
"""Retrieve experiment results by ID."""
result = benchmark_service.get_experiment(experiment_id)
if result is None:
raise BenchmarkError(f"Experiment not found: {experiment_id}")
# Resolve document filename
from src.storage import sqlite as db
doc = db.get_document(result.get("document_id", ""))
doc_filename = doc.get("filename", "Unknown") if doc else "Deleted"
return ExperimentDetailResponse(
id=result["id"],
document_id=result["document_id"],
document_filename=doc_filename,
benchmark_config=result.get("benchmark_config", {}),
questions=result.get("questions", []),
per_question=result.get("per_question", []),
aggregate_metrics={
k: StrategyMetrics(**v) for k, v in result.get("aggregate_metrics", {}).items()
},
strategies_used=result.get("strategies_used", []),
created_at=result["created_at"],
)
@router.get("/experiments")
async def list_experiments(
document_id: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(200, ge=1, le=500),
):
"""List all experiments, optionally filtered by document."""
result = benchmark_service.list_experiments(
document_id=document_id, offset=offset, limit=limit
)
# Enrich with document filenames and best_strategy
from src.storage import sqlite as db
for item in result.get("items", []):
doc = db.get_document(item.get("document_id", ""))
item["document_filename"] = doc.get("filename", "Unknown") if doc else "Deleted"
questions = item.get("questions") or []
item["questions_count"] = (
item.get("benchmark_config", {}).get("num_questions")
or (len(questions) if isinstance(questions, list) else 0)
)
# Calculate best_strategy from aggregate_metrics
aggs = item.get("aggregate_metrics", {})
best_strat, best_score = "N/A", -1
for strat, m in aggs.items():
score = (m.get("avg_context_relevance", 0) * 0.3 +
m.get("avg_answer_similarity", 0) * 0.4 +
m.get("avg_faithfulness", 0) * 0.3)
adjusted = score * (1 - m.get("hallucination_rate", 0))
if adjusted > best_score:
best_score = adjusted
best_strat = strat
item["best_strategy"] = best_strat
# Surface embedding on list even if only in benchmark_config
if not item.get("embedding_model_id"):
cfg = item.get("benchmark_config") or {}
item["embedding_model_id"] = cfg.get("embedding_model_id")
item["embedding_provider"] = cfg.get("embedding_provider")
return result
@router.delete("/experiments/{experiment_id}")
async def delete_experiment(experiment_id: str):
"""Delete an experiment by ID."""
from src.storage import sqlite as db
deleted = db.delete_experiment(experiment_id)
if not deleted:
raise BenchmarkError(f"Experiment not found: {experiment_id}")
return {"deleted": True, "experiment_id": experiment_id}
@router.get("/benchmarks/{experiment_id}/report", response_class=HTMLResponse)
async def get_report(experiment_id: str, view: str = "managerial"):
"""Generate and return an HTML report for an experiment.
Views:
- managerial: Decision-focused, high-level insights (default)
- technical: Full observability with detailed data
"""
from src.benchmarking.report import generate_report
result = benchmark_service.get_experiment(experiment_id)
if result is None:
raise BenchmarkError(f"Experiment not found: {experiment_id}")
# Validate view parameter
if view not in ("managerial", "technical"):
view = "managerial"
# Generate HTML report
html = generate_report(result, view)
return HTMLResponse(content=html)
# ── HTML Report Generator ─────────────────────────────────────────
def _generate_html_report(experiment: dict) -> str:
"""Generate an HTML report from experiment data."""
config = experiment.get("benchmark_config", {})
aggregate = experiment.get("aggregate_metrics", {})
per_question = experiment.get("per_question", [])
strategies = experiment.get("strategies_used", [])
# Build strategy rows for summary table
strategy_rows = ""
for strategy_name in strategies:
metrics = aggregate.get(strategy_name, {})
strategy_rows += f"""
<tr>
<td><strong>{strategy_name}</strong></td>
<td>{metrics.get('avg_context_relevance', 0):.1f}</td>
<td>{metrics.get('avg_answer_similarity', 0):.1f}</td>
<td>{metrics.get('avg_faithfulness', 0):.1f}</td>
<td>{metrics.get('hallucination_rate', 0)*100:.1f}%</td>
<td>{metrics.get('total_questions', 0)}</td>
</tr>"""
# Build per-question rows
question_rows = ""
for qr in per_question:
q_text = qr.get("question", "")[:80]
q_id = qr.get("question_id", "")
for strategy_name in strategies:
strat_result = qr.get("strategies", {}).get(strategy_name, {})
scores = strat_result.get("scores", {})
answer_preview = strat_result.get("answer", "")[:100]
question_rows += f"""
<tr>
<td>{q_id}</td>
<td>{q_text}...</td>
<td>{strategy_name}</td>
<td>{scores.get('context_relevance', 'N/A')}</td>
<td>{scores.get('answer_similarity', 'N/A')}</td>
<td>{scores.get('faithfulness', 'N/A')}</td>
<td>{'✓' if not scores.get('hallucination', False) else '✗'}</td>
<td>{answer_preview}...</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benchmark Report - {experiment.get('id', 'Unknown')}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; }}
.container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
h1 {{ color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }}
h2 {{ color: #555; margin-top: 30px; }}
.summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }}
.summary-card {{ background: #f9f9f9; padding: 20px; border-radius: 8px; text-align: center; }}
.summary-card h3 {{ margin: 0; color: #666; font-size: 14px; }}
.summary-card .value {{ font-size: 24px; font-weight: bold; color: #4CAF50; }}
table {{ width: 100%; border-collapse: collapse; margin: 20px 0; }}
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background: #4CAF50; color: white; }}
tr:hover {{ background: #f5f5f5; }}
.best {{ background: #e8f5e9; font-weight: bold; }}
.footer {{ margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<h1>📊 Benchmark Report</h1>
<div class="summary">
<div class="summary-card">
<h3>Experiment ID</h3>
<div class="value" style="font-size: 14px;">{experiment.get('id', 'N/A')[:16]}...</div>
</div>
<div class="summary-card">
<h3>Document</h3>
<div class="value" style="font-size: 14px;">{experiment.get('document_id', 'N/A')[:16]}...</div>
</div>
<div class="summary-card">
<h3>Questions</h3>
<div class="value">{config.get('num_questions', len(per_question))}</div>
</div>
<div class="summary-card">
<h3>Strategies</h3>
<div class="value">{len(strategies)}</div>
</div>
</div>
<h2>📈 Summary by Strategy</h2>
<table>
<thead>
<tr>
<th>Strategy</th>
<th>Context Relevance</th>
<th>Answer Similarity</th>
<th>Faithfulness</th>
<th>Hallucination Rate</th>
<th>Questions</th>
</tr>
</thead>
<tbody>
{strategy_rows}
</tbody>
</table>
<h2>📝 Detailed Results</h2>
<table>
<thead>
<tr>
<th>Q-ID</th>
<th>Question</th>
<th>Strategy</th>
<th>Context</th>
<th>Similarity</th>
<th>Faithfulness</th>
<th>No Halluc.</th>
<th>Answer Preview</th>
</tr>
</thead>
<tbody>
{question_rows}
</tbody>
</table>
<div class="footer">
<p>Generated by RAG Chunking Benchmarker | {experiment.get('created_at', 'N/A')}</p>
</div>
</div>
</body>
</html>"""
return html