o2o-site-AEO/solution/backend/services/agent/runtime.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

139 lines
5.1 KiB
Python

"""에이전트 런타임 — 발화 → 도구 선택 → 실행 → 응답."""
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}