From f7dd5a342a4e5640c7a91fd4cd6bc88a80a7df43 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Mon, 23 Feb 2026 16:19:21 -0600 Subject: [PATCH] feat: add POST /api/solve/{id}/validate endpoint Co-Authored-By: Claude Opus 4.6 --- tests/test_app.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++ unredact/app.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/tests/test_app.py b/tests/test_app.py index b56cb66..a4bc7c6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -442,3 +442,51 @@ async def test_cancel_clears_buffer(): assert fake_id not in _solve_results _active_solves.pop(fake_id, None) + + +@pytest.mark.anyio +async def test_validate_endpoint(monkeypatch): + """POST /api/solve/{id}/validate should return LLM-scored results.""" + from unredact.pipeline import llm_validate + + fake_id = "validate_test" + _solve_results[fake_id] = [ + {"text": "Smith", "width_px": 50.0, "error_px": 0.1, "source": "names"}, + {"text": "house", "width_px": 50.0, "error_px": 0.2, "source": "words"}, + {"text": "running", "width_px": 50.0, "error_px": 0.3, "source": "words"}, + ] + + async def mock_validate(left, right, candidates): + return [95, 10, 5] + + monkeypatch.setattr(llm_validate, "validate_candidates", mock_validate) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post(f"/api/solve/{fake_id}/validate", json={ + "left_context": "Dear Mr.", + "right_context": ", we are writing", + }) + assert resp.status_code == 200 + data = resp.json() + assert len(data["results"]) == 3 + assert data["total"] == 3 + # Sorted by llm_score descending + assert data["results"][0]["text"] == "Smith" + assert data["results"][0]["llm_score"] == 95 + assert data["results"][1]["llm_score"] == 10 + assert data["results"][2]["llm_score"] == 5 + + _solve_results.pop(fake_id, None) + + +@pytest.mark.anyio +async def test_validate_unknown_solve_id(): + """POST /api/solve/{id}/validate with unknown id should 404.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/solve/nonexistent/validate", json={ + "left_context": "test", + "right_context": "test", + }) + assert resp.status_code == 404 diff --git a/unredact/app.py b/unredact/app.py index 0bfe42e..439ee27 100644 --- a/unredact/app.py +++ b/unredact/app.py @@ -585,6 +585,36 @@ async def get_solve_results(solve_id: str, offset: int = 0, limit: int = 200): } +class ValidateRequest(BaseModel): + left_context: str = "" + right_context: str = "" + + +@app.post("/api/solve/{solve_id}/validate") +async def validate_solve(solve_id: str, req: ValidateRequest): + if solve_id not in _solve_results: + return JSONResponse({"error": "solve not found"}, status_code=404) + + from unredact.pipeline.llm_validate import validate_candidates + + buf = _solve_results[solve_id] + candidates = [r["text"] for r in buf] + + try: + scores = await validate_candidates( + req.left_context, req.right_context, candidates, + ) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + scored = [] + for i, result in enumerate(buf): + scored.append({**result, "llm_score": scores[i] if i < len(scores) else 0}) + scored.sort(key=lambda x: x["llm_score"], reverse=True) + + return {"results": scored, "total": len(scored)} + + @app.delete("/api/solve/{solve_id}") async def cancel_solve(solve_id: str): if solve_id in _active_solves: