feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
2aee6286d6
commit
5e0615f36d
126 files changed
+17436
No files matched your search
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
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
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
# Reuse the ORM enums so the API contract can never drift from the DB.
|
||||
from app.models.investigation import InvestigationRunStatus, InvestigationRunType
|
||||
from app.models.ticket import ResolutionOutcome, TicketPriority, TicketSource, TicketStatus
|
||||
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||||
from app.models.schemas.pagination import Pagination
|
||||
|
||||
MAX_TITLE_LENGTH = 500
|
||||
MAX_DESCRIPTION_LENGTH = 20000
|
||||
MAX_COMMENT_LENGTH = 10000
|
||||
MAX_TAGS = 5
|
||||
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=MAX_TITLE_LENGTH)
|
||||
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
priority: TicketPriority = TicketPriority.MEDIUM
|
||||
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
|
||||
customer_id: Optional[UUID] = None
|
||||
# Manual tickets: resolve/create the customer by email so direct-email
|
||||
# notifications have somewhere to go. Ignored when customer_id is set.
|
||||
customer_email: Optional[EmailStr] = None
|
||||
customer_name: Optional[str] = Field(default=None, max_length=200)
|
||||
session_id: Optional[UUID] = None
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID] = None
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def _strip_title(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be blank")
|
||||
return v
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
"""Partial update — omitted fields keep their current values (apply with
|
||||
model_dump(exclude_unset=True))."""
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=MAX_TITLE_LENGTH)
|
||||
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
# Set/replace the ticket's customer by email (find-or-create in the org).
|
||||
customer_email: Optional[EmailStr] = None
|
||||
customer_name: Optional[str] = Field(default=None, max_length=200)
|
||||
status: Optional[TicketStatus] = None
|
||||
priority: Optional[TicketPriority] = None
|
||||
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID] = None
|
||||
resolution_outcome: Optional[ResolutionOutcome] = None
|
||||
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
customer_resolution_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
|
||||
|
||||
class TicketCommentCreate(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
|
||||
is_internal: bool = True
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be blank")
|
||||
return v
|
||||
|
||||
|
||||
class TicketReopen(BaseModel):
|
||||
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
|
||||
|
||||
|
||||
class TicketResolve(BaseModel):
|
||||
outcome: ResolutionOutcome = ResolutionOutcome.FIXED
|
||||
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
customer_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
|
||||
|
||||
class TicketUserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class TicketCustomerOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class TicketActivityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
activity_type: TicketActivityType
|
||||
actor_type: TicketActorType
|
||||
actor_user_id: Optional[UUID] = None
|
||||
actor_name: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
is_internal: bool = True
|
||||
activity_metadata: Optional[dict] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class InvestigationRunOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
run_type: InvestigationRunType
|
||||
status: InvestigationRunStatus
|
||||
trigger: str
|
||||
error: Optional[str] = None
|
||||
tool_calls_used: int = 0
|
||||
max_tool_calls: int = 25
|
||||
llm_calls: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
metered: bool = False
|
||||
model_name: Optional[str] = None
|
||||
# {"configured": N, "loaded": M, "failed": [{"name", "error"}]}
|
||||
connector_status: Optional[dict] = None
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class HypothesisOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
idx: int
|
||||
title: str
|
||||
rationale: Optional[str] = None
|
||||
status: str
|
||||
confidence: Optional[float] = None
|
||||
conclusion: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class InvestigationEventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
hypothesis_id: Optional[UUID] = None
|
||||
seq: int
|
||||
event_type: str
|
||||
label: Optional[str] = None
|
||||
tool_name: Optional[str] = None
|
||||
connector_name: Optional[str] = None
|
||||
tool_input: Optional[str] = None
|
||||
tool_result: Optional[str] = None
|
||||
duration_ms: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class RCADocumentOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
run_id: Optional[UUID] = None
|
||||
version: int
|
||||
summary: Optional[str] = None
|
||||
impact: Optional[str] = None
|
||||
timeline: Optional[list] = None
|
||||
investigation_log: Optional[str] = None
|
||||
contributing_factors: Optional[list] = None
|
||||
conclusion: Optional[str] = None
|
||||
remediation: Optional[str] = None
|
||||
prevention: Optional[str] = None
|
||||
customer_summary: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
is_partial: bool = False
|
||||
generated_by: str = "ai"
|
||||
reviewed_by_user_id: Optional[UUID] = None
|
||||
reviewed_by_name: Optional[str] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ProposalOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
run_id: Optional[UUID] = None
|
||||
summary: str
|
||||
customer_message: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
status: str
|
||||
decided_by_user_id: Optional[UUID] = None
|
||||
decided_by_name: Optional[str] = None
|
||||
decided_at: Optional[datetime] = None
|
||||
reject_reason: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ProposalRejectRequest(BaseModel):
|
||||
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
|
||||
# Re-enqueue a refined investigation with the reason as context.
|
||||
reinvestigate: bool = False
|
||||
|
||||
|
||||
class InvestigationDetailResponse(BaseModel):
|
||||
"""Glass-box payload for the ticket detail page: the (latest or requested)
|
||||
investigation run with its hypotheses, evidence events and RCA document."""
|
||||
run: Optional[InvestigationRunOut] = None
|
||||
hypotheses: List[HypothesisOut] = []
|
||||
events: List[InvestigationEventOut] = []
|
||||
rca: Optional[RCADocumentOut] = None
|
||||
# Latest proposal (any status) — drives the approval banner.
|
||||
proposal: Optional[ProposalOut] = None
|
||||
|
||||
|
||||
class RcaUpdate(BaseModel):
|
||||
customer_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
# Stamp the caller as reviewer ("generated by AI · reviewed by …").
|
||||
mark_reviewed: bool = False
|
||||
|
||||
|
||||
class InvestigateRequest(BaseModel):
|
||||
run_type: InvestigationRunType = InvestigationRunType.INVESTIGATION
|
||||
# Free-text context for the run, e.g. what to focus on this time.
|
||||
context_note: Optional[str] = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class TicketOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ticket_number: int
|
||||
display_number: str
|
||||
organization_id: UUID
|
||||
customer_id: Optional[UUID] = None
|
||||
title: str
|
||||
original_title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: TicketStatus
|
||||
priority: TicketPriority
|
||||
severity: Optional[int] = None
|
||||
source: TicketSource
|
||||
intent: Optional[str] = None
|
||||
triage_confidence: Optional[float] = None
|
||||
ai_summary: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID] = None
|
||||
agent_id: Optional[UUID] = None
|
||||
duplicate_of_ticket_id: Optional[UUID] = None
|
||||
resolution_outcome: Optional[ResolutionOutcome] = None
|
||||
resolution_summary: Optional[str] = None
|
||||
customer_resolution_message: Optional[str] = None
|
||||
first_response_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
closed_at: Optional[datetime] = None
|
||||
confirmation_requested_at: Optional[datetime] = None
|
||||
reopened_count: int = 0
|
||||
csat_requested_at: Optional[datetime] = None
|
||||
csat_score: Optional[int] = None
|
||||
csat_responded_at: Optional[datetime] = None
|
||||
external_ref_type: Optional[str] = None
|
||||
external_ref_id: Optional[str] = None
|
||||
external_ref_url: Optional[str] = None
|
||||
created_by_user_id: Optional[UUID] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
# Derived UI fields.
|
||||
ai_state: Optional[str] = None
|
||||
sla_due_at: Optional[datetime] = None
|
||||
assignee: Optional[TicketUserOut] = None
|
||||
customer: Optional[TicketCustomerOut] = None
|
||||
|
||||
|
||||
class TicketListItem(BaseModel):
|
||||
"""Slim row for the list view."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ticket_number: int
|
||||
display_number: str
|
||||
title: str
|
||||
status: TicketStatus
|
||||
priority: TicketPriority
|
||||
tags: Optional[List[str]] = None
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
assignee_name: Optional[str] = None
|
||||
ai_state: Optional[str] = None
|
||||
sla_due_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
tickets: List[TicketListItem]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class TicketDetailResponse(BaseModel):
|
||||
ticket: TicketOut
|
||||
activities: List[TicketActivityOut]
|
||||
runs: List[InvestigationRunOut]
|
||||
linked_session_ids: List[UUID]
|
||||
possible_duplicates: List[TicketListItem] = []
|
||||
# Whether any outbound path to the customer exists (linked conversation or
|
||||
# customer email) — gates the "send to customer" affordances in the UI.
|
||||
can_notify_customer: bool = False
|
||||
|
||||
|
||||
class TicketStats(BaseModel):
|
||||
open: int
|
||||
awaiting_approval: int
|
||||
sla_breaching: int
|
||||
ai_resolved_pct_7d: Optional[float] = None
|
||||
# CSAT over the trailing csat_window_days, overall and split by resolver.
|
||||
csat_avg: Optional[float] = None
|
||||
csat_ai_avg: Optional[float] = None
|
||||
csat_human_avg: Optional[float] = None
|
||||
csat_responses: int = 0
|
||||
csat_window_days: int = 30
|
||||
|
||||
|
||||
class SlaTarget(BaseModel):
|
||||
first_response_minutes: int = Field(gt=0, le=60 * 24 * 30)
|
||||
resolution_minutes: int = Field(gt=0, le=60 * 24 * 90)
|
||||
|
||||
|
||||
class TicketSettingsOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
autonomy_level: int
|
||||
auto_investigate_on_create: bool
|
||||
min_confidence_to_auto_resolve: float
|
||||
confirmation_timeout_hours: int
|
||||
csat_enabled: bool
|
||||
sla_targets: Optional[dict] = None
|
||||
created_template: Optional[str] = None
|
||||
resolved_template: Optional[str] = None
|
||||
jira_escalation_enabled: bool
|
||||
jira_escalation_priority: Optional[str] = None
|
||||
investigation_mcp_tool_ids: Optional[List[int]] = None
|
||||
alert_webhook_enabled: bool
|
||||
# Only exposed to settings managers; forms the intake URL path.
|
||||
alert_webhook_secret: Optional[str] = None
|
||||
max_tool_calls_per_run: int
|
||||
max_runs_per_ticket: int
|
||||
|
||||
|
||||
class TicketSettingsUpdate(BaseModel):
|
||||
autonomy_level: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
auto_investigate_on_create: Optional[bool] = None
|
||||
min_confidence_to_auto_resolve: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||
confirmation_timeout_hours: Optional[int] = Field(default=None, ge=1, le=24 * 30)
|
||||
csat_enabled: Optional[bool] = None
|
||||
sla_targets: Optional[dict] = None
|
||||
created_template: Optional[str] = Field(default=None, max_length=2000)
|
||||
resolved_template: Optional[str] = Field(default=None, max_length=2000)
|
||||
jira_escalation_enabled: Optional[bool] = None
|
||||
jira_escalation_priority: Optional[TicketPriority] = None
|
||||
investigation_mcp_tool_ids: Optional[List[int]] = None
|
||||
alert_webhook_enabled: Optional[bool] = None
|
||||
max_tool_calls_per_run: Optional[int] = Field(default=None, ge=1, le=100)
|
||||
max_runs_per_ticket: Optional[int] = Field(default=None, ge=1, le=10)
|
||||
|
||||
@field_validator("sla_targets")
|
||||
@classmethod
|
||||
def _validate_sla(cls, v: Optional[dict]) -> Optional[dict]:
|
||||
if v is None:
|
||||
return v
|
||||
for priority, target in v.items():
|
||||
if priority not in [p.value for p in TicketPriority]:
|
||||
raise ValueError(f"unknown priority '{priority}'")
|
||||
SlaTarget.model_validate(target)
|
||||
return v
|
||||
Reference in new issue
Block a user