Skip to content

Commit defb6cf

Browse files
committed
[B2] Stop empty-project AI auto completion & RAG leakage
1 parent 1ee1872 commit defb6cf

File tree

7 files changed

+153
-8
lines changed

7 files changed

+153
-8
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
22
B1,Raise max_tokens UI upper limit,"Increase AI config `max_tokens` input upper bound from 4000 to >=1000000 and ensure load/save does not clamp.","AI config dialog accepts >=1000000; typing first digit does not block 4+ digits; value persists to config.","python -m pytest -q tests/test_ai_config_max_tokens.py",none,DONE,DONE,TODO,"src/gui/ai/unified_ai_config_dialog.py | tests/test_ai_config_max_tokens.py",none,"done_at:2026-02-10 | test:pytest -q tests/test_ai_config_max_tokens.py"
3-
B2,Stop empty-project AI auto completion & RAG leakage,"Prevent auto completion from triggering for empty/short context and when no project is open. Ensure project change clearing emits signals and unbinds vector store; skip RAG search on empty/placeholder queries; default completion_mode to manual when unset.","New empty project/document does not auto-trigger AI completion; switching to no-project clears vector store; RAG not queried for empty/placeholder queries.","python -m pytest -q tests/test_empty_project_completion_guards.py",none,TODO,TODO,TODO,"src/core/shared.py | src/gui/services/index_scheduler.py | src/application/ai_context.py | src/gui/ai/enhanced_ai_manager.py | src/gui/ai/unified_ai_config_dialog.py | tests/test_empty_project_completion_guards.py",none,none
3+
B2,Stop empty-project AI auto completion & RAG leakage,"Prevent auto completion from triggering for empty/short context and when no project is open. Ensure project change clearing emits signals and unbinds vector store; skip RAG search on empty/placeholder queries; default completion_mode to manual when unset.","New empty project/document does not auto-trigger AI completion; switching to no-project clears vector store; RAG not queried for empty/placeholder queries.","python -m pytest -q tests/test_empty_project_completion_guards.py",none,DONE,DONE,TODO,"src/core/shared.py | src/gui/services/index_scheduler.py | src/application/ai_context.py | src/gui/ai/enhanced_ai_manager.py | src/gui/ai/unified_ai_config_dialog.py | tests/test_empty_project_completion_guards.py",none,"done_at:2026-02-10 | test:pytest -q tests/test_empty_project_completion_guards.py"
44
B3,Fix bottom status bar clipping/overlap,"Fix bottom UI layout so status bar/editor status frame does not clip or overlap at common DPI/font sizes by removing hardcoded fixed heights and using size hints/font metrics; add a Qt geometry test.","Status bar and editor bottom status frame render without clipping/overlap in Qt geometry test at default and larger fonts.","python -m pytest -q tests/test_status_bar_layout.py",none,TODO,TODO,TODO,"src/gui/status/status_bar.py | src/gui/editor/editor_panel.py | tests/test_status_bar_layout.py",none,none

src/application/ai_context.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,16 @@ def _search_rag_relevant(self, text: str, cursor_pos: int, mode: str) -> str:
188188

189189
# RAG检索
190190
if self.rag_service:
191+
placeholder_queries = {"默认查询内容", "默认查询", "强制默认查询"}
192+
query_stripped = (query_text or "").strip()
193+
if (
194+
(not query_stripped)
195+
or len(query_stripped) < 5
196+
or query_stripped in placeholder_queries
197+
):
198+
logger.debug("跳过RAG检索:查询为空/过短/占位符 query=%r", query_stripped)
199+
return ""
200+
191201
context_mode = {"fast": "fast", "balanced": "balanced", "full": "full"}
192202
planned_tokens = []
193203
try:

src/core/shared.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,17 @@ def current_project_path(self) -> Optional[Path]:
6060
def current_project_path(self, path: Path | str | None):
6161
"""设置当前项目路径"""
6262
if isinstance(path, str):
63-
path = Path(path)
63+
if not path.strip():
64+
path = None
65+
else:
66+
path = Path(path)
6467
if self._current_project_path != path:
6568
self._current_project_path = path
69+
self.projectChanged.emit(str(path) if path else "")
6670
if path:
67-
self.projectChanged.emit(str(path))
6871
logger.info(f"Current project changed to: {path}")
72+
else:
73+
logger.info("Current project cleared")
6974

7075
@property
7176
def current_document_id(self) -> Optional[str]:

src/gui/ai/enhanced_ai_manager.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,8 +574,21 @@ def _on_text_changed(self):
574574

575575
# 获取当前文本和光标位置
576576
cursor = self._current_editor.textCursor()
577-
context = self._current_editor.toPlainText()
577+
context = self._current_editor.toPlainText() or ""
578578
cursor_pos = cursor.position()
579+
580+
# 空/短文本不触发自动补全(避免空白项目“自发补全”和无意义RAG查询)
581+
try:
582+
min_chars = int(self._config.get("ai", "min_chars", 3) or 3)
583+
except Exception:
584+
min_chars = 3
585+
if len(context.strip()) < max(1, min_chars):
586+
return
587+
588+
# 无项目路径时不触发自动补全(避免跨项目检索泄露/污染)
589+
project_path = getattr(self._shared, "current_project_path", None) if self._shared else None
590+
if not project_path:
591+
return
579592

580593
# 调度自动补全
581594
self.schedule_completion(context, cursor_pos)

src/gui/ai/unified_ai_config_dialog.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,7 +1026,7 @@ def set_settings(self, settings: Dict[str, Any]):
10261026
self.punctuation_assist.setChecked(settings.get('punctuation_assist', True))
10271027
self.trigger_delay_slider.setValue(settings.get('trigger_delay', 500))
10281028

