Compare commits
4 Commits
589946ab36
...
68f741af72
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f741af72 | |||
| bdb6fd83c4 | |||
| a2dff825ad | |||
| 3faf8b09ce |
@@ -41,6 +41,9 @@ _container.db_service().init_schema()
|
|||||||
_cfg = _container.config()
|
_cfg = _container.config()
|
||||||
_agent_cache: dict[str, AgentService] = {}
|
_agent_cache: dict[str, AgentService] = {}
|
||||||
|
|
||||||
|
# Vision 모델 — VISION_ENABLED=true 시 lazy 초기화
|
||||||
|
_vision_model = _container.vision_model() if _cfg.vision_enabled else None
|
||||||
|
|
||||||
|
|
||||||
def _get_agent(user_id: str) -> AgentService:
|
def _get_agent(user_id: str) -> AgentService:
|
||||||
if user_id not in _agent_cache:
|
if user_id not in _agent_cache:
|
||||||
@@ -57,6 +60,8 @@ def _get_agent(user_id: str) -> AgentService:
|
|||||||
conversation_repository=_container.conversation_repository(),
|
conversation_repository=_container.conversation_repository(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
if _vision_model:
|
||||||
|
_agent_cache[user_id].set_vision_model(_vision_model)
|
||||||
return _agent_cache[user_id]
|
return _agent_cache[user_id]
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +79,7 @@ class ChatRequest(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
user_id: str = "default"
|
user_id: str = "default"
|
||||||
show_thinking: bool = False
|
show_thinking: bool = False
|
||||||
|
image_base64: str | None = None # base64 인코딩된 이미지 (선택)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackRequest(BaseModel):
|
class FeedbackRequest(BaseModel):
|
||||||
@@ -97,10 +103,33 @@ async def chat(req: ChatRequest, _=Depends(_auth)):
|
|||||||
"""SSE 스트리밍 응답. 각 라인: `data: <JSON 토큰>\n\n`, 종료: `data: [DONE]\n\n`"""
|
"""SSE 스트리밍 응답. 각 라인: `data: <JSON 토큰>\n\n`, 종료: `data: [DONE]\n\n`"""
|
||||||
agent = _get_agent(req.user_id)
|
agent = _get_agent(req.user_id)
|
||||||
|
|
||||||
|
# 이미지 base64 → 임시 파일 저장
|
||||||
|
image_path: str | None = None
|
||||||
|
tmp_path: str | None = None
|
||||||
|
if req.image_base64 and _vision_model:
|
||||||
|
import base64
|
||||||
|
img_bytes = base64.b64decode(req.image_base64)
|
||||||
|
suffix = ".jpg"
|
||||||
|
if img_bytes[:4] == b"\x89PNG":
|
||||||
|
suffix = ".png"
|
||||||
|
elif img_bytes[:4] == b"GIF8":
|
||||||
|
suffix = ".gif"
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir="/tmp", prefix="youlbot_img_")
|
||||||
|
tmp.write(img_bytes)
|
||||||
|
tmp.close()
|
||||||
|
image_path = tmp.name
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
async def generate():
|
async def generate():
|
||||||
async for token in agent.stream_response(req.message, show_thinking=req.show_thinking):
|
try:
|
||||||
|
async for token in agent.stream_response(
|
||||||
|
req.message, show_thinking=req.show_thinking, image_path=image_path
|
||||||
|
):
|
||||||
yield f"data: {json.dumps(token, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(token, ensure_ascii=False)}\n\n"
|
||||||
yield f"data: {json.dumps({'__done': True, 'run_id': agent.last_run_id}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'__done': True, 'run_id': agent.last_run_id}, ensure_ascii=False)}\n\n"
|
||||||
|
finally:
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ class Config(BaseSettings):
|
|||||||
whisper_model_size: str = "small"
|
whisper_model_size: str = "small"
|
||||||
tts_voice: str = "Yuna" # macOS say 명령어 한국어 음성
|
tts_voice: str = "Yuna" # macOS say 명령어 한국어 음성
|
||||||
|
|
||||||
|
# Vision (Phase 17)
|
||||||
|
vision_enabled: bool = False
|
||||||
|
vision_model_id: str = "mlx-community/Qwen2.5-VL-7B-Instruct-4bit"
|
||||||
|
vision_max_tokens: int = 512
|
||||||
|
|
||||||
system_prompt: str = """모든 사고 과정(thinking)과 답변은 반드시 한국어로만 작성하세요. 영어 사용 절대 금지.
|
system_prompt: str = """모든 사고 과정(thinking)과 답변은 반드시 한국어로만 작성하세요. 영어 사용 절대 금지.
|
||||||
|
|
||||||
당신의 이름은 '율봇'입니다. 친절하고 따뜻한 한국어 상담 도우미입니다.
|
당신의 이름은 '율봇'입니다. 친절하고 따뜻한 한국어 상담 도우미입니다.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from services.rag.ingestion_service import IngestionService
|
|||||||
from services.rag.rerank_service import RerankService
|
from services.rag.rerank_service import RerankService
|
||||||
from services.rag.retriever_service import RetrieverService
|
from services.rag.retriever_service import RetrieverService
|
||||||
from services.agent.agent_service import AgentService
|
from services.agent.agent_service import AgentService
|
||||||
|
from services.model.mlx_vision_model import MlxVisionModel
|
||||||
|
|
||||||
|
|
||||||
class Container(containers.DeclarativeContainer):
|
class Container(containers.DeclarativeContainer):
|
||||||
@@ -130,6 +131,13 @@ class Container(containers.DeclarativeContainer):
|
|||||||
sparse_embeddings=sparse_embeddings,
|
sparse_embeddings=sparse_embeddings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Phase 17 — Vision Model (lazy load)
|
||||||
|
vision_model = providers.Singleton(
|
||||||
|
MlxVisionModel,
|
||||||
|
model_id=providers.Callable(lambda c: c.vision_model_id, config),
|
||||||
|
max_tokens=providers.Callable(lambda c: c.vision_max_tokens, config),
|
||||||
|
)
|
||||||
|
|
||||||
# Phase 3 — LangGraph Agent
|
# Phase 3 — LangGraph Agent
|
||||||
agent_service = providers.Singleton(
|
agent_service = providers.Singleton(
|
||||||
AgentService,
|
AgentService,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
template: plan
|
||||||
|
version: 1.3
|
||||||
|
feature: phase17-multimodal
|
||||||
|
date: 2026-06-02
|
||||||
|
author: sal
|
||||||
|
project: youlbot
|
||||||
|
status: Draft
|
||||||
|
---
|
||||||
|
|
||||||
|
# phase17-multimodal Planning Document
|
||||||
|
|
||||||
|
> **Summary**: analyze_image 도구 방식으로 이미지 이해 기능을 추가한다.
|
||||||
|
> Qwen3-8B가 대화를 유지하고, 이미지 첨부 시 Qwen2.5-VL-7B를 도구로 호출해 설명을 얻은 뒤 답변한다.
|
||||||
|
>
|
||||||
|
> **Project**: youlbot
|
||||||
|
> **Author**: sal
|
||||||
|
> **Date**: 2026-06-02
|
||||||
|
> **Status**: Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| Perspective | Content |
|
||||||
|
|-------------|---------|
|
||||||
|
| **Problem** | 이유식 사진·금융 서류 등 이미지를 텍스트로만 처리하는 현재 한계 |
|
||||||
|
| **Solution** | Qwen2.5-VL-7B를 `analyze_image` LangChain 도구로 래핑, Qwen3-8B가 필요 시 자동 호출 |
|
||||||
|
| **Function/UX Effect** | 채팅창에 이미지 첨부 → 자동 분석 → 육아·금융 상담으로 자연스럽게 연결 |
|
||||||
|
| **Core Value** | 텍스트 추론 품질(Qwen3-8B)을 유지하면서 이미지 이해 기능 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context Anchor
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| **WHY** | 손이 자유롭지 않은 육아 상황에서 사진 한 장으로 재료 분석·서류 해석이 가능해야 함 |
|
||||||
|
| **WHO** | 아록(주 사용자) — 이유식 사진, 건강보험 서류, 접종 기록지 등 촬영 후 질문 |
|
||||||
|
| **RISK** | 16GB 메모리에서 두 모델 동시 로드 시 OOM 가능 → Vision 모델 lazy load로 완화 |
|
||||||
|
| **SUCCESS** | 이미지 첨부 → analyze_image 도구 자동 호출 → 설명이 대화 히스토리에 남아 후속 질문 가능 |
|
||||||
|
| **SCOPE** | 이미지 분석 + 채팅 연동. 동영상·실시간 캡처는 제외 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
### 1.1 Purpose
|
||||||
|
사진을 첨부하면 `analyze_image` 도구가 Qwen2.5-VL-7B를 호출해 이미지 설명을 생성하고,
|
||||||
|
Qwen3-8B가 그 설명을 컨텍스트로 삼아 육아·금융 상담 답변을 제공한다.
|
||||||
|
|
||||||
|
### 1.2 모델 분담
|
||||||
|
|
||||||
|
| 모델 | 역할 | 메모리 |
|
||||||
|
|------|------|--------|
|
||||||
|
| Qwen3-8B-4bit | 대화·추론·도구 결정 (항상 로드) | ~5GB |
|
||||||
|
| Qwen2.5-VL-7B-Instruct-4bit | 이미지 분석 (lazy load) | ~5GB |
|
||||||
|
| 합계 | — | ~10GB / 16GB 사용 가능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
### 2.1 In Scope
|
||||||
|
- `mlx-vlm` 패키지로 Vision 모델 로드 및 추론
|
||||||
|
- `analyze_image(image_path, prompt)` LangChain 도구 구현
|
||||||
|
- AgentService: 요청에 이미지 있을 때 도구 동적 주입
|
||||||
|
- API(`/chat`): 이미지 파일 업로드 지원 (multipart form)
|
||||||
|
- WebUI: 채팅 입력창에 이미지 첨부 버튼 추가
|
||||||
|
- Telegram: 사진 메시지 수신 → 이미지 다운로드 → API 전달
|
||||||
|
|
||||||
|
### 2.2 Out of Scope
|
||||||
|
- 동영상 분석
|
||||||
|
- 이미지 생성(text-to-image)
|
||||||
|
- 실시간 카메라 입력
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture — C방식 (analyze_image 도구)
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자
|
||||||
|
│ 텍스트 + 이미지(선택)
|
||||||
|
▼
|
||||||
|
API /chat (multipart form)
|
||||||
|
│ image → /tmp/youlbot_img_xxx.jpg 저장
|
||||||
|
│ image_path → AgentService.stream_response(message, image_path=...)
|
||||||
|
▼
|
||||||
|
AgentService
|
||||||
|
│ image_path 있을 때: analyze_image 도구를 tools 목록에 동적 추가
|
||||||
|
│ image_path를 도구 클로저로 바인딩
|
||||||
|
▼
|
||||||
|
LangGraph ReAct
|
||||||
|
│ Qwen3-8B가 이미지 관련 질문 감지 → analyze_image() 자동 호출
|
||||||
|
▼
|
||||||
|
analyze_image 도구
|
||||||
|
│ mlx_vision_model.analyze(image_path, prompt)
|
||||||
|
▼
|
||||||
|
MlxVisionModel (Qwen2.5-VL-7B, lazy load)
|
||||||
|
│ 이미지 설명 텍스트 반환
|
||||||
|
▼
|
||||||
|
LangGraph
|
||||||
|
│ 설명이 ToolMessage로 대화 히스토리에 저장
|
||||||
|
▼
|
||||||
|
Qwen3-8B → 최종 답변 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
**핵심 특성:**
|
||||||
|
- Vision 모델은 처음 analyze_image 호출 시 로드 (이후 캐시)
|
||||||
|
- 이미지 설명이 대화 히스토리에 남아 후속 질문("그 재료로 이유식 만들어줘") 가능
|
||||||
|
- 이미지 없는 메시지는 기존과 완전히 동일하게 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 변경 파일 목록
|
||||||
|
|
||||||
|
### 신규 생성
|
||||||
|
| 파일 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `services/model/mlx_vision_model.py` | MlxVisionModel 클래스 (mlx-vlm 래퍼, lazy load) |
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
| 파일 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| `config.py` | `vision_enabled: bool`, `vision_model_id: str` 추가 |
|
||||||
|
| `container.py` | `vision_model` Singleton 프로바이더 추가 |
|
||||||
|
| `services/agent/tools.py` | `make_vision_tool(vision_model, image_path)` 추가 |
|
||||||
|
| `services/agent/agent_service.py` | `stream_response(image_path=None)` 파라미터 추가, 도구 동적 주입 |
|
||||||
|
| `api.py` | `/chat` → multipart form으로 변경, 이미지 temp 저장 |
|
||||||
|
| `youlbot-webui/api_client.py` | `chat(image_path=None)` 파라미터 추가, multipart 전송 |
|
||||||
|
| `youlbot-webui/app.py` | 채팅 입력 영역에 이미지 업로드 컴포넌트 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 주요 구현 세부사항
|
||||||
|
|
||||||
|
### 5.1 MlxVisionModel
|
||||||
|
```python
|
||||||
|
class MlxVisionModel:
|
||||||
|
def __init__(self, model_id: str): ...
|
||||||
|
|
||||||
|
def analyze(self, image_path: str, prompt: str = "이 이미지를 한국어로 자세히 설명해줘.") -> str:
|
||||||
|
# 첫 호출 시 lazy load
|
||||||
|
# mlx_vlm.generate() 호출
|
||||||
|
# 한국어 설명 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 make_vision_tool
|
||||||
|
```python
|
||||||
|
def make_vision_tool(vision_model, image_path: str):
|
||||||
|
@tool
|
||||||
|
def analyze_image(prompt: str = "이 이미지를 설명해줘") -> str:
|
||||||
|
"""현재 첨부된 이미지를 분석한다."""
|
||||||
|
return vision_model.analyze(image_path, prompt)
|
||||||
|
return analyze_image
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 API /chat 변경
|
||||||
|
- JSON Body → `multipart/form-data`
|
||||||
|
- 필드: `message`, `user_id`, `show_thinking`, `image` (optional file)
|
||||||
|
- 이미지를 `/tmp/youlbot_img_{uuid}.{ext}`에 저장 후 agent에 전달
|
||||||
|
- 응답 완료 후 temp 파일 삭제
|
||||||
|
|
||||||
|
### 5.4 WebUI 변경
|
||||||
|
- `gr.Image(type="filepath", ...)` 컴포넌트 채팅 입력 영역에 추가
|
||||||
|
- 이미지 첨부 시 api_client.chat()에 image_path 전달
|
||||||
|
- 전송 후 이미지 초기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 환경 설정
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env 추가
|
||||||
|
VISION_ENABLED=true
|
||||||
|
VISION_MODEL_ID=mlx-community/Qwen2.5-VL-7B-Instruct-4bit
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 패키지 설치
|
||||||
|
pip install mlx-vlm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 위험 요소 및 대응
|
||||||
|
|
||||||
|
| 위험 | 대응 |
|
||||||
|
|------|------|
|
||||||
|
| 16GB에서 두 모델 동시 OOM | Vision 모델 lazy load + 미사용 시 unload 옵션 제공 |
|
||||||
|
| mlx-vlm API 변경 가능성 | MlxVisionModel로 캡슐화해 교체 용이하게 |
|
||||||
|
| Telegram 이미지 전달 복잡성 | Phase 17-B로 분리, 우선 WebUI만 구현 |
|
||||||
|
| 이미지 temp 파일 누적 | 응답 완료 후 즉시 삭제 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 성공 기준
|
||||||
|
|
||||||
|
- [ ] 이미지 첨부 시 `analyze_image` 도구가 자동 호출되어 설명 생성
|
||||||
|
- [ ] "이 사진에서 뭐가 보여?" 후속 질문이 히스토리 기반으로 동작
|
||||||
|
- [ ] 이미지 없는 일반 질문은 기존과 동일하게 Qwen3-8B로 처리
|
||||||
|
- [ ] 16GB 환경에서 OOM 없이 동작
|
||||||
+49
-10
@@ -409,14 +409,39 @@ cd youlbot-webui && python app.py
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆
|
## ✅ Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆
|
||||||
|
|
||||||
**배경**: 청킹 전략·검색 파라미터·Reranker 변경 시 답변 품질이 실제로 나아졌는지 수치로 확인할 방법이 없다.
|
**배경**: 청킹 전략·검색 파라미터·Reranker 변경 시 답변 품질이 실제로 나아졌는지 수치로 확인할 방법이 없다.
|
||||||
|
|
||||||
**구현 방식**:
|
**구현 내용**:
|
||||||
- `ragas` 라이브러리로 Faithfulness·Answer Relevancy·Context Recall 자동 측정
|
|
||||||
- 테스트 질문-정답 셋을 `eval/` 디렉터리에 관리
|
```
|
||||||
- 설정 변경 후 `python eval/run_ragas.py`로 비교 가능
|
eval/
|
||||||
|
├── dataset.jsonl ← 평가용 Q&A 쌍 (질문·정답 — 필요 시 수정)
|
||||||
|
├── run_ragas.py ← 평가 실행 스크립트
|
||||||
|
├── requirements.txt ← ragas==0.2.9, datasets, langchain-google-vertexai
|
||||||
|
└── results/ ← report_YYYYMMDD_HHMMSS.{csv,json} 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
**평가 지표**:
|
||||||
|
|
||||||
|
| 지표 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `faithfulness` | 답변이 검색 컨텍스트에 충실한가 (환각 탐지) |
|
||||||
|
| `answer_relevancy` | 답변이 질문에 얼마나 관련 있는가 |
|
||||||
|
| `context_recall` | 컨텍스트가 정답에 필요한 정보를 포함하는가 |
|
||||||
|
| `context_precision` | 검색된 컨텍스트 중 실제 유용한 비율 |
|
||||||
|
|
||||||
|
**평가 LLM 우선순위**: OpenAI GPT-4o-mini > Anthropic Claude Haiku > 로컬 Qwen3
|
||||||
|
|
||||||
|
**실행 방법**:
|
||||||
|
```bash
|
||||||
|
# API 서버 실행 후
|
||||||
|
python eval/run_ragas.py
|
||||||
|
python eval/run_ragas.py --dataset eval/dataset.jsonl --api http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**호환성 처리**: ragas 0.2가 langchain-community 0.4+에서 `ChatVertexAI` 임포트 실패하는 문제를 런타임 shim으로 우회.
|
||||||
|
|
||||||
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 관리 기반)
|
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 관리 기반)
|
||||||
|
|
||||||
@@ -454,13 +479,27 @@ docker-compose.yml
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 17 — 멀티모달 이미지 이해 ★☆☆
|
## ✅ Phase 17 — 멀티모달 이미지 이해 ★☆☆
|
||||||
|
|
||||||
**배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등.
|
**배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등.
|
||||||
|
|
||||||
**제약**: Qwen3-14B는 이미지 미지원 → `mlx-community/Qwen2.5-VL-7B-Instruct-4bit` 교체 필요.
|
**구현 방식**: Dual-model C방식 — analyze_image 도구
|
||||||
|
|
||||||
**난이도**: 높음 | **임팩트**: 높음 (장기 과제)
|
| 모델 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| Qwen3-8B-4bit | 대화·추론 (항상 로드) |
|
||||||
|
| Qwen2.5-VL-7B-Instruct-4bit | 이미지 분석 (lazy load) |
|
||||||
|
|
||||||
|
- `services/model/mlx_vision_model.py` — MlxVisionModel (mlx-vlm 래퍼, lazy load)
|
||||||
|
- `services/agent/tools.py` — `make_vision_tool(vision_model, image_path)` 추가
|
||||||
|
- `agent_service.py` — `stream_response(image_path=None)`, config 경유 vision tool 동적 주입
|
||||||
|
- `api.py` — `image_base64` 필드 추가, temp 파일 저장 후 응답 완료 시 삭제
|
||||||
|
- `youlbot-webui` — `image_input` 컴포넌트 추가, ChatService.chat(image_path=) 연결
|
||||||
|
- `.env` — `VISION_ENABLED=true`, `VISION_MODEL_ID` 설정
|
||||||
|
|
||||||
|
**실행 방법**: API 서버 재시작 후 WebUI 이미지 첨부 버튼으로 사진 전송
|
||||||
|
|
||||||
|
**난이도**: 높음 | **임팩트**: 높음
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -501,7 +540,7 @@ Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 16 (Docke
|
|||||||
| Phase 23 WebUI 분리 | ✅ 완료 | — | — | — |
|
| Phase 23 WebUI 분리 | ✅ 완료 | — | — | — |
|
||||||
| Phase 24 사고 과정 UI 분리 | ✅ 완료 | — | — | — |
|
| Phase 24 사고 과정 UI 분리 | ✅ 완료 | — | — | — |
|
||||||
| Phase 25 RAG 출처 전용 박스 | ✅ 완료 | — | — | — |
|
| Phase 25 RAG 출처 전용 박스 | ✅ 완료 | — | — | — |
|
||||||
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 1순위 |
|
| Phase 20 RAGAS 평가 | ✅ 완료 | — | — | — |
|
||||||
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
|
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
|
||||||
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
|
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
|
||||||
| Phase 17 멀티모달 | 🔲 미완 | 높음 | 높음 | 6순위 |
|
| Phase 17 멀티모달 | ✅ 완료 | — | — | — |
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"question": "부모의 반응성이 아동의 인지 발달에 어떤 영향을 미치나요?", "ground_truth": "부모의 민감한 반응성은 아동의 인지 발달에 긍정적인 영향을 미친다. 특히 영유아기에 부모가 아동의 신호에 적절히 반응할 때 아동의 탐색 행동과 학습 능력이 향상된다."}
|
||||||
|
{"question": "아동의 사회성 발달을 돕기 위해 부모가 할 수 있는 것은?", "ground_truth": "부모는 일관된 애착 관계를 형성하고 긍정적인 상호작용 모델을 보여줌으로써 아동의 사회성 발달을 지원할 수 있다."}
|
||||||
|
{"question": "영유아기 발달 평가 방법에는 어떤 것들이 있나요?", "ground_truth": "영유아기 발달 평가는 표준화된 발달 검사, 관찰 기반 평가, 부모 보고 척도 등 다양한 방법을 통해 이루어진다."}
|
||||||
|
{"question": "논리수학 지능 발달에 영향을 미치는 요인은?", "ground_truth": "논리수학 지능 발달에는 부모의 상호작용 방식, 탐색 기회 제공, 문제 해결 경험 등이 영향을 미친다."}
|
||||||
|
{"question": "어머니의 반응성과 아동 언어 발달의 관계는?", "ground_truth": "어머니의 반응성은 아동의 언어 발달에 긍정적 영향을 미치며, 어머니가 아동의 발화에 민감하게 반응할수록 어휘 습득과 언어 이해 능력이 향상된다."}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ragas==0.2.9
|
||||||
|
datasets>=2.14.0
|
||||||
|
langchain-google-vertexai>=2.0.0
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""youlbot RAGAS 평가 스크립트 (Phase 20)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
cd /path/to/youlbot
|
||||||
|
python eval/run_ragas.py [--dataset eval/dataset.jsonl] [--api http://localhost:8000]
|
||||||
|
|
||||||
|
결과:
|
||||||
|
eval/results/report_YYYYMMDD_HHMMSS.csv
|
||||||
|
|
||||||
|
사전 조건:
|
||||||
|
- youlbot API 서버 실행 중 (uvicorn api:app --port 8000)
|
||||||
|
- Qdrant + MySQL 접근 가능
|
||||||
|
- .env에 API_TOKEN, RAG_SHOW_SOURCES=true 설정
|
||||||
|
|
||||||
|
평가 지표:
|
||||||
|
- faithfulness : 답변이 검색 컨텍스트에 충실한가 (환각 탐지)
|
||||||
|
- answer_relevancy : 답변이 질문에 얼마나 관련 있는가
|
||||||
|
- context_recall : 컨텍스트가 정답에 필요한 정보를 포함하는가
|
||||||
|
- context_precision : 검색된 컨텍스트 중 실제 유용한 비율
|
||||||
|
|
||||||
|
참고:
|
||||||
|
평가에 로컬 LLM(Qwen3)을 사용하므로 결과 신뢰도는 모델 크기에 의존합니다.
|
||||||
|
더 정확한 평가를 원하면 OPENAI_API_KEY 또는 ANTHROPIC_API_KEY를 설정하세요.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Compatibility shim ───────────────────────────────────────────────────────
|
||||||
|
# ragas 0.2.x imports langchain_community.chat_models.vertexai which was
|
||||||
|
# removed in langchain-community 0.4+. Re-export from langchain-google-vertexai.
|
||||||
|
try:
|
||||||
|
import langchain_community.chat_models.vertexai # noqa: F401
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
try:
|
||||||
|
from langchain_google_vertexai import ChatVertexAI as _CV
|
||||||
|
_stub = type(sys)("langchain_community.chat_models.vertexai")
|
||||||
|
_stub.ChatVertexAI = _CV
|
||||||
|
sys.modules["langchain_community.chat_models.vertexai"] = _stub
|
||||||
|
except ImportError:
|
||||||
|
# vertexai not available — inject an empty stub (unused by our eval)
|
||||||
|
_stub = type(sys)("langchain_community.chat_models.vertexai")
|
||||||
|
_stub.ChatVertexAI = object
|
||||||
|
sys.modules["langchain_community.chat_models.vertexai"] = _stub
|
||||||
|
|
||||||
|
from ragas import evaluate
|
||||||
|
from ragas.metrics import answer_relevancy, context_precision, context_recall, faithfulness
|
||||||
|
from ragas.embeddings import LangchainEmbeddingsWrapper
|
||||||
|
from ragas.llms import LangchainLLMWrapper
|
||||||
|
from datasets import Dataset
|
||||||
|
|
||||||
|
# ── Project path ─────────────────────────────────────────────────────────────
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.chdir(ROOT) # .env 읽기 위해 프로젝트 루트로 이동
|
||||||
|
|
||||||
|
from container import Container # noqa: E402 (after sys.path setup)
|
||||||
|
|
||||||
|
_container = Container()
|
||||||
|
_container.db_service().connect()
|
||||||
|
_container.db_service().init_schema()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Answer collection via API ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _collect_answer(api_url: str, token: str, message: str) -> str:
|
||||||
|
"""youlbot /chat SSE 스트림에서 순수 답변 텍스트만 수집."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||||
|
parts: list[str] = []
|
||||||
|
async with httpx.AsyncClient(timeout=180) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{api_url}/chat",
|
||||||
|
json={"message": message, "user_id": "eval", "show_thinking": False},
|
||||||
|
headers=headers,
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
payload = json.loads(line[6:])
|
||||||
|
if isinstance(payload, str):
|
||||||
|
parts.append(payload)
|
||||||
|
elif isinstance(payload, dict) and payload.get("__done"):
|
||||||
|
await resp.aclose()
|
||||||
|
break
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_answer(api_url: str, token: str, message: str) -> str:
|
||||||
|
return asyncio.run(_collect_answer(api_url, token, message))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Evaluator LLM 선택 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_evaluator_llm():
|
||||||
|
"""평가용 LLM: OpenAI > Anthropic > 로컬 MLX 순으로 시도."""
|
||||||
|
if os.getenv("OPENAI_API_KEY"):
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
print("[RAGAS] 평가 LLM: OpenAI GPT-4o-mini")
|
||||||
|
return LangchainLLMWrapper(ChatOpenAI(model="gpt-4o-mini", temperature=0))
|
||||||
|
|
||||||
|
if os.getenv("ANTHROPIC_API_KEY"):
|
||||||
|
from langchain_anthropic import ChatAnthropic
|
||||||
|
print("[RAGAS] 평가 LLM: Anthropic Claude Haiku")
|
||||||
|
return LangchainLLMWrapper(
|
||||||
|
ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0)
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[RAGAS] 평가 LLM: 로컬 Qwen3 (신뢰도 제한적)")
|
||||||
|
return LangchainLLMWrapper(_container.chat_model())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_evaluator_embeddings():
|
||||||
|
return LangchainEmbeddingsWrapper(_container.embeddings())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run(dataset_path: str, api_url: str, api_token: str) -> None:
|
||||||
|
# 1. 데이터셋 로드
|
||||||
|
samples = []
|
||||||
|
with open(dataset_path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
samples.append(json.loads(line))
|
||||||
|
|
||||||
|
if not samples:
|
||||||
|
print(f"[오류] 데이터셋이 비어 있습니다: {dataset_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"[RAGAS] 평가 시작 — {len(samples)}개 질문, API: {api_url}")
|
||||||
|
|
||||||
|
# 2. RetrieverService 초기화
|
||||||
|
retriever = _container.retriever_service()
|
||||||
|
|
||||||
|
# 3. 질문별 context + answer 수집
|
||||||
|
questions: list[str] = []
|
||||||
|
answers: list[str] = []
|
||||||
|
contexts: list[list[str]] = []
|
||||||
|
ground_truths: list[str] = []
|
||||||
|
|
||||||
|
for i, sample in enumerate(samples, 1):
|
||||||
|
q = sample["question"]
|
||||||
|
gt = sample["ground_truth"]
|
||||||
|
print(f"\n[{i}/{len(samples)}] {q[:50]}...")
|
||||||
|
|
||||||
|
docs = retriever.search(q)
|
||||||
|
ctxs = [doc.page_content for doc in docs]
|
||||||
|
print(f" 컨텍스트: {len(ctxs)}개 청크")
|
||||||
|
|
||||||
|
answer = collect_answer(api_url, api_token, q)
|
||||||
|
print(f" 답변: {len(answer)}자")
|
||||||
|
|
||||||
|
questions.append(q)
|
||||||
|
answers.append(answer)
|
||||||
|
contexts.append(ctxs)
|
||||||
|
ground_truths.append(gt)
|
||||||
|
|
||||||
|
# 4. RAGAS Dataset
|
||||||
|
ds = Dataset.from_dict(
|
||||||
|
{
|
||||||
|
"question": questions,
|
||||||
|
"answer": answers,
|
||||||
|
"contexts": contexts,
|
||||||
|
"ground_truth": ground_truths,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 평가 실행
|
||||||
|
llm = _build_evaluator_llm()
|
||||||
|
emb = _build_evaluator_embeddings()
|
||||||
|
|
||||||
|
# 로컬 LLM은 응답이 느리므로 타임아웃을 충분히 크게, 병렬 작업 수를 줄임
|
||||||
|
from ragas.run_config import RunConfig
|
||||||
|
run_cfg = RunConfig(timeout=600, max_retries=1, max_workers=1)
|
||||||
|
|
||||||
|
print("\n[RAGAS] 지표 계산 중... (로컬 LLM 사용 시 수 분 소요)")
|
||||||
|
result = evaluate(
|
||||||
|
ds,
|
||||||
|
metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
|
||||||
|
llm=llm,
|
||||||
|
embeddings=emb,
|
||||||
|
run_config=run_cfg,
|
||||||
|
raise_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. 결과 출력 및 저장
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
results_dir = ROOT / "eval" / "results"
|
||||||
|
results_dir.mkdir(exist_ok=True)
|
||||||
|
out_csv = results_dir / f"report_{ts}.csv"
|
||||||
|
out_json = results_dir / f"report_{ts}.json"
|
||||||
|
|
||||||
|
df = result.to_pandas()
|
||||||
|
df.to_csv(out_csv, index=False, encoding="utf-8-sig")
|
||||||
|
|
||||||
|
# 점수 추출: to_pandas() 컬럼 평균으로 안전하게 계산 (타임아웃 시 NaN 처리)
|
||||||
|
def _score(col: str) -> float | None:
|
||||||
|
if col not in df.columns:
|
||||||
|
return None
|
||||||
|
val = df[col].dropna().mean()
|
||||||
|
return float(val) if not (val != val) else None # NaN 체크
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"timestamp": ts,
|
||||||
|
"dataset": dataset_path,
|
||||||
|
"n_samples": len(samples),
|
||||||
|
"scores": {
|
||||||
|
"faithfulness": _score("faithfulness"),
|
||||||
|
"answer_relevancy": _score("answer_relevancy"),
|
||||||
|
"context_recall": _score("context_recall"),
|
||||||
|
"context_precision": _score("context_precision"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"\n{'='*55}")
|
||||||
|
print("RAGAS 평가 결과")
|
||||||
|
print("="*55)
|
||||||
|
for k, v in summary["scores"].items():
|
||||||
|
bar = "█" * int((v or 0) * 20) if v is not None else ""
|
||||||
|
score_str = f"{v:.3f}" if v is not None else "N/A"
|
||||||
|
print(f" {k:<22} {score_str} {bar}")
|
||||||
|
print("="*55)
|
||||||
|
print(f"CSV : {out_csv}")
|
||||||
|
print(f"JSON: {out_json}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="youlbot RAGAS 평가")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dataset",
|
||||||
|
default=str(ROOT / "eval" / "dataset.jsonl"),
|
||||||
|
help="평가 데이터셋 경로 (기본: eval/dataset.jsonl)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api",
|
||||||
|
default=os.getenv("YOULBOT_API_URL", "http://localhost:8000"),
|
||||||
|
help="youlbot API URL (기본: http://localhost:8000)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(ROOT / ".env")
|
||||||
|
api_token = os.getenv("API_TOKEN", "")
|
||||||
|
|
||||||
|
run(args.dataset, args.api, api_token)
|
||||||
@@ -10,7 +10,7 @@ from langgraph.config import get_stream_writer
|
|||||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||||
from langgraph.prebuilt import ToolNode
|
from langgraph.prebuilt import ToolNode
|
||||||
|
|
||||||
from services.agent.tools import get_current_date, make_memory_tools, make_retriever_tool, make_search_tool, web_search
|
from services.agent.tools import get_current_date, make_memory_tools, make_retriever_tool, make_search_tool, make_vision_tool, web_search
|
||||||
|
|
||||||
|
|
||||||
class AgentService:
|
class AgentService:
|
||||||
@@ -71,11 +71,13 @@ class AgentService:
|
|||||||
search_tool = make_search_tool(retriever_service, self._source_buffer)
|
search_tool = make_search_tool(retriever_service, self._source_buffer)
|
||||||
else:
|
else:
|
||||||
search_tool = make_retriever_tool(retriever_service)
|
search_tool = make_retriever_tool(retriever_service)
|
||||||
tools = [search_tool, web_search, get_current_date]
|
self._base_tools = [search_tool, web_search, get_current_date]
|
||||||
if user_profile_repository is not None:
|
if user_profile_repository is not None:
|
||||||
remember_tool, recall_tool = make_memory_tools(user_profile_repository, user_id)
|
remember_tool, recall_tool = make_memory_tools(user_profile_repository, user_id)
|
||||||
tools += [remember_tool, recall_tool]
|
self._base_tools += [remember_tool, recall_tool]
|
||||||
llm_with_tools = chat_model.bind_tools(tools)
|
self._vision_model = None # set via set_vision_model()
|
||||||
|
self._llm_with_tools = chat_model.bind_tools(self._base_tools)
|
||||||
|
self._chat_model = chat_model
|
||||||
|
|
||||||
async def call_model(state: MessagesState, config: RunnableConfig) -> dict:
|
async def call_model(state: MessagesState, config: RunnableConfig) -> dict:
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -123,9 +125,16 @@ class AgentService:
|
|||||||
# LLM 추론 시작 직전에 즉시 신호 emit — UI에 "분석 중" 표시
|
# LLM 추론 시작 직전에 즉시 신호 emit — UI에 "분석 중" 표시
|
||||||
if writer:
|
if writer:
|
||||||
writer({"__start": True})
|
writer({"__start": True})
|
||||||
# 체크박스 값을 모델의 enable_thinking으로 전달 (런타임 오버라이드)
|
# 이미지 첨부 시 vision tool 동적 추가 (요청별로 독립적으로 바인딩)
|
||||||
show_thinking = config.get("configurable", {}).get("show_thinking", False)
|
cfg = config.get("configurable", {})
|
||||||
_llm = llm_with_tools.bind(enable_thinking=show_thinking) if show_thinking != chat_model.enable_thinking else llm_with_tools
|
show_thinking = cfg.get("show_thinking", False)
|
||||||
|
image_path = cfg.get("image_path")
|
||||||
|
if image_path and self._vision_model:
|
||||||
|
tools_for_req = self._base_tools + [make_vision_tool(self._vision_model, image_path)]
|
||||||
|
_llm_base = self._chat_model.bind_tools(tools_for_req)
|
||||||
|
else:
|
||||||
|
_llm_base = self._llm_with_tools
|
||||||
|
_llm = _llm_base.bind(enable_thinking=show_thinking) if show_thinking != chat_model.enable_thinking else _llm_base
|
||||||
async for chunk in _llm.astream(msgs, config):
|
async for chunk in _llm.astream(msgs, config):
|
||||||
t = chunk.additional_kwargs.get("thinking", "")
|
t = chunk.additional_kwargs.get("thinking", "")
|
||||||
if t:
|
if t:
|
||||||
@@ -221,10 +230,21 @@ class AgentService:
|
|||||||
def last_run_id(self) -> str | None:
|
def last_run_id(self) -> str | None:
|
||||||
return self._last_run_id
|
return self._last_run_id
|
||||||
|
|
||||||
def _make_config(self, show_thinking: bool = False) -> dict:
|
def set_vision_model(self, vision_model) -> None:
|
||||||
return {"configurable": {"thread_id": self._thread_id, "show_thinking": show_thinking}}
|
self._vision_model = vision_model
|
||||||
|
|
||||||
async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str | dict]:
|
def _make_config(self, show_thinking: bool = False, image_path: str | None = None) -> dict:
|
||||||
|
cfg: dict = {"thread_id": self._thread_id, "show_thinking": show_thinking}
|
||||||
|
if image_path:
|
||||||
|
cfg["image_path"] = image_path
|
||||||
|
return {"configurable": cfg}
|
||||||
|
|
||||||
|
async def stream_response(
|
||||||
|
self,
|
||||||
|
user_input: str,
|
||||||
|
show_thinking: bool | None = None,
|
||||||
|
image_path: str | None = None,
|
||||||
|
) -> AsyncIterator[str | dict]:
|
||||||
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다.
|
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다.
|
||||||
|
|
||||||
실제 답변: plain str
|
실제 답변: plain str
|
||||||
@@ -233,7 +253,7 @@ class AgentService:
|
|||||||
_think_verbose = show_thinking if show_thinking is not None else self._think_verbose
|
_think_verbose = show_thinking if show_thinking is not None else self._think_verbose
|
||||||
self._source_buffer.clear()
|
self._source_buffer.clear()
|
||||||
run_id = uuid.uuid4()
|
run_id = uuid.uuid4()
|
||||||
run_config = {**self._make_config(_think_verbose), "run_id": str(run_id)}
|
run_config = {**self._make_config(_think_verbose, image_path=image_path), "run_id": str(run_id)}
|
||||||
|
|
||||||
# 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입
|
# 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입
|
||||||
if self._pending_history:
|
if self._pending_history:
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ from datetime import date
|
|||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
|
||||||
|
|
||||||
|
def make_vision_tool(vision_model, image_path: str):
|
||||||
|
"""현재 요청에 첨부된 이미지를 분석하는 도구."""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def analyze_image(prompt: str = "이 이미지를 한국어로 자세히 설명해줘.") -> str:
|
||||||
|
"""첨부된 이미지를 분석한다. 이미지 속 음식, 문서, 사람, 사물 등을 파악할 때 사용하세요."""
|
||||||
|
return vision_model.analyze(image_path, prompt)
|
||||||
|
|
||||||
|
return analyze_image
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def get_current_date() -> str:
|
def get_current_date() -> str:
|
||||||
"""오늘 날짜를 반환합니다. 나이 계산, 날짜 비교 등 현재 날짜가 필요할 때 반드시 먼저 호출하세요."""
|
"""오늘 날짜를 반환합니다. 나이 계산, 날짜 비교 등 현재 날짜가 필요할 때 반드시 먼저 호출하세요."""
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Qwen2.5-VL (mlx-vlm) 기반 이미지 분석 서비스.
|
||||||
|
|
||||||
|
첫 analyze() 호출 시 모델을 lazy load해 메모리를 아낀다.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_PROMPT = "이 이미지를 한국어로 자세히 설명해줘. 사람, 음식, 문서 등 보이는 것을 빠짐없이 설명해."
|
||||||
|
|
||||||
|
|
||||||
|
class MlxVisionModel:
|
||||||
|
def __init__(self, model_id: str, max_tokens: int = 512) -> None:
|
||||||
|
self._model_id = model_id
|
||||||
|
self._max_tokens = max_tokens
|
||||||
|
self._model = None
|
||||||
|
self._processor = None
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if self._model is not None:
|
||||||
|
return
|
||||||
|
logger.info("Vision 모델 로딩 중: %s", self._model_id)
|
||||||
|
from mlx_vlm import load
|
||||||
|
self._model, self._processor = load(self._model_id)
|
||||||
|
logger.info("Vision 모델 로딩 완료")
|
||||||
|
|
||||||
|
def analyze(self, image_path: str, prompt: str = _DEFAULT_PROMPT) -> str:
|
||||||
|
"""이미지를 분석해 한국어 설명을 반환한다."""
|
||||||
|
self._load()
|
||||||
|
from mlx_vlm import generate
|
||||||
|
from mlx_vlm.prompt_utils import apply_chat_template
|
||||||
|
from mlx_vlm.utils import load_config
|
||||||
|
|
||||||
|
config = load_config(self._model_id)
|
||||||
|
formatted_prompt = apply_chat_template(
|
||||||
|
self._processor, config, prompt, num_images=1
|
||||||
|
)
|
||||||
|
result = generate(
|
||||||
|
self._model,
|
||||||
|
self._processor,
|
||||||
|
image=image_path,
|
||||||
|
prompt=formatted_prompt,
|
||||||
|
max_tokens=self._max_tokens,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
return result if isinstance(result, str) else str(result)
|
||||||
Reference in New Issue
Block a user