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."""
|
"""PSA integration endpoints — connection CRUD and test."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from sqlalchemy import delete
|
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.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.psa_connection import PsaConnection
|
from app.models.psa_connection import PsaConnection
|
||||||
@@ -37,6 +40,8 @@ from app.schemas.psa_tickets import (
|
|||||||
TicketCreatePayloadSchema,
|
TicketCreatePayloadSchema,
|
||||||
PSAPrioritySchema,
|
PSAPrioritySchema,
|
||||||
TicketListResponseSchema,
|
TicketListResponseSchema,
|
||||||
|
AiParseRequestSchema,
|
||||||
|
AiParseResponseSchema,
|
||||||
)
|
)
|
||||||
import app.services.ticket_service as ticket_svc
|
import app.services.ticket_service as ticket_svc
|
||||||
from app.services.psa.encryption import (
|
from app.services.psa.encryption import (
|
||||||
@@ -492,6 +497,110 @@ async def create_ticket(
|
|||||||
raise HTTPException(status_code=502, detail=str(e))
|
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)
|
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
|
||||||
async def update_ticket_status_endpoint(
|
async def update_ticket_status_endpoint(
|
||||||
ticket_id: int,
|
ticket_id: int,
|
||||||
|
|||||||
Reference in New Issue
Block a user