feat(translation): tunable acceptance floor (0.90) + per-post sticky override (#155)
The gate at a fixed 0.80 couldn't catch the real pain: Interpreter (fresh == cached, verified by probe) confidently mis-detects short ASCII English like "... WIP Part 1" as German at 0.86 — above the floor — so it was accepted and a re-translate reproduced it. Confidence alone can't separate the 0.86 collision (genuine German lands there too), and single-word mis-flags sit at a confident 1.0 no floor catches. Two operator-approved levers: - Acceptance floor is now a live Settings value (ImportSettings. translation_min_confidence, default 0.90; surfaced in the Translation card), so it's tunable without a redeploy. _accept takes the threshold as a parameter. - Per-post sticky override (Post.translation_override: auto/force/original). 'force' stores a translation even below the floor (rescue a skipped legit-foreign title); 'original' keeps the original and clears any stored translation (kill a confident mis-flag no floor catches). The sweep honors it on every run and _reset_translations skips 'original', so the choice survives a Re-translate-all. POST /api/posts/<id>/translation-override applies it immediately (translate now when the service is up, else queue for the sweep). UI: PostTranslationControl on the posts-feed card. Migration 0084 (both columns + a CHECK on the override). The feed + provenance serializers expose translation_override. With a stricter floor the rollback finally works: raise it -> Re-translate all -> the 0.86 mis-flags are rejected and restored to the original; force / keep-original handle the residual either way. Tests: gate thresholds against the param (0.86 rejected at 0.90, explicit-floor cases); sweep force/original + re-translate-skips-original; override endpoint (validation, original clears, force queues when disabled, feed exposes it); settings min_confidence default/save/validate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
This commit is contained in:
@@ -70,6 +70,14 @@
|
||||
@click.stop="showOriginal = !showOriginal"
|
||||
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
|
||||
|
||||
<!-- Per-post translation override (#155): force a skipped translation on,
|
||||
or keep the original for a confidently mis-flagged title. Only where
|
||||
there's text to translate. -->
|
||||
<PostTranslationControl
|
||||
v-if="post.post_title || post.description_plain"
|
||||
:post="post"
|
||||
/>
|
||||
|
||||
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
||||
HTML (headings, lists, links, inline images). Collapsed and the
|
||||
no-detail fallback stay plain text. -->
|
||||
@@ -136,6 +144,7 @@ import { useModalStore } from '../../stores/modal.js'
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||
import PostSeriesMenu from './PostSeriesMenu.vue'
|
||||
import PostTranslationControl from './PostTranslationControl.vue'
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true },
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<!-- Sticky per-post translation override (#155). Quiet inline control under the
|
||||
post title: force a skipped legit-foreign title on, or knock a wrongly
|
||||
mis-translated one back to the original. The choice sticks through a
|
||||
Re-translate-all. -->
|
||||
<div class="fc-post-tx">
|
||||
<span class="fc-post-tx__label">Translation:</span>
|
||||
<v-menu :disabled="busy">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<button
|
||||
type="button" class="fc-post-tx__btn" v-bind="menuProps" :disabled="busy"
|
||||
:aria-label="`Translation handling: ${currentLabel}`"
|
||||
>
|
||||
{{ currentLabel }}
|
||||
<v-icon size="14">mdi-menu-down</v-icon>
|
||||
</button>
|
||||
</template>
|
||||
<v-list density="compact" min-width="240" class="fc-post-tx__list">
|
||||
<v-list-item
|
||||
v-for="opt in OPTIONS" :key="opt.value"
|
||||
:active="opt.value === current" @click="choose(opt.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small">{{ opt.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ opt.help }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-progress-circular v-if="busy" indeterminate size="13" width="2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
|
||||
const props = defineProps({ post: { type: Object, required: true } })
|
||||
const posts = usePostsStore()
|
||||
const busy = ref(false)
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto', icon: 'mdi-cog-outline',
|
||||
help: 'Translate only when confident enough' },
|
||||
{ value: 'force', label: 'Force translate', icon: 'mdi-translate',
|
||||
help: 'Always translate, even at low confidence' },
|
||||
{ value: 'original', label: 'Keep original', icon: 'mdi-translate-off',
|
||||
help: 'Never translate — keep the original text' },
|
||||
]
|
||||
|
||||
const current = computed(() => props.post.translation_override || 'auto')
|
||||
const currentLabel = computed(
|
||||
() => (OPTIONS.find((o) => o.value === current.value) || OPTIONS[0]).label,
|
||||
)
|
||||
|
||||
async function choose (value) {
|
||||
if (value === current.value || busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await posts.applyTranslationOverride(props.post.id, value)
|
||||
if (res.applied === 'queued') {
|
||||
toast({
|
||||
text: 'Saved — this post will translate on the next sweep (service offline).',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ text: `Couldn't update translation: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-tx {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.fc-post-tx__label {
|
||||
font-size: 0.72rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-tx__btn {
|
||||
display: inline-flex; align-items: center; gap: 1px;
|
||||
padding: 0; background: none; border: 0; cursor: pointer;
|
||||
font-size: 0.72rem; font-weight: 700;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-tx__btn:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-post-tx__btn:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.fc-post-tx__btn:disabled { cursor: default; opacity: 0.6; }
|
||||
.fc-post-tx__list :deep(.v-list-item-subtitle) {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,21 @@
|
||||
@change="onSave"
|
||||
/>
|
||||
|
||||
<!-- Acceptance floor (#155): latin-script translations below this Interpreter
|
||||
confidence are kept as the original. Per-post overrides handle exceptions. -->
|
||||
<v-text-field
|
||||
v-model.number="minConfidence" label="Acceptance confidence"
|
||||
type="number" min="0" max="1" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" class="mb-1" :disabled="busy"
|
||||
@change="onSaveConfidence"
|
||||
/>
|
||||
<div class="fc-muted text-caption mb-3">
|
||||
Latin-script titles Interpreter is less than this sure about (0–1) are kept
|
||||
as the original; CJK is always translated. Default 0.90 — raise it to reject
|
||||
more mis-detections, lower it to translate more. Per-post controls in the
|
||||
posts feed override this either way.
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -175,6 +190,7 @@ const store = useImportStore()
|
||||
const enabled = ref(false)
|
||||
const baseUrl = ref('')
|
||||
const targetLang = ref('en')
|
||||
const minConfidence = ref(0.9)
|
||||
const busy = ref(false)
|
||||
const running = ref(false)
|
||||
const retranslating = ref(false)
|
||||
@@ -258,6 +274,7 @@ onMounted(async () => {
|
||||
enabled.value = !!s.translation_enabled
|
||||
baseUrl.value = s.interpreter_base_url || ''
|
||||
targetLang.value = s.translation_target_lang || 'en'
|
||||
minConfidence.value = s.translation_min_confidence ?? 0.9
|
||||
} catch { /* non-fatal */ }
|
||||
await loadStatus()
|
||||
})
|
||||
@@ -282,6 +299,14 @@ async function onSave() {
|
||||
})
|
||||
await loadStatus()
|
||||
}
|
||||
async function onSaveConfidence() {
|
||||
// Clamp to [0, 1] so a stray value never bounces off the API 400.
|
||||
let v = Number(minConfidence.value)
|
||||
if (!Number.isFinite(v)) v = 0.9
|
||||
v = Math.min(1, Math.max(0, v))
|
||||
minConfidence.value = v
|
||||
await save({ translation_min_confidence: v })
|
||||
}
|
||||
async function onRun() {
|
||||
running.value = true
|
||||
err.value = null
|
||||
|
||||
@@ -71,6 +71,24 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return await api.get(`/api/posts/${id}`)
|
||||
}
|
||||
|
||||
// Set a post's sticky translation override (auto/force/original, #155) and
|
||||
// patch the loaded item in place with the endpoint's result (it translates /
|
||||
// clears immediately when the service is up), so the card reflects the change
|
||||
// without a feed reload.
|
||||
async function applyTranslationOverride(postId, override) {
|
||||
const res = await api.post(`/api/posts/${postId}/translation-override`, {
|
||||
body: { override },
|
||||
})
|
||||
const item = items.value.find((p) => p.id === postId)
|
||||
if (item) {
|
||||
item.translation_override = res.translation_override
|
||||
item.post_title_translated = res.post_title_translated
|
||||
item.description_translated = res.description_translated
|
||||
item.translated_source_lang = res.translated_source_lang
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Filter overlay for the around/older/newer (in-context anchored)
|
||||
// path. Keep this distinct from `filters.value` (the down-only feed)
|
||||
// so a normal-feed filter change doesn't leak into an active anchored
|
||||
@@ -168,7 +186,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return {
|
||||
items, cursor, loading, done, error, filters,
|
||||
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
|
||||
loadInitial, loadMore, getPostFull,
|
||||
loadInitial, loadMore, getPostFull, applyTranslationOverride,
|
||||
loadAround, loadOlder, loadNewer,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user