feat: deduplicate voice design requests

This commit is contained in:
mARTin-B78 2026-09-04 08:10:27 +02:00
parent 116e234c20
commit 2b99dc8ccc
3 changed files with 30 additions and 3 deletions

View File

@ -51,6 +51,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
prompt/source content and sensitive result fields are excluded from the audit record.
- **Synthesized Audio Cache Eviction.** Persisted line-audio clips now use configurable global file-count and byte
limits with oldest-first eviction, and oversized individual clips are rejected.
- **Voice Design Idempotency.** Repeated voice-design submissions with the same `Idempotency-Key` now reuse the
original temporary result; reusing a key for different request data is rejected.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

View File

@ -51,8 +51,8 @@ the first block before investing in architecture or polish.
- [ ] **M — Make profile portraits and character sheets use one canonical identity.**
Shared structured prompts now version consistent pairs as v3 and fingerprint canonical appearance fields; add
cross-record validation so mismatched profile/reference files are detected even when both are current-version.
- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate
repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs.
- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference and voice-design jobs now
deduplicate repeated requests with a bounded, expiring `Idempotency-Key` contract; casting jobs remain.
- [ ] **M — Improve large-table performance.** Character productions and the flat voice library now paginate at 50
visible rows; updating only affected rows and disabling preloading on low-memory devices remain.
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses and persisted synthesized

View File

@ -5,6 +5,7 @@ import asyncio
import io
import json
import logging
import hashlib
import os
import re
import threading
@ -55,6 +56,8 @@ logger = logging.getLogger("uvicorn.error")
router = APIRouter()
_SEED_FINDER_VOICE_LOCK = threading.Lock()
_TTS_GENERATION_SLOTS = asyncio.Semaphore(2)
_VOICE_DESIGN_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {}
_VOICE_DESIGN_IDEMPOTENCY_LOCK = threading.Lock()
async def _bounded_tts_call(fn, *args):
@ -777,6 +780,21 @@ async def tts_style_variation(request: Request):
@router.post("/api/voice-design")
async def voice_design(request: Request):
data = await request.json()
idempotency_key = str(request.headers.get("idempotency-key") or "").strip()
if len(idempotency_key) > 200:
raise HTTPException(400, "Idempotency-Key is too long")
request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
if idempotency_key:
now = time.time()
with _VOICE_DESIGN_IDEMPOTENCY_LOCK:
for old_key, (_, _, created) in list(_VOICE_DESIGN_IDEMPOTENCY.items()):
if now - created > 1800:
_VOICE_DESIGN_IDEMPOTENCY.pop(old_key, None)
previous = _VOICE_DESIGN_IDEMPOTENCY.get(idempotency_key)
if previous:
if previous[0] != request_fingerprint:
raise HTTPException(409, "Idempotency-Key was already used for a different request")
return {**previous[1], "deduplicated": True}
instruct: str = data.get("instruct", "").strip()
sample_text: str = data.get("sample_text", "Hello! This is a voice design sample.").strip()
language: str = data.get("language", "Auto")
@ -807,7 +825,14 @@ async def voice_design(request: Request):
tmp.write_bytes(audio)
fid = uuid.uuid4().hex
_registry_put(fid, tmp)
return {"id": fid, "duration": _duration(tmp)}
result = {"id": fid, "duration": _duration(tmp)}
if idempotency_key:
with _VOICE_DESIGN_IDEMPOTENCY_LOCK:
_VOICE_DESIGN_IDEMPOTENCY[idempotency_key] = (request_fingerprint, result, time.time())
if len(_VOICE_DESIGN_IDEMPOTENCY) > 256:
oldest = min(_VOICE_DESIGN_IDEMPOTENCY, key=lambda k: _VOICE_DESIGN_IDEMPOTENCY[k][2])
_VOICE_DESIGN_IDEMPOTENCY.pop(oldest, None)
return result
@router.post("/api/tts-route-test")