yukikase commited on
Commit
568bbca
·
1 Parent(s): ccb18c8

feat: サークル検索API実装(BM25F + fastText対応)

Browse files

- サークルデータ取得API(/api/circles)の追加
- サークル詳細取得API(/api/details)の追加
- 検索API(/api/search)のBM25F + fastText対応
- CirclesRepository, CirclesServiceの実装
- CORSミドルウェアの設定
- スキーマ定義(circles.py)の追加
- ビルドスクリプトの更新

app/main.py CHANGED
@@ -9,6 +9,7 @@ from contextlib import asynccontextmanager
9
  from fastapi import FastAPI, Response, Depends, HTTPException, Security, Query
10
  from fastapi.responses import RedirectResponse, JSONResponse
11
  from fastapi.security import APIKeyHeader
 
12
  from pydantic import BaseModel
13
 
14
  from dotenv import load_dotenv
@@ -19,11 +20,11 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
19
  sys.path.append(os.path.dirname(__file__))
20
 
21
  from utils.logger import setup_logger
22
- from schemas.projects import Image, ProjectSummary, ProjectDetail, ProjectIds
23
 
24
- from .repositories.projects_repository import ProjectsRepository
25
  from .search.engine import SearchEngine
26
- from .services.projects_service import ProjectsService
27
 
28
  log = setup_logger(__name__)
29
 
@@ -46,10 +47,10 @@ async def get_api_key(key: str = Security(api_key_header)):
46
 
47
  # --- App ---
48
  engine = SearchEngine()
49
- projects_repository = ProjectsRepository()
50
- projects_cache_ttl = int(os.getenv("PROJECTS_CACHE_TTL", "300"))
51
- projects_service = ProjectsService(
52
- projects_repository, cache_ttl_seconds=projects_cache_ttl
53
  )
54
 
55
 
@@ -80,6 +81,18 @@ async def lifespan(app: FastAPI):
80
 
81
  app = FastAPI(lifespan=lifespan)
82
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  # PostHog(ログ分析)
84
  if POSTHOG_PROJECT_API_KEY:
