From 0976a5057bf4452c4b70fb712d0209982bad3a9a Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 20 Jun 2026 15:05:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=85=EB=AC=B4=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=20AI=20=ED=95=A0=20=EC=9D=BC=20=EC=B6=94=EC=B2=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/modules/ai/ai-suggest.controller.ts | 40 +++ backend/src/modules/ai/ai.module.ts | 3 +- backend/src/modules/ai/ai.service.ts | 91 ++++++ .../modules/ai/dto/suggest-checklist.dto.ts | 35 +++ docs/api-contract.md | 2 + frontend/src/composables/useAi.ts | 13 + frontend/src/pages/relay/TaskCreatePage.vue | 286 ++++++++++++++++++ frontend/src/types/ai.ts | 9 + 8 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/ai/ai-suggest.controller.ts create mode 100644 backend/src/modules/ai/dto/suggest-checklist.dto.ts diff --git a/backend/src/modules/ai/ai-suggest.controller.ts b/backend/src/modules/ai/ai-suggest.controller.ts new file mode 100644 index 0000000..4f6325b --- /dev/null +++ b/backend/src/modules/ai/ai-suggest.controller.ts @@ -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, + }); + } +} diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 67d087a..d198cc6 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -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 {} diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index fe27162..60133fa 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -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 = { 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, diff --git a/backend/src/modules/ai/dto/suggest-checklist.dto.ts b/backend/src/modules/ai/dto/suggest-checklist.dto.ts new file mode 100644 index 0000000..f0a2e8a --- /dev/null +++ b/backend/src/modules/ai/dto/suggest-checklist.dto.ts @@ -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[]; +} diff --git a/docs/api-contract.md b/docs/api-contract.md index b92e477..bc13b89 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -345,11 +345,13 @@ Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가 | POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 | | 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/분 | - **대화/메시지를 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 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵). ### 그 외(미착수) diff --git a/frontend/src/composables/useAi.ts b/frontend/src/composables/useAi.ts index 1b7ccd7..0ab624c 100644 --- a/frontend/src/composables/useAi.ts +++ b/frontend/src/composables/useAi.ts @@ -3,6 +3,7 @@ import type { ConversationDetail, ConversationSummary, SendResult, + SuggestChecklistResult, } from '@/types/ai' import type { Paginated } from '@/types/pagination' @@ -43,11 +44,23 @@ export function useAi() { await api.delete(`/ai/conversations/${id}`) } + // 할 일(체크리스트) 추천 — 실패는 호출측에서 인라인 처리(silent) + async function suggestChecklist(payload: { + repoId: string + title: string + content?: string[] + }): Promise { + return (await api.post('/ai/suggest-checklist', payload, { + silent: true, + })) as unknown as SuggestChecklistResult + } + return { listConversations, getConversation, startConversation, sendMessage, deleteConversation, + suggestChecklist, } } diff --git a/frontend/src/pages/relay/TaskCreatePage.vue b/frontend/src/pages/relay/TaskCreatePage.vue index c74d7a2..3ee2a34 100644 --- a/frontend/src/pages/relay/TaskCreatePage.vue +++ b/frontend/src/pages/relay/TaskCreatePage.vue @@ -10,8 +10,10 @@ import { useMemberStore } from '@/stores/member.store' import { useTaskStore } from '@/stores/task.store' import { useAuthStore } from '@/stores/auth.store' import { useTask } from '@/composables/useTask' +import { useAi } from '@/composables/useAi' import type { Repo, ChecklistItem, User } from '@/mock/relay.mock' import type { ApiMember } from '@/types/repo' +import type { ChecklistSuggestionGroup } from '@/types/ai' const route = useRoute() const router = useRouter() @@ -25,6 +27,7 @@ const memberStore = useMemberStore() const taskStore = useTaskStore() const authStore = useAuthStore() const taskApi = useTask() +const ai = useAi() // API 멤버 → 화면용 User(이니셜/색상 파생) function toUserView(m: ApiMember): User { @@ -151,6 +154,52 @@ function addItem() { newItem.value = '' } +// --- AI 할 일 추천 --- +const aiSuggesting = ref(false) +const aiSuggestions = ref(null) +const aiError = ref(null) +const canRequestAi = computed(() => !aiSuggesting.value && title.value.trim().length > 0) + +// 저장소 설명 + 제목/내용을 분석해 카테고리별 할 일 추천을 받는다 +async function requestAiSuggestions() { + if (!canRequestAi.value) return + aiSuggesting.value = true + aiError.value = null + aiSuggestions.value = null + try { + const content = contentText.value + .split('\n') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + const { groups } = await ai.suggestChecklist({ + repoId: repoId.value, + title: title.value.trim(), + content: content.length ? content : undefined, + }) + aiSuggestions.value = groups + } catch { + aiError.value = '추천을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.' + } finally { + aiSuggesting.value = false + } +} + +// 추천 항목을 체크리스트에 추가(중복 텍스트는 건너뜀) +function isSuggestionAdded(text: string): boolean { + return checklist.value.some((c) => c.text === text) +} +function addSuggestion(text: string) { + if (isSuggestionAdded(text)) return + checklist.value.push({ text, done: false }) +} +function addSuggestionGroup(group: ChecklistSuggestionGroup) { + group.items.forEach((item) => addSuggestion(item)) +} +function dismissSuggestions() { + aiSuggestions.value = null + aiError.value = null +} + // --- 제출 --- const submitting = ref(false) const canSubmit = computed( @@ -235,6 +284,28 @@ function goBack() { > 취소 + + + +
+ 저장소와 업무 내용을 분석하고 있어요… +
+
+ {{ aiError }} +
+ + @@ -586,6 +734,25 @@ function goBack() { .pagehead .btn { height: 2.125rem; } +.btn.ai { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--accent); + background: var(--accent-weak); + border: 1px solid var(--accent-border); +} +.btn.ai:hover:not(:disabled) { + background: #e7e4fb; +} +.btn.ai:disabled { + opacity: 0.55; + cursor: default; +} +.btn.ai svg { + width: 0.9375rem; + height: 0.9375rem; +} /* 레이아웃 */ .layout { @@ -839,6 +1006,125 @@ function goBack() { color: var(--text-3); } +/* AI 추천 할 일 */ +.ai-suggest { + margin-top: 0.875rem; + border: 1px solid var(--accent-border); + background: #faf9ff; + border-radius: 0.625rem; + padding: 0.75rem 0.875rem; +} +.ai-suggest-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} +.ai-suggest-title { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 700; + color: var(--accent); +} +.ai-suggest-title svg { + width: 0.875rem; + height: 0.875rem; +} +.ai-suggest-close { + border: none; + background: transparent; + color: var(--text-3); + font-size: 1.125rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; +} +.ai-suggest-close:hover { + color: var(--text); +} +.ai-suggest-msg { + font-size: 0.8125rem; + color: var(--text-2); + padding: 0.375rem 0; +} +.ai-suggest-msg.err { + color: var(--red); +} +.ai-group { + margin-top: 0.625rem; +} +.ai-group:first-of-type { + margin-top: 0; +} +.ai-group-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.3125rem; +} +.ai-group-cat { + font-size: 0.75rem; + font-weight: 700; + color: var(--text); +} +.ai-group-all { + border: none; + background: transparent; + color: var(--accent); + font-family: inherit; + font-size: 0.7187rem; + font-weight: 600; + cursor: pointer; + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; +} +.ai-group-all:hover { + background: var(--accent-weak); +} +.ai-sg-item { + display: flex; + align-items: flex-start; + gap: 0.4375rem; + width: 100%; + text-align: left; + border: 1px solid var(--border); + background: #fff; + border-radius: 0.4375rem; + padding: 0.4375rem 0.5625rem; + margin-bottom: 0.3125rem; + font-family: inherit; + font-size: 0.8125rem; + color: var(--text); + cursor: pointer; +} +.ai-sg-item:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-weak); +} +.ai-sg-item.added { + cursor: default; + color: var(--text-3); + background: #f4f5f7; +} +.ai-sg-ico { + font-weight: 700; + color: var(--accent); + line-height: 1.45; +} +.ai-sg-item.added .ai-sg-ico { + color: var(--green); +} +.ai-sg-txt { + flex: 1; +} +.ai-suggest-foot { + margin-top: 0.5rem; + font-size: 0.7187rem; + color: var(--text-3); +} + /* 사이드바 */ .side { display: flex; diff --git a/frontend/src/types/ai.ts b/frontend/src/types/ai.ts index cd04c14..a63f221 100644 --- a/frontend/src/types/ai.ts +++ b/frontend/src/types/ai.ts @@ -24,3 +24,12 @@ export interface SendResult { title: string reply: string } + +// 할 일(체크리스트) 추천 — 카테고리별 항목 +export interface ChecklistSuggestionGroup { + category: string + items: string[] +} +export interface SuggestChecklistResult { + groups: ChecklistSuggestionGroup[] +}