R76-R77: 修复解析入库aweme_id关联失败+告警机制—①server.js匹配同构规范化(normKey无空格紧凑+补全角问号,根因:文件夹名吞空格vs库标题留空格)②import加unmatched告警+video_id双键(匹配不到跳过不插脏)③解析prompt加入库自查(返回体unmatched含本视频即报失败)④全功能落库排查库内健康(双null=0/孤儿=0)

This commit is contained in:
maogeigei committed 2026-09-04 18:43:41 +08:00
1 parent d7c00a53b8
commit 383532719d
4 files changed
+48 -17

No files matched your search

+15
View File
@@ -232,3 +232,18 @@
- **宿主注册机制结论(可复用)**:宿主**无限深度递归扫描 SKILL.md**(任意嵌套目录含 SKILL.md 即注册,命名空间=父路径段拼接)→ 技能树内所有 SKILL.md 必须都是"有效技能"或不存在;无效占位/重定向文件会导致坏变体注册(browser-harness 3 变体根因)
- **#110 dsh storyboard-prompt 同步 R73(完成)**:先三方比对——dsh(679行) vs 源HEAD(680行) 仅 **4 处 dsh 特化**(frontmatter name=storyboard-prompt/deployment=dsh+deployed_at=2026-08-31、产出路径段兜底 `DSHSkill项目/` 正文+目录树注释),证实 dsh 为 R73 前同步态非全量镜像 → **同步法=以源工作区文件为底覆盖 + 重放 4 处特化**(禁整包覆盖,会吞特化);F1 细则 md5 与源一致、dsh 侧 3 词库引用目标存在、备份 .bak-r73 清理
- **★dsh 同步可复用方法**:源↔dsh 有特化差异时禁止直接 cp 覆盖——先 diff 三方(dsh/源HEAD/源工作区)确认特化清单 → cp 源工作区 → 逐条重放特化 → diff 复验"差异=特化集" → 引用目标存在性验证
## R76 视频解析入库 aweme_id 匹配修复(18:32-18:50,用户报"解析了看不到")
- **现象**:平凡的胡德禄《小主人...臭猫昏迷》(aweme 7674245412476081913, video id=58) 解析任务成功(detailId=33367, content+analysis 齐备落盘)但工作台视频详情页 3 tab 全空
- **排查链**:宿主 automations 找到任务 automation-1788516378186(18:06-18:31, result_success=1, runs_json 完整)→ 工作台库 source id=116 已入库但 **aweme_id=null** → 前端 dsh-data.js 取数 `WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)`(要求 NOT NULL)→ 查不到
- **根因(双规范化不对称)**:server.js importAccountIntoDb 的 matchAweme 用 normTitle(folder) 匹配 account_videos.video_title——但①文件夹命名规则「标题去特殊符号」把词间空格也吞了("小主人: 终于"→"小主人终于"),normTitle 只删标点保留空格("小主人 终于")→ 两侧永不相等;②全角问号「?」不在 normTitle 删标点集合(title 尾部残留 ? 干扰)→ 精确/包含匹配双失效 → aweme_id=null 脏入库(09-01 曾修标点缺全角问题,但空格不对称与 ? 漏删残留至今)
- **修复(server.js,治本)**:新增 `normKey = normTitle(s).replace(/\s+/g,'')`(无空格紧凑形态),xlsxByNorm/dbByNorm 键与 matchAweme 比较统一用 normKey;normTitle 标点集合补全角「?」。语法过 + 本地模拟唯一候选命中 7674245412476081913 ✓
- **重跑验证**:重启 8900(k9DZfI)→ POST /api/import/account(import 开头自动清 aweme_id/video_id 双 null 脏记录)→ source 命中 aweme_id、content 4787B + analysis 29887B(S1-S40 全 40 场次)、脏记录 0、前端查询语句模拟命中 ✓
- **教训沉淀**:凡"产物落盘成功但页面查不到"→ 先查库中记录关联键(aweme_id/video_id)是否为 null;文件夹名与库标题匹配必须同构规范化(无空格紧凑 + 全角标点全集),两侧规则各改一处即失效
## R77 import 告警 + prompt 自查 + 全功能落库排查(18:39-19:00,用户确认实施)
- **#111 import unmatched 告警(server.js)**:importAccountIntoDb 视频分析循环重构——awemeId 命中后反查 account_videos.id 一并填 video_id(双键关联,前端 video_id/aweme_id 皆可命中,历史 video_id 多为 null 仅靠 aweme_id 兜底);**awemeId 匹配不到 → 记 stats.unmatched + unmatched_folders 并跳过插入**(不再制造双 null 脏记录、避免"入库假成功",补 xlsx/视频列表后重跑即恢复);stats 初始化加 unmatched:0/unmatched_folders:[];路由直接返回 res 自动带出
- **#112 解析 prompt 自查(data-pages.js SKILL_HINT_VIDEO_PARSE ⑤步)**:入库后强制自查——检查返回体 unmatched/unmatched_folders 含本次视频文件夹 → 明确汇报「入库关联失败」禁止报成功;再查询库中该视频确能查到 source 记录方为成功。该常量为唯一解析入口(解析/刷新共用,行 1549 组装)覆盖全入口;技能侧 F5 文档无入库步骤(入库只在工作台 prompt 层)无需同步
- **#113 全功能落库排查结论(库内健康)**:双 null 脏记录 source/analysis=0;analysis 12/12 有 aweme_id(选题 JOIN 正常);rewrite_log 26/26 全关联;account_videos 626/626 有 aweme;孤儿 source 0。dry-run 31 个产出文件夹 21 匹配;10 个 unmatched 全属**历史遗留且已入库无害**——王微斯 4 个(早期 folder 标题截断'关于我想你这件事'=标题首句+标签,非完整标题规则,但对应视频 source/analysis 09-03 早已入库);小咪橘座 6 个(账号未登记 hot_accounts→无映射源,且 folder 名带'人'字标题错乱,账号级缺失非匹配缺陷)
- **验证**:服务重启(s4hdzW);平凡胡德禄重跑 import → video_source=1 unmatched=0 ✓(#111 生效);data-pages.js bump ?v=20260904u→v
- **待办**:本批改动未 commit(R76+R77 与 R64-R70 已 push 过、R76/R77 新改动待提交)
@@ -16,7 +16,7 @@ const SKILL_HINT_STORYBOARD = '请调用 Skill 工具加载「短视频分镜脚
const SKILL_HINT_REVIEW = '请调用 Skill 工具加载「脚本复盘」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-script-review/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为工作台复盘任务:严格按技能执行方式判定与复盘流程执行(评分式复盘/对比分析模式由任务说明指定;一条脚本=一个独立上下文,禁止混审多条脚本)。技能边界:只做分析与建议,不修改脚本、不触发创作流程、不改动任何技能文件。';
/* 视频解析技能声明(09-03 R28:视频详情页「解析/刷新」按钮=单视频 MCP 解析,任务必须按 mcn-dou-analysis 功能五执行,
一次性产出「视频脚本」(content 分镜头表)与「视频拆解」(analysis 结构化拆解)两个数据源 */
const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能五「解析视频」:对单条抖音视频做 MCP 内容解析。按 references/feature/05_解析视频.md 流程执行:① 调用 MCP 工具 upload_douyin_video 提交视频链接(share_text=该视频抖音链接)获取 detailId;② 等待解析完成(新视频约 3-5 分钟,用 sleep 等待后查询,content 已出而 analysis 未出时稍后重试);③ 用 short_video_detail 查询解析结果 content(视频脚本/分镜头表)与 analysis(视频拆解/结构化JSON);④ 按 references/解析结果格式规范.md 用 scripts/json_tool.py 将 content.json 与 analysis.json 保存到产出目录对应视频文件夹;⑤ 解析结果入库:工作台环境(本机 http://localhost:8900)产物落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库,接口不可用则跳过不影响保存。完成后汇报 detailId、content/analysis 是否齐备与保存路径。技能边界:只做该单视频解析,不抓取账号信息、不提炼账号设定、不创作/改写脚本。';
const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能五「解析视频」:对单条抖音视频做 MCP 内容解析。按 references/feature/05_解析视频.md 流程执行:① 调用 MCP 工具 upload_douyin_video 提交视频链接(share_text=该视频抖音链接)获取 detailId;② 等待解析完成(新视频约 3-5 分钟,用 sleep 等待后查询,content 已出而 analysis 未出时稍后重试);③ 用 short_video_detail 查询解析结果 content(视频脚本/分镜头表)与 analysis(视频拆解/结构化JSON);④ 按 references/解析结果格式规范.md 用 scripts/json_tool.py 将 content.json 与 analysis.json 保存到产出目录对应视频文件夹;⑤ 解析结果入库:工作台环境(本机 http://localhost:8900)产物落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库,接口不可用则跳过不影响保存;**入库后必须自查关联是否成功(09-04 修复后强制)**:检查接口返回体 `unmatched`/`unmatched_folders` 字段——若 `unmatched>0` 且含本次视频文件夹名,说明 aweme_id 未能与库内视频关联(入库失败),必须明确汇报「入库关联失败 + unmatched 明细」,禁止报成功;返回正常后再查询库中该视频(按 aweme_id 或标题)确能查到 source 记录方为成功。完成后汇报 detailId、content/analysis 是否齐备、保存路径与入库关联结果(成功/失败含 unmatched)。技能边界:只做该单视频解析,不抓取账号信息、不提炼账号设定、不创作/改写脚本。';
/* 09-02 v25:AI创作任务完成后的写库回调指令——否则脚本只落会话/产出目录,工作台「AI写脚本」列表看不到。
参考选题创作(有原视频,如视频行 AI创作):传 videoId/awemeId → rewrite_log.video_id 关联原视频;
原创选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「原创选题创作」tab。
@@ -43,6 +43,6 @@
<script src="app.js?v=20260904i"></script>
<script src="help-guide.js?v=20260904e"></script>
<script src="data-pages.js?v=20260904u"></script>
<script src="data-pages.js?v=20260904v"></script>
</body>
</html>
@@ -74,7 +74,7 @@ function resolveAccountDir(name, root) {
}
function importAccountIntoDb(db, account, dir) {
const stats = { video_analysis: 0, video_source: 0, persona: 0, account_analysis: 0, videos_upserted: 0, skipped: [] };
const stats = { video_analysis: 0, video_source: 0, persona: 0, account_analysis: 0, videos_upserted: 0, unmatched: 0, unmatched_folders: [], skipped: [] };
const now = new Date().toISOString();
const insAnalysis = db.prepare(`INSERT INTO account_video_analysis (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`);
const insSource = db.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary, analysis_json) VALUES (?,?,?,?,?,?)`);
@@ -85,18 +85,23 @@ function importAccountIntoDb(db, account, dir) {
// 清理历史误插脏数据:09-01 匹配缺陷(标点未规范化)产生 aweme_id=null 且 video_id=null 的无意义记录,重跑入库前先清
db.prepare("DELETE FROM account_video_analysis WHERE aweme_id IS NULL AND video_id IS NULL").run();
db.prepare("DELETE FROM account_video_source WHERE aweme_id IS NULL AND video_id IS NULL").run();
// 规范化标题:去 # 标签 → 删全角/半角标点(xlsx 标题带「!」「“”」而文件夹名无,09-01 匹配失败根因)→ 下划线转空格(对齐文件夹 _标签 结构)→ 空格归一
const normTitle = (s) => String(s || '').split('#')[0].replace(/[!!“”‘’"'`,.…。,、;::;·]/g, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
// 规范化标题:去 # 标签 → 删全角/半角标点(xlsx 标题带「!」「“”」而文件夹名无,09-01 匹配失败根因;09-04 补全角问号「?」——folder 生成时删 ? 而 title 未删导致残留干扰)→ 下划线转空格(对齐文件夹 _标签 结构)→ 空格归一
const normTitle = (s) => String(s || '').split('#')[0].replace(/[!!??“”‘’"'`,.…。,、;::;·]/g, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
// 09-04 修复:文件夹名与 video_title 的规范化不对称——文件夹命名规则「标题去特殊符号」会把词间空格一并吞掉
// ("小主人: 终于"→"小主人终于"),而 normTitle 只删标点保留空格("小主人 终于")→ 两侧精确/包含匹配双失效
// → aweme_id=null 脏入库 → 前端视频详情(按 aweme_id 关联,要求 NOT NULL)查不到解析。
// 治本:比较键统一为「无空格紧凑形态」(normTitle 后再删全部空白),folder 与 title 两侧同构;精确失败后走无空格包含匹配。
const normKey = (s) => normTitle(s).replace(/\s+/g, '');
// 0) aweme_id 权威映射:短视频表格.xlsx(子进程 python,最高优先级)→ account_videos 表(兜底)
const xlsxList = readXlsxMap(dir);
const xlsxByNorm = {};
for (const r of xlsxList) { if (r.aweme_id && r.title) xlsxByNorm[normTitle(r.title)] = r.aweme_id; }
for (const r of xlsxList) { if (r.aweme_id && r.title) xlsxByNorm[normKey(r.title)] = r.aweme_id; }
const titleRows = db.prepare('SELECT aweme_id, video_title FROM account_videos WHERE video_title IS NOT NULL').all();
const dbByNorm = {};
for (const r of titleRows) { if (r.aweme_id && r.video_title) dbByNorm[normTitle(r.video_title)] = String(r.aweme_id); }
// 匹配:精确优先;包含匹配要求双方标题 ≥10 字且按标题长度降序(最具体优先,防短标题误配)
for (const r of titleRows) { if (r.aweme_id && r.video_title) dbByNorm[normKey(r.video_title)] = String(r.aweme_id); }
// 匹配:无空格紧凑精确优先;包含匹配要求双方 ≥10 字符且按长度降序(最具体优先,防短标题误配)
const matchAweme = (folder) => {
const base = normTitle(folder);
const base = normKey(folder);
if (!base) return null;
if (xlsxByNorm[base]) return xlsxByNorm[base];
if (dbByNorm[base]) return dbByNorm[base];
@@ -134,22 +139,33 @@ function importAccountIntoDb(db, account, dir) {
let st; try { st = fs.statSync(fdir); } catch (e) { continue; }
if (!st.isDirectory()) continue;
const awemeId = matchAweme(folder);
// 09-04:awemeId 命中后反查 account_videos.id 一并填 video_id(双键关联,前端 video_id/aweme_id 皆可命中;历史 source 行 video_id 多为 null 靠 aweme_id 兜底)
let videoId = null;
if (awemeId) { const vv = db.prepare('SELECT id FROM account_videos WHERE aweme_id=?').get(awemeId); videoId = vv ? vv.id : null; }
const anPath = path.join(fdir, 'analysis.json');
const coPath = path.join(fdir, 'content.json');
if (fs.existsSync(coPath)) {
const coExists = fs.existsSync(coPath), anExists = fs.existsSync(anPath);
if (!coExists && !anExists) continue; // 空文件夹(半途写入残留)跳过,不计 unmatched
// 09-04:aweme_id 匹配不到 → 记 unmatched 告警并跳过插入(不再制造双 null 脏记录、避免"入库假成功";补 xlsx/视频列表后重跑 import 即恢复)
if (!awemeId) { stats.unmatched++; stats.unmatched_folders.push(folder); }
if (coExists) {
const content = fs.readFileSync(coPath, 'utf8');
const analysisJson = fs.existsSync(anPath) ? fs.readFileSync(anPath, 'utf8') : null;
if (awemeId) db.prepare('DELETE FROM account_video_source WHERE aweme_id=?').run(awemeId);
insSource.run(null, awemeId, now, content, '', analysisJson);
stats.video_source++;
const analysisJson = anExists ? fs.readFileSync(anPath, 'utf8') : null;
if (awemeId) {
db.prepare('DELETE FROM account_video_source WHERE aweme_id=?').run(awemeId);
insSource.run(videoId, awemeId, now, content, '', analysisJson);
stats.video_source++;
}
}
// 拆解分析.md → account_video_analysis(功能四逐条拆解产物,与解析并存于同一文件夹;无则不入)
const decon = fs.readdirSync(fdir).find((f) => f.endsWith('拆解分析.md'));
if (decon) {
const content = fs.readFileSync(path.join(fdir, decon), 'utf8');
if (awemeId) db.prepare('DELETE FROM account_video_analysis WHERE aweme_id=?').run(awemeId);
insAnalysis.run(null, awemeId, now, content, content.slice(0, 500));
stats.video_analysis++;
if (awemeId) {
db.prepare('DELETE FROM account_video_analysis WHERE aweme_id=?').run(awemeId);
insAnalysis.run(videoId, awemeId, now, content, content.slice(0, 500));
stats.video_analysis++;
}
}
}
}