UI → 오케스트레이션 → 도구 호출 → 근거 정리(Evidence) → LLM 생성 → 스키마 검증(Validator) → (필요 시) Repair
사용자가 Streamlit에서 질문 + 사진 업로드 후 “분석 실행”을 누르면 다음 순서로 동작한다.
main.py : 입력 수집(텍스트/이미지)
pipeline.run() : 요청 1건 처리 엔트리포인트
router.decide() : 어떤 도구를 쓸지 결정(needs_vision, needs_rag …)
visionclient* : (조건부) 사진 분석 결과 JSON 반환
ragretriever* : (조건부) 근거 문서 조각(passages) 반환
evidence.build() : 도구 결과를 LLM 입력용으로 정리
generator.generate_report() : LLM 호출 → FinalReport(JSON) 생성
validate_report() : 스키마 검사(Pydantic)
(실패 시) repair_json() : JSON 형식만 고치도록 1회 재요청
결과 저장(outputs/runs/…) + UI 출력
Streamlit의 file_uploader는 UploadedFile 객체를 반환한다.
하지만 이 객체는 UI 내부 타입이라 다른 모듈(tools/orchestrator)로 넘기기엔 불편하다.
그래서 f.read()로 바이너리(bytes) 로 표준화해서 넘긴다.
HTTP API로 보내기 쉬움(multipart/form-data)
디버깅/재현이 쉬움(같은 bytes를 저장해두면 동일 입력 재현 가능)
모듈 경계가 깔끔해짐(UI 타입이 백엔드로 새지 않음)
결과적으로 images_bytes: List[bytes]는 “도구 호출/저장/테스트”에 가장 안전한 입력 포맷
Router는 “도구를 호출할지 말지”를 정하는 의사결정 레이어다.
데모에서는 가장 단순한 형태로:
사진이 있는가?
질문이 “진단/추천/성분” 의도를 갖는가?
를 기준으로 needs_vision, needs_rag 같은 플래그를 만든다.
LLM/외부 API 호출은 비용/지연이 크다.
그래서 라우터에서 “불필요한 호출”을 줄이기 위해 의도를 대략 추정한다.
예:
“피부 진단해줘” → vision 필요
“루틴 추천해줘” → rag(근거) 필요
“이 성분 뭐야” → ocr/db/rag 필요(추후 확장)
데모는 룰 기반이지만, 나중에 LLM 분류(intent classifier)로 바꿔도 구조는 동일하다.
현재 vision_client_mock.py는 고정된 JSON을 반환한다.
실제 서비스에서는 이 파일이 B의 딥러닝 추론 API 호출 클라이언트로 교체된다.
입력: images_bytes[]
처리: HTTP로 Vision 서버에 이미지 업로드
출력: vision_result.json
LLM은 이미지를 직접 “보는” 게 아니라, 이 JSON을 근거로 리포트를 작성
{ "quality_flags": ["low_light", "blur"], "findings": [ {"name": "redness", "score": 0.72, "confidence": 0.82, "region": "cheek"}, {"name": "dryness", "score": 0.64, "confidence": 0.76, "region": "cheek"} ], "red_flags": [] }
rag_passages는 RAG의 Retrieval 결과, 즉 근거 문서 조각(passages) 이다.
데모: rag_retriever_mock.search()가 고정 스니펫을 반환
실제: rag_retriever_chroma.search()가 ChromaDB에서 top-k를 검색해 반환
이 “근거 조각”을 LLM 입력에 붙여서 답하게 만드는 것이 RAG의 핵심
[ {"source_id": "doc:redness-002", "snippet": "홍조가 두드러질 경우 ...", "score": 0.12}, {"source_id": "doc:skin-barrier-001", "snippet": "장벽 강화(세라마이드, 판테놀)...", "score": 0.18} ]
Evidence는 “환각을 검출하는 장치”라기보다,
LLM이 참조할 정보를 정리해서 입력을 통제하는 장치다.
LLM 환각이 늘어나는 조건은 보통:
입력이 너무 넓거나(무엇이 근거인지 불명확)
도구 결과가 난잡하거나(raw가 그대로 들어감)
근거가 부족한데도 단정하도록 유도될 때
Evidence 단계에서는:
vision 결과 중 신뢰도 높은 것만 추출
rag_passages 중 핵심 근거만 추려서 전달
사진 품질 플래그/신뢰도 낮음 등을 “불확실성 신호”로 포함
이렇게 “근거 범위를 좁히고 명확하게” 하면 LLM이 지어낼 여지가 줄어든다.
Generator는 evidence bundle을 입력으로 받아 최종 리포트를 만든다.
입력: evidence_bundle (user_text + vision_result + rag_passages …)
처리: OpenAI 호출(“JSON만 출력”, “근거 기반으로만 답하라” 지시)
출력: FinalReport(JSON)
FinalReport는 UI/DB가 바로 쓰도록 구조화된 결과로 만든다.
예:
summary
observations(사진 기반)
recommendations(AM/PM/Lifestyle/Ingredients)
warnings/red_flags
citations(source_id/snippet)
LLM은 종종:
JSON을 깨뜨리거나
필드를 누락하거나
타입을 바꾸는(숫자→문자열) 문제를 일으킨다.
그래서 FinalReport 스키마(Pydantic)를 만들고,
validate_report()로 형식을 강제한다.
이건 “내용이 맞는지”를 보장하는 게 아니라,
UI가 깨지지 않게
저장(DB)이 안정적으로 되게
하는 형식 안정화 장치
Validator에서 실패하면, 1회만 Repair를 시도한다.
Repair는:
에러 메시지 + 스키마 힌트 + 깨진 출력물을 묶어서
LLM에게 “내용은 유지하고 형식만 JSON으로 고쳐라”라고 요청하는 방식이다.
이걸 넣으면 운영에서 흔한 문제인
“LLM이 갑자기 JSON 밖에 텍스트를 섞어서 죽는 상황”을 크게 줄일 수 있다.