diff --git a/ANALYSIS_ATTRIBUTION_CONTRACT_TASK.md b/ANALYSIS_ATTRIBUTION_CONTRACT_TASK.md new file mode 100644 index 0000000..0048b7d --- /dev/null +++ b/ANALYSIS_ATTRIBUTION_CONTRACT_TASK.md @@ -0,0 +1,55 @@ +# AI 分析与内容归因契约修复任务 + +## 背景 + +第二次工程复检确认三个数据正确性问题:长直播结果缺少必需质量字段,显式反馈会错误绑定当前 active Run,官方导出缺少时长时 Prompt 对比无法计算平均观看比例。 + +## 目标 + +- 让长直播完整与不完整结果都产生结构完整、可验证的质量元数据。 +- 让反馈只绑定候选片段的真实来源 Run;来源不可信时保留反馈但不进行 Prompt 归因。 +- 让 Prompt 对比复用内容诊断已经采用的有效时长回退顺序。 + +## 允许修改范围 + +- `app/services/ai/long_live_talk_analyzer.py` +- `app/services/clip_feedback_service.py` +- `app/services/content_review_service.py` +- 上述行为对应的测试文件 +- `DEVELOPMENT_LOG.md`、`NEXT_STEPS.md` 与本任务文件 + +## 禁止修改范围 + +- 数据库结构和活动 SQLite。 +- Provider 调用、发布执行、Chrome Worker 和运行中服务。 +- 前端交互、迁移框架和本阶段无关的代码味道。 + +## 已确定实现要求 + +1. `quality_degraded` 必须由长直播分析器显式写入,不能放宽共享校验器的缺失字段门禁。 +2. 显式反馈不得查询或猜测当前 active Run;只接受候选 `source_analysis_run_id` 且必须属于同一任务。 +3. 来源缺失、不存在或属于其他任务时,`analysis_run_id` 写为 `NULL`,反馈本身继续保存。 +4. 有效时长顺序固定为导入时长、候选时长、输出片段 `source_duration_ms`。 + +## 验收标准 + +- 完整长直播元数据通过共享切片校验,不完整结果仍被门禁阻止。 +- 旧候选在新 active Run 存在时仍归因到旧来源 Run。 +- 不可信来源不会回退到 active Run,也不会触发外键错误。 +- 官方导出时长为空时,Prompt 对比能用候选或输出片段时长计算观看比例。 +- 定向测试、全量测试、Ruff、Compileall 和 `git diff --check` 通过。 + +## 测试命令 + +```powershell +pytest -q tests/test_long_live_selection.py tests/test_partial_ai_analysis.py tests/test_content_review_foundation.py tests/test_content_review.py +ruff check app tests scripts +python -m compileall -q app tests scripts +pytest -q +git diff --check +``` + +## 返回格式 + +- 三个契约的修复说明与回归证据。 +- 修改文件、测试结果、分支、提交 SHA、远端 SHA 和 PR 状态。 diff --git a/CONTENT_REVIEW_FEEDBACK_LOOP_TASK.md b/CONTENT_REVIEW_FEEDBACK_LOOP_TASK.md new file mode 100644 index 0000000..68208e4 --- /dev/null +++ b/CONTENT_REVIEW_FEEDBACK_LOOP_TASK.md @@ -0,0 +1,60 @@ +# 内容复盘反馈闭环实施任务 + +## 背景 + +当前内容复盘已经具备官方作品报表导入、作品归因和 Prompt 版本证据链,但精确同步时间、匹配语义、折叠体验和可执行改进闭环仍不完整;现有“周期”还错误地按导入批次数计算。 + +## 目标 + +- 明确展示最近一次官方作品导出的北京时间和匹配统计。 +- 美化文件选择入口,并让趋势明细、作品归因、Prompt 证据默认折叠且记忆状态。 +- 修正 2 秒跳出率的好坏方向和匹配状态文案。 +- 新增透明、无 AI 调用的作品诊断与人工实验闭环。 +- 在投稿执行前允许给发布任务关联一个实验,执行开始后冻结关联。 + +## 允许修改范围 + +- 内容复盘服务、路由、模型、模板、JavaScript 和 CSS。 +- 发送中心中与“关联内容实验”直接相关的服务、路由、模板和 JavaScript。 +- SQLite 向后兼容迁移、数据库文档、开发日志、后续步骤和 UI 参考。 +- 内容复盘、发送中心、迁移及浏览器测试。 + +## 禁止修改范围 + +- 不修改 Provider、登录、Token、Cookie、`.env` 或代理配置。 +- 不触发真实抖音导出、AI 分析、排期、投稿或远端数据库操作。 +- 不自动修改 Prompt、候选片段、发布状态或现有历史快照。 +- 不重构前端框架,不改无关页面,不覆盖用户数据。 + +## 已确定实现要求 + +- 官方导出周只按成功的 `douyin_item_export` 最新采集时间所在自然周去重。 +- 诊断只使用最新、已准确匹配且指标完整的作品;按体裁、片长区间和发布年龄建立对照,使用中位数与四分位数。 +- 每条建议必须带证据、单一动作、主指标、护栏指标和数据充分性;缺失作品级封面点击率时不得生成封面建议。 +- 实验基线在创建时冻结;发布任务同一时间只能属于一个实验;进入 `PUBLISHING`、`PUBLISHED`、`FAILED` 或人工复核等执行后状态后不得关联或解除。 +- 10 条实验作品仅显示早期趋势;对照与实验各至少 20 条且覆盖 3 个官方导出周后才允许记录最终结论。 +- 系统只记录用户的保留、回退或结论不足决定,不自动实施改动。 + +## 验收标准 + +- 页面显示独立官方导出精确时间、批次和匹配统计。 +- 自定义文件按钮可访问、显示文件名、未选文件时不可预览。 +- 三个明细区默认折叠并通过浏览器本地存储记忆开合状态。 +- 匹配文案和 2 秒跳出率颜色正确。 +- 可创建实验、投稿前关联/解除、执行后冻结,并基于后续官方快照自动汇总进度。 +- Prompt 证据按账号隔离并显示真实周数与缺口,不把历史未版本化作品当成 Prompt 结论。 +- 迁移完整性、外键检查、定向测试、完整测试、静态检查和浏览器宽度回归通过。 + +## 测试命令 + +- `.\.venv\Scripts\python.exe -m pytest tests\test_content_review.py tests\test_content_review_browser.py -q` +- `.\.venv\Scripts\python.exe -m pytest tests -q` +- `.\.venv\Scripts\python.exe -m ruff check app tests` +- `.\.venv\Scripts\python.exe -m compileall app` +- `node --check app\static\js\content-review.js` +- `node --check app\static\js\publish-center.js` +- `git diff --check` + +## 返回格式 + +报告修改摘要、文件范围、数据库迁移、测试证据、Git 分支/提交/远端 SHA、PR 与 CI 状态;明确说明未触发真实导出、AI 或投稿。 diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 7d1985c..2f7ca6e 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,16 @@ # Development Log +## 2026-08-29 内容复盘“数据 → 动作 → 验证”闭环 + +- 自动导出卡改为显示最近一次官方作品导出的北京时间、批次作品数及已匹配/待确认/未匹配统计;账号趋势表不再参与作品导出时间和周数口径,同一自然周多次导出只计一个官方导出周。 +- 文件上传复用项目自定义按钮,选择后显示文件名,未选择时禁用预览;账号每日明细、作品归因和 Prompt 高级证据默认折叠,并通过当前浏览器 `localStorage` 记忆状态。 +- 匹配状态统一为“已匹配·唯一证据 / 已匹配·人工确认 / 待人工确认·多个候选 / 未匹配·没有候选”;修正 2 秒跳出率好坏方向,上升为红色、下降为绿色。 +- 主栏目改为“本周改进建议”:只对准确匹配且指标完整的官方作品,按同体裁、片长和发布年龄构造中位数与四分位数对照,规则化识别开头留存、中段节奏、分发窗口和正样本;作品级封面指标为空时不生成封面建议,全程不调用 AI。 +- 新增内容实验与作品关联:创建时冻结批次基线、唯一改动、主指标和护栏;发送中心可在投稿前选择实验,一个作品同一时刻只有一个实验归属,执行领取或已有执行证据后冻结。切换归属由单一事务完成,避免先删后加的中间状态。 +- 实验 10 条后才显示早期趋势;只有实验与对照各至少 20 条、覆盖 3 个不同官方导出周时,才允许人工记录“保留 / 回退 / 结论不足”。系统只记录结论,不改 Prompt、切片、文案、排期或投稿。 +- Prompt 证据按账号及官方作品导出隔离,显示真实导出周数和距离 30 条准确作品的缺口;历史未绑定 Prompt 的作品继续只作观察基线。 +- 新增账本迁移 `20260829_02_content_feedback_loop`,只追加 `content_improvement_experiments` 与 `content_improvement_experiment_items`,保留全部现有快照、发布记录和排期。当前开发验收使用 pytest 临时库,未迁移活动库、未重启服务,也未触发真实导出、AI 或投稿。 + ## 2026-08-29 抖音官方作品报表全量同步 - 内容复盘不再监听创作者中心私有作品接口,也不再逐作品请求 `metrics_trend`;Windows Chrome Worker 改为打开内容管理页、只点击一次“导出数据”,使用浏览器下载事件读取官方 `作品列表导出.xlsx`。 @@ -1423,3 +1434,10 @@ - 保留 `master` 分支 push 的既有 CI,同时取消 Pull Request 目标分支限制,让以功能分支为目标的堆叠 PR 也运行 Linux 测试、Windows 主机冒烟和 Docker 镜像冒烟。 - 不修改 CI Job 内容、权限、Secrets、依赖版本或业务代码;继续使用权限更低的 `pull_request` 事件,不切换到 `pull_request_target`。 - 本次只恢复自动验收链路。现有堆叠 PR 的依赖、合并顺序与目标分支将在 CI 修复合并后逐一核对,不自动改写历史或合并 PR。 + +## 2026-08-30 AI 分析与内容归因契约修复 + +- 长直播分析结果现在始终显式写入 `quality_degraded=false`;完整结果可通过共享质量校验,不完整窗口仍由 `analysis_incomplete` 和覆盖率门禁阻止切片,没有放宽损坏元数据的 fail-closed 规则。 +- 显式片段反馈改为只绑定候选的 `source_analysis_run_id`,并验证 Run 属于同一任务;来源缺失、不存在或跨任务时保留反馈但不归因,绝不回退到当前 active Run。 +- Prompt 对比统一使用“官方导入时长 → 候选时长 → 输出片段源时长”的有效时长口径,官方报表时长为空时仍能计算平均观看比例。 +- 定向回归由修复前 `55 passed` 增加到 `62 passed`,全量回归 `861 passed`;Ruff、Compileall、5 个 JavaScript 语法检查、20 个 PowerShell 解析检查、三套合并 Compose 配置、`pip check` 和 `git diff --check` 均通过。测试只使用临时 SQLite 和本地 mock,未调用真实 Provider、Chrome 或发布平台,也未修改活动数据库。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 1d4c606..648857a 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,14 @@ # Next Steps +## 2026-08-29 内容复盘闭环使用与上线检查 + +1. 本分支合并并受控重启 Web 前,先确认没有 `PUBLISHING` 任务并备份活动 SQLite;启动时会应用 `20260829_02_content_feedback_loop`。重启后检查 `/api/system/readiness?deep=1` 和发送中心,确认迁移账本、实验表及 Worker 状态均正常。 +2. 打开“内容复盘”,自动导出卡应显示“上次成功导出:北京时间 YYYY-MM-DD HH:mm”以及本批匹配统计;账号每日明细和作品归因默认收起。折叠一次后刷新,状态应保持。 +3. “本周改进建议”只使用准确匹配且指标完整的官方作品。若显示“证据不足”,先处理未匹配作品或等待下一次官方导出,不要为了得到建议手工补造指标。 +4. 点击“采纳为下轮实验”只会建立实验卡。前往发送中心,在确实采用该唯一改动的作品上选择实验并保存;已经开始投稿的作品不能补标、改标或解除。 +5. 少于 10 条只显示收集进度;10 条起仅显示早期趋势。实验与对照各 20 条并覆盖 3 个不同官方导出周后,页面才开放“保留改动 / 回退 / 结论不足”,仍由用户人工决定。 +6. Prompt 高级证据区只评估当前账号、已绑定真实 Prompt 版本的官方作品;历史未绑定作品不能补猜版本。页面和接口不会自动修改 Prompt、重新切片、调整排期或投稿。 + ## 2026-08-29 官方作品报表同步使用说明 1. 在“内容复盘”选择正确的抖音账号后,点击“自动导出并同步全部作品”;系统只会打开创作者中心内容管理页并点击一次“导出数据”,不会投稿、改排期或改 Prompt。 @@ -1164,3 +1173,11 @@ 2. 由用户明确确认后再 Squash 合并该 PR;Codex 不自动合并。 3. 合并后,将最新 `master` 通过普通 merge 合入仍需继续的堆叠分支,不 rebase、不强推。 4. 逐个确认目标为功能分支的 PR 已出现三项 CI 检查,再继续业务正确性修复。 + +## 2026-08-30 AI 分析与内容归因契约验收 + +1. 先确认阶段 1 PR 的全量测试、Windows 主机冒烟和 Docker 镜像冒烟全部通过;未经用户确认不合并。 +2. 后续人工发起新的长直播分析时,完整结果应能进入审核与切片;若存在失败窗口,仍应明确显示分析不完整并阻止切片。该验证会消耗 Provider 额度,不在自动测试中触发。 +3. 对历史候选提交显式反馈时,反馈应归属于候选生成时的 Prompt Run;来源无法证明的旧候选应显示为未归因,不得算到当前 Prompt。 +4. 导入抖音官方作品报表后,即使报表没有视频时长,已匹配作品的 Prompt 对比仍应按候选或输出片段时长显示平均观看比例。 +5. 本阶段不需要重启服务、修改活动数据库或执行真实投稿;完成自动验收后继续处理数据库迁移原子性。 diff --git a/app/db/database.py b/app/db/database.py index c09e229..c0f9f4b 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -114,6 +114,30 @@ DOUYIN_ITEM_EXPORT_MIGRATION_CHECKSUM = hashlib.sha256( DOUYIN_ITEM_EXPORT_MIGRATION_SPEC.encode("utf-8") ).hexdigest() +CONTENT_FEEDBACK_LOOP_MIGRATION_VERSION = "20260829_02_content_feedback_loop" +CONTENT_FEEDBACK_LOOP_MIGRATION_NAME = "内容复盘诊断与实验闭环" +CONTENT_FEEDBACK_LOOP_REQUIRED_TABLES = ( + "content_improvement_experiments", + "content_improvement_experiment_items", +) +CONTENT_FEEDBACK_LOOP_REQUIRED_INDEXES = ( + "idx_content_experiments_account_status", + "idx_content_experiment_items_experiment", +) +CONTENT_FEEDBACK_LOOP_MIGRATION_SPEC = "\n".join( + ( + CONTENT_FEEDBACK_LOOP_MIGRATION_VERSION, + CONTENT_FEEDBACK_LOOP_MIGRATION_NAME, + *CONTENT_FEEDBACK_LOOP_REQUIRED_TABLES, + *CONTENT_FEEDBACK_LOOP_REQUIRED_INDEXES, + "one-experiment-per-publish-job", + "freeze-baseline-at-creation", + "no-automatic-prompt-or-publish-actions", + ) +) +CONTENT_FEEDBACK_LOOP_MIGRATION_CHECKSUM = hashlib.sha256( + CONTENT_FEEDBACK_LOOP_MIGRATION_SPEC.encode("utf-8") +).hexdigest() class SchemaMigrationError(RuntimeError): @@ -155,6 +179,9 @@ def init_db() -> None: 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) needs_douyin_item_export_backup = _requires_douyin_item_export_migration(settings.database_path) + needs_content_feedback_loop_backup = _requires_content_feedback_loop_migration( + settings.database_path + ) if needs_long_live_backup: create_schema_migration_backup( settings.database_path, @@ -210,6 +237,16 @@ def init_db() -> None: settings.data_dir / "backups", "douyin-official-item-export", ) + if ( + needs_content_feedback_loop_backup + and not needs_content_review_backup + and not needs_douyin_item_export_backup + ): + create_schema_migration_backup( + settings.database_path, + settings.data_dir / "backups", + "content-feedback-loop", + ) with get_connection() as connection: connection.executescript( @@ -767,6 +804,41 @@ def init_db() -> None: FOREIGN KEY(publish_job_id) REFERENCES publish_jobs(id), FOREIGN KEY(account_id) REFERENCES publish_accounts(id) ); + + CREATE TABLE IF NOT EXISTS content_improvement_experiments ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + recommendation_id TEXT NOT NULL, + diagnosis_code TEXT NOT NULL, + title TEXT NOT NULL, + hypothesis TEXT NOT NULL, + action_text TEXT NOT NULL, + primary_metric TEXT NOT NULL, + primary_direction TEXT NOT NULL, + guardrail_metrics_json TEXT NOT NULL DEFAULT '[]', + baseline_batch_id TEXT NOT NULL, + baseline_json TEXT NOT NULL, + target_sample_size INTEGER NOT NULL DEFAULT 20, + minimum_baseline_size INTEGER NOT NULL DEFAULT 20, + minimum_weeks INTEGER NOT NULL DEFAULT 3, + status TEXT NOT NULL DEFAULT 'active', + decision TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + UNIQUE(account_id, recommendation_id), + FOREIGN KEY(account_id) REFERENCES publish_accounts(id), + FOREIGN KEY(baseline_batch_id) REFERENCES content_metric_import_batches(id) + ); + + CREATE TABLE IF NOT EXISTS content_improvement_experiment_items ( + id TEXT PRIMARY KEY, + experiment_id TEXT NOT NULL, + publish_job_id TEXT NOT NULL UNIQUE, + assigned_at TEXT NOT NULL, + FOREIGN KEY(experiment_id) REFERENCES content_improvement_experiments(id) ON DELETE CASCADE, + FOREIGN KEY(publish_job_id) REFERENCES publish_jobs(id) + ); """ ) _migrate_tasks_table(connection) @@ -1011,6 +1083,53 @@ def _requires_douyin_item_export_migration(database_path) -> bool: connection.close() +def _requires_content_feedback_loop_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 "content_metric_import_batches" not in table_names: + return False + 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_FEEDBACK_LOOP_MIGRATION_VERSION, + CONTENT_FEEDBACK_LOOP_MIGRATION_CHECKSUM, + ), + ).fetchone() + except sqlite3.Error: + return True + return ( + not set(CONTENT_FEEDBACK_LOOP_REQUIRED_TABLES) <= table_names + or not set(CONTENT_FEEDBACK_LOOP_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} @@ -1411,6 +1530,77 @@ def _verify_douyin_item_export_migration(connection: sqlite3.Connection) -> None ) +def _apply_content_feedback_loop_migration(connection: sqlite3.Connection) -> None: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS content_improvement_experiments ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + recommendation_id TEXT NOT NULL, + diagnosis_code TEXT NOT NULL, + title TEXT NOT NULL, + hypothesis TEXT NOT NULL, + action_text TEXT NOT NULL, + primary_metric TEXT NOT NULL, + primary_direction TEXT NOT NULL, + guardrail_metrics_json TEXT NOT NULL DEFAULT '[]', + baseline_batch_id TEXT NOT NULL, + baseline_json TEXT NOT NULL, + target_sample_size INTEGER NOT NULL DEFAULT 20, + minimum_baseline_size INTEGER NOT NULL DEFAULT 20, + minimum_weeks INTEGER NOT NULL DEFAULT 3, + status TEXT NOT NULL DEFAULT 'active', + decision TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + UNIQUE(account_id, recommendation_id), + FOREIGN KEY(account_id) REFERENCES publish_accounts(id), + FOREIGN KEY(baseline_batch_id) REFERENCES content_metric_import_batches(id) + ); + + CREATE TABLE IF NOT EXISTS content_improvement_experiment_items ( + id TEXT PRIMARY KEY, + experiment_id TEXT NOT NULL, + publish_job_id TEXT NOT NULL UNIQUE, + assigned_at TEXT NOT NULL, + FOREIGN KEY(experiment_id) REFERENCES content_improvement_experiments(id) ON DELETE CASCADE, + FOREIGN KEY(publish_job_id) REFERENCES publish_jobs(id) + ); + + CREATE INDEX IF NOT EXISTS idx_content_experiments_account_status + ON content_improvement_experiments(account_id, status, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_content_experiment_items_experiment + ON content_improvement_experiment_items(experiment_id, assigned_at DESC); + """ + ) + + +def _verify_content_feedback_loop_migration(connection: sqlite3.Connection) -> None: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + indexes = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='index'" + ).fetchall() + } + missing_tables = sorted(set(CONTENT_FEEDBACK_LOOP_REQUIRED_TABLES) - tables) + missing_indexes = sorted(set(CONTENT_FEEDBACK_LOOP_REQUIRED_INDEXES) - indexes) + if missing_tables: + raise SchemaMigrationError( + "内容实验迁移缺少数据表:" + ", ".join(missing_tables) + ) + if missing_indexes: + raise SchemaMigrationError( + "内容实验迁移缺少索引:" + ", ".join(missing_indexes) + ) + + def _registered_schema_migrations() -> tuple[SchemaMigration, ...]: return ( SchemaMigration( @@ -1441,6 +1631,13 @@ def _registered_schema_migrations() -> tuple[SchemaMigration, ...]: apply=_apply_douyin_item_export_migration, verify=_verify_douyin_item_export_migration, ), + SchemaMigration( + version=CONTENT_FEEDBACK_LOOP_MIGRATION_VERSION, + name=CONTENT_FEEDBACK_LOOP_MIGRATION_NAME, + checksum=CONTENT_FEEDBACK_LOOP_MIGRATION_CHECKSUM, + apply=_apply_content_feedback_loop_migration, + verify=_verify_content_feedback_loop_migration, + ), ) diff --git a/app/models/content_review.py b/app/models/content_review.py index dfe8b38..fe00dbc 100644 --- a/app/models/content_review.py +++ b/app/models/content_review.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic import BaseModel, Field @@ -11,3 +13,16 @@ class DouyinAnalyticsExportSyncRequest(BaseModel): class ContentItemMatchUpdate(BaseModel): publish_job_id: str = Field(..., min_length=1, max_length=120) + + +class ContentExperimentCreateRequest(BaseModel): + account_id: str = Field(default="", max_length=120) + recommendation_id: str = Field(..., min_length=1, max_length=80) + + +class ContentExperimentDecisionRequest(BaseModel): + decision: Literal["keep", "revert", "inconclusive", "cancel"] + + +class ContentExperimentAssignmentRequest(BaseModel): + experiment_id: str = Field(default="", max_length=120) diff --git a/app/routers/content_review.py b/app/routers/content_review.py index 49623fe..b0c63ed 100644 --- a/app/routers/content_review.py +++ b/app/routers/content_review.py @@ -1,6 +1,9 @@ from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile from app.models.content_review import ( + ContentExperimentAssignmentRequest, + ContentExperimentCreateRequest, + ContentExperimentDecisionRequest, ContentItemMatchUpdate, ContentMetricImportCommitRequest, DouyinAnalyticsExportSyncRequest, @@ -120,6 +123,91 @@ async def prompt_comparison(account_id: str = Query(default="", max_length=120)) _raise_content_review_http(exc) +@router.get("/insights") +async def insights(account_id: str = Query(default="", max_length=120)) -> dict: + try: + return content_review_service.get_content_review_insights(account_id) + except content_review_service.ContentReviewError as exc: + _raise_content_review_http(exc) + + +@router.get("/experiments") +async def experiments( + account_id: str = Query(default="", max_length=120), + include_closed: bool = Query(default=True), +) -> dict: + try: + return { + "experiments": content_review_service.list_content_experiments( + account_id, + include_closed=include_closed, + ) + } + except content_review_service.ContentReviewError as exc: + _raise_content_review_http(exc) + + +@router.post("/experiments") +async def create_experiment(payload: ContentExperimentCreateRequest) -> dict: + try: + return content_review_service.create_content_experiment( + payload.account_id, + payload.recommendation_id, + ) + except content_review_service.ContentReviewError as exc: + _raise_content_review_http(exc) + + +@router.patch("/experiments/{experiment_id}") +async def update_experiment( + experiment_id: str, + payload: ContentExperimentDecisionRequest, +) -> dict: + try: + return content_review_service.update_content_experiment( + experiment_id, + payload.decision, + ) + except content_review_service.ContentReviewError as exc: + _raise_content_review_http(exc) + + +@router.put("/experiments/{experiment_id}/jobs/{publish_job_id}") +async def assign_experiment_job(experiment_id: str, publish_job_id: str) -> dict: + try: + return content_review_service.assign_publish_job_to_experiment( + experiment_id, + publish_job_id, + ) + except content_review_service.ContentReviewError as exc: + _raise_content_review_http(exc) + + +@router.delete("/experiments/{experiment_id}/jobs/{publish_job_id}") +async def remove_experiment_job(experiment_id: str, publish_job_id: str) -> dict: + try: + return content_review_service.remove_publish_job_from_experiment( + experiment_id, + publish_job_id, + ) + except content_review_service.ContentReviewError as exc: + _raise_content_review_http(exc) + + +@router.put("/experiment-assignments/{publish_job_id}") +async def set_experiment_assignment( + publish_job_id: str, + payload: ContentExperimentAssignmentRequest, +) -> dict: + try: + return content_review_service.set_publish_job_experiment( + publish_job_id, + payload.experiment_id, + ) + except content_review_service.ContentReviewError as exc: + _raise_content_review_http(exc) + + @router.get("/imports") async def imports( account_id: str = Query(default="", max_length=120), diff --git a/app/services/ai/long_live_talk_analyzer.py b/app/services/ai/long_live_talk_analyzer.py index 50216e2..0e478f5 100644 --- a/app/services/ai/long_live_talk_analyzer.py +++ b/app/services/ai/long_live_talk_analyzer.py @@ -261,6 +261,7 @@ def analyze_long_live_talk( "coverage_ratio": round(coverage_ratio, 6), "coverage_percent": coverage_percent, "analysis_incomplete": incomplete, + "quality_degraded": False, "minimum_complete_coverage": MIN_COMPLETE_COVERAGE, "highlight_density_per_hour": density, "highlight_total_limit": total_limit, diff --git a/app/services/clip_feedback_service.py b/app/services/clip_feedback_service.py index f8687e0..466374b 100644 --- a/app/services/clip_feedback_service.py +++ b/app/services/clip_feedback_service.py @@ -88,14 +88,14 @@ def save_clip_feedback(task_id: str, clip_id: str, payload: ClipFeedbackCreate) now = _now_iso() with get_connection() as connection: - active_run = connection.execute( - """ - SELECT id FROM ai_analysis_runs - WHERE task_id = ? AND is_active = 1 - ORDER BY run_number DESC LIMIT 1 - """, - (task_id,), - ).fetchone() + source_run_id = str(clip.get("source_analysis_run_id") or "").strip() or None + if source_run_id is not None: + source_run = connection.execute( + "SELECT id FROM ai_analysis_runs WHERE id = ? AND task_id = ?", + (source_run_id, task_id), + ).fetchone() + if source_run is None: + source_run_id = None connection.execute( """ INSERT INTO clip_feedback ( @@ -108,7 +108,7 @@ def save_clip_feedback(task_id: str, clip_id: str, payload: ClipFeedbackCreate) uuid4().hex[:12], task_id, clip_id, - active_run["id"] if active_run else None, + source_run_id, task.get("selection_profile") or "general", payload.decision, payload.reason_code, diff --git a/app/services/content_review_service.py b/app/services/content_review_service.py index 338797a..a82472d 100644 --- a/app/services/content_review_service.py +++ b/app/services/content_review_service.py @@ -113,6 +113,13 @@ "worth_publishing": "保留", } MATCHED_STATUSES = {"matched_exact", "matched_unique", "confirmed_manual"} +MATCH_STATUS_LABELS = { + "matched_exact": "已匹配·唯一证据", + "matched_unique": "已匹配·唯一证据", + "confirmed_manual": "已匹配·人工确认", + "ambiguous": "待人工确认·多个候选", + "unmatched": "未匹配·没有候选", +} class ContentReviewError(ValueError): @@ -126,7 +133,7 @@ def _now() -> datetime: def _now_iso() -> str: - return _now().isoformat(timespec="seconds") + return _now().astimezone(BEIJING_TIMEZONE).isoformat(timespec="seconds") def _resolve_douyin_account_id(account_id: str = "") -> str: @@ -849,6 +856,97 @@ def _comparison_delta(current, previous) -> float | None: return round((float(current) - float(previous)) / abs(float(previous)), 6) +def _parse_iso_datetime(value) -> datetime | None: + normalized = str(value or "").strip() + if not normalized: + return None + try: + parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00")) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=BEIJING_TIMEZONE) + except ValueError: + return None + + +def _official_export_context(connection, account_id: str) -> dict: + rows = connection.execute( + """ + SELECT b.id, b.committed_at, b.row_count, b.matched_count, + b.ambiguous_count, b.invalid_count, + MAX(i.captured_at) AS captured_at + FROM content_metric_import_batches b + LEFT JOIN douyin_item_metric_snapshots i ON i.batch_id = b.id + WHERE b.account_id = ? AND b.status = 'committed' + AND b.source_kind = ? + GROUP BY b.id + ORDER BY b.committed_at DESC, b.created_at DESC + """, + (account_id, DOUYIN_ITEM_EXPORT_SOURCE_KIND), + ).fetchall() + week_keys = set() + for row in rows: + captured_at = _parse_iso_datetime(row["captured_at"] or row["committed_at"]) + if captured_at is None: + continue + localized = captured_at.astimezone(BEIJING_TIMEZONE) + iso_year, iso_week, _ = localized.isocalendar() + week_keys.add(f"{iso_year}-W{iso_week:02d}") + latest = dict(rows[0]) if rows else {} + row_count = int(latest.get("row_count") or 0) + matched_count = int(latest.get("matched_count") or 0) + ambiguous_count = int(latest.get("ambiguous_count") or 0) + invalid_count = int(latest.get("invalid_count") or 0) + return { + "last_export_batch_id": latest.get("id"), + "last_export_committed_at": latest.get("committed_at"), + "last_export_captured_at": latest.get("captured_at"), + "last_export_row_count": row_count, + "last_export_matched_count": matched_count, + "last_export_ambiguous_count": ambiguous_count, + "last_export_unmatched_count": max( + 0, + row_count - matched_count - ambiguous_count - invalid_count, + ), + "official_export_weeks": len(week_keys), + "official_export_week_keys": sorted(week_keys), + } + + +def _latest_match_summary(connection, account_id: str) -> dict: + rows = connection.execute( + """ + WITH latest_items AS ( + SELECT i.match_status, + ROW_NUMBER() OVER ( + PARTITION BY CASE + WHEN i.publish_job_id IS NOT NULL THEN 'job:' || i.publish_job_id + ELSE 'work:' || i.aweme_id + END + ORDER BY i.captured_at DESC, i.created_at DESC, i.rowid DESC + ) AS item_rank + FROM douyin_item_metric_snapshots i + JOIN content_metric_import_batches b ON b.id = i.batch_id + WHERE i.account_id = ? AND b.status = 'committed' + AND b.source_kind = ? + ) + SELECT match_status, COUNT(*) AS item_count + FROM latest_items + WHERE item_rank = 1 + GROUP BY match_status + """, + (account_id, DOUYIN_ITEM_EXPORT_SOURCE_KIND), + ).fetchall() + counts = {str(row["match_status"]): int(row["item_count"] or 0) for row in rows} + return { + "total": sum(counts.values()), + "matched": sum(counts.get(status, 0) for status in MATCHED_STATUSES), + "ambiguous": counts.get("ambiguous", 0), + "unmatched": counts.get("unmatched", 0), + "matched_exact": counts.get("matched_exact", 0), + "matched_unique": counts.get("matched_unique", 0), + "confirmed_manual": counts.get("confirmed_manual", 0), + } + + def get_content_review_summary(account_id: str = "", days: int = 28) -> dict: resolved = _resolve_douyin_account_id(account_id) safe_days = max(14, min(180, int(days))) @@ -871,13 +969,14 @@ def get_content_review_summary(account_id: str = "", days: int = 28) -> dict: ).fetchall() sync_row = connection.execute( """ - SELECT MAX(committed_at) AS last_sync_at, - COUNT(*) AS completed_cycles + SELECT MAX(committed_at) AS last_sync_at FROM content_metric_import_batches WHERE account_id = ? AND status = 'committed' """, (resolved,), ).fetchone() + export_context = _official_export_context(connection, resolved) + match_summary = _latest_match_summary(connection, resolved) history = [dict(row) for row in rows] if not history: return { @@ -886,11 +985,13 @@ def get_content_review_summary(account_id: str = "", days: int = 28) -> dict: "message": "还没有确认导入的账号级数据。", "last_sync_at": None, "days_since_sync": None, - "completed_cycles": 0, + "completed_cycles": export_context["official_export_weeks"], "current_period": _aggregate_period([]), "previous_period": _aggregate_period([]), "comparisons": {}, "history": [], + "match_summary": match_summary, + **export_context, } latest_date = date.fromisoformat(history[-1]["metric_date"]) @@ -919,12 +1020,14 @@ def get_content_review_summary(account_id: str = "", days: int = 28) -> dict: "latest_metric_date": latest_date.isoformat(), "last_sync_at": last_sync_at, "days_since_sync": days_since, - "completed_cycles": int(sync_row["completed_cycles"] or 0), + "completed_cycles": export_context["official_export_weeks"], "current_period": current, "previous_period": previous, "comparisons": comparisons, "history": visible_history, "attribution": "unattributed_account_daily_baseline", + "match_summary": match_summary, + **export_context, } @@ -984,6 +1087,10 @@ def list_content_review_works(account_id: str = "", limit: int = 100) -> list[di str(work.get("review_reason_code") or ""), str(work.get("review_reason_code") or ""), ) + work["match_label"] = MATCH_STATUS_LABELS.get( + str(work.get("match_status") or ""), + "未匹配·没有候选", + ) work["attribution_complete"] = bool( work.get("publish_job_id") and work.get("clip_candidate_id") @@ -1007,17 +1114,27 @@ def get_prompt_comparison(account_id: str = "") -> dict: """ SELECT pv.id AS prompt_version_id, pv.preset_id, pv.version_number, pv.preset_name_snapshot, pv.created_at, - c.id AS candidate_id, c.enabled, - ( - SELECT f.decision FROM clip_feedback f - WHERE f.clip_candidate_id = c.id - ORDER BY f.created_at DESC, f.rowid DESC LIMIT 1 - ) AS latest_decision + scoped.candidate_id, scoped.enabled, scoped.latest_decision FROM ai_prompt_versions pv - LEFT JOIN ai_analysis_runs ar ON ar.prompt_version_id = pv.id - LEFT JOIN clip_candidates c ON c.source_analysis_run_id = ar.id AND c.is_deleted = 0 + JOIN ( + SELECT DISTINCT ar.prompt_version_id, + c.id AS candidate_id, c.enabled, + ( + SELECT f.decision FROM clip_feedback f + WHERE f.clip_candidate_id = c.id + ORDER BY f.created_at DESC, f.rowid DESC LIMIT 1 + ) AS latest_decision + FROM ai_analysis_runs ar + JOIN clip_candidates c + ON c.source_analysis_run_id = ar.id AND c.is_deleted = 0 + JOIN output_clip oc ON oc.clip_candidate_id = c.id + JOIN publish_jobs pj ON pj.output_clip_id = oc.id + WHERE pj.account_id = ? AND pj.platform = 'douyin' + AND ar.prompt_version_id IS NOT NULL + ) scoped ON scoped.prompt_version_id = pv.id ORDER BY pv.created_at, pv.preset_id, pv.version_number - """ + """, + (resolved,), ).fetchall() work_rows = connection.execute( """ @@ -1033,9 +1150,15 @@ def get_prompt_comparison(account_id: str = "") -> dict: FROM douyin_item_metric_snapshots i JOIN content_metric_import_batches b ON b.id = i.batch_id WHERE i.account_id = ? AND b.status = 'committed' + AND b.source_kind = ? AND i.match_status IN ('matched_exact', 'matched_unique', 'confirmed_manual') ) - SELECT i.*, c.id AS candidate_id, ar.prompt_version_id + SELECT i.*, c.id AS candidate_id, ar.prompt_version_id, + COALESCE( + NULLIF(i.duration_seconds, 0), + NULLIF(c.duration_seconds, 0), + NULLIF(oc.source_duration_ms, 0) / 1000.0 + ) AS effective_duration_seconds FROM latest_items i JOIN publish_jobs pj ON pj.id = i.publish_job_id JOIN output_clip oc ON oc.id = pj.output_clip_id @@ -1043,15 +1166,9 @@ def get_prompt_comparison(account_id: str = "") -> dict: JOIN ai_analysis_runs ar ON ar.id = c.source_analysis_run_id WHERE i.item_rank = 1 AND ar.prompt_version_id IS NOT NULL """, - (resolved,), + (resolved, DOUYIN_ITEM_EXPORT_SOURCE_KIND), ).fetchall() - cycle_row = connection.execute( - """ - SELECT COUNT(*) FROM content_metric_import_batches - WHERE account_id = ? AND status = 'committed' - """, - (resolved,), - ).fetchone() + export_context = _official_export_context(connection, resolved) groups: dict[str, dict] = {} for row in candidate_rows: @@ -1094,9 +1211,10 @@ def get_prompt_comparison(account_id: str = "") -> dict: five_rates = [float(work["five_second_completion_rate"]) for work in works if work["five_second_completion_rate"] is not None] bounce_rates = [float(work["two_second_bounce_rate"]) for work in works if work["two_second_bounce_rate"] is not None] watch_ratios = [ - float(work["average_watch_seconds"]) / float(work["duration_seconds"]) + float(work["average_watch_seconds"]) / float(work["effective_duration_seconds"]) for work in works - if work["average_watch_seconds"] is not None and float(work["duration_seconds"] or 0) > 0 + if work["average_watch_seconds"] is not None + and float(work["effective_duration_seconds"] or 0) > 0 ] interactions = sum( int(work["like_count"] or 0) @@ -1122,6 +1240,8 @@ def get_prompt_comparison(account_id: str = "") -> dict: "average_watch_ratio": statistics.fmean(watch_ratios) if watch_ratios else None, "interaction_rate": _safe_ratio(interactions, total_plays), "evaluable": len(works) >= 30, + "required_accurate_published_count": 30, + "remaining_accurate_published_count": max(0, 30 - len(works)), } ) versions.sort(key=lambda item: (item["created_at"], item["preset_id"], item["version_number"])) @@ -1136,7 +1256,7 @@ def get_prompt_comparison(account_id: str = "") -> dict: "note": "仅相关性,不代表因果;请结合选题、发布时间和样本结构判断。", } ) - completed_cycles = int(cycle_row[0] or 0) if cycle_row else 0 + completed_cycles = int(export_context["official_export_weeks"] or 0) return { "account_id": resolved, "completed_cycles": completed_cycles, @@ -1147,12 +1267,794 @@ def get_prompt_comparison(account_id: str = "") -> dict: "message": ( "已达到评估门槛,系统只提供建议,不会自动修改 Prompt。" if completed_cycles >= 3 and any(item["evaluable"] for item in versions) - else "数据不足:默认需要 3 个完整周期,且当前 Prompt 至少 30 条准确关联作品。" + else "数据不足:需要 3 个不同官方导出周,且当前 Prompt 至少 30 条准确关联作品。" ), "causality_notice": "所有对比仅表示相关性,不代表因果。", } +DIAGNOSIS_CORE_METRICS = ( + "play_count", + "five_second_completion_rate", + "two_second_bounce_rate", + "completion_rate", + "watch_ratio", +) +EXPERIMENT_EDITABLE_JOB_STATUSES = {"DRAFT", "WAITING", "SCHEDULED"} +EXPERIMENT_DECISIONS = {"keep", "revert", "inconclusive", "cancel"} + + +def _duration_bucket(value) -> str: + seconds = float(value or 0) + if seconds <= 0: + return "时长未知" + if seconds <= 60: + return "60秒内" + if seconds <= 120: + return "61-120秒" + return "120秒以上" + + +def _publish_age_bucket(published_at, captured_at) -> str: + published = _parse_iso_datetime(published_at) + captured = _parse_iso_datetime(captured_at) + if published is None or captured is None: + return "发布年龄未知" + days = max(0, (captured - published).days) + if days <= 7: + return "发布7天内" + if days <= 30: + return "发布8-30天" + return "发布30天以上" + + +def _percentile(values: list[float], fraction: float) -> float | None: + if not values: + return None + ordered = sorted(float(value) for value in values) + if len(ordered) == 1: + return ordered[0] + position = (len(ordered) - 1) * fraction + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return ordered[lower] + weight = position - lower + return ordered[lower] * (1 - weight) + ordered[upper] * weight + + +def _cohort_benchmarks(rows: list[dict]) -> dict: + metrics = {} + for metric in DIAGNOSIS_CORE_METRICS: + values = [float(row[metric]) for row in rows if row.get(metric) is not None] + metrics[metric] = { + "count": len(values), + "p25": round(_percentile(values, 0.25), 6) if values else None, + "median": round(statistics.median(values), 6) if values else None, + "p75": round(_percentile(values, 0.75), 6) if values else None, + } + return metrics + + +def _latest_diagnosis_rows(connection, account_id: str) -> list[dict]: + rows = connection.execute( + """ + WITH latest_items AS ( + SELECT i.*, b.id AS metric_batch_id, + ROW_NUMBER() OVER ( + PARTITION BY CASE + WHEN i.publish_job_id IS NOT NULL THEN 'job:' || i.publish_job_id + ELSE 'work:' || i.aweme_id + END + ORDER BY i.captured_at DESC, i.created_at DESC, i.rowid DESC + ) AS item_rank + FROM douyin_item_metric_snapshots i + JOIN content_metric_import_batches b ON b.id = i.batch_id + WHERE i.account_id = ? AND b.status = 'committed' + AND b.source_kind = ? + ) + SELECT i.*, pj.title AS publish_title, oc.clip_candidate_id, + c.title AS candidate_title, + COALESCE( + NULLIF(i.duration_seconds, 0), + NULLIF(c.duration_seconds, 0), + NULLIF(oc.source_duration_ms, 0) / 1000.0 + ) AS effective_duration_seconds + FROM latest_items i + LEFT JOIN publish_jobs pj ON pj.id = i.publish_job_id + LEFT JOIN output_clip oc ON oc.id = pj.output_clip_id + LEFT JOIN clip_candidates c ON c.id = oc.clip_candidate_id + WHERE i.item_rank = 1 + ORDER BY COALESCE(i.published_at, i.captured_at) DESC + """, + (account_id, DOUYIN_ITEM_EXPORT_SOURCE_KIND), + ).fetchall() + result = [] + for source in rows: + row = dict(source) + duration = float(row.get("effective_duration_seconds") or 0) + average_watch = row.get("average_watch_seconds") + row["watch_ratio"] = ( + round(float(average_watch) / duration, 6) + if average_watch is not None and duration > 0 + else None + ) + row["duration_bucket"] = _duration_bucket(duration) + row["age_bucket"] = _publish_age_bucket( + row.get("published_at"), + row.get("captured_at"), + ) + row["genre_bucket"] = str(row.get("content_genre") or "体裁未知") + result.append(row) + return result + + +def _select_comparable_cohort(work: dict, eligible: list[dict]) -> tuple[list[dict], dict]: + selectors = ( + ( + "同体裁、同片长、同发布年龄", + lambda item: item["genre_bucket"] == work["genre_bucket"] + and item["duration_bucket"] == work["duration_bucket"] + and item["age_bucket"] == work["age_bucket"], + ), + ( + "同体裁、同片长", + lambda item: item["genre_bucket"] == work["genre_bucket"] + and item["duration_bucket"] == work["duration_bucket"], + ), + ( + "同片长", + lambda item: item["duration_bucket"] == work["duration_bucket"], + ), + ("全部准确匹配作品", lambda _item: True), + ) + for label, selector in selectors: + cohort = [item for item in eligible if selector(item)] + if len(cohort) >= 8 or label == "全部准确匹配作品": + return cohort, { + "label": label, + "genre": work["genre_bucket"], + "duration": work["duration_bucket"], + "publish_age": work["age_bucket"], + } + return eligible, {"label": "全部准确匹配作品"} + + +def _recommendation_definition(code: str) -> dict: + definitions = { + "weak_opening": { + "title": "开头留存偏弱", + "hypothesis": "前 1~2 秒的铺垫过长,用户尚未进入冲突、笑点或观点就离开。", + "action_text": "下一批只改开头:删除开场铺垫,让前 1~2 秒直接进入冲突、笑点或核心观点。", + "primary_metric": "two_second_bounce_rate", + "primary_direction": "lower", + "guardrail_metrics": ["five_second_completion_rate", "completion_rate"], + "priority": 4, + }, + "weak_pacing": { + "title": "中段节奏偏弱", + "hypothesis": "开头能够留住用户,但中段重复、停顿或解释拖慢了完播。", + "action_text": "下一批只改中段:压缩重复、停顿和解释,并把关键结果提前。", + "primary_metric": "completion_rate", + "primary_direction": "higher", + "guardrail_metrics": ["five_second_completion_rate", "two_second_bounce_rate"], + "priority": 3, + }, + "distribution_window": { + "title": "留存尚可但播放偏低", + "hypothesis": "片段本身的早期留存不差,低播放暂不能归咎于 Prompt。", + "action_text": "保持选片、剪辑和文案不变,下一批只测试发布时间窗口。", + "primary_metric": "play_count", + "primary_direction": "higher", + "guardrail_metrics": ["five_second_completion_rate", "two_second_bounce_rate"], + "priority": 2, + }, + "positive_reference": { + "title": "可作为正样本", + "hypothesis": "开头、持续观看和完播同时优于同类作品,可作为下批审片参考。", + "action_text": "下一批只强化同类开场结构,其他选片条件保持不变。", + "primary_metric": "five_second_completion_rate", + "primary_direction": "higher", + "guardrail_metrics": ["two_second_bounce_rate", "completion_rate"], + "priority": 1, + }, + } + return definitions[code] + + +def _build_content_review_insights(account_id: str) -> dict: + with get_connection() as connection: + export_context = _official_export_context(connection, account_id) + works = _latest_diagnosis_rows(connection, account_id) + eligible = [ + work + for work in works + if work.get("publish_job_id") + and str(work.get("match_status") or "") in MATCHED_STATUSES + and all(work.get(metric) is not None for metric in DIAGNOSIS_CORE_METRICS) + ] + recommendations = [] + for work in eligible: + cohort, cohort_key = _select_comparable_cohort(work, eligible) + benchmarks = _cohort_benchmarks(cohort) + five = float(work["five_second_completion_rate"]) + bounce = float(work["two_second_bounce_rate"]) + completion = float(work["completion_rate"]) + watch_ratio = float(work["watch_ratio"]) + plays = float(work["play_count"]) + code = "" + score = 0.0 + if ( + bounce >= float(benchmarks["two_second_bounce_rate"]["p75"]) + and five <= float(benchmarks["five_second_completion_rate"]["p25"]) + ): + code = "weak_opening" + score = bounce - float(benchmarks["two_second_bounce_rate"]["p75"]) + elif five >= float(benchmarks["five_second_completion_rate"]["median"]) and ( + completion <= float(benchmarks["completion_rate"]["p25"]) + or watch_ratio <= float(benchmarks["watch_ratio"]["p25"]) + ): + code = "weak_pacing" + score = max( + float(benchmarks["completion_rate"]["p25"]) - completion, + float(benchmarks["watch_ratio"]["p25"]) - watch_ratio, + ) + elif ( + plays <= float(benchmarks["play_count"]["p25"]) + and five >= float(benchmarks["five_second_completion_rate"]["median"]) + and bounce <= float(benchmarks["two_second_bounce_rate"]["median"]) + ): + code = "distribution_window" + score = ( + float(benchmarks["play_count"]["p25"]) - plays + ) / max(float(benchmarks["play_count"]["p25"]), 1.0) + elif ( + five >= float(benchmarks["five_second_completion_rate"]["p75"]) + and bounce <= float(benchmarks["two_second_bounce_rate"]["p25"]) + and completion >= float(benchmarks["completion_rate"]["p75"]) + ): + code = "positive_reference" + score = five - float(benchmarks["five_second_completion_rate"]["p75"]) + if not code: + continue + definition = _recommendation_definition(code) + identity = "|".join( + ( + account_id, + str(export_context.get("last_export_batch_id") or ""), + str(work["publish_job_id"]), + code, + ) + ) + recommendations.append( + { + "recommendation_id": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:20], + "diagnosis_code": code, + "title": definition["title"], + "hypothesis": definition["hypothesis"], + "action_text": definition["action_text"], + "primary_metric": definition["primary_metric"], + "primary_direction": definition["primary_direction"], + "guardrail_metrics": definition["guardrail_metrics"], + "source_work": { + "publish_job_id": work["publish_job_id"], + "title": work.get("title") or work.get("publish_title") or "未命名作品", + "published_at": work.get("published_at"), + }, + "evidence": {metric: work.get(metric) for metric in DIAGNOSIS_CORE_METRICS}, + "baseline": { + "batch_id": export_context.get("last_export_batch_id"), + "work_count": len(cohort), + "cohort": cohort_key, + "metrics": benchmarks, + }, + "comparison_interval": { + metric: { + "p25": values.get("p25"), + "median": values.get("median"), + "p75": values.get("p75"), + } + for metric, values in benchmarks.items() + }, + "data_sufficiency": "sufficient", + "priority_score": round(float(definition["priority"]) + score, 6), + } + ) + recommendations.sort(key=lambda item: item["priority_score"], reverse=True) + selected = [] + per_code = {} + for item in recommendations: + code = item["diagnosis_code"] + if per_code.get(code, 0) >= 3: + continue + selected.append(item) + per_code[code] = per_code.get(code, 0) + 1 + if len(selected) >= 12: + break + unmatched_count = sum( + 1 + for work in works + if not work.get("publish_job_id") + or str(work.get("match_status") or "") not in MATCHED_STATUSES + ) + missing_metric_count = sum( + 1 + for work in works + if work.get("publish_job_id") + and str(work.get("match_status") or "") in MATCHED_STATUSES + and any(work.get(metric) is None for metric in DIAGNOSIS_CORE_METRICS) + ) + return { + "account_id": account_id, + "generated_at": _now_iso(), + "baseline_batch_id": export_context.get("last_export_batch_id"), + "official_export_weeks": export_context["official_export_weeks"], + "summary": { + "total_works": len(works), + "eligible_works": len(eligible), + "insufficient_works": len(works) - len(eligible), + "unmatched_works": unmatched_count, + "missing_metric_works": missing_metric_count, + "recommendation_count": len(selected), + "cover_metric_available": any(work.get("cover_click_rate") is not None for work in works), + "note": "作品级封面点击率缺失时不会生成封面建议。", + }, + "recommendations": selected, + } + + +def _experiment_metric_summary(rows: list[dict]) -> dict: + normalized = [] + for source in rows: + row = dict(source) + duration = float(row.get("effective_duration_seconds") or 0) + row["watch_ratio"] = ( + float(row["average_watch_seconds"]) / duration + if row.get("average_watch_seconds") is not None and duration > 0 + else None + ) + normalized.append(row) + return { + metric: ( + round(statistics.median(values), 6) + if (values := [float(row[metric]) for row in normalized if row.get(metric) is not None]) + else None + ) + for metric in DIAGNOSIS_CORE_METRICS + } + + +def _experiment_progress(connection, experiment: dict) -> dict: + assigned_count = int( + connection.execute( + "SELECT COUNT(*) FROM content_improvement_experiment_items WHERE experiment_id = ?", + (experiment["id"],), + ).fetchone()[0] + ) + rows = connection.execute( + """ + WITH latest_items AS ( + SELECT i.*, + COALESCE( + NULLIF(i.duration_seconds, 0), + NULLIF(c.duration_seconds, 0), + NULLIF(oc.source_duration_ms, 0) / 1000.0 + ) AS effective_duration_seconds, + ROW_NUMBER() OVER ( + PARTITION BY i.publish_job_id + ORDER BY i.captured_at DESC, i.created_at DESC, i.rowid DESC + ) AS item_rank + FROM content_improvement_experiment_items ei + JOIN douyin_item_metric_snapshots i ON i.publish_job_id = ei.publish_job_id + LEFT JOIN publish_jobs pj ON pj.id = i.publish_job_id + LEFT JOIN output_clip oc ON oc.id = pj.output_clip_id + LEFT JOIN clip_candidates c ON c.id = oc.clip_candidate_id + JOIN content_metric_import_batches b ON b.id = i.batch_id + WHERE ei.experiment_id = ? AND b.status = 'committed' + AND b.source_kind = ? + AND i.match_status IN ('matched_exact', 'matched_unique', 'confirmed_manual') + ) + SELECT * FROM latest_items WHERE item_rank = 1 + """, + (experiment["id"], DOUYIN_ITEM_EXPORT_SOURCE_KIND), + ).fetchall() + week_rows = connection.execute( + """ + SELECT DISTINCT i.captured_at + FROM content_improvement_experiment_items ei + JOIN douyin_item_metric_snapshots i ON i.publish_job_id = ei.publish_job_id + JOIN content_metric_import_batches b ON b.id = i.batch_id + WHERE ei.experiment_id = ? AND b.status = 'committed' + AND b.source_kind = ? + AND i.match_status IN ('matched_exact', 'matched_unique', 'confirmed_manual') + """, + (experiment["id"], DOUYIN_ITEM_EXPORT_SOURCE_KIND), + ).fetchall() + weeks = set() + for week_row in week_rows: + captured = _parse_iso_datetime(week_row["captured_at"]) + if captured is None: + continue + year, week, _ = captured.astimezone(BEIJING_TIMEZONE).isocalendar() + weeks.add(f"{year}-W{week:02d}") + baseline = json.loads(str(experiment.get("baseline_json") or "{}")) + baseline_count = int(baseline.get("work_count") or 0) + treatment_count = len(rows) + target = int(experiment.get("target_sample_size") or 20) + minimum_baseline = int(experiment.get("minimum_baseline_size") or 20) + minimum_weeks = int(experiment.get("minimum_weeks") or 3) + decision_ready = ( + treatment_count >= target + and baseline_count >= minimum_baseline + and len(weeks) >= minimum_weeks + ) + stage = "decision_ready" if decision_ready else ("early" if treatment_count >= 10 else "collecting") + treatment_metrics = _experiment_metric_summary([dict(row) for row in rows]) + primary_metric = str(experiment.get("primary_metric") or "") + baseline_primary = ( + baseline.get("metrics", {}).get(primary_metric, {}).get("median") + ) + treatment_primary = treatment_metrics.get(primary_metric) + primary_delta = ( + round(float(treatment_primary) - float(baseline_primary), 6) + if treatment_primary is not None and baseline_primary is not None + else None + ) + return { + "assigned_count": assigned_count, + "treatment_count": treatment_count, + "baseline_count": baseline_count, + "official_export_weeks": len(weeks), + "official_export_week_keys": sorted(weeks), + "target_sample_size": target, + "minimum_baseline_size": minimum_baseline, + "minimum_weeks": minimum_weeks, + "stage": stage, + "trend_visible": treatment_count >= 10, + "decision_ready": decision_ready, + "treatment_metrics": treatment_metrics, + "baseline_primary": baseline_primary, + "treatment_primary": treatment_primary, + "primary_delta": primary_delta, + } + + +def list_content_experiments(account_id: str = "", *, include_closed: bool = True) -> list[dict]: + resolved = _resolve_douyin_account_id(account_id) + status_filter = "" if include_closed else " AND status = 'active'" + with get_connection() as connection: + rows = connection.execute( + f""" + SELECT * FROM content_improvement_experiments + WHERE account_id = ?{status_filter} + ORDER BY created_at DESC + """, + (resolved,), + ).fetchall() + experiments = [] + for source in rows: + experiment = dict(source) + experiment["guardrail_metrics"] = json.loads( + str(experiment.pop("guardrail_metrics_json") or "[]") + ) + experiment["baseline"] = json.loads( + str(experiment.get("baseline_json") or "{}") + ) + experiment["progress"] = _experiment_progress(connection, experiment) + experiments.append(experiment) + return experiments + + +def list_active_content_experiments_for_publish() -> list[dict]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, account_id, title, action_text, primary_metric, created_at + FROM content_improvement_experiments + WHERE status = 'active' + ORDER BY created_at DESC + """ + ).fetchall() + return [dict(row) for row in rows] + + +def get_content_review_insights(account_id: str = "") -> dict: + resolved = _resolve_douyin_account_id(account_id) + result = _build_content_review_insights(resolved) + result["experiments"] = list_content_experiments(resolved) + return result + + +def create_content_experiment(account_id: str, recommendation_id: str) -> dict: + resolved = _resolve_douyin_account_id(account_id) + insights = _build_content_review_insights(resolved) + recommendation = next( + ( + item + for item in insights["recommendations"] + if item["recommendation_id"] == str(recommendation_id or "").strip() + ), + None, + ) + if recommendation is None: + raise ContentReviewError("这条建议已经更新,请刷新页面后重新选择", status_code=409) + baseline = recommendation["baseline"] + if not baseline.get("batch_id"): + raise ContentReviewError("还没有可冻结的官方作品导出基线", status_code=409) + experiment_id = f"experiment-{uuid4().hex[:16]}" + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + existing = connection.execute( + """ + SELECT id FROM content_improvement_experiments + WHERE account_id = ? AND recommendation_id = ? + """, + (resolved, recommendation["recommendation_id"]), + ).fetchone() + if existing is not None: + connection.rollback() + raise ContentReviewError("这条建议已经记录过实验,不能重复挑选同一批证据", status_code=409) + connection.execute( + """ + INSERT INTO content_improvement_experiments ( + id, account_id, recommendation_id, diagnosis_code, title, + hypothesis, action_text, primary_metric, primary_direction, + guardrail_metrics_json, baseline_batch_id, baseline_json, + target_sample_size, minimum_baseline_size, minimum_weeks, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 20, 20, 3, 'active', ?, ?) + """, + ( + experiment_id, + resolved, + recommendation["recommendation_id"], + recommendation["diagnosis_code"], + recommendation["title"], + recommendation["hypothesis"], + recommendation["action_text"], + recommendation["primary_metric"], + recommendation["primary_direction"], + json.dumps(recommendation["guardrail_metrics"], ensure_ascii=False), + baseline["batch_id"], + json.dumps(baseline, ensure_ascii=False), + now, + now, + ), + ) + connection.commit() + return { + "status": "created", + "message": "实验已建立。请在发送中心投稿前标记实际采用该规则的作品。", + "experiment_id": experiment_id, + "experiment": next( + item for item in list_content_experiments(resolved) if item["id"] == experiment_id + ), + } + + +def update_content_experiment(experiment_id: str, decision: str) -> dict: + normalized = str(decision or "").strip().lower() + if normalized not in EXPERIMENT_DECISIONS: + raise ContentReviewError("实验结论只能是保留、回退、结论不足或取消") + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT * FROM content_improvement_experiments WHERE id = ?", + (experiment_id,), + ).fetchone() + if row is None: + connection.rollback() + raise ContentReviewError("内容实验不存在", status_code=404) + experiment = dict(row) + if experiment["status"] != "active": + connection.rollback() + raise ContentReviewError("这个实验已经结束", status_code=409) + progress = _experiment_progress(connection, experiment) + if normalized != "cancel" and not progress["decision_ready"]: + connection.rollback() + raise ContentReviewError("样本或官方导出周数还不足,暂时不能记录最终结论", status_code=409) + status = "cancelled" if normalized == "cancel" else "completed" + stored_decision = None if normalized == "cancel" else normalized + connection.execute( + """ + UPDATE content_improvement_experiments + SET status = ?, decision = ?, completed_at = ?, updated_at = ? + WHERE id = ? + """, + (status, stored_decision, now, now, experiment_id), + ) + connection.commit() + return {"status": status, "decision": stored_decision, "message": "实验结论已记录。"} + + +def _assert_experiment_job_editable(job: dict) -> None: + status = str(job.get("status") or "").upper() + if ( + status not in EXPERIMENT_EDITABLE_JOB_STATUSES + or job.get("claimed_at") + or job.get("started_at") + or int(job.get("attempt_count") or 0) > 0 + ): + raise ContentReviewError("投稿执行已经开始,实验归属已冻结", status_code=409) + + +def assign_publish_job_to_experiment(experiment_id: str, publish_job_id: str) -> dict: + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + experiment_row = connection.execute( + "SELECT * FROM content_improvement_experiments WHERE id = ?", + (experiment_id,), + ).fetchone() + if experiment_row is None: + connection.rollback() + raise ContentReviewError("内容实验不存在", status_code=404) + experiment = dict(experiment_row) + if experiment["status"] != "active": + connection.rollback() + raise ContentReviewError("只能关联进行中的实验", status_code=409) + job_row = connection.execute( + "SELECT * FROM publish_jobs WHERE id = ?", + (publish_job_id,), + ).fetchone() + if job_row is None: + connection.rollback() + raise ContentReviewError("发布内容不存在", status_code=404) + job = dict(job_row) + _assert_experiment_job_editable(job) + if job.get("platform") != "douyin" or job.get("account_id") != experiment["account_id"]: + connection.rollback() + raise ContentReviewError("实验与发布内容的抖音账号不一致", status_code=409) + existing = connection.execute( + """ + SELECT experiment_id FROM content_improvement_experiment_items + WHERE publish_job_id = ? + """, + (publish_job_id,), + ).fetchone() + if existing is not None: + if existing["experiment_id"] == experiment_id: + connection.commit() + return {"status": "already_assigned", "experiment_id": experiment_id} + connection.rollback() + raise ContentReviewError("这条作品已经属于另一个实验", status_code=409) + connection.execute( + """ + INSERT INTO content_improvement_experiment_items ( + id, experiment_id, publish_job_id, assigned_at + ) VALUES (?, ?, ?, ?) + """, + (f"experiment-item-{uuid4().hex[:16]}", experiment_id, publish_job_id, now), + ) + connection.commit() + return {"status": "assigned", "experiment_id": experiment_id, "publish_job_id": publish_job_id} + + +def set_publish_job_experiment(publish_job_id: str, experiment_id: str = "") -> dict: + """原子设置实验归属,避免前端先删旧关联、再加新关联时留下中间状态。""" + + normalized_experiment_id = str(experiment_id or "").strip() + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + job_row = connection.execute( + "SELECT * FROM publish_jobs WHERE id = ?", + (publish_job_id,), + ).fetchone() + if job_row is None: + connection.rollback() + raise ContentReviewError("发布内容不存在", status_code=404) + job = dict(job_row) + _assert_experiment_job_editable(job) + existing = connection.execute( + """ + SELECT id, experiment_id + FROM content_improvement_experiment_items + WHERE publish_job_id = ? + """, + (publish_job_id,), + ).fetchone() + if existing is not None: + current = connection.execute( + "SELECT status FROM content_improvement_experiments WHERE id = ?", + (existing["experiment_id"],), + ).fetchone() + if current is not None and current["status"] != "active": + connection.rollback() + raise ContentReviewError("实验已经结束,作品归属已冻结", status_code=409) + if not normalized_experiment_id: + if existing is not None: + connection.execute( + "DELETE FROM content_improvement_experiment_items WHERE id = ?", + (existing["id"],), + ) + connection.commit() + return {"status": "removed" if existing is not None else "not_assigned", "publish_job_id": publish_job_id} + + experiment_row = connection.execute( + "SELECT * FROM content_improvement_experiments WHERE id = ?", + (normalized_experiment_id,), + ).fetchone() + if experiment_row is None: + connection.rollback() + raise ContentReviewError("内容实验不存在", status_code=404) + experiment = dict(experiment_row) + if experiment["status"] != "active": + connection.rollback() + raise ContentReviewError("只能关联进行中的实验", status_code=409) + if job.get("platform") != "douyin" or job.get("account_id") != experiment["account_id"]: + connection.rollback() + raise ContentReviewError("实验与发布内容的抖音账号不一致", status_code=409) + if existing is not None and existing["experiment_id"] == normalized_experiment_id: + connection.commit() + return {"status": "already_assigned", "experiment_id": normalized_experiment_id} + if existing is None: + connection.execute( + """ + INSERT INTO content_improvement_experiment_items ( + id, experiment_id, publish_job_id, assigned_at + ) VALUES (?, ?, ?, ?) + """, + ( + f"experiment-item-{uuid4().hex[:16]}", + normalized_experiment_id, + publish_job_id, + now, + ), + ) + else: + connection.execute( + """ + UPDATE content_improvement_experiment_items + SET experiment_id = ?, assigned_at = ? + WHERE id = ? + """, + (normalized_experiment_id, now, existing["id"]), + ) + connection.commit() + return { + "status": "assigned" if existing is None else "reassigned", + "experiment_id": normalized_experiment_id, + "publish_job_id": publish_job_id, + } + + +def remove_publish_job_from_experiment(experiment_id: str, publish_job_id: str) -> dict: + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + item = connection.execute( + """ + SELECT ei.id, e.status AS experiment_status + FROM content_improvement_experiment_items ei + JOIN content_improvement_experiments e ON e.id = ei.experiment_id + WHERE ei.experiment_id = ? AND ei.publish_job_id = ? + """, + (experiment_id, publish_job_id), + ).fetchone() + if item is None: + connection.commit() + return {"status": "not_assigned"} + job_row = connection.execute( + "SELECT * FROM publish_jobs WHERE id = ?", + (publish_job_id,), + ).fetchone() + if job_row is None: + connection.rollback() + raise ContentReviewError("发布内容不存在", status_code=404) + _assert_experiment_job_editable(dict(job_row)) + if item["experiment_status"] != "active": + connection.rollback() + raise ContentReviewError("实验已经结束,作品归属已冻结", status_code=409) + connection.execute( + "DELETE FROM content_improvement_experiment_items WHERE id = ?", + (item["id"],), + ) + connection.commit() + return {"status": "removed", "publish_job_id": publish_job_id} + + def set_item_match(snapshot_id: str, publish_job_id: str) -> dict: now = _now_iso() with get_connection() as connection: diff --git a/app/services/publish_service.py b/app/services/publish_service.py index f20a3f9..65d657e 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -2847,7 +2847,13 @@ def get_publish_job(job_id: str) -> dict | None: output_clip.is_active AS output_is_active, publish_accounts.account_name, publish_accounts.login_status AS account_login_status, - publish_accounts.login_message AS account_login_message + publish_accounts.login_message AS account_login_message, + ( + SELECT ei.experiment_id + FROM content_improvement_experiment_items ei + WHERE ei.publish_job_id = publish_jobs.id + LIMIT 1 + ) AS content_experiment_id FROM publish_jobs LEFT JOIN tasks ON tasks.id = publish_jobs.task_id LEFT JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id @@ -2873,7 +2879,13 @@ def list_publish_jobs(limit: int | None = 100, *, worker_state: dict | None = No output_clip.is_active AS output_is_active, publish_accounts.account_name, publish_accounts.login_status AS account_login_status, - publish_accounts.login_message AS account_login_message + publish_accounts.login_message AS account_login_message, + ( + SELECT ei.experiment_id + FROM content_improvement_experiment_items ei + WHERE ei.publish_job_id = publish_jobs.id + LIMIT 1 + ) AS content_experiment_id FROM publish_jobs LEFT JOIN tasks ON tasks.id = publish_jobs.task_id LEFT JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id @@ -4750,6 +4762,8 @@ def get_publish_center_context(*, focus_task_id: str = "") -> dict: } missing_cover_count = sum(missing_cover_counts.values()) opencli_status = _opencli_status() + from app.services.content_review_service import list_active_content_experiments_for_publish + return { "publish_items": publish_items, "send_queue_items": queue_items, @@ -4763,6 +4777,7 @@ def get_publish_center_context(*, focus_task_id: str = "") -> dict: "jobs_by_platform": jobs_by_platform, "platforms": [{"id": platform, "label": PLATFORM_LABELS[platform]} for platform in AUTO_PUBLISH_PLATFORMS], "accounts": list_accounts(), + "content_experiments": list_active_content_experiments_for_publish(), "app_timezone": settings.app_timezone, "opencli_available": opencli_status["available"], "opencli_status": opencli_status, diff --git a/app/static/css/content-review.css b/app/static/css/content-review.css index ba5b5c6..eebc139 100644 --- a/app/static/css/content-review.css +++ b/app/static/css/content-review.css @@ -1,3 +1,14 @@ +.content-review-heading, +.content-review-sync-grid, +.content-review-summary-panel, +.content-review-works-panel, +.content-review-insights-panel, +.content-review-bottom-grid { + --muted-text: var(--muted); + --border-color: var(--line); + --accent-color: var(--blue); +} + .content-review-heading { align-items: end; } @@ -43,17 +54,41 @@ .content-review-import-form { display: flex; + align-items: center; flex-wrap: wrap; gap: 10px; } -.content-review-import-form input[type="file"] { - flex: 1 1 260px; - min-height: 44px; - padding: 9px 12px; - border: 1px solid var(--border-color); - border-radius: 12px; - background: rgba(255, 255, 255, 0.78); +.content-review-import-form input.visually-hidden-file { + display: none; +} + +.content-review-import-form .file-upload-button { + min-width: 142px; + margin: 0; +} + +.content-review-file-name { + flex: 1 1 190px; + min-width: 0; + overflow: hidden; + color: var(--muted-text); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.content-review-export-facts { + display: grid; + gap: 5px; + padding: 13px 14px; + border: 1px solid rgba(0, 122, 255, 0.16); + border-radius: 14px; + background: rgba(0, 122, 255, 0.045); +} + +.content-review-export-facts small { + color: var(--muted-text); } .content-review-preview { @@ -90,7 +125,8 @@ } .content-review-summary-panel, -.content-review-works-panel { +.content-review-works-panel, +.content-review-insights-panel { margin-bottom: 18px; } @@ -133,7 +169,47 @@ .content-review-history-wrap { overflow-x: auto; + margin-top: 12px; +} + +.content-review-disclosure { margin-top: 18px; + border-top: 1px solid var(--border-color); +} + +.content-review-disclosure > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 2px 2px; + cursor: pointer; + list-style: none; +} + +.content-review-disclosure > summary::-webkit-details-marker { + display: none; +} + +.content-review-disclosure > summary span { + font-weight: 750; +} + +.content-review-disclosure > summary small { + color: var(--muted-text); + text-align: right; +} + +.content-review-disclosure > summary::after { + content: "展开"; + flex: 0 0 auto; + color: var(--accent-color); + font-size: 12px; + font-weight: 750; +} + +.content-review-disclosure[open] > summary::after { + content: "收起"; } .content-review-table { @@ -196,14 +272,18 @@ } .content-review-prompt-list, -.content-review-import-list { +.content-review-import-list, +.content-review-insight-list, +.content-review-experiment-list { display: grid; gap: 10px; margin-top: 14px; } .content-review-prompt-item, -.content-review-import-item { +.content-review-import-item, +.content-review-insight-item, +.content-review-experiment-item { padding: 14px; border: 1px solid var(--border-color); border-radius: 14px; @@ -211,12 +291,76 @@ } .content-review-prompt-item header, -.content-review-import-item header { +.content-review-import-item header, +.content-review-insight-item header, +.content-review-experiment-item header, +.content-review-experiment-heading { display: flex; + align-items: start; justify-content: space-between; gap: 12px; } +.content-review-insight-item { + display: grid; + gap: 12px; +} + +.content-review-insight-evidence, +.content-review-experiment-progress { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.content-review-insight-evidence div, +.content-review-experiment-progress div { + display: grid; + gap: 3px; + padding: 10px; + border-radius: 11px; + background: rgba(242, 247, 255, 0.82); +} + +.content-review-insight-evidence small, +.content-review-experiment-progress small, +.content-review-insight-item p, +.content-review-experiment-item p, +.content-review-experiment-heading small { + color: var(--muted-text); +} + +.content-review-insight-action { + padding-left: 12px; + border-left: 3px solid var(--accent-color); +} + +.content-review-experiment-heading { + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid var(--border-color); +} + +.content-review-experiment-heading h3 { + margin: 3px 0 0; +} + +.content-review-experiment-item.is-completed { + opacity: 0.76; +} + +.content-review-prompt-evidence { + margin-top: 0; + border-top: 0; +} + +.content-review-prompt-item .content-review-sample-gap { + display: block; + margin-top: 8px; + color: var(--muted-text); + font-size: 12px; +} + .content-review-prompt-item dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -279,10 +423,22 @@ .content-review-metric-grid, .content-review-preview dl, - .content-review-prompt-item dl { + .content-review-prompt-item dl, + .content-review-insight-evidence, + .content-review-experiment-progress { grid-template-columns: 1fr; } + .content-review-disclosure > summary, + .content-review-experiment-heading { + align-items: stretch; + flex-direction: column; + } + + .content-review-disclosure > summary small { + text-align: left; + } + .content-review-metric { min-height: 118px; } diff --git a/app/static/js/content-review.js b/app/static/js/content-review.js index 8b2f63c..7f308bb 100644 --- a/app/static/js/content-review.js +++ b/app/static/js/content-review.js @@ -2,11 +2,14 @@ const contentReviewAccount = document.querySelector("#content-review-account"); const contentReviewMessage = document.querySelector("#content-review-message"); const contentReviewImportForm = document.querySelector("#content-review-import-form"); const contentReviewFile = document.querySelector("#content-review-file"); +const contentReviewFileName = document.querySelector("#content-review-file-name"); +const contentReviewPreviewButton = document.querySelector("#content-review-preview-button"); const contentReviewPreview = document.querySelector("#content-review-preview"); const contentReviewCommit = document.querySelector("#content-review-commit"); const contentReviewSync = document.querySelector("#content-review-sync"); let previewBatchId = ""; +let contentReviewLoadSequence = 0; function currentAccountId() { return contentReviewAccount?.value || ""; @@ -75,17 +78,52 @@ function formatDateTime(value) { if (!value) return "—"; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return String(value); - return parsed.toLocaleString("zh-CN", { hour12: false }); + const parts = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Asia/Shanghai", + }).formatToParts(parsed); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}`; +} + +function initializeDisclosures() { + document.querySelectorAll("[data-content-review-disclosure]").forEach((details) => { + const key = `niuma.content-review.${details.dataset.contentReviewDisclosure}.open`; + details.open = window.localStorage.getItem(key) === "true"; + details.addEventListener("toggle", () => { + window.localStorage.setItem(key, details.open ? "true" : "false"); + }); + }); } function renderSummary(summary) { const latestDate = document.querySelector("#content-review-latest-date"); const syncAge = document.querySelector("#content-review-sync-age"); + const lastExport = document.querySelector("#content-review-last-export"); + const lastExportStats = document.querySelector("#content-review-last-export-stats"); + const matchSummary = document.querySelector("#content-review-match-summary"); if (latestDate) latestDate.textContent = summary.latest_metric_date || "暂无数据"; if (syncAge) { - syncAge.textContent = summary.days_since_sync == null - ? "尚未同步" - : summary.days_since_sync === 0 ? "今天已同步" : `${summary.days_since_sync} 天前同步`; + syncAge.textContent = summary.last_export_committed_at ? "已同步" : "尚未同步"; + } + if (lastExport) { + lastExport.textContent = summary.last_export_committed_at + ? `上次成功导出:北京时间 ${formatDateTime(summary.last_export_committed_at)}` + : "上次成功导出:尚未同步"; + } + if (lastExportStats) { + lastExportStats.textContent = summary.last_export_batch_id + ? `${summary.last_export_row_count || 0} 条作品 · 已匹配 ${summary.last_export_matched_count || 0} · 待确认 ${summary.last_export_ambiguous_count || 0} · 未匹配 ${summary.last_export_unmatched_count || 0}` + : "等待官方作品数据"; + } + if (matchSummary) { + const counts = summary.match_summary || {}; + matchSummary.textContent = `${counts.matched || 0} 已匹配 / ${counts.ambiguous || 0} 待确认 / ${counts.unmatched || 0} 未匹配`; } document.querySelectorAll("[data-metric]").forEach((card) => { const key = card.dataset.metric; @@ -100,7 +138,8 @@ function renderSummary(summary) { deltaNode.textContent = "暂无可比基线"; } else { deltaNode.textContent = `${delta >= 0 ? "↑" : "↓"} ${Math.abs(Number(delta) * 100).toFixed(1)}%`; - deltaNode.classList.add(delta >= 0 ? "is-up" : "is-down"); + const improvement = key === "two_second_bounce_rate" ? delta <= 0 : delta >= 0; + deltaNode.classList.add(improvement ? "is-up" : "is-down"); } }); @@ -151,12 +190,12 @@ function appendDetailedCell(row, primary, details = []) { function matchLabel(status) { return { - matched_exact: "作品 ID 精确匹配", - matched_unique: "标题 / 正文 + 时间唯一匹配", - confirmed_manual: "人工确认", - ambiguous: "存在多个候选", - unmatched: "未匹配", - }[status] || status || "未匹配"; + matched_exact: "已匹配·唯一证据", + matched_unique: "已匹配·唯一证据", + confirmed_manual: "已匹配·人工确认", + ambiguous: "待人工确认·多个候选", + unmatched: "未匹配·没有候选", + }[status] || status || "未匹配 · 没有候选"; } async function updateItemMatch(snapshotId, publishJobId) { @@ -232,7 +271,7 @@ function renderWorks(works) { const matchCell = document.createElement("td"); const tools = document.createElement("div"); tools.className = "content-review-match-tools"; - tools.append(textNode("strong", matchLabel(work.match_status))); + tools.append(textNode("strong", work.match_label || matchLabel(work.match_status))); if (["ambiguous", "unmatched"].includes(work.match_status)) { const input = document.createElement("input"); input.type = "text"; @@ -277,6 +316,188 @@ function renderWorks(works) { }); } +function metricLabel(key) { + return { + play_count: "播放", + five_second_completion_rate: "5 秒完播", + two_second_bounce_rate: "2 秒跳出", + completion_rate: "完播率", + watch_ratio: "平均观看 / 片长", + }[key] || key; +} + +function formatInsightMetric(key, value) { + if (["five_second_completion_rate", "two_second_bounce_rate", "completion_rate", "watch_ratio"].includes(key)) { + return formatPercent(value); + } + return formatNumber(value); +} + +async function createExperiment(recommendationId, button) { + button.disabled = true; + try { + const data = await contentReviewApi("/api/content-review/experiments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ account_id: currentAccountId(), recommendation_id: recommendationId }), + }); + showContentReviewMessage(data.message || "实验已建立。", "success"); + await loadContentReviewData(); + } catch (error) { + showContentReviewMessage(`建立实验失败:${error.message}`, "error"); + } finally { + button.disabled = false; + } +} + +async function recordExperimentDecision(experimentId, decision, button) { + button.disabled = true; + try { + const data = await contentReviewApi(`/api/content-review/experiments/${encodeURIComponent(experimentId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ decision }), + }); + showContentReviewMessage(data.message || "实验结论已记录。", "success"); + await loadContentReviewData(); + } catch (error) { + showContentReviewMessage(`记录实验失败:${error.message}`, "error"); + } finally { + button.disabled = false; + } +} + +function renderInsights(data) { + const count = document.querySelector("#content-review-insight-count"); + const note = document.querySelector("#content-review-insight-note"); + const list = document.querySelector("#content-review-insights"); + const recommendations = Array.from(data.recommendations || []); + const recordedRecommendationIds = new Set( + (data.experiments || []).map((item) => item.recommendation_id), + ); + if (count) count.textContent = `${recommendations.length} 条建议`; + if (note) { + const summary = data.summary || {}; + note.textContent = `${summary.eligible_works || 0} 条作品证据完整,${summary.insufficient_works || 0} 条证据不足。${summary.cover_metric_available ? "已读取作品级封面指标。" : "官方作品级封面点击率缺失,本轮不生成封面建议。"}`; + } + if (!list) return; + list.replaceChildren(); + if (!recommendations.length) { + list.append(textNode("p", "当前没有达到规则阈值的改进建议。请先同步最新官方作品数据,或继续积累准确匹配作品。", "empty-note")); + return; + } + recommendations.forEach((recommendation) => { + const item = document.createElement("article"); + item.className = "content-review-insight-item"; + const header = document.createElement("header"); + const identity = document.createElement("div"); + identity.append( + textNode("strong", recommendation.title), + textNode("small", recommendation.source_work?.title || "未命名作品"), + ); + header.append(identity, textNode("span", recommendation.baseline?.cohort?.label || "同类对照", "status-pill")); + const hypothesis = textNode("p", recommendation.hypothesis); + const evidence = document.createElement("div"); + evidence.className = "content-review-insight-evidence"; + Object.entries(recommendation.evidence || {}).forEach(([key, value]) => { + const metric = document.createElement("div"); + metric.append(textNode("small", metricLabel(key)), textNode("strong", formatInsightMetric(key, value))); + evidence.append(metric); + }); + const action = textNode("p", `建议动作:${recommendation.action_text}`, "content-review-insight-action"); + const primaryBenchmark = recommendation.comparison_interval?.[recommendation.primary_metric] || {}; + const benchmark = textNode( + "p", + `同类对照 ${recommendation.baseline?.work_count || 0} 条:${metricLabel(recommendation.primary_metric)} P25 ${formatInsightMetric(recommendation.primary_metric, primaryBenchmark.p25)} / 中位数 ${formatInsightMetric(recommendation.primary_metric, primaryBenchmark.median)} / P75 ${formatInsightMetric(recommendation.primary_metric, primaryBenchmark.p75)}。`, + "content-review-insight-benchmark", + ); + const actions = document.createElement("div"); + actions.className = "button-row"; + const recorded = recordedRecommendationIds.has(recommendation.recommendation_id); + const button = textNode("button", recorded ? "已记录实验" : "采纳为下轮实验", recorded ? "secondary-button" : "primary-button"); + button.type = "button"; + button.disabled = recorded; + if (!recorded) button.addEventListener("click", () => createExperiment(recommendation.recommendation_id, button)); + actions.append(button, textNode("small", `主指标:${metricLabel(recommendation.primary_metric)} · 对照 ${recommendation.baseline?.work_count || 0} 条`)); + item.append(header, hypothesis, evidence, benchmark, action, actions); + list.append(item); + }); +} + +function experimentStageLabel(experiment) { + if (experiment.status === "completed") { + return { keep: "已保留改动", revert: "已决定回退", inconclusive: "结论不足" }[experiment.decision] || "已完成"; + } + if (experiment.status === "cancelled") return "已取消"; + return { + collecting: "收集中", + early: "早期趋势", + decision_ready: "可做决定", + }[experiment.progress?.stage] || "收集中"; +} + +function renderExperiments(experiments) { + const list = document.querySelector("#content-review-experiments"); + if (!list) return; + list.replaceChildren(); + if (!(experiments || []).length) { + list.append(textNode("p", "还没有实验。先从上方建议中选择一条,再去发送中心标记实际采用规则的作品。", "empty-note")); + return; + } + experiments.forEach((experiment) => { + const progress = experiment.progress || {}; + const item = document.createElement("article"); + item.className = `content-review-experiment-item${experiment.status === "active" ? "" : " is-completed"}`; + const header = document.createElement("header"); + header.append(textNode("strong", experiment.title), textNode("span", experimentStageLabel(experiment), "status-pill")); + const action = textNode("p", experiment.action_text); + const progressGrid = document.createElement("div"); + progressGrid.className = "content-review-experiment-progress"; + [ + ["投稿前已标记", `${progress.assigned_count || 0} 条`], + ["已有官方指标", `${progress.treatment_count || 0} / ${progress.target_sample_size || 20} 条`], + ["冻结对照", `${progress.baseline_count || 0} / ${progress.minimum_baseline_size || 20} 条`], + ["官方导出周", `${progress.official_export_weeks || 0} / ${progress.minimum_weeks || 3} 周`], + ].forEach(([label, value]) => { + const cell = document.createElement("div"); + cell.append(textNode("small", label), textNode("strong", value)); + progressGrid.append(cell); + }); + const result = textNode( + "p", + !progress.trend_visible + ? `主指标:${metricLabel(experiment.primary_metric)};累计 10 条实验作品后才显示早期趋势。` + : progress.treatment_primary == null + ? `主指标:${metricLabel(experiment.primary_metric)},等待实验作品同步官方指标。` + : `主指标:对照 ${formatInsightMetric(experiment.primary_metric, progress.baseline_primary)} → 实验 ${formatInsightMetric(experiment.primary_metric, progress.treatment_primary)}。`, + ); + const guardrails = textNode( + "small", + `护栏指标:${(experiment.guardrail_metrics || []).map(metricLabel).join("、") || "无"}`, + "content-review-experiment-guardrails", + ); + item.append(header, action, progressGrid, result, guardrails); + if (experiment.status === "active") { + const actions = document.createElement("div"); + actions.className = "button-row"; + if (progress.decision_ready) { + [["保留改动", "keep"], ["回退", "revert"], ["结论不足", "inconclusive"]].forEach(([label, decision]) => { + const button = textNode("button", label, decision === "keep" ? "primary-button" : "secondary-button"); + button.type = "button"; + button.addEventListener("click", () => recordExperimentDecision(experiment.id, decision, button)); + actions.append(button); + }); + } + const cancel = textNode("button", "取消实验", "text-button danger"); + cancel.type = "button"; + cancel.addEventListener("click", () => recordExperimentDecision(experiment.id, "cancel", cancel)); + actions.append(cancel); + item.append(actions); + } + list.append(item); + }); +} + function promptMetric(label, value) { const wrapper = document.createElement("div"); wrapper.append(textNode("dt", label), textNode("dd", value)); @@ -287,7 +508,7 @@ function renderPromptComparison(data) { const cycles = document.querySelector("#content-review-cycles"); const message = document.querySelector("#content-review-prompt-message"); const list = document.querySelector("#content-review-prompts"); - if (cycles) cycles.textContent = `${data.completed_cycles || 0} / ${data.minimum_cycles || 3} 周期`; + if (cycles) cycles.textContent = `${data.completed_cycles || 0} / ${data.minimum_cycles || 3} 个官方导出周`; if (message) message.textContent = data.message || "数据不足。"; if (!list) return; list.replaceChildren(); @@ -314,7 +535,14 @@ function renderPromptComparison(data) { promptMetric("平均观看 / 片长", formatPercent(version.average_watch_ratio)), promptMetric("互动率", formatPercent(version.interaction_rate)), ); - item.append(header, metrics); + const gap = textNode( + "small", + version.evaluable + ? "已达到单版本评估门槛" + : `还差 ${version.remaining_accurate_published_count ?? Math.max(0, 30 - Number(version.accurate_published_count || 0))} 条准确关联作品`, + "content-review-sample-gap", + ); + item.append(header, metrics, gap); list.append(item); }); } @@ -352,23 +580,39 @@ function renderImports(imports) { async function loadContentReviewData() { const accountId = currentAccountId(); if (!accountId) return; + const loadSequence = ++contentReviewLoadSequence; const query = `account_id=${encodeURIComponent(accountId)}`; try { - const [summary, works, prompts, imports] = await Promise.all([ + const [summary, works, prompts, imports, insights] = await Promise.all([ contentReviewApi(`/api/content-review/summary?${query}&days=28`), contentReviewApi(`/api/content-review/works?${query}&limit=200`), contentReviewApi(`/api/content-review/prompt-comparison?${query}`), contentReviewApi(`/api/content-review/imports?${query}&limit=20`), + contentReviewApi(`/api/content-review/insights?${query}`), ]); + if (loadSequence !== contentReviewLoadSequence || accountId !== currentAccountId()) return; renderSummary(summary); renderWorks(works.works || []); renderPromptComparison(prompts); renderImports(imports.imports || []); + renderInsights(insights); + renderExperiments(insights.experiments || []); } catch (error) { + if (loadSequence !== contentReviewLoadSequence) return; showContentReviewMessage(`读取复盘数据失败:${error.message}`, "error"); } } +contentReviewFile?.addEventListener("change", () => { + const file = contentReviewFile.files?.[0]; + previewBatchId = ""; + if (contentReviewPreview) contentReviewPreview.hidden = true; + if (contentReviewFileName) contentReviewFileName.textContent = file?.name || "未选择文件"; + if (contentReviewPreviewButton) contentReviewPreviewButton.disabled = !file; + const status = document.querySelector("#content-review-file-status"); + if (status) status.textContent = file ? "已选择" : "尚未选择"; +}); + contentReviewImportForm?.addEventListener("submit", async (event) => { event.preventDefault(); const file = contentReviewFile?.files?.[0]; @@ -403,7 +647,7 @@ contentReviewImportForm?.addEventListener("submit", async (event) => { if (contentReviewPreview) contentReviewPreview.hidden = true; showContentReviewMessage(`预览失败:${error.message}`, "error"); } finally { - button.disabled = false; + button.disabled = !contentReviewFile?.files?.[0]; } }); @@ -453,4 +697,5 @@ contentReviewAccount?.addEventListener("change", () => { loadContentReviewData(); }); +initializeDisclosures(); loadContentReviewData(); diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js index 1c656f6..7bf83db 100644 --- a/app/static/js/publish-center.js +++ b/app/static/js/publish-center.js @@ -1303,17 +1303,44 @@ if (publishCenterRoot) { if (chosen?.disabled) select.value = ""; } + function syncExperimentOptions(form) { + if (!form) return; + const select = form.elements.content_experiment_id; + if (!select) return; + const accountId = String(form.elements.account_id?.value || ""); + Array.from(select.options).forEach((option) => { + const optionAccountId = option.dataset.accountId || ""; + const unavailable = Boolean(optionAccountId && optionAccountId !== accountId); + option.hidden = unavailable; + option.disabled = unavailable; + }); + if (select.selectedOptions[0]?.disabled) select.value = ""; + } + function syncPlatformFields(form) { if (!form) return; const platform = String(form.elements.platform?.value || "douyin"); const bilibiliFields = form.querySelector("[data-bilibili-fields]"); if (bilibiliFields) bilibiliFields.hidden = platform !== "bilibili"; filterAccountOptions(form.elements.account_id, platform); + syncExperimentOptions(form); const repost = String(form.elements.bilibili_copyright?.value || "original") === "repost"; const source = form.querySelector("[data-repost-source]"); if (source) source.hidden = platform !== "bilibili" || !repost; } + async function saveExperimentAssignment(row, form) { + const jobId = String(row?.dataset.jobId || ""); + const previousId = String(row?.dataset.experimentId || ""); + const nextId = String(form?.elements.content_experiment_id?.value || ""); + if (!jobId || previousId === nextId) return; + await window.apiFetch( + `/api/content-review/experiment-assignments/${encodeURIComponent(jobId)}`, + { method: "PUT", body: JSON.stringify({ experiment_id: nextId }) }, + ); + row.dataset.experimentId = nextId; + } + function openAccountDrawer(platform = "") { if (!accountDrawer || !accountBackdrop) return; if (platform && platform !== activePlatform) setActivePlatform(platform); @@ -1600,7 +1627,11 @@ if (publishCenterRoot) { updateSelectionUi(); } const form = event.target.closest("[data-publish-editor]"); - if (form && (event.target.matches("[data-platform-select]") || event.target.matches("[data-copyright-select]"))) syncPlatformFields(form); + if (form && ( + event.target.matches("[data-platform-select]") + || event.target.matches("[data-copyright-select]") + || event.target.matches("[data-account-select]") + )) syncPlatformFields(form); if (event.target.closest("[data-schedule-form]")) invalidatePreview(); }); @@ -1640,6 +1671,7 @@ if (publishCenterRoot) { try { await window.apiFetch(`/api/publish/jobs/${jobId}/target`, { method: "PATCH", body: JSON.stringify(target) }); const data = await window.apiFetch(`/api/publish/jobs/${jobId}/send-content`, { method: "PATCH", body: JSON.stringify(content) }); + await saveExperimentAssignment(row, form); updateRowFromJob(data.job); if (resultNode) resultNode.textContent = "已保存"; } catch (error) { diff --git a/app/templates/content_review.html b/app/templates/content_review.html index 3b75998..833fd2e 100644 --- a/app/templates/content_review.html +++ b/app/templates/content_review.html @@ -3,7 +3,7 @@ {% block title %}内容复盘 · {{ settings.app_name }}{% endblock %} {% block extra_head %} - + {% endblock %} {% block content %} @@ -48,8 +48,10 @@

