Commits vergleichen
1 Commits
| Autor | SHA1 | Datum | |
|---|---|---|---|
|
|
4532086e0e |
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
<th>Gültig bis</th>
|
||||
<th>Status</th>
|
||||
<th>Pipeline-Sprache</th>
|
||||
<th>KI-Verarbeitung</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -431,6 +432,15 @@
|
||||
</select>
|
||||
<small class="text-secondary">Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor.</small>
|
||||
</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);">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
|
||||
@@ -943,7 +953,7 @@
|
||||
</div>
|
||||
|
||||
<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/aufgaben.js?v=20260725d"></script>
|
||||
<script src="/static/js/x-scraper.js?v=20260725c"></script>
|
||||
|
||||
@@ -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 = '<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;
|
||||
}
|
||||
// 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="en"${org.output_language === "en" ? " selected" : ""}>English</option>
|
||||
</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 => `
|
||||
<tr>
|
||||
<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><span class="badge badge-${l.status}">${l.status}</span></td>
|
||||
${l.status === "active" ? spracheZelle : '<td class="text-muted">–</td>'}
|
||||
${l.status === "active" ? kiZelle : '<td class="text-muted">–</td>'}
|
||||
<td>
|
||||
${l.status === "active" ? `
|
||||
<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,
|
||||
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");
|
||||
|
||||
138
tests/test_org_ki_vorgabe.py
Normale Datei
138
tests/test_org_ki_vorgabe.py
Normale Datei
@@ -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"]
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren