Skip to content
🎯 场景实战第 12 / 16 节

结构恢复:把纯文本还原成 Markdown ​

💡 一句话总结:用 Jev 完成纯文本转 Markdown —— 判断断行和块类型,由代码拼接并添加 Markdown 标记,格式整理不要顺手变成正文改写。

学完你能做什么 ​

  • 找出纯文本里被硬换行拆开的句子。
  • 解释为什么本例需要先拼行、再分类的两次请求。
  • 区分正文、标题、列表、引用、代码和提示块。
  • 用原文对照与低置信度检查验收结果,而不是只看排版漂亮与否。

为什么用 Jev,而不是直接让生成式模型改写? ​

两条路线解决的目标不同。本课只讨论官方 autoformat 示例,不据此做所有模型的效果排名。

检查项直接生成 Markdown 的路线本例的 Jev 路线
正文来源❌ 需要检查生成过程是否改动措辞✅ 渲染器使用输入文本块
模型任务❌ 同时决定结构并输出文本✅ 回答断行、块类型等窄问题
排版生成❌ 由模型输出 Markdown✅ 由代码添加标记
不确定性❌ 需要另行设计检查方法✅ 保留判断结果中的概率信息

这不是逐字节无损转换。官方预处理会整理空白、去掉行首尾空白,拼行时加入空格;渲染时也会新增 Markdown 标记。

你现在的困境 ​

复制来的邮件保留了换行,却丢了标题和项目符号。直接把所有相邻行合并,会把名单挤进上一句;逐行保留,又会把一个正常段落拆碎。

先把问题缩小:这一行是不是上一行中途断掉的句子的延续?这比判断“是不是同一段”更贴近你真正要做的决定。

什么时候用这一招 ​

适合正文仍在、结构标记丢失的纯文本。官方演示是一份构建系统迁移备忘录。

它不是 OCR、事实纠错或内容补写工具。输入本身漏字、顺序错乱或代码缩进已损坏时,应先保留原件并安排人工检查,不能承诺自动还原作者原意。

🎒 开始前的准备 ​

先理解 Choice 题型 与 Noul 题型。本课代码逐字取自 官方 Structure recovery 示例,按顺序照抄就能跑通。

官方安装命令(cooksafe 是官方配套工具库,提供本课用到的 JsonCache 缓存与 Playground 链接助手):

bash
pip install ipython 'cooksafe>=0.2.0,<0.3.0'

从官方 autoformat 示例目录下载缓存到运行脚本的目录(回放官方录制数字全靠它):

bash
curl -sL -o json_cache.json https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/autoformat/json_cache.json

官方代码用 TYPESAFE_API_KEY 环境变量构造客户端,不要把真实 Key 写进笔记或文章;只回放缓存时设一个占位值即可(见第 1 步)。代码还导入 IPython 的显示功能(官方末尾生成 Playground 链接时用到),本课全部步骤在普通 Python 脚本里就能跑。不放缓存文件则全部实时请求,需要有效凭据。

缓存不等于免费运行任意输入

官方缓存用于回放匹配的示例调用。改了输入或问题后,不能假设仍然命中,也不要把一次缓存回放称为新模型的实测结果。本课没有发起付费实时调用。

跟我做 ​

先说好怎么跑:新建 autoformat.py,把下面每一步的代码块按顺序贴进同一个文件,每完成一步运行一次 python autoformat.py。代码逐字取自官方示例,唯一调整是把官方附录的对照问法 naive_join_question 提前定义(第 3 步的对照实验要用)。json_cache.json 就位时,本课全部调用命中缓存,逐字回放官方录制的答案与延迟数字,不发起真实请求。

第 1 步:准备原文,确定纯文本转 Markdown 的边界 ​

为什么:先固定原件,才能知道最后是恢复格式还是悄悄改了内容。

贴入官方 Setup 代码——客户端、缓存、模型版本:

python
import os
import re
import urllib.request
from pathlib import Path
from time import perf_counter

from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient

TYPESAFE_MODEL = "jev-1.12"
client = TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], timeout=120.0)
json_cache = JsonCache(Path("json_cache.json"))

