feat: 업무 작성 AI 할 일 추천

- POST /ai/suggest-checklist — 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내
  카테고리별(필수/추가/보안) 할 일 항목을 JSON 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502)
- AiSuggestController + SuggestChecklistDto + AiService.suggestChecklist/parseSuggestions
- 프론트: TaskCreatePage 액션바 "취소 / AI 추천 / 지시 보내기" + 체크리스트 추천 패널
  (카테고리별 항목 클릭 추가·전체 추가·중복 스킵), useAi.suggestChecklist(silent)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 15:05:34 +09:00
parent 51b608efeb
commit 0976a5057b
8 changed files with 478 additions and 1 deletions
@@ -0,0 +1,40 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
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 ChecklistSuggestionGroup } from './ai.service';
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
@ApiTags('AI')
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiSuggestController {
constructor(private readonly aiService: AiService) {}
@Post('suggest-checklist')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 20, ttl: 60_000 } })
@ApiOperation({ summary: '할 일(체크리스트) 추천 — 저장소·업무 기반' })
@ApiResponse({ status: 200, description: '카테고리별 추천 항목' })
@ApiResponse({ status: 404, description: '저장소 없음 (RES_001)' })
suggestChecklist(
@CurrentUser() user: PublicUser,
@Body() dto: SuggestChecklistDto,
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
return this.aiService.suggestChecklist(user.id, {
repoId: dto.repoId,
title: dto.title,
content: dto.content,
});
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TaskModule } from '../task/task.module';
import { RepoModule } from '../repo/repo.module';
import { AiController } from './ai.controller';
import { AiSuggestController } from './ai-suggest.controller';
import { AiService } from './ai.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
@@ -15,7 +16,7 @@ import { AiMessage } from './entities/ai-message.entity';
TaskModule,
RepoModule,
],
controllers: [AiController],
controllers: [AiController, AiSuggestController],
providers: [AiService],
})
export class AiModule {}
+91
View File
@@ -36,6 +36,12 @@ export interface SendResult {
reply: string;
}
// 할 일 추천 — 카테고리별 항목
export interface ChecklistSuggestionGroup {
category: string;
items: string[];
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_TOKENS = 1024;
@@ -70,6 +76,21 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
done: '완료',
};
// 할 일 추천용 시스템 프롬프트 — JSON 으로만 응답하도록 강제
const SUGGEST_SYSTEM_PROMPT = [
'당신은 소프트웨어/업무 기획 보조자입니다.',
"주어진 저장소와 업무 정보를 바탕으로, 이 업무(기능)를 완성하기 위해 필요한 '할 일(체크리스트)' 항목을 제안합니다.",
'',
'[규칙]',
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
'- 카테고리는 "필수 기능", "추가 기능", "보안 기능" 을 기본으로 하되 업무 성격에 맞게 가감하세요.',
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
'- 업무·저장소 맥락에 구체적으로 맞추고 일반론은 피하세요. 카테고리당 3~6개가 적당합니다.',
'',
'[형식]',
'{"groups":[{"category":"필수 기능","items":["...","..."]},{"category":"보안 기능","items":["..."]}]}',
].join('\n');
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
@Injectable()
@@ -163,6 +184,76 @@ export class AiService {
return this.appendAndReply(conv, content, userId);
}
// 할 일(체크리스트) 추천 — 저장소 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안
async suggestChecklist(
userId: string,
input: { repoId: string; title: string; content?: string[] },
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
this.ensureEnabled();
// 저장소 정보(이름/설명) — 조직 모델상 인증 사용자면 열람 가능. 없으면 404.
const repo = await this.repoService.findOne(input.repoId, userId);
const content = (input.content ?? []).join('\n').trim() || '(없음)';
const userMsg = [
`[저장소] 표시 제목: ${repo.name}`,
`설명: ${repo.desc ?? '(없음)'}`,
'',
`[업무] 제목: ${input.title}`,
'내용:',
content,
].join('\n');
const raw = await this.callClaude(
[{ role: 'user', content: userMsg }],
SUGGEST_SYSTEM_PROMPT,
);
return { groups: this.parseSuggestions(raw) };
}
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
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.suggestFailed();
}
const rawGroups = (parsed as { groups?: unknown }).groups;
const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : [];
const result: ChecklistSuggestionGroup[] = [];
for (const g of groups.slice(0, 8)) {
const obj = g as { category?: unknown; items?: unknown };
const category =
typeof obj.category === 'string' ? obj.category.trim() : '';
if (!category || !Array.isArray(obj.items)) continue;
const items = obj.items
.filter((x): x is string => typeof x === 'string')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.slice(0, 15);
if (items.length > 0) result.push({ category, items });
}
if (result.length === 0) throw this.suggestFailed();
return result;
}
private suggestFailed(): BusinessException {
return new BusinessException(
'BIZ_001',
'추천을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
// 대화 삭제(본인 소유만) — 메시지 CASCADE
async deleteConversation(
userId: string,
@@ -0,0 +1,35 @@
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 할 일(체크리스트) 추천 요청 — 저장소·업무 정보를 바탕으로 AI 가 항목 제안
export class SuggestChecklistDto {
@ApiProperty({ description: '저장소 식별자(slugName)' })
@IsString()
@IsNotEmpty({ message: '저장소가 필요합니다.' })
repoId!: string;
@ApiProperty({ description: '업무 제목' })
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title!: string;
@ApiProperty({
description: '업무 내용(문단 배열)',
required: false,
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(5000, { each: true })
content?: string[];
}