You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

319 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;