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:
2026-09-22 08:39:22 +09:00
co-authored by Claude Opus 5
parent 76225aa118
commit b500b3e57d
3 changed files with 254 additions and 58 deletions
+83
View File
@@ -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()
+4 -58
View File
@@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import on_language_changed, tr from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.graph import graph_export from cowork_local.presentation.graph import graph_export
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView 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_builder import build_scene
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
from cowork_local.presentation.shared import HAS_WEB_ENGINE 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 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.""" """Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
status_message = Signal(str) status_message = Signal(str)
node_selected = Signal(object) # a node's .data, whenever the scene selection changes 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._needs_scan = False
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) 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 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 = QTimer(self)
self._rescan_timer.setSingleShot(True) 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)] 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 ---------------------------------------------------------------- # # ---- helpers ---------------------------------------------------------------- #
def _pick(self) -> None: def _pick(self) -> None:
"""Mở hộp thoại chọn thư mục gốc để quét.""" """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