Compare commits

..

1 Commits

Author SHA1 Message Date
032eeb54fb Merge develop into main for production deploy.
All checks were successful
Deploy Production / deploy (push) Successful in 40s
2026-09-08 10:07:57 +02:00
68 changed files with 233 additions and 6110 deletions

View File

@ -28,10 +28,3 @@ KANSHO_FRONTEND_PORT=3006
KANSHO_BACKEND_PORT=8005 KANSHO_BACKEND_PORT=8005
KANSHO_PROVIDER_KEY= KANSHO_PROVIDER_KEY=
KANSHO_DETECT_PROVIDER_KEY= KANSHO_DETECT_PROVIDER_KEY=
# Betriebsmail. Leer = kein Versand (production/test: Fehler, development: Link nur im Server-Log).
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM=kansho@example.test

View File

@ -14,8 +14,8 @@ KANSHO_PROVIDER_KEY=
# KANSHO_PROVIDER_ZDR=1 # KANSHO_PROVIDER_ZDR=1
# KANSHO_PROVIDER_NO_TRAIN=1 # KANSHO_PROVIDER_NO_TRAIN=1
# Detect: lokal (Ollama LAN) oder in Development/Test remote. Production remote nur mit KANSHO_ALLOW_REMOTE_DETECT. # Detect: Klartext lokal, in Development/Test, oder in Production mit KANSHO_ALLOW_REMOTE_DETECT.
# URL/Modell liegen in LLM-Profilen (Admin → Schnittstellen), nicht in der .env. # Leer = Generate-Key mitnutzen (nur wenn remote Detect erlaubt ist).
KANSHO_DETECT_PROVIDER_KEY= KANSHO_DETECT_PROVIDER_KEY=
# KANSHO_DETECT_PROVIDER_URL=https://openrouter.ai/api/v1/chat/completions # KANSHO_DETECT_PROVIDER_URL=https://openrouter.ai/api/v1/chat/completions
# KANSHO_DETECT_PROVIDER_MODEL=openai/gpt-4.1-nano # KANSHO_DETECT_PROVIDER_MODEL=openai/gpt-4.1-nano
@ -33,12 +33,3 @@ KANSHO_DETECT_PROVIDER_KEY=
# DB_USER=kansho # DB_USER=kansho
# DB_PASSWORD= # DB_PASSWORD=
# Betriebsmail für Passwort-Reset und Adressbestätigung. Ohne SMTP schlägt der Versand fehl.
# In development wird der Link nur ins Server-Log geschrieben.
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM=kansho@example.test
# APP_URL=http://localhost:5188

View File

