fix(graphrag): đổi thư mục project thì quét lại đồ thị theo thư mục mới
Sau khi đường dẫn trên thanh đã trỏ đúng thư mục mới, các node giữa màn vẫn là
của thư mục cũ: không có lệnh quét lại nào được phát ra.
Cùng một họ sai lầm với hai mảnh trước — câu hỏi "có gì đổi không" trả lời bằng
project id chứ không bằng thứ quyết định kết quả quét:
project_changed = pid != self._active_project_id
Đổi thư mục giữ nguyên id, nên project_changed là False và cả khối phát tín
hiệu lẫn khối gọi _scan() đều bị bỏ qua, trong khi dòng đặt path_edit lại nằm
ngoài khối đó — thanh địa chỉ đúng mà đồ thị đứng yên.
Tách khối "project sandbox lock" sang graph_project_lock.py: graph_renderer.py
đang ở 399/400 dòng, đúng một dòng trước trần của scripts/check_loc.py, và cổng
đó nói rõ cách duy nhất đúng khi chạm trần là tách file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Khoá phạm vi quét của màn GraphRAG vào một project.
|
||||
|
||||
Tách khỏi ``graph_renderer.py``: file đó đã ở 399/400 dòng — đúng một dòng
|
||||
trước trần của ``scripts/check_loc.py``, và cổng ấy nói rõ cách duy nhất đúng
|
||||
khi chạm trần là tách file, không phải nới con số. Khối này là chỗ tự nhiên
|
||||
để cắt: ba phương thức dưới đây chỉ nói về một việc — project nào đang khoá,
|
||||
và thư mục nào đi theo nó — còn phần còn lại của renderer lo việc vẽ.
|
||||
|
||||
Là mixin chứ không phải đối tượng rời, cùng lý do như ``NavRailMixin``: ba
|
||||
phương thức này đọc/ghi state của chính renderer (``project_combo``,
|
||||
``path_edit``, ``_needs_scan``…). Biến thành đối tượng cộng tác thì phải viết
|
||||
lại từng chỗ ``self.X`` thành ``self.renderer.X`` mà không đổi hành vi gì.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphProjectLockMixin:
|
||||
"""Ba phương thức khoá-theo-project. Trộn vào ``GraphRenderer``."""
|
||||
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
# Changing the FOLDER changes what we scan just as much as changing the
|
||||
# project does. Keying this off the id alone left the path in the bar
|
||||
# updated while the graph in the middle still showed the old folder's
|
||||
# nodes: "Đổi" in the Project screen moves the folder, never the id.
|
||||
duong_dan = self.path_edit.text().strip()
|
||||
doi_muc_tieu = project_changed or duong_dan != self._active_path
|
||||
self._active_path = duong_dan
|
||||
if doi_muc_tieu:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and duong_dan:
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
@@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph import graph_export
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_project_lock import GraphProjectLockMixin
|
||||
from cowork_local.presentation.graph.graph_scene_builder import build_scene
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
@@ -35,7 +36,7 @@ from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
class GraphRenderer(GraphProjectLockMixin, QWidget):
|
||||
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
@@ -60,6 +61,8 @@ class GraphRenderer(QWidget):
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
# The folder last scanned — see graph_project_lock.py.
|
||||
self._active_path = ""
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
@@ -154,63 +157,6 @@ class GraphRenderer(QWidget):
|
||||
"""
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Đổi thư mục project thì đồ thị giữa màn GraphRAG phải quét lại.
|
||||
|
||||
Nối tiếp ``test_graphrag_follows_folder_change.py``. Sau khi đường dẫn trên
|
||||
thanh đã trỏ đúng thư mục mới, các node ở giữa màn vẫn là của thư mục cũ: không
|
||||
có lệnh quét lại nào được phát ra.
|
||||
|
||||
Nguyên nhân cùng một họ với hai mảnh trước — câu hỏi "có gì đổi không" được trả
|
||||
lời bằng project id chứ không bằng thứ thật sự quyết định kết quả quét:
|
||||
|
||||
project_changed = pid != self._active_project_id
|
||||
|
||||
Đổi thư mục làm việc ở màn Project giữ nguyên id, nên ``project_changed`` là
|
||||
False và cả khối phát tín hiệu lẫn khối gọi ``_scan()`` đều bị bỏ qua.
|
||||
|
||||
Cố ý KHÔNG dựng ``GraphRenderer`` thật: nó kéo theo QtWebEngine, dựng trong bộ
|
||||
``tests/ui`` làm cả bộ chết giữa chừng (xem docstring của
|
||||
``test_graphrag_follows_folder_change.py``). ``_on_project_changed`` là Python
|
||||
thuần trên các thuộc tính của chính nó, nên gọi thẳng với một ``self`` giả là đủ
|
||||
và đúng hơn — bài test chốt luồng quyết định, không chốt phần vẽ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module renderer")
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
|
||||
class _O:
|
||||
"""Vật thể ghi lại lời gọi, thay cho một widget Qt."""
|
||||
|
||||
def __init__(self, **thuoc_tinh):
|
||||
self.__dict__.update(thuoc_tinh)
|
||||
self.da_goi = []
|
||||
|
||||
def __getattr__(self, ten):
|
||||
def ghi(*args, **kwargs):
|
||||
self.da_goi.append((ten, args))
|
||||
return ghi
|
||||
|
||||
|
||||
class _ComboGia:
|
||||
def __init__(self, pid):
|
||||
self._pid = pid
|
||||
|
||||
def currentData(self):
|
||||
return self._pid
|
||||
|
||||
|
||||
class _OGia:
|
||||
"""Ô nhập đường dẫn: giữ được chữ, và ghi lại việc bị khoá."""
|
||||
|
||||
def __init__(self, text=""):
|
||||
self._text = text
|
||||
self.read_only = False
|
||||
|
||||
def text(self):
|
||||
return self._text
|
||||
|
||||
def setText(self, value):
|
||||
self._text = value
|
||||
|
||||
def setReadOnly(self, value):
|
||||
self.read_only = value
|
||||
|
||||
|
||||
class _Renderer:
|
||||
"""``self`` giả cho ``GraphRenderer._on_project_changed``."""
|
||||
|
||||
def __init__(self, pid, active_id="", active_path="", hien=True):
|
||||
self.project_combo = _ComboGia(pid)
|
||||
self.path_edit = _OGia()
|
||||
self._pick_btn = _O()
|
||||
self.project_changed = _O()
|
||||
self._active_project_id = active_id
|
||||
self._active_path = active_path
|
||||
self._needs_scan = False
|
||||
self._hien = hien
|
||||
self.lan_quet = 0
|
||||
|
||||
def isVisible(self):
|
||||
return self._hien
|
||||
|
||||
def _scan(self):
|
||||
self.lan_quet += 1
|
||||
|
||||
# -- tiện cho khẳng định --------------------------------------------- #
|
||||
@property
|
||||
def so_lan_bao_doi(self):
|
||||
"""Số lần phát tín hiệu "đã đổi mục tiêu"."""
|
||||
return sum(1 for ten, _ in self.project_changed.da_goi if ten == "emit")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def du_an(monkeypatch, tmp_path):
|
||||
"""Một project duy nhất trong kho giả, trỏ vào ``tmp_path/cu``."""
|
||||
p = Project(project_id="p1", name="test", output_dir=str(tmp_path / "cu"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: p if pid == "p1" else None)
|
||||
return p
|
||||
|
||||
|
||||
def _chay(renderer):
|
||||
"""Gọi đúng hàm thật với ``self`` giả."""
|
||||
GraphRenderer._on_project_changed(renderer, 0)
|
||||
|
||||
|
||||
def test_cung_project_thu_muc_moi_thi_quet_lai(du_an, tmp_path):
|
||||
"""Đây là chỗ hỏng người dùng báo: đường dẫn đổi mà node giữa màn thì không."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r) # lần đầu: khoá vào project
|
||||
assert r.lan_quet == 1
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi") # người dùng bấm "Đổi" ở tab Project
|
||||
_chay(r)
|
||||
|
||||
assert r.path_edit.text() == str(tmp_path / "moi")
|
||||
assert r.lan_quet == 2, "đổi thư mục xong nhưng không quét lại — node vẫn của thư mục cũ"
|
||||
assert r.so_lan_bao_doi == 2, (
|
||||
"phải báo đổi để khung hỏi-đáp bỏ phần trích xuất của thư mục cũ")
|
||||
|
||||
|
||||
def test_cung_project_cung_thu_muc_thi_khong_quet_lai(du_an):
|
||||
"""Bảo vệ sẵn có: ``_bind_project`` chạy lại mỗi lần vào lại màn Workspace,
|
||||
quét lại vô cớ là vừa giật vừa tốn."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
assert r.lan_quet == 1
|
||||
|
||||
_chay(r)
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 1
|
||||
|
||||
|
||||
def test_doi_sang_project_khac_van_quet_lai(du_an, tmp_path, monkeypatch):
|
||||
"""Hành vi vốn có, không được mất."""
|
||||
khac = Project(project_id="p2", name="khac", output_dir=str(tmp_path / "cua-p2"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: du_an if pid == "p1" else khac)
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
|
||||
r.project_combo._pid = "p2"
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 2
|
||||
assert r.path_edit.text() == str(tmp_path / "cua-p2")
|
||||
|
||||
|
||||
def test_man_dang_an_thi_hoan_quet_chu_khong_quet_ngay(du_an, tmp_path):
|
||||
"""Đổi thư mục từ màn Project trong khi GraphRAG đang ẩn: đánh dấu để quét
|
||||
ở lần vào sau, đúng luật hoãn mà ``auto_scan_and_fit`` dựa vào."""
|
||||
r = _Renderer("p1", hien=False)
|
||||
_chay(r)
|
||||
# Lần khoá đầu tiên đã đặt cờ rồi; xoá đi để bài này thật sự kiểm được
|
||||
# lần ĐỔI THƯ MỤC, chứ không xanh nhờ cờ còn sót của lần trước.
|
||||
r._needs_scan = False
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi")
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 0
|
||||
assert r._needs_scan is True
|
||||
Reference in New Issue
Block a user