导入抖音官方数据表

支持账号趋势表和“作品列表导出.xlsx”,系统按官方表头自动识别。先预览校验,再确认导入。

- - + + + 未选择文件 +

复用 Windows Chrome Worker 的登录态,只点击一次“导出数据”并读取官方 Excel;不会投稿。登录失效、验证码、限流、下载失败或页面变化都会立即停止,不会自动重试。

+
+ 上次成功导出:尚未同步 + 等待官方作品数据 +
@@ -98,12 +104,15 @@

账号趋势

{% endfor %} -
- - - -
日期投稿播放互动5 秒完播2 秒跳出封面点击平均时长
-
+
+ 查看每日详细数据默认收起,点击展开最近 28 天 +
+ + + +
日期投稿播放互动5 秒完播2 秒跳出封面点击平均时长
+
+
@@ -112,25 +121,42 @@

账号趋势

Attribution Chain

作品级归因

- 多结果不强行匹配 + 等待作品数据 + +

只有唯一证据才会自动关联;多个候选表示尚未匹配,必须人工确认。

+
+ 查看全部作品归因展开后可检查未匹配作品并人工确认 +
+ + + +
官方作品 / 来源官方完整指标发布记录候选 / 审核AI / Prompt匹配
+
+
+
+ +
+
+

