diff --git a/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py b/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py index ee0ce55bc..6ada34528 100644 --- a/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py +++ b/B01_Dashboard/B01_Dashboard_Knowledge_Chat.py @@ -1,5 +1,6 @@ """지식DB Markdown 검색과 Gemini 설명. 제외 폴더는 읽지 않는다.""" +import asyncio import os import re from functools import lru_cache @@ -150,21 +151,44 @@ async def ask_knowledge_chat(payload: ChatRequest, _session=Depends(verify_sessi ) try: async with httpx.AsyncClient(timeout=30) as client: - response = await client.post( - f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent", - headers={"x-goog-api-key": key}, - json={"contents": [{"parts": [{"text": prompt}]}]}, - ) + for attempt in range(3): + response = await client.post( + f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent", + headers={"x-goog-api-key": key}, + json={"contents": [{"parts": [{"text": prompt}]}]}, + ) + if response.status_code != 503 or attempt == 2: + break + await asyncio.sleep(attempt + 1) + if response.status_code == 503: + raise HTTPException( + status_code=503, + detail="Gemini 모델에 요청이 몰려 있습니다. 잠시 후 다시 질문해 주세요.", + ) response.raise_for_status() answer = "".join( part.get("text", "") for candidate in response.json().get("candidates", []) for part in candidate.get("content", {}).get("parts", []) ).strip() - except (httpx.HTTPError, ValueError) as exc: + except httpx.HTTPStatusError as exc: + detail = { + 400: "Gemini 요청 형식이 올바르지 않습니다.", + 401: "Gemini API 키를 확인해 주세요.", + 403: "Gemini API 키의 사용 권한을 확인해 주세요.", + 429: "Gemini API 사용 한도에 도달했습니다. 잠시 후 다시 시도해 주세요.", + }.get(exc.response.status_code, "Gemini 요청에 실패했습니다. 잠시 후 다시 시도해 주세요.") + raise HTTPException(status_code=502, detail=detail) from exc + except httpx.TimeoutException as exc: + raise HTTPException( + status_code=504, + detail="Gemini 3.8 Flash 응답이 지연되어 시간 초과됐습니다. " + "잠시 후 다시 시도해 주세요.", + ) from exc + except (httpx.RequestError, ValueError) as exc: raise HTTPException( status_code=502, - detail="Gemini 응답을 받지 못했습니다. API 키와 사용 한도를 확인해 주세요.", + detail="Gemini 서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.", ) from exc if not answer: raise HTTPException(status_code=502, detail="Gemini가 답변을 생성하지 못했습니다.") diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 40cb0ec38..bb496d819 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -310,3 +310,14 @@ B 방향이 맞겠네. 새 프로젝트를 만드는건 문제없어보여. 이 (10-6 사용자) 다음으로 좌측 패널에서 서브 컨테이너의 항목을 선택/변경/생성 할 경우에 횡단도에서 선택한것처럼 이동되어야할것같아. + +(10-6 사용자) +추가로 배관의 도수로/돌붙임의 경우에는 복수 값이 아니야. 생성이 되었다면 추가 버튼이 없어져야함. + +(10-6 사용자) +1. 종단도의 구조물 알약의 이름이 구조물군의 종류 값과 매칭이 안됨. 같은 값을 보게 개선. +2. 종단도에서 배관이 선택한 상태에서 다른 지점에 옹벽을 추가하기 위해 마우스 오른쪽 버튼을 클릭후 사면안정을 눌렀더니 하위 리스트는 안보이고 리셋된 상태가 됨. 확인 + +(10-6 사용자) +1. 도수로 돌붙임에는 컨테이너 제목에 1과 2 같은 정보는 필요없어보임. +2. 구조물의 높이 선택에 따라 제약이 발생한 경우 토스트 메세지 발생. 이것도 흙막이, 집수정, 옹벽, 관 보호공에서 높이 제약을 풀어줘. 사용자가 선택하게 할거야. 이건 이전에 요청했던 내용이야. diff --git a/resources/tester/test_b01_knowledge_chat.py b/resources/tester/test_b01_knowledge_chat.py index 1860ba666..fb647702e 100644 --- a/resources/tester/test_b01_knowledge_chat.py +++ b/resources/tester/test_b01_knowledge_chat.py @@ -1,5 +1,6 @@ import asyncio +import httpx import pytest from fastapi import HTTPException @@ -35,6 +36,60 @@ def test_missing_api_key_is_reported_before_search(monkeypatch): assert error.value.status_code == 503 +def test_busy_gemini_is_retried_and_reported(monkeypatch): + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + monkeypatch.setattr(chat, "search", lambda _question: []) + calls = [] + + class Client: + def __init__(self, timeout): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + pass + + async def post(self, *_args, **_kwargs): + calls.append(1) + return type("Response", (), {"status_code": 503})() + + async def no_wait(_seconds): + pass + + monkeypatch.setattr(chat.httpx, "AsyncClient", Client) + monkeypatch.setattr(chat.asyncio, "sleep", no_wait) + with pytest.raises(HTTPException) as error: + asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="일위대가가 뭐야"), {})) + assert len(calls) == 3 + assert "요청이 몰려" in error.value.detail + + +def test_gemini_timeout_is_reported(monkeypatch): + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + monkeypatch.setattr(chat, "search", lambda _question: []) + + class Client: + def __init__(self, timeout): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + pass + + async def post(self, *_args, **_kwargs): + raise httpx.ReadTimeout("timed out") + + monkeypatch.setattr(chat.httpx, "AsyncClient", Client) + with pytest.raises(HTTPException) as error: + asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="일위대가가 뭐야"), {})) + assert error.value.status_code == 504 + assert "시간 초과" in error.value.detail + + def test_chat_searches_all_knowledge_markdown_except_excluded_folders(monkeypatch, tmp_path): public = tmp_path / "original" / "법률" guide = tmp_path / "technical_info" @@ -57,6 +112,8 @@ def test_chat_searches_all_knowledge_markdown_except_excluded_folders(monkeypatc sent = {} class Response: + status_code = 200 + def raise_for_status(self): pass