---
tags: [plans, pydantic-ai, langgraph, llm, structured-output, ir-analysis]
---

# Pydantic AI 부분 도입 LLM 출력안정화 계획

**작성일**: 2026-03-13  
**상태**: planned  
**목표**: `LangGraph`는 유지하면서 `IR Deck` 종합 평가 경로에 우선 `Pydantic-only` 기반 typed validation 계층을 도입해, 모순된 점수/등급/추천이 저장·노출되지 않게 합니다.

## 관련 문서
- [Pydantic AI 도입 기반 LLM 출력 안정화 아이디어](../ideas/260313_pydantic_ai_도입_기반_llm_출력_안정화_아이디어.md)
- [Pydantic AI 도입 기반 LLM 출력 안정화 방향확정 리서치](../research/260313_pydantic_ai_도입_기반_llm_출력_안정화_방향확정_리서치.md)
- [콜드메일 IR 분석이 첫 번째 PDF를 잘못 선택하는 상태](../debug/260313_coldmail_ir_분석대상_오선택_디버그.md)

## 1. 이번 계획의 결정
- 1차는 `Pydantic-only`로 갑니다.
- `Pydantic AI`는 버리지 않되, 후속 2차 후보로 둡니다.
- 전면 교체는 하지 않고, `IR Deck 종합 평가` 경로에만 부분 도입합니다.
- `LangGraph` 워크플로는 유지합니다.
- 첫 단계에서는 `raw output -> Pydantic model_validate -> semantic validation -> final output -> save` 경계를 추가합니다.

## 2. 왜 지금 이 계획으로 가는가
- 실측 기준으로 현재 `_evaluate_comprehensive()`는 `total_score=100`, `grade=B` 같은 모순값을 그대로 통과시킵니다.
- 실측 기준으로 `Pydantic` validator만으로도 같은 입력을 차단했습니다.
- 따라서 지금 필요한 것은 `Pydantic AI`까지 바로 올리는 것보다, 먼저 가장 작은 변경으로 `IR Deck` 종합 평가 출력에 검증 경계를 추가하는 것입니다.

## 3. 범위
- 포함:
  - `rb8001/app/services/ir_deck_analyzer.py` 종합 평가 출력 경로
  - 필요 시 `rb8001/app/services/ir_deck_output_models.py` 또는 동등한 전용 모델 파일 추가
  - `rb8001/tests/test_ir_deck_json_parsing.py` 보강
  - `rb8001/tests/test_ir_deck_workflow.py` 보강
  - `Pydantic` typed output 모델 및 validator 추가
  - 저장 전 `final_evaluation_result` 생성
  - 모순 출력 테스트 추가
- 제외:
  - `LangGraph` 제거 또는 재설계
  - `Pydantic AI` 의존성 즉시 추가
  - 브리핑, 콜드메일, 투자의견 전 경로 동시 적용
  - 운영용 공통 출력 정책 프레임워크 전면화

## 4. 구현 원칙
- `LangGraph`는 상태 관리 계층으로 유지합니다.
- `Pydantic-only`는 종합 평가 결과 검증 계층으로만 붙입니다.
- `raw`와 `final` 결과를 분리합니다.
- `grade`, `recommendation`은 서버가 최종 소유합니다.
- 검증 실패는 성공처럼 저장하지 않습니다.

## 5. 구현 단계

### A. 의존성 추가
- 추가 의존성 없이 현재 `pydantic` 자산을 먼저 사용합니다.
- 첫 적용 범위는 `rb8001` 한 경로로 제한합니다.

### B. 출력 모델 추가
- 권장 위치:
  - 1순위: `rb8001/app/services/ir_deck_output_models.py`
  - 대안: `rb8001/app/services/ir_deck_analyzer.py` 내부 보조 모델
- 이유:
  - validator 로직과 LLM 호출 로직을 분리해 테스트 가능성을 높이기 위함입니다.
- `IRDeckEvaluationResult`에 해당하는 typed output 모델을 추가합니다.
- 최소 필드:
  - `total_score`
  - `grade`
  - `story_scores`
  - `summary`
  - `strengths`
  - `weaknesses`
  - `risks`
  - `investment_opinion`
- 함께 둘 보조 모델:
  - `StoryScore`
  - `InvestmentOpinion`
- validator 권장 항목:
  - `grade == assign_grade(total_score)`
  - `summary` 최소 길이
  - `story_scores` 개수/범위
  - `recommendation` 허용값 또는 서버 재계산 대상 표시

### C. `Pydantic-only` 검증 경로 추가
- `_evaluate_comprehensive()` 내부에서 기존 자유형 JSON 파싱 뒤 `Pydantic model_validate()`를 수행합니다.
- model validator에서 의미 검증을 수행합니다.
- 필요 시 검증 함수는 `parse_raw_evaluation_result()`와 `finalize_evaluation_result()`로 분리합니다.
- 권장 함수 분리:
  - `_extract_raw_evaluation_dict(response: str) -> dict`
  - `_validate_evaluation_result(raw: dict) -> IRDeckEvaluationResult`
  - `_finalize_evaluation_result(validated: IRDeckEvaluationResult) -> dict`
- 이유:
  - 재질의 정책과 validator 테스트를 각각 독립적으로 검증하기 위함입니다.