Diagnose → Act → Verify

本周改进建议

+ 等待诊断
-
- - - -
官方作品 / 来源官方完整指标发布记录候选 / 审核AI / Prompt匹配
+

系统只使用已准确匹配的官方指标生成透明建议,不调用 AI,也不会自动修改 Prompt 或投稿。

+
+
+

Controlled Experiments

改进实验

+ 投稿前标记采用规则的作品;执行开始后归属自动冻结。
+
-
-

Prompt Evidence

Prompt 版本对比

- 0 / 3 周期 -
-

数据不足,暂不生成结论。

-
-

仅相关性,不代表因果;系统不会自动修改 Prompt。

+
+ Prompt 与实验依据0 / 3 个官方导出周 +

数据不足,暂不生成 Prompt 结论。

+
+

历史未绑定 Prompt 的作品只作为观察基线;所有对比仅表示相关性。

+
@@ -144,5 +170,5 @@

作品级归因

{% endblock %} {% block extra_scripts %} -{% if accounts %}{% endif %} +{% if accounts %}{% endif %} {% endblock %} diff --git a/app/templates/publish.html b/app/templates/publish.html index ba870cf..ebc0a91 100644 --- a/app/templates/publish.html +++ b/app/templates/publish.html @@ -12,7 +12,7 @@ {% endmacro %} {% macro content_row(job) %} -
+
@@ -60,6 +60,16 @@ + diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 4e52a97..2e5f8d6 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,5 +1,23 @@ # 数据库结构说明 +## 2026-08-29:内容诊断与实验闭环 + +迁移账本版本为 `20260829_02_content_feedback_loop`。迁移只新增表和索引,不删除或改写现有作品快照、发布记录、排期或 Prompt 版本;应用前继续通过 SQLite Online Backup 生成 `workflow-before-content-feedback-loop-*` 备份。 + +### `content_improvement_experiments` + +- 以 `account_id + recommendation_id` 唯一标识一次基于固定证据批次的实验,保存诊断类型、假设、唯一动作、主指标方向、护栏指标、基线批次及冻结的中位数/四分位数 JSON。 +- 固定门槛为实验 20 条、对照 20 条、3 个不同官方导出周;状态为 `active / completed / cancelled`,人工结论为 `keep / revert / inconclusive`。 +- `baseline_batch_id` 外键指向官方导入批次;创建后不会随着后续数据重算或改写冻结基线。 + +### `content_improvement_experiment_items` + +- 关联实验与 `publish_jobs`,`publish_job_id` 全局唯一,保证一个作品同一时刻只有一个实验归属。 +- 只允许在 `DRAFT / WAITING / SCHEDULED` 且没有 `claimed_at / started_at / attempt_count` 执行证据时关联、切换或解除;执行开始后永久冻结该作品的实验归属。 +- 发送中心使用原子设置接口完成新增、切换和解除,不会留下“旧关联已删、新关联未写”的中间状态。 + +诊断本身为只读查询:只读取当前账号最新的 `douyin_item_export` 快照,未匹配、多个候选或核心指标缺失的作品标记为证据不足。官方导出周按 `captured_at` 的北京时间 ISO 自然周去重;账号趋势表不参与周数。Prompt 统计同样按账号和官方作品来源隔离。 + ## 2026-08-29:抖音官方作品报表完整指标 - 新迁移账本版本:`20260829_01_douyin_official_item_export`。它只追加字段,不修改已应用的 `20260828_01_content_review_v1` 名称、定义或 checksum。 diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index e6b648c..d07b1d0 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,16 @@ # UI 参考说明 +## 2026-08-29 更新:内容复盘可执行闭环 + +- 自动导出卡固定展示精确北京时间和本批作品/匹配统计;文件上传使用蓝色自定义按钮、独立文件名和禁用态预览按钮,不展示浏览器原生文件控件。 +- 账号趋势指标卡常显,每日明细默认折叠;2 秒跳出率是反向指标,下降为绿色、上升为红色。作品归因默认折叠,标题区常显“已匹配 / 待确认 / 未匹配”。 +- 所有折叠区用原生 `details/summary`,状态保存到当前浏览器 `localStorage`;无 JavaScript 时仍可手动展开,不依赖前端框架。 +- “本周改进建议”卡显示来源作品、证据指标、同类对照的 P25/中位数/P75、唯一建议动作、主指标与采纳按钮;证据不足和缺失封面指标只说明原因,不生成伪结论。 +- 实验卡显示已标记、已有官方指标、冻结对照和官方导出周;10 条前不展示结果,10 条起标为“早期趋势”,达到 20+20+3 周后才展示人工决定按钮。 +- “Prompt 与实验依据”降级为默认折叠的高级区,显示真实官方导出周和每个版本缺少的准确作品数。 +- 发送中心内容卡增加可选实验下拉,只展示当前发布账号可用的实验;保存时原子更新实验归属,提示执行开始后冻结。 +- 桌面 1440px 与窄屏 390px 均保持页面无整体横向溢出;宽作品表只在自己的滚动容器内滚动。 + ## 2026-08-29 更新:官方作品报表全量同步 - 内容复盘左侧人工导入卡支持账号趋势表和官方 `作品列表导出.xlsx`,预览区明确显示识别类型、日期范围、行数和归因口径,再由用户确认导入。 diff --git a/tests/test_content_review.py b/tests/test_content_review.py index 869c14b..31398bc 100644 --- a/tests/test_content_review.py +++ b/tests/test_content_review.py @@ -1,7 +1,10 @@ from __future__ import annotations import csv +from datetime import datetime +import hashlib import io +import json from uuid import uuid4 from fastapi.testclient import TestClient @@ -34,6 +37,19 @@ def _cleanup() -> None: ).fetchall() account_ids = [row["id"] for row in account_rows] for account_id in account_ids: + experiment_rows = connection.execute( + "SELECT id FROM content_improvement_experiments WHERE account_id = ?", + (account_id,), + ).fetchall() + for experiment in experiment_rows: + connection.execute( + "DELETE FROM content_improvement_experiment_items WHERE experiment_id = ?", + (experiment["id"],), + ) + connection.execute( + "DELETE FROM content_improvement_experiments WHERE account_id = ?", + (account_id,), + ) batch_rows = connection.execute( "SELECT id FROM content_metric_import_batches WHERE account_id = ?", (account_id,), @@ -58,6 +74,8 @@ def _cleanup() -> None: connection.execute("DELETE FROM clip_candidates WHERE task_id LIKE ?", (f"{PREFIX}%",)) connection.execute("DELETE FROM ai_analysis_runs WHERE task_id LIKE ?", (f"{PREFIX}%",)) connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM ai_prompt_versions WHERE preset_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM ai_prompt_presets WHERE id LIKE ?", (f"{PREFIX}%",)) connection.execute("DELETE FROM publish_accounts WHERE id LIKE ?", (f"{PREFIX}%",)) connection.commit() @@ -389,6 +407,7 @@ def _insert_publish_job( duration_seconds: int = 60, description: str = "", caption: str = "", + status: str = "PUBLISHED", ) -> str: suffix = uuid4().hex[:8] task_id = f"{PREFIX}task-{suffix}" @@ -414,7 +433,7 @@ def _insert_publish_job( INSERT INTO publish_jobs ( id, task_id, output_clip_id, account_id, platform, title, description, caption, status, platform_item_id, published_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, 'douyin', ?, ?, ?, 'PUBLISHED', ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, 'douyin', ?, ?, ?, ?, ?, ?, ?, ?) """, ( job_id, @@ -424,6 +443,7 @@ def _insert_publish_job( title, description, caption, + status, platform_item_id, published_at, now, @@ -434,6 +454,129 @@ def _insert_publish_job( return job_id +def _diagnosis_item(index: int, *, title: str, published_at: str, **overrides) -> dict: + item = { + "aweme_id": f"export:test-{index}-{uuid4().hex[:6]}", + "title": title, + "published_at": published_at, + "duration_seconds": 60, + "content_genre": "视频", + "audit_status": "审核通过", + "play_count": 1000 + index * 100, + "completion_rate": 0.45 + index * 0.005, + "five_second_completion_rate": 0.60 + index * 0.005, + "two_second_bounce_rate": 0.20 + index * 0.003, + "average_watch_seconds": 30 + index, + "cover_click_rate": None, + "like_count": 20, + "comment_count": 5, + "share_count": 3, + } + item.update(overrides) + return item + + +def _seed_diagnosis_baseline(account_id: str, *, captured_at: str = "2026-08-29T12:00:00+08:00") -> dict: + items = [] + for index in range(9): + published_at = f"2026-08-2{index}T10:00:00+08:00" + title = f"诊断基线作品 {index}" + _insert_publish_job( + account_id, + title=title, + published_at=published_at, + duration_seconds=60, + ) + overrides = {} + if index == 8: + overrides = { + "five_second_completion_rate": 0.30, + "two_second_bounce_rate": 0.55, + "completion_rate": 0.12, + "average_watch_seconds": 12, + } + items.append( + _diagnosis_item( + index, + title=title, + published_at=published_at, + **overrides, + ) + ) + items.append( + _diagnosis_item( + 99, + title="没有候选的作品", + published_at="2026-08-28T18:00:00+08:00", + ) + ) + return content_review_service.commit_douyin_item_export( + account_id=account_id, + items=items, + captured_at=captured_at, + source_filename="作品列表导出.xlsx", + ) + + +def _attach_prompt_chain(job_id: str, label: str) -> str: + suffix = uuid4().hex[:8] + preset_id = f"{PREFIX}preset-{label}-{suffix}" + version_id = f"{PREFIX}version-{label}-{suffix}" + run_id = f"{PREFIX}run-{label}-{suffix}" + candidate_id = f"{PREFIX}candidate-{label}-{suffix}" + prompt_text = f"{label} prompt" + prompt_hash = hashlib.sha256(prompt_text.encode("utf-8")).hexdigest() + now = "2026-08-28T10:00:00+08:00" + with get_connection() as connection: + job = connection.execute( + "SELECT task_id, output_clip_id FROM publish_jobs WHERE id = ?", + (job_id,), + ).fetchone() + slot = int(connection.execute("SELECT COALESCE(MAX(slot), 1000) + 1 FROM ai_prompt_presets").fetchone()[0]) + connection.execute( + """ + INSERT INTO ai_prompt_presets ( + id, slot, name, prompt_text, is_default, created_at, updated_at + ) VALUES (?, ?, ?, ?, 0, ?, ?) + """, + (preset_id, slot, label, prompt_text, now, now), + ) + connection.execute( + """ + INSERT INTO ai_prompt_versions ( + id, preset_id, version_number, preset_name_snapshot, + prompt_text, prompt_sha256, created_at + ) VALUES (?, ?, 1, ?, ?, ?, ?) + """, + (version_id, preset_id, label, prompt_text, prompt_hash, now), + ) + connection.execute( + """ + INSERT INTO ai_analysis_runs ( + id, task_id, run_number, provider, provider_label, model, + prompt_version_id, prompt_text_sha256, requested_clip_count, + clip_count, analysis_payload_json, created_at + ) VALUES (?, ?, 1, 'test', 'Test', 'test-model', ?, ?, 1, 1, ?, ?) + """, + (run_id, job["task_id"], version_id, prompt_hash, json.dumps({"clips": []}), now), + ) + connection.execute( + """ + INSERT INTO clip_candidates ( + id, task_id, clip_key, title, start_time, end_time, + duration_seconds, source_analysis_run_id, created_at, updated_at + ) VALUES (?, ?, 'clip_001', ?, '00:00:00', '00:01:00', 60, ?, ?, ?) + """, + (candidate_id, job["task_id"], label, run_id, now, now), + ) + connection.execute( + "UPDATE output_clip SET clip_candidate_id = ? WHERE id = ?", + (candidate_id, job["output_clip_id"]), + ) + connection.commit() + return version_id + + def test_item_matching_exact_unique_ambiguous_and_manual_confirmation(): account_id = _insert_account() exact_job = _insert_publish_job( @@ -501,6 +644,8 @@ def test_item_matching_exact_unique_ambiguous_and_manual_confirmation(): ], ) assert sync["ambiguous_count"] == 1 + works = content_review_service.list_content_review_works(account_id) + assert works[0]["match_label"] == "待人工确认·多个候选" with get_connection() as connection: snapshot_id = connection.execute( "SELECT id FROM douyin_item_metric_snapshots WHERE title = '同名作品'" @@ -508,6 +653,7 @@ def test_item_matching_exact_unique_ambiguous_and_manual_confirmation(): content_review_service.set_item_match(snapshot_id, ambiguous_a) works = content_review_service.list_content_review_works(account_id) assert works[0]["match_status"] == "confirmed_manual" + assert works[0]["match_label"] == "已匹配·人工确认" assert works[0]["publish_job_id"] == ambiguous_a content_review_service.delete_item_match(snapshot_id) works = content_review_service.list_content_review_works(account_id) @@ -704,3 +850,332 @@ def rate_limited(_self, *, account_id): ) assert response.status_code == 429 assert response.json()["detail"]["error_code"] == "RATE_LIMITED" + + +def test_summary_uses_latest_official_export_and_deduplicates_same_week(monkeypatch): + account_id = _insert_account() + job_id = _insert_publish_job( + account_id, + title="周口径测试作品", + published_at="2026-08-28T10:00:00+08:00", + ) + clock = {"value": datetime.fromisoformat("2026-08-29T00:48:37+08:00")} + monkeypatch.setattr(content_review_service, "_now", lambda: clock["value"]) + first = content_review_service.commit_douyin_item_export( + account_id=account_id, + items=[ + _diagnosis_item( + 1, + title="周口径测试作品", + published_at="2026-08-28T10:00:00+08:00", + ) + ], + captured_at="2026-08-29T00:47:00+08:00", + source_filename="作品列表导出.xlsx", + ) + clock["value"] = datetime.fromisoformat("2026-08-30T08:15:00+08:00") + second = content_review_service.commit_douyin_item_export( + account_id=account_id, + items=[ + _diagnosis_item( + 1, + title="周口径测试作品", + published_at="2026-08-28T10:00:00+08:00", + play_count=2222, + ) + ], + captured_at="2026-08-30T08:14:00+08:00", + source_filename="作品列表导出.xlsx", + ) + clock["value"] = datetime.fromisoformat("2026-09-01T09:00:00+08:00") + preview = content_review_service.preview_metric_import( + account_id=account_id, + filename="账号趋势.xlsx", + content=_xlsx_bytes(), + ) + content_review_service.commit_metric_import(preview["batch_id"]) + + summary = content_review_service.get_content_review_summary(account_id) + + assert first["matched_count"] == second["matched_count"] == 1 + assert summary["last_export_batch_id"] == second["batch_id"] + assert summary["last_export_committed_at"] == "2026-08-30T08:15:00+08:00" + assert summary["last_export_row_count"] == 1 + assert summary["last_export_matched_count"] == 1 + assert summary["last_export_unmatched_count"] == 0 + assert summary["official_export_weeks"] == 1 + assert summary["completed_cycles"] == 1 + assert summary["last_sync_at"] == "2026-09-01T09:00:00+08:00" + assert job_id + + +def test_diagnosis_uses_matched_metrics_and_skips_missing_cover_or_unmatched_works(): + account_id = _insert_account() + export = _seed_diagnosis_baseline(account_id) + + insights = content_review_service.get_content_review_insights(account_id) + works = content_review_service.list_content_review_works(account_id) + + assert export["matched_count"] == 9 + assert export["ambiguous_count"] == 0 + assert insights["summary"] == { + "total_works": 10, + "eligible_works": 9, + "insufficient_works": 1, + "unmatched_works": 1, + "missing_metric_works": 0, + "recommendation_count": len(insights["recommendations"]), + "cover_metric_available": False, + "note": "作品级封面点击率缺失时不会生成封面建议。", + } + weak_opening = next( + item for item in insights["recommendations"] if item["diagnosis_code"] == "weak_opening" + ) + assert weak_opening["data_sufficiency"] == "sufficient" + assert weak_opening["source_work"]["title"] == "诊断基线作品 8" + assert weak_opening["comparison_interval"]["two_second_bounce_rate"]["p75"] is not None + assert all(item["source_work"]["title"] != "没有候选的作品" for item in insights["recommendations"]) + labels = {work["match_status"]: work["match_label"] for work in works} + assert labels["matched_unique"] == "已匹配·唯一证据" + assert labels["unmatched"] == "未匹配·没有候选" + + +def test_prompt_comparison_only_includes_versions_used_by_selected_account(): + first_account = _insert_account() + second_account = _insert_account() + first_job = _insert_publish_job( + first_account, + title="账号一作品", + published_at="2026-08-28T10:00:00+08:00", + ) + second_job = _insert_publish_job( + second_account, + title="账号二作品", + published_at="2026-08-28T11:00:00+08:00", + ) + first_version = _attach_prompt_chain(first_job, "账号一 Prompt") + second_version = _attach_prompt_chain(second_job, "账号二 Prompt") + + first = content_review_service.get_prompt_comparison(first_account) + second = content_review_service.get_prompt_comparison(second_account) + + assert [item["prompt_version_id"] for item in first["versions"]] == [first_version] + assert [item["prompt_version_id"] for item in second["versions"]] == [second_version] + assert first["completed_cycles"] == 0 + assert second["completed_cycles"] == 0 + + +@pytest.mark.parametrize( + ("candidate_duration", "expected_ratio"), + [(60, 0.5), (0, 1 / 3)], +) +def test_prompt_comparison_uses_effective_duration_for_official_export_watch_ratio( + candidate_duration: int, + expected_ratio: float, +): + account_id = _insert_account() + published_at = "2026-08-28T10:00:00+08:00" + job_id = _insert_publish_job( + account_id, + title="官方导出时长回退作品", + published_at=published_at, + duration_seconds=90, + ) + version_id = _attach_prompt_chain(job_id, "时长回退 Prompt") + with get_connection() as connection: + connection.execute( + """ + UPDATE clip_candidates + SET duration_seconds = ? + WHERE id = ( + SELECT oc.clip_candidate_id + FROM publish_jobs pj + JOIN output_clip oc ON oc.id = pj.output_clip_id + WHERE pj.id = ? + ) + """, + (candidate_duration, job_id), + ) + connection.commit() + exported = content_review_service.commit_douyin_item_export( + account_id=account_id, + captured_at="2026-08-29T12:00:00+08:00", + source_filename="作品列表导出.xlsx", + items=[ + _diagnosis_item( + 301, + title="官方导出时长回退作品", + published_at=published_at, + duration_seconds=None, + average_watch_seconds=30, + ) + ], + ) + + comparison = content_review_service.get_prompt_comparison(account_id) + version = next(item for item in comparison["versions"] if item["prompt_version_id"] == version_id) + + assert exported["matched_count"] == 1 + assert version["average_watch_ratio"] == pytest.approx(expected_ratio) + + +def test_experiment_assignment_is_unique_atomic_and_freezes_after_execution(): + account_id = _insert_account() + _seed_diagnosis_baseline(account_id) + insights = content_review_service.get_content_review_insights(account_id) + recommendation = next( + item for item in insights["recommendations"] if item["diagnosis_code"] == "weak_opening" + ) + created = content_review_service.create_content_experiment( + account_id, + recommendation["recommendation_id"], + ) + first_experiment_id = created["experiment_id"] + assert created["experiment"]["progress"]["trend_visible"] is False + second_experiment_id = f"{PREFIX}experiment-second-{uuid4().hex[:8]}" + with get_connection() as connection: + first = connection.execute( + "SELECT * FROM content_improvement_experiments WHERE id = ?", + (first_experiment_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO content_improvement_experiments ( + id, account_id, recommendation_id, diagnosis_code, title, + hypothesis, action_text, primary_metric, primary_direction, + guardrail_metrics_json, baseline_batch_id, baseline_json, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + """, + ( + second_experiment_id, + account_id, + f"second-{uuid4().hex[:8]}", + first["diagnosis_code"], + "第二个实验", + first["hypothesis"], + first["action_text"], + first["primary_metric"], + first["primary_direction"], + first["guardrail_metrics_json"], + first["baseline_batch_id"], + first["baseline_json"], + first["created_at"], + first["updated_at"], + ), + ) + connection.commit() + job_id = _insert_publish_job( + account_id, + title="实验作品", + published_at="2026-08-30T10:00:00+08:00", + status="WAITING", + ) + + assigned = content_review_service.set_publish_job_experiment(job_id, first_experiment_id) + reassigned = content_review_service.set_publish_job_experiment(job_id, second_experiment_id) + assert assigned["status"] == "assigned" + assert reassigned["status"] == "reassigned" + with get_connection() as connection: + count = connection.execute( + "SELECT COUNT(*) FROM content_improvement_experiment_items WHERE publish_job_id = ?", + (job_id,), + ).fetchone()[0] + owner = connection.execute( + "SELECT experiment_id FROM content_improvement_experiment_items WHERE publish_job_id = ?", + (job_id,), + ).fetchone()[0] + connection.execute( + """ + UPDATE publish_jobs + SET status = 'PUBLISHING', claimed_at = '2026-08-30T10:05:00+08:00', + started_at = '2026-08-30T10:05:00+08:00', attempt_count = 1 + WHERE id = ? + """, + (job_id,), + ) + connection.commit() + assert count == 1 + assert owner == second_experiment_id + with pytest.raises(content_review_service.ContentReviewError, match="已经开始"): + content_review_service.set_publish_job_experiment(job_id, "") + with pytest.raises(content_review_service.ContentReviewError, match="样本或官方导出周数还不足"): + content_review_service.update_content_experiment(first_experiment_id, "keep") + + +def test_experiment_requires_twenty_treatment_twenty_baseline_and_three_export_weeks(): + account_id = _insert_account() + _seed_diagnosis_baseline(account_id, captured_at="2026-08-29T12:00:00+08:00") + recommendation = next( + item + for item in content_review_service.get_content_review_insights(account_id)["recommendations"] + if item["diagnosis_code"] == "weak_opening" + ) + created = content_review_service.create_content_experiment( + account_id, + recommendation["recommendation_id"], + ) + experiment_id = created["experiment_id"] + with get_connection() as connection: + baseline = json.loads( + connection.execute( + "SELECT baseline_json FROM content_improvement_experiments WHERE id = ?", + (experiment_id,), + ).fetchone()[0] + ) + baseline["work_count"] = 20 + connection.execute( + "UPDATE content_improvement_experiments SET baseline_json = ? WHERE id = ?", + (json.dumps(baseline, ensure_ascii=False), experiment_id), + ) + connection.commit() + + treatment_items = [] + for index in range(20): + published_at = f"2026-08-30T09:{index:02d}:00+08:00" + title = f"实验门槛作品 {index}" + job_id = _insert_publish_job( + account_id, + title=title, + published_at=published_at, + status="WAITING", + ) + content_review_service.set_publish_job_experiment(job_id, experiment_id) + treatment_items.append( + _diagnosis_item(index + 200, title=title, published_at=published_at) + ) + + for week_index, captured_at in enumerate( + [ + "2026-09-07T12:00:00+08:00", + "2026-09-14T12:00:00+08:00", + "2026-09-21T12:00:00+08:00", + ] + ): + weekly_items = [ + {**item, "play_count": int(item["play_count"]) + week_index} + for item in treatment_items + ] + content_review_service.commit_douyin_item_export( + account_id=account_id, + items=weekly_items, + captured_at=captured_at, + source_filename="作品列表导出.xlsx", + ) + + experiment = next( + item + for item in content_review_service.list_content_experiments(account_id) + if item["id"] == experiment_id + ) + assert experiment["progress"]["treatment_count"] == 20 + assert experiment["progress"]["baseline_count"] == 20 + assert experiment["progress"]["official_export_weeks"] == 3 + assert experiment["progress"]["trend_visible"] is True + assert experiment["progress"]["decision_ready"] is True + decided = content_review_service.update_content_experiment(experiment_id, "inconclusive") + assert decided == { + "status": "completed", + "decision": "inconclusive", + "message": "实验结论已记录。", + } diff --git a/tests/test_content_review_browser.py b/tests/test_content_review_browser.py index 7807d6f..ccfcbbd 100644 --- a/tests/test_content_review_browser.py +++ b/tests/test_content_review_browser.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from datetime import datetime from pathlib import Path import socket import threading @@ -14,6 +15,7 @@ from app.db.database import get_connection, init_db # noqa: E402 from app.main import app # noqa: E402 +from app.services import content_review_service # noqa: E402 def _free_port() -> int: @@ -38,9 +40,33 @@ def _seed_account() -> str: @pytest.mark.parametrize("width", [1440, 390]) -def test_content_review_page_has_no_document_overflow(width: int): +def test_content_review_page_has_no_document_overflow(width: int, monkeypatch, tmp_path): init_db() account_id = _seed_account() + monkeypatch.setattr( + content_review_service, + "_now", + lambda: datetime.fromisoformat("2026-08-29T00:48:37+08:00"), + ) + content_review_service.commit_douyin_item_export( + account_id=account_id, + items=[ + { + "aweme_id": "export:browser-test", + "title": "页面测试作品", + "published_at": "2026-08-28T20:00:00+08:00", + "duration_seconds": 60, + "play_count": 100, + "completion_rate": 0.4, + "five_second_completion_rate": 0.6, + "two_second_bounce_rate": 0.2, + "average_watch_seconds": 24, + "content_genre": "视频", + } + ], + captured_at="2026-08-29T00:47:00+08:00", + source_filename="作品列表导出.xlsx", + ) port = _free_port() server = uvicorn.Server( uvicorn.Config( @@ -79,6 +105,63 @@ def test_content_review_page_has_no_document_overflow(width: int): assert page.locator("h1").filter(has_text="内容复盘").is_visible() assert page.locator("#content-review-import-form").is_visible() assert page.locator("#content-review-sync").is_visible() + page.locator("#content-review-last-export").filter( + has_text="上次成功导出:北京时间 2026-08-29 00:48" + ).wait_for() + assert page.locator("#content-review-last-export-stats").inner_text() == ( + "1 条作品 · 已匹配 0 · 待确认 0 · 未匹配 1" + ) + assert page.locator("label.file-upload-button").is_visible() + assert not page.locator("#content-review-file").is_visible() + assert page.locator("#content-review-preview-button").is_disabled() + sample_file = tmp_path / "页面测试.xlsx" + sample_file.write_bytes(b"not-uploaded") + page.locator("#content-review-file").set_input_files(str(sample_file)) + assert page.locator("#content-review-file-name").inner_text() == "页面测试.xlsx" + assert page.locator("#content-review-preview-button").is_enabled() + account_details = page.locator('[data-content-review-disclosure="account-history"]') + work_details = page.locator('[data-content-review-disclosure="work-attribution"]') + assert account_details.get_attribute("open") is None + assert work_details.get_attribute("open") is None + account_details.locator("summary").click() + assert account_details.get_attribute("open") is not None + page.reload(wait_until="networkidle") + assert page.locator( + '[data-content-review-disclosure="account-history"]' + ).get_attribute("open") is not None + assert page.locator( + '[data-content-review-disclosure="work-attribution"]' + ).get_attribute("open") is None + page.evaluate( + """ + renderSummary({ + latest_metric_date: '2026-08-28', + last_export_committed_at: '2026-08-29T00:48:37+08:00', + current_period: {two_second_bounce_rate: 0.30}, + previous_period: {two_second_bounce_rate: 0.20}, + comparisons: {two_second_bounce_rate: 0.50}, + history: [], + match_summary: {} + }) + """ + ) + bounce_delta = page.locator( + '[data-metric="two_second_bounce_rate"] [data-delta]' + ) + assert "is-down" in (bounce_delta.get_attribute("class") or "") + page.evaluate( + """ + renderSummary({ + latest_metric_date: '2026-08-28', + current_period: {two_second_bounce_rate: 0.18}, + previous_period: {two_second_bounce_rate: 0.20}, + comparisons: {two_second_bounce_rate: -0.10}, + history: [], + match_summary: {} + }) + """ + ) + assert "is-up" in (bounce_delta.get_attribute("class") or "") overflow = page.evaluate( "document.documentElement.scrollWidth - window.innerWidth" ) @@ -89,6 +172,19 @@ def test_content_review_page_has_no_document_overflow(width: int): server.should_exit = True thread.join(timeout=10) with get_connection() as connection: + batch_rows = connection.execute( + "SELECT id FROM content_metric_import_batches WHERE account_id = ?", + (account_id,), + ).fetchall() + for batch in batch_rows: + connection.execute( + "DELETE FROM douyin_item_metric_snapshots WHERE batch_id = ?", + (batch["id"],), + ) + connection.execute( + "DELETE FROM content_metric_import_batches WHERE account_id = ?", + (account_id,), + ) connection.execute( "DELETE FROM publish_accounts WHERE id = ?", (account_id,), diff --git a/tests/test_content_review_foundation.py b/tests/test_content_review_foundation.py index 2c0b3f1..ec0be17 100644 --- a/tests/test_content_review_foundation.py +++ b/tests/test_content_review_foundation.py @@ -66,6 +66,24 @@ def _insert_task_and_candidate(*, enabled: bool = True, reviewed: bool = False) return task_id, clip_id +def _insert_analysis_run(task_id: str, run_number: int, *, is_active: bool) -> str: + run_id = f"{task_id}-run-{run_number}" + now = "2026-08-28T10:00:00" + with get_connection() as connection: + connection.execute( + """ + INSERT INTO ai_analysis_runs ( + id, task_id, run_number, provider, provider_label, model, + requested_clip_count, clip_count, analysis_payload_json, + created_at, is_active + ) VALUES (?, ?, ?, 'test', 'Test', 'test-model', 1, 1, '{}', ?, ?) + """, + (run_id, task_id, run_number, now, int(is_active)), + ) + connection.commit() + return run_id + + def _payload(clip_id: str, *, enabled: bool, reason: str | None = None) -> ClipCandidateBatchItem: return ClipCandidateBatchItem( id=clip_id, @@ -162,6 +180,64 @@ def test_legacy_feedback_endpoint_marks_source_and_latest_context_uses_one_per_c assert task_context[0]["decision"] == "keep" +def test_explicit_feedback_uses_candidate_source_run_instead_of_active_run(): + task_id, clip_id = _insert_task_and_candidate(enabled=True, reviewed=False) + source_run_id = _insert_analysis_run(task_id, 1, is_active=False) + active_run_id = _insert_analysis_run(task_id, 2, is_active=True) + with get_connection() as connection: + connection.execute( + "UPDATE clip_candidates SET source_analysis_run_id = ? WHERE id = ?", + (source_run_id, clip_id), + ) + connection.commit() + + save_clip_feedback( + task_id, + clip_id, + ClipFeedbackCreate(decision="reject", reason_code="not_funny"), + ) + + with get_connection() as connection: + feedback = connection.execute( + "SELECT analysis_run_id FROM clip_feedback WHERE clip_candidate_id = ?", + (clip_id,), + ).fetchone() + assert feedback["analysis_run_id"] == source_run_id + assert feedback["analysis_run_id"] != active_run_id + + +@pytest.mark.parametrize("source_kind", ["missing", "unknown", "cross_task"]) +def test_explicit_feedback_without_trustworthy_source_run_stays_unattributed(source_kind: str): + task_id, clip_id = _insert_task_and_candidate(enabled=True, reviewed=False) + active_run_id = _insert_analysis_run(task_id, 2, is_active=True) + source_run_id = None + if source_kind == "unknown": + source_run_id = f"{task_id}-missing-run" + elif source_kind == "cross_task": + other_task_id, _ = _insert_task_and_candidate(enabled=True, reviewed=False) + source_run_id = _insert_analysis_run(other_task_id, 1, is_active=True) + with get_connection() as connection: + connection.execute( + "UPDATE clip_candidates SET source_analysis_run_id = ? WHERE id = ?", + (source_run_id, clip_id), + ) + connection.commit() + + save_clip_feedback( + task_id, + clip_id, + ClipFeedbackCreate(decision="keep", reason_code="worth_publishing"), + ) + + with get_connection() as connection: + feedback = connection.execute( + "SELECT analysis_run_id FROM clip_feedback WHERE clip_candidate_id = ?", + (clip_id,), + ).fetchone() + assert feedback["analysis_run_id"] is None + assert feedback["analysis_run_id"] != active_run_id + + def test_prompt_version_changes_only_when_prompt_content_changes(): suffix = uuid4().hex[:8] preset_id = f"{PREFIX}preset-{suffix}" diff --git a/tests/test_long_live_selection.py b/tests/test_long_live_selection.py index 4b688f6..7357d58 100644 --- a/tests/test_long_live_selection.py +++ b/tests/test_long_live_selection.py @@ -267,6 +267,7 @@ def test_ambiguous_window_failure_is_not_retried_after_restart(tmp_path): assert second.meta["failed_window_count"] == 1 assert second.meta["reused_window_count"] == second.meta["window_count"] - 1 assert second.meta["analysis_incomplete"] is True + assert second.meta["quality_degraded"] is False def test_corrupt_completed_window_checkpoint_is_not_rebilled(tmp_path): @@ -328,6 +329,8 @@ def test_safe_rate_limit_window_is_retried_within_bound(tmp_path): result = analyze_long_live_talk(request, provider=provider, sleep_fn=lambda _seconds: None) assert provider.calls[1] == 3 assert result.meta["failed_window_count"] == 0 + assert result.meta["analysis_incomplete"] is False + assert result.meta["quality_degraded"] is False def test_stale_worker_cannot_update_long_live_checkpoint(): diff --git a/tests/test_partial_ai_analysis.py b/tests/test_partial_ai_analysis.py index 5614cc5..b18e992 100644 --- a/tests/test_partial_ai_analysis.py +++ b/tests/test_partial_ai_analysis.py @@ -236,6 +236,24 @@ def test_quality_degraded_analysis_is_manual_review_only(monkeypatch): process_task_video_cuts("test-quality-cut") +def test_complete_long_live_analysis_meta_passes_cut_validation(): + meta = { + "schema_version": 2, + "selection_profile": "long_live_talk", + "analysis_incomplete": False, + "quality_degraded": False, + "coverage_ratio": 1.0, + "coverage_percent": 100.0, + "invalid_item_count": 0, + "window_count": 2, + "completed_window_count": 2, + "failed_window_count": 0, + "failed_windows": [], + } + + assert validate_ai_analysis_meta_for_cut(meta, "long_live_talk") == meta + + def test_missing_analysis_meta_blocks_manual_and_auto_cut(monkeypatch): engine = PipelineEngine() monkeypatch.setattr(engine, "_get_task", lambda _task_id: {"selection_profile": "general"}) diff --git a/tests/test_schema_migration_ledger.py b/tests/test_schema_migration_ledger.py index d198e40..4b81da8 100644 --- a/tests/test_schema_migration_ledger.py +++ b/tests/test_schema_migration_ledger.py @@ -48,7 +48,7 @@ def test_init_records_migration_once_and_switches_unique_index(isolated_database ).fetchall() indexes = _index_names(connection) - assert len(migrations) == 4 + assert len(migrations) == 5 migrations_by_version = {row["version"]: row for row in migrations} publish_migration = migrations_by_version[database_module.PUBLISH_ACTIVE_INDEX_MIGRATION_VERSION] assert publish_migration["name"] == database_module.PUBLISH_ACTIVE_INDEX_MIGRATION_NAME @@ -66,15 +66,27 @@ def test_init_records_migration_once_and_switches_unique_index(isolated_database assert export_migration["name"] == database_module.DOUYIN_ITEM_EXPORT_MIGRATION_NAME assert export_migration["checksum"] == database_module.DOUYIN_ITEM_EXPORT_MIGRATION_CHECKSUM assert export_migration["applied_at"] + feedback_migration = migrations_by_version[database_module.CONTENT_FEEDBACK_LOOP_MIGRATION_VERSION] + assert feedback_migration["name"] == database_module.CONTENT_FEEDBACK_LOOP_MIGRATION_NAME + assert feedback_migration["checksum"] == database_module.CONTENT_FEEDBACK_LOOP_MIGRATION_CHECKSUM + assert feedback_migration["applied_at"] assert database_module.PUBLISH_ACTIVE_UNIQUE_INDEX_NAME in indexes assert database_module.PUBLISH_ACTIVE_UNIQUE_INDEX_LEGACY_NAME not in indexes assert set(database_module.CONTENT_REVIEW_REQUIRED_INDEXES) <= indexes + assert set(database_module.CONTENT_FEEDBACK_LOOP_REQUIRED_INDEXES) <= indexes with _connect(isolated_database) as connection: item_columns = { row["name"] for row in connection.execute("PRAGMA table_info(douyin_item_metric_snapshots)") } + tables = { + row["name"] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + } assert set(database_module.DOUYIN_ITEM_EXPORT_COLUMNS) <= item_columns + assert set(database_module.CONTENT_FEEDBACK_LOOP_REQUIRED_TABLES) <= tables def test_checksum_drift_refuses_startup(isolated_database):