@ -1,468 +0,0 @@
"""Login identifiers, address verification, and one-time reset links."""
from __future__ import annotations
import hashlib
import os
import re
import secrets
import uuid
from datetime import datetime, timedelta
from fastapi import HTTPException
from auth import hash_password
from db import row_to_dict
USERNAME_RE = re.compile(r"^[a-z0-9._]{3,32}$")
PUBLIC_COLUMNS = (
"id",
"email",
"name",
"role",
"status",
"tier_id",
"created",
"username",
"secondary_email",
"email_verified_at",
"secondary_email_verified_at",
"pending_email",
"pending_secondary_email",
)
FORGOT_MESSAGE = (
"Wenn ein Konto zu dieser Angabe existiert, wurde ein Link an die verifizierten Adressen gesendet."
)
RESET_MINUTES = 60
VERIFY_HOURS = 24
LINK_LIMIT = 3
def utc_stamp(moment: datetime | None = None) -> str:
current = moment or datetime.utcnow()
return current.strftime("%Y-%m-%d %H:%M:%S")
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def app_base_url() -> str:
return (os.environ.get("APP_URL") or "http://localhost:5188").rstrip("/")
def profile_view(row: dict | None) -> dict | None:
if not row:
return None
data = {key: row.get(key) for key in PUBLIC_COLUMNS}
data["email_verified"] = bool(row.get("email_verified_at"))
data["secondary_email_verified"] = bool(row.get("secondary_email_verified_at"))
return data
def normalize_email(value: str) -> str:
email = (value or "").strip().lower()
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email or email.count("@") != 1:
raise HTTPException(400, "E-Mail-Adresse ist ungültig")
local, domain = email.split("@", 1)
if not local or "." not in domain:
raise HTTPException(400, "E-Mail-Adresse ist ungültig")
return email
def normalize_username(value: str) -> str:
username = (value or "").strip().lower()
if not USERNAME_RE.match(username):
raise HTTPException(400, "Benutzername: 3–32 Zeichen, Buchstaben, Ziffern, Punkt oder Unterstrich")
return username
def find_by_login(conn, identifier: str) -> dict | None:
key = (identifier or "").strip().lower()
if not key:
return None
return row_to_dict(
conn.execute(
"""
SELECT * FROM profiles
WHERE lower(email) = ? OR lower(COALESCE(username, '')) = ?
""",
(key, key),
).fetchone()
)
def _owner_of_address(conn, email: str, exclude_id: str | None) -> dict | None:
return row_to_dict(
conn.execute(
"""
SELECT id FROM profiles
WHERE (
lower(email) = ?
OR lower(COALESCE(secondary_email, '')) = ?
OR lower(COALESCE(pending_email, '')) = ?
OR lower(COALESCE(pending_secondary_email, '')) = ?
)
AND id != COALESCE(?, '')
""",
(email, email, email, email, exclude_id or ""),
).fetchone()
)
def assert_address_free(conn, email: str, exclude_id: str | None) -> None:
if _owner_of_address(conn, email, exclude_id):
raise HTTPException(409, "E-Mail ist bereits vergeben")
def assert_username_free(conn, username: str, exclude_id: str | None) -> None:
row = conn.execute(
"""
SELECT id FROM profiles
WHERE lower(COALESCE(username, '')) = ? AND id != COALESCE(?, '')
""",
(username, exclude_id or ""),
).fetchone()
if row:
raise HTTPException(409, "Benutzername ist bereits vergeben")
def _recent_count(conn, table: str, profile_id: str) -> int:
cutoff = utc_stamp(datetime.utcnow() - timedelta(hours=1))
if table not in {"password_reset_tokens", "email_verification_tokens"}:
raise HTTPException(500, "Unbekannte Token-Tabelle")
row = conn.execute(
f"SELECT COUNT(*) AS n FROM {table} WHERE profile_id = ? AND created > ?",
(profile_id, cutoff),
).fetchone()
return int(row["n"])
def issue_password_reset(conn, profile_id: str) -> str | None:
if _recent_count(conn, "password_reset_tokens", profile_id) >= LINK_LIMIT:
return None
raw = secrets.token_urlsafe(32)
now = datetime.utcnow()
conn.execute(
"""
INSERT INTO password_reset_tokens (id, profile_id, token_hash, expires_at, created)
VALUES (?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
profile_id,
hash_token(raw),
utc_stamp(now + timedelta(minutes=RESET_MINUTES)),
utc_stamp(now),
),
)
return raw
def issue_verification(conn, profile_id: str, purpose: str, email: str) -> str | None:
if purpose not in {"primary", "secondary"}:
raise HTTPException(400, "Unbekannter Verifikationszweck")
if _recent_count(conn, "email_verification_tokens", profile_id) >= LINK_LIMIT:
return None
raw = secrets.token_urlsafe(32)
now = datetime.utcnow()
conn.execute(
"""
INSERT INTO email_verification_tokens
(id, profile_id, purpose, email, token_hash, expires_at, created)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
profile_id,
purpose,
email,
hash_token(raw),
utc_stamp(now + timedelta(hours=VERIFY_HOURS)),
utc_stamp(now),
),
)
return raw
def reset_link(token: str) -> str:
return f"{app_base_url()}/reset-password?token={token}"
def verify_link(token: str) -> str:
return f"{app_base_url()}/verify-email?token={token}"
def reset_message(token: str) -> tuple[str, str]:
body = (
"Über diesen Link setzt du das Passwort für Kanshō zurück.\n\n"
f"{reset_link(token)}\n\n"
"Der Link gilt 60 Minuten und kann nur einmal verwendet werden. "
"Wenn du das nicht angefordert hast, ignoriere diese Nachricht."
)
return "Kanshō – Passwort zurücksetzen", body
def verify_message(token: str) -> tuple[str, str]:
body = (
"Über diesen Link bestätigst du eine E-Mail-Adresse für Kanshō.\n\n"
f"{verify_link(token)}\n\n"
"Der Link gilt 24 Stunden und kann nur einmal verwendet werden."
)
return "Kanshō – E-Mail-Adresse bestätigen", body
def change_notice() -> tuple[str, str]:
body = (
"Für dieses Kanshō-Konto wurde eine Änderung der E-Mail-Adresse angefordert. "
"Wenn du das nicht warst, setze das Passwort zurück."
)
return "Kanshō – Änderung der E-Mail-Adresse", body
def verified_reset_recipients(profile: dict) -> list[str]:
recipients: list[str] = []
if profile.get("email_verified_at") and profile.get("email"):
recipients.append(profile["email"])
if profile.get("secondary_email_verified_at") and profile.get("secondary_email"):
recipients.append(profile["secondary_email"])
return recipients
def admin_reset_recipients(profile: dict) -> list[str]:
recipients: list[str] = []
if profile.get("email"):
recipients.append(profile["email"])
if profile.get("secondary_email_verified_at") and profile.get("secondary_email"):
recipients.append(profile["secondary_email"])
return recipients
def notice_recipients(profile: dict) -> list[str]:
return verified_reset_recipients(profile)
def load_profile(conn, profile_id: str) -> dict | None:
return row_to_dict(conn.execute("SELECT * FROM profiles WHERE id = ?", (profile_id,)).fetchone())
def consume_password_reset(conn, token: str, password: str) -> None:
if len(password or "") < 4:
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
now = utc_stamp()
row = row_to_dict(
conn.execute(
"""
SELECT * FROM password_reset_tokens
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
""",
(hash_token(token.strip()), now),
).fetchone()
)
if not row:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
conn.execute(
"UPDATE profiles SET password_hash = ? WHERE id = ?",
(hash_password(password), row["profile_id"]),
)
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (row["profile_id"],))
def consume_verification(conn, token: str) -> dict:
now = utc_stamp()
row = row_to_dict(
conn.execute(
"""
SELECT * FROM email_verification_tokens
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
""",
(hash_token(token.strip()), now),
).fetchone()
)
if not row:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
profile = load_profile(conn, row["profile_id"])
if not profile:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
purpose = row["purpose"]
address = row["email"]
if purpose == "primary":
pending = (profile.get("pending_email") or "").strip().lower()
current = (profile.get("email") or "").strip().lower()
if pending:
if address != pending:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
assert_address_free(conn, address, profile["id"])
conn.execute(
"""
UPDATE profiles
SET email = ?, email_verified_at = ?, pending_email = NULL
WHERE id = ?
""",
(address, now, profile["id"]),
)
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile["id"],))
elif address == current:
conn.execute(
"UPDATE profiles SET email_verified_at = ? WHERE id = ?",
(now, profile["id"]),
)
else:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
elif purpose == "secondary":
pending = (profile.get("pending_secondary_email") or "").strip().lower()
current = (profile.get("secondary_email") or "").strip().lower()
if pending:
if address != pending:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
assert_address_free(conn, address, profile["id"])
conn.execute(
"""
UPDATE profiles
SET secondary_email = ?, secondary_email_verified_at = ?, pending_secondary_email = NULL
WHERE id = ?
""",
(address, now, profile["id"]),
)
elif current and address == current:
conn.execute(
"UPDATE profiles SET secondary_email_verified_at = ? WHERE id = ?",
(now, profile["id"]),
)
else:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
else:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
conn.execute(
"UPDATE email_verification_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
updated = load_profile(conn, profile["id"])
viewed = profile_view(updated)
if viewed is None:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
return viewed
def _queue_verification(conn, profile: dict, purpose: str, address: str) -> tuple[str, str] | None:
raw = issue_verification(conn, profile["id"], purpose, address)
if not raw:
return None
return verify_message(raw)
def apply_identifier_edits(
conn,
profile: dict,
*,
username: str | None = None,
email: str | None = None,
secondary_email: str | None = None,
username_set: bool = False,
email_set: bool = False,
secondary_set: bool = False,
) -> list[tuple[list[str], str, str]]:
"""Apply username and address edits. Returns mails as (recipients, subject, body)."""
jobs: list[tuple[list[str], str, str]] = []
if username_set:
if username is None or username.strip() == "":
conn.execute("UPDATE profiles SET username = NULL WHERE id = ?", (profile["id"],))
else:
cleaned = normalize_username(username)
assert_username_free(conn, cleaned, profile["id"])
conn.execute("UPDATE profiles SET username = ? WHERE id = ?", (cleaned, profile["id"]))
profile = load_profile(conn, profile["id"]) or profile
if email_set and email is not None:
cleaned = normalize_email(email)
current = (profile.get("email") or "").strip().lower()
secondary = (profile.get("secondary_email") or "").strip().lower()
pending_secondary = (profile.get("pending_secondary_email") or "").strip().lower()
if cleaned == current:
pass
else:
if cleaned == secondary or cleaned == pending_secondary:
raise HTTPException(409, "E-Mail ist bereits vergeben")
assert_address_free(conn, cleaned, profile["id"])
conn.execute("UPDATE profiles SET pending_email = ? WHERE id = ?", (cleaned, profile["id"]))
queued = _queue_verification(conn, profile, "primary", cleaned)
if queued:
subject, body = queued
jobs.append(([cleaned], subject, body))
notice = notice_recipients(profile)
notice = [item for item in notice if item != cleaned]
if notice:
jobs.append((notice, *change_notice()))
else:
raise HTTPException(429, "Zu viele Bestätigungslinks. Bitte später erneut versuchen.")
profile = load_profile(conn, profile["id"]) or profile
if secondary_set:
if secondary_email is None or secondary_email.strip() == "":
conn.execute(
"""
UPDATE profiles
SET secondary_email = NULL,
secondary_email_verified_at = NULL,
pending_secondary_email = NULL
WHERE id = ?
""",
(profile["id"],),
)
else:
cleaned = normalize_email(secondary_email)
current_primary = (profile.get("email") or "").strip().lower()
pending_primary = (profile.get("pending_email") or "").strip().lower()
current_secondary = (profile.get("secondary_email") or "").strip().lower()
if cleaned == current_primary or cleaned == pending_primary:
raise HTTPException(409, "E-Mail ist bereits vergeben")
if cleaned == current_secondary and profile.get("secondary_email_verified_at") and not profile.get("pending_secondary_email"):
pass
else:
assert_address_free(conn, cleaned, profile["id"])
conn.execute(
"UPDATE profiles SET pending_secondary_email = ? WHERE id = ?",
(cleaned, profile["id"]),
)
queued = _queue_verification(conn, profile, "secondary", cleaned)
if queued:
subject, body = queued
jobs.append(([cleaned], subject, body))
notice = [item for item in notice_recipients(profile) if item != cleaned]
if notice:
jobs.append((notice, *change_notice()))
else:
raise HTTPException(429, "Zu viele Bestätigungslinks. Bitte später erneut versuchen.")
return jobs
def deliver_jobs(jobs: list[tuple[list[str], str, str]]) -> None:
from mailer import send_mail
for recipients, subject, body in jobs:
send_mail(recipients, subject, body)
def resend_verification(conn, profile: dict, purpose: str) -> tuple[list[str], str, str]:
if purpose == "primary":
target = (profile.get("pending_email") or "").strip().lower()
if not target and not profile.get("email_verified_at"):
target = (profile.get("email") or "").strip().lower()
if not target or (profile.get("email_verified_at") and not profile.get("pending_email")):
raise HTTPException(400, "Keine offene Bestätigung für die primäre E-Mail")
elif purpose == "secondary":
target = (profile.get("pending_secondary_email") or "").strip().lower()
if not target and profile.get("secondary_email") and not profile.get("secondary_email_verified_at"):
target = profile["secondary_email"].strip().lower()
if not target or (
profile.get("secondary_email_verified_at") and not profile.get("pending_secondary_email")
):
raise HTTPException(400, "Keine offene Bestätigung für die zweite E-Mail")
else:
raise HTTPException(400, "Unbekannter Verifikationszweck")
queued = _queue_verification(conn, profile, purpose, target)
if not queued:
raise HTTPException(429, "Zu viele Bestätigungslinks. Bitte später erneut versuchen.")
subject, body = queued
return [target], subject, body

View File

@ -56,35 +56,7 @@ SQLITE_HISTORY_MIGRATIONS = (
_PROFILE_COLUMNS = { _PROFILE_COLUMNS = {
"status": "TEXT NOT NULL DEFAULT 'active'", "status": "TEXT NOT NULL DEFAULT 'active'",
"tier_id": "TEXT NOT NULL DEFAULT 'local'", "tier_id": "TEXT NOT NULL DEFAULT 'local'",
"username": "TEXT",
"secondary_email": "TEXT",
"email_verified_at": "TEXT",
"secondary_email_verified_at": "TEXT",
"pending_email": "TEXT",
"pending_secondary_email": "TEXT",
} }
_ACCOUNT_INDEXES = (
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_username_lower_idx
ON profiles (lower(username))
WHERE username IS NOT NULL AND username <> ''
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_secondary_email_lower_idx
ON profiles (lower(secondary_email))
WHERE secondary_email IS NOT NULL AND secondary_email <> ''
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_email_lower_idx
ON profiles (lower(pending_email))
WHERE pending_email IS NOT NULL AND pending_email <> ''
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_secondary_email_lower_idx
ON profiles (lower(pending_secondary_email))
WHERE pending_secondary_email IS NOT NULL AND pending_secondary_email <> ''
""",
)
_PROMPT_COLUMNS = { _PROMPT_COLUMNS = {
"description": "TEXT NOT NULL DEFAULT ''", "description": "TEXT NOT NULL DEFAULT ''",
"category": "TEXT NOT NULL DEFAULT 'uncategorized'", "category": "TEXT NOT NULL DEFAULT 'uncategorized'",
@ -255,27 +227,6 @@ def _mark(conn: Any, migration_id: str) -> None:
) )
def _ensure_account_identifiers(conn: Any) -> None:
"""Add login identifiers on existing SQLite files and grandfather current emails once."""
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
for statement in _ACCOUNT_INDEXES:
conn.execute(statement)
row = conn.execute(
"SELECT id FROM schema_migrations WHERE id = ?",
("027_account_identifiers",),
).fetchone()
if row:
return
conn.execute(
"""
UPDATE profiles
SET email_verified_at = COALESCE(created, datetime('now'))
WHERE email_verified_at IS NULL OR email_verified_at = ''
"""
)
_mark(conn, "027_account_identifiers")
def _seed_platform(conn: Any) -> None: def _seed_platform(conn: Any) -> None:
seed = json.loads(SEED_PATH.read_text(encoding="utf-8")) seed = json.loads(SEED_PATH.read_text(encoding="utf-8"))
for tier in seed.get("tiers", []): for tier in seed.get("tiers", []):
@ -629,9 +580,8 @@ def init_db() -> None:
_seed_prompts(conn) _seed_prompts(conn)
from provider_settings import seed_provider_settings from provider_settings import seed_provider_settings
_ensure_columns(conn, "provider_settings", {"profile_id": "TEXT"})
seed_provider_settings(conn) seed_provider_settings(conn)
_ensure_account_identifiers(conn) _ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
_ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS) _ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS)
_ensure_columns(conn, "journal_days", _JOURNAL_DAY_COLUMNS) _ensure_columns(conn, "journal_days", _JOURNAL_DAY_COLUMNS)
_ensure_columns(conn, "journal_drafts", _JOURNAL_DRAFT_COLUMNS) _ensure_columns(conn, "journal_drafts", _JOURNAL_DRAFT_COLUMNS)
@ -643,7 +593,6 @@ def init_db() -> None:
migrate_journal_source_refs(conn) migrate_journal_source_refs(conn)
_migrate_writing_profile_shell(conn) _migrate_writing_profile_shell(conn)
_ensure_columns(conn, "identity_mappings", _IDENTITY_MAPPING_COLUMNS) _ensure_columns(conn, "identity_mappings", _IDENTITY_MAPPING_COLUMNS)
_ensure_columns(conn, "label_sense_cues", {"excerpt": "TEXT NOT NULL DEFAULT ''"})
from identity_store import migrate_legacy_identity_rows from identity_store import migrate_legacy_identity_rows
migrate_legacy_identity_rows(conn) migrate_legacy_identity_rows(conn)

File diff suppressed because it is too large Load Diff

View File

@ -1,620 +0,0 @@
"""Student-first detect pipeline with optional teacher comparison.
Cues stay local. OpenRouter/GLiNER never create identities. Compact counters
carry no plaintext labels.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from detect_learning import (
MODE_LEARNING_MANUAL,
MODE_STUDENT,
_CUE_BARE,
_DECISION_ASK,
_DECISION_IDENTITY,
_DECISION_NOT,
_align_user_span,
_mention_fields,
_resolve_user_span,
_token_for_label,
_word_matches,
apply_learned_decisions,
auto_resolve_ambiguous,
cue_for_mention,
decision_for_mention,
get_cue_decision,
get_detect_operating_mode,
get_local_ner_settings,
get_sense,
is_bypass_mode,
is_learning_mode,
learning_stage,
merge_gliner_candidates,
record_cue,
supplement_kinship_candidates,
)
from entity_detect import DetectError, detect_personal_egress
from identity_store import ENTITY_TYPES, is_maskable_label
KIND_DIFF = "diff"
KIND_MANUAL = "manual"
KIND_CONFIRM_REMOTE = "confirm_remote"
PROTECTING_TYPES = set(ENTITY_TYPES)
FOOD_TYPES = frozenset(
{"FOOD", "DISH", "MEAL", "CUISINE", "FOODSTUFF", "INGREDIENT", "food", "dish"}
)
TYPE_MAP = {
"person": "PERSON",
"place": "PLACE",
"org": "ORG",
"organization": "ORG",
"project": "PROJECT",
"food": "FOOD",
"dish": "DISH",
}
@dataclass
class MentionSpan:
text: str
start: int
end: int
entity_type: str
protection: str
source: str
confirmed_cue: bool = False
cue: str = _CUE_BARE
def key(self) -> tuple[int, int, str]:
return (self.start, self.end, self.text.casefold())
@dataclass
class StudentResult:
mappings: list[dict] = field(default_factory=list)
candidates: list[dict] = field(default_factory=list)
spans: list[MentionSpan] = field(default_factory=list)
gliner_missing: bool = False
@dataclass
class TeacherResult:
spans: list[MentionSpan] = field(default_factory=list)
mappings: list[dict] = field(default_factory=list)
ok: bool = True
error: str = ""
@dataclass
class PipelineResult:
mappings: list[dict]
candidates: list[dict]
kind: str = KIND_DIFF
need_review: bool = False
need_remote_confirm: bool = False
counters: dict[str, int] = field(default_factory=dict)
mode: str = ""
def empty_counters() -> dict[str, int]:
return {
"diff_agree": 0,
"diff_only_student": 0,
"diff_only_teacher": 0,
"diff_type_conflict": 0,
"diff_new_identity": 0,
"diff_cue_reinforced": 0,
}
def map_entity_type(raw: str) -> str:
token = (raw or "").strip()
if token.upper() in PROTECTING_TYPES or token.upper() in {item.upper() for item in FOOD_TYPES}:
mapped = TYPE_MAP.get(token.lower())
return mapped or token.upper()
return TYPE_MAP.get(token.lower()) or token.upper()
def protects(entity_type: str) -> bool:
kind = map_entity_type(entity_type)
if kind in FOOD_TYPES or kind.upper() in {item.upper() for item in FOOD_TYPES}:
return False
return kind in PROTECTING_TYPES
def types_equivalent(left: str, right: str) -> bool:
a, b = map_entity_type(left), map_entity_type(right)
if a in FOOD_TYPES or b in FOOD_TYPES or a in {"FOOD", "DISH"} or b in {"FOOD", "DISH"}:
return a == b
if protects(a) and protects(b):
return True
return a == b
def _contains(outer: MentionSpan, inner: MentionSpan) -> bool:
return outer.start <= inner.start and inner.end <= outer.end and outer.end > outer.start
def same_mention(left: MentionSpan, right: MentionSpan) -> bool:
if left.text.casefold() == right.text.casefold() and left.start == right.start and left.end == right.end:
return True
if _contains(left, right) or _contains(right, left):
return True
return False
def _collapse_same_protection(spans: list[MentionSpan]) -> list[MentionSpan]:
if not spans:
return []
remaining = list(spans)
chosen: list[MentionSpan] = []
while remaining:
current = remaining.pop(0)
cluster = [current]
rest = []
for item in remaining:
if same_mention(current, item):
cluster.append(item)
else:
rest.append(item)
remaining = rest
protections = {item.protection for item in cluster}
if len(protections) > 1:
chosen.extend(cluster)
continue
chosen.append(max(cluster, key=lambda item: (item.end - item.start, -item.start)))
return chosen
def _user_span_for_mapping(mapping: dict, user_body: str, rendered: str = "") -> tuple[int, int] | None:
return _resolve_user_span(
user_body,
mapping.get("start"),
mapping.get("end"),
mapping.get("local_label") or "",
rendered,
)
def _mapping_from_user_span(
mappings: list[dict],
label: str,
user_body: str,
start: int,
end: int,
entity_type: str,
rendered: str,
) -> dict:
aligned = _align_user_span(rendered, user_body, start, end)
kind = entity_type if entity_type in ENTITY_TYPES else "PERSON"
return {
"token": _token_for_label(mappings, label, kind),
"local_label": label,
"canonical_label": label,
"demask_label": label,
"entity_type": kind,
"source": "request_local",
"start": aligned[0],
"end": aligned[1],
"aliases": [],
"labels": [label],
}
def run_student_path(profile_id: str, user_body: str, rendered: str = "") -> StudentResult:
mappings: list[dict] = []
candidates: list[dict] = []
spans: list[MentionSpan] = []
text = user_body or ""
for match in _word_matches(text):
label = match.group(0)
if not is_maskable_label(label):
continue
start, end = match.start(), match.end()
verdict = decision_for_mention(profile_id, label, text, start, end, rendered)
cue = cue_for_mention(text, start, end, label, rendered)
if verdict == _DECISION_IDENTITY:
mappings.append(_mapping_from_user_span(mappings, label, text, start, end, "PERSON", rendered))
spans.append(
MentionSpan(label, start, end, "PERSON", _DECISION_IDENTITY, "cue", True, cue)
)
elif verdict == _DECISION_NOT:
spans.append(
MentionSpan(label, start, end, "FOOD", _DECISION_NOT, "cue", True, cue)
)
elif get_sense(profile_id, label)["ambiguous"]:
aligned = _align_user_span(rendered, text, start, end)
candidates.append(
{
"id": str(uuid.uuid4()),
"text": label,
"entity_type": "PERSON",
"start": aligned[0],
"end": aligned[1],
**_mention_fields(text, start, end, label, rendered),
"ambiguous": True,
}
)
candidates, mappings = supplement_kinship_candidates(
profile_id, candidates, mappings, text, rendered
)
candidates, mappings = auto_resolve_ambiguous(profile_id, candidates, mappings)
bound = {(span.start, span.end, span.text.casefold()) for span in spans}
for item in candidates:
resolved = _resolve_user_span(
text,
item.get("user_start", item.get("start")),
item.get("user_end", item.get("end")),
item.get("text") or "",
rendered,
)
if resolved:
bound.add((resolved[0], resolved[1], (item.get("text") or "").casefold()))
settings = get_local_ner_settings()
gliner_missing = False
if settings.get("enabled"):
from gliner_client import extract_entities
raw = extract_entities(text, url=settings.get("url"))
if raw is None:
gliner_missing = True
else:
usable = []
for item in raw:
try:
start, end = int(item["start"]), int(item["end"])
except (KeyError, TypeError, ValueError):
continue
label = (item.get("text") or "").casefold()
if any(
not (end <= left or start >= right) and folded == label
for left, right, folded in bound
) or any(left <= start and end <= right for left, right, _folded in bound):
continue
usable.append(item)
candidates, mappings = merge_gliner_candidates(
profile_id, candidates, mappings, text, rendered, usable
)
candidates, mappings = apply_learned_decisions(profile_id, candidates, mappings, text, rendered)
for item in candidates:
resolved = _resolve_user_span(
text,
item.get("user_start", item.get("start")),
item.get("user_end", item.get("end")),
item.get("text") or "",
rendered,
)
if not resolved:
continue
suggested = item.get("suggested") or _DECISION_IDENTITY
kind = item.get("entity_type") or "PERSON"
protection = _DECISION_NOT if suggested == _DECISION_NOT else _DECISION_IDENTITY
entity = "FOOD" if protection == _DECISION_NOT else kind
spans.append(
MentionSpan(
item.get("text") or "",
resolved[0],
resolved[1],
entity,
protection,
"candidate",
False,
item.get("cue") or _CUE_BARE,
)
)
return StudentResult(
mappings=mappings,
candidates=candidates,
spans=_collapse_same_protection(spans),
gliner_missing=gliner_missing,
)
def blank_cue_safe_forms(profile_id: str, user_body: str, rendered: str = "") -> str:
chars = list(user_body or "")
for match in _word_matches(user_body or ""):
label = match.group(0)
if not is_maskable_label(label):
continue
verdict = decision_for_mention(
profile_id, label, user_body, match.start(), match.end(), rendered
)
if verdict not in {_DECISION_IDENTITY, _DECISION_NOT}:
continue
for index in range(match.start(), match.end()):
chars[index] = " "
return "".join(chars)
def _teacher_span(label: str, start: int, end: int, entity_type: str) -> MentionSpan:
kind = map_entity_type(entity_type)
protection = _DECISION_IDENTITY if protects(kind) else _DECISION_NOT
return MentionSpan(label, start, end, kind, protection, "teacher", False)
def run_teacher(
profile_id: str,
user_body: str,
*,
stage: int = 1,
rendered: str = "",
) -> TeacherResult:
source = user_body or ""
if stage >= 2:
source = blank_cue_safe_forms(profile_id, user_body, rendered)
try:
outcome = detect_personal_egress(profile_id, source)
except DetectError as exc:
return TeacherResult(ok=False, error=exc.code)
spans: list[MentionSpan] = []
for mapping in outcome.mappings or []:
label = mapping.get("local_label") or ""
start, end = mapping.get("start"), mapping.get("end")
resolved = None
if start is not None and end is not None:
left, right = int(start), int(end)
if 0 <= left < right <= len(user_body) and user_body[left:right].casefold() == label.casefold():
resolved = (left, right)
if not resolved:
resolved = _resolve_user_span(user_body, start, end, label, user_body)
if not resolved:
continue
spans.append(
_teacher_span(label, resolved[0], resolved[1], mapping.get("entity_type") or "PERSON")
)
omitted = list((outcome.stats.omitted_non_identity_types or []) if outcome.stats else [])
for kind in omitted:
if map_entity_type(kind) in FOOD_TYPES or kind.upper() in {"FOOD", "DISH"}:
continue
return TeacherResult(spans=_collapse_same_protection(spans), mappings=list(outcome.mappings or []), ok=True)
def _candidate_from_span(
span: MentionSpan,
user_body: str,
rendered: str,
*,
suggested: str,
reason: str,
) -> dict:
aligned = _align_user_span(rendered, user_body, span.start, span.end)
kind = span.entity_type if span.entity_type in ENTITY_TYPES else "PERSON"
return {
"id": str(uuid.uuid4()),
"text": span.text,
"entity_type": kind,
"start": aligned[0],
"end": aligned[1],
**_mention_fields(user_body, span.start, span.end, span.text, rendered),
"ambiguous": False,
"suggested": suggested,
"reason": reason,
}
def compare_mentions(
profile_id: str,
user_body: str,
student_spans: list[MentionSpan],
teacher_spans: list[MentionSpan],
rendered: str = "",
) -> tuple[list[dict], dict[str, int]]:
counters = empty_counters()
review: list[dict] = []
used_teacher: set[int] = set()
student_list = _collapse_same_protection(student_spans)
teacher_list = _collapse_same_protection(teacher_spans)
def take_teacher(index: int) -> MentionSpan:
used_teacher.add(index)
return teacher_list[index]
for student in student_list:
match_index = next(
(
index
for index, teacher in enumerate(teacher_list)
if index not in used_teacher and same_mention(student, teacher)
),
None,
)
if match_index is None:
counters["diff_only_student"] += 1
if student.confirmed_cue:
continue
if student.protection == _DECISION_IDENTITY:
counters["diff_new_identity"] += 1
review.append(
_candidate_from_span(
student, user_body, rendered, suggested=_DECISION_IDENTITY, reason="only_student"
)
)
continue
teacher = take_teacher(match_index)
if student.protection != teacher.protection or (
protects(student.entity_type) != protects(teacher.entity_type)
):
counters["diff_type_conflict"] += 1
if student.confirmed_cue:
continue
review.append(
_candidate_from_span(
student,
user_body,
rendered,
suggested=student.protection,
reason="type_conflict",
)
)
continue
if types_equivalent(student.entity_type, teacher.entity_type) or (
student.protection == teacher.protection
):
counters["diff_agree"] += 1
if student.confirmed_cue:
record_cue(profile_id, student.text, student.cue, student.protection)
counters["diff_cue_reinforced"] += 1
elif student.protection == _DECISION_IDENTITY:
learned = get_cue_decision(profile_id, student.text, student.cue)
if learned == _DECISION_IDENTITY:
record_cue(profile_id, student.text, student.cue, _DECISION_IDENTITY)
counters["diff_cue_reinforced"] += 1
else:
counters["diff_new_identity"] += 1
review.append(
_candidate_from_span(
student, user_body, rendered, suggested=_DECISION_IDENTITY, reason="new_identity"
)
)
for index, teacher in enumerate(teacher_list):
if index in used_teacher:
continue
counters["diff_only_teacher"] += 1
cue = cue_for_mention(user_body, teacher.start, teacher.end, teacher.text, rendered)
learned = get_cue_decision(profile_id, teacher.text, cue)
if learned:
continue
if teacher.protection == _DECISION_IDENTITY:
counters["diff_new_identity"] += 1
review.append(
_candidate_from_span(
teacher, user_body, rendered, suggested=_DECISION_IDENTITY, reason="only_teacher"
)
)
return review, counters
def _remote_confirm_candidate(user_body: str) -> dict:
snippet = (user_body or "")[:80]
return {
"id": str(uuid.uuid4()),
"text": "Klartext-Detect",
"entity_type": "PERSON",
"start": 0,
"end": 0,
"excerpt": snippet,
"highlight_start": None,
"highlight_end": None,
"user_start": None,
"user_end": None,
"cue": _CUE_BARE,
"ambiguous": False,
"suggested": _DECISION_NOT,
"reason": KIND_CONFIRM_REMOTE,
}
def prepare_detect_pipeline(profile_id: str, user_body: str, rendered: str = "") -> PipelineResult:
mode = get_detect_operating_mode()
counters = empty_counters()
if is_bypass_mode(mode):
return PipelineResult(mappings=[], candidates=[], kind=KIND_DIFF, mode=mode, counters=counters)
student = run_student_path(profile_id, user_body, rendered)
if mode == MODE_STUDENT:
if student.gliner_missing:
return PipelineResult(
mappings=student.mappings,
candidates=[_remote_confirm_candidate(user_body)],
kind=KIND_CONFIRM_REMOTE,
need_review=True,
need_remote_confirm=True,
counters=counters,
mode=mode,
)
return PipelineResult(
mappings=student.mappings,
candidates=[],
kind=KIND_DIFF,
counters=counters,
mode=mode,
)
if mode == MODE_LEARNING_MANUAL or student.gliner_missing:
return PipelineResult(
mappings=student.mappings,
candidates=student.candidates,
kind=KIND_MANUAL,
need_review=bool(student.candidates),
counters=counters,
mode=mode,
)
teacher = run_teacher(profile_id, user_body, stage=learning_stage(mode), rendered=rendered)
if not teacher.ok:
return PipelineResult(
mappings=student.mappings,
candidates=student.candidates,
kind=KIND_MANUAL,
need_review=bool(student.candidates),
counters=counters,
mode=mode,
)
review, counters = compare_mentions(
profile_id, user_body, student.spans, teacher.spans, rendered
)
merged = list(student.mappings)
seen = {
(
item.get("start"),
item.get("end"),
(item.get("local_label") or "").casefold(),
)
for item in merged
}
for mapping in teacher.mappings:
key = (mapping.get("start"), mapping.get("end"), (mapping.get("local_label") or "").casefold())
if key in seen:
continue
label = mapping.get("local_label") or ""
resolved = _resolve_user_span(
user_body, mapping.get("start"), mapping.get("end"), label, user_body
)
if resolved:
cue = cue_for_mention(user_body, resolved[0], resolved[1], label, rendered)
if get_cue_decision(profile_id, label, cue) == _DECISION_NOT:
continue
merged.append(mapping)
seen.add(key)
return PipelineResult(
mappings=merged,
candidates=review,
kind=KIND_DIFF,
need_review=bool(review),
counters=counters,
mode=mode,
)
def compact_diff_counters(counters: dict[str, int] | None) -> dict[str, int]:
payload = empty_counters()
for key in payload:
payload[key] = int((counters or {}).get(key) or 0)
return payload
def apply_remote_confirm(profile_id: str, pending: dict, decisions: list[dict]) -> list[dict]:
"""After the user allows plaintext detect, run teacher on the user line."""
approved = any((item.get("decision") or "").strip() == _DECISION_IDENTITY for item in decisions)
if not approved:
raise DetectError(
"remote_detect_declined",
"Klartext-Detect wurde nicht bestätigt.",
403,
)
user_body = pending.get("user_body") or ""
teacher = run_teacher(profile_id, user_body, stage=1)
if not teacher.ok:
raise DetectError(teacher.error or "detect_provider_unavailable", "Lehrer-Detect ist nicht erreichbar.")
merged = list(pending.get("mappings") or [])
seen = {
(item.get("start"), item.get("end"), (item.get("local_label") or "").casefold())
for item in merged
}
for mapping in teacher.mappings:
key = (mapping.get("start"), mapping.get("end"), (mapping.get("local_label") or "").casefold())
if key in seen:
continue
merged.append(mapping)
seen.add(key)
return merged

View File

@ -7,19 +7,9 @@ import re
from context_builder import assemble_text, build_internal_context, is_closing_turn from context_builder import assemble_text, build_internal_context, is_closing_turn
from conversation_signals import infer_signals from conversation_signals import infer_signals
from debug_store import persist_engine_error, persist_step from debug_store import persist_engine_error, persist_step
from detect_learning import (
apply_review_decisions,
confirm_known_identity_spans,
drop_pending,
is_bypass_mode,
load_pending,
save_pending,
suppress_known_non_identity,
)
from detect_pipeline import KIND_CONFIRM_REMOTE, prepare_detect_pipeline
from dialogue_store import append_message, get_conversation, list_messages, update_conversation_signals from dialogue_store import append_message, get_conversation, list_messages, update_conversation_signals
from engine import EngineError, execute_prompt, load_active_prompt, preview_prompt from engine import EngineError, execute_prompt, load_active_prompt
from entity_detect import DETECT_DIALOGUE_FALLBACK_CODES, DetectError from entity_detect import DETECT_DIALOGUE_FALLBACK_CODES
from writing_profile_store import remember_dialogue_style from writing_profile_store import remember_dialogue_style
from profile_review import consider_dialogue from profile_review import consider_dialogue
@ -312,22 +302,23 @@ def visible_for_role(payload: dict, role: str | None) -> dict:
return cleaned return cleaned
def _finish_turn( def run_turn(profile_id: str, conversation_id: str, body: str, message_id: str | None = None) -> dict:
profile_id: str, conversation = get_conversation(profile_id, conversation_id)
conversation_id: str, user = append_message(profile_id, conversation_id, body, role="user", message_id=message_id)
user: dict, context = build_internal_context(
assembled: dict, profile_id,
prompt: dict, conversation_id=conversation_id,
*, space_id=conversation.get("space_id"),
precomputed_mappings: list[dict] | None = None, journal_day_id=conversation.get("journal_day_id"),
) -> dict: purpose="dialogue_turn",
)
assembled = assemble_text(context)
prompt = load_active_prompt("mvp.dialogue_turn")
calls = 0 calls = 0
result = None result = None
impulse = "" impulse = ""
decision: dict = {"operation": "unparsed", "label": "nicht erkannt", "parsed": False} decision: dict = {"operation": "unparsed", "label": "nicht erkannt", "parsed": False}
call_traces: list[dict] = [] call_traces: list[dict] = []
working = dict(assembled)
working["user_body"] = user.get("body") or working.get("user_body") or ""
try: try:
while calls < 2: while calls < 2:
result = execute_prompt( result = execute_prompt(
@ -335,8 +326,7 @@ def _finish_turn(
profile_id, profile_id,
purpose="dialogue_turn", purpose="dialogue_turn",
data_class="B", data_class="B",
context=working, context=assembled,
precomputed_mappings=precomputed_mappings if calls == 0 else None,
) )
calls += 1 calls += 1
if result.get("trace"): if result.get("trace"):
@ -345,16 +335,16 @@ def _finish_turn(
if not content: if not content:
raise EngineError("empty_provider_response", "Der Provider lieferte keine Antwort.") raise EngineError("empty_provider_response", "Der Provider lieferte keine Antwort.")
impulse, decision = parse_turn_payload(content) impulse, decision = parse_turn_payload(content)
if not needs_repair(impulse, working): if not needs_repair(impulse, assembled):
break break
decision = {**decision, "guard": "impulse_rejected"} decision = {**decision, "guard": "impulse_rejected"}
working = dict(working) assembled = dict(assembled)
working["dialogue_context"] = ( assembled["dialogue_context"] = (
(working.get("dialogue_context") or "") + "\n\n" + repair_note(impulse, working) (assembled.get("dialogue_context") or "") + "\n\n" + repair_note(impulse, assembled)
) )
if needs_repair(impulse, working): if needs_repair(impulse, assembled):
parsed_ok = bool(decision.get("parsed")) parsed_ok = bool(decision.get("parsed"))
impulse = local_hold(last_user_text(working)) impulse = local_hold(last_user_text(assembled))
decision = { decision = {
"operation": "fortfuehren", "operation": "fortfuehren",
"label": OPERATIONS["fortfuehren"], "label": OPERATIONS["fortfuehren"],
@ -379,7 +369,7 @@ def _finish_turn(
}, },
) )
raise raise
impulse = local_hold(last_user_text(working)) impulse = local_hold(last_user_text(assembled))
decision = { decision = {
"operation": "fortfuehren", "operation": "fortfuehren",
"label": OPERATIONS["fortfuehren"], "label": OPERATIONS["fortfuehren"],
@ -398,7 +388,7 @@ def _finish_turn(
infer_signals(user_bodies, decision.get("operation")), infer_signals(user_bodies, decision.get("operation")),
) )
remember_dialogue_style(profile_id) remember_dialogue_style(profile_id)
consider_dialogue(profile_id, user.get("body") or "") consider_dialogue(profile_id, body)
trace = result.get("trace") if result else None trace = result.get("trace") if result else None
persist_step( persist_step(
profile_id, profile_id,
@ -426,114 +416,3 @@ def _finish_turn(
"decision": decision, "decision": decision,
"trace": trace, "trace": trace,
} }
def _review_pause_payload(review_id: str, candidates: list[dict], kind: str, purpose: str = "dialogue_turn") -> dict:
return {
"id": review_id,
"candidates": candidates,
"kind": kind,
"purpose": purpose,
}
def _learning_pause(profile_id: str, conversation_id: str, user: dict, assembled: dict, prompt: dict) -> dict | None:
preview = preview_prompt(prompt, assembled)
rendered = preview.get("rendered") or ""
user_body = user.get("body") or ""
result = prepare_detect_pipeline(profile_id, user_body, rendered)
mappings = list(result.mappings or [])
if not result.need_review and not result.candidates:
confirm_known_identity_spans(profile_id, mappings, user_body)
mappings = suppress_known_non_identity(profile_id, mappings, user_body)
return _finish_turn(
profile_id,
conversation_id,
user,
assembled,
prompt,
precomputed_mappings=mappings,
)
review_id = save_pending(
profile_id,
conversation_id,
user.get("id") or "",
{
"mappings": mappings,
"candidates": result.candidates,
"user_body": user_body,
"kind": result.kind,
"counters": result.counters,
"purpose": "dialogue_turn",
},
)
return {
"conversation": get_conversation(profile_id, conversation_id),
"user": user,
"assistant": None,
"calls": 0,
"messages": list_messages(profile_id, conversation_id),
"pending_mask_review": _review_pause_payload(review_id, result.candidates, result.kind),
}
def run_turn(profile_id: str, conversation_id: str, body: str, message_id: str | None = None) -> dict:
conversation = get_conversation(profile_id, conversation_id)
user = append_message(profile_id, conversation_id, body, role="user", message_id=message_id)
context = build_internal_context(
profile_id,
conversation_id=conversation_id,
space_id=conversation.get("space_id"),
journal_day_id=conversation.get("journal_day_id"),
purpose="dialogue_turn",
)
assembled = assemble_text(context)
prompt = load_active_prompt("mvp.dialogue_turn")
if is_bypass_mode():
return _finish_turn(profile_id, conversation_id, user, assembled, prompt)
paused = _learning_pause(profile_id, conversation_id, user, assembled, prompt)
if paused is not None:
return paused
return _finish_turn(profile_id, conversation_id, user, assembled, prompt)
def continue_turn(profile_id: str, conversation_id: str, review_id: str, decisions: list[dict]) -> dict:
pending = load_pending(profile_id, review_id)
if not pending or pending.get("conversation_id") != conversation_id:
raise EngineError("mask_review_missing", "Die Maskierungsprüfung ist nicht mehr gültig.", 404)
if pending.get("purpose") == "journal_generate":
raise EngineError("mask_review_missing", "Diese Prüfung gehört zum Journalentwurf.", 409)
if pending.get("kind") == KIND_CONFIRM_REMOTE:
try:
from detect_pipeline import apply_remote_confirm
mappings = apply_remote_confirm(profile_id, pending, decisions)
except DetectError as exc:
raise EngineError(exc.code, exc.message, getattr(exc, "status_code", 403)) from exc
else:
mappings = apply_review_decisions(profile_id, pending, decisions)
drop_pending(profile_id, review_id)
conversation = get_conversation(profile_id, conversation_id)
context = build_internal_context(
profile_id,
conversation_id=conversation_id,
space_id=conversation.get("space_id"),
journal_day_id=conversation.get("journal_day_id"),
purpose="dialogue_turn",
)
assembled = assemble_text(context)
prompt = load_active_prompt("mvp.dialogue_turn")
user = {"id": pending.get("user_message_id"), "body": pending.get("user_body") or ""}
messages = list_messages(profile_id, conversation_id)
for item in messages:
if item.get("id") == pending.get("user_message_id"):
user = item
break
return _finish_turn(
profile_id,
conversation_id,
user,
assembled,
prompt,
precomputed_mappings=mappings,
)

View File

@ -75,8 +75,6 @@ def execute_prompt(
disable_context_compression: bool = False, disable_context_compression: bool = False,
budget=None, budget=None,
diagnostics: dict[str, Any] | None = None, diagnostics: dict[str, Any] | None = None,
precomputed_mappings: list[dict] | None = None,
detect_counters: dict | None = None,
) -> dict: ) -> dict:
preview = preview_prompt(prompt, context) preview = preview_prompt(prompt, context)
feature_id = prompt.get("required_feature") or "ai_calls" feature_id = prompt.get("required_feature") or "ai_calls"
@ -102,7 +100,6 @@ def execute_prompt(
payload={ payload={
"rendered": preview["rendered"], "rendered": preview["rendered"],
"source_text": source_text, "source_text": source_text,
"user_body": (ctx.get("user_body") or user_source_text(ctx.get("dialogue_context") or "")).strip(),
"privacy_tokens": preview["privacy_tokens"], "privacy_tokens": preview["privacy_tokens"],
"prompt_slug": prompt.get("slug"), "prompt_slug": prompt.get("slug"),
"prompt_revision": prompt.get("seed_revision") or "", "prompt_revision": prompt.get("seed_revision") or "",
@ -110,8 +107,6 @@ def execute_prompt(
"disable_context_compression": disable_context_compression, "disable_context_compression": disable_context_compression,
"budget": budget, "budget": budget,
"diagnostics": diagnostics or {}, "diagnostics": diagnostics or {},
"precomputed_mappings": precomputed_mappings,
"detect_counters": detect_counters or {},
}, },
) )
) )

View File

@ -31,9 +31,7 @@ from providers import ChatResult, ProviderError, complete_chat, detect_provider
DETECT_CHUNK_CHARS = 4000 DETECT_CHUNK_CHARS = 4000
DETECT_CHUNK_OVERLAP = 250 DETECT_CHUNK_OVERLAP = 250
DETECT_TIMEOUT = 90.0 DETECT_TIMEOUT = 90.0
DETECT_TIMEOUT_LOCAL = 300.0
DETECT_MAX_TOKENS = 1024 DETECT_MAX_TOKENS = 1024
DETECT_MAX_TOKENS_LOCAL = 256
JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL) JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
ALLOWED_ENTITY_FIELDS = frozenset({"start", "end", "text", "entity_type"}) ALLOWED_ENTITY_FIELDS = frozenset({"start", "end", "text", "entity_type"})
ALLOWED_ROOT_FIELDS = frozenset({"entities"}) ALLOWED_ROOT_FIELDS = frozenset({"entities"})
@ -575,13 +573,6 @@ def _add_usage(stats: DetectionStats, usage: dict | None) -> None:
pass pass
def detect_chat_limits(config) -> tuple[float, int]:
"""Remote detect stays at 90s/1024. Local CPU hosts need a longer wait and a shorter cap."""
if getattr(config, "local", False):
return DETECT_TIMEOUT_LOCAL, DETECT_MAX_TOKENS_LOCAL
return DETECT_TIMEOUT, DETECT_MAX_TOKENS
def _llm_chunk(config, excerpt: str, *, schema_retry: bool = False) -> ChatResult: def _llm_chunk(config, excerpt: str, *, schema_retry: bool = False) -> ChatResult:
prompt = resolve_template( prompt = resolve_template(
_detect_prompt()["template"], _detect_prompt()["template"],
@ -589,12 +580,11 @@ def _llm_chunk(config, excerpt: str, *, schema_retry: bool = False) -> ChatResul
) )
if schema_retry: if schema_retry:
prompt = f"{prompt.rstrip()}\n\n{SCHEMA_RETRY_HINT}" prompt = f"{prompt.rstrip()}\n\n{SCHEMA_RETRY_HINT}"
timeout, max_tokens = detect_chat_limits(config)
return complete_chat( return complete_chat(
config, config,
[{"role": "user", "content": prompt}], [{"role": "user", "content": prompt}],
timeout=timeout, timeout=DETECT_TIMEOUT,
max_tokens=max_tokens, max_tokens=DETECT_MAX_TOKENS,
disable_context_compression=True, disable_context_compression=True,
) )

View File

@ -1,121 +0,0 @@
"""Stateless client for the LAN GLiNER spike. Fail-soft; never logs request text."""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse, urlunparse
import httpx
from providers import is_local_url
log = logging.getLogger("gliner_client")
DEFAULT_URL = "http://192.168.2.144:8090"
DEFAULT_LABELS = ("person", "place", "organization", "project", "dish", "food")
IDENTITY_LABELS = {
"person": "PERSON",
"place": "PLACE",
"organization": "ORG",
"project": "PROJECT",
}
NON_IDENTITY_LABELS = frozenset({"dish", "food"})
EXTRACT_TIMEOUT = 8.0
HEALTH_TIMEOUT = 2.0
def normalize_base_url(url: str | None) -> str:
raw = (url or DEFAULT_URL).strip() or DEFAULT_URL
parsed = urlparse(raw)
if not parsed.scheme or not parsed.netloc:
return DEFAULT_URL
path = (parsed.path or "").rstrip("/")
return urlunparse((parsed.scheme, parsed.netloc, path, "", "", ""))
def gliner_identity_type(label: str) -> str | None:
return IDENTITY_LABELS.get((label or "").strip().lower())
def is_gliner_non_identity(label: str) -> bool:
return (label or "").strip().lower() in NON_IDENTITY_LABELS
def health(url: str | None = None) -> dict[str, Any]:
"""Ping GLiNER. Never include request text. Fail-soft."""
base = normalize_base_url(url)
if not is_local_url(base):
return {"ok": False, "status": "rejected", "reason": "not_local"}
try:
response = httpx.get(f"{base}/health", timeout=HEALTH_TIMEOUT)
except httpx.HTTPError:
return {"ok": False, "status": "unreachable", "url": base}
if response.status_code >= 400:
return {"ok": False, "status": "http_error", "http_status": response.status_code}
try:
payload = response.json()
except ValueError:
return {"ok": False, "status": "invalid"}
if not isinstance(payload, dict):
return {"ok": False, "status": "invalid"}
status = (payload.get("status") or "").strip() or "ok"
return {
"ok": status == "ok",
"status": status,
"model": payload.get("model") or "",
"offline": bool(payload.get("offline")),
}
def extract_entities(text: str, url: str | None = None) -> list[dict] | None:
"""Return spans or None if the service cannot be used. Empty list is a valid miss."""
body = text or ""
if not body.strip():
return []
base = normalize_base_url(url)
if not is_local_url(base):
log.info("gliner skipped: url is not local")
return None
try:
response = httpx.post(
f"{base}/v1/extract",
json={"text": body, "labels": list(DEFAULT_LABELS)},
timeout=EXTRACT_TIMEOUT,
)
except httpx.HTTPError:
log.info("gliner extract unreachable chars=%s", len(body))
return None
if response.status_code >= 400:
log.info("gliner extract http=%s chars=%s", response.status_code, len(body))
return None
try:
payload = response.json()
except ValueError:
return None
raw = payload.get("spans") if isinstance(payload, dict) else None
if not isinstance(raw, list):
return None
spans = []
for item in raw:
if not isinstance(item, dict):
continue
try:
start = int(item["start"])
end = int(item["end"])
except (KeyError, TypeError, ValueError):
continue
label = str(item.get("text") or "")
kind = str(item.get("label") or "")
if start < 0 or end <= start or not label or not kind:
continue
spans.append(
{
"text": label,
"start": start,
"end": end,
"label": kind,
"score": item.get("score"),
}
)
log.info("gliner extract chars=%s spans=%s", len(body), len(spans))
return spans

View File

@ -5,17 +5,8 @@ import re
from context_builder import assemble_text, build_internal_context from context_builder import assemble_text, build_internal_context
from debug_store import persist_engine_error, persist_step from debug_store import persist_engine_error, persist_step
from detect_learning import (
apply_review_decisions,
drop_pending,
is_bypass_mode,
load_pending,
save_pending,
)
from detect_pipeline import KIND_CONFIRM_REMOTE, apply_remote_confirm, prepare_detect_pipeline
from dialogue_store import StoreError, list_conversations_for_day, list_messages from dialogue_store import StoreError, list_conversations_for_day, list_messages
from engine import EngineError, execute_prompt, load_active_prompt, preview_prompt from engine import EngineError, execute_prompt, load_active_prompt, preview_prompt
from entity_detect import DetectError
from journal_policy import require_explicit_generate, source_conversation_ids from journal_policy import require_explicit_generate, source_conversation_ids
from identity_store import is_maskable_label, list_mappings, mapping_spellings from identity_store import is_maskable_label, list_mappings, mapping_spellings
from journal_body import clean_title from journal_body import clean_title
@ -381,7 +372,6 @@ def generate_draft(
explicit: bool = True, explicit: bool = True,
generation_selection: dict | None = None, generation_selection: dict | None = None,
remember_generation_selection: bool = False, remember_generation_selection: bool = False,
precomputed_mappings: list[dict] | None = None,
) -> dict: ) -> dict:
try: try:
draft = _generate_draft( draft = _generate_draft(
@ -392,7 +382,6 @@ def generate_draft(
explicit=explicit, explicit=explicit,
generation_selection=generation_selection, generation_selection=generation_selection,
remember_generation_selection=remember_generation_selection, remember_generation_selection=remember_generation_selection,
precomputed_mappings=precomputed_mappings,
) )
except EngineError as exc: except EngineError as exc:
persist_engine_error( persist_engine_error(
@ -405,8 +394,6 @@ def generate_draft(
extra={"conversation_ids": conversation_ids or []}, extra={"conversation_ids": conversation_ids or []},
) )
raise raise
if draft.get("pending_mask_review"):
return draft
persist_step( persist_step(
profile_id, profile_id,
purpose="journal_generate", purpose="journal_generate",
@ -436,7 +423,6 @@ def _generate_draft(
explicit: bool = True, explicit: bool = True,
generation_selection: dict | None = None, generation_selection: dict | None = None,
remember_generation_selection: bool = False, remember_generation_selection: bool = False,
precomputed_mappings: list[dict] | None = None,
) -> dict: ) -> dict:
require_explicit_generate(explicit) require_explicit_generate(explicit)
day = get_day(profile_id, journal_day_id) day = get_day(profile_id, journal_day_id)
@ -578,44 +564,6 @@ def _generate_draft(
) )
rendered_preview = preview_prompt(narrate_prompt, assembled) rendered_preview = preview_prompt(narrate_prompt, assembled)
assert_template_resolved(rendered_preview.get("rendered") or "") assert_template_resolved(rendered_preview.get("rendered") or "")
detect_mappings = precomputed_mappings
if detect_mappings is None and not is_bypass_mode():
user_text = "\n".join(source_user)
pipe = prepare_detect_pipeline(profile_id, user_text, "")
if (pipe.need_review or pipe.candidates) and selected:
review_id = save_pending(
profile_id,
selected[0],
"",
{
"mappings": pipe.mappings,
"candidates": pipe.candidates,
"user_body": user_text,
"kind": pipe.kind,
"counters": pipe.counters,
"purpose": "journal_generate",
"generate": {
"journal_day_id": journal_day_id,
"conversation_ids": selected,
"include_existing": include_existing,
"generation_selection": generation_selection,
"remember_generation_selection": remember_generation_selection,
},
},
)
return {
"pending_mask_review": {
"id": review_id,
"candidates": pipe.candidates,
"kind": pipe.kind,
"purpose": "journal_generate",
},
"run_log": run_log,
"trace": None,
}
detect_mappings = pipe.mappings
assembled = dict(assembled)
assembled["user_body"] = "\n".join(source_user)
narrate_result = execute_prompt( narrate_result = execute_prompt(
narrate_prompt, narrate_prompt,
profile_id, profile_id,
@ -625,7 +573,6 @@ def _generate_draft(
max_tokens=narrate_budget.reserved_output_tokens, max_tokens=narrate_budget.reserved_output_tokens,
disable_context_compression=True, disable_context_compression=True,
budget=narrate_budget, budget=narrate_budget,
precomputed_mappings=detect_mappings,
) )
_stamp_log(run_log, "journal_generate", _log_items(narrate_result)) _stamp_log(run_log, "journal_generate", _log_items(narrate_result))
title, body = _split_title(narrate_result.get("content") or "") title, body = _split_title(narrate_result.get("content") or "")
@ -798,30 +745,3 @@ def _generate_draft(
draft["run_log"] = run_log draft["run_log"] = run_log
draft["trace"] = bundle draft["trace"] = bundle
return draft return draft
def continue_journal_generate(profile_id: str, journal_day_id: str, review_id: str, decisions: list[dict]) -> dict:
pending = load_pending(profile_id, review_id)
if not pending or pending.get("purpose") != "journal_generate":
raise EngineError("mask_review_missing", "Die Maskierungsprüfung ist nicht mehr gültig.", 404)
generate = pending.get("generate") or {}
if generate.get("journal_day_id") != journal_day_id:
raise EngineError("mask_review_missing", "Die Maskierungsprüfung ist nicht mehr gültig.", 404)
if pending.get("kind") == KIND_CONFIRM_REMOTE:
try:
mappings = apply_remote_confirm(profile_id, pending, decisions)
except DetectError as exc:
raise EngineError(exc.code, exc.message, getattr(exc, "status_code", 403)) from exc
else:
mappings = apply_review_decisions(profile_id, pending, decisions)
drop_pending(profile_id, review_id)
return generate_draft(
profile_id,
journal_day_id,
conversation_ids=generate.get("conversation_ids"),
include_existing=bool(generate.get("include_existing")),
explicit=True,
generation_selection=generate.get("generation_selection"),
remember_generation_selection=bool(generate.get("remember_generation_selection")),
precomputed_mappings=mappings,
)

View File

@ -1,64 +0,0 @@
"""Operational mail for account links. No dialogue content, no model egress."""
from __future__ import annotations
import logging
import os
import smtplib
from email.message import EmailMessage
from env_loader import env_flag, runtime_env
log = logging.getLogger("kansho.mail")
OUTBOX: list[dict] = []
class MailNotConfigured(RuntimeError):
"""SMTP is required and neither configured nor in the development log path."""
def clear_outbox() -> None:
OUTBOX.clear()
def smtp_configured() -> bool:
return bool((os.environ.get("SMTP_HOST") or "").strip() and (os.environ.get("SMTP_FROM") or "").strip())
def send_mail(recipients: list[str], subject: str, body: str) -> None:
cleaned: list[str] = []
for item in recipients:
address = (item or "").strip().lower()
if address and address not in cleaned:
cleaned.append(address)
if not cleaned:
raise MailNotConfigured("Mailversand ist nicht konfiguriert")
if env_flag("KANSHO_FAKE_MAIL"):
OUTBOX.append({"to": cleaned, "subject": subject, "body": body})
return
host = (os.environ.get("SMTP_HOST") or "").strip()
sender = (os.environ.get("SMTP_FROM") or "").strip()
if not host or not sender:
if runtime_env() == "development":
log.warning("Kansho mail (development, SMTP fehlt): %s", body)
return
raise MailNotConfigured("Mailversand ist nicht konfiguriert")
port = int((os.environ.get("SMTP_PORT") or "587").strip() or "587")
user = (os.environ.get("SMTP_USER") or "").strip()
password = os.environ.get("SMTP_PASSWORD") or ""
message = EmailMessage()
message["Subject"] = subject
message["From"] = sender
message["To"] = ", ".join(cleaned)
message.set_content(body)
if port == 465:
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
if user:
smtp.login(user, password)
smtp.send_message(message)
return
with smtplib.SMTP(host, port, timeout=20) as smtp:
smtp.starttls()
if user:
smtp.login(user, password)
smtp.send_message(message)

View File

@ -1,16 +0,0 @@
-- LLM profiles: named, reusable generate/detect endpoint+model presets.
-- Keys stay in env. Active assignment remains provider_settings.
CREATE TABLE IF NOT EXISTS llm_profiles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
zdr INTEGER NOT NULL DEFAULT 1,
no_train INTEGER NOT NULL DEFAULT 1,
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text,
updated TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text
);
ALTER TABLE provider_settings ADD COLUMN IF NOT EXISTS profile_id TEXT;

View File

@ -1,23 +0,0 @@
-- Transitional learning detect: user-confirmed label senses, pending mask review.
-- Detect output still does not auto-activate identities.
CREATE TABLE IF NOT EXISTS label_senses (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
normalized_label TEXT NOT NULL,
identity_hits INTEGER NOT NULL DEFAULT 0,
non_identity_hits INTEGER NOT NULL DEFAULT 0,
updated TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text,
PRIMARY KEY (profile_id, normalized_label)
);
CREATE TABLE IF NOT EXISTS pending_mask_reviews (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_message_id TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text
);
CREATE INDEX IF NOT EXISTS idx_pending_mask_reviews_conv
ON pending_mask_reviews (profile_id, conversation_id);

View File

@ -1,12 +0,0 @@
-- Remember mask-review decisions per local context cue (previous word).
-- Not a word list: cues come from the user line, e.g. Frau vs. a food mention.
CREATE TABLE IF NOT EXISTS label_sense_cues (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
normalized_label TEXT NOT NULL,
cue TEXT NOT NULL,
decision TEXT NOT NULL,
hits INTEGER NOT NULL DEFAULT 1,
updated TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text,
PRIMARY KEY (profile_id, normalized_label, cue)
);

View File

@ -1,2 +0,0 @@
-- Mini-passages beside each cue so local detect/GLiNER can pre-decide from context.
ALTER TABLE label_sense_cues ADD COLUMN IF NOT EXISTS excerpt TEXT NOT NULL DEFAULT '';

View File

@ -1,49 +0,0 @@
-- Account identifiers, verified addresses, and one-time mail tokens.
-- Existing primary addresses are grandfathered as verified once.
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS username TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS secondary_email TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verified_at TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS secondary_email_verified_at TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS pending_email TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS pending_secondary_email TEXT;
UPDATE profiles
SET email_verified_at = COALESCE(NULLIF(created, ''), CURRENT_TIMESTAMP::text)
WHERE email_verified_at IS NULL OR email_verified_at = '';
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text
);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
purpose TEXT NOT NULL CHECK (purpose IN ('primary', 'secondary')),
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text
);
CREATE UNIQUE INDEX IF NOT EXISTS profiles_username_lower_idx
ON profiles (lower(username))
WHERE username IS NOT NULL AND username <> '';
CREATE UNIQUE INDEX IF NOT EXISTS profiles_secondary_email_lower_idx
ON profiles (lower(secondary_email))
WHERE secondary_email IS NOT NULL AND secondary_email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_email_lower_idx
ON profiles (lower(pending_email))
WHERE pending_email IS NOT NULL AND pending_email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_secondary_email_lower_idx
ON profiles (lower(pending_secondary_email))
WHERE pending_secondary_email IS NOT NULL AND pending_secondary_email <> '';

View File

@ -263,77 +263,17 @@ def list_provider_models(config) -> list[dict]:
url = getattr(config, "url", "") or "" url = getattr(config, "url", "") or ""
if not url: if not url:
return [] return []
key = getattr(config, "key", "") or "" catalog_key = models_url(url)
items = _list_openai_models(url, key)
if items:
return items
return _list_ollama_tags(url)
def list_models_for_url(url: str, key: str = "") -> list[dict]:
from providers import ProviderConfig, is_local_url
url = (url or "").strip()
if not url:
return []
local = is_local_url(url)
return list_provider_models(
ProviderConfig(
role="generate",
name="ollama" if local else "openrouter",
mode="http",
url=url,
model="",
key=key,
local=local,
zdr=True,
no_train=True,
)
)
def _list_openai_models(chat_url: str, key: str) -> list[dict]:
catalog_key = models_url(chat_url)
try: try:
rows = _request_models_payload(catalog_key, key) rows = _request_models_payload(catalog_key, getattr(config, "key", "") or "")
except JournalBudgetError: except JournalBudgetError:
return [] return []
return _ids_from_rows(rows, id_keys=("id",))
def _list_ollama_tags(chat_url: str) -> list[dict]:
parsed = urlparse(chat_url or "")
if not parsed.scheme or not parsed.netloc:
return []
tags_url = f"{parsed.scheme}://{parsed.netloc}/api/tags"
headers = {"Content-Type": "application/json"}
try:
response = httpx.get(tags_url, headers=headers, timeout=8.0)
except httpx.HTTPError:
return []
if response.status_code >= 400:
return []
try:
payload = response.json()
except ValueError:
return []
rows = payload.get("models") if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return []
return _ids_from_rows(rows, id_keys=("name", "model", "id"))
def _ids_from_rows(rows: list, *, id_keys: tuple[str, ...]) -> list[dict]:
seen: set[str] = set() seen: set[str] = set()
items: list[dict] = [] items: list[dict] = []
for row in rows: for row in rows:
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
model_id = "" model_id = str(row.get("id") or "").strip()
for key in id_keys:
model_id = str(row.get(key) or "").strip()
if model_id:
break
if not model_id or model_id in seen: if not model_id or model_id in seen:
continue continue
seen.add(model_id) seen.add(model_id)

View File

@ -98,12 +98,6 @@ COMPACT_DIAGNOSTIC_KEYS = (
"invalid_entity_type", "invalid_entity_type",
"omitted_non_identity_types", "omitted_non_identity_types",
"detect_cost_unknown", "detect_cost_unknown",
"diff_agree",
"diff_only_student",
"diff_only_teacher",
"diff_type_conflict",
"diff_new_identity",
"diff_cue_reinforced",
"generation_selection", "generation_selection",
"style_application", "style_application",
) )
@ -1061,53 +1055,7 @@ def complete(request: GatewayRequest) -> GatewayResult:
detect_note = None detect_note = None
detect_stats: dict[str, Any] = {} detect_stats: dict[str, Any] = {}
local_identities: list[dict[str, Any]] = [] local_identities: list[dict[str, Any]] = []
precomputed = request.payload.get("precomputed_mappings") if request.payload else None
try: try:
if precomputed is not None:
mappings = list(precomputed)
extra = dict((request.payload or {}).get("detect_counters") or {})
detect_stats = {
"detect_note": "precomputed_learning_review",
"full_detection_coverage": True,
"semantic_identity_guaranteed": False,
**{key: extra[key] for key in extra if key.startswith("diff_")},
}
local_identities = [
{
"local_label": item.get("local_label"),
"token": item.get("token"),
"entity_type": item.get("entity_type"),
"source": item.get("source"),
}
for item in mappings
]
detect_name = "learning_review"
else:
from detect_learning import is_bypass_mode
from detect_pipeline import compact_diff_counters, prepare_detect_pipeline
user_body = str((request.payload or {}).get("user_body") or "").strip()
if request.profile_id and not is_bypass_mode() and user_body:
pipe = prepare_detect_pipeline(request.profile_id, user_body, rendered)
mappings = list(pipe.mappings or [])
detect_stats = {
"detect_note": "student_path" if not pipe.need_review else "student_path_unreviewed",
"full_detection_coverage": True,
"semantic_identity_guaranteed": False,
**compact_diff_counters(pipe.counters),
}
detect_name = "student_path"
detect_note = detect_stats["detect_note"]
local_identities = [
{
"local_label": item.get("local_label"),
"token": item.get("token"),
"entity_type": item.get("entity_type"),
"source": item.get("source"),
}
for item in mappings
]
else:
outcome = detect_personal_egress(request.profile_id, rendered) outcome = detect_personal_egress(request.profile_id, rendered)
mappings = outcome.mappings mappings = outcome.mappings
detect_stats = outcome.stats.public() detect_stats = outcome.stats.public()

View File

@ -3,12 +3,10 @@ from __future__ import annotations
import json import json
import os import os
import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from db import get_db from db import get_db
from detect_learning import get_detect_operating_mode
from env_loader import ( from env_loader import (
allows_remote_plaintext_detect, allows_remote_plaintext_detect,
remote_plaintext_detect_reason, remote_plaintext_detect_reason,
@ -18,8 +16,6 @@ from env_loader import (
SEED_PATH = Path(__file__).resolve().parent / "config" / "providers.seed.json" SEED_PATH = Path(__file__).resolve().parent / "config" / "providers.seed.json"
ROLES = ("generate", "detect") ROLES = ("generate", "detect")
OLLAMA_LAN_ID = "llm-ollama-lan"
OLLAMA_LAN_URL = "http://192.168.2.144:11434/v1/chat/completions"
_KEY_ENV = { _KEY_ENV = {
"generate": "KANSHO_PROVIDER_KEY", "generate": "KANSHO_PROVIDER_KEY",
"detect": "KANSHO_DETECT_PROVIDER_KEY", "detect": "KANSHO_DETECT_PROVIDER_KEY",
@ -32,7 +28,7 @@ ROLE_META = {
}, },
"detect": { "detect": {
"title": "Maskierung", "title": "Maskierung",
"task": "Eigener Detect-Endpunkt und eigenes Modell. Vollständige semantische Detection des persönlichen Egress. Ziel: lokales Detect-Modell (Ollama). Übergang: externes Klartext-Detect nur mit Operator-Freigabe. Kein Pattern-Fallback.", "task": "Eigener Detect-Endpunkt und eigenes Modell. Vollständige semantische Detection des persönlichen Egress. Ziel: lokales Detect-Modell. Übergang: externes Klartext-Detect nur mit Operator-Freigabe. Kein Pattern-Fallback.",
}, },
} }
@ -45,38 +41,6 @@ class SettingsError(Exception):
self.status_code = status_code self.status_code = status_code
def _validate_url(url: str) -> str:
url = (url or "").strip()
if not url:
return ""
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise SettingsError("provider_url_invalid", "URL muss http(s) sein.")
return url
def _profile_row(conn, profile_id: str) -> dict | None:
row = conn.execute("SELECT * FROM llm_profiles WHERE id = ?", (profile_id,)).fetchone()
return dict(row) if row else None
def _public_profile(row: dict) -> dict:
from providers import is_local_url
url = row.get("url") or ""
return {
"id": row.get("id") or "",
"title": row.get("title") or "",
"name": row.get("name") or "",
"url": url,
"model": row.get("model") or "",
"zdr": bool(row.get("zdr", 1)),
"no_train": bool(row.get("no_train", 1)),
"local": is_local_url(url),
"updated": row.get("updated") or "",
}
def seed_provider_settings(conn) -> None: def seed_provider_settings(conn) -> None:
items = json.loads(SEED_PATH.read_text(encoding="utf-8")) items = json.loads(SEED_PATH.read_text(encoding="utf-8"))
for item in items: for item in items:
@ -100,237 +64,6 @@ def seed_provider_settings(conn) -> None:
1 if item.get("no_train", True) else 0, 1 if item.get("no_train", True) else 0,
), ),
) )
seed_llm_profiles(conn)
def seed_llm_profiles(conn) -> None:
"""Named presets. Existing rows are not overwritten. Keys never stored."""
ollama = conn.execute("SELECT * FROM llm_profiles WHERE id = ?", (OLLAMA_LAN_ID,)).fetchone()
if not ollama:
conn.execute(
"""
INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train)
VALUES (?, ?, ?, ?, ?, 1, 1)
""",
(OLLAMA_LAN_ID, "Ollama (LAN)", "ollama", OLLAMA_LAN_URL, "phi3:mini"),
)
elif not (dict(ollama).get("model") or "").strip():
conn.execute(
"UPDATE llm_profiles SET model = ?, updated = datetime('now') WHERE id = ?",
("phi3:mini", OLLAMA_LAN_ID),
)
for role in ROLES:
row = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone()
if not row:
continue
data = dict(row)
profile_id = (data.get("profile_id") or "").strip()
if profile_id and _profile_row(conn, profile_id):
continue
url = (data.get("url") or "").strip()
model = (data.get("model") or "").strip()
if not url:
continue
match = conn.execute(
"SELECT id FROM llm_profiles WHERE url = ? AND model = ? ORDER BY created LIMIT 1",
(url, model),
).fetchone()
if match:
chosen = match["id"]
else:
chosen = f"llm-{role}-current"
if _profile_row(conn, chosen):
chosen = str(uuid.uuid4())
title = "Sprachmodell (aktuell)" if role == "generate" else "Maskierung (aktuell)"
conn.execute(
"""
INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
chosen,
title,
data.get("name") or "",
url,
model,
1 if data.get("zdr", 1) else 0,
1 if data.get("no_train", 1) else 0,
),
)
conn.execute(
"UPDATE provider_settings SET profile_id = ? WHERE role = ?",
(chosen, role),
)
def list_profiles() -> list[dict]:
with get_db() as conn:
rows = conn.execute("SELECT * FROM llm_profiles ORDER BY title, created").fetchall()
return [_public_profile(dict(row)) for row in rows]
def get_profile(profile_id: str) -> dict | None:
with get_db() as conn:
row = _profile_row(conn, profile_id)
return _public_profile(row) if row else None
def _write_profile(
conn,
profile_id: str,
*,
title: str,
name: str,
url: str,
model: str,
zdr: bool,
no_train: bool,
) -> dict:
title = (title or "").strip()
if not title:
raise SettingsError("profile_title_required", "Ein Profil braucht einen Namen.")
url = _validate_url(url)
if not url:
raise SettingsError("provider_url_required", "Ein Profil braucht eine URL.")
model = (model or "").strip()
name = (name or "").strip()
existing = _profile_row(conn, profile_id)
if existing:
conn.execute(
"""
UPDATE llm_profiles
SET title = ?, name = ?, url = ?, model = ?, zdr = ?, no_train = ?, updated = datetime('now')
WHERE id = ?
""",
(title, name, url, model, 1 if zdr else 0, 1 if no_train else 0, profile_id),
)
else:
conn.execute(
"""
INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(profile_id, title, name, url, model, 1 if zdr else 0, 1 if no_train else 0),
)
for role in ROLES:
active = conn.execute(
"SELECT role FROM provider_settings WHERE role = ? AND profile_id = ?",
(role, profile_id),
).fetchone()
if not active:
continue
conn.execute(
"""
UPDATE provider_settings
SET name = ?, url = ?, model = ?, zdr = ?, no_train = ?, updated = datetime('now')
WHERE role = ?
""",
(name, url, model, 1 if zdr else 0, 1 if no_train else 0, role),
)
row = _profile_row(conn, profile_id)
return _public_profile(row or {})
def create_profile(*, title: str, name: str, url: str, model: str, zdr: bool, no_train: bool) -> dict:
profile_id = str(uuid.uuid4())
with get_db() as conn:
return _write_profile(
conn,
profile_id,
title=title,
name=name,
url=url,
model=model,
zdr=zdr,
no_train=no_train,
)
def update_profile(
profile_id: str,
*,
title: str,
name: str,
url: str,
model: str,
zdr: bool,
no_train: bool,
) -> dict:
with get_db() as conn:
if not _profile_row(conn, profile_id):
raise SettingsError("profile_missing", "Profil fehlt.", 404)
return _write_profile(
conn,
profile_id,
title=title,
name=name,
url=url,
model=model,
zdr=zdr,
no_train=no_train,
)
def delete_profile(profile_id: str) -> None:
with get_db() as conn:
if not _profile_row(conn, profile_id):
raise SettingsError("profile_missing", "Profil fehlt.", 404)
used = conn.execute(
"SELECT role FROM provider_settings WHERE profile_id = ?",
(profile_id,),
).fetchall()
if used:
roles = ", ".join(row["role"] for row in used)
raise SettingsError(
"profile_in_use",
f"Profil ist noch der Stufe {roles} zugeordnet. Zuerst ein anderes Profil wählen.",
)
from detect_learning import get_local_ner_settings, set_local_ner_settings
if get_local_ner_settings().get("passage_profile_id") == profile_id:
set_local_ner_settings(passage_profile_id="")
conn.execute("DELETE FROM llm_profiles WHERE id = ?", (profile_id,))
def activate_profile(role: str, profile_id: str) -> dict:
if role not in ROLES:
raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.")
with get_db() as conn:
row = _profile_row(conn, profile_id)
if not row:
raise SettingsError("profile_missing", "Profil fehlt.", 404)
url = (row.get("url") or "").strip()
model = (row.get("model") or "").strip()
if role == "generate" and not url:
raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.")
if role == "detect" and not url:
raise SettingsError("provider_url_required", "Die Maskierung braucht eine URL.")
if not model:
raise SettingsError("provider_model_required", "Das Profil braucht ein Modell.")
conn.execute(
"""
INSERT INTO provider_settings (role, name, url, model, zdr, no_train, profile_id, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(role) DO UPDATE SET
name = excluded.name,
url = excluded.url,
model = excluded.model,
zdr = excluded.zdr,
no_train = excluded.no_train,
profile_id = excluded.profile_id,
updated = datetime('now')
""",
(
role,
row.get("name") or "",
url,
model,
1 if row.get("zdr", 1) else 0,
1 if row.get("no_train", 1) else 0,
profile_id,
),
)
return get_setting(role) or {}
def get_setting(role: str) -> dict | None: def get_setting(role: str) -> dict | None:
@ -341,71 +74,34 @@ def get_setting(role: str) -> dict | None:
return dict(row) if row else None return dict(row) if row else None
def upsert_setting( def upsert_setting(role: str, *, name: str, url: str, model: str, zdr: bool, no_train: bool) -> dict:
role: str,
*,
name: str,
url: str,
model: str,
zdr: bool,
no_train: bool,
profile_id: str | None = None,
save_as_title: str | None = None,
) -> dict:
if role not in ROLES: if role not in ROLES:
raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.") raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.")
url = _validate_url(url) url = (url or "").strip()
model = (model or "").strip() model = (model or "").strip()
name = (name or "").strip() name = (name or "").strip()
if role == "generate" and not url: if role == "generate" and not url:
raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.") raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.")
if url:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise SettingsError("provider_url_invalid", "URL muss http(s) sein.")
if role == "generate" and not model: if role == "generate" and not model:
raise SettingsError("provider_model_required", "Das Sprachmodell braucht ein Modell.") raise SettingsError("provider_model_required", "Das Sprachmodell braucht ein Modell.")
title = (save_as_title or "").strip()
with get_db() as conn: with get_db() as conn:
current = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone()
current_id = (dict(current).get("profile_id") if current else "") or ""
chosen = (profile_id or "").strip() or current_id
if title:
chosen = str(uuid.uuid4())
_write_profile(
conn,
chosen,
title=title,
name=name,
url=url,
model=model,
zdr=zdr,
no_train=no_train,
)
elif chosen:
existing = _profile_row(conn, chosen)
if not existing:
raise SettingsError("profile_missing", "Profil fehlt.", 404)
_write_profile(
conn,
chosen,
title=existing.get("title") or title or role,
name=name,
url=url,
model=model,
zdr=zdr,
no_train=no_train,
)
conn.execute( conn.execute(
""" """
INSERT INTO provider_settings (role, name, url, model, zdr, no_train, profile_id, updated) INSERT INTO provider_settings (role, name, url, model, zdr, no_train, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(role) DO UPDATE SET ON CONFLICT(role) DO UPDATE SET
name = excluded.name, name = excluded.name,
url = excluded.url, url = excluded.url,
model = excluded.model, model = excluded.model,
zdr = excluded.zdr, zdr = excluded.zdr,
no_train = excluded.no_train, no_train = excluded.no_train,
profile_id = excluded.profile_id,
updated = datetime('now') updated = datetime('now')
""", """,
(role, name, url, model, 1 if zdr else 0, 1 if no_train else 0, chosen or None), (role, name, url, model, 1 if zdr else 0, 1 if no_train else 0),
) )
return get_setting(role) or {} return get_setting(role) or {}
@ -420,11 +116,11 @@ def set_role_key(role: str, key: str) -> None:
def public_role_status(role: str) -> dict: def public_role_status(role: str) -> dict:
from providers import detect_provider, generate_provider, is_local_url from providers import _is_local_url, detect_provider, generate_provider
row = get_setting(role) or {} row = get_setting(role) or {}
config = generate_provider() if role == "generate" else detect_provider() config = generate_provider() if role == "generate" else detect_provider()
local = is_local_url(row.get("url") or "") local = _is_local_url(row.get("url") or "")
own_key = bool((os.environ.get(_KEY_ENV[role]) or "").strip()) own_key = bool((os.environ.get(_KEY_ENV[role]) or "").strip())
generate_key = bool((os.environ.get(_KEY_ENV["generate"]) or "").strip()) generate_key = bool((os.environ.get(_KEY_ENV["generate"]) or "").strip())
if own_key: if own_key:
@ -443,7 +139,6 @@ def public_role_status(role: str) -> dict:
"model": row.get("model") or "", "model": row.get("model") or "",
"zdr": bool(row.get("zdr", 1)), "zdr": bool(row.get("zdr", 1)),
"no_train": bool(row.get("no_train", 1)), "no_train": bool(row.get("no_train", 1)),
"profile_id": row.get("profile_id") or "",
"local": local, "local": local,
"key_present": key_source != "none", "key_present": key_source != "none",
"key_source": key_source, "key_source": key_source,
@ -459,18 +154,8 @@ def public_role_status(role: str) -> dict:
def public_status() -> dict: def public_status() -> dict:
return { return {
"roles": [public_role_status(role) for role in ROLES], "roles": [public_role_status(role) for role in ROLES],
"profiles": list_profiles(),
"runtime_env": runtime_env(), "runtime_env": runtime_env(),
"note": "LLM-Profile speichern URL und Modell je Endpunkt. Maskierung und Sprachmodell wählen je ein Profil. Semantische Detection ist Pflicht vor Generate. LAN-Ollama gilt als lokal. Keys nur in .env.", "note": "Zwei Stufen, zwei Modelle: Maskierung und Sprachmodell. Semantische Detection ist Pflicht vor Generate. Ziel bleibt lokales Detect. Externes Klartext-Detect in Production nur mit KANSHO_ALLOW_REMOTE_DETECT. Keys nur in .env.",
"remote_plaintext_reason": remote_plaintext_detect_reason(), "remote_plaintext_reason": remote_plaintext_detect_reason(),
"remote_plaintext_allowed": allows_remote_plaintext_detect(), "remote_plaintext_allowed": allows_remote_plaintext_detect(),
"ollama_url": OLLAMA_LAN_URL,
"detect_operating_mode": get_detect_operating_mode(),
"local_ner": _local_ner_status(),
} }
def _local_ner_status() -> dict:
from detect_learning import get_local_ner_settings
return get_local_ner_settings()

View File

@ -1,7 +1,6 @@
"""Two independently configured OpenAI-compatible providers: generate vs detect.""" """Two independently configured OpenAI-compatible providers: generate vs detect."""
from __future__ import annotations from __future__ import annotations
import ipaddress
import json import json
import os import os
import re import re
@ -108,27 +107,9 @@ def _truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def is_local_url(url: str) -> bool:
"""Loopback, RFC1918/ULA and .local stay in the local trusted zone.
Homelab Ollama (e.g. 192.168.2.144) is local, not an external provider.
"""
host = (urlparse(url or "").hostname or "").lower()
if not host:
return False
if host in {"localhost", "127.0.0.1", "::1", "ollama"}:
return True
if host.endswith(".local"):
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return bool(ip.is_loopback or ip.is_private or ip.is_link_local)
def _is_local_url(url: str) -> bool: def _is_local_url(url: str) -> bool:
return is_local_url(url) host = (urlparse(url).hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1", "ollama"}
def _setting(role: str) -> dict: def _setting(role: str) -> dict:
@ -144,9 +125,9 @@ def _text(env_name: str, db_value: str | None, default: str = "") -> str:
env = (os.environ.get(env_name) or "").strip() env = (os.environ.get(env_name) or "").strip()
if env: if env:
return env return env
if db_value is None: if db_value:
return default
return str(db_value).strip() return str(db_value).strip()
return default
def _flag(env_name: str, db_value, default: bool = True) -> bool: def _flag(env_name: str, db_value, default: bool = True) -> bool:
@ -174,11 +155,9 @@ def generate_provider() -> ProviderConfig | None:
key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip() key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip()
url = _text("KANSHO_PROVIDER_URL", row.get("url"), "https://openrouter.ai/api/v1/chat/completions") url = _text("KANSHO_PROVIDER_URL", row.get("url"), "https://openrouter.ai/api/v1/chat/completions")
model = _text("KANSHO_PROVIDER_MODEL", row.get("model"), "openai/gpt-4o-mini") model = _text("KANSHO_PROVIDER_MODEL", row.get("model"), "openai/gpt-4o-mini")
local = is_local_url(url) local = _is_local_url(url)
zdr = local or _flag("KANSHO_PROVIDER_ZDR", row.get("zdr"), True) zdr = local or _flag("KANSHO_PROVIDER_ZDR", row.get("zdr"), True)
no_train = local or _flag("KANSHO_PROVIDER_NO_TRAIN", row.get("no_train"), True) no_train = local or _flag("KANSHO_PROVIDER_NO_TRAIN", row.get("no_train"), True)
if not url or not model:
return None
if not local and not key: if not local and not key:
return None return None
if not local and (not zdr or not no_train): if not local and (not zdr or not no_train):
@ -214,10 +193,8 @@ def detect_provider() -> ProviderConfig | None:
if not url: if not url:
return None return None
model = _text("KANSHO_DETECT_PROVIDER_MODEL", row.get("model"), "openai/gpt-4.1-nano") model = _text("KANSHO_DETECT_PROVIDER_MODEL", row.get("model"), "openai/gpt-4.1-nano")
if not model:
return None
key = (os.environ.get("KANSHO_DETECT_PROVIDER_KEY") or "").strip() key = (os.environ.get("KANSHO_DETECT_PROVIDER_KEY") or "").strip()
local = is_local_url(url) local = _is_local_url(url)
if not key and not local: if not key and not local:
key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip() key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip()
zdr = local or _flag("KANSHO_DETECT_ZDR", row.get("zdr"), True) zdr = local or _flag("KANSHO_DETECT_ZDR", row.get("zdr"), True)

View File

@ -2,7 +2,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import json import json
import os
from auth import require_admin_dep from auth import require_admin_dep
from db import get_db from db import get_db
@ -20,7 +19,6 @@ from debug_store import (
settings_payload, settings_payload,
) )
from dialogue_store import StoreError, get_conversation, inventory, list_conversations, list_derived_for_conversation, list_messages from dialogue_store import StoreError, get_conversation, inventory, list_conversations, list_derived_for_conversation, list_messages
from detect_learning import list_senses
from identity_store import ( from identity_store import (
confirm_identity, confirm_identity,
confirm_review_proposal, confirm_review_proposal,
@ -32,18 +30,7 @@ from identity_store import (
list_review_proposals, list_review_proposals,
update_identity, update_identity,
) )
from provider_settings import ( from provider_settings import ROLES, SettingsError, public_status, set_role_key, upsert_setting
ROLES,
SettingsError,
activate_profile,
create_profile,
delete_profile,
list_profiles,
public_status,
set_role_key,
update_profile,
upsert_setting,
)
from version import APP_VERSION, BUILD_DATE, MODULE_VERSIONS from version import APP_VERSION, BUILD_DATE, MODULE_VERSIONS
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@ -209,102 +196,19 @@ class ProviderUpdate(BaseModel):
model: str = "" model: str = ""
zdr: bool = True zdr: bool = True
no_train: bool = True no_train: bool = True
profile_id: str | None = None
save_as_title: str | None = None
key: str | None = Field(default=None, description="Write-only. Never returned.") key: str | None = Field(default=None, description="Write-only. Never returned.")
class LlmProfileWrite(BaseModel):
title: str
name: str = ""
url: str
model: str = ""
zdr: bool = True
no_train: bool = True
class ActivateProfile(BaseModel):
profile_id: str
class DetectModeWrite(BaseModel):
mode: str
class LocalNerWrite(BaseModel):
enabled: bool | None = None
url: str | None = None
passage_profile_id: str | None = None
@router.put("/providers/detect-mode")
def admin_set_detect_mode(body: DetectModeWrite, session: dict = Depends(require_admin_dep)):
from detect_learning import set_detect_operating_mode
from provider_settings import public_status as provider_public_status
try:
set_detect_operating_mode(body.mode)
except ValueError:
raise HTTPException(
status_code=400,
detail={
"code": "invalid_detect_operating_mode",
"message": "Detect-Modus muss student, learning_full, learning_reduced, learning_manual oder bypass sein.",
},
)
return provider_public_status()
@router.put("/providers/local-ner")
def admin_set_local_ner(body: LocalNerWrite, session: dict = Depends(require_admin_dep)):
from detect_learning import set_local_ner_settings
from provider_settings import public_status as provider_public_status
try:
set_local_ner_settings(
enabled=body.enabled,
url=body.url,
passage_profile_id=body.passage_profile_id,
)
except ValueError as exc:
code = str(exc)
if code == "gliner_url_not_local":
raise HTTPException(
status_code=400,
detail={
"code": code,
"message": "GLiNER-URL muss in der lokalen Trusted Zone liegen.",
},
) from exc
if code == "passage_profile_missing":
raise HTTPException(
status_code=400,
detail={"code": code, "message": "Das Klärungs-Profil fehlt."},
) from exc
raise HTTPException(
status_code=400,
detail={"code": "invalid_local_ner", "message": "Lokale NER-Einstellung ungültig."},
) from exc
return provider_public_status()
@router.get("/providers") @router.get("/providers")
def admin_providers(session: dict = Depends(require_admin_dep)): def admin_providers(session: dict = Depends(require_admin_dep)):
return public_status() return public_status()
@router.get("/providers/models") @router.get("/providers/models")
def admin_provider_models(url: str | None = None, session: dict = Depends(require_admin_dep)): def admin_provider_models(session: dict = Depends(require_admin_dep)):
from model_catalog import list_models_for_url, list_provider_models from model_catalog import list_provider_models
from providers import detect_provider, generate_provider from providers import detect_provider, generate_provider
if url:
key = ""
from providers import is_local_url
if not is_local_url(url):
key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip()
return {"url": url, "models": list_models_for_url(url, key)}
return { return {
"roles": { "roles": {
"generate": list_provider_models(generate_provider()), "generate": list_provider_models(generate_provider()),
@ -325,8 +229,6 @@ def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depen
model=body.model, model=body.model,
zdr=body.zdr, zdr=body.zdr,
no_train=body.no_train, no_train=body.no_train,
profile_id=body.profile_id,
save_as_title=body.save_as_title,
) )
if body.key is not None and body.key.strip(): if body.key is not None and body.key.strip():
set_role_key(role, body.key) set_role_key(role, body.key)
@ -335,64 +237,6 @@ def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depen
return public_status() return public_status()
@router.post("/providers/{role}/activate")
def admin_activate_provider(role: str, body: ActivateProfile, session: dict = Depends(require_admin_dep)):
if role not in ROLES:
raise HTTPException(status_code=404, detail={"code": "unknown_provider_role", "message": "Unbekannte Provider-Rolle."})
try:
activate_profile(role, body.profile_id)
except SettingsError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
return public_status()
@router.get("/llm-profiles")
def admin_list_profiles(session: dict = Depends(require_admin_dep)):
return {"profiles": list_profiles()}
@router.post("/llm-profiles")
def admin_create_profile(body: LlmProfileWrite, session: dict = Depends(require_admin_dep)):
try:
profile = create_profile(
title=body.title,
name=body.name,
url=body.url,
model=body.model,
zdr=body.zdr,
no_train=body.no_train,
)
except SettingsError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
return {"profile": profile, **public_status()}
@router.put("/llm-profiles/{profile_id}")
def admin_update_profile(profile_id: str, body: LlmProfileWrite, session: dict = Depends(require_admin_dep)):
try:
profile = update_profile(
profile_id,
title=body.title,
name=body.name,
url=body.url,
model=body.model,
zdr=body.zdr,
no_train=body.no_train,
)
except SettingsError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
return {"profile": profile, **public_status()}
@router.delete("/llm-profiles/{profile_id}")
def admin_delete_profile(profile_id: str, session: dict = Depends(require_admin_dep)):
try:
delete_profile(profile_id)
except SettingsError as exc:
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc
return public_status()
class IdentityWrite(BaseModel): class IdentityWrite(BaseModel):
canonical_label: str = "" canonical_label: str = ""
entity_type: str = "PERSON" entity_type: str = "PERSON"
@ -426,7 +270,6 @@ def admin_identities(session: dict = Depends(require_admin_dep)):
return { return {
"registry": list_registry(profile_id), "registry": list_registry(profile_id),
"proposals": list_review_proposals(profile_id, include_dismissed=True), "proposals": list_review_proposals(profile_id, include_dismissed=True),
"senses": list_senses(profile_id),
"note": "Nur lokale bestätigte Registry und unbestätigte Vorschläge. Kein externer Egress.", "note": "Nur lokale bestätigte Registry und unbestätigte Vorschläge. Kein externer Egress.",
"backup": ( "backup": (
"Vor einer Bereinigung die lokale Datei backend/data/kansho.sqlite kopieren. " "Vor einer Bereinigung die lokale Datei backend/data/kansho.sqlite kopieren. "

View File

@ -5,25 +5,8 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from account_access import (
FORGOT_MESSAGE,
consume_password_reset,
consume_verification,
deliver_jobs,
find_by_login,
issue_password_reset,
issue_verification,
load_profile,
normalize_email,
profile_view,
resend_verification,
reset_message,
verified_reset_recipients,
verify_message,
)
from auth import create_session, hash_password, require_auth, verify_password from auth import create_session, hash_password, require_auth, verify_password
from db import get_db from db import get_db, row_to_dict
from mailer import MailNotConfigured
from version import APP_VERSION from version import APP_VERSION
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -40,23 +23,6 @@ class SetupRequest(BaseModel):
password: str = Field(min_length=4) password: str = Field(min_length=4)
class ForgotRequest(BaseModel):
email: str = Field(min_length=1)
class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=10)
password: str = Field(min_length=4)
class VerifyEmailRequest(BaseModel):
token: str = Field(min_length=10)
class ResendVerificationRequest(BaseModel):
purpose: str
def _profile_count() -> int: def _profile_count() -> int:
with get_db() as conn: with get_db() as conn:
row = conn.execute("SELECT COUNT(*) AS n FROM profiles").fetchone() row = conn.execute("SELECT COUNT(*) AS n FROM profiles").fetchone()
@ -77,22 +43,15 @@ def auth_status():
def setup(req: SetupRequest): def setup(req: SetupRequest):
if _profile_count() > 0: if _profile_count() > 0:
raise HTTPException(409, "Setup ist bereits abgeschlossen") raise HTTPException(409, "Setup ist bereits abgeschlossen")
email = normalize_email(req.email)
profile_id = str(uuid.uuid4()) profile_id = str(uuid.uuid4())
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn: with get_db() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO profiles (id, email, name, password_hash, role) INSERT INTO profiles (id, email, name, password_hash, role)
VALUES (?, ?, ?, ?, 'admin') VALUES (?, ?, ?, ?, 'admin')
""", """,
(profile_id, email, req.name.strip(), hash_password(req.password)), (profile_id, req.email.lower().strip(), req.name.strip(), hash_password(req.password)),
) )
raw = issue_verification(conn, profile_id, "primary", email)
if raw:
subject, body = verify_message(raw)
jobs.append(([email], subject, body))
_send_or_log_failure(jobs)
token, expires = create_session(profile_id) token, expires = create_session(profile_id)
return { return {
"token": token, "token": token,
@ -103,20 +62,15 @@ def setup(req: SetupRequest):
} }
def _send_or_log_failure(jobs: list[tuple[list[str], str, str]]) -> bool:
if not jobs:
return True
try:
deliver_jobs(jobs)
except MailNotConfigured:
return False
return True
@router.post("/login") @router.post("/login")
def login(req: LoginRequest): def login(req: LoginRequest):
with get_db() as conn: with get_db() as conn:
prof = find_by_login(conn, req.email) prof = row_to_dict(
conn.execute(
"SELECT * FROM profiles WHERE email = ?",
(req.email.lower().strip(),),
).fetchone()
)
if not prof or not verify_password(req.password, prof["password_hash"]): if not prof or not verify_password(req.password, prof["password_hash"]):
raise HTTPException(401, "Ungültige Zugangsdaten") raise HTTPException(401, "Ungültige Zugangsdaten")
if (prof.get("status") or "active") != "active": if (prof.get("status") or "active") != "active":
@ -141,49 +95,9 @@ def logout(session: dict = Depends(require_auth)):
@router.get("/me") @router.get("/me")
def me(session: dict = Depends(require_auth)): def me(session: dict = Depends(require_auth)):
with get_db() as conn: with get_db() as conn:
prof = profile_view(load_profile(conn, session["profile_id"])) prof = row_to_dict(
conn.execute("SELECT id, email, name, role, status, tier_id FROM profiles WHERE id = ?", (session["profile_id"],)).fetchone()
)
if not prof: if not prof:
raise HTTPException(401, "Profil nicht gefunden") raise HTTPException(401, "Profil nicht gefunden")
return prof return prof
@router.post("/forgot-password")
def forgot_password(req: ForgotRequest):
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
prof = find_by_login(conn, req.email)
if prof and (prof.get("status") or "active") == "active":
recipients = verified_reset_recipients(prof)
if recipients:
raw = issue_password_reset(conn, prof["id"])
if raw:
subject, body = reset_message(raw)
jobs.append((recipients, subject, body))
_send_or_log_failure(jobs)
return {"ok": True, "message": FORGOT_MESSAGE}
@router.post("/reset-password")
def reset_password(req: ResetPasswordRequest):
with get_db() as conn:
consume_password_reset(conn, req.token, req.password)
return {"ok": True}
@router.post("/verify-email")
def verify_email(req: VerifyEmailRequest):
with get_db() as conn:
return consume_verification(conn, req.token)
@router.post("/resend-verification")
def resend(req: ResendVerificationRequest, session: dict = Depends(require_auth)):
with get_db() as conn:
profile = load_profile(conn, session["profile_id"])
if not profile:
raise HTTPException(401, "Profil nicht gefunden")
recipients, subject, body = resend_verification(conn, profile, req.purpose)
jobs = [(recipients, subject, body)]
if not _send_or_log_failure(jobs):
raise HTTPException(503, "Mailversand ist nicht konfiguriert")
return {"ok": True}

View File

@ -7,8 +7,7 @@ from auth import require_auth
from context_builder import build_internal_context from context_builder import build_internal_context
from continuity import checkpoint_usage_session, close_usage_session from continuity import checkpoint_usage_session, close_usage_session
from derived_kinds import catalog from derived_kinds import catalog
from detect_learning import pending_for_conversation from dialogue_turn import run_turn, visible_for_role
from dialogue_turn import continue_turn, run_turn, visible_for_role
from engine import EngineError from engine import EngineError
from privacy_gateway import public_error_detail from privacy_gateway import public_error_detail
from dialogue_store import ( from dialogue_store import (
@ -52,11 +51,6 @@ class MessageWrite(BaseModel):
id: str | None = None id: str | None = None
class MaskReviewWrite(BaseModel):
review_id: str
decisions: list[dict] = Field(default_factory=list)
class ThreadWrite(BaseModel): class ThreadWrite(BaseModel):
title: str = "" title: str = ""
status: str = "open" status: str = "open"
@ -146,9 +140,6 @@ def get_one_conversation(conversation_id: str, session: dict = Depends(require_a
conv = get_conversation(session["profile_id"], conversation_id) conv = get_conversation(session["profile_id"], conversation_id)
conv["messages"] = list_messages(session["profile_id"], conversation_id) conv["messages"] = list_messages(session["profile_id"], conversation_id)
conv["derived"] = list_derived_for_conversation(session["profile_id"], conversation_id) conv["derived"] = list_derived_for_conversation(session["profile_id"], conversation_id)
pending = pending_for_conversation(session["profile_id"], conversation_id)
if pending:
conv["pending_mask_review"] = pending
return conv return conv
except StoreError as exc: except StoreError as exc:
_http(exc) _http(exc)
@ -181,17 +172,6 @@ def conversation_turn(conversation_id: str, req: MessageWrite, session: dict = D
_http(exc) _http(exc)
@router.post("/conversations/{conversation_id}/turn/review")
def conversation_turn_review(conversation_id: str, req: MaskReviewWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
continue_turn(session["profile_id"], conversation_id, req.review_id, req.decisions),
session.get("role"),
)
except (StoreError, EngineError) as exc:
_http(exc)
@router.get("/conversations/{conversation_id}/messages") @router.get("/conversations/{conversation_id}/messages")
def get_messages(conversation_id: str, session: dict = Depends(require_auth)): def get_messages(conversation_id: str, session: dict = Depends(require_auth)):
try: try:

View File

@ -5,12 +5,11 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from auth import require_auth from auth import require_auth
from detect_learning import pending_for_conversation
from dialogue_store import StoreError, delete_conversation, get_conversation, list_messages from dialogue_store import StoreError, delete_conversation, get_conversation, list_messages
from dialogue_turn import continue_turn, run_turn, visible_for_role from dialogue_turn import run_turn, visible_for_role
from privacy_gateway import GatewayRequest, inspect, public_error_detail from privacy_gateway import GatewayRequest, inspect, public_error_detail
from engine import EngineError from engine import EngineError
from journal_generate import continue_journal_generate, generate_draft from journal_generate import generate_draft
from journal_generation_policy import settings_payload from journal_generation_policy import settings_payload
from journal_opening import maybe_open_journal_conversation from journal_opening import maybe_open_journal_conversation
from journal_policy import PolicyError from journal_policy import PolicyError
@ -97,11 +96,6 @@ class TurnWrite(BaseModel):
id: str | None = None id: str | None = None
class MaskReviewWrite(BaseModel):
review_id: str
decisions: list[dict] = Field(default_factory=list)
class GenerateWrite(BaseModel): class GenerateWrite(BaseModel):
conversation_ids: list[str] | None = None conversation_ids: list[str] | None = None
include_existing: bool = False include_existing: bool = False
@ -292,11 +286,7 @@ def remove_conversation(conversation_id: str, session: dict = Depends(require_au
def read_conversation(conversation_id: str, session: dict = Depends(require_auth)): def read_conversation(conversation_id: str, session: dict = Depends(require_auth)):
try: try:
conv = get_conversation(session["profile_id"], conversation_id) conv = get_conversation(session["profile_id"], conversation_id)
payload = {"conversation": conv, "messages": list_messages(session["profile_id"], conversation_id)} return {"conversation": conv, "messages": list_messages(session["profile_id"], conversation_id)}
pending = pending_for_conversation(session["profile_id"], conversation_id)
if pending:
payload["pending_mask_review"] = pending
return payload
except StoreError as exc: except StoreError as exc:
_http(exc) _http(exc)
@ -318,17 +308,6 @@ def conversation_turn(conversation_id: str, body: TurnWrite, session: dict = Dep
_http(exc) _http(exc)
@router.post("/conversations/{conversation_id}/turn/review")
def conversation_turn_review(conversation_id: str, body: MaskReviewWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
continue_turn(session["profile_id"], conversation_id, body.review_id, body.decisions),
session.get("role"),
)
except (StoreError, EngineError) as exc:
_http(exc)
@router.get("/generation-settings") @router.get("/generation-settings")
def read_generation_settings(session: dict = Depends(require_auth)): def read_generation_settings(session: dict = Depends(require_auth)):
return settings_payload(session["profile_id"]) return settings_payload(session["profile_id"])
@ -353,22 +332,6 @@ def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(r
_http(exc) _http(exc)
@router.post("/days/{journal_day_id}/generate/review")
def generate_review(journal_day_id: str, body: MaskReviewWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
continue_journal_generate(
session["profile_id"],
journal_day_id,
body.review_id,
body.decisions,
),
session.get("role"),
)
except (StoreError, PolicyError, EngineError) as exc:
_http(exc)
@router.post("/entries") @router.post("/entries")
def write_entry(body: EntryWrite, session: dict = Depends(require_auth)): def write_entry(body: EntryWrite, session: dict = Depends(require_auth)):
try: try:

View File

@ -5,34 +5,19 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from account_access import (
admin_reset_recipients,
apply_identifier_edits,
assert_address_free,
assert_username_free,
deliver_jobs,
issue_password_reset,
issue_verification,
load_profile,
normalize_email,
normalize_username,
profile_view,
reset_message,
verify_message,
)
from auth import hash_password, require_admin_dep, require_auth from auth import hash_password, require_admin_dep, require_auth
from db import get_db from db import get_db, row_to_dict
from mailer import MailNotConfigured
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
PUBLIC_FIELDS = "id, email, name, role, status, tier_id, created"
class CreateUserRequest(BaseModel): class CreateUserRequest(BaseModel):
email: str email: str
name: str = Field(min_length=1) name: str = Field(min_length=1)
password: str = Field(min_length=4) password: str = Field(min_length=4)
role: str = "user" role: str = "user"
username: str | None = None
class PatchUserRequest(BaseModel): class PatchUserRequest(BaseModel):
@ -40,16 +25,6 @@ class PatchUserRequest(BaseModel):
role: str | None = None role: str | None = None
status: str | None = None status: str | None = None
password: str | None = None password: str | None = None
username: str | None = None
email: str | None = None
secondary_email: str | None = None
class SelfPatchRequest(BaseModel):
username: str | None = None
email: str | None = None
secondary_email: str | None = None
password: str | None = None
def _active_admin_count(conn, exclude_id: str | None = None) -> int: def _active_admin_count(conn, exclude_id: str | None = None) -> int:
@ -65,104 +40,45 @@ def _active_admin_count(conn, exclude_id: str | None = None) -> int:
return int(row["n"]) return int(row["n"])
def _send_jobs(jobs: list[tuple[list[str], str, str]]) -> bool:
if not jobs:
return True
try:
deliver_jobs(jobs)
except MailNotConfigured:
return False
return True
@router.get("") @router.get("")
def list_users(session: dict = Depends(require_admin_dep)): def list_users(session: dict = Depends(require_admin_dep)):
with get_db() as conn: with get_db() as conn:
rows = conn.execute("SELECT * FROM profiles ORDER BY created").fetchall() rows = conn.execute(f"SELECT {PUBLIC_FIELDS} FROM profiles ORDER BY created").fetchall()
return [profile_view(dict(row)) for row in rows] return [row_to_dict(row) for row in rows]
@router.post("") @router.post("")
def create_user(req: CreateUserRequest, session: dict = Depends(require_admin_dep)): def create_user(req: CreateUserRequest, session: dict = Depends(require_admin_dep)):
if req.role not in {"user", "admin"}: if req.role not in {"user", "admin"}:
raise HTTPException(400, "Rolle muss user oder admin sein") raise HTTPException(400, "Rolle muss user oder admin sein")
email = normalize_email(req.email)
username = normalize_username(req.username) if req.username and req.username.strip() else None
profile_id = str(uuid.uuid4()) profile_id = str(uuid.uuid4())
jobs: list[tuple[list[str], str, str]] = []
try: try:
with get_db() as conn: with get_db() as conn:
assert_address_free(conn, email, None)
if username:
assert_username_free(conn, username, None)
conn.execute( conn.execute(
""" """
INSERT INTO profiles (id, email, name, password_hash, role, status, tier_id, username) INSERT INTO profiles (id, email, name, password_hash, role, status, tier_id)
VALUES (?, ?, ?, ?, ?, 'active', 'local', ?) VALUES (?, ?, ?, ?, ?, 'active', 'local')
""", """,
( (
profile_id, profile_id,
email, req.email.lower().strip(),
req.name.strip(), req.name.strip(),
hash_password(req.password), hash_password(req.password),
req.role, req.role,
username,
), ),
) )
raw = issue_verification(conn, profile_id, "primary", email) prof = row_to_dict(conn.execute(f"SELECT {PUBLIC_FIELDS} FROM profiles WHERE id = ?", (profile_id,)).fetchone())
if raw:
subject, body = verify_message(raw)
jobs.append(([email], subject, body))
prof = profile_view(load_profile(conn, profile_id))
except HTTPException:
raise
except Exception as exc: except Exception as exc:
if "UNIQUE" in str(exc).upper(): if "UNIQUE" in str(exc).upper():
raise HTTPException(409, "E-Mail ist bereits vergeben") from exc raise HTTPException(409, "E-Mail ist bereits vergeben") from exc
raise raise
if not _send_jobs(jobs):
prof = dict(prof or {})
prof["mail_sent"] = False
return prof
@router.patch("/me")
def patch_me(req: SelfPatchRequest, session: dict = Depends(require_auth)):
provided = req.model_fields_set
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
current = load_profile(conn, session["profile_id"])
if not current:
raise HTTPException(401, "Profil nicht gefunden")
if req.password:
if len(req.password) < 4:
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
conn.execute(
"UPDATE profiles SET password_hash = ? WHERE id = ?",
(hash_password(req.password), current["id"]),
)
jobs = apply_identifier_edits(
conn,
current,
username=req.username,
email=req.email,
secondary_email=req.secondary_email,
username_set="username" in provided,
email_set="email" in provided,
secondary_set="secondary_email" in provided,
)
prof = profile_view(load_profile(conn, current["id"]))
if not _send_jobs(jobs):
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
return prof return prof
@router.patch("/{profile_id}") @router.patch("/{profile_id}")
def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(require_admin_dep)): def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(require_admin_dep)):
provided = req.model_fields_set
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn: with get_db() as conn:
current = load_profile(conn, profile_id) current = row_to_dict(conn.execute("SELECT * FROM profiles WHERE id = ?", (profile_id,)).fetchone())
if not current: if not current:
raise HTTPException(404, "Nutzer nicht gefunden") raise HTTPException(404, "Nutzer nicht gefunden")
name = req.name.strip() if req.name is not None else current["name"] name = req.name.strip() if req.name is not None else current["name"]
@ -189,49 +105,18 @@ def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(r
) )
if status == "disabled": if status == "disabled":
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile_id,)) conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile_id,))
current = load_profile(conn, profile_id) or current return row_to_dict(conn.execute(f"SELECT {PUBLIC_FIELDS} FROM profiles WHERE id = ?", (profile_id,)).fetchone())
jobs = apply_identifier_edits(
conn,
current,
username=req.username,
email=req.email,
secondary_email=req.secondary_email,
username_set="username" in provided,
email_set="email" in provided,
secondary_set="secondary_email" in provided,
)
prof = profile_view(load_profile(conn, profile_id))
if not _send_jobs(jobs):
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
return prof
@router.post("/{profile_id}/password-reset")
def admin_password_reset(profile_id: str, session: dict = Depends(require_admin_dep)):
if profile_id == session["profile_id"]:
raise HTTPException(400, "Den eigenen Reset startest du über die Anmeldung")
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
profile = load_profile(conn, profile_id)
if not profile:
raise HTTPException(404, "Nutzer nicht gefunden")
recipients = admin_reset_recipients(profile)
if not recipients:
raise HTTPException(400, "Für dieses Konto ist keine E-Mail hinterlegt")
raw = issue_password_reset(conn, profile_id)
if not raw:
raise HTTPException(429, "Zu viele Reset-Links. Bitte später erneut versuchen.")
subject, body = reset_message(raw)
jobs.append((recipients, subject, body))
if not _send_jobs(jobs):
raise HTTPException(503, "Mailversand ist nicht konfiguriert")
return {"ok": True}
@router.get("/me") @router.get("/me")
def my_account(session: dict = Depends(require_auth)): def my_account(session: dict = Depends(require_auth)):
with get_db() as conn: with get_db() as conn:
prof = profile_view(load_profile(conn, session["profile_id"])) prof = row_to_dict(
conn.execute(
f"SELECT {PUBLIC_FIELDS} FROM profiles WHERE id = ?",
(session["profile_id"],),
).fetchone()
)
if not prof: if not prof:
raise HTTPException(401, "Profil nicht gefunden") raise HTTPException(401, "Profil nicht gefunden")
return prof return prof

View File

@ -40,12 +40,6 @@ CREATE TABLE IF NOT EXISTS profiles (
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
tier_id TEXT NOT NULL DEFAULT 'local' REFERENCES tiers(id), tier_id TEXT NOT NULL DEFAULT 'local' REFERENCES tiers(id),
session_days INTEGER NOT NULL DEFAULT 30, session_days INTEGER NOT NULL DEFAULT 30,
username TEXT,
secondary_email TEXT,
email_verified_at TEXT,
secondary_email_verified_at TEXT,
pending_email TEXT,
pending_secondary_email TEXT,
created TEXT NOT NULL DEFAULT (datetime('now')) created TEXT NOT NULL DEFAULT (datetime('now'))
); );
@ -56,26 +50,6 @@ CREATE TABLE IF NOT EXISTS sessions (
created TEXT NOT NULL DEFAULT (datetime('now')) created TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
purpose TEXT NOT NULL CHECK (purpose IN ('primary', 'secondary')),
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_feature_restrictions ( CREATE TABLE IF NOT EXISTS user_feature_restrictions (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
@ -256,38 +230,6 @@ CREATE TABLE IF NOT EXISTS identity_review_proposals (
UNIQUE (profile_id, observed_label, entity_type) UNIQUE (profile_id, observed_label, entity_type)
); );
CREATE TABLE IF NOT EXISTS label_senses (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
normalized_label TEXT NOT NULL,
identity_hits INTEGER NOT NULL DEFAULT 0,
non_identity_hits INTEGER NOT NULL DEFAULT 0,
updated TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (profile_id, normalized_label)
);
CREATE TABLE IF NOT EXISTS label_sense_cues (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
normalized_label TEXT NOT NULL,
cue TEXT NOT NULL,
decision TEXT NOT NULL,
hits INTEGER NOT NULL DEFAULT 1,
excerpt TEXT NOT NULL DEFAULT '',
updated TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (profile_id, normalized_label, cue)
);
CREATE TABLE IF NOT EXISTS pending_mask_reviews (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_message_id TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_pending_mask_reviews_conv
ON pending_mask_reviews (profile_id, conversation_id);
CREATE TABLE IF NOT EXISTS journal_days ( CREATE TABLE IF NOT EXISTS journal_days (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
@ -513,18 +455,6 @@ CREATE TABLE IF NOT EXISTS writing_profile_versions (
UNIQUE (profile_id, seq) UNIQUE (profile_id, seq)
); );
CREATE TABLE IF NOT EXISTS llm_profiles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
zdr INTEGER NOT NULL DEFAULT 1,
no_train INTEGER NOT NULL DEFAULT 1,
created TEXT NOT NULL DEFAULT (datetime('now')),
updated TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS provider_settings ( CREATE TABLE IF NOT EXISTS provider_settings (
role TEXT PRIMARY KEY CHECK (role IN ('generate', 'detect')), role TEXT PRIMARY KEY CHECK (role IN ('generate', 'detect')),
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
@ -532,7 +462,6 @@ CREATE TABLE IF NOT EXISTS provider_settings (
model TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '',
zdr INTEGER NOT NULL DEFAULT 1, zdr INTEGER NOT NULL DEFAULT 1,
no_train INTEGER NOT NULL DEFAULT 1, no_train INTEGER NOT NULL DEFAULT 1,
profile_id TEXT,
updated TEXT NOT NULL DEFAULT (datetime('now')) updated TEXT NOT NULL DEFAULT (datetime('now'))
); );

View File

@ -43,9 +43,6 @@ TABLES = [
"re_grounding_events", "re_grounding_events",
"identity_mappings", "identity_mappings",
"identity_review_proposals", "identity_review_proposals",
"label_senses",
"label_sense_cues",
"pending_mask_reviews",
"journal_days", "journal_days",
"journal_drafts", "journal_drafts",
"journal_entries", "journal_entries",
@ -65,7 +62,6 @@ TABLES = [
"writing_profile_evidence", "writing_profile_evidence",
"writing_profile_reviews", "writing_profile_reviews",
"writing_profile_versions", "writing_profile_versions",
"llm_profiles",
"provider_settings", "provider_settings",
"journal_generation_selection", "journal_generation_selection",
"generation_guidelines", "generation_guidelines",
@ -189,13 +185,6 @@ def migrate(sqlite_path: Path, media_src: Path, media_dest: Path, replace: bool)
count = pg.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] count = pg.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
print(f" {table}: {len(rows)} → {count}") print(f" {table}: {len(rows)} → {count}")
stats[table] = (len(rows), int(count)) stats[table] = (len(rows), int(count))
pg.execute(
"""
UPDATE profiles
SET email_verified_at = COALESCE(NULLIF(created, ''), CURRENT_TIMESTAMP::text)
WHERE email_verified_at IS NULL OR email_verified_at = ''
"""
)
pg.execute( pg.execute(
"INSERT INTO schema_migrations (id) VALUES (%s) ON CONFLICT (id) DO NOTHING", "INSERT INTO schema_migrations (id) VALUES (%s) ON CONFLICT (id) DO NOTHING",
("022_postgres_runtime_baseline",), ("022_postgres_runtime_baseline",),

View File

@ -31,13 +31,10 @@ UNIT_MODULES = frozenset(
"test_local_backup", "test_local_backup",
"test_provenance", "test_provenance",
"test_model_catalog", "test_model_catalog",
"test_local_url",
"test_privacy_detect_eval", "test_privacy_detect_eval",
"test_journal_shape", "test_journal_shape",
"test_journal_body", "test_journal_body",
"test_writing_profile", "test_writing_profile",
"test_gliner_client",
"test_detect_pipeline",
} }
) )

View File

@ -1,176 +0,0 @@
"""Account identifiers, reset links, and address verification."""
from __future__ import annotations
import os
import re
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_PROVIDER_KEY"] = ""
os.environ["KANSHO_DETECT_PROVIDER_KEY"] = ""
os.environ["KANSHO_FAKE_MAIL"] = "1"
os.environ["KANSHO_ENV"] = "test"
os.environ["APP_URL"] = "http://localhost:5188"
if (os.environ.get("KANSHO_DB_BACKEND") or "").strip().lower() not in {"postgres", "postgresql", "pg"}:
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.mkdtemp(prefix="kansho-account-")) / "account.sqlite")
from fastapi.testclient import TestClient
from main import app
from db import get_db
from mailer import OUTBOX, clear_outbox
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def auth_header(token: str) -> dict:
return {"X-Auth-Token": token}
def mail_tokens() -> list[str]:
found: list[str] = []
for item in OUTBOX:
found.extend(re.findall(r"token=([A-Za-z0-9_\-]+)", item["body"]))
return found
def main() -> None:
clear_outbox()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
expect(setup.status_code == 200, f"setup {setup.text}")
token = setup.json()["token"]
headers = auth_header(token)
verify_tokens = mail_tokens()
expect(len(verify_tokens) == 1, "setup sends one verification link")
verified = client.post("/api/auth/verify-email", json={"token": verify_tokens[0]})
expect(verified.status_code == 200 and verified.json()["email_verified"] is True, "admin email verified")
clear_outbox()
me = client.get("/api/auth/me", headers=headers)
expect(me.status_code == 200, "session survives initial verification")
admin_id = me.json()["id"]
blocked = client.patch(
f"/api/users/{admin_id}",
headers=headers,
json={"status": "disabled"},
)
expect(blocked.status_code == 400, f"last admin cannot be disabled {blocked.text}")
created = client.post(
"/api/users",
headers=headers,
json={
"email": "ute@example.test",
"name": "Ute",
"password": "user-pass",
"role": "user",
"username": "ute.user",
},
)
expect(created.status_code == 200 and created.json()["username"] == "ute.user", f"create with username {created.text}")
expect(created.json()["email_verified"] is False, "new primary email starts unverified")
clear_outbox()
by_name = client.post("/api/auth/login", json={"email": "Ute.User", "password": "user-pass"})
expect(by_name.status_code == 200, f"login by username {by_name.text}")
unknown = client.post("/api/auth/forgot-password", json={"email": "missing@example.test"})
known = client.post("/api/auth/forgot-password", json={"email": "lars@example.test"})
expect(unknown.status_code == 200 and known.status_code == 200, "forgot password always 200")
expect(unknown.json() == known.json(), "forgot password does not reveal whether the account exists")
expect(len(mail_tokens()) == 1, "reset mail only for the verified account")
reset_token = mail_tokens()[0]
clear_outbox()
changed = client.post(
"/api/auth/reset-password",
json={"token": reset_token, "password": "new-pass"},
)
expect(changed.status_code == 200, f"reset password {changed.text}")
stale = client.get("/api/auth/me", headers=headers)
expect(stale.status_code == 401, "reset deletes sessions")
old_password = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "test-pass"})
expect(old_password.status_code == 401, "old password rejected")
relogin = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "new-pass"})
expect(relogin.status_code == 200, "login with reset password")
headers = auth_header(relogin.json()["token"])
again = client.post("/api/auth/reset-password", json={"token": reset_token, "password": "other-pass"})
expect(again.status_code == 400, "reset token is single use")
client.post("/api/auth/forgot-password", json={"email": "lars@example.test"})
expired_token = mail_tokens()[0]
clear_outbox()
with get_db() as conn:
conn.execute(
"UPDATE password_reset_tokens SET expires_at = ? WHERE token_hash IS NOT NULL",
("2000-01-01 00:00:00",),
)
expired = client.post(
"/api/auth/reset-password",
json={"token": expired_token, "password": "newer-pass"},
)
expect(expired.status_code == 400, "expired reset token is rejected")
clear_outbox()
renamed = client.patch(
"/api/users/me",
headers=headers,
json={"email": "lars.new@example.test"},
)
expect(renamed.status_code == 200, f"email change queued {renamed.text}")
expect(renamed.json()["email"] == "lars@example.test", "login email unchanged until verification")
expect(renamed.json()["pending_email"] == "lars.new@example.test", "pending email stored")
still = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "new-pass"})
expect(still.status_code == 200, "old email still logs in")
confirm = client.post("/api/auth/verify-email", json={"token": mail_tokens()[0]})
expect(confirm.status_code == 200 and confirm.json()["email"] == "lars.new@example.test", "verified address becomes login")
gone = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "new-pass"})
expect(gone.status_code == 401, "previous email stops working")
moved = client.post("/api/auth/login", json={"email": "lars.new@example.test", "password": "new-pass"})
expect(moved.status_code == 200, "new email logs in")
headers = auth_header(moved.json()["token"])
clear_outbox()
other = client.post(
f"/api/users/{created.json()['id']}/password-reset",
headers=headers,
)
expect(other.status_code == 200, f"admin reset for another user {other.text}")
expect(len(mail_tokens()) == 1, "admin reset sends one link")
self_reset = client.post(f"/api/users/{moved.json()['profile_id']}/password-reset", headers=headers)
expect(self_reset.status_code == 400, "admin cannot reset the own account via the admin endpoint")
os.environ.pop("KANSHO_FAKE_MAIL", None)
os.environ.pop("SMTP_HOST", None)
os.environ.pop("SMTP_FROM", None)
os.environ["KANSHO_ENV"] = "test"
failed = client.post(
f"/api/users/{created.json()['id']}/password-reset",
headers=headers,
)
expect(failed.status_code == 503, f"admin reset without SMTP fails closed {failed.text}")
secondary_login = client.post(
"/api/auth/login",
json={"email": "ute.second@example.test", "password": "user-pass"},
)
expect(secondary_login.status_code == 401, "unknown address is not a login")
print("Account access tests passed.")
if __name__ == "__main__":
main()

View File

@ -1,446 +0,0 @@
"""Transitional learning detect: senses from dialogue review, admin mode switch.
Fake detect/provider only. Run from backend/: python tests/test_detect_learning.py
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
from fastapi.testclient import TestClient
from detect_learning import (
cue_for_mention,
decision_for_mention,
excerpt_view,
get_cue_decision,
get_detect_operating_mode,
get_sense,
merge_gliner_candidates,
passage_provider,
record_cue,
record_sense,
set_detect_operating_mode,
try_local_passage_decision,
)
from detect_pipeline import (
MentionSpan,
compact_diff_counters,
compare_mentions,
map_entity_type,
prepare_detect_pipeline,
same_mention,
)
from providers import ChatResult, ProviderConfig
from dialogue_turn import visible_for_role
from identity_store import list_confirmed_identities
from main import app
from privacy_gateway import reset_debug
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def status_ok(response, label: str) -> None:
if response.status_code != 200:
detail = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
code = detail.get("detail", {}).get("code") if isinstance(detail, dict) else None
expect(False, f"{label} HTTP {response.status_code} {code or ''}".strip())
expect(True, label)
def header(token: str) -> dict:
return {"X-Auth-Token": token}
def open_conv(client: TestClient, headers: dict) -> str:
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-09-08"},
)
conv = client.post(
f"/api/journal/days/{day.json()['day']['id']}/conversations",
headers=headers,
json={"title": "Gespräch"},
)
return conv.json()["id"]
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 = header(setup.json()["token"])
profile_id = setup.json()["profile_id"]
providers = client.get("/api/admin/providers", headers=headers)
expect(providers.status_code == 200, "providers status")
expect(providers.json()["detect_operating_mode"] == "bypass", "fake-env default is bypass")
expect(get_detect_operating_mode() == "bypass", "store default in tests is bypass")
ner = providers.json().get("local_ner") or {}
expect(ner.get("enabled") is False, "GLiNER is off by default")
detect_before = next(item for item in providers.json()["roles"] if item["role"] == "detect")
remote_gliner = client.put(
"/api/admin/providers/local-ner",
headers=headers,
json={"url": "https://openrouter.ai/api/v1"},
)
expect(remote_gliner.status_code == 400, "GLiNER URL must stay local")
enabled = client.put(
"/api/admin/providers/local-ner",
headers=headers,
json={"enabled": True, "url": "http://192.168.2.144:8090"},
)
status_ok(enabled, "enable local GLiNER")
expect(enabled.json()["local_ner"]["enabled"] is True, "GLiNER enabled flag persisted")
expect(enabled.json()["local_ner"]["url"] == "http://192.168.2.144:8090", "GLiNER LAN URL persisted")
detect_after = next(item for item in enabled.json()["roles"] if item["role"] == "detect")
expect(detect_after.get("url") == detect_before.get("url"), "OpenRouter detect URL is unchanged")
client.put("/api/admin/providers/local-ner", headers=headers, json={"enabled": False})
bad = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "wordlist"})
expect(bad.status_code == 400, "invalid detect mode is rejected")
conv_semantic = open_conv(client, headers)
semantic_turn = client.post(
f"/api/journal/conversations/{conv_semantic}/turn",
headers=headers,
json={"body": "Anna kam vorbei."},
)
status_ok(semantic_turn, "bypass turn")
expect(not semantic_turn.json().get("pending_mask_review"), "bypass mode does not pause for review")
expect(any(item.get("role") == "assistant" for item in semantic_turn.json().get("messages") or []), "bypass turn gets an assistant")
switched = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "learning"})
status_ok(switched, "switch to learning alias")
expect(switched.json()["detect_operating_mode"] == "learning_full", "learning alias stores learning_full")
conv = open_conv(client, headers)
paused = client.post(
f"/api/journal/conversations/{conv}/turn",
headers=headers,
json={"body": "Anna kam vorbei."},
)
status_ok(paused, "learning pause")
review = paused.json().get("pending_mask_review") or {}
expect(bool(review.get("id")), "learning mode returns a review id")
expect(paused.json().get("assistant") is None, "paused turn has no assistant")
candidates = review.get("candidates") or []
anna = next((item for item in candidates if item.get("text") == "Anna"), None)
expect(anna is not None, "Anna is a review candidate")
hidden = visible_for_role({"pending_mask_review": review, "trace": {"egress": "x"}}, "user")
expect("pending_mask_review" in hidden, "users still receive the mask review")
expect("trace" not in hidden, "users do not receive the detect trace")
reloaded = client.get(f"/api/journal/conversations/{conv}", headers=headers)
expect(reloaded.json().get("pending_mask_review", {}).get("id") == review["id"], "pending review survives reload")
missing = client.post(
f"/api/journal/conversations/{conv}/turn/review",
headers=headers,
json={"review_id": "missing", "decisions": []},
)
expect(missing.status_code == 404, "unknown review is gone")
identity = client.post(
f"/api/journal/conversations/{conv}/turn/review",
headers=headers,
json={
"review_id": review["id"],
"decisions": [{"id": anna["id"], "decision": "identity"}],
},
)
status_ok(identity, "identity review")
expect(not identity.json().get("pending_mask_review"), "review continue clears the pause")
expect(any(item.get("role") == "assistant" for item in identity.json().get("messages") or []), "continue produces an assistant")
confirmed = list_confirmed_identities(profile_id)
expect(any((item.get("canonical_label") or "").casefold() == "anna" for item in confirmed), "identity review confirms the registry")
sense = get_sense(profile_id, "Anna")
expect(sense["identity_hits"] >= 1 and not sense["ambiguous"], "Anna is identity-only")
second = client.post(
f"/api/journal/conversations/{conv}/turn",
headers=headers,
json={"body": "Anna hat später angerufen."},
)
status_ok(second, "second Anna turn")
expect(not second.json().get("pending_mask_review"), "identity-only spelling skips the popup")
food_conv = open_conv(client, headers)
food = client.post(
f"/api/journal/conversations/{food_conv}/turn",
headers=headers,
json={"body": "Hanna aß mit uns."},
)
status_ok(food, "hanna pause")
food_review = food.json().get("pending_mask_review") or {}
food_candidates = food_review.get("candidates") or []
hanna = next((item for item in food_candidates if item.get("text") == "Hanna"), None)
expect(hanna is not None, "Hanna is a review candidate")
declined = client.post(
f"/api/journal/conversations/{food_conv}/turn/review",
headers=headers,
json={
"review_id": food_review["id"],
"decisions": [{"id": hanna["id"], "decision": "not_identity"}],
},
)
status_ok(declined, "not_identity review")
expect(not any((item.get("canonical_label") or "").casefold() == "hanna" for item in list_confirmed_identities(profile_id)), "not_identity does not confirm Hanna")
expect(get_sense(profile_id, "Hanna")["non_identity_hits"] >= 1, "not_identity records a sense")
hanna_again = client.post(
f"/api/journal/conversations/{food_conv}/turn",
headers=headers,
json={"body": "Hanna aß mit uns."},
)
status_ok(hanna_again, "second Hanna turn")
expect(
not hanna_again.json().get("pending_mask_review"),
"stored Hanna cue is not re-asked against teacher PERSON",
)
record_sense(profile_id, "Clarissa", identity=True)
record_sense(profile_id, "Clarissa", identity=False)
expect(get_sense(profile_id, "Clarissa")["ambiguous"], "both senses mark Clarissa as ambiguous")
amb_conv = open_conv(client, headers)
with patch("detect_learning.try_local_passage_decision", return_value="not_identity"):
amb = client.post(
f"/api/journal/conversations/{amb_conv}/turn",
headers=headers,
json={"body": "Clarissa lag auf dem Tisch."},
)
status_ok(amb, "ambiguous local passage")
expect(not amb.json().get("pending_mask_review"), "local passage can finish without a popup")
expect(any(item.get("role") == "assistant" for item in amb.json().get("messages") or []), "local passage still generates")
kin_conv = open_conv(client, headers)
kin = client.post(
f"/api/journal/conversations/{kin_conv}/turn",
headers=headers,
json={"body": "Ich war mit meinem Sohn Leon im Park."},
)
status_ok(kin, "kinship Leon pause")
kin_review = kin.json().get("pending_mask_review") or {}
kin_names = [item.get("text") for item in kin_review.get("candidates") or []]
expect("Leon" in kin_names, "Sohn Leon is offered even when Detect does not report it")
leon = next((item for item in kin_review.get("candidates") or [] if item.get("text") == "Leon"), None)
expect(leon is not None, "Leon candidate id is present")
kin_done = client.post(
f"/api/journal/conversations/{kin_conv}/turn/review",
headers=headers,
json={
"review_id": kin_review["id"],
"decisions": [{"id": leon["id"], "decision": "identity"}],
},
)
status_ok(kin_done, "Leon identity review")
kin_again = client.post(
f"/api/journal/conversations/{kin_conv}/turn",
headers=headers,
json={"body": "Ich war mit meinem Sohn Leon im Park."},
)
status_ok(kin_again, "second Leon turn")
expect(not kin_again.json().get("pending_mask_review"), "confirmed Sohn Leon skips the popup")
sushi_conv = open_conv(client, headers)
sushi = client.post(
f"/api/journal/conversations/{sushi_conv}/turn",
headers=headers,
json={
"body": (
"Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan "
"Sushi essen gegangen. Das Restaurant hat mich dabei total beeindruckt."
)
},
)
status_ok(sushi, "homonym sentence pause")
sushi_names = [item.get("text") for item in (sushi.json().get("pending_mask_review") or {}).get("candidates") or []]
expect("Rohan" in sushi_names, "Rohan from Sohn is a candidate")
expect("Sushi" in sushi_names, "Sushi from Frau is a candidate")
expect("Restaurant" not in sushi_names, "plain Restaurant is not kinship-offered")
sushi_hits = [
item
for item in (sushi.json().get("pending_mask_review") or {}).get("candidates") or []
if item.get("text") == "Sushi"
]
expect(len(sushi_hits) >= 2, "both Sushi mentions are offered")
expect(
sushi_hits[0].get("highlight_start") != sushi_hits[1].get("highlight_start"),
"the two Sushi mentions highlight different offsets",
)
for item in sushi_hits:
excerpt = item.get("excerpt") or ""
start = item.get("highlight_start")
end = item.get("highlight_end")
expect(excerpt[start:end] == "Sushi", "highlight covers the Sushi token")
homonym = (
"Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan "
"Sushi essen gegangen."
)
first_at = homonym.index("Sushi")
second_at = homonym.rindex("Sushi")
first = excerpt_view(homonym, first_at, first_at + 5, "Sushi")
second = excerpt_view(homonym, second_at, second_at + 5, "Sushi")
expect(first["highlight_start"] < second["highlight_start"], "person Sushi sits left of dish Sushi")
expect(first["excerpt"][first["highlight_start"]:first["highlight_end"]] == "Sushi", "first highlight is Sushi")
expect(second["excerpt"][second["highlight_start"]:second["highlight_end"]] == "Sushi", "second highlight is Sushi")
prefix = "SYSTEM\n"
mapped = excerpt_view(
homonym,
len(prefix) + second_at,
len(prefix) + second_at + 5,
"Sushi",
prefix + homonym,
)
expect(mapped["highlight_start"] == second["highlight_start"], "rendered detect offsets map to the dish Sushi")
expect(cue_for_mention(homonym, first_at, first_at + 5, "Sushi") == "frau", "person Sushi attaches to Frau")
expect(cue_for_mention(homonym, second_at, second_at + 5, "Sushi") == "rohan", "dish Sushi stays distinct from Frau")
appos = "Sushi, meine Frau war erkältet."
appos_at = appos.index("Sushi")
expect(cue_for_mention(appos, appos_at, appos_at + 5, "Sushi") == "frau", "apposition Sushi, meine Frau binds Frau")
far = "Meine Frau und ich aßen Sushi."
far_at = far.rindex("Sushi")
expect(cue_for_mention(far, far_at, far_at + 5, "Sushi") != "frau", "Frau elsewhere in the sentence does not bind the dish")
sushi_review = sushi.json().get("pending_mask_review") or {}
sushi_decisions = []
for item in sushi_review.get("candidates") or []:
label = item.get("text")
cue = item.get("cue")
if label == "Sushi" and cue != "frau":
sushi_decisions.append({"id": item["id"], "decision": "not_identity"})
else:
sushi_decisions.append({"id": item["id"], "decision": "identity"})
sushi_done = client.post(
f"/api/journal/conversations/{sushi_conv}/turn/review",
headers=headers,
json={"review_id": sushi_review["id"], "decisions": sushi_decisions},
)
status_ok(sushi_done, "homonym review")
expect(get_cue_decision(profile_id, "Sushi", "frau") == "identity", "Frau Sushi is stored as identity")
expect(get_cue_decision(profile_id, "Sushi", "rohan") == "not_identity", "dish Sushi after Rohan is stored")
expect(get_cue_decision(profile_id, "Rohan", "sohn") == "identity", "Sohn Rohan cue is stored")
sushi_again = client.post(
f"/api/journal/conversations/{sushi_conv}/turn",
headers=headers,
json={
"body": (
"Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan "
"Sushi essen gegangen. Das Restaurant hat mich dabei total beeindruckt."
)
},
)
status_ok(sushi_again, "repeat homonym sentence")
expect(not sushi_again.json().get("pending_mask_review"), "same reviewed sentence does not re-ask")
dish = "Ich aß Sushi."
dish_at = dish.index("Sushi")
food_spans = [{"text": "Sushi", "start": dish_at, "end": dish_at + 5, "label": "food", "score": 0.8}]
food_cands, _maps = merge_gliner_candidates(profile_id, [], [], dish, dish, food_spans)
expect(
any(item.get("text") == "Sushi" and item.get("suggested") == "not_identity" for item in food_cands),
"GLiNER food span suggests not_identity",
)
person_line = "Ich war mit meiner Frau Sushi unterwegs."
person_at = person_line.index("Sushi")
gliner_miss = [{"text": "Sushi", "start": person_at, "end": person_at + 5, "label": "food", "score": 0.9}]
merged, _maps = merge_gliner_candidates(profile_id, [], [], person_line, person_line, gliner_miss)
expect(
not any(item.get("suggested") == "not_identity" for item in merged),
"GLiNER food does not override Frau attachment",
)
expect(
decision_for_mention(profile_id, "Sushi", person_line, person_at, person_at + 5, person_line) == "identity",
"learned Frau cue wins over GLiNER food",
)
dish_line = "Ich aß Sushi."
dish_pos = dish_line.index("Sushi")
expect(
decision_for_mention(profile_id, "Sushi", dish_line, dish_pos, dish_pos + 5, dish_line) == "ask",
"Tag-21: identity hits plus new cue stay visible",
)
person = MentionSpan("Sushi", 20, 25, "PERSON", "identity", "cue", True, "frau")
food = MentionSpan("Sushi", 20, 25, "FOOD", "not_identity", "teacher", False, "aß")
expect(same_mention(person, food), "same offsets are the same mention")
wrapped = MentionSpan("Frau Sushi", 15, 25, "PERSON", "identity", "teacher", False, "frau")
expect(same_mention(person, wrapped), "contained Sushi matches Frau Sushi")
expect(map_entity_type("person") == "PERSON", "GLiNER person maps to PERSON")
guessed = MentionSpan("Sushi", 20, 25, "PERSON", "identity", "candidate", False, "aß")
review, counters = compare_mentions(profile_id, dish_line, [guessed], [food], dish_line)
expect(counters["diff_type_conflict"] >= 1, "FOOD versus PERSON is a type conflict")
expect(any(item.get("reason") == "type_conflict" for item in review), "unconfirmed type conflict is offered")
confirmed_dish = MentionSpan("Sushi", 20, 25, "FOOD", "not_identity", "cue", True, "rohan")
teacher_person = MentionSpan("Sushi", 20, 25, "PERSON", "identity", "teacher", False, "rohan")
kept, kept_counters = compare_mentions(
profile_id, dish_line, [confirmed_dish], [teacher_person], dish_line
)
expect(kept_counters["diff_type_conflict"] >= 1, "confirmed cue still counts the type conflict")
expect(not kept, "confirmed cue is not re-asked on type conflict")
compact = compact_diff_counters(counters)
dumped = str(compact)
expect("Sushi" not in dumped and "Frau" not in dumped, "compact diff counters carry no plaintext")
expect(set(compact) == {
"diff_agree",
"diff_only_student",
"diff_only_teacher",
"diff_type_conflict",
"diff_new_identity",
"diff_cue_reinforced",
}, "compact counters are the agreed keys")
set_detect_operating_mode("learning_reduced")
expect(get_detect_operating_mode() == "learning_reduced", "stage 2 mode persists")
set_detect_operating_mode("learning_manual")
expect(get_detect_operating_mode() == "learning_manual", "manual mode persists")
set_detect_operating_mode("student")
pipe = prepare_detect_pipeline(profile_id, "Anna kam vorbei.", "Anna kam vorbei.")
expect(pipe.mode == "student", "student path is selectable")
expect(not pipe.need_review, "zielpfad does not open a teacher popup")
record_sense(profile_id, "Sushi", identity=True)
record_cue(profile_id, "Sushi", "frau", "identity", "meine Frau Sushi")
expect(
decision_for_mention(profile_id, "Sushi", "Ich aß Sushi.", dish_pos, dish_pos + 5) == "ask",
"Tag-21 remains ask after more identity hits",
)
fake_passage = ProviderConfig(
role="passage",
name="ollama",
mode="http",
url="http://192.168.2.144:11434/v1/chat/completions",
model="phi3:mini",
key="",
local=True,
zdr=True,
no_train=True,
)
with patch("detect_learning.passage_provider", return_value=fake_passage):
with patch("providers.complete_chat", return_value=ChatResult(content='{"decision":"not_identity"}')) as chat:
decided = try_local_passage_decision("Rohan Sushi essen", "Sushi")
expect(decided == "not_identity", "passage profile can decide without local Detect")
expect(chat.called, "passage uses complete_chat")
expect(passage_provider() is None, "no passage profile means no silent detect fallback")
back = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "semantic"})
expect(back.json()["detect_operating_mode"] == "bypass", "semantic alias stores bypass")
full = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "learning_full"})
expect(full.json()["detect_operating_mode"] == "learning_full", "learning_full is accepted")
if __name__ == "__main__":
main()

View File

@ -1,79 +0,0 @@
"""Unit tests for student/teacher comparison. No database."""
from __future__ import annotations
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ.setdefault("KANSHO_PROVIDER_KEY", "")
os.environ.setdefault("KANSHO_DETECT_PROVIDER_KEY", "")
from unittest.mock import patch
from detect_learning import canonicalize_detect_mode, decision_for_mention
from detect_pipeline import (
MentionSpan,
compact_diff_counters,
compare_mentions,
map_entity_type,
protects,
same_mention,
types_equivalent,
)
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def main() -> None:
expect(canonicalize_detect_mode("learning") == "learning_full", "learning aliases to learning_full")
expect(canonicalize_detect_mode("semantic") == "bypass", "semantic aliases to bypass")
expect(canonicalize_detect_mode("wordlist") is None, "unknown mode is rejected")
expect(map_entity_type("person") == "PERSON", "person maps to PERSON")
expect(map_entity_type("place") == "PLACE", "place maps to PLACE")
expect(protects("PERSON") and not protects("FOOD"), "PERSON protects, FOOD does not")
expect(types_equivalent("PERSON", "PLACE"), "similar protectable types are equivalent")
expect(not types_equivalent("FOOD", "PERSON"), "FOOD versus PERSON is never equivalent")
guessed = MentionSpan("Sushi", 20, 25, "PERSON", "identity", "candidate", False, "aß")
teacher_food = MentionSpan("Sushi", 20, 25, "FOOD", "not_identity", "teacher", False, "aß")
offered, offered_counters = compare_mentions("p", "Ich aß Sushi.", [guessed], [teacher_food])
expect(offered_counters["diff_type_conflict"] == 1, "unconfirmed FOOD versus PERSON is counted")
expect(any(item.get("reason") == "type_conflict" for item in offered), "unconfirmed type conflict is offered")
confirmed = MentionSpan("Sushi", 20, 25, "FOOD", "not_identity", "cue", True, "rohan")
teacher_person = MentionSpan("Sushi", 20, 25, "PERSON", "identity", "teacher", False, "rohan")
kept, kept_counters = compare_mentions("p", "Rohan Sushi essen", [confirmed], [teacher_person])
expect(kept_counters["diff_type_conflict"] == 1, "confirmed cue still counts FOOD versus PERSON")
expect(not kept, "confirmed cue is not re-asked on type conflict")
inner = MentionSpan("Sushi", 20, 25, "PERSON", "identity", "cue", True, "frau")
outer = MentionSpan("Frau Sushi", 15, 25, "PERSON", "identity", "teacher", False, "frau")
expect(same_mention(inner, outer), "contained span is the same mention")
food = MentionSpan("Sushi", 20, 25, "FOOD", "not_identity", "teacher", False, "aß")
expect(same_mention(inner, food), "same offsets stay one mention even when types differ")
compact = compact_diff_counters({"diff_agree": 2, "diff_type_conflict": 1, "label": "Sushi"})
expect(compact["diff_agree"] == 2 and compact["diff_type_conflict"] == 1, "known counters are kept")
expect("label" not in compact and "Sushi" not in str(compact), "compact counters drop plaintext")
with patch("detect_learning.get_cue_decision", return_value=None), patch(
"detect_learning.get_sense",
return_value={"identity_hits": 3, "non_identity_hits": 0, "ambiguous": False},
), patch("detect_learning.cue_for_mention", return_value="aß"):
expect(
decision_for_mention("p", "Sushi", "Ich aß Sushi.", 7, 12) == "ask",
"Tag-21: identity-only sense does not lock a new cue",
)
with patch("detect_learning.get_cue_decision", return_value="identity"), patch(
"detect_learning.cue_for_mention", return_value="frau"
):
expect(
decision_for_mention("p", "Sushi", "meine Frau Sushi", 11, 16) == "identity",
"stored cue remains binding",
)
print("detect pipeline unit ok")
if __name__ == "__main__":
main()

View File

@ -89,7 +89,6 @@ def main() -> None:
expect(set(roles) == {"generate", "detect"}, "two provider roles") expect(set(roles) == {"generate", "detect"}, "two provider roles")
expect(providers.json()["remote_plaintext_allowed"] is True, "test runtime allows remote detect") expect(providers.json()["remote_plaintext_allowed"] is True, "test runtime allows remote detect")
expect(providers.json()["remote_plaintext_reason"] == "non_production", "tests are not production") expect(providers.json()["remote_plaintext_reason"] == "non_production", "tests are not production")
expect(providers.json().get("detect_operating_mode") == "learning_full", "detect mode defaults to learning stage 1")
expect(roles["generate"]["ready"] is False, "generate fail-closed without key") expect(roles["generate"]["ready"] is False, "generate fail-closed without key")
expect("sk-" not in providers.text, "provider status has no secret") expect("sk-" not in providers.text, "provider status has no secret")
expect("KANSHO_PROVIDER_KEY=" not in providers.text, "env assignment not leaked") expect("KANSHO_PROVIDER_KEY=" not in providers.text, "env assignment not leaked")
@ -108,37 +107,6 @@ def main() -> None:
expect(saved.status_code == 200, f"save generate settings {saved.text}") expect(saved.status_code == 200, f"save generate settings {saved.text}")
saved_roles = {item["role"]: item for item in saved.json()["roles"]} saved_roles = {item["role"]: item for item in saved.json()["roles"]}
expect(saved_roles["generate"]["model"] == "openai/gpt-4o", "generate model persisted") expect(saved_roles["generate"]["model"] == "openai/gpt-4o", "generate model persisted")
expect("profiles" in saved.json(), "llm profiles are listed")
titles = {item["title"] for item in saved.json()["profiles"]}
expect("Ollama (LAN)" in titles, "LAN Ollama profile is seeded")
ollama = next(item for item in saved.json()["profiles"] if item["title"] == "Ollama (LAN)")
expect(ollama["local"] is True, "LAN Ollama is a local trusted endpoint")
expect(ollama["url"].startswith("http://192.168.2.144:11434"), "Ollama URL is the agreed LAN host")
switched = client.put(
"/api/admin/llm-profiles/" + ollama["id"],
headers=headers,
json={
"title": "Ollama (LAN)",
"name": "ollama",
"url": ollama["url"],
"model": "llama3.1:latest",
"zdr": True,
"no_train": True,
},
)
expect(switched.status_code == 200, f"save ollama model {switched.text}")
activated = client.post(
"/api/admin/providers/detect/activate",
headers=headers,
json={"profile_id": ollama["id"]},
)
expect(activated.status_code == 200, f"activate ollama detect {activated.text}")
detect_role = {item["role"]: item for item in activated.json()["roles"]}["detect"]
expect(detect_role["local"] is True, "detect uses local ollama after profile switch")
expect(detect_role["model"] == "llama3.1:latest", "detect keeps the saved ollama model")
generate_role = {item["role"]: item for item in activated.json()["roles"]}["generate"]
expect(generate_role["model"] == "openai/gpt-4o", "generate profile stays on its own model")
unknown = client.put( unknown = client.put(
"/api/admin/providers/training", "/api/admin/providers/training",

View File

@ -1,54 +0,0 @@
"""GLiNER client: local URL only, fail-soft, no request text in logs."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import patch
import httpx
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ.setdefault("KANSHO_PROVIDER_KEY", "")
os.environ.setdefault("KANSHO_DETECT_PROVIDER_KEY", "")
from gliner_client import DEFAULT_URL, extract_entities, health, normalize_base_url
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
class _Resp:
def __init__(self, status_code: int, payload):
self.status_code = status_code
self._payload = payload
def json(self):
return self._payload
def main() -> None:
expect(normalize_base_url("http://192.168.2.144:8090/") == DEFAULT_URL, "default GLiNER URL is normalized")
expect(extract_entities("Anna", url="https://openrouter.ai/api/v1") is None, "OpenRouter URL is rejected")
expect(health("https://openrouter.ai").get("reason") == "not_local", "health rejects non-local URL")
with patch("gliner_client.httpx.post", return_value=_Resp(200, {"spans": [{"text": "Anna", "start": 0, "end": 4, "label": "person", "score": 0.9}]})):
spans = extract_entities("Anna kam.", url="http://192.168.2.144:8090")
expect(spans == [{"text": "Anna", "start": 0, "end": 4, "label": "person", "score": 0.9}], "extract returns spans")
with patch("gliner_client.httpx.post", side_effect=httpx.ConnectError("down")):
expect(extract_entities("Anna kam.", url="http://192.168.2.144:8090") is None, "extract is fail-soft on errors")
with patch("gliner_client.httpx.get", return_value=_Resp(200, {"status": "ok", "model": "urchade/gliner_multi-v2.1", "offline": True})):
ping = health("http://192.168.2.144:8090")
expect(ping.get("ok") is True and ping.get("offline") is True, "health reports ready offline model")
print("gliner client: OK")
if __name__ == "__main__":
main()

View File

@ -1,70 +0,0 @@
"""Local trusted-zone URL tests. No database."""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ.setdefault("KANSHO_PROVIDER_KEY", "")
os.environ.setdefault("KANSHO_DETECT_PROVIDER_KEY", "")
Path(tempfile.gettempdir()).mkdir(parents=True, exist_ok=True)
from entity_detect import (
DETECT_MAX_TOKENS,
DETECT_MAX_TOKENS_LOCAL,
DETECT_TIMEOUT,
DETECT_TIMEOUT_LOCAL,
detect_chat_limits,
)
from providers import ProviderConfig, is_local_url
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def main() -> None:
expect(is_local_url("http://127.0.0.1:11434/v1/chat/completions"), "loopback is local")
expect(is_local_url("http://localhost:11434/v1/chat/completions"), "localhost is local")
expect(is_local_url("http://192.168.2.144:8090"), "RFC1918 GLiNER host is local")
expect(is_local_url("http://10.0.0.8:11434/v1/chat/completions"), "10/8 is local")
expect(is_local_url("http://ollama:11434/v1/chat/completions"), "compose hostname ollama is local")
expect(not is_local_url("https://openrouter.ai/api/v1/chat/completions"), "OpenRouter is not local")
expect(not is_local_url("https://8.8.8.8/v1/chat/completions"), "public IP is not local")
remote = ProviderConfig(
role="detect",
name="openrouter",
mode="http",
url="https://openrouter.ai/api/v1/chat/completions",
model="openai/gpt-4.1-nano",
key="x",
local=False,
zdr=True,
no_train=True,
)
local = ProviderConfig(
role="detect",
name="ollama",
mode="http",
url="http://192.168.2.144:11434/v1/chat/completions",
model="phi3:mini",
key="",
local=True,
zdr=True,
no_train=True,
)
expect(detect_chat_limits(remote) == (DETECT_TIMEOUT, DETECT_MAX_TOKENS), "remote detect keeps 90s/1024")
expect(
detect_chat_limits(local) == (DETECT_TIMEOUT_LOCAL, DETECT_MAX_TOKENS_LOCAL),
"local detect waits 300s with a 256-token cap",
)
print("local url tests passed.")
if __name__ == "__main__":
main()

View File

@ -210,30 +210,6 @@ def test_list_provider_models() -> None:
expect(list_provider_models(fake) == [], "fake provider has no catalog") expect(list_provider_models(fake) == [], "fake provider has no catalog")
def test_list_ollama_tags() -> None:
reset_catalog()
def fake_get(url, *args, **kwargs):
if str(url).endswith("/api/tags"):
return FakeResponse({"models": [{"name": "llama3.1:latest"}, {"name": "qwen2.5:7b"}]})
return FakeResponse({"nope": True})
config = ProviderConfig(
role="detect",
name="ollama",
mode="http",
url="http://192.168.2.144:11434/v1/chat/completions",
model="llama3.1:latest",
key="",
local=True,
zdr=True,
no_train=True,
)
with patch("model_catalog.httpx.get", fake_get):
items = list_provider_models(config)
expect([item["id"] for item in items] == ["llama3.1:latest", "qwen2.5:7b"], "ollama tags fill the picker")
def main() -> None: def main() -> None:
test_reads_context_and_completion() test_reads_context_and_completion()
test_model_not_in_catalog() test_model_not_in_catalog()
@ -241,7 +217,6 @@ def main() -> None:
test_cache_hit_and_ttl_expiry() test_cache_hit_and_ttl_expiry()
test_env_fallback_and_fail_closed() test_env_fallback_and_fail_closed()
test_list_provider_models() test_list_provider_models()
test_list_ollama_tags()
print("model catalog tests passed.") print("model catalog tests passed.")

View File

@ -38,11 +38,6 @@ services:
KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}" KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}"
KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}" KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}"
KANSHO_ALLOW_REMOTE_DETECT: "${KANSHO_ALLOW_REMOTE_DETECT:-}" KANSHO_ALLOW_REMOTE_DETECT: "${KANSHO_ALLOW_REMOTE_DETECT:-}"
SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-587}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
SMTP_FROM: "${SMTP_FROM:-}"
volumes: volumes:
- dev-kansho-media:/app/data/media - dev-kansho-media:/app/data/media
ports: ports:

