Zun Iguchi commited on
Commit
680845f
·
unverified ·
2 Parent(s): 0d8c5ed2f9b2bf

Merge pull request #11 from chibafes-dev/improve/issues-7

Browse files
{api → app/api}/__init__.py RENAMED
File without changes
app/db/session.py ADDED
File without changes
{api → app}/main.py RENAMED
@@ -12,9 +12,10 @@ from pydantic import BaseModel
12
  from dotenv import load_dotenv
13
 
14
  sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
 
15
 
16
  from utils.logger import setup_logger
17
- from api.search.engine import SearchEngine
18
  import schemas.projects as schema_projects
19
 
20
  log = setup_logger(__name__)
 
12
  from dotenv import load_dotenv
13
 
14
  sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
15
+ sys.path.append(os.path.dirname(__file__))
16
 
17
  from utils.logger import setup_logger
18
+ from search.engine import SearchEngine
19
  import schemas.projects as schema_projects
20
 
21
  log = setup_logger(__name__)
app/repositories/projects_repository.py ADDED
File without changes
{api → app}/search/__init__.py RENAMED
File without changes
{api → app}/search/engine.py RENAMED
@@ -1,8 +1,9 @@
1
  import json
2
  import os
3
  import sys
 
4
  from dataclasses import dataclass
5
- from typing import Any, Dict, List, Optional, Tuple
6
 
7
  import numpy as np
8
  from sudachipy import dictionary, tokenizer
@@ -68,8 +69,10 @@ class SearchEngine:
68
  # Data
69
  self.projects: List[Dict[str, Any]] = []
70
  self.project_map: Dict[str, Dict[str, Any]] = {}
 
71
  self.org_norms: Dict[str, str] = {}
72
  self.reading_norms: Dict[str, str] = {}
 
73
 
74
  # BM25F assets
75
  self.idf: Dict[str, float] = {}
@@ -146,10 +149,22 @@ class SearchEngine:
146
  except Exception:
147
  pass
148
 
 
 
 
 
 
 
 
 
 
 
 
149
  # Projects
150
  with open(files("projects.projects_json"), encoding="utf-8") as f:
151
  self.projects = json.load(f)
152
  self.project_map = {p["projectId"]: p for p in self.projects}
 
153
  self.org_norms = {
154
  p["projectId"]: normalize_text_for_org(p.get("organization") or "")
155
  for p in self.projects
@@ -238,6 +253,82 @@ class SearchEngine:
238
  max_q = int(self.cfg.syn_limits.get("max_query_variants", 5))
239
  return expanded[: max_q * max_exp + len(terms)]
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  # ----- BM25F -----
242
  def _bm25f_scores(self, terms: List[str]) -> np.ndarray:
243
  N = len(self.tf_token_docs)
@@ -419,6 +510,17 @@ class SearchEngine:
419
  if self.cfg.synonyms_enable:
420
  terms = self._expand_synonyms(terms)
421
 
 
 
 
 
 
 
 
 
 
 
 
422
  # BM25F
423
  bm25 = self._bm25f_scores(terms)
424
 
@@ -432,6 +534,7 @@ class SearchEngine:
432
  # Organization/reading auto-boost based on raw query substring match
433
  qn = normalize_text_for_org(query)
434
  boost_enabled = len(qn) >= int(self.cfg.org_boost_min_len)
 
435
  if boost_enabled:
436
  exact = np.zeros((len(self.projects),), dtype=bool)
437
  prefix = np.zeros_like(exact)
@@ -452,7 +555,6 @@ class SearchEngine:
452
  + prefix.astype(np.float32) * float(self.cfg.org_boost_prefix)
453
  + substr.astype(np.float32) * float(self.cfg.org_boost_substring)
454
  )
455
- pass
456
 
457
  # collect results
458
  ids = [d.get("projectId") for d in self.projects]
@@ -474,24 +576,42 @@ class SearchEngine:
474
  | (ws_filter >= self.cfg.word_sim_min)
475
  | (score_with_boost >= self.cfg.fused_min)
476
  ) & (score_with_boost >= fused_cut)
 
 
477
  order = np.argsort(-score_with_boost) # descending by fused
478
  selected_idx: List[int] = []
 
 
 
 
 
 
479
  for i in order:
