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
This commit is contained in:
Woody 2026-04-29 09:26:50 +08:00
parent c6d4a38013
commit a56f8f69e2
3 changed files with 297 additions and 1 deletions

View File

@ -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)

View File

@ -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")

View File

@ -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 = "<html><body>highlighted chunk</body></html>"
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