source-kb 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. cli/__init__.py +50 -0
  2. cli/__main__.py +5 -0
  3. cli/commands/__init__.py +1 -0
  4. cli/commands/anchor_fix.py +47 -0
  5. cli/commands/diff_doc.py +52 -0
  6. cli/commands/dispatch.py +77 -0
  7. cli/commands/extract.py +72 -0
  8. cli/commands/file_list.py +74 -0
  9. cli/commands/index.py +84 -0
  10. cli/commands/lock.py +89 -0
  11. cli/commands/merge.py +60 -0
  12. cli/commands/merge_delta.py +19 -0
  13. cli/commands/metadata.py +24 -0
  14. cli/commands/pipeline.py +45 -0
  15. cli/commands/post_merge.py +43 -0
  16. cli/commands/query.py +52 -0
  17. cli/commands/render.py +101 -0
  18. cli/commands/scan_repos.py +46 -0
  19. cli/commands/setup.py +94 -0
  20. cli/commands/split.py +196 -0
  21. cli/commands/stale_files.py +98 -0
  22. cli/commands/validate.py +191 -0
  23. core/__init__.py +32 -0
  24. core/config.py +261 -0
  25. core/docs/__init__.py +7 -0
  26. core/docs/section_updater.py +286 -0
  27. core/docs/shared.py +149 -0
  28. core/git.py +294 -0
  29. core/interfaces.py +249 -0
  30. core/monitor/__init__.py +5 -0
  31. core/monitor/progress.py +83 -0
  32. core/monitor/prompt_store.py +49 -0
  33. core/paths.py +141 -0
  34. core/preset.py +237 -0
  35. core/preset_accessors.py +202 -0
  36. core/preset_classify.py +132 -0
  37. core/preset_hooks.py +129 -0
  38. core/preset_profile.py +89 -0
  39. core/prompt/__init__.py +7 -0
  40. core/prompt/__main__.py +147 -0
  41. core/prompt/content.py +320 -0
  42. core/prompt/context_manager.py +164 -0
  43. core/prompt/renderer.py +236 -0
  44. core/prompt/response_parser.py +274 -0
  45. core/prompt/templates.py +357 -0
  46. core/prompt/validate_parity.py +162 -0
  47. core/prompt/variables.py +339 -0
  48. core/rag/__init__.py +22 -0
  49. core/rag/__main__.py +136 -0
  50. core/rag/bm25_index.py +268 -0
  51. core/rag/chunker.py +273 -0
  52. core/rag/embedder.py +151 -0
  53. core/rag/indexer.py +292 -0
  54. core/rag/loader.py +89 -0
  55. core/rag/retriever.py +82 -0
  56. core/skeleton/__init__.py +11 -0
  57. core/skeleton/__main__.py +934 -0
  58. core/skeleton/anchor_fix.py +250 -0
  59. core/skeleton/classify.py +331 -0
  60. core/skeleton/cmd_anchor_fix.py +43 -0
  61. core/skeleton/cmd_diff_doc.py +44 -0
  62. core/skeleton/cmd_lock.py +87 -0
  63. core/skeleton/cmd_merge_delta.py +41 -0
  64. core/skeleton/community.py +233 -0
  65. core/skeleton/dependency_graph.py +306 -0
  66. core/skeleton/diff_doc.py +248 -0
  67. core/skeleton/dispatch.py +273 -0
  68. core/skeleton/dispatch_render.py +319 -0
  69. core/skeleton/dispatch_source.py +111 -0
  70. core/skeleton/extract.py +218 -0
  71. core/skeleton/extract_methods.py +298 -0
  72. core/skeleton/file_list.py +239 -0
  73. core/skeleton/impact.py +278 -0
  74. core/skeleton/jar_download.py +177 -0
  75. core/skeleton/jar_resolver.py +186 -0
  76. core/skeleton/loader.py +162 -0
  77. core/skeleton/merge.py +278 -0
  78. core/skeleton/merge_delta.py +229 -0
  79. core/skeleton/metadata.py +96 -0
  80. core/skeleton/metadata_builders.py +264 -0
  81. core/skeleton/module_dag.py +330 -0
  82. core/skeleton/parsers/__init__.py +71 -0
  83. core/skeleton/parsers/jqassistant.py +300 -0
  84. core/skeleton/parsers/jqassistant_cypher.py +225 -0
  85. core/skeleton/parsers/regex.py +171 -0
  86. core/skeleton/parsers/treesitter.py +324 -0
  87. core/skeleton/parsers/treesitter_java.py +284 -0
  88. core/skeleton/parsers/treesitter_multi.py +289 -0
  89. core/skeleton/pom_parser.py +299 -0
  90. core/skeleton/post_merge.py +295 -0
  91. core/skeleton/post_merge_llm.py +82 -0
  92. core/skeleton/query.py +195 -0
  93. core/skeleton/shard_context.py +177 -0
  94. core/skeleton/split.py +180 -0
  95. core/skeleton/split_cache.py +107 -0
  96. core/skeleton/split_feedback.py +174 -0
  97. core/skeleton/split_plan.py +219 -0
  98. core/skeleton/split_plan_helpers.py +305 -0
  99. core/skeleton/split_plan_llm.py +274 -0
  100. core/utils.py +135 -0
  101. core/validators/__init__.py +65 -0
  102. core/validators/__main__.py +215 -0
  103. core/validators/consistency.py +203 -0
  104. core/validators/coverage.py +171 -0
  105. core/validators/duplicates.py +76 -0
  106. core/validators/engine.py +224 -0
  107. core/validators/links.py +76 -0
  108. core/validators/sampling.py +169 -0
  109. core/validators/structure.py +144 -0
  110. engine/__init__.py +7 -0
  111. engine/assembler.py +231 -0
  112. engine/confirm.py +65 -0
  113. engine/dedup.py +106 -0
  114. engine/main.py +211 -0
  115. engine/pipeline/__init__.py +163 -0
  116. engine/pipeline/recovery.py +250 -0
  117. engine/pipeline/steps/__init__.py +23 -0
  118. engine/pipeline/steps/audit.py +220 -0
  119. engine/pipeline/steps/audit_apply.py +195 -0
  120. engine/pipeline/steps/audit_helpers.py +155 -0
  121. engine/pipeline/steps/classify_llm.py +236 -0
  122. engine/pipeline/steps/classify_prompt.py +223 -0
  123. engine/pipeline/steps/finalize.py +160 -0
  124. engine/pipeline/steps/generate.py +169 -0
  125. engine/pipeline/steps/generate_batch.py +197 -0
  126. engine/pipeline/steps/generate_recovery.py +170 -0
  127. engine/pipeline/steps/llm_plan_split.py +253 -0
  128. engine/pipeline/steps/lock.py +64 -0
  129. engine/pipeline/steps/preflight.py +237 -0
  130. engine/pipeline/steps/preflight_adjust.py +147 -0
  131. engine/pipeline/steps/pregenerate.py +130 -0
  132. engine/pipeline/steps/quality.py +81 -0
  133. engine/pipeline/steps/skeleton.py +149 -0
  134. engine/pipeline/steps/source.py +163 -0
  135. engine/pipeline/steps/sync.py +117 -0
  136. engine/pipeline/steps/sync_finalize.py +237 -0
  137. engine/pipeline/steps/sync_update.py +341 -0
  138. engine/pipelines.py +91 -0
  139. engine/runner.py +335 -0
  140. engine/strategies/__init__.py +86 -0
  141. engine/strategies/api.py +128 -0
  142. engine/strategies/delegated.py +50 -0
  143. engine/strategies/dryrun.py +25 -0
  144. engine/two_phase.py +143 -0
  145. mcp_server/__init__.py +73 -0
  146. mcp_server/__main__.py +5 -0
  147. mcp_server/tools/__init__.py +1 -0
  148. mcp_server/tools/config.py +63 -0
  149. mcp_server/tools/discovery.py +276 -0
  150. mcp_server/tools/generation.py +184 -0
  151. mcp_server/tools/planning.py +144 -0
  152. mcp_server/tools/source.py +175 -0
  153. mcp_server/tools/validation.py +140 -0
  154. mcp_server/tools/workflow.py +166 -0
  155. mcp_server/workflow_loader.py +204 -0
  156. presets/generic/audit_dimensions.md +132 -0
  157. presets/generic/doc_types.yaml +152 -0
  158. presets/generic/preset.yaml +115 -0
  159. presets/java-spring/audit_dimensions.md +228 -0
  160. presets/java-spring/audit_dimensions.yaml +203 -0
  161. presets/java-spring/doc_types.yaml +269 -0
  162. presets/java-spring/hooks.py +122 -0
  163. presets/java-spring/preset.yaml +341 -0
  164. presets/java-spring/templates/README.md +34 -0
  165. presets/java-spring/templates/audit-system.md +15 -0
  166. presets/java-spring/templates/subagent-aop.md +105 -0
  167. presets/java-spring/templates/subagent-api.md +63 -0
  168. presets/java-spring/templates/subagent-architecture.md +111 -0
  169. presets/java-spring/templates/subagent-async-events.md +107 -0
  170. presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
  171. presets/java-spring/templates/subagent-audit-architecture.md +38 -0
  172. presets/java-spring/templates/subagent-audit-business.md +40 -0
  173. presets/java-spring/templates/subagent-audit-data-models.md +40 -0
  174. presets/java-spring/templates/subagent-business.md +129 -0
  175. presets/java-spring/templates/subagent-caching.md +75 -0
  176. presets/java-spring/templates/subagent-database-access.md +114 -0
  177. presets/java-spring/templates/subagent-enum.md +75 -0
  178. presets/java-spring/templates/subagent-error-handling.md +91 -0
  179. presets/java-spring/templates/subagent-external-integrations.md +80 -0
  180. presets/java-spring/templates/subagent-index.md +122 -0
  181. presets/java-spring/templates/subagent-messaging.md +97 -0
  182. presets/java-spring/templates/subagent-model.md +88 -0
  183. presets/java-spring/templates/subagent-observability.md +91 -0
  184. presets/java-spring/templates/subagent-scheduled.md +81 -0
  185. presets/java-spring/templates/subagent-security.md +102 -0
  186. presets/java-spring/templates/subagent-structure.md +101 -0
  187. presets/java-spring/templates/subagent-sync-section.md +34 -0
  188. presets/java-spring/templates/subagent-utils.md +73 -0
  189. presets/java-spring/templates/sync-system.md +8 -0
  190. presets/java-spring/workflow-extensions.md +112 -0
  191. skills/__init__.py +1 -0
  192. skills/_shared/README.md +30 -0
  193. skills/_shared/doc-coverage-shared.md +134 -0
  194. skills/_shared/doc-quality-standard.md +1058 -0
  195. skills/_shared/doc-subagent-rules.md +762 -0
  196. skills/_shared/windows-compat.md +89 -0
  197. skills/kb-audit/SKILL.md +52 -0
  198. skills/kb-audit/rules.md +88 -0
  199. skills/kb-audit/steps/step-01-prepare.md +75 -0
  200. skills/kb-audit/steps/step-02-audit.md +96 -0
  201. skills/kb-audit/steps/step-03-verify.md +65 -0
  202. skills/kb-audit/steps/step-04-report.md +64 -0
  203. skills/kb-init/SKILL.md +146 -0
  204. skills/kb-init/rules.md +187 -0
  205. skills/kb-init/steps/step-01-scope.md +62 -0
  206. skills/kb-init/steps/step-02-source.md +410 -0
  207. skills/kb-init/steps/step-03-generate.md +307 -0
  208. skills/kb-init/steps/step-04-quality.md +92 -0
  209. skills/kb-init/steps/step-05-finalize.md +132 -0
  210. skills/kb-init/templates/core/execution-modes.md +29 -0
  211. skills/kb-init/templates/core/output-only.md +4 -0
  212. skills/kb-init/templates/core/readwrite.md +33 -0
  213. skills/kb-search/SKILL.md +138 -0
  214. skills/kb-search/rules.md +64 -0
  215. skills/kb-sync/SKILL.md +43 -0
  216. skills/kb-sync/rules.md +70 -0
  217. skills/kb-sync/scripts/rebuild_module.py +91 -0
  218. skills/kb-sync/scripts/scan_repos.py +687 -0
  219. skills/kb-sync/steps/step-01-detect.md +72 -0
  220. skills/kb-sync/steps/step-02-update.md +71 -0
  221. skills/kb-sync/steps/step-03-verify.md +47 -0
  222. skills/kb-sync/steps/step-04-finalize.md +52 -0
  223. source_kb-0.2.2.dist-info/METADATA +194 -0
  224. source_kb-0.2.2.dist-info/RECORD +228 -0
  225. source_kb-0.2.2.dist-info/WHEEL +5 -0
  226. source_kb-0.2.2.dist-info/entry_points.txt +3 -0
  227. source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
  228. source_kb-0.2.2.dist-info/top_level.txt +6 -0
