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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user