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.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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.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)); }
|
||||
|
||||
@ -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;
|
||||
|
||||
/** 회의록 저장 요청. */
|
||||
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
|
||||
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
|
||||
|
||||
@ -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).
|
||||
* ★ 갭: 녹음 재생·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,
|
||||
|
||||
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