☰
freehire 中的 OJCP JSON Schema 存证:把规范自身变成可执行的校验预言机
2026/10/23 2:47:01 网站建设 项目流程

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

在 freehire 项目中,internal/api/ojcp负责把职位数据投影成 Open Job Context Protocol(OJCP)v0.1 的标准线格式。本文围绕该目录下 schemas 说明文档,讲清三件事:这些 schema 文件从哪来、为什么必须原样存证、以及为什么"schema 校验通过"并不等于"投影正确"。读完后你应该能独立完成一次 OJCP schema 的重新 vendor,并为新的响应形状写出真正能失败的校验测试。

一、为什么要把 schema 复制进仓库

OJCP 是一个面向 AI Agent 的职位数据读取协议。freehire 把自己目录中的职位投影成该协议的线上形状,供 REST 与 MCP 两种传输共用。这个投影过程本身不读数据库、不建立连接、不知道 Fiber 的存在,是纯函数式的:输入一个已经加载好的领域对象,输出一个结构体。这一设计在 jobposting.go 的包注释中有明确表述。

正因为它"纯",测试才不需要夹具之外的任何东西——只需要一份领域值,以及一份可信的标准产物。仓库的做法不是手写断言,而是把 OJCP 官方发布的 JSON Schema 原样复制到 internal/api/ojcp/testdata/schemas 下,作为所有投影测试的预言机(oracle)。

原文档给出的理由非常直接:

Hand-written Go assertions drift from the spec silently; these cannot, because they are the spec's own artifact.

手写的 Go 断言会悄悄偏离规范,而官方 schema 不会——因为它们就是规范自己的产物。

来源与提交哈希

这批文件是从ojcp-org/ojcp仓库的提交2ed6d6087c152747f4d5560c5405a05e47cd9188(2026-09-23,提交说明为 "allow jobLocation to be an array of Place")逐字复制而来。这个哈希是溯源凭证,必须与文件字节一起维护:一个过期的哈希比没有哈希更糟,因为它声称了一份字节已经不存在的出处。

二、被 vendor 的文件清单

存证范围被刻意收窄:只保留读取面(read surface)需要的内容——三个读取工具的输入与响应、两种响应都携带的职位形状、清单文件,以及错误信封。对照原文档的表格如下:

文件校验对象
manifest.json服务在/.well-known/ojcp.json提供的文档
job-posting.json单个投影后的职位(被responses/job-detail.json引用)
tools/search-jobs-input.jsonAgent 可以向search_jobs发送的内容
tools/get-job-detail-input.jsonAgent 可以向get_job_detail发送的内容
tools/get-employer-context-input.jsonAgent 可以向get_employer_context发送的内容
responses/search-jobs.jsonsearch_jobs响应信封
responses/job-detail.jsonget_job_detail响应
responses/employer-context.jsonget_employer_context响应
responses/error.json两种传输共同渲染的错误信封
candidate-context.json被tools/search-jobs-input.json以$ref引用

输入 schema 与响应 schema 同样重要的原因,原文档写得很清楚:工具处理程序必须读取"标准允许 Agent 发送什么",而"忽略不认识的东西"这条规则,只有对照已识别的字段清单才可检验。

candidate-context.json是个特例。这个读取面是匿名的,不接收任何候选人 PII,读取工具会忽略该字段。它之所以被 vendor,纯粹是因为搜索输入 schema 通过$ref引用了它——没有它,输入 schema 根本编译不过。这一点恰恰是"拒绝式加载器"暴露出来的:如果没有那个加载器,编译过程会去ojcp.dev拉取该文件,而没有人会知道这件事。

三、离线编译:让$ref只能从磁盘解析

这些 schema 是 JSON Schema draft 2020-12,彼此通过绝对的$idURL 互相引用,形如https://ojcp.dev/schemas/v0.1/...。如果按库的默认行为,编译器会真的去访问网络。仓库没有接受这个默认值。

在 schema_oracle_test.go 中,compileVendoredSchemas做了三件关键的事:

c := jsonschema.NewCompiler() // Draft 2020-12 默认把 format 当注解,必须显式开启断言 c.AssertFormat() // 离线保证是显式断言的,而不是继承库的默认加载器 c.UseLoader(refusingLoader{})

随后它遍历存证目录,读取每个文件的$id,先全部注册、再统一编译:

