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 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-05 20:00:28 -05:00
parent e4c5948fbd
commit 882f67f42e
20 changed files with 1627 additions and 63 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"}

View File

@@ -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", "<br>")
subject = "Your FlowPilot Survey Responses"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">Your Survey Responses</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Hi {safe_name}, here's a copy of your FlowPilot survey responses for your records.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<div style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:24px;">
<p style="margin:0;color:#e0e0e0;font-size:13px;line-height:1.8;">{safe_responses}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Thank you for your contribution to FlowPilot research.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
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,

View File

@@ -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)
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,