480
- if keep[i]:
481
- selected_idx.append(int(i))
482
- if len(selected_idx) >= self.cfg.max_results:
 
 
 
 
 
483
  break
484
  # Single-step fallback: if zero, relax the relative cut and use absolute thresholds only
485
- if len(selected_idx) == 0:
486
  keep2 = (
487
  (bm25 >= self.cfg.bm25_min)
488
  | (ws_filter >= self.cfg.word_sim_min)
489
  | (score_with_boost >= self.cfg.fused_min)
490
  )
491
  for i in order:
492
- if keep2[i]:
493
- selected_idx.append(int(i))
494
- if len(selected_idx) >= self.cfg.max_results:
 
 
 
 
 
495
  break
496
  # Rerank with pair-avg word similarity (if enabled)
497
  ws_rerank = None
@@ -510,20 +630,28 @@ class SearchEngine:
510
  pairs.sort(key=lambda x: (-x[1], x[0]))
511
  if not debug:
512
  return pairs
513
- # build debug details for selected docs
 
 
 
 
514
  details = []
515
- for i in selected_idx:
 
516
  details.append(
517
  {
518
- "projectId": ids[i],
519
- "bm25": float(bm25[i]),
520
- "ws_filter_topk": float(ws_filter[i]),
521
- "ws_rerank_pairavg": float(ws_rerank[i])
 
 
522
  if ws_rerank is not None
523
  else None,
524
- "org_boost": float(boost[i]) if boost_enabled else None,
525
- "fused_filter": float(fused_filter[i]),
526
- "fused_final": float(final_scores[i]),
 
527
  }
528
  )
529
  return pairs, {"details": details}
 
1
  import json
2
  import os
3
  import sys
4
+ import unicodedata
5
  from dataclasses import dataclass
6
+ from typing import Any, Dict, List, Optional, Set, Tuple
7
 
8
  import numpy as np
9
  from sudachipy import dictionary, tokenizer
 
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]] = {}
76
 
77
  # BM25F assets
78
  self.idf: Dict[str, float] = {}
 
149
  except Exception:
150
  pass
151
 
152
+ # Substring index for organization substring lookup
153
+ substring_index_path = files("substring.substring_index")
154
+ if os.path.exists(substring_index_path):
155
+ try:
156
+ with open(substring_index_path, encoding="utf-8") as f:
157
+ self.substring_index = json.load(f)
158
+ except Exception as e:
159
+ log.warning(f"failed to load substring_index: {e}")
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("organization") or "")
170
  for p in self.projects
 
253
  max_q = int(self.cfg.syn_limits.get("max_query_variants", 5))
254
  return expanded[: max_q * max_exp + len(terms)]
255
 
256
+ @staticmethod
257
+ def _katakana_to_hiragana(text: str) -> str:
258
+ if not text:
259
+ return ""
260
+ chars: List[str] = []
261
+ for ch in text:
262
+ code = ord(ch)
263
+ if 0x30A1 <= code <= 0x30F6:
264
+ chars.append(chr(code - 0x60))
265
+ else:
266
+ chars.append(ch)
267
+ return "".join(chars)
268
+
269
+ def _normalize_substring_token(self, token: str) -> str:
270
+ if not token:
271
+ return ""
272
+ try:
273
+ token_nfkc = unicodedata.normalize("NFKC", token)
274
+ except Exception:
275
+ token_nfkc = token
276
+
277
+ readings: List[str] = []
278
+ if self.tokenizer is not None:
279
+ try:
280
+ for m in self.tokenizer.tokenize(
281
+ token_nfkc, tokenizer.Tokenizer.SplitMode.C
282
+ ):
283
+ reading = m.reading_form()
284
+ if not reading or reading == "*":
285
+ reading = m.normalized_form()
286
+ if reading:
287
+ readings.append(reading)
288
+ except Exception:
289
+ readings = []
290
+
291
+ reading = "".join(readings) if readings else token_nfkc
292
+ lowered = reading.lower()
293
+ hira = self._katakana_to_hiragana(lowered)
294
+
295
+ normalized_chars: List[str] = []
296
+ for ch in hira:
297
+ if ch in ("\u0020", "\u3000"):
298
+ continue
299
+ category = unicodedata.category(ch)
300
+ if category.startswith("P") or category.startswith("S"):
301
+ if ch != "ー":
302
+ continue
303
+ normalized_chars.append(ch)
304
+ return "".join(normalized_chars)
305
+
306
+ def _normalize_substring_terms(self, query: str) -> List[str]:
307
+ if not query:
308
+ return []
309
+ try:
310
+ normalized_query = unicodedata.normalize("NFKC", query)
311
+ except Exception:
312
+ normalized_query = query
313
+
314
+ out: List[str] = []
315
+ for raw in normalized_query.split():
316
+ term = self._normalize_substring_token(raw)
317
+ if term:
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)
325
+ matches: Set[str] = set()
326
+ for term in terms:
327
+ if len(term) < 2:
328
+ continue
329
+ matches.update(self.substring_index.get(term, []))
330
+ return matches
331
+
332
  # ----- BM25F -----
