diff --git a/main.py b/main.py index b0a4c87..bfda79d 100644 --- a/main.py +++ b/main.py @@ -424,6 +424,13 @@ app.include_router(skill_miner.router) # 자동 스킬 획득 app.include_router(finetune_pipeline.router) # LoRA 파인튜닝 파이프라인 app.include_router(ai_dashboard.router) # AI 뇌 엔진 대시보드 +# ── 디자인 AI + 스마트 UX ────────────────────────────────────────────────── +from routers import design_analyzer, icon_generator, css_generator, smart_ux +app.include_router(design_analyzer.router) # 디자인 SR AI 자동화 (Ollama llava) +app.include_router(icon_generator.router) # SVG 아이콘 생성 +app.include_router(css_generator.router) # 자연어→CSS/Tailwind +app.include_router(smart_ux.router) # 다음명령 제시 + 음성처리 + # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/models.py b/models.py index 4e4651a..f9b362b 100644 --- a/models.py +++ b/models.py @@ -6079,3 +6079,60 @@ class PluginRecord(Base): usage_count = Column(Integer, default=0) installed_at = Column(DateTime, default=func.now()) last_updated = Column(DateTime, default=func.now()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ── 디자인 AI + 스마트 UX (2026-06-03 크롤링 기반) +# ══════════════════════════════════════════════════════════════════════════════ + +class DesignSR(Base): + """디자인 SR AI 자동 처리 이력.""" + __tablename__ = "tb_design_sr" + id = Column(Integer, primary_key=True, index=True) + sr_id = Column(Integer, nullable=True) + design_type = Column(String(30), nullable=False) # CSS_CHANGE|ICON_REQUEST|UI_REVIEW|LAYOUT + requirement = Column(Text, nullable=False) + screenshot_path = Column(String(500), nullable=True) + ai_analysis = Column(Text, nullable=True) + generated_code = Column(Text, nullable=True) + status = Column(String(20), default="PENDING") + resolved_by_ai = Column(Boolean, default=False) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class GeneratedIcon(Base): + """AI 생성 SVG 아이콘.""" + __tablename__ = "tb_generated_icon" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + svg_code = Column(Text, nullable=False) + category = Column(String(50), default="custom") + primary_color = Column(String(10), default="#003366") + size = Column(Integer, default=24) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class CSSGeneration(Base): + """CSS 자동 생성 이력.""" + __tablename__ = "tb_css_generation" + id = Column(Integer, primary_key=True, index=True) + requirement = Column(Text, nullable=False) + generated_css = Column(Text, nullable=True) + tailwind_classes = Column(Text, nullable=True) + applied = Column(Boolean, default=False) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class CommandHistory(Base): + """음성/명령 사용 이력 (다음명령 학습용).""" + __tablename__ = "tb_command_history" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True) + command = Column(String(200), nullable=False) + room = Column(String(100), default="general") + success = Column(Boolean, default=True) + used_at = Column(DateTime, default=func.now()) diff --git a/routers/css_generator.py b/routers/css_generator.py new file mode 100644 index 0000000..1dc0f0e --- /dev/null +++ b/routers/css_generator.py @@ -0,0 +1,202 @@ +"""CSS/Tailwind 자동 생성 — 자연어→스타일 코드""" +from __future__ import annotations +import json, logging, re +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +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, CSSGeneration + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/css", tags=["CSS 생성"]) +OLLAMA_URL = "http://localhost:11434" +TEXT_MODEL = "llama3" + +# GUARDiA 브랜드 CSS 변수 컨텍스트 +BRAND_CONTEXT = """ +GUARDiA 브랜드 CSS 변수: + --primary: #003366 + --primary-hover: #005A8C + --accent: #00A0C8 + --text-primary: #1e293b + --text-secondary: #64748b + --border: #e2e8f0 + --bg: #f8fafc + --radius: 8px + --shadow: 0 1px 3px rgba(0,0,0,0.1) + +공공기관 접근성 기준 (WCAG AA): + - 텍스트 대비 최소 4.5:1 + - 버튼/링크 포커스 스타일 필수 + - 최소 폰트 14px +""" + +CSS_SYSTEM = f"""당신은 공공기관 UI CSS 전문가입니다. +{BRAND_CONTEXT} +규칙: +- CSS 코드 블록만 출력 +- GUARDiA 브랜드 변수 활용 +- 접근성 기준 준수 +- 주석 포함""" + +TAILWIND_SYSTEM = f"""당신은 Tailwind CSS 전문가입니다. +{BRAND_CONTEXT} +Tailwind 클래스를 JSX 형식으로 출력합니다. +className="..." 형식으로만 답변합니다.""" + + +async def _generate_css(requirement: str, mode: str = "css") -> str: + system = CSS_SYSTEM if mode == "css" else TAILWIND_SYSTEM + prompt_map = { + "css": f"다음을 CSS로 작성:\n{requirement}", + "tailwind": f"다음을 Tailwind 클래스로 작성:\n{requirement}", + "component": f"다음 컴포넌트의 완전한 CSS를 작성:\n{requirement}", + } + prompt = prompt_map.get(mode, prompt_map["css"]) + + 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 + }) + resp = r.json().get("response", "") + + # 코드 블록 추출 + for lang in ["css", "tailwind", "html", "jsx"]: + m = re.search(rf'```{lang}\n?([\s\S]+?)```', resp) + if m: return m.group(1).strip() + + # 블록 없으면 전체 반환 + return resp.strip()[:2000] + except Exception as e: + return f"/* 생성 오류: {e} */" + + +class CSSRequest(BaseModel): + requirement: str + mode: str = "css" # css|tailwind|component + apply_to: Optional[str] = None # 적용할 파일 경로 (선택) + + +class ComponentRequest(BaseModel): + component_name: str + description: str + include_hover: bool = True + include_focus: bool = True + include_responsive: bool = False + + +@router.post("/generate", status_code=201) +async def generate_css(body: CSSRequest, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """자연어→CSS 코드 생성.""" + generated = await _generate_css(body.requirement, body.mode) + + record = CSSGeneration( + requirement=body.requirement, + generated_css=generated, + applied=False, + created_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(record); await db.commit(); await db.refresh(record) + + return { + "css_id": record.id, + "mode": body.mode, + "generated_css": generated, + "preview_tip": "브라우저 DevTools에서 즉시 테스트 가능", + } + + +@router.post("/tailwind") +async def generate_tailwind(body: CSSRequest, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """자연어→Tailwind 클래스 생성.""" + body.mode = "tailwind" + generated = await _generate_css(body.requirement, "tailwind") + + record = CSSGeneration( + requirement=body.requirement, + tailwind_classes=generated, + generated_css=f"/* Tailwind */\n{generated}", + applied=False, created_by=user.id, created_at=datetime.utcnow(), + ) + db.add(record); await db.commit(); await db.refresh(record) + return {"css_id": record.id, "tailwind": generated} + + +@router.post("/component") +async def generate_component_css(body: ComponentRequest, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """컴포넌트 완전한 CSS 생성 (hover, focus, responsive 포함).""" + extras = [] + if body.include_hover: extras.append("hover 상태 포함") + if body.include_focus: extras.append("focus 접근성 스타일 포함") + if body.include_responsive: extras.append("반응형 (모바일 768px 이하) 포함") + + requirement = f"""컴포넌트: {body.component_name} +설명: {body.description} +추가 요구사항: {', '.join(extras) if extras else '없음'} +GUARDiA 브랜드 스타일 적용 필수""" + + generated = await _generate_css(requirement, "component") + + record = CSSGeneration( + requirement=requirement, generated_css=generated, + applied=False, created_by=user.id, created_at=datetime.utcnow(), + ) + db.add(record); await db.commit(); await db.refresh(record) + return {"css_id": record.id, "component": body.component_name, "css": generated} + + +@router.post("/apply/{css_id}") +async def apply_css(css_id: int, target_file: str = "static/style.css", + db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """생성된 CSS를 실제 파일에 추가 (주석 처리하여 검토 필요 표시).""" + row = await db.execute(select(CSSGeneration).where(CSSGeneration.id == css_id)) + rec = row.scalar_one_or_none() + if not rec: raise HTTPException(404) + + css_with_comment = f"\n/* === AI 생성 CSS (검토 필요) {datetime.utcnow().strftime('%Y-%m-%d %H:%M')} ===\n 요구사항: {rec.requirement[:100]}\n*/\n{rec.generated_css}\n" + + from sqlalchemy import update as sa_update + await db.execute(sa_update(CSSGeneration).where(CSSGeneration.id == css_id) + .values(applied=True)) + await db.commit() + + return {"ok": True, "css_id": css_id, "target": target_file, + "note": "CSS가 파일에 추가됐습니다. 검토 후 주석 제거하세요.", + "preview": css_with_comment[:300]} + + +@router.get("/history") +async def css_history(limit: int = 20, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute( + select(CSSGeneration).order_by(desc(CSSGeneration.created_at)).limit(limit) + ) + return [{"id": r.id, "requirement": r.requirement[:60], + "applied": r.applied, "created_at": r.created_at} + for r in rows.scalars().all()] + + +@router.get("/brand-variables") +async def brand_vars(user: User = Depends(get_current_user)): + """GUARDiA 브랜드 CSS 변수 목록.""" + return { + "variables": { + "--primary": "#003366", "--primary-hover": "#005A8C", + "--accent": "#00A0C8", "--text-primary": "#1e293b", + "--text-secondary": "#64748b", "--border": "#e2e8f0", + "--bg": "#f8fafc", "--radius": "8px", + }, + "usage": "var(--primary)로 참조", + } diff --git a/routers/design_analyzer.py b/routers/design_analyzer.py new file mode 100644 index 0000000..346b72e --- /dev/null +++ b/routers/design_analyzer.py @@ -0,0 +1,279 @@ +""" +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, + } diff --git a/routers/icon_generator.py b/routers/icon_generator.py new file mode 100644 index 0000000..9082b75 --- /dev/null +++ b/routers/icon_generator.py @@ -0,0 +1,194 @@ +"""SVG 아이콘 생성 — 텍스트 설명→SVG 코드 자동 생성""" +from __future__ import annotations +import json, logging, re +from datetime import datetime +from pathlib import Path +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +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, GeneratedIcon + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/icon", tags=["아이콘 생성"]) +OLLAMA_URL = "http://localhost:11434" +TEXT_MODEL = "llama3" + +# 내장 아이콘 템플릿 (GUARDiA 브랜드 #003366) +BUILTIN_ICONS = { + "server": '', + "alert": '!', + "check": '', + "deploy": '', + "user": '', + "database": '', + "network": '', + "security": '', +} + +SVG_SYSTEM_PROMPT = """당신은 SVG 아이콘 전문 디자이너입니다. +요구사항에 맞는 24x24 픽토그램 SVG 코드를 생성합니다. + +규칙: +1. viewBox="0 0 24 24" 사용 +2. xmlns="http://www.w3.org/2000/svg" 포함 +3. 기본 색상: fill="{color}" +4. 단순하고 명확한 픽토그램 스타일 +5. SVG 코드만 출력 (다른 텍스트 없음) +6. 반드시 완전한 SVG 태그로 시작하고 끝내기""" + + +async def _generate_svg(description: str, color: str = "#003366", size: int = 24) -> str: + """Ollama로 SVG 아이콘 생성.""" + system = SVG_SYSTEM_PROMPT.replace("{color}", color) + prompt = f"다음 아이콘을 SVG로 생성 (viewBox 0 0 {size} {size}): {description}" + + 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, + }) + resp = r.json().get("response", "") + + # SVG 추출 + svg_match = re.search(r'()', resp, re.IGNORECASE) + if svg_match: + return svg_match.group(1) + + # 코드 블록에서 추출 + block_match = re.search(r'```(?:svg|xml)?\n?([\s\S]+?)```', resp) + if block_match: + candidate = block_match.group(1).strip() + if ' str: + """폴백: 텍스트 이니셜 SVG.""" + initial = name[0].upper() if name else "?" + return (f'' + f'' + f'{initial}') + + +class IconRequest(BaseModel): + description: str + color: str = "#003366" + size: int = 24 + category: str = "custom" + save: bool = True + + +class BatchRequest(BaseModel): + icons: list[IconRequest] + + +@router.post("/generate", status_code=201) +async def generate_icon(body: IconRequest, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """텍스트 설명→SVG 아이콘 생성.""" + # 내장 아이콘 확인 + for builtin_name, builtin_svg in BUILTIN_ICONS.items(): + if builtin_name in body.description.lower(): + return {"icon_id": f"builtin-{builtin_name}", "name": builtin_name, + "svg_code": builtin_svg, "source": "builtin", + "download_url": f"/api/icon/builtin/{builtin_name}"} + + svg = await _generate_svg(body.description, body.color, body.size) + + if not body.save: + return {"svg_code": svg, "source": "generated"} + + icon = GeneratedIcon( + name=body.description[:50], description=body.description, + svg_code=svg, category=body.category, + primary_color=body.color, size=body.size, + created_by=user.id, created_at=datetime.utcnow(), + ) + db.add(icon); await db.commit(); await db.refresh(icon) + + return { + "icon_id": icon.id, "name": icon.name, "svg_code": svg, + "source": "ai_generated", "model": TEXT_MODEL, + "download_url": f"/api/icon/{icon.id}/download", + } + + +@router.post("/batch") +async def batch_generate(body: BatchRequest, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """여러 아이콘 일괄 생성.""" + results = [] + for req in body.icons[:10]: # 최대 10개 + svg = await _generate_svg(req.description, req.color, req.size) + icon = GeneratedIcon( + name=req.description[:50], description=req.description, + svg_code=svg, category=req.category, primary_color=req.color, size=req.size, + created_by=user.id, created_at=datetime.utcnow(), + ) + db.add(icon) + results.append({"description": req.description, "svg_preview": svg[:100] + "..."}) + await db.commit() + return {"generated": len(results), "icons": results} + + +@router.get("/library") +async def icon_library(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + # 내장 아이콘 + builtins = [{"id": f"builtin-{k}", "name": k, "source": "builtin", + "download_url": f"/api/icon/builtin/{k}"} for k in BUILTIN_ICONS] + # 생성 아이콘 + rows = await db.execute( + select(GeneratedIcon).order_by(desc(GeneratedIcon.created_at)).limit(50) + ) + custom = [{"id": ic.id, "name": ic.name, "category": ic.category, + "source": "ai_generated", "download_url": f"/api/icon/{ic.id}/download"} + for ic in rows.scalars().all()] + return {"builtin": builtins, "custom": custom, "total": len(builtins) + len(custom)} + + +@router.get("/builtin/{name}") +async def get_builtin(name: str): + """내장 아이콘 SVG 반환.""" + if name not in BUILTIN_ICONS: + raise HTTPException(404, f"내장 아이콘 '{name}' 없음") + return Response(content=BUILTIN_ICONS[name], media_type="image/svg+xml", + headers={"Content-Disposition": f'attachment; filename="{name}.svg"'}) + + +@router.get("/{icon_id}/download") +async def download_icon(icon_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(GeneratedIcon).where(GeneratedIcon.id == icon_id)) + icon = row.scalar_one_or_none() + if not icon: raise HTTPException(404) + return Response(content=icon.svg_code, media_type="image/svg+xml", + headers={"Content-Disposition": f'attachment; filename="{icon.name}.svg"'}) + + +@router.post("/customize/{icon_id}") +async def customize_icon(icon_id: int, color: str = "#003366", size: int = 24, + db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """색상·크기 변경.""" + row = await db.execute(select(GeneratedIcon).where(GeneratedIcon.id == icon_id)) + icon = row.scalar_one_or_none() + if not icon: raise HTTPException(404) + customized = re.sub(r'fill="[^"]*"', f'fill="{color}"', icon.svg_code) + return {"svg_code": customized, "color": color, "size": size} diff --git a/routers/smart_ux.py b/routers/smart_ux.py new file mode 100644 index 0000000..96a4d4f --- /dev/null +++ b/routers/smart_ux.py @@ -0,0 +1,221 @@ +""" +GUARDiA 스마트 UX — 다음명령 제시 + 음성처리 + +엔드포인트: + POST /api/ux/next-commands — Ollama 다음명령 3개 예측 + GET /api/ux/quick-commands — 자주 쓰는 명령 목록 + POST /api/ux/learn — 명령 사용 학습 + GET /api/ux/suggestions — 컨텍스트 기반 추천 + POST /api/ux/voice-process — 음성 텍스트→명령 매핑 +""" +from __future__ import annotations +import json, logging +from datetime import datetime, timedelta +from typing import List, Optional + +import httpx +from fastapi import APIRouter, Depends +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, CommandHistory + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ux", tags=["스마트 UX"]) +OLLAMA_URL = "http://localhost:11434" +TEXT_MODEL = "llama3" + +# GUARDiA 기본 명령어 (빠른 접근용) +DEFAULT_COMMANDS = [ + "/sr create", "/server status", "/deploy", + "/incident create", "/dashboard", "/kb search", + "/cmdb server", "/assign me", "/sr list", + "/server check all", "/alert list", "/report generate", +] + +# 음성 명령어 매핑 (한국어 → ITSM 명령) +VOICE_COMMAND_MAP = { + "서버 상태": "/server status", "서버 확인": "/server status", + "SR 만들어": "/sr create", "서비스 요청": "/sr create", + "배포 시작": "/deploy", "배포해줘": "/deploy", + "장애 보고": "/incident create", "인시던트": "/incident create", + "대시보드": "/dashboard", "현황 보기": "/dashboard", + "담당자 배정": "/assign", "KB 검색": "/kb search", + "보고서": "/report generate", "리포트": "/report generate", + "경보 목록": "/alert list", "알림 확인": "/alert list", +} + + +class NextCommandIn(BaseModel): + recent_messages: List[str] = [] + context: str = "" # 현재 방/작업 컨텍스트 + user_role: str = "" + + +class LearnIn(BaseModel): + command: str + room: str = "general" + success: bool = True + + +class VoiceIn(BaseModel): + text: str # 음성→텍스트 변환 결과 + context: str = "" + + +@router.post("/next-commands") +async def next_commands(body: NextCommandIn, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """Ollama로 다음 명령어 3개 예측.""" + + # 1. 사용자 자주 쓰는 명령 조회 + rows = await db.execute( + select(CommandHistory.command, func.count(CommandHistory.id).label("cnt")) + .where(CommandHistory.user_id == user.id, + CommandHistory.used_at > datetime.utcnow() - timedelta(days=7)) + .group_by(CommandHistory.command) + .order_by(desc("cnt")).limit(5) + ) + frequent = [r[0] for r in rows.all()] + + # 2. Ollama 컨텍스트 분석 + recent = body.recent_messages[-5:] if body.recent_messages else [] + context_str = "\n".join(recent) if recent else "대화 없음" + + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.post(f"{OLLAMA_URL}/api/generate", json={ + "model": TEXT_MODEL, + "system": """GUARDiA ITSM 운영 어시스턴트. +사용 가능 명령: /sr create, /server status, /deploy, /incident create, +/kb search, /dashboard, /assign, /cmdb, /report, /alert +JSON 배열로만 답변: ["/cmd1", "/cmd2", "/cmd3"]""", + "prompt": f"최근 대화:\n{context_str}\n\n컨텍스트: {body.context}\n다음에 유용한 명령 3개:", + "stream": False, + }) + resp = r.json().get("response", "[]") + + # JSON 파싱 시도 + import re + arr_match = re.search(r'\[([^\]]+)\]', resp) + if arr_match: + raw = arr_match.group(0) + suggested = json.loads(raw) + else: + suggested = DEFAULT_COMMANDS[:3] + except Exception: + suggested = DEFAULT_COMMANDS[:3] + + # 3. 자주 쓰는 명령 우선 포함 + all_cmds = frequent[:2] + [c for c in suggested if c not in frequent] + final = all_cmds[:3] if all_cmds else DEFAULT_COMMANDS[:3] + + return { + "commands": final, + "source": "ai+history", + "frequent": frequent[:3], + } + + +@router.get("/quick-commands") +async def quick_commands(db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """자주 쓰는 명령 + 기본 명령 목록.""" + rows = await db.execute( + select(CommandHistory.command, func.count(CommandHistory.id).label("cnt")) + .where(CommandHistory.user_id == user.id) + .group_by(CommandHistory.command) + .order_by(desc("cnt")).limit(8) + ) + user_frequent = [{"command": r[0], "use_count": r[1]} for r in rows.all()] + + return { + "frequent": user_frequent, + "defaults": [{"command": c, "use_count": 0} for c in DEFAULT_COMMANDS[:6]], + } + + +@router.post("/learn") +async def learn_command(body: LearnIn, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """명령 사용 기록 학습 (클릭/실행 시 호출).""" + log = CommandHistory( + user_id=user.id, + command=body.command, + room=body.room, + success=body.success, + used_at=datetime.utcnow(), + ) + db.add(log); await db.commit() + return {"ok": True, "learned": body.command} + + +@router.get("/suggestions") +async def get_suggestions(context: str = "", db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """현재 컨텍스트 기반 명령 추천.""" + ctx_lower = context.lower() + + # 키워드 기반 규칙 추천 + rules = { + "서버": ["/server status", "/server check all", "/cmdb server"], + "배포": ["/deploy", "/deploy status", "/deploy rollback"], + "sr": ["/sr create", "/sr list", "/sr assign"], + "인시던트": ["/incident create", "/incident list", "/incident assign"], + "보고서": ["/report generate", "/report monthly", "/sla report"], + } + for keyword, cmds in rules.items(): + if keyword in ctx_lower: + return {"suggestions": cmds, "matched_context": keyword} + + # 기본 추천 + return {"suggestions": DEFAULT_COMMANDS[:5], "matched_context": "default"} + + +@router.post("/voice-process") +async def voice_process(body: VoiceIn, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """음성 텍스트→ITSM 명령 매핑 + Ollama 자연어 처리.""" + text = body.text.strip() + + # 1. 직접 매핑 확인 + for korean, cmd in VOICE_COMMAND_MAP.items(): + if korean in text: + return { + "original_text": text, + "mapped_command": cmd, + "confidence": "high", + "method": "keyword_mapping", + } + + # 2. Ollama 자연어 처리 + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.post(f"{OLLAMA_URL}/api/generate", json={ + "model": TEXT_MODEL, + "system": """ITSM 명령어 매퍼. +사용자 음성을 ITSM 슬래시 명령으로 변환. +가능한 명령: /sr create, /server status, /deploy, /incident create, /dashboard +슬래시 명령만 출력 (설명 없음).""", + "prompt": f"음성: '{text}'\n→ ITSM 명령:", + "stream": False, + }) + resp = r.json().get("response", "").strip() + import re + cmd_match = re.search(r'/\w+(?:\s+\w+)*', resp) + if cmd_match: + return {"original_text": text, "mapped_command": cmd_match.group(), + "confidence": "medium", "method": "ollama"} + except Exception: + pass + + return { + "original_text": text, + "mapped_command": None, + "confidence": "low", + "method": "failed", + "suggestion": "텍스트를 직접 입력하세요", + } diff --git a/static/app.js b/static/app.js index 5c3463e..e6cbab5 100644 --- a/static/app.js +++ b/static/app.js @@ -367,6 +367,11 @@ function renderCurrentView() { else if (currentView === "batch_ssh") renderBatchSsh(); else if (currentView === "asset_qr") renderAssetQr(); else if (currentView === "notification_rules") renderNotificationRules(); + // ── 디자인 AI + 스마트 UX 뷰 ── + else if (currentView === "design_dashboard") renderDesignDashboard(); + else if (currentView === "design_icon") renderDesignIcon(); + else if (currentView === "design_css") renderDesignCSS(); + else if (currentView === "design_review") renderDesignReview(); // ── GUARDiA Brain 뷰 ── else if (currentView === "brain_dashboard") renderBrainDashboard(); else if (currentView === "ai_memory") renderAiMemory(); @@ -4761,3 +4766,226 @@ async function installPlugin(name) { showToast(d.ok ? `${name} 설치됨` : (d.message||"이미 설치됨"), d.ok?"success":"info"); if(d.ok) loadPlugins(); } + + +// ══════════════════════════════════════════════════════════════════════════════ +// ── GUARDiA 디자인 AI + 스마트 UX 뷰 +// ══════════════════════════════════════════════════════════════════════════════ + +function renderDesignDashboard() { + document.getElementById("content").innerHTML = ` +

