Files
ai-kefu-admin/app/engine/models.py
T

186 lines
6.8 KiB
Python

import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, JSON, Enum as SAEnum, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class ConversationStatus(str, enum.Enum):
active = "active"
completed = "completed"
abandoned = "abandoned"
class MessageRole(str, enum.Enum):
user = "user"
assistant = "assistant"
system = "system"
class ConversationSource(str, enum.Enum):
standalone = "standalone"
embedded = "embedded"
widget = "widget"
app = "app"
class IntentLevel(str, enum.Enum):
interested = "interested"
not_interested = "not_interested"
pending = "pending"
class Conversation(Base):
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
page_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
status: Mapped[ConversationStatus] = mapped_column(
SAEnum(ConversationStatus), default=ConversationStatus.active
)
source: Mapped[ConversationSource] = mapped_column(
SAEnum(ConversationSource), default=ConversationSource.standalone
)
client_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation", order_by="Message.created_at"
)
profile: Mapped["CustomerProfile | None"] = relationship(
back_populates="conversation", uselist=False
)
evaluation: Mapped["Evaluation | None"] = relationship(
back_populates="conversation", uselist=False
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE")
)
role: Mapped[MessageRole] = mapped_column(SAEnum(MessageRole))
content: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
class CustomerProfile(Base):
__tablename__ = "customer_profiles"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), unique=True
)
raw_info: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
conversation: Mapped["Conversation"] = relationship(back_populates="profile")
class Evaluation(Base):
__tablename__ = "evaluations"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), unique=True
)
intent_level: Mapped[IntentLevel] = mapped_column(SAEnum(IntentLevel))
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
contact_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
correction_count: Mapped[int] = mapped_column(Integer, default=0)
evaluated_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
conversation: Mapped["Conversation"] = relationship(back_populates="evaluation")
class LeadPushLog(Base):
"""询盘推送 UniService 日志(设计 2026-09-28-lead-push-to-uniservice)。
status: pending → success/failed;skipped = contact 三选一(phone/whatsapp/
email)不满足,对方必拒,预检后不发 HTTP 只留审计痕迹。
outcome: 200 时对方逐条结论 accepted/duplicated(duplicated 不是错误)。
"""
__tablename__ = "lead_push_logs"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
evaluation_id: Mapped[str] = mapped_column(
ForeignKey("evaluations.id", ondelete="CASCADE"), index=True
)
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
)
push_type: Mapped[str] = mapped_column(String(16)) # lead/returning/correction
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
response_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
outcome: Mapped[str | None] = mapped_column(String(16), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
async def ensure_schema(engine) -> None:
"""create_all + 旧库加列迁移(自 app/main.py lifespan/_migrate 提取,逻辑逐字等价)。
jc 上线库(conversations 无 page_token / evaluations 无 correction_count)
经此升级;新库 create_all 已含全部列,加列分支空跑。TenantRegistry 懒建
每租户库时复用(Phase 1);生产装配见 app/main.py lifespan。
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
result = await conn.execute(text("PRAGMA table_info(conversations)"))
columns = {row[1] for row in result}
if "page_token" not in columns:
await conn.execute(text(
"ALTER TABLE conversations ADD COLUMN page_token VARCHAR(64)"
))
result = await conn.execute(text("PRAGMA table_info(evaluations)"))
columns = {row[1] for row in result}
if "correction_count" not in columns:
await conn.execute(text(
"ALTER TABLE evaluations ADD COLUMN correction_count INTEGER DEFAULT 0"
))