diff --git a/backend/alembic/versions/048_add_chat_conclusion_fields.py b/backend/alembic/versions/048_add_chat_conclusion_fields.py new file mode 100644 index 00000000..af6cbe7c --- /dev/null +++ b/backend/alembic/versions/048_add_chat_conclusion_fields.py @@ -0,0 +1,36 @@ +"""Add conclusion fields to assistant_chats. + +Revision ID: 048 +Revises: 047 +Create Date: 2026-03-05 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "048" +down_revision: str = "047" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "assistant_chats", + sa.Column("conclusion_outcome", sa.String(20), nullable=True), + ) + op.add_column( + "assistant_chats", + sa.Column("conclusion_summary", sa.String, nullable=True), + ) + op.add_column( + "assistant_chats", + sa.Column("concluded_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("assistant_chats", "concluded_at") + op.drop_column("assistant_chats", "conclusion_summary") + op.drop_column("assistant_chats", "conclusion_outcome") diff --git a/backend/alembic/versions/049_add_survey_response_management_fields.py b/backend/alembic/versions/049_add_survey_response_management_fields.py new file mode 100644 index 00000000..27bac8bf --- /dev/null +++ b/backend/alembic/versions/049_add_survey_response_management_fields.py @@ -0,0 +1,31 @@ +"""Add is_read and archived_at to survey_responses. + +Revision ID: 049 +Revises: 048 +Create Date: 2026-03-05 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "049" +down_revision: str = "048" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "survey_responses", + sa.Column("is_read", sa.Boolean(), nullable=False, server_default="false"), + ) + op.add_column( + "survey_responses", + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("survey_responses", "archived_at") + op.drop_column("survey_responses", "is_read") diff --git a/backend/app/api/endpoints/admin_survey.py b/backend/app/api/endpoints/admin_survey.py index a107fd02..f998b1e4 100644 --- a/backend/app/api/endpoints/admin_survey.py +++ b/backend/app/api/endpoints/admin_survey.py @@ -4,10 +4,11 @@ import io import logging from datetime import datetime, timedelta, timezone from typing import Annotated, Any +from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Body, Depends, HTTPException, status from fastapi.responses import StreamingResponse -from sqlalchemy import select +from sqlalchemy import select, update, delete from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_admin @@ -96,6 +97,7 @@ async def list_survey_invites( async def list_survey_responses( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], + include_archived: bool = False, ): """List all survey responses with summary stats.""" stmt = ( @@ -103,11 +105,15 @@ async def list_survey_responses( .outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id) .order_by(SurveyResponse.created_at.desc()) ) + if not include_archived: + stmt = stmt.where(SurveyResponse.archived_at.is_(None)) + result = await db.execute(stmt) rows = result.all() one_week_ago = datetime.now(timezone.utc) - timedelta(days=7) this_week = 0 + unread = 0 responses: list[SurveyResponseDetail] = [] for survey_resp, invite_name in rows: @@ -119,19 +125,168 @@ async def list_survey_responses( responses=survey_resp.responses, source=source, invite_name=invite_name, + is_read=survey_resp.is_read, + archived_at=survey_resp.archived_at, created_at=survey_resp.created_at, ) ) if survey_resp.created_at >= one_week_ago: this_week += 1 + if not survey_resp.is_read: + unread += 1 return SurveyResponseListResponse( responses=responses, total=len(responses), this_week=this_week, + unread=unread, ) +@router.put("/survey-responses/{response_id}/read", status_code=200) +async def mark_response_read( + response_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Mark a survey response as read.""" + result = await db.execute( + select(SurveyResponse).where(SurveyResponse.id == response_id) + ) + resp = result.scalar_one_or_none() + if not resp: + raise HTTPException(status_code=404, detail="Response not found") + + resp.is_read = True + await db.commit() + return {"id": str(resp.id), "is_read": True} + + +@router.put("/survey-responses/{response_id}/unread", status_code=200) +async def mark_response_unread( + response_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Mark a survey response as unread.""" + result = await db.execute( + select(SurveyResponse).where(SurveyResponse.id == response_id) + ) + resp = result.scalar_one_or_none() + if not resp: + raise HTTPException(status_code=404, detail="Response not found") + + resp.is_read = False + await db.commit() + return {"id": str(resp.id), "is_read": False} + + +@router.put("/survey-responses/{response_id}/archive", status_code=200) +async def archive_response( + response_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Archive a survey response.""" + result = await db.execute( + select(SurveyResponse).where(SurveyResponse.id == response_id) + ) + resp = result.scalar_one_or_none() + if not resp: + raise HTTPException(status_code=404, detail="Response not found") + + resp.archived_at = datetime.now(timezone.utc) + await db.commit() + return {"id": str(resp.id), "archived_at": resp.archived_at.isoformat()} + + +@router.put("/survey-responses/{response_id}/unarchive", status_code=200) +async def unarchive_response( + response_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Unarchive a survey response.""" + result = await db.execute( + select(SurveyResponse).where(SurveyResponse.id == response_id) + ) + resp = result.scalar_one_or_none() + if not resp: + raise HTTPException(status_code=404, detail="Response not found") + + resp.archived_at = None + await db.commit() + return {"id": str(resp.id), "archived_at": None} + + +@router.delete("/survey-responses/{response_id}", status_code=204) +async def delete_response( + response_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], +): + """Permanently delete a survey response.""" + result = await db.execute( + select(SurveyResponse).where(SurveyResponse.id == response_id) + ) + resp = result.scalar_one_or_none() + if not resp: + raise HTTPException(status_code=404, detail="Response not found") + + await db.delete(resp) + await db.commit() + + +@router.post("/survey-responses/bulk", status_code=200) +async def bulk_action_responses( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)], + action: str = Body(...), + ids: list[str] = Body(...), +): + """Bulk action on survey responses. Actions: mark_read, mark_unread, archive, delete.""" + from uuid import UUID as _UUID + + uuids = [] + for id_str in ids: + try: + uuids.append(_UUID(id_str)) + except ValueError: + continue + + if not uuids: + raise HTTPException(status_code=400, detail="No valid IDs provided") + + if action == "mark_read": + await db.execute( + update(SurveyResponse) + .where(SurveyResponse.id.in_(uuids)) + .values(is_read=True) + ) + elif action == "mark_unread": + await db.execute( + update(SurveyResponse) + .where(SurveyResponse.id.in_(uuids)) + .values(is_read=False) + ) + elif action == "archive": + await db.execute( + update(SurveyResponse) + .where(SurveyResponse.id.in_(uuids)) + .values(archived_at=datetime.now(timezone.utc)) + ) + elif action == "delete": + await db.execute( + delete(SurveyResponse) + .where(SurveyResponse.id.in_(uuids)) + ) + else: + raise HTTPException(status_code=400, detail=f"Unknown action: {action}") + + await db.commit() + return {"action": action, "count": len(uuids)} + + # Question IDs in survey order, used for CSV export columns. QUESTION_IDS = [ "prereqs", diff --git a/backend/app/api/endpoints/assistant_chat.py b/backend/app/api/endpoints/assistant_chat.py index 42bd104c..83422367 100644 --- a/backend/app/api/endpoints/assistant_chat.py +++ b/backend/app/api/endpoints/assistant_chat.py @@ -35,6 +35,8 @@ from app.schemas.assistant_chat import ( ChatUpdateRequest, RetentionSettingsResponse, RetentionSettingsUpdate, + ConcludeChatRequest, + ConcludeChatResponse, ) from app.schemas.copilot import SuggestedFlow from app.services import assistant_chat_service @@ -203,6 +205,67 @@ async def post_message( ) +@router.post("/chats/{chat_id}/conclude", response_model=ConcludeChatResponse) +@limiter.limit("10/minute") +async def conclude_chat( + request: Request, + chat_id: UUID, + data: ConcludeChatRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +): + """Conclude a chat session and generate ticket-ready summary.""" + _require_ai_enabled() + + result = await db.execute( + select(AssistantChat).where( + AssistantChat.id == chat_id, + AssistantChat.user_id == current_user.id, + ) + ) + chat = result.scalar_one_or_none() + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found") + + if chat.concluded_at: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Chat already concluded", + ) + + if chat.message_count < 2: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Chat must have at least one exchange before concluding", + ) + + try: + summary = await assistant_chat_service.generate_conclusion_summary( + chat=chat, + outcome=data.outcome, + notes=data.notes, + ) + except Exception as e: + logger.exception("Failed to generate conclusion summary: %s", e) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to generate summary. Please try again.", + ) + + now = datetime.now(timezone.utc) + chat.conclusion_outcome = data.outcome + chat.conclusion_summary = summary + chat.concluded_at = now + await db.commit() + + return ConcludeChatResponse( + summary=summary, + outcome=data.outcome, + concluded_at=now, + ) + + @router.patch("/chats/{chat_id}", response_model=ChatDetailResponse) async def update_chat( chat_id: UUID, diff --git a/backend/app/api/endpoints/survey.py b/backend/app/api/endpoints/survey.py index bdd14d4f..7e66b00e 100644 --- a/backend/app/api/endpoints/survey.py +++ b/backend/app/api/endpoints/survey.py @@ -14,7 +14,7 @@ from app.core.email import EmailService from app.core.rate_limit import limiter from app.models.survey_invite import SurveyInvite from app.models.survey_response import SurveyResponse -from app.schemas.survey import SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse +from app.schemas.survey import SurveyEmailCopyRequest, SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse logger = logging.getLogger(__name__) @@ -88,3 +88,79 @@ async def submit_survey( await db.commit() return SurveySubmissionResponse(id=str(response.id)) + + +# Question metadata for formatting email copy +_QUESTION_LABELS = { + "prereqs": "Q1. Before you start troubleshooting, what info do you need?", + "verify_fix": "Q2. After you apply a fix, how do you verify it actually worked?", + "steps_at_a_time": "Q3. How many steps do you prefer to see at once?", + "first_step": "Q4. \"Internet is down.\" What's your FIRST move?", + "junior_mistake": "Q5. Most common mistake junior engineers make?", + "pivot": "Q6. When do you stop pursuing one theory and pivot?", + "scenario_approach": "Q7. First 3 diagnostic steps for this ticket.", + "scenario_deeper": "Q8. Server pings fine, you can RDP in. What next?", + "doc_pct": "Q9. Percentage of steps you actually document?", + "go_to_commands": "Q10. Top 3 go-to PowerShell commands?", + "secret_weapon": "Q11. Secret weapon command/tool/technique?", + "gotcha": "Q12. Issue where the obvious diagnosis was WRONG?", + "hard_rules": "Q13. Which rules do you follow?", + "prioritization": "Q14. Rank factors by diagnostic priority.", + "detail_level": "Q15. How specific should AI suggestions be?", + "ai_personality": "Q16. What makes an AI feel like a useful colleague?", +} + +_QUESTION_ORDER = [ + "prereqs", "verify_fix", "steps_at_a_time", "first_step", "junior_mistake", + "pivot", "scenario_approach", "scenario_deeper", "doc_pct", "go_to_commands", + "secret_weapon", "gotcha", "hard_rules", "prioritization", "detail_level", "ai_personality", +] + + +@router.post("/survey/email-copy") +@limiter.limit("5/hour") +async def email_survey_copy( + request: Request, + data: SurveyEmailCopyRequest, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Email a copy of survey responses to the respondent.""" + from uuid import UUID as _UUID + + try: + resp_id = _UUID(data.response_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid response ID") + + result = await db.execute( + select(SurveyResponse).where(SurveyResponse.id == resp_id) + ) + response = result.scalar_one_or_none() + if not response: + raise HTTPException(status_code=404, detail="Response not found") + + # Build formatted responses for the email + answers = response.responses or {} + formatted_lines = [] + for qid in _QUESTION_ORDER: + label = _QUESTION_LABELS.get(qid, qid) + val = answers.get(qid) + if isinstance(val, list): + answer_str = ", ".join(str(v) for v in val) + elif val is not None: + answer_str = str(val) + else: + answer_str = "(no answer)" + formatted_lines.append(f"{label}\n{answer_str}") + + try: + await EmailService.send_survey_copy_email( + to_email=data.email, + respondent_name=response.respondent_name, + formatted_responses="\n\n".join(formatted_lines), + ) + except Exception: + logger.exception("Failed to send survey copy email to %s", data.email) + raise HTTPException(status_code=502, detail="Failed to send email. Please try again.") + + return {"message": "Email sent"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8fd6e4a8..f1b0edc2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -84,6 +84,9 @@ class Settings(BaseSettings): AI_MODEL_GEMINI: str = "gemini-2.5-flash" AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001" + # MCP (Model Context Protocol) integrations + ENABLE_MCP_MICROSOFT_LEARN: bool = True + # Embedding / RAG VOYAGE_API_KEY: Optional[str] = None EMBEDDING_MODEL: str = "voyage-3.5" diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 5a5defae..bdea462f 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -354,6 +354,70 @@ class EmailService: logger.exception("Failed to send survey notification email") return False + @staticmethod + async def send_survey_copy_email( + to_email: str, + respondent_name: str | None, + formatted_responses: str, + ) -> bool: + """Send a copy of survey responses to the respondent.""" + if not settings.email_enabled: + logger.warning("Email not sent — RESEND_API_KEY not configured") + return False + + try: + import resend + import html as html_mod + + resend.api_key = settings.RESEND_API_KEY + + safe_name = html_mod.escape(respondent_name or "there") + safe_responses = html_mod.escape(formatted_responses).replace("\n", "
") + subject = "Your FlowPilot Survey Responses" + + email_html = f""" + + + + +
+ + + + + +
+

