Files
Jyotisha/frontend/src/lib/personal-report-longform-outline.ts
T
Jesse_ChenandCursor cfcd369d4f feat(report): render longform Markdown as the report and close gaps2 holes
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>
2026-09-06 21:46:16 +08:00

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, "-");
}