lonr

Hello, world!
avatar

Profile

使用 gpt 3.5 turbo 总结 Markdown 长文

一个关注的雪球大佬说:

其实挺有意思的,我看了本 30 万字的书,写了 3 万字读书笔记,不知道以后是不是会有人再给我写 3000 字摘要发在雪球
—— 郭荆璞

所以想用 gpt-3.5-turbo 的 API 来生成这份总结。主要工作是把 Markdown 按章节进行总结,然后组合到一起。

后记:

最终效果并不很好,所以没有发布到雪球。主要因为(GPT 3.5):

  1. token 数限制导致上下文不足
  2. prompt 不好调整

如果使用新的 GPT-4 API 效果应该会更好。(甚至不需要分割合并)

保持大纲的总结 Prompt 尝试

短文档可以参考官方的用法

Summarize the following text.

Text:
"""
<content>
"""

Summary:

如果输入一段文档的同时希望保留小节标题,以下是我摸索出的一种 prompt:

以下是一段 Markdown 文档,请保留 h1,h2,并对 h1,h2 对应的段落进行总结。

"""
<要总结的 Markdown>
"""

结果:

Prompt 影响很大,而且调整比较玄学:

  • 分节效果还是不稳定,改几个字就不能用了
  • 用英文的 prompt,说明了不需要翻译,但 3.5 把我中文的内容还是给翻译为英文了
  • 给了全文的主题,然后每个段落往给的主题上拐太多。所以又加上说明“段落不一定直接推倒出主题的结论”
  • 如何要求字数呢?

参考官方的 Cookbook:

Tips:

  • API 有其他参数可以调整,可以参考 TL;DR 的默认参数
  • 浏览器扩展 Copy as Markdown,可以复制网页内容为 Markdown
  • OpenAI 还有一个 3.0 的 Edits API,正适合缩写,但是 GPT 版本太低

Markdown 分节和总结

因为输入的 Markdown 很长,所以肯定得多次请求。

直觉上认为应该使用后序遍历(在遍历到一个父章节时可以结合其总结过的子章节生成总体的总结),实际上错了,因为这会导致子章节的内容被总结(压缩)多次,而根章节的 intro 只会被总结一次。所以一次总结操作需要把所有内容总结一次,如果字数太多就再全部总结一次。

使用前序遍历(也就是阅读顺序)可以先总结父章节的内容(一般是是所有子章节的 introduction),然后用这个 intro 作为总结子章节时的上下文。

我只使用了整篇文章的总结和子章节本身的标题作为总结的上下文,在这种情况下如何遍历没有区别。

以下是我使用 remark 处理并总结 Markdown 文件的过程:

  1. 把 Markdown 分节为树状结构
  2. 对于每个章节,给 API 标题和内容以及全文的梗概以生成总结
  3. 如果字数没有达到要求,继续执行第 2. 步

实际上也不需要分节为树状结构,我复杂化了,而且因为选错了后序遍历,所以最终没有利用到后序遍历的特点。

分节

标题在文档里的结构其实不是树状的:

# h1

content1

## h2

content2

其中的 h1ph2 其实在结构上是扁平的(HTML 中也类似,除非每层都被 <section> 包裹),所以得手动生成树状结构,把标题、标题内容、子标题、子标题内容包在一起,类似 TOC 生成。

询问 ChatGPT 后发现可以使用 jake-low/remark-sectionize 给 mdast 每层包裹一个 section node。注意到修改后的 mdast 不能直接生成 Markdown,得手动把 section node 去掉。

自己用 ts 写了一个 mdast-util-sectionize,类似 remark-sectionize,但可选择包裹 intro 部分到一个 div node 中以便处理


直觉上有另一种思路是利用 HTML 的分节,将 Markdown 转换为 HTML 再分节,随后再转换为 Markdown。但实际上 Markdown 和 HTML 互相转换会丢失信息(见番外),单独的实现更好。

HTML 本身有同样的分节需求。npm 上也有 rehype-sectionize

遍历

为什么还是 postorder?因为做完才意识到应该使用 preorder 更好

官方的 unist-util-visit(修改 AST)和 unist-util-map(生成一个新 AST)都是 preorder 的。

发现 unist-util-reduceunist-util-flatmap 是 postorder 的。这两个库的用法类似,都可以返回一个 Node 数组来替换原 Node。这也是我需要的操作之一——删除 section Node,用其 children 替代。

