Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Auto-sync from GitHub Actions
Browse files- core/engine.py +184 -1
- mcp_server.py +195 -176
core/engine.py
CHANGED
|
@@ -12,6 +12,7 @@ DanbooruTagger 核心引擎
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import asyncio
|
|
|
|
| 15 |
import json
|
| 16 |
import os
|
| 17 |
import re
|
|
@@ -215,6 +216,7 @@ class DanbooruTagger:
|
|
| 215 |
self._group_cn_names: dict[str, str] = {}
|
| 216 |
self._tag_artist_index: dict[str, list[tuple]] = {}
|
| 217 |
self._artist_top_tags: dict[str, list[tuple]] = {} # artist → [(tag, npmi, cn_short), ...]
|
|
|
|
| 218 |
self.is_loaded: bool = False
|
| 219 |
|
| 220 |
# 预提取的列数组,避免热点路径上反复执行 df.iloc[idx]
|
|
@@ -1323,6 +1325,182 @@ class DanbooruTagger:
|
|
| 1323 |
result[artist] = items
|
| 1324 |
return result
|
| 1325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1326 |
def search_artists_pipeline(
|
| 1327 |
self,
|
| 1328 |
query: str,
|
|
@@ -1506,12 +1684,17 @@ class DanbooruTagger:
|
|
| 1506 |
|
| 1507 |
# 构建反向索引:artist → top tags(按 NPMI 降序)
|
| 1508 |
artist_tags: dict[str, list[tuple]] = {}
|
|
|
|
| 1509 |
for tag, entries in index.items():
|
| 1510 |
-
for artist, npmi, cooc,
|
| 1511 |
artist_tags.setdefault(artist, []).append((tag, npmi, cooc))
|
|
|
|
|
|
|
|
|
|
| 1512 |
for artist, entries in artist_tags.items():
|
| 1513 |
entries.sort(key=lambda x: -x[1])
|
| 1514 |
self._artist_top_tags = artist_tags
|
|
|
|
| 1515 |
|
| 1516 |
print(
|
| 1517 |
f'[Engine] 标签-画师共现表加载完成,{len(index):,} 个标签,'
|
|
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import asyncio
|
| 15 |
+
import difflib
|
| 16 |
import json
|
| 17 |
import os
|
| 18 |
import re
|
|
|
|
| 216 |
self._group_cn_names: dict[str, str] = {}
|
| 217 |
self._tag_artist_index: dict[str, list[tuple]] = {}
|
| 218 |
self._artist_top_tags: dict[str, list[tuple]] = {} # artist → [(tag, npmi, cn_short), ...]
|
| 219 |
+
self._artist_post_count: dict[str, int] = {}
|
| 220 |
self.is_loaded: bool = False
|
| 221 |
|
| 222 |
# 预提取的列数组,避免热点路径上反复执行 df.iloc[idx]
|
|
|
|
| 1325 |
result[artist] = items
|
| 1326 |
return result
|
| 1327 |
|
| 1328 |
+
@staticmethod
|
| 1329 |
+
def _normalize_artist_name(name: str) -> str:
|
| 1330 |
+
"""Normalize user-entered artist names toward Danbooru tag form."""
|
| 1331 |
+
text = str(name or "").strip().lower()
|
| 1332 |
+
if text.startswith("@"):
|
| 1333 |
+
text = text[1:].strip()
|
| 1334 |
+
text = re.sub(r"[\s\-]+", "_", text)
|
| 1335 |
+
text = re.sub(r"_+", "_", text)
|
| 1336 |
+
return text.strip("_")
|
| 1337 |
+
|
| 1338 |
+
@staticmethod
|
| 1339 |
+
def _compact_artist_key(name: str) -> str:
|
| 1340 |
+
return re.sub(r"[\W_]+", "", str(name or "").lower())
|
| 1341 |
+
|
| 1342 |
+
def resolve_artist_name(self, artist_name: str) -> dict[str, Any]:
|
| 1343 |
+
"""Resolve a user-entered artist name to the artist co-occurrence index."""
|
| 1344 |
+
artists = set(self._artist_top_tags.keys())
|
| 1345 |
+
normalized = self._normalize_artist_name(artist_name)
|
| 1346 |
+
|
| 1347 |
+
if artist_name in artists:
|
| 1348 |
+
return {
|
| 1349 |
+
"artist": artist_name,
|
| 1350 |
+
"matched_by": "exact",
|
| 1351 |
+
"candidates": [],
|
| 1352 |
+
}
|
| 1353 |
+
|
| 1354 |
+
if normalized in artists:
|
| 1355 |
+
return {
|
| 1356 |
+
"artist": normalized,
|
| 1357 |
+
"matched_by": "normalized_exact",
|
| 1358 |
+
"candidates": [],
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
compact_query = self._compact_artist_key(artist_name)
|
| 1362 |
+
compact_map: dict[str, list[str]] = {}
|
| 1363 |
+
for artist in artists:
|
| 1364 |
+
compact_map.setdefault(self._compact_artist_key(artist), []).append(artist)
|
| 1365 |
+
compact_matches = compact_map.get(compact_query, [])
|
| 1366 |
+
if len(compact_matches) == 1:
|
| 1367 |
+
return {
|
| 1368 |
+
"artist": compact_matches[0],
|
| 1369 |
+
"matched_by": "compact_exact",
|
| 1370 |
+
"candidates": [],
|
| 1371 |
+
}
|
| 1372 |
+
if len(compact_matches) > 1:
|
| 1373 |
+
return {
|
| 1374 |
+
"artist": None,
|
| 1375 |
+
"matched_by": "ambiguous_compact",
|
| 1376 |
+
"candidates": sorted(compact_matches)[:10],
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
close = difflib.get_close_matches(normalized, sorted(artists), n=5, cutoff=0.78)
|
| 1380 |
+
if len(close) == 1:
|
| 1381 |
+
return {
|
| 1382 |
+
"artist": close[0],
|
| 1383 |
+
"matched_by": "fuzzy",
|
| 1384 |
+
"candidates": close,
|
| 1385 |
+
}
|
| 1386 |
+
return {
|
| 1387 |
+
"artist": None,
|
| 1388 |
+
"matched_by": "not_found",
|
| 1389 |
+
"candidates": close,
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
def resolve_tag_name(self, tag_name: str) -> dict[str, Any]:
|
| 1393 |
+
"""Resolve a user-entered tag name to the canonical tag index without semantic search."""
|
| 1394 |
+
tags = set(self._name_to_idx.keys())
|
| 1395 |
+
normalized = self._normalize_artist_name(tag_name)
|
| 1396 |
+
|
| 1397 |
+
if tag_name in tags:
|
| 1398 |
+
return {
|
| 1399 |
+
"tag": tag_name,
|
| 1400 |
+
"matched_by": "exact",
|
| 1401 |
+
"candidates": [],
|
| 1402 |
+
}
|
| 1403 |
+
|
| 1404 |
+
if normalized in tags:
|
| 1405 |
+
return {
|
| 1406 |
+
"tag": normalized,
|
| 1407 |
+
"matched_by": "normalized_exact",
|
| 1408 |
+
"candidates": [],
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
plural = f"{normalized}s"
|
| 1412 |
+
if plural in tags:
|
| 1413 |
+
return {
|
| 1414 |
+
"tag": plural,
|
| 1415 |
+
"matched_by": "plural_exact",
|
| 1416 |
+
"candidates": [],
|
| 1417 |
+
}
|
| 1418 |
+
if normalized.endswith("s") and normalized[:-1] in tags:
|
| 1419 |
+
return {
|
| 1420 |
+
"tag": normalized[:-1],
|
| 1421 |
+
"matched_by": "singular_exact",
|
| 1422 |
+
"candidates": [],
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
+
compact_query = self._compact_artist_key(tag_name)
|
| 1426 |
+
compact_map: dict[str, list[str]] = {}
|
| 1427 |
+
for tag in tags:
|
| 1428 |
+
compact_map.setdefault(self._compact_artist_key(tag), []).append(tag)
|
| 1429 |
+
compact_matches = compact_map.get(compact_query, [])
|
| 1430 |
+
if len(compact_matches) == 1:
|
| 1431 |
+
return {
|
| 1432 |
+
"tag": compact_matches[0],
|
| 1433 |
+
"matched_by": "compact_exact",
|
| 1434 |
+
"candidates": [],
|
| 1435 |
+
}
|
| 1436 |
+
if len(compact_matches) > 1:
|
| 1437 |
+
return {
|
| 1438 |
+
"tag": None,
|
| 1439 |
+
"matched_by": "ambiguous_compact",
|
| 1440 |
+
"candidates": sorted(compact_matches)[:10],
|
| 1441 |
+
}
|
| 1442 |
+
|
| 1443 |
+
close = difflib.get_close_matches(normalized, sorted(tags), n=5, cutoff=0.78)
|
| 1444 |
+
if len(close) == 1:
|
| 1445 |
+
return {
|
| 1446 |
+
"tag": close[0],
|
| 1447 |
+
"matched_by": "fuzzy",
|
| 1448 |
+
"candidates": close,
|
| 1449 |
+
}
|
| 1450 |
+
return {
|
| 1451 |
+
"tag": None,
|
| 1452 |
+
"matched_by": "not_found",
|
| 1453 |
+
"candidates": close,
|
| 1454 |
+
}
|
| 1455 |
+
|
| 1456 |
+
def get_artist_profile(self, artist_name: str, top_n: int = 20,
|
| 1457 |
+
show_nsfw: bool = True) -> dict[str, Any]:
|
| 1458 |
+
"""Return a resolved artist and their common co-occurring tags."""
|
| 1459 |
+
resolved = self.resolve_artist_name(artist_name)
|
| 1460 |
+
artist = resolved["artist"]
|
| 1461 |
+
if not artist:
|
| 1462 |
+
return {
|
| 1463 |
+
"error": "artist_not_found",
|
| 1464 |
+
"input": artist_name,
|
| 1465 |
+
"matched_by": resolved["matched_by"],
|
| 1466 |
+
"candidates": resolved["candidates"],
|
| 1467 |
+
"message": (
|
| 1468 |
+
"未在画师共现库中找到唯一画师;这不代表 Danbooru 标签不存在,"
|
| 1469 |
+
"也不应使用 search_tags 验证画师名。"
|
| 1470 |
+
),
|
| 1471 |
+
}
|
| 1472 |
+
|
| 1473 |
+
top_tags: list[dict[str, str]] = []
|
| 1474 |
+
for tag, _npmi, _cooc in self._artist_top_tags.get(artist, []):
|
| 1475 |
+
if len(top_tags) >= top_n:
|
| 1476 |
+
break
|
| 1477 |
+
if not show_nsfw and self._name_to_idx is not None and tag in self._name_to_idx:
|
| 1478 |
+
idx = self._name_to_idx[tag]
|
| 1479 |
+
if self._arr_nsfw is not None and self._arr_nsfw[idx] == '1':
|
| 1480 |
+
continue
|
| 1481 |
+
|
| 1482 |
+
cn_short = ""
|
| 1483 |
+
if self._name_to_idx is not None and tag in self._name_to_idx:
|
| 1484 |
+
idx = self._name_to_idx[tag]
|
| 1485 |
+
cn_full = str(self._arr_cn_name[idx]) if self._arr_cn_name is not None else ""
|
| 1486 |
+
cn_short = cn_full.split(',')[0].strip() if cn_full else ""
|
| 1487 |
+
top_tags.append({
|
| 1488 |
+
"tag": tag,
|
| 1489 |
+
"cn_name": cn_short,
|
| 1490 |
+
})
|
| 1491 |
+
|
| 1492 |
+
return {
|
| 1493 |
+
"artist": artist,
|
| 1494 |
+
"input": artist_name,
|
| 1495 |
+
"matched_by": resolved["matched_by"],
|
| 1496 |
+
"post_count": self._artist_post_count.get(artist, 0),
|
| 1497 |
+
"top_tags": top_tags,
|
| 1498 |
+
"note": (
|
| 1499 |
+
"这些是该画师作品中常共现的标签,可作为风格参考;"
|
| 1500 |
+
"不是对画风的完整语义描述。"
|
| 1501 |
+
),
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
def search_artists_pipeline(
|
| 1505 |
self,
|
| 1506 |
query: str,
|
|
|
|
| 1684 |
|
| 1685 |
# 构建反向索引:artist → top tags(按 NPMI 降序)
|
| 1686 |
artist_tags: dict[str, list[tuple]] = {}
|
| 1687 |
+
artist_post_count: dict[str, int] = {}
|
| 1688 |
for tag, entries in index.items():
|
| 1689 |
+
for artist, npmi, cooc, artist_pc in entries:
|
| 1690 |
artist_tags.setdefault(artist, []).append((tag, npmi, cooc))
|
| 1691 |
+
artist_post_count[artist] = max(
|
| 1692 |
+
artist_post_count.get(artist, 0), artist_pc,
|
| 1693 |
+
)
|
| 1694 |
for artist, entries in artist_tags.items():
|
| 1695 |
entries.sort(key=lambda x: -x[1])
|
| 1696 |
self._artist_top_tags = artist_tags
|
| 1697 |
+
self._artist_post_count = artist_post_count
|
| 1698 |
|
| 1699 |
print(
|
| 1700 |
f'[Engine] 标签-画师共现表加载完成,{len(index):,} 个标签,'
|
mcp_server.py
CHANGED
|
@@ -13,6 +13,7 @@ MCP 服务层
|
|
| 13 |
支持的工具:
|
| 14 |
search_tags 自然语言搜索标签
|
| 15 |
get_related_tags 基于共现表查关联推荐
|
|
|
|
| 16 |
get_anima_format 返回 Anima 模型 Hybrid 提示词格式规范
|
| 17 |
get_newbie_format 返回 NewBie 模型 XML 提示词格式规范
|
| 18 |
"""
|
|
@@ -72,6 +73,29 @@ mcp = FastMCP(
|
|
| 72 |
)
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
@mcp.tool()
|
| 76 |
async def search_tags(
|
| 77 |
query: str,
|
|
@@ -81,65 +105,66 @@ async def search_tags(
|
|
| 81 |
include_wiki: bool = False,
|
| 82 |
) -> str:
|
| 83 |
"""
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
(e.g. "一个穿着白色水手服的少女在雨中奔跑", "金发双马尾女孩坐在教室窗边看书,夕阳",
|
| 94 |
"芙兰朵露 金发 辫子 发带 连衣裙 围裙 灯笼裤")
|
| 95 |
-
"concept_explore" — **
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
many elements; a detailed scene is still "full_scene".
|
| 99 |
(e.g. "各种各样的汉服", "兔耳朵都有哪些", "赛博朋克服装有什么风格")
|
| 100 |
-
"subject_describe" — **
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
"
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
Each delimiter-bounded segment ≤7 characters stays atomic — the engine respects your intent.
|
| 126 |
-
|
| 127 |
-
| Query style | Example |
|
| 128 |
|---|---|
|
| 129 |
-
|
|
| 130 |
-
|
|
| 131 |
-
|
|
| 132 |
-
|
|
| 133 |
|
| 134 |
-
##
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
|
| 139 |
-
##
|
| 140 |
|
| 141 |
-
JSON
|
| 142 |
-
|
| 143 |
"""
|
| 144 |
_SEARCH_MODE_PRESETS: dict[str, dict] = {
|
| 145 |
"precise_lookup": {"top_k": 10, "limit": 10, "popularity_weight": 0.15, "use_segmentation": False, "group_mode": "off", "max_per_group": 2},
|
|
@@ -218,91 +243,63 @@ async def get_related_tags(
|
|
| 218 |
include_wiki: bool = False,
|
| 219 |
) -> str:
|
| 220 |
"""
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
|
| 227 |
-
##
|
| 228 |
|
| 229 |
-
-
|
| 230 |
-
|
| 231 |
-
-
|
| 232 |
-
|
| 233 |
-
-
|
| 234 |
-
|
| 235 |
-
-
|
| 236 |
-
|
| 237 |
-
-
|
| 238 |
-
|
| 239 |
|
| 240 |
-
|
| 241 |
-
with the `category` parameter instead.
|
| 242 |
|
| 243 |
-
##
|
| 244 |
|
| 245 |
-
|
| 246 |
-
|
| 247 |
|
| 248 |
-
##
|
| 249 |
|
| 250 |
-
- tags:
|
| 251 |
-
|
| 252 |
-
- limit:
|
| 253 |
-
- show_nsfw:
|
| 254 |
-
- include_wiki:
|
| 255 |
-
|
| 256 |
|
| 257 |
-
##
|
| 258 |
|
| 259 |
-
JSON
|
| 260 |
- tag, cn_name
|
| 261 |
-
- sources:
|
| 262 |
-
- wiki:
|
| 263 |
"""
|
| 264 |
tagger = await DanbooruTagger.get_instance()
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
if t in tagger._name_to_idx:
|
| 271 |
-
valid_tags.append(t)
|
| 272 |
-
else:
|
| 273 |
-
invalid_tags.append(t)
|
| 274 |
-
|
| 275 |
-
corrections = {}
|
| 276 |
-
if invalid_tags:
|
| 277 |
-
for bad_tag in invalid_tags:
|
| 278 |
-
try:
|
| 279 |
-
req = SearchRequest(
|
| 280 |
-
query=bad_tag,
|
| 281 |
-
top_k=5,
|
| 282 |
-
limit=5,
|
| 283 |
-
popularity_weight=0.15,
|
| 284 |
-
use_segmentation=False,
|
| 285 |
-
target_layers=['英文']
|
| 286 |
-
)
|
| 287 |
-
resp = await tagger.search_async(req)
|
| 288 |
-
if resp.results:
|
| 289 |
-
corrections[bad_tag] = resp.results[0].tag
|
| 290 |
-
except Exception:
|
| 291 |
-
pass
|
| 292 |
-
|
| 293 |
-
if not valid_tags and not corrections:
|
| 294 |
-
return json.dumps({
|
| 295 |
"error": "所有传入的标签均不存在于标签表中",
|
| 296 |
"invalid_tags": invalid_tags,
|
| 297 |
-
}
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
for t in tags:
|
| 302 |
-
if t in valid_tags:
|
| 303 |
-
corrected_tags.append(t)
|
| 304 |
-
elif t in corrections:
|
| 305 |
-
corrected_tags.append(corrections[t])
|
| 306 |
|
| 307 |
results = await tagger.get_related_async(
|
| 308 |
corrected_tags,
|
|
@@ -349,73 +346,45 @@ async def get_artist_recommendations(
|
|
| 349 |
show_nsfw: bool = True,
|
| 350 |
) -> str:
|
| 351 |
"""
|
| 352 |
-
|
| 353 |
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
NPMI score.
|
| 357 |
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
e.g. ["1girl", "blue_hair", "school_uniform"]
|
| 361 |
-
- limit: Max artists returned. Default 30.
|
| 362 |
-
- min_cooc: Minimum co-occurrence count per (tag, artist) pair to consider. Default 3.
|
| 363 |
-
- show_nsfw: Include NSFW artist data. Default True.
|
| 364 |
|
| 365 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
-
JSON
|
| 368 |
-
- artist: Danbooru
|
| 369 |
-
- cooc_count:
|
| 370 |
-
- post_count:
|
| 371 |
-
- sources:
|
| 372 |
-
- top_tags:
|
| 373 |
"""
|
| 374 |
tagger = await DanbooruTagger.get_instance()
|
| 375 |
|
| 376 |
if not tags:
|
| 377 |
return json.dumps({"error": "tags 列表不能为空"}, ensure_ascii=False, indent=2)
|
| 378 |
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
if t in tagger._name_to_idx:
|
| 384 |
-
valid_tags.append(t)
|
| 385 |
-
else:
|
| 386 |
-
invalid_tags.append(t)
|
| 387 |
-
|
| 388 |
-
corrections = {}
|
| 389 |
-
if invalid_tags:
|
| 390 |
-
for bad_tag in invalid_tags:
|
| 391 |
-
try:
|
| 392 |
-
req = SearchRequest(
|
| 393 |
-
query=bad_tag,
|
| 394 |
-
top_k=5,
|
| 395 |
-
limit=5,
|
| 396 |
-
popularity_weight=0.15,
|
| 397 |
-
use_segmentation=False,
|
| 398 |
-
target_layers=['英文']
|
| 399 |
-
)
|
| 400 |
-
resp = await tagger.search_async(req)
|
| 401 |
-
if resp.results:
|
| 402 |
-
corrections[bad_tag] = resp.results[0].tag
|
| 403 |
-
except Exception:
|
| 404 |
-
pass
|
| 405 |
-
|
| 406 |
-
if not valid_tags and not corrections:
|
| 407 |
-
return json.dumps({
|
| 408 |
"error": "所有传入的标签均不存在于标签表中",
|
| 409 |
"invalid_tags": invalid_tags,
|
| 410 |
-
}
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
for t in tags:
|
| 415 |
-
if t in valid_tags:
|
| 416 |
-
corrected_tags.append(t)
|
| 417 |
-
elif t in corrections:
|
| 418 |
-
corrected_tags.append(corrections[t])
|
| 419 |
|
| 420 |
results = await tagger.search_artists_by_tags_async(
|
| 421 |
corrected_tags, limit=limit, min_cooc=min_cooc,
|
|
@@ -456,6 +425,56 @@ async def get_artist_recommendations(
|
|
| 456 |
return json.dumps(payload, ensure_ascii=False, indent=2)
|
| 457 |
|
| 458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
# ── Anima 提示词格式说明 ─────────────────────────────────────────────────
|
| 460 |
_ANIMA_FORMAT_INSTRUCTION = """
|
| 461 |
# Anima Hybrid Prompt Format Specification
|
|
@@ -1057,4 +1076,4 @@ async def get_newbie_format() -> str:
|
|
| 1057 |
|
| 1058 |
包含完整 NewBie 提示词格式规范的文本,涵盖 XML 结构、标签处理规则、多人物规则等。
|
| 1059 |
"""
|
| 1060 |
-
return _NEWBIE_OUTPUT_FORMAT
|
|
|
|
| 13 |
支持的工具:
|
| 14 |
search_tags 自然语言搜索标签
|
| 15 |
get_related_tags 基于共现表查关联推荐
|
| 16 |
+
get_artist_profile 查询单个画师常见共现标签
|
| 17 |
get_anima_format 返回 Anima 模型 Hybrid 提示词格式规范
|
| 18 |
get_newbie_format 返回 NewBie 模型 XML 提示词格式规范
|
| 19 |
"""
|
|
|
|
| 73 |
)
|
| 74 |
|
| 75 |
|
| 76 |
+
def _resolve_canonical_tags(tagger: DanbooruTagger, tags: list[str]) -> tuple[list[str], list[str], dict[str, str], dict[str, list[str]]]:
|
| 77 |
+
"""轻量解析 canonical tag 名,不调用语义搜索。"""
|
| 78 |
+
resolved_tags: list[str] = []
|
| 79 |
+
invalid_tags: list[str] = []
|
| 80 |
+
corrections: dict[str, str] = {}
|
| 81 |
+
candidates: dict[str, list[str]] = {}
|
| 82 |
+
|
| 83 |
+
for raw_tag in tags:
|
| 84 |
+
resolved = tagger.resolve_tag_name(raw_tag)
|
| 85 |
+
tag = resolved.get("tag")
|
| 86 |
+
if tag:
|
| 87 |
+
resolved_tags.append(tag)
|
| 88 |
+
if tag != raw_tag:
|
| 89 |
+
corrections[raw_tag] = tag
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
invalid_tags.append(raw_tag)
|
| 93 |
+
if resolved.get("candidates"):
|
| 94 |
+
candidates[raw_tag] = resolved["candidates"]
|
| 95 |
+
|
| 96 |
+
return resolved_tags, invalid_tags, corrections, candidates
|
| 97 |
+
|
| 98 |
+
|
| 99 |
@mcp.tool()
|
| 100 |
async def search_tags(
|
| 101 |
query: str,
|
|
|
|
| 105 |
include_wiki: bool = False,
|
| 106 |
) -> str:
|
| 107 |
"""
|
| 108 |
+
使用自然语言搜索 Danbooru 视觉标签、角色标签、作品标签,并返回可直接用于提示词的 tag 列表。
|
| 109 |
+
|
| 110 |
+
本工具适合搜索可见画面内容:主体、服装、姿势、动作、表情、背景、构图、角色名、作品名等。
|
| 111 |
+
|
| 112 |
+
不要用本工具搜索画师名、画师风格、creator/artist lookup,也不要用它验证某个画师标签是否存在。
|
| 113 |
+
遇到 "Mika Pikazo style"、"画师 mika_pikazo"、"by redjuice"、"这个画师常画什么" 这类请求时,
|
| 114 |
+
应改用 get_artist_profile。若用户同时给出画师/风格参考和可见画面描述,只把可见画面描述交给
|
| 115 |
+
search_tags,不要把画师名放进 query。
|
| 116 |
+
|
| 117 |
+
## 参数
|
| 118 |
+
- query: 自然语言画面描述。推荐使用中文。
|
| 119 |
+
- search_mode: 搜索策略。**默认是 "full_scene";除非用户明确想探索多种候选,否则保持默认。**
|
| 120 |
+
"full_scene" — **默认。** 用户给出具体画面描述时使用:场景、主体、服装、姿势、动作、
|
| 121 |
+
背景等,不管描述多长、元素多少。用户想要的是一张图的一组连贯提示词。
|
| 122 |
(e.g. "一个穿着白色水手服的少女在雨中奔跑", "金发双马尾女孩坐在教室窗边看书,夕阳",
|
| 123 |
"芙兰朵露 金发 辫子 发带 连衣裙 围裙 灯笼裤")
|
| 124 |
+
"concept_explore" — **只用于开放式概念浏览。** 当用户想看某个模糊/单一概念有哪些类型、
|
| 125 |
+
想从大量候选中挑选时使用。会返回最多 80 个候选,token 成本较高。
|
| 126 |
+
不要因为描述元素多就使用此模式;详细场景仍然属于 "full_scene"。
|
|
|
|
| 127 |
(e.g. "各种各样的汉服", "兔耳朵都有哪些", "赛博朋克服装有什么风格")
|
| 128 |
+
"subject_describe" — **只用于描述一个单一视觉概念。** 此模式关闭分词,不能解析多元素 query。
|
| 129 |
+
如果 query 包含角色名 + 属性、多个服装物件、或任何组合场景,应使用
|
| 130 |
+
"full_scene"。
|
| 131 |
+
适合:"EVA中蓝发的驾驶员"(单一角色概念)、"灯笼裤"(单一物件)、
|
| 132 |
+
"两侧有开口,前方有拉绳的运动短裤"(带细节的单一物件)。
|
| 133 |
+
"precise_lookup" — 精确查词 / 拼写纠错,例如 "selafuku"、"thighhigh"。
|
| 134 |
+
- 判断规则:用户是想得到一张具体图的提示词(→ full_scene),还是想浏览某个概念的多种候选
|
| 135 |
+
(→ concept_explore)?元素数量不是判断依据,探索意图才是。
|
| 136 |
+
- 重要:只要 query 是具体场景、多元素组合、角色 + 属性,就用 "full_scene"。拿不准时也用
|
| 137 |
+
"full_scene",它能处理具体画面描述。
|
| 138 |
+
- category: 限定搜索类别。默认 "all"。
|
| 139 |
+
"all" — 全部(通用 + 作品 + 角色)
|
| 140 |
+
"general" — 可见属性、服装、姿势、背景等通用标签
|
| 141 |
+
"character" — 角色标签
|
| 142 |
+
"copyright" — 动画/游戏/作品名等版权标签
|
| 143 |
+
- show_nsfw: 是否包含 NSFW 标签。默认 True。
|
| 144 |
+
- include_wiki: 是否在结果中附带 wiki 说明。默认 False。
|
| 145 |
+
当标签含义不熟悉、需要消歧时设为 True。
|
| 146 |
+
|
| 147 |
+
## query 写法建议
|
| 148 |
+
|
| 149 |
+
可以使用**空格、换行、中文逗号(,)、顿号(、)**手动分隔概念。
|
| 150 |
+
被分隔符包围且长度不超过 7 个汉字的片段会尽量保持原子性,搜索引擎会尊重你的拆分意图。
|
| 151 |
+
|
| 152 |
+
| 写法 | 示例 |
|
|
|
|
|
|
|
|
|
|
| 153 |
|---|---|
|
| 154 |
+
| 空格分隔概念 | `运动社团 校队 比赛 运动会` |
|
| 155 |
+
| 顿号分隔概念 | `反乌托邦、赛博朋克、蒸汽朋克` |
|
| 156 |
+
| 自然句子 | `一个穿着白色水手服的少女在雨中奔跑` |
|
| 157 |
+
| 混合写法 | `运动社团 一个穿水手服的少女` |
|
| 158 |
|
| 159 |
+
## 工作流
|
| 160 |
|
| 161 |
+
调用 search_tags 后,可以把选中的标签传给 get_related_tags,通过共现关系发现互补标签。
|
| 162 |
+
可按 search_tags → get_related_tags → get_related_tags → search_tags 多跳探索。
|
| 163 |
|
| 164 |
+
## 返回
|
| 165 |
|
| 166 |
+
JSON 对象,包含 prompt(逗号分隔 tag)、keywords、results。
|
| 167 |
+
每个 result 包含 tag、cn_name;当 include_wiki=True 时额外包含 wiki。
|
| 168 |
"""
|
| 169 |
_SEARCH_MODE_PRESETS: dict[str, dict] = {
|
| 170 |
"precise_lookup": {"top_k": 10, "limit": 10, "popularity_weight": 0.15, "use_segmentation": False, "group_mode": "off", "max_per_group": 2},
|
|
|
|
| 243 |
include_wiki: bool = False,
|
| 244 |
) -> str:
|
| 245 |
"""
|
| 246 |
+
根据已给定的 Danbooru 标签列表,返回基于 NPMI 共现评分的关联标签推荐。
|
| 247 |
+
本工具只支持通用标签、作品标签、角色标签;**���支持画师标签和 meta 标签。**
|
| 248 |
+
|
| 249 |
+
不要用本工具搜索画师名、画师风格、creator/artist lookup,也不要用它验证某个画师标签是否存在。
|
| 250 |
+
如果用户询问某个具体画师常画什么,或询问画师风格参考,应使用 get_artist_profile。
|
| 251 |
|
| 252 |
+
本工具会找出在 Danbooru 中经常与种子标签共同出现的标签。结果会按设计混合
|
| 253 |
+
General / Character / Copyright 类别。
|
| 254 |
|
| 255 |
+
## 典型用法
|
| 256 |
|
| 257 |
+
- 属性 → 拥有该属性的角色
|
| 258 |
+
例如 ["fingerless_gloves"] → tifa_lockhart, cammy_white, bridget_(guilty_gear), ...
|
| 259 |
+
- 作品 → 作品中的角色
|
| 260 |
+
例如 ["overlord_(maruyama)"] → shalltear_bloodfallen, ainz_ooal_gown, albedo_(overlord), ...
|
| 261 |
+
- 角色 → 该角色常见视觉属性
|
| 262 |
+
例如 ["amiya_(arknights)"] → 服装、表情、配饰等
|
| 263 |
+
- 主题探索
|
| 264 |
+
例如 ["fighter_jet"] → 飞机类型、动作、背景等
|
| 265 |
+
- 多标签交集
|
| 266 |
+
例如 ["maid", "twintails"] → 与该组合强相关的标签,按聚合 NPMI 评分排序
|
| 267 |
|
| 268 |
+
如果要做同类别内部探索,例如“更多类似 X 的服装标签”,请使用 search_tags 并设置 category。
|
|
|
|
| 269 |
|
| 270 |
+
## 工作流
|
| 271 |
|
| 272 |
+
可按 search_tags → get_related_tags → get_related_tags → search_tags 链式调用。
|
| 273 |
+
沿共现图多跳探索时,可以发现单纯语义搜索不容易召回的标签。
|
| 274 |
|
| 275 |
+
## 参数
|
| 276 |
|
| 277 |
+
- tags: canonical Danbooru tag 名列表,使用下划线,不使用空格。
|
| 278 |
+
例如 ["white_serafuku", "sailor_collar"]
|
| 279 |
+
- limit: 最多返回的推荐数量。默认 50。
|
| 280 |
+
- show_nsfw: 是否包含 NSFW 标签。默认 True。
|
| 281 |
+
- include_wiki: 是否在结果中附带 wiki 说明。默认 False。
|
| 282 |
+
当结果标签不熟悉、需要消歧时设为 True。
|
| 283 |
|
| 284 |
+
## 返回
|
| 285 |
|
| 286 |
+
JSON 对象,results 按聚合 NPMI 分数降序排序。每个结果包含:
|
| 287 |
- tag, cn_name
|
| 288 |
+
- sources: 对该推荐有贡献的种子标签
|
| 289 |
+
- wiki: 仅当 include_wiki=True 时返回
|
| 290 |
"""
|
| 291 |
tagger = await DanbooruTagger.get_instance()
|
| 292 |
|
| 293 |
+
corrected_tags, invalid_tags, corrections, candidates = _resolve_canonical_tags(tagger, tags)
|
| 294 |
+
|
| 295 |
+
if not corrected_tags:
|
| 296 |
+
payload = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
"error": "所有传入的标签均不存在于标签表中",
|
| 298 |
"invalid_tags": invalid_tags,
|
| 299 |
+
}
|
| 300 |
+
if candidates:
|
| 301 |
+
payload["candidates"] = candidates
|
| 302 |
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
results = await tagger.get_related_async(
|
| 305 |
corrected_tags,
|
|
|
|
| 346 |
show_nsfw: bool = True,
|
| 347 |
) -> str:
|
| 348 |
"""
|
| 349 |
+
根据标签-画师 NPMI 共现数据,推荐擅长绘制给定标签的画师。
|
| 350 |
|
| 351 |
+
输入一组 canonical Danbooru 标签(例如角色名、服装、主题、视觉元素),本工具会返回作品中
|
| 352 |
+
经常与这些标签共同出现的画师,并按聚合 NPMI 分数排序。
|
|
|
|
| 353 |
|
| 354 |
+
本工具用于 tag → artist 推荐。输入必须是 canonical Danbooru tag 名,不是画师名。
|
| 355 |
+
不要用本工具查询某个具体画师;画师 → 常见标签应使用 get_artist_profile。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
+
## 参数
|
| 358 |
+
- tags: canonical Danbooru tag 名列表,使用下划线,不使用空格。
|
| 359 |
+
例如 ["1girl", "blue_hair", "school_uniform"]
|
| 360 |
+
- limit: 最多返回的画师数量。默认 30。
|
| 361 |
+
- min_cooc: 单个 (tag, artist) 组合进入计算所需的最小共现次数。默认 3。
|
| 362 |
+
- show_nsfw: 是否包含 NSFW 画师数据。默认 True。
|
| 363 |
+
|
| 364 |
+
## 返回
|
| 365 |
|
| 366 |
+
JSON 对象,results 按 NPMI 分数降序排序。每个结果包含:
|
| 367 |
+
- artist: Danbooru 画师 tag 名
|
| 368 |
+
- cooc_count: 所有输入标签上的累计共现次数
|
| 369 |
+
- post_count: 该画师在 Danbooru 的作品数
|
| 370 |
+
- sources: 命中该画师的输入标签
|
| 371 |
+
- top_tags: 该画师最常画的前 10 个标签(带中文名)
|
| 372 |
"""
|
| 373 |
tagger = await DanbooruTagger.get_instance()
|
| 374 |
|
| 375 |
if not tags:
|
| 376 |
return json.dumps({"error": "tags 列表不能为空"}, ensure_ascii=False, indent=2)
|
| 377 |
|
| 378 |
+
corrected_tags, invalid_tags, corrections, candidates = _resolve_canonical_tags(tagger, tags)
|
| 379 |
+
|
| 380 |
+
if not corrected_tags:
|
| 381 |
+
payload = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
"error": "所有传入的标签均不存在于标签表中",
|
| 383 |
"invalid_tags": invalid_tags,
|
| 384 |
+
}
|
| 385 |
+
if candidates:
|
| 386 |
+
payload["candidates"] = candidates
|
| 387 |
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
|
| 389 |
results = await tagger.search_artists_by_tags_async(
|
| 390 |
corrected_tags, limit=limit, min_cooc=min_cooc,
|
|
|
|
| 425 |
return json.dumps(payload, ensure_ascii=False, indent=2)
|
| 426 |
|
| 427 |
|
| 428 |
+
@mcp.tool()
|
| 429 |
+
async def get_artist_profile(
|
| 430 |
+
artist_name: str,
|
| 431 |
+
top_n: int = 20,
|
| 432 |
+
show_nsfw: bool = True,
|
| 433 |
+
) -> str:
|
| 434 |
+
"""
|
| 435 |
+
在画师-标签共现数据库中查询单个 Danbooru 画师,并返回该画师常见共现标签。
|
| 436 |
+
|
| 437 |
+
当用户询问某个具体画师或画师风格参考时使用本工具,例如��
|
| 438 |
+
"Mika Pikazo style"、"画师 mika_pikazo"、"by redjuice"、"这个画师常画什么"。
|
| 439 |
+
本工具查询的是画师数据库,不是普通视觉 tag 搜索索引。
|
| 440 |
+
|
| 441 |
+
画师名会在查询前自动规范化。因此,当数据库中存在 "mika_pikazo" 时,
|
| 442 |
+
"Mika Pikazo"、"mika pikazo"、"mika_pikazo"、"MikaPikazo" 都可以解析到它。
|
| 443 |
+
|
| 444 |
+
## 参数
|
| 445 |
+
- artist_name: 画师名或 Danbooru 画师 tag。允许大小写差异和空格。
|
| 446 |
+
- top_n: 最多返回的常见标签数量。默认 20。
|
| 447 |
+
- show_nsfw: 是否包含 NSFW 常见标签。默认 True。
|
| 448 |
+
|
| 449 |
+
## 返回
|
| 450 |
+
|
| 451 |
+
JSON 对象,包含:
|
| 452 |
+
- artist: 解析后的 canonical Danbooru 画师 tag
|
| 453 |
+
- input: 原始输入
|
| 454 |
+
- matched_by: 匹配方式,可能是 exact / normalized_exact / compact_exact / fuzzy
|
| 455 |
+
- post_count: 该画师在共现数据库中的作品数
|
| 456 |
+
- top_tags: 常见共现标签列表,每项只包含 tag 和 cn_name
|
| 457 |
+
- note: 说明这些常见标签只能作为风格参考,不等于完整画风语义描述
|
| 458 |
+
|
| 459 |
+
如果没有找到唯一画师,会返回 artist_not_found 和候选画师名。这不代表该画师 tag 在 Danbooru
|
| 460 |
+
不存在,也不要改用 search_tags 验证画师名。
|
| 461 |
+
"""
|
| 462 |
+
tagger = await DanbooruTagger.get_instance()
|
| 463 |
+
profile = tagger.get_artist_profile(
|
| 464 |
+
artist_name,
|
| 465 |
+
top_n=max(1, min(int(top_n), 100)),
|
| 466 |
+
show_nsfw=show_nsfw,
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
await counter.increment()
|
| 470 |
+
await counter.increment_mcp()
|
| 471 |
+
if "error" not in profile:
|
| 472 |
+
await counter.increment_success()
|
| 473 |
+
await counter.increment_copy()
|
| 474 |
+
|
| 475 |
+
return json.dumps(profile, ensure_ascii=False, indent=2)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
# ── Anima 提示词格式说明 ─────────────────────────────────────────────────
|
| 479 |
_ANIMA_FORMAT_INSTRUCTION = """
|
| 480 |
# Anima Hybrid Prompt Format Specification
|
|
|
|
| 1076 |
|
| 1077 |
包含完整 NewBie 提示词格式规范的文本,涵盖 XML 结构、标签处理规则、多人物规则等。
|
| 1078 |
"""
|
| 1079 |
+
return _NEWBIE_OUTPUT_FORMAT
|