import type { AgentDecomposeResult, AgentDevTaskDraft, AgentReference, AgentTestCaseDraft, } from '@ftb/shared'; import type { DevTask, Reference } from './dev-task'; import type { TestCase } from './test-case'; import type { TaskCategory } from './task-category'; interface RequirementIdentity { id: string; code: string; } export interface DecomposeDedupeInput { existingDevTasks: DevTask[]; existingTestCases: TestCase[]; categories: TaskCategory[]; requirements: RequirementIdentity[]; } export interface DecomposeDedupeResult { result: AgentDecomposeResult; removedDevTaskCount: number; removedTestCaseCount: number; } export function filterDuplicateDecomposeDrafts( result: AgentDecomposeResult, input: DecomposeDedupeInput, ): DecomposeDedupeResult { const devFingerprints = new Set( input.existingDevTasks .map((task) => existingItemFingerprint(task.title, task.categoryId, task.references ?? [], input)) .filter(Boolean), ); const testCaseFingerprints = new Set( input.existingTestCases .map((testCase) => existingItemFingerprint(testCase.title, testCase.categoryId, testCase.references ?? [], input)) .filter(Boolean), ); const filteredDevTaskDrafts = filterDrafts(result.devTaskDrafts, devFingerprints, input, draftFingerprint); const filteredTestCaseDrafts = filterDrafts(result.testCaseDrafts, testCaseFingerprints, input, draftFingerprint); return { result: { ...result, devTaskDrafts: filteredDevTaskDrafts.items, testCaseDrafts: filteredTestCaseDrafts.items, }, removedDevTaskCount: result.devTaskDrafts.length - filteredDevTaskDrafts.items.length, removedTestCaseCount: result.testCaseDrafts.length - filteredTestCaseDrafts.items.length, }; } function filterDrafts( drafts: T[], existingFingerprints: Set, input: DecomposeDedupeInput, getFingerprint: (draft: T, input: DecomposeDedupeInput) => string, ): { items: T[] } { const seen = new Set(); const items: T[] = []; for (const draft of drafts) { const fingerprint = getFingerprint(draft, input); if (existingFingerprints.has(fingerprint) || seen.has(fingerprint)) continue; seen.add(fingerprint); items.push(draft); } return { items }; } function draftFingerprint( draft: AgentDevTaskDraft | AgentTestCaseDraft, input: DecomposeDedupeInput, ): string { return buildFingerprint(draft.title, draftCategoryIdentity(draft, input), draft.references, input); } function existingItemFingerprint( title: string, categoryId: string, references: Reference[], input: DecomposeDedupeInput, ): string { const categoryName = input.categories.find((category) => category.id === categoryId)?.name ?? categoryId; return buildFingerprint(title, categoryName, references, input); } function draftCategoryIdentity( draft: AgentDevTaskDraft | AgentTestCaseDraft, input: DecomposeDedupeInput, ): string { const explicitName = draft.taskTypeName?.trim(); if (explicitName) return explicitName; if (draft.categoryCode) { return input.categories.find((category) => category.code === draft.categoryCode)?.name ?? draft.categoryCode; } return ''; } function buildFingerprint( title: string, categoryName: string, references: Array, input: DecomposeDedupeInput, ): string { const refKey = references .map((ref) => normalizeReferenceKey(ref, input.requirements)) .filter(Boolean) .sort() .join('|'); return `${normalizeTitle(categoryName)}::${normalizeTitle(title)}::${refKey}`; } function normalizeTitle(title: string): string { return title .trim() .toLowerCase() .replace(/[\s::,,.。;;、\-—_]/g, ''); } function normalizeReferenceKey( ref: AgentReference | Reference, requirements: RequirementIdentity[], ): string { const rawId = String(ref.id || '').trim(); if (!rawId) return ''; if (ref.type === 'requirement') { const matched = requirements.find((requirement) => requirement.id === rawId || requirement.code === rawId); return `requirement:${matched?.id ?? rawId}`; } return `${ref.type}:${rawId.toLowerCase()}`; }