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());
|