core/rag/bm25_index.py ADDED
@@ -0,0 +1,268 @@
1
+ """BM25 keyword-based fallback index.
2
+
3
+ Used when embedding API is unavailable or embed_backend is "none".
4
+ Provides the same query interface as the vector index.
5
+
6
+ Storage: {knowledge_dir}/.bm25-index.json
7
+
8
+ Usage:
9
+ from core.rag.bm25_index import BM25Index
10
+
11
+ index = BM25Index(Path("knowledge/my-kb/.bm25-index.json"))
12
+ index.build(Path("knowledge/my-kb"))
13
+ index.save()
14
+
15
+ results = index.search("points issuance", top_k=5)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import math
23
+ import re
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # BM25 parameters
30
+ _K1 = 1.5
31
+ _B = 0.75
32
+
33
+
34
+ @dataclass
35
+ class SearchResult:
36
+ """Single search result."""
37
+
38
+ doc_path: str
39
+ chunk_text: str
40
+ score: float
41
+ metadata: dict | None = None
42
+
43
+
44
+ class BM25Index:
45
+ """BM25 keyword index with Chinese tokenization support."""
46
+
47
+ def __init__(self, index_path: Path):
48
+ self._path = index_path
49
+ self._documents: list[dict] = [] # [{path, chunk_idx, text, terms, tf}]
50
+ self._idf: dict[str, float] = {}
51
+ self._avg_dl: float = 0.0
52
+ self._total_docs: int = 0
53
+
54
+ def build(self, knowledge_dir: Path) -> None:
55
+ """Build index from all .md files in knowledge_dir.
56
+
57
+ Splits documents into chunks (~500-1000 chars) and tokenizes.
58
+ """
59
+ self._documents = []
60
+ md_files = sorted(knowledge_dir.rglob("*.md"))
61
+
62
+ for md_file in md_files:
63
+ if md_file.name.startswith("."):
64
+ continue
65
+ try:
66
+ content = md_file.read_text(encoding="utf-8", errors="replace")
67
+ except OSError:
68
+ continue
69
+
70
+ rel_path = str(md_file.relative_to(knowledge_dir))
71
+ chunks = _split_into_chunks(content)
72
+
73
+ for i, chunk in enumerate(chunks):
74
+ terms = _tokenize(chunk)
75
+ tf = _compute_tf(terms)
76
+ self._documents.append({
77
+ "path": rel_path,
78
+ "chunk_idx": i,
79
+ "text": chunk[:500], # Store truncated for display
80
+ "terms": terms,
81
+ "tf": tf,
82
+ "dl": len(terms),
83
+ })
84
+
85
+ # Compute IDF
86
+ self._total_docs = len(self._documents)
87
+ if self._total_docs == 0:
88
+ return
89
+
90
+ self._avg_dl = sum(d["dl"] for d in self._documents) / self._total_docs
91
+ df: dict[str, int] = {}
92
+ for doc in self._documents:
93
+ seen: set[str] = set()
94
+ for term in doc["terms"]:
95
+ if term not in seen:
96
+ df[term] = df.get(term, 0) + 1
97
+ seen.add(term)
98
+
99
+ self._idf = {
100
+ term: math.log((self._total_docs - freq + 0.5) / (freq + 0.5) + 1)
101
+ for term, freq in df.items()
102
+ }
103
+
104
+ logger.info("[bm25] Built index: %d docs, %d chunks, %d unique terms",
105
+ len(md_files), self._total_docs, len(self._idf))
106
+
107
+ def search(self, query: str, top_k: int = 10) -> list[SearchResult]:
108
+ """Search index using BM25 scoring."""
109
+ if not self._documents:
110
+ return []
111
+
112
+ query_terms = _tokenize(query)
113
+ if not query_terms:
114
+ return []
115
+
116
+ scores: list[tuple[int, float]] = []
117
+
118
+ for idx, doc in enumerate(self._documents):
119
+ score = 0.0
120
+ dl = doc["dl"]
121
+ tf = doc["tf"]
122
+
123
+ for term in query_terms:
124
+ if term not in tf:
125
+ continue
126
+ term_tf = tf[term]
127
+ idf = self._idf.get(term, 0.0)
128
+ # BM25 formula
129
+ numerator = term_tf * (_K1 + 1)
130
+ denominator = term_tf + _K1 * (1 - _B + _B * dl / max(self._avg_dl, 1))
131
+ score += idf * (numerator / denominator)
132
+
133
+ if score > 0:
134
+ scores.append((idx, score))
135
+
136
+ # Sort by score descending
137
+ scores.sort(key=lambda x: -x[1])
138
+
139
+ results: list[SearchResult] = []
140
+ for idx, score in scores[:top_k]:
141
+ doc = self._documents[idx]
142
+ results.append(SearchResult(
143
+ doc_path=doc["path"],
144
+ chunk_text=doc["text"],
145
+ score=round(score, 4),
146
+ metadata={"chunk_idx": doc["chunk_idx"], "source": doc["path"]},
147
+ ))
148
+
149
+ return results
150
+
151
+ def save(self) -> None:
152
+ """Persist index to JSON file."""
153
+ data = {
154
+ "version": "1.0",
155
+ "total_docs": self._total_docs,
156
+ "avg_dl": self._avg_dl,
157
+ "idf": self._idf,
158
+ "documents": [
159
+ {"path": d["path"], "chunk_idx": d["chunk_idx"],
160
+ "text": d["text"], "tf": d["tf"], "dl": d["dl"]}
161
+ for d in self._documents
162
+ ],
163
+ }
164
+ self._path.parent.mkdir(parents=True, exist_ok=True)
165
+ self._path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
166
+ logger.info("[bm25] Saved index: %s (%d docs)", self._path, self._total_docs)
167
+
168
+ @classmethod
169
+ def load(cls, index_path: Path) -> BM25Index:
170
+ """Load existing index from file."""
171
+ index = cls(index_path)
172
+ if not index_path.exists():
173
+ return index
174
+
175
+ try:
176
+ data = json.loads(index_path.read_text(encoding="utf-8"))
177
+ except (json.JSONDecodeError, OSError):
178
+ return index
179
+
180
+ index._total_docs = data.get("total_docs", 0)
181
+ index._avg_dl = data.get("avg_dl", 0.0)
182
+ index._idf = data.get("idf", {})
183
+ index._documents = [
184
+ {**d, "terms": list(d.get("tf", {}).keys())}
185
+ for d in data.get("documents", [])
186
+ ]
187
+
188
+ return index
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Tokenization
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def _tokenize(text: str) -> list[str]:
197
+ """Tokenize text using character n-grams for Chinese + word splitting for English.
198
+
199
+ Falls back to simple splitting if jieba is unavailable.
200
+ """
201
+ if not text:
202
+ return []
203
+
204
+ terms: list[str] = []
205
+
206
+ # Try jieba first
207
+ try:
208
+ import jieba
209
+ words = jieba.lcut_for_search(text)
210
+ terms = [w.strip().lower() for w in words if len(w.strip()) > 1]
211
+ return terms
212
+ except ImportError:
213
+ pass
214
+
215
+ # Fallback: simple tokenization
216
+ # Chinese: bigrams
217
+ chinese_chars = re.findall(r'[\u4e00-\u9fff]+', text)
218
+ for segment in chinese_chars:
219
+ for i in range(len(segment) - 1):
220
+ terms.append(segment[i:i + 2])
221
+ if len(segment) >= 3:
222
+ for i in range(len(segment) - 2):
223
+ terms.append(segment[i:i + 3])
224
+
225
+ # English/code: word splitting
226
+ english_words = re.findall(r'[a-zA-Z_]\w{2,}', text)
227
+ terms.extend(w.lower() for w in english_words)
228
+
229
+ return terms
230
+
231
+
232
+ def _compute_tf(terms: list[str]) -> dict[str, int]:
233
+ """Compute term frequency."""
234
+ tf: dict[str, int] = {}
235
+ for term in terms:
236
+ tf[term] = tf.get(term, 0) + 1
237
+ return tf
238
+
239
+
240
+ def _split_into_chunks(content: str, target_size: int = 800) -> list[str]:
241
+ """Split document into chunks of approximately target_size characters.
242
+
243
+ Splits on paragraph boundaries (double newline) or heading boundaries.
244
+ """
245
+ if len(content) <= target_size:
246
+ return [content]
247
+
248
+ chunks: list[str] = []
249
+ current: list[str] = []
250
+ current_len = 0
251
+
252
+ paragraphs = re.split(r'\n\n+', content)
253
+
254
+ for para in paragraphs:
255
+ para_len = len(para)
256
+
257
+ if current_len + para_len > target_size and current:
258
+ chunks.append("\n\n".join(current))
259
+ current = []
260
+ current_len = 0
261
+
262
+ current.append(para)
263
+ current_len += para_len
264
+
265
+ if current:
266
+ chunks.append("\n\n".join(current))
267
+
268
+ return chunks
core/rag/chunker.py ADDED
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ """Semantic chunker — splits by headings, paragraphs, and code structure.
4
+
5
+ Migrated from engine/rag/chunker.py. Removes print() calls (no CLI I/O in core/).
6
+ Supports two modes:
7
+ - "semantic" (default): splits markdown by headings, then by sentence boundaries
8
+ - "ast": splits source code at function/class boundaries
9
+ """
10
+
11
+ import logging
12
+ import re
13
+ from dataclasses import dataclass
14
+
15
+ from core.rag.loader import Document
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class Chunk:
22
+ """A text chunk with associated metadata."""
23
+ text: str
24
+ metadata: dict
25
+
26
+
27
+ def chunk_documents(
28
+ docs: list[Document],
29
+ size: int = 512,
30
+ overlap: int = 100,
31
+ min_length: int = 50,
32
+ mode: str = "semantic",
33
+ ) -> list[Chunk]:
34
+ """Chunk a list of documents.
35
+
36
+ Args:
37
+ docs: List of documents to chunk.
38
+ size: Maximum chunk size in characters.
39
+ overlap: Overlap between chunks in characters.
40
+ min_length: Minimum chunk length filter threshold in characters.
41
+ mode: Chunking mode — "semantic" (by headings) or "ast" (by code structure).
42
+ """
43
+ chunks: list[Chunk] = []
44
+ for doc in docs:
45
+ if mode == "ast" and _is_code_file(doc):
46
+ chunks.extend(_chunk_ast(doc, size, min_length))
47
+ else:
48
+ chunks.extend(_chunk_one(doc, size, overlap, min_length))
49
+ logger.info("Generated %d chunks (mode=%s)", len(chunks), mode)
50
+ return chunks
51
+
52
+
53
+ def _is_code_file(doc: Document) -> bool:
54
+ """Check if document is a source code file (not markdown)."""
55
+ source = doc.metadata.get("source", "")
56
+ code_extensions = (".java", ".py", ".go", ".kt", ".ts", ".js", ".rs", ".cpp", ".c")
57
+ return any(source.endswith(ext) for ext in code_extensions)
58
+
59
+
60
+ def _chunk_ast(doc: Document, size: int, min_length: int) -> list[Chunk]:
61
+ """AST-aware chunking: split at function/class boundaries."""
62
+ lines = doc.content.splitlines(keepends=True)
63
+ if not lines:
64
+ return []
65
+
66
+ boundaries = _find_code_boundaries(lines)
67
+ if not boundaries:
68
+ return _chunk_by_lines(doc, lines, size, min_length)
69
+
70
+ chunks: list[Chunk] = []
71
+ for i, start in enumerate(boundaries):
72
+ end = boundaries[i + 1] if i + 1 < len(boundaries) else len(lines)
73
+ block = "".join(lines[start:end]).strip()
74
+
75
+ if len(block) < min_length:
76
+ continue
77
+
78
+ if len(block) <= size:
79
+ chunks.append(Chunk(
80
+ text=block,
81
+ metadata={**doc.metadata, "section": _extract_symbol_name(lines[start])},
82
+ ))
83
+ else:
84
+ sub_chunks = _split_large_block(block, size, min_length)
85
+ symbol = _extract_symbol_name(lines[start])
86
+ for j, sub in enumerate(sub_chunks):
87
+ chunks.append(Chunk(
88
+ text=sub,
89
+ metadata={**doc.metadata, "section": f"{symbol} (part {j+1})"},
90
+ ))
91
+
92
+ if not chunks:
93
+ return _chunk_by_lines(doc, lines, size, min_length)
94
+ return chunks
95
+
96
+
97
+ def _find_code_boundaries(lines: list[str]) -> list[int]:
98
+ """Find line indices where class/function definitions start."""
99
+ class_pattern = re.compile(
100
+ r'^\s*(?:public|protected|private|static|abstract|final|\s)*'
101
+ r'(?:class|interface|enum|record)\s+\w+'
102
+ )
103
+ method_pattern = re.compile(
104
+ r'^\s*(?:public|protected|private|static|abstract|final|synchronized|native|\s)*'
105
+ r'(?:[\w<>\[\],\s]+)\s+\w+\s*\('
106
+ )
107
+ py_pattern = re.compile(r'^(?:class|def|async\s+def)\s+\w+')
108
+
109
+ boundaries: list[int] = []
110
+ for i, line in enumerate(lines):
111
+ stripped = line.strip()
112
+ if not stripped or stripped.startswith("//") or stripped.startswith("#"):
113
+ continue
114
+ if (class_pattern.match(stripped)
115
+ or (method_pattern.match(stripped) and not stripped.startswith("return"))
116
+ or py_pattern.match(stripped)):
117
+ boundaries.append(i)
118
+ return boundaries
119
+
120
+
121
+ def _extract_symbol_name(line: str) -> str:
122
+ """Extract symbol name from a declaration line."""
123
+ m = re.search(r'(?:class|interface|enum|record|def)\s+(\w+)', line)
124
+ if m:
125
+ return m.group(1)
126
+ m = re.search(r'(\w+)\s*\(', line)
127
+ if m:
128
+ return m.group(1)
129
+ return line.strip()[:30]
130
+
131
+
132
+ def _split_large_block(block: str, size: int, min_length: int) -> list[str]:
133
+ """Split an oversized code block at logical boundaries."""
134
+ lines = block.splitlines(keepends=True)
135
+ chunks: list[str] = []
136
+ current: list[str] = []
137
+ current_len = 0
138
+
139
+ for line in lines:
140
+ if current_len + len(line) > size and current_len >= min_length:
141
+ chunks.append("".join(current))
142
+ overlap_lines = current[-3:] if len(current) > 3 else []
143
+ current = overlap_lines.copy()
144
+ current_len = sum(len(ln) for ln in current)
145
+ current.append(line)
146
+ current_len += len(line)
147
+
148
+ if current:
149
+ chunks.append("".join(current))
150
+ return chunks
151
+
152
+
153
+ def _chunk_by_lines(doc: Document, lines: list[str], size: int, min_length: int) -> list[Chunk]:
154
+ """Fallback: chunk code file by character size with overlap."""
155
+ chunks: list[Chunk] = []
156
+ current: list[str] = []
157
+ current_len = 0
158
+
159
+ for line in lines:
160
+ if current_len + len(line) > size and current_len >= min_length:
161
+ chunks.append(Chunk(
162
+ text="".join(current),
163
+ metadata={**doc.metadata, "section": ""},
164
+ ))
165
+ current = current[-3:]
166
+ current_len = sum(len(ln) for ln in current)
167
+ current.append(line)
168
+ current_len += len(line)
169
+
170
+ if current and current_len >= min_length:
171
+ chunks.append(Chunk(
172
+ text="".join(current),
173
+ metadata={**doc.metadata, "section": ""},
174
+ ))
175
+ return chunks
176
+
177
+
178
+ def _chunk_one(doc: Document, size: int, overlap: int, min_length: int) -> list[Chunk]:
179
+ """Split by markdown headings, then by sentence boundaries for large sections."""
180
+ sections = re.split(r'\n(?=#{1,3}\s)', doc.content)
181
+ chunks: list[Chunk] = []
182
+
183
+ for section in sections:
184
+ section = section.strip()
185
+ if not section:
186
+ continue
187
+
188
+ lines = section.split("\n")
189
+ section_title = lines[0].lstrip("#").strip() if lines[0].startswith("#") else ""
190
+ content_text = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""
191
+
192
+ if len(content_text) < min_length:
193
+ if section_title and content_text:
194
+ chunks.append(Chunk(
195
+ text=section,
196
+ metadata={**doc.metadata, "section": section_title},
197
+ ))
198
+ continue
199
+
200
+ if len(section) <= size:
201
+ chunks.append(Chunk(
202
+ text=section,
203
+ metadata={**doc.metadata, "section": section_title},
204
+ ))
205
+ else:
206
+ title_prefix = f"## {section_title}\n" if section_title else ""
207
+ available_size = size - len(title_prefix)
208
+ for sub in _sliding_window_sentence(content_text, available_size, overlap):
209
+ chunk_text = title_prefix + sub if title_prefix else sub
210
+ chunks.append(Chunk(
211
+ text=chunk_text,
212
+ metadata={**doc.metadata, "section": section_title},
213
+ ))
214
+ return chunks
215
+
216
+
217
+ _SENTENCE_END = re.compile(r'(?<=[。!?;\n.!?;])\s*')
218
+
219
+
220
+ def _split_sentences(text: str) -> list[str]:
221
+ """Split by sentence/paragraph boundaries."""
222
+ parts = _SENTENCE_END.split(text)
223
+ sentences: list[str] = []
224
+ buf = ""
225
+ for p in parts:
226
+ buf += p
227
+ if len(buf) >= 20:
228
+ sentences.append(buf)
229
+ buf = ""
230
+ if buf.strip():
231
+ if sentences:
232
+ sentences[-1] += buf
233
+ else:
234
+ sentences.append(buf)
235
+ return sentences
236
+
237
+
238
+ def _sliding_window_sentence(text: str, size: int, overlap: int) -> list[str]:
239
+ """Sliding window split by sentence boundaries."""
240
+ sentences = _split_sentences(text)
241
+ if not sentences:
242
+ return [text] if text.strip() else []
243
+
244
+ result: list[str] = []
245
+ i = 0
246
+ while i < len(sentences):
247
+ chunk_parts: list[str] = []
248
+ chunk_len = 0
249
+ j = i
250
+ while j < len(sentences):
251
+ s = sentences[j]
252
+ if chunk_len + len(s) > size and chunk_parts:
253
+ break
254
+ chunk_parts.append(s)
255
+ chunk_len += len(s)
256
+ j += 1
257
+
258
+ result.append("".join(chunk_parts))
259
+
260
+ if j >= len(sentences):
261
+ break
262
+
263
+ overlap_start = j
264
+ overlap_len = 0
265
+ for k in range(j - 1, i - 1, -1):
266
+ if overlap_len + len(sentences[k]) > overlap:
267
+ break
268
+ overlap_len += len(sentences[k])
269
+ overlap_start = k
270
+
271
+ i = overlap_start if overlap_start > i else j
272
+
273
+ return result
core/rag/embedder.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ """Embedding wrapper — supports Ollama / OpenAI / DashScope / ChromaDB built-in.
4
+
5
+ Migrated from engine/rag/embedder.py. Removes global config dependency;
6
+ accepts Config object explicitly. Embedding API calls are allowed in core/
7
+ (they are not generative LLM calls).
8
+
9
+ Rate-limit retry is built into each backend.
10
+ """
11
+
12
+ import logging
13
+ import threading
14
+ import time
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from core.config import Config
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ MAX_RETRIES = 5 # Max retries for 429 rate limiting
23
+
24
+
25
+ def get_embeddings(texts: list[str], config: "Config") -> list[list[float]]:
26
+ """Select embedding backend based on configuration.
27
+
28
+ Args:
29
+ texts: List of text strings to embed.
30
+ config: Config object providing embed_backend, embed_model, etc.
31
+
32
+ Returns:
33
+ List of embedding vectors (one per input text).
34
+ """
35
+ if not texts:
36
+ return []
37
+ backend = config.embed_backend
38
+ if backend == "chromadb-default":
39
+ return _chromadb_default_embed(texts)
40
+ elif backend == "dashscope":
41
+ return _dashscope_embed(texts, config)
42
+ elif backend == "openai":
43
+ return _openai_embed(texts, config)
44
+ else: # ollama
45
+ return _ollama_embed(texts, config)
46
+
47
+
48
+ _default_ef = None
49
+ _default_ef_lock = threading.Lock()
50
+
51
+
52
+ def _chromadb_default_embed(texts: list[str]) -> list[list[float]]:
53
+ """ChromaDB built-in embedding (all-MiniLM-L6-v2, runs locally)."""
54
+ global _default_ef
55
+ if _default_ef is None:
56
+ with _default_ef_lock:
57
+ if _default_ef is None:
58
+ from chromadb.utils.embedding_functions import DefaultEmbeddingFunction
59
+ _default_ef = DefaultEmbeddingFunction()
60
+ return _default_ef(texts)
61
+
62
+
63
+ def _ollama_embed(texts: list[str], config: "Config") -> list[list[float]]:
64
+ """Ollama local embedding with retry on rate limit."""
65
+ import requests
66
+
67
+ for attempt in range(1, MAX_RETRIES + 1):
68
+ resp = requests.post(
69
+ f"{config.embed_base_url}/api/embed",
70
+ json={"model": config.embed_model, "input": texts},
71
+ timeout=60,
72
+ )
73
+ if resp.status_code == 429:
74
+ if attempt >= MAX_RETRIES:
75
+ raise RuntimeError(
76
+ f"Ollama embedding rate limited after {MAX_RETRIES} retries"
77
+ )
78
+ retry_after = int(resp.headers.get("Retry-After", 5))
79
+ time.sleep(retry_after)
80
+ continue
81
+ resp.raise_for_status()
82
+ return resp.json()["embeddings"]
83
+ raise RuntimeError(f"Ollama embedding failed after {MAX_RETRIES} retries")
84
+
85
+
86
+ def _openai_embed(texts: list[str], config: "Config") -> list[list[float]]:
87
+ """OpenAI-compatible API (also works with vLLM, LocalAI, etc.).
88
+
89
+ Automatically batches requests (max 10 per call) to respect API limits.
90
+ """
91
+ import requests
92
+
93
+ headers = {"Content-Type": "application/json"}
94
+ api_key = config.embed_api_key
95
+ if api_key:
96
+ headers["Authorization"] = f"Bearer {api_key}"
97
+
98
+ batch_size = 10
99
+ results: list[list[float]] = []
100
+ for i in range(0, len(texts), batch_size):
101
+ batch = texts[i:i + batch_size]
102
+ for attempt in range(1, MAX_RETRIES + 1):
103
+ resp = requests.post(
104
+ f"{config.embed_base_url}/v1/embeddings",
105
+ headers=headers,
106
+ json={"model": config.embed_model, "input": batch},
107
+ timeout=60,
108
+ )
109
+ if resp.status_code == 429:
110
+ if attempt >= MAX_RETRIES:
111
+ raise RuntimeError(
112
+ f"OpenAI embedding rate limited after {MAX_RETRIES} retries"
113
+ )
114
+ retry_after = int(resp.headers.get("Retry-After", 5))
115
+ time.sleep(retry_after)
116
+ continue
117
+ resp.raise_for_status()
118
+ data = resp.json()
119
+ results.extend([item["embedding"] for item in data["data"]])
120
+ break
121
+ return results
122
+
123
+
124
+ def _dashscope_embed(texts: list[str], config: "Config") -> list[list[float]]:
125
+ """DashScope text-embedding with retry on rate limit."""
126
+ import dashscope
127
+
128
+ api_key = config.embed_api_key
129
+ results: list[list[float]] = []
130
+ batch_size = 25 # DashScope max 25 per request
131
+ for i in range(0, len(texts), batch_size):
132
+ batch = texts[i:i + batch_size]
133
+ for attempt in range(1, MAX_RETRIES + 1):
134
+ resp = dashscope.TextEmbedding.call(
135
+ model=config.embed_model,
136
+ input=batch,
137
+ api_key=api_key,
138
+ )
139
+ if resp.status_code == 429:
140
+ if attempt >= MAX_RETRIES:
141
+ raise RuntimeError(
142
+ f"DashScope embedding rate limited after {MAX_RETRIES} retries"
143
+ )
144
+ time.sleep(attempt * 2)
145
+ continue
146
+ if resp.status_code != 200:
147
+ raise RuntimeError(f"DashScope embedding failed: {resp.message}")
148
+ for item in resp.output["embeddings"]:
149
+ results.append(item["embedding"])
150
+ break
151
+ return results