feat: detect stale character image identity

This commit is contained in:
mARTin-B78 2026-09-04 07:59:17 +02:00
parent 94f09c110d
commit 9517cca00c
4 changed files with 28 additions and 5 deletions

View File

@ -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.

View File

@ -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

View File

@ -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))

View File

@ -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 = '<div class="lcd-avatar-wrap">'
+ (rec.image
@ -1230,7 +1235,7 @@ async function _charDetailPage(rec, allChars, opts) {
+ '<span class="mdi mdi-creation"></span> ' + (sh.concept_art_image ? 'Neu generieren' : 'Generieren')
+ '</button>'
+ '</div>'
+ (rec.image && sh.concept_art_image && sh.consistent_prompt_version !== 'v3'
+ (rec.image && sh.concept_art_image && (sh.consistent_prompt_version !== 'v3' || (sh.consistent_identity_fingerprint && sh.consistent_identity_fingerprint !== identityFingerprintShort))
? '<div class="note" style="color:var(--warning,#d97706);margin-top:6px"><span class="mdi mdi-alert-outline"></span> Dieses Bildpaar stammt aus einer älteren Prompt-Version und sollte für konsistente Identität neu erzeugt werden.</div>'
: '')
+ (sh.concept_art_image
@ -3394,11 +3399,11 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null;
const target = current || rec;
target.image = d.profile;
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', identity_consistent: d.identity_consistent !== false });
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', identity_consistent: d.identity_consistent !== false });
target.updated = new Date();
if (typeof clPut === 'function') await clPut(target);
rec.image = d.profile;
rec.sheet = Object.assign({}, sh, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', identity_consistent: d.identity_consistent !== false });
rec.sheet = Object.assign({}, sh, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', identity_consistent: d.identity_consistent !== false });
document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) {
av.innerHTML = '<img src="' + d.profile + '" alt="' + escHtml(rec.name) + '">';
});