View File

@ -40,11 +40,6 @@ services:
KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}" KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}"
KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}" KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}"
KANSHO_ALLOW_REMOTE_DETECT: "${KANSHO_ALLOW_REMOTE_DETECT:-}" KANSHO_ALLOW_REMOTE_DETECT: "${KANSHO_ALLOW_REMOTE_DETECT:-}"
SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-587}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
SMTP_FROM: "${SMTP_FROM:-}"
volumes: volumes:
- kansho-media:/app/data/media - kansho-media:/app/data/media
ports: ports:

View File

@ -49,8 +49,6 @@ cp .env.example .env
Host-nginx: `nginx/kansho.conf` und `nginx/kansho-dev.conf` nach `/etc/nginx/sites-available/`, dann `nginx/certbot-setup.sh`. DNS A/AAAA für `dev.kansho.jinkendo.de` und `kansho.jinkendo.de` auf den Reverse-Proxy. Bis TLS steht, bleibt der LAN-Zugriff über die Publish-Ports. Host-nginx: `nginx/kansho.conf` und `nginx/kansho-dev.conf` nach `/etc/nginx/sites-available/`, dann `nginx/certbot-setup.sh`. DNS A/AAAA für `dev.kansho.jinkendo.de` und `kansho.jinkendo.de` auf den Reverse-Proxy. Bis TLS steht, bleibt der LAN-Zugriff über die Publish-Ports.
**Additiv 2026-09-08:** Live-TLS endet auf dem Synology-NAS `192.168.2.63` (DSM Reverse Proxy, nicht auf dem Kanshō-Pi). Pro Kanshō-Regel unter Erweitert Proxy-Lese- und -Sende-Timeout auf 600s. DSM-Default 60s liefert HTTP 504, während Detect/Generate auf dem Pi weiterlaufen. LAN `http://192.168.2.49:3096` umgeht diesen Hop (Frontend-Container bereits 600s). AdGuard-Rewrites bleiben auf `192.168.2.63`.
Gitea: Actions aktivieren. Derselbe Pi-Runner wie die Schwesterprodukte (`ubuntu-latest`). Gitea: Actions aktivieren. Derselbe Pi-Runner wie die Schwesterprodukte (`ubuntu-latest`).
Watchtower bleibt aus. Watchtower bleibt aus.

