mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
fix: 时间线为空时不再误导去改模型设置 (#188)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
## [未发布]
|
||||
|
||||
### 修复
|
||||
- 时间线为空时不再误导去改模型设置(Related #182)
|
||||
- 本地导入没有可用字幕时,失败说明会指到「设置 → 转写」,也可以在重新导入时附上 .srt。已经失败、只记下「字幕文件不存在」的项目,打开后同样能看到这条去向(#186)
|
||||
- 桌面端连续查看项目、下载或生成合集时,不再因为数据库连接占满而打不开列表和详情(#175)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: 内容评分
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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('没有可用的语音识别')) {
|
||||
|
||||
@@ -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('时间线为空')
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in New Issue
Block a user