L2文档三大板块重构+评分细则不公开化;系统对齐:一键三轮评审、受验者注册表映射、提交状态探测工具

- 文档重构为 考核目的(1.x)/提交要求(2.x,含成果物需求)/考核内容(3.x) 板块编号体系
- 删除公开评分细则(7维表/合格线/难度赋分/功能拆分),保留验收基准与提交要求
- 自选题取消事前登记,改由 AGENTS.md 记录核心内容;前端同步移除登记字段
- 新增 l2-participants 注册表服务与 teams-config L2 URL 自动映射
- 新增 startReviewRounds 一键N轮评审(自动续跑+快照聚合中位数),前端「重新评审×3」
- 新增 check-repos.mjs 提交状态探测(API四态判定:已提交/空仓/未创建/未授权)
- 文档措辞与实现对齐(AI辅助评审)、修复引用/残片/格式问题
This commit is contained in:
hangshuo652
2026-08-26 10:52:23 +08:00
parent 6a2419d4a4
commit 040808ef6b
10 changed files with 510 additions and 554 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* 参赛/受验者仓库提交状态探测工具
* 用法:node server/scripts/check-repos.mjs [赛道一|赛道二|L2考核|all]
*
* 通过 Gitea API 快速判定四种状态(无需克隆):
* SUBMITTED 仓库存在且非空(已提交)
* EMPTY 仓库存在但为空(建仓未推送)
* NOT_CREATED 仓库不存在(或全局评审账号无权限且无备用凭据可区分)
* NO_ACCESS 仓库存在(队伍自身凭据可见)但评审账号未被授权协作者
* AUTH_FAIL 队伍自身凭据失效
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..');
// ---------- 加载 .env ----------
function loadEnv() {
const p = path.join(ROOT, 'server', '.env');
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m && !line.trim().startsWith('#')) out[m[1]] = m[2].trim();
}
return out;
}
const env = loadEnv();
const G_USER = env.GITEA_USERNAME || '';
const G_TOKEN = env.GITEA_TOKEN || '';
if (!G_USER || !G_TOKEN) {
console.error('缺少 GITEA_USERNAME / GITEA_TOKENserver/.env');
process.exit(1);
}
const filterArg = process.argv[2] || 'all';
// ---------- 组装探测目标 ----------
const targets = [];
if (filterArg === 'all' || filterArg === '赛道一' || filterArg === '赛道二') {
const teams = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'teams.json'), 'utf8')).teams || [];
for (const t of teams) {
if (filterArg !== 'all' && t.track !== filterArg) continue;
targets.push({
kind: t.track,
label: `${t.team || t.dept || ''}${t.leader ? '/' + t.leader : ''}`,
user: t.gittea.user,
repo: t.gittea.repo,
// 备用凭据:队伍自己的 token/密码(用于区分"未创建"与"未授权"
altAuth: t.gittea.token
? { type: 'token', value: t.gittea.token }
: (t.gittea.password ? { type: 'basic', value: t.gittea.password } : null),
});
}
}
if (filterArg === 'all' || filterArg === 'L2考核') {
try {
const l2 = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'l2-participants.json'), 'utf8'));
for (const p of l2.participants || []) {
targets.push({
kind: 'L2考核',
label: `${p.no}${p.name ? '/' + p.name : ''}`,
user: p.gitteaUser,
repo: p.repo || 'L2-assessment',
altAuth: null, // 自注册模式无个人 token404 无法与"未授权"区分
});
}
} catch { /* 注册表不存在则跳过 */ }
}
if (targets.length === 0) {
console.error('没有匹配的探测目标。用法: node server/scripts/check-repos.mjs [赛道一|赛道二|L2考核|all]');
process.exit(1);
}
// ---------- 探测 ----------
async function probeRepo(base, user, repo, auth) {
const url = `${base.replace(/\/+$/, '')}/api/v1/repos/${encodeURIComponent(user)}/${encodeURIComponent(repo)}`;
const headers = {};
if (auth?.type === 'token') headers.Authorization = `token ${auth.value}`;
else if (auth?.type === 'basic') headers.Authorization = 'Basic ' + Buffer.from(`${G_USER}:${auth.value}`).toString('base64');
else headers.Authorization = 'Basic ' + Buffer.from(`${G_USER}:${G_TOKEN}`).toString('base64');
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 12000);
const res = await fetch(url, { headers, signal: ctrl.signal });
clearTimeout(timer);
if (res.status === 200) {
const j = await res.json();
return { code: 'ok', empty: !!j.empty, defaultBranch: j.default_branch || '', updatedAt: j.updated_at || '' };
}
if (res.status === 404) return { code: 'miss' };
if (res.status === 401 || res.status === 403) return { code: 'denied' };
return { code: 'http', detail: res.status };
} catch (e) {
if (attempt === 2) return { code: 'net', detail: (e.message || '').slice(0, 60) };
await new Promise(r => setTimeout(r, 800));
}
}
}
async function classify(t, base) {
const g = await probeRepo(base, t.user, t.repo, null);
if (g.code === 'ok') {
if (g.empty) return { state: 'EMPTY', icon: '🟡', text: '空仓库(已建仓,未推送任何内容)' };
const branchWarn = g.defaultBranch && g.defaultBranch !== 'main' ? `,注意默认分支=${g.defaultBranch}` : '';
return { state: 'SUBMITTED', icon: '✅', text: `已提交(${g.defaultBranch}${branchWarn},更新 ${String(g.updatedAt).slice(0, 10)}` };
}
if (g.code === 'denied') return { state: 'NO_ACCESS', icon: '⚠️ ', text: '评审账号无权限(403' };
if (g.code === 'miss') {
if (t.altAuth) {
const a = await probeRepo(base, t.user, t.repo, t.altAuth);
if (a.code === 'ok') {
const extra = a.empty ? '(且为空仓库)' : '';
return { state: 'NO_ACCESS', icon: '⚠️ ', text: `仓库存在${extra},但评审账号未被授权协作者` };
}
if (a.code === 'miss') return { state: 'NOT_CREATED', icon: '🔴', text: '仓库未创建(两队凭据均确认 404)' };
if (a.code === 'denied') return { state: 'AUTH_FAIL', icon: '🔴', text: '队伍自身凭据失效(token/密码不可用)' };
return { state: 'ERR', icon: '❓', text: '队伍凭据探测异常' };
}
return { state: 'NOT_CREATED', icon: '🔴', text: '未创建 或 未授权(无备用凭据,无法区分)' };
}
return { state: 'ERR', icon: '❓', text: `探测失败 ${g.detail || ''}` };
}
// 小并发池
async function pool(items, worker, size = 6) {
const results = new Array(items.length);
let i = 0;
async function run() {
while (i < items.length) {
const idx = i++;
results[idx] = await worker(items[idx]);
}
}
await Promise.all(Array.from({ length: Math.min(size, items.length) }, run));
return results;
}
// ---------- 主流程 ----------
const l2cfg = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'l2-participants.json'), 'utf8')); } catch { return {}; } })();
const BASE = l2cfg.gitteaUrl || 'https://gittea.dev';
console.log(`探测目标 ${targets.length} 个(base=${BASE}, 评审账号=${G_USER}`);
console.log('='.repeat(100));
const results = await pool(targets, async (t) => ({ t, r: await classify(t, BASE) }));
const ORDER = ['SUBMITTED', 'EMPTY', 'NO_ACCESS', 'NOT_CREATED', 'AUTH_FAIL', 'ERR'];
const STATE_TEXT = {
SUBMITTED: '已提交', EMPTY: '空仓库', NO_ACCESS: '未授权',
NOT_CREATED: '未创建', AUTH_FAIL: '凭据失效', ERR: '探测异常',
};
const counts = {};
for (const { t, r } of results) {
counts[r.state] = (counts[r.state] || 0) + 1;
console.log(`[${t.kind}] ${t.label.padEnd(24)} ${r.icon} ${STATE_TEXT[r.state].padEnd(6)} ${r.text}`);
}
console.log('='.repeat(100));
console.log('汇总:');
for (const s of ORDER) {
if (counts[s]) console.log(` ${STATE_TEXT[s]}: ${counts[s]}`);
}
const notDone = (counts.EMPTY || 0) + (counts.NO_ACCESS || 0) + (counts.NOT_CREATED || 0) + (counts.AUTH_FAIL || 0);
console.log(`\n结论:已提交 ${counts.SUBMITTED || 0} / 总数 ${results.length};未完成 ${notDone} 个(见上方明细)。`);
+6 -3
View File
@@ -11,7 +11,7 @@ import { parseDimensions } from './standards';
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 { startReview, startReviewB, startReviewRounds, resolveWebMode, averageDimensions } from '../services/review.service';
import { generateEntryPdf } from '../services/pdf.service';
import { resolveRepoUrlFromConfig } from '../services/teams-config';
import { findL2Topic } from '../services/l2-topics';
@@ -399,8 +399,11 @@ router.post('/:entryId/start', (req: Request, res: Response) => {
// §2.4 重评:清空旧结果,attempt+1(保留 review_snapshots 历史)
db.prepare("UPDATE entries SET status = 'pending', ai_report = NULL, raw_score = NULL, final_score = NULL, score_a = 0, score_b = 0, stage_b_status = '', project_understanding = '', final_level = NULL, attempt = attempt + 1, updated_at = datetime('now') WHERE id = ?").run(eid(req));
startReview(eid(req));
res.json({ success: true });
// 一键多轮:rounds>1 时连续完成 N 轮后自动聚合(中位数)为正式分
const rounds = Math.max(1, Math.min(5, parseInt(req.body?.rounds, 10) || 1));
if (rounds > 1) startReviewRounds(eid(req), rounds);
else startReview(eid(req));
res.json({ success: true, rounds });
});
// §2.5 阶段 B 触发端点:a_done → 接收 build_statusdone/failed)→ hasWeb 校验 service_url → startReviewB(复用 queue,受 MAX_CONCURRENT
+56
View File
@@ -0,0 +1,56 @@
import fs from 'fs';
import path from 'path';
/**
* L2考核受验者注册表(config/l2-participants.json)。
* 受验者自行注册 Gitea 账号(用户名=员工编号)并建仓 L2-assessment
* 登记后由本表映射拉取 URL,避免手动填写仓库地址出错。
* 拉取认证走组委会评审账号的只读协作者权限(cloneRepo 全局凭据),本表不含任何密钥。
*/
export interface L2Participant {
no: string;
name: string;
gitteaUser: string;
repo: string;
remark?: string;
}
export interface L2ParticipantsFile {
note: string;
gitteaUrl: string;
reviewPullAccount: string;
participants: L2Participant[];
}
const CONFIG_PATH = path.resolve(__dirname, '../../../config/l2-participants.json');
let cached: L2ParticipantsFile | null = null;
export function loadL2Participants(): L2ParticipantsFile {
if (cached) return cached;
try {
cached = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) as L2ParticipantsFile;
} catch {
cached = { note: '', gitteaUrl: 'https://gittea.dev', reviewPullAccount: '', participants: [] };
}
return cached;
}
/** 按员工编号/姓名/Gitea用户名匹配受验者(精确或包含) */
export function findL2Participant(query: string): L2Participant | undefined {
const q = query?.trim();
if (!q) return undefined;
const { participants } = loadL2Participants();
return participants.find(p =>
p.no === q || p.name === q || p.gitteaUser === q ||
q.includes(p.name) || q.includes(p.no) || (p.name && q.includes(p.gitteaUser))
);
}
/** 生成受验者仓库拉取 URLhttps://gittea.dev/<gitteaUser>/<repo>.git */
export function buildL2RepoUrl(p: L2Participant): string {
const { gitteaUrl } = loadL2Participants();
const base = (gitteaUrl || 'https://gittea.dev').replace(/\/+$/, '');
return `${base}/${encodeURIComponent(p.gitteaUser)}/${encodeURIComponent(p.repo || 'L2-assessment')}.git`;
}
+6 -23
View File
@@ -54,7 +54,7 @@ export function findL2Topic(id: string): L2Topic | null {
* 组装「功能完整性」维度子 Agent 的选题验收基准上下文(extraContext 注入)。
* 非命题/无匹配时返回空串。
*/
export function buildL2FuncContext(topicId: string, selfRegistration?: string): string {
export function buildL2FuncContext(topicId: string): string {
const t = findL2Topic(topicId);
if (!t || !topicId) return '';
const lines: string[] = [
@@ -68,28 +68,11 @@ export function buildL2FuncContext(topicId: string, selfRegistration?: string):
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 声明功能清单核对');
}
lines.push(
'自选题核对基准(规范 §1.3.4):以仓库 AGENTS.md「自选题核心内容」记录的选题名称、痛点背景、预期功能清单与功能边界为准;',
'README 功能声明不得低于其中核心功能,未经说明的缩减部分按未实现计。',
'评审时先读取 AGENTS.md 提取功能清单,再逐项核对实现情况。'
);
}
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 -2
View File
@@ -10,7 +10,7 @@ import { parseDimensions } from '../routes/standards';
import { matchDimKey, computeLatePenalty, computeCalibration, parseDimResponse, resolveSubmitTime, computeLateDays, classifyVerifiability, detectStructuralContradictions, neutralizeTestEvidence, computeL2Result, computeL2LatePenalty } from './standard-utils';
import { isPathInside } from '../path-security';
import { applyHardRules, applyTrackHardRules } from './hard-rules';
import { findL2Topic, buildL2FuncContext, parseRegistration } from './l2-topics';
import { findL2Topic, buildL2FuncContext } from './l2-topics';
import { detectPlagiarism, readRepoFiles, detectCommitBehavior, PlagiarismReport } from './plagiarism-detect';
import {
REVIEW_CONSTANTS,
@@ -56,6 +56,28 @@ export function startReview(entryId: string) {
runReview(entryId, 'A');
}
// 一键多轮评审(2026-08-26):同一提交连续完成 N 轮,聚合取中位数,避免单次 AI 误判
const roundChain = new Map<string, { remain: number }>();
/** 一键 N 轮评审:第 1 轮立即启动,后续轮在每轮 review_done 后自动续跑(attempt 递增、各自落快照),全部完成后聚合成正式分 */
export function startReviewRounds(entryId: string, rounds: number) {
const n = Math.max(1, Math.min(5, Math.floor(rounds) || 1));
if (n > 1) roundChain.set(entryId, { remain: n - 1 });
startReview(entryId);
}
/** 轮次链推进:仅在本轮成功 review_done 且还有剩余轮次时续跑下一轮;失败/两段式(a_done)即中止 */
function maybeRunNextRound(entryId: string) {
const chain = roundChain.get(entryId);
if (!chain) return;
const st = (db.prepare('SELECT status FROM entries WHERE id = ?').get(entryId) as any)?.status;
if (st !== 'review_done' || chain.remain <= 0) { roundChain.delete(entryId); return; }
chain.remain--;
db.prepare("UPDATE entries SET status = 'pending', attempt = attempt + 1, updated_at = datetime('now') WHERE id = ?").run(entryId);
addLog(entryId, 'pending', `自动启动下一轮评审(剩余 ${chain.remain} 轮)`);
runReview(entryId, 'A');
}
// B 阶段启动??verify 触发):复???queue 机制,受 MAX_CONCURRENT 并发限制
export function startReviewB(entryId: string, buildStatus: 'done' | 'failed' = 'done') {
const entry = db.prepare('SELECT * FROM entries WHERE id = ?').get(entryId) as any;
@@ -172,7 +194,7 @@ 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);
const block = buildL2FuncContext(topicId);
if (!block) return undefined;
return { '功能完整性': '\n' + block };
}
@@ -196,6 +218,7 @@ async function runReview(entryId: string, stage: 'A' | 'B', buildStatus?: 'done'
toStatus, stage === 'B' ? 'failed' : '', JSON.stringify([{ time: new Date().toISOString(), status: toStatus, msg }]), entryId);
} finally {
activeCount--;
try { maybeRunNextRound(entryId); } catch (e: any) { console.error('[rounds] chain error:', e.message); }
processQueue();
}
}
+6
View File
@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { findL2Participant, buildL2RepoUrl } from './l2-participants';
export interface GitteaConfig {
url: string;
@@ -51,5 +52,10 @@ export function resolveRepoUrlFromConfig(title: string, track: string, fallback:
const team = findTeamByTitle(title);
if (team) return buildRepoUrl(team);
}
// L2考核:按受验者注册表(员工编号/姓名)映射拉取 URL,避免手动填写出错
if (track === 'L2考核') {
const p = findL2Participant(title);
if (p) return buildL2RepoUrl(p);
}
return fallback;
}