## 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>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user