280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""
|
|
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,
|
|
}
|