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,155 @@
|
|
|
1
|
+
"""Audit helpers — progress tracking, prompt building, and utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from core.interfaces import PipelineContext
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_DEFAULT_AUDIT_ORDER = [
|
|
15
|
+
"source-tree-analysis", "data-models", "enums-and-constants",
|
|
16
|
+
"database-access", "api-contracts", "architecture",
|
|
17
|
+
"caching", "messaging", "scheduled-tasks", "error-handling",
|
|
18
|
+
"security", "aop-and-interceptors", "observability",
|
|
19
|
+
"async-and-events", "external-integrations",
|
|
20
|
+
"business-logic", "utils", "index",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compute_doc_size(module_dir: Path) -> int:
|
|
25
|
+
"""Compute total size of markdown docs in a module directory."""
|
|
26
|
+
if not module_dir.is_dir():
|
|
27
|
+
return 0
|
|
28
|
+
return sum(f.stat().st_size for f in module_dir.glob("*.md"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_audit_order(ctx: PipelineContext) -> list[str]:
|
|
32
|
+
"""Get document audit order from preset or default."""
|
|
33
|
+
from core.preset import load_preset
|
|
34
|
+
|
|
35
|
+
preset_name = ctx.kb_config.get("preset", "generic")
|
|
36
|
+
try:
|
|
37
|
+
preset = load_preset(preset_name)
|
|
38
|
+
doc_types = preset.get("doc_types", {})
|
|
39
|
+
ordered = sorted(
|
|
40
|
+
[(k, v.get("batch", 99)) for k, v in doc_types.items() if isinstance(v, dict)],
|
|
41
|
+
key=lambda x: x[1],
|
|
42
|
+
)
|
|
43
|
+
return [k for k, _ in ordered]
|
|
44
|
+
except Exception:
|
|
45
|
+
return _DEFAULT_AUDIT_ORDER
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_audit_progress(module_dir: Path) -> dict:
|
|
49
|
+
"""Load audit progress state."""
|
|
50
|
+
progress_file = module_dir / ".audit-progress.json"
|
|
51
|
+
if not progress_file.exists():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(progress_file.read_text(encoding="utf-8"))
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def update_audit_progress(
|
|
60
|
+
module_dir: Path, doc_type: str, status: str, findings_count: int = 0
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Update audit progress for a document type."""
|
|
63
|
+
progress_file = module_dir / ".audit-progress.json"
|
|
64
|
+
progress: dict = {}
|
|
65
|
+
if progress_file.exists():
|
|
66
|
+
try:
|
|
67
|
+
progress = json.loads(progress_file.read_text(encoding="utf-8"))
|
|
68
|
+
except (json.JSONDecodeError, OSError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
progress[doc_type] = {
|
|
72
|
+
"status": status,
|
|
73
|
+
"findings_count": findings_count,
|
|
74
|
+
"last_updated": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
75
|
+
}
|
|
76
|
+
progress_file.write_text(
|
|
77
|
+
json.dumps(progress, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_audit_system_prompt(doc_type: str) -> str:
|
|
82
|
+
"""Build system prompt for document audit. Loads from preset template if available."""
|
|
83
|
+
from core.preset import find_preset_template
|
|
84
|
+
|
|
85
|
+
template_path = find_preset_template("java-spring", "audit-system.md")
|
|
86
|
+
if template_path and template_path.exists():
|
|
87
|
+
try:
|
|
88
|
+
return template_path.read_text(encoding="utf-8").strip()
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
return (
|
|
92
|
+
"You are a knowledge base quality auditor. Compare document content against "
|
|
93
|
+
"the source code skeleton and identify inconsistencies.\n\n"
|
|
94
|
+
"Output format: JSON array, each element contains:\n"
|
|
95
|
+
"- dimension: audit dimension name\n"
|
|
96
|
+
'- status: "pass" or "fail"\n'
|
|
97
|
+
"- detail: issue description (required when status=fail)\n"
|
|
98
|
+
"- fix: suggested fix content (optional)\n\n"
|
|
99
|
+
"Audit dimensions:\n"
|
|
100
|
+
"- completeness: classes/methods/fields in skeleton covered in document\n"
|
|
101
|
+
"- accuracy: document descriptions match source code\n"
|
|
102
|
+
"- freshness: no outdated content (source deleted/renamed but doc not updated)\n"
|
|
103
|
+
"- reference_integrity: cross-document reference links are valid\n\n"
|
|
104
|
+
"Output JSON only, no other text."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_audit_user_prompt(
|
|
109
|
+
doc_file: Path, doc_type: str, module_dir: Path, doc_truncate_chars: int = 50000
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Build user prompt for document audit."""
|
|
112
|
+
try:
|
|
113
|
+
doc_content = doc_file.read_text(encoding="utf-8", errors="replace")
|
|
114
|
+
except OSError:
|
|
115
|
+
doc_content = ""
|
|
116
|
+
|
|
117
|
+
if len(doc_content) > doc_truncate_chars:
|
|
118
|
+
doc_content = doc_content[:doc_truncate_chars] + "\n\n... (truncated)"
|
|
119
|
+
|
|
120
|
+
skeleton_summary = ""
|
|
121
|
+
meta_dir = module_dir / ".meta"
|
|
122
|
+
summary_path = meta_dir / "skeleton-summary.json"
|
|
123
|
+
if summary_path.exists():
|
|
124
|
+
try:
|
|
125
|
+
summary_data = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
126
|
+
summary_lines = []
|
|
127
|
+
for entry in summary_data[:50]:
|
|
128
|
+
fname = entry.get("file", "")
|
|
129
|
+
mc = entry.get("method_count", 0)
|
|
130
|
+
summary_lines.append(f" {fname} ({mc} methods)")
|
|
131
|
+
skeleton_summary = "\n".join(summary_lines)
|
|
132
|
+
except (json.JSONDecodeError, OSError):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
parts = [
|
|
136
|
+
f"Doc type: {doc_type}",
|
|
137
|
+
f"Doc path: {doc_file.name}",
|
|
138
|
+
"",
|
|
139
|
+
"Document content:",
|
|
140
|
+
doc_content,
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
if skeleton_summary:
|
|
144
|
+
parts.extend([
|
|
145
|
+
"",
|
|
146
|
+
"Skeleton summary (file list):",
|
|
147
|
+
skeleton_summary,
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
parts.extend([
|
|
151
|
+
"",
|
|
152
|
+
"Audit this document and output JSON-formatted audit results.",
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""LLM classification step — batch LLM calls for uncovered files.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- LlmClassifyStep: orchestrates LLM-based file classification
|
|
5
|
+
- Batch LLM calls with result caching
|
|
6
|
+
- Delegated mode skip logic
|
|
7
|
+
|
|
8
|
+
Requirements: 12.1, 12.2, 12.3
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from core.interfaces import Step, StepResult, PipelineContext, LlmStrategy, LlmRequest
|
|
19
|
+
from core.paths import file_list_dir, file_list_path
|
|
20
|
+
from engine.pipeline import register_step
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CACHE_FILENAME = ".llm-classify-cache.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@register_step
|
|
28
|
+
class LlmClassifyStep(Step):
|
|
29
|
+
"""LLM-assisted classification for files not covered by rules.
|
|
30
|
+
|
|
31
|
+
Triggers when uncovered files exist after rule-based classification.
|
|
32
|
+
Results are cached to avoid redundant LLM calls on re-runs.
|
|
33
|
+
Skipped in delegated mode (no LLM access).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
default_name = "llm-classify"
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
super().__init__("llm-classify")
|
|
40
|
+
|
|
41
|
+
def run(self, ctx: PipelineContext) -> StepResult:
|
|
42
|
+
from core.skeleton.file_list import load_skeleton
|
|
43
|
+
from core.skeleton.classify import find_uncovered_files
|
|
44
|
+
from core.preset import load_preset, get_doc_type_mapping
|
|
45
|
+
from engine.strategies import create_strategy
|
|
46
|
+
from engine.pipeline.steps.generate import _make_config_obj
|
|
47
|
+
from engine.pipeline.steps.classify_prompt import (
|
|
48
|
+
build_classify_prompt, parse_classify_response, generate_rules,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Create strategy — if delegated, this step is handled by Agent directly
|
|
52
|
+
config_obj = _make_config_obj(ctx)
|
|
53
|
+
strategy = create_strategy(config_obj)
|
|
54
|
+
|
|
55
|
+
# Probe: delegated strategy means Agent handles classification
|
|
56
|
+
from core.interfaces import LlmRequest
|
|
57
|
+
probe = strategy.call(LlmRequest(system="probe", user="probe"))
|
|
58
|
+
if probe.status == "delegated":
|
|
59
|
+
return StepResult(status="skipped", message="Delegated mode: LLM classify handled by Agent")
|
|
60
|
+
|
|
61
|
+
preset_name = ctx.kb_config.get("preset", "generic")
|
|
62
|
+
preset = load_preset(preset_name)
|
|
63
|
+
doc_mapping = get_doc_type_mapping(preset)
|
|
64
|
+
strategy = create_strategy(config_obj)
|
|
65
|
+
|
|
66
|
+
module_repos: dict[str, Path] = ctx.state.get("module_repos", {})
|
|
67
|
+
cache_dir = _resolve_cache_dir(ctx)
|
|
68
|
+
|
|
69
|
+
total_uncovered = 0
|
|
70
|
+
total_classified = 0
|
|
71
|
+
|
|
72
|
+
for name in module_repos:
|
|
73
|
+
module_dir = ctx.knowledge_dir / name
|
|
74
|
+
source_cache = cache_dir / name
|
|
75
|
+
entries = load_skeleton(module_dir)
|
|
76
|
+
if not entries:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
uncovered = find_uncovered_files(entries, preset)
|
|
80
|
+
if not uncovered:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
total_uncovered += len(uncovered)
|
|
84
|
+
|
|
85
|
+
# Check cache
|
|
86
|
+
cached = _load_cache(module_dir)
|
|
87
|
+
cache_key = _compute_cache_key(uncovered)
|
|
88
|
+
if cached.get("key") == cache_key and cached.get("classifications"):
|
|
89
|
+
classifications = cached["classifications"]
|
|
90
|
+
total_classified += len(classifications)
|
|
91
|
+
_apply_classifications(module_dir, classifications, doc_mapping, source_cache)
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# Build prompt and call LLM
|
|
95
|
+
limits = preset.get("limits", {})
|
|
96
|
+
classify_max_tokens = limits.get("classify_max_tokens", 4096)
|
|
97
|
+
system_prompt, user_prompt = build_classify_prompt(uncovered, preset)
|
|
98
|
+
try:
|
|
99
|
+
resp = strategy.call(LlmRequest(
|
|
100
|
+
system=system_prompt,
|
|
101
|
+
user=user_prompt,
|
|
102
|
+
max_tokens=classify_max_tokens,
|
|
103
|
+
temperature=0.1,
|
|
104
|
+
))
|
|
105
|
+
except Exception as e:
|
|
106
|
+
print(f" [{name}] LLM classify error: {e}", file=sys.stderr)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if resp.status == "delegated":
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
classifications = parse_classify_response(resp.content)
|
|
113
|
+
if not classifications:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
total_classified += len(classifications)
|
|
117
|
+
|
|
118
|
+
# Cache results
|
|
119
|
+
_save_cache(module_dir, cache_key, classifications)
|
|
120
|
+
|
|
121
|
+
# Apply to file lists
|
|
122
|
+
_apply_classifications(module_dir, classifications, doc_mapping, source_cache)
|
|
123
|
+
|
|
124
|
+
# Generate rules
|
|
125
|
+
rules = generate_rules(classifications, preset)
|
|
126
|
+
if rules:
|
|
127
|
+
_write_rules(ctx.project_root, preset_name, rules)
|
|
128
|
+
|
|
129
|
+
if total_uncovered == 0:
|
|
130
|
+
return StepResult(status="ok", message="All files covered by rules")
|
|
131
|
+
|
|
132
|
+
return StepResult(
|
|
133
|
+
status="ok",
|
|
134
|
+
message=f"LLM classified {total_classified}/{total_uncovered} uncovered files",
|
|
135
|
+
details={"uncovered": total_uncovered, "classified": total_classified},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _resolve_cache_dir(ctx: PipelineContext) -> Path:
|
|
140
|
+
"""Resolve source cache directory."""
|
|
141
|
+
cache_dir = Path(ctx.kb_config.get("source", {}).get("cache_dir", ".source-cache"))
|
|
142
|
+
if not cache_dir.is_absolute():
|
|
143
|
+
cache_dir = (ctx.project_root / cache_dir).resolve()
|
|
144
|
+
return cache_dir
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _compute_cache_key(entries: list[dict]) -> str:
|
|
148
|
+
"""Compute a stable cache key from uncovered entries."""
|
|
149
|
+
import hashlib
|
|
150
|
+
files = sorted(e.get("file", "") for e in entries)
|
|
151
|
+
return hashlib.md5("|".join(files).encode()).hexdigest()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _load_cache(module_dir: Path) -> dict:
|
|
155
|
+
"""Load classification cache for a module."""
|
|
156
|
+
cache_path = module_dir / ".meta" / CACHE_FILENAME
|
|
157
|
+
if not cache_path.exists():
|
|
158
|
+
return {}
|
|
159
|
+
try:
|
|
160
|
+
return json.loads(cache_path.read_text(encoding="utf-8"))
|
|
161
|
+
except (json.JSONDecodeError, OSError):
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _save_cache(module_dir: Path, key: str, classifications: list[dict]) -> None:
|
|
166
|
+
"""Save classification results to cache."""
|
|
167
|
+
cache_path = module_dir / ".meta" / CACHE_FILENAME
|
|
168
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
data = {"key": key, "classifications": classifications}
|
|
170
|
+
cache_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _apply_classifications(
|
|
174
|
+
module_dir: Path,
|
|
175
|
+
classifications: list[dict],
|
|
176
|
+
doc_mapping: dict[str, str],
|
|
177
|
+
source_cache: Path,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Apply LLM classifications to file lists."""
|
|
180
|
+
fl_dir = file_list_dir(module_dir)
|
|
181
|
+
fl_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
# Group files by target doc type
|
|
184
|
+
additions: dict[str, list[str]] = {}
|
|
185
|
+
for cls in classifications:
|
|
186
|
+
category = cls.get("category", "")
|
|
187
|
+
file_path = cls.get("file", "")
|
|
188
|
+
if not category or not file_path:
|
|
189
|
+
continue
|
|
190
|
+
# Find which doc file this category maps to
|
|
191
|
+
for doc_key, doc_filename in doc_mapping.items():
|
|
192
|
+
if doc_key == category or category in doc_key:
|
|
193
|
+
stem = doc_filename.removesuffix(".md")
|
|
194
|
+
full_path = str((source_cache / file_path).resolve()).replace("\\", "/")
|
|
195
|
+
additions.setdefault(stem, []).append(full_path)
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
# Append to existing file lists
|
|
199
|
+
for stem, files in additions.items():
|
|
200
|
+
fl = file_list_path(module_dir, stem)
|
|
201
|
+
existing = set()
|
|
202
|
+
if fl.exists():
|
|
203
|
+
existing = set(fl.read_text(encoding="utf-8").strip().splitlines())
|
|
204
|
+
existing.update(files)
|
|
205
|
+
fl.write_text("\n".join(sorted(existing)) + "\n", encoding="utf-8")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _write_rules(project_root: Path, preset_name: str, rules: list[dict]) -> None:
|
|
209
|
+
"""Write generated rules to custom_rules.yaml."""
|
|
210
|
+
import yaml
|
|
211
|
+
|
|
212
|
+
rules_path = project_root / "presets" / preset_name / "custom_rules.yaml"
|
|
213
|
+
rules_path.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
existing: dict = {}
|
|
216
|
+
if rules_path.exists():
|
|
217
|
+
try:
|
|
218
|
+
existing = yaml.safe_load(rules_path.read_text(encoding="utf-8")) or {}
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
classification = existing.setdefault("file_classification", {})
|
|
223
|
+
for rule in rules:
|
|
224
|
+
target = rule.get("target_category", "")
|
|
225
|
+
action = rule.get("action", "")
|
|
226
|
+
value = rule.get("value", "")
|
|
227
|
+
if target and action and value:
|
|
228
|
+
cat = classification.setdefault(target, {})
|
|
229
|
+
cat.setdefault(action, [])
|
|
230
|
+
if value not in cat[action]:
|
|
231
|
+
cat[action].append(value)
|
|
232
|
+
|
|
233
|
+
rules_path.write_text(
|
|
234
|
+
yaml.dump(existing, allow_unicode=True, default_flow_style=False),
|
|
235
|
+
encoding="utf-8",
|
|
236
|
+
)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Classification prompt module — template building and result parsing.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- Classification prompt template construction
|
|
5
|
+
- LLM result parsing (JSON extraction)
|
|
6
|
+
- Rule generation from classification results
|
|
7
|
+
|
|
8
|
+
Requirements: 12.3
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_classify_prompt(
|
|
22
|
+
uncovered_entries: list[dict],
|
|
23
|
+
preset: dict,
|
|
24
|
+
max_files: int = 50,
|
|
25
|
+
) -> tuple[str, str]:
|
|
26
|
+
"""Build LLM classification prompt.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
uncovered_entries: Skeleton entries not covered by rules
|
|
30
|
+
preset: Current preset configuration
|
|
31
|
+
max_files: Max files per prompt batch
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
(system_prompt, user_prompt)
|
|
35
|
+
"""
|
|
36
|
+
entries = uncovered_entries[:max_files]
|
|
37
|
+
classification = preset.get("file_classification", {})
|
|
38
|
+
|
|
39
|
+
rules_summary = _format_rules(classification)
|
|
40
|
+
files_summary = _format_files(entries)
|
|
41
|
+
categories = sorted(classification.keys())
|
|
42
|
+
|
|
43
|
+
language = preset.get("language", "any")
|
|
44
|
+
language_label = preset.get("name", language)
|
|
45
|
+
|
|
46
|
+
system_prompt = (
|
|
47
|
+
f"You are a {language_label} source code classification expert.\n"
|
|
48
|
+
"Your task is to classify source files that cannot be automatically categorized by existing rules, determining which document category they belong to.\n\n"
|
|
49
|
+
"Rules:\n"
|
|
50
|
+
"1. Each file must be assigned to at least one category\n"
|
|
51
|
+
"2. Prefer existing categories; only suggest a new category if the file truly cannot fit into any existing one\n"
|
|
52
|
+
"3. Suggested rules must be automatable (based on annotations, field types, path patterns)\n"
|
|
53
|
+
"4. Output strict JSON format, do not add markdown code block markers"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
user_prompt = (
|
|
57
|
+
f"## Existing Classification Rules\n\n{rules_summary}\n\n"
|
|
58
|
+
f"## Available Categories\n{', '.join(categories)}\n\n"
|
|
59
|
+
f"## Unclassified Files ({len(entries)} total)\n\n{files_summary}\n\n"
|
|
60
|
+
"## Output Requirements\n\n"
|
|
61
|
+
"Output strict JSON:\n"
|
|
62
|
+
"{\n"
|
|
63
|
+
' "classifications": [\n'
|
|
64
|
+
' {"file": "path", "category": "category_name", "reason": "rationale",\n'
|
|
65
|
+
' "rule_suggestion": {"target_category": "category", '
|
|
66
|
+
'"action": "patterns_append", "value": "value"}}\n'
|
|
67
|
+
" ]\n"
|
|
68
|
+
"}\n\n"
|
|
69
|
+
"Note: rule_suggestion is optional."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return system_prompt, user_prompt
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_classify_response(response_text: str) -> list[dict]:
|
|
76
|
+
"""Parse LLM classification response.
|
|
77
|
+
|
|
78
|
+
Handles:
|
|
79
|
+
- Raw JSON
|
|
80
|
+
- JSON wrapped in markdown code blocks
|
|
81
|
+
- Partial JSON extraction
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of classification dicts with keys: file, category, reason, rule_suggestion
|
|
85
|
+
"""
|
|
86
|
+
if not response_text:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
text = response_text.strip()
|
|
90
|
+
|
|
91
|
+
# Remove markdown code block wrapper
|
|
92
|
+
if text.startswith("```"):
|
|
93
|
+
lines = text.split("\n")
|
|
94
|
+
if lines[0].startswith("```"):
|
|
95
|
+
lines = lines[1:]
|
|
96
|
+
if lines and lines[-1].strip() == "```":
|
|
97
|
+
lines = lines[:-1]
|
|
98
|
+
text = "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
# Try direct parse
|
|
101
|
+
data = _try_parse_json(text)
|
|
102
|
+
if data is None:
|
|
103
|
+
# Try extracting JSON block
|
|
104
|
+
match = re.search(r'\{[\s\S]*\}', text)
|
|
105
|
+
if match:
|
|
106
|
+
data = _try_parse_json(match.group())
|
|
107
|
+
|
|
108
|
+
if not data:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
classifications = data.get("classifications", [])
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
"file": c.get("file", ""),
|
|
115
|
+
"category": c.get("category", ""),
|
|
116
|
+
"reason": c.get("reason", ""),
|
|
117
|
+
"rule_suggestion": c.get("rule_suggestion"),
|
|
118
|
+
}
|
|
119
|
+
for c in classifications
|
|
120
|
+
if c.get("file") and c.get("category")
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def generate_rules(classifications: list[dict], preset: dict) -> list[dict]:
|
|
125
|
+
"""Generate automation rules from classification results.
|
|
126
|
+
|
|
127
|
+
Only generates rules when a clear pattern is detected
|
|
128
|
+
(e.g., multiple files with same path pattern classified to same category).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
List of rule dicts with keys: target_category, action, value
|
|
132
|
+
"""
|
|
133
|
+
rules: list[dict] = []
|
|
134
|
+
valid_categories = set(preset.get("file_classification", {}).keys())
|
|
135
|
+
|
|
136
|
+
for cls in classifications:
|
|
137
|
+
suggestion = cls.get("rule_suggestion")
|
|
138
|
+
if not suggestion:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
target = suggestion.get("target_category", "")
|
|
142
|
+
action = suggestion.get("action", "")
|
|
143
|
+
value = suggestion.get("value", "")
|
|
144
|
+
|
|
145
|
+
if not target or not action or not value:
|
|
146
|
+
continue
|
|
147
|
+
if target not in valid_categories:
|
|
148
|
+
continue
|
|
149
|
+
if not action.endswith("_append"):
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
rules.append({
|
|
153
|
+
"target_category": target,
|
|
154
|
+
"action": action,
|
|
155
|
+
"value": value,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return _deduplicate_rules(rules)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _format_rules(classification: dict) -> str:
|
|
162
|
+
"""Format classification rules for prompt context."""
|
|
163
|
+
lines = []
|
|
164
|
+
for name, config in classification.items():
|
|
165
|
+
if not isinstance(config, dict):
|
|
166
|
+
continue
|
|
167
|
+
parts = [f"**{name}**"]
|
|
168
|
+
if config.get("class_annotations"):
|
|
169
|
+
parts.append(f" Class annotations: {config['class_annotations']}")
|
|
170
|
+
if config.get("method_annotations"):
|
|
171
|
+
parts.append(f" Method annotations: {config['method_annotations']}")
|
|
172
|
+
if config.get("field_types"):
|
|
173
|
+
parts.append(f" Field types: {config['field_types'][:10]}")
|
|
174
|
+
if config.get("patterns"):
|
|
175
|
+
parts.append(f" Path patterns: {config['patterns']}")
|
|
176
|
+
if config.get("affects"):
|
|
177
|
+
parts.append(f" Affects documents: {config['affects']}")
|
|
178
|
+
lines.append("\n".join(parts))
|
|
179
|
+
return "\n\n".join(lines)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _format_files(entries: list[dict]) -> str:
|
|
183
|
+
"""Format uncovered files for prompt."""
|
|
184
|
+
lines = []
|
|
185
|
+
for entry in entries:
|
|
186
|
+
file_path = entry.get("file", "")
|
|
187
|
+
classes = entry.get("classes", [])
|
|
188
|
+
methods = entry.get("methods", [])
|
|
189
|
+
|
|
190
|
+
parts = [f"### {file_path}"]
|
|
191
|
+
if classes:
|
|
192
|
+
class_info = []
|
|
193
|
+
for c in classes[:3]:
|
|
194
|
+
annos = c.get("annotations", [])
|
|
195
|
+
anno_str = f" [{', '.join(str(a) for a in annos[:5])}]" if annos else ""
|
|
196
|
+
class_info.append(f"{c.get('name', '?')}{anno_str}")
|
|
197
|
+
parts.append(f" Classes: {'; '.join(class_info)}")
|
|
198
|
+
if methods:
|
|
199
|
+
method_names = [m.get("name", "?") for m in methods[:8]]
|
|
200
|
+
parts.append(f" Methods: {', '.join(method_names)}")
|
|
201
|
+
lines.append("\n".join(parts))
|
|
202
|
+
|
|
203
|
+
return "\n\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _try_parse_json(text: str) -> dict | None:
|
|
207
|
+
"""Try to parse JSON, return None on failure."""
|
|
208
|
+
try:
|
|
209
|
+
return json.loads(text)
|
|
210
|
+
except (json.JSONDecodeError, ValueError):
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _deduplicate_rules(rules: list[dict]) -> list[dict]:
|
|
215
|
+
"""Remove duplicate rules."""
|
|
216
|
+
seen: set[str] = set()
|
|
217
|
+
unique: list[dict] = []
|
|
218
|
+
for rule in rules:
|
|
219
|
+
key = f"{rule['target_category']}:{rule['action']}:{rule['value']}"
|
|
220
|
+
if key not in seen:
|
|
221
|
+
seen.add(key)
|
|
222
|
+
unique.append(rule)
|
|
223
|
+
return unique
|