85
  posthog = Posthog(
@@ -102,40 +115,42 @@ def health_check():
102
 
103
 
104
  @app.get(
105
- "/api/projects",
106
- response_model=list[ProjectSummary],
107
  dependencies=[Depends(get_api_key)],
108
  )
109
  def get_summary_data():
110
  # ここで必要なフィールドのみ抽出して返す
111
  summaries_payload: list[dict[str, object]] = []
112
- for project in projects_service.list_projects():
113
  # 画像はprimaryの1枚だけ返す
114
- image = _select_primary_image(project.images)
115
- if not image:
116
- log.error("企画ID %s の画像選択に失敗しました", project.projectId)
117
-
118
- summary = ProjectSummary(
119
- projectId=project.projectId,
120
- circleName=project.circleName,
121
- name=project.name,
122
- projectType=project.projectType,
123
- category=project.category,
124
- day1=project.day1,
125
- day2=project.day2,
126
- day3=project.day3,
127
- location=project.location,
128
- description=project.description,
129
- prSummary=project.prSummary,
130
- remark=project.remark,
131
- image=image,
132
- tags=project.tags,
133
- isArchived=project.isArchived,
 
 
134
  )
135
  summaries_payload.append(summary.model_dump(mode="json"))
136
 
137
  content = json.dumps(summaries_payload, ensure_ascii=False).encode("utf-8")
138
- log.info(f"Project summaries fetched: {len(summaries_payload)} items")
139
  return Response(
140
  content=gzip.compress(content),
141
  headers={"Content-Encoding": "gzip", "Content-Type": "application/json"},
@@ -144,15 +159,15 @@ def get_summary_data():
144
 
145
  @app.get(
146
  "/api/details",
147
- response_model=ProjectDetail,
148
  dependencies=[Depends(get_api_key)],
149
  )
150
- def get_project_detail(projectId: str = Query(..., description="取得したい企画のID")):
151
- p = projects_service.get_project(projectId)
152
- if not p:
153
- raise HTTPException(status_code=404, detail="Project not found")
154
- log.info(f"Project detail fetched: {projectId}")
155
- return ProjectDetail(**p.model_dump(include=set(ProjectDetail.model_fields.keys())))
156
 
157
 
158
  class SearchRequest(BaseModel):
@@ -166,7 +181,7 @@ class TaskUpdateResponse(BaseModel):
166
 
167
  @app.post(
168
  "/api/search",
169
- response_model=ProjectIds,
170
  dependencies=[Depends(get_api_key)],
171
  )
172
  def search(request: SearchRequest):
@@ -176,25 +191,25 @@ def search(request: SearchRequest):
176
  result = engine.search(request.query, debug=request.debug)
177
  if request.debug:
178
  pairs, diag = result
179
- ids = [pid for pid, _ in pairs]
180
  return JSONResponse(
181
  content={
182
- "projectIds": ids,
183
  "scores": [
184
- {"projectId": pid, "score": float(score)} for pid, score in pairs
185
  ],
186
  "details": diag.get("details", []),
187
  }
188
  )
189
  pairs = result
190
- ids = [pid for pid, _ in pairs]
191
 
192
  # ログ送信
193
  GET_LOGS = os.getenv("GET_LOGS", "false").lower() == "true"
194
  if GET_LOGS and posthog:
195
  try:
196
  posthog.capture(
197
- event="projects searched",
198
  properties={
199
  "query": request.query,
200
  "result_count": len(ids),
@@ -204,7 +219,7 @@ def search(request: SearchRequest):
204
  except Exception:
205
  pass
206
  log.info(f'Search query="{request.query}" => {len(ids)} results')
207
- return ProjectIds(projectIds=ids)
208
 
209
 
210
  @app.post(
 
9
  from fastapi import FastAPI, Response, Depends, HTTPException, Security, Query
10
  from fastapi.responses import RedirectResponse, JSONResponse
11
  from fastapi.security import APIKeyHeader
12
+ from fastapi.middleware.cors import CORSMiddleware
13
  from pydantic import BaseModel
14
 
15
  from dotenv import load_dotenv
 
20
  sys.path.append(os.path.dirname(__file__))
21
 
22
  from utils.logger import setup_logger
23
+ from schemas.circles import Image, CircleSummary, CircleDetail, CircleIds
24
 
25
+ from .repositories.circles_repository import CirclesRepository
26
  from .search.engine import SearchEngine
27
+ from .services.circles_service import CirclesService
28
 
29
  log = setup_logger(__name__)
30
 
 
47
 
48
  # --- App ---
49
  engine = SearchEngine()
50
+ circles_repository = CirclesRepository()
51
+ circles_cache_ttl = int(os.getenv("CIRCLES_CACHE_TTL", "300"))
52
+ circles_service = CirclesService(
53
+ circles_repository, cache_ttl_seconds=circles_cache_ttl
54
  )
55
 
56
 
 
81
 
82
  app = FastAPI(lifespan=lifespan)
83
 
84
+ # CORS設定
85
+ app.add_middleware(
86
+ CORSMiddleware,
87
+ allow_origins=[
88
+ "https://circle-search-26.pages.dev",
89
+ "http://localhost:3000",
90
+ ],
91
+ allow_credentials=True,
92
+ allow_methods=["*"],
93
+ allow_headers=["*"],
94
+ )
95
+
96
  # PostHog(ログ分析)
97
  if POSTHOG_PROJECT_API_KEY:
98
  posthog = Posthog(
 
115
 
116
 
117
  @app.get(
118
+ "/api/circles",
119
+ response_model=list[CircleSummary],
120
  dependencies=[Depends(get_api_key)],
121
  )
122
  def get_summary_data():
123
  # ここで必要なフィールドのみ抽出して返す
124
  summaries_payload: list[dict[str, object]] = []
125
+ for circle in circles_service.list_circles():
126
  # 画像はprimaryの1枚だけ返す
127
+ image = _select_primary_image(circle.images) if circle.images else None
128
+
129
+ summary = CircleSummary(
130
+ circleId=circle.circleId,
131
+ circleName=circle.circleName,
132
+ circleNameKana=circle.circleNameKana,
133
+ isOfficial=circle.isOfficial,
134
+ isIntercollegiate=circle.isIntercollegiate,
135
+ projectId=circle.projectId,
136
+ projectName=circle.projectName,
137
+ mainCategory=circle.mainCategory,
138
+ subCategory=circle.subCategory,
139
+ category=circle.category,
140
+ areaCode=circle.areaCode,
141
+ pamphletNumber=circle.pamphletNumber,
142
+ prSummary=circle.prSummary,
143
+ description=circle.description,
144
+ mainImage=image,
145
+ memberCount=circle.memberCount,
146
+ tags=circle.tags,
147
+ featureTags=circle.featureTags,
148
+ isArchived=circle.isArchived,
149
  )
150
  summaries_payload.append(summary.model_dump(mode="json"))
151
 
152
  content = json.dumps(summaries_payload, ensure_ascii=False).encode("utf-8")
153
+ log.info(f"Circle summaries fetched: {len(summaries_payload)} items")
154
  return Response(
155
  content=gzip.compress(content),
156
  headers={"Content-Encoding": "gzip", "Content-Type": "application/json"},
 
159
 
160
  @app.get(
161
  "/api/details",
162
+ response_model=CircleDetail,
163
  dependencies=[Depends(get_api_key)],
164
  )
165
+ def get_circle_detail(circleId: str = Query(..., description="取得したいサークルのID")):
166
+ c = circles_service.get_circle(circleId)
167
+ if not c:
168
+ raise HTTPException(status_code=404, detail="Circle not found")
169
+ log.info(f"Circle detail fetched: {circleId}")
170
+ return CircleDetail(**c.model_dump(include=set(CircleDetail.model_fields.keys())))
171
 
172
 
173
  class SearchRequest(BaseModel):
 
181
 
182
  @app.post(
183
  "/api/search",
184
+ response_model=CircleIds,
185
  dependencies=[Depends(get_api_key)],
186
  )
187
  def search(request: SearchRequest):
 
191
  result = engine.search(request.query, debug=request.debug)
192
  if request.debug:
193
  pairs, diag = result
194
+ ids = [cid for cid, _ in pairs]
195
  return JSONResponse(
196
  content={
197
+ "circleIds": ids,
198
  "scores": [
199
+ {"circleId": cid, "score": float(score)} for cid, score in pairs
200
  ],
201
  "details": diag.get("details", []),
202
  }
203
  )
204
  pairs = result
205
+ ids = [cid for cid, _ in pairs]
206
 
207
  # ログ送信
208
  GET_LOGS = os.getenv("GET_LOGS", "false").lower() == "true"
209
  if GET_LOGS and posthog:
210
  try:
211
  posthog.capture(
212
+ event="circles searched",
213
  properties={
214
  "query": request.query,
215
  "result_count": len(ids),
 
219
  except Exception:
220
  pass
221
  log.info(f'Search query="{request.query}" => {len(ids)} results')
222
+ return CircleIds(circleIds=ids)
223
 
224
 
225
  @app.post(
app/repositories/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
  """Repository layer providing data access abstractions."""
2
 
3
- from .projects_repository import ProjectsRepository
4
 
5
- __all__ = ["ProjectsRepository"]
 
1
  """Repository layer providing data access abstractions."""
2
 
3
+ from .circles_repository import CirclesRepository
4
 
5
+ __all__ = ["CirclesRepository"]
app/repositories/circles_repository.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import sys
6
+ import os
7
+
8
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
9
+
10
+ from schemas.circles import Circle
11
+ from utils.logger import setup_logger
12
+
13
+ log = setup_logger(__name__)
14
+
15
+
16
+ class CirclesRepository:
17
+ """サークルデータのリポジトリ"""
18
+
19
+ def __init__(self):
20
+ self.circles: list[Circle] = []
21
+ self.circles_map: dict[str, Circle] = {}
22
+ self._load_circles()
23
+
24
+ def _load_circles(self):
25
+ """サークルデータをJSONファイルから読み込む"""
26
+ # config/files.json から読み込み
27
+ config_path = Path(__file__).resolve().parents[2] / "config" / "files.json"
28
+ with open(config_path, "r", encoding="utf-8") as f:
29
+ config = json.load(f)
30
+
31
+ circles_json_path = Path(__file__).resolve().parents[2] / config["circles"]["circles_json"]
32
+
33
+ if not circles_json_path.exists():
34
+ log.warning(f"Circles JSON file not found: {circles_json_path}")
35
+ return
36
+
37
+ with open(circles_json_path, "r", encoding="utf-8") as f:
38
+ data = json.load(f)
39
+
40
+ self.circles = [Circle(**item) for item in data]
41
+ self.circles_map = {c.circleId: c for c in self.circles}
42
+
43
+ log.info(f"Loaded {len(self.circles)} circles from {circles_json_path}")
44
+
45
+ def list_circles(self) -> list[Circle]:
46
+ """全サークルを取得"""
47
+ return self.circles
48
+
49
+ def get_circle(self, circle_id: str) -> Circle | None:
50
+ """指定IDのサークルを取得"""
51
+ return self.circles_map.get(circle_id)
app/search/engine.py CHANGED
@@ -66,10 +66,10 @@ class SearchEngine:
66
  self.custom_synonyms: Dict[str, List[str]] = {}
67
  self.synonyms_cache: Dict[str, List[str]] = {}
68
 
69
- # Data
70
- self.projects: List[Dict[str, Any]] = []
71
- self.project_map: Dict[str, Dict[str, Any]] = {}
72
- self.project_idx: Dict[str, int] = {}
73
  self.org_norms: Dict[str, str] = {}
74
  self.reading_norms: Dict[str, str] = {}
75
  self.substring_index: Dict[str, List[str]] = {}
@@ -108,16 +108,16 @@ class SearchEngine:
108
  query_subword_enable=bool(search("query_subword.enable")),
109
  query_subword_path=files("embeddings.fasttext_bin"),
110
  query_subword_oov_weight=float(search("query_subword.oov_weight")),
111
- org_boost_exact=float(search("circleName.boost.exact", 1.0)),
112
- org_boost_prefix=float(search("circleName.boost.prefix", 0.7)),
113
- org_boost_substring=float(search("circleName.boost.substring", 0.5)),
114
- org_boost_min_len=int(search("circleName.boost.min_len", 2)),
115
  min_results=int(search("filter.min_results", 20)),
116
- max_results=int(search("filter.max_results", 50)),
117
- bm25_min=float(search("filter.bm25_min", 0.1)),
118
- word_sim_min=float(search("filter.word_sim_min", 0.35)),
119
- fused_min=float(search("filter.fused_min", 0.12)),
120
- fused_rel_top_ratio=float(search("filter.fused_rel_top_ratio", 0.5)),
121
  )
122
 
123
  # Tokenizer
@@ -160,18 +160,18 @@ class SearchEngine:
160
  else:
161
  self.substring_index = {}
162
 
163
- # Projects
164
- with open(files("projects.projects_json"), encoding="utf-8") as f:
165
- self.projects = json.load(f)
166
- self.project_map = {p["projectId"]: p for p in self.projects}
167
- self.project_idx = {p["projectId"]: idx for idx, p in enumerate(self.projects)}
168
  self.org_norms = {
169
- p["projectId"]: normalize_text_for_org(p.get("circleName") or "")
170
- for p in self.projects
171
  }
172
  self.reading_norms = {
173
- p["projectId"]: normalize_text_for_org(p.get("circleNameKana") or "")
174
- for p in self.projects
175
  }
176
 
177
  # BM25F assets
@@ -318,7 +318,7 @@ class SearchEngine:
318
  out.append(term)
319
  return out
320
 
321
- def _substring_match_project_ids(self, query: str) -> Set[str]:
322
  if not self.substring_index:
323
  return set()
324
  terms = self._normalize_substring_terms(query)
@@ -510,12 +510,12 @@ class SearchEngine:
510
  if self.cfg.synonyms_enable:
511
  terms = self._expand_synonyms(terms)
512
 
513
- substring_hits = self._substring_match_project_ids(query)
514
  substring_idx_set: Set[int] = set()
515
- substring_mask = np.zeros((len(self.projects),), dtype=bool)
516
  if substring_hits:
517
- for pid in substring_hits:
518
- idx = self.project_idx.get(pid)
519
  if idx is None:
520
  continue
521
  substring_idx_set.add(idx)
@@ -534,15 +534,15 @@ class SearchEngine:
534
  # circleName/circleNameKana auto-boost based on raw query substring match
535
  qn = normalize_text_for_org(query)
536
  boost_enabled = len(qn) >= int(self.cfg.org_boost_min_len)
537
- boost = np.zeros((len(self.projects),), dtype=np.float32)
538
  if boost_enabled:
539
- exact = np.zeros((len(self.projects),), dtype=bool)
540
  prefix = np.zeros_like(exact)
541
  substr = np.zeros_like(exact)
542
- for i, d in enumerate(self.projects):
543
- pid = d.get("projectId")
544
- on = self.org_norms.get(pid, "")
545
- rn = self.reading_norms.get(pid, "")
546
  if qn and (qn == on or (rn and qn == rn)):
547
  exact[i] = True
548
  elif qn and (on.startswith(qn) or (rn and rn.startswith(qn))):
@@ -557,7 +557,7 @@ class SearchEngine:
557
  )
558
 
559
  # collect results
560
- ids = [d.get("projectId") for d in self.projects]
561
 
562
  # Filtering to reduce false positives while keeping recall
563
  # Relative threshold anchored to the top fused score
@@ -632,17 +632,16 @@ class SearchEngine:
632
  return pairs
633
  # build debug details for all docs sorted by score
634
  ranked_indices = sorted(
635
- range(len(self.projects)),
636
  key=lambda idx: (-float(final_scores[idx]), ids[idx]),
637
  )
638
  details = []
639
  for idx in ranked_indices:
640
- project = self.projects[idx]
641
  details.append(
642
  {
643
- "projectId": ids[idx],
644
- "circleName": project.get("circleName"),
645
- "name": project.get("name"),
646
  "bm25": float(bm25[idx]),
647
  "ws_filter_topk": float(ws_filter[idx]),
648
  "ws_rerank_pairavg": float(ws_rerank[idx])
@@ -656,8 +655,8 @@ class SearchEngine:
656
  )
657
  return pairs, {"details": details}
658
 
659
- def get_projects(self) -> List[Dict[str, Any]]:
660
- return self.projects
661
 
662
- def get_project_map(self) -> Dict[str, Dict[str, Any]]:
663
- return self.project_map
 
66
  self.custom_synonyms: Dict[str, List[str]] = {}
67
  self.synonyms_cache: Dict[str, List[str]] = {}
68
 
69
+ # Data (project -> circle)
70
+ self.circles: List[Dict[str, Any]] = []
71
+ self.circle_map: Dict[str, Dict[str, Any]] = {}
72
+ self.circle_idx: Dict[str, int] = {}
73
  self.org_norms: Dict[str, str] = {}
74
  self.reading_norms: Dict[str, str] = {}
75
  self.substring_index: Dict[str, List[str]] = {}
 
108
  query_subword_enable=bool(search("query_subword.enable")),
109
  query_subword_path=files("embeddings.fasttext_bin"),
110
  query_subword_oov_weight=float(search("query_subword.oov_weight")),
111
+ org_boost_exact=float(search("org_boost.exact", 1.5)),
112
+ org_boost_prefix=float(search("org_boost.prefix", 0.9)),
113
+ org_boost_substring=float(search("org_boost.substring", 0.6)),
114
+ org_boost_min_len=int(search("org_boost.min_len", 2)),
115
  min_results=int(search("filter.min_results", 20)),
116
+ max_results=int(search("filter.max_results", 100)),
117
+ bm25_min=float(search("filter.bm25_min", 0.5)),
118
+ word_sim_min=float(search("filter.word_sim_min", 0.3)),
119
+ fused_min=float(search("filter.fused_min", 0.4)),
120
+ fused_rel_top_ratio=float(search("filter.fused_rel_top_ratio", 0.7)),
121
  )
122
 
123
  # Tokenizer
 
160
  else:
161
  self.substring_index = {}
162
 
163
+ # Circles (projects -> circles)
164
+ with open(files("circles.circles_json"), encoding="utf-8") as f:
165
+ self.circles = json.load(f)
166
+ self.circle_map = {c["circleId"]: c for c in self.circles}
167
+ self.circle_idx = {c["circleId"]: idx for idx, c in enumerate(self.circles)}
168
  self.org_norms = {
169
+ c["circleId"]: normalize_text_for_org(c.get("circleName") or "")
170
+ for c in self.circles
171
  }
172
  self.reading_norms = {
173
+ c["circleId"]: normalize_text_for_org(c.get("circleNameKana") or "")
174
+ for c in self.circles
175
  }
176
 
177
  # BM25F assets
 
318
  out.append(term)
319
  return out
320
 
321
+ def _substring_match_circle_ids(self, query: str) -> Set[str]:
322
  if not self.substring_index:
323
  return set()
324
  terms = self._normalize_substring_terms(query)
 
510
  if self.cfg.synonyms_enable:
511
  terms = self._expand_synonyms(terms)
512
 
513
+ substring_hits = self._substring_match_circle_ids(query)
514
  substring_idx_set: Set[int] = set()
515
+ substring_mask = np.zeros((len(self.circles),), dtype=bool)
516
  if substring_hits:
517
+ for cid in substring_hits:
518
+ idx = self.circle_idx.get(cid)
519
  if idx is None:
520
  continue
521
  substring_idx_set.add(idx)
 
534
  # circleName/circleNameKana auto-boost based on raw query substring match
535
  qn = normalize_text_for_org(query)
536
  boost_enabled = len(qn) >= int(self.cfg.org_boost_min_len)
537
+ boost = np.zeros((len(self.circles),), dtype=np.float32)
538
  if boost_enabled:
539
+ exact = np.zeros((len(self.circles),), dtype=bool)
540
  prefix = np.zeros_like(exact)
541
  substr = np.zeros_like(exact)
542
+ for i, d in enumerate(self.circles):
543
+ cid = d.get("circleId")
544
+ on = self.org_norms.get(cid, "")
545
+ rn = self.reading_norms.get(cid, "")
546
  if qn and (qn == on or (rn and qn == rn)):
547
  exact[i] = True
548
  elif qn and (on.startswith(qn) or (rn and rn.startswith(qn))):
 
557
  )
558
 
559
  # collect results
560
+ ids = [d.get("circleId") for d in self.circles]
561
 
562
  # Filtering to reduce false positives while keeping recall
563
  # Relative threshold anchored to the top fused score
 
632
  return pairs
633
  # build debug details for all docs sorted by score
634
  ranked_indices = sorted(
635
+ range(len(self.circles)),
636
  key=lambda idx: (-float(final_scores[idx]), ids[idx]),
637
  )
638
  details = []
639
  for idx in ranked_indices:
640
+ circle = self.circles[idx]
641
  details.append(
642
  {
643
+ "circleId": ids[idx],
644
+ "circleName": circle.get("circleName"),
 
645
  "bm25": float(bm25[idx]),
646
  "ws_filter_topk": float(ws_filter[idx]),
647
  "ws_rerank_pairavg": float(ws_rerank[idx])
 
655
  )
656
  return pairs, {"details": details}
657
 
658
+ def get_circles(self) -> List[Dict[str, Any]]:
659
+ return self.circles
660
 
661
+ def get_circle_map(self) -> Dict[str, Dict[str, Any]]:
662
+ return self.circle_map
app/services/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
  """Service layer modules."""
2
 
3
- from .projects_service import ProjectsService
4
 
5
- __all__ = ["ProjectsService"]
 
1
  """Service layer modules."""
2
 
3
+ from .circles_service import CirclesService
4
 
5
+ __all__ = ["CirclesService"]
app/services/circles_service.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import Any
3
+
4
+ import sys
5
+ import os
6
+
7
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
8
+
9
+ from schemas.circles import Circle
10
+ from app.repositories.circles_repository import CirclesRepository
11
+ from utils.logger import setup_logger
12
+
13
+ log = setup_logger(__name__)
14
+
15
+
16
+ class CirclesService:
17
+ """サークル情報を管理するサービス"""
18
+
19
+ def __init__(self, repository: CirclesRepository, cache_ttl_seconds: int = 300):
20
+ self.repository = repository
21
+ self.cache_ttl = cache_ttl_seconds
22
+ self._cache: list[Circle] | None = None
23
+ self._cache_time: float = 0
24
+
25
+ def list_circles(self) -> list[Circle]:
26
+ """全サークルを取得(キャッシュ付き)"""
27
+ now = time.time()
28
+ if self._cache is None or (now - self._cache_time) > self.cache_ttl:
29
+ self._cache = self.repository.list_circles()
30
+ self._cache_time = now
31
+ log.info("Cache refreshed")
32
+ return self._cache
33
+
34
+ def get_circle(self, circle_id: str) -> Circle | None:
35
+ """指定IDのサークルを取得"""
36
+ return self.repository.get_circle(circle_id)
config/files.json CHANGED
@@ -7,9 +7,9 @@
7
  "synonyms_cache": "data/generated/synonyms_cache.json",
8
  "stopwords": "resources/stopwords.json"
9
  },
10
- "projects": {
11
- "projects_json": "data/generated/projects.json",
12
- "output_json": "data/generated/projects.json"
13
  },
14
  "bm25": {
15
  "bm25_meta": "data/generated/bm25_meta.json",
 
7
  "synonyms_cache": "data/generated/synonyms_cache.json",
8
  "stopwords": "resources/stopwords.json"
9
  },
10
+ "circles": {
11
+ "original_csv": "resources/original_circles.csv",
12
+ "circles_json": "data/generated/circles.json"
13
  },
14
  "bm25": {
15
  "bm25_meta": "data/generated/bm25_meta.json",
config/search_model.json CHANGED
@@ -1,18 +1,25 @@
1
  {
2
- "target_pos_l1": [
3
- "名詞",
4
- "動詞",
5
- "形容詞",
6
- "形容動詞語幹"
7
- ],
8
  "target_fields": [
9
- "name",
10
  "circleName",
11
  "circleNameKana",
 
12
  "description",
13
  "prSummary",
14
- "prDetail"
15
  ],
 
 
 
 
 
 
 
 
 
 
 
 
16
  "synonyms": {
17
  "enable": true,
18
  "sources": {
@@ -20,54 +27,37 @@
20
  "custom_json": "resources/synonyms_custom.json"
21
  },
22
  "limits": {
 
 
23
  "max_expansions_per_term": 4,
24
  "max_query_variants": 5,
25
  "min_char_len": 2
26
  },
27
- "banlist": [
28
- "部",
29
- "会",
30
- "サークル"
31
- ]
32
- },
33
- "bm25f": {
34
- "k1": 1.2,
35
- "b": 0.75,
36
- "field_weights": {
37
- "name": 2.0,
38
- "circleName": 1.5,
39
- "circleNameKana": 0.6,
40
- "description": 1.0,
41
- "prSummary": 1.0,
42
- "prDetail": 0.8
43
- }
44
  },
45
  "word_sim": {
46
  "enable": true,
47
- "mode": "topk",
48
  "alpha": 0.5,
49
  "topk_k": 3,
50
  "rerank": "pair_avg"
51
  },
52
  "query_subword": {
53
- "enable": true,
54
- "oov_weight": 0.8,
55
- "cache_size": 50000
56
  },
57
- "circleName": {
58
- "boost": {
59
- "exact": 1.0,
60
- "prefix": 0.7,
61
- "substring": 0.5,
62
- "min_len": 2
63
- }
64
  },
65
  "filter": {
66
- "min_results": 10,
67
- "max_results": 30,
68
- "bm25_min": 0.15,
69
- "word_sim_min": 0.40,
70
- "fused_min": 0.15,
71
- "fused_rel_top_ratio": 0.50
72
  }
73
  }
 
1
  {
2
+ "target_pos_l1": ["名詞", "動詞", "形容詞"],
 
 
 
 
 
3
  "target_fields": [
 
4
  "circleName",
5
  "circleNameKana",
6
+ "projectName",
7
  "description",
8
  "prSummary",
9
+ "detailDescription"
10
  ],
11
+ "bm25f": {
12
+ "k1": 1.2,
13
+ "b": 0.75,
14
+ "field_weights": {
15
+ "circleName": 2.5,
16
+ "circleNameKana": 0.8,
17
+ "projectName": 1.8,
18
+ "description": 1.0,
19
+ "prSummary": 1.2,
20
+ "detailDescription": 0.9
21
+ }
22
+ },
23
  "synonyms": {
24
  "enable": true,
25
  "sources": {
 
27
  "custom_json": "resources/synonyms_custom.json"
28
  },
29
  "limits": {
30
+ "max_synonym_per_term": 3,
31
+ "max_term_len": 6,
32
  "max_expansions_per_term": 4,
33
  "max_query_variants": 5,
34
  "min_char_len": 2
35
  },
36
+ "banlist": ["の", "こと", "もの", "部", "会", "サークル"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  },
38
  "word_sim": {
39
  "enable": true,
 
40
  "alpha": 0.5,
41
  "topk_k": 3,
42
  "rerank": "pair_avg"
43
  },
44
  "query_subword": {
45
+ "enable": false,
46
+ "path": "resources/embeddings/subword_vectors.bin",
47
+ "oov_weight": 0.5
48
  },
49
+ "org_boost": {
50
+ "exact": 1.5,
51
+ "prefix": 0.9,
52
+ "substring": 0.6,
53
+ "min_len": 2
 
 
54
  },
55
  "filter": {
56
+ "min_results": 20,
57
+ "max_results": 100,
58
+ "bm25_min": 0.5,
59
+ "word_sim_min": 0.3,
60
+ "fused_min": 0.4,
61
+ "fused_rel_top_ratio": 0.7
62
  }
63
  }
docs/【有効】2026-01-19_サークル検索API実装ガイド.md ADDED
@@ -0,0 +1,1356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # サークル検索API実装ガイド
2
+ ## ステップバイステップ完全版
3
+
4
+ **最終更新**: 2026年1月19日
5
+ **対象**: circle-search-26のためのBM25F + fastText検索API構築
6
+
7
+ ---
8
+
9
+ ## 前提条件
10
+
11
+ - GitHubアカウント
12
+ - Huggingface アカウント(無料)
13
+ - ローカル開発環境(Python 3.12、Git、エディタ)
14
+ - circle-search-26は既にAPI検索対応済み(`src/lib/api.ts`)
15
+
16
+ ---
17
+
18
+ ## 全体フロー概要
19
+
20
+ ```
21
+ 1. chibafes-website-api-v2 をフォーク
22
+ 2. サークル検索用にコード改造
23
+ 3. ローカルでテスト
24
+ 4. Huggingface Space にデプロイ
25
+ 5. GitHub Actions 設定
26
+ 6. circle-search-26 から接続テスト
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Phase 1: リポジトリのフォークとセットアップ
32
+
33
+ ### Step 1-1: chibafes-website-api-v2 をフォーク
34
+
35
+ 1. ブラウザで https://github.com/chibafes-dev/chibafes-website-api-v2 を開く
36
+
37
+ 2. 右上の "Fork" ボタンをクリック
38
+
39
+ 3. リポジトリ名を変更:
40
+ ```
41
+ Repository name: circle-search-api
42
+ Description: Circle search API with BM25F and fastText for circle-search-26
43
+ ```
44
+
45
+ 4. "Create fork" をクリック
46
+
47
+ ### Step 1-2: ローカルにクローン
48
+
49
+ ```bash
50
+ # フォークしたリポジトリをクローン
51
+ git clone https://github.com/YOUR_USERNAME/circle-search-api.git
52
+ cd circle-search-api
53
+
54
+ # リモートに upstream を追加(オリジナルの追跡用)
55
+ git remote add upstream https://github.com/chibafes-dev/chibafes-website-api-v2.git
56
+ ```
57
+
58
+ ### Step 1-3: Python環境のセットアップ
59
+
60
+ ```bash
61
+ # pyenv で Python 3.12.11 をインストール(未インストールの場合)
62
+ pyenv install 3.12.11
63
+
64
+ # 仮想環境を作成・有効化
65
+ python -m venv .venv
66
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
67
+
68
+ # 依存関係をインストール
69
+ pip install --upgrade pip
70
+ pip install -r requirements.txt
71
+ ```
72
+
73
+ ### Step 1-4: 環境変数ファイルの作成
74
+
75
+ `.env` ファイルをプロジェクトルートに作成:
76
+
77
+ ```bash
78
+ # API側 GitHub の secrets と HF Spaces の Secrets の両方に登録
79
+ HF_TOKEN=hf_xxx # Huggingfaceのアクセストークン
80
+
81
+ # HF Spaces の secrets に登録
82
+ API_SECRET_KEY=your-secret-key-here # 任意の強力なパスワード
83
+
84
+ # Huggingface embeddings リポジトリ(後で作成)
85
+ HF_EMBEDDINGS_REPO_ID=YOUR_USERNAME/circle-embeddings
86
+
87
+ # PostHog(オプション、分析用)
88
+ POSTHOG_PROJECT_API_KEY= # 空欄でOK
89
+
90
+ # ログ取得(オプション)
91
+ GET_LOGS=false
92
+ ```
93
+
94
+ **重要**: `.env` ファイルは `.gitignore` に含まれているか必ず確認!
95
+
96
+ ---
97
+
98
+ ## Phase 2: コードの改造(企画 → サークル)
99
+
100
+ ### Step 2-1: スキーマ定義の変更
101
+
102
+ #### `schemas/circles.py` を新規作成
103
+
104
+ ```python
105
+ from typing import Literal
106
+ from pydantic import BaseModel
107
+
108
+
109
+ class Image(BaseModel):
110
+ """画像情報"""
111
+ url: str
112
+ alt: str | None = None
113
+ order: int = 0
114
+
115
+
116
+ class Sns(BaseModel):
117
+ """SNS情報"""
118
+ type: str
119
+ url: str
120
+
121
+
122
+ class Circle(BaseModel):
123
+ """サークル情報(フル)"""
124
+ circleId: str
125
+ circleName: str
126
+ circleNameKana: str
127
+ isOfficial: bool
128
+ isIntercollegiate: bool
129
+
130
+ # 企画関連(大祭企画との紐付け用、オプション)
131
+ projectId: str | None = None
132
+ projectName: str | None = None
133
+
134
+ # カテゴリ情報
135
+ mainCategory: str
136
+ subCategory: str | None = None
137
+ category: str | None = None # 大祭用カテゴリ
138
+
139
+ # 場所情報
140
+ areaCode: str | None = None
141
+ areaNumber: str | None = None
142
+ pamphletNumber: int | None = None
143
+
144
+ # 開催日情報(大祭用、オプション)
145
+ beforeDay: bool = False
146
+ firstDay: bool = False
147
+ secondDay: bool = False
148
+ thirdDay: bool = False
149
+
150
+ # 概要情報
151
+ prSummary: str | None = None
152
+ description: str | None = None
153
+ detailDescription: str | None = None
154
+ message: str | None = None
155
+
156
+ # 画像・SNS
157
+ images: list[Image] = []
158
+ sns: list[Sns] = []
159
+
160
+ # メンバー情報
161
+ memberCount: int | None = None
162
+ genderRatioMale: float | None = None
163
+ genderRatioFemale: float | None = None
164
+
165
+ # 設立情報
166
+ establishedYear: int | None = None
167
+
168
+ # 金銭情報
169
+ annualFee: int | None = None
170
+ hasNoFee: bool = False
171
+
172
+ # タグ
173
+ tags: list[str] = []
174
+ featureTags: list[str] = []
175
+
176
+ # 活動情報
177
+ activityFrequency: str | None = None
178
+ activityTimeSlot: str | None = None
179
+
180
+ # 新歓情報
181
+ hasTrialEvent: bool = False
182
+
183
+ # アーカイブ
184
+ isArchived: bool = False
185
+
186
+
187
+ class CircleSummary(BaseModel):
188
+ """サークル概要(一覧表示用)"""
189
+ circleId: str
190
+ circleName: str
191
+ circleNameKana: str
192
+ isOfficial: bool
193
+ isIntercollegiate: bool
194
+ projectId: str | None = None
195
+ projectName: str | None = None
196
+ mainCategory: str
197
+ subCategory: str | None = None
198
+ category: str | None = None
199
+ areaCode: str | None = None
200
+ pamphletNumber: int | None = None
201
+ prSummary: str | None = None
202
+ description: str | None = None
203
+ mainImage: Image | None = None
204
+ memberCount: int | None = None
205
+ tags: list[str] = []
206
+ featureTags: list[str] = []
207
+ isArchived: bool = False
208
+
209
+
210
+ class CircleDetail(BaseModel):
211
+ """サークル詳細"""
212
+ circleId: str
213
+ circleName: str
214
+ circleNameKana: str
215
+ isOfficial: bool
216
+ isIntercollegiate: bool
217
+ projectId: str | None = None
218
+ projectName: str | None = None
219
+ mainCategory: str
220
+ subCategory: str | None = None
221
+ category: str | None = None
222
+ prSummary: str | None = None
223
+ description: str | None = None
224
+ detailDescription: str | None = None
225
+ message: str | None = None
226
+ images: list[Image] = []
227
+ sns: list[Sns] = []
228
+ memberCount: int | None = None
229
+ tags: list[str] = []
230
+ featureTags: list[str] = []
231
+ isArchived: bool = False
232
+
233
+
234
+ class CircleIds(BaseModel):
235
+ """検索結果のID配列"""
236
+ circleIds: list[str]
237
+ ```
238
+
239
+ #### `schemas/__init__.py` を更新
240
+
241
+ ```python
242
+ from .circles import Circle, CircleSummary, CircleDetail, CircleIds, Image, Sns
243
+
244
+ __all__ = [
245
+ "Circle",
246
+ "CircleSummary",
247
+ "CircleDetail",
248
+ "CircleIds",
249
+ "Image",
250
+ "Sns",
251
+ ]
252
+ ```
253
+
254
+ ### Step 2-2: リポジトリ層の変更
255
+
256
+ #### `app/repositories/circles_repository.py` を新規作成
257
+
258
+ ```python
259
+ import json
260
+ from pathlib import Path
261
+ from typing import Any
262
+
263
+ import sys
264
+ import os
265
+
266
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
267
+
268
+ from schemas.circles import Circle
269
+ from utils.logger import setup_logger
270
+
271
+ log = setup_logger(__name__)
272
+
273
+
274
+ class CirclesRepository:
275
+ """サークルデータのリポジトリ"""
276
+
277
+ def __init__(self):
278
+ self.circles: list[Circle] = []
279
+ self.circles_map: dict[str, Circle] = {}
280
+ self._load_circles()
281
+
282
+ def _load_circles(self):
283
+ """サークルデータをJSONファイルから読み込む"""
284
+ # config/files.json から読み込み
285
+ config_path = Path(__file__).resolve().parents[2] / "config" / "files.json"
286
+ with open(config_path, "r", encoding="utf-8") as f:
287
+ config = json.load(f)
288
+
289
+ circles_json_path = Path(__file__).resolve().parents[2] / config["circles"]["circles_json"]
290
+
291
+ if not circles_json_path.exists():
292
+ log.warning(f"Circles JSON file not found: {circles_json_path}")
293
+ return
294
+
295
+ with open(circles_json_path, "r", encoding="utf-8") as f:
296
+ data = json.load(f)
297
+
298
+ self.circles = [Circle(**item) for item in data]
299
+ self.circles_map = {c.circleId: c for c in self.circles}
300
+
301
+ log.info(f"Loaded {len(self.circles)} circles from {circles_json_path}")
302
+
303
+ def list_circles(self) -> list[Circle]:
304
+ """全サークルを取得"""
305
+ return self.circles
306
+
307
+ def get_circle(self, circle_id: str) -> Circle | None:
308
+ """指定IDのサークルを取得"""
309
+ return self.circles_map.get(circle_id)
310
+ ```
311
+
312
+ ### Step 2-3: サービス層の変更
313
+
314
+ #### `app/services/circles_service.py` を新規作成
315
+
316
+ ```python
317
+ import time
318
+ from typing import Any
319
+
320
+ import sys
321
+ import os
322
+
323
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
324
+
325
+ from schemas.circles import Circle
326
+ from app.repositories.circles_repository import CirclesRepository
327
+ from utils.logger import setup_logger
328
+
329
+ log = setup_logger(__name__)
330
+
331
+
332
+ class CirclesService:
333
+ """サークル情報を管理するサービス"""
334
+
335
+ def __init__(self, repository: CirclesRepository, cache_ttl_seconds: int = 300):
336
+ self.repository = repository
337
+ self.cache_ttl = cache_ttl_seconds
338
+ self._cache: list[Circle] | None = None
339
+ self._cache_time: float = 0
340
+
341
+ def list_circles(self) -> list[Circle]:
342
+ """全サークルを取得(キャッシュ付き)"""
343
+ now = time.time()
344
+ if self._cache is None or (now - self._cache_time) > self.cache_ttl:
345
+ self._cache = self.repository.list_circles()
346
+ self._cache_time = now
347
+ log.info("Cache refreshed")
348
+ return self._cache
349
+
350
+ def get_circle(self, circle_id: str) -> Circle | None:
351
+ """指定IDのサークルを取得"""
352
+ return self.repository.get_circle(circle_id)
353
+ ```
354
+
355
+ ### Step 2-4: 検索エンジンの変更
356
+
357
+ #### `app/search/engine.py` の修正(重要な変更のみ)
358
+
359
+ **変更1: インポート部分**
360
+
361
+ ```python
362
+ # 既存のインポートに追加
363
+ from schemas.circles import Circle
364
+
365
+ # projects関連のインポートを削除して circles に変更
366
+ ```
367
+
368
+ **変更2: SearchEngine クラスの初期化**
369
+
370
+ ```python
371
+ class SearchEngine:
372
+ def __init__(self):
373
+ # ... 既存のコード ...
374
+
375
+ # Data(プロパティ名変更)
376
+ self.circles: List[Dict[str, Any]] = [] # projects → circles
377
+ self.circle_map: Dict[str, Dict[str, Any]] = {} # project_map → circle_map
378
+ self.circle_idx: Dict[str, int] = {} # project_idx → circle_idx
379
+
380
+ # 以下同様に全ての project を circle に変更
381
+ ```
382
+
383
+ **変更3: データ読み込み部分**
384
+
385
+ ```python
386
+ def initialize(self):
387
+ files = field_getter("config/files.json")
388
+ search = field_getter("config/search_model.json")
389
+
390
+ # ... 既存の設定読み込み ...
391
+
392
+ # circles.json のパスを取得(projects.json → circles.json)
393
+ circles_json_path = files("circles.circles_json")
394
+ with open(circles_json_path, "r", encoding="utf-8") as f:
395
+ self.circles = json.load(f)
396
+
397
+ # circle_id でマッピング(projectId → circleId)
398
+ for idx, c in enumerate(self.circles):
399
+ cid = c["circleId"] # projectId → circleId
400
+ self.circle_map[cid] = c
401
+ self.circle_idx[cid] = idx
402
+ # org_norms も circleName で作成
403
+ self.org_norms[cid] = normalize_text_for_org(c.get("circleName", ""))
404
+
405
+ # ... 以下同様に全ての project を circle に変更 ...
406
+ ```
407
+
408
+ **変更4: 検索対象フィールドの調整**
409
+
410
+ ```python
411
+ # target_fields の定義を circle に合わせて調整
412
+ # デフォルト設定例:
413
+ "target_fields": [
414
+ "circleName",
415
+ "circleNameKana",
416
+ "projectName",
417
+ "description",
418
+ "prSummary",
419
+ "detailDescription"
420
+ ]
421
+ ```
422
+
423
+ ### Step 2-5: FastAPI エンドポイントの変更
424
+
425
+ #### `app/main.py` を大幅修正
426
+
427
+ ```python
428
+ # インポート部分
429
+ from schemas.circles import Image, CircleSummary, CircleDetail, CircleIds
430
+ from .repositories.circles_repository import CirclesRepository
431
+ from .services.circles_service import CirclesService
432
+
433
+ # 初期化
434
+ engine = SearchEngine()
435
+ circles_repository = CirclesRepository()
436
+ circles_cache_ttl = int(os.getenv("CIRCLES_CACHE_TTL", "300"))
437
+ circles_service = CirclesService(
438
+ circles_repository, cache_ttl_seconds=circles_cache_ttl
439
+ )
440
+
441
+ # エンドポイント変更
442
+ @app.get(
443
+ "/api/circles",
444
+ response_model=list[CircleSummary],
445
+ dependencies=[Depends(get_api_key)],
446
+ )
447
+ def get_summary_data():
448
+ summaries_payload: list[dict[str, object]] = []
449
+ for circle in circles_service.list_circles():
450
+ # 画像はmainImageの1枚だけ返す
451
+ image = circle.mainImage if hasattr(circle, 'mainImage') else None
452
+
453
+ summary = CircleSummary(
454
+ circleId=circle.circleId,
455
+ circleName=circle.circleName,
456
+ circleNameKana=circle.circleNameKana,
457
+ isOfficial=circle.isOfficial,
458
+ isIntercollegiate=circle.isIntercollegiate,
459
+ projectId=circle.projectId,
460
+ projectName=circle.projectName,
461
+ mainCategory=circle.mainCategory,
462
+ subCategory=circle.subCategory,
463
+ category=circle.category,
464
+ areaCode=circle.areaCode,
465
+ pamphletNumber=circle.pamphletNumber,
466
+ prSummary=circle.prSummary,
467
+ description=circle.description,
468
+ mainImage=image,
469
+ memberCount=circle.memberCount,
470
+ tags=circle.tags,
471
+ featureTags=circle.featureTags,
472
+ isArchived=circle.isArchived,
473
+ )
474
+ summaries_payload.append(summary.model_dump(mode="json"))
475
+
476
+ content = json.dumps(summaries_payload, ensure_ascii=False).encode("utf-8")
477
+ log.info(f"Circle summaries fetched: {len(summaries_payload)} items")
478
+ return Response(
479
+ content=gzip.compress(content),
480
+ headers={"Content-Encoding": "gzip", "Content-Type": "application/json"},
481
+ )
482
+
483
+
484
+ @app.get(
485
+ "/api/details",
486
+ response_model=CircleDetail,
487
+ dependencies=[Depends(get_api_key)],
488
+ )
489
+ def get_circle_detail(circleId: str = Query(..., description="取得したいサークルのID")):
490
+ c = circles_service.get_circle(circleId)
491
+ if not c:
492
+ raise HTTPException(status_code=404, detail="Circle not found")
493
+ log.info(f"Circle detail fetched: {circleId}")
494
+ return CircleDetail(**c.model_dump(include=set(CircleDetail.model_fields.keys())))
495
+
496
+
497
+ @app.post(
498
+ "/api/search",
499
+ response_model=CircleIds,
500
+ dependencies=[Depends(get_api_key)],
501
+ )
502
+ def search(request: SearchRequest):
503
+ if not request.query:
504
+ raise HTTPException(status_code=400, detail="Query cannot be empty")
505
+
506
+ result = engine.search(request.query, debug=request.debug)
507
+ if request.debug:
508
+ pairs, diag = result
509
+ ids = [cid for cid, _ in pairs]
510
+ return JSONResponse(
511
+ content={
512
+ "circleIds": ids,
513
+ "scores": [
514
+ {"circleId": cid, "score": float(score)} for cid, score in pairs
515
+ ],
516
+ "details": diag.get("details", []),
517
+ }
518
+ )
519
+ pairs = result
520
+ ids = [cid for cid, _ in pairs]
521
+
522
+ # ログ送信
523
+ GET_LOGS = os.getenv("GET_LOGS", "false").lower() == "true"
524
+ if GET_LOGS and posthog:
525
+ try:
526
+ posthog.capture(
527
+ event="circles searched",
528
+ properties={
529
+ "query": request.query,
530
+ "result_count": len(ids),
531
+ "$process_person_profile": False,
532
+ },
533
+ )
534
+ except Exception:
535
+ pass
536
+ log.info(f'Search query="{request.query}" => {len(ids)} results')
537
+ return CircleIds(circleIds=ids)
538
+ ```
539
+
540
+ ### Step 2-6: 設定ファイルの変更
541
+
542
+ #### `config/files.json` を修正
543
+
544
+ ```json
545
+ {
546
+ "circles": {
547
+ "original_csv": "resources/original_circles.csv",
548
+ "circles_json": "data/generated/circles.json"
549
+ },
550
+ "tf_token": {
551
+ "tf_token_json": "data/generated/tf_token.json"
552
+ },
553
+ "circle_names": {
554
+ "circle_names_txt": "data/generated/circle_names.txt"
555
+ },
556
+ "bm25f_meta": {
557
+ "idf_json": "data/generated/idf.json",
558
+ "avg_len_json": "data/generated/avg_len.json"
559
+ },
560
+ "embeddings": {
561
+ "vocab_txt": "resources/embeddings/vocab.txt",
562
+ "vectors_npy": "resources/embeddings/vectors.npy"
563
+ },
564
+ "stopwords": {
565
+ "stopwords_json": "resources/stopwords.json"
566
+ },
567
+ "synonyms": {
568
+ "custom_json": "resources/synonyms_custom.json",
569
+ "sudachi_txt": "resources/synonyms_DO_NOT_EDIT.txt"
570
+ },
571
+ "user_dict": {
572
+ "user_dict_csv": "resources/user_dict.csv"
573
+ }
574
+ }
575
+ ```
576
+
577
+ #### `config/search_model.json` を修正
578
+
579
+ ```json
580
+ {
581
+ "target_pos_l1": ["名詞", "動詞", "形容詞"],
582
+ "target_fields": [
583
+ "circleName",
584
+ "circleNameKana",
585
+ "projectName",
586
+ "description",
587
+ "prSummary",
588
+ "detailDescription"
589
+ ],
590
+ "bm25f": {
591
+ "k1": 1.2,
592
+ "b": 0.75,
593
+ "field_weights": {
594
+ "circleName": 2.5,
595
+ "circleNameKana": 0.8,
596
+ "projectName": 1.8,
597
+ "description": 1.0,
598
+ "prSummary": 1.2,
599
+ "detailDescription": 0.9
600
+ }
601
+ },
602
+ "synonyms": {
603
+ "enable": true,
604
+ "limits": {
605
+ "max_synonym_per_term": 3,
606
+ "max_term_len": 6
607
+ }
608
+ },
609
+ "banlist": ["の", "こと", "もの"],
610
+ "word_sim": {
611
+ "enable": true,
612
+ "alpha": 0.5,
613
+ "topk_k": 3,
614
+ "rerank": "pair_avg"
615
+ },
616
+ "query_subword": {
617
+ "enable": false,
618
+ "path": "resources/embeddings/subword_vectors.bin",
619
+ "oov_weight": 0.5
620
+ },
621
+ "org_boost": {
622
+ "exact": 1.5,
623
+ "prefix": 0.9,
624
+ "substring": 0.6,
625
+ "min_len": 2
626
+ },
627
+ "filter": {
628
+ "min_results": 20,
629
+ "max_results": 100,
630
+ "bm25_min": 0.5,
631
+ "word_sim_min": 0.3,
632
+ "fused_min": 0.4,
633
+ "fused_rel_top_ratio": 0.7
634
+ }
635
+ }
636
+ ```
637
+
638
+ ### Step 2-7: スクリプトの変更
639
+
640
+ #### `scripts/2_create_projects_data.py` を `scripts/2_create_circles_data.py` にリネーム・修正
641
+
642
+ ```python
643
+ #!/usr/bin/env python3
644
+ """
645
+ CSVからサークルデータのJSONを生成するスクリプト
646
+ """
647
+
648
+ import sys
649
+ import os
650
+ import json
651
+ import csv
652
+ from pathlib import Path
653
+
654
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
655
+
656
+ from utils.json import field_getter
657
+ from utils.logger import setup_logger
658
+
659
+ log = setup_logger(__name__)
660
+
661
+
662
+ def main():
663
+ log.info("Starting circles data creation...")
664
+
665
+ files = field_getter("config/files.json")
666
+
667
+ # CSVファイルを読み込み
668
+ csv_path = Path(__file__).resolve().parents[1] / files("circles.original_csv")
669
+ if not csv_path.exists():
670
+ log.error(f"CSV file not found: {csv_path}")
671
+ return
672
+
673
+ circles = []
674
+ with open(csv_path, "r", encoding="utf-8") as f:
675
+ reader = csv.DictReader(f)
676
+ for row in reader:
677
+ circle = {
678
+ "circleId": row.get("circleId", ""),
679
+ "circleName": row.get("circleName", ""),
680
+ "circleNameKana": row.get("circleNameKana", ""),
681
+ "isOfficial": row.get("isOfficial", "false").lower() == "true",
682
+ "isIntercollegiate": row.get("isIntercollegiate", "false").lower() == "true",
683
+ "projectId": row.get("projectId") or None,
684
+ "projectName": row.get("projectName") or None,
685
+ "mainCategory": row.get("mainCategory", "OTHER"),
686
+ "subCategory": row.get("subCategory") or None,
687
+ "category": row.get("category") or None,
688
+ "areaCode": row.get("areaCode") or None,
689
+ "pamphletNumber": int(row["pamphletNumber"]) if row.get("pamphletNumber") else None,
690
+ "prSummary": row.get("prSummary") or None,
691
+ "description": row.get("description") or None,
692
+ "detailDescription": row.get("detailDescription") or None,
693
+ "memberCount": int(row["memberCount"]) if row.get("memberCount") else None,
694
+ "tags": row.get("tags", "").split(",") if row.get("tags") else [],
695
+ "featureTags": row.get("featureTags", "").split(",") if row.get("featureTags") else [],
696
+ "isArchived": row.get("isArchived", "false").lower() == "true",
697
+ }
698
+ circles.append(circle)
699
+
700
+ # JSONファイルを出力
701
+ output_path = Path(__file__).resolve().parents[1] / files("circles.circles_json")
702
+ output_path.parent.mkdir(parents=True, exist_ok=True)
703
+
704
+ with open(output_path, "w", encoding="utf-8") as f:
705
+ json.dump(circles, f, ensure_ascii=False, indent=2)
706
+
707
+ log.info(f"Created circles JSON: {output_path} ({len(circles)} circles)")
708
+
709
+
710
+ if __name__ == "__main__":
711
+ main()
712
+ ```
713
+
714
+ #### `scripts/7_prepare_circle_names.py` を作成(旧 `7_prepare_circle_names.py` を修正)
715
+
716
+ ```python
717
+ #!/usr/bin/env python3
718
+ """
719
+ サークル名の正規化テキストを生成するスクリプト
720
+ """
721
+
722
+ import sys
723
+ import os
724
+ import json
725
+ import unicodedata
726
+ from pathlib import Path
727
+
728
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
729
+
730
+ from utils.json import field_getter
731
+ from utils.logger import setup_logger
732
+
733
+ log = setup_logger(__name__)
734
+
735
+
736
+ def normalize_text_for_org(s: str) -> str:
737
+ """団体名正規化"""
738
+ try:
739
+ s = unicodedata.normalize("NFKC", s)
740
+ except Exception:
741
+ pass
742
+ s = " ".join(s.split())
743
+ return s
744
+
745
+
746
+ def main():
747
+ log.info("Starting circle names preparation...")
748
+
749
+ files = field_getter("config/files.json")
750
+
751
+ # circles.json を読み込み
752
+ circles_json_path = Path(__file__).resolve().parents[1] / files("circles.circles_json")
753
+ with open(circles_json_path, "r", encoding="utf-8") as f:
754
+ circles = json.load(f)
755
+
756
+ # 正規化した団体名を生成
757
+ circle_names = []
758
+ for c in circles:
759
+ name = c.get("circleName", "")
760
+ if name:
761
+ normalized = normalize_text_for_org(name)
762
+ circle_names.append(normalized)
763
+
764
+ # テキストファイルに出力
765
+ output_path = Path(__file__).resolve().parents[1] / files("circle_names.circle_names_txt")
766
+ output_path.parent.mkdir(parents=True, exist_ok=True)
767
+
768
+ with open(output_path, "w", encoding="utf-8") as f:
769
+ f.write("\n".join(circle_names))
770
+
771
+ log.info(f"Created circle names: {output_path} ({len(circle_names)} names)")
772
+
773
+
774
+ if __name__ == "__main__":
775
+ main()
776
+ ```
777
+
778
+ #### `scripts/build_all.py` を修正
779
+
780
+ ```python
781
+ # 既存のインポートと変更なし
782
+ # 実行部分を修正
783
+
784
+ if __name__ == "__main__":
785
+ log.info("=== Starting full build process ===")
786
+
787
+ # Step 0: データダウンロード(オプション)
788
+ # run_script("scripts/0_download_data.py")
789
+
790
+ # Step 1: 辞書構築
791
+ run_script("scripts/1_build_dict.py")
792
+
793
+ # Step 2: サークルデータ作成(projects → circles)
794
+ run_script("scripts/2_create_circles_data.py")
795
+
796
+ # Step 3: 同義語辞書構築
797
+ run_script("scripts/3_build_synonyms_from_sudachi.py")
798
+
799
+ # Step 4: BM25F メタデータ準備
800
+ run_script("scripts/4_prepare_bm25f_meta.py")
801
+
802
+ # Step 5: TF-token 準備
803
+ run_script("scripts/5_prepare_tf_token.py")
804
+
805
+ # Step 6: Word embeddings 構築
806
+ run_script("scripts/6_build_word_embeddings.py")
807
+
808
+ # Step 7: サークル名準備(projects → circles)
809
+ run_script("scripts/7_prepare_circle_names.py")
810
+
811
+ log.info("=== Full build process completed ===")
812
+ ```
813
+
814
+ ### Step 2-8: サンプルデータの作成
815
+
816
+ #### `resources/original_circles.csv` を作成
817
+
818
+ 最小限のサンプルデータ:
819
+
820
+ ```csv
821
+ circleId,circleName,circleNameKana,isOfficial,isIntercollegiate,projectId,projectName,mainCategory,subCategory,category,areaCode,pamphletNumber,prSummary,description,detailDescription,memberCount,tags,featureTags,isArchived
822
+ circle-001,千葉大学サッカー部,ちばだいがくさっかーぶ,true,false,,,SPORTS,BALL_SPORTS,PERFORMANCE,T,1,関東大学サッカーリーグ所属。週5日の練習で全国大会出場を目指しています。,サッカー部の詳細説明,詳細な活動内容,50,サッカー|運動,freq_daily|size_medium|feat_official,false
823
+ circle-002,吹奏楽部,すいそうがくぶ,true,false,,,CULTURE,MUSIC_PERFORM,MUSIC,L,2,定期演奏会や学祭での演奏を行っています。,吹奏楽部の詳細説明,詳細な活動内容,60,音楽|吹奏楽,freq_3_4|size_large|feat_official,false
824
+ circle-003,軽音楽サークル,けいおんがくさーくる,false,false,,,CULTURE,MUSIC_PERFORM,MUSIC,K,3,バンド活動を通じて音楽を楽しむサークルです。,軽音楽サークルの詳細説明,詳細な活動内容,30,音楽|バンド,freq_1_2|size_medium|feat_beginner,false
825
+ ```
826
+
827
+ ---
828
+
829
+ ## Phase 3: ローカルテスト
830
+
831
+ ### Step 3-1: データビルド
832
+
833
+ ```bash
834
+ # 仮想環境を有効化
835
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
836
+
837
+ # データビルドスクリプトを実行
838
+ python scripts/build_all.py
839
+ ```
840
+
841
+ **注意**: 初回実行時は約6GBのfastTextモデルをダウンロードするため、WiFi環境で実行してください。
842
+
843
+ ### Step 3-2: サーバー起動
844
+
845
+ ```bash
846
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
847
+ ```
848
+
849
+ ### Step 3-3: 動作確認
850
+
851
+ #### ブラウザで確認
852
+
853
+ ```
854
+ http://localhost:8000/docs
855
+ ```
856
+
857
+ Swagger UIが開きます。
858
+
859
+ #### API テスト
860
+
861
+ **1. Health Check**
862
+ ```bash
863
+ curl http://localhost:8000/api/health
864
+ ```
865
+
866
+ **2. サークル一覧取得**
867
+ ```bash
868
+ curl -X 'GET' \
869
+ 'http://localhost:8000/api/circles' \
870
+ -H 'X-API-KEY: your-secret-key-here'
871
+ ```
872
+
873
+ **3. 検索テスト**
874
+ ```bash
875
+ curl -X 'POST' \
876
+ 'http://localhost:8000/api/search' \
877
+ -H 'X-API-KEY: your-secret-key-here' \
878
+ -H 'Content-Type: application/json' \
879
+ -d '{
880
+ "query": "サッカー",
881
+ "debug": false
882
+ }'
883
+ ```
884
+
885
+ 期待される結果:
886
+ ```json
887
+ {
888
+ "circleIds": ["circle-001"]
889
+ }
890
+ ```
891
+
892
+ **4. デバッグモードでの検索**
893
+ ```bash
894
+ curl -X 'POST' \
895
+ 'http://localhost:8000/api/search' \
896
+ -H 'X-API-KEY: your-secret-key-here' \
897
+ -H 'Content-Type: application/json' \
898
+ -d '{
899
+ "query": "音楽",
900
+ "debug": true
901
+ }'
902
+ ```
903
+
904
+ 期待される結果(スコア情報付き):
905
+ ```json
906
+ {
907
+ "circleIds": ["circle-002", "circle-003"],
908
+ "scores": [
909
+ {"circleId": "circle-002", "score": 15.23},
910
+ {"circleId": "circle-003", "score": 12.45}
911
+ ],
912
+ "details": [...]
913
+ }
914
+ ```
915
+
916
+ ---
917
+
918
+ ## Phase 4: Huggingface Space へのデプロイ
919
+
920
+ ### Step 4-1: Huggingface Spaceの作成
921
+
922
+ 1. https://huggingface.co/spaces にアクセス
923
+
924
+ 2. "Create new Space" をクリック
925
+
926
+ 3. 設定:
927
+ ```
928
+ Owner: YOUR_USERNAME
929
+ Space name: circle-search-api
930
+ License: mit
931
+ SDK: Docker
932
+ Space hardware: CPU basic (無料)
933
+ ```
934
+
935
+ 4. "Create Space" をクリック
936
+
937
+ ### Step 4-2: Huggingface トークンの取得
938
+
939
+ 1. https://huggingface.co/settings/tokens にアクセス
940
+
941
+ 2. "New token" をクリック
942
+
943
+ 3. 設定:
944
+ ```
945
+ Name: circle-search-api
946
+ Type: Write
947
+ ```
948
+
949
+ 4. "Generate token" をクリックしてトークンをコピー
950
+
951
+ ### Step 4-3: Huggingface Spaceのシークレット設定
952
+
953
+ 1. 作成したSpaceのページで "Settings" タブをクリック
954
+
955
+ 2. "Variables and secrets" セクションで以下を追加:
956
+
957
+ ```
958
+ Name: HF_TOKEN
959
+ Value: hf_xxxxxxxxxxxxxxxxxx (Step 4-2で取得したトークン)
960
+
961
+ Name: API_SECRET_KEY
962
+ Value: your-secret-key-here (強力なパスワード)
963
+
964
+ Name: HF_EMBEDDINGS_REPO_ID
965
+ Value: YOUR_USERNAME/circle-embeddings (後で作成)
966
+
967
+ Name: POSTHOG_PROJECT_API_KEY
968
+ Value: (空欄でOK、または分析用のキー)
969
+
970
+ Name: GET_LOGS
971
+ Value: false
972
+ ```
973
+
974
+ ### Step 4-4: リポジトリのプッシュ
975
+
976
+ ```bash
977
+ # Huggingface Space用のリモートを追加
978
+ git remote add hf https://huggingface.co/spaces/YOUR_USERNAME/circle-search-api
979
+
980
+ # mainブランチにコミット
981
+ git add .
982
+ git commit -m "Initial commit: Circle search API"
983
+
984
+ # Huggingface Spaceにプッシュ
985
+ git push hf main
986
+ ```
987
+
988
+ **注意**: プッシュ時にユーザー名とパスワードを求められます。
989
+ - ユーザー名: Huggingface のユーザー名
990
+ - パスワード: Step 4-2で取得したHuggingfaceトークン
991
+
992
+ ### Step 4-5: デプロイの確認
993
+
994
+ 1. Huggingface SpaceのページでBuild logを確認
995
+
996
+ 2. デプロイ完了後、以下のURLでアクセス可能:
997
+ ```
998
+ https://YOUR_USERNAME-circle-search-api.hf.space
999
+ ```
1000
+
1001
+ 3. Swagger UIで確認:
1002
+ ```
1003
+ https://YOUR_USERNAME-circle-search-api.hf.space/docs
1004
+ ```
1005
+
1006
+ ### Step 4-6: CORS設定の追加
1007
+
1008
+ `app/main.py` に以下を追加:
1009
+
1010
+ ```python
1011
+ from fastapi.middleware.cors import CORSMiddleware
1012
+
1013
+ # FastAPIアプリ作成後に追加
1014
+ app.add_middleware(
1015
+ CORSMiddleware,
1016
+ allow_origins=[
1017
+ "https://circle-search-26.pages.dev", # あなたのCloudflare PagesのURL
1018
+ "http://localhost:3000", # ローカル開発用
1019
+ ],
1020
+ allow_credentials=True,
1021
+ allow_methods=["*"],
1022
+ allow_headers=["*"],
1023
+ )
1024
+ ```
1025
+
1026
+ 変更をコミット・プッシュ:
1027
+ ```bash
1028
+ git add app/main.py
1029
+ git commit -m "Add CORS configuration"
1030
+ git push hf main
1031
+ ```
1032
+
1033
+ ---
1034
+
1035
+ ## Phase 5: GitHub Actions の設定
1036
+
1037
+ ### Step 5-1: GitHub リポジトリの準備
1038
+
1039
+ 1. GitHubでフォークしたリポジトリ (`YOUR_USERNAME/circle-search-api`) を開く
1040
+
1041
+ 2. "Settings" → "Secrets and variables" → "Actions" をクリック
1042
+
1043
+ 3. "New repository secret" をクリックして以下を追加:
1044
+
1045
+ ```
1046
+ Name: HF_TOKEN
1047
+ Secret: hf_xxxxxxxxxxxxxxxxxx
1048
+
1049
+ Name: API_SECRET_KEY
1050
+ Secret: your-secret-key-here
1051
+ ```
1052
+
1053
+ 4. "Variables" タブで以下を追加:
1054
+
1055
+ ```
1056
+ Name: FASTAPI_URL
1057
+ Value: https://YOUR_USERNAME-circle-search-api.hf.space
1058
+ ```
1059
+
1060
+ ### Step 5-2: GitHub Actions ワークフローの確認
1061
+
1062
+ 既存の `.github/workflows/deploy_to_hf_space.yaml` を確認:
1063
+
1064
+ ```yaml
1065
+ name: Deploy to Hugging Face Space
1066
+
1067
+ on:
1068
+ push:
1069
+ branches:
1070
+ - main
1071
+ workflow_dispatch:
1072
+
1073
+ jobs:
1074
+ deploy-to-space:
1075
+ runs-on: ubuntu-latest
1076
+ steps:
1077
+ - name: Checkout repository
1078
+ uses: actions/checkout@v4
1079
+
1080
+ - name: Push to Hugging Face Space
1081
+ env:
1082
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
1083
+ run: |
1084
+ git clone https://YOUR_USERNAME:$HF_TOKEN@huggingface.co/spaces/YOUR_USERNAME/circle-search-api space-repo
1085
+ rsync -av --delete --exclude='.git/' --exclude='space-repo/' ./ space-repo/
1086
+ cd space-repo
1087
+ git config user.name "GitHub Actions"
1088
+ git config user.email "actions@github.com"
1089
+ git add .
1090
+ if ! git diff-index --quiet HEAD; then
1091
+ git commit -m "Update from GitHub Actions"
1092
+ fi
1093
+ git push https://YOUR_USERNAME:$HF_TOKEN@huggingface.co/spaces/YOUR_USERNAME/circle-search-api main
1094
+ ```
1095
+
1096
+ ### Step 5-3: 定期データ更新の設定(オプション)
1097
+
1098
+ 既存の `.github/workflows/update_data.yaml` を確認:
1099
+
1100
+ ```yaml
1101
+ name: update data
1102
+ on:
1103
+ schedule:
1104
+ - cron: '0 18 * * *' # 毎日3時(JST)に実行
1105
+ workflow_dispatch:
1106
+
1107
+ jobs:
1108
+ call:
1109
+ runs-on: ubuntu-latest
1110
+ steps:
1111
+ - name: Trigger HF Space FastAPI Endpoint
1112
+ run: |
1113
+ curl --fail --show-error --silent -X 'POST' \
1114
+ '${{ vars.FASTAPI_URL }}/tasks/update' \
1115
+ -H 'accept: application/json' \
1116
+ -H 'Authorization: Bearer ${{ secrets.HF_TOKEN }}' \
1117
+ -H 'X-API-KEY: ${{ secrets.API_SECRET_KEY }}' \
1118
+ -d ''
1119
+ ```
1120
+
1121
+ ### Step 5-4: 動作確認
1122
+
1123
+ 1. GitHubリポジトリで "Actions" タブをクリック
1124
+
1125
+ 2. "Deploy to Hugging Face Space" ワークフローを選択
1126
+
1127
+ 3. "Run workflow" をクリックして手動実行
1128
+
1129
+ 4. ログを確認してデプロイが成功したことを確認
1130
+
1131
+ ---
1132
+
1133
+ ## Phase 6: circle-search-26 からの接続テスト
1134
+
1135
+ ### Step 6-1: 環境変数の設定
1136
+
1137
+ `circle-search-26/.env.local` ファイルを作成:
1138
+
1139
+ ```bash
1140
+ # 検索API設定
1141
+ NEXT_PUBLIC_USE_API_SEARCH=true
1142
+ NEXT_PUBLIC_SEARCH_API_URL=https://YOUR_USERNAME-circle-search-api.hf.space
1143
+ NEXT_PUBLIC_SEARCH_API_KEY=your-secret-key-here
1144
+ ```
1145
+
1146
+ ### Step 6-2: 開発サーバーの起動
1147
+
1148
+ ```bash
1149
+ cd /path/to/circle-search-26
1150
+ npm run dev
1151
+ ```
1152
+
1153
+ ### Step 6-3: 検索テスト
1154
+
1155
+ 1. ブラウザで `http://localhost:3000` を開く
1156
+
1157
+ 2. 検索バーに「サッカー」と入力
1158
+
1159
+ 3. 開発者ツール(F12)の "Network" タブを確認:
1160
+ - `https://YOUR_USERNAME-circle-search-api.hf.space/api/search` へのPOSTリクエストが表示される
1161
+ - レスポンスに `circleIds` が含まれている
1162
+
1163
+ 4. コンソールログを確認:
1164
+ ```
1165
+ Search using API: サッカー
1166
+ API search success: 1 results
1167
+ ```
1168
+
1169
+ ### Step 6-4: フォールバック機能のテスト
1170
+
1171
+ 1. `.env.local` で一時的に無効なAPIキーを設定:
1172
+ ```bash
1173
+ NEXT_PUBLIC_SEARCH_API_KEY=invalid-key
1174
+ ```
1175
+
1176
+ 2. サーバーを再起動して検索
1177
+
1178
+ 3. コンソールログを確認:
1179
+ ```
1180
+ API search failed, falling back to client-side search
1181
+ ```
1182
+
1183
+ 4. 検索結果が表示されることを確認(クライアントサイド検索が動作)
1184
+
1185
+ ---
1186
+
1187
+ ## Phase 7: 本番デプロイとモニタリング
1188
+
1189
+ ### Step 7-1: Cloudflare Pages の環境変数設定
1190
+
1191
+ 1. Cloudflare Dashboard で Pages プロジェクトを開く
1192
+
1193
+ 2. "Settings" → "Environment variables" をクリック
1194
+
1195
+ 3. Production環境に以下を追加:
1196
+ ```
1197
+ NEXT_PUBLIC_USE_API_SEARCH=true
1198
+ NEXT_PUBLIC_SEARCH_API_URL=https://YOUR_USERNAME-circle-search-api.hf.space
1199
+ NEXT_PUBLIC_SEARCH_API_KEY=your-secret-key-here
1200
+ ```
1201
+
1202
+ ### Step 7-2: デプロイ
1203
+
1204
+ ```bash
1205
+ cd /path/to/circle-search-26
1206
+ git add .
1207
+ git commit -m "Enable API search"
1208
+ git push origin main
1209
+ ```
1210
+
1211
+ Cloudflare Pagesが自動的にデプロイします。
1212
+
1213
+ ### Step 7-3: 本番環境での動作確認
1214
+
1215
+ 1. デプロイされたサイトにアクセス
1216
+
1217
+ 2. 検索機能をテスト
1218
+
1219
+ 3. ブラウザの開発者ツールでネットワークリクエストを確認
1220
+
1221
+ ### Step 7-4: モニタリング設定
1222
+
1223
+ #### Huggingface Space のログ確認
1224
+
1225
+ 1. Huggingface SpaceのページでLogsタブを確認
1226
+
1227
+ 2. リクエスト数、エラーレート、レスポンスタイムを監視
1228
+
1229
+ #### PostHog 分析(オプション)
1230
+
1231
+ 1. https://posthog.com でアカウント作成
1232
+
1233
+ 2. プロジェクトを作成してAPIキーを取得
1234
+
1235
+ 3. Huggingface Spaceのシークレットに追加:
1236
+ ```
1237
+ POSTHOG_PROJECT_API_KEY=phc_xxxxx
1238
+ GET_LOGS=true
1239
+ ```
1240
+
1241
+ 4. PostHogダッシュボードで検索クエリや結果数を分析
1242
+
1243
+ ---
1244
+
1245
+ ## トラブルシューティング
1246
+
1247
+ ### 問題1: Huggingface Space のビルドが失敗する
1248
+
1249
+ **症状**: Build logに "Error: ..." が表示される
1250
+
1251
+ **解決策**:
1252
+ 1. `requirements.txt` の依存関係を確認
1253
+ 2. `Dockerfile` の構文を確認
1254
+ 3. `.env` ファイルが含まれていないことを確認
1255
+ 4. ログの詳細なエラーメッセージを確認
1256
+
1257
+ ### 問題2: 検索が遅い
1258
+
1259
+ **症状**: 検索リクエストに3秒以上かかる
1260
+
1261
+ **解決策**:
1262
+ 1. Huggingface Spaceの無料プランはCPUが限られているため、初回リクエストが遅い
1263
+ 2. キャッシュを有効化(`CIRCLES_CACHE_TTL=600` 等)
1264
+ 3. 有料プランへの移行を検討($0.60/hour〜)
1265
+
1266
+ ### 問題3: CORSエラーが発生する
1267
+
1268
+ **症状**: ブラウザのコンソールに "CORS policy" エラーが表示される
1269
+
1270
+ **解決策**:
1271
+ 1. `app/main.py` のCORS設定を確認
1272
+ 2. `allow_origins` にCloudflare PagesのURLが含まれているか確認
1273
+ 3. Huggingface Spaceを再デプロイ
1274
+
1275
+ ### 問題4: APIキー認証エラー
1276
+
1277
+ **症状**: "Could not validate credentials" エラーが返される
1278
+
1279
+ **解決策**:
1280
+ 1. `.env.local` のAPIキーを確認
1281
+ 2. Huggingface Spaceのシークレット設定を確認
1282
+ 3. `app/main.py` の `get_api_key` 関数を確認
1283
+
1284
+ ### 問題5: 検索結果が0件
1285
+
1286
+ **症状**: 検索クエリに対して結果が返されない
1287
+
1288
+ **解決策**:
1289
+ 1. `resources/original_circles.csv` にデータが存在するか確認
1290
+ 2. `data/generated/circles.json` が正しく生成されているか確認
1291
+ 3. デバッグモード(`debug: true`)で検索して詳細を確認
1292
+ 4. `config/search_model.json` のフィルター設定を確認
1293
+
1294
+ ---
1295
+
1296
+ ## 次のステップ
1297
+
1298
+ ### 実際のサークルデータの追加
1299
+
1300
+ 1. Google Spreadsheetsでサークルデータを整理
1301
+
1302
+ 2. CSVファイルとしてエクスポート
1303
+
1304
+ 3. `resources/original_circles.csv` に配置
1305
+
1306
+ 4. `python scripts/build_all.py` を実行
1307
+
1308
+ 5. Gitにコミット・プッシュ
1309
+
1310
+ ### 検索精度の改善
1311
+
1312
+ 1. `config/search_model.json` のパラメータを調整:
1313
+ - `field_weights`: フィールドごとの重み
1314
+ - `bm25f.k1`, `bm25f.b`: BM25のパラメータ
1315
+ - `word_sim.alpha`: BM25とベクトル類似度のバランス
1316
+
1317
+ 2. ユーザーフィードバックを収集
1318
+
1319
+ 3. 同義語辞書を拡充(`resources/synonyms_custom.json`)
1320
+
1321
+ ### パフォーマンス最適化
1322
+
1323
+ 1. キャッシュ戦略の改善:
1324
+ - Redis等の外部キャッシュの導入
1325
+ - CDNの活用
1326
+
1327
+ 2. Huggingface Space の有料プランへの移行
1328
+
1329
+ 3. リクエストのバッチング実装
1330
+
1331
+ ---
1332
+
1333
+ ## まとめ
1334
+
1335
+ このガイドに従うことで、以下が実現できます:
1336
+
1337
+ ✅ chibafes-website-api-v2 をフォークしてサークル検索用に改造
1338
+ ✅ BM25F + fastText による高精度な日本語検索
1339
+ ✅ Huggingface Space での無料ホスティング
1340
+ ✅ GitHub Actions による自動デプロイ
1341
+ ✅ circle-search-26 からのシームレスな統合
1342
+ ✅ フォールバック機能による高可用性
1343
+
1344
+ **推定所要時間**: 初回セットアップ 4-6時間
1345
+
1346
+ **運用コスト**: 月0円〜(Huggingface無料枠内)
1347
+
1348
+ ---
1349
+
1350
+ ## 参考資料
1351
+
1352
+ - [chibafes-website-api-v2 Documentation](./docs/)
1353
+ - [Huggingface Spaces Documentation](https://huggingface.co/docs/hub/spaces)
1354
+ - [FastAPI Documentation](https://fastapi.tiangolo.com/)
1355
+ - [GitHub Actions Documentation](https://docs.github.com/actions)
1356
+ - [BM25アルゴリズム詳説](./docs/検索アルゴリズム詳説.md)
resources/original_circles.csv ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ circleId,circleName,circleNameKana,isOfficial,isIntercollegiate,projectId,projectName,mainCategory,subCategory,category,areaCode,pamphletNumber,prSummary,description,detailDescription,memberCount,tags,featureTags,isArchived
2
+ circle-001,千葉大学サッカー部,ちばだいがくさっかーぶ,true,false,,,SPORTS,BALL_SPORTS,PERFORMANCE,T,1,関東大学サッカーリーグ所属。週5日の練習で全国大会出場を目指しています。,サッカー部の詳細説明,詳細な活動内容,50,サッカー|運動,freq_daily|size_medium|feat_official,false
3
+ circle-002,吹奏楽部,すいそうがくぶ,true,false,,,CULTURE,MUSIC_PERFORM,MUSIC,L,2,定期演奏会や学祭での演奏を行っています。,吹奏楽部の詳細説明,詳細な活動内容,60,音楽|吹奏楽,freq_3_4|size_large|feat_official,false
4
+ circle-003,軽音楽サークル,けいおんがくさーくる,false,false,,,CULTURE,MUSIC_PERFORM,MUSIC,K,3,バンド活動を通じて音楽を楽しむサークルです。,軽音楽サークルの詳細説明,詳細な活動内容,30,音楽|バンド,freq_1_2|size_medium|feat_beginner,false
schemas/__init__.py CHANGED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from .circles import Circle, CircleSummary, CircleDetail, CircleIds, Image, Sns
2
+
3
+ __all__ = [
4
+ "Circle",
5
+ "CircleSummary",
6
+ "CircleDetail",
7
+ "CircleIds",
8
+ "Image",
9
+ "Sns",
10
+ ]
schemas/circles.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class Image(BaseModel):
6
+ """画像情報"""
7
+ url: str
8
+ alt: str | None = None
9
+ order: int = 0
10
+
11
+
12
+ class Sns(BaseModel):
13
+ """SNS情報"""
14
+ type: str
15
+ url: str
16
+
17
+
18
+ class Circle(BaseModel):
19
+ """サークル情報(フル)"""
20
+ circleId: str
21
+ circleName: str
22
+ circleNameKana: str
23
+ isOfficial: bool
24
+ isIntercollegiate: bool
25
+
26
+ # 企画関連(大祭企画との紐付け用、オプション)
27
+ projectId: str | None = None
28
+ projectName: str | None = None
29
+
30
+ # カテゴリ情報
31
+ mainCategory: str
32
+ subCategory: str | None = None
33
+ category: str | None = None # 大祭用カテゴリ
34
+
35
+ # 場所情報
36
+ areaCode: str | None = None
37
+ areaNumber: str | None = None
38
+ pamphletNumber: int | None = None
39
+
40
+ # 開催日情報(大祭用、オプション)
41
+ beforeDay: bool = False
42
+ firstDay: bool = False
43
+ secondDay: bool = False
44
+ thirdDay: bool = False
45
+
46
+ # 概要情報
47
+ prSummary: str | None = None
48
+ description: str | None = None
49
+ detailDescription: str | None = None
50
+ message: str | None = None
51
+
52
+ # 画像・SNS
53
+ images: list[Image] = []
54
+ sns: list[Sns] = []
55
+
56
+ # メンバー情報
57
+ memberCount: int | None = None
58
+ genderRatioMale: float | None = None
59
+ genderRatioFemale: float | None = None
60
+
61
+ # 設立情報
62
+ establishedYear: int | None = None
63
+
64
+ # 金銭情報
65
+ annualFee: int | None = None
66
+ hasNoFee: bool = False
67
+
68
+ # タグ
69
+ tags: list[str] = []
70
+ featureTags: list[str] = []
71
+
72
+ # 活動情報
73
+ activityFrequency: str | None = None
74
+ activityTimeSlot: str | None = None
75
+
76
+ # 新歓情報
77
+ hasTrialEvent: bool = False
78
+
79
+ # アーカイブ
80
+ isArchived: bool = False
81
+
82
+
83
+ class CircleSummary(BaseModel):
84
+ """サークル概要(一覧表示用)"""
85
+ circleId: str
86
+ circleName: str
87
+ circleNameKana: str
88
+ isOfficial: bool
89
+ isIntercollegiate: bool
90
+ projectId: str | None = None
91
+ projectName: str | None = None
92
+ mainCategory: str
93
+ subCategory: str | None = None
94
+ category: str | None = None
95
+ areaCode: str | None = None
96
+ pamphletNumber: int | None = None
97
+ prSummary: str | None = None
98
+ description: str | None = None
99
+ mainImage: Image | None = None
100
+ memberCount: int | None = None
101
+ tags: list[str] = []
102
+ featureTags: list[str] = []
103
+ isArchived: bool = False
104
+
105
+
106
+ class CircleDetail(BaseModel):
107
+ """サークル詳細"""
108
+ circleId: str
109
+ circleName: str
110
+ circleNameKana: str
111
+ isOfficial: bool
112
+ isIntercollegiate: bool
113
+ projectId: str | None = None
114
+ projectName: str | None = None
115
+ mainCategory: str
116
+ subCategory: str | None = None
117
+ category: str | None = None
118
+ prSummary: str | None = None
119
+ description: str | None = None
120
+ detailDescription: str | None = None
121
+ message: str | None = None
122
+ images: list[Image] = []
123
+ sns: list[Sns] = []
124
+ memberCount: int | None = None
125
+ tags: list[str] = []
126
+ featureTags: list[str] = []
127
+ isArchived: bool = False
128
+
129
+
130
+ class CircleIds(BaseModel):
131
+ """検索結果のID配列"""
132
+ circleIds: list[str]
schemas/tf_token.py CHANGED
@@ -15,8 +15,8 @@ class Fields(BaseModel):
15
  prDetail: TfOfField
16
 
17
 
18
- class Project(BaseModel):
19
- projectId: str
20
  fields: Fields
21
  tf: dict[str, int]
22
  tokens: dict[str, int]
 
15
  prDetail: TfOfField
16
 
17
 
18
+ class Circle(BaseModel):
19
+ circleId: str
20
  fields: Fields
21
  tf: dict[str, int]
22
  tokens: dict[str, int]
scripts/2_create_circles_data.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ CSVからサークルデータのJSONを生成するスクリプト
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import csv
9
+ import json
10
+
11
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
12
+
13
+ from utils.logger import setup_logger
14
+ from utils.json import get_file_path_from_config, json_dumps
15
+
16
+ # --- ロギングの設定 ---
17
+ log = setup_logger(__name__)
18
+
19
+
20
+ def parse_bool(value: str) -> bool:
21
+ """文字列をブール値に変換"""
22
+ return value.lower() in ("true", "1", "yes")
23
+
24
+
25
+ def parse_int(value: str) -> int | None:
26
+ """文字列を整数に変換(空の場合はNone)"""
27
+ if not value or value.strip() == "":
28
+ return None
29
+ try:
30
+ return int(value)
31
+ except ValueError:
32
+ return None
33
+
34
+
35
+ def parse_list(value: str, delimiter: str = "|") -> list[str]:
36
+ """区切り文字で分割してリストに変換"""
37
+ if not value or value.strip() == "":
38
+ return []
39
+ return [item.strip() for item in value.split(delimiter) if item.strip()]
40
+
41
+
42
+ def main():
43
+ """CSVからサークルデータを読み込み circles.json を生成する。"""
44
+
45
+ log.info("CSVファイルからサークルデータを読み込みcircles.jsonを生成します。")
46
+
47
+ input_csv_path = get_file_path_from_config(
48
+ "circles.original_csv", "resources/original_circles.csv"
49
+ )
50
+ output_json_path = get_file_path_from_config(
51
+ "circles.circles_json", "data/generated/circles.json"
52
+ )
53
+
54
+ if not os.path.exists(input_csv_path):
55
+ log.error(f"入力CSVファイルが見つかりません: {input_csv_path}")
56
+ sys.exit(1)
57
+
58
+ circles = []
59
+ try:
60
+ with open(input_csv_path, "r", encoding="utf-8") as f:
61
+ reader = csv.DictReader(f)
62
+ for row in reader:
63
+ circle = {
64
+ "circleId": row.get("circleId", ""),
65
+ "circleName": row.get("circleName", ""),
66
+ "circleNameKana": row.get("circleNameKana", ""),
67
+ "isOfficial": parse_bool(row.get("isOfficial", "false")),
68
+ "isIntercollegiate": parse_bool(row.get("isIntercollegiate", "false")),
69
+ "projectId": row.get("projectId") or None,
70
+ "projectName": row.get("projectName") or None,
71
+ "mainCategory": row.get("mainCategory", ""),
72
+ "subCategory": row.get("subCategory", ""),
73
+ "category": row.get("category", ""),
74
+ "areaCode": row.get("areaCode", ""),
75
+ "pamphletNumber": parse_int(row.get("pamphletNumber", "")),
76
+ "prSummary": row.get("prSummary", ""),
77
+ "description": row.get("description", ""),
78
+ "detailDescription": row.get("detailDescription", ""),
79
+ "memberCount": parse_int(row.get("memberCount", "")),
80
+ "tags": parse_list(row.get("tags", "")),
81
+ "featureTags": parse_list(row.get("featureTags", "")),
82
+ "isArchived": parse_bool(row.get("isArchived", "false")),
83
+ "images": [], # CSVではimagesは空配列
84
+ "sns": [], # CSVではsnsは空配列
85
+ }
86
+ circles.append(circle)
87
+ except Exception as exc:
88
+ log.error(f"CSVファイルの読み込みに失敗しました: {exc}")
89
+ sys.exit(1)
90
+
91
+ os.makedirs(os.path.dirname(output_json_path), exist_ok=True)
92
+ json_dumps(circles, output_json_path)
93
+
94
+ log.info(f"サークルデータの生成が完了しました: {output_json_path} ({len(circles)}件)")
95
+ sys.exit(0)
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
scripts/3_build_synonyms_from_sudachi.py CHANGED
@@ -12,13 +12,13 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
12
  from utils.logger import setup_logger
13
  from utils.json import get_file_path_from_config, field_getter, json_dumps
14
  from utils.io import LineIteratorIO, comment_filtered_lines
15
- from schemas.projects import Project
16
 
17
  # --- ロギングの設定 ---
18
  log = setup_logger(__name__)
19
 
20
  # --- 設定 ---
21
- input_file = get_file_path_from_config("projects.projects_json")
22
  output_file = get_file_path_from_config("sudachi.synonyms_cache")
23
  sudachi_config_file = get_file_path_from_config("sudachi.sudachi_config")
24
 
@@ -65,13 +65,13 @@ def tokenize(text: str) -> list[str]:
65
  return tokens
66
 
67
 
68
- def get_corpus_vocab(projects: list[Project]) -> set[str]:
69
- """プロジェ全体から語彙セットを構築する"""
70
  vocab = set()
71
  log.info("語彙セットを構築中...")
72
- for project in tqdm(projects):
73
  for field in target_fields:
74
- text = getattr(project, field, "")
75
  if not text:
76
  continue
77
  tokens = tokenize(str(text))
@@ -124,8 +124,8 @@ def main():
124
 
125
  with open(input_file, encoding="utf-8") as f:
126
  try:
127
- project_dicts = json.load(f)
128
- projects = [Project(**item) for item in project_dicts]
129
  except json.JSONDecodeError as e:
130
  log.error(f"JSONデコードエラー: {e}")
131
  sys.exit(1)
@@ -137,7 +137,7 @@ def main():
137
  sys.exit(1)
138
 
139
  # 1. コーパスの語彙を構築
140
- vocab = get_corpus_vocab(projects)
141
  log.info(f"コーパスの語彙数: {len(vocab)}")
142
 
143
  # 2. Sudachiの同義語辞書をパース
 
12
  from utils.logger import setup_logger
13
  from utils.json import get_file_path_from_config, field_getter, json_dumps
14
  from utils.io import LineIteratorIO, comment_filtered_lines
15
+ from schemas.circles import Circle
16
 
17
  # --- ロギングの設定 ---
18
  log = setup_logger(__name__)
19
 
20
  # --- 設定 ---
21
+ input_file = get_file_path_from_config("circles.circles_json")
22
  output_file = get_file_path_from_config("sudachi.synonyms_cache")
23
  sudachi_config_file = get_file_path_from_config("sudachi.sudachi_config")
24
 
 
65
  return tokens
66
 
67
 
68
+ def get_corpus_vocab(circles: list[Circle]) -> set[str]:
69
+ """サー全体から語彙セットを構築する"""
70
  vocab = set()
71
  log.info("語彙セットを構築中...")
72
+ for circle in tqdm(circles):
73
  for field in target_fields:
74
+ text = getattr(circle, field, "")
75
  if not text:
76
  continue
77
  tokens = tokenize(str(text))
 
124
 
125
  with open(input_file, encoding="utf-8") as f:
126
  try:
127
+ circle_dicts = json.load(f)
128
+ circles = [Circle(**item) for item in circle_dicts]
129
  except json.JSONDecodeError as e:
130
  log.error(f"JSONデコードエラー: {e}")
131
  sys.exit(1)
 
137
  sys.exit(1)
138
 
139
  # 1. コーパスの語彙を構築
140
+ vocab = get_corpus_vocab(circles)
141
  log.info(f"コーパスの語彙数: {len(vocab)}")
142
 
143
  # 2. Sudachiの同義語辞書をパース
scripts/4_prepare_bm25f_meta.py CHANGED
@@ -67,32 +67,32 @@ def main():
67
  log.error(f"設定の読み込みに失敗しました: {e}")
68
  sys.exit(1)
69
 
70
- projects_path = get_file_path_from_config(
71
- "projects.projects_json", "data/generated/projects.json"
72
  )
73
  output_path = get_file_path_from_config(
74
  "bm25.bm25_meta", "data/generated/bm25_meta.json"
75
  )
76
 
77
  try:
78
- with open(projects_path, encoding="utf-8") as f:
79
- projects = json.load(f)
80
  except Exception as e:
81
- log.error(f"projects.jsonの読み込みに失敗しました: {e}")
82
  sys.exit(1)
83
 
84
  tok, mode = build_tokenizer(sudachi_config_path)
85
 
86
- N = len(projects)
87
  df: Dict[str, int] = defaultdict(int)
88
  field_token_lens_sum: Dict[str, int] = {f: 0 for f in target_fields}
89
 
90
  log.info(f"ドキュメント数: {N}")
91
 
92
- for p in projects:
93
  seen_in_doc = set()
94
  for field in target_fields:
95
- text = p.get(field) or ""
96
  if not text:
97
  continue
98
  toks = tokenize(str(text), tok, mode, target_pos_l1, ban_list, stopwords)
 
67
  log.error(f"設定の読み込みに失敗しました: {e}")
68
  sys.exit(1)
69
 
70
+ circles_path = get_file_path_from_config(
71
+ "circles.circles_json", "data/generated/circles.json"
72
  )
73
  output_path = get_file_path_from_config(
74
  "bm25.bm25_meta", "data/generated/bm25_meta.json"
75
  )
76
 
77
  try:
78
+ with open(circles_path, encoding="utf-8") as f:
79
+ circles = json.load(f)
80
  except Exception as e:
81
+ log.error(f"circles.jsonの読み込みに失敗しました: {e}")
82
  sys.exit(1)
83
 
84
  tok, mode = build_tokenizer(sudachi_config_path)
85
 
86
+ N = len(circles)
87
  df: Dict[str, int] = defaultdict(int)
88
  field_token_lens_sum: Dict[str, int] = {f: 0 for f in target_fields}
89
 
90
  log.info(f"ドキュメント数: {N}")
91
 
92
+ for c in circles:
93
  seen_in_doc = set()
94
  for field in target_fields:
95
+ text = c.get(field) or ""
96
  if not text:
97
  continue
98
  toks = tokenize(str(text), tok, mode, target_pos_l1, ban_list, stopwords)
scripts/5_prepare_tf_token.py CHANGED
@@ -67,26 +67,26 @@ def main():
67
  log.error(f"設定の読み込みに失敗しました: {e}")
68
  sys.exit(1)
69
 
70
- projects_path = get_file_path_from_config(
71
- "projects.projects_json", "data/generated/projects.json"
72
  )
73
  output_path = get_file_path_from_config(
74
  "bm25.tf_token", "data/generated/tf_token.json"
75
  )
76
 
77
  try:
78
- with open(projects_path, encoding="utf-8") as f:
79
- projects = json.load(f)
80
  except Exception as e:
81
- log.error(f"projects.jsonの読み込みに失敗しました: {e}")
82
  sys.exit(1)
83
 
84
  tok, mode = build_tokenizer(sudachi_config_path)
85
 
86
- results: List[tf_token.Project] = []
87
 
88
- for p in projects:
89
- project_id = p.get("projectId")
90
 
91
  # 各フィールドのトークン化とTF
92
  field_objs: Dict[str, tf_token.TfOfField] = {}
@@ -94,7 +94,7 @@ def main():
94
  doc_token_set: set = set()
95
 
96
  for field in target_fields:
97
- text = p.get(field) or ""
98
  toks = tokenize(str(text), tok, mode, target_pos_l1, ban_list, stopwords)
99
  tf = Counter(toks)
100
  field_objs[field] = tf_token.TfOfField(len=len(toks), tf=dict(tf))
@@ -114,14 +114,14 @@ def main():
114
  prDetail=get_field("prDetail"),
115
  )
116
 
117
- project_entry = tf_token.Project(
118
- projectId=project_id,
119
  fields=fields_obj,
120
  tf=dict(doc_tf_counter),
121
  # tokens はユニーク語彙の存在フラグ(1)とする
122
  tokens={t: 1 for t in sorted(doc_token_set)},
123
  )
124
- results.append(project_entry)
125
 
126
  os.makedirs(os.path.dirname(output_path), exist_ok=True)
127
  json_dumps([r.model_dump() for r in results], output_path)
 
67
  log.error(f"設定の読み込みに失敗しました: {e}")
68
  sys.exit(1)
69
 
70
+ circles_path = get_file_path_from_config(
71
+ "circles.circles_json", "data/generated/circles.json"
72
  )
73
  output_path = get_file_path_from_config(
74
  "bm25.tf_token", "data/generated/tf_token.json"
75
  )
76
 
77
  try:
78
+ with open(circles_path, encoding="utf-8") as f:
79
+ circles = json.load(f)
80
  except Exception as e:
81
+ log.error(f"circles.jsonの読み込みに失敗しました: {e}")
82
  sys.exit(1)
83
 
84
  tok, mode = build_tokenizer(sudachi_config_path)
85
 
86
+ results: List[tf_token.Circle] = []
87
 
88
+ for c in circles:
89
+ circle_id = c.get("circleId")
90
 
91
  # 各フィールドのトークン化とTF
92
  field_objs: Dict[str, tf_token.TfOfField] = {}
 
94
  doc_token_set: set = set()
95
 
96
  for field in target_fields:
97
+ text = c.get(field) or ""
98
  toks = tokenize(str(text), tok, mode, target_pos_l1, ban_list, stopwords)
99
  tf = Counter(toks)
100
  field_objs[field] = tf_token.TfOfField(len=len(toks), tf=dict(tf))
 
114
  prDetail=get_field("prDetail"),
115
  )
116
 
117
+ circle_entry = tf_token.Circle(
118
+ circleId=circle_id,
119
  fields=fields_obj,
120
  tf=dict(doc_tf_counter),
121
  # tokens はユニーク語彙の存在フラグ(1)とする
122
  tokens={t: 1 for t in sorted(doc_token_set)},
123
  )
124
+ results.append(circle_entry)
125
 
126
  os.makedirs(os.path.dirname(output_path), exist_ok=True)
127
  json_dumps([r.model_dump() for r in results], output_path)
scripts/7_prepare_circle_names.py CHANGED
@@ -8,7 +8,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
 
9
  from utils.logger import setup_logger
10
  from utils.json import get_file_path_from_config, json_dumps
11
- from schemas.projects import Project
12
 
13
  log = setup_logger(__name__)
14
 
@@ -38,7 +38,7 @@ def get_substrings(texts: Dict[any, str]) -> Set[str]:
38
  """テキストから2文字以上の連続部分文字列をすべて抽出する。"""
39
  substrings = set()
40
  for key, text in texts.items():
41
- if key == "projectId":
42
  continue
43
  text = text.replace(" ", "").replace(" ", "")
44
  length = len(text)
@@ -50,11 +50,11 @@ def get_substrings(texts: Dict[any, str]) -> Set[str]:
50
 
51
 
52
  def main():
53
- input_file = get_file_path_from_config("projects.projects_json")
54
 
55
  try:
56
  with open(input_file, encoding="utf-8") as f:
57
- projects = json.load(f)
58
  except FileNotFoundError:
59
  log.error(f"入力ファイルが見つかりません: {input_file}")
60
  sys.exit(1)
@@ -64,15 +64,15 @@ def main():
64
 
65
  # --- 団体名データを生成 ---
66
  output_file_names = get_file_path_from_config("substring.circle_names")
67
- projects = [Project(**item) for item in projects]
68
  circle_names = [
69
  {
70
- "projectId": p.projectId,
71
- "circle": p.circleName,
72
- "circleNormalized": normalized_circle_name(p.circleName),
73
- "circleKana": p.circleNameKana or "",
74
  }
75
- for p in projects
76
  ]
77
 
78
  # 出力先ディレクトリ作成(念のため)
@@ -84,18 +84,18 @@ def main():
84
 
85
  # --- 部分文字列インデックスを生成 ---
86
  output_file_substring = get_file_path_from_config("substring.substring_index")
87
- substring_to_project_ids: Dict[str, List[str]] = defaultdict(list)
88
- for proj in circle_names:
89
- projectId = proj["projectId"]
90
- substrings = get_substrings(proj)
91
  for substr in substrings:
92
- substring_to_project_ids[substr].append(projectId)
93
 
94
  # 出力先ディレクトリ作成(念のため)
95
  os.makedirs(os.path.dirname(output_file_substring), exist_ok=True)
96
 
97
  # JSON に書き出し
98
- json_dumps(substring_to_project_ids, output_file_substring)
99
  log.info(f"部分文字列インデックスを出力しました: {output_file_substring}")
100
 
101
 
 
8
 
9
  from utils.logger import setup_logger
10
  from utils.json import get_file_path_from_config, json_dumps
11
+ from schemas.circles import Circle
12
 
13
  log = setup_logger(__name__)
14
 
 
38
  """テキストから2文字以上の連続部分文字列をすべて抽出する。"""
39
  substrings = set()
40
  for key, text in texts.items():
41
+ if key == "circleId":
42
  continue
43
  text = text.replace(" ", "").replace(" ", "")
44
  length = len(text)
 
50
 
51
 
52
  def main():
53
+ input_file = get_file_path_from_config("circles.circles_json")
54
 
55
  try:
56
  with open(input_file, encoding="utf-8") as f:
57
+ circles = json.load(f)
58
  except FileNotFoundError:
59
  log.error(f"入力ファイルが見つかりません: {input_file}")
60
  sys.exit(1)
 
64
 
65
  # --- 団体名データを生成 ---
66
  output_file_names = get_file_path_from_config("substring.circle_names")
67
+ circles = [Circle(**item) for item in circles]
68
  circle_names = [
69
  {
70
+ "circleId": c.circleId,
71
+ "circle": c.circleName,
72
+ "circleNormalized": normalized_circle_name(c.circleName),
73
+ "circleKana": c.circleNameKana or "",
74
  }
75
+ for c in circles
76
  ]
77
 
78
  # 出力先ディレクトリ作成(念のため)
 
84
 
85
  # --- 部分文字列インデックスを生成 ---
86
  output_file_substring = get_file_path_from_config("substring.substring_index")
87
+ substring_to_circle_ids: Dict[str, List[str]] = defaultdict(list)
88
+ for circle in circle_names:
89
+ circleId = circle["circleId"]
90
+ substrings = get_substrings(circle)
91
  for substr in substrings:
92
+ substring_to_circle_ids[substr].append(circleId)
93
 
94
  # 出力先ディレクトリ作成(念のため)
95
  os.makedirs(os.path.dirname(output_file_substring), exist_ok=True)
96
 
97
  # JSON に書き出し
98
+ json_dumps(substring_to_circle_ids, output_file_substring)
99
  log.info(f"部分文字列インデックスを出力しました: {output_file_substring}")
100
 
101
 
scripts/build_all.py CHANGED
@@ -30,8 +30,8 @@ def main():
30
  # Step 1: Sudachi user dict (optional but recommended before tokenization)
31
  run_step([sys.executable, "scripts/1_build_dict.py"], allow_fail=True)
32
 
33
- # Step 2: projects.json
34
- run_step([sys.executable, "scripts/2_create_projects_data.py"])
35
 
36
  # Step 3: synonyms cache
37
  run_step([sys.executable, "scripts/3_build_synonyms_from_sudachi.py"]) # idempotent
 
30
  # Step 1: Sudachi user dict (optional but recommended before tokenization)
31
  run_step([sys.executable, "scripts/1_build_dict.py"], allow_fail=True)
32
 
33
+ # Step 2: circles.json
34
+ run_step([sys.executable, "scripts/2_create_circles_data.py"])
35
 
36
  # Step 3: synonyms cache
37
  run_step([sys.executable, "scripts/3_build_synonyms_from_sudachi.py"]) # idempotent