New reports skip the writer, persist pl9 Markdown as the body, and settle zero-token usage on the catalog model. Planned longform sections now emit blocked rows instead of vanishing. Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
export type LongformHeadingLevel = 2 | 3;
|
|
|
|
export type LongformHeading = Readonly<{
|
|
id: string;
|
|
level: LongformHeadingLevel;
|
|
title: string;
|
|
}>;
|
|
|
|
export type LongformSection = Readonly<{
|
|
id: string;
|
|
title: string;
|
|
markdown: string;
|
|
headings: readonly LongformHeading[];
|
|
eager: boolean;
|
|
}>;
|
|
|
|
export type LongformOutline = Readonly<{
|
|
headings: readonly LongformHeading[];
|
|
leadMarkdown: string;
|
|
sections: readonly LongformSection[];
|
|
}>;
|
|
|
|
const EAGER_H2 = /成品阅读导航|质量验收矩阵/;
|
|
const SUMMARY_TITLE = /^(?:解读摘要|摘要)$/;
|
|
|
|
export function slugifyHeading(title: string, used: Map<string, number>): string {
|
|
const base = title
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 80) || "section";
|
|
const next = (used.get(base) ?? 0) + 1;
|
|
used.set(base, next);
|
|
return next === 1 ? base : `${base}-${next}`;
|
|
}
|
|
|
|
function parseHeading(line: string): { level: LongformHeadingLevel; title: string } | null {
|
|
const match = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
|
|
if (!match) return null;
|
|
return {
|
|
level: match[1].length === 2 ? 2 : 3,
|
|
title: match[2].replace(/\s+#+\s*$/, "").trim(),
|
|
};
|
|
}
|
|
|
|
function extractHeadingBlock(markdown: string, pattern: RegExp, level: LongformHeadingLevel): string | null {
|
|
const lines = markdown.split("\n");
|
|
let start = -1;
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const heading = parseHeading(lines[index] ?? "");
|
|
if (heading?.level === level && pattern.test(heading.title)) {
|
|
start = index;
|
|
break;
|
|
}
|
|
}
|
|
if (start < 0) return null;
|
|
const collected = [lines[start]];
|
|
for (let index = start + 1; index < lines.length; index += 1) {
|
|
const heading = parseHeading(lines[index] ?? "");
|
|
if (heading && heading.level <= level) break;
|
|
collected.push(lines[index] ?? "");
|
|
}
|
|
const text = collected.join("\n").trim();
|
|
return text.length > 0 ? text : null;
|
|
}
|
|
|
|
function extractH3Block(markdown: string, pattern: RegExp): string | null {
|
|
return extractHeadingBlock(markdown, pattern, 3);
|
|
}
|
|
|
|
function stripH3Block(markdown: string, pattern: RegExp): string {
|
|
const lines = markdown.split("\n");
|
|
const kept: string[] = [];
|
|
let skipping = false;
|
|
for (const line of lines) {
|
|
const heading = parseHeading(line);
|
|
if (skipping) {
|
|
if (heading && heading.level <= 3) skipping = false;
|
|
else continue;
|
|
}
|
|
if (!skipping && heading?.level === 3 && pattern.test(heading.title)) {
|
|
skipping = true;
|
|
continue;
|
|
}
|
|
if (!skipping) kept.push(line);
|
|
}
|
|
return kept.join("\n").replace(/^\n+|\n+$/g, "");
|
|
}
|
|
|
|
export function buildLongformOutline(markdown: string): LongformOutline {
|
|
const used = new Map<string, number>();
|
|
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
const headings: LongformHeading[] = [];
|
|
type Block = {
|
|
title: string;
|
|
start: number;
|
|
headingIndex: number;
|
|
h3Indexes: number[];
|
|
};
|
|
const blocks: Block[] = [];
|
|
let current: Block | null = null;
|
|
|
|
lines.forEach((line, lineIndex) => {
|
|
const parsed = parseHeading(line);
|
|
if (!parsed) return;
|
|
const heading: LongformHeading = {
|
|
id: slugifyHeading(parsed.title, used),
|
|
level: parsed.level,
|
|
title: parsed.title,
|
|
};
|
|
const headingIndex = headings.length;
|
|
headings.push(heading);
|
|
if (parsed.level === 2) {
|
|
current = {
|
|
title: parsed.title,
|
|
start: lineIndex,
|
|
headingIndex,
|
|
h3Indexes: [],
|
|
};
|
|
blocks.push(current);
|
|
return;
|
|
}
|
|
if (current) current.h3Indexes.push(headingIndex);
|
|
});
|
|
|
|
const leadParts: string[] = [];
|
|
const preamble = (blocks[0] ? lines.slice(0, blocks[0].start) : lines).join("\n").trim();
|
|
if (preamble) leadParts.push(preamble);
|
|
|
|
const sections: LongformSection[] = blocks.map((block, index) => {
|
|
const end = index + 1 < blocks.length ? blocks[index + 1].start : lines.length;
|
|
const raw = lines.slice(block.start, end).join("\n").trim();
|
|
const eager = EAGER_H2.test(block.title) || SUMMARY_TITLE.test(block.title);
|
|
const summary = extractH3Block(raw, SUMMARY_TITLE);
|
|
if (eager) leadParts.push(raw);
|
|
else if (summary) leadParts.push(summary);
|
|
const sectionMarkdown = eager || !summary ? raw : stripH3Block(raw, SUMMARY_TITLE);
|
|
return {
|
|
id: headings[block.headingIndex]?.id ?? slugifyHeading(block.title, used),
|
|
title: block.title,
|
|
markdown: sectionMarkdown,
|
|
headings: [
|
|
headings[block.headingIndex],
|
|
...block.h3Indexes.map((item) => headings[item]),
|
|
].filter((item): item is LongformHeading => Boolean(item)),
|
|
eager,
|
|
};
|
|
});
|
|
|
|
return {
|
|
headings,
|
|
leadMarkdown: leadParts.join("\n\n").trim(),
|
|
sections,
|
|
};
|
|
}
|
|
|
|
export function extractLongformSummary(markdown: string, maxLength = 2000): string {
|
|
const summaryBlock = extractH3Block(markdown, SUMMARY_TITLE)
|
|
?? extractHeadingBlock(markdown, SUMMARY_TITLE, 2)
|
|
?? "";
|
|
const cleaned = summaryBlock
|
|
.replace(/^#{1,6}\s+.+$/gm, " ")
|
|
.replace(/<[^>]*>/g, " ")
|
|
.replace(/[#>*`|_\[\]]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const fallback = "个人长报告已生成。详情页展示完整 Markdown 正文。";
|
|
const text = cleaned.length > 0 ? cleaned : fallback;
|
|
if (text.length <= maxLength) return text;
|
|
return `${text.slice(0, maxLength - 1)}…`;
|
|
}
|
|
|
|
export function personalReportMarkdownFilename(reportDate: string | null | undefined): string {
|
|
const stamp = typeof reportDate === "string" && /^\d{4}-\d{2}-\d{2}/.test(reportDate)
|
|
? reportDate.slice(0, 10)
|
|
: new Date().toISOString().slice(0, 10);
|
|
return `个人报告-${stamp}`.replace(/[\\/:*?"<>|]+/g, "-");
|
|
}
|