mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-12 22:59:10 +00:00
AI 제안사항 Hallucination 문제 해결 및 추출 개선
주요 변경사항:
1. AI 서비스 설정
- claude_max_tokens: 8192 → 25000으로 증가 (회의록 통합을 위한 충분한 토큰 확보)
- AI 서비스 타임아웃: 30초 → 60초로 증가
2. 프롬프트 개선 (consolidate_prompt.py)
- JSON 생성 전문가 역할 추가
- JSON 이스케이프 규칙 명시 (큰따옴표, 줄바꿈, 역슬래시)
- Markdown 볼드체(**) 제거하여 JSON 파싱 오류 방지
- 문자열 검증 지시사항 추가
3. JSON 파싱 개선 (claude_service.py)
- 4단계 재시도 전략 구현:
* 이스케이프되지 않은 개행 문자 자동 수정
* strict=False 옵션으로 파싱
* 잘린 응답 복구 시도
* 제어 문자 제거 후 재시도
- 디버깅 로깅 강화 (Input/Output Tokens, Stop Reason)
- 파싱 실패 시 전체 응답을 파일로 저장
4. 회의 종료 로직 개선 (EndMeetingService.java)
- 통합 회의록 생성 또는 조회 로직 추가 (userId=NULL)
- Minutes 테이블에 전체 결정사항 저장
- AgendaSection에 minutesId 정확히 매핑
5. 테스트 데이터 추가
- AI 회의록 요약 테스트용 SQL 스크립트 작성
- 3명 참석자, 3개 안건의 현실적인 회의 시나리오
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ class Settings(BaseSettings):
|
||||
# Claude API
|
||||
claude_api_key: str = "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"
|
||||
claude_model: str = "claude-sonnet-4-5-20250929"
|
||||
claude_max_tokens: int = 8192 # 4096 → 8192 증가 (더 많은 제안사항 생성 가능)
|
||||
claude_max_tokens: int = 25000 # 회의록 통합을 위해 25000으로 증가
|
||||
claude_temperature: float = 0.7
|
||||
|
||||
# Redis
|
||||
|
||||
@@ -19,7 +19,9 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
f"{i+1}. {agenda}" for i, agenda in enumerate(agendas)
|
||||
])
|
||||
|
||||
prompt = f"""당신은 회의록 작성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
|
||||
prompt = f"""당신은 회의록 작성 전문가이며 JSON 생성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
|
||||
|
||||
**매우 중요**: 응답은 반드시 유효한 JSON 형식이어야 합니다. 문자열 내의 모든 특수문자를 올바르게 이스케이프해야 합니다.
|
||||
|
||||
# 입력 데이터
|
||||
|
||||
@@ -40,7 +42,11 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
3. **회의 전체 결정사항 (decisions)**:
|
||||
- 회의 전체에서 최종 결정된 사항들을 TEXT 형식으로 정리
|
||||
- 안건별 결정사항을 모두 포함하여 회의록 수정 페이지에서 사용자가 확인 및 수정할 수 있도록 작성
|
||||
- 형식: "**안건1 결정사항:**\n- 결정1\n- 결정2\n\n**안건2 결정사항:**\n- 결정3"
|
||||
- 형식: "안건1 결정사항:\n- 결정1\n- 결정2\n\n안건2 결정사항:\n- 결정3"
|
||||
- **JSON 이스케이프 필수**:
|
||||
* 큰따옴표(")는 반드시 제거하거나 작은따옴표(')로 대체
|
||||
* 줄바꿈은 \\n으로 이스케이프
|
||||
* 역슬래시(\\)는 \\\\로 이스케이프
|
||||
|
||||
4. **안건별 요약 (agenda_summaries)**:
|
||||
회의 내용을 분석하여 안건별로 구조화:
|
||||
@@ -51,8 +57,9 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
- **summary_short**: AI가 생성한 1줄 요약 (20자 이내, 사용자 수정 불가)
|
||||
- **summary**: 안건별 회의록 요약 (논의사항과 결정사항 모두 포함)
|
||||
* 회의록 수정 페이지에서 사용자가 수정할 수 있는 입력 필드
|
||||
* 형식: "**논의 사항:**\n- 논의내용1\n- 논의내용2\n\n**결정 사항:**\n- 결정1\n- 결정2"
|
||||
* 형식: "논의 사항:\n- 논의내용1\n- 논의내용2\n\n결정 사항:\n- 결정1\n- 결정2"
|
||||
* 사용자가 자유롭게 편집할 수 있도록 구조화된 텍스트로 작성
|
||||
* **JSON 이스케이프 필수**: 큰따옴표(")는 제거하거나 작은따옴표(')로 대체, 줄바꿈은 \\n으로 표현
|
||||
- **decisions**: 안건별 결정사항 배열 (대시보드 표시용, summary의 결정사항 부분을 배열로 추출)
|
||||
* 형식: ["결정사항1", "결정사항2", "결정사항3"]
|
||||
* 회의에서 최종 결정된 사항만 포함
|
||||
@@ -71,22 +78,28 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
|
||||
# 출력 형식
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.
|
||||
**매우 중요 - JSON 생성 규칙**:
|
||||
1. JSON 외의 다른 텍스트는 절대 포함하지 마세요
|
||||
2. 문자열 내부의 큰따옴표(")는 반드시 작은따옴표(')로 대체하세요
|
||||
3. 문자열 내부의 줄바꿈은 반드시 \\n으로 이스케이프하세요
|
||||
4. 역슬래시(\\)는 \\\\로 이스케이프하세요
|
||||
5. 모든 문자열이 끝까지 올바르게 닫혀 있는지 확인하세요
|
||||
6. 각 문자열 값이 유효한 JSON 문자열인지 검증하세요
|
||||
|
||||
```json
|
||||
{{
|
||||
"keywords": ["키워드1", "키워드2", "키워드3"],
|
||||
"statistics": {{
|
||||
"agendas_count": 숫자,
|
||||
"todos_count": 숫자
|
||||
"agendas_count": 2,
|
||||
"todos_count": 3
|
||||
}},
|
||||
"decisions": "**안건1 결정사항:**\\n- 결정1\\n- 결정2\\n\\n**안건2 결정사항:**\\n- 결정3",
|
||||
"decisions": "안건1 결정사항:\\n- 결정1\\n- 결정2\\n\\n안건2 결정사항:\\n- 결정3",
|
||||
"agenda_summaries": [
|
||||
{{
|
||||
"agenda_number": 1,
|
||||
"agenda_title": "안건 제목",
|
||||
"summary_short": "짧은 요약 (20자 이내)",
|
||||
"summary": "**논의 사항:**\\n- 논의내용1\\n- 논의내용2\\n\\n**결정 사항:**\\n- 결정1\\n- 결정2",
|
||||
"summary_short": "짧은 요약",
|
||||
"summary": "논의 사항:\\n- 논의내용1\\n- 논의내용2\\n\\n결정 사항:\\n- 결정1\\n- 결정2",
|
||||
"decisions": ["결정사항1", "결정사항2"],
|
||||
"pending": ["보류사항"],
|
||||
"todos": [
|
||||
@@ -113,20 +126,27 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
3. **완전성**: 모든 필드를 빠짐없이 작성
|
||||
4. **구조화**: 안건별로 명확히 분리
|
||||
5. **결정사항 추출**:
|
||||
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식)
|
||||
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식, \\n 이스케이프 필수)
|
||||
- 안건별 결정사항(agenda_summaries[].decisions): 각 안건의 결정사항을 배열로 추출
|
||||
- 결정사항이 명확하게 언급된 경우에만 포함
|
||||
6. **summary 작성**:
|
||||
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가)
|
||||
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능)
|
||||
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가, 특수문자 이스케이프)
|
||||
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능, \\n 이스케이프 필수)
|
||||
- decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용)
|
||||
7. **Todo 추출**:
|
||||
- 제목 필수, 담당자는 언급된 경우에만 추출
|
||||
- 자연스러운 표현에서 추출: "김대리가 ~하기로 함" → title: "~", assignee: "김대리"
|
||||
- 담당자가 없으면 assignee: "" (빈 문자열)
|
||||
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
|
||||
8. **JSON 형식 엄수 - 가장 중요**:
|
||||
- 추가 설명, 주석, 서문 없이 JSON만 반환
|
||||
- 문자열 내 큰따옴표(")는 작은따옴표(')로 대체
|
||||
- 문자열 내 줄바꿈은 \\n으로 이스케이프
|
||||
- 역슬래시는 \\\\로 이스케이프
|
||||
- 모든 문자열을 올바르게 닫기
|
||||
- 생성 후 유효한 JSON인지 자체 검증
|
||||
|
||||
이제 위 회의록들을 분석하여 통합 요약을 JSON 형식으로 생성해주세요.
|
||||
**최종 지시**: 위 회의록들을 분석하여 **유효한 JSON 형식으로만** 통합 요약을 생성해주세요.
|
||||
JSON 파싱 오류가 발생하지 않도록 모든 특수문자를 올바르게 이스케이프하세요.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
|
||||
@@ -43,7 +43,7 @@ class ClaudeService:
|
||||
]
|
||||
|
||||
# API 호출
|
||||
logger.info(f"Claude API 호출 시작 - Model: {self.model}")
|
||||
logger.info(f"Claude API 호출 시작 - Model: {self.model}, Max Tokens: {self.max_tokens}")
|
||||
|
||||
if system_prompt:
|
||||
response = self.client.messages.create(
|
||||
@@ -63,7 +63,12 @@ class ClaudeService:
|
||||
|
||||
# 응답 텍스트 추출
|
||||
response_text = response.content[0].text
|
||||
logger.info(f"Claude API 응답 수신 완료 - Tokens: {response.usage.input_tokens + response.usage.output_tokens}")
|
||||
logger.info(
|
||||
f"Claude API 응답 수신 완료 - "
|
||||
f"Input Tokens: {response.usage.input_tokens}, "
|
||||
f"Output Tokens: {response.usage.output_tokens}, "
|
||||
f"Stop Reason: {response.stop_reason}"
|
||||
)
|
||||
|
||||
# JSON 파싱
|
||||
# ```json ... ``` 블록 제거
|
||||
@@ -72,13 +77,113 @@ class ClaudeService:
|
||||
elif "```" in response_text:
|
||||
response_text = response_text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
# JSON 파싱 전 전처리: 제어 문자 및 문제 문자 정리
|
||||
import re
|
||||
# 탭 문자를 공백으로 변환
|
||||
response_text = response_text.replace('\t', ' ')
|
||||
# 연속된 공백을 하나로 축소 (JSON 문자열 내부는 제외)
|
||||
# response_text = re.sub(r'\s+', ' ', response_text)
|
||||
|
||||
result = json.loads(response_text)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON 파싱 실패: {e}")
|
||||
logger.error(f"응답 텍스트: {response_text[:500]}...")
|
||||
logger.error(f"응답 텍스트 전체 길이: {len(response_text)}")
|
||||
logger.error(f"응답 텍스트 (처음 1000자): {response_text[:1000]}")
|
||||
logger.error(f"응답 텍스트 (마지막 1000자): {response_text[-1000:]}")
|
||||
|
||||
# 전체 응답을 파일로 저장하여 디버깅
|
||||
import os
|
||||
from datetime import datetime
|
||||
debug_dir = "/Users/jominseo/HGZero/ai-python/logs/debug"
|
||||
os.makedirs(debug_dir, exist_ok=True)
|
||||
debug_file = f"{debug_dir}/claude_response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
with open(debug_file, 'w', encoding='utf-8') as f:
|
||||
f.write(response_text)
|
||||
logger.error(f"전체 응답을 파일로 저장: {debug_file}")
|
||||
|
||||
# JSON 파싱 재시도 전략
|
||||
|
||||
# 방법 1: 이스케이프되지 않은 개행 문자 처리
|
||||
try:
|
||||
import re
|
||||
# JSON 문자열 내부의 이스케이프되지 않은 개행을 찾아 \\n으로 변환
|
||||
# 패턴: 큰따옴표 내부에서 "\"로 시작하지 않는 개행
|
||||
def fix_unescaped_newlines(text):
|
||||
# 간단한 접근: 모든 실제 개행을 \\n으로 변환
|
||||
# 단, JSON 구조의 개행 (객체/배열 사이)은 유지
|
||||
in_string = False
|
||||
escape_next = False
|
||||
result = []
|
||||
|
||||
for char in text:
|
||||
if escape_next:
|
||||
result.append(char)
|
||||
escape_next = False
|
||||
continue
|
||||
|
||||
if char == '\\':
|
||||
escape_next = True
|
||||
result.append(char)
|
||||
continue
|
||||
|
||||
if char == '"':
|
||||
in_string = not in_string
|
||||
result.append(char)
|
||||
continue
|
||||
|
||||
if char == '\n':
|
||||
if in_string:
|
||||
# 문자열 내부의 개행은 \\n으로 변환
|
||||
result.append('\\n')
|
||||
else:
|
||||
# JSON 구조의 개행은 유지
|
||||
result.append(char)
|
||||
else:
|
||||
result.append(char)
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
fixed_text = fix_unescaped_newlines(response_text)
|
||||
result = json.loads(fixed_text)
|
||||
logger.info("JSON 파싱 재시도 성공 (개행 문자 수정)")
|
||||
return result
|
||||
except Exception as e1:
|
||||
logger.warning(f"개행 문자 수정 실패: {e1}")
|
||||
|
||||
# 방법 2: strict=False 옵션으로 파싱
|
||||
try:
|
||||
result = json.loads(response_text, strict=False)
|
||||
logger.info("JSON 파싱 재시도 성공 (strict=False)")
|
||||
return result
|
||||
except Exception as e2:
|
||||
logger.warning(f"strict=False 파싱 실패: {e2}")
|
||||
|
||||
# 방법 3: 마지막 닫는 괄호까지만 파싱 시도
|
||||
try:
|
||||
last_brace_idx = response_text.rfind('}')
|
||||
if last_brace_idx > 0:
|
||||
truncated_text = response_text[:last_brace_idx+1]
|
||||
# 개행 수정도 적용
|
||||
truncated_text = fix_unescaped_newlines(truncated_text)
|
||||
result = json.loads(truncated_text, strict=False)
|
||||
logger.info("JSON 파싱 재시도 성공 (잘린 부분 복구)")
|
||||
return result
|
||||
except Exception as e3:
|
||||
logger.warning(f"잘린 부분 복구 실패: {e3}")
|
||||
|
||||
# 방법 4: 정규식으로 문제 문자 제거 후 재시도
|
||||
try:
|
||||
# 제어 문자 제거 (줄바꿈, 탭 제외)
|
||||
cleaned = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]', '', response_text)
|
||||
result = json.loads(cleaned, strict=False)
|
||||
logger.info("JSON 파싱 재시도 성공 (제어 문자 제거)")
|
||||
return result
|
||||
except Exception as e4:
|
||||
logger.warning(f"제어 문자 제거 실패: {e4}")
|
||||
|
||||
raise ValueError(f"Claude API 응답을 JSON으로 파싱할 수 없습니다: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user