""" GUARDiA 디자인 AI — Ollama llava 비전으로 스크린샷 분석 + 디자인 SR 자동 처리 엔드포인트: POST /api/design/analyze — 스크린샷 Ollama llava 분석 POST /api/design/review — 디자인 리뷰 보고서 생성 POST /api/design/auto-resolve — 디자인 SR 자동 분류·처리 GET /api/design/sr-queue — 디자인 SR 목록 GET /api/design/stats — 디자인 SR 통계 """ from __future__ import annotations import base64, json, logging, re from datetime import datetime from pathlib import Path from typing import Optional import httpx from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlalchemy import select, desc, func from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import User, DesignSR logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/design", tags=["디자인 AI"]) OLLAMA_URL = "http://localhost:11434" VISION_MODEL = "llava:7b" TEXT_MODEL = "llama3" UPLOAD_DIR = Path("/opt/guardia/app/uploads/icons") UPLOAD_DIR.mkdir(parents=True, exist_ok=True) # 디자인 SR 키워드 분류 DESIGN_KEYWORDS = { "CSS_CHANGE": ["색상", "color", "배경", "background", "버튼", "button", "폰트", "font", "글꼴", "크기", "size", "여백", "margin", "패딩"], "ICON_REQUEST": ["아이콘", "icon", "이미지", "image", "그림", "픽토그램", "svg", "로고", "logo", "심볼"], "LAYOUT_CHANGE": ["레이아웃", "layout", "정렬", "align", "위치", "position", "배치", "그리드", "grid", "열", "column"], "UI_REVIEW": ["ui", "ux", "전체", "개편", "리뷰", "review", "개선", "사용성", "스크린샷", "screenshot"], "TYPOGRAPHY": ["폰트", "font", "텍스트", "text", "글자", "글씨", "크기"], } def _classify_design_sr(text: str) -> str: text_lower = text.lower() for dtype, keywords in DESIGN_KEYWORDS.items(): if any(kw in text_lower for kw in keywords): return dtype return "GENERAL" async def _analyze_with_vision(image_bytes: bytes, prompt: str) -> str: """Ollama llava로 이미지 분석.""" img_b64 = base64.b64encode(image_bytes).decode() try: async with httpx.AsyncClient(timeout=60) as c: r = await c.post(f"{OLLAMA_URL}/api/generate", json={ "model": VISION_MODEL, "prompt": prompt, "images": [img_b64], "stream": False, }) return r.json().get("response", "분석 실패") except Exception as e: logger.warning(f"Vision 분석 실패: {e}") return f"비전 분석 오류: {e}" async def _text_generate(prompt: str, system: str = "") -> str: """Ollama 텍스트 생성.""" try: async with httpx.AsyncClient(timeout=30) as c: r = await c.post(f"{OLLAMA_URL}/api/generate", json={ "model": TEXT_MODEL, "system": system, "prompt": prompt, "stream": False, }) return r.json().get("response", "") except Exception as e: return f"생성 오류: {e}" class ReviewRequest(BaseModel): requirement: str sr_id: Optional[int] = None design_type: Optional[str] = None class AutoResolveIn(BaseModel): sr_id: Optional[int] = None requirement: str priority: str = "MEDIUM" # ── 엔드포인트 ───────────────────────────────────────────────────────────────── @router.post("/analyze") async def analyze_screenshot( file: UploadFile = File(...), question: str = Form("이 UI의 개선점을 한국어로 3가지 제안해줘"), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """스크린샷 업로드 → Ollama llava 분석 → 개선 제안.""" if not file.content_type.startswith("image/"): raise HTTPException(400, "이미지 파일만 업로드 가능합니다") img_bytes = await file.read() if len(img_bytes) > 10 * 1024 * 1024: raise HTTPException(413, "10MB 이하 이미지만 지원합니다") prompt = f"""당신은 공공기관 UI/UX 전문가입니다. 이 스크린샷을 분석하고 다음 질문에 답하세요: {question} 분석 기준: 1. 가독성 (폰트 크기, 대비, 간격) 2. 일관성 (색상, 컴포넌트 스타일) 3. 접근성 (KWCAG 2.1 준수) 4. 공공기관 표준 UI 가이드 적합성 구체적인 CSS 수정안 또는 디자인 가이드를 포함해주세요.""" analysis = await _analyze_with_vision(img_bytes, prompt) # DB 저장 design_sr = DesignSR( design_type="UI_REVIEW", requirement=question, ai_analysis=analysis, status="ANALYZED", resolved_by_ai=False, created_by=user.id, created_at=datetime.utcnow(), ) db.add(design_sr) await db.commit() await db.refresh(design_sr) return { "analysis_id": design_sr.id, "analysis": analysis, "model": VISION_MODEL, "tip": "분석 결과를 SR에 첨부하거나 /api/design/auto-resolve로 자동 처리하세요", } @router.post("/review") async def design_review(body: ReviewRequest, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """텍스트 요구사항 기반 디자인 리뷰 보고서.""" design_type = body.design_type or _classify_design_sr(body.requirement) system = """당신은 공공기관 UI/UX 디자인 전문가입니다. 디자인 요구사항을 분석하고 구체적인 구현 방안을 제시합니다. CSS 코드, SVG, 또는 Tailwind 클래스를 포함한 실용적인 답변을 제공합니다.""" prompt = f"""디자인 요구사항: {body.requirement} 분류: {design_type} 다음 형식으로 답변해주세요: 1. 분석: (요구사항 해석) 2. 구현 방안: (CSS/SVG 코드 포함) 3. 적용 방법: (단계별 가이드) 4. 주의사항: (공공기관 접근성 기준)""" analysis = await _text_generate(prompt, system) design_sr = DesignSR( sr_id=body.sr_id, design_type=design_type, requirement=body.requirement, ai_analysis=analysis, status="REVIEWED", resolved_by_ai=False, created_by=user.id, created_at=datetime.utcnow(), ) db.add(design_sr) await db.commit() await db.refresh(design_sr) return { "review_id": design_sr.id, "design_type": design_type, "analysis": analysis, } @router.post("/auto-resolve") async def auto_resolve_sr(body: AutoResolveIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """디자인 SR 자동 분류 + AI 처리.""" design_type = _classify_design_sr(body.requirement) # 유형별 특화 처리 if design_type == "CSS_CHANGE": system = "CSS 전문가. 요구사항을 정확한 CSS 코드로 변환. 코드 블록만 출력." prompt = f"다음 디자인 변경을 CSS로 작성 (GUARDiA 변수: --primary: #003366):\n{body.requirement}" elif design_type == "ICON_REQUEST": system = "SVG 아이콘 전문가. 24x24 픽토그램 SVG 코드만 출력." prompt = f"다음 아이콘을 SVG로 생성 (viewBox='0 0 24 24', fill='#003366'):\n{body.requirement}" elif design_type == "TYPOGRAPHY": system = "CSS 타이포그래피 전문가. 폰트/텍스트 CSS만 출력." prompt = f"다음 타이포그래피 요구사항을 CSS로:\n{body.requirement}" else: system = "UI/UX 디자인 전문가. 구체적 개선안 제시." prompt = f"다음 디자인 요구사항 처리 방안:\n{body.requirement}" generated = await _text_generate(prompt, system) # 코드 블록 추출 code_match = re.search(r'```(?:css|svg|html)?\n?([\s\S]+?)```', generated) generated_code = code_match.group(1).strip() if code_match else generated[:500] design_sr = DesignSR( sr_id=body.sr_id, design_type=design_type, requirement=body.requirement, ai_analysis=generated, generated_code=generated_code, status="AI_RESOLVED", resolved_by_ai=True, created_by=user.id, created_at=datetime.utcnow(), ) db.add(design_sr) await db.commit() await db.refresh(design_sr) return { "design_sr_id": design_sr.id, "design_type": design_type, "generated_code": generated_code, "full_analysis": generated, "resolved_by_ai": True, "next_step": "운영자 검토 후 /api/design/sr-queue에서 승인하세요", } @router.get("/sr-queue") async def get_sr_queue(limit: int = 20, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): rows = await db.execute( select(DesignSR).order_by(desc(DesignSR.created_at)).limit(limit) ) items = rows.scalars().all() return [ {"id": s.id, "design_type": s.design_type, "requirement": s.requirement[:80], "status": s.status, "resolved_by_ai": s.resolved_by_ai, "has_code": bool(s.generated_code), "created_at": s.created_at} for s in items ] @router.get("/stats") async def design_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): total = (await db.execute(select(func.count(DesignSR.id)))).scalar() or 0 ai_resolved = (await db.execute( select(func.count(DesignSR.id)).where(DesignSR.resolved_by_ai == True) )).scalar() or 0 by_type = {} for dt in ["CSS_CHANGE", "ICON_REQUEST", "UI_REVIEW", "LAYOUT_CHANGE", "TYPOGRAPHY"]: cnt = (await db.execute( select(func.count(DesignSR.id)).where(DesignSR.design_type == dt) )).scalar() or 0 by_type[dt] = cnt return { "total": total, "ai_resolved": ai_resolved, "ai_resolution_rate": round(ai_resolved / max(total, 1) * 100, 1), "by_type": by_type, "vision_model": VISION_MODEL, }