ResolutionFlow

+

Your Survey Responses

+
+

+ Hi {safe_name}, here's a copy of your FlowPilot survey responses for your records. +

+
+
+

{safe_responses}

+
+
+

+ Thank you for your contribution to FlowPilot research. +

+
+
+""" + + resend.Emails.send({ + "from": settings.FROM_EMAIL, + "to": [to_email], + "subject": subject, + "html": email_html, + }) + logger.info("Survey copy email sent to %s", to_email) + return True + + except Exception: + logger.exception("Failed to send survey copy email to %s", to_email) + return False + @staticmethod async def send_survey_invite_email( to_email: str, diff --git a/backend/app/models/assistant_chat.py b/backend/app/models/assistant_chat.py index c028cb38..f0edbf66 100644 --- a/backend/app/models/assistant_chat.py +++ b/backend/app/models/assistant_chat.py @@ -49,6 +49,15 @@ class AssistantChat(Base): pinned: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False ) + conclusion_outcome: Mapped[Optional[str]] = mapped_column( + String(20), nullable=True + ) + conclusion_summary: Mapped[Optional[str]] = mapped_column( + String, nullable=True + ) + concluded_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) diff --git a/backend/app/models/survey_response.py b/backend/app/models/survey_response.py index 36a29da4..1340e793 100644 --- a/backend/app/models/survey_response.py +++ b/backend/app/models/survey_response.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from sqlalchemy import Column, DateTime, ForeignKey, String, Text +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import JSONB, UUID from app.core.database import Base @@ -17,4 +17,6 @@ class SurveyResponse(Base): ip_address = Column(String(45), nullable=True) user_agent = Column(Text, nullable=True) invite_id = Column(UUID(as_uuid=True), ForeignKey("survey_invites.id"), nullable=True) + is_read = Column(Boolean, nullable=False, default=False) + archived_at = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/backend/app/schemas/assistant_chat.py b/backend/app/schemas/assistant_chat.py index a8e6edb9..5d7fc6af 100644 --- a/backend/app/schemas/assistant_chat.py +++ b/backend/app/schemas/assistant_chat.py @@ -1,5 +1,5 @@ """Pydantic schemas for standalone AI assistant chat.""" -from typing import Optional, Any +from typing import Optional, Any, Literal from uuid import UUID from datetime import datetime from pydantic import BaseModel, Field @@ -57,3 +57,14 @@ class RetentionSettingsResponse(BaseModel): class RetentionSettingsUpdate(BaseModel): chat_retention_days: Optional[int] = Field(None, ge=1, le=365) chat_retention_max_count: Optional[int] = Field(None, ge=10, le=10000) + + +class ConcludeChatRequest(BaseModel): + outcome: Literal["resolved", "escalated", "paused"] + notes: Optional[str] = Field(None, max_length=2000) + + +class ConcludeChatResponse(BaseModel): + summary: str + outcome: str + concluded_at: datetime diff --git a/backend/app/schemas/survey.py b/backend/app/schemas/survey.py index dbf3041e..84c8d74b 100644 --- a/backend/app/schemas/survey.py +++ b/backend/app/schemas/survey.py @@ -46,6 +46,12 @@ class SurveyInviteStatus(BaseModel): status: str +class SurveyEmailCopyRequest(BaseModel): + """Request to email a copy of responses to the respondent.""" + email: str = Field(..., max_length=255) + response_id: str + + class SurveyResponseDetail(BaseModel): """Full survey response returned to admin.""" id: str @@ -53,6 +59,8 @@ class SurveyResponseDetail(BaseModel): responses: dict[str, Any] source: str invite_name: Optional[str] + is_read: bool = False + archived_at: Optional[datetime] = None created_at: datetime @@ -61,3 +69,4 @@ class SurveyResponseListResponse(BaseModel): responses: list[SurveyResponseDetail] total: int this_week: int + unread: int diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 797a3be7..22d8bb26 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -2,37 +2,224 @@ Provides persistent conversation history for general IT questions with semantic search over the team's flow library. + +Uses Anthropic prompt caching to reduce cost on multi-turn conversations: +- The static system prompt is cached (ephemeral, 5-min TTL) +- The conversation history prefix is cached via a breakpoint on the + last existing message before the new user input + +Optionally connects to Microsoft Learn via Anthropic's MCP connector +for real-time documentation lookups (controlled by ENABLE_MCP_MICROSOFT_LEARN). """ import logging -from typing import Optional, Any +from typing import Any from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.ai_provider import get_ai_provider +from app.core.config import settings from app.models.assistant_chat import AssistantChat from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows logger = logging.getLogger(__name__) -ASSISTANT_SYSTEM_PROMPT = """You are a Senior Systems and Network Engineer with 15+ years of experience working in Managed Service Provider (MSP) environments. You specialize in: -- Windows Server, Active Directory, Group Policy, and Hybrid Identity (Entra ID) -- Networking (TCP/IP, DNS, DHCP, VPN, firewall troubleshooting, Cisco/Fortinet) -- Virtualization (VMware, Hyper-V) and cloud platforms (Azure, AWS, M365) -- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya) -- PowerShell scripting and automation +ASSISTANT_SYSTEM_PROMPT = """\ +You are ResolutionFlow Assistant — an expert IT systems engineer embedded in a \ +troubleshooting platform built for Managed Service Provider (MSP) teams. -When answering: -- Be direct and actionable — MSP engineers need fast, practical answers -- Include specific commands, paths, and config values when relevant -- Mention potential risks or gotchas before suggesting changes -- If a relevant troubleshooting flow exists in the team's library, reference it -- Keep responses concise but thorough — prefer bullet points and code blocks -- Format code with proper markdown code blocks +## Your Role +You are a senior peer helping fellow MSP engineers solve problems fast. You have \ +deep expertise across the MSP technology stack: +- Windows Server, Active Directory, Group Policy, Hybrid Identity (Entra ID / Azure AD) +- Networking: TCP/IP, DNS, DHCP, VPN, firewalls (Cisco, Fortinet, Meraki, SonicWall) +- Virtualization: VMware vSphere, Hyper-V, Proxmox +- Cloud platforms: Microsoft 365, Azure, AWS +- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya, NinjaRMM) +- PowerShell scripting and automation +- Security: MFA, Conditional Access, EDR, backup/DR + +## How to Answer +- **Be direct and actionable.** Engineers are mid-ticket — give them the answer, \ +not a lecture. Lead with the fix, then explain why. +- **Include specifics.** Exact commands, registry paths, config values, port numbers. \ +Vague advice wastes time. +- **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \ +say so upfront — before the command. +- **Use structured formatting.** Bullet points for steps, code blocks for commands, \ +bold for key terms. Engineers scan, they don't read essays. +- **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \ +where to verify (vendor docs, a specific KB article) rather than guessing. + +## Using the Team's Flow Library +Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \ +appear in the context below, reference them by name so the engineer can launch them \ +directly. Prefer the team's proven flows over ad-hoc instructions when they exist. + +## Using Microsoft Learn Documentation +You have access to Microsoft's official documentation via Microsoft Learn. Use it when: +- The question involves exact cmdlet syntax, API parameters, or configuration steps +- You need to verify current Microsoft/Azure behavior or requirements +- No team flow covers the topic and vendor-specific detail would help +Do NOT use Microsoft Learn for every question — only when official docs add real value. + +## Boundaries +- Stay focused on IT infrastructure, systems administration, and MSP operations. +- If a question is clearly outside your domain, say so briefly and redirect. +- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so. """ +async def _call_ai( + system_base: str, + rag_context: str, + history: list[dict[str, Any]], + new_message: str, + max_tokens: int = 4096, +) -> tuple[str, int, int]: + """Call the AI with prompt caching when using Anthropic. + + Caching strategy: + - System prompt base: cached (stable across all turns) + - RAG context: NOT cached (changes per query) + - Conversation history prefix: cached via breakpoint on last + existing message (stable — only new user message is uncached) + """ + if settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY: + return await _call_anthropic_cached( + system_base, rag_context, history, new_message, max_tokens + ) + + # Fallback: generic provider (Gemini, etc.) + from app.core.ai_provider import get_ai_provider + + system_prompt = system_base + rag_context + messages = history + [{"role": "user", "content": new_message}] + provider = get_ai_provider() + return await provider.generate_text( + system_prompt=system_prompt, + messages=messages, + max_tokens=max_tokens, + ) + + +async def _call_anthropic_cached( + system_base: str, + rag_context: str, + history: list[dict[str, Any]], + new_message: str, + max_tokens: int, +) -> tuple[str, int, int]: + """Call Anthropic with prompt caching on system prompt and history. + + Uses structured system blocks so the static base prompt is cached + independently from the per-query RAG context. Optionally connects + to Microsoft Learn via MCP for real-time documentation lookups. + """ + import anthropic + + client = anthropic.AsyncAnthropic( + api_key=settings.ANTHROPIC_API_KEY, + timeout=settings.AI_REQUEST_TIMEOUT_SECONDS, + ) + + # System prompt as structured blocks: + # Block 1: static base prompt (cached) + # Block 2: RAG context (changes per query, not cached) + system_blocks: list[dict[str, Any]] = [ + { + "type": "text", + "text": system_base, + "cache_control": {"type": "ephemeral"}, + }, + ] + if rag_context: + system_blocks.append({"type": "text", "text": rag_context}) + + # Build messages with cache breakpoint on conversation history + messages: list[dict[str, Any]] = [] + for msg in history: + messages.append({"role": msg["role"], "content": msg["content"]}) + + # Place cache breakpoint on the last history message so the entire + # conversation prefix is cached across turns + if messages: + last = messages[-1] + messages[-1] = { + "role": last["role"], + "content": [ + { + "type": "text", + "text": last["content"], + "cache_control": {"type": "ephemeral"}, + } + ], + } + + # Add the new user message (uncached — it's new each turn) + messages.append({"role": "user", "content": new_message}) + + # MCP server config (optional — controlled by settings) + mcp_servers = anthropic.NOT_GIVEN + tools = anthropic.NOT_GIVEN + + if settings.ENABLE_MCP_MICROSOFT_LEARN: + mcp_servers = [ + { + "type": "url", + "url": "https://learn.microsoft.com/api/mcp", + "name": "microsoft-learn", + } + ] + tools = [ + { + "type": "mcp_toolset", + "mcp_server_name": "microsoft-learn", + } + ] + + response = await client.beta.messages.create( + model=settings.AI_MODEL_ANTHROPIC, + max_tokens=max_tokens, + system=system_blocks, + messages=messages, + mcp_servers=mcp_servers, + tools=tools, + betas=["mcp-client-2025-11-20"], + ) + + # Extract text from response — MCP responses can have multiple block + # types (text, mcp_tool_use, mcp_tool_result). We join all text blocks. + text_parts = [] + mcp_tools_used = [] + for block in response.content: + if hasattr(block, "text"): + text_parts.append(block.text) + if getattr(block, "type", None) == "mcp_tool_use": + mcp_tools_used.append(getattr(block, "name", "unknown")) + + text = "\n".join(text_parts) if text_parts else "" + + usage = response.usage + input_tokens = usage.input_tokens + output_tokens = usage.output_tokens + + # Log MCP tool usage + if mcp_tools_used: + logger.info("MCP tools used: %s", ", ".join(mcp_tools_used)) + + # Log cache performance + cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0 + cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0 + if cache_read or cache_creation: + logger.info( + "Anthropic cache: read=%d creation=%d input=%d output=%d", + cache_read, cache_creation, input_tokens, output_tokens, + ) + + return text, input_tokens, output_tokens + + def _auto_title(message: str) -> str: """Generate a short title from the first user message.""" title = message.strip()[:100] @@ -41,6 +228,117 @@ def _auto_title(message: str) -> str: return title +CONCLUSION_SYSTEM_PROMPT = """\ +You are a ticket documentation specialist for MSP (Managed Service Provider) teams. \ +Your job is to transform an AI troubleshooting conversation into clean, professional \ +ticket notes that can be pasted directly into a PSA/ticketing system (ConnectWise, \ +Autotask, HaloPSA, etc.). + +## Output Format + +Generate a structured summary using this exact format: + +**Subject:** [One-line summary of the issue] + +**Outcome:** {outcome_label} + +**Problem Description:** +[2-3 sentence summary of the original problem] + +**Steps Taken:** +1. [Step] — [Result/finding] +2. [Step] — [Result/finding] +(list all troubleshooting steps from the conversation) + +**Current Status:** +[Where things stand now — what was resolved, what remains] + +{notes_section} + +**Key Findings:** +- [Important discovery or configuration detail] +- [Any relevant error codes, settings, or values identified] + +{resume_section} + +## Rules +- Be concise but thorough — these notes will be read by another engineer +- Include specific technical details (commands run, error messages, config values) +- Use plain text formatting (no HTML) — bold with ** is fine +- Do NOT include conversational filler, greetings, or meta-commentary +- Extract ALL actionable steps from the conversation, in chronological order +- If the conversation identified root cause, state it clearly +""" + + +async def generate_conclusion_summary( + chat: "AssistantChat", + outcome: str, + notes: str | None = None, +) -> str: + """Generate a ticket-ready summary from a concluded chat conversation.""" + outcome_labels = { + "resolved": "Resolved", + "escalated": "Escalated", + "paused": "Paused — To Be Continued", + } + outcome_label = outcome_labels.get(outcome, outcome) + + notes_section = "" + if notes: + notes_section = f"\n**Engineer Notes:**\n{notes}\n" + + resume_section = "" + if outcome == "paused": + resume_section = ( + "\n**Next Steps (for resumption):**\n" + "- [What needs to happen next]\n" + "- [Any pending actions or follow-ups]\n" + ) + elif outcome == "escalated": + resume_section = ( + "\n**Escalation Details:**\n" + "- [Reason for escalation]\n" + "- [Recommended next steps for receiving team/tier]\n" + ) + + # Build the conversation transcript for the AI + transcript_lines = [] + for msg in chat.messages: + role_label = "ENGINEER" if msg["role"] == "user" else "AI ASSISTANT" + transcript_lines.append(f"[{role_label}]: {msg['content']}") + + transcript = "\n\n".join(transcript_lines) + + prompt = ( + f"Outcome: {outcome_label}\n\n" + f"{'Engineer Notes: ' + notes if notes else '(No additional notes)'}\n\n" + f"--- CONVERSATION TRANSCRIPT ---\n\n{transcript}\n\n" + f"--- END TRANSCRIPT ---\n\n" + f"Generate the ticket notes now. Replace all placeholder brackets with actual content from the conversation. " + f"The notes_section placeholder should be: {notes_section or '(omit this section)'}\n" + f"The resume_section placeholder should be filled based on the conversation context." + ) + + system_with_vars = CONCLUSION_SYSTEM_PROMPT.replace( + "{outcome_label}", outcome_label + ).replace( + "{notes_section}", notes_section or "" + ).replace( + "{resume_section}", resume_section + ) + + content, _, _ = await _call_ai( + system_base=system_with_vars, + rag_context="", + history=[], + new_message=prompt, + max_tokens=2048, + ) + + return content + + async def create_chat( user_id: UUID, account_id: UUID, @@ -90,22 +388,20 @@ async def send_message( limit=8, ) - # Build system prompt - system_prompt = ASSISTANT_SYSTEM_PROMPT + build_rag_context(rag_results) + rag_context = build_rag_context(rag_results) # Build messages for AI - ai_messages = [] + ai_messages: list[dict[str, Any]] = [] for msg in chat.messages: if msg["role"] in ("user", "assistant"): ai_messages.append({"role": msg["role"], "content": msg["content"]}) - ai_messages.append({"role": "user", "content": message}) - # Call AI - provider = get_ai_provider() - ai_content, input_tokens, output_tokens = await provider.generate_text( - system_prompt=system_prompt, - messages=ai_messages, - max_tokens=4096, + # Call AI with prompt caching (Anthropic) or generic provider + ai_content, input_tokens, output_tokens = await _call_ai( + system_base=ASSISTANT_SYSTEM_PROMPT, + rag_context=rag_context, + history=ai_messages, + new_message=message, ) # Update chat diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index be915ebc..ba013fcc 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -38,6 +38,8 @@ export interface SurveyResponseDetail { responses: Record source: 'invite' | 'direct' invite_name: string | null + is_read: boolean + archived_at: string | null created_at: string } @@ -45,6 +47,7 @@ export interface SurveyResponseListResponse { responses: SurveyResponseDetail[] total: number this_week: number + unread: number } export const adminApi = { @@ -175,10 +178,22 @@ export const adminApi = { api.post('/admin/survey-invites', data).then(r => r.data), // Survey Responses - listSurveyResponses: () => - api.get('/admin/survey-responses').then(r => r.data), + listSurveyResponses: (includeArchived = false) => + api.get('/admin/survey-responses', { params: { include_archived: includeArchived } }).then(r => r.data), exportSurveyResponsesCsv: () => api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data), + markResponseRead: (id: string) => + api.put(`/admin/survey-responses/${id}/read`).then(r => r.data), + markResponseUnread: (id: string) => + api.put(`/admin/survey-responses/${id}/unread`).then(r => r.data), + archiveResponse: (id: string) => + api.put(`/admin/survey-responses/${id}/archive`).then(r => r.data), + unarchiveResponse: (id: string) => + api.put(`/admin/survey-responses/${id}/unarchive`).then(r => r.data), + deleteResponse: (id: string) => + api.delete(`/admin/survey-responses/${id}`), + bulkActionResponses: (action: string, ids: string[]) => + api.post('/admin/survey-responses/bulk', { action, ids }).then(r => r.data), } export default adminApi diff --git a/frontend/src/api/assistantChat.ts b/frontend/src/api/assistantChat.ts index b7dda915..4c8a7d27 100644 --- a/frontend/src/api/assistantChat.ts +++ b/frontend/src/api/assistantChat.ts @@ -4,6 +4,8 @@ import type { ChatListItem, ChatMessageResponse, RetentionSettings, + ConcludeChatRequest, + ConcludeChatResponse, } from '@/types/assistant-chat' export const assistantChatApi = { @@ -54,6 +56,14 @@ export const assistantChatApi = { const response = await apiClient.patch('/assistant/retention', data) return response.data }, + + async concludeChat(chatId: string, data: ConcludeChatRequest): Promise { + const response = await apiClient.post( + `/assistant/chats/${chatId}/conclude`, + data + ) + return response.data + }, } export default assistantChatApi diff --git a/frontend/src/components/assistant/ConcludeSessionModal.tsx b/frontend/src/components/assistant/ConcludeSessionModal.tsx new file mode 100644 index 00000000..54669c96 --- /dev/null +++ b/frontend/src/components/assistant/ConcludeSessionModal.tsx @@ -0,0 +1,421 @@ +import { useState, useEffect } from 'react' +import { + X, + CheckCircle2, + ArrowUpRight, + Pause, + Loader2, + Copy, + Check, + RefreshCw, + ClipboardList, + Sparkles, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { MarkdownContent } from '@/components/ui/MarkdownContent' + +type ConclusionOutcome = 'resolved' | 'escalated' | 'paused' + +interface ConcludeSessionModalProps { + isOpen: boolean + onClose: () => void + onConclude: (outcome: ConclusionOutcome, notes: string) => Promise + onResumeNew: (summary: string) => void + chatTitle: string +} + +const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [ + { + value: 'resolved', + label: 'Resolved', + description: 'Issue has been fixed or answered', + icon: CheckCircle2, + color: 'text-emerald-400', + bg: 'bg-emerald-400/10', + border: 'border-emerald-400/30', + }, + { + value: 'escalated', + label: 'Escalate', + description: 'Needs to be handed off or escalated', + icon: ArrowUpRight, + color: 'text-amber-400', + bg: 'bg-amber-400/10', + border: 'border-amber-400/30', + }, + { + value: 'paused', + label: 'Paused', + description: 'Continuing later — saving progress', + icon: Pause, + color: 'text-blue-400', + bg: 'bg-blue-400/10', + border: 'border-blue-400/30', + }, +] + +type ModalStep = 'select-outcome' | 'add-notes' | 'summary' + +export function ConcludeSessionModal({ + isOpen, + onClose, + onConclude, + onResumeNew, + chatTitle, +}: ConcludeSessionModalProps) { + const [step, setStep] = useState('select-outcome') + const [outcome, setOutcome] = useState(null) + const [notes, setNotes] = useState('') + const [summary, setSummary] = useState('') + const [generating, setGenerating] = useState(false) + const [copied, setCopied] = useState(false) + const [error, setError] = useState(null) + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setStep('select-outcome') + setOutcome(null) + setNotes('') + setSummary('') + setGenerating(false) + setCopied(false) + setError(null) + } + }, [isOpen]) + + const handleOutcomeSelect = (selected: ConclusionOutcome) => { + setOutcome(selected) + setStep('add-notes') + } + + const handleGenerate = async () => { + if (!outcome) return + setGenerating(true) + setError(null) + + try { + const result = await onConclude(outcome, notes) + setSummary(result) + setStep('summary') + } catch { + setError('Failed to generate summary. Please try again.') + } finally { + setGenerating(false) + } + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(summary) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Fallback + const textarea = document.createElement('textarea') + textarea.value = summary + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + const handleResumeNew = () => { + onResumeNew(summary) + onClose() + } + + if (!isOpen) return null + + const selectedOutcome = OUTCOMES.find(o => o.value === outcome) + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ +
+
+

+ Conclude Session +

+

+ {chatTitle} +

+
+
+ +
+ + {/* Step indicator */} +
+ {(['select-outcome', 'add-notes', 'summary'] as ModalStep[]).map((s, i) => ( +
+ {i > 0 && ( +
+ )} +
+ {i + 1} +
+ + {s === 'select-outcome' ? 'Outcome' : s === 'add-notes' ? 'Notes' : 'Summary'} + +
+ ))} +
+ + {/* Content */} +
+ {/* Step 1: Select Outcome */} + {step === 'select-outcome' && ( +
+

+ How did this session end? +

+ {OUTCOMES.map(o => { + const Icon = o.icon + return ( + + ) + })} +
+ )} + + {/* Step 2: Add Notes */} + {step === 'add-notes' && selectedOutcome && ( +
+ {/* Selected outcome badge */} +
+
+ + {selectedOutcome.label} +
+ +
+ +
+ +