Files
FabledCurator/backend/app/services/ml/aliases.py
T
bvandeusen 5c3f8ebd70
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m7s
fix(aliases): store modal alias under raw model key + make aliases visible/manageable
The headline bug: aliases created from the modal NEVER resolved. Create
sent the normalized display name ('Sword', 'Uchiha Sasuke') while
resolution keys on the raw booru model key ('sword', 'uchiha_sasuke',
case-sensitive) — so the mapping was stored under a key nothing looks up,
and the prediction kept reappearing unaliased. The raw key wasn't even in
the /suggestions response, so the modal couldn't send it.

- Suggestion now carries raw_name (the model key an alias must use) and
  via_alias (surfaced via an operator alias); both serialized by the API.
- Modal alias-create sends raw_name, not display_name (the fix). Aliased
  suggestions show an 'alias' badge and a 'Remove alias' action; 'Treat as
  alias for…' is hidden for centroid hits (no model key) and already-aliased
  rows.
- Tag-side management: TagCard ⋮ → 'Aliases…' opens a dialog listing the
  model keys that fold into a tag, with remove (GET /api/tags/<id>/aliases +
  AliasService.list_for_tag). Creation stays in the modal suggestion flow.

Tests: full API round-trip locking the raw-key contract (raw_name exposed →
alias authored with it → resolves + via_alias on a later image);
list_for_tag (service + API); via_alias/raw_name on the existing service
suggestion tests. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:05:58 -04:00

130 lines
4.2 KiB
Python

"""Alias resolution + CRUD.
A tag_alias maps (model_name, model_category) -> canonical Tag. Resolution
happens at suggestion-read time so the raw image_prediction rows stay unmolested.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Tag, TagAlias
@dataclass(frozen=True)
class AliasRow:
alias_string: str
alias_category: str
canonical_tag_id: int
canonical_tag_name: str
class AliasService:
def __init__(self, session: AsyncSession):
self.session = session
async def resolve(self, name: str, category: str) -> Tag | None:
"""Return the canonical Tag for (name, category), or None if no alias."""
stmt = (
select(Tag)
.join(TagAlias, TagAlias.canonical_tag_id == Tag.id)
.where(TagAlias.alias_string == name)
.where(TagAlias.alias_category == category)
)
return (await self.session.execute(stmt)).scalar_one_or_none()
async def resolve_many(
self, pairs: list[tuple[str, str]]
) -> dict[tuple[str, str], Tag]:
"""Batch-resolve. Returns only the pairs that have an alias.
Used by SuggestionService so it does one query instead of N.
"""
if not pairs:
return {}
strings = {p[0] for p in pairs}
stmt = (
select(TagAlias, Tag)
.join(Tag, Tag.id == TagAlias.canonical_tag_id)
.where(TagAlias.alias_string.in_(strings))
)
rows = (await self.session.execute(stmt)).all()
wanted = set(pairs)
out: dict[tuple[str, str], Tag] = {}
for alias, tag in rows:
key = (alias.alias_string, alias.alias_category)
if key in wanted:
out[key] = tag
return out
async def create(
self, alias_string: str, alias_category: str, canonical_tag_id: int
) -> None:
"""Idempotent create (ON CONFLICT DO NOTHING)."""
stmt = insert(TagAlias).values(
alias_string=alias_string,
alias_category=alias_category,
canonical_tag_id=canonical_tag_id,
)
stmt = stmt.on_conflict_do_nothing(
index_elements=["alias_string", "alias_category"]
)
await self.session.execute(stmt)
async def remove(self, alias_string: str, alias_category: str) -> None:
await self.session.execute(
delete(TagAlias)
.where(TagAlias.alias_string == alias_string)
.where(TagAlias.alias_category == alias_category)
)
async def list_for_tag(self, canonical_tag_id: int) -> Sequence[AliasRow]:
"""Aliases that resolve TO this tag — drives the tag-side 'Aliases'
view (see/remove the model keys that fold into a tag)."""
stmt = (
select(
TagAlias.alias_string,
TagAlias.alias_category,
TagAlias.canonical_tag_id,
Tag.name,
)
.join(Tag, Tag.id == TagAlias.canonical_tag_id)
.where(TagAlias.canonical_tag_id == canonical_tag_id)
.order_by(TagAlias.alias_string.asc())
)
rows = (await self.session.execute(stmt)).all()
return [
AliasRow(
alias_string=r[0],
alias_category=r[1],
canonical_tag_id=r[2],
canonical_tag_name=r[3],
)
for r in rows
]
async def list_all(self) -> Sequence[AliasRow]:
stmt = (
select(
TagAlias.alias_string,
TagAlias.alias_category,
TagAlias.canonical_tag_id,
Tag.name,
)
.join(Tag, Tag.id == TagAlias.canonical_tag_id)
.order_by(TagAlias.alias_string.asc())
)
rows = (await self.session.execute(stmt)).all()
return [
AliasRow(
alias_string=r[0],
alias_category=r[1],
canonical_tag_id=r[2],
canonical_tag_name=r[3],
)
for r in rows
]