Refactor: AI 서비스 Python 구현 및 디렉토리 구조 변경

- ai-python: FastAPI 기반 AI 서비스 구현
  - 실시간 회의 제안 기능 추가
  - Claude API 통합
  - EventHub 및 Redis 연동

- ai-java-back: 기존 Java AI 서비스 백업 디렉토리로 이동
  - Spring Boot 기반 구현 보존

- ai 디렉토리: Java 서비스 파일 삭제 처리

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo
2025-10-30 10:02:23 +09:00
parent 5b4ca011c3
commit e9e03e1ff8
104 changed files with 71 additions and 12532 deletions
+113
View File
@@ -0,0 +1,113 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ai-service" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Configuration -->
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="20.249.153.213" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="aidb" />
<entry key="DB_USERNAME" value="hgzerouser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<!-- JPA Configuration -->
<entry key="SHOW_SQL" value="true" />
<entry key="DDL_AUTO" value="update" />
<!-- Redis Configuration -->
<entry key="REDIS_HOST" value="20.249.177.114" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="4" />
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8083" />
<entry key="CONTEXT_PATH" value="" />
<!-- JWT Configuration -->
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="1800" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
<!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
<!-- External AI API Configuration -->
<entry key="CLAUDE_API_KEY" value="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" />
<entry key="CLAUDE_BASE_URL" value="https://api.anthropic.com" />
<entry key="OPENAI_API_KEY" value="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" />
<entry key="OPENAI_BASE_URL" value="https://api.openai.com" />
<entry key="OPENWEATHER_API_KEY" value="1aa5bfca079a20586915b56f29235cc0" />
<entry key="OPENWEATHER_BASE_URL" value="https://api.openweathermap.org" />
<entry key="KAKAO_API_KEY" value="094feac895a3e4a6d7ffa66d877bf48f" />
<entry key="KAKAO_BASE_URL" value="https://dapi.kakao.com" />
<!-- Azure OpenAI Configuration -->
<entry key="AZURE_OPENAI_API_KEY" value="" />
<entry key="AZURE_OPENAI_ENDPOINT" value="" />
<entry key="AZURE_OPENAI_DEPLOYMENT" value="gpt-4o" />
<entry key="AZURE_OPENAI_EMBEDDING_DEPLOYMENT" value="text-embedding-3-large" />
<entry key="AZURE_OPENAI_MAX_TOKENS" value="2000" />
<entry key="AZURE_OPENAI_TEMPERATURE" value="0.3" />
<!-- Azure AI Search Configuration -->
<entry key="AZURE_AI_SEARCH_ENDPOINT" value="" />
<entry key="AZURE_AI_SEARCH_API_KEY" value="" />
<entry key="AZURE_AI_SEARCH_INDEX" value="meeting-transcripts" />
<!-- Azure Event Hubs Configuration -->
<entry key="AZURE_EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=ai-listen-policy;SharedAccessKey=wqcbVIXlOMyn/C562lx6DD75AyjHQ87xo+AEhJ7js9Q=;EntityPath=hgzero-eventhub-name" />
<entry key="AZURE_EVENTHUB_NAMESPACE" value="hgzero-eventhub-ns" />
<entry key="AZURE_EVENTHUB_NAME" value="hgzero-eventhub-name" />
<entry key="AZURE_CHECKPOINT_STORAGE_CONNECTION_STRING" value="" />
<entry key="AZURE_CHECKPOINT_CONTAINER" value="hgzero-checkpoints" />
<entry key="AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT" value="ai-transcript-group" />
<entry key="AZURE_EVENTHUB_CONSUMER_GROUP_MEETING" value="ai-meeting-group" />
<!-- Logging Configuration -->
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="INFO" />
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
<entry key="LOG_FILE" value="logs/ai-service.log" />
<entry key="LOG_MAX_FILE_SIZE" value="10MB" />
<entry key="LOG_MAX_HISTORY" value="7" />
<entry key="LOG_TOTAL_SIZE_CAP" value="100MB" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":ai:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
</ENTRIES>
</extension>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+98
View File
@@ -0,0 +1,98 @@
# AI 서비스 환경변수 설정 가이드
## 필수 환경변수
### 데이터베이스 설정
```bash
export DB_HOST=20.249.153.213
export DB_NAME=aidb
export DB_USERNAME=hgzerouser
export DB_PASSWORD=Hi5Jessica!
export DB_PORT=5432
export DDL_AUTO=update
```
### Redis 설정
```bash
export REDIS_HOST=20.249.177.114
export REDIS_PORT=6379
export REDIS_PASSWORD=Hi5Jessica!
export REDIS_DATABASE=4
```
### 서버 설정
```bash
export SERVER_PORT=8083
export SPRING_PROFILES_ACTIVE=dev
```
### JWT 설정
```bash
export JWT_SECRET=dev-jwt-secret-key-for-development-only
export JWT_ACCESS_TOKEN_VALIDITY=1800
export JWT_REFRESH_TOKEN_VALIDITY=86400
```
### 로깅 설정
```bash
export LOG_LEVEL_ROOT=INFO
export LOG_LEVEL_APP=DEBUG
export LOG_FILE=logs/ai-service.log
export LOG_MAX_FILE_SIZE=10MB
export LOG_MAX_HISTORY=7
export LOG_TOTAL_SIZE_CAP=100MB
```
### 외부 API 키 설정
```bash
export CLAUDE_API_KEY=sk-ant-ap...
export OPENAI_API_KEY=sk-proj-An4Q...
export OPENWEATHER_API_KEY=1aa5b...
export KAKAO_API_KEY=5cdc24....
```
### Azure EventHub 설정
```bash
export AZURE_EVENTHUB_CONNECTION_STRING="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo="
export AZURE_EVENTHUB_NAME=hgzero-eventhub-name
export AZURE_EVENTHUB_NAMESPACE=hgzero-eventhub-ns
```
### CORS 설정
```bash
export CORS_ALLOWED_ORIGINS=http://localhost:*
```
## 선택적 환경변수 (Azure AI 서비스 사용 시)
```bash
export AZURE_OPENAI_API_KEY=your-azure-openai-key
export AZURE_OPENAI_ENDPOINT=your-azure-openai-endpoint
export AZURE_AI_SEARCH_ENDPOINT=your-azure-search-endpoint
export AZURE_AI_SEARCH_API_KEY=your-azure-search-key
```
## 실행 방법
### 1. IntelliJ IDEA 실행 프로파일 사용
- IntelliJ에서 ai-service 실행 프로파일이 자동으로 설정됨
- 환경변수가 미리 설정되어 있어 바로 실행 가능
### 2. Gradle 직접 실행
```bash
cd ai
export 환경변수들...
./gradlew bootRun
```
### 3. 환경변수 파일 사용 (.env)
```bash
# .env 파일 생성 후 위 환경변수들 설정
source .env
./gradlew bootRun
```
## 주의사항
- API 키들은 보안을 위해 실제 값으로 교체해야 함
- 운영 환경에서는 JWT_SECRET을 반드시 변경
- 로그 파일 위치는 애플리케이션 실행 권한이 있는 경로로 설정
- EventHub Connection String은 실제 Azure 리소스에 맞게 설정
+148
View File
@@ -0,0 +1,148 @@
spring:
application:
name: ai
# Database Configuration
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:20.249.153.213}:${DB_PORT:5432}/${DB_NAME:aidb}
username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:Hi5Jessica!}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA Configuration
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis Configuration
data:
redis:
host: ${REDIS_HOST:20.249.177.114}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Hi5Jessica!}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:4}
# Server Configuration
server:
port: ${SERVER_PORT:8083}
servlet:
context-path: ${CONTEXT_PATH:}
# JWT Configuration
jwt:
secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
# Azure OpenAI Configuration
azure:
openai:
api-key: ${AZURE_OPENAI_API_KEY:}
endpoint: ${AZURE_OPENAI_ENDPOINT:}
deployment-name: ${AZURE_OPENAI_DEPLOYMENT:gpt-4o}
embedding-deployment: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT:text-embedding-3-large}
max-tokens: ${AZURE_OPENAI_MAX_TOKENS:2000}
temperature: ${AZURE_OPENAI_TEMPERATURE:0.3}
# External AI API Configuration
external:
ai:
claude:
api-key: ${CLAUDE_API_KEY:}
base-url: ${CLAUDE_BASE_URL:https://api.anthropic.com}
openai:
api-key: ${OPENAI_API_KEY:}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
openweather:
api-key: ${OPENWEATHER_API_KEY:}
base-url: ${OPENWEATHER_BASE_URL:https://api.openweathermap.org}
kakao:
api-key: ${KAKAO_API_KEY:}
base-url: ${KAKAO_BASE_URL:https://dapi.kakao.com}
# Azure AI Search Configuration
ai-search:
endpoint: ${AZURE_AI_SEARCH_ENDPOINT:}
api-key: ${AZURE_AI_SEARCH_API_KEY:}
index-name: ${AZURE_AI_SEARCH_INDEX:meeting-transcripts}
# Azure Event Hubs Configuration
eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=}
namespace: ${AZURE_EVENTHUB_NAMESPACE:hgzero-eventhub-ns}
eventhub-name: ${AZURE_EVENTHUB_NAME:hgzero-eventhub-name}
checkpoint-storage-connection-string: ${AZURE_CHECKPOINT_STORAGE_CONNECTION_STRING:}
checkpoint-container: ${AZURE_CHECKPOINT_CONTAINER:hgzero-checkpoints}
consumer-group:
transcript: ${AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT:ai-transcript-group}
meeting: ${AZURE_EVENTHUB_CONSUMER_GROUP_MEETING:ai-meeting-group}
# Actuator Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: always
show-components: always
health:
livenessState:
enabled: true
readinessState:
enabled: true
# OpenAPI Documentation
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
# Logging Configuration
logging:
level:
root: ${LOG_LEVEL_ROOT:INFO}
com.unicorn.hgzero.ai: ${LOG_LEVEL_APP:DEBUG}
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: ${LOG_FILE:logs/ai-service.log}
logback:
rollingpolicy:
max-file-size: ${LOG_MAX_FILE_SIZE:10MB}
max-history: ${LOG_MAX_HISTORY:7}
total-size-cap: ${LOG_TOTAL_SIZE_CAP:100MB}
+40
View File
@@ -0,0 +1,40 @@
bootJar {
archiveFileName = 'ai.jar'
}
dependencies {
// Common module
implementation project(':common')
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// PostgreSQL
runtimeOnly 'org.postgresql:postgresql'
// OpenAI
implementation "com.theokanning.openai-gpt3-java:service:${openaiVersion}"
// Anthropic Claude SDK
implementation 'com.anthropic:anthropic-java:2.1.0'
// Azure AI Search
implementation "com.azure:azure-search-documents:${azureAiSearchVersion}"
// Azure Event Hubs
implementation "com.azure:azure-messaging-eventhubs:${azureEventHubsVersion}"
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
// Feign (for external API calls)
implementation "io.github.openfeign:feign-jackson:${feignJacksonVersion}"
implementation "io.github.openfeign:feign-okhttp:${feignJacksonVersion}"
// Spring WebFlux for SSE streaming
implementation 'org.springframework.boot:spring-boot-starter-webflux'
// Springdoc OpenAPI
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
// H2 Database for local development
runtimeOnly 'com.h2database:h2'
}
@@ -0,0 +1,23 @@
package com.unicorn.hgzero.ai;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* AI Service Application
* AI 기반 회의록 자동 작성 및 제안 서비스 메인 클래스
* - LLM 기반 회의록 자동 작성
* - Todo 자동 추출 및 담당자 식별
* - 섹션 AI 요약 재생성
* - 전문용어 맥락 기반 설명 (RAG)
*/
@SpringBootApplication
@ComponentScan(basePackages = {"com.unicorn.hgzero.ai", "com.unicorn.hgzero.common"})
public class AiApplication {
public static void main(String[] args) {
SpringApplication.run(AiApplication.class, args);
}
}
@@ -0,0 +1,44 @@
package com.unicorn.hgzero.ai.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 추출된 Todo 도메인 모델
* AI가 회의록에서 추출한 Todo 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExtractedTodo {
/**
* Todo 내용
*/
private String content;
/**
* 담당자
*/
private String assignee;
/**
* 마감일
*/
private LocalDate dueDate;
/**
* 우선순위 (HIGH, MEDIUM, LOW)
*/
private String priority;
/**
* 관련 회의록 섹션
*/
private String sectionReference;
}
@@ -0,0 +1,86 @@
package com.unicorn.hgzero.ai.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 처리된 회의록 도메인 모델
* AI가 처리한 회의록 정보를 담는 도메인 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProcessedTranscript {
/**
* 회의록 ID
*/
private String transcriptId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 전체 요약
*/
private String summary;
/**
* 논의사항 목록
*/
private List<DiscussionItem> discussions;
/**
* 결정사항 목록
*/
private List<DecisionItem> decisions;
/**
* 보류사항 목록
*/
private List<String> pendingItems;
/**
* 생성 시간
*/
private LocalDateTime createdAt;
/**
* 상태 (DRAFT, COMPLETED)
*/
private String status;
/**
* 논의사항 아이템
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DiscussionItem {
private String topic;
private String speaker;
private String content;
}
/**
* 결정사항 아이템
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DecisionItem {
private String content;
private String decisionMaker;
private String category;
}
}
@@ -0,0 +1,60 @@
package com.unicorn.hgzero.ai.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* 관련 회의록 도메인 모델
* RAG 검색으로 찾은 관련 회의록 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RelatedMinutes {
/**
* 회의록 ID
*/
private String transcriptId;
/**
* 회의 제목
*/
private String title;
/**
* 회의 날짜
*/
private LocalDate date;
/**
* 참석자 목록
*/
private List<String> participants;
/**
* 관련도 점수 (0-100)
*/
private Double relevanceScore;
/**
* 공통 키워드 목록
*/
private List<String> commonKeywords;
/**
* 회의록 핵심 내용 요약 (1-2문장)
*/
private String summary;
/**
* 회의록 링크
*/
private String link;
}
@@ -0,0 +1,87 @@
package com.unicorn.hgzero.ai.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 제안사항 도메인 모델
* AI가 제안하는 논의사항 또는 결정사항
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Suggestion {
/**
* 제안 ID
*/
private String id;
/**
* 제안 유형 (DISCUSSION, DECISION)
*/
private SuggestionType type;
/**
* 제안 내용
*/
private String content;
/**
* 우선순위 (HIGH, MEDIUM, LOW)
*/
private String priority;
/**
* 제안 이유
*/
private String reason;
/**
* 신뢰도 점수 (0-1)
*/
private Double confidence;
/**
* 관련 안건
*/
private String relatedAgenda;
/**
* 예상 소요 시간 (분)
*/
private Integer estimatedTime;
/**
* 참여자 목록 (결정사항인 경우)
*/
private List<String> participants;
/**
* 카테고리 (결정사항인 경우: 기술, 일정, 리소스, 정책, 기타)
*/
private String category;
/**
* 원문 발췌 (결정사항인 경우)
*/
private String extractedFrom;
/**
* 배경 설명 (결정사항인 경우)
*/
private String context;
/**
* 제안 유형
*/
public enum SuggestionType {
DISCUSSION, // 논의사항
DECISION // 결정사항
}
}
@@ -0,0 +1,70 @@
package com.unicorn.hgzero.ai.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 전문용어 도메인 모델
* 회의록에서 감지된 전문용어 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Term {
/**
* 용어명
*/
private String term;
/**
* 텍스트 위치 정보
*/
private TextPosition position;
/**
* 신뢰도 점수 (0-1)
*/
private Double confidence;
/**
* 용어 카테고리 (기술, 업무, 도메인, 기획, 비즈니스, 전략, 마케팅)
*/
private String category;
/**
* 용어 정의 (간단한 설명)
*/
private String definition;
/**
* 용어가 사용된 맥락 (과거 회의록 참조)
* 예: "신제품 기획 회의(2024-09-15)에서 언급"
*/
private String context;
/**
* 관련 회의 ID (용어가 논의된 과거 회의)
*/
private String relatedMeetingId;
/**
* 하이라이트 여부
*/
private Boolean highlight;
/**
* 텍스트 위치
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TextPosition {
private Integer line;
private Integer offset;
}
}
@@ -0,0 +1,64 @@
package com.unicorn.hgzero.ai.biz.gateway;
import java.util.List;
/**
* LLM Gateway 인터페이스
* OpenAI API 연동을 추상화
*/
public interface LlmGateway {
/**
* 회의록 자동 작성 (LLM 기반)
*
* @param transcriptText STT 변환 텍스트
* @param title 회의 제목
* @param participants 참석자 목록
* @param agenda 회의 안건
* @return LLM 생성 회의록 (JSON 형식)
*/
String generateTranscript(String transcriptText, String title, List<String> participants, List<String> agenda);
/**
* Todo 추출 (LLM 기반)
*
* @param minutesContent 회의록 내용
* @return 추출된 Todo JSON
*/
String extractTodos(String minutesContent);
/**
* 섹션 요약 생성 (LLM 기반)
*
* @param sectionContent 섹션 내용
* @param meetingContext 회의 맥락
* @return 생성된 요약 (2-3문장)
*/
String generateSummary(String sectionContent, String meetingContext);
/**
* 전문용어 감지 (LLM 기반)
*
* @param text 분석할 텍스트
* @param organizationId 조직 ID
* @return 감지된 용어 JSON
*/
String detectTerms(String text, String organizationId);
/**
* 논의사항 제안 (LLM 기반)
*
* @param transcriptText 현재 회의록 텍스트
* @param agenda 회의 안건
* @return 논의사항 제안 JSON
*/
String suggestDiscussions(String transcriptText, List<String> agenda);
/**
* 결정사항 제안 (LLM 기반)
*
* @param transcriptText 현재 회의록 텍스트
* @return 결정사항 제안 JSON
*/
String suggestDecisions(String transcriptText);
}
@@ -0,0 +1,39 @@
package com.unicorn.hgzero.ai.biz.gateway;
import java.util.List;
/**
* RAG 검색 Gateway 인터페이스
* Azure AI Search 연동을 추상화
*/
public interface SearchGateway {
/**
* 관련 회의록 검색 (벡터 유사도 기반)
*
* @param meetingId 회의 ID
* @param transcriptId 회의록 ID
* @param limit 최대 개수
* @return 관련 회의록 JSON
*/
String searchRelatedTranscripts(String meetingId, String transcriptId, int limit);
/**
* 용어 설명을 위한 문서 검색
*
* @param term 용어명
* @param meetingId 회의 ID
* @param context 맥락
* @return 관련 문서 JSON
*/
String searchTermExplanation(String term, String meetingId, String context);
/**
* 회의록 인덱싱 (벡터 임베딩 저장)
*
* @param transcriptId 회의록 ID
* @param content 회의록 내용
* @param metadata 메타데이터
*/
void indexTranscript(String transcriptId, String content, String metadata);
}
@@ -0,0 +1,68 @@
package com.unicorn.hgzero.ai.biz.gateway;
import com.unicorn.hgzero.ai.biz.domain.ProcessedTranscript;
import java.util.List;
import java.util.Optional;
/**
* 회의록 데이터 Gateway 인터페이스
* 회의록 영속성 관리를 추상화
*/
public interface TranscriptGateway {
/**
* 회의록 저장
*
* @param transcript 처리된 회의록
* @return 저장된 회의록
*/
ProcessedTranscript save(ProcessedTranscript transcript);
/**
* 회의록 ID로 조회
*
* @param transcriptId 회의록 ID
* @return 회의록 (Optional)
*/
Optional<ProcessedTranscript> findById(String transcriptId);
/**
* 회의 ID로 조회
*
* @param meetingId 회의 ID
* @return 회의록 (Optional)
*/
Optional<ProcessedTranscript> findByMeetingId(String meetingId);
/**
* 회의 ID 목록으로 조회
*
* @param meetingIds 회의 ID 목록
* @return 회의록 목록
*/
List<ProcessedTranscript> findByMeetingIds(List<String> meetingIds);
/**
* 상태로 조회
*
* @param status 상태
* @return 회의록 목록
*/
List<ProcessedTranscript> findByStatus(String status);
/**
* 회의록 존재 여부 확인
*
* @param meetingId 회의 ID
* @return 존재 여부
*/
boolean existsByMeetingId(String meetingId);
/**
* 회의록 삭제
*
* @param transcriptId 회의록 ID
*/
void delete(String transcriptId);
}
@@ -0,0 +1,48 @@
package com.unicorn.hgzero.ai.biz.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.ai.biz.domain.RelatedMinutes;
import com.unicorn.hgzero.ai.biz.gateway.SearchGateway;
import com.unicorn.hgzero.ai.biz.usecase.RelatedTranscriptSearchUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
/**
* 관련 회의록 검색 Service
* RAG 기반 벡터 유사도 검색
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RelatedTranscriptSearchService implements RelatedTranscriptSearchUseCase {
private final SearchGateway searchGateway;
private final ObjectMapper objectMapper;
@Override
public List<RelatedMinutes> findRelatedTranscripts(String meetingId, String transcriptId, int limit) {
log.info("Searching related transcripts: meetingId={}, transcriptId={}, limit={}",
meetingId, transcriptId, limit);
// RAG 검색
String searchResult = searchGateway.searchRelatedTranscripts(meetingId, transcriptId, limit);
// TODO: JSON 파싱 및 RelatedMinutes 리스트 생성
// 현재는 mock 데이터 반환
return List.of(
RelatedMinutes.builder()
.transcriptId("aa0e8400-e29b-41d4-a716-446655440005")
.title("프로젝트 X 주간 회의")
.date(LocalDate.of(2025, 1, 15))
.participants(List.of("김철수", "이영희"))
.relevanceScore(85.5)
.commonKeywords(List.of("MSA", "API Gateway", "Spring Boot"))
.link("/transcripts/aa0e8400-e29b-41d4-a716-446655440005")
.build()
);
}
}
@@ -0,0 +1,28 @@
package com.unicorn.hgzero.ai.biz.service;
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
import com.unicorn.hgzero.ai.biz.usecase.SectionSummaryUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 섹션 AI 요약 재생성 Service
* LLM 기반 섹션 요약 생성
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SectionSummaryService implements SectionSummaryUseCase {
private final LlmGateway llmGateway;
@Override
public String regenerateSummary(String sectionId, String sectionContent, String meetingId) {
log.info("Regenerating section summary: sectionId={}, meetingId={}", sectionId, meetingId);
// LLM을 통한 요약 생성
String meetingContext = meetingId != null ? "회의 ID: " + meetingId : "";
return llmGateway.generateSummary(sectionContent, meetingContext);
}
}
@@ -0,0 +1,287 @@
package com.unicorn.hgzero.ai.biz.service;
import com.unicorn.hgzero.ai.biz.domain.Suggestion;
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
import com.unicorn.hgzero.ai.biz.usecase.SuggestionUseCase;
import com.unicorn.hgzero.ai.infra.client.ClaudeApiClient;
import com.unicorn.hgzero.ai.infra.dto.common.SimpleSuggestionDto;
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 논의사항/결정사항 제안 Service
* LLM 기반 실시간 회의 제안
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SuggestionService implements SuggestionUseCase {
private final LlmGateway llmGateway;
private final ClaudeApiClient claudeApiClient;
private final RedisTemplate<String, String> redisTemplate;
// 회의별 실시간 스트림 관리 (회의 ID -> Sink)
private final Map<String, Sinks.Many<RealtimeSuggestionsDto>> meetingSinks = new ConcurrentHashMap<>();
// 분석 임계값 설정 (MVP용 완화)
private static final int MIN_SEGMENTS_FOR_ANALYSIS = 5; // 5개 세그먼트 = 약 50-100자 (MVP용 완화)
private static final long TEXT_RETENTION_MS = 5 * 60 * 1000; // 5분
@Override
public List<Suggestion> suggestDiscussions(String meetingId, String transcriptText) {
log.info("Suggesting discussions: meetingId={}", meetingId);
// TODO: 회의 안건 조회
List<String> agenda = List.of();
// LLM을 통한 논의사항 제안
String llmResponse = llmGateway.suggestDiscussions(transcriptText, agenda);
// TODO: JSON 파싱 및 Suggestion 리스트 생성
return List.of(
Suggestion.builder()
.id("sugg-001")
.type(Suggestion.SuggestionType.DISCUSSION)
.content("보안 요구사항 검토")
.priority("HIGH")
.reason("안건에 포함되어 있으나 아직 논의되지 않음")
.confidence(0.9)
.relatedAgenda("프로젝트 개요")
.estimatedTime(15)
.build()
);
}
@Override
public List<Suggestion> suggestDecisions(String meetingId, String transcriptText) {
log.info("Suggesting decisions: meetingId={}", meetingId);
// LLM을 통한 결정사항 제안
String llmResponse = llmGateway.suggestDecisions(transcriptText);
// TODO: JSON 파싱 및 Suggestion 리스트 생성
return List.of(
Suggestion.builder()
.id("dec-001")
.type(Suggestion.SuggestionType.DECISION)
.content("React로 프론트엔드 개발")
.category("기술")
.participants(List.of("김철수", "이영희"))
.confidence(0.85)
.extractedFrom("프론트엔드는 React로 개발하기로 했습니다")
.context("팀원 대부분이 React 경험이 있어 개발 속도가 빠를 것으로 예상")
.build()
);
}
@Override
public Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId) {
log.info("실시간 AI 제안사항 스트리밍 시작 - meetingId: {}", meetingId);
// Sink 생성 및 등록 (멀티캐스트 - 여러 클라이언트 동시 지원)
Sinks.Many<RealtimeSuggestionsDto> sink = Sinks.many()
.multicast()
.onBackpressureBuffer();
meetingSinks.put(meetingId, sink);
// TODO: AI 개발 완료 후 제거 - 개발 중 프론트엔드 테스트를 위한 Mock 데이터 자동 발행
startMockDataEmission(meetingId, sink);
return sink.asFlux()
.doOnCancel(() -> {
log.info("SSE 스트림 종료 - meetingId: {}", meetingId);
meetingSinks.remove(meetingId);
cleanupMeetingData(meetingId);
})
.doOnError(error ->
log.error("AI 제안사항 스트리밍 오류 - meetingId: {}", meetingId, error));
}
/**
* Event Hub에서 수신한 실시간 텍스트 처리
* STT Service에서 TranscriptSegmentReady 이벤트를 받아 처리
*
* @param meetingId 회의 ID
* @param text 변환된 텍스트 세그먼트
* @param timestamp 타임스탬프 (ms)
*/
public void processRealtimeTranscript(String meetingId, String text, Long timestamp) {
try {
// 1. Redis에 실시간 텍스트 축적 (슬라이딩 윈도우: 최근 5분)
String key = "meeting:" + meetingId + ":transcript";
String value = timestamp + ":" + text;
redisTemplate.opsForZSet().add(key, value, timestamp.doubleValue());
// 5분 이전 데이터 제거
long fiveMinutesAgo = System.currentTimeMillis() - TEXT_RETENTION_MS;
redisTemplate.opsForZSet().removeRangeByScore(key, 0, fiveMinutesAgo);
// 2. 누적 텍스트가 임계값 이상이면 AI 분석
Long segmentCount = redisTemplate.opsForZSet().size(key);
if (segmentCount != null && segmentCount >= MIN_SEGMENTS_FOR_ANALYSIS) {
analyzeAndEmitSuggestions(meetingId);
}
} catch (Exception e) {
log.error("실시간 텍스트 처리 실패 - meetingId: {}", meetingId, e);
}
}
/**
* AI 분석 및 SSE 발행
*/
private void analyzeAndEmitSuggestions(String meetingId) {
// Redis에서 최근 5분 텍스트 조회
String key = "meeting:" + meetingId + ":transcript";
Set<String> recentTexts = redisTemplate.opsForZSet().reverseRange(key, 0, -1);
if (recentTexts == null || recentTexts.isEmpty()) {
return;
}
// 타임스탬프 제거 및 텍스트만 추출
String accumulatedText = recentTexts.stream()
.map(entry -> entry.split(":", 2)[1])
.collect(Collectors.joining("\n"));
// Claude API 분석 (비동기)
claudeApiClient.analyzeSuggestions(accumulatedText)
.subscribe(
suggestions -> {
// SSE 스트림으로 전송
Sinks.Many<RealtimeSuggestionsDto> sink = meetingSinks.get(meetingId);
if (sink != null) {
sink.tryEmitNext(suggestions);
log.info("AI 제안사항 발행 완료 - meetingId: {}, 제안사항: {}개",
meetingId,
suggestions.getSuggestions().size());
}
},
error -> log.error("Claude API 분석 실패 - meetingId: {}", meetingId, error)
);
}
/**
* 회의 종료 시 데이터 정리
*/
private void cleanupMeetingData(String meetingId) {
String key = "meeting:" + meetingId + ":transcript";
redisTemplate.delete(key);
log.info("회의 데이터 정리 완료 - meetingId: {}", meetingId);
}
/**
* TODO: AI 개발 완료 후 제거
* Mock 데이터 자동 발행 (프론트엔드 개발용)
* 5초마다 샘플 제안사항을 발행합니다.
*/
private void startMockDataEmission(String meetingId, Sinks.Many<RealtimeSuggestionsDto> sink) {
log.info("Mock 데이터 자동 발행 시작 - meetingId: {}", meetingId);
// 프론트엔드 HTML에 맞춘 샘플 데이터 (3개)
List<SimpleSuggestionDto> mockSuggestions = List.of(
SimpleSuggestionDto.builder()
.id("suggestion-1")
.content("신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.")
.timestamp("00:05:23")
.confidence(0.92)
.build(),
SimpleSuggestionDto.builder()
.id("suggestion-2")
.content("개발 일정: 1차 프로토타입은 11월 15일까지 완성, 2차 베타는 12월 1일 론칭")
.timestamp("00:08:45")
.confidence(0.88)
.build(),
SimpleSuggestionDto.builder()
.id("suggestion-3")
.content("마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요")
.timestamp("00:12:18")
.confidence(0.85)
.build()
);
// 5초마다 하나씩 발행 (총 3개)
Flux.interval(Duration.ofSeconds(5))
.take(3)
.map(index -> {
SimpleSuggestionDto suggestion = mockSuggestions.get(index.intValue());
return RealtimeSuggestionsDto.builder()
.suggestions(List.of(suggestion))
.build();
})
.subscribe(
suggestions -> {
sink.tryEmitNext(suggestions);
log.info("Mock 제안사항 발행 - meetingId: {}, 제안: {}",
meetingId,
suggestions.getSuggestions().get(0).getContent());
},
error -> log.error("Mock 데이터 발행 오류 - meetingId: {}", meetingId, error),
() -> log.info("Mock 데이터 발행 완료 - meetingId: {}", meetingId)
);
}
/**
* 실시간 AI 제안사항 생성 (Mock) - 간소화 버전
* 실제로는 STT 텍스트를 분석하여 AI가 제안사항을 생성
*
* @param meetingId 회의 ID
* @param sequence 시퀀스 번호
* @return RealtimeSuggestionsDto AI 제안사항
*/
private RealtimeSuggestionsDto generateRealtimeSuggestions(String meetingId, Long sequence) {
// Mock 데이터 - 실제로는 LLM을 통해 STT 텍스트 분석 후 생성
List<SimpleSuggestionDto> suggestions = List.of(
SimpleSuggestionDto.builder()
.id("sugg-" + sequence)
.content(getMockSuggestionContent(sequence))
.timestamp(getCurrentTimestamp())
.confidence(0.85 + (sequence % 15) * 0.01)
.build()
);
return RealtimeSuggestionsDto.builder()
.suggestions(suggestions)
.build();
}
/**
* Mock 제안사항 내용 생성
*/
private String getMockSuggestionContent(Long sequence) {
String[] suggestions = {
"신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
"개발 일정: 1차 프로토타입은 11월 15일까지 완성, 2차 베타는 12월 1일 론칭",
"마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요",
"보안 요구사항 검토가 필요하며, 데이터 암호화 방식에 대한 논의가 진행 중입니다.",
"React로 프론트엔드 개발하기로 결정되었으며, TypeScript 사용을 권장합니다.",
"데이터베이스는 PostgreSQL을 메인으로 사용하고, Redis를 캐시로 활용하기로 했습니다."
};
return suggestions[(int) (sequence % suggestions.length)];
}
/**
* 현재 타임스탬프 생성 (HH:MM:SS 형식)
*/
private String getCurrentTimestamp() {
java.time.LocalTime now = java.time.LocalTime.now();
return String.format("%02d:%02d:%02d",
now.getHour(),
now.getMinute(),
now.getSecond());
}
}
@@ -0,0 +1,42 @@
package com.unicorn.hgzero.ai.biz.service;
import com.unicorn.hgzero.ai.biz.domain.Term;
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
import com.unicorn.hgzero.ai.biz.usecase.TermDetectionUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 전문용어 감지 Service
* LLM 기반 전문용어 자동 감지
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TermDetectionService implements TermDetectionUseCase {
private final LlmGateway llmGateway;
@Override
public List<Term> detectTerms(String meetingId, String text, String organizationId) {
log.info("Detecting terms: meetingId={}, organizationId={}", meetingId, organizationId);
// LLM을 통한 전문용어 감지
String llmResponse = llmGateway.detectTerms(text, organizationId);
// TODO: JSON 파싱 및 Term 리스트 생성
// 현재는 mock 데이터 반환
return List.of(
Term.builder()
.term("MSA")
.position(Term.TextPosition.builder().line(5).offset(42).build())
.confidence(0.92)
.category("기술")
.highlight(true)
.build()
);
}
}
@@ -0,0 +1,54 @@
package com.unicorn.hgzero.ai.biz.service;
import com.unicorn.hgzero.ai.biz.gateway.SearchGateway;
import com.unicorn.hgzero.ai.biz.usecase.TermExplanationUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
/**
* 전문용어 설명 Service
* RAG 기반 맥락적 용어 설명 생성
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TermExplanationService implements TermExplanationUseCase {
private final SearchGateway searchGateway;
@Override
public TermExplanationResult explainTerm(String term, String meetingId, String context) {
log.info("Explaining term: term={}, meetingId={}", term, meetingId);
// RAG 검색
String searchResult = searchGateway.searchTermExplanation(term, meetingId, context);
// TODO: JSON 파싱 및 TermExplanationResult 생성
// 현재는 mock 데이터 반환
return new TermExplanationResult(
"MSA",
"Microservices Architecture의 약자",
"이번 프로젝트에서는 확장성과 독립 배포를 위해 MSA를 적용하기로 결정",
List.of(
"2024년 프로젝트 X에서 주문/결제/배송 서비스를 독립적으로 구성",
"서비스별 독립 배포로 배포 시간 70% 단축"
),
List.of(new RelatedProject("프로젝트 X", "동일한 MSA 아키텍처 적용")),
List.of(new PastDiscussion(
LocalDate.of(2024, 12, 15),
List.of("김철수", "이영희"),
"MSA 아키텍처의 장단점을 비교하고 적용 방안을 논의",
"/transcripts/bb0e8400-e29b-41d4-a716-446655440006"
)),
List.of(new Reference(
"MSA 아키텍처 가이드",
"위키",
"https://wiki.example.com/msa-guide"
))
);
}
}
@@ -0,0 +1,47 @@
package com.unicorn.hgzero.ai.biz.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.ai.biz.domain.ExtractedTodo;
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
import com.unicorn.hgzero.ai.biz.usecase.TodoExtractionUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Todo 자동 추출 Service
* LLM 기반 액션 아이템 추출
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TodoExtractionService implements TodoExtractionUseCase {
private final LlmGateway llmGateway;
private final ObjectMapper objectMapper;
@Override
public List<ExtractedTodo> extractTodos(String meetingId, String minutesContent, String userId) {
log.info("Extracting todos from minutes: meetingId={}, userId={}", meetingId, userId);
// LLM을 통한 Todo 추출
String llmResponse = llmGateway.extractTodos(minutesContent);
// TODO: JSON 파싱 및 ExtractedTodo 리스트 생성
// 현재는 mock 데이터 반환
return List.of(
ExtractedTodo.builder()
.content("API 설계서 작성")
.assignee("박민수")
.dueDate(LocalDate.of(2025, 1, 30))
.priority("HIGH")
.sectionReference("결정사항 #3")
.build()
);
}
}
@@ -0,0 +1,180 @@
package com.unicorn.hgzero.ai.biz.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.ai.biz.domain.ProcessedTranscript;
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
import com.unicorn.hgzero.ai.biz.gateway.SearchGateway;
import com.unicorn.hgzero.ai.biz.gateway.TranscriptGateway;
import com.unicorn.hgzero.ai.biz.usecase.TranscriptProcessUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 회의록 자동 작성 Service
* LLM 기반 회의록 생성 및 저장
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TranscriptProcessService implements TranscriptProcessUseCase {
private final LlmGateway llmGateway;
private final SearchGateway searchGateway;
private final TranscriptGateway transcriptGateway;
private final ObjectMapper objectMapper;
@Override
@Transactional
public ProcessedTranscript processTranscript(
String meetingId,
String transcriptText,
String userId,
String userName,
String title,
List<String> participants,
List<String> agenda
) {
log.info("Processing transcript for meeting: meetingId={}, userId={}", meetingId, userId);
// 1. LLM을 통한 회의록 자동 생성
String llmResponse = llmGateway.generateTranscript(transcriptText, title, participants, agenda);
log.debug("LLM response received: length={}", llmResponse.length());
// 2. LLM 응답 파싱
ProcessedTranscript processedTranscript = parseTranscriptFromLlm(llmResponse, meetingId);
// 3. 회의록 저장
ProcessedTranscript saved = transcriptGateway.save(processedTranscript);
log.info("Transcript saved: transcriptId={}, meetingId={}", saved.getTranscriptId(), meetingId);
// 4. RAG 인덱싱 (비동기 처리 고려)
indexTranscriptForSearch(saved);
return saved;
}
@Override
@Transactional(readOnly = true)
public ProcessedTranscript getTranscript(String transcriptId) {
log.debug("Retrieving transcript: transcriptId={}", transcriptId);
return transcriptGateway.findById(transcriptId)
.orElseThrow(() -> new IllegalArgumentException("Transcript not found: " + transcriptId));
}
@Override
@Transactional(readOnly = true)
public ProcessedTranscript getTranscriptByMeetingId(String meetingId) {
log.debug("Retrieving transcript by meetingId: {}", meetingId);
return transcriptGateway.findByMeetingId(meetingId)
.orElseThrow(() -> new IllegalArgumentException("Transcript not found for meeting: " + meetingId));
}
/**
* LLM 응답을 ProcessedTranscript 도메인으로 파싱
*/
private ProcessedTranscript parseTranscriptFromLlm(String llmResponse, String meetingId) {
try {
JsonNode root = objectMapper.readTree(llmResponse);
// Discussions 파싱
List<ProcessedTranscript.DiscussionItem> discussions = new ArrayList<>();
if (root.has("discussions")) {
root.get("discussions").forEach(node -> {
discussions.add(ProcessedTranscript.DiscussionItem.builder()
.topic(node.get("topic").asText())
.speaker(node.get("speaker").asText())
.content(node.get("content").asText())
.build());
});
}
// Decisions 파싱
List<ProcessedTranscript.DecisionItem> decisions = new ArrayList<>();
if (root.has("decisions")) {
root.get("decisions").forEach(node -> {
decisions.add(ProcessedTranscript.DecisionItem.builder()
.content(node.get("content").asText())
.decisionMaker(node.get("decisionMaker").asText())
.category(node.get("category").asText())
.build());
});
}
// Pending items 파싱
List<String> pendingItems = new ArrayList<>();
if (root.has("pendingItems")) {
root.get("pendingItems").forEach(node -> pendingItems.add(node.asText()));
}
return ProcessedTranscript.builder()
.transcriptId(UUID.randomUUID().toString())
.meetingId(meetingId)
.summary(root.has("summary") ? root.get("summary").asText() : "")
.discussions(discussions)
.decisions(decisions)
.pendingItems(pendingItems)
.createdAt(LocalDateTime.now())
.status("DRAFT")
.build();
} catch (JsonProcessingException e) {
log.error("Failed to parse LLM response: {}", llmResponse, e);
throw new RuntimeException("Failed to parse transcript from LLM", e);
}
}
/**
* RAG 검색을 위한 회의록 인덱싱
*/
private void indexTranscriptForSearch(ProcessedTranscript transcript) {
try {
String content = buildSearchableContent(transcript);
String metadata = buildMetadata(transcript);
searchGateway.indexTranscript(transcript.getTranscriptId(), content, metadata);
log.debug("Transcript indexed for search: transcriptId={}", transcript.getTranscriptId());
} catch (Exception e) {
log.error("Failed to index transcript for search: transcriptId={}",
transcript.getTranscriptId(), e);
// 인덱싱 실패는 치명적이지 않으므로 예외를 전파하지 않음
}
}
private String buildSearchableContent(ProcessedTranscript transcript) {
StringBuilder content = new StringBuilder();
content.append(transcript.getSummary()).append("\n\n");
if (transcript.getDiscussions() != null) {
transcript.getDiscussions().forEach(d ->
content.append(d.getTopic()).append(": ").append(d.getContent()).append("\n")
);
}
if (transcript.getDecisions() != null) {
transcript.getDecisions().forEach(d ->
content.append("결정: ").append(d.getContent()).append("\n")
);
}
return content.toString();
}
private String buildMetadata(ProcessedTranscript transcript) {
try {
return objectMapper.writeValueAsString(transcript);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize transcript metadata", e);
return "{}";
}
}
}
@@ -0,0 +1,22 @@
package com.unicorn.hgzero.ai.biz.usecase;
import com.unicorn.hgzero.ai.biz.domain.RelatedMinutes;
import java.util.List;
/**
* 관련 회의록 검색 UseCase
* RAG 기반 벡터 유사도 검색으로 관련 회의록 조회
*/
public interface RelatedTranscriptSearchUseCase {
/**
* 관련 회의록 검색
*
* @param meetingId 회의 ID
* @param transcriptId 회의록 ID
* @param limit 반환할 최대 개수
* @return 관련 회의록 목록
*/
List<RelatedMinutes> findRelatedTranscripts(String meetingId, String transcriptId, int limit);
}
@@ -0,0 +1,18 @@
package com.unicorn.hgzero.ai.biz.usecase;
/**
* 섹션 AI 요약 재생성 UseCase
* 사용자가 작성한 섹션 내용을 기반으로 AI 요약 재생성
*/
public interface SectionSummaryUseCase {
/**
* 섹션 요약 재생성
*
* @param sectionId 섹션 ID
* @param sectionContent 섹션 내용 (Markdown 형식)
* @param meetingId 회의 ID (선택적, 맥락 이해용)
* @return 생성된 AI 요약 (2-3문장)
*/
String regenerateSummary(String sectionId, String sectionContent, String meetingId);
}
@@ -0,0 +1,41 @@
package com.unicorn.hgzero.ai.biz.usecase;
import com.unicorn.hgzero.ai.biz.domain.Suggestion;
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
import reactor.core.publisher.Flux;
import java.util.List;
/**
* 논의사항/결정사항 제안 UseCase
* AI 기반 실시간 회의 제안 기능
*/
public interface SuggestionUseCase {
/**
* 논의사항 제안
*
* @param meetingId 회의 ID
* @param transcriptText 현재까지의 회의록 텍스트
* @return 논의사항 제안 목록
*/
List<Suggestion> suggestDiscussions(String meetingId, String transcriptText);
/**
* 결정사항 제안
*
* @param meetingId 회의 ID
* @param transcriptText 현재까지의 회의록 텍스트
* @return 결정사항 제안 목록
*/
List<Suggestion> suggestDecisions(String meetingId, String transcriptText);
/**
* 실시간 AI 제안사항 스트리밍
* 회의 진행 중 실시간으로 논의사항과 결정사항을 분석하여 제안
*
* @param meetingId 회의 ID
* @return 실시간 제안사항 스트림
*/
Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId);
}
@@ -0,0 +1,22 @@
package com.unicorn.hgzero.ai.biz.usecase;
import com.unicorn.hgzero.ai.biz.domain.Term;
import java.util.List;
/**
* 전문용어 감지 UseCase
* 회의록 텍스트에서 전문용어를 자동으로 감지
*/
public interface TermDetectionUseCase {
/**
* 전문용어 감지
*
* @param meetingId 회의 ID
* @param text 분석할 회의록 텍스트
* @param organizationId 조직 ID
* @return 감지된 전문용어 목록
*/
List<Term> detectTerms(String meetingId, String text, String organizationId);
}
@@ -0,0 +1,37 @@
package com.unicorn.hgzero.ai.biz.usecase;
import java.util.List;
/**
* 전문용어 설명 UseCase
* RAG 기반 맥락적 용어 설명 생성
*/
public interface TermExplanationUseCase {
/**
* 용어 설명 생성
*
* @param term 용어명
* @param meetingId 회의 ID
* @param context 현재 회의 맥락 (선택)
* @return 용어 설명 결과
*/
TermExplanationResult explainTerm(String term, String meetingId, String context);
/**
* 용어 설명 결과
*/
record TermExplanationResult(
String term,
String basicDefinition,
String contextualMeaning,
List<String> useCases,
List<RelatedProject> relatedProjects,
List<PastDiscussion> pastDiscussions,
List<Reference> references
) {}
record RelatedProject(String name, String relevance) {}
record PastDiscussion(java.time.LocalDate date, List<String> participants, String summary, String link) {}
record Reference(String title, String type, String link) {}
}
@@ -0,0 +1,22 @@
package com.unicorn.hgzero.ai.biz.usecase;
import com.unicorn.hgzero.ai.biz.domain.ExtractedTodo;
import java.util.List;
/**
* Todo 자동 추출 UseCase
* 회의록에서 액션 아이템을 자동으로 추출하고 담당자 식별
*/
public interface TodoExtractionUseCase {
/**
* 회의록에서 Todo 추출
*
* @param meetingId 회의 ID
* @param minutesContent 회의록 전체 내용 (Markdown 형식)
* @param userId 요청자 ID
* @return 추출된 Todo 목록
*/
List<ExtractedTodo> extractTodos(String meetingId, String minutesContent, String userId);
}
@@ -0,0 +1,48 @@
package com.unicorn.hgzero.ai.biz.usecase;
import com.unicorn.hgzero.ai.biz.domain.ProcessedTranscript;
/**
* 회의록 자동 작성 UseCase
* STT에서 변환된 텍스트를 받아 LLM 기반으로 회의록 자동 작성
*/
public interface TranscriptProcessUseCase {
/**
* 회의록 자동 작성
*
* @param meetingId 회의 ID
* @param transcriptText STT에서 변환된 텍스트
* @param userId 사용자 ID
* @param userName 사용자 이름
* @param title 회의 제목
* @param participants 참석자 목록
* @param agenda 회의 안건
* @return 처리된 회의록
*/
ProcessedTranscript processTranscript(
String meetingId,
String transcriptText,
String userId,
String userName,
String title,
java.util.List<String> participants,
java.util.List<String> agenda
);
/**
* 회의록 조회
*
* @param transcriptId 회의록 ID
* @return 처리된 회의록
*/
ProcessedTranscript getTranscript(String transcriptId);
/**
* 회의 ID로 회의록 조회
*
* @param meetingId 회의 ID
* @return 처리된 회의록
*/
ProcessedTranscript getTranscriptByMeetingId(String meetingId);
}
@@ -0,0 +1,173 @@
package com.unicorn.hgzero.ai.infra.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.ai.infra.config.ClaudeConfig;
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
import com.unicorn.hgzero.ai.infra.dto.common.SimpleSuggestionDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Claude API 클라이언트
* Anthropic Claude API를 호출하여 AI 제안사항 생성
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ClaudeApiClient {
private final ClaudeConfig claudeConfig;
private final ObjectMapper objectMapper;
private final WebClient webClient;
/**
* 실시간 AI 제안사항 분석 (간소화 버전)
*
* @param transcriptText 누적된 회의록 텍스트
* @return AI 제안사항 (논의사항과 결정사항 통합)
*/
public Mono<RealtimeSuggestionsDto> analyzeSuggestions(String transcriptText) {
log.debug("Claude API 호출 - 텍스트 길이: {}", transcriptText.length());
String systemPrompt = """
당신은 회의록 작성 전문 AI 어시스턴트입니다.
실시간 회의 텍스트를 분석하여 **제안사항을 적극적으로** 추출하세요.
**추출 대상 (MVP용 - 넓은 기준)**:
- 회의 안건 관련 내용
- 논의 중인 주제 (확정되지 않아도 OK)
- 의견이나 제안
- 결정된 사항
- 액션 아이템
- 계획이나 일정 관련 언급
- 검토가 필요한 내용
**제외할 내용** (최소화):
- 명백한 잡담이나 농담
- 회의 시작/종료 인사말
**응답 형식**: JSON만 반환 (다른 설명 없이)
{
"suggestions": [
{
"content": "구체적인 제안 내용 (자연스러운 문장으로)",
"confidence": 0.7
}
]
}
**주의**:
- 확신이 없어도 제안사항으로 포함 (confidence 0.6 이상이면 OK)
- 회의 내용에서 의미 있는 내용은 모두 제안사항으로 추출
- confidence는 0-1 사이 값 (MVP에서는 낮아도 괜찮음)
""";
String userPrompt = String.format("""
다음 회의 내용을 분석해주세요:
%s
""", transcriptText);
// Claude API 요청 페이로드
Map<String, Object> requestBody = Map.of(
"model", claudeConfig.getModel(),
"max_tokens", claudeConfig.getMaxTokens(),
"temperature", claudeConfig.getTemperature(),
"system", systemPrompt,
"messages", List.of(
Map.of(
"role", "user",
"content", userPrompt
)
)
);
return webClient.post()
.uri(claudeConfig.getBaseUrl() + "/v1/messages")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.header("x-api-key", claudeConfig.getApiKey())
.header("anthropic-version", "2023-06-01")
.bodyValue(requestBody)
.retrieve()
.bodyToMono(String.class)
.map(this::parseClaudeResponse)
.doOnSuccess(result -> log.info("Claude API 응답 성공 - 제안사항: {}개",
result.getSuggestions().size()))
.doOnError(error -> log.error("Claude API 호출 실패", error))
.onErrorResume(error -> Mono.just(RealtimeSuggestionsDto.builder()
.suggestions(new ArrayList<>())
.build()));
}
/**
* Claude API 응답 파싱 (간소화 버전)
*/
private RealtimeSuggestionsDto parseClaudeResponse(String responseBody) {
try {
JsonNode root = objectMapper.readTree(responseBody);
// Claude 응답 구조: { "content": [ { "text": "..." } ] }
String contentText = root.path("content").get(0).path("text").asText();
// JSON 부분만 추출 (코드 블록 제거)
String jsonText = extractJson(contentText);
JsonNode suggestionsJson = objectMapper.readTree(jsonText);
// 제안사항 파싱
List<SimpleSuggestionDto> suggestions = new ArrayList<>();
JsonNode suggestionsNode = suggestionsJson.path("suggestions");
if (suggestionsNode.isArray()) {
for (JsonNode node : suggestionsNode) {
suggestions.add(SimpleSuggestionDto.builder()
.id(UUID.randomUUID().toString())
.content(node.path("content").asText())
.confidence(node.path("confidence").asDouble(0.8))
.build());
}
}
return RealtimeSuggestionsDto.builder()
.suggestions(suggestions)
.build();
} catch (Exception e) {
log.error("Claude 응답 파싱 실패", e);
return RealtimeSuggestionsDto.builder()
.suggestions(new ArrayList<>())
.build();
}
}
/**
* 응답에서 JSON 부분만 추출
* Claude가 마크다운 코드 블록으로 감싼 경우 처리
*/
private String extractJson(String text) {
// ```json ... ``` 형식 제거
if (text.contains("```json")) {
int start = text.indexOf("```json") + 7;
int end = text.lastIndexOf("```");
return text.substring(start, end).trim();
}
// ``` ... ``` 형식 제거
else if (text.contains("```")) {
int start = text.indexOf("```") + 3;
int end = text.lastIndexOf("```");
return text.substring(start, end).trim();
}
return text.trim();
}
}
@@ -0,0 +1,28 @@
package com.unicorn.hgzero.ai.infra.config;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
/**
* Claude API 설정
*/
@Configuration
@Getter
public class ClaudeConfig {
@Value("${external.ai.claude.api-key}")
private String apiKey;
@Value("${external.ai.claude.base-url}")
private String baseUrl;
@Value("${external.ai.claude.model}")
private String model;
@Value("${external.ai.claude.max-tokens}")
private Integer maxTokens;
@Value("${external.ai.claude.temperature}")
private Double temperature;
}
@@ -0,0 +1,131 @@
package com.unicorn.hgzero.ai.infra.config;
import com.azure.messaging.eventhubs.EventProcessorClient;
import com.azure.messaging.eventhubs.EventProcessorClientBuilder;
import com.azure.messaging.eventhubs.checkpointstore.blob.BlobCheckpointStore;
import com.azure.messaging.eventhubs.models.ErrorContext;
import com.azure.messaging.eventhubs.models.EventContext;
import com.azure.storage.blob.BlobContainerAsyncClient;
import com.azure.storage.blob.BlobContainerClientBuilder;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.ai.biz.service.SuggestionService;
import com.unicorn.hgzero.ai.infra.event.TranscriptSegmentReadyEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
/**
* Azure Event Hub 설정
* STT Service의 TranscriptSegmentReady 이벤트 구독
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class EventHubConfig {
private final SuggestionService suggestionService;
private final ObjectMapper objectMapper;
@Value("${external.eventhub.connection-string}")
private String connectionString;
@Value("${external.eventhub.eventhub-name}")
private String eventHubName;
@Value("${external.eventhub.consumer-group.transcript}")
private String consumerGroup;
@Value("${external.eventhub.checkpoint-storage-connection-string:}")
private String checkpointStorageConnectionString;
@Value("${external.eventhub.checkpoint-container}")
private String checkpointContainer;
private EventProcessorClient eventProcessorClient;
@PostConstruct
public void startEventProcessor() {
log.info("Event Hub Processor 시작 - eventhub: {}, consumerGroup: {}",
eventHubName, consumerGroup);
EventProcessorClientBuilder builder = new EventProcessorClientBuilder()
.connectionString(connectionString, eventHubName)
.consumerGroup(consumerGroup)
.processEvent(this::processEvent)
.processError(this::processError);
// Checkpoint Storage 설정
if (checkpointStorageConnectionString != null && !checkpointStorageConnectionString.isEmpty()) {
log.info("Checkpoint Storage 활성화 (Azure Blob) - container: {}", checkpointContainer);
BlobContainerAsyncClient blobContainerAsyncClient = new BlobContainerClientBuilder()
.connectionString(checkpointStorageConnectionString)
.containerName(checkpointContainer)
.buildAsyncClient();
builder.checkpointStore(new BlobCheckpointStore(blobContainerAsyncClient));
} else {
log.warn("⚠️ Checkpoint Storage 미설정 - 체크포인트 저장 안 함 (재시작 시 처음부터 읽음)");
log.warn("⚠️ 프로덕션 환경에서는 AZURE_BLOB_CONNECTION_STRING 설정 필요");
// Checkpoint Store 없이 실행 (재시작 시 처음부터 읽음)
}
eventProcessorClient = builder.buildEventProcessorClient();
eventProcessorClient.start();
log.info("Event Hub Processor 시작 완료");
}
@PreDestroy
public void stopEventProcessor() {
if (eventProcessorClient != null) {
log.info("Event Hub Processor 종료");
eventProcessorClient.stop();
}
}
/**
* 이벤트 처리 핸들러
*/
private void processEvent(EventContext eventContext) {
try {
String eventData = eventContext.getEventData().getBodyAsString();
log.debug("이벤트 수신: {}", eventData);
// JSON 역직렬화
TranscriptSegmentReadyEvent event = objectMapper.readValue(
eventData,
TranscriptSegmentReadyEvent.class
);
log.info("실시간 텍스트 수신 - meetingId: {}, text: {}",
event.getMeetingId(), event.getText());
// SuggestionService로 전달하여 AI 분석 트리거
suggestionService.processRealtimeTranscript(
event.getMeetingId(),
event.getText(),
event.getTimestamp()
);
// 체크포인트 업데이트
eventContext.updateCheckpoint();
} catch (Exception e) {
log.error("이벤트 처리 실패", e);
}
}
/**
* 에러 처리 핸들러
*/
private void processError(ErrorContext errorContext) {
log.error("Event Hub 에러 - partition: {}, error: {}",
errorContext.getPartitionContext().getPartitionId(),
errorContext.getThrowable().getMessage(),
errorContext.getThrowable());
}
}
@@ -0,0 +1,69 @@
package com.unicorn.hgzero.ai.infra.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정
* CORS 설정 및 API 보안 설정 (인증 없음)
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${cors.allowed-origins:http://localhost:3000,http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 모든 요청 허용 (인증 없음)
.anyRequest().permitAll()
)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
// 허용할 HTTP 메소드
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
// 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers",
"X-User-Id", "X-User-Name"
));
// 자격 증명 허용
configuration.setAllowCredentials(true);
// Pre-flight 요청 캐시 시간
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,63 @@
package com.unicorn.hgzero.ai.infra.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger/OpenAPI 설정
* AI Service API 문서화를 위한 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.addServersItem(new Server()
.url("http://localhost:8083")
.description("Local Development"))
.addServersItem(new Server()
.url("{protocol}://{host}:{port}")
.description("Custom Server")
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("http")
.description("Protocol (http or https)")
.addEnumItem("http")
.addEnumItem("https"))
.addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("localhost")
.description("Server host"))
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("8083")
.description("Server port"))))
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
}
private Info apiInfo() {
return new Info()
.title("AI Service API")
.description("AI 기반 회의록 자동 작성 및 분석 서비스 API")
.version("1.0.0")
.contact(new Contact()
.name("HGZero Development Team")
.email("dev@hgzero.com"));
}
private SecurityScheme createAPIKeyScheme() {
return new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.bearerFormat("JWT")
.scheme("bearer");
}
}
@@ -0,0 +1,22 @@
package com.unicorn.hgzero.ai.infra.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
/**
* WebClient 설정
* 외부 API 호출을 위한 WebClient 빈 생성
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder()
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(10 * 1024 * 1024)) // 10MB
.build();
}
}
@@ -0,0 +1,74 @@
package com.unicorn.hgzero.ai.infra.controller;
import com.unicorn.hgzero.ai.biz.usecase.TermExplanationUseCase;
import com.unicorn.hgzero.ai.infra.dto.response.TermExplanationResponse;
import com.unicorn.hgzero.ai.infra.dto.common.*;
import com.unicorn.hgzero.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.stream.Collectors;
/**
* 전문용어 설명 Controller
* GET /api/terms/{term}/explain
*/
@RestController
@RequestMapping("/api/ai/terms")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Term", description = "전문용어 감지 및 설명 API")
public class ExplanationController {
private final TermExplanationUseCase termExplanationUseCase;
@GetMapping("/{term}/explain")
@Operation(summary = "맥락 기반 용어 설명", description = "전문용어에 대한 맥락 기반 설명을 생성합니다")
public ResponseEntity<ApiResponse<TermExplanationResponse>> explainTerm(
@PathVariable String term,
@RequestParam String meetingId,
@RequestParam(required = false) String context) {
log.info("용어 설명 요청 - term: {}, meetingId: {}", term, meetingId);
TermExplanationUseCase.TermExplanationResult result = termExplanationUseCase.explainTerm(
term,
meetingId,
context
);
TermExplanationResponse response = TermExplanationResponse.builder()
.term(result.term())
.basicDefinition(result.basicDefinition())
.contextualMeaning(result.contextualMeaning())
.useCases(result.useCases())
.relatedProjects(result.relatedProjects().stream()
.map(p -> RelatedProjectDto.builder()
.name(p.name())
.relevance(p.relevance())
.build())
.collect(Collectors.toList()))
.pastDiscussions(result.pastDiscussions().stream()
.map(d -> PastDiscussionDto.builder()
.date(d.date())
.participants(d.participants())
.summary(d.summary())
.link(d.link())
.build())
.collect(Collectors.toList()))
.references(result.references().stream()
.map(r -> ReferenceDto.builder()
.title(r.title())
.type(r.type())
.link(r.link())
.build())
.collect(Collectors.toList()))
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,64 @@
package com.unicorn.hgzero.ai.infra.controller;
import com.unicorn.hgzero.ai.biz.domain.RelatedMinutes;
import com.unicorn.hgzero.ai.biz.usecase.RelatedTranscriptSearchUseCase;
import com.unicorn.hgzero.ai.infra.dto.response.RelatedTranscriptsResponse;
import com.unicorn.hgzero.ai.infra.dto.common.RelatedTranscriptDto;
import com.unicorn.hgzero.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* 관련 회의록 조회 Controller
* GET /api/transcripts/{meetingId}/related
*/
@RestController
@RequestMapping("/api/ai/transcripts")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Relation", description = "관련 회의록 조회 API")
public class RelationController {
private final RelatedTranscriptSearchUseCase relatedTranscriptSearchUseCase;
@GetMapping("/{meetingId}/related")
@Operation(summary = "관련 회의록 조회", description = "벡터 유사도 검색을 통해 관련된 회의록을 찾아 반환합니다")
public ResponseEntity<ApiResponse<RelatedTranscriptsResponse>> findRelatedTranscripts(
@PathVariable String meetingId,
@RequestParam String transcriptId,
@RequestParam(defaultValue = "5") int limit) {
log.info("관련 회의록 조회 요청 - meetingId: {}, transcriptId: {}, limit: {}", meetingId, transcriptId, limit);
List<RelatedMinutes> relatedMinutes = relatedTranscriptSearchUseCase.findRelatedTranscripts(
meetingId,
transcriptId,
limit
);
RelatedTranscriptsResponse response = RelatedTranscriptsResponse.builder()
.relatedTranscripts(relatedMinutes.stream()
.map(r -> RelatedTranscriptDto.builder()
.transcriptId(r.getTranscriptId())
.title(r.getTitle())
.date(r.getDate())
.participants(r.getParticipants())
.relevanceScore(r.getRelevanceScore())
.commonKeywords(r.getCommonKeywords())
.summary(r.getSummary())
.link(r.getLink())
.build())
.collect(Collectors.toList()))
.totalCount(relatedMinutes.size())
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,51 @@
package com.unicorn.hgzero.ai.infra.controller;
import com.unicorn.hgzero.ai.biz.usecase.SectionSummaryUseCase;
import com.unicorn.hgzero.ai.infra.dto.request.SectionSummaryRequest;
import com.unicorn.hgzero.ai.infra.dto.response.SectionSummaryResponse;
import com.unicorn.hgzero.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/**
* 섹션 AI 요약 재생성 Controller
* POST /api/sections/{sectionId}/regenerate-summary
*/
@RestController
@RequestMapping("/api/ai/sections")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Section", description = "섹션 AI 요약 재생성 API")
public class SectionController {
private final SectionSummaryUseCase sectionSummaryUseCase;
@PostMapping("/{sectionId}/regenerate-summary")
@Operation(summary = "섹션 AI 요약 재생성", description = "사용자가 작성한 섹션 내용을 기반으로 AI 요약을 재생성합니다")
public ResponseEntity<ApiResponse<SectionSummaryResponse>> regenerateSummary(
@PathVariable String sectionId,
@Valid @RequestBody SectionSummaryRequest request) {
log.info("섹션 요약 재생성 요청 - sectionId: {}, meetingId: {}", sectionId, request.getMeetingId());
String summary = sectionSummaryUseCase.regenerateSummary(
sectionId,
request.getSectionContent(),
request.getMeetingId()
);
SectionSummaryResponse response = SectionSummaryResponse.builder()
.summary(summary)
.generatedAt(LocalDateTime.now())
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,132 @@
package com.unicorn.hgzero.ai.infra.controller;
import com.unicorn.hgzero.ai.biz.domain.Suggestion;
import com.unicorn.hgzero.ai.biz.usecase.SuggestionUseCase;
import com.unicorn.hgzero.ai.infra.dto.request.DiscussionSuggestionRequest;
import com.unicorn.hgzero.ai.infra.dto.request.DecisionSuggestionRequest;
import com.unicorn.hgzero.ai.infra.dto.response.DiscussionSuggestionResponse;
import com.unicorn.hgzero.ai.infra.dto.response.DecisionSuggestionResponse;
import com.unicorn.hgzero.ai.infra.dto.common.DiscussionSuggestionDto;
import com.unicorn.hgzero.ai.infra.dto.common.DecisionSuggestionDto;
import com.unicorn.hgzero.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 논의사항/결정사항 제안 Controller
* POST /api/suggestions/discussion
* POST /api/suggestions/decision
*/
@RestController
@RequestMapping("/api/ai/suggestions")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Suggestion", description = "논의사항/결정사항 제안 API")
public class SuggestionController {
private final SuggestionUseCase suggestionUseCase;
@PostMapping("/discussion")
@Operation(summary = "논의사항 제안", description = "현재 회의 진행 상황을 분석하여 추가로 논의하면 좋을 주제를 제안합니다")
public ResponseEntity<ApiResponse<DiscussionSuggestionResponse>> suggestDiscussion(
@Valid @RequestBody DiscussionSuggestionRequest request) {
log.info("논의사항 제안 요청 - meetingId: {}", request.getMeetingId());
List<Suggestion> suggestions = suggestionUseCase.suggestDiscussions(
request.getMeetingId(),
request.getTranscriptText()
);
DiscussionSuggestionResponse response = DiscussionSuggestionResponse.builder()
.suggestions(suggestions.stream()
.map(s -> DiscussionSuggestionDto.builder()
.id(s.getId())
.topic(s.getContent())
.reason(s.getReason())
.priority(s.getPriority())
.relatedAgenda(s.getRelatedAgenda())
.estimatedTime(s.getEstimatedTime())
.build())
.collect(Collectors.toList()))
.totalCount(suggestions.size())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/decision")
@Operation(summary = "결정사항 제안", description = "회의록 텍스트에서 결정사항 패턴을 감지하여 제안합니다")
public ResponseEntity<ApiResponse<DecisionSuggestionResponse>> suggestDecision(
@Valid @RequestBody DecisionSuggestionRequest request) {
log.info("결정사항 제안 요청 - meetingId: {}", request.getMeetingId());
List<Suggestion> suggestions = suggestionUseCase.suggestDecisions(
request.getMeetingId(),
request.getTranscriptText()
);
DecisionSuggestionResponse response = DecisionSuggestionResponse.builder()
.suggestions(suggestions.stream()
.map(s -> DecisionSuggestionDto.builder()
.id(s.getId())
.content(s.getContent())
.category(s.getCategory())
.decisionMaker("") // TODO: Extract from suggestion
.participants(s.getParticipants())
.confidence(s.getConfidence())
.extractedFrom(s.getExtractedFrom())
.context(s.getContext())
.build())
.collect(Collectors.toList()))
.totalCount(suggestions.size())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 실시간 AI 제안사항 스트리밍 (SSE)
* 회의 진행 중 실시간으로 AI가 분석한 제안사항을 Server-Sent Events로 스트리밍
*
* @param meetingId 회의 ID
* @return Flux<ServerSentEvent<RealtimeSuggestionsDto>> AI 제안사항 스트림
*/
@GetMapping(value = "/meetings/{meetingId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(
summary = "실시간 AI 제안사항 스트리밍",
description = "회의 진행 중 실시간으로 AI가 분석한 제안사항을 Server-Sent Events(SSE)로 스트리밍합니다. " +
"클라이언트는 EventSource API를 사용하여 연결할 수 있습니다."
)
public Flux<ServerSentEvent<com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto>> streamRealtimeSuggestions(
@Parameter(description = "회의 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440000")
@PathVariable String meetingId) {
log.info("실시간 AI 제안사항 스트리밍 요청 - meetingId: {}", meetingId);
return suggestionUseCase.streamRealtimeSuggestions(meetingId)
.map(suggestions -> ServerSentEvent.<com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto>builder()
.id(suggestions.hashCode() + "")
.event("ai-suggestion")
.data(suggestions)
.build())
.doOnComplete(() -> log.info("AI 제안사항 스트리밍 완료 - meetingId: {}", meetingId))
.doOnError(error -> log.error("AI 제안사항 스트리밍 오류 - meetingId: {}", meetingId, error));
}
}
@@ -0,0 +1,83 @@
package com.unicorn.hgzero.ai.infra.controller;
import com.unicorn.hgzero.ai.biz.domain.Term;
import com.unicorn.hgzero.ai.biz.usecase.TermDetectionUseCase;
import com.unicorn.hgzero.ai.infra.dto.request.TermDetectionRequest;
import com.unicorn.hgzero.ai.infra.dto.response.TermDetectionResponse;
import com.unicorn.hgzero.ai.infra.dto.common.DetectedTermDto;
import com.unicorn.hgzero.ai.infra.dto.common.HighlightInfoDto;
import com.unicorn.hgzero.ai.infra.dto.common.TextPositionDto;
import com.unicorn.hgzero.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* 전문용어 감지 Controller
* POST /api/terms/detect
*/
@RestController
@RequestMapping("/api/ai/terms")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Term", description = "전문용어 감지 및 설명 API")
public class TermController {
private final TermDetectionUseCase termDetectionUseCase;
@PostMapping("/detect")
@Operation(summary = "전문용어 감지", description = "회의록 텍스트에서 전문용어를 자동으로 감지합니다")
public ResponseEntity<ApiResponse<TermDetectionResponse>> detectTerms(
@Valid @RequestBody TermDetectionRequest request) {
log.info("전문용어 감지 요청 - meetingId: {}, organizationId: {}",
request.getMeetingId(), request.getOrganizationId());
List<Term> terms = termDetectionUseCase.detectTerms(
request.getMeetingId(),
request.getText(),
request.getOrganizationId()
);
List<DetectedTermDto> detectedTerms = terms.stream()
.map(t -> DetectedTermDto.builder()
.term(t.getTerm())
.position(t.getPosition() != null ? TextPositionDto.builder()
.line(t.getPosition().getLine())
.offset(t.getPosition().getOffset())
.build() : null)
.confidence(t.getConfidence())
.category(t.getCategory())
.definition(t.getDefinition())
.context(t.getContext())
.relatedMeetingId(t.getRelatedMeetingId())
.highlight(t.getHighlight())
.build())
.collect(Collectors.toList());
List<HighlightInfoDto> highlightInfo = detectedTerms.stream()
.filter(t -> Boolean.TRUE.equals(t.getHighlight()))
.map(t -> HighlightInfoDto.builder()
.term(t.getTerm())
.position(t.getPosition())
.style("background-color: yellow")
.tooltip("용어 설명 로딩 중...")
.build())
.collect(Collectors.toList());
TermDetectionResponse response = TermDetectionResponse.builder()
.detectedTerms(detectedTerms)
.totalCount(detectedTerms.size())
.highlightInfo(highlightInfo)
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,65 @@
package com.unicorn.hgzero.ai.infra.controller;
import com.unicorn.hgzero.ai.biz.domain.ExtractedTodo;
import com.unicorn.hgzero.ai.biz.usecase.TodoExtractionUseCase;
import com.unicorn.hgzero.ai.infra.dto.request.TodoExtractionRequest;
import com.unicorn.hgzero.ai.infra.dto.response.TodoExtractionResponse;
import com.unicorn.hgzero.ai.infra.dto.common.ExtractedTodoDto;
import com.unicorn.hgzero.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* Todo 자동 추출 Controller
* POST /api/todos/extract
*/
@RestController
@RequestMapping("/api/ai/todos")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Todo", description = "Todo 자동 추출 API")
public class TodoController {
private final TodoExtractionUseCase todoExtractionUseCase;
@PostMapping("/extract")
@Operation(summary = "Todo 자동 추출", description = "회의록에서 액션 아이템을 자동으로 추출하고 담당자를 식별합니다")
public ResponseEntity<ApiResponse<TodoExtractionResponse>> extractTodos(
@RequestHeader("X-User-Id") String userId,
@Valid @RequestBody TodoExtractionRequest request) {
log.info("Todo 추출 요청 - meetingId: {}, userId: {}", request.getMeetingId(), userId);
List<ExtractedTodo> todos = todoExtractionUseCase.extractTodos(
request.getMeetingId(),
request.getMinutesContent(),
request.getUserId() != null ? request.getUserId() : userId
);
TodoExtractionResponse response = TodoExtractionResponse.builder()
.meetingId(request.getMeetingId())
.todos(todos.stream()
.map(t -> ExtractedTodoDto.builder()
.content(t.getContent())
.assignee(t.getAssignee())
.dueDate(t.getDueDate())
.priority(t.getPriority())
.sectionReference(t.getSectionReference())
.build())
.collect(Collectors.toList()))
.totalCount(todos.size())
.extractedAt(LocalDateTime.now())
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,86 @@
package com.unicorn.hgzero.ai.infra.controller;
import com.unicorn.hgzero.ai.biz.domain.ProcessedTranscript;
import com.unicorn.hgzero.ai.biz.usecase.TranscriptProcessUseCase;
import com.unicorn.hgzero.ai.infra.dto.request.TranscriptProcessRequest;
import com.unicorn.hgzero.ai.infra.dto.response.TranscriptProcessResponse;
import com.unicorn.hgzero.ai.infra.dto.common.*;
import com.unicorn.hgzero.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.stream.Collectors;
/**
* 회의록 자동 작성 Controller
* POST /api/transcripts/process
*/
@RestController
@RequestMapping("/api/ai/transcripts")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Transcript", description = "회의록 자동 작성 API")
public class TranscriptController {
private final TranscriptProcessUseCase transcriptProcessUseCase;
@PostMapping("/process")
@Operation(summary = "회의록 자동 작성", description = "STT에서 변환된 텍스트를 받아 LLM 기반으로 회의록을 자동 작성합니다")
public ResponseEntity<ApiResponse<TranscriptProcessResponse>> processTranscript(
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Name") String userName,
@Valid @RequestBody TranscriptProcessRequest request) {
log.info("회의록 자동 작성 요청 - meetingId: {}, userId: {}", request.getMeetingId(), userId);
ProcessedTranscript result = transcriptProcessUseCase.processTranscript(
request.getMeetingId(),
request.getTranscriptText(),
request.getUserId() != null ? request.getUserId() : userId,
request.getUserName() != null ? request.getUserName() : userName,
request.getContext() != null ? request.getContext().getTitle() : "",
request.getContext() != null ? request.getContext().getParticipants() : null,
request.getContext() != null ? request.getContext().getAgenda() : null
);
TranscriptProcessResponse response = mapToResponse(result);
return ResponseEntity.ok(ApiResponse.success(response));
}
private TranscriptProcessResponse mapToResponse(ProcessedTranscript domain) {
return TranscriptProcessResponse.builder()
.transcriptId(domain.getTranscriptId())
.meetingId(domain.getMeetingId())
.content(mapContent(domain))
.suggestions(null) // TODO: 실시간 제안 기능 구현 시 추가
.createdAt(domain.getCreatedAt())
.status(domain.getStatus())
.build();
}
private TranscriptContentDto mapContent(ProcessedTranscript domain) {
return TranscriptContentDto.builder()
.summary(domain.getSummary())
.discussions(domain.getDiscussions().stream()
.map(d -> DiscussionItemDto.builder()
.topic(d.getTopic())
.speaker(d.getSpeaker())
.content(d.getContent())
.build())
.collect(Collectors.toList()))
.decisions(domain.getDecisions().stream()
.map(d -> DecisionItemDto.builder()
.content(d.getContent())
.decisionMaker(d.getDecisionMaker())
.category(d.getCategory())
.build())
.collect(Collectors.toList()))
.pendingItems(domain.getPendingItems())
.build();
}
}
@@ -0,0 +1,32 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 결정사항 아이템 DTO
* 결정 내용, 결정자, 카테고리 포함
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DecisionItemDto {
/**
* 결정 내용
*/
private String content;
/**
* 결정자
*/
private String decisionMaker;
/**
* 결정 카테고리 (기술, 일정, 리소스, 정책, 기타)
*/
private String category;
}
@@ -0,0 +1,59 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 결정사항 제안 DTO
* AI가 감지한 결정사항 패턴 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DecisionSuggestionDto {
/**
* 제안 ID
*/
private String id;
/**
* 결정 내용
*/
private String content;
/**
* 결정 카테고리 (기술, 일정, 리소스, 정책, 기타)
*/
private String category;
/**
* 결정자
*/
private String decisionMaker;
/**
* 참여자 목록
*/
private List<String> participants;
/**
* 신뢰도 점수 (0-1)
*/
private Double confidence;
/**
* 원문 발췌
*/
private String extractedFrom;
/**
* 결정 배경
*/
private String context;
}
@@ -0,0 +1,58 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 감지된 전문용어 DTO
* 회의록에서 감지된 전문용어 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DetectedTermDto {
/**
* 용어명
*/
private String term;
/**
* 텍스트 위치 정보
*/
private TextPositionDto position;
/**
* 신뢰도 점수 (0-1)
*/
private Double confidence;
/**
* 용어 카테고리 (기술, 업무, 도메인, 기획, 비즈니스, 전략, 마케팅)
*/
private String category;
/**
* 용어 정의 (간단한 설명)
*/
private String definition;
/**
* 용어가 사용된 맥락 (과거 회의록 참조)
* 예: "신제품 기획 회의(2024-09-15)에서 언급"
*/
private String context;
/**
* 관련 회의 ID (용어가 논의된 과거 회의)
*/
private String relatedMeetingId;
/**
* 하이라이트 여부
*/
private Boolean highlight;
}
@@ -0,0 +1,32 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 논의사항 아이템 DTO
* 논의 주제, 발언자, 논의 내용 포함
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiscussionItemDto {
/**
* 논의 주제
*/
private String topic;
/**
* 발언자
*/
private String speaker;
/**
* 논의 내용
*/
private String content;
}
@@ -0,0 +1,47 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 논의사항 제안 DTO
* AI가 추천하는 논의 주제 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiscussionSuggestionDto {
/**
* 제안 ID
*/
private String id;
/**
* 논의 주제
*/
private String topic;
/**
* 제안 이유
*/
private String reason;
/**
* 우선순위 (HIGH, MEDIUM, LOW)
*/
private String priority;
/**
* 관련 안건
*/
private String relatedAgenda;
/**
* 예상 소요 시간 (분)
*/
private Integer estimatedTime;
}
@@ -0,0 +1,34 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 에러 응답 DTO
* API 에러 발생 시 통일된 형식으로 에러 정보 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponseDto {
/**
* 에러 코드
*/
private String error;
/**
* 에러 메시지
*/
private String message;
/**
* 에러 발생 시각
*/
private LocalDateTime timestamp;
}
@@ -0,0 +1,44 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 추출된 Todo DTO
* AI가 회의록에서 추출한 Todo 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExtractedTodoDto {
/**
* Todo 내용
*/
private String content;
/**
* 담당자
*/
private String assignee;
/**
* 마감일
*/
private LocalDate dueDate;
/**
* 우선순위 (HIGH, MEDIUM, LOW)
*/
private String priority;
/**
* 관련 회의록 섹션
*/
private String sectionReference;
}
@@ -0,0 +1,37 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 하이라이트 정보 DTO
* 용어 하이라이트 스타일과 툴팁 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HighlightInfoDto {
/**
* 용어명
*/
private String term;
/**
* 텍스트 위치 정보
*/
private TextPositionDto position;
/**
* 하이라이트 스타일
*/
private String style;
/**
* 툴팁 텍스트
*/
private String tooltip;
}
@@ -0,0 +1,39 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 회의 맥락 정보 DTO
* 회의 제목, 참석자, 안건, 이전 회의록 등의 맥락 정보를 전달
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MeetingContextDto {
/**
* 회의 제목
*/
private String title;
/**
* 참석자 목록
*/
private List<String> participants;
/**
* 회의 안건 목록
*/
private List<String> agenda;
/**
* 이전 회의록 내용
*/
private String previousContent;
}
@@ -0,0 +1,40 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* 과거 논의 DTO
* 전문용어 관련 과거 논의 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PastDiscussionDto {
/**
* 논의 날짜
*/
private LocalDate date;
/**
* 참석자 목록
*/
private List<String> participants;
/**
* 논의 요약
*/
private String summary;
/**
* 회의록 링크
*/
private String link;
}
@@ -0,0 +1,24 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 실시간 추천사항 DTO (간소화 버전)
* 논의사항과 결정사항을 구분하지 않고 통합 제공
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RealtimeSuggestionsDto {
/**
* AI 제안사항 목록 (논의사항 + 결정사항 통합)
*/
private List<SimpleSuggestionDto> suggestions;
}
@@ -0,0 +1,32 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 참조 문서 DTO
* 전문용어 관련 참조 문서 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReferenceDto {
/**
* 문서 제목
*/
private String title;
/**
* 문서 유형 (위키, 매뉴얼, 회의록, 보고서)
*/
private String type;
/**
* 문서 URL
*/
private String link;
}
@@ -0,0 +1,27 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 관련 프로젝트 DTO
* 전문용어와 관련된 프로젝트 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RelatedProjectDto {
/**
* 프로젝트명
*/
private String name;
/**
* 연관성 설명
*/
private String relevance;
}
@@ -0,0 +1,60 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* 관련 회의록 DTO
* RAG 검색으로 찾은 관련 회의록 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RelatedTranscriptDto {
/**
* 회의록 ID
*/
private String transcriptId;
/**
* 회의 제목
*/
private String title;
/**
* 회의 날짜
*/
private LocalDate date;
/**
* 참석자 목록
*/
private List<String> participants;
/**
* 관련도 점수 (0-100%)
*/
private Double relevanceScore;
/**
* 공통 키워드 목록
*/
private List<String> commonKeywords;
/**
* 회의록 핵심 내용 요약 (1-2문장)
*/
private String summary;
/**
* 회의록 링크
*/
private String link;
}
@@ -0,0 +1,37 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 간소화된 AI 제안사항 DTO
* 논의사항과 결정사항을 구분하지 않고 통합 제공
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SimpleSuggestionDto {
/**
* 제안 ID
*/
private String id;
/**
* 제안 내용 (논의사항 또는 결정사항)
*/
private String content;
/**
* 타임스탬프 (초 단위, 예: 00:05:23)
*/
private String timestamp;
/**
* 신뢰도 점수 (0-1)
*/
private Double confidence;
}
@@ -0,0 +1,27 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 텍스트 위치 정보 DTO
* 줄 번호와 오프셋 정보를 포함
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TextPositionDto {
/**
* 줄 번호
*/
private Integer line;
/**
* 시작 오프셋
*/
private Integer offset;
}
@@ -0,0 +1,39 @@
package com.unicorn.hgzero.ai.infra.dto.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 회의록 내용 DTO
* 전체 요약, 논의사항, 결정사항, 보류사항 포함
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TranscriptContentDto {
/**
* 전체 요약
*/
private String summary;
/**
* 논의사항 목록
*/
private List<DiscussionItemDto> discussions;
/**
* 결정사항 목록
*/
private List<DecisionItemDto> decisions;
/**
* 보류사항 목록
*/
private List<String> pendingItems;
}
@@ -0,0 +1,31 @@
package com.unicorn.hgzero.ai.infra.dto.request;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 결정사항 제안 요청 DTO
* 회의록 텍스트에서 결정사항 패턴을 감지하여 제안 요청
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DecisionSuggestionRequest {
/**
* 회의 ID (필수)
*/
@NotBlank(message = "회의 ID는 필수입니다")
private String meetingId;
/**
* 현재까지의 회의록 텍스트 (필수)
*/
@NotBlank(message = "회의록 텍스트는 필수입니다")
private String transcriptText;
}
@@ -0,0 +1,31 @@
package com.unicorn.hgzero.ai.infra.dto.request;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 논의사항 제안 요청 DTO
* 현재 회의 진행 상황을 분석하여 추가 논의 주제 제안 요청
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiscussionSuggestionRequest {
/**
* 회의 ID (필수)
*/
@NotBlank(message = "회의 ID는 필수입니다")
private String meetingId;
/**
* 현재까지의 회의록 텍스트 (필수)
*/
@NotBlank(message = "회의록 텍스트는 필수입니다")
private String transcriptText;
}
@@ -0,0 +1,30 @@
package com.unicorn.hgzero.ai.infra.dto.request;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 섹션 AI 요약 재생성 요청 DTO
* 사용자가 작성한 섹션 내용을 기반으로 AI 요약 재생성 요청
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SectionSummaryRequest {
/**
* 사용자가 작성/수정한 섹션 내용 (필수, Markdown 형식)
*/
@NotBlank(message = "섹션 내용은 필수입니다")
private String sectionContent;
/**
* 회의 ID (맥락 이해용, 선택적)
*/
private String meetingId;
}
@@ -0,0 +1,36 @@
package com.unicorn.hgzero.ai.infra.dto.request;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 전문용어 감지 요청 DTO
* 회의록 텍스트에서 전문용어를 자동으로 감지 요청
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TermDetectionRequest {
/**
* 회의 ID (필수)
*/
@NotBlank(message = "회의 ID는 필수입니다")
private String meetingId;
/**
* 분석할 회의록 텍스트 (필수)
*/
@NotBlank(message = "분석할 텍스트는 필수입니다")
private String text;
/**
* 조직 ID
*/
private String organizationId;
}
@@ -0,0 +1,36 @@
package com.unicorn.hgzero.ai.infra.dto.request;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* Todo 자동 추출 요청 DTO
* 회의록에서 액션 아이템을 자동으로 추출하고 담당자 식별 요청
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TodoExtractionRequest {
/**
* 회의 ID (필수)
*/
@NotBlank(message = "회의 ID는 필수입니다")
private String meetingId;
/**
* 요청자 ID
*/
private String userId;
/**
* 회의록 전체 내용 (필수, Markdown 형식)
*/
@NotBlank(message = "회의록 내용은 필수입니다")
private String minutesContent;
}
@@ -0,0 +1,48 @@
package com.unicorn.hgzero.ai.infra.dto.request;
import com.unicorn.hgzero.ai.infra.dto.common.MeetingContextDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 회의록 자동 작성 요청 DTO
* STT에서 변환된 텍스트를 받아 LLM 기반 회의록 자동 작성 요청
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TranscriptProcessRequest {
/**
* 회의 ID (필수)
*/
@NotBlank(message = "회의 ID는 필수입니다")
private String meetingId;
/**
* STT에서 변환된 텍스트 (필수)
*/
@NotBlank(message = "회의록 텍스트는 필수입니다")
private String transcriptText;
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String userName;
/**
* 회의 맥락 정보
*/
private MeetingContextDto context;
}
@@ -0,0 +1,36 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import com.unicorn.hgzero.ai.infra.dto.common.DecisionSuggestionDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 결정사항 제안 응답 DTO
* AI가 감지한 결정사항 패턴 목록 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DecisionSuggestionResponse {
/**
* 결정사항 제안 목록
*/
private List<DecisionSuggestionDto> suggestions;
/**
* 제안 개수
*/
private Integer totalCount;
/**
* 생성 시각
*/
private LocalDateTime timestamp;
}
@@ -0,0 +1,36 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import com.unicorn.hgzero.ai.infra.dto.common.DiscussionSuggestionDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 논의사항 제안 응답 DTO
* AI가 제안하는 추가 논의 주제 목록 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiscussionSuggestionResponse {
/**
* 논의사항 제안 목록
*/
private List<DiscussionSuggestionDto> suggestions;
/**
* 제안 개수
*/
private Integer totalCount;
/**
* 생성 시각
*/
private LocalDateTime timestamp;
}
@@ -0,0 +1,30 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import com.unicorn.hgzero.ai.infra.dto.common.RelatedTranscriptDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 관련 회의록 조회 응답 DTO
* RAG 검색으로 찾은 관련 회의록 목록 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RelatedTranscriptsResponse {
/**
* 관련 회의록 목록
*/
private List<RelatedTranscriptDto> relatedTranscripts;
/**
* 관련 회의록 개수
*/
private Integer totalCount;
}
@@ -0,0 +1,29 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 섹션 요약 응답 DTO
* AI가 생성한 섹션 요약 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SectionSummaryResponse {
/**
* 생성된 AI 요약 (2-3문장)
*/
private String summary;
/**
* 생성 시간
*/
private LocalDateTime generatedAt;
}
@@ -0,0 +1,36 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import com.unicorn.hgzero.ai.infra.dto.common.DetectedTermDto;
import com.unicorn.hgzero.ai.infra.dto.common.HighlightInfoDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 전문용어 감지 응답 DTO
* 감지된 전문용어 목록과 하이라이트 정보 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TermDetectionResponse {
/**
* 감지된 용어 목록
*/
private List<DetectedTermDto> detectedTerms;
/**
* 감지된 용어 개수
*/
private Integer totalCount;
/**
* 하이라이트 정보 목록
*/
private List<HighlightInfoDto> highlightInfo;
}
@@ -0,0 +1,57 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import com.unicorn.hgzero.ai.infra.dto.common.PastDiscussionDto;
import com.unicorn.hgzero.ai.infra.dto.common.ReferenceDto;
import com.unicorn.hgzero.ai.infra.dto.common.RelatedProjectDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 전문용어 설명 응답 DTO
* RAG 기반 맥락적 용어 설명 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TermExplanationResponse {
/**
* 용어명
*/
private String term;
/**
* 간단한 정의
*/
private String basicDefinition;
/**
* 현재 회의 맥락에서의 의미
*/
private String contextualMeaning;
/**
* 실제 사용 사례 목록
*/
private List<String> useCases;
/**
* 관련 프로젝트 목록
*/
private List<RelatedProjectDto> relatedProjects;
/**
* 과거 논의 목록
*/
private List<PastDiscussionDto> pastDiscussions;
/**
* 참조 문서 목록
*/
private List<ReferenceDto> references;
}
@@ -0,0 +1,41 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import com.unicorn.hgzero.ai.infra.dto.common.ExtractedTodoDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* Todo 추출 응답 DTO
* AI가 추출한 Todo 목록 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TodoExtractionResponse {
/**
* 회의 ID
*/
private String meetingId;
/**
* 추출된 Todo 목록
*/
private List<ExtractedTodoDto> todos;
/**
* 추출된 Todo 개수
*/
private Integer totalCount;
/**
* 추출 시간
*/
private LocalDateTime extractedAt;
}
@@ -0,0 +1,51 @@
package com.unicorn.hgzero.ai.infra.dto.response;
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
import com.unicorn.hgzero.ai.infra.dto.common.TranscriptContentDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회의록 자동 작성 응답 DTO
* LLM 기반으로 생성된 회의록 정보 반환
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TranscriptProcessResponse {
/**
* 생성된 회의록 ID
*/
private String transcriptId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 회의록 내용
*/
private TranscriptContentDto content;
/**
* 실시간 추천사항
*/
private RealtimeSuggestionsDto suggestions;
/**
* 생성 시간
*/
private LocalDateTime createdAt;
/**
* 회의록 상태 (DRAFT, COMPLETED)
*/
private String status;
}
@@ -0,0 +1,80 @@
package com.unicorn.hgzero.ai.infra.gateway;
import com.unicorn.hgzero.ai.biz.domain.ProcessedTranscript;
import com.unicorn.hgzero.ai.biz.gateway.TranscriptGateway;
import com.unicorn.hgzero.ai.infra.gateway.entity.ProcessedTranscriptEntity;
import com.unicorn.hgzero.ai.infra.gateway.repository.ProcessedTranscriptJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 회의록 Gateway 구현체
* JPA Repository를 사용한 회의록 영속성 관리
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TranscriptGatewayImpl implements TranscriptGateway {
private final ProcessedTranscriptJpaRepository repository;
@Override
public ProcessedTranscript save(ProcessedTranscript transcript) {
log.debug("Saving transcript: transcriptId={}, meetingId={}",
transcript.getTranscriptId(), transcript.getMeetingId());
ProcessedTranscriptEntity entity = ProcessedTranscriptEntity.fromDomain(transcript);
ProcessedTranscriptEntity saved = repository.save(entity);
log.info("Transcript saved successfully: transcriptId={}", saved.getTranscriptId());
return saved.toDomain();
}
@Override
public Optional<ProcessedTranscript> findById(String transcriptId) {
log.debug("Finding transcript by id: {}", transcriptId);
return repository.findById(transcriptId)
.map(ProcessedTranscriptEntity::toDomain);
}
@Override
public Optional<ProcessedTranscript> findByMeetingId(String meetingId) {
log.debug("Finding transcript by meetingId: {}", meetingId);
return repository.findByMeetingId(meetingId)
.map(ProcessedTranscriptEntity::toDomain);
}
@Override
public List<ProcessedTranscript> findByMeetingIds(List<String> meetingIds) {
log.debug("Finding transcripts by meetingIds: count={}", meetingIds.size());
return repository.findByMeetingIdIn(meetingIds).stream()
.map(ProcessedTranscriptEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public List<ProcessedTranscript> findByStatus(String status) {
log.debug("Finding transcripts by status: {}", status);
return repository.findByStatus(status).stream()
.map(ProcessedTranscriptEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public boolean existsByMeetingId(String meetingId) {
log.debug("Checking transcript existence by meetingId: {}", meetingId);
return repository.existsByMeetingId(meetingId);
}
@Override
public void delete(String transcriptId) {
log.debug("Deleting transcript: {}", transcriptId);
repository.deleteById(transcriptId);
log.info("Transcript deleted successfully: {}", transcriptId);
}
}
@@ -0,0 +1,179 @@
package com.unicorn.hgzero.ai.infra.gateway.entity;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.ai.biz.domain.ProcessedTranscript;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 처리된 회의록 Entity
* AI가 처리한 회의록 정보를 데이터베이스에 영속화
*/
@Slf4j
@Entity
@Table(name = "processed_transcripts")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProcessedTranscriptEntity extends BaseTimeEntity {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Id
@Column(name = "transcript_id", length = 50)
private String transcriptId;
@Column(name = "meeting_id", length = 50, nullable = false)
private String meetingId;
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
/**
* 논의사항 목록 (JSON 형식)
*/
@Column(name = "discussions", columnDefinition = "TEXT")
private String discussions;
/**
* 결정사항 목록 (JSON 형식)
*/
@Column(name = "decisions", columnDefinition = "TEXT")
private String decisions;
/**
* 보류사항 목록 (콤마 구분)
*/
@Column(name = "pending_items", columnDefinition = "TEXT")
private String pendingItems;
@Column(name = "status", length = 20, nullable = false)
@Builder.Default
private String status = "DRAFT";
/**
* Entity를 Domain 모델로 변환
*/
public ProcessedTranscript toDomain() {
return ProcessedTranscript.builder()
.transcriptId(this.transcriptId)
.meetingId(this.meetingId)
.summary(this.summary)
.discussions(parseDiscussions(this.discussions))
.decisions(parseDecisions(this.decisions))
.pendingItems(parsePendingItems(this.pendingItems))
.createdAt(this.getCreatedAt())
.status(this.status)
.build();
}
/**
* Domain 모델에서 Entity로 변환
*/
public static ProcessedTranscriptEntity fromDomain(ProcessedTranscript domain) {
return ProcessedTranscriptEntity.builder()
.transcriptId(domain.getTranscriptId())
.meetingId(domain.getMeetingId())
.summary(domain.getSummary())
.discussions(formatDiscussions(domain.getDiscussions()))
.decisions(formatDecisions(domain.getDecisions()))
.pendingItems(formatPendingItems(domain.getPendingItems()))
.status(domain.getStatus())
.build();
}
/**
* 상태 업데이트
*/
public void updateStatus(String status) {
this.status = status;
}
/**
* 요약 업데이트
*/
public void updateSummary(String summary) {
this.summary = summary;
}
// ========================================
// Private Helper Methods - JSON 변환
// ========================================
private static List<ProcessedTranscript.DiscussionItem> parseDiscussions(String json) {
if (json == null || json.isEmpty()) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json,
new TypeReference<List<ProcessedTranscript.DiscussionItem>>() {});
} catch (JsonProcessingException e) {
log.error("Failed to parse discussions JSON: {}", json, e);
return new ArrayList<>();
}
}
private static String formatDiscussions(List<ProcessedTranscript.DiscussionItem> discussions) {
if (discussions == null || discussions.isEmpty()) {
return "";
}
try {
return objectMapper.writeValueAsString(discussions);
} catch (JsonProcessingException e) {
log.error("Failed to format discussions to JSON", e);
return "";
}
}
private static List<ProcessedTranscript.DecisionItem> parseDecisions(String json) {
if (json == null || json.isEmpty()) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json,
new TypeReference<List<ProcessedTranscript.DecisionItem>>() {});
} catch (JsonProcessingException e) {
log.error("Failed to parse decisions JSON: {}", json, e);
return new ArrayList<>();
}
}
private static String formatDecisions(List<ProcessedTranscript.DecisionItem> decisions) {
if (decisions == null || decisions.isEmpty()) {
return "";
}
try {
return objectMapper.writeValueAsString(decisions);
} catch (JsonProcessingException e) {
log.error("Failed to format decisions to JSON", e);
return "";
}
}
private static List<String> parsePendingItems(String items) {
if (items == null || items.isEmpty()) {
return new ArrayList<>();
}
return Arrays.asList(items.split(","));
}
private static String formatPendingItems(List<String> items) {
if (items == null || items.isEmpty()) {
return "";
}
return String.join(",", items);
}
}
@@ -0,0 +1,41 @@
package com.unicorn.hgzero.ai.infra.gateway.repository;
import com.unicorn.hgzero.ai.infra.gateway.entity.ProcessedTranscriptEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 처리된 회의록 JPA Repository
* 회의록 데이터 영속성 관리
*/
@Repository
public interface ProcessedTranscriptJpaRepository extends JpaRepository<ProcessedTranscriptEntity, String> {
/**
* 회의 ID로 회의록 조회
*/
Optional<ProcessedTranscriptEntity> findByMeetingId(String meetingId);
/**
* 회의 ID 목록으로 회의록 목록 조회
*/
List<ProcessedTranscriptEntity> findByMeetingIdIn(List<String> meetingIds);
/**
* 상태로 회의록 목록 조회
*/
List<ProcessedTranscriptEntity> findByStatus(String status);
/**
* 회의 ID와 상태로 회의록 조회
*/
Optional<ProcessedTranscriptEntity> findByMeetingIdAndStatus(String meetingId, String status);
/**
* 회의 ID로 회의록 존재 여부 확인
*/
boolean existsByMeetingId(String meetingId);
}
@@ -0,0 +1,146 @@
package com.unicorn.hgzero.ai.infra.llm;
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* OpenAI LLM Gateway 구현체
* OpenAI API를 사용한 LLM 연동
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenAiLlmGateway implements LlmGateway {
// TODO: OpenAI API 클라이언트 주입
// private final OpenAiClient openAiClient;
@Override
public String generateTranscript(String transcriptText, String title, List<String> participants, List<String> agenda) {
log.info("Generating transcript using OpenAI: title={}", title);
// TODO: OpenAI API 호출
// 1. 프롬프트 구성 (회의록 자동 작성 프롬프트)
// 2. GPT-4 호출
// 3. 응답 JSON 파싱
// 4. 반환
// 임시 mock 응답
return """
{
"summary": "회의록 자동 생성 요약",
"discussions": [
{
"topic": "프로젝트 진행 상황",
"speaker": "김철수",
"content": "현재 80% 진행 중"
}
],
"decisions": [
{
"content": "React로 프론트엔드 개발",
"decisionMaker": "이영희",
"category": "기술"
}
],
"pendingItems": ["추가 예산 검토", "외주 업체 선정"]
}
""";
}
@Override
public String extractTodos(String minutesContent) {
log.info("Extracting todos using OpenAI");
// TODO: OpenAI API 호출 (Todo 추출 프롬프트)
return """
{
"todos": [
{
"content": "API 설계서 작성",
"assignee": "박민수",
"dueDate": "2025-01-30",
"priority": "HIGH",
"sectionReference": "결정사항 #3"
}
]
}
""";
}
@Override
public String generateSummary(String sectionContent, String meetingContext) {
log.info("Generating section summary using OpenAI");
// TODO: OpenAI API 호출 (섹션 요약 프롬프트)
return "AI 기반 회의록 자동화 서비스로 결정. 타겟은 중소기업 및 스타트업이며, 주요 기능은 음성인식, AI 요약, Todo 추출입니다.";
}
@Override
public String detectTerms(String text, String organizationId) {
log.info("Detecting terms using OpenAI: organizationId={}", organizationId);
// TODO: OpenAI API 호출 (전문용어 감지 프롬프트)
return """
{
"terms": [
{
"term": "MSA",
"position": {"line": 5, "offset": 42},
"confidence": 0.92,
"category": "기술",
"highlight": true
}
]
}
""";
}
@Override
public String suggestDiscussions(String transcriptText, List<String> agenda) {
log.info("Suggesting discussions using OpenAI");
// TODO: OpenAI API 호출 (논의사항 제안 프롬프트)
return """
{
"suggestions": [
{
"id": "sugg-001",
"topic": "보안 요구사항 검토",
"reason": "안건에 포함되어 있으나 아직 논의되지 않음",
"priority": "HIGH",
"relatedAgenda": "프로젝트 개요",
"estimatedTime": 15
}
]
}
""";
}
@Override
public String suggestDecisions(String transcriptText) {
log.info("Suggesting decisions using OpenAI");
// TODO: OpenAI API 호출 (결정사항 제안 프롬프트)
return """
{
"suggestions": [
{
"id": "dec-001",
"content": "React로 프론트엔드 개발",
"category": "기술",
"decisionMaker": "김철수",
"participants": ["김철수", "이영희"],
"confidence": 0.85,
"extractedFrom": "프론트엔드는 React로 개발하기로 했습니다",
"context": "팀원 대부분이 React 경험이 있어 개발 속도가 빠를 것으로 예상"
}
]
}
""";
}
}
@@ -0,0 +1,100 @@
package com.unicorn.hgzero.ai.infra.search;
import com.unicorn.hgzero.ai.biz.gateway.SearchGateway;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* Azure AI Search Gateway 구현체
* RAG 기반 벡터 검색 기능 제공
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AzureAiSearchGateway implements SearchGateway {
// TODO: Azure AI Search 클라이언트 주입
// private final SearchClient searchClient;
@Override
public String searchRelatedTranscripts(String meetingId, String transcriptId, int limit) {
log.info("Searching related transcripts: meetingId={}, transcriptId={}, limit={}",
meetingId, transcriptId, limit);
// TODO: Azure AI Search 벡터 검색
// 1. 회의록 내용으로 임베딩 생성
// 2. 벡터 유사도 검색
// 3. 상위 N개 결과 반환
// 임시 mock 응답
return """
{
"relatedTranscripts": [
{
"transcriptId": "aa0e8400-e29b-41d4-a716-446655440005",
"title": "프로젝트 X 주간 회의",
"date": "2025-01-15",
"participants": ["김철수", "이영희"],
"relevanceScore": 85.5,
"commonKeywords": ["MSA", "API Gateway", "Spring Boot"],
"link": "/transcripts/aa0e8400-e29b-41d4-a716-446655440005"
}
]
}
""";
}
@Override
public String searchTermExplanation(String term, String meetingId, String context) {
log.info("Searching term explanation: term={}, meetingId={}", term, meetingId);
// TODO: Azure AI Search 문서 검색
// 1. 용어와 맥락으로 검색 쿼리 구성
// 2. 과거 회의록, 위키, 매뉴얼 검색
// 3. 관련 문서 반환
// 임시 mock 응답
return """
{
"term": "MSA",
"basicDefinition": "Microservices Architecture의 약자",
"contextualMeaning": "이번 프로젝트에서는 확장성과 독립 배포를 위해 MSA를 적용하기로 결정",
"useCases": [
"2024년 프로젝트 X에서 주문/결제/배송 서비스를 독립적으로 구성",
"서비스별 독립 배포로 배포 시간 70% 단축"
],
"relatedProjects": [
{"name": "프로젝트 X", "relevance": "동일한 MSA 아키텍처 적용"}
],
"pastDiscussions": [
{
"date": "2024-12-15",
"participants": ["김철수", "이영희"],
"summary": "MSA 아키텍처의 장단점을 비교하고 적용 방안을 논의",
"link": "/transcripts/bb0e8400-e29b-41d4-a716-446655440006"
}
],
"references": [
{
"title": "MSA 아키텍처 가이드",
"type": "위키",
"link": "https://wiki.example.com/msa-guide"
}
]
}
""";
}
@Override
public void indexTranscript(String transcriptId, String content, String metadata) {
log.info("Indexing transcript: transcriptId={}", transcriptId);
// TODO: Azure AI Search 인덱싱
// 1. 회의록 내용 임베딩 생성
// 2. 벡터와 메타데이터를 인덱스에 저장
// 3. 검색 가능 상태로 만들기
log.debug("Transcript indexed successfully: {}", transcriptId);
}
}
@@ -0,0 +1,158 @@
spring:
application:
name: ai
# Database Configuration
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:20.249.153.213}:${DB_PORT:5432}/${DB_NAME:aidb}
username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:Hi5Jessica!}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA Configuration
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Flyway Configuration
flyway:
enabled: false
# Redis Configuration
data:
redis:
host: ${REDIS_HOST:20.249.177.114}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Hi5Jessica!}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:4}
# Server Configuration
server:
port: ${SERVER_PORT:8083}
servlet:
context-path: ${CONTEXT_PATH:}
# JWT Configuration
jwt:
secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
# Azure OpenAI Configuration
azure:
openai:
api-key: ${AZURE_OPENAI_API_KEY:}
endpoint: ${AZURE_OPENAI_ENDPOINT:}
deployment-name: ${AZURE_OPENAI_DEPLOYMENT:gpt-4o}
embedding-deployment: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT:text-embedding-3-large}
max-tokens: ${AZURE_OPENAI_MAX_TOKENS:2000}
temperature: ${AZURE_OPENAI_TEMPERATURE:0.3}
# External AI API Configuration
external:
ai:
claude:
api-key: ${CLAUDE_API_KEY:}
base-url: ${CLAUDE_BASE_URL:https://api.anthropic.com}
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
max-tokens: ${CLAUDE_MAX_TOKENS:2000}
temperature: ${CLAUDE_TEMPERATURE:0.3}
openai:
api-key: ${OPENAI_API_KEY:}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
openweather:
api-key: ${OPENWEATHER_API_KEY:}
base-url: ${OPENWEATHER_BASE_URL:https://api.openweathermap.org}
kakao:
api-key: ${KAKAO_API_KEY:}
base-url: ${KAKAO_BASE_URL:https://dapi.kakao.com}
# Azure AI Search Configuration
ai-search:
endpoint: ${AZURE_AI_SEARCH_ENDPOINT:}
api-key: ${AZURE_AI_SEARCH_API_KEY:}
index-name: ${AZURE_AI_SEARCH_INDEX:meeting-transcripts}
# Azure Event Hubs Configuration
eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=}
namespace: ${AZURE_EVENTHUB_NAMESPACE:hgzero-eventhub-ns}
eventhub-name: ${AZURE_EVENTHUB_NAME:hgzero-eventhub-name}
checkpoint-storage-connection-string: ${AZURE_CHECKPOINT_STORAGE_CONNECTION_STRING:}
checkpoint-container: ${AZURE_CHECKPOINT_CONTAINER:hgzero-checkpoints}
consumer-group:
transcript: ${AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT:ai-transcript-group}
meeting: ${AZURE_EVENTHUB_CONSUMER_GROUP_MEETING:ai-meeting-group}
# Actuator Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: always
show-components: always
health:
livenessState:
enabled: true
readinessState:
enabled: true
# OpenAPI Documentation
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
# Logging Configuration
logging:
level:
root: ${LOG_LEVEL_ROOT:INFO}
com.unicorn.hgzero.ai: ${LOG_LEVEL_APP:DEBUG}
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: ${LOG_FILE:logs/ai-service.log}
logback:
rollingpolicy:
max-file-size: ${LOG_MAX_FILE_SIZE:10MB}
max-history: ${LOG_MAX_HISTORY:7}
total-size-cap: ${LOG_TOTAL_SIZE_CAP:100MB}