1078 lines
44 KiB
Python
1078 lines
44 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ChatterMate
|
||
|
|
|
||
|
|
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.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from typing import List, Optional, Tuple
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.channels.constants import MessageSource
|
||
|
|
from app.core.logger import get_logger
|
||
|
|
from app.models.investigation import (
|
||
|
|
InvestigationRun,
|
||
|
|
InvestigationRunType,
|
||
|
|
InvestigationTrigger,
|
||
|
|
ProposalStatus,
|
||
|
|
TicketProposal,
|
||
|
|
)
|
||
|
|
from app.models.session_to_agent import SessionToAgent
|
||
|
|
from app.models.ticket import (
|
||
|
|
OPEN_TICKET_STATUSES,
|
||
|
|
TICKET_STATUS_TRANSITIONS,
|
||
|
|
ResolutionOutcome,
|
||
|
|
Ticket,
|
||
|
|
TicketPriority,
|
||
|
|
TicketSource,
|
||
|
|
TicketStatus,
|
||
|
|
)
|
||
|
|
from app.models.ticket_activity import TicketActivity, TicketActivityType, TicketActorType
|
||
|
|
from app.models.ticket_settings import (
|
||
|
|
DEFAULT_CREATED_TEMPLATE,
|
||
|
|
DEFAULT_RESOLVED_TEMPLATE,
|
||
|
|
DEFAULT_SLA_TARGETS,
|
||
|
|
OrganizationTicketSettings,
|
||
|
|
)
|
||
|
|
from app.repositories.chat import ChatRepository
|
||
|
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
||
|
|
from app.services.message_delivery import deliver_to_customer
|
||
|
|
from app.repositories.ticket import (
|
||
|
|
InvestigationRepository,
|
||
|
|
TicketActivityRepository,
|
||
|
|
TicketRepository,
|
||
|
|
TicketSettingsRepository,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
# Widget sessions carry this channel; their replies reach the browser tab over
|
||
|
|
# Socket.IO only, so ticket updates to them are also emailed (see below).
|
||
|
|
WEB_CHANNEL = "web"
|
||
|
|
|
||
|
|
# Open tickets whose title+description embed this close to an existing open
|
||
|
|
# ticket are flagged as possible duplicates.
|
||
|
|
DUPLICATE_SIMILARITY_THRESHOLD = 0.90
|
||
|
|
|
||
|
|
# Denormalized mirror value on session_to_agents.integration_type for native
|
||
|
|
# tickets (Jira sessions keep 'JIRA').
|
||
|
|
NATIVE_INTEGRATION_TYPE = "NATIVE"
|
||
|
|
|
||
|
|
# Trailing window for the workspace CSAT stat. Wider than the 7-day resolution
|
||
|
|
# stats because CSAT responses are sparse.
|
||
|
|
CSAT_WINDOW_DAYS = 30
|
||
|
|
|
||
|
|
_embedder = None
|
||
|
|
|
||
|
|
|
||
|
|
def _get_embedder():
|
||
|
|
"""Lazy module-level FastEmbed embedder (local model, no API cost)."""
|
||
|
|
global _embedder
|
||
|
|
if _embedder is None:
|
||
|
|
from app.knowledge.embedder import get_embedder
|
||
|
|
_embedder = get_embedder()
|
||
|
|
return _embedder
|
||
|
|
|
||
|
|
|
||
|
|
def embed_ticket_text(title: str, description: Optional[str]) -> Optional[List[float]]:
|
||
|
|
try:
|
||
|
|
content = title if not description else f"{title}\n\n{description}"
|
||
|
|
return _get_embedder().get_embedding(content[:4000])
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Ticket embedding failed: {e}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def render_customer_template(template: str, customer_name: Optional[str], display_number: str) -> str:
|
||
|
|
return (
|
||
|
|
template.replace("[customer]", customer_name or "there")
|
||
|
|
.replace("[ticket]", display_number)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TicketService:
|
||
|
|
"""Ticket lifecycle. Every mutation writes a TicketActivity row — the
|
||
|
|
activity table is the audit trail, never bypass it."""
|
||
|
|
|
||
|
|
def __init__(self, db: Session):
|
||
|
|
self.db = db
|
||
|
|
self.repo = TicketRepository(db)
|
||
|
|
self.activity_repo = TicketActivityRepository(db)
|
||
|
|
self.settings_repo = TicketSettingsRepository(db)
|
||
|
|
self.run_repo = InvestigationRepository(db)
|
||
|
|
|
||
|
|
# ---------- creation ----------
|
||
|
|
|
||
|
|
def create_ticket(
|
||
|
|
self,
|
||
|
|
organization_id: UUID,
|
||
|
|
title: str,
|
||
|
|
description: Optional[str] = None,
|
||
|
|
priority: str = TicketPriority.MEDIUM,
|
||
|
|
severity: Optional[int] = None,
|
||
|
|
tags: Optional[List[str]] = None,
|
||
|
|
source: str = TicketSource.MANUAL,
|
||
|
|
customer_id: Optional[UUID] = None,
|
||
|
|
customer_email: Optional[str] = None,
|
||
|
|
customer_name: Optional[str] = None,
|
||
|
|
session_id: Optional[UUID] = None,
|
||
|
|
agent_id: Optional[UUID] = None,
|
||
|
|
assignee_user_id: Optional[UUID] = None,
|
||
|
|
group_id: Optional[UUID] = None,
|
||
|
|
created_by_user_id: Optional[UUID] = None,
|
||
|
|
link_session_customer: bool = True,
|
||
|
|
) -> Tuple[Ticket, List[Tuple[Ticket, float]]]:
|
||
|
|
"""Create a ticket, link its session, mirror the legacy session
|
||
|
|
columns, and enqueue triage. Returns (ticket, possible_duplicates).
|
||
|
|
Caller commits.
|
||
|
|
|
||
|
|
customer_email (manual tickets): resolved to an existing customer in
|
||
|
|
the org or a new record — the direct-email notification path.
|
||
|
|
|
||
|
|
link_session_customer=False keeps the session's customer off the
|
||
|
|
ticket: a widget visitor bound to a customer by an email they only
|
||
|
|
typed must not hand the investigator that customer's identity."""
|
||
|
|
settings = self.settings_repo.get_or_create(organization_id)
|
||
|
|
|
||
|
|
if customer_id is None and customer_email:
|
||
|
|
customer_id = self._resolve_customer_by_email(
|
||
|
|
organization_id, customer_email, customer_name
|
||
|
|
)
|
||
|
|
|
||
|
|
session_record = None
|
||
|
|
if session_id is not None:
|
||
|
|
session_record = SessionToAgentRepository(self.db).get_session(session_id)
|
||
|
|
# Cross-org guard: linking mutates the session row and messages its
|
||
|
|
# customer, so a foreign session must be rejected, not ignored.
|
||
|
|
if session_record is None or str(session_record.organization_id) != str(organization_id):
|
||
|
|
raise ValueError("Session not found in this organization")
|
||
|
|
if customer_id is None and link_session_customer:
|
||
|
|
customer_id = session_record.customer_id
|
||
|
|
if agent_id is None:
|
||
|
|
agent_id = session_record.agent_id
|
||
|
|
|
||
|
|
embedding = embed_ticket_text(title, description)
|
||
|
|
|
||
|
|
ticket = Ticket(
|
||
|
|
organization_id=organization_id,
|
||
|
|
customer_id=customer_id,
|
||
|
|
title=title,
|
||
|
|
description=description,
|
||
|
|
priority=priority,
|
||
|
|
severity=severity,
|
||
|
|
tags=tags,
|
||
|
|
source=source,
|
||
|
|
status=TicketStatus.OPEN,
|
||
|
|
agent_id=agent_id,
|
||
|
|
assignee_user_id=assignee_user_id,
|
||
|
|
group_id=group_id,
|
||
|
|
created_by_user_id=created_by_user_id,
|
||
|
|
embedding=embedding,
|
||
|
|
)
|
||
|
|
self.repo.create(ticket)
|
||
|
|
|
||
|
|
possible_duplicates: List[Tuple[Ticket, float]] = []
|
||
|
|
if embedding is not None:
|
||
|
|
possible_duplicates = self.repo.find_similar(
|
||
|
|
organization_id,
|
||
|
|
embedding,
|
||
|
|
limit=3,
|
||
|
|
exclude_ticket_id=ticket.id,
|
||
|
|
only_open=True,
|
||
|
|
min_similarity=DUPLICATE_SIMILARITY_THRESHOLD,
|
||
|
|
)
|
||
|
|
|
||
|
|
actor_type = TicketActorType.AI if source == TicketSource.CHAT_AI else (
|
||
|
|
TicketActorType.USER if created_by_user_id else TicketActorType.SYSTEM
|
||
|
|
)
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.STATUS_CHANGE,
|
||
|
|
actor_type=actor_type,
|
||
|
|
actor_user_id=created_by_user_id,
|
||
|
|
body=(
|
||
|
|
f"Ticket created from conversation" if session_id else "Ticket created"
|
||
|
|
),
|
||
|
|
metadata={"to": TicketStatus.OPEN.value, "source": source},
|
||
|
|
)
|
||
|
|
|
||
|
|
if session_record is not None:
|
||
|
|
self.repo.link_session(ticket.id, session_record.session_id)
|
||
|
|
self._mirror_session_columns(session_record, ticket)
|
||
|
|
|
||
|
|
if settings.auto_investigate_on_create:
|
||
|
|
self.enqueue_run(
|
||
|
|
ticket,
|
||
|
|
run_type=InvestigationRunType.TRIAGE,
|
||
|
|
trigger=InvestigationTrigger.AUTO_ON_CREATE,
|
||
|
|
settings=settings,
|
||
|
|
)
|
||
|
|
|
||
|
|
return ticket, possible_duplicates
|
||
|
|
|
||
|
|
def _mirror_session_columns(self, session_record: SessionToAgent, ticket: Ticket) -> None:
|
||
|
|
"""Keep the deprecated session_to_agents.ticket_* columns in sync so
|
||
|
|
existing session/widget consumers keep working. Never clobbers an
|
||
|
|
explicit Jira linkage — JiraTools reads ticket_id as a Jira issue key."""
|
||
|
|
if session_record.integration_type not in (None, NATIVE_INTEGRATION_TYPE):
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
session_record.ticket_id = ticket.display_number
|
||
|
|
session_record.ticket_status = str(ticket.status)
|
||
|
|
session_record.ticket_summary = ticket.title[:255]
|
||
|
|
session_record.ticket_description = (ticket.description or "")[:1000]
|
||
|
|
session_record.integration_type = NATIVE_INTEGRATION_TYPE
|
||
|
|
session_record.ticket_priority = str(ticket.priority)
|
||
|
|
self.db.flush()
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Failed to mirror ticket columns onto session: {e}")
|
||
|
|
|
||
|
|
def _sync_linked_sessions(self, ticket: Ticket) -> None:
|
||
|
|
session_repo = SessionToAgentRepository(self.db)
|
||
|
|
for sid in self.repo.get_session_ids(ticket.id):
|
||
|
|
record = session_repo.get_session(sid)
|
||
|
|
if record is not None and record.integration_type == NATIVE_INTEGRATION_TYPE:
|
||
|
|
record.ticket_status = str(ticket.status)
|
||
|
|
record.ticket_priority = str(ticket.priority)
|
||
|
|
self.db.flush()
|
||
|
|
|
||
|
|
async def build_session_transcript(
|
||
|
|
self, session_id, max_messages: int = 15, line_chars: int = 400
|
||
|
|
) -> Optional[str]:
|
||
|
|
"""Plain-text transcript of a session's recent messages — shared by the
|
||
|
|
create-modal draft endpoint and the triage context builder."""
|
||
|
|
messages = (await ChatRepository(self.db).get_session_history(session_id))[-max_messages:]
|
||
|
|
lines = []
|
||
|
|
for m in messages:
|
||
|
|
who = "Customer" if m.message_type == "user" else "Agent"
|
||
|
|
text = (m.message or "").strip()
|
||
|
|
if text:
|
||
|
|
lines.append(f"{who}: {text[:line_chars]}")
|
||
|
|
return "\n".join(lines) or None
|
||
|
|
|
||
|
|
# ---------- lifecycle ----------
|
||
|
|
|
||
|
|
def transition_status(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
new_status: TicketStatus,
|
||
|
|
actor_type: str = TicketActorType.USER,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
reason: Optional[str] = None,
|
||
|
|
) -> Ticket:
|
||
|
|
current = TicketStatus(ticket.status)
|
||
|
|
new_status = TicketStatus(new_status)
|
||
|
|
if new_status == current:
|
||
|
|
return ticket
|
||
|
|
allowed = TICKET_STATUS_TRANSITIONS.get(current, set())
|
||
|
|
if new_status not in allowed:
|
||
|
|
raise ValueError(f"Illegal status transition {current.value} -> {new_status.value}")
|
||
|
|
|
||
|
|
ticket.status = new_status
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
if new_status == TicketStatus.RESOLVED and ticket.resolved_at is None:
|
||
|
|
ticket.resolved_at = now
|
||
|
|
if ticket.resolved_by_actor is None:
|
||
|
|
ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user"
|
||
|
|
if new_status == TicketStatus.RESOLVED_PENDING_CONFIRMATION:
|
||
|
|
ticket.confirmation_requested_at = now
|
||
|
|
if new_status == TicketStatus.CLOSED:
|
||
|
|
ticket.closed_at = now
|
||
|
|
if ticket.resolved_at is None:
|
||
|
|
ticket.resolved_at = now
|
||
|
|
if ticket.resolved_by_actor is None:
|
||
|
|
ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user"
|
||
|
|
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.STATUS_CHANGE,
|
||
|
|
actor_type=actor_type,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
body=reason or f"Status changed {current.value} → {new_status.value}",
|
||
|
|
metadata={"from": current.value, "to": new_status.value},
|
||
|
|
)
|
||
|
|
self._sync_linked_sessions(ticket)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def assign(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
assignee_user_id: Optional[UUID],
|
||
|
|
group_id: Optional[UUID],
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
) -> Ticket:
|
||
|
|
ticket.assignee_user_id = assignee_user_id
|
||
|
|
ticket.group_id = group_id
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.ASSIGNMENT,
|
||
|
|
actor_type=TicketActorType.USER if actor_user_id else TicketActorType.SYSTEM,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
metadata={
|
||
|
|
"assignee_user_id": str(assignee_user_id) if assignee_user_id else None,
|
||
|
|
"group_id": str(group_id) if group_id else None,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def set_priority(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
priority: TicketPriority,
|
||
|
|
actor_type: str = TicketActorType.USER,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
) -> Ticket:
|
||
|
|
old = ticket.priority
|
||
|
|
if str(old) == str(priority):
|
||
|
|
return ticket
|
||
|
|
ticket.priority = priority
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.PRIORITY_CHANGE,
|
||
|
|
actor_type=actor_type,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
metadata={"from": str(old), "to": str(priority)},
|
||
|
|
)
|
||
|
|
self._sync_linked_sessions(ticket)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def add_comment(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
body: str,
|
||
|
|
is_internal: bool = True,
|
||
|
|
actor_type: str = TicketActorType.USER,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
) -> TicketActivity:
|
||
|
|
activity = self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.COMMENT,
|
||
|
|
actor_type=actor_type,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
body=body,
|
||
|
|
is_internal=is_internal,
|
||
|
|
)
|
||
|
|
if ticket.first_response_at is None and actor_type in (
|
||
|
|
TicketActorType.USER, TicketActorType.AI
|
||
|
|
) and not is_internal:
|
||
|
|
ticket.first_response_at = datetime.now(timezone.utc)
|
||
|
|
return activity
|
||
|
|
|
||
|
|
async def resolve(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
outcome: str,
|
||
|
|
resolution_summary: Optional[str] = None,
|
||
|
|
customer_message: Optional[str] = None,
|
||
|
|
actor_type: str = TicketActorType.USER,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
) -> Ticket:
|
||
|
|
"""Resolve pending customer confirmation. Notifies the customer with
|
||
|
|
the plain-language message; the lifecycle worker closes it after the
|
||
|
|
confirmation timeout."""
|
||
|
|
# A repeat resolve (double-clicked button, client retry) must not
|
||
|
|
# re-send the resolution message. transition_status already no-ops when
|
||
|
|
# the status is unchanged, so key the notification off a real
|
||
|
|
# transition: only notify when the ticket wasn't already awaiting
|
||
|
|
# confirmation. Pair with a row lock in the endpoint so concurrent
|
||
|
|
# resolves serialize and the later ones observe the resolved status.
|
||
|
|
already_pending = (
|
||
|
|
TicketStatus(ticket.status) == TicketStatus.RESOLVED_PENDING_CONFIRMATION
|
||
|
|
)
|
||
|
|
ticket.resolution_outcome = outcome
|
||
|
|
if resolution_summary:
|
||
|
|
ticket.resolution_summary = resolution_summary
|
||
|
|
if customer_message:
|
||
|
|
ticket.customer_resolution_message = customer_message
|
||
|
|
ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user"
|
||
|
|
self.transition_status(
|
||
|
|
ticket,
|
||
|
|
TicketStatus.RESOLVED_PENDING_CONFIRMATION,
|
||
|
|
actor_type=actor_type,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
)
|
||
|
|
if not already_pending:
|
||
|
|
await self.notify_customer_resolved(ticket)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
async def close(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
actor_type: str = TicketActorType.SYSTEM,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
reason: Optional[str] = None,
|
||
|
|
) -> Ticket:
|
||
|
|
self.transition_status(
|
||
|
|
ticket, TicketStatus.CLOSED, actor_type=actor_type,
|
||
|
|
actor_user_id=actor_user_id, reason=reason,
|
||
|
|
)
|
||
|
|
settings = self.settings_repo.get_or_create(ticket.organization_id)
|
||
|
|
if settings.csat_enabled:
|
||
|
|
await self.request_csat(ticket)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def reopen(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
reason: Optional[str] = None,
|
||
|
|
actor_type: str = TicketActorType.USER,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
) -> Ticket:
|
||
|
|
ticket.reopened_count = (ticket.reopened_count or 0) + 1
|
||
|
|
ticket.resolved_at = None
|
||
|
|
ticket.closed_at = None
|
||
|
|
ticket.confirmation_requested_at = None
|
||
|
|
ticket.resolved_by_actor = None
|
||
|
|
ticket.status = TicketStatus.REOPENED
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.REOPENED,
|
||
|
|
actor_type=actor_type,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
body=reason,
|
||
|
|
)
|
||
|
|
self._sync_linked_sessions(ticket)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
# ---------- AI runs ----------
|
||
|
|
|
||
|
|
def _investigation_over_budget(self, ticket: Ticket) -> bool:
|
||
|
|
"""True when a metered (hosted-model) org has no message credits left.
|
||
|
|
Fails open (never blocks) for own-key orgs, unlimited plans, self-host
|
||
|
|
without the enterprise module, or on any metering error."""
|
||
|
|
from app.services.usage_metering import (
|
||
|
|
is_hosted_model,
|
||
|
|
remaining_message_credits,
|
||
|
|
)
|
||
|
|
if not is_hosted_model(self.db, ticket.organization_id):
|
||
|
|
return False
|
||
|
|
remaining = remaining_message_credits(self.db, ticket.organization_id)
|
||
|
|
return remaining is not None and remaining <= 0
|
||
|
|
|
||
|
|
def enqueue_run(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
run_type: str = InvestigationRunType.TRIAGE,
|
||
|
|
trigger: str = InvestigationTrigger.MANUAL,
|
||
|
|
requested_by_user_id: Optional[UUID] = None,
|
||
|
|
context_note: Optional[str] = None,
|
||
|
|
settings: Optional[OrganizationTicketSettings] = None,
|
||
|
|
) -> Optional[InvestigationRun]:
|
||
|
|
settings = settings or self.settings_repo.get_or_create(ticket.organization_id)
|
||
|
|
if run_type == InvestigationRunType.INVESTIGATION:
|
||
|
|
done = self.run_repo.count_for_ticket(ticket.id, InvestigationRunType.INVESTIGATION)
|
||
|
|
if done >= settings.max_runs_per_ticket:
|
||
|
|
logger.info(f"Ticket {ticket.display_number}: max investigation runs reached")
|
||
|
|
return None
|
||
|
|
# An investigation makes many metered LLM calls, so a hosted-model
|
||
|
|
# org that's out of message credits can't start one. Triage (cheap,
|
||
|
|
# one call) is never gated, so tickets still get classified.
|
||
|
|
if self._investigation_over_budget(ticket):
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.STATUS_CHANGE,
|
||
|
|
actor_type=TicketActorType.SYSTEM,
|
||
|
|
body=(
|
||
|
|
"AI investigation paused — the organization is out of "
|
||
|
|
"message credits for this billing period."
|
||
|
|
),
|
||
|
|
metadata={"reason": "message_limit_reached"},
|
||
|
|
)
|
||
|
|
logger.info(f"Ticket {ticket.display_number}: investigation blocked, out of credits")
|
||
|
|
return None
|
||
|
|
run = self.run_repo.enqueue(
|
||
|
|
InvestigationRun(
|
||
|
|
ticket_id=ticket.id,
|
||
|
|
organization_id=ticket.organization_id,
|
||
|
|
run_type=run_type,
|
||
|
|
trigger=trigger,
|
||
|
|
requested_by_user_id=requested_by_user_id,
|
||
|
|
context_note=context_note,
|
||
|
|
max_tool_calls=settings.max_tool_calls_per_run,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return run
|
||
|
|
|
||
|
|
# ---------- autonomy L2: propose / approve ----------
|
||
|
|
|
||
|
|
def create_proposal(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
run_id: Optional[UUID],
|
||
|
|
summary: str,
|
||
|
|
customer_message: Optional[str],
|
||
|
|
confidence: Optional[float],
|
||
|
|
) -> TicketProposal:
|
||
|
|
"""Record an AI-proposed resolution and park the ticket in
|
||
|
|
awaiting_approval. Any earlier pending proposal is superseded."""
|
||
|
|
(
|
||
|
|
self.db.query(TicketProposal)
|
||
|
|
.filter(
|
||
|
|
TicketProposal.ticket_id == ticket.id,
|
||
|
|
TicketProposal.status == ProposalStatus.PENDING.value,
|
||
|
|
)
|
||
|
|
.update({TicketProposal.status: ProposalStatus.SUPERSEDED.value})
|
||
|
|
)
|
||
|
|
proposal = TicketProposal(
|
||
|
|
ticket_id=ticket.id,
|
||
|
|
organization_id=ticket.organization_id,
|
||
|
|
run_id=run_id,
|
||
|
|
summary=summary,
|
||
|
|
customer_message=customer_message,
|
||
|
|
confidence=confidence,
|
||
|
|
)
|
||
|
|
self.db.add(proposal)
|
||
|
|
self.db.flush()
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.AI_RESOLUTION_PROPOSED,
|
||
|
|
actor_type=TicketActorType.AI,
|
||
|
|
body=summary,
|
||
|
|
metadata={"proposal_id": str(proposal.id), "confidence": confidence},
|
||
|
|
)
|
||
|
|
if TicketStatus(str(ticket.status)) in TICKET_STATUS_TRANSITIONS and (
|
||
|
|
TicketStatus.AWAITING_APPROVAL
|
||
|
|
in TICKET_STATUS_TRANSITIONS[TicketStatus(str(ticket.status))]
|
||
|
|
):
|
||
|
|
self.transition_status(
|
||
|
|
ticket, TicketStatus.AWAITING_APPROVAL, actor_type=TicketActorType.AI
|
||
|
|
)
|
||
|
|
return proposal
|
||
|
|
|
||
|
|
async def approve_proposal(
|
||
|
|
self, ticket: Ticket, proposal: TicketProposal, user_id: UUID
|
||
|
|
) -> Ticket:
|
||
|
|
"""Human approval: the proposed resolution is applied — the ticket
|
||
|
|
resolves (pending customer confirmation) and the customer gets the
|
||
|
|
proposal's message. Any infra change stays with humans/runbooks."""
|
||
|
|
proposal.status = ProposalStatus.APPROVED
|
||
|
|
proposal.decided_by_user_id = user_id
|
||
|
|
proposal.decided_at = datetime.now(timezone.utc)
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.AI_RESOLUTION_APPROVED,
|
||
|
|
actor_type=TicketActorType.USER,
|
||
|
|
actor_user_id=user_id,
|
||
|
|
body=proposal.summary,
|
||
|
|
metadata={"proposal_id": str(proposal.id)},
|
||
|
|
)
|
||
|
|
# The AI resolved it (a human signed off) — counts as AI-resolved.
|
||
|
|
await self.resolve(
|
||
|
|
ticket,
|
||
|
|
outcome=ResolutionOutcome.FIXED,
|
||
|
|
resolution_summary=proposal.summary,
|
||
|
|
customer_message=proposal.customer_message,
|
||
|
|
actor_type=TicketActorType.AI,
|
||
|
|
)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def reject_proposal(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
proposal: TicketProposal,
|
||
|
|
user_id: UUID,
|
||
|
|
reason: Optional[str],
|
||
|
|
reinvestigate: bool = False,
|
||
|
|
) -> Optional[InvestigationRun]:
|
||
|
|
"""Human rejection. Optionally feeds the reason into a refined
|
||
|
|
investigation run (bounded by max_runs_per_ticket)."""
|
||
|
|
proposal.status = ProposalStatus.REJECTED
|
||
|
|
proposal.decided_by_user_id = user_id
|
||
|
|
proposal.decided_at = datetime.now(timezone.utc)
|
||
|
|
proposal.reject_reason = reason
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.AI_RESOLUTION_REJECTED,
|
||
|
|
actor_type=TicketActorType.USER,
|
||
|
|
actor_user_id=user_id,
|
||
|
|
body=reason,
|
||
|
|
metadata={"proposal_id": str(proposal.id), "reinvestigate": reinvestigate},
|
||
|
|
)
|
||
|
|
if str(ticket.status) == TicketStatus.AWAITING_APPROVAL.value:
|
||
|
|
self.transition_status(
|
||
|
|
ticket, TicketStatus.OPEN,
|
||
|
|
actor_type=TicketActorType.USER, actor_user_id=user_id,
|
||
|
|
reason="Proposal rejected",
|
||
|
|
)
|
||
|
|
if not reinvestigate:
|
||
|
|
return None
|
||
|
|
return self.enqueue_run(
|
||
|
|
ticket,
|
||
|
|
run_type=InvestigationRunType.INVESTIGATION,
|
||
|
|
trigger=InvestigationTrigger.REJECTION_FEEDBACK,
|
||
|
|
requested_by_user_id=user_id,
|
||
|
|
context_note=reason,
|
||
|
|
)
|
||
|
|
|
||
|
|
def pending_proposal(self, ticket_id: UUID) -> Optional[TicketProposal]:
|
||
|
|
return (
|
||
|
|
self.db.query(TicketProposal)
|
||
|
|
.filter(
|
||
|
|
TicketProposal.ticket_id == ticket_id,
|
||
|
|
TicketProposal.status == ProposalStatus.PENDING.value,
|
||
|
|
)
|
||
|
|
.order_by(TicketProposal.created_at.desc())
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
|
||
|
|
def latest_proposal(self, ticket_id: UUID) -> Optional[TicketProposal]:
|
||
|
|
return (
|
||
|
|
self.db.query(TicketProposal)
|
||
|
|
.filter(TicketProposal.ticket_id == ticket_id)
|
||
|
|
.order_by(TicketProposal.created_at.desc())
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
|
||
|
|
# ---------- customer communications ----------
|
||
|
|
|
||
|
|
async def notify_customer_created(
|
||
|
|
self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None
|
||
|
|
) -> None:
|
||
|
|
settings = settings or self.settings_repo.get_or_create(ticket.organization_id)
|
||
|
|
template = settings.created_template or DEFAULT_CREATED_TEMPLATE
|
||
|
|
message = render_customer_template(
|
||
|
|
template, self._customer_name(ticket), ticket.display_number
|
||
|
|
)
|
||
|
|
await self.send_customer_message(ticket, message)
|
||
|
|
|
||
|
|
async def notify_customer_resolved(
|
||
|
|
self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None
|
||
|
|
) -> None:
|
||
|
|
settings = settings or self.settings_repo.get_or_create(ticket.organization_id)
|
||
|
|
template = settings.resolved_template or DEFAULT_RESOLVED_TEMPLATE
|
||
|
|
message = render_customer_template(
|
||
|
|
template, self._customer_name(ticket), ticket.display_number
|
||
|
|
)
|
||
|
|
if ticket.customer_resolution_message:
|
||
|
|
message = f"{message}\n\n{ticket.customer_resolution_message}"
|
||
|
|
await self.send_customer_message(ticket, message)
|
||
|
|
|
||
|
|
async def request_csat(self, ticket: Ticket) -> None:
|
||
|
|
"""Ask the customer to rate the ticket, reusing the widget's existing
|
||
|
|
conversation rating prompt.
|
||
|
|
|
||
|
|
Conversation-only by design: a rating row is keyed to a session
|
||
|
|
(ratings.session_id is NOT NULL), so a ticket with no linked
|
||
|
|
conversation — manual or email-only — has nothing to rate and no
|
||
|
|
capture path short of a public tokenised rating page. Those tickets
|
||
|
|
are simply never asked; csat_requested_at stays NULL and the UI shows
|
||
|
|
"not requested" rather than a CSAT that will never arrive.
|
||
|
|
"""
|
||
|
|
session_record = self._primary_session(ticket)
|
||
|
|
if session_record is None:
|
||
|
|
return
|
||
|
|
message = (
|
||
|
|
f"How did we do on ticket {ticket.display_number}? "
|
||
|
|
"Your feedback helps us improve — you can rate this conversation."
|
||
|
|
)
|
||
|
|
# The widget only shows its stars on a message that also ends the chat
|
||
|
|
# (as the AI's own wrap-up does); without these the customer was asked
|
||
|
|
# to rate with nothing to rate with. Stored with the same attributes so
|
||
|
|
# the thread and a reloaded widget show the ask too.
|
||
|
|
rating_ask = {
|
||
|
|
"request_rating": True,
|
||
|
|
"end_chat": True,
|
||
|
|
"end_chat_reason": "ISSUE_RESOLVED",
|
||
|
|
}
|
||
|
|
try:
|
||
|
|
stored = ChatRepository(self.db).create_message({
|
||
|
|
"message": message,
|
||
|
|
"message_type": "bot",
|
||
|
|
"session_id": session_record.session_id,
|
||
|
|
"organization_id": ticket.organization_id,
|
||
|
|
"agent_id": session_record.agent_id,
|
||
|
|
"customer_id": session_record.customer_id,
|
||
|
|
"attributes": {"ticket_number": ticket.display_number, **rating_ask},
|
||
|
|
})
|
||
|
|
await deliver_to_customer(self.db, session_record, {
|
||
|
|
"message": message,
|
||
|
|
"type": "chat_response",
|
||
|
|
"session_id": str(session_record.session_id),
|
||
|
|
**rating_ask,
|
||
|
|
}, message_id=stored.id, source=MessageSource.SYSTEM)
|
||
|
|
ticket.csat_requested_at = datetime.now(timezone.utc)
|
||
|
|
self._add_activity(
|
||
|
|
ticket, TicketActivityType.CSAT_REQUESTED,
|
||
|
|
actor_type=TicketActorType.SYSTEM,
|
||
|
|
metadata={"session_id": str(session_record.session_id)},
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"CSAT request failed for {ticket.display_number}: {e}")
|
||
|
|
|
||
|
|
def record_csat(
|
||
|
|
self, ticket: Ticket, rating_id: UUID, score: int, feedback: Optional[str] = None
|
||
|
|
) -> Ticket:
|
||
|
|
"""Attach a submitted conversation rating to the ticket. The most
|
||
|
|
recent rating wins if a customer rates the same conversation twice.
|
||
|
|
|
||
|
|
Re-submitting the same score is ignored: the customer controls how
|
||
|
|
often this fires, and every write here appends to the audit feed."""
|
||
|
|
if ticket.csat_rating_id == rating_id and ticket.csat_score == score:
|
||
|
|
return ticket
|
||
|
|
ticket.csat_score = score
|
||
|
|
ticket.csat_rating_id = rating_id
|
||
|
|
ticket.csat_responded_at = datetime.now(timezone.utc)
|
||
|
|
body = f"Customer rated {score}/5"
|
||
|
|
if feedback:
|
||
|
|
body = f"{body} — {feedback}"
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.CSAT_RECEIVED,
|
||
|
|
actor_type=TicketActorType.CUSTOMER,
|
||
|
|
body=body,
|
||
|
|
metadata={"rating_id": str(rating_id), "score": score},
|
||
|
|
)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def _resolve_customer_by_email(
|
||
|
|
self, organization_id: UUID, customer_email: str, customer_name: Optional[str]
|
||
|
|
) -> UUID:
|
||
|
|
from app.repositories.customer import CustomerRepository
|
||
|
|
customer = CustomerRepository(self.db).get_or_create_customer(
|
||
|
|
email=customer_email.strip().lower(),
|
||
|
|
organization_id=organization_id,
|
||
|
|
full_name=(customer_name or "").strip() or None,
|
||
|
|
)
|
||
|
|
return customer.id
|
||
|
|
|
||
|
|
def set_customer(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
customer_email: str,
|
||
|
|
customer_name: Optional[str] = None,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
) -> Ticket:
|
||
|
|
"""Set/replace the ticket's customer by email (find-or-create)."""
|
||
|
|
customer_id = self._resolve_customer_by_email(
|
||
|
|
ticket.organization_id, customer_email, customer_name
|
||
|
|
)
|
||
|
|
if ticket.customer_id == customer_id:
|
||
|
|
return ticket
|
||
|
|
ticket.customer_id = customer_id
|
||
|
|
self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.CUSTOMER_LINKED,
|
||
|
|
actor_type=TicketActorType.USER if actor_user_id else TicketActorType.SYSTEM,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
body=f"Customer set to {customer_email.strip().lower()}",
|
||
|
|
metadata={"customer_id": str(customer_id)},
|
||
|
|
)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def can_notify_customer(self, ticket: Ticket) -> bool:
|
||
|
|
"""Whether any outbound path to the customer exists: a linked
|
||
|
|
conversation, or a customer with an email for direct sending."""
|
||
|
|
if self.repo.get_session_ids(ticket.id):
|
||
|
|
return True
|
||
|
|
return bool(ticket.customer and ticket.customer.email)
|
||
|
|
|
||
|
|
def _scrub_for_customer(self, ticket: Ticket, message: str) -> str:
|
||
|
|
"""Redact identifiers that aren't the recipient's own before delivery."""
|
||
|
|
from app.services.ticket_privacy import scrub_outbound
|
||
|
|
|
||
|
|
customer = ticket.customer
|
||
|
|
owned = (customer.email, customer.phone) if customer else ()
|
||
|
|
scrubbed = scrub_outbound(message, owned)
|
||
|
|
if scrubbed != message:
|
||
|
|
logger.warning(
|
||
|
|
f"Third-party identifiers redacted from an outbound message on "
|
||
|
|
f"{ticket.display_number}"
|
||
|
|
)
|
||
|
|
return scrubbed or message
|
||
|
|
|
||
|
|
async def send_customer_message(
|
||
|
|
self, ticket: Ticket, message: str, record_activity: bool = True
|
||
|
|
) -> None:
|
||
|
|
"""Deliver a ticket message to the customer: through the linked
|
||
|
|
conversation's channel (widget socket or email/WhatsApp/... adapter)
|
||
|
|
when one exists, by direct email otherwise (manual tickets).
|
||
|
|
record_activity=False when the caller already wrote its own activity
|
||
|
|
row (customer-visible comments).
|
||
|
|
|
||
|
|
Every outbound path passes through here, so this is where third-party
|
||
|
|
identifiers are stripped: the investigator reads other customers'
|
||
|
|
tickets and unscoped database rows, and at autonomy L3 its summary
|
||
|
|
reaches the customer with no human in between."""
|
||
|
|
message = self._scrub_for_customer(ticket, message)
|
||
|
|
session_record = self._primary_session(ticket)
|
||
|
|
if session_record is None:
|
||
|
|
await self._send_direct_email(ticket, message, record_activity)
|
||
|
|
return
|
||
|
|
channel = getattr(session_record, "channel", None) or WEB_CHANNEL
|
||
|
|
chat_repo = ChatRepository(self.db)
|
||
|
|
stored = delivery = None
|
||
|
|
try:
|
||
|
|
# Stored first so the channel's message id lands on this row
|
||
|
|
stored = chat_repo.create_message({
|
||
|
|
"message": message,
|
||
|
|
"message_type": "bot",
|
||
|
|
"session_id": session_record.session_id,
|
||
|
|
"organization_id": ticket.organization_id,
|
||
|
|
"agent_id": session_record.agent_id,
|
||
|
|
"customer_id": session_record.customer_id,
|
||
|
|
"attributes": {"ticket_number": ticket.display_number},
|
||
|
|
})
|
||
|
|
delivery = await deliver_to_customer(self.db, session_record, {
|
||
|
|
"message": message,
|
||
|
|
"type": "chat_response",
|
||
|
|
"session_id": str(session_record.session_id),
|
||
|
|
}, message_id=stored.id, source=MessageSource.SYSTEM)
|
||
|
|
if not delivery.ok:
|
||
|
|
chat_repo.mark_delivery_failed(stored.id, delivery.reason)
|
||
|
|
if ticket.first_response_at is None:
|
||
|
|
ticket.first_response_at = datetime.now(timezone.utc)
|
||
|
|
if record_activity:
|
||
|
|
self._add_activity(
|
||
|
|
ticket, TicketActivityType.CUSTOMER_NOTIFIED,
|
||
|
|
actor_type=TicketActorType.SYSTEM,
|
||
|
|
body=message,
|
||
|
|
metadata={
|
||
|
|
"session_id": str(session_record.session_id),
|
||
|
|
"channel": channel,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Customer notification failed for {ticket.display_number}: {e}")
|
||
|
|
# Stored but the hand-over itself raised: don't let the thread
|
||
|
|
# show it as sent
|
||
|
|
if stored is not None and delivery is None:
|
||
|
|
chat_repo.mark_delivery_failed(stored.id, "send_failed")
|
||
|
|
|
||
|
|
# Widget (web) sessions are delivered over Socket.IO to the browser tab
|
||
|
|
# only, but a ticket reply almost always arrives after the visitor has
|
||
|
|
# closed the chat — so it reaches nobody. When we have a real inbox for
|
||
|
|
# them, also email the reply through the org's connected email channel
|
||
|
|
# (their own domain) so async ticket updates actually land. External
|
||
|
|
# channels (WhatsApp/Telegram/email) already deliver asynchronously via
|
||
|
|
# their own adapter, so this supplement is web-only.
|
||
|
|
if channel == WEB_CHANNEL:
|
||
|
|
to_email = self._deliverable_email(ticket)
|
||
|
|
if to_email:
|
||
|
|
await self._email_customer_copy(ticket, message, to_email)
|
||
|
|
|
||
|
|
async def _send_direct_email(
|
||
|
|
self, ticket: Ticket, message: str, record_activity: bool
|
||
|
|
) -> None:
|
||
|
|
"""Email fallback for tickets with no linked conversation."""
|
||
|
|
to_email = self._deliverable_email(ticket)
|
||
|
|
if not to_email:
|
||
|
|
logger.info(
|
||
|
|
f"Ticket {ticket.display_number}: no linked session or mailable customer email to notify"
|
||
|
|
)
|
||
|
|
return
|
||
|
|
if not await self._email_customer_copy(ticket, message, to_email):
|
||
|
|
return
|
||
|
|
if ticket.first_response_at is None:
|
||
|
|
ticket.first_response_at = datetime.now(timezone.utc)
|
||
|
|
if not record_activity:
|
||
|
|
return
|
||
|
|
self._add_activity(
|
||
|
|
ticket, TicketActivityType.CUSTOMER_NOTIFIED,
|
||
|
|
actor_type=TicketActorType.SYSTEM,
|
||
|
|
body=message,
|
||
|
|
metadata={"channel": "email", "to": to_email},
|
||
|
|
)
|
||
|
|
|
||
|
|
def _deliverable_email(self, ticket: Ticket) -> Optional[str]:
|
||
|
|
"""The customer's real, mailable address, or None for anonymous /
|
||
|
|
placeholder records: …@noemail.com widget visitors, …@<channel>.channel
|
||
|
|
identity keys, and generate-token …@<org>.local anonymous stand-ins —
|
||
|
|
none of which can receive mail."""
|
||
|
|
from app.repositories.customer import CustomerRepository
|
||
|
|
email = ticket.customer.email if ticket.customer else None
|
||
|
|
if not email or CustomerRepository.is_placeholder_email(email) or email.endswith(".local"):
|
||
|
|
return None
|
||
|
|
return email
|
||
|
|
|
||
|
|
async def _email_customer_copy(self, ticket: Ticket, message: str, to_email: str) -> bool:
|
||
|
|
"""Email a ticket reply to a customer through the org's email channel.
|
||
|
|
The single send point for both the widget (web) supplement and the
|
||
|
|
no-session fallback. Returns whether the mail was sent.
|
||
|
|
|
||
|
|
Best-effort — send_ticket_email swallows SMTP errors and returns False,
|
||
|
|
so a mail hiccup never fails the surrounding ticket mutation."""
|
||
|
|
from app.services.ticket_email import send_ticket_email
|
||
|
|
return await send_ticket_email(
|
||
|
|
self.db,
|
||
|
|
ticket.organization_id,
|
||
|
|
to_email,
|
||
|
|
subject=f"[{ticket.display_number}] {ticket.title}",
|
||
|
|
body=message,
|
||
|
|
ticket_id=ticket.id,
|
||
|
|
in_reply_to=self.last_customer_email_message_id(ticket),
|
||
|
|
)
|
||
|
|
|
||
|
|
def last_customer_email_message_id(self, ticket: Ticket) -> Optional[str]:
|
||
|
|
"""Message-ID of the customer's most recent emailed reply, so the next
|
||
|
|
notification threads under it in their client."""
|
||
|
|
for activity in reversed(self.activity_repo.list_for_ticket(ticket.id)):
|
||
|
|
if activity.activity_type != TicketActivityType.CUSTOMER_REPLIED.value:
|
||
|
|
continue
|
||
|
|
message_id = (activity.activity_metadata or {}).get("email_message_id")
|
||
|
|
if message_id:
|
||
|
|
return message_id
|
||
|
|
return None
|
||
|
|
|
||
|
|
def record_customer_email_reply(
|
||
|
|
self, ticket: Ticket, body: str, from_email: str, message_id: Optional[str] = None
|
||
|
|
) -> TicketActivity:
|
||
|
|
"""Append a customer's emailed reply to the ticket feed, reopening it
|
||
|
|
when the reply lands on a resolution the customer never confirmed.
|
||
|
|
|
||
|
|
Customer-visible (is_internal=False): it is the customer's own words,
|
||
|
|
and the agent-facing feed is where the team reads them."""
|
||
|
|
activity = self._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.CUSTOMER_REPLIED,
|
||
|
|
actor_type=TicketActorType.CUSTOMER,
|
||
|
|
body=body,
|
||
|
|
is_internal=False,
|
||
|
|
metadata={
|
||
|
|
"channel": "email",
|
||
|
|
"from": from_email,
|
||
|
|
"email_message_id": message_id,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
current = TicketStatus(str(ticket.status))
|
||
|
|
if current == TicketStatus.RESOLVED_PENDING_CONFIRMATION and (
|
||
|
|
TicketStatus.REOPENED in TICKET_STATUS_TRANSITIONS.get(current, set())
|
||
|
|
):
|
||
|
|
self.reopen(
|
||
|
|
ticket,
|
||
|
|
reason="Customer replied by email before confirming the resolution",
|
||
|
|
actor_type=TicketActorType.CUSTOMER,
|
||
|
|
)
|
||
|
|
return activity
|
||
|
|
|
||
|
|
def _primary_session(self, ticket: Ticket) -> Optional[SessionToAgent]:
|
||
|
|
session_ids = self.repo.get_session_ids(ticket.id)
|
||
|
|
if not session_ids:
|
||
|
|
return None
|
||
|
|
return SessionToAgentRepository(self.db).get_session(session_ids[0])
|
||
|
|
|
||
|
|
def _customer_name(self, ticket: Ticket) -> Optional[str]:
|
||
|
|
try:
|
||
|
|
return ticket.customer.full_name if ticket.customer else None
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# ---------- derived fields ----------
|
||
|
|
|
||
|
|
def ai_state(self, ticket: Ticket, has_active_run: Optional[bool] = None) -> str:
|
||
|
|
"""Derived UI field: investigating / awaiting / human / resolved.
|
||
|
|
Resolution wins over a stale not-yet-processed run."""
|
||
|
|
status = str(ticket.status)
|
||
|
|
resolved_states = (
|
||
|
|
TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||
|
|
TicketStatus.RESOLVED_PENDING_CONFIRMATION,
|
||
|
|
)
|
||
|
|
if status in resolved_states:
|
||
|
|
return "resolved" if ticket.resolved_by_actor == "ai" else "human"
|
||
|
|
if has_active_run is None:
|
||
|
|
has_active_run = self.run_repo.get_active_for_ticket(ticket.id) is not None
|
||
|
|
if status in (TicketStatus.TRIAGING, TicketStatus.INVESTIGATING) or has_active_run:
|
||
|
|
return "investigating"
|
||
|
|
if status == TicketStatus.AWAITING_APPROVAL:
|
||
|
|
return "awaiting"
|
||
|
|
return "human"
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _resolution_due(
|
||
|
|
priority: str, created_at, settings: OrganizationTicketSettings
|
||
|
|
) -> Optional[datetime]:
|
||
|
|
targets = settings.sla_targets or DEFAULT_SLA_TARGETS
|
||
|
|
target = targets.get(str(priority))
|
||
|
|
if not target or not created_at:
|
||
|
|
return None
|
||
|
|
if created_at.tzinfo is None:
|
||
|
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
||
|
|
return created_at + timedelta(minutes=target["resolution_minutes"])
|
||
|
|
|
||
|
|
def sla_due_at(
|
||
|
|
self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None
|
||
|
|
) -> Optional[datetime]:
|
||
|
|
"""Resolution SLA deadline from the org's per-priority targets."""
|
||
|
|
if str(ticket.status) not in OPEN_TICKET_STATUSES:
|
||
|
|
return None
|
||
|
|
settings = settings or self.settings_repo.get_or_create(ticket.organization_id)
|
||
|
|
return self._resolution_due(ticket.priority, ticket.created_at, settings)
|
||
|
|
|
||
|
|
def stats(self, organization_id: UUID) -> dict:
|
||
|
|
counts = self.repo.count_by_status(organization_id)
|
||
|
|
open_count = sum(counts.get(s, 0) for s in OPEN_TICKET_STATUSES)
|
||
|
|
awaiting = counts.get(TicketStatus.AWAITING_APPROVAL.value, 0)
|
||
|
|
|
||
|
|
settings = self.settings_repo.get_or_create(organization_id)
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
breaching = 0
|
||
|
|
for priority, created_at in self.repo.open_ticket_sla_fields(organization_id):
|
||
|
|
due = self._resolution_due(priority, created_at, settings)
|
||
|
|
if due is not None and due <= now + timedelta(minutes=30):
|
||
|
|
breaching += 1
|
||
|
|
|
||
|
|
ai_resolved, total_resolved = self.repo.resolved_counts_7d(organization_id)
|
||
|
|
pct = round(100.0 * ai_resolved / total_resolved, 1) if total_resolved else None
|
||
|
|
|
||
|
|
return {
|
||
|
|
"open": open_count,
|
||
|
|
"awaiting_approval": awaiting,
|
||
|
|
"sla_breaching": breaching,
|
||
|
|
"ai_resolved_pct_7d": pct,
|
||
|
|
**self.csat_stats(organization_id),
|
||
|
|
}
|
||
|
|
|
||
|
|
def csat_stats(self, organization_id: UUID, days: int = CSAT_WINDOW_DAYS) -> dict:
|
||
|
|
"""Overall CSAT plus the AI-resolved vs human-resolved split. A wider
|
||
|
|
window than the resolution stats — CSAT responses are far sparser, and
|
||
|
|
a 7-day average would be noise for most orgs."""
|
||
|
|
by_resolver = self.repo.csat_by_resolver(organization_id, days=days)
|
||
|
|
total = sum(count for count, _ in by_resolver.values())
|
||
|
|
weighted = sum(count * avg for count, avg in by_resolver.values())
|
||
|
|
|
||
|
|
def side(actor: str) -> Optional[float]:
|
||
|
|
entry = by_resolver.get(actor)
|
||
|
|
return round(entry[1], 2) if entry else None
|
||
|
|
|
||
|
|
return {
|
||
|
|
"csat_responses": total,
|
||
|
|
"csat_avg": round(weighted / total, 2) if total else None,
|
||
|
|
"csat_ai_avg": side("ai"),
|
||
|
|
"csat_human_avg": side("user"),
|
||
|
|
"csat_window_days": days,
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------- internals ----------
|
||
|
|
|
||
|
|
def _add_activity(
|
||
|
|
self,
|
||
|
|
ticket: Ticket,
|
||
|
|
activity_type: str,
|
||
|
|
actor_type: str = TicketActorType.SYSTEM,
|
||
|
|
actor_user_id: Optional[UUID] = None,
|
||
|
|
body: Optional[str] = None,
|
||
|
|
is_internal: bool = True,
|
||
|
|
metadata: Optional[dict] = None,
|
||
|
|
) -> TicketActivity:
|
||
|
|
return self.activity_repo.add(
|
||
|
|
TicketActivity(
|
||
|
|
ticket_id=ticket.id,
|
||
|
|
activity_type=activity_type,
|
||
|
|
actor_type=actor_type,
|
||
|
|
actor_user_id=actor_user_id,
|
||
|
|
body=body,
|
||
|
|
is_internal=is_internal,
|
||
|
|
activity_metadata=metadata,
|
||
|
|
)
|
||
|
|
)
|