os.environ["TYPESAFE_API_KEY"] 在变量没设置时会直接报错。只回放缓存时设一个占位值即可,例如 export TYPESAFE_API_KEY=cache-only——缓存命中不会发起真实请求。

再贴入官方下载原文的代码(@json_cache 让这次下载同样可回放):

python
DOCUMENT_URL = (
    "https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/autoformat/build-memo.txt"
)


@json_cache
def fetch_document(url: str) -> str:
    request = urllib.request.Request(url, headers={"User-Agent": "typesafe-cookbook/1.0"})
    with urllib.request.urlopen(request) as response:
        return response.read().decode()


RAW = fetch_document(DOCUMENT_URL)
print(RAW[:560])

运行 python autoformat.py。

你应该看到:一份没有 Markdown 标题和列表标记的备忘录开头,而不是已经排好版的文章:

text
Migration to the new build system

Hi everyone, quick heads up about the build system migration that is
happening next week. We have been running the new pipeline in shadow
mode for three weeks and the results look solid, so it is time to
make the switch for real.

后面还有:小标题 What changes for you、单独一行的 bun run build、两份没有符号的名单、一段没有任何标记的警告——标题、列表这些标记全丢了,但空行还在(第 2 步正要用这份证据)。

第一次练习不替换输入,也不修改分类规则,先走通公开示例。后续所有判断都围绕这一份原文 build-memo.txt。

第 2 步:保留空行证据,检查断行 ​

为什么:空行是代码可以直接读取的结构信息,不必再让模型猜一次。

贴入官方 to_lines 与标记行号的代码。它整理空白、保留相邻文本之间是否存在空行的标记,并给非空行分配 L 开头的编号:

python
def to_lines(text: str) -> list[dict]:
    lines, gap = [], False
    for raw in text.split("\n"):
        stripped = re.sub(r"[\t ]+", " ", raw).strip()
        if not stripped:
            gap = bool(lines)  # a leading blank is not a break
            continue
        lines.append({"text": stripped, "gap": gap})
        gap = False
    return lines


def tag(items: list[dict], prefix: str) -> str:
    return "\n".join(
        f"{chr(10) if item['gap'] else ''}{prefix}{i:03d}| {item['text']}"
        for i, item in enumerate(items)
    )


def line_id(i: int) -> str:
    return f"L{i:03d}"


def block_id(i: int) -> str:
    return f"B{i:03d}"


LINES = to_lines(RAW)
print(f"{len(LINES)} non-blank lines. The model sees, e.g.:")
print("\n".join(tag(LINES, "L").splitlines()[19:24]))

运行。

你应该看到(官方示例输出):

text
28 non-blank lines. The model sees, e.g.:
L013| The cutover touches three teams, so check whether you are on this
L014| list before you plan anything for Monday:
L015| The platform team
L016| The web client team
L017| Whoever still owns the release tooling

官方示例有 28 个非空行——这只是该输入的核对点,不是你自己的文档必须达到的数量。模型读到的是带 L 编号的文本,问题和答案都按编号引用行。

第 3 步:用 Noul 判断是否接续句子 ​

为什么:先恢复被硬换行拆开的句子,后面的块分类才有正确输入。

贴入官方 Pass 1 的问题构造代码。每对未被空行隔开的相邻行对应一个 Noul 问题,这些问题放进同一次请求;naive_join_question 是官方附录的对照问法,本步末尾的对照实验会用到:

python
def join_question(i: int) -> Noul:
    return Noul(
        instructions=f"Does line {line_id(i)} pick up mid-sentence, continuing a sentence left unfinished at the end of line {line_id(i - 1)}?",
        criteria=NoulCriteria(
            true="The line starts in the middle of a sentence that began on the previous line - the line break tore the sentence apart",
            false="The line begins a new sentence, item, heading, or thought of its own",
        ),
    )


def naive_join_question(i: int) -> Noul:
    return Noul(
        instructions=f"Are lines {line_id(i - 1)} and {line_id(i)} part of the same paragraph?",
        criteria=NoulCriteria(
            true="The two lines belong to the same paragraph of running text",
            false="The two lines belong to different paragraphs or different pieces of content",
        ),
    )

