feat(agent): 检索工具、模型接入与结构化回复
Agent 按组织与知识源范围调用检索工具,密钥加密保存,回复含来源与转人工意图。
This commit is contained in:
1 parent
c7011040c3
commit
2dd062787d
65 files changed
+33409
No files matched your search
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
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})
|
||||
Reference in new issue
Block a user