From fa0c0f6ba0cd846e5fe4d8ed359f2a7346a4d942 Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Tue, 5 May 2026 00:17:42 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat(editorial):=20chat=20sessions=20/=20?= =?UTF-8?q?messages=20=ED=85=8C=EC=9D=B4=EB=B8=94=20(Stage=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit editorial_article 의 대화형 편집을 위한 2 테이블. - editorial_article_chat_sessions: 한 article 에 여러 세션 가능 (article_id FK CASCADE + created_by + last_message_at). - editorial_article_chat_messages: role (user/assistant/tool/system) + content + tool_calls (JSONB) + tool_call_id/name + tool_result (JSONB). agent 의 tool 실행 audit 보존. RLS: admin-only ALL (채팅 내용은 운영 audit). #429 --- .../20260505000000_editorial_article_chat.sql | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 supabase/migrations/20260505000000_editorial_article_chat.sql diff --git a/supabase/migrations/20260505000000_editorial_article_chat.sql b/supabase/migrations/20260505000000_editorial_article_chat.sql new file mode 100644 index 00000000..77a92a2d --- /dev/null +++ b/supabase/migrations/20260505000000_editorial_article_chat.sql @@ -0,0 +1,83 @@ +-- Editorial article 대화형 편집 (Stage 3) — chat sessions + messages. +-- +-- Why: Stage 2 가 매거진 드래프트를 만든 후, 컨텐츠 매니저가 LLM agent 와 +-- 대화하면서 카피/섹션을 다듬을 수 있어야 함. 한 article 에 여러 세션 +-- 허용 (다른 매니저, 다른 시점). Tool calling 으로 layout_json 부분 수정 +-- 을 agent 가 직접 실행 — 이력은 chat_messages 에 보존돼 audit / replay +-- 가능. +-- +-- 운영 모델: +-- - editorial_article_chat_sessions: 한 article 에 1+ 세션. 가장 최근 세션 +-- 을 기본으로 web UI 가 resume. +-- - editorial_article_chat_messages: LangChain message 형태를 JSONB 로 저장 +-- (role: user/assistant/tool, parts/tool_calls/tool_results 포함). 한 +-- 세션의 모든 turn 시간순. +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS / DROP POLICY IF EXISTS. + +BEGIN; + +-- ───────────────────────────────────────────────────────────────────────────── +-- 1) editorial_article_chat_sessions +-- ───────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.editorial_article_chat_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + article_id uuid NOT NULL + REFERENCES public.editorial_articles(id) ON DELETE CASCADE, + created_by uuid, -- admin user + title varchar, -- optional, agent 가 자동 생성 + last_message_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS editorial_article_chat_sessions_article_idx + ON public.editorial_article_chat_sessions (article_id, created_at DESC); + +COMMENT ON TABLE public.editorial_article_chat_sessions IS + 'Stage 3: editorial article 대화형 편집 세션. 한 article 에 여러 세션 가능.'; + +-- ───────────────────────────────────────────────────────────────────────────── +-- 2) editorial_article_chat_messages +-- ───────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.editorial_article_chat_messages ( + id bigserial PRIMARY KEY, + session_id uuid NOT NULL + REFERENCES public.editorial_article_chat_sessions(id) ON DELETE CASCADE, + role varchar NOT NULL + CHECK (role IN ('user', 'assistant', 'tool', 'system')), + content text, -- plain text body + tool_calls jsonb, -- assistant 가 호출한 tool 들 + tool_call_id text, -- role='tool' 일 때 어떤 호출 결과인지 + tool_name text, -- role='tool' 일 때 tool 이름 + tool_result jsonb, -- tool 결과 (성공/실패 + 변경 요약) + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS editorial_article_chat_messages_session_idx + ON public.editorial_article_chat_messages (session_id, id ASC); + +COMMENT ON TABLE public.editorial_article_chat_messages IS + 'Stage 3: 한 채팅 세션의 모든 turn (user / assistant / tool). 시간순 replay.'; + +-- ───────────────────────────────────────────────────────────────────────────── +-- RLS (admin-only — 채팅 내용은 운영 audit 자료) +-- ───────────────────────────────────────────────────────────────────────────── +ALTER TABLE public.editorial_article_chat_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.editorial_article_chat_messages ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "admin_all_editorial_chat_sessions" + ON public.editorial_article_chat_sessions; +CREATE POLICY "admin_all_editorial_chat_sessions" + ON public.editorial_article_chat_sessions FOR ALL + USING (public.is_admin(auth.uid())) + WITH CHECK (public.is_admin(auth.uid())); + +DROP POLICY IF EXISTS "admin_all_editorial_chat_messages" + ON public.editorial_article_chat_messages; +CREATE POLICY "admin_all_editorial_chat_messages" + ON public.editorial_article_chat_messages FOR ALL + USING (public.is_admin(auth.uid())) + WITH CHECK (public.is_admin(auth.uid())); + +COMMIT; From 401af637a7e3cdcf60150771484d36aba8cd6fad Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Tue, 5 May 2026 00:26:12 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(editorial):=20Stage=203=20=E2=80=94?= =?UTF-8?q?=20=EB=A7=A4=EA=B1=B0=EC=A7=84=20article=20=EB=8C=80=ED=99=94?= =?UTF-8?q?=ED=98=95=20=ED=8E=B8=EC=A7=91=20(chat=20agent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨텐츠 매니저가 채팅으로 매거진 layout 을 다듬는 agent. LangGraph 대신 google-genai function calling + 단순 loop. Vercel AI SDK / Claude 도입 안 함 — 기존 Gemini stack 일관성. ai-server (editorial_article_chat 모듈): - tools.py: 7 tools (update_title/subtitle, update_section_body/title, remove_section, regenerate_section_body, get_current_layout). 각 tool 은 in-memory layout 을 mutate, 마지막에 한 번 DB persist. regenerate_section_body 는 별도 Gemini 호출로 카피 생성. - agent.py: run_turn — 기존 messages 로드 → contents 구성 → Gemini Pro 호출 → function_call 있으면 ToolExecutor → 결과 model 에 다시 → 반복 (max 6 step). 모든 messages DB 영속화. layout 변경 있으면 트랜잭션. - repository.py: chat_sessions / chat_messages CRUD + article load. - api.py: FastAPI router (/api/v1/editorial-article-chat/*) — sessions list/create, messages list/send. #416 gRPC-only 컨벤션 예외 (chat 은 multi-turn / admin UI 호출 패턴이라 HTTP 자연스러움). - bootstrap.py: chat router include. web: - Next.js proxy routes /api/admin/editorial-article-chat/{sessions,messages} → ai-server (admin auth + bearer 패턴). AI_SERVER_HTTP_URL env (server-env.ts). - useEditorialChat hook: useChatSessions / useCreateChatSession / useChatMessages / useSendChatMessage. Mutation 성공 시 article query invalidate → MagazineRenderer 자동 리렌더. - ChatPanel.tsx: drafts/[id] 페이지 우측 sidebar 에 통합. 가장 최근 세션 자동 로드/생성. user/assistant/tool 메시지 differentiated. Enter 전송, Shift+Enter 줄바꿈. 검증: - curl 로 'get_current_layout' tool 호출 → agent 가 4섹션 인식 정확 - 'remove_section' → DB 4→3 섹션, layout_changed=true 확인 #429 --- packages/ai-server/src/bootstrap.py | 6 + .../src/editorial_article_chat/__init__.py | 1 + .../src/editorial_article_chat/agent.py | 271 ++++++++++++ .../src/editorial_article_chat/api.py | 165 ++++++++ .../src/editorial_article_chat/repository.py | 272 ++++++++++++ .../src/editorial_article_chat/tools.py | 387 ++++++++++++++++++ .../editorial/magazine/drafts/[id]/page.tsx | 3 + .../messages/[sessionId]/route.ts | 90 ++++ .../sessions/[articleId]/route.ts | 95 +++++ .../admin/editorial/magazine/ChatPanel.tsx | 211 ++++++++++ .../web/lib/hooks/admin/useEditorialChat.ts | 143 +++++++ packages/web/lib/server-env.ts | 7 + 12 files changed, 1651 insertions(+) create mode 100644 packages/ai-server/src/editorial_article_chat/__init__.py create mode 100644 packages/ai-server/src/editorial_article_chat/agent.py create mode 100644 packages/ai-server/src/editorial_article_chat/api.py create mode 100644 packages/ai-server/src/editorial_article_chat/repository.py create mode 100644 packages/ai-server/src/editorial_article_chat/tools.py create mode 100644 packages/web/app/api/admin/editorial-article-chat/messages/[sessionId]/route.ts create mode 100644 packages/web/app/api/admin/editorial-article-chat/sessions/[articleId]/route.ts create mode 100644 packages/web/lib/components/admin/editorial/magazine/ChatPanel.tsx create mode 100644 packages/web/lib/hooks/admin/useEditorialChat.ts diff --git a/packages/ai-server/src/bootstrap.py b/packages/ai-server/src/bootstrap.py index 5d60bd13..cf07b533 100644 --- a/packages/ai-server/src/bootstrap.py +++ b/packages/ai-server/src/bootstrap.py @@ -96,6 +96,12 @@ async def health_check(): } # #416 — 모든 비즈니스 routes 가 gRPC 로 옮겨짐. router include 없음. + # 예외: editorial article chat (#429 Stage 3) — multi-turn / admin UI 호출 패턴이라 + # HTTP 가 자연스러움. operation DB pool 주입. + from src.editorial_article_chat.api import create_router as create_chat_router + + operation_db = application.infrastructure().operation_database_manager() + app.include_router(create_chat_router(operation_db)) return app 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..449cb9e5 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/agent.py @@ -0,0 +1,271 @@ +"""Chat agent — Gemini function calling loop. + +흐름: + 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 (트랜잭션) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import 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 + + +logger = logging.getLogger(__name__) + + +_MAX_STEPS = 6 # tool calling round trip 최대 횟수 (안전 가드) + + +@dataclass +class TurnResult: + assistant_text: str + tool_calls_made: int + layout_changed: bool + + +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[ChatMessage], new_user_text: str +) -> list[genai_types.Content]: + """DB 메시지 → Gemini Content[] 변환.""" + contents: list[genai_types.Content] = [] + for msg in history: + if msg.role == "user" and msg.content: + contents.append( + genai_types.Content( + role="user", parts=[genai_types.Part.from_text(text=msg.content)] + ) + ) + elif msg.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 []: + 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 msg.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 {}, + ) + ], + ) + ) + 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( + *, + db: DatabaseManager, + repo: EditorialArticleChatRepository, + article_id: str, + session_id: str, + 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) + 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) + + config = genai_types.GenerateContentConfig( + temperature=0.4, + system_instruction=_system_prompt( + meta.get("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) + + 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") + await repo.insert_assistant_message( + session_id, content=f"(에이전트 오류: {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 포함) + 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, + ) + + 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 message 저장 + 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(), + ) + 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 = ( + "(편집 사이클 한도 도달 — 안전상 중단. " + "더 진행하려면 다시 메시지 보내주세요.)" + ) + 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 저장 중 오류 발생.)" + + await repo.touch_session(session_id) + + return TurnResult( + assistant_text=final_text, + tool_calls_made=tool_calls_made, + layout_changed=layout_changed, + ) diff --git a/packages/ai-server/src/editorial_article_chat/api.py b/packages/ai-server/src/editorial_article_chat/api.py new file mode 100644 index 00000000..43bc67e8 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/api.py @@ -0,0 +1,165 @@ +"""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 new file mode 100644 index 00000000..996e0315 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/repository.py @@ -0,0 +1,272 @@ +"""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 new file mode 100644 index 00000000..f363703a --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/tools.py @@ -0,0 +1,387 @@ +"""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 가 담당 — DB write 까지 책임지므로 article_id 와 db 를 +context 로 받음. +""" + +from __future__ import annotations + +import json +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.managers.database import DatabaseManager +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 + DB persist.""" + + def __init__( + self, + db: DatabaseManager, + article_id: str, + layout: MagazineLayout, + ) -> None: + self._db = db + self._article_id = article_id + 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)) + + 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: + 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/web/app/admin/editorial/magazine/drafts/[id]/page.tsx b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx index 631a40e2..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 }) {