From 42da80003bb507dd49bb4fe056c362a8852df1ab Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 13:56:42 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=BB=BA?= =?UTF-8?q?=E7=AB=8B=E5=AE=A1=E7=89=87=E5=8F=8D=E9=A6=88=E4=B8=8E=20Prompt?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E5=BD=92=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/database.py | 406 +++++++++++++++++- app/models/task.py | 3 + app/services/ai_analysis_workflow_service.py | 96 ++++- app/services/ai_prompt_preset_service.py | 137 +++++- app/services/clip_feedback_service.py | 84 +++- app/services/task_service.py | 165 ++++--- app/static/css/styles.css | 6 - app/static/js/app.js | 80 ++-- app/templates/clip_review.html | 17 +- .../2026-08-28-douyin-content-review.md | 71 +++ tests/test_content_review_foundation.py | 285 ++++++++++++ tests/test_schema_migration_ledger.py | 7 +- tests/test_variety_comedy_selection.py | 2 +- 13 files changed, 1220 insertions(+), 139 deletions(-) create mode 100644 docs/agent_tasks/2026-08-28-douyin-content-review.md create mode 100644 tests/test_content_review_foundation.py diff --git a/app/db/database.py b/app/db/database.py index cfd57c7..c0a06e0 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -62,6 +62,37 @@ f"{TASK_UPLOAD_ONLY_MIGRATION_SQL}" ).encode("utf-8") ).hexdigest() +CONTENT_REVIEW_MIGRATION_VERSION = "20260828_01_content_review_v1" +CONTENT_REVIEW_MIGRATION_NAME = "内容复盘归因与指标快照基础结构" +CONTENT_REVIEW_REQUIRED_INDEXES = ( + "idx_clip_candidates_source_analysis_run", + "idx_ai_analysis_runs_prompt_version", + "idx_clip_feedback_candidate_created", + "idx_content_metric_import_batches_account_created", + "idx_douyin_account_daily_account_date", + "idx_douyin_item_metrics_account_published", + "idx_douyin_item_metrics_match_status", +) +CONTENT_REVIEW_MIGRATION_SPEC = "\n".join( + ( + CONTENT_REVIEW_MIGRATION_VERSION, + CONTENT_REVIEW_MIGRATION_NAME, + "clip_candidates.source_analysis_run_id", + "ai_analysis_runs.prompt_version_id", + "ai_analysis_runs.prompt_text_sha256", + "clip_feedback.decision_source", + "ai_prompt_versions", + "content_metric_import_batches", + "douyin_account_daily_metric_snapshots", + "douyin_item_metric_snapshots", + *CONTENT_REVIEW_REQUIRED_INDEXES, + "backfill-candidate-only-when-one-analysis-run", + "do-not-guess-historical-prompt-version", + ) +) +CONTENT_REVIEW_MIGRATION_CHECKSUM = hashlib.sha256( + CONTENT_REVIEW_MIGRATION_SPEC.encode("utf-8") +).hexdigest() class SchemaMigrationError(RuntimeError): @@ -101,6 +132,7 @@ def init_db() -> None: needs_subtitle_auto_backup = _requires_subtitle_auto_schema_migration(settings.database_path) needs_publish_index_backup = _requires_publish_active_index_migration(settings.database_path) needs_task_upload_only_backup = _requires_task_upload_only_migration(settings.database_path) + needs_content_review_backup = _requires_content_review_schema_migration(settings.database_path) if needs_long_live_backup: create_schema_migration_backup( settings.database_path, @@ -144,6 +176,12 @@ def init_db() -> None: settings.data_dir / "backups", "task-upload-only", ) + if needs_content_review_backup: + create_schema_migration_backup( + settings.database_path, + settings.data_dir / "backups", + "content-review-v1", + ) with get_connection() as connection: connection.executescript( @@ -211,6 +249,7 @@ def init_db() -> None: selected_by_default INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, reviewed INTEGER NOT NULL DEFAULT 0, + source_analysis_run_id TEXT, is_deleted INTEGER NOT NULL DEFAULT 0, deleted_at TEXT, created_at TEXT NOT NULL, @@ -247,6 +286,19 @@ def init_db() -> None: updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS ai_prompt_versions ( + id TEXT PRIMARY KEY, + preset_id TEXT NOT NULL, + version_number INTEGER NOT NULL, + preset_name_snapshot TEXT NOT NULL, + prompt_text TEXT NOT NULL, + prompt_sha256 TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(preset_id, version_number), + UNIQUE(preset_id, prompt_sha256), + FOREIGN KEY(preset_id) REFERENCES ai_prompt_presets(id) + ); + CREATE TABLE IF NOT EXISTS ai_analysis_runs ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, @@ -256,13 +308,16 @@ def init_db() -> None: model TEXT NOT NULL, ai_prompt_preset_id TEXT, ai_prompt_preset_name TEXT, + prompt_version_id TEXT, + prompt_text_sha256 TEXT, requested_clip_count INTEGER NOT NULL DEFAULT 5, clip_count INTEGER NOT NULL DEFAULT 0, analysis_summary TEXT, fallback_notice TEXT, analysis_payload_json TEXT NOT NULL, created_at TEXT NOT NULL, - FOREIGN KEY(task_id) REFERENCES tasks(id) + FOREIGN KEY(task_id) REFERENCES tasks(id), + FOREIGN KEY(prompt_version_id) REFERENCES ai_prompt_versions(id) ); CREATE TABLE IF NOT EXISTS subtitle_style_presets ( @@ -600,6 +655,7 @@ def init_db() -> None: selection_profile TEXT NOT NULL DEFAULT 'general', decision TEXT NOT NULL, reason_code TEXT NOT NULL, + decision_source TEXT NOT NULL DEFAULT 'explicit_feedback', note TEXT, title_snapshot TEXT, summary_snapshot TEXT, @@ -609,6 +665,75 @@ def init_db() -> None: FOREIGN KEY(task_id) REFERENCES tasks(id), FOREIGN KEY(analysis_run_id) REFERENCES ai_analysis_runs(id) ); + + CREATE TABLE IF NOT EXISTS content_metric_import_batches ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_filename TEXT NOT NULL, + source_sha256 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'previewed', + period_start TEXT, + period_end TEXT, + normalized_payload_json TEXT NOT NULL DEFAULT '[]', + row_count INTEGER NOT NULL DEFAULT 0, + matched_count INTEGER NOT NULL DEFAULT 0, + ambiguous_count INTEGER NOT NULL DEFAULT 0, + invalid_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + committed_at TEXT, + expires_at TEXT, + UNIQUE(account_id, source_kind, source_sha256), + FOREIGN KEY(account_id) REFERENCES publish_accounts(id) + ); + + CREATE TABLE IF NOT EXISTS douyin_account_daily_metric_snapshots ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + account_id TEXT NOT NULL, + metric_date TEXT NOT NULL, + post_count INTEGER NOT NULL DEFAULT 0, + play_count INTEGER NOT NULL DEFAULT 0, + like_count INTEGER NOT NULL DEFAULT 0, + share_count INTEGER NOT NULL DEFAULT 0, + comment_count INTEGER NOT NULL DEFAULT 0, + five_second_completion_rate REAL, + two_second_bounce_rate REAL, + cover_click_rate REAL, + average_watch_seconds REAL, + created_at TEXT NOT NULL, + UNIQUE(batch_id, metric_date), + FOREIGN KEY(batch_id) REFERENCES content_metric_import_batches(id) ON DELETE CASCADE, + FOREIGN KEY(account_id) REFERENCES publish_accounts(id) + ); + + CREATE TABLE IF NOT EXISTS douyin_item_metric_snapshots ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + publish_job_id TEXT, + account_id TEXT NOT NULL, + aweme_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + published_at TEXT, + duration_seconds REAL, + captured_at TEXT NOT NULL, + play_count INTEGER, + like_count INTEGER, + comment_count INTEGER, + share_count INTEGER, + collect_count INTEGER, + five_second_completion_rate REAL, + two_second_bounce_rate REAL, + cover_click_rate REAL, + average_watch_seconds REAL, + match_status TEXT NOT NULL DEFAULT 'unmatched', + match_method TEXT, + created_at TEXT NOT NULL, + UNIQUE(batch_id, aweme_id), + FOREIGN KEY(batch_id) REFERENCES content_metric_import_batches(id) ON DELETE CASCADE, + FOREIGN KEY(publish_job_id) REFERENCES publish_jobs(id), + FOREIGN KEY(account_id) REFERENCES publish_accounts(id) + ); """ ) _migrate_tasks_table(connection) @@ -774,6 +899,51 @@ def _requires_task_upload_only_migration(database_path) -> bool: connection.close() +def _requires_content_review_schema_migration(database_path) -> bool: + """已有库缺少内容复盘基础结构时,账本迁移前先创建可恢复备份。""" + if not database_path.exists() or database_path.stat().st_size == 0: + return False + connection = None + try: + connection = sqlite3.connect(f"{database_path.resolve().as_uri()}?mode=ro", uri=True, timeout=10) + table_names = { + row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + if "clip_candidates" not in table_names: + return False + clip_columns = {row[1] for row in connection.execute("PRAGMA table_info(clip_candidates)").fetchall()} + run_columns = {row[1] for row in connection.execute("PRAGMA table_info(ai_analysis_runs)").fetchall()} + feedback_columns = {row[1] for row in connection.execute("PRAGMA table_info(clip_feedback)").fetchall()} + index_names = { + row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='index'").fetchall() + } + ledger_row = None + if "schema_migrations" in table_names: + try: + ledger_row = connection.execute( + "SELECT 1 FROM schema_migrations WHERE version = ? AND checksum = ?", + (CONTENT_REVIEW_MIGRATION_VERSION, CONTENT_REVIEW_MIGRATION_CHECKSUM), + ).fetchone() + except sqlite3.Error: + return True + return ( + "source_analysis_run_id" not in clip_columns + or not {"prompt_version_id", "prompt_text_sha256"} <= run_columns + or "decision_source" not in feedback_columns + or not { + "ai_prompt_versions", + "content_metric_import_batches", + "douyin_account_daily_metric_snapshots", + "douyin_item_metric_snapshots", + } <= table_names + or not set(CONTENT_REVIEW_REQUIRED_INDEXES) <= index_names + or ledger_row is None + ) + finally: + if connection is not None: + connection.close() + + def _get_table_columns(connection: sqlite3.Connection, table_name: str) -> set[str]: rows = connection.execute(f"PRAGMA table_info({table_name})").fetchall() return {row["name"] for row in rows} @@ -927,6 +1097,233 @@ def _verify_task_upload_only_migration(connection: sqlite3.Connection) -> None: raise SchemaMigrationError("仍存在未归一化的 NAS 视频来源记录") +def _apply_content_review_migration(connection: sqlite3.Connection) -> None: + clip_columns = _get_table_columns(connection, "clip_candidates") + if "source_analysis_run_id" not in clip_columns: + connection.execute("ALTER TABLE clip_candidates ADD COLUMN source_analysis_run_id TEXT") + + run_columns = _get_table_columns(connection, "ai_analysis_runs") + if "prompt_version_id" not in run_columns: + connection.execute("ALTER TABLE ai_analysis_runs ADD COLUMN prompt_version_id TEXT") + if "prompt_text_sha256" not in run_columns: + connection.execute("ALTER TABLE ai_analysis_runs ADD COLUMN prompt_text_sha256 TEXT") + + feedback_columns = _get_table_columns(connection, "clip_feedback") + if "decision_source" not in feedback_columns: + connection.execute( + "ALTER TABLE clip_feedback ADD COLUMN decision_source " + "TEXT NOT NULL DEFAULT 'explicit_feedback'" + ) + + schema_sql = """ + CREATE TABLE IF NOT EXISTS ai_prompt_versions ( + id TEXT PRIMARY KEY, + preset_id TEXT NOT NULL, + version_number INTEGER NOT NULL, + preset_name_snapshot TEXT NOT NULL, + prompt_text TEXT NOT NULL, + prompt_sha256 TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(preset_id, version_number), + UNIQUE(preset_id, prompt_sha256), + FOREIGN KEY(preset_id) REFERENCES ai_prompt_presets(id) + ); + + CREATE TABLE IF NOT EXISTS content_metric_import_batches ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_filename TEXT NOT NULL, + source_sha256 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'previewed', + period_start TEXT, + period_end TEXT, + normalized_payload_json TEXT NOT NULL DEFAULT '[]', + row_count INTEGER NOT NULL DEFAULT 0, + matched_count INTEGER NOT NULL DEFAULT 0, + ambiguous_count INTEGER NOT NULL DEFAULT 0, + invalid_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + committed_at TEXT, + expires_at TEXT, + UNIQUE(account_id, source_kind, source_sha256), + FOREIGN KEY(account_id) REFERENCES publish_accounts(id) + ); + + CREATE TABLE IF NOT EXISTS douyin_account_daily_metric_snapshots ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + account_id TEXT NOT NULL, + metric_date TEXT NOT NULL, + post_count INTEGER NOT NULL DEFAULT 0, + play_count INTEGER NOT NULL DEFAULT 0, + like_count INTEGER NOT NULL DEFAULT 0, + share_count INTEGER NOT NULL DEFAULT 0, + comment_count INTEGER NOT NULL DEFAULT 0, + five_second_completion_rate REAL, + two_second_bounce_rate REAL, + cover_click_rate REAL, + average_watch_seconds REAL, + created_at TEXT NOT NULL, + UNIQUE(batch_id, metric_date), + FOREIGN KEY(batch_id) REFERENCES content_metric_import_batches(id) ON DELETE CASCADE, + FOREIGN KEY(account_id) REFERENCES publish_accounts(id) + ); + + CREATE TABLE IF NOT EXISTS douyin_item_metric_snapshots ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + publish_job_id TEXT, + account_id TEXT NOT NULL, + aweme_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + published_at TEXT, + duration_seconds REAL, + captured_at TEXT NOT NULL, + play_count INTEGER, + like_count INTEGER, + comment_count INTEGER, + share_count INTEGER, + collect_count INTEGER, + five_second_completion_rate REAL, + two_second_bounce_rate REAL, + cover_click_rate REAL, + average_watch_seconds REAL, + match_status TEXT NOT NULL DEFAULT 'unmatched', + match_method TEXT, + created_at TEXT NOT NULL, + UNIQUE(batch_id, aweme_id), + FOREIGN KEY(batch_id) REFERENCES content_metric_import_batches(id) ON DELETE CASCADE, + FOREIGN KEY(publish_job_id) REFERENCES publish_jobs(id), + FOREIGN KEY(account_id) REFERENCES publish_accounts(id) + ); + + CREATE INDEX IF NOT EXISTS idx_clip_candidates_source_analysis_run + ON clip_candidates(source_analysis_run_id); + CREATE INDEX IF NOT EXISTS idx_ai_analysis_runs_prompt_version + ON ai_analysis_runs(prompt_version_id); + CREATE INDEX IF NOT EXISTS idx_clip_feedback_candidate_created + ON clip_feedback(clip_candidate_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_content_metric_import_batches_account_created + ON content_metric_import_batches(account_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_douyin_account_daily_account_date + ON douyin_account_daily_metric_snapshots(account_id, metric_date DESC); + CREATE INDEX IF NOT EXISTS idx_douyin_item_metrics_account_published + ON douyin_item_metric_snapshots(account_id, published_at DESC); + CREATE INDEX IF NOT EXISTS idx_douyin_item_metrics_match_status + ON douyin_item_metric_snapshots(match_status, created_at DESC); + """ + # sqlite3.executescript() 会隐式提交,账本迁移必须逐条执行以保持同一事务。 + for statement in schema_sql.split(";"): + normalized = statement.strip() + if normalized: + connection.execute(normalized) + + now = datetime.now().astimezone().isoformat(timespec="seconds") + presets = connection.execute( + "SELECT id, name, prompt_text FROM ai_prompt_presets ORDER BY slot" + ).fetchall() + for preset in presets: + prompt_text = str(preset["prompt_text"] or "").strip() + prompt_sha256 = hashlib.sha256(prompt_text.encode("utf-8")).hexdigest() + existing = connection.execute( + "SELECT 1 FROM ai_prompt_versions WHERE preset_id = ? AND prompt_sha256 = ?", + (preset["id"], prompt_sha256), + ).fetchone() + if existing is not None: + continue + version_number = int( + connection.execute( + "SELECT COALESCE(MAX(version_number), 0) + 1 FROM ai_prompt_versions WHERE preset_id = ?", + (preset["id"],), + ).fetchone()[0] + ) + version_id = f"promptv_{preset['id']}_{version_number:03d}" + connection.execute( + """ + INSERT INTO ai_prompt_versions ( + id, preset_id, version_number, preset_name_snapshot, + prompt_text, prompt_sha256, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + version_id, + preset["id"], + version_number, + str(preset["name"] or "未命名方案"), + prompt_text, + prompt_sha256, + now, + ), + ) + + connection.execute( + """ + UPDATE clip_candidates + SET source_analysis_run_id = ( + SELECT MIN(r.id) FROM ai_analysis_runs r WHERE r.task_id = clip_candidates.task_id + ) + WHERE source_analysis_run_id IS NULL + AND 1 = ( + SELECT COUNT(*) FROM ai_analysis_runs r WHERE r.task_id = clip_candidates.task_id + ) + """ + ) + + +def _verify_content_review_migration(connection: sqlite3.Connection) -> None: + required_columns = { + "clip_candidates": {"source_analysis_run_id"}, + "ai_analysis_runs": {"prompt_version_id", "prompt_text_sha256"}, + "clip_feedback": {"decision_source"}, + } + for table_name, expected in required_columns.items(): + missing = expected - _get_table_columns(connection, table_name) + if missing: + raise SchemaMigrationError( + f"内容复盘迁移后的 {table_name} 缺少字段:{', '.join(sorted(missing))}" + ) + + required_tables = { + "ai_prompt_versions", + "content_metric_import_batches", + "douyin_account_daily_metric_snapshots", + "douyin_item_metric_snapshots", + } + actual_tables = { + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() + } + missing_tables = sorted(required_tables - actual_tables) + if missing_tables: + raise SchemaMigrationError("内容复盘迁移缺少数据表:" + ", ".join(missing_tables)) + + actual_indexes = { + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'index'").fetchall() + } + missing_indexes = sorted(set(CONTENT_REVIEW_REQUIRED_INDEXES) - actual_indexes) + if missing_indexes: + raise SchemaMigrationError("内容复盘迁移缺少索引:" + ", ".join(missing_indexes)) + + stale_candidates = connection.execute( + """ + SELECT 1 + FROM clip_candidates c + WHERE c.source_analysis_run_id IS NULL + AND 1 = (SELECT COUNT(*) FROM ai_analysis_runs r WHERE r.task_id = c.task_id) + LIMIT 1 + """ + ).fetchone() + if stale_candidates is not None: + raise SchemaMigrationError("存在可唯一归因但尚未关联 AI 分析记录的历史候选片段") + + for row in connection.execute("SELECT id, prompt_text, prompt_sha256 FROM ai_prompt_versions"): + actual_hash = hashlib.sha256(str(row["prompt_text"] or "").encode("utf-8")).hexdigest() + if actual_hash != row["prompt_sha256"]: + raise SchemaMigrationError(f"Prompt 版本 {row['id']} 的 SHA-256 校验失败") + + def _registered_schema_migrations() -> tuple[SchemaMigration, ...]: return ( SchemaMigration( @@ -943,6 +1340,13 @@ def _registered_schema_migrations() -> tuple[SchemaMigration, ...]: apply=_apply_task_upload_only_migration, verify=_verify_task_upload_only_migration, ), + SchemaMigration( + version=CONTENT_REVIEW_MIGRATION_VERSION, + name=CONTENT_REVIEW_MIGRATION_NAME, + checksum=CONTENT_REVIEW_MIGRATION_CHECKSUM, + apply=_apply_content_review_migration, + verify=_verify_content_review_migration, + ), ) diff --git a/app/models/task.py b/app/models/task.py index 7a2c197..e6e0350 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -396,6 +396,9 @@ class ClipCandidateUpdate(BaseModel): end_time: str = Field(..., min_length=1, max_length=16) enabled: bool = True summary: Optional[str] = Field(default=None, max_length=1000) + feedback_reason_code: Optional[ + Literal["not_funny", "fragmented", "missing_setup", "duplicate", "dragging", "other"] + ] = None class ClipCandidateBatchItem(ClipCandidateUpdate): diff --git a/app/services/ai_analysis_workflow_service.py b/app/services/ai_analysis_workflow_service.py index d135b87..b3679ed 100644 --- a/app/services/ai_analysis_workflow_service.py +++ b/app/services/ai_analysis_workflow_service.py @@ -29,7 +29,7 @@ ) from app.services.ai.diagnostics import ensure_local_ai_ready from app.services.ai.base import AIProviderError -from app.services.ai_prompt_preset_service import get_task_ai_prompt_preset +from app.services.ai_prompt_preset_service import get_task_ai_prompt_preset, get_task_ai_prompt_snapshot from app.services.storage_service import get_artifact_paths from app.services.task_log_service import append_task_log, read_task_log_tail @@ -319,16 +319,33 @@ def _clear_clip_candidates(task_id: str) -> None: connection.commit() -def _insert_clip_candidates(task_id: str, clips: list[dict]) -> None: +def _insert_clip_candidates( + task_id: str, + clips: list[dict], + source_analysis_run_id: str | None = None, +) -> None: from app.services.task_service import _now_iso now = _now_iso() with get_connection() as connection: - _insert_clip_candidates_with_connection(connection, task_id, clips, now) + _insert_clip_candidates_with_connection( + connection, + task_id, + clips, + now, + source_analysis_run_id=source_analysis_run_id, + ) connection.commit() -def _insert_clip_candidates_with_connection(connection, task_id: str, clips: list[dict], now: str) -> None: +def _insert_clip_candidates_with_connection( + connection, + task_id: str, + clips: list[dict], + now: str, + *, + source_analysis_run_id: str | None = None, +) -> None: for index, clip in enumerate(clips, start=1): clip_key = str(clip["clip_id"]) database_id = f"{task_id}_{clip_key}"[:120] @@ -341,9 +358,10 @@ def _insert_clip_candidates_with_connection(connection, task_id: str, clips: lis confidence_score, quality_tier, quality_score, text_quality_score, humor_score, completeness_score, audio_reaction_score, topic_key, key_moment_time, quality_evidence_json, rejection_reason, - selected_by_default, enabled, reviewed, created_at, updated_at + selected_by_default, enabled, reviewed, source_analysis_run_id, + created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( database_id or f"{task_id}_clip_{index:03d}", @@ -373,20 +391,31 @@ def _insert_clip_candidates_with_connection(connection, task_id: str, clips: lis 1 if selected_by_default else 0, 1 if selected_by_default else 0, 0, + source_analysis_run_id, now, now, ), ) -def _replace_clip_candidates(task_id: str, clips: list[dict]) -> None: +def _replace_clip_candidates( + task_id: str, + clips: list[dict], + source_analysis_run_id: str | None = None, +) -> None: """在同一个事务里替换候选片段,失败时保留原结果。""" from app.services.task_service import _now_iso now = _now_iso() with get_connection() as connection: connection.execute("BEGIN IMMEDIATE") - _replace_clip_candidates_with_connection(connection, task_id, clips, now) + _replace_clip_candidates_with_connection( + connection, + task_id, + clips, + now, + source_analysis_run_id=source_analysis_run_id, + ) connection.commit() @@ -395,6 +424,8 @@ def _replace_clip_candidates_with_connection( task_id: str, clips: list[dict], now: str, + *, + source_analysis_run_id: str | None = None, ) -> None: referenced = connection.execute( """ @@ -411,7 +442,13 @@ def _replace_clip_candidates_with_connection( "请在片段审核页修改现有候选并重新切片。" ) connection.execute("DELETE FROM clip_candidates WHERE task_id = ?", (task_id,)) - _insert_clip_candidates_with_connection(connection, task_id, clips, now) + _insert_clip_candidates_with_connection( + connection, + task_id, + clips, + now, + source_analysis_run_id=source_analysis_run_id, + ) def _assert_ai_task_can_start(connection, task_id: str, current_status: str, *, current_job_id: str = "") -> None: @@ -702,6 +739,8 @@ def _analysis_run_row_to_dict(row: Row, include_payload: bool = False) -> dict: "model": run.get("model") or "", "ai_prompt_preset_id": run.get("ai_prompt_preset_id") or "", "ai_prompt_preset_name": run.get("ai_prompt_preset_name") or "", + "prompt_version_id": run.get("prompt_version_id") or "", + "prompt_text_sha256": run.get("prompt_text_sha256") or "", "requested_clip_count": int(run.get("requested_clip_count") or 0), "clip_count": int(run.get("clip_count") or 0), "analysis_summary": run.get("analysis_summary") or "", @@ -736,6 +775,8 @@ def _analysis_payload_to_preview(task_id: str, payload: dict, fallback: dict | N "model": model, "ai_prompt_preset_id": fallback.get("ai_prompt_preset_id") or "", "ai_prompt_preset_name": fallback.get("ai_prompt_preset_name") or "", + "prompt_version_id": meta.get("prompt_version_id") or fallback.get("prompt_version_id") or "", + "prompt_text_sha256": meta.get("prompt_sha256") or fallback.get("prompt_text_sha256") or "", "requested_clip_count": int(fallback.get("requested_clip_count") or len(clips)), "clip_count": len(clips), "analysis_summary": payload.get("analysis_summary") or fallback.get("analysis_summary") or "", @@ -842,8 +883,9 @@ def _insert_ai_analysis_run_with_connection( prompt_preset: dict, requested_clip_count: int, now: str, + run_id: str | None = None, ) -> str: - run_id = uuid4().hex[:12] + run_id = run_id or uuid4().hex[:12] clips = analysis_payload.get("clips") or [] run_number = _next_ai_analysis_run_number(connection, task_id) connection.execute("UPDATE ai_analysis_runs SET is_active = 0 WHERE task_id = ?", (task_id,)) @@ -851,11 +893,12 @@ def _insert_ai_analysis_run_with_connection( """ INSERT INTO ai_analysis_runs ( id, task_id, run_number, provider, provider_label, model, - ai_prompt_preset_id, ai_prompt_preset_name, requested_clip_count, + ai_prompt_preset_id, ai_prompt_preset_name, + prompt_version_id, prompt_text_sha256, requested_clip_count, clip_count, analysis_summary, fallback_notice, analysis_payload_json, is_active, created_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( run_id, @@ -866,6 +909,8 @@ def _insert_ai_analysis_run_with_connection( model, prompt_preset.get("id") or "", prompt_preset.get("name") or "", + prompt_preset.get("prompt_version_id"), + prompt_preset.get("prompt_sha256"), requested_clip_count, len(clips), analysis_payload.get("analysis_summary") or "", @@ -1045,6 +1090,7 @@ def restore_ai_analysis_run(task_id: str, run_id: str) -> dict: task_id, payload.get("clips") or [], now, + source_analysis_run_id=run_id, ) connection.execute("UPDATE ai_analysis_runs SET is_active = 0 WHERE task_id = ?", (task_id,)) cursor = connection.execute( @@ -1086,8 +1132,13 @@ def restore_ai_analysis_run(task_id: str, run_id: str) -> dict: # ---------- AI 分析核心流程 ---------- -def _analyze_with_provider(task_id: str, task: dict, paths: dict[str, Path], provider_name: str): - prompt_preset = get_task_ai_prompt_preset(task_id) +def _analyze_with_provider( + task_id: str, + task: dict, + paths: dict[str, Path], + provider_name: str, + prompt_preset: dict, +): prompt_template = (prompt_preset.get("prompt_text") or "").strip() if not prompt_template: raise AIAnalysisError(f"当前选择的 AI Prompt 方案\"{prompt_preset.get('name')}\"还没有填写 Prompt 内容") @@ -1212,6 +1263,7 @@ def _commit_ai_analysis_result( from app.services.task_service import STATUS_PROGRESS, _now_iso now = _now_iso() + run_id = uuid4().hex[:12] with get_connection() as connection: connection.execute("BEGIN IMMEDIATE") try: @@ -1237,8 +1289,9 @@ def _commit_ai_analysis_result( task_id, analysis_payload.get("clips") or [], now, + source_analysis_run_id=run_id, ) - run_id = _insert_ai_analysis_run_with_connection( + _insert_ai_analysis_run_with_connection( connection, task_id=task_id, analysis_payload=analysis_payload, @@ -1249,6 +1302,7 @@ def _commit_ai_analysis_result( prompt_preset=prompt_preset, requested_clip_count=requested_clip_count, now=now, + run_id=run_id, ) cursor = connection.execute( """ @@ -1391,8 +1445,15 @@ def process_task_ai_analysis(task_id: str, provider: str | None = None) -> dict: try: if not paths["transcript_path"].exists(): raise AIAnalysisError("请先生成带时间戳的转写 Markdown,再开始 AI 分析") + prompt_preset = get_task_ai_prompt_snapshot(task_id) try: - analysis = _analyze_with_provider(task_id, task, paths, provider_name) + analysis = _analyze_with_provider( + task_id, + task, + paths, + provider_name, + prompt_preset, + ) except Exception as provider_exc: provider_error = ( provider_exc.checkpoint_message() @@ -1422,9 +1483,10 @@ def process_task_ai_analysis(task_id: str, provider: str | None = None) -> dict: "final_clip_target": int(task.get("final_clip_target") or 5), "generated_at": _now_iso(), "workflow_job_id": str(job["id"]), + "prompt_version_id": prompt_preset.get("prompt_version_id"), + "prompt_sha256": prompt_preset.get("prompt_sha256"), **long_live_meta, } - prompt_preset = get_task_ai_prompt_preset(task_id) provider_label = _ai_provider_label(used_provider) model_name = _ai_model_name(used_provider) analysis_run = _commit_ai_analysis_result( diff --git a/app/services/ai_prompt_preset_service.py b/app/services/ai_prompt_preset_service.py index 6085409..b588fa7 100644 --- a/app/services/ai_prompt_preset_service.py +++ b/app/services/ai_prompt_preset_service.py @@ -1,3 +1,4 @@ +import hashlib from datetime import datetime from app.db.database import DEFAULT_AI_PROMPT_PRESET_ID, get_connection @@ -8,6 +9,69 @@ def _now_iso() -> str: return datetime.now().isoformat(timespec="seconds") +def _prompt_sha256(prompt_text: str) -> str: + return hashlib.sha256((prompt_text or "").strip().encode("utf-8")).hexdigest() + + +def ensure_ai_prompt_version_with_connection( + connection, + *, + preset_id: str, + preset_name: str, + prompt_text: str, + now: str | None = None, +) -> dict: + """按 Prompt 实际内容复用或创建不可变版本;仅改名称不会增加版本号。""" + normalized_prompt = (prompt_text or "").strip() + prompt_sha256 = _prompt_sha256(normalized_prompt) + existing = connection.execute( + """ + SELECT id, preset_id, version_number, preset_name_snapshot, + prompt_text, prompt_sha256, created_at + FROM ai_prompt_versions + WHERE preset_id = ? AND prompt_sha256 = ? + """, + (preset_id, prompt_sha256), + ).fetchone() + if existing is not None: + return dict(existing) + + version_number = int( + connection.execute( + "SELECT COALESCE(MAX(version_number), 0) + 1 FROM ai_prompt_versions WHERE preset_id = ?", + (preset_id,), + ).fetchone()[0] + ) + version_id = f"promptv_{preset_id}_{version_number:03d}" + created_at = now or _now_iso() + connection.execute( + """ + INSERT INTO ai_prompt_versions ( + id, preset_id, version_number, preset_name_snapshot, + prompt_text, prompt_sha256, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + version_id, + preset_id, + version_number, + (preset_name or "未命名方案").strip() or "未命名方案", + normalized_prompt, + prompt_sha256, + created_at, + ), + ) + return { + "id": version_id, + "preset_id": preset_id, + "version_number": version_number, + "preset_name_snapshot": (preset_name or "未命名方案").strip() or "未命名方案", + "prompt_text": normalized_prompt, + "prompt_sha256": prompt_sha256, + "created_at": created_at, + } + + def list_ai_prompt_presets() -> list[dict]: with get_connection() as connection: rows = connection.execute( @@ -50,6 +114,7 @@ def update_ai_prompt_preset(preset_id: str, payload: AIPromptPresetUpdate) -> di normalized_name = payload.name.strip() or "未命名方案" prompt_text = payload.prompt_text.strip() with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") cursor = connection.execute( """ UPDATE ai_prompt_presets @@ -58,9 +123,17 @@ def update_ai_prompt_preset(preset_id: str, payload: AIPromptPresetUpdate) -> di """, (normalized_name, prompt_text, now, preset_id), ) + if cursor.rowcount == 0: + connection.rollback() + raise ValueError("AI Prompt 方案不存在") + ensure_ai_prompt_version_with_connection( + connection, + preset_id=preset_id, + preset_name=normalized_name, + prompt_text=prompt_text, + now=now, + ) connection.commit() - if cursor.rowcount == 0: - raise ValueError("AI Prompt 方案不存在") return { "status": "ok", "message": "AI Prompt 方案已保存。", @@ -117,6 +190,66 @@ def get_task_ai_prompt_preset(task_id: str) -> dict: return fallback +def get_task_ai_prompt_snapshot(task_id: str) -> dict: + """一次读取任务 Prompt 并绑定不可变版本,供整次 AI 分析复用。""" + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + """ + SELECT p.id, p.slot, p.name, p.prompt_text, p.is_default, + p.created_at, p.updated_at + FROM tasks t + LEFT JOIN ai_prompt_presets p ON p.id = t.ai_prompt_preset_id + WHERE t.id = ? + """, + (task_id,), + ).fetchone() + if row is None: + connection.rollback() + raise ValueError("任务不存在") + if not row["id"]: + row = connection.execute( + """ + SELECT id, slot, name, prompt_text, is_default, created_at, updated_at + FROM ai_prompt_presets WHERE id = ? + """, + (DEFAULT_AI_PROMPT_PRESET_ID,), + ).fetchone() + if row is None: + connection.rollback() + raise ValueError("默认 AI Prompt 方案不存在") + + preset = dict(row) + version = ensure_ai_prompt_version_with_connection( + connection, + preset_id=str(preset["id"]), + preset_name=str(preset["name"] or ""), + prompt_text=str(preset["prompt_text"] or ""), + now=now, + ) + connection.commit() + preset["is_default"] = bool(preset["is_default"]) + preset["prompt_preview"] = _prompt_preview(preset["prompt_text"]) + preset["prompt_version_id"] = version["id"] + preset["prompt_version_number"] = version["version_number"] + preset["prompt_sha256"] = version["prompt_sha256"] + return preset + + +def list_ai_prompt_versions() -> list[dict]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, preset_id, version_number, preset_name_snapshot, + prompt_sha256, created_at + FROM ai_prompt_versions + ORDER BY preset_id, version_number DESC + """ + ).fetchall() + return [dict(row) for row in rows] + + def _prompt_preview(prompt_text: str) -> str: compact = " ".join((prompt_text or "").split()) if len(compact) <= 80: diff --git a/app/services/clip_feedback_service.py b/app/services/clip_feedback_service.py index ef7ef63..f8687e0 100644 --- a/app/services/clip_feedback_service.py +++ b/app/services/clip_feedback_service.py @@ -15,14 +15,69 @@ FEEDBACK_REASON_LABELS = { "worth_publishing": "值得发", "not_funny": "不好笑", - "fragmented": "内容太碎", - "missing_setup": "铺垫不足", + "fragmented": "片段不完整", + "missing_setup": "铺垫缺失", "duplicate": "内容重复", "dragging": "节奏拖沓", "other": "其他", } +def record_review_toggle_feedback_with_connection( + connection, + *, + task_id: str, + clip: dict, + selection_profile: str, + enabled: bool, + reason_code: str | None, + now: str, +) -> bool: + """在候选保存事务中记录开关反馈;相同状态和原因不会重复写入。""" + decision = "keep" if enabled else "reject" + normalized_reason = "worth_publishing" if enabled else (reason_code or "other") + latest = connection.execute( + """ + SELECT decision, reason_code + FROM clip_feedback + WHERE task_id = ? AND clip_candidate_id = ? + ORDER BY created_at DESC, rowid DESC + LIMIT 1 + """, + (task_id, clip["id"]), + ).fetchone() + if latest is not None and ( + str(latest["decision"] or "") == decision + and str(latest["reason_code"] or "") == normalized_reason + ): + return False + + connection.execute( + """ + INSERT INTO clip_feedback ( + id, task_id, clip_candidate_id, analysis_run_id, selection_profile, + decision, reason_code, decision_source, note, title_snapshot, + summary_snapshot, start_time, end_time, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'review_toggle', '', ?, ?, ?, ?, ?) + """, + ( + uuid4().hex[:12], + task_id, + clip["id"], + clip.get("source_analysis_run_id"), + selection_profile or "general", + decision, + normalized_reason, + clip.get("title") or "", + clip.get("summary") or "", + clip.get("start_time") or "", + clip.get("end_time") or "", + now, + ), + ) + return True + + def save_clip_feedback(task_id: str, clip_id: str, payload: ClipFeedbackCreate) -> dict: from app.services.task_service import _now_iso, get_clip_candidate, get_task # noqa: F811 @@ -45,9 +100,9 @@ def save_clip_feedback(task_id: str, clip_id: str, payload: ClipFeedbackCreate) """ INSERT INTO clip_feedback ( id, task_id, clip_candidate_id, analysis_run_id, selection_profile, - decision, reason_code, note, title_snapshot, summary_snapshot, + decision, reason_code, decision_source, note, title_snapshot, summary_snapshot, start_time, end_time, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'explicit_feedback', ?, ?, ?, ?, ?, ?) """, ( uuid4().hex[:12], @@ -93,9 +148,17 @@ def list_recent_feedback_context(selection_profile: str, limit: int = 20) -> lis rows = connection.execute( """ SELECT decision, reason_code, note, title_snapshot, summary_snapshot, - start_time, end_time, created_at - FROM clip_feedback - WHERE selection_profile = ? + start_time, end_time, created_at, decision_source + FROM ( + SELECT f.*, + ROW_NUMBER() OVER ( + PARTITION BY f.task_id, f.clip_candidate_id + ORDER BY f.created_at DESC, f.rowid DESC + ) AS feedback_rank + FROM clip_feedback f + WHERE f.selection_profile = ? + ) + WHERE feedback_rank = 1 ORDER BY created_at DESC LIMIT ? """, @@ -104,4 +167,9 @@ def list_recent_feedback_context(selection_profile: str, limit: int = 20) -> lis return [dict(row) for row in rows] -__all__ = ["FEEDBACK_REASON_LABELS", "list_recent_feedback_context", "save_clip_feedback"] +__all__ = [ + "FEEDBACK_REASON_LABELS", + "list_recent_feedback_context", + "record_review_toggle_feedback_with_connection", + "save_clip_feedback", +] diff --git a/app/services/task_service.py b/app/services/task_service.py index 8805e82..a4ae503 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -43,7 +43,10 @@ queue_task_ai_analysis, restore_ai_analysis_run, ) -from app.services.clip_feedback_service import save_clip_feedback +from app.services.clip_feedback_service import ( + record_review_toggle_feedback_with_connection, + save_clip_feedback, +) from app.services.storage_service import ( get_artifact_paths, get_source_video_path, @@ -897,14 +900,21 @@ def list_clip_candidates(task_id: str) -> list[dict]: with get_connection() as connection: rows = connection.execute( """ - SELECT id, task_id, clip_key, title, start_time, end_time, duration_seconds, cover_time_seconds, + SELECT c.id, c.task_id, c.clip_key, c.title, c.start_time, c.end_time, + c.duration_seconds, c.cover_time_seconds, summary, reason, highlight_reason, spread_value, suggested_editing, confidence_score, quality_tier, quality_score, text_quality_score, humor_score, completeness_score, audio_reaction_score, topic_key, key_moment_time, quality_evidence_json, rejection_reason, - selected_by_default, enabled, reviewed, is_deleted, deleted_at, created_at, updated_at - FROM clip_candidates - WHERE task_id = ? AND is_deleted = 0 - ORDER BY start_time ASC + selected_by_default, enabled, reviewed, source_analysis_run_id, + is_deleted, deleted_at, c.created_at, c.updated_at, + ( + SELECT f.reason_code FROM clip_feedback f + WHERE f.task_id = c.task_id AND f.clip_candidate_id = c.id + ORDER BY f.created_at DESC, f.rowid DESC LIMIT 1 + ) AS feedback_reason_code + FROM clip_candidates c + WHERE c.task_id = ? AND c.is_deleted = 0 + ORDER BY c.start_time ASC """, (task_id,), ).fetchall() @@ -939,6 +949,12 @@ def list_clip_candidates(task_id: str) -> list[dict]: "key_moment_time": clip.get("key_moment_time") or "", "quality_evidence": quality_evidence, "rejection_reason": clip.get("rejection_reason") or "", + "feedback_reason_code": ( + clip.get("feedback_reason_code") + if clip.get("feedback_reason_code") + in {"not_funny", "fragmented", "missing_setup", "duplicate", "dragging", "other"} + else "" + ), "ai_source_label": ai_source_label, "selected_by_default": bool(clip.get("selected_by_default")), "enabled": bool(clip.get("enabled")), @@ -1035,6 +1051,18 @@ def update_clip_candidate(task_id: str, clip_id: str, payload: ClipCandidateUpda data = _validate_clip_update(task, payload) now = _now_iso() with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + current = connection.execute( + """ + SELECT id, source_analysis_run_id + FROM clip_candidates + WHERE id = ? AND task_id = ? AND is_deleted = 0 + """, + (clip_id, task_id), + ).fetchone() + if current is None: + connection.rollback() + raise ValueError("候选片段不存在") cursor = connection.execute( """ UPDATE clip_candidates @@ -1054,6 +1082,19 @@ def update_clip_candidate(task_id: str, clip_id: str, payload: ClipCandidateUpda task_id, ), ) + record_review_toggle_feedback_with_connection( + connection, + task_id=task_id, + clip={ + "id": clip_id, + "source_analysis_run_id": current["source_analysis_run_id"], + **data, + }, + selection_profile=task.get("selection_profile") or "general", + enabled=bool(data["enabled"]), + reason_code=payload.feedback_reason_code, + now=now, + ) connection.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, task_id)) connection.commit() @@ -1072,61 +1113,83 @@ def update_clip_candidates_batch(task_id: str, payloads: list[ClipCandidateBatch validated = [] for payload in payloads: - validated.append((payload.id, _validate_clip_update(task, payload))) + validated.append((payload.id, _validate_clip_update(task, payload), payload.feedback_reason_code)) now = _now_iso() changed_count = 0 + feedback_count = 0 with get_connection() as connection: - for clip_id, data in validated: - current = connection.execute( - """ - SELECT title, start_time, end_time, duration_seconds, enabled, summary - FROM clip_candidates - WHERE id = ? AND task_id = ? AND is_deleted = 0 - """, - (clip_id, task_id), - ).fetchone() - if current is None: - raise ValueError(f"候选片段不存在:{clip_id}") - if any( - ( - str(current["title"] or "") != data["title"], - str(current["start_time"] or "") != data["start_time"], - str(current["end_time"] or "") != data["end_time"], - int(current["duration_seconds"] or 0) != int(data["duration_seconds"]), - int(current["enabled"] or 0) != int(data["enabled"]), - str(current["summary"] or "") != data["summary"], + connection.execute("BEGIN IMMEDIATE") + try: + for clip_id, data, feedback_reason_code in validated: + current = connection.execute( + """ + SELECT title, start_time, end_time, duration_seconds, enabled, summary, + source_analysis_run_id + FROM clip_candidates + WHERE id = ? AND task_id = ? AND is_deleted = 0 + """, + (clip_id, task_id), + ).fetchone() + if current is None: + raise ValueError(f"候选片段不存在:{clip_id}") + if any( + ( + str(current["title"] or "") != data["title"], + str(current["start_time"] or "") != data["start_time"], + str(current["end_time"] or "") != data["end_time"], + int(current["duration_seconds"] or 0) != int(data["duration_seconds"]), + int(current["enabled"] or 0) != int(data["enabled"]), + str(current["summary"] or "") != data["summary"], + ) + ): + changed_count += 1 + cursor = connection.execute( + """ + UPDATE clip_candidates + SET title = ?, start_time = ?, end_time = ?, duration_seconds = ?, + enabled = ?, summary = ?, reviewed = 1, updated_at = ? + WHERE id = ? AND task_id = ? AND is_deleted = 0 + """, + ( + data["title"], + data["start_time"], + data["end_time"], + data["duration_seconds"], + data["enabled"], + data["summary"], + now, + clip_id, + task_id, + ), ) - ): - changed_count += 1 - cursor = connection.execute( - """ - UPDATE clip_candidates - SET title = ?, start_time = ?, end_time = ?, duration_seconds = ?, - enabled = ?, summary = ?, reviewed = 1, updated_at = ? - WHERE id = ? AND task_id = ? AND is_deleted = 0 - """, - ( - data["title"], - data["start_time"], - data["end_time"], - data["duration_seconds"], - data["enabled"], - data["summary"], - now, - clip_id, - task_id, - ), - ) - if cursor.rowcount == 0: - raise ValueError(f"候选片段不存在:{clip_id}") - connection.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, task_id)) - connection.commit() + if cursor.rowcount == 0: + raise ValueError(f"候选片段不存在:{clip_id}") + if record_review_toggle_feedback_with_connection( + connection, + task_id=task_id, + clip={ + "id": clip_id, + "source_analysis_run_id": current["source_analysis_run_id"], + **data, + }, + selection_profile=task.get("selection_profile") or "general", + enabled=bool(data["enabled"]), + reason_code=feedback_reason_code, + now=now, + ): + feedback_count += 1 + connection.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, task_id)) + connection.commit() + except Exception: + connection.rollback() + raise _append_task_log(task_id, f"已批量保存 {len(validated)} 条候选片段审核修改") return { "message": f"已保存 {len(validated)} 条候选片段,任务状态仍保持 AI 结果待检查。", "changed_count": changed_count, + "feedback_count": feedback_count, "task": get_task(task_id, include_video_probe=False), "clips": list_clip_candidates(task_id), } diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 8ccd4ef..8511c8b 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1763,12 +1763,6 @@ fieldset input.visually-hidden-file { background: rgba(0, 122, 255, 0.1); } -.clip-feedback .feedback-positive.is-active { - border-color: rgba(52, 199, 89, 0.55); - color: #11643f; - background: rgba(52, 199, 89, 0.12); -} - .compact-button { min-height: 34px; padding: 0 12px; diff --git a/app/static/js/app.js b/app/static/js/app.js index 8c3cfa4..6282155 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -920,14 +920,18 @@ function updateClipReviewActionState() { function collectClipReviewPayload() { const cards = getClipReviewCards(); - return cards.map((card) => ({ - id: card.dataset.clipId, - title: card.querySelector("[name='title']").value.trim(), - start_time: card.querySelector("[name='start_time']").value.trim(), - end_time: card.querySelector("[name='end_time']").value.trim(), - enabled: card.querySelector("[name='enabled']").checked, - summary: card.querySelector("[name='summary']").value.trim(), - })); + return cards.map((card) => { + const enabled = card.querySelector("[name='enabled']").checked; + return { + id: card.dataset.clipId, + title: card.querySelector("[name='title']").value.trim(), + start_time: card.querySelector("[name='start_time']").value.trim(), + end_time: card.querySelector("[name='end_time']").value.trim(), + enabled, + summary: card.querySelector("[name='summary']").value.trim(), + feedback_reason_code: enabled ? null : (card.dataset.feedbackReason || null), + }; + }); } async function persistClipReviewChanges() { @@ -981,38 +985,11 @@ async function deleteClipCard(card, button) { } } -async function saveClipFeedback(card, button) { - if (!clipReviewForm || !card || !button) return; - const taskId = clipReviewForm.dataset.taskId; - const clipId = card.dataset.clipId; - const decision = button.dataset.feedbackDecision; - const reasonCode = button.dataset.feedbackReason; - const feedbackButtons = Array.from(card.querySelectorAll("[data-feedback-decision]")); - feedbackButtons.forEach((item) => { item.disabled = true; }); - showClipReviewMessage("正在记录你的审片判断...", "info"); - - try { - const response = await fetch(`/api/tasks/${taskId}/clips/${clipId}/feedback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ decision, reason_code: reasonCode }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "保存反馈失败"); - } - feedbackButtons.forEach((item) => { - item.classList.toggle("is-active", item === button); - item.setAttribute("aria-pressed", item === button ? "true" : "false"); - }); - const enabledInput = card.querySelector("[name='enabled']"); - if (enabledInput) enabledInput.checked = Boolean(data.enabled); - showClipReviewMessage(data.message || "反馈已保存。", "success"); - } catch (error) { - showClipReviewMessage(`保存反馈失败:${error.message}`, "error"); - } finally { - feedbackButtons.forEach((item) => { item.disabled = false; }); - } +function syncRejectReasonVisibility(card) { + if (!card) return; + const enabledInput = card.querySelector("[name='enabled']"); + const rejectFeedback = card.querySelector("[data-reject-feedback]"); + if (rejectFeedback && enabledInput) rejectFeedback.hidden = enabledInput.checked; } function timeTextToSeconds(value) { @@ -1302,7 +1279,10 @@ document.querySelectorAll("[data-clip-card] input[name='start_time'], [data-clip if (clipSelectAll) { clipSelectAll.addEventListener("change", () => { const shouldEnable = clipSelectAll.checked; - getClipEnableCheckboxes().forEach((checkbox) => { checkbox.checked = shouldEnable; }); + getClipEnableCheckboxes().forEach((checkbox) => { + checkbox.checked = shouldEnable; + syncRejectReasonVisibility(checkbox.closest("[data-clip-card]")); + }); updateClipSelectAllUi(); showClipReviewMessage( shouldEnable @@ -1314,7 +1294,10 @@ if (clipSelectAll) { } clipReviewForm?.addEventListener("change", (event) => { - if (event.target.matches("[data-clip-card] input[name='enabled']")) updateClipSelectAllUi(); + if (event.target.matches("[data-clip-card] input[name='enabled']")) { + syncRejectReasonVisibility(event.target.closest("[data-clip-card]")); + updateClipSelectAllUi(); + } }); updateClipSelectAllUi(); @@ -1459,9 +1442,18 @@ document.querySelectorAll("[data-delete-trigger]").forEach((button) => { }); }); -document.querySelectorAll("[data-feedback-decision]").forEach((button) => { +document.querySelectorAll("[data-reject-reason]").forEach((button) => { button.addEventListener("click", () => { - saveClipFeedback(button.closest("[data-clip-card]"), button); + const card = button.closest("[data-clip-card]"); + if (!card) return; + const selected = card.dataset.feedbackReason === button.dataset.rejectReason; + card.dataset.feedbackReason = selected ? "" : button.dataset.rejectReason; + card.querySelectorAll("[data-reject-reason]").forEach((item) => { + const active = !selected && item === button; + item.classList.toggle("is-active", active); + item.setAttribute("aria-pressed", active ? "true" : "false"); + }); + showClipReviewMessage("淘汰原因已暂存;点击“保存修改”后统一写入。", "info"); }); }); diff --git a/app/templates/clip_review.html b/app/templates/clip_review.html index 14171cc..0c40206 100644 --- a/app/templates/clip_review.html +++ b/app/templates/clip_review.html @@ -45,6 +45,7 @@

片段审核 · {{ task.title }}

data-start-seconds="{{ clip.start_seconds }}" data-end-seconds="{{ clip.end_seconds }}" data-title="{{ clip.title }}" + data-feedback-reason="{{ clip.feedback_reason_code or '' }}" >