View File

@ -94,7 +94,6 @@ Kanonisches Home der Fit-Gap-Analyse ist `mvp_stand_und_abgleich.md`. Es ersetzt
3. `self_model_and_lived_experience.md` 3. `self_model_and_lived_experience.md`
4. `memory_and_context.md` 4. `memory_and_context.md`
5. das konkret betroffene Technik- oder Integrationskapitel 5. das konkret betroffene Technik- oder Integrationskapitel
6. bei Detect-Selbstlernen (Konzept, nicht Code-Stand): `docs/work_orders/detect_self_learning_pipeline.md`
## 4. Technische Architektur ## 4. Technische Architektur
@ -104,7 +103,7 @@ Sie übernimmt den Produktrahmen **weitgehend von Mitai**. Datenschutz, Privacy
Technische Umsetzung des gebauten Journal-Slices: `../technical/mvp_implementation.md`. Fit-Gap bleibt fachlich: `mvp_stand_und_abgleich.md`. Technische Umsetzung des gebauten Journal-Slices: `../technical/mvp_implementation.md`. Fit-Gap bleibt fachlich: `mvp_stand_und_abgleich.md`.
Arbeitsaufträge (kein Ersatz kanonischer Kapitel): `docs/work_orders/` – Laptop-Abschluss, Heim-Setup, Debug-Export, Dialog/Memory-Slice, Selbstlern-Detect-Pipeline (`detect_self_learning_pipeline.md`, Konzept 2026-09-11). Slice-2-Konzeptbrief derzeit `Idea_seconmd_sclide.md`. Arbeitsaufträge (kein Ersatz kanonischer Kapitel): `docs/work_orders/` – Laptop-Abschluss, Heim-Setup, Debug-Export, Dialog/Memory-Slice. Slice-2-Konzeptbrief derzeit `Idea_seconmd_sclide.md`.
## 5. Ladeprinzip ## 5. Ladeprinzip

