← 返回蜂巢洞察

如何使用Next.js、Supabase以及TypeSafe Jev来构建一个用于筛选人工智能相关简历的工具

每当我们发布一份工程招聘启事时,一周内就会收到300到400份简历。仔细阅读每一份简历大约需要两分钟的时间。因此,仅仅为了处理一个职位空缺,我们在开始任何面试之前就已经要花费11个小时来处理这些简历了。 但实际上,并没有人会真的去阅读每一份简历。相反,人力资源部门会进行快速的筛选工作。他们会快速浏览应聘者的职位名称、工作经验年限以及所掌握的技术框架,然后在大约15秒的时间内,将简历分为“需要进一步查看”和“可能不符合要求”两类。当他们看到第40份简历时,他们对这份简历的关注程度就已经远远低于对第4份简历时的关注了。 我们的目标就是取代这种快速筛选的过程,而不是放弃阅读简历这一环节。当阅读简历确

每当我们发布一份工程招聘启事时,一周内就会收到300到400份简历。仔细阅读每一份简历大约需要两分钟的时间。因此,仅仅为了处理一个职位空缺,我们在开始任何面试之前就已经要花费11个小时来处理这些简历了。

但实际上,并没有人会真的去阅读每一份简历。相反,人力资源部门会进行快速的筛选工作。他们会快速浏览应聘者的职位名称、工作经验年限以及所掌握的技术框架,然后在大约15秒的时间内,将简历分为“需要进一步查看”和“可能不符合要求”两类。当他们看到第40份简历时,他们对这份简历的关注程度就已经远远低于对第4份简历时的关注了。

我们的目标就是取代这种快速筛选的过程,而不是放弃阅读简历这一环节。当阅读简历确实值得花费时间时,人们是能够胜任这项工作的。但人类在面对大规模的简历筛选任务时会遇到困难,而正是这样的原因,那些经验描述方式容易被忽略的优秀候选人,就有可能因此被遗漏。

如果你已经熟悉大语言模型了,并且只想了解如何构建这个简历筛选工具,可以直接跳到如何构建简历筛选应用程序这部分内容。

目录

先决条件

要顺利完成接下来的步骤,您应该已经具备以下知识:

  • Next.js应用路由器:了解什么是服务器组件,以及服务器操作大致在何时执行。构建过程会涉及到这两个概念。

  • 具备足够的SQL基础知识,以便能够阅读迁移脚本。不过您不需要手动编写任何迁移脚本,因为所有的迁移文件都存储在代码仓库中。

  • 无需了解Jev或机器学习的相关知识。接下来的两个章节会涵盖构建过程中所需的所有内容。

在开始之前,您还需要准备以下工具:

  • Node v24.21.0及npm。

  • Docker。Supabase CLI需要使用Docker在您的机器上运行Postgres、身份验证系统和存储服务。

  • Supabase CLI。所有操作都在本地进行,因此直到最终部署阶段之前,您并不需要使用云服务。

  • Claude Code。构建过程共包含九个指令,每个指令都需要在新的Claude Code会话中执行。虽然这些指令在其他编码工具中也可以使用,但其中涉及到的TypeSafe技能安装是专门为Claude Code设计的。

  • 来自console.typesafe.ai的TypeSafe API密钥。Jev服务是按照输入令牌的数量来计费的,因此进行构建和测试所需的费用非常低廉。

  • Vercel AI Gateway密钥。此选项为可选。它会被用来从简历文本中提取候选人的姓名和电子邮件地址,但您也可以手动输入这些信息。

  • 只有当您计划在最后阶段进行部署时,才需要创建一个Vercel账户。

为什么不直接使用现有的工具呢?

在构建任何招聘系统之前,我们尝试过三种类型的筛选工具。

关键词和布尔值过滤器仍然是大多数求职者跟踪系统默认提供的筛选方式。用户只需选择所需的关键词,系统就会自动进行匹配。然而候选人们也知道这一点,因此每个针对React职位的简历中都会出现六次“React”这个词。

这种过滤方式只能评估候选人的写作能力,却无法判断他们是否真正具备完成工作所需的能力;而且那些用不同词语描述相同工作的优秀候选人,往往会被这类过滤器忽略。

匹配得分

是目前大多数ATS供应商所推出的升级功能:该系统会将简历内容与职位描述进行比较,并生成一个百分比分数。这个分数确实能够对候选人进行排序,但评分标准是由他人制定的,用户既无法查看也无法修改这些标准。

当招聘经理询问为什么某个候选人的得分是81分而另一个是64分时,得到的回答往往是“因为系统设定的评分规则”。但对于工程类职位来说,每个职位的招聘要求都在不断变化,因此这样的解释根本无法作为实际决策的依据。此外,这些工具大多也是为规模较大的招聘团队设计的,而不是为只有两名人力资源人员的中小型企业准备的。

大语言模型评估

是最新出现的一种筛选方式,我们也是首先尝试了这种方法。将简历和职位描述输入到大语言模型中,就能得到相应的分析结果。不过在接下来的章节中,我会详细说明为什么这种方法并不适用。简而言之,虽然这些分析结果看起来很合理,但无法根据这些文字对候选人进行有效排序。

我们所需要的评分标准应该比这些现有的任何标准都要简洁。这些标准应该由招聘经理用通俗的语言为每个职位具体制定出来,人力资源部门可以根据这些标准进行评估和讨论。同时,评分过程应该足够简单快捷,这样当经理改变对哪些因素重要的看法时,就可以在几秒钟内重新为所有候选人进行评分,而无需重新阅读他们的简历。

关于最后一个原因,我也必须坦诚相告:开发这个评分系统只花了几天的时间,因为当时正好有一个专为解决这个问题而设计的模型刚刚问世。我们想看看这个模型的效果如何,而手册的其余部分就是用来记录这个测试结果的。

我们正在构建什么

我们正在开发一个内部招聘门户网站。人力资源部门可以创建职位信息,并设定重要的评估标准,比如什么是“深厚的技术经验”,指导他人是否重要,以及所需的工作经验年限等。他们可以逐一上传候选人的简历,系统会根据这些标准为每份简历打分,评分结果会以可排序、可过滤的表格形式显示出来。

每一行表格都会显示总得分、各项标准的具体得分、模型对评估结果的信心程度,以及当信心度较低时是否需要人工审核的提示。这个工具从不会自动拒绝任何简历,它只是对所有简历进行排序,最终的决定仍由人工做出。

招聘门户网站截图

评分工作是由TypeSafe AI在2026年9月推出的Jev模型来完成的。与GPT或Claude不同,Jev并不会生成任何文本。你只需要向它提供简历和一组预设的问题,它就会返回带有精确概率数值的评估结果。根本不需要编写提示语、解析JSON数据或阅读长篇文字,你直接就能得到可供代码使用的评分结果。

在开发过程中,我们将使用以下技术组件:

  1. Next.js(用于构建应用程序路由系统)

  2. Supabase用于身份验证、Postgres数据库及文件存储

  3. Tailwind CSS与shadcn/ui用于界面设计

  4. TanStack Table用于展示应用程序列表

  5. unpdf工具用于从PDF文件中提取文本

  6. Vercel AI SDK及AI Gateway,用于完成某项特定的数据提取任务

  7. TypeSafe Jev模型用于进行评分计算

  8. Zod库,用于处理所有需要用户输入的数据

我之所以会参与这个项目的开发,是因为我经营着一家软件咨询公司MTechZilla,而这个招聘评分系统正是我们自己的人力资源团队最初开始开发的。文末提到的各项数据都是实际运行该系统后获得的结果,并非预测值。TypeSafe公司根本不知道我在写这篇文章。

简历筛选其实是一个决策问题

想象一下,招聘人员在收到筛选后的简历后,会先对结果进行排序和过滤,然后将其与其他候选人的简历进行比较,最后才会仔细阅读排名靠前的几份简历。

每一个步骤都需要具体的数值或标签来表示,而不是长篇的描述文字。

我是通过惨痛的经历才明白这一点的。该工具的第一个版本会将每份简历和职位描述发送给大型语言模型,并要求它们给出简短的书面评估意见。这些反馈往往很有深度且具体详细,甚至比我自己写的内容还要好。但实际上这些反馈并没有什么实际用途,因为人们根本无法根据这些段落来对招聘信息进行分类或整理。人力资源部门的员工只会阅读前几段内容,点点头,然后就继续去打开其他的PDF文件了。

要理解为什么不能简单地“要求模型直接给出一个数字作为答案”,你就需要了解大型语言模型究竟是如何生成答案的。

语言模型是如何生成答案的

大型语言模型实际上是一种自回归模型。这个术语用来描述一个简单的原理:它是一次只生成一个输出部分,而每个新的输出部分都是根据之前生成的所有内容来决定的。

这些被生成的输出部分被称为标记。一个标记大致相当于一个单词或单词的一部分——“screening”可能是一个标记,“unpdf”可能是三个标记。当你向大型语言模型提问时,它并不会先计算出完整的答案然后再将其输出。而是会先计算出下一个应该出现的标记的概率分布,从中选择一个,将其添加到当前的文本中,然后再重新运行整个生成过程来决定接下来的标记是什么。一个由200个标记组成的答案,实际上意味着这个答案是通过一个规模庞大的神经网络经过200次连续的计算才生成的。

这就是为什么使用大型语言模型时会感觉它们反应较慢的原因。这种延迟并非仅仅是计算开销造成的,而是它们工作原理本身所决定的。每个标记的生成都需要整个模型进行一次完整的运算过程,而这些运算步骤无法同时进行,因为每一个步骤都依赖于前一个步骤的结果。这也解释了为什么输出标记所需的计算资源会比输入标记更多:输入数据是可以一次性被处理的,但输出结果却是需要逐步生成的。

受限解码方法其实解决的是错误的问题

现代的大型语言模型提供了结构化的输出方式。你只需要向模型提供一份JSON格式的数据结构,它就会返回一个符合该结构的对象作为结果。在内部实现上,这种机制被称为受限解码:在每次生成新的输出内容时,那些会导致无效结果的标记都会被暂时忽略,模型只有在确保不会产生错误结果的情况下才会选择这些标记。如果数据结构明确规定下一个生成的元素必须是一个数字,那么模型就只能选择一个数字作为答案。

我必须明确指出,这种方法是有效的。我们再也不用使用正则表达式来解析模型的输出结果了,也不必在JSON格式的数据出现错误时重新进行尝试了。

但是,我们需要仔细思考一下受限解码方法实际上改变了什么。模型仍然会逐个标记地生成字符串,而且生成过程中仍然会遇到类似的延迟和计算开销。当你看到像“score”: 7这样的结果时,其实模型并没有真正计算出一个分数。它只是根据候选人的简历、提示信息以及它所掌握的关于评估的相关知识,预测出“7”是最有可能出现在这个位置上的标记而已。这个数字实际上只是看起来像数字的文本而已。

为什么生成的数字并不代表概率

这里有一个非常重要的区别需要理解。假设模型告诉你某个候选人的评估结果是7分(满分10分),或者说他们有70%的可能性是非常合适的候选人。

如果这个模型是经过校准的,那么这个数字就确实具有具体的意义。也就是说,如果你让这个模型对所有被评定为70%合适度的候选人进行评估,那么大约70%的候选人确实会符合你的要求。这个数字是一种量化指标,你可以根据它来做出相应的决策。例如,你可以设定60%作为最低标准,从而清楚地知道自己到底在接受还是拒绝哪些候选人。

语言模型给出的“70%”这个数值其实并不意味着相同的事情。在其训练数据中,没有任何因素将“70%”这个数字与实际意义上的70%概率联系起来。该模型之所以会输出“70%”,只是因为在它的训练数据中,类似的评估往往会使用这样的数字而已。它只不过是在模仿一种看似“自信的判断方式”罢了。 在实践中,还有两个因素会使这种情况更加严重。 首先就是采样问题。大多数大型语言模型都会使用大于零的“温度参数”,因此这些模型并不会总是选择最有可能的正确答案,而是会进行随机采样。例如,如果你两次输入相同的简历,结果可能会一次得到7分,下一次却得到8分,尽管实际上没有任何变化。将“温度参数”设置为0确实会有所帮助,但这个问题并不能因此得到彻底解决,因为那些数字本来就不是基于客观事实得出的。 另一个问题是缺乏统一的评估标准。当你先对候选人A进行评分,然后再对候选人B进行评分时,模型在评估候选人B时并不会记得之前对候选人A的评分结果。每个“7分”都是根据模型在当时所参考的标准独立计算出来的。因此,你的评分表中出现的两个“7分”看似相同,但实际上它们并不代表相同的意义。事实上,拥有这样一列看似可比较但实际上并不可比的数字,反而比根本没有这些数字更糟糕,因为人们往往会相信自己看到的数据。 正是这些问题导致了第一个版本的语言模型失败,而不是因为解析效率问题或延迟问题。由于不同行之间的评分标准并不统一,因此按照这些评分结果进行排序实际上只是在随机排列数据而已。

生成与区分

在机器学习领域,早就有一种分类方式能够准确描述这种现象。生成模型的学习目标是生成与其训练数据相似的数据;而区分模型则负责将输入数据分配到预先设定的类别中,并为每个类别输出相应的概率值。
大型语言模型属于生成模型。它的输出范围是所有可能的字符串,正是这种特性使得它具有灵活性,但同时也意味着它可能会“产生一些不存在的东西”:因为没有任何规则限制它只能生成真实的字符串或与现实选项相对应的字符串,所以它可以随意创造引文、函数内容或候选人的资历信息,因为任何字符串都是它合法的输出结果。 而针对固定选项集设计的区分模型则无法做到这一点。如果允许的答案只有“初级”、“中级”、“高级”和“高级主管”这几个选项,那么该模型就无法给出“校长”这样的答案,也无法用句子来表达其他非预设的答案。它只能为这四个选项中的每一个输出一个概率值,这就是它的全部输出结果了。对于这类模型来说,“创造新的形式”是不可能的,不是因为它们更加谨慎,而是因为它们根本没有这样的能力。 但这并不意味着生成模型的判断总是正确的。例如,对于一个显然属于中级水平的人,它也可能会给出“高级”这样的评分。不过,在固定选项集范围内出错,与在开放性问题上出错是截然不同的两种情况。对于前者,我们可以对其进行测量、校准、设定阈值,并据此来做出相应的决策。

系统1思维与系统2思维

丹尼尔·卡尼曼将人类的思维方式分为两种模式。系统1思维速度快,具有直觉性,善于识别模式;比如当你看到一张脸时,你能立刻判断出它表现出的情绪是愤怒的。系统2思维速度较慢,需要经过深思熟虑才能完成某些任务,比如填写纳税申报表。简历筛选属于“系统1”类型的任务。经验丰富的招聘人员只需花十秒钟浏览一份简历,就能相当准确地判断这份简历是否值得花费两分钟去仔细评估。他们并没有进行推理,而是在识别那些他们已经见过上千次的模式。 如果将基于推理的大型语言模型应用于这种任务,就相当于是将“系统2”的处理机制强行套用到“系统1”类型的问题上。这类模型会详细阐述自己的思考过程、权衡各种因素,最终生成一段内容详尽的回答。然而,这样的过程既缓慢又耗时,而且完全不符合这项任务的实际需求。这项任务所需要的,其实是招聘人员那短短十秒钟的快速判断——这种能力需要经过反复训练才能形成,并且可以连续使用350次而不会产生疲劳。 TypeSafe就是根据这一原理来命名其模型类别的。“系统1”型模型专门用于执行这种快速、精准的识别任务,仅此而已。

95%的问题

还有一点需要特别说明:因为这直接关系到这些任务是否真的能够被自动化处理。 假设你的筛选模型有95%的准确率,这个数字听起来确实不错。但如果你无法知道那5%的错误案例具体是哪些,你就不得不逐一检查每一条数据,这样一来就根本没有任何效率可言了。关键不在于准确率本身,而在于能否清楚地了解准确率的适用范围。 一个经过精准训练的模型能够提供这样的帮助。当它在某个问题上给出的准确率为55%,而通常情况下这个答案应该是90%或10%时,这就说明这个问题存在不确定性,因此应该由人工来进行审核。 正是这种机制使得“人工介入”的审核流程真正成为一种有效的设计方式,而不是仅仅是一种“我们无论如何都会检查所有内容”的说法。高置信度的结果可以直接采用;而低置信度的结果则需要人工进行复核。

最适合的解决方案

输入的是非结构化的文本,输出的是经过精准训练后得出的决定结果。中间不存在任何其他步骤或形式。 这种模型并不会先生成一个答案,然后让你再将其解析成具体的决策结果;它的唯一可能输出就是那个决策结果本身。在模型被训练之前,所有允许的答案就已经被确定了;模型返回的数值会被训练得使其具有明确的含义,并且下次输入相同的数据时,它仍然会给出相同的答案。 这是一种完全不同的模型类型,它已经在9月份正式投入使用。

TypeSafe Jev是什么,又不是什么

Jev是一种模型,它接收文本和一系列结构化的问题作为输入,然后为每个问题返回一个数值答案。这就是它的全部功能。要理解它的行为方式,了解它的训练过程是非常有帮助的,因为这正是决定它独特性的关键因素。

它的起源

所有现代语言模型都是从同样的方式开始开发的:首先会训练一个庞大的神经网络,让它利用互联网上大量的文本数据来预测下一个输出结果。这样就可以得到一个预训练模型。虽然这个模型掌握了很多信息,但在最初使用时并不十分实用,因为它只是能够简单地延续文本中的内容而已。如果你向它提出一个问题,它可能会给出答案,也可能会生成更多的问题,甚至是一些多年前的随机论坛帖子。<为了让模型能够发挥实际作用,还需要进行第二个阶段的处理,也就是训练之后的优化阶段。目前,针对这一阶段主要有三种处理方法。>