333
  def _bm25f_scores(self, terms: List[str]) -> np.ndarray:
334
  N = len(self.tf_token_docs)
 
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)
522
+ substring_mask[idx] = True
523
+
524
  # BM25F
525
  bm25 = self._bm25f_scores(terms)
526
 
 
534
  # Organization/reading 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)
 
555
  + prefix.astype(np.float32) * float(self.cfg.org_boost_prefix)
556
  + substr.astype(np.float32) * float(self.cfg.org_boost_substring)
557
  )
 
558
 
559
  # collect results
560
  ids = [d.get("projectId") for d in self.projects]
 
576
  | (ws_filter >= self.cfg.word_sim_min)
577
  | (score_with_boost >= self.cfg.fused_min)
578
  ) & (score_with_boost >= fused_cut)
579
+ if substring_idx_set:
580
+ keep = keep | substring_mask
581
  order = np.argsort(-score_with_boost) # descending by fused
582
  selected_idx: List[int] = []
583
+ selected_idx_set: Set[int] = set()
584
+ substring_sorted = sorted(substring_idx_set, key=lambda i: -score_with_boost[i])
585
+ for idx in substring_sorted:
586
+ selected_idx.append(int(idx))
587
+ selected_idx_set.add(int(idx))
588
+ non_sub_count = 0
589
  for i in order:
590
+ idx = int(i)
591
+ if idx in selected_idx_set:
592
+ continue
593
+ if keep[idx]:
594
+ selected_idx.append(idx)
595
+ selected_idx_set.add(idx)
596
+ non_sub_count += 1
597
+ if non_sub_count >= self.cfg.max_results:
598
  break
599
  # Single-step fallback: if zero, relax the relative cut and use absolute thresholds only
600
+ if not selected_idx_set:
601
  keep2 = (
602
  (bm25 >= self.cfg.bm25_min)
603
  | (ws_filter >= self.cfg.word_sim_min)
604
  | (score_with_boost >= self.cfg.fused_min)
605
  )
606
  for i in order:
607
+ idx = int(i)
608
+ if idx in selected_idx_set:
609
+ continue
610
+ if keep2[idx]:
611
+ selected_idx.append(idx)
612
+ selected_idx_set.add(idx)
613
+ non_sub_count += 1
614
+ if non_sub_count >= self.cfg.max_results:
615
  break
616
  # Rerank with pair-avg word similarity (if enabled)
617
  ws_rerank = None
 
630
  pairs.sort(key=lambda x: (-x[1], x[0]))
631
  if not debug:
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
+ "organization": project.get("organization"),
645
+ "title": project.get("title"),
646
+ "bm25": float(bm25[idx]),
647
+ "ws_filter_topk": float(ws_filter[idx]),
648
+ "ws_rerank_pairavg": float(ws_rerank[idx])
649
  if ws_rerank is not None
650
  else None,
651
+ "org_boost": float(boost[idx]) if boost_enabled else None,
652
+ "matched_substring": bool(substring_mask[idx]),
653
+ "fused_filter": float(fused_filter[idx]),
654
+ "fused_final": float(final_scores[idx]),
655
  }
656
  )
657
  return pairs, {"details": details}
config/db/connection.md ADDED
File without changes
config/files.json CHANGED
@@ -15,6 +15,10 @@
15
  "bm25_meta": "data/generated/bm25_meta.json",
16
  "tf_token": "data/generated/tf_token.json"
17
  },
 
 
 
 
