From 51b608efeb662c8211ef112753e4fde6e56d8921 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 20 Jun 2026 15:03:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=EC=B1=84=ED=8C=85=20=EB=8B=A4?= =?UTF-8?q?=EB=93=AC=EA=B8=B0=20=E2=80=94=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=C2=B7=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=C2=B7=EB=A7=88=ED=81=AC=EB=8B=A4=EC=9A=B4=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=C2=B7=EB=A7=90=ED=88=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컨텍스트에 저장소 목록 추가(RepoService.findAll), slug 빼고 표시 제목만 노출 - AI 응답 마크다운 렌더링: shared/utils/markdown.ts (escapeHtml 후 신뢰 태그만 조립, 의존성 0, XSS 안전 — 굵게/목록/코드/제목/링크) - 시스템 프롬프트: 간결·이모지/사과 남발 금지·과도한 존칭 지양·마크다운 사용·저장소는 표시제목으로 안내 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/ai/ai.module.ts | 9 ++- backend/src/modules/ai/ai.service.ts | 39 +++++++++-- docs/api-contract.md | 4 +- frontend/src/components/AgentChatPanel.vue | 73 ++++++++++++++++++++- frontend/src/shared/utils/markdown.ts | Bin 0 -> 3057 bytes 5 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 frontend/src/shared/utils/markdown.ts diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 337279f..67d087a 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -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], }) diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 95b63e8..fe27162 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -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, @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( diff --git a/docs/api-contract.md b/docs/api-contract.md index fb77e59..b92e477 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -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 안전: 굵게/목록/코드/제목/링크). ### 그 외(미착수) diff --git a/frontend/src/components/AgentChatPanel.vue b/frontend/src/components/AgentChatPanel.vue index 955f6ab..76fa87e 100644 --- a/frontend/src/components/AgentChatPanel.vue +++ b/frontend/src/components/AgentChatPanel.vue @@ -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 -
+ + +
+ +
{{ m.content }}
@@ -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; diff --git a/frontend/src/shared/utils/markdown.ts b/frontend/src/shared/utils/markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..f734a2cad155f17ca3de6f8ac93d6d666c0cbdef GIT binary patch literal 3057 zcmb7`U2hXd6o$F>uQ+uhdDl4JLaR!R>_qfKq7t+cp;A?^VHbNGFRXX1{YYtzB|{BL zlTf7)lt688ssIU!L>fYHrEt@q!ewUsA9`j#onVmgg}pnTIrGkQ-g9=fTHUl9XaKQG zYMq>RYC1HbVwp8aWu=N~)l?^yNJtW#`W9ICPrC4uJ=lbUo!9iy??9i<4X$s|g?08| z7v9{u1MJ=$>$TzZ`Lkz$_7>^wW%_6<%a_w<8}x1u1~+fB`SrnkpFZ9Py0OoCt>LW) z_c}oDtOHx<(Z9FhV7o=vFxj^m%_62wg%TTYdI$iLLA=Dy~s- zG}8dh&^3bye$vVlU^|v(%!uIyG!g(7aa_v)0WTp4S@y00FdwlJ|G)yhv5Uy)JnGq7 zM(}|`1dY5fvGbf?us&;%x~`UpASpuOf>Jy#D$+~_gp0KdltK|AU=^%BuTRvB=u=!S!f<^m#Ap=yyzJ~`IFS+lMQZ|1! z1lx*p84C5BRWc3R0f$_5AP;;ROie*5CC0XpE!p5{OUXt`OyGW)>=Pmk{^(%!A1XZ7 zZHIjTeaH4Xff3ot64uz^4#Rd<>60y>+nZ=fehOa*x7C-rS-N6}DhovvRwza@Y)73T zWgO$0W0i`c6x8uwPmTZd$@mw=v}{ZX1=*ITi|Hv5|BHQx8-$@fSM%};ch|~SF>tIQ zs}p0!sRlDbW!buGSN&4t8Le9cO1f&>`DCp;j(5q)uu$SgEbySQTG<$rkZ4L2!&1sg zxH8PEUsgfUE62Y2{_AhX8Yr%LOhGNpFbgL|@H-x$rLk`cY`sliv_J}zX_C?E!3eV+zDmOjh9cUE<(*_vix3r5aiQ}XLE1D}QZBH2o=tj!M9 zVHL0#t7yGh?6TN94?Si1s}aUM8OG&Rh|6PljwXKe9bpK7%QeRY3ghYGh|{}zeEi|M zfseQcumt13M_F>nl&PGn$611?l2HYZ6J(DU6#ooS(zTH=Ts@}DY2dV|R7b-&^tLoa z!aPg@OHa;@Kw-WY1-79NKw-WU1;+Vc6y}Gcz;}}E)!^mOM{%T`3?-mGl0OzFQm^~a WdD;$lf#6ZV!(2A8D6n7PL;rv2G(JiI literal 0 HcmV?d00001