From 2cfc7d682f2ad4c19afb4f3c06ad288a980ed9e6 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 3 Aug 2026 04:04:49 +0800 Subject: [PATCH 1/5] Stamp the package version on each SonarCloud analysis SonarCloud labelled every analysis "projectVersion: not provided", so the "previous version" new-code baseline had nothing to anchor to and counted the whole history as new: it reported 21800 new lines against a project of 11440. The coverage gate was therefore measuring all code, not new code, which is why 57.5% showed up against a threshold meant for freshly written lines. The scanner now reads the version out of pyproject.toml and passes it as sonar.projectVersion. This takes effect from the next release: that analysis establishes the baseline, and the one after it measures only what changed between them. --- .github/workflows/dev.yml | 8 ++++++++ .github/workflows/stable.yml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 15f0f62..edd826f 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -70,8 +70,16 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-xml + - name: Read package version + # Without a version, SonarCloud's "previous version" new-code baseline has + # nothing to anchor to and counts the whole history as new -- which is how + # new_lines came to exceed the project's total line count. + id: version + run: echo "value=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: SonarQube Cloud scan uses: SonarSource/sonarqube-scan-action@v8.2.1 + with: + args: -Dsonar.projectVersion=${{ steps.version.outputs.value }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: https://sonarcloud.io diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index 00c0997..1c64b31 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -70,8 +70,16 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-xml + - name: Read package version + # Without a version, SonarCloud's "previous version" new-code baseline has + # nothing to anchor to and counts the whole history as new -- which is how + # new_lines came to exceed the project's total line count. + id: version + run: echo "value=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: SonarQube Cloud scan uses: SonarSource/sonarqube-scan-action@v8.2.1 + with: + args: -Dsonar.projectVersion=${{ steps.version.outputs.value }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: https://sonarcloud.io From ddece123309c6360379ab01547a1c712deb21052 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 3 Aug 2026 04:48:15 +0800 Subject: [PATCH 2/5] Cover the settings dialog, the file tree menu and the plugin menus Three of the least-covered areas, chosen for being reachable headlessly rather than for being easy: each test drives the real widget with the modal dialogs stubbed to whatever the user would have answered. - The prthinker settings form: every field gets an editor, keys are echoed as dots, the backend and platform come from the supported lists, a stored value that is no longer on offer does not silently persist, and a save that fails leaves the window open rather than looking like it worked. - The project tree's right-click actions: where a new item lands, refusing to overwrite an existing name, an open editor tab following a rename and closing with a delete, absolute against tree-relative path copying, and an OSError surfacing as a dialog rather than a traceback. - The plugin and "Run with" menus: entries built from a supplied registry rather than whatever is installed, sorted and labelled by suffix, and the suffix mismatch that must warn instead of running the wrong compiler. 64 new tests, 937 total. Coverage 57% to 60%: the settings dialog reaches 100%, editor_main 20% to 58%, menu 40% to 54%. --- architecture_explore.md | 4 +- .../test_utils/test_file_tree_context_menu.py | 328 ++++++++++++++++++ test/test_utils/test_plugin_menu.py | 246 +++++++++++++ .../test_prthinker_setting_dialog.py | 127 +++++++ 4 files changed, 703 insertions(+), 2 deletions(-) create mode 100644 test/test_utils/test_file_tree_context_menu.py create mode 100644 test/test_utils/test_plugin_menu.py create mode 100644 test/test_utils/test_prthinker_setting_dialog.py diff --git a/architecture_explore.md b/architecture_explore.md index a5a2d8f..f10c277 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -396,10 +396,10 @@ extend_ai_gui/ ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 60 個 `test_*.py`。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 63 個 `test_*.py`、937 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 -- **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 57%(`utils/` 與 `tools_gui` 95–100%,UI 層 20–45% 拉低) +- **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) - **靜態分析** SonarCloud(`sonar-project.properties`,CI-based analysis;Automatic Analysis 已關閉且必須維持關閉,兩種模式互斥)+ Codacy(`.codacy.yml`)+ Bandit(`pyproject.toml` 中排除 test、skip B101/B404) - **SonarCloud 方案限制** 該組織的方案只開放 `main` 與 PR 的分析結果。非 main 分支的分析送得出去、CE 任務也會成功,但結果讀回來是 403(組織內每個專案都只有 `main` 一條分支)。因此 `dev.yml` 只在 PR 時掃描,`stable.yml` 另外掃 push to `main` diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py new file mode 100644 index 0000000..69de26e --- /dev/null +++ b/test/test_utils/test_file_tree_context_menu.py @@ -0,0 +1,328 @@ +"""The project tree's right-click actions: creating, renaming, deleting, copying. + +Every action is driven through a real ``QTreeView`` over a real ``QFileSystemModel`` +rooted in a temporary directory, with the modal dialogs stubbed out so the answer +the user would have given is supplied directly. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QPoint, Qt +from PySide6.QtWidgets import ( + QApplication, QFileSystemModel, QMessageBox, QTabWidget, QTreeView, QWidget +) + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as ctx +from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import ( + _action_copy_path, _action_delete, _action_new_file, _action_new_folder, + _action_rename, _attach_context_menu, _find_editor_for_file, _get_tree_root_path, + _perform_file_op, _resolve_parent_dir, setup_file_tree_context_menu +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def tree(app, tmp_path): + """A tree view rooted at *tmp_path*, as the project tree would be.""" + view = QTreeView() + model = QFileSystemModel() + model.setRootPath(str(tmp_path)) + view.setModel(model) + view.setRootIndex(model.index(str(tmp_path))) + yield view + view.deleteLater() + + +def answer(monkeypatch, text: str, accepted: bool = True) -> None: + """Stub the name prompt with what the user would have typed.""" + monkeypatch.setattr( + ctx.QInputDialog, "getText", + staticmethod(lambda *a, **k: (text, accepted))) + + +def confirm(monkeypatch, yes: bool) -> None: + """Stub the delete confirmation.""" + button = (QMessageBox.StandardButton.Yes if yes + else QMessageBox.StandardButton.No) + monkeypatch.setattr( + ctx.QMessageBox, "question", staticmethod(lambda *a, **k: button)) + + +@pytest.fixture() +def warnings(monkeypatch): + """Collect the warning dialogs an action raises instead of showing them.""" + shown: list[str] = [] + monkeypatch.setattr( + ctx.QMessageBox, "warning", + staticmethod(lambda _p, _t, message, *a, **k: shown.append(message))) + return shown + + +class FakeEditor(QWidget): + """Stands in for an EditorWidget holding one open file. + + A real QWidget, because the delete path looks the editor up with + ``tab_widget.indexOf`` and removes its tab. + """ + + def __init__(self, path: str) -> None: + super().__init__() + self.current_file = path + self.code_edit = type("Edit", (), {"current_file": path})() + self.renamed = False + self.closed = False + + def rename_self_tab(self) -> None: + self.renamed = True + + def close(self) -> bool: + self.closed = True + return super().close() + + +class FakeWindow: + """A main window with just the tab widget the actions reach for.""" + + def __init__(self) -> None: + self.tab_widget = QTabWidget() + + +class TestWhereANewItemGoes: + def test_a_directory_receives_the_new_item(self, tree, tmp_path): + folder = tmp_path / "pkg" + folder.mkdir() + assert _resolve_parent_dir(tree, folder) == folder + + def test_a_file_puts_it_beside_itself(self, tree, tmp_path): + target = tmp_path / "module.py" + target.touch() + assert _resolve_parent_dir(tree, target) == tmp_path + + def test_no_selection_falls_back_to_the_tree_root(self, tree, tmp_path): + assert _resolve_parent_dir(tree, None) == tmp_path + + def test_the_root_is_what_the_view_is_rooted_at(self, tree, tmp_path): + assert _get_tree_root_path(tree) == tmp_path + + +class TestSurfacingFailures: + def test_a_successful_operation_reports_success(self, tree): + assert _perform_file_op(tree, lambda: None) is True + + def test_an_os_error_becomes_a_dialog_not_a_traceback(self, tree, warnings): + def explode() -> None: + raise OSError("disk is full") + + assert _perform_file_op(tree, explode) is False + assert "disk is full" in warnings[0] + + +class TestCreating: + def test_a_new_file_appears(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "notes.txt") + _action_new_file(tree, None) + assert (tmp_path / "notes.txt").is_file() + + def test_a_cancelled_prompt_creates_nothing(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "notes.txt", accepted=False) + _action_new_file(tree, None) + assert not (tmp_path / "notes.txt").exists() + + def test_a_blank_name_creates_nothing(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, " ") + _action_new_file(tree, None) + assert list(tmp_path.iterdir()) == [] + + def test_the_name_is_trimmed(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, " notes.txt ") + _action_new_file(tree, None) + assert (tmp_path / "notes.txt").is_file() + + def test_an_existing_name_is_refused_rather_than_overwritten( + self, tree, tmp_path, monkeypatch, warnings): + existing = tmp_path / "notes.txt" + existing.write_text("keep me", encoding="utf-8") + answer(monkeypatch, "notes.txt") + _action_new_file(tree, None) + assert existing.read_text(encoding="utf-8") == "keep me" + assert warnings + + def test_a_new_folder_appears(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "package") + _action_new_folder(tree, None) + assert (tmp_path / "package").is_dir() + + def test_a_new_folder_lands_inside_the_selected_directory( + self, tree, tmp_path, monkeypatch): + parent = tmp_path / "outer" + parent.mkdir() + answer(monkeypatch, "inner") + _action_new_folder(tree, parent) + assert (parent / "inner").is_dir() + + def test_an_existing_folder_name_is_refused( + self, tree, tmp_path, monkeypatch, warnings): + (tmp_path / "package").mkdir() + answer(monkeypatch, "package") + _action_new_folder(tree, None) + assert warnings + + +class TestRenaming: + def test_the_file_moves_to_the_new_name(self, tree, tmp_path, monkeypatch): + original = tmp_path / "old.py" + original.write_text("body", encoding="utf-8") + answer(monkeypatch, "new.py") + _action_rename(tree, FakeWindow(), original) + assert not original.exists() + assert (tmp_path / "new.py").read_text(encoding="utf-8") == "body" + + def test_nothing_selected_does_nothing(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "new.py") + _action_rename(tree, FakeWindow(), None) + assert list(tmp_path.iterdir()) == [] + + def test_the_same_name_is_a_no_op(self, tree, tmp_path, monkeypatch): + original = tmp_path / "same.py" + original.touch() + answer(monkeypatch, "same.py") + _action_rename(tree, FakeWindow(), original) + assert original.exists() + + def test_renaming_onto_an_existing_file_is_refused( + self, tree, tmp_path, monkeypatch, warnings): + original = tmp_path / "old.py" + original.touch() + occupied = tmp_path / "taken.py" + occupied.write_text("keep me", encoding="utf-8") + answer(monkeypatch, "taken.py") + _action_rename(tree, FakeWindow(), original) + assert original.exists() + assert occupied.read_text(encoding="utf-8") == "keep me" + assert warnings + + def test_an_open_tab_follows_the_rename(self, tree, tmp_path, monkeypatch): + original = tmp_path / "open.py" + original.touch() + window = FakeWindow() + editor = FakeEditor(str(original)) + monkeypatch.setattr( + ctx, "_find_editor_for_file", lambda _w, _p: editor) + answer(monkeypatch, "renamed.py") + _action_rename(tree, window, original) + assert editor.current_file == str(tmp_path / "renamed.py") + assert editor.code_edit.current_file == str(tmp_path / "renamed.py") + assert editor.renamed + + +class TestDeleting: + def test_a_confirmed_delete_removes_the_file(self, tree, tmp_path, monkeypatch): + target = tmp_path / "gone.py" + target.touch() + confirm(monkeypatch, yes=True) + _action_delete(tree, FakeWindow(), target) + assert not target.exists() + + def test_declining_keeps_the_file(self, tree, tmp_path, monkeypatch): + target = tmp_path / "kept.py" + target.touch() + confirm(monkeypatch, yes=False) + _action_delete(tree, FakeWindow(), target) + assert target.exists() + + def test_a_directory_goes_with_its_contents(self, tree, tmp_path, monkeypatch): + folder = tmp_path / "pkg" + folder.mkdir() + (folder / "inner.py").touch() + confirm(monkeypatch, yes=True) + _action_delete(tree, FakeWindow(), folder) + assert not folder.exists() + + def test_nothing_selected_does_nothing(self, tree, tmp_path, monkeypatch): + confirm(monkeypatch, yes=True) + _action_delete(tree, FakeWindow(), None) + + def test_an_open_tab_is_closed_with_the_file(self, tree, tmp_path, monkeypatch): + target = tmp_path / "open.py" + target.touch() + window = FakeWindow() + editor = FakeEditor(str(target)) + window.tab_widget.addTab(editor, "open.py") + monkeypatch.setattr(ctx, "_find_editor_for_file", lambda _w, _p: editor) + confirm(monkeypatch, yes=True) + _action_delete(tree, window, target) + assert editor.closed + assert window.tab_widget.count() == 0 + assert not target.exists() + + +class TestCopyingThePath: + def test_the_absolute_path_reaches_the_clipboard(self, tree, tmp_path): + target = tmp_path / "module.py" + target.touch() + _action_copy_path(tree, target, relative=False) + assert QApplication.clipboard().text() == str(target) + + def test_the_relative_path_is_relative_to_the_tree_root(self, tree, tmp_path): + nested = tmp_path / "pkg" + nested.mkdir() + target = nested / "module.py" + target.touch() + _action_copy_path(tree, target, relative=True) + assert QApplication.clipboard().text() == os.path.join("pkg", "module.py") + + def test_a_path_outside_the_root_falls_back_to_absolute(self, tree, tmp_path): + outside = tmp_path.parent / "elsewhere.py" + _action_copy_path(tree, outside, relative=True) + assert QApplication.clipboard().text() == str(outside) + + def test_nothing_selected_leaves_the_clipboard_alone(self, tree): + QApplication.clipboard().setText("untouched") + _action_copy_path(tree, None) + assert QApplication.clipboard().text() == "untouched" + + +class TestFindingTheOpenEditor: + def test_a_window_with_no_editor_tabs_finds_nothing(self, app, tmp_path): + assert _find_editor_for_file(FakeWindow(), tmp_path / "any.py") is None + + +class TestAttachingTheMenu: + def test_the_view_switches_to_a_custom_menu(self, tree): + _attach_context_menu(tree, FakeWindow()) + assert tree.contextMenuPolicy() == Qt.ContextMenuPolicy.CustomContextMenu + + def test_attaching_twice_still_opens_one_menu(self, tree, monkeypatch): + # A second attach must not connect the signal again: two handlers would + # pop the context menu twice for a single right-click. + opened: list[object] = [] + monkeypatch.setattr( + ctx, "_show_context_menu", + lambda pos, tv, mw: opened.append(pos)) + window = FakeWindow() + _attach_context_menu(tree, window) + _attach_context_menu(tree, window) + tree.customContextMenuRequested.emit(QPoint(1, 1)) + assert len(opened) == 1 + + def test_setup_leaves_later_tabs_working(self, app): + # setup wraps addTab so future editor tabs also get the menu; the wrapper + # must still add the tab and return the index addTab promises. + window = FakeWindow() + setup_file_tree_context_menu(window) + placeholder = QTreeView() + index = window.tab_widget.addTab(placeholder, "tab") + assert index == 0 + assert window.tab_widget.count() == 1 + placeholder.deleteLater() diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py new file mode 100644 index 0000000..67612cc --- /dev/null +++ b/test/test_utils/test_plugin_menu.py @@ -0,0 +1,246 @@ +"""The plugin menus: what they build from a plugin registry, and what "Run with" refuses. + +Both menus read je_editor's plugin registry, so each test supplies its own +registry contents rather than depending on whichever plugins happen to be +installed. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QMenuBar, QTabWidget, QWidget +) + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.plugin_menu import build_plugin_menu as plugin_menu +from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with +from pybreeze.pybreeze_ui.menu.plugin_menu.build_plugin_menu import set_plugin_menu +from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import ( + _get_current_file, _run_with, set_run_with_menu +) + +GO_CONFIG = {"name": "Go", "compiler": "go", "args": ("run",), "suffixes": (".go",)} +RUST_CONFIG = {"name": "Rust", "compiler": "rustc", "suffixes": (".rs",), + "compile_then_run": True, "output_flag": "-o"} +MULTI_CONFIG = {"name": "C++", "compiler": "g++", "suffixes": (".cpp", ".hpp")} + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeWindow(QMainWindow): + """A main window with the handful of members the menu builders touch.""" + + def __init__(self) -> None: + super().__init__() + self.menu = QMenuBar(self) + self.run_menu = self.menu.addMenu("Run") + self.tab_widget = QTabWidget() + self.current_run_code_window: list[QWidget] = [] + self.encoding = "utf-8" + + +@pytest.fixture() +def window(app): + made = FakeWindow() + yield made + made.deleteLater() + + +def labels(menu) -> list[str]: + return [action.text() for action in menu.actions()] + + +def submenu_action_texts(menu) -> list[list[str]]: + """Return the action texts of each submenu of *menu*, submenu by submenu. + + The texts are read while the owning ``QAction`` is still referenced rather + than handed back as ``QMenu`` objects: a submenu built by ``addMenu(title)`` + is owned through its action, and under pytest the wrapper is collected + eagerly enough that a returned ``QMenu`` can be dead before the caller + touches it. + """ + collected: list[list[str]] = [] + for action in menu.actions(): + sub = action.menu() + if sub is not None: + collected.append([entry.text() for entry in sub.actions()]) + del sub + return collected + + +class TestTheRunWithMenu: + def test_no_plugins_means_no_menu(self, window, monkeypatch): + monkeypatch.setattr(run_with, "get_all_plugin_run_configs", lambda: []) + set_run_with_menu(window) + assert not hasattr(window, "run_with_menu") + + def test_one_entry_per_run_config(self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", lambda: [GO_CONFIG, RUST_CONFIG]) + set_run_with_menu(window) + assert len(window.run_with_menu.actions()) == 2 + + def test_entries_are_sorted_by_name(self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", + lambda: [RUST_CONFIG, GO_CONFIG, MULTI_CONFIG]) + set_run_with_menu(window) + names = [text.split(" ")[0] for text in labels(window.run_with_menu)] + assert names == ["C++", "Go", "Rust"] + + def test_the_label_lists_the_suffixes(self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", lambda: [MULTI_CONFIG]) + set_run_with_menu(window) + assert ".cpp, .hpp" in labels(window.run_with_menu)[0] + + def test_a_config_without_suffixes_is_labelled_by_name_alone( + self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", lambda: [{"name": "Bare"}]) + set_run_with_menu(window) + assert labels(window.run_with_menu) == ["Bare"] + + +class TestFindingTheFileToRun: + def test_a_non_editor_tab_offers_no_file(self, window): + window.tab_widget.addTab(QWidget(), "not an editor") + window.tab_widget.setCurrentIndex(0) + assert _get_current_file(window) is None + + def test_no_tabs_at_all_offers_no_file(self, window): + assert _get_current_file(window) is None + + +class TestRefusingToRunTheWrongFile: + def test_a_suffix_mismatch_warns_and_runs_nothing( + self, window, tmp_path, monkeypatch): + script = tmp_path / "script.py" + script.touch() + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + shown: list[str] = [] + monkeypatch.setattr( + run_with.QMessageBox, "exec", lambda self: shown.append(self.text())) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: started.append(kwargs)) + + _run_with(window, GO_CONFIG) + + assert shown and ".py" in shown[0] + assert not started + + def test_no_file_means_nothing_runs(self, window, monkeypatch): + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: None) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) + _run_with(window, GO_CONFIG) + assert not started + + def test_a_matching_suffix_opens_a_run_window_and_starts( + self, window, tmp_path, monkeypatch): + script = tmp_path / "main.go" + script.touch() + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + + class Runner: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + started.append(self) + + def run_file(self, config, path) -> None: + self.ran = (config, path) + + started: list[Runner] = [] + monkeypatch.setattr(run_with, "FileRunnerProcess", Runner) + + _run_with(window, GO_CONFIG) + + assert len(started) == 1 + assert started[0].ran == (GO_CONFIG, str(script)) + assert len(window.current_run_code_window) == 1 + + def test_a_config_without_suffixes_accepts_any_file( + self, window, tmp_path, monkeypatch): + script = tmp_path / "anything.xyz" + script.touch() + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) + _run_with(window, {"name": "Anything", "compiler": "cat"}) + assert started + + +class TestThePluginMenu: + def test_no_plugins_means_no_menu(self, window, monkeypatch): + monkeypatch.setattr(plugin_menu, "get_all_plugin_metadata", lambda: []) + set_plugin_menu(window) + assert not hasattr(window, "plugin_menu") + + def test_a_plugin_without_a_run_config_gets_a_bare_entry( + self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "French", "version": "1.0", "author": "someone"}]) + set_plugin_menu(window) + assert "French" in labels(window.plugin_menu) + + def test_a_plugin_with_a_run_config_gets_a_submenu(self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "Go", "version": "1.0", "author": "someone", + "run_config": GO_CONFIG}]) + set_plugin_menu(window) + submenus = submenu_action_texts(window.plugin_menu) + assert len(submenus) == 1 + # About, a separator (empty text), then one run action for the suffix + assert [text for text in submenus[0] if text] == ["About", "Run with Go"] + + def test_multiple_suffixes_get_one_run_action_each(self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "C++", "version": "1.0", "author": "someone", + "run_config": MULTI_CONFIG}]) + set_plugin_menu(window) + submenu = submenu_action_texts(window.plugin_menu)[0] + runs = [text for text in submenu if "(" in text] + assert len(runs) == 2 + assert ".cpp" in runs[0] and ".hpp" in runs[1] + + def test_the_browser_entry_comes_first(self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "French", "version": "1.0", "author": "someone"}]) + set_plugin_menu(window) + assert "Plugin Browser" in window.plugin_menu.actions()[0].text() + + def test_the_about_dialog_names_version_and_author(self, app, monkeypatch): + shown: list[str] = [] + monkeypatch.setattr( + plugin_menu.QMessageBox, "exec", lambda self: shown.append(self.text())) + plugin_menu._make_about_callback("Go", "2.1", "someone")() + assert "2.1" in shown[0] + assert "someone" in shown[0] + + def test_a_run_callback_ignores_a_non_editor_tab(self, window, monkeypatch): + window.tab_widget.addTab(QWidget(), "not an editor") + window.tab_widget.setCurrentIndex(0) + started: list[object] = [] + monkeypatch.setattr( + plugin_menu, "FileRunnerProcess", + lambda **kwargs: started.append(kwargs)) + plugin_menu._make_run_callback(window, GO_CONFIG)() + assert not started diff --git a/test/test_utils/test_prthinker_setting_dialog.py b/test/test_utils/test_prthinker_setting_dialog.py new file mode 100644 index 0000000..a2b0c39 --- /dev/null +++ b/test/test_utils/test_prthinker_setting_dialog.py @@ -0,0 +1,127 @@ +"""The prthinker settings form: what it builds, what it reads back, what it stores.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit + +from pybreeze.extend.prthinker_extend import prthinker_setting +from pybreeze.extend.prthinker_extend.prthinker_setting import ( + BACKENDS, DEFAULT_SETTING, PLATFORMS, SETTING_FILE_NAME, save_setting +) +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.dialog import prthinker_setting_dialog +from pybreeze.pybreeze_ui.dialog.prthinker_setting_dialog import ( + FIELDS, SECRET_FIELDS, PRThinkerSettingDialog +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def data_dir(tmp_path, monkeypatch): + """Point the settings file at a temporary directory, never the real home.""" + monkeypatch.setattr(prthinker_setting, "pybreeze_data_dir", lambda: tmp_path) + return tmp_path + + +@pytest.fixture() +def dialog(app, data_dir): + made = PRThinkerSettingDialog() + yield made + made.deleteLater() + + +def stored(data_dir) -> dict: + return json.loads((data_dir / SETTING_FILE_NAME).read_text(encoding="utf-8")) + + +class TestTheFormItBuilds: + def test_every_field_gets_an_editor(self, dialog): + assert set(dialog.editors) == {key for key, _label in FIELDS} + + def test_a_key_is_shown_as_dots(self, dialog): + for key in SECRET_FIELDS: + assert dialog.editors[key].echoMode() == QLineEdit.EchoMode.Password, key + + def test_an_ordinary_field_is_shown_as_text(self, dialog): + assert dialog.editors["repository"].echoMode() == QLineEdit.EchoMode.Normal + + def test_the_backend_is_chosen_from_the_supported_list(self, dialog): + editor = dialog.editors["backend"] + assert isinstance(editor, QComboBox) + assert [editor.itemText(i) for i in range(editor.count())] == list(BACKENDS) + + def test_the_platform_is_chosen_from_the_supported_list(self, dialog): + editor = dialog.editors["platform"] + assert [editor.itemText(i) for i in range(editor.count())] == list(PLATFORMS) + + def test_a_stored_choice_comes_back_selected(self, app, data_dir): + save_setting({**DEFAULT_SETTING, "backend": "anthropic", "platform": "gitea"}) + made = PRThinkerSettingDialog() + assert made.editors["backend"].currentText() == "anthropic" + assert made.editors["platform"].currentText() == "gitea" + made.deleteLater() + + def test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice(self, app, data_dir): + (data_dir / SETTING_FILE_NAME).write_text( + json.dumps({**DEFAULT_SETTING, "backend": "nonsense"}), encoding="utf-8") + made = PRThinkerSettingDialog() + assert made.editors["backend"].currentText() == BACKENDS[0] + made.deleteLater() + + def test_stored_text_is_filled_in(self, app, data_dir): + save_setting({**DEFAULT_SETTING, "repository": "owner/name"}) + made = PRThinkerSettingDialog() + assert made.editors["repository"].text() == "owner/name" + made.deleteLater() + + +class TestReadingTheFormBack: + def test_values_reads_both_kinds_of_editor(self, dialog): + dialog.editors["repository"].setText("owner/name") + dialog.editors["backend"].setCurrentText("openai") + values = dialog.values() + assert values["repository"] == "owner/name" + assert values["backend"] == "openai" + + def test_values_covers_every_field(self, dialog): + assert set(dialog.values()) == {key for key, _label in FIELDS} + + +class TestSaving: + def test_what_was_typed_reaches_the_file(self, dialog, data_dir): + dialog.editors["repository"].setText("owner/name") + dialog.editors["platform_token"].setText("secret-token") + dialog.save() + assert stored(data_dir)["repository"] == "owner/name" + assert stored(data_dir)["platform_token"] == "secret-token" + + def test_saving_closes_the_window(self, dialog): + dialog.save() + assert dialog.result() == QDialog.DialogCode.Accepted + + def test_a_failed_save_leaves_the_window_open(self, dialog, monkeypatch): + # A settings file that cannot be written must not look like a success: + # the user needs the form still in front of them to retry or copy from. + monkeypatch.setattr( + prthinker_setting_dialog, "save_setting", lambda _setting: False) + dialog.save() + assert dialog.result() != QDialog.DialogCode.Accepted + + def test_a_field_left_untouched_keeps_its_stored_value(self, app, data_dir): + save_setting({**DEFAULT_SETTING, "model_name": "kept"}) + made = PRThinkerSettingDialog() + made.editors["repository"].setText("owner/name") + made.save() + assert stored(data_dir)["model_name"] == "kept" + made.deleteLater() From 4bd5a29a8ad5516525f7103e2492a6c119fca1bd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 3 Aug 2026 05:03:04 +0800 Subject: [PATCH 3/5] Run the three prompt templates that were sitting unused judge, judge_single_review and step_by_step_analysis were never imported: 217 lines of prompt that no chain ran and the editor did not even offer. They are now steps in the chain, placed where their inputs exist. first_summary -> first_code_review -> judge_single_review -> linter -> code_smell_detector -> step_by_step_analysis -> total_summary -> judge judge_single_review scores the review written just before it. step_by_step walks each lint message and code smell through cause, impact and fix. judge scores the finished summary with the findings it was meant to cover in hand, which needed the total summary's answer kept rather than discarded. Eight steps would have taken the match statement in _run_templates past the complexity the project allows, so the wiring moves into cot_chain: two tables saying where each answer is stored and which placeholder each fills. The order is now a dependency order a test can check, rather than a sequence held in one function's local variables. Two behaviours change with it. A step whose input never ran quotes an empty section instead of the literal word "None". A step that fails is still shown to the user but no longer stored, so a later step cannot quote "could not send" back to the model as if it were a review. --- architecture_explore.md | 19 ++- .../extend_ai_gui/ai_gui_global_variable.py | 16 +++ .../code_review/code_review_thread.py | 96 ++++---------- .../extend_ai_gui/code_review/cot_chain.py | 87 ++++++++++++ test/test_utils/test_cot_chain.py | 124 ++++++++++++++++++ test/test_utils/test_cot_session_reuse.py | 78 +++++++++++ 6 files changed, 348 insertions(+), 72 deletions(-) create mode 100644 pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py create mode 100644 test/test_utils/test_cot_chain.py diff --git a/architecture_explore.md b/architecture_explore.md index f10c277..7dbd4ba 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -245,18 +245,27 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) extend_ai_gui/ ├── ai_gui_global_variable.py 模板檔名清單 + 檔名→模板內容對照表 ├── code_review/ -│ ├── code_review_thread.py SenderThread(QThread):CoT 多階段審查 +│ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 +│ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 │ └── cot_code_review_gui.py UI ├── prompt_edit_gui/ -│ ├── cot_prompt_editor_widget.py 編輯 5 個 CoT 模板(QFileSystemWatcher 熱更新) +│ ├── cot_prompt_editor_widget.py 編輯 8 個 CoT 模板(QFileSystemWatcher 熱更新) │ ├── skills_prompt_editor_widget.py 編輯 2 個 Skill 模板 │ ├── prompt_file_io.py 共用存檔(失敗跳警告對話框) -│ ├── cot_code_review_prompt_templates/ 7 個模板常數 +│ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 └── skills/skills_send_gui.py 單次 prompt 發送(RequestThread) ``` -**CoT 審查鏈**(`code_review_thread.py`):`first_summary` → `first_code_review` → `linter` → `code_smell_detector` → `total_summary`。前四階段的結果被收集起來餵給最後的 total summary。每階段都套 `build_global_rule_template()` 包一層全域規則。 +**CoT 審查鏈**(`cot_chain.py` 定義接線,`code_review_thread.py` 執行)八個步驟: + +``` +first_summary → first_code_review → judge_single_review ┐(評分前一步的審查) + → linter → code_smell_detector → step_by_step_analysis ┐(走過每條發現) + → total_summary → judge(帶 linter/code smell 脈絡評分總結) +``` + +`cot_chain.py` 用兩張表描述接線:`STEP_RESULT_KEY`(每步答案存在哪個 key)與 `STEP_ARGUMENTS`(每步的 placeholder 由哪個 key 填)。**順序即相依順序** —— 每步只能引用它上面的步驟,`test_cot_chain.py` 有結構性測試守住這件事。步驟失敗時錯誤訊息只顯示給使用者、不會被存進 results,避免後續步驟把「傳送失敗」當成審查內容引用。每步都套 `build_global_rule_template()` 包一層全域規則。 安全處理:送出前 `validate_url()`、`allow_redirects=False`、`stream=True` 搭配 `read_capped_text()` 限制回應大小、單一 `requests.Session` 重用 TCP/TLS 連線、`isInterruptionRequested()` 讓 widget 關閉時能中止。 @@ -396,7 +405,7 @@ extend_ai_gui/ ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 63 個 `test_*.py`、937 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 64 個 `test_*.py`、965 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py b/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py index b4984d3..1c78dd8 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py @@ -6,8 +6,14 @@ FIRST_CODE_REVIEW_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.first_summary_prompt import \ FIRST_SUMMARY_TEMPLATE +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.judge import \ + JUDGE_TEMPLATE +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.judge_single_review import \ + JUDGE_SINGLE_REVIEW_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.linter import \ LINTER_TEMPLATE +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.step_by_step_analysis import \ + STEP_BY_STEP_ANALYSIS_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.total_summary import \ TOTAL_SUMMARY_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_templates.code_explainer import \ @@ -15,20 +21,30 @@ from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_templates.code_review import \ CODE_REVIEW_SKILL_TEMPLATE +# The order the chain runs in. Each step may only quote steps above it, so the +# order is a dependency order, not a preference: judge_single_review scores the +# review written just before it, step_by_step_analysis walks the linter and code +# smell findings, and judge scores the finished summary. COT_TEMPLATE_FILES = [ "first_summary_prompt.md", "first_code_review.md", + "judge_single_review.md", "linter.md", "code_smell_detector.md", + "step_by_step_analysis.md", "total_summary.md", + "judge.md", ] COT_TEMPLATE_RELATION = { "first_summary_prompt.md": FIRST_SUMMARY_TEMPLATE, "first_code_review.md": FIRST_CODE_REVIEW_TEMPLATE, + "judge_single_review.md": JUDGE_SINGLE_REVIEW_TEMPLATE, "linter.md": LINTER_TEMPLATE, "code_smell_detector.md": CODE_SMELL_DETECTOR_TEMPLATE, + "step_by_step_analysis.md": STEP_BY_STEP_ANALYSIS_TEMPLATE, "total_summary.md": TOTAL_SUMMARY_TEMPLATE, + "judge.md": JUDGE_TEMPLATE, } SKILLS_TEMPLATE_FILES = [ diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py index a8e48af..280df72 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py @@ -5,9 +5,9 @@ from PySide6.QtCore import QThread, Signal from je_editor import language_wrapper -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_RELATION -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.global_rule import \ - build_global_rule_template +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_chain import ( + CODE_DIFF, STEP_RESULT_KEY, build_prompt +) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, @@ -40,74 +40,36 @@ def run(self): session.close() def _run_templates(self, session: requests.Session, code: str) -> None: - first_code_review_result = None - first_summary_result = None - linter_result = None - code_smell_result = None + # Answers accumulate here as the chain runs; a later step quotes whichever + # of them its template asks for. See cot_chain for the wiring. + results: dict[str, str] = {CODE_DIFF: code} for file in self.files: # Stop promptly if the widget is closing instead of firing off the # remaining per-template POSTs. if self.isInterruptionRequested(): return - match file: - case "first_summary_prompt.md": - first_summary_prompt = COT_TEMPLATE_RELATION["first_summary_prompt.md"] - prompt = build_global_rule_template( - prompt=first_summary_prompt.format(code_diff=code) - ) - case "first_code_review.md": - first_code_review_prompt = COT_TEMPLATE_RELATION["first_code_review.md"] - prompt = build_global_rule_template( - prompt=first_code_review_prompt.format(code_diff=code) - ) - case "linter.md": - linter_prompt = COT_TEMPLATE_RELATION["linter.md"] - prompt = build_global_rule_template( - prompt=linter_prompt.format(code_diff=code) - ) - case "code_smell_detector.md": - code_smell_detector_prompt = COT_TEMPLATE_RELATION["code_smell_detector.md"] - prompt = build_global_rule_template( - prompt=code_smell_detector_prompt.format(code_diff=code) - ) - case "total_summary.md": - total_summary_prompt = COT_TEMPLATE_RELATION["total_summary.md"] - prompt = build_global_rule_template( - prompt=total_summary_prompt.format( - first_code_review=first_code_review_result, - first_summary=first_summary_result, - linter_result=linter_result, - code_smell_result=code_smell_result, - code_diff=code, - ) - ) - case _: - continue - - try: - # 傳送到指定 URL(重用 session 連線) - resp = session.post( - self.url, json={"prompt": prompt}, - timeout=(CONNECT_TIMEOUT, 60), allow_redirects=False, stream=True, - ) - reply_text = read_capped_text(resp) - match file: - case "first_summary_prompt.md": - first_summary_result = reply_text - case "first_code_review.md": - first_code_review_result = reply_text - case "linter.md": - linter_result = reply_text - case "code_smell_detector.md": - code_smell_result = reply_text - case _: - # total_summary.md has no intermediate result to store but - # must still be emitted — `continue` here previously - # dropped the final summary before it reached the UI. - pass - except (requests.RequestException, ResponseTooLargeError) as e: - pybreeze_logger.error("CoT code review send failed for %s: %r", file, e) - reply_text = f"{language_wrapper.language_word_dict.get('cot_gui_error_sending')} {file} {e}" - + prompt = build_prompt(file, results) + if prompt is None: + continue + reply_text, answered = self._ask(session, file, prompt) + result_key = STEP_RESULT_KEY.get(file) + # A failure message is shown but never stored: a later step must not + # quote "could not send" back to the model as if it were a review. + if answered and result_key is not None: + results[result_key] = reply_text # 發送訊號更新 UI self.update_response.emit(file, reply_text) + + def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, bool]: + """Send one step's prompt; return its answer and whether it arrived.""" + try: + # 傳送到指定 URL(重用 session 連線) + resp = session.post( + self.url, json={"prompt": prompt}, + timeout=(CONNECT_TIMEOUT, 60), allow_redirects=False, stream=True, + ) + return read_capped_text(resp), True + except (requests.RequestException, ResponseTooLargeError) as error: + pybreeze_logger.error("CoT code review send failed for %s: %r", file, error) + word = language_wrapper.language_word_dict + return f"{word.get('cot_gui_error_sending')} {file} {error}", False diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py new file mode 100644 index 0000000..01119a1 --- /dev/null +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py @@ -0,0 +1,87 @@ +"""How a chain-of-thought review is wired: what each step quotes from the ones before it. + +A step is one prompt sent to the review endpoint. Its answer is kept under a short +result key so a later step can quote it, and that quoting is what decides the order +the steps run in: a judge cannot score a review that has not been written yet, and +the step-by-step analysis has nothing to walk through until the linter and the code +smell detector have reported. + +Pure logic with no Qt and no network, so the wiring can be tested on its own. +""" +from __future__ import annotations + +from collections.abc import Mapping + +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_RELATION +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.global_rule import ( + build_global_rule_template +) + +# The key the code under review is seeded into the results under. +CODE_DIFF = "code_diff" + +# The result key each step's answer is stored under. +STEP_RESULT_KEY: dict[str, str] = { + "first_summary_prompt.md": "first_summary", + "first_code_review.md": "first_code_review", + "judge_single_review.md": "judge_single_review", + "linter.md": "linter", + "code_smell_detector.md": "code_smell", + "step_by_step_analysis.md": "step_by_step", + "total_summary.md": "total_summary", + "judge.md": "judge", +} + +# Each step's template placeholders, and the result key that fills each one. +STEP_ARGUMENTS: dict[str, dict[str, str]] = { + "first_summary_prompt.md": {"code_diff": CODE_DIFF}, + "first_code_review.md": {"code_diff": CODE_DIFF}, + # Scores the one review just written, against the code it was written about. + "judge_single_review.md": { + "review_comment": "first_code_review", + "code_diff": CODE_DIFF, + }, + "linter.md": {"code_diff": CODE_DIFF}, + "code_smell_detector.md": {"code_diff": CODE_DIFF}, + # Walks every lint message and code smell through cause, impact and fix. + "step_by_step_analysis.md": { + "linter_result": "linter", + "code_smell_result": "code_smell", + }, + "total_summary.md": { + "first_code_review": "first_code_review", + "first_summary": "first_summary", + "linter_result": "linter", + "code_smell_result": "code_smell", + "code_diff": CODE_DIFF, + }, + # Scores the finished summary with the findings it was meant to cover in hand. + "judge.md": { + "review_comment": "total_summary", + "code_smell_detector_messages": "code_smell", + "linter_messages": "linter", + "code_diff": CODE_DIFF, + }, +} + + +def build_prompt(step: str, results: Mapping[str, str]) -> str | None: + """Return the prompt for *step*, wrapped in the global review rules. + + :param step: the template file name the step is known by + :param results: the answers collected so far, keyed as in + :data:`STEP_RESULT_KEY`, with the code under review under + :data:`CODE_DIFF` + :return: the prompt to send, or ``None`` when *step* is not part of the chain + """ + template = COT_TEMPLATE_RELATION.get(step) + arguments = STEP_ARGUMENTS.get(step) + if template is None or arguments is None: + return None + # A step whose input never ran quotes an empty section rather than the word + # "None": the model should see that there is nothing there, not read a value. + filled = { + placeholder: results.get(key, "") + for placeholder, key in arguments.items() + } + return build_global_rule_template(prompt=template.format(**filled)) diff --git a/test/test_utils/test_cot_chain.py b/test/test_utils/test_cot_chain.py new file mode 100644 index 0000000..c79f7ff --- /dev/null +++ b/test/test_utils/test_cot_chain.py @@ -0,0 +1,124 @@ +"""The chain-of-thought wiring: what each step quotes, and that the order allows it.""" +from __future__ import annotations + +import pytest + +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + COT_TEMPLATE_FILES, COT_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_chain import ( + CODE_DIFF, STEP_ARGUMENTS, STEP_RESULT_KEY, build_prompt +) + +CODE = "def apply_discount(total, percent):\n return total - total * percent / 100\n" + + +def run_chain(steps=None, answers=None) -> dict[str, str]: + """Walk the chain, recording the prompt each step would send. + + :param steps: the steps to run, defaulting to the whole chain + :param answers: answer text per step, defaulting to a recognisable marker + :return: step name -> the prompt built for it + """ + steps = COT_TEMPLATE_FILES if steps is None else steps + answers = answers or {} + results = {CODE_DIFF: CODE} + prompts: dict[str, str] = {} + for step in steps: + prompts[step] = build_prompt(step, results) + results[STEP_RESULT_KEY[step]] = answers.get(step, f"<>") + return prompts + + +class TestTheChainIsCompletelyWired: + def test_every_step_has_a_template(self): + assert set(COT_TEMPLATE_FILES) <= set(COT_TEMPLATE_RELATION) + + def test_every_step_has_arguments_and_a_result_key(self): + for step in COT_TEMPLATE_FILES: + assert step in STEP_ARGUMENTS, step + assert step in STEP_RESULT_KEY, step + + def test_no_template_is_left_out_of_the_chain(self): + # A template nobody runs is dead weight: this is the guard that caught + # judge, judge_single_review and step_by_step_analysis sitting unused. + assert set(COT_TEMPLATE_RELATION) == set(COT_TEMPLATE_FILES) + + def test_result_keys_are_distinct(self): + keys = [STEP_RESULT_KEY[step] for step in COT_TEMPLATE_FILES] + assert len(keys) == len(set(keys)) + + def test_arguments_match_the_template_placeholders(self): + for step in COT_TEMPLATE_FILES: + # format() raises KeyError for a placeholder the wiring forgot, and + # the wiring naming one the template lacks is caught by the diff below. + filled = {name: "x" for name in STEP_ARGUMENTS[step]} + assert COT_TEMPLATE_RELATION[step].format(**filled) + + +class TestTheOrderIsADependencyOrder: + def test_no_step_quotes_a_result_that_has_not_been_produced_yet(self): + produced = {CODE_DIFF} + for step in COT_TEMPLATE_FILES: + needed = set(STEP_ARGUMENTS[step].values()) + assert needed <= produced, ( + f"{step} quotes {needed - produced}, which no earlier step produces") + produced.add(STEP_RESULT_KEY[step]) + + def test_every_step_builds_a_prompt_when_run_in_order(self): + assert all(prompt for prompt in run_chain().values()) + + +class TestWhatEachStepQuotes: + def test_the_code_reaches_the_first_review(self): + assert CODE in run_chain()["first_code_review.md"] + + def test_the_single_review_judge_quotes_the_review_it_scores(self): + prompts = run_chain() + assert "<>" in prompts["judge_single_review.md"] + + def test_the_single_review_judge_also_sees_the_original_code(self): + assert CODE in run_chain()["judge_single_review.md"] + + def test_the_step_by_step_analysis_walks_the_linter_and_the_smells(self): + prompt = run_chain()["step_by_step_analysis.md"] + assert "<>" in prompt + assert "<>" in prompt + + def test_the_total_summary_gathers_the_four_earlier_answers(self): + prompt = run_chain()["total_summary.md"] + for earlier in ("first_summary_prompt.md", "first_code_review.md", + "linter.md", "code_smell_detector.md"): + assert f"<>" in prompt, earlier + + def test_the_final_judge_scores_the_summary_with_the_findings_in_hand(self): + prompt = run_chain()["judge.md"] + assert "<>" in prompt + assert "<>" in prompt + assert "<>" in prompt + + def test_every_prompt_carries_the_global_rules(self): + for step, prompt in run_chain().items(): + assert "conduct a code review according to the following global rules" in prompt, step + + +class TestRunningPartOfTheChain: + def test_a_step_whose_input_never_ran_quotes_nothing_rather_than_none(self): + # Selecting only the judge leaves it with no review to score. It must see + # an empty section, not the literal word "None" read as a review. + prompt = build_prompt("judge.md", {CODE_DIFF: CODE}) + assert prompt is not None + assert "None" not in prompt.split("## Review Comment:")[1].split("##")[0] + + def test_an_unknown_step_is_skipped(self): + assert build_prompt("nonsense.md", {CODE_DIFF: CODE}) is None + + def test_a_step_can_run_on_its_own(self): + assert CODE in build_prompt("linter.md", {CODE_DIFF: CODE}) + + +@pytest.mark.parametrize("step", COT_TEMPLATE_FILES) +def test_a_step_never_leaves_an_unfilled_placeholder(step): + prompt = run_chain()[step] + for placeholder in STEP_ARGUMENTS[step]: + assert "{" + placeholder + "}" not in prompt diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index d05d607..b331bf3 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -63,6 +63,84 @@ def test_one_session_serves_all_templates(): assert "total_summary.md" in {name for name, _ in received} +class _FlakySession(_FakeSession): + """Answers every step but the *fail_on*-th, which raises.""" + + def __init__(self, fail_on: int): + super().__init__() + self.fail_on = fail_on + + def post(self, url, **kwargs): + import requests + + self.post_calls.append((url, kwargs)) + if len(self.post_calls) == self.fail_on: + raise requests.RequestException("endpoint down") + return _FakeResponse(f"answer {len(self.post_calls)}".encode()) + + +def _prompts(session) -> list[str]: + return [kwargs["json"]["prompt"] for _url, kwargs in session.post_calls] + + +def test_a_failed_step_is_shown_but_never_quoted_back(): + # The linter step fails. The step-by-step analysis quotes the linter, and must + # quote nothing rather than feed "could not send" back as if it were findings. + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + linter_step = COT_TEMPLATE_FILES.index("linter.md") + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + received = {} + thread.update_response.connect(lambda name, resp: received.__setitem__(name, resp)) + + session = _FlakySession(fail_on=linter_step + 1) + thread._run_templates(session, "print('x')") + + # The failure still reaches the user ... + assert "endpoint down" in received["linter.md"] + # ... every later step still runs ... + assert len(session.post_calls) == len(COT_TEMPLATE_FILES) + # ... and none of them carries the failure text into the model. + later = _prompts(session)[linter_step + 1:] + assert not any("endpoint down" in prompt for prompt in later) + + +def test_an_answered_step_is_quoted_by_the_step_that_needs_it(): + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + session = _FlakySession(fail_on=0) # nothing fails + thread._run_templates(session, "print('x')") + + prompts = _prompts(session) + # judge_single_review is third and quotes the second step's answer. + assert "answer 2" in prompts[COT_TEMPLATE_FILES.index("judge_single_review.md")] + # judge is last and quotes the total summary written just before it. + assert "answer 7" in prompts[COT_TEMPLATE_FILES.index("judge.md")] + + +def test_an_interrupted_run_stops_sending(): + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + session = _FlakySession(fail_on=0) + # Interrupt once two steps have gone out, as closing the widget would. + thread.isInterruptionRequested = lambda: len(session.post_calls) >= 2 + + thread._run_templates(session, "print('x')") + + assert len(session.post_calls) == 2 + + def test_run_closes_session_even_on_error(monkeypatch): _qt_app() from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread as mod From 0eacae64191e4645ff57da5cbd638d0122a83f09 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 3 Aug 2026 05:23:00 +0800 Subject: [PATCH 4/5] Send the prompts the editor edits The prompt editors wrote .md files that nothing ever read: a review always sent the template compiled into the source tree, so editing a prompt changed nothing. The editor existed to adjust what a review asks, and did not. prompt_store resolves a prompt to the file the editor writes, falling back to the built-in constant. The files move from the working directory to ~/.pybreeze/prompts/, beside the SSH known hosts and the prthinker settings, so the prompts someone has written are the same whichever folder the IDE was started from; both editors now show that path. Nothing is orphaned by the move -- no prompt file existed anywhere yet. A missing, empty or unreadable file falls back to the built-in, and so does an edited prompt naming a placeholder the chain cannot fill: a KeyError there would take down a review the user has no way to debug from the UI. Reading a prompt no longer creates the directory, so a session that only looks at built-ins leaves nothing behind. The skill selector in the send window was connected to nothing at all -- picking a template did not load it. It now loads the chosen prompt, edited version first. --- architecture_explore.md | 8 +- .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../extend_ai_gui/code_review/cot_chain.py | 19 +- .../cot_prompt_editor_widget.py | 32 ++- .../prompt_edit_gui/prompt_file_io.py | 5 + .../skills_prompt_editor_widget.py | 32 ++- .../pybreeze_ui/extend_ai_gui/prompt_store.py | 56 +++++ .../extend_ai_gui/skills/skills_send_gui.py | 25 ++- test/test_utils/test_prompt_store.py | 203 ++++++++++++++++++ 10 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py create mode 100644 test/test_utils/test_prompt_store.py diff --git a/architecture_explore.md b/architecture_explore.md index 7dbd4ba..4d41b29 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -243,7 +243,8 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ``` extend_ai_gui/ -├── ai_gui_global_variable.py 模板檔名清單 + 檔名→模板內容對照表 +├── ai_gui_global_variable.py 模板檔名清單 + 檔名→內建模板內容對照表 +├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/) ├── code_review/ │ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 @@ -265,6 +266,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 → total_summary → judge(帶 linter/code smell 脈絡評分總結) ``` +**編輯過的 prompt 會生效**(`prompt_store.py`):每個模板都以程式碼常數出貨,`~/.pybreeze/prompts/<名稱>.md` 存在且非空時覆寫它。編輯器讀寫的就是這個位置,所以在編輯器裡改 prompt 會改變審查實際送出的內容 —— 這正是編輯器存在的理由。檔案缺失、空白、讀不到都退回內建版本;編輯過的 prompt 若含有鏈填不了的 placeholder,記 log 後退回內建,不讓整次審查倒在使用者無法從 UI 診斷的 `KeyError` 上。讀取不會建目錄,只有存檔才會。 + `cot_chain.py` 用兩張表描述接線:`STEP_RESULT_KEY`(每步答案存在哪個 key)與 `STEP_ARGUMENTS`(每步的 placeholder 由哪個 key 填)。**順序即相依順序** —— 每步只能引用它上面的步驟,`test_cot_chain.py` 有結構性測試守住這件事。步驟失敗時錯誤訊息只顯示給使用者、不會被存進 results,避免後續步驟把「傳送失敗」當成審查內容引用。每步都套 `build_global_rule_template()` 包一層全域規則。 安全處理:送出前 `validate_url()`、`allow_redirects=False`、`stream=True` 搭配 `read_capped_text()` 限制回應大小、單一 `requests.Session` 重用 TCP/TLS 連線、`isInterruptionRequested()` 讓 widget 關閉時能中止。 @@ -396,6 +399,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 |---|---| | `ssh_known_hosts` | TOFU 確認過的 SSH host key | | `prthinker_setting.json` | prthinker 後端/平台/金鑰設定 | +| `prompts/*.md` | 編輯過的 CoT / Skill prompt,覆寫內建模板 | | `response_stats.txt` | AI 審查接受/拒絕統計 | | `urls.txt` | AI 審查端點歷史 | @@ -405,7 +409,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 64 個 `test_*.py`、965 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 65 個 `test_*.py`、984 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 9258609..3c11025 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -134,6 +134,8 @@ "prthinker_setting_extra_arguments_label": "Extra command-line arguments", "prthinker_setting_source_path_label": "prthinker source folder", "prthinker_setting_stored_at_label": "Stored at:", + # Prompt editors — where the edited prompts override the built-in ones from + "prompt_editor_stored_at_label": "Prompt files (these override the built-in prompts):", "prthinker_choose_source_path_label": "Choose the prthinker source folder", "prthinker_need_source_path_message": "prthinker is installed from source. Choose the folder holding its " diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index b6fd5f7..fb41649 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -134,6 +134,8 @@ "prthinker_setting_extra_arguments_label": "額外的命令列參數", "prthinker_setting_source_path_label": "prthinker 原始碼資料夾", "prthinker_setting_stored_at_label": "設定檔位置:", + # Prompt 編輯器 —— 編輯過的 prompt 會覆寫內建版本 + "prompt_editor_stored_at_label": "Prompt 檔案位置(會覆寫內建 prompt):", "prthinker_choose_source_path_label": "選擇 prthinker 原始碼資料夾", "prthinker_need_source_path_message": "prthinker 是從原始碼安裝的。請選擇含有 pyproject.toml 的資料夾," diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py index 01119a1..4b64ce3 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py @@ -16,6 +16,8 @@ from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.global_rule import ( build_global_rule_template ) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import load_prompt +from pybreeze.utils.logging.logger import pybreeze_logger # The key the code under review is seeded into the results under. CODE_DIFF = "code_diff" @@ -74,9 +76,9 @@ def build_prompt(step: str, results: Mapping[str, str]) -> str | None: :data:`CODE_DIFF` :return: the prompt to send, or ``None`` when *step* is not part of the chain """ - template = COT_TEMPLATE_RELATION.get(step) + built_in = COT_TEMPLATE_RELATION.get(step) arguments = STEP_ARGUMENTS.get(step) - if template is None or arguments is None: + if built_in is None or arguments is None: return None # A step whose input never ran quotes an empty section rather than the word # "None": the model should see that there is nothing there, not read a value. @@ -84,4 +86,15 @@ def build_prompt(step: str, results: Mapping[str, str]) -> str | None: placeholder: results.get(key, "") for placeholder, key in arguments.items() } - return build_global_rule_template(prompt=template.format(**filled)) + template = load_prompt(step, built_in) + try: + body = template.format(**filled) + except (KeyError, IndexError, ValueError) as error: + # An edited prompt that names a placeholder the chain cannot fill would + # otherwise take the whole review down. Fall back to the built-in and say + # so, rather than failing a step the user cannot debug from the UI. + pybreeze_logger.error( + "Edited prompt %s could not be filled in (%r); using the built-in", + step, error) + body = built_in.format(**filled) + return build_global_rule_template(prompt=body) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py index 84d79ce..a44e492 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py @@ -1,17 +1,18 @@ from __future__ import annotations -import os +from pathlib import Path from PySide6.QtCore import QFileSystemWatcher from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, - QComboBox, QTextEdit, QPushButton, QGroupBox, QMessageBox + QComboBox, QTextEdit, QPushButton, QGroupBox, QLabel, QMessageBox ) from je_editor import language_wrapper from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES, \ COT_TEMPLATE_RELATION from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import prompt_dir, prompt_path class CoTPromptEditor(QWidget): @@ -70,13 +71,22 @@ def __init__(self, prompt_files=None, parent=None): bottom_layout.addWidget(self.save_button) bottom_layout.addWidget(self.create_button) + # 這些檔案覆寫審查實際送出的 prompt,位置要讓人找得到 + # These files override what a review actually sends, so say where they are + where = QLabel( + f"{language_wrapper.language_word_dict.get('prompt_editor_stored_at_label')} " + f"{prompt_dir()}") + where.setWordWrap(True) + # --- Combine layouts (組合版面配置) --- main_layout.addLayout(top_layout) main_layout.addLayout(editor_layout) + main_layout.addWidget(where) main_layout.addLayout(bottom_layout) # --- FileSystemWatcher (檔案監控器) --- - self.watcher = QFileSystemWatcher(self.prompt_files) + self.watcher = QFileSystemWatcher( + [str(prompt_path(name)) for name in self.prompt_files]) self.watcher.fileChanged.connect(self.on_file_changed) # 預設載入第一個檔案 @@ -85,11 +95,10 @@ def __init__(self, prompt_files=None, parent=None): def load_file_content(self, index): """載入選擇的檔案內容到左邊編輯區""" filename = self.prompt_files[index] - self.current_file = filename - if os.path.exists(filename): - with open(filename, encoding="utf-8") as f: - content = f.read() - self.middle_editor.setPlainText(content) + self.current_file = str(prompt_path(filename)) + path = Path(self.current_file) + if path.is_file(): + self.middle_editor.setPlainText(path.read_text(encoding="utf-8")) else: self.middle_editor.setPlainText(language_wrapper.language_word_dict.get( "cot_prompt_editor_file_not_exist" @@ -98,20 +107,23 @@ def load_file_content(self, index): def create_file(self): """建立目前選擇的檔案,若不存在則用模板內容建立""" filename = self.current_file - if os.path.exists(filename): + if Path(filename).is_file(): QMessageBox.information( self, language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_info_title"), language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_file_exists").format(filename=filename)) return - template_content = self.templates.get(filename, "") + template_content = self.templates.get(Path(filename).name, "") if not save_prompt_text( self, filename, template_content, language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_error_title"), ): return + # Only now does the file exist, so only now can it be watched for the + # external edits this editor promises to pick up. + self.watcher.addPath(filename) QMessageBox.information( self, language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_success_title"), diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py index 9e1623a..9627a37 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from pathlib import Path + from PySide6.QtWidgets import QMessageBox, QWidget from pybreeze.utils.logging.logger import pybreeze_logger @@ -18,6 +20,9 @@ def save_prompt_text(parent: QWidget, path: str, content: str, error_title: str) caller can skip its success message). """ try: + # The directory is made here rather than on every read, so a session that + # only looks at the built-in prompts leaves nothing behind. + Path(path).parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as file_handle: file_handle.write(content) return True diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py index d266995..10b29f2 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py @@ -1,17 +1,18 @@ from __future__ import annotations -import os +from pathlib import Path from PySide6.QtCore import QFileSystemWatcher from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, - QComboBox, QTextEdit, QPushButton, QGroupBox, QMessageBox + QComboBox, QTextEdit, QPushButton, QGroupBox, QLabel, QMessageBox ) from je_editor import language_wrapper from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES, \ SKILLS_TEMPLATE_RELATION from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import prompt_dir, prompt_path class SkillPromptEditor(QWidget): @@ -69,12 +70,21 @@ def __init__(self, skill_files=None, parent=None): bottom_layout.addWidget(self.save_button) bottom_layout.addWidget(self.create_button) + # 這些檔案覆寫送出的 prompt,位置要讓人找得到 + # These files override the prompt that is sent, so say where they are + where = QLabel( + f"{language_wrapper.language_word_dict.get('prompt_editor_stored_at_label')} " + f"{prompt_dir()}") + where.setWordWrap(True) + # --- Combine layouts --- main_layout.addLayout(editor_layout) + main_layout.addWidget(where) main_layout.addLayout(bottom_layout) # --- FileSystemWatcher --- - self.watcher = QFileSystemWatcher(self.skill_files) + self.watcher = QFileSystemWatcher( + [str(prompt_path(name)) for name in self.skill_files]) self.watcher.fileChanged.connect(self.on_file_changed) # 預設載入第一個檔案 @@ -83,11 +93,10 @@ def __init__(self, skill_files=None, parent=None): def load_file_content(self, index): """載入選擇的檔案內容到編輯區""" filename = self.skill_files[index] - self.current_file = filename - if os.path.exists(filename): - with open(filename, encoding="utf-8") as f: - content = f.read() - self.middle_editor.setPlainText(content) + self.current_file = str(prompt_path(filename)) + path = Path(self.current_file) + if path.is_file(): + self.middle_editor.setPlainText(path.read_text(encoding="utf-8")) else: self.middle_editor.setPlainText(language_wrapper.language_word_dict.get( "skill_prompt_editor_file_not_exist" @@ -96,7 +105,7 @@ def load_file_content(self, index): def create_file(self): """建立目前選擇的檔案,若不存在則用模板內容建立""" filename = self.current_file - if os.path.exists(filename): + if Path(filename).is_file(): QMessageBox.information( self, language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_info_title"), @@ -104,13 +113,16 @@ def create_file(self): filename=filename)) return - template_content = self.templates.get(filename, "") + template_content = self.templates.get(Path(filename).name, "") if not save_prompt_text( self, filename, template_content, language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_error_title"), ): return + # Only now does the file exist, so only now can it be watched for the + # external edits this editor promises to pick up. + self.watcher.addPath(filename) QMessageBox.information( self, language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_success_title"), diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py new file mode 100644 index 0000000..ed28cb1 --- /dev/null +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py @@ -0,0 +1,56 @@ +"""Where the editable prompt templates live, and how one is resolved. + +Every template ships as a constant in the source tree and may be overridden by a +file the user edits. The file wins when it has content, so editing a prompt in the +editor changes what the review actually sends — which is the only reason the +editor is there. + +The files sit under the user's own directory rather than the working one, so the +prompts someone has written are the same whichever folder the IDE was started +from, matching where the SSH known hosts and the prthinker settings are kept. +""" +from __future__ import annotations + +from pathlib import Path + +from pybreeze.utils.app_dirs import pybreeze_data_dir +from pybreeze.utils.logging.logger import pybreeze_logger + +_PROMPT_DIR_NAME = "prompts" + + +def prompt_dir() -> Path: + """Return the directory the editable prompt files live in. + + Reading and naming a prompt must not create anything: opening the editor to + look at a built-in prompt should leave no directory behind. ``save_prompt_text`` + makes the directory when there is finally something to put in it. + """ + return pybreeze_data_dir() / _PROMPT_DIR_NAME + + +def prompt_path(name: str) -> Path: + """Return the file the prompt called *name* is edited in.""" + return prompt_dir() / name + + +def load_prompt(name: str, default: str) -> str: + """Return the edited prompt *name*, or the built-in *default*. + + A file that is missing, empty or unreadable falls back to the built-in: an + override that says nothing must not leave a review step with nothing to ask, + and a file that cannot be read must not stop the review. + + :param name: the prompt's file name, e.g. ``linter.md`` + :param default: the template compiled into the source tree + :return: the prompt text to use + """ + path = prompt_path(name) + if not path.is_file(): + return default + try: + edited = path.read_text(encoding="utf-8") + except OSError as error: + pybreeze_logger.error("Prompt %s could not be read: %r", name, error) + return default + return edited if edited.strip() else default diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py index fc01c6a..3838706 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py @@ -8,7 +8,10 @@ from PySide6.QtCore import QThread, Signal from je_editor import language_wrapper -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import load_prompt from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, truncate_for_display, @@ -80,6 +83,7 @@ def __init__(self): self.prompt_select_label = QLabel(language_wrapper.language_word_dict.get("skills_prompt_select_label")) self.prompt_select = QComboBox() self.prompt_select.addItems(SKILLS_TEMPLATE_FILES) + self.prompt_select.currentTextChanged.connect(self.load_selected_prompt) layout.addWidget(self.prompt_select_label) layout.addWidget(self.prompt_select) @@ -104,6 +108,25 @@ def __init__(self): self.setLayout(layout) self.thread = None # 保存執行緒 + # 開啟時就把選到的那個模板載進來,選單才不是擺著好看 + # Load the selected template on open, so the selector does something + self.load_selected_prompt(self.prompt_select.currentText()) + + def load_selected_prompt(self, name: str) -> None: + """ + 把選到的 skill 模板載進編輯區 + Put the selected skill template in the edit area. + + 以編輯過的檔案為準,沒有就用內建的;載進來以後仍然可以改,送出的是編輯區的內容。 + The edited file wins over the built-in one. What lands here stays + editable: what is sent is whatever the edit area holds. + + :param name: 模板檔名 / the template's file name + """ + built_in = SKILLS_TEMPLATE_RELATION.get(name) + if built_in is None: + return + self.prompt_input.setPlainText(load_prompt(name, built_in)) def send_prompt(self): # Ignore re-submits while a request is in flight: reassigning self.thread diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py new file mode 100644 index 0000000..71ea936 --- /dev/null +++ b/test/test_utils/test_prompt_store.py @@ -0,0 +1,203 @@ +"""An edited prompt file must be what the review actually sends.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +from pybreeze.pybreeze_ui.extend_ai_gui import prompt_store +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + COT_TEMPLATE_RELATION, SKILLS_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_chain import CODE_DIFF, build_prompt +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import ( + load_prompt, prompt_dir, prompt_path +) + +CODE = "def f():\n pass\n" + + +@pytest.fixture() +def prompts(tmp_path, monkeypatch): + """Point the prompt directory at a temporary one, never the real home.""" + monkeypatch.setattr(prompt_store, "pybreeze_data_dir", lambda: tmp_path) + return tmp_path / "prompts" + + +def write(prompts, name: str, text: str) -> None: + prompts.mkdir(parents=True, exist_ok=True) + (prompts / name).write_text(text, encoding="utf-8") + + +class TestWhereThePromptsLive: + def test_looking_at_a_prompt_creates_no_directory(self, prompts): + # Opening the editor on a built-in prompt must leave nothing behind. + load_prompt("linter.md", "built-in") + assert not prompt_dir().exists() + + def test_saving_creates_the_directory(self, prompts, tmp_path): + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import ( + save_prompt_text + ) + + assert save_prompt_text(None, str(prompt_path("linter.md")), "text", "error") + assert prompt_dir().is_dir() + assert load_prompt("linter.md", "built-in") == "text" + + def test_it_sits_under_the_user_directory_not_the_working_one(self, prompts): + # The whole point of moving off bare filenames: the prompts a user wrote + # are the same whichever folder the IDE was started from. + assert prompt_path("linter.md").parent == prompts + + def test_a_prompt_path_is_named_after_its_template(self, prompts): + assert prompt_path("linter.md").name == "linter.md" + + +class TestResolvingAPrompt: + def test_no_file_means_the_built_in_is_used(self, prompts): + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_an_edited_file_wins(self, prompts): + write(prompts, "linter.md", "my own linter prompt") + assert load_prompt("linter.md", "built-in") == "my own linter prompt" + + def test_an_empty_file_falls_back_rather_than_asking_nothing(self, prompts): + write(prompts, "linter.md", " \n ") + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_an_unreadable_file_falls_back_instead_of_stopping_the_review( + self, prompts, monkeypatch): + write(prompts, "linter.md", "my own linter prompt") + + def refuse(*_args, **_kwargs): + raise OSError("permission denied") + + monkeypatch.setattr(prompt_store.Path, "read_text", refuse) + assert load_prompt("linter.md", "built-in") == "built-in" + + +class TestTheChainUsesTheEditedPrompt: + def test_an_edited_step_reaches_the_prompt_that_is_sent(self, prompts): + write(prompts, "linter.md", "Only report imports. Code:\n{code_diff}") + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert "Only report imports" in prompt + assert CODE in prompt + + def test_an_unedited_step_still_uses_its_built_in(self, prompts): + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert "Only report imports" not in prompt + assert prompt + + def test_the_edited_prompt_is_still_wrapped_in_the_global_rules(self, prompts): + write(prompts, "linter.md", "Only report imports.") + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert "conduct a code review according to the following global rules" in prompt + + def test_an_edit_that_drops_a_placeholder_is_honoured(self, prompts): + # Removing {code_diff} is a legitimate edit, not an error. + write(prompts, "linter.md", "Say hello and nothing else.") + assert "Say hello" in build_prompt("linter.md", {CODE_DIFF: CODE}) + + def test_an_edit_naming_an_unknown_placeholder_falls_back(self, prompts): + # A prompt the chain cannot fill would otherwise take the review down + # with a KeyError the user could not diagnose from the UI. + write(prompts, "linter.md", "Review {this_does_not_exist}") + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert prompt is not None + assert "this_does_not_exist" not in prompt + assert CODE in prompt + + def test_every_template_can_be_overridden(self, prompts): + for name in COT_TEMPLATE_RELATION: + write(prompts, name, f"edited {name}") + prompt = build_prompt(name, {CODE_DIFF: CODE}) + assert f"edited {name}" in prompt, name + + +class TestTheEditorAndTheChainAgree: + """The point of the editor: what is saved there is what a review sends.""" + + def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts): + from PySide6.QtWidgets import QApplication, QMessageBox + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + editor = CoTPromptEditor() + # The confirmation dialogs would block; the save itself is what matters. + QMessageBox.information = staticmethod(lambda *a, **k: None) + + editor.file_selector.setCurrentIndex( + editor.prompt_files.index("linter.md")) + editor.middle_editor.setPlainText("Only report imports. Code:\n{code_diff}") + editor.save_file() + + assert "Only report imports" in build_prompt("linter.md", {CODE_DIFF: CODE}) + editor.deleteLater() + + def test_the_editor_writes_where_the_chain_reads(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + editor = CoTPromptEditor() + editor.file_selector.setCurrentIndex(0) + assert editor.current_file == str(prompt_path(editor.prompt_files[0])) + editor.deleteLater() + + def test_the_editor_shows_an_edited_file_rather_than_the_built_in(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + write(prompts, "linter.md", "my own linter prompt") + editor = CoTPromptEditor() + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + assert editor.middle_editor.toPlainText() == "my own linter prompt" + editor.deleteLater() + + +class TestTheSkillSelectorLoadsWhatItNames: + def test_choosing_a_skill_loads_its_built_in_text(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + + QApplication.instance() or QApplication([]) + update_language_dict() + widget = SkillsSendGUI() + first = widget.prompt_select.currentText() + assert widget.prompt_input.toPlainText() == SKILLS_TEMPLATE_RELATION[first] + widget.deleteLater() + + def test_an_edited_skill_is_what_the_selector_loads(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + + QApplication.instance() or QApplication([]) + update_language_dict() + name = next(iter(SKILLS_TEMPLATE_RELATION)) + write(prompts, name, "my own skill prompt") + widget = SkillsSendGUI() + widget.prompt_select.setCurrentText(name) + assert widget.prompt_input.toPlainText() == "my own skill prompt" + widget.deleteLater() From d0068d25f9516c442490585edff5199c4c30ac64 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 3 Aug 2026 05:33:21 +0800 Subject: [PATCH 5/5] Write the prompt editor once instead of twice The CoT and Skill editors were the same 157 lines each, differing only in which templates they list and which language keys label them. Loading the selected file, creating it from its built-in template, saving, and picking up an external edit were all written out twice, so a fix to one of them was a fix to half the editors. PromptEditorWidget holds that behaviour; each editor is now the file list, the templates and a PromptEditorLabels of its language keys. 314 lines become 233. The keys stay literal strings at the subclass rather than being built from a prefix, because the parity test finds keys by reading the source for language_word_dict.get("..."). A prefix would have slipped past it, so the keys are declared plainly and a new test walks the two label sets directly -- a renamed key is still caught rather than showing up as a blank button. --- architecture_explore.md | 7 +- .../cot_prompt_editor_widget.py | 177 +++--------------- .../prompt_edit_gui/prompt_editor_widget.py | 165 ++++++++++++++++ .../skills_prompt_editor_widget.py | 177 +++--------------- test/test_utils/test_language_parity.py | 21 +++ 5 files changed, 244 insertions(+), 303 deletions(-) create mode 100644 pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py diff --git a/architecture_explore.md b/architecture_explore.md index 4d41b29..313f66e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -250,8 +250,9 @@ extend_ai_gui/ │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 │ └── cot_code_review_gui.py UI ├── prompt_edit_gui/ -│ ├── cot_prompt_editor_widget.py 編輯 8 個 CoT 模板(QFileSystemWatcher 熱更新) -│ ├── skills_prompt_editor_widget.py 編輯 2 個 Skill 模板 +│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新) +│ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 +│ ├── skills_prompt_editor_widget.py 2 個 Skill 模板的檔案清單+語言鍵 │ ├── prompt_file_io.py 共用存檔(失敗跳警告對話框) │ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 @@ -409,7 +410,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 65 個 `test_*.py`、984 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 65 個 `test_*.py`、985 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py index a44e492..c74b833 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py @@ -1,157 +1,34 @@ +"""The editor for the chain-of-thought review prompts.""" from __future__ import annotations -from pathlib import Path - -from PySide6.QtCore import QFileSystemWatcher -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, - QComboBox, QTextEdit, QPushButton, QGroupBox, QLabel, QMessageBox +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + COT_TEMPLATE_FILES, COT_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_editor_widget import ( + PromptEditorLabels, PromptEditorWidget ) -from je_editor import language_wrapper - -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES, \ - COT_TEMPLATE_RELATION -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import prompt_dir, prompt_path - - -class CoTPromptEditor(QWidget): - def __init__(self, prompt_files=None, parent=None): - super().__init__(parent) - self.prompt_files = prompt_files or COT_TEMPLATE_FILES - - # 對應檔案名稱與模板內容 - self.templates = COT_TEMPLATE_RELATION - - self.setWindowTitle(language_wrapper.language_word_dict.get( - "cot_prompt_editor_window_title" - )) # 視窗標題:Prompt 編輯器 - - # --- Layouts (版面配置) --- - main_layout = QVBoxLayout(self) - top_layout = QHBoxLayout() - editor_layout = QHBoxLayout() - bottom_layout = QHBoxLayout() - - # --- ComboBox for selecting files (下拉選單選擇檔案) --- - self.file_selector = QComboBox() - self.file_selector.addItems(self.prompt_files) - self.file_selector.currentIndexChanged.connect(self.load_file_content) - - # --- Left Editable panel (左邊編輯區塊) --- - self.middle_editor = QTextEdit() - prompt_group = QGroupBox(language_wrapper.language_word_dict.get( - "cot_prompt_editor_groupbox_edit_file_content" - )) # 左邊編輯檔案內容 - middle_layout = QVBoxLayout() - middle_layout.addWidget(self.middle_editor) - prompt_group.setLayout(middle_layout) - - editor_layout.addWidget(prompt_group, 1) - - # --- Buttons --- - self.create_button = QPushButton(language_wrapper.language_word_dict.get( - "cot_prompt_editor_button_create_file" - )) - self.create_button.clicked.connect(self.create_file) - - self.save_button = QPushButton(language_wrapper.language_word_dict.get( - "cot_prompt_editor_button_save_file" - )) - self.save_button.clicked.connect(self.save_file) - - self.reload_button = QPushButton(language_wrapper.language_word_dict.get( - "cot_prompt_editor_button_reload_file" - )) - self.reload_button.clicked.connect(lambda: self.load_file_content(self.file_selector.currentIndex())) - - bottom_layout.addWidget(self.file_selector) - bottom_layout.addStretch() - bottom_layout.addWidget(self.reload_button) - bottom_layout.addWidget(self.save_button) - bottom_layout.addWidget(self.create_button) - - # 這些檔案覆寫審查實際送出的 prompt,位置要讓人找得到 - # These files override what a review actually sends, so say where they are - where = QLabel( - f"{language_wrapper.language_word_dict.get('prompt_editor_stored_at_label')} " - f"{prompt_dir()}") - where.setWordWrap(True) - - # --- Combine layouts (組合版面配置) --- - main_layout.addLayout(top_layout) - main_layout.addLayout(editor_layout) - main_layout.addWidget(where) - main_layout.addLayout(bottom_layout) - - # --- FileSystemWatcher (檔案監控器) --- - self.watcher = QFileSystemWatcher( - [str(prompt_path(name)) for name in self.prompt_files]) - self.watcher.fileChanged.connect(self.on_file_changed) - - # 預設載入第一個檔案 - self.load_file_content(0) - - def load_file_content(self, index): - """載入選擇的檔案內容到左邊編輯區""" - filename = self.prompt_files[index] - self.current_file = str(prompt_path(filename)) - path = Path(self.current_file) - if path.is_file(): - self.middle_editor.setPlainText(path.read_text(encoding="utf-8")) - else: - self.middle_editor.setPlainText(language_wrapper.language_word_dict.get( - "cot_prompt_editor_file_not_exist" - ).format(filename=filename)) - - def create_file(self): - """建立目前選擇的檔案,若不存在則用模板內容建立""" - filename = self.current_file - if Path(filename).is_file(): - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_info_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_file_exists").format(filename=filename)) - return - - template_content = self.templates.get(Path(filename).name, "") - if not save_prompt_text( - self, filename, template_content, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_error_title"), - ): - return - # Only now does the file exist, so only now can it be watched for the - # external edits this editor promises to pick up. - self.watcher.addPath(filename) - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_file_created").format(filename=filename)) - self.load_file_content(self.file_selector.currentIndex()) +COT_LABELS = PromptEditorLabels( + window_title="cot_prompt_editor_window_title", + edit_group="cot_prompt_editor_groupbox_edit_file_content", + create_button="cot_prompt_editor_button_create_file", + save_button="cot_prompt_editor_button_save_file", + reload_button="cot_prompt_editor_button_reload_file", + file_not_exist="cot_prompt_editor_file_not_exist", + info_title="cot_prompt_editor_msgbox_info_title", + error_title="cot_prompt_editor_msgbox_error_title", + success_title="cot_prompt_editor_msgbox_success_title", + file_exists="cot_prompt_editor_msgbox_file_exists", + file_created="cot_prompt_editor_msgbox_file_created", + file_saved="cot_prompt_editor_msgbox_file_saved", + no_file_selected="cot_prompt_editor_msgbox_no_file_selected", +) - def on_file_changed(self, path): - """當檔案被外部修改時即時更新""" - if path == self.current_file: - self.load_file_content(self.file_selector.currentIndex()) - def save_file(self): - """將左邊編輯區內容儲存到目前檔案""" - if not hasattr(self, "current_file"): - QMessageBox.warning( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_error_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_no_file_selected")) - return +class CoTPromptEditor(PromptEditorWidget): + """Edit the prompts the chain-of-thought review sends, step by step.""" - content = self.middle_editor.toPlainText() - if not save_prompt_text( - self, self.current_file, content, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_error_title"), - ): - return - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_file_saved").format( - filename=self.current_file)) + def __init__(self, prompt_files=None, parent=None) -> None: + super().__init__( + prompt_files or COT_TEMPLATE_FILES, COT_TEMPLATE_RELATION, + COT_LABELS, parent) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py new file mode 100644 index 0000000..bb40054 --- /dev/null +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -0,0 +1,165 @@ +"""One prompt editor, shared by the CoT and the Skill template editors. + +The two differ in exactly two things: which templates they list, and which +language keys label them. Everything else — loading the selected file, creating +it from its built-in template, saving, and picking up an external edit — was +written twice, line for line. It lives here once instead. + +The language keys arrive as a :class:`PromptEditorLabels` of literal strings +rather than being built from a prefix, so a key that does not exist can still be +caught by a test reading the source rather than only at runtime. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtCore import QFileSystemWatcher +from PySide6.QtWidgets import ( + QComboBox, QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, + QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import prompt_dir, prompt_path + + +@dataclass(frozen=True) +class PromptEditorLabels: + """The language keys one editor labels itself with.""" + + window_title: str + edit_group: str + create_button: str + save_button: str + reload_button: str + file_not_exist: str + info_title: str + error_title: str + success_title: str + file_exists: str + file_created: str + file_saved: str + no_file_selected: str + + +class PromptEditorWidget(QWidget): + """Edit the prompt files that override the built-in templates.""" + + def __init__(self, files: list[str], templates: dict[str, str], + labels: PromptEditorLabels, parent=None) -> None: + """ + :param files: the template file names this editor offers, in order + :param templates: file name -> the template compiled into the source tree + :param labels: the language keys this editor is labelled with + """ + super().__init__(parent) + self.prompt_files = files + self.templates = templates + self._labels = labels + self.current_file: str | None = None + + word = language_wrapper.language_word_dict + self.setWindowTitle(word.get(labels.window_title)) + + self.file_selector = QComboBox() + self.file_selector.addItems(self.prompt_files) + self.file_selector.currentIndexChanged.connect(self.load_file_content) + + self.middle_editor = QTextEdit() + group = QGroupBox(word.get(labels.edit_group)) + group_layout = QVBoxLayout() + group_layout.addWidget(self.middle_editor) + group.setLayout(group_layout) + + self.reload_button = QPushButton(word.get(labels.reload_button)) + self.reload_button.clicked.connect( + lambda: self.load_file_content(self.file_selector.currentIndex())) + self.save_button = QPushButton(word.get(labels.save_button)) + self.save_button.clicked.connect(self.save_file) + self.create_button = QPushButton(word.get(labels.create_button)) + self.create_button.clicked.connect(self.create_file) + + bottom_layout = QHBoxLayout() + bottom_layout.addWidget(self.file_selector) + bottom_layout.addStretch() + for button in (self.reload_button, self.save_button, self.create_button): + bottom_layout.addWidget(button) + + # 這些檔案覆寫實際送出的 prompt,位置要讓人找得到 + # These files override the prompt that is sent, so say where they are + where = QLabel(f"{word.get('prompt_editor_stored_at_label')} {prompt_dir()}") + where.setWordWrap(True) + + main_layout = QVBoxLayout(self) + main_layout.addWidget(group) + main_layout.addWidget(where) + main_layout.addLayout(bottom_layout) + + # 檔案監控器:外部改動即時反映 / Pick up edits made outside the editor + self.watcher = QFileSystemWatcher( + [str(prompt_path(name)) for name in self.prompt_files]) + self.watcher.fileChanged.connect(self.on_file_changed) + + if self.prompt_files: + self.load_file_content(0) + + def load_file_content(self, index: int) -> None: + """載入選擇的檔案內容 / Show the selected file, or say it is not there yet.""" + name = self.prompt_files[index] + self.current_file = str(prompt_path(name)) + path = Path(self.current_file) + if path.is_file(): + self.middle_editor.setPlainText(path.read_text(encoding="utf-8")) + return + self.middle_editor.setPlainText( + language_wrapper.language_word_dict.get( + self._labels.file_not_exist).format(filename=name)) + + def create_file(self) -> None: + """用內建模板建立目前選擇的檔案 / Create the selected file from its built-in template.""" + word = language_wrapper.language_word_dict + if self.current_file is None: + return + if Path(self.current_file).is_file(): + QMessageBox.information( + self, word.get(self._labels.info_title), + word.get(self._labels.file_exists).format(filename=self.current_file)) + return + + content = self.templates.get(Path(self.current_file).name, "") + if not save_prompt_text( + self, self.current_file, content, word.get(self._labels.error_title)): + return + + # Only now does the file exist, so only now can it be watched for the + # external edits this editor promises to pick up. + self.watcher.addPath(self.current_file) + QMessageBox.information( + self, word.get(self._labels.success_title), + word.get(self._labels.file_created).format(filename=self.current_file)) + self.load_file_content(self.file_selector.currentIndex()) + + def on_file_changed(self, path: str) -> None: + """外部改動時重新載入 / Reload when the file changes underneath us.""" + if path == self.current_file: + self.load_file_content(self.file_selector.currentIndex()) + + def save_file(self) -> None: + """把編輯區內容存回檔案 / Write the edit area back to the file.""" + word = language_wrapper.language_word_dict + if self.current_file is None: + QMessageBox.warning( + self, word.get(self._labels.error_title), + word.get(self._labels.no_file_selected)) + return + + if not save_prompt_text( + self, self.current_file, self.middle_editor.toPlainText(), + word.get(self._labels.error_title)): + return + self.watcher.addPath(self.current_file) + QMessageBox.information( + self, word.get(self._labels.success_title), + word.get(self._labels.file_saved).format(filename=self.current_file)) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py index 10b29f2..502ae86 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py @@ -1,157 +1,34 @@ +"""The editor for the reusable skill prompts.""" from __future__ import annotations -from pathlib import Path - -from PySide6.QtCore import QFileSystemWatcher -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, - QComboBox, QTextEdit, QPushButton, QGroupBox, QLabel, QMessageBox +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_editor_widget import ( + PromptEditorLabels, PromptEditorWidget ) -from je_editor import language_wrapper - -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES, \ - SKILLS_TEMPLATE_RELATION -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import prompt_dir, prompt_path - - -class SkillPromptEditor(QWidget): - def __init__(self, skill_files=None, parent=None): - super().__init__(parent) - self.skill_files = skill_files or SKILLS_TEMPLATE_FILES - - # 對應檔案名稱與模板內容 - self.templates = SKILLS_TEMPLATE_RELATION - - self.setWindowTitle(language_wrapper.language_word_dict.get( - "skill_prompt_editor_window_title" - )) # 視窗標題:Skill Prompt 編輯器 - - # --- Layouts --- - main_layout = QVBoxLayout(self) - editor_layout = QHBoxLayout() - bottom_layout = QHBoxLayout() - - # --- ComboBox for selecting files --- - self.file_selector = QComboBox() - self.file_selector.addItems(self.skill_files) - self.file_selector.currentIndexChanged.connect(self.load_file_content) - - # --- Editable panel --- - self.middle_editor = QTextEdit() - skill_group = QGroupBox(language_wrapper.language_word_dict.get( - "skill_prompt_editor_groupbox_edit_file_content" - )) - middle_layout = QVBoxLayout() - middle_layout.addWidget(self.middle_editor) - skill_group.setLayout(middle_layout) - - editor_layout.addWidget(skill_group, 1) - - # --- Buttons --- - self.create_button = QPushButton(language_wrapper.language_word_dict.get( - "skill_prompt_editor_button_create_file" - )) - self.create_button.clicked.connect(self.create_file) - - self.save_button = QPushButton(language_wrapper.language_word_dict.get( - "skill_prompt_editor_button_save_file" - )) - self.save_button.clicked.connect(self.save_file) - - self.reload_button = QPushButton(language_wrapper.language_word_dict.get( - "skill_prompt_editor_button_reload_file" - )) - self.reload_button.clicked.connect(lambda: self.load_file_content(self.file_selector.currentIndex())) - - bottom_layout.addWidget(self.file_selector) - bottom_layout.addStretch() - bottom_layout.addWidget(self.reload_button) - bottom_layout.addWidget(self.save_button) - bottom_layout.addWidget(self.create_button) - - # 這些檔案覆寫送出的 prompt,位置要讓人找得到 - # These files override the prompt that is sent, so say where they are - where = QLabel( - f"{language_wrapper.language_word_dict.get('prompt_editor_stored_at_label')} " - f"{prompt_dir()}") - where.setWordWrap(True) - - # --- Combine layouts --- - main_layout.addLayout(editor_layout) - main_layout.addWidget(where) - main_layout.addLayout(bottom_layout) - - # --- FileSystemWatcher --- - self.watcher = QFileSystemWatcher( - [str(prompt_path(name)) for name in self.skill_files]) - self.watcher.fileChanged.connect(self.on_file_changed) - - # 預設載入第一個檔案 - self.load_file_content(0) - - def load_file_content(self, index): - """載入選擇的檔案內容到編輯區""" - filename = self.skill_files[index] - self.current_file = str(prompt_path(filename)) - path = Path(self.current_file) - if path.is_file(): - self.middle_editor.setPlainText(path.read_text(encoding="utf-8")) - else: - self.middle_editor.setPlainText(language_wrapper.language_word_dict.get( - "skill_prompt_editor_file_not_exist" - ).format(filename=filename)) - - def create_file(self): - """建立目前選擇的檔案,若不存在則用模板內容建立""" - filename = self.current_file - if Path(filename).is_file(): - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_info_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_file_exists").format( - filename=filename)) - return - - template_content = self.templates.get(Path(filename).name, "") - if not save_prompt_text( - self, filename, template_content, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_error_title"), - ): - return - # Only now does the file exist, so only now can it be watched for the - # external edits this editor promises to pick up. - self.watcher.addPath(filename) - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_file_created").format( - filename=filename)) - self.load_file_content(self.file_selector.currentIndex()) +SKILL_LABELS = PromptEditorLabels( + window_title="skill_prompt_editor_window_title", + edit_group="skill_prompt_editor_groupbox_edit_file_content", + create_button="skill_prompt_editor_button_create_file", + save_button="skill_prompt_editor_button_save_file", + reload_button="skill_prompt_editor_button_reload_file", + file_not_exist="skill_prompt_editor_file_not_exist", + info_title="skill_prompt_editor_msgbox_info_title", + error_title="skill_prompt_editor_msgbox_error_title", + success_title="skill_prompt_editor_msgbox_success_title", + file_exists="skill_prompt_editor_msgbox_file_exists", + file_created="skill_prompt_editor_msgbox_file_created", + file_saved="skill_prompt_editor_msgbox_file_saved", + no_file_selected="skill_prompt_editor_msgbox_no_file_selected", +) - def on_file_changed(self, path): - """當檔案被外部修改時即時更新""" - if path == self.current_file: - self.load_file_content(self.file_selector.currentIndex()) - def save_file(self): - """將編輯區內容儲存到目前檔案""" - if not hasattr(self, "current_file"): - QMessageBox.warning( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_error_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_no_file_selected")) - return +class SkillPromptEditor(PromptEditorWidget): + """Edit the reusable skill prompts the send window offers.""" - content = self.middle_editor.toPlainText() - if not save_prompt_text( - self, self.current_file, content, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_error_title"), - ): - return - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_file_saved").format( - filename=self.current_file)) + def __init__(self, skill_files=None, parent=None) -> None: + super().__init__( + skill_files or SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION, + SKILL_LABELS, parent) diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 26a206f..0cbaf13 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -61,3 +61,24 @@ def test_every_get_key_exists_in_dict(self): used = _code_used_keys() missing = {k: src for k, src in used.items() if k not in EN} assert not missing, f"language_word_dict.get() keys missing from the dict: {missing}" + + def test_every_prompt_editor_label_key_exists(self): + # The prompt editors pass their keys through a dataclass, so the regex + # above cannot see them. Check the declared keys directly instead, or a + # renamed key would show up as a blank button with nothing to catch it. + import dataclasses + + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + COT_LABELS + ) + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_editor_widget import ( + SKILL_LABELS + ) + + missing = [ + key + for labels in (COT_LABELS, SKILL_LABELS) + for key in dataclasses.astuple(labels) + if key not in EN + ] + assert not missing, f"Prompt editor label keys missing from the dict: {missing}"