diff --git a/packages/ai-server/src/bootstrap.py b/packages/ai-server/src/bootstrap.py index 04dc791e..7be56e0f 100644 --- a/packages/ai-server/src/bootstrap.py +++ b/packages/ai-server/src/bootstrap.py @@ -96,12 +96,9 @@ async def health_check(): } # #416 — 모든 비즈니스 routes 가 gRPC 로 옮겨짐. router include 없음. - # 예외: editorial article chat (#429 Stage 3) — multi-turn / admin UI 호출 패턴. - # #429: chat sessions / messages / article load 모두 assets staging. - from src.editorial_article_chat.api import create_router as create_chat_router - - assets_db = application.infrastructure().assets_database_manager() - app.include_router(create_chat_router(assets_db)) + # 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/editorial_article_chat/agent.py b/packages/ai-server/src/editorial_article_chat/agent.py index 449cb9e5..f976d0b4 100644 --- a/packages/ai-server/src/editorial_article_chat/agent.py +++ b/packages/ai-server/src/editorial_article_chat/agent.py @@ -1,30 +1,33 @@ -"""Chat agent — Gemini function calling loop. +"""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. session 의 기존 messages 로드 → Gemini contents 형태로 변환 - 2. 새 user message append + DB 저장 - 3. Gemini 호출 (tools 바인딩) - 4. function_call 있으면 → ToolExecutor 로 실행 → tool result 를 Gemini 에 다시 - 보내고 step 반복 (max_steps 까지) - 5. function_call 없으면 → 최종 assistant text 반환 - 6. 마지막 layout 변경 있으면 DB persist (트랜잭션) + 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 -from typing import Optional +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.managers.database import DatabaseManager from src.post_editorial.config import get_settings from src.post_editorial.gemini_retry import call_gemini_with_fallback -from .repository import ChatMessage, EditorialArticleChatRepository from .tools import TOOL_DECLARATIONS, ToolExecutor @@ -35,10 +38,16 @@ @dataclass -class TurnResult: - assistant_text: str - tool_calls_made: int - layout_changed: bool +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: @@ -67,22 +76,29 @@ def _system_prompt(article_title: str, sections_brief: str) -> str: def _build_contents( - history: list[ChatMessage], new_user_text: str + history: list[dict[str, Any]], new_user_text: str ) -> list[genai_types.Content]: - """DB 메시지 → Gemini 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: - if msg.role == "user" and msg.content: + 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)] + role="user", + parts=[genai_types.Part.from_text(text=msg["content"])], ) ) - elif msg.role == "assistant": + elif role == "assistant": parts: list[genai_types.Part] = [] - if msg.content: - parts.append(genai_types.Part.from_text(text=msg.content)) - for fc in msg.tool_calls or []: + 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", {}) @@ -90,14 +106,14 @@ def _build_contents( ) if parts: contents.append(genai_types.Content(role="model", parts=parts)) - elif msg.role == "tool": + elif role == "tool": contents.append( genai_types.Content( role="user", parts=[ genai_types.Part.from_function_response( - name=msg.tool_name or "unknown", - response=msg.tool_result or {}, + name=msg.get("tool_name") or "unknown", + response=msg.get("tool_result") or {}, ) ], ) @@ -147,37 +163,31 @@ def _extract_text(response: genai_types.GenerateContentResponse) -> str: async def run_turn( *, - db: DatabaseManager, - repo: EditorialArticleChatRepository, - article_id: str, - session_id: str, + article_title: str, + layout: MagazineLayout, + history: list[dict[str, Any]], user_text: str, -) -> TurnResult: - """한 턴 실행 — user_text 받아서 모든 tool 호출 후 final text 반환.""" - layout, meta = await repo.load_article(article_id) - if layout is None: - raise ValueError(f"article {article_id} has no layout_json") - - history = await repo.list_messages(session_id) +) -> 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(db=db, article_id=article_id, layout=layout) + executor = ToolExecutor(layout=layout) + initial_layout = layout config = genai_types.GenerateContentConfig( temperature=0.4, system_instruction=_system_prompt( - meta.get("title") or "Untitled", _summarize_sections(layout) + article_title or "Untitled", _summarize_sections(layout) ), tools=[genai_types.Tool(function_declarations=TOOL_DECLARATIONS)], ) - # Persist user message immediately so client can poll - await repo.insert_user_message(session_id, user_text) - + 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( @@ -190,28 +200,34 @@ async def _generate(model: str) -> genai_types.GenerateContentResponse: ) except Exception as exc: logger.exception("chat agent: Gemini call failed") - await repo.insert_assistant_message( - session_id, content=f"(에이전트 오류: {exc})" + 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), ) - await repo.touch_session(session_id) - return TurnResult(f"(에이전트 오류: {exc})", tool_calls_made, False) function_calls = _extract_function_calls(response) text_part = _extract_text(response) - # assistant message 저장 (tool_calls 포함) + # assistant 이벤트 누적 tool_call_payloads = [ {"name": name, "args": args, "id": cid or f"tc_{step}_{i}"} for i, (name, args, cid) in enumerate(function_calls) ] - await repo.insert_assistant_message( - session_id, - content=text_part if text_part else None, - tool_calls=tool_call_payloads if tool_call_payloads else None, + 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 @@ -225,17 +241,19 @@ async def _generate(model: str) -> genai_types.GenerateContentResponse: ) contents.append(genai_types.Content(role="model", parts=model_parts)) - # 각 tool 실행 + tool message 저장 + Gemini 에 결과 전달 + # 각 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"] - await repo.insert_tool_message( - session_id=session_id, - tool_call_id=tc_id, - tool_name=name, - tool_result=result.to_dict(), + 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( @@ -251,21 +269,14 @@ async def _generate(model: str) -> genai_types.GenerateContentResponse: "(편집 사이클 한도 도달 — 안전상 중단. " "더 진행하려면 다시 메시지 보내주세요.)" ) - await repo.insert_assistant_message(session_id, content=final_text) - - # layout 변경 있으면 DB persist - layout_changed = executor.layout != layout - if layout_changed: - try: - await executor.persist_layout() - except Exception: - logger.exception("chat agent: persist_layout failed") - final_text += "\n\n(주의: DB 저장 중 오류 발생.)" + events.append({"role": "assistant", "content": final_text}) - await repo.touch_session(session_id) + layout_changed = executor.layout != initial_layout - return TurnResult( - assistant_text=final_text, - tool_calls_made=tool_calls_made, + 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/api.py b/packages/ai-server/src/editorial_article_chat/api.py deleted file mode 100644 index 43bc67e8..00000000 --- a/packages/ai-server/src/editorial_article_chat/api.py +++ /dev/null @@ -1,165 +0,0 @@ -"""HTTP endpoints for editorial article chat (FastAPI router). - -ai-server 의 다른 routes 는 #416 이후 gRPC 로 옮겨갔지만, chat 은 multi-turn / -stream / 단순한 admin UI 호출 패턴이라 HTTP 가 더 자연스럽다 (예외). - -Routes (모두 prefix `/api/v1/editorial-article-chat`): - GET /sessions/{article_id} — 세션 리스트 - POST /sessions/{article_id} — 새 세션 생성 - GET /sessions/{session_id}/messages — 메시지 리스트 - POST /sessions/{session_id}/messages — 새 user 메시지 → agent 실행 -""" - -from __future__ import annotations - -import logging -from typing import Optional - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from .agent import run_turn -from .repository import EditorialArticleChatRepository -from src.managers.database import DatabaseManager - - -logger = logging.getLogger(__name__) - - -# ───────────────────────────────────────────────────────────────────────────── -# Request / Response models -# ───────────────────────────────────────────────────────────────────────────── - - -class SessionResponse(BaseModel): - id: str - article_id: str - created_by: Optional[str] = None - title: Optional[str] = None - last_message_at: Optional[str] = None - created_at: str - - -class CreateSessionRequest(BaseModel): - created_by: Optional[str] = None - - -class MessageResponse(BaseModel): - id: int - session_id: str - role: str - content: Optional[str] = None - tool_calls: Optional[list[dict]] = None - tool_call_id: Optional[str] = None - tool_name: Optional[str] = None - tool_result: Optional[dict] = None - created_at: str - - -class SendMessageRequest(BaseModel): - text: str = Field(min_length=1, max_length=4000) - - -class SendMessageResponse(BaseModel): - assistant_text: str - tool_calls_made: int - layout_changed: bool - - -# ───────────────────────────────────────────────────────────────────────────── -# Router factory (DI 용 — operation_db / repo 주입) -# ───────────────────────────────────────────────────────────────────────────── - - -def create_router(db: DatabaseManager) -> APIRouter: - repo = EditorialArticleChatRepository(db) - router = APIRouter(prefix="/api/v1/editorial-article-chat", tags=["editorial-chat"]) - - @router.get("/sessions/{article_id}") - async def list_sessions(article_id: str) -> list[SessionResponse]: - sessions = await repo.list_sessions(article_id) - return [ - SessionResponse( - id=s.id, - article_id=s.article_id, - created_by=s.created_by, - title=s.title, - last_message_at=s.last_message_at.isoformat() - if s.last_message_at - else None, - created_at=s.created_at.isoformat(), - ) - for s in sessions - ] - - @router.post("/sessions/{article_id}") - async def create_session( - article_id: str, body: CreateSessionRequest - ) -> SessionResponse: - # article 존재 확인 - layout, _ = await repo.load_article(article_id) - if layout is None: - raise HTTPException(404, f"article {article_id} not found / no layout") - sess = await repo.create_session(article_id, body.created_by) - return SessionResponse( - id=sess.id, - article_id=sess.article_id, - created_by=sess.created_by, - title=sess.title, - last_message_at=None, - created_at=sess.created_at.isoformat(), - ) - - @router.get("/sessions/{session_id}/messages") - async def list_messages(session_id: str) -> list[MessageResponse]: - messages = await repo.list_messages(session_id) - return [ - MessageResponse( - id=m.id, - session_id=m.session_id, - role=m.role, - content=m.content, - tool_calls=m.tool_calls, - tool_call_id=m.tool_call_id, - tool_name=m.tool_name, - tool_result=m.tool_result, - created_at=m.created_at.isoformat(), - ) - for m in messages - ] - - @router.post("/sessions/{session_id}/messages") - async def send_message( - session_id: str, body: SendMessageRequest - ) -> SendMessageResponse: - # session 의 article_id 확인 - async with db.acquire() as conn: - row = await conn.fetchrow( - "SELECT article_id FROM public.editorial_article_chat_sessions WHERE id = $1::uuid", - session_id, - ) - if row is None: - raise HTTPException(404, f"session {session_id} not found") - article_id = str(row["article_id"]) - - try: - result = await run_turn( - db=db, - repo=repo, - article_id=article_id, - session_id=session_id, - user_text=body.text, - ) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - except Exception as exc: - logger.exception("send_message: agent crashed") - raise HTTPException(500, f"agent error: {exc}") from exc - - return SendMessageResponse( - assistant_text=result.assistant_text, - tool_calls_made=result.tool_calls_made, - layout_changed=result.layout_changed, - ) - - return router diff --git a/packages/ai-server/src/editorial_article_chat/repository.py b/packages/ai-server/src/editorial_article_chat/repository.py deleted file mode 100644 index 996e0315..00000000 --- a/packages/ai-server/src/editorial_article_chat/repository.py +++ /dev/null @@ -1,272 +0,0 @@ -"""DB access for chat sessions / messages + article layout load.""" - -from __future__ import annotations - -import json -import logging -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Optional -from uuid import UUID - -from src.editorial_article.models import MagazineLayout -from src.managers.database import DatabaseManager - - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class ChatSession: - id: str - article_id: str - created_by: Optional[str] - title: Optional[str] - last_message_at: Optional[datetime] - created_at: datetime - - -@dataclass(frozen=True) -class ChatMessage: - id: int - session_id: str - role: str # user|assistant|tool|system - content: Optional[str] - tool_calls: Optional[list[dict[str, Any]]] - tool_call_id: Optional[str] - tool_name: Optional[str] - tool_result: Optional[dict[str, Any]] - created_at: datetime - - -class EditorialArticleChatRepository: - def __init__(self, database_manager: DatabaseManager) -> None: - self._db = database_manager - - # ---- Article load ---------------------------------------------------- - - async def load_article( - self, article_id: str - ) -> tuple[Optional[MagazineLayout], dict]: - """현재 article 의 layout_json + 메타 (title/status) 로드.""" - async with self._db.acquire() as conn: - row = await conn.fetchrow( - """ - SELECT title, subtitle, hero_image_url, layout_json, status - FROM public.editorial_articles - WHERE id = $1::uuid - """, - article_id, - ) - if row is None: - return None, {} - layout_data = row["layout_json"] - if isinstance(layout_data, str): - try: - layout_data = json.loads(layout_data) - except Exception: - layout_data = None - layout = ( - MagazineLayout.model_validate(layout_data) - if isinstance(layout_data, dict) - else None - ) - meta = { - "title": row["title"], - "subtitle": row["subtitle"], - "hero_image_url": row["hero_image_url"], - "status": row["status"], - } - return layout, meta - - # ---- Sessions -------------------------------------------------------- - - async def create_session( - self, - article_id: str, - created_by: Optional[str] = None, - ) -> ChatSession: - async with self._db.acquire() as conn: - row = await conn.fetchrow( - """ - 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 - """, - article_id, - UUID(created_by) if created_by else None, - ) - return ChatSession( - id=str(row["id"]), - article_id=str(row["article_id"]), - created_by=str(row["created_by"]) if row["created_by"] else None, - title=row["title"], - last_message_at=row["last_message_at"], - created_at=row["created_at"], - ) - - async def list_sessions( - self, article_id: str, limit: int = 20 - ) -> list[ChatSession]: - async with self._db.acquire() as conn: - rows = await conn.fetch( - """ - 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 $2 - """, - article_id, - limit, - ) - return [ - ChatSession( - id=str(r["id"]), - article_id=str(r["article_id"]), - created_by=str(r["created_by"]) if r["created_by"] else None, - title=r["title"], - last_message_at=r["last_message_at"], - created_at=r["created_at"], - ) - for r in rows - ] - - async def get_or_create_latest_session( - self, article_id: str, created_by: Optional[str] = None - ) -> ChatSession: - sessions = await self.list_sessions(article_id, limit=1) - if sessions: - return sessions[0] - return await self.create_session(article_id, created_by) - - async def touch_session(self, session_id: str) -> None: - async with self._db.acquire() as conn: - await conn.execute( - """ - UPDATE public.editorial_article_chat_sessions - SET last_message_at = now(), updated_at = now() - WHERE id = $1::uuid - """, - session_id, - ) - - # ---- Messages -------------------------------------------------------- - - async def list_messages(self, session_id: str) -> list[ChatMessage]: - async with self._db.acquire() as conn: - rows = await conn.fetch( - """ - 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 - """, - session_id, - ) - return [_row_to_message(r) for r in rows] - - async def insert_user_message( - self, session_id: str, content: str - ) -> ChatMessage: - return await self._insert( - session_id=session_id, - role="user", - content=content, - tool_calls=None, - tool_call_id=None, - tool_name=None, - tool_result=None, - ) - - async def insert_assistant_message( - self, - session_id: str, - content: Optional[str], - tool_calls: Optional[list[dict[str, Any]]] = None, - ) -> ChatMessage: - return await self._insert( - session_id=session_id, - role="assistant", - content=content, - tool_calls=tool_calls, - tool_call_id=None, - tool_name=None, - tool_result=None, - ) - - async def insert_tool_message( - self, - session_id: str, - tool_call_id: str, - tool_name: str, - tool_result: dict[str, Any], - ) -> ChatMessage: - return await self._insert( - session_id=session_id, - role="tool", - content=None, - tool_calls=None, - tool_call_id=tool_call_id, - tool_name=tool_name, - tool_result=tool_result, - ) - - async def _insert( - self, - *, - session_id: str, - role: str, - content: Optional[str], - tool_calls: Optional[list[dict[str, Any]]], - tool_call_id: Optional[str], - tool_name: Optional[str], - tool_result: Optional[dict[str, Any]], - ) -> ChatMessage: - async with self._db.acquire() as conn: - row = await conn.fetchrow( - """ - INSERT INTO public.editorial_article_chat_messages - (session_id, role, content, tool_calls, - tool_call_id, tool_name, tool_result) - VALUES ($1::uuid, $2, $3, $4::jsonb, $5, $6, $7::jsonb) - RETURNING id, session_id, role, content, tool_calls, - tool_call_id, tool_name, tool_result, created_at - """, - session_id, - role, - content, - json.dumps(tool_calls) if tool_calls is not None else None, - tool_call_id, - tool_name, - json.dumps(tool_result) if tool_result is not None else None, - ) - return _row_to_message(row) - - -def _row_to_message(row: Any) -> ChatMessage: - tool_calls = row["tool_calls"] - if isinstance(tool_calls, str): - try: - tool_calls = json.loads(tool_calls) - except Exception: - tool_calls = None - tool_result = row["tool_result"] - if isinstance(tool_result, str): - try: - tool_result = json.loads(tool_result) - except Exception: - tool_result = None - return ChatMessage( - id=row["id"], - session_id=str(row["session_id"]), - role=row["role"], - content=row["content"], - tool_calls=tool_calls, - tool_call_id=row["tool_call_id"], - tool_name=row["tool_name"], - tool_result=tool_result, - created_at=row["created_at"], - ) diff --git a/packages/ai-server/src/editorial_article_chat/tools.py b/packages/ai-server/src/editorial_article_chat/tools.py index f363703a..025a39d9 100644 --- a/packages/ai-server/src/editorial_article_chat/tools.py +++ b/packages/ai-server/src/editorial_article_chat/tools.py @@ -6,13 +6,12 @@ 새 카피를 생성해서 반영 (e.g., regenerate_section_body) 각 tool 의 declaration 은 google-genai 의 FunctionDeclaration 형식. -실행은 ToolExecutor 가 담당 — DB write 까지 책임지므로 article_id 와 db 를 -context 로 받음. +실행은 ToolExecutor 가 담당 — pure compute (in-memory layout mutate) 만 수행. +DB persist 는 호출자 (api-server) 책임 (#446 fixup). """ from __future__ import annotations -import json import logging from dataclasses import dataclass from typing import Any, Callable, Optional @@ -21,7 +20,6 @@ from google.genai import types as genai_types from src.editorial_article.models import MagazineLayout, MagazineSection -from src.managers.database import DatabaseManager from src.post_editorial.config import get_settings from src.post_editorial.gemini_retry import call_gemini_with_fallback @@ -169,16 +167,13 @@ def to_dict(self) -> dict: class ToolExecutor: - """Layout 을 메모리에 들고 있다가 각 tool 호출에 대해 mutate + DB persist.""" - - def __init__( - self, - db: DatabaseManager, - article_id: str, - layout: MagazineLayout, - ) -> None: - self._db = db - self._article_id = article_id + """Layout 을 메모리에 들고 있다가 각 tool 호출에 대해 mutate. + + Pure compute — DB 는 만지지 않는다. 최종 layout 은 caller 가 self.layout + 프로퍼티로 가져가 persist 한다. + """ + + def __init__(self, layout: MagazineLayout) -> None: self._layout = layout @property @@ -199,26 +194,6 @@ async def execute(self, name: str, args: dict) -> ToolResult: logger.exception("ToolExecutor: %s crashed", name) return ToolResult(False, f"Tool crashed: {exc}", error=str(exc)) - async def persist_layout(self) -> None: - layout_json = self._layout.model_dump(mode="json") - async with self._db.acquire() as conn: - await conn.execute( - """ - UPDATE public.editorial_articles - SET title = $2, - subtitle = $3, - hero_image_url = $4, - layout_json = $5::jsonb, - updated_at = now() - WHERE id = $1::uuid - """, - self._article_id, - self._layout.title or "Untitled", - self._layout.subtitle, - self._layout.hero_image_url, - json.dumps(layout_json), - ) - # ---- Handlers -------------------------------------------------------- async def _update_title(self, title: str) -> ToolResult: 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/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/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/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/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/api/admin/editorial-article-chat/messages/[sessionId]/route.ts b/packages/web/app/api/admin/editorial-article-chat/messages/[sessionId]/route.ts index d47fc2f7..41634117 100644 --- a/packages/web/app/api/admin/editorial-article-chat/messages/[sessionId]/route.ts +++ b/packages/web/app/api/admin/editorial-article-chat/messages/[sessionId]/route.ts @@ -1,13 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { checkIsAdmin } from "@/lib/supabase/admin"; -import { AI_SERVER_HTTP_URL } from "@/lib/server-env"; +import { API_BASE_URL } from "@/lib/server-env"; // GET /api/admin/editorial-article-chat/messages/{sessionId} → 메시지 리스트 -// POST /api/admin/editorial-article-chat/messages/{sessionId} → user 메시지 → agent 실행 -// (Stage 3, issue #429) +// POST /api/admin/editorial-article-chat/messages/{sessionId} → user 메시지 → agent 한 턴 +// +// (#446 fixup) — api-server 가 chat 소유. send_message 는 ~30s 걸릴 수 있음. -async function adminCheck() { +async function adminSession() { const supabase = await createSupabaseServerClient(); const { data: { user }, @@ -16,30 +17,40 @@ async function adminCheck() { if (!(await checkIsAdmin(supabase, user.id))) { return { error: "Forbidden", status: 403 as const }; } - return { userId: user.id }; + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session?.access_token) { + return { error: "No session", status: 401 as const }; + } + return { token: session.access_token }; } export async function GET( _req: NextRequest, context: { params: Promise<{ sessionId: string }> } ) { - const auth = await adminCheck(); + const auth = await adminSession(); if ("error" in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } - if (!AI_SERVER_HTTP_URL) { + if (!API_BASE_URL) { return NextResponse.json( - { error: "AI_SERVER_HTTP_URL not configured" }, + { error: "API_BASE_URL is not configured" }, { status: 502 } ); } const { sessionId } = await context.params; try { const res = await fetch( - `${AI_SERVER_HTTP_URL}/api/v1/editorial-article-chat/sessions/${encodeURIComponent(sessionId)}/messages` + `${API_BASE_URL}/api/v1/admin/editorial-article-chat/messages/${encodeURIComponent(sessionId)}`, + { + headers: { Authorization: `Bearer ${auth.token}` }, + cache: "no-store", + } ); - const text = await res.text(); - return new NextResponse(text, { + const body = await res.text(); + return new NextResponse(body, { status: res.status, headers: { "content-type": "application/json" }, }); @@ -55,29 +66,32 @@ export async function POST( req: NextRequest, context: { params: Promise<{ sessionId: string }> } ) { - const auth = await adminCheck(); + const auth = await adminSession(); if ("error" in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } - if (!AI_SERVER_HTTP_URL) { + if (!API_BASE_URL) { return NextResponse.json( - { error: "AI_SERVER_HTTP_URL not configured" }, + { error: "API_BASE_URL is not configured" }, { status: 502 } ); } const { sessionId } = await context.params; - const body = await req.text(); + const payload = await req.text(); try { const res = await fetch( - `${AI_SERVER_HTTP_URL}/api/v1/editorial-article-chat/sessions/${encodeURIComponent(sessionId)}/messages`, + `${API_BASE_URL}/api/v1/admin/editorial-article-chat/messages/${encodeURIComponent(sessionId)}`, { method: "POST", - headers: { "content-type": "application/json" }, - body, + headers: { + "content-type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: payload, } ); - const text = await res.text(); - return new NextResponse(text, { + const body = await res.text(); + return new NextResponse(body, { status: res.status, headers: { "content-type": "application/json" }, }); diff --git a/packages/web/app/api/admin/editorial-article-chat/sessions/[articleId]/route.ts b/packages/web/app/api/admin/editorial-article-chat/sessions/[articleId]/route.ts index d76ecc4a..37e71c7f 100644 --- a/packages/web/app/api/admin/editorial-article-chat/sessions/[articleId]/route.ts +++ b/packages/web/app/api/admin/editorial-article-chat/sessions/[articleId]/route.ts @@ -1,13 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { checkIsAdmin } from "@/lib/supabase/admin"; -import { AI_SERVER_HTTP_URL } from "@/lib/server-env"; +import { API_BASE_URL } from "@/lib/server-env"; // GET /api/admin/editorial-article-chat/sessions/{articleId} → 세션 리스트 // POST /api/admin/editorial-article-chat/sessions/{articleId} → 새 세션 생성 -// (Stage 3, issue #429) +// +// (#446 fixup) — api-server 가 chat 소유. 이전엔 ai-server FastAPI 직접 호출. -async function adminCheck() { +async function adminSession() { const supabase = await createSupabaseServerClient(); const { data: { user }, @@ -16,35 +17,40 @@ async function adminCheck() { if (!(await checkIsAdmin(supabase, user.id))) { return { error: "Forbidden", status: 403 as const }; } - return { userId: user.id }; -} - -function aiBase(): string | null { - return AI_SERVER_HTTP_URL || null; + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session?.access_token) { + return { error: "No session", status: 401 as const }; + } + return { token: session.access_token }; } export async function GET( _req: NextRequest, context: { params: Promise<{ articleId: string }> } ) { - const auth = await adminCheck(); + const auth = await adminSession(); if ("error" in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } - const base = aiBase(); - if (!base) { + if (!API_BASE_URL) { return NextResponse.json( - { error: "AI_SERVER_HTTP_URL not configured" }, + { error: "API_BASE_URL is not configured" }, { status: 502 } ); } const { articleId } = await context.params; try { const res = await fetch( - `${base}/api/v1/editorial-article-chat/sessions/${encodeURIComponent(articleId)}` + `${API_BASE_URL}/api/v1/admin/editorial-article-chat/sessions/${encodeURIComponent(articleId)}`, + { + headers: { Authorization: `Bearer ${auth.token}` }, + cache: "no-store", + } ); - const text = await res.text(); - return new NextResponse(text, { + const body = await res.text(); + return new NextResponse(body, { status: res.status, headers: { "content-type": "application/json" }, }); @@ -60,29 +66,30 @@ export async function POST( _req: NextRequest, context: { params: Promise<{ articleId: string }> } ) { - const auth = await adminCheck(); + const auth = await adminSession(); if ("error" in auth) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } - const base = aiBase(); - if (!base) { + if (!API_BASE_URL) { return NextResponse.json( - { error: "AI_SERVER_HTTP_URL not configured" }, + { error: "API_BASE_URL is not configured" }, { status: 502 } ); } const { articleId } = await context.params; try { const res = await fetch( - `${base}/api/v1/editorial-article-chat/sessions/${encodeURIComponent(articleId)}`, + `${API_BASE_URL}/api/v1/admin/editorial-article-chat/sessions/${encodeURIComponent(articleId)}`, { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ created_by: auth.userId }), + headers: { + "content-type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, } ); - const text = await res.text(); - return new NextResponse(text, { + const body = await res.text(); + return new NextResponse(body, { status: res.status, headers: { "content-type": "application/json" }, }); diff --git a/packages/web/lib/server-env.ts b/packages/web/lib/server-env.ts index 57484b4e..d2f57bfd 100644 --- a/packages/web/lib/server-env.ts +++ b/packages/web/lib/server-env.ts @@ -8,12 +8,6 @@ function getEnvOrEmpty(name: string): string { * * 참고 — `AI_SERVER_URL` 은 #416 에서 제거됨. 모든 ai-server 진입은 * api-server gRPC 통해서. ai-server 는 internal docker network only. + * (#446 fixup) — chat 도 동일 원칙. AI_SERVER_HTTP_URL 도 제거됨. */ export const API_BASE_URL = getEnvOrEmpty("API_BASE_URL"); - -/** - * AI-server HTTP base URL (#429 Stage 3 chat 예외 — gRPC 아닌 HTTP). - * dev: http://localhost:10000. prod: ai.decoded.style 또는 internal route. - * 비어 있으면 chat endpoints 가 502 반환. - */ -export const AI_SERVER_HTTP_URL = getEnvOrEmpty("AI_SERVER_HTTP_URL"); diff --git a/supabase/migrations/20260505020001_editorial_articles_published.sql b/supabase/migrations/20260505020001_editorial_articles_published.sql index 91a08259..11b5848d 100644 --- a/supabase/migrations/20260505020001_editorial_articles_published.sql +++ b/supabase/migrations/20260505020001_editorial_articles_published.sql @@ -7,6 +7,11 @@ BEGIN; +-- staging editorial_articles 가 같은 이름으로 살아있을 수 있음 (PR #446 머지 후 +-- operation cloud 에 020002 보다 먼저 도달). publish 스키마 (slug 컬럼 등) 와 +-- 충돌하므로 강제 drop 후 재생성. CASCADE 로 chat/events FK 같이 정리. +DROP TABLE IF EXISTS public.editorial_articles CASCADE; + CREATE TABLE IF NOT EXISTS public.editorial_articles ( id uuid PRIMARY KEY, -- assets article id 와 동일 source_article_id uuid NOT NULL, -- assets 의 원본 (logical, FK 없음)