diff --git a/src/advance/classic/fix/fix-tool-loop.ts b/src/advance/classic/fix/fix-tool-loop.ts index aa3dc2c..ae93582 100755 --- a/src/advance/classic/fix/fix-tool-loop.ts +++ b/src/advance/classic/fix/fix-tool-loop.ts @@ -20,6 +20,7 @@ import { extractJsonText } from '../utils/json-extraction.js'; import type { ValidationStrategy, ValidationResult } from './validation-strategy.js'; import { ErrorDeltaValidationStrategy } from './validation-strategy.js'; import { defaultPromptLoader, type PromptLoader } from '../../llm/prompts/loader.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; export interface FixToolLoopOptions { llmClient: LlmClient; @@ -64,6 +65,8 @@ export interface FixToolLoopOptions { reason: string; evidence?: string; }>; + /** 可选的轻量判别辅助,用于无进展时的卡点校正建议 */ + localJudge?: MaintainerLocalJudge; } /** 判断 stopReason 是否表示输出被长度截断 */ @@ -99,6 +102,7 @@ export class FixToolLoop { private readonly validationStrategy: ValidationStrategy; private readonly promptLoader: PromptLoader; private readonly recheckAlreadyFixed?: FixToolLoopOptions['recheckAlreadyFixed']; + private readonly localJudge?: MaintainerLocalJudge; private readonly messages: LlmMessage[] = []; private appliedFiles = new Set(); @@ -140,6 +144,7 @@ export class FixToolLoop { this.finalActingSteps = Math.max(1, options.finalActingSteps ?? 3); this.promptLoader = options.promptLoader ?? defaultPromptLoader; this.recheckAlreadyFixed = options.recheckAlreadyFixed; + this.localJudge = options.localJudge; this.registry = new ToolRegistry(FIX_TOOLS); this.executor = new ToolExecutor({ worktreeManager: options.worktreeManager, @@ -296,9 +301,14 @@ export class FixToolLoop { }); if (this.stepsWithoutProgress === this.staleReminderStep) { + let reminder = this.promptLoader.load('fix-tool-loop-stale-reminder'); + const stuckAdvice = await this.tryGetStuckAdvice(); + if (stuckAdvice) { + reminder += `\n\n${stuckAdvice}`; + } this.messages.push({ role: 'user', - content: this.promptLoader.load('fix-tool-loop-stale-reminder'), + content: reminder, }); } @@ -400,6 +410,44 @@ export class FixToolLoop { } } + /** + * 可选的卡点校正辅助:无进展时向 localJudge 请求转向建议。 + * 不可靠/不可用时返回 null,不影响既有静态提醒流程。 + */ + private async tryGetStuckAdvice(): Promise { + if (!this.localJudge) return null; + try { + const progressSummary = `已执行 ${this.stepsWithoutProgress} 步无实质进展(未修改/删除文件、未读取新文件窗口)。已修改文件: ${Array.from(this.appliedFiles).join(', ') || '无'},已读取文件: ${this.readFilesThisRun.size} 个窗口。`; + const verdict = await this.localJudge.adviseOnStuckProgress( + this.finding.message, + progressSummary, + ); + // StuckCorrectionResult 无 kind 字段;LocalJudgeVerdict(unreliable) 有 kind + if (!('suggestion' in verdict)) return null; + return this.formatStuckAdvice(verdict); + } catch { + return null; + } + } + + /** 格式化卡点校正建议为提示文本 */ + private formatStuckAdvice(result: { + suggestion: string; + suggestStop: boolean; + reason: string; + }): string | null { + if (result.suggestStop) { + return `⚠️ 辅助判别建议:当前方向可能无效,建议考虑收拢或改变策略。理由:${result.reason}`; + } + if (result.suggestion === 'refocus') { + return `💡 辅助判别建议:尝试缩小范围,聚焦到更具体的修改目标。理由:${result.reason}`; + } + if (result.suggestion === 'broaden') { + return `💡 辅助判别建议:当前范围可能太窄,考虑读取更多相关文件或上下文。理由:${result.reason}`; + } + return null; + } + getAppliedFiles(): string[] { return Array.from(this.appliedFiles); } diff --git a/src/advance/classic/fix/maintainer-actor.ts b/src/advance/classic/fix/maintainer-actor.ts index c8d4e89..c077196 100755 --- a/src/advance/classic/fix/maintainer-actor.ts +++ b/src/advance/classic/fix/maintainer-actor.ts @@ -40,6 +40,7 @@ import { buildDefaultDeleteMessage, } from './commit-pipeline.js'; import { isSelfAnswerableQuestion } from './ask-gate.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; import { compactDiscussionReason } from '../runners/shared/reply-safety.js'; // 兼容既有引用(含测试):从本模块再导出,实现统一收敛到 commit-pipeline @@ -64,6 +65,8 @@ export interface MaintainerActorOptions { checkpoint?: () => void; /** 可选的 M 系列过程指标计数器(M1/M2/M3/M5/M6 由本类自增) */ metrics?: MrLifecycleMetrics; + /** 可选的轻量判别辅助,用于 FixToolLoop 卡点校正建议 */ + localJudge?: MaintainerLocalJudge; } export interface MaintainerActionResult { @@ -862,6 +865,7 @@ export class MaintainerActor { .filter(Boolean) .join('\n\n'), recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), + localJudge: this.options.localJudge, }); const reflowResult = await reflowLoop.run(); this.trackFinalActingRound(reflowLoop); @@ -973,6 +977,7 @@ export class MaintainerActor { .filter(Boolean) .join('\n\n'), recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), + localJudge: this.options.localJudge, }); const result = await loop.run(); this.trackFinalActingRound(loop); @@ -1337,6 +1342,7 @@ export class MaintainerActor { .filter(Boolean) .join('\n\n'), recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), + localJudge: this.options.localJudge, }); const result = await loop.run(); this.trackFinalActingRound(loop); @@ -1742,6 +1748,7 @@ export class MaintainerActor { .filter(Boolean) .join('\n\n'), recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(syntheticFinding), + localJudge: this.options.localJudge, }); const result = await loop.run(); this.trackFinalActingRound(loop); @@ -2098,6 +2105,7 @@ export class MaintainerActor { .filter(Boolean) .join('\n\n'), recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(reflowFinding), + localJudge: this.options.localJudge, }); const result = await loop.run(); this.trackFinalActingRound(loop); diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index 09ee951..50c1905 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -687,6 +687,7 @@ export class MaintainerRunner extends BaseRoleRunner { recallPlanner, checkpoint: () => saveState(project, state, 'maintainer'), metrics: lifecycle.metrics, + localJudge: this.localJudge, }); let currentHeadSha: string | undefined; @@ -1937,6 +1938,37 @@ export class MaintainerRunner extends BaseRoleRunner { return; } + // 语义重识别:stale finding 无精确 key 匹配时(行号漂移), + // 尝试匹配同文件的历史 ignore 决策,避免完整 LLM 重新评估。 + if (staleFinding && !existing) { + const semanticMatch = await this.trySemanticReidentification( + threadState, + finding, + key + ); + if (semanticMatch) { + threadState.decisions[key] = semanticMatch; + threadState.lastHumanNoteAt = lastHumanNoteAt; + await actor.applyDecision( + mr, + discussion, + finding, + { + action: 'ignore', + alreadyFixed: semanticMatch.alreadyFixed, + reason: semanticMatch.reason, + replyBody: semanticMatch.replyBody, + }, + state + ); + console.log( + `[MaintainerRunner] stale finding ${key} 语义匹配历史 ignore 决策,跳过重评估` + ); + recordProcessed(); + return; + } + } + const fileContent = await readDiscussionFileContent( worktreeManager, projectRootPath, @@ -2194,6 +2226,35 @@ export class MaintainerRunner extends BaseRoleRunner { continue; } + // 语义重识别:stale finding 无精确 key 匹配时,尝试匹配同文件的历史 ignore 决策 + if (staleFinding && !existing) { + const semanticMatch = await this.trySemanticReidentification( + threadState, + finding, + key + ); + if (semanticMatch) { + threadState.decisions[key] = semanticMatch; + this.applyStoredDecision( + semanticMatch, + finding, + { + fixedItems, + failedItems, + askedItems, + ignoredItems, + alreadyFixedItems, + fixableItems, + }, + suppressRepeatedAsk + ); + console.log( + `[MaintainerRunner] stale finding ${key} 语义匹配历史 ignore 决策,跳过重评估` + ); + continue; + } + } + const focusedContent = await readDiscussionFileContent( worktreeManager, projectRootPath, @@ -2900,8 +2961,7 @@ export class MaintainerRunner extends BaseRoleRunner { } try { - const baseResult = await brain.recheckAlreadyFixed(finding); - + // 先跑轻量 LLM 辅助判断,命中时跳过昂贵的 CognitiveEngine 全量分析 const assist = await this.localJudge.assistAlreadyFixedCheck( `${finding.message}\n${finding.suggestion}`, focusedContextToString(focusedContent) @@ -2914,7 +2974,8 @@ export class MaintainerRunner extends BaseRoleRunner { }; } - return baseResult; + // 辅助不可靠或不认为已修复 → 走原有重逻辑 + return await brain.recheckAlreadyFixed(finding); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( @@ -2924,6 +2985,48 @@ export class MaintainerRunner extends BaseRoleRunner { } } + /** + * 语义重识别:stale finding 无精确 key 匹配时(行号漂移), + * 尝试匹配同文件的历史 ignore 决策。 + * + * 只在同文件存在 ignore 决策时尝试,且仅匹配最近一条, + * 避免 N 次 LLM 调用。不可靠时静默返回 null,走原有评估流程。 + */ + private async trySemanticReidentification( + threadState: MaintainerThreadState, + finding: ReviewFinding, + currentKey: string + ): Promise { + const sameFileDecisions = Object.entries(threadState.decisions) + .filter(([k]) => k.startsWith(`${finding.file}:`) && k !== currentKey) + .filter(([, d]) => d.action === 'ignore') + .sort(([, a], [, b]) => b.decidedAt - a.decidedAt); + + if (sameFileDecisions.length === 0) return null; + + const [, bestCandidate] = sameFileDecisions[0]; + try { + const verdict = await this.localJudge.reassessSemanticIdentity( + finding.message, + `${bestCandidate.action}: ${bestCandidate.reason}` + ); + // SemanticReidentificationResult 无 kind 字段;LocalJudgeVerdict(unreliable) 有 kind + if ('kind' in verdict) return null; + if (verdict.likelySame && verdict.confidence !== 'low') { + console.log( + `[MaintainerRunner] stale finding ${currentKey} 语义匹配历史决策(confidence=${verdict.confidence}): ${verdict.reason}` + ); + return { + ...bestCandidate, + decidedAt: Date.now(), + }; + } + } catch { + // 语义匹配失败不影响主流程 + } + return null; + } + /** * 轻量预检:从原始正文中识别批量统计/聚合报告。 * diff --git a/tests/advance/classic/fix/maintainer-llm-judge.test.ts b/tests/advance/classic/fix/maintainer-llm-judge.test.ts index cb38b8a..7ac03a6 100644 --- a/tests/advance/classic/fix/maintainer-llm-judge.test.ts +++ b/tests/advance/classic/fix/maintainer-llm-judge.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; import { LlmClient } from '../../../../src/advance/llm/client.js'; import { LlmMaintainerLocalJudge } from '../../../../src/advance/classic/fix/maintainer-llm-judge.js'; +import { mockOf } from '../../../helpers/mock-of.js'; + +function createJudge( + completeJsonImpl: (...args: unknown[]) => Promise +): LlmMaintainerLocalJudge { + const llmClient = mockOf({ + completeJson: vi.fn().mockImplementation(completeJsonImpl), + }); + return new LlmMaintainerLocalJudge(llmClient); +} describe('LlmMaintainerLocalJudge', () => { it('最终决策红队复核会同时检查候选方案、既有意见和主决策回应', async () => { @@ -33,4 +43,272 @@ describe('LlmMaintainerLocalJudge', () => { expect(completeJson.mock.calls[0]?.[0]).toContain('adversarialResponses'); expect(completeJson.mock.calls[0]?.[1]).toContain('独立红队验收员'); }); + + describe('isAvailable', () => { + it('始终返回 true', () => { + const judge = createJudge(vi.fn()); + expect(judge.isAvailable()).toBe(true); + }); + }); + + describe('assistAlreadyFixedCheck', () => { + it('LLM 判定已修复时返回 reliable + likelyAlreadyFixed=true', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ + likelyAlreadyFixed: true, + reason: '该问题在当前代码中已不存在', + evidence: 'function foo() { return fixed; }', + }) + ) + ); + + const result = await judge.assistAlreadyFixedCheck('变量未使用', 'const x = 1;'); + + expect(result.kind).toBe('reliable'); + if (result.kind === 'reliable') { + expect(result.likelyAlreadyFixed).toBe(true); + expect(result.reason).toBe('该问题在当前代码中已不存在'); + expect(result.evidence).toBe('function foo() { return fixed; }'); + } + }); + + it('LLM 判定未修复时返回 reliable + likelyAlreadyFixed=false', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ likelyAlreadyFixed: false, reason: '问题仍然存在' }) + ) + ); + + const result = await judge.assistAlreadyFixedCheck('变量未使用'); + + expect(result.kind).toBe('reliable'); + if (result.kind === 'reliable') { + expect(result.likelyAlreadyFixed).toBe(false); + } + }); + + it('LLM 返回无效 JSON 时返回 unreliable', async () => { + const judge = createJudge(vi.fn().mockResolvedValue('not json')); + + const result = await judge.assistAlreadyFixedCheck('test'); + + expect(result.kind).toBe('unreliable'); + if (result.kind === 'unreliable') { + expect(result.reason).toContain('不可解析'); + } + }); + + it('LLM 返回缺少必需字段时返回 unreliable', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue(JSON.stringify({ reason: 'no boolean field' })) + ); + + const result = await judge.assistAlreadyFixedCheck('test'); + + expect(result.kind).toBe('unreliable'); + }); + + it('LLM 调用抛异常时返回 unreliable', async () => { + const judge = createJudge(vi.fn().mockRejectedValue(new Error('API rate limit'))); + + const result = await judge.assistAlreadyFixedCheck('test'); + + expect(result.kind).toBe('unreliable'); + if (result.kind === 'unreliable') { + expect(result.reason).toContain('API rate limit'); + } + }); + }); + + describe('reassessSemanticIdentity', () => { + it('LLM 判定同一语义问题时返回 likelySame=true', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ + likelySame: true, + confidence: 'high', + reason: '描述的是同一个变量未使用问题', + }) + ) + ); + + const result = await judge.reassessSemanticIdentity( + '变量 x 未使用', + 'ignore: 已处理变量未使用' + ); + + // SemanticReidentificationResult 无 kind 字段 + expect('likelySame' in result && result.likelySame).toBe(true); + if ('likelySame' in result) { + expect(result.confidence).toBe('high'); + } + }); + + it('LLM 判定不同语义问题时返回 likelySame=false', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ likelySame: false, confidence: 'medium', reason: '不同的问题' }) + ) + ); + + const result = await judge.reassessSemanticIdentity( + '缺少错误处理', + 'ignore: 已处理变量未使用' + ); + + if ('likelySame' in result) { + expect(result.likelySame).toBe(false); + expect(result.confidence).toBe('medium'); + } + }); + + it('confidence 值非法时回退为 low', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ likelySame: true, confidence: 'invalid', reason: 'test' }) + ) + ); + + const result = await judge.reassessSemanticIdentity('desc', 'prev'); + + if ('likelySame' in result) { + expect(result.confidence).toBe('low'); + } + }); + + it('LLM 调用失败时返回 unreliable', async () => { + const judge = createJudge(vi.fn().mockRejectedValue(new Error('network error'))); + + const result = await judge.reassessSemanticIdentity('desc', 'prev'); + + expect('kind' in result && result.kind === 'unreliable').toBe(true); + }); + }); + + describe('adviseOnStuckProgress', () => { + it('LLM 建议继续时返回 suggestion=continue', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ suggestion: 'continue', suggestStop: false, reason: '方向正确' }) + ) + ); + + const result = await judge.adviseOnStuckProgress('修复变量未使用', '已尝试两种方法'); + + // StuckCorrectionResult 无 kind 字段 + if ('suggestion' in result) { + expect(result.suggestion).toBe('continue'); + expect(result.suggestStop).toBe(false); + } + }); + + it('LLM 建议停止时返回 suggestion=stop + suggestStop=true', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ suggestion: 'stop', suggestStop: true, reason: '信息不足' }) + ) + ); + + const result = await judge.adviseOnStuckProgress('模糊的描述', '多次尝试无进展'); + + if ('suggestion' in result) { + expect(result.suggestion).toBe('stop'); + expect(result.suggestStop).toBe(true); + } + }); + + it('LLM 返回无效 suggestion 时返回 unreliable', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ suggestion: 'invalid_action', suggestStop: false, reason: 'test' }) + ) + ); + + const result = await judge.adviseOnStuckProgress('desc', 'progress'); + + expect('kind' in result && result.kind === 'unreliable').toBe(true); + }); + }); + + describe('preFilterScope', () => { + it('LLM 判定 trivial 时返回 reliable + scope=trivial', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue(JSON.stringify({ scope: 'trivial', reason: '单行注释修改' })) + ); + + const result = await judge.preFilterScope('缺少注释', 'src/a.ts', 10); + + expect(result.kind).toBe('reliable'); + if (result.kind === 'reliable') { + expect(result.scope).toBe('trivial'); + } + }); + + it('LLM 判定 cross-file 时返回 reliable + scope=cross-file', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue(JSON.stringify({ scope: 'cross-file', reason: '涉及接口变更' })) + ); + + const result = await judge.preFilterScope('接口签名变更'); + + expect(result.kind).toBe('reliable'); + if (result.kind === 'reliable') { + expect(result.scope).toBe('cross-file'); + } + }); + + it('LLM 返回无效 scope 值时返回 unreliable', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue(JSON.stringify({ scope: 'invalid', reason: 'test' })) + ); + + const result = await judge.preFilterScope('test'); + + expect(result.kind).toBe('unreliable'); + if (result.kind === 'unreliable') { + expect(result.reason).toContain('无效的 scope 值'); + } + }); + }); + + describe('preFilterNonFindingDiscussion', () => { + it('LLM 判定非 finding 时返回 reliable + isProbablyNonFinding=true', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ isProbablyNonFinding: true, reason: '纯统计汇总' }) + ) + ); + + const result = await judge.preFilterNonFindingDiscussion('本次扫描共发现 100 个问题', 1); + + expect(result.kind).toBe('reliable'); + if (result.kind === 'reliable') { + expect(result.isProbablyNonFinding).toBe(true); + } + }); + + it('LLM 判定是 finding 时返回 reliable + isProbablyNonFinding=false', async () => { + const judge = createJudge( + vi.fn().mockResolvedValue( + JSON.stringify({ isProbablyNonFinding: false, reason: '指向具体代码问题' }) + ) + ); + + const result = await judge.preFilterNonFindingDiscussion('src/a.ts:10 变量未使用'); + + expect(result.kind).toBe('reliable'); + if (result.kind === 'reliable') { + expect(result.isProbablyNonFinding).toBe(false); + } + }); + + it('LLM 调用失败时返回 unreliable', async () => { + const judge = createJudge(vi.fn().mockRejectedValue(new Error('timeout'))); + + const result = await judge.preFilterNonFindingDiscussion('body'); + + expect(result.kind).toBe('unreliable'); + }); + }); });