fix: 时间线为空时不再误导去改模型设置 (#188)

This commit is contained in:
Kris K
2026-09-25 19:46:14 +08:00
committed by GitHub
parent aa2e4f1853
commit 121bbf0aaa
14 changed files with 181 additions and 20 deletions
+3
View File
@@ -83,6 +83,9 @@ jobs:
- name: Subtitle failure guidance
run: node --test tests/subtitle-failure.test.cjs
- name: Empty timeline guidance
run: node --test tests/timeline-failure.test.cjs
- name: Publish UI helpers
run: node --test tests/upload-post-ui.test.cjs
+1
View File
@@ -8,6 +8,7 @@
## [未发布]
### 修复
- 时间线为空时不再误导去改模型设置(Related #182)
- 本地导入没有可用字幕时,失败说明会指到「设置 → 转写」,也可以在重新导入时附上 .srt。已经失败、只记下「字幕文件不存在」的项目,打开后同样能看到这条去向(#186)
- 桌面端连续查看项目、下载或生成合集时,不再因为数据库连接占满而打不开列表和详情(#175)
+18 -1
View File
@@ -20,9 +20,10 @@ class PipelineFailure(RuntimeError):
super().__init__(message)
self.stage = stage
self.hint = hint
# 稳定机器码。前端用来打开对应设置页。
# 稳定机器码。前端用来打开对应设置页,或避免打开错误的设置页。
# llm_not_configured:没有可用提供商 / 缺少 API Key / 连接测试没通过
# whisper_not_installed | whisper_install_failed | transcription_empty | subtitle_setup
# timeline_empty:时间线为空,但模型连接已经成功(不要再指到「设置 → 模型」)
self.code = code
@property
@@ -72,6 +73,22 @@ def llm_key_failure(stage: str, message: str) -> PipelineFailure:
HINT_SUBTITLE = "到「设置 → 转写」安装 Whisper 模型让 AutoClip 自动转写,或导入 .srt 字幕后重试。"
HINT_LOWER_THRESHOLD = "到「设置 → 模型 → 最低评分阈值」调低后重试,或换一个更强的模型。"
HINT_CHECK_FFMPEG = "确认 ffmpeg 可用(桌面版内置;Docker / 脚本模式请检查 PATH),以及原视频文件完整可播放。"
# 预检已通过:空时间线更常是短片被时长下限滤掉,或时间戳对不上字幕。不要再提 API Key。
CODE_TIMELINE_EMPTY = "timeline_empty"
HINT_EMPTY_TIMELINE = (
"短视频里的片段常被最短时长滤掉(短片约 20 秒起),或模型给出的时间戳对不上字幕。"
"换一条更长、口播更完整的素材后再试。"
)
def empty_timeline_failure(topic_count: int) -> PipelineFailure:
"""Step 2 没有留下可用片段。调用方应已确认模型连接成功。"""
return PipelineFailure(
"ANALYZE",
f"时间线提取为空:{topic_count} 个话题在对齐并按时长筛选后没有留下可用片段。",
HINT_EMPTY_TIMELINE,
code=CODE_TIMELINE_EMPTY,
)
def missing_subtitle_failure() -> PipelineFailure:
+1 -1
View File
@@ -72,7 +72,7 @@ class ProjectResponse(BaseSchema):
# 失败态:最近一次失败任务的错误文本(带阶段与下一步提示),详情页 / 项目卡 / 应用内反馈直接展示
error_message: Optional[str] = Field(default=None, description="Latest failure message when status is failed")
error_code: Optional[str] = Field(default=None, description="Stable failure code, e.g. llm_not_configured or whisper_not_installed")
error_code: Optional[str] = Field(default=None, description="Stable failure code, e.g. llm_not_configured, whisper_not_installed, or timeline_empty")
# Statistics
total_clips: int = Field(default=0, description="Total number of clips")
+4 -6
View File
@@ -9,8 +9,9 @@ from pathlib import Path
from backend.services.simple_progress import emit_progress, clear_progress
from backend.pipeline.failures import (
PipelineFailure, HINT_CHECK_LLM, HINT_LOWER_THRESHOLD, HINT_CHECK_FFMPEG,
PipelineFailure, HINT_LOWER_THRESHOLD, HINT_CHECK_FFMPEG,
llm_key_failure, failure_from_speech_error, missing_subtitle_failure,
empty_timeline_failure,
)
from backend.pipeline.step1_outline import run_step1_outline
from backend.pipeline.step2_timeline import run_step2_timeline
@@ -205,11 +206,8 @@ class SimplePipelineAdapter:
prompt_files=prompt_files,
)
if not timeline_data:
raise PipelineFailure(
"ANALYZE",
f"时间线提取为空:{len(outlines)} 个话题都没能对齐到字幕时间轴。",
HINT_CHECK_LLM,
)
# 能走到这里说明 _preflight_llm 已通过,连接和密钥不是这条失败的原因(#182)
raise empty_timeline_failure(len(outlines))
emit_progress(self.project_id, "ANALYZE", "时间线提取完成", subpercent=50)
# Step 3: 内容评分
+53
View File
@@ -226,6 +226,59 @@ def test_adapter_surfaces_whisper_error_on_the_subtitle_stage(adapter, monkeypat
assert result["message"] == result["error"]
def test_adapter_empty_timeline_does_not_send_user_to_model_settings(adapter, monkeypatch, tmp_path):
"""连接已成功时,时间线为空不要再提示去改模型 / API Key(#182)。"""
from backend.services import simple_pipeline_adapter as mod
_fake_manager(monkeypatch, available=True, display_name="阿里通义千问", model="qwen-plus")
srt = tmp_path / "in.srt"
srt.write_text(SRT, encoding="utf-8")
monkeypatch.setattr(mod, "run_step1_outline", lambda *a, **k: [{"title": f"t{i}"} for i in range(4)])
monkeypatch.setattr(mod, "run_step2_timeline", lambda *a, **k: [])
result = asyncio.run(adapter.process_project_sync(str(tmp_path / "in.mp4"), str(srt)))
assert result["status"] == "failed"
assert result["stage"] == "ANALYZE"
assert result["error_code"] == "timeline_empty"
assert "4 个话题" in result["error"]
assert "20" in result["error"]
assert "时间戳" in result["error"]
assert "最短时长" in result["error"]
assert "设置 → 模型" not in result["error"]
assert "API Key" not in result["error"]
assert "测试连接" not in result["error"]
assert result["message"] == result["error"]
assert adapter._events[-1][0] == "ANALYZE"
assert "时间线提取为空" in adapter._events[-1][1]
def test_empty_timeline_hint_survives_last_error_metadata():
"""CLI 只把正文写进 last_error 时,错误码和提示仍能回到详情页。"""
from backend.pipeline.failures import empty_timeline_failure
from backend.services.project_service import ProjectService
class Q:
def filter(self, *a, **k): return self
def order_by(self, *a, **k): return self
def first(self): return None
failure = empty_timeline_failure(4)
svc = ProjectService.__new__(ProjectService)
svc.db = SimpleNamespace(query=lambda model: Q())
project = SimpleNamespace(
id="p1",
status="failed",
project_metadata={"last_error": failure.user_message(), "last_error_code": failure.code},
)
message = svc.latest_error_message(project)
assert message == failure.user_message()
assert svc.latest_error_code(project) == "timeline_empty"
assert "设置 → 模型" not in message
assert "API Key" not in message
def test_adapter_fails_when_scoring_keeps_nothing(adapter, monkeypatch, tmp_path):
from backend.services import simple_pipeline_adapter as mod
+7 -8
View File
@@ -12,6 +12,7 @@ import { useSimpleProgressStore } from '../stores/useSimpleProgressStore'
import { Btn } from '../ui'
import { classifyLlmKeyFailure } from '../utils/llmFailure'
import { classifySubtitleFailure } from '../utils/subtitleFailure'
import { classifyTimelineEmpty } from '../utils/timelineFailure'
import { isSourceDownloading, readDownloadProgress } from '../utils/downloadProgress'
// import {
// getProjectStatusConfig,
@@ -294,14 +295,12 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
stage: failedProgress?.stage,
error_message: project.error_message || failedProgress?.message || undefined,
}
const llmKeyFailure = classifyLlmKeyFailure(
project.error_message || failedProgress?.message,
project.error_code,
)
const subtitleFailure = classifySubtitleFailure(
project.error_message || failedProgress?.message,
project.error_code,
)
const failureText = project.error_message || failedProgress?.message
const timelineEmpty = classifyTimelineEmpty(failureText, project.error_code)
const llmKeyFailure = !timelineEmpty && classifyLlmKeyFailure(failureText, project.error_code)
const subtitleFailure = timelineEmpty
? null
: classifySubtitleFailure(failureText, project.error_code)
const handleRetry = async (opts?: { silent?: boolean }) => {
if (isRetrying) return
+6 -2
View File
@@ -18,6 +18,7 @@ import FeedbackDialog from '../components/FeedbackDialog'
import { Btn, Icon, Section, Segmented, parseTimecode, fmtDuration } from '../ui'
import LlmKeyFailureEmpty from '../components/LlmKeyFailureEmpty'
import { classifyLlmKeyFailure } from '../utils/llmFailure'
import { classifyTimelineEmpty } from '../utils/timelineFailure'
const ProjectDetailPage: React.FC = () => {
useTranslation()
@@ -51,7 +52,10 @@ const ProjectDetailPage: React.FC = () => {
loadProcessingStatus()
}, [id])
const subtitleKind = classifySubtitleFailure(currentProject?.error_message, currentProject?.error_code)
const timelineEmpty = classifyTimelineEmpty(currentProject?.error_message, currentProject?.error_code)
const subtitleKind = timelineEmpty
? null
: classifySubtitleFailure(currentProject?.error_message, currentProject?.error_code)
const [refinedSubtitle, setRefinedSubtitle] = useState<{ id?: string; kind: SubtitleFailureKind } | null>(null)
useEffect(() => {
// 1.3.2 只留下一句「本地转写没有生成结果」。升级后按这台机器现在的 Whisper 状态说清楚下一步。
@@ -254,7 +258,7 @@ const ProjectDetailPage: React.FC = () => {
})
const isCompleted = currentProject.status === 'completed'
const isFailed = currentProject.status === 'failed' || (currentProject.status as string) === 'error'
const llmKeyFailure = classifyLlmKeyFailure(currentProject.error_message, currentProject.error_code)
const llmKeyFailure = !timelineEmpty && classifyLlmKeyFailure(currentProject.error_message, currentProject.error_code)
const failureContext = {
source: 'failure' as const,
project_id: currentProject.id,
+3 -1
View File
@@ -3,12 +3,14 @@ export const LLM_NOT_CONFIGURED = 'llm_not_configured'
/**
* 有 error_code 时以它为准。1.3.2 只留下「没有可用的 LLM 提供商…缺少 API Key」,
* 那种旧文案也要能点进设置。评分阈值、字幕失败不要算进来。
* 那种旧文案也要能点进设置。评分阈值、字幕失败、时间线为空不要算进来。
*/
export function classifyLlmKeyFailure(error?: string | null, code?: string | null): boolean {
if (code === LLM_NOT_CONFIGURED) return true
if (code === 'timeline_empty') return false
const text = error || ''
if (!text) return false
if (text.includes('时间线提取为空') || text.includes('时间线为空')) return false
if (text.includes('最低评分')) return false
if (
text.includes('没有可用的 LLM')
+2
View File
@@ -25,6 +25,8 @@ function mentionsSubtitle(text: string): boolean {
export function classifySubtitleFailure(error?: string | null, code?: string | null): SubtitleFailureKind | null {
if (code && CODES.has(code as SubtitleFailureKind)) return code as SubtitleFailureKind
const text = error || ''
// 时间线为空会提到字幕轴,但下一步不是去装 Whisper(#182)。
if (code === 'timeline_empty' || text.includes('时间线提取为空') || text.includes('时间线为空')) return null
if (!text || !mentionsSubtitle(text)) return null
if (text.includes('上次安装没有成功') || text.includes('安装没有成功')) return 'whisper_install_failed'
if (text.includes('还没安装') || text.includes('运行时未安装') || text.includes('没有可用的语音识别')) {
+13
View File
@@ -0,0 +1,13 @@
/** 时间线为空,且模型连接已经成功。不要打开「设置 → 模型」或「设置 → 转写」。 */
export const TIMELINE_EMPTY = 'timeline_empty'
/**
* 有 error_code 时以它为准。CLI / 进度条有时只留下 last_error 正文,
* 那种「时间线提取为空…」也要认出来,避免被当成缺密钥或没字幕。
*/
export function classifyTimelineEmpty(error?: string | null, code?: string | null): boolean {
if (code === TIMELINE_EMPTY) return true
const text = error || ''
if (!text) return false
return text.includes('时间线提取为空') || text.includes('时间线为空')
}
+6 -1
View File
@@ -27,11 +27,16 @@ test('classifies a missing key, a failed provider test, and the stable code', ()
assert.equal(classifyLlmKeyFailure('API连接测试失败。请检查API Key是否正确'), true)
})
test('does not treat scoring or subtitle failures as a missing model key', () => {
test('does not treat scoring, subtitle, or empty-timeline failures as a missing model key', () => {
assert.equal(classifyLlmKeyFailure('没有片段通过评分筛选。到「设置 → 模型 → 最低评分阈值」调低后重试。'), false)
assert.equal(classifyLlmKeyFailure('没有字幕可分析:视频不带字幕,且本地 Whisper 还没安装。到「设置 → 转写」安装。'), false)
const timeline = '时间线提取为空:4 个话题在对齐并按时长筛选后没有留下可用片段。 短视频里的片段常被最短时长滤掉(短片约 20 秒起),或模型给出的时间戳对不上字幕。换一条更长、口播更完整的素材后再试。'
assert.equal(classifyLlmKeyFailure(timeline), false)
assert.equal(classifyLlmKeyFailure(timeline, 'timeline_empty'), false)
assert.equal(classifyLlmKeyFailure('处理失败:' + timeline), false)
assert.equal(classifyLlmKeyFailure(''), false)
assert.equal(classifyLlmKeyFailure(null, 'whisper_not_installed'), false)
assert.equal(classifyLlmKeyFailure(null, 'timeline_empty'), false)
})
test('failure screens deep-link to Settings → Model', () => {
+3
View File
@@ -43,6 +43,9 @@ test('classifies structured codes and the four user-facing cases', () => {
test('does not treat unrelated failures as a transcription settings problem', () => {
assert.equal(classifySubtitleFailure('没有可用的 LLM 提供商。请到「设置 → 模型」检查。'), null)
assert.equal(classifySubtitleFailure('没有片段通过评分筛选。到「设置 → 模型 → 最低评分阈值」调低后重试。'), null)
const timeline = '时间线提取为空:4 个话题在对齐并按时长筛选后没有留下可用片段。 短视频里的片段常被最短时长滤掉(短片约 20 秒起),或模型给出的时间戳对不上字幕。换一条更长、口播更完整的素材后再试。'
assert.equal(classifySubtitleFailure(timeline), null)
assert.equal(classifySubtitleFailure(timeline, 'timeline_empty'), null)
assert.equal(classifySubtitleFailure(''), null)
assert.equal(classifySubtitleFailure(null, 'not_a_code'), null)
})
+61
View File
@@ -0,0 +1,61 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
function load(relativePath) {
const file = path.join(__dirname, relativePath)
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText
const module = { exports: {} }
vm.runInNewContext(code, { module, exports: module.exports })
return module.exports
}
const { classifyTimelineEmpty } = load('../src/utils/timelineFailure.ts')
const { classifyLlmKeyFailure } = load('../src/utils/llmFailure.ts')
const { classifySubtitleFailure } = load('../src/utils/subtitleFailure.ts')
// 与 backend empty_timeline_failure().user_message() 一致;CLI 会把它写进 last_error。
const LAST_ERROR = '时间线提取为空:4 个话题在对齐并按时长筛选后没有留下可用片段。 短视频里的片段常被最短时长滤掉(短片约 20 秒起),或模型给出的时间戳对不上字幕。换一条更长、口播更完整的素材后再试。'
const PROGRESS = `处理失败:${LAST_ERROR}`
test('classifies the stable code and a last_error that has no code', () => {
assert.equal(classifyTimelineEmpty('anything', 'timeline_empty'), true)
assert.equal(classifyTimelineEmpty(LAST_ERROR), true)
assert.equal(classifyTimelineEmpty(PROGRESS), true)
assert.equal(classifyTimelineEmpty('时间线为空:没有可用片段。'), true)
})
test('does not treat model, scoring, or subtitle failures as an empty timeline', () => {
assert.equal(classifyTimelineEmpty('没有可用的 LLM 提供商,缺少 API Key。请到「设置 → 模型」点「测试连接」。'), false)
assert.equal(classifyTimelineEmpty('没有片段通过评分筛选。到「设置 → 模型 → 最低评分阈值」调低后重试。'), false)
assert.equal(classifyTimelineEmpty('没有字幕可分析:视频不带字幕。到「设置 → 转写」安装。'), false)
assert.equal(classifyTimelineEmpty(''), false)
assert.equal(classifyTimelineEmpty(null, 'llm_not_configured'), false)
assert.equal(classifyTimelineEmpty(null, 'whisper_not_installed'), false)
})
test('an empty timeline last_error does not open model or transcription settings', () => {
assert.equal(classifyLlmKeyFailure(LAST_ERROR), false)
assert.equal(classifyLlmKeyFailure(LAST_ERROR, 'timeline_empty'), false)
assert.equal(classifyLlmKeyFailure(PROGRESS), false)
assert.equal(classifySubtitleFailure(LAST_ERROR), null)
assert.equal(classifySubtitleFailure(LAST_ERROR, 'timeline_empty'), null)
assert.equal(classifySubtitleFailure(PROGRESS), null)
assert.equal(LAST_ERROR.includes('设置 → 模型'), false)
assert.equal(LAST_ERROR.includes('API Key'), false)
assert.equal(LAST_ERROR.includes('测试连接'), false)
})
test('failure screens do not route an empty timeline to Settings → Model', () => {
const detail = fs.readFileSync(path.join(__dirname, '../src/pages/ProjectDetailPage.tsx'), 'utf8')
const card = fs.readFileSync(path.join(__dirname, '../src/components/ProjectCard.tsx'), 'utf8')
assert.match(detail, /classifyTimelineEmpty/)
assert.match(detail, /!timelineEmpty && classifyLlmKeyFailure/)
assert.match(card, /classifyTimelineEmpty/)
assert.match(card, /!timelineEmpty && classifyLlmKeyFailure/)
})