feat(meeting): audio upload + STT + AI minutes generation (G-06)

Meeting recording upload (<=20MB, multipart cap raised to 25MB),
configurable on-prem whisper STT (empty URL = degraded fallback),
Claude-routed minutes generation with action-item drafts, transcript
persisted via V52 for re-generation. Frontend meeting page wires the
record->transcribe->minutes flow (shared workAiApi client).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 06:26:30 +09:00
parent 3c8058546e
commit ef7400632e
17 changed files with 946 additions and 22 deletions

View File

@ -0,0 +1,5 @@
package com.zioinfo.kintex.work.meeting;
/** 회의 녹음 업로드 응답 — audioId(일회성 토큰)로 STT 를 호출한다. */
public record AudioUploadResponse(String audioId, String filename, long sizeBytes) {
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.kintex.work.meeting;
import java.util.List;
/**
* AI 회의록 자동작성 결과.
*
* @param summary 요약(문단)
* @param decisions 결정사항 목록
* @param actionItems 액션아이템 초안 목록(저장은 사용자 확인 )
* @param minutesText 요약+결정+액션을 합친 회의록 본문 초안(프론트 textarea 프리필용)
* @param degraded AI 미가용/실패로 폴백( 초안) 여부 true 근거 기반 산출이 아니다
* @param provider 실제 응답 provider("claude"|"ollama"|"none")
*/
public record GeneratedMinutes(String summary, List<String> decisions,
List<MinutesActionDraft> actionItems, String minutesText,
boolean degraded, String provider) {
}

View File

@ -0,0 +1,117 @@
package com.zioinfo.kintex.work.meeting;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import java.util.UUID;
/**
* 회의 음성 임시 저장소(UIWS meeting 패턴 이식). STT 시도 폐기(개인정보 최소화 DB BLOB 금지).
*
* <p>토큰(audioId) UUID(파일명 추측·경로순회 차단). 실제 경로는 서버에서만 해석하며 물리 파일명은
* 응답/로그에 노출하지 않는다. 저장 루트는 첨부 루트와 분리한다({@code {kintex.upload.dir}/meeting-tmp}).
*
* <p>검증: 파일 거부 크기 상한(20MB) 오디오 계열 확장자/콘텐츠타입 화이트리스트.
*/
@Service
public class MeetingAudioStore {
private static final Logger log = LoggerFactory.getLogger(MeetingAudioStore.class);
private static final String SUBDIR = "meeting-tmp";
/** 회의 녹음 상한(20MB) — 장시간 녹음은 분할 업로드 권장. */
private static final long MAX_AUDIO_BYTES = 20L * 1024 * 1024;
/** 오디오 계열 확장자 화이트리스트(실행형/문서 업로드 차단). */
private static final Set<String> ALLOWED_EXT =
Set.of("m4a", "mp3", "mp4", "wav", "webm", "ogg", "aac", "amr", "3gp", "caf", "flac");
private final Path root;
public MeetingAudioStore(@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
this.root = Paths.get(uploadDir).toAbsolutePath().normalize();
}
/** 멀티파트 오디오를 검증·임시 저장하고 audioId(UUID 토큰) 반환. */
public String store(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ApiException(ErrorCode.VALIDATION, "녹음 파일을 첨부해 주세요.");
}
if (file.getSize() > MAX_AUDIO_BYTES) {
throw new ApiException(ErrorCode.VALIDATION, "녹음 파일은 20MB 이하만 업로드할 수 있습니다.");
}
validateFormat(file);
String token = UUID.randomUUID().toString().replace("-", "");
try {
Path target = resolve(token);
Files.createDirectories(target.getParent());
file.transferTo(target.toFile());
return token;
} catch (IOException e) {
log.warn("meeting audio store failed: {}", e.getClass().getSimpleName());
throw new ApiException(ErrorCode.INTERNAL, "녹음 파일 저장에 실패했습니다.");
}
}
/** 토큰으로 임시 파일 경로 해석(경로순회 차단). 존재 보장은 하지 않음. */
public Path path(String audioId) {
return resolve(sanitize(audioId));
}
/** 임시 파일 폐기(전사 완료/실패 무관 best-effort). */
public void discard(String audioId) {
if (audioId == null || audioId.isBlank()) {
return;
}
try {
Files.deleteIfExists(resolve(sanitize(audioId)));
} catch (Exception ignore) {
// 임시파일 삭제 실패는 무시(정합성 영향 없음)
}
}
private void validateFormat(MultipartFile file) {
String name = file.getOriginalFilename();
String ext = null;
if (name != null) {
int dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length() - 1) {
ext = name.substring(dot + 1).toLowerCase();
}
}
String ct = file.getContentType();
boolean ctAudio = ct != null && (ct.startsWith("audio/") || ct.startsWith("video/"));
boolean extOk = ext != null && ALLOWED_EXT.contains(ext);
// 확장자 화이트리스트 우선, 없으면 audio/* 콘텐츠타입 허용(모바일 녹음은 확장자 누락 가능).
if (!extOk && !ctAudio) {
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 오디오 형식입니다(m4a·mp3·wav·webm 등).");
}
}
private Path resolve(String token) {
Path base = root.resolve(SUBDIR);
Path target = base.resolve(token + ".audio").normalize();
if (!target.startsWith(base)) {
throw new ApiException(ErrorCode.VALIDATION, "허용되지 않은 경로입니다.");
}
return target;
}
/** 토큰은 영숫자만 허용(경로순회·확장자 주입 차단). */
private String sanitize(String token) {
if (token == null || !token.matches("[A-Za-z0-9]{1,64}")) {
throw new ApiException(ErrorCode.NOT_FOUND, "녹음 파일을 찾을 수 없습니다.");
}
return token;
}
}