RLHF,也就是基于人类反馈的强化学习方法,正是ChatGPT背后的技术原理。人类评估者会对比模型产生的不同输出结果,并选择他们更喜欢的那个版本。奖励机制会帮助模型学会预测这些人类的偏好,而语言模型则会被训练来生成那些能够获得较高评分的输出结果。简而言之,这种模型就是通过学习来说出人们想听的话。

这种技术确实推动了聊天机器人的发展,但它也带来了一些不可避免的弊端。优化人类喜欢的输出与优化客观正确的答案是两回事;RLHF机制反而可能会促使模型产生奉承话或听起来自信但实际上错误的回答。 还有一种更为隐蔽的影响,根据TypeSafe的解释,这种影响被称为“模式固化”:模型会专注于那些被评估者认可的回应风格,从而不再倾向于生成其他类型的输出。因此,这样的模型往往会显得更加顺从他人意见,同时也更不愿意承认自己的不确定性。 RLVR,即使用可验证的奖励进行强化学习的方法,主要用于训练推理模型。在这种机制中,模型的奖励来自于将其生成的答案与某些可以被验证的标准进行对比——比如数学计算结果是否正确。这种方法在处理数学问题和代码逻辑时效果非常好,但由于模型在给出答案之前必须先展示其推理过程,因此这种方法的速度较慢,成本也相对较高。
RLCD,也就是用于训练决策模型的强化学习方法,正是TypeSafe公司开发Jev工具时所采用的技术。这种模型不会生成文本,而是会从预设的选项集中选择一个答案,并同时给出该答案正确的概率。我们的目标是让这个概率与实际正确答案的比例相匹配。例如,如果模型给出的概率是0.8,那么它的答案正确率应该在80%左右;如果概率是0.2,那么正确率应该在20%左右。 这种“校准”机制才是真正的主要目标。我们关注的是模型的校准程度,而不仅仅是其准确率。一个即使错误率高达30%,但能够明确指出哪些答案是错误的模型,显然比一个错误率只有10%却无法说明错误原因的模型要有用得多。 RLHF技术的共同发明者Diogo Almeida也是TypeSafe公司的联合创始人。在参与开发那些以取悦用户为目标的聊天机器人之后,他现在认为软件系统应该采用能够诚实地反映自身不确定性的模型。

请求的结构

