diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..fdf5294 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,27 @@ +.env +.env.* +.venv +.git +.pytest_cache +.ruff_cache +__pycache__ +graphify-out +gun +phases +tests +scripts +docs +.agents +.codex +.claude +.serena +.impeccable +*.pdf +*.pptx +*.docx +*.ipynb +/*.xlsx +data/route_cache.json +data/관광데이터랩 +data/한국문화관광연구원 +data/체류시간분석 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..3728dcc --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,50 @@ + +# Vercel 배포 + +프로젝트 루트를 배포한다. `pyproject.toml`의 `[tool.vercel]`에 ASGI 진입점 +`src.api.main:app`을 명시한다. `vercel.json`의 함수 설정도 `src/api/main.py`에 적용한다. +프레임워크는 FastAPI로 선택하고 Docker나 프론트엔드용 +빌드 명령, Output Directory를 별도로 지정하지 않는다. `.python-version`은 +Python 3.12를 지정한다. `package.json`의 pptxgenjs는 발표자료용이며 웹 빌드가 아니다. + +## 환경변수 + +Vercel 프로젝트 설정에 Production과 필요한 Preview 환경변수를 등록한다. +`.env`는 업로드하지 않는다. + +- `ENVIRONMENT=production` +- `API_AUTH_TOKEN`: 운영자가 생성한 접근 키. 웹 화면에서 첫 인증 오류 시 입력하며 메모리에만 보관한다. +- `ANTHROPIC_API_KEY`, `CLAUDE_MODEL`: 문서 파싱과 설명 생성에 사용할 키와 계정에서 사용 가능한 모델. +- `KAKAO_REST_API_KEY`, `KAKAO_MOBILITY_KEY`: 장소 검색과 도로 이동시간 계산. +- `TOUR_API_KEY`, `SEOUL_DATA_API_KEY`: 관광 데이터와 서울 혼잡도. +- `CORS_ALLOWED_ORIGINS`: 별도 프론트엔드를 둘 경우 정확한 HTTPS origin. 같은 도메인의 웹 화면은 CORS 설정 없이 요청 가능. + +Vercel 환경(`VERCEL=1`)에서는 환경 지정이 없으면 production이 기본값이다. +접근 키가 없으면 비용이 발생하는 API는 503으로 거부한다. 접근 키를 +프론트엔드 소스에 넣지 않는다. 이 방식은 운영자/초대 사용자용이며, 공개 서비스의 +회원 인증을 대체하지 않는다. 공개 운영에는 사용자 인증과 플랫폼 측 비용 제한이 필요하다. + +## 서버리스 대응 + +- 정적 화면과 데이터 파일을 Python 함수 번들에 포함한다. +- 이동시간 캐시는 `/tmp/qtrip/route_cache.json`에 쓴다. 인스턴스 간 공유하거나 영구 저장하지 않는다. +- 업로드는 4MiB까지 허용한다. 큰 문서는 분할하거나 텍스트 입력을 사용한다. +- 함수 실행시간은 300초로 요청한다. 배포 계정에서 적용되는 실행시간을 확인한다. +- Rate Limit은 프로세스별이다. 여러 인스턴스 전체를 제한하려면 Vercel 방화벽 등의 별도 제한이 필요하다. +- SciPy, scikit-learn, OR-Tools를 포함하므로 실제 Linux 빌드의 함수 용량을 반드시 확인한다. + 용량 초과 시 라이브러리를 무작정 제외하면 최적화 기능이 달라진다. 백엔드를 별도 + 컨테이너 서비스로 분리하는 방법을 검토한다. + +## 공개 전 확인 + +로컬 테스트 통과는 Vercel 배포 성공을 보장하지 않는다. Preview 빌드에서 +의존성 설치와 함수 크기, 콜드 스타트를 확인한 후 다음을 점검한다. + +1. `/`, `/static/landing.css`, `/static/qtrip-map.png`가 200인지 확인한다. +2. `/health`가 200인지 확인한다. 이 엔드포인트는 외부 API 연결 성공을 보증하지 않는다. +3. 키 없이 `/api/validate` 요청 시 401, 올바른 키와 잘못된 요청 본문은 422인지 확인한다. +4. 웹에서 일정 텍스트와 4MiB 이하 PDF를 각각 입력하고 검증 결과까지 확인한다. +5. 실제 이동시간 API와 Claude 호출 성공 여부를 Vercel 로그에서 확인한다. +6. 모바일 화면과 재방문 시 서비스 워커의 새 버전 적용을 확인한다. + +개발 확인: `python -m pytest tests/ -q`, `ruff check src/ tests/ app.py`. diff --git a/app.py b/app.py new file mode 100644 index 0000000..e800b87 --- /dev/null +++ b/app.py @@ -0,0 +1,5 @@ +"""Vercel ASGI entry point; also usable with uvicorn app:app.""" + +from src.api.main import app + +__all__ = ["app"] diff --git a/pyproject.toml b/pyproject.toml index 179068e..184afb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ dev = [ where = ["."] include = ["src*"] +[tool.vercel] +entrypoint = "src.api.main:app" + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/src/api/router.py b/src/api/router.py index 70f6a3a..c787e8f 100644 --- a/src/api/router.py +++ b/src/api/router.py @@ -48,7 +48,7 @@ # 일정표 업로드(PDF·이미지·복사 텍스트) 파싱 — Claude Vision + 쿼리 리라이팅. _EXTRACTOR = PlanExtractor.from_env() -_MAX_UPLOAD_BYTES = 15 * 1024 * 1024 # 15MB +_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 # Leave room for multipart overhead on Vercel. _ALLOWED_DOC_TYPES = { "application/pdf": "application/pdf", "image/png": "image/png", @@ -1532,11 +1532,11 @@ async def parse_document(file: UploadFile) -> ParsedPlanResponse: media_type = _ALLOWED_DOC_TYPES.get((file.content_type or "").lower()) if media_type is None: raise HTTPException(status_code=415, detail="PDF, PNG, JPG 파일만 지원합니다.") - data = await file.read() + data = await file.read(_MAX_UPLOAD_BYTES + 1) if not data: raise HTTPException(status_code=422, detail="빈 파일입니다.") if len(data) > _MAX_UPLOAD_BYTES: - raise HTTPException(status_code=413, detail="파일이 너무 큽니다 (최대 15MB).") + raise HTTPException(status_code=413, detail="파일이 너무 큽니다 (최대 4MB).") try: return await asyncio.to_thread(_EXTRACTOR.extract_from_document, data, media_type) except PlanExtractionError as e: diff --git a/src/api/static/api-client.js b/src/api/static/api-client.js new file mode 100644 index 0000000..fd67844 --- /dev/null +++ b/src/api/static/api-client.js @@ -0,0 +1,34 @@ +/* Deployment access key is entered by the operator, never embedded in assets. + Keep it in memory only; reload clears it. */ +(() => { + const originalFetch = window.fetch.bind(window); + const protectedPaths = new Set(['/api/validate', '/api/parse/text', '/api/parse/document']); + let accessToken = ''; + let askingForToken = false; + window.fetch = async (input, init = {}) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.origin !== location.origin || !protectedPaths.has(url.pathname)) { + return originalFetch(input, init); + } + const send = () => { + const headers = new Headers(init.headers || (input instanceof Request ? input.headers : undefined)); + if (accessToken) headers.set('Authorization', 'Bearer ' + accessToken); + return originalFetch(input instanceof Request ? input.clone() : input, {...init, headers}); + }; + let response = await send(); + if (response.status === 401 && !askingForToken) { + askingForToken = true; + try { + const entered = window.prompt('이 서비스는 접근 키가 필요합니다. 운영자에게 받은 접근 키를 입력해 주세요.'); + if (entered && entered.trim()) { + accessToken = entered.trim(); + response = await send(); + if (response.status === 401) accessToken = ''; + } + } finally { + askingForToken = false; + } + } + return response; + }; +})(); diff --git a/src/api/static/index.html b/src/api/static/index.html index c866579..ec19a44 100644 --- a/src/api/static/index.html +++ b/src/api/static/index.html @@ -1694,6 +1694,7 @@