|
|
const path = require('path');
|
|
|
const fs = require('fs');
|
|
|
const { pathToFileURL } = require('url');
|
|
|
const express = require('express');
|
|
|
const { analyzeChatLogsFile, defaultReport, buildHookChatWindows } = require('./lib/analyzeChatLogs');
|
|
|
const { mergeAiEvaluation, loadAiEvaluationFile } = require('./lib/mergeAiEvaluation');
|
|
|
const { loadStudentRepoContext, attachRepoToHeuristicReport } = require('./lib/repoContext.cjs');
|
|
|
const { finalizeTaskAlignedEvidence } = require('./lib/taskAlignedAbility.cjs');
|
|
|
const { ensureStudentRepo } = require('./lib/studentRepo.cjs');
|
|
|
const { resolveChatLogPath } = require('./lib/resolveChatLogPath.cjs');
|
|
|
const { getDefaultPublicStudentGitUrl } = require('./lib/gitCloneUrl.cjs');
|
|
|
const { loadEvaluationDimensions, alignEvaluationAbilityToDimensions } = require('./lib/evaluationDimensions.cjs');
|
|
|
|
|
|
const ROOT = __dirname;
|
|
|
const PORT = Number(process.env.PORT) || 53780;
|
|
|
const CHAT_LOG = resolveChatLogPath(ROOT);
|
|
|
|
|
|
/** 与 report-ui mergeQuestions 一致:跨会话合并提问卡片 */
|
|
|
function mergeHookQuestionsAcrossConversations(conversations) {
|
|
|
const seen = new Set();
|
|
|
const out = [];
|
|
|
for (const c of conversations || []) {
|
|
|
const cid = String(c.conversation_id || c.id || c.session_id || '').trim();
|
|
|
for (const q of c.questions || []) {
|
|
|
const k = q.title || q.detail;
|
|
|
if (!k) continue;
|
|
|
const dedupeKey = `${cid}\0${k}\0${String(q.time ?? '')}`;
|
|
|
if (seen.has(dedupeKey)) continue;
|
|
|
seen.add(dedupeKey);
|
|
|
out.push({
|
|
|
...q,
|
|
|
conversation_id: q.conversation_id || cid || null,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
/** 不含密码,用于 API 与页面展示;clone 时在 lib/studentRepo 内按需注入 DEFAULT_STUDENT_GIT_USER / DEFAULT_STUDENT_GIT_PASSWORD */
|
|
|
const DEFAULT_STUDENT_GIT_URL = getDefaultPublicStudentGitUrl();
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
app.use(express.json({ limit: '2mb' }));
|
|
|
|
|
|
function isSafeHttpUrl(u) {
|
|
|
try {
|
|
|
const x = new URL(String(u).trim());
|
|
|
return x.protocol === 'https:' || x.protocol === 'http:';
|
|
|
} catch {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function pickGitUrl(req) {
|
|
|
const raw = req.query.git;
|
|
|
if (typeof raw === 'string' && raw.trim()) {
|
|
|
try {
|
|
|
const decoded = decodeURIComponent(raw.trim());
|
|
|
if (isSafeHttpUrl(decoded)) return decoded;
|
|
|
} catch {
|
|
|
if (isSafeHttpUrl(raw.trim())) return raw.trim();
|
|
|
}
|
|
|
}
|
|
|
return DEFAULT_STUDENT_GIT_URL;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 从磁盘 chat_logs 注入:① 各会话完整 Hook 时间线(查看详情)② Hook 原文提问列表(与大模型摘要合并)
|
|
|
*/
|
|
|
function enrichReportWithHookTranscriptMap(report, chatLogPath) {
|
|
|
if (!report || !report.ok) return;
|
|
|
report.hook_transcript_source_file = path.basename(chatLogPath);
|
|
|
if (!fs.existsSync(chatLogPath)) {
|
|
|
report.hook_transcript_map_error = `未找到「${path.basename(chatLogPath)}」。可设置环境变量 CHAT_LOG_PATH 指向云端落盘路径。`;
|
|
|
report.hook_transcript_by_conv_id = {};
|
|
|
report.hook_chat_windows = [];
|
|
|
return;
|
|
|
}
|
|
|
try {
|
|
|
const raw = JSON.parse(fs.readFileSync(chatLogPath, 'utf8'));
|
|
|
const analysis = analyzeChatLogsFile(chatLogPath);
|
|
|
if (!analysis.ok) {
|
|
|
report.hook_transcript_map_error = analysis.error || 'chat_logs 解析失败';
|
|
|
report.hook_transcript_by_conv_id = {};
|
|
|
report.hook_chat_windows = [];
|
|
|
return;
|
|
|
}
|
|
|
report.hook_excerpt_questions = mergeHookQuestionsAcrossConversations(analysis.conversations);
|
|
|
report.hook_chat_windows = buildHookChatWindows(raw);
|
|
|
const m = {};
|
|
|
for (const c of analysis.conversations || []) {
|
|
|
const t = c.hook_transcript;
|
|
|
if (typeof t !== 'string' || !t.trim() || !c.id) continue;
|
|
|
const topId = String(c.id).trim();
|
|
|
m[topId] = t;
|
|
|
const events = Array.isArray(raw[topId]) ? raw[topId] : null;
|
|
|
if (!events) continue;
|
|
|
for (const ev of events) {
|
|
|
const hid = ev?.hook_input?.conversation_id;
|
|
|
if (hid == null) continue;
|
|
|
const inner = String(hid).trim();
|
|
|
if (inner && inner !== topId) m[inner] = t;
|
|
|
}
|
|
|
}
|
|
|
report.hook_transcript_by_conv_id = m;
|
|
|
report.hook_transcript_map_size = Object.keys(m).length;
|
|
|
if (report.hook_transcript_map_size === 0) {
|
|
|
report.hook_transcript_map_error =
|
|
|
'日志已读入但未生成时间线,请确认 JSON 为「会话ID → Hook 事件数组」且事件含 hook_input。';
|
|
|
} else {
|
|
|
delete report.hook_transcript_map_error;
|
|
|
}
|
|
|
} catch (e) {
|
|
|
report.hook_transcript_map_error = String(e.message || e);
|
|
|
report.hook_transcript_by_conv_id = {};
|
|
|
report.hook_chat_windows = [];
|
|
|
// eslint-disable-next-line no-console
|
|
|
console.warn('[api/report] hook_transcript_by_conv_id 构建失败:', e.message || e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function loadReportHeuristicOnly() {
|
|
|
let report;
|
|
|
if (!fs.existsSync(CHAT_LOG)) {
|
|
|
report = defaultReport();
|
|
|
report.note = `未找到 ${path.basename(CHAT_LOG)},已返回占位报告。`;
|
|
|
} else {
|
|
|
try {
|
|
|
report = analyzeChatLogsFile(CHAT_LOG);
|
|
|
} catch (e) {
|
|
|
return { ok: false, error: String(e.message || e) };
|
|
|
}
|
|
|
}
|
|
|
const heuristicEvalSnapshot =
|
|
|
report.ok && report.evaluation ? JSON.parse(JSON.stringify(report.evaluation)) : null;
|
|
|
const ai = loadAiEvaluationFile(ROOT);
|
|
|
if (report.ok && ai) {
|
|
|
report.evaluation = mergeAiEvaluation(report.evaluation, ai);
|
|
|
}
|
|
|
const dimList = loadEvaluationDimensions(ROOT);
|
|
|
if (report.ok && report.evaluation && dimList.length) {
|
|
|
alignEvaluationAbilityToDimensions(report.evaluation, dimList, heuristicEvalSnapshot);
|
|
|
}
|
|
|
return report;
|
|
|
}
|
|
|
|
|
|
const MAX_REPO_FILE_BYTES = 200000;
|
|
|
|
|
|
const MIME = {
|
|
|
'.png': 'image/png',
|
|
|
'.jpg': 'image/jpeg',
|
|
|
'.jpeg': 'image/jpeg',
|
|
|
'.gif': 'image/gif',
|
|
|
'.webp': 'image/webp',
|
|
|
'.ico': 'image/x-icon',
|
|
|
'.svg': 'image/svg+xml',
|
|
|
};
|
|
|
|
|
|
app.get('/api/repo-file', (req, res) => {
|
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
|
const gitUrl = pickGitUrl(req);
|
|
|
const relRaw = req.query.file;
|
|
|
if (typeof relRaw !== 'string' || !relRaw.trim()) {
|
|
|
return res.status(400).json({ ok: false, error: '缺少 file 参数(仓库内相对路径)' });
|
|
|
}
|
|
|
let rel = relRaw.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
|
if (rel.includes('..') || path.isAbsolute(rel)) {
|
|
|
return res.status(400).json({ ok: false, error: '非法路径' });
|
|
|
}
|
|
|
try {
|
|
|
const repoPath = ensureStudentRepo(gitUrl, ROOT);
|
|
|
const full = path.join(repoPath, rel);
|
|
|
const resolved = path.resolve(full);
|
|
|
const rootResolved = path.resolve(repoPath);
|
|
|
if (!resolved.startsWith(rootResolved + path.sep) && resolved !== rootResolved) {
|
|
|
return res.status(403).json({ ok: false, error: '禁止越权访问' });
|
|
|
}
|
|
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
|
return res.status(404).json({ ok: false, error: '文件不存在' });
|
|
|
}
|
|
|
const ext = path.extname(resolved).toLowerCase();
|
|
|
const buf = fs.readFileSync(resolved);
|
|
|
const mime = MIME[ext];
|
|
|
if (mime && !['.svg'].includes(ext)) {
|
|
|
return res.json({
|
|
|
ok: true,
|
|
|
path: rel,
|
|
|
mode: 'binary',
|
|
|
mime,
|
|
|
base64: buf.toString('base64'),
|
|
|
size: buf.length,
|
|
|
});
|
|
|
}
|
|
|
const text = buf.toString('utf8');
|
|
|
const truncated = buf.length > MAX_REPO_FILE_BYTES;
|
|
|
return res.json({
|
|
|
ok: true,
|
|
|
path: rel,
|
|
|
mode: 'text',
|
|
|
mime: mime || 'text/plain',
|
|
|
truncated,
|
|
|
content: truncated ? text.slice(0, MAX_REPO_FILE_BYTES) : text,
|
|
|
size: buf.length,
|
|
|
});
|
|
|
} catch (e) {
|
|
|
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
|
|
}
|
|
|
});
|
|
|
|
|
|
app.get('/api/report', async (req, res) => {
|
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
|
const gitUrl = pickGitUrl(req);
|
|
|
const qFast = String(req.query.fast || req.query.heuristic || req.query.nollm || '').toLowerCase();
|
|
|
const forceHeuristic = qFast === '1' || qFast === 'true' || qFast === 'yes';
|
|
|
const useLlm =
|
|
|
!forceHeuristic &&
|
|
|
process.env.DISABLE_FULL_LLM_REPORT !== '1' &&
|
|
|
Boolean(process.env.CURSOR_API_KEY);
|
|
|
const t0 = Date.now();
|
|
|
|
|
|
let ctx = null;
|
|
|
try {
|
|
|
ctx = loadStudentRepoContext(gitUrl, ROOT, CHAT_LOG);
|
|
|
} catch (e) {
|
|
|
console.error('[api/report] git clone/scan failed:', e.message || e);
|
|
|
if (useLlm) {
|
|
|
return res.status(502).json({
|
|
|
ok: false,
|
|
|
error: `无法拉取或扫描远程仓库:${e.message || e}`,
|
|
|
git_url: gitUrl,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (useLlm && ctx) {
|
|
|
try {
|
|
|
const modUrl = pathToFileURL(path.join(ROOT, 'lib/runFullPageAgent.mjs')).href;
|
|
|
const { generateFullPageReport } = await import(modUrl);
|
|
|
const data = await generateFullPageReport({
|
|
|
root: ROOT,
|
|
|
gitUrl,
|
|
|
chatLogPath: CHAT_LOG,
|
|
|
repoContext: ctx,
|
|
|
});
|
|
|
enrichReportWithHookTranscriptMap(data, CHAT_LOG);
|
|
|
if (ctx) finalizeTaskAlignedEvidence(ctx, data);
|
|
|
data.report_build_ms = Date.now() - t0;
|
|
|
return res.json(data);
|
|
|
} catch (e) {
|
|
|
console.error('[api/report] full LLM report failed:', e.message || e);
|
|
|
const fallback = loadReportHeuristicOnly();
|
|
|
if (fallback.ok) {
|
|
|
fallback.source = 'heuristic_fallback';
|
|
|
fallback.git_url = gitUrl;
|
|
|
fallback.llm_error = String(e.message || e);
|
|
|
fallback.note = (fallback.note ? `${fallback.note} ` : '') + '大模型整页生成失败,已回退到启发式 + lab-eval-ai.json(若存在)。';
|
|
|
if (ctx) attachRepoToHeuristicReport(fallback, ctx, gitUrl);
|
|
|
} else {
|
|
|
fallback.llm_error = String(e.message || e);
|
|
|
}
|
|
|
if (fallback.ok) enrichReportWithHookTranscriptMap(fallback, CHAT_LOG);
|
|
|
if (fallback.ok && ctx) finalizeTaskAlignedEvidence(ctx, fallback);
|
|
|
fallback.report_build_ms = Date.now() - t0;
|
|
|
return res.json(fallback);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const data = loadReportHeuristicOnly();
|
|
|
if (data.ok) {
|
|
|
data.source = forceHeuristic ? 'heuristic_fast' : 'heuristic_only';
|
|
|
data.git_url = gitUrl;
|
|
|
data.note =
|
|
|
(data.note ? `${data.note} ` : '') +
|
|
|
(forceHeuristic
|
|
|
? '已使用 ?fast=1 跳过整页大模型生成(仅 git 扫描 + chat_logs 启发式),响应更快。'
|
|
|
: '未设置 CURSOR_API_KEY 或已 DISABLE_FULL_LLM_REPORT=1,未调用大模型;已尽量从仓库拉取文件列表与评测表。');
|
|
|
if (ctx) attachRepoToHeuristicReport(data, ctx, gitUrl);
|
|
|
}
|
|
|
if (data.ok) enrichReportWithHookTranscriptMap(data, CHAT_LOG);
|
|
|
if (data.ok && ctx) finalizeTaskAlignedEvidence(ctx, data);
|
|
|
if (data.ok) data.report_build_ms = Date.now() - t0;
|
|
|
return res.json(data);
|
|
|
});
|
|
|
|
|
|
app.get('/api/health', (_req, res) => {
|
|
|
res.json({
|
|
|
ok: true,
|
|
|
service: 'chat-lab-report',
|
|
|
port: PORT,
|
|
|
default_git: DEFAULT_STUDENT_GIT_URL,
|
|
|
llm_enabled: Boolean(process.env.CURSOR_API_KEY) && process.env.DISABLE_FULL_LLM_REPORT !== '1',
|
|
|
});
|
|
|
});
|
|
|
|
|
|
app.get('/', (_req, res) => {
|
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
|
|
res.setHeader('Pragma', 'no-cache');
|
|
|
res.sendFile(path.join(ROOT, 'code.html'));
|
|
|
});
|
|
|
|
|
|
app.get('/report-ui.js', (_req, res) => {
|
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
|
|
res.sendFile(path.join(ROOT, 'report-ui.js'));
|
|
|
});
|
|
|
|
|
|
app.use(express.static(ROOT, { index: false }));
|
|
|
|
|
|
const server = app.listen(PORT, () => {
|
|
|
// eslint-disable-next-line no-console
|
|
|
console.log(`报告服务已启动: http://127.0.0.1:${PORT}/`);
|
|
|
console.log(`API: http://127.0.0.1:${PORT}/api/report?git=<可选仓库URL>`);
|
|
|
console.log(`快速模式(跳过整页大模型,仅 git+chat_logs): /api/report?fast=1&git=...`);
|
|
|
console.log(`默认学员仓库: ${DEFAULT_STUDENT_GIT_URL}`);
|
|
|
});
|
|
|
|
|
|
server.timeout = Number(process.env.REPORT_SERVER_TIMEOUT_MS) || 900000;
|
|
|
server.headersTimeout = server.timeout + 10000;
|