diff --git a/doc/ARCHITECTURE_REVIEW_FINDINGS.md b/doc/ARCHITECTURE_REVIEW_FINDINGS.md index 2f65dcb..eca224b 100644 --- a/doc/ARCHITECTURE_REVIEW_FINDINGS.md +++ b/doc/ARCHITECTURE_REVIEW_FINDINGS.md @@ -233,7 +233,7 @@ Alongside this batch, `doc/PRODUCTION_ROADMAP.md` was written: a dependency-sequ 56. **[MED] The model-facing protocol is regex-scraped free text.** Responses are mined with regexes for ```fences```, custom `` tags, ``/`` tags, `--- REASONING ---` markers, and Harmony channel tokens (`_parse_response` graphlink_window_actions.py:393-429; `split_reasoning_and_content` api_provider.py:461-507). Multiple code blocks are joined into one CodeNode with a `# --- Next Code Block ---` comment, keeping only the first block's language. History entries embed pseudo-markup (`--- EXECUTED PYTHON CODE ---`, `--- SANDBOX REQUEST ---`) that later stages re-parse. Fragile by construction, and the SAFE/UNSAFE bug (§3.4) is this pattern's proven failure mode. -57. **[MED] Silent intent hijacking by keyword heuristic.** `_looks_like_image_generation_request` (graphlink_window_actions.py:159-172) reroutes ordinary chat messages containing verb+noun pairs like "make … a poster" into image generation with no user control and no way to opt out other than rephrasing. +57. **[FIXED 2026-07-14] Silent intent hijacking by keyword heuristic.** Removed `_looks_like_image_generation_request` and the implicit image-generation branch from `send_message()`. Image generation is now only entered through an explicit node action, so ordinary Ollama/local chat cannot be routed into the API-only image backend. Regression coverage is in `tests/test_image_generation_routing.py`. 58. **[LOW] Triplicated `clean_text` markdown strippers.** `ExplainerAgent`, `KeyTakeawayAgent`, and `GroupSummaryAgent` each carry a near-identical ~40-line cleaner (graphlink_agents_core.py:285-346, 417-477, 561-605) that also destroys legitimate content (`*`, `_`, backticks stripped from prose/code). - **[FIXED 2026-07-09] (dedup only)** Extracted to one shared `clean_agent_markdown_response()` helper. Before touching any of the three, captured golden outputs from the *original* implementations across ~9 representative inputs and confirmed the refactored versions are byte-identical. That comparison actually turned up a genuine (narrow) behavioral difference between the three originals: `GroupSummaryAgent` reset its bullet-list tracking state on a section-marker line, the other two didn't — only visible as one extra blank line in the specific case of [bullet, bullet, section-marker, bullet]. Preserved exactly per-agent via a `reset_bullet_state_on_section_header` parameter rather than silently unifying it. Net effect: -48 lines in `graphlink_agents_core.py` despite the new shared function. The "destroys legitimate `*`/`_`/backtick content in prose or code" complaint itself is unchanged (that's the stripper's original design, not something this dedup pass touched) — still open if that behavior itself needs to change. diff --git a/graphlink_app/graphlink_window_actions.py b/graphlink_app/graphlink_window_actions.py index cc0b8f2..5d218b9 100644 --- a/graphlink_app/graphlink_window_actions.py +++ b/graphlink_app/graphlink_window_actions.py @@ -156,21 +156,6 @@ def _wrap_attachment_xml(self, attachment, content): f'' ) - def _looks_like_image_generation_request(self, message): - text = (message or "").strip().lower() - if not text: - return False - - if text.endswith("?") or text.startswith(("how ", "what ", "why ", "can ", "could ", "should ", "would ", "do ")): - return False - - image_verbs = ("generate", "create", "make", "render", "draw", "illustrate", "design") - image_targets = ( - "image", "picture", "photo", "portrait", "illustration", - "art", "artwork", "logo", "icon", "wallpaper", "poster" - ) - return any(verb in text for verb in image_verbs) and any(target in text for target in image_targets) - def send_message(self): message = self.message_input.text().strip() attachments = list(getattr(self, 'pending_attachments', [])) @@ -284,18 +269,9 @@ def send_message(self): assign_history(user_node, history_for_worker) self.session_manager.save_current_chat() - if self._looks_like_image_generation_request(message) and not attachments: - self.current_node = user_node - self.chat_view.reveal_item(user_node) - self.message_input.clear() - self.message_input.setEnabled(True) - self.send_button.setEnabled(True) - self.attach_file_btn.setEnabled(True) - self.clear_attachment() - self.generate_image(user_node) - self.save_chat() - return - + # Image generation is an explicit node action. Never infer it from chat + # text: doing so routes ordinary local/Ollama prompts into the API-only + # image backend and produces a misleading provider-mode error. self._show_pending_response_preview(user_node) worker_thread = ChatWorkerThread(self.agent, history_for_worker, history_context_node) diff --git a/graphlink_app/tests/test_image_generation_routing.py b/graphlink_app/tests/test_image_generation_routing.py new file mode 100644 index 0000000..8316831 --- /dev/null +++ b/graphlink_app/tests/test_image_generation_routing.py @@ -0,0 +1,39 @@ +"""Regression coverage for explicit image-generation routing. + +Chat text must always use the normal chat worker. Image generation is available +only through an explicit node action, so local/Ollama chat cannot be hijacked by +keyword matching and sent to the API-only image backend. +""" + +import ast +from pathlib import Path + + +WINDOW_ACTIONS_PATH = Path(__file__).resolve().parents[1] / "graphlink_window_actions.py" + + +def _method_node(method_name): + tree = ast.parse(WINDOW_ACTIONS_PATH.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == method_name: + return node + raise AssertionError(f"Could not find {method_name} in {WINDOW_ACTIONS_PATH}") + + +def test_send_message_does_not_silently_route_text_to_image_generation(): + send_message = _method_node("send_message") + + image_calls = [ + node + for node in ast.walk(send_message) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "generate_image" + ] + + assert not image_calls + + +def test_explicit_image_generation_action_remains_available(): + generate_image = _method_node("generate_image") + assert generate_image.name == "generate_image"