feat: deduplicate casting requests

This commit is contained in:
mARTin-B78 2026-09-04 08:10:55 +02:00
parent 2b99dc8ccc
commit fc0361bc71
3 changed files with 29 additions and 3 deletions

View File

@ -53,6 +53,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
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.
- **Casting Idempotency.** Character-to-voice matching now applies the same bounded, expiring idempotency contract,
preventing repeated clicks from issuing duplicate LLM casting requests.
- **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 and voice-design jobs now
deduplicate repeated requests with a bounded, expiring `Idempotency-Key` contract; casting jobs remain.
- [x] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference, voice-design, and casting
jobs now deduplicate repeated requests with bounded, expiring `Idempotency-Key` contracts.
- [ ] **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

@ -62,6 +62,8 @@ _consistent_image_jobs: dict[str, dict] = {}
_consistent_image_jobs_lock = threading.Lock()
_consistent_image_idempotency: dict[str, str] = {}
_consistent_image_slots = threading.BoundedSemaphore(2)
_CAST_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {}
_CAST_IDEMPOTENCY_LOCK = threading.Lock()
_CHARACTER_VISUAL_PROMPT_VERSION = "v3"
_CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
@ -536,6 +538,21 @@ async def match_characters_voices(request: Request):
Returns: {"assignments": [{name, voice_id, reason}]}
"""
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 _CAST_IDEMPOTENCY_LOCK:
for old_key, (_, _, created) in list(_CAST_IDEMPOTENCY.items()):
if now - created > 1800:
_CAST_IDEMPOTENCY.pop(old_key, None)
previous = _CAST_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}
script: str = (data.get("script") or "").strip()
names: list = data.get("names") or []
voices: list = data.get("voices") or []
@ -638,7 +655,14 @@ async def match_characters_voices(request: Request):
continue
used_ids.add(vid)
clean.append(a)
return {"assignments": clean}
result = {"assignments": clean}
if idempotency_key:
with _CAST_IDEMPOTENCY_LOCK:
_CAST_IDEMPOTENCY[idempotency_key] = (request_fingerprint, result, time.time())
if len(_CAST_IDEMPOTENCY) > 256:
oldest = min(_CAST_IDEMPOTENCY, key=lambda k: _CAST_IDEMPOTENCY[k][2])
_CAST_IDEMPOTENCY.pop(oldest, None)
return result
def _extract_json_block(text: str) -> str: