Commits vergleichen

...

1 Commits

Autor SHA1 Nachricht Datum
claude-dev
4532086e0e feat(orgs): KI-Verarbeitung organisationsweit einstellbar
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) <noreply@anthropic.com>
2026-08-02 18:31:06 +00:00
6 geänderte Dateien mit 190 neuen und 3 gelöschten Zeilen

Datei anzeigen

@@ -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" 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/: 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)" 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" 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" 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_models.py: Pydantic-Validierung (MagicLink, Org, License, User)
test_source_meta.py: Single Source of Truth Konsistenz test_source_meta.py: Single Source of Truth Konsistenz
test_imports.py: alle Backend-Module importierbar (Syntax-Catchnet) 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: philosophie:
- reine Unit-Tests, kein DB-Zugriff, kein HTTP-Server - reine Unit-Tests, kein DB-Zugriff, kein HTTP-Server

Datei anzeigen

@@ -49,6 +49,8 @@ class OrgCreate(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$") slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$")
output_language: str = Field(default="de", pattern="^(de|en)$") 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 # Optional direkt eine Lizenz mit Credits-Kontingent anlegen
license: Optional[LicenseParams] = None license: Optional[LicenseParams] = None
@@ -57,6 +59,7 @@ class OrgUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200) name: Optional[str] = Field(default=None, max_length=200)
is_active: Optional[bool] = None is_active: Optional[bool] = None
output_language: Optional[str] = Field(default=None, pattern="^(de|en)$") output_language: Optional[str] = Field(default=None, pattern="^(de|en)$")
ai_backend: Optional[str] = Field(default=None, pattern="^(cli|bedrock|)$")
class OrgResponse(BaseModel): class OrgResponse(BaseModel):
@@ -71,6 +74,7 @@ class OrgResponse(BaseModel):
globe_access: bool = False globe_access: bool = False
network_access: bool = False network_access: bool = False
output_language: str = "de" output_language: str = "de"
ai_backend: str = ""
class LicenseCreate(LicenseParams): class LicenseCreate(LicenseParams):

Datei anzeigen

