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
|
@@ -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
|