feat: AI 채팅 다듬기 — 저장소 컨텍스트·표시제목·마크다운 렌더·말투
- 컨텍스트에 저장소 목록 추가(RepoService.findAll), slug 빼고 표시 제목만 노출 - AI 응답 마크다운 렌더링: shared/utils/markdown.ts (escapeHtml 후 신뢰 태그만 조립, 의존성 0, XSS 안전 — 굵게/목록/코드/제목/링크) - 시스템 프롬프트: 간결·이모지/사과 남발 금지·과도한 존칭 지양·마크다운 사용·저장소는 표시제목으로 안내 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TaskModule } from '../task/task.module';
|
||||
import { RepoModule } from '../repo/repo.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) 채팅 프록시 + 대화/메시지 영속.
|
||||
// TaskModule 을 가져와 사용자 업무 컨텍스트를 시스템 프롬프트에 주입한다.
|
||||
// Task/RepoModule 을 가져와 사용자 업무·저장소 컨텍스트를 시스템 프롬프트에 주입한다.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AiConversation, AiMessage]), TaskModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AiConversation, AiMessage]),
|
||||
TaskModule,
|
||||
RepoModule,
|
||||
],
|
||||
controllers: [AiController],
|
||||
providers: [AiService],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../../common/pagination/pagination';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import type { TaskStatus } from '../task/entities/task.entity';
|
||||
import { RepoService } from '../repo/repo.service';
|
||||
import { AiConversation } from './entities/ai-conversation.entity';
|
||||
import { AiMessage } from './entities/ai-message.entity';
|
||||
|
||||
@@ -41,6 +42,8 @@ const MAX_TOKENS = 1024;
|
||||
const TITLE_MAX = 60;
|
||||
// 컨텍스트로 주입할 담당 업무 최대 건수(토큰 절약)
|
||||
const CONTEXT_TASK_LIMIT = 30;
|
||||
// 컨텍스트로 주입할 저장소 최대 개수
|
||||
const CONTEXT_REPO_LIMIT = 30;
|
||||
|
||||
// 주제 한정(가드레일) + 데이터 근거 지시
|
||||
const SYSTEM_PROMPT = [
|
||||
@@ -51,9 +54,11 @@ const SYSTEM_PROMPT = [
|
||||
'- 업무와 무관한 주제(일반 상식, 시사, 번역, 코딩 과제, 잡담 등)는 정중히 거절하고 업무 관련 질문을 하도록 안내하세요.',
|
||||
'',
|
||||
'[규칙]',
|
||||
'- 한국어로 간결하고 정확하게 답하세요.',
|
||||
'- 한국어로 간결하고 명확하게 답하세요. 불필요한 인사말·사과·이모지 남발을 피하고, 과도한 존칭("회원님" 등) 대신 자연스러운 존댓말을 쓰세요.',
|
||||
'- 목록이나 강조가 필요하면 마크다운(굵게 **텍스트**, 목록 - 또는 1.)을 사용하세요. 표·복잡한 서식은 피하세요.',
|
||||
"- 아래 '사용자 데이터' 에 근거해 답하고, 데이터에 없는 내용은 추측하지 말고 모른다고 하세요.",
|
||||
"- 마감/일정은 제공된 '오늘 날짜' 를 기준으로 계산하세요.",
|
||||
'- 저장소는 영문 내부 이름이 아니라 한글 표시 제목으로 설명하세요.',
|
||||
].join('\n');
|
||||
|
||||
// 업무 상태 → 한국어 라벨(컨텍스트 표기용)
|
||||
@@ -76,6 +81,7 @@ export class AiService {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly taskService: TaskService,
|
||||
private readonly repoService: RepoService,
|
||||
@InjectRepository(AiConversation)
|
||||
private readonly convRepo: Repository<AiConversation>,
|
||||
@InjectRepository(AiMessage)
|
||||
@@ -264,11 +270,17 @@ export class AiService {
|
||||
.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 assigned = await this.taskService.listAssigned(
|
||||
userId,
|
||||
1,
|
||||
CONTEXT_TASK_LIMIT,
|
||||
);
|
||||
const issued = await this.taskService.listIssued(userId, 1, 1);
|
||||
const repos = await this.repoService.findAll(1, CONTEXT_REPO_LIMIT);
|
||||
|
||||
const lines = [`오늘 날짜: ${today} (KST)`, ''];
|
||||
|
||||
// 담당 업무
|
||||
lines.push(`내가 담당한 업무 (${assigned.total}건):`);
|
||||
if (assigned.items.length === 0) {
|
||||
lines.push('- (없음)');
|
||||
@@ -285,6 +297,23 @@ export class AiService {
|
||||
}
|
||||
}
|
||||
lines.push('', `내가 지시한 업무: ${issued.total}건`);
|
||||
|
||||
// 저장소 목록(조직 전체 — 인증 사용자는 모두 열람 가능)
|
||||
lines.push('', `저장소 목록 (${repos.total}개):`);
|
||||
if (repos.items.length === 0) {
|
||||
lines.push('- (없음)');
|
||||
} else {
|
||||
for (const r of repos.items) {
|
||||
const vis = r.visibility === 'private' ? '비공개' : '공개';
|
||||
// 표시 제목(name) 위주로 안내 — slug(영문 내부 이름)는 사용자 노출용이 아니라 제외
|
||||
lines.push(
|
||||
`- ${r.name} — ${vis}, 업무 ${r.doneCount}/${r.totalCount}건`,
|
||||
);
|
||||
}
|
||||
if (repos.total > repos.items.length) {
|
||||
lines.push(`- … 외 ${repos.total - repos.items.length}개`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -348,8 +348,8 @@ 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 영속이라 다른 기기·재로그인에도 기록 유지**.
|
||||
- **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/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 안전: 굵게/목록/코드/제목/링크).
|
||||
|
||||
### 그 외(미착수)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { nextTick, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAiStore } from '@/stores/ai.store'
|
||||
import { relativeTimeKo } from '@/shared/utils/format'
|
||||
import { renderMarkdown } from '@/shared/utils/markdown'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -235,7 +236,18 @@ async function removeConv(id: string) {
|
||||
v-if="m.role === 'assistant'"
|
||||
class="ac-ava"
|
||||
>AI</span>
|
||||
<div class="ac-bubble">
|
||||
<!-- AI 응답은 마크다운 렌더(renderMarkdown 가 escapeHtml 후 신뢰 태그만 조립 → XSS 안전) -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="m.role === 'assistant'"
|
||||
class="ac-bubble ac-md"
|
||||
v-html="renderMarkdown(m.content)"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<div
|
||||
v-else
|
||||
class="ac-bubble"
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,6 +477,65 @@ async function removeConv(id: string) {
|
||||
background: #f4f5f7;
|
||||
border-top-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* 마크다운 렌더 영역 — v-html 내부라 :deep() 로 타겟 */
|
||||
.ac-bubble.ac-md {
|
||||
white-space: normal;
|
||||
}
|
||||
.ac-md :deep(p) {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
.ac-md :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.ac-md :deep(ul),
|
||||
.ac-md :deep(ol) {
|
||||
margin: 0.25em 0 0.5em;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
.ac-md :deep(li) {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
.ac-md :deep(strong) {
|
||||
font-weight: 700;
|
||||
}
|
||||
.ac-md :deep(code) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
font-family: ui-monospace, 'SFMono-Regular', monospace;
|
||||
}
|
||||
.ac-md :deep(pre.md-pre) {
|
||||
background: #1f2430;
|
||||
color: #e6e8ec;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.ac-md :deep(pre.md-pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ac-md :deep(.md-h) {
|
||||
font-weight: 700;
|
||||
margin: 0.4em 0 0.25em;
|
||||
}
|
||||
.ac-md :deep(.md-h1) {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.ac-md :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
.ac-md :deep(a) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ac-msg.user .ac-bubble {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user