CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
398 lines
18 KiB
Python
398 lines
18 KiB
Python
"""GraphQaWidget — the right-side "ask questions about this graph" panel of
|
|
GraphRAG (R08-T14, extracted from
|
|
``ui/structure_graph_view.py::StructureGraphView``, lines 288-334/342-360
|
|
(partial)/647-663/704-955 of the original 1035-line file).
|
|
|
|
Reads the current graph and scene selection from a
|
|
``graph_renderer.py::GraphRenderer`` instance passed at construction
|
|
(``renderer.graph``, ``renderer.selected_node_data()``,
|
|
``renderer.active_project_id``) and reacts to its
|
|
``node_selected``/``graph_rendered``/``raw_json_ready`` signals — this class
|
|
has no rendering state of its own, matching how
|
|
``presentation/folder/ai_file_editor_dialog.py`` reads
|
|
``DocumentPreviewManager`` rather than duplicating file state.
|
|
|
|
File-content extraction for grounding the answer goes through
|
|
``application/workspaces/graph_index_service.py`` (R08-T14 also moved that
|
|
out of this file, as pure Python — see its own docstring).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
|
|
from PySide6.QtCore import QUrl, Signal
|
|
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
|
|
|
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
|
from cowork_local.core.worker import AgentWorker
|
|
from cowork_local.i18n import tr
|
|
from cowork_local.ui.icons import collapse_right_icon, icon
|
|
from cowork_local.ui.osutil import open_folder, open_location
|
|
from cowork_local.ui.widgets import CollapseStrip
|
|
|
|
|
|
class GraphQaWidget(QWidget):
|
|
"""The collapsible pane itself (strip + header + ask row + detail
|
|
browser) — the shell adds ONE widget to its splitter."""
|
|
|
|
status_message = Signal(str)
|
|
collapse_changed = Signal(bool) # so the shell can resize its own splitter
|
|
|
|
def __init__(self, ctx, renderer, parent=None):
|
|
"""Panel Hỏi đáp bên phải màn GraphRAG.
|
|
|
|
Có cache trích xuất vì cùng một node hay bị hỏi lại nhiều lần trong một
|
|
phiên, mà mỗi lần trích lại phải đọc tệp.
|
|
"""
|
|
super().__init__(parent)
|
|
self.ctx = ctx
|
|
self._renderer = renderer
|
|
self._ask_worker: Optional[AgentWorker] = None
|
|
self._answer = ""
|
|
self._detail_mode = "idle" # "answer" | "node" | "idle"
|
|
self._extract_cache: dict = {}
|
|
self._extract_dir = None
|
|
|
|
outer = QHBoxLayout(self)
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|
outer.setSpacing(0)
|
|
self._strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
|
|
self._strip.clicked.connect(lambda: self._set_collapsed(False))
|
|
self._strip.setVisible(False)
|
|
outer.addWidget(self._strip)
|
|
|
|
self._panel = QWidget()
|
|
rl = QVBoxLayout(self._panel)
|
|
rl.setContentsMargins(0, 0, 0, 0)
|
|
ag_hdr = QHBoxLayout()
|
|
self._collapse_btn = QPushButton()
|
|
self._collapse_btn.setIcon(collapse_right_icon())
|
|
self._collapse_btn.setFixedWidth(28)
|
|
self._collapse_btn.clicked.connect(lambda: self._set_collapsed(True))
|
|
self._label = QLabel()
|
|
ag_hdr.addWidget(self._collapse_btn)
|
|
ag_hdr.addWidget(self._label, 1)
|
|
rl.addLayout(ag_hdr)
|
|
|
|
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)
|
|
|
|
self.detail = QTextBrowser()
|
|
self.detail.setReadOnly(True)
|
|
self.detail.setOpenLinks(False)
|
|
self.detail.anchorClicked.connect(self._on_detail_link)
|
|
rl.addWidget(self.detail, 1)
|
|
outer.addWidget(self._panel, 1)
|
|
|
|
renderer.node_selected.connect(self._on_node_selected)
|
|
renderer.graph_rendered.connect(self._preserve_answer)
|
|
renderer.raw_json_ready.connect(self._show_raw_json)
|
|
renderer.project_changed.connect(self.clear_extracts)
|
|
|
|
self.retranslate()
|
|
|
|
def retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, ô hỏi và tooltip."""
|
|
self._collapse_btn.setToolTip(tr("structure.collapse_agent_tooltip"))
|
|
self._label.setText(tr("structure.agent_header"))
|
|
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
|
|
self._ask_btn.setText(tr("structure.ask"))
|
|
if self._detail_mode == "idle":
|
|
self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
|
|
self._strip.setToolTip(tr("structure.expand_agent_tooltip"))
|
|
|
|
# ---- collapse ------------------------------------------------------------- #
|
|
def _set_collapsed(self, collapsed: bool) -> None:
|
|
"""Gập/mở panel hỏi-đáp và báo ra ngoài để vỏ chỉnh lại splitter."""
|
|
self._panel.setVisible(not collapsed)
|
|
self._strip.setVisible(collapsed)
|
|
self.collapse_changed.emit(collapsed)
|
|
|
|
# ---- reacting to the renderer ----------------------------------------------- #
|
|
def _on_node_selected(self, data) -> None:
|
|
"""Chọn một node trên đồ thị: hiện chi tiết của node đó ở khung dưới."""
|
|
self.detail.setPlainText(f"[{data.kind.upper()}] {data.label}\n\n{data.detail}")
|
|
self._detail_mode = "node"
|
|
|
|
def _show_raw_json(self, html_text: str) -> None:
|
|
"""Hiện JSON thô của một hội thoại được chọn ở tab Tin nhắn."""
|
|
self.detail.setHtml(html_text)
|
|
|
|
def _preserve_answer(self) -> None:
|
|
"""Vẽ lại câu trả lời AI sau khi khung chi tiết bị dùng cho việc khác — nếu"""
|
|
if self._detail_mode == "answer" and self._answer.strip():
|
|
self._render_answer()
|
|
|
|
# ---- Q&A -------------------------------------------------------------------- #
|
|
@staticmethod
|
|
def _graph_context(graph) -> str:
|
|
"""Tóm tắt đồ thị thành văn bản gọn để nhét vào prompt: gom node theo loại,"""
|
|
from collections import defaultdict
|
|
by_kind = defaultdict(list)
|
|
for n in graph.nodes:
|
|
by_kind[n.kind].append(n.label)
|
|
lines = []
|
|
for kind in ("file", "class", "function", "method", "module", "section"):
|
|
items = by_kind.get(kind, [])
|
|
if items:
|
|
lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
|
|
id2label = {n.id: n.label for n in graph.nodes}
|
|
rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
|
|
for e in graph.edges[:140]]
|
|
if rels:
|
|
lines.append("Relationships (sample):\n" + "\n".join(rels))
|
|
return "\n".join(lines)[:7000]
|
|
|
|
def _matched_sources(self, text: str):
|
|
"""Các node có tên xuất hiện trong câu trả lời — dùng để gắn link nguồn kiểm"""
|
|
graph = self._renderer.graph
|
|
if graph is None or not text:
|
|
return []
|
|
found: dict = {}
|
|
for n in graph.nodes:
|
|
if not n.path:
|
|
continue
|
|
label = n.label.rstrip("()")
|
|
if len(label) < 3:
|
|
continue
|
|
if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
|
|
found[n.path] = (n.kind, n.label, n.detail or n.path)
|
|
return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
|
|
|
|
def _linkify_files(self, text: str, sources) -> str:
|
|
"""Turn file/entity NAMES mentioned in the answer into clickable
|
|
links that open the file."""
|
|
for path, (kind, label, rel) in sources:
|
|
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
|
tokens = []
|
|
base = Path(path).name
|
|
if base and len(base) >= 3:
|
|
tokens.append(base)
|
|
lab = (label or "").rstrip("()").strip()
|
|
if lab and lab != base and len(lab) >= 3:
|
|
tokens.append(lab)
|
|
for tok in tokens:
|
|
esc = re.escape(tok)
|
|
text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
|
|
text = re.sub(rf"(?<![\w`/\\.\]\)]){esc}(?![\w`\]\(])", f"[{tok}]({href})", text)
|
|
return text
|
|
|
|
def _render_answer(self) -> None:
|
|
"""Vẽ câu trả lời dạng markdown kèm danh sách nguồn bấm mở được."""
|
|
text = self._answer
|
|
sources = self._matched_sources(text)
|
|
if sources:
|
|
text = self._linkify_files(text, sources)
|
|
lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
|
|
for path, (kind, label, rel) in sources:
|
|
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
|
kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
|
|
lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
|
|
text = "\n".join(lines)
|
|
self.detail.setMarkdown(text)
|
|
|
|
def _on_detail_link(self, url: QUrl) -> None:
|
|
"""Bấm một link trong khung chi tiết: là tệp thì mở tệp, còn lại mở bằng trình
|
|
duyệt.
|
|
"""
|
|
if url.isLocalFile():
|
|
p = url.toLocalFile()
|
|
if Path(p).is_file():
|
|
open_location(p)
|
|
else:
|
|
open_folder(p)
|
|
|
|
def _ask(self) -> None:
|
|
"""Gửi câu hỏi về đồ thị tới model, chạy ở luồng nền."""
|
|
question = self.ask_edit.text().strip()
|
|
if not question:
|
|
return
|
|
from cowork_local.core.skills import parse_skill_command
|
|
skill_prefix, question, info = parse_skill_command(question)
|
|
if info is not None:
|
|
self.detail.setMarkdown(info)
|
|
self._detail_mode = "answer"
|
|
self.ask_edit.clear()
|
|
return
|
|
graph = self._renderer.graph
|
|
if graph is None:
|
|
self.status_message.emit(tr("structure.scan_first"))
|
|
return
|
|
context = self._graph_context(graph)
|
|
file_paths = self._candidate_file_paths()
|
|
extract_cache = dict(self._extract_cache)
|
|
extract_dir = str(self._extract_tmp_dir())
|
|
self._answer = ""
|
|
self._detail_mode = "answer"
|
|
self.detail.setPlainText("…")
|
|
self.ask_edit.clear()
|
|
|
|
active_project_id = self._renderer.active_project_id
|
|
selected_nodes = self._renderer.selected_node_data()
|
|
selected_context = self._selection_context(selected_nodes, graph)
|
|
|
|
def job(worker: AgentWorker):
|
|
"""Chạy nền: ghép ngữ cảnh đồ thị + nội dung tệp đã trích rồi gọi model."""
|
|
provider = self.ctx.build_active_provider()
|
|
system = self._system_prompt(skill_prefix, active_project_id)
|
|
user_content = f"Graph context:\n{context}"
|
|
if selected_context:
|
|
user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
|
|
content_block, new_cache = extract_file_contents(file_paths, extract_cache, extract_dir)
|
|
if content_block:
|
|
user_content += ("\n\nExtracted file contents (read these to answer about file "
|
|
"details/data; cite the file path):\n" + content_block)
|
|
user_content += f"\n\nQuestion: {question}"
|
|
messages = [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user_content},
|
|
]
|
|
from cowork_local.core import agent_roles, audit_log
|
|
ok = True
|
|
try:
|
|
provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
|
|
cancel=worker.is_cancelled)
|
|
except Exception:
|
|
ok = False
|
|
raise
|
|
finally:
|
|
audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
|
|
agent_role=agent_roles.KNOWLEDGE)
|
|
return {"extracted": new_cache}
|
|
|
|
w = AgentWorker(job)
|
|
w.event.connect(self._on_ask_event)
|
|
w.finished_ok.connect(self._on_ask_done)
|
|
w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
|
|
self._ask_worker = w
|
|
w.start()
|
|
|
|
@staticmethod
|
|
def _selection_context(selected_nodes, graph) -> str:
|
|
"""Phần ngữ cảnh thêm cho các node người dùng đang chọn — câu hỏi "cái này là"""
|
|
if not selected_nodes:
|
|
return ""
|
|
node_lines = []
|
|
for nd in selected_nodes:
|
|
node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
|
|
if nd.detail:
|
|
node_lines.append(f" detail: {nd.detail}")
|
|
connected_ids = set()
|
|
for nd in selected_nodes:
|
|
for edge in graph.edges:
|
|
if edge.source == nd.id:
|
|
connected_ids.add(edge.target)
|
|
elif edge.target == nd.id:
|
|
connected_ids.add(edge.source)
|
|
connected_nodes = [n for n in graph.nodes if n.id in connected_ids]
|
|
if connected_nodes:
|
|
node_lines.append("\nConnected nodes:")
|
|
for cn in connected_nodes:
|
|
node_lines.append(f"- {cn.label} (kind: {cn.kind})")
|
|
return "\n".join(node_lines)
|
|
|
|
@staticmethod
|
|
def _system_prompt(skill_prefix: str, active_project_id: str) -> str:
|
|
"""Prompt hệ thống cho khung hỏi-đáp."""
|
|
system = ("You answer questions about a code/document knowledge graph. Use the provided "
|
|
"graph context AND the extracted file contents to retrieve, synthesize and "
|
|
"explain the answer. Be concise. Answer ONLY from what is provided (graph "
|
|
"context + extracted contents) — never invent files, functions, or facts that "
|
|
"aren't in it.\n\n"
|
|
"EACH answer MUST include source citations so the user can verify where "
|
|
"information came from. For every factual claim, file reference, or code "
|
|
"element you mention, add a citation using this format:\n\n"
|
|
" [source: filename.ext, line/section: XXX]\n\n"
|
|
"Rules for citations:\n"
|
|
" 1. Cite the EXACT file path from the graph context (use the path field).\n"
|
|
" 2. For Python files: cite the function/class name and approximate line "
|
|
" if available, or the module name.\n"
|
|
" 3. For document files (.md, .txt): cite the section heading.\n"
|
|
" 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
|
|
" 5. Place citations inline after the relevant sentence or fact.\n"
|
|
" 6. At the end of your answer, add a '---' separator followed by a "
|
|
" numbered **Sources cited:** section listing each unique source with "
|
|
" its full path so the user can click to open it.\n\n"
|
|
"Example citation format in text:\n"
|
|
" The `process_data()` function handles CSV parsing "
|
|
"[source: src/utils/parser.py, function: process_data].\n\n"
|
|
"Example end-of-answer source list:\n"
|
|
" ---\n"
|
|
" **Sources cited:**\n"
|
|
" 1. `src/utils/parser.py` — process_data function\n"
|
|
" 2. `docs/api.md` — Section: Authentication\n")
|
|
if skill_prefix:
|
|
system += "\n\nFollow this skill:\n" + skill_prefix
|
|
if active_project_id:
|
|
from cowork_local.core.projects import load_project, project_context_text
|
|
proj_ctx = project_context_text(load_project(active_project_id))
|
|
if proj_ctx:
|
|
system += "\n\n" + proj_ctx
|
|
return system
|
|
|
|
def _on_ask_event(self, ev: dict) -> None:
|
|
"""Nhận từng mẩu trả lời đang phát dần và vẽ dần vào khung chi tiết."""
|
|
if ev.get("type") == "text":
|
|
if self._answer == "":
|
|
self.detail.clear()
|
|
self._answer += ev.get("delta", "")
|
|
self.detail.setPlainText(self._answer)
|
|
|
|
def _on_ask_done(self, result: dict) -> None:
|
|
# Keep the (temporary) extracted content so repeated questions reuse
|
|
# it without re-extracting — dropped when leaving the tab.
|
|
"""Trả lời xong: giữ lại nội dung tệp đã trích để câu hỏi sau không phải trích"""
|
|
if isinstance(result, dict):
|
|
self._extract_cache.update(result.get("extracted", {}) or {})
|
|
self._render_answer()
|
|
|
|
# ---- temporary file-content extraction for Q&A -------------------------------- #
|
|
def _candidate_file_paths(self) -> List[str]:
|
|
"""File paths to read for a question: the SELECTED file nodes if
|
|
any, else every file node in the graph (capped downstream)."""
|
|
graph = self._renderer.graph
|
|
if graph is None:
|
|
return []
|
|
sel = self._renderer.selected_node_data()
|
|
nodes = sel or list(graph.nodes)
|
|
out, seen = [], set()
|
|
for nd in nodes:
|
|
p = (getattr(nd, "path", "") or "").strip()
|
|
if p and p not in seen and Path(p).is_file():
|
|
seen.add(p)
|
|
out.append(p)
|
|
return out
|
|
|
|
def _extract_tmp_dir(self) -> Path:
|
|
"""Thư mục tạm chứa nội dung tệp đã trích, tạo lười ở lần cần đầu tiên."""
|
|
if self._extract_dir is None:
|
|
import tempfile
|
|
from cowork_local.config import CONFIG_DIR
|
|
base = CONFIG_DIR / "tmp" / "graphrag_extract"
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
self._extract_dir = Path(tempfile.mkdtemp(dir=str(base)))
|
|
return self._extract_dir
|
|
|
|
def clear_extracts(self) -> None:
|
|
"""Discard the temporary extracted content (on leaving the tab /
|
|
switching project). The extraction is a scratch aid, never
|
|
persisted."""
|
|
self._extract_cache = {}
|
|
d, self._extract_dir = self._extract_dir, None
|
|
if d is not None:
|
|
import shutil
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
|
|
__all__ = ["GraphQaWidget"]
|