refactor(graph): R08-T14 — structure_graph_view.py 1034 -> 11, tách 6 file

presentation/graph/
      structure_graph_view.py  325  lớp chính + dựng giao diện
      graph_qa_widget.py       322  hỏi-đáp trên đồ thị (_ask 119 dòng)
      graph_render.py          226  quét, vẽ Qt + D3, xuất ảnh
      graph_scene.py           138  node, cạnh, khung nhìn — thuần đồ hoạ
      graph_project.py         109  chọn project, đổi tab xem
      graph_web.py              38  cờ có dùng được QtWebEngine không
    ui/structure_graph_view.py  11  vỏ chuyển tiếp, giữ đường import cũ

BA LẦN CẮT HỎNG, ĐỀU LÀ TÊN CẤP MODULE BỊ BỎ LẠI
------------------------------------------------
_HAS_WEB, QWebEngineView, QWebChannel, _Bridge, _Edge, _Node — tất cả định
nghĩa ở file gốc, dùng ở file mới, nên NameError ngay lúc chạy. Bộ test đơn
vị KHÔNG bắt được cái nào: 756 bài vẫn xanh suốt ba lần. Chỉ
check_graphrag_rescan bắt, vì nó gọi prewarm() thật rồi chờ đồ thị dựng xong.

Sau lần thứ ba tôi bỏ cách đuổi từng lỗi và viết bộ dò tên chưa định nghĩa có
tính đến phạm vi hàm (tham số, biến cục bộ, except-as, comprehension). Nó
tìm ra nốt _fmt_plan và _qcolor còn thiếu ở hai file Co4E đã tách hôm trước —
hai quả mìn chưa nổ.

_HAS_WEB tách hẳn ra graph_web.py: cả structure_graph_view.py lẫn
graph_render.py đều phải hỏi, để ở một trong hai là vòng import.

