323 lines
13 KiB
Python
323 lines
13 KiB
Python
"""
|
|
GUARDiA 바이브코딩 (Vibe Coding) — 온프레미스 AI 코드 생성 엔진
|
|
자연어 → 코드 생성 / 리팩터링 / 테스트 자동 작성
|
|
100% Ollama 온프레미스 (외부 API 절대 금지)
|
|
"""
|
|
import os
|
|
import httpx
|
|
import json
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
|
|
router = APIRouter(prefix="/api/vibe", tags=["Vibe Coding"])
|
|
|
|
OLLAMA_BASE = "http://localhost:11434"
|
|
CODE_MODEL = "codellama" # 코드 생성 전용 모델 (폐쇄망)
|
|
REVIEW_MODEL = "llama3" # 코드 리뷰·설명 모델 (폐쇄망)
|
|
|
|
# 개방망 모드: 외부 AI API 허용
|
|
_OPEN_NET = os.environ.get("GUARDIA_NETWORK_MODE") == "open"
|
|
_EXT_API_KEY = os.environ.get("OPENAI_API_KEY", "") # 개방망 전용 (폐쇄망에서 무시)
|
|
|
|
|
|
# ── Pydantic 모델 ──────────────────────────────────────────────────────────
|
|
|
|
class CodeGenRequest(BaseModel):
|
|
prompt: str # 자연어 요청
|
|
language: str = "python" # python | typescript | java | sql | bash | yaml
|
|
context: Optional[str] = None # 기존 코드 컨텍스트
|
|
style: str = "guardia" # guardia | clean | functional | oop
|
|
|
|
class RefactorRequest(BaseModel):
|
|
code: str
|
|
language: str = "python"
|
|
goal: str = "improve" # improve | simplify | performance | security | test
|
|
|
|
class TestGenRequest(BaseModel):
|
|
code: str
|
|
language: str = "python"
|
|
framework: str = "pytest" # pytest | jest | junit | unittest
|
|
|
|
class ExplainRequest(BaseModel):
|
|
code: str
|
|
language: str = "python"
|
|
detail: str = "normal" # brief | normal | detailed
|
|
|
|
class ReviewRequest(BaseModel):
|
|
code: str
|
|
language: str = "python"
|
|
checklist: List[str] = ["security", "performance", "readability", "maintainability"]
|
|
|
|
class FixRequest(BaseModel):
|
|
code: str
|
|
error: str
|
|
language: str = "python"
|
|
|
|
class ComponentGenRequest(BaseModel):
|
|
description: str
|
|
framework: str = "react" # react | vue | fastapi | sqlalchemy
|
|
style_tokens: Optional[Dict[str, str]] = None # 디자인 토큰 주입
|
|
|
|
class SqlGenRequest(BaseModel):
|
|
description: str
|
|
tables: Optional[List[str]] = None # 관련 테이블 목록
|
|
dialect: str = "postgresql"
|
|
|
|
|
|
# ── Ollama 호출 헬퍼 ──────────────────────────────────────────────────────
|
|
|
|
async def _ollama(model: str, prompt: str, system: str = "") -> str:
|
|
"""온프레미스 Ollama 호출 (폐쇄망 기본값)."""
|
|
payload: Dict[str, Any] = {
|
|
"model": model,
|
|
"prompt": prompt,
|
|
"stream": False,
|
|
"options": {"temperature": 0.2, "num_predict": 2048},
|
|
}
|
|
if system:
|
|
payload["system"] = system
|
|
|
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
resp = await client.post(f"{OLLAMA_BASE}/api/generate", json=payload)
|
|
if resp.status_code != 200:
|
|
raise HTTPException(503, "Ollama 서비스 불가")
|
|
return resp.json().get("response", "")
|
|
|
|
|
|
async def _ai_call(prompt: str, system: str = "", model: str = CODE_MODEL) -> str:
|
|
"""
|
|
개방망: 외부 API 허용 (GUARDIA_NETWORK_MODE=open)
|
|
폐쇄망: Ollama 온프레미스만 (기본)
|
|
"""
|
|
if _OPEN_NET and _EXT_API_KEY:
|
|
# 개방망 — OpenAI 호환 외부 API 사용
|
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
messages = []
|
|
if system:
|
|
messages.append({"role": "system", "content": system})
|
|
messages.append({"role": "user", "content": prompt})
|
|
resp = await client.post(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {_EXT_API_KEY}"},
|
|
json={"model": "gpt-4o-mini", "messages": messages, "max_tokens": 2048},
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.json()["choices"][0]["message"]["content"]
|
|
# 폐쇄망 fallback — Ollama
|
|
return await _ollama(model, prompt, system)
|
|
|
|
|
|
GUARDIA_STYLE = """
|
|
GUARDiA 코드 스타일:
|
|
- FastAPI 비동기 패턴 사용 (async/await)
|
|
- Pydantic v2 모델로 입출력 타입 정의
|
|
- SQLAlchemy async 세션 사용
|
|
- 보안: 입력값 검증, SQL 인젝션 방지
|
|
- 외부 API 호출 절대 금지 (Ollama localhost만 허용)
|
|
- 한국어 주석 허용
|
|
"""
|
|
|
|
|
|
# ── 엔드포인트 ────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/generate")
|
|
async def generate_code(req: CodeGenRequest):
|
|
"""자연어 → 코드 생성."""
|
|
style_hint = GUARDIA_STYLE if req.style == "guardia" else ""
|
|
context_block = f"\n\n기존 코드:\n```{req.language}\n{req.context}\n```" if req.context else ""
|
|
|
|
prompt = (
|
|
f"다음 요청에 맞는 {req.language} 코드를 작성하라.\n"
|
|
f"요청: {req.prompt}{context_block}\n"
|
|
f"{style_hint}\n"
|
|
"코드 블록만 출력하라. 설명 없음."
|
|
)
|
|
code = await _ai_call(prompt, model=CODE_MODEL)
|
|
return {
|
|
"language": req.language,
|
|
"code": code,
|
|
"model": CODE_MODEL if not _OPEN_NET else "gpt-4o-mini(open)",
|
|
"generated_at": datetime.utcnow().isoformat(),
|
|
}
|
|
|
|
|
|
@router.post("/refactor")
|
|
async def refactor_code(req: RefactorRequest):
|
|
"""코드 리팩터링 (목적 기반)."""
|
|
goals = {
|
|
"improve": "전반적인 코드 품질 향상",
|
|
"simplify": "복잡도 감소, 가독성 향상",
|
|
"performance": "성능 최적화",
|
|
"security": "보안 취약점 제거",
|
|
"test": "테스트 가능한 구조로 변환",
|
|
}
|
|
prompt = (
|
|
f"다음 {req.language} 코드를 '{goals.get(req.goal, req.goal)}' 목적으로 리팩터링하라.\n\n"
|
|
f"```{req.language}\n{req.code}\n```\n\n"
|
|
"개선된 코드와 변경 사항 요약을 JSON으로 출력: {\"code\": \"...\", \"changes\": [\"...\"]}"
|
|
)
|
|
result = await _ollama(CODE_MODEL, prompt)
|
|
try:
|
|
parsed = json.loads(result)
|
|
return {"refactored": parsed, "model": CODE_MODEL}
|
|
except json.JSONDecodeError:
|
|
return {"refactored": {"code": result, "changes": []}, "model": CODE_MODEL}
|
|
|
|
|
|
@router.post("/test-gen")
|
|
async def generate_tests(req: TestGenRequest):
|
|
"""단위 테스트 자동 생성."""
|
|
prompt = (
|
|
f"다음 {req.language} 코드에 대한 {req.framework} 단위 테스트를 작성하라.\n\n"
|
|
f"```{req.language}\n{req.code}\n```\n\n"
|
|
"엣지 케이스와 예외 케이스를 포함하라. 테스트 코드만 출력하라."
|
|
)
|
|
tests = await _ollama(CODE_MODEL, prompt)
|
|
return {
|
|
"framework": req.framework,
|
|
"tests": tests,
|
|
"model": CODE_MODEL,
|
|
}
|
|
|
|
|
|
@router.post("/explain")
|
|
async def explain_code(req: ExplainRequest):
|
|
"""코드 설명 (레벨별)."""
|
|
detail_map = {
|
|
"brief": "2~3줄로 핵심만",
|
|
"normal": "각 함수/블록별로",
|
|
"detailed": "라인별 상세 설명 + 설계 의도",
|
|
}
|
|
prompt = (
|
|
f"다음 {req.language} 코드를 {detail_map.get(req.detail, '일반')}로 설명하라.\n\n"
|
|
f"```{req.language}\n{req.code}\n```"
|
|
)
|
|
explanation = await _ollama(REVIEW_MODEL, prompt)
|
|
return {"explanation": explanation, "detail": req.detail, "model": REVIEW_MODEL}
|
|
|
|
|
|
@router.post("/review")
|
|
async def review_code(req: ReviewRequest):
|
|
"""코드 리뷰 (체크리스트 기반)."""
|
|
checklist_str = ", ".join(req.checklist)
|
|
prompt = (
|
|
f"다음 {req.language} 코드를 [{checklist_str}] 관점으로 리뷰하라.\n\n"
|
|
f"```{req.language}\n{req.code}\n```\n\n"
|
|
"JSON 출력: {\"issues\": [{\"type\": \"...\", \"line\": N, \"msg\": \"...\", \"severity\": \"...\"}], "
|
|
"\"score\": {\"security\": N, \"performance\": N, \"readability\": N}, \"summary\": \"...\"}"
|
|
)
|
|
result = await _ollama(REVIEW_MODEL, prompt)
|
|
try:
|
|
return {"review": json.loads(result), "model": REVIEW_MODEL}
|
|
except json.JSONDecodeError:
|
|
return {"review": {"summary": result, "issues": [], "score": {}}, "model": REVIEW_MODEL}
|
|
|
|
|
|
@router.post("/fix")
|
|
async def fix_code(req: FixRequest):
|
|
"""오류 코드 자동 수정."""
|
|
prompt = (
|
|
f"다음 {req.language} 코드에서 오류가 발생했다:\n\n"
|
|
f"오류: {req.error}\n\n"
|
|
f"```{req.language}\n{req.code}\n```\n\n"
|
|
"수정된 코드와 원인 설명을 JSON으로: {\"fixed_code\": \"...\", \"cause\": \"...\", \"fix\": \"...\"}"
|
|
)
|
|
result = await _ollama(CODE_MODEL, prompt)
|
|
try:
|
|
return {"result": json.loads(result), "model": CODE_MODEL}
|
|
except json.JSONDecodeError:
|
|
return {"result": {"fixed_code": result, "cause": "", "fix": ""}, "model": CODE_MODEL}
|
|
|
|
|
|
@router.post("/component")
|
|
async def generate_component(req: ComponentGenRequest):
|
|
"""UI/API 컴포넌트 자동 생성 (디자인 토큰 적용)."""
|
|
token_hint = ""
|
|
if req.style_tokens:
|
|
token_hint = f"\n디자인 토큰: {json.dumps(req.style_tokens, ensure_ascii=False)}"
|
|
|
|
framework_hints = {
|
|
"react": "React TypeScript 함수형 컴포넌트, Tailwind CSS 사용",
|
|
"vue": "Vue 3 Composition API",
|
|
"fastapi": "FastAPI 라우터 + Pydantic 모델",
|
|
"sqlalchemy": "SQLAlchemy 2.0 async ORM 모델",
|
|
}
|
|
hint = framework_hints.get(req.framework, req.framework)
|
|
|
|
prompt = (
|
|
f"{hint} 스타일로 다음 컴포넌트를 생성하라:\n{req.description}{token_hint}\n\n"
|
|
"완전한 코드만 출력하라."
|
|
)
|
|
code = await _ollama(CODE_MODEL, prompt)
|
|
return {"framework": req.framework, "code": code, "model": CODE_MODEL}
|
|
|
|
|
|
@router.post("/sql")
|
|
async def generate_sql(req: SqlGenRequest):
|
|
"""자연어 → SQL 쿼리 생성."""
|
|
table_hint = f"\n관련 테이블: {', '.join(req.tables)}" if req.tables else ""
|
|
prompt = (
|
|
f"{req.dialect} SQL 쿼리를 작성하라:\n요청: {req.description}{table_hint}\n\n"
|
|
"SQL 쿼리만 출력하라."
|
|
)
|
|
sql = await _ollama(CODE_MODEL, prompt)
|
|
return {"dialect": req.dialect, "sql": sql, "model": CODE_MODEL}
|
|
|
|
|
|
@router.get("/templates")
|
|
async def list_templates():
|
|
"""GUARDiA 코드 템플릿 목록."""
|
|
return {
|
|
"templates": [
|
|
{"id": "fastapi-router", "name": "FastAPI 라우터", "lang": "python"},
|
|
{"id": "pydantic-model", "name": "Pydantic 모델", "lang": "python"},
|
|
{"id": "sqlalchemy-model", "name": "SQLAlchemy 모델", "lang": "python"},
|
|
{"id": "react-component", "name": "React 컴포넌트", "lang": "typescript"},
|
|
{"id": "react-hook", "name": "React 커스텀 훅", "lang": "typescript"},
|
|
{"id": "test-pytest", "name": "pytest 단위 테스트", "lang": "python"},
|
|
{"id": "test-jest", "name": "Jest 단위 테스트", "lang": "typescript"},
|
|
{"id": "ansible-task", "name": "Ansible Task", "lang": "yaml"},
|
|
{"id": "shell-deploy", "name": "배포 쉘 스크립트", "lang": "bash"},
|
|
]
|
|
}
|
|
|
|
|
|
@router.post("/complete")
|
|
async def code_complete(
|
|
code: str,
|
|
cursor_line: int = Query(...),
|
|
language: str = Query("python"),
|
|
):
|
|
"""코드 자동 완성 (커서 위치 기반)."""
|
|
lines = code.split("\n")
|
|
prefix = "\n".join(lines[:cursor_line])
|
|
prompt = (
|
|
f"다음 {language} 코드의 커서 위치 이후를 자연스럽게 완성하라:\n\n"
|
|
f"```{language}\n{prefix}\n```\n\n"
|
|
"완성 코드만 출력하라 (prefix 반복 없음)."
|
|
)
|
|
completion = await _ollama(CODE_MODEL, prompt)
|
|
return {"completion": completion, "cursor_line": cursor_line}
|
|
|
|
|
|
@router.get("/health")
|
|
async def vibe_health():
|
|
"""바이브코딩 엔진 상태 확인."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as c:
|
|
r = await c.get(f"{OLLAMA_BASE}/api/tags")
|
|
models = [m["name"] for m in r.json().get("models", [])]
|
|
code_ok = any(CODE_MODEL in m for m in models)
|
|
review_ok = any(REVIEW_MODEL in m for m in models)
|
|
return {
|
|
"status": "healthy" if code_ok else "degraded",
|
|
"ollama": "up",
|
|
"code_model": {"name": CODE_MODEL, "available": code_ok},
|
|
"review_model": {"name": REVIEW_MODEL, "available": review_ok},
|
|
"models": models,
|
|
}
|
|
except Exception as e:
|
|
return {"status": "down", "error": str(e)}
|