diff --git a/packages/ai-server/src/bootstrap.py b/packages/ai-server/src/bootstrap.py index 5d60bd13..7be56e0f 100644 --- a/packages/ai-server/src/bootstrap.py +++ b/packages/ai-server/src/bootstrap.py @@ -96,6 +96,9 @@ async def health_check(): } # #416 — 모든 비즈니스 routes 가 gRPC 로 옮겨짐. router include 없음. + # editorial article chat (#446 fixup): 더 이상 HTTP router 없음 — 한 턴 LLM + # 실행은 inbound.Queue/RunChatTurn gRPC 로 통일. session/message CRUD 는 + # api-server 가 소유 (assets DB 직접 접근). return app diff --git a/packages/ai-server/src/config/_container.py b/packages/ai-server/src/config/_container.py index 07bd856e..610fa83e 100644 --- a/packages/ai-server/src/config/_container.py +++ b/packages/ai-server/src/config/_container.py @@ -21,6 +21,7 @@ from src.services.metadata.management.failed_items_manager import FailedItemsManager from src.services.raw_posts.adapters import build_default_adapters from src.managers.llm.adapters.nano_banana import NanoBananaClient +from src.managers.llm.adapters.openai_image import OpenAIImageClient from src.services.raw_posts.discovery.composite_filter import CompositeFilter from src.services.raw_posts.pipeline import RawPostsPipeline from src.services.raw_posts.processors import ( @@ -75,6 +76,20 @@ class InfrastructureContainer(DeclarativeContainer): environment=environment, ) + # Generic LLM image-gen adapter — raw_posts + editorial_article 공용 (#429) + nano_banana_client: Singleton[NanoBananaClient] = Singleton( + NanoBananaClient, + api_key=Callable(lambda env: env.GEMINI_API_KEY, environment), + model=Callable(lambda env: env.NANO_BANANA_MODEL, environment), + timeout_seconds=Callable(lambda env: env.NANO_BANANA_TIMEOUT, environment), + ) + + # OpenAI gpt-image-1 — magazine thumbnail 전용 (#429, 한글 text rendering 우수) + openai_image_client: Singleton[OpenAIImageClient] = Singleton( + OpenAIImageClient, + api_key=Callable(lambda env: env.OPENAI_API_KEY, environment), + ) + # #214 raw_posts_callback_client removed — ai-server writes DB directly. # ─── Postgres asyncpg pool — assets (raw_posts) vs operation (운영 DB) 분리 (#333, #369) ─── @@ -207,17 +222,10 @@ class RawPostsContainer(DeclarativeContainer): database_manager=infrastructure.assets_database_manager, ) - # #348 — processor stack (Nano Banana + Gemini Pro + Gemini Flash) - nano_banana_client: Singleton[NanoBananaClient] = Singleton( - NanoBananaClient, - api_key=Callable(lambda env: env.GEMINI_API_KEY, environment), - model=Callable(lambda env: env.NANO_BANANA_MODEL, environment), - timeout_seconds=Callable(lambda env: env.NANO_BANANA_TIMEOUT, environment), - ) - + # #348 — processor stack uses InfrastructureContainer.nano_banana_client (#429 공유) hero_reframe_service: Singleton[HeroReframeService] = Singleton( HeroReframeService, - client=nano_banana_client, + client=infrastructure.nano_banana_client, ) items_parser: Singleton[ItemsParser] = Singleton( @@ -235,7 +243,7 @@ class RawPostsContainer(DeclarativeContainer): # #352 — composite crop + nano-banana 1:1 catalog photo per item. items_thumbnail_service: Singleton[ItemsThumbnailService] = Singleton( ItemsThumbnailService, - client=nano_banana_client, + client=infrastructure.nano_banana_client, r2=infrastructure.r2_client, ) @@ -344,9 +352,13 @@ class EditorialDiscoveryContainer(DeclarativeContainer): DependenciesContainer() ) + # #429 architectural fix: editorial staging 은 assets DB. + # posts/spots/solutions read 는 별도로 operation pool 사용 + # (gather_discovery_input 안에서 두 풀 모두 acquire). repository: Singleton[EditorialDiscoveryRepository] = Singleton( EditorialDiscoveryRepository, - database_manager=infrastructure.operation_database_manager, + assets_db=infrastructure.assets_database_manager, + operation_db=infrastructure.operation_database_manager, ) scheduler: Singleton[DiscoveryScheduler] = Singleton( @@ -365,9 +377,10 @@ class EditorialArticlePickupContainer(DeclarativeContainer): DependenciesContainer() ) + # #429: assets pool — editorial_articles staging scheduler: Singleton[ArticlePickupScheduler] = Singleton( ArticlePickupScheduler, - database_manager=infrastructure.operation_database_manager, + database_manager=infrastructure.assets_database_manager, queue_manager=infrastructure.queue_manager, ) diff --git a/packages/ai-server/src/editorial_article/graph.py b/packages/ai-server/src/editorial_article/graph.py index 2590234e..2012e64c 100644 --- a/packages/ai-server/src/editorial_article/graph.py +++ b/packages/ai-server/src/editorial_article/graph.py @@ -10,6 +10,7 @@ from .nodes.compose_layout import compose_layout_node from .nodes.fetch_sources import fetch_sources_node +from .nodes.generate_thumbnail import generate_thumbnail_node from .nodes.publish import publish_node from .state import EditorialArticleState @@ -23,6 +24,7 @@ def create_editorial_article_graph(): builder.add_node("fetch_sources", fetch_sources_node) builder.add_node("compose_layout", compose_layout_node) + builder.add_node("generate_thumbnail", generate_thumbnail_node) builder.add_node("publish", publish_node) builder.add_edge(START, "fetch_sources") @@ -34,8 +36,10 @@ def create_editorial_article_graph(): builder.add_conditional_edges( "compose_layout", _route_after, - {"ok": "publish", "failed": END}, + # generate_thumbnail 는 graceful — 실패해도 publish 로 진행 + {"ok": "generate_thumbnail", "failed": END}, ) + builder.add_edge("generate_thumbnail", "publish") builder.add_edge("publish", END) return builder.compile() diff --git a/packages/ai-server/src/editorial_article/models.py b/packages/ai-server/src/editorial_article/models.py index 2a8d6f50..3f940294 100644 --- a/packages/ai-server/src/editorial_article/models.py +++ b/packages/ai-server/src/editorial_article/models.py @@ -15,6 +15,8 @@ class SectionSolution(BaseModel): brand: Optional[str] = None price: Optional[str] = None image_url: Optional[str] = None + original_url: Optional[str] = None # 상품 페이지 (#429 commercial) + affiliate_url: Optional[str] = None # 제휴 링크 (현재 미수집, 향후) class MagazineSection(BaseModel): @@ -42,7 +44,8 @@ class MagazineLayout(BaseModel): schema_version: str = "1.0" title: str subtitle: Optional[str] = None - hero_image_url: Optional[str] = None + hero_image_url: Optional[str] = None # 16:9 — nano-banana 결과 (#429) + thumbnail_url: Optional[str] = None # 4:5 — nano-banana 결과 (#429) sections: list[MagazineSection] @@ -58,6 +61,8 @@ class SourceSolution(BaseModel): price: Optional[str] = None image_url: Optional[str] = None sub_category: Optional[str] = None + original_url: Optional[str] = None # 상품 페이지 (#429 commercial) + affiliate_url: Optional[str] = None class SourcePost(BaseModel): diff --git a/packages/ai-server/src/editorial_article/nodes/fetch_sources.py b/packages/ai-server/src/editorial_article/nodes/fetch_sources.py index e1a60532..140a363e 100644 --- a/packages/ai-server/src/editorial_article/nodes/fetch_sources.py +++ b/packages/ai-server/src/editorial_article/nodes/fetch_sources.py @@ -16,9 +16,12 @@ async def _load_context( - db: DatabaseManager, recommendation_id: str + assets_db: DatabaseManager, + operation_db: DatabaseManager, + recommendation_id: str, ) -> RecommendationContext: - async with db.acquire() as conn: + # #429: recommendations 는 assets, posts/spots/solutions 는 operation. + async with assets_db.acquire() as conn: rec = await conn.fetchrow( """ SELECT id, angle_title, angle_description, rationale, @@ -28,14 +31,15 @@ async def _load_context( """, recommendation_id, ) - if rec is None: - raise ValueError(f"recommendation {recommendation_id} not found") + if rec is None: + raise ValueError(f"recommendation {recommendation_id} not found") - post_ids = list(rec["source_post_ids"] or []) - sol_id_filter = set(str(x) for x in (rec["source_solution_ids"] or [])) + post_ids = list(rec["source_post_ids"] or []) + sol_id_filter = set(str(x) for x in (rec["source_solution_ids"] or [])) - posts: list[SourcePost] = [] - if post_ids: + posts: list[SourcePost] = [] + if post_ids: + async with operation_db.acquire() as conn: post_rows = await conn.fetch( """ SELECT id, artist_name, group_name, context, image_url, style_tags @@ -48,7 +52,8 @@ async def _load_context( sol_rows = await conn.fetch( """ SELECT s.post_id, - sol.id, sol.title, sol.thumbnail_url, sol.metadata + sol.id, sol.title, sol.thumbnail_url, sol.metadata, + sol.original_url, sol.affiliate_url FROM public.spots s JOIN public.solutions sol ON sol.spot_id = s.id WHERE s.post_id = ANY($1::uuid[]) @@ -80,59 +85,64 @@ async def _load_context( price=str(meta.get("price")) if meta.get("price") else None, image_url=sr["thumbnail_url"], sub_category=meta.get("sub_category"), + original_url=sr["original_url"], + affiliate_url=sr["affiliate_url"], ) ) - for pr in post_rows: - pid = str(pr["id"]) - style_tags = pr["style_tags"] or [] - if isinstance(style_tags, str): - try: - style_tags = json.loads(style_tags) - except Exception: - style_tags = [] - posts.append( - SourcePost( - post_id=pid, - artist_name=pr["artist_name"], - group_name=pr["group_name"], - context=pr["context"], - image_url=pr["image_url"], - style_tags=list(style_tags) if isinstance(style_tags, list) else [], - solutions=sols_by_post.get(pid, []), - ) + for pr in post_rows: + pid = str(pr["id"]) + style_tags = pr["style_tags"] or [] + if isinstance(style_tags, str): + try: + style_tags = json.loads(style_tags) + except Exception: + style_tags = [] + posts.append( + SourcePost( + post_id=pid, + artist_name=pr["artist_name"], + group_name=pr["group_name"], + context=pr["context"], + image_url=pr["image_url"], + style_tags=list(style_tags) if isinstance(style_tags, list) else [], + solutions=sols_by_post.get(pid, []), ) + ) - keywords = rec["keywords"] or [] - if isinstance(keywords, str): - try: - keywords = json.loads(keywords) - except Exception: - keywords = [] - - return RecommendationContext( - recommendation_id=str(rec["id"]), - angle_title=rec["angle_title"], - angle_description=rec["angle_description"], - rationale=rec["rationale"], - keywords=list(keywords) if isinstance(keywords, list) else [], - source_posts=posts, - ) + keywords = rec["keywords"] or [] + if isinstance(keywords, str): + try: + keywords = json.loads(keywords) + except Exception: + keywords = [] + + return RecommendationContext( + recommendation_id=str(rec["id"]), + angle_title=rec["angle_title"], + angle_description=rec["angle_description"], + rationale=rec["rationale"], + keywords=list(keywords) if isinstance(keywords, list) else [], + source_posts=posts, + ) async def fetch_sources_node(state: dict, config: RunnableConfig) -> dict: - """recommendation_id 로 posts + 솔루션 모두 로드.""" - db: DatabaseManager | None = (config or {}).get("configurable", {}).get( - "operation_database_manager" - ) - if db is None: + """recommendation (assets) + posts/spots/solutions (operation) 로드.""" + cfg = (config or {}).get("configurable", {}) + assets_db: DatabaseManager | None = cfg.get("assets_database_manager") + operation_db: DatabaseManager | None = cfg.get("operation_database_manager") + if assets_db is None or operation_db is None: return { "pipeline_status": "failed", - "error_log": ["fetch_sources: operation_database_manager missing"], + "error_log": [ + "fetch_sources: db pools missing " + f"(assets={assets_db}, operation={operation_db})" + ], } try: - ctx = await _load_context(db, state["recommendation_id"]) + ctx = await _load_context(assets_db, operation_db, state["recommendation_id"]) except Exception as exc: logger.exception("fetch_sources failed") return { diff --git a/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py b/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py new file mode 100644 index 00000000..a019f4ad --- /dev/null +++ b/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py @@ -0,0 +1,152 @@ +"""generate_thumbnail node — gpt-image-2 magazine-style 2:3 thumbnail. + +source post 1장 + 한글 title overlay → OpenAI gpt-image-1 edit → R2 업로드. +nano-banana 는 한글 text rendering 약해서 이번 노드에서는 OpenAI 사용 (#429). + +흐름: + 1. layout 의 첫 curation_card image_url 다운로드 + 2. gpt-image-1 edit: source 이미지 + magazine-cover prompt (한글 title 포함) + 3. 결과는 1024x1536 (2:3) — 1024x1280 (4:5) center-crop + 4. R2: editorial-magazines/{article_id}/thumbnail.png + +graceful — 실패 시 layout.thumbnail_url 미설정. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +import httpx +from langchain_core.runnables import RunnableConfig + +from src.managers.llm.adapters.openai_image import ( + OpenAIImageClient, + OpenAIImageError, +) +from src.managers.storage.r2_client import R2Client + +from ..models import MagazineLayout + + +logger = logging.getLogger(__name__) + + +def _build_prompt(title: str, subtitle: Optional[str]) -> str: # noqa: ARG001 + return f"""Transform this photograph into a Korean fashion magazine Instagram-style thumbnail +(2:3 portrait, 1024x1536). + +KEEP the subject (person, pose, outfit) recognizable from the source photo. + +ADD design treatment over the photograph: + +1. KOREAN TITLE TEXT OVERLAY at bottom-left: + "{title}" + + Typography: + - Bold sans-serif Korean (Pretendard Bold / Noto Sans CJK Bold style) + - WHITE color, large readable thumbnail size + - Break into 2-3 lines at natural Korean phrase boundaries (commas, particles) + - Position in lower-left third with proper margin from image edge + +2. "DECODED MAG" wordmark in TOP-RIGHT corner — REQUIRED: + - Color: bright lime green (#eafd67) — the brand magazine accent color + - Bold sans-serif uppercase (modern editorial typography — think Pretendard / + Inter / Helvetica Bold) + - Small but readable size — magazine masthead style + - Position: top-right with proper margin (matches title margin on bottom-left) + - Render text PERFECTLY — exactly "DECODED MAG" (uppercase, with single space) + +3. Editorial color grading: slight desaturation, sophisticated cinematic tone. + +4. If text legibility needs help, add a subtle dark gradient ONLY in the text area (lower-left). + +CRITICAL: +- Render the Korean text PERFECTLY — no typos, no character substitutions +- 2:3 portrait (1024x1536) strictly +- Subject must remain visible behind/around text overlay + +Style reference: 053 Magazine, W Korea, Vogue Korea Instagram covers. + +Output: the photograph with Korean text overlay applied.""" + + +def _pick_source_url(layout: MagazineLayout) -> Optional[str]: + for sec in layout.sections: + if sec.type == "curation_card" and sec.image_url: + return sec.image_url + for sec in layout.sections: + if sec.image_url: + return sec.image_url + return layout.hero_image_url + + +async def _download_image(url: str) -> tuple[bytes, str]: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.get(url) + resp.raise_for_status() + ct = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip() + return resp.content, ct or "image/jpeg" + + +async def generate_thumbnail_node(state: dict, config: RunnableConfig) -> dict: + layout: Optional[MagazineLayout] = state.get("layout") + if layout is None: + return {} + + cfg = (config or {}).get("configurable", {}) or {} + openai_client: Optional[OpenAIImageClient] = cfg.get("openai_image_client") + r2: Optional[R2Client] = cfg.get("r2_client") + + if openai_client is None or r2 is None or not r2.is_configured(): + logger.info("generate_thumbnail: OpenAI / R2 not configured — skip") + return {} + + article_id = state["article_id"] + + src_url = _pick_source_url(layout) + if not src_url: + logger.info("generate_thumbnail: no source image — skip") + return {} + + try: + src_bytes, src_mime = await _download_image(src_url) + except Exception as exc: + logger.warning("generate_thumbnail: source download failed (%s)", exc) + return {} + + prompt = _build_prompt(layout.title or "", layout.subtitle) + + try: + # 1024x1536 (2:3 portrait) — center crop 제거 (#429): crop 이 watermark + # (top) 와 title (bottom) 양쪽 잘라냈음. 2:3 portrait 으로 직접 사용. + out_bytes = await openai_client.edit( + image_bytes=src_bytes, + image_mime_type=src_mime, + prompt=prompt, + size="1024x1536", + quality="high", + ) + except OpenAIImageError as exc: + logger.warning("generate_thumbnail: OpenAI failed (%s)", exc) + return {"error_log": [f"generate_thumbnail: openai: {exc}"]} + except Exception as exc: + logger.exception("generate_thumbnail: unexpected error") + return {"error_log": [f"generate_thumbnail: {type(exc).__name__}: {exc}"]} + + key = f"editorial-magazines/{article_id}/thumbnail.png" + try: + result = await asyncio.to_thread(r2.put, key, out_bytes, "image/png") + except Exception as exc: + logger.warning("generate_thumbnail: R2 upload failed (%s)", exc) + return {"error_log": [f"generate_thumbnail: R2: {exc}"]} + + if not result.url: + return {} + + new_layout = layout.model_copy(update={"thumbnail_url": result.url}) + logger.info( + "generate_thumbnail: ok article=%s url=%s", article_id, result.url + ) + return {"layout": new_layout} diff --git a/packages/ai-server/src/editorial_article/nodes/publish.py b/packages/ai-server/src/editorial_article/nodes/publish.py index 835cdb89..ac2ddfed 100644 --- a/packages/ai-server/src/editorial_article/nodes/publish.py +++ b/packages/ai-server/src/editorial_article/nodes/publish.py @@ -25,16 +25,17 @@ async def _persist( layout_json = layout.model_dump(mode="json") async with db.acquire() as conn: async with conn.transaction(): - # 1) editorial_articles: status=draft, layout_json + title/subtitle/hero + # 1) editorial_articles: status=draft, layout_json + title/subtitle/hero/thumb prev_row = await conn.fetchrow( """ UPDATE public.editorial_articles SET title = $2, subtitle = $3, - hero_image_url = $4, - layout_json = $5::jsonb, - status = 'draft', - updated_at = now() + hero_image_url = $4, + thumbnail_url = $5, + layout_json = $6::jsonb, + status = 'draft', + updated_at = now() WHERE id = $1::uuid RETURNING id """, @@ -42,6 +43,7 @@ async def _persist( layout.title or "Untitled", layout.subtitle, layout.hero_image_url, + layout.thumbnail_url, json.dumps(layout_json), ) if prev_row is None: @@ -72,13 +74,14 @@ async def _persist( async def publish_node(state: dict, config: RunnableConfig) -> dict: + # #429: editorial_articles + recommendations + events 모두 assets staging. db: DatabaseManager | None = (config or {}).get("configurable", {}).get( - "operation_database_manager" + "assets_database_manager" ) if db is None: return { "pipeline_status": "failed", - "error_log": ["publish: operation_database_manager missing"], + "error_log": ["publish: assets_database_manager missing"], } layout: MagazineLayout | None = state.get("layout") diff --git a/packages/ai-server/src/editorial_article/prompts.py b/packages/ai-server/src/editorial_article/prompts.py index 7825904d..2b29f232 100644 --- a/packages/ai-server/src/editorial_article/prompts.py +++ b/packages/ai-server/src/editorial_article/prompts.py @@ -26,6 +26,8 @@ def build_compose_layout_prompt(ctx: RecommendationContext) -> str: "price": s.price, "image_url": s.image_url, "sub_category": s.sub_category, + "original_url": s.original_url, + "affiliate_url": s.affiliate_url, } for s in p.solutions ], @@ -50,25 +52,30 @@ def build_compose_layout_prompt(ctx: RecommendationContext) -> str: 요구사항: - title: angle_title 을 다듬은 매거진 제목 (한국어, TAGGED 톤). - subtitle: 한 문장 부제. -- hero_image_url: source posts 중 가장 적합한 image_url 1개. -- sections: 다음 순서로 구성. - 1. hero (title/subtitle/hero_image_url 반복) - 2. intro (200-300자 도입 카피, 이 angle 이 왜 지금 의미있는지) - 3. curation_card N개 — source post 마다 1개. body 에 100-200자 카피, post_id / +- hero_image_url: null. (#429 — 매거진 본문에 hero banner 안 씀, OG 메타 등은 + thumbnail_url 사용. LLM 은 hero_image_url 채우지 마라.) +- sections: 다음 순서로 구성. **hero 섹션 만들지 마라** — title/subtitle 은 layout + 의 최상위 필드에서 이미 표시됨. + 1. intro (200-300자 도입 카피, 이 angle 이 왜 지금 의미있는지) + 2. curation_card N개 — source post 마다 1개. body 에 100-200자 카피, post_id / image_url / solutions (그 post 의 솔루션 카드들) 포함. - 4. (선택) spotlight 1개 — 가장 강조하고 싶은 솔루션 / 브랜드. - 5. closing (100-150자 마무리 카피) + 3. (선택) spotlight 1개 — 가장 강조하고 싶은 솔루션 / 브랜드. + 4. closing (100-150자 마무리 카피) - 모든 post_id / solution_id 는 입력에 등장한 것만 사용. 새 id 만들지 마라. +- solutions 배열에는 input solution 의 **original_url / affiliate_url 그대로 + 복사** (있으면). 사용자가 상품 페이지로 바로 갈 수 있어야 함. URL 변형 X. - "Hypebeast" / "Tagged" 라는 단어는 출력에 절대 포함 금지. Decoded 로 표기. - 카피는 한국어, 자연스럽고 트렌디한 매거진 톤. JSON 출력만. MagazineLayout 스키마: {{"schema_version": "1.0", "title": "...", "subtitle": "...", "hero_image_url": "...", - "sections": [{{"type": "hero|intro|curation_card|spotlight|closing", + "sections": [{{"type": "intro|curation_card|spotlight|closing", "title": "...", "body": "...", "post_id": "...", "image_url": "...", "solutions": [{{"solution_id": "...", "title": "...", "brand": "...", "price": "...", - "image_url": "..."}}]}}]}} + "image_url": "...", + "original_url": "...", + "affiliate_url": "..."}}]}}]}} """ diff --git a/packages/ai-server/src/editorial_article_chat/__init__.py b/packages/ai-server/src/editorial_article_chat/__init__.py new file mode 100644 index 00000000..058fb751 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/__init__.py @@ -0,0 +1 @@ +"""Stage 3: editorial article 대화형 편집 — Gemini function calling 기반 agent.""" diff --git a/packages/ai-server/src/editorial_article_chat/agent.py b/packages/ai-server/src/editorial_article_chat/agent.py new file mode 100644 index 00000000..f976d0b4 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/agent.py @@ -0,0 +1,282 @@ +"""Chat agent — pure compute (Gemini function calling loop). + +#446 fixup: DB 는 만지지 않는다. caller (api-server via gRPC) 가 + - 현재 layout 과 history 를 input 으로 넘기고 + - 결과로 events (assistant/tool 메시지 시퀀스) + final_layout 을 받아서 + - 직접 INSERT / UPDATE 한다. + +흐름: + 1. caller 가 보낸 history 를 Gemini contents 로 변환 + 2. user_message append + 첫 Gemini 호출 + 3. function_call 있으면 → ToolExecutor 로 실행 → tool result 를 Gemini 에 다시 + 보내고 step 반복 (max_steps 까지). 매 step 의 assistant 메시지와 tool + 메시지를 events 에 누적. + 4. function_call 없으면 → 최종 assistant text 반환 + 5. 최종 layout 이 입력과 다르면 layout_changed=True +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Optional + +from google import genai +from google.genai import types as genai_types + +from src.editorial_article.models import MagazineLayout +from src.post_editorial.config import get_settings +from src.post_editorial.gemini_retry import call_gemini_with_fallback + +from .tools import TOOL_DECLARATIONS, ToolExecutor + + +logger = logging.getLogger(__name__) + + +_MAX_STEPS = 6 # tool calling round trip 최대 횟수 (안전 가드) + + +@dataclass +class RunTurnResult: + # caller 가 순서대로 INSERT 할 메시지들. 각 dict 형식: + # assistant: {"role":"assistant", "content":str|None, "tool_calls":[{name,args,id}]|None} + # tool: {"role":"tool", "tool_call_id":str, "tool_name":str, "tool_result":dict} + events: list[dict[str, Any]] = field(default_factory=list) + final_text: str = "" + final_layout: Optional[MagazineLayout] = None + layout_changed: bool = False + tool_calls_made: int = 0 + error_message: str = "" + + +def _system_prompt(article_title: str, sections_brief: str) -> str: + return f"""너는 Decoded 매거진의 시니어 에디터를 돕는 편집 AI다. +컨텐츠 매니저와 한국어로 대화하며 주어진 매거진 article 의 layout 을 다듬는다. + +[현재 매거진] +title: {article_title} +sections: +{sections_brief} + +[행동 원칙] +1. 사용자 의도를 정확히 파악 — 어떤 섹션을, 어떻게 바꾸고 싶은지. +2. 모호하면 한 번 물어보고, 명확하면 즉시 도구 호출. +3. 한 번에 여러 섹션 수정 가능 — 도구를 연속 호출. +4. 작은 텍스트 수정은 update_section_body / update_title 직접. + 톤·길이 변경 등 LLM 도움 필요한 수정은 regenerate_section_body. +5. 잘못된 인물 / 부적절한 카드는 remove_section. +6. 모든 변경 후 한국어로 무엇을 했는지 1-2 문장 요약. +7. layout 구조를 모르면 먼저 get_current_layout. + +[금기] +- "Hypebeast" / "Tagged" 같은 단어 출력 금지 (Decoded 톤). +- 사용자가 명시 안 한 변경 임의로 하지 말 것. +- 도구 호출 결과가 실패면 사용자에게 솔직히 알리고 다음 행동 제안.""" + + +def _build_contents( + history: list[dict[str, Any]], new_user_text: str +) -> list[genai_types.Content]: + """caller 가 보낸 history (DB 메시지 dict list) → Gemini Content[] 변환. + + 각 history dict 의 형식 (caller 측 SeaORM Model 직렬화 결과): + {role: "user"|"assistant"|"tool", content?, tool_calls?, tool_call_id?, + tool_name?, tool_result?} + """ + contents: list[genai_types.Content] = [] + for msg in history: + role = msg.get("role") + if role == "user" and msg.get("content"): + contents.append( + genai_types.Content( + role="user", + parts=[genai_types.Part.from_text(text=msg["content"])], + ) + ) + elif role == "assistant": + parts: list[genai_types.Part] = [] + if msg.get("content"): + parts.append(genai_types.Part.from_text(text=msg["content"])) + for fc in msg.get("tool_calls") or []: + parts.append( + genai_types.Part.from_function_call( + name=fc["name"], args=fc.get("args", {}) + ) + ) + if parts: + contents.append(genai_types.Content(role="model", parts=parts)) + elif role == "tool": + contents.append( + genai_types.Content( + role="user", + parts=[ + genai_types.Part.from_function_response( + name=msg.get("tool_name") or "unknown", + response=msg.get("tool_result") or {}, + ) + ], + ) + ) + contents.append( + genai_types.Content( + role="user", parts=[genai_types.Part.from_text(text=new_user_text)] + ) + ) + return contents + + +def _summarize_sections(layout: MagazineLayout) -> str: + lines = [] + for i, sec in enumerate(layout.sections): + title = sec.title or "(no title)" + lines.append(f" [{i}] {sec.type}: {title}") + return "\n".join(lines) if lines else " (sections 없음)" + + +def _extract_function_calls( + response: genai_types.GenerateContentResponse, +) -> list[tuple[str, dict, Optional[str]]]: + """response 의 function_call 들 추출 — (name, args, call_id) tuple list.""" + calls: list[tuple[str, dict, Optional[str]]] = [] + for candidate in response.candidates or []: + content = candidate.content + for part in (content.parts if content else []) or []: + fc = getattr(part, "function_call", None) + if fc and fc.name: + args = dict(fc.args) if fc.args else {} + call_id = getattr(fc, "id", None) + calls.append((fc.name, args, call_id)) + return calls + + +def _extract_text(response: genai_types.GenerateContentResponse) -> str: + parts_text: list[str] = [] + for candidate in response.candidates or []: + content = candidate.content + for part in (content.parts if content else []) or []: + t = getattr(part, "text", None) + if t: + parts_text.append(t) + return "\n".join(parts_text).strip() + + +async def run_turn( + *, + article_title: str, + layout: MagazineLayout, + history: list[dict[str, Any]], + user_text: str, +) -> RunTurnResult: + """한 턴 실행 — pure compute. caller 가 결과를 받아 DB 에 persist.""" + contents = _build_contents(history, user_text) + + settings = get_settings() + client = genai.Client(api_key=settings.gemini_api_key) + executor = ToolExecutor(layout=layout) + initial_layout = layout + + config = genai_types.GenerateContentConfig( + temperature=0.4, + system_instruction=_system_prompt( + article_title or "Untitled", _summarize_sections(layout) + ), + tools=[genai_types.Tool(function_declarations=TOOL_DECLARATIONS)], + ) + + events: list[dict[str, Any]] = [] + tool_calls_made = 0 + final_text = "" + + for step in range(_MAX_STEPS): + async def _generate(model: str) -> genai_types.GenerateContentResponse: + return await client.aio.models.generate_content( + model=model, contents=contents, config=config + ) + + try: + response = await call_gemini_with_fallback( + settings.gemini_pro_model, settings.gemini_model, _generate + ) + except Exception as exc: + logger.exception("chat agent: Gemini call failed") + err_text = f"(에이전트 오류: {exc})" + events.append({"role": "assistant", "content": err_text}) + return RunTurnResult( + events=events, + final_text=err_text, + final_layout=executor.layout, + layout_changed=False, + tool_calls_made=tool_calls_made, + error_message=str(exc), + ) + + function_calls = _extract_function_calls(response) + text_part = _extract_text(response) + + # assistant 이벤트 누적 + tool_call_payloads = [ + {"name": name, "args": args, "id": cid or f"tc_{step}_{i}"} + for i, (name, args, cid) in enumerate(function_calls) + ] + events.append( + { + "role": "assistant", + "content": text_part if text_part else None, + "tool_calls": tool_call_payloads if tool_call_payloads else None, + } + ) + + if not function_calls: + final_text = text_part or "" + break + + # 다음 step 을 위해 model turn 도 contents 에 append + model_parts: list[genai_types.Part] = [] + if text_part: + model_parts.append(genai_types.Part.from_text(text=text_part)) + for name, args, _ in function_calls: + model_parts.append( + genai_types.Part.from_function_call(name=name, args=args) + ) + contents.append(genai_types.Content(role="model", parts=model_parts)) + + # 각 tool 실행 + tool 이벤트 누적 + Gemini 에 결과 전달 + tool_response_parts: list[genai_types.Part] = [] + for i, (name, args, _cid) in enumerate(function_calls): + tool_calls_made += 1 + result = await executor.execute(name, args) + tc_id = tool_call_payloads[i]["id"] + events.append( + { + "role": "tool", + "tool_call_id": tc_id, + "tool_name": name, + "tool_result": result.to_dict(), + } + ) + tool_response_parts.append( + genai_types.Part.from_function_response( + name=name, response=result.to_dict() + ) + ) + contents.append( + genai_types.Content(role="user", parts=tool_response_parts) + ) + else: + # max steps 도달 + final_text = ( + "(편집 사이클 한도 도달 — 안전상 중단. " + "더 진행하려면 다시 메시지 보내주세요.)" + ) + events.append({"role": "assistant", "content": final_text}) + + layout_changed = executor.layout != initial_layout + + return RunTurnResult( + events=events, + final_text=final_text, + final_layout=executor.layout, + layout_changed=layout_changed, + tool_calls_made=tool_calls_made, + ) diff --git a/packages/ai-server/src/editorial_article_chat/tools.py b/packages/ai-server/src/editorial_article_chat/tools.py new file mode 100644 index 00000000..025a39d9 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/tools.py @@ -0,0 +1,362 @@ +"""Tool definitions for editorial article chat agent. + +Tools 는 두 가지 카테고리: + 1. Direct edits — agent 가 layout_json 의 특정 필드를 직접 교체 (text 변경) + 2. LLM-assisted regenerations — agent 가 hint 와 함께 호출하면 별도 Gemini 가 + 새 카피를 생성해서 반영 (e.g., regenerate_section_body) + +각 tool 의 declaration 은 google-genai 의 FunctionDeclaration 형식. +실행은 ToolExecutor 가 담당 — pure compute (in-memory layout mutate) 만 수행. +DB persist 는 호출자 (api-server) 책임 (#446 fixup). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from google import genai +from google.genai import types as genai_types + +from src.editorial_article.models import MagazineLayout, MagazineSection +from src.post_editorial.config import get_settings +from src.post_editorial.gemini_retry import call_gemini_with_fallback + + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────────────────────── +# Tool 선언 (Gemini function declarations) +# ───────────────────────────────────────────────────────────────────────────── + +TOOL_DECLARATIONS: list[genai_types.FunctionDeclaration] = [ + genai_types.FunctionDeclaration( + name="update_title", + description=( + "매거진 article 의 title 을 새 문자열로 교체합니다. " + "subtitle 변경에는 update_subtitle 을 사용하세요." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "title": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 매거진 title (한국어, TAGGED 톤).", + ), + }, + required=["title"], + ), + ), + genai_types.FunctionDeclaration( + name="update_subtitle", + description="매거진 article 의 subtitle (부제) 를 교체합니다.", + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "subtitle": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 subtitle. 빈 문자열이면 subtitle 제거.", + ), + }, + required=["subtitle"], + ), + ), + genai_types.FunctionDeclaration( + name="update_section_body", + description=( + "특정 섹션의 body 카피를 새 문자열로 교체합니다. " + "현재 body 를 유지하고 약간만 다듬을 때 사용. " + "전체 톤을 LLM 에게 다시 생성시키려면 regenerate_section_body 사용." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema( + type=genai_types.Type.INTEGER, + description="섹션 0-based index. get_current_layout 결과 참조.", + ), + "body": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 body 카피.", + ), + }, + required=["section_idx", "body"], + ), + ), + genai_types.FunctionDeclaration( + name="update_section_title", + description="특정 섹션의 title 을 교체합니다.", + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema(type=genai_types.Type.INTEGER), + "title": genai_types.Schema(type=genai_types.Type.STRING), + }, + required=["section_idx", "title"], + ), + ), + genai_types.FunctionDeclaration( + name="remove_section", + description=( + "특정 섹션을 매거진에서 제거합니다. " + "예: 잘못된 인물의 curation_card 또는 부적절한 섹션. " + "hero / closing 도 제거 가능 — 매거진 구조 자체를 바꿀 때." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema(type=genai_types.Type.INTEGER), + }, + required=["section_idx"], + ), + ), + genai_types.FunctionDeclaration( + name="regenerate_section_body", + description=( + "특정 섹션의 body 카피를 LLM 으로 다시 생성합니다. " + "사용자가 톤 / 길이 / 강조점 변경을 요구할 때 사용. " + "hint 에 어떻게 다시 쓸지 구체 지침 (예: '더 차분하게', " + "'브랜드 가격을 강조', '문장 짧게')." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema(type=genai_types.Type.INTEGER), + "hint": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 카피 작성 지침 (한국어).", + ), + }, + required=["section_idx", "hint"], + ), + ), + genai_types.FunctionDeclaration( + name="get_current_layout", + description=( + "현재 매거진 layout 의 요약 (각 섹션의 idx, type, title, body 의 첫 200자) " + "을 반환합니다. 사용자 요청을 수행하기 전 어떤 섹션이 있는지 확인할 때 사용." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, properties={} + ), + ), +] + + +# ───────────────────────────────────────────────────────────────────────────── +# Tool 실행 +# ───────────────────────────────────────────────────────────────────────────── + + +@dataclass +class ToolResult: + success: bool + summary: str + layout_changed: bool = False + error: Optional[str] = None + + def to_dict(self) -> dict: + return { + "success": self.success, + "summary": self.summary, + "layout_changed": self.layout_changed, + "error": self.error, + } + + +class ToolExecutor: + """Layout 을 메모리에 들고 있다가 각 tool 호출에 대해 mutate. + + Pure compute — DB 는 만지지 않는다. 최종 layout 은 caller 가 self.layout + 프로퍼티로 가져가 persist 한다. + """ + + def __init__(self, layout: MagazineLayout) -> None: + self._layout = layout + + @property + def layout(self) -> MagazineLayout: + return self._layout + + async def execute(self, name: str, args: dict) -> ToolResult: + handler: Optional[Callable[..., Any]] = _HANDLERS.get(name) + if handler is None: + return ToolResult(False, f"Unknown tool: {name}", error="unknown_tool") + + try: + return await handler(self, **args) + except TypeError as exc: + logger.warning("ToolExecutor: bad args for %s — %s", name, exc) + return ToolResult(False, f"Bad arguments: {exc}", error=str(exc)) + except Exception as exc: + logger.exception("ToolExecutor: %s crashed", name) + return ToolResult(False, f"Tool crashed: {exc}", error=str(exc)) + + # ---- Handlers -------------------------------------------------------- + + async def _update_title(self, title: str) -> ToolResult: + old = self._layout.title + self._layout = self._layout.model_copy(update={"title": title}) + return ToolResult(True, f"Title: '{old}' → '{title}'", layout_changed=True) + + async def _update_subtitle(self, subtitle: str) -> ToolResult: + new = subtitle if subtitle else None + old = self._layout.subtitle + self._layout = self._layout.model_copy(update={"subtitle": new}) + return ToolResult( + True, f"Subtitle: '{old}' → '{new}'", layout_changed=True + ) + + def _check_idx(self, section_idx: int) -> Optional[ToolResult]: + if section_idx < 0 or section_idx >= len(self._layout.sections): + return ToolResult( + False, + f"section_idx {section_idx} out of range (0..{len(self._layout.sections)-1}).", + error="out_of_range", + ) + return None + + async def _update_section_body( + self, section_idx: int, body: str + ) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sections = list(self._layout.sections) + sec = sections[section_idx].model_copy(update={"body": body}) + sections[section_idx] = sec + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} ({sec.type}) body 교체 — {len(body)} 자", + layout_changed=True, + ) + + async def _update_section_title( + self, section_idx: int, title: str + ) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sections = list(self._layout.sections) + old = sections[section_idx].title + sections[section_idx] = sections[section_idx].model_copy( + update={"title": title} + ) + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} title: '{old}' → '{title}'", + layout_changed=True, + ) + + async def _remove_section(self, section_idx: int) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sections = list(self._layout.sections) + removed = sections.pop(section_idx) + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} ({removed.type}, '{removed.title}') 제거. 새 길이 {len(sections)}.", + layout_changed=True, + ) + + async def _regenerate_section_body( + self, section_idx: int, hint: str + ) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sec = self._layout.sections[section_idx] + new_body = await _llm_regenerate_body(self._layout, sec, hint) + sections = list(self._layout.sections) + sections[section_idx] = sec.model_copy(update={"body": new_body}) + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} ({sec.type}) body 재생성 (hint: {hint[:60]}{'…' if len(hint) > 60 else ''})", + layout_changed=True, + ) + + async def _get_current_layout(self) -> ToolResult: + lines = [ + f"Title: {self._layout.title}", + f"Subtitle: {self._layout.subtitle or '(none)'}", + f"Sections ({len(self._layout.sections)}):", + ] + for i, sec in enumerate(self._layout.sections): + preview = (sec.body or "")[:200].replace("\n", " ") + lines.append( + f" [{i}] type={sec.type} title='{sec.title or ''}' body='{preview}'" + ) + return ToolResult(True, "\n".join(lines)) + + +_HANDLERS: dict[str, Callable[..., Any]] = { + "update_title": ToolExecutor._update_title, + "update_subtitle": ToolExecutor._update_subtitle, + "update_section_body": ToolExecutor._update_section_body, + "update_section_title": ToolExecutor._update_section_title, + "remove_section": ToolExecutor._remove_section, + "regenerate_section_body": ToolExecutor._regenerate_section_body, + "get_current_layout": ToolExecutor._get_current_layout, +} + + +# ───────────────────────────────────────────────────────────────────────────── +# LLM helper — section body 재생성 +# ───────────────────────────────────────────────────────────────────────────── + + +async def _llm_regenerate_body( + layout: MagazineLayout, section: MagazineSection, hint: str +) -> str: + settings = get_settings() + client = genai.Client(api_key=settings.gemini_api_key) + prompt = _build_regenerate_prompt(layout, section, hint) + + async def _generate(model: str) -> str: + resp = await client.aio.models.generate_content( + model=model, + contents=prompt, + config=genai_types.GenerateContentConfig(temperature=0.7), + ) + return (resp.text or "").strip() + + return await call_gemini_with_fallback( + settings.gemini_model, # flash 면 충분 + settings.gemini_fallback_model, + _generate, + ) + + +def _build_regenerate_prompt( + layout: MagazineLayout, section: MagazineSection, hint: str +) -> str: + return f"""너는 Decoded 매거진의 시니어 에디터다. +아래 매거진 한 섹션의 body 카피를 다시 작성해라. + +[매거진 컨텍스트] +title: {layout.title} +subtitle: {layout.subtitle or ''} + +[수정할 섹션] +type: {section.type} +title: {section.title or ''} +현재 body: +{section.body or '(empty)'} + +[수정 지침] +{hint} + +요구사항: +- 한국어, 트렌디하면서도 자연스러운 매거진 톤 +- "Hypebeast" / "Tagged" 라는 단어 절대 사용 금지 (Decoded 로) +- 섹션 type 에 적합한 길이: + - intro: 200-300자 + - curation_card: 100-200자 + - spotlight: 150-200자 + - closing: 100-150자 + - hero: 50자 이내 (있을 경우) + +새 body 카피만 출력. 따옴표 / JSON / 추가 설명 없이 plain text 로.""" diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound.proto b/packages/ai-server/src/grpc/proto/inbound/inbound.proto index 6fa5700e..9bcb8908 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound.proto +++ b/packages/ai-server/src/grpc/proto/inbound/inbound.proto @@ -28,6 +28,12 @@ service Queue { // 없애기 위해 모든 진입을 gRPC 로 통일. rpc TriggerSource (TriggerSourceRequest) returns (TriggerSourceResponse); rpc ReparseRawPost (ReparseRawPostRequest) returns (ReparseRawPostResponse); + + // #446 fixup — editorial article chat 의 한 턴 LLM 실행만 ai-server 가 + // 담당. session/message CRUD 와 layout persist 는 api-server 가 소유. + // 호출자가 article_id, 현재 layout_json, history_json, user_message 를 넘기면 + // events_json (assistant/tool 메시지 시퀀스) + final_layout_json 을 반환. + rpc RunChatTurn (RunChatTurnRequest) returns (RunChatTurnResponse); } // #214 RawPostsWorker service removed — ai-server schedules itself. @@ -230,3 +236,32 @@ message ReparseRawPostResponse { bool accepted = 1; string error_message = 2; } + +// Editorial article chat — 한 턴 LLM 실행 (#446 fixup). +// +// dynamic 페이로드 (layout/history/events/tool_calls/tool_result) 는 모두 JSON +// string 으로 전달. 이유: 이 RPC 의 입출력이 Pydantic / serde 자유 형식이라 +// proto 의 typed schema 이득이 없음. control-plane RPC (boolean+id) 와 다름. +message RunChatTurnRequest { + string article_id = 1; + // MagazineLayout JSON. ai-server 가 model_validate 로 파싱. + string layout_json = 2; + // [{role, content?, tool_calls?, tool_call_id?, tool_name?, tool_result?}, ...] + string history_json = 3; + string user_message = 4; +} + +message RunChatTurnResponse { + // 호출자가 순서대로 INSERT 해야 하는 메시지들. 각 원소: + // assistant: {role:"assistant", content?, tool_calls?:[{name,args,id}]} + // tool: {role:"tool", tool_call_id, tool_name, tool_result} + string events_json = 1; + // 변경 없으면 빈 string. 있으면 전체 layout JSON. + string final_layout_json = 2; + // 마지막 assistant 텍스트 (UI 즉시 표시용 편의). + string final_text = 3; + int32 tool_calls_made = 4; + bool layout_changed = 5; + // 비어있지 않으면 부분/전체 실패. 호출자가 사용자에게 노출. + string error_message = 6; +} diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py index 9043cbfe..d4b6f55c 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py +++ b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rinbound.proto\x12\x07inbound\"O\n\x1bProcessPostEditorialRequest\x12\x18\n\x10post_magazine_id\x18\x01 \x01(\t\x12\x16\n\x0epost_data_json\x18\x02 \x01(\t\"R\n\x1cProcessPostEditorialResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"X\n\x08\x44\x61taItem\x12\x0f\n\x07item_id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x14\n\x07post_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x42\n\n\x08_post_id\";\n\x17ProcessDataBatchRequest\x12 \n\x05items\x18\x01 \x03(\x0b\x32\x11.inbound.DataItem\"N\n\x18ProcessDataBatchResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"#\n\x14\x45xtractOGDataRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\"\xf0\x01\n\x0cLinkMetadata\x12\x12\n\x05price\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08\x63urrency\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05\x62rand\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x10\n\x08material\x18\x04 \x03(\t\x12\x13\n\x06origin\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x15\n\x08\x63\x61tegory\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x19\n\x0csub_category\x18\x07 \x01(\tH\x05\x88\x01\x01\x42\x08\n\x06_priceB\x0b\n\t_currencyB\x08\n\x06_brandB\t\n\x07_originB\x0b\n\t_categoryB\x0f\n\r_sub_category\"\x92\x01\n\x15\x45xtractOGDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\r\n\x05image\x18\x05 \x01(\t\x12\x11\n\tsite_name\x18\x06 \x01(\t\x12\x15\n\rerror_message\x18\x07 \x01(\t\"i\n\x12\x41nalyzeLinkRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0f\n\x07post_id\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\x11\n\tsite_name\x18\x05 \x01(\t\"I\n\x13\x41nalyzeLinkResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"*\n\x06QAPair\x12\x10\n\x08question\x18\x01 \x01(\t\x12\x0e\n\x06\x61nswer\x18\x02 \x01(\t\"\x8c\x01\n\x0fProductMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\r\n\x05\x62rand\x18\x03 \x01(\t\x12\r\n\x05price\x18\x04 \x01(\t\x12\x10\n\x08\x63urrency\x18\x05 \x01(\t\x12\x11\n\tmaterials\x18\x06 \x03(\t\x12\x0e\n\x06origin\x18\x07 \x01(\t\"\x87\x01\n\x0f\x41rticleMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x16\n\x0epublished_date\x18\x04 \x01(\t\x12\x14\n\x0creading_time\x18\x05 \x01(\t\x12\x0e\n\x06topics\x18\x06 \x03(\t\"\x83\x01\n\rVideoMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x03 \x01(\t\x12\x10\n\x08\x64uration\x18\x04 \x01(\t\x12\x12\n\nview_count\x18\x05 \x01(\t\x12\x13\n\x0bupload_date\x18\x06 \x01(\t\"M\n\rOtherMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\"\xf1\x02\n\x19\x41nalyzeLinkDirectResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0f\n\x07summary\x18\x03 \x01(\t\x12\x10\n\x08keywords\x18\x04 \x03(\t\x12\x1c\n\x03qna\x18\x05 \x03(\x0b\x32\x0f.inbound.QAPair\x12\x34\n\x10product_metadata\x18\x06 \x01(\x0b\x32\x18.inbound.ProductMetadataH\x00\x12\x34\n\x10\x61rticle_metadata\x18\x07 \x01(\x0b\x32\x18.inbound.ArticleMetadataH\x00\x12\x30\n\x0evideo_metadata\x18\x08 \x01(\x0b\x32\x16.inbound.VideoMetadataH\x00\x12\x30\n\x0eother_metadata\x18\t \x01(\x0b\x32\x16.inbound.OtherMetadataH\x00\x12\x15\n\rerror_message\x18\n \x01(\tB\n\n\x08metadata\"i\n\x13\x41nalyzeImageRequest\x12\x12\n\nimage_data\x18\x01 \x01(\t\x12\x0f\n\x07item_id\x18\x02 \x01(\t\x12-\n\x0e\x63\x61tegory_rules\x18\x03 \x03(\x0b\x32\x15.inbound.CategoryRule\"8\n\x0c\x43\x61tegoryRule\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x16\n\x0esub_categories\x18\x02 \x03(\t\"o\n\x13ItemWithCoordinates\x12\x14\n\x0csub_category\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x10\n\x03top\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x11\n\x04left\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x06\n\x04_topB\x07\n\x05_left\"7\n\x08ItemList\x12+\n\x05items\x18\x01 \x03(\x0b\x32\x1c.inbound.ItemWithCoordinates\"\xcc\x02\n\x14\x41nalyzeImageResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07subject\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x18\n\x0b\x61rtist_name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ngroup_name\x18\x05 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07\x63ontext\x18\x06 \x01(\tH\x02\x88\x01\x01\x12\x37\n\x05items\x18\x07 \x03(\x0b\x32(.inbound.AnalyzeImageResponse.ItemsEntry\x12\x15\n\rerror_message\x18\x08 \x01(\t\x1a?\n\nItemsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.inbound.ItemList:\x02\x38\x01\x42\x0e\n\x0c_artist_nameB\r\n\x0b_group_nameB\n\n\x08_context\"?\n\x19\x45xtractPostContextRequest\x12\x0f\n\x07post_id\x18\x01 \x01(\t\x12\x11\n\timage_url\x18\x02 \x01(\t\"\x88\x01\n\x1a\x45xtractPostContextResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07\x63ontext\x18\x02 \x01(\t\x12\x12\n\nstyle_tags\x18\x03 \x03(\t\x12\x0c\n\x04mood\x18\x04 \x01(\t\x12\x0f\n\x07setting\x18\x05 \x01(\t\x12\x15\n\rerror_message\x18\x06 \x01(\t\")\n\x14TriggerSourceRequest\x12\x11\n\tsource_id\x18\x01 \x01(\t\"@\n\x15TriggerSourceResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t\"p\n\x15ReparseRawPostRequest\x12\x13\n\x0braw_post_id\x18\x01 \x01(\t\x12%\n\x18hero_reframe_prompt_hint\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x1b\n\x19_hero_reframe_prompt_hint\"A\n\x16ReparseRawPostResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t2\x84\x06\n\x05Queue\x12W\n\x10ProcessDataBatch\x12 .inbound.ProcessDataBatchRequest\x1a!.inbound.ProcessDataBatchResponse\x12N\n\rExtractOGData\x12\x1d.inbound.ExtractOGDataRequest\x1a\x1e.inbound.ExtractOGDataResponse\x12H\n\x0b\x41nalyzeLink\x12\x1b.inbound.AnalyzeLinkRequest\x1a\x1c.inbound.AnalyzeLinkResponse\x12T\n\x11\x41nalyzeLinkDirect\x12\x1b.inbound.AnalyzeLinkRequest\x1a\".inbound.AnalyzeLinkDirectResponse\x12K\n\x0c\x41nalyzeImage\x12\x1c.inbound.AnalyzeImageRequest\x1a\x1d.inbound.AnalyzeImageResponse\x12\x63\n\x14ProcessPostEditorial\x12$.inbound.ProcessPostEditorialRequest\x1a%.inbound.ProcessPostEditorialResponse\x12]\n\x12\x45xtractPostContext\x12\".inbound.ExtractPostContextRequest\x1a#.inbound.ExtractPostContextResponse\x12N\n\rTriggerSource\x12\x1d.inbound.TriggerSourceRequest\x1a\x1e.inbound.TriggerSourceResponse\x12Q\n\x0eReparseRawPost\x12\x1e.inbound.ReparseRawPostRequest\x1a\x1f.inbound.ReparseRawPostResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rinbound.proto\x12\x07inbound\"O\n\x1bProcessPostEditorialRequest\x12\x18\n\x10post_magazine_id\x18\x01 \x01(\t\x12\x16\n\x0epost_data_json\x18\x02 \x01(\t\"R\n\x1cProcessPostEditorialResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"X\n\x08\x44\x61taItem\x12\x0f\n\x07item_id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x14\n\x07post_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x42\n\n\x08_post_id\";\n\x17ProcessDataBatchRequest\x12 \n\x05items\x18\x01 \x03(\x0b\x32\x11.inbound.DataItem\"N\n\x18ProcessDataBatchResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"#\n\x14\x45xtractOGDataRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\"\xf0\x01\n\x0cLinkMetadata\x12\x12\n\x05price\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08\x63urrency\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05\x62rand\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x10\n\x08material\x18\x04 \x03(\t\x12\x13\n\x06origin\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x15\n\x08\x63\x61tegory\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x19\n\x0csub_category\x18\x07 \x01(\tH\x05\x88\x01\x01\x42\x08\n\x06_priceB\x0b\n\t_currencyB\x08\n\x06_brandB\t\n\x07_originB\x0b\n\t_categoryB\x0f\n\r_sub_category\"\x92\x01\n\x15\x45xtractOGDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\r\n\x05image\x18\x05 \x01(\t\x12\x11\n\tsite_name\x18\x06 \x01(\t\x12\x15\n\rerror_message\x18\x07 \x01(\t\"i\n\x12\x41nalyzeLinkRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0f\n\x07post_id\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\x11\n\tsite_name\x18\x05 \x01(\t\"I\n\x13\x41nalyzeLinkResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"*\n\x06QAPair\x12\x10\n\x08question\x18\x01 \x01(\t\x12\x0e\n\x06\x61nswer\x18\x02 \x01(\t\"\x8c\x01\n\x0fProductMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\r\n\x05\x62rand\x18\x03 \x01(\t\x12\r\n\x05price\x18\x04 \x01(\t\x12\x10\n\x08\x63urrency\x18\x05 \x01(\t\x12\x11\n\tmaterials\x18\x06 \x03(\t\x12\x0e\n\x06origin\x18\x07 \x01(\t\"\x87\x01\n\x0f\x41rticleMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x16\n\x0epublished_date\x18\x04 \x01(\t\x12\x14\n\x0creading_time\x18\x05 \x01(\t\x12\x0e\n\x06topics\x18\x06 \x03(\t\"\x83\x01\n\rVideoMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x03 \x01(\t\x12\x10\n\x08\x64uration\x18\x04 \x01(\t\x12\x12\n\nview_count\x18\x05 \x01(\t\x12\x13\n\x0bupload_date\x18\x06 \x01(\t\"M\n\rOtherMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\"\xf1\x02\n\x19\x41nalyzeLinkDirectResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0f\n\x07summary\x18\x03 \x01(\t\x12\x10\n\x08keywords\x18\x04 \x03(\t\x12\x1c\n\x03qna\x18\x05 \x03(\x0b\x32\x0f.inbound.QAPair\x12\x34\n\x10product_metadata\x18\x06 \x01(\x0b\x32\x18.inbound.ProductMetadataH\x00\x12\x34\n\x10\x61rticle_metadata\x18\x07 \x01(\x0b\x32\x18.inbound.ArticleMetadataH\x00\x12\x30\n\x0evideo_metadata\x18\x08 \x01(\x0b\x32\x16.inbound.VideoMetadataH\x00\x12\x30\n\x0eother_metadata\x18\t \x01(\x0b\x32\x16.inbound.OtherMetadataH\x00\x12\x15\n\rerror_message\x18\n \x01(\tB\n\n\x08metadata\"i\n\x13\x41nalyzeImageRequest\x12\x12\n\nimage_data\x18\x01 \x01(\t\x12\x0f\n\x07item_id\x18\x02 \x01(\t\x12-\n\x0e\x63\x61tegory_rules\x18\x03 \x03(\x0b\x32\x15.inbound.CategoryRule\"8\n\x0c\x43\x61tegoryRule\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x16\n\x0esub_categories\x18\x02 \x03(\t\"o\n\x13ItemWithCoordinates\x12\x14\n\x0csub_category\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x10\n\x03top\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x11\n\x04left\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x06\n\x04_topB\x07\n\x05_left\"7\n\x08ItemList\x12+\n\x05items\x18\x01 \x03(\x0b\x32\x1c.inbound.ItemWithCoordinates\"\xcc\x02\n\x14\x41nalyzeImageResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07subject\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x18\n\x0b\x61rtist_name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ngroup_name\x18\x05 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07\x63ontext\x18\x06 \x01(\tH\x02\x88\x01\x01\x12\x37\n\x05items\x18\x07 \x03(\x0b\x32(.inbound.AnalyzeImageResponse.ItemsEntry\x12\x15\n\rerror_message\x18\x08 \x01(\t\x1a?\n\nItemsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.inbound.ItemList:\x02\x38\x01\x42\x0e\n\x0c_artist_nameB\r\n\x0b_group_nameB\n\n\x08_context\"?\n\x19\x45xtractPostContextRequest\x12\x0f\n\x07post_id\x18\x01 \x01(\t\x12\x11\n\timage_url\x18\x02 \x01(\t\"\x88\x01\n\x1a\x45xtractPostContextResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07\x63ontext\x18\x02 \x01(\t\x12\x12\n\nstyle_tags\x18\x03 \x03(\t\x12\x0c\n\x04mood\x18\x04 \x01(\t\x12\x0f\n\x07setting\x18\x05 \x01(\t\x12\x15\n\rerror_message\x18\x06 \x01(\t\")\n\x14TriggerSourceRequest\x12\x11\n\tsource_id\x18\x01 \x01(\t\"@\n\x15TriggerSourceResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t\"p\n\x15ReparseRawPostRequest\x12\x13\n\x0braw_post_id\x18\x01 \x01(\t\x12%\n\x18hero_reframe_prompt_hint\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x1b\n\x19_hero_reframe_prompt_hint\"A\n\x16ReparseRawPostResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t\"i\n\x12RunChatTurnRequest\x12\x12\n\narticle_id\x18\x01 \x01(\t\x12\x13\n\x0blayout_json\x18\x02 \x01(\t\x12\x14\n\x0chistory_json\x18\x03 \x01(\t\x12\x14\n\x0cuser_message\x18\x04 \x01(\t\"\xa1\x01\n\x13RunChatTurnResponse\x12\x13\n\x0b\x65vents_json\x18\x01 \x01(\t\x12\x19\n\x11\x66inal_layout_json\x18\x02 \x01(\t\x12\x12\n\nfinal_text\x18\x03 \x01(\t\x12\x17\n\x0ftool_calls_made\x18\x04 \x01(\x05\x12\x16\n\x0elayout_changed\x18\x05 \x01(\x08\x12\x15\n\rerror_message\x18\x06 \x01(\t2\xce\x06\n\x05Queue\x12W\n\x10ProcessDataBatch\x12 .inbound.ProcessDataBatchRequest\x1a!.inbound.ProcessDataBatchResponse\x12N\n\rExtractOGData\x12\x1d.inbound.ExtractOGDataRequest\x1a\x1e.inbound.ExtractOGDataResponse\x12H\n\x0b\x41nalyzeLink\x12\x1b.inbound.AnalyzeLinkRequest\x1a\x1c.inbound.AnalyzeLinkResponse\x12T\n\x11\x41nalyzeLinkDirect\x12\x1b.inbound.AnalyzeLinkRequest\x1a\".inbound.AnalyzeLinkDirectResponse\x12K\n\x0c\x41nalyzeImage\x12\x1c.inbound.AnalyzeImageRequest\x1a\x1d.inbound.AnalyzeImageResponse\x12\x63\n\x14ProcessPostEditorial\x12$.inbound.ProcessPostEditorialRequest\x1a%.inbound.ProcessPostEditorialResponse\x12]\n\x12\x45xtractPostContext\x12\".inbound.ExtractPostContextRequest\x1a#.inbound.ExtractPostContextResponse\x12N\n\rTriggerSource\x12\x1d.inbound.TriggerSourceRequest\x1a\x1e.inbound.TriggerSourceResponse\x12Q\n\x0eReparseRawPost\x12\x1e.inbound.ReparseRawPostRequest\x1a\x1f.inbound.ReparseRawPostResponse\x12H\n\x0bRunChatTurn\x12\x1b.inbound.RunChatTurnRequest\x1a\x1c.inbound.RunChatTurnResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -89,6 +89,10 @@ _globals['_REPARSERAWPOSTREQUEST']._serialized_end=3038 _globals['_REPARSERAWPOSTRESPONSE']._serialized_start=3040 _globals['_REPARSERAWPOSTRESPONSE']._serialized_end=3105 - _globals['_QUEUE']._serialized_start=3108 - _globals['_QUEUE']._serialized_end=3880 + _globals['_RUNCHATTURNREQUEST']._serialized_start=3107 + _globals['_RUNCHATTURNREQUEST']._serialized_end=3212 + _globals['_RUNCHATTURNRESPONSE']._serialized_start=3215 + _globals['_RUNCHATTURNRESPONSE']._serialized_end=3376 + _globals['_QUEUE']._serialized_start=3379 + _globals['_QUEUE']._serialized_end=4225 # @@protoc_insertion_point(module_scope) diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi index a6d771a0..07b6f696 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi +++ b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi @@ -326,3 +326,31 @@ class ReparseRawPostResponse(_message.Message): accepted: bool error_message: str def __init__(self, accepted: bool = ..., error_message: _Optional[str] = ...) -> None: ... + +class RunChatTurnRequest(_message.Message): + __slots__ = ("article_id", "layout_json", "history_json", "user_message") + ARTICLE_ID_FIELD_NUMBER: _ClassVar[int] + LAYOUT_JSON_FIELD_NUMBER: _ClassVar[int] + HISTORY_JSON_FIELD_NUMBER: _ClassVar[int] + USER_MESSAGE_FIELD_NUMBER: _ClassVar[int] + article_id: str + layout_json: str + history_json: str + user_message: str + def __init__(self, article_id: _Optional[str] = ..., layout_json: _Optional[str] = ..., history_json: _Optional[str] = ..., user_message: _Optional[str] = ...) -> None: ... + +class RunChatTurnResponse(_message.Message): + __slots__ = ("events_json", "final_layout_json", "final_text", "tool_calls_made", "layout_changed", "error_message") + EVENTS_JSON_FIELD_NUMBER: _ClassVar[int] + FINAL_LAYOUT_JSON_FIELD_NUMBER: _ClassVar[int] + FINAL_TEXT_FIELD_NUMBER: _ClassVar[int] + TOOL_CALLS_MADE_FIELD_NUMBER: _ClassVar[int] + LAYOUT_CHANGED_FIELD_NUMBER: _ClassVar[int] + ERROR_MESSAGE_FIELD_NUMBER: _ClassVar[int] + events_json: str + final_layout_json: str + final_text: str + tool_calls_made: int + layout_changed: bool + error_message: str + def __init__(self, events_json: _Optional[str] = ..., final_layout_json: _Optional[str] = ..., final_text: _Optional[str] = ..., tool_calls_made: _Optional[int] = ..., layout_changed: bool = ..., error_message: _Optional[str] = ...) -> None: ... diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py index 86475dd1..fcfeb168 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py +++ b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py @@ -79,6 +79,11 @@ def __init__(self, channel): request_serializer=inbound__pb2.ReparseRawPostRequest.SerializeToString, response_deserializer=inbound__pb2.ReparseRawPostResponse.FromString, _registered_method=True) + self.RunChatTurn = channel.unary_unary( + '/inbound.Queue/RunChatTurn', + request_serializer=inbound__pb2.RunChatTurnRequest.SerializeToString, + response_deserializer=inbound__pb2.RunChatTurnResponse.FromString, + _registered_method=True) class QueueServicer(object): @@ -148,6 +153,16 @@ def ReparseRawPost(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def RunChatTurn(self, request, context): + """#446 fixup — editorial article chat 의 한 턴 LLM 실행만 ai-server 가 + 담당. session/message CRUD 와 layout persist 는 api-server 가 소유. + 호출자가 article_id, 현재 layout_json, history_json, user_message 를 넘기면 + events_json (assistant/tool 메시지 시퀀스) + final_layout_json 을 반환. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_QueueServicer_to_server(servicer, server): rpc_method_handlers = { @@ -196,6 +211,11 @@ def add_QueueServicer_to_server(servicer, server): request_deserializer=inbound__pb2.ReparseRawPostRequest.FromString, response_serializer=inbound__pb2.ReparseRawPostResponse.SerializeToString, ), + 'RunChatTurn': grpc.unary_unary_rpc_method_handler( + servicer.RunChatTurn, + request_deserializer=inbound__pb2.RunChatTurnRequest.FromString, + response_serializer=inbound__pb2.RunChatTurnResponse.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'inbound.Queue', rpc_method_handlers) @@ -449,3 +469,30 @@ def ReparseRawPost(request, timeout, metadata, _registered_method=True) + + @staticmethod + def RunChatTurn(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/inbound.Queue/RunChatTurn', + inbound__pb2.RunChatTurnRequest.SerializeToString, + inbound__pb2.RunChatTurnResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/packages/ai-server/src/grpc/servicer/metadata_servicer.py b/packages/ai-server/src/grpc/servicer/metadata_servicer.py index 7d386468..eac7fd67 100644 --- a/packages/ai-server/src/grpc/servicer/metadata_servicer.py +++ b/packages/ai-server/src/grpc/servicer/metadata_servicer.py @@ -547,3 +547,69 @@ async def ExtractPostContext( setting="", error_message=str(e), ) + + async def RunChatTurn( + self, + request: inbound_pb2.RunChatTurnRequest, + context, + ) -> inbound_pb2.RunChatTurnResponse: + """Editorial article chat — 한 턴 LLM 실행 (#446 fixup). + + Pure compute: caller (api-server) 가 layout/history/user_message 를 보내고 + events + final_layout 을 받아서 직접 DB persist. ai-server 는 DB 안 만짐. + """ + import json as _json + + from src.editorial_article.models import MagazineLayout + from src.editorial_article_chat.agent import run_turn + + try: + layout = MagazineLayout.model_validate_json(request.layout_json) + history = _json.loads(request.history_json) if request.history_json else [] + if not isinstance(history, list): + raise ValueError("history_json must decode to a list") + article_title = layout.title or "Untitled" + + result = await run_turn( + article_title=article_title, + layout=layout, + history=history, + user_text=request.user_message, + ) + + final_layout_json = "" + if result.layout_changed and result.final_layout is not None: + final_layout_json = result.final_layout.model_dump_json() + + return inbound_pb2.RunChatTurnResponse( + events_json=_json.dumps(result.events, ensure_ascii=False), + final_layout_json=final_layout_json, + final_text=result.final_text, + tool_calls_made=result.tool_calls_made, + layout_changed=result.layout_changed, + error_message=result.error_message, + ) + except ValueError as exc: + self.logger.warning(f"RunChatTurn invalid input: {exc}") + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(exc)) + return inbound_pb2.RunChatTurnResponse( + events_json="[]", + final_layout_json="", + final_text="", + tool_calls_made=0, + layout_changed=False, + error_message=str(exc), + ) + except Exception as exc: + self.logger.error(f"RunChatTurn failed: {exc}", exc_info=True) + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(str(exc)) + return inbound_pb2.RunChatTurnResponse( + events_json="[]", + final_layout_json="", + final_text="", + tool_calls_made=0, + layout_changed=False, + error_message=str(exc), + ) diff --git a/packages/ai-server/src/managers/llm/adapters/openai_image.py b/packages/ai-server/src/managers/llm/adapters/openai_image.py new file mode 100644 index 00000000..27d73744 --- /dev/null +++ b/packages/ai-server/src/managers/llm/adapters/openai_image.py @@ -0,0 +1,132 @@ +"""Async wrapper for OpenAI image gen (gpt-image-1) — magazine thumbnail용 (#429). + +nano-banana 가 한글 text rendering 약해서 thumbnail 은 gpt-image-1 로 전환. +edits / generate 두 모드 지원. 결과는 PNG bytes. + +OpenAI image API 의 size 지원: 1024x1024 / 1024x1536 (2:3 portrait) / 1536x1024 +/ auto. 4:5 는 직접 지원 X — caller 가 1024x1536 로 받아서 PIL crop 처리. +""" + +from __future__ import annotations + +import base64 +import io +import logging +from typing import Optional + +from openai import AsyncOpenAI + + +logger = logging.getLogger(__name__) + + +class OpenAIImageError(RuntimeError): + pass + + +class OpenAIImageClient: + """Thin async wrapper around `gpt-image-1` for magazine thumbnails.""" + + def __init__( + self, + api_key: str, + model: str = "gpt-image-2", + timeout_seconds: int = 300, + ) -> None: + if not api_key: + raise OpenAIImageError("OpenAIImageClient: api_key is empty") + self._model = model + self._timeout = timeout_seconds + self._client = AsyncOpenAI(api_key=api_key, timeout=timeout_seconds) + + async def edit( + self, + *, + image_bytes: bytes, + image_mime_type: str, + prompt: str, + size: str = "1024x1536", + quality: str = "high", + ) -> bytes: + """Edit an existing image with a text prompt. Returns PNG bytes. + + size: '1024x1024' | '1024x1536' | '1536x1024' | 'auto'. + 4:5 thumbnail 은 1024x1536 받아 caller 가 1024x1280 으로 crop. + """ + suffix = "png" if "png" in image_mime_type else "jpg" + image_file = io.BytesIO(image_bytes) + image_file.name = f"input.{suffix}" + + try: + resp = await self._client.images.edit( + model=self._model, + image=image_file, + prompt=prompt, + size=size, + n=1, + quality=quality, + ) + except Exception as exc: + raise OpenAIImageError(f"openai images.edit failed: {exc}") from exc + + return _extract_png_bytes(resp) + + async def generate( + self, + *, + prompt: str, + size: str = "1024x1536", + quality: str = "high", + ) -> bytes: + """Pure text-to-image (no input). Returns PNG bytes.""" + try: + resp = await self._client.images.generate( + model=self._model, + prompt=prompt, + size=size, + n=1, + quality=quality, + ) + except Exception as exc: + raise OpenAIImageError(f"openai images.generate failed: {exc}") from exc + + return _extract_png_bytes(resp) + + @property + def model(self) -> str: + return self._model + + +def _extract_png_bytes(resp) -> bytes: + if not resp.data: + raise OpenAIImageError("openai response has no data") + item = resp.data[0] + b64 = getattr(item, "b64_json", None) + if b64: + return base64.b64decode(b64) + url = getattr(item, "url", None) + if url: + # gpt-image-1 returns b64 by default; url path is fallback + import httpx + + with httpx.Client(timeout=60) as client: + r = client.get(url) + r.raise_for_status() + return r.content + raise OpenAIImageError("openai response item has neither b64_json nor url") + + +def crop_to_4_5(png_bytes: bytes, target_height: Optional[int] = None) -> bytes: + """1024x1536 → 1024x1280 (4:5) center crop. PIL 사용.""" + from PIL import Image + + img = Image.open(io.BytesIO(png_bytes)) + w, h = img.size + target_h = target_height or int(w * 5 / 4) # 4:5 portrait + if h <= target_h: + return png_bytes + top = (h - target_h) // 2 + cropped = img.crop((0, top, w, top + target_h)) + out = io.BytesIO() + cropped.save(out, format="PNG") + return out.getvalue() diff --git a/packages/ai-server/src/managers/queue/worker.py b/packages/ai-server/src/managers/queue/worker.py index 655e13eb..6d7a9535 100644 --- a/packages/ai-server/src/managers/queue/worker.py +++ b/packages/ai-server/src/managers/queue/worker.py @@ -122,6 +122,12 @@ async def create_worker( ) # #397 — pipeline 실패 알림 fire-and-forget (env 비면 no-op). ctx["telegram_notifier"] = infrastructure_container.telegram_notifier() + # #429 — editorial_article 의 generate_thumbnail 노드용 + ctx["nano_banana_client"] = infrastructure_container.nano_banana_client() + ctx["openai_image_client"] = ( + infrastructure_container.openai_image_client() + ) + ctx["r2_client"] = infrastructure_container.r2_client() # Create worker with settings worker = Worker( diff --git a/packages/ai-server/src/services/editorial_article/editorial_article_service.py b/packages/ai-server/src/services/editorial_article/editorial_article_service.py index 9d0145a5..e3702879 100644 --- a/packages/ai-server/src/services/editorial_article/editorial_article_service.py +++ b/packages/ai-server/src/services/editorial_article/editorial_article_service.py @@ -26,11 +26,21 @@ async def editorial_article_job( article_id: str, recommendation_id: str, ) -> Dict[str, Any]: + # #429: assets = editorial staging (recommendations / articles / events / chat), + # operation = posts/spots/solutions read + publish snapshot. 두 pool 모두 주입. + assets_db: DatabaseManager | None = ctx.get("assets_database_manager") operation_db: DatabaseManager | None = ctx.get("operation_database_manager") - - if operation_db is None: - logger.error("editorial_article_job: operation_database_manager missing") - return {"success": False, "error": "operation_db missing"} + nano_banana = ctx.get("nano_banana_client") + openai_image = ctx.get("openai_image_client") + r2 = ctx.get("r2_client") + + if assets_db is None or operation_db is None: + logger.error( + "editorial_article_job: db pools missing (assets=%s operation=%s)", + assets_db, + operation_db, + ) + return {"success": False, "error": "db pools missing"} graph = create_editorial_article_graph() @@ -45,7 +55,11 @@ async def editorial_article_job( config = { "configurable": { + "assets_database_manager": assets_db, "operation_database_manager": operation_db, + "nano_banana_client": nano_banana, + "openai_image_client": openai_image, + "r2_client": r2, } } @@ -53,7 +67,7 @@ async def editorial_article_job( final_state = await graph.ainvoke(initial_state, config=config) except Exception as exc: logger.exception("editorial_article_job: graph crashed") - await _mark_failed(operation_db, article_id, f"graph crash: {exc}") + await _mark_failed(assets_db, article_id, f"graph crash: {exc}") return {"success": False, "error": str(exc)} status = final_state.get("pipeline_status", "") @@ -65,7 +79,7 @@ async def editorial_article_job( status, err, ) - await _mark_failed(operation_db, article_id, err[:500]) + await _mark_failed(assets_db, article_id, err[:500]) return {"success": False, "error": err} return {"success": True, "article_id": article_id} diff --git a/packages/ai-server/src/services/editorial_article/scheduler.py b/packages/ai-server/src/services/editorial_article/scheduler.py index b57cf54a..8834c355 100644 --- a/packages/ai-server/src/services/editorial_article/scheduler.py +++ b/packages/ai-server/src/services/editorial_article/scheduler.py @@ -1,17 +1,18 @@ -"""ArticlePickupScheduler — Stage 2 자동 트리거. +"""ArticlePickupScheduler — Stage 2 enqueue (#429 후 단순화). -api-server 가 approve 클릭에서 추가 enqueue 책임을 지지 않도록, ai-server 에서 -다음 패턴으로 self-pickup 한다: +이전 (PR #440): approve 시점에 recommendation status 만 갱신 → scheduler 가 +INSERT article + ARQ enqueue 모두 담당. UI 가 1분 정도 'silence' 봤음. - 1. 매 분 polling — `editorial_recommendations` 중 `status='approved'` AND - `article_id IS NULL` 인 row 1건 픽 - 2. `editorial_articles` row INSERT (status='generating', source_post_ids 복사) - 3. recommendation.article_id 연결 - 4. ARQ enqueue `editorial_article_job(article_id, recommendation_id)` +현재: api-server 가 approve 트랜잭션 안에서 article INSERT 까지 처리 → UI 즉시 +Drafts 탭에서 'generating' 표시. scheduler 는 enqueue 만 담당. + + 1. 매 분 polling — editorial_articles.status='generating' AND 아직 enqueue + event 없는 row 1건 픽 + 2. ARQ enqueue editorial_article_job(article_id, recommendation_id) + 3. event INSERT (note='enqueued') — 중복 enqueue 방지 운영 약속: - - 동시 진행 1건 (max_instances=1) — 다음 tick 에서 다음 1건 픽 - - lock = `editorial_articles.status='generating'` 존재 여부 + - 동시 진행 1건 (max_instances=1) - 실패 자동 재시도 X (status='failed' 후 admin 수동 재시도) """ @@ -20,7 +21,6 @@ import logging from datetime import datetime, timedelta, timezone from typing import Optional -from uuid import uuid4 from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -74,84 +74,65 @@ async def _cycle(self) -> None: logger.exception("ArticlePickupScheduler: cycle crashed") async def _run_one(self) -> None: - # lock 체크 + 다음 후보 픽 (단일 SQL) + # api-server 가 approve 시점에 article INSERT 까지 처리 (#429 — Drafts + # 즉시 가시성). 이 scheduler 는 enqueue 만 담당: + # editorial_articles.status='generating' AND 아직 enqueue event 없는 + # article 1건 픽 → ARQ enqueue + event 기록. + # ARQ pool 동시성 1 이라 한 번에 하나씩 처리. async with self._db.acquire() as conn: row = await conn.fetchrow( """ - WITH lock_check AS ( - SELECT EXISTS ( - SELECT 1 FROM public.editorial_articles - WHERE status = 'generating' - ) AS is_locked - ) - SELECT (SELECT is_locked FROM lock_check) AS is_locked, - r.id, r.source_post_ids - FROM public.editorial_recommendations r - WHERE r.status = 'approved' - AND r.article_id IS NULL - ORDER BY r.approved_at ASC NULLS LAST, r.created_at ASC + SELECT a.id, a.recommendation_id + FROM public.editorial_articles a + WHERE a.status = 'generating' + AND NOT EXISTS ( + SELECT 1 FROM public.editorial_article_events e + WHERE e.article_id = a.id + AND e.note = 'enqueued' + ) + ORDER BY a.created_at ASC LIMIT 1 """ ) if row is None: return - if row["is_locked"]: - logger.debug("ArticlePickupScheduler: lock held — skip") - return - recommendation_id = str(row["id"]) - source_post_ids = list(row["source_post_ids"] or []) - - article_id = uuid4() - # editorial_articles INSERT + recommendation 연결 (트랜잭션) - async with self._db.acquire() as conn: - async with conn.transaction(): - await conn.execute( - """ - INSERT INTO public.editorial_articles - (id, recommendation_id, source_post_ids, status) - VALUES ($1::uuid, $2::uuid, $3::uuid[], 'generating') - """, - article_id, - recommendation_id, - source_post_ids, - ) - await conn.execute( - """ - UPDATE public.editorial_recommendations - SET article_id = $1::uuid, - updated_at = now() - WHERE id = $2::uuid - """, - article_id, - recommendation_id, - ) - await conn.execute( - """ - INSERT INTO public.editorial_article_events - (article_id, from_status, to_status, step, note) - VALUES ($1::uuid, NULL, 'generating', NULL, 'pickup') - """, - article_id, - ) + article_id = str(row["id"]) + recommendation_id = str(row["recommendation_id"]) if row["recommendation_id"] else "" # ARQ enqueue try: await self._queue.enqueue_job( "editorial_article_job", - str(article_id), + article_id, recommendation_id, _job_id=f"editorial_article_{article_id}", ) except Exception: logger.exception("ArticlePickupScheduler: enqueue failed — marking failed") - await self._mark_failed(str(article_id), "enqueue failed") + await self._mark_failed(article_id, "enqueue failed") return + # event 기록 — 다음 cycle 에서 동일 article 다시 enqueue 안 되도록 + try: + async with self._db.acquire() as conn: + await conn.execute( + """ + INSERT INTO public.editorial_article_events + (article_id, from_status, to_status, step, note) + VALUES ($1::uuid, 'generating', 'generating', 'pickup', 'enqueued') + """, + article_id, + ) + except Exception: + logger.exception( + "ArticlePickupScheduler: enqueue event INSERT failed (job 이미 enqueue 됨, 다음 cycle 중복 enqueue 가능)" + ) + logger.info( - "ArticlePickupScheduler: picked recommendation=%s article=%s", - recommendation_id, + "ArticlePickupScheduler: enqueued article=%s recommendation=%s", article_id, + recommendation_id, ) async def _mark_failed(self, article_id: str, error: str) -> None: diff --git a/packages/ai-server/src/services/editorial_discovery/repository.py b/packages/ai-server/src/services/editorial_discovery/repository.py index 807a1103..c2e44312 100644 --- a/packages/ai-server/src/services/editorial_discovery/repository.py +++ b/packages/ai-server/src/services/editorial_discovery/repository.py @@ -29,7 +29,7 @@ class DiscoverySettings: """`public.editorial_discovery_settings` singleton row.""" enabled: bool - cycle_seconds: int + cycle_minutes: int lookback_days: int max_recommendations_per_run: int min_posts_per_angle: int @@ -44,8 +44,17 @@ class DiscoverySettings: class EditorialDiscoveryRepository: - def __init__(self, database_manager: DatabaseManager) -> None: - self._db = database_manager + """#429 architectural fix: settings/recommendations 는 assets, posts/spots/ + solutions read 는 operation. 두 풀 분리 주입. + """ + + def __init__( + self, + assets_db: DatabaseManager, + operation_db: DatabaseManager, + ) -> None: + self._db = assets_db # backward compat — settings/recommendations 가 assets + self._operation_db = operation_db # ---- Settings -------------------------------------------------------- @@ -54,7 +63,7 @@ async def fetch_settings(self) -> DiscoverySettings: row = await conn.fetchrow( """ SELECT enabled, - cycle_seconds, + cycle_minutes, lookback_days, max_recommendations_per_run, min_posts_per_angle, @@ -72,7 +81,7 @@ async def fetch_settings(self) -> DiscoverySettings: ) return DiscoverySettings( enabled=False, - cycle_seconds=21600, + cycle_minutes=360, lookback_days=7, max_recommendations_per_run=5, min_posts_per_angle=3, @@ -83,7 +92,7 @@ async def fetch_settings(self) -> DiscoverySettings: ) return DiscoverySettings( enabled=row["enabled"], - cycle_seconds=row["cycle_seconds"], + cycle_minutes=row["cycle_minutes"], lookback_days=row["lookback_days"], max_recommendations_per_run=row["max_recommendations_per_run"], min_posts_per_angle=row["min_posts_per_angle"], @@ -137,15 +146,23 @@ async def gather_discovery_input(self, *, lookback_days: int) -> DiscoveryInput: - 이미 다른 recommendation 의 source_post_ids 에 포함된 post 도 제외 (중복 angle 방지) """ + # #429: used_posts 는 assets 의 editorial_recommendations 조회. posts / + # spots / solutions 는 operation. 두 풀 분리 호출. async with self._db.acquire() as conn: + used_rows = await conn.fetch( + """ + SELECT DISTINCT unnest(source_post_ids) AS post_id + FROM public.editorial_recommendations + WHERE status IN ('pending', 'approved', 'drafted') + AND created_at >= now() - ($1::int * interval '1 day') + """, + lookback_days, + ) + used_post_ids = [r["post_id"] for r in used_rows] + + async with self._operation_db.acquire() as conn: post_rows = await conn.fetch( """ - WITH used_posts AS ( - SELECT DISTINCT unnest(source_post_ids) AS post_id - FROM public.editorial_recommendations - WHERE status IN ('pending', 'approved', 'drafted') - AND created_at >= now() - ($1::int * interval '1 day') - ) SELECT p.id, p.artist_name, p.group_name, @@ -159,7 +176,7 @@ async def gather_discovery_input(self, *, lookback_days: int) -> DiscoveryInput: AND p.status = 'active' AND p.image_url IS NOT NULL AND p.post_magazine_id IS NULL - AND p.id NOT IN (SELECT post_id FROM used_posts) + AND ($3::uuid[] = '{}' OR p.id != ALL($3::uuid[])) ORDER BY COALESCE(p.trending_score, 0) DESC, p.view_count DESC, p.created_at DESC @@ -167,6 +184,7 @@ async def gather_discovery_input(self, *, lookback_days: int) -> DiscoveryInput: """, lookback_days, _MAX_POSTS_FOR_MINING, + used_post_ids, ) posts: list[PostSummary] = [] diff --git a/packages/ai-server/src/services/editorial_discovery/scheduler.py b/packages/ai-server/src/services/editorial_discovery/scheduler.py index d74b7b96..7476b7db 100644 --- a/packages/ai-server/src/services/editorial_discovery/scheduler.py +++ b/packages/ai-server/src/services/editorial_discovery/scheduler.py @@ -69,13 +69,13 @@ async def shutdown(self) -> None: @staticmethod def _interval_elapsed( - last_run_at: Optional[datetime], cycle_seconds: int + last_run_at: Optional[datetime], cycle_minutes: int ) -> bool: if last_run_at is None: return True return ( datetime.now(timezone.utc) - last_run_at - ).total_seconds() >= cycle_seconds + ).total_seconds() >= cycle_minutes * 60 async def _cycle(self) -> None: try: @@ -86,7 +86,7 @@ async def _cycle(self) -> None: if not settings.enabled: return - if not self._interval_elapsed(settings.last_run_at, settings.cycle_seconds): + if not self._interval_elapsed(settings.last_run_at, settings.cycle_minutes): return try: diff --git a/packages/ai-server/src/services/post_editorial/repository.py b/packages/ai-server/src/services/post_editorial/repository.py index ce29c977..9fda7342 100644 --- a/packages/ai-server/src/services/post_editorial/repository.py +++ b/packages/ai-server/src/services/post_editorial/repository.py @@ -29,7 +29,7 @@ class EditorialPipelineSettings: """`public.editorial_pipeline_settings` singleton row.""" enabled: bool - cycle_seconds: int + cycle_minutes: int min_spots: int min_solutions_per_spot: int last_run_at: Optional[datetime] @@ -62,7 +62,7 @@ async def fetch_settings(self) -> EditorialPipelineSettings: row = await conn.fetchrow( """ SELECT enabled, - cycle_seconds, + cycle_minutes, min_spots, min_solutions_per_spot, last_run_at, @@ -80,7 +80,7 @@ async def fetch_settings(self) -> EditorialPipelineSettings: ) return EditorialPipelineSettings( enabled=False, - cycle_seconds=60, + cycle_minutes=1, min_spots=4, min_solutions_per_spot=1, last_run_at=None, @@ -90,7 +90,7 @@ async def fetch_settings(self) -> EditorialPipelineSettings: ) return EditorialPipelineSettings( enabled=row["enabled"], - cycle_seconds=row["cycle_seconds"], + cycle_minutes=row["cycle_minutes"], min_spots=row["min_spots"], min_solutions_per_spot=row["min_solutions_per_spot"], last_run_at=row["last_run_at"], diff --git a/packages/ai-server/src/services/post_editorial/scheduler.py b/packages/ai-server/src/services/post_editorial/scheduler.py index 9394763c..1c6172d3 100644 --- a/packages/ai-server/src/services/post_editorial/scheduler.py +++ b/packages/ai-server/src/services/post_editorial/scheduler.py @@ -103,13 +103,13 @@ async def shutdown(self) -> None: @staticmethod def _interval_elapsed( - last_run_at: Optional[datetime], cycle_seconds: int + last_run_at: Optional[datetime], cycle_minutes: int ) -> bool: if last_run_at is None: return True return ( datetime.now(timezone.utc) - last_run_at - ).total_seconds() >= cycle_seconds + ).total_seconds() >= cycle_minutes * 60 async def _cycle(self) -> None: # Sweep 은 settings.enabled / cycle 과 무관하게 매 tick 실행. @@ -127,7 +127,7 @@ async def _cycle(self) -> None: if not settings.enabled: return - if not self._interval_elapsed(settings.last_run_at, settings.cycle_seconds): + if not self._interval_elapsed(settings.last_run_at, settings.cycle_minutes): return try: diff --git a/packages/api-server/proto/ai.proto b/packages/api-server/proto/ai.proto index a6688fed..50cf0355 100644 --- a/packages/api-server/proto/ai.proto +++ b/packages/api-server/proto/ai.proto @@ -27,6 +27,12 @@ service Queue { // /raw-posts/items/{id}/reparse) 를 gRPC 로 통일. rpc TriggerSource (TriggerSourceRequest) returns (TriggerSourceResponse); rpc ReparseRawPost (ReparseRawPostRequest) returns (ReparseRawPostResponse); + + // #446 fixup — editorial article chat 의 한 턴 LLM 실행만 ai-server 가 + // 담당. session/message CRUD 와 layout persist 는 api-server 가 소유. + // 호출자가 article_id, 현재 layout_json, history_json, user_message 를 넘기면 + // events_json (assistant/tool 메시지 시퀀스) + final_layout_json 을 반환. + rpc RunChatTurn (RunChatTurnRequest) returns (RunChatTurnResponse); } // #214 RawPostsWorker service removed — ai-server now schedules itself. @@ -228,3 +234,32 @@ message ReparseRawPostResponse { bool accepted = 1; string error_message = 2; } + +// Editorial article chat — 한 턴 LLM 실행 (#446 fixup). +// +// dynamic 페이로드 (layout/history/events/tool_calls/tool_result) 는 모두 JSON +// string 으로 전달. 이유: 이 RPC 의 입출력이 Pydantic / serde 자유 형식이라 +// proto 의 typed schema 이득이 없음. control-plane RPC (boolean+id) 와 다름. +message RunChatTurnRequest { + string article_id = 1; + // MagazineLayout JSON. ai-server 가 model_validate 로 파싱. + string layout_json = 2; + // [{role, content?, tool_calls?, tool_call_id?, tool_name?, tool_result?}, ...] + string history_json = 3; + string user_message = 4; +} + +message RunChatTurnResponse { + // 호출자가 순서대로 INSERT 해야 하는 메시지들. 각 원소: + // assistant: {role:"assistant", content?, tool_calls?:[{name,args,id}]} + // tool: {role:"tool", tool_call_id, tool_name, tool_result} + string events_json = 1; + // 변경 없으면 빈 string. 있으면 전체 layout JSON. + string final_layout_json = 2; + // 마지막 assistant 텍스트 (UI 즉시 표시용 편의). + string final_text = 3; + int32 tool_calls_made = 4; + bool layout_changed = 5; + // 비어있지 않으면 부분/전체 실패. 호출자가 사용자에게 노출. + string error_message = 6; +} diff --git a/packages/api-server/src/domains/admin/editorial_article_chat.rs b/packages/api-server/src/domains/admin/editorial_article_chat.rs new file mode 100644 index 00000000..97f658e4 --- /dev/null +++ b/packages/api-server/src/domains/admin/editorial_article_chat.rs @@ -0,0 +1,417 @@ +//! Admin — editorial article chat (#446 fixup). +//! +//! Stage 3 매거진 대화형 편집. 이전엔 web 이 ai-server FastAPI 라우터를 직접 +//! 호출했지만 (#446 머지 직후 발견), 모노레포 컨벤션 위반 — ai-server 는 +//! internal compute, web 은 항상 api-server 만 호출. 이 모듈이 새 진입점. +//! +//! 책임 분리: +//! - api-server (이 모듈): chat session/message CRUD, layout persist, admin +//! 인증 게이팅 +//! - ai-server (gRPC `RunChatTurn`): 한 턴 LLM 실행 (Gemini + tool loop) — +//! stateless compute, DB 안 만짐 +//! +//! 라우트: +//! GET /sessions/{article_id} — 세션 list +//! POST /sessions/{article_id} — 새 세션 생성 +//! GET /messages/{session_id} — 메시지 list +//! POST /messages/{session_id} — user 메시지 send + agent 한 턴 실행 + +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::get, + Json, Router, +}; +use sea_orm::{ + ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, AppState}, + error::{AppError, AppResult}, + middleware::auth::User, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// DTOs +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct SessionResponse { + pub id: Uuid, + pub article_id: Uuid, + pub created_by: Option, + pub title: Option, + pub last_message_at: Option, + pub created_at: String, +} + +#[derive(Debug, Serialize)] +pub struct MessageResponse { + pub id: i64, + pub session_id: Uuid, + pub role: String, + pub content: Option, + pub tool_calls: Option, + pub tool_call_id: Option, + pub tool_name: Option, + pub tool_result: Option, + pub created_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct SendMessageRequest { + pub text: String, +} + +#[derive(Debug, Serialize)] +pub struct SendMessageResponse { + pub assistant_text: String, + pub tool_calls_made: i32, + pub layout_changed: bool, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn map_session(row: &sea_orm::QueryResult) -> Result { + Ok(SessionResponse { + id: row.try_get::("", "id").map_err(AppError::DatabaseError)?, + article_id: row + .try_get::("", "article_id") + .map_err(AppError::DatabaseError)?, + created_by: row.try_get::>("", "created_by").ok().flatten(), + title: row.try_get::>("", "title").ok().flatten(), + last_message_at: row + .try_get::>>("", "last_message_at") + .ok() + .flatten() + .map(|t| t.to_rfc3339()), + created_at: row + .try_get::>("", "created_at") + .map_err(AppError::DatabaseError)? + .to_rfc3339(), + }) +} + +fn map_message(row: &sea_orm::QueryResult) -> Result { + Ok(MessageResponse { + id: row.try_get::("", "id").map_err(AppError::DatabaseError)?, + session_id: row + .try_get::("", "session_id") + .map_err(AppError::DatabaseError)?, + role: row + .try_get::("", "role") + .map_err(AppError::DatabaseError)?, + content: row.try_get::>("", "content").ok().flatten(), + tool_calls: row + .try_get::>("", "tool_calls") + .ok() + .flatten(), + tool_call_id: row + .try_get::>("", "tool_call_id") + .ok() + .flatten(), + tool_name: row.try_get::>("", "tool_name").ok().flatten(), + tool_result: row + .try_get::>("", "tool_result") + .ok() + .flatten(), + created_at: row + .try_get::>("", "created_at") + .map_err(AppError::DatabaseError)? + .to_rfc3339(), + }) +} + +async fn load_messages( + db: &DatabaseConnection, + session_id: Uuid, +) -> AppResult> { + let rows = db + .query_all(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, session_id, role, content, tool_calls, tool_call_id, \ + tool_name, tool_result, created_at \ + FROM public.editorial_article_chat_messages \ + WHERE session_id = $1::uuid \ + ORDER BY id ASC", + vec![session_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + rows.iter().map(map_message).collect() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Handlers +// ───────────────────────────────────────────────────────────────────────────── + +/// GET /sessions/{article_id} +pub async fn list_sessions( + State(state): State, + _user: axum::Extension, + Path(article_id): Path, +) -> AppResult>> { + let rows = state + .assets_db + .query_all(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, article_id, created_by, title, last_message_at, created_at \ + FROM public.editorial_article_chat_sessions \ + WHERE article_id = $1::uuid \ + ORDER BY created_at DESC \ + LIMIT 20", + vec![article_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + let items: Result, _> = rows.iter().map(map_session).collect(); + Ok(Json(items?)) +} + +/// POST /sessions/{article_id} +pub async fn create_session( + State(state): State, + user: axum::Extension, + Path(article_id): Path, +) -> AppResult<(StatusCode, Json)> { + let row = state + .assets_db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_chat_sessions \ + (article_id, created_by) \ + VALUES ($1::uuid, $2::uuid) \ + RETURNING id, article_id, created_by, title, last_message_at, created_at", + vec![article_id.into(), user.id.into()], + )) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::InternalError("create_session: no row returned".into()))?; + Ok((StatusCode::CREATED, Json(map_session(&row)?))) +} + +/// GET /messages/{session_id} +pub async fn list_messages( + State(state): State, + _user: axum::Extension, + Path(session_id): Path, +) -> AppResult>> { + Ok(Json(load_messages(state.assets_db.as_ref(), session_id).await?)) +} + +/// POST /messages/{session_id} +/// +/// 흐름 (오케스트레이션): +/// 1. user 메시지 INSERT (즉시, gRPC 호출 전) +/// 2. article (layout, title) 로드 + history 로드 — 1번 INSERT 포함 +/// 3. ai-server gRPC RunChatTurn (deadline 120s) — pure compute +/// 4. 응답의 events_json 을 순서대로 INSERT (assistant + tool 메시지) +/// 5. layout_changed 면 article UPDATE +/// 6. session.last_message_at touch +pub async fn send_message( + State(state): State, + _user: axum::Extension, + Path(session_id): Path, + Json(body): Json, +) -> AppResult> { + let text = body.text.trim(); + if text.is_empty() { + return Err(AppError::ValidationError( + "text 가 비어있습니다.".into(), + )); + } + if text.len() > 4000 { + return Err(AppError::ValidationError( + "text 가 너무 깁니다 (max 4000).".into(), + )); + } + + let db = state.assets_db.as_ref(); + + // Resolve article_id from session + let session_row = db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT article_id FROM public.editorial_article_chat_sessions \ + WHERE id = $1::uuid", + vec![session_id.into()], + )) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::NotFound(format!("session {session_id} 없음")))?; + let article_id: Uuid = session_row + .try_get::("", "article_id") + .map_err(AppError::DatabaseError)?; + + // Load article layout + title + let article_row = db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT title, layout_json \ + FROM public.editorial_articles \ + WHERE id = $1::uuid", + vec![article_id.into()], + )) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::NotFound(format!("article {article_id} 없음")))?; + let layout_value: Option = article_row + .try_get::>("", "layout_json") + .ok() + .flatten(); + let layout_json = layout_value + .ok_or_else(|| AppError::ValidationError("article 의 layout_json 이 없음".into()))? + .to_string(); + + // INSERT user message (immediate) + db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_chat_messages (session_id, role, content) \ + VALUES ($1::uuid, 'user', $2)", + vec![session_id.into(), text.to_string().into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + // Load full history (includes the user msg we just inserted) + let history = load_messages(db, session_id).await?; + let history_json = serde_json::to_string(&history) + .map_err(|e| AppError::InternalError(format!("history serialize failed: {e}")))?; + + // gRPC call to ai-server + let resp = state + .decoded_ai_client + .run_chat_turn( + article_id.to_string(), + layout_json, + history_json, + text.to_string(), + ) + .await + .map_err(|e| AppError::ExternalService(format!("ai-server: {e}")))?; + + // Iterate events_json and INSERT + let events: Vec = serde_json::from_str(&resp.events_json) + .map_err(|e| AppError::InternalError(format!("events_json parse failed: {e}")))?; + for evt in &events { + let role = evt.get("role").and_then(|v| v.as_str()).unwrap_or(""); + match role { + "assistant" => { + let content = evt.get("content").and_then(|v| v.as_str()); + let tool_calls = evt.get("tool_calls").cloned(); + db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_chat_messages \ + (session_id, role, content, tool_calls) \ + VALUES ($1::uuid, 'assistant', $2, $3::jsonb)", + vec![ + session_id.into(), + content.map(|s| s.to_string()).into(), + tool_calls.into(), + ], + )) + .await + .map_err(AppError::DatabaseError)?; + } + "tool" => { + let tool_call_id = evt.get("tool_call_id").and_then(|v| v.as_str()); + let tool_name = evt.get("tool_name").and_then(|v| v.as_str()); + let tool_result = evt.get("tool_result").cloned(); + db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_chat_messages \ + (session_id, role, tool_call_id, tool_name, tool_result) \ + VALUES ($1::uuid, 'tool', $2, $3, $4::jsonb)", + vec![ + session_id.into(), + tool_call_id.map(|s| s.to_string()).into(), + tool_name.map(|s| s.to_string()).into(), + tool_result.into(), + ], + )) + .await + .map_err(AppError::DatabaseError)?; + } + other => { + tracing::warn!("RunChatTurn event with unknown role={other:?}, skipping"); + } + } + } + + // UPDATE article layout if changed + if resp.layout_changed && !resp.final_layout_json.is_empty() { + let layout_value: serde_json::Value = + serde_json::from_str(&resp.final_layout_json).map_err(|e| { + AppError::InternalError(format!("final_layout_json parse failed: {e}")) + })?; + let title = layout_value + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled") + .to_string(); + let subtitle = layout_value + .get("subtitle") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let hero = layout_value + .get("hero_image_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_articles \ + SET title = $2, \ + subtitle = $3, \ + hero_image_url = $4, \ + layout_json = $5::jsonb, \ + updated_at = now() \ + WHERE id = $1::uuid", + vec![ + article_id.into(), + title.into(), + subtitle.into(), + hero.into(), + layout_value.into(), + ], + )) + .await + .map_err(AppError::DatabaseError)?; + } + + // Touch session.last_message_at + db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_article_chat_sessions \ + SET last_message_at = now(), updated_at = now() \ + WHERE id = $1::uuid", + vec![session_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(Json(SendMessageResponse { + assistant_text: resp.final_text, + tool_calls_made: resp.tool_calls_made, + layout_changed: resp.layout_changed, + })) +} + +pub fn router(state: AppState, app_config: AppConfig) -> Router { + Router::new() + .route("/sessions/{article_id}", get(list_sessions).post(create_session)) + .route("/messages/{session_id}", get(list_messages).post(send_message)) + .layer(axum::middleware::from_fn_with_state( + state, + crate::middleware::admin_db_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + app_config, + crate::middleware::auth_middleware, + )) +} diff --git a/packages/api-server/src/domains/admin/editorial_articles.rs b/packages/api-server/src/domains/admin/editorial_articles.rs index 88d3f880..4e1d5d68 100644 --- a/packages/api-server/src/domains/admin/editorial_articles.rs +++ b/packages/api-server/src/domains/admin/editorial_articles.rs @@ -42,6 +42,7 @@ pub struct ArticleListItem { pub title: String, pub subtitle: Option, pub hero_image_url: Option, + pub thumbnail_url: Option, pub source_post_ids: Vec, pub status: String, pub published_at: Option, @@ -64,6 +65,7 @@ pub struct ArticleDetailResponse { pub title: String, pub subtitle: Option, pub hero_image_url: Option, + pub thumbnail_url: Option, pub layout_json: Option, pub source_post_ids: Vec, pub status: String, @@ -125,7 +127,7 @@ pub async fn list( let count_sql = format!("SELECT COUNT(*)::bigint AS c FROM public.editorial_articles {where_sql}"); let total: i64 = state - .db + .assets_db .query_one(Statement::from_sql_and_values( DatabaseBackend::Postgres, &count_sql, @@ -142,13 +144,13 @@ pub async fn list( "$1 OFFSET $2" }; let list_sql = format!( - "SELECT id, recommendation_id, title, subtitle, hero_image_url, source_post_ids, \ - status, published_at, created_at, updated_at \ + "SELECT id, recommendation_id, title, subtitle, hero_image_url, thumbnail_url, \ + source_post_ids, status, published_at, created_at, updated_at \ FROM public.editorial_articles {where_sql} \ ORDER BY created_at DESC LIMIT {limit_offset}" ); let rows = state - .db + .assets_db .query_all(Statement::from_sql_and_values( DatabaseBackend::Postgres, &list_sql, @@ -164,6 +166,7 @@ pub async fn list( let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; let subtitle: Option = row.try_get("", "subtitle").ok(); let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); let source_post_ids: Vec = row .try_get("", "source_post_ids") .map_err(AppError::DatabaseError)?; @@ -183,6 +186,7 @@ pub async fn list( title, subtitle, hero_image_url, + thumbnail_url, source_post_ids, status, published_at: published_at.map(|t| t.to_rfc3339()), @@ -207,15 +211,15 @@ pub async fn get_one( ) -> AppResult> { let stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - "SELECT id, recommendation_id, title, subtitle, hero_image_url, layout_json, \ - source_post_ids, status, review_summary, error_log, rejection_reason, \ - approved_by, published_at, created_at, updated_at \ + "SELECT id, recommendation_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, status, review_summary, error_log, \ + rejection_reason, approved_by, published_at, created_at, updated_at \ FROM public.editorial_articles \ WHERE id = $1::uuid", vec![id.into()], ); let row = state - .db + .assets_db .query_one(stmt) .await .map_err(AppError::DatabaseError)? @@ -225,6 +229,7 @@ pub async fn get_one( let recommendation_id: Option = row.try_get("", "recommendation_id").ok(); let subtitle: Option = row.try_get("", "subtitle").ok(); let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); let layout_json: Option = row.try_get("", "layout_json").ok(); let source_post_ids: Vec = row .try_get("", "source_post_ids") @@ -251,6 +256,7 @@ pub async fn get_one( title, subtitle, hero_image_url, + thumbnail_url, layout_json, source_post_ids, status, @@ -280,10 +286,132 @@ pub async fn patch( )); } - apply_patch(state.db.as_ref(), id, target, &body, user.id).await?; + if target == "published" { + // #429: publish 는 assets staging → operation snapshot 복사 + assets status update. + publish_to_operation( + state.assets_db.as_ref(), + state.db.as_ref(), + id, + &body, + user.id, + ) + .await?; + return Ok(StatusCode::NO_CONTENT); + } + + apply_patch(state.assets_db.as_ref(), id, target, &body, user.id).await?; Ok(StatusCode::NO_CONTENT) } +/// publish — assets editorial_articles 의 article 을 operation editorial_articles +/// snapshot 으로 INSERT + assets status='published' UPDATE. +/// +/// 두 DB 라 분산 트랜잭션 X. 순서: +/// 1. assets 에서 article 로드 (status check 포함) +/// 2. operation 에 INSERT (실패 시 abort, assets 변경 없음) +/// 3. assets status='published' UPDATE (best-effort — 실패 시 operation 에는 이미 들어감, +/// log 로 inconsistency 추적) +async fn publish_to_operation( + assets_db: &DatabaseConnection, + operation_db: &DatabaseConnection, + id: Uuid, + body: &PatchArticleRequest, + actor: Uuid, +) -> AppResult<()> { + // 1) assets 에서 article 로드 + let load_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT title, subtitle, hero_image_url, thumbnail_url, layout_json, \ + source_post_ids, status \ + FROM public.editorial_articles WHERE id = $1::uuid", + vec![id.into()], + ); + let row = assets_db + .query_one(load_stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::not_found(format!("editorial_article {id} not found")))?; + + let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; + let subtitle: Option = row.try_get("", "subtitle").ok(); + let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); + let layout_json: serde_json::Value = row + .try_get("", "layout_json") + .unwrap_or(serde_json::Value::Null); + let source_post_ids: Vec = row + .try_get("", "source_post_ids") + .map_err(AppError::DatabaseError)?; + let status: String = row.try_get("", "status").map_err(AppError::DatabaseError)?; + + if status == "generating" || status == "failed" { + return Err(AppError::bad_request(format!( + "article status='{status}' — cannot publish (must be draft / in_review / rejected)", + ))); + } + + // 2) operation INSERT (id = assets id 그대로, source_article_id 도 동일 — audit) + let insert_op = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_articles \ + (id, source_article_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, slug, published_at, published_by) \ + VALUES ($1::uuid, $1::uuid, $2, $3, $4, $5, $6::jsonb, $7::uuid[], NULL, now(), $8::uuid) \ + ON CONFLICT (id) DO UPDATE \ + SET title = EXCLUDED.title, \ + subtitle = EXCLUDED.subtitle, \ + hero_image_url = EXCLUDED.hero_image_url, \ + thumbnail_url = EXCLUDED.thumbnail_url, \ + layout_json = EXCLUDED.layout_json, \ + source_post_ids = EXCLUDED.source_post_ids, \ + published_at = EXCLUDED.published_at, \ + published_by = EXCLUDED.published_by, \ + updated_at = now()", + vec![ + id.into(), + title.into(), + subtitle.into(), + hero_image_url.into(), + thumbnail_url.into(), + layout_json.into(), + source_post_ids.into(), + actor.into(), + ], + ); + operation_db + .execute(insert_op) + .await + .map_err(AppError::DatabaseError)?; + + // 3) assets status='published' UPDATE (best-effort) + let upd_assets = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_articles \ + SET status = 'published', \ + approved_by = $2::uuid, \ + published_at = now(), \ + review_summary = COALESCE($3, review_summary), \ + updated_at = now() \ + WHERE id = $1::uuid", + vec![id.into(), actor.into(), body.review_summary.clone().into()], + ); + assets_db + .execute(upd_assets) + .await + .map_err(AppError::DatabaseError)?; + + // 4) event 기록 (assets) + let ev_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_events \ + (article_id, from_status, to_status, step, actor, note) \ + VALUES ($1::uuid, $2, 'published', 'publish', $3::uuid, 'snapshot to operation')", + vec![id.into(), status.into(), actor.into()], + ); + let _ = assets_db.execute(ev_stmt).await; + Ok(()) +} + async fn apply_patch( db: &DatabaseConnection, id: Uuid, @@ -291,17 +419,8 @@ async fn apply_patch( body: &PatchArticleRequest, actor: Uuid, ) -> AppResult<()> { + // 'published' 는 publish_to_operation 에서 처리 — 여기 안 옴. let (sql, values): (&str, Vec) = match target { - "published" => ( - "UPDATE public.editorial_articles \ - SET status = 'published', \ - approved_by = $2::uuid, \ - published_at = now(), \ - review_summary = COALESCE($3, review_summary), \ - updated_at = now() \ - WHERE id = $1::uuid", - vec![id.into(), actor.into(), body.review_summary.clone().into()], - ), "rejected" => ( "UPDATE public.editorial_articles \ SET status = 'rejected', \ @@ -359,7 +478,7 @@ pub async fn list_events( vec![id.into()], ); let rows = state - .db + .assets_db .query_all(stmt) .await .map_err(AppError::DatabaseError)?; @@ -396,10 +515,36 @@ pub async fn list_events( Ok(Json(events)) } +/// DELETE /api/v1/admin/editorial-articles/{id} — hard delete. +/// editorial_article_events / chat_sessions 는 FK CASCADE 로 같이 삭제, +/// recommendation 의 article_id 는 FK SET NULL. +pub async fn delete_one( + State(state): State, + _user: axum::Extension, + Path(id): Path, +) -> AppResult { + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "DELETE FROM public.editorial_articles WHERE id = $1::uuid", + vec![id.into()], + ); + let res = state + .assets_db + .execute(stmt) + .await + .map_err(AppError::DatabaseError)?; + if res.rows_affected() == 0 { + return Err(AppError::not_found(format!( + "editorial_article {id} not found" + ))); + } + Ok(StatusCode::NO_CONTENT) +} + pub fn router(state: AppState, app_config: AppConfig) -> Router { Router::new() .route("/", get(list)) - .route("/{id}", get(get_one).patch(patch)) + .route("/{id}", get(get_one).patch(patch).delete(delete_one)) .route("/{id}/events", get(list_events)) .layer(axum::middleware::from_fn_with_state( state, diff --git a/packages/api-server/src/domains/admin/editorial_discovery_settings.rs b/packages/api-server/src/domains/admin/editorial_discovery_settings.rs index 995dec29..308c77d2 100644 --- a/packages/api-server/src/domains/admin/editorial_discovery_settings.rs +++ b/packages/api-server/src/domains/admin/editorial_discovery_settings.rs @@ -17,7 +17,7 @@ use crate::{ #[derive(Debug, Serialize)] pub struct EditorialDiscoverySettingsResponse { pub enabled: bool, - pub cycle_seconds: i32, + pub cycle_minutes: i32, pub lookback_days: i32, pub max_recommendations_per_run: i32, pub min_posts_per_angle: i32, @@ -30,7 +30,7 @@ pub struct EditorialDiscoverySettingsResponse { #[derive(Debug, Deserialize)] pub struct UpdateEditorialDiscoverySettingsRequest { pub enabled: Option, - pub cycle_seconds: Option, + pub cycle_minutes: Option, pub lookback_days: Option, pub max_recommendations_per_run: Option, pub min_posts_per_angle: Option, @@ -41,7 +41,7 @@ pub async fn get_settings( State(state): State, _user: axum::Extension, ) -> AppResult> { - let row = fetch_settings(state.db.as_ref()).await?; + let row = fetch_settings(state.assets_db.as_ref()).await?; Ok(Json(row)) } @@ -51,9 +51,9 @@ pub async fn update_settings( _user: axum::Extension, Json(body): Json, ) -> AppResult { - if let Some(c) = body.cycle_seconds { - if c < 1800 { - return Err(AppError::bad_request("cycle_seconds must be >= 1800")); + if let Some(c) = body.cycle_minutes { + if c < 1 { + return Err(AppError::bad_request("cycle_minutes must be >= 1")); } } if let Some(d) = body.lookback_days { @@ -74,14 +74,14 @@ pub async fn update_settings( } } - apply_update(state.db.as_ref(), &body).await?; + apply_update(state.assets_db.as_ref(), &body).await?; Ok(StatusCode::NO_CONTENT) } async fn fetch_settings(db: &DatabaseConnection) -> AppResult { let stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - "SELECT enabled, cycle_seconds, lookback_days, max_recommendations_per_run, \ + "SELECT enabled, cycle_minutes, lookback_days, max_recommendations_per_run, \ min_posts_per_angle, last_run_at, last_success_at, last_error, last_error_at \ FROM public.editorial_discovery_settings \ WHERE id = 1", @@ -96,8 +96,8 @@ async fn fetch_settings(db: &DatabaseConnection) -> AppResult AppResult, @@ -30,7 +30,7 @@ pub struct EditorialPipelineSettingsResponse { #[derive(Debug, Deserialize)] pub struct UpdateEditorialPipelineSettingsRequest { pub enabled: Option, - pub cycle_seconds: Option, + pub cycle_minutes: Option, pub min_spots: Option, pub min_solutions_per_spot: Option, } @@ -50,9 +50,9 @@ pub async fn update_settings( _user: axum::Extension, Json(body): Json, ) -> AppResult { - if let Some(c) = body.cycle_seconds { - if c < 30 { - return Err(AppError::bad_request("cycle_seconds must be >= 30")); + if let Some(c) = body.cycle_minutes { + if c < 1 { + return Err(AppError::bad_request("cycle_minutes must be >= 1")); } } if let Some(s) = body.min_spots { @@ -73,7 +73,7 @@ pub async fn update_settings( async fn fetch_settings(db: &DatabaseConnection) -> AppResult { let stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - "SELECT enabled, cycle_seconds, min_spots, min_solutions_per_spot, \ + "SELECT enabled, cycle_minutes, min_spots, min_solutions_per_spot, \ last_run_at, last_success_at, last_error, last_error_at \ FROM public.editorial_pipeline_settings \ WHERE id = 1", @@ -88,8 +88,8 @@ async fn fetch_settings(db: &DatabaseConnection) -> AppResult AppResult AppResult<()> { + // approved 는 article INSERT 까지 같은 트랜잭션 — UI 가 즉시 Drafts 탭에서 + // 'generating' 상태를 볼 수 있게. ArticlePickupScheduler 는 enqueue 만 담당. + if target_status == "approved" { + return approve_in_tx(db, id, actor).await; + } + let sql = match target_status { - "approved" => { - "UPDATE public.editorial_recommendations \ - SET status = 'approved', \ - approved_by = $2::uuid, \ - approved_at = now(), \ - updated_at = now() \ - WHERE id = $1::uuid \ - AND status IN ('pending')" - } "rejected" => { "UPDATE public.editorial_recommendations \ SET status = 'rejected', \ @@ -235,7 +232,6 @@ async fn apply_patch( }; let values: Vec = match target_status { - "approved" => vec![id.into(), actor.into()], "rejected" => vec![ id.into(), actor.into(), @@ -249,10 +245,113 @@ async fn apply_patch( Ok(()) } +/// approve + INSERT editorial_articles + 연결 + event — 단일 트랜잭션. +async fn approve_in_tx(db: &DatabaseConnection, id: Uuid, actor: Uuid) -> AppResult<()> { + let txn = db.begin().await.map_err(AppError::DatabaseError)?; + + // 1) recommendation 업데이트 (status pending → approved). status 가 pending + // 아니면 0 row affected — 멱등. + let upd_rec = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_recommendations \ + SET status = 'approved', \ + approved_by = $2::uuid, \ + approved_at = now(), \ + updated_at = now() \ + WHERE id = $1::uuid \ + AND status = 'pending' \ + RETURNING source_post_ids, angle_title", + vec![id.into(), actor.into()], + ); + let row = txn + .query_one(upd_rec) + .await + .map_err(AppError::DatabaseError)?; + let Some(row) = row else { + // 이미 approved/rejected/drafted — no-op (멱등) + txn.commit().await.map_err(AppError::DatabaseError)?; + return Ok(()); + }; + let source_post_ids: Vec = row + .try_get("", "source_post_ids") + .map_err(AppError::DatabaseError)?; + let angle_title: String = row + .try_get("", "angle_title") + .map_err(AppError::DatabaseError)?; + + // 2) editorial_articles INSERT (status='generating', source_post_ids 복사, + // title 임시 = angle_title — 나중에 compose_layout 이 덮어씀) + let ins_art = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_articles \ + (recommendation_id, title, source_post_ids, status) \ + VALUES ($1::uuid, $2, $3::uuid[], 'generating') \ + RETURNING id", + vec![id.into(), angle_title.into(), source_post_ids.into()], + ); + let art_row = txn + .query_one(ins_art) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("editorial_articles INSERT returned no row"))?; + let article_id: Uuid = art_row.try_get("", "id").map_err(AppError::DatabaseError)?; + + // 3) recommendation 의 article_id 연결 + let link_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_recommendations \ + SET article_id = $1::uuid, updated_at = now() \ + WHERE id = $2::uuid", + vec![article_id.into(), id.into()], + ); + txn.execute(link_stmt) + .await + .map_err(AppError::DatabaseError)?; + + // 4) event 기록 (admin approve → article 생성) + let ev_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_events \ + (article_id, from_status, to_status, step, actor, note) \ + VALUES ($1::uuid, NULL, 'generating', 'created', $2::uuid, 'admin approved')", + vec![article_id.into(), actor.into()], + ); + txn.execute(ev_stmt) + .await + .map_err(AppError::DatabaseError)?; + + txn.commit().await.map_err(AppError::DatabaseError)?; + Ok(()) +} + +/// DELETE /api/v1/admin/editorial-recommendations/{id} — hard delete (테스트 garbage / 잘못 만든 row 정리용). +pub async fn delete_one( + State(state): State, + _user: axum::Extension, + Path(id): Path, +) -> AppResult { + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "DELETE FROM public.editorial_recommendations WHERE id = $1::uuid", + vec![id.into()], + ); + let res = state + .assets_db + .execute(stmt) + .await + .map_err(AppError::DatabaseError)?; + if res.rows_affected() == 0 { + return Err(AppError::not_found(format!( + "editorial_recommendation {id} not found" + ))); + } + Ok(StatusCode::NO_CONTENT) +} + pub fn router(state: AppState, app_config: AppConfig) -> Router { Router::new() .route("/", get(list)) - .route("/{id}", axum::routing::patch(patch)) + .route("/{id}", axum::routing::patch(patch).delete(delete_one)) .layer(axum::middleware::from_fn_with_state( state, crate::middleware::admin_db_middleware, diff --git a/packages/api-server/src/domains/admin/handlers.rs b/packages/api-server/src/domains/admin/handlers.rs index d2239817..5b1b5c51 100644 --- a/packages/api-server/src/domains/admin/handlers.rs +++ b/packages/api-server/src/domains/admin/handlers.rs @@ -7,9 +7,9 @@ use axum::Router; use crate::{app_state::AppState, config::AppConfig}; use super::{ - badges, categories, curations, dashboard, editorial_articles, editorial_candidates, - editorial_discovery_settings, editorial_pipeline_settings, editorial_recommendations, - magazine_sessions, monitoring, posts, solutions, spots, synonyms, + badges, categories, curations, dashboard, editorial_article_chat, editorial_articles, + editorial_candidates, editorial_discovery_settings, editorial_pipeline_settings, + editorial_recommendations, magazine_sessions, monitoring, posts, solutions, spots, synonyms, }; use crate::domains::reports; @@ -32,6 +32,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { "/editorial-articles", editorial_articles::router(state.clone(), app_config.clone()), ) + .nest( + "/editorial-article-chat", + editorial_article_chat::router(state.clone(), app_config.clone()), + ) .nest( "/editorial-discovery/settings", editorial_discovery_settings::router(state.clone(), app_config.clone()), diff --git a/packages/api-server/src/domains/admin/mod.rs b/packages/api-server/src/domains/admin/mod.rs index f404ece0..f79112df 100644 --- a/packages/api-server/src/domains/admin/mod.rs +++ b/packages/api-server/src/domains/admin/mod.rs @@ -7,6 +7,7 @@ pub mod badges; pub mod categories; pub mod curations; pub mod dashboard; +pub mod editorial_article_chat; pub mod editorial_articles; pub mod editorial_candidates; pub mod editorial_discovery_settings; diff --git a/packages/api-server/src/domains/editorial_articles_published/handlers.rs b/packages/api-server/src/domains/editorial_articles_published/handlers.rs new file mode 100644 index 00000000..2949dde3 --- /dev/null +++ b/packages/api-server/src/domains/editorial_articles_published/handlers.rs @@ -0,0 +1,203 @@ +//! Public editorial-articles handlers — operation editorial_articles read. +//! +//! GET /api/v1/editorial-articles?page&per_page — 발행 매거진 목록 (최신순) +//! GET /api/v1/editorial-articles/{id_or_slug} — 매거진 상세 (layout_json 포함) +//! +//! 인증 없음 (공개). RLS 의 'public_can_select_editorial_articles' 가 보장. + +use axum::{ + extract::{Path, Query, State}, + routing::get, + Json, Router, +}; +use sea_orm::{ConnectionTrait, DatabaseBackend, Statement}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::AppState, + error::{AppError, AppResult}, +}; + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + #[serde(default = "default_page")] + pub page: u64, + #[serde(default = "default_per_page")] + pub per_page: u64, +} + +fn default_page() -> u64 { + 1 +} +fn default_per_page() -> u64 { + 20 +} + +#[derive(Debug, Serialize)] +pub struct PublishedArticleListItem { + pub id: Uuid, + pub title: String, + pub subtitle: Option, + pub hero_image_url: Option, + pub thumbnail_url: Option, + pub slug: Option, + pub published_at: String, +} + +#[derive(Debug, Serialize)] +pub struct ListResponse { + pub items: Vec, + pub total: i64, + pub page: u64, + pub per_page: u64, +} + +#[derive(Debug, Serialize)] +pub struct PublishedArticleDetail { + pub id: Uuid, + pub source_article_id: Uuid, + pub title: String, + pub subtitle: Option, + pub hero_image_url: Option, + pub thumbnail_url: Option, + pub layout_json: serde_json::Value, + pub source_post_ids: Vec, + pub slug: Option, + pub published_at: String, +} + +pub async fn list( + State(state): State, + Query(q): Query, +) -> AppResult> { + let page = q.page.max(1); + let per_page = q.per_page.clamp(1, 100); + let offset = (page - 1) * per_page; + + let total: i64 = state + .db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*)::bigint AS c FROM public.editorial_articles", + Vec::::new(), + )) + .await + .map_err(AppError::DatabaseError)? + .map(|r| r.try_get::("", "c").unwrap_or(0)) + .unwrap_or(0); + + let rows = state + .db + .query_all(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, title, subtitle, hero_image_url, thumbnail_url, slug, published_at \ + FROM public.editorial_articles \ + ORDER BY published_at DESC \ + LIMIT $1 OFFSET $2", + vec![(per_page as i64).into(), (offset as i64).into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?; + let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; + let subtitle: Option = row.try_get("", "subtitle").ok(); + let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); + let slug: Option = row.try_get("", "slug").ok(); + let published_at: chrono::DateTime = + row.try_get("", "published_at") + .map_err(AppError::DatabaseError)?; + + items.push(PublishedArticleListItem { + id, + title, + subtitle, + hero_image_url, + thumbnail_url, + slug, + published_at: published_at.to_rfc3339(), + }); + } + + Ok(Json(ListResponse { + items, + total, + page, + per_page, + })) +} + +pub async fn get_one( + State(state): State, + Path(id_or_slug): Path, +) -> AppResult> { + // UUID 파싱 시도 → 실패면 slug 로 조회 + let row = if let Ok(uuid) = Uuid::parse_str(&id_or_slug) { + state + .db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, source_article_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, slug, published_at \ + FROM public.editorial_articles WHERE id = $1::uuid", + vec![uuid.into()], + )) + .await + .map_err(AppError::DatabaseError)? + } else { + state + .db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, source_article_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, slug, published_at \ + FROM public.editorial_articles WHERE slug = $1", + vec![id_or_slug.clone().into()], + )) + .await + .map_err(AppError::DatabaseError)? + } + .ok_or_else(|| AppError::not_found(format!("editorial_article '{id_or_slug}' not found")))?; + + let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?; + let source_article_id: Uuid = row + .try_get("", "source_article_id") + .map_err(AppError::DatabaseError)?; + let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; + let subtitle: Option = row.try_get("", "subtitle").ok(); + let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); + let layout_json: serde_json::Value = row + .try_get("", "layout_json") + .unwrap_or(serde_json::Value::Null); + let source_post_ids: Vec = row + .try_get("", "source_post_ids") + .map_err(AppError::DatabaseError)?; + let slug: Option = row.try_get("", "slug").ok(); + let published_at: chrono::DateTime = row + .try_get("", "published_at") + .map_err(AppError::DatabaseError)?; + + Ok(Json(PublishedArticleDetail { + id, + source_article_id, + title, + subtitle, + hero_image_url, + thumbnail_url, + layout_json, + source_post_ids, + slug, + published_at: published_at.to_rfc3339(), + })) +} + +pub fn router() -> Router { + Router::new() + .route("/", get(list)) + .route("/{id_or_slug}", get(get_one)) +} diff --git a/packages/api-server/src/domains/editorial_articles_published/mod.rs b/packages/api-server/src/domains/editorial_articles_published/mod.rs new file mode 100644 index 00000000..ca7a0432 --- /dev/null +++ b/packages/api-server/src/domains/editorial_articles_published/mod.rs @@ -0,0 +1,8 @@ +//! Public read endpoints for published editorial magazines (#429). +//! +//! operation 의 editorial_articles 테이블 — assets staging 에서 publish 시 +//! snapshot 복사된 운영 매거진. 공개 reader (web magazine page) 가 호출. + +pub mod handlers; + +pub use handlers::router; diff --git a/packages/api-server/src/domains/mod.rs b/packages/api-server/src/domains/mod.rs index 00bc38fb..5bed8955 100644 --- a/packages/api-server/src/domains/mod.rs +++ b/packages/api-server/src/domains/mod.rs @@ -7,6 +7,7 @@ pub mod badges; pub mod categories; pub mod comments; pub mod earnings; +pub mod editorial_articles_published; pub mod events; pub mod feed; pub mod post_likes; diff --git a/packages/api-server/src/entities/assets_editorial_article_chat_messages.rs b/packages/api-server/src/entities/assets_editorial_article_chat_messages.rs new file mode 100644 index 00000000..c189a97f --- /dev/null +++ b/packages/api-server/src/entities/assets_editorial_article_chat_messages.rs @@ -0,0 +1,46 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// assets.public.editorial_article_chat_messages (#446 fixup). +/// +/// 한 chat session 의 메시지 시퀀스. role 은 user/assistant/tool/system. +/// tool_calls / tool_result 는 자유 형식 JSON (Gemini function calling 페이로드). +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "editorial_article_chat_messages")] +pub struct Model { + /// bigserial — DB 가 자동 생성. + #[sea_orm(primary_key)] + pub id: i64, + + /// editorial_article_chat_sessions.id (FK, ON DELETE CASCADE). + pub session_id: Uuid, + + /// 'user' | 'assistant' | 'tool' | 'system' (CHECK 제약). + pub role: String, + + #[sea_orm(nullable, column_type = "Text")] + pub content: Option, + + /// assistant 가 호출한 tool 들. shape: [{name, args, id}]. + #[sea_orm(nullable, column_type = "JsonBinary")] + pub tool_calls: Option, + + /// role='tool' 일 때, 매칭되는 assistant tool_call 의 id. + #[sea_orm(nullable, column_type = "Text")] + pub tool_call_id: Option, + + /// role='tool' 일 때, 호출된 tool 이름. + #[sea_orm(nullable, column_type = "Text")] + pub tool_name: Option, + + /// role='tool' 일 때 tool 실행 결과 (자유 형식 dict). + #[sea_orm(nullable, column_type = "JsonBinary")] + pub tool_result: Option, + + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/api-server/src/entities/assets_editorial_article_chat_sessions.rs b/packages/api-server/src/entities/assets_editorial_article_chat_sessions.rs new file mode 100644 index 00000000..92ebd4ea --- /dev/null +++ b/packages/api-server/src/entities/assets_editorial_article_chat_sessions.rs @@ -0,0 +1,35 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// assets.public.editorial_article_chat_sessions (#446 fixup). +/// +/// 매거진 article 별 대화형 편집 세션. api-server 가 소유 (이전엔 ai-server +/// 가 직접 manage 했으나 #446 fixup 으로 이전). +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "editorial_article_chat_sessions")] +pub struct Model { + #[sea_orm(primary_key, column_type = "Uuid", auto_increment = false)] + pub id: Uuid, + + /// assets.editorial_articles.id (FK, ON DELETE CASCADE). + pub article_id: Uuid, + + /// admin user id (auth.users.id, FK 없음 — assets ↔ auth 다른 스키마). + #[sea_orm(nullable)] + pub created_by: Option, + + #[sea_orm(nullable)] + pub title: Option, + + /// 마지막 메시지 timestamp — 세션 list ordering 용. + #[sea_orm(nullable)] + pub last_message_at: Option, + + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/api-server/src/entities/mod.rs b/packages/api-server/src/entities/mod.rs index 6edc62f7..2d32ae71 100644 --- a/packages/api-server/src/entities/mod.rs +++ b/packages/api-server/src/entities/mod.rs @@ -42,6 +42,8 @@ pub mod votes; // raw_posts* 는 신규 assets 프로젝트의 public 스키마로 이동 (assets_* 접두). pub mod admin_audit_log; pub mod artists; +pub mod assets_editorial_article_chat_messages; +pub mod assets_editorial_article_chat_sessions; pub mod assets_pipeline_events; pub mod assets_pipeline_settings; pub mod assets_raw_post_sources; @@ -196,3 +198,11 @@ pub use assets_pipeline_events::Model as AssetsPipelineEventsModel; pub use assets_pipeline_settings::ActiveModel as AssetsPipelineSettingsActiveModel; pub use assets_pipeline_settings::Entity as AssetsPipelineSettings; pub use assets_pipeline_settings::Model as AssetsPipelineSettingsModel; + +pub use assets_editorial_article_chat_sessions::ActiveModel as AssetsEditorialArticleChatSessionsActiveModel; +pub use assets_editorial_article_chat_sessions::Entity as AssetsEditorialArticleChatSessions; +pub use assets_editorial_article_chat_sessions::Model as AssetsEditorialArticleChatSessionsModel; + +pub use assets_editorial_article_chat_messages::ActiveModel as AssetsEditorialArticleChatMessagesActiveModel; +pub use assets_editorial_article_chat_messages::Entity as AssetsEditorialArticleChatMessages; +pub use assets_editorial_article_chat_messages::Model as AssetsEditorialArticleChatMessagesModel; diff --git a/packages/api-server/src/router.rs b/packages/api-server/src/router.rs index 8d74181d..74125123 100644 --- a/packages/api-server/src/router.rs +++ b/packages/api-server/src/router.rs @@ -34,6 +34,10 @@ pub fn build_api_router(state: AppState) -> Router { ) // Config가 필요 없는 라우터들 .nest("/post-magazines", domains::post_magazines::router()) + .nest( + "/editorial-articles", + domains::editorial_articles_published::router(), + ) .nest("/categories", domains::categories::router()) .nest("/subcategories", domains::subcategories::handlers::router()) .nest("/warehouse", domains::warehouse::router()) diff --git a/packages/api-server/src/services/decoded_ai_grpc/client.rs b/packages/api-server/src/services/decoded_ai_grpc/client.rs index a40a53f8..5295c11a 100644 --- a/packages/api-server/src/services/decoded_ai_grpc/client.rs +++ b/packages/api-server/src/services/decoded_ai_grpc/client.rs @@ -10,7 +10,8 @@ use crate::grpc::inbound::{ AnalyzeLinkDirectResponse, AnalyzeLinkRequest, AnalyzeLinkResponse, CategoryRule, ExtractOgDataRequest, ExtractOgDataResponse, ExtractPostContextRequest, ExtractPostContextResponse, ProcessPostEditorialRequest, ProcessPostEditorialResponse, - ReparseRawPostRequest, ReparseRawPostResponse, TriggerSourceRequest, TriggerSourceResponse, + ReparseRawPostRequest, ReparseRawPostResponse, RunChatTurnRequest, RunChatTurnResponse, + TriggerSourceRequest, TriggerSourceResponse, }; use crate::observability::grpc::record_decoded_ai_call; @@ -283,6 +284,33 @@ impl DecodedAIGrpcClient { record_decoded_ai_call("reparse_raw_post", res.is_ok(), start.elapsed()); res } + + /// #446 fixup — editorial article chat 의 한 턴 LLM 실행. + /// LLM tool loop 가 길 수 있으므로 deadline 120s. + pub async fn run_chat_turn( + &self, + article_id: String, + layout_json: String, + history_json: String, + user_message: String, + ) -> Result> { + let start = Instant::now(); + let mut client = self.client.clone(); + let mut request = tonic::Request::new(RunChatTurnRequest { + article_id, + layout_json, + history_json, + user_message, + }); + request.set_timeout(std::time::Duration::from_secs(120)); + let res = async { + let response = client.run_chat_turn(request).await?; + Ok::<_, Box>(response.into_inner()) + } + .await; + record_decoded_ai_call("run_chat_turn", res.is_ok(), start.elapsed()); + res + } } #[cfg(test)] diff --git a/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx index 3d09951d..e37b6509 100644 --- a/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx +++ b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/hooks/admin/useEditorialArticles"; import { MagazineRenderer } from "@/lib/components/admin/editorial/magazine/MagazineRenderer"; import { ArticleActions } from "@/lib/components/admin/editorial/magazine/ArticleActions"; +import { ChatPanel } from "@/lib/components/admin/editorial/magazine/ChatPanel"; interface PageProps { params: Promise<{ id: string }>; @@ -90,6 +91,8 @@ function ArticleDetailContent({ id }: { id: string }) {