Files
webber/webber-api/src/domains/conversations/service.py
T
jpmschweitzerandClaude eb3467d06a fix(webber-api): clear ruff, and two things it was pointing at
97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10
unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both
were visible only because the lint made me look.

`webber version` did not exist. src/cli/commands/version.py defines
show_version(), main.py imported it, and the registration line was never
written — the CLI exposed chat and explore only. The import carried
`# noqa: F401`, which is what kept the omission quiet: someone marked the
symptom as intentional instead of asking why it was unused. show_version is not
redundant with the --version flag; it prints the resolved Ollama URL, model and
debug state, which is the form worth having when something is misconfigured.
Registered, and the suppression dropped because the import is now genuinely used.

test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched
get_agent, and stopped at the comment "For now, verify the explore agent would
be called correctly". It had been counted as a passing test. An AST sweep of all
238 test functions found it was the only one, which is worth knowing — the
problem was contained, not systemic. It is now skipped with a reason, so it
reports as unfinished rather than as passing. Reducing it rather than deleting
its imports was the point: tidying the imports would have made a hollow test
look clean.

Two findings were false positives, and both are recorded rather than silently
worked around:

B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is
awaited at line 326 before `continue` reaches the next iteration, so neither
name can be rebound while the closure is pending, and the exception path
cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays
true if the await ever moves. I had called it a live bug before tracing it,
which is the mistake Rule 5 exists for.

RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its
suggested fix — annotate ClassVar — would remove the field from the model.
ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per
instance; verified by constructing two and confirming their lists are distinct
objects. Suppressed with that evidence in the comment. Ruff cannot see the
pydantic base because BaseSchema is a local subclass of BaseModel.

Also moved a stray `from src.shared.logging import ...` that had drifted below
a function definition, and merged a nested if in the ollama provider.

215 passed, 23 skipped, unchanged except for the new skip. `webber version`
exercised end to end.

mypy is NOT addressed here and the gate still fails on it — 55 errors in 14
files, 35 of them no-any-return from pydantic_ai's untyped returns. That was
hidden behind ruff, because the gate stops at the first failing stage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 15:05:02 +02:00

355 lines
10 KiB
Python

