Resume Matcher API 请求/响应流程全解析:从简历上传、AI 润色到求职追踪的端点调用链
【免费下载链接】Resume-MatcherThe #1 AI Harness for Building Resumes, PDFs, Cover Letters & more, locally with 100+ LLMs support.项目地址: https://gitcode.com/GitHub_Trending/re/Resume-Matcher
本文以 docs/agent/apis/api-flow-maps.md 为骨架,逐条拆解 Resume Matcher 后端全部核心 API 端点的请求/响应流向,并结合 apps/backend/app/routers 下各路由的真实实现与 apps/backend/app/services 中的服务层源码,讲清楚每个端点"进来什么、内部经过哪几步、最终返回什么"。读完你将掌握:简历上传与 LLM 结构化解析的状态机、AI 润色的 preview/confirm 两段式流程、面试准备与 PDF 生成的服务端调用链、健康检查与系统状态的设计哲学、加密 API Key 存储机制,以及求职追踪(Application Tracker)的列分组与自动建卡逻辑——可直接用于二次开发、联调排错与 Agent 自动化接入。
一、概览:所有路由如何挂载
在深入单个端点前,先看清路由的整体挂载方式。入口文件 apps/backend/app/main.py 将所有路由统一挂在/api/v1前缀下:
health_router→/api/v1/health、/api/v1/statusconfig_router→/api/v1/config/*resumes_router→/api/v1/resumes/*jobs_router→/api/v1/jobs/*enrichment_router→/api/v1/enrichment/*applications_router→/api/v1/applicationsresume_wizard_router→/api/v1/resume-wizard/*
因此下文所有端点路径都以/api/v1开头。应用启动时(lifespan)还会自动执行两件幂等操作:把旧 TinyDB 数据迁移进 SQLite,以及将旧版明文 API Key 折叠进加密存储并从config.json中剥离(见 main.py),这对理解下文"API Keys 不再写入 config.json"的设计有直接关联。
二、Resume Upload:上传 → 解析 → 结构化三步流水线
原文档给出的流程为:
POST /api/v1/resumes/upload ├── Validate file (PDF/DOCX, ≤4MB) ├── parse_document() → Markdown ├── db.create_resume(status="processing") ├── parse_resume_to_json() → LLM │ ├── Success: status="ready" │ └── Failure: status="failed" └── Return {resume_id}对照实现 apps/backend/app/routers/resumes.py#L630-L713,可将每一步细化如下:
1. 文件校验(同步、无 LLM)
- 白名单 MIME 类型:
application/pdf、application/msword、application/vnd.openxmlformats-officedocument.wordprocessingml.document(即 PDF、DOC、DOCX)。不在白名单内直接返回400。 - 大小上限
MAX_FILE_SIZE = 4 * 1024 * 1024(4MB),超限返回413;空文件返回400。
2.parse_document()→ Markdown由 apps/backend/app/services/parser.py#L119-L141 实现:将上传字节写入临时文件,交给markitdown库转成 Markdown 文本。如果转换失败或提取出的文本为空(例如扫描件/纯图片 PDF),返回422并提示用户上传含可选文本的文件。
3. 落库为 "processing" 状态调用db.create_resume_atomic_master(),以content_type="md"、processing_status="processing"先写入记录,并把original_markdown永久保留——即使之后 builder 保存用 JSON 覆盖content,原始 Markdown 仍可供日期恢复等后处理使用。这是"先落库、后解析"的关键设计,保证解析失败时用户仍能看到上传记录。
4.parse_resume_to_json()→ LLM 结构化见 parser.py#L144-L176:用PARSE_RESUME_PROMPT拼出提示词,调用complete_json()(带 3 次重试),随后做两项后处理:
restore_dates_from_markdown():LLM 常把 "Jun 2020 - Aug 2021" 压缩成 "2020 - 2021",此函数从原始 Markdown 提取含月份的日期区间并按年份回填;- 用
ResumeDataPydantic 模型校验,保证产出符合统一 schema。
5. 状态收敛与返回
- 成功:
db.update_resume(..., processing_status="ready"),返回resume_id与processing_status="ready"; - 失败:状态置为
"failed",但上传记录仍然保留,返回resume_id与processing_status="failed",并附带is_master标记(第一份上传的简历会成为 master resume)。
值得补充的是 resumes.py#L1674-L1724 的POST /resumes/{id}/retry-processing:当状态卡在failed或processing时,可对已存 Markdown 重跑parse_resume_to_json(),无需重新上传。
三、Resume Improvement:preview/confirm 两段式 AI 润色
原文档给出的流程:
POST /api/v1/resumes/improve ├── Fetch resume + job from DB ├── extract_job_keywords() → LLM ├── improve_resume() → LLM ├── [If enabled] generate_cover_letter() → LLM ├── [If enabled] generate_outreach_message() → LLM ├── [If enabled] generate_interview_prep() → LLM ├── db.create_resume(improved) ├── db.create_improvement() └── Return {data, cover_letter, outreach_message, interview_prep}当前实现已将旧版单端点拆分为preview(预览不落库)+ confirm(确认后持久化)两段式,同时保留旧版POST /improve作为兼容路径。三者共享同一套内部管线,理解_improve_preview_flow(resumes.py#L848-L1112)即可掌握核心:
1. 加载简历与 JD,读取特征开关从请求中取resume_id、job_id;_load_config()读取三个布尔开关enable_cover_letter/enable_outreach_message/enable_interview_prep,以及get_content_language()决定输出语言。
2. 关键词提取(带内容哈希缓存)extract_job_keywords()对 JD 做一次 LLM 调用。结果按job_keywords_hash = sha256(job.content)缓存在 job 记录上:哈希一致则直接复用,避免重复付费调用。同时把 LLM 返回的company/role提升到 job 顶层字段(带类型守卫后再.strip()),供后续 tracker 自动建卡零额外调用读取。
3. 差异化润色(diff-based improvement)若原始简历有结构化数据(original_resume_data),走 diff 管线:
generate_skill_target_plan()+verify_skill_target_plan():先让 LLM 产出技能改写目标,再用规则校验哪些可接受(不支持的技能目标会被拒绝并计入 warnings);generate_resume_diffs()→apply_diffs():LLM 只产出"变更集",由代码应用;被拒的变更计入 warnings;verify_diff_result():应用后逐条核对,保证没有越界改写。 若无结构化数据,则回退到improve_resume()全量输出模式。
4. 四层安全网(defense in depth)
_preserve_personal_info():个人联系方式永远以原稿为准,改动会被拒绝(见 resumes.py#L435-L459);_restore_original_dates()+restore_dates_from_markdown():LLM 截断的月份日期被还原(resumes.py#L234-L308);_preserve_original_skills():技术技能/证书/语言/奖项中任何被 LLM 丢弃的条目都会被追回(resumes.py#L311-L362);_protect_custom_sections():自定义 section 的条目数不增不减,虚构的 description 被回滚(resumes.py#L365-L432)。
5. 多轮精炼(refinement)拿到 master resume 数据后调用refine_resume()做多轮处理:注入可注入关键词、移除 AI 腔短语、校验与原稿的对齐一致性,并产出RefinementStats(passes 数、注入关键词数、移除 AI 短语数、修复的 critical 对齐违规数、初终匹配率)。
6. 差异摘要 + ATS 评分 + 辅助内容
_calculate_diff_from_resume()生成diff_summary与detailed_changes(原结构数据缺失时给出original_data_missing之类的错误原因);_build_ats_score()计算 ATS 总分与子分、缺失关键词、可注入关键词、建议;_generate_auxiliary_messages()(resumes.py#L556-L617)用asyncio.gather并行生成标题(始终开启)以及按开关启用的 cover letter、outreach、interview prep;单个失败只记 warning,不阻断主流程。
7. preview 与 confirm 的分野
POST /improve/preview(resumes.py#L796-L845):不落库,resume_id返回null;但会把preview_hash写回 job 记录,且整体包在asyncio.wait_for里受settings.request_timeout_seconds超时保护,超时返回504并提示调整REQUEST_TIMEOUT_SECONDS/NEXT_PUBLIC_REQUEST_TIMEOUT_MS。POST /improve/confirm(resumes.py#L1115-L1260):先校验personalInfo未被改动,再用_hash_improved_data()(经ResumeData规范化后做 SHA-256,见 resumes.py#L154-L177)与 job 上缓存的 preview hash 比对,防篡改;通过后db.create_resume(content_type="json", parent_id=原简历)、db.create_improvement(),并触发 tracker 自动建卡(见第七节)。随后并行生成 cover letter / outreach / interview prep 并随响应返回。- 旧版
POST /improve(resumes.py#L1263-L1522)行为与 confirm 一致,只是不做 preview-hash 校验,属于兼容入口。
四、Interview Prep Generation:按需生成面试准备
原文档流程:
POST /api/v1/resumes/{id}/generate-interview-prep ├── Require tailored resume (parent_id) ├── Fetch improvement record and associated job description ├── Require processed resume data ├── generate_interview_prep() → LLM JSON ├── Validate InterviewPrepData ├── Save resumes.interview_prep as serialized JSON TEXT └── Return {interview_prep, message}实现位于 resumes.py#L1908-L1972:
前置校验链:① 简历必须存在(404);② 必须是润色后的简历(有parent_id,否则 400 "Interview preparation can only be generated for tailored resumes");③ 通过db.get_improvement_by_tailored_resume()找到关联 JD(improvements 表是"润色简历 → JD"的桥);④processed_data必须存在。
LLM 调用(apps/backend/app/services/interview_prep.py#L101-L128):用INTERVIEW_PREP_PROMPT拼装提示词,并对输入做了显式截断保护——JD 超 12,000 字符、简历 JSON 超 30,000 字符时逐级降采样(字符串限长/列表限条数),并附_prompt_truncation_notice提示 LLM"只依据可见证据、不得臆造"。max_tokens用get_safe_max_tokens(model, requested=8192)求安全上限,结果经InterviewPrepData.model_validate()强校验。
存储方式:存入resumes.interview_prep字段的是序列化 JSON 文本(_serialize_interview_prep(),json.dumps(model_dump(...)))。读取时由_parse_interview_prep()(resumes.py#L135-L151)反向反序列化并再次校验,解析失败只记日志返回None,不会 500。这一点是联调时最容易踩的坑:该字段是 TEXT 而非嵌套 JSON 列。
五、PDF Generation:Playwright 无头渲染
原文档流程:
GET /api/v1/resumes/{id}/pdf ├── Fetch resume from DB ├── Build URL: {frontend}/print/resumes/{id}?{params} ├── Playwright render (wait for .resume-print) └── Return PDF bytes端点签名位于 resumes.py#L1582-L1662,支持大量模板与排版参数(均带Query校验):
| 参数 | 默认值 | 取值范围/说明 |
|---|---|---|
template | swiss-single | swiss-single / swiss-two-column / modern / modern-two-column / latex / clean / vivid |
pageSize | A4 | A4/LETTER |
marginTop/Bottom/Left/Right | 10 | 页边距(mm),5-25 |
sectionSpacing/itemSpacing | 3/2 | 区块/条目间距,1-5 |
lineHeight/fontSize/headerScale | 3 | 行高/字号/页眉缩放,1-5 |
headerFont/bodyFont | serif/sans-serif | serif/sans-serif/mono |
compactMode | false | 紧凑模式 |
showContactIcons | false | 联系方式图标 |
accentColor | blue | blue/green/orange/red |
lang | 无 | 形如en、zh-CN的 locale |
实现要点:拼接{settings.frontend_base_url}/print/resumes/{resume_id}?{params}后交给 apps/backend/app/pdf.py#L279-L337 的render_resume_pdf():
- 渲染核心(pdf.py#L138-L163)有意识地不使用
networkidle(Next.js dev server 的 HMR/RSC 流式响应会让 idle 永不到来而挂死),而是按确定性条件等待:document "load"→ 目标选择器.resume-print出现 →document.fonts.ready,全程受_NAV_TIMEOUT_MS = 60_000显式超时约束; - 浏览器启动策略:优先复用常驻实例(带
asyncio.Lock防并发初始化竞态);无内置 Playwright 浏览器时回退到系统 Chrome/Chromium/Edge(跨 Windows/macOS/Linux 探测路径);Windows 下若事件循环不支持子进程,则退化为线程内新事件循环渲染; - 错误语义化:缺浏览器可执行文件 → 提示安装 Playwright chromium;
net::ERR_CONNECTION_REFUSED→ 提示检查FRONTEND_BASE_URL环境变量与前端是否在运行(这正是联调 PDF 时最常见的两类失败)。
封面信 PDF 走同一管线:GET /resumes/{id}/cover-letter/pdf(resumes.py#L2017-L2055),选择器换为.cover-letter-print,渲染失败统一转 503。
六、Health Check 与 System Status:健康探针的隔离哲学
原文档将两个端点并列,语义区分非常明确:
GET /api/v1/health └── Return {status: "healthy"} # pure liveness — does NOT call the LLM GET /api/v1/status # each check isolated → 200 (partial/degraded), never 500 ├── try: get_llm_config() │ ├── llm_configured = api_key set OR provider ∈ {ollama, openai_compatible} │ └── check_llm_health() → llm_healthy # failure here degrades only this field ├── try: db.get_stats() # failure → empty stats, still 200 └── Return {status, llm_configured, llm_healthy, has_master_resume, database_stats}源码位于 apps/backend/app/routers/health.py:
GET /health(health.py#L25-L31):纯存活探针,用于 Docker HEALTHCHECK,永不调用 LLM,固定返回{"status": "healthy"},不会因外部服务抖动而误报容器不健康。GET /status(health.py#L34-L68):深度状态页,核心设计是"逐项隔离,任何单项失败只降级该字段,整体恒为 200":- LLM 配置判定:
llm_configured = bool(api_key) or provider in ("ollama", "openai_compatible")—— 注意本地 Ollama 与 OpenAI 兼容端点无需 Key 也算已配置; check_llm_health()失败只把llm_healthy置 false;db.get_stats()失败时回落到_EMPTY_DB_STATS空统计(total_resumes/jobs/improvements 全 0、has_master_resume=False),同样不影响 200;- 顶层
status由llm_healthy and has_master_resume收敛为"ready"或"setup_required"。
- LLM 配置判定:
这套设计让前端状态面板永远能渲染"部分降级/待配置"视图,而不是整页报错,是 Agent 做环境自检时的首选端点。
七、Configuration 与加密 API Keys:密钥从 config.json 迁出的安全演进
原文档对配置更新与 API Keys 分了两组端点,源码在 apps/backend/app/routers/config.py。
7.1 非密钥配置更新
PUT /api/v1/config/llm-api-key ├── _load_config() ├── Merge new NON-SECRET values (provider/model/base/...) ├── (no longer persists any key — keys go through /config/api-keys) ├── _save_config() └── Return masked config实现见 config.py#L111-L174。要点:
- 只更新请求中显式出现的字段:
provider、model、api_base、reasoning_effort; api_key字段在该端点被有意忽略、不再持久化——注释明确说明:过去把单 Key 写进api_key槽位正是各 provider 互相覆盖、并遮蔽resolve_api_key()中 per-provider 映射的根因(issue #760 同类问题);api_base做了"空值即显式清除"的语义区分:请求中出现api_base: null/""会清理旧值,并归一化为None,避免空字符串被当成伪端点传给 LiteLLM;- 保存永远成功(不因健康检查失败而硬失败,用户配置代理/聚合器或临时不可达端点时仍可落盘),随后在
BackgroundTasks里做 best-effort 健康检查仅用于服务端日志,连通性验证请走POST /config/llm-test。
7.2 每 Provider 加密 Key 存储
GET /api/v1/config/api-keys POST /api/v1/config/api-keys DELETE /api/v1/config/api-keys/{provider} DELETE /api/v1/config/api-keys?confirm=...支持 provider 集合(config.py#L435-L444 常量):openai、anthropic、google、openrouter、deepseek、groq、openai_compatible、ollama。行为细节:
GET返回每个 provider 的{provider, configured, masked_key},掩码策略是保留最后 4 位(如...abcd),任何场景都不回显明文;POST支持一次更新多个 provider,只动请求里出现的 provider,其余保持不动;空字符串等于清除该 provider 的 Key;写入时先 Fernet 加密再 upsert 进 SQLiteapi_keys表;- 两个 DELETE 是破坏性操作:单个 provider 删除直接执行;清空全部需要 query 参数
confirm=CLEAR_ALL_KEYS,否则 400。POST /config/reset同理需要confirm=RESET_ALL_DATA才会清库; - 应用启动时
migrate_legacy_keys()(main.py)会把 config.json 里的旧明文 Key 幂等迁移进加密存储并剥离明文。
7.3 功能开关、语言、提示词配置
GET/PUT /config/features:管理enable_cover_letter/enable_outreach_message/enable_interview_prep三个布尔开关,默认全关(config.py#L220-L252);GET/PUT /config/language:ui_language与content_language分离,支持集合为["en", "es", "zh", "ja", "pt", "fr"],并兼容旧版单一language字段迁移(config.py#L256-L309);GET/PUT /config/prompts:管理润色默认提示词default_prompt_id(非法值回落内置默认,config.py#L312-L357);GET/PUT /config/feature-prompts:自定义 cover letter / outreach 提示词,非空时校验必须包含{job_description}、{resume_data}、{output_language}三个占位符,缺失返回 422 且 detail 里列出具体缺失项;空串表示清除覆盖、回退内置默认(config.py#L360-L429)。
八、Job Upload:批量 JD 入库
原文档流程极简,实现位于 apps/backend/app/routers/jobs.py#L11-L39:
POST /api/v1/jobs/upload ├── For each description: │ └── db.create_job() └── Return {job_id[]}细节:请求体{job_descriptions: [...], resume_id},空列表 400;逐条校验非空后db.create_job(content=jd, resume_id=...);返回job_id数组(与输入顺序一一对应)。GET /jobs/{job_id}用于按 ID 取回 JD 原文(404 兜底)。JD 只在润色时才按需做关键词提取并缓存(见第三节),上传本身不触发任何 LLM 调用。
九、Resume Operations:CRUD 速查
原文档用表格概括了四个端点,实现位置与补充语义如下:
| 端点 | 实现位置 | 关键行为 |
|---|---|---|
GET /resumes?id= | resumes.py#L716-L767 | 返回raw_resume+processed_resume(经normalize_resume_data()惰性迁移旧记录的 section 元数据),同时带出cover_letter、outreach_message、interview_prep(反序列化)、parent_id、title |
GET /resumes/list?include_master= | resumes.py#L770-L793 | 默认排除 master resume;按updated_at倒序;返回摘要列表(含is_master、parent_id、processing_status) |
PATCH /resumes/{id} | resumes.py#L1525-L1579 | 请求体为完整ResumeData,落库时把content同步为 JSON 文本、content_type="json"、processing_status="ready" |
DELETE /resumes/{id} | resumes.py#L1665-L1671 | 删除不存在时 404 |
其余衍生端点同属此模块:PATCH /resumes/{id}/cover-letter、/outreach-message、/title(标题截断 80 字符);POST /resumes/{id}/generate-cover-letter与/generate-outreach(均要求parent_id且能从 improvements 表找到 JD);GET /resumes/{id}/job-description(回取润色所用 JD)。
十、Application Tracker:七列看板与自动建卡
原文档用一段树状图 + 两张表格描述了求职追踪的全部端点,核心数据模型见 apps/backend/app/schemas/applications.py#L9-L22:
class ApplicationStatus(str, Enum): saved = "saved" applied = "applied" no_response = "no_response" response = "response" interview = "interview" accepted = "accepted" rejected = "rejected" APPLICATION_STATUS_ORDER = [s.value for s in ApplicationStatus]七个状态键即七列看板,顺序由APPLICATION_STATUS_ORDER固定,且与 i18n 文案解耦(只存 enum 值,文案由前端映射)。
10.1 列表与详情
GET /applications:db.list_applications()后由_group_by_status()(applications.py#L27-L41)按七列分组,七列键永远齐全;遇到未知状态的行直接跳过并记日志,而不是让整个看板 500。GET /applications/{id}:一次往返内把卡片与关联job_content、投递所用简历一起返回;简历被删时resume字段为null(弹窗渲染"简历不可用"而非 500)。
10.2 手动添加(贴 JD 建卡)
POST /applications(applications.py#L55-L97):
POST /api/v1/applications # manual add from a pasted JD ├── db.create_job(jd) ├── [If company/role missing] extract_job_keywords() → LLM # one best-effort call ├── db.create_application(status default "applied") # dedupes on (job_id, resume_id) └── Return Application要点:公司/角色缺失时只做一次 best-effort 的extract_job_keywords()(复用关键词提取,不新增提示词路径),失败则回退为空值(可手动编辑),LLM 抖动绝不阻塞建卡;若db.create_application()失败,会顺带清理刚创建的孤儿 job 记录,避免"重试漂移"。db.create_application层对(job_id, resume_id)去重。
10.3 更新、批量操作与删除
| 端点 | 语义 |
|---|---|
PATCH /applications/{id} | 部分更新 status/position/notes/company/role/applied_at;status 由 enum 归一化为稳定字符串;position由服务端重新编号 |
PATCH /applications/bulk | {application_ids, status}批量移动卡片到同一列,返回受影响数 |
DELETE /applications/{id} | 单卡删除 |
POST /applications/bulk-delete | {application_ids}批量删除 |
10.4 自动建卡(Auto-create)
原文档特别标注:POST /resumes/improve/confirm(及旧版POST /resumes/improve)在持久化润色简历后会创建一个applied卡片。实现是 resumes.py#L73-L98 的_auto_create_tracker_application():
- 公司/角色直接复用第三节中已缓存在 job 上的 keyword 提取结果(
job.get("company")、title or job.get("role")),零额外 LLM 调用; - 整个调用包在
try/except里,tracker 失败只记logger.warning,永远不会反过来破坏润色主流程——这是"best-effort 副作用"的典型实现,Agent 接入时应把该卡片视为可重建的派生数据。
十一、调试与联调指引:把流程图变成可执行验证
结合上述源码事实,给出三条可落地的联调路径:
- 全链路自检:先
GET /api/v1/status确认llm_configured/llm_healthy/has_master_resume,再POST /config/llm-test做连通性实测;LLM 未就绪时上传简历会得到processing_status="failed"(不是报错),此时用POST /resumes/{id}/retry-processing重试。 - 润色联调:按
POST /jobs/upload→POST /resumes/improve/preview(拿到preview_hash与 diff 预览)→ 修改确认后POST /resumes/improve/confirm的顺序走;若 confirm 报 400 "Invalid improved resume data. Please retry preview.",多半是前端把非 schema 完整字段发回导致 hash 不一致(服务端已用ResumeData规范化缓解)。 - PDF 排障:
GET /resumes/{id}/pdf返回 503 时,对照 pdf.py 的错误分类检查:前端是否运行、FRONTEND_BASE_URL是否与前端地址一致、Playwright 浏览器是否已python -m playwright install chromium。 - 权限与安全边界:所有配置与重置端点(
/config/reset、/config/api-keys的 DELETE)在源码注释中被明确标注为"本地单用户部署设计,多用户生产环境需自行加认证"——接入前务必评估。
结语
本文以 docs/agent/apis/api-flow-maps.md 的流程图为纲,逐一落到 apps/backend/app/routers 与 apps/backend/app/services 的源码实现上。可以看到:Resume Matcher 的 API 层在设计上高度一致——LLM 调用全部收敛在服务层、可缓存结果全部哈希化复用、副作用(如 tracker 自动建卡)一律 best-effort 隔离、敏感字段(API Key)走加密独立存储并全程掩码返回。这些模式不仅适用于本项目的二次开发,也值得在自建 LLM 工具链时直接借鉴。如需继续深入,可对照 apps/backend/tests/integration 下的集成测试(如test_regenerate_endpoints.py、test_tracker_autocreate.py、test_resume_api.py)逐端点验证本文描述的行为。
【免费下载链接】Resume-MatcherThe #1 AI Harness for Building Resumes, PDFs, Cover Letters & more, locally with 100+ LLMs support.项目地址: https://gitcode.com/GitHub_Trending/re/Resume-Matcher
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考