Files
StarAsh042 8db4c37d16 feat(core): 角色标签解析与作品筛选
角色数据的 trigger 是「作品 + 角色 + 外观标签」的混合串,
先拆解成结构化记录,才能按作品筛选、按需拼装描述。
2026-10-16 09:12:00 +08:00

334 lines
13 KiB
Python
Raw Permalink 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.
"""角色数据的解析、筛选与提示词组装(RollingCharacter 的核心逻辑)。
数据源是 ``danbooru_character_001.csv``(由 ``modify_danbooru_character.py`` 生成):
``character,copyright,trigger,core_tags,count``,已按 count 降序。
``trigger`` 列的结构经 34,416 行全量校验是**恒定的两段**:
- 第 1 段 == 转义后的 ``character`` 列(34,416/34,416)
- 第 2 段 == 转义后的 ``copyright`` 列(34,416/34,416)
因此默认读 ``trigger`` 列:第 1 段当角色标签(已经转义,可直接使用),其余段就是作品标签。
输出不做权重分配,每个角色一行:``角色标签,作品标签,core_tags``,
多个角色之间用「逗号 + 换行」分隔。
纯逻辑模块,不依赖 ComfyUI,可单独测试。
"""
import re
import threading
from collections import OrderedDict
from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Sequence, Set, Tuple
from .artists import (
COLUMN_ALL,
COLUMN_AUTO,
ArtistRepository,
is_header_row,
normalize_cell,
normalize_column_spec,
resolve_layout,
)
__all__ = [
"CharacterRecord",
"build_character_prompt",
"build_records",
"build_payload",
"clear_records_cache",
"copyright_options",
"escape_parens",
"filter_by_copyright",
"load_records",
"parse_copyright_filter",
"split_core_tags",
"split_tags",
"unique_tags",
"COPYRIGHT_ANY",
]
# 名字列之外的辅助列(按表头名定位,缺失时该字段留空)
AUX_COLUMNS: Tuple[str, ...] = ("copyright", "core_tags", "count")
# 下拉菜单里代表「不限」的哨兵值。copyright 列实测没有空值,
# 与真实作品名不可能撞车;界面文案写在 tooltip 里
COPYRIGHT_ANY = "(不限)"
class CharacterRecord(NamedTuple):
"""一位角色及其附带信息(不可变、内存开销小,适合缓存几万条)。
字段名用 ``post_count`` 而非 ``count``:``NamedTuple`` 继承自 ``tuple``,
而 ``tuple`` 已有 ``count`` 方法,同名会遮蔽它并让类型检查器报错
(``CharacterRecord.count`` 与 ``tuple.count`` 类型不兼容)。
对外的 JSON 字段仍叫 ``count``,保持输出兼容。
"""
name: str # 角色名(名字列的第 1 段,已转义)
extra_tags: str # 名字列的其余段(作品标签等,已转义,逗号连接)
copyright: str # copyright 列原值(用于筛选与 JSON,保留原始写法)
core_tags: str # core_tags 列原值(角色固定外观标签)
post_count: int # 热度(缺失或非法时为 0)
# ----------------------------------------------------------------------
# 文本解析
# ----------------------------------------------------------------------
def split_tags(value: str) -> List[str]:
"""按逗号拆分标签串,去掉首尾空白与空段。"""
return [part.strip() for part in str(value or "").split(",") if part.strip()]
def _escape_paren(match: "re.Match") -> str:
"""给未被转义的括号补一个反斜杠;已转义的保持原样。"""
backslashes, paren = match.group(1), match.group(2)
if len(backslashes) % 2 == 0:
return backslashes + "\\" + paren
return match.group(0)
def escape_parens(value: str) -> str:
"""只转义括号(不动空格与连字符)。
用于把 ``copyright`` 列原值安全地放进提示词:该列形如 ``fate_(series)``,
括号不转义会被提示词解析器当成权重分组;而它本身不含空格,无需做下划线替换。
"""
return re.sub(r"(\\*)([\(\)])", _escape_paren, str(value or "").strip())
def split_core_tags(value: str) -> List[str]:
"""拆分 core_tags(与 :func:`split_tags` 同规则,单独命名便于阅读)。"""
return split_tags(value)
def _spec_is_name(spec: str) -> bool:
"""判断 csv_column 是否写的是「列名」而不是 auto / all / 列序号。"""
return spec not in ("", COLUMN_AUTO, COLUMN_ALL) and not spec.isdigit()
def _locate(header_cells: Sequence[str]) -> Dict[str, int]:
"""按表头定位辅助列(copyright / core_tags / count)。"""
indexes: Dict[str, int] = {}
for name in AUX_COLUMNS:
if name in header_cells:
indexes[name] = header_cells.index(name)
return indexes
def _cell(row: Sequence[str], index: Optional[int]) -> str:
if index is None or index >= len(row):
return ""
return str(row[index]).strip()
def build_records(rows: Sequence[Sequence[str]], column: Optional[str] = "trigger") -> List[CharacterRecord]:
"""把 CSV 行解析成角色记录列表。
参数:
column: 名字来源列,``auto`` / ``all`` / 列序号 / 列名(默认 ``trigger``)。
``all`` 对角色节点没有意义,会直接报错。
行内保持原始顺序(源文件已按 count 降序),空行与名字为空的行会被跳过。
"""
first: Optional[Sequence[str]] = None
header_position = 0
for position, row in enumerate(rows):
if row and any(str(cell or "").strip() for cell in row):
first, header_position = row, position
break
if first is None:
return []
spec = normalize_column_spec(column)
if spec == COLUMN_ALL:
raise ValueError(
"[RollingCharacter] csv_column=all 对角色数据没有意义:"
"请指定具体列(默认 trigger,即「角色标签,作品标签」)"
)
cells = [normalize_cell(cell) for cell in first]
# 名字写的是列名且能在首行找到时,首行就是表头(即使含未收录的额外列名)
has_header = is_header_row(first) or (_spec_is_name(spec) and spec in cells)
name_index = int(resolve_layout(first, spec, has_header))
aux = _locate(cells) if has_header else {}
start = header_position + 1 if has_header else 0
records: List[CharacterRecord] = []
for position, row in enumerate(rows):
if position < start:
continue
if not row or not any(str(cell or "").strip() for cell in row):
continue
if name_index >= len(row):
continue
segments = split_tags(row[name_index])
if not segments:
continue
raw_count = _cell(row, aux.get("count"))
records.append(
CharacterRecord(
name=segments[0],
extra_tags=",".join(segments[1:]),
copyright=_cell(row, aux.get("copyright")),
core_tags=_cell(row, aux.get("core_tags")),
post_count=int(raw_count) if raw_count.isdigit() else 0,
)
)
return records
# ----------------------------------------------------------------------
# 作品筛选
# ----------------------------------------------------------------------
def parse_copyright_filter(text: Optional[str]) -> List[str]:
"""解析作品过滤串:逗号分隔、去空白、保持顺序去重(比较时忽略大小写)。"""
names: List[str] = []
seen: Set[str] = set()
for item in str(text or "").split(","):
name = item.strip()
key = name.lower()
if name and key not in seen:
seen.add(key)
names.append(name)
return names
def filter_by_copyright(records: Sequence[CharacterRecord], text: Optional[str]) -> List[int]:
"""按作品名筛选,返回允许的记录下标(空过滤串返回全部)。
同时接受 ``copyright`` 列的原始写法(``fate_(series)``)与
``trigger`` 里转义后的写法(``fate_\\(series\\)``),比较忽略大小写。
"""
wanted = {name.lower() for name in parse_copyright_filter(text)}
if not wanted:
return list(range(len(records)))
allowed: List[int] = []
for index, record in enumerate(records):
forms = {record.copyright.lower()}
forms.update(tag.lower() for tag in split_tags(record.extra_tags))
if forms & wanted:
allowed.append(index)
return allowed
def copyright_options(records: Sequence[CharacterRecord]) -> List[str]:
"""列出可进下拉菜单的全部作品名(按 **count 总和**降序,相同时按名字升序)。
排序依据是「该作品下所有角色的 ``count`` 相加」——反映的是「这个作品整体有多热门」,
而不是「这个作品里有多少个角色」(后者会让 ``original`` 这种大杂烩永远排第一)。
并列时按名字升序,保证顺序稳定可复现。
默认数据(34,416 行)共 3,460 个作品,全部列出:现代前端的下拉可以直接打字筛选,
所以不再需要"只放常用作品 + 手输长尾"那套折中做法。
"""
totals: Dict[str, int] = {}
for record in records:
name = record.copyright.strip()
if name:
totals[name] = totals.get(name, 0) + record.post_count
ordered = sorted(totals.items(), key=lambda item: (-item[1], item[0]))
return [name for name, _ in ordered]
# ----------------------------------------------------------------------
# 标签去重
# ----------------------------------------------------------------------
def unique_tags(tags: Iterable[str]) -> List[str]:
"""按出现顺序去重(保留首次出现的位置)。"""
merged: List[str] = []
seen: Set[str] = set()
for tag in tags:
if tag and tag not in seen:
seen.add(tag)
merged.append(tag)
return merged
def build_character_prompt(records: Sequence[CharacterRecord], include_core_tags: bool = True) -> str:
"""组装角色输出:**每个角色一行**,格式为 ``角色标签,作品标签,core_tags``。
- 不带权重:角色标签直接输出,不套 ``(名称:权重)``
- 作品标签优先取名字列的其余段(``trigger`` 列的第 2 段,已转义);
名字列不是 ``trigger`` 时退回 ``copyright`` 列原值,并只转义其中的括号
(该列不含空格与连字符,无需做下划线替换)
- 外观标签取自 ``core_tags``,行内去重(源数据里存在同一角色重复列同一标签的情况);
``include_core_tags=False`` 时每行只输出 ``角色标签,作品标签``
- 空的部分自动省略,不会留下多余逗号(没有作品或外观标签时就只有角色标签)
- 多个角色用「逗号 + 换行」分隔:每一行末尾都补一个逗号(最后一行不补),
这样把多行拼成一行时标签之间不会粘在一起;单个角色时不带尾逗号
"""
lines: List[str] = []
for record in records:
copyright_tag = record.extra_tags or escape_parens(record.copyright)
parts = [record.name]
parts.extend(split_tags(copyright_tag))
if include_core_tags:
parts.extend(unique_tags(split_core_tags(record.core_tags)))
line = ",".join(part for part in parts if part)
if line:
lines.append(line)
return ",\n".join(lines)
# ----------------------------------------------------------------------
# 输出组装
# ----------------------------------------------------------------------
def build_payload(records: Sequence[CharacterRecord], top_names: Optional[Set[str]]) -> Dict[str, Any]:
"""组装 ``characters_json``:角色标签 / 作品 / 是否 Top / 热度 / 外观标签。"""
return {
"characters": [
{
"name": record.name,
"copyright": record.copyright,
"top": top_names is None or record.name in top_names,
"count": record.post_count,
"core_tags": unique_tags(split_core_tags(record.core_tags)),
}
for record in records
],
"status": "OK",
}
# ----------------------------------------------------------------------
# 记录缓存
# ----------------------------------------------------------------------
# 解析 34,416 行需要构造三万多条记录,按「文件签名 + 列」缓存,
# 多个节点实例共享,避免每次执行都重解析。
_RECORDS_CACHE: "OrderedDict[Tuple[str, str, int, int], List[CharacterRecord]]" = OrderedDict()
_RECORDS_LOCK = threading.RLock()
_MAX_RECORDS_CACHE = 4
def load_records(
repository: ArtistRepository, custom_path: Optional[str] = None, column: Optional[str] = "trigger"
) -> List[CharacterRecord]:
"""读取并缓存角色记录(内容未变化时直接命中缓存)。"""
rows, stat = repository.load_rows(custom_path)
if stat is None:
return build_records(rows, column)
key = (stat[0], normalize_column_spec(column), stat[1], stat[2])
with _RECORDS_LOCK:
cached = _RECORDS_CACHE.get(key)
if cached is not None:
_RECORDS_CACHE.move_to_end(key)
return cached
records = build_records(rows, column)
if records:
with _RECORDS_LOCK:
_RECORDS_CACHE[key] = records
_RECORDS_CACHE.move_to_end(key)
while len(_RECORDS_CACHE) > _MAX_RECORDS_CACHE:
_RECORDS_CACHE.popitem(last=False)
return records
def clear_records_cache() -> None:
"""清空角色记录缓存(测试或手动热重载时使用)。"""
with _RECORDS_LOCK:
_RECORDS_CACHE.clear()