Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| mcp_server.py | |
| ───────────── | |
| MCP 服务层 | |
| 挂载方式(在 ui_nicegui.py 中): | |
| from mcp_server import mcp | |
| app.mount('/mcp', mcp.streamable_http_app()) | |
| 接入地址: | |
| https://sakizuki-danboorusearch.hf.space/mcp/mcp | |
| 支持的工具: | |
| search_tags 自然语言搜索标签 | |
| get_related_tags 基于共现表查关联推荐 | |
| """ | |
| import json | |
| import asyncio | |
| import logging | |
| from anyio import BrokenResourceError, ClosedResourceError | |
| from mcp.server.fastmcp import FastMCP | |
| from mcp.server.transport_security import TransportSecuritySettings | |
| from core.engine import DanbooruTagger | |
| from core.models import SearchRequest | |
| import core.counter as counter | |
| import re | |
| # ── 过滤客户端断连产生的无害报错噪音 ────────────────────────────────── | |
| class _SuppressClientDisconnect(logging.Filter): | |
| def filter(self, record: logging.LogRecord) -> bool: | |
| exc = record.exc_info[1] if record.exc_info else None | |
| if isinstance(exc, (BrokenResourceError, ClosedResourceError)): | |
| return False # 丢弃该日志记录 | |
| return True | |
| _disconnect_filter = _SuppressClientDisconnect() | |
| logging.getLogger("mcp.server.streamable_http").addFilter(_disconnect_filter) | |
| logging.getLogger("uvicorn.error").addFilter(_disconnect_filter) | |
| mcp = FastMCP( | |
| name="danbooru-searcher", | |
| transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), | |
| ) | |
| async def search_tags( | |
| query: str, | |
| use_segmentation: bool = True, | |
| top_k: int = 5, | |
| limit: int = 80, | |
| popularity_weight: float = 0.15, | |
| show_nsfw: bool = True, | |
| include_wiki: bool = False, | |
| category: str = "all", | |
| group_mode: str = "off", | |
| max_per_group: int = 2, | |
| ) -> str: | |
| """ | |
| Search Danbooru tags using natural language and return a ready-to-use prompt. | |
| ## Args | |
| - query: Natural language description (Chinese recommended). | |
| - use_segmentation: Split multi-concept input into segments for separate retrieval. True for scene descriptions, False for single-concept queries. | |
| - top_k: Candidates recalled per segment. Semantics change with use_segmentation — see guide below. | |
| - limit: Max tags returned. | |
| - popularity_weight: Influence of tag post count on ranking (0.0–1.0). Default 0.15. | |
| - show_nsfw: Include NSFW tags. Default True. | |
| - include_wiki: Append wiki description to each result. Default False. | |
| - category: Filter results to a specific tag category. Default "all". | |
| "all" — All categories (通用 + 版权 + 人物 ) | |
| "general" — General: visual attributes, clothing, pose, background, etc. | |
| "copyright" — Copyright: specific anime/game/franchise titles | |
| "character" — Character: named characters from any series | |
| Use this when you know what kind of tag you need — e.g. looking for a | |
| character name vs. describing a scene visually. | |
| - group_mode: Tag group processing mode. Default "off". | |
| "off" — No group processing (backward compatible) | |
| "expand" — Boost same-group tags for concept exploration | |
| "diverse" — Limit tags per group for scene diversity | |
| - max_per_group: Max tags per group in diverse mode. Default 2. | |
| ## Query writing guide | |
| The `query` parameter supports explicit delimiter control for precise segmentation. | |
| ### Explicit delimiters | |
| Use **spaces, newlines, Chinese commas (,), or Chinese dunhao (、)** to manually separate concepts. | |
| Each delimiter-bounded segment ≤7 characters stays atomic — the engine respects your intent. | |
| | Query style | Example | When to use | | |
| |---|---|---| | |
| | Concept list (spaces) | `运动社团 校队 比赛 运动会` | You know the exact concepts to search | | |
| | Concept list (dun hao) | `反乌托邦、赛博朋克、蒸汽朋克` | Same, with Chinese list punctuation | | |
| | Natural sentence | `一个穿着白色水手服的少女在雨中奔跑` | Scene description, let the engine auto-split | | |
| | Mixed | `运动社团 一个穿水手服的少女` | Mix concepts with descriptive phrases | | |
| Segments >7 characters are still auto-split by jieba, but the raw segment is kept as an additional query | |
| to preserve clause-level semantics (multi-granularity retrieval). | |
| ### Recommendations | |
| 1. **Concept lists → use explicit delimiters:** Group independent concepts with spaces or dunhao. | |
| `运动社团 校队 比赛 体育祭 田径部` is better than `运动社团校队比赛体育祭田径部`. | |
| 2. **Scene descriptions → write naturally:** Natural Chinese with Chinese commas works well for full scenes. | |
| `一个穿着白色水手服,蓝色短裙的少女在雨中奔跑` — commas here are grammatical, not delimiters. | |
| 3. **Precise lookup → turn off segmentation:** For finding a specific character or copyright title, | |
| set `use_segmentation=False` and combine with `category` filter. | |
| e.g. `query="EVA中蓝发的零号机驾驶员"` with `category="character"` and `use_segmentation=False`. | |
| 4. **Category filtering:** Use `category` to narrow results. Looking for a character? | |
| `category="character"`. Building a scene prompt? `category="general"`. | |
| ## Parameter guide | |
| ### Step 1 — Decide use_segmentation + top_k together | |
| top_k means "candidates per segment"; its effect depends on whether segmentation is on. | |
| Multi-concept input (scene description) → use_segmentation=True | |
| | Sub-scenario | top_k | Reason | | |
| |-----------------------|-------|----------------------------------------------------------| | |
| | Full scene → prompt | 5 | Many segments; low top_k distributes result slots fairly | | |
| | Vague concept explore | 80 | Few segments; high top_k needed for broad recall | | |
| Single-concept input → use_segmentation=False | |
| top_k acts as total candidate pool size. Use 20 for all single-concept cases. | |
| | Sub-scenario | top_k | | |
| |-------------------------------|-------| | |
| | Describe subject / find tag | 20 | | |
| | Precise lookup / spell fix | 20 | | |
| ### Step 2 — Decide limit independently | |
| | Goal | limit | | |
| |------------------------------|-------| | |
| | Full prompt for image gen | 80 | | |
| | Concept exploration | 20–80 | | |
| | Precise lookup / role search | 10–20 | | |
| ### Auxiliary params | |
| popularity_weight (default 0.15, rarely needs changing): | |
| - Higher (0.3+): favor common, well-established tags | |
| - Lower (0.0): surface niche/rare tags | |
| include_wiki (default False): | |
| - True: The meaning of the tag is important — disambiguation, explaining tags to users, exploring unfamiliar domains, or when you are unsure of the tag's meaning | |
| - False: Prompt generation (Wiki is irrelevant to the downstream task), tags are known | |
| ### Quick reference | |
| | Scenario | use_segmentation | top_k | limit | | |
| |-------------------------------|------------------|-------|-------| | |
| | Full scene → prompt (default) | True | 5 | 80 | | |
| | Vague concept exploration | True | 80 | 80 | | |
| | Describe subject / find tag | False | 20 | 20 | | |
| | Precise lookup / spell fix | False | 20 | 10 | | |
| ### Workflow | |
| After search_tags, pass selected tags to get_related_tags to discover complementary tags via co-occurrence (accessories, character features, scene atmosphere). | |
| Supports chained exploration / iterative loops – take the interesting tags from the returned results as input to call get_related_tags again, | |
| and use the results from get_related to feed back into a new round of search, enabling multi-hop deep traversal along the co-occurrence graph. | |
| ## Examples | |
| Precise lookup / spell fix — e.g. "selafuku", "thighhigh", "twintail" | |
| → use_segmentation=False, top_k=20, limit=10 | |
| Vague concept exploration — e.g. "兔耳朵", "赛博朋克服装", "假肢" | |
| → use_segmentation=True, top_k=80, limit=80 | |
| Describe subject / find tag — e.g. "EVA中蓝发的零号机驾驶员", "命运石之门中的助手" | |
| → use_segmentation=False, top_k=20, limit=20 | |
| Full scene → prompt — e.g. "一个穿着白色水手服,蓝色短裙的少女在雨中的城市里奔跑" | |
| → use_segmentation=True, top_k=5, limit=80 | |
| ## Returns | |
| JSON with: prompt (comma-separated tags), keywords, results. | |
| Each result: tag, cn_name, category, final_score, count[, wiki if include_wiki=True]. | |
| """ | |
| _CATEGORY_MAP: dict[str, list[str]] = { | |
| "all": ["General", "Character", "Copyright", "Artist", "Meta"], | |
| "general": ["General"], | |
| "character": ["Character"], | |
| "copyright": ["Copyright"], | |
| } | |
| target_categories = _CATEGORY_MAP.get( | |
| category, | |
| _CATEGORY_MAP["all"], # unrecognized value → fall back to all | |
| ) | |
| tagger = await DanbooruTagger.get_instance() | |
| request = SearchRequest( | |
| query=query, | |
| top_k=top_k, | |
| limit=limit, | |
| popularity_weight=popularity_weight, | |
| show_nsfw=show_nsfw, | |
| use_segmentation=use_segmentation, | |
| target_categories=target_categories, | |
| group_mode=group_mode, | |
| max_per_group=max_per_group, | |
| ) | |
| response = await tagger.search_async(request) | |
| # 计数:每次 MCP 搜索调用均计入搜索、成功、复制;访问不变 | |
| await counter.increment() | |
| await counter.increment_success() | |
| await counter.increment_copy() | |
| await counter.increment_mcp() | |
| results = [] | |
| for r in response.results: | |
| if r.nsfw == '1' and not show_nsfw: | |
| continue | |
| item = { | |
| "tag": r.tag, | |
| "cn_name": r.cn_name, | |
| "category": r.category, | |
| "final_score": r.final_score, | |
| "count": r.count, | |
| } | |
| if include_wiki: | |
| item["wiki"] = r.wiki | |
| results.append(item) | |
| payload = { | |
| "prompt": response.tags_sfw if not show_nsfw else response.tags_all, | |
| "keywords": response.keywords, | |
| "results": results, | |
| } | |
| han_chars = re.findall(r'[\u4e00-\u9fff]', query) | |
| if len(query) > 0 and len(han_chars) / len(query) < 0.5: | |
| payload["hint"] = ( | |
| "检测到英文查询,该搜索引擎对中文查询优化更好,如果搜索结果不合预期,推荐用中文重试" | |
| ) | |
| return json.dumps(payload, ensure_ascii=False, indent=2) | |
| async def get_related_tags( | |
| tags: list[str], | |
| limit: int = 50, | |
| show_nsfw: bool = True, | |
| include_wiki: bool = False, | |
| ) -> str: | |
| """ | |
| Return co-occurrence-based tag recommendations for a given tag list (NPMI scoring). | |
| This tool surfaces tags that frequently appear alongside the seeds in | |
| Danbooru, mixing categories (General / Character / Copyright) by design. | |
| ## Typical use cases | |
| - Attribute → characters who have it | |
| e.g. ["fingerless_gloves"] → tifa_lockhart, cammy_white, bridget_(guilty_gear), ... | |
| - Work → characters in it | |
| e.g. ["overlord_(maruyama)"] → shalltear_bloodfallen, ainz_ooal_gown, albedo_(overlord), ... | |
| - Character → their visual attributes | |
| e.g. ["amiya_(arknights)"] → outfits, expressions, accessories | |
| - Theme exploration | |
| e.g. ["fighter_jet"] → aircraft types, actions, backgrounds | |
| - Multi-tag intersection | |
| e.g. ["maid", "twintails"] → tags specific to the combination, scored by summed NPMI | |
| For within-category exploration (e.g. "more clothing tags like X"), use search_tags | |
| with the `category` parameter instead. | |
| ## Workflow | |
| Chain freely: search_tags → get_related_tags → get_related_tags → search_tags. | |
| Each hop along the co-occurrence graph reveals tags unreachable by semantic search alone. | |
| ## Args | |
| - tags: List of canonical Danbooru tag names (underscores, no spaces). | |
| e.g. ["white_serafuku", "sailor_collar"] | |
| - limit: Max recommendations returned. Default 50. | |
| - show_nsfw: Include NSFW tags. Default True. | |
| - include_wiki: Append wiki description to each result. Default False. | |
| Set True when result tags are unfamiliar and need disambiguation. | |
| ## Returns | |
| JSON array sorted by aggregated NPMI score (descending). Each result: | |
| - tag, cn_name, category, count (post_count), cooc_score (normalized to [0,1]) | |
| - sources: seed tags that contributed to this score | |
| - wiki: only if include_wiki=True | |
| """ | |
| tagger = await DanbooruTagger.get_instance() | |
| # ── 检查标签是否存在,不存在则尝试 search_tags 纠错 ────────────────── | |
| valid_tags = [] | |
| invalid_tags = [] | |
| for t in tags: | |
| if t in tagger._name_to_idx: | |
| valid_tags.append(t) | |
| else: | |
| invalid_tags.append(t) | |
| corrections = {} | |
| if invalid_tags: | |
| for bad_tag in invalid_tags: | |
| try: | |
| req = SearchRequest( | |
| query=bad_tag, | |
| top_k=5, | |
| limit=5, | |
| popularity_weight=0.15, | |
| use_segmentation=False, | |
| target_layers=['英文'] | |
| ) | |
| resp = await tagger.search_async(req) | |
| if resp.results: | |
| corrections[bad_tag] = resp.results[0].tag | |
| except Exception: | |
| pass | |
| if not valid_tags and not corrections: | |
| return json.dumps({ | |
| "error": "所有传入的标签均不存在于标签表中", | |
| "invalid_tags": invalid_tags, | |
| }, ensure_ascii=False, indent=2) | |
| # 用纠错后的标签替换无效标签 | |
| corrected_tags = [] | |
| for t in tags: | |
| if t in valid_tags: | |
| corrected_tags.append(t) | |
| elif t in corrections: | |
| corrected_tags.append(corrections[t]) | |
| results = await asyncio.to_thread( | |
| tagger.get_related, | |
| corrected_tags, | |
| set(corrected_tags), | |
| limit, | |
| show_nsfw, | |
| ) | |
| # 计数:每次 MCP related 调用均计入搜索、成功、复制;访问不变 | |
| await counter.increment() | |
| await counter.increment_success() | |
| await counter.increment_copy() | |
| await counter.increment_mcp() | |
| output = [] | |
| for r in results: | |
| item = { | |
| "tag": r.tag, | |
| "cn_name": r.cn_name, | |
| "category": r.category, | |
| "count": r.post_count, | |
| "cooc_score": r.cooc_score, | |
| "sources": r.sources, | |
| } | |
| if include_wiki: | |
| item["wiki"] = r.wiki | |
| output.append(item) | |
| payload = output | |
| if corrections: | |
| correction_notes = [ | |
| f"{bad} → {good}" for bad, good in corrections.items() | |
| ] | |
| payload = { | |
| "correction_note": "标签拼写错误,已经纠错: " + ", ".join(correction_notes), | |
| "corrections": corrections, | |
| "results": output, | |
| } | |
| return json.dumps(payload, ensure_ascii=False, indent=2) |