diff --git a/fastapi/app.py b/fastapi/app.py new file mode 100644 index 00000000..2a0463a0 --- /dev/null +++ b/fastapi/app.py @@ -0,0 +1,81 @@ +#app.py +#FastAPI 최소 서버: /health, /v1/generate/post +#목적: Spring Boot 와의 연결 테스트 및 LLM 모듈 자리잡기 +#현재는 LLM 호출 없이 mock 응답 반환 + +from fastapi import FastAPI +from pydantic import BaseModel, Field +from typing import List, Optional + + +#App Instance - FastAPI 앱의 진입점 객체 +app = FastAPI( + title="AI Content Service", + description="Generates promotional blog content from a crawled product brief.", + version="0.1.0", +) + +# ====== 1) 요청/응답 스키마 ====== +#Define data model(schema) - Pydantic +class ProductBrief(BaseModel): + """ + 크롤링/전처리 결과에서 LLM에 필요한 최소 필드. + 실제 스키마가 정해지면 여기를 확장/수정하면 됨. + """ + product_name: str + source_url: Optional[str] = None #필드가 없어도/NULL이어도 허용 + price: Optional[str] = None + keywords: List[str] = Field(default_factory=list) + +class PostDraft(BaseModel): + """ + LLM(현재 mock)이 생성한 블로그 초안 + """ + title: str + meta_description: str + hashtags: List[str] + body_markdown: str + +# ====== 2) 헬스체크 ====== +@app.get("/health") +def health(): + """ + 헬스체크 엔드포인트 + - 배포/연결/모니터링에서 살아있는지 확인할 때 사용 + - 반환값은 단순한 JSON + """ + return {"ok":True} #딕셔너리 → FastAPI가 자동으로 JSON으로 변환해 응답 + +# ====== 3) 생성 엔드포인트 (현재 mock) ====== +@app.post("/v1/generate/post", response_model=PostDraft) +def generate_post(brief: ProductBrief): + """ + 블로그 프로모션용 초안 생성 + - 지금은 LLM을 호출하지 않고 샘플 응답을 반환 + - 이후 Gemini 연결 시 이 로직을 교체 + """ + # 간단한 규칙 기반 타이틀/본문 생성 (연결 검증용) + title = f"[sample] {brief.product_name} promotion" + meta = "이 제품의 주요 장점을 간단히 소개합니다" + hashtags = ["#추천", "#가성비"] + + #keywords를 본문에 사용 (데모) + kw_line = "" + if brief.keywords: + kw_line = "키워드: " + ",".join(brief.keywords) + + body = ( + "## 왜 좋은가요?\n" + "- 합리적 가격\n" + "- 실용적 기능\n\n" + f"{kw_line}\n\n" + "자세한 정보는 링크를 확인하세요." + ) + + return PostDraft( + title=title, + meta_description=meta, + hashtags=hashtags, + body_markdown=body + ) + diff --git a/fastapi/app/main.py b/fastapi/app/main.py index 3ff561d1..3a457fb1 100644 --- a/fastapi/app/main.py +++ b/fastapi/app/main.py @@ -4,7 +4,7 @@ from datetime import datetime from . import models, schemas from .database import SessionLocal, engine, get_db - +from ..app_test import app as ai_app # 데이터베이스 테이블 생성 models.Base.metadata.create_all(bind=engine) @@ -14,6 +14,7 @@ description="FastAPI + Docker + Nginx + Oracle RDS 프로젝트", version="1.0.0" ) +app.mount("/ai", ai_app) # 기본 헬스체크 API @app.get("/health") diff --git a/fastapi/app_test.py b/fastapi/app_test.py new file mode 100644 index 00000000..e2e1dedf --- /dev/null +++ b/fastapi/app_test.py @@ -0,0 +1,370 @@ +# app_test.py +# 목적: 운영 고려된 최소 LLM 백엔드 (LangChain) +# 기능: /health, /v1/generate/post, /dry-run +# 포함: JSON 스키마 고정, 가드레일, 타임아웃/재시도, 폴백, NDJSON 로그 +# 패치: (1) evidence 검증前 자동 보강 (2) 실패 로그 reason 필드 추가 + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from dotenv import load_dotenv +from pathlib import Path +import os, re, json, time, statistics, random, math + +# LangChain +from langchain_openai import ChatOpenAI +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import PydanticOutputParser + +# ────────────────────────────────────────────────────────────────────────────── +# 환경 변수/기본 설정 +load_dotenv() +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +MODEL_NAME = os.getenv("LLM_MODEL", "gpt-4o-mini") +LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT_SEC", "20")) # LLM 호출 타임아웃(초) +RETRY_MAX = int(os.getenv("LLM_RETRY_MAX", "2")) # 429/5xx 재시도 횟수 +LOG_DIR = Path(os.getenv("LOG_DIR", "./logs")); LOG_DIR.mkdir(parents=True, exist_ok=True) +LOG_PATH = LOG_DIR / "requests.ndjson" + +app = FastAPI(title="AI Content Service (LangChain Stable)", version="1.0.0") + +# ────────────────────────────────────────────────────────────────────────────── +# Pydantic 스키마 (JSON 고정): title, meta_description, hashtags[], body_markdown, evidence[] +class ProductBrief(BaseModel): + product_name: str + source_url: Optional[str] = None + price: Optional[str] = None + keywords: List[str] = Field(default_factory=list) + +class PostDraft(BaseModel): + title: str + meta_description: str + hashtags: List[str] + body_markdown: str + evidence: List[str] = Field(default_factory=list) # 출처/근거(링크/문장 등) + version: str = "1.0.0" # 응답 스키마 버전 + +# ────────────────────────────────────────────────────────────────────────────── +# LangChain 구성: Prompt + LLM + Parser +SYSTEM_MSG = """You are a Korean e-commerce copywriter. +- Use Korean only. +- Be specific and avoid hallucinations. +- Do NOT infer unseen brands, model names, or numeric specs. If unknown, write "[미상]". +- Return ONLY JSON (no code fences).""" + +parser = PydanticOutputParser(pydantic_object=PostDraft) + +GEN_PROMPT = ChatPromptTemplate.from_messages( + [ + ("system", SYSTEM_MSG), + ("user", """Generate a promotional blog post with the following constraints. + +Product Context: +- Name: {name} +- Price: {price} +- Keywords: {keywords_str} +- Source: {url} + +Hard Constraints: +- Title: <= 60 Korean characters +- Meta description: <= 155 Korean characters +- Hashtags: 2~6 items (no spaces) +- Body: Markdown with H2/H3 and bullet points +- If a number/brand/spec is not explicitly provided in the input, write "[미상]" instead of guessing. +- Add the source URL at the end of the body if available. +- Provide "evidence" array listing explicit sources or lines used (prefer source_url if present). + +Return JSON matching exactly: +{format_instructions} +"""), + ] +) + +FIX_PROMPT = ChatPromptTemplate.from_messages( + [ + ("system", SYSTEM_MSG), + ("user", """Fix the JSON draft to satisfy ALL constraints. + +Draft (JSON): +{draft_json} + +Violations: +{violations} + +Return JSON with this exact schema: +{format_instructions} +"""), + ] +) + +llm = ChatOpenAI( + model=MODEL_NAME, + temperature=0.2, # 결정론 강화 + timeout=LLM_TIMEOUT, # 호출 타임아웃 + max_retries=0, # 내부 재시도 끔(우리가 명시적으로 제어) + api_key=OPENAI_API_KEY, +) + +gen_prompt = GEN_PROMPT +fix_prompt = FIX_PROMPT + +# ────────────────────────────────────────────────────────────────────────────── +# 가드레일(검증) 규칙 +MAX_TITLE = 60 +MAX_META = 155 +MIN_HASHTAGS, MAX_HASHTAGS = 2, 6 +BANNED = ["100% 보장", "무조건", "전부 다", "세계 최고", "절대"] + +def validate_draft(d: PostDraft, brief: ProductBrief) -> List[str]: + errs: List[str] = [] + if len(d.title) > MAX_TITLE: + errs.append(f"title <= {MAX_TITLE}") + if len(d.meta_description) > MAX_META: + errs.append(f"meta_description <= {MAX_META}") + if not (MIN_HASHTAGS <= len(d.hashtags) <= MAX_HASHTAGS): + errs.append(f"hashtags count {MIN_HASHTAGS}~{MAX_HASHTAGS}") + combined = f"{d.title}\n{d.body_markdown}" + if any(w in combined for w in BANNED): + errs.append("banned words present") + # 출처 포함 + if brief.source_url and brief.source_url not in d.body_markdown: + errs.append("source_url must be included in body_markdown") + # 입력에 없는 가격인데 숫자/통화 표현 등장 → 금지 + if not brief.price and re.search(r"(₩|\d{1,3}(?:,\d{3})+|\d+\s?원)", d.body_markdown or ""): + errs.append("numeric price present without input price -> use [미상]") + # 키워드 최소 1개 등장 + if brief.keywords and not any(k in (d.body_markdown or "") for k in brief.keywords): + errs.append("at least one keyword must appear in body_markdown") + # evidence 최소 1개 (가능하면 source_url 포함) + if not d.evidence: + errs.append("evidence must contain at least one item") + elif brief.source_url and brief.source_url not in d.evidence: + errs.append("evidence should include source_url if available") + return errs + +def ensure_evidence(draft: PostDraft, brief: ProductBrief) -> None: + """ + 검증 전에 evidence 배열을 안전 보강한다. + - source_url이 있으면 body_markdown/ evidence에 반영. + - draft.evidence가 None인 경우 빈 배열로 초기화. + """ + if draft.evidence is None: + draft.evidence = [] + if brief.source_url: + if brief.source_url not in (draft.body_markdown or ""): + draft.body_markdown = (draft.body_markdown or "") + f"\n\n자세한 내용: {brief.source_url}" + if brief.source_url not in draft.evidence: + draft.evidence.append(brief.source_url) + +# ────────────────────────────────────────────────────────────────────────────── +# NDJSON 로깅 (요청당 한 줄) +def log_ndjson(**kwargs): + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with LOG_PATH.open("a", encoding="utf-8") as f: + f.write(json.dumps(kwargs, ensure_ascii=False) + "\n") + +# ────────────────────────────────────────────────────────────────────────────── +# LLM 호출 유틸: 재시도(지수 백오프) + 타임아웃 + 토큰/레이턴시 메타 + 폴백 +def _call_llm_with_parser(messages, parser_: PydanticOutputParser) -> (Optional[PostDraft], Dict[str, Any]): + """ + messages: ChatPromptTemplate.format_messages(...) 결과 (BaseMessage 리스트) + return: (PostDraft or None, meta dict) + """ + retries = 0 + backoff = 0.6 # seconds + last_err = None + total_start = time.perf_counter() + token_in = token_out = None + + while True: + try: + start = time.perf_counter() + ai_msg = llm.invoke(messages) # LangChain ChatOpenAI: BaseMessage 리스트 입력 + # 단일 호출 지연 + latency_ms = (time.perf_counter() - start) * 1000 + + # 토큰 사용량(있을 때만) + meta = ai_msg.response_metadata or {} + usage = meta.get("token_usage") or meta.get("usage") or {} + token_in = usage.get("prompt_tokens") + token_out = usage.get("completion_tokens") + + # 파싱 + content = ai_msg.content + draft: PostDraft = parser_.parse(content) + total_latency_ms = (time.perf_counter() - total_start) * 1000 + return draft, { + "latency_ms": round(total_latency_ms, 2), + "retries": retries, + "fallback_used": False, + "tokens_prompt": token_in, + "tokens_completion": token_out, + } + except Exception as e: + last_err = str(e) + retriable = any(s in last_err for s in ["429", "rate", "Rate", "quota", "timeout", "5", "Service Unavailable"]) + if retriable and retries < RETRY_MAX: + retries += 1 + time.sleep(backoff) + backoff *= 2 + continue + # 실패 → 폴백 + total_latency_ms = (time.perf_counter() - total_start) * 1000 + return None, { + "latency_ms": round(total_latency_ms, 2), + "retries": retries, + "fallback_used": True, + "tokens_prompt": token_in, + "tokens_completion": token_out, + "error": last_err, + } + +def _fallback_from_brief(brief: ProductBrief) -> PostDraft: + name = brief.product_name or "[미상]" + price = brief.price or "[미상]" + kws = ", ".join(brief.keywords) if brief.keywords else "[미상]" + src = brief.source_url or "[미상]" + body = ( + f"## {name} 한눈에 보기\n" + f"- 가격: {price}\n" + f"- 특징 키워드: {kws}\n\n" + f"### 상세 안내\n" + f"제공된 정보 범위 내에서만 안내합니다. 추가 정보는 [미상]으로 표기되었습니다.\n\n" + f"자세한 내용: {src}" + ) + return PostDraft( + title=f"[안전폴백] {name} 소개", + meta_description=f"{name} 기본 정보를 요약합니다.", + hashtags=["#정보요약", "#안전폴백"], + body_markdown=body, + evidence=[src] if src != "[미상]" else ["[미상]"], + version="1.0.0", + ) + +# ────────────────────────────────────────────────────────────────────────────── +# 엔드포인트 +@app.get("/health") +def health(): + return {"ok": True} + +@app.post("/v1/generate/post", response_model=PostDraft) +def generate_post(brief: ProductBrief): + if not OPENAI_API_KEY: + raise HTTPException(500, "OPENAI_API_KEY not configured") + + # 1) 생성 프롬프트 메시지 조립 + inputs = { + "name": brief.product_name, + "price": brief.price or "[미상]", + "keywords_str": ", ".join(brief.keywords) if brief.keywords else "[미상]", + "url": brief.source_url or "[미상]", + "format_instructions": parser.get_format_instructions(), + } + messages = gen_prompt.format_messages(**inputs) + + # 2) 호출(재시도/타임아웃/토큰/레이턴시 메타) + draft, meta = _call_llm_with_parser(messages, parser) + + # 3) evidence 보강 (검증 이전) + if draft is not None: + ensure_evidence(draft, brief) + + # 4) 폴백 처리 + 보강 + if draft is None: + draft = _fallback_from_brief(brief) + ensure_evidence(draft, brief) + + # 5) 가드레일 검사 → 수정(1회 시도) → 실패 시 422 + errs = validate_draft(draft, brief) + if errs: + fix_inputs = { + "draft_json": draft.model_dump_json(ensure_ascii=False), + "violations": "\n".join(f"- {e}" for e in errs), + "format_instructions": parser.get_format_instructions(), + } + fix_msgs = fix_prompt.format_messages(**fix_inputs) + fixed, meta_fix = _call_llm_with_parser(fix_msgs, parser) + + if fixed is not None: + draft = fixed + ensure_evidence(draft, brief) # 수정본도 검증前 보강 + errs2 = validate_draft(draft, brief) + if errs2: + log_ndjson(endpoint="/v1/generate/post", ok=False, reason="GUARDRAIL_FAILED", violations=errs2, **meta) + raise HTTPException(422, {"error_code":"GUARDRAIL_FAILED","violations":errs2}) + meta = meta_fix + else: + # 수정 실패지만 폴백이 이미 적용된 경우 통과 가능 (검증은 아래에서 다시 진행되지 않음) + pass + + # 6) NDJSON 로그 + reason = "FALLBACK" if meta.get("fallback_used") else ("RETRIED" if meta.get("retries", 0) > 0 else None) + log_ndjson( + endpoint="/v1/generate/post", + ok=True, + reason=reason, + product_name=brief.product_name, + latency_ms=meta.get("latency_ms"), + retries=meta.get("retries"), + fallback_used=meta.get("fallback_used"), + tokens_prompt=meta.get("tokens_prompt"), + tokens_completion=meta.get("tokens_completion"), + model=MODEL_NAME, + ) + return draft + +# ────────────────────────────────────────────────────────────────────────────── +# /dry-run: 샘플 5~10건 스모크 → 성과표(p50/p95, 성공률) + 미리보기 +SAMPLE_PRODUCTS = [ + ("에코백", "₩15,900", ["보냉","휴대"]), + ("텀블러", None, ["보온","세척"]), + ("러닝화", "₩59,000", ["쿠셔닝","통기성"]), + ("무선이어폰", None, ["저지연","배터리"]), + ("폴딩우산", "₩9,900", ["방수","경량"]), +] + +class DryRunResult(BaseModel): + total: int + success: int + success_rate: float + p50_latency_ms: Optional[float] + p95_latency_ms: Optional[float] + preview: List[Dict[str, Any]] + +@app.post("/dry-run", response_model=DryRunResult) +def dry_run(n: int = 5): + if n < 1: n = 1 + if n > 10: n = 10 + cases = random.sample(SAMPLE_PRODUCTS, k=min(n, len(SAMPLE_PRODUCTS))) + + latencies: List[float] = [] + previews: List[Dict[str, Any]] = [] + success = 0 + + for name, price, kws in cases: + brief = ProductBrief(product_name=name, price=price, keywords=kws, source_url="https://example.com/item") + try: + start = time.perf_counter() + _ = generate_post(brief) # 동일 파이프라인 사용(로그/가드레일 포함) + lat = (time.perf_counter() - start) * 1000 + latencies.append(lat) + success += 1 + previews.append({"product": name, "latency_ms": round(lat, 2)}) + except Exception as e: + previews.append({"product": name, "error": str(e)}) + + p50 = round(statistics.median(latencies), 2) if latencies else None + p95 = None + if latencies: + sorted_lat = sorted(latencies) + idx = min(len(sorted_lat)-1, math.ceil(0.95*len(sorted_lat))-1) + p95 = round(sorted_lat[idx], 2) + + return DryRunResult( + total=len(cases), + success=success, + success_rate=round(success/len(cases), 2), + p50_latency_ms=p50, + p95_latency_ms=p95, + preview=previews[:min(3, len(previews))], # README 캡처용 2~3개만 + ) diff --git a/springboot/build.gradle b/springboot/build.gradle index 118d4fa8..47a3a1ef 100644 --- a/springboot/build.gradle +++ b/springboot/build.gradle @@ -25,6 +25,14 @@ repositories { mavenCentral() } +//spotless { +// java { +// googleJavaFormat('1.17.0') // Google Java Style 적용 +// removeUnusedImports() // 사용하지 않는 import 제거 +// trimTrailingWhitespace() // 라인 끝 공백 제거 +// endWithNewline() // 파일 끝 개행 유지 +// } +//} spotless { java { target 'src/**/*.java' @@ -38,6 +46,13 @@ spotless { dependencies { // implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + // WebClient (필요하면 starter-webflux 유지) + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' + // Oracle JDBC 드라이버 + implementation 'com.oracle.database.jdbc:ojdbc8:21.9.0.0' implementation 'org.springframework.boot:spring-boot-starter-web' //SLF4J, Logback, AOP 의존성 포함 implementation 'org.springframework.boot:spring-boot-starter-aop' // AspectJ 지원 compileOnly 'org.projectlombok:lombok' @@ -46,10 +61,17 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'io.projectreactor:reactor-test' // WebFlux 테스트시 유용 + // 환경 꼬임 방지용으로 Jupiter를 명시적으로 고정 + testImplementation platform('org.junit:junit-bom:5.10.2') + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - + // 검증 관련 의존성 implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -64,11 +86,17 @@ dependencies { // WebClient implementation 'org.springframework.boot:spring-boot-starter-webflux' - + // H2 Database for testing testRuntimeOnly 'com.h2database:h2' + + implementation("commons-codec:commons-codec:1.17.1") } tasks.named('test') { useJUnitPlatform() } + +test { + useJUnitPlatform() // JUnit 5 플랫폼 사용 (경고 제거) +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/config/AiClientConfig.java b/springboot/src/main/java/com/softlabs/aicontents/config/AiClientConfig.java new file mode 100644 index 00000000..21fe73f0 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/config/AiClientConfig.java @@ -0,0 +1,21 @@ +package com.softlabs.aicontents.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * FastAPI 서버로 HTTP 요청을 보낼 때 사용할 WebClient를 애플리케이션 전역 Bean으로 등록. - baseUrl: FastAPI 실행 주소 (기본 + * 127.0.0.1:8001) - 다른 클래스에서 생성자 주입으로 바로 가져다 씀 + */ +@Configuration +public class AiClientConfig { + + @Bean + public WebClient llmwebClient() { + return WebClient.builder() + .baseUrl( + "http://127.0.0.1:8001") // ("http://13.124.8.131/fastapi")//("http://127.0.0.1:8001") + .build(); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/config/MyBatisConfig.java b/springboot/src/main/java/com/softlabs/aicontents/config/MyBatisConfig.java new file mode 100644 index 00000000..3311ff01 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/config/MyBatisConfig.java @@ -0,0 +1,8 @@ +package com.softlabs.aicontents.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan(basePackages = "com.softlabs.aicontents") +public class MyBatisConfig {} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/controller/AiController.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/controller/AiController.java new file mode 100644 index 00000000..8ba9e364 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/controller/AiController.java @@ -0,0 +1,50 @@ +package com.softlabs.aicontents.domain.ai.controller; + +// import com.softlabs.aicontents.domain.ai.dto.response.PostDraft; +import com.softlabs.aicontents.domain.ai.dto.request.ProductBrief; +import com.softlabs.aicontents.domain.ai.service.AiOrchestrator; +import com.softlabs.aicontents.domain.ai.service.AiService; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/test/llm") +public class AiController { + private final AiService aiService; + private final AiOrchestrator aiOrchestrator; + + public AiController(AiService aiService, AiOrchestrator aiOrchestrator) { + this.aiService = aiService; + this.aiOrchestrator = aiOrchestrator; + } + + // 권장: Postman Body(JSON)를 그대로 받아 처리 + @PostMapping("/generate/save") + public ResponseEntity> generateAndSave(@RequestBody ProductBrief brief) { + Long postId = aiOrchestrator.generateAndSave(brief); + return ResponseEntity.ok(Map.of("ok", true, "postId", postId)); + } + + // POST /test/llm/generate?product=텀블러 + // @PostMapping("/generate") + // public PostDraft generate(@RequestParam(defaultValue = "텀블러") String product) { + // ProductBrief brief = new ProductBrief( + // product, + // "https://ssadagu.kr/...", + // "₩15,900", + // List.of("보냉", "휴대") + // ); + // return aiService.generate(brief); + // } + // + // // 새로 추가: 생성 + DB 저장 + // @PostMapping("/generate/save") + // public Map generateAndSave(@RequestParam(defaultValue = "텀블러") String + // product) { + // ProductBrief brief = new ProductBrief(product, "https://ssadagu.kr/...", "₩15,900", + // List.of("보냉","휴대")); + // Long postId = aiOrchestrator.generateAndSave(brief); + // return Map.of("ok", true, "postId", postId); + // } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/dto/request/ProductBrief.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/dto/request/ProductBrief.java new file mode 100644 index 00000000..2abb0749 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/dto/request/ProductBrief.java @@ -0,0 +1,20 @@ +package com.softlabs.aicontents.domain.ai.dto.request; + +import java.util.List; + +/** FastAPI의 요청 JSON과 1:1 매핑 */ +public class ProductBrief { + public String product_name; + public String source_url; + public String price; + public List keywords; + + public ProductBrief() {} + + public ProductBrief(String product_name, String source_url, String price, List keywords) { + this.product_name = product_name; + this.source_url = source_url; + this.price = price; + this.keywords = keywords; + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/dto/response/PostDraft.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/dto/response/PostDraft.java new file mode 100644 index 00000000..5d2c1906 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/dto/response/PostDraft.java @@ -0,0 +1,15 @@ +package com.softlabs.aicontents.domain.ai.dto.response; + +import java.util.List; + +/** FastAPI 응답 JSON과 1:1 매핑. */ +public class PostDraft { + public String title; + public String meta_description; + public List hashtags; + public String body_markdown; + public List evidence; + public String version; + + public PostDraft() {} +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiGenerationEntity.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiGenerationEntity.java new file mode 100644 index 00000000..36474a5c --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiGenerationEntity.java @@ -0,0 +1,26 @@ +package com.softlabs.aicontents.domain.ai.entity; + +import lombok.Data; + +@Data +public class AiGenerationEntity { + private Long genId; + private Long requestId; + private String status; // PENDING/SUCCESS/ERROR + + private String modelName; + private Double temperature; + private Double timeoutSec; + private Integer retries; + + private Double latencyMs; + private Integer tokensPrompt; + private Integer tokensCompletion; + private String fallbackUsed; // 'Y' / 'N' + + private String errorMessage; + + private String systemMsgSnap; // CLOB + private String genPromptSnap; // CLOB + private String fixPromptSnap; // CLOB +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiPostEntity.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiPostEntity.java new file mode 100644 index 00000000..6962416c --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiPostEntity.java @@ -0,0 +1,17 @@ +package com.softlabs.aicontents.domain.ai.entity; + +import lombok.Data; + +@Data +public class AiPostEntity { + private Long postId; + private Long genId; + + private String title; + private String metaDescription; + private String bodyMarkdown; + + private String hashtagsCsv; // 최소 설계 + private String evidenceCsv; + private String schemaVersion; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiRequestEntity.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiRequestEntity.java new file mode 100644 index 00000000..4797be62 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/entity/AiRequestEntity.java @@ -0,0 +1,14 @@ +package com.softlabs.aicontents.domain.ai.entity; + +import lombok.Data; + +@Data +public class AiRequestEntity { + private Long requestId; + private String productName; + private String priceStr; + private String sourceUrl; + private String requestJson; // CLOB ↔ String (텍스트만) + private String requestHash; + // createdAt은 필요 시 추가 +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiGenerationMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiGenerationMapper.java new file mode 100644 index 00000000..6319f80f --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiGenerationMapper.java @@ -0,0 +1,14 @@ +package com.softlabs.aicontents.domain.ai.mapper; + +import com.softlabs.aicontents.domain.ai.entity.AiGenerationEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface AiGenerationMapper { + int insertGeneration(AiGenerationEntity entity); + + int markSuccess(AiGenerationEntity entity); + + int markError(@Param("genId") Long genId, @Param("errorMsg") String errorMsg); +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiPostMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiPostMapper.java new file mode 100644 index 00000000..4b3ee4e2 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiPostMapper.java @@ -0,0 +1,12 @@ +package com.softlabs.aicontents.domain.ai.mapper; + +import com.softlabs.aicontents.domain.ai.entity.AiPostEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface AiPostMapper { + int insertPost(AiPostEntity entity); + + AiPostEntity selectByGenId(@Param("genId") Long genId); +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiRequestMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiRequestMapper.java new file mode 100644 index 00000000..c98d3b9a --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/mapper/AiRequestMapper.java @@ -0,0 +1,12 @@ +package com.softlabs.aicontents.domain.ai.mapper; + +import com.softlabs.aicontents.domain.ai.entity.AiRequestEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface AiRequestMapper { + AiRequestEntity findByHash(@Param("requestHash") String requestHash); + + int insertRequest(AiRequestEntity entity); +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/service/AiOrchestrator.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/service/AiOrchestrator.java new file mode 100644 index 00000000..0a6d08af --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/service/AiOrchestrator.java @@ -0,0 +1,103 @@ +package com.softlabs.aicontents.domain.ai.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.softlabs.aicontents.domain.ai.dto.request.ProductBrief; +import com.softlabs.aicontents.domain.ai.dto.response.PostDraft; +import com.softlabs.aicontents.domain.ai.entity.AiGenerationEntity; +import com.softlabs.aicontents.domain.ai.entity.AiPostEntity; +import com.softlabs.aicontents.domain.ai.entity.AiRequestEntity; +import com.softlabs.aicontents.domain.ai.mapper.AiGenerationMapper; +import com.softlabs.aicontents.domain.ai.mapper.AiPostMapper; +import com.softlabs.aicontents.domain.ai.mapper.AiRequestMapper; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.SerializationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AiOrchestrator { + private final AiService aiService; // FastAPI 호출 (이미 있음) + private final AiRequestMapper aiRequestMapper; + private final AiGenerationMapper aiGenerationMapper; + private final AiPostMapper aiPostMapper; + private final ObjectMapper objectMapper; + + @Transactional + public Long generateAndSave(ProductBrief brief) { + // 1) 요청 해시로 idem 보장 (간단 예: name|price|url|keywords) + String hash = + DigestUtils.sha256Hex( + (brief.product_name + + "|" + + brief.price + + "|" + + brief.source_url + + "|" + + String.join(",", brief.keywords)) + .getBytes(StandardCharsets.UTF_8)); + + AiRequestEntity existing = aiRequestMapper.findByHash(hash); + Long requestId; + if (existing == null) { + AiRequestEntity req = new AiRequestEntity(); + req.setProductName(brief.product_name); + req.setPriceStr(brief.price); + req.setSourceUrl(brief.source_url); + try { + req.setRequestJson(objectMapper.writeValueAsString(brief)); + } catch (JsonProcessingException e) { + throw new SerializationException("Failed to serialize ProductBrief", e); + } + req.setRequestHash(hash); + aiRequestMapper.insertRequest(req); + requestId = req.getRequestId(); + } else { + requestId = existing.getRequestId(); + } + + // 2) generation row (PENDING) + AiGenerationEntity gen = new AiGenerationEntity(); + gen.setRequestId(requestId); + gen.setStatus("PENDING"); + gen.setModelName("fastapi:" + "v1/generate/post"); + gen.setTemperature(0.2); + gen.setTimeoutSec(20.0); + gen.setRetries(2); + aiGenerationMapper.insertGeneration(gen); + + long t0 = System.currentTimeMillis(); + try { + // 3) FastAPI 호출 + PostDraft draft = aiService.generate(brief); + long latency = System.currentTimeMillis() - t0; + + // 4) 성공 표시 + gen.setGenId(gen.getGenId()); // (MyBatis selectKey로 채워짐) + gen.setLatencyMs((double) latency); + gen.setTokensPrompt(0); // 필요시 FastAPI에서 넘겨주면 반영 + gen.setTokensCompletion(0); + gen.setFallbackUsed("N"); + aiGenerationMapper.markSuccess(gen); + + // 5) 결과 저장 + AiPostEntity post = new AiPostEntity(); + post.setGenId(gen.getGenId()); + post.setTitle(draft.title); + post.setMetaDescription(draft.meta_description); + post.setBodyMarkdown(draft.body_markdown); + post.setHashtagsCsv(String.join(",", draft.hashtags)); + post.setEvidenceCsv(String.join(",", draft.evidence)); + post.setSchemaVersion(draft.version); + aiPostMapper.insertPost(post); + + return post.getPostId(); + } catch (Exception e) { + aiGenerationMapper.markError(gen.getGenId(), e.getMessage()); + throw e; + } + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/ai/service/AiService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/service/AiService.java new file mode 100644 index 00000000..63a40014 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/ai/service/AiService.java @@ -0,0 +1,27 @@ +package com.softlabs.aicontents.domain.ai.service; + +import com.softlabs.aicontents.domain.ai.dto.request.ProductBrief; +import com.softlabs.aicontents.domain.ai.dto.response.PostDraft; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +/** FastAPI의 /v1/generate/post 에 POST 요청을 보내고 응답을 PostDraft로 받아서 반환. */ +@Service +public class AiService { + private final WebClient llmwebClient; + + // 생성자로 WebClient Bean 주입 + public AiService(WebClient llmwebClient) { + this.llmwebClient = llmwebClient; + } + + public PostDraft generate(ProductBrief brief) { + return llmwebClient + .post() + .uri("/v1/generate/post") + .bodyValue(brief) // ProductBrief → JSON + .retrieve() + .bodyToMono(PostDraft.class) // JSON → PostDraft + .block(); // 데모: 동기 블록 + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/entity/TestExample.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/entity/TestExample.java new file mode 100644 index 00000000..7859b78b --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/entity/TestExample.java @@ -0,0 +1,9 @@ +package com.softlabs.aicontents.domain.testexample.entity; + +import lombok.Data; + +@Data +public class TestExample { + private Long id; + private String name; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/mapper/TestExampleMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/mapper/TestExampleMapper.java new file mode 100644 index 00000000..20b54ccd --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/mapper/TestExampleMapper.java @@ -0,0 +1,12 @@ +package com.softlabs.aicontents.domain.testexample.mapper; + +import com.softlabs.aicontents.domain.testexample.entity.TestExample; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TestExampleMapper { + @org.apache.ibatis.annotations.Select("SELECT 1 FROM DUAL") + Integer ping(); + + void insertTestExample(TestExample testExample); +} diff --git a/springboot/src/main/resources/application.properties b/springboot/src/main/resources/application.properties index 0b87a5a4..4a67c6a4 100644 --- a/springboot/src/main/resources/application.properties +++ b/springboot/src/main/resources/application.properties @@ -2,6 +2,22 @@ # 공통 설정 (모든 환경에서 사용) # ============================= spring.application.name=springboot +spring.jackson.property-naming-strategy=SNAKE_CASE + +# =============================== +# MyBatis Settings +# =============================== +#mybatis.mapper-locations=classpath:/mapper/**/*.xml +#mybatis.type-aliases-package=com.example.demo.domain + +# =============================== +# Connection Pool (??) +# =============================== +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=5 +spring.datasource.hikari.idle-timeout=30000 +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.pool-name=HikariCP # ============================= # MyBatis 공통 설정 @@ -9,11 +25,13 @@ spring.application.name=springboot mybatis.mapper-locations=classpath:/mappers/**/*.xml mybatis.type-aliases-package=com.softlabs.aicontents.domain mybatis.configuration.map-underscore-to-camel-case=true +mybatis.configuration.use-generated-keys=false +mybatis.configuration.jdbc-type-for-null=VARCHAR # ============================= # Swagger UI / OpenAPI 설정 # ============================= -springdoc.api-docs.path=/v3/api-docs +#springdoc.api-docs.path=/v3/api-docs springdoc.swagger-ui.enabled=true # ============================= @@ -24,4 +42,10 @@ server.forward-headers-strategy=framework # ============================= # Message Source 인코딩 설정 # ============================= -spring.messages.encoding=UTF-8 \ No newline at end of file +spring.messages.encoding=UTF-8 +springdoc.swagger-ui.path=/swagger-ui/index.html +springdoc.swagger-ui.url=/api/v3/api-docs +springdoc.api-docs.path=/v3/api-docs + +logging.level.org.mybatis=DEBUG +logging.level.org.mybatis.spring=DEBUG diff --git a/springboot/src/main/resources/mappers/domain/ai/AiGenerationMapper.xml b/springboot/src/main/resources/mappers/domain/ai/AiGenerationMapper.xml new file mode 100644 index 00000000..1c6ab05d --- /dev/null +++ b/springboot/src/main/resources/mappers/domain/ai/AiGenerationMapper.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + SELECT AI_GENERATION_SEQ.NEXTVAL FROM DUAL + + INSERT INTO AI_GENERATION ( + GEN_ID, REQUEST_ID, STATUS, MODEL_NAME, TEMPERATURE, TIMEOUT_SEC, RETRIES, + LATENCY_MS, TOKENS_PROMPT, TOKENS_COMPLETION, FALLBACK_USED, + ERROR_MESSAGE, SYSTEM_MSG_SNAP, GEN_PROMPT_SNAP, FIX_PROMPT_SNAP + ) VALUES ( + #{genId}, #{requestId}, #{status}, #{modelName}, #{temperature}, #{timeoutSec}, #{retries}, + #{latencyMs}, #{tokensPrompt}, #{tokensCompletion}, #{fallbackUsed}, + #{errorMessage}, #{systemMsgSnap}, #{genPromptSnap}, #{fixPromptSnap} + ) + + + + + UPDATE AI_GENERATION + SET STATUS = 'SUCCESS', + LATENCY_MS = #{latencyMs}, + TOKENS_PROMPT = #{tokensPrompt}, + TOKENS_COMPLETION = #{tokensCompletion}, + RETRIES = #{retries}, + FALLBACK_USED = #{fallbackUsed}, + UPDATED_AT = SYSTIMESTAMP + WHERE GEN_ID = #{genId} + + + + UPDATE AI_GENERATION + SET STATUS = 'ERROR', + ERROR_MESSAGE = #{errorMessage}, + UPDATED_AT = SYSTIMESTAMP + WHERE GEN_ID = #{genId} + + + diff --git a/springboot/src/main/resources/mappers/domain/ai/AiPostMapper.xml b/springboot/src/main/resources/mappers/domain/ai/AiPostMapper.xml new file mode 100644 index 00000000..5f8efeb4 --- /dev/null +++ b/springboot/src/main/resources/mappers/domain/ai/AiPostMapper.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT AI_POST_SEQ.NEXTVAL FROM DUAL + + INSERT INTO AI_POST ( + POST_ID, GEN_ID, TITLE, META_DESCRIPTION, BODY_MARKDOWN, + HASHTAGS_CSV, EVIDENCE_CSV, SCHEMA_VERSION + ) VALUES ( + #{postId}, #{genId}, #{title}, #{metaDescription}, #{bodyMarkdown}, + #{hashtagsCsv}, #{evidenceCsv}, #{schemaVersion} + ) + + + + + diff --git a/springboot/src/main/resources/mappers/domain/ai/AiRequestMapper.xml b/springboot/src/main/resources/mappers/domain/ai/AiRequestMapper.xml new file mode 100644 index 00000000..d3248662 --- /dev/null +++ b/springboot/src/main/resources/mappers/domain/ai/AiRequestMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT AI_REQUEST_SEQ.NEXTVAL FROM DUAL + + INSERT INTO AI_REQUEST ( + REQUEST_ID, PRODUCT_NAME, PRICE_STR, SOURCE_URL, REQUEST_JSON, REQUEST_HASH + ) VALUES ( + #{requestId}, #{productName}, #{priceStr}, #{sourceUrl}, #{requestJson}, #{requestHash} + ) + + + +