557 lines
26 KiB
Python
557 lines
26 KiB
Python
"""Semantic detection contracts. Fake provider proves schema and fail-closed, not live quality.
|
|
|
|
Run from backend/: python tests/test_privacy_detect.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-privacy-detect-test.sqlite")
|
|
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
|
|
os.environ["KANSHO_FAKE_DETECT"] = "1"
|
|
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
|
|
|
from fastapi.testclient import TestClient
|
|
from identity_store import (
|
|
STATUS_LEGACY,
|
|
confirm_identity,
|
|
list_confirmed_identities,
|
|
list_mappings,
|
|
list_registry,
|
|
list_review_proposals,
|
|
remember_mapping,
|
|
)
|
|
from main import app
|
|
from privacy_gateway import (
|
|
GatewayRequest,
|
|
PrivacyGatewayError,
|
|
_demask,
|
|
_validate_response,
|
|
complete,
|
|
mask_prompt,
|
|
reset_debug,
|
|
)
|
|
from entity_detect import (
|
|
DETECT_CHUNK_CHARS,
|
|
DetectError,
|
|
detect_personal_egress,
|
|
install_test_detect_failure,
|
|
install_test_spans,
|
|
install_test_truncated,
|
|
reset_detect_test_hooks,
|
|
split_detect_chunks,
|
|
uses_llm_detect,
|
|
validate_detected_entity,
|
|
)
|
|
from providers import ProviderConfig
|
|
|
|
|
|
def expect(ok: bool, message: str) -> None:
|
|
if not ok:
|
|
raise SystemExit(f"FAIL: {message}")
|
|
print(f"OK {message}")
|
|
|
|
|
|
def _run(profile_id: str, rendered: str, purpose: str = "dialogue_turn"):
|
|
return complete(
|
|
GatewayRequest(
|
|
prompt_id="detect-test",
|
|
purpose=purpose,
|
|
data_class="B",
|
|
profile_id=profile_id,
|
|
payload={"rendered": rendered, "source_text": rendered},
|
|
)
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
reset_debug()
|
|
with TestClient(app) as client:
|
|
setup = client.post(
|
|
"/api/auth/setup",
|
|
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
|
|
)
|
|
headers = {"X-Auth-Token": setup.json()["token"]}
|
|
profile_id = setup.json()["profile_id"]
|
|
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Alltag"})
|
|
day = client.post(
|
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
|
headers=headers,
|
|
json={"calendar_date": "2026-08-22"},
|
|
)
|
|
conv = client.post(
|
|
f"/api/journal/days/{day.json()['day']['id']}/conversations",
|
|
headers=headers,
|
|
json={"title": "Gespräch"},
|
|
)
|
|
|
|
balcony = _run(profile_id, "Ich ging auf den Balkon und setzte mich.")
|
|
expect(balcony.allowed, "balcony sentence is allowed")
|
|
expect("Balkon" in (balcony.trace.get("egress") or ""), "capitalized common noun stays unmasked")
|
|
expect("[[PERSON:" not in (balcony.trace.get("egress") or ""), "balcony is not a person placeholder")
|
|
|
|
typo = _run(profile_id, "Ich ging auf den Bakon und setzte mich.")
|
|
expect("Bakon" in (typo.trace.get("egress") or ""), "misspelled common noun is not stored as a person")
|
|
expect(
|
|
not any((item.get("canonical_label") or "") == "Bakon" for item in list_confirmed_identities(profile_id)),
|
|
"misspelled noun is not a confirmed identity",
|
|
)
|
|
|
|
food = _run(profile_id, "Ich aß Sushi auf dem Teller.")
|
|
expect("Sushi" in (food.trace.get("egress") or ""), "food context keeps the word")
|
|
person_sushi = _run(profile_id, "Sushi kam ins Wohnzimmer.")
|
|
expect("[[PERSON:" in (person_sushi.trace.get("egress") or ""), "same word can be a person in another sentence")
|
|
expect("Sushi" not in (person_sushi.trace.get("egress") or "").replace("[[PERSON:", ""), "person Sushi is masked")
|
|
|
|
mixed = _run(
|
|
profile_id,
|
|
"Ich traf Anna. Ich arbeitete am privaten Projekt Aurora. "
|
|
"Die Organisation Nordwerk schickte Unterlagen. Ich war in Hamburg.",
|
|
)
|
|
egress = mixed.trace.get("egress") or ""
|
|
expect("[[PERSON:" in egress, "person is typed as PERSON")
|
|
expect("[[PROJECT:" in egress, "private project is typed as PROJECT")
|
|
expect("[[ORG:" in egress, "organisation is typed as ORG")
|
|
expect("[[PLACE:" in egress, "named city is typed as PLACE")
|
|
|
|
sample = "Ich traf Anna."
|
|
start = sample.index("Anna")
|
|
span = validate_detected_entity(
|
|
{"start": start, "end": start + 4, "text": "Anna", "entity_type": "PERSON"},
|
|
sample,
|
|
0,
|
|
)
|
|
expect(span.text == "Anna" and span.start == start, "valid span matches the local substring")
|
|
relocated = validate_detected_entity(
|
|
{"start": 0, "end": 4, "text": "Anna", "entity_type": "PERSON"},
|
|
sample,
|
|
0,
|
|
)
|
|
expect(relocated is not None and relocated.start == start and relocated.text == "Anna", "wrong offsets are grounded to the local word")
|
|
expect(
|
|
validate_detected_entity(
|
|
{"start": -1, "end": 4, "text": "Anna", "entity_type": "PERSON"},
|
|
sample,
|
|
0,
|
|
).start
|
|
== start,
|
|
"negative offsets still ground when the word exists",
|
|
)
|
|
expect(
|
|
validate_detected_entity(
|
|
{"start": 0, "end": 4, "text": "Nein", "entity_type": "PERSON"},
|
|
sample,
|
|
0,
|
|
)
|
|
is None,
|
|
"a word that is not in the chunk is skipped",
|
|
)
|
|
expect(
|
|
validate_detected_entity(
|
|
{"start": "x", "end": 4, "text": "Anna", "entity_type": "PERSON"},
|
|
sample,
|
|
0,
|
|
).start
|
|
== start,
|
|
"non-numeric offsets still ground when the word exists",
|
|
)
|
|
rejected = []
|
|
for payload, label in (
|
|
({"start": start, "end": start + 4, "text": "Anna", "entity_type": "PET"}, "unknown type"),
|
|
({"start": start, "end": start + 4, "text": "Anna", "entity_type": "PERSON", "token": "PERSON:01"}, "detector token"),
|
|
({"start": start, "end": start + 4, "text": "Anna", "entity_type": "PERSON", "score": 1}, "extra field"),
|
|
):
|
|
try:
|
|
validate_detected_entity(payload, sample, 0)
|
|
except DetectError:
|
|
rejected.append(label)
|
|
expect(rejected == ["unknown type", "detector token", "extra field"], "invalid detector output is rejected")
|
|
|
|
first = _run(profile_id, "Ich war mit Anna in Hamburg und danach allein.")
|
|
expect(first.trace.get("detect_provider") == "fake-detect", "detect provider is separate")
|
|
expect("Anna" not in (first.trace.get("egress") or ""), "Anna masked before generate")
|
|
expect("Hamburg" not in (first.trace.get("egress") or ""), "Hamburg masked before generate")
|
|
expect(first.trace.get("full_detection_coverage") is True, "successful detect records full coverage")
|
|
expect(first.trace.get("generate_called") is True, "generate runs after complete detection")
|
|
expect(first.diagnostics.get("detect_calls") == 1, "normal short text uses one detect call")
|
|
labels_after = {item["canonical_label"] for item in list_confirmed_identities(profile_id)}
|
|
expect("Anna" not in labels_after, "detect hit is not auto-confirmed")
|
|
expect("Hamburg" not in labels_after, "place detect hit is not auto-confirmed")
|
|
proposals = [item["observed_label"] for item in list_review_proposals(profile_id)]
|
|
expect("Anna" in proposals, "unconfirmed proposal may be stored for review")
|
|
|
|
second = _run(profile_id, "Anna war ruhig.")
|
|
expect("Anna" not in (second.trace.get("egress") or ""), "second request still runs full detection")
|
|
expect(second.trace.get("request_local_hits", 0) >= 1, "known labels are not excluded from the new detection")
|
|
|
|
poisoned = remember_mapping(profile_id, "Wohnzimmer", "PERSON:77")
|
|
expect(poisoned["status"] == "confirmed", "remember_mapping is explicit confirmation")
|
|
update = client.patch(
|
|
f"/api/admin/identities/{poisoned['id']}",
|
|
headers=headers,
|
|
json={"status": "inactive"},
|
|
)
|
|
expect(update.status_code == 200, "admin can deactivate a false mapping")
|
|
expect(update.json()["status"] == "inactive", "deactivated mapping is inactive")
|
|
later = _run(profile_id, "Ich saß im Wohnzimmer.")
|
|
expect("Wohnzimmer" in (later.trace.get("egress") or ""), "inactive mapping does not keep masking a room")
|
|
expect(
|
|
not any(item.get("canonical_label") == "Wohnzimmer" and item.get("status") == "confirmed" for item in list_registry(profile_id)),
|
|
"later request is not bound to the deactivated mapping",
|
|
)
|
|
|
|
confirmed_project = confirm_identity(profile_id, "Kanshō", entity_type="PROJECT", aliases=["Kansho"])
|
|
canonical = _run(profile_id, "Ich arbeitete an Kansho weiter.")
|
|
expect("[[PROJECT:" in (canonical.trace.get("egress") or ""), "confirmed alias is masked")
|
|
expect("Kansho" not in (canonical.trace.get("egress") or ""), "confirmed alias does not leak")
|
|
project_manifest = mask_prompt(
|
|
"Ich arbeitete an Kansho weiter.",
|
|
[
|
|
{
|
|
"local_label": "Kansho",
|
|
"token": confirmed_project["token"],
|
|
"entity_type": "PROJECT",
|
|
"demask_label": "Kanshō",
|
|
}
|
|
],
|
|
"journal_generate",
|
|
)
|
|
expect(
|
|
_demask(f"[[{confirmed_project['token']}]] weiter", project_manifest) == "Kanshō weiter",
|
|
"confirmed alias demasks to the canonical spelling",
|
|
)
|
|
|
|
observed_manifest = mask_prompt(
|
|
"Ich traf Anna am Abend.",
|
|
[{"local_label": "Anna", "token": "PERSON:01", "demask_label": "Anna"}],
|
|
"journal_generate",
|
|
)
|
|
expect(_demask("[[PERSON:01]] am Abend.", observed_manifest) == "Anna am Abend.", "request-local hit demasks to the observed spelling")
|
|
|
|
leftover = _demask("[[PERSON:01]] und [[PERSON:99]]", mask_prompt("Anna war da.", [{"local_label": "Anna", "token": "PERSON:01", "demask_label": "Anna"}], "journal_generate"))
|
|
expect("[[PERSON:99]]" in leftover, "unknown placeholder is not materialized")
|
|
|
|
long_text = ("Anfang. " + ("x" * 1200) + " Anna in der Mitte. " + ("y" * 1200) + " Ende.")
|
|
with patch("entity_detect.DETECT_CHUNK_CHARS", 400), patch("entity_detect.DETECT_CHUNK_OVERLAP", 80):
|
|
chunks = split_detect_chunks(long_text, chunk_chars=400, overlap=80)
|
|
expect(len(chunks) >= 3, "long egress is chunked")
|
|
covered = 0
|
|
prev_end = 0
|
|
for offset, chunk in chunks:
|
|
expect(offset <= prev_end, "chunks overlap or abut")
|
|
covered = max(covered, offset + len(chunk))
|
|
prev_end = offset + len(chunk)
|
|
expect(covered == len(long_text), "chunk union covers the full text")
|
|
boundary = _run(profile_id, long_text)
|
|
expect(boundary.trace.get("chunk_count") >= 2, "gateway chunks a long prompt")
|
|
expect(boundary.trace.get("chunks_ok") == boundary.trace.get("chunk_count"), "every chunk is marked ok")
|
|
expect("Anna" not in (boundary.trace.get("egress") or ""), "entity at a chunk boundary is still masked")
|
|
expect(boundary.trace.get("full_detection_coverage") is True, "coverage is true only after every chunk")
|
|
|
|
generate_calls = {"n": 0}
|
|
|
|
def boom(_messages, _policy):
|
|
generate_calls["n"] += 1
|
|
raise AssertionError("generate must not run")
|
|
|
|
install_test_detect_failure(DetectError("detect_chunk_failed", "chunk failed"))
|
|
blocked = False
|
|
with patch("privacy_gateway.complete_model", boom):
|
|
try:
|
|
_run(profile_id, "Ich traf Anna.")
|
|
except PrivacyGatewayError as exc:
|
|
blocked = exc.code == "detect_chunk_failed"
|
|
expect(exc.diagnostics.get("generate_called") is False, "failed chunk does not call generate")
|
|
expect(exc.diagnostics.get("full_detection_coverage") is False, "failed chunk is not full coverage")
|
|
expect("Substring" not in (exc.message or ""), "detect errors omit the internal substring wording")
|
|
expect("Angaben" in (exc.message or ""), "detect errors use a user-facing message")
|
|
reset_detect_test_hooks()
|
|
expect(blocked, "failed chunk fails closed")
|
|
expect(generate_calls["n"] == 0, "generate is not called when a chunk fails")
|
|
|
|
install_test_truncated(True)
|
|
truncated = False
|
|
generate_calls["n"] = 0
|
|
with patch("privacy_gateway.complete_model", boom):
|
|
try:
|
|
_run(profile_id, "Ich traf Anna.")
|
|
except PrivacyGatewayError as exc:
|
|
truncated = exc.code == "detect_truncated"
|
|
expect(exc.diagnostics.get("generate_called") is False, "truncated detect does not call generate")
|
|
reset_detect_test_hooks()
|
|
expect(truncated, "truncated detect output fails closed")
|
|
|
|
start = "Ich traf Anna.".index("Anna")
|
|
install_test_spans([{"start": start, "end": start + 4, "text": "Anna", "entity_type": "PERSON", "token": "PERSON:09"}])
|
|
extra = False
|
|
with patch("privacy_gateway.complete_model", boom):
|
|
try:
|
|
_run(profile_id, "Ich traf Anna.")
|
|
except PrivacyGatewayError as exc:
|
|
extra = exc.code == "detect_invalid_output"
|
|
reset_detect_test_hooks()
|
|
expect(extra, "detector-assigned tokens fail closed")
|
|
|
|
install_test_spans([{"start": 0, "end": 4, "text": "Anna", "entity_type": "PERSON"}])
|
|
try:
|
|
grounded_run = _run(profile_id, "Ich traf Anna am Abend.")
|
|
expect("Anna" not in (grounded_run.trace.get("egress") or ""), "grounded name is still masked")
|
|
expect(grounded_run.trace.get("generate_called") is True, "wrong offsets do not block generate")
|
|
finally:
|
|
reset_detect_test_hooks()
|
|
|
|
results = []
|
|
|
|
def worker(label: str):
|
|
reset_debug()
|
|
out = _run(profile_id, f"Ich traf {label}.")
|
|
results.append((label, out.trace.get("active_tokens"), threading.get_ident()))
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
one = pool.submit(worker, "Anna")
|
|
two = pool.submit(worker, "Maren")
|
|
one.result()
|
|
two.result()
|
|
labels = {item[0] for item in results}
|
|
expect(labels == {"Anna", "Maren"}, "parallel requests keep their own manifests")
|
|
|
|
from db import get_db
|
|
import uuid
|
|
|
|
legacy_id = str(uuid.uuid4())
|
|
with get_db() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO identity_mappings
|
|
(id, profile_id, token, local_label, canonical_label, entity_type, status, origin)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(legacy_id, profile_id, "PERSON:55", "Wohnblock", "Wohnblock", "PERSON", STATUS_LEGACY, "legacy_auto"),
|
|
)
|
|
expect(
|
|
not any(item.get("id") == legacy_id for item in list_mappings(profile_id)),
|
|
"legacy mappings are not treated as confirmed",
|
|
)
|
|
legacy_run = _run(profile_id, "Der Wohnblock war ruhig.")
|
|
expect("Wohnblock" in (legacy_run.trace.get("egress") or ""), "legacy auto-mapping does not skip detection or force masking")
|
|
|
|
listed = client.get("/api/admin/identities", headers=headers)
|
|
expect(listed.status_code == 200, "admin identities list is authenticated")
|
|
expect("registry" in listed.json() and "proposals" in listed.json(), "admin list returns registry and proposals")
|
|
expect("local_label" not in json.dumps(first.diagnostics), "compact diagnostics omit labels")
|
|
expect("Anna" not in json.dumps(first.diagnostics), "compact diagnostics omit clear names")
|
|
expect("Anna" not in json.dumps(first.trace.get("log") or []), "run log omits labels")
|
|
|
|
provider_seen = {"n": 0}
|
|
|
|
def mark_provider(messages, policy):
|
|
provider_seen["n"] += 1
|
|
from privacy_gateway import ChatResult as _unused # noqa: F401
|
|
from providers import ChatResult
|
|
|
|
return ChatResult(content="[[PERSON:01]] war ruhig.", model="fake", usage={})
|
|
|
|
with patch("privacy_gateway.complete_model", mark_provider):
|
|
_run(profile_id, "Ich traf Anna.")
|
|
expect(provider_seen["n"] == 1, "successful detection still uses exactly one generate call")
|
|
|
|
from identity_store import coalesce_mappings, normalize_token
|
|
from privacy_gateway import _mask
|
|
from pronoun_bind import bind_user_lines, bind_pronouns
|
|
from privacy_gateway import mask_for_egress
|
|
|
|
expect(normalize_token("PERSON:KURZ") is None, "template token KURZ rejected")
|
|
first_map = remember_mapping(profile_id, "meine Frau Sushi", "PERSON:08")
|
|
second_map = remember_mapping(profile_id, "Sushi", "PERSON:KURZ")
|
|
expect(first_map["token"] == second_map["token"], "alias reuses token")
|
|
expect(second_map["canonical_label"] == "Sushi", "canonical label is the name")
|
|
extra_id = str(uuid.uuid4())
|
|
with get_db() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO identity_mappings (id, profile_id, token, local_label, canonical_label, entity_type, status, origin)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(extra_id, profile_id, "PERSON:KURZ", "meine Frau Sushi", "meine Frau Sushi", "PERSON", "confirmed", "local_authoritative"),
|
|
)
|
|
coalesce_mappings(profile_id)
|
|
sushi_rows = [
|
|
item
|
|
for item in list_registry(profile_id)
|
|
if (item.get("canonical_label") or "").lower() == "sushi"
|
|
]
|
|
expect(len(sushi_rows) == 1, "coalesce keeps one Sushi row")
|
|
expect(sushi_rows[0]["token"].upper() != "PERSON:KURZ", "stable token wins over KURZ")
|
|
joined = _mask("meine Frau Sushi kam. Sushi stellte fest.", [{"local_label": "Sushi", "token": sushi_rows[0]["token"]}])
|
|
expect(joined.count(f"[[{sushi_rows[0]['token']}]]") == 2, "both mentions share one placeholder")
|
|
expect("meine Frau " in joined, "kinship stays visible")
|
|
homonym = _mask(
|
|
"Ich esse gerne Sushi mit meiner Frau Sushi.",
|
|
[{"local_label": "Sushi", "token": sushi_rows[0]["token"]}],
|
|
)
|
|
expect("esse gerne Sushi" in homonym, "food homonym stays")
|
|
expect(homonym.count("[[") == 1, "only the person mention is masked")
|
|
eating = _mask("Heute nur von Sushi essen erzählt.", [{"local_label": "Sushi", "token": sushi_rows[0]["token"]}])
|
|
expect("Sushi essen" in eating, "noun before eat stays food")
|
|
|
|
anna = [{"local_label": "Anna", "token": "PERSON:01"}]
|
|
bound = bind_user_lines(
|
|
_mask(
|
|
"user: Ich war mit Anna.\nuser: Sie setzte sich zu mir, und ich gab ihr Tee.",
|
|
anna,
|
|
)
|
|
)
|
|
expect(bound.count("[[PERSON:01]]") == 3, "name and both pronouns share one token")
|
|
journal_masked = mask_for_egress(
|
|
"user: Ich war mit Anna.\nuser: Sie setzte sich zu mir, und ich gab ihr Tee.",
|
|
anna,
|
|
"journal_generate",
|
|
)
|
|
expect(journal_masked.count("[[PERSON:01]]") == 1, "journal masks the name only")
|
|
dialogue_masked = mask_for_egress(
|
|
"user: Ich war mit Anna.\nuser: Sie setzte sich zu mir, und ich gab ihr Tee.",
|
|
anna,
|
|
"dialogue_turn",
|
|
)
|
|
expect(dialogue_masked.count("[[PERSON:01]]") == 3, "dialogue still binds pronouns")
|
|
herr = [{"local_label": "Berg", "token": "PERSON:02"}]
|
|
male, _ = bind_pronouns(_mask("Herr Berg kam. Er setzte sich.", herr))
|
|
expect(male.count("[[PERSON:02]]") == 2, "masculine pronoun follows last man")
|
|
address, _ = bind_pronouns(_mask("Anna kam. Seid ihr schon da?", anna))
|
|
expect("ihr" in address, "address ihr is not a person")
|
|
plural, _ = bind_pronouns(_mask("Anna kam. Sie waren zu dritt.", anna))
|
|
expect("Sie waren" in plural or "sie waren" in plural.lower(), "plural sie stays")
|
|
|
|
from identity_store import is_maskable_label
|
|
|
|
expect(not is_maskable_label("ich"), "pronoun ich is not a mapping")
|
|
expect(not is_maskable_label("user"), "role user is not a mapping")
|
|
expect(not is_maskable_label("6:00 Uhr"), "clock time is not identity")
|
|
expect(is_maskable_label("Sushi"), "name-shaped label stays structurally maskable")
|
|
expect(is_maskable_label("Himmel"), "weather word is not banned by a stopword list")
|
|
|
|
from db import get_db as _get_db
|
|
|
|
with _get_db() as conn:
|
|
row = conn.execute(
|
|
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
|
("mvp.entity_detect",),
|
|
).fetchone()
|
|
expect(row["seed_revision"] == "2026-08-27-detect-ground-v1", "detect prompt revision is stored")
|
|
expect("{{known_labels}}" not in (row["template"] or ""), "new detect prompt has no known_labels skip list")
|
|
expect("Zwiebeln" not in (row["template"] or ""), "detect prompt has no food word list")
|
|
|
|
with _get_db() as conn:
|
|
conn.execute(
|
|
"UPDATE ai_prompts SET template = ? WHERE slug = ?",
|
|
("CUSTOM DETECT {{source_text}}", "mvp.entity_detect"),
|
|
)
|
|
from db import init_db
|
|
|
|
init_db()
|
|
with _get_db() as conn:
|
|
custom = conn.execute(
|
|
"SELECT template, seed_revision FROM ai_prompts WHERE slug = ?",
|
|
("mvp.entity_detect",),
|
|
).fetchone()
|
|
expect(custom["template"] == "CUSTOM DETECT {{source_text}}", "user-edited detect prompt is not overwritten")
|
|
expect(custom["seed_revision"] == "2026-08-27-detect-ground-v1", "revision still updates the default")
|
|
|
|
remote = ProviderConfig(
|
|
role="detect",
|
|
name="openrouter",
|
|
mode="http",
|
|
url="https://openrouter.ai/api/v1/chat/completions",
|
|
model="x",
|
|
key="sk-test",
|
|
local=False,
|
|
zdr=True,
|
|
no_train=True,
|
|
)
|
|
expect(uses_llm_detect(remote), "development allows remote detect")
|
|
os.environ["KANSHO_ENV"] = "production"
|
|
expect(not uses_llm_detect(remote), "production blocks remote detect")
|
|
local = ProviderConfig(
|
|
role="detect",
|
|
name="ollama",
|
|
mode="http",
|
|
url="http://127.0.0.1:11434/v1/chat/completions",
|
|
model="x",
|
|
key="",
|
|
local=True,
|
|
zdr=True,
|
|
no_train=True,
|
|
)
|
|
expect(uses_llm_detect(local), "production still allows local detect")
|
|
os.environ.pop("KANSHO_ENV", None)
|
|
|
|
mapped = [{"local_label": "Anna", "token": "PERSON:01"}]
|
|
anna_manifest = mask_prompt("Anna war am Markt.", mapped, "journal_generate")
|
|
kept = _validate_response("[[PERSON:01]] war ruhig.", anna_manifest)
|
|
expect(_demask(kept, anna_manifest) == "Anna war ruhig.", "demask after validation")
|
|
expect(
|
|
_demask("[[ person:01 ]] war ruhig.", anna_manifest) == "Anna war ruhig.",
|
|
"demask is case- and space-insensitive",
|
|
)
|
|
blocked_plain = False
|
|
try:
|
|
_validate_response("Anna war ruhig.", anna_manifest)
|
|
except PrivacyGatewayError as exc:
|
|
blocked_plain = exc.code == "response_validation_failed"
|
|
expect(blocked_plain, "plaintext identity is blocked before demask")
|
|
|
|
no_user = client.get("/api/admin/identities")
|
|
expect(no_user.status_code in {401, 403}, "identity registry requires auth")
|
|
admin_calls = {"n": 0}
|
|
|
|
def no_egress(*_args, **_kwargs):
|
|
admin_calls["n"] += 1
|
|
raise AssertionError("admin identity actions must not call detect")
|
|
|
|
with patch("entity_detect.complete_chat", no_egress), patch("privacy_gateway.complete_model", no_egress):
|
|
created = client.post(
|
|
"/api/admin/identities",
|
|
headers=headers,
|
|
json={"canonical_label": "Nordlicht", "entity_type": "PROJECT", "aliases": ["Nordlicht-Projekt"]},
|
|
)
|
|
expect(created.status_code == 200, f"admin create stays local {created.text}")
|
|
expect(admin_calls["n"] == 0, "admin registry actions create no external egress")
|
|
expect(created.json()["status"] == "confirmed", "admin create confirms locally")
|
|
|
|
turn = client.post(
|
|
f"/api/journal/conversations/{conv.json()['id']}/turn",
|
|
headers=headers,
|
|
json={"body": "Ich war mit Anna in Hamburg und danach allein."},
|
|
)
|
|
expect(turn.status_code == 200, f"turn {turn.status_code}")
|
|
expect((turn.json().get("trace") or {}).get("generate_called") is True, "dialogue generate still runs once after detect")
|
|
|
|
install_test_detect_failure(DetectError("detect_invalid_output", "Detect-text entspricht nicht dem lokalen Substring."))
|
|
try:
|
|
held = client.post(
|
|
f"/api/journal/conversations/{conv.json()['id']}/turn",
|
|
headers=headers,
|
|
json={"body": "Heute nur der Markt."},
|
|
)
|
|
expect(held.status_code == 200, f"detect fallback turn {held.status_code} {held.text}")
|
|
expect((held.json().get("assistant") or {}).get("body"), "detect failure still leaves a local impulse")
|
|
expect((held.json().get("decision") or {}).get("guard") == "detect_blocked", "detect fallback is marked locally")
|
|
finally:
|
|
reset_detect_test_hooks()
|
|
|
|
print("All privacy detect tests passed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|