Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93142e2250 | ||
|
|
c521451b66 |
+19
-17
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chatJSON } from './llm.js';
|
import { chatJSON, mapWithConcurrency, LLM_CONCURRENCY } from './llm.js';
|
||||||
import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } 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';
|
||||||
@@ -368,14 +368,14 @@ function extractFileDiff(diff, file) {
|
|||||||
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
|
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
|
||||||
*/
|
*/
|
||||||
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
||||||
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS } = deps;
|
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
|
||||||
let resolved = 0;
|
// 只挑「缺行號且有檔名」的 finding;各自以獨立 LLM 子行程並行定位(併發上限見 concurrency)。
|
||||||
let pending = 0;
|
const pending = findings.filter(f => findingLine(f.location) == null
|
||||||
for (const f of findings) {
|
&& String(f.location || '').split(',')[0].split(':')[0].trim());
|
||||||
if (findingLine(f.location) != null) continue; // 已有行號
|
if (pending.length === 0) return findings;
|
||||||
|
|
||||||
|
const outcomes = await mapWithConcurrency(pending, concurrency, async (f) => {
|
||||||
const file = String(f.location || '').split(',')[0].split(':')[0].trim();
|
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 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)}`;
|
const userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`;
|
||||||
let located = null;
|
let located = null;
|
||||||
@@ -390,12 +390,13 @@ export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
|||||||
}
|
}
|
||||||
if (located != null) {
|
if (located != null) {
|
||||||
f.location = `${file}:${located}`;
|
f.location = `${file}:${located}`;
|
||||||
resolved += 1;
|
return true;
|
||||||
} else {
|
}
|
||||||
warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`);
|
warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`);
|
||||||
}
|
return false;
|
||||||
}
|
});
|
||||||
if (pending > 0) ok(`補行號: ${resolved}/${pending} 筆成功定位`);
|
|
||||||
|
ok(`補行號: ${outcomes.filter(Boolean).length}/${pending.length} 筆成功定位`);
|
||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,10 +577,11 @@ export async function filterFalsePositivesWithAI(findings, exclusions = [], chat
|
|||||||
? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 每條 finding 各派一個防守方 sub-agent 裁決,多條時平行處理
|
// 每條 finding 各派一個防守方 sub-agent 裁決;併發上限與其他 LLM 任務共用 LLM_CONCURRENCY(預設不限制)。
|
||||||
const verdicts = await Promise.all(
|
const verdicts = await mapWithConcurrency(findings, LLM_CONCURRENCY, async (f) => ({
|
||||||
findings.map(f => judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn).then(isFP => ({ f, isFP }))),
|
f,
|
||||||
);
|
isFP: await judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn),
|
||||||
|
}));
|
||||||
const kept = verdicts.filter(v => !v.isFP).map(v => v.f);
|
const kept = verdicts.filter(v => !v.isFP).map(v => v.f);
|
||||||
ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`);
|
ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`);
|
||||||
return kept;
|
return kept;
|
||||||
|
|||||||
+33
@@ -6,6 +6,39 @@ import { getLLMConfig } from './config.js';
|
|||||||
import { recordUsage } from './usage.js';
|
import { recordUsage } from './usage.js';
|
||||||
import { line } from './log.js';
|
import { line } from './log.js';
|
||||||
|
|
||||||
|
// 每個 LLM CLI 呼叫(角色分析、補行號等)都是一個獨立子行程。預設「不限制」併發(全部同時跑);
|
||||||
|
// 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。
|
||||||
|
// 0 / 未設定 / 非正整數 → 不限制。
|
||||||
|
export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。
|
||||||
|
*
|
||||||
|
* limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。
|
||||||
|
* fn 需自行處理例外(內部 try/catch);本函式不會因單一項目 reject 而中斷其餘工作。
|
||||||
|
* @template T, R
|
||||||
|
* @param {T[]} items - 要處理的項目。
|
||||||
|
* @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。
|
||||||
|
* @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
|
||||||
|
* @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。
|
||||||
|
*/
|
||||||
|
export async function mapWithConcurrency(items, limit, fn) {
|
||||||
|
const list = Array.isArray(items) ? items : [];
|
||||||
|
const results = new Array(list.length);
|
||||||
|
if (list.length === 0) return results;
|
||||||
|
const n = Number(limit);
|
||||||
|
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
|
||||||
|
let cursor = 0;
|
||||||
|
async function run() {
|
||||||
|
while (cursor < list.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
results[i] = await fn(list[i], i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: workers }, run));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
||||||
*/
|
*/
|
||||||
|
|||||||
+11
-4
@@ -9,6 +9,7 @@ import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatU
|
|||||||
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
||||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||||
import { runPreflight } from './preflight.js';
|
import { runPreflight } from './preflight.js';
|
||||||
|
import { mapWithConcurrency, LLM_CONCURRENCY } from './llm.js';
|
||||||
import { section, step, line, input, output, result, warn, error } from './log.js';
|
import { section, step, line, input, output, result, warn, error } from './log.js';
|
||||||
|
|
||||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||||
@@ -120,15 +121,21 @@ async function main() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
// 各角色以獨立 LLM 子行程並行分析(併發上限見 LLM_CONCURRENCY),單一角色失敗僅 warn 後跳過。
|
||||||
const newFindings = [];
|
const newFindings = [];
|
||||||
let fulfilledAnalyses = 0;
|
let fulfilledAnalyses = 0;
|
||||||
for (const role of roles) {
|
const roleResults = await mapWithConcurrency(roles, LLM_CONCURRENCY, async (role) => {
|
||||||
try {
|
try {
|
||||||
const findings = await analyzeWithRole(role, diff);
|
return await analyzeWithRole(role, diff);
|
||||||
fulfilledAnalyses += 1;
|
|
||||||
newFindings.push(...findings);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
|
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const findings of roleResults) {
|
||||||
|
if (findings) {
|
||||||
|
fulfilledAnalyses += 1;
|
||||||
|
newFindings.push(...findings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fulfilledAnalyses === 0) {
|
if (fulfilledAnalyses === 0) {
|
||||||
|
|||||||
+40
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
|
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { extractBalancedJSON, extractJSONText, extractMeaningfulError } from '../llm.js';
|
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
||||||
@@ -273,3 +273,42 @@ describe('extractMeaningfulError', () => {
|
|||||||
assert.equal(extractMeaningfulError(null), '');
|
assert.equal(extractMeaningfulError(null), '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mapWithConcurrency', () => {
|
||||||
|
it('回傳與輸入同索引對應的結果(保序)', async () => {
|
||||||
|
const out = await mapWithConcurrency([1, 2, 3, 4], 2, async (n) => n * 10);
|
||||||
|
assert.deepEqual(out, [10, 20, 30, 40]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('遵守併發上限(同時執行數不超過 limit)', async () => {
|
||||||
|
let active = 0, peak = 0;
|
||||||
|
const wait = () => new Promise(r => setTimeout(r, 5));
|
||||||
|
await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async () => {
|
||||||
|
active += 1; peak = Math.max(peak, active);
|
||||||
|
await wait();
|
||||||
|
active -= 1;
|
||||||
|
});
|
||||||
|
assert.ok(peak <= 2, `peak=${peak} 應 <= 2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limit 大於項目數時仍全部執行', async () => {
|
||||||
|
const out = await mapWithConcurrency(['a', 'b'], 10, async (s) => s.toUpperCase());
|
||||||
|
assert.deepEqual(out, ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limit<=0 表示不限制(全部同時並行)', async () => {
|
||||||
|
let active = 0, peak = 0;
|
||||||
|
const wait = () => new Promise(r => setTimeout(r, 5));
|
||||||
|
await mapWithConcurrency([1, 2, 3, 4, 5], 0, async () => {
|
||||||
|
active += 1; peak = Math.max(peak, active);
|
||||||
|
await wait();
|
||||||
|
active -= 1;
|
||||||
|
});
|
||||||
|
assert.equal(peak, 5, `peak=${peak} 應等於項目數(不限制)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空輸入回傳空陣列', async () => {
|
||||||
|
assert.deepEqual(await mapWithConcurrency([], 3, async () => 1), []);
|
||||||
|
assert.deepEqual(await mapWithConcurrency(null, 3, async () => 1), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user