feat: AI 에이전트 주제 한정(가드레일) + 사용자 업무 컨텍스트 주입

- 시스템 프롬프트를 업무/Relay 한정으로 재작성 — 무관한 주제는 정중히 거절
- 매 요청 시 사용자 컨텍스트 주입: 오늘 날짜(KST) + 담당 업무 최대 30건(제목/상태/마감/체크리스트) + 지시 업무 수
- TaskService.listAssigned/listIssued 활용(AiModule→TaskModule 단방향), 조회 실패해도 채팅 진행
- callClaude(messages, system)로 시그니처 변경(매 메시지 최신 컨텍스트)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 09:03:45 +09:00
parent e432832c92
commit 2f7f28dd7b
3 changed files with 82 additions and 9 deletions
+4 -2
View File
@@ -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],
})
+77 -7
View File
@@ -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<TaskStatus, string> = {
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<AiConversation>,
@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<SendResult> {
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<SendResult> {
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<string> {
const context = await this.buildUserContext(userId);
return `${SYSTEM_PROMPT}\n\n--- 사용자 데이터 ---\n${context}`;
}
// 현재 사용자의 담당/지시 업무를 요약해 컨텍스트 문자열로 — 실패해도 채팅은 진행
private async buildUserContext(userId: string): Promise<string> {
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<string> {
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),
+1
View File
@@ -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 영속이라 다른 기기·재로그인에도 기록 유지**.
### 그 외(미착수)