feat(psa): add AI ticket parse endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user