This commit is contained in:
2026-05-20 00:09:28 +02:00
parent 968bf7d102
commit 51b5ddc6f2
695 changed files with 999722 additions and 270 deletions

View File

@@ -60,6 +60,16 @@ from openai import OpenAI
from aza_rate_limit import default_ip_limiter, default_token_limiter
from aza_security import require_api_token
from aza_license_logic import compute_license_decision
from aza_ai_budget import (
budget_gate_blocked_payload_or_none,
budget_json_for_client,
compute_budget_snapshot,
ensure_ai_budget_schema,
estimate_audio_seconds_for_transcription,
record_success_after_openai,
resolve_license_for_device,
resolve_license_for_empfang,
)
from aza_device_enforcement import enforce_and_touch_device, list_devices_for_email
from aza_news_backend import get_news_items, get_event_items
from services.live_event_search import SearchProviderConfigError
@@ -716,6 +726,7 @@ def ensure_license_schema(db_path: Optional[Path] = None) -> None:
_ensure_sqlite_column(con, "licenses", "current_period_end", "INTEGER")
_ensure_sqlite_column(con, "licenses", "license_key", "TEXT")
_ensure_sqlite_column(con, "licenses", "practice_id", "TEXT")
_ensure_sqlite_column(con, "licenses", "current_period_start", "INTEGER")
# Gerätebindungen liegen in derselben Datei (enforce_and_touch_device)
try:
@@ -725,9 +736,126 @@ def ensure_license_schema(db_path: Optional[Path] = None) -> None:
except Exception as exc:
print(f"[LICENSE-SCHEMA] ensure_device_table übersprungen: {exc}")
try:
ensure_ai_budget_schema(con)
except Exception as exc:
print(f"[LICENSE-SCHEMA] ensure_ai_budget_schema übersprungen: {exc}")
# Bestehende Lizenzen: Start der Periode heuristisch, wenn Stripe noch kein Start lieferte
try:
con.execute(
"""
UPDATE licenses
SET current_period_start = CAST(current_period_end AS INTEGER) - 3024000
WHERE current_period_start IS NULL
AND current_period_end IS NOT NULL
"""
)
except Exception:
pass
con.commit()
def _no_license_mapping_response(request_id: str) -> JSONResponse:
return JSONResponse(
status_code=402,
content={
"success": False,
"error_code": "AI_BUDGET_NO_LICENSE_MAPPING",
"message_user": (
"KI-Guthaben konnte keiner aktiven Lizenz zugeordnet werden.\n"
"Bitte Praxis/Lizenz prüfen oder den Support kontaktieren."
),
"available_percent": 0,
"period_end": 0,
"request_id": request_id,
},
)
def _gate_ai_budget_or_none(
*,
db_path: Path,
device_id: Optional[str],
practice_id: Optional[str],
request_id: str,
operation_type: str,
model: str,
) -> Optional[JSONResponse]:
"""402 JSON wenn KI-Budget erschöpft oder keine Lizenz; None wenn Anfrage durch darf."""
if not db_path.exists():
return None
try:
ensure_license_schema(db_path)
with sqlite3.connect(db_path) as con:
lic = resolve_license_for_empfang(
con,
x_device_id=device_id,
session_practice_id=practice_id,
)
if not lic:
if device_id or practice_id:
return _no_license_mapping_response(request_id)
return None
payload = budget_gate_blocked_payload_or_none(
con,
lic,
device_id=device_id,
request_id=request_id,
operation_type=operation_type,
model=model,
gate_meta={"route": "backend_main"},
)
if payload is not None:
return JSONResponse(status_code=402, content=payload)
return None
except Exception as exc:
print(f"[AI-BUDGET] gate error: {exc}")
return None
def _record_ai_budget_success(
*,
device_id: Optional[str],
practice_id: Optional[str],
request_id: str,
model: str,
operation_type: str,
input_tokens: int,
output_tokens: int,
total_tokens: int,
audio_seconds: float,
) -> None:
db_path = _stripe_db_path()
if not db_path.exists():
return
try:
ensure_license_schema(db_path)
with sqlite3.connect(db_path) as con:
lic = resolve_license_for_empfang(
con,
x_device_id=device_id,
session_practice_id=practice_id,
)
if not lic:
return
record_success_after_openai(
con,
lic,
device_id=device_id,
request_id=request_id,
model=model,
operation_type=operation_type,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
audio_seconds=audio_seconds,
)
except Exception as exc:
print(f"[AI-BUDGET] record success error: {exc}")
def _has_any_active_license() -> bool:
db_path = _stripe_db_path()
if not db_path.exists():
@@ -2783,6 +2911,64 @@ async def delete_schedule_item_by_day(request: Request):
})
@app.get("/v1/ai-budget/status", dependencies=[Depends(require_api_token)])
def ai_budget_status(request: Request):
"""Read-only KI-Kontingent für Desktop (nur Prozent, keine USD)."""
device_id = (request.headers.get("X-Device-Id") or "").strip() or None
practice_id = (request.headers.get("X-Practice-Id") or "").strip() or None
db_path = _stripe_db_path()
if not db_path.exists():
return JSONResponse(
status_code=503,
content={
"ok": False,
"active": False,
"available_percent": 0,
"period_end": 0,
"show_warning": False,
"user_label": "KI-Kontingent: Server-Datenbank nicht erreichbar",
"error_code": "AI_BUDGET_DB_MISSING",
},
)
try:
ensure_license_schema(db_path)
with sqlite3.connect(db_path) as con:
lic = resolve_license_for_empfang(
con,
x_device_id=device_id,
session_practice_id=practice_id,
)
if not lic:
return JSONResponse(
content={
"ok": False,
"active": False,
"available_percent": 0,
"period_end": 0,
"show_warning": False,
"user_label": "KI-Kontingent: Gerät nicht einer aktiven Lizenz zugeordnet",
"error_code": "AI_BUDGET_NO_LICENSE_MAPPING",
}
)
snap = compute_budget_snapshot(con, lic)
body = budget_json_for_client(snap, include_operator_fields=False)
return JSONResponse(content=body)
except Exception as exc:
print(f"[AI-BUDGET] status error: {exc}")
return JSONResponse(
status_code=503,
content={
"ok": False,
"active": False,
"available_percent": 0,
"period_end": 0,
"show_warning": False,
"user_label": "KI-Kontingent: Status vorübergehend nicht verfügbar",
"error_code": "AI_BUDGET_STATUS_ERROR",
},
)
@app.post("/v1/transcribe", dependencies=[Depends(require_api_token)])
async def transcribe(
request: Request,
@@ -2809,6 +2995,18 @@ async def transcribe(
pass
request_id = f"srv_{uuid.uuid4().hex[:12]}"
device_id_hdr = (request.headers.get("X-Device-Id") or "").strip() or None
practice_id_hdr = (request.headers.get("X-Practice-Id") or "").strip() or None
blocked = _gate_ai_budget_or_none(
db_path=_stripe_db_path(),
device_id=device_id_hdr,
practice_id=practice_id_hdr,
request_id=request_id,
operation_type="transcription",
model=TRANSCRIBE_MODEL,
)
if blocked is not None:
return blocked
t0 = time.perf_counter()
tmp_path = None
@@ -2957,6 +3155,25 @@ async def transcribe(
print(f'TRANSCRIBE request_id={request_id} file="{fname}" bytes={file_bytes} ms={duration_ms} success=true')
model_used = "whisper-1" if used_fallback else TRANSCRIBE_MODEL
try:
audio_sec = estimate_audio_seconds_for_transcription(
byte_size=file_bytes,
file_path=tmp_path,
suffix=ext,
)
_record_ai_budget_success(
device_id=device_id_hdr,
practice_id=practice_id_hdr,
request_id=request_id,
model=model_used,
operation_type="transcription",
input_tokens=0,
output_tokens=0,
total_tokens=0,
audio_seconds=audio_sec,
)
except Exception:
pass
return JSONResponse(content={
"success": True,
"transcript": text,
@@ -3033,6 +3250,25 @@ async def chat_proxy(request: Request, body: ChatRequest):
},
)
device_id_hdr = (request.headers.get("X-Device-Id") or "").strip() or None
practice_id_hdr = (request.headers.get("X-Practice-Id") or "").strip() or None
op_type = "chat"
for msg in reversed(body.messages):
c = (msg.content or "")
if "TRANSKRIPT:" in c.upper():
op_type = "kg"
break
blocked = _gate_ai_budget_or_none(
db_path=_stripe_db_path(),
device_id=device_id_hdr,
practice_id=practice_id_hdr,
request_id=request_id,
operation_type=op_type,
model=model,
)
if blocked is not None:
return blocked
try:
client = _get_openai()
@@ -3064,6 +3300,24 @@ async def chat_proxy(request: Request, body: ChatRequest):
duration_ms = int((time.perf_counter() - t0) * 1000)
print(f"CHAT request_id={request_id} model={model} ms={duration_ms} success=true")
try:
pt = int(usage["prompt_tokens"]) if usage else 0
ct = int(usage["completion_tokens"]) if usage else 0
tt = int(usage["total_tokens"]) if usage and usage.get("total_tokens") else (pt + ct)
_record_ai_budget_success(
device_id=device_id_hdr,
practice_id=practice_id_hdr,
request_id=request_id,
model=(resp.model or model) if resp else model,
operation_type=op_type,
input_tokens=pt,
output_tokens=ct,
total_tokens=tt,
audio_seconds=0.0,
)
except Exception:
pass
return JSONResponse(content={
"success": True,
"content": content or "",