756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 22:45:25 +09:00
co-authored by Claude Opus 5
parent 062ea4ba21
commit 4fef41481b
7 changed files with 1169 additions and 1029 deletions
+325
View File
@@ -0,0 +1,325 @@
"""Structure (RAG) tab — knowledge graph of code / document structure.
Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates
when idle and opens a node's storage folder on click. If WebEngine isn't
available (e.g. the standalone .exe), a native draggable QGraphicsView is the
in-app fallback. The graph auto-updates when the Code agent produces output,
and an Agent box on the right answers questions over the graph (Graph-RAG).
"""
from __future__ import annotations
from .graph_qa_widget import GraphQaMixin
from .graph_project import GraphProjectMixin
from .graph_web import _HAS_WEB, QWebChannel, QWebEngineView, _frozen_onefile
from .graph_render import GraphRenderMixin
from .graph_scene import _Edge, _GraphView, _Node
import re
import sys
from pathlib import Path
from PySide6.QtCore import QPointF, Qt, QTimer, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QComboBox, QFileDialog, QGraphicsScene, QGraphicsView, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, QTextBrowser, QVBoxLayout, QWidget
from ...theme import current_palette
from ...core.worker import AgentWorker
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...ui.icons import collapse_right_icon, icon
from ...ui.widgets import CollapseStrip
try:
from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available
except Exception:
pass
class StructureGraphView(GraphQaMixin, GraphRenderMixin,
GraphProjectMixin, QWidget):
status_message = Signal(str)
def __init__(self, ctx: AppContext):
super().__init__()
self.ctx = ctx
self._worker: AgentWorker | None = None
self._node_items: list[_Node] = []
self._edge_items: list[_Edge] = []
self._centroid = QPointF(0, 0)
self._link = 120
self._graph = None
self._needs_scan = False
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
self._ask_worker: AgentWorker | None = None
self._answer = ""
self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows
# TEMPORARY extracted file content for Q&A (real content, not just the
# graph structure). Kept only while this tab is shown — cleared on leaving
# the tab or switching project/root (see _clear_extracts / hideEvent).
self._extract_cache: dict = {} # path -> extracted text
self._extract_dir = None # temp folder for md/json dumps
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
self._rescan_timer = QTimer(self)
self._rescan_timer.setSingleShot(True)
self._rescan_timer.setInterval(1500)
self._rescan_timer.timeout.connect(self._scan)
root = QVBoxLayout(self)
bar = QHBoxLayout()
self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir()))
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
self._pick_btn = QPushButton()
self._pick_btn.setIcon(icon("folder"))
self._pick_btn.setObjectName("primary")
self._pick_btn.clicked.connect(self._pick)
self.project_combo = QComboBox()
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
self._scan_btn = QPushButton()
self._scan_btn.setIcon(icon("search"))
self._scan_btn.setObjectName("primary")
self._scan_btn.clicked.connect(self._scan)
# ONE toolbar row. There used to be a second row holding just the
# messages toggle and Export, which cost a whole row of height to carry
# two buttons.
self._export_btn = QPushButton()
self._export_btn.setIcon(icon("upload"))
self._export_btn.setObjectName("primary")
self._export_btn.clicked.connect(self._export)
bar.addWidget(self.path_edit, 1)
bar.addWidget(self._pick_btn)
bar.addWidget(self.project_combo)
bar.addWidget(self._scan_btn)
bar.addWidget(self._export_btn)
root.addLayout(bar)
self._refresh_project_combo()
# Đồ thị | Tin nhắn as a real pair of tabs: the old single button
# relabelled itself, so the view you were NOT looking at was the only
# one named on screen.
self.view_tabs = QTabBar()
self.view_tabs.setObjectName("viewTabs")
self.view_tabs.setDrawBase(False)
self.view_tabs.setExpanding(False)
self.view_tabs.addTab(icon("graph"), "")
self.view_tabs.addTab(icon("message"), "")
self.view_tabs.currentChanged.connect(self._on_view_tab)
tab_row = QHBoxLayout()
tab_row.setContentsMargins(0, 0, 0, 0)
tab_row.addWidget(self.view_tabs)
tab_row.addStretch(1)
root.addLayout(tab_row)
split = QSplitter(Qt.Horizontal)
self.scene = QGraphicsScene()
self.scene.setBackgroundBrush(QColor(current_palette().bg))
self.scene.selectionChanged.connect(self._on_selection)
self.view = _GraphView(self.scene)
self._stack = QStackedWidget()
self._stack.addWidget(self.view)
# A "Messages" view: all conversation messages grouped BY DAY, shown as
# JSON — a plain tree switched in via setCurrentWidget (never touches the
# D3/WebEngine graph). Populated from the (project-scoped) history store.
from PySide6.QtWidgets import QTreeWidget
self._msgs_view = QTreeWidget()
self._msgs_view.setHeaderHidden(True)
self._msgs_view.itemClicked.connect(self._show_msg_json)
self._stack.addWidget(self._msgs_view)
self.web = None
self._bridge = None
self._channel = None
# The legend + Show-relationship control live INSIDE the D3 graph
# template now (assets/graph_template.html) — the graph column is just
# the stack (native view / D3 web / messages).
split.addWidget(self._stack)
# Right-side agent panel (GraphRAG Q&A)
right = QWidget()
rl = QVBoxLayout(right)
rl.setContentsMargins(0, 0, 0, 0)
# Agent panel header with collapse button
ag_hdr = QHBoxLayout()
self._ag_collapse = QPushButton()
self._ag_collapse.setIcon(collapse_right_icon())
self._ag_collapse.setFixedWidth(28)
self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True))
self._ag_label = QLabel()
ag_hdr.addWidget(self._ag_collapse)
ag_hdr.addWidget(self._ag_label, 1)
rl.addLayout(ag_hdr)
# Ask row
ask_row = QHBoxLayout()
self.ask_edit = QLineEdit()
self.ask_edit.returnPressed.connect(self._ask)
self._ask_btn = QPushButton()
self._ask_btn.setIcon(icon("chat"))
self._ask_btn.setObjectName("primary")
self._ask_btn.clicked.connect(self._ask)
ask_row.addWidget(self.ask_edit, 1)
ask_row.addWidget(self._ask_btn)
rl.addLayout(ask_row)
# Detail browser
self.detail = QTextBrowser()
self.detail.setReadOnly(True)
self.detail.setOpenLinks(False)
self.detail.anchorClicked.connect(self._on_detail_link)
rl.addWidget(self.detail, 1)
self._agent_panel = right
self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False))
self._agent_strip.setVisible(False)
self._agent_pane = QWidget()
apl = QHBoxLayout(self._agent_pane)
apl.setContentsMargins(0, 0, 0, 0)
apl.setSpacing(0)
apl.addWidget(self._agent_strip)
apl.addWidget(right, 1)
self._split = split
split.addWidget(self._agent_pane)
split.setChildrenCollapsible(False)
split.setSizes([840, 320])
root.addWidget(split, 1)
on_language_changed(self._retranslate)
# ---- project sandbox lock -----------------------------------------
# ---- helpers -----------------------------------------------------
# ---- Messages (by day, as JSON) --------------------------------------
# ---- scan --------------------------------------------------------
# ---- native interactions ----------------------------------------
# ---- agent Q&A over the graph -----------------------------------
# ---- temporary file-content extraction for Q&A ------------------------
def hideEvent(self, e): # noqa: N802
# Leaving the GraphRAG tab → drop the temporary extracted info.
self._clear_extracts()
super().hideEvent(e)
# --------------------------------------------------------------------------
# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker)
# --------------------------------------------------------------------------
def _pdf_to_markdown(pdf_path, out_dir) -> str | None:
"""Convert a PDF to Markdown with opendataloader-pdf when available (richer
structure than a plain text dump). Best-effort — returns None if the package
isn't installed or the call fails, so the caller falls back to doc_extract."""
from pathlib import Path as _P
try:
import opendataloader_pdf # optional; auto-installed elsewhere if present
except Exception: # noqa: BLE001
try:
from ...core.deps import ensure_module
if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None:
return None
import opendataloader_pdf # noqa: F811
except Exception: # noqa: BLE001
return None
out = _P(out_dir)
out.mkdir(parents=True, exist_ok=True)
for call in (
lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out),
generate_markdown=True),
lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)),
lambda: opendataloader_pdf.convert(str(pdf_path), str(out)),
):
try:
call()
break
except TypeError:
continue
except Exception: # noqa: BLE001
return None
mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md"))
for md in mds:
try:
return md.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
return None
def _extract_file_contents(paths, cache: dict, tmp_dir,
max_files: int = 15, max_total: int = 120_000):
"""Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when
available, else doc_extract for office/pdf/text). Returns ``(block, cache)``
— ``block`` is the concatenated content for the prompt (bounded), ``cache``
maps path→text for reuse. Never raises."""
from pathlib import Path as _P
from ...core import doc_extract
cache = dict(cache or {})
parts, total = [], 0
for p in paths[:max_files]:
if total >= max_total:
break
text = cache.get(p)
if text is None:
try:
if _P(p).suffix.lower() == ".pdf":
text = _pdf_to_markdown(p, tmp_dir)
if not text:
text, _n = doc_extract.extract_text(p)
else:
text, _n = doc_extract.extract_text(p)
except Exception: # noqa: BLE001
text = ""
cache[p] = text or ""
text = cache.get(p) or ""
if not text:
continue
chunk = text[: max(0, max_total - total)]
total += len(chunk)
parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}')
return ("\n\n".join(parts), cache)