118 lines
5.2 KiB
Python
118 lines
5.2 KiB
Python
"""
|
|||
|
|
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 sqlalchemy import (
|
||
|
|
Column, Integer, String, Boolean, DateTime, ForeignKey,
|
||
|
|
Enum as SQLEnum, JSON, Text, func, false, true,
|
||
|
|
)
|
||
|
|
from sqlalchemy.orm import relationship
|
||
|
|
from sqlalchemy.dialects.postgresql import UUID
|
||
|
|
import enum
|
||
|
|
import uuid
|
||
|
|
from app.database import Base
|
||
|
|
|
||
|
|
|
||
|
|
class LeadAssignmentMode(str, enum.Enum):
|
||
|
|
"""Where a qualified lead is routed. Phase 1: stored only, not acted on."""
|
||
|
|
NONE = "none"
|
||
|
|
SALES_TEAM = "sales_team"
|
||
|
|
SPECIFIC_PERSON = "specific_person"
|
||
|
|
ROUND_ROBIN = "round_robin"
|
||
|
|
|
||
|
|
|
||
|
|
class CrmSyncTarget(str, enum.Enum):
|
||
|
|
"""Which CRM a qualified lead syncs to.
|
||
|
|
|
||
|
|
Stored as a plain string column (not a DB enum) so adding a CRM never
|
||
|
|
requires a schema migration — mirrors ChannelType. str() is the value so
|
||
|
|
in-memory enum instances and refreshed-from-DB strings render identically.
|
||
|
|
"""
|
||
|
|
|
||
|
|
NONE = "none"
|
||
|
|
HUBSPOT = "hubspot"
|
||
|
|
PIPEDRIVE = "pipedrive"
|
||
|
|
SALESFORCE = "salesforce"
|
||
|
|
|
||
|
|
def __str__(self) -> str: # pragma: no cover - trivial
|
||
|
|
return self.value
|
||
|
|
|
||
|
|
|
||
|
|
class LeadCaptureConfig(Base):
|
||
|
|
"""Per-agent lead-capture configuration (1:1 with Agent).
|
||
|
|
|
||
|
|
Prompt-driven, like transfer_to_human: a simple on/off toggle. When enabled, the
|
||
|
|
agent collects the configured fields conversationally (no form) and emits them as
|
||
|
|
structured output plus an AI-written lead summary.
|
||
|
|
"""
|
||
|
|
__tablename__ = "lead_capture_configs"
|
||
|
|
|
||
|
|
id = Column(Integer, primary_key=True, index=True)
|
||
|
|
agent_id = Column(
|
||
|
|
UUID(as_uuid=True),
|
||
|
|
ForeignKey("agents.id", ondelete="CASCADE"),
|
||
|
|
unique=True,
|
||
|
|
nullable=False,
|
||
|
|
)
|
||
|
|
# Master toggle (mirrors Agent.transfer_to_human).
|
||
|
|
enabled = Column(Boolean, default=False, server_default=false(), nullable=False)
|
||
|
|
# GDPR: require the visitor's explicit "yes" before a lead is recorded (default on).
|
||
|
|
require_consent = Column(Boolean, default=True, server_default=true(), nullable=False)
|
||
|
|
|
||
|
|
# Free-text steering appended to the agent's system prompt (e.g. "prioritise
|
||
|
|
# pricing-page visitors, be proactive after hours"). The agent decides *when* to ask.
|
||
|
|
guidance = Column(Text, nullable=True)
|
||
|
|
# Which details to collect — feeds the prompt and defines the expected lead_data keys:
|
||
|
|
# {"key": "email", "standard": true, "required": true, "enabled": true}
|
||
|
|
# {"key": "custom_1", "standard": false, "label": "Team size", "enabled": true}
|
||
|
|
fields = Column(JSON, nullable=True)
|
||
|
|
|
||
|
|
# Routing config — persisted so the shape is ready for real integration later,
|
||
|
|
# but never acted on in phase 1 (no CRM/Slack/assignment side effects).
|
||
|
|
assignment_mode = Column(SQLEnum(LeadAssignmentMode), default=LeadAssignmentMode.NONE, server_default="NONE", nullable=False)
|
||
|
|
assignment_target_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||
|
|
crm_sync_target = Column(String(32), default=CrmSyncTarget.NONE.value, server_default="none", nullable=False)
|
||
|
|
slack_notify_enabled = Column(Boolean, default=False, server_default=false(), nullable=False)
|
||
|
|
|
||
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||
|
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||
|
|
|
||
|
|
# Relationships
|
||
|
|
agent = relationship("Agent", back_populates="lead_capture_config", uselist=False)
|
||
|
|
|
||
|
|
|
||
|
|
class LeadCaptureResponse(Base):
|
||
|
|
"""One row per captured lead — the fields the agent extracted conversationally,
|
||
|
|
plus an AI-written qualification summary. Kept separate from Customer.meta_data
|
||
|
|
(integrator-supplied) so AI-captured leads have a clean home."""
|
||
|
|
__tablename__ = "lead_capture_responses"
|
||
|
|
|
||
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
|
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||
|
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||
|
|
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
|
||
|
|
session_id = Column(UUID(as_uuid=True), ForeignKey("session_to_agents.session_id"), nullable=True)
|
||
|
|
|
||
|
|
field_values = Column(JSON, nullable=True) # {"email": ..., "company": ..., "custom_1": ...}
|
||
|
|
# AI-written qualification narrative (e.g. "40-person fintech replacing Intercom, wants a demo").
|
||
|
|
summary = Column(Text, nullable=True)
|
||
|
|
# Whether the visitor gave explicit consent to be contacted (GDPR).
|
||
|
|
consent = Column(Boolean, default=False, nullable=False)
|
||
|
|
qualified = Column(Boolean, default=False, nullable=False)
|
||
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||
|
|
|
||
|
|
# Relationships
|
||
|
|
customer = relationship("Customer", back_populates="lead_capture_responses")
|
||
|
|
agent = relationship("Agent")
|