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,1665 @@
|
|
|
1
|
+
"""MCP tool implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import uuid
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Awaitable, Callable
|
|
17
|
+
|
|
18
|
+
from git import Repo
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from codepreproc_client.layer2_tooling.condensation.chunking.ast_chunker import AstChunker
|
|
22
|
+
from codepreproc_client.layer2_tooling.coupling.domain import CouplingExpandRequest, CouplingProjectRequest, CouplingReport
|
|
23
|
+
from codepreproc_client.layer2_tooling.coupling.graph import CouplingGraph
|
|
24
|
+
from codepreproc_client.layer2_tooling.coupling.projector import CouplingProjector
|
|
25
|
+
from codepreproc_client.layer1_business.git_utils import get_current_branch, get_current_sha, get_dirty_files
|
|
26
|
+
from codepreproc_client.layer2_tooling.condensation.ast_graph import AstGraph
|
|
27
|
+
from codepreproc_client.layer2_tooling.condensation.bm25_store import BM25Store
|
|
28
|
+
from codepreproc_client.layer2_tooling.condensation.scanning.repo_mapper import NeedsMonorepoConfig, RepoMapAnnotator, RepoMapper
|
|
29
|
+
from codepreproc_client.layer2_tooling.condensation.embeddings import Embedder
|
|
30
|
+
from codepreproc_client.layer2_tooling.condensation.indexer import ProjectIndexer
|
|
31
|
+
from codepreproc_client.layer2_tooling.condensation.qdrant_store import QdrantStore
|
|
32
|
+
from codepreproc_client.layer1_business.api_client.reasoning_client import LLMRuntime
|
|
33
|
+
from codepreproc_client.layer2_tooling.condensation.extract.compactor import ContextCompactor
|
|
34
|
+
from codepreproc_client.layer1_business.api_client.reasoning_client import IntentExtractor
|
|
35
|
+
from codepreproc_client.layer1_business.mcp.action_monitor import ActionMonitor, ToolExecutionError
|
|
36
|
+
from contracts.dtos import (
|
|
37
|
+
ProjectConfig,
|
|
38
|
+
Settings as SettingsModel,
|
|
39
|
+
StoredAssemblyTask,
|
|
40
|
+
StoredFilesystemTask,
|
|
41
|
+
StoredSemanticTask,
|
|
42
|
+
)
|
|
43
|
+
from codepreproc_client.layer2_tooling.materialization.pipeline.filesystem_reorg import FilesystemReorganizationPipeline
|
|
44
|
+
from codepreproc_client.layer2_tooling.materialization.pipeline.execute_semantic import SemanticExecutionPipeline
|
|
45
|
+
from codepreproc_client.layer2_tooling.materialization.snippet.loader import SnippetLoader
|
|
46
|
+
from codepreproc_client.layer2_tooling.materialization.snippet.assembler import SnippetAssembler
|
|
47
|
+
from codepreproc_client.layer2_tooling.materialization.semantic.target_locator import TargetLocator
|
|
48
|
+
from codepreproc_client.layer1_business.project_registry import ProjectRegistry
|
|
49
|
+
from codepreproc_client.layer2_tooling.retrieval.graph_walk import GraphWalker
|
|
50
|
+
from codepreproc_client.layer2_tooling.retrieval.hybrid import HybridRetriever
|
|
51
|
+
from codepreproc_client.layer2_tooling.retrieval.reranker import Reranker
|
|
52
|
+
from codepreproc_client.layer2_tooling.materialization.semantic import ApplicabilityChecker, RegionResolver, SemanticEditPlanner, SemanticSynthesizer
|
|
53
|
+
from contracts.ir import BusinessLogicDiff, bld_from_plan
|
|
54
|
+
from codepreproc_client.layer1_business.api_client.superkg_client import EnrichResult, SuperKGClient
|
|
55
|
+
|
|
56
|
+
_CONTEXT_MAP_SYSTEM_PROMPT = """\
|
|
57
|
+
You are a code architecture analyst. Given a set of retrieved code components and their dependency relationships, \
|
|
58
|
+
produce a concise narrative (3-8 sentences) explaining how these components collaborate to implement the queried \
|
|
59
|
+
functionality. Focus on data flow, call chains, and architectural boundaries — not on re-describing each function. \
|
|
60
|
+
Use exact symbol names from the context. Output plain text only, no Markdown headers or code fences.\
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_DOC_GENERATION_SYSTEM_PROMPT = """\
|
|
64
|
+
You are a technical documentation expert specializing in software architecture documentation.
|
|
65
|
+
Using ONLY the code context provided, produce the requested document as raw Markdown.
|
|
66
|
+
|
|
67
|
+
Apply ALL of the following structural patterns where applicable:
|
|
68
|
+
|
|
69
|
+
REQUIRED SECTIONS (include every section that has supporting evidence in the context):
|
|
70
|
+
1. **Visión general + principios**: 2–3 sentences on what the system does, then 4–6 bullet points \
|
|
71
|
+
on guiding design principles extracted from the code philosophy (e.g. "Estado sobre patch", thresholds as contracts).
|
|
72
|
+
2. **Component inventory table**: | Componente | Tipo | Ruta | Rol | — one row per package/module, \
|
|
73
|
+
"Tipo" is Package/App/Specs/etc., "Rol" is a one-line description.
|
|
74
|
+
3. **End-to-end flow diagram**: ASCII art with boxes (┌─┐ │ └─┘), arrows (→ ▼ ▲), \
|
|
75
|
+
and numbered step labels (PASO 1: …, PASO 2: …). Show the full lifecycle from input to output.
|
|
76
|
+
4. **Per-component sections**: For EACH component include:
|
|
77
|
+
- Exact file path, internal dependencies (package names only)
|
|
78
|
+
- Exported functions/classes listed verbatim from the code (not paraphrased)
|
|
79
|
+
- Constants, thresholds, enum values with EXACT numeric values (e.g. score ≥ 0.72)
|
|
80
|
+
- Input/output types using exact type names from the code
|
|
81
|
+
- Sub-file breakdown if the component has multiple source files
|
|
82
|
+
5. **Contracts section**: State machine transitions (e.g. `draft → ready → reflecting → canonicalized → committed`), \
|
|
83
|
+
numeric thresholds table with exact values, validation dimensions, blocking conditions.
|
|
84
|
+
6. **External integrations table**: | Integration | Protocol | Detail |
|
|
85
|
+
7. **Directed dependency graph**: ASCII diagram with directed arrows (◄ ▲) showing dependency direction. \
|
|
86
|
+
Higher-level packages at top depending on lower-level ones at bottom.
|
|
87
|
+
8. **Entry/exit points table**: | Canal | Punto de entrada | Tipo | for inputs; same for outputs.
|
|
88
|
+
|
|
89
|
+
EXTRACTION RULES (critical — the expensive approach always does these):
|
|
90
|
+
- Extract EXACT function/method names from exports, never paraphrase them.
|
|
91
|
+
- Include EXACT numeric constants: scores, thresholds, limits, port numbers, counts.
|
|
92
|
+
- Include EXACT TypeScript/Python type names for inputs and outputs.
|
|
93
|
+
- Include ALL enum/union values (e.g. `os_spec | state_config | idea_state | seed_pack`).
|
|
94
|
+
- Group MCP tools / REST endpoints by category in tables.
|
|
95
|
+
- Use the same technical terminology as the codebase — do not translate or simplify.
|
|
96
|
+
- When listing required artifacts, list ALL of them by name.
|
|
97
|
+
|
|
98
|
+
OUTPUT: Raw Markdown only. No preamble. No trailing explanation. No code fence wrapping the whole document.\
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
_BUSINESS_LOGIC_SYSTEM_PROMPT = """\
|
|
102
|
+
You are a business analyst and domain expert. Given code context from a software repository, \
|
|
103
|
+
extract and document the BUSINESS LOGIC — what the system does, not how it is implemented technically.
|
|
104
|
+
|
|
105
|
+
Produce a Markdown document with the following sections (include only those with evidence in the context):
|
|
106
|
+
|
|
107
|
+
1. **Overview**: 2–4 sentences describing what this system does from a business perspective.
|
|
108
|
+
2. **Domain Model**: Named business entities, their attributes, and relationships. Use a table: | Entity | Key Attributes | Relationships |
|
|
109
|
+
3. **Business Rules**: Numbered list of explicit invariants, constraints, and validations enforced by the code \
|
|
110
|
+
(e.g. "An order cannot be shipped if payment is not confirmed"). Extract exact thresholds and enum values.
|
|
111
|
+
4. **Workflows / Processes**: For each significant workflow or use case, describe the steps as a numbered list \
|
|
112
|
+
or state-machine diagram (states and transitions). Use exact state/status names from the code.
|
|
113
|
+
5. **Entry Points**: How the system receives requests (REST endpoints, MCP tools, CLI commands, events). \
|
|
114
|
+
Table: | Entry Point | Input | Trigger | Output |
|
|
115
|
+
6. **External Integrations**: Services, databases, or APIs the system relies on. \
|
|
116
|
+
Table: | Integration | Purpose | Protocol |
|
|
117
|
+
7. **Error Conditions & Edge Cases**: Explicit failure modes and how the system handles them.
|
|
118
|
+
|
|
119
|
+
RULES:
|
|
120
|
+
- Focus on WHAT the business logic does, not HOW the code implements it.
|
|
121
|
+
- Use exact names from the code for entities, statuses, and operations — do not paraphrase.
|
|
122
|
+
- Extract exact numeric thresholds and enum values verbatim.
|
|
123
|
+
- Do NOT describe implementation details like classes, imports, or algorithms unless they encode a business rule.
|
|
124
|
+
- If a rule is enforced by a constant or threshold in the code, state both the rule and the exact value.
|
|
125
|
+
|
|
126
|
+
OUTPUT: Raw Markdown only. No preamble. No trailing explanation. No code fence wrapping the whole document.\
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
_BUSINESS_LOGIC_QUERIES = [
|
|
130
|
+
"business logic domain rules validation services workflows",
|
|
131
|
+
"domain models entities state machine transitions status",
|
|
132
|
+
"business rules invariants constraints validation error handling",
|
|
133
|
+
"service layer use cases commands queries operations",
|
|
134
|
+
"API endpoints entry points request handling",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SwitchProjectInput(BaseModel):
|
|
139
|
+
project: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AnalyzeRequestInput(BaseModel):
|
|
143
|
+
prompt: str
|
|
144
|
+
project: str | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AnalyzeFilesystemReorgInput(BaseModel):
|
|
148
|
+
prompt: str
|
|
149
|
+
project: str | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PreviewTaskInput(BaseModel):
|
|
153
|
+
task_id: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class DisambiguateInput(BaseModel):
|
|
157
|
+
task_id: str
|
|
158
|
+
chosen_region_id: str
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ApplyPatchInput(BaseModel):
|
|
162
|
+
task_id: str
|
|
163
|
+
patch_index: int | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ApplyFilesystemPlanInput(BaseModel):
|
|
167
|
+
task_id: str
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class UsageReportInput(BaseModel):
|
|
171
|
+
project: str | None = None
|
|
172
|
+
since_days: int = 30
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SetPolicyInput(BaseModel):
|
|
176
|
+
project: str
|
|
177
|
+
policy: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SearchContextInput(BaseModel):
|
|
181
|
+
query: str
|
|
182
|
+
project: str | None = None
|
|
183
|
+
top_k: int = 10
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class MapContextInput(BaseModel):
|
|
187
|
+
query: str
|
|
188
|
+
project: str | None = None
|
|
189
|
+
top_k: int = 10
|
|
190
|
+
include_bodies: bool = True
|
|
191
|
+
synthesize: bool = True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class GenerateDocumentInput(BaseModel):
|
|
195
|
+
prompt: str
|
|
196
|
+
output_path: str
|
|
197
|
+
project: str | None = None
|
|
198
|
+
top_k: int = 15
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class GenerateBusinessLogicInput(BaseModel):
|
|
202
|
+
project: str | None = None
|
|
203
|
+
output_path: str = "BUSINESS_LOGIC.md"
|
|
204
|
+
top_k: int = 20
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ProjectPseudocodeInput(BaseModel):
|
|
208
|
+
project: str | None = None
|
|
209
|
+
file_pattern: str | None = None
|
|
210
|
+
include_bodies: bool = False
|
|
211
|
+
exclude_tests: bool = True
|
|
212
|
+
max_files: int = 200
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ListSnippetsInput(BaseModel):
|
|
216
|
+
framework: str | None = None
|
|
217
|
+
language: str | None = None
|
|
218
|
+
layer: str | None = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class AssembleFromSnippetsInput(BaseModel):
|
|
222
|
+
prompt: str
|
|
223
|
+
project: str | None = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ReindexInput(BaseModel):
|
|
227
|
+
project: str | None = None
|
|
228
|
+
full: bool = False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ProjectStatusInput(BaseModel):
|
|
232
|
+
project: str | None = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class InitProjectInput(BaseModel):
|
|
236
|
+
path: str | None = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class CouplingProjectInput(BaseModel):
|
|
240
|
+
anchors: list[str] = Field(default_factory=list)
|
|
241
|
+
intent: str | None = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class CouplingExpandInput(BaseModel):
|
|
245
|
+
handle_id: str
|
|
246
|
+
edge_id: str | None = None
|
|
247
|
+
offset: int = 0
|
|
248
|
+
limit: int = 50
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class SessionState:
|
|
253
|
+
"""Per-client session cache."""
|
|
254
|
+
|
|
255
|
+
active_project: str | None = None
|
|
256
|
+
user_id: str | None = field(default_factory=lambda: os.environ.get("CODEPREPROC_USER_ID"))
|
|
257
|
+
roots: list[Path] = field(default_factory=list)
|
|
258
|
+
loaded_indexers: dict[str, ProjectIndexer] = field(default_factory=dict)
|
|
259
|
+
semantic_tasks: dict[str, StoredSemanticTask] = field(default_factory=dict)
|
|
260
|
+
filesystem_tasks: dict[str, StoredFilesystemTask] = field(default_factory=dict)
|
|
261
|
+
assembly_tasks: dict[str, StoredAssemblyTask] = field(default_factory=dict)
|
|
262
|
+
coupling_reports: dict[str, CouplingReport] = field(default_factory=dict)
|
|
263
|
+
policy_overrides: dict[str, str] = field(default_factory=dict)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class GenerateRepoMapInput(BaseModel):
|
|
267
|
+
project: str | None = None
|
|
268
|
+
artifacts_folder: str | None = None
|
|
269
|
+
modules_folder: str | None = None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class KGContextInput(BaseModel):
|
|
273
|
+
project: str | None = None
|
|
274
|
+
mode: str = "narrative"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class KGInsightsInput(BaseModel):
|
|
278
|
+
project: str | None = None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class CorrectGraphInput(BaseModel):
|
|
282
|
+
change: str
|
|
283
|
+
before: list[str] = Field(default_factory=list)
|
|
284
|
+
after: list[str] = Field(default_factory=list)
|
|
285
|
+
rules_added: list[str] = Field(default_factory=list)
|
|
286
|
+
rules_removed: list[str] = Field(default_factory=list)
|
|
287
|
+
rules_modified: list[str] = Field(default_factory=list)
|
|
288
|
+
project: str | None = None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class CodepreprocTools:
|
|
292
|
+
"""Concrete MCP tool handlers."""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
settings: SettingsModel,
|
|
297
|
+
registry: ProjectRegistry,
|
|
298
|
+
qdrant_store: QdrantStore,
|
|
299
|
+
llm_runtime: LLMRuntime,
|
|
300
|
+
logger: logging.Logger,
|
|
301
|
+
superkg: SuperKGClient | None = None,
|
|
302
|
+
snippet_loader: SnippetLoader | None = None,
|
|
303
|
+
coupling_graph: CouplingGraph | None = None,
|
|
304
|
+
jwt_provider: Callable[[str], Awaitable[str]] | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
self.settings = settings
|
|
307
|
+
self.registry = registry
|
|
308
|
+
self.qdrant_store = qdrant_store
|
|
309
|
+
self.llm_runtime = llm_runtime
|
|
310
|
+
self.logger = logger
|
|
311
|
+
|
|
312
|
+
async def _disabled_jwt_provider(project_id: str) -> str:
|
|
313
|
+
raise RuntimeError("no jwt_provider configured - SuperKG proxy calls are unavailable")
|
|
314
|
+
|
|
315
|
+
self._jwt_provider = jwt_provider or _disabled_jwt_provider
|
|
316
|
+
self.superkg = superkg or SuperKGClient(
|
|
317
|
+
base_url="http://localhost:8443", jwt_provider=self._jwt_provider, enabled=False
|
|
318
|
+
)
|
|
319
|
+
self.embedder = Embedder.get(settings.defaults)
|
|
320
|
+
self.reranker = Reranker.get(settings.defaults)
|
|
321
|
+
if snippet_loader is None:
|
|
322
|
+
raise ValueError("snippet_loader is required — pass SnippetLoader.from_api() result")
|
|
323
|
+
self._snippet_loader = snippet_loader
|
|
324
|
+
self._coupling_graph = coupling_graph or CouplingGraph(
|
|
325
|
+
settings.indexes_dir / "coupling.db"
|
|
326
|
+
)
|
|
327
|
+
self._coupling_projector = CouplingProjector()
|
|
328
|
+
|
|
329
|
+
async def list_projects(self, session: SessionState) -> dict:
|
|
330
|
+
projects = []
|
|
331
|
+
for project in self.registry.list_projects():
|
|
332
|
+
indexer = self._get_indexer(session, project)
|
|
333
|
+
state = indexer.get_index_status()
|
|
334
|
+
projects.append(
|
|
335
|
+
{
|
|
336
|
+
"id": project.project_id,
|
|
337
|
+
"root": str(project.root),
|
|
338
|
+
"languages": project.languages,
|
|
339
|
+
"llm_policy": session.policy_overrides.get(project.project_id, project.llm_policy),
|
|
340
|
+
"indexed": indexer.ast_graph.get_last_indexed_sha() is not None and state["index_state"] == "ready",
|
|
341
|
+
"last_indexed_sha": indexer.ast_graph.get_last_indexed_sha(),
|
|
342
|
+
"chunk_count": indexer.ast_graph.get_chunk_count(),
|
|
343
|
+
"index_state": state["index_state"],
|
|
344
|
+
"last_index_error": state.get("last_index_error"),
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
return {"projects": projects}
|
|
348
|
+
|
|
349
|
+
async def switch_project(self, session: SessionState, arguments: dict) -> dict:
|
|
350
|
+
payload = SwitchProjectInput.model_validate(arguments)
|
|
351
|
+
status = "ok" if payload.project in self.registry.registry.projects else "not_found"
|
|
352
|
+
if status == "ok":
|
|
353
|
+
session.active_project = payload.project
|
|
354
|
+
return {"active_project": session.active_project, "status": status}
|
|
355
|
+
|
|
356
|
+
async def analyze_request(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
357
|
+
payload = AnalyzeRequestInput.model_validate(arguments)
|
|
358
|
+
project = self._resolve_project(session, payload.project)
|
|
359
|
+
pipeline = self._make_pipeline(session, project)
|
|
360
|
+
stored = await pipeline.run(
|
|
361
|
+
payload.prompt,
|
|
362
|
+
policy_override=session.policy_overrides.get(project.project_id),
|
|
363
|
+
monitor=monitor,
|
|
364
|
+
)
|
|
365
|
+
session.semantic_tasks[stored.pack.task_id] = stored
|
|
366
|
+
return stored.pack.model_dump(mode="json")
|
|
367
|
+
|
|
368
|
+
async def analyze_filesystem_reorg(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
369
|
+
payload = AnalyzeFilesystemReorgInput.model_validate(arguments)
|
|
370
|
+
project = self._resolve_project(session, payload.project)
|
|
371
|
+
pipeline = self._make_filesystem_pipeline(project)
|
|
372
|
+
stored = await pipeline.run(
|
|
373
|
+
payload.prompt,
|
|
374
|
+
policy_override=session.policy_overrides.get(project.project_id),
|
|
375
|
+
monitor=monitor,
|
|
376
|
+
)
|
|
377
|
+
session.filesystem_tasks[stored.pack.task_id] = stored
|
|
378
|
+
return stored.pack.model_dump(mode="json")
|
|
379
|
+
|
|
380
|
+
async def preview_semantic_plan(self, session: SessionState, arguments: dict) -> dict:
|
|
381
|
+
payload = PreviewTaskInput.model_validate(arguments)
|
|
382
|
+
self._prune_tasks(session)
|
|
383
|
+
item = session.semantic_tasks.get(payload.task_id)
|
|
384
|
+
if item is None:
|
|
385
|
+
return {"error": "not_found"}
|
|
386
|
+
return {"semantic_plan": item.pack.semantic_plan.model_dump(mode="json") if item.pack.semantic_plan else None}
|
|
387
|
+
|
|
388
|
+
async def preview_filesystem_plan(self, session: SessionState, arguments: dict) -> dict:
|
|
389
|
+
payload = PreviewTaskInput.model_validate(arguments)
|
|
390
|
+
self._prune_tasks(session)
|
|
391
|
+
item = session.filesystem_tasks.get(payload.task_id)
|
|
392
|
+
if item is None:
|
|
393
|
+
return {"error": "not_found"}
|
|
394
|
+
return {
|
|
395
|
+
"filesystem_plan": item.pack.filesystem_plan.model_dump(mode="json") if item.pack.filesystem_plan else None,
|
|
396
|
+
"validation": item.pack.validation.model_dump(mode="json") if item.pack.validation else None,
|
|
397
|
+
"failure": item.pack.failure.model_dump(mode="json") if item.pack.failure else None,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async def preview_patch(self, session: SessionState, arguments: dict) -> dict:
|
|
401
|
+
payload = PreviewTaskInput.model_validate(arguments)
|
|
402
|
+
self._prune_tasks(session)
|
|
403
|
+
item = session.semantic_tasks.get(payload.task_id)
|
|
404
|
+
if item is None:
|
|
405
|
+
return {"error": "not_found"}
|
|
406
|
+
return {
|
|
407
|
+
"patches": [
|
|
408
|
+
{"file_path": patch.file_path, "diff": patch.unified_diff}
|
|
409
|
+
for patch in sorted(item.pack.patches, key=lambda patch: patch.file_path)
|
|
410
|
+
]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async def validate_patch(self, session: SessionState, arguments: dict) -> dict:
|
|
414
|
+
payload = PreviewTaskInput.model_validate(arguments)
|
|
415
|
+
self._prune_tasks(session)
|
|
416
|
+
item = session.semantic_tasks.get(payload.task_id)
|
|
417
|
+
if item is None:
|
|
418
|
+
return {"error": "not_found"}
|
|
419
|
+
if item.pack.validation is None:
|
|
420
|
+
return {"valid": False, "errors": ["missing_validation"], "warnings": []}
|
|
421
|
+
errors = [error for layer in item.pack.validation.layers for error in layer.errors]
|
|
422
|
+
warnings = [warning for layer in item.pack.validation.layers for warning in layer.warnings]
|
|
423
|
+
return {"valid": item.pack.validation.valid, "errors": errors, "warnings": warnings}
|
|
424
|
+
|
|
425
|
+
async def disambiguate_region(self, session: SessionState, arguments: dict) -> dict:
|
|
426
|
+
payload = DisambiguateInput.model_validate(arguments)
|
|
427
|
+
self._prune_tasks(session)
|
|
428
|
+
item = session.semantic_tasks.get(payload.task_id)
|
|
429
|
+
if item is None:
|
|
430
|
+
return {"error": "not_found"}
|
|
431
|
+
project = self.registry.get(item.project_id)
|
|
432
|
+
pipeline = self._make_pipeline(session, project)
|
|
433
|
+
stored = await pipeline.continue_with_region(item, payload.chosen_region_id)
|
|
434
|
+
session.semantic_tasks[payload.task_id] = stored
|
|
435
|
+
return stored.pack.model_dump(mode="json")
|
|
436
|
+
|
|
437
|
+
async def apply_patch(self, session: SessionState, arguments: dict) -> dict:
|
|
438
|
+
payload = ApplyPatchInput.model_validate(arguments)
|
|
439
|
+
self._prune_tasks(session)
|
|
440
|
+
item = session.semantic_tasks.get(payload.task_id)
|
|
441
|
+
if item is None:
|
|
442
|
+
return {"applied": False, "files_changed": [], "error": "not_found"}
|
|
443
|
+
patches = item.pack.patches
|
|
444
|
+
if payload.patch_index is not None:
|
|
445
|
+
if payload.patch_index < 0 or payload.patch_index >= len(patches):
|
|
446
|
+
return {"applied": False, "files_changed": [], "error": "patch_index_out_of_range"}
|
|
447
|
+
patches = [patches[payload.patch_index]]
|
|
448
|
+
combined = "".join(patch.unified_diff for patch in patches)
|
|
449
|
+
project = self.registry.get(item.project_id)
|
|
450
|
+
proc = subprocess.run(
|
|
451
|
+
["git", "apply", "--ignore-whitespace", "--whitespace=nowarn", "-"],
|
|
452
|
+
input=combined,
|
|
453
|
+
text=True,
|
|
454
|
+
encoding="utf-8",
|
|
455
|
+
capture_output=True,
|
|
456
|
+
cwd=project.root,
|
|
457
|
+
check=False,
|
|
458
|
+
)
|
|
459
|
+
if proc.returncode != 0:
|
|
460
|
+
return {"applied": False, "files_changed": [], "error": (proc.stderr or proc.stdout).strip() or "git apply failed"}
|
|
461
|
+
touched = [patch.file_path for patch in patches]
|
|
462
|
+
for file_path in touched:
|
|
463
|
+
newline_style = item.newline_styles.get(file_path)
|
|
464
|
+
if newline_style == "crlf":
|
|
465
|
+
full_path = project.root / file_path
|
|
466
|
+
full_path.write_text(full_path.read_text(encoding="utf-8").replace("\n", "\r\n"), encoding="utf-8", newline="")
|
|
467
|
+
|
|
468
|
+
# Transactional SuperKG write: fires only after disk mutation succeeds
|
|
469
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
470
|
+
if superkg_system_id:
|
|
471
|
+
plan = item.pack.semantic_plan
|
|
472
|
+
bld = bld_from_plan(plan) if plan else None
|
|
473
|
+
intent_text = getattr(item.pack.intent, "intent", "") if item.pack.intent else ""
|
|
474
|
+
await self.superkg.record_decision(
|
|
475
|
+
project_id=project.project_id,
|
|
476
|
+
system_id=superkg_system_id,
|
|
477
|
+
session_id=None,
|
|
478
|
+
intent=intent_text,
|
|
479
|
+
plan_summary={"task_id": item.pack.task_id, "applied": True},
|
|
480
|
+
bld=bld,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
return {"applied": True, "files_changed": touched, "error": None}
|
|
484
|
+
|
|
485
|
+
async def apply_filesystem_plan(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
486
|
+
payload = ApplyFilesystemPlanInput.model_validate(arguments)
|
|
487
|
+
self._prune_tasks(session)
|
|
488
|
+
item = session.filesystem_tasks.get(payload.task_id)
|
|
489
|
+
if item is None:
|
|
490
|
+
return {"applied": False, "operations_applied": [], "error": "not_found"}
|
|
491
|
+
project = self.registry.get(item.project_id)
|
|
492
|
+
pipeline = self._make_filesystem_pipeline(project)
|
|
493
|
+
result = await pipeline.apply(item, monitor=monitor)
|
|
494
|
+
if result.get("applied"):
|
|
495
|
+
session.filesystem_tasks.pop(payload.task_id, None)
|
|
496
|
+
return result
|
|
497
|
+
|
|
498
|
+
async def usage_report(self, session: SessionState, arguments: dict) -> dict:
|
|
499
|
+
payload = UsageReportInput.model_validate(arguments)
|
|
500
|
+
return self.llm_runtime.cost_tracker.usage_report(project=payload.project, since_days=payload.since_days)
|
|
501
|
+
|
|
502
|
+
async def set_llm_policy(self, session: SessionState, arguments: dict) -> dict:
|
|
503
|
+
payload = SetPolicyInput.model_validate(arguments)
|
|
504
|
+
project = self.registry.get(payload.project)
|
|
505
|
+
previous = session.policy_overrides.get(project.project_id, project.llm_policy)
|
|
506
|
+
session.policy_overrides[project.project_id] = payload.policy
|
|
507
|
+
return {"applied": True, "previous_policy": previous}
|
|
508
|
+
|
|
509
|
+
async def reindex(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
510
|
+
payload = ReindexInput.model_validate(arguments)
|
|
511
|
+
|
|
512
|
+
# Reload the local project registry mirror so config changes (e.g. languages)
|
|
513
|
+
# take effect without restart - no server round trip needed to list projects.
|
|
514
|
+
from codepreproc_client.layer1_business.config import apply_env_overrides, get_home_dir
|
|
515
|
+
from codepreproc_client.layer1_business.local_registry import load_local_projects
|
|
516
|
+
from contracts.dtos import Registry as _Registry
|
|
517
|
+
new_data = apply_env_overrides(
|
|
518
|
+
_Registry(defaults=self.registry.registry.defaults, projects=load_local_projects(get_home_dir()))
|
|
519
|
+
)
|
|
520
|
+
self.registry.registry = new_data
|
|
521
|
+
|
|
522
|
+
project = self._resolve_project(session, payload.project)
|
|
523
|
+
# Drop any cached indexer so it is rebuilt with the refreshed project config
|
|
524
|
+
session.loaded_indexers.pop(project.project_id, None)
|
|
525
|
+
|
|
526
|
+
if monitor is not None:
|
|
527
|
+
await monitor.stage("health_check", f"reindex: verificando Qdrant para {project.project_id}")
|
|
528
|
+
try:
|
|
529
|
+
await asyncio.to_thread(self.qdrant_store.health_check)
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
raise ToolExecutionError(
|
|
532
|
+
"qdrant_unavailable",
|
|
533
|
+
f"Qdrant no responde en {self.qdrant_store._host}: {exc}",
|
|
534
|
+
details={"host": self.qdrant_store._host, "grpc_port": self.qdrant_store._grpc_port},
|
|
535
|
+
) from exc
|
|
536
|
+
indexer = self._get_indexer(session, project)
|
|
537
|
+
stats = await (indexer.full_reindex(monitor=monitor) if payload.full else indexer.incremental_reindex(monitor=monitor))
|
|
538
|
+
result = stats.model_dump(mode="json")
|
|
539
|
+
|
|
540
|
+
# Log indexing activity in SuperKG (fire-and-forget)
|
|
541
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
542
|
+
if superkg_system_id:
|
|
543
|
+
await self.superkg.log_activity(
|
|
544
|
+
project_id=project.project_id,
|
|
545
|
+
system_id=superkg_system_id,
|
|
546
|
+
activity_type="reindex",
|
|
547
|
+
details={
|
|
548
|
+
"mode": result.get("mode", "full" if payload.full else "incremental"),
|
|
549
|
+
"files_indexed": result.get("files_indexed", 0),
|
|
550
|
+
"chunks_created": result.get("chunks_created", 0),
|
|
551
|
+
"duration_ms": result.get("duration_ms", 0),
|
|
552
|
+
"project_id": project.project_id,
|
|
553
|
+
},
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
return result
|
|
557
|
+
|
|
558
|
+
async def project_status(self, session: SessionState, arguments: dict) -> dict:
|
|
559
|
+
payload = ProjectStatusInput.model_validate(arguments)
|
|
560
|
+
project = self._resolve_project(session, payload.project)
|
|
561
|
+
indexer = self._get_indexer(session, project)
|
|
562
|
+
repo = Repo(project.root)
|
|
563
|
+
current_sha = get_current_sha(repo)
|
|
564
|
+
status = indexer.get_index_status()
|
|
565
|
+
result: dict = {
|
|
566
|
+
"project": project.project_id,
|
|
567
|
+
"git_sha": current_sha,
|
|
568
|
+
"branch": get_current_branch(repo),
|
|
569
|
+
"dirty_files": len(get_dirty_files(repo)),
|
|
570
|
+
"last_indexed_sha": indexer.ast_graph.get_last_indexed_sha(),
|
|
571
|
+
"drift": indexer.ast_graph.get_last_indexed_sha() != current_sha,
|
|
572
|
+
"llm_policy": session.policy_overrides.get(project.project_id, project.llm_policy),
|
|
573
|
+
"index_state": status["index_state"],
|
|
574
|
+
"last_index_error": status.get("last_index_error"),
|
|
575
|
+
"superkg_system_id": project.superkg_system_id,
|
|
576
|
+
"kg_snapshot": None,
|
|
577
|
+
}
|
|
578
|
+
if project.superkg_system_id:
|
|
579
|
+
result["kg_snapshot"] = await self.superkg.get_snapshot(project.project_id, project.superkg_system_id)
|
|
580
|
+
return result
|
|
581
|
+
|
|
582
|
+
async def search_context(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
583
|
+
payload = SearchContextInput.model_validate(arguments)
|
|
584
|
+
project = self._resolve_project(session, payload.project)
|
|
585
|
+
indexer = self._get_indexer(session, project)
|
|
586
|
+
retriever = HybridRetriever(
|
|
587
|
+
self.embedder,
|
|
588
|
+
self.qdrant_store,
|
|
589
|
+
indexer.bm25_store,
|
|
590
|
+
self.settings.defaults.retrieval,
|
|
591
|
+
)
|
|
592
|
+
if monitor is not None:
|
|
593
|
+
await monitor.stage("retrieve", "search_context: recuperando chunks relevantes")
|
|
594
|
+
retrieved = await retriever.retrieve(
|
|
595
|
+
project,
|
|
596
|
+
payload.query,
|
|
597
|
+
overlay_collection=None,
|
|
598
|
+
collection_name=indexer.active_collection_name(),
|
|
599
|
+
user_collection_name=indexer.active_user_collection_name(),
|
|
600
|
+
monitor=monitor,
|
|
601
|
+
ast_graph=indexer.ast_graph,
|
|
602
|
+
)
|
|
603
|
+
if monitor is not None:
|
|
604
|
+
await monitor.stage("rerank", "search_context: reranking")
|
|
605
|
+
top_k = max(1, min(payload.top_k, 30))
|
|
606
|
+
reranked = await self.reranker.rerank(payload.query, retrieved, top_k)
|
|
607
|
+
|
|
608
|
+
enrich_result: EnrichResult | None = None
|
|
609
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
610
|
+
if superkg_system_id:
|
|
611
|
+
candidates = [
|
|
612
|
+
{"chunk_id": item.chunk.chunk_id, "file_path": item.chunk.file_path, "score": item.score}
|
|
613
|
+
for item in reranked
|
|
614
|
+
]
|
|
615
|
+
enrich_result = await self.superkg.enrich(project.project_id, superkg_system_id, payload.query, candidates)
|
|
616
|
+
|
|
617
|
+
kg_scores = enrich_result.kg_scores if enrich_result else {}
|
|
618
|
+
trace_id = enrich_result.trace_id if enrich_result else None
|
|
619
|
+
|
|
620
|
+
def _final_score(item) -> float:
|
|
621
|
+
kg = kg_scores.get(item.chunk.chunk_id)
|
|
622
|
+
if kg:
|
|
623
|
+
return item.score * 0.7 + kg.kg_score * 0.3
|
|
624
|
+
return item.score
|
|
625
|
+
|
|
626
|
+
if kg_scores:
|
|
627
|
+
reranked = sorted(reranked, key=_final_score, reverse=True)
|
|
628
|
+
|
|
629
|
+
snippets = []
|
|
630
|
+
for item in reranked:
|
|
631
|
+
chunk = item.chunk
|
|
632
|
+
kg = kg_scores.get(chunk.chunk_id)
|
|
633
|
+
entry: dict = {
|
|
634
|
+
"file_path": chunk.file_path,
|
|
635
|
+
"symbol": chunk.symbol,
|
|
636
|
+
"signature": chunk.signature or "",
|
|
637
|
+
"score": round(_final_score(item), 4),
|
|
638
|
+
"body": chunk.body,
|
|
639
|
+
}
|
|
640
|
+
if kg:
|
|
641
|
+
entry["kg_score"] = round(kg.kg_score, 4)
|
|
642
|
+
entry["kg_signals"] = kg.signals
|
|
643
|
+
snippets.append(entry)
|
|
644
|
+
|
|
645
|
+
result: dict = {
|
|
646
|
+
"query": payload.query,
|
|
647
|
+
"project": project.project_id,
|
|
648
|
+
"chunks": snippets,
|
|
649
|
+
"total": len(snippets),
|
|
650
|
+
}
|
|
651
|
+
if trace_id:
|
|
652
|
+
result["trace_id"] = trace_id
|
|
653
|
+
if enrich_result and enrich_result.pattern_suggestions:
|
|
654
|
+
result["pattern_suggestions"] = enrich_result.pattern_suggestions
|
|
655
|
+
if enrich_result and enrich_result.warnings:
|
|
656
|
+
result["kg_warnings"] = enrich_result.warnings
|
|
657
|
+
return result
|
|
658
|
+
|
|
659
|
+
async def map_context(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
660
|
+
payload = MapContextInput.model_validate(arguments)
|
|
661
|
+
project = self._resolve_project(session, payload.project)
|
|
662
|
+
indexer = self._get_indexer(session, project)
|
|
663
|
+
top_k = max(1, min(payload.top_k, 20))
|
|
664
|
+
active_collection = indexer.active_collection_name()
|
|
665
|
+
|
|
666
|
+
retriever = HybridRetriever(
|
|
667
|
+
self.embedder,
|
|
668
|
+
self.qdrant_store,
|
|
669
|
+
indexer.bm25_store,
|
|
670
|
+
self.settings.defaults.retrieval,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if monitor is not None:
|
|
674
|
+
await monitor.stage("retrieve", "map_context: recuperando chunks relevantes")
|
|
675
|
+
retrieved = await retriever.retrieve(
|
|
676
|
+
project,
|
|
677
|
+
payload.query,
|
|
678
|
+
overlay_collection=None,
|
|
679
|
+
collection_name=active_collection,
|
|
680
|
+
user_collection_name=indexer.active_user_collection_name(),
|
|
681
|
+
monitor=monitor,
|
|
682
|
+
ast_graph=indexer.ast_graph,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if monitor is not None:
|
|
686
|
+
await monitor.stage("rerank", "map_context: reranking")
|
|
687
|
+
reranked = await self.reranker.rerank(payload.query, retrieved, top_k)
|
|
688
|
+
|
|
689
|
+
enrich_result: EnrichResult | None = None
|
|
690
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
691
|
+
if superkg_system_id:
|
|
692
|
+
candidates = [
|
|
693
|
+
{"chunk_id": item.chunk.chunk_id, "file_path": item.chunk.file_path, "score": item.score}
|
|
694
|
+
for item in reranked
|
|
695
|
+
]
|
|
696
|
+
enrich_result = await self.superkg.enrich(project.project_id, superkg_system_id, payload.query, candidates)
|
|
697
|
+
|
|
698
|
+
if enrich_result and enrich_result.kg_scores:
|
|
699
|
+
kg_scores = enrich_result.kg_scores
|
|
700
|
+
|
|
701
|
+
def _merged(item) -> float:
|
|
702
|
+
kg = kg_scores.get(item.chunk.chunk_id)
|
|
703
|
+
return item.score * 0.7 + kg.kg_score * 0.3 if kg else item.score
|
|
704
|
+
|
|
705
|
+
reranked = sorted(reranked, key=_merged, reverse=True)
|
|
706
|
+
|
|
707
|
+
if monitor is not None:
|
|
708
|
+
await monitor.stage("graph_walk", "map_context: expandiendo grafo de dependencias")
|
|
709
|
+
seed_chunks = [item.chunk for item in reranked]
|
|
710
|
+
expanded = GraphWalker().expand(
|
|
711
|
+
seed_chunks,
|
|
712
|
+
indexer.ast_graph,
|
|
713
|
+
lambda ids: indexer.qdrant_store.get_chunks_by_ids(active_collection, ids) if active_collection else [],
|
|
714
|
+
depth=self.settings.defaults.retrieval.graph_walk_depth,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
seed_ids = {c.chunk_id for c in seed_chunks}
|
|
718
|
+
seed_scores = {item.chunk.chunk_id: item.score for item in reranked}
|
|
719
|
+
|
|
720
|
+
# Build dependency graph and per-chunk caller/callee info — batch SQL + single Qdrant fetch
|
|
721
|
+
seed_symbols = [c.symbol for c in seed_chunks]
|
|
722
|
+
seed_chunk_ids_list = [c.chunk_id for c in seed_chunks]
|
|
723
|
+
callers_by_symbol = indexer.ast_graph.find_callers_batch(seed_symbols)
|
|
724
|
+
callees_by_chunk = indexer.ast_graph.find_callees_batch(seed_chunk_ids_list)
|
|
725
|
+
|
|
726
|
+
all_resolve_ids = list(dict.fromkeys(
|
|
727
|
+
cid
|
|
728
|
+
for ids in (*callees_by_chunk.values(), *callers_by_symbol.values())
|
|
729
|
+
for cid in ids
|
|
730
|
+
))
|
|
731
|
+
id_to_symbol: dict[str, str] = {}
|
|
732
|
+
if all_resolve_ids and active_collection:
|
|
733
|
+
fetched = indexer.qdrant_store.get_chunks_by_ids(active_collection, all_resolve_ids)
|
|
734
|
+
id_to_symbol = {c.chunk_id: c.symbol for c in fetched}
|
|
735
|
+
|
|
736
|
+
dependency_graph: dict[str, list[str]] = {}
|
|
737
|
+
chunk_callers: dict[str, list[str]] = {}
|
|
738
|
+
chunk_callees: dict[str, list[str]] = {}
|
|
739
|
+
for chunk in seed_chunks:
|
|
740
|
+
callee_ids = callees_by_chunk.get(chunk.chunk_id, [])
|
|
741
|
+
callee_symbols = [id_to_symbol[cid] for cid in callee_ids if cid in id_to_symbol]
|
|
742
|
+
caller_ids = callers_by_symbol.get(chunk.symbol, [])
|
|
743
|
+
caller_symbols = [id_to_symbol[cid] for cid in caller_ids if cid in id_to_symbol]
|
|
744
|
+
dependency_graph[chunk.symbol] = callee_symbols
|
|
745
|
+
chunk_callers[chunk.chunk_id] = caller_symbols
|
|
746
|
+
chunk_callees[chunk.chunk_id] = callee_symbols
|
|
747
|
+
|
|
748
|
+
# Build file index
|
|
749
|
+
file_index: dict[str, list[str]] = defaultdict(list)
|
|
750
|
+
seen_ids: set[str] = set()
|
|
751
|
+
all_chunks = []
|
|
752
|
+
for chunk in seed_chunks + expanded:
|
|
753
|
+
if chunk.chunk_id not in seen_ids:
|
|
754
|
+
seen_ids.add(chunk.chunk_id)
|
|
755
|
+
all_chunks.append(chunk)
|
|
756
|
+
if chunk.symbol not in file_index[chunk.file_path]:
|
|
757
|
+
file_index[chunk.file_path].append(chunk.symbol)
|
|
758
|
+
|
|
759
|
+
# Assemble component list
|
|
760
|
+
components = []
|
|
761
|
+
for chunk in all_chunks:
|
|
762
|
+
is_seed = chunk.chunk_id in seed_ids
|
|
763
|
+
entry: dict = {
|
|
764
|
+
"file_path": chunk.file_path,
|
|
765
|
+
"symbol": chunk.symbol,
|
|
766
|
+
"kind": chunk.kind,
|
|
767
|
+
"signature": chunk.signature or "",
|
|
768
|
+
"start_line": chunk.start_line,
|
|
769
|
+
"end_line": chunk.end_line,
|
|
770
|
+
"relevance_score": round(seed_scores[chunk.chunk_id], 4) if is_seed else None,
|
|
771
|
+
"calls": chunk_callees.get(chunk.chunk_id, []),
|
|
772
|
+
"called_by": chunk_callers.get(chunk.chunk_id, []),
|
|
773
|
+
}
|
|
774
|
+
if payload.include_bodies and is_seed:
|
|
775
|
+
entry["body"] = chunk.body
|
|
776
|
+
components.append(entry)
|
|
777
|
+
|
|
778
|
+
# Optional LLM synthesis (always local, never remote)
|
|
779
|
+
summary = ""
|
|
780
|
+
if payload.synthesize:
|
|
781
|
+
if monitor is not None:
|
|
782
|
+
await monitor.stage("synthesize", "map_context: sintetizando mapa arquitectónico")
|
|
783
|
+
context_lines = []
|
|
784
|
+
for chunk in seed_chunks:
|
|
785
|
+
sig = chunk.signature or chunk.symbol
|
|
786
|
+
callee_list = ", ".join(chunk_callees.get(chunk.chunk_id, [])) or "none"
|
|
787
|
+
caller_list = ", ".join(chunk_callers.get(chunk.chunk_id, [])) or "none"
|
|
788
|
+
context_lines.append(
|
|
789
|
+
f"{chunk.file_path}::{chunk.symbol} ({chunk.kind})\n"
|
|
790
|
+
f" signature: {sig}\n"
|
|
791
|
+
f" calls: {callee_list}\n"
|
|
792
|
+
f" called_by: {caller_list}\n"
|
|
793
|
+
f" body_preview: {chunk.body[:400]}"
|
|
794
|
+
)
|
|
795
|
+
context_text = "\n\n".join(context_lines)
|
|
796
|
+
try:
|
|
797
|
+
result, _ = await self.llm_runtime.router.chat(
|
|
798
|
+
task_type="context_map",
|
|
799
|
+
policy="local_only",
|
|
800
|
+
messages=[
|
|
801
|
+
{"role": "system", "content": _CONTEXT_MAP_SYSTEM_PROMPT},
|
|
802
|
+
{"role": "user", "content": f"Query: {payload.query}\n\nComponents:\n{context_text}"},
|
|
803
|
+
],
|
|
804
|
+
project_id=project.project_id,
|
|
805
|
+
task_id=uuid.uuid4().hex,
|
|
806
|
+
)
|
|
807
|
+
summary = result.strip()
|
|
808
|
+
except Exception:
|
|
809
|
+
summary = ""
|
|
810
|
+
|
|
811
|
+
map_result: dict = {
|
|
812
|
+
"query": payload.query,
|
|
813
|
+
"project": project.project_id,
|
|
814
|
+
"summary": summary,
|
|
815
|
+
"components": components,
|
|
816
|
+
"dependency_graph": dependency_graph,
|
|
817
|
+
"file_index": dict(file_index),
|
|
818
|
+
"total_components": len(components),
|
|
819
|
+
}
|
|
820
|
+
if enrich_result:
|
|
821
|
+
if enrich_result.trace_id:
|
|
822
|
+
map_result["trace_id"] = enrich_result.trace_id
|
|
823
|
+
if enrich_result.pattern_suggestions:
|
|
824
|
+
map_result["pattern_suggestions"] = enrich_result.pattern_suggestions
|
|
825
|
+
if enrich_result.warnings:
|
|
826
|
+
map_result["kg_warnings"] = enrich_result.warnings
|
|
827
|
+
return map_result
|
|
828
|
+
|
|
829
|
+
async def generate_document(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
830
|
+
payload = GenerateDocumentInput.model_validate(arguments)
|
|
831
|
+
project = self._resolve_project(session, payload.project)
|
|
832
|
+
indexer = self._get_indexer(session, project)
|
|
833
|
+
effective_policy = session.policy_overrides.get(project.project_id, project.llm_policy)
|
|
834
|
+
task_id = uuid.uuid4().hex
|
|
835
|
+
|
|
836
|
+
retriever = HybridRetriever(
|
|
837
|
+
self.embedder,
|
|
838
|
+
self.qdrant_store,
|
|
839
|
+
indexer.bm25_store,
|
|
840
|
+
self.settings.defaults.retrieval,
|
|
841
|
+
)
|
|
842
|
+
active_collection = indexer.active_collection_name()
|
|
843
|
+
|
|
844
|
+
if monitor is not None:
|
|
845
|
+
await monitor.stage("retrieve", "generate_document: recuperando chunks relevantes")
|
|
846
|
+
retrieved = await retriever.retrieve(
|
|
847
|
+
project,
|
|
848
|
+
payload.prompt,
|
|
849
|
+
overlay_collection=None,
|
|
850
|
+
collection_name=active_collection,
|
|
851
|
+
user_collection_name=indexer.active_user_collection_name(),
|
|
852
|
+
monitor=monitor,
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
if monitor is not None:
|
|
856
|
+
await monitor.stage("rerank", "generate_document: reranking")
|
|
857
|
+
top_k = max(1, min(payload.top_k, 30))
|
|
858
|
+
reranked = await self.reranker.rerank(payload.prompt, retrieved, top_k)
|
|
859
|
+
|
|
860
|
+
if monitor is not None:
|
|
861
|
+
await monitor.stage("graph_walk", "generate_document: expandiendo grafo de dependencias")
|
|
862
|
+
seed_chunks = [item.chunk for item in reranked]
|
|
863
|
+
expanded = GraphWalker().expand(
|
|
864
|
+
seed_chunks,
|
|
865
|
+
indexer.ast_graph,
|
|
866
|
+
lambda ids: indexer.qdrant_store.get_chunks_by_ids(active_collection, ids) if active_collection else [],
|
|
867
|
+
depth=self.settings.defaults.retrieval.graph_walk_depth,
|
|
868
|
+
)
|
|
869
|
+
seen: set[str] = set()
|
|
870
|
+
all_chunks = []
|
|
871
|
+
for chunk in seed_chunks + expanded:
|
|
872
|
+
if chunk.chunk_id not in seen:
|
|
873
|
+
seen.add(chunk.chunk_id)
|
|
874
|
+
all_chunks.append(chunk)
|
|
875
|
+
|
|
876
|
+
context_text = self._format_doc_context(all_chunks)
|
|
877
|
+
|
|
878
|
+
# Enrich with SuperKG narrative context if available (fire-and-forget)
|
|
879
|
+
kg_narrative = ""
|
|
880
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
881
|
+
if superkg_system_id:
|
|
882
|
+
kg_data = await self.superkg.get_context(project.project_id, superkg_system_id, mode="narrative")
|
|
883
|
+
if isinstance(kg_data, dict):
|
|
884
|
+
kg_narrative = kg_data.get("content") or kg_data.get("narrative") or kg_data.get("text") or ""
|
|
885
|
+
if not kg_narrative and "context" in kg_data:
|
|
886
|
+
kg_narrative = str(kg_data["context"])
|
|
887
|
+
|
|
888
|
+
if monitor is not None:
|
|
889
|
+
await monitor.stage("generate", "generate_document: generando contenido con LLM")
|
|
890
|
+
|
|
891
|
+
kg_section = f"\n\nSuperKG project context:\n{kg_narrative}" if kg_narrative else ""
|
|
892
|
+
result, usage = await self.llm_runtime.router.chat(
|
|
893
|
+
task_type="document_generation",
|
|
894
|
+
policy=effective_policy, # type: ignore[arg-type]
|
|
895
|
+
messages=[
|
|
896
|
+
{"role": "system", "content": _DOC_GENERATION_SYSTEM_PROMPT},
|
|
897
|
+
{
|
|
898
|
+
"role": "user",
|
|
899
|
+
"content": (
|
|
900
|
+
f"Request: {payload.prompt}\n\n"
|
|
901
|
+
f"Output file: {payload.output_path}\n\n"
|
|
902
|
+
f"Code context ({len(all_chunks)} chunks from {len({c.file_path for c in all_chunks})} files):\n\n"
|
|
903
|
+
f"{context_text}{kg_section}"
|
|
904
|
+
),
|
|
905
|
+
},
|
|
906
|
+
],
|
|
907
|
+
schema=None,
|
|
908
|
+
max_tokens=6000,
|
|
909
|
+
temperature=0.2,
|
|
910
|
+
project_id=project.project_id,
|
|
911
|
+
task_id=task_id,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
content: str = result["content"] if isinstance(result, dict) else str(result)
|
|
915
|
+
|
|
916
|
+
output_path = Path(payload.output_path)
|
|
917
|
+
if output_path.is_absolute():
|
|
918
|
+
full_path = output_path
|
|
919
|
+
else:
|
|
920
|
+
full_path = project.root / output_path
|
|
921
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
922
|
+
full_path.write_text(content, encoding="utf-8")
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
"success": True,
|
|
926
|
+
"file_path": str(full_path),
|
|
927
|
+
"bytes_written": len(content.encode("utf-8")),
|
|
928
|
+
"chunks_used": len(all_chunks),
|
|
929
|
+
"llm_usage": usage.model_dump(mode="json"),
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async def generate_business_logic(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
933
|
+
payload = GenerateBusinessLogicInput.model_validate(arguments)
|
|
934
|
+
project = self._resolve_project(session, payload.project)
|
|
935
|
+
indexer = self._get_indexer(session, project)
|
|
936
|
+
effective_policy = session.policy_overrides.get(project.project_id, project.llm_policy)
|
|
937
|
+
task_id = uuid.uuid4().hex
|
|
938
|
+
|
|
939
|
+
retriever = HybridRetriever(
|
|
940
|
+
self.embedder,
|
|
941
|
+
self.qdrant_store,
|
|
942
|
+
indexer.bm25_store,
|
|
943
|
+
self.settings.defaults.retrieval,
|
|
944
|
+
)
|
|
945
|
+
active_collection = indexer.active_collection_name()
|
|
946
|
+
|
|
947
|
+
if monitor is not None:
|
|
948
|
+
await monitor.stage("retrieve", "generate_business_logic: recuperando chunks de lógica de negocio")
|
|
949
|
+
|
|
950
|
+
seen: set[str] = set()
|
|
951
|
+
all_chunks = []
|
|
952
|
+
top_k_per_query = max(5, payload.top_k // len(_BUSINESS_LOGIC_QUERIES))
|
|
953
|
+
for query in _BUSINESS_LOGIC_QUERIES:
|
|
954
|
+
retrieved = await retriever.retrieve(
|
|
955
|
+
project,
|
|
956
|
+
query,
|
|
957
|
+
overlay_collection=None,
|
|
958
|
+
collection_name=active_collection,
|
|
959
|
+
user_collection_name=indexer.active_user_collection_name(),
|
|
960
|
+
monitor=None,
|
|
961
|
+
)
|
|
962
|
+
reranked = await self.reranker.rerank(query, retrieved, top_k_per_query)
|
|
963
|
+
for item in reranked:
|
|
964
|
+
if item.chunk.chunk_id not in seen:
|
|
965
|
+
seen.add(item.chunk.chunk_id)
|
|
966
|
+
all_chunks.append(item.chunk)
|
|
967
|
+
|
|
968
|
+
if monitor is not None:
|
|
969
|
+
await monitor.stage("graph_walk", "generate_business_logic: expandiendo grafo")
|
|
970
|
+
expanded = GraphWalker().expand(
|
|
971
|
+
all_chunks,
|
|
972
|
+
indexer.ast_graph,
|
|
973
|
+
lambda ids: indexer.qdrant_store.get_chunks_by_ids(active_collection, ids) if active_collection else [],
|
|
974
|
+
depth=self.settings.defaults.retrieval.graph_walk_depth,
|
|
975
|
+
)
|
|
976
|
+
for chunk in expanded:
|
|
977
|
+
if chunk.chunk_id not in seen:
|
|
978
|
+
seen.add(chunk.chunk_id)
|
|
979
|
+
all_chunks.append(chunk)
|
|
980
|
+
|
|
981
|
+
context_text = self._format_doc_context(all_chunks)
|
|
982
|
+
|
|
983
|
+
kg_narrative = ""
|
|
984
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
985
|
+
if superkg_system_id:
|
|
986
|
+
kg_data = await self.superkg.get_context(project.project_id, superkg_system_id, mode="narrative")
|
|
987
|
+
if isinstance(kg_data, dict):
|
|
988
|
+
kg_narrative = kg_data.get("content") or kg_data.get("narrative") or kg_data.get("text") or ""
|
|
989
|
+
if not kg_narrative and "context" in kg_data:
|
|
990
|
+
kg_narrative = str(kg_data["context"])
|
|
991
|
+
|
|
992
|
+
if monitor is not None:
|
|
993
|
+
await monitor.stage("generate", "generate_business_logic: sintetizando lógica de negocio con LLM")
|
|
994
|
+
|
|
995
|
+
kg_section = f"\n\nSuperKG project context:\n{kg_narrative}" if kg_narrative else ""
|
|
996
|
+
result, usage = await self.llm_runtime.router.chat(
|
|
997
|
+
task_type="document_generation",
|
|
998
|
+
policy=effective_policy, # type: ignore[arg-type]
|
|
999
|
+
messages=[
|
|
1000
|
+
{"role": "system", "content": _BUSINESS_LOGIC_SYSTEM_PROMPT},
|
|
1001
|
+
{
|
|
1002
|
+
"role": "user",
|
|
1003
|
+
"content": (
|
|
1004
|
+
f"Repository: {project.root}\n\n"
|
|
1005
|
+
f"Code context ({len(all_chunks)} chunks from {len({c.file_path for c in all_chunks})} files):\n\n"
|
|
1006
|
+
f"{context_text}{kg_section}"
|
|
1007
|
+
),
|
|
1008
|
+
},
|
|
1009
|
+
],
|
|
1010
|
+
schema=None,
|
|
1011
|
+
max_tokens=8000,
|
|
1012
|
+
temperature=0.1,
|
|
1013
|
+
project_id=project.project_id,
|
|
1014
|
+
task_id=task_id,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
content: str = result["content"] if isinstance(result, dict) else str(result)
|
|
1018
|
+
|
|
1019
|
+
output_path = Path(payload.output_path)
|
|
1020
|
+
full_path = output_path if output_path.is_absolute() else project.root / output_path
|
|
1021
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1022
|
+
full_path.write_text(content, encoding="utf-8")
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
"success": True,
|
|
1026
|
+
"file_path": str(full_path),
|
|
1027
|
+
"bytes_written": len(content.encode("utf-8")),
|
|
1028
|
+
"chunks_used": len(all_chunks),
|
|
1029
|
+
"overwritten": full_path.exists(),
|
|
1030
|
+
"llm_usage": usage.model_dump(mode="json"),
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async def list_snippets(self, session: SessionState, arguments: dict) -> dict:
|
|
1034
|
+
payload = ListSnippetsInput.model_validate(arguments)
|
|
1035
|
+
snippets = self._snippet_loader.list_snippets(
|
|
1036
|
+
framework=payload.framework,
|
|
1037
|
+
language=payload.language,
|
|
1038
|
+
layer=payload.layer,
|
|
1039
|
+
)
|
|
1040
|
+
return {
|
|
1041
|
+
"snippets": [
|
|
1042
|
+
{
|
|
1043
|
+
"id": s.id,
|
|
1044
|
+
"framework": s.framework,
|
|
1045
|
+
"language": s.language,
|
|
1046
|
+
"layer": s.layer,
|
|
1047
|
+
"tags": s.tags,
|
|
1048
|
+
"produces": s.produces,
|
|
1049
|
+
"requires": s.requires,
|
|
1050
|
+
}
|
|
1051
|
+
for s in snippets
|
|
1052
|
+
],
|
|
1053
|
+
"total": len(snippets),
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async def assemble_from_snippets(
|
|
1057
|
+
self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None
|
|
1058
|
+
) -> dict:
|
|
1059
|
+
payload = AssembleFromSnippetsInput.model_validate(arguments)
|
|
1060
|
+
project = self._resolve_project(session, payload.project)
|
|
1061
|
+
effective_policy = session.policy_overrides.get(project.project_id, project.llm_policy)
|
|
1062
|
+
task_id = uuid.uuid4().hex
|
|
1063
|
+
|
|
1064
|
+
assembler = SnippetAssembler(
|
|
1065
|
+
loader=self._snippet_loader,
|
|
1066
|
+
router=self.llm_runtime.router,
|
|
1067
|
+
project=project,
|
|
1068
|
+
logger=self.logger,
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
if monitor is not None:
|
|
1072
|
+
await monitor.stage("intent", "assemble_from_snippets: resolviendo intent")
|
|
1073
|
+
pack = await assembler.assemble(
|
|
1074
|
+
user_prompt=payload.prompt,
|
|
1075
|
+
task_id=task_id,
|
|
1076
|
+
policy=effective_policy, # type: ignore[arg-type]
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
stored = StoredAssemblyTask(
|
|
1080
|
+
pack=pack,
|
|
1081
|
+
user_prompt=payload.prompt,
|
|
1082
|
+
project_id=project.project_id,
|
|
1083
|
+
effective_policy=effective_policy, # type: ignore[arg-type]
|
|
1084
|
+
expires_at=datetime.now(timezone.utc).replace(microsecond=0) + timedelta(hours=2),
|
|
1085
|
+
)
|
|
1086
|
+
session.assembly_tasks[task_id] = stored
|
|
1087
|
+
|
|
1088
|
+
result: dict = {
|
|
1089
|
+
"success": pack.success,
|
|
1090
|
+
"task_id": task_id,
|
|
1091
|
+
"project": project.project_id,
|
|
1092
|
+
}
|
|
1093
|
+
if pack.intent:
|
|
1094
|
+
result["intent"] = pack.intent.model_dump()
|
|
1095
|
+
if pack.selection:
|
|
1096
|
+
result["selected_snippets"] = pack.selection.selected_snippet_ids
|
|
1097
|
+
if pack.variables:
|
|
1098
|
+
result["variables"] = pack.variables.model_dump(exclude={"extra"})
|
|
1099
|
+
if pack.result:
|
|
1100
|
+
result["files_created"] = pack.result.files_created
|
|
1101
|
+
result["files_modified"] = pack.result.files_modified
|
|
1102
|
+
result["integration_notes"] = pack.result.integration_notes
|
|
1103
|
+
if pack.failure:
|
|
1104
|
+
result["failure"] = pack.failure.model_dump()
|
|
1105
|
+
result["llm_usage"] = [u.model_dump(mode="json") for u in pack.llm_usage]
|
|
1106
|
+
return result
|
|
1107
|
+
|
|
1108
|
+
@staticmethod
|
|
1109
|
+
def _format_doc_context(all_chunks: list) -> str:
|
|
1110
|
+
"""Group chunks by package/module and extract invariants for richer LLM context."""
|
|
1111
|
+
# Group by top-level package or module (first 2 path segments)
|
|
1112
|
+
groups: dict[str, list] = defaultdict(list)
|
|
1113
|
+
for chunk in all_chunks[:32]:
|
|
1114
|
+
parts = Path(chunk.file_path).parts
|
|
1115
|
+
group_key = "/".join(parts[:2]) if len(parts) >= 2 else parts[0]
|
|
1116
|
+
groups[group_key].append(chunk)
|
|
1117
|
+
|
|
1118
|
+
# Extract invariants: constants, thresholds, exports, state values
|
|
1119
|
+
_INVARIANT_RE = re.compile(
|
|
1120
|
+
r"^\s*(?:"
|
|
1121
|
+
r"(?:export\s+(?:const|function|class|type|interface|enum)\s+\w+)" # TS exports
|
|
1122
|
+
r"|(?:[A-Z][A-Z0-9_]{2,}\s*=\s*.+)" # UPPERCASE_CONSTANTS
|
|
1123
|
+
r"|(?:.*=\s*0\.\d{2,})" # score thresholds
|
|
1124
|
+
r"|(?:status[:\s]+['\"]?\w+['\"]?)" # status values
|
|
1125
|
+
r")",
|
|
1126
|
+
re.MULTILINE,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
sections: list[str] = []
|
|
1130
|
+
for group_key in sorted(groups):
|
|
1131
|
+
chunks = groups[group_key]
|
|
1132
|
+
section_lines = [f"## `{group_key}`\n"]
|
|
1133
|
+
|
|
1134
|
+
invariants: list[str] = []
|
|
1135
|
+
for chunk in chunks:
|
|
1136
|
+
for line in chunk.body.splitlines():
|
|
1137
|
+
if _INVARIANT_RE.match(line):
|
|
1138
|
+
stripped = line.strip()
|
|
1139
|
+
if stripped and stripped not in invariants:
|
|
1140
|
+
invariants.append(stripped)
|
|
1141
|
+
|
|
1142
|
+
if invariants:
|
|
1143
|
+
section_lines.append("**Exports / constants / thresholds:**")
|
|
1144
|
+
section_lines.extend(f" - `{inv}`" for inv in invariants[:20])
|
|
1145
|
+
section_lines.append("")
|
|
1146
|
+
|
|
1147
|
+
for chunk in chunks:
|
|
1148
|
+
header = f"### `{chunk.symbol}` — `{chunk.file_path}`"
|
|
1149
|
+
if chunk.signature:
|
|
1150
|
+
header += f"\n*{chunk.signature}*"
|
|
1151
|
+
body_preview = "\n".join(chunk.body.splitlines()[:60])
|
|
1152
|
+
section_lines.append(f"{header}\n```\n{body_preview}\n```")
|
|
1153
|
+
|
|
1154
|
+
sections.append("\n".join(section_lines))
|
|
1155
|
+
|
|
1156
|
+
return "\n\n---\n\n".join(sections)
|
|
1157
|
+
|
|
1158
|
+
async def init_project(self, session: SessionState, arguments: dict) -> dict:
|
|
1159
|
+
payload = InitProjectInput.model_validate(arguments)
|
|
1160
|
+
|
|
1161
|
+
if payload.path:
|
|
1162
|
+
target = Path(payload.path)
|
|
1163
|
+
elif session.roots:
|
|
1164
|
+
target = session.roots[0]
|
|
1165
|
+
else:
|
|
1166
|
+
return {"success": False, "message": "No path provided and no session root available."}
|
|
1167
|
+
|
|
1168
|
+
from codepreproc_client.layer1_business.cli.init import init_project as _cli_init
|
|
1169
|
+
from codepreproc_client.layer1_business.config import apply_env_overrides, get_home_dir
|
|
1170
|
+
from codepreproc_client.layer1_business.local_registry import load_local_projects
|
|
1171
|
+
from contracts.dtos import Registry as _Registry
|
|
1172
|
+
|
|
1173
|
+
# verbose=False avoids any print() calls that would corrupt MCP stdio
|
|
1174
|
+
exit_code = await asyncio.to_thread(_cli_init, path=target, verbose=False)
|
|
1175
|
+
|
|
1176
|
+
if exit_code != 0:
|
|
1177
|
+
return {"success": False, "message": f"Failed to register project at '{target}'. Check that the path exists and is accessible."}
|
|
1178
|
+
|
|
1179
|
+
# Hot-reload the local registry mirror so the new project is immediately
|
|
1180
|
+
# available (cli.init.init_project already registered it server-side).
|
|
1181
|
+
new_data = apply_env_overrides(
|
|
1182
|
+
_Registry(defaults=self.registry.registry.defaults, projects=load_local_projects(get_home_dir()))
|
|
1183
|
+
)
|
|
1184
|
+
self.registry.registry = new_data
|
|
1185
|
+
|
|
1186
|
+
target_resolved = str(target.resolve())
|
|
1187
|
+
for pid, pconf in new_data.projects.items():
|
|
1188
|
+
if str(pconf.root) == target_resolved:
|
|
1189
|
+
return {
|
|
1190
|
+
"success": True,
|
|
1191
|
+
"project_id": pid,
|
|
1192
|
+
"root": str(pconf.root),
|
|
1193
|
+
"languages": pconf.languages,
|
|
1194
|
+
"qdrant_collection": pconf.qdrant_collection,
|
|
1195
|
+
"message": f"Project '{pid}' registered. Run reindex to build the index.",
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return {"success": True, "project_id": None, "message": "Project was already registered. No changes made."}
|
|
1199
|
+
|
|
1200
|
+
async def generate_repo_map(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
|
|
1201
|
+
payload = GenerateRepoMapInput.model_validate(arguments)
|
|
1202
|
+
project = self._resolve_project(session, payload.project)
|
|
1203
|
+
indexer = self._get_indexer(session, project)
|
|
1204
|
+
|
|
1205
|
+
if monitor is not None:
|
|
1206
|
+
await monitor.stage("build_map", "generate_repo_map: construyendo mapa del repositorio")
|
|
1207
|
+
|
|
1208
|
+
db_path = indexer.ast_graph.db_path
|
|
1209
|
+
mapper = RepoMapper(project, db_path)
|
|
1210
|
+
|
|
1211
|
+
try:
|
|
1212
|
+
repo_map = await asyncio.to_thread(
|
|
1213
|
+
mapper.build,
|
|
1214
|
+
artifacts_folder=payload.artifacts_folder,
|
|
1215
|
+
modules_folder=payload.modules_folder,
|
|
1216
|
+
)
|
|
1217
|
+
except NeedsMonorepoConfig as exc:
|
|
1218
|
+
return {
|
|
1219
|
+
"success": False,
|
|
1220
|
+
"needs_clarification": True,
|
|
1221
|
+
"project_id": project.project_id,
|
|
1222
|
+
"questions": [
|
|
1223
|
+
{
|
|
1224
|
+
"param": "artifacts_folder",
|
|
1225
|
+
"question": (
|
|
1226
|
+
"Which top-level folder contains the deployable artifacts (apps)? "
|
|
1227
|
+
f"Available root directories: {exc.root_dirs}"
|
|
1228
|
+
),
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
"param": "modules_folder",
|
|
1232
|
+
"question": (
|
|
1233
|
+
"Which top-level folder contains the reusable shared modules (packages)? "
|
|
1234
|
+
f"Available root directories: {exc.root_dirs}"
|
|
1235
|
+
),
|
|
1236
|
+
},
|
|
1237
|
+
],
|
|
1238
|
+
"hint": (
|
|
1239
|
+
"Re-invoke generate_repo_map with artifacts_folder and modules_folder set "
|
|
1240
|
+
"to the correct directory names."
|
|
1241
|
+
),
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if monitor is not None:
|
|
1245
|
+
await monitor.stage("annotate", "generate_repo_map: enriqueciendo con KG y LLM")
|
|
1246
|
+
|
|
1247
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
1248
|
+
policy = session.policy_overrides.get(project.project_id, project.llm_policy)
|
|
1249
|
+
annotator = RepoMapAnnotator(
|
|
1250
|
+
router=self.llm_runtime.router,
|
|
1251
|
+
superkg=self.superkg,
|
|
1252
|
+
cache_path=db_path.parent / "repo_map_cache.json",
|
|
1253
|
+
)
|
|
1254
|
+
final_map = await annotator.annotate(
|
|
1255
|
+
raw_map=repo_map,
|
|
1256
|
+
policy=policy,
|
|
1257
|
+
project_id=project.project_id,
|
|
1258
|
+
project=project,
|
|
1259
|
+
superkg_system_id=superkg_system_id,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
if monitor is not None:
|
|
1263
|
+
await monitor.stage("write_json", "generate_repo_map: escribiendo repo_map.json")
|
|
1264
|
+
|
|
1265
|
+
output_path = project.root / "repo_map.json"
|
|
1266
|
+
output_path.write_text(json.dumps(final_map, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
1267
|
+
|
|
1268
|
+
if superkg_system_id:
|
|
1269
|
+
await self.superkg.ingest_repo_map(
|
|
1270
|
+
project_id=project.project_id,
|
|
1271
|
+
system_id=superkg_system_id,
|
|
1272
|
+
repo_map=final_map,
|
|
1273
|
+
output_path=str(output_path),
|
|
1274
|
+
)
|
|
1275
|
+
await self.superkg.log_activity(
|
|
1276
|
+
project_id=project.project_id,
|
|
1277
|
+
system_id=superkg_system_id,
|
|
1278
|
+
activity_type="repo_map_generated",
|
|
1279
|
+
details={
|
|
1280
|
+
"project_id": project.project_id,
|
|
1281
|
+
"output_path": str(output_path),
|
|
1282
|
+
**final_map["stats"],
|
|
1283
|
+
},
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
return {
|
|
1287
|
+
"success": True,
|
|
1288
|
+
"project_id": project.project_id,
|
|
1289
|
+
"output_path": str(output_path),
|
|
1290
|
+
"stats": final_map["stats"],
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async def kg_context(self, session: SessionState, arguments: dict) -> dict:
|
|
1294
|
+
payload = KGContextInput.model_validate(arguments)
|
|
1295
|
+
project = self._resolve_project(session, payload.project)
|
|
1296
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
1297
|
+
if not superkg_system_id:
|
|
1298
|
+
return {"error": "project_not_registered_in_superkg", "project": project.project_id}
|
|
1299
|
+
data = await self.superkg.get_context(project.project_id, superkg_system_id, mode=payload.mode)
|
|
1300
|
+
return {
|
|
1301
|
+
"project": project.project_id,
|
|
1302
|
+
"system_id": superkg_system_id,
|
|
1303
|
+
"mode": payload.mode,
|
|
1304
|
+
"context": data,
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async def kg_insights(self, session: SessionState, arguments: dict) -> dict:
|
|
1308
|
+
payload = KGInsightsInput.model_validate(arguments)
|
|
1309
|
+
project = self._resolve_project(session, payload.project)
|
|
1310
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
1311
|
+
if not superkg_system_id:
|
|
1312
|
+
return {"error": "project_not_registered_in_superkg", "project": project.project_id, "insights": []}
|
|
1313
|
+
items = await self.superkg.get_insights(project.project_id, superkg_system_id)
|
|
1314
|
+
return {
|
|
1315
|
+
"project": project.project_id,
|
|
1316
|
+
"system_id": superkg_system_id,
|
|
1317
|
+
"insights": items,
|
|
1318
|
+
"total": len(items),
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async def kg_patterns(self, session: SessionState, arguments: dict) -> dict:
|
|
1322
|
+
# Patterns aren't really project-scoped (SuperKG observes them across all
|
|
1323
|
+
# systems), but the /v1/superkg/query proxy authorizes every call against
|
|
1324
|
+
# one owned project+system_id - callers must have at least one active,
|
|
1325
|
+
# SuperKG-registered project to see cross-project patterns at all.
|
|
1326
|
+
project = self._resolve_project(session, None)
|
|
1327
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
1328
|
+
if not superkg_system_id:
|
|
1329
|
+
return {"patterns": [], "total": 0}
|
|
1330
|
+
items = await self.superkg.get_patterns(project.project_id, superkg_system_id)
|
|
1331
|
+
return {"patterns": items, "total": len(items)}
|
|
1332
|
+
|
|
1333
|
+
async def correct_graph(self, session: SessionState, arguments: dict) -> dict:
|
|
1334
|
+
payload = CorrectGraphInput.model_validate(arguments)
|
|
1335
|
+
project = self._resolve_project(session, payload.project)
|
|
1336
|
+
superkg_system_id = await self._ensure_superkg_system(project)
|
|
1337
|
+
if not superkg_system_id:
|
|
1338
|
+
return {"recorded": False, "error": "project_not_registered_in_superkg"}
|
|
1339
|
+
bld = BusinessLogicDiff(
|
|
1340
|
+
change=payload.change,
|
|
1341
|
+
before=payload.before,
|
|
1342
|
+
after=payload.after,
|
|
1343
|
+
rules_added=payload.rules_added,
|
|
1344
|
+
rules_removed=payload.rules_removed,
|
|
1345
|
+
rules_modified=payload.rules_modified,
|
|
1346
|
+
)
|
|
1347
|
+
await self.superkg.record_decision(
|
|
1348
|
+
project_id=project.project_id,
|
|
1349
|
+
system_id=superkg_system_id,
|
|
1350
|
+
session_id=None,
|
|
1351
|
+
intent=f"graph_correction: {payload.change}",
|
|
1352
|
+
plan_summary={"source": "human_correction"},
|
|
1353
|
+
bld=bld,
|
|
1354
|
+
)
|
|
1355
|
+
return {"recorded": True, "system_id": superkg_system_id, "change": payload.change}
|
|
1356
|
+
|
|
1357
|
+
async def project_pseudocode(self, session: SessionState, arguments: dict) -> dict:
|
|
1358
|
+
import fnmatch
|
|
1359
|
+
|
|
1360
|
+
from codepreproc_client.layer1_business.git_utils import get_current_sha
|
|
1361
|
+
from codepreproc_client.layer2_tooling.condensation.ppl.emitter import PPLEmitter
|
|
1362
|
+
from git import Repo
|
|
1363
|
+
|
|
1364
|
+
payload = ProjectPseudocodeInput.model_validate(arguments)
|
|
1365
|
+
project = self._resolve_project(session, payload.project)
|
|
1366
|
+
indexer = self._get_indexer(session, project)
|
|
1367
|
+
|
|
1368
|
+
rows = indexer.ast_graph.get_all_symbols()
|
|
1369
|
+
|
|
1370
|
+
if payload.file_pattern:
|
|
1371
|
+
pat = payload.file_pattern
|
|
1372
|
+
rows = [r for r in rows if fnmatch.fnmatch(r[2].replace("\\", "/"), pat)]
|
|
1373
|
+
if payload.exclude_tests:
|
|
1374
|
+
_TEST_MARKERS = ("/test/", "/tests/", "\\test\\", "\\tests\\", "_test.py", "test_.py")
|
|
1375
|
+
rows = [r for r in rows if not any(m in r[2] for m in _TEST_MARKERS)]
|
|
1376
|
+
|
|
1377
|
+
by_file: dict[str, list[tuple[str, str, str, str | None]]] = defaultdict(list)
|
|
1378
|
+
for chunk_id, symbol, file_path, kind, signature in rows:
|
|
1379
|
+
by_file[file_path].append((chunk_id, symbol, kind, signature))
|
|
1380
|
+
|
|
1381
|
+
all_files = sorted(by_file.keys())
|
|
1382
|
+
truncated = len(all_files) > payload.max_files
|
|
1383
|
+
files = all_files[: payload.max_files]
|
|
1384
|
+
|
|
1385
|
+
# Always fetch bodies — needed for @bind hash computation
|
|
1386
|
+
active_collection = indexer.active_collection_name()
|
|
1387
|
+
all_ids = [r[0] for fp in files for r in by_file[fp]]
|
|
1388
|
+
chunk_map: dict[str, object] = {}
|
|
1389
|
+
if all_ids and active_collection:
|
|
1390
|
+
fetched = indexer.qdrant_store.get_chunks_by_ids(active_collection, all_ids)
|
|
1391
|
+
chunk_map = {c.chunk_id: c for c in fetched}
|
|
1392
|
+
|
|
1393
|
+
# Fetch caller/callee cross-references from AST graph (batch)
|
|
1394
|
+
chunk_ids_list = list(chunk_map.keys())
|
|
1395
|
+
symbols_list = [sym for fp in files for _, sym, _, _ in by_file[fp]]
|
|
1396
|
+
callees_by_chunk = indexer.ast_graph.find_callees_batch(chunk_ids_list)
|
|
1397
|
+
callers_by_symbol = indexer.ast_graph.find_callers_batch(symbols_list)
|
|
1398
|
+
id_to_symbol = {r[0]: r[1] for r in indexer.ast_graph.get_all_symbols()}
|
|
1399
|
+
|
|
1400
|
+
# Build ordered CodeChunk list for PPL emission
|
|
1401
|
+
ordered_chunks = []
|
|
1402
|
+
for fp in files:
|
|
1403
|
+
file_chunks = [
|
|
1404
|
+
chunk_map[cid]
|
|
1405
|
+
for cid, *_ in by_file[fp]
|
|
1406
|
+
if cid in chunk_map
|
|
1407
|
+
]
|
|
1408
|
+
file_chunks.sort(key=lambda c: c.start_line)
|
|
1409
|
+
ordered_chunks.extend(file_chunks)
|
|
1410
|
+
|
|
1411
|
+
repo = Repo(project.root)
|
|
1412
|
+
try:
|
|
1413
|
+
git_sha = get_current_sha(repo)
|
|
1414
|
+
finally:
|
|
1415
|
+
repo.close()
|
|
1416
|
+
|
|
1417
|
+
ppl_document = PPLEmitter().emit(
|
|
1418
|
+
project_id=project.project_id,
|
|
1419
|
+
git_sha=git_sha,
|
|
1420
|
+
chunks=ordered_chunks,
|
|
1421
|
+
callers_by_symbol=callers_by_symbol,
|
|
1422
|
+
callees_by_chunk_id=callees_by_chunk,
|
|
1423
|
+
id_to_symbol=id_to_symbol,
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
return {
|
|
1427
|
+
"project": project.project_id,
|
|
1428
|
+
"total_files": len(files),
|
|
1429
|
+
"total_symbols": sum(len(by_file[f]) for f in files),
|
|
1430
|
+
"truncated": truncated,
|
|
1431
|
+
"ppl_document": ppl_document,
|
|
1432
|
+
# Legacy key kept for backward compatibility
|
|
1433
|
+
"pseudocode": ppl_document,
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async def _ensure_superkg_system(self, project: ProjectConfig) -> str | None:
|
|
1437
|
+
if project.superkg_system_id:
|
|
1438
|
+
return project.superkg_system_id
|
|
1439
|
+
if not getattr(self.superkg, "enabled", False):
|
|
1440
|
+
return None
|
|
1441
|
+
|
|
1442
|
+
system_id = await self.superkg.bootstrap(
|
|
1443
|
+
project_id=project.project_id,
|
|
1444
|
+
name=project.root.name or project.project_id,
|
|
1445
|
+
description=project.project_context or f"Codepreproc project at {project.root}",
|
|
1446
|
+
)
|
|
1447
|
+
if not system_id:
|
|
1448
|
+
return None
|
|
1449
|
+
|
|
1450
|
+
project.superkg_system_id = system_id
|
|
1451
|
+
registered = self.registry.registry.projects.get(project.project_id)
|
|
1452
|
+
if registered is not None:
|
|
1453
|
+
registered.superkg_system_id = system_id
|
|
1454
|
+
|
|
1455
|
+
try:
|
|
1456
|
+
from codepreproc_client.layer1_business.api_client.registry_client import set_superkg_id
|
|
1457
|
+
from codepreproc_client.layer1_business.config import get_home_dir, get_server_url
|
|
1458
|
+
from codepreproc_client.layer1_business.local_registry import load_local_projects, save_local_project
|
|
1459
|
+
|
|
1460
|
+
jwt = await self._jwt_provider(project.project_id)
|
|
1461
|
+
await set_superkg_id(
|
|
1462
|
+
base_url=get_server_url(), jwt=jwt, project_id=project.project_id, system_id=system_id
|
|
1463
|
+
)
|
|
1464
|
+
home_dir = get_home_dir()
|
|
1465
|
+
local = load_local_projects(home_dir)
|
|
1466
|
+
local_project = local.get(project.project_id)
|
|
1467
|
+
if local_project is not None:
|
|
1468
|
+
save_local_project(home_dir, local_project.model_copy(update={"superkg_system_id": system_id}))
|
|
1469
|
+
except Exception as exc:
|
|
1470
|
+
self.logger.debug("superkg_system_id persistence failed for %s: %s", project.project_id, exc)
|
|
1471
|
+
|
|
1472
|
+
return system_id
|
|
1473
|
+
|
|
1474
|
+
def _resolve_project(self, session: SessionState, explicit_project: str | None) -> ProjectConfig:
|
|
1475
|
+
try:
|
|
1476
|
+
return self.registry.resolve_project(
|
|
1477
|
+
explicit_project=explicit_project,
|
|
1478
|
+
active_project=session.active_project,
|
|
1479
|
+
roots=session.roots,
|
|
1480
|
+
fallback_path=session.roots[0] if session.roots else None,
|
|
1481
|
+
)
|
|
1482
|
+
except KeyError as exc:
|
|
1483
|
+
available = [project.project_id for project in self.registry.list_projects()]
|
|
1484
|
+
raise ValueError({"error": str(exc), "available_projects": available}) from exc
|
|
1485
|
+
|
|
1486
|
+
def _get_indexer(self, session: SessionState, project: ProjectConfig) -> ProjectIndexer:
|
|
1487
|
+
if project.project_id not in session.loaded_indexers:
|
|
1488
|
+
project_dir = self.settings.indexes_dir / project.project_id
|
|
1489
|
+
session.loaded_indexers[project.project_id] = ProjectIndexer(
|
|
1490
|
+
project=project,
|
|
1491
|
+
settings=self.settings,
|
|
1492
|
+
qdrant_store=self.qdrant_store,
|
|
1493
|
+
bm25_store=BM25Store(project_dir / "bm25.pkl"),
|
|
1494
|
+
ast_graph=AstGraph(project_dir / "ast_graph.sqlite"),
|
|
1495
|
+
chunker=AstChunker(self.settings.defaults.chunking),
|
|
1496
|
+
embedder=self.embedder,
|
|
1497
|
+
coupling_graph=self._coupling_graph,
|
|
1498
|
+
user_id=session.user_id,
|
|
1499
|
+
)
|
|
1500
|
+
return session.loaded_indexers[project.project_id]
|
|
1501
|
+
|
|
1502
|
+
def _make_pipeline(self, session: SessionState, project: ProjectConfig) -> SemanticExecutionPipeline:
|
|
1503
|
+
indexer = self._get_indexer(session, project)
|
|
1504
|
+
return SemanticExecutionPipeline(
|
|
1505
|
+
project=project,
|
|
1506
|
+
settings=self.settings,
|
|
1507
|
+
indexer=indexer,
|
|
1508
|
+
retriever=HybridRetriever(self.embedder, self.qdrant_store, indexer.bm25_store, self.settings.defaults.retrieval),
|
|
1509
|
+
reranker=self.reranker,
|
|
1510
|
+
graph_walker=GraphWalker(),
|
|
1511
|
+
intent_extractor=IntentExtractor(self.llm_runtime.router),
|
|
1512
|
+
context_compactor=ContextCompactor(self.llm_runtime.router),
|
|
1513
|
+
target_locator=TargetLocator(
|
|
1514
|
+
primary_limit=self.settings.defaults.semantic_editing.max_primary_targets,
|
|
1515
|
+
secondary_limit=self.settings.defaults.semantic_editing.max_secondary_targets,
|
|
1516
|
+
),
|
|
1517
|
+
region_resolver=RegionResolver(),
|
|
1518
|
+
planner=SemanticEditPlanner(self.llm_runtime.router),
|
|
1519
|
+
applicability_checker=ApplicabilityChecker(),
|
|
1520
|
+
synthesizer=SemanticSynthesizer(
|
|
1521
|
+
self.llm_runtime.router,
|
|
1522
|
+
micro_editor_temperature=self.settings.defaults.semantic_editing.micro_editor_temperature,
|
|
1523
|
+
),
|
|
1524
|
+
logger=self.logger,
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
def _make_filesystem_pipeline(self, project: ProjectConfig) -> FilesystemReorganizationPipeline:
|
|
1528
|
+
return FilesystemReorganizationPipeline(
|
|
1529
|
+
project=project,
|
|
1530
|
+
settings=self.settings,
|
|
1531
|
+
router=self.llm_runtime.router,
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
async def coupling_project(
|
|
1535
|
+
self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None
|
|
1536
|
+
) -> dict:
|
|
1537
|
+
"""Project cross-repo coupling structure for a set of repos in one call."""
|
|
1538
|
+
payload = CouplingProjectInput.model_validate(arguments)
|
|
1539
|
+
req = CouplingProjectRequest(anchors=payload.anchors, intent=payload.intent)
|
|
1540
|
+
|
|
1541
|
+
# Resolve intent → project_ids when anchors not given
|
|
1542
|
+
if not req.is_concrete():
|
|
1543
|
+
if not req.intent:
|
|
1544
|
+
return {"error": "provide anchors (project_id list) or intent string"}
|
|
1545
|
+
all_ids = [p.project_id for p in self.registry.list_projects()]
|
|
1546
|
+
intent_lower = req.intent.lower()
|
|
1547
|
+
anchors = [pid for pid in all_ids if intent_lower in pid.lower()]
|
|
1548
|
+
if not anchors:
|
|
1549
|
+
anchors = all_ids
|
|
1550
|
+
else:
|
|
1551
|
+
anchors = payload.anchors
|
|
1552
|
+
|
|
1553
|
+
# Validate all anchors are registered projects
|
|
1554
|
+
known = {p.project_id for p in self.registry.list_projects()}
|
|
1555
|
+
unknown = [a for a in anchors if a not in known]
|
|
1556
|
+
if unknown:
|
|
1557
|
+
return {"error": f"unknown project_ids: {unknown}", "known": sorted(known)}
|
|
1558
|
+
|
|
1559
|
+
if monitor is not None:
|
|
1560
|
+
await monitor.stage("hydrate", "coupling_project: hidratando edges")
|
|
1561
|
+
|
|
1562
|
+
# Hydrate edge superset from coupling graph (Layer 1 — candidate level)
|
|
1563
|
+
edges = await asyncio.to_thread(
|
|
1564
|
+
self._coupling_graph.get_edges_for_projects, anchors
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
# Apply Layer 2 gate: disambiguate ambiguous edges and revalidate stale ones.
|
|
1568
|
+
# Uses export_surface_hash rather than last_indexed_sha so body-only commits
|
|
1569
|
+
# in dst projects do not trigger re-resolution.
|
|
1570
|
+
if monitor is not None:
|
|
1571
|
+
await monitor.stage("revalidate", "coupling_project: aplicando gate Layer 2")
|
|
1572
|
+
from codepreproc_client.layer2_tooling.coupling.layer2_gate import apply_layer2_gate
|
|
1573
|
+
edges, stale_count = await asyncio.to_thread(
|
|
1574
|
+
apply_layer2_gate, edges, self.settings.indexes_dir, self._coupling_graph
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
if monitor is not None:
|
|
1578
|
+
await monitor.stage("project", "coupling_project: proyectando métricas")
|
|
1579
|
+
|
|
1580
|
+
summary = await asyncio.to_thread(
|
|
1581
|
+
self._coupling_projector.project, anchors, edges, stale_count
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
handle_id = uuid.uuid4().hex
|
|
1585
|
+
report = CouplingReport(
|
|
1586
|
+
handle_id=handle_id,
|
|
1587
|
+
anchors=anchors,
|
|
1588
|
+
edges=edges,
|
|
1589
|
+
summary=summary,
|
|
1590
|
+
)
|
|
1591
|
+
session.coupling_reports[handle_id] = report
|
|
1592
|
+
|
|
1593
|
+
from codepreproc_client.layer2_tooling.coupling.domain import CouplingHandle
|
|
1594
|
+
handle = CouplingHandle(
|
|
1595
|
+
handle_id=handle_id,
|
|
1596
|
+
summary=summary,
|
|
1597
|
+
provenance_level="resolved",
|
|
1598
|
+
)
|
|
1599
|
+
return handle.model_dump(mode="json")
|
|
1600
|
+
|
|
1601
|
+
async def coupling_expand(self, session: SessionState, arguments: dict) -> dict:
|
|
1602
|
+
"""Expand a coupling handle to retrieve edge details on demand."""
|
|
1603
|
+
payload = CouplingExpandInput.model_validate(arguments)
|
|
1604
|
+
report = session.coupling_reports.get(payload.handle_id)
|
|
1605
|
+
if report is None:
|
|
1606
|
+
return {"error": "handle_not_found", "handle_id": payload.handle_id}
|
|
1607
|
+
|
|
1608
|
+
if payload.edge_id:
|
|
1609
|
+
# Single edge detail
|
|
1610
|
+
edge = next((e for e in report.edges if e.edge_id == payload.edge_id), None)
|
|
1611
|
+
if edge is None:
|
|
1612
|
+
return {"error": "edge_not_found", "edge_id": payload.edge_id}
|
|
1613
|
+
# Enrich with file paths from local AstGraph
|
|
1614
|
+
from codepreproc_client.layer2_tooling.coupling.domain import EdgeDetail
|
|
1615
|
+
src_file = ""
|
|
1616
|
+
dst_file = ""
|
|
1617
|
+
src_project = self.registry.registry.projects.get(edge.src_project_id)
|
|
1618
|
+
dst_project = self.registry.registry.projects.get(edge.dst_project_id)
|
|
1619
|
+
if src_project:
|
|
1620
|
+
src_indexer_dir = self.settings.indexes_dir / edge.src_project_id
|
|
1621
|
+
try:
|
|
1622
|
+
import sqlite3 as _sqlite3
|
|
1623
|
+
conn = _sqlite3.connect(str(src_indexer_dir / "ast_graph.sqlite"))
|
|
1624
|
+
row = conn.execute("SELECT file_path FROM symbols WHERE chunk_id = ?", (edge.src_chunk_id,)).fetchone()
|
|
1625
|
+
conn.close()
|
|
1626
|
+
src_file = row[0] if row else ""
|
|
1627
|
+
except Exception:
|
|
1628
|
+
pass
|
|
1629
|
+
if dst_project:
|
|
1630
|
+
dst_indexer_dir = self.settings.indexes_dir / edge.dst_project_id
|
|
1631
|
+
try:
|
|
1632
|
+
import sqlite3 as _sqlite3
|
|
1633
|
+
conn = _sqlite3.connect(str(dst_indexer_dir / "ast_graph.sqlite"))
|
|
1634
|
+
row = conn.execute("SELECT file_path FROM symbols WHERE chunk_id = ?", (edge.dst_chunk_id,)).fetchone()
|
|
1635
|
+
conn.close()
|
|
1636
|
+
dst_file = row[0] if row else ""
|
|
1637
|
+
except Exception:
|
|
1638
|
+
pass
|
|
1639
|
+
detail = EdgeDetail(edge=edge, src_file_path=src_file, dst_file_path=dst_file)
|
|
1640
|
+
return detail.model_dump(mode="json")
|
|
1641
|
+
|
|
1642
|
+
# Paginated edge list
|
|
1643
|
+
total = len(report.edges)
|
|
1644
|
+
page = report.edges[payload.offset: payload.offset + payload.limit]
|
|
1645
|
+
return {
|
|
1646
|
+
"handle_id": payload.handle_id,
|
|
1647
|
+
"anchors": report.anchors,
|
|
1648
|
+
"total_edges": total,
|
|
1649
|
+
"offset": payload.offset,
|
|
1650
|
+
"limit": payload.limit,
|
|
1651
|
+
"edges": [e.model_dump(mode="json") for e in page],
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
@staticmethod
|
|
1655
|
+
def _prune_tasks(session: SessionState) -> None:
|
|
1656
|
+
now = datetime.now(timezone.utc)
|
|
1657
|
+
expired = [task_id for task_id, item in session.semantic_tasks.items() if item.expires_at <= now]
|
|
1658
|
+
for task_id in expired:
|
|
1659
|
+
session.semantic_tasks.pop(task_id, None)
|
|
1660
|
+
expired_filesystem = [task_id for task_id, item in session.filesystem_tasks.items() if item.expires_at <= now]
|
|
1661
|
+
for task_id in expired_filesystem:
|
|
1662
|
+
session.filesystem_tasks.pop(task_id, None)
|
|
1663
|
+
expired_assembly = [task_id for task_id, item in session.assembly_tasks.items() if item.expires_at <= now]
|
|
1664
|
+
for task_id in expired_assembly:
|
|
1665
|
+
session.assembly_tasks.pop(task_id, None)
|