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.
- cli/__init__.py +50 -0
- cli/__main__.py +5 -0
- cli/commands/__init__.py +1 -0
- cli/commands/anchor_fix.py +47 -0
- cli/commands/diff_doc.py +52 -0
- cli/commands/dispatch.py +77 -0
- cli/commands/extract.py +72 -0
- cli/commands/file_list.py +74 -0
- cli/commands/index.py +84 -0
- cli/commands/lock.py +89 -0
- cli/commands/merge.py +60 -0
- cli/commands/merge_delta.py +19 -0
- cli/commands/metadata.py +24 -0
- cli/commands/pipeline.py +45 -0
- cli/commands/post_merge.py +43 -0
- cli/commands/query.py +52 -0
- cli/commands/render.py +101 -0
- cli/commands/scan_repos.py +46 -0
- cli/commands/setup.py +94 -0
- cli/commands/split.py +196 -0
- cli/commands/stale_files.py +98 -0
- cli/commands/validate.py +191 -0
- core/__init__.py +32 -0
- core/config.py +261 -0
- core/docs/__init__.py +7 -0
- core/docs/section_updater.py +286 -0
- core/docs/shared.py +149 -0
- core/git.py +294 -0
- core/interfaces.py +249 -0
- core/monitor/__init__.py +5 -0
- core/monitor/progress.py +83 -0
- core/monitor/prompt_store.py +49 -0
- core/paths.py +141 -0
- core/preset.py +237 -0
- core/preset_accessors.py +202 -0
- core/preset_classify.py +132 -0
- core/preset_hooks.py +129 -0
- core/preset_profile.py +89 -0
- core/prompt/__init__.py +7 -0
- core/prompt/__main__.py +147 -0
- core/prompt/content.py +320 -0
- core/prompt/context_manager.py +164 -0
- core/prompt/renderer.py +236 -0
- core/prompt/response_parser.py +274 -0
- core/prompt/templates.py +357 -0
- core/prompt/validate_parity.py +162 -0
- core/prompt/variables.py +339 -0
- core/rag/__init__.py +22 -0
- core/rag/__main__.py +136 -0
- core/rag/bm25_index.py +268 -0
- core/rag/chunker.py +273 -0
- core/rag/embedder.py +151 -0
- core/rag/indexer.py +292 -0
- core/rag/loader.py +89 -0
- core/rag/retriever.py +82 -0
- core/skeleton/__init__.py +11 -0
- core/skeleton/__main__.py +934 -0
- core/skeleton/anchor_fix.py +250 -0
- core/skeleton/classify.py +331 -0
- core/skeleton/cmd_anchor_fix.py +43 -0
- core/skeleton/cmd_diff_doc.py +44 -0
- core/skeleton/cmd_lock.py +87 -0
- core/skeleton/cmd_merge_delta.py +41 -0
- core/skeleton/community.py +233 -0
- core/skeleton/dependency_graph.py +306 -0
- core/skeleton/diff_doc.py +248 -0
- core/skeleton/dispatch.py +273 -0
- core/skeleton/dispatch_render.py +319 -0
- core/skeleton/dispatch_source.py +111 -0
- core/skeleton/extract.py +218 -0
- core/skeleton/extract_methods.py +298 -0
- core/skeleton/file_list.py +239 -0
- core/skeleton/impact.py +278 -0
- core/skeleton/jar_download.py +177 -0
- core/skeleton/jar_resolver.py +186 -0
- core/skeleton/loader.py +162 -0
- core/skeleton/merge.py +278 -0
- core/skeleton/merge_delta.py +229 -0
- core/skeleton/metadata.py +96 -0
- core/skeleton/metadata_builders.py +264 -0
- core/skeleton/module_dag.py +330 -0
- core/skeleton/parsers/__init__.py +71 -0
- core/skeleton/parsers/jqassistant.py +300 -0
- core/skeleton/parsers/jqassistant_cypher.py +225 -0
- core/skeleton/parsers/regex.py +171 -0
- core/skeleton/parsers/treesitter.py +324 -0
- core/skeleton/parsers/treesitter_java.py +284 -0
- core/skeleton/parsers/treesitter_multi.py +289 -0
- core/skeleton/pom_parser.py +299 -0
- core/skeleton/post_merge.py +295 -0
- core/skeleton/post_merge_llm.py +82 -0
- core/skeleton/query.py +195 -0
- core/skeleton/shard_context.py +177 -0
- core/skeleton/split.py +180 -0
- core/skeleton/split_cache.py +107 -0
- core/skeleton/split_feedback.py +174 -0
- core/skeleton/split_plan.py +219 -0
- core/skeleton/split_plan_helpers.py +305 -0
- core/skeleton/split_plan_llm.py +274 -0
- core/utils.py +135 -0
- core/validators/__init__.py +65 -0
- core/validators/__main__.py +215 -0
- core/validators/consistency.py +203 -0
- core/validators/coverage.py +171 -0
- core/validators/duplicates.py +76 -0
- core/validators/engine.py +224 -0
- core/validators/links.py +76 -0
- core/validators/sampling.py +169 -0
- core/validators/structure.py +144 -0
- engine/__init__.py +7 -0
- engine/assembler.py +231 -0
- engine/confirm.py +65 -0
- engine/dedup.py +106 -0
- engine/main.py +211 -0
- engine/pipeline/__init__.py +163 -0
- engine/pipeline/recovery.py +250 -0
- engine/pipeline/steps/__init__.py +23 -0
- engine/pipeline/steps/audit.py +220 -0
- engine/pipeline/steps/audit_apply.py +195 -0
- engine/pipeline/steps/audit_helpers.py +155 -0
- engine/pipeline/steps/classify_llm.py +236 -0
- engine/pipeline/steps/classify_prompt.py +223 -0
- engine/pipeline/steps/finalize.py +160 -0
- engine/pipeline/steps/generate.py +169 -0
- engine/pipeline/steps/generate_batch.py +197 -0
- engine/pipeline/steps/generate_recovery.py +170 -0
- engine/pipeline/steps/llm_plan_split.py +253 -0
- engine/pipeline/steps/lock.py +64 -0
- engine/pipeline/steps/preflight.py +237 -0
- engine/pipeline/steps/preflight_adjust.py +147 -0
- engine/pipeline/steps/pregenerate.py +130 -0
- engine/pipeline/steps/quality.py +81 -0
- engine/pipeline/steps/skeleton.py +149 -0
- engine/pipeline/steps/source.py +163 -0
- engine/pipeline/steps/sync.py +117 -0
- engine/pipeline/steps/sync_finalize.py +237 -0
- engine/pipeline/steps/sync_update.py +341 -0
- engine/pipelines.py +91 -0
- engine/runner.py +335 -0
- engine/strategies/__init__.py +86 -0
- engine/strategies/api.py +128 -0
- engine/strategies/delegated.py +50 -0
- engine/strategies/dryrun.py +25 -0
- engine/two_phase.py +143 -0
- mcp_server/__init__.py +73 -0
- mcp_server/__main__.py +5 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/config.py +63 -0
- mcp_server/tools/discovery.py +276 -0
- mcp_server/tools/generation.py +184 -0
- mcp_server/tools/planning.py +144 -0
- mcp_server/tools/source.py +175 -0
- mcp_server/tools/validation.py +140 -0
- mcp_server/tools/workflow.py +166 -0
- mcp_server/workflow_loader.py +204 -0
- presets/generic/audit_dimensions.md +132 -0
- presets/generic/doc_types.yaml +152 -0
- presets/generic/preset.yaml +115 -0
- presets/java-spring/audit_dimensions.md +228 -0
- presets/java-spring/audit_dimensions.yaml +203 -0
- presets/java-spring/doc_types.yaml +269 -0
- presets/java-spring/hooks.py +122 -0
- presets/java-spring/preset.yaml +341 -0
- presets/java-spring/templates/README.md +34 -0
- presets/java-spring/templates/audit-system.md +15 -0
- presets/java-spring/templates/subagent-aop.md +105 -0
- presets/java-spring/templates/subagent-api.md +63 -0
- presets/java-spring/templates/subagent-architecture.md +111 -0
- presets/java-spring/templates/subagent-async-events.md +107 -0
- presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
- presets/java-spring/templates/subagent-audit-architecture.md +38 -0
- presets/java-spring/templates/subagent-audit-business.md +40 -0
- presets/java-spring/templates/subagent-audit-data-models.md +40 -0
- presets/java-spring/templates/subagent-business.md +129 -0
- presets/java-spring/templates/subagent-caching.md +75 -0
- presets/java-spring/templates/subagent-database-access.md +114 -0
- presets/java-spring/templates/subagent-enum.md +75 -0
- presets/java-spring/templates/subagent-error-handling.md +91 -0
- presets/java-spring/templates/subagent-external-integrations.md +80 -0
- presets/java-spring/templates/subagent-index.md +122 -0
- presets/java-spring/templates/subagent-messaging.md +97 -0
- presets/java-spring/templates/subagent-model.md +88 -0
- presets/java-spring/templates/subagent-observability.md +91 -0
- presets/java-spring/templates/subagent-scheduled.md +81 -0
- presets/java-spring/templates/subagent-security.md +102 -0
- presets/java-spring/templates/subagent-structure.md +101 -0
- presets/java-spring/templates/subagent-sync-section.md +34 -0
- presets/java-spring/templates/subagent-utils.md +73 -0
- presets/java-spring/templates/sync-system.md +8 -0
- presets/java-spring/workflow-extensions.md +112 -0
- skills/__init__.py +1 -0
- skills/_shared/README.md +30 -0
- skills/_shared/doc-coverage-shared.md +134 -0
- skills/_shared/doc-quality-standard.md +1058 -0
- skills/_shared/doc-subagent-rules.md +762 -0
- skills/_shared/windows-compat.md +89 -0
- skills/kb-audit/SKILL.md +52 -0
- skills/kb-audit/rules.md +88 -0
- skills/kb-audit/steps/step-01-prepare.md +75 -0
- skills/kb-audit/steps/step-02-audit.md +96 -0
- skills/kb-audit/steps/step-03-verify.md +65 -0
- skills/kb-audit/steps/step-04-report.md +64 -0
- skills/kb-init/SKILL.md +146 -0
- skills/kb-init/rules.md +187 -0
- skills/kb-init/steps/step-01-scope.md +62 -0
- skills/kb-init/steps/step-02-source.md +410 -0
- skills/kb-init/steps/step-03-generate.md +307 -0
- skills/kb-init/steps/step-04-quality.md +92 -0
- skills/kb-init/steps/step-05-finalize.md +132 -0
- skills/kb-init/templates/core/execution-modes.md +29 -0
- skills/kb-init/templates/core/output-only.md +4 -0
- skills/kb-init/templates/core/readwrite.md +33 -0
- skills/kb-search/SKILL.md +138 -0
- skills/kb-search/rules.md +64 -0
- skills/kb-sync/SKILL.md +43 -0
- skills/kb-sync/rules.md +70 -0
- skills/kb-sync/scripts/rebuild_module.py +91 -0
- skills/kb-sync/scripts/scan_repos.py +687 -0
- skills/kb-sync/steps/step-01-detect.md +72 -0
- skills/kb-sync/steps/step-02-update.md +71 -0
- skills/kb-sync/steps/step-03-verify.md +47 -0
- skills/kb-sync/steps/step-04-finalize.md +52 -0
- source_kb-0.2.2.dist-info/METADATA +194 -0
- source_kb-0.2.2.dist-info/RECORD +228 -0
- source_kb-0.2.2.dist-info/WHEEL +5 -0
- source_kb-0.2.2.dist-info/entry_points.txt +3 -0
- source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
- 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
|