Spaces:
Sleeping
Sleeping
File size: 8,151 Bytes
a089e74 60495e1 a089e74 b2b44fd 8c54dac a089e74 b0438db a089e74 568bbca a089e74 b0438db afca41c 60495e1 4504888 60495e1 a089e74 568bbca 67c9f93 568bbca 67c9f93 568bbca a089e74 b0438db a089e74 9b201b1 a089e74 afca41c a089e74 b0438db 5b2ffea a089e74 f4ede90 568bbca 67c9f93 f4ede90 fa87959 8c54dac a089e74 8c54dac a089e74 568bbca afca41c 1f58876 afca41c a089e74 b77fa8d a089e74 568bbca a089e74 67c9f93 fa87959 568bbca 5733dea 568bbca c0206de 568bbca 77d626c 5733dea 568bbca 723ba3b ce36f11 723ba3b ce36f11 723ba3b ce36f11 568bbca 0cf3b78 fa87959 568bbca a089e74 568bbca a089e74 568bbca a089e74 b2b44fd a089e74 568bbca a089e74 881407b b0438db 67c9f93 568bbca d72bce3 568bbca d72bce3 568bbca d72bce3 67c9f93 568bbca afca41c e143f0d 568bbca e143f0d f1138ff 568bbca b2b44fd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | 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
@asynccontextmanager
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
@app.get("/", include_in_schema=False)
def root():
return RedirectResponse("/docs")
@app.get("/api/health")
def health_check():
log.info("Health check OK")
return {"status": "ok"}
@app.get(
"/api/circles",
response_model=list[CircleSummary],
dependencies=[Depends(get_api_key)],
)
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"},
)
@app.get(
"/api/details",
response_model=CircleDetail,
dependencies=[Depends(get_api_key)],
)
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
@app.post(
"/api/search",
response_model=CircleIds,
dependencies=[Depends(get_api_key)],
)
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)
@app.post(
"/tasks/update",
response_model=TaskUpdateResponse,
dependencies=[Depends(get_api_key)],
)
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.")
|