Files
replypier/backend/app/tools/ticket_toolkit.py
T
WangShen007 2dd062787d feat(agent): 检索工具、模型接入与结构化回复
Agent 按组织与知识源范围调用检索工具,密钥加密保存,回复含来源与转人工意图。
2026-10-02 21:25:00 +08:00

266 lines
11 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import json
import re
from typing import Optional
from uuid import UUID
from agno.tools import Toolkit
from app.core.logger import get_logger
from app.database import SessionLocal
from app.models.session_to_agent import SessionToAgent
from app.models.ticket import TicketPriority, TicketSource
from app.repositories.customer import CustomerRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services.widget_identity import trusts_identity_in_conversation
from app.models.ticket_activity import TicketActivityType, TicketActorType
logger = get_logger(__name__)
VALID_PRIORITIES = {p.value for p in TicketPriority}
# Loose sanity check only — a real inbox is not verified here, we just refuse
# to store obviously-not-an-email junk the model might pass.
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _clean_email(email: Optional[str]) -> Optional[str]:
if not email:
return None
email = email.strip().lower()
return email if _EMAIL_RE.match(email) else None
def _clean_name(name: Optional[str]) -> Optional[str]:
name = (name or "").strip()
return name or None
class TicketTools(Toolkit):
"""Native ticket tools for the customer-facing chat agent — the AI-first
replacement for JiraTools. One native ticket per session: a second
create_ticket call appends the new details as an activity instead of
opening a duplicate.
"""
def __init__(self, agent_id: str, org_id: str, session_id: str):
super().__init__(name="ticket_tools")
self.agent_id = agent_id
self.org_id = org_id
self.session_id = session_id
self.register(self.create_ticket)
self.register(self.get_ticket_status)
self.register(self.check_existing_ticket)
def create_ticket(
self,
summary: str,
description: str,
priority: Optional[str] = "medium",
customer_email: Optional[str] = None,
customer_name: Optional[str] = None,
) -> str:
"""
Create a support ticket for the current conversation, or add the new
details to the session's existing ticket. Use this when the customer
reports a technical issue you cannot resolve from the knowledge base —
the ticket is investigated by the support team's AI and humans.
Always pass the customer's account email and registered name when you
know them (ask the customer first if they haven't been shared) — the
support AI uses them to look the customer up in the connected systems,
and without a real email it cannot analyse the customer's own records.
Args:
summary (str): Short title of the issue.
description (str): Detailed description: what happens, error
messages, customer impact, and anything needed to reproduce.
priority (str, optional): urgent, high, medium or low. Defaults to
"medium".
customer_email (str, optional): The customer's account email, as
they gave it. Used to correlate them in the support systems.
customer_name (str, optional): The customer's registered name.
Returns:
str: JSON string with the ticket number and status.
"""
try:
from app.services.ticket import TicketService
priority_value = (priority or "medium").lower()
if priority_value not in VALID_PRIORITIES:
priority_value = TicketPriority.MEDIUM.value
email = _clean_email(customer_email)
name = _clean_name(customer_name)
with SessionLocal() as db:
service = TicketService(db)
session_uuid = UUID(str(self.session_id))
conversation = SessionToAgentRepository(db).get_session(session_uuid)
customer = (
CustomerRepository(db).get_by_id(conversation.customer_id)
if conversation is not None and conversation.customer_id else None
)
trusted = trusts_identity_in_conversation(db, customer, conversation)
if trusted:
# Enrich the (usually anonymous) widget customer with the
# identity the customer stated in chat, so the ticket — and
# the investigator's DB correlation — carry a real email +
# name instead of the …@noemail.com placeholder.
self._capture_customer_identity(db, conversation, email, name)
else:
# A visitor who only typed a customer's email is not them:
# the ticket carries no customer, so the investigator has no
# one's records to look up or quote back into this chat.
email = name = None
existing = service.repo.get_by_session(session_uuid)
if existing is not None:
service._add_activity(
existing,
TicketActivityType.CUSTOMER_REPLIED,
actor_type=TicketActorType.AI,
body=f"Additional details from the conversation:\n{description}",
)
db.commit()
return json.dumps({
"success": True,
"updated": True,
"ticket_id": existing.display_number,
"status": str(existing.status),
"message": (
f"Ticket {existing.display_number} already exists for this "
"conversation — the new details were added to it."
),
})
ticket, _duplicates = service.create_ticket(
organization_id=UUID(str(self.org_id)),
title=summary[:500],
description=description,
priority=priority_value,
source=TicketSource.CHAT_AI,
session_id=session_uuid,
agent_id=UUID(str(self.agent_id)),
customer_email=email,
customer_name=name,
link_session_customer=trusted,
)
db.commit()
db.refresh(ticket)
ticket_number = ticket.display_number
status = str(ticket.status)
# The acknowledgment message is sent by the chat agent itself in
# its reply, so no separate customer notification here.
return json.dumps({
"success": True,
"updated": False,
"ticket_id": ticket_number,
"status": status,
"message": f"Ticket {ticket_number} created. The team's AI will start investigating.",
})
except Exception as e:
logger.error(f"create_ticket failed for session {self.session_id}: {e}")
return json.dumps({"success": False, "message": "Failed to create the ticket."})
def _capture_customer_identity(
self, db, conversation: SessionToAgent, email: Optional[str], name: Optional[str]
) -> None:
"""Fold a stated email/name onto the conversation's customer record in place.
Widget visitors are anonymous (`…@noemail.com`, no name); the customer
naming their real account in chat is the only identity the investigator
gets. update_contact only overwrites a placeholder email and never
clobbers a real one or a channel identity key. Only for a conversation
trusted to speak for its customer (see create_ticket).
"""
if not email and not name:
return
try:
CustomerRepository(db).update_contact(
conversation.customer_id, email=email, full_name=name
)
db.commit()
except Exception as e:
db.rollback()
logger.error(
f"Failed to capture customer identity for session {conversation.session_id}: {e}"
)
def get_ticket_status(self, ticket_number: Optional[str] = None) -> str:
"""
Get the current status of a ticket. Without a ticket number, returns
the status of this conversation's ticket.
Args:
ticket_number (str, optional): Ticket number like "TKT-123".
Returns:
str: JSON string with the ticket status and priority.
"""
try:
from app.repositories.ticket import TicketRepository
with SessionLocal() as db:
repo = TicketRepository(db)
ticket = None
if ticket_number:
digits = str(ticket_number).strip().upper().removeprefix("TKT-")
if digits.isdigit():
ticket = repo.get_by_number(UUID(str(self.org_id)), int(digits))
else:
ticket = repo.get_by_session(UUID(str(self.session_id)))
if ticket is None or str(ticket.organization_id) != str(self.org_id):
return json.dumps({"exists": False, "message": "No ticket found."})
return json.dumps({
"exists": True,
"ticket_id": ticket.display_number,
"status": str(ticket.status),
"priority": str(ticket.priority),
"title": ticket.title,
})
except Exception as e:
logger.error(f"get_ticket_status failed: {e}")
return json.dumps({"exists": False, "message": "Could not look up the ticket."})
def check_existing_ticket(self) -> str:
"""
Check whether this conversation already has a ticket. Always call this
before creating a ticket.
Returns:
str: JSON string with "exists" and the ticket number if present.
"""
try:
from app.repositories.ticket import TicketRepository
with SessionLocal() as db:
ticket = TicketRepository(db).get_by_session(UUID(str(self.session_id)))
if ticket is None:
return json.dumps({"exists": False})
return json.dumps({
"exists": True,
"ticket_id": ticket.display_number,
"status": str(ticket.status),
})
except Exception as e:
logger.error(f"check_existing_ticket failed: {e}")
return json.dumps({"exists": False})