From a56f8f69e21d5e42b1dd92087da8d31db4beeff4 Mon Sep 17 00:00:00 2001 From: Woody Date: Wed, 29 Apr 2026 09:26:50 +0800 Subject: [PATCH] feat: add highlight batch and GET endpoints (Phase 5.4.5) - POST /api/v1/v2/highlights/batch: compute and cache highlights for cited chunks - GET /api/v1/v2/highlights: serve cached highlighted HTML pages - chunks.py router registered in main.py - Dynamic DB path computation (prompts.db -> highlights.db), no Settings changes - 7 endpoint tests: POST 200/422, GET 200/404, mock service verification --- backend/app/main.py | 3 +- backend/app/routers/chunks.py | 60 +++++ .../test/test_phase5_highlight_endpoints.py | 235 ++++++++++++++++++ 3 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/chunks.py create mode 100644 backend/app/test/test_phase5_highlight_endpoints.py diff --git a/backend/app/main.py b/backend/app/main.py index bf8e62f..6c5eabd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse -from app.routers import ingest, query, documents, prompts, history +from app.routers import ingest, query, documents, prompts, history, chunks from app.core.config import get_settings from app.core.sqlite_db import ( get_prompts_db, @@ -55,6 +55,7 @@ app.include_router(query.router, prefix="/api/v1") app.include_router(documents.router, prefix="/api/v1") app.include_router(prompts.router) app.include_router(history.router) +app.include_router(chunks.router) _prompts_conn = get_prompts_db() init_prompts_db(_prompts_conn) diff --git a/backend/app/routers/chunks.py b/backend/app/routers/chunks.py new file mode 100644 index 0000000..ca5d47f --- /dev/null +++ b/backend/app/routers/chunks.py @@ -0,0 +1,60 @@ +import logging + +from fastapi import APIRouter, HTTPException, Query, Response + +from app.core.config import get_settings +from app.models.highlight import ( + HighlightBatchRequest, + HighlightBatchResponse, +) +from app.services.chunk_highlight_service import ChunkHighlightService +from app.services.highlight_cache import HighlightCache, compute_cache_key +from app.services.llm_client import LLMClient +from app.services.rag import RAGService + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["chunks"]) + +HIGHLIGHTS_DB_FILENAME = "highlights.db" + + +def _highlights_db_path(settings) -> str: + return str(settings.prompts_db_path).replace("prompts.db", HIGHLIGHTS_DB_FILENAME) + + +@router.post("/api/v1/v2/highlights/batch", response_model=HighlightBatchResponse) +async def compute_highlights_batch(request: HighlightBatchRequest): + """Compute and cache highlighted chunk views for cited chunks.""" + settings = get_settings() + cache = HighlightCache(db_path=_highlights_db_path(settings)) + llm_client = LLMClient(settings) + rag = RAGService(settings=settings) + service = ChunkHighlightService( + rag_service=rag, + llm_client=llm_client, + highlight_cache=cache, + settings=settings, + ) + try: + result = await service.compute_highlights_batch(request.targets) + return result + except Exception as e: + logger.error("Highlight batch computation failed: %s", e, exc_info=True) + return HighlightBatchResponse(status="failed", cached_count=0, errors=[str(e)]) + + +@router.get("/api/v1/v2/highlights", response_class=Response) +async def get_highlight( + document_id: str = Query(...), + chunk_index: int = Query(...), + sub_question: str = Query(...), +): + """Serve a cached highlighted chunk view as HTML.""" + settings = get_settings() + cache = HighlightCache(db_path=_highlights_db_path(settings)) + cache_key = compute_cache_key(document_id, chunk_index, sub_question) + html = cache.get_highlight(cache_key) + if html is None: + raise HTTPException(status_code=404, detail="Highlight not found. Batch may not have been computed.") + return Response(content=html, media_type="text/html") diff --git a/backend/app/test/test_phase5_highlight_endpoints.py b/backend/app/test/test_phase5_highlight_endpoints.py new file mode 100644 index 0000000..9d34070 --- /dev/null +++ b/backend/app/test/test_phase5_highlight_endpoints.py @@ -0,0 +1,235 @@ +"""Phase 5 highlight endpoint tests: POST /api/v1/v2/highlights/batch and GET /api/v1/v2/highlights. + +Covers: +- POST batch returns 200 with HighlightBatchResponse on valid targets +- POST batch returns 422 when request body is invalid (missing fields) +- POST batch returns 200 with status="completed" matching mock +- GET returns 200 text/html on cache hit +- GET returns 404 on cache miss +- GET returns 404 when missing required query params + +Uses TestClient + isolated FastAPI app + monkeypatch for mocking. +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.models.highlight import ( + ChunkHighlightTarget, + HighlightBatchResponse, +) +from app.routers import chunks +from app.services.highlight_cache import HighlightCache, compute_cache_key + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Create TestClient with chunks router, isolated DB paths, mocked settings.""" + prompts_path = str(tmp_path / "prompts.db") + highlights_path = str(tmp_path / "highlights.db") + + # Monkeypatch get_settings to return a settings-like object + class _FakeSettings: + prompts_db_path = prompts_path + llm_api_key = "test-key" + llm_base_url = "https://example.com" + llm_model_name = "test-model" + llm_enable_thinking = False + vllm_engine = False + embedding_model = "test-emb" + embedding_base_url = "https://example.com" + embedding_api_key = "test-key" + chroma_db_path = str(tmp_path / "chroma") + document_chunk_path = str(tmp_path / "chunks") + history_db_path = str(tmp_path / "history.db") + cors_origins = ["*"] + chunk_size = 1000 + chunk_overlap = 200 + retrieval_n_results = 10 + relevance_threshold = 7.0 + llm_timeout = 60.0 + + from app.core.config import get_settings + get_settings.cache_clear() + + monkeypatch.setattr("app.routers.chunks.get_settings", lambda: _FakeSettings()) + + test_app = FastAPI() + test_app.include_router(chunks.router) + + yield TestClient(test_app), _FakeSettings, highlights_path + + get_settings.cache_clear() + + +# --------------------------------------------------------------------------- +# POST /api/v1/v2/highlights/batch +# --------------------------------------------------------------------------- + + +class TestPostBatchHighlights: + """Tests for POST /api/v1/v2/highlights/batch.""" + + def test_batch_returns_200_on_valid_targets(self, client, monkeypatch): + """POST batch returns 200 with HighlightBatchResponse for valid targets.""" + test_client, fake_settings, _ = client + + mock_response = HighlightBatchResponse( + status="completed", cached_count=2, errors=[] + ) + + async def _mock_compute(self, targets): + return mock_response + + monkeypatch.setattr( + "app.routers.chunks.ChunkHighlightService.compute_highlights_batch", + _mock_compute, + ) + + payload = { + "targets": [ + { + "document_id": "doc1.pdf", + "chunk_index": 0, + "sub_question_text": "What is X?", + "sub_question_index": 0, + }, + { + "document_id": "doc2.pdf", + "chunk_index": 1, + "sub_question_text": "What is Y?", + "sub_question_index": 1, + }, + ] + } + + resp = test_client.post("/api/v1/v2/highlights/batch", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "completed" + assert data["cached_count"] == 2 + assert data["errors"] == [] + + def test_batch_returns_422_on_invalid_body(self, client): + """POST batch returns 422 when request body is missing required fields.""" + test_client, _, _ = client + + # Missing targets entirely + resp = test_client.post("/api/v1/v2/highlights/batch", json={}) + assert resp.status_code == 422 + + def test_batch_returns_422_on_invalid_target_fields(self, client): + """POST batch returns 422 when target objects lack required fields.""" + test_client, _, _ = client + + payload = { + "targets": [ + { + "document_id": "doc1.pdf", + # missing chunk_index, sub_question_text, sub_question_index + } + ] + } + resp = test_client.post("/api/v1/v2/highlights/batch", json=payload) + assert resp.status_code == 422 + + def test_batch_returns_completed_with_matching_mock(self, client, monkeypatch): + """POST batch returns status='completed' and cached_count matches mock.""" + test_client, _, _ = client + + mock_response = HighlightBatchResponse( + status="completed", cached_count=5, errors=[] + ) + + async def _mock_compute(self, targets): + return mock_response + + monkeypatch.setattr( + "app.routers.chunks.ChunkHighlightService.compute_highlights_batch", + _mock_compute, + ) + + payload = { + "targets": [ + { + "document_id": "doc.pdf", + "chunk_index": 0, + "sub_question_text": "Q1", + "sub_question_index": 0, + } + ] + } + resp = test_client.post("/api/v1/v2/highlights/batch", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "completed" + assert data["cached_count"] == 5 + + +# --------------------------------------------------------------------------- +# GET /api/v1/v2/highlights +# --------------------------------------------------------------------------- + + +class TestGetHighlight: + """Tests for GET /api/v1/v2/highlights.""" + + def test_get_returns_200_html_on_cache_hit(self, client): + """GET returns 200 text/html when cache key exists.""" + test_client, fake_settings, _ = client + + # Build the same cache the router will use + db_path = str(fake_settings.prompts_db_path).replace( + "prompts.db", "highlights.db" + ) + cache = HighlightCache(db_path=db_path) + + doc_id = "doc1.pdf" + chunk_idx = 3 + sub_q = "What is the budget?" + cache_key = compute_cache_key(doc_id, chunk_idx, sub_q) + + html_content = "highlighted chunk" + cache.set_highlight( + cache_key=cache_key, + document_id=doc_id, + chunk_index=chunk_idx, + sub_question=sub_q, + relevant_sentences_json='[]', + html_content=html_content, + ) + + resp = test_client.get( + "/api/v1/v2/highlights", + params={ + "document_id": doc_id, + "chunk_index": chunk_idx, + "sub_question": sub_q, + }, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + assert "highlighted chunk" in resp.text + + def test_get_returns_404_on_cache_miss(self, client): + """GET returns 404 when document_id not in cache.""" + test_client, _, _ = client + + resp = test_client.get( + "/api/v1/v2/highlights", + params={ + "document_id": "nonexistent.pdf", + "chunk_index": 99, + "sub_question": "unknown question", + }, + ) + assert resp.status_code == 404 + + def test_get_returns_404_on_missing_params(self, client): + """GET returns 404 (or 422) when required query params are missing.""" + test_client, _, _ = client + + # Missing all params — FastAPI returns 422 for required Query params + resp = test_client.get("/api/v1/v2/highlights") + assert resp.status_code == 422