从 PDF 到可信 JSON:AI 简历 CLI 的需求拆解与工程实现
最近看到一个简历分析工具的需求:读取 PDF 简历、调用 AI 提取信息、再根据岗位描述给出匹配评分。
字面上只有三项功能,如果直接开工,写一个能跑的版本并不费事。
我当时没有马上建目录。解析和提取是什么关系?PDF 是否默认走 OCR?parse 要不要把整份简历铺在终端里?没有 API Key 时怎样演示?模型给出的分数靠什么解释?这些问题都没有现成答案,放着不管,后面一定会以返工的方式出现。
于是这个项目从一轮挺长的需求讨论开始。问题一条条定下来以后,我把结论整理成开发计划,再交给 Codex 按阶段执行。中间继续检查实现、调整细节、补测试,最后才有了现在的 resume-cli。这篇记录主要写这段过程,也顺便解释几个关键设计。
项目和实际效果可以从下面俩个入口查看:
写代码前,先把模糊的地方问完
最开始讨论得最多的,是三个命令之间的关系。
parse 和 extract 都会读取简历,看上去有些重复。继续拆开以后就清楚了:parse 解决 PDF 文本层读取问题,全程留在本地;extract 复用解析结果,再把完整文本交给模型生成固定 JSON。score 也会解析 PDF,不过它直接使用完整简历和 JD,不先经过 extract,因为姓名、技能、学历这些摘要字段不足以判断项目深度、上线职责和量化结果。
其他问题也在这一阶段逐渐收敛:
| 讨论的问题 | 最后采用的约定 |
|---|---|
| 普通 PDF 是否需要 OCR | 有文本层就直接解析;扫描件检测后提示先做 OCR |
parse 默认输出什么 | 只显示文件信息和有限预览,--full 或 --output 才给全文 |
| Mock 是否可以不传文件 | 不可以;Mock 只替换 AI,PDF 和 JD 仍走完整校验 |
| 评分是否交给模型计算 | 模型找证据并判断状态,Python 按固定公式计算 |
| 是否拆成四个 Agent | 当前流程一次结构化调用就够,不增加重复请求和汇总误差 |
| Key、模型和 Base URL 怎么配置 | Key 用环境变量或 .env,模型可临时覆盖,Base URL 固定 |
| Docker、uv、Makefile 怎么分工 | uv 负责 Python 安装和依赖,Docker 负责隔离运行,Makefile 只做开发快捷入口 |
这些讨论最后写进了仓库的 docs/implementation-plan.md。计划里不仅列功能,也写了公共命令、数据结构、评分权重、退出码、隐私边界、测试范围、提交顺序和发布标准。
这份计划也调整过几次。比如最初的评分更偏技能,讨论后改成技能 30%、项目与交付经验 40%、教育 10%、持续成长 20%;overall_score 从简单平均改成固定加权;公开 JSON 保持扁平,内部模型则使用嵌套结构,避免维护两份分数来源。
等这些细节稳定下来,Codex 才按照计划依次完成项目骨架、PDF 解析、Kimi Provider、评分、Mock、测试、CI、文档和 Release。开发过程中遇到新问题,就回到计划和测试里补上,而不是只改到当前命令能继续运行。
三个命令各自管一段
CLI 是 Command-Line Interface,也就是命令行界面。安装完成后,Windows 的 PowerShell、macOS 的 Terminal 和 Linux Shell 都可以直接执行 resume-cli。
项目保留了三条职责很清楚的命令:
resume-cli parse examples/sample-resume.pdf
resume-cli extract examples/sample-resume.pdf --mock
resume-cli score examples/sample-resume.pdf --jd examples/sample-jd.txt --mock它们共用同一个 PDF 解析服务,拿到文本以后的走向不同。
parse 默认只显示路径、页数、字符数和前面一小段内容。这样可以快速确认文件能否解析,又不会把一整份简历刷满终端。需要完整文本时再加 --full,或者用 --output parsed.txt 保存。
extract 读取的是完整文本,不受预览长度影响。真实模式调用 Kimi,Mock 模式使用离线 Provider,两者最后都要通过同一份 Pydantic Schema。缺失字段只能是 null 或空数组,不能根据常识补出一段个人经历。
score 同样使用完整简历,并要求传入非空的 UTF-8 JD。这个命令没有先调用 extract,少走一步调用是一方面,更实际的原因是结构化摘要会损失大量项目上下文。
PDF 能不能读,看的是文本层
“本地 PDF”这个说法容易让人联想到 OCR。实际判断方式很简单:在阅读器里能不能选中并复制正文。
Word、WPS、浏览器或者在线简历系统正常导出的 PDF,通常已经带有文本层,pypdf 可以直接读取。扫描仪生成的文件、手机拍照、截图拼接成的 PDF 通常只有图片,需要先经过 OCR。双栏、表格和特殊字体即使有文本层,也可能出现阅读顺序不理想的问题。
v1 没有内置 OCR。项目会识别空文本、疑似扫描件和加密文件,并告诉用户下一步怎么处理。OCR 涉及语言模型、系统依赖、版面恢复和准确率,仓促接进去反而会让一个稳定的解析命令变得难以预测。
公开示例也专门做成了带文本层的中文 PDF。生成脚本使用中文字体,测试会逐行核对提取结果;我还把 PDF 渲染成图片检查过实际排版,避免出现程序能读、打开却是方框或乱码的情况。
LLM 提供语义判断,代码验证并计算
如果提示模型“请给这份简历打分”,它通常会返回一个挺像样的数字,可这个数字很难解释,也不容易复现。项目把这一步拆成了证据分析和数值计算。
这里可以说得更具体一点:LLM 提供语义判断,判定的是四个维度下共 14 个内部标准,并准备好判断理由和简历/JD 原文短引用。它不会直接决定技能分、经验分或综合分,也不能修改评分项和权重。
程序拿到结果后,会重新检查该项要求的引用是否确实存在于输入文件。每个非 missing 项都需要简历原文证据,直接涉及岗位要求的标准还需要 JD 原文证据;伪造、改写或无法定位的必要引用不计分。通过校验以后,matched、partial、missing 才分别映射为 100%、50%、0%,再进入固定公式。代码没有重复做一次语义分析,它检查的是模型给出的判断能不能追溯到原文。
overall_score = round_half_up(
skill_score × 30% +
experience_score × 40% +
education_score × 10% +
growth_score × 20%
)仓库内置的中文示例正好可以把整个过程算一遍。Mock 模式使用固定数据模拟 LLM 的逐项判断,真实模式则由 Kimi 返回这些状态和依据;后面的证据校验和公式完全相同。
技能维度的三项都判为 matched:
| 内部标准 | 状态 | 简历/JD 依据 | 维度内得分 |
|---|---|---|---|
| 必备技能覆盖 50% | matched | Python、TypeScript、FastAPI / 熟练使用 Python 和 TypeScript | 50 |
| 项目应用深度 30% | matched | 基于 Kimi 兼容接口开发简历分析服务 / 大模型 API 集成和结构化输出 | 30 |
| 加分/相邻技能 20% | matched | Docker、GitHub Actions / Docker 和 CI/CD | 20 |
所以 skill_score = 50 + 30 + 20 = 100。
项目与交付经验有一项只得到 partial:
| 内部标准 | 状态 | 简历/JD 依据 | 维度内得分 |
|---|---|---|---|
| 相关性 25% | matched | 生产级人才服务 API / 生产级 API 交付 | 25 |
| 所有权 20% | matched | 架构设计与上线交付 / 从实现、上线到运行维护的完整责任 | 20 |
| 复杂度/设计 20% | matched | 设计原文证据校验和确定性评分机制 / 系统设计经验 | 20 |
| 上线运维迭代 20% | partial | 写明上线交付,但没有充分说明后续运维责任 | 10 |
| 量化结果 15% | matched | 将处理耗时降低 40% / 可量化的产品效果或性能优化成果 | 15 |
因此 experience_score = 25 + 20 + 20 + 10 + 15 = 90。这里的 10 分来自 20 × 50%,也是第一道面试追问会继续核实上线后运维职责的原因。
教育与基础的两项均为 matched:
| 内部标准 | 状态 | 简历/JD 依据 | 维度内得分 |
|---|---|---|---|
| 学历/专业 70% | matched | 计算机科学与技术,本科 / 计算机相关专业本科 | 70 |
| 基础/证书/等价学习 30% | matched | 计算机科学与技术 / 具备同等基础 | 30 |
所以 education_score = 70 + 30 = 100。
持续成长维度的计算是:
| 内部标准 | 状态 | 简历/JD 依据 | 维度内得分 |
|---|---|---|---|
| 能力演进 25% | partial | 只写明当前高级工程师经历,没有展开早期成长轨迹 | 12.5 |
| 持续输出 30% | matched | 每月更新技术博客 / 技术写作经验 | 30 |
| 维护质量 25% | matched | 自 2023 年持续维护 Resume Toolkit / 开源项目长期维护 | 25 |
| 外部影响 20% | matched | 1200 个 GitHub Star / 开源项目长期维护 | 20 |
四项相加是 87.5,按 round half up 得到 growth_score = 88。最后再计算综合分:
overall_score = round_half_up(
100 × 30% +
90 × 40% +
100 × 10% +
88 × 20%
)
= round_half_up(93.6)
= 94这样回看输出中的 94,它就不再是模型随口给出的数字。模型负责阅读语义、逐项判断和引用证据,程序负责拒绝无法核实的依据,并把通过校验的状态换算成四个维度分数和综合分。
项目与交付经验占 40%,里面继续看相关性、主导性、复杂度、上线运维和量化结果。持续成长占 20%,关注能力演进、长期输出、维护质量和外部影响。GitHub Star 只会影响外部影响这个小项,不会让整个成长维度直接满分。
结果里的 2~5 个问题不参与得分。简历只写了“负责上线”,没有说明上线后具体承担哪些运维工作,工具就把它留成一道追问。这里保留一点“不确定”,比自动补齐经历稳妥得多。
没有为四个维度各建一个 Agent
讨论评分时,我也考虑过四个分析 Agent 加一个汇总 Agent。后来把调用过程和失败点列了一遍,这套设计对当前项目没有明显帮助。
四个 Agent 会重复读取同一份简历和 JD,Token、延迟和出错机会都会增加。综合分本身是一条固定公式,再让第五个 Agent 汇总,结果反而可能漂移。当前任务没有循环规划、工具自治、人工审批和持久状态,一次严格结构化调用更合适。
代码里仍然保留了 Provider 协议。Kimi Provider 负责真实请求,Mock Provider 负责离线演示,CLI 和评分器只依赖协议。以后若增加联网核验、并行检索或人工复核,可以在这个边界后面扩展,现阶段用不到 LangChain 或 LangGraph。
Mock 留住了文件和业务链路
公开仓库不能假设每个读者都有 Moonshot Key。--mock 让安装后的人可以立即跑通示例,不过它只跳过外部模型请求。
PDF 仍要存在并且可解析,JD 仍要存在、非空且编码正确,证据校验、评分和 JSON 序列化也照常执行。仓库里的中文虚构简历和 JD 对应一套稳定 fixture,适合 CI 和录屏;换成其他文件时,Mock 会给出保守结果,并在 stderr 提醒这不是语义分析。
这种安排也方便排查问题。Mock 正常、真实模式失败时,可以优先检查 Key、网络和模型响应;两种模式都失败,就回到 PDF、JD 或本地业务链路继续定位。
uv、Docker 和 Makefile 各管一件事
用户从固定版本安装最省事:
uv tool install --python 3.12 git+https://github.com/Mars13333/resume-cli.git@v1.0.1
resume-cli --helpuv tool 会创建隔离的 Python 环境,并把命令放进用户 PATH。源码贡献者则使用 uv sync 和 uv run。这两种方式都支持 Windows、macOS 和 Linux。
Docker 提供另一套运行环境。宿主机不需要安装 Python 或 uv,只需挂载本地文件并运行镜像。容器使用非 root 用户,示例 Mock 可以断网执行,.env 也不会进入镜像。
Makefile 只给开发者缩短命令,例如 make check、make build、make docker-smoke。Windows 没有 Make 也不影响使用,README 里保留了对应的 uv 和 Docker 原始命令。
代码之外也需要验收
功能跑通以后,仓库还经历了几轮检查。
stdout 只放正文或 JSON,日志和警告放 stderr,方便继续接管道;文件输出先写临时文件,再原子替换目标;详细日志不记录简历、JD 和 API Key。错误按参数、PDF、AI 服务和写入失败分配稳定退出码。
测试覆盖了正常流程,也包括伪 PDF、损坏文件、加密文件、扫描件、非法 JD 编码、API 认证、限流、超时、模型截断、非法 JSON、伪造证据、Windows 中文输出和舍入边界。目前共有 61 个测试,覆盖率超过 94%。GitHub Actions 分别在 Python 3.11/Linux、3.12/Windows、3.13/macOS 上运行;Linux 还会构建 wheel、源码包和 Docker 镜像。
提交记录也按照计划分开保留:项目初始化、PDF 解析、Kimi 提取、证据评分、Mock、测试、CI、文档和发布各自独立。以后查看某次修改时,可以从提交信息知道它解决了什么,不必在一个巨大提交里猜。
文档同样参与验收。README 要说清安装、配置、命令、评分、OCR、Docker、Makefile、隐私和限制;重要取舍单独放进 ADR;演示脚本规定录屏顺序和敏感信息检查。原始需求留在本地,公开仓库和 Blog 只写通用需求,不出现真实需求来源、公司名称或个人简历。
总结
回头翻开发记录,最有用的部分还是最早那串问题。parse 默认打印多少内容、Mock 要不要校验文件、总分怎样舍入、Docker 是否替代 uv,这些都不算复杂技术,可每一个都会影响命令接口、测试和文档。如果等代码写完再决定,改动会散到整个项目里。
现在这套流程已经比较固定了:先把需求里含糊的词圈出来,和 AI 把使用场景、异常边界、数据结构与交付方式聊清楚;把结论写成可以逐项验收的计划;再让编码工具执行,人负责看取舍、检查结果和继续追问。代码风格、提交记录、测试范围和文档质量,都在这个过程中一起形成。
项目目前发布到了 v1.0.1。它没有 OCR、DOCX、在线事实核验和桌面界面,评分也只能作为辅助信息。后面如果继续做,我会先看实际使用中最常出现的 PDF 和 JD 问题,再决定下一步,而不会为了让功能列表更长就把边界重新打开。