diff --git a/CHANGELOG.md b/CHANGELOG.md index f909890..6c4f0a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi concurrent recognition requests from overcommitting the inference device. - **Visual Identity Versioning.** Consistent profile/reference results now carry the standardized v3 prompt marker, and older unmarked image pairs are visibly flagged for regeneration. +- **Visual Identity Fingerprints.** Consistent pairs now record a compact fingerprint of canonical appearance fields; + changing those fields causes the existing pair to be flagged stale. - **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 380f494..f99da6a 100644 --- a/TODO.md +++ b/TODO.md @@ -49,8 +49,8 @@ the first block before investing in architecture or polish. - [ ] **M — Fix the Schwester Larissa unclosed-guillemet split.** Add a regression test for the epigraph and verify the parser does not split mid-sentence. - [ ] **M — Make profile portraits and character sheets use one canonical identity.** - Shared structured prompts now version consistent pairs as v3 and flag older pairs; add a field fingerprint so - changes to age, hair, eyes, face, build, skin tone, marks or era also detect stale images. + 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 — Improve large-table performance.** Virtualize/paginate character and voice diff --git a/routes/conversation.py b/routes/conversation.py index a2bbdc3..2527e67 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -63,6 +63,21 @@ _consistent_image_jobs_lock = threading.Lock() _consistent_image_idempotency: dict[str, str] = {} _consistent_image_slots = threading.BoundedSemaphore(2) _CHARACTER_VISUAL_PROMPT_VERSION = "v3" +_CHARACTER_IDENTITY_FIELDS = ( + "age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species", + "gender", "marks", "scars", "nationality_background", "clothing", "era", "profession", +) + + +def _character_identity_fingerprint(sheet: dict) -> str: + values = {key: sheet.get(key, "") for key in _CHARACTER_IDENTITY_FIELDS} + raw = "|".join(f"{key}:{values[key] if values[key] is not None else ''}" for key in _CHARACTER_IDENTITY_FIELDS) + # Keep this compact hash algorithm identical to the browser-side stale + # check; the fingerprint is only a change detector, not a security token. + value = 0 + for char in raw: + value = ((value << 5) - value + ord(char)) & 0xFFFFFFFF + return format(value, "x") def _cij_new(job_id: str, name: str) -> None: @@ -3126,6 +3141,7 @@ async def character_generate_consistent_images(request: Request): result = dict(result or {}) result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION result["identity_consistent"] = True + result["identity_fingerprint"] = _character_identity_fingerprint(sheet) _cij_set(job_id, stage="done", detail="Done", done=True, result=result) except HTTPException as exc: _cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail)) diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 5a5c56a..656f7dc 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -1202,6 +1202,11 @@ async function _charDetailPage(rec, allChars, opts) { 'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' }, }; const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral']; + const identityFields = ['age', 'age_estimate', 'hair', 'eyes', 'physical', 'build', 'skin_tone', 'race_species', 'gender', 'marks', 'scars', 'nationality_background', 'clothing', 'era', 'profession']; + const identityFingerprint = identityFields.map(function (key) { return key + ':' + _libStr(sh[key]); }).join('|'); + let identityHash = 0; + for (let i = 0; i < identityFingerprint.length; i++) identityHash = ((identityHash << 5) - identityHash + identityFingerprint.charCodeAt(i)) | 0; + const identityFingerprintShort = (identityHash >>> 0).toString(16); const avatarHtml = '