Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ KMU_AI_API_KEY=
# 게이트웨이 주소. 바꿀 일이 없으면 비워 둔다(기본값이 들어간다).
# LLM_BASE_URL=https://ai.cs.kookmin.ac.kr/v1

# ─── 백엔드 연결 (재고 같은 실시간 조회) ──────────────────────
# 백엔드 주소. 도구가 학생 JWT(X-User-Token)로 백엔드 학생 API를 조회한다.
# 비워 두면 tools.py의 가짜 응답을 쓴다. 로컬 개발과 평가(eval.py)는 비워 둔다(학생 토큰이 없다).
# ENV=prod 인데 비어 있으면 서버가 뜨지 않는다.
BACKEND_BASE_URL=

# ─── 평가용 Gemini (eval.py만 쓴다, 서버는 쓰지 않는다) ─────────
# eval.py의 채점기와 예전 답변 경로가 Gemini를 쓴다. 서버를 띄우기만 할 거면 없어도 된다.
#
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ GET /health

POST /chat
헤더: X-Internal-Token, X-User-Id (토큰 불일치 401, 사용자 ID가 양의 정수가 아니면 400)
X-User-Token (학생 JWT. 도구가 백엔드 학생 API를 조회할 때만 쓴다. 없으면 실시간 조회가 "확인할 수 없음"으로 답한다)
본문: { "messages": [ { "role": "user" | "model", "text": str } ] }
응답: SSE 스트림
```
Expand Down
18 changes: 15 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import llm
from search import search
import tools
from tools import TOOLS

load_dotenv()
Expand Down Expand Up @@ -199,7 +200,11 @@ def build_system_instruction(docs: list[dict], today: str | None = None) -> str:
)


async def relay(req: Request, messages: list[Msg]):
async def relay(req: Request, messages: list[Msg], user_token: str | None):
# 학생 JWT를 이 요청 동안만 도구가 볼 수 있게 넣어 둔다(tools.user_token 설명 참고).
# 여기(스트림을 실제로 만드는 곳)에서 넣어야 도구가 호출될 때 같은 값이 보인다.
tools.user_token.set(user_token)

# 검색부터 전부 try 안에서 한다.
# StreamingResponse는 이 함수가 돌기 전에 이미 200과 헤더를 보냈기 때문에,
# 여기서 밖으로 튀는 예외는 error 이벤트도 없이 연결만 끊는다. 클라이언트는 이유를 모른다.
Expand Down Expand Up @@ -238,11 +243,18 @@ async def relay(req: Request, messages: list[Msg]):


@app.post("/chat")
async def chat(req: Request, body: ChatReq, user_id: int | None = Depends(verify_backend)):
async def chat(
req: Request,
body: ChatReq,
user_id: int | None = Depends(verify_backend),
x_user_token: str | None = Header(default=None),
):
# user_id는 아직 쓰지 않는다. 개인 데이터 조회 도구(#52)가 이 값으로만 조회한다.
# 모델이 정한 값을 쓰면 다른 학생 정보를 물어 꺼낼 수 있기 때문이다.
# x_user_token: 백엔드가 넘겨준 학생 JWT. 도구가 백엔드 학생 API를 조회할 때 쓴다(#69).
# 검사는 백엔드가 이미 끝냈다. 여기서는 그대로 전달만 하고, 로그에 남기지 않는다.
return StreamingResponse(
relay(req, body.messages),
relay(req, body.messages, x_user_token),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
5 changes: 4 additions & 1 deletion docs/guides/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ Coolify 리소스의 Environment Variables에 넣는다. 저장소에는 올리
| `INTERNAL_TOKEN` | 새로 만든 값. 백엔드의 `STREAM_AI_INTERNAL_TOKEN`과 **같은 값** |
| `LLM_MODEL` | `gpt-oss-120b` (모델 선정 근거: `eval/model-comparison.md`) |
| `KMU_AI_API_KEY` | 학교 AI 게이트웨이 키. **실험용과 다른 운영 전용 키**를 쓴다 |
| `BACKEND_BASE_URL` | `http://<Coolify가 정한 백엔드 컨테이너 이름>:8080`. 재고 같은 실시간 조회에 쓴다(#69) |

