From fbf83f1cbbf7864c56f6bfa3e6ca7f0120b3cec4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 23 Jun 2026 15:36:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-review=20=E8=A1=8C=E8=99=9F):=20?= =?UTF-8?q?=E8=A7=92=E8=89=B2=20prompt=20=E5=BC=B7=E5=88=B6=E8=A1=8C?= =?UTF-8?q?=E8=99=9F=EF=BC=8C=E7=BC=BA=E8=A1=8C=E8=99=9F=E6=99=82=E5=8F=8D?= =?UTF-8?q?=E5=95=8F=E5=8E=9F=E8=A7=92=E8=89=B2=E4=BE=9D=20diff=20?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=EF=BC=88=E9=87=8D=E8=A9=A6=E4=B8=8A=E9=99=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/findings.js | 61 ++++++++++++++++++++++++++++++++++++++++++++++++- app/main.js | 4 +++- app/roles.js | 21 ++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/app/findings.js b/app/findings.js index 2d8687a..c055672 100644 --- a/app/findings.js +++ b/app/findings.js @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { chatJSON } from './llm.js'; -import { buildAnalysisPrompt, loadRole, buildVerdictPrompt } from './roles.js'; +import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js'; import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { line, ok, warn } from './log.js'; @@ -244,6 +244,65 @@ function fallback(label, findings, e) { return findings; } +const MAX_LOCATE_ATTEMPTS = 3; + +/** 從 location 取出行號;無 `檔案:行號`(或多檔逗號)時回 null。 */ +function findingLine(location) { + const s = String(location || '').trim(); + if (!s || s.includes(',')) return null; + const m = /^(.+?):(\d+)(?:-\d+)?$/.exec(s); + return m ? Number(m[2]) : null; +} + +/** 從整份 unified diff 擷取指定檔案的區段,找不到時回退整份 diff。 */ +function extractFileDiff(diff, file) { + const lines = String(diff || '').split('\n'); + const out = []; + let capturing = false; + for (const l of lines) { + if (l.startsWith('diff --git ')) capturing = l.includes(`b/${file}`) || l.includes(`a/${file}`); + if (capturing) out.push(l); + } + return out.length ? out.join('\n') : String(diff || ''); +} + +/** + * 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號, + * 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈); + * 成功則把 location 補成 `檔案:行號`,否則保留原檔名。 + */ +export async function resolveMissingLineNumbers(findings, diff, deps = {}) { + const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS } = deps; + let resolved = 0; + let pending = 0; + for (const f of findings) { + if (findingLine(f.location) != null) continue; // 已有行號 + const file = String(f.location || '').split(',')[0].split(':')[0].trim(); + if (!file) continue; + pending += 1; + const systemPrompt = buildLocateLinePrompt(getRole(f.role) || { name: f.role }); + const userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`; + let located = null; + for (let attempt = 1; attempt <= maxAttempts && located == null; attempt++) { + try { + const res = await chatFn(systemPrompt, userContent); + const ln = Number(res?.line); + if (Number.isInteger(ln) && ln > 0) located = ln; + } catch (e) { + warn(`[${f.role}] 行號定位失敗(第 ${attempt}/${maxAttempts} 次): ${e.message}`); + } + } + if (located != null) { + f.location = `${file}:${located}`; + resolved += 1; + } else { + warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`); + } + } + if (pending > 0) ok(`補行號: ${resolved}/${pending} 筆成功定位`); + return findings; +} + /** 只保留 AI 需要的欄位,減少 token 用量 */ function toAIPayload(findings) { return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion })); diff --git a/app/main.js b/app/main.js index 23a70f9..6339511 100644 --- a/app/main.js +++ b/app/main.js @@ -2,7 +2,7 @@ import path from 'path'; import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { loadRoles, getRoleIntro } from './roles.js'; import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js'; -import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions } from './findings.js'; +import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers } from './findings.js'; import { reconcileConversations, dropResolvedFindings, addCarriedFindings } from './resolve.js'; import { saveFindings, postFindingsReview, formatFindingsStatsLine } from './comments.js'; import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatUsageStatsLine } from './usage.js'; @@ -88,6 +88,8 @@ async function main() { if (analyses[i].status === 'fulfilled') newFindings.push(...analyses[i].value); else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`); } + // 對只有檔名、缺行號的問題,反問原角色補上行號(最多重試數次),確保後續能行內標註 + await resolveMissingLineNumbers(newFindings, diff); output(`新 findings ${newFindings.length} 筆(${formatFindingsStatsLine(newFindings)})`); // Step6 合併與去重:舊問題 + 對話收斂結果 + 新問題 → 語意去重 diff --git a/app/roles.js b/app/roles.js index 675e86e..67fd066 100644 --- a/app/roles.js +++ b/app/roles.js @@ -70,7 +70,7 @@ export function buildAnalysisPrompt(role) { '{', ' "level": "critical|warning|info",', ` "role": "${role.name}",`, - ' "location": "檔案路徑:行號 或 檔案路徑",', + ' "location": "檔案路徑:行號(行號為必填,例如 app/foo.js:42)",', ' "problem": "繁體中文(台灣用語)說明審查員認為這裡有問題的原因,不要只填檔案路徑或行號",', ' "suggestion": "繁體中文(台灣用語)的具體修改建議"', '}', @@ -80,10 +80,29 @@ export function buildAnalysisPrompt(role) { '- warning:建議修正的問題', '- info:可選的改善建議', '', + 'location 規則(務必遵守):', + '- **每一條問題都必須帶行號**,格式一律為 `檔案路徑:行號`(單一行號,例如 `app/foo.js:42`)。', + '- 嚴禁只給檔名而省略行號;行號請取該問題在 Git Diff 新增/修改處的實際行號。', + '- 一條問題只對應一個檔案與一個行號,不要用逗號列多個檔案。', + '', '只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。', ].filter(l => l !== '').join('\n'); } +/** + * 由角色定義組出「補行號」的 system prompt: + * 當該角色先前提出的問題只有檔名、缺行號時,請它對照 Git Diff 找出實際行號。 + */ +export function buildLocateLinePrompt(role) { + const name = role?.name || 'AI Review'; + const badge = role?.badge ? `${role.badge} ` : ''; + return [ + `你是 ${badge}${name}${role?.focus ? `(負責「${role.focus}」面向)` : ''}。`, + '你先前提出了一個問題,但 location 只給了檔名、沒有行號。請對照下方提供的該檔案 Git Diff,找出這個問題對應的**實際行號**(新增/修改處在該檔案中的行號)。', + '只回傳 JSON 物件:{"line": 數字},不要有其他文字。若 diff 中確實找不到對應行,回傳 {"line": 0}。', + ].join('\n'); +} + /** * 由防守方角色定義組出「單條 finding 誤報裁決」的 system prompt: * 套用其個性與裁決準則本文,要求對一條 finding 判定成立或誤報,回固定 JSON 物件。