贴入官方请求与拼行代码——stitch() 把全部行对放进一次请求,merge() 按阈值把接续的行拼成块:

python
@json_cache
def stitch(wording: str = "mid-sentence") -> dict:
    make = join_question if wording == "mid-sentence" else naive_join_question
    questions = {line_id(i): make(i) for i in range(1, len(LINES)) if not LINES[i]["gap"]}
    started = perf_counter()
    response = client.system_one(
        state=tag(LINES, "L"), questions=questions, model=TYPESAFE_MODEL
    )
    return {
        "joins": [
            response.answers[line_id(i)].noul if line_id(i) in response.answers else 0.0
            for i in range(len(LINES))
        ],
        "seconds": round(perf_counter() - started, 2),
        "usage": [response.usage.input_tokens, response.usage.output_tokens],
    }


result = stitch()
print(f"{sum(1 for l in LINES if not l['gap']) - 1} pair questions, one request, "
      f"{result['seconds']}s")
python
JOIN_AFTER_DANGLING, JOIN_AFTER_TERMINAL = 0.2, 0.5


def ends_terminal(text: str) -> bool:
    return re.search(r'[.!?:;…]["\')\]]*$', text) is not None


def merge(joins: list[float]) -> list[dict]:
    blocks = []
    for i, line in enumerate(LINES):
        bar = (
            JOIN_AFTER_TERMINAL
            if i and ends_terminal(LINES[i - 1]["text"])
            else JOIN_AFTER_DANGLING
        )
        if blocks and not line["gap"] and joins[i] >= bar:
            blocks[-1]["text"] += " " + line["text"]
            blocks[-1]["lines"].append(i)
        else:
            blocks.append({"text": line["text"], "lines": [i], "gap": line["gap"]})
    return blocks


blocks = merge(result["joins"])
healed = len(LINES) - len(blocks)
print(f"{len(LINES)} lines -> {len(blocks)} blocks ({healed} line breaks healed)")
for i, block in enumerate(blocks):
    n = len(block["lines"])
    print(f"{block_id(i)}  {n} line{'s' if n > 1 else ' '}  {block['text'][:62]}")

运行。

你应该看到(官方录制输出,节选;缓存回放时延迟数字也来自录制):

text
16 pair questions, one request, 0.32s
28 lines -> 17 blocks (11 line breaks healed)
B000  1 line   Migration to the new build system
B001  4 lines  Hi everyone, quick heads up about the build system migration t
B002  1 line   What changes for you
B003  3 lines  The old make targets keep working until the end of the month. 
B004  1 line   bun run build
……
B014  3 lines  If the doctor script reports a red result on the toolchain che
B015  2 lines  As Dana put it in the kickoff, "a migration nobody notices is 
B016  1 line   Thanks, and shout if anything looks off.

官方缓存示例把 28 行合成 17 块。实时请求输出不承诺与缓存完全相同;不一致时先检查输入、问法和缓存使用方式,不要直接改预期数字。

这里为什么还要代码规则

官方合并逻辑会区分上一行是否以终止标点结束,使用不同阈值(上一行无终止标点用 0.2,有终止标点升到 0.5);不能只看“内容相关”就连接两行。这些阈值是该示例的选择,不是所有语言和文档的通用最优值。

对照实验:别把问法换成「属于同一段落吗」。 官方附录用 naive_join_question 重跑了拼行——宽泛问法下,同主题的列表项得分 0.77–0.91,名单被并进引导句,17 块缩成 12 块:

python
naive = stitch("same-paragraph")
print(f"{'':14}{'mid-sentence':>13}{'same paragraph':>16}")
for i in (15, 16, 17, 20, 21):
    print(f"{line_id(i)}{'':2}{LINES[i]['text'][:36]:<38}"
          f"{result['joins'][i]:>7.2f}{naive['joins'][i]:>13.2f}")
print(f"\nblocks after merge: {len(blocks)} (mid-sentence) vs "
      f"{len(merge(naive['joins']))} (same paragraph)")
text
               mid-sentence  same paragraph
