Spaces:
Runtime error
Runtime error
Jeongsoo1975
commited on
Commit
·
30ff654
1
Parent(s):
5798aca
feat: 주요 개선사항 적용 - 코드 재사용, 다운로드, 사용자 정의 화자명
Browse files- app.py +141 -38
- audio_summarizer.py +206 -413
- config.json +35 -0
- stt_processor.py +374 -149
app.py
CHANGED
@@ -67,12 +67,12 @@ def initialize_models():
|
|
67 |
logger.error(f"모델 초기화 실패: {e}")
|
68 |
return False, f"❌ 초기화 실패: {str(e)}"
|
69 |
|
70 |
-
def process_audio_file(audio_file, progress=gr.Progress()):
|
71 |
"""업로드된 오디오 파일을 처리합니다."""
|
72 |
global text_processor, whisper_model
|
73 |
|
74 |
if audio_file is None:
|
75 |
-
return "❌ 오디오 파일을 업로드해주세요.", "", "", "", "", ""
|
76 |
|
77 |
try:
|
78 |
# 모델 초기화 (필요한 경우)
|
@@ -80,7 +80,7 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
80 |
progress(0.05, desc="모델 초기화 중...")
|
81 |
success, message = initialize_models()
|
82 |
if not success:
|
83 |
-
return message, "", "", "", "", ""
|
84 |
|
85 |
# 오디오 파일 경로 확인
|
86 |
audio_path = audio_file.name if hasattr(audio_file, 'name') else str(audio_file)
|
@@ -94,7 +94,7 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
94 |
full_text = result['text'].strip()
|
95 |
|
96 |
if not full_text:
|
97 |
-
return "❌ 오디오에서 텍스트를 추출할 수 없습니다.", "", "", "", "", ""
|
98 |
|
99 |
language = result.get('language', 'unknown')
|
100 |
logger.info(f"음성 인식 완료. 언어: {language}, 텍스트 길이: {len(full_text)}")
|
@@ -111,10 +111,20 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
111 |
|
112 |
# 3단계: 텍스트 처리 (화자 분리 + 맞춤법 교정)
|
113 |
progress(0.4, desc="AI 화자 분리 및 맞춤법 교정 중...")
|
114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
115 |
|
116 |
if not text_result.get("success", False):
|
117 |
-
return f"❌ 텍스트 처리 실패: {text_result.get('error', 'Unknown error')}", full_text, "", "", "", ""
|
118 |
|
119 |
# 결과 추출
|
120 |
progress(0.95, desc="결과 정리 중...")
|
@@ -124,8 +134,21 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
124 |
|
125 |
# 화자별 대화 추출
|
126 |
conversations = text_result["conversations_by_speaker_corrected"]
|
127 |
-
|
128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
129 |
|
130 |
progress(1.0, desc="처리 완료!")
|
131 |
|
@@ -135,22 +158,22 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
135 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
136 |
- 감지된 언어: {language}
|
137 |
- 텍스트 길이: {len(full_text)}자
|
138 |
-
-
|
139 |
-
-
|
140 |
"""
|
141 |
|
142 |
-
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text
|
143 |
|
144 |
except Exception as e:
|
145 |
logger.error(f"오디오 파일 처리 중 오류: {e}")
|
146 |
-
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", ""
|
147 |
|
148 |
-
def process_text_input(input_text, progress=gr.Progress()):
|
149 |
"""입력된 텍스트를 처리합니다."""
|
150 |
global text_processor
|
151 |
|
152 |
if not input_text or not input_text.strip():
|
153 |
-
return "❌ 처리할 텍스트를 입력해주세요.", "", "", "", "", ""
|
154 |
|
155 |
try:
|
156 |
# 텍스트 프로세서만 초기화
|
@@ -158,14 +181,14 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
158 |
progress(0.1, desc="텍스트 프로세서 초기화 중...")
|
159 |
|
160 |
google_api_key = os.getenv("GOOGLE_API_KEY")
|
161 |
-
if not google_api_key:
|
162 |
-
return "❌ Google API 키가 설정되지 않았습니다.", "", "", "", "", ""
|
163 |
|
164 |
TextProcessor, processor_error = safe_import_processor()
|
165 |
if TextProcessor is None:
|
166 |
-
return f"❌ TextProcessor 로딩 실패: {processor_error}", "", "", "", "", ""
|
167 |
|
168 |
-
text_processor = TextProcessor(google_api_key)
|
169 |
|
170 |
# 모델 로딩
|
171 |
progress(0.2, desc="AI 모델 로딩 중...")
|
@@ -179,10 +202,20 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
179 |
|
180 |
# 텍스트 처리
|
181 |
progress(0.3, desc="텍스트 처리 시작...")
|
182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
183 |
|
184 |
if not result.get("success", False):
|
185 |
-
return f"❌ 처리 실패: {result.get('error', 'Unknown error')}", "", "", "", "", ""
|
186 |
|
187 |
# 결과 추출
|
188 |
progress(0.95, desc="결과 정리 중...")
|
@@ -192,8 +225,21 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
192 |
|
193 |
# 화자별 대화 추출
|
194 |
conversations = result["conversations_by_speaker_corrected"]
|
195 |
-
|
196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
197 |
|
198 |
progress(1.0, desc="처리 완료!")
|
199 |
|
@@ -201,15 +247,15 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
201 |
✅ **텍스트 처리 완료!**
|
202 |
- 텍스트명: {result['text_name']}
|
203 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
204 |
-
-
|
205 |
-
-
|
206 |
"""
|
207 |
|
208 |
-
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text
|
209 |
|
210 |
except Exception as e:
|
211 |
logger.error(f"텍스트 처리 중 오류: {e}")
|
212 |
-
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", ""
|
213 |
|
214 |
def create_interface():
|
215 |
"""Gradio 인터페이스를 생성합니다."""
|
@@ -229,6 +275,12 @@ def create_interface():
|
|
229 |
color: #2c3e50;
|
230 |
margin-bottom: 20px;
|
231 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
232 |
"""
|
233 |
|
234 |
with gr.Blocks(css=css, title="2인 대화 STT 처리기") as interface:
|
@@ -243,6 +295,24 @@ def create_interface():
|
|
243 |
|
244 |
with gr.Row():
|
245 |
with gr.Column(scale=1):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
246 |
# 입력 섹션
|
247 |
with gr.Tabs():
|
248 |
with gr.TabItem("🎤 오디오 업로드"):
|
@@ -273,9 +343,16 @@ def create_interface():
|
|
273 |
|
274 |
# 상태 표시
|
275 |
status_output = gr.Markdown(
|
276 |
-
"### 📊 처리 상태\n준비 완료. 오디오 파일을 업로드하거나 텍스트를
|
277 |
elem_classes=["status-box"]
|
278 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
279 |
|
280 |
with gr.Column(scale=2):
|
281 |
# 결과 표시 섹션
|
@@ -325,22 +402,32 @@ def create_interface():
|
|
325 |
### 📖 사용법
|
326 |
|
327 |
**🎤 오디오 파일 처리:**
|
328 |
-
1.
|
329 |
-
2.
|
330 |
-
3.
|
|
|
|
|
331 |
|
332 |
**📝 텍스트 직접 입력:**
|
333 |
-
1.
|
334 |
-
2.
|
335 |
-
3.
|
|
|
336 |
|
337 |
### ⚙️ 기술 정보
|
338 |
- **음성 인식**: OpenAI Whisper (다국어 지원)
|
339 |
-
- **화자 분리**: Google Gemini 2.0 Flash
|
340 |
- **맞춤법 교정**: 고급 AI 기반 한국어 교정
|
|
|
341 |
- **지원 형식**: WAV, MP3, MP4, M4A 등
|
342 |
- **최적 환경**: 2인 대화, 명확한 음질
|
343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
344 |
### ⚠️ 주의사항
|
345 |
- 처리 시간은 오디오 길이에 따라 달라집니다 (보통 1-5분)
|
346 |
- Google AI API 사용량 제한이 있을 수 있습니다
|
@@ -349,28 +436,44 @@ def create_interface():
|
|
349 |
- 배경 소음이 적고 화자 구분이 명확한 오디오를 권장합니다
|
350 |
""")
|
351 |
|
352 |
-
# 이벤트 연결
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
353 |
outputs = [
|
354 |
status_output,
|
355 |
original_output,
|
356 |
separated_output,
|
357 |
corrected_output,
|
358 |
speaker1_output,
|
359 |
-
speaker2_output
|
|
|
360 |
]
|
361 |
|
362 |
# 오디오 처리 이벤트
|
363 |
audio_process_btn.click(
|
364 |
fn=process_audio_file,
|
365 |
-
inputs=[audio_input],
|
366 |
outputs=outputs
|
|
|
|
|
|
|
|
|
367 |
)
|
368 |
|
369 |
# 텍스트 처리 이벤트
|
370 |
text_process_btn.click(
|
371 |
fn=process_text_input,
|
372 |
-
inputs=[text_input],
|
373 |
outputs=outputs
|
|
|
|
|
|
|
|
|
374 |
)
|
375 |
|
376 |
return interface
|
|
|
67 |
logger.error(f"모델 초기화 실패: {e}")
|
68 |
return False, f"❌ 초기화 실패: {str(e)}"
|
69 |
|
70 |
+
def process_audio_file(audio_file, speaker1_name, speaker2_name, progress=gr.Progress()):
|
71 |
"""업로드된 오디오 파일을 처리합니다."""
|
72 |
global text_processor, whisper_model
|
73 |
|
74 |
if audio_file is None:
|
75 |
+
return "❌ 오디오 파일을 업로드해주세요.", "", "", "", "", "", None
|
76 |
|
77 |
try:
|
78 |
# 모델 초기화 (필요한 경우)
|
|
|
80 |
progress(0.05, desc="모델 초기화 중...")
|
81 |
success, message = initialize_models()
|
82 |
if not success:
|
83 |
+
return message, "", "", "", "", "", None
|
84 |
|
85 |
# 오디오 파일 경로 확인
|
86 |
audio_path = audio_file.name if hasattr(audio_file, 'name') else str(audio_file)
|
|
|
94 |
full_text = result['text'].strip()
|
95 |
|
96 |
if not full_text:
|
97 |
+
return "❌ 오디오에서 텍스트를 추출할 수 없습니다.", "", "", "", "", "", None
|
98 |
|
99 |
language = result.get('language', 'unknown')
|
100 |
logger.info(f"음성 인식 완료. 언어: {language}, 텍스트 길이: {len(full_text)}")
|
|
|
111 |
|
112 |
# 3단계: 텍스트 처리 (화자 분리 + 맞춤법 교정)
|
113 |
progress(0.4, desc="AI 화자 분리 및 맞춤법 교정 중...")
|
114 |
+
|
115 |
+
# 사용자 정의 화자 이름 적용
|
116 |
+
custom_speaker1 = speaker1_name.strip() if speaker1_name and speaker1_name.strip() else None
|
117 |
+
custom_speaker2 = speaker2_name.strip() if speaker2_name and speaker2_name.strip() else None
|
118 |
+
|
119 |
+
text_result = text_processor.process_text(
|
120 |
+
full_text,
|
121 |
+
progress_callback=progress_callback,
|
122 |
+
speaker1_name=custom_speaker1,
|
123 |
+
speaker2_name=custom_speaker2
|
124 |
+
)
|
125 |
|
126 |
if not text_result.get("success", False):
|
127 |
+
return f"❌ 텍스트 처리 실패: {text_result.get('error', 'Unknown error')}", full_text, "", "", "", "", None
|
128 |
|
129 |
# 결과 추출
|
130 |
progress(0.95, desc="결과 정리 중...")
|
|
|
134 |
|
135 |
# 화자별 대화 추출
|
136 |
conversations = text_result["conversations_by_speaker_corrected"]
|
137 |
+
speaker1_key = custom_speaker1 or "화자1"
|
138 |
+
speaker2_key = custom_speaker2 or "화자2"
|
139 |
+
|
140 |
+
speaker1_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker1_key, []))])
|
141 |
+
speaker2_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker2_key, []))])
|
142 |
+
|
143 |
+
# 다운로드 파일 생성
|
144 |
+
download_file = None
|
145 |
+
try:
|
146 |
+
text_processor.save_results_to_files(text_result)
|
147 |
+
zip_path = text_processor.create_download_zip(text_result)
|
148 |
+
if zip_path and os.path.exists(zip_path):
|
149 |
+
download_file = zip_path
|
150 |
+
except Exception as e:
|
151 |
+
logger.warning(f"다운로드 파일 생성 실패: {e}")
|
152 |
|
153 |
progress(1.0, desc="처리 완료!")
|
154 |
|
|
|
158 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
159 |
- 감지된 언어: {language}
|
160 |
- 텍스트 길이: {len(full_text)}자
|
161 |
+
- {speaker1_key} 발언 수: {len(conversations.get(speaker1_key, []))}개
|
162 |
+
- {speaker2_key} 발언 수: {len(conversations.get(speaker2_key, []))}개
|
163 |
"""
|
164 |
|
165 |
+
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text, download_file
|
166 |
|
167 |
except Exception as e:
|
168 |
logger.error(f"오디오 파일 처리 중 오류: {e}")
|
169 |
+
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", "", None
|
170 |
|
171 |
+
def process_text_input(input_text, speaker1_name, speaker2_name, progress=gr.Progress()):
|
172 |
"""입력된 텍스트를 처리합니다."""
|
173 |
global text_processor
|
174 |
|
175 |
if not input_text or not input_text.strip():
|
176 |
+
return "❌ 처리할 텍스트를 입력해주세요.", "", "", "", "", "", None
|
177 |
|
178 |
try:
|
179 |
# 텍스트 프로세서만 초기화
|
|
|
181 |
progress(0.1, desc="텍스트 프로세서 초기화 중...")
|
182 |
|
183 |
google_api_key = os.getenv("GOOGLE_API_KEY")
|
184 |
+
if not google_api_key or not isinstance(google_api_key, str) or len(google_api_key.strip()) == 0:
|
185 |
+
return "❌ Google API 키가 설정되지 않았습니다.", "", "", "", "", "", None
|
186 |
|
187 |
TextProcessor, processor_error = safe_import_processor()
|
188 |
if TextProcessor is None:
|
189 |
+
return f"❌ TextProcessor 로딩 실패: {processor_error}", "", "", "", "", "", None
|
190 |
|
191 |
+
text_processor = TextProcessor(google_api_key.strip())
|
192 |
|
193 |
# 모델 로딩
|
194 |
progress(0.2, desc="AI 모델 로딩 중...")
|
|
|
202 |
|
203 |
# 텍스트 처리
|
204 |
progress(0.3, desc="텍스트 처리 시작...")
|
205 |
+
|
206 |
+
# 사용자 정의 화자 이름 적용
|
207 |
+
custom_speaker1 = speaker1_name.strip() if speaker1_name and speaker1_name.strip() else None
|
208 |
+
custom_speaker2 = speaker2_name.strip() if speaker2_name and speaker2_name.strip() else None
|
209 |
+
|
210 |
+
result = text_processor.process_text(
|
211 |
+
input_text,
|
212 |
+
progress_callback=progress_callback,
|
213 |
+
speaker1_name=custom_speaker1,
|
214 |
+
speaker2_name=custom_speaker2
|
215 |
+
)
|
216 |
|
217 |
if not result.get("success", False):
|
218 |
+
return f"❌ 처리 실패: {result.get('error', 'Unknown error')}", "", "", "", "", "", None
|
219 |
|
220 |
# 결과 추출
|
221 |
progress(0.95, desc="결과 정리 중...")
|
|
|
225 |
|
226 |
# 화자별 대화 추출
|
227 |
conversations = result["conversations_by_speaker_corrected"]
|
228 |
+
speaker1_key = custom_speaker1 or "화자1"
|
229 |
+
speaker2_key = custom_speaker2 or "화자2"
|
230 |
+
|
231 |
+
speaker1_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker1_key, []))])
|
232 |
+
speaker2_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker2_key, []))])
|
233 |
+
|
234 |
+
# 다운로드 파일 생성
|
235 |
+
download_file = None
|
236 |
+
try:
|
237 |
+
text_processor.save_results_to_files(result)
|
238 |
+
zip_path = text_processor.create_download_zip(result)
|
239 |
+
if zip_path and os.path.exists(zip_path):
|
240 |
+
download_file = zip_path
|
241 |
+
except Exception as e:
|
242 |
+
logger.warning(f"다운로드 파일 생성 실패: {e}")
|
243 |
|
244 |
progress(1.0, desc="처리 완료!")
|
245 |
|
|
|
247 |
✅ **텍스트 처리 완료!**
|
248 |
- 텍스트명: {result['text_name']}
|
249 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
250 |
+
- {speaker1_key} 발언 수: {len(conversations.get(speaker1_key, []))}개
|
251 |
+
- {speaker2_key} 발언 수: {len(conversations.get(speaker2_key, []))}개
|
252 |
"""
|
253 |
|
254 |
+
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text, download_file
|
255 |
|
256 |
except Exception as e:
|
257 |
logger.error(f"텍스트 처리 중 오류: {e}")
|
258 |
+
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", "", None
|
259 |
|
260 |
def create_interface():
|
261 |
"""Gradio 인터페이스를 생성합니다."""
|
|
|
275 |
color: #2c3e50;
|
276 |
margin-bottom: 20px;
|
277 |
}
|
278 |
+
.speaker-config {
|
279 |
+
background-color: #f8f9fa;
|
280 |
+
padding: 15px;
|
281 |
+
border-radius: 8px;
|
282 |
+
margin: 10px 0;
|
283 |
+
}
|
284 |
"""
|
285 |
|
286 |
with gr.Blocks(css=css, title="2인 대화 STT 처리기") as interface:
|
|
|
295 |
|
296 |
with gr.Row():
|
297 |
with gr.Column(scale=1):
|
298 |
+
# 화자 이름 설정
|
299 |
+
gr.HTML('<div class="speaker-config">')
|
300 |
+
gr.Markdown("### 👥 화자 이름 설정 (선택사항)")
|
301 |
+
with gr.Row():
|
302 |
+
speaker1_name = gr.Textbox(
|
303 |
+
label="화자1 이름",
|
304 |
+
placeholder="예: 김팀장, 홍길동 등 (비워두면 '화자1')",
|
305 |
+
value="",
|
306 |
+
scale=1
|
307 |
+
)
|
308 |
+
speaker2_name = gr.Textbox(
|
309 |
+
label="화자2 이름",
|
310 |
+
placeholder="예: 이대리, 김영희 등 (비워두면 '화자2')",
|
311 |
+
value="",
|
312 |
+
scale=1
|
313 |
+
)
|
314 |
+
gr.HTML('</div>')
|
315 |
+
|
316 |
# 입력 섹션
|
317 |
with gr.Tabs():
|
318 |
with gr.TabItem("🎤 오디오 업로드"):
|
|
|
343 |
|
344 |
# 상태 표시
|
345 |
status_output = gr.Markdown(
|
346 |
+
"### 📊 처리 상태\n준비 완료. 화자 이름을 설정하고 오디오 파일을 업로드하거나 텍스트를 입력한 후 처리 버튼을 클릭하세요.",
|
347 |
elem_classes=["status-box"]
|
348 |
)
|
349 |
+
|
350 |
+
# 다운로드 섹션
|
351 |
+
gr.Markdown("### 📥 결과 다운로드")
|
352 |
+
download_file = gr.File(
|
353 |
+
label="처리 결과 ZIP 파일",
|
354 |
+
visible=False
|
355 |
+
)
|
356 |
|
357 |
with gr.Column(scale=2):
|
358 |
# 결과 표시 섹션
|
|
|
402 |
### 📖 사용법
|
403 |
|
404 |
**🎤 오디오 파일 처리:**
|
405 |
+
1. **화자 이름 설정**: 원하는 화자 이름을 입력하세요 (예: 김팀장, 이대리)
|
406 |
+
2. **오디오 업로드**: WAV, MP3, MP4 등의 오디오 파일을 업로드하세요
|
407 |
+
3. **처리 시작**: '🚀 오디오 처리 시작' 버튼을 클릭하세요
|
408 |
+
4. **결과 확인**: 음성 인식 → 화자 분리 → 맞춤법 교정 순으로 처리됩니다
|
409 |
+
5. **다운로드**: 처리 완료 후 ZIP 파일로 모든 결과를 다운로드할 수 있습니다
|
410 |
|
411 |
**📝 텍스트 직접 입력:**
|
412 |
+
1. **화자 이름 설정**: 원하는 화자 이름을 입력하세요
|
413 |
+
2. **텍스트 입력**: 2인 대화 텍스트를 입력란에 붙여넣기하세요
|
414 |
+
3. **처리 시작**: '🚀 텍스트 처리 시작' 버튼을 클릭하세요
|
415 |
+
4. **결과 확인**: 각 탭에서 화자 분리 결과를 확인하세요
|
416 |
|
417 |
### ⚙️ 기술 정보
|
418 |
- **음성 인식**: OpenAI Whisper (다국어 지원)
|
419 |
+
- **화자 분리**: Google Gemini 2.0 Flash + AI 응답 검증
|
420 |
- **맞춤법 교정**: 고급 AI 기반 한국어 교정
|
421 |
+
- **청킹 처리**: 대용량 텍스트 자동 분할 처리
|
422 |
- **지원 형식**: WAV, MP3, MP4, M4A 등
|
423 |
- **최적 환경**: 2인 대화, 명확한 음질
|
424 |
|
425 |
+
### 🆕 새로운 기능
|
426 |
+
- **사용자 정의 화자 이름**: '화자1', '화자2' 대신 실제 이름 사용
|
427 |
+
- **다운로드 기능**: 전체 결과를 ZIP 파일로 다운로드
|
428 |
+
- **AI 응답 검증**: 화자 분리 실패 시 자동 감지 및 오류 처리
|
429 |
+
- **대용량 파일 지원**: 긴 오디오도 청킹으로 안정적 처리
|
430 |
+
|
431 |
### ⚠️ 주의사항
|
432 |
- 처리 시간은 오디오 길이에 따라 달라집니다 (보통 1-5분)
|
433 |
- Google AI API 사용량 제한이 있을 수 있습니다
|
|
|
436 |
- 배경 소음이 적고 화자 구분이 명확한 오디오를 권장합니다
|
437 |
""")
|
438 |
|
439 |
+
# 이벤트 연결 - 다운로드 파일 포함
|
440 |
+
def update_download_visibility(download_path):
|
441 |
+
"""다운로드 파일이 생성되면 표시합니다."""
|
442 |
+
if download_path and os.path.exists(download_path):
|
443 |
+
return gr.File(value=download_path, visible=True)
|
444 |
+
else:
|
445 |
+
return gr.File(visible=False)
|
446 |
+
|
447 |
outputs = [
|
448 |
status_output,
|
449 |
original_output,
|
450 |
separated_output,
|
451 |
corrected_output,
|
452 |
speaker1_output,
|
453 |
+
speaker2_output,
|
454 |
+
download_file
|
455 |
]
|
456 |
|
457 |
# 오디오 처리 이벤트
|
458 |
audio_process_btn.click(
|
459 |
fn=process_audio_file,
|
460 |
+
inputs=[audio_input, speaker1_name, speaker2_name],
|
461 |
outputs=outputs
|
462 |
+
).then(
|
463 |
+
fn=update_download_visibility,
|
464 |
+
inputs=[download_file],
|
465 |
+
outputs=[download_file]
|
466 |
)
|
467 |
|
468 |
# 텍스트 처리 이벤트
|
469 |
text_process_btn.click(
|
470 |
fn=process_text_input,
|
471 |
+
inputs=[text_input, speaker1_name, speaker2_name],
|
472 |
outputs=outputs
|
473 |
+
).then(
|
474 |
+
fn=update_download_visibility,
|
475 |
+
inputs=[download_file],
|
476 |
+
outputs=[download_file]
|
477 |
)
|
478 |
|
479 |
return interface
|
audio_summarizer.py
CHANGED
@@ -2,15 +2,12 @@ import tkinter as tk
|
|
2 |
from tkinter import scrolledtext, messagebox, ttk
|
3 |
import threading
|
4 |
import os
|
5 |
-
import torch
|
6 |
import whisper
|
7 |
-
import google.generativeai as genai
|
8 |
from dotenv import load_dotenv
|
9 |
import logging
|
10 |
-
import json
|
11 |
-
from datetime import datetime
|
12 |
import glob
|
13 |
-
import
|
|
|
14 |
|
15 |
# 환경 변수 로드
|
16 |
load_dotenv()
|
@@ -18,17 +15,10 @@ load_dotenv()
|
|
18 |
# --- 설정: .env 파일에서 API 키를 읽어옵니다 ---
|
19 |
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
|
20 |
|
21 |
-
#
|
22 |
-
|
23 |
-
os.
|
24 |
-
|
25 |
-
# output 폴더 생성
|
26 |
-
if not os.path.exists("output"):
|
27 |
-
os.makedirs("output")
|
28 |
-
|
29 |
-
# data 폴더 생성
|
30 |
-
if not os.path.exists("data"):
|
31 |
-
os.makedirs("data")
|
32 |
|
33 |
# 로깅 설정
|
34 |
logging.basicConfig(
|
@@ -41,451 +31,254 @@ logging.basicConfig(
|
|
41 |
)
|
42 |
logger = logging.getLogger(__name__)
|
43 |
|
44 |
-
# -----------------------------------------
|
45 |
-
|
46 |
class STTProcessorApp:
|
47 |
def __init__(self, root):
|
48 |
self.root = root
|
49 |
self.root.title("2인 대화 STT 처리기 (AI 화자 분리)")
|
50 |
self.root.geometry("1000x750")
|
51 |
-
|
52 |
-
# 모델
|
53 |
-
self.models_loaded = False
|
54 |
self.whisper_model = None
|
55 |
-
self.
|
56 |
-
|
57 |
-
# UI 요소 생성
|
58 |
-
self.main_frame = tk.Frame(root, padx=10, pady=10)
|
59 |
-
self.main_frame.pack(fill=tk.BOTH, expand=True)
|
60 |
-
|
61 |
-
# 제목
|
62 |
-
title_label = tk.Label(self.main_frame, text="2인 대화 STT 처리기 (AI 화자 분리)", font=("Arial", 16, "bold"))
|
63 |
-
title_label.pack(pady=5)
|
64 |
-
|
65 |
-
# 설명
|
66 |
-
desc_label = tk.Label(self.main_frame, text="Whisper STT + Gemini AI 화자 분리로 2명의 대화를 자동으로 구분합니다", font=("Arial", 10))
|
67 |
-
desc_label.pack(pady=2)
|
68 |
-
|
69 |
-
# WAV 파일 목록 프레임
|
70 |
-
files_frame = tk.LabelFrame(self.main_frame, text="data 폴더의 WAV 파일 목록", padx=5, pady=5)
|
71 |
-
files_frame.pack(fill=tk.BOTH, expand=True, pady=5)
|
72 |
-
|
73 |
-
# 파일 목록과 스크롤바
|
74 |
-
list_frame = tk.Frame(files_frame)
|
75 |
-
list_frame.pack(fill=tk.BOTH, expand=True)
|
76 |
-
|
77 |
-
scrollbar = tk.Scrollbar(list_frame)
|
78 |
-
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
79 |
-
|
80 |
-
self.file_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set, selectmode=tk.SINGLE)
|
81 |
-
self.file_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
82 |
-
scrollbar.config(command=self.file_listbox.yview)
|
83 |
-
|
84 |
-
# 버튼 프레임
|
85 |
-
button_frame = tk.Frame(self.main_frame)
|
86 |
-
button_frame.pack(fill=tk.X, pady=5)
|
87 |
-
|
88 |
-
self.refresh_button = tk.Button(button_frame, text="파일 목록 새로고침", command=self.refresh_file_list)
|
89 |
-
self.refresh_button.pack(side=tk.LEFT, padx=5)
|
90 |
-
|
91 |
-
self.process_button = tk.Button(button_frame, text="선택된 파일 처리", command=self.start_processing,
|
92 |
-
state=tk.DISABLED)
|
93 |
-
self.process_button.pack(side=tk.LEFT, padx=5)
|
94 |
-
|
95 |
-
self.process_all_button = tk.Button(button_frame, text="모든 파일 처리", command=self.start_processing_all,
|
96 |
-
state=tk.DISABLED)
|
97 |
-
self.process_all_button.pack(side=tk.LEFT, padx=5)
|
98 |
-
|
99 |
-
# 진행률 표시
|
100 |
-
progress_frame = tk.Frame(self.main_frame)
|
101 |
-
progress_frame.pack(fill=tk.X, pady=5)
|
102 |
-
|
103 |
-
tk.Label(progress_frame, text="진행률:").pack(side=tk.LEFT)
|
104 |
-
self.progress_var = tk.StringVar(value="대기 중")
|
105 |
-
tk.Label(progress_frame, textvariable=self.progress_var).pack(side=tk.LEFT, padx=10)
|
106 |
-
|
107 |
-
self.progress_bar = ttk.Progressbar(progress_frame, mode='determinate')
|
108 |
-
self.progress_bar.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=10)
|
109 |
-
|
110 |
-
# 상태 표시줄
|
111 |
-
self.status_label = tk.Label(self.main_frame, text="준비 완료. Google API 키를 설정하고 '처리' 버튼을 누르세요.", bd=1,
|
112 |
-
relief=tk.SUNKEN, anchor=tk.W)
|
113 |
-
self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
|
114 |
-
|
115 |
-
# 결과 출력 영역
|
116 |
-
result_frame = tk.LabelFrame(self.main_frame, text="처리 결과", padx=5, pady=5)
|
117 |
-
result_frame.pack(fill=tk.BOTH, expand=True, pady=5)
|
118 |
-
|
119 |
-
self.result_text = scrolledtext.ScrolledText(result_frame, wrap=tk.WORD, state=tk.DISABLED, height=15)
|
120 |
-
self.result_text.pack(fill=tk.BOTH, expand=True)
|
121 |
-
|
122 |
-
# 초기 파일 목록 로드
|
123 |
-
self.refresh_file_list()
|
124 |
-
|
125 |
-
def refresh_file_list(self):
|
126 |
-
"""data 폴더의 WAV 파일 목록을 새로고침합니다."""
|
127 |
-
self.file_listbox.delete(0, tk.END)
|
128 |
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
logger.info(f"{len(wav_files)}개의 WAV 파일을 발견했습니다.")
|
137 |
-
else:
|
138 |
-
self.file_listbox.insert(tk.END, "WAV 파일이 없습니다. data 폴더에 WAV 파일을 넣어주세요.")
|
139 |
-
self.process_button.config(state=tk.DISABLED)
|
140 |
-
self.process_all_button.config(state=tk.DISABLED)
|
141 |
-
logger.warning("data 폴더에 WAV 파일이 없습니다.")
|
142 |
|
143 |
-
def
|
144 |
-
"""UI
|
145 |
-
|
146 |
-
self.root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
147 |
|
148 |
-
def
|
149 |
-
"""
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
if message:
|
154 |
-
self.progress_var.set(f"{message} ({current}/{total})")
|
155 |
-
else:
|
156 |
-
self.progress_var.set(f"{current}/{total}")
|
157 |
-
self.root.update_idletasks()
|
158 |
|
159 |
-
def
|
160 |
-
"""
|
161 |
-
|
162 |
-
self.result_text.insert(tk.END, content + "\n\n")
|
163 |
-
self.result_text.see(tk.END)
|
164 |
-
self.result_text.config(state=tk.DISABLED)
|
165 |
|
166 |
def load_models(self):
|
167 |
-
"""
|
168 |
try:
|
|
|
|
|
|
|
|
|
169 |
if not GOOGLE_API_KEY or GOOGLE_API_KEY == "your_google_api_key_here":
|
170 |
-
|
171 |
-
|
172 |
-
return False
|
173 |
-
|
174 |
-
logger.info("모델 로딩을 시작합니다.")
|
175 |
-
self.update_status("모델 로딩 중... (최초 실행 시 시간이 걸릴 수 있습니다)")
|
176 |
-
|
177 |
# Whisper 모델 로딩
|
178 |
-
self.
|
179 |
-
|
180 |
-
self.
|
181 |
-
logger.info("Whisper 모델 로딩이 완료되었습니다.")
|
182 |
-
|
183 |
-
# Gemini 모델 설정
|
184 |
-
self.update_status("AI 화자 분리 모델(Gemini) 설정 중...")
|
185 |
-
logger.info("Gemini 모델 설정을 시작합니다.")
|
186 |
-
genai.configure(api_key=GOOGLE_API_KEY)
|
187 |
-
# gemini-2.0-flash: 최신 Gemini 2.0 모델, 빠르고 정확한 처리
|
188 |
-
self.gemini_model = genai.GenerativeModel('gemini-2.0-flash')
|
189 |
-
logger.info("Gemini 2.0 Flash 모델 설정이 완료되었습니다.")
|
190 |
-
|
191 |
-
self.models_loaded = True
|
192 |
-
self.update_status("모든 모델 로딩 완료. 처리 준비 완료.")
|
193 |
-
logger.info("모든 모델 로딩이 완료되었습니다.")
|
194 |
-
return True
|
195 |
-
except Exception as e:
|
196 |
-
error_msg = f"모델을 로딩하는 중 오류가 발생했습니다: {e}"
|
197 |
-
messagebox.showerror("모델 로딩 오류", error_msg)
|
198 |
-
logger.error(error_msg)
|
199 |
-
self.update_status("오류: 모델 로딩 실패")
|
200 |
-
return False
|
201 |
-
|
202 |
-
def start_processing(self):
|
203 |
-
"""선택된 파일 처리 시작."""
|
204 |
-
selection = self.file_listbox.curselection()
|
205 |
-
if not selection:
|
206 |
-
messagebox.showwarning("파일 미선택", "처리할 파일을 선택해주세요.")
|
207 |
-
return
|
208 |
-
|
209 |
-
filename = self.file_listbox.get(selection[0])
|
210 |
-
if filename == "WAV 파일이 없습니다. data 폴더에 WAV 파일을 넣어주세요.":
|
211 |
-
return
|
212 |
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
220 |
return
|
221 |
|
222 |
-
|
223 |
-
self.process_files(filenames)
|
224 |
-
|
225 |
-
def process_files(self, filenames):
|
226 |
-
"""파일 처리 시작."""
|
227 |
-
# 모델이 로드되지 않았으면 먼저 로드
|
228 |
-
if not self.models_loaded:
|
229 |
-
if not self.load_models():
|
230 |
-
return # 모델 로딩 실패 시 중단
|
231 |
-
|
232 |
-
# UI 비활성화 및 처리 스레드 시작
|
233 |
-
self.refresh_button.config(state=tk.DISABLED)
|
234 |
-
self.process_button.config(state=tk.DISABLED)
|
235 |
-
self.process_all_button.config(state=tk.DISABLED)
|
236 |
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
def process_audio_files(self, filenames):
|
241 |
-
"""백그라운드에서 여러 오디오 파일을 처리하는 메인 로직."""
|
242 |
try:
|
243 |
-
|
244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
245 |
|
246 |
-
|
247 |
-
|
248 |
-
self.
|
|
|
249 |
|
250 |
-
|
251 |
-
|
252 |
-
self.show_result(f"✅ {filename} 처리 완료")
|
253 |
else:
|
254 |
-
self.
|
|
|
|
|
|
|
255 |
|
256 |
-
self.update_progress(total_files, total_files, "완료")
|
257 |
-
self.update_status("모든 파일 처리 완료!")
|
258 |
-
logger.info("모든 파일 처리가 완료되었습니다.")
|
259 |
-
|
260 |
except Exception as e:
|
261 |
-
error_msg = f"파일 처리 중
|
262 |
-
|
263 |
-
self.update_status(
|
|
|
264 |
finally:
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
|
|
269 |
|
270 |
-
def process_single_audio_file(self, file_path
|
271 |
"""단일 오디오 파일을 처리합니다."""
|
272 |
try:
|
273 |
-
|
274 |
base_name = os.path.splitext(filename)[0]
|
275 |
|
276 |
-
|
277 |
-
self.update_status(f"1/4: 음성 인식 진행 중: {filename}")
|
278 |
-
logger.info(f"음성 인식 시작: {filename}")
|
279 |
|
|
|
|
|
280 |
result = self.whisper_model.transcribe(file_path)
|
281 |
full_text = result['text'].strip()
|
282 |
|
283 |
if not full_text:
|
284 |
-
|
285 |
return False
|
286 |
|
287 |
-
|
288 |
-
self.
|
289 |
-
logger.info(f"AI 화자 분리 시작: {filename}")
|
290 |
|
291 |
-
|
|
|
292 |
|
293 |
-
|
294 |
-
|
295 |
-
logger.info(f"맞춤법 교정 시작: {filename}")
|
296 |
|
297 |
-
|
|
|
|
|
|
|
|
|
298 |
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
except Exception as e:
|
307 |
-
logger.error(f"파일 {filename} 처리 중 오류: {e}")
|
308 |
-
return False
|
309 |
-
|
310 |
-
def separate_speakers_with_gemini(self, text):
|
311 |
-
"""Gemini API를 사용하여 텍스트를 화자별로 분리합니다."""
|
312 |
-
try:
|
313 |
-
prompt = f"""
|
314 |
-
당신은 2명의 화자가 나누는 대화를 분석하는 전문가입니다.
|
315 |
-
주어진 텍스트를 분석하여 각 발언을 화자별로 구분해주세요.
|
316 |
-
|
317 |
-
분석 지침:
|
318 |
-
1. 대화의 맥락과 내용을 기반으로 화자를 구분하세요
|
319 |
-
2. 말투, 주제 전환, 질문과 답변의 패턴을 활용하세요
|
320 |
-
3. 화자1과 화자2로 구분하여 표시하세요
|
321 |
-
4. 각 발언 앞에 [화자1] 또는 [화자2]를 붙여주세요
|
322 |
-
5. 발언이 너무 길 경우 자연스러운 지점에서 나누어주세요
|
323 |
-
|
324 |
-
출력 형식:
|
325 |
-
[화자1] 첫 번째 발언 내용
|
326 |
-
[화자2] 두 번째 발언 내용
|
327 |
-
[화자1] 세 번째 발언 내용
|
328 |
-
...
|
329 |
-
|
330 |
-
분석할 텍스트:
|
331 |
-
{text}
|
332 |
-
"""
|
333 |
-
|
334 |
-
response = self.gemini_model.generate_content(prompt)
|
335 |
-
separated_text = response.text.strip()
|
336 |
|
337 |
-
|
338 |
-
|
|
|
|
|
|
|
|
|
|
|
339 |
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
try:
|
347 |
-
prompt = f"""
|
348 |
-
당신은 한국어 맞춤법 교정 전문가입니다.
|
349 |
-
주어진 텍스트에서 맞춤법 오류, 띄어쓰기 오류, 오타를 수정해주세요.
|
350 |
-
|
351 |
-
교정 지침:
|
352 |
-
1. 자연스러운 한국어 표현으로 수정하되, 원본의 의미와 말투는 유지하세요
|
353 |
-
2. [화자1], [화자2] 태그는 그대로 유지하세요
|
354 |
-
3. 전문 용어나 고유명사는 가능한 정확하게 수정하세요
|
355 |
-
4. 구어체 특성은 유지하되, 명백한 오타만 수정하세요
|
356 |
-
5. 문맥에 맞는 올바른 단어로 교체하세요
|
357 |
-
|
358 |
-
수정이 필요한 예시:
|
359 |
-
- "치특기" → "치트키"
|
360 |
-
- "실점픽" → "실전 픽"
|
361 |
-
- "복사부천억" → "복사 붙여넣기"
|
362 |
-
- "핵심같이가" → "핵심 가치가"
|
363 |
-
- "재활" → "재활용"
|
364 |
-
- "저정할" → "저장할"
|
365 |
-
- "플레일" → "플레어"
|
366 |
-
- "서벌 수" → "서버리스"
|
367 |
-
- "커리" → "쿼리"
|
368 |
-
- "전력" → "전략"
|
369 |
-
- "클라클라" → "클라크"
|
370 |
-
- "가인만" → "가입만"
|
371 |
-
- "M5U" → "MAU"
|
372 |
-
- "나온 로도" → "다운로드"
|
373 |
-
- "무시무치" → "무시무시"
|
374 |
-
- "송신유금" → "송신 요금"
|
375 |
-
- "10지가" → "10GB"
|
376 |
-
- "유금" → "요금"
|
377 |
-
- "전 색을" → "전 세계"
|
378 |
-
- "도무원은" → "도구들은"
|
379 |
-
- "골차품데" → "골치 아픈데"
|
380 |
-
- "변원해" → "변환해"
|
381 |
-
- "f 운영" → "서비스 운영"
|
382 |
-
- "오류추저개" → "오류 추적기"
|
383 |
-
- "f 늘려질" → "서비스가 늘어날"
|
384 |
-
- "캐시칭" → "캐싱"
|
385 |
-
- "플레이어" → "플레어"
|
386 |
-
- "업스테시" → "업스태시"
|
387 |
-
- "원시근을" → "웬지슨"
|
388 |
-
- "부각이릉도" → "부각들도"
|
389 |
-
- "컴포넌트" → "컴포넌트"
|
390 |
-
- "본이터링" → "모니터링"
|
391 |
-
- "번뜨기는" → "번뜩이는"
|
392 |
-
- "사용적 경험" → "사용자 경험"
|
393 |
-
|
394 |
-
교정할 텍스트:
|
395 |
-
{separated_text}
|
396 |
-
"""
|
397 |
-
|
398 |
-
response = self.gemini_model.generate_content(prompt)
|
399 |
-
corrected_text = response.text.strip()
|
400 |
|
401 |
-
|
402 |
-
return corrected_text
|
403 |
|
404 |
except Exception as e:
|
405 |
-
|
406 |
-
return
|
407 |
-
|
408 |
-
def parse_separated_text(self, separated_text):
|
409 |
-
"""화자별로 분리된 텍스트를 파싱하여 구조화합니다."""
|
410 |
-
conversations = {
|
411 |
-
"화자1": [],
|
412 |
-
"화자2": []
|
413 |
-
}
|
414 |
-
|
415 |
-
# 정규표현식으로 화자별 발언 추출
|
416 |
-
pattern = r'\[화자([12])\]\s*(.+?)(?=\[화자[12]\]|$)'
|
417 |
-
matches = re.findall(pattern, separated_text, re.DOTALL)
|
418 |
-
|
419 |
-
for speaker_num, content in matches:
|
420 |
-
speaker = f"화자{speaker_num}"
|
421 |
-
content = content.strip()
|
422 |
-
if content:
|
423 |
-
conversations[speaker].append(content)
|
424 |
-
|
425 |
-
return conversations
|
426 |
-
|
427 |
-
def save_separated_conversations(self, base_name, original_text, separated_text, corrected_text, whisper_result):
|
428 |
-
"""화자별로 분리되고 맞춤법이 교정된 대화 내용을 파일로 저장합니다."""
|
429 |
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
430 |
-
|
431 |
-
# 교정된 텍스트에서 화자별 대화 파싱
|
432 |
-
corrected_conversations = self.parse_separated_text(corrected_text)
|
433 |
-
|
434 |
-
# 원본 화자별 대화 파싱 (비교용)
|
435 |
-
original_conversations = self.parse_separated_text(separated_text)
|
436 |
-
|
437 |
-
# 1. 전체 대화 저장 (원본, 화자 분리, 맞춤법 교정 포함)
|
438 |
-
all_txt_path = f"output/{base_name}_전체대화_{timestamp}.txt"
|
439 |
-
with open(all_txt_path, 'w', encoding='utf-8') as f:
|
440 |
-
f.write(f"파일명: {base_name}\n")
|
441 |
-
f.write(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
442 |
-
f.write(f"언어: {whisper_result.get('language', 'unknown')}\n")
|
443 |
-
f.write("="*50 + "\n\n")
|
444 |
-
f.write("원본 텍스트:\n")
|
445 |
-
f.write(original_text + "\n\n")
|
446 |
-
f.write("="*50 + "\n\n")
|
447 |
-
f.write("화자별 분리 결과 (원본):\n")
|
448 |
-
f.write(separated_text + "\n\n")
|
449 |
-
f.write("="*50 + "\n\n")
|
450 |
-
f.write("화자별 분리 결과 (맞춤법 교정):\n")
|
451 |
-
f.write(corrected_text + "\n")
|
452 |
-
|
453 |
-
# 2. 교정된 화자별 개별 파일 저장
|
454 |
-
for speaker, utterances in corrected_conversations.items():
|
455 |
-
if utterances:
|
456 |
-
speaker_txt_path = f"output/{base_name}_{speaker}_교정본_{timestamp}.txt"
|
457 |
-
with open(speaker_txt_path, 'w', encoding='utf-8') as f:
|
458 |
-
f.write(f"파일명: {base_name}\n")
|
459 |
-
f.write(f"화자: {speaker}\n")
|
460 |
-
f.write(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
461 |
-
f.write(f"발언 수: {len(utterances)}\n")
|
462 |
-
f.write("="*50 + "\n\n")
|
463 |
-
|
464 |
-
for idx, utterance in enumerate(utterances, 1):
|
465 |
-
f.write(f"{idx}. {utterance}\n\n")
|
466 |
-
|
467 |
-
# 3. JSON 형태로도 저장 (분석용)
|
468 |
-
json_path = f"output/{base_name}_data_{timestamp}.json"
|
469 |
-
json_data = {
|
470 |
-
"filename": base_name,
|
471 |
-
"processed_time": datetime.now().isoformat(),
|
472 |
-
"language": whisper_result.get("language", "unknown"),
|
473 |
-
"original_text": original_text,
|
474 |
-
"separated_text": separated_text,
|
475 |
-
"corrected_text": corrected_text,
|
476 |
-
"conversations_by_speaker_original": original_conversations,
|
477 |
-
"conversations_by_speaker_corrected": corrected_conversations,
|
478 |
-
"segments": whisper_result.get("segments", [])
|
479 |
-
}
|
480 |
-
|
481 |
-
with open(json_path, 'w', encoding='utf-8') as f:
|
482 |
-
json.dump(json_data, f, ensure_ascii=False, indent=2)
|
483 |
-
|
484 |
-
logger.info(f"결과 저장 완료: {all_txt_path}, {json_path}")
|
485 |
-
logger.info(f"교정된 화자별 파일도 저장되었습니다.")
|
486 |
-
|
487 |
|
488 |
-
|
|
|
489 |
root = tk.Tk()
|
490 |
app = STTProcessorApp(root)
|
491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
from tkinter import scrolledtext, messagebox, ttk
|
3 |
import threading
|
4 |
import os
|
|
|
5 |
import whisper
|
|
|
6 |
from dotenv import load_dotenv
|
7 |
import logging
|
|
|
|
|
8 |
import glob
|
9 |
+
from datetime import datetime
|
10 |
+
from stt_processor import TextProcessor
|
11 |
|
12 |
# 환경 변수 로드
|
13 |
load_dotenv()
|
|
|
15 |
# --- 설정: .env 파일에서 API 키를 읽어옵니다 ---
|
16 |
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
|
17 |
|
18 |
+
# 폴더 생성
|
19 |
+
for folder in ["logs", "output", "data"]:
|
20 |
+
if not os.path.exists(folder):
|
21 |
+
os.makedirs(folder)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
# 로깅 설정
|
24 |
logging.basicConfig(
|
|
|
31 |
)
|
32 |
logger = logging.getLogger(__name__)
|
33 |
|
|
|
|
|
34 |
class STTProcessorApp:
|
35 |
def __init__(self, root):
|
36 |
self.root = root
|
37 |
self.root.title("2인 대화 STT 처리기 (AI 화자 분리)")
|
38 |
self.root.geometry("1000x750")
|
39 |
+
|
40 |
+
# 모델 초기화
|
|
|
41 |
self.whisper_model = None
|
42 |
+
self.text_processor = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
|
44 |
+
# UI 구성 요소
|
45 |
+
self.setup_ui()
|
46 |
+
|
47 |
+
# 상태 추적
|
48 |
+
self.is_processing = False
|
49 |
+
|
50 |
+
logger.info("STT 처리기 앱이 시작되었습니다.")
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
|
52 |
+
def setup_ui(self):
|
53 |
+
"""UI 컴포넌트를 설정합니다."""
|
54 |
+
# 상단 프레임 - 상태 정보
|
55 |
+
status_frame = ttk.Frame(self.root)
|
56 |
+
status_frame.pack(fill=tk.X, padx=10, pady=5)
|
57 |
+
|
58 |
+
ttk.Label(status_frame, text="상태:").pack(side=tk.LEFT)
|
59 |
+
self.status_label = ttk.Label(status_frame, text="준비", foreground="green")
|
60 |
+
self.status_label.pack(side=tk.LEFT, padx=(5, 0))
|
61 |
+
|
62 |
+
# 중앙 프레임 - 로그 출력
|
63 |
+
log_frame = ttk.LabelFrame(self.root, text="처리 로그")
|
64 |
+
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
65 |
+
|
66 |
+
self.log_text = scrolledtext.ScrolledText(log_frame, height=25, wrap=tk.WORD)
|
67 |
+
self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
68 |
+
|
69 |
+
# 하단 프레임 - 컨트롤
|
70 |
+
control_frame = ttk.Frame(self.root)
|
71 |
+
control_frame.pack(fill=tk.X, padx=10, pady=5)
|
72 |
+
|
73 |
+
# 왼쪽: 모델 로딩 버튼
|
74 |
+
self.load_models_btn = ttk.Button(
|
75 |
+
control_frame,
|
76 |
+
text="모델 로딩",
|
77 |
+
command=self.load_models_threaded
|
78 |
+
)
|
79 |
+
self.load_models_btn.pack(side=tk.LEFT, padx=(0, 10))
|
80 |
+
|
81 |
+
# 중앙: 처리 버튼
|
82 |
+
self.process_btn = ttk.Button(
|
83 |
+
control_frame,
|
84 |
+
text="오디오 파일 처리 시작",
|
85 |
+
command=self.process_files_threaded,
|
86 |
+
state=tk.DISABLED
|
87 |
+
)
|
88 |
+
self.process_btn.pack(side=tk.LEFT, padx=(0, 10))
|
89 |
+
|
90 |
+
# 오른쪽: 종료 버튼
|
91 |
+
ttk.Button(
|
92 |
+
control_frame,
|
93 |
+
text="종료",
|
94 |
+
command=self.root.quit
|
95 |
+
).pack(side=tk.RIGHT)
|
96 |
+
|
97 |
+
# 진행률 표시
|
98 |
+
self.progress = ttk.Progressbar(
|
99 |
+
control_frame,
|
100 |
+
mode='indeterminate'
|
101 |
+
)
|
102 |
+
self.progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
|
103 |
+
|
104 |
+
def log(self, message):
|
105 |
+
"""로그 메시지를 UI에 출력합니다."""
|
106 |
+
def append_log():
|
107 |
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
108 |
+
self.log_text.insert(tk.END, f"[{timestamp}] {message}\n")
|
109 |
+
self.log_text.see(tk.END)
|
110 |
+
|
111 |
+
self.root.after(0, append_log)
|
112 |
+
logger.info(message)
|
113 |
|
114 |
+
def update_status(self, status, color="black"):
|
115 |
+
"""상태 라벨을 업데이트합니다."""
|
116 |
+
def update():
|
117 |
+
self.status_label.config(text=status, foreground=color)
|
118 |
+
self.root.after(0, update)
|
|
|
|
|
|
|
|
|
|
|
119 |
|
120 |
+
def load_models_threaded(self):
|
121 |
+
"""별도 스레드에서 모델을 로딩합니다."""
|
122 |
+
threading.Thread(target=self.load_models, daemon=True).start()
|
|
|
|
|
|
|
123 |
|
124 |
def load_models(self):
|
125 |
+
"""AI 모델들을 로딩합니다."""
|
126 |
try:
|
127 |
+
self.update_status("모델 로딩 중...", "orange")
|
128 |
+
self.log("AI 모델 로딩을 시작합니다...")
|
129 |
+
|
130 |
+
# API 키 검증
|
131 |
if not GOOGLE_API_KEY or GOOGLE_API_KEY == "your_google_api_key_here":
|
132 |
+
raise ValueError("Google API 키가 설정되지 않았습니다. .env 파일을 확인하세요.")
|
133 |
+
|
|
|
|
|
|
|
|
|
|
|
134 |
# Whisper 모델 로딩
|
135 |
+
self.log("Whisper 모델을 로딩합니다...")
|
136 |
+
self.whisper_model = whisper.load_model("base")
|
137 |
+
self.log("Whisper 모델 로딩 완료!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
138 |
|
139 |
+
# TextProcessor 초기화
|
140 |
+
self.log("Gemini 텍스트 프로세서를 초기화합니다...")
|
141 |
+
self.text_processor = TextProcessor(GOOGLE_API_KEY)
|
142 |
+
self.text_processor.load_models()
|
143 |
+
self.log("모든 모델이 성공적으로 로딩되었습니다!")
|
144 |
+
|
145 |
+
self.update_status("준비 완료", "green")
|
146 |
+
|
147 |
+
# 처리 버튼 활성화
|
148 |
+
def enable_button():
|
149 |
+
self.process_btn.config(state=tk.NORMAL)
|
150 |
+
self.root.after(0, enable_button)
|
151 |
+
|
152 |
+
except Exception as e:
|
153 |
+
error_msg = f"모델 로딩 실패: {str(e)}"
|
154 |
+
self.log(error_msg)
|
155 |
+
self.update_status("모델 로딩 실패", "red")
|
156 |
+
messagebox.showerror("오류", error_msg)
|
157 |
+
|
158 |
+
def process_files_threaded(self):
|
159 |
+
"""별도 스레드에서 파일을 처리합니다."""
|
160 |
+
if self.is_processing:
|
161 |
+
messagebox.showwarning("경고", "이미 처리 중입니다.")
|
162 |
return
|
163 |
|
164 |
+
threading.Thread(target=self.process_files, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
|
166 |
+
def process_files(self):
|
167 |
+
"""data 폴더의 모든 WAV 파일을 처리합니다."""
|
|
|
|
|
|
|
168 |
try:
|
169 |
+
self.is_processing = True
|
170 |
+
self.update_status("처리 중...", "orange")
|
171 |
+
|
172 |
+
# 진행률 표시 시작
|
173 |
+
def start_progress():
|
174 |
+
self.progress.start(10)
|
175 |
+
self.root.after(0, start_progress)
|
176 |
+
|
177 |
+
# WAV 파일 찾기
|
178 |
+
wav_files = glob.glob("data/*.wav")
|
179 |
+
if not wav_files:
|
180 |
+
self.log("data 폴더에 WAV 파일이 없습니다.")
|
181 |
+
return
|
182 |
+
|
183 |
+
self.log(f"{len(wav_files)}개의 WAV 파일을 발견했습니다.")
|
184 |
|
185 |
+
# 각 파일 처리
|
186 |
+
for i, wav_file in enumerate(wav_files):
|
187 |
+
self.log(f"\n=== 파일 처리 ({i+1}/{len(wav_files)}) ===")
|
188 |
+
success = self.process_single_audio_file(wav_file)
|
189 |
|
190 |
+
if success:
|
191 |
+
self.log(f"✅ {os.path.basename(wav_file)} 처리 완료")
|
|
|
192 |
else:
|
193 |
+
self.log(f"❌ {os.path.basename(wav_file)} 처리 실패")
|
194 |
+
|
195 |
+
self.log(f"\n모든 파일 처리 완료! 총 {len(wav_files)}개 파일")
|
196 |
+
self.update_status("처리 완료", "green")
|
197 |
|
|
|
|
|
|
|
|
|
198 |
except Exception as e:
|
199 |
+
error_msg = f"파일 처리 중 오류: {str(e)}"
|
200 |
+
self.log(error_msg)
|
201 |
+
self.update_status("처리 실패", "red")
|
202 |
+
|
203 |
finally:
|
204 |
+
self.is_processing = False
|
205 |
+
# 진행률 표시 중지
|
206 |
+
def stop_progress():
|
207 |
+
self.progress.stop()
|
208 |
+
self.root.after(0, stop_progress)
|
209 |
|
210 |
+
def process_single_audio_file(self, file_path):
|
211 |
"""단일 오디오 파일을 처리합니다."""
|
212 |
try:
|
213 |
+
filename = os.path.basename(file_path)
|
214 |
base_name = os.path.splitext(filename)[0]
|
215 |
|
216 |
+
self.log(f"파일 처리 시작: {filename}")
|
|
|
|
|
217 |
|
218 |
+
# 1단계: Whisper로 음성 인식
|
219 |
+
self.log("1/3: 음성 인식 진행 중...")
|
220 |
result = self.whisper_model.transcribe(file_path)
|
221 |
full_text = result['text'].strip()
|
222 |
|
223 |
if not full_text:
|
224 |
+
self.log(f"❌ 파일 {filename}에서 텍스트를 추출할 수 없습니다.")
|
225 |
return False
|
226 |
|
227 |
+
language = result.get('language', 'unknown')
|
228 |
+
self.log(f"음성 인식 완료 (언어: {language}, 길이: {len(full_text)}자)")
|
|
|
229 |
|
230 |
+
# 2단계: TextProcessor로 화자 분리 및 맞춤법 교정
|
231 |
+
self.log("2/3: AI 화자 분리 및 맞춤법 교정 진행 중...")
|
232 |
|
233 |
+
def progress_callback(status, current, total):
|
234 |
+
self.log(f" → {status} ({current}/{total})")
|
|
|
235 |
|
236 |
+
text_result = self.text_processor.process_text(
|
237 |
+
full_text,
|
238 |
+
text_name=base_name,
|
239 |
+
progress_callback=progress_callback
|
240 |
+
)
|
241 |
|
242 |
+
if not text_result.get("success", False):
|
243 |
+
self.log(f"❌ 텍스트 처리 실패: {text_result.get('error', 'Unknown error')}")
|
244 |
+
return False
|
245 |
|
246 |
+
# 3단계: 결과 저장
|
247 |
+
self.log("3/3: 결과 저장 중...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
248 |
|
249 |
+
# 기존 결과에 Whisper 정보 추가
|
250 |
+
enhanced_result = text_result.copy()
|
251 |
+
enhanced_result.update({
|
252 |
+
"base_name": base_name,
|
253 |
+
"language": language,
|
254 |
+
"whisper_segments": result.get("segments", [])
|
255 |
+
})
|
256 |
|
257 |
+
# 파일 저장
|
258 |
+
saved = self.text_processor.save_results_to_files(enhanced_result)
|
259 |
+
if saved:
|
260 |
+
self.log("결과 파일 저장 완료!")
|
261 |
+
else:
|
262 |
+
self.log("⚠️ 결과 파일 저장 중 일부 오류 발생")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
263 |
|
264 |
+
return True
|
|
|
265 |
|
266 |
except Exception as e:
|
267 |
+
self.log(f"❌ 파일 {filename} 처리 중 오류: {str(e)}")
|
268 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
269 |
|
270 |
+
def main():
|
271 |
+
"""메인 함수"""
|
272 |
root = tk.Tk()
|
273 |
app = STTProcessorApp(root)
|
274 |
+
|
275 |
+
try:
|
276 |
+
root.mainloop()
|
277 |
+
except KeyboardInterrupt:
|
278 |
+
logger.info("사용자에 의해 프로그램이 종료되었습니다.")
|
279 |
+
except Exception as e:
|
280 |
+
logger.error(f"예상치 못한 오류: {e}")
|
281 |
+
messagebox.showerror("오류", f"예상치 못한 오류가 발생했습니다: {str(e)}")
|
282 |
+
|
283 |
+
if __name__ == "__main__":
|
284 |
+
main()
|
config.json
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"models": {
|
3 |
+
"whisper": {
|
4 |
+
"name": "base",
|
5 |
+
"options": {
|
6 |
+
"language": null,
|
7 |
+
"task": "transcribe"
|
8 |
+
}
|
9 |
+
},
|
10 |
+
"gemini": {
|
11 |
+
"name": "gemini-2.0-flash",
|
12 |
+
"temperature": 0.3,
|
13 |
+
"max_tokens": null
|
14 |
+
}
|
15 |
+
},
|
16 |
+
"prompts": {
|
17 |
+
"speaker_separation": "당신은 2명의 화자가 나누는 대화를 분석하는 전문가입니다. \n주어진 텍스트를 분석하여 각 발언을 화자별로 구분해주세요.\n\n분석 지침:\n1. 대화의 맥락과 내용을 기반으로 화자를 구분하세요\n2. 말투, 주제 전환, 질문과 답변의 패턴을 활용하세요\n3. 화자1과 화자2로 구분하여 표시하세요\n4. 각 발언 앞에 [화자1] 또는 [화자2]를 붙여주세요\n5. 발언이 너무 길 경우 자연스러운 지점에서 나누어주세요\n\n출력 형식:\n[화자1] 첫 번째 발언 내용\n[화자2] 두 번째 발언 내용\n[화자1] 세 번째 발언 내용\n...\n\n분석할 텍스트:\n{text}",
|
18 |
+
|
19 |
+
"spell_correction": "당신은 한국어 맞춤법 교정 전문가입니다. \n주어진 텍스트에서 맞춤법 오류, 띄어쓰기 오류, 오타를 수정해주세요.\n\n교정 지침:\n1. 자연스러운 한국어 표현으로 수정하되, 원본의 의미와 말투는 유지하세요\n2. [화자1], [화자2] 태그는 그대로 유지하세요\n3. 전문 용어나 고유명사는 가능한 정확하게 수정하세요\n4. 구어체 특성은 유지하되, 명백한 오타만 수정하세요\n5. 문맥에 맞는 올바른 단어로 교체하세요\n\n수정이 필요한 예시:\n- \"치특기\" → \"치트키\"\n- \"실점픽\" → \"실전 픽\"\n- \"복사부천억\" → \"복사 붙여넣기\"\n- \"핵심같이가\" → \"핵심 가치가\"\n- \"재활\" → \"재활용\"\n- \"저정할\" → \"저장할\"\n- \"플레일\" → \"플레어\"\n- \"서벌 수\" → \"서버리스\"\n- \"커리\" → \"쿼리\"\n- \"전력\" → \"전략\"\n- \"클라클라\" → \"클라크\"\n- \"가인만\" → \"가입만\"\n- \"M5U\" → \"MAU\"\n- \"나온 로도\" → \"다운로드\"\n- \"무시무치\" → \"무시무시\"\n- \"송신유금\" → \"송신 요금\"\n- \"10지가\" → \"10GB\"\n- \"유금\" → \"요금\"\n- \"전 색을\" → \"전 세계\"\n- \"도무원은\" → \"도구들은\"\n- \"골차품데\" → \"골치 아픈데\"\n- \"변원해\" → \"변환해\"\n- \"f 운영\" → \"서비스 운영\"\n- \"오류추저개\" → \"오류 추적기\"\n- \"f 늘려질\" → \"서비스가 늘어날\"\n- \"캐시칭\" → \"캐싱\"\n- \"플레이어\" → \"플레어\"\n- \"업스테시\" → \"업스태시\"\n- \"원시근을\" → \"웬지슨\"\n- \"부각이릉도\" → \"부각들도\"\n- \"컴포넌트\" → \"컴포넌트\"\n- \"본이터링\" → \"모니터링\"\n- \"번뜨기는\" → \"번뜩이는\"\n- \"사용적 경험\" → \"사용자 경험\"\n\n교정할 텍스트:\n{text}"
|
20 |
+
},
|
21 |
+
"processing": {
|
22 |
+
"chunk_size": 20000,
|
23 |
+
"enable_chunking": true,
|
24 |
+
"validate_ai_response": true,
|
25 |
+
"required_speaker_tags": ["[화자1]", "[화자2]"]
|
26 |
+
},
|
27 |
+
"output": {
|
28 |
+
"save_original": true,
|
29 |
+
"save_separated": true,
|
30 |
+
"save_corrected": true,
|
31 |
+
"save_individual_speakers": true,
|
32 |
+
"save_json": true,
|
33 |
+
"create_download_zip": true
|
34 |
+
}
|
35 |
+
}
|
stt_processor.py
CHANGED
@@ -5,6 +5,8 @@ import logging
|
|
5 |
import json
|
6 |
from datetime import datetime
|
7 |
import re
|
|
|
|
|
8 |
|
9 |
# 환경 변수 로드
|
10 |
load_dotenv()
|
@@ -17,12 +19,13 @@ class TextProcessor:
|
|
17 |
텍스트를 AI를 통한 화자 분리 및 맞춤법 교정을 수행하는 클래스
|
18 |
"""
|
19 |
|
20 |
-
def __init__(self, google_api_key=None):
|
21 |
"""
|
22 |
TextProcessor 초기화
|
23 |
|
24 |
Args:
|
25 |
google_api_key (str): Google AI API 키. None인 경우 환경 변수에서 읽음
|
|
|
26 |
"""
|
27 |
# API 키 안전하게 가져오기
|
28 |
if google_api_key:
|
@@ -33,6 +36,9 @@ class TextProcessor:
|
|
33 |
self.gemini_model = None
|
34 |
self.models_loaded = False
|
35 |
|
|
|
|
|
|
|
36 |
# API 키 검증 - 더 안전한 체크
|
37 |
if (self.google_api_key is None or
|
38 |
not isinstance(self.google_api_key, str) or
|
@@ -40,15 +46,55 @@ class TextProcessor:
|
|
40 |
self.google_api_key.strip() == "your_google_api_key_here"):
|
41 |
raise ValueError("Google AI API 키가 설정되지 않았습니다. 환경 변수 GOOGLE_API_KEY를 설정하거나 매개변수로 전달하세요.")
|
42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
def load_models(self):
|
44 |
"""Gemini AI 모델을 로드합니다."""
|
45 |
try:
|
46 |
logger.info("Gemini 모델 로딩을 시작합니다.")
|
47 |
|
|
|
|
|
|
|
48 |
# Gemini 모델 설정
|
49 |
genai.configure(api_key=self.google_api_key)
|
50 |
-
self.gemini_model = genai.GenerativeModel(
|
51 |
-
logger.info("
|
52 |
|
53 |
self.models_loaded = True
|
54 |
logger.info("Gemini 모델 로딩이 완료되었습니다.")
|
@@ -59,7 +105,74 @@ class TextProcessor:
|
|
59 |
logger.error(error_msg)
|
60 |
raise Exception(error_msg)
|
61 |
|
62 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
"""
|
64 |
텍스트를 처리하여 화자 분리 및 맞춤법 교정을 수행합니다.
|
65 |
|
@@ -67,6 +180,8 @@ class TextProcessor:
|
|
67 |
input_text (str): 처리할 텍스트
|
68 |
text_name (str): 텍스트 이름 (선택사항)
|
69 |
progress_callback (function): 진행 상황을 알려주는 콜백 함수
|
|
|
|
|
70 |
|
71 |
Returns:
|
72 |
dict: 처리 결과 딕셔너리
|
@@ -84,42 +199,14 @@ class TextProcessor:
|
|
84 |
|
85 |
full_text = input_text.strip()
|
86 |
|
87 |
-
#
|
88 |
-
|
89 |
-
|
90 |
-
logger.info(f"AI 화자 분리 시작: {text_name}")
|
91 |
-
|
92 |
-
speaker_separated_text = self.separate_speakers_with_gemini(full_text)
|
93 |
-
|
94 |
-
# 2단계: 맞춤법 교정
|
95 |
-
if progress_callback:
|
96 |
-
progress_callback("맞춤법 교정 중...", 2, 3)
|
97 |
-
logger.info(f"맞춤법 교정 시작: {text_name}")
|
98 |
-
|
99 |
-
corrected_text = self.correct_spelling_with_gemini(speaker_separated_text)
|
100 |
-
|
101 |
-
# 3단계: 결과 파싱
|
102 |
-
if progress_callback:
|
103 |
-
progress_callback("결과 정리 중...", 3, 3)
|
104 |
-
|
105 |
-
# 교정된 텍스트에서 화자별 대화 파싱
|
106 |
-
corrected_conversations = self.parse_separated_text(corrected_text)
|
107 |
-
original_conversations = self.parse_separated_text(speaker_separated_text)
|
108 |
-
|
109 |
-
# 결과 딕셔너리 생성
|
110 |
-
processing_result = {
|
111 |
-
"text_name": text_name,
|
112 |
-
"processed_time": datetime.now().isoformat(),
|
113 |
-
"original_text": full_text,
|
114 |
-
"separated_text": speaker_separated_text,
|
115 |
-
"corrected_text": corrected_text,
|
116 |
-
"conversations_by_speaker_original": original_conversations,
|
117 |
-
"conversations_by_speaker_corrected": corrected_conversations,
|
118 |
-
"success": True
|
119 |
-
}
|
120 |
|
121 |
-
|
122 |
-
|
|
|
|
|
123 |
|
124 |
except Exception as e:
|
125 |
logger.error(f"텍스트 {text_name} 처리 중 ��류: {e}")
|
@@ -129,30 +216,154 @@ class TextProcessor:
|
|
129 |
"error": str(e)
|
130 |
}
|
131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
132 |
def separate_speakers_with_gemini(self, text):
|
133 |
"""Gemini API를 사용하여 텍스트를 화자별로 분리합니다."""
|
134 |
try:
|
135 |
-
prompt =
|
136 |
-
|
137 |
-
주어진 텍스트를 분석하여 각 발언을 화자별로 구분해주세요.
|
138 |
-
|
139 |
-
분석 지침:
|
140 |
-
1. 대화의 맥락과 내용을 기반으로 화자를 구분하세요
|
141 |
-
2. 말투, 주제 전환, 질문과 답변의 패턴을 활용하세요
|
142 |
-
3. 화자1과 화자2로 구분하여 표시하세요
|
143 |
-
4. 각 발언 앞에 [화자1] 또는 [화자2]를 붙여주세요
|
144 |
-
5. 발언이 너무 길 경우 자연스러운 지점에서 나누어주세요
|
145 |
-
|
146 |
-
출력 형식:
|
147 |
-
[화자1] 첫 번째 발언 내용
|
148 |
-
[화자2] 두 번째 발언 내용
|
149 |
-
[화자1] 세 번째 발언 내용
|
150 |
-
...
|
151 |
-
|
152 |
-
분석할 텍스트:
|
153 |
-
{text}
|
154 |
-
"""
|
155 |
-
|
156 |
response = self.gemini_model.generate_content(prompt)
|
157 |
separated_text = response.text.strip()
|
158 |
|
@@ -166,57 +377,8 @@ class TextProcessor:
|
|
166 |
def correct_spelling_with_gemini(self, separated_text):
|
167 |
"""Gemini API를 사용하여 화자별 분리된 텍스트의 맞춤법을 교정합니다."""
|
168 |
try:
|
169 |
-
prompt =
|
170 |
-
|
171 |
-
주어진 텍스트에서 맞춤법 오류, 띄어쓰기 오류, 오타를 수정해주세요.
|
172 |
-
|
173 |
-
교정 지침:
|
174 |
-
1. 자연스러운 한국어 표현으로 수정하되, 원본의 의미와 말투는 유지하세요
|
175 |
-
2. [화자1], [화자2] 태그는 그대로 유지하세요
|
176 |
-
3. 전문 용어나 고유명사는 가능한 정확하게 수정하세요
|
177 |
-
4. 구어체 특성은 유지하되, 명백한 오타만 수정하세요
|
178 |
-
5. 문맥에 맞는 올바른 단어로 교체하세요
|
179 |
-
|
180 |
-
수정이 필요한 예시:
|
181 |
-
- "치특기" → "치트키"
|
182 |
-
- "실점픽" → "실전 픽"
|
183 |
-
- "복사부천억" → "복사 붙여넣기"
|
184 |
-
- "핵심같이가" → "핵심 가치가"
|
185 |
-
- "재활" → "재활용"
|
186 |
-
- "저정할" → "저장할"
|
187 |
-
- "플레일" → "플레어"
|
188 |
-
- "서벌 수" → "서버리스"
|
189 |
-
- "커리" → "쿼리"
|
190 |
-
- "전력" → "전략"
|
191 |
-
- "클라클라" → "클라크"
|
192 |
-
- "가인만" → "가입만"
|
193 |
-
- "M5U" → "MAU"
|
194 |
-
- "나온 로도" → "다운로드"
|
195 |
-
- "무시무치" → "무시무시"
|
196 |
-
- "송신유금" → "송신 요금"
|
197 |
-
- "10지가" → "10GB"
|
198 |
-
- "유금" → "요금"
|
199 |
-
- "전 색을" → "전 세계"
|
200 |
-
- "도무원은" → "도구들은"
|
201 |
-
- "골차품데" → "골치 아픈데"
|
202 |
-
- "변원해" → "변환해"
|
203 |
-
- "f 운영" → "서비스 운영"
|
204 |
-
- "오류추저개" → "오류 추적기"
|
205 |
-
- "f 늘려질" → "서비스가 늘어날"
|
206 |
-
- "캐시칭" → "캐싱"
|
207 |
-
- "플레이어" → "플레어"
|
208 |
-
- "업스테시" → "업스태시"
|
209 |
-
- "원시근을" → "웬지슨"
|
210 |
-
- "부각이릉도" → "부각들도"
|
211 |
-
- "컴포넌트" → "컴포넌트"
|
212 |
-
- "본이터링" → "모니터링"
|
213 |
-
- "번뜨기는" → "번뜩이는"
|
214 |
-
- "사용적 경험" → "사용자 경험"
|
215 |
-
|
216 |
-
교정할 텍스트:
|
217 |
-
{separated_text}
|
218 |
-
"""
|
219 |
-
|
220 |
response = self.gemini_model.generate_content(prompt)
|
221 |
corrected_text = response.text.strip()
|
222 |
|
@@ -246,6 +408,72 @@ class TextProcessor:
|
|
246 |
|
247 |
return conversations
|
248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
249 |
def save_results_to_files(self, result, output_dir="output"):
|
250 |
"""처리 결과를 파일로 저장합니다."""
|
251 |
if not result.get("success", False):
|
@@ -257,47 +485,44 @@ class TextProcessor:
|
|
257 |
if not os.path.exists(output_dir):
|
258 |
os.makedirs(output_dir)
|
259 |
|
260 |
-
base_name = result["
|
261 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
262 |
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
f
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
with open(json_path, 'w', encoding='utf-8') as f:
|
296 |
-
json.dump(result, f, ensure_ascii=False, indent=2)
|
297 |
-
|
298 |
-
logger.info(f"결과 파일 저장 완료: {output_dir}")
|
299 |
return True
|
300 |
|
301 |
except Exception as e:
|
302 |
logger.error(f"결과 파일 저장 중 오류: {e}")
|
303 |
-
return False
|
|
|
5 |
import json
|
6 |
from datetime import datetime
|
7 |
import re
|
8 |
+
import tempfile
|
9 |
+
import zipfile
|
10 |
|
11 |
# 환경 변수 로드
|
12 |
load_dotenv()
|
|
|
19 |
텍스트를 AI를 통한 화자 분리 및 맞춤법 교정을 수행하는 클래스
|
20 |
"""
|
21 |
|
22 |
+
def __init__(self, google_api_key=None, config_path="config.json"):
|
23 |
"""
|
24 |
TextProcessor 초기화
|
25 |
|
26 |
Args:
|
27 |
google_api_key (str): Google AI API 키. None인 경우 환경 변수에서 읽음
|
28 |
+
config_path (str): 설정 파일 경로
|
29 |
"""
|
30 |
# API 키 안전하게 가져오기
|
31 |
if google_api_key:
|
|
|
36 |
self.gemini_model = None
|
37 |
self.models_loaded = False
|
38 |
|
39 |
+
# 설정 파일 로드
|
40 |
+
self.config = self.load_config(config_path)
|
41 |
+
|
42 |
# API 키 검증 - 더 안전한 체크
|
43 |
if (self.google_api_key is None or
|
44 |
not isinstance(self.google_api_key, str) or
|
|
|
46 |
self.google_api_key.strip() == "your_google_api_key_here"):
|
47 |
raise ValueError("Google AI API 키가 설정되지 않았습니다. 환경 변수 GOOGLE_API_KEY를 설정하거나 매개변수로 전달하세요.")
|
48 |
|
49 |
+
def load_config(self, config_path):
|
50 |
+
"""설정 파일을 로드합니다."""
|
51 |
+
try:
|
52 |
+
if os.path.exists(config_path):
|
53 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
54 |
+
config = json.load(f)
|
55 |
+
logger.info(f"설정 파일 로드 완료: {config_path}")
|
56 |
+
return config
|
57 |
+
else:
|
58 |
+
logger.warning(f"설정 파일을 찾을 수 없습니다: {config_path}. 기본 설정을 사용합니다.")
|
59 |
+
return self.get_default_config()
|
60 |
+
except Exception as e:
|
61 |
+
logger.error(f"설정 파일 로드 실패: {e}. 기본 설정을 사용합니다.")
|
62 |
+
return self.get_default_config()
|
63 |
+
|
64 |
+
def get_default_config(self):
|
65 |
+
"""기본 설정을 반환합니다."""
|
66 |
+
return {
|
67 |
+
"models": {
|
68 |
+
"gemini": {"name": "gemini-2.0-flash", "temperature": 0.3}
|
69 |
+
},
|
70 |
+
"processing": {
|
71 |
+
"chunk_size": 20000,
|
72 |
+
"enable_chunking": True,
|
73 |
+
"validate_ai_response": True,
|
74 |
+
"required_speaker_tags": ["[화자1]", "[화자2]"]
|
75 |
+
},
|
76 |
+
"output": {
|
77 |
+
"save_original": True,
|
78 |
+
"save_separated": True,
|
79 |
+
"save_corrected": True,
|
80 |
+
"save_individual_speakers": True,
|
81 |
+
"save_json": True,
|
82 |
+
"create_download_zip": True
|
83 |
+
}
|
84 |
+
}
|
85 |
+
|
86 |
def load_models(self):
|
87 |
"""Gemini AI 모델을 로드합니다."""
|
88 |
try:
|
89 |
logger.info("Gemini 모델 로딩을 시작합니다.")
|
90 |
|
91 |
+
# 설정에서 모델 이름 가져오기
|
92 |
+
model_name = self.config.get("models", {}).get("gemini", {}).get("name", "gemini-2.0-flash")
|
93 |
+
|
94 |
# Gemini 모델 설정
|
95 |
genai.configure(api_key=self.google_api_key)
|
96 |
+
self.gemini_model = genai.GenerativeModel(model_name)
|
97 |
+
logger.info(f"{model_name} 모델 설정이 완료되었습니다.")
|
98 |
|
99 |
self.models_loaded = True
|
100 |
logger.info("Gemini 모델 로딩이 완료되었습니다.")
|
|
|
105 |
logger.error(error_msg)
|
106 |
raise Exception(error_msg)
|
107 |
|
108 |
+
def split_text_into_chunks(self, text, chunk_size=None):
|
109 |
+
"""텍스트를 청크로 분할합니다."""
|
110 |
+
if chunk_size is None:
|
111 |
+
chunk_size = self.config.get("processing", {}).get("chunk_size", 20000)
|
112 |
+
|
113 |
+
if len(text) <= chunk_size:
|
114 |
+
return [text]
|
115 |
+
|
116 |
+
chunks = []
|
117 |
+
sentences = re.split(r'[.!?。!?]\s+', text)
|
118 |
+
current_chunk = ""
|
119 |
+
|
120 |
+
for sentence in sentences:
|
121 |
+
if len(current_chunk) + len(sentence) <= chunk_size:
|
122 |
+
current_chunk += sentence + ". "
|
123 |
+
else:
|
124 |
+
if current_chunk:
|
125 |
+
chunks.append(current_chunk.strip())
|
126 |
+
current_chunk = sentence + ". "
|
127 |
+
|
128 |
+
if current_chunk:
|
129 |
+
chunks.append(current_chunk.strip())
|
130 |
+
|
131 |
+
logger.info(f"텍스트를 {len(chunks)}개 청크로 분할했습니다.")
|
132 |
+
return chunks
|
133 |
+
|
134 |
+
def validate_ai_response(self, response_text, expected_tags=None):
|
135 |
+
"""AI 응답의 유효성을 검증합니다."""
|
136 |
+
if not self.config.get("processing", {}).get("validate_ai_response", True):
|
137 |
+
return True, "검증 비활성화됨"
|
138 |
+
|
139 |
+
if expected_tags is None:
|
140 |
+
expected_tags = self.config.get("processing", {}).get("required_speaker_tags", ["[화자1]", "[화자2]"])
|
141 |
+
|
142 |
+
# 응답이 비어있는지 확인
|
143 |
+
if not response_text or not response_text.strip():
|
144 |
+
return False, "AI 응답이 비어 있습니다."
|
145 |
+
|
146 |
+
# 필요한 태그가 포함되어 있는지 확인
|
147 |
+
found_tags = []
|
148 |
+
for tag in expected_tags:
|
149 |
+
if tag in response_text:
|
150 |
+
found_tags.append(tag)
|
151 |
+
|
152 |
+
if not found_tags:
|
153 |
+
return False, f"화자 태그({', '.join(expected_tags)})가 응답에 포함되지 않았습니다."
|
154 |
+
|
155 |
+
if len(found_tags) < 2:
|
156 |
+
return False, f"최소 2개의 화자 태그가 필요하지만 {len(found_tags)}개만 발견되었습니다."
|
157 |
+
|
158 |
+
return True, f"검증 성공: {', '.join(found_tags)} 태그 발견"
|
159 |
+
|
160 |
+
def get_prompt(self, prompt_type, **kwargs):
|
161 |
+
"""설정에서 프롬프트를 가져와 포맷팅합니다."""
|
162 |
+
prompts = self.config.get("prompts", {})
|
163 |
+
|
164 |
+
if prompt_type == "speaker_separation":
|
165 |
+
template = prompts.get("speaker_separation",
|
166 |
+
"당신은 2명의 화자가 나누는 대화를 분석하는 전문가입니다. 주어진 텍스트를 화자별로 분리해주세요.\n\n분석할 텍스트:\n{text}")
|
167 |
+
elif prompt_type == "spell_correction":
|
168 |
+
template = prompts.get("spell_correction",
|
169 |
+
"한국어 맞춤법을 교정해주세요. [화자1], [화자2] 태그는 유지하세요.\n\n교정할 텍스트:\n{text}")
|
170 |
+
else:
|
171 |
+
raise ValueError(f"알 수 없는 프롬프트 타입: {prompt_type}")
|
172 |
+
|
173 |
+
return template.format(**kwargs)
|
174 |
+
|
175 |
+
def process_text(self, input_text, text_name=None, progress_callback=None, speaker1_name=None, speaker2_name=None):
|
176 |
"""
|
177 |
텍스트를 처리하여 화자 분리 및 맞춤법 교정을 수행합니다.
|
178 |
|
|
|
180 |
input_text (str): 처리할 텍스트
|
181 |
text_name (str): 텍스트 이름 (선택사항)
|
182 |
progress_callback (function): 진행 상황을 알려주는 콜백 함수
|
183 |
+
speaker1_name (str): 화자1의 사용자 정의 이름
|
184 |
+
speaker2_name (str): 화자2의 사용자 정의 이름
|
185 |
|
186 |
Returns:
|
187 |
dict: 처리 결과 딕셔너리
|
|
|
199 |
|
200 |
full_text = input_text.strip()
|
201 |
|
202 |
+
# 청킹 여부 결정
|
203 |
+
enable_chunking = self.config.get("processing", {}).get("enable_chunking", True)
|
204 |
+
chunk_size = self.config.get("processing", {}).get("chunk_size", 20000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
205 |
|
206 |
+
if enable_chunking and len(full_text) > chunk_size:
|
207 |
+
return self.process_text_with_chunking(full_text, text_name, progress_callback, speaker1_name, speaker2_name)
|
208 |
+
else:
|
209 |
+
return self.process_text_single(full_text, text_name, progress_callback, speaker1_name, speaker2_name)
|
210 |
|
211 |
except Exception as e:
|
212 |
logger.error(f"텍스트 {text_name} 처리 중 ��류: {e}")
|
|
|
216 |
"error": str(e)
|
217 |
}
|
218 |
|
219 |
+
def process_text_single(self, full_text, text_name, progress_callback, speaker1_name, speaker2_name):
|
220 |
+
"""단일 텍스트를 처리합니다."""
|
221 |
+
# 1단계: Gemini로 화자 분리
|
222 |
+
if progress_callback:
|
223 |
+
progress_callback("AI 화자 분리 중...", 1, 3)
|
224 |
+
logger.info(f"AI 화자 분리 시작: {text_name}")
|
225 |
+
|
226 |
+
speaker_separated_text = self.separate_speakers_with_gemini(full_text)
|
227 |
+
|
228 |
+
# AI 응답 검증
|
229 |
+
is_valid, validation_msg = self.validate_ai_response(speaker_separated_text)
|
230 |
+
if not is_valid:
|
231 |
+
raise ValueError(f"화자 분리 실패: {validation_msg}")
|
232 |
+
|
233 |
+
logger.info(f"화자 분리 검증 완료: {validation_msg}")
|
234 |
+
|
235 |
+
# 2단계: 맞춤법 교정
|
236 |
+
if progress_callback:
|
237 |
+
progress_callback("맞춤법 교정 중...", 2, 3)
|
238 |
+
logger.info(f"맞춤법 교정 시작: {text_name}")
|
239 |
+
|
240 |
+
corrected_text = self.correct_spelling_with_gemini(speaker_separated_text)
|
241 |
+
|
242 |
+
# 3단계: 결과 파싱 및 사용자 정의 이름 적용
|
243 |
+
if progress_callback:
|
244 |
+
progress_callback("결과 정리 중...", 3, 3)
|
245 |
+
|
246 |
+
# 교정된 텍스트에서 화자별 대화 파싱
|
247 |
+
corrected_conversations = self.parse_separated_text(corrected_text)
|
248 |
+
original_conversations = self.parse_separated_text(speaker_separated_text)
|
249 |
+
|
250 |
+
# 사용자 정의 화자 이름 적용
|
251 |
+
if speaker1_name or speaker2_name:
|
252 |
+
corrected_conversations, corrected_text = self.apply_custom_speaker_names(
|
253 |
+
corrected_conversations, corrected_text, speaker1_name, speaker2_name)
|
254 |
+
original_conversations, speaker_separated_text = self.apply_custom_speaker_names(
|
255 |
+
original_conversations, speaker_separated_text, speaker1_name, speaker2_name)
|
256 |
+
|
257 |
+
# 결과 딕셔너리 생성
|
258 |
+
processing_result = {
|
259 |
+
"text_name": text_name,
|
260 |
+
"processed_time": datetime.now().isoformat(),
|
261 |
+
"original_text": full_text,
|
262 |
+
"separated_text": speaker_separated_text,
|
263 |
+
"corrected_text": corrected_text,
|
264 |
+
"conversations_by_speaker_original": original_conversations,
|
265 |
+
"conversations_by_speaker_corrected": corrected_conversations,
|
266 |
+
"speaker1_name": speaker1_name or "화자1",
|
267 |
+
"speaker2_name": speaker2_name or "화자2",
|
268 |
+
"success": True
|
269 |
+
}
|
270 |
+
|
271 |
+
logger.info(f"텍스트 처리 완료: {text_name}")
|
272 |
+
return processing_result
|
273 |
+
|
274 |
+
def process_text_with_chunking(self, full_text, text_name, progress_callback, speaker1_name, speaker2_name):
|
275 |
+
"""청킹을 사용하여 대용량 텍스트를 처리합니다."""
|
276 |
+
logger.info(f"대용량 텍스트 청킹 처리 시작: {text_name}")
|
277 |
+
|
278 |
+
chunks = self.split_text_into_chunks(full_text)
|
279 |
+
total_steps = len(chunks) * 2 # 화자 분리 + 맞춤법 교정
|
280 |
+
current_step = 0
|
281 |
+
|
282 |
+
separated_chunks = []
|
283 |
+
corrected_chunks = []
|
284 |
+
|
285 |
+
# 각 청크 처리
|
286 |
+
for i, chunk in enumerate(chunks):
|
287 |
+
# 화자 분리
|
288 |
+
current_step += 1
|
289 |
+
if progress_callback:
|
290 |
+
progress_callback(f"청크 {i+1}/{len(chunks)} 화자 분리 중...", current_step, total_steps)
|
291 |
+
|
292 |
+
separated_chunk = self.separate_speakers_with_gemini(chunk)
|
293 |
+
|
294 |
+
# AI 응답 검증
|
295 |
+
is_valid, validation_msg = self.validate_ai_response(separated_chunk)
|
296 |
+
if not is_valid:
|
297 |
+
logger.warning(f"청크 {i+1} 화자 분리 검증 실패: {validation_msg}")
|
298 |
+
# 검증 실패한 청크는 원본을 사용하되 기본 태그 추가
|
299 |
+
separated_chunk = f"[화자1] {chunk}"
|
300 |
+
|
301 |
+
separated_chunks.append(separated_chunk)
|
302 |
+
|
303 |
+
# 맞춤법 교정
|
304 |
+
current_step += 1
|
305 |
+
if progress_callback:
|
306 |
+
progress_callback(f"청크 {i+1}/{len(chunks)} 맞춤법 교정 중...", current_step, total_steps)
|
307 |
+
|
308 |
+
corrected_chunk = self.correct_spelling_with_gemini(separated_chunk)
|
309 |
+
corrected_chunks.append(corrected_chunk)
|
310 |
+
|
311 |
+
# 청크들을 다시 합치기
|
312 |
+
speaker_separated_text = "\n\n".join(separated_chunks)
|
313 |
+
corrected_text = "\n\n".join(corrected_chunks)
|
314 |
+
|
315 |
+
# 결과 파싱 및 사용자 정의 이름 적용
|
316 |
+
corrected_conversations = self.parse_separated_text(corrected_text)
|
317 |
+
original_conversations = self.parse_separated_text(speaker_separated_text)
|
318 |
+
|
319 |
+
if speaker1_name or speaker2_name:
|
320 |
+
corrected_conversations, corrected_text = self.apply_custom_speaker_names(
|
321 |
+
corrected_conversations, corrected_text, speaker1_name, speaker2_name)
|
322 |
+
original_conversations, speaker_separated_text = self.apply_custom_speaker_names(
|
323 |
+
original_conversations, speaker_separated_text, speaker1_name, speaker2_name)
|
324 |
+
|
325 |
+
processing_result = {
|
326 |
+
"text_name": text_name,
|
327 |
+
"processed_time": datetime.now().isoformat(),
|
328 |
+
"original_text": full_text,
|
329 |
+
"separated_text": speaker_separated_text,
|
330 |
+
"corrected_text": corrected_text,
|
331 |
+
"conversations_by_speaker_original": original_conversations,
|
332 |
+
"conversations_by_speaker_corrected": corrected_conversations,
|
333 |
+
"speaker1_name": speaker1_name or "화자1",
|
334 |
+
"speaker2_name": speaker2_name or "화자2",
|
335 |
+
"chunks_processed": len(chunks),
|
336 |
+
"success": True
|
337 |
+
}
|
338 |
+
|
339 |
+
logger.info(f"청킹 처리 완료: {text_name} ({len(chunks)}개 청크)")
|
340 |
+
return processing_result
|
341 |
+
|
342 |
+
def apply_custom_speaker_names(self, conversations, text, speaker1_name, speaker2_name):
|
343 |
+
"""사용자 정의 화자 이름을 적용합니다."""
|
344 |
+
updated_conversations = {}
|
345 |
+
updated_text = text
|
346 |
+
|
347 |
+
# 대화 딕셔너리 업데이트
|
348 |
+
if speaker1_name:
|
349 |
+
updated_conversations[speaker1_name] = conversations.get("화자1", [])
|
350 |
+
updated_text = updated_text.replace("[화자1]", f"[{speaker1_name}]")
|
351 |
+
else:
|
352 |
+
updated_conversations["화자1"] = conversations.get("화자1", [])
|
353 |
+
|
354 |
+
if speaker2_name:
|
355 |
+
updated_conversations[speaker2_name] = conversations.get("화자2", [])
|
356 |
+
updated_text = updated_text.replace("[화자2]", f"[{speaker2_name}]")
|
357 |
+
else:
|
358 |
+
updated_conversations["화자2"] = conversations.get("화자2", [])
|
359 |
+
|
360 |
+
return updated_conversations, updated_text
|
361 |
+
|
362 |
def separate_speakers_with_gemini(self, text):
|
363 |
"""Gemini API를 사용하여 텍스트를 화자별로 분리합니다."""
|
364 |
try:
|
365 |
+
prompt = self.get_prompt("speaker_separation", text=text)
|
366 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
367 |
response = self.gemini_model.generate_content(prompt)
|
368 |
separated_text = response.text.strip()
|
369 |
|
|
|
377 |
def correct_spelling_with_gemini(self, separated_text):
|
378 |
"""Gemini API를 사용하여 화자별 분리된 텍스트의 맞춤법을 교정합니다."""
|
379 |
try:
|
380 |
+
prompt = self.get_prompt("spell_correction", text=separated_text)
|
381 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
382 |
response = self.gemini_model.generate_content(prompt)
|
383 |
corrected_text = response.text.strip()
|
384 |
|
|
|
408 |
|
409 |
return conversations
|
410 |
|
411 |
+
def create_download_zip(self, result, output_dir="output"):
|
412 |
+
"""처리 결과를 ZIP 파일로 생성합니다."""
|
413 |
+
try:
|
414 |
+
if not self.config.get("output", {}).get("create_download_zip", True):
|
415 |
+
return None
|
416 |
+
|
417 |
+
base_name = result["text_name"]
|
418 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
419 |
+
zip_path = os.path.join(output_dir, f"{base_name}_complete_{timestamp}.zip")
|
420 |
+
|
421 |
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
422 |
+
# 전체 대화 저장
|
423 |
+
all_content = self._generate_complete_text_content(result)
|
424 |
+
zipf.writestr(f"{base_name}_전체대화_{timestamp}.txt", all_content)
|
425 |
+
|
426 |
+
# 화자별 개별 파일
|
427 |
+
for speaker, utterances in result['conversations_by_speaker_corrected'].items():
|
428 |
+
if utterances:
|
429 |
+
speaker_content = self._generate_speaker_content(result, speaker, utterances)
|
430 |
+
zipf.writestr(f"{base_name}_{speaker}_교정본_{timestamp}.txt", speaker_content)
|
431 |
+
|
432 |
+
# JSON 데이터
|
433 |
+
json_content = json.dumps(result, ensure_ascii=False, indent=2)
|
434 |
+
zipf.writestr(f"{base_name}_data_{timestamp}.json", json_content)
|
435 |
+
|
436 |
+
logger.info(f"ZIP 파일 생성 완료: {zip_path}")
|
437 |
+
return zip_path
|
438 |
+
|
439 |
+
except Exception as e:
|
440 |
+
logger.error(f"ZIP 파일 생성 중 오류: {e}")
|
441 |
+
return None
|
442 |
+
|
443 |
+
def _generate_complete_text_content(self, result):
|
444 |
+
"""전체 대화 텍스트 내용을 생성합니다."""
|
445 |
+
content = []
|
446 |
+
content.append(f"파일명: {result['text_name']}")
|
447 |
+
content.append(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
448 |
+
content.append(f"화자1: {result.get('speaker1_name', '화자1')}")
|
449 |
+
content.append(f"화자2: {result.get('speaker2_name', '화자2')}")
|
450 |
+
content.append("="*50)
|
451 |
+
content.append("원본 텍스트:")
|
452 |
+
content.append(result['original_text'])
|
453 |
+
content.append("="*50)
|
454 |
+
content.append("화자별 분리 결과 (원본):")
|
455 |
+
content.append(result['separated_text'])
|
456 |
+
content.append("="*50)
|
457 |
+
content.append("화자별 분리 결과 (맞춤법 교정):")
|
458 |
+
content.append(result['corrected_text'])
|
459 |
+
|
460 |
+
return "\n".join(content)
|
461 |
+
|
462 |
+
def _generate_speaker_content(self, result, speaker, utterances):
|
463 |
+
"""화자별 개별 파일 내용을 생성합니다."""
|
464 |
+
content = []
|
465 |
+
content.append(f"파일명: {result['text_name']}")
|
466 |
+
content.append(f"화자: {speaker}")
|
467 |
+
content.append(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
468 |
+
content.append(f"발언 수: {len(utterances)}")
|
469 |
+
content.append("="*50)
|
470 |
+
|
471 |
+
for idx, utterance in enumerate(utterances, 1):
|
472 |
+
content.append(f"{idx}. {utterance}")
|
473 |
+
content.append("")
|
474 |
+
|
475 |
+
return "\n".join(content)
|
476 |
+
|
477 |
def save_results_to_files(self, result, output_dir="output"):
|
478 |
"""처리 결과를 파일로 저장합니다."""
|
479 |
if not result.get("success", False):
|
|
|
485 |
if not os.path.exists(output_dir):
|
486 |
os.makedirs(output_dir)
|
487 |
|
488 |
+
base_name = result["text_name"]
|
489 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
490 |
+
saved_files = []
|
491 |
|
492 |
+
output_config = self.config.get("output", {})
|
493 |
+
|
494 |
+
# 1. 전체 대화 저장
|
495 |
+
if output_config.get("save_original", True) or output_config.get("save_separated", True) or output_config.get("save_corrected", True):
|
496 |
+
all_txt_path = f"{output_dir}/{base_name}_전체대화_{timestamp}.txt"
|
497 |
+
with open(all_txt_path, 'w', encoding='utf-8') as f:
|
498 |
+
f.write(self._generate_complete_text_content(result))
|
499 |
+
saved_files.append(all_txt_path)
|
500 |
+
|
501 |
+
# 2. 화자별 개별 파일 저장
|
502 |
+
if output_config.get("save_individual_speakers", True):
|
503 |
+
for speaker, utterances in result['conversations_by_speaker_corrected'].items():
|
504 |
+
if utterances:
|
505 |
+
speaker_txt_path = f"{output_dir}/{base_name}_{speaker}_교정본_{timestamp}.txt"
|
506 |
+
with open(speaker_txt_path, 'w', encoding='utf-8') as f:
|
507 |
+
f.write(self._generate_speaker_content(result, speaker, utterances))
|
508 |
+
saved_files.append(speaker_txt_path)
|
509 |
+
|
510 |
+
# 3. JSON 형태로도 저장
|
511 |
+
if output_config.get("save_json", True):
|
512 |
+
json_path = f"{output_dir}/{base_name}_data_{timestamp}.json"
|
513 |
+
with open(json_path, 'w', encoding='utf-8') as f:
|
514 |
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
515 |
+
saved_files.append(json_path)
|
516 |
+
|
517 |
+
# 4. ZIP 파일 생성
|
518 |
+
zip_path = self.create_download_zip(result, output_dir)
|
519 |
+
if zip_path:
|
520 |
+
saved_files.append(zip_path)
|
521 |
+
|
522 |
+
logger.info(f"결과 파일 저장 완료: {len(saved_files)}개 파일")
|
523 |
+
result["saved_files"] = saved_files
|
|
|
|
|
|
|
|
|
524 |
return True
|
525 |
|
526 |
except Exception as e:
|
527 |
logger.error(f"결과 파일 저장 중 오류: {e}")
|
528 |
+
return False
|