diff --git a/src/benchmarking/models.py b/src/benchmarking/models.py index 20d7bb4..8fb77c3 100644 --- a/src/benchmarking/models.py +++ b/src/benchmarking/models.py @@ -51,3 +51,77 @@ class QueryDetailResponse(BaseModel): latency_breakdown: dict[str, float] token_usage: dict[str, int] created_at: str + + +# ── Benchmark Request/Response ──────────────────────────────────── + +class BenchmarkRequest(BaseModel): + """Body for POST /benchmarks.""" + document_id: str = Field(description="Document ID to benchmark against") + strategies: list[StrategyName] = Field( + default=[ + StrategyName.RECURSIVE, + StrategyName.FIXED_SIZE, + StrategyName.SEMANTIC, + StrategyName.CONTEXTUAL_RETRIEVAL, + StrategyName.SEMANTIC_PARENT_CHILD, + ], + description="Strategies to benchmark (defaults to all 5)", + min_length=1, + ) + questions: list[dict] = Field( + default_factory=list, + description="List of question objects (overrides questions_file if provided)", + ) + questions_file: Optional[str] = Field( + default=None, + description="Path to questions JSON file (relative to project root)", + ) + top_k: int = Field(default=5, description="Number of chunks to retrieve", ge=1, le=20) + dry_run: bool = Field(default=False, description="Only return cost estimate, don't run benchmark") + + +class CostEstimate(BaseModel): + """Cost estimation for a benchmark run.""" + num_questions: int + num_strategies: int + total_queries: int + total_evaluations: int + estimated_tokens: dict[str, int] + estimated_cost_usd: float + cost_breakdown: dict[str, float] + + +class StrategyMetrics(BaseModel): + """Aggregated metrics for a single strategy.""" + avg_context_relevance: float + avg_answer_similarity: float + avg_faithfulness: float + hallucination_rate: float + total_questions: int + failed_questions: int + + +class BenchmarkResponse(BaseModel): + """Returned after running a benchmark.""" + experiment_id: str + document_id: str + strategies_used: list[str] + questions_count: int + aggregate_metrics: dict[str, StrategyMetrics] + best_strategy: str + total_latency_seconds: float + estimated_cost_usd: float + created_at: str + + +class ExperimentDetailResponse(BaseModel): + """Returned when retrieving an experiment.""" + id: str + document_id: str + benchmark_config: dict + questions: list[dict] + per_question: list[dict] + aggregate_metrics: dict[str, StrategyMetrics] + strategies_used: list[str] + created_at: str diff --git a/src/benchmarking/routes.py b/src/benchmarking/routes.py index fbed4cf..1b40e3f 100644 --- a/src/benchmarking/routes.py +++ b/src/benchmarking/routes.py @@ -1,25 +1,36 @@ -"""Query API routes. +"""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 +from fastapi.responses import HTMLResponse -from src.core.exceptions import QueryError +from src.core.exceptions import BenchmarkError, QueryError from src.core.models import StrategyName -from src.benchmarking import query_service +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. @@ -66,3 +77,257 @@ async def get_query(query_id: str): 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, + ) + + 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}") + + return ExperimentDetailResponse( + id=result["id"], + document_id=result["document_id"], + 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): + """List all experiments, optionally filtered by document.""" + return benchmark_service.list_experiments(document_id=document_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""" + + {strategy_name} + {metrics.get('avg_context_relevance', 0):.1f} + {metrics.get('avg_answer_similarity', 0):.1f} + {metrics.get('avg_faithfulness', 0):.1f} + {metrics.get('hallucination_rate', 0)*100:.1f}% + {metrics.get('total_questions', 0)} + """ + + # 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""" + + {q_id} + {q_text}... + {strategy_name} + {scores.get('context_relevance', 'N/A')} + {scores.get('answer_similarity', 'N/A')} + {scores.get('faithfulness', 'N/A')} + {'✓' if not scores.get('hallucination', False) else '✗'} + {answer_preview}... + """ + + html = f""" + + + + + Benchmark Report - {experiment.get('id', 'Unknown')} + + + +
+

📊 Benchmark Report

+ +
+
+

Experiment ID

+
{experiment.get('id', 'N/A')[:16]}...
+
+
+

Document

+
{experiment.get('document_id', 'N/A')[:16]}...
+
+
+

Questions

+
{config.get('num_questions', len(per_question))}
+
+
+

Strategies

+
{len(strategies)}
+
+
+ +

📈 Summary by Strategy

+ + + + + + + + + + + + + {strategy_rows} + +
StrategyContext RelevanceAnswer SimilarityFaithfulnessHallucination RateQuestions
+ +

📝 Detailed Results

+ + + + + + + + + + + + + + + {question_rows} + +
Q-IDQuestionStrategyContextSimilarityFaithfulnessNo Halluc.Answer Preview
+ + +
+ +""" + + return html