From 0fb1ef33a082ae78f4dffe3a90cc2f74a08a1220 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 5 Mar 2026 22:43:02 -0500 Subject: [PATCH] feat: AI chat conclusion + survey completion & management (#95) * fix: increase assistant chat input height from 1 to 3 rows Co-Authored-By: Claude Opus 4.6 * feat: add Anthropic prompt caching to assistant chat Cache the static system prompt and conversation history prefix across turns, reducing input token costs by ~80% on multi-turn conversations. RAG context is intentionally uncached since it changes per query. Co-Authored-By: Claude Opus 4.6 * feat: add Microsoft Learn MCP integration + refine assistant system prompt - Integrate Microsoft Learn MCP server via Anthropic's MCP connector for real-time documentation lookups (docs search, fetch, code samples) - Refine system prompt: clear persona, structured answer guidelines, when to use RAG flows vs Microsoft Learn, guardrails against fabrication - Add ENABLE_MCP_MICROSOFT_LEARN config toggle (default: True) - Fix bugs from prior edit: wrong MCP URL, broken indentation, undefined usage/token variables, NOT_GIVEN for disabled MCP params - Log MCP tool usage and cache performance Co-Authored-By: Claude Opus 4.6 * feat: AI chat session conclusion + survey completion & management AI Assistant - Conclude Session: - 3-step modal: select outcome (resolved/escalated/paused), add notes, AI-generated summary - AI generates structured ticket notes from conversation transcript (PSA-ready format) - Copy to clipboard for pasting into ticketing systems - "Resume in New Chat" for paused sessions (pre-loads context into new chat) - Backend: POST /chats/{id}/conclude endpoint, conclusion_summary/outcome/concluded_at fields - Migration 048: add conclusion fields to assistant_chats Survey Completion Flow: - Email-to-self option after submission (branded HTML email with formatted responses) - Finish button navigates to /survey/thank-you page - Thank you page with close-window message and feedback email callout - Already-submitted state updated with same messaging - Backend: POST /survey/email-copy public endpoint Survey Admin Management: - Read/unread indicators (cyan dot, bold name, auto-mark on expand) - Unread count stat card - Per-row context menu: mark read/unread, archive/unarchive, delete - Bulk actions bar: select all, mark read/unread, archive, delete - Show Archived toggle to filter archived responses - Backend: 7 new admin endpoints (read, unread, archive, unarchive, delete, bulk) - Migration 049: add is_read, archived_at to survey_responses Co-Authored-By: Claude Opus 4.6 * fix: initialize VerifyEmailPage state from token to avoid setState in effect Moves the no-token error case from useEffect into initial state to satisfy the react-hooks/set-state-in-effect ESLint rule. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../048_add_chat_conclusion_fields.py | 36 ++ ...9_add_survey_response_management_fields.py | 31 ++ backend/app/api/endpoints/admin_survey.py | 159 ++++++- backend/app/api/endpoints/assistant_chat.py | 63 +++ backend/app/api/endpoints/survey.py | 78 +++- backend/app/core/email.py | 64 +++ backend/app/models/assistant_chat.py | 9 + backend/app/models/survey_response.py | 4 +- backend/app/schemas/assistant_chat.py | 13 +- backend/app/schemas/survey.py | 9 + .../app/services/assistant_chat_service.py | 111 +++++ frontend/src/api/admin.ts | 19 +- frontend/src/api/assistantChat.ts | 10 + .../assistant/ConcludeSessionModal.tsx | 421 ++++++++++++++++++ frontend/src/pages/AssistantChatPage.tsx | 92 +++- frontend/src/pages/SurveyPage.tsx | 106 ++++- frontend/src/pages/SurveyThankYouPage.tsx | 76 ++++ frontend/src/pages/VerifyEmailPage.tsx | 10 +- .../src/pages/admin/SurveyResponsesPage.tsx | 366 +++++++++++++-- frontend/src/router.tsx | 10 + frontend/src/types/assistant-chat.ts | 13 + 21 files changed, 1630 insertions(+), 70 deletions(-) create mode 100644 backend/alembic/versions/048_add_chat_conclusion_fields.py create mode 100644 backend/alembic/versions/049_add_survey_response_management_fields.py create mode 100644 frontend/src/components/assistant/ConcludeSessionModal.tsx create mode 100644 frontend/src/pages/SurveyThankYouPage.tsx 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/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 20160cea..22d8bb26 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -228,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, 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} +
+ +
+ +
+ +