L015  The platform team                        0.22         0.77
L016  The web client team                      0.11         0.81
L017  Whoever still owns the release tooli     0.12         0.78
L020  Delete the old build cache directory     0.08         0.88
L021  Run the doctor script and fix anythi     0.05         0.91

blocks after merge: 17 (mid-sentence) vs 12 (same paragraph)

「是不是同一段」问的是主题延续,「是不是接着上一行的句子」问的是文本本身。判断要喂给阈值时,问法应该点名最窄的那个事实——这里问法的差别就是 17 块和 12 块的差别。

第 4 步:对已经形成的块做 Choice 分类 ​

为什么:块只有在拼行结果出来后才存在,所以分类不能与第一遍合成并进同一次请求。

贴入官方 Pass 2 的分类标准。块类型包括 heading、paragraph、list_item、quote、code、callout;官方说明这三个字典(加上步骤题的是非判断)就是分类器的全部规格——想适配自己的文档,改的就是这些描述:

python
TYPE_CRITERIA = {
    "heading": "A short label or title that names the document or the section that follows it - not a full sentence of content",
    "paragraph": "Running prose: one or more complete sentences of explanatory or narrative text",
    "list_item": "One entry in a list of parallel items - an ingredient, a feature, a task, an attendee; reads as one of several sibling entries",
    "quote": "Words attributed to a person or source - quoted speech, a citation, an excerpt someone else wrote",
    "code": "Computer code, a shell command, terminal output, or a config snippet meant to be read verbatim",
    "callout": "A warning, tip, or important note that interrupts the flow to flag something the reader must not miss",
}
HLEVEL_CRITERIA = {
    "title": "The title of the whole document",
    "section": "A major section heading within the document",
    "subsection": "A minor heading nested under a section",
}
CALLOUT_CRITERIA = {
    "note": "Neutral extra information the reader should be aware of",
    "tip": "A helpful suggestion or shortcut that makes things easier",
    "warning": "A caution about something that can go wrong or cause harm",
}

贴入官方问题构造代码。同一分类请求还提前询问标题层级、列表是否有步骤顺序、提示块类别——类型事先未知,等它就要第三次往返,所以辅助问题先问;渲染器只采用与最终块类型相关的辅助答案,不把所有辅助判断都当作有效标签:

python
HEADING_MAX_CHARS = 90  # longer blocks can't render as headings, so don't ask


def classify_questions(texts: list[str]) -> dict:
    questions = {}
    for i, text in enumerate(texts):
        bid = block_id(i)
        questions[f"type_{bid}"] = Choice(
            instructions=f"What kind of content is block {bid}?", criteria=TYPE_CRITERIA
        )
        if len(text) <= HEADING_MAX_CHARS:
            questions[f"hlevel_{bid}"] = Choice(
                instructions=f"As a heading, what level would block {bid} occupy in this document's structure?",
                criteria=HLEVEL_CRITERIA,
            )
        questions[f"step_{bid}"] = Noul(
            instructions=f"Is block {bid} an instruction in a sequence where the order of the items matters?",
            criteria=NoulCriteria(
                true="It is one step of a procedure - the items around it must happen in order",
                false="Order is irrelevant - it is a loose collection, or not a list item at all",
            ),
        )
        questions[f"callout_{bid}"] = Choice(
            instructions=f"What kind of aside is block {bid}?", criteria=CALLOUT_CRITERIA
        )
    return questions

贴入官方分类请求与打印代码:

python
@json_cache
def classify(texts: list[str], gaps: list[bool]) -> dict:
    tagged = tag([{"text": t, "gap": g} for t, g in zip(texts, gaps)], "B")
    questions = classify_questions(texts)
    started = perf_counter()
    response = client.system_one(state=tagged, questions=questions, model=TYPESAFE_MODEL)
    judgments = []
    for i in range(len(texts)):
        bid = block_id(i)
        type_answer = response.answers[f"type_{bid}"]
        hlevel = response.answers.get(f"hlevel_{bid}")
        judgments.append(
            {
                "type": type_answer.choice,
                "confidence": type_answer.confidence,
                "probabilities": type_answer.probabilities,
                "hlevel": hlevel.choice if hlevel else "section",
                "step": response.answers[f"step_{bid}"].noul,
                "callout": response.answers[f"callout_{bid}"].choice,
            }
        )
    return {
        "judgments": judgments,
        "n_questions": len(questions),
        "seconds": round(perf_counter() - started, 2),
        "usage": [response.usage.input_tokens, response.usage.output_tokens],
    }


