Spaces:
Sleeping
Sleeping
| import os | |
| import sys | |
| import json | |
| import gzip | |
| import subprocess | |
| from pathlib import Path | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, Response, Depends, HTTPException, Security, Query | |
| from fastapi.responses import RedirectResponse, JSONResponse | |
| from fastapi.security import APIKeyHeader | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from dotenv import load_dotenv | |
| from posthog import Posthog | |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) | |
| sys.path.append(os.path.dirname(__file__)) | |
| from utils.logger import setup_logger | |
| from schemas.circles import Image, CircleSummary, CircleDetail, CircleIds | |
| from .repositories.circles_repository import CirclesRepository | |
| from .search.engine import SearchEngine | |
| from .services.circles_service import CirclesService | |
| log = setup_logger(__name__) | |
| load_dotenv() | |
| # --- Auth --- | |
| API_KEY_NAME = "X-API-KEY" | |
| APISECRETKEY = os.getenv("API_SECRET_KEY") | |
| api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=True) | |
| POSTHOG_PROJECT_API_KEY = os.getenv("POSTHOG_PROJECT_API_KEY") | |
| async def get_api_key(key: str = Security(api_key_header)): | |
| """APIキーを検証する依存関係""" | |
| if not APISECRETKEY or key != APISECRETKEY: | |
| raise HTTPException(status_code=403, detail="Could not validate credentials.") | |
| return key | |
| # --- App --- | |
| engine = SearchEngine() | |
| circles_repository = CirclesRepository() | |
| circles_cache_ttl = int(os.getenv("CIRCLES_CACHE_TTL", "300")) | |
| circles_service = CirclesService( | |
| circles_repository, cache_ttl_seconds=circles_cache_ttl | |
| ) | |
| def _select_primary_image(images: list[Image]) -> Image | None: | |
| primary: Image | None = None | |
| for image in images: | |
| if image.order == 0: | |
| primary = image | |
| if primary is not None: | |
| return primary | |
| if images: | |
| return images[0] | |
| return None | |
| async def lifespan(app: FastAPI): | |
| """アプリケーションの起動時と終了時に実行されるコード""" | |
| # --- 起動時処理 --- | |
| log.info("Initializing search engine and loading assets...") | |
| engine.initialize() | |
| log.info("Initialization complete.") | |
| yield | |
| # --- 終了時処理 --- | |
| log.info("Shutting down search engine...") | |
| app = FastAPI(lifespan=lifespan) | |
| # CORS設定 | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[ | |
| "https://circle-search-26.pages.dev", | |
| "http://localhost:3000", | |
| ], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # PostHog(ログ分析) | |
| if POSTHOG_PROJECT_API_KEY: | |
| posthog = Posthog( | |
| project_api_key=POSTHOG_PROJECT_API_KEY, host="https://us.i.posthog.com" | |
| ) | |
| else: | |
| log.warning("POSTHOG_PROJECT_API_KEY is not set. PostHog is disabled.") | |
| posthog = None | |
| def root(): | |
| return RedirectResponse("/docs") | |
| def health_check(): | |
| log.info("Health check OK") | |
| return {"status": "ok"} | |
| def get_summary_data(): | |
| # ここで必要なフィールドのみ抽出して返す | |
| summaries_payload: list[dict[str, object]] = [] | |
| for circle in circles_service.list_circles(): | |
| # 画像はcircle.mainImageを使用、なければimagesから選択 | |
| image = circle.mainImage | |
| if image is None and circle.images: | |
| image = _select_primary_image(circle.images) | |
| summary = CircleSummary( | |
| circleId=circle.circleId, | |
| circleName=circle.circleName, | |
| circleNameKana=circle.circleNameKana, | |
| projectId=circle.projectId, | |
| projectName=circle.projectName, | |
| genre=circle.genre, | |
| areaCode=circle.areaCode, | |
| pamphletNumber=circle.pamphletNumber, | |
| shortIntro=circle.shortIntro, | |
| detailDescription=circle.detailDescription, | |
| mainImage=image, | |
| memberCount=circle.memberCount, | |
| memberNote=circle.memberNote, | |
| annualFee=circle.annualFee, | |
| otherCosts=circle.otherCosts, | |
| activityFrequency=circle.activityFrequency, | |
| activityFrequencyNote=circle.activityFrequencyNote, | |
| activityLocation=circle.activityLocation, | |
| isArchived=circle.isArchived, | |
| ) | |
| summaries_payload.append(summary.model_dump(mode="json")) | |
| content = json.dumps(summaries_payload, ensure_ascii=False).encode("utf-8") | |
| log.info(f"Circle summaries fetched: {len(summaries_payload)} items") | |
| return Response( | |
| content=gzip.compress(content), | |
| headers={"Content-Encoding": "gzip", "Content-Type": "application/json"}, | |
| ) | |
| def get_circle_detail(circleId: str = Query(..., description="取得したいサークルのID")): | |
| c = circles_service.get_circle(circleId) | |
| if not c: | |
| raise HTTPException(status_code=404, detail="Circle not found") | |
| log.info(f"Circle detail fetched: {circleId}") | |
| return CircleDetail(**c.model_dump(include=set(CircleDetail.model_fields.keys()))) | |
| class SearchRequest(BaseModel): | |
| query: str | |
| debug: bool = False | |
| class TaskUpdateResponse(BaseModel): | |
| message: str | |
| def search(request: SearchRequest): | |
| if not request.query: | |
| raise HTTPException(status_code=400, detail="Query cannot be empty") | |
| result = engine.search(request.query, debug=request.debug) | |
| if request.debug: | |
| pairs, diag = result | |
| ids = [cid for cid, _ in pairs] | |
| return JSONResponse( | |
| content={ | |
| "circleIds": ids, | |
| "scores": [ | |
| {"circleId": cid, "score": float(score)} for cid, score in pairs | |
| ], | |
| "details": diag.get("details", []), | |
| } | |
| ) | |
| pairs = result | |
| ids = [cid for cid, _ in pairs] | |
| # ログ送信 | |
| GET_LOGS = os.getenv("GET_LOGS", "false").lower() == "true" | |
| if GET_LOGS and posthog: | |
| try: | |
| posthog.capture( | |
| event="circles searched", | |
| properties={ | |
| "query": request.query, | |
| "result_count": len(ids), | |
| "$process_person_profile": False, | |
| }, | |
| ) | |
| except Exception: | |
| pass | |
| log.info(f'Search query="{request.query}" => {len(ids)} results') | |
| return CircleIds(circleIds=ids) | |
| def update_tasks(): | |
| project_root = Path(__file__).resolve().parents[1] | |
| script_path = project_root / "scripts" / "build_all.py" | |
| if not script_path.exists(): | |
| log.error("Requested update script not found: %s", script_path) | |
| raise HTTPException(status_code=500, detail="Update script not found.") | |
| try: | |
| result = subprocess.run( | |
| [sys.executable, str(script_path)], | |
| check=True, | |
| capture_output=True, | |
| text=True, | |
| cwd=str(project_root), | |
| ) | |
| except subprocess.CalledProcessError as exc: | |
| stdout = exc.stdout.strip() if exc.stdout else "" | |
| stderr = exc.stderr.strip() if exc.stderr else "" | |
| if stdout: | |
| log.error("build_all.py stdout:\n%s", stdout) | |
| if stderr: | |
| log.error("build_all.py stderr:\n%s", stderr) | |
| raise HTTPException( | |
| status_code=500, | |
| detail="Data update failed while running build_all.py.", | |
| ) from exc | |
| stdout = result.stdout.strip() if result.stdout else "" | |
| stderr = result.stderr.strip() if result.stderr else "" | |
| if stdout: | |
| log.info("build_all.py stdout:\n%s", stdout) | |
| if stderr: | |
| log.warning("build_all.py stderr:\n%s", stderr) | |
| return TaskUpdateResponse(message="Data update completed.") | |