View File

@ -705,32 +705,8 @@ Das ist kein Admin-Feature-Flag und kein Umdeuten von `KANSHO_ENV=production` na
**Entschieden (Modellwahl):** Maskierung und Sprachmodell bleiben zwei getrennte Stufen. URL und Modell sind je Stufe in Admin → Schnittstellen wählbar. Das größere Modell gehört an Generate, nicht zwingend an Detect. **Entschieden (Modellwahl):** Maskierung und Sprachmodell bleiben zwei getrennte Stufen. URL und Modell sind je Stufe in Admin → Schnittstellen wählbar. Das größere Modell gehört an Generate, nicht zwingend an Detect.
**Additiv 2026-09-08 (LLM-Profile / LAN-Ollama):** Benannte Profile speichern Endpunkt und Modell ohne Keys. Ein Wechsel lädt das gespeicherte Profil. RFC1918-Ollama (`192.168.2.144:11434`) liegt in der lokalen Trusted Zone; Detect dorthin ist kein externes Klartext-Detect.
**Später prüfen:** Flag entfernen, sobald lokales Detect auf Prod läuft. Dann wieder Production ohne Klartext-Detect. **Später prüfen:** Flag entfernen, sobald lokales Detect auf Prod läuft. Dann wieder Production ohne Klartext-Detect.
## 22.4 Übergang: Detect-Lernmodus (2026-09-08)
Additiv. Technische Abbildung: `../technical/privacy_gateway.md` §9.7.
**Entschieden (Übergang):** Doppeldeutigkeiten werden nicht als eigene Wortliste gepflegt. Sinne entstehen aus Bestätigungen im Dialog (Identität / nicht schützenswert). Beides bei derselben Schreibweise markiert sie als mehrdeutig.
**Entschieden (Übergang):** Im Admin konfigurierbarer Detector-Modus `semantic` (Default) oder `learning`. Lernmodus öffnet vor Generate ein Bestätigungs-Popup für Detect-Treffer im aktuellen Nutzersatz. Das ist mehr Arbeit am Anfang und kein Ersatz für semantische Detection.
**Entschieden (Übergang):** Eine Mini-Passage an ein internes Modell geht nur bei bereits mehrdeutiger Schreibweise und nur an ein lokales Detect. OpenRouter sieht diese Passage nicht. Fehlt das lokale Modell, bleibt das Popup.
**Additiv 2026-09-10:** Eine bestätigte Nennung wird nicht erneut gefragt, wenn derselbe lokale Kontext wiederkehrt. Enge Verwandtschaftsbindung in der Nominalphrase (auch Apposition: `Sushi, meine Frau`) ist ein Hinweis, nicht die ganze Wahrheit. Der gespeicherte Ausschnitt ist der Kontext für eine lokale Vorentscheidung.
**Additiv 2026-09-11 (GLiNER-Vorstufe):** Im Lernmodus darf die LAN-Instanz `http://192.168.2.144:8090` den aktuellen Nutzersatz sehen (`POST /v1/extract`). Gelernte Sinne/Cues bleiben führend. Unsichere Nennungen gehen als Mini-Passage an ein im Admin gewähltes LLM-Profil (Ollama oder OpenRouter), nicht an die Detect-Stufe. OpenRouter-Maskierung bleibt vollständig konfigurierbar und ist in diesem Slice weiter der Egress-Detect. GLiNER ist kein alleiniges Detect und keine Wortliste. Fail-soft, wenn der Dienst fehlt.
**Additiv 2026-09-11 (Selbstlern-Pipeline, Konzept):** Zielbild mit Schülerpfad vor GLiNER/Lehrer und zwei Lernstufen: `../../work_orders/detect_self_learning_pipeline.md`. Das ersetzt den laufenden Lernmodus-Code nicht. Offene Punkte dort §6 vor der Umsetzung klären.
**Additiv 2026-09-11 (Selbstlern-Pipeline, §6 geschlossen):** Start ist Lernmodus Stufe 1: Schüler (Cues, Verwandtschaft, Homonym-Mini-LLM nur bei beiden Sinnen, GLiNER) parallel zum Lehrer auf dem unveränderten Nutzersatz. Admin: Zielpfad, Lernen Stufe 1/2/manuell, Bypass (alter Voll-Detect, mittelfristig weg). Default nach Umbau: Stufe 1. Neuer Cue bei nur Identity-Hits bleibt sichtbar (Sushi-Tag-21). Cue gilt pro Vorkommen. Lehrer-Miss eines bestätigten Cues ohne Meldung verlernt nicht. FOOD gegen PERSON ist immer Diff. Diagnose nur Zähler ohne Klartext. Gilt für alle persönlichen Detect-Aufrufe einschließlich Journalentwurf. GLiNER bleibt fail-soft im Lernmodus (dann manuelle Review); außerhalb Lernmodus Bestätigung vor öffentlichem Klartext-Egress. Gateway nicht abschaltbar.
**Additiv 2026-10-12:** FOOD gegen PERSON bleibt ein Typkonflikt im Zähler. Ein **bereits bestätigter** Cue wird dabei nicht erneut gefragt und nicht still geändert — derselbe lokale Kontext wie am 2026-09-10. Unbestätigte Nennungen mit abweichendem Schutz bleiben im Popup.
**Nicht:** Gateway abschalten, Detect-Treffer auto-speichern, `Sushi_`/`Sushi+` im Nutzertext, Pattern-Wortliste als Wahrheit.
--- ---
# 23. Externe Referenzquellen # 23. Externe Referenzquellen

View File

@ -53,16 +53,6 @@ Nicht übernehmen: Mitai-Admin für Körpertarife, Coupons, Training Types als K
**Additiv 2026-09-08:** Zwei Stufen mit je eigenem Modell. `GET /api/admin/providers/models` liefert den Katalog zur Auswahl (soft-fail). `remote_plaintext_reason` macht den Production-Übergang `KANSHO_ALLOW_REMOTE_DETECT` sichtbar, setzt ihn aber nicht. **Additiv 2026-09-08:** Zwei Stufen mit je eigenem Modell. `GET /api/admin/providers/models` liefert den Katalog zur Auswahl (soft-fail). `remote_plaintext_reason` macht den Production-Übergang `KANSHO_ALLOW_REMOTE_DETECT` sichtbar, setzt ihn aber nicht.
**Additiv 2026-09-08 (LLM-Profile):** `GET/POST/PUT/DELETE /api/admin/llm-profiles`, `POST /api/admin/providers/{role}/activate`. Profile enthalten URL/Modell/ZDR, niemals Keys. LAN-Ollama (`192.168.2.144:11434`) ist lokal.
**Additiv 2026-09-08 (Detect-Lernmodus):** `PUT /api/admin/providers/detect-mode` mit `semantic` | `learning`. Statusfeld `detect_operating_mode`. Keine Wortlisten-Seite. Identitäten dürfen ein Badge „mehrdeutig“ aus Dialog-Sinnen zeigen. Dialog und Journal-Gespräch pausieren im Lernmodus mit Bestätigungs-Popup; Journal-Generate nicht.
**Additiv 2026-09-11 (GLiNER-Vorstufe):** `PUT /api/admin/providers/local-ner` mit `enabled`, `url`, `passage_profile_id`. Status `local_ner`. Unabhängig von der Detect-URL. GLiNER nur lokale Trusted-Zone. Klärung unsicherer Nennungen über ein bestehendes LLM-Profil.
**Additiv 2026-09-11 (Selbstlern-Pipeline):** `PUT /api/admin/providers/detect-mode` nimmt `student` | `learning_full` | `learning_reduced` | `learning_manual` | `bypass` (Aliase `learning`, `semantic`). Compact-Diagnose darf Diff-Zähler ohne Klartext zeigen, keine Langeweile-Kennzahl. Stufe wechselt nur der Admin. Journalentwurf nutzt dieselbe Bestätigungsfläche.
**Additiv 2026-10-12:** `diff_type_conflict` zählt auch, wenn ein bestätigter Cue dem Lehrer widerspricht. Das Popup bleibt dann zu; der Cue ändert sich nicht.
## 4.2 Implementierungsstand (Dialog-Testspur) ## 4.2 Implementierungsstand (Dialog-Testspur)
**Additiv 2026-08-26:** Lokale Identitätsregistry unter `/admin/identities`. Detect-Vorschläge sind unbestätigt. Compact-Diagnose enthält Detect-Abdeckung, Chunks, Kosten und Laufzeit, aber keine Labels. **Additiv 2026-08-26:** Lokale Identitätsregistry unter `/admin/identities`. Detect-Vorschläge sind unbestätigt. Compact-Diagnose enthält Detect-Abdeckung, Chunks, Kosten und Laufzeit, aber keine Labels.