classified = classify([b["text"] for b in blocks], [b["gap"] for b in blocks])
for block, judgment in zip(blocks, classified["judgments"]):
    block.update(judgment)
print(f"{classified['n_questions']} questions about {len(blocks)} blocks, one request, "
      f"{classified['seconds']}s\n")
print(f"{'block':<6}{'type':<11}{'conf':<6}{'companion used':<18}text")
for i, b in enumerate(blocks):
    companion = {
        "heading": f"level={b['hlevel']}",
        "list_item": f"step={b['step']:.2f}",
        "callout": f"kind={b['callout']}",
    }.get(b["type"], "-")
    print(f"{block_id(i):<6}{b['type']:<11}{b['confidence']:.2f}  {companion:<18}"
          f"{b['text'][:46]}")

运行。

你应该看到(官方录制输出,节选):

text
62 questions about 17 blocks, one request, 0.51s

block type       conf  companion used    text
B000  heading    0.99  level=title       Migration to the new build system
B001  paragraph  0.98  -                 Hi everyone, quick heads up about the build sy
B002  heading    0.75  level=section     What changes for you
B003  paragraph  0.89  -                 The old make targets keep working until the en
B004  code       1.00  -                 bun run build
B005  paragraph  0.90  -                 Generated artifacts no longer need to be commi
……
B014  callout    0.65  kind=warning      If the doctor script reports a red result on t
B015  quote      0.99  -                 As Dana put it in the kickoff, "a migration no
B016  paragraph  0.92  -                 Thanks, and shout if anything looks off.

每个块有类型及相应判断记录,标题块能提供层级,列表块能提供顺序判断。注意两份名单的 step 概率:待办事项三条在 0.86–0.90(第 5 步会渲染成编号列表),团队名单三条在 0.12–0.16(渲染成项目符号列表)。不要为了复现文档里的延迟数字反复调用 API。

第 5 步:渲染 Markdown,并和原文对照 ​

为什么:判断正确不代表最终展示一定符合你的需求,还要检查渲染和内容。

贴入官方渲染代码。to_markdown(blocks) 把连续列表项分组,组内 step 概率均值达到 0.5 就用编号、否则用项目符号,并为标题、代码、引用和提示块添加标记(代码里有三反引号字符串,所以这个代码块用四反引号围栏):

python
STEP_THRESHOLD = 0.5
HEADING_MARK = {"title": "#", "section": "##", "subsection": "###"}
CALLOUT_MARK = {"note": "NOTE", "tip": "TIP", "warning": "WARNING"}


def to_markdown(blocks: list[dict]) -> str:
    groups = []
    for b in blocks:
        if b["type"] in ("list_item", "code") and groups and groups[-1][0] == b["type"]:
            groups[-1][1].append(b)
        else:
            groups.append((b["type"], [b]))
    parts = []
    for kind, items in groups:
        if kind == "list_item":
            ordered = sum(b["step"] for b in items) / len(items) >= STEP_THRESHOLD
            parts.append("\n".join(
                f"{n + 1}. {b['text']}" if ordered else f"- {b['text']}"
                for n, b in enumerate(items)
            ))
        elif kind == "code":
            parts.append("```\n" + "\n".join(b["text"] for b in items) + "\n```")
        elif kind == "heading":
            parts.append(f"{HEADING_MARK[items[0]['hlevel']]} {items[0]['text']}")
        elif kind == "quote":
            parts.append(f"> {items[0]['text']}")
        elif kind == "callout":
            parts.append(f"> [!{CALLOUT_MARK[items[0]['callout']]}]\n> {items[0]['text']}")
        else:
            parts.append(items[0]["text"])
    return "\n\n".join(parts) + "\n"


markdown = to_markdown(blocks)
print(markdown)

运行,先读打印出的 Markdown,再看渲染结果。