"""
Conversation service - Business logic for conversation management.
Handles CRUD operations, context building, and summarization triggers.
"""
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.domains.agents.base import get_agent
from src.domains.conversations.models import Conversation, Message
from src.domains.conversations.summarize import generate_summary
from src.shared.config import get_settings
from src.shared.logging import get_logger
from src.shared.tokens import count_tokens
logger = get_logger(__name__)
class ConversationService:
"""
Service for managing conversations and messages.
Handles:
- CRUD operations for conversations and messages
- Context building for agent prompts
- Automatic summarization when approaching token limits
"""
def __init__(self, session: AsyncSession):
"""
Initialize with database session.
Args:
session: Async SQLAlchemy session
"""
self.session = session
self.settings = get_settings()
# === Conversation CRUD ===
async def create(
self,
user_id: str,
agent_type: str = "explore",
working_dir: str = ".",
title: str | None = None,
) -> Conversation:
"""
Create a new conversation.
Args:
user_id: Owner's user ID
agent_type: Type of agent for this conversation
working_dir: Working directory for agent
title: Optional title (auto-generated from first message if None)
Returns:
Created Conversation object
"""
conversation = Conversation(
user_id=user_id,
agent_type=agent_type,
working_dir=working_dir,
title=title,
)
self.session.add(conversation)
await self.session.flush()
logger.info(f"Created conversation {conversation.id} for user {user_id}")
return conversation
async def get(self, conversation_id: UUID) -> Conversation | None:
"""Get conversation by ID without messages."""
result = await self.session.execute(
select(Conversation).where(Conversation.id == conversation_id)
)
return result.scalar_one_or_none()
async def get_with_messages(self, conversation_id: UUID) -> Conversation | None:
"""Get conversation by ID with messages loaded."""
result = await self.session.execute(
select(Conversation)
.options(selectinload(Conversation.messages))
.where(Conversation.id == conversation_id)
)
return result.scalar_one_or_none()
async def list_by_user(
self,
user_id: str,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Conversation], int]:
"""
List conversations for a user.
Args:
user_id: User ID to filter by
limit: Maximum results to return
offset: Offset for pagination
Returns:
Tuple of (conversations, total_count)
"""
# Get total count
count_result = await self.session.execute(
select(func.count(Conversation.id))
.where(Conversation.user_id == user_id)
)
total = count_result.scalar() or 0
# Get conversations
result = await self.session.execute(
select(Conversation)
.where(Conversation.user_id == user_id)
.order_by(Conversation.updated_at.desc())
.limit(limit)
.offset(offset)
)
conversations = list(result.scalars().all())
return conversations, total
async def delete(self, conversation_id: UUID) -> bool:
"""Delete a conversation and all its messages."""
conversation = await self.get(conversation_id)
if conversation:
await self.session.delete(conversation)
logger.info(f"Deleted conversation {conversation_id}")
return True
return False
# === Message Operations ===
async def add_message(
self,
conversation_id: UUID,
role: str,
content: str,
) -> Message:
"""
Add a message to a conversation.
Args:
conversation_id: Conversation to add to
role: Message role (user, assistant, system, summary)
content: Message content
Returns:
Created Message object
"""
# Count tokens
token_count = count_tokens(content)
message = Message(
conversation_id=conversation_id,
role=role,
content=content,
token_count=token_count,
)
self.session.add(message)
# Update conversation total tokens
conversation = await self.get(conversation_id)
if conversation:
conversation.total_tokens += token_count
# Auto-generate title from first user message
if conversation.title is None and role == "user":
conversation.title = content[:100] + ("..." if len(content) > 100 else "")
await self.session.flush()
return message
# === Context Building ===
def build_context_prompt(
self,
messages: list[Message],
current_message: str,
) -> str:
"""
Build a prompt with conversation context.
Includes summary (if exists) and recent messages.
Args:
messages: All conversation messages
current_message: The current user message
Returns:
Formatted prompt with context
"""
parts = []
# Find most recent summary
summaries = [m for m in messages if m.is_summary]
if summaries:
latest_summary = summaries[-1]
parts.append(
f"<conversation_summary>\n{latest_summary.content}\n</conversation_summary>"
)
# Get recent non-summary messages
recent = [m for m in messages if not m.is_summary]
keep_count = self.settings.keep_recent_messages
recent = recent[-keep_count:] if len(recent) > keep_count else recent
if recent:
parts.append("<recent_conversation>")
for msg in recent:
role_label = msg.role.upper()
parts.append(f"{role_label}: {msg.content}")
parts.append("</recent_conversation>")
# Add current message
parts.append(f"<current_request>\n{current_message}\n</current_request>")
return "\n\n".join(parts)
# === Agent Integration ===
async def get_agent_response(
self,
conversation_id: UUID,
user_message: str,
) -> str:
"""
Get agent response with conversation context.
Args:
conversation_id: Conversation ID
user_message: Current user message
Returns:
Agent's response text
"""
conversation = await self.get_with_messages(conversation_id)
if not conversation:
raise ValueError(f"Conversation {conversation_id} not found")
agent = get_agent(conversation.agent_type)
if not agent:
raise ValueError(f"Unknown agent type: {conversation.agent_type}")
# Build context prompt
context_prompt = self.build_context_prompt(
conversation.messages,
user_message,
)
# Run agent
response = await agent.run(
context_prompt,
working_dir=conversation.working_dir,
)
return response
# === Summarization ===
async def should_summarize(self, conversation_id: UUID) -> bool:
"""
Check if conversation needs summarization.
Args:
conversation_id: Conversation to check
Returns:
True if summarization should be triggered
"""
conversation = await self.get(conversation_id)
if not conversation:
return False
threshold = self.settings.max_context_tokens * self.settings.summarization_threshold
return conversation.total_tokens > threshold
async def summarize_if_needed(self, conversation_id: UUID) -> bool:
"""
Summarize old messages if approaching token limit.
Args:
conversation_id: Conversation to check and potentially summarize
Returns:
True if summarization was performed
"""
if not await self.should_summarize(conversation_id):
return False
conversation = await self.get_with_messages(conversation_id)
if not conversation:
return False
messages = conversation.messages
keep_count = self.settings.keep_recent_messages
# Don't summarize if not enough messages
if len(messages) <= keep_count + 1:
return False
# Get messages to summarize (exclude recent and existing summaries)
non_summary_msgs = [m for m in messages if not m.is_summary]
to_summarize = non_summary_msgs[:-keep_count]
if not to_summarize:
return False
logger.info(
f"Summarizing {len(to_summarize)} messages in conversation {conversation_id}"
)
# Generate summary
summary_text = await generate_summary(
to_summarize,
working_dir=conversation.working_dir,
)
# Get ID of last summarized message
last_summarized_id = to_summarize[-1].id
# Calculate tokens being removed
removed_tokens = sum(m.token_count for m in to_summarize)
summary_tokens = count_tokens(summary_text)
# Add summary message
summary_message = Message(
conversation_id=conversation_id,
role="summary",
content=summary_text,
token_count=summary_tokens,
is_summary=True,
summarizes_up_to=last_summarized_id,
)
self.session.add(summary_message)
# Mark old messages as summarized (soft delete by excluding from context)
for msg in to_summarize:
msg.is_summary = True # Reuse flag to mark as "summarized away"
# Update conversation token count
conversation.total_tokens = conversation.total_tokens - removed_tokens + summary_tokens
await self.session.flush()
logger.info(
f"Summarization complete: removed {removed_tokens} tokens, "
f"added {summary_tokens} token summary"
)
return True