Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/ARCHITECTURE_REVIEW_FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<code_block>` tags, `<think>`/`<thinking>` 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.
Expand Down
30 changes: 3 additions & 27 deletions graphlink_app/graphlink_window_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,21 +156,6 @@ def _wrap_attachment_xml(self, attachment, content):
f'</attachment>'
)

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', []))
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions graphlink_app/tests/test_image_generation_routing.py
Original file line number Diff line number Diff line change
@@ -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"
Loading