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" ))