View File

@ -43,31 +43,6 @@ Mehrere Nutzer pro Instanz: ja. Jede Session ist an genau ein `profile_id` gebun
Klarnamen, E-Mail und Mapping-Tabellen sind Klasse A (Local Only). Sie verlassen die Trusted Zone nicht als Teil eines External Model Context. Klarnamen, E-Mail und Mapping-Tabellen sind Klasse A (Local Only). Sie verlassen die Trusted Zone nicht als Teil eines External Model Context.
**Additiv 2026-10-12.** Der Benutzername ist eine weitere Login-Kennung desselben Profils, keine zweite Identität. Die zweite E-Mail ist Kontaktadresse, kein Login. Beide bleiben Klasse A.
## 2.1 Kontokennungen und Verifikation
**Status: entschieden** (2026-10-12)
- Login mit primärer E-Mail oder Benutzername. Der Benutzername ist optional, eindeutig, 3–32 Zeichen aus Buchstaben, Ziffern, Punkt und Unterstrich.
- Bestehende primäre Adressen gelten mit der Migration als verifiziert.
- Eine neue oder geänderte Adresse wird erst nach dem Bestätigungslink wirksam. Bis dahin bleibt die bisher verifizierte Adresse die Login-Adresse.
- Solange die primäre Adresse nicht verifiziert ist, zeigt die Oberfläche nur den Kontobereich.
- Die zweite E-Mail erhält Reset- und Hinweismails erst nach eigener Verifikation.
- Passwort-Reset-Link: 60 Minuten, einmalig, nur der SHA-256-Hash liegt in der Datenbank. Danach werden die Sessions des Kontos gelöscht.
- Bestätigungslink: 24 Stunden, einmalig, nur der Hash.
- Öffentliches „Passwort vergessen“ antwortet immer gleich und geht nur an bereits verifizierte Adressen. Höchstens drei neue Links pro Konto und Stunde.
- Ein Admin kann für ein anderes Konto denselben Reset auslösen. Der Link geht an die hinterlegte primäre Adresse und an eine bereits verifizierte zweite Adresse. Der letzte aktive Admin kann weder deaktiviert noch auf `user` gesetzt werden.
- SMTP steht in der Server-`.env`. Fehlt sie, gibt es keinen stillen Versand. In `development` wird der Link nur ins Server-Log geschrieben. Der Mailtext enthält nur den Link und den Produktnamen, keinen Dialoginhalt.
## 2.2 Zwei-Faktor
**Status: Hypothese** (2026-10-12). Kein Schema, kein Endpunkt.
- App-TOTP nach dem Passwort und vor der Session. Kein SMS.
- Geheimnis und Wiederherstellungscodes bleiben lokal, Klasse A. Codes werden nur als Hash gespeichert und sind einmalig.
- Sind App und Wiederherstellungscodes verloren, setzt ein Admin den zweiten Faktor zurück. Das läuft nicht über ein Modell.
## 3. Session-Flow (übernommen) ## 3. Session-Flow (übernommen)
Herkunft: Mitai-Implementierung, als Muster. Herkunft: Mitai-Implementierung, als Muster.
@ -129,11 +104,6 @@ Keine Secrets, API-Keys oder Identity-Mappings im Frontend-Bundle.
| Session-Länge | ~30 Tage | bevorzugte Richtung | | Session-Länge | ~30 Tage | bevorzugte Richtung |
| Feineres Rollenmodell | später | offen | | Feineres Rollenmodell | später | offen |
| Token-Ablage Client | localStorage wie Mitai | bevorzugte Richtung | | Token-Ablage Client | localStorage wie Mitai | bevorzugte Richtung |
| Benutzername als zweite Login-Kennung | optional, ein Profil | entschieden (2026-10-12) |
| Zweite E-Mail | Kontakt, kein Login, verifiziert | entschieden (2026-10-12) |
| Reset-Link | 60 Minuten, einmalig, Hash | entschieden (2026-10-12) |
| Letzter aktiver Admin | nicht deaktivieren, nicht herabstufen | entschieden (2026-10-12) |
| Zwei-Faktor (App-TOTP) | vor der Session, lokal | Hypothese |
## 8. Offene Fragen ## 8. Offene Fragen

View File

@ -139,8 +139,6 @@ UI: `/admin/generation` mit getrennten Dimensionen, Karten und einem Editor nach
**Status: Code vorhanden.** `GET /api/admin/providers`, `PUT /api/admin/providers/{generate|detect}`. Session-Admin. Body darf einen write-only `key` enthalten; die Antwort enthält ihn nicht. Persistenz: URL/Modell/Flags in `provider_settings`, Key in `backend/.env`. **Status: Code vorhanden.** `GET /api/admin/providers`, `PUT /api/admin/providers/{generate|detect}`. Session-Admin. Body darf einen write-only `key` enthalten; die Antwort enthält ihn nicht. Persistenz: URL/Modell/Flags in `provider_settings`, Key in `backend/.env`.
**Additiv 2026-09-08:** `llm_profiles`. `POST /api/admin/providers/{role}/activate`, CRUD unter `/api/admin/llm-profiles`. Keys weiterhin nicht in der DB.
## 5.4 Implementierungsstand (Debug-Persistenz) ## 5.4 Implementierungsstand (Debug-Persistenz)
**Additiv 2026-08-28.** Session-Admin. Default aus. Nur eigenes Profil in Liste/Export. **Additiv 2026-08-28.** Session-Admin. Default aus. Nur eigenes Profil in Liste/Export.

View File

@ -137,7 +137,7 @@ Kanonisches Home **wie** der Slice gebaut ist: `mvp_implementation.md`. Fachlich
6. `../../work_orders/home_environment_setup.md` 6. `../../work_orders/home_environment_setup.md`
7. fachlich: `../functional/guardrails.md` (Datenklassen beim Kopieren von Backup und `.env`) 7. fachlich: `../functional/guardrails.md` (Datenklassen beim Kopieren von Backup und `.env`)
Arbeitsaufträge liegen unter `docs/work_orders/`. Sie ersetzen keine kanonischen Kapitel. Betrieb auf dem Pi: `docs/DEPLOYMENT.md`. Detect-Selbstlernen (Konzept 2026-09-11, Umsetzung ausstehend): `../../work_orders/detect_self_learning_pipeline.md`. Arbeitsaufträge liegen unter `docs/work_orders/`. Sie ersetzen keine kanonischen Kapitel. Betrieb auf dem Pi: `docs/DEPLOYMENT.md`.
## 4. Ladeprinzip ## 4. Ladeprinzip

View File

@ -179,7 +179,7 @@ Editor speichert Markdown, nicht HTML. Medien-Token bleiben lokal. Dirty: In-App
| Schicht | Rolle | Call | | Schicht | Rolle | Call |
|---|---|---| |---|---|---|
| Maskierung | `detect` | Vollständige semantische Detection des Generate-Egress. Externes Klartext-Detect in Development/Test; in Production default-off, optional Operator-Übergang `KANSHO_ALLOW_REMOTE_DETECT`. Ziel: lokales Detect-Modell. Kein Pattern-Fallback. Admin-Modus `semantic` (Default) oder `learning` (Übergang, Bestätigung vor Generate). | | Maskierung | `detect` | Vollständige semantische Detection des Generate-Egress. Externes Klartext-Detect in Development/Test; in Production default-off, optional Operator-Übergang `KANSHO_ALLOW_REMOTE_DETECT`. Ziel: lokales Detect-Modell. Kein Pattern-Fallback. |
| Dialogzug | `generate` | Operation + Impuls | | Dialogzug | `generate` | Operation + Impuls |
| Journalentwurf | `generate` | Explizit, getrennt | | Journalentwurf | `generate` | Explizit, getrennt |
@ -258,7 +258,7 @@ Additiv zum Slice, 2026-08-25. Kein Target-Model-Vorbau.
- Provenance von Draft und Entry-Version ist relational (`journal_*_source_refs`); JSON-ID-Listen werden verlustfrei migriert. Source-Messages bleiben unangetastet. - Provenance von Draft und Entry-Version ist relational (`journal_*_source_refs`); JSON-ID-Listen werden verlustfrei migriert. Source-Messages bleiben unangetastet.
- Context Builder spricht Selection-Specs; Recency bleibt die aktuelle Retrieval-Heuristik, nicht die fachliche API. - Context Builder spricht Selection-Specs; Recency bleibt die aktuelle Retrieval-Heuristik, nicht die fachliche API.
- Dialogzug kann begrenzte jüngere Original-Conversations desselben Space sehen, ohne denselben Journal Day voll zu laden. - Dialogzug kann begrenzte jüngere Original-Conversations desselben Space sehen, ohne denselben Journal Day voll zu laden.
- Externes Klartext-Detect ist kein Produktmodus (`KANSHO_ENV=production` ohne lokales Detect fail-closed). **Additiv 2026-09-08:** Operator darf mit `KANSHO_ALLOW_REMOTE_DETECT` den Übergang bis Ollama explizit öffnen; ohne Flag bleibt der Block. **Additiv 2026-09-08:** RFC1918-Ollama gilt als lokal; LLM-Profile speichern URL/Modell je Endpunkt. Lokales Detect bleibt austauschbare Rolle. Detect-Ausgabe persistiert keine aktive Identität. - Externes Klartext-Detect ist kein Produktmodus (`KANSHO_ENV=production` ohne lokales Detect fail-closed). **Additiv 2026-09-08:** Operator darf mit `KANSHO_ALLOW_REMOTE_DETECT` den Übergang bis Ollama explizit öffnen; ohne Flag bleibt der Block. Lokales Detect bleibt austauschbare Rolle. Detect-Ausgabe persistiert keine aktive Identität.
- Response Validation blockiert Klartext-Identität vor Demask. Dialog-Egress maskiert den ganzen Prompt inkl. Opening/Space-Kontext; ein Leak bricht den Zug nicht leer ab. Journal-Generate bricht bei einem Leak nach Retry nicht ab: lokales Quellenartefakt bzw. lokaler Entwurf, die leckende Modellantwort wird nicht verwendet. - Response Validation blockiert Klartext-Identität vor Demask. Dialog-Egress maskiert den ganzen Prompt inkl. Opening/Space-Kontext; ein Leak bricht den Zug nicht leer ab. Journal-Generate bricht bei einem Leak nach Retry nicht ab: lokales Quellenartefakt bzw. lokaler Entwurf, die leckende Modellantwort wird nicht verwendet.
- Konsolidierung nutzt lokale Signale, nicht `count >= 2`. - Konsolidierung nutzt lokale Signale, nicht `count >= 2`.
- `/dialog` ist Admin-Harness, nicht Produkt-IA. - `/dialog` ist Admin-Harness, nicht Produkt-IA.

View File

@ -124,7 +124,6 @@ Ziel wie Mitai: **Einfachheit in der Nutzer-UX, Differenzierung in administrierb
- UI-Katalog: welche Karten/Nav-Beiträge sichtbar, Layout-Overrides - UI-Katalog: welche Karten/Nav-Beiträge sichtbar, Layout-Overrides
- System-Prompts vs. Instanz-Anpassungen (Reset auf Default) - System-Prompts vs. Instanz-Anpassungen (Reset auf Default)
- Provider-URL, Modell und ZDR/No-Train für Generate und Detect (`provider_settings`, Admin Schnittstellen) - Provider-URL, Modell und ZDR/No-Train für Generate und Detect (`provider_settings`, Admin Schnittstellen)
- **Additiv 2026-09-08:** benannte LLM-Profile (`llm_profiles`); aktive Zuordnung je Stufe. Secrets bleiben Env.
- später: Privacy-Profile (Strict/Balanced), sobald fachlich konkret - später: Privacy-Profile (Strict/Balanced), sobald fachlich konkret
### 4.2 Code / Env (nicht pro Klick im Admin) ### 4.2 Code / Env (nicht pro Klick im Admin)

View File

