feat(ai-review 行號): 角色 prompt 強制行號,缺行號時反問原角色依 diff 定位(重試上限)
This commit is contained in:
+60
-1
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chatJSON } from './llm.js';
|
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 { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||||||
import { line, ok, warn } from './log.js';
|
import { line, ok, warn } from './log.js';
|
||||||
|
|
||||||
@@ -244,6 +244,65 @@ function fallback(label, findings, e) {
|
|||||||
return findings;
|
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 用量 */
|
/** 只保留 AI 需要的欄位,減少 token 用量 */
|
||||||
function toAIPayload(findings) {
|
function toAIPayload(findings) {
|
||||||
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
|
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
|
||||||
|
|||||||
+3
-1
@@ -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 { 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 { loadRoles, getRoleIntro } from './roles.js';
|
||||||
import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.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 { reconcileConversations, dropResolvedFindings, addCarriedFindings } from './resolve.js';
|
||||||
import { saveFindings, postFindingsReview, formatFindingsStatsLine } from './comments.js';
|
import { saveFindings, postFindingsReview, formatFindingsStatsLine } from './comments.js';
|
||||||
import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatUsageStatsLine } from './usage.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);
|
if (analyses[i].status === 'fulfilled') newFindings.push(...analyses[i].value);
|
||||||
else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`);
|
else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`);
|
||||||
}
|
}
|
||||||
|
// 對只有檔名、缺行號的問題,反問原角色補上行號(最多重試數次),確保後續能行內標註
|
||||||
|
await resolveMissingLineNumbers(newFindings, diff);
|
||||||
output(`新 findings ${newFindings.length} 筆(${formatFindingsStatsLine(newFindings)})`);
|
output(`新 findings ${newFindings.length} 筆(${formatFindingsStatsLine(newFindings)})`);
|
||||||
|
|
||||||
// Step6 合併與去重:舊問題 + 對話收斂結果 + 新問題 → 語意去重
|
// Step6 合併與去重:舊問題 + 對話收斂結果 + 新問題 → 語意去重
|
||||||
|
|||||||
+20
-1
@@ -70,7 +70,7 @@ export function buildAnalysisPrompt(role) {
|
|||||||
'{',
|
'{',
|
||||||
' "level": "critical|warning|info",',
|
' "level": "critical|warning|info",',
|
||||||
` "role": "${role.name}",`,
|
` "role": "${role.name}",`,
|
||||||
' "location": "檔案路徑:行號 或 檔案路徑",',
|
' "location": "檔案路徑:行號(行號為必填,例如 app/foo.js:42)",',
|
||||||
' "problem": "繁體中文(台灣用語)說明審查員認為這裡有問題的原因,不要只填檔案路徑或行號",',
|
' "problem": "繁體中文(台灣用語)說明審查員認為這裡有問題的原因,不要只填檔案路徑或行號",',
|
||||||
' "suggestion": "繁體中文(台灣用語)的具體修改建議"',
|
' "suggestion": "繁體中文(台灣用語)的具體修改建議"',
|
||||||
'}',
|
'}',
|
||||||
@@ -80,10 +80,29 @@ export function buildAnalysisPrompt(role) {
|
|||||||
'- warning:建議修正的問題',
|
'- warning:建議修正的問題',
|
||||||
'- info:可選的改善建議',
|
'- info:可選的改善建議',
|
||||||
'',
|
'',
|
||||||
|
'location 規則(務必遵守):',
|
||||||
|
'- **每一條問題都必須帶行號**,格式一律為 `檔案路徑:行號`(單一行號,例如 `app/foo.js:42`)。',
|
||||||
|
'- 嚴禁只給檔名而省略行號;行號請取該問題在 Git Diff 新增/修改處的實際行號。',
|
||||||
|
'- 一條問題只對應一個檔案與一個行號,不要用逗號列多個檔案。',
|
||||||
|
'',
|
||||||
'只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。',
|
'只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。',
|
||||||
].filter(l => l !== '').join('\n');
|
].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 誤報裁決」的 system prompt:
|
||||||
* 套用其個性與裁決準則本文,要求對一條 finding 判定成立或誤報,回固定 JSON 物件。
|
* 套用其個性與裁決準則本文,要求對一條 finding 判定成立或誤報,回固定 JSON 物件。
|
||||||
|
|||||||
Reference in New Issue
Block a user