Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 032eeb54fb |
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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 {},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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)
|
|
||||||
);
|
|
||||||
|
|
@ -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 '';
|
|
||||||
|
|
@ -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 <> '';
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,59 +1055,13 @@ 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:
|
outcome = detect_personal_egress(request.profile_id, rendered)
|
||||||
mappings = list(precomputed)
|
mappings = outcome.mappings
|
||||||
extra = dict((request.payload or {}).get("detect_counters") or {})
|
detect_stats = outcome.stats.public()
|
||||||
detect_stats = {
|
detect_name = outcome.stats.detect_provider
|
||||||
"detect_note": "precomputed_learning_review",
|
detect_note = outcome.stats.detect_note
|
||||||
"full_detection_coverage": True,
|
local_identities = outcome.local_identities
|
||||||
"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)
|
|
||||||
mappings = outcome.mappings
|
|
||||||
detect_stats = outcome.stats.public()
|
|
||||||
detect_name = outcome.stats.detect_provider
|
|
||||||
detect_note = outcome.stats.detect_note
|
|
||||||
local_identities = outcome.local_identities
|
|
||||||
except DetectError as exc:
|
except DetectError as exc:
|
||||||
detect_stats = dict(exc.diagnostics or {})
|
detect_stats = dict(exc.diagnostics or {})
|
||||||
_log_event(
|
_log_event(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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. "
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
```
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,147 +47,53 @@ 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) => {
|
|
||||||
setData(payload)
|
|
||||||
const next = {}
|
|
||||||
for (const role of payload.roles || []) {
|
|
||||||
next[role.role] = {
|
|
||||||
role: role.role,
|
|
||||||
name: role.name || '',
|
|
||||||
url: role.url || '',
|
|
||||||
model: role.model || '',
|
|
||||||
zdr: Boolean(role.zdr),
|
|
||||||
no_train: Boolean(role.no_train),
|
|
||||||
profile_id: role.profile_id || '',
|
|
||||||
key: '',
|
|
||||||
saveAsTitle: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForms(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = () =>
|
const load = () =>
|
||||||
api('/api/admin/providers', { token: session.token })
|
api('/api/admin/providers', { token: session.token })
|
||||||
.then(applyPayload)
|
.then((payload) => {
|
||||||
|
setData(payload)
|
||||||
|
const next = {}
|
||||||
|
for (const role of payload.roles) {
|
||||||
|
next[role.role] = {
|
||||||
|
role: role.role,
|
||||||
|
name: role.name || '',
|
||||||
|
url: role.url || '',
|
||||||
|
model: role.model || '',
|
||||||
|
zdr: Boolean(role.zdr),
|
||||||
|
no_train: Boolean(role.no_train),
|
||||||
|
key: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setForms(next)
|
||||||
|
})
|
||||||
.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,273 +136,94 @@ 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'}
|
||||||
/>
|
/>
|
||||||
|
<datalist id={listId}>
|
||||||
|
{options.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name && item.name !== item.id ? item.name : item.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
</label>
|
</label>
|
||||||
<label className="check">
|
<label className="check">
|
||||||
<input type="checkbox" checked={form.zdr} onChange={(e) => updateRole(role.role, 'zdr', e.target.checked)} />
|
<input type="checkbox" checked={form.zdr} onChange={(e) => update(role.role, '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={form.no_train}
|
checked={form.no_train}
|
||||||
onChange={(e) => updateRole(role.role, 'no_train', e.target.checked)}
|
onChange={(e) => update(role.role, 'no_train', e.target.checked)}
|
||||||
/>
|
/>
|
||||||
kein Provider-Training
|
kein Provider-Training
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
API-Key {view.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(für externe URL)'}
|
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(pflichtig für externe URL)'}
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={form.key}
|
value={form.key}
|
||||||
onChange={(e) => updateRole(role.role, 'key', e.target.value)}
|
onChange={(e) => update(role.role, 'key', e.target.value)}
|
||||||
placeholder={view.local ? 'lokal ohne Key' : (role.role === 'detect' ? 'leer = Key des Sprachmodells' : '')}
|
placeholder={role.role === 'detect' ? 'leer = Key des Sprachmodells' : ''}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={saving === role.role}>
|
<button type="submit" disabled={saving === role.role}>
|
||||||
{saving === role.role ? 'Speichert …' : 'Stufe speichern'}
|
{saving === role.role ? 'Speichert …' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<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>
|
||||||
<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}>
|
|
||||||
{item.name && item.name !== item.id ? item.name : item.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</label>
|
|
||||||
<label className="check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={draft.zdr}
|
|
||||||
onChange={(e) => setDraft((prev) => ({ ...prev, zdr: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
Zero Data Retention
|
|
||||||
</label>
|
|
||||||
<label className="check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={draft.no_train}
|
|
||||||
onChange={(e) => setDraft((prev) => ({ ...prev, no_train: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
kein Provider-Training
|
|
||||||
</label>
|
|
||||||
<div className="row-actions">
|
|
||||||
<button type="submit" disabled={saving === 'draft'}>
|
|
||||||
{saving === 'draft' ? 'Speichert …' : (draft.id ? 'Profil speichern' : 'Profil anlegen')}
|
|
||||||
</button>
|
|
||||||
{draft.id && (
|
|
||||||
<button type="button" className="ghost" onClick={() => setDraft(emptyDraft())}>
|
|
||||||
Neu
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</thead>
|
||||||
<th>Benutzername</th>
|
<tbody>
|
||||||
<th>E-Mail</th>
|
{users.map((user) => (
|
||||||
<th>Zweite E-Mail</th>
|
<tr key={user.id}>
|
||||||
<th>Rolle</th>
|
<td>{user.name}</td>
|
||||||
<th>Status</th>
|
<td>{user.email}</td>
|
||||||
<th />
|
<td>{user.role}</td>
|
||||||
|
<td>{user.status}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="ghost" onClick={() => toggleStatus(user)}>
|
||||||
|
{user.status === 'active' ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{users.map((user) => {
|
</table>
|
||||||
const lastAdmin = user.role === 'admin' && user.status === 'active' && activeAdmins < 2
|
|
||||||
return (
|
|
||||||
<tr key={user.id}>
|
|
||||||
<td>{user.name}</td>
|
|
||||||
<td>{user.username || '—'}</td>
|
|
||||||
<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>
|
|
||||||
<div className="row-actions">
|
|
||||||
<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'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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,35 +109,10 @@ 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()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
if (activeId) await openConversation(activeId)
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
await loadList()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
if (activeId) await openConversation(activeId)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user