diff --git a/backend/app/services/resolution_output_generator.py b/backend/app/services/resolution_output_generator.py new file mode 100644 index 00000000..4d8f3e3d --- /dev/null +++ b/backend/app/services/resolution_output_generator.py @@ -0,0 +1,118 @@ +"""Resolution output generator — three deliverables on session resolve.""" +import logging +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.ai_session import AISession +from app.models.session_resolution_output import SessionResolutionOutput +from app.services.assistant_chat_service import _call_ai + +logger = logging.getLogger(__name__) + +RESOLUTION_MODEL = "claude-sonnet-4-6" + + +class ResolutionOutputGenerator: + def __init__(self, db: AsyncSession): + self.db = db + + async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]: + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError(f"Session {session_id} not found") + + context = self._build_session_context(session) + + outputs = [] + for output_type, prompt in [ + ("psa_ticket_notes", self._psa_notes_prompt(context)), + ("knowledge_base", self._kb_article_prompt(context)), + ("client_summary", self._client_summary_prompt(context)), + ]: + content, _, _ = await _call_ai( + system_base="You are a technical documentation assistant for MSP teams.", + rag_context="", + history=[], + new_message=prompt, + max_tokens=2048, + ) + + output = SessionResolutionOutput( + session_id=session_id, + output_type=output_type, + generated_content=content, + status="draft", + generated_by_model=RESOLUTION_MODEL, + ) + self.db.add(output) + outputs.append(output) + + await self.db.flush() + return outputs + + async def edit_output(self, output_id: UUID, edited_content: str) -> SessionResolutionOutput: + result = await self.db.execute( + select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id) + ) + output = result.scalar_one_or_none() + if not output: + raise ValueError(f"Output {output_id} not found") + output.edited_content = edited_content + await self.db.flush() + return output + + async def push_output(self, output_id: UUID, destination: str) -> SessionResolutionOutput: + result = await self.db.execute( + select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id) + ) + output = result.scalar_one_or_none() + if not output: + raise ValueError(f"Output {output_id} not found") + + from datetime import datetime, timezone + output.status = "pushed" + output.pushed_to = destination + output.pushed_at = datetime.now(timezone.utc) + await self.db.flush() + return output + + def _build_session_context(self, session: AISession) -> str: + parts = [ + f"Problem: {session.problem_summary or 'Unknown'}", + f"Domain: {session.problem_domain or 'Unknown'}", + f"Resolution: {session.resolution_summary or 'Not specified'}", + f"Steps taken: {session.step_count}", + ] + msgs = session.conversation_messages or [] + if msgs: + parts.append("\nConversation highlights:") + for msg in msgs[-10:]: + role = msg.get("role", "unknown") + content = msg.get("content", "")[:200] + parts.append(f" [{role}]: {content}") + return "\n".join(parts) + + def _psa_notes_prompt(self, context: str) -> str: + return ( + f"Generate professional PSA ticket notes for this resolved troubleshooting session.\n" + f"Format as structured markdown with: Problem, Diagnostic Steps, Resolution, Recommendations.\n\n{context}" + ) + + def _kb_article_prompt(self, context: str) -> str: + return ( + f"Generate a knowledge base article draft from this resolved session.\n" + f"Include: Symptoms, Root Cause, Resolution Steps, Things to Rule Out First.\n\n{context}" + ) + + def _client_summary_prompt(self, context: str) -> str: + return ( + f"Generate a non-technical summary for the end user/client.\n" + f"Explain what was wrong and what was done to fix it in plain language.\n" + f"No jargon. 2-3 paragraphs max.\n\n{context}" + ) diff --git a/backend/tests/test_resolution_outputs.py b/backend/tests/test_resolution_outputs.py new file mode 100644 index 00000000..a852ebca --- /dev/null +++ b/backend/tests/test_resolution_outputs.py @@ -0,0 +1,77 @@ +"""Integration tests for ResolutionOutputGenerator.""" +import pytest +from unittest.mock import AsyncMock, patch +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.session_resolution_output import SessionResolutionOutput + + +@pytest.mark.asyncio +@patch("app.services.resolution_output_generator._call_ai") +async def test_generate_all_creates_three_outputs( + mock_call_ai, client: AsyncClient, test_user, auth_headers, test_db +): + """generate_all creates PSA notes, KB article, and client summary.""" + mock_call_ai.return_value = ("Generated content here", 100, 50) + + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[ + {"role": "user", "content": "DNS not working"}, + {"role": "assistant", "content": "Fixed by flushing DNS cache"}, + ], + resolution_summary="Flushed DNS cache", + ) + test_db.add(session) + await test_db.flush() + + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(test_db) + outputs = await gen.generate_all(session.id) + + assert len(outputs) == 3 + types = {o.output_type for o in outputs} + assert types == {"psa_ticket_notes", "knowledge_base", "client_summary"} + assert all(o.status == "draft" for o in outputs) + assert mock_call_ai.call_count == 3 + + +@pytest.mark.asyncio +async def test_edit_output(client: AsyncClient, test_user, auth_headers, test_db): + """Editing an output stores edited_content.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[], + resolution_summary="Fixed it", + ) + test_db.add(session) + await test_db.flush() + + output = SessionResolutionOutput( + session_id=session.id, + output_type="psa_ticket_notes", + generated_content="Original notes", + status="draft", + generated_by_model="claude-sonnet-4-6", + ) + test_db.add(output) + await test_db.flush() + + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(test_db) + edited = await gen.edit_output(output.id, "My edited notes") + + assert edited.edited_content == "My edited notes"