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.")