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
@@ -0,0 +1,253 @@
1
+ """LLM smart split step — fallback when community detection fails.
2
+
3
+ When deterministic community detection cannot produce a good split plan,
4
+ this module calls LLM to analyze the skeleton and suggest groupings.
5
+
6
+ Requirements: 9.3
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from core.interfaces import LlmStrategy, LlmRequest
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Prompt template for LLM split planning
21
+ SPLIT_PROMPT_TEMPLATE = """# Task: Analyze source code structure and split by business domain
22
+
23
+ You are a senior system architect. Analyze the following source code skeleton, identify business domain boundaries, and group files by business domain.
24
+
25
+ ## Target Document Type
26
+ {doc_type}
27
+
28
+ ## Source Code Skeleton Summary
29
+ {skeleton_summary}
30
+
31
+ ## Split Requirements
32
+
33
+ 1. **Identify business domains**: Identify independent business domains based on class names, method names, and package structure
34
+ 2. **Granularity control**: Keep each shard within 2000-5000 lines of code
35
+ 3. **High cohesion**: Files belonging to the same business function should be in the same shard
36
+ 4. **Low coupling**: Different business domains should be separated as much as possible
37
+ 5. **Maximum {max_splits} shards**
38
+
39
+ ## Output Format
40
+
41
+ Output JSON directly:
42
+ {{
43
+ "analysis": "Brief rationale for domain partitioning",
44
+ "splits": [
45
+ {{
46
+ "name": "domain-name (English, kebab-case)",
47
+ "description": "Domain description",
48
+ "files": ["FileName1.java", "FileName2.java"],
49
+ "estimated_lines": 3000
50
+ }}
51
+ ]
52
+ }}
53
+
54
+ Ensure all files are assigned to a shard.
55
+ """
56
+
57
+ def _get_focus_hint(doc_type: str, preset_name: str = "java-spring") -> str:
58
+ """Get focus hint from hooks, with empty fallback."""
59
+ try:
60
+ from core.preset_hooks import load_hooks
61
+ hooks = load_hooks(preset_name)
62
+ return hooks.get_focus_hint(doc_type)
63
+ except Exception:
64
+ return ""
65
+
66
+
67
+ def plan_splits_via_llm(
68
+ module_dir: Path,
69
+ doc_type: str,
70
+ module_name: str,
71
+ strategy: LlmStrategy,
72
+ max_splits: int = 4,
73
+ ) -> dict:
74
+ """Use LLM to generate a split plan for a doc type.
75
+
76
+ Called as fallback when community detection fails or produces
77
+ poor results (e.g., all files in one community).
78
+
79
+ Args:
80
+ module_dir: Module knowledge directory
81
+ doc_type: Target document type
82
+ module_name: Module name
83
+ strategy: LLM strategy for making calls
84
+ max_splits: Maximum number of splits
85
+
86
+ Returns:
87
+ Split plan dict with keys: module, doc_type, analysis, splits, source
88
+ """
89
+ skeleton_summary = _load_skeleton_summary(module_dir)
90
+ if not skeleton_summary:
91
+ return {"error": "No skeleton data available", "splits": []}
92
+
93
+ focus = _get_focus_hint(doc_type) or "Group by file functionality"
94
+ prompt = SPLIT_PROMPT_TEMPLATE.format(
95
+ doc_type=f"{doc_type} ({focus})",
96
+ skeleton_summary=skeleton_summary,
97
+ max_splits=max_splits,
98
+ )
99
+
100
+ try:
101
+ resp = strategy.call(LlmRequest(
102
+ system="You are a code architecture analyst. Output only valid JSON.",
103
+ user=prompt,
104
+ max_tokens=2000,
105
+ temperature=0.0,
106
+ ))
107
+ except Exception as e:
108
+ logger.error("[llm-split] LLM call failed: %s", e)
109
+ return {"error": str(e), "splits": []}
110
+
111
+ if resp.status != "done":
112
+ return {"error": f"LLM status: {resp.status}", "splits": []}
113
+
114
+ result = _parse_response(resp.content)
115
+ if not result.get("splits"):
116
+ return {"error": "LLM returned no valid splits", "splits": []}
117
+
118
+ # Enforce max_splits limit
119
+ splits = result["splits"]
120
+ if len(splits) > max_splits:
121
+ splits = sorted(splits, key=lambda x: x.get("estimated_lines", 0), reverse=True)
122
+ splits = splits[:max_splits]
123
+
124
+ return {
125
+ "module": module_name,
126
+ "doc_type": doc_type,
127
+ "analysis": result.get("analysis", ""),
128
+ "recommended_agents": len(splits),
129
+ "splits": splits,
130
+ "source": "llm",
131
+ }
132
+
133
+
134
+ def apply_split_plan(
135
+ module_dir: Path,
136
+ doc_type: str,
137
+ plan: dict,
138
+ source_cache: Path,
139
+ ) -> list[list[str]]:
140
+ """Convert a split plan into file groups for shard generation.
141
+
142
+ Args:
143
+ module_dir: Module knowledge directory
144
+ doc_type: Document type
145
+ plan: Split plan from plan_splits_via_llm
146
+ source_cache: Source cache directory for resolving paths
147
+
148
+ Returns:
149
+ List of file groups (each group is a list of file paths)
150
+ """
151
+ splits = plan.get("splits", [])
152
+ if not splits:
153
+ return []
154
+
155
+ # Build filename → full path mapping from file list
156
+ from core.paths import resolve_file_list
157
+ fl_path = resolve_file_list(module_dir, doc_type)
158
+ if not fl_path:
159
+ return []
160
+
161
+ try:
162
+ all_files = fl_path.read_text(encoding="utf-8").strip().splitlines()
163
+ except OSError:
164
+ return []
165
+
166
+ # Map basenames to full paths
167
+ basename_map: dict[str, list[str]] = {}
168
+ for fp in all_files:
169
+ basename = fp.rsplit("/", 1)[-1] if "/" in fp else fp
170
+ basename_map.setdefault(basename, []).append(fp)
171
+
172
+ groups: list[list[str]] = []
173
+ assigned: set[str] = set()
174
+
175
+ for split in splits:
176
+ group: list[str] = []
177
+ for filename in split.get("files", []):
178
+ # Try exact basename match
179
+ matches = basename_map.get(filename, [])
180
+ for m in matches:
181
+ if m not in assigned:
182
+ group.append(m)
183
+ assigned.add(m)
184
+ groups.append(group)
185
+
186
+ # Assign unmatched files to the largest group
187
+ unassigned = [f for f in all_files if f not in assigned]
188
+ if unassigned and groups:
189
+ largest = max(groups, key=len)
190
+ largest.extend(unassigned)
191
+
192
+ return [g for g in groups if g]
193
+
194
+
195
+ def _load_skeleton_summary(module_dir: Path, max_entries: int = 80) -> str:
196
+ """Load skeleton summary for prompt context."""
197
+ summary_file = module_dir / ".meta" / "skeleton" / "summary.json"
198
+ if not summary_file.exists():
199
+ summary_file = module_dir / ".skeleton-summary.json"
200
+ if not summary_file.exists():
201
+ return ""
202
+
203
+ try:
204
+ data = json.loads(summary_file.read_text(encoding="utf-8"))
205
+ except (json.JSONDecodeError, OSError):
206
+ return ""
207
+
208
+ lines = []
209
+ for entry in data[:max_entries]:
210
+ file_path = entry.get("file", "")
211
+ classes = entry.get("classes", [])
212
+ methods = entry.get("methods", [])
213
+
214
+ class_names = [c.get("name", "") for c in classes]
215
+ method_names = [m.get("name", "") for m in methods[:5]]
216
+
217
+ parts = [file_path]
218
+ if class_names:
219
+ parts.append(f" classes: {', '.join(class_names)}")
220
+ if method_names:
221
+ parts.append(f" methods: {', '.join(method_names)}")
222
+ lines.append("\n".join(parts))
223
+
224
+ return "\n".join(lines)
225
+
226
+
227
+ def _parse_response(text: str) -> dict:
228
+ """Parse LLM response, extracting JSON."""
229
+ text = text.strip()
230
+
231
+ # Remove markdown code blocks
232
+ if text.startswith("```"):
233
+ lines = text.split("\n")
234
+ if lines[0].startswith("```"):
235
+ lines = lines[1:]
236
+ if lines and lines[-1].strip() == "```":
237
+ lines = lines[:-1]
238
+ text = "\n".join(lines)
239
+
240
+ try:
241
+ return json.loads(text)
242
+ except json.JSONDecodeError:
243
+ pass
244
+
245
+ # Try extracting JSON block
246
+ match = re.search(r'\{[\s\S]*\}', text)
247
+ if match:
248
+ try:
249
+ return json.loads(match.group())
250
+ except json.JSONDecodeError:
251
+ pass
252
+
253
+ return {}
@@ -0,0 +1,64 @@
1
+ """Lock management steps — acquire and release .kb-lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from filelock import FileLock, Timeout
6
+
7
+ from core.interfaces import Step, StepResult, PipelineContext
8
+ from engine.pipeline import register_step
9
+
10
+
11
+ @register_step
12
+ class AcquireLockStep(Step):
13
+ """Acquire file lock on knowledge directory."""
14
+
15
+ default_name = "acquire-lock"
16
+
17
+ def __init__(self, timeout_minutes: int = 120):
18
+ super().__init__("acquire-lock")
19
+ self._timeout = timeout_minutes * 60
20
+
21
+ def run(self, ctx: PipelineContext) -> StepResult:
22
+ lock_path = ctx.knowledge_dir / ".kb-lock"
23
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
24
+ lock = FileLock(str(lock_path), timeout=self._timeout)
25
+ try:
26
+ lock.acquire()
27
+ except Timeout:
28
+ return StepResult(status="failed", message=f"Lock timeout ({self._timeout // 60}min)")
29
+ ctx.state["_lock"] = lock
30
+ ctx.state["_lock_path"] = lock_path
31
+ return StepResult(status="ok", message=f"Lock acquired: {lock_path}")
32
+
33
+ def rollback(self, ctx: PipelineContext) -> None:
34
+ _release(ctx)
35
+
36
+
37
+ @register_step
38
+ class ReleaseLockStep(Step):
39
+ """Release file lock."""
40
+
41
+ default_name = "release-lock"
42
+
43
+ def __init__(self):
44
+ super().__init__("release-lock")
45
+
46
+ def run(self, ctx: PipelineContext) -> StepResult:
47
+ return _release(ctx)
48
+
49
+
50
+ def _release(ctx: PipelineContext) -> StepResult:
51
+ lock: FileLock | None = ctx.state.pop("_lock", None)
52
+ if lock is None:
53
+ return StepResult(status="skipped", message="No lock to release")
54
+ try:
55
+ lock.release()
56
+ except Exception:
57
+ pass
58
+ lock_path = ctx.state.pop("_lock_path", None)
59
+ if lock_path and lock_path.exists():
60
+ try:
61
+ lock_path.unlink()
62
+ except OSError:
63
+ pass
64
+ return StepResult(status="ok", message="Lock released")
@@ -0,0 +1,237 @@
1
+ """Model capacity preflight check — validates prompt sizes against model context window.
2
+
3
+ Three-phase check:
4
+ 1. Render actual prompts to get precise byte sizes
5
+ 2. LLM probe call (max_tokens=1) to verify connectivity + get token count
6
+ 3. Auto-adjust split thresholds if over limit, re-split and rewrite dispatch-tasks
7
+
8
+ Runs before document generation in CLI pipeline.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ from pathlib import Path
17
+
18
+ from core.interfaces import Step, StepResult, PipelineContext, LlmRequest
19
+ from engine.pipeline import register_step
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _MODEL_CAPACITY: dict[str, int] = {
24
+ "claude-opus-4-6": 200000,
25
+ "claude-sonnet-4-6": 200000,
26
+ "claude-sonnet-4.6": 200000,
27
+ "claude-opus-4.6": 200000,
28
+ "claude-3-5-sonnet": 200000,
29
+ "claude-3-opus": 200000,
30
+ "claude-3-haiku": 200000,
31
+ "gpt-4o": 128000,
32
+ "gpt-4o-mini": 128000,
33
+ "gpt-4-turbo": 128000,
34
+ "gpt-4": 8192,
35
+ "gpt-3.5-turbo": 16384,
36
+ "deepseek-chat": 64000,
37
+ "deepseek-coder": 64000,
38
+ "qwen-turbo": 8000,
39
+ "qwen-plus": 32000,
40
+ "qwen-max": 32000,
41
+ }
42
+
43
+ BYTES_PER_TOKEN = 3
44
+ INPUT_BUDGET_RATIO = 0.80
45
+
46
+
47
+ def _get_model_capacity_tokens(model: str, config: dict) -> int:
48
+ """Get model max input tokens. Resolution: config override > known table > 128K default."""
49
+ explicit = config.get("agent", {}).get("model_max_input_tokens")
50
+ if explicit:
51
+ return int(explicit)
52
+
53
+ model_lower = model.lower().strip()
54
+ for known, capacity in _MODEL_CAPACITY.items():
55
+ if known in model_lower or model_lower in known:
56
+ return capacity
57
+
58
+ return 128000
59
+
60
+
61
+ @register_step
62
+ class PreflightCheckStep(Step):
63
+ """Validate model capacity, probe connectivity, auto-adjust if needed."""
64
+
65
+ default_name = "preflight-check"
66
+
67
+ def __init__(self):
68
+ super().__init__("preflight-check")
69
+
70
+ def run(self, ctx: PipelineContext) -> StepResult:
71
+ config = ctx.config
72
+ model = os.getenv("LLM_MODEL", "") or config.get("agent", {}).get("model", "unknown")
73
+ model_capacity = _get_model_capacity_tokens(model, config)
74
+ input_budget = int(model_capacity * INPUT_BUDGET_RATIO)
75
+
76
+ phase1 = self._phase1_render_prompts(ctx, config, model)
77
+ if phase1["error"]:
78
+ return StepResult(status="ok", message=f"Preflight: {phase1['error']}", details=phase1)
79
+
80
+ max_prompt_bytes = phase1["max_bytes"]
81
+ max_task_id = phase1["max_task_id"]
82
+ max_prompt = phase1["max_prompt"]
83
+ all_sizes = phase1["all_sizes"]
84
+
85
+ logger.info("Preflight phase 1: max prompt %dKB (%s), %d tasks total",
86
+ max_prompt_bytes // 1024, max_task_id, len(all_sizes))
87
+
88
+ phase2 = self._phase2_probe(ctx, config, max_prompt, model)
89
+ actual_input_tokens = phase2.get("input_tokens")
90
+ probe_error = phase2.get("error", "")
91
+ context_exceeded = phase2.get("context_exceeded", False)
92
+
93
+ if probe_error and not context_exceeded:
94
+ msg = f"Preflight probe failed: {probe_error}"
95
+ logger.warning(msg)
96
+ return StepResult(status="failed", message=msg, details={
97
+ "model": model, "probe_error": probe_error,
98
+ })
99
+
100
+ if actual_input_tokens:
101
+ utilization = actual_input_tokens / input_budget
102
+ elif context_exceeded:
103
+ utilization = 1.5
104
+ else:
105
+ estimated_tokens = max_prompt_bytes // BYTES_PER_TOKEN
106
+ utilization = estimated_tokens / input_budget
107
+
108
+ details = {
109
+ "model": model,
110
+ "model_capacity_tokens": model_capacity,
111
+ "input_budget_tokens": input_budget,
112
+ "max_prompt_bytes": max_prompt_bytes,
113
+ "max_task_id": max_task_id,
114
+ "actual_input_tokens": actual_input_tokens,
115
+ "utilization": f"{utilization:.0%}",
116
+ "task_count": len(all_sizes),
117
+ }
118
+
119
+ if utilization > 0.85 or context_exceeded:
120
+ from engine.pipeline.steps.preflight_adjust import auto_adjust
121
+ adjust_result = auto_adjust(ctx, config, utilization, input_budget, all_sizes)
122
+ if adjust_result:
123
+ details["adjustment"] = adjust_result
124
+ msg = (
125
+ f"Preflight: {model} capacity exceeded (utilization={utilization:.0%}), "
126
+ f"auto-adjusted splits: {adjust_result['summary']}"
127
+ )
128
+ logger.info(msg)
129
+ return StepResult(status="ok", message=msg, details=details)
130
+ else:
131
+ msg = (
132
+ f"Preflight: {model} capacity exceeded (utilization={utilization:.0%}), "
133
+ f"auto-adjust failed — reduce max_lines/max_bytes or switch model"
134
+ )
135
+ return StepResult(status="failed", message=msg, details=details)
136
+
137
+ msg = f"Preflight OK: {model}, max prompt {max_prompt_bytes//1024}KB, utilization {utilization:.0%}"
138
+ return StepResult(status="ok", message=msg, details=details)
139
+
140
+ def _phase1_render_prompts(self, ctx: PipelineContext, config: dict, model: str) -> dict:
141
+ """Render all prompts and measure actual sizes."""
142
+ from core.preset import load_preset, get_template_path
143
+ from core.prompt.renderer import render_prompt
144
+ from engine.assembler import InlinePromptAssembler
145
+
146
+ preset_name = ctx.kb_config.get("preset", "generic")
147
+ preset = load_preset(preset_name)
148
+ assembler = InlinePromptAssembler(preset=preset)
149
+
150
+ snippet_path = Path(ctx.project_root) / "skills" / "kb-init" / "templates" / "core" / "output-only.md"
151
+ execution_snippet = snippet_path.read_text(encoding="utf-8") if snippet_path.exists() else ""
152
+
153
+ all_sizes: list[dict] = []
154
+ max_bytes = 0
155
+ max_task_id = ""
156
+ max_prompt = ""
157
+
158
+ for name in ctx.state.get("module_repos", {}):
159
+ module_dir = ctx.knowledge_dir / name
160
+ tasks_file = module_dir / ".meta" / "dispatch-tasks.json"
161
+ if not tasks_file.exists():
162
+ continue
163
+
164
+ manifest = json.loads(tasks_file.read_text(encoding="utf-8"))
165
+ for task in manifest.get("tasks", []):
166
+ tpl_name = get_template_path(preset, task["doc_type"], preset_name)
167
+ if not tpl_name:
168
+ continue
169
+ tpl_path = ctx.project_root / "presets" / preset_name / "templates" / tpl_name
170
+ if not tpl_path.exists():
171
+ continue
172
+
173
+ extras = {}
174
+ if task.get("shard_file_list"):
175
+ extras["file_list_override"] = task["shard_file_list"]
176
+
177
+ try:
178
+ prompt = render_prompt(
179
+ template_path=tpl_path, config=config,
180
+ kb_name=ctx.kb_name, module_name=name,
181
+ doc_type=task["doc_type"], assembler=assembler,
182
+ extras=extras, execution_snippet=execution_snippet,
183
+ preset=preset,
184
+ )
185
+ except Exception as e:
186
+ logger.debug("Preflight render failed for %s: %s", task["id"], e)
187
+ continue
188
+
189
+ size = len(prompt.encode("utf-8"))
190
+ all_sizes.append({"task_id": task["id"], "doc_type": task["doc_type"], "bytes": size, "module": name})
191
+
192
+ if size > max_bytes:
193
+ max_bytes = size
194
+ max_task_id = task["id"]
195
+ max_prompt = prompt
196
+
197
+ if not all_sizes:
198
+ return {"error": "No renderable tasks found", "max_bytes": 0, "max_task_id": "",
199
+ "max_prompt": "", "all_sizes": []}
200
+
201
+ return {"error": "", "max_bytes": max_bytes, "max_task_id": max_task_id,
202
+ "max_prompt": max_prompt, "all_sizes": all_sizes}
203
+
204
+ def _phase2_probe(self, ctx: PipelineContext, config: dict, prompt: str, model: str) -> dict:
205
+ """Send a probe request (max_tokens=1) to verify connectivity and get token count."""
206
+ from engine.strategies import create_strategy, ConfigProxy
207
+
208
+ if not prompt:
209
+ return {"error": "No prompt to probe"}
210
+
211
+ try:
212
+ strategy = create_strategy(ConfigProxy(config, timeout_override=30))
213
+ except Exception as e:
214
+ return {"error": f"Strategy init failed: {e}"}
215
+
216
+ try:
217
+ resp = strategy.call(LlmRequest(
218
+ system="Reply with a single dot.",
219
+ user=prompt,
220
+ model=None,
221
+ max_tokens=1,
222
+ temperature=0,
223
+ ))
224
+ except Exception as e:
225
+ error_str = str(e)
226
+ if "context_length" in error_str or "too long" in error_str.lower():
227
+ return {"context_exceeded": True, "error": error_str}
228
+ return {"error": error_str}
229
+
230
+ if resp.status == "failed":
231
+ error_str = resp.error
232
+ if "context_length" in error_str or "too long" in error_str.lower() or "maximum" in error_str.lower():
233
+ return {"context_exceeded": True, "error": error_str}
234
+ return {"error": error_str}
235
+
236
+ input_tokens = resp.usage.get("prompt_tokens", 0)
237
+ return {"input_tokens": input_tokens, "error": ""}
@@ -0,0 +1,147 @@
1
+ """Preflight auto-adjust — re-split oversized doc types when model capacity is exceeded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ from core.interfaces import PipelineContext
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ BYTES_PER_TOKEN = 3
15
+
16
+
17
+ def auto_adjust(
18
+ ctx: PipelineContext,
19
+ config: dict,
20
+ utilization: float,
21
+ input_budget: int,
22
+ all_sizes: list[dict],
23
+ ) -> dict | None:
24
+ """Auto-adjust split thresholds for oversized doc types and re-split."""
25
+ from core.preset import load_preset
26
+ from core.skeleton.split import SplitConfig
27
+
28
+ preset_name = ctx.kb_config.get("preset", "generic")
29
+ preset = load_preset(preset_name)
30
+
31
+ budget_bytes = int(input_budget * BYTES_PER_TOKEN)
32
+ oversized = [s for s in all_sizes if s["bytes"] > budget_bytes * 0.85]
33
+
34
+ if not oversized:
35
+ oversized = sorted(all_sizes, key=lambda x: -x["bytes"])[:3]
36
+
37
+ target_ratio = 0.70 / utilization
38
+ target_ratio = max(target_ratio, 0.3)
39
+
40
+ current_config = SplitConfig.from_preset(preset, "output-only")
41
+ new_max_lines = max(2000, int(current_config.max_lines * target_ratio))
42
+ new_max_bytes = max(100 * 1024, int(current_config.max_bytes * target_ratio))
43
+
44
+ logger.info("Auto-adjust: ratio=%.2f, max_lines %d->%d, max_bytes %d->%d",
45
+ target_ratio, current_config.max_lines, new_max_lines,
46
+ current_config.max_bytes, new_max_bytes)
47
+
48
+ affected_doc_types = set(s["doc_type"] for s in oversized)
49
+ resplit_count = 0
50
+
51
+ for name in ctx.state.get("module_repos", {}):
52
+ module_dir = ctx.knowledge_dir / name
53
+ tasks_file = module_dir / ".meta" / "dispatch-tasks.json"
54
+ if not tasks_file.exists():
55
+ continue
56
+
57
+ try:
58
+ resplit_count += _resplit_module(
59
+ ctx, name, module_dir, preset, affected_doc_types,
60
+ new_max_lines, new_max_bytes,
61
+ )
62
+ except Exception as e:
63
+ logger.error("Re-split failed for %s: %s", name, e)
64
+ return None
65
+
66
+ if resplit_count == 0:
67
+ return None
68
+
69
+ return {
70
+ "summary": f"max_lines={new_max_lines}, max_bytes={new_max_bytes//1024}KB, re-split {resplit_count} doc types",
71
+ "new_max_lines": new_max_lines,
72
+ "new_max_bytes": new_max_bytes,
73
+ "affected_doc_types": list(affected_doc_types),
74
+ "target_ratio": round(target_ratio, 2),
75
+ }
76
+
77
+
78
+ def _resplit_module(
79
+ ctx: PipelineContext,
80
+ module_name: str,
81
+ module_dir: Path,
82
+ preset: dict,
83
+ affected_doc_types: set[str],
84
+ new_max_lines: int,
85
+ new_max_bytes: int,
86
+ ) -> int:
87
+ """Re-run split planning for a module with adjusted thresholds."""
88
+ from core.skeleton.dispatch import compute_dispatch_plan
89
+ from core.skeleton.dispatch_render import plan_to_tasks, write_shard_files
90
+
91
+ adjusted_preset = copy.deepcopy(preset)
92
+ split_section = adjusted_preset.setdefault("split", {})
93
+ output_only = split_section.setdefault("output-only", {})
94
+ output_only["max_lines"] = new_max_lines
95
+ output_only["max_bytes"] = new_max_bytes
96
+
97
+ source = ctx.kb_config.get("source", {})
98
+ cache_dir = Path(source.get("cache_dir", "./.source-cache"))
99
+ base_dir = Path(ctx.config.get("_config_dir", ".")).resolve()
100
+ if not cache_dir.is_absolute():
101
+ cache_dir = (base_dir / cache_dir).resolve()
102
+
103
+ if source.get("structure") == "monorepo":
104
+ repo_name = source.get("repo_name", "repo")
105
+ for mod in source.get("modules", []):
106
+ if mod["name"] == module_name:
107
+ source_cache = cache_dir / repo_name / mod.get("path", module_name)
108
+ break
109
+ else:
110
+ source_cache = cache_dir / module_name
111
+ else:
112
+ source_cache = cache_dir / module_name
113
+
114
+ module_type = "service"
115
+ for repo in source.get("repos", []):
116
+ if repo["name"] == module_name:
117
+ module_type = repo.get("type", "service")
118
+ break
119
+
120
+ plan = compute_dispatch_plan(
121
+ preset=adjusted_preset,
122
+ module_dir=module_dir,
123
+ source_cache=source_cache,
124
+ mode="output-only",
125
+ module_name=module_name,
126
+ module_type=module_type,
127
+ )
128
+
129
+ if not plan or not plan.entries:
130
+ return 0
131
+
132
+ preset_name = adjusted_preset.get("name", "java-spring")
133
+ tasks_manifest = plan_to_tasks(
134
+ plan=plan, kb_name=ctx.kb_name, preset_name=preset_name,
135
+ preset=adjusted_preset, knowledge_dir=ctx.knowledge_dir, mode="output-only",
136
+ )
137
+ tasks_file = module_dir / ".meta" / "dispatch-tasks.json"
138
+ tasks_file.write_text(
139
+ json.dumps(tasks_manifest, ensure_ascii=False, indent=2),
140
+ encoding="utf-8",
141
+ )
142
+
143
+ write_shard_files(plan, module_dir)
144
+
145
+ resplit_count = sum(1 for e in plan.entries if e.doc_type in affected_doc_types)
146
+ logger.info("Re-split %s: %d doc types adjusted", module_name, resplit_count)
147
+ return resplit_count