1029-
mode = settings.get('completion_mode', '自动AI补全')
1029+
mode = settings.get('completion_mode', '手动AI补全')
10301030
index = self.completion_mode.findText(mode)
10311031
if index >= 0:
10321032
self.completion_mode.setCurrentIndex(index)
@@ -1217,7 +1217,7 @@ def _load_config(self):
12171217
'full': '全局模式 (200K+ tokens)'
12181218
}
12191219

1220-
mode_internal = self._config.get('ai', 'completion_mode', 'auto_ai')
1220+
mode_internal = self._config.get('ai', 'completion_mode', 'manual_ai')
12211221
mode_display = mode_reverse_mapping.get(mode_internal, '手动AI补全') # 修复:默认手动模式
12221222

12231223
context_internal = self._config.get('ai', 'context_mode', 'balanced')
@@ -1352,7 +1352,7 @@ def _save_config(self):
13521352
}
13531353

13541354
mode_display = completion_settings.get('completion_mode', '手动AI补全') # 修复:默认手动模式
1355-
mode_internal = mode_mapping.get(mode_display, 'auto_ai')
1355+
mode_internal = mode_mapping.get(mode_display, 'manual_ai')
13561356

13571357
context_display = completion_settings.get('context_mode', '平衡模式 (2-8K tokens)')
13581358
context_internal = context_mapping.get(context_display, 'balanced')

src/gui/services/index_scheduler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ def _on_document_saved(self, document_id: str, content: str) -> None:
9797
def _on_project_changed(self, project_path: str) -> None:
9898
logger.debug("IndexScheduler received projectChanged: %s", project_path)
9999
if not project_path:
100-
self.schedule_full_scan()
100+
# No active project: unbind vector store to avoid cross-project retrieval leaks.
101+
self._disable_rag()
101102
return
102103

103104
try:
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
9+
10+
pytest.importorskip("PyQt6")
11+
pytest.importorskip("pytestqt")
12+
13+
from PyQt6.QtTest import QSignalSpy
14+
15+
from application.ai_context import IntelligentContextBuilder
16+
from core.shared import Shared
17+
from gui.services.index_scheduler import IndexScheduler
18+
19+
20+
class _DummyConfig:
21+
def get(self, section: str, key: str, default=None):
22+
return default
23+
24+
25+
def test_shared_emits_project_changed_on_clear(qtbot) -> None:
26+
shared = Shared(_DummyConfig())
27+
28+
spy = QSignalSpy(shared.projectChanged)
29+
shared.current_project_path = Path("C:/tmp/project_one")
30+
shared.current_project_path = None
31+
32+
assert len(spy) >= 2
33+
assert spy[-1][0] == ""
34+
35+
36+
def test_shared_treats_empty_string_project_path_as_clear(qtbot) -> None:
37+
shared = Shared(_DummyConfig())
38+
39+
spy = QSignalSpy(shared.projectChanged)
40+
shared.current_project_path = Path("C:/tmp/project_one")
41+
shared.current_project_path = ""
42+
43+
assert shared.current_project_path is None
44+
assert len(spy) >= 2
45+
assert spy[-1][0] == ""
46+
47+
48+
class _DummyTaskManager:
49+
def __init__(self) -> None:
50+
self.cancelled_prefixes: list[str] = []
51+
52+
def cancel_prefix(self, prefix: str) -> None:
53+
self.cancelled_prefixes.append(prefix)
54+
55+
56+
class _DummyRagService:
57+
def __init__(self) -> None:
58+
self.set_vector_store_calls: list[object | None] = []
59+
60+
def set_vector_store(self, store) -> None:
61+
self.set_vector_store_calls.append(store)
62+
63+
64+
class _DummyShared:
65+
def __init__(self) -> None:
66+
self.rag_service = _DummyRagService()
67+
self.vector_store = object()
68+
69+
70+
def test_index_scheduler_unbinds_vector_store_on_empty_project_path(qtbot) -> None:
71+
task_manager = _DummyTaskManager()
72+
scheduler = IndexScheduler(task_manager)
73+
74+
shared = _DummyShared()
75+
scheduler._shared = shared
76+
77+
scheduler._on_project_changed("")
78+
79+
assert scheduler._rag_disabled is True
80+
assert shared.vector_store is None
81+
assert shared.rag_service.set_vector_store_calls == [None]
82+
assert "rag/" in task_manager.cancelled_prefixes
83+
84+
85+
class _RaisingRagService:
86+
def search_with_like_tokens(self, *args, **kwargs):
87+
raise AssertionError("RAG search should have been skipped")
88+
89+
def search_with_context(self, *args, **kwargs):
90+
raise AssertionError("RAG search should have been skipped")
91+
92+
93+
class _StubContextResult:
94+
def __init__(self, rag_query: str) -> None:
95+
self.rag_query = rag_query
96+
self.detected_entities = []
97+
self.primary_keywords = []
98+
99+
100+
class _StubCollector:
101+
def __init__(self, rag_query: str) -> None:
102+
self._rag_query = rag_query
103+
104+
def collect_context_for_completion(self, text: str, cursor_position: int):
105+
return _StubContextResult(self._rag_query)
106+
107+
108+
@pytest.mark.parametrize("rag_query", ["", "默认查询内容", "默认查询", "强制默认查询"])
109+
def test_context_builder_skips_rag_for_empty_or_placeholder_query(rag_query: str) -> None:
110+
builder = IntelligentContextBuilder(shared=None)
111+
builder.rag_service = _RaisingRagService()
112+
builder._intelligent_context_collector = _StubCollector(rag_query)
113+
114+
result = builder._search_rag_relevant("任意文本", cursor_pos=0, mode="balanced")
115+
assert result == ""
116+

0 commit comments

Comments
 (0)