@ -239,7 +239,7 @@ Bestätigte Registry-Zeilen und bestätigte Aliase werden im gesamten gerenderte
### Tests und Live-Qualität ### Tests und Live-Qualität
Contract-Tests: `backend/tests/test_privacy_detect.py`, `backend/tests/test_identity_registry.py`, `backend/tests/test_privacy_response_integrity.py`, `backend/tests/test_detect_contract_retry.py`, `backend/tests/test_detect_learning.py`. Sie beweisen Schema, Fail-closed, span-genaue Maskierung, Lernmodus-Pause und Datenfluss, nicht semantische Modellleistung. Opt-in: `python entity_detect_eval.py --live` mit synthetischen Sätzen und exakten erwarteten Spans. Ohne diesen Lauf bleibt die Live-Qualität unbestätigt. Das aktuell konfigurierte `openai/gpt-4.1-nano` gilt durch reale False-Positive-Vorschläge qualitativ nicht als zuverlässig bestätigt; das Modell wird deshalb nicht stillschweigend gewechselt. Contract-Tests: `backend/tests/test_privacy_detect.py`, `backend/tests/test_identity_registry.py`, `backend/tests/test_privacy_response_integrity.py`, `backend/tests/test_detect_contract_retry.py`. Sie beweisen Schema, Fail-closed, span-genaue Maskierung und Datenfluss, nicht semantische Modellleistung. Opt-in: `python entity_detect_eval.py --live` mit synthetischen Sätzen und exakten erwarteten Spans. Ohne diesen Lauf bleibt die Live-Qualität unbestätigt. Das aktuell konfigurierte `openai/gpt-4.1-nano` gilt durch reale False-Positive-Vorschläge qualitativ nicht als zuverlässig bestätigt; das Modell wird deshalb nicht stillschweigend gewechselt.
## 9.6 Übergang: `KANSHO_ALLOW_REMOTE_DETECT` (2026-09-08) ## 9.6 Übergang: `KANSHO_ALLOW_REMOTE_DETECT` (2026-09-08)
@ -249,35 +249,7 @@ Default in `KANSHO_ENV=production`: externes Klartext-Detect bleibt fail-closed
Operator-Übergang bis Ollama: `KANSHO_ALLOW_REMOTE_DETECT=1` (Compose `.env`, Container neu anlegen). Das Gateway, die Pflicht-Detection vor Generate, ZDR/No-Train für Generate und die Key-Trennung bleiben. Admin kann das Flag nicht setzen. URL und Modell je Stufe bleiben in `provider_settings`. Operator-Übergang bis Ollama: `KANSHO_ALLOW_REMOTE_DETECT=1` (Compose `.env`, Container neu anlegen). Das Gateway, die Pflicht-Detection vor Generate, ZDR/No-Train für Generate und die Key-Trennung bleiben. Admin kann das Flag nicht setzen. URL und Modell je Stufe bleiben in `provider_settings`.
**Additiv 2026-09-08 (LLM-Profile, LAN-Ollama):** `llm_profiles` speichert benannte URL/Modell-Presets ohne Keys. Jede Stufe (`generate` / `detect`) zeigt auf ein Profil; Wechsel kopiert die gespeicherten Felder, statt sie neu einzugeben. RFC1918-, Loopback- und `.local`-URLs gelten als lokale Trusted Zone. `http://192.168.2.144:11434/v1/chat/completions` ist damit lokales Detect, kein externes Klartext-Detect. Ollama muss auf dem Host auf `0.0.0.0:11434` lauschen; der Pi muss Port 11434 erreichen. Status in `GET /api/admin/providers`: `remote_plaintext_allowed`, `remote_plaintext_reason` (`non_production` | `operator_override` | `blocked`). Modellauswahl: `GET /api/admin/providers/models` (Katalog, soft-fail).
**Additiv 2026-09-08 (lokales Detect, Timeouts):** CPU-Ollama braucht für denselben Detect-Prompt deutlich länger als OpenRouter. Remote-Detect bleibt 90s / 1024 Tokens. Lokales Detect wartet 300s und begrenzt auf 256 Tokens. Live-TLS endet auf dem Synology-Reverse-Proxy (`192.168.2.63`, DSM-Default 60s). Dort Proxy-Lese- und -Sende-Timeout 600s setzen, sonst sieht der Browser HTTP 504, während das Backend weiterläuft. Ein Dialogzug kann danach lokal einen Halte-Impuls gespeichert haben. Die Compose-Frontend-Nginx auf Port 3096 hat bereits 600s.
Status in `GET /api/admin/providers`: `remote_plaintext_allowed`, `remote_plaintext_reason` (`non_production` | `operator_override` | `blocked`), `profiles`, `detect_operating_mode` (`semantic` | `learning`). Modellauswahl: `GET /api/admin/providers/models` (OpenRouter-Katalog oder Ollama `/api/tags`, soft-fail).
**Additiv 2026-09-11:** `detect_operating_mode` erweitert, siehe §9.7. Die Werte `semantic` und `learning` bleiben als Alias lesbar (`bypass` / `learning_full`).
## 9.7 Übergang: Detect-Lernmodus (2026-09-08)
Additiv. Fachliches Home: `../functional/guardrails.md` §22.4. Ersetzt weder semantische Detection noch die bestätigte Registry.
Default bleibt `semantic`: Detect läuft wie bisher, Generate folgt ohne Pause. `learning` ist ein Admin-Schalter (`PUT /api/admin/providers/detect-mode`), kein Gateway-Bypass und keine Wortlisten-UI.
Im Lernmodus untersucht Detect weiterhin den vollen gerenderten Generate-Egress. Bevor Generate startet, werden `request_local`-Spans im aktuellen Nutzersatz zur Bestätigung angeboten (Dialog und Journal-Gespräch, nicht Journal-Generate). Zusätzlich: Nennungen nach Verwandtschaftswörtern der bestehenden Identitätsregel (`Frau`, `Sohn`, …) im Nutzersatz, auch wenn Detect sie als Gericht weglässt oder nicht meldet. Andere Großschreibung (`Restaurant`) wird dadurch nicht angeboten. „Identität“ bestätigt die lokale Registry und zählt einen Identitätssinn. „Nicht schützenswert“ maskiert diese Nennung nicht und zählt den anderen Sinn. Detect-Ausgabe allein speichert weiterhin keine aktive Identität.
Es gibt keine separat zu pflegende Doppeldeutigkeitsliste. Mehrdeutigkeit entsteht, wenn dieselbe Schreibweise beide Sinne hat. Nur dann darf ein **lokales** Detect-Modell eine Mini-Passage (Ausschnitt um die Nennung) entscheiden. Fehlt ein lokales Modell oder ist die Antwort unbrauchbar, bleibt das Popup. OpenRouter erhält diese Passage nicht.
**Additiv 2026-09-10:** Bestätigungen speichern den lokalen Cue und den Ausschnitt in `label_sense_cues`. Cue ist die enge Bindung in der Phrase (`Frau Sushi`, `Sushi, meine Frau`), sonst das Wort unmittelbar davor — nicht jedes Verwandtschaftswort irgendwo im Satz, damit Homonyme (`Frau Sushi` / Speise) getrennt bleiben.
**Additiv 2026-09-11:** Lernmodus kann LAN-GLiNER (`192.168.2.144:8090`, `POST /v1/extract`) auf den Nutzersatz anwenden. Settings: `gliner_enabled`, `gliner_url`, `passage_profile_id` in `app_settings`, nicht in `provider_settings.detect`. Cues überschreiben GLiNER. Mini-Passagen gehen an das gewählte LLM-Profil. Detect-OpenRouter bleibt die Egress-Maskierung. Kein Klartext in GLiNER-Logs seitens Kanshō. Tests: `backend/tests/test_gliner_client.py`, `backend/tests/test_detect_learning.py`.
**Additiv 2026-09-11 (Selbstlern-Pipeline, Konzept):** Reihenfolge umkehren und zwei Lernstufen: `../../work_orders/detect_self_learning_pipeline.md`. Ist-Code bleibt OpenRouter-Detect zuerst, bis die Umsetzung nach Finalisierung von §6 dort folgt.
**Additiv 2026-09-11 (Selbstlern-Pipeline, Umsetzung nach §6):** `detect_operating_mode` ist `student` | `learning_full` | `learning_reduced` | `learning_manual` | `bypass`. Aliase: `learning` → `learning_full`, `semantic` → `bypass`. Default im Produkt: `learning_full`. Schülerpfad in `detect_pipeline.run_student_path`. Lehrer Stufe 1 = unveränderter Nutzersatz, Stufe 2 = ohne cue-sichere Oberflächenformen. Vergleich nur User-Spans; Compact-Zähler `diff_agree`, `diff_only_student`, `diff_only_teacher`, `diff_type_conflict`, `diff_new_identity`, `diff_cue_reinforced` ohne Labels. Journalentwurf pausiert über `POST /api/journal/days/{id}/generate/review` und dieselbe Review-UI. Bypass bleibt der alte Detect auf dem gerenderten Prompt.
**Additiv 2026-10-12:** `compare_mentions` zählt `diff_type_conflict` auch bei bestätigtem Cue (FOOD gegen PERSON), öffnet dafür aber kein zweites Popup. Cue bleibt. Unbestätigte Typkonflikte bleiben Review-Kandidaten.
Nach der Bestätigung läuft Generate mit den geprüften Mappings (`precomputed_learning_review`), ohne zweiten Detect-Pass. Compact-Diagnose enthält weiterhin keine Klartextlabels. Tests: `backend/tests/test_detect_learning.py`.
## 10. Offene Fragen ## 10. Offene Fragen
@ -285,8 +257,6 @@ Nach der Bestätigung läuft Generate mit den geprüften Mappings (`precomputed_
**Additiv 2026-08-28:** Wie sollen neue, vom Detect verfehlte und lokal nicht bestätigte Identitäten vor dem Egress behandelt werden, ohne span-genaue Homonyme oder Allgemeinbegriffe dauerhaft zu maskieren? Optionen ohne Vorentscheidung: fail-closed vor Generate, Review der Detect-Vorschläge vor Egress, oder ein zweites lokales Verfahren. Keine Heuristik auf Verdacht. **Additiv 2026-08-28:** Wie sollen neue, vom Detect verfehlte und lokal nicht bestätigte Identitäten vor dem Egress behandelt werden, ohne span-genaue Homonyme oder Allgemeinbegriffe dauerhaft zu maskieren? Optionen ohne Vorentscheidung: fail-closed vor Generate, Review der Detect-Vorschläge vor Egress, oder ein zweites lokales Verfahren. Keine Heuristik auf Verdacht.
**Additiv 2026-09-08:** Der Detect-Lernmodus ist eine Übergangslösung für die Review-Option, nicht der Endzustand. Semantische Detection bleibt Pflicht. Unbekannte Namen sind nicht deshalb harmlos, weil sie nicht auf einer Liste stehen.
## 11. Querverweise ## 11. Querverweise
- Fachlich: `../functional/guardrails.md` - Fachlich: `../functional/guardrails.md`

View File

@ -85,8 +85,6 @@ merge --no-ff develop in main → push main → deploy-prod.yml
**Additiv 2026-09-07 (Qualitätssystem):** Backend-Runner ist pytest (`backend/pytest.ini`, `backend/tests/conftest.py`). Gitea startet die Suite erst nach erfolgreichem Dev-Deploy (`workflow_run` auf `Deploy Development`), nicht parallel zum Image-Build. Destruktive Tests bleiben auf `kansho_test`. Zusätzlich ein nicht-schreibender Smoke gegen die laufende Dev-API (`kansho_dev`, nur `/api/health`). Öffentliche URLs nach Host-Nginx/TLS: `https://dev.kansho.jinkendo.de` und `https://kansho.jinkendo.de`. LAN-Ports 3096/8096 und 3006/8005 bleiben die Compose-Publish-Ziele. **Additiv 2026-09-07 (Qualitätssystem):** Backend-Runner ist pytest (`backend/pytest.ini`, `backend/tests/conftest.py`). Gitea startet die Suite erst nach erfolgreichem Dev-Deploy (`workflow_run` auf `Deploy Development`), nicht parallel zum Image-Build. Destruktive Tests bleiben auf `kansho_test`. Zusätzlich ein nicht-schreibender Smoke gegen die laufende Dev-API (`kansho_dev`, nur `/api/health`). Öffentliche URLs nach Host-Nginx/TLS: `https://dev.kansho.jinkendo.de` und `https://kansho.jinkendo.de`. LAN-Ports 3096/8096 und 3006/8005 bleiben die Compose-Publish-Ziele.
**Additiv 2026-09-08 (TLS-Hop):** DNS für `*.kansho.jinkendo.de` zeigt auf das Synology-NAS `192.168.2.63`. Der Pi hat kein Host-HTTPS auf 443. DSM Reverse Proxy: Proxy-Lese-/Sende-Timeout 600s; Default 60s bricht lokale Detect-Läufe mit HTTP 504 ab. Ziel bleibt der Pi (`3096`/`8096` Dev, `3006`/`8005` Prod). Repo-Dateien `nginx/kansho.conf` und `nginx/kansho-dev.conf` beschreiben denselben Timeout, falls TLS später auf dem Pi endet.
Kanshō-Repo liegt auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichtet. Der Pi-Runner (`ubuntu-latest`) ist derselbe wie bei Mitai/Shinkan/Kairo. Kanshō-Repo liegt auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichtet. Der Pi-Runner (`ubuntu-latest`) ist derselbe wie bei Mitai/Shinkan/Kairo.
## 5. Was Deploy nicht übernimmt ## 5. Was Deploy nicht übernimmt

View File

@ -21,8 +21,6 @@ Kanonisches Home für Zugriffssicherheit des Mitai-Rahmens (Auth, TLS, Secrets,
- Session-Invalidierung beim Logout. - Session-Invalidierung beim Logout.
- Kein IDOR über Client-gewähltes Profil (`auth_identity_and_roles.md`). - Kein IDOR über Client-gewähltes Profil (`auth_identity_and_roles.md`).
**Additiv 2026-10-12.** Reset- und Bestätigungstoken werden nur als SHA-256-Hash gespeichert. SMTP ist Betriebsmail und Fail Closed: ohne Konfiguration kein stiller Versand. In `development` erscheint der Link nur im Server-Log, nie in der API-Antwort. Details: `auth_identity_and_roles.md` §2.1.
## 2. Kanshō-spezifisch ## 2. Kanshō-spezifisch
- Provider-Keys für LLM nur hinter dem Gateway, mit Policy-Check vor Verwendung. - Provider-Keys für LLM nur hinter dem Gateway, mit Policy-Check vor Verwendung.

View File

@ -1,177 +0,0 @@
---
title: "Kanshō – Selbstlernende Detect-Pipeline (Konzept und Anforderung)"
status: "Konzept / Arbeitsauftrag — §6 finalisiert 2026-09-11"
date: "2026-09-11"
document_role: "Cursor Implementation Brief / Concept Record"
canonical_functional: "docs/architecture/functional/guardrails.md §22.4"
canonical_technical: "docs/architecture/technical/privacy_gateway.md §9.7"
---
# Selbstlernende Detect-Pipeline
Dieses Dokument ist das Home für das **Zielbild 2026-09-11**. Es ersetzt weder `guardrails.md` noch `privacy_gateway.md`. Statuswörter ernst nehmen. §6 wurde am 2026-09-11 mit dem Nutzer im Planmodus geschlossen; die Umsetzung folgt diesen Entscheidungen.
Herkunft: explizite Nutzeranforderung und gemeinsame Bewertung in der Konzeptsession 2026-09-11.
## 1. Ziel
Ein **selbstlernender Schutz** pro Profil, der über Zeit immer weniger persönliche Klarformen an ein externes LLM gibt und dabei verlässlicher wird.
GLiNER findet neue Spannen. Kanshō entscheidet und merkt (Sinne, Cues, Registry). OpenRouter ist Lehrer und Lückenfüller, nicht Alltag und nicht Identitätsquelle. GLiNER-Gewichte werden zur Laufzeit nicht nachtrainiert; ein gemeinsames Jinkendo-NER-Projekt ist ein späterer, getrennter Dienst (stateless, keine gemeinsame Identität).
Leitbeispiel: Lange Zeit bedeutet „Sushi“ die Partnerin. Am Tag 21 ist „Sushi“ Essen. Ein Lexem-Lock auf Identität und ein Lehrer, der nur noch Platzhalter sieht, machen das Essen unsichtbar. Das darf nicht passieren.
## 2. Entscheidungsstand
### Entschieden
- Identität bleibt lokal. Klasse-A-Mapping, Klarnamen und Registry gehen nicht an Provider.
- Keine Wortlisten-UI. Sinne entstehen aus Bestätigungen (Identität / nicht schützenswert).
- OpenRouter darf Identitäten nicht stillschweigend anlegen oder löschen. Der Mensch entscheidet über neue Identität und über Abweichungen.
- Cue-Treffer gelten nur für **dieses Vorkommen**, nicht für die Schreibweise insgesamt.
- Nur Identity-Hits ohne Non-Identity dürfen einen **neuen** Cue nicht festnageln (Sushi-Tag-21).
- Schülerpfad-Reihenfolge: lokal cue-aufgelöste Vorkommen → unklare Homonyme als enger Ausschnitt an das Passage-LLM → GLiNER auf den Rest → Generate mit gebauten Mappings.
- GLiNER sieht die Originalzeile mit übersprungenen, bereits aufgelösten Offsets, nicht den schon ersetzten Platzhaltertext.
- Zwei Lernstufen. Umschalten ist eine Admin-Entscheidung, kein Autopilot.
- **Stufe 1 (vollständig):** Kontrollaufruf auf den **unveränderten Nutzersatz** (keine Ersetzungen), parallel der Schülerpfad, Vergleich, bei Uneinigkeit oder neuer Identität Popup, dann lernen.
- **Stufe 2:** Lehrer sieht keine **cue-sicheren** Vorkommen mehr. Dieselbe Vergleichslogik. Am Anfang fehleranfälliger; deshalb nach Stufe 1, nicht als Start.
- Lehrer-Slice ist der Nutzersatz, nicht der gerenderte Detect-Prompt (kein Mitnehmen von Biografie/Kontext, den GLiNER nicht sieht).
- Übereinstimmung plus bereits bestätigtes Label: Cue verstärken, kein Popup.
- GLiNER nicht als alleiniges Detect in die Detect-URL schreiben. Detect-OpenRouter bleibt konfigurierbar.
- Kein Gateway-Bypass, kein Feature-Flag zum Abschalten der Maskierung.
- Bestehenden GLiNER-Client, Local-NER-Settings, Cues und Review-UI wiederverwenden, nicht wegwerfen; **umverdrahten**.
- **Startbetrieb:** Lernmodus Stufe 1. Schüler (Cues → Verwandtschaft → Homonym-Mini-LLM bei beiden Sinnen → GLiNER) und Lehrer (unveränderter Nutzersatz) parallel. Diff zum Nutzer. Geprüftes Ergebnis maskiert Generate.
- **Admin:** Zielpfad (`student`) + Lernen automatisch Stufe 1 (`learning_full`) + Lernen automatisch Stufe 2 (`learning_reduced`) + Lernen manuell (`learning_manual`) + Bypass (`bypass`, alter OpenRouter-Voll-Detect). Bypass entfällt mittelfristig. Default nach Umbau: `learning_full`. Aliase: `learning` → `learning_full`, `semantic` → `bypass`.
- **Stufe 2** ist im ersten Schnitt vollständig (Lehrer ohne cue-sichere Oberflächenformen).
- **Lehrer weg:** alle Schüler-/GLiNER-Funde zur Bestätigung (gleicher Weg wie manuell).
- **GLiNER weg:** im Lernmodus manuelle Review nach lokaler Vorauswahl. Außerhalb Lernmodus: Nutzer bestätigt Klartext-Egress an das öffentliche LLM.
- **Verwandtschaft** bleibt eigener lokaler Schüler-Schritt vor GLiNER.
- **Vergleich:** gleiche Nennung = gleicher Wortlaut an derselben Stelle oder eine Spanne vollständig in der anderen. Typen `person→PERSON` usw.; ähnliche schützenswerte Typen gleich; FOOD gegen PERSON immer Diff. Nur bei abweichendem Schutz fragen; gleicher Schutz, anderer Zuschnitt → längere Spanne. Nutzer entscheidet Speise/Person; Cue nicht still ändern; Lehrer-Miss ohne Meldung → kein Popup, Cue bleibt. **Additiv 2026-10-12:** Bestätigter Cue bei Typkonflikt: zählen, nicht erneut fragen, Cue bleibt. Unbestätigter Typkonflikt bleibt Popup.
- **Diagnose:** Zähler ohne Klartext (Einigkeit, nur Schüler, nur Lehrer, Typkonflikt, neue Identität, Cue verstärkt). Keine Langeweile-Kennzahl; Stufe nur per Schalter.
- **Mini-LLM** nur wenn beide Sinne schon existieren, auch in Stufe 1.
- **Geltungsbereich:** alle persönlichen Detect-Aufrufe (Security-Gateway), einschließlich Journalentwurf.
### Bevorzugte Richtung
- Nach ausreichend langweiliger Abweichung in Stufe 1: Stufe 2, später Betrieb ohne Kontrollaufruf (Schülerpfad allein).
- Diagnostik zeigt Diff-Art (nur Schüler / nur Lehrer / Typkonflikt), ohne Klartextlabels in Compact-Exports.
- Fragment-Ausschnitte so eng, dass fremde noch unmaskierte Identitäten nicht mitgehen.
- Eigenes Jinkendo-Local-Detect als späteres LAN-Projekt (Text rein, Spannen raus, kein Speicher). Nicht in diesem Slice.
### Verworfen
- GLiNER oder OpenRouter als alleiniges Detect.
- Lexemweites „einmal Person, immer Person“.
- Lehrer auf bereits komplett vormaskiertem Text in Stufe 1.
- Online-Fine-Tune von GLiNER auf Journaltext.
- Gemeinsame Namensliste über Jinkendo-Produkte.
- Detect-Treffer ohne Nutzer als aktive Identität speichern.
### Offen
Keine offenen Punkte aus der Planmodus-Runde 2026-09-11. Frühere §6-Fragen sind unter Entschieden festgehalten.
## 3. Ziel-Workflow
```
Nutzersatz
│
├─ Schüler (immer, sobald dieser Pfad gilt)
│ 1. Cue-aufgelöste Vorkommen lokal binden
│ (Identität maskieren / Nicht-Identität sperren)
│ 2. Unklare Homonyme: enger Ausschnitt → Passage-LLM
│ 3. Rest → GLiNER (Skip bereits gebundener Spannen)
│ 4. Mappings für Generate
│
└─ Lehrer (nur Lernmodus)
Stufe 1: unveränderter Nutzersatz → OpenRouter-Detect
Stufe 2: Nutzersatz ohne cue-sichere Oberflächenformen
Vergleich Schüler ↔ Lehrer
Uneinigkeit / neue Identität → Nutzer → Cue/Sinn
Einigkeit + bekanntes Label → Cue verstärken
```
Generate folgt auf geprüfte bzw. schülerseitig gebaute Mappings, ohne zweiten Voll-Detect.
## 4. Ist-Stand (Code, 2026-09-11)
Nicht mit dem Zielbild verwechseln. Älterer Stand vor der Umsetzung nach §6:
- `_learning_pause` hing OpenRouter-Detect an den **gerenderten** Prompt, dann Verwandtschaft, GLiNER, Cues, Passage, Popup.
- `decision_for_mention` nagelte bei nur Identity-Hits jeden neuen Cue fest (Tag-21).
- Admin: `semantic` | `learning`.
**Additiv nach Umsetzung §6:** Schülerpfad in `detect_pipeline.py`, Lehrer auf dem Nutzersatz, Admin-Modi `student` | `learning_full` | `learning_reduced` | `learning_manual` | `bypass`. Tabellen `label_senses`, `label_sense_cues`, `pending_mask_reviews` bleiben die Lernspeicher.
## 5. Anforderungen an die Umsetzung (nach Finalisierung)
1. Policy: `decision_for_mention` so ändern, dass ein neuer Cue bei nur einem Sinn **sichtbar bleibt** (nicht automatisch binden). Cue-Hit bleibt bindend.
2. Schülerpfad als eigene, testbare Funktion; `_learning_pause` und später `semantic` hängen daran, sobald §6 das festlegt.
3. Lehrer Stufe 1: Detect-Schema auf dem Nutzersatz, Offsets vergleichbar machen (GLiNER `person|place|…` ↔ Detect `PERSON|…`).
4. Diff nur auf User-Spans; Lehrer-Miss eines bereits nutzerbestätigten Cues nicht automatisch verlernen.
5. Popup über vorhandenes `MaskReviewPanel`, Inhalt = Diff und neue Identitätskandidaten, nicht jedes schon gelöste Cue-Vorkommen.
6. Admin: zwei Lernstufen wählbar (konkretes Schaltermodell laut §6).
7. Tests: Tag-21-Sushi (Identity-Hits, Cue `Frau` vs. neuer Cue „essen“); Diff-Fälle; kein Klartext in Compact-Diagnose; Local-URL-Policy für GLiNER bleibt.
8. Docs additiv in `guardrails.md` §22.4, `privacy_gateway.md` §9.7, `admin_diagnostics.md`. Bestehende Absätze nicht kürzen. Dieses Work-Order-Dokument als Konzept-Home belassen, bis der Code dem Zielbild entspricht.
9. Kein Commit/Push, unless explicitly requested.
Nicht in diesem Slice: Fine-Tune, neues NER-Repo, Wortlisten-Editor, Shared-Identity. Journal-Generate-Pause ist durch §6.9 **doch** im Schnitt (alle persönlichen Detect-Aufrufe).
## 6. Offene Punkte — geschlossen 2026-09-11
1. **Start ist Stufe 1 Lernen**, nicht Schüler-allein und nicht der alte `semantic`-Alltag. Schüler und Lehrer-Klartext (Nutzersatz) parallel, Diff zum Nutzer, dann Generate.
2. **Admin:** Zielpfad + Lernen (automatisch 1 / automatisch 2 / manuell) + Bypass. Bypass mittelfristig weg. Default: `learning_full`.
3. **Stufe 2 vollständig** im ersten Schnitt.
4. **Lehrer weg** → manuelle Review aller GLiNER-/Schülerfunde. **GLiNER weg** → im Lernmodus manuell nach Vorauswahl; sonst Bestätigung vor öffentlichem Klartext-Egress.
5. **Verwandtschaft bleibt** eigener lokaler Schritt vor GLiNER.
6. **Vergleich:** enthaltene Spanne / gleicher Wortlaut; Mapping inkl. FOOD≠PERSON; nur abweichender Schutz fragt; Cue nicht still ändern; Lehrer-Miss ohne Meldung kein Popup. Bestätigter Cue bei Typkonflikt zählt, fragt aber nicht erneut.
7. **Diagnose:** Zähler ohne Klartext, keine Langeweile-Kennzahl.
8. **Mini-LLM** nur bei schon beiden Sinnen, auch in Stufe 1.
9. **Alle persönlichen Detect-Aufrufe**, einschließlich Journalentwurf.
## 7. Context Bundle für die Umsetzungs-Session
1. Diese Datei.
2. `docs/architecture/functional/documentation_index.md`
3. `docs/architecture/functional/guardrails.md` (§22.4, Invarianten)
4. `docs/architecture/technical/privacy_gateway.md` (§9.7)
5. `docs/architecture/technical/admin_diagnostics.md`
6. Code: `backend/detect_learning.py`, `backend/dialogue_turn.py`, `backend/gliner_client.py`, `frontend/src/pages/AdminProvidersPage.jsx`, `frontend/src/components/MaskReviewPanel.jsx`, `backend/tests/test_detect_learning.py`
Nicht den ganzen `docs/`-Ordner laden. Dialog-/MVP-Fachstand nicht stillschweigend festlegen.
## 8. Prompt für die neue Session
Den folgenden Block unverändert in eine **neue** Cursor-Session mit frischem Kontextfenster einfügen.
```
Arbeite im Repo C:\Dev\Kansho. Sprache: Deutsch.
Zuerst in den Planmodus wechseln (SwitchMode → plan). Noch keinen Code schreiben, keine Policy umbauen, nicht committen.
Auftrag: Selbstlernende Detect-Pipeline gemäß
docs/work_orders/detect_self_learning_pipeline.md
Das Dokument ist Konzept-Home. Es ersetzt guardrails.md / privacy_gateway.md nicht. Der Code hängt GLiNER und Cues noch hinter OpenRouter-Detect; decision_for_mention nagelt bei nur Identity-Hits jeden neuen Cue fest. Beides soll weg, aber erst nach Finalisierung der offenen Punkte.
Sofort laden (nur dieses Bundle):
- docs/work_orders/detect_self_learning_pipeline.md (vollständig, besonders §2, §3, §6)
- docs/architecture/functional/documentation_index.md
- docs/architecture/functional/guardrails.md (§22.4 und Invarianten)
- docs/architecture/technical/privacy_gateway.md (§9.7)
- Ist-Code: backend/dialogue_turn.py (_learning_pause), backend/detect_learning.py (decision_for_mention, apply_gliner_if_enabled, auto_resolve_ambiguous)
Danach mit mir im Planmodus §6 der Work-Order Punkt für Punkt schließen. Nicht raten. Nicht „Bevorzugte Richtung“ als Entschieden behandeln. Erst wenn §6 schriftlich entschieden ist, einen knappen Umsetzungsplan vorschlagen (erster Schnitt vs. später). Umsetzung erst nach meiner Freigabe, dann Agent-Modus.
Invarianten, die nicht zur Disposition stehen:
- Identität lokal; kein stilles Identity aus OpenRouter/GLiNER
- keine Wortliste; Sinne/Cues aus Bestätigungen
- Cue gilt pro Vorkommen, nicht lexemweit (Sushi-Tag-21)
- Stufe-1-Lehrer sieht den unveränderten Nutzersatz
- GLiNER nicht als alleiniges Detect in die Detect-URL
- Gateway nicht abschaltbar
- bestehenden GLiNER-Client/Cues/Review-UI umverdrahten, nicht löschen
- Docs additiv; kanonische Kapitel nicht kürzen
Zielbild in einem Satz: Schülerpfad lokal zuerst, Lehrer nur im Lernmodus, zwei Stufen, Mensch bei Diff und neuer Identität.
```

View File

@ -1,5 +1,5 @@
import { NavLink, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom' import { NavLink, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'
import { LogOut, Settings } from 'lucide-react' import { LogOut } from 'lucide-react'
import { useAuth } from './context/AuthContext.jsx' import { useAuth } from './context/AuthContext.jsx'
import { getMainNavItems } from './config/appNav.js' import { getMainNavItems } from './config/appNav.js'
import LoginScreen from './pages/LoginScreen.jsx' import LoginScreen from './pages/LoginScreen.jsx'
@ -13,9 +13,6 @@ import JournalDayPage from './pages/JournalDayPage.jsx'
import JournalEditorPage from './pages/JournalEditorPage.jsx' import JournalEditorPage from './pages/JournalEditorPage.jsx'
import JournalSourcePage from './pages/JournalSourcePage.jsx' import JournalSourcePage from './pages/JournalSourcePage.jsx'
import SettingsPage from './pages/SettingsPage.jsx' import SettingsPage from './pages/SettingsPage.jsx'
import ForgotPasswordPage from './pages/ForgotPasswordPage.jsx'
import ResetPasswordPage from './pages/ResetPasswordPage.jsx'
import VerifyEmailPage from './pages/VerifyEmailPage.jsx'
import AdminHomePage from './pages/AdminHomePage.jsx' import AdminHomePage from './pages/AdminHomePage.jsx'
import AdminUsersPage from './pages/AdminUsersPage.jsx' import AdminUsersPage from './pages/AdminUsersPage.jsx'
import AdminPromptsPage from './pages/AdminPromptsPage.jsx' import AdminPromptsPage from './pages/AdminPromptsPage.jsx'
@ -36,10 +33,8 @@ function navItemActive(pathname, item, routerIsActive) {
return routerIsActive return routerIsActive
} }
function BottomNav({ isAdmin, limited }) { function BottomNav({ isAdmin }) {
const items = limited const items = getMainNavItems(isAdmin)
? [{ to: '/settings', label: 'Einstellungen', icon: Settings }]
: getMainNavItems(isAdmin)
const loc = useLocation() const loc = useLocation()
return ( return (
<nav className="bottom-nav"> <nav className="bottom-nav">
@ -63,10 +58,8 @@ function BottomNav({ isAdmin, limited }) {
) )
} }
function DesktopSidebar({ isAdmin, onLogout, name, limited }) { function DesktopSidebar({ isAdmin, onLogout, name }) {
const items = limited const items = getMainNavItems(isAdmin)
? [{ to: '/settings', label: 'Einstellungen', icon: Settings }]
: getMainNavItems(isAdmin)
const loc = useLocation() const loc = useLocation()
return ( return (
<aside className="desktop-sidebar"> <aside className="desktop-sidebar">
@ -99,7 +92,7 @@ function DesktopSidebar({ isAdmin, onLogout, name, limited }) {
) )
} }
function AppShell({ limited = false }) { function AppShell() {
const { session, isAdmin, logout } = useAuth() const { session, isAdmin, logout } = useAuth()
const { confirmLeave } = useUnsavedChanges() const { confirmLeave } = useUnsavedChanges()
const onLogout = () => { const onLogout = () => {
@ -108,19 +101,16 @@ function AppShell({ limited = false }) {
} }
return ( return (
<div className="app-shell"> <div className="app-shell">
<DesktopSidebar isAdmin={isAdmin} onLogout={onLogout} name={session?.profile?.name} limited={limited} /> <DesktopSidebar isAdmin={isAdmin} onLogout={onLogout} name={session?.profile?.name} />
<div className="app-main"> <div className="app-main">
<header className="mobile-header"> <header className="mobile-header">
<strong>Kanshō</strong> <strong>Kanshō</strong>
<button type="button" className="ghost" onClick={onLogout}>Abmelden</button> <button type="button" className="ghost" onClick={onLogout}>Abmelden</button>
</header> </header>
<main className="page"> <main className="page">
{limited && (
<p className="error">Die E-Mail-Adresse ist noch nicht bestätigt. Bis dahin bleibt nur der Kontobereich offen.</p>
)}
<Outlet /> <Outlet />
</main> </main>
<BottomNav isAdmin={isAdmin} limited={limited} /> <BottomNav isAdmin={isAdmin} />
</div> </div>
</div> </div>
) )
@ -136,24 +126,13 @@ export default function App() {
return <SetupScreen /> return <SetupScreen />
} }
if (!session) { if (!session) {
return ( return <LoginScreen />
<Routes>
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="*" element={<LoginScreen />} />
</Routes>
)
} }
const verified = Boolean(session.profile?.email_verified_at)
return ( return (
<UnsavedProvider> <UnsavedProvider>
<Routes> <Routes>
<Route element={<AppShell limited={!verified} />}> <Route element={<AppShell />}>
{verified ? (
<>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/journal" element={<JournalSpacesPage />} /> <Route path="/journal" element={<JournalSpacesPage />} />
<Route path="/journal/:spaceId" element={<JournalSpacePage />} /> <Route path="/journal/:spaceId" element={<JournalSpacePage />} />
@ -162,9 +141,6 @@ export default function App() {
<Route path="/journal/:spaceId/:dayId/entry" element={<JournalEditorPage />} /> <Route path="/journal/:spaceId/:dayId/entry" element={<JournalEditorPage />} />
<Route path="/journal/:spaceId/:dayId/source/:conversationId" element={<JournalSourcePage />} /> <Route path="/journal/:spaceId/:dayId/source/:conversationId" element={<JournalSourcePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route element={<RequireAdmin />}> <Route element={<RequireAdmin />}>
<Route path="/dialog" element={<DialoguePage />} /> <Route path="/dialog" element={<DialoguePage />} />
<Route path="/admin" element={<AdminShell />}> <Route path="/admin" element={<AdminShell />}>
@ -180,16 +156,8 @@ export default function App() {
<Route path="debug" element={<AdminDebugPage />} /> <Route path="debug" element={<AdminDebugPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} />
</>
) : (
<>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</>
)}
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</UnsavedProvider> </UnsavedProvider>
) )

View File

@ -687,34 +687,3 @@ pre.code {
.runlog-list li.bad { border-color: #e3c0c0; } .runlog-list li.bad { border-color: #e3c0c0; }
.runlog-trace { margin-top: 0.8rem; } .runlog-trace { margin-top: 0.8rem; }
.runlog-trace .trace-panel { border-top: 1px dashed var(--line); } .runlog-trace .trace-panel { border-top: 1px dashed var(--line); }
.runlog-modal fieldset.row-actions {
border: 0;
margin: 0.4rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
}
fieldset.stack {
border: 1px solid var(--line);
border-radius: 12px;
margin: 0.6rem 0;
padding: 0.7rem 0.8rem 0.9rem;
}
fieldset.stack legend {
padding: 0 0.3rem;
font-weight: 600;
}
.mask-excerpt {
margin: 0.35rem 0 0;
color: var(--ink);
font-size: 0.95rem;
line-height: 1.45;
}
.mask-excerpt mark {
background: #f3e2b8;
color: inherit;
font-weight: 600;
padding: 0 0.12em;
border-radius: 3px;
}

View File

@ -1,100 +0,0 @@
function HighlightedExcerpt({ excerpt, highlightStart, highlightEnd, label }) {
if (!excerpt) return null
const start = Number(highlightStart)
const end = Number(highlightEnd)
if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end <= excerpt.length && end > start) {
return (
<p className="mask-excerpt">
{excerpt.slice(0, start)}
<mark>{excerpt.slice(start, end)}</mark>
{excerpt.slice(end)}
</p>
)
}
const needle = label || ''
const at = needle ? excerpt.toLowerCase().indexOf(needle.toLowerCase()) : -1
if (at < 0) return <p className="mask-excerpt">{excerpt}</p>
return (
<p className="mask-excerpt">
{excerpt.slice(0, at)}
<mark>{excerpt.slice(at, at + needle.length)}</mark>
{excerpt.slice(at + needle.length)}
</p>
)
}
function reviewCopy(kind) {
if (kind === 'confirm_remote') {
return {
title: 'Klartext-Detect bestätigen',
status: 'GLiNER fehlt. Ein öffentliches Detect darf den Klartext nur senden, wenn du das ausdrücklich bestätigst. „Identität“ heißt senden, „Nicht schützenswert“ bricht ab.'
}
}
if (kind === 'manual') {
return {
title: 'Maskierung prüfen',
status: 'Manuelles Lernen: bitte jede vorgeschlagene Nennung bestätigen oder als nicht schützenswert markieren.'
}
}
return {
title: 'Maskierung prüfen',
status: 'Abweichung oder neue Identität. Nur die gelb markierte Nennung. Bestätigen legt die Bezeichnung lokal ab. „Nicht schützenswert“ verhindert die Maskierung.'
}
}
export default function MaskReviewPanel({ review, busy, onSubmit }) {
if (!review?.candidates?.length) return null
const copy = reviewCopy(review.kind)
return (
<div className="runlog-overlay" role="dialog" aria-labelledby="mask-review-title">
<div className="runlog-modal">
<div className="runlog-head">
<div>
<h2 id="mask-review-title">{copy.title}</h2>
<p className="runlog-status">{copy.status}</p>
</div>
</div>
<form
className="stack"
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const decisions = review.candidates.map((item) => ({
id: item.id,
decision: data.get(`decision-${item.id}`) || 'identity'
}))
onSubmit(decisions)
}}
>
<ul className="stack">
{review.candidates.map((item) => (
<li key={item.id} className="row-item">
<div>
<strong>{item.text}</strong>
<span className="muted"> · {item.entity_type}{item.ambiguous ? ' · mehrdeutig' : ''}</span>
<HighlightedExcerpt
excerpt={item.excerpt}
highlightStart={item.highlight_start}
highlightEnd={item.highlight_end}
label={item.text}
/>
</div>
<fieldset className="row-actions">
<label className="check">
<input type="radio" name={`decision-${item.id}`} value="identity" defaultChecked={item.suggested !== 'not_identity'} />
Identität
</label>
<label className="check">
<input type="radio" name={`decision-${item.id}`} value="not_identity" defaultChecked={item.suggested === 'not_identity'} />
Nicht schützenswert
</label>
</fieldset>
</li>
))}
</ul>
<button type="submit" disabled={busy}>{busy ? 'Übernimmt …' : 'Übernehmen und weiter'}</button>
</form>
</div>
</div>
)
}

View File

@ -89,20 +89,6 @@ export function AuthProvider({ children }) {
setSession(null) setSession(null)
} }
const refreshProfile = async () => {
const token = session?.token || localStorage.getItem(TOKEN_KEY)
if (!token) return null
const me = await fetch('/api/auth/me', { headers: { 'X-Auth-Token': token } })
if (!me.ok) {
localStorage.removeItem(TOKEN_KEY)
setSession(null)
return null
}
const profile = await me.json()
setSession({ token, profile_id: profile.id, role: profile.role, profile })
return profile
}
return ( return (
<AuthContext.Provider value={{ <AuthContext.Provider value={{
session, session,
@ -111,8 +97,7 @@ export function AuthProvider({ children }) {
isAdmin: session?.role === 'admin', isAdmin: session?.role === 'admin',
login, login,
setup, setup,
logout, logout
refreshProfile
}}> }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>

View File

@ -1,112 +0,0 @@
import { useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
function statusLabel(verified, pending) {
if (pending) return `Bestätigung offen für ${pending}`
return verified ? 'bestätigt' : 'nicht bestätigt'
}
export default function AccountPanel() {
const { session, refreshProfile } = useAuth()
const profile = session?.profile || {}
const [username, setUsername] = useState(profile.username || '')
const [email, setEmail] = useState('')
const [secondary, setSecondary] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const save = async (body) => {
setBusy(true)
setError('')
setMessage('')
try {
await api('/api/users/me', { token: session.token, method: 'PATCH', body })
await refreshProfile()
setMessage('Gespeichert.')
setPassword('')
setEmail('')
setSecondary('')
} catch (err) {
setError(err.message)
await refreshProfile()
} finally {
setBusy(false)
}
}
const resend = async (purpose) => {
setBusy(true)
setError('')
setMessage('')
try {
await api('/api/auth/resend-verification', { token: session.token, method: 'POST', body: { purpose } })
setMessage('Bestätigungslink wurde gesendet.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="stack">
<h2>Konto</h2>
<p className="muted">
Primäre E-Mail: {profile.email} ({statusLabel(profile.email_verified, profile.pending_email)})
</p>
<p className="muted">
Zweite E-Mail: {profile.secondary_email || 'keine'} ({statusLabel(profile.secondary_email_verified, profile.pending_secondary_email)})
</p>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ username }) }}>
<label>
Benutzername
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<button type="submit" disabled={busy}>Benutzername speichern</button>
</form>
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ email }) }}>
<label>
Neue primäre E-Mail
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<button type="submit" disabled={busy}>Bestätigung senden</button>
</form>
{(!profile.email_verified || profile.pending_email) && (
<button type="button" className="ghost" disabled={busy} onClick={() => resend('primary')}>
Bestätigung der primären E-Mail erneut senden
</button>
)}
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ secondary_email: secondary }) }}>
<label>
Zweite E-Mail
<input type="email" value={secondary} onChange={(e) => setSecondary(e.target.value)} placeholder={profile.secondary_email || ''} />
</label>
<div className="row-actions">
<button type="submit" disabled={busy || !secondary.trim()}>Zweite E-Mail bestätigen lassen</button>
{profile.secondary_email && (
<button type="button" className="ghost" disabled={busy} onClick={() => save({ secondary_email: '' })}>
Zweite E-Mail entfernen
</button>
)}
</div>
</form>
{(profile.pending_secondary_email || (profile.secondary_email && !profile.secondary_email_verified)) && (
<button type="button" className="ghost" disabled={busy} onClick={() => resend('secondary')}>
Bestätigung der zweiten E-Mail erneut senden
</button>
)}
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ password }) }}>
<label>
Neues Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={4} />
</label>
<button type="submit" disabled={busy}>Passwort ändern</button>
</form>
</div>
)
}

View File