你应该看到(官方录制输出,节选——四行硬换行的开头段合成了一段,命令进了代码围栏,两份名单一份带编号一份带符号,没有任何标记的警告成了提示块):

text
# Migration to the new build system

Hi everyone, quick heads up about the build system migration that is happening next week. We have been running the new pipeline in shadow mode for three weeks and the results look solid, so it is time to make the switch for real.

……
```
bun run build
```
……
- The platform team
- The web client team
- Whoever still owns the release tooling

## Things to do before Monday

1. Update your local toolchain to version 2.4 or later
2. Delete the old build cache directory
3. Run the doctor script and fix anything it flags

> [!WARNING]
> If the doctor script reports a red result on the toolchain check, do not proceed with the migration. Ping the infra channel first and we will sort it out together.

> As Dana put it in the kickoff, "a migration nobody notices is the only kind worth shipping."

Thanks, and shout if anything looks off.

标题不再混在普通段落里,名单与操作步骤以适当列表表示,示例里的命令作为文本展示。文档中的命令是待排版内容,不是要你执行的系统操作。

逐块确认原文信息是否还在;特别检查名单前的冒号、跨行句子、列表边界和代码。对低置信度或多种类型难以区分的块,优先人工复核——官方示例里置信度最低的块正是名单引导句(0.43,paragraph / list_item / callout 三种概率 0.53 / 0.24 / 0.19 分散),官方给的复核线例子是把类型置信度低于 0.55 的块标出来送人工。

💡 这就是 Jev 的独有价值

输出的每个字都来自输入:模型只回答了「这行是否接续句子」「这块是什么类型」这类窄问题,边界、类型和标记全由代码决定。对比直接让生成式模型改写——你还得另想办法检查它有没有顺手改措辞。

📋 魔法 Prompt ​

前两个是本课流水线的可抄 SDK 问题模板(占位符用【】,带 ? 的是选填),后两个是人工核验用的文本模板——都不是已运行的模型效果证明。

📝 拼行题(Noul 版) ​

预期效果:自己的文档要拼行时,给每对相邻行抄这一题,全部放进一次请求;判断标准写文本事实,不写主题延续。

python
from typesafe_sdk import Noul, NoulCriteria

# 必填项:【i】是当前行号;true/false 是给模型看的判断标准
join_q = Noul(
    instructions=f"Does line {line_id(【i】)} pick up mid-sentence, continuing a "
                 f"sentence left unfinished at the end of line {line_id(【i - 1】)}?",
    criteria=NoulCriteria(
        true="【这一行从上一行没写完的句子中间开始——换行把句子拆断了】",
        false="【这一行自己开始新的句子、条目、标题或意思】",
    ),
)
# 选填项:官方 ends_terminal 只认英文标点,处理中文文档时把全角标点补进正则?
# 输出规范:answer.noul 是「接着上一行」的概率,拼不拼由代码按阈值决定
# 约束条件:
# ✅ 问「是否接续句子」这种窄事实——官方对照:换成「同一段落」,列表项得分 0.77–0.91,17 块并成 12 块
# ✅ 空行隔开的行对跳过不发;上一行结尾有无标点由代码读,两档阈值分开设
# ❌ 别让模型输出拼接后的文本——拼接由代码做,输出每个字都来自输入

✅ 块类型题(Choice 版) ​

预期效果:把「这块是什么内容」变成一道选择题;想适配自己的文档,改的就是描述文字——官方说这三个字典就是分类器的全部规格。

python
from typesafe_sdk import Choice

# 必填项:criteria 的键是类型名,值是给模型看的判断标准
type_q = Choice(
    instructions=f"What kind of content is block {【块编号】}?",
    criteria={
        "heading": "【短标签或标题,命名文档或其后章节——不是完整句子】",
        "paragraph": "【成段散文:一句或多句完整的说明或叙述】",
        "list_item": "【平行条目列表中的一条,读起来是同类的兄弟条目】",
        # 官方还有 quote / code / callout 三类,按同样格式补齐
    },
)
# 选填项:辅助题(标题层级 / 步骤顺序 / 提示块类别)提前放进同一请求,答案只在类型相关时读?
# 输出规范:answer.choice 是类型名;answer.confidence 和 probabilities 接人工复核线
# 约束条件:
# ✅ 每块一题、全部块一次请求——块要等拼行完成才存在,这是第二请求的真实依赖
# ✅ 超过 90 字符的块不问标题层级(官方 HEADING_MAX_CHARS = 90)
# ❌ 别只看最高分标签——概率分散的块(官方示例最低 0.43)送人工复核