for _, path := range files { raw, err := os.ReadFile(path) ... doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) ... id, err := readSchemaID(doc, path) ... if err := c.AddResource(id, doc); err != nil { ... } ids = append(ids, id) } compiled = make(map[string]*jsonschema.Schema, len(ids)) for _, id := range ids { schema, err := c.Compile(id) ... compiled[id] = schema }

"先注册再编译"不是风格偏好。代码注释说明了原因:边遍历边编译,会在第一个$ref指向遍历顺序中靠后文件时直接失败。

拒绝式加载器

refusingLoader是整个包对"外部世界"的全部视野——它对任何 URL 都返回错误:

type refusingLoader struct{} func (refusingLoader) Load(url string) (any, error) { return nil, fmt.Errorf("refusing to fetch %s: OJCP schemas resolve only from %s", url, vendoredSchemaDir) }

于是,凡是被存证树自身无法满足的$ref,都会变成一个点名缺失$id的编译失败。这正是"重新 vendor 时漏了一个文件"所需要的诊断信息;而如果静默联网拉取,CI 就会隐式依赖ojcp.dev的可用性——某天它不可达时,红灯会落在某个毫不相关的 PR 上,且没有任何线索指向根因。

目录遍历而非手工清单

func vendoredSchemaPaths() ([]string, error) { var paths []string err := filepath.WalkDir(vendoredSchemaDir, func(path string, entry fs.DirEntry, err error) error { ... if !entry.IsDir() && filepath.Ext(path) == ".json" { paths = append(paths, path) } return nil }) return paths, err }

遍历目录而不是维护一份手写文件列表,意味着"新增一个 schema 就自动加入预言机"。手写清单只会再多一个"重新 vendor 只做了一半"的地方。

用$id而不是文件路径寻址

测试中引用的常量不是路径,而是 schema 自己发布的$id:

const ( schemaManifest = "https://ojcp.dev/schemas/v0.1/manifest.json" schemaJobPosting = "https://ojcp.dev/schemas/v0.1/job-posting.json" schemaSearchJobsResponse = "https://ojcp.dev/schemas/v0.1/responses/search-jobs.json" schemaJobDetailResponse = "https://ojcp.dev/schemas/v0.1/responses/job-detail.json" schemaEmployerContextResponse = "https://ojcp.dev/schemas/v0.1/responses/employer-context.json" schemaErrorResponse = "https://ojcp.dev/schemas/v0.1/responses/error.json" )

原因写在注释里:在标准中,一个 schema 由它的$id标识,文件恰好放在哪个路径只是 vendor 的实现细节。同理,readSchemaID把"没有$id"当成错误而非跳过——这种文件会编译成功,却永远无法被任何测试引用,看起来就和"没人想起来要校验的 schema"一模一样。

四、format断言:draft 2020-12 默认不检查

这是最容易被低估的一处细节。Draft 2020-12 把format视为注解,除非校验器主动开启断言。不开启的话,datePosted写成16/09/2026、url写成not a url,都会干净通过。

这正是c.AssertFormat()存在的理由,而 schema_test.go 为它写了会失败的用例:

for _, datePosted := range []string{ "16/09/2026", // 日在前,不是 ISO "2026-09-16T00:00:00Z", // schema 要的是日期,这里给了时间戳 "", // 空 } { ... if err := validateAgainstSchema(t, schemaJobPosting, posting); err == nil { t.Fatalf("datePosted %q was accepted; the oracle does not assert `format`", datePosted) } }

以及:

posting["url"] = "not a url at all" if err := validateAgainstSchema(t, schemaJobPosting, posting); err == nil { t.Fatal("a malformed `url` was accepted") }

校验的是"Agent 收到的字节"

validateAgainstSchema并不直接校验 Go 结构体。它先json.Marshal,再用jsonschema.UnmarshalJSON读回通用 JSON 树:

raw, err := json.Marshal(value) ... decoded, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) ... return schema.Validate(decoded)

代码注释点明了设计意图:投影必须按照"Agent 实际收到的那份字节"来判定,而这正是一次序列化往返所产生的东西。这一点对omitempty语义、指针类型的薪资块、以及 joblocation.go 中Places自定义的MarshalJSON(单地点序列化为对象、多地点序列化为数组)尤其关键。

编译只做一次,通过sync.Once缓存:

