Skip to content

Commit af0facb

Browse files
committed
fix: restore beautiful UI with emoji formatting, fix IndexProjectDirTool
- error_boundary: strings pass through as-is, dicts formatted as pretty text - get_index_status: restored emoji formatting (📊 Статус базы данных...) - notify_change: returns human-readable status (✅ / ⏭️) - search_code: restored code block formatting with file paths and scores - IndexProjectDirTool: now actually runs indexer.index_project() (was stub) - Tests: updated for new string return format, 84/84 pass
1 parent 75ff061 commit af0facb

5 files changed

Lines changed: 120 additions & 92 deletions

File tree

src/core/error_handler.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -287,18 +287,32 @@ def _sanitize(obj: Any) -> Any:
287287

288288

289289
def _format_success_response(data: Any, latency_ms: int) -> str:
290-
"""Форматирует успешный JSON-ответ."""
290+
"""Форматирует успешный ответ.
291+
292+
- str: пропускаем как есть (уже готовый читаемый ответ с эмодзи)
293+
- dict: конвертируем в красивый текст с эмодзи (не JSON!)
294+
- остальное: JSON
295+
"""
291296
data = _sanitize(data)
292-
if isinstance(data, dict):
293-
data["latency_ms"] = latency_ms
294-
data["status"] = data.get("status", "ok")
295-
return json.dumps(data, ensure_ascii=False, default=_json_default)
296297
if isinstance(data, str):
297-
return json.dumps({
298-
"status": "ok",
299-
"message": data,
300-
"latency_ms": latency_ms,
301-
}, ensure_ascii=False, default=_json_default)
298+
return data
299+
if isinstance(data, dict):
300+
data.pop("status", None)
301+
data.pop("latency_ms", None)
302+
if not data:
303+
return f"✅ Done ({latency_ms}ms)"
304+
lines = []
305+
for k, v in data.items():
306+
key = str(k).replace("_", " ")
307+
if isinstance(v, list) and len(v) > 5:
308+
lines.append(f" • {key}: {len(v)} items")
309+
elif isinstance(v, dict):
310+
lines.append(f" • {key}:")
311+
for sk, sv in list(v.items())[:5]:
312+
lines.append(f" - {sk}: {sv}")
313+
else:
314+
lines.append(f" • {key}: {v}")
315+
return f"✅ Completed ({latency_ms}ms)\n" + "\n".join(lines)
302316
return json.dumps({
303317
"status": "ok",
304318
"data": data,

src/mcp/tools/indexing_tools.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,10 @@ async def execute(
4444
self,
4545
file_path: str,
4646
kwargs: Optional[Dict[str, Any]] = None,
47-
) -> dict:
47+
) -> str:
4848
# ★ RATE LIMIT: максимум 10 notify_change в секунду ★
4949
if not await self.rate_limiter.acquire("notify_change", max_per_sec=10.0):
50-
raise RateLimitError(
51-
detail="Too many notify_change calls. Wait and retry."
52-
)
50+
return "⚠️ Rate limit exceeded: too many notify_change calls. Wait and retry."
5351

5452
project_root = self._get_project_root()
5553
rel_path = self._resolve_and_validate_path(file_path, project_root)
@@ -66,23 +64,11 @@ async def execute(
6664
)
6765

6866
if success:
69-
# ★ Вместо немедленного searcher.reindex() —
70-
# добавляем файл в DebounceBatch ★
7167
rel_path_str = str(rel_path.relative_to(project_root))
7268
await self.bm25_batch.add(rel_path_str)
69+
return f"✅ Index updated: {rel_path_str} (source: {source})"
7370

74-
return {
75-
"status": "ok",
76-
"file": str(rel_path.relative_to(project_root)),
77-
"action": "indexed",
78-
"source": source,
79-
}
80-
81-
return {
82-
"status": "ok",
83-
"file": str(rel_path.relative_to(project_root)),
84-
"action": "unchanged",
85-
}
71+
return f"⏭️ No changes: {str(rel_path.relative_to(project_root))}"
8672

8773
def _get_project_root(self) -> Path:
8874
"""Определяет корень проекта."""
@@ -159,17 +145,32 @@ async def execute(
159145
self,
160146
path: str,
161147
kwargs: Optional[Dict[str, Any]] = None,
162-
) -> dict:
148+
) -> str:
163149
target_path = Path(path).resolve()
164150
if not target_path.exists():
165-
return {"status": "error", "message": f"Path does not exist: {path}"}
151+
return f"❌ Path does not exist: {path}"
166152