但它们都不支持 Promise,而我需要通过 API 获得总结。所以基于 unist-util-map 写了一个 postorder 的版本unist-util-map-postorder并添加了数组和 Promise 支持。

Rate Limits

运行时发现超出了 API 请求频率限制,免费帐号只有 20 RPM,付费帐号有 3,500 RPM。我又没办法添加支付方式,真的 😭

只能再加些 await。原本的 await Promise.all(node.children.map((child, index) => postorder(child, index, node))) 也得拆开。

番外:为什么 HTML 转换到 MD 可能会丢失信息

对于 HTML,可能有:

<section>
# header 1

<intro>
content 1
</intro>

<section>
## header 2

<intro>
content 2
</intro>

<outro>
conclusion 2
</outro>

</section>

<outro>
conclusion 1
</outro>

</section>

这样的 HTML 转换为 MD 会丢失“conclusion 1 属于 h1”这个信息。虽然好像不太会出现这种情况??书本中会使用一个小节来总结

番外:废弃的 HTML 大纲算法

语义化 HTML - WikiPedia 百科中可以看到语义化标签原本是为了方便爬取信息,发展到现在则更专注于可访问性。HTML5 推出一些新标签,其中 <article><section> 尝试带来新的分节逻辑,所以规范当时提出了一套大纲算法,但因为浏览器厂商并没有实现,所以目前已被移除

<h1>总标题</h1>
<section>
  <h1>子标题</h1>
  <p>内容</p>
</section>

曾提议的配套大纲算法会对以上 HTML 生成:

1. 总标题
   1. 子标题

而现实中浏览器并没有实现过这个算法,所以例子 总标题子标题 因为都使用了 <h1> 标签,最终在浏览器的大纲中同级,即:

1. 总标题
2. 子标题

所以如果网站“使用了” <section> 的分节逻辑反而带来更差的体验,目前的规范中 <article><section> 并不再影响分节逻辑。唯一正确的用法(和不使用 <section> 生成同样的大纲):

<h1>总标题</h1>
<section>
  <h2>子标题</h1>
  <p>内容</p>
</section>

生成

1. 总标题
   1. 子标题

但是修改后的规范没有给出具体的大纲算法实现啊 😥。找到被删除的算法,然后只使用其中不带 <section> 的逻辑不就好了


I would in fact prefer, instead of <H1>, <H2> etc for headings [those come from the AAP DTD] to have a nestable <SECTION>..</SECTION> element, and a generic <H>..</H> which at any level within the sections would produce the required level of heading.
--- Sir Uncle Timbo

添加 <section> 来分节其实是 30 年前的设想!

番外:另一个失败的 remark 作品

教训是“动手前得多思考”

Markdown 里的链接显著降低了可读性,remark 官方有一个将链接转化为引用风格的插件:remark-reference-links。基于这款插件,我曾做了另一个插件(见 demo),可以将引用定义生成在章节末尾而不是文档末尾。

remark 插件的执行流程:Markdown -> AST -> 插件修改 AST -> Markdown。因为 AST 并不保存原文的一些格式(比如 listitem 是 - 还是 * 开头的),所以无关内容的格式也会变。这是 Prettier 的使用场景,而我真正想做的类似于 VSCode 中的 ESLint,修改链接的同时不会破坏其他。

remarkjs/remark-lint 的 issue Fixable rules #82 讨论过 ESLint 式的修改方式。所以我的实现应该直接利用 AST 的位置信息直接修改文本,一些 tricks:

  • 获得所有要修改操作,从文档末尾开始修改。这样做不需要重新解析文档或者更新未执行修改的位置。
  • 类似的,magic-string 支持使用原文档的位置来修改。
  • 有一个问题是如果修改部分嵌套或者重叠呢?得特别处理

本来想完成一个小功能,但是发现它应该属于 mdx-js/vscode-mdxVS Code Markdown Language Service 这样的大工程。

番外:误删文件了!

因为网络问题,所以使用了 GitHub Codespaces 开发。差不多完成了,之前一直没有 commit。想修改 remark-sectionize,所以打算 commit 一下切换回本地以节省 Codespaces 的免费时间,结果整理时把 packages 文件夹给删了!而 Codespaces 里文件直接就无了!和本地不一样:Codespaces 删除单个文件是可以 undo 恢复,但是删除了文件夹就没办法还原了!

重新写了一遍