Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Auto-sync from GitHub Actions
Browse files- README.md +12 -16
- api_fastapi.py +56 -61
- core/engine.py +86 -9
- core/models.py +2 -1
- ui_nicegui.py +49 -11
README.md
CHANGED
|
@@ -347,23 +347,20 @@ thumbnail: >-
|
|
| 347 |
POST /api/search
|
| 348 |
{
|
| 349 |
"query": "白色水手服的女孩",
|
| 350 |
-
"
|
| 351 |
-
"
|
|
|
|
| 352 |
"show_nsfw": true,
|
| 353 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
}
|
| 355 |
```
|
| 356 |
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
| 模式 | 适用场景 |
|
| 360 |
-
|---|---|
|
| 361 |
-
| `full_scene` | 默认模式。用于完整画面、多人/多元素描述,返回可直接使用的 prompt |
|
| 362 |
-
| `concept_explore` | 开放式概念发散,例如“兔耳朵都有哪些” |
|
| 363 |
-
| `subject_describe` | 单一角色、物品或视觉概念描述,例如“EVA中蓝发的驾驶员” |
|
| 364 |
-
| `precise_lookup` | 精确查词或拼写纠错,例如 `selafuku` |
|
| 365 |
-
|
| 366 |
-
`category` 可选:`all`、`general`、`character`、`copyright`。搜索响应包含 `prompt`、`keywords`、`results`,当 `include_wiki=true` 时,每条结果会附带 `wiki` 字段。
|
| 367 |
|
| 368 |
**关联推荐接口**
|
| 369 |
```
|
|
@@ -371,12 +368,11 @@ POST /api/related
|
|
| 371 |
{
|
| 372 |
"tags": ["white_serafuku", "sailor_collar"],
|
| 373 |
"limit": 50,
|
| 374 |
-
"show_nsfw": true
|
| 375 |
-
"include_wiki": false
|
| 376 |
}
|
| 377 |
```
|
| 378 |
|
| 379 |
-
响应为 `results` 列表;如果输入标签拼写错误但可被纠正,会额外返回 `correction_note` 和 `corrections`。
|
| 380 |
|
| 381 |
**推荐擅长画师接口**
|
| 382 |
```
|
|
|
|
| 347 |
POST /api/search
|
| 348 |
{
|
| 349 |
"query": "白色水手服的女孩",
|
| 350 |
+
"top_k": 5,
|
| 351 |
+
"limit": 80,
|
| 352 |
+
"popularity_weight": 0.15,
|
| 353 |
"show_nsfw": true,
|
| 354 |
+
"use_segmentation": true,
|
| 355 |
+
"target_layers": ["英文", "中文扩展词", "释义", "中文核心词"],
|
| 356 |
+
"target_categories": ["General", "Character", "Copyright"],
|
| 357 |
+
"group_mode": "off",
|
| 358 |
+
"max_per_group": 2
|
| 359 |
}
|
| 360 |
```
|
| 361 |
|
| 362 |
+
搜索接口保留底层可调参数,适合需要精细控制召回数量、分层检索、分词策略、类别筛选和分组去重的工作流。
|
| 363 |
+
响应包含 `tags_all`、`tags_sfw`、`keywords`、`results`;每条结果默认包含完整字段和 `wiki`。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
|
| 365 |
**关联推荐接口**
|
| 366 |
```
|
|
|
|
| 368 |
{
|
| 369 |
"tags": ["white_serafuku", "sailor_collar"],
|
| 370 |
"limit": 50,
|
| 371 |
+
"show_nsfw": true
|
|
|
|
| 372 |
}
|
| 373 |
```
|
| 374 |
|
| 375 |
+
响应为 `results` 列表,每条结果默认包含 `wiki`;如果输入标签拼写错误但可被纠正,会额外返回 `correction_note` 和 `corrections`。
|
| 376 |
|
| 377 |
**推荐擅长画师接口**
|
| 378 |
```
|
api_fastapi.py
CHANGED
|
@@ -27,10 +27,10 @@ FastAPI 适配层(可选)。
|
|
| 27 |
from __future__ import annotations
|
| 28 |
|
| 29 |
import asyncio
|
| 30 |
-
import re
|
| 31 |
from typing import Any
|
|
|
|
| 32 |
from fastapi import FastAPI, HTTPException
|
| 33 |
-
from pydantic import BaseModel, Field
|
| 34 |
|
| 35 |
from core.engine import DanbooruTagger
|
| 36 |
from core.models import SearchRequest, SearchResponse
|
|
@@ -39,26 +39,64 @@ import core.counter as counter
|
|
| 39 |
|
| 40 |
# ── Pydantic I/O 模型(API 层专用,与 core.models 解耦)──
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
class SearchIn(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
query: str
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 47 |
show_nsfw: bool = True
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class TagOut(BaseModel):
|
| 52 |
tag: str
|
| 53 |
cn_name: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
wiki: str = ""
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
class RelatedIn(BaseModel):
|
| 58 |
tags: list[str]
|
| 59 |
limit: int = Field(50, ge=1, le=200)
|
| 60 |
show_nsfw: bool = True
|
| 61 |
-
include_wiki: bool = False
|
| 62 |
|
| 63 |
|
| 64 |
class RelatedTagOut(BaseModel):
|
|
@@ -69,10 +107,10 @@ class RelatedTagOut(BaseModel):
|
|
| 69 |
|
| 70 |
|
| 71 |
class SearchOut(BaseModel):
|
| 72 |
-
|
|
|
|
| 73 |
results: list[TagOut]
|
| 74 |
keywords: list[str]
|
| 75 |
-
hint: str | None = None
|
| 76 |
|
| 77 |
|
| 78 |
class ArtistIn(BaseModel):
|
|
@@ -90,21 +128,6 @@ class ArtistOut(BaseModel):
|
|
| 90 |
top_tags: list[str]
|
| 91 |
|
| 92 |
|
| 93 |
-
_SEARCH_MODE_PRESETS: dict[str, dict[str, Any]] = {
|
| 94 |
-
"precise_lookup": {"top_k": 10, "limit": 10, "popularity_weight": 0.15, "use_segmentation": False, "group_mode": "off", "max_per_group": 2},
|
| 95 |
-
"concept_explore": {"top_k": 80, "limit": 80, "popularity_weight": 0.15, "use_segmentation": True, "group_mode": "expand", "max_per_group": 2},
|
| 96 |
-
"subject_describe": {"top_k": 20, "limit": 20, "popularity_weight": 0.15, "use_segmentation": False, "group_mode": "off", "max_per_group": 2},
|
| 97 |
-
"full_scene": {"top_k": 5, "limit": 80, "popularity_weight": 0.15, "use_segmentation": True, "group_mode": "diverse", "max_per_group": 2},
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
_CATEGORY_MAP: dict[str, list[str]] = {
|
| 101 |
-
"all": ["General", "Character", "Copyright", "Artist", "Meta"],
|
| 102 |
-
"general": ["General"],
|
| 103 |
-
"character": ["Character"],
|
| 104 |
-
"copyright": ["Copyright"],
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
|
| 108 |
async def _correct_tags(tagger: DanbooruTagger, tags: list[str]) -> tuple[list[str], list[str], dict[str, str]]:
|
| 109 |
valid_tags: list[str] = []
|
| 110 |
invalid_tags: list[str] = []
|
|
@@ -163,24 +186,12 @@ app = FastAPI(
|
|
| 163 |
|
| 164 |
# ── 端点 ──
|
| 165 |
|
| 166 |
-
@app.post("/search")
|
| 167 |
-
async def search(body: SearchIn) ->
|
| 168 |
tagger = await DanbooruTagger.get_instance()
|
| 169 |
|
| 170 |
# SearchIn → core.models.SearchRequest(两者字段一一对应,直接解包)
|
| 171 |
-
|
| 172 |
-
target_categories = _CATEGORY_MAP.get(body.category, _CATEGORY_MAP["all"])
|
| 173 |
-
request = SearchRequest(
|
| 174 |
-
query=body.query,
|
| 175 |
-
top_k=preset["top_k"],
|
| 176 |
-
limit=preset["limit"],
|
| 177 |
-
popularity_weight=preset["popularity_weight"],
|
| 178 |
-
show_nsfw=body.show_nsfw,
|
| 179 |
-
use_segmentation=preset["use_segmentation"],
|
| 180 |
-
target_categories=target_categories,
|
| 181 |
-
group_mode=preset["group_mode"],
|
| 182 |
-
max_per_group=preset["max_per_group"],
|
| 183 |
-
)
|
| 184 |
|
| 185 |
# 并发安全的异步 search(信号量串行化 + 线程池执行)
|
| 186 |
try:
|
|
@@ -193,27 +204,12 @@ async def search(body: SearchIn) -> dict[str, Any]:
|
|
| 193 |
await counter.increment_success()
|
| 194 |
await counter.increment_copy()
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
"cn_name": result.cn_name,
|
| 203 |
-
}
|
| 204 |
-
if body.include_wiki:
|
| 205 |
-
item["wiki"] = result.wiki
|
| 206 |
-
results.append(item)
|
| 207 |
-
|
| 208 |
-
payload: dict[str, Any] = {
|
| 209 |
-
"prompt": response.tags_sfw if not body.show_nsfw else response.tags_all,
|
| 210 |
-
"keywords": response.keywords,
|
| 211 |
-
"results": results,
|
| 212 |
-
}
|
| 213 |
-
han_chars = re.findall(r'[\u4e00-\u9fff]', body.query)
|
| 214 |
-
if body.query and len(han_chars) / len(body.query) < 0.5:
|
| 215 |
-
payload["hint"] = "检测到英文查询,该搜索引擎对中文查询优化更好,如果搜索结果不合预期,推荐用中文重试"
|
| 216 |
-
return payload
|
| 217 |
|
| 218 |
|
| 219 |
@app.post("/related")
|
|
@@ -250,8 +246,7 @@ async def related(body: RelatedIn) -> dict[str, Any]:
|
|
| 250 |
"cn_name": result.cn_name,
|
| 251 |
"sources": result.sources,
|
| 252 |
}
|
| 253 |
-
|
| 254 |
-
item["wiki"] = result.wiki
|
| 255 |
output.append(item)
|
| 256 |
|
| 257 |
return _with_corrections(output, corrections)
|
|
|
|
| 27 |
from __future__ import annotations
|
| 28 |
|
| 29 |
import asyncio
|
|
|
|
| 30 |
from typing import Any
|
| 31 |
+
from typing import Literal
|
| 32 |
from fastapi import FastAPI, HTTPException
|
| 33 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 34 |
|
| 35 |
from core.engine import DanbooruTagger
|
| 36 |
from core.models import SearchRequest, SearchResponse
|
|
|
|
| 39 |
|
| 40 |
# ── Pydantic I/O 模型(API 层专用,与 core.models 解耦)──
|
| 41 |
|
| 42 |
+
LayerName = Literal['英文', '中文扩展词', '释义', '中文核心词', 'artist']
|
| 43 |
+
CategoryName = Literal['General', 'Artist', 'Copyright', 'Character', 'Meta']
|
| 44 |
+
GroupMode = Literal['off', 'expand', 'diverse']
|
| 45 |
+
|
| 46 |
|
| 47 |
class SearchIn(BaseModel):
|
| 48 |
+
model_config = ConfigDict(
|
| 49 |
+
json_schema_extra={
|
| 50 |
+
"example": {
|
| 51 |
+
"query": "白色水手服的女孩",
|
| 52 |
+
"top_k": 5,
|
| 53 |
+
"limit": 80,
|
| 54 |
+
"popularity_weight": 0.15,
|
| 55 |
+
"show_nsfw": True,
|
| 56 |
+
"use_segmentation": True,
|
| 57 |
+
"target_layers": ['英文', '中文扩展词', '释义', '中文核心词', 'artist'],
|
| 58 |
+
"target_categories": ['General', 'Character', 'Copyright'],
|
| 59 |
+
"group_mode": "off",
|
| 60 |
+
"max_per_group": 2,
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
query: str
|
| 66 |
+
top_k: int = Field(5, ge=1, le=50)
|
| 67 |
+
limit: int = Field(80, ge=1, le=500)
|
| 68 |
+
popularity_weight: float = Field(0.15, ge=0.0, le=1.0)
|
| 69 |
show_nsfw: bool = True
|
| 70 |
+
use_segmentation: bool = True
|
| 71 |
+
target_layers: list[LayerName] = Field(
|
| 72 |
+
default_factory=lambda: ['英文', '中文扩展词', '释义', '中文核心词'],
|
| 73 |
+
description="匹配层;可显式加入 'artist' 以返回编辑距离<=1的画师标签行。",
|
| 74 |
+
)
|
| 75 |
+
target_categories: list[CategoryName] = Field(
|
| 76 |
+
default_factory=lambda: ['General', 'Character', 'Copyright'],
|
| 77 |
+
)
|
| 78 |
+
group_mode: GroupMode = "off"
|
| 79 |
+
max_per_group: int = 2
|
| 80 |
|
| 81 |
|
| 82 |
class TagOut(BaseModel):
|
| 83 |
tag: str
|
| 84 |
cn_name: str
|
| 85 |
+
category: str
|
| 86 |
+
nsfw: str
|
| 87 |
+
final_score: float
|
| 88 |
+
semantic_score: float
|
| 89 |
+
count: int
|
| 90 |
+
source: str
|
| 91 |
+
layer: str
|
| 92 |
wiki: str = ""
|
| 93 |
+
artist_top_tags: list[str] = Field(default_factory=list)
|
| 94 |
|
| 95 |
|
| 96 |
class RelatedIn(BaseModel):
|
| 97 |
tags: list[str]
|
| 98 |
limit: int = Field(50, ge=1, le=200)
|
| 99 |
show_nsfw: bool = True
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
class RelatedTagOut(BaseModel):
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
class SearchOut(BaseModel):
|
| 110 |
+
tags_all: str
|
| 111 |
+
tags_sfw: str
|
| 112 |
results: list[TagOut]
|
| 113 |
keywords: list[str]
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
class ArtistIn(BaseModel):
|
|
|
|
| 128 |
top_tags: list[str]
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
async def _correct_tags(tagger: DanbooruTagger, tags: list[str]) -> tuple[list[str], list[str], dict[str, str]]:
|
| 132 |
valid_tags: list[str] = []
|
| 133 |
invalid_tags: list[str] = []
|
|
|
|
| 186 |
|
| 187 |
# ── 端点 ──
|
| 188 |
|
| 189 |
+
@app.post("/search", response_model=SearchOut)
|
| 190 |
+
async def search(body: SearchIn) -> SearchOut:
|
| 191 |
tagger = await DanbooruTagger.get_instance()
|
| 192 |
|
| 193 |
# SearchIn → core.models.SearchRequest(两者字段一一对应,直接解包)
|
| 194 |
+
request = SearchRequest(**body.model_dump())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
# 并发安全的异步 search(信号量串行化 + 线程池执行)
|
| 197 |
try:
|
|
|
|
| 204 |
await counter.increment_success()
|
| 205 |
await counter.increment_copy()
|
| 206 |
|
| 207 |
+
return SearchOut(
|
| 208 |
+
tags_all=response.tags_all,
|
| 209 |
+
tags_sfw=response.tags_sfw,
|
| 210 |
+
results=[TagOut(**vars(result)) for result in response.results],
|
| 211 |
+
keywords=response.keywords,
|
| 212 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
|
| 215 |
@app.post("/related")
|
|
|
|
| 246 |
"cn_name": result.cn_name,
|
| 247 |
"sources": result.sources,
|
| 248 |
}
|
| 249 |
+
item["wiki"] = result.wiki
|
|
|
|
| 250 |
output.append(item)
|
| 251 |
|
| 252 |
return _with_corrections(output, corrections)
|
core/engine.py
CHANGED
|
@@ -396,6 +396,29 @@ class DanbooruTagger:
|
|
| 396 |
if cached is not None:
|
| 397 |
return cached
|
| 398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
if request.use_segmentation:
|
| 400 |
raw_kw, raw_segments = self._smart_split(request.query)
|
| 401 |
keywords = [w.strip() for w in raw_kw if w.strip() and w.strip() not in STOP_WORDS]
|
|
@@ -554,16 +577,8 @@ class DanbooruTagger:
|
|
| 554 |
if len(valid) < request.limit or r.tag in guaranteed_tags:
|
| 555 |
valid.append(r)
|
| 556 |
|
| 557 |
-
tags_all = ', '.join(r.tag for r in valid)
|
| 558 |
-
tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
|
| 559 |
cached_queries = [q for q, hit in zip(queries, hit_mask) if hit]
|
| 560 |
-
|
| 561 |
-
tags_all=tags_all, tags_sfw=tags_sfw,
|
| 562 |
-
results=valid, keywords=keywords, segments=extra_segments,
|
| 563 |
-
cached_queries=cached_queries,
|
| 564 |
-
)
|
| 565 |
-
self._search_cache.put(cache_key, response)
|
| 566 |
-
return response
|
| 567 |
|
| 568 |
# ── CPU 并发闸门(类级信号量,所有 CPU 密集型操作共享)───────────────
|
| 569 |
|
|
@@ -1339,6 +1354,68 @@ class DanbooruTagger:
|
|
| 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())
|
|
|
|
| 396 |
if cached is not None:
|
| 397 |
return cached
|
| 398 |
|
| 399 |
+
tag_results, keywords, extra_segments, cached_queries = self._search_tag_results(request)
|
| 400 |
+
artist_results = (
|
| 401 |
+
self.search_artist_rows(request.query, request.limit, show_nsfw=request.show_nsfw)
|
| 402 |
+
if "artist" in request.target_layers else []
|
| 403 |
+
)
|
| 404 |
+
if artist_results:
|
| 405 |
+
artist_tags = {r.tag for r in artist_results}
|
| 406 |
+
tag_results = [r for r in tag_results if r.tag not in artist_tags]
|
| 407 |
+
valid = artist_results + tag_results
|
| 408 |
+
|
| 409 |
+
tags_all = ', '.join(r.tag for r in valid)
|
| 410 |
+
tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
|
| 411 |
+
response = SearchResponse(
|
| 412 |
+
tags_all=tags_all, tags_sfw=tags_sfw,
|
| 413 |
+
results=valid, keywords=keywords, segments=extra_segments,
|
| 414 |
+
cached_queries=cached_queries,
|
| 415 |
+
)
|
| 416 |
+
self._search_cache.put(cache_key, response)
|
| 417 |
+
return response
|
| 418 |
+
|
| 419 |
+
def _search_tag_results(
|
| 420 |
+
self, request: SearchRequest,
|
| 421 |
+
) -> tuple[list[TagResult], list[str], list[str], list[str]]:
|
| 422 |
if request.use_segmentation:
|
| 423 |
raw_kw, raw_segments = self._smart_split(request.query)
|
| 424 |
keywords = [w.strip() for w in raw_kw if w.strip() and w.strip() not in STOP_WORDS]
|
|
|
|
| 577 |
if len(valid) < request.limit or r.tag in guaranteed_tags:
|
| 578 |
valid.append(r)
|
| 579 |
|
|
|
|
|
|
|
| 580 |
cached_queries = [q for q, hit in zip(queries, hit_mask) if hit]
|
| 581 |
+
return valid, keywords, extra_segments, cached_queries
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
|
| 583 |
# ── CPU 并发闸门(类级信号量,所有 CPU 密集型操作共享)───────────────
|
| 584 |
|
|
|
|
| 1354 |
def _compact_artist_key(name: str) -> str:
|
| 1355 |
return re.sub(r"[\W_]+", "", str(name or "").lower())
|
| 1356 |
|
| 1357 |
+
@staticmethod
|
| 1358 |
+
def _edit_distance_at_most_one(left: str, right: str) -> bool:
|
| 1359 |
+
if left == right:
|
| 1360 |
+
return True
|
| 1361 |
+
if abs(len(left) - len(right)) > 1:
|
| 1362 |
+
return False
|
| 1363 |
+
|
| 1364 |
+
if len(left) == len(right):
|
| 1365 |
+
mismatches = 0
|
| 1366 |
+
for a, b in zip(left, right):
|
| 1367 |
+
if a != b:
|
| 1368 |
+
mismatches += 1
|
| 1369 |
+
if mismatches > 1:
|
| 1370 |
+
return False
|
| 1371 |
+
return True
|
| 1372 |
+
|
| 1373 |
+
short, long = (left, right) if len(left) < len(right) else (right, left)
|
| 1374 |
+
i = j = edits = 0
|
| 1375 |
+
while i < len(short) and j < len(long):
|
| 1376 |
+
if short[i] == long[j]:
|
| 1377 |
+
i += 1
|
| 1378 |
+
j += 1
|
| 1379 |
+
continue
|
| 1380 |
+
edits += 1
|
| 1381 |
+
if edits > 1:
|
| 1382 |
+
return False
|
| 1383 |
+
j += 1
|
| 1384 |
+
return True
|
| 1385 |
+
|
| 1386 |
+
def search_artist_rows(
|
| 1387 |
+
self, query: str, limit: int = 20, show_nsfw: bool = True,
|
| 1388 |
+
) -> list[TagResult]:
|
| 1389 |
+
"""Return artist rows whose normalized name is within edit distance 1."""
|
| 1390 |
+
normalized = self._normalize_artist_name(query)
|
| 1391 |
+
compact_query = self._compact_artist_key(normalized)
|
| 1392 |
+
if not compact_query:
|
| 1393 |
+
return []
|
| 1394 |
+
|
| 1395 |
+
matches: list[TagResult] = []
|
| 1396 |
+
for artist in sorted(self._artist_top_tags.keys()):
|
| 1397 |
+
if not self._edit_distance_at_most_one(compact_query, self._compact_artist_key(artist)):
|
| 1398 |
+
continue
|
| 1399 |
+
top_tags = self.get_artist_top_tags(
|
| 1400 |
+
[artist], top_n=10, show_nsfw=show_nsfw,
|
| 1401 |
+
).get(artist, [])
|
| 1402 |
+
matches.append(TagResult(
|
| 1403 |
+
tag=artist,
|
| 1404 |
+
cn_name="画师标签",
|
| 1405 |
+
category="Artist",
|
| 1406 |
+
nsfw="0",
|
| 1407 |
+
final_score=1.0,
|
| 1408 |
+
semantic_score=1.0,
|
| 1409 |
+
count=int(self._artist_post_count.get(artist, 0)),
|
| 1410 |
+
source=query,
|
| 1411 |
+
layer="artist",
|
| 1412 |
+
wiki="",
|
| 1413 |
+
artist_top_tags=top_tags,
|
| 1414 |
+
))
|
| 1415 |
+
|
| 1416 |
+
matches.sort(key=lambda r: (r.count, r.tag), reverse=True)
|
| 1417 |
+
return matches[:limit]
|
| 1418 |
+
|
| 1419 |
def resolve_artist_name(self, artist_name: str) -> dict[str, Any]:
|
| 1420 |
"""Resolve a user-entered artist name to the artist co-occurrence index."""
|
| 1421 |
artists = set(self._artist_top_tags.keys())
|
core/models.py
CHANGED
|
@@ -18,6 +18,7 @@ class TagResult:
|
|
| 18 |
source: str
|
| 19 |
layer: str
|
| 20 |
wiki: str = ""
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
@dataclass
|
|
@@ -72,4 +73,4 @@ class SearchResponse:
|
|
| 72 |
results: list[TagResult]
|
| 73 |
keywords: list[str]
|
| 74 |
segments: list[str] = field(default_factory=list) # 分隔符切分后的原始从句级片段
|
| 75 |
-
cached_queries: list[str] = field(default_factory=list) # 命中 emb 缓存的查询文本
|
|
|
|
| 18 |
source: str
|
| 19 |
layer: str
|
| 20 |
wiki: str = ""
|
| 21 |
+
artist_top_tags: list[str] = field(default_factory=list)
|
| 22 |
|
| 23 |
|
| 24 |
@dataclass
|
|
|
|
| 73 |
results: list[TagResult]
|
| 74 |
keywords: list[str]
|
| 75 |
segments: list[str] = field(default_factory=list) # 分隔符切分后的原始从句级片段
|
| 76 |
+
cached_queries: list[str] = field(default_factory=list) # 命中 emb 缓存的查询文本
|
ui_nicegui.py
CHANGED
|
@@ -207,7 +207,7 @@ class DanbooruSearchUI:
|
|
| 207 |
self.spinner = None
|
| 208 |
self.search_btn = None
|
| 209 |
|
| 210 |
-
self.selected_layers = {'英文': True, '中文扩展词': True, '释义': True, '中文核心词': True}
|
| 211 |
self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
|
| 212 |
|
| 213 |
self.bad_case_btn = None
|
|
@@ -229,6 +229,8 @@ class DanbooruSearchUI:
|
|
| 229 |
self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
|
| 230 |
# 当前推荐画师的标签名集合(用于 Anima 模式复制时加 @ 前缀)
|
| 231 |
self._current_artist_rec_tags: set[str] = set()
|
|
|
|
|
|
|
| 232 |
|
| 233 |
# 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
|
| 234 |
self._layer_checkboxes: dict[str, ui.checkbox] = {}
|
|
@@ -739,8 +741,9 @@ class DanbooruSearchUI:
|
|
| 739 |
display_map = {
|
| 740 |
'英文': '英文标签', '中文扩展词': '中文扩展词',
|
| 741 |
'释义': '维基释义', '中文核心词': '中文核心词',
|
|
|
|
| 742 |
}
|
| 743 |
-
for layer in ['英文', '中文扩展词', '释义', '中文核心词']:
|
| 744 |
cb = ui.checkbox(
|
| 745 |
display_map.get(layer, layer), value=True,
|
| 746 |
on_change=lambda e, l=layer: self.selected_layers.__setitem__(l, e.value)
|
|
@@ -981,6 +984,8 @@ class DanbooruSearchUI:
|
|
| 981 |
self.result_table.selected = []
|
| 982 |
self._artist_rec_checkboxes.clear()
|
| 983 |
self._current_artist_rec_tags.clear()
|
|
|
|
|
|
|
| 984 |
self._render_selected_chips()
|
| 985 |
if self.selection_count_label is not None:
|
| 986 |
self.selection_count_label.text = '0'
|
|
@@ -1025,6 +1030,7 @@ class DanbooruSearchUI:
|
|
| 1025 |
:class="props.row._nsfw_blocked ? 'nsfw-row-blocked' : ''"
|
| 1026 |
:style="{
|
| 1027 |
'background-color':
|
|
|
|
| 1028 |
props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
|
| 1029 |
props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
|
| 1030 |
props.row.category === 'Copyright' ? 'rgba(168,85,247,0.06)' : ''
|
|
@@ -1036,7 +1042,7 @@ class DanbooruSearchUI:
|
|
| 1036 |
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
| 1037 |
<template v-if="col.name === 'tag' || col.name === 'cn_name'">
|
| 1038 |
<div :class="props.row._nsfw_blocked ? 'nsfw-blur-cell' : ''">
|
| 1039 |
-
<template v-if="col.name === 'cn_name' && col.value">
|
| 1040 |
<span style="font-size:14px;display:inline-flex;align-items:center;gap:4px;">
|
| 1041 |
<span>{{ col.value.split(',')[0] }}</span>
|
| 1042 |
<q-btn icon="report_problem"
|
|
@@ -1073,7 +1079,17 @@ class DanbooruSearchUI:
|
|
| 1073 |
</template>
|
| 1074 |
<template v-else>{{ col.value }}</template>
|
| 1075 |
</q-td>
|
| 1076 |
-
<q-tooltip v-if="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
content-class="bg-black text-white shadow-4"
|
| 1078 |
max-width="500px" :offset="[10,10]">
|
| 1079 |
<div style="font-size:14px;line-height:1.5;">
|
|
@@ -1353,6 +1369,7 @@ class DanbooruSearchUI:
|
|
| 1353 |
return
|
| 1354 |
|
| 1355 |
table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
|
|
|
|
| 1356 |
self.full_table_data = table_data
|
| 1357 |
self.full_tags_str = response.tags_all
|
| 1358 |
self.full_tags_str_sfw = response.tags_sfw
|
|
@@ -1372,6 +1389,7 @@ class DanbooruSearchUI:
|
|
| 1372 |
self._save_staged_tags()
|
| 1373 |
|
| 1374 |
self._refresh_related([], show_nsfw_val)
|
|
|
|
| 1375 |
|
| 1376 |
# 分词筛选 chips
|
| 1377 |
self.current_filter_keyword = 'ALL'
|
|
@@ -1436,6 +1454,26 @@ class DanbooruSearchUI:
|
|
| 1436 |
extra = [t for t in self.chip_extra_selected if t not in seen]
|
| 1437 |
return table_tags + extra
|
| 1438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1439 |
def _set_selected_tags(self, tags: list[str], skip_refresh: bool = False):
|
| 1440 |
tag_set = set(tags)
|
| 1441 |
table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set()
|
|
@@ -1463,9 +1501,7 @@ class DanbooruSearchUI:
|
|
| 1463 |
# 从关联推荐/同类标签勾选时跳过,由各自动态刷新或手动按钮触发。
|
| 1464 |
if not skip_refresh:
|
| 1465 |
show_nsfw_val = self.input_nsfw.value
|
| 1466 |
-
self.
|
| 1467 |
-
self._refresh_group_from_selection(all_tags, show_nsfw_val)
|
| 1468 |
-
self._refresh_artist_from_selection(all_tags, show_nsfw_val)
|
| 1469 |
if not all_tags:
|
| 1470 |
self.chip_extra_selected.clear()
|
| 1471 |
|
|
@@ -1493,9 +1529,7 @@ class DanbooruSearchUI:
|
|
| 1493 |
cb.set_value(t in tag_set)
|
| 1494 |
|
| 1495 |
show_nsfw_val = self.input_nsfw.value
|
| 1496 |
-
self.
|
| 1497 |
-
self._refresh_group_from_selection(all_tags, show_nsfw_val)
|
| 1498 |
-
self._refresh_artist_from_selection(all_tags, show_nsfw_val)
|
| 1499 |
if not all_tags:
|
| 1500 |
self.chip_extra_selected.clear()
|
| 1501 |
self._save_staged_tags()
|
|
@@ -1605,6 +1639,7 @@ class DanbooruSearchUI:
|
|
| 1605 |
|
| 1606 |
def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1607 |
"""仅刷新关联推荐列表(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
|
|
|
|
| 1608 |
# 取消上次未执行的刷新
|
| 1609 |
if self._debounce_related_task and not self._debounce_related_task.done():
|
| 1610 |
self._debounce_related_task.cancel()
|
|
@@ -1625,6 +1660,7 @@ class DanbooruSearchUI:
|
|
| 1625 |
|
| 1626 |
def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1627 |
"""仅刷新同类扩展区域(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
|
|
|
|
| 1628 |
if self._debounce_group_task and not self._debounce_group_task.done():
|
| 1629 |
self._debounce_group_task.cancel()
|
| 1630 |
async def _do():
|
|
@@ -1645,6 +1681,7 @@ class DanbooruSearchUI:
|
|
| 1645 |
|
| 1646 |
def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
|
| 1647 |
"""根据已选标签刷新画师推荐(300ms 去抖)。"""
|
|
|
|
| 1648 |
if self._debounce_artist_task and not self._debounce_artist_task.done():
|
| 1649 |
self._debounce_artist_task.cancel()
|
| 1650 |
async def _do():
|
|
@@ -1888,9 +1925,10 @@ class DanbooruSearchUI:
|
|
| 1888 |
self._mark_interaction()
|
| 1889 |
tags = self._get_selected_tags()
|
| 1890 |
parts = []
|
|
|
|
| 1891 |
for t in tags:
|
| 1892 |
w = self.tag_weights.get(t, 1.0)
|
| 1893 |
-
if self.prompt_format == 'anima' and t in
|
| 1894 |
parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
|
| 1895 |
else:
|
| 1896 |
parts.append(_format_tag_with_weight(t, w, self.prompt_format))
|
|
|
|
| 207 |
self.spinner = None
|
| 208 |
self.search_btn = None
|
| 209 |
|
| 210 |
+
self.selected_layers = {'英文': True, '中文扩展词': True, '释义': True, '中文核心词': True, 'artist': True}
|
| 211 |
self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
|
| 212 |
|
| 213 |
self.bad_case_btn = None
|
|
|
|
| 229 |
self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
|
| 230 |
# 当前推荐画师的标签名集合(用于 Anima 模式复制时加 @ 前缀)
|
| 231 |
self._current_artist_rec_tags: set[str] = set()
|
| 232 |
+
self._artist_result_tags: set[str] = set()
|
| 233 |
+
self._last_recommendation_seed_tags: list[str] = []
|
| 234 |
|
| 235 |
# 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
|
| 236 |
self._layer_checkboxes: dict[str, ui.checkbox] = {}
|
|
|
|
| 741 |
display_map = {
|
| 742 |
'英文': '英文标签', '中文扩展词': '中文扩展词',
|
| 743 |
'释义': '维基释义', '中文核心词': '中文核心词',
|
| 744 |
+
'artist': 'artist',
|
| 745 |
}
|
| 746 |
+
for layer in ['英文', '中文扩展词', '释义', '中文核心词', 'artist']:
|
| 747 |
cb = ui.checkbox(
|
| 748 |
display_map.get(layer, layer), value=True,
|
| 749 |
on_change=lambda e, l=layer: self.selected_layers.__setitem__(l, e.value)
|
|
|
|
| 984 |
self.result_table.selected = []
|
| 985 |
self._artist_rec_checkboxes.clear()
|
| 986 |
self._current_artist_rec_tags.clear()
|
| 987 |
+
self._artist_result_tags.clear()
|
| 988 |
+
self._last_recommendation_seed_tags = []
|
| 989 |
self._render_selected_chips()
|
| 990 |
if self.selection_count_label is not None:
|
| 991 |
self.selection_count_label.text = '0'
|
|
|
|
| 1030 |
:class="props.row._nsfw_blocked ? 'nsfw-row-blocked' : ''"
|
| 1031 |
:style="{
|
| 1032 |
'background-color':
|
| 1033 |
+
props.row.layer === 'artist' ? 'rgba(244,114,182,0.08)' :
|
| 1034 |
props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
|
| 1035 |
props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
|
| 1036 |
props.row.category === 'Copyright' ? 'rgba(168,85,247,0.06)' : ''
|
|
|
|
| 1042 |
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
| 1043 |
<template v-if="col.name === 'tag' || col.name === 'cn_name'">
|
| 1044 |
<div :class="props.row._nsfw_blocked ? 'nsfw-blur-cell' : ''">
|
| 1045 |
+
<template v-if="col.name === 'cn_name' && col.value && props.row.layer !== 'artist'">
|
| 1046 |
<span style="font-size:14px;display:inline-flex;align-items:center;gap:4px;">
|
| 1047 |
<span>{{ col.value.split(',')[0] }}</span>
|
| 1048 |
<q-btn icon="report_problem"
|
|
|
|
| 1079 |
</template>
|
| 1080 |
<template v-else>{{ col.value }}</template>
|
| 1081 |
</q-td>
|
| 1082 |
+
<q-tooltip v-if="props.row.layer === 'artist' && props.row.artist_top_tags && props.row.artist_top_tags.length && !props.row._nsfw_blocked"
|
| 1083 |
+
content-class="bg-black text-white shadow-4"
|
| 1084 |
+
max-width="400px" :offset="[10,10]">
|
| 1085 |
+
<div style="font-size:14px;line-height:1.5;max-width:380px;">
|
| 1086 |
+
<b>{{ props.row.tag }}</b><br>这位画师经常画:<br>
|
| 1087 |
+
<template v-for="tag in props.row.artist_top_tags.slice(0, 10)" :key="tag">
|
| 1088 |
+
· {{ tag }}<br>
|
| 1089 |
+
</template>
|
| 1090 |
+
</div>
|
| 1091 |
+
</q-tooltip>
|
| 1092 |
+
<q-tooltip v-else-if="(props.row.wiki || props.row.cn_name) && !props.row._nsfw_blocked"
|
| 1093 |
content-class="bg-black text-white shadow-4"
|
| 1094 |
max-width="500px" :offset="[10,10]">
|
| 1095 |
<div style="font-size:14px;line-height:1.5;">
|
|
|
|
| 1369 |
return
|
| 1370 |
|
| 1371 |
table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
|
| 1372 |
+
self._artist_result_tags = {row['tag'] for row in table_data if row.get('layer') == 'artist'}
|
| 1373 |
self.full_table_data = table_data
|
| 1374 |
self.full_tags_str = response.tags_all
|
| 1375 |
self.full_tags_str_sfw = response.tags_sfw
|
|
|
|
| 1389 |
self._save_staged_tags()
|
| 1390 |
|
| 1391 |
self._refresh_related([], show_nsfw_val)
|
| 1392 |
+
self._last_recommendation_seed_tags = []
|
| 1393 |
|
| 1394 |
# 分词筛选 chips
|
| 1395 |
self.current_filter_keyword = 'ALL'
|
|
|
|
| 1454 |
extra = [t for t in self.chip_extra_selected if t not in seen]
|
| 1455 |
return table_tags + extra
|
| 1456 |
|
| 1457 |
+
def _get_recommendation_seed_tags(self, selected_tags: list[str]) -> list[str]:
|
| 1458 |
+
artist_tags = set(self._current_artist_rec_tags) | set(self._artist_result_tags)
|
| 1459 |
+
if self.result_table is not None:
|
| 1460 |
+
for row in self.result_table.rows:
|
| 1461 |
+
if row.get('layer') != 'artist':
|
| 1462 |
+
continue
|
| 1463 |
+
tag = row.get('tag')
|
| 1464 |
+
if tag:
|
| 1465 |
+
artist_tags.add(tag)
|
| 1466 |
+
return [tag for tag in selected_tags if tag not in artist_tags]
|
| 1467 |
+
|
| 1468 |
+
def _refresh_recommendations_if_seed_changed(self, selected_tags: list[str], show_nsfw: bool):
|
| 1469 |
+
seed_tags = self._get_recommendation_seed_tags(selected_tags)
|
| 1470 |
+
if seed_tags == self._last_recommendation_seed_tags:
|
| 1471 |
+
return
|
| 1472 |
+
self._last_recommendation_seed_tags = list(seed_tags)
|
| 1473 |
+
self._refresh_related_from_selection(seed_tags, show_nsfw)
|
| 1474 |
+
self._refresh_group_from_selection(seed_tags, show_nsfw)
|
| 1475 |
+
self._refresh_artist_from_selection(seed_tags, show_nsfw)
|
| 1476 |
+
|
| 1477 |
def _set_selected_tags(self, tags: list[str], skip_refresh: bool = False):
|
| 1478 |
tag_set = set(tags)
|
| 1479 |
table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set()
|
|
|
|
| 1501 |
# 从关联推荐/同类标签勾选时跳过,由各自动态刷新或手动按钮触发。
|
| 1502 |
if not skip_refresh:
|
| 1503 |
show_nsfw_val = self.input_nsfw.value
|
| 1504 |
+
self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
|
|
|
|
|
|
|
| 1505 |
if not all_tags:
|
| 1506 |
self.chip_extra_selected.clear()
|
| 1507 |
|
|
|
|
| 1529 |
cb.set_value(t in tag_set)
|
| 1530 |
|
| 1531 |
show_nsfw_val = self.input_nsfw.value
|
| 1532 |
+
self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
|
|
|
|
|
|
|
| 1533 |
if not all_tags:
|
| 1534 |
self.chip_extra_selected.clear()
|
| 1535 |
self._save_staged_tags()
|
|
|
|
| 1639 |
|
| 1640 |
def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1641 |
"""仅刷新关联推荐列表(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
|
| 1642 |
+
selected_tags = self._get_recommendation_seed_tags(selected_tags)
|
| 1643 |
# 取消上次未执行的刷新
|
| 1644 |
if self._debounce_related_task and not self._debounce_related_task.done():
|
| 1645 |
self._debounce_related_task.cancel()
|
|
|
|
| 1660 |
|
| 1661 |
def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1662 |
"""仅刷新同类扩展区域(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
|
| 1663 |
+
selected_tags = self._get_recommendation_seed_tags(selected_tags)
|
| 1664 |
if self._debounce_group_task and not self._debounce_group_task.done():
|
| 1665 |
self._debounce_group_task.cancel()
|
| 1666 |
async def _do():
|
|
|
|
| 1681 |
|
| 1682 |
def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
|
| 1683 |
"""根据已选标签刷新画师推荐(300ms 去抖)。"""
|
| 1684 |
+
selected_tags = self._get_recommendation_seed_tags(selected_tags)
|
| 1685 |
if self._debounce_artist_task and not self._debounce_artist_task.done():
|
| 1686 |
self._debounce_artist_task.cancel()
|
| 1687 |
async def _do():
|
|
|
|
| 1925 |
self._mark_interaction()
|
| 1926 |
tags = self._get_selected_tags()
|
| 1927 |
parts = []
|
| 1928 |
+
artist_tags = set(self._current_artist_rec_tags) | set(self._artist_result_tags)
|
| 1929 |
for t in tags:
|
| 1930 |
w = self.tag_weights.get(t, 1.0)
|
| 1931 |
+
if self.prompt_format == 'anima' and t in artist_tags:
|
| 1932 |
parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
|
| 1933 |
else:
|
| 1934 |
parts.append(_format_tag_with_weight(t, w, self.prompt_format))
|