From fc0361bc71e8ab01b8e37907ee7b6b6b188e92df Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 08:10:55 +0200 Subject: [PATCH] feat: deduplicate casting requests --- CHANGELOG.md | 2 ++ TODO.md | 4 ++-- routes/conversation.py | 26 +++++++++++++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cd399..8978141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TODO.md b/TODO.md index 6aedccf..86d170c 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/routes/conversation.py b/routes/conversation.py index 0a3af17..e760235 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -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: