Resume-Matcher 自定义求职信与外联提示词:从 fork 源码到 Settings 可视化配置的设计与实践
【免费下载链接】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
本文基于 Resume-Matcher 仓库中的设计文档 2026-04-17-custom-feature-prompts-design.md 展开,系统讲解该功能(Issue #749)从问题定位、方案设计、后端实现到前端落地的完整链路。读完本文,你将掌握 Resume-Matcher 中"求职信(cover letter)与冷外联(cold outreach)提示词"的自定义机制:如何新增两个可选配置字段、如何在保存时校验三个必需占位符、如何在运行时做防御性回退,以及如何在 Settings 页面提供可视化编辑与一键恢复默认。文中的每一处实现结论都可以在对应源码与集成测试中直接验证。
一、问题背景:为什么"改一行常量"不是解决方案
在设计文档中,问题的起点非常具体:COVER_LETTER_PROMPT和OUTREACH_MESSAGE_PROMPT是 templates.py 中的模块级常量,分别定义了求职信与外联消息的默认生成指令。用户希望按自己的需求定制长度、语气和内容——例如"150 words"、"include company research"、"bold specific keywords"。但在引入自定义功能之前,唯一的途径是fork 整个仓库修改常量,这既无法跟随上游更新,也无法在单实例部署中为不同用户提供差异配置。
有趣的是,简历优化(resume-generation)提示词已经支持定制:用户可以在 Settings 的 "Prompt Profile" 区块从三个内置变体(nudge/keywords/full)中选择,对应 IMPROVE_RESUME_PROMPTS。但这个"固定菜单"模式并不适合求职信与外联场景——用户需要的是自由文本覆盖(free-form text override),而不是在预设变体里选一个。这正是本设计文档与既有Prompt Profile模式的关键差异点。
二、设计目标与非目标
设计文档明确划定了功能边界:
目标:
- 用户可以对求职信和冷外联分别覆盖默认提示词文本(per-feature);
- 空值 / 缺失值 = 使用默认提示词(行为与之前完全一致,向后兼容);
- 自定义提示词必须注入与默认值相同的占位符
{job_description}、{resume_data}、{output_language},缺失占位符在保存时直接返回 422; - UI 将默认提示词作为 textarea 的 placeholder 展示,并提供 "Reset to default" 一键还原按钮;
- 简历优化提示词(已有变体机制)不在此次范围内,被明确延迟。
非目标:
- 不支持按简历粒度的覆盖(自定义提示词是全局的);
- 不覆盖 enrichment、refinement、JD 匹配、简历标题生成的提示词;
- 不做提示词版本管理或历史记录;
- 不引入占位符替换之外的任何模板机制。
这个边界划分保证了改动是"纯增量"(pure additive),下文第 11 节会看到这正是回滚策略的前提。
三、配置存储设计:config.json 中的两个可选字段
设计文档规定在config.json顶层新增两个可选字符串字段:
{ "cover_letter_prompt": "", "outreach_message_prompt": "" }语义约定:
- 空字符串 = "使用默认值";
- 键不存在 = 与空字符串等价。
一个容易被忽略但非常关键的设计决策是:Settings类(pydantic-settings 环境配置)刻意不被扩展。设计文档的理由是——这些覆盖项属于"每部署的用户数据",而不是环境配置,因此 config.py 保持原样,运行期通过已有的load_config_file()在调用点加载。这一取舍避免了把用户运行时数据混入环境变量配置体系,也让回滚(删除两个键即可)变得零成本。
从当前源码看,config.py 中load_config_file()/save_config_file()两个函数已经存在:前者读取 config.json 并注入解密后的 API keys,后者在写盘前剥离 secrets。设计文档中"把llm.py的私有_load_stored_config提升为公共函数"的计划,最终落地为直接复用 config.py 中已有的load_config_file,服务层与路由层统一从app.config导入,避免了重复实现。
四、占位符校验:把运行时 KeyError 提前为保存时的 422
自定义提示词最终会被服务层用str.format()渲染,而format()对缺失占位符会抛KeyError。设计文档的思路是:在保存时就校验,让用户立刻看到清晰的 422 错误,而不是等到生成时 500。
校验逻辑实现在 prompts/init.py:
REQUIRED_FEATURE_PROMPT_PLACEHOLDERS: tuple[str, ...] = ( "{job_description}", "{resume_data}", "{output_language}", ) def validate_prompt_placeholders(prompt: str) -> list[str]: """Return required placeholders missing from ``prompt``. Empty or whitespace-only prompts are treated as "use default" and return an empty list (valid — the router treats them as clearing the override). Non-empty prompts must include every entry from ``REQUIRED_FEATURE_PROMPT_PLACEHOLDERS``. """ if not prompt or not prompt.strip(): return [] return [p for p in REQUIRED_FEATURE_PROMPT_PLACEHOLDERS if p not in prompt]要点:
- 空字符串返回
[](合法)——因为空字符串是"使用默认"的哨兵值,路由层只对非空字符串执行校验; - 返回值是缺失占位符列表——空列表即合法;非空列表会被路由层转成 422,并附带
missing字段列出具体缺失项; - 三个占位符与 cover_letter.py 中
format()的调用参数一一对应:job_description=job_description、resume_data=json.dumps(resume_data)、output_language=output_language。
从注释可见,{resume_data}会被注入为简历 JSON 的字符串形式,{output_language}则是经get_language_name()转换后的完整语言名(如 "English"、"Chinese (Simplified)"),映射定义在 templates.py。
五、后端服务集成:运行时解析与防御性回退
服务层是自定义提示词的"消费者"。设计文档给出的核心思路是一个共享解析 helper,当前实现为 cover_letter.py 中的_resolve_feature_prompt:
def _resolve_feature_prompt( custom_key: str, default_template: str, ) -> tuple[str, bool]: """Resolve a feature-prompt template at runtime. Returns ``(template, is_custom)``. If the stored custom prompt is empty or absent, returns the default template. The ``is_custom`` flag lets callers decide whether to fall back to the default on a format failure (defensive — save-time validation should have caught a malformed custom prompt). """ stored = load_config_file() custom = (stored.get(custom_key) or "").strip() if not custom: return default_template, False return custom, Truegenerate_cover_letter与generate_outreach_message两个函数都采用同样的模式:
template, is_custom = _resolve_feature_prompt( "cover_letter_prompt", COVER_LETTER_PROMPT ) try: prompt = template.format( job_description=job_description, resume_data=json.dumps(resume_data), output_language=output_language, ) except (KeyError, IndexError, ValueError) as e: if not is_custom: raise logging.warning( "Custom cover letter prompt failed to format (%s); falling back to default", e, ) prompt = COVER_LETTER_PROMPT.format(...)这里有两层防护值得注意:
is_custom标志位:只有当当前模板确实是用户自定义时才回退;如果失败的是内置默认模板,说明上游逻辑有 bug,直接 re-raise 让调用方暴露问题;- 异常集合覆盖了
format()的所有失败模式:KeyError(未知占位符)、IndexError(越界的位置参数)、ValueError(未闭合的花括号如{foo)——这一扩展比设计文档最初版本更完备。
generate_outreach_message读取outreach_message_prompt键、默认模板为OUTREACH_MESSAGE_PROMPT,结构完全一致。两个函数的 LLM 调用参数也值得对比:求职信使用system_prompt="You are a professional career coach and resume writer..."且max_tokens=2048,外联消息则使用 "professional networking coach" 且max_tokens=1024(cover_letter.py)。
六、API 端点设计:GET/PUT /api/v1/config/feature-prompts
路由层在 routers/config.py 中新增了两个端点,与已有的/config/prompts(简历优化变体选择)平级但按功能域隔离:
GET /api/v1/config/feature-prompts → { cover_letter_prompt, outreach_message_prompt, cover_letter_default, outreach_message_default } PUT /api/v1/config/feature-prompts body: { cover_letter_prompt?, outreach_message_prompt? } → same schema as GETGET 端的_default字段设计是文档反复强调的一个巧妙点:cover_letter_default/outreach_message_default直接返回内置默认提示词全文(来自 templates.py 的COVER_LETTER_PROMPT/OUTREACH_MESSAGE_PROMPT),前端拿到后作为 textarea 的 placeholder 展示,无需在多语言环境中重复维护这份长文本。
PUT 端的校验逻辑(以 cover letter 为例):
if request.cover_letter_prompt is not None: prompt = request.cover_letter_prompt.strip() if prompt: missing = validate_prompt_placeholders(prompt) if missing: raise HTTPException( status_code=422, detail={ "code": "missing_placeholders", "field": "cover_letter_prompt", "missing": missing, }, ) stored["cover_letter_prompt"] = promptoutreach 分支结构相同。所有变更通过_save_config(stored)落盘,该 helper 内部调用save_config_file()并触发invalidate_config_cache()使共享配置缓存失效(config.py 路由),确保后续请求读到新值。
七、Schema 模型与请求语义:None 与 "" 的严格区分
两个 Pydantic 模型定义在 schemas/models.py:
class FeaturePromptsRequest(BaseModel): """Request to update custom feature prompts. ``None`` means "don't change this field". An empty string clears the override — the server persists ``""`` so runtime resolution falls back to the built-in default without the key disappearing from config.json. """ cover_letter_prompt: str | None = None outreach_message_prompt: str | None = None class FeaturePromptsResponse(BaseModel): """Response for custom feature prompts. The ``*_default`` fields expose the built-in prompt strings so the UI can render them as placeholder text without duplicating the content across locales. """ cover_letter_prompt: str outreach_message_prompt: str cover_letter_default: str outreach_message_default: str请求模型的str | None = None语义是整条链路正确性的基石:
- 字段缺省 /
null→ "本次不改动该字段",对应if request.cover_letter_prompt is not None的守卫; - 空字符串→ "清除覆盖",服务端持久化
""后运行期自动回退默认; - 响应模型不含可空字段,保证前端拿到的是规范化的字符串。
八、前端:API 客户端、Settings UI 与 i18n 陷阱
8.1 API 客户端
lib/api/config.ts 中定义了完整类型与请求函数:
export interface FeaturePrompts { cover_letter_prompt: string; outreach_message_prompt: string; cover_letter_default: string; outreach_message_default: string; } export interface FeaturePromptsUpdate { cover_letter_prompt?: string; outreach_message_prompt?: string; } export interface FeaturePromptsValidationError { code: 'missing_placeholders'; field: 'cover_letter_prompt' | 'outreach_message_prompt'; missing: string[]; } export class FeaturePromptsError extends Error { detail: FeaturePromptsValidationError; ... }updateFeaturePrompts对 422 做了专门处理:解析响应体中的结构化detail,当code === 'missing_placeholders'时抛出携带detail的FeaturePromptsError,让 Settings 页面能把缺失的占位符逐项展示给用户。同时它对非 422 错误做了健壮处理——FastAPI 的detail可能是字符串也可能是对象,代码显式区分序列化,避免出现[object Object]。
8.2 Settings 界面
在 "Content Generation" 区块下,每个功能(求职信 / 外联)的 prompt textarea只在对应功能 toggle 开启时渲染(与既有 UX 一致)。每个功能包含:
<label>"Custom prompt (optional)";rows={8}的等宽字体 textarea,placeholder 显示默认提示词全文;- 帮助文案:必须包含三个占位符,留空使用默认;
- "Reset to default" 按钮——同时清空 textarea 并以空字符串调用 PUT;
- 保存失败时的行内 422 错误提示,列出缺失的占位符。
8.3 i18n 多语言与 next-intl 的 ICU 陷阱
新增文案需要落到全部 5 个语言文件(en / es / ja / zh / pt-BR)。设计文档在此处记录了一个非常实战的坑:next-intl 使用{name}作为变量占位符语法,而帮助文案中的{job_description}、{resume_data}、{output_language}恰好与 ICU 语法重叠,直接写入会被 next-intl 当作变量引用导致渲染失败。给出的三种解法(ICU 转义'{...}'/values参数模板化 / 改写文案去掉花括号)中,文档明确选择了方案 (c)——把帮助文案改写为不带字面花括号的表述,例如:
Must include three placeholders: job_description, resume_data, output_language (each in curly braces). Leave blank to use default.
这个细节对所有基于 next-intl 的多语言项目都有直接借鉴价值。
九、数据流全景
设计文档给出了端到端数据流,与当前实现完全吻合:
User opens Settings → enables Cover Letter toggle ↓ Settings renders textarea with default prompt as placeholder ↓ User pastes custom prompt, clicks Save ↓ PUT /api/v1/config/feature-prompts { cover_letter_prompt: "..." } ↓ Router validates placeholders → 422 on missing OR 200 + persist ↓ stored.cover_letter_prompt = "<user text>" OR "" (on clear) ↓ Later: user runs Tailor → generates cover letter ↓ generate_cover_letter() reads stored config → uses custom or default ↓ .format() substitutes placeholders → LLM call → returns text十、错误处理矩阵
设计文档用表格完整枚举了失败场景与预期行为,这是理解系统边界的最佳入口:
| 失败场景 | 行为 |
|---|---|
| 保存空提示词 | 视为"清除为默认",返回 200 OK |
| 提示词缺失必需占位符 | 422,code=missing_placeholders,列出缺失项;无状态变更 |
| 提示词含额外未知占位符 | 通过保存校验。运行时format()对单花括号按字面量处理(无害),但对{foo}风格会抛错;服务层防御性 try/except 回退默认并记日志 |
| 存储的提示词被磁盘编辑损坏 | 同样的防御性回退,落到默认提示词 |
最后两行对应的是_resolve_feature_prompt+ try/except 组合对"绕过 API 直接改 config.json"这类带外变更的兜底能力。
十一、验证与测试:从设计到集成测试
设计文档列出了 6 步手工验证清单:空提示词走默认、三段式自定义提示词(如 "Write in Shakespearean English, 200 words")生效、缺失{resume_data}时 422 且 UI 提示、Reset 还原默认、外联消息同流程复验。
这些场景已被自动化集成测试覆盖。在 test_config_api.py 的TestFeaturePrompts类中:
test_get_feature_prompts:验证 GET 返回存储值与默认值,且三个占位符都出现在两个*_default字段中;test_put_feature_prompts_rejects_missing_placeholders:提交"Use {job_description} only",断言 422 且detail精确为{"code": "missing_placeholders", "field": "cover_letter_prompt", "missing": ["{resume_data}", "{output_language}"]};test_put_feature_prompts_strips_and_clears_values:验证首尾空白被 strip(含多行提示词保留内部换行),纯空白输入被规范化为""清除。
十二、回滚策略
因为整个功能是纯增量的,回滚同样干净:
- 移除 UI textareas,用户只看到功能 toggle(恢复原状);
- 移除端点后,下一次前端调用会 404——因此需要前后端同步回滚;
- config.json 中已存储的
cover_letter_prompt/outreach_message_prompt字段成为惰性数据——被回滚后的服务代码忽略,不会报错; - 数据不删除:重新启用功能即可恢复已保存的自定义提示词。
十三、风险与边界
设计文档坦诚列出了三个风险:
_load_stored_config移动破坏既有导入:实现中已确认llm.py的私有 helper 被移除并统一改用app.config.load_config_file,服务层与路由层从同一来源导入,规避了重复实现与漂移;- 超长自定义提示词推高 token 消耗:本次不强制 token 上限,由 LLM 侧的
max_tokens(求职信 2048 / 外联 1024)与提供商限制兜底; format()将{视为特殊字符:用户若想在提示词中写字面花括号(例如在提示词里演示 JSON schema),必须使用{{/}}转义,helper 的文档注释已写明。
结语
Issue #749 的设计文档展示了一个教科书式的"小功能大设计":用两个可选 JSON 字段承载用户数据、用保存时校验换取干净的运行期、用is_custom标志位实现防御性回退、用_default字段避免多语言环境重复维护默认文案,最后用严格的 None/"" 语义区分"不改动"与"清除"。对于想要深入理解该实现或在此基础上扩展更多自定义提示词功能的开发者,建议按以下路径阅读源码:设计文档 → 校验 helper(prompts/init.py)→ 服务解析(services/cover_letter.py)→ 路由端点(routers/config.py)→ 前端客户端(lib/api/config.ts)→ 集成测试(test_config_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),仅供参考