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

@@ -15,6 +15,7 @@ import os
import re
from collections import defaultdict
import secrets
import sqlite3
import tempfile
import time
import unicodedata
@@ -791,6 +792,14 @@ def _extract_client_ip(request: Request) -> str:
# Messages (practice-scoped)
# =====================================================================
# Serverseitige Chat-Aufbewahrung: beim Laden von internem Chat
# (empfang_nachrichten.json) und Cross-Praxis-DM (empfang_external_messages.json)
# werden Nachrichten aelter als diese Tage aus dem jeweiligen Store ENTFERNT und
# die Datei ggf. neu geschrieben (siehe _load_messages / _load_external_dm_messages).
# Hinweis: Anhangsdateien unter data/empfang_attachments werden dabei NICHT
# automatisch geloescht (separater Orphan-Pfad / kuenftige Wartung).
# Aufgaben (empfang_tasks.json), Notizen (empfang_user_notes.json) und
# Arzt-Brieflogik ausserhalb dieser Stores sind hiervon nicht betroffen.
EMPFANG_MESSAGE_RETENTION_DAYS = 14
@@ -7684,13 +7693,14 @@ def _audio_suffix_for_attachment(att: dict) -> str:
return ".webm"
def _empfang_transcribe_openai_from_bytes(
def _empfang_transcribe_openai_from_bytes_core(
audio_bytes: bytes,
*,
filename_suffix: str = ".webm",
) -> str:
"""Eine Audiodatei transkribieren (OpenAI wie backend /v1/transcribe). Tempfile wird geloescht."""
) -> Tuple[str, float]:
"""OpenAI-Transkription (Kernpfad). Tempfile wird geloescht. Zweiter Rueckgabewert: geschaetzte Audiodauer (s)."""
import backend_main as bm
from aza_ai_budget import estimate_audio_seconds_for_transcription
tmp_path: Optional[str] = None
try:
@@ -7733,7 +7743,12 @@ def _empfang_transcribe_openai_from_bytes(
text = bm.apply_medical_corrections(text, "")
text = bm.apply_medical_post_corrections(text)
text = bm.apply_medication_fuzzy_corrections(text)
return (text or "").strip()
audio_sec = estimate_audio_seconds_for_transcription(
byte_size=len(audio_bytes),
file_path=tmp_path,
suffix=filename_suffix,
)
return (text or "").strip(), float(audio_sec)
finally:
if tmp_path:
try:
@@ -7742,6 +7757,133 @@ def _empfang_transcribe_openai_from_bytes(
pass
def _empfang_transcribe_openai_budgeted(
request: Request,
audio_bytes: bytes,
*,
practice_id: str,
filename_suffix: str = ".webm",
) -> Tuple[Optional[JSONResponse], Optional[str]]:
"""KI-Budget Gate + Recording fuer Empfang-Transkription; (JSONResponse-402, None) oder (None, transcript)."""
import backend_main as bm
from aza_ai_budget import (
budget_gate_blocked_payload_or_none,
ensure_ai_budget_schema,
record_openai_error_event,
record_success_after_openai,
resolve_license_for_empfang,
)
request_id = f"ef_tr_{uuid.uuid4().hex[:12]}"
db_path = bm._stripe_db_path()
x_dev = (request.headers.get("X-Device-Id") or "").strip() or None
if not db_path.exists():
try:
text, _sec = _empfang_transcribe_openai_from_bytes_core(
audio_bytes, filename_suffix=filename_suffix
)
return None, text
except HTTPException:
raise
except Exception:
raise
lic = None
try:
bm.ensure_license_schema(db_path)
with sqlite3.connect(str(db_path)) as con:
ensure_ai_budget_schema(con)
lic = resolve_license_for_empfang(
con, x_device_id=x_dev, session_practice_id=(practice_id or "").strip()
)
if lic is None:
return (
JSONResponse(
status_code=402,
content={
"success": False,
"error_code": "AI_BUDGET_NO_LICENSE_MAPPING",
"message_user": (
"KI-Kontingent konnte nicht zugeordnet werden (keine aktive Lizenz fuer diese Praxis).\n"
"Bitte wenden Sie sich an den Praxisadministrator."
),
"available_percent": 0,
"period_end": 0,
"request_id": request_id,
},
),
None,
)
blocked = budget_gate_blocked_payload_or_none(
con,
lic,
device_id=x_dev,
request_id=request_id,
operation_type="transcription",
model=bm.TRANSCRIBE_MODEL,
gate_meta={"route": "empfang_transcribe"},
)
if blocked is not None:
return JSONResponse(status_code=402, content=blocked), None
except Exception as exc:
_log.warning("EMPFANG_AI_BUDGET_GATE err=%s", exc)
lic = None
if lic is None:
try:
text, _s = _empfang_transcribe_openai_from_bytes_core(
audio_bytes, filename_suffix=filename_suffix
)
return None, text
except HTTPException:
raise
except Exception:
raise
try:
text, audio_sec = _empfang_transcribe_openai_from_bytes_core(
audio_bytes, filename_suffix=filename_suffix
)
except Exception as exc:
try:
bm.ensure_license_schema(db_path)
with sqlite3.connect(str(db_path)) as con:
record_openai_error_event(
con,
lic,
device_id=x_dev,
request_id=request_id,
model=bm.TRANSCRIBE_MODEL,
operation_type="transcription",
error_code=type(exc).__name__,
meta={"route": "empfang_transcribe", "nbytes": len(audio_bytes)},
)
except Exception:
pass
raise
try:
bm.ensure_license_schema(db_path)
with sqlite3.connect(str(db_path)) as con:
record_success_after_openai(
con,
lic,
device_id=x_dev,
request_id=request_id,
model=bm.TRANSCRIBE_MODEL,
operation_type="transcription",
input_tokens=0,
output_tokens=0,
total_tokens=0,
audio_seconds=audio_sec,
)
except Exception as rec_exc:
_log.warning("EMPFANG_AI_BUDGET_RECORD ok err=%s", rec_exc)
return None, text
@router.post("/messages/{msg_id}/transcribe-audio")
async def empfang_message_transcribe_audio(msg_id: str, request: Request):
"""Transkribiert das erste Audio-Attachment einer Nachricht; speichert ``transcript`` am Attachment."""
@@ -7794,7 +7936,11 @@ async def empfang_message_transcribe_audio(msg_id: str, request: Request):
raise HTTPException(status_code=413, detail="Audiodatei zu gross")
suffix = _audio_suffix_for_attachment(att)
try:
transcript = _empfang_transcribe_openai_from_bytes(raw, filename_suffix=suffix)
budget_resp, transcript = _empfang_transcribe_openai_budgeted(
request, raw, practice_id=pid, filename_suffix=suffix
)
if budget_resp is not None:
return budget_resp
except HTTPException:
raise
except Exception as exc:
@@ -7941,7 +8087,11 @@ async def empfang_external_dm_transcribe_audio(msg_id: str, request: Request):
att_for_suffix["mime"] = str(att.get("mime_type") or "")
suffix = _audio_suffix_for_attachment(att_for_suffix)
try:
transcript = _empfang_transcribe_openai_from_bytes(raw, filename_suffix=suffix)
budget_resp, transcript = _empfang_transcribe_openai_budgeted(
request, raw, practice_id=my_pid, filename_suffix=suffix
)
if budget_resp is not None:
return budget_resp
except HTTPException:
raise
except Exception as exc:
@@ -8580,6 +8730,26 @@ async def federation_practices(request: Request):
# CLEANUP + PRACTICE INFO
# =====================================================================
@router.get("/retention-info")
async def empfang_retention_info():
"""Nur Metadaten zur Aufbewahrungsregel (keine Inhalte, keine Loeschen-Aktion)."""
return JSONResponse(
content={
"success": True,
"chat_message_retention_days": EMPFANG_MESSAGE_RETENTION_DAYS,
"auto_prune_internal_messages_on_api_load": True,
"auto_prune_external_dm_on_api_load": True,
"storage_internal_messages": "empfang_nachrichten.json",
"storage_external_dm": "empfang_external_messages.json",
"attachment_auto_delete_with_message": False,
"not_in_scope_note": (
"Briefe, Aufgaben und Notizen liegen in anderen Speichern "
"und werden durch diese Chat-Retention nicht geloescht."
),
}
)
@router.post("/cleanup")
async def empfang_cleanup(request: Request):
try: