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,47 @@
|
|
|
1
|
+
"""Dependency expansion over the AST graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from codepreproc_client.layer2_tooling.condensation.ast_graph import AstGraph
|
|
9
|
+
from contracts.dtos import CodeChunk
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GraphWalker:
|
|
13
|
+
"""Expand from seed chunks to nearby callers and callees."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, max_chunks: int = 30) -> None:
|
|
16
|
+
self.max_chunks = max_chunks
|
|
17
|
+
|
|
18
|
+
def expand(
|
|
19
|
+
self,
|
|
20
|
+
seed_chunks: list[CodeChunk],
|
|
21
|
+
ast_graph: AstGraph,
|
|
22
|
+
chunk_loader: Callable[[list[str]], list[CodeChunk]],
|
|
23
|
+
depth: int,
|
|
24
|
+
) -> list[CodeChunk]:
|
|
25
|
+
"""Return extra chunks up to a fixed graph depth."""
|
|
26
|
+
|
|
27
|
+
symbol_map, callees_map, callers_map = ast_graph.load_for_walk_cached()
|
|
28
|
+
|
|
29
|
+
seen = {chunk.chunk_id for chunk in seed_chunks}
|
|
30
|
+
queue = deque((chunk.chunk_id, chunk.symbol, 0) for chunk in seed_chunks)
|
|
31
|
+
discovered: list[str] = []
|
|
32
|
+
|
|
33
|
+
while queue and len(discovered) < self.max_chunks:
|
|
34
|
+
chunk_id, symbol, level = queue.popleft()
|
|
35
|
+
if level >= depth:
|
|
36
|
+
continue
|
|
37
|
+
neighbors = callees_map.get(chunk_id, []) + callers_map.get(symbol, [])
|
|
38
|
+
for neighbor in neighbors:
|
|
39
|
+
if neighbor in seen:
|
|
40
|
+
continue
|
|
41
|
+
seen.add(neighbor)
|
|
42
|
+
discovered.append(neighbor)
|
|
43
|
+
nb_sym = symbol_map.get(neighbor) or symbol
|
|
44
|
+
queue.append((neighbor, nb_sym, level + 1))
|
|
45
|
+
if len(discovered) >= self.max_chunks:
|
|
46
|
+
break
|
|
47
|
+
return chunk_loader(discovered)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Hybrid retrieval using dense plus BM25 fusion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from codepreproc_client.layer2_tooling.condensation.bm25_store import BM25Store
|
|
10
|
+
from codepreproc_client.layer2_tooling.condensation.embeddings import Embedder
|
|
11
|
+
from codepreproc_client.layer2_tooling.condensation.qdrant_store import QdrantStore
|
|
12
|
+
from contracts.dtos import ProjectConfig, RetrievalConfig, ScoredChunk
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from codepreproc_client.layer2_tooling.condensation.ast_graph import AstGraph
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HybridRetriever:
|
|
19
|
+
"""Fuse dense and lexical retrieval with RRF."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, embedder: Embedder, qdrant_store: QdrantStore, bm25_store: BM25Store, config: RetrievalConfig) -> None:
|
|
22
|
+
self.embedder = embedder
|
|
23
|
+
self.qdrant_store = qdrant_store
|
|
24
|
+
self.bm25_store = bm25_store
|
|
25
|
+
self.config = config
|
|
26
|
+
|
|
27
|
+
async def retrieve(
|
|
28
|
+
self,
|
|
29
|
+
project: ProjectConfig,
|
|
30
|
+
query: str,
|
|
31
|
+
overlay_collection: str | None,
|
|
32
|
+
*,
|
|
33
|
+
collection_name: str | None = None,
|
|
34
|
+
user_collection_name: str | None = None,
|
|
35
|
+
monitor=None,
|
|
36
|
+
ast_graph: "AstGraph | None" = None,
|
|
37
|
+
md_harness_top_k: int = 3,
|
|
38
|
+
) -> list[ScoredChunk]:
|
|
39
|
+
"""Retrieve candidate chunks, optionally prepending directory-scoped MD harness."""
|
|
40
|
+
|
|
41
|
+
query_vec = (await self.embedder.encode_batch([query], monitor=monitor, stage_name="retrieve"))[0]
|
|
42
|
+
dense_results = []
|
|
43
|
+
active_collection = collection_name or project.qdrant_collection
|
|
44
|
+
if active_collection:
|
|
45
|
+
dense_results = self.qdrant_store.search(active_collection, query_vec, self.config.dense_top_k)
|
|
46
|
+
if user_collection_name and self.qdrant_store.collection_exists(user_collection_name):
|
|
47
|
+
dense_results.extend(self.qdrant_store.search(user_collection_name, query_vec, self.config.dense_top_k))
|
|
48
|
+
if overlay_collection:
|
|
49
|
+
dense_results.extend(self.qdrant_store.search(overlay_collection, query_vec, self.config.dense_top_k))
|
|
50
|
+
bm25_results = self.bm25_store.search(query, self.config.bm25_top_k)
|
|
51
|
+
results = self._rrf_fuse(dense_results, bm25_results)[: self.config.rerank_top_k]
|
|
52
|
+
|
|
53
|
+
if ast_graph is not None and md_harness_top_k > 0:
|
|
54
|
+
harness = self._get_md_harness(ast_graph, results, top_k=md_harness_top_k)
|
|
55
|
+
if harness:
|
|
56
|
+
existing_ids = {item.chunk.chunk_id for item in results}
|
|
57
|
+
new_harness = [h for h in harness if h.chunk.chunk_id not in existing_ids]
|
|
58
|
+
results = new_harness + results
|
|
59
|
+
|
|
60
|
+
return results
|
|
61
|
+
|
|
62
|
+
def _get_md_harness(self, ast_graph: "AstGraph", results: list[ScoredChunk], top_k: int = 3) -> list[ScoredChunk]:
|
|
63
|
+
"""Fetch directory-scoped MD knowledge chunks for the retrieved results."""
|
|
64
|
+
file_paths = [item.chunk.file_path for item in results if item.chunk.language != 'markdown']
|
|
65
|
+
if not file_paths:
|
|
66
|
+
return []
|
|
67
|
+
dir_paths = self._dir_hierarchy(file_paths)
|
|
68
|
+
chunk_ids = ast_graph.get_md_chunk_ids_for_dirs(dir_paths)
|
|
69
|
+
if not chunk_ids:
|
|
70
|
+
return []
|
|
71
|
+
md_chunks = self.bm25_store.get_by_ids(chunk_ids[:top_k])
|
|
72
|
+
return [ScoredChunk(chunk=c, score=1.0, source="md_harness") for c in md_chunks]
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _dir_hierarchy(file_paths: list[str]) -> list[str]:
|
|
76
|
+
"""Return all unique ancestor directory paths for the given files."""
|
|
77
|
+
dirs: set[str] = set()
|
|
78
|
+
for fp in file_paths:
|
|
79
|
+
parts = Path(fp).parent.parts
|
|
80
|
+
dirs.add('.')
|
|
81
|
+
for i in range(1, len(parts) + 1):
|
|
82
|
+
dirs.add('/'.join(parts[:i]))
|
|
83
|
+
return sorted(dirs)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _rrf_fuse(*rank_lists: list[ScoredChunk], k: int = 60) -> list[ScoredChunk]:
|
|
87
|
+
scores: dict[str, float] = defaultdict(float)
|
|
88
|
+
chunks: dict[str, ScoredChunk] = {}
|
|
89
|
+
for rank_list in rank_lists:
|
|
90
|
+
for rank, item in enumerate(rank_list, start=1):
|
|
91
|
+
scores[item.chunk.chunk_id] += 1.0 / (k + rank)
|
|
92
|
+
chunks[item.chunk.chunk_id] = item
|
|
93
|
+
fused = [ScoredChunk(chunk=chunks[key].chunk, score=value, source="rrf") for key, value in scores.items()]
|
|
94
|
+
fused.sort(key=lambda item: item.score, reverse=True)
|
|
95
|
+
return fused
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Cross-encoder reranker subprocess worker.
|
|
2
|
+
|
|
3
|
+
Launched as ``python -u -m codepreproc_client.layer2_tooling.retrieval.rerank_worker``.
|
|
4
|
+
Communicates with the parent via stdin/stdout JSON lines.
|
|
5
|
+
|
|
6
|
+
Protocol
|
|
7
|
+
--------
|
|
8
|
+
Parent → worker (stdin):
|
|
9
|
+
First line: {"model_name": "...", "device": "..."}
|
|
10
|
+
Subsequent: {"type": "rerank", "request_id": "...", "pairs": [[q, t], ...], "batch_size": N}
|
|
11
|
+
or {"type": "shutdown"}
|
|
12
|
+
|
|
13
|
+
Worker → parent (stdout):
|
|
14
|
+
{"type": "worker_ready"}
|
|
15
|
+
{"type": "worker_failed", "error": "..."}
|
|
16
|
+
{"type": "rerank_done", "request_id": "...", "scores": [...]}
|
|
17
|
+
{"type": "failed", "request_id": "...", "error": "..."}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _send(event: dict) -> None:
|
|
27
|
+
sys.stdout.write(json.dumps(event) + "\n")
|
|
28
|
+
sys.stdout.flush()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
config_line = sys.stdin.readline()
|
|
33
|
+
if not config_line:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
config = json.loads(config_line)
|
|
37
|
+
except json.JSONDecodeError as exc:
|
|
38
|
+
_send({"type": "worker_failed", "error": f"bad config line: {exc}"})
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
model_name = config["model_name"]
|
|
42
|
+
device = config["device"]
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from sentence_transformers import CrossEncoder
|
|
46
|
+
|
|
47
|
+
model = CrossEncoder(model_name, device=device)
|
|
48
|
+
_send({"type": "worker_ready"})
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
_send({"type": "worker_failed", "error": str(exc)})
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
for raw_line in sys.stdin:
|
|
54
|
+
line = raw_line.strip()
|
|
55
|
+
if not line:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
command = json.loads(line)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if command["type"] == "shutdown":
|
|
63
|
+
return
|
|
64
|
+
if command["type"] != "rerank":
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
request_id = command["request_id"]
|
|
68
|
+
pairs = command["pairs"]
|
|
69
|
+
batch_size = max(1, int(command["batch_size"]))
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
scores = model.predict(pairs, batch_size=batch_size, show_progress_bar=False)
|
|
75
|
+
break
|
|
76
|
+
except RuntimeError as exc:
|
|
77
|
+
if "out of memory" not in str(exc).lower() or batch_size == 1:
|
|
78
|
+
raise
|
|
79
|
+
batch_size = max(1, batch_size // 2)
|
|
80
|
+
_send({"type": "rerank_done", "request_id": request_id, "scores": [float(s) for s in scores]})
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
_send({"type": "failed", "request_id": request_id, "error": str(exc)})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
main()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Cross-encoder reranker.
|
|
2
|
+
|
|
3
|
+
On non-Windows platforms the model is loaded in a thread pool via
|
|
4
|
+
``asyncio.to_thread``. On Windows, loading PyTorch models inside a thread
|
|
5
|
+
pool deadlocks due to the Windows DLL Loader Lock, so we launch a dedicated
|
|
6
|
+
``subprocess.Popen`` pointing at ``codepreproc_client.layer2_tooling.retrieval.rerank_worker`` which
|
|
7
|
+
loads the model in its own main thread.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import queue
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, ClassVar
|
|
21
|
+
|
|
22
|
+
from codepreproc_client.layer1_business.mcp.action_monitor import ToolExecutionError
|
|
23
|
+
from contracts.dtos import DefaultsConfig, ScoredChunk
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Reranker:
|
|
27
|
+
"""Lazy reranker singleton."""
|
|
28
|
+
|
|
29
|
+
_instance: ClassVar["Reranker" | None] = None
|
|
30
|
+
|
|
31
|
+
def __init__(self, defaults: DefaultsConfig) -> None:
|
|
32
|
+
self.defaults = defaults
|
|
33
|
+
self._use_subprocess_worker = os.name == "nt"
|
|
34
|
+
# --- in-process state (non-Windows) ---
|
|
35
|
+
self._model = None
|
|
36
|
+
# --- subprocess state (Windows) ---
|
|
37
|
+
self._proc: subprocess.Popen | None = None # type: ignore[type-arg]
|
|
38
|
+
self._proc_event_queue: queue.Queue | None = None
|
|
39
|
+
self._reader_thread: threading.Thread | None = None
|
|
40
|
+
self._worker_lock = asyncio.Lock()
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get(cls, defaults: DefaultsConfig) -> "Reranker":
|
|
44
|
+
"""Return singleton instance."""
|
|
45
|
+
|
|
46
|
+
if cls._instance is None:
|
|
47
|
+
cls._instance = cls(defaults)
|
|
48
|
+
return cls._instance
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Public interface
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async def rerank(self, query: str, chunks: list[ScoredChunk], top_k: int) -> list[ScoredChunk]:
|
|
55
|
+
"""Rerank without blocking the event loop."""
|
|
56
|
+
|
|
57
|
+
if not chunks:
|
|
58
|
+
return []
|
|
59
|
+
async with self._worker_lock:
|
|
60
|
+
if self._use_subprocess_worker:
|
|
61
|
+
return await self._rerank_subprocess(query, chunks, top_k)
|
|
62
|
+
return await asyncio.to_thread(self.rerank_sync, query, chunks, top_k)
|
|
63
|
+
|
|
64
|
+
def rerank_sync(self, query: str, chunks: list[ScoredChunk], top_k: int) -> list[ScoredChunk]:
|
|
65
|
+
"""Rerank synchronously (non-Windows / direct call)."""
|
|
66
|
+
|
|
67
|
+
if not chunks:
|
|
68
|
+
return []
|
|
69
|
+
model = self._ensure_model()
|
|
70
|
+
pairs = [(query, f"{item.chunk.file_path}::{item.chunk.symbol}\n{item.chunk.body[:1500]}") for item in chunks]
|
|
71
|
+
batch_size = min(16, max(1, len(pairs)))
|
|
72
|
+
while batch_size >= 1:
|
|
73
|
+
try:
|
|
74
|
+
scores = model.predict(pairs, batch_size=batch_size, show_progress_bar=False)
|
|
75
|
+
break
|
|
76
|
+
except RuntimeError as exc:
|
|
77
|
+
if "out of memory" not in str(exc).lower() or batch_size == 1:
|
|
78
|
+
raise
|
|
79
|
+
batch_size = max(1, batch_size // 2)
|
|
80
|
+
reranked = [ScoredChunk(chunk=item.chunk, score=float(score), source="rerank") for item, score in zip(chunks, scores, strict=True)]
|
|
81
|
+
reranked.sort(key=lambda item: item.score, reverse=True)
|
|
82
|
+
return reranked[:top_k]
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# In-process model (non-Windows)
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def _ensure_model(self):
|
|
89
|
+
if self._model is None:
|
|
90
|
+
from sentence_transformers import CrossEncoder
|
|
91
|
+
|
|
92
|
+
self._model = CrossEncoder(self.defaults.reranker_model, device=self.defaults.reranker_device)
|
|
93
|
+
return self._model
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Subprocess worker (Windows)
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def _rerank_subprocess(self, query: str, chunks: list[ScoredChunk], top_k: int) -> list[ScoredChunk]:
|
|
100
|
+
await self._ensure_subprocess_worker()
|
|
101
|
+
pairs = [[query, f"{item.chunk.file_path}::{item.chunk.symbol}\n{item.chunk.body[:1500]}"] for item in chunks]
|
|
102
|
+
batch_size = min(16, max(1, len(pairs)))
|
|
103
|
+
request_id = uuid.uuid4().hex
|
|
104
|
+
self._send_subprocess_command(
|
|
105
|
+
{"type": "rerank", "request_id": request_id, "pairs": pairs, "batch_size": batch_size}
|
|
106
|
+
)
|
|
107
|
+
while True:
|
|
108
|
+
event = await self._wait_subprocess_event()
|
|
109
|
+
event_type = event["type"]
|
|
110
|
+
if event.get("request_id") != request_id and event_type not in ("worker_failed", "worker_exited"):
|
|
111
|
+
continue
|
|
112
|
+
if event_type == "rerank_done":
|
|
113
|
+
scores = event["scores"]
|
|
114
|
+
reranked = [
|
|
115
|
+
ScoredChunk(chunk=item.chunk, score=float(score), source="rerank")
|
|
116
|
+
for item, score in zip(chunks, scores, strict=True)
|
|
117
|
+
]
|
|
118
|
+
reranked.sort(key=lambda item: item.score, reverse=True)
|
|
119
|
+
return reranked[:top_k]
|
|
120
|
+
if event_type == "failed":
|
|
121
|
+
self._restart_subprocess_worker()
|
|
122
|
+
raise ToolExecutionError(
|
|
123
|
+
"rerank_failed",
|
|
124
|
+
event.get("error", "unknown rerank error")[:500],
|
|
125
|
+
details={"stage": "rerank/graph"},
|
|
126
|
+
)
|
|
127
|
+
if event_type in ("worker_failed", "worker_exited"):
|
|
128
|
+
self._restart_subprocess_worker()
|
|
129
|
+
raise ToolExecutionError(
|
|
130
|
+
"rerank_worker_failed",
|
|
131
|
+
event.get("error", "worker exited unexpectedly")[:500],
|
|
132
|
+
details={"stage": "rerank/graph"},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def _ensure_subprocess_worker(self) -> None:
|
|
136
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
137
|
+
return
|
|
138
|
+
self._restart_subprocess_worker()
|
|
139
|
+
self._proc_event_queue = queue.Queue()
|
|
140
|
+
self._proc = subprocess.Popen(
|
|
141
|
+
[sys.executable, "-u", "-m", "codepreproc_client.layer2_tooling.retrieval.rerank_worker"],
|
|
142
|
+
stdin=subprocess.PIPE,
|
|
143
|
+
stdout=subprocess.PIPE,
|
|
144
|
+
stderr=subprocess.DEVNULL,
|
|
145
|
+
)
|
|
146
|
+
config_line = (
|
|
147
|
+
json.dumps({"model_name": self.defaults.reranker_model, "device": self.defaults.reranker_device}) + "\n"
|
|
148
|
+
)
|
|
149
|
+
self._proc.stdin.write(config_line.encode())
|
|
150
|
+
self._proc.stdin.flush()
|
|
151
|
+
self._reader_thread = threading.Thread(target=self._read_subprocess_stdout, daemon=True)
|
|
152
|
+
self._reader_thread.start()
|
|
153
|
+
# Wait for worker_ready
|
|
154
|
+
while True:
|
|
155
|
+
event = await self._wait_subprocess_event()
|
|
156
|
+
event_type = event["type"]
|
|
157
|
+
if event_type == "worker_ready":
|
|
158
|
+
return
|
|
159
|
+
if event_type in ("worker_failed", "worker_exited"):
|
|
160
|
+
self._restart_subprocess_worker()
|
|
161
|
+
raise ToolExecutionError(
|
|
162
|
+
"rerank_worker_failed",
|
|
163
|
+
event.get("error", "worker exited unexpectedly")[:500],
|
|
164
|
+
details={"stage": "rerank/graph"},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _read_subprocess_stdout(self) -> None:
|
|
168
|
+
try:
|
|
169
|
+
for raw_line in self._proc.stdout:
|
|
170
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
171
|
+
if line:
|
|
172
|
+
try:
|
|
173
|
+
self._proc_event_queue.put(json.loads(line))
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
pass
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
finally:
|
|
179
|
+
if self._proc_event_queue is not None:
|
|
180
|
+
self._proc_event_queue.put({"type": "worker_exited"})
|
|
181
|
+
|
|
182
|
+
async def _wait_subprocess_event(self) -> dict[str, Any]:
|
|
183
|
+
while True:
|
|
184
|
+
try:
|
|
185
|
+
return await asyncio.to_thread(self._proc_event_queue.get, True, 1.0)
|
|
186
|
+
except queue.Empty:
|
|
187
|
+
if self._proc is None or self._proc.poll() is not None:
|
|
188
|
+
raise ToolExecutionError(
|
|
189
|
+
"rerank_worker_failed",
|
|
190
|
+
"reranker subprocess exited unexpectedly",
|
|
191
|
+
details={"stage": "rerank/graph"},
|
|
192
|
+
) from None
|
|
193
|
+
|
|
194
|
+
def _send_subprocess_command(self, command: dict) -> None:
|
|
195
|
+
line = (json.dumps(command) + "\n").encode()
|
|
196
|
+
self._proc.stdin.write(line)
|
|
197
|
+
self._proc.stdin.flush()
|
|
198
|
+
|
|
199
|
+
def _restart_subprocess_worker(self) -> None:
|
|
200
|
+
if self._proc is not None:
|
|
201
|
+
try:
|
|
202
|
+
self._proc.stdin.close()
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
if self._proc.poll() is None:
|
|
206
|
+
self._proc.terminate()
|
|
207
|
+
try:
|
|
208
|
+
self._proc.wait(timeout=2)
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
self._proc.kill()
|
|
211
|
+
self._proc = None
|
|
212
|
+
self._proc_event_queue = None
|
|
213
|
+
self._reader_thread = None
|
|
File without changes
|