From d775dd73d8c55237cb509d89568af51e68e12c5a Mon Sep 17 00:00:00 2001 From: overhyeon <105336318+overhyeon@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:06:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9E=AC=EA=B3=A0=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=EA=B0=80=20=ED=95=99=EC=83=9D=20JWT=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=8B=A4=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 18 +++++++++++++++--- tools.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index bd91e02..86d423d 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ import llm from search import search +import tools from tools import TOOLS load_dotenv() @@ -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 이벤트도 없이 연결만 끊는다. 클라이언트는 이유를 모른다. @@ -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"}, ) diff --git a/tools.py b/tools.py index b695ba7..82a60a0 100644 --- a/tools.py +++ b/tools.py @@ -12,15 +12,41 @@ """ 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). @@ -28,7 +54,7 @@ # # 품목과 수량은 test_data/billilge.json(골든셋 기준 데이터)과 맞췄다. # 그 파일은 예전 빌릴게 백엔드 모양(itemName, itemType)이라 직접 읽지 않는다. -# 학생 앱 API는 학생 로그인(JWT)이 있어야 해서 AI 서버가 부를 수 없다. 내부 API가 따로 필요하다. +# 학생 앱 API는 학생 로그인(JWT)이 있어야 한다. 그래서 백엔드가 중계할 때 학생 JWT를 같이 넘겨 준다(#69). _FAKE_RESPONSE = { "success": True, "code": "SUCCESS", @@ -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"] From 96bc4c7e8d2f7e25771737ea715bbc3f95103d35 Mon Sep 17 00:00:00 2001 From: overhyeon <105336318+overhyeon@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:06:49 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20X-User-Token=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EA=B3=BC=20BACKEND=5FBASE=5FURL=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++++ AGENTS.md | 1 + docs/guides/deploy.md | 5 ++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 6dbd0f5..01bebfe 100644 --- a/.env.example +++ b/.env.example @@ -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를 쓴다. 서버를 띄우기만 할 거면 없어도 된다. # diff --git a/AGENTS.md b/AGENTS.md index c26adce..2d40575 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 스트림 ``` diff --git a/docs/guides/deploy.md b/docs/guides/deploy.md index d2d13d9..e009bc2 100644 --- a/docs/guides/deploy.md +++ b/docs/guides/deploy.md @@ -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://: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에 머지하면 바로 배포된다.** 새 환경변수가 필요한 변경은 환경변수를 먼저 넣고 머지한다. @@ -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 .`가 되는지 먼저 확인한다 |