Files
replypier/backend/app/repositories/ticket.py
T

556 lines
21 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.
"""
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Tuple
from uuid import UUID
from sqlalchemy import and_, func, or_, text
from sqlalchemy.orm import Session, joinedload
from app.core.logger import get_logger
from app.models.investigation import (
ACTIVE_RUN_STATUSES,
InvestigationEvent,
InvestigationHypothesis,
InvestigationRun,
InvestigationRunStatus,
RCADocument,
)
from app.models.ticket import (
OPEN_TICKET_STATUSES,
Ticket,
TicketSequence,
TicketSession,
TicketStatus,
)
from app.models.ticket_activity import TicketActivity
from app.models.ticket_settings import OrganizationTicketSettings
logger = get_logger(__name__)
# Safety cap for the in-Python SLA-breach computation over open tickets.
MAX_OPEN_TICKETS_FOR_STATS = 5000
class TicketRepository:
def __init__(self, db: Session):
self.db = db
def allocate_ticket_number(self, organization_id: UUID) -> int:
"""Allocate the next per-org display number. Locks the sequence row
(SELECT ... FOR UPDATE) so concurrent creates can't collide; must be
committed in the same transaction as the ticket row."""
if self.db.bind is not None and self.db.bind.dialect.name == "postgresql":
# FOR UPDATE can't lock a row that doesn't exist yet: two
# concurrent first tickets would both insert and one would die on
# the PK. Upsert the row race-free, then lock it.
self.db.execute(
text(
"INSERT INTO ticket_sequences (organization_id, next_number) "
"VALUES (:org, 1) ON CONFLICT (organization_id) DO NOTHING"
),
{"org": str(organization_id)},
)
seq = (
self.db.query(TicketSequence)
.filter(TicketSequence.organization_id == organization_id)
.with_for_update()
.first()
)
if seq is None: # non-postgres (sqlite tests)
seq = TicketSequence(organization_id=organization_id, next_number=1)
self.db.add(seq)
self.db.flush()
number = seq.next_number
seq.next_number = number + 1
self.db.flush()
return number
def create(self, ticket: Ticket) -> Ticket:
ticket.ticket_number = self.allocate_ticket_number(ticket.organization_id)
self.db.add(ticket)
self.db.flush()
return ticket
def get_by_id(
self,
ticket_id: UUID,
organization_id: Optional[UUID] = None,
for_update: bool = False,
) -> Optional[Ticket]:
query = self.db.query(Ticket).filter(Ticket.id == ticket_id)
if organization_id is not None:
query = query.filter(Ticket.organization_id == organization_id)
if for_update:
# Serialize concurrent mutations of the same ticket (e.g. a
# double-submitted resolve) so each request observes the prior
# one's committed status. No-op on sqlite (tests).
query = query.with_for_update()
return query.first()
def get_by_number(self, organization_id: UUID, ticket_number: int) -> Optional[Ticket]:
return (
self.db.query(Ticket)
.filter(
Ticket.organization_id == organization_id,
Ticket.ticket_number == ticket_number,
)
.first()
)
def get_by_session(self, session_id: UUID) -> Optional[Ticket]:
"""The native ticket linked to a chat session (at most one)."""
return (
self.db.query(Ticket)
.join(TicketSession, TicketSession.ticket_id == Ticket.id)
.filter(TicketSession.session_id == session_id)
.order_by(Ticket.created_at.desc())
.first()
)
def link_session(self, ticket_id: UUID, session_id: UUID) -> None:
exists = (
self.db.query(TicketSession)
.filter(
TicketSession.ticket_id == ticket_id,
TicketSession.session_id == session_id,
)
.first()
)
if not exists:
self.db.add(TicketSession(ticket_id=ticket_id, session_id=session_id))
self.db.flush()
def get_session_ids(self, ticket_id: UUID) -> List[UUID]:
rows = (
self.db.query(TicketSession.session_id)
.filter(TicketSession.ticket_id == ticket_id)
.order_by(TicketSession.linked_at)
.all()
)
return [r[0] for r in rows]
def _ai_state_condition(self, ai_state: str):
"""SQL condition matching the derived AI-state used by the list filter.
Mirrors TicketService.ai_state()."""
from sqlalchemy import exists
active_run = exists().where(
and_(
InvestigationRun.ticket_id == Ticket.id,
InvestigationRun.status.in_(ACTIVE_RUN_STATUSES),
)
)
resolved_states = [
TicketStatus.RESOLVED.value,
TicketStatus.CLOSED.value,
TicketStatus.RESOLVED_PENDING_CONFIRMATION.value,
]
is_resolved = Ticket.status.in_(resolved_states)
# Resolution wins over a stale not-yet-processed run (mirrors ai_state()).
investigating = and_(
~is_resolved,
or_(
active_run,
Ticket.status.in_([TicketStatus.TRIAGING.value, TicketStatus.INVESTIGATING.value]),
),
)
awaiting = and_(
Ticket.status == TicketStatus.AWAITING_APPROVAL.value, ~active_run
)
ai_resolved = and_(is_resolved, Ticket.resolved_by_actor == "ai")
if ai_state == "investigating":
return investigating
if ai_state == "awaiting":
return awaiting
if ai_state == "resolved":
return ai_resolved
if ai_state == "human":
return and_(~investigating, ~awaiting, ~ai_resolved)
return None
def list(
self,
organization_id: UUID,
status: Optional[List[str]] = None,
priority: Optional[str] = None,
assignee_user_id: Optional[UUID] = None,
group_id: Optional[UUID] = None,
unassigned: bool = False,
ai_state: Optional[str] = None,
search: Optional[str] = None,
sort: str = "updated",
page: int = 1,
page_size: int = 25,
) -> Tuple[List[Ticket], int]:
query = (
self.db.query(Ticket)
.options(joinedload(Ticket.assignee))
.filter(Ticket.organization_id == organization_id)
)
if status:
query = query.filter(Ticket.status.in_(status))
if ai_state:
condition = self._ai_state_condition(ai_state)
if condition is not None:
query = query.filter(condition)
if priority:
query = query.filter(Ticket.priority == priority)
if assignee_user_id is not None:
query = query.filter(Ticket.assignee_user_id == assignee_user_id)
if unassigned:
query = query.filter(Ticket.assignee_user_id.is_(None))
if group_id is not None:
query = query.filter(Ticket.group_id == group_id)
if search:
# Escape LIKE metacharacters so a literal '%'/'_' in the search
# text doesn't become a wildcard.
escaped = (
search.strip()
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
term = f"%{escaped}%"
filters = [
Ticket.title.ilike(term, escape="\\"),
Ticket.description.ilike(term, escape="\\"),
]
# "TKT-123" / "123" searches match the display number.
digits = search.strip().upper().removeprefix("TKT-")
if digits.isdigit():
filters.append(Ticket.ticket_number == int(digits))
query = query.filter(or_(*filters))
total = query.count()
if sort == "priority":
# urgent < high < medium < low by enum position.
priority_order = {"urgent": 0, "high": 1, "medium": 2, "low": 3}
whens = [(Ticket.priority == value, rank) for value, rank in priority_order.items()]
from sqlalchemy import case
query = query.order_by(case(*whens, else_=99), Ticket.updated_at.desc())
elif sort == "created":
query = query.order_by(Ticket.created_at.desc())
else:
query = query.order_by(Ticket.updated_at.desc())
tickets = query.offset((page - 1) * page_size).limit(page_size).all()
return tickets, total
def find_similar(
self,
organization_id: UUID,
embedding: List[float],
limit: int = 5,
exclude_ticket_id: Optional[UUID] = None,
only_open: bool = False,
min_similarity: float = 0.0,
) -> List[Tuple[Ticket, float]]:
"""kNN cosine search over ticket embeddings. Postgres-only (pgvector);
returns [] on other dialects (sqlite tests) or on error."""
if self.db.bind is None or self.db.bind.dialect.name != "postgresql":
return []
try:
from sqlalchemy import bindparam
vec = "[" + ",".join(f"{v:.8f}" for v in embedding) + "]"
conditions = ["organization_id = :org_id", "embedding IS NOT NULL"]
params = {"org_id": str(organization_id), "vec": vec, "limit": limit}
if exclude_ticket_id is not None:
conditions.append("id != :exclude_id")
params["exclude_id"] = str(exclude_ticket_id)
if only_open:
conditions.append("status IN :statuses")
params["statuses"] = list(OPEN_TICKET_STATUSES)
sql = text(
"SELECT id, 1 - (embedding <=> :vec) AS similarity FROM tickets "
f"WHERE {' AND '.join(conditions)} "
"ORDER BY embedding <=> :vec LIMIT :limit"
)
if only_open:
sql = sql.bindparams(bindparam("statuses", expanding=True))
rows = self.db.execute(sql, params).fetchall()
similarities = {
row[0]: float(row[1]) for row in rows if float(row[1]) >= min_similarity
}
if not similarities:
return []
# One IN query instead of a round-trip per row.
tickets = (
self.db.query(Ticket).filter(Ticket.id.in_(similarities.keys())).all()
)
results = [(t, similarities[t.id]) for t in tickets]
results.sort(key=lambda pair: pair[1], reverse=True)
return results
except Exception as e:
logger.error(f"Ticket similarity search failed: {e}")
return []
def active_run_ticket_ids(self, ticket_ids: List[UUID]) -> set:
"""Which of the given tickets have an active investigation run."""
if not ticket_ids:
return set()
rows = (
self.db.query(InvestigationRun.ticket_id)
.filter(
InvestigationRun.ticket_id.in_(ticket_ids),
InvestigationRun.status.in_(ACTIVE_RUN_STATUSES),
)
.all()
)
return {r[0] for r in rows}
def count_by_status(self, organization_id: UUID) -> dict:
rows = (
self.db.query(Ticket.status, func.count(Ticket.id))
.filter(Ticket.organization_id == organization_id)
.group_by(Ticket.status)
.all()
)
return {status: count for status, count in rows}
def open_ticket_sla_fields(self, organization_id: UUID) -> List[Tuple[str, object]]:
"""(priority, created_at) of open tickets — all the SLA-breach stat
needs; avoids hydrating full rows (embedding + text) per poll."""
return (
self.db.query(Ticket.priority, Ticket.created_at)
.filter(
Ticket.organization_id == organization_id,
Ticket.status.in_(OPEN_TICKET_STATUSES),
)
.limit(MAX_OPEN_TICKETS_FOR_STATS)
.all()
)
def resolved_counts_7d(self, organization_id: UUID) -> Tuple[int, int]:
"""(ai_resolved, total_resolved) over the trailing 7 days."""
since = datetime.now(timezone.utc) - timedelta(days=7)
base = self.db.query(func.count(Ticket.id)).filter(
Ticket.organization_id == organization_id,
Ticket.resolved_at.isnot(None),
Ticket.resolved_at >= since,
)
total = base.scalar() or 0
ai = base.filter(Ticket.resolved_by_actor == "ai").scalar() or 0
return ai, total
def csat_by_resolver(self, organization_id: UUID, days: int = 30) -> Dict[str, Tuple[int, float]]:
"""{resolver: (responses, average)} over the trailing window, keyed by
resolved_by_actor ('ai' | 'user' | '' when the ticket was never
formally resolved). Powers the AI- vs human-resolved CSAT split."""
since = datetime.now(timezone.utc) - timedelta(days=days)
rows = (
self.db.query(
Ticket.resolved_by_actor,
func.count(Ticket.id),
func.avg(Ticket.csat_score),
)
.filter(
Ticket.organization_id == organization_id,
Ticket.csat_score.isnot(None),
Ticket.csat_responded_at >= since,
)
.group_by(Ticket.resolved_by_actor)
.all()
)
return {(actor or ""): (count, float(avg)) for actor, count, avg in rows}
class TicketActivityRepository:
def __init__(self, db: Session):
self.db = db
def add(self, activity: TicketActivity) -> TicketActivity:
self.db.add(activity)
self.db.flush()
return activity
def list_for_ticket(self, ticket_id: UUID, limit: int = 200) -> List[TicketActivity]:
return (
self.db.query(TicketActivity)
.options(joinedload(TicketActivity.actor_user))
.filter(TicketActivity.ticket_id == ticket_id)
.order_by(TicketActivity.created_at)
.limit(limit)
.all()
)
class TicketSettingsRepository:
def __init__(self, db: Session):
self.db = db
def get(self, organization_id: UUID) -> Optional[OrganizationTicketSettings]:
return (
self.db.query(OrganizationTicketSettings)
.filter(OrganizationTicketSettings.organization_id == organization_id)
.first()
)
def get_or_create(self, organization_id: UUID) -> OrganizationTicketSettings:
settings = self.get(organization_id)
if settings is None:
settings = OrganizationTicketSettings(organization_id=organization_id)
self.db.add(settings)
self.db.commit()
self.db.refresh(settings)
return settings
class InvestigationRepository:
def __init__(self, db: Session):
self.db = db
def get_by_id(self, run_id: UUID) -> Optional[InvestigationRun]:
return self.db.query(InvestigationRun).filter(InvestigationRun.id == run_id).first()
def get_active_for_ticket(self, ticket_id: UUID) -> Optional[InvestigationRun]:
return (
self.db.query(InvestigationRun)
.filter(
InvestigationRun.ticket_id == ticket_id,
InvestigationRun.status.in_(ACTIVE_RUN_STATUSES),
)
.first()
)
def count_for_ticket(self, ticket_id: UUID, run_type: Optional[str] = None) -> int:
query = self.db.query(func.count(InvestigationRun.id)).filter(
InvestigationRun.ticket_id == ticket_id
)
if run_type:
query = query.filter(InvestigationRun.run_type == run_type)
return query.scalar() or 0
def list_for_ticket(self, ticket_id: UUID) -> List[InvestigationRun]:
return (
self.db.query(InvestigationRun)
.filter(InvestigationRun.ticket_id == ticket_id)
.order_by(InvestigationRun.created_at.desc())
.all()
)
def enqueue(self, run: InvestigationRun) -> Optional[InvestigationRun]:
"""Insert a pending run unless the ticket already has an active one
(double-guarded by uq_investigation_runs_one_active in Postgres)."""
# Sessions run with autoflush=False: the caller may have just moved
# the previous run to a terminal state in this same session, and
# without a flush the guard query would still see the stale row and
# silently refuse the enqueue (bit the triage -> investigation chain).
self.db.flush()
if self.get_active_for_ticket(run.ticket_id) is not None:
return None
self.db.add(run)
self.db.flush()
return run
def get_pending_ids(self, limit: int = 10) -> List[UUID]:
rows = (
self.db.query(InvestigationRun.id)
.filter(InvestigationRun.status == InvestigationRunStatus.PENDING)
.order_by(InvestigationRun.created_at)
.limit(limit)
.all()
)
return [r[0] for r in rows]
def count_llm_calls_for_period(
self, organization_id: UUID, start_date: datetime, end_date: datetime
) -> int:
"""Metered investigation LLM calls in a billing period — the hosted-
model usage the enterprise message-limit check adds to the bot-message
count. Mirrors FAQGenerationJobRepository.count_llm_calls_for_period."""
total = (
self.db.query(func.coalesce(func.sum(InvestigationRun.llm_calls), 0))
.filter(
InvestigationRun.organization_id == organization_id,
InvestigationRun.metered.is_(True),
InvestigationRun.created_at >= start_date,
InvestigationRun.created_at <= end_date,
)
.scalar()
)
return int(total or 0)
def latest_run_of_type(self, ticket_id: UUID, run_type: str) -> Optional[InvestigationRun]:
return (
self.db.query(InvestigationRun)
.filter(
InvestigationRun.ticket_id == ticket_id,
InvestigationRun.run_type == run_type,
)
.order_by(InvestigationRun.created_at.desc())
.first()
)
def list_hypotheses(self, run_id: UUID) -> List[InvestigationHypothesis]:
return (
self.db.query(InvestigationHypothesis)
.filter(InvestigationHypothesis.run_id == run_id)
.order_by(InvestigationHypothesis.idx)
.all()
)
def list_events(self, run_id: UUID, limit: int = 500) -> List[InvestigationEvent]:
return (
self.db.query(InvestigationEvent)
.filter(InvestigationEvent.run_id == run_id)
.order_by(InvestigationEvent.seq)
.limit(limit)
.all()
)
def latest_rca(self, ticket_id: UUID) -> Optional[RCADocument]:
return (
self.db.query(RCADocument)
.filter(RCADocument.ticket_id == ticket_id)
.order_by(RCADocument.version.desc())
.first()
)
def next_rca_version(self, ticket_id: UUID) -> int:
current = (
self.db.query(func.max(RCADocument.version))
.filter(RCADocument.ticket_id == ticket_id)
.scalar()
)
return (current or 0) + 1
def reap_orphaned_runs(self) -> int:
"""Fail 'running' rows left behind by a dead worker (startup pass), and
un-stick their tickets. Without the ticket reset a crash mid-run leaves
the ticket in triaging/investigating with no active run — its ai_state
would read 'investigating' forever (nothing else recovers it)."""
orphaned = (
self.db.query(InvestigationRun)
.filter(InvestigationRun.status == InvestigationRunStatus.RUNNING)
.all()
)
transient = (TicketStatus.TRIAGING.value, TicketStatus.INVESTIGATING.value)
for run in orphaned:
run.status = InvestigationRunStatus.FAILED
run.error = "Worker restarted while run was in progress"
run.finished_at = datetime.now(timezone.utc)
# NB: self is InvestigationRepository — query the Ticket directly,
# not self.get_by_id (that resolves run ids).
ticket = self.db.query(Ticket).filter(Ticket.id == run.ticket_id).first()
if ticket is not None and str(ticket.status) in transient:
ticket.status = TicketStatus.OPEN.value
if orphaned:
self.db.commit()
return len(orphaned)