From 6e0188d0b4a5d43339c439010d2b24c8e16b1bcf Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:59:02 +0000 Subject: [PATCH] feat(psa): add AI ticket parse endpoint Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/integrations.py | 109 ++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 0f66774c..53abd104 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -1,6 +1,7 @@ """PSA integration endpoints — connection CRUD and test.""" from __future__ import annotations +import logging from datetime import datetime, timezone from typing import Annotated from uuid import UUID @@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete +logger = logging.getLogger(__name__) + from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin from app.core.database import get_db from app.models.psa_connection import PsaConnection @@ -37,6 +40,8 @@ from app.schemas.psa_tickets import ( TicketCreatePayloadSchema, PSAPrioritySchema, TicketListResponseSchema, + AiParseRequestSchema, + AiParseResponseSchema, ) import app.services.ticket_service as ticket_svc from app.services.psa.encryption import ( @@ -492,6 +497,110 @@ async def create_ticket( raise HTTPException(status_code=502, detail=str(e)) +@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema) +async def ai_parse_ticket( + data: AiParseRequestSchema, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Parse natural language into a ticket pre-fill payload using Claude.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + + from app.services.psa.registry import get_provider_for_account + from app.services.psa.exceptions import PSAError + import anthropic + import json + + # Fetch boards + members for context (both cached) + boards = [] + members = [] + try: + provider = await get_provider_for_account(current_user.account_id, db) + boards = await provider.list_boards() + members = await provider.list_members() + except PSAError: + pass + + boards_list = [{"id": b.id, "name": b.name} for b in boards] + members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members] + + system_prompt = """You are a ticket triage assistant for an MSP help desk. +Extract structured ticket information from the engineer's natural language description. +Return ONLY valid JSON matching this exact schema — no other text: +{ + "summary": "short one-line ticket title or null", + "board_id": "integer matching one of the provided boards or null", + "priority_name": "one of: Critical, High, Medium, Low, or null", + "description": "expanded description or null", + "assignee_identifier": "member identifier string from the provided members list or null", + "warnings": ["list of strings explaining what could not be resolved"] +}""" + + user_msg = f"""Available boards: {json.dumps(boards_list)} +Available members: {json.dumps(members_list[:50])} + +Engineer's description: {data.prompt}""" + + missing_fields: list[str] = [] + warnings: list[str] = [] + response_data = AiParseResponseSchema() + + try: + client = anthropic.AsyncAnthropic( + api_key=settings.ANTHROPIC_API_KEY, + max_retries=1, + ) + msg = await client.messages.create( + model=settings.get_model_for_action("default"), + max_tokens=512, + system=system_prompt, + messages=[{"role": "user", "content": user_msg}], + ) + raw = msg.content[0].text.strip() + # Strip markdown fences if present + if raw.startswith("```"): + import re + raw = re.sub(r'^```(?:json)?\s*', '', raw) + raw = re.sub(r'\s*```$', '', raw.strip()) + parsed = json.loads(raw) + + response_data.summary = parsed.get("summary") + response_data.description = parsed.get("description") + warnings = parsed.get("warnings", []) + + # Resolve board_id + if parsed.get("board_id"): + board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None) + if board_match: + response_data.board_id = board_match.id + else: + missing_fields.append("board_id") + warnings.append(f"Board ID {parsed['board_id']} not found") + else: + missing_fields.append("board_id") + + # Resolve assignee + if parsed.get("assignee_identifier"): + member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None) + if member: + response_data.assigned_member_id = int(member.id) + else: + warnings.append(f"Member '{parsed['assignee_identifier']}' not found") + + # Priority/status/company always need manual selection + missing_fields.extend(["status_id", "priority_id", "company_id"]) + + except Exception as e: + logger.warning("AI parse failed: %s", e) + missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"] + warnings = ["AI parsing failed — please fill in manually"] + + response_data.missing_fields = missing_fields + response_data.warnings = warnings + return response_data + + @router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema) async def update_ticket_status_endpoint( ticket_id: int,