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:
hangshuo652
2026-08-23 20:58:56 +08:00
parent 4da7044c4b
commit 6a2419d4a4
26 changed files with 1306 additions and 662 deletions
+9 -5
View File
@@ -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 初始化 + migrationsALTER 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-Axxx`人才测评追加维度前缀)→ group=Q2;其余 common
- 验证:三个标准模板解析结果应固定为——赛道一 12维/150、赛道二 8维/100、人才测评 common100+maxBonus50/effective150Q2-Q6 分组正确)
- group 推断:`## [Q2] xxx`(显式)或 `## 2-A. xxx` / `## 2-Axxx`题目追加维度前缀)→ 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-18migration 添加):赛道二/人才测评单阶段人工构建确认,''=未确认(自动构建) / done / failed;赛道一 B 阶段不存此列,走 /verify 请求体
- `build_status TEXT DEFAULT ''`2026-08-18migration 添加):赛道二/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,封顶选题 cap100/105/110)。合格线≥60 且 功能完整性≥15(地板线)
当前维度 guideline 只覆盖标准维度名(如"场景价值与合理性"),赛道一变体名(如"开发范式与架构设计")需扩展匹配关键词。L2考核 7 维标准自带分档锚点与交叉验证规则,content 优先不触发内置兜底。
+16 -21
View File
@@ -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 @@ L2Level 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级用例附针对性改进建议 | 抽查评级合理性 |
| 报告规范与规则外置 | 评级结果+覆盖矩阵完整展示 | 评级标准在配置文件中可调整 | 演示验证 |
| 报告规范与规则外置 | 评级结果+覆盖矩阵完整展示 | 评级标准在配置文件中可调整,有脚本佐证 | 运行验证脚本核验 |
#### 提交物清单
+210
View File
@@ -0,0 +1,210 @@
{
"version": "2026-08-23",
"note": "L2考核选题元数据。maxScoreCap=共通100分+难度赋分;funcBreakdown 为功能完整性30分的题目专属拆分,注入该维度子Agent promptacceptance 为验收基准要点;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缺要素、技术栈声明与依赖不一致)+ 缺陷标注清单 + 备用清单模板一套"
}
]
}
-357
View File
@@ -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分
## 代码质量 + README10分)
检查以下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分
## 代码质量+README10分)
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分并在评语标注
+30 -28
View File
@@ -448,73 +448,75 @@ describe('Cookie AuthK7 回归)', () => {
});
});
describe('Admin Correction: cap + final_levelK3 回归)', () => {
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## 代码质量+README10分)\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_reportfinal_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);
});
});
+1 -1
View File
@@ -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',
+79
View File
@@ -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();
});
});
+3 -32
View File
@@ -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);
+1 -1
View File
@@ -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分)',
'共通',
+1 -1
View File
@@ -314,7 +314,7 @@ describe('isBuildRelatedDim(构建封顶维度识别,含功能完整性盲
expect(isBuildRelatedDim('实现完整度')).toBe(true);
});
it('修复 功能完整性 盲区(人才测评 L2', () => {
it('识别 功能完整性L2 构建封顶维度', () => {
expect(isBuildRelatedDim('功能完整性')).toBe(true);
});
+6 -1
View File
@@ -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`);
+64 -20
View File
@@ -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_levelL2考核用 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));
+12 -6
View File
@@ -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,
+39
View File
@@ -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);
}
+95
View File
@@ -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: [] };
}
}
+25
View File
@@ -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>`;
+258
View File
@@ -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: [],
};
}
+1 -1
View File
@@ -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 = [
'实现完整度',
'效果与数据',
+191 -37
View File
@@ -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) : ''}
+26 -25
View File
@@ -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
* - 线 passLine60
* - 线 < floorLine15 线
* / ''
*/
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';
+5 -2
View File
@@ -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}`));
+32 -30
View File
@@ -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);
// 共通维度合计 100L2 格线 = 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 维合计 100L2 格线 = 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);
});
});
+29 -22
View File
@@ -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();
});
});
+93 -71
View File
@@ -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>
) : (
+1 -1
View File
@@ -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">