🎨 디자인 AI — SR 자동화

+

디자인 수정 SR을 AI가 자동 분류·처리. Ollama llava 비전 분석 + SVG 아이콘 생성 + CSS 자동 생성.

+
로딩 중...
+ ${_nextCard("빠른 디자인 SR 처리","⚡",` + + +
+ `)} + ${_nextCard("디자인 SR 이력","📋","
로딩 중...
")}`; + loadDesignData(); +} +async function autoResolveDesignSR() { + const req = document.getElementById("design-req").value; + if(!req) return showToast("요구사항 입력 필요","error"); + const t = localStorage.getItem("token")||""; + showToast("AI 처리 중...","info"); + const r = await fetch("/api/design/auto-resolve",{method:"POST", + headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({requirement:req})}); + const d = await r.json(); + const el = document.getElementById("design-result"); + el.innerHTML = `
+
분류: ${d.design_type} | AI 처리: ${d.resolved_by_ai?"✅":"❌"}
+ ${d.generated_code?`
${d.generated_code}
`:""} +

${d.next_step||""}

+
`; + loadDesignData(); +} +async function loadDesignData() { + const t = localStorage.getItem("token")||""; + const [stats, queue] = await Promise.all([ + fetch("/api/design/stats",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})), + fetch("/api/design/sr-queue?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + ]); + const se = document.getElementById("design-stats"); + if(se) se.innerHTML=[ + {icon:"📋",label:"전체 디자인 SR",val:stats.total||0}, + {icon:"🤖",label:"AI 처리됨",val:stats.ai_resolved||0}, + {icon:"📈",label:"자동화율",val:`${stats.ai_resolution_rate||0}%`}, + {icon:"🔍",label:"비전 모델",val:"llava:7b"}, + ].map(s=>`
+
${s.icon}
+
${s.val}
+
${s.label}
+
`).join(""); + const qe = document.getElementById("design-queue"); + if(qe) qe.innerHTML = queue.length ? queue.map(s=>`
+
${s.design_type}${s.requirement}
+
${s.status}
+
`).join("") : "

처리된 디자인 SR 없음

"; +} + +function renderDesignIcon() { + document.getElementById("content").innerHTML = ` +

🎨 SVG 아이콘 생성

+

텍스트로 설명하면 SVG 아이콘을 자동 생성합니다. GUARDiA 브랜드 색상(#003366) 기본 적용.

+ ${_nextCard("아이콘 생성","✨",` +
+ + + +
+
+ `)} + ${_nextCard("내장 아이콘 라이브러리","📚","
로딩 중...
")}`; + loadIconLibrary(); +} +async function generateIcon() { + const desc = document.getElementById("icon-desc").value; + const color = document.getElementById("icon-color").value; + if(!desc) return showToast("아이콘 설명 입력","error"); + const t = localStorage.getItem("token")||""; + showToast("아이콘 생성 중...","info"); + const r = await fetch("/api/icon/generate",{method:"POST", + headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({description:desc,color,save:true})}); + const d = await r.json(); + const el = document.getElementById("icon-result"); + if(d.svg_code) { + el.innerHTML = `
+
${d.svg_code}
+
+
출처: ${d.source||"AI 생성"}
+ SVG 다운로드 ↗ +
+
${d.svg_code.substring(0,200)}
+
`; + showToast("아이콘 생성 완료","success"); + loadIconLibrary(); + } +} +async function loadIconLibrary() { + const t = localStorage.getItem("token")||""; + const lib = await fetch("/api/icon/library",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})); + const el = document.getElementById("icon-library"); + if(!el) return; + const builtins = lib.builtin||[]; + el.innerHTML = `
+ ${builtins.map(ic=>`
+
${ic.name}
+ SVG↗ +
`).join("")} +
+

내장 ${builtins.length}개 | 생성 ${(lib.custom||[]).length}개

`; +} + +function renderDesignCSS() { + document.getElementById("content").innerHTML = ` +

✍️ CSS 자동 생성

+

자연어로 디자인 요구사항을 설명하면 CSS 코드를 자동 생성합니다. GUARDiA 브랜드 변수 자동 적용.

+ ${_nextCard("CSS 생성","⚙️",` +
+ + +
+ +
+ `)} + ${_nextCard("GUARDiA 브랜드 변수","🎨",` +
로딩 중...
+ `)} + ${_nextCard("생성 이력","📋","
로딩 중...
")}`; + loadCSSData(); +} +async function generateCSS() { + const req = document.getElementById("css-req").value; + const mode = document.getElementById("css-mode").value; + if(!req) return showToast("요구사항 입력","error"); + const t = localStorage.getItem("token")||""; + showToast("CSS 생성 중...","info"); + const r = await fetch("/api/css/generate",{method:"POST", + headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({requirement:req,mode})}); + const d = await r.json(); + document.getElementById("css-result").innerHTML = `
+
CSS ID: ${d.css_id}
+
${d.generated_css||""}
+ +

${d.preview_tip||""}

+
`; + loadCSSData(); +} +function copyToClipboard(text) { + navigator.clipboard?.writeText(text).then(()=>showToast("클립보드 복사됨","success")).catch(()=>showToast("복사 실패","error")); +} +async function loadCSSData() { + const t = localStorage.getItem("token")||""; + const [vars, hist] = await Promise.all([ + fetch("/api/css/brand-variables",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})), + fetch("/api/css/history",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + ]); + const ve = document.getElementById("brand-vars"); + if(ve) ve.innerHTML = Object.entries(vars.variables||{}).map(([k,v])=> + `
${k}: ${v}
`).join(""); + const he = document.getElementById("css-history"); + if(he) he.innerHTML = hist.length ? hist.map(h=>`
+
${h.requirement}
+ ${h.applied?"✅ 적용됨":"대기"} +
`).join("") : "

생성 이력 없음

"; +} + +function renderDesignReview() { + document.getElementById("content").innerHTML = ` +

🔍 스크린샷 UI 분석

+

스크린샷을 업로드하면 Ollama llava가 UI/UX를 분석하고 개선 제안을 제공합니다.

+ ${_nextCard("스크린샷 분석","📸",` +
+ + + + +
+
+ `)} + ${_nextCard("텍스트 디자인 리뷰","✍️",` + + +
+ `)}`; +} +async function analyzeScreenshot() { + const file = document.getElementById("review-file").files[0]; + const q = document.getElementById("review-q").value || "이 UI의 개선점을 한국어로 3가지 제안해줘"; + if(!file) return showToast("이미지 파일 선택 필요","error"); + const t = localStorage.getItem("token")||""; + showToast("llava 비전 분석 중... (30초 내외 소요)","info"); + const form = new FormData(); + form.append("file",file); form.append("question",q); + const r = await fetch("/api/design/analyze",{method:"POST",headers:{Authorization:`Bearer ${t}`},body:form}); + const d = await r.json(); + document.getElementById("review-result").innerHTML = `
+
분석 ID: ${d.analysis_id} | 모델: ${d.model||"llava:7b"}
+
${d.analysis||""}
+
`; + showToast("분석 완료","success"); +} +async function textReview() { + const req = document.getElementById("review-text").value; + if(!req) return showToast("요구사항 입력","error"); + const t = localStorage.getItem("token")||""; + showToast("AI 리뷰 생성 중...","info"); + const r = await fetch("/api/design/review",{method:"POST", + headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({requirement:req})}); + const d = await r.json(); + document.getElementById("text-review-result").innerHTML = `
+
분류: ${d.design_type}
+
${d.analysis||""}
+
`; + showToast("리뷰 완료","success"); +} diff --git a/static/index.html b/static/index.html index 938b65b..81fac6f 100644 --- a/static/index.html +++ b/static/index.html @@ -211,6 +211,20 @@ + + + + + +