175 lines
6.2 KiB
JavaScript
175 lines
6.2 KiB
JavaScript
#!/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());
|