tts-voice-creator-clone-and.../tests/test_character_identity.py
mARTin-B78 5877aa2edc fix(characters): persist base-appearance image pairs, deterministic prompts, form-aware review; add forms panel
- fix: base-appearance matched pair lost its concept image + metadata on reload; visual save
  wrote base concept art to the record root; one save path for base and forms now
- fix: extraction no longer lets the LLM author image_prompt (source of avatar/concept drift);
  avatar-only generation uses the deterministic prompt and flags an existing pair
- fix: prompt writes and Physical/Clothing edits go to the active form; review endpoint checks
  each form's own pair; fingerprint parity with the browser incl. visual_* overrides
- feat: appearance forms panel (add/rename/delete/show, per-form stale flag, generate one/all)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:27:21 +02:00

167 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Character visual identity: fingerprint parity with the browser, and the form-aware pair review."""
from __future__ import annotations
import base64
import json
import os
import re
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-charid-test-config-"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.characters as characters
from routes.conversation import (
_CHARACTER_IDENTITY_FIELDS, _CHARACTER_IDENTITY_OVERRIDE_FIELDS, _CHARACTER_VISUAL_PROMPT_VERSION,
_character_identity_fingerprint,
)
ROOT = Path(__file__).resolve().parent.parent
JS = (ROOT / "static/js/library-characters.js").read_text(encoding="utf-8")
def _js_list(name: str) -> list[str]:
m = re.search(rf"const {name} = \[(.*?)\];", JS, re.S)
return re.findall(r"'([^']+)'", m.group(1))
def _js_fingerprint(sheets: list[dict]) -> list[str]:
"""Run the browser's own fingerprint code in Node."""
def grab(pattern: str) -> str:
return re.search(pattern, JS, re.S).group(0)
src = "\n".join([
grab(r"function _libStr\(v\) \{.*?\n\}"),
grab(r"const LIB_IDENTITY_FIELDS = \[.*?\];"),
grab(r"const LIB_IDENTITY_OVERRIDE_FIELDS = \[.*?\];"),
grab(r"function _libIdentityFingerprint\(sheet\) \{.*?\n\}"),
f"console.log(JSON.stringify({json.dumps(sheets)}.map(_libIdentityFingerprint)));",
])
out = subprocess.run(["node", "-e", src], capture_output=True, text=True, check=True, timeout=30)
return json.loads(out.stdout)
def _legacy_fingerprint(sheet: dict) -> str:
"""The algorithm before override fields existed, for backward-compat checks."""
raw = "|".join(f"{k}:{sheet.get(k) if sheet.get(k) is not None else ''}" for k in _CHARACTER_IDENTITY_FIELDS)
value = 0
for ch in raw:
value = ((value << 5) - value + ord(ch)) & 0xFFFFFFFF
return format(value, "x")
class FingerprintTests(unittest.TestCase):
def test_browser_and_server_use_the_same_field_lists(self):
self.assertEqual(_js_list("LIB_IDENTITY_FIELDS"), list(_CHARACTER_IDENTITY_FIELDS))
self.assertEqual(_js_list("LIB_IDENTITY_OVERRIDE_FIELDS"), list(_CHARACTER_IDENTITY_OVERRIDE_FIELDS))
def test_browser_and_server_compute_identical_fingerprints(self):
sheets = [
{},
{"physical": "tall, white hair", "clothing": "grey coat", "gender": "female", "age_estimate": "40"},
{"physical": "x", "visual_appearance": "copper hair, green eyes", "visual_costume": "blue wool coat"},
{"physical": "ä ö ü ß – »Zitat« 😀", "marks": ["scar", "tattoo"], "visual_species": "Elf"},
]
self.assertEqual(_js_fingerprint(sheets), [_character_identity_fingerprint(s) for s in sheets])
def test_a_pair_made_before_overrides_existed_keeps_a_valid_fingerprint(self):
sheet = {"physical": "tall, white hair", "clothing": "grey coat", "gender": "female"}
self.assertEqual(_character_identity_fingerprint(sheet), _legacy_fingerprint(sheet))
def test_editing_an_override_marks_the_pair_stale(self):
base = {"physical": "tall", "clothing": "coat"}
before = _character_identity_fingerprint(base)
for key in _CHARACTER_IDENTITY_OVERRIDE_FIELDS:
with self.subTest(key=key):
self.assertNotEqual(_character_identity_fingerprint({**base, key: "changed"}), before)
def test_active_form_changes_the_fingerprint_but_not_the_base(self):
sheet = {"physical": "human", "forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}]}
base = _character_identity_fingerprint({**sheet, "active_form_id": ""})
self.assertNotEqual(_character_identity_fingerprint({**sheet, "active_form_id": "wolf"}), base)
_PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
def _img(n: int) -> str:
"""A distinct valid data URI per n (digests differ)."""
return "data:image/png;base64," + base64.b64encode(_PNG + bytes([n])).decode()
def _pair_meta(sheet: dict) -> dict:
return {
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
"consistent_identity_fingerprint": _character_identity_fingerprint(sheet),
"identity_consistent": True,
}
class VisualPairReviewTests(unittest.TestCase):
def setUp(self):
app = FastAPI()
app.include_router(characters.router)
self.client = TestClient(app)
def review(self, records):
with mock.patch.object(characters, "char_get_all", return_value=records):
r = self.client.get("/api/characters/visual-pair-review")
self.assertEqual(r.status_code, 200)
return r.json()
def test_a_healthy_base_pair_and_a_healthy_form_pair_pass(self):
base = {"physical": "human", "clothing": "coat"}
form = {"id": "wolf", "name": "Wolf", "physical": "grey fur"}
form_fp = _character_identity_fingerprint({**base, "forms": [form], "active_form_id": "wolf"})
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
"forms": [{**form, "image": _img(3), "concept_art_image": _img(4),
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
"consistent_identity_fingerprint": form_fp, "identity_consistent": True}],
"active_form_id": "wolf"}}
self.assertEqual(self.review([rec])["issues"], [])
def test_a_stale_form_pair_is_reported_under_the_form_name(self):
base = {"physical": "human"}
form = {"id": "wolf", "name": "Wolf", "physical": "grey fur", "image": _img(3), "concept_art_image": _img(4),
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
"consistent_identity_fingerprint": "deadbeef", "identity_consistent": True}
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2), "forms": [form]}}
issues = self.review([rec])["issues"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["form_id"], "wolf")
self.assertEqual(issues[0]["form_name"], "Wolf")
self.assertIn("appearance_changed_since_generation", issues[0]["issues"])
def test_an_active_form_no_longer_makes_the_base_pair_look_unfingerprinted(self):
base = {"physical": "human"}
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
"forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}], "active_form_id": "wolf"}}
self.assertEqual(self.review([rec])["issues"], [])
def test_a_form_without_images_is_not_reviewed(self):
base = {"physical": "human"}
rec = {"id": "c1", "name": "A", "image": _img(1),
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
"forms": [{"id": "f", "name": "F", "physical": "x"}]}}
self.assertEqual(self.review([rec])["issues"], [])
def test_the_same_asset_on_two_characters_is_flagged(self):
s = {"physical": "a"}
recs = [{"id": f"c{i}", "name": f"N{i}", "image": _img(9),
"sheet": {**s, **_pair_meta(s), "concept_art_image": _img(10 + i)}} for i in range(2)]
issues = self.review(recs)["issues"]
self.assertEqual({i["id"] for i in issues}, {"c0", "c1"})
self.assertTrue(all("asset_reused_across_characters" in i["issues"] for i in issues))
if __name__ == "__main__":
unittest.main()