### D. 서버 최종값 재계산
- `grade`는 `total_score` 기반으로 서버가 다시 계산합니다.
- `recommendation`도 서버 정책으로 다시 계산합니다.
- 필요 시 `story_scores` 기반 총점 재계산 또는 허용 오차 검증을 추가합니다.
- 권장 고정안:
  - 1차에서는 `grade`, `recommendation`만 서버 최종값으로 고정
  - `total_score`는 우선 validator 통과값을 사용하되, 후속 단계에서 재계산 여부 검토
- 이유:
  - 변경 범위를 줄이면서도 사용자에게 보이는 핵심 모순을 먼저 닫기 위함입니다.

### E. 저장 경계 분리
- 저장은 `raw_evaluation_result`가 아니라 `final_evaluation_result`만 사용합니다.
- 검증 실패 시 저장하지 않거나, 실패 상태를 명시적으로 반환합니다.
- 권장 구현:
  - raw dict는 지역 변수로만 유지
  - repository에는 `final_evaluation_result`에서 꺼낸 값만 전달
  - 실패 시 `ValueError` 또는 명시적 커스텀 예외를 발생시켜 상위 경로에서 실패 처리

### E-1. 라우터/워크플로 영향 범위 확인
- 확인 대상:
  - `rb8001/app/services/workflows/ir_deck_workflow.py`
  - `rb8001/app/router/ir_deck.py`
- 확인 항목:
  - `_evaluate_comprehensive()` 반환 구조가 바뀌어도 기존 `evaluate_node`, `save_node`, `EvaluationResponse`가 깨지지 않는지
  - 실패 시 비동기 평가 잡이 침묵 성공으로 끝나지 않는지
- 이유:
  - 이번 수정은 analyzer 내부가 중심이지만, 실제 저장/응답 경로는 workflow/router까지 이어지기 때문입니다.

### F. 테스트 추가
- 최소 테스트:
  - `100점 + B등급` 입력 차단
  - 잘못된 enum 차단
  - `story_scores`와 총점 불일치 차단 또는 보정
  - `summary` 최소 길이 미달 처리
- 권장 반영 위치:
  - `rb8001/tests/test_ir_deck_json_parsing.py`
    - validator 단위 테스트
    - raw dict -> model_validate 실패 테스트
    - 재질의 필요 판정 테스트
  - `rb8001/tests/test_ir_deck_workflow.py`
    - validator 통과 결과가 workflow save까지 정상 전달되는지
    - validator 실패 시 workflow가 실패를 드러내는지
- 가능하면 `_evaluate_comprehensive()` 파싱 경로와 validator를 분리해 단위 테스트를 우선 추가합니다.

## 6. 실패 정책
- 1차 원칙:
  - validator 실패 시 조용히 저장하지 않습니다.
- 기본안:
  - 기존 `call_llm()` 응답 검증 실패 시 직접 재질의 1회
  - 1회 재시도 후에도 실패하면 명시적 실패 처리
- 보조안:
  - `grade`, `recommendation`은 서버 재계산으로 보정 가능
  - 구조 자체가 깨진 경우는 실패 처리
- 권장 세부 순서:
  1. 첫 응답 JSON 추출
  2. `Pydantic model_validate()`
  3. 실패 시 같은 프롬프트로 1회 재호출
  4. 2차도 실패하면 저장하지 않고 실패 로그 남김
  5. 단, `grade/recommendation` 같은 서버 소유 필드는 validator 통과 후에도 서버가 다시 계산
- 이렇게 두는 이유:
  - 재질의는 최소 1회만 허용해 비용과 지연을 제한하고
  - 서버 소유 필드는 보정 가능하지만 구조 실패는 숨기지 않기 위함입니다.

## 7. 완료 판정 기준
1. `IR Deck` 종합 평가 경로가 `Pydantic` typed validation을 사용합니다.
2. `total_score=100`, `grade=B` 같은 모순 입력은 저장되지 않습니다.
3. 저장 전 `final_evaluation_result` 경계가 코드에 존재합니다.
4. `grade`, `recommendation`은 서버 정책을 거친 값만 저장됩니다.
5. 관련 테스트가 추가되고 통과합니다.

## 7-1. 검증 실행 기준
- 코드 수정 후 최소 검증:
  - `pytest rb8001/tests/test_ir_deck_json_parsing.py`
  - `pytest rb8001/tests/test_ir_deck_workflow.py`
- 가능하면 추가 검증:
  - 기존 IR Deck 관련 대표 테스트 1회
- 배포 후 확인 기준:
  - 실제 평가 1건에서 `100점 + B등급` 같은 모순 저장이 발생하지 않는지
  - 실패 케이스가 있으면 성공처럼 `completed`로만 남지 않는지 로그 확인
- 이유:
  - 이번 수정의 핵심은 parser/validator/workflow 경계이므로 이 세 축을 직접 검증해야 합니다.

## 8. 후속 경계
- 이번 계획이 닫혀도 브리핑, 콜드메일, 투자 의견 경로는 별도 확대 계획으로 다룹니다.
- 2차 단계에서 retry, output validator, 생성-검증 일체화를 더 강하게 원하면 `Pydantic AI` 확장 계획을 별도로 검토합니다.

## 9. 이번 계획의 한 줄 결론
- 지금은 `LangGraph를 바꾸는 단계`가 아니라, `IR Deck 종합 평가에 Pydantic-only를 먼저 붙여 typed validation 계약을 실제 코드에 고정하는 단계`입니다.
