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