Skip to content

Commit 509dbfd

Browse files
committed
Read a model reply through the text property, not the deprecated call
langchain deprecated calling AIMessage.text as a method; it still returns the right value, so nothing failed and nothing warned loudly enough to notice. The AI widget had no tests at all, which is why this sat unseen through a langchain upgrade. The reply path now has its own tests, including one that records warnings rather than raising them: call_ai_model catches every exception and answers with a message box, so a raised warning would be swallowed there instead of failing. Confirmed the test fails on the deprecated form before fixing it.
1 parent f41abb8 commit 509dbfd

3 files changed

Lines changed: 101 additions & 5 deletions

File tree

architecture_explore.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵
1616
| 語言 / 版本 | Python 3.10+(CI 測 3.10 / 3.11 / 3.12) |
1717
| UI 框架 | PySide6 6.11.0 + qt-material 主題 |
1818
| 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython``watchdog``qtconsole` + `IPython``langchain_openai` + `langchain_core``frontengine` |
19-
| 測試 | pytest + pytest-qt,88 個測試檔、約 13,400|
19+
| 測試 | pytest + pytest-qt,89 個測試檔、約 13,450|
2020
| 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit |
2121

2222
### 各套件規模
@@ -69,7 +69,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵
6969
**設計慣例**:幾乎每個功能都拆成「純邏輯 + Qt 整合層」兩塊。
7070
例如折疊 = `utils/code_folding/fold_regions.py`(算區塊)+ `pyside_ui/code/folding/folding_manager.py`(藏行、重畫);
7171
書籤 = `utils/bookmark/bookmark_navigation.py` + `pyside_ui/code/bookmark/bookmark_manager.py`
72-
這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 88 個測試檔的原因。
72+
這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 89 個測試檔的原因。
7373

7474
---
7575

@@ -478,7 +478,7 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff
478478

479479
## 7. 測試與 CI
480480

481-
- `test/` 88 個測試檔、約 13,400 行,與模組大致一對一(`test_fold_regions.py``test_shortcut_registry.py`…)。
481+
- `test/` 89 個測試檔、約 13,450 行,與模組大致一對一(`test_fold_regions.py``test_shortcut_registry.py`…)。
482482
- `conftest.py` 提供 session 級 `qapp``tmp_dir``tmp_file`,以及 autouse 的「等工具列背景執行緒結束」fixture;
483483
`collect_ignore_glob` 排除會真的開視窗的 `start_qt_ui.py` / `extend_test.py`
484484
- `pyproject.toml` 設定 `testpaths = ["test"]``qt_api = "pyside6"`;bandit 排除測試目錄(pytest 慣用 `assert`)。

je_editor/pyside_ui/main_ui/ai_widget/langchain_interface.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ def call_ai_model(self, prompt: str) -> str | None:
6262
"""
6363
message = None
6464
try:
65-
# 呼叫 AI 並取得回覆 / Invoke AI and get response
66-
message = self.chat_ai.invoke(prompt).text()
65+
# 呼叫 AI 並取得回覆;``text`` 是屬性,當成方法呼叫已被 langchain 標為棄用
66+
# Invoke AI and get response. ``text`` is a property: calling it as a
67+
# method is deprecated in langchain and will stop working.
68+
message = self.chat_ai.invoke(prompt).text
6769

6870
# 嘗試過濾掉 <think> 標籤前的內容,只保留主要回覆
6971
# Try to filter out content before </think>, keep only main response

test/test_langchain_interface.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for how the AI widget reads a reply out of a langchain message."""
2+
from __future__ import annotations
3+
4+
import os
5+
import warnings
6+
7+
import pytest
8+
from langchain_core.messages import AIMessage
9+
10+
from je_editor.pyside_ui.main_ui.ai_widget.langchain_interface import LangChainInterface
11+
12+
13+
class FakeChat:
14+
"""Stand in for ChatOpenAI, returning a real AIMessage without any network."""
15+
16+
def __init__(self, content: str) -> None:
17+
self._content = content
18+
self.prompts: list[str] = []
19+
20+
def invoke(self, prompt: str) -> AIMessage:
21+
self.prompts.append(prompt)
22+
return AIMessage(content=self._content)
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def _restore_openai_environment():
27+
"""The interface writes its settings into os.environ; put them back afterwards."""
28+
keys = ("OPENAI_BASE_URL", "OPENAI_API_KEY", "CHAT_MODEL")
29+
saved = {key: os.environ.get(key) for key in keys}
30+
yield
31+
for key, value in saved.items():
32+
if value is None:
33+
os.environ.pop(key, None)
34+
else:
35+
os.environ[key] = value
36+
37+
38+
def build_interface(content: str) -> LangChainInterface:
39+
"""An interface whose model is replaced by a fake that answers with ``content``."""
40+
interface = LangChainInterface(
41+
main_window=None,
42+
prompt_template="You are a {role}.",
43+
base_url="https://example.invalid/v1",
44+
api_key="not-a-real-key",
45+
chat_model="gpt-4o-mini",
46+
)
47+
interface.chat_ai = FakeChat(content)
48+
return interface
49+
50+
51+
class TestReadingTheReply:
52+
def test_a_plain_reply_comes_back_unchanged(self):
53+
assert build_interface("hello there").call_ai_model("hi") == "hello there"
54+
55+
def test_the_prompt_reaches_the_model(self):
56+
interface = build_interface("anything")
57+
interface.call_ai_model("what is 2 + 2?")
58+
assert interface.chat_ai.prompts == ["what is 2 + 2?"]
59+
60+
def test_an_empty_reply_stays_empty(self):
61+
assert build_interface("").call_ai_model("hi") == ""
62+
63+
def test_reading_the_text_warns_of_nothing_deprecated(self):
64+
"""
65+
``text`` is a property; calling it as a method is deprecated upstream and
66+
will eventually stop working. Nothing else here would notice, because the
67+
deprecated form still returns the right value.
68+
69+
The warnings are recorded rather than raised: ``call_ai_model`` catches
70+
every exception and answers with a message box, so a raised warning would
71+
be swallowed there instead of failing the test.
72+
"""
73+
interface = build_interface("hello there")
74+
with warnings.catch_warnings(record=True) as caught:
75+
warnings.simplefilter("always")
76+
assert interface.call_ai_model("hi") == "hello there"
77+
deprecated = [
78+
str(warning.message) for warning in caught
79+
if issubclass(warning.category, DeprecationWarning)
80+
]
81+
assert not deprecated, deprecated
82+
83+
84+
class TestStrippingTheThinkingBlock:
85+
def test_content_before_the_closing_tag_is_dropped(self):
86+
interface = build_interface("<think>internal reasoning</think>\n the answer ")
87+
assert interface.call_ai_model("hi") == "the answer"
88+
89+
def test_a_reply_without_the_tag_is_left_alone(self):
90+
assert build_interface("just the answer").call_ai_model("hi") == "just the answer"
91+
92+
def test_only_the_first_closing_tag_starts_the_answer(self):
93+
interface = build_interface("<think>a</think>first</think>second")
94+
assert interface.call_ai_model("hi") == "first</think>second"

0 commit comments

Comments
 (0)