Files
chihlasm 0fb1ef33a0 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:43:02 -05:00

167 lines
6.0 KiB
Python

"""Public survey submission endpoint. No authentication required."""
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
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 SurveyEmailCopyRequest, SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["survey"])
@router.get("/survey/invite/{token}", response_model=SurveyInviteStatus)
async def check_invite_status(
token: str,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Check if a survey invite token is valid and its status."""
result = await db.execute(
select(SurveyInvite).where(SurveyInvite.token == token)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="Invalid invite token")
return SurveyInviteStatus(name=invite.recipient_name, status=invite.status)
@router.post("/survey/submit", response_model=SurveySubmissionResponse)
@limiter.limit("3/hour")
async def submit_survey(
request: Request,
data: SurveySubmission,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Accept a public survey submission. No auth required.
Rate limited to 3/hour per IP to prevent spam.
Saves to DB and sends notification email to configured address.
"""
ip = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
invite = None
if data.token:
result = await db.execute(
select(SurveyInvite).where(SurveyInvite.token == data.token)
)
invite = result.scalar_one_or_none()
if invite and invite.status == "completed":
raise HTTPException(status_code=409, detail="This survey has already been submitted")
response = SurveyResponse(
respondent_name=data.respondent_name or (invite.recipient_name if invite else None),
responses=data.responses,
ip_address=ip,
user_agent=ua,
invite_id=invite.id if invite else None,
)
db.add(response)
if invite:
invite.status = "completed"
invite.completed_at = datetime.now(timezone.utc)
await db.flush()
try:
if settings.FEEDBACK_EMAIL:
await EmailService.send_survey_notification_email(
to_email=settings.FEEDBACK_EMAIL,
respondent_name=response.respondent_name,
responses=data.responses,
)
except Exception:
logger.exception("Failed to send survey notification email")
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"}