ForcePilot/backend/package/yuxi/services/mention_search_service.py

284 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import base64
import os
from collections.abc import Sequence
from pathlib import Path
import ormsgpack
from yuxi.agents.backends.sandbox.paths import (
sandbox_outputs_dir,
sandbox_uploads_dir,
sandbox_workspace_dir,
)
from yuxi.config.app import config
from yuxi.services.run_queue_service import get_redis_client
from yuxi.utils.logging_config import logger
MENTION_EXCLUDE_DIRS = {
".git",
"node_modules",
".venv",
"venv",
"__pycache__",
".idea",
".vscode",
"dist",
"build",
".tox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
}
MAX_MENTION_RESULTS = 50
MAX_ENTRIES_PER_DIR = 500
MAX_SEARCH_DEPTH = 15
CACHE_TTL = 60 # 缓存有效期 60 秒
MAX_CACHED_ENTRIES = 100000
REDIS_KEY_PREFIX = "yuxi:mention:cache:"
WORKSPACE_CACHE_PREFIX = f"{REDIS_KEY_PREFIX}workspace:"
THREAD_CACHE_PREFIX = f"{REDIS_KEY_PREFIX}thread:"
WORKSPACE_THREAD_PLACEHOLDER = "_workspace"
MENTION_SOURCES = {"workspace", "thread"}
def _scan_pruned_files(root: Path, max_entries: int) -> list[tuple[str, str]]:
"""
同步扫描磁盘文件目录并进行多重限额剪枝保护 (防止大文件仓库卡死)
"""
results: list[tuple[str, str]] = []
if not root.exists():
return results
root_str = str(root)
for dirpath, dirnames, filenames in os.walk(root_str):
# 1. 剪枝黑名单和隐藏目录 (直接在 dirnames 中修改,阻止 os.walk 深入)
dirnames[:] = [d for d in dirnames if d not in MENTION_EXCLUDE_DIRS and not d.startswith(".")]
# 2. 深度保护限制最大搜索深度root 本身为第 0 层,第 15 层时 rel.parts 长度恰好为 15
try:
rel = Path(dirpath).relative_to(root)
if len(rel.parts) >= MAX_SEARCH_DEPTH:
dirnames.clear()
continue
except Exception:
pass
# 3. 宽度与全局限额保护下的合格“子目录实体”收集
for dirname in dirnames:
full_dir_path = Path(dirpath) / dirname
rel_dir_path = full_dir_path.relative_to(root).as_posix()
# 使用以 '/' 结尾的虚拟相对路径,代表这是一个目录
virtual_dir_path = f"{rel_dir_path}/"
results.append((dirname, virtual_dir_path))
if len(results) >= max_entries:
return results
# 4. 宽度限额保护:单层目录限制最多只读取 500 个文件,防止扁平超宽目录卡死
scan_filenames = filenames[:MAX_ENTRIES_PER_DIR]
for filename in scan_filenames:
full_path = Path(dirpath) / filename
# 计算相对于根路径的相对路径
rel_path = full_path.relative_to(root).as_posix()
# 存为紧凑型元组 (filename, relative_path)
results.append((filename, rel_path))
# 5. 全局上限保护:如果总文件数已达上限,熔断退出
if len(results) >= max_entries:
return results
return results
async def _read_cached_index(redis, redis_key: str) -> list[tuple[str, str]] | None:
cached_str = await redis.get(redis_key)
if not cached_str:
return None
try:
packed_bytes = base64.b64decode(cached_str)
return ormsgpack.unpackb(packed_bytes)
except Exception as e:
logger.warning(f"Failed to unpack mention cache {redis_key}: {e}")
return None
async def _write_cached_index(redis, redis_key: str, entries: list[tuple[str, str]]) -> None:
try:
packed_bytes = ormsgpack.packb(entries)
packed_str = base64.b64encode(packed_bytes).decode("ascii")
await redis.set(redis_key, packed_str, ex=CACHE_TTL)
except Exception as e:
logger.warning(f"Failed to write mention cache {redis_key}: {e}")
def _normalize_sources(sources: Sequence[str] | None, *, has_thread: bool) -> tuple[str, ...]:
if not sources:
return ("thread", "workspace") if has_thread else ("workspace",)
normalized = []
for source in sources:
value = str(source or "").strip().lower()
if value in MENTION_SOURCES and value not in normalized:
normalized.append(value)
if not has_thread:
normalized = [source for source in normalized if source == "workspace"]
return tuple(normalized or (["workspace"] if not has_thread else ["thread", "workspace"]))
def _workspace_root(uid: str) -> Path:
return sandbox_workspace_dir(WORKSPACE_THREAD_PLACEHOLDER, uid)
async def _scan_virtual_root(root: Path, virtual_prefix: str, max_entries: int) -> list[tuple[str, str]]:
scan_results = await asyncio.to_thread(_scan_pruned_files, root, max_entries)
return [
(name, f"{virtual_prefix}/{rel_path}" if rel_path and rel_path != "." else virtual_prefix)
for name, rel_path in scan_results
]
async def get_or_build_workspace_index(uid: str) -> list[tuple[str, str]]:
redis = await get_redis_client()
redis_key = f"{WORKSPACE_CACHE_PREFIX}{uid}"
cached = await _read_cached_index(redis, redis_key)
if cached is not None:
return cached
entries = await _scan_virtual_root(_workspace_root(uid), "workspace", MAX_CACHED_ENTRIES)
await _write_cached_index(redis, redis_key, entries)
return entries
async def get_or_build_thread_index(thread_id: str) -> list[tuple[str, str]]:
redis = await get_redis_client()
redis_key = f"{THREAD_CACHE_PREFIX}{thread_id}"
cached = await _read_cached_index(redis, redis_key)
if cached is not None:
return cached
entries: list[tuple[str, str]] = []
for virtual_prefix, root in (
("uploads", sandbox_uploads_dir(thread_id)),
("outputs", sandbox_outputs_dir(thread_id)),
):
needed = MAX_CACHED_ENTRIES - len(entries)
if needed <= 0:
break
entries.extend(await _scan_virtual_root(root, virtual_prefix, needed))
await _write_cached_index(redis, redis_key, entries)
return entries
async def get_or_build_file_index(
thread_id: str | None,
uid: str,
sources: Sequence[str] | None = None,
) -> list[tuple[str, str, str]]:
"""获取或构建当前可提及文件索引workspace 与 thread 缓存分离。"""
selected_sources = _normalize_sources(sources, has_thread=bool(thread_id))
entries: list[tuple[str, str, str]] = []
for source in selected_sources:
if source == "thread" and thread_id:
entries.extend(
(name, virtual_path, "thread") for name, virtual_path in await get_or_build_thread_index(thread_id)
)
elif source == "workspace":
entries.extend(
(name, virtual_path, "workspace") for name, virtual_path in await get_or_build_workspace_index(uid)
)
return entries
def _rank_mention_entries(index: list[tuple[str, str, str]], query: str) -> list[dict]:
query_lower = query.lower()
prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/")
name_matched = []
path_matched = []
for name, virtual_path, source in index:
name_lower = name.lower()
path_lower = virtual_path.lower()
is_dir = virtual_path.endswith("/")
if query_lower in name_lower:
if name_lower == query_lower:
score = 1000.0
else:
score = 500.0
if name_lower.startswith(query_lower):
score += 50.0
if name_lower.endswith(query_lower):
score += 20.0
start_idx = name_lower.find(query_lower)
if start_idx != -1:
score -= min(start_idx, 30.0)
score -= min(len(name) * 0.5, 50.0)
name_matched.append(
{"name": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "source": source, "score": score}
)
elif query_lower in path_lower:
score = 10.0 - min(len(virtual_path) * 0.1, 5.0)
path_matched.append(
{"name": name, "path": f"{prefix}/{virtual_path}", "is_dir": is_dir, "source": source, "score": score}
)
name_matched.sort(key=lambda x: -x["score"])
path_matched.sort(key=lambda x: len(x["path"]))
return [*name_matched, *path_matched]
async def search_mention_files_in_index(
thread_id: str | None,
uid: str,
query: str,
sources: Sequence[str] | None = None,
) -> list[dict]:
"""搜索可提及文件;未绑定 thread 时只搜索用户 workspace。"""
if not query:
return []
selected_sources = _normalize_sources(sources, has_thread=bool(thread_id))
results: list[dict] = []
for source in selected_sources:
source_index = await get_or_build_file_index(thread_id, uid, [source])
source_results = _rank_mention_entries(source_index, query)
remaining = MAX_MENTION_RESULTS - len(results)
if remaining <= 0:
break
results.extend(source_results[:remaining])
return [
{"name": item["name"], "path": item["path"], "is_dir": item["is_dir"], "source": item["source"]}
for item in results[:MAX_MENTION_RESULTS]
]
async def invalidate_mention_cache(thread_id: str) -> None:
"""清理指定 thread 的提及文件缓存。"""
try:
redis = await get_redis_client()
await redis.delete(f"{THREAD_CACHE_PREFIX}{thread_id}")
await redis.delete(f"{REDIS_KEY_PREFIX}{thread_id}")
except Exception as e:
logger.warning(f"Failed to invalidate mention cache for thread {thread_id}: {e}")
async def invalidate_workspace_mention_cache(uid: str) -> None:
"""清理指定用户 workspace 的提及文件缓存。"""
try:
redis = await get_redis_client()
await redis.delete(f"{WORKSPACE_CACHE_PREFIX}{uid}")
except Exception as e:
logger.warning(f"Failed to invalidate workspace mention cache for uid {uid}: {e}")