feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
2aee6286d6
commit
5e0615f36d
126 files changed
+17436
No files matched your search
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Display prefix for ticket numbers (TKT-1024). Also mirrored into
|
||||
# session_to_agents.ticket_id so existing session/widget consumers keep working.
|
||||
TICKET_NUMBER_PREFIX = "TKT"
|
||||
|
||||
# FastEmbed model used for dedup/similarity embeddings. The dimension is baked
|
||||
# into the tickets.embedding column — changing models requires a re-embed
|
||||
# migration.
|
||||
TICKET_EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
TICKET_EMBEDDING_DIM = 384
|
||||
|
||||
|
||||
class _ValueStrEnum(str, enum.Enum):
|
||||
"""str-enum whose str() is the value — so in-memory enum instances and
|
||||
refreshed-from-DB plain strings render identically (session mirroring,
|
||||
ai_state comparisons, API payloads)."""
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - trivial
|
||||
return self.value
|
||||
|
||||
|
||||
class TicketStatus(_ValueStrEnum):
|
||||
OPEN = "open"
|
||||
TRIAGING = "triaging" # AI triage running
|
||||
INVESTIGATING = "investigating" # investigation run active
|
||||
AWAITING_APPROVAL = "awaiting_approval" # L2: resolution proposed
|
||||
IN_PROGRESS = "in_progress" # human working it
|
||||
RESOLVED_PENDING_CONFIRMATION = "resolved_pending_confirmation"
|
||||
RESOLVED = "resolved"
|
||||
CLOSED = "closed"
|
||||
REOPENED = "reopened"
|
||||
|
||||
|
||||
class TicketPriority(_ValueStrEnum):
|
||||
URGENT = "urgent"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
|
||||
|
||||
class TicketSource(_ValueStrEnum):
|
||||
CHAT_AI = "chat_ai"
|
||||
HUMAN_AGENT = "human_agent"
|
||||
MANUAL = "manual"
|
||||
API = "api"
|
||||
|
||||
|
||||
class ResolutionOutcome(_ValueStrEnum):
|
||||
FIXED = "fixed"
|
||||
WORKAROUND = "workaround"
|
||||
NOT_A_BUG = "not_a_bug"
|
||||
DUPLICATE = "duplicate"
|
||||
CANNOT_REPRODUCE = "cannot_reproduce"
|
||||
CUSTOMER_UNRESPONSIVE = "customer_unresponsive"
|
||||
ESCALATED_EXTERNAL = "escalated_external"
|
||||
|
||||
|
||||
# Statuses that count as "still open" — derived so a future status defaults to
|
||||
# open rather than silently escaping open-ticket queries and dedup checks.
|
||||
TERMINAL_TICKET_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
|
||||
OPEN_TICKET_STATUSES = [s.value for s in TicketStatus if s not in TERMINAL_TICKET_STATUSES]
|
||||
|
||||
# Legal status transitions, enforced centrally in TicketService.transition_status.
|
||||
TICKET_STATUS_TRANSITIONS = {
|
||||
TicketStatus.OPEN: {
|
||||
TicketStatus.TRIAGING, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL,
|
||||
TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION,
|
||||
TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||
},
|
||||
TicketStatus.TRIAGING: {
|
||||
TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.IN_PROGRESS,
|
||||
TicketStatus.AWAITING_APPROVAL, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||
},
|
||||
TicketStatus.INVESTIGATING: {
|
||||
TicketStatus.OPEN, TicketStatus.AWAITING_APPROVAL, TicketStatus.IN_PROGRESS,
|
||||
TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||
},
|
||||
TicketStatus.AWAITING_APPROVAL: {
|
||||
TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.IN_PROGRESS,
|
||||
TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||
},
|
||||
TicketStatus.IN_PROGRESS: {
|
||||
TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL,
|
||||
TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||
},
|
||||
TicketStatus.RESOLVED_PENDING_CONFIRMATION: {
|
||||
TicketStatus.RESOLVED, TicketStatus.CLOSED, TicketStatus.REOPENED,
|
||||
},
|
||||
TicketStatus.RESOLVED: {TicketStatus.CLOSED, TicketStatus.REOPENED},
|
||||
TicketStatus.CLOSED: {TicketStatus.REOPENED},
|
||||
TicketStatus.REOPENED: {
|
||||
TicketStatus.TRIAGING, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL,
|
||||
TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION,
|
||||
TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Ticket(Base):
|
||||
"""A native support ticket — the aggregate root of the AI-first ticketing
|
||||
feature. Decoupled from chat sessions: a ticket may aggregate 0..n linked
|
||||
sessions via TicketSession; a session spawns at most one native ticket.
|
||||
|
||||
Status/priority/source/outcome columns are plain strings (values from the
|
||||
str enums above, mirroring faq_generation_jobs) so new values never need an
|
||||
ALTER TYPE migration.
|
||||
"""
|
||||
__tablename__ = "tickets"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
# Per-org monotonically increasing display number (TKT-{n}), allocated from
|
||||
# ticket_sequences with SELECT ... FOR UPDATE in the same transaction that
|
||||
# commits the ticket.
|
||||
ticket_number = Column(Integer, nullable=False)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Nullable: manual/internal tickets may have no customer.
|
||||
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
title = Column(String(500), nullable=False)
|
||||
# Preserved when AI triage rewrites the title.
|
||||
original_title = Column(String(500), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
status = Column(String, nullable=False, default=TicketStatus.OPEN, index=True)
|
||||
priority = Column(String, nullable=False, default=TicketPriority.MEDIUM)
|
||||
# SEV-1..3 (1 = worst). Nullable — set by triage or humans, not required.
|
||||
severity = Column(Integer, nullable=True)
|
||||
source = Column(String, nullable=False, default=TicketSource.MANUAL)
|
||||
|
||||
# AI triage output.
|
||||
intent = Column(String, nullable=True)
|
||||
triage_confidence = Column(Float, nullable=True)
|
||||
ai_summary = Column(Text, nullable=True)
|
||||
# ["payments", "ledger"] — rendered as the "payments · ledger" sub-line.
|
||||
tags = Column(JSON, nullable=True)
|
||||
|
||||
# Assignment. All nullable: an unassigned open ticket with an active
|
||||
# investigation run is "AI-owned" in the UI.
|
||||
assignee_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True)
|
||||
# Which chat agent created it (source=chat_ai).
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Dedup: cosine similarity over FastEmbed embeddings (pgvector).
|
||||
duplicate_of_ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
|
||||
embedding = Column(Vector(TICKET_EMBEDDING_DIM), nullable=True)
|
||||
|
||||
# Resolution. resolution_summary is internal; customer_resolution_message
|
||||
# is the separate plain-language text sent to the customer.
|
||||
resolution_outcome = Column(String, nullable=True)
|
||||
resolution_summary = Column(Text, nullable=True)
|
||||
customer_resolution_message = Column(Text, nullable=True)
|
||||
# Who resolved it ('ai' | 'user') — powers the AI-resolved stat and the
|
||||
# AI-state list column without scanning activities.
|
||||
resolved_by_actor = Column(String, nullable=True)
|
||||
|
||||
# SLA timestamps.
|
||||
first_response_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
confirmation_requested_at = Column(DateTime(timezone=True), nullable=True)
|
||||
reopened_count = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
|
||||
# CSAT. The ask goes out on close (csat_requested_at); the score arrives
|
||||
# later, when the customer rates the linked conversation — see
|
||||
# app/services/ticket_csat.py for the capture path and why it is
|
||||
# conversation-only.
|
||||
csat_requested_at = Column(DateTime(timezone=True), nullable=True)
|
||||
csat_score = Column(Integer, nullable=True)
|
||||
csat_rating_id = Column(UUID(as_uuid=True), ForeignKey("ratings.id", ondelete="SET NULL"), nullable=True)
|
||||
csat_responded_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Optional one-way escalation reference (e.g. 'JIRA' + issue key + URL).
|
||||
external_ref_type = Column(String, nullable=True)
|
||||
external_ref_id = Column(String, nullable=True)
|
||||
external_ref_url = Column(String, nullable=True)
|
||||
|
||||
created_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
organization = relationship("Organization")
|
||||
customer = relationship("Customer")
|
||||
assignee = relationship("User", foreign_keys=[assignee_user_id])
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
group = relationship("UserGroup")
|
||||
agent = relationship("Agent")
|
||||
duplicate_of = relationship("Ticket", remote_side=[id])
|
||||
sessions = relationship("TicketSession", back_populates="ticket", cascade="all, delete-orphan")
|
||||
activities = relationship(
|
||||
"TicketActivity",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TicketActivity.created_at",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("organization_id", "ticket_number", name="uq_tickets_org_number"),
|
||||
Index("ix_tickets_org_status", "organization_id", "status"),
|
||||
Index("ix_tickets_org_assignee", "organization_id", "assignee_user_id"),
|
||||
Index("ix_tickets_org_created", "organization_id", "created_at"),
|
||||
# Drives the CSAT stat (AI- vs human-resolved averages over a window).
|
||||
Index("ix_tickets_org_csat", "organization_id", "csat_responded_at"),
|
||||
# NOTE: production also has an HNSW cosine index on embedding — it
|
||||
# lives only in the migration (sqlite tests can't compile it).
|
||||
)
|
||||
|
||||
@property
|
||||
def display_number(self) -> str:
|
||||
return f"{TICKET_NUMBER_PREFIX}-{self.ticket_number}"
|
||||
|
||||
|
||||
class TicketSequence(Base):
|
||||
"""Per-org allocator for ticket display numbers. Read with
|
||||
SELECT ... FOR UPDATE and committed together with the ticket row.
|
||||
"""
|
||||
__tablename__ = "ticket_sequences"
|
||||
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
next_number = Column(Integer, nullable=False, default=1)
|
||||
|
||||
|
||||
class TicketSession(Base):
|
||||
"""Link table between tickets and chat sessions. A link table (rather than
|
||||
an FK on session_to_agents) so merged/duplicate tickets can aggregate
|
||||
sessions. The legacy session_to_agents.ticket_* columns are kept as a
|
||||
denormalized mirror for existing consumers.
|
||||
"""
|
||||
__tablename__ = "ticket_sessions"
|
||||
|
||||
ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="CASCADE"), primary_key=True)
|
||||
session_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("session_to_agents.session_id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
)
|
||||
linked_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
ticket = relationship("Ticket", back_populates="sessions")
|
||||
session = relationship("SessionToAgent")
|
||||
Reference in new issue
Block a user