"""에이전트 런타임 — 발화 → 도구 선택 → 실행 → 응답.""" import uuid import httpx from common.category_schema.loader import get_schema from common.enums import DBWRType, ErrorType, PlaceCategory from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import places from common.models.gmodel import UserInfo from config import agent_config as config from config.server_configs import external_api_config from crud.fact_crud import FactCRUD from crud.place_crud import PlaceCRUD from services.agent import tools as registry from services.agent.tools import ToolContext, ToolGrade, ToolRejected from services.fact_service import FactService from services.llm import provider from services.llm.errors import LlmError from services.prompts import agent as prompt from common.logger import LOG # 발화 길이 상한. MAX_MESSAGE = 500 # 도구 선택은 짧은 프롬프트라 빠르다. REQUEST_TIMEOUT = httpx.Timeout(20.0, connect=5.0) class AgentError(RuntimeError): """라우터가 HTTP 로 옮길 도메인 예외.""" def is_configured() -> bool: """대화창을 열 수 있나 — 스위치와 LLM 키를 **둘 다** 본다.""" return config.chat_enabled() and provider.active().is_configured() async def _load_place(user: UserInfo, place_id: str): """소유자 범위.""" err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: PlaceCRUD().get_place(s, uuid.UUID(user.user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise AgentError("PLACE_NOT_FOUND") return place async def _context_facts(user: UserInfo, place_id: str, place) -> list[dict]: """모델에게 줄 '지금 값'.""" res = await FactService(FactCRUD(), PlaceCRUD()).list_facts(user, place_id, publishable_only=True) schema = get_schema(PlaceCategory(place.category)) out = [] for f in (res.facts or []): spec = schema.get(f.key) if spec and spec.scope == "place" and (f.value or "").strip(): # label 은 싣지 않는다 — 아래 '항목 목록' 에 이미 key↔label 이 있다. out.append({f.key: f.value}) # 상한을 둔다. return out[:30] async def _choose(place, fields, facts, message) -> dict: """LLM 한 번.""" active = provider.active() async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: result = await active.generate( client, external_api_config.gemini_text_model if active.__name__.endswith("gemini") else external_api_config.openai_text_model, prompt=prompt.build_prompt( place_name=place.name, tools=registry.describe(), fields=fields, facts=facts, message=message, ), response_schema=prompt.RESPONSE_SCHEMA, temperature=0.0, ) return result.json or {} async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None = None) -> dict: """대화 한 번.""" message = (message or "").strip() if confirm is None and not message: raise AgentError("AGENT_EMPTY_MESSAGE") if len(message) > MAX_MESSAGE: raise AgentError("AGENT_MESSAGE_TOO_LONG") place = await _load_place(user, place_id) ctx = ToolContext(user=user, place_id=place_id, place=place) if confirm is not None: tool = registry.REGISTRY.get(confirm.get("tool") or "") if tool is None or tool.grade == ToolGrade.READ: raise AgentError("AGENT_UNKNOWN_TOOL") return await _execute(ctx, tool, confirm.get("args") or {}) if not is_configured(): raise AgentError("AGENT_NOT_CONFIGURED") fields = registry.fields_of(place) facts = await _context_facts(user, place_id, place) # 사이트 상태는 프롬프트에 싣지 않는다. try: choice = await _choose(place, fields, facts, message) except LlmError as ex: LOG.w(f"[agent] 도구 선택 실패: {type(ex).__name__}") raise AgentError("AGENT_CALL_FAILED") from ex name = (choice.get("tool") or "").strip() tool = registry.REGISTRY.get(name) if tool is None: return { "reply": (choice.get("message") or "").strip() or "무엇을 도와드릴까요?", "tool": None, "needs_confirm": False, } args = choice.get("args") or {} if tool.grade == ToolGrade.SEMI: # 실행하지 않는다. return {"reply": tool.confirm, "tool": tool.name, "args": args, "needs_confirm": True} return await _execute(ctx, tool, args) async def _execute(ctx: ToolContext, tool, args: dict) -> dict: try: reply = await tool.run(ctx, args) except ToolRejected as ex: # 도구가 거절한 이유는 사장님께 그대로 보여 준다 — 실패를 숨기면 다시 시도한다. return {"reply": str(ex), "tool": tool.name, "needs_confirm": False, "rejected": True} return {"reply": reply, "tool": tool.name, "needs_confirm": False, "done": tool.grade != ToolGrade.READ}