From 4bc50f8120674d1b1d097171b0802b312e3469c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rgen?= Date: Sat, 28 Mar 2026 22:37:47 +0100 Subject: [PATCH] Fix screening coverage and blank inputs --- sanctions/src/main.py | 76 ++++++++++++++++++++++++++++-------- sanctions/tests/test_main.py | 52 +++++++++++++++++++++++- 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/sanctions/src/main.py b/sanctions/src/main.py index 8108c74..55e333c 100644 --- a/sanctions/src/main.py +++ b/sanctions/src/main.py @@ -7,11 +7,12 @@ import html as html_lib import json import os +from typing import Annotated import httpx from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse -from pydantic import BaseModel +from pydantic import BaseModel, StringConstraints app = FastAPI( title="Beyond AI — Sanctions Screener", @@ -20,10 +21,20 @@ ) YENTE_URL = os.getenv("YENTE_URL", "http://localhost:8100") +YENTE_MATCH_LIMIT = 10 +YENTE_QUERY_SCHEMAS = { + "person": "Person", + "organization": "Organization", +} + +ScreenName = Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1), +] class ScreenRequest(BaseModel): - name: str + name: ScreenName threshold: float = 0.7 @@ -42,21 +53,41 @@ class ScreenResponse(BaseModel): async def _query_yente(name: str, threshold: float = 0.7) -> list[dict]: + query_name = name.strip() + queries = { + query_id: { + "schema": schema, + "properties": {"name": [query_name]}, + } + for query_id, schema in YENTE_QUERY_SCHEMAS.items() + } + async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( f"{YENTE_URL}/match/default", - params={"threshold": threshold, "limit": 10}, - json={ - "queries": { - "q1": { - "schema": "Person", - "properties": {"name": [name]}, - } - } - }, + params={"threshold": threshold, "limit": YENTE_MATCH_LIMIT}, + json={"queries": queries}, ) resp.raise_for_status() - return resp.json().get("responses", {}).get("q1", {}).get("results", []) + + responses = resp.json().get("responses", {}) + combined_results = [] + seen_matches = set() + + for query_id in queries: + for result in responses.get(query_id, {}).get("results", []): + match_key = ( + str(result.get("id", "")), + tuple(sorted(str(dataset) for dataset in result.get("datasets", []))), + str(result.get("caption", result.get("name", ""))), + ) + if match_key in seen_matches: + continue + seen_matches.add(match_key) + combined_results.append(result) + + combined_results.sort(key=lambda result: result.get("score", 0.0), reverse=True) + return combined_results[:YENTE_MATCH_LIMIT] @app.get("/health") @@ -70,13 +101,16 @@ async def search_ui(request: Request, q: str = "", threshold: float = 0.7): matches = [] error = None raw_json = "" + normalized_query = q.strip() def escape_text(value: object) -> str: return html_lib.escape(str(value), quote=True) - if q: + if q and not normalized_query: + error = "Bitte einen Namen eingeben." + elif normalized_query: try: - results = await _query_yente(q, threshold) + results = await _query_yente(normalized_query, threshold) matches = [ { "id": r.get("id", ""), @@ -88,7 +122,11 @@ def escape_text(value: object) -> str: for r in results ] raw_json = json.dumps( - {"query": q, "total": len(matches), "matches": matches}, + { + "query": normalized_query, + "total": len(matches), + "matches": matches, + }, indent=2, ensure_ascii=False, ) @@ -157,9 +195,13 @@ def prop_row(key: str, vals: list) -> str: """ result_section = "" - if q and not error: + if normalized_query and not error: status_color = "text-red-600 font-semibold" if matches else "text-green-600 font-semibold" - status_text = f"⚠️ {len(matches)} Treffer gefunden" if matches else "✅ Keine Treffer — Person nicht gelistet" + status_text = ( + f"⚠️ {len(matches)} Treffer gefunden" + if matches + else "✅ Keine Treffer — Entität nicht gelistet" + ) result_section = f"""
{status_text}
{match_cards if matches else ""}""" diff --git a/sanctions/tests/test_main.py b/sanctions/tests/test_main.py index 6ac1c77..7a89afe 100644 --- a/sanctions/tests/test_main.py +++ b/sanctions/tests/test_main.py @@ -4,13 +4,13 @@ """ import html -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import httpx import pytest from fastapi.testclient import TestClient -from sanctions.src.main import app +from sanctions.src.main import _query_yente, app client = TestClient(app) @@ -62,6 +62,48 @@ def test_search_ui_escapes_query_and_result_fields(): assert "<img src=x onerror=alert(1)>" in response.text +def test_search_ui_whitespace_query_shows_validation_error(): + """Whitespace-only Queries duerfen nicht als cleanes Screening erscheinen.""" + with patch("sanctions.src.main._query_yente", new=AsyncMock()) as query_mock: + response = client.get("/search", params={"q": " "}) + + assert response.status_code == 200 + query_mock.assert_not_called() + assert "Bitte einen Namen eingeben." in response.text + assert "Keine Treffer" not in response.text + + +@pytest.mark.asyncio +async def test_query_yente_screens_persons_and_organizations(): + """Organisationen werden neben Personen gegen yente abgefragt.""" + fake_response = Mock() + fake_response.raise_for_status = Mock() + fake_response.json.return_value = { + "responses": { + "person": {"results": []}, + "organization": { + "results": [ + { + "id": "org-1", + "caption": "JOINT STOCK COMPANY SBERBANK", + "score": 1.0, + "datasets": ["sanctions"], + "properties": {"name": ["Sberbank"]}, + } + ] + }, + } + } + + with patch("httpx.AsyncClient.post", new=AsyncMock(return_value=fake_response)) as post_mock: + results = await _query_yente("Sberbank") + + assert results[0]["id"] == "org-1" + queries = post_mock.await_args.kwargs["json"]["queries"] + assert queries["person"]["schema"] == "Person" + assert queries["organization"]["schema"] == "Organization" + + @pytest.mark.asyncio async def test_screen_endpoint_mocked(): """POST /api/screen mit gemocktem yente-Aufruf.""" @@ -121,6 +163,12 @@ def test_screen_timeout_returns_gateway_timeout(): } +def test_screen_blank_name_rejected(): + """Leere oder whitespace-only Namen sind ungueltig.""" + response = client.post("/api/screen", json={"name": " "}) + assert response.status_code == 422 + + def test_screen_missing_body(): """Fehlender Body → 422 Validation Error.""" response = client.post("/api/screen", json={})