"""M10: reading and writing how an entity looks. `models.VisualProfile` carries the design reasoning — why these rows are campaign-scoped rather than per-position, why there is one table for characters, locations and items, and why nothing here is story state. This module is the narrow set of operations on them, and its own job is to make two things true: * **a profile can only name an entity the campaign actually has**, so a typo produces an error rather than a row describing nobody; * **writing one changes nothing authoritative**, which is guaranteed by this module not importing anything that could. ## Why the entity is checked against the current head An entity key means something only in a state document, and a campaign has a different document at every position. The check is made against the state at the **active head** — the story the reader is on — for the same reason `narrative/validate.py` resolves its `refs` there: it is the only position the reader is looking at, and a key that means nothing there is a mistake, not a branch subtlety. The row that results is campaign-scoped anyway, so a profile written while standing on one branch is visible from every branch. That asymmetry is deliberate and is the continuity the profile exists for: the check is *"does this name someone"*, and the storage answers *"what do they look like"*, which does not vary by path. """ from __future__ import annotations from sqlalchemy import select from sqlalchemy.orm import Session from .. import models from ..narrative import model as narrative_model from ..narrative import store as narrative_store #: How many descriptors one profile may carry, and how long each may be. A #: profile is a handful of stable traits, not a document: the bound exists so a #: future provider's prompt cannot be grown without limit through this door, and #: so one campaign cannot store an essay per entity. MAX_DESCRIPTORS = 40 MAX_FEATURES = 40 MAX_VALUE = 400 MAX_STYLE_NOTES = 2_000 MAX_KEY = 200 class ProfileError(ValueError): """A visual profile could not be written, and why.""" def entity_exists(state: dict, entity_key: str) -> bool: """Whether the state document names this entity.""" return narrative_model.entity(state, entity_key) is not None def set_profile( db: Session, adventure: models.Adventure, entity_key: str, *, descriptors: dict | None = None, features: list | None = None, style_notes: str | None = None, ) -> models.VisualProfile: """Records how `entity_key` looks, creating or replacing the profile. Replaces rather than merges. A profile is one answer to "what does this look like", and merging would make it impossible to *remove* a descriptor — the caller would be able to add "wearing a red coat" and never take it off, which for continuity metadata is the wrong default. A caller that wants to amend one reads it first. Raises `ProfileError` if the campaign's state at the active head does not name the entity, or if the profile is malformed. It writes nothing in either case, and it writes nothing to `narrative_state` in any case. """ key = _checked_key(entity_key) state = narrative_store.current(adventure) if not entity_exists(state, key): raise ProfileError( f"This campaign has no entity called {key!r}, so there is nothing " f"for a visual profile to describe. Profiles attach to the " f"campaign's own entities, not to names." ) row = get_profile(db, adventure, key) if row is None: row = models.VisualProfile(adventure_id=adventure.id, entity_key=key) db.add(row) row.descriptors = _checked_descriptors(descriptors) row.features = _checked_features(features) row.style_notes = _checked_notes(style_notes) return row def get_profile( db: Session, adventure: models.Adventure, entity_key: str ) -> models.VisualProfile | None: return db.execute( select(models.VisualProfile).where( models.VisualProfile.adventure_id == adventure.id, models.VisualProfile.entity_key == entity_key, ) ).scalars().first() def all_for(db: Session, adventure: models.Adventure) -> list[models.VisualProfile]: return list(db.execute( select(models.VisualProfile) .where(models.VisualProfile.adventure_id == adventure.id) .order_by(models.VisualProfile.entity_key) ).scalars().all()) def by_key(db: Session, adventure: models.Adventure) -> dict[str, dict]: """Every profile in the campaign, keyed by entity, as plain dictionaries. One query, because the Scene Packet needs several profiles at once and fetching them per entity would be a query per character in the scene. """ return {row.entity_key: as_dict(row) for row in all_for(db, adventure)} def as_dict(row: models.VisualProfile) -> dict: """One profile as it appears in a Scene Packet.""" return { "descriptors": dict(row.descriptors or {}), "features": list(row.features or []), "style_notes": row.style_notes or "", } def delete_profile( db: Session, adventure: models.Adventure, entity_key: str ) -> bool: """Removes a profile. Returns whether there was one. Deleting a profile removes a *description*, never the entity: the entity lives in the authoritative state document and nothing here can reach it. """ row = get_profile(db, adventure, entity_key) if row is None: return False db.delete(row) return True # ------------------------------------------------------------- the checking def _checked_key(entity_key) -> str: if not isinstance(entity_key, str) or not entity_key.strip(): raise ProfileError("A visual profile has to name an entity.") key = entity_key.strip() if len(key) > MAX_KEY: raise ProfileError(f"Entity keys are at most {MAX_KEY} characters.") return key def _checked_descriptors(descriptors) -> dict: """Trait -> value, both short strings. Values are text rather than arbitrary JSON on purpose. A descriptor is something a future provider will put in a prompt, and a nested structure would either be flattened by whoever does that — inconsistently — or smuggle a provider-shaped payload through a story-side field, which is the boundary this package exists to keep. """ if descriptors is None: return {} if not isinstance(descriptors, dict): raise ProfileError("`descriptors` must be a map of trait to value.") if len(descriptors) > MAX_DESCRIPTORS: raise ProfileError( f"A profile may carry at most {MAX_DESCRIPTORS} descriptors." ) out: dict[str, str] = {} for trait, value in descriptors.items(): if not isinstance(trait, str) or not trait.strip(): raise ProfileError("Every descriptor needs a name.") if not isinstance(value, str): raise ProfileError( f"The value for {trait!r} must be text — a profile describes " f"how something looks, in words a person could read back." ) if len(value) > MAX_VALUE: raise ProfileError( f"The value for {trait!r} is longer than {MAX_VALUE} characters." ) out[trait.strip()[:MAX_KEY]] = value return out def _checked_features(features) -> list: if features is None: return [] if not isinstance(features, list): raise ProfileError("`features` must be a list of short phrases.") if len(features) > MAX_FEATURES: raise ProfileError(f"A profile may carry at most {MAX_FEATURES} features.") out = [] for feature in features: if not isinstance(feature, str) or not feature.strip(): raise ProfileError("Every feature must be a non-empty phrase.") if len(feature) > MAX_VALUE: raise ProfileError(f"A feature is longer than {MAX_VALUE} characters.") out.append(feature.strip()) return out def _checked_notes(style_notes) -> str: if style_notes is None: return "" if not isinstance(style_notes, str): raise ProfileError("`style_notes` must be text.") if len(style_notes) > MAX_STYLE_NOTES: raise ProfileError( f"Style notes are longer than {MAX_STYLE_NOTES} characters." ) return style_notes.strip()