var ( compileOnce sync.Once compiled map[string]*jsonschema.Schema compileErr error )

五、预言机证明的东西,比它看起来更窄

这是原文档篇幅最重、也最值得单独成节的部分。不要把一个绿色的 schema 检查读成比它本身更多的东西。文档明确列出两种结构性局限,它们会直接影响你写投影测试的方式。

局限一:job-posting.json没有设置additionalProperties: false

查看 job-posting.json 可以看到,根对象只有required与properties,没有关闭额外属性。后果是:一个带有skills_requred(拼错)或applyPaths(大小写不对)字段的职位,校验是干净的——该字段对 schema 而言根本不可见。更严重的是,一个把skills_required和apply_paths整个丢掉的投影,同样符合 schema,同时也毫无用处。

因此原文档给出的规则是:

presence of the fields we mean to emit needs its own assertion, beside the schema check.

即"我们要发出的字段确实在场"必须有独立断言。仓库正是这么做的,见 jobposting_test.go:

func TestJobPostingFromCarriesTheFieldsWeMeanToEmit(t *testing.T) { // schema 没有 additionalProperties:false,字段被丢弃或拼错都会校验通过 posting := jobPostingFrom(openPosting(), testOrigin) if posting.OJCPID != "senior-go-engineer-at-acme" { ... } if posting.Title != "Senior Go Engineer" { ... } if posting.Employer.Name != "Acme Corp" { ... } // Skills 不在这里断言:它们来自职位自己陈述的要求,而本夹具没有陈述任何要求 }

这段测试的注释还留下了一条重要提示:技能字段不在这个夹具里断言,因为它来自"职位陈述的要求"而非技能 facet,本夹具没有陈述任何要求——对应测试在skills_test.go。这体现了同一原则:每个断言只验证它真正想验证的东西。

局限二:responses/search-jobs.json内联了一个更宽松的职位形状

第二个局限更隐蔽。搜索响应 schema 并没有$ref到job-posting.json,而是内联了一份更宽松的 job summary 形状。打开 responses/search-jobs.json 就能看到,jobs.items是一段独立定义的 object,只要求ojcp_id、title、employer、datePosted,且baseSalary、apply_paths等子结构都是简化版本。

结论是:校验一个search_jobs信封,并不会顺带校验其中的jobs[]元素是不是合法的 JobPosting。每个元素都必须单独对着job-posting.json校验,否则最繁忙的那个接口上的逐条投影根本没被测到。

仓库的处理见 searchresponse_test.go:

func TestEveryJobInASearchPageIsAConformingPosting(t *testing.T) { // 搜索响应 schema 内联了更宽松的职位形状,而不是 $ref job-posting.json // 因此校验信封并不校验信封里的内容,每个元素必须单独校验 resp := SearchJobsResponse{Jobs: searchPage()}.Finalize() for i, job := range resp.Jobs { if err := validateAgainstSchema(t, schemaJobPosting, job); err != nil { t.Fatalf("jobs[%d] is not a conforming JobPosting: %v", i, err) } } }

局限是"需要知道",不是"需要修复"

原文档强硬地指出:通过编辑这些文件去收紧任何一处都是错的——它们是规范的产物,不是你的产物。这是需要了解的边界,不是待修的缺陷。

六、不要编辑,只能重新复制

原文档用加粗写着Do not edit them.。当 OJCP 发布变更时,正确流程是:

  1. 重新复制受影响的那几个文件;
  2. 在同一个 diff中更新文档顶部的提交哈希。

第二条不是形式主义。文档说明了理由:过期的哈希比没有哈希更糟,因为它声称了一份字节已经不再具有的出处。

配合第三节的目录遍历实现,重新 vendor 只需把文件放回 internal/api/ojcp/testdata/schemas 下正确的层级——vendoredSchemaPaths会自动把它纳入预言机。如果漏了某个被$ref引用的文件,refusingLoader会让编译失败并报出缺失的$id,而不是静默联网。

七、先测试预言机本身

原文档没有明说,但从 schema_test.go 的注释可以读出整个测试策略的起点:

The vendored schemas are the oracle for every projection in this package, so the oracle itself is tested first: a loader that silently accepted everything would make every later test green and meaningless.

一个默默接受一切的加载器,会让后续所有测试变绿且毫无意义。因此预言机自己先被测:

  • 接受合法值:只带四个必填字段的最小 JobPosting 必须通过;
  • 拒绝缺必填字段:删掉title必须失败;
  • 拒绝格式错误:三种非法datePosted与非法url必须失败;
  • 离线编译:所有存证 schema 必须能在无网络下编译;
  • 跨文件引用离线可解:responses/job-detail.json通过绝对$id引用job-posting.json,编译它就是证明解析走的是本地副本。

其中"离线"这条还额外验证了加载器确实在拒绝:

compileOnce.Do(compileVendoredSchemas) if compileErr != nil { ... } if _, err := (refusingLoader{}).Load(schemaJobPosting); err == nil { t.Fatal("the loader wired into the compiler does not refuse a URL; offline is not pinned") }

注意这里检查的不是"编译成功"这一件事,而是"被接进编译器的加载器确实会拒绝 URL"。少了这一步,UseLoader哪天被删掉,编译测试仍可能因为网络尚可而变绿。

八、一个声明了却从未使用的 schema 常量,等于关掉的检查

schema_oracle_test.go 声明了schemaErrorResponse。但在一段时间里,这个常量既被声明、又被使用于零处校验——而错误信封当时发出的形状,标准根本没有描述:标准要的是扁平的、来自封闭枚举的error_code,而这个包发的是嵌套的error: {code, message},且代码是自创的。

从内部看,这和一个正常工作的检查一模一样。这与原文档的描述完全一致:声明但未使用的 schema 常量不是死代码,而是一个被关掉的检查。

修复后的错误信封见 response.go,字段是扁平的:

type ErrorResponse struct { OJCPVersion string `json:"ojcp_version"` ErrorCode string `json:"error_code"` Message string `json:"message"` RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` }

而 errorresponse_test.go 同时覆盖了"能通过"与"能失败"两侧:

func TestErrorEnvelopeConformsToTheStandard(t *testing.T) { for _, code := range []string{ ErrorJobNotFound, ErrorEmployerNotFound, ErrorInvalidRequest, ErrorProviderError, } { ... validateAgainstSchema(t, schemaErrorResponse, NewError(code, "something went wrong")) ... } } func TestRateLimitErrorAlwaysSaysHowLongToWait(t *testing.T) { if err := validateAgainstSchema(t, schemaErrorResponse, NewRateLimitError(30)); err != nil { ... } if err := validateAgainstSchema(t, schemaErrorResponse, NewError(ErrorRateLimited, "slow down")); err == nil { t.Fatal("a rate-limit refusal with no retry_after_seconds validated") } } func TestErrorCodeOutsideTheStandardsEnumIsRejected(t *testing.T) { if err := validateAgainstSchema(t, schemaErrorResponse, NewError("not_found", "a code we made up")); err == nil { t.Fatal("an invented error code validated; the enum is not being enforced") } }

第二条测试的价值在于:它验证的是"缺少伴随字段时必须失败"。翻看 responses/error.json 能看到这条约束的来源:

"allOf": [ { "if": { "required": ["error_code"], "properties": { "error_code": { "const": "rate_limited" } } }, "then": { "required": ["retry_after_seconds"] } } ]

这条条件约束只在预言机终于被指向该信封时才浮出水面。一个被告知要慢一点、却没被告知要等多久的 Agent 只能猜,而猜测的 Agent 会重试。

九、从 schema 到投影:几条由文件直接推出的实现约束

存证 schema 不只是测试工具,它直接决定了几处投影代码的写法。以下几条都能在 schema 文件中找到对应证据,可供你在扩展投影时对照。

1. 必填字段与"绝不发空值"

job-posting.json的required是["ojcp_id", "title", "employer", "datePosted"]。同时datePosted是format: date。所以一个空字符串既非法,也会被 Agent 读成"我们声称知道一个日期"。这就是 jobposting.go 中"未陈述的字段一律省略,绝不以空值发出"的由来;datePosted没有回退分支,因为jobview已经用effectivePosted在源头做过回退。

BaseSalary是指针,理由同样来自语义而非类型:一个零值薪资块会让 Agent 读成"这份工作不给钱",而这是职位从未作出的声明。

2. 封闭枚举:翻译不了就省略

remote_policy在 job-posting.json 中是enum: ["on_site", "hybrid", "remote", "flexible"],所以内部的onsite必须翻成on_site,否则整个职位被拒,而不只是该字段。与之相对,experienceLevel只是普通 string,描述里明确允许提供方使用额外值,因此只翻译两个含义完全相同的层级(middle → mid、c_level → executive),其余原样输出。

baseSalary.unitText是封闭枚举["HOUR", "WEEK", "MONTH", "YEAR"],而目录里有day。salaryUnit把day映射成空字符串——这是"记录这一缺口"而非"忘记这个键";salaryFrom则直接丢弃整个薪资块,因为把日薪当成月薪或年薪来发布,是一个错误答案,而不是一个不完整的答案。

这类"出站映射必须为每个源值准备一个已决定的答案(翻译、或有意留空)"的约束,在 AGENTS.md 中有系统说明,并由vocabulary_test.go遍历词汇表来守住。

3.ojcp_version的枚举与实现版本

responses/search-jobs.json 与 responses/error.json 都把ojcp_version限定为["0.1", "0.2"]。实现中 jobposting.go 的const Version = "0.1"由各响应结构的Finalize()统一写入,避免每个调用点分别记得填版本号。

4. 输入 schema 决定分页上界

search-jobs-input.json 规定pagination.limit默认 10、最大 50,offset默认 0。这两个数字直接成为 searchinput.go 的常量,Page()用min(limit, maxSearchLimit)夹紧:如果照单全收一个更大的 limit,返回的页会被标准自己的 schema 拒绝。

5. 输入 schema 也用于"该拒绝什么"

输入 schema 的职责不只是宽松接受。candidate_context被接受但忽略(匿名读取面不收候选人 PII),字段声明在 searchinput.go。而"忽略未识别参数"这条可检验性,来自对已识别字段清单的对照:QueryValues()返回url.Values与一个unsupported列表,后者最终进响应信封的ignored_params扩展字段——因为一个因为参数没被理解而变宽的答案,必须自己说出来。

十、落地检查清单

把这篇文章的要点压缩成一份可执行清单,适用于任何"用外部规范产物做预言机"的场景:

  1. 逐字复制,记录出处:仓库、提交哈希、提交说明,与文件放在一起。
  2. 不要编辑规范产物:局限是已知边界,不是待修代码;升级时重新复制并同步更新哈希。
  3. 按$id而非路径寻址:文件位置是 vendor 细节,$id才是标准中的身份;缺$id应报错而不是跳过。
  4. 显式接管加载器:用拒绝式加载器把"离线"变成构造性保证,而不是依赖库的默认行为。
  5. 先注册全部资源,再统一编译:避免遍历顺序影响$ref解析。
  6. 显式开启format断言:draft 2020-12 默认关。
  7. 校验序列化后的字节:投影的判定对象是 Agent 收到的 JSON,不是内存里的 Go 值。
  8. 为"字段在场"单独写断言:没有additionalProperties: false时,拼错与丢失都是绿的。
  9. 信封校验 ≠ 内容校验:检查内联的宽松形状,逐元素对核心 schema 再校验一次。
  10. 让检查能够失败:为每条约束至少写一个反例;声明了却从未使用的 schema 常量,按"关掉的检查"对待。
  11. 夹具走真实投影路径:jobview.FromRow(db.Job{...})而非jobview.Job字面量,否则会喂进生产路径产不出的值(例如未打 utm 标签的 URL、未小写的国家码)。

相关仓库路径

  • 存证说明与原文档:internal/api/ojcp/testdata/schemas/README.md
  • 预言机实现:internal/api/ojcp/schema_oracle_test.go
  • 预言机自检:internal/api/ojcp/schema_test.go
  • 投影约定与历史教训:internal/api/ojcp/AGENTS.md
  • 职位投影实现:internal/api/ojcp/jobposting.go
  • 响应信封与错误码:internal/api/ojcp/response.go
  • 搜索输入翻译:internal/api/ojcp/searchinput.go
  • 在场字段断言范例:internal/api/ojcp/jobposting_test.go
  • 逐元素校验范例:internal/api/ojcp/searchresponse_test.go
  • 错误信封反例测试:internal/api/ojcp/errorresponse_test.go

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载
上一篇:报告只是起点:用 Better Harness 的 repair-plan 把每条发现变成可执行的修复闭环
下一篇:OpenDM机器人数据指南:JSONL帧格式5步构建训练数据集并注册上手

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询