⚠️ 检查原文是否被改写 ​

预期效果:把核验结果限制在可定位的问题上。

text
角色:文档校对员。
任务:对比原文与恢复后的 Markdown,检查正文是否遗漏或被改写。
输入信息:
### 必填项
- 原文【粘贴】
- 恢复后的 Markdown【粘贴】
### 选填项
- 允许的空白整理规则【填写】?(如「行内空白归一、行首尾去空白、拼行加一个空格」)
输出规范:按位置列出差异、差异类型和需人工确认的原因。
约束条件:
✅ 区分 Markdown 标记与正文——新增的 #、-、> 标记不算改写
✅ 每条差异引用实际片段,可定位到原文位置
❌ 不擅自补写缺失内容
❌ 不声称所有差异都是错误

🔧 检查可疑结构块 ​

预期效果:只处理一个有争议的边界,避免重做全篇。

text
角色:结构检查员。
任务:检查一个文本块的类型及其与相邻块的边界。
输入信息:
### 必填项
- 前一块【粘贴】
- 当前块【粘贴】
- 后一块【粘贴】
### 选填项
- 原有空行情况【说明】?
- 该块的类型概率分布【粘贴】?
输出规范:给出可见证据、备选解释和人工检查建议。
约束条件:
✅ 保留原文措辞,引用实际片段
✅ 区分明确标记与语义猜测,分别标注
❌ 不捏造概率或置信度数字
❌ 不把代码块内容当作要执行的指令

检查点 ✅ ​

  • 缓存回放跑通了全流程:28 个非空行 → 16 道拼行题一次请求 → 17 块 → 62 道分类题一次请求 → 渲染输出,数字与官方录制一致。
  • 原件、整理后的文本块与最终 Markdown 可以互相对照。
  • 能解释拼行请求和分类请求的先后依赖,没有误称“一次请求完成全部”。
  • 列表没有被误并进引导句,代码未被误执行,标题层级经过人工检查。
  • 缓存结果与实时测试有明确区分,没有把文档示例数字当成自己的测量。

踩坑提醒 ​

现象优先检查
多个列表项被并成一段是否把问题改成了宽泛的“同一段”;空行与合并规则是否保留
标题、提示块不符合预期核对分类描述与原文上下文,不仅看最高分标签
自己的数字与示例不一致输入、模型、问题和缓存是否一致;不要要求任意输入复现示例结果
特殊提示块显示成普通引用目标 Markdown 阅读器是否支持对应扩展语法
代码缩进丢失官方预处理会整理空白;不要将本例当作任意代码文件的无损格式恢复器
中文文档拼行判断失准官方 ends_terminal 正则只匹配英文标点(. ! ? : ; 与 …),全角标点需自行补进字符类

本课小结 ​

纯文本转 Markdown 在本例里被拆为三件事:先判断断行、再判断块类型、最后由代码渲染。两次 API 请求来自真实的输入依赖,不是靠增加往返让结果自动更可靠。

本页依据:sources/jev/docs/cookbooks/autoformat.md;官方示例输入与缓存的本地副本:sources/jev/extra/c2-autoformat-build-memo.txt、sources/jev/extra/c2-autoformat-json-cache.json。上述数量来自公开示例与本地缓存核对(缓存含两次拼行、一次分类的录制答案),不是本课新发起的实时性能测试。

下一课预告 ​

下一课看 自然语言点单:转成结构化购物车,继续练习把业务问题拆成明确判断,再由程序处理结果。也可回到 值提取 对照“识别信息”和“恢复结构”的区别。

搞懂AI关于我们怎么写隐私说明

独立的第三方教程,与文中提到的任何产品及其公司没有隶属或合作关系;产品名称和商标归各自所有者。价格和政策以官方页面为准。