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:
parent
3c8058546e
commit
ef7400632e
@ -0,0 +1,5 @@
|
|||||||
|
package com.zioinfo.kintex.work.meeting;
|
||||||
|
|
||||||
|
/** 회의 녹음 업로드 응답 — audioId(일회성 토큰)로 STT 를 호출한다. */
|
||||||
|
public record AudioUploadResponse(String audioId, String filename, long sizeBytes) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,12 @@ import com.zioinfo.kintex.auth.KintexPrincipal;
|
|||||||
import com.zioinfo.kintex.common.ApiResponse;
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
import com.zioinfo.kintex.common.PageResponse;
|
import com.zioinfo.kintex.common.PageResponse;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** 회의 API (/api/work/meetings). 인증 사용자. 편집·회의록·삭제는 주최자(서비스에서 검증). */
|
/** 회의 API (/api/work/meetings). 인증 사용자. 편집·회의록·삭제는 주최자(서비스에서 검증). */
|
||||||
@RestController
|
@RestController
|
||||||
@ -56,10 +60,45 @@ public class MeetingController {
|
|||||||
public ApiResponse<Void> minutes(@AuthenticationPrincipal KintexPrincipal principal,
|
public ApiResponse<Void> minutes(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
@PathVariable String id, @RequestBody MinutesRequest req) {
|
@PathVariable String id, @RequestBody MinutesRequest req) {
|
||||||
guard.require(principal);
|
guard.require(principal);
|
||||||
service.saveMinutes(id, req.minutes());
|
service.saveMinutes(id, req.minutes(), req.transcript());
|
||||||
return ApiResponse.ok(null);
|
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")
|
@PostMapping("/{id}/actions")
|
||||||
public ApiResponse<MeetingActionDto> addAction(@AuthenticationPrincipal KintexPrincipal principal,
|
public ApiResponse<MeetingActionDto> addAction(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
|
|||||||
@ -56,8 +56,15 @@ public interface MeetingMapper {
|
|||||||
""")
|
""")
|
||||||
int update(Map<String, Object> p);
|
int update(Map<String, Object> p);
|
||||||
|
|
||||||
@Update("UPDATE meeting SET minutes=#{minutes}, updated_at=now() WHERE id=#{id}")
|
@Update("""
|
||||||
int updateMinutes(@Param("id") String id, @Param("minutes") String minutes);
|
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}")
|
@Delete("DELETE FROM meeting WHERE id=#{id} AND organizer_id=#{organizerId}")
|
||||||
int delete(@Param("id") String id, @Param("organizerId") String organizerId);
|
int delete(@Param("id") String id, @Param("organizerId") String organizerId);
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,19 +7,80 @@ import com.zioinfo.kintex.common.error.ErrorCode;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/** 회의 서비스 — 회의·회의록·액션아이템. 편집/회의록/삭제는 주최자 본인. */
|
/**
|
||||||
|
* 회의 서비스 — 회의·회의록·액션아이템 + 녹음 STT·AI 회의록 자동작성(G-06).
|
||||||
|
* 편집/회의록/삭제는 주최자 본인. STT 는 온프레미스 whisper(선택) 또는 degraded(수동 입력).
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class MeetingService {
|
public class MeetingService {
|
||||||
|
|
||||||
private final MeetingMapper mapper;
|
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.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) {
|
public PageResponse<MeetingDto> list(String eventId, int page, int size) {
|
||||||
@ -67,8 +128,8 @@ public class MeetingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void saveMinutes(String id, String minutes) {
|
public void saveMinutes(String id, String minutes, String transcript) {
|
||||||
if (mapper.updateMinutes(id, minutes) == 0) {
|
if (mapper.updateMinutes(id, minutes, emptyToNull(transcript)) == 0) {
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
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")));
|
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 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) {
|
private static int intVal(Object o) {
|
||||||
if (o instanceof Number n) return n.intValue();
|
if (o instanceof Number n) return n.intValue();
|
||||||
try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); }
|
try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); }
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.zioinfo.kintex.work.meeting;
|
||||||
|
|
||||||
|
/** AI 가 추출한 액션아이템 초안(저장 전). assignee/dueDate 는 없을 수 있다. */
|
||||||
|
public record MinutesActionDraft(String content, String assignee, String dueDate) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
package com.zioinfo.kintex.work.meeting;
|
package com.zioinfo.kintex.work.meeting;
|
||||||
|
|
||||||
/** 회의록 저장 요청. */
|
/**
|
||||||
public record MinutesRequest(String minutes) {
|
* 회의록 저장 요청.
|
||||||
|
* transcript 는 선택 — 제공 시 함께 보존(재추출 근거), null 이면 기존 전사 유지(COALESCE).
|
||||||
|
*/
|
||||||
|
public record MinutesRequest(String minutes, String transcript) {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.kintex.work.meeting;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** STT 요청 — 업로드로 발급받은 audioId(일회성 토큰). */
|
||||||
|
public record SttRequest(@NotBlank String audioId) {
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.zioinfo.kintex.work.meeting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STT 결과. degraded=true 면 서버 전사 불가(온프레미스 STT 미가용) — 사용자가 직접 전사 텍스트를 입력한다.
|
||||||
|
* transcript 는 degraded 시 빈 문자열.
|
||||||
|
*/
|
||||||
|
public record SttResponse(String transcript, boolean degraded) {
|
||||||
|
}
|
||||||
@ -19,8 +19,8 @@ spring:
|
|||||||
name: kintex-backend
|
name: kintex-backend
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 10MB # 로그인 슬라이드·ReRoom 참조 이미지 업로드 상한(사진 시안, 소유자 지시 13.2)
|
max-file-size: 25MB # 이미지(로그인 슬라이드·ReRoom 10MB) + 회의 녹음 STT(≤20MB, G-06) 수용
|
||||||
max-request-size: 12MB
|
max-request-size: 27MB
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/kintex_db}
|
url: ${DB_URL:jdbc:postgresql://localhost:5432/kintex_db}
|
||||||
username: ${DB_USER:kintex}
|
username: ${DB_USER:kintex}
|
||||||
@ -88,6 +88,11 @@ kintex:
|
|||||||
from: ${MAIL_FROM:no-reply@kintex.zioinfo.co.kr}
|
from: ${MAIL_FROM:no-reply@kintex.zioinfo.co.kr}
|
||||||
# 재설정 링크의 프론트 베이스 URL — 링크는 코드 안내용(프론트 reset 흐름은 이메일+코드 입력).
|
# 재설정 링크의 프론트 베이스 URL — 링크는 코드 안내용(프론트 reset 흐름은 이메일+코드 입력).
|
||||||
web-base-url: ${WEB_BASE_URL:https://kintex.zioinfo.co.kr}
|
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:
|
rules:
|
||||||
# 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응)
|
# 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응)
|
||||||
compliance-ruleset: classpath:rulesets/compliance-v1.json
|
compliance-ruleset: classpath:rulesets/compliance-v1.json
|
||||||
|
|||||||
@ -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;
|
||||||
@ -4,17 +4,19 @@
|
|||||||
* 백엔드: /api/work/meetings (get·create·minutes·actions·action status).
|
* 백엔드: /api/work/meetings (get·create·minutes·actions·action status).
|
||||||
* ★ 갭: 녹음 재생·STT 전사·AI 요약·Jasper PDF 엔드포인트 부재 → 해당 UI는 disabled+툴팁. 07_work_api_gaps.md 기록.
|
* ★ 갭: 녹음 재생·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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { meetingApi } from '../../api/endpoints';
|
import { meetingApi } from '../../api/endpoints';
|
||||||
import type { MeetingSaveRequest } from '../../api/types';
|
import type { MeetingSaveRequest } from '../../api/types';
|
||||||
|
import { AiLabel } from '../../components/ui/Badge';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
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 { StatusPill, errMessage, fmtDateTime, useToast } from './workShared';
|
||||||
|
import { meetingAiApi, type GeneratedMinutes, type MinutesActionDraft } from './workAiApi';
|
||||||
import './work.css';
|
import './work.css';
|
||||||
|
|
||||||
const NOT_SUPPORTED = '백엔드 미지원 — STT/PDF 파이프라인 연동 후 활성화';
|
const NOT_SUPPORTED = '백엔드 미지원 — Jasper PDF 파이프라인 연동 후 활성화';
|
||||||
|
|
||||||
export function MeetingPage() {
|
export function MeetingPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@ -22,6 +24,7 @@ export function MeetingPage() {
|
|||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [composing, setComposing] = useState(false);
|
const [composing, setComposing] = useState(false);
|
||||||
const [minutesDraft, setMinutesDraft] = useState('');
|
const [minutesDraft, setMinutesDraft] = useState('');
|
||||||
|
const [transcriptDraft, setTranscriptDraft] = useState('');
|
||||||
const [actionText, setActionText] = useState('');
|
const [actionText, setActionText] = useState('');
|
||||||
|
|
||||||
const listQ = useQuery({
|
const listQ = useQuery({
|
||||||
@ -35,7 +38,10 @@ export function MeetingPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detailQ.data) setMinutesDraft(detailQ.data.meeting.minutes ?? '');
|
if (detailQ.data) {
|
||||||
|
setMinutesDraft(detailQ.data.meeting.minutes ?? '');
|
||||||
|
setTranscriptDraft('');
|
||||||
|
}
|
||||||
}, [detailQ.data]);
|
}, [detailQ.data]);
|
||||||
|
|
||||||
const createM = useMutation({
|
const createM = useMutation({
|
||||||
@ -49,14 +55,23 @@ export function MeetingPage() {
|
|||||||
onError: (e) => show(errMessage(e)),
|
onError: (e) => show(errMessage(e)),
|
||||||
});
|
});
|
||||||
const minutesM = useMutation({
|
const minutesM = useMutation({
|
||||||
mutationFn: ({ id, minutes }: { id: string; minutes: string }) =>
|
mutationFn: ({ id, minutes, transcript }: { id: string; minutes: string; transcript?: string }) =>
|
||||||
meetingApi.saveMinutes(id, minutes),
|
meetingAiApi.saveMinutes(id, minutes, transcript),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
show('회의록이 저장되었습니다.');
|
show('회의록이 저장되었습니다.');
|
||||||
qc.invalidateQueries({ queryKey: ['meeting', selectedId] });
|
qc.invalidateQueries({ queryKey: ['meeting', selectedId] });
|
||||||
},
|
},
|
||||||
onError: (e) => show(errMessage(e)),
|
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({
|
const actionM = useMutation({
|
||||||
mutationFn: ({ id, item }: { id: string; item: string }) =>
|
mutationFn: ({ id, item }: { id: string; item: string }) =>
|
||||||
meetingApi.addAction(id, { actionItem: item }),
|
meetingApi.addAction(id, { actionItem: item }),
|
||||||
@ -156,10 +171,19 @@ export function MeetingPage() {
|
|||||||
{detailQ.data.meeting.location && <span>· {detailQ.data.meeting.location}</span>}
|
{detailQ.data.meeting.location && <span>· {detailQ.data.meeting.location}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 녹음/STT (미지원) */}
|
{/* 녹음 업로드 → STT 전사 → AI 회의록 자동작성 (G-06) */}
|
||||||
<div className="kx-meet__player" title={NOT_SUPPORTED}>
|
<MeetingAiPanel
|
||||||
▶ 녹음 재생 · STT 전사 (준비 중)
|
title={detailQ.data.meeting.title}
|
||||||
</div>
|
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 && (
|
{detailQ.data.meeting.content && (
|
||||||
<div className="kx-field">
|
<div className="kx-field">
|
||||||
@ -181,7 +205,13 @@ export function MeetingPage() {
|
|||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Button
|
<Button
|
||||||
disabled={minutesM.isPending}
|
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 ? '저장 중…' : '회의록 저장'}
|
{minutesM.isPending ? '저장 중…' : '회의록 저장'}
|
||||||
</Button>
|
</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({
|
function MeetingCompose({
|
||||||
onCancel,
|
onCancel,
|
||||||
onSave,
|
onSave,
|
||||||
|
|||||||
64
src/frontend/src/screens/work/workAiApi.ts
Normal file
64
src/frontend/src/screens/work/workAiApi.ts
Normal 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 }),
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user