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"]