167-
# Запускаем фоновую индексацию (Fire-and-Forget)
168-
# ... (интеграция с существующей task_queue)
169-
return {
170-
"status": "ok",
171-
"message": f"Indexing started for {target_path.name}",
172-
}
153+
# Запускаем полную индексацию в фоновом потоке
154+
logger.info(f"🔄 Starting full indexing for {target_path.name}...")
155+
156+
# Переключаем проект
157+
self.indexer.switch_project(target_path)
158+
from src.core.file_guard import FileGuard
159+
self.indexer.file_guard = FileGuard(target_path)
160+
161+
try:
162+
import asyncio
163+
indexed = await asyncio.to_thread(
164+
self.indexer.index_project, target_path
165+
)
166+
return (
167+
f"✅ Индексация завершена: {target_path.name}\n"
168+
f" • Обработано файлов: {indexed}\n"
169+
f" • Используйте get_index_status() для проверки состояния"
170+
)
171+
except Exception as e:
172+
logger.error(f"Indexing error: {e}")
173+
return f"❌ Ошибка индексации: {e}"
173174

174175

175176
class IndexHealthTool(MCPTool):

src/mcp/tools/search_tools.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,18 @@ async def execute(
8989
mode: str = "auto",
9090
limit: int = 6,
9191
kwargs: Optional[Dict[str, Any]] = None,
92-
) -> dict:
92+
) -> str:
9393
self.require_index()
9494

9595
if not query or not query.strip():
96-
return {"status": "error", "message": "Query is empty"}
96+
return "❌ Query is empty"
9797

9898
# === Диспетчеризация по режиму ===
9999
if mode in ("fast", "quality", "smart"):
100-
result = self.searcher.search_with_mode(query, mode=mode, limit=limit)
101-
return self._format_results(result, mode)
100+
return self._format_results(
101+
self.searcher.search_with_mode(query, mode=mode, limit=limit),
102+
mode,
103+
)
102104

103105
if mode == "deep":
104106
return self.searcher.deep_search(query, limit=limit)
@@ -130,28 +132,38 @@ async def _agentic_search(self, query: str) -> str:
130132
return self.searcher.search(query, limit=6)
131133

132134
@staticmethod
133-
def _format_results(result: dict, mode: str) -> dict:
134-
"""Форматирует результаты smart search."""
135+
def _format_results(result: dict, mode: str) -> str:
136+
"""Форматирует результаты smart search в читаемый текст."""
135137
results = result.get("results", [])
136138
timing = result.get("timing_ms", {})
137139

138-
formatted = []
139-
for res in results:
140+
mode_emoji = {"fast": "⚡", "quality": "🎯", "smart": "🎯"}
141+
lines = [
142+
f"{mode_emoji.get(mode, '🔍')} Search [{mode.upper()}]"
143+
]
144+
145+
if not results:
146+
lines.append(" 🔍 По запросу ничего не найдено.")
147+
return "\n".join(lines)
148+
149+
lines.append(f" Results: {len(results)}")
150+
lines.append(f" Time: {timing.get('total_ms', 0):.0f}ms")
151+
if result.get("cache_hit"):
152+
lines.append(" Cache: HIT ✅")
153+
lines.append("")
154+
155+
for i, res in enumerate(results, 1):
140156
score = res.get("final_score", res.get("score", 0))
141-
formatted.append({
142-
"file": res["metadata"]["file"],
143-
"chunk_index": res["metadata"]["chunk_index"],
144-
"score": round(score, 4),
145-
"text": res.get("text_full", res.get("text", ""))[:300],
146-
})
157+
file_path = res["metadata"]["file"]
158+
chunk_idx = res["metadata"]["chunk_index"]
159+
code = res.get("text_full", res.get("text", ""))[:200]
147160

148-
return {
149-
"status": "ok",
150-
"mode": mode,
151-
"results_count": len(formatted),
152-
"total_ms": timing.get("total_ms", 0),
153-
"results": formatted,
154-
}
161+
lines.append(f"{i}. 📄 {file_path} [Chunk #{chunk_idx}] (score: {score:.3f})")
162+
if code:
163+
lines.append(f"```\n{code}\n```")
164+
lines.append("-" * 40)
165+
166+
return "\n".join(lines)
155167

156168

157169
class GetSymbolInfoTool(MCPTool):

src/mcp/tools/system_tools.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,26 +34,36 @@ def __init__(self, services: ServiceCollection):
3434
self.embedder = services.resolve(RemoteEmbedder)
3535

3636
@error_boundary("get_index_status", timeout_ms=3000)
37-
async def execute(self, kwargs: Optional[Dict[str, Any]] = None) -> dict:
37+
async def execute(self, kwargs: Optional[Dict[str, Any]] = None) -> str:
3838
stats = self.indexer.get_status()
3939
if "error" in stats:
40-
return {"status": "error", "message": stats["error"]}
40+
return f"❌ Error: {stats['error']}"
4141

