Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions packages/ai-server/src/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
157 changes: 84 additions & 73 deletions packages/ai-server/src/editorial_article_chat/agent.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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:
Expand Down Expand Up @@ -67,37 +76,44 @@ 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", {})
)
)
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 {},
)
],
)
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand All @@ -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(
Expand All @@ -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,
)
Loading
Loading