Zun Iguchi commited on
Commit
d7c1fde
·
unverified ·
2 Parent(s): ab5c9844504888

Merge pull request #10 from chibafes-dev/codex/substring-engine

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,80 @@ 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 +508,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
 
@@ -474,24 +574,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
@@ -522,6 +640,7 @@ class SearchEngine:
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
  }
 
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(token_nfkc, self.mode):
281
+ reading = m.reading_form()
282
+ if not reading or reading == "*":
283
+ reading = m.normalized_form()
284
+ if reading:
285
+ readings.append(reading)
286
+ except Exception:
287
+ readings = []
288
+
289
+ reading = "".join(readings) if readings else token_nfkc
290
+ lowered = reading.lower()
291
+ hira = self._katakana_to_hiragana(lowered)
292
+
293
+ normalized_chars: List[str] = []
294
+ for ch in hira:
295
+ if ch in ("\u0020", "\u3000"):
296
+ continue
297
+ category = unicodedata.category(ch)
298
+ if category.startswith("P") or category.startswith("S"):
299
+ if ch != "ー":
300
+ continue
301
+ normalized_chars.append(ch)
302
+ return "".join(normalized_chars)
303
+
304
+ def _normalize_substring_terms(self, query: str) -> List[str]:
305
+ if not query:
306
+ return []
307
+ try:
308
+ normalized_query = unicodedata.normalize("NFKC", query)
309
+ except Exception:
310
+ normalized_query = query
311
+
312
+ out: List[str] = []
313
+ for raw in normalized_query.split():
314
+ term = self._normalize_substring_token(raw)
315
+ if term:
316
+ out.append(term)
317
+ return out
318
+
319
+ def _substring_match_project_ids(self, query: str) -> Set[str]:
320
+ if not self.substring_index:
321
+ return set()
322
+ terms = self._normalize_substring_terms(query)
323
+ matches: Set[str] = set()
324
+ for term in terms:
325
+ if len(term) < 2:
326
+ continue
327
+ matches.update(self.substring_index.get(term, []))
328
+ return matches
329
+
330
  # ----- BM25F -----
331
  def _bm25f_scores(self, terms: List[str]) -> np.ndarray:
332
  N = len(self.tf_token_docs)
 
508
  if self.cfg.synonyms_enable:
509
  terms = self._expand_synonyms(terms)
510
 
511
+ substring_hits = self._substring_match_project_ids(query)
512
+ substring_idx_set: Set[int] = set()
513
+ substring_mask = np.zeros((len(self.projects),), dtype=bool)
514
+ if substring_hits:
515
+ for pid in substring_hits:
516
+ idx = self.project_idx.get(pid)
517
+ if idx is None:
518
+ continue
519
+ substring_idx_set.add(idx)
520
+ substring_mask[idx] = True
521
+
522
  # BM25F
523
  bm25 = self._bm25f_scores(terms)
524
 
 
574
  | (ws_filter >= self.cfg.word_sim_min)
575
  | (score_with_boost >= self.cfg.fused_min)
576
  ) & (score_with_boost >= fused_cut)
577
+ if substring_idx_set:
578
+ keep = keep | substring_mask
579
  order = np.argsort(-score_with_boost) # descending by fused
580
  selected_idx: List[int] = []
581
+ selected_idx_set: Set[int] = set()
582
+ substring_sorted = sorted(substring_idx_set, key=lambda i: -score_with_boost[i])
583
+ for idx in substring_sorted:
584
+ selected_idx.append(int(idx))
585
+ selected_idx_set.add(int(idx))
586
+ non_sub_count = 0
587
  for i in order:
588
+ idx = int(i)
589
+ if idx in selected_idx_set:
590
+ continue
591
+ if keep[idx]:
592
+ selected_idx.append(idx)
593
+ selected_idx_set.add(idx)
594
+ non_sub_count += 1
595
+ if non_sub_count >= self.cfg.max_results:
596
  break
597
  # Single-step fallback: if zero, relax the relative cut and use absolute thresholds only
598
+ if not selected_idx_set:
599
  keep2 = (
600
  (bm25 >= self.cfg.bm25_min)
601
  | (ws_filter >= self.cfg.word_sim_min)
602
  | (score_with_boost >= self.cfg.fused_min)
603
  )
604
  for i in order:
605
+ idx = int(i)
606
+ if idx in selected_idx_set:
607
+ continue
608
+ if keep2[idx]:
609
+ selected_idx.append(idx)
610
+ selected_idx_set.add(idx)
611
+ non_sub_count += 1
612
+ if non_sub_count >= self.cfg.max_results:
613
  break
614
  # Rerank with pair-avg word similarity (if enabled)
615
  ws_rerank = None
 
640
  if ws_rerank is not None
641
  else None,
642
  "org_boost": float(boost[i]) if boost_enabled else None,
643
+ "matched_substring": bool(substring_mask[i]),
644
  "fused_filter": float(fused_filter[i]),
645
  "fused_final": float(final_scores[i]),
646
  }
config/db/connection.md ADDED
File without changes