5c3f8ebd70
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>
130 lines
4.2 KiB
Python
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
|
|
]
|