codrspot-processor-mcp 0.1.0__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.
- codepreproc_client/__init__.py +14 -0
- codepreproc_client/__main__.py +9 -0
- codepreproc_client/layer1_business/__init__.py +0 -0
- codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
- codepreproc_client/layer1_business/api_client/__init__.py +0 -0
- codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
- codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
- codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
- codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
- codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
- codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
- codepreproc_client/layer1_business/cli/__init__.py +0 -0
- codepreproc_client/layer1_business/cli/init.py +578 -0
- codepreproc_client/layer1_business/cli/main.py +78 -0
- codepreproc_client/layer1_business/config.py +104 -0
- codepreproc_client/layer1_business/git_utils.py +67 -0
- codepreproc_client/layer1_business/license/__init__.py +0 -0
- codepreproc_client/layer1_business/license/fingerprint.py +61 -0
- codepreproc_client/layer1_business/license/jwt_client.py +60 -0
- codepreproc_client/layer1_business/license/project_lock.py +44 -0
- codepreproc_client/layer1_business/local_registry.py +40 -0
- codepreproc_client/layer1_business/manifest/__init__.py +0 -0
- codepreproc_client/layer1_business/manifest/credentials.py +35 -0
- codepreproc_client/layer1_business/mcp/__init__.py +0 -0
- codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
- codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
- codepreproc_client/layer1_business/mcp/server.py +656 -0
- codepreproc_client/layer1_business/mcp/tools.py +1665 -0
- codepreproc_client/layer1_business/project_registry.py +99 -0
- codepreproc_client/layer2_tooling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
- codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
- codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
- codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
- codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
- codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
- codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
- codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
- codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
- codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
- codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
- codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
- codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
- codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
- codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
- codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
- codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
- codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
- codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
- codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
- codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
- codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
- codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
- codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
- codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
- codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
- codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
- codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
- codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
- codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
- codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
- codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
- codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
- codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
- codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
- codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
- codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
- codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
- codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
- codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
- codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
- codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
- codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
- codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
- contracts/__init__.py +0 -0
- contracts/dtos.py +1239 -0
- contracts/ir.py +102 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Two-phase snippet assembler (Subfase 4.2 + 4.3).
|
|
2
|
+
|
|
3
|
+
Phase 1 (low-cost model): intent resolution → snippet selection → variable binding
|
|
4
|
+
→ scope definition → template instantiation → AssemblyPayload.
|
|
5
|
+
Phase 2 (high-capability model): controlled integration → files written to disk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from codepreproc_client.layer1_business.api_client.reasoning_client import LLMRouter
|
|
17
|
+
from contracts.dtos import (
|
|
18
|
+
AssemblyExecutionPack,
|
|
19
|
+
AssemblyIntent,
|
|
20
|
+
AssemblyPayload,
|
|
21
|
+
AssemblyResult,
|
|
22
|
+
AssemblyScope,
|
|
23
|
+
FailureReason,
|
|
24
|
+
InstantiatedSnippet,
|
|
25
|
+
LLMPolicy,
|
|
26
|
+
LLMUsageStats,
|
|
27
|
+
ProjectConfig,
|
|
28
|
+
SnippetSelection,
|
|
29
|
+
VariableBinding,
|
|
30
|
+
)
|
|
31
|
+
from codepreproc_client.layer2_tooling.materialization.snippet.loader import SnippetLoader
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_ASSEMBLY_SYSTEM_PROMPT = """\
|
|
36
|
+
You are a DDD snippet assembly planner. You select snippets from a library and resolve variables.
|
|
37
|
+
You must respond ONLY with valid JSON. No markdown, no explanation.
|
|
38
|
+
|
|
39
|
+
DDD layer rules:
|
|
40
|
+
- domain: no external dependencies
|
|
41
|
+
- application: orchestrates domain, no infrastructure or interface imports
|
|
42
|
+
- infrastructure: implements adapters, no interface imports
|
|
43
|
+
- interface: handles IO, may import application and domain
|
|
44
|
+
|
|
45
|
+
Forbidden actions:
|
|
46
|
+
- generating code without a snippet
|
|
47
|
+
- modifying out-of-scope files
|
|
48
|
+
- introducing new architecture
|
|
49
|
+
- duplicating logic
|
|
50
|
+
- cross-layer violations
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_INTEGRATION_SYSTEM_PROMPT = """\
|
|
54
|
+
You are a DDD integration specialist. You integrate pre-instantiated code snippets into a codebase.
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
- Do not rewrite entire files unless required
|
|
58
|
+
- Preserve existing logic
|
|
59
|
+
- Merge changes locally
|
|
60
|
+
- Respect imports and structure
|
|
61
|
+
- Maintain layer boundaries
|
|
62
|
+
- No missing imports
|
|
63
|
+
- No unused variables
|
|
64
|
+
- No broken references
|
|
65
|
+
- No duplicate logic
|
|
66
|
+
- Consistent naming
|
|
67
|
+
- Valid DDD layering
|
|
68
|
+
|
|
69
|
+
Output format: JSON with keys "files_modified" (list of paths), "files_created" (list of paths),
|
|
70
|
+
"integration_notes" (list of strings explaining integration decisions).
|
|
71
|
+
No explanations outside the JSON.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_DDD_CONSTRAINTS = [
|
|
75
|
+
"domain has no external dependencies",
|
|
76
|
+
"application orchestrates domain only",
|
|
77
|
+
"infrastructure implements adapters, no interface imports",
|
|
78
|
+
"interface handles IO, may import application and domain",
|
|
79
|
+
"no circular dependencies between layers",
|
|
80
|
+
"no free code generation outside snippet templates",
|
|
81
|
+
"no modifications to out-of-scope files",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SnippetAssembler:
|
|
86
|
+
"""Runs the two-phase assembly pipeline."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
loader: SnippetLoader,
|
|
91
|
+
router: LLMRouter,
|
|
92
|
+
project: ProjectConfig,
|
|
93
|
+
logger: logging.Logger | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
self._loader = loader
|
|
96
|
+
self._router = router
|
|
97
|
+
self._project = project
|
|
98
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
99
|
+
|
|
100
|
+
async def assemble(
|
|
101
|
+
self,
|
|
102
|
+
user_prompt: str,
|
|
103
|
+
task_id: str,
|
|
104
|
+
policy: LLMPolicy,
|
|
105
|
+
) -> AssemblyExecutionPack:
|
|
106
|
+
"""Run both phases and return the execution pack."""
|
|
107
|
+
usage_log: list[LLMUsageStats] = []
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Phase 1: intent → selection → variables → scope → instantiation
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
intent, usage = await self._resolve_intent(user_prompt, policy)
|
|
113
|
+
usage_log.append(usage)
|
|
114
|
+
if intent is None:
|
|
115
|
+
return AssemblyExecutionPack(
|
|
116
|
+
success=False,
|
|
117
|
+
task_id=task_id,
|
|
118
|
+
project_id=self._project.project_id,
|
|
119
|
+
failure=FailureReason(
|
|
120
|
+
code="ASSEMBLY_INTENT_FAILED",
|
|
121
|
+
stage="intent_resolution",
|
|
122
|
+
message="Low-cost model could not resolve snippet assembly intent",
|
|
123
|
+
actionable_hint="Ensure the prompt specifies entity, action, framework and language",
|
|
124
|
+
),
|
|
125
|
+
llm_usage=usage_log,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
available = self._loader.list_snippets(framework=intent.framework, language=intent.language)
|
|
129
|
+
if not available:
|
|
130
|
+
return AssemblyExecutionPack(
|
|
131
|
+
success=False,
|
|
132
|
+
task_id=task_id,
|
|
133
|
+
project_id=self._project.project_id,
|
|
134
|
+
intent=intent,
|
|
135
|
+
failure=FailureReason(
|
|
136
|
+
code="NO_SNIPPETS_AVAILABLE",
|
|
137
|
+
stage="snippet_selection",
|
|
138
|
+
message=f"No snippets for framework={intent.framework} language={intent.language}",
|
|
139
|
+
actionable_hint="Check snippets in the cp_snippets PostgreSQL table and the snippets/ directory",
|
|
140
|
+
),
|
|
141
|
+
llm_usage=usage_log,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
selection, usage = await self._select_snippets(user_prompt, intent, available, policy)
|
|
145
|
+
usage_log.append(usage)
|
|
146
|
+
|
|
147
|
+
variables, usage = await self._resolve_variables(user_prompt, intent, policy)
|
|
148
|
+
usage_log.append(usage)
|
|
149
|
+
|
|
150
|
+
scope = self._build_scope(selection, variables, intent)
|
|
151
|
+
|
|
152
|
+
snippets = self._loader.get_snippets_by_ids(
|
|
153
|
+
selection.selected_snippet_ids, intent.framework, intent.language
|
|
154
|
+
)
|
|
155
|
+
if not snippets:
|
|
156
|
+
return AssemblyExecutionPack(
|
|
157
|
+
success=False,
|
|
158
|
+
task_id=task_id,
|
|
159
|
+
project_id=self._project.project_id,
|
|
160
|
+
intent=intent,
|
|
161
|
+
selection=selection,
|
|
162
|
+
failure=FailureReason(
|
|
163
|
+
code="NO_SNIPPETS_SELECTED",
|
|
164
|
+
stage="snippet_selection",
|
|
165
|
+
message="No valid snippets could be loaded for the selected IDs",
|
|
166
|
+
actionable_hint=f"Selected IDs: {selection.selected_snippet_ids}. "
|
|
167
|
+
"Verify ids exist in the registry for the resolved framework/language.",
|
|
168
|
+
),
|
|
169
|
+
llm_usage=usage_log,
|
|
170
|
+
)
|
|
171
|
+
instantiated = [self._instantiate(s, variables) for s in snippets]
|
|
172
|
+
|
|
173
|
+
payload = AssemblyPayload(
|
|
174
|
+
snippets=instantiated,
|
|
175
|
+
variables=variables,
|
|
176
|
+
scope=scope,
|
|
177
|
+
target_files=[path for s in instantiated for path in s.produces],
|
|
178
|
+
ddd_constraints=_DDD_CONSTRAINTS,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Phase 2: controlled integration
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
result, usage = await self._integrate(user_prompt, payload, policy)
|
|
185
|
+
usage_log.append(usage)
|
|
186
|
+
|
|
187
|
+
# Write files to disk
|
|
188
|
+
written = self._write_files(instantiated, payload.scope)
|
|
189
|
+
|
|
190
|
+
return AssemblyExecutionPack(
|
|
191
|
+
success=True,
|
|
192
|
+
task_id=task_id,
|
|
193
|
+
project_id=self._project.project_id,
|
|
194
|
+
intent=intent,
|
|
195
|
+
selection=selection,
|
|
196
|
+
variables=variables,
|
|
197
|
+
payload=payload,
|
|
198
|
+
result=result or AssemblyResult(
|
|
199
|
+
files_created=written,
|
|
200
|
+
integration_notes=["Files written from instantiated snippets"],
|
|
201
|
+
),
|
|
202
|
+
llm_usage=usage_log,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Phase 1 steps
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async def _resolve_intent(
|
|
210
|
+
self, user_prompt: str, policy: LLMPolicy
|
|
211
|
+
) -> tuple[AssemblyIntent | None, LLMUsageStats]:
|
|
212
|
+
available_frameworks = list({
|
|
213
|
+
s.framework for s in self._loader.list_snippets()
|
|
214
|
+
})
|
|
215
|
+
available_languages = list({
|
|
216
|
+
s.language for s in self._loader.list_snippets()
|
|
217
|
+
})
|
|
218
|
+
messages = [
|
|
219
|
+
{"role": "system", "content": _ASSEMBLY_SYSTEM_PROMPT},
|
|
220
|
+
{
|
|
221
|
+
"role": "user",
|
|
222
|
+
"content": (
|
|
223
|
+
f"User request: {user_prompt}\n\n"
|
|
224
|
+
f"Available frameworks: {available_frameworks}\n"
|
|
225
|
+
f"Available languages: {available_languages}\n\n"
|
|
226
|
+
"Respond with JSON:\n"
|
|
227
|
+
'{"intent": "...", "entity": "EntityName", "action": "create|update|delete|list", '
|
|
228
|
+
'"framework": "nest|fastapi|flutter|laravel", "language": "typescript|python|dart|php", "pattern": "ddd"}'
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
result, usage = await self._router.chat(
|
|
233
|
+
task_type="snippet_assembly",
|
|
234
|
+
policy=policy,
|
|
235
|
+
messages=messages,
|
|
236
|
+
schema=None,
|
|
237
|
+
max_tokens=256,
|
|
238
|
+
temperature=0.0,
|
|
239
|
+
project_id=self._project.project_id,
|
|
240
|
+
task_id="intent",
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
data = _parse_json(result)
|
|
244
|
+
return AssemblyIntent.model_validate(data), usage
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
self._logger.warning("Intent resolution failed: %s", exc)
|
|
247
|
+
return None, usage
|
|
248
|
+
|
|
249
|
+
async def _select_snippets(
|
|
250
|
+
self,
|
|
251
|
+
user_prompt: str,
|
|
252
|
+
intent: AssemblyIntent,
|
|
253
|
+
available: list,
|
|
254
|
+
policy: LLMPolicy,
|
|
255
|
+
) -> tuple[SnippetSelection, LLMUsageStats]:
|
|
256
|
+
snippet_list = "\n".join(
|
|
257
|
+
f"- id={s.id} layer={s.layer} tags={s.tags}" for s in available
|
|
258
|
+
)
|
|
259
|
+
messages = [
|
|
260
|
+
{"role": "system", "content": _ASSEMBLY_SYSTEM_PROMPT},
|
|
261
|
+
{
|
|
262
|
+
"role": "user",
|
|
263
|
+
"content": (
|
|
264
|
+
f"User request: {user_prompt}\n"
|
|
265
|
+
f"Intent: {intent.model_dump_json()}\n\n"
|
|
266
|
+
f"Available snippets for {intent.framework}/{intent.language}:\n{snippet_list}\n\n"
|
|
267
|
+
"Select only the snippets needed. Use exact ids. Respond with JSON:\n"
|
|
268
|
+
'{"selected_snippet_ids": ["id1", "id2"], "rationale": "..."}'
|
|
269
|
+
),
|
|
270
|
+
},
|
|
271
|
+
]
|
|
272
|
+
result, usage = await self._router.chat(
|
|
273
|
+
task_type="snippet_assembly",
|
|
274
|
+
policy=policy,
|
|
275
|
+
messages=messages,
|
|
276
|
+
schema=None,
|
|
277
|
+
max_tokens=256,
|
|
278
|
+
temperature=0.0,
|
|
279
|
+
project_id=self._project.project_id,
|
|
280
|
+
task_id="selection",
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
data = _parse_json(result)
|
|
284
|
+
return SnippetSelection.model_validate(data), usage
|
|
285
|
+
except Exception:
|
|
286
|
+
# Fallback: select all available snippets
|
|
287
|
+
return SnippetSelection(
|
|
288
|
+
selected_snippet_ids=[s.id for s in available],
|
|
289
|
+
rationale="fallback: all available snippets selected",
|
|
290
|
+
), usage
|
|
291
|
+
|
|
292
|
+
async def _resolve_variables(
|
|
293
|
+
self, user_prompt: str, intent: AssemblyIntent, policy: LLMPolicy
|
|
294
|
+
) -> tuple[VariableBinding, LLMUsageStats]:
|
|
295
|
+
messages = [
|
|
296
|
+
{"role": "system", "content": _ASSEMBLY_SYSTEM_PROMPT},
|
|
297
|
+
{
|
|
298
|
+
"role": "user",
|
|
299
|
+
"content": (
|
|
300
|
+
f"Entity: {intent.entity}\n"
|
|
301
|
+
f"Framework: {intent.framework}\n\n"
|
|
302
|
+
"Derive variable values. Rules:\n"
|
|
303
|
+
"- EntityName: PascalCase of the entity\n"
|
|
304
|
+
"- entityName: camelCase (TypeScript/Dart) or snake_case (Python/PHP)\n"
|
|
305
|
+
"- moduleName: kebab-case (NestJS), snake_case (FastAPI/Flutter/Laravel)\n\n"
|
|
306
|
+
"Respond with JSON:\n"
|
|
307
|
+
'{"EntityName": "...", "entityName": "...", "moduleName": "..."}'
|
|
308
|
+
),
|
|
309
|
+
},
|
|
310
|
+
]
|
|
311
|
+
result, usage = await self._router.chat(
|
|
312
|
+
task_type="snippet_assembly",
|
|
313
|
+
policy=policy,
|
|
314
|
+
messages=messages,
|
|
315
|
+
schema=None,
|
|
316
|
+
max_tokens=128,
|
|
317
|
+
temperature=0.0,
|
|
318
|
+
project_id=self._project.project_id,
|
|
319
|
+
task_id="variables",
|
|
320
|
+
)
|
|
321
|
+
try:
|
|
322
|
+
data = _parse_json(result)
|
|
323
|
+
return VariableBinding.model_validate(data), usage
|
|
324
|
+
except Exception:
|
|
325
|
+
# Fallback: derive from intent.entity
|
|
326
|
+
entity = intent.entity
|
|
327
|
+
entity_pascal = _to_pascal(entity)
|
|
328
|
+
if intent.language in {"python", "dart", "php"}:
|
|
329
|
+
entity_lower = _to_snake(entity)
|
|
330
|
+
module = _to_snake(entity)
|
|
331
|
+
else:
|
|
332
|
+
entity_lower = _to_camel(entity)
|
|
333
|
+
module = _to_kebab(entity)
|
|
334
|
+
return VariableBinding(
|
|
335
|
+
EntityName=entity_pascal,
|
|
336
|
+
entityName=entity_lower,
|
|
337
|
+
moduleName=module,
|
|
338
|
+
), usage
|
|
339
|
+
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
# Phase 2: controlled integration
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
async def _integrate(
|
|
345
|
+
self,
|
|
346
|
+
user_prompt: str,
|
|
347
|
+
payload: AssemblyPayload,
|
|
348
|
+
policy: LLMPolicy,
|
|
349
|
+
) -> tuple[AssemblyResult | None, LLMUsageStats]:
|
|
350
|
+
snippets_summary = "\n".join(
|
|
351
|
+
f"- {s.snippet_id} ({s.layer}) → {s.produces}"
|
|
352
|
+
for s in payload.snippets
|
|
353
|
+
)
|
|
354
|
+
messages = [
|
|
355
|
+
{"role": "system", "content": _INTEGRATION_SYSTEM_PROMPT},
|
|
356
|
+
{
|
|
357
|
+
"role": "user",
|
|
358
|
+
"content": (
|
|
359
|
+
f"Request: {user_prompt}\n\n"
|
|
360
|
+
f"Variables: {payload.variables.model_dump_json()}\n\n"
|
|
361
|
+
f"Snippets to integrate:\n{snippets_summary}\n\n"
|
|
362
|
+
f"Scope — allowed files:\n" + "\n".join(f" - {f}" for f in payload.scope.allowed_files) + "\n\n"
|
|
363
|
+
f"DDD constraints:\n" + "\n".join(f" - {c}" for c in payload.ddd_constraints) + "\n\n"
|
|
364
|
+
"Respond with JSON:\n"
|
|
365
|
+
'{"files_modified": [], "files_created": ["path1", ...], "integration_notes": ["..."]}'
|
|
366
|
+
),
|
|
367
|
+
},
|
|
368
|
+
]
|
|
369
|
+
result, usage = await self._router.chat(
|
|
370
|
+
task_type="snippet_integration",
|
|
371
|
+
policy=policy,
|
|
372
|
+
messages=messages,
|
|
373
|
+
schema=None,
|
|
374
|
+
max_tokens=512,
|
|
375
|
+
temperature=0.0,
|
|
376
|
+
project_id=self._project.project_id,
|
|
377
|
+
task_id="integration",
|
|
378
|
+
)
|
|
379
|
+
try:
|
|
380
|
+
data = _parse_json(result)
|
|
381
|
+
return AssemblyResult.model_validate(data), usage
|
|
382
|
+
except Exception as exc:
|
|
383
|
+
self._logger.warning(
|
|
384
|
+
"Integration phase failed — files will be written without LLM guidance: %s", exc
|
|
385
|
+
)
|
|
386
|
+
return None, usage
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
# File writing
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def _build_scope(
|
|
393
|
+
self, selection: SnippetSelection, variables: VariableBinding, intent: AssemblyIntent
|
|
394
|
+
) -> AssemblyScope:
|
|
395
|
+
snippets = self._loader.get_snippets_by_ids(
|
|
396
|
+
selection.selected_snippet_ids, intent.framework, intent.language
|
|
397
|
+
)
|
|
398
|
+
allowed: list[str] = []
|
|
399
|
+
for spec in snippets:
|
|
400
|
+
for path_template in spec.produces:
|
|
401
|
+
resolved = _apply_variables(path_template, variables)
|
|
402
|
+
allowed.append(resolved)
|
|
403
|
+
return AssemblyScope(
|
|
404
|
+
allowed_files=allowed,
|
|
405
|
+
forbidden=["global_config", "unrelated_modules"],
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def _instantiate(self, spec: Any, variables: VariableBinding) -> InstantiatedSnippet:
|
|
409
|
+
code = _apply_variables(spec.template, variables)
|
|
410
|
+
produces = [_apply_variables(p, variables) for p in spec.produces]
|
|
411
|
+
return InstantiatedSnippet(
|
|
412
|
+
snippet_id=spec.id,
|
|
413
|
+
framework=spec.framework,
|
|
414
|
+
language=spec.language,
|
|
415
|
+
layer=spec.layer,
|
|
416
|
+
produces=produces,
|
|
417
|
+
instantiated_code=code,
|
|
418
|
+
integration=spec.integration,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _write_files(self, snippets: list[InstantiatedSnippet], scope: AssemblyScope) -> list[str]:
|
|
422
|
+
written: list[str] = []
|
|
423
|
+
for snippet in snippets:
|
|
424
|
+
for i, relative_path in enumerate(snippet.produces):
|
|
425
|
+
if relative_path not in scope.allowed_files:
|
|
426
|
+
self._logger.warning("Skipping out-of-scope file: %s", relative_path)
|
|
427
|
+
continue
|
|
428
|
+
full_path = self._project.root / relative_path
|
|
429
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
full_path.write_text(snippet.instantiated_code, encoding="utf-8")
|
|
431
|
+
written.append(str(full_path))
|
|
432
|
+
self._logger.info("Written: %s", full_path)
|
|
433
|
+
return written
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ------------------------------------------------------------------
|
|
437
|
+
# Utility helpers
|
|
438
|
+
# ------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def _parse_json(result: Any) -> dict:
|
|
441
|
+
"""Extract first valid JSON object from LLM result.
|
|
442
|
+
|
|
443
|
+
Tolerates: markdown code fences, surrounding explanation text,
|
|
444
|
+
non-string content (empty dict from FakeProvider, etc.).
|
|
445
|
+
"""
|
|
446
|
+
if isinstance(result, dict):
|
|
447
|
+
content = result.get("content", "")
|
|
448
|
+
if isinstance(content, dict):
|
|
449
|
+
# Provider returned a pre-parsed dict (e.g. FakeProvider) — use directly
|
|
450
|
+
return content
|
|
451
|
+
if not isinstance(content, str):
|
|
452
|
+
content = ""
|
|
453
|
+
else:
|
|
454
|
+
content = str(result)
|
|
455
|
+
|
|
456
|
+
# Strip markdown code fences (``` or ```json)
|
|
457
|
+
content = re.sub(r"```(?:json)?\s*", "", content)
|
|
458
|
+
content = content.strip()
|
|
459
|
+
|
|
460
|
+
# 1. Try parsing the whole cleaned content directly
|
|
461
|
+
try:
|
|
462
|
+
return json.loads(content)
|
|
463
|
+
except json.JSONDecodeError:
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
# 2. Find the first {...} block in case there is surrounding text
|
|
467
|
+
match = re.search(r"\{.*\}", content, re.DOTALL)
|
|
468
|
+
if match:
|
|
469
|
+
try:
|
|
470
|
+
return json.loads(match.group())
|
|
471
|
+
except json.JSONDecodeError:
|
|
472
|
+
pass
|
|
473
|
+
|
|
474
|
+
raise ValueError(f"No valid JSON object found in LLM response: {content[:300]!r}")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _apply_variables(template: str, variables: VariableBinding) -> str:
|
|
478
|
+
result = template
|
|
479
|
+
result = result.replace("{{EntityName}}", variables.EntityName)
|
|
480
|
+
result = result.replace("{{entityName}}", variables.entityName)
|
|
481
|
+
result = result.replace("{{moduleName}}", variables.moduleName)
|
|
482
|
+
for key, value in variables.extra.items():
|
|
483
|
+
result = result.replace(f"{{{{{key}}}}}", value)
|
|
484
|
+
return result
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _to_pascal(name: str) -> str:
|
|
488
|
+
return "".join(word.capitalize() for word in re.split(r"[_\-\s]+", name) if word)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _to_camel(name: str) -> str:
|
|
492
|
+
words = [w for w in re.split(r"[_\-\s]+", name) if w]
|
|
493
|
+
return words[0].lower() + "".join(w.capitalize() for w in words[1:]) if words else name
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _to_snake(name: str) -> str:
|
|
497
|
+
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
498
|
+
s = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s)
|
|
499
|
+
return re.sub(r"[\-\s]+", "_", s).lower()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _to_kebab(name: str) -> str:
|
|
503
|
+
return _to_snake(name).replace("_", "-")
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Loads snippets from the registry and snippet files (or PostgreSQL)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Iterator
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from contracts.dtos import SnippetSpec
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SnippetLoader:
|
|
20
|
+
"""Reads snippet_registry.yaml and resolves snippet files relative to a base dir."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, registry_path: Path, base_dir: Path | None = None) -> None:
|
|
23
|
+
self._registry_path = registry_path
|
|
24
|
+
# base_dir defaults to registry_path.parent.parent:
|
|
25
|
+
# registry/snippet_registry.yaml → registry → home_dir
|
|
26
|
+
self._base_dir = base_dir or registry_path.parent.parent
|
|
27
|
+
self._registry: dict | None = None
|
|
28
|
+
# Tuple key (id, framework, language) avoids "::"-separator collisions
|
|
29
|
+
self._cache: dict[tuple[str, str, str], SnippetSpec] = {}
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Public API
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def list_snippets(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
framework: str | None = None,
|
|
39
|
+
language: str | None = None,
|
|
40
|
+
layer: str | None = None,
|
|
41
|
+
tags: list[str] | None = None,
|
|
42
|
+
) -> list[SnippetSpec]:
|
|
43
|
+
"""Return all snippets matching the given filters."""
|
|
44
|
+
results = []
|
|
45
|
+
for spec in self._iter_registry_entries():
|
|
46
|
+
if framework and spec.framework != framework:
|
|
47
|
+
continue
|
|
48
|
+
if language and spec.language != language:
|
|
49
|
+
continue
|
|
50
|
+
if layer and spec.layer != layer:
|
|
51
|
+
continue
|
|
52
|
+
if tags and not all(t in spec.tags for t in tags):
|
|
53
|
+
continue
|
|
54
|
+
results.append(spec)
|
|
55
|
+
return results
|
|
56
|
+
|
|
57
|
+
def get_snippet(self, snippet_id: str, framework: str, language: str) -> SnippetSpec | None:
|
|
58
|
+
"""Return a fully loaded snippet by (id, framework, language)."""
|
|
59
|
+
cache_key = (snippet_id, framework, language)
|
|
60
|
+
if cache_key in self._cache:
|
|
61
|
+
return self._cache[cache_key]
|
|
62
|
+
|
|
63
|
+
for entry in self._registry_data().get("snippets", []):
|
|
64
|
+
if (
|
|
65
|
+
entry.get("id") == snippet_id
|
|
66
|
+
and entry.get("framework") == framework
|
|
67
|
+
and entry.get("language") == language
|
|
68
|
+
):
|
|
69
|
+
spec = self._load_snippet_file(entry)
|
|
70
|
+
if spec is not None:
|
|
71
|
+
self._cache[cache_key] = spec
|
|
72
|
+
return spec
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def get_snippets_by_ids(self, ids: list[str], framework: str, language: str) -> list[SnippetSpec]:
|
|
76
|
+
"""Return snippets in full transitive dependency order (DFS, deepest deps first)."""
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
result: list[SnippetSpec] = []
|
|
79
|
+
for snippet_id in ids:
|
|
80
|
+
self._collect_deps(snippet_id, framework, language, seen, result)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
def _collect_deps(
|
|
84
|
+
self,
|
|
85
|
+
snippet_id: str,
|
|
86
|
+
framework: str,
|
|
87
|
+
language: str,
|
|
88
|
+
seen: set[str],
|
|
89
|
+
result: list[SnippetSpec],
|
|
90
|
+
) -> None:
|
|
91
|
+
"""DFS: insert all transitive dependencies before the snippet itself."""
|
|
92
|
+
if snippet_id in seen:
|
|
93
|
+
return
|
|
94
|
+
seen.add(snippet_id)
|
|
95
|
+
spec = self.get_snippet(snippet_id, framework, language)
|
|
96
|
+
if spec is None:
|
|
97
|
+
return
|
|
98
|
+
for req_id in spec.requires:
|
|
99
|
+
self._collect_deps(req_id, framework, language, seen, result)
|
|
100
|
+
result.append(spec)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Private helpers
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _registry_data(self) -> dict:
|
|
107
|
+
if self._registry is None:
|
|
108
|
+
if not self._registry_path.exists():
|
|
109
|
+
logger.warning("snippet_registry.yaml not found at %s", self._registry_path)
|
|
110
|
+
self._registry = {}
|
|
111
|
+
else:
|
|
112
|
+
with self._registry_path.open(encoding="utf-8") as fh:
|
|
113
|
+
self._registry = yaml.safe_load(fh) or {}
|
|
114
|
+
return self._registry
|
|
115
|
+
|
|
116
|
+
def _iter_registry_entries(self) -> Iterator[SnippetSpec]:
|
|
117
|
+
for entry in self._registry_data().get("snippets", []):
|
|
118
|
+
cache_key = (entry.get("id", ""), entry.get("framework", ""), entry.get("language", ""))
|
|
119
|
+
if cache_key in self._cache:
|
|
120
|
+
yield self._cache[cache_key]
|
|
121
|
+
continue
|
|
122
|
+
spec = self._load_snippet_file(entry)
|
|
123
|
+
if spec is not None:
|
|
124
|
+
yield spec
|
|
125
|
+
|
|
126
|
+
def _load_snippet_file(self, entry: dict) -> SnippetSpec | None:
|
|
127
|
+
relative_path = entry.get("path", "")
|
|
128
|
+
full_path = self._base_dir / relative_path
|
|
129
|
+
if not full_path.exists():
|
|
130
|
+
logger.warning("Snippet file not found: %s", full_path)
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
with full_path.open(encoding="utf-8") as fh:
|
|
134
|
+
data = yaml.safe_load(fh) or {}
|
|
135
|
+
# Merge registry-level metadata with file-level data
|
|
136
|
+
merged = {**entry, **data}
|
|
137
|
+
merged["path"] = relative_path
|
|
138
|
+
return SnippetSpec.model_validate(merged)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
logger.warning("Failed to load snippet %s: %s", full_path, exc)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# API-backed factory
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
async def from_api(cls, *, base_url: str, jwt: str | None, base_dir: Path | None = None) -> "SnippetLoader":
|
|
149
|
+
"""Build a SnippetLoader pre-populated from GET /v1/snippets.
|
|
150
|
+
|
|
151
|
+
Falls back to an empty snippet library (rather than raising) if no
|
|
152
|
+
bootstrap JWT is available yet - e.g. before any project is
|
|
153
|
+
registered - or the server is unreachable.
|
|
154
|
+
"""
|
|
155
|
+
sentinel = Path("/dev/null")
|
|
156
|
+
instance = cls(registry_path=sentinel, base_dir=base_dir)
|
|
157
|
+
|
|
158
|
+
rows = []
|
|
159
|
+
if jwt is not None:
|
|
160
|
+
try:
|
|
161
|
+
from codepreproc_client.layer1_business.api_client.registry_client import list_snippets
|
|
162
|
+
|
|
163
|
+
rows = await list_snippets(base_url=base_url, jwt=jwt)
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.warning("SnippetLoader: failed to load snippets from the API, starting empty", exc_info=True)
|
|
166
|
+
|
|
167
|
+
for spec in rows:
|
|
168
|
+
cache_key = (spec.id, spec.framework, spec.language)
|
|
169
|
+
instance._cache[cache_key] = spec
|
|
170
|
+
# Also populate _registry so list_snippets() works via _iter_registry_entries
|
|
171
|
+
# Build an in-memory registry structure so filtering still works
|
|
172
|
+
instance._registry = {
|
|
173
|
+
"snippets": [
|
|
174
|
+
{
|
|
175
|
+
"id": spec.id,
|
|
176
|
+
"framework": spec.framework,
|
|
177
|
+
"language": spec.language,
|
|
178
|
+
"type": spec.type,
|
|
179
|
+
"layer": spec.layer,
|
|
180
|
+
"tags": spec.tags,
|
|
181
|
+
"path": spec.path,
|
|
182
|
+
}
|
|
183
|
+
for spec in rows
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
logger.info("SnippetLoader: %d snippets loaded from the API", len(rows))
|
|
187
|
+
return instance
|
|
File without changes
|