diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/AudioUploadResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/AudioUploadResponse.java new file mode 100644 index 0000000..3838e03 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/AudioUploadResponse.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.work.meeting; + +/** 회의 녹음 업로드 응답 — audioId(일회성 토큰)로 STT 를 호출한다. */ +public record AudioUploadResponse(String audioId, String filename, long sizeBytes) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/GeneratedMinutes.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/GeneratedMinutes.java new file mode 100644 index 0000000..266d971 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/GeneratedMinutes.java @@ -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 decisions, + List actionItems, String minutesText, + boolean degraded, String provider) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingAudioStore.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingAudioStore.java new file mode 100644 index 0000000..eb65426 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingAudioStore.java @@ -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 금지). + * + *

토큰(audioId)은 UUID(파일명 추측·경로순회 차단). 실제 경로는 서버에서만 해석하며 물리 파일명은 + * 응답/로그에 노출하지 않는다. 저장 루트는 첨부 루트와 분리한다({@code {kintex.upload.dir}/meeting-tmp}). + * + *

검증: ①빈 파일 거부 ②크기 상한(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 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingController.java index 76d2e04..cf85b38 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingController.java @@ -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 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 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 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 generateMinutes(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody MinutesGenerateRequest req) { + guard.require(principal); + return ApiResponse.ok(service.generateMinutes(req)); + } + + /** 액션아이템 일괄 저장 — AI 추출분 확인 후 한 번에 등록. 저장된 전체 목록 반환. */ + @PostMapping("/{id}/actions/bulk") + public ApiResponse> addActionsBulk(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @RequestBody List items) { + guard.require(principal); + return ApiResponse.ok(service.addActionsBulk(id, items)); + } + @PostMapping("/{id}/actions") public ApiResponse addAction(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String id, diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMapper.java index 171ef71..415e6fb 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMapper.java @@ -56,8 +56,15 @@ public interface MeetingMapper { """) int update(Map 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); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMinutesGenerator.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMinutesGenerator.java new file mode 100644 index 0000000..b6eb794 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMinutesGenerator.java @@ -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; + +/** + * 전사 텍스트 → 회의록(요약·결정·액션아이템) 구조화 생성. + * + *

{@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 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 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 decisions = asStringList(root.get("decisions")); + List 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 decisions, List 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 asStringList(JsonNode node) { + List 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(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingService.java index 2b442b0..587ad6d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingService.java @@ -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 addActionsBulk(String meetingId, List 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 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 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)); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingSttClient.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingSttClient.java new file mode 100644 index 0000000..099595d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingSttClient.java @@ -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. + * + *

서버 RAM 제약: 대형 STT 모델(whisper large 등)을 이 서버에서 직접 구동하지 않는다. + * whisper 엔드포인트가 env 로 설정된 경우에만(별도 온프레미스/원격 whisper 서비스) 시도하고, + * 미설정(기본) 시 즉시 degraded 폴백(빈 전사) → 사용자가 전사 텍스트를 직접 입력한다. + * CLOVA 등 외부 STT 는 킨텍스 승인 범위 밖이므로 사용하지 않는다(외부 API = Claude 텍스트만 허용). + * + *

동시 STT 1건 제한(부하 억제). 예외는 상위로 전파하지 않으며(무전파) 로그에는 상태코드만 남긴다. + * 보안 불변: 키·오디오·전사 원문을 로그·에러·응답에 미기재. 오디오 임시 파일 폐기는 호출자 책임. + */ +@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 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; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesActionDraft.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesActionDraft.java new file mode 100644 index 0000000..f4cf1fe --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesActionDraft.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.work.meeting; + +/** AI 가 추출한 액션아이템 초안(저장 전). assignee/dueDate 는 없을 수 있다. */ +public record MinutesActionDraft(String content, String assignee, String dueDate) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesGenerateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesGenerateRequest.java new file mode 100644 index 0000000..7b15405 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesGenerateRequest.java @@ -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 attendees) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesRequest.java index da4b9b7..678d068 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesRequest.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesRequest.java @@ -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) { } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/SttRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/SttRequest.java new file mode 100644 index 0000000..a5a2920 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/SttRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.meeting; + +import jakarta.validation.constraints.NotBlank; + +/** STT 요청 — 업로드로 발급받은 audioId(일회성 토큰). */ +public record SttRequest(@NotBlank String audioId) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/SttResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/SttResponse.java new file mode 100644 index 0000000..517700c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/SttResponse.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.work.meeting; + +/** + * STT 결과. degraded=true 면 서버 전사 불가(온프레미스 STT 미가용) — 사용자가 직접 전사 텍스트를 입력한다. + * transcript 는 degraded 시 빈 문자열. + */ +public record SttResponse(String transcript, boolean degraded) { +} diff --git a/src/backend/src/main/resources/application.yml b/src/backend/src/main/resources/application.yml index 859a58e..008b7e6 100644 --- a/src/backend/src/main/resources/application.yml +++ b/src/backend/src/main/resources/application.yml @@ -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 diff --git a/src/backend/src/main/resources/db/migration/V52__meeting_transcript.sql b/src/backend/src/main/resources/db/migration/V52__meeting_transcript.sql new file mode 100644 index 0000000..016b028 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V52__meeting_transcript.sql @@ -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; diff --git a/src/frontend/src/screens/work/MeetingPage.tsx b/src/frontend/src/screens/work/MeetingPage.tsx index 45ca872..c39e29d 100644 --- a/src/frontend/src/screens/work/MeetingPage.tsx +++ b/src/frontend/src/screens/work/MeetingPage.tsx @@ -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(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 && · {detailQ.data.meeting.location}} - {/* 녹음/STT (미지원) */} -

- ▶ 녹음 재생 · STT 전사 (준비 중) -
+ {/* 녹음 업로드 → STT 전사 → AI 회의록 자동작성 (G-06) */} + 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 && (
@@ -181,7 +205,13 @@ export function MeetingPage() {
@@ -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(null); + const [uploading, setUploading] = useState(false); + const [sttRunning, setSttRunning] = useState(false); + const [sttDegraded, setSttDegraded] = useState(false); + const [transcript, setTranscript] = useState(''); + const [fileName, setFileName] = useState(null); + const [generating, setGenerating] = useState(false); + const [generated, setGenerated] = useState(null); + + function updateTranscript(v: string) { + setTranscript(v); + onTranscript(v); + } + + async function onPickFile(e: React.ChangeEvent) { + 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 ( +
+
+ 회의록 AI + 녹음 업로드 → 전사 → 회의록 자동작성 +
+ +
+ + + +
+ + {fileName && ( +
+ {fileName} + {sttDegraded && 서버 전사 불가 · 직접 입력} +
+ )} + + {/* 전사 텍스트(편집 가능) — degraded 시 직접 입력 경로 */} +
+ 전사 내용 +