diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index fd20b2c..337279f 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -1,13 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { TaskModule } from '../task/task.module'; import { AiController } from './ai.controller'; import { AiService } from './ai.service'; import { AiConversation } from './entities/ai-conversation.entity'; import { AiMessage } from './entities/ai-message.entity'; -// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속 +// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속. +// TaskModule 을 가져와 사용자 업무 컨텍스트를 시스템 프롬프트에 주입한다. @Module({ - imports: [TypeOrmModule.forFeature([AiConversation, AiMessage])], + imports: [TypeOrmModule.forFeature([AiConversation, AiMessage]), TaskModule], controllers: [AiController], providers: [AiService], }) diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 4e86b1c..95b63e8 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -8,6 +8,8 @@ import { resolvePage, type PaginatedResult, } from '../../common/pagination/pagination'; +import { TaskService } from '../task/task.service'; +import type { TaskStatus } from '../task/entities/task.entity'; import { AiConversation } from './entities/ai-conversation.entity'; import { AiMessage } from './entities/ai-message.entity'; @@ -37,10 +39,31 @@ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; const REQUEST_TIMEOUT_MS = 30_000; const MAX_TOKENS = 1024; const TITLE_MAX = 60; -const SYSTEM_PROMPT = - '당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다. ' + - '사용자의 질문에 한국어로 간결하고 정확하게 답하세요. ' + - '모르는 내용은 추측하지 말고 모른다고 답하세요.'; +// 컨텍스트로 주입할 담당 업무 최대 건수(토큰 절약) +const CONTEXT_TASK_LIMIT = 30; + +// 주제 한정(가드레일) + 데이터 근거 지시 +const SYSTEM_PROMPT = [ + '당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다.', + '', + '[역할]', + "- 업무 관리, 일정·마감, 협업, Relay 사용법, 그리고 아래 '사용자 데이터' 에 관한 질문에 답합니다.", + '- 업무와 무관한 주제(일반 상식, 시사, 번역, 코딩 과제, 잡담 등)는 정중히 거절하고 업무 관련 질문을 하도록 안내하세요.', + '', + '[규칙]', + '- 한국어로 간결하고 정확하게 답하세요.', + "- 아래 '사용자 데이터' 에 근거해 답하고, 데이터에 없는 내용은 추측하지 말고 모른다고 하세요.", + "- 마감/일정은 제공된 '오늘 날짜' 를 기준으로 계산하세요.", +].join('\n'); + +// 업무 상태 → 한국어 라벨(컨텍스트 표기용) +const STATUS_LABEL: Record = { + todo: '할 일', + prog: '진행 중', + review: '검토 대기', + changes: '수정 요청', + done: '완료', +}; // AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출. // 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다. @@ -52,6 +75,7 @@ export class AiService { constructor( config: ConfigService, + private readonly taskService: TaskService, @InjectRepository(AiConversation) private readonly convRepo: Repository, @InjectRepository(AiMessage) @@ -119,7 +143,7 @@ export class AiService { title: this.makeTitle(content), }), ); - return this.appendAndReply(conv, content); + return this.appendAndReply(conv, content, userId); } // 기존 대화에 메시지 추가 후 응답(본인 소유만) @@ -130,7 +154,7 @@ export class AiService { ): Promise { this.ensureEnabled(); const conv = await this.getOwnedConversation(userId, conversationId); - return this.appendAndReply(conv, content); + return this.appendAndReply(conv, content, userId); } // 대화 삭제(본인 소유만) — 메시지 CASCADE @@ -147,6 +171,7 @@ export class AiService { private async appendAndReply( conv: AiConversation, content: string, + userId: string, ): Promise { const userMsg = await this.msgRepo.save( this.msgRepo.create({ @@ -162,8 +187,10 @@ export class AiService { let reply: string; try { + const system = await this.buildSystemPrompt(userId); reply = await this.callClaude( history.map((m) => ({ role: m.role, content: m.content })), + system, ); } catch (e) { // 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제 @@ -225,9 +252,52 @@ export class AiService { } } + // 시스템 프롬프트 = 가드레일 + 현재 사용자 데이터 컨텍스트 + private async buildSystemPrompt(userId: string): Promise { + const context = await this.buildUserContext(userId); + return `${SYSTEM_PROMPT}\n\n--- 사용자 데이터 ---\n${context}`; + } + + // 현재 사용자의 담당/지시 업무를 요약해 컨텍스트 문자열로 — 실패해도 채팅은 진행 + private async buildUserContext(userId: string): Promise { + const today = new Date(Date.now() + 9 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); // KST 기준 오늘 + try { + const [assigned, issued] = await Promise.all([ + this.taskService.listAssigned(userId, 1, CONTEXT_TASK_LIMIT), + this.taskService.listIssued(userId, 1, 1), + ]); + const lines = [`오늘 날짜: ${today} (KST)`, '']; + lines.push(`내가 담당한 업무 (${assigned.total}건):`); + if (assigned.items.length === 0) { + lines.push('- (없음)'); + } else { + for (const t of assigned.items) { + const due = t.dueDate ? t.dueDate.slice(0, 10) : '없음'; + const [done, totalC] = t.checklist; + lines.push( + `- [${t.repoName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`, + ); + } + if (assigned.total > assigned.items.length) { + lines.push(`- … 외 ${assigned.total - assigned.items.length}건`); + } + } + lines.push('', `내가 지시한 업무: ${issued.total}건`); + return lines.join('\n'); + } catch (e) { + this.logger.warn( + `사용자 컨텍스트 조회 실패: ${e instanceof Error ? e.message : String(e)}`, + ); + return `오늘 날짜: ${today} (KST)\n(사용자 업무 데이터를 불러오지 못했습니다.)`; + } + } + // Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구 private async callClaude( messages: { role: 'user' | 'assistant'; content: string }[], + system: string, ): Promise { let response: Response; try { @@ -241,7 +311,7 @@ export class AiService { body: JSON.stringify({ model: this.model, max_tokens: MAX_TOKENS, - system: SYSTEM_PROMPT, + system, messages, }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), diff --git a/docs/api-contract.md b/docs/api-contract.md index 6686f6a..fb77e59 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -348,6 +348,7 @@ Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가 - **대화/메시지를 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 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **현재 사용자의 담당 업무(최대 30건: 제목/상태/마감/체크리스트)·지시 업무 수·오늘 날짜(KST)** 를 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued`) → "내 마감 임박 업무" 같은 질문에 실데이터로 응답. 컨텍스트 조회 실패해도 채팅은 진행(베스트에포트). - 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. ### 그 외(미착수)