/** * Review-history summariser. * * Deliberately separate from the OpenCodeReview pipeline: OCR decides what is * wrong with a diff; this module turns a finished review into a short, * uniformly formatted record for the history list. It shares the global LLM * settings but uses its own prompt, and it never influences blocking or merge * decisions. */ export const SUMMARY_MAX_CHARS = 500; export const SUMMARY_PROMPT_VERSION = "review-history-summary/v1"; export const SUMMARY_SECTIONS = ["结论", "范围", "问题", "要点"]; const SYSTEM_PROMPT = [ "你是一名代码审查记录整理员。你会收到一次自动代码审查的结构化结果。", "你的唯一任务是把这次审查整理成一段简洁、客观的中文记录,供团队在审查历史里快速浏览。", "", "严格要求:", "1. 只依据输入内容,不要推测、不要补充未出现的信息,不要提出新建议。", "2. 不要评价审查工具本身,不要输出“建议进一步检查”之类的话。", "3. 按下面的固定格式输出,保留小标题,每节一行到两行:", "结论:<通过 / 有阻断问题 / 有非阻断问题 / 未发现变更>", "范围:<审查了哪些文件、多少行;信息不足写“未知”>", "问题:<按严重级别归纳问题类型与数量;无问题写“无”>", "要点:<最值得关注的一到三条具体问题,写明文件与行为;无问题写“无”>", "4. 全文不超过 500 个字符,不要使用 Markdown 标题符号、代码块或列表符号。", "5. 直接输出上述四节内容,不要任何前言、后缀或解释。", ].join("\n"); function clip(text, max) { const value = String(text ?? ""); return value.length <= max ? value : `${value.slice(0, max)}…`; } /** Build the user message describing one finished review. */ export function buildSummaryPrompt({ repo, record, findings, summary, requirement }) { const lines = []; lines.push(`仓库:${repo.owner}/${repo.name}`); lines.push(`分支:${record.ref_name}`); if (record.pr_number) lines.push(`Pull Request:#${record.pr_number}`); lines.push(`提交:${record.to_sha}`); if (record.from_sha) lines.push(`对比基准:${record.from_sha}`); if (requirement) lines.push(`本次需求/背景:${clip(requirement, 600)}`); if (summary) { const bits = []; if (summary.files_reviewed != null) bits.push(`审查文件 ${summary.files_reviewed} 个`); if (summary.comments != null) bits.push(`原始意见 ${summary.comments} 条`); if (summary.total_tokens) bits.push(`消耗 ${summary.total_tokens} tokens`); if (summary.elapsed) bits.push(`耗时 ${summary.elapsed}`); if (bits.length) lines.push(`运行数据:${bits.join(",")}`); } if (record.llm_status) lines.push(`审查状态:${record.llm_status}`); lines.push("", "审查发现:"); if (!findings.length) { lines.push("(本次没有产生任何意见)"); } else { for (const f of findings) { const where = f.start_line ? `${f.path}:${f.start_line}${f.end_line && f.end_line !== f.start_line ? `-${f.end_line}` : ""}` : f.path; const tags = [f.severity, f.category].filter(Boolean).join("/"); lines.push(`- [${tags || "未分级"}] ${where} ${clip(f.content, 300)}`); } } return lines.join("\n"); } /** * Call an OpenAI- or Anthropic-compatible endpoint with the built-in fetch, so * the runtime image needs no extra HTTP client. */ async function runLlm(llm, messages, timeoutMs) { const base = String(llm.url).replace(/\/+$/, ""); const isAnthropic = String(llm.protocol || "").toLowerCase().includes("anthropic"); const endpoint = isAnthropic ? (base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`) : (base.endsWith("/v1") ? `${base}/chat/completions` : `${base}/v1/chat/completions`); const headers = { "Content-Type": "application/json" }; if (isAnthropic) { headers[llm.authHeader || "x-api-key"] = llm.token; headers["anthropic-version"] = "2023-06-01"; } else { headers.Authorization = `Bearer ${llm.token}`; } for (const pair of String(llm.extraHeaders || "").split(",")) { const [k, ...rest] = pair.split("="); if (k && rest.length) headers[k.trim()] = rest.join("=").trim(); } const payload = isAnthropic ? { model: llm.model, max_tokens: 1024, system: messages[0].content, messages: [{ role: "user", content: messages[1].content }], } : { model: llm.model, messages, max_tokens: 1024, stream: false }; let res; try { res = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs), }); } catch (err) { const reason = err.name === "TimeoutError" ? `请求超时(${timeoutMs} ms)` : err.message; return { ok: false, error: `调用 LLM 失败:${reason}` }; } const text = await res.text(); let parsed; try { parsed = JSON.parse(text); } catch { return { ok: false, error: `无法解析 LLM 响应(HTTP ${res.status}):${clip(text, 200)}` }; } if (!res.ok) { const msg = typeof parsed.error === "string" ? parsed.error : (parsed.error?.message || JSON.stringify(parsed.error || parsed)); return { ok: false, error: `LLM 返回 HTTP ${res.status}:${clip(msg, 200)}` }; } const content = isAnthropic ? (parsed.content || []).map((p) => p.text || "").join("") : (parsed.choices?.[0]?.message?.content ?? ""); if (!content) return { ok: false, error: `LLM 返回空内容:${clip(text, 200)}` }; return { ok: true, text: String(content).trim(), servedModel: parsed.model || null }; } /** Normalise the model output into the fixed four-section shape. */ export function normaliseSummary(text) { const cleaned = String(text || "") .replace(/```[a-z]*\n?/gi, "") .replace(/^\s*#{1,6}\s*/gm, "") .trim(); const found = SUMMARY_SECTIONS.filter((s) => new RegExp(`^\\s*${s}[::]`, "m").test(cleaned)); const collapsed = cleaned.replace(/\n{3,}/g, "\n\n"); const clipped = collapsed.length > SUMMARY_MAX_CHARS ? `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…` : collapsed; return { text: clipped, complete: found.length === SUMMARY_SECTIONS.length, sectionsFound: found }; } /** * Summarise one review record. * @returns {Promise<{ok: boolean, text?: string, error?: string, model?: string}>} */ export async function summariseReview({ llm, repo, record, findings, summary, requirement, timeoutMs = 120000 }) { if (!llm?.url || !llm?.token || !llm?.model) { return { ok: false, error: "未配置全局 LLM,无法生成审查摘要" }; } const messages = [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: buildSummaryPrompt({ repo, record, findings, summary, requirement }) }, ]; const res = await runLlm(llm, messages, timeoutMs); if (!res.ok) return res; const normalised = normaliseSummary(res.text); return { ok: true, text: normalised.text, model: res.servedModel || llm.model, complete: normalised.complete }; } export { SYSTEM_PROMPT };