Compare commits
7 Commits
f8b667604f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2923257529 | |||
| a017771621 | |||
| 25e555cab6 | |||
| 934731e9ef | |||
| 3c7ab44e74 | |||
| 4c67c116b2 | |||
| 06f98acf3e |
@@ -250,29 +250,39 @@ def _summary(status: str, translated: int, scanned: int) -> str:
|
||||
return f"translated={translated} scanned={scanned}"
|
||||
|
||||
|
||||
def _translate_field(text: str, base_url: str, target: str):
|
||||
"""Translate ONE field independently. Returns (translated, source_lang,
|
||||
engine_version), or (None, None, None) when there's nothing to store — empty
|
||||
text, already the target language, or a passthrough (engine "none"). Per-field
|
||||
(not aggregate first-item) detection so a non-English description still gets
|
||||
translated when the title is already English (mixed-language posts)."""
|
||||
if not text:
|
||||
return None, None, None
|
||||
res = ic.translate([text], base_url=base_url, target=target)
|
||||
detected = res["detected_lang"] or target
|
||||
if detected == target or res["engine"] == "none":
|
||||
return None, None, None
|
||||
return res["translations"][0], detected, res["engine_version"]
|
||||
|
||||
|
||||
def _translate_one(session, post, base_url: str, target: str) -> int:
|
||||
"""Translate one post's title/description in place. Returns 1 if it stored a
|
||||
translation, 0 for passthrough/empty (which still marks the post handled)."""
|
||||
"""Translate a post's title/description in place, each field independently.
|
||||
Returns 1 if it stored at least one translation, 0 when the post is all
|
||||
passthrough/empty (still marks it handled so the sweep won't revisit it)."""
|
||||
title = (post.post_title or "").strip()
|
||||
desc = (html_to_plain(post.description) if post.description else "") or ""
|
||||
desc = desc.strip()
|
||||
fields: list[tuple[str, str]] = []
|
||||
if title:
|
||||
fields.append(("title", title))
|
||||
if desc:
|
||||
fields.append(("desc", desc))
|
||||
if not fields:
|
||||
post.translated_source_lang = target # nothing to translate → handled
|
||||
title_tr, title_lang, title_ev = _translate_field(title, base_url, target)
|
||||
desc_tr, desc_lang, desc_ev = _translate_field(desc, base_url, target)
|
||||
if title_tr is None and desc_tr is None:
|
||||
# Nothing non-target (or nothing to translate) → handled, store nothing.
|
||||
post.translated_source_lang = target
|
||||
return 0
|
||||
res = ic.translate([t for _, t in fields], base_url=base_url, target=target)
|
||||
detected = res["detected_lang"] or target
|
||||
if detected == target or res["engine"] == "none":
|
||||
post.translated_source_lang = target # already target language → handled
|
||||
return 0
|
||||
by_field = {f: res["translations"][i] for i, (f, _) in enumerate(fields)}
|
||||
post.post_title_translated = by_field.get("title")
|
||||
post.description_translated = by_field.get("desc")
|
||||
post.translated_source_lang = detected
|
||||
post.translation_engine_version = res["engine_version"]
|
||||
post.post_title_translated = title_tr
|
||||
post.description_translated = desc_tr
|
||||
# Source lang = whichever field was actually non-target (for a mixed post,
|
||||
# the translated field's language, not the already-English one).
|
||||
post.translated_source_lang = title_lang or desc_lang
|
||||
post.translation_engine_version = title_ev or desc_ev
|
||||
post.translated_at = datetime.now(UTC)
|
||||
return 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span class="fc-tag-chip" @mouseenter="onEnter" @mouseleave="onLeave">
|
||||
<v-chip
|
||||
size="small" :closable="!unconfirmedAuto"
|
||||
size="default" :closable="!unconfirmedAuto"
|
||||
:color="store.colorFor(tag.kind)" variant="tonal"
|
||||
class="fc-tag-chip__nav"
|
||||
role="link"
|
||||
@@ -9,7 +9,7 @@
|
||||
@click="$emit('navigate', tag)"
|
||||
@click:close="$emit('remove', tag.id)"
|
||||
>
|
||||
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
||||
<v-icon start size="small">{{ iconFor(tag.kind) }}</v-icon>
|
||||
<span class="fc-tag-chip__name">{{ tag.name }}</span><v-icon
|
||||
v-if="tag.is_system" end size="x-small" class="fc-tag-chip__system"
|
||||
title="System tag — tagged items are excluded from training other concepts"
|
||||
@@ -27,13 +27,13 @@
|
||||
:title="`Yes — keep “${tag.name}” (trains the model, won't be retracted)`"
|
||||
:aria-label="`Confirm ${tag.name}`"
|
||||
@click.stop="$emit('confirm', tag)"
|
||||
><v-icon size="13">mdi-check</v-icon></button>
|
||||
><v-icon size="15">mdi-check</v-icon></button>
|
||||
<button
|
||||
type="button" class="fc-tag-chip__no"
|
||||
:title="`No — remove “${tag.name}”`"
|
||||
:aria-label="`Reject ${tag.name}`"
|
||||
@click.stop="$emit('remove', tag.id)"
|
||||
><v-icon size="13">mdi-close</v-icon></button>
|
||||
><v-icon size="15">mdi-close</v-icon></button>
|
||||
</span>
|
||||
</v-chip>
|
||||
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
|
||||
@@ -143,7 +143,19 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
chip's hover title. */
|
||||
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; max-width: 100%; min-width: 0; }
|
||||
.fc-tag-chip__nav { max-width: 100%; min-width: 0; }
|
||||
/* Tonal chips (esp. character = info) wash out against the dark rail — the fill
|
||||
is intentionally faint. Give every tag chip a thin border in its OWN kind
|
||||
colour (currentColor = the tonal chip's themed foreground) so the edge reads
|
||||
clearly without touching the fill (operator-asked 2026-07-08). Theme-aware:
|
||||
currentColor tracks the kind colour in either light or dark. */
|
||||
.fc-tag-chip__nav { border: thin solid color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.fc-tag-chip__nav :deep(.v-chip__content) { min-width: 0; overflow: hidden; }
|
||||
/* The bigger size=default chip widens Vuetify's negative start-margin on the
|
||||
leading kind-icon, pulling it LEFT of the content box above — where that
|
||||
overflow:hidden then clips the icon's left edge as a vertical slice
|
||||
(operator-flagged 2026-07-08). Zero the pull so the icon sits inside the clip
|
||||
box; the chip's own 12px padding keeps a comfortable left inset. */
|
||||
.fc-tag-chip__nav :deep(.v-icon--start) { margin-inline-start: 0; }
|
||||
.fc-tag-chip__name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
min-width: 0; flex: 0 1 auto;
|
||||
@@ -159,23 +171,27 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
.fc-tag-chip__kebab { opacity: 0.7; }
|
||||
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
||||
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
|
||||
/* Provisional auto-tag: a compact green ✓ / red ✗ pair in place of the ✕. The
|
||||
yes/no is obvious enough on its own, so there's no "auto" label (operator-asked
|
||||
/* Provisional auto-tag: a green ✓ / red ✗ pair in place of the ✕. The yes/no is
|
||||
obvious enough on its own, so there's no "auto" label (operator-asked
|
||||
2026-07-07). flex:0 0 auto keeps it visible while the name ellipsis-truncates. */
|
||||
.fc-tag-chip__verdict {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 1px;
|
||||
margin-left: 3px;
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 3px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
/* Solid-filled (white glyph on a full success/error circle) so accept/reject
|
||||
stay well-defined even on a muted tonal chip — e.g. character (info) — instead
|
||||
of a faint icon that only lit up on hover. Mirrors the Suggestions rail's
|
||||
verdict buttons so the affordance reads identically (operator-asked 2026-07-08). */
|
||||
.fc-tag-chip__yes, .fc-tag-chip__no {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; padding: 0; border: none; border-radius: 50%;
|
||||
background: transparent; cursor: pointer; transition: background 0.1s;
|
||||
width: 22px; height: 22px; padding: 0; border: none; border-radius: 50%;
|
||||
color: #fff; cursor: pointer; opacity: 0.92;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.fc-tag-chip__yes { color: rgb(var(--v-theme-success)); }
|
||||
.fc-tag-chip__no { color: rgb(var(--v-theme-error)); }
|
||||
.fc-tag-chip__yes:hover { background: rgb(var(--v-theme-success), 0.16); }
|
||||
.fc-tag-chip__no:hover { background: rgb(var(--v-theme-error), 0.16); }
|
||||
.fc-tag-chip__yes { background: rgb(var(--v-theme-success)); }
|
||||
.fc-tag-chip__no { background: rgb(var(--v-theme-error)); }
|
||||
.fc-tag-chip__yes:hover, .fc-tag-chip__no:hover { opacity: 1; transform: scale(1.1); }
|
||||
.fc-tag-chip__yes:focus-visible, .fc-tag-chip__no:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -115,6 +115,35 @@ def test_translate_posts_passthrough_english_marks_handled(db_sync, monkeypatch)
|
||||
assert p.post_title_translated is None # ...but nothing to show
|
||||
|
||||
|
||||
def test_translate_posts_mixed_language_translates_nonenglish_field(db_sync, monkeypatch):
|
||||
# English title + Japanese description: the description is still translated
|
||||
# even though the title is already English (per-field detection, not the old
|
||||
# aggregate first-item bail). Source lang comes from the translated field.
|
||||
_patch(monkeypatch, db_sync)
|
||||
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||
|
||||
def fake_translate(texts, **k):
|
||||
t = texts[0]
|
||||
if t.isascii(): # already English → passthrough
|
||||
return {"translations": [t], "detected_lang": "en",
|
||||
"engine": "none", "engine_version": None}
|
||||
return {"translations": [f"EN:{t}"], "detected_lang": "ja",
|
||||
"engine": "llm", "engine_version": "v9"}
|
||||
|
||||
monkeypatch.setattr("backend.app.tasks.translation.ic.translate", fake_translate)
|
||||
a = _artist(db_sync)
|
||||
p = _post(db_sync, a.id, title="hello", desc="ねこ")
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
assert "translated=1" in translate_posts()
|
||||
db_sync.refresh(p)
|
||||
assert p.post_title_translated is None # English title untouched
|
||||
assert p.description_translated == "EN:ねこ" # Japanese desc translated
|
||||
assert p.translated_source_lang == "ja" # from the translated field
|
||||
assert p.translation_engine_version == "v9"
|
||||
|
||||
|
||||
def test_translate_posts_disabled_is_noop(db_sync, monkeypatch):
|
||||
_patch(monkeypatch, db_sync)
|
||||
a = _artist(db_sync)
|
||||
|
||||
Reference in New Issue
Block a user