Compare commits

...
3 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 9980e4764c fix: remove <think> wrappers from think messages
Build and Push / build (release) Successful in 1m49s
Messages in reasoning_content should be plain text, not wrapped
in <think> tags. Removed wrappers from:
- delegation.py household think messages
- orchestration.py status messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:12:17 +01:00
jpmschweitzerandClaude Opus 4.5 4907798e74 fix: use reasoning_content for Open WebUI streaming
Build and Push / build (release) Successful in 51s
Use DeepSeek R1 format (reasoning_content field) instead of <think>
tags in content. Open WebUI now renders thinking as proper
collapsible blocks instead of broken escaped HTML.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:56:45 +01:00
jpmschweitzerandClaude Opus 4.5 fb54887c03 fix: handle HybridRAG keywords schema change
Build and Push / build (release) Successful in 52s
library-desk now returns keywords as dict with core_keywords field.
Client now handles both list and dict formats for backwards compat.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:32:36 +01:00
10 changed files with 114 additions and 117 deletions
+22
View File
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.8.4] - 2025-12-16
### Fixed
- **Remove `<think>` wrappers from think messages** - Messages in `reasoning_content` should be plain text
- Removed `<think>` wrappers from delegation.py household think messages
- Removed `<think>` wrappers from orchestration.py status messages
- Think messages now appear cleanly in Open WebUI's reasoning block
## [1.8.3] - 2025-12-16
### Fixed
- **Open WebUI streaming rendering** - Use `reasoning_content` field for thinking (DeepSeek R1 format) instead of `<think>` tags in `content`
- Open WebUI now renders thinking as proper collapsible blocks instead of broken HTML
## [1.8.2] - 2025-12-16
### Fixed
- **HybridRAG keywords schema mismatch** - library-desk now returns `keywords` as dict with `core_keywords`, client now handles both formats
## [1.8.1] - 2025-12-16
### Fixed
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tatlock"
version = "1.8.1"
version = "1.8.4"
description = "OpenAI-compatible API with Ollama backend"
requires-python = ">=3.12"
dependencies = []
+23 -22
View File
@@ -40,45 +40,46 @@ class ActionType(Enum):
# =============================================================================
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
# Note: No <think> wrappers needed - these go to reasoning_content field
"librarian": {
ActionType.RETRIEVE: {
"start": "<think>Allow me to consult the archives, sir.</think>",
"success": "<think>The Librarian has compiled the relevant findings.</think>",
"error": "<think>I'm afraid the archives proved difficult to access.</think>",
"start": "Allow me to consult the archives, sir.",
"success": "The Librarian has compiled the relevant findings.",
"error": "I'm afraid the archives proved difficult to access.",
},
ActionType.RESEARCH: {
"start": "<think>I've dispatched the Librarian to conduct some fresh research.</think>",
"success": "<think>The Librarian has returned with findings, sir.</think>",
"error": "<think>The research proved inconclusive, I'm afraid.</think>",
"start": "I've dispatched the Librarian to conduct some fresh research.",
"success": "The Librarian has returned with findings, sir.",
"error": "The research proved inconclusive, I'm afraid.",
},
ActionType.CREATE: {
"start": "<think>I'm having the Librarian prepare a new entry.</think>",
"success": "<think>The new material has been properly catalogued, sir.</think>",
"error": "<think>I'm afraid there was difficulty filing the entry.</think>",
"start": "I'm having the Librarian prepare a new entry.",
"success": "The new material has been properly catalogued, sir.",
"error": "I'm afraid there was difficulty filing the entry.",
},
},
"biographer": {
ActionType.RETRIEVE: {
"start": "<think>Let me consult the household records.</think>",
"success": "<think>The Biographer has located the relevant information, sir.</think>",
"error": "<think>I'm unable to locate those particular records.</think>",
"start": "Let me consult the household records.",
"success": "The Biographer has located the relevant information, sir.",
"error": "I'm unable to locate those particular records.",
},
ActionType.RECORD: {
"start": "<think>I've asked the Biographer to take note of this, sir.</think>",
"success": "<think>The household records have been updated accordingly.</think>",
"error": "<think>I'm afraid there was difficulty recording the entry.</think>",
"start": "I've asked the Biographer to take note of this, sir.",
"success": "The household records have been updated accordingly.",
"error": "I'm afraid there was difficulty recording the entry.",
},
},
"housekeeper": {
ActionType.RETRIEVE: {
"start": "<think>Allow me to inquire with the household staff.</think>",
"success": "<think>The staff reports the current status, sir.</think>",
"error": "<think>The household staff is momentarily unavailable, I'm afraid.</think>",
"start": "Allow me to inquire with the household staff.",
"success": "The staff reports the current status, sir.",
"error": "The household staff is momentarily unavailable, I'm afraid.",
},
ActionType.CONTROL: {
"start": "<think>I'm instructing the household staff now, sir.</think>",
"success": "<think>The household has been configured as requested.</think>",
"error": "<think>I'm afraid the staff reports an issue with that request.</think>",
"start": "I'm instructing the household staff now, sir.",
"success": "The household has been configured as requested.",
"error": "I'm afraid the staff reports an issue with that request.",
},
},
}
@@ -139,7 +140,7 @@ def get_think_message(expert: str, task: str, phase: str) -> str:
action_type = _detect_action_type(expert, task)
expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {})
action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {}))
return action_messages.get(phase, f"<think>Consulting {expert}...</think>")
return action_messages.get(phase, f"Consulting {expert}...")
@dataclass
+8 -1
View File
@@ -281,9 +281,16 @@ class LibraryDeskClient:
metadata=r.get("metadata", {}),
))
# Handle keywords being either a list or a dict with core_keywords
raw_keywords = data.get("keywords", [])
if isinstance(raw_keywords, dict):
keywords = raw_keywords.get("core_keywords", [])
else:
keywords = raw_keywords
return HybridRAGResponse(
results=results,
keywords=data.get("keywords", []),
keywords=keywords,
synonyms=data.get("synonyms", []),
related_dossiers=data.get("related_dossiers", []),
formatted_context=data.get("formatted_context", ""),
+13 -13
View File
@@ -176,19 +176,19 @@ async def orchestrate_with_think_updates(
if delegation_task.expert_name == "librarian":
expert_display_name = "The Librarian"
yield f"<think>🤝 Consulting {expert_display_name}...</think>\n"
yield f"🤝 Consulting {expert_display_name}...\n"
# Execute delegation (uses run() internally)
result = await execute_delegation(delegation_task)
if result.success:
yield f"<think>{expert_display_name} completed research.</think>\n"
yield f"{expert_display_name} completed research.\n"
# Yield the expert's findings
if result.output:
yield f"\n{result.output}"
else:
yield f"<think>⚠️ {expert_display_name} encountered an issue: {result.error}</think>\n"
yield f"⚠️ {expert_display_name} encountered an issue: {result.error}\n"
logger.info(
"orchestration_complete",
@@ -449,12 +449,12 @@ async def orchestrate_multi_expert(
return
# Stream: Starting multi-expert coordination
yield f"<think>🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...</think>\n"
yield f"🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...\n"
if mode == ExecutionMode.PARALLEL:
# Parallel execution - emit one update then run all at once
expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks)
yield f"<think>🔄 Consulting in parallel: {expert_names}...</think>\n"
yield f"🔄 Consulting in parallel: {expert_names}...\n"
result = await execute_parallel(tasks)
@@ -462,9 +462,9 @@ async def orchestrate_multi_expert(
for expert_name, expert_result in result.results.items():
display_name = _get_display_name(expert_name)
if expert_result.success:
yield f"<think>{display_name} completed.</think>\n"
yield f"{display_name} completed.\n"
else:
yield f"<think>⚠️ {display_name} failed: {expert_result.error}</think>\n"
yield f"⚠️ {display_name} failed: {expert_result.error}\n"
else:
# Sequential execution - emit updates for each task
@@ -472,27 +472,27 @@ async def orchestrate_multi_expert(
for task in tasks:
display_name = _get_display_name(task.expert_name)
yield f"<think>🤝 Consulting {display_name}...</think>\n"
yield f"🤝 Consulting {display_name}...\n"
task_result = await execute_delegation(task)
result.add_result(task_result)
if task_result.success:
yield f"<think>{display_name} completed.</think>\n"
yield f"{display_name} completed.\n"
else:
yield f"<think>⚠️ {display_name} failed: {task_result.error}</think>\n"
yield f"⚠️ {display_name} failed: {task_result.error}\n"
if stop_on_failure:
yield "<think>🛑 Stopping due to failure.</think>\n"
yield "🛑 Stopping due to failure.\n"
break
result.aggregate_outputs()
# Stream: Summary
if result.all_succeeded:
yield "<think>🎉 All experts completed successfully.</think>\n"
yield "🎉 All experts completed successfully.\n"
else:
failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts)
yield f"<think>⚠️ Some experts failed: {failed_names}</think>\n"
yield f"⚠️ Some experts failed: {failed_names}\n"
# Yield combined output
if result.combined_output:
+1
View File
@@ -55,6 +55,7 @@ class ChatCompletionChunkDelta(CustomBaseModel):
"""Delta in streaming chunk."""
role: str | None = None
content: str | None = None
reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format)
class ChatCompletionChunkChoice(CustomBaseModel):
+6 -35
View File
@@ -172,24 +172,9 @@ async def create_chat_completion_stream(
async for event in stream_generator:
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
# Start <think> block if needed
if not in_reasoning:
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="<think>\n"),
finish_reason=None,
)
],
)
in_reasoning = True
# Stream reasoning delta
# Stream reasoning via reasoning_content field (DeepSeek R1 format)
# Open WebUI renders this as collapsible thinking block
in_reasoning = True
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -198,29 +183,15 @@ async def create_chat_completion_stream(
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=event.delta),
delta=ChatCompletionChunkDelta(reasoning_content=event.delta),
finish_reason=None,
)
],
)
elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
# Close <think> block
if in_reasoning:
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
finish_reason=None,
)
],
)
in_reasoning = False
# Signal end of reasoning block (no content needed)
in_reasoning = False
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
# Stream message content
+14 -10
View File
@@ -248,13 +248,16 @@ class TestHouseholdThinkMessages:
assert "success" in messages, f"{expert}/{action_type} missing 'success'"
assert "error" in messages, f"{expert}/{action_type} missing 'error'"
def test_messages_are_think_tags(self):
"""Test messages are wrapped in <think> tags."""
def test_messages_are_plain_text(self):
"""Test messages are plain text (no <think> wrappers - those go to reasoning_content)."""
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
for action_type, messages in action_types.items():
for phase, msg in messages.items():
assert msg.startswith("<think>"), f"{expert}/{action_type}/{phase}"
assert msg.endswith("</think>"), f"{expert}/{action_type}/{phase}"
# Messages should NOT have <think> wrappers - they go to reasoning_content field
assert "<think>" not in msg, f"{expert}/{action_type}/{phase} should not have <think> wrapper"
assert "</think>" not in msg, f"{expert}/{action_type}/{phase} should not have </think> wrapper"
# Messages should be non-empty strings
assert isinstance(msg, str) and len(msg) > 0, f"{expert}/{action_type}/{phase}"
@pytest.mark.unit
@@ -310,31 +313,32 @@ class TestGetThinkMessage:
def test_librarian_retrieve_start(self):
"""Test getting librarian retrieve start message."""
msg = get_think_message("librarian", "search for Docker", "start")
assert "<think>" in msg
assert "</think>" in msg
# No <think> wrappers - messages go to reasoning_content field
assert "<think>" not in msg
assert "archives" in msg.lower() or "consult" in msg.lower()
def test_librarian_create_success(self):
"""Test getting librarian create success message."""
msg = get_think_message("librarian", "create a wiki page", "success")
assert "<think>" in msg
assert "<think>" not in msg
assert "catalogued" in msg.lower()
def test_biographer_record_start(self):
"""Test getting biographer record start message."""
msg = get_think_message("biographer", "remember my preference", "start")
assert "<think>" in msg
assert "<think>" not in msg
assert "note" in msg.lower() or "biographer" in msg.lower()
def test_housekeeper_control_success(self):
"""Test getting housekeeper control success message."""
msg = get_think_message("housekeeper", "turn on the lights", "success")
assert "<think>" in msg
assert "<think>" not in msg
assert "configured" in msg.lower()
def test_unknown_expert_fallback(self):
"""Test unknown expert gets fallback message."""
msg = get_think_message("unknown_expert", "some task", "start")
assert "<think>" in msg
assert "<think>" not in msg
assert "unknown_expert" in msg.lower()
+4 -4
View File
@@ -208,8 +208,8 @@ class TestOrchestrateWithThinkUpdates:
):
updates.append(update)
# First update should be think tag about consulting
assert any("<think>" in u and "Consulting" in u for u in updates)
# First update should be about consulting (no <think> wrappers anymore)
assert any("Consulting" in u for u in updates)
@pytest.mark.asyncio
async def test_orchestrate_emits_think_after_delegation(self):
@@ -233,8 +233,8 @@ class TestOrchestrateWithThinkUpdates:
):
updates.append(update)
# Should have think tag about completion
assert any("<think>" in u and "completed" in u for u in updates)
# Should have message about completion (no <think> wrappers anymore)
assert any("completed" in u for u in updates)
@pytest.mark.asyncio
async def test_orchestrate_yields_expert_output(self):
+22 -31
View File
@@ -4,7 +4,7 @@ Tests for chat completions streaming wrapper.
Tests that the wrapper correctly:
- Wraps Responses API
- Enables reasoning automatically
- Converts reasoning to <think> tags
- Streams reasoning via reasoning_content field (DeepSeek R1 format)
- Streams both reasoning and content
"""
import json
@@ -17,7 +17,7 @@ from src.chat import constants
@pytest.mark.unit
@pytest.mark.asyncio
async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
"""Test that streaming wrapper automatically enables reasoning."""
"""Test that streaming wrapper automatically enables reasoning via reasoning_content."""
request_data = {
"model": "lorem-tester",
"messages": [
@@ -27,7 +27,7 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
}
chunks_received = []
think_tags_found = False
reasoning_content_found = False
async with async_client.stream(
"POST",
@@ -51,12 +51,12 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
chunk = json.loads(data_str)
chunks_received.append(chunk)
# Check for <think> tags in delta content
# Check for reasoning_content in delta (DeepSeek R1 format)
if "choices" in chunk and len(chunk["choices"]) > 0:
delta = chunk["choices"][0].get("delta", {})
content = delta.get("content")
if content and ("<think>" in content or "</think>" in content):
think_tags_found = True
reasoning = delta.get("reasoning_content")
if reasoning:
reasoning_content_found = True
except json.JSONDecodeError:
pass
@@ -64,14 +64,14 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
# Should have received chunks
assert len(chunks_received) > 0
# Should have found <think> tags (reasoning enabled automatically)
assert think_tags_found, "Expected <think> tags in streaming output"
# Should have found reasoning_content (reasoning enabled automatically)
assert reasoning_content_found, "Expected reasoning_content in streaming output"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncClient):
"""Test that reasoning (<think> tags) comes before actual content."""
"""Test that reasoning_content comes before regular content."""
request_data = {
"model": "lorem-tester",
"messages": [
@@ -80,10 +80,7 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
"stream": True
}
all_content = []
found_think_opening = False
found_think_closing = False
found_content_after_think = False
chunk_types = [] # Track order: 'reasoning' or 'content'
async with async_client.stream(
"POST",
@@ -106,28 +103,22 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
chunk = json.loads(data_str)
if "choices" in chunk and len(chunk["choices"]) > 0:
delta = chunk["choices"][0].get("delta", {})
content = delta.get("content", "")
if content:
all_content.append(content)
reasoning = delta.get("reasoning_content")
content = delta.get("content")
if "<think>" in content:
found_think_opening = True
if "</think>" in content:
found_think_closing = True
# Content after closing think tag
if found_think_closing and content.strip() and "<think>" not in content and "</think>" not in content:
found_content_after_think = True
if reasoning:
chunk_types.append("reasoning")
if content:
chunk_types.append("content")
except json.JSONDecodeError:
pass
# Verify ordering
full_text = "".join(all_content)
if found_think_opening and found_think_closing:
# Reasoning should come before main content
think_start = full_text.index("<think>")
think_end = full_text.index("</think>")
assert think_start < think_end, "Opening <think> should come before closing </think>"
# Verify reasoning comes before content
if "reasoning" in chunk_types and "content" in chunk_types:
first_reasoning = chunk_types.index("reasoning")
first_content = chunk_types.index("content")
assert first_reasoning < first_content, "reasoning_content should come before content"
@pytest.mark.unit