mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)
Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for user-onboarding lead-gen surfaces
14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): register PostHog events for lead-gen surfaces
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared field options, work-email validation, and submit seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): inline math captcha field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): enterprise intake modal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): hire-an-expert modal with enterprise link
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): top-up modal with >20k volume-pricing gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared lead-forms context provider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): mount LeadFormsProvider in app layout
Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): add Credits & Billing page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(ui): add lint:lead-flow guard script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur
Revised lead-capture surfaces and credits bar:
- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
sticky footer, trust-line slot), PhoneField (react-international-phone,
dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
monthly volume. EnterpriseModal: + work email (required logged-out),
conditional deployment (yes/no/maybe, source-gated), agent goal.
OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): split-screen auth + enterprise CTA + dark theme default
- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
waveform motif + proof points + Bland-style enterprise CTA block on the
left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
(defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ui rezig, onboarding, billing, hire us & on prem cues
* ui changes
* chore: update comment
* chore: untrack docs/superpowers and gitignore it
* feat: refactor user configuration table
* feat(ui): 'check your email' confirmation on lead forms
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* added email and country in form submissions
* chore: update leads api
* fix: wrap dograh model config in card
---------
Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1356 lines
47 KiB
Python
1356 lines
47 KiB
Python
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
from pgvector.sqlalchemy import Vector
|
|
from sqlalchemy import (
|
|
JSON,
|
|
Boolean,
|
|
Column,
|
|
DateTime,
|
|
Enum,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Table,
|
|
Text,
|
|
UniqueConstraint,
|
|
and_,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import declarative_base, relationship
|
|
|
|
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG
|
|
|
|
from ..enums import (
|
|
CallType,
|
|
IntegrationAction,
|
|
ToolCategory,
|
|
ToolStatus,
|
|
TriggerState,
|
|
WebhookCredentialType,
|
|
WorkflowRunState,
|
|
WorkflowStatus,
|
|
)
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
# TODO: remove workflow_defintion after migration, remove nullable workflow_defintion_id from Workflow and Workflowrun
|
|
|
|
|
|
# Association table for many-to-many relationship between users and organizations
|
|
organization_users_association = Table(
|
|
"organization_users",
|
|
Base.metadata,
|
|
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
|
|
Column(
|
|
"organization_id", Integer, ForeignKey("organizations.id"), primary_key=True
|
|
),
|
|
)
|
|
|
|
|
|
class UserModel(Base):
|
|
__tablename__ = "users"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
provider_id = Column(String, unique=True, index=True, nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
workflows = relationship("WorkflowModel", back_populates="user")
|
|
selected_organization_id = Column(
|
|
Integer, ForeignKey("organizations.id"), nullable=True
|
|
)
|
|
selected_organization = relationship("OrganizationModel", back_populates="users")
|
|
organizations = relationship(
|
|
"OrganizationModel",
|
|
secondary=organization_users_association,
|
|
back_populates="users",
|
|
)
|
|
is_superuser = Column(Boolean, default=False)
|
|
email = Column(String, nullable=True)
|
|
password_hash = Column(String, nullable=True)
|
|
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_users_email_lower",
|
|
func.lower(email),
|
|
unique=True,
|
|
postgresql_where=text("email IS NOT NULL"),
|
|
),
|
|
)
|
|
|
|
|
|
class UserConfigurationModel(Base):
|
|
"""Per-user keyed JSON store, mirroring organization_configurations.
|
|
|
|
Keys are defined in UserConfigurationKey. The legacy v1 AI model
|
|
configuration lives under MODEL_CONFIGURATION; last_validated_at is only
|
|
meaningful for that key.
|
|
"""
|
|
|
|
__tablename__ = "user_configurations"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
key = Column(String, nullable=False)
|
|
configuration = Column(JSON, nullable=False, default=dict)
|
|
last_validated_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "key", name="_user_configuration_key_uc"),
|
|
)
|
|
|
|
|
|
# New Organization model
|
|
class OrganizationModel(Base):
|
|
__tablename__ = "organizations"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
provider_id = Column(String, unique=True, index=True, nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
# Deprecated: MPS owns quota and credit ledger state.
|
|
quota_type = Column(
|
|
Enum("monthly", "annual", name="quota_type"),
|
|
nullable=False,
|
|
default="monthly",
|
|
server_default=text("'monthly'::quota_type"),
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
info={"deprecated": True},
|
|
)
|
|
quota_dograh_tokens = Column(
|
|
Integer,
|
|
nullable=False,
|
|
default=0,
|
|
server_default=text("0"),
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
info={"deprecated": True},
|
|
)
|
|
quota_reset_day = Column(
|
|
Integer,
|
|
nullable=False,
|
|
default=1,
|
|
server_default=text("1"),
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
info={"deprecated": True},
|
|
)
|
|
quota_start_date = Column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
info={"deprecated": True},
|
|
)
|
|
quota_enabled = Column(
|
|
Boolean,
|
|
nullable=False,
|
|
default=False,
|
|
server_default=text("false"),
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
info={"deprecated": True},
|
|
)
|
|
|
|
price_per_second_usd = Column(Float, nullable=True)
|
|
|
|
# Relationships
|
|
users = relationship(
|
|
"UserModel",
|
|
secondary=organization_users_association,
|
|
back_populates="organizations",
|
|
)
|
|
integrations = relationship("IntegrationModel", back_populates="organization")
|
|
usage_cycles = relationship(
|
|
"OrganizationUsageCycleModel", back_populates="organization"
|
|
)
|
|
configurations = relationship(
|
|
"OrganizationConfigurationModel", back_populates="organization"
|
|
)
|
|
api_keys = relationship("APIKeyModel", back_populates="organization")
|
|
|
|
|
|
class APIKeyModel(Base):
|
|
__tablename__ = "api_keys"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
name = Column(String, nullable=False)
|
|
key_hash = Column(String, nullable=False, unique=True, index=True)
|
|
key_prefix = Column(String, nullable=False) # Store first 8 chars for display
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
archived_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel", back_populates="api_keys")
|
|
created_by_user = relationship("UserModel")
|
|
|
|
# Indexes for performance
|
|
__table_args__ = (
|
|
Index("ix_api_keys_organization_id", "organization_id"),
|
|
Index("ix_api_keys_key_hash", "key_hash"),
|
|
Index("ix_api_keys_active", "is_active"),
|
|
)
|
|
|
|
|
|
class OrganizationConfigurationModel(Base):
|
|
__tablename__ = "organization_configurations"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
key = Column(String, nullable=False)
|
|
value = Column(JSON, nullable=False, default=dict)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel", back_populates="configurations")
|
|
|
|
# Constraints and indexes
|
|
__table_args__ = (
|
|
UniqueConstraint("organization_id", "key", name="_organization_key_uc"),
|
|
Index("ix_organization_configurations_organization_id", "organization_id"),
|
|
)
|
|
|
|
|
|
class TelephonyConfigurationModel(Base):
|
|
__tablename__ = "telephony_configurations"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
name = Column(String(64), nullable=False)
|
|
provider = Column(String(32), nullable=False)
|
|
credentials = Column(JSON, nullable=False, default=dict)
|
|
is_default_outbound = Column(
|
|
Boolean, nullable=False, default=False, server_default=text("false")
|
|
)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
organization = relationship("OrganizationModel")
|
|
phone_numbers = relationship(
|
|
"TelephonyPhoneNumberModel",
|
|
back_populates="configuration",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"organization_id", "name", name="uq_telephony_configurations_org_name"
|
|
),
|
|
Index("ix_telephony_configurations_org", "organization_id"),
|
|
Index(
|
|
"uq_telephony_configurations_default",
|
|
"organization_id",
|
|
unique=True,
|
|
postgresql_where=text("is_default_outbound = true"),
|
|
),
|
|
)
|
|
|
|
|
|
class TelephonyPhoneNumberModel(Base):
|
|
__tablename__ = "telephony_phone_numbers"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
telephony_configuration_id = Column(
|
|
Integer,
|
|
ForeignKey("telephony_configurations.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
address = Column(String(255), nullable=False)
|
|
address_normalized = Column(String(255), nullable=False)
|
|
address_type = Column(String(16), nullable=False)
|
|
country_code = Column(String(2), nullable=True)
|
|
label = Column(String(64), nullable=True)
|
|
inbound_workflow_id = Column(
|
|
Integer,
|
|
ForeignKey("workflows.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
is_active = Column(
|
|
Boolean, nullable=False, default=True, server_default=text("true")
|
|
)
|
|
is_default_caller_id = Column(
|
|
Boolean, nullable=False, default=False, server_default=text("false")
|
|
)
|
|
extra_metadata = Column(
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
configuration = relationship(
|
|
"TelephonyConfigurationModel", back_populates="phone_numbers"
|
|
)
|
|
inbound_workflow = relationship("WorkflowModel")
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"organization_id",
|
|
"address_normalized",
|
|
name="uq_phone_numbers_org_address",
|
|
),
|
|
Index("ix_phone_numbers_config", "telephony_configuration_id"),
|
|
Index(
|
|
"ix_phone_numbers_workflow",
|
|
"inbound_workflow_id",
|
|
postgresql_where=text("inbound_workflow_id IS NOT NULL"),
|
|
),
|
|
Index(
|
|
"ix_phone_numbers_inbound_lookup",
|
|
"address_normalized",
|
|
"organization_id",
|
|
postgresql_where=text("is_active = true"),
|
|
),
|
|
Index(
|
|
"uq_phone_numbers_default_caller",
|
|
"telephony_configuration_id",
|
|
unique=True,
|
|
postgresql_where=text("is_default_caller_id = true"),
|
|
),
|
|
)
|
|
|
|
|
|
class IntegrationModel(Base):
|
|
__tablename__ = "integrations"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
integration_id = Column(
|
|
String, nullable=False, index=True
|
|
) # External connection ID
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
|
provider = Column(String, nullable=False)
|
|
created_by = Column(Integer, ForeignKey("users.id"))
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
connection_details = Column(JSON, nullable=False, default=dict)
|
|
action = Column(String, nullable=False, default=IntegrationAction.ALL_CALLS.value)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel", back_populates="integrations")
|
|
|
|
|
|
class WorkflowDefinitionModel(Base):
|
|
__tablename__ = "workflow_definitions"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
workflow_hash = Column(String, nullable=True) # Legacy, no longer used
|
|
workflow_json = Column(JSON, nullable=False, default=dict)
|
|
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=True)
|
|
is_current = Column(
|
|
Boolean, default=False, nullable=False, server_default=text("false")
|
|
)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
# Versioning columns
|
|
status = Column(
|
|
String,
|
|
nullable=False,
|
|
default="published",
|
|
server_default=text("'published'"),
|
|
) # draft | published | archived
|
|
version_number = Column(
|
|
Integer, nullable=True
|
|
) # Sequential per workflow, display only
|
|
published_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Full behavioral snapshot (moved from WorkflowModel to enable versioning)
|
|
workflow_configurations = Column(
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
)
|
|
template_context_variables = Column(
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
)
|
|
|
|
# Table constraints and indexes — unique hash constraint removed (no more dedup)
|
|
__table_args__ = (
|
|
Index("ix_workflow_definitions_workflow_status", "workflow_id", "status"),
|
|
)
|
|
|
|
# Relationships
|
|
workflow = relationship(
|
|
"WorkflowModel",
|
|
back_populates="definitions",
|
|
foreign_keys=[workflow_id],
|
|
)
|
|
workflow_runs = relationship("WorkflowRunModel", back_populates="definition")
|
|
|
|
|
|
class FolderModel(Base):
|
|
"""A folder for grouping workflows (agents) within an organization.
|
|
|
|
Folders are flat (no nesting) and org-scoped. A workflow belongs to at
|
|
most one folder via ``WorkflowModel.folder_id``; a NULL folder_id means
|
|
the workflow is "Uncategorized".
|
|
"""
|
|
|
|
__tablename__ = "folders"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id"), nullable=False, index=True
|
|
)
|
|
organization = relationship("OrganizationModel")
|
|
name = Column(String, nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
workflows = relationship("WorkflowModel", back_populates="folder")
|
|
|
|
# Folder names must be unique within an organization.
|
|
__table_args__ = (
|
|
UniqueConstraint("organization_id", "name", name="uq_folder_org_name"),
|
|
)
|
|
|
|
|
|
class WorkflowModel(Base):
|
|
__tablename__ = "workflows"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
workflow_uuid = Column(
|
|
String(36),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
default=lambda: str(uuid.uuid4()),
|
|
)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
user = relationship("UserModel", back_populates="workflows")
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
|
|
organization = relationship("OrganizationModel")
|
|
# Optional folder for grouping in the agents list. NULL = "Uncategorized".
|
|
# ON DELETE SET NULL: deleting a folder un-files its agents, never deletes them.
|
|
folder_id = Column(
|
|
Integer,
|
|
ForeignKey("folders.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
folder = relationship("FolderModel", back_populates="workflows")
|
|
name = Column(String, index=True, nullable=False)
|
|
status = Column(
|
|
Enum(*[status.value for status in WorkflowStatus], name="workflow_status"),
|
|
nullable=False,
|
|
default=WorkflowStatus.ACTIVE.value,
|
|
server_default=text("'active'::workflow_status"),
|
|
)
|
|
workflow_definition = Column(JSON, nullable=False, default=dict)
|
|
template_context_variables = Column(JSON, nullable=False, default=dict)
|
|
call_disposition_codes = Column(JSON, nullable=False, default=dict)
|
|
workflow_configurations = Column(
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
)
|
|
runs = relationship("WorkflowRunModel", back_populates="workflow")
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
# Pointer to the currently-live (published) version
|
|
released_definition_id = Column(
|
|
Integer,
|
|
ForeignKey("workflow_definitions.id", use_alter=True),
|
|
nullable=True,
|
|
)
|
|
released_definition = relationship(
|
|
"WorkflowDefinitionModel",
|
|
foreign_keys=[released_definition_id],
|
|
uselist=False,
|
|
viewonly=True,
|
|
)
|
|
|
|
# All versions / historical definitions of this workflow
|
|
definitions = relationship(
|
|
"WorkflowDefinitionModel",
|
|
back_populates="workflow",
|
|
foreign_keys="WorkflowDefinitionModel.workflow_id",
|
|
)
|
|
|
|
# Relationship to fetch the current (is_current=True) definition
|
|
# Kept for backward compatibility during transition
|
|
current_definition = relationship(
|
|
"WorkflowDefinitionModel",
|
|
primaryjoin=lambda: and_(
|
|
WorkflowDefinitionModel.workflow_id == WorkflowModel.id,
|
|
WorkflowDefinitionModel.is_current.is_(True),
|
|
),
|
|
uselist=False,
|
|
viewonly=True,
|
|
)
|
|
|
|
@property
|
|
def current_definition_id(self):
|
|
"""Return ID of the current workflow definition (helper for backwards-compat)."""
|
|
current_def = self.__dict__.get("current_definition")
|
|
if current_def is not None:
|
|
return current_def.id
|
|
|
|
# If relationship is not loaded, we cannot safely access definitions without
|
|
# risking an implicit lazy load on a detached instance. Return ``None`` in
|
|
# that scenario so callers can handle the absence explicitly.
|
|
return None
|
|
|
|
|
|
class WorkflowTemplates(Base):
|
|
__tablename__ = "workflow_templates"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
template_name = Column(String, nullable=False, index=True)
|
|
template_description = Column(String, nullable=False, index=True)
|
|
template_json = Column(JSON, nullable=False, default=dict)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
class WorkflowRunModel(Base):
|
|
__tablename__ = "workflow_runs"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String, nullable=False)
|
|
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
|
workflow = relationship("WorkflowModel", back_populates="runs")
|
|
definition_id = Column(
|
|
Integer, ForeignKey("workflow_definitions.id"), nullable=True
|
|
)
|
|
definition = relationship("WorkflowDefinitionModel", back_populates="workflow_runs")
|
|
# Stored as VARCHAR (not a Postgres ENUM) so new telephony providers can
|
|
# be added purely in application code without a database migration.
|
|
# See WorkflowRunMode in api/enums.py for the canonical value set.
|
|
mode = Column(String(64), nullable=False)
|
|
call_type = Column(
|
|
Enum(*[call_type.value for call_type in CallType], name="workflow_call_type"),
|
|
nullable=False,
|
|
default=CallType.OUTBOUND.value,
|
|
server_default=text("'outbound'::workflow_call_type"),
|
|
)
|
|
state = Column(
|
|
Enum(*[state.value for state in WorkflowRunState], name="workflow_run_state"),
|
|
nullable=False,
|
|
default=WorkflowRunState.INITIALIZED.value,
|
|
server_default=text("'initialized'::workflow_run_state"),
|
|
)
|
|
is_completed = Column(Boolean, default=False)
|
|
recording_url = Column(String, nullable=True)
|
|
transcript_url = Column(String, nullable=True)
|
|
extra = Column(
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
)
|
|
# Store storage backend as string enum (s3, minio)
|
|
storage_backend = Column(
|
|
Enum("s3", "minio", name="storage_backend"),
|
|
nullable=False,
|
|
default="s3",
|
|
server_default=text("'s3'::storage_backend"),
|
|
)
|
|
usage_info = Column(JSON, nullable=False, default=dict)
|
|
cost_info = Column(JSON, nullable=False, default=dict)
|
|
initial_context = Column(JSON, nullable=False, default=dict)
|
|
gathered_context = Column(JSON, nullable=False, default=dict)
|
|
logs = Column(JSON, nullable=False, default=dict, server_default=text("'{}'::json"))
|
|
annotations = Column(JSON, nullable=False, default=dict)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
campaign_id = Column(Integer, ForeignKey("campaigns.id"), nullable=True)
|
|
campaign = relationship("CampaignModel")
|
|
queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
|
|
queued_run = relationship("QueuedRunModel", foreign_keys=[queued_run_id])
|
|
public_access_token = Column(String(36), nullable=True)
|
|
text_session = relationship(
|
|
"WorkflowRunTextSessionModel",
|
|
back_populates="workflow_run",
|
|
uselist=False,
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index(
|
|
"idx_workflow_runs_public_access_token",
|
|
"public_access_token",
|
|
unique=True,
|
|
postgresql_where=text("public_access_token IS NOT NULL"),
|
|
),
|
|
Index(
|
|
"idx_workflow_runs_call_id",
|
|
text("(gathered_context->>'call_id')"),
|
|
postgresql_where=text("gathered_context->>'call_id' IS NOT NULL"),
|
|
),
|
|
Index("idx_workflow_runs_workflow_id", "workflow_id"),
|
|
Index("idx_workflow_runs_campaign_id", "campaign_id"),
|
|
)
|
|
|
|
|
|
class WorkflowRunTextSessionModel(Base):
|
|
__tablename__ = "workflow_run_text_sessions"
|
|
|
|
workflow_run_id = Column(
|
|
Integer,
|
|
ForeignKey("workflow_runs.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
workflow_run = relationship("WorkflowRunModel", back_populates="text_session")
|
|
revision = Column(
|
|
Integer,
|
|
nullable=False,
|
|
default=0,
|
|
server_default=text("0"),
|
|
)
|
|
session_data = Column(
|
|
JSON,
|
|
nullable=False,
|
|
default=dict,
|
|
server_default=text("'{}'::json"),
|
|
)
|
|
checkpoint = Column(
|
|
JSON,
|
|
nullable=False,
|
|
default=dict,
|
|
server_default=text("'{}'::json"),
|
|
)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
__table_args__ = (Index("ix_workflow_run_text_sessions_updated_at", "updated_at"),)
|
|
|
|
|
|
class OrganizationUsageCycleModel(Base):
|
|
"""
|
|
This model is used to track reporting aggregates for an organization for a given
|
|
usage cycle. Quota fields on this model are deprecated; MPS owns quota and
|
|
credit ledger state.
|
|
"""
|
|
|
|
__tablename__ = "organization_usage_cycles"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
|
period_start = Column(DateTime(timezone=True), nullable=False)
|
|
period_end = Column(DateTime(timezone=True), nullable=False)
|
|
quota_dograh_tokens = Column(
|
|
Integer,
|
|
nullable=False,
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
info={"deprecated": True},
|
|
)
|
|
used_dograh_tokens = Column(Float, nullable=False, default=0)
|
|
total_duration_seconds = Column(
|
|
Integer, nullable=False, default=0, server_default=text("0")
|
|
)
|
|
# New USD tracking fields
|
|
used_amount_usd = Column(Float, nullable=True, default=0)
|
|
quota_amount_usd = Column(
|
|
Float,
|
|
nullable=True,
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
info={"deprecated": True},
|
|
)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel", back_populates="usage_cycles")
|
|
|
|
# Constraints and indexes
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"organization_id", "period_start", "period_end", name="unique_org_period"
|
|
),
|
|
Index("idx_usage_cycles_org_period", "organization_id", "period_end"),
|
|
)
|
|
|
|
|
|
class CampaignModel(Base):
|
|
__tablename__ = "campaigns"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String, nullable=False, index=True)
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
|
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
# Nullable during the legacy → multi-config migration window. Backfilled to the
|
|
# org's default config by the migration; will become NOT NULL in a follow-up.
|
|
telephony_configuration_id = Column(
|
|
Integer, ForeignKey("telephony_configurations.id"), nullable=True
|
|
)
|
|
|
|
# Source configuration
|
|
source_type = Column(String, nullable=False, default="csv")
|
|
source_id = Column(String, nullable=False) # CSV file key
|
|
|
|
# State management
|
|
state = Column(
|
|
Enum(
|
|
"created",
|
|
"syncing",
|
|
"running",
|
|
"paused",
|
|
"completed",
|
|
"failed",
|
|
name="campaign_state",
|
|
),
|
|
nullable=False,
|
|
default="created",
|
|
)
|
|
|
|
# Progress tracking
|
|
total_rows = Column(Integer, nullable=True)
|
|
processed_rows = Column(Integer, nullable=False, default=0)
|
|
failed_rows = Column(Integer, nullable=False, default=0)
|
|
|
|
# Rate limiting and sync configuration
|
|
rate_limit_per_second = Column(Integer, nullable=False, default=1)
|
|
max_retries = Column(Integer, nullable=False, default=0)
|
|
source_sync_status = Column(String, nullable=False, default="pending")
|
|
source_last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
|
source_sync_error = Column(String, nullable=True)
|
|
|
|
# Retry configuration for call failures
|
|
retry_config = Column(
|
|
JSON,
|
|
nullable=False,
|
|
default=DEFAULT_CAMPAIGN_RETRY_CONFIG,
|
|
server_default=text(
|
|
'\'{"enabled": true, "max_retries": 2, "retry_on_busy": true, "retry_on_no_answer": true, "retry_on_voicemail": true, "retry_delay_seconds": 120}\'::jsonb'
|
|
),
|
|
)
|
|
|
|
# Orchestrator tracking fields
|
|
last_batch_scheduled_at = Column(DateTime(timezone=True), nullable=True)
|
|
last_activity_at = Column(DateTime(timezone=True), nullable=True)
|
|
orchestrator_metadata = Column(
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
)
|
|
|
|
# Append-only timestamped log entries for state transitions, failures,
|
|
# and circuit-breaker events. Surfaced in the UI so operators can see
|
|
# why a campaign moved to paused/failed without digging through logs.
|
|
logs = Column(
|
|
JSON,
|
|
nullable=False,
|
|
default=list,
|
|
server_default=text("'[]'::json"),
|
|
)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
started_at = Column(DateTime(timezone=True), nullable=True)
|
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel")
|
|
workflow = relationship("WorkflowModel")
|
|
created_by_user = relationship("UserModel")
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index("ix_campaigns_org_id", "organization_id"),
|
|
Index("ix_campaigns_state", "state"),
|
|
Index("ix_campaigns_workflow_id", "workflow_id"),
|
|
Index(
|
|
"ix_campaigns_telephony_config",
|
|
"telephony_configuration_id",
|
|
postgresql_where=text("telephony_configuration_id IS NOT NULL"),
|
|
),
|
|
# Index for efficient querying of active campaigns
|
|
Index(
|
|
"idx_campaigns_active_status",
|
|
"state",
|
|
postgresql_where=text("state IN ('syncing', 'running', 'paused')"),
|
|
),
|
|
)
|
|
|
|
|
|
class QueuedRunModel(Base):
|
|
__tablename__ = "queued_runs"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
campaign_id = Column(
|
|
Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
source_uuid = Column(String, nullable=False)
|
|
context_variables = Column(JSON, nullable=False, default=dict)
|
|
state = Column(
|
|
Enum("queued", "processed", "processing", "failed", name="queued_run_state"),
|
|
nullable=False,
|
|
default="queued",
|
|
)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
processed_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# New retry-related fields
|
|
retry_count = Column(Integer, default=0, nullable=False, server_default=text("0"))
|
|
parent_queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
|
|
scheduled_for = Column(DateTime(timezone=True), nullable=True)
|
|
retry_reason = Column(String, nullable=True) # 'busy', 'no_answer', 'voicemail'
|
|
|
|
# Relationships
|
|
campaign = relationship("CampaignModel")
|
|
parent_queued_run = relationship("QueuedRunModel", remote_side=[id])
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index("idx_queued_runs_campaign_state", "campaign_id", "state"),
|
|
Index("idx_queued_runs_created", "created_at"),
|
|
Index("idx_queued_runs_source_uuid", "source_uuid"),
|
|
Index(
|
|
"idx_queued_runs_scheduled", "scheduled_for"
|
|
), # New index for scheduled retries
|
|
# Optimized index for checking queued runs efficiently
|
|
Index(
|
|
"idx_queued_runs_campaign_state_optimized",
|
|
"campaign_id",
|
|
"state",
|
|
postgresql_where=text("state = 'queued'"),
|
|
),
|
|
# Optimized index for scheduled retries
|
|
Index(
|
|
"idx_queued_runs_scheduled_optimized",
|
|
"campaign_id",
|
|
"scheduled_for",
|
|
postgresql_where=text("scheduled_for IS NOT NULL"),
|
|
),
|
|
UniqueConstraint(
|
|
"campaign_id",
|
|
"source_uuid",
|
|
"retry_count",
|
|
name="unique_campaign_source_retry",
|
|
),
|
|
)
|
|
|
|
|
|
class EmbedTokenModel(Base):
|
|
"""Model for storing workflow embed tokens"""
|
|
|
|
__tablename__ = "embed_tokens"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
token = Column(String(255), unique=True, nullable=False, index=True)
|
|
workflow_id = Column(
|
|
Integer,
|
|
ForeignKey("workflows.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
organization_id = Column(
|
|
Integer,
|
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
allowed_domains = Column(JSON, nullable=True) # Array of whitelisted domains
|
|
settings = Column(JSON, nullable=True) # Widget customization settings
|
|
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
|
usage_limit = Column(Integer, nullable=True) # Optional usage limit
|
|
usage_count = Column(Integer, default=0, nullable=False)
|
|
expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
created_by = Column(
|
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
updated_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Relationships
|
|
workflow = relationship("WorkflowModel")
|
|
organization = relationship("OrganizationModel")
|
|
creator = relationship("UserModel")
|
|
sessions = relationship(
|
|
"EmbedSessionModel", back_populates="embed_token", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class EmbedSessionModel(Base):
|
|
"""Model for storing temporary embed sessions"""
|
|
|
|
__tablename__ = "embed_sessions"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
session_token = Column(String(255), unique=True, nullable=False, index=True)
|
|
embed_token_id = Column(
|
|
Integer, ForeignKey("embed_tokens.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
workflow_run_id = Column(
|
|
Integer, ForeignKey("workflow_runs.id", ondelete="CASCADE"), nullable=True
|
|
)
|
|
client_ip = Column(String(45), nullable=True)
|
|
user_agent = Column(String(500), nullable=True)
|
|
origin = Column(String(255), nullable=True)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
|
|
|
# Relationships
|
|
embed_token = relationship("EmbedTokenModel", back_populates="sessions")
|
|
workflow_run = relationship("WorkflowRunModel")
|
|
|
|
|
|
class AgentTriggerModel(Base):
|
|
"""Model for storing agent trigger mappings (UUID -> workflow_id).
|
|
|
|
This is a minimal lookup table that maps trigger UUIDs to workflows.
|
|
The trigger node in the workflow definition is the source of truth.
|
|
"""
|
|
|
|
__tablename__ = "agent_triggers"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Globally unique trigger path (UUID format)
|
|
trigger_path = Column(String(36), unique=True, nullable=False, index=True)
|
|
|
|
# Link to workflow
|
|
workflow_id = Column(
|
|
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# State management (active/archived)
|
|
state = Column(
|
|
Enum(*[state.value for state in TriggerState], name="trigger_state"),
|
|
nullable=False,
|
|
default=TriggerState.ACTIVE.value,
|
|
server_default=text("'active'::trigger_state"),
|
|
)
|
|
|
|
# Audit
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
# Relationships
|
|
workflow = relationship("WorkflowModel")
|
|
organization = relationship("OrganizationModel")
|
|
|
|
# Indexes for performance
|
|
__table_args__ = (
|
|
Index("ix_agent_triggers_workflow_id", "workflow_id"),
|
|
Index("ix_agent_triggers_state", "state"),
|
|
)
|
|
|
|
|
|
class ExternalCredentialModel(Base):
|
|
"""Model for storing external authentication credentials.
|
|
|
|
Credentials are stored separately from webhook configurations to allow
|
|
reuse across multiple workflows and secure storage of sensitive data.
|
|
"""
|
|
|
|
__tablename__ = "external_credentials"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Public UUID reference (used in APIs and workflow definitions)
|
|
# This prevents enumeration attacks and hides internal IDs
|
|
credential_uuid = Column(
|
|
String(36),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
default=lambda: str(uuid.uuid4()),
|
|
)
|
|
|
|
# Organization scoping
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# Credential metadata
|
|
name = Column(String, nullable=False) # Display name, e.g., "Salesforce API"
|
|
description = Column(String, nullable=True) # Optional description
|
|
|
|
# Credential type - uses enum from api/enums.py
|
|
credential_type = Column(
|
|
Enum(
|
|
*[t.value for t in WebhookCredentialType],
|
|
name="webhook_credential_type",
|
|
),
|
|
nullable=False,
|
|
default=WebhookCredentialType.NONE.value,
|
|
)
|
|
|
|
# Encrypted credential data (JSON)
|
|
# Structure depends on credential_type:
|
|
# - api_key: {"header_name": "X-API-Key", "api_key": "value"}
|
|
# - bearer_token: {"token": "value"}
|
|
# - basic_auth: {"username": "user", "password": "value"}
|
|
# - custom_header: {"header_name": "X-Custom", "header_value": "value"}
|
|
credential_data = Column(JSON, nullable=False, default=dict)
|
|
|
|
# Audit fields
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
# Soft delete for safety
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel")
|
|
created_by_user = relationship("UserModel")
|
|
|
|
# Indexes and constraints
|
|
__table_args__ = (
|
|
Index("ix_webhook_credentials_organization_id", "organization_id"),
|
|
Index("ix_webhook_credentials_uuid", "credential_uuid"),
|
|
UniqueConstraint("organization_id", "name", name="unique_org_credential_name"),
|
|
)
|
|
|
|
|
|
class ToolModel(Base):
|
|
"""Model for storing reusable tools that can be invoked during workflows.
|
|
|
|
Tools provide a standardized way to integrate external functionality - from
|
|
HTTP API calls to native integrations.
|
|
"""
|
|
|
|
__tablename__ = "tools"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Public identifier (used in APIs and workflow references)
|
|
tool_uuid = Column(
|
|
String(36),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
default=lambda: str(uuid.uuid4()),
|
|
)
|
|
|
|
# Organization scoping
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# Tool metadata
|
|
name = Column(String(255), nullable=False)
|
|
description = Column(String, nullable=True)
|
|
|
|
# Tool category - uses enum from api/enums.py
|
|
category = Column(
|
|
Enum(
|
|
*[c.value for c in ToolCategory],
|
|
name="tool_category",
|
|
),
|
|
nullable=False,
|
|
default=ToolCategory.HTTP_API.value,
|
|
)
|
|
|
|
# Icon configuration (for UI display)
|
|
icon = Column(String(50), nullable=True) # Icon identifier
|
|
icon_color = Column(String(7), nullable=True) # Hex color code
|
|
|
|
# Status management
|
|
status = Column(
|
|
Enum(
|
|
*[s.value for s in ToolStatus],
|
|
name="tool_status",
|
|
),
|
|
nullable=False,
|
|
default=ToolStatus.ACTIVE.value,
|
|
server_default=text("'active'::tool_status"),
|
|
)
|
|
|
|
# The tool definition (JSONB) - contains schema_version for compatibility
|
|
# Structure depends on category:
|
|
# - http_api: {"schema_version": 1, "type": "http_api", "config": {...}}
|
|
definition = Column(JSON, nullable=False, default=dict)
|
|
|
|
# Audit fields
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel")
|
|
created_by_user = relationship("UserModel")
|
|
|
|
# Indexes and constraints
|
|
__table_args__ = (
|
|
Index("ix_tools_organization_id", "organization_id"),
|
|
Index("ix_tools_uuid", "tool_uuid"),
|
|
Index("ix_tools_status", "status"),
|
|
Index("ix_tools_category", "category"),
|
|
)
|
|
|
|
|
|
class KnowledgeBaseDocumentModel(Base):
|
|
"""Model for storing document-level metadata in the knowledge base.
|
|
|
|
Each document represents a source file (PDF, DOCX, etc.) that has been
|
|
processed and chunked for retrieval.
|
|
"""
|
|
|
|
__tablename__ = "knowledge_base_documents"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Public identifier for API references
|
|
document_uuid = Column(
|
|
String(36),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
default=lambda: str(uuid.uuid4()),
|
|
)
|
|
|
|
# Organization scoping
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# Document metadata
|
|
filename = Column(String(500), nullable=False)
|
|
file_size_bytes = Column(Integer, nullable=True)
|
|
file_hash = Column(String(64), nullable=True) # SHA-256 hash for deduplication
|
|
mime_type = Column(String(100), nullable=True)
|
|
|
|
# Retrieval mode: "chunked" (vector search) or "full_document" (return full text)
|
|
retrieval_mode = Column(
|
|
String(20), nullable=False, default="chunked", server_default="chunked"
|
|
)
|
|
full_text = Column(
|
|
Text, nullable=True
|
|
) # Stored when retrieval_mode is "full_document"
|
|
|
|
# Processing metadata
|
|
source_url = Column(String, nullable=True) # If document was fetched from URL
|
|
total_chunks = Column(Integer, nullable=False, default=0)
|
|
processing_status = Column(
|
|
Enum(
|
|
"pending",
|
|
"processing",
|
|
"completed",
|
|
"failed",
|
|
name="document_processing_status",
|
|
),
|
|
nullable=False,
|
|
default="pending",
|
|
server_default=text("'pending'::document_processing_status"),
|
|
)
|
|
processing_error = Column(Text, nullable=True)
|
|
|
|
# Docling conversion metadata
|
|
docling_metadata = Column(
|
|
JSON, nullable=False, default=dict
|
|
) # Store docling document metadata
|
|
|
|
# Custom metadata (user-defined tags, categories, etc.)
|
|
custom_metadata = Column(JSON, nullable=False, default=dict)
|
|
|
|
# Audit fields
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
# Soft delete
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
archived_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Relationships
|
|
organization = relationship("OrganizationModel")
|
|
created_by_user = relationship("UserModel")
|
|
chunks = relationship(
|
|
"KnowledgeBaseChunkModel",
|
|
back_populates="document",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Indexes and constraints
|
|
__table_args__ = (
|
|
Index("ix_kb_documents_organization_id", "organization_id"),
|
|
Index("ix_kb_documents_uuid", "document_uuid"),
|
|
Index("ix_kb_documents_status", "processing_status"),
|
|
Index("ix_kb_documents_created_at", "created_at"),
|
|
)
|
|
|
|
|
|
class WorkflowRecordingModel(Base):
|
|
"""Model for storing audio recordings scoped to an organization.
|
|
|
|
Recordings are used in hybrid prompts where parts of the output are pre-recorded
|
|
audio rather than dynamically generated TTS.
|
|
"""
|
|
|
|
__tablename__ = "workflow_recordings"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Descriptive ID used in prompts (unique per organization)
|
|
recording_id = Column(String(64), nullable=False, index=True)
|
|
|
|
# Scoping
|
|
workflow_id = Column(
|
|
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=True
|
|
)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# TTS configuration metadata (optional, legacy)
|
|
tts_provider = Column(String, nullable=True)
|
|
tts_model = Column(String, nullable=True)
|
|
tts_voice_id = Column(String, nullable=True)
|
|
|
|
# Content
|
|
transcript = Column(Text, nullable=False)
|
|
|
|
# Storage
|
|
storage_key = Column(String, nullable=False)
|
|
storage_backend = Column(
|
|
Enum("s3", "minio", name="recording_storage_backend"),
|
|
nullable=False,
|
|
default="s3",
|
|
server_default=text("'s3'::recording_storage_backend"),
|
|
)
|
|
|
|
# Extra metadata (file_size_bytes, duration_seconds, original_filename, mime_type, etc.)
|
|
recording_metadata = Column(
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
)
|
|
|
|
# Audit
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
# Soft delete
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
# Relationships
|
|
workflow = relationship("WorkflowModel")
|
|
organization = relationship("OrganizationModel")
|
|
created_by_user = relationship("UserModel")
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"recording_id",
|
|
"organization_id",
|
|
name="uq_workflow_recordings_recording_id_org",
|
|
),
|
|
Index("ix_workflow_recordings_workflow_id", "workflow_id"),
|
|
Index("ix_workflow_recordings_org_id", "organization_id"),
|
|
Index("ix_workflow_recordings_recording_id", "recording_id"),
|
|
)
|
|
|
|
|
|
class KnowledgeBaseChunkModel(Base):
|
|
"""Model for storing document chunks with vector embeddings.
|
|
|
|
Each chunk represents a portion of a document that has been:
|
|
1. Extracted and chunked by docling's HybridChunker
|
|
2. Optionally contextualized with surrounding information
|
|
3. Embedded into a vector representation for semantic search
|
|
"""
|
|
|
|
__tablename__ = "knowledge_base_chunks"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Link to parent document
|
|
document_id = Column(
|
|
Integer,
|
|
ForeignKey("knowledge_base_documents.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
|
|
# Organization scoping (denormalized for efficient querying)
|
|
organization_id = Column(
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# Chunk content
|
|
chunk_text = Column(Text, nullable=False) # The actual chunk text
|
|
contextualized_text = Column(
|
|
Text, nullable=True
|
|
) # Enriched text from chunker.contextualize()
|
|
|
|
# Chunk positioning and metadata
|
|
chunk_index = Column(Integer, nullable=False) # Position in document (0-based)
|
|
|
|
# Docling chunk metadata
|
|
chunk_metadata = Column(
|
|
JSON, nullable=False, default=dict
|
|
) # Store chunk.meta if available
|
|
|
|
# Embedding configuration
|
|
embedding_model = Column(
|
|
String(200), nullable=False
|
|
) # e.g., "sentence-transformers/all-MiniLM-L6-v2"
|
|
embedding_dimension = Column(
|
|
Integer, nullable=False
|
|
) # e.g., 384 for all-MiniLM-L6-v2
|
|
|
|
# Vector embedding (pgvector column)
|
|
# The dimension should match the embedding_dimension field
|
|
# Default: 1536 dimensions for OpenAI text-embedding-3-small
|
|
# SentenceTransformer (384-dim) also supported but stored as 384-dim vectors
|
|
embedding = Column(Vector(1536), nullable=True)
|
|
|
|
# Token count (useful for chunking strategy analysis)
|
|
token_count = Column(Integer, nullable=True)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(UTC),
|
|
onupdate=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
# Relationships
|
|
document = relationship("KnowledgeBaseDocumentModel", back_populates="chunks")
|
|
organization = relationship("OrganizationModel")
|
|
|
|
# Indexes and constraints
|
|
__table_args__ = (
|
|
Index("ix_kb_chunks_document_id", "document_id"),
|
|
Index("ix_kb_chunks_organization_id", "organization_id"),
|
|
Index("ix_kb_chunks_chunk_index", "chunk_index"),
|
|
Index(
|
|
"ix_kb_chunks_embedding_model", "embedding_model"
|
|
), # For filtering by model
|
|
# Vector similarity search index (using IVFFlat or HNSW)
|
|
# IVFFlat is good for datasets with 10k-1M vectors
|
|
# HNSW is better for larger datasets but uses more memory
|
|
Index(
|
|
"ix_kb_chunks_embedding_ivfflat",
|
|
"embedding",
|
|
postgresql_using="ivfflat",
|
|
postgresql_with={"lists": 100}, # Adjust based on dataset size
|
|
postgresql_ops={"embedding": "vector_cosine_ops"},
|
|
),
|
|
)
|