18
  "embeddings": {
19
  "fasttext_vec": "resources/embeddings/cc.ja.300.vec",
20
  "fasttext_bin": "resources/embeddings/cc.ja.300.bin",
 
15
  "bm25_meta": "data/generated/bm25_meta.json",
16
  "tf_token": "data/generated/tf_token.json"
17
  },
18
+ "substring": {
19
+ "circle_names": "data/generated/circle_names.json",
20
+ "substring_index": "data/generated/substring_index.json"
21
+ },
22
  "embeddings": {
23
  "fasttext_vec": "resources/embeddings/cc.ja.300.vec",
24
  "fasttext_bin": "resources/embeddings/cc.ja.300.bin",
config/search_model.json CHANGED
@@ -45,7 +45,7 @@
45
  "word_sim": {
46
  "enable": true,
47
  "mode": "topk",
48
- "alpha": 0.3,
49
  "topk_k": 3,
50
  "rerank": "pair_avg"
51
  },
 
45
  "word_sim": {
46
  "enable": true,
47
  "mode": "topk",
48
+ "alpha": 0.5,
49
  "topk_k": 3,
50
  "rerank": "pair_avg"
51
  },
scripts/3_build_synonyms_from_sudachi.py CHANGED
@@ -12,7 +12,7 @@ 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
- import schemas.projects as projects_schema
16
 
17
  # --- ロギングの設定 ---
18
  log = setup_logger(__name__)
@@ -65,7 +65,7 @@ def tokenize(text: str) -> list[str]:
65
  return tokens
66
 
67
 
68
- def get_corpus_vocab(projects: list[projects_schema.Project]) -> set[str]:
69
  """プロジェクト全体から語彙セットを構築する"""
70
  vocab = set()
71
  log.info("語彙セットを構築中...")
@@ -125,7 +125,7 @@ def main():
125
  with open(input_file, encoding="utf-8") as f:
126
  try:
127
  project_dicts = json.load(f)
128
- projects = [projects_schema.Project(**item) for item in project_dicts]
129
  except json.JSONDecodeError as e:
130
  log.error(f"JSONデコードエラー: {e}")
131
  sys.exit(1)
 
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__)
 
65
  return tokens
66
 
67
 
68
+ def get_corpus_vocab(projects: list[Project]) -> set[str]:
69
  """プロジェクト全体から語彙セットを構築する"""
70
  vocab = set()
71
  log.info("語彙セットを構築中...")
 
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)
scripts/4_prepare_bm25f_meta.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
2
  import sys
3
  import json
 
4
  from collections import defaultdict
5
  from typing import Dict, List
6
 
@@ -37,7 +38,9 @@ def build_tokenizer(sudachi_config_path: str):
37
  return tok, mode
38
 
39
 
40
- def tokenize(text: str, tok, mode, target_pos_l1: List[str], ban_list: set, stopwords: set) -> List[str]:
 
 
41
  if not text:
42
  return []
43
  out: List[str] = []
