Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 59 additions & 17 deletions sanctions/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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


Expand All @@ -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")
Expand All @@ -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", ""),
Expand All @@ -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,
)
Expand Down Expand Up @@ -157,9 +195,13 @@ def prop_row(key: str, vals: list) -> str:
</div>"""

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"""
<div class="mt-2 mb-4 text-sm {status_color}">{status_text}</div>
{match_cards if matches else ""}"""
Expand Down
52 changes: 50 additions & 2 deletions sanctions/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -62,6 +62,48 @@ def test_search_ui_escapes_query_and_result_fields():
assert "&lt;img src=x onerror=alert(1)&gt;" 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."""
Expand Down Expand Up @@ -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={})
Expand Down
Loading