o2o-site-AEO/solution/backend/router/router.py
hbyang b1a34ba58d [feat] solution/backend,frontend: 에이전트 도구·런타임·빌더 채팅창 — 2단계
런타임이 채널을 모르므로 채널·챗봇 심사 없이 에이전트 전체를 빌더 화면에서
검증할 수 있다. 웹훅 핸들러 안에 짜면 빌더에서 같은 걸 못 쓰고, 심사가 끝나야
무엇 하나 확인되지 않는다 — 카톡은 나중에 붙는 두 번째 입구다.

- services/agent/tools.py: 도구 넷 + 등급 셋(READ·REVERSIBLE·SEMI).
  ★ 도구는 반드시 services/* 를 통과한다 — crud 를 직접 부르면 스키마 검증·
  출처 필수·정정본 보호가 아무 증상 없이 사라진다. 테스트가 소스로 검사한다
- services/agent/runtime.py: 발화 → 도구 선택(LLM 1콜) → 실행 → 응답
- services/prompts/agent.py: LLM 네 겹 규약대로 프롬프트만 여기
- router/v1/agent/chat.py + features/agent/AgentChatDock.tsx(/sites 우하단)

모델에게 맡기지 않은 셋:
- 등급 — 응답 스키마에 칸 자체가 없다. 모델이 정하면 프롬프트에 끼어든 한 줄이
  확인 절차를 건너뛴다
- 결과 문구 — 도구가 만든다. 모델이 쓰면 하지 않은 일을 했다고 말할 수 있고
  사장님에게는 그 말이 사실로 보인다
- key — set_fact 의 key 는 업종 스키마가 최종 판정이다

확인(SEMI)은 실행하지 않고 되묻는다. 돌아온 confirm 값을 믿지 않고 도구는
레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다 — 확인 절차가 검증을
건너뛰는 구멍이 되면 안 된다.

값을 고치면 재발행 안내를 함께 낸다 — fact 는 바뀌어도 사이트는 안 바뀐다.

test_agent_runtime.py 17 passed(LLM 은 monkeypatch, 실제 모델 호출 없음).
전체 796 passed / 50 failed — 그 50건은 HEAD 에서도 동일한 기존 이슈.
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:18:00 +09:00

145 lines
6.1 KiB
Python

import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType
from common.logger import LOG
from common.utils.gtime import GTime
from config.server_configs import web_server_config
from scheduler import shutdown_scheduler, start_scheduler
import router.v1.auth.account
import router.v1.place.place
import router.v1.fact.fact
import router.v1.faq.faq
import router.v1.media.media
import router.v1.media.relay
import router.v1.job.job
import router.v1.site.site
import router.v1.site.showcase
import router.v1.site.booking_request
import router.v1.site.post
import router.v1.site.review
import router.v1.local.local
import router.v1.social.social
import router.v1.social.oauth
import router.v1.agent.kakao
import router.v1.agent.chat
API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup: 배치 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만)
start_scheduler()
yield
# shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리
shutdown_scheduler()
await DB_SESSION_MNG.dispose_all()
app = FastAPI(title="Web4Ai API", lifespan=lifespan)
# CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외).
#
# ★ client_url 은 쉼표로 여러 오리진을 받는다. 로컬 개발에서 vite 는 3000 이 막혀 있으면
# 3001, 3002… 로 옮겨 뜨는데(--port 는 희망값이지 고정이 아니다), 그때마다 서버 설정을
# 고치게 하면 원인이 CORS 라는 걸 알아내는 데만 반나절이 든다. 개발 포트 몇 개를 한 줄에 적어 둔다.
# 운영은 실제 도메인 하나만 적으면 된다.
def _origins(*values: str) -> list[str]:
seen: list[str] = []
for value in values:
for origin in value.split(","):
origin = origin.strip().rstrip("/")
if origin and origin not in seen:
seen.append(origin)
return seen
ALLOWED_ORIGINS = _origins(web_server_config.client_url, web_server_config.landing_url)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축.
app.add_middleware(GZipMiddleware, minimum_size=1000)
@app.middleware("http")
async def log_time(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
if request.url.path.startswith('/v1/social/'):
response.headers['Cache-Control'] = 'no-store'
response.headers['Referrer-Policy'] = 'no-referrer'
response.headers['X-Robots-Tag'] = 'noindex, nofollow'
elapsed = time.time() - start_time
# status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게).
LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s")
return response
@app.get(path="/healthz", responses={404: {"description": "Not found"}})
async def healthz():
return API_SERVER_START_TIME
@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}})
async def readyz(response: Response):
"""★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200),
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다."""
try:
async def _ping(s):
await s.execute(text("SELECT 1"))
return True
await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _ping)
return {"ok": True, "db": "up"}
except Exception as ex: # noqa: BLE001 — 준비 안 됐다는 것 자체가 이 엔드포인트의 응답이다
LOG.w(f"[readyz] DB 연결 확인 실패: {type(ex).__name__}: {ex}")
response.status_code = 503
return {"ok": False, "db": "down"}
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.place.place.router)
app.include_router(router.v1.fact.fact.router)
app.include_router(router.v1.faq.faq.router)
app.include_router(router.v1.media.media.router)
# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 —
# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py).
app.include_router(router.v1.media.relay.router)
app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router)
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
app.include_router(router.v1.site.showcase.router)
app.include_router(router.v1.site.booking_request.router)
app.include_router(router.v1.site.post.router)
app.include_router(router.v1.site.post.owner_router)
app.include_router(router.v1.site.review.router)
app.include_router(router.v1.local.local.router)
app.include_router(router.v1.local.local.weather_router)
app.include_router(router.v1.social.social.router)
app.include_router(router.v1.social.oauth.router)
app.include_router(router.v1.agent.kakao.router)
app.include_router(router.v1.agent.chat.router)