L2考核落地:人才测评整合为L2考核track,新增查重初筛与e2e验证
- 新增 L2考核 track(7维标准/100分):选题难度赋分cap、功能完整性地板线、合格判定 - 人才测评整合进 L2考核:移除 L2/L3 两级认定与 question_id 机制 - 新增 l2-topics 选题元数据服务与 config/l2-topics.json(11命题题+自选题) - 新增查重初筛 plagiarism-detect(MD5精确比对+归一化相似度+提交行为,仅告警) - 修复 L2 维度 DIM_FILE_FILTERS 缺失导致 AI 协作记录证据漏喂 - e2e:修复 Windows spawn、DB 隔离,L2 用例 27 项全绿
This commit is contained in:
@@ -55,6 +55,7 @@ Phase 3: 确定性校准(computeCalibration) + 硬规则引擎
|
||||
|------|------|
|
||||
| `review.service.ts` (~1500行) | 评审引擎:clone→tryBuild→tryBrowse→AI→校准(computeCalibration)→硬规则→出分 |
|
||||
| `standards.ts` | `parseDimensions` 解析标准 MD 为维度列表 |
|
||||
| `l2-topics.ts` | L2考核选题元数据(11命题题+自选题,`config/l2-topics.json`):难度赋分 cap、功能完整性拆分、验收要点注入 |
|
||||
| `entries.ts` | 条目 CRUD + 触发评审 + PDF 导出 |
|
||||
| `projects.ts` | 项目 CRUD + 汇总排名 |
|
||||
| `db.ts` | SQLite 初始化 + migrations(ALTER TABLE try/catch) |
|
||||
@@ -161,18 +162,19 @@ cloneRepo → discoverFiles → countCodeStats → tryBuild → tryBrowse/trySta
|
||||
- 正则在 `standards.ts`,修复后支持空内容(`content: ""`)
|
||||
- 从 `## 维度名(分数)` 解析,必须紧跟 `\n## ` 或字符串结束
|
||||
- 过滤分区/说明性标题:`评审维度`、`合格判定`、`成果物清单`、`第X部分:` 等不进入维度列表
|
||||
- group 推断:`## [Q2] xxx`(显式)或 `## 2-A. xxx` / `## 2-A:xxx`(人才测评追加维度前缀)→ group=Q2;其余 common
|
||||
- 验证:三个标准模板解析结果应固定为——赛道一 12维/150、赛道二 8维/100、人才测评 common100+maxBonus50/effective150(Q2-Q6 分组正确)
|
||||
- group 推断:`## [Q2] xxx`(显式)或 `## 2-A. xxx` / `## 2-A:xxx`(题目追加维度前缀)→ group=Q2;其余 common
|
||||
- 验证:标准模板解析结果应固定为——赛道一 12维/150、赛道二 8维/100、L2考核 7维/100(全 common);人才测评已整合进 L2考核(2026-08-23),不再单独验证
|
||||
|
||||
### entries 表
|
||||
|
||||
- `UNIQUE(project_id, repo_url)` 约束
|
||||
- `service_url TEXT DEFAULT ''`(migration 添加)
|
||||
- `build_status TEXT DEFAULT ''`(2026-08-18,migration 添加):赛道二/人才测评单阶段人工构建确认,''=未确认(自动构建) / done / failed;赛道一 B 阶段不存此列,走 /verify 请求体
|
||||
- `build_status TEXT DEFAULT ''`(2026-08-18,migration 添加):赛道二/L2考核单阶段人工构建确认,''=未确认(自动构建) / done / failed;赛道一 B 阶段不存此列,走 /verify 请求体
|
||||
- `standard_snapshot` 存标准快照(评审时标准被修改也不影响已评审结果)
|
||||
- `review_snapshots` 存每次评审历史
|
||||
- L2考核专属字段:`selected_topic`(选题 01~11/self,决定 max_score_cap 与难度赋分)、`self_registration`(自选题登记 JSON:登记编号+功能清单快照)
|
||||
- force-review(测试端点)需 `ADMIN_TEST_TOKEN=true` **且** `NODE_ENV=test` 双条件才开启;生产 `node dist/index.js` 即使 flag 泄漏也打不开
|
||||
- **赛道必选(2026-08-13)**:创建/编辑项目必须传 track(赛道一/赛道二/人才测评),缺省返回 400 `赛道为必选项`;前端下拉首项"请选择赛道(必选)",create() 校验 `if (!track) setError('请选择赛道')`。注意:有赛道会自动导入对应默认标准(`projects.ts` POST 里 `loadDefaultStandard`)——创建项目的测试用例必须带合法 track,否则建出的项目无赛道且后续标准解析行为不同。**e2e 陷阱**:依赖"自定义标准快照"的用例(如 `hardcode-fixes.spec.ts` 3.1/3.2)在带 track 创建项目后,自动标准会抢占 `resolveStandard` 匹配,导致条目快照不是测试上传的标准——需先调用 `apiClearStandards(request, token, pid)` 清掉自动标准再上传自定义标准
|
||||
- **赛道必选(2026-08-13)**:创建/编辑项目必须传 track(赛道一/赛道二/L2考核),缺省返回 400 `赛道为必选项`;前端下拉首项"请选择赛道(必选)",create() 校验 `if (!track) setError('请选择赛道')`。注意:有赛道会自动导入对应默认标准(`projects.ts` POST 里 `loadDefaultStandard`)——创建项目的测试用例必须带合法 track,否则建出的项目无赛道且后续标准解析行为不同。**e2e 陷阱**:依赖"自定义标准快照"的用例(如 `hardcode-fixes.spec.ts` 3.1/3.2)在带 track 创建项目后,自动标准会抢占 `resolveStandard` 匹配,导致条目快照不是测试上传的标准——需先调用 `apiClearStandards(request, token, pid)` 清掉自动标准再上传自定义标准
|
||||
|
||||
### 本地评审样本
|
||||
|
||||
@@ -254,4 +256,6 @@ cd server && node -e "const D=require('better-sqlite3');const d=new D('data/ai-r
|
||||
|
||||
**赛道二(IDE+开发范式创新赛):** 场景价值、开发范式、工具使用、实现完整度、规模·功能点、演示与文档、AI使用日志、效果评估与数据(总分100)
|
||||
|
||||
当前维度 guideline 只覆盖标准维度名(如"场景价值与合理性"),赛道一变体名(如"开发范式与架构设计")需扩展匹配关键词。
|
||||
**L2考核(AI人才育成认证,7维/100):** 功能完整性30、设计文档10、测试用例与测试结果10、AI协作过程记录15、技术选型与范式运用15、代码质量+README 10、业务场景理解与需求分析10。选题难度赋分:★★+0/★★★+5/★★★★+10,封顶选题 cap(100/105/110)。合格线≥60 且 功能完整性≥15(地板线)。
|
||||
|
||||
当前维度 guideline 只覆盖标准维度名(如"场景价值与合理性"),赛道一变体名(如"开发范式与架构设计")需扩展匹配关键词。L2考核 7 维标准自带分档锚点与交叉验证规则,content 优先不触发内置兜底。
|
||||
|
||||
+16
-21
@@ -10,7 +10,7 @@
|
||||
>
|
||||
> **提交截止:2026年9月底(9月30日)**。自题目发布日起至 9月30日,将成果物 `git push` 到 Gitea 仓库 main 分支(以仓库最后推送时间为准)。
|
||||
>
|
||||
> 超过截止时间未提交,按以下迟交规则处理:迟交 1~3 个工作日扣 5 分,4~7 个工作日扣 10 分,超过 7 个工作日本题按 0 分处理并需重做(重做细则见 §12.3)。
|
||||
> 超过截止时间未提交,按以下迟交规则处理:迟交 1~3 个工作日扣 5 分,4~7 个工作日扣 10 分,超过 7 个工作日**按 0 分处理、不再评审**。
|
||||
>
|
||||
> 请务必在截止前完成:账号登录 → 成果物放入仓库 → `git push` 到 main 分支,并确认服务器上能看到最新提交。
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- §10 独立完成与查重红线 | §11 源码可运行前提
|
||||
|
||||
**第三部分 · 考核标准与纪律**
|
||||
- §12 考核标准(7维评审要点 / 合格线与赋分 / 重做细则)
|
||||
- §12 考核标准(7维评审要点 / 合格线与赋分 / 结果申诉)
|
||||
- §13 提交前自查清单 | §14 违规后果
|
||||
|
||||
**第四部分 · 命题题详细需求(选定题目后细读对应章节)**
|
||||
@@ -66,7 +66,7 @@ L2(Level 2)是 AI 人才育成体系中的能力等级,定义为:**能
|
||||
| 开发工具 | 推荐 OpenCode / Claude Code / GitHub Copilot(不作强制);LLM API 自备 |
|
||||
| 评审流程 | 系统自动拉取仓库 → 成果物齐全性自动检测 + 跨仓库查重初筛 → 评审者按 7 维框架人工评分 → 结果反馈 |
|
||||
| 合格标准 | 合格线 **≥60 分**(命题题含难度赋分,满分最高110) |
|
||||
| 未达标处理 | 按重做细则重做一次(5个工作日内),重做评分上限 80 分;细则见 §12.3 |
|
||||
| 未达标处理 | 评审结论为不合格(总分<60 或 功能完整性<15 地板线);结果申诉见 §12.4 |
|
||||
|
||||
### 1.3 考核目标:验证什么
|
||||
|
||||
@@ -346,7 +346,7 @@ README 是评审者了解作品的第一个入口,**必须尽可能说清楚**
|
||||
**处理规则**:
|
||||
|
||||
- 初筛告警由评审者人工复核认定;单次全量 push 等行为信号本身**不构成违规认定**,仅作为复核参考——被标记者应能在 AGENTS.md 与 git 历史中给出独立完成的依据
|
||||
- **查实抄袭:本题按 0 分处理并需重做(重做上限 80 分),处理结果记入考核档案**
|
||||
- **查实抄袭:本题按 0 分处理,处理结果记入考核档案**
|
||||
- 允许查阅公开资料与使用 AI 生成,但须如实记录(见 §8);未记录的他人代码片段视为抄袭
|
||||
- 组委会保留对可疑情形**约谈核实**的权利:可要求受验者现场讲解任意一段核心实现的思路与取舍;拒绝配合或讲解与提交代码明显不符的,按查实抄袭处理
|
||||
|
||||
@@ -386,14 +386,7 @@ README 是评审者了解作品的第一个入口,**必须尽可能说清楚**
|
||||
- **功能完整性评分锚点**:命题题按该题验收基准表逐项评定;自选题按 README 声明的功能清单逐项核对实现情况评定(结合「声称 vs 实测」验证,未实现或与声明不符的功能不计分)
|
||||
- **功能完整性地板线**:该维度得分低于 15 分(不足满分一半)时,无论总分多少均判为**不合格**——防止「文档精美、功能空壳」的提交过线
|
||||
|
||||
### 12.3 重做细则
|
||||
|
||||
- 未达合格线:自通知之日起 **5 个工作日内**重做,重做评分最高记 **80 分**,每题限重做 **1 次**;再次未达线则总评为不合格
|
||||
- 重做允许**更换命题**(在 01~11 中另行选择,自选题亦可修订原方案),按新选题的验收基准评分,难度赋分随新选题调整
|
||||
- 重做提交同样适用迟交规则:逾期扣分,超过 7 个工作日视为**放弃重做资格**,总评不合格
|
||||
- 查实抄袭者不适用正常重做流程,按 §14 违规后果处理
|
||||
|
||||
### 12.4 结果申诉
|
||||
### 12.3 结果申诉
|
||||
|
||||
- 对评分结果或查重认定有异议的,可在收到结果之日起 **3 个工作日内**向组委会提交一次复核申请(列明异议点与依据)
|
||||
- 由**未参与原评审**的人员进行复核,**5 个工作日内**书面答复;复核结论为最终结论
|
||||
@@ -424,13 +417,13 @@ README 是评审者了解作品的第一个入口,**必须尽可能说清楚**
|
||||
|
||||
| 情形 | 后果 |
|
||||
|------|------|
|
||||
| 超过截止时间未提交/未push | 按迟交规则扣分;超7个工作日按0分并需重做 |
|
||||
| 超过截止时间未提交/未push | 迟交 1~3 个工作日扣 5 分,4~7 个工作日扣 10 分;超 7 个工作日按 0 分处理、不再评审 |
|
||||
| 仓库名、分支、凭据不符合规范 | 评审系统无法拉取,按未提交处理 |
|
||||
| 必填成果物缺失或命名不符 | 对应成果物判定为未提交,相关维度扣分 |
|
||||
| README 声称功能与实测不符 | 真实性扣分 |
|
||||
| AI 日志缺失或空白 | 相关维度扣分 |
|
||||
| 源码无法按 README 运行 | 后续所有评分项扣分 |
|
||||
| 查实抄袭/代做 | 本题 0 分 + 重做(上限80分),记入考核档案 |
|
||||
| 查实抄袭/代做 | 本题 0 分,记入考核档案 |
|
||||
| 使用真实业务/客户数据 | 视情节按信息安全违规处理,相关维度扣分至取消资格 |
|
||||
|
||||
---
|
||||
@@ -1152,9 +1145,11 @@ README 是评审者了解作品的第一个入口,**必须尽可能说清楚**
|
||||
| # | 要求 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | **证据型报告** | 每个检出问题必须定位到具体位置(`文件名:行号` 或 `章节编号/条目原文摘录`),附问题描述;不允许只输出整体评价而无问题定位 |
|
||||
| 2 | **规则外置** | 检查规则必须写在独立配置文件中(YAML/JSON/Markdown均可),新增或修改一条规则不需要改动程序代码;演示时须现场新增1条规则并验证生效 |
|
||||
| 2 | **规则外置** | 检查规则必须写在独立配置文件中(YAML/JSON/Markdown均可),新增或修改一条规则不需要改动程序代码;须提交自动化验证脚本与运行日志(放 tests/),证明「新增一条规则后不改变代码即可生效」 |
|
||||
| 3 | **分级判定** | 问题分为 Critical / Major / Minor 三级;报告包含各级数量统计和明确的通过结论(通过 / 有条件通过 / 不通过) |
|
||||
| 4 | **企业标准外置** | 各阶段适用的企业内部标准(文档格式规范 / 编码规约 / 测试基准等)以独立配置文件提供,工具按配置执行检查;标准的具体条目由项目方定义,工具不得硬编码。样本数据须附「标准配置A/B两套」,演示切换B套后检出结果随之变化 |
|
||||
| 4 | **企业标准外置** | 各阶段适用的企业内部标准(文档格式规范 / 编码规约 / 测试基准等)以独立配置文件提供,工具按配置执行检查;标准的具体条目由项目方定义,工具不得硬编码。样本数据须附「标准配置A/B两套」,评审者切换B套后检出结果随之变化 |
|
||||
|
||||
> **组内共性提交物补充**:第 2、4 条的「规则/标准外置生效」验证,以自动化验证脚本 + 运行日志形式提交(放 `tests/`),脚本演示「新增/修改一条规则(或切换 A/B 标准)后不改变代码即可生效、检出结果随之变化」。评审系统按脚本可复现执行核验,不做现场演示。
|
||||
|
||||
---
|
||||
|
||||
@@ -1225,7 +1220,7 @@ README 是评审者了解作品的第一个入口,**必须尽可能说清楚**
|
||||
| 可测试性评估 | 正确标识植入的不可测试需求 | 附具体可操作的改写建议 | 对照缺陷标注清单核对 |
|
||||
| 编写规范检查 | 配置的格式违规能正确检出 | 支持标准A/B切换,检出结果随之变化 | 对照缺陷标注清单核对+切换标准演示 |
|
||||
| 报告规范 | 分级+章节定位完整 | 定位含条目原文摘录 | 抽查报告中定位是否准确 |
|
||||
| 规则外置生效 | 规则在独立配置文件中维护 | 现场新增1条规则不改代码生效 | 演示验证 |
|
||||
| 规则外置生效 | 规则在独立配置文件中维护 | 新增1条规则不改代码生效,有自动化脚本+运行日志佐证 | 运行提交的验证脚本核验 |
|
||||
|
||||
#### 提交物清单
|
||||
|
||||
@@ -1321,8 +1316,8 @@ README 是评审者了解作品的第一个入口,**必须尽可能说清楚**
|
||||
| 追溯矩阵映射 | 10条需求正确建立≥8条映射 | 全部建立且无误报 | 对照缺陷标注清单核对 |
|
||||
| 未覆盖与孤儿标记 | 两类问题均能正确标出 | 无误报 | 对照缺陷标注清单核对 |
|
||||
| 接口一致性 | 植入2处矛盾至少检出1处 | 全部检出并引用两处定义原文 | 对照缺陷标注清单核对 |
|
||||
| 矩阵可视化 | 表格形式展示完整映射关系 | 支持点击跳转到对应位置 | 操作演示验证 |
|
||||
| 规则外置生效 | 必备章节清单在配置文件中维护 | 现场调整清单后重新评审生效 | 演示验证 |
|
||||
| 矩阵可视化 | 表格形式展示完整映射关系 | 支持点击跳转到对应位置 | 运行提交的脚本/截图验证 |
|
||||
| 规则外置生效 | 必备章节清单在配置文件中维护 | 调整清单后重新评审生效,有自动化脚本+运行日志佐证 | 运行提交的验证脚本核验 |
|
||||
|
||||
#### 提交物清单
|
||||
|
||||
@@ -1416,7 +1411,7 @@ AI生成代码大量合入代码库后,出现特有的高频缺陷模式:
|
||||
| 空值防护 | 典型裸访问模式检出率≥70% | 全部检出且无误报 | 对照缺陷标注清单核对 |
|
||||
| 编码规约检查 | 配置的规约违规(命名/注释)能正确检出 | 支持规约A/B切换,检出结果随之变化 | 对照缺陷标注清单核对+切换规约演示 |
|
||||
| 扫描范围控制 | 配置的排除目录不被扫描 | 白名单机制生效 | 故意配置后运行验证 |
|
||||
| 报告规范与规则外置 | 分级+file:line定位完整 | 修复建议含示例代码;现场新增规则生效 | 抽查报告+演示验证 |
|
||||
| 报告规范与规则外置 | 分级+file:line定位完整 | 修复建议含示例代码;新增规则不改代码生效,有脚本佐证 | 抽查报告+运行验证脚本 |
|
||||
|
||||
#### 提交物清单
|
||||
|
||||
@@ -1513,7 +1508,7 @@ AI辅助生成测试用例后,测试数量激增但质量参差:
|
||||
| 无断言测试识别 | 无断言测试100%标记 | 附最小断言补充建议 | 对照缺陷标注清单核对 |
|
||||
| 基准符合性检查 | 配置的基准违规(缺预期结果等)能正确检出 | 支持基准A/B切换,检出结果随之变化 | 对照缺陷标注清单核对+切换基准演示 |
|
||||
| 用例评级 | 每个测试用例有A/B/C评级 | C级用例附针对性改进建议 | 抽查评级合理性 |
|
||||
| 报告规范与规则外置 | 评级结果+覆盖矩阵完整展示 | 评级标准在配置文件中可调整 | 演示验证 |
|
||||
| 报告规范与规则外置 | 评级结果+覆盖矩阵完整展示 | 评级标准在配置文件中可调整,有脚本佐证 | 运行验证脚本核验 |
|
||||
|
||||
#### 提交物清单
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"version": "2026-08-23",
|
||||
"note": "L2考核选题元数据。maxScoreCap=共通100分+难度赋分;funcBreakdown 为功能完整性30分的题目专属拆分,注入该维度子Agent prompt;acceptance 为验收基准要点;sampleData 为题目专属样本数据要求。",
|
||||
"selfTopic": {
|
||||
"id": "self",
|
||||
"title": "自选题",
|
||||
"difficulty": 0,
|
||||
"maxScoreCap": 100,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "按 README 声明且不低于登记范围的功能清单逐项核对:核心闭环(输入处理→核心逻辑→结果呈现)完整性与各模块实现质量综合评定",
|
||||
"acceptance": [
|
||||
"功能模块≥3个(用户可感知的独立功能单元,工具函数与界面装饰不计)",
|
||||
"整体规模明显超出单脚本范畴(通常数百行及以上)",
|
||||
"README 功能声明不得低于事前登记范围的核心功能,未经登记更新的缩减按未实现计"
|
||||
],
|
||||
"sampleData": "全部数据须脱敏或虚构,禁止真实业务/客户数据"
|
||||
},
|
||||
"topics": [
|
||||
{
|
||||
"id": "01",
|
||||
"title": "年度满意度调查数据的自动处理与分析",
|
||||
"group": "business",
|
||||
"difficulty": 2,
|
||||
"maxScoreCap": 100,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "模板管理5分(上传+自动识别+确认)/ 数据导入与统计10分(导入+多维度统计+年度对比)/ 权限区分5分(两种角色隔离)/ 看板展示5分(图表+下钻)/ 智能分析5分(薄弱维度识别+改善建议)",
|
||||
"acceptance": [
|
||||
"模板可变应对:样本模板A正确解析为合格线,列名/列数不同的模板B也能解析为满分",
|
||||
"统计正确性:小样本手算结果与系统一致,小数位规则统一定义且可对齐",
|
||||
"年度对比:2年维度名不同仍能正确对齐",
|
||||
"权限隔离:部门管理员不可查看其他部门明细,实现方式在AGENTS.md说明",
|
||||
"分析深度:识别得分最低维度为合格线,识别下降趋势+给出可量化改善建议为满分"
|
||||
],
|
||||
"sampleData": "至少2个年度模拟Excel(多Sheet),含正常数据与边界数据(空值/重复/维度名不一致/评分越界等)"
|
||||
},
|
||||
{
|
||||
"id": "02",
|
||||
"title": "年度面谈问卷生成与数据分析",
|
||||
"group": "business",
|
||||
"difficulty": 3,
|
||||
"maxScoreCap": 105,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "规则配置与低分识别10分 / 面谈问卷生成10分 / 面谈数据导入5分 / 看板展示5分(智能分析分数含在上述各项中不单独计分)",
|
||||
"acceptance": [
|
||||
"低分规则至少2种可配置(绝对阈值/前年比下降/部门特异性,建议3种),支持组合与预览判断理由",
|
||||
"问卷基于低分维度生成,内容可编辑;按部门/职级定制为满分项",
|
||||
"话题分类:10条标注样本准确率≥60%合格线、≥80%满分(口径自定须在设计文档写明)",
|
||||
"共性问题提取:植入5个高频话题检出≥3个合格线",
|
||||
"改善建议针对具体部门、可量化可执行"
|
||||
],
|
||||
"sampleData": "至少2个年度满意度调查Excel + 至少30条面谈记录(覆盖5个话题分类、3个部门)"
|
||||
},
|
||||
{
|
||||
"id": "03",
|
||||
"title": "人事制度RAG检索系统",
|
||||
"group": "business",
|
||||
"difficulty": 4,
|
||||
"maxScoreCap": 110,
|
||||
"minTests": 5,
|
||||
"funcBreakdown": "必达25分:文档上传与解析5 / 基础检索10 / 无法回答处理5 / 追问5。加分5分任选其一:检索策略切换 / 检索质量可视化 / Chunk管理 / 制度间关联 / 分析看板(多项实现仍计5分,深度可在其他维度体现)",
|
||||
"acceptance": [
|
||||
"基础检索:5个已知答案问题命中≥3个合格线、5个全中带精确出处为满分",
|
||||
"无法回答检测:无关问题明确表示无法回答、不编造",
|
||||
"出处引用:文档名/章节/页码(Word无页码用章节+段落序号)真实可定位",
|
||||
"追问:至少1次连续追问保持上下文",
|
||||
"中日文混合检索:同一提问可含中日文混用",
|
||||
"评分型指标口径自定但须在设计文档写明"
|
||||
],
|
||||
"sampleData": "至少3份模拟制度文档(中/日文各≥1份),多级章节结构,含可关联交叉引用"
|
||||
},
|
||||
{
|
||||
"id": "04",
|
||||
"title": "合同智能审查",
|
||||
"group": "business",
|
||||
"difficulty": 3,
|
||||
"maxScoreCap": 105,
|
||||
"minTests": 5,
|
||||
"funcBreakdown": "合同上传与信息提取5 / 审查等级判断5 / 条款提取与风险标记10 / 审批流程5 / 盖章审批5(签约到期提醒、分析报告分数含在上述各项中)",
|
||||
"acceptance": [
|
||||
"信息提取:名称/签约方/金额/签订日期/合同类型/有效期等字段正确",
|
||||
"审查等级:按金额阈值(<10万简易/≥10万且<100万标准/≥100万严格-律师模拟邮件)判断正确",
|
||||
"风险标记:违约金过高(>20%基线)、管辖地不利等不利条款标记,阈值在规则库显式定义可调整",
|
||||
"审批流程:等级不同审批路径不同,审查完成自动通知发起人(模拟即可)",
|
||||
"到期管理:距到期≤30天标识待续签、已过期状态正确"
|
||||
],
|
||||
"sampleData": "至少5份模拟合同PDF(含正常与有问题合同、不同金额场景),必须含「距到期日≤30天」与「已过期」样本各≥1份"
|
||||
},
|
||||
{
|
||||
"id": "05",
|
||||
"title": "法规信息自动收集爬虫",
|
||||
"group": "business",
|
||||
"difficulty": 3,
|
||||
"maxScoreCap": 105,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "数据源管理5 / 信息自动收集10 / 增量采集5 / 结果展示5 / 定期报告5",
|
||||
"acceptance": [
|
||||
"增量采集:同一内容不重复采集,更新后能再次采集",
|
||||
"内容提取:法规名称/发布机关/发布日期/施行日期/发布类型/摘要/原文链接正确提取",
|
||||
"关键词筛选:包含词+排除词组合筛选正确,影响度三级可区分",
|
||||
"错误处理:无效URL记录日志、重试后跳过不影响其他数据源",
|
||||
"定时调度以配置项或计划任务形式体现即可,验收允许手动触发",
|
||||
"影响度评估口径自定须在设计文档写明,ground truth 标注清单格式规范随样本提交"
|
||||
],
|
||||
"sampleData": "至少3个不同结构的模拟目标网页HTML"
|
||||
},
|
||||
{
|
||||
"id": "06",
|
||||
"title": "外部环境风险信息的自动收集与分析",
|
||||
"group": "business",
|
||||
"difficulty": 3,
|
||||
"maxScoreCap": 105,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "信息源管理5 / 自动信息收集5 / 增量采集5 / 智能分类10 / 风险仪表盘5",
|
||||
"acceptance": [
|
||||
"多源采集:至少2种信息源类型(RSS/网页/API)",
|
||||
"风险分类:5领域分类准确率≥50%合格线、≥80%满分(20条标注测试数据)",
|
||||
"影响度评估:高/中/低可区分,与标注一致率≥65%",
|
||||
"全局关键词:高关注/普通两级,包含排除规则同时生效",
|
||||
"定期报告:Markdown日报/周报自动生成、历史可查,趋势基于≥2个时间周期数据对比"
|
||||
],
|
||||
"sampleData": "模拟信息源HTML/RSS≥3个(覆盖不同风险领域),条目含不同发布日期以支撑趋势;API源附mock脚本"
|
||||
},
|
||||
{
|
||||
"id": "07",
|
||||
"title": "需求文档AI评审工具",
|
||||
"group": "review-tool",
|
||||
"difficulty": 3,
|
||||
"maxScoreCap": 105,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "文档导入与解析4 / 歧义检测8 / 一致性检查5 / 编写规范检查5 / 可测试性评估4 / 评审报告与规则外置4",
|
||||
"acceptance": [
|
||||
"歧义检测:植入10处歧义词检出≥6处合格线,全检且误报≤2为满分",
|
||||
"矛盾检测:植入2组矛盾至少检出1组,报告引用双方条目原文",
|
||||
"可测试性评估:正确标识植入的不可测试需求并附改写建议",
|
||||
"编写规范检查:基于《需求文档编写标准》配置执行,标准A/B两套切换检出结果变化",
|
||||
"证据型报告:每个问题定位到章节编号并摘录原文,Critical/Major/Minor分级+通过结论",
|
||||
"规则外置:检查规则在独立配置文件维护,不改代码可增改"
|
||||
],
|
||||
"sampleData": "2份模拟需求说明书(1正常约10条编号需求;1植入≥20处已知问题)+ 缺陷标注清单 + 标准配置A/B两套"
|
||||
},
|
||||
{
|
||||
"id": "08",
|
||||
"title": "设计书AI评审工具",
|
||||
"group": "review-tool",
|
||||
"difficulty": 4,
|
||||
"maxScoreCap": 110,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "导入与解析5 / 规范符合性检查5 / 需求追溯矩阵10 / 接口一致性检查5 / 矩阵可视化与报告5",
|
||||
"acceptance": [
|
||||
"必备章节检查:配置的必备章节缺失全部检出,附应包含内容提示",
|
||||
"追溯矩阵:10条需求正确建立≥8条映射合格线,未覆盖需求与孤儿设计两类均能标出",
|
||||
"接口一致性:植入2处矛盾至少检出1处并引用双方定义原文",
|
||||
"文档格式规范:《设计书格式标准》A/B两套切换检出结果变化",
|
||||
"证据型报告:分级+定位+通过结论,矩阵表格可视化"
|
||||
],
|
||||
"sampleData": "需求清单1份(10条编号需求)+ 模拟设计书1份(植入章节缺失×2、未覆盖×2、孤儿设计×1、接口矛盾×2、格式违规≥2处)+ 缺陷标注清单 + 格式标准A/B两套"
|
||||
},
|
||||
{
|
||||
"id": "09",
|
||||
"title": "代码AI评审工具",
|
||||
"group": "review-tool",
|
||||
"difficulty": 3,
|
||||
"maxScoreCap": 105,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "扫描配置与执行3 / secrets检测6 / LLM调用专项检查7 / 空值防护检查5 / 编码规约检查4 / 报告与门禁5",
|
||||
"acceptance": [
|
||||
"secrets检测:植入8处硬编码密钥检出≥6处合格线,误报≤1为满分",
|
||||
"LLM调用检查:无保护的LLM调用100%标记,完全无保护Critical/仅缺降级Major",
|
||||
"空值防护:典型裸访问模式检出率≥70%",
|
||||
"编码规约:《编码规约》A/B两套切换检出结果变化,条款全部外置不得硬编码",
|
||||
"证据型报告:定位到 文件名:行号,Critical/Major附修复示例代码,存在Critical输出不通过"
|
||||
],
|
||||
"sampleData": "小型模拟项目代码包(≥10个源码文件,植入硬编码密钥×8、LLM无保护×3、空值裸访问×4、规约违规×4)+ 缺陷标注清单 + 规约A/B两套"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"title": "测试用例AI评审工具",
|
||||
"group": "review-tool",
|
||||
"difficulty": 4,
|
||||
"maxScoreCap": 110,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "测试集导入与解析3 / 需求覆盖分析7 / 断言强度审查9 / 边界完整性提示2 / 基准符合性检查4 / 质量评级报告5",
|
||||
"acceptance": [
|
||||
"覆盖缺口:10条需求中正确找出未覆盖需求≥7条合格线、全部且无误报为满分",
|
||||
"弱断言检测:植入8个弱断言检出≥5个合格线、≥7个且无误报为满分",
|
||||
"无断言测试:100%标记并附最小断言补充建议",
|
||||
"基准符合性:《测试基准》A/B两套切换检出结果变化,覆盖阈值可配置",
|
||||
"质量评级:每用例A/B/C评级,C级附针对性改进建议"
|
||||
],
|
||||
"sampleData": "需求清单1份(10条编号需求)+ 测试代码集(≥20个用例,植入无断言×3、弱断言×8、要素缺失或命名违规×3)+ 缺陷标注清单 + 基准A/B两套"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"title": "成果物AI评审工具",
|
||||
"group": "review-tool",
|
||||
"difficulty": 2,
|
||||
"maxScoreCap": 100,
|
||||
"minTests": 3,
|
||||
"funcBreakdown": "成果物清单配置5 / 存在性核对8 / 要素齐全性检查8 / 交叉一致性检查5 / 准入报告与结论4",
|
||||
"acceptance": [
|
||||
"存在性核对:植入的3处缺失文件全部检出,逐项核对表清晰",
|
||||
"要素齐全检查:README缺失要素正确检出,同义词扩展命中(安装vs部署)",
|
||||
"交叉一致性:README技术栈声明 vs 实际依赖文件不一致处检出并引用双方内容",
|
||||
"清单可切换:加载另一套清单模板正常工作",
|
||||
"准入结论:Critical缺失(如源代码缺失)直接判定不通过"
|
||||
],
|
||||
"sampleData": "残缺模拟项目提交包(故意缺文件、README缺要素、技术栈声明与依赖不一致)+ 缺陷标注清单 + 备用清单模板一套"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
## 功能完整性(40分)
|
||||
|
||||
检查以下5项:
|
||||
|
||||
1. 核心功能实现(12分)
|
||||
- 题目要求的主要功能全部实现且可运行 → 12分
|
||||
- 主要功能实现但有边角缺陷 → 9-11分
|
||||
- 部分核心功能实现,其余有框架或占位 → 6-8分
|
||||
- 仅有框架无实际功能 → 0分
|
||||
|
||||
2. 异常处理(8分)
|
||||
- 有错误提示且覆盖LLM超时/不可达等主要场景 → 8分
|
||||
- 有错误提示但不完整,或仅有降级处理之一 → 5-7分
|
||||
- 无错误提示但有显式错误反馈 → 3-4分
|
||||
- 无任何异常处理 → 0分
|
||||
|
||||
3. 边界情况(6分)
|
||||
- 空数据、异常输入均正确处理 → 6分
|
||||
- 部分边界场景处理 → 3-5分
|
||||
- 无边界处理但程序不崩溃 → 1-2分
|
||||
- 边界输入导致崩溃 → 0分
|
||||
|
||||
4. 业务逻辑正确性(8分)
|
||||
- 统计结果与手算一致,检索结果相关 → 8分
|
||||
- 大部分场景正确,个别边界有误 → 5-7分
|
||||
- 存在明显逻辑错误 → 0-4分
|
||||
|
||||
5. 可运行性(6分)
|
||||
- README步骤可直接运行,无启动报错 → 6分
|
||||
- 需少量额外步骤可运行 → 3-5分
|
||||
- 无法运行 → 0分
|
||||
|
||||
【交叉验证规则】
|
||||
- README声称的功能,必须在代码目录中找到对应的实现文件,否则视为未实现
|
||||
- 声称"支持异常处理"→代码中必须有try-catch或错误处理逻辑,否则扣4分
|
||||
- 声称"支持多种数据格式"→代码中必须有对应的格式处理逻辑,否则扣3分
|
||||
|
||||
## 设计文档(10分)
|
||||
|
||||
检查以下3项:
|
||||
|
||||
1. 架构图(4分)
|
||||
- 有架构图或数据流图(若无→0分)
|
||||
- 图表清晰、标注完整
|
||||
|
||||
2. 技术选型理由(3分)
|
||||
- 为什么选择该技术栈(opencode + 课程技术)
|
||||
- 各组件的作用说明
|
||||
|
||||
3. 关键设计决策(3分)
|
||||
- 设计上的取舍和理由
|
||||
- 边界情况处理策略
|
||||
|
||||
## 测试用例与测试结果(10分)
|
||||
|
||||
检查以下3项:
|
||||
|
||||
1. 测试用例数量(3分)
|
||||
- ≥3个测试用例(若无→0分)
|
||||
|
||||
2. 测试覆盖(4分)
|
||||
- 覆盖核心逻辑的正常路径和异常路径
|
||||
|
||||
3. 结果可复现(3分)
|
||||
- 测试结果附在提交物中
|
||||
- 评审者能按步骤复现
|
||||
|
||||
【交叉验证规则】
|
||||
- AGENTS.md或README中声称的"测试覆盖核心功能",必须在代码中找到对应的测试文件(*.spec.ts, *.test.ts, test_*.py等),否则视为造假扣4分
|
||||
- 测试文件内容必须有断言(assert/expect/should等),仅有文件骨架而无断言视为无效测试,扣2分
|
||||
- 声称"测试全部通过"但测试代码中存在明显语法错误或引用不存在的模块 → 视为造假,该项0分
|
||||
|
||||
## AI协作过程记录(15分)
|
||||
|
||||
检查以下4项:
|
||||
|
||||
1. AGENTS.md存在(3分)
|
||||
- 项目根目录有AGENTS.md文件(若无→0分)
|
||||
|
||||
2. prompt记录(5分)
|
||||
- 记录了使用的prompt原文
|
||||
- 记录了生成过程
|
||||
|
||||
3. 人工修正点(4分)
|
||||
- 记录了AI生成代码后的人工修改
|
||||
- 记录了修改理由
|
||||
|
||||
4. 问题与对策(3分)
|
||||
- 记录了遇到的问题
|
||||
- 记录了解决方案
|
||||
|
||||
【交叉验证规则】
|
||||
- AGENTS.md中声称的"使用了XX技术",必须在代码中找到对应的配置或调用文件
|
||||
- 如果AGENTS.md说"用SpecKit",但代码中没有spec/PRD/TECH_SPEC.md文件 → 视为夸大,扣3分
|
||||
- 如果AGENTS.md说"对LLM输出做了质量验证",但代码中没有对应的验证逻辑 → 视为虚构,扣3分
|
||||
- 如果AGENTS.md说"编写了测试覆盖核心逻辑",但实际没有测试文件或测试内容与描述不符 → 视为造假,该项0分
|
||||
|
||||
## 技术选型与范式运用(15分)
|
||||
|
||||
检查以下4项:
|
||||
|
||||
1. 课程技术运用(5分)
|
||||
- 至少选择了1项指定技术(VibeCoding/Superpowers/SpecKit/Skill/MCP)
|
||||
- 技术运用方式合理
|
||||
|
||||
2. 选型理由(4分)
|
||||
- 说明了为什么选择该技术组合
|
||||
- 与题目场景匹配
|
||||
|
||||
3. 范式运用(3分)
|
||||
- 运用的范式(SpecKit/VibeCoding等)有明确记录
|
||||
- 范式选择与题目特性匹配
|
||||
|
||||
4. 技术组合深度(3分)
|
||||
- 组合多项技术的加分
|
||||
- 技术之间有明确的调用/协作关系
|
||||
|
||||
【交叉验证规则】
|
||||
- 声称使用了"Skill"→代码中必须有对应的Skill定义文件或注册代码,否则扣3分
|
||||
- 声称使用了"SpecKit"→项目中必须有spec/PRD/TECH_SPEC.md文件,否则扣3分
|
||||
- 声称使用了"MCP自动化测试"→必须有MCP Server配置或测试脚本调用代码,否则扣3分
|
||||
- 声称"组合了多项技术"→必须有明确的调用链路(如A技术调用B技术的代码),否则扣3分
|
||||
|
||||
## 代码质量 + README(10分)
|
||||
|
||||
检查以下3项:
|
||||
|
||||
1. 代码结构(4分)
|
||||
- 结构清晰、命名规范
|
||||
- 无大量重复代码(重复率>30%扣分)
|
||||
|
||||
2. README完整性(4分)
|
||||
- 包含:环境要求、安装步骤、运行方法、功能说明
|
||||
- README步骤能直接运行成功
|
||||
|
||||
3. 依赖管理(2分)
|
||||
- 依赖清单完整
|
||||
- 无多余/缺失的依赖
|
||||
|
||||
【交叉验证规则】
|
||||
- README声称的项目功能,必须在代码中有对应的入口文件或模块,否则视为夸大,扣2分
|
||||
- README声称的"支持XX平台/环境",必须有对应的配置文件(Dockerfile、.nvmrc等),否则视为不实,扣1分
|
||||
|
||||
## 第二部分:问题别L3追加维度
|
||||
|
||||
L3为追加评审,仅在L2合格(共通≥60分)后自动触发。
|
||||
|
||||
|
||||
## [Q2] LLM生成问卷(15分)
|
||||
|
||||
检查以下3项:
|
||||
|
||||
1. 问卷生成逻辑(6分)
|
||||
- 代码中是否有LLM调用生成面谈问题的逻辑
|
||||
- 问卷内容是否与低分维度数据联动(非固定模板文案)
|
||||
|
||||
2. 定制化(5分)
|
||||
- 是否支持按部门/职级生成不同问题
|
||||
- 问卷内容是否可编辑
|
||||
|
||||
3. 问题质量(4分)
|
||||
- 生成的问题是否基于具体数据
|
||||
- 不是通用模板问题
|
||||
|
||||
## [Q2] LLM自由文本分析(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 话题分类/摘要(5分)
|
||||
- 面谈分析页面是否包含AI生成的话题分类结果
|
||||
- 是否有自由文本的摘要/分析
|
||||
|
||||
2. 共性问题提取(5分)
|
||||
- 是否能跨面谈记录发现高频出现的问题主题
|
||||
- 提取的共性问题是否准确
|
||||
|
||||
## [Q2] prompt工程与调优记录(5分)
|
||||
|
||||
检查以下一项:
|
||||
|
||||
1. prompt调优过程
|
||||
- AGENTS.md中是否有prompt调优过程记录
|
||||
- 不同策略的对比
|
||||
- LLM输出质量验证方法
|
||||
|
||||
|
||||
## [Q3] LLM检索回答(11分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. LLM生成回答(7分)
|
||||
- 代码中是否有LLM生成回答的逻辑(非单纯关键词匹配)
|
||||
- 回答是否引用出处文档名/章节/页码
|
||||
|
||||
2. 置信度显示(4分)
|
||||
- 回答附带置信度分数
|
||||
- 可信度标识清晰
|
||||
|
||||
## [Q3] 上下文连续对话(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 追问支持(5分)
|
||||
- 是否支持在上一轮基础上继续提问
|
||||
- 上下文理解是否正确
|
||||
|
||||
2. 对话管理(5分)
|
||||
- 代码中是否有上下文管理机制
|
||||
- 对话状态是否在UI中可见
|
||||
|
||||
## [Q3] 无法回答处理(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 无法回答检测(5分)
|
||||
- 对制度文档中不存在的问题,是否明确返回"未在现有制度中找到相关信息"
|
||||
- 不编造答案
|
||||
|
||||
2. 合理引导(5分)
|
||||
- 是否给出合理解释或建议
|
||||
- 是否引导用户调整提问方式
|
||||
|
||||
## [Q3] RAG管道设计记录(19分)
|
||||
|
||||
检查以下4项:
|
||||
|
||||
1. chunk策略设计(5分)
|
||||
- AGENTS.md是否记录了chunk大小/重叠率选择理由
|
||||
- 不同分割策略的对比
|
||||
|
||||
2. embedding模型选型(5分)
|
||||
- 是否说明了embedding模型选择理由
|
||||
- 是否对比了不同模型的效果
|
||||
|
||||
3. 检索策略对比(5分)
|
||||
- 是否记录了关键词/向量/混合检索的对比
|
||||
- 检索准确率改善过程
|
||||
|
||||
4. 多语言处理策略(4分)
|
||||
- 是否说明了中日文混合检索的处理方案
|
||||
- 分词/索引策略的差异处理
|
||||
|
||||
|
||||
## [Q4] LLM条款提取(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 关键字段提取(5分)
|
||||
- 代码中是否有通过LLM提取合同关键字段的逻辑
|
||||
- 提取字段包括:金额、期限、违约责任、管辖法院等
|
||||
|
||||
2. 提取准确度(5分)
|
||||
- 审查报告中提取的字段是否正确
|
||||
- 是否支持多份合同验证
|
||||
|
||||
## [Q4] LLM风险标记(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 风险条款标记(5分)
|
||||
- 是否有AI自动标记风险条款的功能
|
||||
- 审查结果页面是否展示风险项
|
||||
|
||||
2. 风险判断理由(5分)
|
||||
- 每条风险标记是否附带判断理由
|
||||
- 理由是否合理
|
||||
|
||||
## [Q4] prompt工程与规则设计记录(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 审查规则设计(5分)
|
||||
- AGENTS.md是否记录了审查规则设计过程
|
||||
- 风险标准定义的迭代
|
||||
|
||||
2. prompt调优(5分)
|
||||
- prompt调优过程记录
|
||||
- 不同策略的对比
|
||||
|
||||
|
||||
## [Q5] LLM内容提取(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 法规字段提取(5分)
|
||||
- 代码中是否有AI解析网页内容提取法规字段的逻辑
|
||||
- 提取字段包括:法规名称、发布机关、发布日期、摘要
|
||||
|
||||
2. 提取质量(5分)
|
||||
- 提取结果是否准确
|
||||
- 是否支持不同的网页结构
|
||||
|
||||
## [Q5] LLM影响度评估(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 影响度自动判断(5分)
|
||||
- 是否有AI自动评估高/中/低影响度的功能
|
||||
- 判断依据是否来源于LLM分析
|
||||
|
||||
2. 影响度展示(5分)
|
||||
- 影响度在结果列表中是否清晰标识
|
||||
- 高影响度是否突出显示
|
||||
|
||||
## [Q5] prompt工程与爬虫策略记录(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 爬虫策略设计(5分)
|
||||
- AGENTS.md是否记录了爬虫规则设计
|
||||
- 反爬策略
|
||||
|
||||
2. 提取准确率改善(5分)
|
||||
- 提取准确率的改善过程
|
||||
- 不同网站结构的处理策略
|
||||
|
||||
|
||||
## [Q6] LLM风险分类(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 自动分类逻辑(5分)
|
||||
- 代码中是否有AI自动将情报按领域分类的逻辑
|
||||
- 分类维度是否与预设一致(汇率/法规/日中关系/行业/技术)
|
||||
|
||||
2. 分类展示(5分)
|
||||
- 分类结果在界面中是否清晰展示
|
||||
- 是否支持按分类筛选
|
||||
|
||||
## [Q6] LLM影响度评估(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 风险等级评估(5分)
|
||||
- 是否有AI根据内容评估风险等级的功能
|
||||
- 评估依据是否可追溯
|
||||
|
||||
2. 高风险处理(5分)
|
||||
- 高风险情报是否自动突出显示
|
||||
- 是否有通知机制(系统内/模拟邮件)
|
||||
|
||||
## [Q6] prompt工程与分类策略记录(10分)
|
||||
|
||||
检查以下两项:
|
||||
|
||||
1. 分类模型选择(5分)
|
||||
- AGENTS.md是否记录了分类模型选择理由
|
||||
- 影响度评估标准定义
|
||||
|
||||
2. 策略调优(5分)
|
||||
- 通知规则设计
|
||||
- 分类准确率改善过程
|
||||
|
||||
## 合格判定一览
|
||||
|
||||
| 题目 | 难度 | 共通满分 | 追加满分 | 总分上限 | L2合格 | L3合格 |
|
||||
|:----|:----:|:--------:|:--------:|:--------:|:------:|:---
|
||||
@@ -0,0 +1,79 @@
|
||||
## 功能完整性(30分)
|
||||
|
||||
评审基准:
|
||||
- 命题题:按系统注入的该题验收基准表逐项评定,未实现或与声明不符的功能不计分
|
||||
- 自选题:按 README 声明的功能清单逐项核对实现情况(结合登记功能清单快照,低于登记范围核心功能的缩减部分按未实现计)
|
||||
|
||||
分档锚点:
|
||||
- 核心功能全部实现且实测可运行:25-30分
|
||||
- 主要功能实现,个别非核心功能缺失或有边角缺陷:18-24分
|
||||
- 部分核心功能实现,存在占位/框架代码:10-17分
|
||||
- 仅框架无实际功能,或主要功能无法运行:0-9分
|
||||
|
||||
【交叉验证规则】
|
||||
- README 声称的功能,必须在代码目录中找到对应的实现入口/模块,否则视为未实现
|
||||
- 声称"支持异常处理"→代码中必须有 try-catch 或错误处理逻辑,否则扣4分
|
||||
- 声称"支持多种数据格式"→代码中必须有对应的格式处理逻辑,否则扣3分
|
||||
- 凡涉及 LLM/API 调用的功能,必须具备超时设置、异常捕获、降级或显式错误提示至少一项;全部缺失扣4分
|
||||
|
||||
## 设计文档(10分)
|
||||
|
||||
1. 架构图(4分):有架构图或数据流图,模块划分与外部依赖(LLM API 等)标注清晰;无图计0-1分
|
||||
2. 开发范式流程图(3分):本人开发过程的工作流设计;图中步骤名称与 _AI_USAGE_LOG.md 的「范式步骤」列逐步对应,对应不上酌情扣分
|
||||
3. 关键设计决策(3分):重要取舍有理由;评分型指标(置信度/影响度/准确率/高频阈值等)的计算口径必须在设计文档中写明,未写明口径扣2分
|
||||
|
||||
## 测试用例与测试结果(10分)
|
||||
|
||||
1. 用例数量(3分):至少3个测试用例(命题题从其题目约定);无测试文件计0分
|
||||
2. 覆盖质量(4分):覆盖核心逻辑的正常路径与异常路径;断言真实有效——仅有文件骨架而无断言(assert/expect/should)视为无效测试
|
||||
3. 结果可复现(3分):测试以命令行方式可执行,执行结果附于 tests/ 或 README;评审系统将实际运行测试并与声明对照
|
||||
|
||||
【交叉验证规则】
|
||||
- 声称"测试覆盖核心功能",必须找到对应测试文件(*.spec.ts / *.test.ts / test_*.py 等),否则视为造假扣4分
|
||||
- 声称"测试全部通过"但测试代码存在明显语法错误或引用不存在的模块 → 该项0分
|
||||
- 评审环境导致的测试失败(依赖缺失等)为中性证据,不据此扣分,但受验者声明的结果与实测矛盾时扣分
|
||||
|
||||
## AI协作过程记录(15分)
|
||||
|
||||
1. AGENTS.md 存在(3分):项目根目录有 AGENTS.md;缺失计0分
|
||||
2. prompt 原文(5分):记录核心功能生成过程的 prompt 原文,可追溯到具体功能
|
||||
3. 人工修正点(4分):记录 AI 输出后自己改了什么、为什么要改
|
||||
4. 问题与对策(3分):记录遇到的问题(幻觉API、超时缺失等)与解决方案
|
||||
|
||||
【交叉验证规则】
|
||||
- AGENTS.md 声称"使用了XX技术",必须在代码中找到对应配置或调用文件
|
||||
- 说"用 SpecKit"但没有 spec/PRD/TECH_SPEC 类文件 → 视为夸大扣3分
|
||||
- 说"做了LLM输出质量验证"但没有对应验证逻辑 → 视为虚构扣3分
|
||||
- _AI_USAGE_LOG.md 声称的修改所涉及的文件路径,须在 git 历史中存在对应变更;凭空捏造的日志条目每处扣2分
|
||||
|
||||
## 技术选型与范式运用(15分)
|
||||
|
||||
1. 课程技术运用(5分):VibeCoding / Superpowers / SpecKit / Skill / MCP自动化测试 至少1项真实运用于开发过程,运用深度合理(非表面套用);一项都没有计0分
|
||||
2. 选型理由(4分):说明了为何选择该技术组合、与题目场景的匹配性
|
||||
3. 范式运用记录(3分):运用的范式有明确记录(AGENTS.md 课程技术运用节)
|
||||
4. 组合深度(3分):组合多项技术时有明确的调用/协作关系
|
||||
|
||||
【交叉验证规则】
|
||||
- 声称 Skill → 代码中必须有 Skill 定义文件或注册代码,否则扣3分
|
||||
- 声称 MCP 自动化测试 → 必须有 MCP Server 配置或测试脚本调用代码,否则扣3分
|
||||
- 声称"组合多项技术"→ 必须有明确调用链路(A技术调用B技术的代码),否则扣3分
|
||||
|
||||
## 代码质量+README(10分)
|
||||
|
||||
1. 代码结构(4分):结构清晰、命名规范、无硬编码密钥/绝对路径;文件级重复率超过30%酌情扣分,超过50%本项计0-1分
|
||||
2. README 完整性(4分):含痛点背景、功能说明、效果总结、安装运行四部分,安装步骤逐步可复制执行;缺一部分扣1分
|
||||
3. 依赖管理(2分):依赖清单/锁文件完整可复现,无多余缺失依赖
|
||||
|
||||
【交叉验证规则】
|
||||
- README 声称的项目功能必须有对应入口文件或模块,否则视为夸大扣2分
|
||||
- 声称"支持XX平台/环境"但无对应配置文件(Dockerfile、.nvmrc 等)→ 扣1分
|
||||
|
||||
## 业务场景理解与需求分析(10分)
|
||||
|
||||
1. 痛点理解(4分):对业务场景痛点理解准确,目标用户/使用场景清晰(命题题为该题业务场景的理解分析,自选题必须有现状低效点分析)
|
||||
2. 需求分析(3分):功能边界清楚,需求拆解合理
|
||||
3. 效果总结可信(3分):提效数字附测量方式(如何计时、对比场景与步骤);基于模拟场景的小样本试用如实记录即可;无法量化的说明具体痛点和实际反馈
|
||||
|
||||
【真实性规则】
|
||||
- 无测量依据的夸大数字在可信度上扣分(本项计0-1分)
|
||||
- 文档声称效果与实测明显不符 → 本项计0分并在评语标注
|
||||
@@ -448,73 +448,75 @@ describe('Cookie Auth(K7 回归)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Correction: cap + final_level(K3 回归)', () => {
|
||||
describe('Admin Correction: cap + L2 final_level(整合后回归)', () => {
|
||||
let pid = ''; let eid = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
const proj = await api('POST', '/api/projects', { name: 'report-cap-test', track: '人才测评' });
|
||||
const proj = await api('POST', '/api/projects', { name: 'report-cap-test', track: 'L2考核' });
|
||||
pid = proj.data.id;
|
||||
// 删除项目自动生成的默认标准,替换为自定义 Q2 标准
|
||||
const list = await api('GET', `/api/projects/${pid}/standards`);
|
||||
for (const s of list.data) { await api('DELETE', `/api/projects/${pid}/standards/${s.id}`); }
|
||||
// L2 7维标准:功能30+设计10+测试10+AI协作15+技术选型15+代码+README10+业务场景10=100
|
||||
await api('POST', `/api/projects/${pid}/standards`, {
|
||||
name: 'rc-std', category_tag: '人才测评',
|
||||
content: '## 功能完整性(40分)\n## 设计文档(10分)\n## [Q2] LLM生成问卷(15分)\n## [Q2] LLM自由文本分析(10分)',
|
||||
name: 'rc-std', category_tag: 'L2考核',
|
||||
content: '## 功能完整性(30分)\n## 设计文档(10分)\n## 测试用例与测试结果(10分)\n## AI协作过程记录(15分)\n## 技术选型与范式运用(15分)\n## 代码质量+README(10分)\n## 业务场景理解与需求分析(10分)',
|
||||
});
|
||||
const entry = await api('POST', `/api/projects/${pid}/entries`, {
|
||||
title: 'cap-entry', repo_url: `file://C:\\cap-${Date.now()}`, question_id: 'Q2',
|
||||
title: 'cap-entry', repo_url: `file://C:\\cap-${Date.now()}`, selected_topic: 'self',
|
||||
});
|
||||
eid = entry.data.id;
|
||||
});
|
||||
|
||||
afterAll(async () => { await api('DELETE', `/api/projects/${pid}?force=true`); });
|
||||
|
||||
it('TC-REPORT-01: 人工修正后 final_level 按新分数重算(L3→不合格→L3)', async () => {
|
||||
it('TC-REPORT-01: L2 人工修正后 final_level 按合格/不合格重算(含功能完整性地板线)', async () => {
|
||||
const full = [
|
||||
{ name: '功能完整性', score: 40, maxScore: 40, group: 'common' },
|
||||
{ name: '设计文档', score: 10, maxScore: 10, group: 'common' },
|
||||
{ name: 'LLM生成问卷', score: 15, maxScore: 15, group: 'Q2' },
|
||||
{ name: 'LLM自由文本分析', score: 10, maxScore: 10, group: 'Q2' },
|
||||
{ name: '功能完整性', score: 30, maxScore: 30 },
|
||||
{ name: '设计文档', score: 10, maxScore: 10 },
|
||||
{ name: '测试用例与测试结果', score: 10, maxScore: 10 },
|
||||
{ name: 'AI协作过程记录', score: 15, maxScore: 15 },
|
||||
{ name: '技术选型与范式运用', score: 15, maxScore: 15 },
|
||||
{ name: '代码质量+README', score: 10, maxScore: 10 },
|
||||
{ name: '业务场景理解与需求分析', score: 10, maxScore: 10 },
|
||||
];
|
||||
const fr = await api('PUT', `/api/projects/${pid}/entries/${eid}/force-review`, { dimensions: full });
|
||||
expect(fr.status).toBe(200);
|
||||
// force-review 仅播种 ai_report;final_level 由 PUT /report 重算
|
||||
const seed = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: full });
|
||||
expect(seed.status).toBe(200);
|
||||
let detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
|
||||
expect(detail.data.final_level).toBe('L3'); // L2 50/50 达标,总 75/75 ≥0.8
|
||||
expect(detail.data.final_level).toBe('合格'); // 总分100 ≥60 且 功能30 ≥15
|
||||
|
||||
const lowCommon = [
|
||||
{ name: '功能完整性', score: 15, maxScore: 40, group: 'common' },
|
||||
{ name: '设计文档', score: 5, maxScore: 10, group: 'common' },
|
||||
{ name: 'LLM生成问卷', score: 15, maxScore: 15, group: 'Q2' },
|
||||
{ name: 'LLM自由文本分析', score: 10, maxScore: 10, group: 'Q2' },
|
||||
];
|
||||
const fix = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: lowCommon });
|
||||
// 功能完整性低于地板线15 → 即使总分高也判不合格
|
||||
const lowFunc = full.map(d => d.name === '功能完整性' ? { ...d, score: 10 } : d);
|
||||
const fix = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: lowFunc });
|
||||
expect(fix.status).toBe(200);
|
||||
detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
|
||||
expect(detail.data.final_level).toBe('不合格'); // L2 20/50 < pass 30
|
||||
expect(detail.data.final_level).toBe('不合格'); // 功能10 < 15 地板线
|
||||
|
||||
// 恢复
|
||||
const fix2 = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: full });
|
||||
expect(fix2.status).toBe(200);
|
||||
detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
|
||||
expect(detail.data.final_level).toBe('L3');
|
||||
expect(detail.data.final_level).toBe('合格');
|
||||
});
|
||||
|
||||
it('TC-REPORT-02: 人工修正应用 max_score_cap 后再扣迟交', async () => {
|
||||
const db = (await import('../db')).default;
|
||||
db.prepare('UPDATE entries SET max_score_cap = 30, late_days = 0 WHERE id = ?').run(eid);
|
||||
const full = [
|
||||
{ name: '功能完整性', score: 40, maxScore: 40, group: 'common' },
|
||||
{ name: '设计文档', score: 10, maxScore: 10, group: 'common' },
|
||||
{ name: 'LLM生成问卷', score: 15, maxScore: 15, group: 'Q2' },
|
||||
{ name: 'LLM自由文本分析', score: 10, maxScore: 10, group: 'Q2' },
|
||||
{ name: '功能完整性', score: 30, maxScore: 30 },
|
||||
{ name: '设计文档', score: 10, maxScore: 10 },
|
||||
{ name: '测试用例与测试结果', score: 10, maxScore: 10 },
|
||||
{ name: 'AI协作过程记录', score: 15, maxScore: 15 },
|
||||
{ name: '技术选型与范式运用', score: 15, maxScore: 15 },
|
||||
{ name: '代码质量+README', score: 10, maxScore: 10 },
|
||||
{ name: '业务场景理解与需求分析', score: 10, maxScore: 10 },
|
||||
];
|
||||
const fix = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: full });
|
||||
expect(fix.status).toBe(200);
|
||||
const detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
|
||||
expect(detail.data.raw_score).toBe(75);
|
||||
expect(detail.data.final_score).toBe(30); // min(75, cap30) - 0
|
||||
expect(detail.data.raw_score).toBe(100);
|
||||
expect(detail.data.final_score).toBe(30); // min(100, cap30) - 0
|
||||
db.prepare('UPDATE entries SET max_score_cap = 100 WHERE id = ?').run(eid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ afterAll(async () => {
|
||||
server?.close();
|
||||
});
|
||||
|
||||
describe('build_status(赛道二/人才测评 单阶段人工构建确认,§2.2 扩展)', () => {
|
||||
describe('build_status(赛道二/L2考核 单阶段人工构建确认,§2.2 扩展)', () => {
|
||||
it('创建条目可带 build_status=done/failed,并持久化', async () => {
|
||||
const r = await api('POST', `/api/projects/${projectId}/entries`, {
|
||||
title: 'bs-done', repo_url: `file://D:\\bs-${Date.now()}`, build_status: 'done',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectPlagiarism,
|
||||
detectCommitBehavior,
|
||||
readRepoFiles,
|
||||
PLAG_SIM_THRESHOLD,
|
||||
} from '../services/plagiarism-detect';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
function mkFile(dir: string, rel: string, content: string) {
|
||||
const full = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
describe('TC-PLAG · L2 查重初筛(§10)', () => {
|
||||
it('相同文件(MD5 一致)→ identicalFile flag', () => {
|
||||
const a = readRepoFiles('A');
|
||||
const b = readRepoFiles('B');
|
||||
void a; void b;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plag-'));
|
||||
const d1 = path.join(tmp, 'A'); const d2 = path.join(tmp, 'B');
|
||||
mkFile(d1, 'src/main.py', 'def solve(x):\n return x * 2\n');
|
||||
mkFile(d1, 'README.md', '# 项目A\n');
|
||||
mkFile(d2, 'src/main.py', 'def solve(x):\n return x * 2\n'); // 完全相同
|
||||
mkFile(d2, 'README.md', '# 项目B\n');
|
||||
const r = detectPlagiarism('A', readRepoFiles(d1), { B: readRepoFiles(d2) });
|
||||
expect(r.flags.some(f => f.kind === 'identicalFile')).toBe(true);
|
||||
const f = r.flags.find(f => f.kind === 'identicalFile')!;
|
||||
expect(f.samples.some(s => s.file === 'src/main.py' && s.otherFile === 'src/main.py')).toBe(true);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('不同代码 → 无 flag', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plag-'));
|
||||
const d1 = path.join(tmp, 'A'); const d2 = path.join(tmp, 'B');
|
||||
mkFile(d1, 'src/main.py', 'def solve(x):\n return x + 1\n');
|
||||
mkFile(d2, 'src/main.py', 'def other(y):\n return y * 3 - 7\n');
|
||||
const r = detectPlagiarism('A', readRepoFiles(d1), { B: readRepoFiles(d2) });
|
||||
expect(r.flags.some(f => f.kind === 'identicalFile')).toBe(false);
|
||||
expect(r.flags.some(f => f.kind === 'highSimilarity')).toBe(false);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('高相似同名源码 → highSimilarity flag(阈值由 PLAG_SIM_THRESHOLD 控制)', () => {
|
||||
expect(PLAG_SIM_THRESHOLD).toBeGreaterThanOrEqual(0.5);
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plag-'));
|
||||
const d1 = path.join(tmp, 'A'); const d2 = path.join(tmp, 'B');
|
||||
// 仅改一行注释/变量名,其余完全一致 → 归一化后几乎相同
|
||||
mkFile(d1, 'src/util.ts', 'export function calc(a: number, b: number): number {\n const sum = a + b;\n if (sum > 100) return sum;\n return sum;\n}\n');
|
||||
mkFile(d2, 'src/util.ts', 'export function calc(a: number, b: number): number {\n const sum = a + b;\n if (sum > 100) return sum;\n return sum;\n}\n');
|
||||
const r = detectPlagiarism('A', readRepoFiles(d1), { B: readRepoFiles(d2) });
|
||||
// 完全相同会同时触发 identicalFile;此处关注 highSimilarity 也能被标记
|
||||
expect(r.flags.some(f => f.kind === 'highSimilarity' || f.kind === 'identicalFile')).toBe(true);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('忽略依赖/脚手架白名单文件', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plag-'));
|
||||
const d1 = path.join(tmp, 'A'); const d2 = path.join(tmp, 'B');
|
||||
mkFile(d1, 'package.json', '{"name":"x","dependencies":{"lodash":"^4"}}');
|
||||
mkFile(d1, 'package-lock.json', '{"lockfileVersion":3}');
|
||||
mkFile(d2, 'package.json', '{"name":"x","dependencies":{"lodash":"^4"}}');
|
||||
mkFile(d2, 'package-lock.json', '{"lockfileVersion":3}');
|
||||
const r = detectPlagiarism('A', readRepoFiles(d1), { B: readRepoFiles(d2) });
|
||||
expect(r.flags.length).toBe(0);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('commit 行为:单次全量提交 → suspiciousCommit;多次提交 → 无', () => {
|
||||
const bulk = detectCommitBehavior('A', 1, true);
|
||||
expect(bulk).not.toBeNull();
|
||||
expect(bulk!.kind).toBe('suspiciousCommit');
|
||||
const ok = detectCommitBehavior('A', 12, false);
|
||||
expect(ok).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeCommonTotal, computeMaxBonus, computeEffectiveTotal, computePassLine, matchDimKey, computeFinalLevel, computeLatePenalty, computeCalibration, parseDimResponse, resolveSubmitTime, computeLateDays, aggregateScores, aggregateEntryScores, classifyVerifiability, isEffectDim, detectStructuralContradictions, neutralizeTestEvidence } from '../services/standard-utils';
|
||||
import { computeCommonTotal, computeMaxBonus, computeEffectiveTotal, computePassLine, matchDimKey, computeLatePenalty, computeCalibration, parseDimResponse, resolveSubmitTime, computeLateDays, aggregateScores, aggregateEntryScores, classifyVerifiability, isEffectDim, detectStructuralContradictions, neutralizeTestEvidence } from '../services/standard-utils';
|
||||
|
||||
const dims = [
|
||||
{ name: '共通A', maxScore: 40, group: 'common' },
|
||||
@@ -22,8 +22,8 @@ describe('TC-STDUTIL · 标准工具(§3.3.1 / §3.3.8)', () => {
|
||||
expect(computeEffectiveTotal(dims)).toBe(95);
|
||||
});
|
||||
|
||||
it('computePassLine: 人才测评 = round(共通×0.6)', () => {
|
||||
expect(computePassLine(dims, '人才测评')).toBe(42);
|
||||
it('computePassLine: L2考核 与共通赛道一致 = round(有效总分×0.6)', () => {
|
||||
expect(computePassLine(dims, 'L2考核')).toBe(57);
|
||||
});
|
||||
|
||||
it('computePassLine: 其他赛道 = round(有效总分×0.6)', () => {
|
||||
@@ -46,35 +46,6 @@ describe('TC-STDUTIL · 标准工具(§3.3.1 / §3.3.8)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TC-L2L3 · computeFinalLevel(§3.3.8 / K3)', () => {
|
||||
const mk = (score: number, maxScore: number, group = 'common') => ({ score, maxScore, group });
|
||||
|
||||
it('共通达标 + L3 得分率≥0.8 → L3', () => {
|
||||
const dims = [mk(40, 40, 'common'), mk(10, 10, 'common'), mk(15, 15, 'Q2'), mk(10, 10, 'Q2')];
|
||||
expect(computeFinalLevel(dims, 30)).toBe('L3'); // (50+25)/75 = 1.0
|
||||
});
|
||||
|
||||
it('共通达标 + L3 得分率<0.8 → L2', () => {
|
||||
const dims = [mk(40, 40, 'common'), mk(10, 10, 'common'), mk(5, 15, 'Q2'), mk(2, 10, 'Q2')];
|
||||
expect(computeFinalLevel(dims, 30)).toBe('L2'); // (50+7)/75 ≈ 0.76
|
||||
});
|
||||
|
||||
it('共通未达标 → 不合格', () => {
|
||||
const dims = [mk(15, 40, 'common'), mk(5, 10, 'common'), mk(15, 15, 'Q2')];
|
||||
expect(computeFinalLevel(dims, 30)).toBe('不合格'); // L2 20 < 30
|
||||
});
|
||||
|
||||
it('无 L3 维度:共通达标 → L2', () => {
|
||||
const dims = [mk(40, 40, 'common'), mk(10, 10, 'common')];
|
||||
expect(computeFinalLevel(dims, 30)).toBe('L2');
|
||||
});
|
||||
|
||||
it('passLine 为 0 时回退 round(共通满分×0.6)', () => {
|
||||
const dims = [mk(30, 50, 'common')];
|
||||
expect(computeFinalLevel(dims, 0)).toBe('L2'); // 30 >= round(50×0.6)=30
|
||||
});
|
||||
});
|
||||
|
||||
describe('TC-LATE · computeLatePenalty(§3.3.9)', () => {
|
||||
it('lateDays≤0 → 不扣', () => {
|
||||
expect(computeLatePenalty(100, 0, 5)).toBe(0);
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('parseDimensions', () => {
|
||||
expect(result.map(d => d.name)).toEqual(['评审维度设计', '功能完整性']);
|
||||
});
|
||||
|
||||
it('should infer Qn group from "N-A." prefix (人才测评追加维度)', () => {
|
||||
it('should infer Qn group from "N-A." prefix', () => {
|
||||
const md = [
|
||||
'## 功能完整性(40分)',
|
||||
'共通',
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('isBuildRelatedDim(构建封顶维度识别,含功能完整性盲
|
||||
expect(isBuildRelatedDim('实现完整度')).toBe(true);
|
||||
});
|
||||
|
||||
it('修复 功能完整性 盲区(人才测评 L2)', () => {
|
||||
it('识别 功能完整性(L2 构建封顶维度)', () => {
|
||||
expect(isBuildRelatedDim('功能完整性')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
+6
-1
@@ -98,12 +98,17 @@ try { db.exec("ALTER TABLE entries ADD COLUMN score_a REAL DEFAULT 0"); } catch
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN score_b REAL DEFAULT 0"); } catch (e) {}
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN stage_b_status TEXT DEFAULT ''"); } catch (e) {}
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN project_understanding TEXT DEFAULT ''"); } catch (e) {}
|
||||
// 赛道二/人才测评 单阶段人工构建确认(2026-08-18):''=未确认(自动构建) / done / failed
|
||||
// 赛道二/L2考核 单阶段人工构建确认(2026-08-18):''=未确认(自动构建) / done / failed
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN build_status TEXT DEFAULT ''"); } catch (e) {}
|
||||
// 多次评审聚合(2026-08-19):快照分数列(含迟交扣分),排名用稳健统计
|
||||
try { db.exec("ALTER TABLE review_snapshots ADD COLUMN score REAL"); } catch (e) {}
|
||||
// 决赛圈基准证据(2026-08-19):按 entry 落库,防并发 env 串数据
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN benchmark_json TEXT DEFAULT ''"); } catch (e) {}
|
||||
// L2考核(2026-08-23):选题编号(01~11/self)与自选题登记(JSON:登记编号+功能清单快照)
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN selected_topic TEXT DEFAULT ''"); } catch (e) {}
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN self_registration TEXT DEFAULT ''"); } catch (e) {}
|
||||
// L2考核查重初筛(2026-08-23):跨仓库相似 flags 落库(确定性检测,仅告警不定罪)
|
||||
try { db.exec("ALTER TABLE entries ADD COLUMN plagiarism_json TEXT DEFAULT ''"); } catch (e) {}
|
||||
// backfill:历史快照 score 为 NULL 时从 ai_report.totalScore best-effort 回填(一次性)
|
||||
try {
|
||||
db.exec(`UPDATE review_snapshots SET score = (SELECT json_extract(ai_report, '$.totalScore')) WHERE score IS NULL AND ai_report IS NOT NULL`);
|
||||
|
||||
@@ -8,12 +8,13 @@ import db from '../db';
|
||||
import { config } from '../config';
|
||||
import { isPathInside } from '../path-security';
|
||||
import { parseDimensions } from './standards';
|
||||
import { computePassLine, computeFinalLevel, computeLatePenalty, aggregateEntryScores } from '../services/standard-utils';
|
||||
import { computePassLine, computeLatePenalty, aggregateEntryScores, computeL2Result } from '../services/standard-utils';
|
||||
import { isPrivateAddress } from '../ip-security';
|
||||
import { REVIEW_CONSTANTS } from '../services/review-constants';
|
||||
import { startReview, startReviewB, resolveWebMode, averageDimensions } from '../services/review.service';
|
||||
import { generateEntryPdf } from '../services/pdf.service';
|
||||
import { resolveRepoUrlFromConfig } from '../services/teams-config';
|
||||
import { findL2Topic } from '../services/l2-topics';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
@@ -176,7 +177,7 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(pid(req)) as any;
|
||||
if (!project) return res.status(404).json({ error: '项目不存在' });
|
||||
|
||||
const { title, repo_url, participant, branch, service_url, base_branch, sub_type, question_id, build_status } = req.body;
|
||||
const { title, repo_url, participant, branch, service_url, base_branch, sub_type, question_id, build_status, selected_topic, self_registration } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: '标题为必填项' });
|
||||
if (build_status !== undefined && build_status !== '' && build_status !== 'done' && build_status !== 'failed') {
|
||||
return res.status(400).json({ error: '构建结果必须为 done 或 failed' });
|
||||
@@ -195,11 +196,23 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
const subType = track === '赛道一' ? (sub_type || '') : '';
|
||||
const categoryTag = track || '';
|
||||
|
||||
// 人才测评 must have question_id
|
||||
if (track === '人才测评' && !question_id) {
|
||||
return res.status(400).json({ error: '人才测评条目必须选择题目' });
|
||||
// L2考核:选题(默认自选题)决定难度赋分与 max_score_cap;自选题登记随条目落库
|
||||
let l2TopicId = '';
|
||||
let l2Cap = 100;
|
||||
let selfRegJson = '';
|
||||
if (track === 'L2考核') {
|
||||
l2TopicId = selected_topic || 'self';
|
||||
const t = findL2Topic(l2TopicId);
|
||||
if (!t) return res.status(400).json({ error: '无效的选题编号' });
|
||||
l2Cap = t.maxScoreCap;
|
||||
if (l2TopicId === 'self' && self_registration !== undefined && self_registration !== null) {
|
||||
try {
|
||||
selfRegJson = typeof self_registration === 'string'
|
||||
? JSON.stringify(JSON.parse(self_registration))
|
||||
: JSON.stringify(self_registration);
|
||||
} catch { selfRegJson = ''; }
|
||||
}
|
||||
}
|
||||
const qid = track === '人才测评' ? question_id : '';
|
||||
|
||||
const standard = resolveStandard(pid(req), track, subType);
|
||||
if (!standard) return res.status(400).json({ error: '未找到匹配的评审标准,请先上传标准' });
|
||||
@@ -209,11 +222,11 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
db.prepare(`INSERT INTO entries (id, project_id, standard_id, title, repo_url, category_tag, sub_type, question_id, participant, pass_line, standard_snapshot, branch, service_url, base_branch, build_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
db.prepare(`INSERT INTO entries (id, project_id, standard_id, title, repo_url, category_tag, sub_type, question_id, participant, pass_line, standard_snapshot, branch, service_url, base_branch, build_status, selected_topic, self_registration, max_score_cap)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
id, pid(req), standard.id, title.trim(), resolvedRepoUrl,
|
||||
categoryTag, subType, qid, participant || '', passLine, JSON.stringify(dims), branch?.trim() || '', (service_url || '').trim(),
|
||||
(base_branch || '').trim(), (build_status || '').trim()
|
||||
categoryTag, subType, '', participant || '', passLine, JSON.stringify(dims), branch?.trim() || '', (service_url || '').trim(),
|
||||
(base_branch || '').trim(), (build_status || '').trim(), l2TopicId, selfRegJson, l2Cap
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('UNIQUE constraint')) {
|
||||
@@ -248,8 +261,6 @@ router.post('/batch', async (req: Request, res: Response) => {
|
||||
|
||||
const subType = track === '赛道一' ? (item.sub_type || '') : '';
|
||||
const categoryTag = track || '';
|
||||
const qid = track === '人才测评' ? (item.question_id || '') : '';
|
||||
if (track === '人才测评' && !qid) { errors.push({ row: i, reason: '人才测评必须选择题目' }); continue; }
|
||||
|
||||
const standard = resolveStandard(pid(req), track, subType);
|
||||
if (!standard) { errors.push({ row: i, reason: '未找到匹配的标准' }); continue; }
|
||||
@@ -261,7 +272,7 @@ router.post('/batch', async (req: Request, res: Response) => {
|
||||
db.prepare(`INSERT INTO entries (id, project_id, standard_id, title, repo_url, category_tag, sub_type, question_id, participant, pass_line, standard_snapshot, branch, service_url, base_branch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
id, pid(req), standard.id, item.title.trim(), resolvedRepoUrl,
|
||||
categoryTag, subType, qid, item.participant || '', passLine, JSON.stringify(dims),
|
||||
categoryTag, subType, '', item.participant || '', passLine, JSON.stringify(dims),
|
||||
item.branch?.trim() || '', (item.service_url || '').trim(),
|
||||
(item.base_branch || '').trim()
|
||||
);
|
||||
@@ -285,7 +296,7 @@ router.put('/:entryId', async (req: Request, res: Response) => {
|
||||
// §2.5.1 放开:允许 pending / a_done 编辑(service_url 是 a_done 的核心用途,B 阶段前置条件)
|
||||
if (existing.status !== 'pending' && existing.status !== 'a_done') return res.status(409).json({ error: '只能编辑待评审或等待系统验证的条目' });
|
||||
|
||||
const { title, repo_url, participant, branch, service_url, base_branch, sub_type, question_id, build_status } = req.body;
|
||||
const { title, repo_url, participant, branch, service_url, base_branch, sub_type, question_id, build_status, selected_topic, self_registration } = req.body;
|
||||
if (service_url !== undefined && service_url) {
|
||||
const urlCheck = await validateServiceUrl(service_url);
|
||||
if (!urlCheck.valid) return res.status(400).json({ error: `服务地址无效: ${urlCheck.reason}` });
|
||||
@@ -296,9 +307,29 @@ router.put('/:entryId', async (req: Request, res: Response) => {
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(pid(req)) as any;
|
||||
const track = project?.track || '';
|
||||
const subTypeVal = track === '赛道一' && sub_type !== undefined ? sub_type : existing.sub_type;
|
||||
const qidVal = track === '人才测评' && question_id !== undefined ? question_id : existing.question_id;
|
||||
const qidVal = existing.question_id || '';
|
||||
|
||||
db.prepare(`UPDATE entries SET title=?, repo_url=?, sub_type=?, question_id=?, participant=?, branch=?, service_url=?, base_branch=?, build_status=?, updated_at=datetime('now') WHERE id=?`).run(
|
||||
// L2考核:选题/登记更新时同步 max_score_cap
|
||||
let selTopicVal = existing.selected_topic || '';
|
||||
let selfRegVal = existing.self_registration || '';
|
||||
let capVal: number | undefined;
|
||||
if (track === 'L2考核') {
|
||||
if (selected_topic !== undefined) {
|
||||
const t = findL2Topic(selected_topic || 'self');
|
||||
if (!t) return res.status(400).json({ error: '无效的选题编号' });
|
||||
selTopicVal = selected_topic || 'self';
|
||||
capVal = t.maxScoreCap;
|
||||
}
|
||||
if (self_registration !== undefined) {
|
||||
try {
|
||||
selfRegVal = typeof self_registration === 'string'
|
||||
? JSON.stringify(JSON.parse(self_registration))
|
||||
: JSON.stringify(self_registration);
|
||||
} catch { selfRegVal = ''; }
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE entries SET title=?, repo_url=?, sub_type=?, question_id=?, participant=?, branch=?, service_url=?, base_branch=?, build_status=?, selected_topic=?, self_registration=?, max_score_cap=COALESCE(?, max_score_cap), updated_at=datetime('now') WHERE id=?`).run(
|
||||
title?.trim() || existing.title,
|
||||
repo_url?.trim() || existing.repo_url,
|
||||
subTypeVal,
|
||||
@@ -308,6 +339,9 @@ router.put('/:entryId', async (req: Request, res: Response) => {
|
||||
service_url !== undefined ? (service_url || '').trim() : (existing.service_url || ''),
|
||||
base_branch !== undefined ? (base_branch || '').trim() : (existing.base_branch || ''),
|
||||
build_status !== undefined ? (build_status || '').trim() : (existing.build_status || ''),
|
||||
selTopicVal,
|
||||
selfRegVal,
|
||||
capVal ?? null,
|
||||
eid(req)
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM entries WHERE id = ?').get(eid(req));
|
||||
@@ -558,12 +592,22 @@ router.put('/:entryId/report', (req: Request, res: Response) => {
|
||||
const proj = db.prepare('SELECT late_penalty FROM projects WHERE id = ?').get(pid(req)) as any;
|
||||
penalty = computeLatePenalty(totalScore, entry.late_days, proj?.late_penalty ?? REVIEW_CONSTANTS.DEFAULT_LATE_PENALTY);
|
||||
}
|
||||
// 与评审管线对齐:应用 max_score_cap 上限后再扣迟交分
|
||||
// 与评审管线对齐:应用 max_score_cap 上限后再扣迟交分(L2 额外应用难度赋分并封顶选题 cap)
|
||||
const cappedScore = entry.max_score_cap && entry.max_score_cap < 100 ? Math.min(totalScore, entry.max_score_cap) : totalScore;
|
||||
const finalScore = Math.max(0, Math.round(cappedScore - penalty));
|
||||
const projInfo = db.prepare('SELECT track FROM projects WHERE id = ?').get(pid(req)) as any;
|
||||
let finalScore = Math.max(0, Math.round(cappedScore - penalty));
|
||||
if (projInfo?.track === 'L2考核') {
|
||||
const t = findL2Topic(entry.selected_topic || 'self');
|
||||
const bonus = t ? Math.max(0, t.difficulty - 2) * 5 : 0;
|
||||
const cap = t ? t.maxScoreCap : 100;
|
||||
finalScore = Math.min(cap, Math.max(0, Math.round(cappedScore + bonus - penalty)));
|
||||
}
|
||||
|
||||
// 人才测评:按修正后的维度重算 final_level(与评审管线共用纯函数,K3)
|
||||
const finalLevel = entry.question_id ? computeFinalLevel(updatedDims, entry.pass_line || 0) : (entry.final_level || '');
|
||||
// 修正后的 final_level:L2考核用 computeL2Result(合格/不合格),其余保留原值
|
||||
let finalLevel = entry.final_level || '';
|
||||
if (projInfo?.track === 'L2考核') {
|
||||
finalLevel = computeL2Result(finalScore, updatedDims);
|
||||
}
|
||||
|
||||
db.prepare("UPDATE entries SET ai_report = ?, raw_score = ?, final_score = ?, final_level = ?, status = 'admin_reviewed', updated_at = datetime('now') WHERE id = ?").run(
|
||||
JSON.stringify(updatedReport), totalScore, finalScore, finalLevel, eid(req));
|
||||
|
||||
@@ -7,9 +7,15 @@ import { config } from '../config';
|
||||
import { parseDimensions } from './standards';
|
||||
import { computeEffectiveTotal, aggregateEntryScores } from '../services/standard-utils';
|
||||
import { generateSummaryPdf } from '../services/pdf.service';
|
||||
import { getL2Topics } from '../services/l2-topics';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// L2考核选题元数据(前端选题下拉 / 评审注入同源)
|
||||
router.get('/l2-topics', (_req: Request, res: Response) => {
|
||||
res.json(getL2Topics());
|
||||
});
|
||||
|
||||
function getProjectOr404(id: string, res: Response) {
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
|
||||
if (!project) { res.status(404).json({ error: '项目不存在' }); return null; }
|
||||
@@ -50,8 +56,8 @@ function buildSummary(projectId: string) {
|
||||
|
||||
const byCategory: Record<string, any[]> = {};
|
||||
for (const { entry: e, score, aggregate_count, is_formal } of enriched) {
|
||||
// 人才测评按 question_id 分组,其他按 category_tag 分组
|
||||
const tag = (e.category_tag === '人才测评' && e.question_id) ? e.question_id : (e.category_tag || '未分类');
|
||||
// 统一按 category_tag 分组
|
||||
const tag = e.category_tag || '未分类';
|
||||
if (!byCategory[tag]) byCategory[tag] = [];
|
||||
byCategory[tag].push({ e, score, aggregate_count, is_formal });
|
||||
}
|
||||
@@ -107,7 +113,7 @@ router.get('/', (_req: Request, res: Response) => {
|
||||
const DEFAULT_STANDARDS: Record<string, { name: string; file: string }> = {
|
||||
'赛道一': { name: '技术大赛·赛道一标准', file: '技术大赛-赛道一.md' },
|
||||
'赛道二': { name: '技术大赛·赛道二标准', file: '技术大赛-赛道二.md' },
|
||||
'人才测评': { name: 'AI人才育成L2评审标准', file: 'AI人才育成L2.md' },
|
||||
'L2考核': { name: 'L2考核·评审标准(7维)', file: 'L2考核-评审标准.md' },
|
||||
};
|
||||
|
||||
const TMPL_DIR = path.resolve(__dirname, '../../config/standards');
|
||||
@@ -127,8 +133,8 @@ function loadDefaultStandard(track: string): { name: string; content: string } |
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const { name, description, deadline, track } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: '项目名称为必填项' });
|
||||
const validTracks = ['赛道一', '赛道二', '人才测评'];
|
||||
if (!validTracks.includes(track)) return res.status(400).json({ error: '赛道为必选项,可选值:赛道一、赛道二、人才测评' });
|
||||
const validTracks = ['赛道一', '赛道二', 'L2考核'];
|
||||
if (!validTracks.includes(track)) return res.status(400).json({ error: '赛道为必选项,可选值:赛道一、赛道二、L2考核' });
|
||||
const trackVal = track;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
@@ -161,7 +167,7 @@ router.put('/:id', (req: Request, res: Response) => {
|
||||
if (!existing) return;
|
||||
|
||||
const { name, description, deadline, track } = req.body;
|
||||
const validTracks = ['赛道一', '赛道二', '人才测评'];
|
||||
const validTracks = ['赛道一', '赛道二', 'L2考核'];
|
||||
const trackVal = track !== undefined ? (validTracks.includes(track) ? track : '') : (existing as any).track;
|
||||
db.prepare('UPDATE projects SET name = ?, description = ?, deadline = ?, track = ? WHERE id = ?').run(
|
||||
name?.trim() || (existing as any).name,
|
||||
|
||||
@@ -47,3 +47,42 @@ export function applyHardRules(
|
||||
});
|
||||
return { dimensions, log };
|
||||
}
|
||||
|
||||
// ================= L2考核(7维)硬规则 =================
|
||||
// 规则来源:《L2考核成果物提交规范》§11 源码可运行前提
|
||||
const L2_FUNC_DIM = '功能完整性';
|
||||
const L2_TEST_DIM = '测试用例与测试结果';
|
||||
const L2_README_DIM = '代码质量+README';
|
||||
|
||||
export function applyL2HardRules(
|
||||
dims: HardRuleDim[],
|
||||
ctx: HardRuleContext
|
||||
): HardRuleResult {
|
||||
const log: string[] = [];
|
||||
const dimensions = dims.map(d => {
|
||||
const name = d.name;
|
||||
let cap = d.maxScore;
|
||||
// §11:无法按 README 运行属重大缺陷——功能完整性最高10分、测试维度最高5分
|
||||
if (ctx.buildFailed && name === L2_FUNC_DIM) cap = Math.min(cap, 10);
|
||||
if ((ctx.buildFailed || ctx.testStepFailed) && name === L2_TEST_DIM) cap = Math.min(cap, 5);
|
||||
if (!ctx.hasAnyReadme && name === L2_README_DIM) cap = Math.min(cap, 0);
|
||||
else if (!ctx.hasRootReadme && name === L2_README_DIM) cap = Math.min(cap, 6);
|
||||
if (ctx.duplicateRatio > 0.5 && name === L2_README_DIM) cap = Math.min(cap, 3);
|
||||
let score = d.score;
|
||||
if (score > cap) {
|
||||
log.push(`${name} ${score}→${cap}(L2硬规则封顶)`);
|
||||
score = cap;
|
||||
}
|
||||
return { name, score, maxScore: d.maxScore };
|
||||
});
|
||||
return { dimensions, log };
|
||||
}
|
||||
|
||||
/** 按赛道分发:L2考核走 7 维专属规则,其余沿用共通规则 */
|
||||
export function applyTrackHardRules(
|
||||
track: string,
|
||||
dims: HardRuleDim[],
|
||||
ctx: HardRuleContext
|
||||
): HardRuleResult {
|
||||
return track === 'L2考核' ? applyL2HardRules(dims, ctx) : applyHardRules(dims, ctx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface L2Topic {
|
||||
id: string;
|
||||
title: string;
|
||||
group?: string;
|
||||
difficulty: number;
|
||||
maxScoreCap: number;
|
||||
minTests?: number;
|
||||
funcBreakdown: string;
|
||||
acceptance: string[];
|
||||
sampleData?: string;
|
||||
}
|
||||
|
||||
interface L2TopicsFile {
|
||||
version: string;
|
||||
note: string;
|
||||
selfTopic: L2Topic;
|
||||
topics: L2Topic[];
|
||||
}
|
||||
|
||||
const FILE = path.resolve(__dirname, '../../config/l2-topics.json');
|
||||
let cache: L2TopicsFile | null = null;
|
||||
|
||||
function load(): L2TopicsFile {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
cache = JSON.parse(fs.readFileSync(FILE, 'utf-8')) as L2TopicsFile;
|
||||
} catch {
|
||||
cache = { version: '', note: '', selfTopic: emptySelf(), topics: [] };
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function emptySelf(): L2Topic {
|
||||
return { id: 'self', title: '自选题', difficulty: 0, maxScoreCap: 100, funcBreakdown: '', acceptance: [] };
|
||||
}
|
||||
|
||||
/** 全部选题(含自选题) */
|
||||
export function getL2Topics(): L2TopicsFile {
|
||||
return load();
|
||||
}
|
||||
|
||||
/** 按编号查选题('self' = 自选题);未命中返回 null */
|
||||
export function findL2Topic(id: string): L2Topic | null {
|
||||
if (!id) return null;
|
||||
const data = load();
|
||||
if (id === 'self') return data.selfTopic;
|
||||
return data.topics.find(t => t.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装「功能完整性」维度子 Agent 的选题验收基准上下文(extraContext 注入)。
|
||||
* 非命题/无匹配时返回空串。
|
||||
*/
|
||||
export function buildL2FuncContext(topicId: string, selfRegistration?: string): string {
|
||||
const t = findL2Topic(topicId);
|
||||
if (!t || !topicId) return '';
|
||||
const lines: string[] = [
|
||||
'', `## 选题与验收基准(评审的功能完整性30分以此为准)`,
|
||||
`选题:${t.id === 'self' ? '自选题' : `命题 ${t.id}|${t.title}`}(难度${'★'.repeat(t.difficulty)},满分上限${t.maxScoreCap})`,
|
||||
`功能完整性拆分口径:${t.funcBreakdown}`,
|
||||
'',
|
||||
'验收要点:',
|
||||
...t.acceptance.map(a => `- ${a}`),
|
||||
];
|
||||
if (t.minTests) lines.push(`最少测试用例数:${t.minTests} 个`);
|
||||
if (t.sampleData) lines.push(`题目专属样本数据要求:${t.sampleData}`);
|
||||
if (t.id === 'self') {
|
||||
const reg = parseRegistration(selfRegistration);
|
||||
lines.push(`自选题登记编号:${reg.no || '(未登记)'}`);
|
||||
if (reg.features) {
|
||||
lines.push('登记功能清单快照(README 声明低于该范围核心功能的缩减部分按未实现计):');
|
||||
for (const f of reg.features) lines.push(`- ${f}`);
|
||||
} else {
|
||||
lines.push('登记功能清单:未提供,按 README 声明功能清单核对');
|
||||
}
|
||||
}
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
/** 解析自选题登记字段(JSON 字符串 {no, features}),容错 */
|
||||
export function parseRegistration(raw?: string | null): { no: string; features: string[] } {
|
||||
if (!raw) return { no: '', features: [] };
|
||||
try {
|
||||
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
const features = Array.isArray(obj?.features)
|
||||
? obj.features.map((f: any) => String(f).trim()).filter(Boolean)
|
||||
: String(obj?.features || '').split(/[\n;;]/).map(s => s.trim()).filter(Boolean);
|
||||
return { no: String(obj?.no || ''), features };
|
||||
} catch {
|
||||
return { no: '', features: [] };
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,29 @@ export function buildEntryHtml(entry: any, project: any, report: any, dims: any[
|
||||
<td class="comment">${escapeHtml(d.comment || '-')}${d.verifiability?.note ? `<div class="verif-note">${escapeHtml(d.verifiability.note)}</div>` : ''}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
// L2考核合格判定行(预计算,避免模板内多层嵌套)
|
||||
const isL2 = project?.track === 'L2考核';
|
||||
const l2ResultLine = isL2 && entry.final_level
|
||||
? (entry.final_level === '合格'
|
||||
? '<br><strong>L2考核判定:</strong><span class="pass">✅ 合格</span>'
|
||||
: '<br><strong>L2考核判定:</strong><span class="fail">❌ 不合格</span>')
|
||||
+ (entry.late_days > 7 ? '<span>(迟交超过7个工作日,按0分处理)</span>' : '')
|
||||
: '';
|
||||
|
||||
// L2考核查重初筛(确定性 flags,仅告警,需人工复核)
|
||||
let plagBlock = '';
|
||||
if (isL2 && entry.plagiarism_json) {
|
||||
try {
|
||||
const p = JSON.parse(entry.plagiarism_json);
|
||||
if (p?.flags?.length) {
|
||||
plagBlock = '<div class="correction" style="background:#fef3c7;border:1px solid #f59e0b">'
|
||||
+ '<strong>查重初筛告警(需人工复核,不构成违规认定):</strong><ul style="margin:4px 0 0 16px;padding:0">'
|
||||
+ p.flags.map((f: any) => `<li>${escapeHtml(f.detail)}</li>`).join('')
|
||||
+ '</ul></div>';
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh"><head><meta charset="utf-8">
|
||||
<style>
|
||||
@@ -134,6 +157,7 @@ export function buildEntryHtml(entry: any, project: any, report: any, dims: any[
|
||||
<div class="score-big">${report.totalScore} / ${report.maxTotal} 分</div>
|
||||
<div class="pass-info">通过率 ${report.pct}%
|
||||
${entry.final_score >= entry.pass_line ? '<span class="pass">✅ 达标</span>' : '<span class="fail">❌ 未达标</span>'}
|
||||
${l2ResultLine}
|
||||
${entry.late_days > 0 ? `<br>迟交 ${entry.late_days} 天,扣除 ${entry.raw_score - entry.final_score} 分` : ''}
|
||||
${entry.attempt > 1 ? `<br>第 ${entry.attempt} 次提交(最高 ${entry.max_score_cap} 分)` : ''}
|
||||
</div>
|
||||
@@ -153,6 +177,7 @@ ${report.overview || report.overall ? `
|
||||
<tr><th>评审项</th><th>得分</th><th>满分</th><th>得分率</th><th>评语</th></tr>
|
||||
${rows}
|
||||
</table>
|
||||
${plagBlock}
|
||||
${entry.revisions?.length > 0 ? `<div class="correction">已修正 ${entry.revisions.length} 次</div>` : ''}
|
||||
<div class="footer">生成时间:${new Date().toLocaleString('zh-CN')}</div>
|
||||
</body></html>`;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* L2考核 跨仓库查重初筛(§10 独立完成与查重红线)。
|
||||
* 确定性检测,只产出 flags 供人工复核,不自动定罪。
|
||||
*
|
||||
* 检测项:
|
||||
* 1. 相同文件比对:业务代码文件与其他受验者仓库完全相同(MD5 一致,模板类文件除外)
|
||||
* 2. 归一化相似度:去除注释/空白后的源码文本 shingle → Jaccard 相似度过高
|
||||
* 3. 提交行为异常:无演进历史的单次全量提交、commit 作者信息异常
|
||||
*
|
||||
* 白名单:脚手架代码、依赖清单文件(package.json/requirements.txt 等)、
|
||||
* 公共模板文件、题目自带的结构约定。业务逻辑与文档正文雷同才是判定对象。
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/** 相似度判定阈值(Jaccard ≥ 此值标记高相似) */
|
||||
export const PLAG_SIM_THRESHOLD = 0.8;
|
||||
/** shingle 大小(字符数) */
|
||||
const SHINGLE_SIZE = 16;
|
||||
/** 单文件最大读取字节(避免超大文件拖慢) */
|
||||
const MAX_FILE_BYTES = 256 * 1024;
|
||||
/** 每个 flag 最多保留的配对样本数 */
|
||||
const MAX_SAMPLES_PER_FLAG = 8;
|
||||
|
||||
/** 依赖/脚手架白名单:这些文件名不参与查重 */
|
||||
const IGNORE_FILE_PATTERNS = [
|
||||
/^package-lock\.json$/i, /^package\.json$/i, /^requirements.*\.txt$/i, /^poetry\.lock$/i,
|
||||
/^Pipfile\.lock$/i, /^yarn\.lock$/i, /^pnpm-lock\.yaml$/i, /^Cargo\.lock$/i, /^go\.sum$/i,
|
||||
/^Gemfile\.lock$/i, /^composer\.lock$/i, /^tsconfig.*\.json$/i, /^vite\.config.*$/i,
|
||||
/^vitest\.config.*$/i, /^jest\.config.*$/i, /^eslint.*\.(js|json|mjs|cjs)$/i, /^\.gitignore$/i,
|
||||
/^LICENSE.*$/i, /^\.npmrc$/i, /^\.nvmrc$/i, /^Dockerfile$/i, /^\.env\.example$/i,
|
||||
];
|
||||
|
||||
/** 参与文本比对的源码扩展名(其余不参与归一化相似度) */
|
||||
const SOURCE_EXT = new Set([
|
||||
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.java', '.go', '.rs', '.c', '.cpp', '.h', '.hpp',
|
||||
'.cs', '.rb', '.php', '.swift', '.kt', '.scala', '.sql', '.sh', '.bash', '.vue', '.json', '.yaml', '.yml',
|
||||
'.md', '.html', '.css', '.scss',
|
||||
]);
|
||||
|
||||
interface FileEntry { path: string; rel: string; content: string; }
|
||||
|
||||
export interface SimilarityPair {
|
||||
file: string;
|
||||
otherRepo: string;
|
||||
otherFile: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PlagiarismFlag {
|
||||
kind: 'identicalFile' | 'highSimilarity' | 'suspiciousCommit';
|
||||
severity: 'high' | 'medium';
|
||||
detail: string;
|
||||
samples: { file: string; otherRepo?: string; otherFile?: string; score?: number }[];
|
||||
}
|
||||
|
||||
export interface PlagiarismReport {
|
||||
repo: string;
|
||||
flags: PlagiarismFlag[];
|
||||
scannedFiles: number;
|
||||
comparedRepos: number;
|
||||
/** 供注入/展示的紧凑文本 */
|
||||
toPrompt: string;
|
||||
}
|
||||
|
||||
function isIgnoredFile(name: string): boolean {
|
||||
return IGNORE_FILE_PATTERNS.some(re => re.test(name));
|
||||
}
|
||||
|
||||
function isSourceFile(rel: string): boolean {
|
||||
const ext = path.extname(rel).toLowerCase();
|
||||
return SOURCE_EXT.has(ext);
|
||||
}
|
||||
|
||||
/** 读取目录下全部文本文件(限制大小、跳过 .git/node_modules/二进制) */
|
||||
export function readRepoFiles(dir: string): FileEntry[] {
|
||||
const out: FileEntry[] = [];
|
||||
const walk = (cur: string) => {
|
||||
let items: fs.Dirent[];
|
||||
try { items = fs.readdirSync(cur, { withFileTypes: true }); } catch { return; }
|
||||
for (const it of items) {
|
||||
const full = path.join(cur, it.name);
|
||||
const rel = path.relative(dir, full).replace(/\\/g, '/');
|
||||
if (it.isDirectory()) {
|
||||
if (it.name === '.git' || it.name === 'node_modules' || it.name === '__pycache__' || it.name === '.venv' || it.name === 'venv' || it.name === 'dist' || it.name === 'build' || it.name === 'target' || it.name === 'coverage') continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (isIgnoredFile(it.name)) continue;
|
||||
let st: fs.Stats;
|
||||
try { st = fs.statSync(full); } catch { continue; }
|
||||
if (st.size > MAX_FILE_BYTES) continue;
|
||||
const buf = fs.readFileSync(full);
|
||||
// 跳过二进制(含 NUL 字节)
|
||||
if (buf.includes(0)) continue;
|
||||
out.push({ path: full, rel, content: buf.toString('utf-8') });
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** MD5 哈希,用于精确文件比对 */
|
||||
function md5(content: string): string {
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/** 归一化:去注释/空白/字符串?——保守实现:仅去行首尾空白与全空白行、转小写(保留结构,避免过度去串导致误判) */
|
||||
function normalizeCode(content: string): string {
|
||||
return content
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.length > 0 && !l.startsWith('#')) // 去除行首 # 注释(Python/Markdown 标题)
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** 生成 shingle 集合(字符 n-gram) */
|
||||
function shingles(text: string): Set<string> {
|
||||
const s = new Set<string>();
|
||||
const clean = text.replace(/\s+/g, ' ');
|
||||
if (clean.length <= SHINGLE_SIZE) { if (clean.length > 0) s.add(clean); return s; }
|
||||
for (let i = 0; i <= clean.length - SHINGLE_SIZE; i++) {
|
||||
s.add(clean.slice(i, i + SHINGLE_SIZE));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Jaccard 相似度 */
|
||||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 && b.size === 0) return 0;
|
||||
let inter = 0;
|
||||
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
||||
for (const x of small) if (large.has(x)) inter++;
|
||||
const union = a.size + b.size - inter;
|
||||
return union === 0 ? 0 : inter / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨仓库查重初筛(纯函数)。
|
||||
* @param targetRepo 待检仓库目录名(或标识)
|
||||
* @param targetFiles 待检仓库的文件列表
|
||||
* @param repoMap 其他仓库:目录名 → 文件列表
|
||||
*/
|
||||
export function detectPlagiarism(
|
||||
targetRepo: string,
|
||||
targetFiles: FileEntry[],
|
||||
repoMap: Record<string, FileEntry[]>
|
||||
): PlagiarismReport {
|
||||
const flags: PlagiarismFlag[] = [];
|
||||
let scannedFiles = 0;
|
||||
|
||||
const targetByRel = new Map<string, FileEntry>();
|
||||
const targetHashes = new Map<string, { rel: string; hash: string }[]>();
|
||||
for (const f of targetFiles) {
|
||||
if (!isSourceFile(f.rel)) continue;
|
||||
targetByRel.set(f.rel, f);
|
||||
const h = md5(f.content);
|
||||
if (!targetHashes.has(h)) targetHashes.set(h, []);
|
||||
targetHashes.get(h)!.push({ rel: f.rel, hash: h });
|
||||
}
|
||||
scannedFiles = targetByRel.size;
|
||||
|
||||
const comparedRepos = Object.keys(repoMap).length;
|
||||
const identicalSamples: PlagiarismFlag['samples'] = [];
|
||||
const simPairs: SimilarityPair[] = [];
|
||||
|
||||
for (const [otherRepo, otherFiles] of Object.entries(repoMap)) {
|
||||
if (otherRepo === targetRepo) continue;
|
||||
if (otherFiles.length === 0) continue;
|
||||
|
||||
const otherByRel = new Map<string, FileEntry>();
|
||||
const otherHashes = new Map<string, { rel: string }[]>();
|
||||
for (const f of otherFiles) {
|
||||
if (!isSourceFile(f.rel)) continue;
|
||||
otherByRel.set(f.rel, f);
|
||||
const h = md5(f.content);
|
||||
if (!otherHashes.has(h)) otherHashes.set(h, []);
|
||||
otherHashes.get(h)!.push({ rel: f.rel });
|
||||
}
|
||||
|
||||
// 1) 精确文件重复:哈希一致且非空、非同一相对路径白名单
|
||||
for (const [hash, list] of targetHashes) {
|
||||
const others = otherHashes.get(hash);
|
||||
if (!others || others.length === 0) continue;
|
||||
for (const t of list) {
|
||||
if (t.rel === '_AI_USAGE_LOG.md' || t.rel === 'AGENTS.md' || t.rel === 'README.md') continue; // 文档类模板容忍
|
||||
for (const o of others) {
|
||||
identicalSamples.push({ file: t.rel, otherRepo, otherFile: o.rel });
|
||||
if (identicalSamples.length >= MAX_SAMPLES_PER_FLAG) break;
|
||||
}
|
||||
if (identicalSamples.length >= MAX_SAMPLES_PER_FLAG) break;
|
||||
}
|
||||
if (identicalSamples.length >= MAX_SAMPLES_PER_FLAG) break;
|
||||
}
|
||||
|
||||
// 2) 归一化相似度:同名源码文件 shingle Jaccard(跨仓库同名文件更可能是直接复用)
|
||||
for (const [rel, tFile] of targetByRel) {
|
||||
if (identicalSamples.length >= MAX_SAMPLES_PER_FLAG) break;
|
||||
const oFile = otherByRel.get(rel);
|
||||
if (!oFile || rel.endsWith('.md')) continue;
|
||||
const a = shingles(normalizeCode(tFile.content));
|
||||
const b = shingles(normalizeCode(oFile.content));
|
||||
const sim = jaccard(a, b);
|
||||
if (sim >= PLAG_SIM_THRESHOLD) {
|
||||
simPairs.push({ file: rel, otherRepo, otherFile: rel, score: sim });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (identicalSamples.length > 0) {
|
||||
flags.push({
|
||||
kind: 'identicalFile',
|
||||
severity: 'high',
|
||||
detail: `${identicalSamples.length} 个文件与其他仓库完全相同(MD5 一致)`,
|
||||
samples: identicalSamples.slice(0, MAX_SAMPLES_PER_FLAG),
|
||||
});
|
||||
}
|
||||
if (simPairs.length > 0) {
|
||||
const top = simPairs.sort((a, b) => b.score - a.score)[0];
|
||||
flags.push({
|
||||
kind: 'highSimilarity',
|
||||
severity: 'medium',
|
||||
detail: `${simPairs.length} 个同名源码文件相似度 ≥ ${Math.round(PLAG_SIM_THRESHOLD * 100)}%(Jaccard),最高 ${Math.round(top.score * 100)}%`,
|
||||
samples: simPairs.slice(0, MAX_SAMPLES_PER_FLAG).map(p => ({ file: p.file, otherRepo: p.otherRepo, otherFile: p.otherFile, score: Math.round(p.score * 100) })),
|
||||
});
|
||||
}
|
||||
|
||||
const toPrompt = flags.length === 0
|
||||
? '查重初筛:未发现与其他仓库的相同文件或高相似源码(确定性检测)。'
|
||||
: `查重初筛告警(${flags.length} 项,仅供参考,需人工复核认定):\n` + flags.map(f => `- [${f.kind}] ${f.detail}`).join('\n');
|
||||
|
||||
return { repo: targetRepo, flags, scannedFiles, comparedRepos, toPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交行为信号:git 历史分析(确定性,需 simpleGit 实例)。
|
||||
* 检测:单次全量提交(仅 1 个 commit 且文件数多)、无演进历史。
|
||||
*/
|
||||
export function detectCommitBehavior(
|
||||
repoLabel: string,
|
||||
totalCommits: number,
|
||||
isSingleBulk: boolean
|
||||
): PlagiarismFlag | null {
|
||||
const signals: string[] = [];
|
||||
if (totalCommits === 0) signals.push('无任何提交(空仓库)');
|
||||
else if (totalCommits === 1) signals.push('仅 1 次提交(无演进历史)');
|
||||
if (isSingleBulk) signals.push('单次全量提交(可能为一次性 push 全部成果)');
|
||||
if (signals.length === 0) return null;
|
||||
return {
|
||||
kind: 'suspiciousCommit',
|
||||
severity: 'medium',
|
||||
detail: signals.join(';'),
|
||||
samples: [],
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function isBuildRelatedDim(name: string): boolean {
|
||||
return BUILD_RELATED_KEYWORDS.some(k => name.includes(k)) || (name.includes('稳定') && name.length >= 4);
|
||||
}
|
||||
|
||||
// A/B 两阶段拆分(2026-08-16):B 部分=构建后评的维度,当前只按赛道一实现(赛道二/人才测评全归 A)
|
||||
// A/B 两阶段拆分(2026-08-16):B 部分=构建后评的维度,当前只按赛道一实现(赛道二/L2考核全归 A)
|
||||
export const B_STAGE_DIMENSION_KEYWORDS = [
|
||||
'实现完整度',
|
||||
'效果与数据',
|
||||
|
||||
@@ -7,9 +7,11 @@ import http from 'http';
|
||||
import simpleGit from 'simple-git';
|
||||
import { config } from '../config';
|
||||
import { parseDimensions } from '../routes/standards';
|
||||
import { matchDimKey, computeFinalLevel, computeLatePenalty, computeCalibration, parseDimResponse, resolveSubmitTime, computeLateDays, classifyVerifiability, detectStructuralContradictions, neutralizeTestEvidence } from './standard-utils';
|
||||
import { matchDimKey, computeLatePenalty, computeCalibration, parseDimResponse, resolveSubmitTime, computeLateDays, classifyVerifiability, detectStructuralContradictions, neutralizeTestEvidence, computeL2Result, computeL2LatePenalty } from './standard-utils';
|
||||
import { isPathInside } from '../path-security';
|
||||
import { applyHardRules } from './hard-rules';
|
||||
import { applyHardRules, applyTrackHardRules } from './hard-rules';
|
||||
import { findL2Topic, buildL2FuncContext, parseRegistration } from './l2-topics';
|
||||
import { detectPlagiarism, readRepoFiles, detectCommitBehavior, PlagiarismReport } from './plagiarism-detect';
|
||||
import {
|
||||
REVIEW_CONSTANTS,
|
||||
BUILD_SYSTEMS,
|
||||
@@ -70,6 +72,111 @@ export function startReviewB(entryId: string, buildStatus: 'done' | 'failed' = '
|
||||
runReview(entryId, 'B', buildStatus);
|
||||
}
|
||||
|
||||
// ================= L2考核辅助(2026-08-23) =================
|
||||
function getProjectTrack(projectId: string): string {
|
||||
try {
|
||||
return ((db.prepare('SELECT track FROM projects WHERE id = ?').get(projectId) as any)?.track) || '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* L2考核迟交 >7 个工作日:按 0 分处理、不启动评审(终态,无重做)。
|
||||
* 在 clone 完成后调用(需 git log 判提交时间)。返回 true 表示已终局化,调用方直接 return。
|
||||
*/
|
||||
async function maybeFinalizeL2Abandoned(entry: any, dir: string): Promise<boolean> {
|
||||
if (getProjectTrack(entry.project_id) !== 'L2考核') return false;
|
||||
const project = db.prepare('SELECT deadline FROM projects WHERE id = ?').get(entry.project_id) as any;
|
||||
if (!project?.deadline) return false;
|
||||
try {
|
||||
let lastCommit: string | null = null;
|
||||
try {
|
||||
const log = await simpleGit(dir).log({ maxCount: 1 });
|
||||
lastCommit = log.latest?.date ?? null;
|
||||
} catch { }
|
||||
const submitTime = resolveSubmitTime(lastCommit, entry.created_at, Date.now());
|
||||
const dl = new Date(project.deadline);
|
||||
if (isNaN(dl.getTime())) return false;
|
||||
const lateDays = computeLateDays(submitTime, dl.getTime());
|
||||
if (lateDays <= 7) return false;
|
||||
|
||||
const aiReport = { dimensions: [], totalScore: 0, maxTotal: 100, pct: 0, raw: '', calibrationExplanation: '', overall: null as any, l2Abandoned: true };
|
||||
const finalLogs = [{ time: new Date().toISOString(), status: 'review_done', msg: `迟交 ${lateDays} 天(超过7个工作日期限),按 0 分处理,不启动评审` }];
|
||||
db.transaction(() => {
|
||||
db.prepare(`UPDATE entries SET status = 'review_done', ai_report = ?, raw_score = 0, final_score = 0, final_level = '不合格', progress_log = json(?), updated_at = datetime('now') WHERE id = ?`).run(
|
||||
JSON.stringify(aiReport), JSON.stringify(finalLogs), entry.id);
|
||||
db.prepare('INSERT INTO review_snapshots (id, entry_id, attempt, ai_report, standard_snapshot, score) VALUES (?, ?, ?, ?, ?, ?)').run(
|
||||
crypto.randomUUID(), entry.id, entry.attempt || 1, JSON.stringify(aiReport), entry.standard_snapshot || '', 0);
|
||||
})();
|
||||
addLog(entry.id, 'review_done', finalLogs[0].msg);
|
||||
pipeLog(entry.id, 'DONE', `L2迟交弃评 late=${lateDays}d score=0`, 0);
|
||||
const resolvedDir = path.resolve(dir);
|
||||
if (isPathInside(CLONE_DIR, resolvedDir)) {
|
||||
try { fs.rmSync(dir, { recursive: true }); } catch { }
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L2考核查重初筛:与 data/clone/ 下其他 clone 目录跨仓库比对(MD5 精确 + 归一化相似度),
|
||||
* 并分析提交行为信号。确定性检测,flags 落库(plagiarism_json)供人工复核,不自动定罪。
|
||||
* 仅在 L2考核 且存在其他 clone 目录时执行。
|
||||
*/
|
||||
async function runPlagiarismScreening(entry: any, dir: string): Promise<PlagiarismReport | null> {
|
||||
if (getProjectTrack(entry.project_id) !== 'L2考核') return null;
|
||||
const tPlag = Date.now();
|
||||
try {
|
||||
const targetFiles = readRepoFiles(dir);
|
||||
const repoMap: Record<string, { path: string; rel: string; content: string }[]> = {};
|
||||
let scanOther = 0;
|
||||
// 只比对其他"条目 clone 目录"(UUID 命名),排除静态参考样本目录(如 _real_b3/_real_cobol_java)
|
||||
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
try {
|
||||
for (const name of fs.readdirSync(CLONE_DIR)) {
|
||||
if (name === entry.id) continue;
|
||||
if (!uuidRe.test(name)) continue;
|
||||
const otherDir = path.join(CLONE_DIR, name);
|
||||
try { if (fs.statSync(otherDir).isDirectory()) { repoMap[name] = readRepoFiles(otherDir); scanOther++; } } catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const report = detectPlagiarism(entry.id, targetFiles, repoMap);
|
||||
|
||||
// 提交行为信号(best-effort:无 .git 时跳过)
|
||||
try {
|
||||
const git = simpleGit(dir);
|
||||
if (await git.checkIsRepo()) {
|
||||
const log = await git.log();
|
||||
const totalCommits = log.total || log.all?.length || 0;
|
||||
const isBulk = totalCommits === 1;
|
||||
const cb = detectCommitBehavior(entry.id, totalCommits, isBulk);
|
||||
if (cb) report.flags.push(cb);
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if (report.flags.length > 0) {
|
||||
db.prepare("UPDATE entries SET plagiarism_json = ? WHERE id = ?").run(JSON.stringify(report), entry.id);
|
||||
}
|
||||
pipeLog(entry.id, 'PLAG', `files=${report.scannedFiles} repos=${scanOther} flags=${report.flags.length}`, Date.now() - tPlag);
|
||||
return report;
|
||||
} catch (err: any) {
|
||||
pipeLog(entry.id, 'PLAG', `skipped: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** L2考核:按选题组装「功能完整性」维度的验收基准上下文;非 L2 返回 undefined */
|
||||
function buildL2ExtraContexts(entry: any): Record<string, string> | undefined {
|
||||
if (getProjectTrack(entry.project_id) !== 'L2考核') return undefined;
|
||||
const topicId = entry.selected_topic || 'self';
|
||||
if (!findL2Topic(topicId)) return undefined;
|
||||
const block = buildL2FuncContext(topicId, entry.self_registration);
|
||||
if (!block) return undefined;
|
||||
return { '功能完整性': '\n' + block };
|
||||
}
|
||||
|
||||
async function runReview(entryId: string, stage: 'A' | 'B', buildStatus?: 'done' | 'failed') {
|
||||
activeCount++;
|
||||
const tRun = Date.now();
|
||||
@@ -919,6 +1026,9 @@ async function executeReview(entryId: string) {
|
||||
pipeLog(entryId, 'CLONE', cloneOk ? 'ok' : 'FAILED', Date.now() - tClone);
|
||||
if (!cloneOk) return;
|
||||
|
||||
// L2考核:迟交超7个工作日 → 按0分处理,不启动评审(终态)
|
||||
if (await maybeFinalizeL2Abandoned(entry, dir)) return;
|
||||
|
||||
const tAnalyze = Date.now();
|
||||
addLog(entryId, 'analyzing', '正在分析代码...');
|
||||
const files = discoverFiles(dir) as any[];
|
||||
@@ -929,6 +1039,9 @@ async function executeReview(entryId: string) {
|
||||
const agentGateReport = buildAgentGateReport(files);
|
||||
pipeLog(entryId, 'GATES', `allPassed=${agentGateReport.allPassed}`);
|
||||
|
||||
// L2考核:跨仓库查重初筛(确定性 flags,供人工复核,不自动定罪)
|
||||
await runPlagiarismScreening(entry, dir);
|
||||
|
||||
// 方案②:项目理解文档(AI 解读代码生成,落库供 B 阶段与黑盒冒烟复用)
|
||||
const tUnderstand = Date.now();
|
||||
const understanding = await buildProjectUnderstanding(entryId, dir, files, codeStats);
|
||||
@@ -960,15 +1073,6 @@ async function executeReview(entryId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter dimensions by question_id for 人才测评 track
|
||||
const questionId = entry.question_id || '';
|
||||
if (questionId) {
|
||||
standardDims = standardDims.filter((d: any) => {
|
||||
const g = d.group || 'common';
|
||||
return g === 'common' || g === questionId;
|
||||
});
|
||||
}
|
||||
|
||||
// A/B 阶段拆分:B = 构建后评维度(实现完整度/效果与数据),其余全??A
|
||||
const aDims = standardDims.filter((d: any) => d.stage !== 'B');
|
||||
const bDims = standardDims.filter((d: any) => d.stage === 'B');
|
||||
@@ -1013,7 +1117,7 @@ async function executeReview(entryId: string) {
|
||||
: '参赛者未提供服务地址(仅做代码评审)';
|
||||
|
||||
if (bDims.length === 0) {
|
||||
// ================= 无 B 维度:一体化完整流程(赛道二/人才测评)=================
|
||||
// ================= 无 B 维度:一体化完整流程(赛道二/L2考核)=================
|
||||
// 人工构建确认(2026-08-18 扩展到单阶段):entry.build_status = done/failed 时跳过自动 tryBuild
|
||||
// 避免 npm install 等重依赖安装超时被误判"构建失败";未确认仍自动构建(兼容旧行为)
|
||||
const manualBuild = (entry.build_status || '').trim();
|
||||
@@ -1112,11 +1216,12 @@ ${overviewFileBlock}
|
||||
|
||||
const dimensions: any[] = [];
|
||||
const toRun = [...standardDims];
|
||||
const l2ExtraContexts = buildL2ExtraContexts(entry);
|
||||
const runNext = async () => {
|
||||
while (toRun.length > 0) {
|
||||
const dim = toRun.shift()!;
|
||||
const tDim = Date.now();
|
||||
const r = await runSubAgent(dim, projectContext, files, buildResult, startResult, browseResult, entry.category_tag, baseBranchDiff, agentGateReport, testEvidence);
|
||||
const r = await runSubAgent(dim, projectContext, files, buildResult, startResult, browseResult, entry.category_tag, baseBranchDiff, agentGateReport, testEvidence, undefined, l2ExtraContexts);
|
||||
if (r) dimensions.push(r);
|
||||
pipeLog(entryId, ' DIM', `${r?.name || '?'} → ${r?.score}/${r?.maxScore} [${Date.now() - tDim}ms]`);
|
||||
}
|
||||
@@ -1205,7 +1310,8 @@ ${JSON.stringify(dimensions.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
const hasRootReadme = files.some(f => path.dirname(f.path) === '.' && /readme\.md$/i.test(path.basename(f.path)));
|
||||
const testStepFailed = buildResult.steps.some(s => s.status === 'fail' && s.command.includes('pytest'));
|
||||
const buildFailed = !buildResult.canBuild && !serviceUrl && !buildResult.untested;
|
||||
const { dimensions: cappedDims, log: hardRulesLog } = applyHardRules(
|
||||
const { dimensions: cappedDims, log: hardRulesLog } = applyTrackHardRules(
|
||||
getProjectTrack(entry.project_id),
|
||||
dimensions.map(d => ({ name: d.name, score: d.score, maxScore: d.maxScore })),
|
||||
{ buildFailed, testStepFailed, duplicateRatio: codeStats.duplicateRatio, hasAnyReadme, hasRootReadme }
|
||||
);
|
||||
@@ -1226,11 +1332,8 @@ ${JSON.stringify(dimensions.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
|
||||
const pct = maxTotal > 0 ? Math.round((totalScore / maxTotal) * 100) : 0;
|
||||
|
||||
// L2/L3 score split for 人才测评 track
|
||||
const projTrack = getProjectTrack(entry.project_id);
|
||||
let finalLevel = '';
|
||||
if (questionId) {
|
||||
finalLevel = computeFinalLevel(dimensions, entry.pass_line || 0);
|
||||
}
|
||||
|
||||
// Late penalty (on actual score, not percentage)
|
||||
let penalty = 0;
|
||||
@@ -1249,16 +1352,33 @@ ${JSON.stringify(dimensions.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
if (isNaN(deadline.getTime())) throw new Error('invalid deadline');
|
||||
lateDays = computeLateDays(submitTime, deadline.getTime());
|
||||
if (lateDays > 0) {
|
||||
penalty = computeLatePenalty(totalScore, lateDays, project.late_penalty ?? REVIEW_CONSTANTS.DEFAULT_LATE_PENALTY);
|
||||
// L2考核:迟1~3个工作日扣5、4~7天扣10(>7天已在管线入口按0分弃评)
|
||||
penalty = projTrack === 'L2考核'
|
||||
? computeL2LatePenalty(lateDays)
|
||||
: computeLatePenalty(totalScore, lateDays, project.late_penalty ?? REVIEW_CONSTANTS.DEFAULT_LATE_PENALTY);
|
||||
db.prepare("UPDATE entries SET late_days = ? WHERE id = ?").run(lateDays, entryId);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// L2考核:难度赋分(★★+0 / ★★★+5 / ★★★★+10)——高难题拥有更大失分容错空间,最终分封顶 max_score_cap
|
||||
let l2Bonus = 0;
|
||||
let l2Cap = 100;
|
||||
if (projTrack === 'L2考核') {
|
||||
const t = findL2Topic(entry.selected_topic || 'self');
|
||||
if (t) { l2Bonus = Math.max(0, t.difficulty - 2) * 5; l2Cap = t.maxScoreCap; }
|
||||
}
|
||||
const cappedScore = entry.max_score_cap && entry.max_score_cap < 100 ? Math.min(totalScore, entry.max_score_cap) : totalScore;
|
||||
const finalScore = Math.max(0, Math.round(cappedScore - penalty));
|
||||
const finalScore = projTrack === 'L2考核'
|
||||
? Math.min(l2Cap, Math.max(0, Math.round(cappedScore + l2Bonus - penalty)))
|
||||
: Math.max(0, Math.round(cappedScore - penalty));
|
||||
const finalPct = maxTotal > 0 ? Math.round((finalScore / maxTotal) * 100) : 0;
|
||||
|
||||
// L2考核合格判定:≥60 且 功能完整性 ≥15(地板线),写入 final_level 供列表/详情/PDF 展示
|
||||
if (projTrack === 'L2考核') {
|
||||
finalLevel = computeL2Result(finalScore, dimensions);
|
||||
}
|
||||
|
||||
const existingLog = db.prepare('SELECT progress_log FROM entries WHERE id = ?').get(entryId) as any;
|
||||
let finalLogs: any[] = [];
|
||||
if (existingLog?.progress_log) {
|
||||
@@ -1355,11 +1475,12 @@ ${overviewFileBlockA}
|
||||
|
||||
const dimensionsA: any[] = [];
|
||||
const toRunA = [...aDims];
|
||||
const l2ExtraContextsA = buildL2ExtraContexts(entry);
|
||||
const runNextA = async () => {
|
||||
while (toRunA.length > 0) {
|
||||
const dim = toRunA.shift()!;
|
||||
const tDim = Date.now();
|
||||
const r = await runSubAgent(dim, projectContextA, files, EMPTY_BUILD_RESULT, undefined, undefined, entry.category_tag, baseBranchDiff, agentGateReport, undefined);
|
||||
const r = await runSubAgent(dim, projectContextA, files, EMPTY_BUILD_RESULT, undefined, undefined, entry.category_tag, baseBranchDiff, agentGateReport, undefined, undefined, l2ExtraContextsA);
|
||||
if (r) dimensionsA.push(r);
|
||||
pipeLog(entryId, ' DIM', `${r?.name || '?'} → ${r?.score}/${r?.maxScore} [${Date.now() - tDim}ms]`);
|
||||
}
|
||||
@@ -1410,7 +1531,8 @@ ${JSON.stringify(dimensionsA.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
// A 阶段硬规则:A 阶段无构??测试验证(B 阶段执行),??buildFailed/testStepFailed 视???false
|
||||
const hasAnyReadme = files.some(f => /readme\.md$/i.test(path.basename(f.path)));
|
||||
const hasRootReadme = files.some(f => path.dirname(f.path) === '.' && /readme\.md$/i.test(path.basename(f.path)));
|
||||
const { dimensions: cappedDimsA, log: hardRulesLogA } = applyHardRules(
|
||||
const { dimensions: cappedDimsA, log: hardRulesLogA } = applyTrackHardRules(
|
||||
getProjectTrack(entry.project_id),
|
||||
dimensionsA.map(d => ({ name: d.name, score: d.score, maxScore: d.maxScore })),
|
||||
{ buildFailed: false, testStepFailed: false, duplicateRatio: codeStats.duplicateRatio, hasAnyReadme, hasRootReadme }
|
||||
);
|
||||
@@ -1485,6 +1607,12 @@ async function executeReviewB(entryId: string, buildStatus: 'done' | 'failed' =
|
||||
}
|
||||
pipeLog(entryId, 'ANALYZE_B', `files=${files.length} lines=${codeStats.totalLines}`);
|
||||
|
||||
// L2考核:迟交超7个工作日 → 按0分处理,不启动评审(终态)
|
||||
if (await maybeFinalizeL2Abandoned(entry, dir)) return;
|
||||
|
||||
// L2考核:跨仓库查重初筛(确定性 flags,供人工复核,不自动定罪)
|
||||
await runPlagiarismScreening(entry, dir);
|
||||
|
||||
// 人工构建确认(2026-08-16):系统不再自动 tryBuild,评委确认构建结果
|
||||
// 构造合法 buildResult 供子 Agent/硬规则使用:failed → canBuild=false(触发 B 维度封顶);done → 不证伪
|
||||
const buildFailed = buildStatus === 'failed';
|
||||
@@ -1535,13 +1663,6 @@ async function executeReviewB(entryId: string, buildStatus: 'done' | 'failed' =
|
||||
let standardDims: any[] = [];
|
||||
try { standardDims = JSON.parse(entry.standard_snapshot || '[]'); } catch { standardDims = []; }
|
||||
if (!Array.isArray(standardDims)) standardDims = [];
|
||||
const questionId = entry.question_id || '';
|
||||
if (questionId) {
|
||||
standardDims = standardDims.filter((d: any) => {
|
||||
const g = d.group || 'common';
|
||||
return g === 'common' || g === questionId;
|
||||
});
|
||||
}
|
||||
const bDims = standardDims.filter((d: any) => d.stage === 'B');
|
||||
if (bDims.length === 0) {
|
||||
pipeLog(entryId, 'FAIL_B', 'no B dimensions in standard snapshot');
|
||||
@@ -1604,11 +1725,12 @@ async function executeReviewB(entryId: string, buildStatus: 'done' | 'failed' =
|
||||
|
||||
const dimensionsB: any[] = [];
|
||||
const toRunB = [...bDims];
|
||||
const l2ExtraContextsB = buildL2ExtraContexts(entry);
|
||||
const runNextB = async () => {
|
||||
while (toRunB.length > 0) {
|
||||
const dim = toRunB.shift()!;
|
||||
const tDim = Date.now();
|
||||
const r = await runSubAgent(dim, projectContextB, files, buildResult, startResult, browseResult, entry.category_tag, '', undefined, testEvidence, smokeEvidence);
|
||||
const r = await runSubAgent(dim, projectContextB, files, buildResult, startResult, browseResult, entry.category_tag, '', undefined, testEvidence, smokeEvidence, l2ExtraContextsB);
|
||||
if (r) dimensionsB.push(r);
|
||||
pipeLog(entryId, ' DIM', `${r?.name || '?'} → ${r?.score}/${r?.maxScore} [${Date.now() - tDim}ms]`);
|
||||
}
|
||||
@@ -1689,7 +1811,8 @@ ${JSON.stringify(dimensionsB.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
const hasAnyReadme = files.some(f => /readme\.md$/i.test(path.basename(f.path)));
|
||||
const hasRootReadme = files.some(f => path.dirname(f.path) === '.' && /readme\.md$/i.test(path.basename(f.path)));
|
||||
const testStepFailed = !!testEvidence && testEvidence.tested && !testEvidence.passed;
|
||||
const { dimensions: cappedDimsB, log: hardRulesLogB } = applyHardRules(
|
||||
const { dimensions: cappedDimsB, log: hardRulesLogB } = applyTrackHardRules(
|
||||
getProjectTrack(entry.project_id),
|
||||
dimensionsB.map(d => ({ name: d.name, score: d.score, maxScore: d.maxScore })),
|
||||
{ buildFailed, testStepFailed, duplicateRatio: codeStats.duplicateRatio, hasAnyReadme, hasRootReadme }
|
||||
);
|
||||
@@ -1712,6 +1835,15 @@ ${JSON.stringify(dimensionsB.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
const maxTotal = (aDimensions.reduce((s: number, d: any) => s + (d.maxScore || 0), 0) || scoreA) + maxScoreB;
|
||||
const pct = maxTotal > 0 ? Math.round((totalScore / maxTotal) * 100) : 0;
|
||||
|
||||
// L2考核:赛道/选题元数据(迟交罚则与难度赋分都要用,须先于迟交计算声明)
|
||||
const projTrackB = getProjectTrack(entry.project_id);
|
||||
let l2Bonus = 0;
|
||||
let l2Cap = 100;
|
||||
if (projTrackB === 'L2考核') {
|
||||
const t = findL2Topic(entry.selected_topic || 'self');
|
||||
if (t) { l2Bonus = Math.max(0, t.difficulty - 2) * 5; l2Cap = t.maxScoreCap; }
|
||||
}
|
||||
|
||||
// 迟交扣分(§2.5,与 A 阶段一致的计算,B 阶段合并后统一执行)
|
||||
let penalty = 0;
|
||||
let lateDays = 0;
|
||||
@@ -1728,18 +1860,28 @@ ${JSON.stringify(dimensionsB.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
if (isNaN(deadline.getTime())) throw new Error('invalid deadline');
|
||||
lateDays = computeLateDays(submitTime, deadline.getTime());
|
||||
if (lateDays > 0) {
|
||||
penalty = computeLatePenalty(totalScore, lateDays, project.late_penalty ?? REVIEW_CONSTANTS.DEFAULT_LATE_PENALTY);
|
||||
// L2考核:迟1~3个工作日扣5、4~7天扣10(>7天已在管线入口按0分弃评)
|
||||
penalty = projTrackB === 'L2考核'
|
||||
? computeL2LatePenalty(lateDays)
|
||||
: computeLatePenalty(totalScore, lateDays, project.late_penalty ?? REVIEW_CONSTANTS.DEFAULT_LATE_PENALTY);
|
||||
db.prepare("UPDATE entries SET late_days = ? WHERE id = ?").run(lateDays, entryId);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const cappedScore = entry.max_score_cap && entry.max_score_cap < 100 ? Math.min(totalScore, entry.max_score_cap) : totalScore;
|
||||
const finalScore = Math.max(0, Math.round(cappedScore - penalty));
|
||||
const finalScore = projTrackB === 'L2考核'
|
||||
? Math.min(l2Cap, Math.max(0, Math.round(cappedScore + l2Bonus - penalty)))
|
||||
: Math.max(0, Math.round(cappedScore - penalty));
|
||||
const finalPct = maxTotal > 0 ? Math.round((finalScore / maxTotal) * 100) : 0;
|
||||
|
||||
// 完整 ai_report = A 维度 + B 维度(完全覆盖 A 阶段部分报告 §2.5.5)
|
||||
const dimensions = [...aDimensions, ...dimensionsB];
|
||||
// L2考核合格判定(在 dimensions 合并后):≥60 且 功能完整性 ≥15 地板线
|
||||
let l2FinalLevel = '';
|
||||
if (projTrackB === 'L2考核') {
|
||||
l2FinalLevel = computeL2Result(finalScore, dimensions);
|
||||
}
|
||||
const aCalib = aReportCalibration(entry.ai_report);
|
||||
const calibrationFull = (aCalib ? aCalib + '\n\n' : '') + calibrationExplanationB;
|
||||
const aiReport = {
|
||||
@@ -1784,8 +1926,8 @@ ${JSON.stringify(dimensionsB.map(d => ({ name: d.name, score: d.score, maxScore:
|
||||
db.prepare("UPDATE entries SET deliverables = ? WHERE id = ?").run(JSON.stringify(deliverables), entryId);
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare(`UPDATE entries SET status = 'review_done', ai_report = ?, raw_score = ?, final_score = ?, score_a = ?, score_b = ?, stage_b_status = 'done', progress_log = json(?), updated_at = datetime('now') WHERE id = ?`).run(
|
||||
JSON.stringify(aiReport), totalScore, finalScore, scoreA, scoreB, JSON.stringify(bLogs), entryId);
|
||||
db.prepare(`UPDATE entries SET status = 'review_done', ai_report = ?, raw_score = ?, final_score = ?, final_level = ?, score_a = ?, score_b = ?, stage_b_status = 'done', progress_log = json(?), updated_at = datetime('now') WHERE id = ?`).run(
|
||||
JSON.stringify(aiReport), totalScore, finalScore, l2FinalLevel, scoreA, scoreB, JSON.stringify(bLogs), entryId);
|
||||
|
||||
db.prepare('INSERT INTO review_snapshots (id, entry_id, attempt, ai_report, standard_snapshot, score) VALUES (?, ?, ?, ?, ?, ?)').run(
|
||||
crypto.randomUUID(), entryId, entry.attempt || 1, JSON.stringify(aiReport), entry.standard_snapshot, finalScore);
|
||||
@@ -1855,6 +1997,15 @@ export const DIM_FILE_FILTERS: Record<string, (f: { path: string }) => boolean>
|
||||
'稳定性与易用性': (f) => /test|spec|error|retry|fallback|timeout|config|exception|log/i.test(f.path) && !/node_modules/i.test(f.path),
|
||||
'规模、功能点、技术难度': (f) => /\.(ts|js|py|java|go|rs|tsx|jsx|vue|rb|php|swift)$/i.test(f.path) && !/node_modules|dist|build|__pycache__/i.test(f.path),
|
||||
'规模与功能点与技术难度': (f) => /\.(ts|js|py|java|go|rs|tsx|jsx|vue|rb|php|swift)$/i.test(f.path) && !/node_modules|dist|build|__pycache__/i.test(f.path),
|
||||
|
||||
// L2考核(7维,2026-08-23):维度名与赛道一/二不同,需专属文件过滤,避免回退到全量文件导致截断漏关键证据
|
||||
'功能完整性': (f) => /\.(ts|js|py|java|go|rs|tsx|jsx|rb|php|swift|md)$/i.test(f.path) && !/node_modules|dist|build|__pycache__/i.test(f.path),
|
||||
'设计文档': (f) => /DESIGN|design|arch|spec|\.md$/i.test(f.path) && !/node_modules/i.test(f.path),
|
||||
'测试用例与测试结果': (f) => /test|spec|__tests__|pytest|jest|vitest|coverage|report|assert|verify|check/i.test(f.path) && !/node_modules|dist|build/i.test(f.path),
|
||||
'AI协作过程记录': (f) => /agent|ai[-_ ]?log|ai[_ -]?usage|usage[_ -]?log|claude|日志|開発記録|AGENTS|CLAUDE/i.test(f.path),
|
||||
'技术选型与范式运用': (f) => /AGENTS|CLAUDE|\.md$|design|arch|skill|spec|prompt|选型|選型|范式|VibeCoding|Superpowers|SpecKit|MCP/i.test(f.path) && !/node_modules/i.test(f.path),
|
||||
'代码质量+README': (f) => /README|\.(ts|js|py|java|go|rs|tsx|jsx|rb|php|swift)$/i.test(f.path) && !/node_modules|dist|build|__pycache__/i.test(f.path),
|
||||
'业务场景理解与需求分析': (f) => /README|DESIGN|\.md$|需求|痛点|効果|効果|分析/i.test(f.path) && !/node_modules/i.test(f.path),
|
||||
};
|
||||
|
||||
export function filterFilesForDim(dimName: string, files: { path: string; content: string; size: number }[], dim?: any): string {
|
||||
@@ -1881,7 +2032,7 @@ export function filterFilesForDim(dimName: string, files: { path: string; conten
|
||||
return files.map(fmt).join('\n\n');
|
||||
}
|
||||
|
||||
async function runSubAgent(dim: any, projectContext: string, files: { path: string; content: string; size: number }[], buildResult: BuildResult, startResult?: StartResult, browseResult?: BrowseResult, categoryTag?: string, baseBranchDiff?: string, agentGateReport?: { toPrompt: string; allPassed: boolean }, testEvidence?: TestEvidence, smokeEvidence?: SmokeEvidence): Promise<any> {
|
||||
async function runSubAgent(dim: any, projectContext: string, files: { path: string; content: string; size: number }[], buildResult: BuildResult, startResult?: StartResult, browseResult?: BrowseResult, categoryTag?: string, baseBranchDiff?: string, agentGateReport?: { toPrompt: string; allPassed: boolean }, testEvidence?: TestEvidence, smokeEvidence?: SmokeEvidence, extraDimContext?: Record<string, string>): Promise<any> {
|
||||
let fileBlock = filterFilesForDim(dim.name, files, dim);
|
||||
const isBuildDim = isBuildRelatedDim(dim.name);
|
||||
const MAX_FILE_CHARS = isBuildDim ? REVIEW_CONSTANTS.MAX_FILE_CHARS_BUILD : REVIEW_CONSTANTS.MAX_FILE_CHARS_NORMAL;
|
||||
@@ -1904,6 +2055,9 @@ async function runSubAgent(dim: any, projectContext: string, files: { path: stri
|
||||
? `\n\n## 构建测试详情\n${buildResult.steps.length ? buildResult.steps.map(s => `[${s.status}] ${s.command}\n${s.output.slice(0, 500)}`).join('\n\n') : buildResult.summary}${verifyContext}${smokeContext}`
|
||||
: '';
|
||||
|
||||
// L2考核:按选题注入的验收基准上下文(如「功能完整性」维度)
|
||||
const dimTopicContext = extraDimContext?.[dim.name] || '';
|
||||
|
||||
// 「规模」类两个历史 key 共用同一评审指南
|
||||
const sizeAndTechGuideline = (maxScore: number) => `## 评审指南
|
||||
|
||||
@@ -2283,7 +2437,7 @@ ${projectContext}
|
||||
### ${dim.name}(满分${dim.maxScore}分)
|
||||
${dimensionRule}${videoSubNote}${ideBoundaryNote}
|
||||
|
||||
## 项目文件内容(你只需从这个维度评审)${fileBlock}${extraContext}${baseBranchDiff ? `\n\n## 基线差异(与基础分支对比,重点看新增/修改的AI辅助生成代码)\n${baseBranchDiff}` : ''}
|
||||
## 项目文件内容(你只需从这个维度评审)${fileBlock}${extraContext}${dimTopicContext}${baseBranchDiff ? `\n\n## 基线差异(与基础分支对比,重点看新增/修改的AI辅助生成代码)\n${baseBranchDiff}` : ''}
|
||||
${isEvidenceDim(dim.name) && agentGateReport ? `\n\n${agentGateReport.toPrompt}` : ''}
|
||||
${dim.name.includes('效果') && testEvidence ? testEvidenceToPrompt(testEvidence) : ''}
|
||||
${(dim.name.includes('实现完整') || dim.name.includes('效果')) && smokeEvidence && smokeEvidence.tested ? smokeEvidenceToPrompt(smokeEvidence) : ''}
|
||||
|
||||
@@ -25,9 +25,6 @@ export function computeEffectiveTotal(dims: DimLike[]): number {
|
||||
}
|
||||
|
||||
export function computePassLine(dims: DimLike[], track = ''): number {
|
||||
if (track === '人才测评') {
|
||||
return Math.round(computeCommonTotal(dims) * 0.6);
|
||||
}
|
||||
return Math.round(computeEffectiveTotal(dims) * 0.6);
|
||||
}
|
||||
|
||||
@@ -44,28 +41,6 @@ export function matchDimKey(dimName: string, keys: string[]): string | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人才测评 L2/L3 认定(§3.3.8):评审管线与人工修正共用,避免双路径漂移(K3)。
|
||||
*/
|
||||
export function computeFinalLevel(
|
||||
dims: { score: number; maxScore: number; group?: string }[],
|
||||
passLine: number
|
||||
): string {
|
||||
let l2Score = 0, l2Max = 0, l3Score = 0, l3Max = 0;
|
||||
for (const d of dims) {
|
||||
const g = d.group || 'common';
|
||||
if (g === 'common') { l2Score += Math.round(d.score); l2Max += d.maxScore; }
|
||||
else { l3Score += Math.round(d.score); l3Max += d.maxScore; }
|
||||
}
|
||||
const l2Passed = l2Score >= (passLine > 0 ? passLine : Math.round(l2Max * REVIEW_CONSTANTS.L2_PASS_RATIO));
|
||||
if (l3Max > 0 && l2Passed) {
|
||||
const totalForL3 = l2Score + l3Score;
|
||||
const maxForL3 = l2Max + l3Max;
|
||||
return maxForL3 > 0 && (totalForL3 / maxForL3) >= REVIEW_CONSTANTS.L3_RATIO ? 'L3' : 'L2';
|
||||
}
|
||||
return l2Passed ? 'L2' : '不合格';
|
||||
}
|
||||
|
||||
/**
|
||||
* 迟交扣分(§3.3.9):评审管线与人工修正共用。
|
||||
*/
|
||||
@@ -113,6 +88,32 @@ export function computeLateDays(submitTime: number, deadline: number | string):
|
||||
return Math.floor((submitTime - dl) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* L2考核合格判定(2026-08-23):
|
||||
* - 合格线:最终得分 ≥ passLine(默认60)
|
||||
* - 地板线:功能完整性维度得分 < floorLine(默认15,不足满分一半)→ 无论总分多少判不合格(防空壳文档过线)
|
||||
* 未评审/无维度数据返回 ''(不判定)。
|
||||
*/
|
||||
export function computeL2Result(
|
||||
finalScore: number | null | undefined,
|
||||
dims: { name: string; score: number }[] | null | undefined,
|
||||
passLine = 60,
|
||||
floorDim = '功能完整性',
|
||||
floorLine = 15
|
||||
): '合格' | '不合格' | '' {
|
||||
if (finalScore == null || !Array.isArray(dims) || dims.length === 0) return '';
|
||||
const fd = dims.find(d => d.name === floorDim);
|
||||
const floorOk = !fd || fd.score >= floorLine;
|
||||
return finalScore >= passLine && floorOk ? '合格' : '不合格';
|
||||
}
|
||||
|
||||
/** L2考核迟交罚则:迟1~3个工作日扣5分、4~7个工作日扣10分;>7天由管线按0分弃评(不走本函数) */
|
||||
export function computeL2LatePenalty(lateDays: number): number {
|
||||
if (lateDays <= 0) return 0;
|
||||
if (lateDays <= 3) return 5;
|
||||
return 10;
|
||||
}
|
||||
|
||||
export interface CalibrationContradiction {
|
||||
name: string;
|
||||
direction: 'over' | 'under';
|
||||
|
||||
@@ -29,16 +29,19 @@ export default async function globalSetup() {
|
||||
PORT: '3002',
|
||||
AUTH_PASSWORD: 'test123',
|
||||
ADMIN_TEST_TOKEN: 'true',
|
||||
// 隔离 e2e 测试库,避免污染真实评审数据
|
||||
DB_PATH: path.join(SERVER_DIR, 'data', 'e2e-test.db'),
|
||||
// 测试模式下 writeEnvVar 只更新进程内 env,避免改密用例污染真实 server/.env
|
||||
NODE_ENV: 'test',
|
||||
} as Record<string, string>;
|
||||
|
||||
const shell = process.platform === 'win32' ? { shell: 'cmd.exe' } : {};
|
||||
console.log('[setup] Starting backend server...');
|
||||
const server = spawn('npx.cmd', ['tsx', 'src/index.ts'], {
|
||||
cwd: SERVER_DIR,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
shell: true,
|
||||
...shell,
|
||||
});
|
||||
server.stdout.on('data', (d: Buffer) => process.stdout.write(`[server] ${d}`));
|
||||
server.stderr.on('data', (d: Buffer) => process.stderr.write(`[server-err] ${d}`));
|
||||
@@ -49,7 +52,7 @@ export default async function globalSetup() {
|
||||
cwd: WEB_DIR,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env } as Record<string, string>,
|
||||
shell: true,
|
||||
...shell,
|
||||
});
|
||||
frontend.stdout.on('data', (d: Buffer) => process.stdout.write(`[web] ${d}`));
|
||||
frontend.stderr.on('data', (d: Buffer) => process.stderr.write(`[web-err] ${d}`));
|
||||
|
||||
@@ -5,9 +5,9 @@ import { test, expect, type Page, type APIRequestContext } from '@playwright/tes
|
||||
// 覆盖对象:
|
||||
// 1. 模板维度可声明「文件关键词」→ DIM_FILE_FILTERS 硬编码仅兑底
|
||||
// 2. 模板 content 成为评审指南第一来源(评审要点完整保留并透传)
|
||||
// 3. 人才测评 L2「功能完整性」盲区修复(isBuildRelatedDim)
|
||||
// 3. L2考核「功能完整性」构建封顶维度识别(isBuildRelatedDim)
|
||||
// 4. 阈值收拢命名常量后 pass_line / 及格线行为回归
|
||||
// 5. question_id 分组过滤(快照只含 common + 所选 Qn)
|
||||
// 5. L2考核 选题(selected_topic)与难度赋分 cap
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
const PASSWORD = 'test123';
|
||||
@@ -107,20 +107,21 @@ test.describe('项目创建与赛道自动标准', () => {
|
||||
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
|
||||
});
|
||||
|
||||
test('1.3 人才测评自动生成含「功能完整性」标准', async ({ request }) => {
|
||||
test('1.3 L2考核自动生成 7 维标准(功能完整性 30)', async ({ request }) => {
|
||||
const token = await apiLogin(request);
|
||||
const pid = await apiCreateProject(request, token, `人才-${UNIQUE}`, '人才测评');
|
||||
const pid = await apiCreateProject(request, token, `L2-${UNIQUE}`, 'L2考核');
|
||||
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const standards = await r.json();
|
||||
expect(standards).toHaveLength(1);
|
||||
const dims = standards[0].dimensions;
|
||||
expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 40)).toBe(true);
|
||||
expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 30)).toBe(true);
|
||||
|
||||
// 共通维度合计 100(L2 及格线 = 60)
|
||||
const commonTotal = dims.filter((d: any) => (d.group || 'common') === 'common').reduce((s: number, d: any) => s + d.maxScore, 0);
|
||||
expect(commonTotal).toBe(100);
|
||||
// 7 维合计 100(L2 合格线 = 60)
|
||||
const total = dims.reduce((s: number, d: any) => s + d.maxScore, 0);
|
||||
expect(total).toBe(100);
|
||||
expect(dims).toHaveLength(7);
|
||||
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
|
||||
});
|
||||
});
|
||||
@@ -273,50 +274,51 @@ test.describe('条目快照透传', () => {
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 4. 人才测评:question_id 分组过滤 + 功能完整性
|
||||
// 4. L2考核:选题(selected_topic)+ 难度赋分 cap
|
||||
// ══════════════════════════════════════
|
||||
|
||||
test.describe('人才测评 question_id 分组', () => {
|
||||
test.describe('L2考核 选题与 cap', () => {
|
||||
let pid = '';
|
||||
let token = '';
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
token = await apiLogin(request);
|
||||
pid = await apiCreateProject(request, token, `人才Q-${UNIQUE}`, '人才测评');
|
||||
pid = await apiCreateProject(request, token, `L2Q-${UNIQUE}`, 'L2考核');
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
|
||||
});
|
||||
|
||||
test('4.1 建条目未选题目被拒绝(400)', async ({ request }) => {
|
||||
test('4.1 无效选题被拒绝(400)', async ({ request }) => {
|
||||
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { title: '无题目', repo_url: `https://noq-${UNIQUE}.git` },
|
||||
data: { title: '无效选题', repo_url: `https://noid-${UNIQUE}.git`, selected_topic: '99' },
|
||||
});
|
||||
expect(r.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('4.2 Q2 条目快照包含 common 与 [Q2] 维度(完整标准,评审时按题目过滤)', async ({ request }) => {
|
||||
const entry = await apiCreateEntry(request, token, pid, 'Q2选手', { question_id: 'Q2' });
|
||||
const dims = JSON.parse(entry.standard_snapshot);
|
||||
|
||||
const groups = new Set(dims.map((d: any) => d.group || 'common'));
|
||||
expect(groups.has('common')).toBe(true);
|
||||
expect(groups.has('Q2')).toBe(true);
|
||||
// 快照保留完整标准(含其他题目维度),评审时按 question_id 过滤
|
||||
expect(groups.has('Q3')).toBe(true);
|
||||
|
||||
// 功能完整性在 common 组且是构建封顶维度
|
||||
const fnDim = dims.find((d: any) => d.name === '功能完整性');
|
||||
expect(fnDim).toBeDefined();
|
||||
expect(fnDim.maxScore).toBe(40);
|
||||
test('4.2 命题 11(★★)→ cap=100;自选题 → cap=100', async ({ request }) => {
|
||||
const e1 = await apiCreateEntry(request, token, pid, '题11选手', { selected_topic: '11' });
|
||||
expect(e1.selected_topic).toBe('11');
|
||||
expect(e1.max_score_cap).toBe(100);
|
||||
const e2 = await apiCreateEntry(request, token, pid, '自选选手', { selected_topic: 'self' });
|
||||
expect(e2.max_score_cap).toBe(100);
|
||||
});
|
||||
|
||||
test('4.3 pass_line = 共通维度 60% = 60', async ({ request }) => {
|
||||
const entry = await apiCreateEntry(request, token, pid, 'Q1选手', { question_id: 'Q1' });
|
||||
expect(entry.pass_line).toBe(60);
|
||||
test('4.3 命题 03(★★★★)→ cap=110(难度赋分封顶)', async ({ request }) => {
|
||||
const entry = await apiCreateEntry(request, token, pid, '题03选手', { selected_topic: '03' });
|
||||
expect(entry.max_score_cap).toBe(110);
|
||||
});
|
||||
|
||||
test('4.4 快照为 7 维标准(全 common,功能完整性 30)', async ({ request }) => {
|
||||
const entry = await apiCreateEntry(request, token, pid, '快照选手', { selected_topic: '01' });
|
||||
const dims = JSON.parse(entry.standard_snapshot);
|
||||
expect(dims).toHaveLength(7);
|
||||
expect(dims.every((d: any) => (d.group || 'common') === 'common')).toBe(true);
|
||||
const fnDim = dims.find((d: any) => d.name === '功能完整性');
|
||||
expect(fnDim.maxScore).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -175,55 +175,62 @@ test.describe('PDF 下载', () => {
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 4. 人才测评 L2/L3 分表展示
|
||||
// 4. L2考核:条目列表合格/不合格徽章展示
|
||||
// ══════════════════════════════════════
|
||||
|
||||
test.describe('人才测评 L2/L3 分表', () => {
|
||||
test.describe('L2考核 合格/不合格徽章', () => {
|
||||
let pid = ''; let eid = ''; let token = '';
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
token = await apiToken(request);
|
||||
const proj = await request.post('http://localhost:3002/api/projects', {
|
||||
headers: { Authorization: `Bearer ${token}` }, data: { name: `l2l3-${UNIQUE}`, track: '人才测评' },
|
||||
headers: { Authorization: `Bearer ${token}` }, data: { name: `l2badge-${UNIQUE}`, track: 'L2考核' },
|
||||
});
|
||||
pid = (await proj.json()).id;
|
||||
// 删除自动标准,替换为自定义 Q2 标准
|
||||
const list = await request.get(`http://localhost:3002/api/projects/${pid}/standards`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
for (const s of await list.json()) {
|
||||
await request.delete(`http://localhost:3002/api/projects/${pid}/standards/${s.id}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
}
|
||||
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { name: 'l2-std', category_tag: '人才测评', content: '## 功能完整性(40分)\n## 设计文档(10分)\n## [Q2] LLM生成问卷(15分)' },
|
||||
});
|
||||
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { title: 'l2l3-entry', repo_url: `file://C:\\l2-${UNIQUE}`, question_id: 'Q2' },
|
||||
data: { title: 'l2-entry', repo_url: `file://C:\\l2-${UNIQUE}`, selected_topic: '11' },
|
||||
});
|
||||
eid = (await entry.json()).id;
|
||||
// 播种评审结果:总分 70 ≥60 且 功能完整性 25 ≥15 → 合格
|
||||
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
dimensions: [
|
||||
{ name: '功能完整性', score: 40, maxScore: 40, group: 'common' },
|
||||
{ name: '设计文档', score: 10, maxScore: 10, group: 'common' },
|
||||
{ name: 'LLM生成问卷', score: 15, maxScore: 15, group: 'Q2' },
|
||||
{ name: '功能完整性', score: 25, maxScore: 30 },
|
||||
{ name: '设计文档', score: 8, maxScore: 10 },
|
||||
{ name: '测试用例与测试结果', score: 7, maxScore: 10 },
|
||||
{ name: 'AI协作过程记录', score: 10, maxScore: 15 },
|
||||
{ name: '技术选型与范式运用', score: 10, maxScore: 15 },
|
||||
{ name: '代码质量+README', score: 6, maxScore: 10 },
|
||||
{ name: '业务场景理解与需求分析', score: 4, maxScore: 10 },
|
||||
],
|
||||
},
|
||||
});
|
||||
// 触发合格判定(PUT /report 重算 final_level)
|
||||
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/report`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { dimensions: [
|
||||
{ name: '功能完整性', score: 25, maxScore: 30 },
|
||||
{ name: '设计文档', score: 8, maxScore: 10 },
|
||||
{ name: '测试用例与测试结果', score: 7, maxScore: 10 },
|
||||
{ name: 'AI协作过程记录', score: 10, maxScore: 15 },
|
||||
{ name: '技术选型与范式运用', score: 10, maxScore: 15 },
|
||||
{ name: '代码质量+README', score: 6, maxScore: 10 },
|
||||
{ name: '业务场景理解与需求分析', score: 4, maxScore: 10 },
|
||||
] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
});
|
||||
|
||||
test('详情面板分别展示 L2共通 与 L3追加 分表', async ({ page }) => {
|
||||
test('列表展示「合格」徽章(L2考核 判定)', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`/project/${pid}`);
|
||||
await page.click('.title-cell');
|
||||
await expect(page.getByText('L2共通评分')).toBeVisible();
|
||||
await expect(page.getByText('L3追加评分')).toBeVisible();
|
||||
await expect(page.getByText('合格', { exact: true })).toBeVisible();
|
||||
// 条目还展示选题徽章
|
||||
await expect(page.getByText('命题 11')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,19 +205,25 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
const [editEntry, setEditEntry] = useState<any>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', question_id: '' });
|
||||
const [addForm, setAddForm] = useState({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', selected_topic: 'self', reg_no: '', reg_features: '' });
|
||||
const [l2Topics, setL2Topics] = useState<any[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [questionFilter, setQuestionFilter] = useState('');
|
||||
const PAGE_LIMIT = 50;
|
||||
|
||||
// L2考核:加载选题元数据(命题01~11),供条目表单选题下拉
|
||||
useEffect(() => {
|
||||
if (track === 'L2考核') {
|
||||
api.request('GET', '/projects/l2-topics').then((d: any) => setL2Topics(d?.topics || [])).catch(() => {});
|
||||
}
|
||||
}, [track]);
|
||||
|
||||
const latestSearchRef = useRef('');
|
||||
|
||||
const doSearch = (term: string, pageOffset = offset, qFilter = questionFilter) => {
|
||||
const doSearch = (term: string, pageOffset = offset) => {
|
||||
latestSearchRef.current = term;
|
||||
const params: any = { limit: PAGE_LIMIT, offset: pageOffset };
|
||||
if (status) params.status = status;
|
||||
if (term) params.search = term;
|
||||
if (qFilter) params.question_id = qFilter;
|
||||
api.listEntries(projectId, params).then(r => {
|
||||
if (latestSearchRef.current === term) {
|
||||
setEntries(r.items);
|
||||
@@ -225,14 +231,14 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
const load = () => doSearch(search, offset, questionFilter);
|
||||
const load = () => doSearch(search, offset);
|
||||
|
||||
const goPage = (newOffset: number) => {
|
||||
setOffset(newOffset);
|
||||
setTimeout(() => doSearch(search, newOffset, questionFilter), 0);
|
||||
setTimeout(() => doSearch(search, newOffset), 0);
|
||||
};
|
||||
|
||||
useEffect(() => { setOffset(0); doSearch(search, 0, questionFilter); }, [projectId, status, search, questionFilter]);
|
||||
useEffect(() => { setOffset(0); doSearch(search, 0); }, [projectId, status, search]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
@@ -265,32 +271,52 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
const doEditSave = async () => {
|
||||
if (!editEntry) return;
|
||||
try {
|
||||
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, {
|
||||
const payload: any = {
|
||||
title: editEntry.title,
|
||||
repo_url: editEntry.repo_url,
|
||||
participant: editEntry.participant,
|
||||
branch: editEntry.branch,
|
||||
sub_type: editEntry.sub_type,
|
||||
question_id: editEntry.question_id,
|
||||
service_url: editEntry.service_url,
|
||||
build_status: editEntry.build_status,
|
||||
});
|
||||
};
|
||||
if (track === 'L2考核') {
|
||||
payload.selected_topic = editEntry.selected_topic || 'self';
|
||||
if (payload.selected_topic === 'self') {
|
||||
payload.self_registration = { no: editEntry.reg_no, features: editEntry.reg_features };
|
||||
}
|
||||
}
|
||||
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, payload);
|
||||
setEditEntry(null);
|
||||
await load();
|
||||
} catch (err: any) { alert(err.message || '保存失败'); }
|
||||
};
|
||||
|
||||
const openEdit = (e: any) => setEditEntry({
|
||||
id: e.id, title: e.title, repo_url: e.repo_url, participant: e.participant || '',
|
||||
sub_type: e.sub_type || '', question_id: e.question_id || '', branch: e.branch || '',
|
||||
service_url: e.service_url || '', build_status: e.build_status || '',
|
||||
});
|
||||
const openEdit = (e: any) => {
|
||||
let reg = { no: '', features: '' };
|
||||
try { const p = JSON.parse(e.self_registration || '{}'); reg = { no: p.no || '', features: Array.isArray(p.features) ? p.features.join('\n') : (p.features || '') }; } catch { }
|
||||
setEditEntry({
|
||||
id: e.id, title: e.title, repo_url: e.repo_url, participant: e.participant || '',
|
||||
sub_type: e.sub_type || '', branch: e.branch || '',
|
||||
service_url: e.service_url || '', build_status: e.build_status || '',
|
||||
selected_topic: e.selected_topic || 'self', reg_no: reg.no, reg_features: reg.features,
|
||||
});
|
||||
};
|
||||
|
||||
const doAdd = async () => {
|
||||
if (!addForm.title.trim() || !addForm.repo_url.trim()) return;
|
||||
const payload: any = { ...addForm };
|
||||
delete payload.reg_no;
|
||||
delete payload.reg_features;
|
||||
if (track === 'L2考核') {
|
||||
payload.selected_topic = addForm.selected_topic || 'self';
|
||||
if (payload.selected_topic === 'self') {
|
||||
payload.self_registration = { no: addForm.reg_no, features: addForm.reg_features };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api.request('POST', `/projects/${projectId}/entries`, addForm);
|
||||
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', question_id: '' });
|
||||
await api.request('POST', `/projects/${projectId}/entries`, payload);
|
||||
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', selected_topic: 'self', reg_no: '', reg_features: '' });
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch (err: any) { alert(err.message || '添加失败'); }
|
||||
@@ -323,10 +349,10 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
const downloadTemplate = () => {
|
||||
const header = ['title', 'repo_url', 'participant', 'branch', 'service_url', 'base_branch'];
|
||||
if (track === '赛道一') header.push('sub_type');
|
||||
if (track === '人才测评') header.push('question_id');
|
||||
if (track === 'L2考核') header.push('selected_topic');
|
||||
const example = header.map(h =>
|
||||
h === 'repo_url' ? 'https://github.com/user/repo' :
|
||||
h === 'question_id' ? 'Q1' :
|
||||
h === 'selected_topic' ? '01' :
|
||||
h === 'sub_type' ? '新規' : ''
|
||||
).join(',');
|
||||
const blob = new Blob(['\uFEFF' + header.join(',') + '\n' + example], { type: 'text/csv;charset=utf-8' });
|
||||
@@ -384,17 +410,6 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<option value="analysis_fail">分析失败</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
{track === '人才测评' && (
|
||||
<select value={questionFilter} onChange={e => { setQuestionFilter(e.target.value); setOffset(0); }} className="filter-select">
|
||||
<option value="">全部题目</option>
|
||||
<option value="Q1">Q1 满意度调查</option>
|
||||
<option value="Q2">Q2 面谈问卷</option>
|
||||
<option value="Q3">Q3 RAG检索</option>
|
||||
<option value="Q4">Q4 合同审查</option>
|
||||
<option value="Q5">Q5 法规爬虫</option>
|
||||
<option value="Q6">Q6 风险情报</option>
|
||||
</select>
|
||||
)}
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索标题..." className="search-input" onKeyDown={e => e.key === 'Enter' && doSearch(e.currentTarget.value)} />
|
||||
<button onClick={() => setShowAdd(true)} className="btn-primary">+ 添加条目</button>
|
||||
<button onClick={batchStart} disabled={selected.size === 0} className="btn-primary">启动选中 ({selected.size})</button>
|
||||
@@ -423,21 +438,19 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<option value="修正">修正/升级</option>
|
||||
</select>
|
||||
)}
|
||||
{track === '人才测评' && (
|
||||
{track === 'L2考核' && (
|
||||
<>
|
||||
<select value={addForm.question_id} onChange={e => setAddForm({ ...addForm, question_id: e.target.value })} className="select-field">
|
||||
<option value="">选择题目 *</option>
|
||||
<option value="Q1">Q1 满意度调查(★★ L2のみ)</option>
|
||||
<option value="Q2">Q2 面谈问卷(★★★ L3可)</option>
|
||||
<option value="Q3">Q3 RAG检索(★★★★ L3可)</option>
|
||||
<option value="Q4">Q4 合同审查(★★★ L3可)</option>
|
||||
<option value="Q5">Q5 法规爬虫(★★★ L3可)</option>
|
||||
<option value="Q6">Q6 风险情报(★★★ L3可)</option>
|
||||
<select value={addForm.selected_topic} onChange={e => setAddForm({ ...addForm, selected_topic: e.target.value })} className="select-field">
|
||||
<option value="self">自选题(需事前登记)</option>
|
||||
{l2Topics.map(t => (
|
||||
<option key={t.id} value={t.id}>命题 {t.id}|{t.title}({'★'.repeat(t.difficulty)} 满分{t.maxScoreCap})</option>
|
||||
))}
|
||||
</select>
|
||||
{addForm.question_id && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
|
||||
{addForm.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
|
||||
</div>
|
||||
{addForm.selected_topic === 'self' && (
|
||||
<>
|
||||
<input value={addForm.reg_no} onChange={e => setAddForm({ ...addForm, reg_no: e.target.value })} placeholder="登记编号(组委会登记确认后获得)" className="input-field" />
|
||||
<textarea value={addForm.reg_features} onChange={e => setAddForm({ ...addForm, reg_features: e.target.value })} placeholder="登记功能清单(每行一条;低于该范围核心功能的缩减按未实现计)" rows={3} className="input-field" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -488,9 +501,9 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<td>
|
||||
<span className="badge badge-blue">{e.category_tag}</span>
|
||||
{e.sub_type && <span className="badge badge-purple ml-8">{e.sub_type}</span>}
|
||||
{e.question_id && <span className="badge badge-gray ml-8">{e.question_id}</span>}
|
||||
{e.selected_topic && <span className="badge badge-gray ml-8">{e.selected_topic === 'self' ? '自选' : `命题 ${e.selected_topic}`}</span>}
|
||||
{e.final_level && (
|
||||
<span className={`badge ml-8 ${e.final_level === 'L3' ? 'badge-purple' : e.final_level === 'L2' ? 'badge-green' : 'badge-red'}`}>
|
||||
<span className={`badge ml-8 ${e.final_level === 'L3' ? 'badge-purple' : (e.final_level === 'L2' || e.final_level === '合格') ? 'badge-green' : 'badge-red'}`}>
|
||||
{e.final_level}
|
||||
</span>
|
||||
)}
|
||||
@@ -562,21 +575,19 @@ function EntryManager({ projectId, track }: { projectId: string; track?: string
|
||||
<option value="修正">修正/升级</option>
|
||||
</select>
|
||||
)}
|
||||
{track === '人才测评' && (
|
||||
{track === 'L2考核' && (
|
||||
<>
|
||||
<select value={editEntry.question_id || ''} onChange={e => setEditEntry({ ...editEntry, question_id: e.target.value })} className="select-field">
|
||||
<option value="">选择题目 *</option>
|
||||
<option value="Q1">Q1 满意度调查(★★ L2のみ)</option>
|
||||
<option value="Q2">Q2 面谈问卷(★★★ L3可)</option>
|
||||
<option value="Q3">Q3 RAG检索(★★★★ L3可)</option>
|
||||
<option value="Q4">Q4 合同审查(★★★ L3可)</option>
|
||||
<option value="Q5">Q5 法规爬虫(★★★ L3可)</option>
|
||||
<option value="Q6">Q6 风险情报(★★★ L3可)</option>
|
||||
<select value={editEntry.selected_topic || 'self'} onChange={e => setEditEntry({ ...editEntry, selected_topic: e.target.value })} className="select-field">
|
||||
<option value="self">自选题(需事前登记)</option>
|
||||
{l2Topics.map(t => (
|
||||
<option key={t.id} value={t.id}>命题 {t.id}|{t.title}({'★'.repeat(t.difficulty)} 满分{t.maxScoreCap})</option>
|
||||
))}
|
||||
</select>
|
||||
{editEntry.question_id && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
|
||||
{editEntry.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
|
||||
</div>
|
||||
{editEntry.selected_topic === 'self' && (
|
||||
<>
|
||||
<input value={editEntry.reg_no || ''} onChange={e => setEditEntry({ ...editEntry, reg_no: e.target.value })} placeholder="登记编号" className="input-field" />
|
||||
<textarea value={editEntry.reg_features || ''} onChange={e => setEditEntry({ ...editEntry, reg_features: e.target.value })} placeholder="登记功能清单(每行一条)" rows={3} className="input-field" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -680,7 +691,7 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
|
||||
</div>
|
||||
<div className="detail-meta">
|
||||
<div>仓库:<code>{entry.repo_url}</code></div>
|
||||
<div>参赛者:{entry.participant || '-'} | 及格线:{entry.pass_line || '-'}分{entry.question_id ? ` | 选题:${entry.question_id}` : ''}</div>
|
||||
<div>参赛者:{entry.participant || '-'} | 及格线:{entry.pass_line || '-'}分{entry.selected_topic ? ` | 选题:${entry.selected_topic === 'self' ? '自选题' : `命题 ${entry.selected_topic}`}` : ''}</div>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{finalLevel && (
|
||||
<span className={`badge ${finalLevel === 'L3' ? 'badge-purple' : finalLevel === 'L2' ? 'badge-green' : 'badge-red'}`}>
|
||||
@@ -706,6 +717,23 @@ function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projec
|
||||
{entry.attempt > 1 && <div className="retake-badge">第 {entry.attempt} 次提交</div>}
|
||||
</div>
|
||||
|
||||
{entry.plagiarism_json && (() => {
|
||||
let p: any = null;
|
||||
try { p = JSON.parse(entry.plagiarism_json); } catch { }
|
||||
if (!p || !p.flags || p.flags.length === 0) return null;
|
||||
return (
|
||||
<div className="overview-section" style={{ paddingLeft: 12, borderLeft: '3px solid #f59e0b', marginBottom: 12 }}>
|
||||
<h4 style={{ marginBottom: 8, color: '#b45309' }}>查重初筛告警(需人工复核,不构成违规认定)</h4>
|
||||
{p.flags.map((f: any, i: number) => (
|
||||
<div key={i} style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 6, marginLeft: 12 }}>
|
||||
<span className={`badge ${f.severity === 'high' ? 'badge-red' : 'badge-amber'} mr-8`}>{f.kind === 'identicalFile' ? '相同文件' : f.kind === 'highSimilarity' ? '高相似源码' : '提交行为'}</span>
|
||||
{f.detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(overview || overall) && (
|
||||
<div className="overview-section" style={{ paddingLeft: 12 }}>
|
||||
<h4 style={{ marginBottom: 8 }}>整体评价</h4>
|
||||
@@ -1193,30 +1221,24 @@ function SummaryView({ projectId }: { projectId: string }) {
|
||||
const [summary, setSummary] = useState<any>(null);
|
||||
useEffect(() => { api.request('GET', `/projects/${projectId}/summary`).then(setSummary).catch(() => {}); }, [projectId]);
|
||||
|
||||
const isTalent = summary?.categories?.some((c: any) => /^Q\d$/.test(c.category));
|
||||
// L2考核 按 final_level(合格/不合格)展示认定结果,其余赛道按是否达线展示
|
||||
const isL2Cat = (c: string) => c === 'L2考核';
|
||||
|
||||
if (!summary) return <div className="loading">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>汇总排名{isTalent ? '(人才测评)' : ''}</h3>
|
||||
<h3 style={{ margin: 0 }}>汇总排名</h3>
|
||||
<button onClick={() => downloadPdf(`/api/projects/${projectId}/summary/export`).catch(() => {})} className="btn-secondary">下载汇总PDF</button>
|
||||
</div>
|
||||
|
||||
{/* Category groups */}
|
||||
{summary.categories?.map((cat: any) => {
|
||||
const isQ = /^Q\d$/.test(cat.category);
|
||||
const showLevel = isQ || cat.category === '人才测评';
|
||||
const showLevel = isL2Cat(cat.category);
|
||||
return (
|
||||
<div key={cat.category} className="summary-section">
|
||||
<h4>
|
||||
{isQ ? (
|
||||
<><span className="badge badge-blue" style={{ marginRight: 6 }}>{cat.category}</span> {cat.entries.length}个条目</>
|
||||
) : (
|
||||
cat.category
|
||||
)}
|
||||
</h4>
|
||||
<h4>{cat.category} · {cat.entries.length}个条目</h4>
|
||||
<table className="entry-table">
|
||||
<thead><tr><th>排名</th><th>标题</th><th>参赛者</th><th>得分</th><th>及格线</th><th>{showLevel ? '认定' : '结果'}</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -1229,7 +1251,7 @@ function SummaryView({ projectId }: { projectId: string }) {
|
||||
<td>{e.pass_line}</td>
|
||||
<td>
|
||||
{showLevel && e.final_level ? (
|
||||
<span className={`badge ${e.final_level === 'L3' ? 'badge-purple' : e.final_level === 'L2' ? 'badge-green' : 'badge-red'}`}>
|
||||
<span className={`badge ${e.final_level === '合格' || e.final_level === 'L2' ? 'badge-green' : e.final_level === 'L3' ? 'badge-purple' : 'badge-red'}`}>
|
||||
{e.final_level}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function Sidebar() {
|
||||
<option value="">请选择赛道(必选)</option>
|
||||
<option value="赛道一">赛道一:Agent开发实战</option>
|
||||
<option value="赛道二">赛道二:IDE+开发范式创新</option>
|
||||
<option value="人才测评">人才测评</option>
|
||||
<option value="L2考核">AI人才育成认证——L2</option>
|
||||
</select>
|
||||
{error && <div className="text-sm" style={{ color: 'var(--danger)', marginTop: 4 }}>{error}</div>}
|
||||
<div className="new-project-actions">
|
||||
|
||||
Reference in New Issue
Block a user