ffdbdbaf07
Throughput: translate_posts now runs every 8h (was daily) as the steady-state cadence for newly-imported posts, and the Settings "Translate now" button runs it in drain mode (run-until-done, no reset) so one press clears the whole untranslated backlog instead of a single 300-post chunk. The interrupt/backoff re-enqueue now preserves the drain flag so a bulk drain resumes cleanly after an Interpreter restart. Misdetection groundwork: surface the detector's confidence from the Interpreter client (it was in the detectedLanguage payload but discarded) and add a read-only "Test translation" box — POST /settings/translation/ probe + TranslationCard UI — that shows detected language + confidence + engine + result for pasted text, without saving. Lets the operator see why a short/abbreviation-heavy English title gets mis-detected so the detection guard (min-length + confidence floor) can be tuned from real numbers. The guard itself follows once the mis-detected cases are probed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
313 lines
11 KiB
Vue
313 lines
11 KiB
Vue
<template>
|
||
<MaintenanceTile
|
||
icon="mdi-translate"
|
||
title="Post translation (Interpreter)"
|
||
blurb="Translate non-English post titles + descriptions to English via your self-hosted Interpreter service, so archived posts read naturally."
|
||
>
|
||
<div class="d-flex align-center mb-3" style="gap: 10px;">
|
||
<span class="fc-section-h">Enabled</span>
|
||
<v-switch
|
||
v-model="enabled" :loading="busy" hide-details density="compact"
|
||
color="success" class="ml-auto" @update:model-value="onToggle"
|
||
/>
|
||
</div>
|
||
<p class="fc-muted text-body-2 mb-3">
|
||
Point this at your Interpreter service. It stays off until a URL is set, and
|
||
only translates posts whose text isn't already in the target language — the
|
||
English is shown by default on post cards, with a toggle back to the original.
|
||
</p>
|
||
|
||
<v-text-field
|
||
v-model="baseUrl" label="Interpreter base URL"
|
||
placeholder="http://interpreter.your-domain/" density="compact"
|
||
hide-details class="mb-3" :disabled="busy" @change="onSave"
|
||
/>
|
||
<v-text-field
|
||
v-model="targetLang" label="Target language" density="compact"
|
||
hide-details style="max-width: 160px;" class="mb-3" :disabled="busy"
|
||
@change="onSave"
|
||
/>
|
||
|
||
<div class="d-flex align-center flex-wrap mb-3" style="gap: 8px;">
|
||
<v-icon size="12" :color="statusColor">mdi-circle</v-icon>
|
||
<span class="fc-muted text-body-2">{{ statusText }}</span>
|
||
<v-btn
|
||
size="x-small" variant="tonal" rounded="pill" class="ml-2"
|
||
:loading="testing" :disabled="!baseUrl" @click="onTest"
|
||
>Test connection</v-btn>
|
||
<span
|
||
v-if="testResult !== null" class="text-caption"
|
||
:class="testResult ? 'text-success' : 'text-error'"
|
||
>{{ testResult ? '✓ reachable' : '✗ unreachable' }}</span>
|
||
</div>
|
||
|
||
<div class="d-flex align-center flex-wrap" style="gap: 10px;">
|
||
<v-btn
|
||
size="small" variant="flat" color="accent" rounded="pill"
|
||
prepend-icon="mdi-translate" :loading="running"
|
||
:disabled="!enabled || !baseUrl" @click="onRun"
|
||
>Translate now</v-btn>
|
||
<v-btn
|
||
size="small" variant="tonal" rounded="pill"
|
||
prepend-icon="mdi-refresh" :loading="retranslating"
|
||
:disabled="!enabled || !baseUrl" @click="confirmAll = true"
|
||
>Re-translate all</v-btn>
|
||
<span
|
||
v-if="status && status.active"
|
||
class="fc-muted text-caption d-inline-flex align-center" style="gap: 6px;"
|
||
>
|
||
<v-progress-circular indeterminate size="12" width="2" color="accent" />
|
||
Translating… {{ status.untranslated_count }} remaining
|
||
</span>
|
||
<span v-else-if="status" class="fc-muted text-caption">
|
||
{{ status.untranslated_count }}
|
||
post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation
|
||
</span>
|
||
</div>
|
||
<p
|
||
v-if="status && status.last_run && ['error', 'timeout'].includes(status.last_run.status)"
|
||
class="text-caption text-error mt-1 mb-0"
|
||
>
|
||
Last translation run ended with “{{ status.last_run.status }}”. It resumes on
|
||
the next sweep; check the Interpreter connection if it persists.
|
||
</p>
|
||
<p class="fc-muted text-caption mt-2 mb-0">
|
||
Use <strong>Re-translate all</strong> after switching the Interpreter model —
|
||
it clears every stored translation and re-runs it through the new model
|
||
(unchanged text is served from cache, so it's cheap). To refresh just one
|
||
artist, use the Re-translate button on that artist's page.
|
||
</p>
|
||
|
||
<v-divider class="my-3" />
|
||
|
||
<!-- Diagnostic probe (task #1376): paste text → what Interpreter DETECTS
|
||
(language + confidence) and returns, without saving. Used to see why a
|
||
short/abbreviation-heavy English title gets mis-detected, and to pick a
|
||
detection-confidence / min-length guard from real numbers. -->
|
||
<div class="mb-1">
|
||
<span class="fc-section-h">Test translation</span>
|
||
<p class="fc-muted text-caption mt-1 mb-2">
|
||
Paste a title or snippet to see what Interpreter detects and returns —
|
||
handy for checking why a short string is mis-detected. Nothing is saved.
|
||
</p>
|
||
</div>
|
||
<v-textarea
|
||
v-model="probeText" label="Text to test" rows="2" auto-grow
|
||
density="compact" hide-details class="mb-2" :disabled="!baseUrl"
|
||
/>
|
||
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px;">
|
||
<v-btn
|
||
size="small" variant="tonal" rounded="pill" prepend-icon="mdi-magnify"
|
||
:loading="probing" :disabled="!baseUrl || !probeText.trim()"
|
||
@click="onProbe"
|
||
>Test translation</v-btn>
|
||
</div>
|
||
<v-sheet
|
||
v-if="probeResult" rounded class="pa-3 mb-1 text-body-2"
|
||
color="rgba(var(--v-theme-accent), 0.06)"
|
||
>
|
||
<div class="d-flex flex-wrap" style="gap: 4px 16px;">
|
||
<span>
|
||
<span class="fc-muted">Detected:</span>
|
||
<strong>{{ probeResult.detected_lang || '—' }}</strong>
|
||
</span>
|
||
<span v-if="probeResult.detected_confidence != null">
|
||
<span class="fc-muted">Confidence:</span>
|
||
<strong>{{ fmtConf(probeResult.detected_confidence) }}</strong>
|
||
</span>
|
||
<span>
|
||
<span class="fc-muted">Engine:</span>
|
||
{{ probeResult.engine || '—' }}<template
|
||
v-if="probeResult.engine_version"
|
||
> ({{ probeResult.engine_version }})</template>
|
||
</span>
|
||
<span><span class="fc-muted">Target:</span> {{ probeResult.target }}</span>
|
||
</div>
|
||
<v-divider class="my-2" />
|
||
<div class="fc-muted mb-1">
|
||
Result<template v-if="probeResult.detected_lang === probeResult.target">
|
||
— already {{ probeResult.target }}, so the sweep leaves it as-is</template>:
|
||
</div>
|
||
<p class="mb-0" style="white-space: pre-wrap;">{{
|
||
probeResult.translated || '(no change)'
|
||
}}</p>
|
||
</v-sheet>
|
||
|
||
<v-alert
|
||
v-if="err" type="error" variant="tonal" density="compact"
|
||
class="mt-3" closable @click:close="err = null"
|
||
>{{ err }}</v-alert>
|
||
|
||
<!-- m146: re-translate-everything is a bigger hammer than 'Translate now'
|
||
(it clears + rebuilds all translations), so it asks first. -->
|
||
<v-dialog v-model="confirmAll" max-width="440">
|
||
<v-card>
|
||
<v-card-title>Re-translate everything?</v-card-title>
|
||
<v-card-text class="text-body-2">
|
||
This clears the stored translation for <strong>every</strong> post and
|
||
re-runs them through your current Interpreter model. Text that hasn't
|
||
changed comes back from the cache instantly; anything the new model
|
||
translates differently is refreshed. It runs in the background until
|
||
done.
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="confirmAll = false">Cancel</v-btn>
|
||
<v-btn
|
||
color="accent" :loading="retranslating" @click="onRetranslateAll"
|
||
>Re-translate all</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</MaintenanceTile>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { toast } from '../../utils/toast.js'
|
||
import { useApi } from '../../composables/useApi.js'
|
||
import { useImportStore } from '../../stores/import.js'
|
||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||
|
||
const api = useApi()
|
||
const store = useImportStore()
|
||
|
||
const enabled = ref(false)
|
||
const baseUrl = ref('')
|
||
const targetLang = ref('en')
|
||
const busy = ref(false)
|
||
const running = ref(false)
|
||
const retranslating = ref(false)
|
||
const confirmAll = ref(false)
|
||
const testing = ref(false)
|
||
const testResult = ref(null) // null = not tested this session; true/false = last result
|
||
const status = ref(null)
|
||
const err = ref(null)
|
||
|
||
// #1376 "Test translation" probe: paste text → detected lang/confidence + result.
|
||
const probeText = ref('')
|
||
const probing = ref(false)
|
||
const probeResult = ref(null)
|
||
|
||
// Confidence scale is Interpreter-defined (0–100 or 0–1) — show it as-is, just
|
||
// trimmed to 2 decimals, so the operator reads the true number.
|
||
function fmtConf (c) {
|
||
return c == null ? '' : Math.round(c * 100) / 100
|
||
}
|
||
|
||
const statusColor = computed(() => {
|
||
if (!enabled.value || !baseUrl.value) return 'grey'
|
||
return status.value?.healthy ? 'success' : 'error'
|
||
})
|
||
const statusText = computed(() => {
|
||
if (!enabled.value) return 'Off'
|
||
if (!baseUrl.value) return 'No base URL set'
|
||
if (status.value == null) return 'Checking…'
|
||
return status.value.healthy ? 'Interpreter reachable' : 'Interpreter unreachable'
|
||
})
|
||
|
||
let pollTimer = null
|
||
async function loadStatus() {
|
||
try { status.value = await api.get('/api/settings/translation/status') }
|
||
catch { status.value = null }
|
||
// Poll live while a sweep is running so the remaining count ticks down; stop
|
||
// when idle (a fresh trigger restarts it via its own setTimeout(loadStatus)).
|
||
clearTimeout(pollTimer)
|
||
if (status.value?.active) pollTimer = setTimeout(loadStatus, 3000)
|
||
}
|
||
onUnmounted(() => clearTimeout(pollTimer))
|
||
|
||
async function onTest() {
|
||
// Ping /v1/health for the CURRENTLY-typed URL (not the saved one), so a new
|
||
// URL can be verified before enabling. Also reflects in the status dot.
|
||
testing.value = true
|
||
err.value = null
|
||
testResult.value = null
|
||
try {
|
||
const r = await api.post('/api/settings/translation/test', {
|
||
body: { base_url: baseUrl.value.trim() },
|
||
})
|
||
testResult.value = !!r.healthy
|
||
if (status.value) status.value.healthy = !!r.healthy
|
||
} catch (e) {
|
||
err.value = e.message
|
||
} finally {
|
||
testing.value = false
|
||
}
|
||
}
|
||
|
||
async function onProbe () {
|
||
probing.value = true
|
||
err.value = null
|
||
probeResult.value = null
|
||
try {
|
||
probeResult.value = await api.post('/api/settings/translation/probe', {
|
||
body: { text: probeText.value.trim() },
|
||
})
|
||
} catch (e) {
|
||
err.value = e.message
|
||
} finally {
|
||
probing.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await store.loadSettings()
|
||
const s = store.settings || {}
|
||
enabled.value = !!s.translation_enabled
|
||
baseUrl.value = s.interpreter_base_url || ''
|
||
targetLang.value = s.translation_target_lang || 'en'
|
||
} catch { /* non-fatal */ }
|
||
await loadStatus()
|
||
})
|
||
|
||
async function save(patch) {
|
||
busy.value = true
|
||
err.value = null
|
||
try { await store.patchSettings(patch) }
|
||
catch (e) { err.value = e.message }
|
||
finally { busy.value = false }
|
||
}
|
||
|
||
async function onToggle(val) {
|
||
await save({ translation_enabled: !!val })
|
||
toast({ text: val ? 'Translation on' : 'Translation off', type: 'success' })
|
||
await loadStatus()
|
||
}
|
||
async function onSave() {
|
||
await save({
|
||
interpreter_base_url: baseUrl.value.trim(),
|
||
translation_target_lang: (targetLang.value || 'en').trim() || 'en',
|
||
})
|
||
await loadStatus()
|
||
}
|
||
async function onRun() {
|
||
running.value = true
|
||
err.value = null
|
||
try {
|
||
await api.post('/api/settings/translation/run')
|
||
toast({ text: 'Translation sweep started', type: 'success' })
|
||
} catch (e) {
|
||
err.value = e.message
|
||
} finally {
|
||
running.value = false
|
||
setTimeout(loadStatus, 1500) // let the sweep make a dent, then refresh
|
||
}
|
||
}
|
||
async function onRetranslateAll() {
|
||
retranslating.value = true
|
||
err.value = null
|
||
try {
|
||
await api.post('/api/settings/translation/retranslate', { body: { all: true } })
|
||
confirmAll.value = false
|
||
toast({ text: 'Re-translating all posts…', type: 'success' })
|
||
} catch (e) {
|
||
err.value = e.message
|
||
} finally {
|
||
retranslating.value = false
|
||
setTimeout(loadStatus, 1500) // reset spikes the untranslated count — refresh it
|
||
}
|
||
}
|
||
</script>
|