Skip to content

Commit 2907e04

Browse files
authored
feat(reports): 회의록 품질 개선 (#3)
- 회의록 생성 품질 튜닝(요약 + STT). 동작 계약은 유지하고 생성 품질만 개선. - 요약(GeminiSummarizer): temperature 0.2 로 일관성 확보 + responseSchema 로 5필드 JSON 구조를 모델단에서 강제(파싱부 throw 감소). - STT(faster-whisper, small 유지): vad_filter 로 무음 환각 감소, initial_prompt로 기술용어 오인식 완화, condition_on_previous_text=false 로 청크 환각 억제.
1 parent e49a21d commit 2907e04

4 files changed

Lines changed: 137 additions & 2 deletions

File tree

‎apps/ai-worker/.env.template‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@
77
# STT_LANGUAGE=ko # STT 언어
88
# STT_CPU_THREADS=1 # CPU 스레드 수
99
# STT_BEAM_SIZE=1 # beam search 크기(클수록 느리고 약간 정확)
10+
# STT_VAD_FILTER=true # 무음 구간 제거(환각 자막 감소). false 로 끌 수 있음
11+
# STT_INITIAL_PROMPT=... # 도메인 어휘 프라이밍(영어 기술용어 오인식 감소). 빈 값이면 비활성

‎apps/ai-worker/main.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@
4040
MODEL_SIZE = os.getenv("STT_MODEL_SIZE", "small")
4141
LANGUAGE = os.getenv("STT_LANGUAGE", "ko")
4242

43+
44+
def _env_bool(name: str, default: bool) -> bool:
45+
raw = os.getenv(name)
46+
# 미설정(None)뿐 아니라 빈 문자열·공백("STT_VAD_FILTER=")도 기본값으로 본다.
47+
if raw is None or not raw.strip():
48+
return default
49+
return raw.strip().lower() in ("1", "true", "yes", "on")
50+
51+
52+
# faster-whisper small 한국어 STT 의 오인식·환각을 줄이는 디코딩 옵션.
53+
#
54+
# - VAD_FILTER: Silero VAD 로 무음 구간을 잘라낸다. 회의 오디오는 긴 정적
55+
# 구간이 많아 그대로 디코드하면 무음에 환각 자막이 붙는다(특히 30s 청크).
56+
# - INITIAL_PROMPT: 도메인 어휘를 모델에 미리 알려 영어 기술 용어 오인식
57+
# (예: "MySQL" → "마이스쿨")을 줄인다. 빈 문자열이면 비활성(None).
58+
# 도메인이 다르면 STT_INITIAL_PROMPT 로 교체한다.
59+
DEFAULT_INITIAL_PROMPT = (
60+
"이 녹음은 IT 개발 동아리의 화상 회의입니다. "
61+
"API, OAuth, MySQL, Redis, Docker, 백엔드, 프론트엔드, 배포, 리팩터링 같은 "
62+
"기술 용어와 영어 약어가 자주 등장합니다."
63+
)
64+
VAD_FILTER = _env_bool("STT_VAD_FILTER", True)
65+
INITIAL_PROMPT = (os.getenv("STT_INITIAL_PROMPT", DEFAULT_INITIAL_PROMPT).strip() or None)
66+
4367
# 모듈 import 시점에 모델을 1회 로드(warm-start). 컨테이너 부팅 시 cold-load 비용
4468
# 한 번만 지불하고, 이후 매 요청은 transcribe 만 호출한다.
4569
model = WhisperModel(
@@ -74,6 +98,12 @@ async def transcribe(request: Request) -> dict[str, Any]:
7498
io.BytesIO(audio),
7599
beam_size=BEAM_SIZE,
76100
language=LANGUAGE,
101+
vad_filter=VAD_FILTER,
102+
initial_prompt=INITIAL_PROMPT,
103+
# 청크 전사는 청크마다 독립 호출이라 이전 청크 문맥이 없다. 기본값
104+
# (True)은 직전 텍스트에 조건을 걸어 반복·드리프트 환각을 유발하므로
105+
# 청크 단위 안정성을 위해 끈다.
106+
condition_on_previous_text=False,
77107
)
78108

79109
segments: list[dict[str, Any]] = []

‎apps/backend/src/reports/infrastructure/gemini.summarizer.spec.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,48 @@ describe('GeminiSummarizer', () => {
9999
expect(promptText).toContain('Korean');
100100
});
101101

102+
it('generationConfig 에 낮은 temperature 와 5필드 responseSchema 가 강제된다', async () => {
103+
const fetchMock = jest.fn().mockResolvedValueOnce(okResponse(validSummaryJson));
104+
const summarizer = new GeminiSummarizer(options, fetchMock as unknown as typeof fetch);
105+
106+
await summarizer.summarize({ transcript: [], chat: [], meta });
107+
108+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
109+
const body = JSON.parse(init.body as string) as {
110+
generationConfig: {
111+
responseMimeType: string;
112+
temperature: number;
113+
responseSchema: {
114+
type: string;
115+
properties: Record<string, { type: string }>;
116+
required: string[];
117+
propertyOrdering?: string[];
118+
};
119+
};
120+
};
121+
// 요약 일관성을 위해 temperature 를 낮춘다(Gemini default 1.0 → 0.2).
122+
expect(body.generationConfig.temperature).toBeLessThanOrEqual(0.2);
123+
// 모델단에서 5필드 JSON 구조를 강제해 어댑터 파싱부의 throw 빈도를 낮춘다.
124+
const schema = body.generationConfig.responseSchema;
125+
// REST v1beta Schema 의 type 은 대문자 enum(OBJECT/STRING/ARRAY).
126+
expect(schema.type).toBe('OBJECT');
127+
expect(Object.keys(schema.properties).sort()).toEqual(
128+
['actionItems', 'decisions', 'keyTopics', 'overview', 'title'],
129+
);
130+
expect(schema.required).toEqual(
131+
expect.arrayContaining([
132+
'title',
133+
'overview',
134+
'decisions',
135+
'actionItems',
136+
'keyTopics',
137+
]),
138+
);
139+
// 중첩 배열 구조(actionItems/keyTopics)도 스키마에 명시한다.
140+
expect(schema.properties.actionItems.type).toBe('ARRAY');
141+
expect(schema.properties.keyTopics.type).toBe('ARRAY');
142+
});
143+
102144
it('응답 JSON 을 ReportSummary VO 로 변환해 돌려준다', async () => {
103145
const fetchMock = jest.fn().mockResolvedValueOnce(okResponse(validSummaryJson));
104146
const summarizer = new GeminiSummarizer(options, fetchMock as unknown as typeof fetch);

‎apps/backend/src/reports/infrastructure/gemini.summarizer.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,70 @@ export interface GeminiSummarizerOptions {
1010
readonly timeoutMs: number;
1111
}
1212

13+
/**
14+
* 회의록 요약은 창의적 생성이 아니라 입력 사실의 일관된 정제이므로,
15+
* Gemini default(1.0)보다 낮은 temperature 로 출력 변동을 줄인다.
16+
*/
17+
const SUMMARY_TEMPERATURE = 0.2;
18+
19+
/**
20+
* `generationConfig.responseSchema` (REST v1beta, OpenAPI-subset — type 은
21+
* 대문자 enum: OBJECT/STRING/ARRAY).
22+
*
23+
* 모델단에서 5필드(title/overview/decisions/actionItems/keyTopics) JSON 구조를
24+
* 강제해 candidates 텍스트가 항상 계약에 맞는 객체로 오게 한다 → 어댑터의
25+
* asString/asStringArray throw 빈도를 낮춘다(요약 실패 감소). `propertyOrdering`
26+
* 은 buildPrompt 의 Output format 필드 순서와 동일하게 둬 모델 혼동을 줄인다.
27+
* 문자열 본문의 한국어 작성·길이 제약은 프롬프트와 ReportSummary VO 가 계속
28+
* 책임진다(스키마는 형/존재만 강제).
29+
*/
30+
const SUMMARY_RESPONSE_SCHEMA = {
31+
type: 'OBJECT',
32+
properties: {
33+
title: { type: 'STRING' },
34+
overview: { type: 'STRING' },
35+
decisions: { type: 'ARRAY', items: { type: 'STRING' } },
36+
actionItems: {
37+
type: 'ARRAY',
38+
items: {
39+
type: 'OBJECT',
40+
properties: {
41+
owner: { type: 'STRING' },
42+
task: { type: 'STRING' },
43+
due: { type: 'STRING' },
44+
},
45+
required: ['task'],
46+
propertyOrdering: ['owner', 'task', 'due'],
47+
},
48+
},
49+
keyTopics: {
50+
type: 'ARRAY',
51+
items: {
52+
type: 'OBJECT',
53+
properties: {
54+
topic: { type: 'STRING' },
55+
points: { type: 'ARRAY', items: { type: 'STRING' } },
56+
},
57+
required: ['topic', 'points'],
58+
propertyOrdering: ['topic', 'points'],
59+
},
60+
},
61+
},
62+
required: ['title', 'overview', 'decisions', 'actionItems', 'keyTopics'],
63+
propertyOrdering: ['title', 'overview', 'decisions', 'actionItems', 'keyTopics'],
64+
} as const;
65+
1366
/**
1467
* Gemini `generateContent` REST API (v1beta) 기반 SummarizerPort 어댑터.
1568
*
1669
* POST {baseUrl}/v1beta/models/{model}:generateContent?key={apiKey}
1770
* body: {
1871
* contents: [{ role: 'user', parts: [{ text: <prompt> }] }],
19-
* generationConfig: { responseMimeType: 'application/json' }
72+
* generationConfig: {
73+
* responseMimeType: 'application/json',
74+
* temperature: <낮은 값 — 일관성>,
75+
* responseSchema: <5필드 구조 강제>
76+
* }
2077
* }
2178
*
2279
* 응답 candidates[0].content.parts[0].text 를 JSON.parse → reportSummary VO
@@ -48,7 +105,11 @@ export class GeminiSummarizer implements SummarizerPort {
48105
headers: { 'Content-Type': 'application/json' },
49106
body: JSON.stringify({
50107
contents: [{ role: 'user', parts: [{ text: prompt }] }],
51-
generationConfig: { responseMimeType: 'application/json' },
108+
generationConfig: {
109+
responseMimeType: 'application/json',
110+
temperature: SUMMARY_TEMPERATURE,
111+
responseSchema: SUMMARY_RESPONSE_SCHEMA,
112+
},
52113
}),
53114
signal: controller.signal,
54115
});

0 commit comments

Comments
 (0)