|
|
/**
|
|
|
* 拉取远程学员仓库 + 本地 chat_logs,调用 Cursor Agent 生成整页报告 JSON。
|
|
|
* 仓库文件列表与「实训步骤/评测对齐」表由服务端扫描与任务描述生成,并在返回前覆盖模型中的对应字段,避免幻觉。
|
|
|
*/
|
|
|
import fs from 'fs';
|
|
|
import path from 'path';
|
|
|
import { fileURLToPath } from 'url';
|
|
|
import { createRequire } from 'module';
|
|
|
import { Agent, CursorAgentError } from '@cursor/sdk';
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
|
const require = createRequire(import.meta.url);
|
|
|
const { analyzeChatLogsFile, defaultReport } = require(path.join(__dirname, 'analyzeChatLogs.js'));
|
|
|
const { loadStudentRepoContext } = require(path.join(__dirname, 'repoContext.cjs'));
|
|
|
const {
|
|
|
loadEvaluationDimensions,
|
|
|
formatDimensionsForPrompt,
|
|
|
alignEvaluationAbilityToDimensions,
|
|
|
} = require(path.join(__dirname, 'evaluationDimensions.cjs'));
|
|
|
|
|
|
function readText(p) {
|
|
|
return fs.readFileSync(p, 'utf8');
|
|
|
}
|
|
|
|
|
|
function extractJson(text) {
|
|
|
if (text == null) throw new Error('模型无输出');
|
|
|
const s = typeof text === 'string' ? text : String(text);
|
|
|
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
|
if (fence) return JSON.parse(fence[1].trim());
|
|
|
const start = s.indexOf('{');
|
|
|
const end = s.lastIndexOf('}');
|
|
|
if (start >= 0 && end > start) return JSON.parse(s.slice(start, end + 1));
|
|
|
throw new Error('无法从模型输出中解析 JSON');
|
|
|
}
|
|
|
|
|
|
function truncate(str, max) {
|
|
|
const t = String(str);
|
|
|
if (t.length <= max) return t;
|
|
|
return `${t.slice(0, max)}\n…(truncated, ${t.length} chars total)…\n`;
|
|
|
}
|
|
|
|
|
|
function loadHeuristic(chatLogPath) {
|
|
|
if (!fs.existsSync(chatLogPath)) {
|
|
|
const r = defaultReport();
|
|
|
r.note = '无 chat_logs.json';
|
|
|
return r;
|
|
|
}
|
|
|
try {
|
|
|
return analyzeChatLogsFile(chatLogPath);
|
|
|
} catch (e) {
|
|
|
return { ok: false, error: String(e.message || e) };
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function applyServerRepoTruth(data, ctx, gitUrl) {
|
|
|
data.summary = data.summary || {};
|
|
|
data.summary.rubric_steps = ctx.rubric_steps;
|
|
|
data.summary.lab_progress_percent = ctx.labPct;
|
|
|
if (ctx.heuristic?.summary) {
|
|
|
data.summary.conversation_count = ctx.heuristic.summary.conversation_count;
|
|
|
data.summary.hook_event_count = ctx.heuristic.summary.hook_event_count;
|
|
|
}
|
|
|
data.student_repo = {
|
|
|
git_url: gitUrl,
|
|
|
local_path: ctx.repoPath,
|
|
|
file_count: ctx.files.length,
|
|
|
files: ctx.files,
|
|
|
scan_flags: ctx.scan?.flags || {},
|
|
|
};
|
|
|
data.rubric_footer_auto = ctx.rubric_footer;
|
|
|
if (!data.ui) data.ui = {};
|
|
|
data.ui.stat_lab_progress = ctx.labPct;
|
|
|
const tok = ctx.heuristic?.evaluation?.meta?.tokens;
|
|
|
const tin = Number(tok?.input_tokens) || 0;
|
|
|
const tout = Number(tok?.output_tokens) || 0;
|
|
|
const tc = Number(tok?.cache_read_tokens) || 0;
|
|
|
const tcw = Number(tok?.cache_write_tokens) || 0;
|
|
|
const tsum = tin + tout + tc + tcw;
|
|
|
data.ui.stat_hook_main = tsum > 0 ? tsum.toLocaleString('zh-CN') : '0';
|
|
|
data.ui.stat_hook_unit = 'TOKENS';
|
|
|
data.ui.stat_hook_sub = tok
|
|
|
? `输入 ${tin.toLocaleString('zh-CN')} · 输出 ${tout.toLocaleString('zh-CN')} · 缓存读取 ${tc.toLocaleString('zh-CN')}${tcw ? ` · 缓存写入 ${tcw.toLocaleString('zh-CN')}` : ''}`
|
|
|
: '(chat_logs 中未汇总到TOKENS)';
|
|
|
data.ui.rubric_footer = ctx.rubric_footer;
|
|
|
}
|
|
|
|
|
|
const SCHEMA_BLOCK = `
|
|
|
你必须只输出一个 JSON 对象(不要 Markdown 围栏外的说明文字)。顶层结构如下:
|
|
|
|
|
|
{
|
|
|
"ok": true,
|
|
|
"source": "ai_full",
|
|
|
"git_url": "string",
|
|
|
"generated_at": "ISO-8601",
|
|
|
"ui": { ... 与原先一致,含 student 文案、评价区展示等 },
|
|
|
"summary": { "conversation_count", "hook_event_count", "lab_progress_percent", "rubric_steps" },
|
|
|
"conversations": [ ... ],
|
|
|
"evaluation": {
|
|
|
"overall": "string,给学员看的总体评价,换行分段。必须用小标题分 4~5 段(如「一、总体结论」「二、从对话记录里能看出什么」…),少用「Hook」「启发式」「flags」等未解释术语;若出现机制说明须用白话括号解释。内容须覆盖:①总体结论(短句);②对话里客观呈现的学习/提问方式;③与本实训(读表、统计、作图、解释)的对照,并说明「步骤打勾若为关键词推断须提醒不等于独立完成」;④仓库扫描结论(若有);⑤可执行的改进建议。另须体现两大视角但可融入段落:学员是否用自己的话描述数据路径、运行结果、排错;使用 AI 是否分步提问、是否带报错与上下文。须写未完成项;若 evaluation_signals.heuristic_untrustworthy 为 true,用学员能懂的话说明「长文粘贴会让自动步骤表虚高」,并与仓库真实文件交叉论证。",
|
|
|
"ability": [ { "id": "须与 evaluation-dimensions.yaml 中 dimensions.id 一致", "name": "string", "value": 0-100, "comment": "须引用仓库或对话中的具体证据,体现学习与工具使用两方面" } ],
|
|
|
"issues": [ { "title": "string", "body": "string" } ],
|
|
|
"learning": [ "string" ],
|
|
|
"resources": [ { "title", "subtitle", "url" } ],
|
|
|
"class_rank": { "place", "total", "note" }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
evaluation 约束:evaluation.ability 的条数、顺序、id、name 必须与「本请求中注入的 YAML 维度列表」完全一致(教师可在 config/evaluation-dimensions.yaml 增删维度);issues 至少 2 条;learning 至少 3 条。禁止输出「未读仓库即满分」式结论。
|
|
|
|
|
|
注意:summary.rubric_steps 与「作业文件」列表将由服务端在返回前用真实 git 扫描覆盖;你的文字仍须与仓库扫描 JSON、chat 启发式 JSON 自洽。为控制耗时,evaluation 各字段优先写关键事实与可执行句,避免重复堆砌。
|
|
|
`;
|
|
|
|
|
|
export async function generateFullPageReport({ root, gitUrl, chatLogPath, repoContext }) {
|
|
|
const apiKey = process.env.CURSOR_API_KEY;
|
|
|
if (!apiKey) {
|
|
|
throw new Error('缺少 CURSOR_API_KEY,无法调用 Cursor 大模型生成整页报告');
|
|
|
}
|
|
|
|
|
|
const ctx = repoContext || loadStudentRepoContext(gitUrl, root, chatLogPath);
|
|
|
const dimObjs = loadEvaluationDimensions(root);
|
|
|
const taskMd = readText(path.join(root, 'config/lab-task-description.md'));
|
|
|
const dims = readText(path.join(root, 'config/evaluation-dimensions.yaml'));
|
|
|
const skillPath = path.join(root, '.cursor/skills/student-lab-ai-evaluation/SKILL.md');
|
|
|
const skill = fs.existsSync(skillPath) ? readText(skillPath) : '';
|
|
|
|
|
|
const heuristic = ctx.heuristic ?? loadHeuristic(chatLogPath);
|
|
|
const filePaths = (ctx.files || []).slice(0, 250).map((f) => f.path);
|
|
|
|
|
|
const dimAnchor =
|
|
|
dimObjs.length > 0
|
|
|
? `
|
|
|
--- 能力评估维度(作业评价 · 必须与 YAML 完全一致)---
|
|
|
evaluation.ability 必须恰好 ${dimObjs.length} 条,按下列顺序逐条输出,id 与 name 与下表一致(不得增删、不得改 id),value 为 0–100,comment 须引用仓库扫描 JSON 或 Hook 中的可核验证据:
|
|
|
${formatDimensionsForPrompt(dimObjs)}
|
|
|
`
|
|
|
: '';
|
|
|
|
|
|
const prompt = `你是头歌实训助教。请根据「远程学员仓库」与「本地 Cursor Hook 启发式摘要」生成实验报告页的完整数据。
|
|
|
|
|
|
${SCHEMA_BLOCK}
|
|
|
|
|
|
--- 教师 Skill(写作风格与评价原则)---
|
|
|
${truncate(skill, 12000)}
|
|
|
|
|
|
--- 实训任务描述 ---
|
|
|
${taskMd}
|
|
|
|
|
|
--- 评价维度 YAML(权威来源)---
|
|
|
${dims}
|
|
|
${dimAnchor}
|
|
|
--- 远程仓库 ---
|
|
|
git_url: ${gitUrl}
|
|
|
本地克隆路径(仅供参考): ${ctx.repoPath}
|
|
|
|
|
|
--- 仓库扫描(机器生成,须引用)---
|
|
|
${truncate(JSON.stringify(ctx.scan, null, 2), 45000)}
|
|
|
|
|
|
--- 仓库文件路径列表(前 250 个,与左侧「作业文件」一致)---
|
|
|
${truncate(JSON.stringify(filePaths, null, 2), 9000)}
|
|
|
|
|
|
--- chat_logs 启发式解析 JSON(仅供参考,可能不完整)---
|
|
|
${truncate(JSON.stringify(heuristic, null, 2), 15000)}
|
|
|
|
|
|
「作业评价」页须同时服务教师与学员:evaluation.overall 以**学员第一眼能读懂**为准(小标题分段、少黑话、结论与不确定写清),但必须能回答「学员学得怎样」「AI 用得怎样」。能力与问题/建议可追溯到仓库路径或对话摘录。若 evaluation_signals.heuristic_untrustworthy 为 true,须在 overall 用白话说明「整段粘贴会让步骤表虚高」,并与仓库是否确有 score_analysis.py / score_chart.png 等交叉说明,不得沿用「全完成」式武断结论。
|
|
|
|
|
|
现在请输出完整 JSON。git_url 字段填「${gitUrl}」。generated_at 使用当前 UTC 时间 ISO 字符串。
|
|
|
`;
|
|
|
|
|
|
const modelId = process.env.LAB_EVAL_MODEL || 'composer-2';
|
|
|
|
|
|
let result;
|
|
|
try {
|
|
|
result = await Agent.prompt(prompt, {
|
|
|
apiKey,
|
|
|
model: { id: modelId },
|
|
|
local: { cwd: root },
|
|
|
});
|
|
|
} catch (err) {
|
|
|
if (err instanceof CursorAgentError) {
|
|
|
throw new Error(`Cursor Agent 启动失败: ${err.message}`);
|
|
|
}
|
|
|
throw err;
|
|
|
}
|
|
|
|
|
|
if (result.status === 'error') {
|
|
|
throw new Error(`Cursor Agent 运行失败: ${JSON.stringify(result)}`);
|
|
|
}
|
|
|
|
|
|
const data = extractJson(result.result);
|
|
|
data.ok = true;
|
|
|
data.source = 'ai_full';
|
|
|
data.git_url = gitUrl;
|
|
|
data.repo_local_path = ctx.repoPath;
|
|
|
if (!data.generated_at) data.generated_at = new Date().toISOString();
|
|
|
if (!data.evaluation) data.evaluation = {};
|
|
|
if (Array.isArray(data.evaluation.abilities) && !data.evaluation.ability) {
|
|
|
data.evaluation.ability = data.evaluation.abilities;
|
|
|
}
|
|
|
const heuristicEvalFallback =
|
|
|
heuristic && heuristic.evaluation && heuristic.ok !== false ? heuristic.evaluation : defaultReport().evaluation;
|
|
|
if (dimObjs.length) {
|
|
|
alignEvaluationAbilityToDimensions(data.evaluation, dimObjs, heuristicEvalFallback);
|
|
|
}
|
|
|
if (!data.summary && data.rubric) {
|
|
|
data.summary = data.rubric;
|
|
|
}
|
|
|
data.evaluation.meta = {
|
|
|
...(data.evaluation.meta || {}),
|
|
|
ai_evaluation_source: 'cursor_agent_refresh',
|
|
|
ai_evaluation_at: data.generated_at,
|
|
|
repo_scan: ctx.scan?.flags || {},
|
|
|
};
|
|
|
|
|
|
const transcriptById = new Map((heuristic.conversations || []).map((c) => [c.id, c.hook_transcript]));
|
|
|
const topicById = new Map(
|
|
|
(heuristic.conversations || []).map((c) => [c.id, c.topic_preview]).filter(([, t]) => t)
|
|
|
);
|
|
|
if (Array.isArray(data.conversations) && (transcriptById.size || topicById.size)) {
|
|
|
for (const c of data.conversations) {
|
|
|
const cid = c.id || c.conversation_id;
|
|
|
if (!c.hook_transcript && cid && transcriptById.has(cid)) {
|
|
|
c.hook_transcript = transcriptById.get(cid);
|
|
|
}
|
|
|
if (!c.topic_preview && cid && topicById.has(cid)) {
|
|
|
c.topic_preview = topicById.get(cid);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
applyServerRepoTruth(data, ctx, gitUrl);
|
|
|
return data;
|
|
|
}
|