Files
ai-code-review/app/findings.js
T

576 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs';
import path from 'path';
import { chatJSON } from './llm.js';
import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js';
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { line, ok, warn } from './log.js';
const LEVELS = ['critical', 'warning', 'info'];
/**
* 用單一角色分析 diff,回傳 findings 陣列。
* role 欄位一律以角色定義的 name 為準,避免 LLM 自行填入不一致的名稱。
*/
export async function analyzeWithRole(role, diff) {
line(`[${role.name}] 開始分析`);
const findings = await chatJSON(buildAnalysisPrompt(role), `以下是 Git Diff 內容:\n\n${diff}`);
const valid = findings.filter(f => f.level && f.location && f.suggestion)
.map(f => ({ ...f, role: role.name, is_new: true }));
ok(`[${role.name}] 找到 ${valid.length} 個問題`);
return valid;
}
/**
* 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列
*/
function readJSONArray(fullPath, label) {
if (!fs.existsSync(fullPath)) {
warn(`${label}檔案不存在,視為空`);
return [];
}
try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
return Array.isArray(data) ? data : [];
} catch (e) {
warn(`讀取${label}失敗: ${e.message},視為空`);
return [];
}
}
/**
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
*
* @param {Array<object>|{exclusions?: Array<object>, excluded_findings?: Array<object>}|*} data - 任意形式的排除資料來源。
* @returns {Array<object>} 對應的排除條目陣列;無法辨識時回傳空陣列。
* @remarks 與 detectExclusionSource 搭配,相容舊有多種 exclusions.json 結構。
*/
function normalizeExclusions(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.exclusions)) return data.exclusions;
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
return [];
}
/**
* 偵測排除資料的原始容器格式,回傳格式標籤。
*
* @param {Array<object>|{exclusions?: *, excluded_findings?: *}|*} data - 任意形式的排除資料來源。
* @returns {('array'|'exclusions'|'excluded_findings'|'unknown')} 對應的格式標籤。
* @remarks 供 loadExclusions 判斷是否需把非陣列格式改寫成標準頂層陣列。
*/
function detectExclusionSource(data) {
if (Array.isArray(data)) return 'array';
if (data && Array.isArray(data.exclusions)) return 'exclusions';
if (data && Array.isArray(data.excluded_findings)) return 'excluded_findings';
return 'unknown';
}
/**
* 以標準格式(2 空白縮排 JSON 陣列、結尾換行、UTF-8)將排除條目寫回檔案,覆蓋原內容。
*
* @param {string} fullPath - 目標檔案路徑;上層目錄須事先存在(本函式不建立目錄)。
* @param {Array<object>} exclusions - 欲寫入的排除條目陣列。
* @returns {void}
* @throws 檔案寫入失敗(權限不足、目錄不存在等)時拋出 fs 錯誤。
* @remarks 統一輸出格式,使 exclusions.json 永遠是可預期的頂層陣列。
*/
function writeCanonicalExclusions(fullPath, exclusions) {
fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8');
}
/**
* 將檔案 mtime(毫秒時間戳)格式化為 ISO 字串,無效值回傳 'unknown'。
*
* @param {number} mtimeMs - 毫秒時間戳(通常為 fs.Stats.mtimeMs)。
* @returns {string} ISO 8601 時間字串,或在輸入非有限數時回傳 'unknown'。
* @remarks 僅用於診斷日誌,呈現舊 findings / exclusions 檔案的修改時間。
*/
function formatFileTime(mtimeMs) {
if (!Number.isFinite(mtimeMs)) return 'unknown';
return new Date(mtimeMs).toISOString();
}
/**
* 安全取字串:字串則去頭尾空白,其餘型別(含 null/undefined/數字)一律回傳空字串。
*
* @param {*} value - 任意值。
* @returns {string} 去除頭尾空白後的字串,或空字串。
* @remarks 作為 normalizeText、toKeyText、getExclusionText 等的基礎防呆。
*/
function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
/**
* 將文字正規化為比對用形式:NFKC、小寫、標點/符號/空白統一為單一空白後壓縮。
*
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
*/
function normalizeText(value) {
return cleanText(value)
.normalize('NFKC')
.toLowerCase()
.replace(/[\p{P}\p{S}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* 將文字壓縮成無分隔符的鍵值:NFKC 後移除所有標點/符號/空白。
*
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
* @returns {string} 去除所有分隔符的緊湊字串(可能為空字串)。
* @remarks 用於 normalizeExclusionEntry 的 textKey 與 fingerprint,以及群組鍵。
* 不確定:是否刻意不轉小寫(與 normalizeText 不同),需人工確認此差異是否預期。
*/
function toKeyText(value) {
return cleanText(value)
.normalize('NFKC')
.replace(/[\p{P}\p{S}\s]+/gu, '')
.trim();
}
/**
* 從排除條目取出代表性文字,依優先序 original_finding > title > suggestion > reason > note 取第一個非空值。
*
* @param {object|null|undefined} exclusion - 排除條目物件(可為 null/undefined)。
* @returns {string} 第一個非空的代表性文字,皆空時回傳空字串。
* @remarks 供 normalizeExclusionEntry 產生比對文字;相容多種人工撰寫的排除欄位命名。
*/
function getExclusionText(exclusion) {
return cleanText(exclusion?.original_finding)
|| cleanText(exclusion?.title)
|| cleanText(exclusion?.suggestion)
|| cleanText(exclusion?.reason)
|| cleanText(exclusion?.note);
}
/**
* 正規化單一排除條目,補上 filePath、text、textKey 與唯一 fingerprint,保留原始欄位。
*
* @param {object} exclusion - 原始排除條目(可能僅含部分欄位)。
* @param {number} index - 條目在來源陣列中的索引;無文字可用時用於產生 fallback 指紋(entry-N)。
* @returns {object} 合併原欄位與衍生欄位(location、filePath、role、text、textKey、fingerprint)的新物件。
* @remarks fingerprint 以 filePath|role|textKey 組成,缺值以 '*' 或 entry-N 補位,供 dedupeExclusions 去重。
*/
function normalizeExclusionEntry(exclusion, index) {
const location = cleanText(exclusion?.location);
const filePath = location ? location.split(':')[0] : '';
const role = cleanText(exclusion?.role);
const text = getExclusionText(exclusion);
const textKey = toKeyText(text);
const fingerprint = [filePath || '*', role || '*', textKey || `entry-${index + 1}`].join('|');
return {
...exclusion,
location: location || null,
filePath,
role: role || null,
text,
textKey,
fingerprint,
};
}
/**
* 依 fingerprint 去除重複的排除條目,保留首次出現者並維持原順序。
*
* @param {Array<object>} exclusions - 已正規化(含 fingerprint)的排除條目陣列。
* @returns {Array<object>} 去重後的排除條目陣列。
* @remarks 須先呼叫 normalizeExclusionEntry 補上 fingerprint,否則缺指紋的條目可能被誤併。
*/
function dedupeExclusions(exclusions) {
const seen = new Set();
return exclusions.filter(exclusion => {
if (seen.has(exclusion.fingerprint)) return false;
seen.add(exclusion.fingerprint);
return true;
});
}
/**
* 將排除條目依 textKey 分組統計,產生供 AI prompt 使用的群組摘要(含出現次數、涉及路徑與角色、樣本)。
*
* @param {Array<object>} exclusions - 已正規化(含 textKey、filePath、role、text、fingerprint)的排除條目。
* @returns {Array<{text: string, count: number, paths: string[], roles: string[], samples: string[]}>}
* 依出現次數、涉及路徑數、文字字典序排序的群組摘要陣列。
* @remarks 每組最多保留 2 筆樣本,避免後續 prompt 過長;供 buildExclusionContext 取前 N 組組裝提示。
*/
function groupExclusionsForAI(exclusions) {
const groups = new Map();
for (const exclusion of exclusions) {
const groupKey = exclusion.textKey || exclusion.fingerprint;
if (!groups.has(groupKey)) {
groups.set(groupKey, {
key: groupKey,
text: exclusion.text || exclusion.location || exclusion.fingerprint,
count: 0,
paths: new Set(),
roles: new Set(),
samples: [],
});
}
const group = groups.get(groupKey);
group.count += 1;
if (exclusion.filePath) group.paths.add(exclusion.filePath);
if (exclusion.role) group.roles.add(exclusion.role);
if (group.samples.length < 2 && exclusion.text) group.samples.push(exclusion.text);
}
return [...groups.values()]
.sort((a, b) => b.count - a.count || b.paths.size - a.paths.size || a.text.localeCompare(b.text))
.map(group => ({
text: group.text,
count: group.count,
paths: [...group.paths].sort(),
roles: [...group.roles].sort(),
samples: group.samples,
}));
}
/**
* 由原始排除條目建立「已知誤報」上下文:正規化、去重、分組後,產生計數摘要與可直接嵌入 prompt 的文字。
*
* @param {Array<object>} exclusions - 原始(未正規化)排除條目陣列。
* @returns {{rawCount: number, uniqueCount: number, groupCount?: number, groups: Array<object>, prompt: string}}
* 含計數、前 12 組群組摘要與 prompt 字串;空輸入時 prompt 為空字串且不含 groupCount。
* @remarks 供 loadExclusions 日誌與 filterFalsePositivesWithAI 組裝防守方提示使用;prompt 最多展開 12 類群組。
*/
function buildExclusionContext(exclusions) {
if (exclusions.length === 0) {
return {
rawCount: 0,
uniqueCount: 0,
groups: [],
prompt: '',
};
}
const normalized = exclusions.map((exclusion, index) => normalizeExclusionEntry(exclusion, index));
const unique = dedupeExclusions(normalized);
const groups = groupExclusionsForAI(unique);
const topGroups = groups.slice(0, 12).map(group => ({
text: group.text,
count: group.count,
paths: group.paths.slice(0, 4),
roles: group.roles.slice(0, 3),
samples: group.samples.slice(0, 2),
}));
const omitted = groups.length - topGroups.length;
const promptLines = [
`已知誤報清單(原始 ${exclusions.length} 筆,整理後 ${unique.length} 筆,分成 ${groups.length} 類):`,
...topGroups.map((group, index) => {
const parts = [
`${index + 1}. ${group.text}`,
`count=${group.count}`,
];
if (group.paths.length > 0) parts.push(`paths=${group.paths.join(', ')}`);
if (group.roles.length > 0) parts.push(`roles=${group.roles.join(', ')}`);
if (group.samples.length > 0) parts.push(`samples=${group.samples.join(' | ')}`);
return `- ${parts.join(' ; ')}`;
}),
];
if (omitted > 0) {
promptLines.push(`- 另有 ${omitted} 類相似排除條目未展開,請依上述群組規則推論。`);
}
return {
rawCount: exclusions.length,
uniqueCount: unique.length,
groupCount: groups.length,
groups: topGroups,
prompt: promptLines.join('\n'),
};
}
/**
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH
*/
export function loadOldFindings(workspace) {
const fullPath = path.join(workspace, FINDINGS_PATH);
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
if (fs.existsSync(fullPath)) {
const stat = fs.statSync(fullPath);
line(`讀取舊 findings 檔案: ${fullPath}`);
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
} else {
warn(`舊 findings 檔案不存在: ${fullPath}`);
}
ok(`讀取舊 findings: ${old.length} 筆`);
return old;
}
/**
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
*/
export function mergeFindings(oldFindings, newFindings) {
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
const seen = new Set(oldFindings.map(key));
const deduped = newFindings.filter(f => {
if (seen.has(key(f))) return false;
seen.add(key(f));
return true;
});
const merged = [...oldFindings, ...deduped];
ok(`合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
return merged;
}
/**
* 依等級排序(critical > warning > info
*/
export function sortByLevel(findings) {
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
}
/**
* AI 呼叫失敗時的統一降級處理
*/
function fallback(label, findings, e) {
const status = e.response?.status;
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
warn(`${label}失敗(${reason}),降級:保留所有問題`);
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;
}
/**
* 將 findings 精簡為僅含 level、role、location、problem、suggestion 的物件,移除多餘欄位以節省 token。
*
* @param {Array<object>} findings - 完整 findings 陣列。
* @returns {Array<{level: *, role: *, location: *, problem: *, suggestion: *}>} 精簡後的 payload 陣列。
* @remarks 送往 LLM 前的瘦身步驟;原始欄位(如 is_new)需由呼叫端事後依鍵補回。
*/
function toAIPayload(findings) {
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
}
/**
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
*/
export async function deduplicateWithAI(findings) {
if (findings.length === 0) return findings;
const systemPrompt = `你是 🛡️ Paladin(聖騎士),這座程式碼競技場沉穩公正的裁判。攻擊方提出了一批程式碼審查問題(JSON 陣列)。請就事論事,把「同檔案位置 + 同問題本質」的重複指控合併,重複者只保留等級較高的一條(critical > warning > info)。只回傳去重後的 JSON 陣列,不要有其他文字。`;
try {
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) {
ok(`AI 去重: ${findings.length} -> ${result.length} 筆`);
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
}
throw new Error('AI 回傳空陣列');
} catch (e) {
return fallback('AI 去重', findings, e);
}
}
/**
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH
*/
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
if (!fs.existsSync(fullPath)) {
warn(`排除問題檔案不存在,視為空: ${fullPath}`);
if (repoState) {
const branch = repoState.branch || 'detached';
const shortSha = repoState.shortSha || repoState.headSha || 'unknown';
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`);
}
ok('讀取排除問題: raw=0 normalized=0 筆');
return [];
}
let exclusions = [];
let rawCount = 0;
try {
const stat = fs.statSync(fullPath);
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
const sourceFormat = detectExclusionSource(data);
const normalizedSource = normalizeExclusions(data);
rawCount = normalizedSource.length;
exclusions = dedupeExclusions(normalizedSource.map((exclusion, index) => normalizeExclusionEntry(exclusion, index)));
const branch = repoState?.branch || 'detached';
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
const commitTime = repoState?.commitTime || 'unknown';
line(`讀取排除問題檔案: ${fullPath}`);
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`);
line(`檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`);
if (sourceFormat !== 'array') {
writeCanonicalExclusions(fullPath, normalizedSource);
if (mirrorWorkspace && path.resolve(mirrorWorkspace) !== path.resolve(workspace)) {
const mirrorPath = path.join(mirrorWorkspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
writeCanonicalExclusions(mirrorPath, normalizedSource);
}
line(`排除問題格式已修正為頂層陣列: source=${sourceFormat} -> array`);
}
} catch (e) {
warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
exclusions = [];
}
const summary = buildExclusionContext(exclusions);
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} groups=${summary.groupCount} 筆`);
return exclusions;
}
/**
* 把新的排除條目(raw 形式)append 到 exclusions.json,去重後以頂層陣列寫回 workspace 與 mirror。
* 去重以「檔案路徑 + 正規化原文」為準。回傳合併後的 raw 陣列(無新增時回傳既有陣列)。
*/
export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) {
if (!newEntries || newEntries.length === 0) return null;
const fileOf = loc => String(loc || '').split(':')[0].trim();
const sigOf = e => `${fileOf(e.location)}|${normalizeText(e.original_finding || e.suggestion || e.text || e.title || '')}`;
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
let existing = [];
if (fs.existsSync(fullPath)) {
try {
existing = normalizeExclusions(JSON.parse(fs.readFileSync(fullPath, 'utf8')));
} catch (e) {
warn(`讀取排除問題以追加失敗,視為空: ${e.message}`);
existing = [];
}
}
const seen = new Set(existing.map(sigOf));
const additions = newEntries.filter(e => {
const sig = sigOf(e);
if (seen.has(sig)) return false;
seen.add(sig);
return true;
});
if (additions.length === 0) {
line(`誤報排除無新增(皆已存在): 候選 ${newEntries.length} 筆`);
return existing;
}
const merged = [...existing, ...additions];
const targets = [workspace];
if (mirrorWorkspace && path.resolve(mirrorWorkspace) !== path.resolve(workspace)) targets.push(mirrorWorkspace);
for (const dir of targets) {
const target = path.join(dir, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(target), { recursive: true });
writeCanonicalExclusions(target, merged);
}
ok(`誤報寫入 exclusions: 新增 ${additions.length} 筆(總計 ${merged.length} 筆)`);
return merged;
}
/**
* 套用排除規則,過濾掉符合排除條件的 findings
* location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用
*/
export function applyExclusions(findings, exclusions) {
if (exclusions.length === 0) return findings;
const before = findings.length;
const filtered = findings.filter(f => !exclusions.some(ex => {
const fPath = String(f.location).split(':')[0];
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
const findingText = normalizeText(f.suggestion || f.title || '');
const exclusionText = ex.textKey || normalizeText(ex.text || ex.suggestion || ex.title || '');
const locationMatches = (!exPath || fPath === exPath);
const roleMatches = (!ex.role || ex.role === f.role);
const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText);
return locationMatches && roleMatches && (exPath || ex.role ? true : textMatches);
}));
ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
return filtered;
}
/** 派一個「防守方」sub-agent 裁決單一 finding 是否為誤報;任何失敗都保守視為成立(保留)。 */
async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) {
const systemPrompt = buildVerdictPrompt(defender, exclusionHint);
try {
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload([finding])[0]));
return result?.verdict === 'false_positive';
} catch (e) {
warn(`誤報裁決失敗(保守視為成立): ${finding.location} error=${e.message}`);
return false;
}
}
/**
* 由「防守方」角色(Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。
* 多個問題時各派一個 sub-agent 平行裁決;任一裁決失敗保守保留該問題,不中斷流程。
*/
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
if (findings.length === 0) return findings;
const defender = loadRole('Paladin');
const exclusionContext = buildExclusionContext(exclusions);
const exclusionHint = exclusionContext.prompt
? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
: '';
// 每條 finding 各派一個防守方 sub-agent 裁決,多條時平行處理
const verdicts = await Promise.all(
findings.map(f => judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn).then(isFP => ({ f, isFP }))),
);
const kept = verdicts.filter(v => !v.isFP).map(v => v.f);
ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`);
return kept;
}