557 lines
21 KiB
Python
557 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,
|
|
InvestigationRunType,
|
|
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)
|