토큰 만들기: `python -c "import secrets; print(secrets.token_urlsafe(32))"`

`ENV=prod`인데 `INTERNAL_TOKEN`이 비어 있으면 서버가 뜨지 않는다. `LLM_MODEL`이나 `KMU_AI_API_KEY`가 없어도 뜨지 않는다.
`ENV=prod`인데 `INTERNAL_TOKEN`이 비어 있으면 서버가 뜨지 않는다. `LLM_MODEL`, `KMU_AI_API_KEY`, `BACKEND_BASE_URL`이 없어도 뜨지 않는다.
일부러 그렇게 만들었다. 서버가 떠 있는데 질문마다 실패하는 것보다 원인을 찾기 쉽다.

**main에 머지하면 바로 배포된다.** 새 환경변수가 필요한 변경은 환경변수를 먼저 넣고 머지한다.
Expand Down Expand Up @@ -89,6 +90,8 @@ curl -s -o /dev/null -w "%{http_code}\n" https://ai.cs.kookmin.ac.kr/v1/models
| 컨테이너가 바로 죽음, 로그에 `LLM_MODEL 또는 KMU_AI_API_KEY가 없습니다` | 2번 환경변수 누락 |
| 질문하면 `error` 이벤트, 로그에 `401 Unauthorized` (`ai.cs.kookmin.ac.kr`) | 게이트웨이 키가 틀렸거나 막혔다. 값 끝에 다른 글자가 붙지 않았는지, 게이트웨이 관리 화면에서 키 상태·남은 크레딧을 본다 |
| 질문하면 `error` 이벤트, 로그에 `ConnectError` 또는 시간 초과 | 3번. Coolify 서버에서 게이트웨이로 나가지 못한다 |
| 컨테이너가 바로 죽음, 로그에 `BACKEND_BASE_URL이 없습니다` | 2번 환경변수 누락 |
| 재고를 물으면 "확인할 수 없어요", 로그에 `재고 조회 실패` | `BACKEND_BASE_URL`이 틀렸거나, 백엔드가 학생 JWT(`X-User-Token`)를 넘기지 않는다(stream-server #309 배포 여부) |
| 백엔드 로그에 `status=401` | 양쪽 토큰이 다르다 |
| 백엔드 로그에 연결 실패 | 같은 네트워크가 아니거나 `STREAM_AI_BASE_URL`의 컨테이너 이름이 틀렸다 |
| Coolify 빌드 실패 | Coolify 빌드 로그를 본다. 로컬에서 `docker build .`가 되는지 먼저 확인한다 |
Expand Down
54 changes: 48 additions & 6 deletions tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,49 @@
"""

import logging
import os
from contextvars import ContextVar
from datetime import datetime
from zoneinfo import ZoneInfo

import httpx
from dotenv import load_dotenv

load_dotenv()

logger = logging.getLogger(__name__)

# ─────────────────────────────────────────────────────────────
# 백엔드 연결 (#69)
# ─────────────────────────────────────────────────────────────
# 백엔드 주소. 배포에서는 같은 도커 네트워크 안의 내부 주소를 넣는다(예: http://<백엔드 컨테이너 이름>:8080).
# 비어 있으면 아래 가짜 응답을 쓴다. 로컬 개발과 평가(eval.py)는 학생 토큰이 없어서 가짜 응답으로 돈다.
BACKEND_BASE_URL = os.getenv("BACKEND_BASE_URL", "").rstrip("/")
if os.getenv("ENV", "dev") == "prod" and not BACKEND_BASE_URL:
# 배포에서 빠뜨리면 학생에게 가짜 재고를 진짜처럼 보여주게 된다. 조용히 뜨지 않고 멈춘다.
raise RuntimeError("ENV=prod 인데 환경변수 BACKEND_BASE_URL이 없습니다. 백엔드 내부 주소를 넣으세요.")

# 백엔드가 느리면 답변 전체가 기다린다. 재고 하나 조회에 5초 넘게 걸리면 실패로 보고 안내 문구로 답한다.
BACKEND_TIMEOUT_SECONDS = 5

# 지금 질문한 학생의 JWT. app.py가 요청마다 넣고, 도구 함수 안에서만 꺼내 쓴다.
# ContextVar: 요청마다 따로 보관되는 변수다. 여러 학생이 동시에 질문해도 서로의 토큰이 섞이지 않는다.
# 함수 인자로 받지 않는 이유: 인자는 모델이 채운다. 모델이 토큰을 보면 답변으로 샐 수 있고, 다른 값을 넣을 수도 있다.
# 이 토큰은 학생의 쓰기 권한까지 갖고 있다. 그래서 이 파일에는 조회(GET) 도구만 둔다(로드맵 「학생 토큰 전달」).
user_token: ContextVar[str | None] = ContextVar("user_token", default=None)

TYPE_LABELS = {"RENTAL": "대여품", "CONSUMABLE": "소모품"}

# ─────────────────────────────────────────────────────────────
# 가짜 응답 — 백엔드 AI 서버 전용 재고 API가 생기기 전까지 쓴다
# 가짜 응답 — 백엔드 주소가 없을 때(로컬 개발·평가) 쓴다
# ─────────────────────────────────────────────────────────────
# 모양은 stream-server의 `GET /v1/app/billilge/items` 실제 응답과 같다
# (ApiResponse → CursorSliceResponse → ItemListItemResponse).
# 그래서 실제 API가 생기면 `_fetch_items`만 HTTP 호출로 바꾸고 나머지는 그대로 둔다.
#
# 품목과 수량은 test_data/billilge.json(골든셋 기준 데이터)과 맞췄다.
# 그 파일은 예전 빌릴게 백엔드 모양(itemName, itemType)이라 직접 읽지 않는다.
# 학생 앱 API는 학생 로그인(JWT)이 있어야 해서 AI 서버가 부를 수 없다. 내부 API가 따로 필요하다.
# 학생 앱 API는 학생 로그인(JWT)이 있어야 한다. 그래서 백엔드가 중계할 때 학생 JWT를 같이 넘겨 준다(#69).
_FAKE_RESPONSE = {
"success": True,
"code": "SUCCESS",
Expand All @@ -50,12 +76,28 @@


def _fetch_items() -> list[dict]:
"""백엔드에서 물품 목록을 받아 온다. 지금은 가짜 응답을 돌려준다.
"""백엔드에서 물품 목록을 받아 온다. 백엔드 주소가 없으면 가짜 응답을 돌려준다.

학생 앱이 쓰는 `GET /v1/app/billilge/items`를 그 학생의 JWT로 부른다.
백엔드 목록은 이름순이고 한 번에 최대 100개다. 품목이 100개를 넘을 일은 당분간 없으므로 첫 페이지만 받는다.

실제 API로 바꿀 때 이 함수만 고친다(#50). 백엔드 목록은 이름순이고 한 번에 최대 100개다.
품목이 100개를 넘을 일은 당분간 없으므로 첫 페이지만 받는다.
실패하면 예외를 던진다. 부르는 쪽(`get_item_stock`)이 잡아서 "확인할 수 없다"는 안내로 바꾼다.
"""
response = _FAKE_RESPONSE
if not BACKEND_BASE_URL:
response = _FAKE_RESPONSE
else:
token = user_token.get()
if not token:
raise RuntimeError("학생 토큰(X-User-Token)이 없어 재고를 조회할 수 없습니다")
# 토큰은 로그에 남기지 않는다. 예외 메시지에도 주소만 들어가고 헤더는 들어가지 않는다.
r = httpx.get(
f"{BACKEND_BASE_URL}/v1/app/billilge/items",
params={"size": 100},
headers={"Authorization": f"Bearer {token}"},
timeout=BACKEND_TIMEOUT_SECONDS,
)
r.raise_for_status()
response = r.json()
if not response["success"]:
raise RuntimeError(f"재고 API 실패: {response['code']}")
return response["data"]["content"]
Expand Down