一次Jev请求由两部分组成。 状态数据指的是请求所涉及的特定对象。它可以是字符串、JSON对象,也可以是文本数组。在我们的例子中,状态数据包括职位描述以及提取出的简历内容。状态数据仅仅是一种输入信息,Jev会读取这些数据,但不会根据其中的内容来执行任何操作。 问题列表则是一组针对状态数据制定的、带有名称和类型的查询问题。每个问题都会被独立、并行地处理,因此一个问题的回答不会影响另一个问题的结果。增加问题数量几乎不会影响请求的处理速度。例如,你可以在一次请求中发送一份简历以及其中包含的8个问题。 以下是我们的人力资源团队根据具体标准制定的、用于查询某位候选人的请求示例:{ "state": { "job_title": "高级产品工程师", "job_description": "全面负责与客户相关的功能开发。整个项目团队均使用TypeScript进行开发,同时需要使用Postgres数据库,并承担值班和指导其他工程师的工作……", "resume_text": "ANJALI MEHTA 高级后端工程师 anjali.mehta@example.com | +91 98200 41122 | 印度浦那 **总结** 拥有九年使用Go和TypeScript开发支付系统及账本系统的经验。曾负责完成一个处理400万笔交易的复式记账系统的迁移工作……" }, "model": "jev-1.13.0", "questions": { "technical_depth": { "type": "score", "instructions": "根据候选人的实际经验及参与的项目,评估其工程实践的深度:候选人亲自完成了哪些工作,这些工作的复杂程度如何,他们在这些项目中承担了哪些职责。请忽略技能列表、头衔和公司名称,仅评价实际体现出的技术能力,而非工作年限。当两个选项难度相当时,选择较低的一个。」 "criteria": [ "没有编写代码的角色或项目。相关经验仅限于辅助性工作,如手动质量测试、IT支持、项目经理职责或销售工程相关工作。」 "编码经验仅体现在课程作业、训练营项目或教程项目中(例如待办事项应用或简单克隆项目),没有任何实际交付给真实用户的产品。” "在他人设计的框架内完成一些规模较小的工作,如修复漏洞、添加次要功能或开发CRUD界面。仅涉及单一语言和单一层级的开发工作。请列出具体完成的任务,而非解决的问题。如果无法判断候选人实际完成了什么工作,也可以在此项中给予评分。” "能够独立负责某个实时系统中的完整功能模块,包括设计、开发、测试和部署工作,并且几乎不需要他人监督。涉及两个或多个技术层面(例如后端接口与前端界面)。需要提及代码审查、测试、部署或值班等相关经验。” "能够独立构建整个系统,并在架构设计上做出权衡。在两个或多个领域都具备深入的经验,例如后端开发与基础设施管理。需要解决一些复杂的问题,例如性能优化、扩展性提升、系统迁移等,并且经常负责领导项目或指导其他工程师。” "具有非常广泛的專業知识,例如维护一个被广泛使用的开源项目,或者深入了解系统底层原理(如编译器、内核、分布式系统或数据库引擎),或者在组织范围内负责架构设计工作。” ] }, "jd_alignment": { "type": "score", "instructions": "候选人的实际经验与“职位描述”中要求的能力是否匹配?请根据职位描述中的具体要求进行评估,而不要以“优秀的工程师应该具备哪些能力”为标准。请忽略技能列表中的关键词重复部分,重点评价候选人实际完成的工作。” "criteria": [ "完全没有重叠之处。候选人的经验与职位描述的要求完全不同。” "属于相关领域。虽然有一些可迁移的技能,但没有一项核心要求得到体现。” "部分匹配。满足了一些核心要求,但明显还有其他要求没有达到,或者提供的证据不够充分。” "高度匹配。通过实际完成的工作,满足了几乎所有的核心要求。” "超越了职位描述中的要求,包括那些“最好具备”的能力,而且有可以直接对比的先前工作经验作为证明。” ] }, "mentorship_demonstrated": { "type": "noul", "instructions": "候选人的简历中是否提到了指导他人的经验?” }, "llm_experience": { "type": "noul", "instructions": "候选人是否有开发大语言模型产品的经验?” "criteria": { "true": "候选人曾经开发过由AI或大语言模型驱动的产品或功能。” "false": "候选人没有开发过AI相关产品的经验。” } }, "open_source_contribution": { "type": "noul", "instructions": "候选人是否有参与开源项目的经验?” }, "career_progression": { "type": "choice", "instructions": "候选人的职业发展路径是怎样的?” "criteria": { "steady_growth": "工作经验逐年提升,职位也越来越高级。” "lateral_moves": "在不同公司担任类似职务。” "job_hopping": "频繁更换工作,但每份工作的任职时间都很短。” "unclear": "职业发展路径不明确。” } }, "primary_talent_profile": { "type": "choice", "instructions": "请选择最能反映候选人才能的选项。请全面考虑他们的经验,而不仅仅依据职位名称或技能列表来做出判断。最近担任的角色应被赋予最高的权重。” "criteria": { "frontend_engineer": "负责开发用户界面,使用React、Vue或Angular等技术,进行系统设计优化,提升浏览器性能并确保应用程序的可访问性。可能会使用外部API,但不会负责这些API的开发。” "backend_engineer": "负责开发服务器端服务、API和数据模型。需要掌握业务逻辑、数据库管理、队列调度以及服务性能优化等相关技能。几乎不涉及用户界面开发工作。” "full_stack_engineer": "在同一项目中同时负责开发用户界面和服务,且两者的重要性相当。不是那种偶尔修改前端模板的后端工程师。” "mobile_engineer": "负责开发iOS、Android或跨平台应用程序,使用Swift、Kotlin、React Native或Flutter等技术。需要确保应用程序能够在应用商店中成功发布,并优化设备性能,同时熟悉相关的原生SDK。” "devops_infrastructure": "负责管理代码的编译、测试和部署流程,使用CI/CD工具、Kubernetes、Terraform等技术来构建云基础设施,同时负责监控系统性能、保障系统的可靠性和处理紧急故障。” "data_engineer": "负责搭建数据管道和数据平台,使用ETL工具进行数据清洗和整合,利用Spark、Airflow等技术进行数据分析,为分析师和模型开发人员提供支持。” "ml_ai_engineer": "负责训练、优化和评估机器学习模型,包括应用型机器学习项目、大语言模型的开发以及相关研究工作。” "security_engineer": "负责保障应用程序、云服务或产品的安全性,包括进行威胁建模、渗透测试、漏洞修复等工作。” "embedded_systems": "从事底层系统开发工作,如编写固件代码、驱动程序、操作系统内核,或者使用C、C++或Rust等语言开发硬件受限环境下的应用程序。” "other": "其他与上述类别都不符合的工程领域的工作经验,例如质量测试自动化、游戏开发,或是负责解决方案设计的工作。” } }, "isresume": { "type": "noul", "instructions": "这份文档是某位求职者的简历或履历。” }, "earliest_role_start_year": { "type": "choice", "instructions": "在候选人的工作经历中,哪一年是他们开始第一份全职工作的年份?请仅从列出的年份中选择。忽略教育背景和证书获取的日期。如果简历中没有明确说明第一份工作的开始时间,请选择“none”。" "criteria": { "2016": null, "2017": null, "2021": null, "none": "简历中没有明确说明第一份工作的开始时间。” } }, "earliest_role_start_month": { "type": "choice", "instructions": "在候选人的工作经历中,他们开始第一份全职工作的月份是哪个月?如果只提到了年份或者没有提到开始时间,请选择“none”。" "criteria": { "january": null, "february": null, "march": null, "april": null, "may": null, "june": null, "july": null, "august": null, "september": null, "october": null, "november": null, "december": null, "none": "只提到了年份,或者没有提到开始时间。” } } } }

以下是评估结果(其中列出的数字仅供参考,这些数据来自一份合成简历,因此你可以从中了解各项能力的大致分布情况):

{
  "model": "jev-1.13.0",
  "answers": {
    "technical_depth": {
      "type": "score",
      "score": 3.32,
      "confidence": 0.71,
      "legend": {
        "0": "没有参与过任何需要编写代码的工作或项目。相关技术经验仅限于手动质量检测、IT支持、项目经理或销售工程师等岗位。",
        "1": "编码经验仅体现在课程作业、训练营项目或教程项目中(例如开发待办列表应用或简单克隆项目),没有实际交付给真实用户使用的产品。",
        "2": "从事过一些范围较小的工作,主要是在他人设计的框架内进行缺陷修复、添加次要功能或开发CRUD界面。通常只使用一种编程语言,处理单一层次的功能。这里也会将那些难以判断其具体完成了哪些工作的情况归为此类。",
        "3":"能够独立完成整个系统的开发工作,包括设计、编码、测试以及部署环节,并且几乎不需要他人监督。涉及两个或多个技术层面(例如API与前端开发)。如果相关记录中提到了代码审查、测试、部署或值班等环节,也会被归为此类。",
        "4":"能够负责整个系统的架构设计,并在功能实现过程中做出相应的权衡。在两个或更多技术领域都具备深入经验(例如后端开发与基础设施管理)。还会遇到一些复杂的问题,比如性能优化、系统扩展性提升或数据迁移等,并且有能力解决这些问题。通常会担任项目负责人或导师的角色。",
        "5":"是真正具有广泛专业知识的专家,要么负责维护一个被广泛使用的开源项目,要么深入研究操作系统内部机制(如编译器、内核、分布式系统或数据库引擎),或者在组织范围内负责架构规划工作。"
      },
      "probabilities": { "0": 0.00, "1": 0.01, "2": 0.12, "3": 0.46, "4": 0.36, "5": 0.05 }
    },
    "jd_alignment": {
      "type": "score",
      "score": 2.87,
      "confidence": 0.68,
      "legend": {
        "0": "与职位要求完全不相关,属于完全不同的领域。",
        "1": "有一定关联。虽然有一些可迁移的技能,但没有满足任何核心要求。",
        "2": "部分符合要求。满足了一些核心要求,但明显还缺少其他要素,或者相关证据不够充分。",
        "3": "非常符合要求。通过实际工作证明了自己满足了几乎所有核心要求。",
        "4":"不仅满足了职位要求,还具备一些额外的优秀能力,并且有可以直接比较的过往工作经验。"
      },
      "probabilities": { "0": 0.01, "1": 0.04, "2": 0.21, "3": 0.55, "4": 0.19 }
    },
    "mentorship_demonstrated": {
      "type": "noul",
      "value": 0.93
    },
    "llm_experience": {
      "type": "noul",
      "value": 0.08
    },
    "open_source_contribution": {
      "type": "noul",
      "value": 0.11
    },
    "career_progression": {
      "type": "choice",
      "option": "steady_growth",
      "confidence": 0.82,
      "probabilities": { "steady_growth": 0.88, "lateral_moves": 0.08, "job_hopping": 0.02, "unclear": 0.02 }
    },
    "primary_talent_profile": {
      "type": "choice",
      "option": "backend_engineer",
      "confidence": 0.79,
      "probabilities": {
        "frontend_engineer": 0.01, "backend_engineer": 0.86, "full_stack_engineer": 0.09,
        "mobile_engineer": 0.00, "devops_infrastructure": 0.03, "data_engineer": 0.01,
        "ml_ai_engineer": 0.00, "security_engineer": 0.00, "embedded_systems": 0.00,
        "other": 0.00
      }
    },
    "isresume": {
      "type": "noul",
      "value": 0.99
    },
    "earliest_role_start_year": {
      "type": "choice",
      "option": "2016",
      "confidence": 0.94,
      "probabilities": { "2016": 0.96, "2017": 0.03, "2021": 0.01, "none": 0.00 }
    },
    "earliest_role_start_month": {
      "type": "choice",
      "option": "august",
      "confidence": 0.88,
      "probabilities": {
        "january": 0.00, "february": 0.00, "march": 0.00, "april": 0.00, "may": 0.00,
        "june": 0.02, "july": 0.03, "august": 0.92, "september": 0.02, "october": 0.00,
        "november": 0.00, "december": 0.00, "none": 0.01
      }
    }
  },
  "usage": {
    "input_tokens": 4611,
    "output_tokens": 512
  }
}

请注意,这里缺少了一些内容:没有文本、解释或“推理”字段。所有的值要么是数字,要么是你预先定义的一组标签中的某个标签,因此你的代码可以直接使用这些值,而无需进行任何额外的解析。

此外,这里也没有关于“工作年限”的问题。这个指标是通过代码根据你提供的两个“最早开始任职时间”来计算得出的,你可以看到这两个选项被放在了页面的底部。这种设计安排正是有意为之的。

三种问题类型

“得分型”问题会根据你定义的有序等级来评估某项指标的状态。这些等级实际上就构成了评估的标准:Jev会逐一分析这些等级,然后返回一个表示各等级概率分布的结果,同时还会给出一个代表该概率分布期望值的分数。

需要注意的是,这些等级应该描述具体的行为或特征,而不是数字。例如,“在真实系统中能够独立完成整个功能模块的开发”这样的描述是Jev可以识别的,而“6年工作经验”这样的数字则无法被它用来进行计算。我们稍后还会再次讨论这个话题。

“是非型”问题是一种简单的二元选择题,其答案就是该选项为“是”的概率。整个回答就只是一个数字而已,没有单独的“置信度”字段,因为这种不确定性已经体现在这个数字中了。例如,0.95表示置信度很高,而0.52则表示模型无法确定答案。

你也可以在问题描述中明确说明“正确”和“错误”分别代表什么,这样就能更好地处理一些边缘情况了。

“多选型”问题要求从一组选项中选择一个。它会返回被选中的选项、每个选项对应的概率以及一个置信度分数。关键在于,“多选型”问题是相对的——它会选择最符合要求的那个选项,而不是判断是否有任何选项是合适的。

而“是非型”问题则是绝对的,即使所有选项的概率都很低,也仍然会被视为正确的答案。在选择使用哪种类型的问题时,这种区别非常重要。例如,“这个人属于哪一类工程师?”就是一个“多选型”问题,而“这个人会担任导师吗?”则是一个“是非型”问题。

置信度

无论是“得分型”问题还是“多选型”问题的答案,都会包含一个介于0到1之间的置信度数值。这个数值并不是单独的判断结果,而是基于概率分布计算得出的统计值。如果所有概率都集中在某个选项上,那么置信度就是1.0;如果概率分布均匀分配,那么置信度就是0。对于“得分型”问题来说,如果概率分布不清晰,或者简历中缺乏足够的信息,那么置信度就会很低。

对于“多选型”问题而言,如果没有任何一个选项的概率明显高于其他选项,那么就意味着没有一个选项是绝对正确的。

概率数值可以帮助你判断应该选择哪个答案,而置信度数值则能告诉你是否应该根据这个答案采取行动。TypeSafe的文档建议将置信度分为三个等级:高置信度表示可以自动采取行动;中等置信度表示需要进一步核实;低置信度则表示不应该直接行动,而应该由人工进行审核。

设定这些判断标准时,需要考虑具体的风险因素。例如,错误的职位等级是可以纠正的,但一旦做出了错误的拒绝决定,就无法挽回了,因此对于低分数的结果,我们应该更加谨慎。

在我们的系统中,我们将置信度阈值设为0.5,任何低于这个阈值的答案都会被标记出来,需要由人工进行审核。筛选引擎的相关代码也会显示这个阈值在程序中的具体位置。

速度与成本

Jev在收到像我们这样的请求后,会在70到500毫秒的时间内给出回复。这个响应速度快 enough,可以直接在用户观看的过程中由服务器进行处理,因此该系统不需要后台作业队列。

收费标准为每百万个输入令牌0.042美元,而输出令牌则是免费的。一份两页的简历加上职位描述大约需要1,500个令牌。提问环节也会产生费用,而且这些问题的数量并不少:八项默认评估标准再加上三项系统自动生成的问题,每次筛选时总共会消耗约3,000个令牌。因此,进行一次完整的评估大概需要4,500个输入令牌,也就是大约0.02美元的成本。如果每周需要处理350份简历,总费用约为7美分。

每个请求的最大令牌使用量为64,000个,其中32,000个用于存储候选人的基本信息,剩下的32,000个则用于最长的一项评估问题。一份普通的简历通常不会达到这个上限,但一份长达15页、还包含附录的简历就可能会超出这个限制,因此筛选系统会对此进行特别检测。

Jev并非什么

大多数介绍资料都会忽略这一部分,但它其实非常重要,因为接下来我们会通过这一部分来解释相关设计决策的由来。

Jev并不生成文本。它不会提供任何总结性内容,也不会给出任何解释理由,更不会说“候选人之所以得分高,是因为……”。招聘人员看到的唯一信息就是各项评估标准的得分情况,因此这些评估标准必须被编写得如此清晰,以至于这些得分数据本身就能说明评估结果。这一设计要求直接决定了评估标准编辑器的使用方式。

Jev也不是一个计算工具。它无法完成物品计数、数字相加或日期比较等操作。例如,Jev会将“2022年1月至今”这类时间描述视为普通文本,而不会将其理解为一个时间间隔。任何涉及算术的计算工作都应该由用户自行在代码中完成。

Jev只能读取文本。如果一份简历是扫描图像的形式,那么Jev就无法对其进行评估。系统会直接拒绝处理这类文件,而不会假装能够正常处理它们。

Jev只会按照字面意思进行解读。用户输入的问题必须表述得非常清晰明确,否则Jev会严格按照字面意思来回答。像“不是”这样的词语、隐含的条件以及语句的范围等等,都会被Jev原原本本地理解。

“从不产生错误解释”这一功能其实有着一定的局限性。Jev只能返回用户在设定范围内提供的答案,它无法创造新的职位等级,也无法用一句话来回答一些复杂的问题。这是一个实实在在的保障机制,因此也就没有必要为它添加额外的解析层了。

不过,这并不意味着Jev总是正确的。例如,对于一个实际上属于中级职位的人,Jev也可能会给出0.85分的评分。虽然这种类型系统相当可靠,但它的判断结果仍有可能出现错误。通过校准数据可以了解这种情况发生的频率。

上述这些限制因素在下一节的设计说明中都会被详细阐述,而在第一次测试所得的数据中也能看到它们对最终结果的具体影响。

应用程序的结构

在开始开发之前,了解整个应用程序的结构以及各部分存在的理由是非常有帮助的。这里所做的许多设计选择都是基于Jev的功能限制来制定的。如果了解了每个部分的用途,你就知道该如何根据自己的需求对系统进行相应的调整了。

 ┌─────────────────────┐
 │  人力资源部门使用笔记本电脑     │
 │  (通过浏览器)          │
 └──────┬──────┬───────┘
        │      │  ① PDF文件会直接通过签名URL保存到存储系统中——
        │      │     它永远不会经过服务器的处理流程
        │      └──────────────────────────────────────────────┐
        │  页面内容、服务器处理操作                               │
        ▼                                                     ▼
 ┌────────────────────────────────────────────┐   ┌────────────────────────────┐
 │  在Vercel平台上运行的Next.js                         │   │  Supabase                  │
 │                                            │   │                            │
 │  proxy.ts        更新会话信息、重定向     │◀─▶│  认证模块      获取用户信息    │
 │  服务器处理操作  对每个输入数据都进行Zod处理        │   │            在每次渲染时都会执行    │
 │  scoring.ts      纯粹用于生成分数——这是系统中唯一一个  
 │                  生成数字的环节     ⑤  │   │            在所有相关数据上都会执行这一操作,  
 │                                            │   │            并且这些数据都是只读的     │
 │                                            │◀─▶│            仅用于生成评分结果      │
 └───────┬───────────────┬───────────────┬────┘   │  存储系统   使用私有存储桶,  
         │ ②             │ ③             │ ④      │            通过签名URL进行数据存取     │
         ▼               ▼               ▼        └────────────────────────────┘
 ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐
 │  unpdf工具        │ │ AI Gateway    │ │ TypeSafe Jev     │
 │  首先将PDF文件转换为纯文本,然后   │ │ → 接着使用小型大语言模型   │ │ 通过one systemOne平台进行处理    │
 │ 进行文本解析      │ │ 获取候选人的姓名、电子邮件地址和电话号码 ——  
 │              │         并且会立即处理每一个问题     │
 │              │         不会处理其他任何信息     │
 │              │         (整个过程都在内存中完成)     │
 └──────────────┘ └───────────────┘ └──────────────────┘

 ① 上传文件   ② 提取文件内容   ③ 获取联系信息字段   ④ 计算分数   ⑤ 进行计算并保存结果

一份简历的处理流程

从人力资源部门上传简历的那一刻起,到最终评分结果显示在系统中,整个过程如下:

  1. 浏览器会使用服务器提供的签名URL,将PDF文件直接保存到Supabase存储系统中。整个上传过程不会经过我们的服务器。

  2. 服务器会从存储系统中下载PDF文件,并使用unpdf工具将其转换为纯文本。如果转换后的文本长度与预期页面数量不符,系统会判定该简历处理失败,并显示提示信息;如果没有可用的数据,整个流程就会停止。

  3. 纯文本数据会被发送到Vercel AI Gateway,然后通过小型大语言模型提取候选人的姓名、电子邮件地址和电话号码。这是系统中唯一一个涉及生成数据的步骤,而且这个步骤是可选的。

  4. 职位描述、招聘要求以及简历内容会被合并成一条Jev请求,所有相关问题都会在一次调用中得到处理。

  5. 代码会根据Jev模型的回答计算出综合评分,标明每一项招聘要求的满足情况或存在的差距,还会检查评估结果的可靠性,最后将所有数据保存到Postgres数据库中。

  6. 系统中的表格就会更新。实际上,大部分时间都花在解析PDF文件和提取信息上,而不是在Jev模型的处理上。

数据模型

共有六个表格用于存储该应用程序中的所有数据。

jobs ─────────┬── job_criteria        (针对该职位的Jev评估问题)
              │
              └── applications ────── screenings ────── screening_answers
                  (每个简历对应一条记录)    (每次评估运行对应一条记录)      (每个评估问题对应一条记录)

profiles      (每位人力资源用户对应一条记录,其数据与auth.users表中的信息一致)

jobs表格用于存储职位名称及相应的职位描述。这些描述会被包含在Jev的评估系统中,因此以文本形式保存,而非作为指向其他文档的链接。

job_criteria这个表格非常关键。其中每一行都代表一个Jev评估问题:包括问题的类型、使用说明、选项等级、权重,以及该问题是否计入最终综合得分的标志。当人力资源部门创建一个新的职位时,系统会复制此表中预设的评估标准,然后由相关人员对这些标准进行修改。这些由人力资源部门制定的评估问题本身就构成了筛选逻辑,系统中没有任何提示信息。

applications表格中,每条记录对应一份上传的简历。该表格会缓存从简历中提取出的文本信息,因此当人力资源部门更改评估标准时,系统无需重新解析PDF文件即可进行再次评估。

screenings表格中,每条记录对应一次评估操作,而不是针对某份具体的简历。每当有一份简历被评估完毕,系统中就会添加一条新的记录,而之前的记录则会保留下来。

screening_answers表格会将Jev系统给出的每一项评估结果分别存储为独立的记录,其中包含原始数值、标准化后的数值、置信度以及所属评分区间。系统会根据这些信息对数据进行排序和筛选。

profiles表格与Supabase系统的auth.users表结构相同,但会额外添加一个显示名称。这个显示名称会在管理员创建用户时由数据库触发程序自动生成。

值得解释的决策

1. 每个职位的评估标准都存储在数据库中,而非代码中。

最初我们考虑过将评估标准放在配置文件中,但这种方法失败了。因为有一次招聘经理表示:“对于这个职位来说,指导能力并不重要,而开源项目经验却非常重要。”

由于评估标准存储在数据库中,我们可以随时通过修改代码来调整各项标准的权重。如果这些标准被写在代码里,那么每次修改都需要进行部署操作。由于每个新创建的职位都会使用默认的评估标准设置,因此只需在需要的地方进行调整即可。

2. 综合得分始终是由我们的代码计算得出的,而非由Jev系统生成的。

这样做有三个原因:

首先,Jev系统的文档明确指出,不应将其评估结果直接用作精确数值。这些评分等级其实是用于划分阈值,并非用于表示具体的数字。

其次,在代码中计算加权总和更容易进行审计,因为其计算过程清晰透明。如果有人询问为什么某个候选人的得分是71分,我们可以直接展示计算公式和输入数据。

最后,如果经理需要调整各项标准的权重,只需更新相应的系数,系统就会在几毫秒内为所有候选人重新计算得分。如果综合得分是由模型生成的,那么这样的操作就无法实现。

3. 选择题仅被用作分类依据,而非用于评分的计算输入。

选择题提供的选项是从一个无序的列表中选取的。例如,“backend_engineer”并不比“mobile_engineer”更重要。如果将这类选项纳入综合评分体系中,就必须为各个类别分配随机数值,而这会导致评分结果出现误导。因此,系统设计确保所有选择题的相关字段都被设置为“不包含在综合评分中”,并且在用户界面中以筛选栏的形式呈现。用户可以先根据“full_stack_engineer”这一条件进行筛选,然后再按得分对结果进行排序——这两种操作是相互独立的。

4. 筛选过程是同步进行的。

整个流程不存在排队、后台处理或轮询等机制。Jev会在不到一秒的时间内完成响应,而那些耗时较长的操作(如PDF文件解析或调用大型语言模型)也能够在正常的服务器响应时间内完成。

如果采用传统的架构来设计“调用AI模型”的功能,那么添加作业队列反而会带来不必要的复杂性。不过,如果以后需要一次性上传大量数据,筛选功能本身已经是独立运行的模块,完全可以将其放入作业队列中处理,而不会影响到其他系统的运行。

5. 对于两项不同的任务,分别使用了两种不同的模型。

提取姓名和电子邮件信息时使用了大型语言模型,因为这种模型能够从简历中生成自然语言文本,而Jev本身并不具备这样的功能。评分工作则由Jev来完成,因为它会依据固定的标准进行判断——正如前面所提到的,大型语言模型并不适合用于这类任务。如果使用同一个模型来处理这两项任务,必然会导致性能上的折中。

6>筛选记录只能被追加到现有数据中,不能被删除。

系统从不会删除任何筛选记录。当评估标准发生变化、需要重新对某位候选人进行评分时,旧评分结果会与新评分结果一起保留下来。这种设计不仅占用的存储空间很小,还能带来两大好处:首先,它为人们提供了查询原因的依据(比如“为什么这位候选人在9月份被拒绝了”);其次,它有助于了解评估标准的变化对整个候选人群体产生的影响。

7>上传文件会直接保存到存储系统中。

Vercel的无服务器功能将请求体的大小限制在4.5MB以内。大多数简历文件的体积都小于1MB,但有些文件(比如设计师的作品集)的体积可能会比较大。使用带签名链接的方式将文件直接上传到Supabase存储系统,可以避免这一限制,同时也能提高上传速度,因为文件只需要传输到一个地方即可。

安全模型

所有人力资源系统的用户都具有相同的权限设置,因此这是一个单角色应用程序,其安全模型也非常简单。系统中为每个表格都启用了行级安全机制:经过身份验证的用户可以完全访问这些数据,而匿名用户则无法访问任何信息。由于系统中没有公共的应用表单,因此未经身份验证的请求根本不可能接触到任何数据。

存储系统也是私有的。简历文件是通过带签名链接的方式发送到用户的浏览器的,而这些链接在几分钟后就会失效。Supabase的服务角色密钥仅用于服务器端代码中,永远不会被发送给客户端。

管理员在Supabase控制面板中创建用户。系统中没有注册页面、邀请流程或密码重置表单,这是有意为之。对于这样一个仅有少数用户的内部工具来说,添加这些功能反而会增加安全风险,而并不会带来实际的好处。

我们刻意不开发的功能

系统中也没有测试脚本、后台作业程序、公共候选人门户、电子邮件通知功能,也没有与ATS系统的集成。这些功能虽然合理,但超出了本手册的范围,因为本手册的重点是介绍筛选逻辑。如果添加这些功能,就会使讨论偏离主题。

如何构建简历筛选应用程序

到目前为止,我们主要关注了应用程序的模型部分。现在,我们将讨论如何实际开发这个应用程序。这一部分的编写方式与常见的教程有所不同,让我来解释一下原因。

代码仓库地址:https://github.com/MTechZilla/recruitment-portal

为什么使用提示语而不是直接编写代码?

在2020年的时候,我在开发这个应用程序时会把所有的文件都分享给大家:我会编写代码,然后大家再复制这些代码。但这个应用程序并不是这样开发的。代码仓库中的每一行代码都是通过Claude Code工具根据预先写好的说明逐个生成的。如果直接复制生成的结果并假装这些都是自己写的,那就不够诚实了,而开发过程本身才是最重要的。

以下每个部分都会介绍我使用的提示语,解释这些提示语的作用以及为什么这样设计。每个提示语所生成的代码都保存在代码仓库中。

在开始运行任何程序之前,有三件事你需要了解。

文件CLAUDE.md是整个应用程序的规范和基础。它位于代码仓库的根目录下,其中包含了所有需要在不同会话中保持一致的限制条件:所使用的技术栈、Jev合约的规则、安全设置以及各种计算公式等。

每个任务提示语都会以“阅读CLAUDE.md”开头。这样就可以确保第六个任务不会无意中撤销第二个任务中所做出的决定。你可以在代码仓库中查看这份完整的文件。其中关于Jev的部分,实际上就是将“TypeSafe Jev到底是什么”这一概念详细地转化为了一系列规则。

# 招聘门户 — 项目规范

这是一个内部人力资源门户。人力资源部门会创建职位信息,每次上传一份简历PDF文件,然后该应用程序会使用TypeSafe AI的Jev模型对这些文件进行筛选。此系统仅支持单一角色登录。

这份文件是所有规定的权威依据。在每次开始使用系统之前,请重新阅读它。当某个任务提示与这份文件中的规定发生冲突时,以这份文件的规定为准——遇到冲突请标记出来,切勿默默地选择其他方案。

---

## 技术栈 — 严禁偏离这些规范

- Node v24.21.0,npm
- Next.js应用路由系统,严格使用TypeScript,所有应用程序代码都存储在`src/`目录下
- 通过`@supabase/ssr`使用Supabase框架(包括认证、Postgres数据库和存储功能);本地开发时则使用Supabase CLI
- 使用Tailwind CSS和shadcn/ui进行界面设计
- 用TanStack Table来处理列表显示
- `@typesafe-ai/sdk`用于评分计算。在开发阶段使用`jev-latest`版本模型;在生产环境中则固定使用特定版本的ID(目前为`jev-1.13.0`);具体配置请参考DEPLOY.md文件。
- `unpdf`工具用于提取PDF文件中的文本内容
- Vercel AI SDK和Vercel AI Gateway仅用于提取候选人信息,不用于评分计算
- Zod库用于处理所有的输入数据以及环境变量
- 使用GitHub Actions进行持续集成与部署,部署平台为Vercel
- **不要使用任何测试框架。**切勿添加Vitest或Playwright。
- **在添加任何未在此列表中列出的依赖项之前,请先征得同意。**

---

## 注意:Jev并不是一个大型语言模型 — 在修改筛选代码之前请务必阅读此说明

Jev仅会返回带有精确概率值的类型化结果。它不会生成字符串,也无法生成超出您定义的数据结构的值,更不会导致类型错误。在一个请求中提出的所有问题都会被并行且独立地针对相同的`状态`进行评估。同时发送多个问题几乎不会影响处理速度,因此请将它们全部一次性发送出去。

### 三种基本类型的响应格式

使用`POST https://api.typesafe.ai/v1/systemone`接口,并传入`{ state, model, questions }`参数。
响应格式为:`{ model, answers, usage: { input_tokens, output_tokens } }`。每个答案都会包含`type`字段,且其键与您在`questions`中使用的键相同。

| 类型 | 判断标准 | 返回值 |
|---|---|---|
| `score` | 一个由2到10个按顺序排列的等级描述组成的数组,数值越低代表等级越低 | `{ type, score: float, legend: {"0": desc, ...}, probabilities: {"0": p, ...}, confidence }` |
| `noul` | 可选参数,值为`{ true: desc, false: desc }` | `{ type, noul: 0..1 }` — **不包含信心度字段** |
| `choice` | 一个键值对映射,其中键表示选项,值表示对应的描述(或为空),最多允许255个选项 | `{ type, choice, probabilities: {opt: p, ...}, confidence }` |

`probabilities`和`legend`都是以字符串为键的映射结构,永远不会是数组形式。`score`代表的是各等级概率加权和后的预期值,其结果可能介于各个等级之间。

`instructions`参数可以接受字符串、对象或数组的形式。如果使用对象,可以在其中一个字段中存放问题描述,在其他字段中存放数据;需要引用数据字段时,请使用反引号将其名称括起来。

在编写用于读取答案的代码之前,请先阅读 `/sdk/javascript.md`文件,了解如何访问SDK返回的数据。切勿仅根据这些表格中的格式来推测数据的结构。

### 需遵循的规则

- 切勿使用一个笼统的“请对这份简历进行评分”这样的问题。应该将其分解成多个具体的子问题。
- **最终的综合得分是由我们的代码计算得出的。**切勿直接请求Jev给出一个最终的分数。
- **系统中不会生成任何由AI编写的摘要内容。**各项能力的强弱以及存在的不足都是通过代码中对这些分数的统计分析得出的。切勿额外调用大型语言模型来撰写关于候选人的描述性文字。
- `choice`类型的问题只是用于分类筛选,并不用于计算得分。这些选项之间没有优先级顺序——`backend_engineer`并不比`mobile_engineer`更重要。它们仅仅用于在界面中展示和过滤数据,切勿将它们的结果转换为数值形式。
- `noul`表示“没有信心度”。只有在计算`score`、`choice`以及`derived`类型的答案时,才需要统计`min_confidence`值。当`|noul - 0.5| < 0.15`时,说明这个选项的信心度较低,需要进一步审核。
- **Jev并不是一个计算器。**它会将日期视为文本进行处理,因此无法进行日期的计算、相加或比较操作。所有与数学运算相关的逻辑都实现在`src/features/screening/lib/scoring.ts`文件中。Jev的任务仅仅是*识别*文本中哪个值是我们所需要的;具体的计算工作则由代码来完成。
- `state`代表的是数据本身。Jev并不会执行其中包含的指令,但简历中的某些文本内容仍然会影响评估结果。因此,判断标准必须尽可能精确。
- 信心度只是一个用于指导筛选流程的指标,并不代表候选人的实际能力水平。信心度较低并不意味着“这个候选人很差”,而是表示“需要人工进一步审核”。用户界面中的描述也必须体现这一原则。

### 问题类别

**职位要求** — 这些要求存储在`job_criteria`字段中,由人力资源部门制定,在创建职位信息时会从`screening-criteria.default.json`文件中复制这些内容。问题类型包括:`score`、`noul`、`choice`和`derived`。

**系统检测问题** — 这些问题是固定的,每次都会被发送出去,不会出现在`job_criteria`字段中,也不会作为分类筛选选项显示出来。这些问题的具体定义位于`screening-criteria.default.json`文件中的`system_questions`部分:
- `is Resume`(类型为`noul`)——用于判断简历是否有效。如果其信心度低于0.5,系统会认为这份简历无效,不会对其进行评分。
- `earliest_role_start_year`(类型为`choice`)——选项包括在简历文本中通过正则表达式找到的四位数年份,以及“无”这个选项。这些选项会在请求时动态生成。
- `earliest_role_start_month`(类型为`choice`)——选项包括十二个月中的任意一个月份,以及“无”这个选项。

**衍生判断标准** — 这些标准的类型为`derived`,不会被发送给Jev进行计算。它们是在`scoring.ts`文件中根据系统检测问题的回答结果以及当前日期计算得出的。`criteria`字段中包含了将计算结果映射到不同等级的数值阈值;`instructions`字段中则包含`{"source": "<名称>"}`这个键值对,用于指定数据的来源。衍生判断标准的信心度实际上就是系统中用于计算它的那些答案的最小信心度值。在默认设置中,唯一的衍生判断标准是`years_of_experience`。

### 综合评分公式

```
对于`score`类型的问题:标准化得分 = score / (levels.length - 1)
对于`noul`类型的问题:标准化得分 = noul                          // 其值本身就已经介于0到1之间
对于`derived`类型的问题:标准化得分 = level_index / (thresholds.length - 1)
对于`choice`类型的问题:不计入综合评分中

最终综合得分 = 100 * Σ(weight_i * normalized_i) / Σ(weight_i)
            其中,只有那些在`include_in_composite`字段中值为true的问题才会被纳入计算范围

根据标准化得分的值进行分类:
- 如果标准化得分 >= 0.70,则表示“优势”
- 如果标准化得分 <= 0.35,则表示“不足”
- 其他情况则表示“中性”
```

这个评分公式完全保存在`src/features/screening/lib/scoring.ts`这个纯模块中,其中不包含任何输入输出操作。`today`变量是这个函数的一个参数,但它并不会从系统内部的时钟获取当前时间。

### 请求资源消耗限制

每次请求最多可以使用64k个令牌,其中32k用于存储`state`数据以及处理最长的那个问题。在调用相关函数之前,请先估算一下所需的令牌数量(大约相当于将输入文本的长度除以4)。如果计算出的令牌数量超过了28k,就应标记请求失败,并提示简历文件太长而无法进行筛选处理。切勿默默地截断输入数据。

### 模型版本管理

响应结果中的`model`字段会包含用于表示当前评分版本的编号。请将这个编号保存在每一条筛选记录中。每当TypeSafe发布新的模型版本时,`jev-latest`这个版本也会随之更新,因此针对某个特定版本调整过的阈值可能在新版本中不再有效。开发环境中使用`jev-latest`版本;生产环境中则固定使用指定的版本编号。

---

## 架构规则

- `src/app/`目录仅用于存放路由配置,其中不包含任何业务逻辑代码。
- 各个功能模块都是独立存在的:`src/features/<名称>//{components,hooks,lib,server,types}`。`server/`目录中存放服务器端的功能和路由处理逻辑。
- `src/components/ui/`目录中仅包含由shadcn生成的文件,切勿直接编辑这些文件。
- `src/lib/`目录中存放客户端相关的代码以及`env.ts`配置文件。`src/utils/`目录中只包含纯函数代码。
- 在没有其他消费者需要使用某个功能模块之前,不要对其进行抽象化设计。
- 尽量使用服务器端组件;只有在确实需要交互功能时才使用客户端组件。

---

## 安全性要求 — 绝不可妥协

- **所有**数据表都启用了RLS安全机制。经过身份验证的用户可以执行完整的CRUD操作,未授权用户则无法访问任何数据。在任何情况下,都不得为未授权用户启用`USING (true)`选项。
- `resumes`数据桶是私有的,只能生成短期的带签名链接,绝对不能公开这些链接。
- `service_role`这个键仅用于服务器端,严禁在客户端组件中使用它,也绝不允许将其设置为`NEXT_PUBLIC_`模式。
- 每个服务器端操作在接触数据库之前都会使用Zod库来验证输入数据。
- 在`src/lib/env.ts`文件中,环境变量会被使用Zod库进行解析和验证;如果缺少任何环境变量,系统会发出明显的错误提示。
- `TYPESAFE_API_KEY`以及AI Gateway相关的密钥也仅限于服务器端使用。

---

## 认证机制

系统只支持单一角色认证模式——所有经过身份验证的用户都属于人力资源部门,他们拥有相同的权限。系统仅支持登录功能:**没有注册入口、没有注册界面、没有自助密码重置功能,也没有邀请他人注册的流程**。管理员需要在Supabase管理面板中创建用户账户。为了保护相关接口,除了使用中间件之外,还必须在服务器端进行会话检查;仅仅依赖中间件是不够的。

---

## 工作协作规范

- 在实施任何更改之前,先制定一个简短的计划,并征得相关人员的同意。
- 未经明确批准,不得将项目部署到Vercel平台上,也不得修改与Supabase云服务相关的配置。
- 每次执行操作时,只完成一个任务,然后在完成下一个任务之前提交代码变更。
- 如果发现某些规定无法按原计划执行,应立即停止当前的工作并报告问题,切勿默默地绕过这些问题继续工作。

请在每个新的Claude Code会话中执行相应的任务。起初,这种方式可能会显得效率低下,但经过多次会话后,你会发现模型会开始受到之前任务的干扰,从而影响后续的判断。到了第八个任务时,模型甚至可能会完全失去正确的判断能力并出现错误。因此,从一开始就使用清晰的提示和指导原则来开展工作,才能生成更优质的代码,而试图回忆之前的所有内容则是行不通的。

请确保在完成一个任务后及时提交你的工作成果,这样一旦出现问题,你就可以轻松地恢复到之前的状态。

screening-criteria.default.json文件是评判标准的核心依据。你可以在仓库的根目录中找到它。该文件包含了HR在招聘过程中会使用的所有默认评估项,以及那些始终适用于所有职位的系统级评估标准。

任务2会使用这个文件来设置数据库;任务4会在创建新职位时复制这份文件;任务6则会根据这份文件来生成所有的Jev请求数据。这个文件是定义什么是优秀候选人的唯一依据,而且由于它只是数据而非代码,因此你可以随时更新这些评估标准,而无需修改任何TypeScript代码。

查看这份文件是了解该应用程序具体功能的最快方法,以下是它的全部内容。_note和_comment字段仅供参考使用,在将数据发送到Jev之前会将其删除。

{
  "_comment": "默认的评估标准集。每当创建新职位时,这份文件会被复制到job_criteria中;HR会对副本进行修改。" "数组中的顺序代表评估标准的优先级。对于类型为'choice'的评估项,"include_in_composite"属性被强制设置为false。类型为'derived'的评估项则是由system_questions字段在代码中计算得出的结果,因此不会被发送到Jev系统中。"
  "criteria": [
    {
      "key": "years_of_experience",
      "label": "工作经验年限",
      "type": "derived",
      "weight": 1.0,
      "include_in_composite": true,
      "instructions": {
        "source": "earliest_role_start"
      },
      "criteria": [
        0,
        2,
        4,
        6,
        8,
        10
      ],
      "_note": "这些数值代表工作经验的年限,从最低到最高排序。代码会计算从最早开始工作的年份/月份至今所经过的实际年数,然后选择其中最高的数值作为评估标准。Level index / (thresholds.length - 1)这个公式用于将数值转换为标准化格式。最终得分取决于这两个来源提供的数值中较低的那个值。Jev系统本身不会进行日期计算。"
    },
    {
      "key": "technical_depth",
      "label": "技术深度",
      "type": "score",
      "weight": 2.0,
      "include_in_composite": true,
      "instructions": "根据候选人的实际工作经验和参与过的项目来评估其技术深度。重点考察候选人亲自参与开发的工作内容、项目的复杂程度以及他们所承担的角色。请忽略技能列表、头衔或公司名称等信息,只关注他们实际完成的工作。”
      "criteria": [
        "没有编写过代码的角色或项目。仅涉及辅助性工作,如手动质量测试、IT支持、项目经理职责或销售技术相关的工作。",
        "仅参与过课程作业、训练营项目或教程性质的项目(例如开发待办事项应用或简单克隆项目),没有将任何成果实际交付给真实用户。",
        "在他人设计的基础上完成一些小规模工作,如修复漏洞、添加次要功能或开发简单的CRUD界面。这些工作通常只涉及单一技术或单一功能模块。请用列表形式列出具体任务,而不是所解决的问题。”
        "能够独立完成整个系统的开发工作,包括设计、编码、测试和部署等环节,并且能够在两个或多个技术层面进行工作(例如同时涉及后端开发和前端开发)。需要证明自己参与过代码审查、测试工作,或者具备值班响应的能力。”
        "负责过整个系统的架构设计工作,需要在不同的技术领域进行权衡和决策。例如需要处理与后端开发、基础设施相关的高难度问题,或者参与过性能优化、系统扩展或故障排查等工作,并且经常负责领导项目或指导他人工作。”
        "在某一领域具有深厚的专业技能,是该领域的专家。例如负责维护一个广泛使用的开源项目,或者深入研究操作系统内部机制、分布式系统技术、数据库引擎等,又或者在组织范围内负责整体架构规划工作。”
      ]
    },
    {
      "key": "jd_alignment",
      "label": "与职位描述的一致性",
      "type": "score",
      "weight": 1.5,
      "include_in_composite": true,
      "_note": "这是默认评估标准集中唯一与具体职位相关的评估项。如果想要制定一个完全不考虑职位要求的评估标准,可以删除这一项;但如果保留这一项,那么`job_description`字段的内容必须是有效的。”
      "instructions": "候选人的实际工作经验与职位描述中的要求相符程度如何?评价时请根据职位描述中明确列出的要求来进行判断,而不要基于对“优秀工程师”的一般概念来主观判断。请忽略技能列表中关键词的重叠情况,重点评估候选人实际完成的工作内容。”
      "criteria": [
        "两者完全不匹配。候选人的工作经验与职位要求属于完全不同的领域。”
        "有一定关联。虽然存在一些可转移的技能,但没有任何一项核心要求得到了体现。”
        "部分匹配。满足了一些核心要求,但明显还缺少其他一些要求,或者提供的证据不够充分。”
        "高度匹配。候选人实际完成的工作内容几乎满足了所有的核心要求。”
        "超越了职位要求,还包括了一些额外的优秀表现,而且这些表现也有相应的实际工作经历作为支撑。”
      ]
    },
    {
      "key": "mentorship_demonstrated",
      "label": "指导他人的经验",
      "type": "noul",
      "weight": 0.5,
      "include_in_composite": true,
      "instructions": "候选人的简历中是否体现了指导他人的经验?”
    },
    {
      "key": "llm_experience",
      "label": "大型语言模型产品开发经验",
      "type": "noul",
      "weight": 0.5,
      "include_in_composite": true,
      "instructions": "候选人是否有过开发大型语言模型产品的经验?”
      "criteria": {
        "true": "候选人曾经开发过由AI或大型语言模型驱动的产品或功能。”
        "false": "候选人没有相关开发经验。”
      }
    },
    {
      "key": "open_source_contribution",
      "label": "开源贡献经历",
      "type": "noul",
      "weight": 0.5,
      "include_in_composite": true,
      "instructions": "候选人是否有过参与开源项目开发的经验?”
    },
    {
      "key": "career_progression",
      "label": "职业发展历程",
      "type": "choice",
      "weight": 0,
      "include_in_composite": false,
      "instructions": "候选人的职业发展轨迹是怎样的?请从以下选项中选择。”
      "criteria": {
        "steady_growth": "工作经历呈现出稳定的晋升趋势,职位等级逐渐提高。”
        "lateral_moves": "在不同公司担任过类似的职位。”
        "job_hopping": "频繁更换工作,而且每份工作的任职时间都很短。”
        "unclear": "职业发展轨迹不明确。”
      }
    },
    {
      "key": "primary_talent_profile",
      "label": "主要才能方向",
      "type": "choice",
      "weight": 0,
      "include_in_composite": false,
      "instructions": "请从以下选项中选择最能反映候选人才能方向的选项。评价时请综合考虑候选人的整体工作经验,而不仅仅看他们的职位名称或技能列表。最近担任的职位应被赋予最高的权重。”
      "criteria": {
        "frontend_engineer": "负责开发用户界面,使用的技术包括React、Vue或Angular等。工作内容可能涉及设计系统、优化浏览器性能或确保应用程序的可访问性。可能会使用一些API,但不会负责这些API的开发和维护。”
        "backend_engineer": "负责开发服务器端服务、API以及数据模型。需要掌握业务逻辑、数据库管理、任务队列调度等方面的技能,并且要保证服务的稳定运行。通常不需要参与前端开发工作。”
        "full_stack_engineer": "在同一项目中同时负责前端和后端的开发工作,而且这两部分工作的重要性相当。不能只是偶尔修改一下后端代码的模板而已。”
        "mobile_engineer": "负责开发iOS、Android或跨平台应用程序,使用的技术包括Swift、Kotlin、React Native或Flutter等。工作内容可能涉及应用的上线发布、设备性能优化以及原生SDK的开发与维护。”
        "devops_infrastructure": "负责管理代码的编译、测试、部署等流程,使用的工具包括CI/CD系统、Kubernetes、Terraform等。同时还需要负责监控系统的运行状态、确保系统的可靠性,并在必要时提供值班支持服务。工作内容涵盖DevOps、SRE以及平台工程等多个方面。”
        "data_engineer": "负责构建数据管道和数据平台,使用的技术包括ETL工具、数据仓库、Spark、Airflow等。工作目标是为分析师和模型开发人员提供所需的数据支持,而不是直接为最终用户服务。”
        "ml_ai_engineer": "负责训练、优化、评估或应用机器学习模型,涉及的应用领域包括机器学习、大型语言模型以及研究型工程等。”
        "security_engineer": "负责保障应用程序、云系统或产品的安全,工作内容可能包括威胁建模、渗透测试、漏洞修复等工作。”
        "embedded_systems": "从事底层系统的开发工作,例如编写固件代码、驱动程序、操作系统内核代码,或者使用C、C++或Rust等语言进行嵌入式系统的开发。”
        "other": "其他不符合上述任何类别的工程实践经验,例如质量测试自动化、游戏开发,或者负责解决方案的设计与实现等工作。”
      }
    }
  ],
  "system_questions": {
    "_comment": "这些评估问题总是会与职位相关的标准一起被发送到Jev系统中。HR无法修改这些内容,它们也不会被保存在job_criteria文件中,也不会以其他形式出现在评估结果中。is Resume这个字段用于进行校验;其中最早开始工作的年份相关的问题会被用来计算years_of_experience这一评估项的值。" 
    "is.resume": {
      "type": "noul",
      "instructions": "这份文件是求职者的简历或履历。”
      "_guard": "如果isResume的值为noul且其数值小于0.5,那么申请状态应被设置为“失败”,并显示提示信息“这份文件看起来不像是简历”。在这种情况下,不需要对这份文件进行评分。”
    },
    "earliest_role_start_year": {
      "type": "choice",
      "instructions": "在候选人的工作经历中,哪一年是他们开始第一份全职工作的年份?请仅从列出的年份中选择。忽略教育背景或证书获取的日期。如果简历中没有明确说明开始工作的年份,可以选择“none”。"
      "criteria_source": "years_found_in_resume",
      "_build": "在需要评估时,会从resume_text文本中使用正则表达式提取出所有4位数的年份,去除重复项后按升序排序,然后将这些年份作为选项列出,每个选项的描述都设置为“none”。如果没有找到符合条件的年份,那么就跳过最早开始工作的年份相关的问题,并将years_of_experience这一评估项标记为“无法计算”。"
      "fixed_options": {
        "none": "简历中没有明确说明开始工作的年份。”
      }
    },
    "earliest_role_start_month": {
      "type": "choice",
      "instructions": "在候选人的工作经历中,哪个月是他们开始第一份全职工作的月份?如果只提到了年份或者没有提到开始工作的具体月份,可以选择“none”。"
      "criteria": {
        "january": null,
        "february": null,
        "march": null,
        "april": null,
        "may": null,
        "june": null,
        "july": null,
        "august": null,
        "september": null,
        "october": null,
        "november": null,
        "december": null,
        "none": "只提到了年份,或者没有提到开始工作的具体月份。”
      }
    }
  }
}

仓库中包含了三个文件:`CLAUDE.md`、`screening-criteria.default.json`和`PROMPTS.md`。

使用这些提示功能的前提条件:需要先全局安装TypeSafe的代理技能。这样,Claude Code才能使用之前介绍过的那些基本数据类型。

claude plugin marketplace add typesafe-ai/skills
claude plugin install typesafe@typesafe-ai

框架搭建

第一个阶段并不会生成任何用户可见的结果。它只是为后续的所有操作建立结构框架,并做出一个对整个构建过程至关重要的决策:在程序启动时,会使用Zod工具验证环境变量,将这些变量分为客户端模式和服务器模式。这样一来,如果尝试将仅适用于服务器的配置信息导入到客户端组件中,构建过程中就会失败,而不会在运行时出现问题。

这种分离机制实际上体现了整个系统的安全架构。Supabase的服务角色密钥以及TypeSafe的API密钥只能从服务器代码中读取,类型系统会确保这一规则得到严格遵守。

请阅读CLAUDE.md文件。目前只需搭建项目框架结构,无需添加任何功能或业务逻辑。

- 创建项目的基本结构:使用TypeScript、App Router、Tailwind框架,设置src/目录,并配置ESLint代码检查工具。
- 安装shadcn/ui组件库,仅安装button、input、label等基本元素。
- 使用supabase CLI初始化数据库环境。确认`supabase start`命令能够正常执行。即使目前supabase/migrations/目录为空,也要创建这个文件夹——因为数据库模式会从第一个提交版本开始就被记录在迁移脚本中,而不会通过手动运行SQL语句来生成。
- 根据CLAUDE.md文件创建项目文件夹结构,并使用.gitkeep文件来保留某些配置文件:
  src/features/{auth,jobs,applications,screening}/{components,hooks,lib,server,types}
  src/lib/env.ts:其中包含经过Zod验证的环境变量,这些变量被分为客户端模式和服务器模式,因此无法将服务器端的配置导入到客户端组件中。涉及的变量包括:
    NEXT_PUBLIC_SUPABASE_URL、NEXTPUBLIC_supABASE_ANON_KEY(客户端)
    SUPABASE_SERVICE_ROLE_KEY、TYPESAFE_API_KEY、TYPESAFE_MODEL、AI_GATEWAY_API_KEY(服务器端)
  当这些变量未设置时,TYPESAFEMODEL的默认值为“jev-latest”。
  src/lib/supabase/{client,server,middleware}.ts:这些文件使用了@supabase/ssr库来处理数据库交互逻辑。
- 将.env.example文件提交到仓库,而其他以.env*结尾的文件应被git忽略。
- 在.github/workflows/ci.yml文件中配置代码检查、类型验证和构建流程,确保每次提交代码时都会执行这些操作。同时,再添加一个额外的任务来测试数据库是否正常工作:在本地启动Supabase数据库,执行`supabase db reset`命令,以确保所有的迁移脚本都能按顺序正确应用,然后再运行supabase/VERIFY.sql文件进行验证。如果某个迁移脚本只能在已经迁移过数据的本地环境中正常运行,那么它就不能被视为有效的迁移脚本。
- 数据库也需要一个部署路径。任何需要上传到服务器的代码都必须先执行迁移操作,而且如果迁移失败,绝对不能继续进行部署——否则,新发布的代码将会应用到一个尚未完成数据库迁移的环境中,从而导致生产环境出现故障。请在README文件中明确指出负责执行这些操作的步骤,即使实际的部署流程是在之后才进行的。
- package.json文件中的脚本包括:dev、build、lint、typecheck、db:start、db:reset、db:types。当`npm run lint`、`npm run typecheck`、`npm run build`这些命令都能成功执行,并且`supabase start`命令也能正常运行时,说明搭建工作已经完成。请展示最终生成的文件结构。

需要注意的还有特征文件夹的结构。src/app/目录中只存放路由配置,没有其他内容。每个功能模块都拥有自己的组件、钩子函数、服务器动作以及相关类型文件,这些文件都存储在src/features/<名称>/目录下。因此,当第六项任务中的筛选逻辑发生变化时,只需要修改其中一个目录即可。

需要注意的事项:执行supabase start命令时,必须确保Docker已经运行。如果这个命令失败,那么几乎可以肯定就是这个原因导致的。

模式与行级安全性

在这里,“应用程序的结构”中所描述的数据模型会被转换成SQL语句,同时应用程序的安全性也会在这个阶段被确定下来。提示信息中有两点需要特别注意。

首先,数据库模式必须与screening-criteria.default.json文件的内容完全匹配,包括那些Jev根本看不到的derived类型标准。提示中明确强调了这一点,因为在编写数据迁移脚本时,很容易让人误将所有标准都设置为“分数”类型。

type列有四种取值,而criteria列的数据类型为jsonb,因为其具体格式取决于所对应的标准类型:对于“分数”类型的标准,criteria列会存储一个字符串数组;对于“选择”类型的标准,它会存储一个对象;而对于derived类型的标准,它会存储一系列数值阈值。

其次,提示要求编写一个VERIFY.sql文件,这个文件的作用是“验证”数据库的安全性模型,而不是简单地声明其安全性。所有表格都启用了行级安全性机制,没有任何策略会赋予匿名用户任何权限,而且“简历”数据桶也是私有的。在第九项任务中会再次执行这个脚本,如果有人质疑是否可以将候选人的信息存储到这个系统中,我就会用这个文件作为依据来说明系统的安全性。

请阅读CLAUDE.md文件,并查看仓库根目录下的screening-criteria.default.json文件。这才是这款应用程序实际使用的题库,数据库模式必须与它完全匹配,包括那些derived类型的标准以及system_questions数组。

在supabase/migrations/目录下编写有序的SQL脚本文件:

profiles
  id uuid PK references auth.users(id) on delete cascade
  full_name text
  created_at timestamptz default now()

jobs
  id uuid PK default gen_random_uuid()
  title text not null
  description text not null            -- 这里填写的是职位描述;这些信息会传递给Jev进行处理
  status text not null default 'open' check (status in ('open','closed'))
  created_by uuid references profiles(id)
  created_at timestamptz default now()

job_criteria                           -- 这个职位对应的题库
  id uuid PK
  job_id uuid references jobs(id) on delete cascade
  key text not null                    -- 这个键值对必须是唯一的,且适合用于URL路径
  label text not null
  type text not null check (type in ('score','noul','choice','derived'))
  instructions jsonb not null          -- 对于Jev能够处理的类型,这个字段可以是一个字符串或对象;
                                       -- 对于derived类型的标准,其值可以是{ "source": "<系统题组>" }
  criteria jsonb                       -- 对于“分数”类型的标准,这个字段是一个包含2到10个等级字符串的数组;
                                       -- 对于“选择”类型的标准,这个字段是一个对象,其中键表示选项,值表示对应的描述;
                                       -- 对于noul类型的标准,这个字段是一个可选的对象,值为true或false,否则为null;
                                       -- 对于derived类型的标准,这个字段是一个包含升序排列的数值阈值的数组
  weight numeric not null default 1 check (weight >= 0)
  include_in_composite boolean not null default true
  sort_order int not null
  unique (job_id, key)

applications
  id uuid PK
  job_id uuid references jobs(id) on delete cascade
  candidate_name text
  candidate_email text
  candidate_phone text
  resume_path text not null            -- 存储简历文件的路径
  resume_text text                     -- 为了便于重新筛选,这个字段会进行缓存处理,避免再次解析简历文件
  page_count int
  status text not null default 'uploaded' check (status in ('uploaded','parsing','parsed','screening','screened','failed','shortlisted','rejected'))
  error_message text
  created_by uuid references profiles(id)
  created_at timestamptz default now()

screenings                             -- 每次运行测试都会生成一行记录;这个表是只支持追加操作的 historiy 数据表
  id uuid PK
  application_id uuid references applications(id) on delete cascade
  model text not null                  -- 来自响应系统的版本号,例如'jev-1.13.0'
  composite_score numeric              -- 分数范围为0到100,这个值是由我们的代码计算得出的
  min_confidence numeric               -- 在“分数”、“选择”以及derived类型的标准中,最低的置信度值
  needs_review boolean not null default false
  raw_response jsonb not null          -- 完整的Jev响应结果,用于审计用途
  system_answers jsonb not null        -- 包括“is Resume”和“earliest_role_start”这两个类型的答案
  input_tokens int
  output_tokens int
  latency_ms int
  created_at timestamptz default now()

screening_answers                      -- 按照每个标准类型整理后的结果
  id uuid PK
  screening_id uuid references screenings(id) on delete cascade
  criterion_key text not null
  label text not null
  type text not null check (type in ('score','noul','choice','derived'))
  raw_score numeric                    -- “分数”类型的标准:Jev给出的得分值
  max_score numeric                    -- “分数”类型的标准:等级字符串数组的长度减1
  noul numeric                         -- noul类型的标准:0到1之间的数值
  choice_value text                    -- “选择”类型的标准:选中的选项对应的键值
  probabilities jsonb                  -- “分数”和“选择”类型的标准:概率分布信息
  derived_value numeric                -- derived类型的标准:计算得出的最终结果值
  derived_level int                    -- derived类型的标准:满足哪个阈值的索引编号
  normalized numeric                   -- noul类型的标准:这个字段值为null
  weight numeric
  included_in_composite boolean not null
  confidence numeric                   -- noul类型的标准:这个字段值为null,因为Jev不会返回置信度信息
  band text check (band in ('strength','neutral','gap'))  -- choice类型的标准:这个字段值为null

此外:
- 当auth.users表中有新记录插入时,会自动触发相应的操作,从而在profiles表中添加一条新的记录。
- “resumes”数据桶是私有的。
- 所有六个表格以及storage_objects对象都启用了行级安全性机制,具体的策略配置详见CLAUDE.md文件。
- 通过CHECK约束或触发器来确保:当类型为“choice”时,include_in_composite字段的值必须为false;
- 通过CHECK约束来确保:当类型为“score”时,criteria数组的长度必须在2到10之间。
- 创建了以下索引:applications(job_id, status)、screenings/application_id, created_at desc)、
  screening_answers(screening_id)、job_criteria(job_id, sort_order)。
- 还创建了一个视图或索引,用于查询“每个应用程序的最新筛选结果”;这个查询不能使用逐行扫描的方式来执行。

supabase/seed.sql:
- 包含一个人力资源用户的配置信息模板,以及一份职位描述为“高级产品工程师”的职位信息,其对应的题库标准则是从screening-criteria.default.json文件中的criteria数组中按顺序复制过来的。system_questions这些题目并没有被直接添加到job_criteria字段中,而是存储在代码中。

supabase/VERIFY.sql: - 确保所有表格都启用了行级安全性机制。 - 确保没有任何策略会赋予匿名用户任何权限。 - 确认“简历”数据桶不是公共访问的。 运行这个脚本后,请将输出结果展示给我看。 最后,在src/lib/database.types.ts文件中执行`supabase gen types typescript --local`命令。 当执行`supabase db reset`命令后一切正常,并且运行VERIFY.sql脚本也没有出现问题时,说明准备工作就完成了。

screenings表格按照惯例仅支持追加数据:应用程序中没有任何操作会删除该表格中的任何一行。重新进行筛查时,只会向表格中添加新行。这种审计追踪机制并不会产生任何成本。

有一个索引需要特别注意。applications表格是按照综合得分进行排序的,而“每个应用程序的最新筛查记录”这一查询方式,如果不小心操作的话,就很容易变成针对每一行的子查询。系统提示要求使用某种视图或索引来实现数据连接。

需要注意的事项:当约束条件为type = 'choice'时,include_in_composite = false这一设置是必须遵守的。如果忽略了这个约束,那么“Choice”类型的数据就可能会以任意数值的形式被纳入综合得分的计算中,而后续的处理流程却不会发现这一点。

认证机制

这是最简单的一项任务,同时也是其中明确禁止进行某些操作的环节。系统提示列出了四项绝对不允许开发的内容,然后强调:如果发现自己正在开发这些内容,就必须立即停止。

请阅读CLAUDE.md文件。

仅支持登录功能,不允许提供注册路径、注册界面、自助密码重置功能或邀请注册流程。如果发现自己正在开发这些内容,必须立即停止。

- `src/features/auth/`文件夹:包含登录表单(电子邮件+密码)、服务器处理逻辑以及Zod验证机制。
- 路由组“(auth)”下的 `/sign-in` 路由。
- `proxy.ts`文件:用于刷新会话状态,将未登录用户重定向到 `/sign-in` 页面,将已登录用户从 `/sign-in` 页面重定向出去。
- 应用程序的布局文件:需要在服务器端进行会话状态检查,切勿仅依赖中间件来处理认证逻辑。
- 注销功能。
- 应用程序外壳界面:在页头显示用户资料中的全名,并提供注销按钮。

还需要添加一个README文档,说明管理员如何通过Supabase控制面板创建用户,以及用户的资料记录是如何由数据库触发器自动生成的。

完成标准如下:在本地Supabase Studio中创建一个用户,登录后访问受保护的页面,然后注销账户,确认系统确实没有提供注册功能。最后使用grep命令检查系统中是否确实不存在任何注册路径。

## 本地测试用户

`supabase/seed.sql`文件只会创建一个账户,仅此一个:

    admin@admin.com / admin123

这个账户仅适用于本地开发环境。Supabase CLI为本地开发环境预设了JWT密钥,因此`supabase db reset --linked`命令在部署到生产环境的项目中不会尝试创建这个账户,而是会给出相应的提示信息。用户的资料记录是由`on_auth_user_created`触发器生成的,而不是来自那个测试种子文件,这意味着每次执行`supabase db reset`命令时,都会重新验证这个触发器的正确性。

注意:系统中不会预先生成任何工作任务、筛选条件或申请记录,这些内容都是通过应用程序来创建的。

这里有一个设计原则很容易被忽略。对于仅有三个用户的内部工具来说,提供注册页面、邀请注册流程或密码重置功能其实只会增加安全风险,而这些功能并不会带来任何实际的好处。管理员应该通过Supabase控制面板来创建用户,而用户的资料记录则由数据库触发器自动生成。就这样简单完成了。

还有另外一条内容值得反复阅读:不仅要使用中间件来保护路由,还必须在应用程序的布局文件中加入服务器端的验证逻辑。中间件主要负责处理边缘情况,但在某些涉及缓存路由的特殊情况下,它们可能会被绕过;而服务器端的验证逻辑则会在每次页面渲染时都被执行。采取这种双重防护措施,所付出的代价仅仅是一次函数调用而已。

需要注意的事项: “完成条件”要求通过grep命令验证确实不存在任何注册路径。请自行运行该命令进行检测。

职位信息与标准编辑器

这是人力资源部门工作人员Jev用于编写评估问题的界面,因此这个界面的设计直接决定了非工程师用户是否能够使用这套评估系统。

系统将这个界面称为应用程序中最重要的用户界面,确实如此。Jev所创建的所有评估问题都取决于在这个编辑器中输入的内容。如果设定的评分标准不明确,那么最终的评分结果也会不准确;而权重设置不当,则会严重影响对所有候选人的评价结果。

请阅读CLAUDE.md文件以及screening-criteria.default.json文件。

在src/features/jobs/目录下有以下结构:
/jobs
  - 列出所有职位信息:标题、状态、申请人数、创建日期
  - 创建职位页面:显示职位标题及描述。在创建新职位时,需将screening-criteria.default.json文件中`criteria`数组中的所有内容按顺序复制到该职位的`job_criteria`数组中,但请不要复制`system_questions`字段。
/jobs/[jobId]
  - 职位详情页面:显示职位标题、状态切换选项、可编辑的职位描述
  - 标准编辑器:这是应用程序中最重要的用户界面,人力资源工作人员在这里编写评估问题。该编辑器需要支持以下四种类型的标准:
      - **评分标准**:按顺序排列的等级列表,支持添加/删除/重新排序,等级数量为2到10级(API限制为10级;请在编辑器中严格遵守这一限制);
      - **简单陈述**:仅包含一个描述性语句,可附加“正确”或“错误”的说明;
      - **多选选项**:提供键值对形式的选项,选项数量为2到10个;
      - **衍生标准**:包括阈值(递增顺序)、权重以及是否纳入综合评分的设置。这些数据的来源信息仅用于展示,系统会根据Jev在简历中标注的信息计算出最终得分。
  - 对于每个评估标准,还会显示以下信息:键名(必须保证唯一性且不会与网址中的路径冲突)、标签、类型、使用说明、具体标准内容、权重、是否纳入综合评分以及排序顺序
  - 对于多选类型的评估标准,需要特别标注“不得纳入综合评分”并禁用相应的选择按钮,并附上解释说明,告知用户多选选项代表的是不同方面的信息,而非具体的得分。
  - 系统会以百分比的形式显示各项标准的实际权重分布情况,这样人力资源工作人员在开始筛选简历之前就能清楚地了解自己设定的权重分配规则。
  - 提供内嵌提示:评分标准的等级设置必须具有描述性,并且必须按照从低到高的顺序排列,同时需要给出“正确”与“错误”的示例。例如:“能够在实际运行的系统中独立完成某个功能”属于正确的描述;而“6年工作经验”则属于错误的描述。请用一句话解释为什么后者是错误的(Jev无法进行数学运算,因此评价标准应该描述行为而非具体的数量)。
  - 在服务器端以及数据库中,会对这些规则进行验证:
      - 在开始筛选任何简历之前,某个职位必须至少有一个设置为“纳入综合评分”的评估标准;
      - 评分标准的等级数量为2到10级;多选选项的数量也为2到10个;衍生标准的阈值需要是递增顺序的;
      - 所有键名都必须保证每个职位都是唯一的,并且不会与网址中的路径冲突;
      - 人力资源工作人员无法创建新的衍生标准,只能修改已复制的标准;新标准的类型选择框中只提供“评分标准”“简单陈述”和“多选选项”三种选项。
  - Zod已经对所有的服务器端操作进行了验证。请将/jobs/[jobId]目录下的“申请信息”部分保留为空置状态。

在那个提示中,有三项决策直接源自“Jev不能做什么”这一原则。

编辑器能够处理四种类型的规则,而第四种类型——衍生规则——受到了明确的限制:人力资源人员可以调整这些规则的阈值和权重,但不得更改其来源数据,也不能创建新的衍生规则。衍生值是通过代码计算得出的,如果允许人们将这样的规则应用于并不存在的问题上,就会导致筛查机制出现无声的故障。

对于“是否包含在综合评分中”这一选项,系统已经禁用了相应的设置功能,并给出了简短的说明理由。这是架构设计中的限制规定,在用户界面中也得到了体现,因此没有人会疑惑为什么这个开关始终处于禁用状态。

内嵌的指导信息中既提供了正确的示例,也给出了错误的示例来说明什么是合适的评分标准。错误的例子就是“6年工作经验”这一项——系统要求模型用一句话解释为什么这种描述是不对的:由于Jev无法进行数学运算,因此评分标准应该描述行为特征,而不是具体的数字。在开始编写规则之前,人力资源人员阅读这样的说明会非常有帮助。

实际应用的评分权重是以百分比形式显示的,因为权重本质上是相对数值,但人们往往会误以为它们是绝对值。例如,如果你将某一项标准的权重设置为3,而其他项目的权重都设置为1,那么这项标准最终占得分数的43%,而不是三倍。在输入规则内容时观察分数条的变化,有助于让人清楚地理解这一机制。

重要提示:在创建招聘职位时,只需复制criteria数组即可,绝对不要复制system_questions块,因为系统问题是由代码直接管理的。

上传文件与PDF提取功能

这项功能根本不需要使用Jev,而且即使Jev被移除,它仍然会保留在应用程序中。该功能使用了带有签名URL的直接存储上传方式、unpdf工具来提取PDF文本中的内容,同时还通过小型大语言模型获取联系信息字段。这些技术都是Next.js和Supabase平台中的标准功能。

请阅读CLAUDE.md文件。

每次只将一份PDF文件导入到招聘职位的评估流程中。

1. 使用服务器动作生成的签名上传URL,直接将文件上传到Supabase存储系统中。切勿通过服务器动作的响应体来传递文件——Vercel的无服务器平台对数据大小的限制为4.5MB,而这种直接上传的方式可以规避这一限制。文件路径格式为:{job_id}/{application_id}.pdf;仅支持PDF格式,客户端和服务器端都会拒绝其他类型的文件。
2. 服务器动作从存储系统中下载文件,然后使用unpdf工具提取文本:
   ```javascript
   import { extractText, getDocumentProxy } from 'unpdf';
   const pdf = await getDocumentProxy(new Uint8Array(buffer));
   const { text, totalPages } = await extractText(pdf, { mergePages: true });
   ```
   注意:需要将`runtime`环境变量设置为`nodejs`,而不是`edge`。
3. 进行质量检查:如果每页提取到的字符数量低于预设阈值,就将状态设置为“失败”,并向人力资源人员提示该PDF文件看起来像是扫描生成的,需要提供基于文本的评估信息。绝对不要对空白或几乎空白的文本进行评估。
4. 使用Vercel AI SDK和Zod架构,从简历文本中提取候选人姓名、电子邮件地址和电话号码。如果提取失败,这些字段可以留为空值,由人力资源人员后续手动填写。注意:此处不要提取日期或其他信息,只提取联系信息即可。
5> 将简历文本和页数信息保存下来,以便日后重新进行评估时无需再次解析文件。
6> 状态变化过程为:上传 → 解析 → 解析完成(或失败),系统会给出相应的用户界面反馈。

在完成上述步骤之后,请编写scripts/audit-extraction.ts脚本(这个脚本是一次性使用的,可以使用npx tsx命令来运行)。在scripts/fixtures目录下放置四份测试用PDF文件:一份是一栏式的标准简历,一份是带有侧边栏的两栏式简历,一份是包含技能列表的简历,还有一份是扫描后的图片格式简历。为这些测试文件生成真实的合成内容,然后分别统计每份文件的字符数量、页数以及前1500个字符的内容。

对于两栏式和包含技能列表的简历文件,需要检查文本的读取顺序是否正确。如果发现读取顺序混乱,请立即停止操作并告知我,不要默默地继续执行后续步骤——因为对于Jev来说,即使文档内容混乱,它仍然会按照简历的结构进行解析,从而导致错误的评分结果。

在开始评估之前,请务必停止所有操作。

上传文件会直接从浏览器发送到存储系统,而不会经过服务器的处理。Vercel的无服务器函数将请求体的大小限制为4.5MB。虽然大多数简历文件的大小都低于这个限制,但设计师的作品集PDF文件往往会超过这个上限。使用签名URL机制可以避免这一限制,同时也能提高用户的体验,因为文件只需要传输到一个地方即可。

我们选择使用unpdf工具来进行PDF文件的提取处理,因为它是基于PDF.js开发的无服务器版本,且不需要任何本地依赖程序。另一个常用的替代方案是pdf-parse,但它只能在本地环境中运行,在Vercel平台上则会出现问题——它会引入一个可选的canvas组件,而文件追踪系统往往会忽略这个依赖。这就是典型的“在自家机器上可以正常运行,但在其他环境下就会出问题”的情况,相关的GitHub问题也有很多。

质量检测这一环节其实比看起来更重要。如果简历被扫描成图片格式或以图像形式导出,那么提取出来的文本几乎会是空的。系统明确提示不要对内容几乎为空的文本进行进一步处理。如果没有这个检查机制,Jev系统很可能会将这样的文本判定为“有效结果”,从而导致最终的结果与其他正常结果没有区别。

AI Gateway的功能仅限于处理联系信息相关的内容。系统也明确要求不要从这些数据中提取日期信息,其原因在筛选引擎的相关说明中有详细解释。Jev系统会将日期数据视为“选择题选项”来处理,而不是让生成模型来处理这些数据,因为我们的目的是测试Jev系统的识别规则是否有效。

提示中的最后一部分内容实际上属于审计环节,并非一项功能。我们会使用四份格式固定的PDF文件来进行测试,其中包括具有两列布局的简历文件和表格文件。在提取文本时,只会打印前1,500个字符。PDF.js会按照内容流的顺序而不是视觉显示的顺序来返回文本数据,因此对于具有两列结构的简历文件,提取出来的文本可能会看起来杂乱无章,但模型仍然能够识别出这些文本属于简历格式。

如果出现了这种情况,系统要求我们立即停止操作并报告问题,而不是试图绕过这个限制。在构建过程中,这是唯一一个我故意让模型出现错误的地方。

需要注意的一点是:在设置提取路径时,必须将export const runtime = 'nodejs'这一配置项设置正确,因为unpdf工具不支持边缘运行环境。

筛选引擎

到目前为止,手册中的所有内容最终都会汇聚到这个环节。在这里,工作的要求会被转化为Jev系统能够理解的形式,用户的回答会被转换成分数,而那些与日期处理相关的问题也会得到解决。

系统会首先提示模型在编写任何处理响应结果的代码之前,先阅读TypeSafe的API参考文档和JavaScript SDK的使用说明。这样做并不是为了形式上的谨慎,而是出于实际的需要。此前这个手册中提供的示例代码中,响应数据的格式就是错误的,因为我是凭记忆来编写的。probabilities这个变量实际上应该是一个以字符串为键的映射对象,而不是数组,这一点是我通过阅读参考文档才意识到的,模型也是根据这些说明来进行处理的。

请阅读 CLAUDE.md 和 screening-criteria.default.json 文件。随后,查阅 https://docs.typesafe.ai/sdk/javascript.md 以及 https://docs.typesafe.ai/api.md,确认在编写任何用于获取答案的代码之前,响应数据的格式以及 SDK 的访问方式是否正确。切勿盲目假设。

src/features/screening/ 目录下包含以下文件:

lib/scoring.ts — 这些函数都是纯函数,不涉及任何输入输出操作,参数 “today” 会被传递进来。其中包含以下方法:
  - normalizeScore(score, levelCount)、normalizeNoul(noul)、normalizeDerived(level, count)
  - computeDerived(sourceAnswers, thresholds, today):对于 “years_of_experience” 这一评估指标,该方法会计算从最早开始工作的年份/月份至今所经过的年数,并确定满足的最高阈值的索引;“confidence” 则是两个来源答案中置信度较低的那个值。如果来源答案为 “none”,或者某些问题被跳过了,该方法会返回 null。
  - composite(rows)、band(normalized)、minConfidence(rows)、noulNeedsReview(noul)
  这些函数构成了可审核的核心部分,因此应该保持其结构简单且易于理解;相关计算公式应记录在头部注释中。选择题不会被纳入综合评估结果中;“noul” 参数会以其原始的 0..1 值参与计算;“score” 参数则会以 score / (levels.length - 1) 的形式参与计算;而 “derived” 参数则会以 level / (thresholds.length - 1) 的形式参与计算。

lib/years.ts — 这个文件也是纯函数,它会从 resume_text 中提取所有四位数的年份(如 19xx 或 20xx),去除重复项后按升序排序,并将结果以字符串数组的形式返回。这些年份信息会被用于填充 earliest_role_start_year 的选项。

lib/questions.ts — 此文件用于构建 Jev 问题对象:
  - 对于 job_criteria 行中的每一条记录,都会生成一个对应的问题,问题的类型为 score / noul / choice,其中指令和标准会原样被保留下来(字符串依旧是字符串,对象依旧是对象)。
  - 相关的派生行会被忽略,不会被发送到 Jev。

lib/screening-criteria.default.json 中包含系统自动生成的问题:
  - is Resume 总是被设置为 true;
  - earliest_role_start_year 及其选项:这些信息会从 lib/years.ts 中获取年份数据,再加上固定的 “none” 选项;early_role_start_month 的值也会根据实际情况进行设置。如果没有找到相关的年份信息,那么 earliest_role_start 相关的问题就会被忽略。

lib/budget.ts — 此文件用于估算状态所需的令牌数量(chars ÷ 4),并导出一个常量 STATE_TOKEN_BUDGET = 28000。

server/screen.ts — 服务器端的相关逻辑包括:
  - 加载应用程序、职位信息以及评估标准。
  - 状态变量包含 job_title、job_description 和 resume_text。
  - 如果估算出的状态令牌数量超过了 STATE_TOKEN_BUDGET,就会将状态设置为 “failed”,并显示提示信息 “简历内容太长,无法进行筛选(共 N 页,约需要 M 个令牌)”。切勿默默地截断简历内容。
  - 对于每一个问题,都会调用 systemOne 方法,并使用 env TYPESAFE_MODEL 中定义的模型进行计算。
  - 如果 isResume.noul 的值小于 0.5,就会将状态设置为 “failed”,并显示提示信息 “这个文件看起来并不像一份简历”,同时不会对其进行评分。
  - 通过 scoringcomputeDerived 方法计算派生出的评估标准,其中 today 的值为 new Date()。
  - 计算综合评估结果、各个等级的分数以及最小置信度值(对于 noul 来说,其置信度值为 0)。
  - 如果 min_confidence 小于 CONFIDENCE_THRESHOLD(默认值为 0.5,具体定义在某个地方),或者任何包含的 noul 值与 0.5 的差值小于 0.15,又或者无法计算出 years_of_experience 的值,那么 needs_review 变量就会被设置为 true。
  - 将筛选结果保存下来(相关数据包括 response.model、raw_response、system_answers、input_tokens、output_tokens、latency_ms),并且为每一项评估标准生成一条 screeningsanswers 行。
  - 最终将状态设置为 “screened”。

重新筛选时:会重用之前存储的 resume_text,不会重新解析简历内容,而是会创建一个新的 screenings 行。切勿覆盖之前的历史记录。

请将对 Jev 的调用纳入 SDK 的重试机制中。对于 RateLimitError 和 APIConnectionError 这类错误,需要明确地进行处理,并向人力资源部门说明实际发生的问题原因,而不要只是简单地显示一些通用性的提示信息。

验证步骤:请按照以下要求操作并展示结果:
1. 取出作为示例的职位评估标准以及一份简历文本,将其粘贴到 TypeSafe 的测试环境中。
2. 使用该测试环境运行相同的程序。对于每一个问题,Jev 给出的答案必须与测试环境中的结果一致。如果出现差异,说明生成请求的数据有误,请在继续下一步操作之前找出原因。
3. 同时打印出计算出的 years_of_experience 值以及两个来源答案的值,这样我就可以手动检查日期相关的逻辑是否正确。

整个代码库由四个主要模块组成,这些模块构成了代码的核心部分。

scoring.ts是一个纯粹的功能模块。它不处理输入/输出操作,也不使用系统时钟;相反,它会将“今天”的日期作为参数传入。如果你想了解分数是如何计算出来的,那么这个模块就是你需要阅读的部分,而且阅读一遍就应该能够理解其中的内容。

对于评分题来说,其得分会被标准化为“得分除以(级别数减1)”的结果;而对于“Noul”类型的问题,则直接使用它们原始的概率值来进行评分。那些基于特定标准得出的衍生分数也会根据它们所达到的阈值进行标准化处理。选项题并不包含在评分范围内,最终得分是各种分数的加权平均值。整个scoring.ts模块的长度大约为六十行。

years.ts这个模块使用正则表达式从简历文本中提取出所有四位数的年份信息。这些年份会被作为“最早角色开始年份”选项的值,因此Jev可以直接从这些已提取的年份中选择,而无需自行计算。

这种处理方式解决了在“Jev不具有的功能”部分提到的日期相关问题,因为它结合了两种TypeSafe编程模式:首先,候选值会在代码中被预先解析出来,然后让Jev从这些候选值中做出选择。

questions.ts负责整理最终的请求数据。其中包含了类型为“Score”、“Noul”和“Choice”的职位要求信息;而那些基于衍生标准得出的评分信息则被省略了,因为Jev并不会使用它们。

系统还会添加三个与日期相关的问题:会检查`isresume`字段的值,同时也会询问两个与日期相关的选项。如果通过正则表达式没有找到任何年份信息,那么这两个日期相关的问题都会被忽略,同时`years_of_experience`字段会被标记为“无法计算”,这样就会触发应用程序需要人工审核的状态。毕竟,没有任何日期信息的简历是非常少见的,因此必须由人工进行核查。

screen.ts负责处理服务器端的操作。在发送请求之前,它会先检查令牌的使用额度,因为过长的简历文件内容很可能会超过32KB的传输限制,而此时系统自动生成的错误信息会比API返回的信息更有用。它会将所有问题一起通过`systemOne`接口发送出去;如果`isresume`字段返回的值低于0.5,那么该申请就会被直接拒绝,而不会进行评分处理。之后,它会完成数据的处理、保存,并更新相应的状态信息。

置信度逻辑分为两部分,因为Jev对两种不同类型的不确定性采取了不同的处理方式:对于“Score”、“Choice”以及那些基于衍生标准得出的答案,它们都包含一个置信度字段,其中最低的置信度值会与预设的阈值进行比较;而对于“Noul”类型的问题,由于它们没有置信度字段,因此如果其概率值与0.5之间的差距在0.15以内,也会被标记为需要审核。

接下来是验证步骤:需要将相同的简历文本同时在应用程序中和TypeSafe的工具环境中运行,然后检查每个问题的答案是否一致。如果答案不一致,那就说明在构建请求数据的过程中存在错误,你需要找到并修复这个问题后再继续下一步操作。

注意:在screenings这一行中列出的模型应该是从服务器的响应结果中获取的,而不是来自环境变量。你可能请求的是“jev-latest”版本,但最终显示的模型才是真正被用来进行评分的那个。

应用程序的用户界面

有两个界面:一个是位于/jobs/[jobId]的页面,人力资源部门会在该页面上进行排序和筛选操作;另一个是位于/applications/[id]的详细信息页面,在那里可以了解某个数值的具体生成原因。

请阅读CLAUDDE.md文件。

/jobs/[jobId] — 应聘者信息表格(TanStack数据库提供,支持服务器端分页和排序功能):
  列目包括:候选人信息、综合评分、工作经验年限(通过计算得出)、主要技能概况、职业发展经历、当前状态、是否需要人工审核以及创建时间。
  可根据综合评分、工作经验年限或创建时间对表格内容进行排序;也可根据状态、评分范围、是否需要审核或技能概况来筛选数据。
  其中,“选择题”栏目实际上属于分类筛选选项,应将其显示为标签或筛选条件,而绝不能以数字形式呈现。

/applications/[id]:
  - 候选人详细信息,可在线编辑。
  - 各评估项的具体得分情况:
      - 评分题:会以条形图的形式显示得分占满分的比例、对应的等级标签以及置信度;当鼠标悬停或展开选项时,还能看到该等级下的概率分布情况。
      - “Noul”题:会显示其出现概率,其中0.5这个中间值会被特别标出,以示这种类型的题目存在最大的不确定性。
      - 综合计算题:会显示计算得出的最终数值(例如“6.4年工作经验”)、达到该数值所对应的等级、用于计算的两个原始答案以及这些原始答案的置信度。需要说明的是,这些数值是“根据Jev指定的日期通过代码计算得出的”,而不是候选人的实际回答。
      - “选择题”:会显示选中的选项以及相应的概率分布情况,这类信息会与评分部分明确区分开来,并且会特别标注说明它们不会影响最终得分。
  - 候选人的优势与不足:会从各项评估结果中归纳出这两方面的内容。这里不会使用冗长的文字描述,也不会借助人工智能进行总结。
  - 会详细说明综合评分的计算方式,包括各评估项的权重、标准化数值以及其对总分的贡献程度。人力资源部门必须能够清楚地理解为什么某个数值会是这样的结果。
  - 会注明生成当前筛选结果的模型版本。
  - 提供通过临时生成的带签名链接查看PDF文件的功能。
  - 提供将候选人加入候选名单或拒绝其申请的操作选项。
  - 提供重新进行筛选的按钮。
  - 会显示候选人的筛选历史记录,用户可以查看之前的筛选结果。

关于界面提示语的设计规则,请参考CLAUDDE.md文件:“需要人工审核”这一提示语必须明确表示为“置信度较低,需要人工进一步审核”,而绝不能被理解为对候选人不利的信息。因此,在编写相关提示语时请务必遵循这一规定。

在详情页面上,系统会将四种不同类型的答案分别进行显示,因为每种类型的答案代表着不同的含义,因此也需要采用不同的展示方式。

“评分题”会显示候选人在所有评估项中最终达到的等级;“Noul”题会显示其出现概率,其中0.5这个中间值会被特别标出,以示这种类型问题的答案存在最大的不确定性;“综合计算题”会明确说明其数值是根据Jev指定的日期通过代码计算得出的,并会展示用于计算的原始答案;“选择题”则会被单独标注出来,并明确说明它们不会影响最终得分。

对各项评估结果的详细分析才是这个系统提供的最主要解释。由于没有文字说明来解释评分机制,因此具体的数学计算过程本身就起到了解释作用:系统中会显示每项评估项的权重、标准化数值及其对总分的贡献程度,这些信息加在一起就能清楚地了解评分结果的产生过程。测试这一设计的目的在于确保人力资源部门能够根据屏幕上显示的信息手动计算出最终得分。

“需要人工审核”这一提示语使用了特定的表述方式,它明确表示“置信度较低,需要人工进一步审核”,而绝不是对候选人的负面评价。“为什么这种区分如此重要”这个问题的答案就是:这种明确的标注有助于避免误解,确保评估结果的准确性和公正性。

让它看起来像一个工具

在完成第七项任务后,所有的界面都具备了功能,但没有一个看起来是经过精心设计的。当模型独立运行时,它们往往会每次生成相同的用户界面:形状统一的圆角卡片、相同的边框半径、灰色的阴影效果、全部大写的标签文字,以及某种渐变效果。这本身并不一定是错误的,但这只是默认的设计方案而已,而默认设置往往会给人们带来“这是被动生成的”这样的感觉。

第八项任务是对设计进行审查,它的要求与其他任务有所不同。在开始创建任何组件之前,模型需要先提交一份书面的设计计划。之后,模型会将自己所了解的默认设计规范与这份计划进行对比,并等待审批。

一旦模型开始构建组件,它的设计选择就已经确定了,因此要想改变界面的外观,唯一的机会就是在编码开始之前。

请阅读CLAUDE.md文件。

所有的界面都存在且能够正常使用,但没有一个看起来是经过深思熟虑设计的。这项任务是对整个应用程序的设计进行全面审查,而相关的截图将会被发布在freeCodeCamp的文章中,因此评价标准应该是“设计师是否会愿意在自己的作品上署名”,而不是“界面是否整洁”。

不要添加npm依赖项。shadcn组件只是复制而来的代码,并非依赖项——只需添加你实际需要的内容即可。字体设置请参考next/font文档,其他方面不需要做任何调整。

# 适用对象
适合两到三名人力资源人员,他们每天需要使用笔记本电脑多次来完成这项工作,而且这个任务会持续数月时间。这是一个用于辅助决策的工具,而不是用来销售的产品。可以把它想象成一种由有品味的人设计的实验室设备或交易终端:界面设计紧凑、简洁明了,屏幕上的每一个元素都能传递信息,数字本身就是内容,而边框等装饰元素应该尽可能被淡化。

# 实施步骤——请按以下顺序进行操作,并在完成第二步后等待我的审批:
1. 在开始编写任何组件之前,先在DESIGN.md文件中制定设计计划:
   - 颜色方案:选择4到6个有名称的十六进制颜色值。结构部分使用中性色彩;只有三个区域(强度/中性/间隔)、需要审核的状态以及错误提示才应使用具有特定含义的颜色。界面的其他部分都不应该使用这些颜色。
   - 字体:选择同一系列字体,或者选择两种风格截然不同的字体。必须使用表格格式来显示数字(例如`tnum`),因为这个应用程序的主要内容就是数字列表。要为字体设置明确的大小比例;正文行长度应控制在80个字符以内。
   - 布局:每个界面的设计理念应该用一句话来概括,并且需要对/jobs/[jobId]和/applications/[id]这两个页面分别制作ASCII线框图。还要明确对齐规则(数字右对齐,文本左对齐)。
   - 设计原则:用3到5行文字说明为什么这个应用程序的用户界面专门用于简历筛选工作。
2. 在开始开发之前,先根据这些通用设计规范来审查你的设计计划。例如,背景颜色可以是奶油色,并搭配带衬线的字体和赤陶色的点缀元素;也可以选择接近黑色的背景,并添加一些酸味色调的点缀;线条宽度可以非常细,且边框半径应为0;字体样式可以使用SaaS平台提供的标准模板(所有内容都显示在形状统一的圆角卡片中,使用相同的边框半径和灰色阴影效果);标签文字应该全部大写;元数据字符串应使用点号来分隔;小标签文字应使用等宽字体;每个按钮上都应该标有“→”符号。如果你的设计计划中包含了这些元素中的任何一项,那就说明你只是采用了默认设置,并没有根据这个具体任务的要求进行创新。请替换这些默认设置,并说明你做了哪些修改。然后停止操作,把DESIGN.md文件拿给我看。
3. 按照以下顺序逐个开发界面:/applications/[id]、/jobs/[jobId]、/jobs、/sign-in以及上传流程页面。在应用程序详情页面上可以使用粗体字体来突出重点;其他页面的字体风格都应该保持简洁明了。
4. 在完成每个界面的开发后,如果当前环境中有适合截图的工具,就请立即进行截图。如果没有这样的工具,请停止操作并向我请求协助。在继续下一步之前,请用三行文字对截图进行评价:哪些元素值得记住,哪些元素没有实际意义,哪些内容可以删除。

# 各个页面的具体设计要求:
/applications/[id]——这个页面是整个应用程序中最需要重点设计的部分。
  综合评分结果显示在界面的中心位置:每个评估标准都会以表格的形式列出,其中会显示权重、标准化数值以及对该指标的贡献程度,这些数据加起来就能得出最终的综合评分。这种设计方式是有明确目的的,因为招聘决策的制定者需要能够向同事清楚地解释这个评分结果的含义。计算过程应该直观易懂,不需要额外的说明文字。分数条应该显示出该指标在所有评估标准中的相对位置,而不仅仅是一个具体的数值。如果某个指标的得分为0.5,那么这一行也应该被清晰地显示出来。对于那些衍生出的数据,比如“年份”这样的信息,应该用通俗易懂的语言来解释其来源。各种选择选项应该单独列出,让人能清楚地看出它们并不属于综合评分的一部分。每行的设计中都应该包含一个表示信度的元素,这个元素的位置应该固定且大小一致。PDF文件应该放在界面的旁边,而不是下面。

/jobs/[jobId]——这个页面是用于处理求职申请的主要界面。
  首先显示的是申请列表,其次才是评估标准编辑器(可以通过标签页切换或折叠显示)。表格中的数据应该非常详细:数字采用表格格式呈现,小数位数要统一,分数应该右对齐,标题栏可以排序,筛选条件也能显示出当前是否处于激活状态,行高应设置为适合在笔记本电脑屏幕上显示20行数据的大小。需要审核的标志应该低调显示,不会引起不必要的注意。评估标准编辑器的使用体验应该类似于修改评分细则,而不是填写表格:等级应该像梯子一样清晰地呈现出来,权重分配情况也应该以直观的方式展示出来,这样你在输入数据时就能看到权重值的变化。

/jobs——这个页面是一个列表界面,显示职位名称、状态、申请人数以及申请日期。不要把这些信息显示成圆角卡片的形式。

/sign-in——这个页面应该只包含一个字段组和一个按钮,没有任何装饰元素,也不要使用任何插图。

Upload——上传流程中的各个阶段(解析、筛选、审核结果)应该通过状态变化来表示,而不是用旋转图标来展示。失败状态也应该用一句话清楚地说明发生了什么以及应该采取什么措施,而且绝对不需要道歉。

# 在所有页面上都必须遵守的规则:
- 所有标签文字都应该使用大小写混合的形式书写,不要全部大写。如果内容本身已经能够解释某个元素的含义,那么就无需在上面添加额外的标签。
- 动画效果只应该在用户执行了某种操作之后才出现(例如展开某一行数据、确认上传操作等)。页面加载时不应该有动画效果,鼠标悬停在卡片上时也不应该有任何视觉变化。
- 边框半径、阴影效果以及边框的粗细应该用来体现层次结构;如果两个元素采用了相同的处理方式,那么它们就应该属于同一类元素。
- 数字应该采用表格格式呈现,每列的小数位数应该统一,单位信息应该在标题栏中一次性显示出来,而不需要在每个单元格中都重复出现。
- 颜色必须有实际的意义,否则就根本不应该使用它。
- 交互提示语应该使用主动语态,按钮上应该清楚地说明点击后会发生什么动作(例如“重新筛选”而不是“提交”),系统发出的提示信息也应该使用相同的动词。空状态也应该明确告诉用户接下来该做什么。错误信息应该说明出了什么问题以及如何解决它。
- 无论在哪个页面上,都应该保证基本的可用性:页面应该在768像素的屏幕上能够正常显示,键盘焦点应该能够被清晰地识别出来,应优先考虑降低动画效果以提升用户体验,任何文本与背景的组合都应满足AA级对比度标准。

# 完成任务的标准:
- DESIGN.md文件已经制定完成并且得到了我的批准。
- 每个页面的截图都经过了根据上述评价标准进行的审查。
- 颜色方案中的所有元素都只具有功能性,没有任何装饰作用。
- 我能够阅读/applications/[id]页面上的综合评分结果,并且能够根据屏幕上显示的信息手动计算出这个数值。

这份任务要求故意设置得较为简单。这种工具是两三个人每天都会使用数月的工具,其核心内容就是各种数字;而颜色则仅起到某种标识作用,有时甚至完全不起任何作用。只有一个屏幕用于展示各项数据的综合分析结果,这些部分需要用粗体来突出显示,而其他所有信息则都应该保持简洁明了。之所以需要使用表格形式来呈现数据,是因为在成绩统计栏中,如果各数值的比例关系不正确,人们虽然能感觉到这种不对劲,但却无法具体说明问题出在哪里。

强化措施与尚未执行的部署流程

最后这项任务几乎不会产生任何可见的结果,因此很容易被忽略;这也是为什么它会被单独列为一项任务,并配有专门的提示说明。如果将其放在第八项任务的后面执行,那么模型在完成前八项任务、专注于格式处理工作之后,可能就不会再关注这项任务了。

请阅读CLAUDE.md文件:
- 重新运行supabase/VERIFY.sql命令,修复其中存在的任何问题。
- 所有涉及权限检查的VERIFY.sql命令都必须以应用程序实际使用的角色身份来执行——应使用`set local role authenticated`命令,而绝不能使用postgres账户。因为超级用户可以绕过EXECUTE权限和RLS安全检查机制,所以即使用postgres账户执行这些命令,结果也可能正常显示,但这并不意味着应用程序没有问题。
- 需要验证每一项新的检查机制是否真正有效:先破坏它所检测的对象,然后确认VERIFY命令是否能返回非零结果,最后再恢复系统到正常状态。如果一项检查机制从未出现过错误,那就说明它根本就没有被真正测试过。
- 如果你修改了任何GRANT、REVOKE语句、RLS安全策略或SECURITY DEFINER函数,那么之后必须在浏览器中实际运行相关功能来验证这些更改是否有效——例如创建一个职位申请、上传简历文件、保存筛选条件等。仅仅用postgres账户成功执行SQL命令,并不能证明应用程序能够正常工作。
- 有两条规定很容易被误解:首先,如果CHECK约束语句调用了某个函数,那么该函数会以执行写入操作的角色的权限来进行评估,因此这个角色必须具有EXECUTE权限;而触发器函数则不会受到这种权限限制,因为触发器在创建时就会进行EXECUTE权限检查,而在实际被触发时并不会再次进行检查。因此,你需要明确自己处于哪种情况,而不能盲目猜测。
- 审计方面:需要查找service_role和NEXT_PUBLIC_这些变量是否被错误使用;同时要确认没有任何仅适用于服务器端的环境变量会影响到客户端程序的运行结果。除了源代码之外,还需要检查编译后的输出文件。
- 对于所有的服务器操作,都必须确保在数据进入系统之前经过了Zod验证。
- 每条路由路径都必须要能够正确处理错误情况和空值状态;任何地方都不允许出现“出了问题”这样的简单提示信息。
- 在上传数据、解析数据到屏幕显示的整个过程中,都需要保证系统的状态变化能够被正确记录下来。
- 在README文件中,需要说明安装步骤、环境变量的设置方法、管理员如何创建用户账户、如何编写Jev评估问卷(并提供优秀与不佳评分的示例)、综合评分的计算公式、years_of_experience字段是如何计算的以及为什么不使用这种计算方式、置信阈值的设定方法及修改位置,还要说明已知的缺陷——即扫描后的PDF文件会被直接拒绝而不会被进行OCR识别处理。
- 最后运行npm run lint / typecheck / build clean命令。

然后编写DEPLOY.md文件,但千万不要执行其中的任何命令:这份文件应该是一份按顺序列出的清单,其中包含了创建云平台上的Supabase项目所需的具体命令,比如`supabase link`、`supabase db push`命令,以及创建简历存储桶及其相关配置信息、设置Vercel环境变量等操作。还需要包含关于模型固定的相关内容:在生产环境中,应将TYPESAFE_MODEL设置为版本号对应的ID(目前是jev-1.13.0),而不是使用jev-latest这个别名,并解释为什么这样设置(因为别名可能会发生变化,而且针对某个版本调整的阈值在下一个版本中可能不再有效)。还需要编写.github/workflows/deploy.yml文件(当代码被推送到main分支时,Vercel CLI会自动执行该文件中的命令),并在DEPLOY.md文件中列出所有需要的仓库密钥。

在针对云平台上的Supabase或Vercel环境执行任何操作之前,请务必先得到我的批准。
如果有些内容你不得不留作未完成状态,或者你对某些设置进行了修改但并没有进行端到端的测试,请一定要报告出来。如果在评论或提交信息中提到了Postgres的某种行为特性,请说明你是如何验证这一点的——否则就不要随意做出这样的断言。
这里会发生四件事: VERIFY.sql脚本需要再次运行。在第二个任务之后,共有七个会话对数据库进行了修改,其中任何一个会话都可能添加了没有使用RLS机制的表格,或者添加了权限设置过于宽松的表格。因此,在架构设计阶段通过的检查,需要在最终确定的架构中再次进行验证。

<实际生成的输出文件会被检测其中是否包含敏感信息,而不是源代码本身。在第一个任务中划分的环境变量应该能够确保仅服务器端使用的变量不会影响到客户端组件,但“应该”这样的说法并不能作为绝对的保证。最终的检查还是针对实际发布的最终产品进行的。

<所有服务器操作在进入执行阶段之前都会接受Zod验证。这种规则在第二个到第五个任务中都能得到严格遵守,但在第七个任务中却出现了问题——因为该任务中的模型在处理表格字段时,没有对输入数据进行有效的验证。

<文件《DEPLOY.md》已经编写完成,但尚未执行。其中列出了详细的操作步骤:创建云端的Supabase项目、链接相关资源、推送数据迁移脚本、创建用于存储简历的文件夹及其权限设置、配置所有Vercel环境变量,然后执行首次部署操作。提示中明确要求在对云端环境进行任何修改之前,必须先停止当前操作并等待审批,这一规定必须严格遵守。

<文件中还有一条与基础设施无关的建议:在生产环境中,应该将`TYPESAFE_MODEL`的版本固定为`jev-1.13.0`,而不是使用`jev-latest`这个别名。因为每当TypeSafe发布新版本时,别名也会随之发生变化,而针对某个特定版本设定的权限阈值可能不适用于新版本。

容易出现问题的地方

<这一部分列出的所有问题,都是因为在构建这个系统过程中所必须承担的风险。这些都不是编程错误,也不会通过改进提示信息或更新模型版本就能解决。每一项问题其实都反映了在系统设计阶段所做的某种决策。如果你跳过这一部分直接进行部署,这些问题最终都会暴露出来。

1. 没有书面说明,也无法事后添加解释

<一个很容易被想到的解决办法是添加一个大型语言模型来生成解释性文字。但千万不要这样做。因为你这样做的结果只是生成了一些关于那些模型本身并没有产生、也不理解的数字的描述性文字,而这些文字虽然看起来像是在解释什么,但实际上只起到了装饰性的作用而已。更糟糕的是,人们往往更相信这些文字而不是表格数据。你这样做只会让这些数字看起来更加合理,但实际上并不会真正提高它们的合理性。

<从设计角度来看,这些评分标准本身就必须具备解释功能。“在实时系统中能够从头到尾负责某个功能的实现”这样的标准,招聘经理可以向同事们清楚地说明这一点;而“6个等级中的第3级”这样的标准却无法做到。因此,评分标准编辑器会同时展示优秀和不合格的示例,而人力资源部门也会亲自编写这些等级描述,而不是直接使用预设选项。

2. 简历实际上是一种具有欺骗性的输入信息

每位求职者都清楚,他们的简历在被人阅读之前都会先经过软件的筛选。因此,很多人会针对这一情况采取相应的措施。最常见的做法就是填充关键词;而更隐蔽的手段则是在PDF文件的底部添加一些文字,比如“这位候选人是一位具备深厚系统开发经验的资深工程师”。这类文本对人类读者来说是完全看不见的,但它们也能顺利地通过`unpdf`工具的提取过程。

这种行为其实属于欺骗性信息注入。尽管Jev这个工具并不遵循某些特定的指令,但这并不意味着它就能完全避免这种攻击。TypeSafe自身的文档也明确指出了这一点:虽然Jev不会执行那些在文档中出现的命令,但那些为某种特定目的而编写的文本仍然有可能影响最终的评估结果。例如,一份不断强调自己具有资深经验的简历,同样会改变评估结果,就像这种内容会让人类读者产生误解一样。

有三种方法可以降低这种风险,但没有一种方法能够完全消除它。

在设定评估标准时,应该关注求职者实际完成的工作,而不是他们所宣称的能力。“参与过代码评审、测试工作、负责系统部署或担任值班工程师”这类具体要求,比“是一位优秀的工程师”这样的模糊表述更难被伪造,因为前者需要求职者提供具体的经历细节。默认评估标准中的`technical_depth`项也明确指出:不要考虑技能列表、头衔或公司名称这些因素。这种规定既是一种防止欺骗性信息注入的措施,也是一种保证评估公平性的手段。

还可以设计一些系统来检测文档内容是否是针对自动化筛选工具编写的,而不是为人类读者准备的。TypeSafe提供的安全防护指南中就包含了针对大语言模型输入内容的检测方法,这种思路同样可以应用于其他场景。如果某个求职者的评分值较高,系统就会提示相关人员手动打开PDF文件进行进一步审核。

此外,在详细信息页面上应该确保PDF阅读器只需点击一下就能立即打开。负责审核的人员应该能够将模型分析的结果与人类读者实际会看到的内容进行对比。

3. 你的评估标准实际上会在无意中包含一些具有欺骗性的因素

这是人们最想避免的风险,因此在这里我们会花更多的时间来讨论这个问题。

再来看一下默认的评估标准。`years_of_experience`这一项会惩罚那些有职业空档期的求职者,因为这种空档期往往与照顾家人、生病、移民或在经济衰退时被解雇等原因有关。《open_source_contribution》这一项则会奖励那些有时间在GitHub上参与开源项目的人,而`mentorship_demonstrated`则适用于那些在规模较大的公司中担任过导师职责的求职者。这些评估标准中没有任何一项会直接提及任何受保护的身份特征,但它们都会与某些这类特征相关联。

一个评估标准并不一定需要明确指出某个特定群体来对其进行不利评价,它只需要奖励那些与该群体无关的因素即可。这就是所谓的替代性因素,而所有现有的筛选标准中都包含这类因素。

与其他工具相比,这个平台在处理这个问题上做得更好,我们有必要详细说明原因。这些评估标准是以表格的形式存在的,其中包含了权重信息,并且这些数据都处于版本控制之下。你可以随时查看这些标准,也可以对比不同版本的差异。甚至你还可以将某个权重的值设置为0,然后重新对所有求职者进行评估,看看排名会如何变化——整个过程只需要几秒钟而已。

对于大型语言模型而言,它并不会提供任何这些功能。无论什么奖励机制存在于其中,要想了解这些机制的具体内容,唯一的办法就是对其进行深入探究。

但是,可审计并不意味着公平。仅仅能够看到“工作年限”这一因素在评估中的权重,并不能说明这种评估方式是否会对某些人造成不利影响。要判断这一点,就需要具体的结果数据:哪些人被列入候选名单,哪些人最终获得了录用机会,而这些数据需要根据你有权且能够测量的各种标准来进行分类分析。如果你无法获取这些数据,那么至少应该让非工程师人士来仔细审视这些评估标准,询问他们每个标准实际上代表什么意义。

这里有两点与法律相关的内容需要说明,我会尽可能直截了当地表达出来。根据欧盟的《人工智能法案》,那些用于筛选求职申请的人工智能系统被视为高风险工具,因此使用这些系统的机构必须承担相应的责任。纽约市则要求,任何在招聘过程中使用的自动化决策工具,在投入使用之前都必须接受独立的偏见审计。如果你的招聘对象位于上述任一地区,虽然解决这些问题并非本教程的职责范围,但本教程有义务告诉你这些规定的存在。

4. 人工审核是一项硬性要求,而系统界面必须确保这一要求能够得到落实

该招聘系统中没有任何机制会直接拒绝任何求职者。这个工具只是对所有申请材料进行重新排序,最终由人类来做出决策。这并不是在逃避责任,而是这种系统的设计架构以及前面提到的信任机制,使得这一流程不仅仅是一句口号而已。当系统对某项标准的评估结果信心不足时,它会将相关申请转交给人工审核,而永远不会直接拒绝求职者。

然而,还有一种比直接自动拒绝申请更为隐蔽的问题需要警惕。一旦某个数字出现在屏幕上,人们往往会盲目地相信这个数字所代表的结论。例如,如果一份简历旁边标注着“43分”,那么负责招聘的人员可能会草率地阅读这份简历,因为这个分数已经告诉他们这份简历的优劣了。这种现象被称为“锚定效应”,它会使原本需要人类参与决策的过程,变成一种机械性的、缺乏主观判断的流程。

该招聘系统中的评估反馈信息是具体而详细的。系统会展示各项评分的具体数值,而不仅仅是总分,这样招聘人员就能清楚地知道是哪些方面得分较低,从而有针对性地对某些评估标准提出异议,而不是对整个评分结果表示质疑。“需要重新审核”的标记只是用来提醒相关人员注意这些问题,并不意味着候选者存在缺陷。如果需要修改评估结果,只需点击一下即可完成操作,而且这些修改记录也会被保存下来,因此你可以了解到人力资源部门在多大程度上会反对系统的自动评估结果——这个数据目前还是我们最缺乏的。

如果系统被修改的频率接近于零,这并不意味着该模型本身很好,而恰恰说明没有人真正去检查过它的运行情况。

首次测试的结果

Jev系统于9月15日正式上线。9月22日,我在一个下午的时间里,使用已经完成的招聘系统,对71份历史简历进行了测试,这些简历涉及两个不同的职位,也采用了两种不同的评估标准,总共进行了80次筛选操作。这些数据属于系统运行过程中的实时监测结果,并非最终的招聘结果。真正的招聘结果需要数月时间才能得出,等有相关数据时,我会更新这一部分内容。

各项数据如下:

参与评估的模型版本

每次筛选所输入的标记数量

每次筛选的成本

处理350份简历一周的总成本

延迟时间的中位数

延迟时间的p90值、p95值及最大值

1.47秒、1.53秒、4.53秒

综合评分的范围

14分至82分,平均分为45分

被标记需要人工审核的简历

years_of_experience这一指标目前无法计算

上传的简历数量

两个职位共75份简历

被扫描PDF系统拒绝的简历

3份(4%)

已进行的筛选次数

80次,其中包括在调整标准后重新进行的8次筛选

jev-1.13.0,每次评估都使用该模型

中位数为4,644个,范围在3,000至6,821个之间

平均成本为0.00019美元,最高成本为0.00029美元

大约为7美分

400毫秒

80份中的48份(60%)

80份中的14份(17.5%)

还有两个数据暂时没有记录,因为它们还需要一段时间才能获得:一是人力资源部门在多大程度上会推翻系统的评分结果;二是排名靠前的候选人是否确实都是被成功录用的对象。第一个数据需要经过数周的实际使用才能得出结论;而第二个数据则需要等到有具体招聘结果的角色被评估完毕之后才能获取。

这些数字的含义

1. 成本并非决定性因素。

以每百万输入标记0.042美元的计算标准,一周内处理所有简历的成本还不到一杯咖啡的价格。而且,在调整评估标准后重新对每位候选人进行筛选,这个成本也低到完全可以随意进行,这一点改变了人们对优化评估流程的认知。

2. 延迟时间由两个数值组成。

在浦那进行的测试中,服务器处理请求的平均延迟时间为400毫秒,这一数值在TypeSafe规定的范围内。然而,在80次测试中,有15次的响应时间超过了1.4秒至4.5秒,而这些测试对象并不是输入标记数量最多的那些。实际上,输入数据的规模与响应时间的长度并无关联。

那些响应时间较长的情况大多发生在系统处于空闲状态之后,这说明问题出在连接建立阶段,而非模型推理本身。如果系统中设置了加载指示器,那么需要特别注意的是,在系统一段时间没有收到请求后,第一次响应的时间可能会是其他正常情况下的四倍。

3. 审核队列中60%的请求都是针对某些具体方面的评估。

当任何一项评估结果的置信度低于0.5时,系统就会标记该申请为需要人工审核。在80次测试中,有28次的最低置信度评估项是career_progression,15次是primary_talent_profile,这两项都属于选择型评估维度,并不会影响最终的综合评分。

有时候,即使模型无法确定某人的职业发展路径是“稳定”的还是“横向发展的”,也会导致整个申请被标记为需要人工审核。如果只对那些会影响最终综合评分的评估项来计算min_confidence值,那么使用相同的数据时,审核队列中需要人工审核的申请比例会下降到44%。如果你愿意的话,只需在scoring.ts文件中添加一行代码即可实现这一调整。而我在这里保留了最初测试时得到的数据结果。

4. 有一个评价标准对所有人的评分都是一样的。

jd_alignment 对全部46份.NET求职者的简历进行了评估,其评分等级为4级中的第2级,标准差为0.03,平均可信度为0.90。其中处于中间级别的评价结果是“部分符合要求;满足了一些核心条件,但明显也缺少其他一些要素,或者相关证据不够充分”,而这一描述几乎适用于任何一份简历。更高级别的评价要求是“必须完全满足所有核心条件”。由于中间级别的评价标准范围较广,而更高级别的评价标准要求较为严格,因此该模型准确地回答了题目所提出的问题。

从中我们可以得出一个结论:关键在于如何设计这些评价标准——只要阅读每个评价标准的中间级别描述,就能判断出哪些简历不符合要求。

5. 那些没有人能满足的评价标准实际上只是惩罚性条款,并非真正的评估依据。

.NET评价体系的评分范围是36到82分;而另一种评价体系使用相同的模型和公式,其评分范围却是14到61分,因为其中三个评价标准的平均得分低于0.18,且方差几乎为零。

对于每一个评估标准,如果没有人能够满足它,那么这个标准就只会起到惩罚作用,而不会真正作为评价依据。在进行了三十次评估之后,分析这些每项标准的评分分布是很有意义的。

6. 日期格式的要求得到了遵守。

在十四次评估中,有候选者的“起始年份选择”选项显示为“无”。我仔细检查了所有这些案例:有些是只有毕业日期的应届毕业生,有些是化学专业的毕业生申请.NET相关职位,还有一些候选者的简历长达四页,但通过正则表达式分析后,只发现了“2008”、“2012”、“2014”、“2015”和“2019”这些数字——而这些数字实际上都是SQL Server或Visual Studio的版本号,并没有任何与工作经历相关的日期信息。

该模型考虑了五个可能的年份,但最终给出的评价结果仍然是“无”,且可信度高达0.99。这恰恰体现了“What Jev isn’t”这个工具的实际用途:当给定一系列可能误导判断的信息时,它拒绝从中选择任何一个作为评估依据,从而确保了评估结果的客观性。最终,这些评估结果会被交给人工进行审核,而这正是应该做的。

7. 有两种防御机制从未被触发过。

该模型的“令牌保护机制”设定的阈值是28,000个令牌;而最长的那份简历在包含了问题内容和职位描述后,其长度仍为6,821个令牌。所谓“上下文失效”确实是这个模型的一种特性,但对于简历评估来说,并不是一个实际需要考虑的问题。is Resume函数对每一份文档都给出了0.97到0.99的评分,因为所有被检测的文件都属于简历类别。我还没有见过这种情况导致该模型给出错误的评分结果。

What Jev在真实简历上无法做到的事情

这些就是“What Jev isn’t”中列出的限制条件,因为在实际应用中确实会出现这些问题。

该模型无法告诉你为什么会出现这样的结果。具体的评分机制才是解释这一切的原因,而上面的案例也说明了:当评价标准的设计使得所有人的评分结果都相同时,就会出现这种问题。

此外,该模型也无法进行简单的算术运算。虽然日期格式的识别功能是有效的,但如果有17.5%的简历需要人工来确认年份信息,那么就不应该让模型来进行自动判断了。

该系统不会评判你的评分标准。它会将所有符合中等标准的选项都归为这一类别,同时也会将那些几乎不符合要求的条件明确标记为“不符合”,并且每次都会非常确信自己的判断是正确的。它的校准是基于答案本身,而不是问题本身。

该系统也无法识别图片文件。在上传的75份材料中,只有3份是扫描图像,但模型根本无法识别这些图片。

此外,它也无法告诉你哪些人才才是真正优秀的候选人。这才是真正重要的信息,但在系统上线一周后,这些数据仍然无法获取。

何时不应使用此工具

以上所有情况都假设这种评估方法适合你的需求。但以下五种情况下,这种工具并不适用:

  • 法律规定你必须向候选人提供书面的拒绝理由。但该系统无法生成这样的理由。它给出的只是数字表格,而且目前还没有任何监管机构认可这种格式有效。

  • 你每月需要审核20份简历。使用这个工具所花费的成本会高于它带来的节省。相比之下,直接阅读简历会更高效。

  • 你的简历是扫描文件。

    我们的系统中也有4%的简历是扫描格式的,但系统直接拒绝了这些文件。如果你的简历主要是图片形式,那么首先需要使用OCR技术将其转换为可识别文本,而这属于另一个独立的项目。

  • 候选人的简历是用英语以外的语言撰写的。

    Jev系统的准确率在处理英语简历时最高,对于其他语言的处理效果则不尽如人意。

  • 你的团队中没有人真正掌握这些评估标准。

    评分标准本身就是这个工具的核心部分。如果人力资源部门不愿意仔细阅读每一份评估结果,去分析哪些内容不符合标准,那么这个工具就会根据你并未明确设定的标准来对简历进行排序。

总结

该系统的源代码已经放在仓库中,其中包含了重建系统所需的三个文件:CLAUDE.md、PROMPTS.md和screening-criteria.default.json

仓库中的版本是经过简化设计的。我们的人力资源团队正在使用的版本基于相同的筛选引擎和scoring.ts文件,但它会从我们的自动招聘系统中获取简历数据,而不是让用户手动上传;它还会批量处理筛选任务,以便一次性完成多份职位的评估;它会根据职位描述自动生成初始评分标准,然后由人力资源部门进行修改,而不是直接使用默认设置;此外,它的用户界面也是为便于对比不同候选人而设计的。

我没有将这些详细信息包含在内,因为每添加一个功能都会使系统变得更为复杂,而这份手册的重点是介绍这个模型本身,而非系统的实现细节。那个简化版本的系统中,Jev的调用方式或数据计算方法并没有任何变化。如果你已经读到了这里,那么你完全有能力自己构建这个系统。

接下来我们要做的就是验证一个关键点:使用这个系统来处理那些结果已经明确的招聘流程,看看我们实际录用的候选人是否真的处于候选人群体的顶尖位置。这才是真正重要的衡量标准,等这一数据准备就绪后,我会在这里补充说明。

仓库链接:https://github.com/MTechZilla/recruitment-portal

<如果这些内容对你有帮助,或者你发现了任何错误,请随时联系我,我的联系方式是https://x.com/sharvinshah26。

相关文章

技术实践

在旧系统迁移过程中如何使用差异测试方法

在旧系统迁移过程中,最危险的时刻并不一定是在开始编写新实现代码的时候,而是在新实现看起来已经完成的时候。 代码编译通过了,测试也通过了,架构也更加清晰了,新的服务响应速度也更快了…… 然后所有人都会开始问同一个问题: 我们现在可以把流量切换过来吗? 这时,信心就会变得难以建立。 一个新的实现虽然能够通过自己的测试套件,但其行为仍可能与它所要替代的系统有所不同。 也许数值的舍入规则发生了变化,或者对空值的处理方式不同了,又或许某个错误现在被当作成功的响应来处理了…… 也许记录的排序方式改变了,某些副作用出现的顺序也变了,又或许在迁移过程中,一些从未被记录下来的业务规则丢失了…… 正因如此,在进行

阅读全文
技术实践

报告主题:现实世界中的自适应推荐系统:推理机制、评估方法与系统设计

Mallika Rao指出,自适应推荐系统的真正复杂性并不在于其模型架构本身。她阐述了实时反馈机制、数据检索的新鲜度保障、多阶段协调流程以及端到端的延迟控制措施,这些因素如何使系统能够在延迟、成本和可观测性等现实操作限制条件下,在生产环境中持续学习并不断发展。 作者:Mallika Rao

阅读全文
技术实践

iOS NFC使用指南:如何使用React Native读取、写入NFC标签以及锁定这些标签

将iPhone靠近贴纸,就会发生一些奇妙的事情:名片会自动添加到联系人列表中,某个聚焦操作会结束,或者某扇门会自动打开。这种芯片的成本大约为20便士,其存储容量约为130字节。 读取一条NFC信息需要执行两次函数调用;而要获得执行这些调用的权限,则需要花费更长的时间。之后,CoreNFC还会要求你再次完成这个流程。 第一个障碍来自苹果公司:你需要拥有一个付费开发者账户,在某个网站平台上注册应用ID,勾选相关选项,并重新生成配置文件。如果其中任何一步出错,构建过程就会因为代码签名错误而失败,而这些错误信息中根本不会提到“NFC”这个词。 第二个障碍则来自CoreNFC本身,而且没有人会提醒你注意

阅读全文
技术实践

如何使用Python和依赖关系图来检测公共数据集中隐藏的目标泄露现象

不久前,我给一个机器学习模型提供了来自公共CDC数据集的五列数据,让它尝试预测同一文件中的第六列数据。该模型的R²值为0.998,这个数值已经非常接近完美了。 虽然这个结果看起来很成功,但实际上该模型对现实世界的认知几乎没有任何提升——因为CDC本身就是根据其他五列数据计算出了第六列数据的,所以该模型实际上只是机械地应用了CDC的计算方法而已。 数据科学家将这种问题称为 目标信息泄露 ,当输入给模型的数据本身就已经包含了答案的某些信息时,就会发生这种情况。 在公共数据中,这类问题很容易被隐藏起来,因为大量的公共数据都是通过其他公共数据计算得出的。例如,一个政府指数可能是根据调查数据构建的,而另

阅读全文