@@ -57,13 +60,19 @@ def tokenize(text: str, tok, mode, target_pos_l1: List[str], ban_list: set, stop
57
  def main():
58
  log.info("BM25Fメタデータ(bm25_meta.json)を生成します")
59
  try:
60
- target_pos_l1, target_fields, ban_list, stopwords, sudachi_config_path = load_configs()
 
 
61
  except Exception as e:
62
  log.error(f"設定の読み込みに失敗しました: {e}")
63
  sys.exit(1)
64
 
65
- projects_path = get_file_path_from_config("projects.projects_json", "data/generated/projects.json")
66
- output_path = get_file_path_from_config("bm25.bm25_meta", "data/generated/bm25_meta.json")
 
 
 
 
67
 
68
  try:
69
  with open(projects_path, encoding="utf-8") as f:
@@ -94,13 +103,14 @@ def main():
94
  # IDF 計算(BM25で一般的な +0.5 smoothing と +1 オフセット)
95
  idf: Dict[str, float] = {}
96
  for term, dfi in df.items():
97
- idf_val = max(0.0, ( ( (N - dfi + 0.5) / (dfi + 0.5) ) ))
98
  # 数値安定化のためlog1p
99
- import math
100
-
101
  idf[term] = math.log1p(idf_val)
102
 
103
- avg_len = {field: (field_token_lens_sum[field] / N if N > 0 else 0.0) for field in target_fields}
 
 
 
104
 
105
  meta = {
106
  "N": N,
 
1
  import os
2
  import sys
3
  import json
4
+ import math
5
  from collections import defaultdict
6
  from typing import Dict, List
7
 
 
38
  return tok, mode
39
 
40
 
41
+ def tokenize(
42
+ text: str, tok, mode, target_pos_l1: List[str], ban_list: set, stopwords: set
43
+ ) -> List[str]:
44
  if not text:
45
  return []
46
  out: List[str] = []
 
60
  def main():
61
  log.info("BM25Fメタデータ(bm25_meta.json)を生成します")
62
  try:
63
+ target_pos_l1, target_fields, ban_list, stopwords, sudachi_config_path = (
64
+ load_configs()
65
+ )
66
  except Exception as e:
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:
 
103
  # IDF 計算(BM25で一般的な +0.5 smoothing と +1 オフセット)
104
  idf: Dict[str, float] = {}
105
  for term, dfi in df.items():
106
+ idf_val = max(0.0, ((N - dfi + 0.5) / (dfi + 0.5)))
107
  # 数値安定化のためlog1p
 
 
108
  idf[term] = math.log1p(idf_val)
109
 
110
+ avg_len = {
111
+ field: (field_token_lens_sum[field] / N if N > 0 else 0.0)
112
+ for field in target_fields
113
+ }
114
 
115
  meta = {
116
  "N": N,
scripts/5_prepare_tf_token.py CHANGED
@@ -10,7 +10,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
10
 
11
  from utils.logger import setup_logger
12
  from utils.json import get_file_path_from_config, field_getter, json_dumps
13
- import schemas.tf_token as tf_schema
14
 
15
  log = setup_logger(__name__)
16
 
@@ -38,7 +38,9 @@ def build_tokenizer(sudachi_config_path: str):
38
  return tok, mode
39
 
40
 
41
- def tokenize(text: str, tok, mode, target_pos_l1: List[str], ban_list: set, stopwords: set) -> List[str]:
 
 
42
  if not text:
43
  return []
44
  out: List[str] = []
@@ -58,13 +60,19 @@ def tokenize(text: str, tok, mode, target_pos_l1: List[str], ban_list: set, stop
58
  def main():
59
  log.info("フィールド別TF/トークン(tf_token.json)を生成します")
60
  try:
61
- target_pos_l1, target_fields, ban_list, stopwords, sudachi_config_path = load_configs()
 
 
62
  except Exception as e:
63
  log.error(f"設定の読み込みに失敗しました: {e}")
64
  sys.exit(1)
65
 
66
- projects_path = get_file_path_from_config("projects.projects_json", "data/generated/projects.json")
67
- output_path = get_file_path_from_config("bm25.tf_token", "data/generated/tf_token.json")
 
 
 
 
68
 
69
  try:
70
  with open(projects_path, encoding="utf-8") as f:
@@ -75,13 +83,13 @@ def main():
75
 
76
  tok, mode = build_tokenizer(sudachi_config_path)
77
 
78
- results: List[tf_schema.Project] = []
79
 
80
  for p in projects:
81
  project_id = p.get("projectId")
82
 
83
  # 各フィールドのトークン化とTF
84
- field_objs: Dict[str, tf_schema.TfOfField] = {}
85
  doc_tf_counter: Counter = Counter()
86
  doc_token_set: set = set()
87
 
@@ -89,15 +97,15 @@ def main():
89
  text = p.get(field) or ""
90
  toks = tokenize(str(text), tok, mode, target_pos_l1, ban_list, stopwords)
91
  tf = Counter(toks)
92
- field_objs[field] = tf_schema.TfOfField(len=len(toks), tf=dict(tf))
93
  doc_tf_counter.update(tf)
94
  doc_token_set.update(tf.keys())
95
 
96
  # スキーマ Fields へ詰める(未定義フィールドは長さ0/空dictで埋める)
97
- def get_field(name: str) -> tf_schema.TfOfField:
98
- return field_objs.get(name, tf_schema.TfOfField(len=0, tf={}))
99
 
100
- fields_obj = tf_schema.Fields(
101
  title=get_field("title"),
102
  organization=get_field("organization"),
103
  reading=get_field("reading"),
@@ -106,7 +114,7 @@ def main():
106
  prCommentLong=get_field("prCommentLong"),
107
  )
108
 
109
- project_entry = tf_schema.Project(
110
  projectId=project_id,
111
  fields=fields_obj,
112
  tf=dict(doc_tf_counter),
 
10
 
11
  from utils.logger import setup_logger
12
  from utils.json import get_file_path_from_config, field_getter, json_dumps
13
+ from schemas import tf_token
14
 
15
  log = setup_logger(__name__)
16
 
 
38
  return tok, mode
39
 
40
 
41
+ def tokenize(
42
+ text: str, tok, mode, target_pos_l1: List[str], ban_list: set, stopwords: set
43
+ ) -> List[str]:
44
  if not text:
45
  return []
46
  out: List[str] = []
 
60
  def main():
61
  log.info("フィールド別TF/トークン(tf_token.json)を生成します")
62
  try:
63
+ target_pos_l1, target_fields, ban_list, stopwords, sudachi_config_path = (
64
+ load_configs()
65
+ )
66
  except Exception as e:
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:
 
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] = {}
93
  doc_tf_counter: Counter = Counter()
94
  doc_token_set: set = set()
95
 
 
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))
101
  doc_tf_counter.update(tf)
102
  doc_token_set.update(tf.keys())
103
 
104
  # スキーマ Fields へ詰める(未定義フィールドは長さ0/空dictで埋める)
105
+ def get_field(name: str) -> tf_token.TfOfField:
106
+ return field_objs.get(name, tf_token.TfOfField(len=0, tf={}))
107
 
108
+ fields_obj = tf_token.Fields(
109
  title=get_field("title"),
110
  organization=get_field("organization"),
111
  reading=get_field("reading"),
 
114
  prCommentLong=get_field("prCommentLong"),
115
  )
116
 
117
+ project_entry = tf_token.Project(
118
  projectId=project_id,
119
  fields=fields_obj,
120
  tf=dict(doc_tf_counter),
scripts/7_prepare_circle_names.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ from collections import defaultdict
5
+ from typing import Dict, List, Set
6
+
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
+
15
+
16
+ def normalized_circle_name(name: str) -> str:
17
+ """
18
+ サークル名の正規化を行う。
19
+ - 漢字・ひらがな・カタカナはそのまま
20
+ - 半角英数字は小文字に変換
21
+ - 記号類(!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~・/?<>()【】?|:)を除く
22
+ """
23
+ SYMBOLS = set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~・/?<>()【】?|:")
24
+ if not name:
25
+ return ""
26
+ normalized = []
27
+ for char in name:
28
+ if "A" <= char <= "Z":
29
+ normalized.append(char.lower())
30
+ elif char in SYMBOLS:
31
+ continue
32
+ else:
33
+ normalized.append(char)
34
+ return "".join(normalized)
35
+
36
+
37
+ 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)
45
+ for start in range(length):
46
+ for end in range(start + 2, length + 1):
47
+ substr = text[start:end]
48
+ substrings.add(substr)
49
+ return substrings
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)
61
+ except json.JSONDecodeError as e:
62
+ log.error(f"JSONデコードエラー: {e}")
63
+ sys.exit(1)
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.organization,
72
+ "circleNormalized": normalized_circle_name(p.organization),
73
+ "circleKana": p.reading,
74
+ }
75
+ for p in projects
76
+ ]
77
+
78
+ # 出力先ディレクトリ作成(念のため)
79
+ os.makedirs(os.path.dirname(output_file_names), exist_ok=True)
80
+
81
+ # JSON に書き出し
82
+ json_dumps(circle_names, output_file_names)
83
+ log.info(f"サークル名データを出力しました: {output_file_names}")
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
+
102
+ if __name__ == "__main__":
103
+ main()
scripts/build_all.py CHANGED
@@ -51,6 +51,10 @@ def main():
51
  else:
52
  log.info(".vec が見つからないため Step 6 をスキップします: %s", vec_path)
53
 
 
 
 
 
54
  log.info("全ステップ完了")
55
 
56
 
 
51
  else:
52
  log.info(".vec が見つからないため Step 6 をスキップします: %s", vec_path)
53
 
54
+ # Step 7: substring index
55
+ run_step(
56
+ [sys.executable, "scripts/7_build_substring_index.py"]
57
+ ) # needs projects.json
58
  log.info("全ステップ完了")
59
 
60