Skip to content
Open
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
24 changes: 17 additions & 7 deletions docker/worker/services/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,20 @@ async def ollama_chat(prompt: str, model: str | None = None, timeout: int = 120)
resp.raise_for_status()
data = resp.json()

# DeepSeek v4 flash: reasoning can consume tokens, leaving response empty
# Return reasoning if content is empty
response = data.get("response", "")
if not response and "reasoning" in data:
response = data["reasoning"]

return response
return _extract_response(data)


def _extract_response(data: dict) -> str:
"""Response body -> reply text.

Reasoning models can consume the whole budget on chain-of-thought, leaving
`response` empty. DeepSeek puts that under `reasoning`; glm-5.x-family
models name it `thinking` (verified on Ollama Cloud). Either is better than
returning nothing — the caller would otherwise fail even though the model
completed fine (#38).
"""
return (
data.get("response", "")
or data.get("reasoning", "")
or data.get("thinking", "")
)
23 changes: 22 additions & 1 deletion docker/worker/tasks/reflection.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,27 @@
"""




def _strip_json_fences(response: str) -> str:
"""Strip one markdown code fence around a JSON payload.

Most current chat models wrap JSON in ```json fences even when the prompt
asks for raw JSON. json.loads fails on the opening backticks, the
JSONDecodeError branch fires, and the reflection is stored as {"raw": ...}
— the structured fields (patterns/connections/insights/actions) are lost
and the point degrades to an opaque blob (#38).
"""
stripped = response.strip()
if stripped.startswith("```"):
first_newline = stripped.find("\n")
if first_newline != -1:
stripped = stripped[first_newline + 1:]
if stripped.rstrip().endswith("```"):
stripped = stripped.rstrip()[:-3]
return stripped


async def reflect_on_memories(qdrant: AsyncQdrantClient) -> dict:
"""
Runs a reflection cycle on unreflected or old memories.
Expand Down Expand Up @@ -74,7 +95,7 @@ async def reflect_on_memories(qdrant: AsyncQdrantClient) -> dict:

try:
response = await ollama_chat(prompt)
reflection_data = json.loads(response)
reflection_data = json.loads(_strip_json_fences(response))
except json.JSONDecodeError:
logger.warning("Reflection returned invalid JSON, saving raw")
reflection_data = {"raw": response}
Expand Down
92 changes: 92 additions & 0 deletions docker/worker/tests/test_llm_reflection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Unit tests for the #38 fixes: thinking-fallback in the Ollama client and
markdown-fence stripping before reflection JSON parsing.

The docker worker has no pytest infrastructure (the repo uses script-style
_test_*.py files), so this file adds one deliberately tiny pytest module
scoped to the two changed functions.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from services.llm import _extract_response
from tasks.reflection import _strip_json_fences, reflect_on_memories


def test_extract_response_prefers_content():
data = {"response": "hello", "reasoning": "chain", "thinking": "thoughts"}
assert _extract_response(data) == "hello"


def test_extract_response_falls_back_to_reasoning():
data = {"response": "", "reasoning": "chain-of-thought"}
assert _extract_response(data) == "chain-of-thought"


def test_extract_response_falls_back_to_thinking():
"""glm-5.x-style models return chain-of-thought under `thinking`, with
`response` empty — the case #38 reports on Ollama Cloud."""
data = {"response": "", "thinking": "thoughts", "done_reason": "length"}
assert _extract_response(data) == "thoughts"


def test_extract_response_empty_when_nothing_useful():
assert _extract_response({}) == ""
assert _extract_response({"response": "", "reasoning": None}) == ""


def test_strip_json_fences_passthrough_plain_json():
body = '{"patterns": []}'
assert _strip_json_fences(body) == '{"patterns": []}'


def test_strip_json_fences_language_tagged():
body = '```json\n{"patterns": ["a"]}\n```'
assert json.loads(_strip_json_fences(body)) == {"patterns": ["a"]}


def test_strip_json_fences_bare_fence_and_trailing_space():
body = '```\n{"insights": ["x"]}\n``` '
assert json.loads(_strip_json_fences(body)) == {"insights": ["x"]}


@pytest.mark.asyncio
async def test_reflection_parses_fenced_json_instead_of_raw_blob(monkeypatch):
"""Regression for #38 Bug 2: before the fix, a fenced response failed
json.loads and the reflection was stored as {"raw": ...}, losing the
structured fields."""
captured = {}

class FakeClient:
async def scroll(self, **kwargs):
class Point:
id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
payload = {"source": "chat", "text": "user said hello"}
return ([Point()], None)

async def upsert(self, collection_name, points, **kwargs):
captured["point"] = points[0]

async def set_payload(self, **kwargs):
return None

async def fake_ollama(prompt):
return '```json\n{"patterns": ["greetings"], "insights": ["hello"], "actions": [], "connections": []}\n```'

async def fake_embedding(text):
return [0.1, 0.2, 0.3]

monkeypatch.setattr("tasks.reflection.ollama_chat", fake_ollama)
monkeypatch.setattr("tasks.reflection.get_embedding", fake_embedding)

await reflect_on_memories(FakeClient())

payload = captured["point"].payload
assert "patterns" in payload["text"], "reflection degraded to a raw blob"
assert payload["source"] == "reflection"