@ -104,7 +104,6 @@ export default function AdminIdentitiesPage() {
<p className="muted"> <p className="muted">
Nur lokale bestätigte Registry. Detect-Treffer werden nicht automatisch aktiv. Nur lokale bestätigte Registry. Detect-Treffer werden nicht automatisch aktiv.
Unbestätigte Vorschläge gelten nicht als bekannte Identität. Kein externer Egress. Unbestätigte Vorschläge gelten nicht als bekannte Identität. Kein externer Egress.
Mehrdeutigkeit entsteht aus Dialog-Bestätigungen, nicht aus einer Wortlisten-Seite.
</p> </p>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{notice && <p>{notice}</p>} {notice && <p>{notice}</p>}
@ -121,16 +120,9 @@ export default function AdminIdentitiesPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(data?.registry || []).map((item) => { {(data?.registry || []).map((item) => (
const sense = (data?.senses || []).find(
(entry) => (entry.normalized_label || '').toLowerCase() === (item.canonical_label || '').toLowerCase()
)
return (
<tr key={item.id}> <tr key={item.id}>
<td> <td>{item.canonical_label}</td>
{item.canonical_label}
{sense?.ambiguous ? <span className="muted"> · mehrdeutig</span> : null}
</td>
<td> <td>
<select <select
value={item.entity_type} value={item.entity_type}
@ -158,8 +150,7 @@ export default function AdminIdentitiesPage() {
<button type="button" className="ghost" onClick={() => remove(item.id)}>Entfernen</button> <button type="button" className="ghost" onClick={() => remove(item.id)}>Entfernen</button>
</td> </td>
</tr> </tr>
) ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '../api.js' import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx' import { useAuth } from '../context/AuthContext.jsx'
@ -9,20 +9,13 @@ const emptyRole = () => ({
model: '', model: '',
zdr: true, zdr: true,
no_train: true, no_train: true,
profile_id: '', key: ''
key: '',
saveAsTitle: ''
}) })
const emptyDraft = () => ({ const FALLBACK_MODELS = {
id: '', generate: ['openai/gpt-5.4', 'openai/gpt-4o', 'openai/gpt-4o-mini', 'anthropic/claude-sonnet-4'],
title: '', detect: ['openai/gpt-4.1-nano', 'openai/gpt-4o-mini', 'llama3.1']
name: '', }
url: 'http://192.168.2.144:11434/v1/chat/completions',
model: '',
zdr: true,
no_train: true
})
function statusLabel(role) { function statusLabel(role) {
if (role.role === 'detect' && !role.url) return 'nicht konfiguriert, fail-closed' if (role.role === 'detect' && !role.url) return 'nicht konfiguriert, fail-closed'
@ -36,21 +29,16 @@ function statusLabel(role) {
if (role.ready) return 'bereit' if (role.ready) return 'bereit'
if (!role.key_present && !role.local) return 'Key fehlt' if (!role.key_present && !role.local) return 'Key fehlt'
if (!role.url) return 'URL fehlt' if (!role.url) return 'URL fehlt'
if (role.local && !role.model) return 'Modell fehlt'
return 'nicht bereit' return 'nicht bereit'
} }
function detectBanner(data) { function detectBanner(data) {
if (!data) return '' if (!data) return ''
const detect = (data.roles || []).find((role) => role.role === 'detect')
if (detect?.local) {
return 'Maskierung läuft lokal (LAN/Ollama). Generate sieht nur den maskierten Kontext.'
}
if (data.runtime_env === 'production' && data.remote_plaintext_reason === 'operator_override') { if (data.runtime_env === 'production' && data.remote_plaintext_reason === 'operator_override') {
return 'Production erlaubt derzeit externes Klartext-Detect (KANSHO_ALLOW_REMOTE_DETECT). Ziel bleibt ein lokales Detect-Modell.' return 'Production erlaubt derzeit externes Klartext-Detect (KANSHO_ALLOW_REMOTE_DETECT). Das Gateway bleibt aktiv; Generate sieht nur maskierten Kontext. Ziel bleibt ein lokales Detect-Modell.'
} }
if (data.runtime_env === 'production' && data.remote_plaintext_allowed === false) { if (data.runtime_env === 'production' && data.remote_plaintext_allowed === false) {
return 'Production blockiert externes Klartext-Detect. Maskierung braucht ein lokales Profil oder die Operator-Freigabe.' return 'Production blockiert externes Klartext-Detect. Maskierung braucht eine lokale URL oder die Operator-Freigabe, sonst entsteht kein Impuls.'
} }
return '' return ''
} }
@ -59,16 +47,17 @@ export default function AdminProvidersPage() {
const { session } = useAuth() const { session } = useAuth()
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [forms, setForms] = useState({}) const [forms, setForms] = useState({})
const [draft, setDraft] = useState(emptyDraft()) const [catalog, setCatalog] = useState({ generate: [], detect: [] })
const [catalog, setCatalog] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [notice, setNotice] = useState('') const [notice, setNotice] = useState('')
const [saving, setSaving] = useState('') const [saving, setSaving] = useState('')
const applyPayload = (payload) => { const load = () =>
api('/api/admin/providers', { token: session.token })
.then((payload) => {
setData(payload) setData(payload)
const next = {} const next = {}
for (const role of payload.roles || []) { for (const role of payload.roles) {
next[role.role] = { next[role.role] = {
role: role.role, role: role.role,
name: role.name || '', name: role.name || '',
@ -76,130 +65,35 @@ export default function AdminProvidersPage() {
model: role.model || '', model: role.model || '',
zdr: Boolean(role.zdr), zdr: Boolean(role.zdr),
no_train: Boolean(role.no_train), no_train: Boolean(role.no_train),
profile_id: role.profile_id || '', key: ''
key: '',
saveAsTitle: ''
} }
} }
setForms(next) setForms(next)
} })
const load = () =>
api('/api/admin/providers', { token: session.token })
.then(applyPayload)
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
const loadModels = (url) => { const loadCatalog = () =>
if (!url) { api('/api/admin/providers/models', { token: session.token })
setCatalog([]) .then((payload) => {
return setCatalog({
} generate: payload.roles?.generate || [],
api(`/api/admin/providers/models?url=${encodeURIComponent(url)}`, { token: session.token }) detect: payload.roles?.detect || []
.then((payload) => setCatalog(payload.models || payload.roles?.generate || [])) })
.catch(() => setCatalog([])) })
} .catch(() => {
setCatalog({ generate: [], detect: [] })
})
useEffect(() => { load() }, [session.token]) useEffect(() => {
useEffect(() => { loadModels(draft.url) }, [draft.url, session.token]) load()
loadCatalog()
}, [session.token])
const profiles = data?.profiles || [] const update = (role, field, value) => {
const usedIds = useMemo(
() => new Set((data?.roles || []).map((role) => role.profile_id).filter(Boolean)),
[data]
)
const updateRole = (role, field, value) => {
setForms((prev) => ({ ...prev, [role]: { ...(prev[role] || emptyRole()), [field]: value } })) setForms((prev) => ({ ...prev, [role]: { ...(prev[role] || emptyRole()), [field]: value } }))
} }
const applyRole = async (role, profileId) => { const save = async (role) => {
const profile = profiles.find((item) => item.id === profileId)
if (!profile) return
setError('')
setNotice('')
setForms((prev) => ({
...prev,
[role]: {
...(prev[role] || emptyRole()),
role,
name: profile.name || '',
url: profile.url || '',
model: profile.model || '',
zdr: Boolean(profile.zdr),
no_train: Boolean(profile.no_train),
profile_id: profile.id,
key: '',
saveAsTitle: ''
}
}))
if (!profile.model) {
setError('Das Profil braucht ein Modell, bevor es einer Stufe zugeordnet werden kann.')
return
}
setSaving(`activate-${role}`)
try {
const payload = await api(`/api/admin/providers/${role}/activate`, {
token: session.token,
method: 'POST',
body: { profile_id: profileId }
})
applyPayload(payload)
setNotice(`${role === 'detect' ? 'Maskierung' : 'Sprachmodell'}: ${profile.title} ist aktiv.`)
} catch (err) {
setError(err.message)
} finally {
setSaving('')
}
}
const saveDetectMode = async (mode) => {
setError('')
setNotice('')
setSaving('detect-mode')
try {
const payload = await api('/api/admin/providers/detect-mode', {
token: session.token,
method: 'PUT',
body: { mode }
})
applyPayload(payload)
const labels = {
student: 'Zielpfad: Schüler allein, ohne Lehrer-Kontrollaufruf.',
learning_full: 'Lernen Stufe 1: Schüler und Lehrer (roher Nutzersatz) parallel.',
learning_reduced: 'Lernen Stufe 2: Lehrer sieht keine cue-sicheren Formen mehr.',
learning_manual: 'Manuelles Lernen: alle Schülerfunde zur Bestätigung.',
bypass: 'Bypass: alter OpenRouter-Detect auf dem gerenderten Prompt.',
learning: 'Lernen Stufe 1.',
semantic: 'Bypass (alter Pfad).'
}
setNotice(labels[mode] || 'Detect-Modus gespeichert.')
} catch (err) {
setError(err.message)
} finally {
setSaving('')
}
}
const saveLocalNer = async (patch) => {
setError('')
setNotice('')
setSaving('local-ner')
try {
const payload = await api('/api/admin/providers/local-ner', {
token: session.token,
method: 'PUT',
body: patch
})
applyPayload(payload)
setNotice('Lokale NER gespeichert. Maskierung (OpenRouter) bleibt unverändert.')
} catch (err) {
setError(err.message)
} finally {
setSaving('')
}
}
const saveRole = async (role) => {
const form = forms[role] const form = forms[role]
if (!form) return if (!form) return
setError('') setError('')
@ -211,9 +105,7 @@ export default function AdminProvidersPage() {
url: form.url, url: form.url,
model: form.model, model: form.model,
zdr: form.zdr, zdr: form.zdr,
no_train: form.no_train, no_train: form.no_train
profile_id: form.profile_id || undefined,
save_as_title: form.saveAsTitle.trim() || undefined
} }
if (form.key.trim()) body.key = form.key.trim() if (form.key.trim()) body.key = form.key.trim()
const payload = await api(`/api/admin/providers/${role}`, { const payload = await api(`/api/admin/providers/${role}`, {
@ -221,8 +113,10 @@ export default function AdminProvidersPage() {
method: 'PUT', method: 'PUT',
body body
}) })
applyPayload(payload) setData(payload)
setNotice(form.saveAsTitle.trim() ? 'Neues Profil gespeichert und dieser Stufe zugeordnet.' : 'Stufe gespeichert. Zugeordnetes Profil wurde mitaktualisiert.') setForms((prev) => ({ ...prev, [role]: { ...prev[role], key: '' } }))
setNotice(form.key.trim() ? `${role}: gespeichert, Key in .env geschrieben.` : `${role}: gespeichert.`)
loadCatalog()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -230,50 +124,10 @@ export default function AdminProvidersPage() {
} }
} }
const saveDraft = async () => { const modelOptions = (role) => {
setError('') const fromCatalog = catalog[role] || []
setNotice('') if (fromCatalog.length) return fromCatalog
setSaving('draft') return (FALLBACK_MODELS[role] || []).map((id) => ({ id, name: id }))
try {
const body = {
title: draft.title,
name: draft.name,
url: draft.url,
model: draft.model,
zdr: draft.zdr,
no_train: draft.no_train
}
const payload = draft.id
? await api(`/api/admin/llm-profiles/${draft.id}`, { token: session.token, method: 'PUT', body })
: await api('/api/admin/llm-profiles', { token: session.token, method: 'POST', body })
applyPayload(payload)
const saved = payload.profile || profiles.find((item) => item.id === draft.id)
setDraft(saved ? { ...saved } : emptyDraft())
setNotice(draft.id ? 'Profil aktualisiert.' : 'Profil angelegt. Du kannst es den Stufen zuordnen, ohne die Felder neu einzugeben.')
} catch (err) {
setError(err.message)
} finally {
setSaving('')
}
}
const removeProfile = async (profileId) => {
setError('')
setNotice('')
setSaving(`delete-${profileId}`)
try {
const payload = await api(`/api/admin/llm-profiles/${profileId}`, {
token: session.token,
method: 'DELETE'
})
applyPayload(payload)
if (draft.id === profileId) setDraft(emptyDraft())
setNotice('Profil gelöscht.')
} catch (err) {
setError(err.message)
} finally {
setSaving('')
}
} }
const banner = detectBanner(data) const banner = detectBanner(data)
@ -282,239 +136,59 @@ export default function AdminProvidersPage() {
<section className="card"> <section className="card">
<h1>Schnittstellen</h1> <h1>Schnittstellen</h1>
<p className="muted"> <p className="muted">
LLM-Profile merken URL, Modell und Policy. Maskierung und Sprachmodell wählen je ein Profil. Zwei Stufen, zwei eigene Modelle: Maskierung prüft den persönlichen Text, das Sprachmodell schreibt Dialogzug und Journalentwurf.
Beim Wechsel musst du die Daten nicht neu eintippen. Keys bleiben in der Umgebung, nicht im Profil. Pro Stufe URL und Modell getrennt wählen. Ohne Detection kein Generate. Der Secret-Key wird nie angezeigt und nie in der Datenbank gespeichert.
</p> </p>
{data && <p className="muted">Laufzeit: {data.runtime_env}</p>} {data && (
<p className="muted">Laufzeit: {data.runtime_env}</p>
)}
{banner && <p>{banner}</p>} {banner && <p>{banner}</p>}
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{notice && <p>{notice}</p>} {notice && <p>{notice}</p>}
{data && ( {data && (
<> <>
{data.roles.map((role) => { {data.roles.map((role) => {
const form = forms[role.role] || emptyRole() const form = forms[role.role] || emptyRole()
const selected = profiles.find((item) => item.id === form.profile_id) const options = modelOptions(role.role)
const view = selected const listId = `kansho-models-${role.role}`
? { ...role, ...form, local: selected.local, ready: selected.local ? Boolean(form.url && form.model) : role.ready }
: role
return ( return (
<div key={role.role} className="stack"> <form
key={role.role}
className="stack"
onSubmit={(e) => { e.preventDefault(); save(role.role) }}
>
<h2>{role.title || role.role}</h2> <h2>{role.title || role.role}</h2>
{role.task && <p className="muted">{role.task}</p>} {role.task && <p className="muted">{role.task}</p>}
{role.role === 'detect' && (
<label>
Detect-Modus
<select
value={data.detect_operating_mode || 'learning_full'}
onChange={(e) => saveDetectMode(e.target.value)}
disabled={saving === 'detect-mode'}
>
<option value="student">Zielpfad (Schüler allein)</option>
<option value="learning_full">Lernen automatisch Stufe 1</option>
<option value="learning_reduced">Lernen automatisch Stufe 2</option>
<option value="learning_manual">Lernen manuell</option>
<option value="bypass">Bypass (alter OpenRouter-Pfad)</option>
</select>
</label>
)}
{role.role === 'detect' && data.detect_operating_mode !== 'bypass' && (
<p className="muted">
Keine separate Wortliste. Sinne entstehen aus Bestätigungen.
GLiNER sieht den unveränderten Nutzersatz. Der Lehrer nur im Lernmodus.
Mini-Passagen gehen an das unten gewählte Profil, nicht an die Maskierungs-Stufe.
</p>
)}
{role.role === 'detect' && (
<fieldset className="stack">
<legend>Lokale NER (GLiNER)</legend>
<p className="muted">
Unabhängig von der Maskierungs-URL. OpenRouter-Detect bleibt die Egress-Maskierung.
</p>
<label className="check">
<input
type="checkbox"
checked={Boolean(data.local_ner?.enabled)}
onChange={(e) => saveLocalNer({ enabled: e.target.checked })}
disabled={saving === 'local-ner'}
/>
GLiNER im Schülerpfad nutzen
</label>
<label>
GLiNER-URL
<input
defaultValue={data.local_ner?.url || 'http://192.168.2.144:8090'}
key={data.local_ner?.url || 'gliner-url'}
onBlur={(e) => {
const next = e.target.value.trim()
if (next && next !== data.local_ner?.url) saveLocalNer({ url: next })
}}
/>
</label>
<p className="muted">
Status: {data.local_ner?.health?.status || (data.local_ner?.enabled ? 'unbekannt' : 'aus')}
{data.local_ner?.health?.model ? ` · ${data.local_ner.health.model}` : ''}
</p>
<label>
Unsichere Nennung klären über
<select
value={data.local_ner?.passage_profile_id || ''}
onChange={(e) => saveLocalNer({ passage_profile_id: e.target.value })}
disabled={saving === 'local-ner'}
>
<option value="">Nicht klären (Popup)</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.title} ({profile.local ? 'lokal' : 'extern'}{profile.model ? ` · ${profile.model}` : ''})
</option>
))}
</select>
</label>
</fieldset>
)}
<dl className="meta"> <dl className="meta">
<dt>Status</dt> <dt>Status</dt>
<dd>{statusLabel(view)}</dd> <dd>{statusLabel(role)}</dd>
<dt>Key</dt> <dt>Key</dt>
<dd>{view.key_present ? (view.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : (view.local ? 'nicht nötig (lokal)' : 'fehlt')}</dd> <dd>{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : 'fehlt'}</dd>
<dt>Modus</dt> <dt>Modus</dt>
<dd>{view.local ? 'lokal' : 'extern'} · {view.mode}</dd> <dd>{role.local ? 'lokal' : 'extern'} · {role.mode}</dd>
</dl> </dl>
<label>
Aktives Profil
<select
value={form.profile_id}
onChange={(e) => {
if (!e.target.value) return
applyRole(role.role, e.target.value)
}}
disabled={saving.startsWith('activate-') || !profiles.length}
>
<option value="">Profil wählen …</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.title} ({profile.local ? 'lokal' : 'extern'}{profile.model ? ` · ${profile.model}` : ''})
</option>
))}
</select>
</label>
<form
className="stack"
onSubmit={(e) => { e.preventDefault(); saveRole(role.role) }}
>
<label> <label>
Name Name
<input value={form.name} onChange={(e) => updateRole(role.role, 'name', e.target.value)} /> <input value={form.name} onChange={(e) => update(role.role, 'name', e.target.value)} />
</label> </label>
<label> <label>
URL URL
<input value={form.url} onChange={(e) => updateRole(role.role, 'url', e.target.value)} /> <input
value={form.url}
onChange={(e) => update(role.role, 'url', e.target.value)}
placeholder={role.role === 'detect' ? 'http://127.0.0.1:11434/v1/chat/completions' : ''}
/>
</label> </label>
<label> <label>
Modell für diese Stufe Modell für diese Stufe
<input value={form.model} onChange={(e) => updateRole(role.role, 'model', e.target.value)} />
</label>
<label>
Als neues Profil speichern (optional)
<input <input
value={form.saveAsTitle} list={listId}
onChange={(e) => updateRole(role.role, 'saveAsTitle', e.target.value)} value={form.model}
placeholder="z. B. Ollama Detect" onChange={(e) => update(role.role, 'model', e.target.value)}
placeholder={role.role === 'detect' ? 'openai/gpt-4.1-nano oder lokales Ollama-Modell' : 'openai/gpt-5.4'}
/> />
</label> <datalist id={listId}>
<label className="check"> {options.map((item) => (
<input type="checkbox" checked={form.zdr} onChange={(e) => updateRole(role.role, 'zdr', e.target.checked)} />
Zero Data Retention
</label>
<label className="check">
<input
type="checkbox"
checked={form.no_train}
onChange={(e) => updateRole(role.role, 'no_train', e.target.checked)}
/>
kein Provider-Training
</label>
<label>
API-Key {view.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(für externe URL)'}
<input
type="password"
autoComplete="off"
value={form.key}
onChange={(e) => updateRole(role.role, 'key', e.target.value)}
placeholder={view.local ? 'lokal ohne Key' : (role.role === 'detect' ? 'leer = Key des Sprachmodells' : '')}
/>
</label>
<button type="submit" disabled={saving === role.role}>
{saving === role.role ? 'Speichert …' : 'Stufe speichern'}
</button>
</form>
</div>
)
})}
<h2>Gespeicherte Profile</h2>
<p className="muted">Ein Profil kann Maskierung, Sprachmodell oder beiden Stufen dienen. Löschen geht nur, wenn es gerade keiner Stufe zugeordnet ist.</p>
<ul className="stack">
{profiles.map((profile) => (
<li key={profile.id} className="row-item">
<button
type="button"
className="ghost"
onClick={() => setDraft({ ...profile })}
>
{profile.title}
<span className="muted"> · {profile.local ? 'lokal' : 'extern'} · {profile.model || 'kein Modell'} · {profile.url}</span>
</button>
<div className="row-actions">
<button type="button" className="ghost" onClick={() => setDraft({ ...profile })}>Bearbeiten</button>
<button
type="button"
className="ghost"
disabled={usedIds.has(profile.id) || saving === `delete-${profile.id}`}
onClick={() => removeProfile(profile.id)}
>
Löschen
</button>
</div>
</li>
))}
</ul>
<form className="stack" onSubmit={(e) => { e.preventDefault(); saveDraft() }}>
<h2>{draft.id ? 'Profil bearbeiten' : 'Neues Profil'}</h2>
<label>
Profilname
<input
value={draft.title}
onChange={(e) => setDraft((prev) => ({ ...prev, title: e.target.value }))}
placeholder="z. B. Ollama (LAN)"
/>
</label>
<label>
Anbietername
<input
value={draft.name}
onChange={(e) => setDraft((prev) => ({ ...prev, name: e.target.value }))}
placeholder="ollama oder openrouter"
/>
</label>
<label>
URL
<input
value={draft.url}
onChange={(e) => setDraft((prev) => ({ ...prev, url: e.target.value }))}
/>
</label>
<label>
Modell
<input
list="kansho-profile-models"
value={draft.model}
onChange={(e) => setDraft((prev) => ({ ...prev, model: e.target.value }))}
placeholder="Ollama-Tag oder OpenRouter-ID"
/>
<datalist id="kansho-profile-models">
{catalog.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{item.name && item.name !== item.id ? item.name : item.id} {item.name && item.name !== item.id ? item.name : item.id}
</option> </option>
@ -522,33 +196,34 @@ export default function AdminProvidersPage() {
</datalist> </datalist>
</label> </label>
<label className="check"> <label className="check">
<input <input type="checkbox" checked={form.zdr} onChange={(e) => update(role.role, 'zdr', e.target.checked)} />
type="checkbox"
checked={draft.zdr}
onChange={(e) => setDraft((prev) => ({ ...prev, zdr: e.target.checked }))}
/>
Zero Data Retention Zero Data Retention
</label> </label>
<label className="check"> <label className="check">
<input <input
type="checkbox" type="checkbox"
checked={draft.no_train} checked={form.no_train}
onChange={(e) => setDraft((prev) => ({ ...prev, no_train: e.target.checked }))} onChange={(e) => update(role.role, 'no_train', e.target.checked)}
/> />
kein Provider-Training kein Provider-Training
</label> </label>
<div className="row-actions"> <label>
<button type="submit" disabled={saving === 'draft'}> API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(pflichtig für externe URL)'}
{saving === 'draft' ? 'Speichert …' : (draft.id ? 'Profil speichern' : 'Profil anlegen')} <input
type="password"
autoComplete="off"
value={form.key}
onChange={(e) => update(role.role, 'key', e.target.value)}
placeholder={role.role === 'detect' ? 'leer = Key des Sprachmodells' : ''}
/>
</label>
<button type="submit" disabled={saving === role.role}>
{saving === role.role ? 'Speichert …' : 'Speichern'}
</button> </button>
{draft.id && (
<button type="button" className="ghost" onClick={() => setDraft(emptyDraft())}>
Neu
</button>
)}
</div>
</form> </form>
<p className="muted">Ohne ZDR und ohne Trainingsverbot bleibt ein externes Sprachmodell fail-closed. Gateway lässt sich darüber nicht umgehen. Ollama auf der LAN-Adresse gilt als lokal.</p> )
})}
<p className="muted">Ohne ZDR und ohne Trainingsverbot bleibt das Sprachmodell fail-closed. Maskierung wählt keine Operation. Gateway lässt sich darüber nicht umgehen.</p>
</> </>
)} )}
</section> </section>

View File

@ -2,42 +2,24 @@ import { useEffect, useState } from 'react'
import { api } from '../api.js' import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx' import { useAuth } from '../context/AuthContext.jsx'
const emptyForm = { email: '', name: '', password: '', role: 'user', username: '' } const emptyForm = { email: '', name: '', password: '', role: 'user' }
function verifiedLabel(ok) {
return ok ? 'bestätigt' : 'offen'
}
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { session } = useAuth() const { session } = useAuth()
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [form, setForm] = useState(emptyForm) const [form, setForm] = useState(emptyForm)
const [editId, setEditId] = useState('')
const [edit, setEdit] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [message, setMessage] = useState('')
const load = () => api('/api/users', { token: session.token }).then(setUsers).catch((e) => setError(e.message)) const load = () => api('/api/users', { token: session.token }).then(setUsers).catch((e) => setError(e.message))
useEffect(() => { load() }, [session.token]) useEffect(() => { load() }, [session.token])
const activeAdmins = users.filter((user) => user.role === 'admin' && user.status === 'active').length const create = async (e) => {
e.preventDefault()
const create = async (event) => {
event.preventDefault()
setError('') setError('')
setMessage('')
try { try {
const body = { await api('/api/users', { token: session.token, method: 'POST', body: form })
email: form.email,
name: form.name,
password: form.password,
role: form.role
}
if (form.username.trim()) body.username = form.username.trim()
await api('/api/users', { token: session.token, method: 'POST', body })
setForm(emptyForm) setForm(emptyForm)
setMessage('Nutzer angelegt. Die primäre E-Mail muss noch bestätigt werden.')
await load() await load()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -46,7 +28,6 @@ export default function AdminUsersPage() {
const toggleStatus = async (user) => { const toggleStatus = async (user) => {
setError('') setError('')
setMessage('')
try { try {
await api(`/api/users/${user.id}`, { await api(`/api/users/${user.id}`, {
token: session.token, token: session.token,
@ -59,145 +40,36 @@ export default function AdminUsersPage() {
} }
} }
const changeRole = async (user, role) => {
setError('')
setMessage('')
try {
await api(`/api/users/${user.id}`, { token: session.token, method: 'PATCH', body: { role } })
await load()
} catch (err) {
setError(err.message)
}
}
const openEdit = (user) => {
setEditId(user.id)
setEdit({
username: user.username || '',
email: '',
secondary_email: ''
})
}
const saveEdit = async (event) => {
event.preventDefault()
setError('')
setMessage('')
const body = {}
if (edit.username !== (users.find((user) => user.id === editId)?.username || '')) {
body.username = edit.username
}
if (edit.email.trim()) body.email = edit.email.trim()
if (edit.secondary_email.trim()) body.secondary_email = edit.secondary_email.trim()
if (!Object.keys(body).length) return
try {
await api(`/api/users/${editId}`, { token: session.token, method: 'PATCH', body })
setMessage('Gespeichert. Geänderte E-Mail-Adressen gelten erst nach der Bestätigung.')
setEditId('')
setEdit(null)
await load()
} catch (err) {
setError(err.message)
}
}
const sendReset = async (user) => {
setError('')
setMessage('')
try {
await api(`/api/users/${user.id}/password-reset`, { token: session.token, method: 'POST' })
setMessage(`Reset-Link für ${user.name} wurde gesendet.`)
} catch (err) {
setError(err.message)
}
}
return ( return (
<section className="card"> <section className="card">
<h1>Nutzer</h1> <h1>Nutzer</h1>
<p className="muted">Ein Login ist ein Profil. Benutzername und zweite E-Mail gehören zu demselben Profil.</p> <p className="muted">Ein Login ist ein Profil. Keine Profilumschaltung, kein Vereinsmodell.</p>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{message && <p>{message}</p>}
<div className="table-scroll"> <div className="table-scroll">
<table className="plain"> <table className="plain">
<thead> <thead>
<tr> <tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th /></tr>
<th>Name</th>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Zweite E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th />
</tr>
</thead> </thead>
<tbody> <tbody>
{users.map((user) => { {users.map((user) => (
const lastAdmin = user.role === 'admin' && user.status === 'active' && activeAdmins < 2
return (
<tr key={user.id}> <tr key={user.id}>
<td>{user.name}</td> <td>{user.name}</td>
<td>{user.username || '—'}</td> <td>{user.email}</td>
<td> <td>{user.role}</td>
{user.email}
<div className="muted">{verifiedLabel(user.email_verified)}{user.pending_email ? ` · neu: ${user.pending_email}` : ''}</div>
</td>
<td>
{user.secondary_email || '—'}
<div className="muted">
{user.secondary_email ? verifiedLabel(user.secondary_email_verified) : ''}
{user.pending_secondary_email ? ` · neu: ${user.pending_secondary_email}` : ''}
</div>
</td>
<td>
<select
value={user.role}
disabled={lastAdmin && user.role === 'admin'}
onChange={(event) => changeRole(user, event.target.value)}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</td>
<td>{user.status}</td> <td>{user.status}</td>
<td> <td>
<div className="row-actions"> <button type="button" className="ghost" onClick={() => toggleStatus(user)}>
<button type="button" className="ghost" onClick={() => openEdit(user)}>Bearbeiten</button>
<button
type="button"
className="ghost"
disabled={user.id === session.profile_id}
onClick={() => sendReset(user)}
>
Reset-Link
</button>
<button type="button" className="ghost" disabled={lastAdmin} onClick={() => toggleStatus(user)}>
{user.status === 'active' ? 'Deaktivieren' : 'Aktivieren'} {user.status === 'active' ? 'Deaktivieren' : 'Aktivieren'}
</button> </button>
</div>
</td> </td>
</tr> </tr>
) ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>
{edit && (
<form className="stack" onSubmit={saveEdit}>
<h2>Nutzer bearbeiten</h2>
<label>Benutzername <input value={edit.username} onChange={(e) => setEdit({ ...edit, username: e.target.value })} /></label>
<label>Neue primäre E-Mail <input type="email" value={edit.email} onChange={(e) => setEdit({ ...edit, email: e.target.value })} /></label>
<label>Neue zweite E-Mail <input type="email" value={edit.secondary_email} onChange={(e) => setEdit({ ...edit, secondary_email: e.target.value })} /></label>
<div className="row-actions">
<button type="submit">Speichern</button>
<button type="button" className="ghost" onClick={() => { setEdit(null); setEditId('') }}>Abbrechen</button>
</div>
</form>
)}
<form className="stack" onSubmit={create}> <form className="stack" onSubmit={create}>
<h2>Nutzer anlegen</h2> <h2>Nutzer anlegen</h2>
<label>Name <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></label> <label>Name <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></label>
<label>Benutzername <input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></label>
<label>E-Mail <input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></label> <label>E-Mail <input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></label>
<label>Passwort <input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} /></label> <label>Passwort <input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} /></label>
<label> <label>

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api, apiDownload } from '../api.js' import { api, apiDownload } from '../api.js'
import MaskReviewPanel from '../components/MaskReviewPanel.jsx'
import { useAuth } from '../context/AuthContext.jsx' import { useAuth } from '../context/AuthContext.jsx'
function convLabel(item, index) { function convLabel(item, index) {
@ -19,7 +18,6 @@ export default function DialoguePage() {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [egress, setEgress] = useState(null) const [egress, setEgress] = useState(null)
const [debugOn, setDebugOn] = useState(false) const [debugOn, setDebugOn] = useState(false)
const [maskReview, setMaskReview] = useState(null)
const loadList = () => const loadList = () =>
api('/api/dialogue/conversations', { token: session.token }) api('/api/dialogue/conversations', { token: session.token })
@ -35,7 +33,6 @@ export default function DialoguePage() {
const data = await api(`/api/dialogue/conversations/${id}`, { token: session.token }) const data = await api(`/api/dialogue/conversations/${id}`, { token: session.token })
setActiveId(id) setActiveId(id)
setMessages(data.messages || []) setMessages(data.messages || [])
setMaskReview(data.pending_mask_review || null)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -81,7 +78,6 @@ export default function DialoguePage() {
else { else {
setActiveId(null) setActiveId(null)
setMessages([]) setMessages([])
setMaskReview(null)
} }
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -113,12 +109,7 @@ export default function DialoguePage() {
}) })
setDraft('') setDraft('')
setMessages(result.messages || []) setMessages(result.messages || [])
if (result.pending_mask_review) {
setMaskReview(result.pending_mask_review)
} else {
setMaskReview(null)
await loadList() await loadList()
}
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
if (activeId) await openConversation(activeId) if (activeId) await openConversation(activeId)
@ -127,26 +118,6 @@ export default function DialoguePage() {
} }
} }
const submitMaskReview = async (decisions) => {
if (!activeId || !maskReview?.id) return
setError('')
setBusy(true)
try {
const result = await api(`/api/dialogue/conversations/${activeId}/turn/review`, {
token: session.token,
method: 'POST',
body: { review_id: maskReview.id, decisions }
})
setMaskReview(null)
setMessages(result.messages || [])
await loadList()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return ( return (
<section className="card dialogue-page"> <section className="card dialogue-page">
<header className="dialogue-head"> <header className="dialogue-head">
@ -203,12 +174,11 @@ export default function DialoguePage() {
{activeId && ( {activeId && (
<form className="dialogue-composer" onSubmit={send}> <form className="dialogue-composer" onSubmit={send}>
<textarea rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Erzählen …" /> <textarea rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Erzählen …" />
<button type="submit" disabled={busy || Boolean(maskReview)}>{busy ? 'Antwortet …' : 'Senden'}</button> <button type="submit" disabled={busy}>{busy ? 'Antwortet …' : 'Senden'}</button>
</form> </form>
)} )}
</div> </div>
</div> </div>
<MaskReviewPanel review={maskReview} busy={busy} onSubmit={submitMaskReview} />
</section> </section>
) )
} }

View File

@ -1,42 +0,0 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const onSubmit = async (event) => {
event.preventDefault()
setBusy(true)
setError('')
setMessage('')
try {
const result = await api('/api/auth/forgot-password', { method: 'POST', body: { email } })
setMessage(result.message || 'Wenn ein Konto existiert, wurde ein Link gesendet.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="auth-screen">
<form className="card" onSubmit={onSubmit}>
<h1>Passwort vergessen</h1>
<p>Gib die E-Mail-Adresse oder den Benutzernamen ein. Der Link gilt 60 Minuten.</p>
<label>
E-Mail oder Benutzername
<input type="text" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="username" />
</label>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy}>{busy ? 'Sende …' : 'Link anfordern'}</button>
<Link to="/">Zur Anmeldung</Link>
</form>
</div>
)
}

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, apiDownload } from '../api.js' import { api, apiDownload } from '../api.js'
import DayScratch from '../components/DayScratch.jsx' import DayScratch from '../components/DayScratch.jsx'
import MaskReviewPanel from '../components/MaskReviewPanel.jsx'
import RunLogPopup from '../components/RunLogPopup.jsx' import RunLogPopup from '../components/RunLogPopup.jsx'
import { useAuth } from '../context/AuthContext.jsx' import { useAuth } from '../context/AuthContext.jsx'
import { entryTitle } from '../journal/document.js' import { entryTitle } from '../journal/document.js'
@ -59,8 +58,6 @@ export default function JournalDayPage() {
const [logStatus, setLogStatus] = useState('ok') const [logStatus, setLogStatus] = useState('ok')
const [runLog, setRunLog] = useState([]) const [runLog, setRunLog] = useState([])
const [debugOn, setDebugOn] = useState(false) const [debugOn, setDebugOn] = useState(false)
const [maskReview, setMaskReview] = useState(null)
const [maskReviewSource, setMaskReviewSource] = useState('turn')
const loadDay = async (preferId) => { const loadDay = async (preferId) => {
const data = await api(`/api/journal/days/${dayId}`, { token: session.token }) const data = await api(`/api/journal/days/${dayId}`, { token: session.token })
@ -71,12 +68,9 @@ export default function JournalDayPage() {
const conv = await api(`/api/journal/conversations/${nextId}`, { token: session.token }) const conv = await api(`/api/journal/conversations/${nextId}`, { token: session.token })
setActiveId(nextId) setActiveId(nextId)
setMessages(conv.messages || []) setMessages(conv.messages || [])
setMaskReview(conv.pending_mask_review || null)
setMaskReviewSource(conv.pending_mask_review?.purpose === 'journal_generate' ? 'generate' : 'turn')
} else { } else {
setActiveId(null) setActiveId(null)
setMessages([]) setMessages([])
setMaskReview(null)
} }
return data return data
} }
@ -154,58 +148,10 @@ export default function JournalDayPage() {
}) })
setDraft('') setDraft('')
setMessages(result.messages || []) setMessages(result.messages || [])
if (result.pending_mask_review) {
setMaskReview(result.pending_mask_review)
setMaskReviewSource('turn')
} else {
setMaskReview(null)
await loadDay(activeId)
}
} catch (err) {
setError(err.message)
await loadDay(activeId)
} finally {
setBusy(false)
}
}
const submitMaskReview = async (decisions) => {
if (!maskReview?.id) return
setError('')
setBusy(true)
try {
if (maskReviewSource === 'generate') {
const result = await api(`/api/journal/days/${dayId}/generate/review`, {
token: session.token,
method: 'POST',
body: { review_id: maskReview.id, decisions }
})
if (result.pending_mask_review) {
setMaskReview(result.pending_mask_review)
setMaskReviewSource('generate')
return
}
setMaskReview(null)
const log = result.run_log || result.trace?.log || []
setRunLog(log)
setTrace(result.trace || null)
await loadDay(activeId)
navigate(`/journal/${spaceId}/${dayId}/entry?draft=1`, {
state: { trace: result.trace || null, run_log: log }
})
return
}
if (!activeId) return
const result = await api(`/api/journal/conversations/${activeId}/turn/review`, {
token: session.token,
method: 'POST',
body: { review_id: maskReview.id, decisions }
})
setMaskReview(null)
setMessages(result.messages || [])
await loadDay(activeId) await loadDay(activeId)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
await loadDay(activeId)
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -230,12 +176,6 @@ export default function JournalDayPage() {
method: 'POST', method: 'POST',
body body
}) })
if (result.pending_mask_review) {
setMaskReview(result.pending_mask_review)
setMaskReviewSource('generate')
setLogOpen(false)
return
}
const log = result.run_log || result.trace?.log || [] const log = result.run_log || result.trace?.log || []
setDecision(null) setDecision(null)
setTrace(result.trace || null) setTrace(result.trace || null)
@ -451,7 +391,7 @@ export default function JournalDayPage() {
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
placeholder="Erzählen …" placeholder="Erzählen …"
/> />
<button type="submit" disabled={busy || Boolean(maskReview)}>{busy ? 'Antwortet …' : 'Senden'}</button> <button type="submit" disabled={busy}>{busy ? 'Antwortet …' : 'Senden'}</button>
</form> </form>
)} )}
</div> </div>
@ -463,7 +403,6 @@ export default function JournalDayPage() {
onError={setError} onError={setError}
/> />
</div> </div>
<MaskReviewPanel review={maskReview} busy={busy} onSubmit={submitMaskReview} />
<RunLogPopup <RunLogPopup
open={logOpen} open={logOpen}
status={logStatus} status={logStatus}

View File

@ -1,5 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext.jsx' import { useAuth } from '../context/AuthContext.jsx'
export default function LoginScreen() { export default function LoginScreen() {
@ -28,14 +27,8 @@ export default function LoginScreen() {
<h1>Kanshō</h1> <h1>Kanshō</h1>
<p>Anmelden, um den Reflexionsraum fortzusetzen.</p> <p>Anmelden, um den Reflexionsraum fortzusetzen.</p>
<label> <label>
E-Mail oder Benutzername E-Mail
<input <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
type="text"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label> </label>
<label> <label>
Passwort Passwort
@ -43,7 +36,6 @@ export default function LoginScreen() {
</label> </label>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<button type="submit" disabled={busy}>{busy ? 'Prüfe …' : 'Anmelden'}</button> <button type="submit" disabled={busy}>{busy ? 'Prüfe …' : 'Anmelden'}</button>
<Link to="/forgot-password">Passwort vergessen</Link>
</form> </form>
</div> </div>
) )

View File

@ -1,43 +0,0 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
export default function ResetPasswordPage() {
const [params] = useSearchParams()
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const token = params.get('token') || ''
const onSubmit = async (event) => {
event.preventDefault()
setBusy(true)
setError('')
try {
await api('/api/auth/reset-password', { method: 'POST', body: { token, password } })
setMessage('Das Passwort ist geändert. Du kannst dich neu anmelden.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="auth-screen">
<form className="card" onSubmit={onSubmit}>
<h1>Neues Passwort</h1>
{!token && <p className="error">Der Link enthält kein Token.</p>}
<label>
Neues Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={4} />
</label>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy || !token}>{busy ? 'Speichere …' : 'Passwort setzen'}</button>
<Link to="/">Zur Anmeldung</Link>
</form>
</div>
)
}

View File

@ -1,7 +1,6 @@
import { useEffect, useId, useState } from 'react' import { useEffect, useId, useState } from 'react'
import { useAuth } from '../context/AuthContext.jsx' import { useAuth } from '../context/AuthContext.jsx'
import { api } from '../api.js' import { api } from '../api.js'
import AccountPanel from './AccountPanel.jsx'
const KIND_LABELS = { const KIND_LABELS = {
journal_entry: 'Finale Fassung (Stimme)', journal_entry: 'Finale Fassung (Stimme)',
@ -457,9 +456,6 @@ export default function SettingsPage() {
return ( return (
<section className="card"> <section className="card">
<h1>Einstellungen</h1> <h1>Einstellungen</h1>
<AccountPanel />
{session?.profile?.email_verified_at ? (
<>
<dl className="meta"> <dl className="meta">
<dt>Name</dt> <dt>Name</dt>
<dd>{session?.profile?.name}</dd> <dd>{session?.profile?.name}</dd>
@ -819,10 +815,6 @@ export default function SettingsPage() {
}} }}
onError={setError} onError={setError}
/> />
</>
) : (
<p className="muted">Bitte bestätige zuerst deine E-Mail-Adresse. Danach steht der Reflexionsraum wieder zur Verfügung.</p>
)}
</section> </section>
) )
} }

View File

@ -1,42 +0,0 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
export default function VerifyEmailPage() {
const [params] = useSearchParams()
const { session, refreshProfile } = useAuth()
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const token = params.get('token') || ''
const confirm = async (event) => {
event.preventDefault()
setBusy(true)
setError('')
try {
await api('/api/auth/verify-email', { method: 'POST', body: { token } })
if (session?.token) await refreshProfile()
setMessage('Die E-Mail-Adresse ist bestätigt.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="auth-screen">
<form className="card" onSubmit={confirm}>
<h1>E-Mail bestätigen</h1>
{!token && <p className="error">Der Link enthält kein Token.</p>}
<p>Der Link gilt 24 Stunden und kann nur einmal verwendet werden.</p>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy || !token}>{busy ? 'Prüfe …' : 'Adresse bestätigen'}</button>
<Link to={session ? '/settings' : '/'}>{session ? 'Zum Konto' : 'Zur Anmeldung'}</Link>
</form>
</div>
)
}

View File

@ -1,6 +1,4 @@
# Kanshō development vhost. Install as /etc/nginx/sites-available/kansho-dev. # Kanshō development vhost. Install as /etc/nginx/sites-available/kansho-dev.
# Live TLS terminates on the Synology Reverse Proxy at 192.168.2.63, not on the Pi.
# Set DSM proxy read/send timeout to 600s (default 60s returns HTTP 504).
server { server {
listen 80; listen 80;
@ -48,6 +46,5 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_read_timeout 600s;
} }
} }

View File

@ -1,8 +1,6 @@
# Kanshō – Host nginx (outside Compose) # Kanshō – Host nginx (outside Compose)
# Install as /etc/nginx/sites-available/kansho and symlink into sites-enabled. # Install as /etc/nginx/sites-available/kansho and symlink into sites-enabled.
# TLS via nginx/certbot-setup.sh. Reverse-proxy targets are the Pi publish ports. # TLS via nginx/certbot-setup.sh. Reverse-proxy targets are the Pi publish ports.
# Live TLS currently terminates on the Synology Reverse Proxy at 192.168.2.63;
# set DSM proxy read/send timeout to 600s (default 60s returns HTTP 504).
server { server {
listen 80; listen 80;
@ -52,6 +50,5 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_read_timeout 600s;
} }
} }