使用 gpt 3.5 turbo 总结 Markdown 长文
一个关注的雪球大佬说:
其实挺有意思的,我看了本 30 万字的书,写了 3 万字读书笔记,不知道以后是不是会有人再给我写 3000 字摘要发在雪球
—— 郭荆璞
所以想用 gpt-3.5-turbo
的 API 来生成这份总结。主要工作是把 Markdown 按章节进行总结,然后组合到一起。
后记:
最终效果并不很好,所以没有发布到雪球。主要因为(GPT 3.5):
- token 数限制导致上下文不足
- 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 文件的过程:
- 把 Markdown 分节为树状结构
- 对于每个章节,给 API 标题和内容以及全文的梗概以生成总结
- 如果字数没有达到要求,继续执行第 2. 步
实际上也不需要分节为树状结构,我复杂化了,而且因为选错了后序遍历,所以最终没有利用到后序遍历的特点。
分节
标题在文档里的结构其实不是树状的:
# h1
content1
## h2
content2
其中的 h1
和 p
和 h2
其实在结构上是扁平的(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-reduce
和 unist-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-mdx 和 VS Code Markdown Language Service 这样的大工程。
番外:误删文件了!
因为网络问题,所以使用了 GitHub Codespaces 开发。差不多完成了,之前一直没有 commit。想修改 remark-sectionize
,所以打算 commit 一下切换回本地以节省 Codespaces 的免费时间,结果整理时把 packages
文件夹给删了!而 Codespaces
里文件直接就无了!和本地不一样:Codespaces 删除单个文件是可以 undo 恢复,但是删除了文件夹就没办法还原了!
重新写了一遍