001project_wildgrowth/backend/scripts/inspect-paragraphs-full.js

175 lines
6.2 KiB
JavaScript
Raw Normal View History

2026-02-11 15:26:03 +08:00
#!/usr/bin/env node
/**
* 彻底排查段落间距全链路
* 1. 该课程所有节点outline.suggestedContent 按双换行拆段数 vs node_slides.content.paragraphs
* 2. 调用 lesson detail API核对返回的 blocks 数量与内容
* 用法node scripts/inspect-paragraphs-full.js <courseId>
* node scripts/inspect-paragraphs-full.js 08253870-7499-4f39-9fa1-9cb76c280770
*/
const { PrismaClient } = require('@prisma/client');
const http = require('http');
const prisma = new PrismaClient();
const API_BASE = process.env.API_BASE || 'http://127.0.0.1:3000';
function get(url) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = http.request(
{ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET' },
(res) => {
let body = '';
res.on('data', (ch) => { body += ch; });
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error('parse JSON: ' + body.slice(0, 200)));
}
});
}
);
req.on('error', reject);
req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
}
async function main() {
const courseId = process.argv[2] ? process.argv[2].trim() : null;
if (!courseId) {
console.log('用法: node scripts/inspect-paragraphs-full.js <courseId>');
process.exit(1);
}
const task = await prisma.courseGenerationTask.findFirst({
where: { courseId },
orderBy: { createdAt: 'desc' },
include: { course: { select: { id: true, title: true } } },
});
if (!task) {
console.log('未找到 courseId 对应的任务:', courseId);
return;
}
const outline = task.outline;
if (!outline || !outline.chapters || !Array.isArray(outline.chapters)) {
console.log('任务无 outline.chapters');
return;
}
const outlineNodes = [];
for (const ch of outline.chapters) {
if (ch.nodes && Array.isArray(ch.nodes)) {
for (const node of ch.nodes) {
outlineNodes.push({
title: node.title || '',
suggestedContent: node.suggestedContent || '',
});
}
}
}
const dbNodes = await prisma.courseNode.findMany({
where: { courseId },
orderBy: { orderIndex: 'asc' },
include: { slides: { orderBy: { orderIndex: 'asc' } } },
});
console.log('=== 课程:', task.course?.title ?? courseId, '===');
console.log('courseId:', courseId);
console.log('taskId:', task.id);
console.log('outline 小节总数:', outlineNodes.length);
console.log('DB 节点总数:', dbNodes.length);
console.log('');
const rows = [];
for (let i = 0; i < Math.max(outlineNodes.length, dbNodes.length); i++) {
const oNode = outlineNodes[i];
const dNode = dbNodes[i];
let outlineParagraphs = 0;
if (oNode && oNode.suggestedContent) {
const split = oNode.suggestedContent.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0);
outlineParagraphs = split.length;
}
let slideParagraphs = 0;
let nodeId = '';
let nodeTitle = '';
if (dNode) {
nodeId = dNode.id;
nodeTitle = dNode.title || '';
const slide = dNode.slides && dNode.slides[0];
if (slide && slide.content && typeof slide.content === 'object' && Array.isArray(slide.content.paragraphs)) {
slideParagraphs = slide.content.paragraphs.length;
}
}
const match = outlineParagraphs === slideParagraphs ? '✓' : '✗';
rows.push({
index: i + 1,
nodeId,
nodeTitle: nodeTitle.slice(0, 36) + (nodeTitle.length > 36 ? '…' : ''),
outlineParagraphs,
slideParagraphs,
match,
});
}
console.log('--- 全节点对比outline 按双换行拆段数 vs node_slides.paragraphs 数)---');
console.log('序号\toutline段数\tslide段数\t一致\t节点标题');
rows.forEach((r) => {
console.log(`${r.index}\t${r.outlineParagraphs}\t\t${r.slideParagraphs}\t\t${r.match}\t${r.nodeTitle}`);
});
const mismatchCount = rows.filter((r) => r.match === '✗').length;
console.log('');
if (mismatchCount > 0) {
console.log('⚠️ 不一致节点数:', mismatchCount);
} else {
console.log('✓ 所有节点 outline 段数与 node_slides 段数一致');
}
console.log('');
console.log('--- 调用 lesson detail API 核对(前 3 个节点)---');
for (let i = 0; i < Math.min(3, dbNodes.length); i++) {
const node = dbNodes[i];
const nodeId = node.id;
try {
const res = await get(`${API_BASE}/api/lessons/nodes/${nodeId}/detail`);
if (!res || !res.success || !res.data) {
console.log(`节点 ${i + 1} (${nodeId}): API 返回异常`, res ? (res.error || res) : 'null');
continue;
}
const blocks = res.data.blocks || [];
const blockTypes = blocks.map((b) => b.type).join(', ');
console.log(`节点 ${i + 1}: ${node.title?.slice(0, 32)}`);
console.log(` nodeId: ${nodeId}`);
console.log(` API blocks 数量: ${blocks.length}`);
console.log(` block types: ${blockTypes}`);
blocks.slice(0, 2).forEach((b, bi) => {
console.log(` block[${bi}] type=${b.type} contentLen=${(b.content || '').length} contentPreview=${JSON.stringify((b.content || '').slice(0, 50))}`);
});
const expectedBlocks = rows[i] ? rows[i].slideParagraphs : 0;
if (blocks.length !== expectedBlocks) {
console.log(` ⚠️ 与 node_slides.paragraphs 数(${expectedBlocks}) 不一致`);
}
console.log('');
} catch (e) {
console.log(`节点 ${i + 1} (${nodeId}): 请求失败`, e.message);
console.log('');
}
}
console.log('--- 小结 ---');
console.log('1. 上表outline 段数 = suggestedContent 按 /\\n\\s*\\n/ 拆分后的段落数slide 段数 = node_slides.content.paragraphs 长度');
console.log('2. getLessonDetail 会把每个 slide 的 content.paragraphs 逐条转成 paragraph block故 blocks 数应等于该节点 slide 的 paragraphs 数');
console.log('3. iOS ContentBlockBuilder 在 index>0 的 block 前插入 paragraphSpacing(24pt),多段即有多段间距');
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());