update
This commit is contained in:
@@ -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 "",
|
||||
|
||||
Reference in New Issue
Block a user