View File

@ -5,8 +5,12 @@ import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/** 회의 API (/api/work/meetings). 인증 사용자. 편집·회의록·삭제는 주최자(서비스에서 검증). */
@RestController
@ -56,10 +60,45 @@ public class MeetingController {
public ApiResponse<Void> minutes(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id, @RequestBody MinutesRequest req) {
guard.require(principal);
service.saveMinutes(id, req.minutes());
service.saveMinutes(id, req.minutes(), req.transcript());
return ApiResponse.ok(null);
}
// G-06 녹음 STT · AI 회의록 자동작성
/** 녹음 파일 업로드(multipart, ≤20MB) → audioId 일회성 토큰. */
@PostMapping(value = "/audio", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<AudioUploadResponse> uploadAudio(@AuthenticationPrincipal KintexPrincipal principal,
@RequestPart("file") MultipartFile file) {
guard.require(principal);
return ApiResponse.ok(service.uploadAudio(file));
}
/** STT — audioId 전사 시도(온프레미스). 불가 시 degraded=true·빈 전사(수동 입력 유도). */
@PostMapping("/stt")
public ApiResponse<SttResponse> stt(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody SttRequest req) {
guard.require(principal);
return ApiResponse.ok(service.stt(req.audioId()));
}
/** AI 회의록 자동작성 — 전사/수동 텍스트 → 요약·결정·액션(Claude, degraded 폴백). */
@PostMapping("/minutes/generate")
public ApiResponse<GeneratedMinutes> generateMinutes(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody MinutesGenerateRequest req) {
guard.require(principal);
return ApiResponse.ok(service.generateMinutes(req));
}
/** 액션아이템 일괄 저장 — AI 추출분 확인 후 한 번에 등록. 저장된 전체 목록 반환. */
@PostMapping("/{id}/actions/bulk")
public ApiResponse<List<MeetingActionDto>> addActionsBulk(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@RequestBody List<MinutesActionDraft> items) {
guard.require(principal);
return ApiResponse.ok(service.addActionsBulk(id, items));
}
@PostMapping("/{id}/actions")
public ApiResponse<MeetingActionDto> addAction(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,

View File

@ -56,8 +56,15 @@ public interface MeetingMapper {
""")
int update(Map<String, Object> p);
@Update("UPDATE meeting SET minutes=#{minutes}, updated_at=now() WHERE id=#{id}")
int updateMinutes(@Param("id") String id, @Param("minutes") String minutes);
@Update("""
UPDATE meeting
SET minutes = #{minutes},
transcript = COALESCE(#{transcript}, transcript),
updated_at = now()
WHERE id = #{id}
""")
int updateMinutes(@Param("id") String id, @Param("minutes") String minutes,
@Param("transcript") String transcript);
@Delete("DELETE FROM meeting WHERE id=#{id} AND organizer_id=#{organizerId}")
int delete(@Param("id") String id, @Param("organizerId") String organizerId);

View File

@ -0,0 +1,174 @@
package com.zioinfo.kintex.work.meeting;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.ai.AiTextRouter;
import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 전사 텍스트 회의록(요약·결정·액션아이템) 구조화 생성.
*
* <p>{@link AiTextRouter}(Claude 기본, 실패 Ollama 폴백) 경유. 결정론적 구조 출력을 위해 프롬프트에서
* JSON 반환하도록 강제하고, 응답은 코드펜스 제거 관대 파싱한다(NlQueryService 패턴).
* AI 미가용/파싱 실패/전사 비어있음 degraded 폴백( 초안). 근거 없는 내용을 지어내지 않도록 지시한다(환각 방지).
*/
@Service
public class MeetingMinutesGenerator {
private static final Logger log = LoggerFactory.getLogger(MeetingMinutesGenerator.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private final AiTextRouter router;
public MeetingMinutesGenerator(AiTextRouter router) {
this.router = router;
}
/**
* 전사로부터 회의록을 생성한다. 어떤 사유로든 실패하면 degraded 폴백( 초안, minutesText=전사 원문).
* @param title 회의 제목
* @param transcript 전사 원문(비어 있으면 degraded)
* @param attendees 참석자(선택) 프롬프트 맥락으로만 사용
*/
public GeneratedMinutes generate(String title, String transcript, List<String> attendees) {
if (transcript == null || transcript.isBlank()) {
return fallback(title, transcript, "none");
}
AiResult ai = router.generate(buildPrompt(title, transcript, attendees), 1024);
if (!ai.usable()) {
log.info("Minutes generation degraded (AI unusable) → empty draft");
return fallback(title, transcript, ai.provider());
}
GeneratedMinutes parsed = parse(ai.text(), ai.provider());
if (parsed == null) {
log.warn("Minutes JSON parse failed → degraded draft");
return fallback(title, transcript, ai.provider());
}
return parsed;
}
private String buildPrompt(String title, String transcript, List<String> attendees) {
String att = (attendees == null || attendees.isEmpty()) ? "(미지정)" : String.join(", ", attendees);
return "당신은 회의록 작성 보조자다. 아래 회의 전사를 분석해 JSON 객체 하나로만 응답하라.\n"
+ "회의 제목: " + title + "\n"
+ "참석자: " + att + "\n"
+ "규칙(엄수):\n"
+ "1) 키는 정확히 summary(요약 문자열), decisions(결정사항 문자열 배열), "
+ "actionItems(객체 배열: content 문자열, assignee 문자열 또는 빈문자열, dueDate 'yyyy-MM-dd' 또는 빈문자열) 3개.\n"
+ "2) 전사에 실제로 언급된 내용만 사용하라. 없는 담당자·기한·결정을 지어내지 마라(불명확하면 빈문자열/빈배열).\n"
+ "3) 설명·머리말·마크다운·코드펜스 없이 JSON 객체 텍스트만 출력하라.\n\n"
+ "전사:\n" + transcript;
}
/** AI 응답(JSON) → GeneratedMinutes. 코드펜스/전후 텍스트를 관대 처리. 실패 시 null. */
private GeneratedMinutes parse(String raw, String provider) {
String json = stripToJson(raw);
if (json == null) {
return null;
}
try {
JsonNode root = MAPPER.readTree(json);
String summary = text(root.get("summary"));
List<String> decisions = asStringList(root.get("decisions"));
List<MinutesActionDraft> actions = new ArrayList<>();
JsonNode ai = root.get("actionItems");
if (ai != null && ai.isArray()) {
for (JsonNode n : ai) {
String content = text(n.get("content"));
if (content == null || content.isBlank()) {
continue;
}
actions.add(new MinutesActionDraft(content.trim(),
emptyToNull(text(n.get("assignee"))), emptyToNull(text(n.get("dueDate")))));
}
}
String minutesText = compose(summary, decisions, actions);
return new GeneratedMinutes(summary == null ? "" : summary.trim(), decisions, actions,
minutesText, false, provider);
} catch (Exception e) {
return null;
}
}
private GeneratedMinutes fallback(String title, String transcript, String provider) {
// 폴백: 요약·결정·액션은 비우고, 전사 원문(있으면) 본문 초안으로 제공해 사용자가 편집하도록 한다.
String body = (transcript != null && !transcript.isBlank()) ? transcript.trim() : "";
return new GeneratedMinutes("", new ArrayList<>(), new ArrayList<>(), body, true, provider);
}
/** 요약+결정+액션을 회의록 본문 문자열로 합친다(textarea 프리필). */
private static String compose(String summary, List<String> decisions, List<MinutesActionDraft> actions) {
StringBuilder sb = new StringBuilder();
if (summary != null && !summary.isBlank()) {
sb.append("[요약]\n").append(summary.trim()).append("\n\n");
}
if (decisions != null && !decisions.isEmpty()) {
sb.append("[결정사항]\n");
for (String d : decisions) {
sb.append("- ").append(d).append('\n');
}
sb.append('\n');
}
if (actions != null && !actions.isEmpty()) {
sb.append("[액션아이템]\n");
for (MinutesActionDraft a : actions) {
sb.append("- ").append(a.content());
boolean hasAssignee = a.assignee() != null && !a.assignee().isBlank();
boolean hasDue = a.dueDate() != null && !a.dueDate().isBlank();
if (hasAssignee || hasDue) {
sb.append(" (");
if (hasAssignee) {
sb.append("담당: ").append(a.assignee());
}
if (hasDue) {
sb.append(hasAssignee ? ", " : "").append("기한: ").append(a.dueDate());
}
sb.append(')');
}
sb.append('\n');
}
}
return sb.toString().trim();
}
/** 코드펜스/전후 텍스트를 제거하고 첫 '{' ~ 마지막 '}' 구간을 반환. */
private static String stripToJson(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String s = raw.trim();
int start = s.indexOf('{');
int end = s.lastIndexOf('}');
if (start < 0 || end <= start) {
return null;
}
return s.substring(start, end + 1);
}
private static List<String> asStringList(JsonNode node) {
List<String> out = new ArrayList<>();
if (node != null && node.isArray()) {
for (JsonNode n : node) {
String v = text(n);
if (v != null && !v.isBlank()) {
out.add(v.trim());
}
}
}
return out;
}
private static String text(JsonNode n) {
return (n == null || n.isNull()) ? null : n.asText();
}
private static String emptyToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}

View File

@ -7,19 +7,80 @@ import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** 회의 서비스 — 회의·회의록·액션아이템. 편집/회의록/삭제는 주최자 본인. */
/**
* 회의 서비스 회의·회의록·액션아이템 + 녹음 STT·AI 회의록 자동작성(G-06).
* 편집/회의록/삭제는 주최자 본인. STT 온프레미스 whisper(선택) 또는 degraded(수동 입력).
*/
@Service
public class MeetingService {
private final MeetingMapper mapper;
private final MeetingAudioStore audioStore;
private final MeetingSttClient sttClient;
private final MeetingMinutesGenerator minutesGenerator;
public MeetingService(MeetingMapper mapper) {
public MeetingService(MeetingMapper mapper, MeetingAudioStore audioStore,
MeetingSttClient sttClient, MeetingMinutesGenerator minutesGenerator) {
this.mapper = mapper;
this.audioStore = audioStore;
this.sttClient = sttClient;
this.minutesGenerator = minutesGenerator;
}
// G-06 녹음 STT · AI 회의록
/** 1) 녹음 업로드 → audioId(일회성 토큰). 검증·저장은 audioStore. */
public AudioUploadResponse uploadAudio(MultipartFile file) {
String token = audioStore.store(file);
String filename = (file != null && file.getOriginalFilename() != null)
? file.getOriginalFilename() : "meeting.audio";
long size = file != null ? file.getSize() : 0L;
return new AudioUploadResponse(token, filename, size);
}
/** 2) STT — audioId 전사 시도(불가 시 degraded). 시도 후 임시 오디오 폐기(보존 안 함). */
public SttResponse stt(String audioId) {
MeetingSttClient.SttResult r = sttClient.transcribe(audioStore.path(audioId));
audioStore.discard(audioId); // 성공·degraded 무관 개인정보 최소화
return new SttResponse(r.transcript(), r.degraded());
}
/** 3) AI 회의록 자동작성 — 전사(또는 수동 텍스트)로 요약·결정·액션 생성(degraded 폴백). */
public GeneratedMinutes generateMinutes(MinutesGenerateRequest req) {
return minutesGenerator.generate(req.title(), req.transcript(), req.attendees());
}
/** 4) 액션아이템 일괄 저장 — AI 추출분을 사용자 확인 후 한 번에 저장. 저장된 전체 목록 반환. */
@Transactional
public List<MeetingActionDto> addActionsBulk(String meetingId, List<MinutesActionDraft> items) {
if (mapper.findById(meetingId) == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
if (items != null) {
int seq = mapper.maxSeq(meetingId);
for (MinutesActionDraft it : items) {
if (it == null || it.content() == null || it.content().isBlank()) {
continue;
}
seq++;
Map<String, Object> p = new HashMap<>();
p.put("id", "ma-" + UUID.randomUUID().toString().substring(0, 12));
p.put("meetingId", meetingId);
p.put("seq", seq);
p.put("actionItem", it.content().trim());
p.put("assigneeId", null); // 담당자 매핑은 별도(문자열 담당명은 저장 )
p.put("dueDate", emptyToNull(it.dueDate()));
mapper.insertAction(p);
}
}
return mapper.findActions(meetingId).stream().map(MeetingService::toAction).toList();
}
public PageResponse<MeetingDto> list(String eventId, int page, int size) {
@ -67,8 +128,8 @@ public class MeetingService {
}
@Transactional
public void saveMinutes(String id, String minutes) {
if (mapper.updateMinutes(id, minutes) == 0) {
public void saveMinutes(String id, String minutes, String transcript) {
if (mapper.updateMinutes(id, minutes, emptyToNull(transcript)) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
}
@ -126,6 +187,7 @@ public class MeetingService {
str(r.get("actionItem")), str(r.get("assigneeId")), str(r.get("dueDate")), str(r.get("status")));
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
private static String emptyToNull(String s) { return (s == null || s.isBlank()) ? null : s.trim(); }
private static int intVal(Object o) {
if (o instanceof Number n) return n.intValue();
try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); }

View File

@ -0,0 +1,176 @@
package com.zioinfo.kintex.work.meeting;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
* 회의 녹음 STT 클라이언트(UIWS meeting 패턴 이식) 온프레미스 whisper.cpp(선택) degraded.
*
* <p><b>서버 RAM 제약</b>: 대형 STT 모델(whisper large ) 서버에서 직접 구동하지 않는다.
* whisper 엔드포인트가 <b>env 설정된 경우에만</b>(별도 온프레미스/원격 whisper 서비스) 시도하고,
* 미설정(기본) 즉시 degraded 폴백( 전사) 사용자가 전사 텍스트를 직접 입력한다.
* CLOVA 외부 STT 킨텍스 승인 범위 밖이므로 사용하지 않는다(외부 API = Claude 텍스트만 허용).
*
* <p>동시 STT 1건 제한(부하 억제). 예외는 상위로 전파하지 않으며(무전파) 로그에는 상태코드만 남긴다.
* <b>보안 불변</b>: ·오디오·전사 원문을 로그·에러·응답에 미기재. 오디오 임시 파일 폐기는 호출자 책임.
*/
@Service
public class MeetingSttClient {
private static final Logger log = LoggerFactory.getLogger(MeetingSttClient.class);
private static final Semaphore GATE = new Semaphore(1);
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String whisperUrl;
private final String whisperKey;
private final int timeoutSec;
public MeetingSttClient(
@Value("${kintex.meeting.stt.whisper.url:}") String whisperUrl,
@Value("${kintex.meeting.stt.whisper.key:}") String whisperKey,
@Value("${kintex.meeting.stt.whisper.timeout-sec:90}") int timeoutSec) {
this.whisperUrl = whisperUrl == null ? "" : whisperUrl.trim();
this.whisperKey = whisperKey == null ? "" : whisperKey.trim();
this.timeoutSec = timeoutSec <= 0 ? 90 : timeoutSec;
}
/** STT 결과(degraded 시 transcript 는 빈 문자열). */
public record SttResult(String transcript, boolean degraded) {
}
private boolean enabled() {
return !whisperUrl.isBlank();
}
/**
* 임시 오디오 파일을 전사한다. whisper 미설정/실패/빈결과/타임아웃/파일없음 degraded(예외 무전파).
* @param audioPath 서버 임시 파일시스템 경로(전사 폐기 책임은 호출자)
*/
public SttResult transcribe(Path audioPath) {
if (!enabled()) {
log.info("STT disabled (whisper 미설정) → degraded fallback");
return new SttResult("", true);
}
if (audioPath == null || !Files.exists(audioPath)) {
return new SttResult("", true);
}
boolean acquired = false;
try {
acquired = GATE.tryAcquire(timeoutSec, TimeUnit.SECONDS);
if (!acquired) {
log.warn("STT busy (concurrency gate timeout) → degraded fallback");
return new SttResult("", true);
}
byte[] audio = Files.readAllBytes(audioPath);
String transcript = tryWhisper(audio);
if (transcript != null && !transcript.isBlank()) {
return new SttResult(transcript.trim(), false);
}
return new SttResult("", true);
} catch (Exception e) {
log.warn("STT failed → degraded fallback: {}", e.getClass().getSimpleName());
return new SttResult("", true);
} finally {
if (acquired) {
GATE.release();
}
}
}
/** whisper.cpp 온프레미스 전사 시도. multipart file=오디오 + response_format=json, 헤더 X-Whisper-Key. */
private String tryWhisper(byte[] audio) {
try {
String boundary = "----kintexWhisper" + UUID.randomUUID().toString().replace("-", "");
byte[] body = buildMultipart(boundary, audio);
HttpClient.Builder cb = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(Math.min(15, timeoutSec)));
HttpRequest.Builder rb = HttpRequest.newBuilder()
.uri(URI.create(whisperUrl))
.timeout(Duration.ofSeconds(timeoutSec))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
if (!whisperKey.isBlank()) {
rb.header("X-Whisper-Key", whisperKey);
}
HttpResponse<String> resp = cb.build().send(rb.build(), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
log.warn("Whisper STT status {} → degraded fallback", resp.statusCode());
return null;
}
return extractText(resp.body());
} catch (Exception e) {
log.warn("Whisper STT failed → degraded fallback: {}", e.getClass().getSimpleName());
return null;
}
}
/** whisper multipart/form-data 본문 수동 구성(신규 의존성 없이 java.net.http). */
private static byte[] buildMultipart(String boundary, byte[] audio) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
String dash = "--";
String crlf = "\r\n";
out.write((dash + boundary + crlf).getBytes(StandardCharsets.UTF_8));
out.write(("Content-Disposition: form-data; name=\"file\"; filename=\"audio\"" + crlf)
.getBytes(StandardCharsets.UTF_8));
out.write(("Content-Type: application/octet-stream" + crlf + crlf).getBytes(StandardCharsets.UTF_8));
out.write(audio);
out.write(crlf.getBytes(StandardCharsets.UTF_8));
out.write((dash + boundary + crlf).getBytes(StandardCharsets.UTF_8));
out.write(("Content-Disposition: form-data; name=\"response_format\"" + crlf + crlf)
.getBytes(StandardCharsets.UTF_8));
out.write("json".getBytes(StandardCharsets.UTF_8));
out.write(crlf.getBytes(StandardCharsets.UTF_8));
out.write((dash + boundary + dash + crlf).getBytes(StandardCharsets.UTF_8));
return out.toByteArray();
}
/** 응답 JSON 에서 전사 text 추출(text 우선, 없으면 segments[].text 이어붙임). 실패 시 null. */
private static String extractText(String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = MAPPER.readTree(body);
JsonNode text = root.get("text");
if (text != null && !text.isNull() && !text.asText().isBlank()) {
return text.asText();
}
JsonNode segments = root.get("segments");
if (segments != null && segments.isArray() && segments.size() > 0) {
StringBuilder sb = new StringBuilder();
for (JsonNode seg : segments) {
JsonNode st = seg.get("text");
if (st != null && !st.isNull() && !st.asText().isBlank()) {
if (sb.length() > 0) {
sb.append(' ');
}
sb.append(st.asText().trim());
}
}
return sb.length() > 0 ? sb.toString() : null;
}
return null;
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.kintex.work.meeting;
/** AI 가 추출한 액션아이템 초안(저장 전). assignee/dueDate 는 없을 수 있다. */
public record MinutesActionDraft(String content, String assignee, String dueDate) {
}

View File

@ -0,0 +1,12 @@
package com.zioinfo.kintex.work.meeting;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
/**
* 회의록 자동작성 요청 전사(또는 수동 입력) 텍스트로 요약·결정·액션아이템을 생성한다.
* transcript 비어 있으면 degraded 폴백( 회의록 초안).
*/
public record MinutesGenerateRequest(@NotBlank String title, String transcript, List<String> attendees) {
}

View File

@ -1,5 +1,8 @@
package com.zioinfo.kintex.work.meeting;
/** 회의록 저장 요청. */
public record MinutesRequest(String minutes) {
/**
* 회의록 저장 요청.
* transcript 선택 제공 함께 보존(재추출 근거), null 이면 기존 전사 유지(COALESCE).
*/
public record MinutesRequest(String minutes, String transcript) {
}

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.work.meeting;
import jakarta.validation.constraints.NotBlank;
/** STT 요청 — 업로드로 발급받은 audioId(일회성 토큰). */
public record SttRequest(@NotBlank String audioId) {
}

View File

@ -0,0 +1,8 @@
package com.zioinfo.kintex.work.meeting;
/**
* STT 결과. degraded=true 서버 전사 불가(온프레미스 STT 미가용) 사용자가 직접 전사 텍스트를 입력한다.
* transcript degraded 문자열.
*/
public record SttResponse(String transcript, boolean degraded) {
}

View File

@ -19,8 +19,8 @@ spring:
name: kintex-backend
servlet:
multipart:
max-file-size: 10MB # 로그인 슬라이드·ReRoom 참조 이미지 업로드 상한(사진 시안, 소유자 지시 13.2)
max-request-size: 12MB
max-file-size: 25MB # 이미지(로그인 슬라이드·ReRoom 10MB) + 회의 녹음 STT(≤20MB, G-06) 수용
max-request-size: 27MB
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/kintex_db}
username: ${DB_USER:kintex}
@ -88,6 +88,11 @@ kintex:
from: ${MAIL_FROM:no-reply@kintex.zioinfo.co.kr}
# 재설정 링크의 프론트 베이스 URL — 링크는 코드 안내용(프론트 reset 흐름은 이메일+코드 입력).
web-base-url: ${WEB_BASE_URL:https://kintex.zioinfo.co.kr}
# EDM 발송 안전장치(G-04) — 테스트 모드 기본 ON. 운영 발송은 요청 live=true 명시 필요.
edm-test-mode: ${EDM_TEST_MODE:true}
edm-test-recipients: ${EDM_TEST_RECIPIENTS:}
edm-campaign-cap: ${EDM_CAMPAIGN_CAP:5000}
edm-rate-per-minute: ${EDM_RATE_PER_MINUTE:300}
rules:
# 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응)
compliance-ruleset: classpath:rulesets/compliance-v1.json

View File

@ -0,0 +1,3 @@
-- V52 회의 녹음 전사 보존 컬럼(G-06) — 재추출 근거용. 파괴적 변경 없음(멱등 ADD COLUMN IF NOT EXISTS).
-- STT/AI 실패 시 degraded 폴백이므로 nullable. 오디오 원본은 보존하지 않고 전사 텍스트만 남긴다.
ALTER TABLE meeting ADD COLUMN IF NOT EXISTS transcript text;

View File

@ -4,17 +4,19 @@
* : /api/work/meetings (get·create·minutes·actions·action status).
* : 녹음 ·STT ·AI ·Jasper PDF UI는 disabled+. 07_work_api_gaps.md .
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { meetingApi } from '../../api/endpoints';
import type { MeetingSaveRequest } from '../../api/types';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconDownload, IconPlus } from '../../components/ui/icons';
import { IconDownload, IconPlus, IconSpark, IconUpload } from '../../components/ui/icons';
import { StatusPill, errMessage, fmtDateTime, useToast } from './workShared';
import { meetingAiApi, type GeneratedMinutes, type MinutesActionDraft } from './workAiApi';
import './work.css';
const NOT_SUPPORTED = '백엔드 미지원 — STT/PDF 파이프라인 연동 후 활성화';
const NOT_SUPPORTED = '백엔드 미지원 — Jasper PDF 파이프라인 연동 후 활성화';
export function MeetingPage() {
const qc = useQueryClient();
@ -22,6 +24,7 @@ export function MeetingPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [composing, setComposing] = useState(false);
const [minutesDraft, setMinutesDraft] = useState('');
const [transcriptDraft, setTranscriptDraft] = useState('');
const [actionText, setActionText] = useState('');
const listQ = useQuery({
@ -35,7 +38,10 @@ export function MeetingPage() {
});
useEffect(() => {
if (detailQ.data) setMinutesDraft(detailQ.data.meeting.minutes ?? '');
if (detailQ.data) {
setMinutesDraft(detailQ.data.meeting.minutes ?? '');
setTranscriptDraft('');
}
}, [detailQ.data]);
const createM = useMutation({
@ -49,14 +55,23 @@ export function MeetingPage() {
onError: (e) => show(errMessage(e)),
});
const minutesM = useMutation({
mutationFn: ({ id, minutes }: { id: string; minutes: string }) =>
meetingApi.saveMinutes(id, minutes),
mutationFn: ({ id, minutes, transcript }: { id: string; minutes: string; transcript?: string }) =>
meetingAiApi.saveMinutes(id, minutes, transcript),
onSuccess: () => {
show('회의록이 저장되었습니다.');
qc.invalidateQueries({ queryKey: ['meeting', selectedId] });
},
onError: (e) => show(errMessage(e)),
});
const bulkActionsM = useMutation({
mutationFn: ({ id, items }: { id: string; items: MinutesActionDraft[] }) =>
meetingAiApi.addActionsBulk(id, items),
onSuccess: () => {
show('액션아이템이 등록되었습니다.');
qc.invalidateQueries({ queryKey: ['meeting', selectedId] });
},
onError: (e) => show(errMessage(e)),
});
const actionM = useMutation({
mutationFn: ({ id, item }: { id: string; item: string }) =>
meetingApi.addAction(id, { actionItem: item }),
@ -156,10 +171,19 @@ export function MeetingPage() {
{detailQ.data.meeting.location && <span>· {detailQ.data.meeting.location}</span>}
</div>
{/* 녹음/STT (미지원) */}
<div className="kx-meet__player" title={NOT_SUPPORTED}>
· STT ( )
</div>
{/* 녹음 업로드 → STT 전사 → AI 회의록 자동작성 (G-06) */}
<MeetingAiPanel
title={detailQ.data.meeting.title}
onTranscript={(t) => setTranscriptDraft(t)}
onMinutes={(g) => {
if (g.minutesText) setMinutesDraft(g.minutesText);
}}
onAdoptActions={(items) =>
bulkActionsM.mutate({ id: detailQ.data!.meeting.id, items })
}
adopting={bulkActionsM.isPending}
show={show}
/>
{detailQ.data.meeting.content && (
<div className="kx-field">
@ -181,7 +205,13 @@ export function MeetingPage() {
<div style={{ marginTop: 8 }}>
<Button
disabled={minutesM.isPending}
onClick={() => minutesM.mutate({ id: detailQ.data!.meeting.id, minutes: minutesDraft })}
onClick={() =>
minutesM.mutate({
id: detailQ.data!.meeting.id,
minutes: minutesDraft,
transcript: transcriptDraft || undefined,
})
}
>
{minutesM.isPending ? '저장 중…' : '회의록 저장'}
</Button>
@ -259,6 +289,195 @@ export function MeetingPage() {
);
}
/**
* G-06 STT AI .
* (20MB) STT ( degraded= ) Claude .
* (··) textarea + .
*/
function MeetingAiPanel({
title,
onTranscript,
onMinutes,
onAdoptActions,
adopting,
show,
}: {
title: string;
onTranscript: (t: string) => void;
onMinutes: (g: GeneratedMinutes) => void;
onAdoptActions: (items: MinutesActionDraft[]) => void;
adopting: boolean;
show: (msg: string) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [sttRunning, setSttRunning] = useState(false);
const [sttDegraded, setSttDegraded] = useState(false);
const [transcript, setTranscript] = useState('');
const [fileName, setFileName] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [generated, setGenerated] = useState<GeneratedMinutes | null>(null);
function updateTranscript(v: string) {
setTranscript(v);
onTranscript(v);
}
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ''; // 같은 파일 재선택 허용
if (!file) return;
if (file.size > 20 * 1024 * 1024) {
show('녹음 파일은 20MB 이하만 업로드할 수 있습니다.');
return;
}
setFileName(file.name);
setGenerated(null);
try {
setUploading(true);
const up = await meetingAiApi.uploadAudio(file);
setUploading(false);
setSttRunning(true);
const res = await meetingAiApi.stt(up.audioId);
setSttDegraded(res.degraded);
if (res.degraded) {
show('서버 전사를 사용할 수 없습니다. 전사 내용을 직접 입력해 주세요.');
} else {
updateTranscript(res.transcript);
show('전사가 완료되었습니다.');
}
} catch (err) {
show(errMessage(err));
} finally {
setUploading(false);
setSttRunning(false);
}
}
async function onGenerate() {
if (!transcript.trim()) {
show('전사 또는 회의 내용을 먼저 입력해 주세요.');
return;
}
try {
setGenerating(true);
const g = await meetingAiApi.generateMinutes({ title, transcript });
setGenerated(g);
onMinutes(g);
if (g.degraded) {
show('AI 회의록 자동작성을 사용할 수 없어 전사 원문을 본문에 반영했습니다.');
} else {
show('AI 회의록 초안이 작성되었습니다.');
}
} catch (err) {
show(errMessage(err));
} finally {
setGenerating(false);
}
}
const busy = uploading || sttRunning;
return (
<div className="kx-meet__ai-card">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<AiLabel> AI</AiLabel>
<span className="kx-list-table__muted"> </span>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<input
ref={fileRef}
type="file"
accept="audio/*,.m4a,.mp3,.wav,.webm,.ogg,.aac"
style={{ display: 'none' }}
onChange={onPickFile}
/>
<Button
variant="secondary"
leadingIcon={<IconUpload size={16} />}
disabled={busy}
onClick={() => fileRef.current?.click()}
>
{uploading ? '업로드 중…' : sttRunning ? '전사 중…' : '녹음 파일 업로드'}
</Button>
<Button
leadingIcon={<IconSpark size={16} />}
disabled={busy || generating || !transcript.trim()}
onClick={onGenerate}
>
{generating ? '작성 중…' : 'AI 회의록 자동작성'}
</Button>
</div>
{fileName && (
<div className="kx-detail__meta" style={{ marginTop: 6 }}>
<span>{fileName}</span>
{sttDegraded && <span className="kx-pill kx-pill--neutral"> · </span>}
</div>
)}
{/* 전사 텍스트(편집 가능) — degraded 시 직접 입력 경로 */}
<div className="kx-field" style={{ marginTop: 8 }}>
<span className="kx-label"> </span>
<textarea
className="kx-textarea"
style={{ minHeight: 90 }}
value={transcript}
onChange={(e) => updateTranscript(e.target.value)}
placeholder="녹음을 업로드해 전사하거나, 회의 내용을 직접 입력하세요."
/>
</div>
{/* AI 결과 미리보기 */}
{generated && !generated.degraded && (
<div style={{ marginTop: 8 }}>
{generated.summary && (
<div className="kx-field">
<span className="kx-label">AI </span>
<div className="kx-detail__body">{generated.summary}</div>
</div>
)}
{generated.decisions.length > 0 && (
<div className="kx-field">
<span className="kx-label"></span>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{generated.decisions.map((d, i) => (
<li key={i}>{d}</li>
))}
</ul>
</div>
)}
{generated.actionItems.length > 0 && (
<div className="kx-field">
<span className="kx-label"> ({generated.actionItems.length})</span>
<ul style={{ margin: '0 0 8px', paddingLeft: 18 }}>
{generated.actionItems.map((a, i) => (
<li key={i}>
{a.content}
{a.assignee && <span className="kx-list-table__muted"> · {a.assignee}</span>}
{a.dueDate && <span className="kx-list-table__muted"> · {a.dueDate}</span>}
</li>
))}
</ul>
<Button
variant="secondary"
disabled={adopting}
onClick={() => onAdoptActions(generated.actionItems)}
>
{adopting ? '등록 중…' : '액션아이템 일괄 등록'}
</Button>
</div>
)}
<p className="kx-list-table__muted" style={{ marginTop: 4 }}>
AI . .
</p>
</div>
)}
</div>
);
}
function MeetingCompose({
onCancel,
onSave,

View File

@ -0,0 +1,64 @@
/*
* work API G-06 STT·AI , G-07 .
* api/endpoints.ts work ( work ).
*/
import { api } from '../../api/client';
import type { SearchResultItem } from '../../api/types';
// ── G-06 회의 녹음 STT · AI 회의록 ──
export interface AudioUploadResponse {
audioId: string;
filename: string;
sizeBytes: number;
}
export interface SttResponse {
transcript: string;
degraded: boolean;
}
export interface MinutesActionDraft {
content: string;
assignee?: string | null;
dueDate?: string | null;
}
export interface GeneratedMinutes {
summary: string;
decisions: string[];
actionItems: MinutesActionDraft[];
minutesText: string;
degraded: boolean;
provider: string;
}
const MEET = '/api/work/meetings';
export const meetingAiApi = {
uploadAudio: (file: File) => {
const form = new FormData();
form.append('file', file);
return api.postForm<AudioUploadResponse>(`${MEET}/audio`, form);
},
stt: (audioId: string) => api.post<SttResponse>(`${MEET}/stt`, { audioId }),
generateMinutes: (body: { title: string; transcript?: string; attendees?: string[] }) =>
api.post<GeneratedMinutes>(`${MEET}/minutes/generate`, body),
addActionsBulk: (id: string, items: MinutesActionDraft[]) =>
api.post<unknown>(`${MEET}/${encodeURIComponent(id)}/actions/bulk`, items),
/** 회의록 + 전사 함께 저장(transcript 선택). */
saveMinutes: (id: string, minutes: string, transcript?: string) =>
api.put<void>(`${MEET}/${encodeURIComponent(id)}/minutes`, { minutes, transcript }),
};
// ── G-07 자연어 통합검색 ──
export interface AiSearchResult {
question: string;
keywords: string[];
types: string[];
summary: string | null;
results: SearchResultItem[];
total: number;
degraded: boolean;
provider: string;
}
export const searchAiApi = {
ask: (q: string, limit = 50) => api.post<AiSearchResult>('/api/work/search/ai', { q, limit }),
};