@@ -34,6 +34,14 @@ async def _enrich_org(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict:
lang_row = await cursor.fetchone() lang_row = await cursor.fetchone()
org["output_language"] = lang_row["value"] if lang_row else "de" 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 return org
@@ -79,6 +87,7 @@ async def create_organization(
# output_language als organization_settings-Eintrag persistieren # output_language als organization_settings-Eintrag persistieren
from shared.services.org_settings import set_org_setting 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, "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,)) cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,))
new_row_obj = await cursor.fetchone() new_row_obj = await cursor.fetchone()
@@ -138,6 +147,12 @@ async def update_organization(
from shared.services.org_settings import set_org_setting 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, "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) after = await row_to_dict(db, "organizations", org_id)
await log_action( await log_action(
db, admin, get_client_ip(request), db, admin, get_client_ip(request),

Datei anzeigen

@@ -170,6 +170,7 @@
<th>Gültig bis</th> <th>Gültig bis</th>
<th>Status</th> <th>Status</th>
<th>Pipeline-Sprache</th> <th>Pipeline-Sprache</th>
<th>KI-Verarbeitung</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@@ -431,6 +432,15 @@
</select> </select>
<small class="text-secondary">Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.</small> <small class="text-secondary">Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.</small>
</div> </div>
<div class="form-group">
<label for="newOrgAiBackend">KI-Verarbeitung</label>
<select id="newOrgAiBackend">
<option value="" selected>Servervorgabe</option>
<option value="cli">Anthropic (heutiger Weg)</option>
<option value="bedrock">EU (Frankfurt, DSGVO-Fassung)</option>
</select>
<small class="text-secondary">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.</small>
</div>
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);"> <div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
@@ -943,7 +953,7 @@
</div> </div>
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260725y"></script> <script src="/static/js/app.js?v=20260802a"></script>
<script src="/static/js/sources.js?v=20260725h"></script> <script src="/static/js/sources.js?v=20260725h"></script>
<script src="/static/js/aufgaben.js?v=20260725d"></script> <script src="/static/js/aufgaben.js?v=20260725d"></script>
<script src="/static/js/x-scraper.js?v=20260725c"></script> <script src="/static/js/x-scraper.js?v=20260725c"></script>

Datei anzeigen

@@ -359,6 +359,14 @@ function setOrgLanguage(wert) {
_saveOrgSetting({ output_language: wert }, "Pipeline-Sprache gespeichert"); _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) // Verbrauchszähler direkt an der Kachel korrigieren (Kulanz, Fehlbereinigung)
async function korrigiereVerbrauch() { async function korrigiereVerbrauch() {
const input = document.getElementById("korrCreditsUsed"); const input = document.getElementById("korrCreditsUsed");
@@ -542,7 +550,7 @@ async function loadOrgLicenses(orgId) {
const licenses = await API.get(`/api/licenses?org_id=${orgId}`); const licenses = await API.get(`/api/licenses?org_id=${orgId}`);
const tbody = document.getElementById("licenseTable"); const tbody = document.getElementById("licenseTable");
if (licenses.length === 0) { if (licenses.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Lizenzen</td></tr>'; tbody.innerHTML = '<tr><td colspan="8" class="text-muted">Keine Lizenzen</td></tr>';
return; return;
} }
// Pipeline-Sprache ist eine Org-Einstellung und steht nur in der aktiven // Pipeline-Sprache ist eine Org-Einstellung und steht nur in der aktiven
@@ -553,6 +561,15 @@ async function loadOrgLicenses(orgId) {
<option value="de"${(org.output_language || "de") === "de" ? " selected" : ""}>Deutsch</option> <option value="de"${(org.output_language || "de") === "de" ? " selected" : ""}>Deutsch</option>
<option value="en"${org.output_language === "en" ? " selected" : ""}>English</option> <option value="en"${org.output_language === "en" ? " selected" : ""}>English</option>
</select></td>`; </select></td>`;
// 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 = `
<td><select style="width:auto; min-width:110px;" onchange="setOrgAiBackend(this.value)" title="Wo die KI-Verarbeitung stattfindet. EU bedeutet AWS Bedrock in Frankfurt und Recherche über den europäischen Suchindex staan.">
<option value=""${ki === "" ? " selected" : ""}>Servervorgabe</option>
<option value="cli"${ki === "cli" ? " selected" : ""}>Anthropic</option>
<option value="bedrock"${ki === "bedrock" ? " selected" : ""}>EU (DSGVO)</option>
</select></td>`;
tbody.innerHTML = licenses.map(l => ` tbody.innerHTML = licenses.map(l => `
<tr> <tr>
<td><span class="badge badge-${l.license_type}">${l.license_type}</span></td> <td><span class="badge badge-${l.license_type}">${l.license_type}</span></td>
@@ -564,6 +581,7 @@ async function loadOrgLicenses(orgId) {
<td>${l.valid_until ? formatDate(l.valid_until) : "Unbegrenzt"}</td> <td>${l.valid_until ? formatDate(l.valid_until) : "Unbegrenzt"}</td>
<td><span class="badge badge-${l.status}">${l.status}</span></td> <td><span class="badge badge-${l.status}">${l.status}</span></td>
${l.status === "active" ? spracheZelle : '<td class="text-muted">–</td>'} ${l.status === "active" ? spracheZelle : '<td class="text-muted">–</td>'}
${l.status === "active" ? kiZelle : '<td class="text-muted">–</td>'}
<td> <td>
${l.status === "active" ? ` ${l.status === "active" ? `
<button class="btn btn-secondary btn-small" onclick="extendLicense(${l.id})">Verlängern</button> <button class="btn btn-secondary btn-small" onclick="extendLicense(${l.id})">Verlängern</button>
@@ -671,6 +689,7 @@ function setupForms() {
name: document.getElementById("newOrgName").value, name: document.getElementById("newOrgName").value,
slug: baseSlug, slug: baseSlug,
output_language: document.getElementById("newOrgLanguage").value || "de", output_language: document.getElementById("newOrgLanguage").value || "de",
ai_backend: (document.getElementById("newOrgAiBackend") || {}).value || "",
}; };
// Optional direkt eine Lizenz mit Credits-Kontingent mitgeben // Optional direkt eine Lizenz mit Credits-Kontingent mitgeben
const withLic = document.getElementById("newOrgWithLicense"); const withLic = document.getElementById("newOrgWithLicense");

138
tests/test_org_ki_vorgabe.py Normale Datei
Datei anzeigen

@@ -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('<tbody id="licenseTable">')
kopf = html[html.rindex("<thead>", 0, bis):bis]
assert len(re.findall(r"<th>", kopf)) == 8
assert "<th>KI-Verarbeitung</th>" 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"<td", zeile)) == 8
assert "spracheZelle" in zeile and "kiZelle" in zeile
def test_leere_tabelle_spannt_ueber_acht_spalten(js):
assert 'colspan="8"' in js
assert 'colspan="7"' not in js
def test_speicherfunktion_reicht_den_wert_unveraendert_durch(js):
assert "function setOrgAiBackend(wert)" in js
assert "_saveOrgSetting({ ai_backend: wert }" in js
assert 'onchange="setOrgAiBackend(this.value)"' in js
def test_auswahl_bietet_drei_werte(js):
auswahl = re.search(r"const kiZelle = `.*?`;", js, re.S).group(0)
assert re.findall(r'value="([^"]*)"', auswahl) == ["", "cli", "bedrock"]
assert auswahl.count("selected") == 3 # jeder Wert kann vorausgewaehlt sein
def test_anlege_dialog_hat_dasselbe_feld(html, js):
auswahl = re.search(r'<select id="newOrgAiBackend">.*?</select>', 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"]