diff --git a/backend/src/modules/ai/ai-suggest.controller.ts b/backend/src/modules/ai/ai-suggest.controller.ts index cf1dc35..4f6325b 100644 --- a/backend/src/modules/ai/ai-suggest.controller.ts +++ b/backend/src/modules/ai/ai-suggest.controller.ts @@ -11,13 +11,8 @@ import { Throttle } from '@nestjs/throttler'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { PublicUser } from '../user/user.service'; -import { - AiService, - type ChecklistReviewResult, - type ChecklistSuggestionGroup, -} from './ai.service'; +import { AiService, type ChecklistSuggestionGroup } from './ai.service'; import { SuggestChecklistDto } from './dto/suggest-checklist.dto'; -import { ReviewChecklistDto } from './dto/review-checklist.dto'; // AI 보조 — 업무 작성 시 할 일(체크리스트) 추천 @ApiTags('AI') @@ -42,23 +37,4 @@ export class AiSuggestController { content: dto.content, }); } - - @Post('review-checklist') - @HttpCode(HttpStatus.OK) - // 코드 검토는 비용이 커서 더 엄격히 — IP당 분당 6회 - @Throttle({ default: { limit: 6, ttl: 60_000 } }) - @ApiOperation({ - summary: '코드 검토 — Gitea 코드로 체크리스트 구현 여부 판정', - }) - @ApiResponse({ - status: 200, - description: '항목별 판정 + 요약(체크리스트 반영됨)', - }) - @ApiResponse({ status: 404, description: '저장소/업무 없음 (RES_001)' }) - reviewChecklist( - @CurrentUser() user: PublicUser, - @Body() dto: ReviewChecklistDto, - ): Promise { - return this.aiService.reviewChecklist(user.id, dto.repoId, dto.taskSeq); - } } diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 5c508e6..d198cc6 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TaskModule } from '../task/task.module'; import { RepoModule } from '../repo/repo.module'; -import { GiteaModule } from '../gitea/gitea.module'; import { AiController } from './ai.controller'; import { AiSuggestController } from './ai-suggest.controller'; import { AiService } from './ai.service'; @@ -10,13 +9,12 @@ import { AiConversation } from './entities/ai-conversation.entity'; import { AiMessage } from './entities/ai-message.entity'; // AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속. -// Task/Repo/GiteaModule 을 가져와 업무·저장소 컨텍스트 주입 및 코드 검토에 사용한다. +// Task/RepoModule 을 가져와 사용자 업무·저장소 컨텍스트를 시스템 프롬프트에 주입한다. @Module({ imports: [ TypeOrmModule.forFeature([AiConversation, AiMessage]), TaskModule, RepoModule, - GiteaModule, ], controllers: [AiController, AiSuggestController], providers: [AiService], diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 1a645cf..60133fa 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -10,9 +10,7 @@ import { } from '../../common/pagination/pagination'; import { TaskService } from '../task/task.service'; import type { TaskStatus } from '../task/entities/task.entity'; -import type { ReviewVerdict } from '../task/entities/checklist-item.entity'; import { RepoService } from '../repo/repo.service'; -import { GiteaService } from '../gitea/gitea.service'; import { AiConversation } from './entities/ai-conversation.entity'; import { AiMessage } from './entities/ai-message.entity'; @@ -44,19 +42,6 @@ export interface ChecklistSuggestionGroup { items: string[]; } -// 코드 검토 결과 — 항목별 판정 + 요약 -export interface ChecklistReviewItem { - id: string; - text: string; - verdict: ReviewVerdict; // 'done'(구현됨) | 'missing'(미구현) - reason: string; -} -export interface ChecklistReviewResult { - items: ChecklistReviewItem[]; - summary: string; - truncated: boolean; // 코드가 예산 초과로 일부만 검토됨 -} - const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; const REQUEST_TIMEOUT_MS = 30_000; const MAX_TOKENS = 1024; @@ -106,21 +91,6 @@ const SUGGEST_SYSTEM_PROMPT = [ '{"groups":[{"category":"필수 기능","items":["...","..."]},{"category":"보안 기능","items":["..."]}]}', ].join('\n'); -// 코드 검토용 시스템 프롬프트 — 체크리스트 항목별 구현 여부를 코드 근거로 판정, JSON 으로만 응답 -const REVIEW_SYSTEM_PROMPT = [ - '당신은 코드 리뷰어입니다. 주어진 저장소 코드를 근거로, 업무의 체크리스트 각 항목이 실제로 구현되었는지 판정합니다.', - '', - '[규칙]', - '- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·코드펜스 없이 순수 JSON 만 출력합니다.', - '- 각 항목의 verdict 는 "done"(코드에 구현 근거가 명확) 또는 "missing"(근거 없음/미구현) 중 하나입니다.', - '- 코드에 근거가 없으면 추측하지 말고 "missing" 으로 판정하세요.', - '- reason 은 판정 근거를 한국어 한 문장으로(가능하면 파일/함수 언급). 제공된 코드는 일부일 수 있음을 감안하세요.', - '- items 의 id 는 입력으로 받은 체크리스트 항목 id 를 그대로 사용합니다.', - '', - '[형식]', - '{"items":[{"id":"<항목id>","verdict":"done","reason":"..."}],"summary":"전체 요약 한두 문장"}', -].join('\n'); - // AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출. // 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다. @Injectable() @@ -133,7 +103,6 @@ export class AiService { config: ConfigService, private readonly taskService: TaskService, private readonly repoService: RepoService, - private readonly giteaService: GiteaService, @InjectRepository(AiConversation) private readonly convRepo: Repository, @InjectRepository(AiMessage) @@ -285,160 +254,6 @@ export class AiService { ); } - // 코드 검토 — 연결된 Gitea 저장소 코드를 가져와 체크리스트 항목별 구현 여부를 판정하고 - // 결과(done/missing)를 체크리스트에 반영(applyReviewVerdicts)한다. - async reviewChecklist( - userId: string, - repoId: string, - taskSeq: number, - ): Promise { - this.ensureEnabled(); - const { repo, task } = await this.taskService.getTaskForReview( - repoId, - userId, - taskSeq, - ); - const checklist = (task.checklist ?? []) - .slice() - .sort((a, b) => a.sortOrder - b.sortOrder); - if (checklist.length === 0) { - throw new BusinessException( - 'BIZ_001', - '검토할 체크리스트 항목이 없습니다.', - HttpStatus.BAD_REQUEST, - ); - } - if (!this.giteaService.enabled || repo.giteaRepoId === null) { - throw new BusinessException( - 'BIZ_001', - 'Gitea 에 연결된 저장소만 코드 검토를 할 수 있습니다.', - HttpStatus.BAD_REQUEST, - ); - } - - // 1) 저장소 코드 스냅샷 - let code: { - files: { path: string; content: string }[]; - truncated: boolean; - }; - try { - code = await this.giteaService.fetchRepoCode(repo.slugName, repo.branch); - } catch (e) { - this.logger.error( - `Gitea 코드 조회 실패: ${e instanceof Error ? e.message : String(e)}`, - ); - throw new BusinessException( - 'BIZ_001', - '저장소 코드를 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.', - HttpStatus.BAD_GATEWAY, - ); - } - if (code.files.length === 0) { - throw new BusinessException( - 'BIZ_001', - '검토할 코드가 저장소에 없습니다.', - HttpStatus.BAD_REQUEST, - ); - } - - // 2) 프롬프트 구성(코드 + 체크리스트) - const codeBlock = code.files - .map((f) => `### ${f.path}\n${f.content}`) - .join('\n\n'); - const checklistBlock = checklist - .map((c) => `- id:${c.id} | ${c.text}`) - .join('\n'); - const userMsg = [ - `[업무] ${task.title}`, - task.content.length ? `내용:\n${task.content.join('\n')}` : '', - '', - `[체크리스트] (각 항목의 구현 여부를 판정)`, - checklistBlock, - '', - `[저장소 코드]${code.truncated ? ' (일부만 포함됨 — 용량 제한)' : ''}`, - codeBlock, - ] - .filter(Boolean) - .join('\n'); - - // 3) 판정 - const raw = await this.callClaude( - [{ role: 'user', content: userMsg }], - REVIEW_SYSTEM_PROMPT, - 2048, - 60_000, // 코드 검토는 프롬프트가 커서 더 넉넉히 - ); - const parsed = this.parseReview(raw, checklist); - - // 4) 체크리스트에 반영(done/missing) - const verdictMap = new Map( - parsed.items.map((i) => [i.id, i.verdict]), - ); - await this.taskService.applyReviewVerdicts(task.id, verdictMap); - - return { ...parsed, truncated: code.truncated }; - } - - // Claude 의 검토 JSON 파싱 — 알려진 체크리스트 id 만 채택, 누락 항목은 missing 으로 보정 - private parseReview( - raw: string, - checklist: { id: string; text: string }[], - ): { items: ChecklistReviewItem[]; summary: string } { - let text = raw - .trim() - .replace(/^```(?:json)?\s*/i, '') - .replace(/```$/, '') - .trim(); - const start = text.indexOf('{'); - const end = text.lastIndexOf('}'); - if (start >= 0 && end > start) text = text.slice(start, end + 1); - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - throw this.reviewFailed(); - } - - const obj = parsed as { items?: unknown; summary?: unknown }; - const rawItems: unknown[] = Array.isArray(obj.items) ? obj.items : []; - const byId = new Map(); - for (const it of rawItems) { - const r = it as { id?: unknown; verdict?: unknown }; - if (typeof r.id !== 'string') continue; - const verdict: ReviewVerdict = r.verdict === 'done' ? 'done' : 'missing'; - byId.set(r.id, verdict); - } - const reasonById = new Map(); - for (const it of rawItems) { - const r = it as { id?: unknown; reason?: unknown }; - if (typeof r.id === 'string' && typeof r.reason === 'string') { - reasonById.set(r.id, r.reason.trim()); - } - } - - // 알려진 체크리스트 항목 기준으로 결과 구성(모델이 빠뜨린 항목은 missing) - const items: ChecklistReviewItem[] = checklist.map((c) => ({ - id: c.id, - text: c.text, - verdict: byId.get(c.id) ?? 'missing', - reason: reasonById.get(c.id) ?? '코드에서 구현 근거를 찾지 못했습니다.', - })); - const summary = - typeof obj.summary === 'string' && obj.summary.trim() - ? obj.summary.trim() - : '코드 검토를 완료했습니다.'; - return { items, summary }; - } - - private reviewFailed(): BusinessException { - return new BusinessException( - 'BIZ_001', - '코드 검토 결과를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.', - HttpStatus.BAD_GATEWAY, - ); - } - // 대화 삭제(본인 소유만) — 메시지 CASCADE async deleteConversation( userId: string, @@ -603,8 +418,6 @@ export class AiService { private async callClaude( messages: { role: 'user' | 'assistant'; content: string }[], system: string, - maxTokens: number = MAX_TOKENS, - timeoutMs: number = REQUEST_TIMEOUT_MS, ): Promise { let response: Response; try { @@ -617,11 +430,11 @@ export class AiService { }, body: JSON.stringify({ model: this.model, - max_tokens: maxTokens, + max_tokens: MAX_TOKENS, system, messages, }), - signal: AbortSignal.timeout(timeoutMs), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (e) { this.logger.error( diff --git a/backend/src/modules/ai/dto/review-checklist.dto.ts b/backend/src/modules/ai/dto/review-checklist.dto.ts deleted file mode 100644 index 84ac356..0000000 --- a/backend/src/modules/ai/dto/review-checklist.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; -import { Type } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; - -// 코드 검토 요청 — 대상 저장소·업무 -export class ReviewChecklistDto { - @ApiProperty({ description: '저장소 식별자(slugName)' }) - @IsString() - @IsNotEmpty({ message: '저장소가 필요합니다.' }) - repoId!: string; - - @ApiProperty({ description: '업무 순번(seq)' }) - @Type(() => Number) - @IsInt() - @Min(1) - taskSeq!: number; -} diff --git a/backend/src/modules/gitea/gitea.service.ts b/backend/src/modules/gitea/gitea.service.ts index b90e104..b46a48b 100644 --- a/backend/src/modules/gitea/gitea.service.ts +++ b/backend/src/modules/gitea/gitea.service.ts @@ -36,12 +36,6 @@ interface GiteaRepoApi { default_branch: string; } -// Git 트리 API 응답(재귀) -interface GiteaTreeApi { - tree?: { path: string; type: string; size?: number }[]; - truncated?: boolean; -} - // 외부 Gitea 인스턴스와 통신하는 얇은 API 클라이언트. // 조직(GITEA_ORG) 아래 저장소를 생성/수정/삭제한다. // 필수 env(GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG)가 비어 있으면 비활성화 상태가 되어, @@ -160,68 +154,6 @@ export class GiteaService { ); } - // 저장소 코드 스냅샷 — AI 코드 검토용. 기본 브랜치의 텍스트 파일을 바이트 예산까지 모아 반환. - // 바이너리/대용량/무관 경로(node_modules, .git, 잠금파일 등)는 제외하고, 합산이 예산을 넘으면 잘린다. - async fetchRepoCode( - name: string, - branch: string, - byteBudget = 120_000, - ): Promise<{ - files: { path: string; content: string }[]; - truncated: boolean; - }> { - const repoPath = `${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`; - // 1) 재귀 트리 — 기본 브랜치 이름을 ref 로 사용 - const tree = await this.request( - 'GET', - `/repos/${repoPath}/git/trees/${encodeURIComponent(branch)}?recursive=true&per_page=1000`, - ); - const blobs = (tree?.tree ?? []) - .filter((e) => e.type === 'blob' && typeof e.path === 'string') - .filter((e) => this.isReviewablePath(e.path)) - .filter((e) => (e.size ?? 0) > 0 && (e.size ?? 0) <= 64_000) - // 작은 파일 우선(예산 내 더 많은 파일 포함) - .sort((a, b) => (a.size ?? 0) - (b.size ?? 0)); - - const files: { path: string; content: string }[] = []; - let used = 0; - let truncated = tree?.truncated ?? false; - for (const blob of blobs) { - if (used + (blob.size ?? 0) > byteBudget) { - truncated = true; - continue; - } - try { - const raw = await this.requestText( - `/repos/${repoPath}/raw/${blob.path - .split('/') - .map(encodeURIComponent) - .join('/')}?ref=${encodeURIComponent(branch)}`, - ); - if (raw == null) continue; - files.push({ path: blob.path, content: raw }); - used += Buffer.byteLength(raw, 'utf8'); - } catch { - // 개별 파일 실패는 건너뜀(베스트에포트) - } - } - return { files, truncated }; - } - - // 검토 대상 경로 필터 — 의존성/빌드산출물/바이너리/잠금파일 제외 - private isReviewablePath(path: string): boolean { - const lower = path.toLowerCase(); - const skipDir = - /(^|\/)(node_modules|\.git|dist|build|coverage|\.next|\.nuxt|vendor|__pycache__)\//; - if (skipDir.test('/' + lower)) return false; - if (/(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(lower)) - return false; - // 텍스트(코드/설정/문서) 확장자만 — 이미지/폰트/바이너리 제외 - return /\.(ts|tsx|js|jsx|vue|mjs|cjs|json|md|py|java|go|rb|php|cs|c|cc|cpp|h|hpp|rs|kt|swift|dart|sql|ya?ml|toml|env|sh|html?|css|scss|sass|less)$/.test( - lower, - ); - } - // 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용 async listOrgRepos(): Promise { const limit = 50; // Gitea 기본 최대 페이지 크기 @@ -272,15 +204,6 @@ export class GiteaService { return (await res.json()) as T; } - // raw 파일 등 텍스트 본문 요청 — JSON 이 아니라 원문 텍스트를 반환(404 등은 null) - private async requestText(path: string): Promise { - const res = await fetch(`${this.baseUrl}/api/v1${path}`, { - headers: { Authorization: `token ${this.token}` }, - }); - if (!res.ok) return null; - return res.text(); - } - // API 응답 → 보관용 결과 매핑 private toResult(data: GiteaRepoApi): GiteaRepoResult { return { diff --git a/backend/src/modules/task/entities/checklist-item.entity.ts b/backend/src/modules/task/entities/checklist-item.entity.ts index 62efe48..cdfc2c1 100644 --- a/backend/src/modules/task/entities/checklist-item.entity.ts +++ b/backend/src/modules/task/entities/checklist-item.entity.ts @@ -8,9 +8,6 @@ import { } from 'typeorm'; import { Task } from './task.entity'; -// AI 코드 검토 판정 — 'done'(구현됨) | 'missing'(미구현). null 은 미검토. -export type ReviewVerdict = 'done' | 'missing'; - // 업무 체크리스트 항목 @Entity('checklist_items') export class ChecklistItem { @@ -34,14 +31,6 @@ export class ChecklistItem { @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; - // AI 코드 검토 판정(미검토면 null) - @Column({ - name: 'review_verdict', - type: 'varchar', - nullable: true, - }) - reviewVerdict!: ReviewVerdict | null; - @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; } diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 4bb5da9..89771e9 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -11,10 +11,7 @@ import { BusinessException } from '../../common/exceptions/business.exception'; import { Repo } from '../repo/entities/repo.entity'; import { RepoMember } from '../repo/entities/repo-member.entity'; import { Task, type TaskStatus } from './entities/task.entity'; -import { - ChecklistItem, - type ReviewVerdict, -} from './entities/checklist-item.entity'; +import { ChecklistItem } from './entities/checklist-item.entity'; import { Comment } from './entities/comment.entity'; import { Attachment, type AttachmentKind } from './entities/attachment.entity'; import { CreateTaskDto } from './dto/create-task.dto'; @@ -55,7 +52,6 @@ export interface ChecklistItemResponse { id: string; text: string; done: boolean; - reviewVerdict: ReviewVerdict | null; // AI 코드 검토 판정(미검토면 null) } // 저장소 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영 @@ -1090,41 +1086,6 @@ export class TaskService { } } - // --- AI 코드 검토 연동 (ai 모듈에서 사용) --- - - // 검토 대상 업무 조회 — 멤버 검증 후 업무(+체크리스트) 반환. - // 반환된 repo 로 호출측(AiService)이 Gitea 코드를 가져온다. - async getTaskForReview( - slugName: string, - actingUserId: string, - seq: number, - ): Promise<{ repo: Repo; task: Task }> { - const repo = await this.getRepoOrThrow(slugName); - await this.assertMember(repo.id, actingUserId); - const task = await this.getTaskOrThrow(repo.id, seq); - return { repo, task }; - } - - // AI 검토 결과를 체크리스트에 반영 — verdict('done'|'missing')별로 done·reviewVerdict 갱신. - // verdicts: 체크리스트 항목 id → 판정. 누락된 항목은 변경하지 않는다. - async applyReviewVerdicts( - taskId: string, - verdicts: Map, - ): Promise { - const items = await this.checklistRepo.find({ - where: { task: { id: taskId } }, - }); - const toSave: ChecklistItem[] = []; - for (const item of items) { - const verdict = verdicts.get(item.id); - if (!verdict) continue; - item.reviewVerdict = verdict; - item.done = verdict === 'done'; - toSave.push(item); - } - if (toSave.length) await this.checklistRepo.save(toSave); - } - // slugName 으로 저장소 로딩, 없으면 404 private async getRepoOrThrow(slugName: string): Promise { const repo = await this.repoRepo.findOne({ where: { slugName } }); @@ -1290,12 +1251,7 @@ export class TaskService { const checklist = (task.checklist ?? []) .slice() .sort((a, b) => a.sortOrder - b.sortOrder) - .map((c) => ({ - id: c.id, - text: c.text, - done: c.done, - reviewVerdict: c.reviewVerdict ?? null, - })); + .map((c) => ({ id: c.id, text: c.text, done: c.done })); // 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출 const approval: ApprovalInfo | null = diff --git a/docs/api-contract.md b/docs/api-contract.md index ec86647..bc13b89 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -346,14 +346,12 @@ Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가 | POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 | | DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) | | POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 | -| POST | `/ai/review-checklist` | `{ repoId, taskSeq }` | `{ items:[{id,text,verdict,reason}], summary, truncated }` | 코드 검토 — Gitea 코드로 체크리스트 구현 여부 판정·반영. throttle 6/분 | - **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기). - `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지). - **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트). - 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크). - **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵). -- **코드 검토**(`POST /ai/review-checklist`): `AiService.reviewChecklist` 가 **연결된 Gitea 코드 스냅샷**(`GiteaService.fetchRepoCode` — 기본 브랜치 텍스트 파일, node_modules/lock/바이너리 제외, 합산 ~120KB 예산까지·초과분 truncated)를 체크리스트와 함께 Claude 에 보내 항목별 `done`/`missing` 판정. 결과를 **체크리스트에 영속 반영**(`ChecklistItem.reviewVerdict` 추가 — `done`이면 done=true, `missing`이면 false). Gitea 미연동/코드 없음/항목 없음은 400. throttle 6/분(비용 큼), Claude 타임아웃 60초. 프론트 **TaskDetailPage** 승인 요청 카드(prog/changes)에 "**AI 검토**" 버튼 → 체크리스트가 ✓(초록)/✗(빨강)로 채워지고 요약 배너 표시. *참고: 코드 일부만 검토하는 베스트에포트 휴리스틱.* ### 그 외(미착수) diff --git a/frontend/src/composables/useAi.ts b/frontend/src/composables/useAi.ts index bbe71f6..0ab624c 100644 --- a/frontend/src/composables/useAi.ts +++ b/frontend/src/composables/useAi.ts @@ -1,6 +1,5 @@ import { useApi } from './useApi' import type { - ChecklistReviewResult, ConversationDetail, ConversationSummary, SendResult, @@ -56,17 +55,6 @@ export function useAi() { })) as unknown as SuggestChecklistResult } - // 코드 검토 — 체크리스트 구현 여부 판정(서버가 체크리스트에 반영). 코드 양에 따라 오래 걸림 - async function reviewChecklist(payload: { - repoId: string - taskSeq: number - }): Promise { - return (await api.post('/ai/review-checklist', payload, { - silent: true, - timeout: 90_000, - })) as unknown as ChecklistReviewResult - } - return { listConversations, getConversation, @@ -74,6 +62,5 @@ export function useAi() { sendMessage, deleteConversation, suggestChecklist, - reviewChecklist, } } diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 66166e4..ba719a5 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -36,8 +36,6 @@ export interface ChecklistItem { id?: string text: string done: boolean - /** AI 코드 검토 판정 — 'done'(구현) | 'missing'(미구현) | null(미검토) */ - reviewVerdict?: 'done' | 'missing' | null } /** 저장소 */ diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index 406f197..c36ad6c 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -21,7 +21,6 @@ import { } from '@/mock/relay.mock' import { useTaskStore } from '@/stores/task.store' import { useAuthStore } from '@/stores/auth.store' -import { useAi } from '@/composables/useAi' import UserAvatar from '@/components/UserAvatar.vue' import { attachmentKindOf, fileBadge } from '@/shared/utils/format' @@ -32,7 +31,6 @@ const taskId = computed(() => Number(route.params.taskId)) const taskStore = useTaskStore() const authStore = useAuthStore() -const ai = useAi() // 업무 상세 — API 연동 const task = ref(null) @@ -171,33 +169,6 @@ async function sendApproval() { } } -// --- AI 코드 검토 --- -// 연결된 Gitea 코드를 검토해 체크리스트 항목별 구현 여부(✓/✗)를 채운다. -const aiReviewing = ref(false) -const aiReviewSummary = ref(null) -const aiReviewTruncated = ref(false) -async function runAiReview() { - if (!task.value || aiReviewing.value) return - aiReviewing.value = true - aiReviewSummary.value = null - try { - const result = await ai.reviewChecklist({ - repoId: repoId.value, - taskSeq: task.value.id, - }) - aiReviewSummary.value = result.summary - aiReviewTruncated.value = result.truncated - // 서버가 체크리스트에 판정을 반영했으므로 상세를 다시 불러와 ✓/✗ 갱신 - await loadTask() - } catch { - window.alert( - 'AI 코드 검토에 실패했습니다. Gitea 연결 상태를 확인하거나 잠시 후 다시 시도해 주세요.', - ) - } finally { - aiReviewing.value = false - } -} - /* ============================================================ * 댓글 / 활동 탭 * ============================================================ */ @@ -515,48 +486,20 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { -
- - {{ aiReviewSummary }} -
-
-