aea2701c28
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
103 lines
3.4 KiB
Vue
103 lines
3.4 KiB
Vue
<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>
|