File size: 14,273 Bytes
e6404d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
from src.prompt_parser import parse_prompt, QUALITY_TAGS
from src.tag_warehouse import TagWarehouse
from src.variation_engine import _find_conflict_group
from src.semantic_coherence import detect_intent, _THEME_GROUPS, _INTENT_ALIGNMENT, _INTENT_OPPOSITION
from src.i18n import t
import html


CLIP_TOKEN_ESTIMATE = 75
T5_TOKEN_ESTIMATE = 256


def _estimate_clip_tokens(text: str) -> int:
    """CLIP BPE estimate: roughly 1 token per 4 latin chars + separators.

    Comma-separated tags each carry an overhead (separator + start/end churn),
    so count tags explicitly on top of character volume.
    """
    tags = [t for t in text.split(",") if t.strip()]
    char_tokens = max(0, len(text) // 4)
    return max(1, tags.__len__() + char_tokens // 2)


def _estimate_t5_tokens(text: str) -> int:
    """T5 sentencepiece estimate: roughly 1 token per 3.5 chars."""
    return max(1, int(len(text) / 3.5))


def _estimate_tokens(text: str) -> int:
    """Backwards-compatible alias kept for callers that expect one number."""
    return _estimate_clip_tokens(text)


def _quality_status(quality_tags: list[str]) -> tuple[str, str]:
    has_positive = any(t in quality_tags for t in
        ["masterpiece", "best quality", "high quality", "good quality"])
    has_negative = any(t in quality_tags for t in
        ["low quality", "worst quality", "normal quality"])
    positive_list = [t for t in quality_tags if t in {
        "masterpiece", "best quality", "high quality", "good quality",
        "score_9", "score_8", "score_7", "score_6", "score_5"}]
    if not quality_tags:
        return "missing", ""
    if has_positive and has_negative:
        return "conflict", ", ".join(positive_list[:3])
    if not has_positive:
        return "low", ""
    return "good", ", ".join(positive_list[:3])


def _find_internal_conflicts(general_tags: list[str]) -> list[tuple[str, str]]:
    conflicts = []
    for i, t1 in enumerate(general_tags):
        group = _find_conflict_group(t1)
        if group is None:
            continue
        for t2 in general_tags[i + 1:]:
            if t2.lower() in group:
                conflicts.append((t1, t2))
    return conflicts


def _find_category_overload_warnings(tags: list[str]) -> list[str]:
    from src.prompt_rewriter import get_tag_categories
    cat_counts: dict[str, int] = {}
    for tag in tags:
        for cat in get_tag_categories(tag):
            cat_counts[cat] = cat_counts.get(cat, 0) + 1
    return [f"{cat}: {count}" for cat, count in sorted(cat_counts.items(), key=lambda x: -x[1]) if count > 3]


def _find_theme_overload_warnings(tags: list[str]) -> list[str]:
    from src.prompt_rewriter import get_tag_categories
    theme_counts: dict[str, int] = {}
    cat_to_theme: dict[str, str] = {}
    for theme, cats in _THEME_GROUPS.items():
        for cat in cats:
            cat_to_theme[cat] = theme
    for tag in tags:
        for cat in get_tag_categories(tag):
            theme = cat_to_theme.get(cat, "misc")
            theme_counts[theme] = theme_counts.get(theme, 0) + 1
    return [f"{theme}: {count}" for theme, count in sorted(theme_counts.items(), key=lambda x: -x[1]) if count > 5]


def _intent_mismatch_suggestions(tags: list[str]) -> list[str]:
    from src.prompt_rewriter import get_tag_categories
    if not tags:
        return []
    suggestions = []
    dummy_parsed = type("obj", (object,), {"subject": "", "general_tags": tags, "character": ""})()
    intent = detect_intent(dummy_parsed)
    if intent == "general":
        return []
    opposed = _INTENT_OPPOSITION.get(intent, set())
    for tag in tags:
        tl = tag.lower().strip()
        for cat in get_tag_categories(tl):
            if cat in opposed:
                suggestions.append(f"{tag} ({intent} intent)")
                break
    return suggestions[:3]


def _core_decorative_ratio(tags: list[str]) -> str | None:
    from src.semantic_coherence import split_core_decorative
    core, deco = split_core_decorative(tags)
    total = len(core) + len(deco)
    if total == 0:
        return None
    ratio = len(core) / total
    if ratio < 0.2:
        return "low_core"
    if ratio > 0.8:
        return "high_core"
    return None


def _detect_duplicates(tags: list[str]) -> list[str]:
    seen = {}
    dups = []
    for t in tags:
        k = t.lower().strip()
        if k in seen:
            dups.append(t)
        seen[k] = t
    return dups


def analyze_prompt(raw: str, warehouse: TagWarehouse) -> dict:
    result = {
        "raw": raw,
        "parsed": None,
        "error": None,
        "structure": {},
        "token_estimate": {},
        "quality": {},
        "conflicts": [],
        "duplicates": [],
        "recommendations": [],
    }

    if not raw or not raw.strip():
        result["error"] = "empty"
        return result

    parsed = parse_prompt(raw)
    if parsed is None:
        result["error"] = "parse"
        return result

    result["parsed"] = parsed

    structure = {
        "subject": parsed.subject or "β€”",
        "character": parsed.character or "β€”",
        "series": parsed.series or "β€”",
        "artists": parsed.artists,
        "n_language": bool(parsed.nl_text),
        "num_quality": len(parsed.quality_tags),
        "num_meta": len(parsed.meta_tags),
        "num_general": len(parsed.general_tags),
        "num_artists": len(parsed.artists),
        "total_tags": len(parsed.quality_tags) + len(parsed.meta_tags)
                      + len(parsed.general_tags) + len(parsed.artists)
                      + (1 if parsed.subject else 0)
                      + (1 if parsed.character else 0)
                      + (1 if parsed.series else 0),
        "has_safety": bool(parsed.safety_tag),
        "has_year": bool(parsed.year_tag),
    }
    result["structure"] = structure

    tokens = {
        "clip": _estimate_clip_tokens(raw),
        "t5": _estimate_t5_tokens(raw),
        "clip_warning": _estimate_clip_tokens(raw) > CLIP_TOKEN_ESTIMATE,
        "t5_warning": _estimate_t5_tokens(raw) > T5_TOKEN_ESTIMATE,
    }
    result["token_estimate"] = tokens

    q_status, q_examples = _quality_status(parsed.quality_tags)
    result["quality"] = {"status": q_status, "examples": q_examples}

    all_tags = (parsed.quality_tags + parsed.meta_tags + parsed.general_tags)
    result["conflicts"] = _find_internal_conflicts(all_tags)
    result["duplicates"] = _detect_duplicates(all_tags)
    result["category_overload"] = _find_category_overload_warnings(all_tags)
    result["theme_overload"] = _find_theme_overload_warnings(all_tags)
    result["intent_mismatches"] = _intent_mismatch_suggestions(parsed.general_tags)
    result["core_ratio"] = _core_decorative_ratio(parsed.general_tags)

    recs = []
    if not parsed.subject:
        recs.append("missing_subject")
    if not parsed.has_booru_structure and not parsed.nl_text:
        recs.append("unrecognized")
    elif parsed.nl_text and not parsed.has_booru_structure:
        recs.append("nl_only")
    if q_status == "missing":
        recs.append("quality_missing")
    elif q_status == "conflict":
        recs.append("quality_conflict")
    elif q_status == "low":
        recs.append("quality_low")
    if not parsed.safety_tag:
        recs.append("safety_missing")
    if not parsed.artists and parsed.has_booru_structure:
        recs.append("artist_missing")
    if tokens["clip_warning"]:
        recs.append("token_clip")
    if tokens["t5_warning"]:
        recs.append("token_t5")
    if result["conflicts"]:
        recs.append("tag_conflicts")
    if result["duplicates"]:
        recs.append("duplicate_tags")
    if structure["num_general"] > 20:
        recs.append("too_many_tags")
    if not parsed.character and not parsed.series and parsed.has_booru_structure:
        recs.append("character_series_missing")
    if len(parsed.artists) > 3:
        recs.append("too_many_artists")
    if result.get("theme_overload"):
        recs.append("theme_overload")
    if result.get("intent_mismatches"):
        recs.append("intent_mismatches")
    if result.get("core_ratio") == "low_core":
        recs.append("low_core_ratio")
    elif result.get("core_ratio") == "high_core":
        recs.append("high_core_ratio")
    result["recommendations"] = recs

    return result


def format_analysis_html(data: dict, lang: str) -> str:
    if data.get("error") == "empty":
        return ""

    if data.get("error") == "parse":
        msg = t("analyzer_parse_error", lang)
        return f"""
        <div style="background:rgba(15,23,42,0.5);border:1px solid rgba(239,68,68,0.25);border-radius:10px;padding:10px 14px;margin-top:4px;">
            <div style="color:#F87171;font-size:12px;">⚠ {msg}</div>
        </div>
        """

    s = data["structure"]
    q = data["quality"]
    tok = data["token_estimate"]
    recs = data["recommendations"]
    conflicts = data["conflicts"]
    dups = data["duplicates"]
    cat_overload = data.get("category_overload", [])
    theme_overload = data.get("theme_overload", [])
    intent_mismatches = data.get("intent_mismatches", [])
    core_ratio = data.get("core_ratio")

    quality_label = {
        "good": t("analyzer_status_good", lang),
        "missing": t("analyzer_status_missing", lang),
        "conflict": t("analyzer_status_conflict", lang),
        "low": t("analyzer_status_low", lang),
    }.get(q["status"], q["status"])

    lines = []

    lines.append("<div style='background:rgba(15,23,42,0.5);border:1px solid rgba(56,189,248,0.12);border-radius:10px;padding:10px 14px;margin-top:4px;'>")

    lines.append(f"<div style='display:flex;gap:12px;flex-wrap:wrap;font-size:11px;color:#94A3B8;margin-bottom:6px;'>")
    lines.append(f"<span><strong style='color:#E2E8F0;'>{t('analyzer_subject', lang)}:</strong> {html.escape(s['subject'])}</span>")
    lines.append(f"<span><strong style='color:#E2E8F0;'>{t('analyzer_character', lang)}:</strong> {html.escape(s['character'])}</span>")
    lines.append(f"<span><strong style='color:#E2E8F0;'>{t('analyzer_series', lang)}:</strong> {html.escape(s['series'])}</span>")
    if s['artists']:
        lines.append(f"<span><strong style='color:#E2E8F0;'>{t('analyzer_artists', lang)}:</strong> {html.escape(', '.join(s['artists']))}</span>")
    lines.append("</div>")

    bar_color = {"good": "#34D399", "missing": "#F87171", "conflict": "#FBBF24", "low": "#FBBF24"}.get(q["status"], "#94A3B8")

    lines.append(f"<div style='display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:#94A3B8;margin-bottom:6px;'>")
    lines.append(f"<span><strong style='color:#E2E8F0;'>{t('analyzer_tags', lang)}:</strong> {s['total_tags']} ({t('analyzer_quality', lang)}: {s['num_quality']}, {t('analyzer_meta', lang)}: {s['num_meta']}, {t('analyzer_general', lang)}: {s['num_general']})</span>")
    lines.append(f"<span><strong style='color:#E2E8F0;'>{t('analyzer_quality', lang)}:</strong> <span style='color:{bar_color};'>{quality_label}</span></span>")
    lines.append(f"<span><strong style='color:#E2E8F0;'>{t('analyzer_tokens', lang)}:</strong> β‰ˆ{tok['clip']} CLIP {'⚠️' if tok['clip_warning'] else ''} / β‰ˆ{tok['t5']} T5 {'⚠️' if tok['t5_warning'] else ''}</span>")
    if s['n_language']:
        lines.append(f"<span style='color:#818CF8;'>{t('analyzer_natural_lang', lang)}</span>")
    lines.append("</div>")

    if conflicts or dups or recs:
        lines.append("<div style='border-top:1px solid rgba(56,189,248,0.08);margin-top:6px;padding-top:6px;'>")
        for c1, c2 in conflicts[:3]:
            lines.append(f"<div style='font-size:11px;color:#FBBF24;'>⚠ <strong>{html.escape(c1)}</strong> ↔ <strong>{html.escape(c2)}</strong> {t('analyzer_conflict', lang)}</div>")
        for d in dups[:3]:
            lines.append(f"<div style='font-size:11px;color:#FBBF24;'>⚠ <strong>{html.escape(d)}</strong> {t('analyzer_duplicate', lang)}</div>")
        for overload in cat_overload[:3]:
            lines.append(f"<div style='font-size:11px;color:#FBBF24;'>πŸ“Š {html.escape(overload)} {t('analyzer_category_overload', lang)}</div>")
        for overload in theme_overload[:3]:
            lines.append(f"<div style='font-size:11px;color:#FBBF24;'>πŸ“Š {html.escape(overload)} {t('analyzer_theme_overload', lang)}</div>")
        for m in intent_mismatches[:2]:
            lines.append(f"<div style='font-size:11px;color:#FBBF24;'>🎯 {html.escape(m)} {t('analyzer_intent_mismatch', lang)}</div>")
        if core_ratio == "low_core":
            lines.append(f"<div style='font-size:11px;color:#38BDF8;'>πŸ’‘ {t('rec_low_core_ratio', lang)}</div>")
        elif core_ratio == "high_core":
            lines.append(f"<div style='font-size:11px;color:#38BDF8;'>πŸ’‘ {t('rec_high_core_ratio', lang)}</div>")

        rec_labels = {
            "missing_subject": t("rec_missing_subject", lang),
            "unrecognized": t("rec_unrecognized", lang),
            "nl_only": t("rec_nl_only", lang),
            "quality_missing": t("rec_quality_missing", lang),
            "quality_conflict": t("rec_quality_conflict", lang),
            "quality_low": t("rec_quality_low", lang),
            "safety_missing": t("rec_safety_missing", lang),
            "artist_missing": t("rec_artist_missing", lang),
            "token_clip": t("rec_token_clip", lang).format(n=CLIP_TOKEN_ESTIMATE),
            "token_t5": t("rec_token_t5", lang).format(n=T5_TOKEN_ESTIMATE),
            "tag_conflicts": t("rec_tag_conflicts", lang),
            "duplicate_tags": t("rec_duplicate_tags", lang),
            "too_many_tags": t("rec_too_many_tags", lang),
            "character_series_missing": t("rec_character_series_missing", lang),
            "too_many_artists": t("rec_too_many_artists", lang),
            "theme_overload": t("rec_theme_overload", lang),
            "intent_mismatches": t("rec_intent_mismatches", lang),
            "low_core_ratio": t("rec_low_core_ratio", lang),
            "high_core_ratio": t("rec_high_core_ratio", lang),
        }
        for r in recs[:5]:
            label = rec_labels.get(r, r)
            lines.append(f"<div style='font-size:11px;color:#38BDF8;margin-top:2px;'>πŸ’‘ {label}</div>")

        lines.append("</div>")

    lines.append("</div>")

    return "\n".join(lines)