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

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