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:
@@ -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_TOKEN(server/.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, // 自注册模式无个人 token,404 无法与"未授权"区分
|
||||
});
|
||||
}
|
||||
} 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} 个(见上方明细)。`);
|
||||
@@ -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_status(done/failed)→ hasWeb 校验 service_url → startReviewB(复用 queue,受 MAX_CONCURRENT)
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
/** 生成受验者仓库拉取 URL:https://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`;
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user