From 4532086e0e4b11149834419e066b936fb743536a Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sun, 2 Aug 2026 18:31:06 +0000 Subject: [PATCH] feat(orgs): KI-Verarbeitung organisationsweit einstellbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Monitor löst den KI-Weg als Lage vor Organisation vor Servervorgabe auf und liest dafür das Org-Setting ai_backend. Geschrieben werden konnte es bisher nur direkt in der Datenbank, eine Oberfläche gab es nicht. Für einen Kunden, der die DSGVO-Fassung will, ist das der praktisch wichtigste Weg, denn der stellt einmal zentral um und nicht Lage für Lage. Das Feld steht jetzt dort, wo die Pipeline-Sprache schon steht, also als eigene Spalte in der Lizenztabelle der aktiven Lizenz, und zusätzlich im Anlege-Dialog für neue Organisationen. Drei Werte. Leer bedeutet Servervorgabe, cli den heutigen Weg über Anthropic, bedrock die EU-Fassung über AWS Bedrock in Frankfurt mit Recherche über den europäischen Suchindex staan. Der Leerstring ist dabei ein gültiger Wert und keine fehlende Angabe. Der Router prüft deshalb auf None statt auf Wahrheit, sonst ließe sich eine gesetzte Vorgabe nie wieder auf die Servervorgabe zurücknehmen. 26 Prüfungen, ohne Datenbank und ohne Netz. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 3 +- src/models.py | 4 + src/routers/organizations.py | 15 ++++ src/static/dashboard.html | 12 ++- src/static/js/app.js | 21 +++++- tests/test_org_ki_vorgabe.py | 138 +++++++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 tests/test_org_ki_vorgabe.py diff --git a/CLAUDE.md b/CLAUDE.md index 827cf69..44c514a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ src/: health_run.py: "In-Memory-Status des manuellen Health-Runs (pure Funktionen, unit-testbar). Der Knopf Jetzt prüfen ruft die geteilten Dienste source_health + source_suggester auf (POST /api/sources/health/run, Polling über GET /health/run-status). Zusätzlich zum Monitor-Nachtjob um 04:00. Status ist nach Neustart idle, partieller Stand heilt beim nächsten Lauf" routers/: - organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)" + organizations.py: "CRUD Mandanten (organizations + Org-Settings inkl. output_language und ai_backend + Token-Budget)" licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode; Helper insert_license_row wird auch vom Org-Anlegen genutzt). Kein Credits-Übertrag mehr (Produktentscheidung 07/2026, ungenutzte Credits verfallen)" users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de" sources.py: "Vereinte Quellenliste (GET /api/sources?scope=all|global|tenant inkl. org_name + health_status), Stats/Languages, Einzelquellen-Health (GET /{id}/health), Aufgaben-Badge (GET /tasks/summary), Promote, Discovery, PDF-Upload, KI-Vorschläge, Klassifikation" @@ -219,6 +219,7 @@ tests: test_models.py: Pydantic-Validierung (MagicLink, Org, License, User) test_source_meta.py: Single Source of Truth Konsistenz test_imports.py: alle Backend-Module importierbar (Syntax-Catchnet) + test_org_ki_vorgabe.py: organisationsweiter KI-Weg (ai_backend), Modelle, Router und Oberflaeche. Der Leerstring bedeutet Servervorgabe und muss von einer fehlenden Angabe unterscheidbar bleiben philosophie: - reine Unit-Tests, kein DB-Zugriff, kein HTTP-Server diff --git a/src/models.py b/src/models.py index abf9728..1fb4df4 100644 --- a/src/models.py +++ b/src/models.py @@ -49,6 +49,8 @@ class OrgCreate(BaseModel): name: str = Field(min_length=1, max_length=200) slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$") output_language: str = Field(default="de", pattern="^(de|en)$") + # KI-Weg der Organisation. Leer heisst, es gilt die Servervorgabe. + ai_backend: str = Field(default="", pattern="^(cli|bedrock|)$") # Optional direkt eine Lizenz mit Credits-Kontingent anlegen license: Optional[LicenseParams] = None @@ -57,6 +59,7 @@ class OrgUpdate(BaseModel): name: Optional[str] = Field(default=None, max_length=200) is_active: Optional[bool] = None output_language: Optional[str] = Field(default=None, pattern="^(de|en)$") + ai_backend: Optional[str] = Field(default=None, pattern="^(cli|bedrock|)$") class OrgResponse(BaseModel): @@ -71,6 +74,7 @@ class OrgResponse(BaseModel): globe_access: bool = False network_access: bool = False output_language: str = "de" + ai_backend: str = "" class LicenseCreate(LicenseParams): diff --git a/src/routers/organizations.py b/src/routers/organizations.py index 28f38e7..3292478 100644 --- a/src/routers/organizations.py +++ b/src/routers/organizations.py @@ -34,6 +34,14 @@ async def _enrich_org(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict: lang_row = await cursor.fetchone() org["output_language"] = lang_row["value"] if lang_row else "de" + # ai_backend aus organization_settings (leer = Servervorgabe) + cursor = await db.execute( + "SELECT value FROM organization_settings WHERE organization_id = ? AND key = 'ai_backend'", + (org["id"],), + ) + ai_row = await cursor.fetchone() + org["ai_backend"] = (ai_row["value"] if ai_row else "") or "" + return org @@ -79,6 +87,7 @@ async def create_organization( # output_language als organization_settings-Eintrag persistieren from shared.services.org_settings import set_org_setting await set_org_setting(db, org_id, "output_language", data.output_language) + await set_org_setting(db, org_id, "ai_backend", data.ai_backend or "") cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)) new_row_obj = await cursor.fetchone() @@ -138,6 +147,12 @@ async def update_organization( from shared.services.org_settings import set_org_setting await set_org_setting(db, org_id, "output_language", data.output_language) + # KI-Weg. Der Leerstring ist ein gueltiger Wert und bedeutet + # "Servervorgabe", deshalb hier die Pruefung auf None statt auf Wahrheit. + if data.ai_backend is not None: + from shared.services.org_settings import set_org_setting + await set_org_setting(db, org_id, "ai_backend", data.ai_backend) + after = await row_to_dict(db, "organizations", org_id) await log_action( db, admin, get_client_ip(request), diff --git a/src/static/dashboard.html b/src/static/dashboard.html index e43b314..03d0a0c 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -170,6 +170,7 @@ Gültig bis Status Pipeline-Sprache + KI-Verarbeitung Aktionen @@ -431,6 +432,15 @@ Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor. +
+ + + Gilt für alle Lagen dieser Organisation, solange eine Lage nichts Eigenes einstellt. EU verarbeitet über AWS Bedrock in Frankfurt und recherchiert über den europäischen Suchindex staan. +
- + diff --git a/src/static/js/app.js b/src/static/js/app.js index 7771a20..cd69039 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -359,6 +359,14 @@ function setOrgLanguage(wert) { _saveOrgSetting({ output_language: wert }, "Pipeline-Sprache gespeichert"); } +// Der Leerstring ist hier ein gueltiger Wert (Servervorgabe), er darf +// also nicht wegoptimiert werden. +function setOrgAiBackend(wert) { + const namen = { "": "Servervorgabe", cli: "Anthropic", bedrock: "EU (Frankfurt)" }; + _saveOrgSetting({ ai_backend: wert }, + "KI-Verarbeitung gespeichert, " + (namen[wert] || wert)); +} + // Verbrauchszähler direkt an der Kachel korrigieren (Kulanz, Fehlbereinigung) async function korrigiereVerbrauch() { const input = document.getElementById("korrCreditsUsed"); @@ -542,7 +550,7 @@ async function loadOrgLicenses(orgId) { const licenses = await API.get(`/api/licenses?org_id=${orgId}`); const tbody = document.getElementById("licenseTable"); if (licenses.length === 0) { - tbody.innerHTML = 'Keine Lizenzen'; + tbody.innerHTML = 'Keine Lizenzen'; return; } // Pipeline-Sprache ist eine Org-Einstellung und steht nur in der aktiven @@ -553,6 +561,15 @@ async function loadOrgLicenses(orgId) { `; + // KI-Weg der Organisation. Greift fuer jede Lage, die nichts Eigenes + // eingestellt hat. Leer heisst, es gilt die Servervorgabe. + const ki = org.ai_backend || ""; + const kiZelle = ` + `; tbody.innerHTML = licenses.map(l => ` ${l.license_type} @@ -564,6 +581,7 @@ async function loadOrgLicenses(orgId) { ${l.valid_until ? formatDate(l.valid_until) : "Unbegrenzt"} ${l.status} ${l.status === "active" ? spracheZelle : '–'} + ${l.status === "active" ? kiZelle : '–'} ${l.status === "active" ? ` @@ -671,6 +689,7 @@ function setupForms() { name: document.getElementById("newOrgName").value, slug: baseSlug, output_language: document.getElementById("newOrgLanguage").value || "de", + ai_backend: (document.getElementById("newOrgAiBackend") || {}).value || "", }; // Optional direkt eine Lizenz mit Credits-Kontingent mitgeben const withLic = document.getElementById("newOrgWithLicense"); diff --git a/tests/test_org_ki_vorgabe.py b/tests/test_org_ki_vorgabe.py new file mode 100644 index 0000000..bcd937e --- /dev/null +++ b/tests/test_org_ki_vorgabe.py @@ -0,0 +1,138 @@ +"""Prueft die organisationsweite KI-Vorgabe ohne Datenbank und ohne Netz. + +Der Monitor loest den KI-Weg als Lage vor Organisation vor Servervorgabe auf. +Diese Datei deckt das mittlere Glied ab, also das Org-Setting 'ai_backend'. +Geprueft werden die Modelle (welche Werte kommen durch), der Router (wird das +Setting geschrieben und gelesen) und die Oberflaeche (Spaltenzahl der Tabelle, +Behandlung des Leerstrings als gueltiger Wert). +""" +import os +import re + +import pytest +from pydantic import ValidationError + +from models import OrgCreate, OrgResponse, OrgUpdate + +STAMM = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def lies(*teile): + with open(os.path.join(STAMM, *teile), encoding="utf-8") as f: + return f.read() + + +@pytest.fixture(scope="module") +def router(): + return lies("src", "routers", "organizations.py") + + +@pytest.fixture(scope="module") +def html(): + return lies("src", "static", "dashboard.html") + + +@pytest.fixture(scope="module") +def js(): + return lies("src", "static", "js", "app.js") + + +# --- Modelle --------------------------------------------------------------- + +@pytest.mark.parametrize("wert", ["", "cli", "bedrock"]) +def test_orgcreate_nimmt_gueltige_werte(wert): + assert OrgCreate(name="Test", slug="test", ai_backend=wert).ai_backend == wert + + +@pytest.mark.parametrize("wert", ["gpt", "CLI", "bedrock ", "openai", "anthropic"]) +def test_orgcreate_lehnt_ungueltige_werte_ab(wert): + with pytest.raises(ValidationError): + OrgCreate(name="Test", slug="test", ai_backend=wert) + + +def test_orgcreate_ohne_angabe_ist_leer(): + assert OrgCreate(name="T", slug="t").ai_backend == "" + + +@pytest.mark.parametrize("wert", ["", "cli", "bedrock"]) +def test_orgupdate_nimmt_gueltige_werte(wert): + assert OrgUpdate(ai_backend=wert).ai_backend == wert + + +def test_orgupdate_unterscheidet_leerstring_von_fehlender_angabe(): + """Der Leerstring bedeutet ausdruecklich "Servervorgabe" und muss den + Router erreichen. Eine fehlende Angabe darf dagegen nichts aendern.""" + assert OrgUpdate(ai_backend="").ai_backend == "" + assert OrgUpdate(name="x").ai_backend is None + + +def test_orgresponse_liefert_das_feld(): + felder = OrgResponse.model_fields + assert "ai_backend" in felder + assert felder["ai_backend"].default == "" + + +# --- Router ---------------------------------------------------------------- + +def test_router_setzt_beim_anlegen(router): + assert 'set_org_setting(db, org_id, "ai_backend", data.ai_backend or "")' in router + + +def test_router_setzt_beim_aendern(router): + assert 'set_org_setting(db, org_id, "ai_backend", data.ai_backend)' in router + + +def test_router_prueft_auf_none_statt_auf_wahrheit(router): + """Eine Pruefung auf Wahrheit wuerde das Zuruecksetzen auf die + Servervorgabe verschlucken, denn der Leerstring ist falsy.""" + assert "if data.ai_backend is not None:" in router + + +def test_router_liefert_das_setting_mit(router): + assert "key = 'ai_backend'" in router + assert 'org["ai_backend"] = (ai_row["value"] if ai_row else "") or ""' in router + + +# --- Oberflaeche ----------------------------------------------------------- + +def test_lizenztabelle_hat_acht_spalten(html): + bis = html.index('') + kopf = html[html.rindex("", 0, bis):bis] + assert len(re.findall(r"", kopf)) == 8 + assert "KI-Verarbeitung" in kopf + + +def test_jede_zeile_fuellt_acht_spalten(js): + """Beide bedingten Zellen liefern in jedem Zweig genau ein td, im + else-Zweig steht es woertlich im Template. Zaehlen genuegt also.""" + zeile = re.search(r"tbody\.innerHTML = licenses\.map.*?\.join\(\"\"\);", js, re.S).group(0) + assert len(re.findall(r".*?', html, re.S) + assert auswahl is not None + assert re.findall(r'value="([^"]*)"', auswahl.group(0)) == ["", "cli", "bedrock"] + assert 'ai_backend: (document.getElementById("newOrgAiBackend") || {}).value || ""' in js + + +def test_cache_buster_wurde_erhoeht(html): + assert re.findall(r"/static/js/app\.js\?v=([0-9a-z]+)", html) == ["20260802a"]