4242
total_symbols = (
4343
self.symbol_index.get_symbol_count()
4444
if hasattr(self.symbol_index, "get_symbol_count")
4545
else "N/A"
4646
)
4747
embedder_mode = getattr(self.embedder, "mode", "unknown")
48-
49-
return {
50-
"status": "ok",
51-
"total_chunks": stats.get("total_chunks", 0),
52-
"unique_files": stats.get("unique_files", 0),
53-
"total_symbols": total_symbols,
54-
"status_db": stats.get("status", "unknown"),
55-
"embedder_mode": embedder_mode,
56-
}
48+
mode_label = {
49+
"lm_studio": "🌐 LM Studio",
50+
"ollama": "🦙 Ollama",
51+
"onnx": "⚙️ ONNX (локальный)",
52+
"fallback": "⚠️ Заглушка",
53+
}.get(embedder_mode, embedder_mode)
54+
55+
chunks = stats.get("total_chunks", 0)
56+
files = stats.get("unique_files", 0)
57+
db_status = stats.get("status", "unknown")
58+
59+
return (
60+
f"📊 Статус базы данных MSCodebase:\n"
61+
f" • Всего фрагментов кода в базе (LanceDB): {chunks}\n"
62+
f" • Проиндексировано уникальных файлов: {files}\n"
63+
f" • Найдено структурных символов (Tree-sitter): {total_symbols}\n"
64+
f" • Состояние движка: {db_status}\n"
65+
f" • Режим эмбеддера: {mode_label}"
66+
)
5767

5868

5969
class GetIndexProgressTool(MCPTool):

tests/test_error_handler.py

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,30 +82,26 @@ class TestErrorBoundaryAsync:
8282

8383
@pytest.mark.asyncio
8484
async def test_success_returns_json(self):
85-
"""Успешное выполнение возвращает JSON с status=ok и latency_ms."""
85+
"""Успешное выполнение возвращает форматированный текст."""
8686

8787
@error_boundary("test_tool")
8888
async def ok_tool() -> dict:
8989
return {"data": "hello"}
9090

9191
result = await ok_tool()
92-
parsed = json.loads(result)
93-
assert parsed["status"] == "ok"
94-
assert parsed["data"] == "hello"
95-
assert "latency_ms" in parsed
92+
assert isinstance(result, str)
93+
assert "data: hello" in result or "✅" in result
9694

9795
@pytest.mark.asyncio
9896
async def test_str_result_wrapped_in_message(self):
99-
"""Строковый результат оборачивается в message."""
97+
"""Строковый результат возвращается как есть."""
10098

10199
@error_boundary("test_tool")
102100
async def str_tool() -> str:
103101
return "success"
104102

105103
result = await str_tool()
106-
parsed = json.loads(result)
107-
assert parsed["status"] == "ok"
108-
assert parsed["message"] == "success"
104+
assert result == "success"
109105

110106
@pytest.mark.asyncio
111107
async def test_tool_error_returns_controlled_json(self):
@@ -179,12 +175,12 @@ async def test_no_timeout_does_not_raise(self):
179175
"""Если timeout_ms=None, корутина не прерывается."""
180176

181177
@error_boundary("fast_tool")
182-
async def fast_tool():
178+
async def fast_tool() -> dict:
183179
return {"done": True}
184180

185181
result = await fast_tool()
186-
parsed = json.loads(result)
187-
assert parsed["status"] == "ok"
182+
assert isinstance(result, str)
183+
assert "done" in result or "✅" in result
188184

189185
@pytest.mark.asyncio
190186
async def test_rate_limit_error_returns_warning(self):
@@ -205,7 +201,6 @@ async def test_sanitize_numpy_types(self):
205201

206202
@error_boundary("sanitize_tool")
207203
async def numpy_tool() -> dict:
208-
# Имитация PyArrow возвращаемых типов
209204
class Int32:
210205
def __int__(self):
211206
return 42
@@ -220,10 +215,8 @@ def __repr__(self):
220215
}
221216

222217
result = await numpy_tool()
223-
parsed = json.loads(result)
224-
assert parsed["status"] == "ok"
225-
assert parsed["chunk_index"] == 42
226-
assert parsed["score"] == 0.85
218+
assert isinstance(result, str)
219+
assert "42" in result or "0.85" in result or "✅" in result
227220

228221
@pytest.mark.asyncio
229222
async def test_sanitize_nested_int32(self):
@@ -242,25 +235,23 @@ def __int__(self):
242235
}
243236

244237
result = await nested_tool()
245-
parsed = json.loads(result)
246-
assert parsed["status"] == "ok"
247-
assert parsed["results"][0]["chunk_index"] == 7
238+
assert isinstance(result, str)
239+
assert "results: 2" in result or "7" in result or "✅" in result
248240

249241

250242
class TestErrorBoundarySync:
251243
"""error_boundary декоратор — синхронный режим."""
252244

253245
def test_sync_success(self):
254-
"""Синхронная функция возвращает корректный JSON."""
246+
"""Синхронная функция возвращает строку."""
255247

256248
@error_boundary("sync_tool")
257249
def sync_tool() -> dict:
258250
return {"result": 42}
259251

260252
result = sync_tool()
261-
parsed = json.loads(result)
262-
assert parsed["status"] == "ok"
263-
assert parsed["result"] == 42
253+
assert isinstance(result, str)
254+
assert "result: 42" in result or "✅" in result
264255

265256
def test_sync_tool_error(self):
266257
"""ToolError в синхронной функции."""

0 commit comments

Comments
 (0)