NoteStack-Test / backend /app /models /content.py
HumeraR's picture
refactor: remove QuizAttempt model and related functionality across backend and frontend
89250ee
Raw History Blame Contribute Delete
26.3 kB
import logging
import uuid
from datetime import datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
Uuid,
event,
text,
true,
)
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
from app.db import Base
from app.models.base import IdMixin, TimestampMixin
def _ws_fk() -> Mapped[uuid.UUID]:
return mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), index=True)
class Source(IdMixin, TimestampMixin, Base):
__tablename__ = "sources"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
feed_url: Mapped[str] = mapped_column(String(1000))
site_url: Mapped[str | None] = mapped_column(String(1000))
platform: Mapped[str] = mapped_column(String(20), default="rss") # substack | ghost | medium | rss | url
title: Mapped[str | None] = mapped_column(String(500))
sync_status: Mapped[str] = mapped_column(String(20), default="pending") # pending | syncing | ok | error
sync_error: Mapped[str | None] = mapped_column(Text)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Document(IdMixin, TimestampMixin, Base):
__tablename__ = "documents"
__table_args__ = (UniqueConstraint("source_id", "url"),)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
source_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("sources.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(String(1000))
url: Mapped[str] = mapped_column(String(1000))
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_html_key: Mapped[str | None] = mapped_column(String(500)) # R2 key
# Corpus relative path of the clean markdown file, e.g. sources/ada-substack-com/2026-03-01-pricing.md
path: Mapped[str | None] = mapped_column(String(500), index=True)
clean_text: Mapped[str] = mapped_column(Text, default="")
content_hash: Mapped[str | None] = mapped_column(String(64))
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
# Resurfacing: 0..1 from the EvergreenScore module (reason and angle live in metadata_json)
evergreen_score: Mapped[float | None] = mapped_column(Float)
last_resurfaced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Notebook(IdMixin, TimestampMixin, Base):
__tablename__ = "notebooks"
# One "All posts" notebook per workspace: two requests opening it at once cannot both create one.
__table_args__ = (Index("uq_notebooks_one_archive", "workspace_id", unique=True,
postgresql_where=text("is_archive"), sqlite_where=text("is_archive")),)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
title: Mapped[str] = mapped_column(String(300))
description: Mapped[str | None] = mapped_column(Text)
summary: Mapped[str | None] = mapped_column(Text)
# The workspace's "All posts" notebook: created on demand, kept in step with every indexed post.
is_archive: Mapped[bool] = mapped_column(Boolean, default=False)
class NotebookDocument(Base):
__tablename__ = "notebook_documents"
notebook_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("notebooks.id", ondelete="CASCADE"), primary_key=True
)
document_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
class VoiceProfile(IdMixin, TimestampMixin, Base):
__tablename__ = "voice_profiles"
workspace_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True
)
profile_json: Mapped[dict] = mapped_column(JSON, default=dict)
sample_doc_ids: Mapped[list] = mapped_column(JSON, default=list)
host_voices: Mapped[dict] = mapped_column(JSON, default=dict) # {"host_a": voice_id, "host_b": voice_id}
class VoiceConsent(IdMixin, TimestampMixin, Base):
"""Required before any voice cloning. The recorded sample lives in R2."""
__tablename__ = "voice_consents"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
sample_key: Mapped[str] = mapped_column(String(500))
consent_text: Mapped[str] = mapped_column(Text)
ip_address: Mapped[str | None] = mapped_column(String(64))
elevenlabs_voice_id: Mapped[str | None] = mapped_column(String(100))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Chat(IdMixin, TimestampMixin, Base):
__tablename__ = "chats"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
notebook_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("notebooks.id", ondelete="CASCADE"))
title: Mapped[str | None] = mapped_column(String(300))
class Message(IdMixin, TimestampMixin, Base):
__tablename__ = "messages"
chat_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("chats.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(20)) # user | assistant
content: Mapped[str] = mapped_column(Text)
# How an assistant answer was produced (triage route, which chat memory was loaded, the steps taken), so a
# thumbs down can be traced back to what recall did. Null for user messages and older rows.
recall_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
citations: Mapped[list["Citation"]] = relationship(cascade="all, delete-orphan")
class MessageFeedback(IdMixin, TimestampMixin, Base):
"""A thumbs up or down on one assistant answer, with a snapshot of the question, answer and recall trace taken
when it was rated. It keeps its own copy so the rating stays useful for improving recall even after the chat is
deleted (the links then become null). One row per message: rating again replaces it."""
__tablename__ = "message_feedback"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
notebook_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("notebooks.id", ondelete="SET NULL"))
chat_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("chats.id", ondelete="SET NULL"))
message_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("messages.id", ondelete="SET NULL"), unique=True, index=True
)
rating: Mapped[int] = mapped_column(Integer) # 1 up, -1 down
reasons: Mapped[list] = mapped_column(JSON, default=list) # tags such as forgot_chat, wrong, bad_sources
comment: Mapped[str | None] = mapped_column(Text)
question: Mapped[str] = mapped_column(Text, default="") # what the writer asked (the message before the answer)
answer: Mapped[str] = mapped_column(Text, default="")
sources: Mapped[list] = mapped_column(JSON, default=list) # [{kind, path, line_start, line_end}] it cited
recall: Mapped[dict] = mapped_column(JSON, default=dict) # copy of Message.recall_json
class ArtifactFeedback(IdMixin, TimestampMixin, Base):
"""A thumbs up or down on one generated report, quiz, flashcard set or infographic, with a snapshot of what it was
(title, prompt, sources, options) taken when it was rated, so the ratings stay useful after it is deleted (the link
then becomes null). One row per artifact and person: rating again replaces it."""
__tablename__ = "artifact_feedback"
__table_args__ = (UniqueConstraint("artifact_id", "user_id"),)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"), index=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"))
artifact_type: Mapped[str] = mapped_column(String(30))
rating: Mapped[int] = mapped_column(Integer) # 1 up, -1 down
reasons: Mapped[list] = mapped_column(JSON, default=list)
comment: Mapped[str | None] = mapped_column(Text)
snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
class ReportTemplateCache(IdMixin, TimestampMixin, Base):
"""The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each
post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog
is instant, and a changed post (its ideas change) gets new ones."""
__tablename__ = "report_template_cache"
__table_args__ = (UniqueConstraint("workspace_id", "key"),)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
key: Mapped[str] = mapped_column(String(64))
templates: Mapped[list] = mapped_column(JSON, default=list)
class WorkspaceMemory(IdMixin, TimestampMixin, Base):
"""The writer's standing notes, one row per workspace, shared by every notebook and chat.
facts maps a short key to {"value", "source" (user | auto), "updated_at"}, e.g.
{"audience": {"value": "indie founders", "source": "auto", "updated_at": "2026-09-30T10:00:00+00:00"}}."""
__tablename__ = "workspace_memory"
workspace_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True, index=True
)
facts: Mapped[dict] = mapped_column(JSON, default=dict)
class Citation(IdMixin, Base):
__tablename__ = "citations"
message_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("messages.id", ondelete="CASCADE"))
# post: a line range of a document. chat: a line range of an earlier chat's topic file, with no document.
kind: Mapped[str] = mapped_column(String(10), default="post", server_default="post")
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="CASCADE"))
marker: Mapped[int] = mapped_column(Integer) # [1], [2] in the answer text
path: Mapped[str] = mapped_column(String(500))
line_start: Mapped[int] = mapped_column(Integer)
line_end: Mapped[int] = mapped_column(Integer)
span: Mapped[str | None] = mapped_column(Text) # verified text read back from the file
class Artifact(IdMixin, TimestampMixin, Base):
__tablename__ = "artifacts"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
notebook_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("notebooks.id", ondelete="SET NULL"))
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
# summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
type: Mapped[str] = mapped_column(String(30))
# pending | generating | ready | failed | rendering | review; a video also takes blog2video's job statuses as they
# are: regenerating (template switch) | voice_regenerating | language_regenerating | script_regenerating
status: Mapped[str] = mapped_column(String(40), default="pending")
content_json: Mapped[dict] = mapped_column(JSON, default=dict)
storage_key: Mapped[str | None] = mapped_column(String(500))
class ArtifactShare(IdMixin, TimestampMixin, Base):
"""A public link to one artifact (a report): anyone holding the token can read the shared view, nothing else.
Revoking deletes the row, so a new link is a new token."""
__tablename__ = "artifact_shares"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
artifact_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"), unique=True)
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
show_sources: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
class B2VVideo(IdMixin, TimestampMixin, Base):
"""One blog2video video we asked for: the only place its blog2video id lives. Every read, edit and delete on
blog2video goes through this row (b2v_video_id, and created_via for the right endpoints). Also records who made it
and whether the workspace's video allowance is still charged for it.
Our API key reaches every video it created, for every workspace, so this table (with the artifact) is what
ties a blog2video id to one workspace. quota_state moves charged -> refunded (or -> kept once generated), with a
conditional UPDATE (see video_quota.refund), so the status poller and the sweep can not refund twice."""
__tablename__ = "b2v_videos"
__table_args__ = (Index("ix_b2v_videos_ws_created", "workspace_id", "created_at"),)
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"))
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"),
index=True) # who made it
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"),
index=True)
# blog2video's video id, which is also its project id. Null until blog2video accepts the create.
b2v_video_id: Mapped[int | None] = mapped_column(Integer, unique=True)
idempotency_key: Mapped[str | None] = mapped_column(String(100), unique=True) # "<workspace_id>:<uuid>"; v1 only
created_via: Mapped[str] = mapped_column(String(10), default="v1", server_default="v1") # v1 | upload
template_ref: Mapped[str | None] = mapped_column(String(100)) # e.g. custom_58: blocks deleting that template
title: Mapped[str | None] = mapped_column(String(300))
source_url: Mapped[str | None] = mapped_column(String(2000)) # the link it was made from (null for files, text)
aspect_ratio: Mapped[str | None] = mapped_column(String(20))
preview_url: Mapped[str | None] = mapped_column(String(1000)) # public live preview (embed token)
video_url: Mapped[str | None] = mapped_column(String(1000)) # the rendered MP4
quota_state: Mapped[str] = mapped_column(String(20), default="charged") # charged | refunded | kept
status: Mapped[str] = mapped_column(String(40), default="queued") # last known blog2video status
class B2VTemplate(TimestampMixin, Base):
"""A custom template this workspace made. Used on videos as custom_<b2v_template_id>; only when ready."""
__tablename__ = "b2v_templates"
b2v_template_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
name: Mapped[str] = mapped_column(String(255))
ready: Mapped[bool] = mapped_column(Boolean, default=False) # code generation finished ('complete')
class B2VCustomVoice(TimestampMixin, Base):
"""A designed or cloned voice this workspace made. Videos reference it by voice_id (custom_voice_id)."""
__tablename__ = "b2v_custom_voices"
b2v_custom_voice_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) # preview/delete
workspace_id: Mapped[uuid.UUID] = _ws_fk()
voice_id: Mapped[str] = mapped_column(String(100), unique=True) # the ElevenLabs id sent as custom_voice_id
name: Mapped[str] = mapped_column(String(255))
source: Mapped[str] = mapped_column(String(20)) # prompt | preset | clone
preview_url: Mapped[str | None] = mapped_column(String(1000))
class UserSavedVoice(TimestampMixin, Base):
"""The workspace's "My voices" list. Kept only here: blog2video's saved-voice list is shared by everyone."""
__tablename__ = "user_saved_voices"
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
primary_key=True)
voice_id: Mapped[str] = mapped_column(String(100), primary_key=True)
name: Mapped[str] = mapped_column(String(255))
preview_url: Mapped[str | None] = mapped_column(String(1000))
gender: Mapped[str | None] = mapped_column(String(20))
accent: Mapped[str | None] = mapped_column(String(50))
premium: Mapped[bool] = mapped_column(Boolean, default=False) # a paid built-in voice, or any custom voice
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
class B2VStyle(TimestampMixin, Base):
"""A custom video style this workspace made. Used on videos as custom:<b2v_style_id>."""
__tablename__ = "b2v_styles"
b2v_style_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
name: Mapped[str] = mapped_column(String(80))
class UsageCounter(Base):
"""Per-workspace counters for the video features blog2video only limits account-wide (video_limits.py)."""
__tablename__ = "usage_counters"
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
primary_key=True)
period: Mapped[str] = mapped_column(String(10), primary_key=True) # 2026-10 | 2026-10-02 | all
metric: Mapped[str] = mapped_column(String(30), primary_key=True)
used: Mapped[int] = mapped_column(Integer, default=0)
class Job(IdMixin, TimestampMixin, Base):
"""Covers ingest, tts and render jobs. kind decides which fields matter.
This table is also the queue: workers claim queued rows with FOR UPDATE SKIP LOCKED
(see app/worker.py), so no separate broker is needed.
"""
__tablename__ = "jobs"
__table_args__ = (Index("ix_jobs_queue", "status", "run_after", "created_at"),)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
kind: Mapped[str] = mapped_column(String(30)) # ingest | tts | render | generate
status: Mapped[str] = mapped_column(String(20), default="queued") # queued | running | done | failed
progress: Mapped[float] = mapped_column(Float, default=0.0)
message: Mapped[str | None] = mapped_column(String(500))
error: Mapped[str | None] = mapped_column(Text)
params: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[dict] = mapped_column(JSON, default=dict)
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"))
cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Queue bookkeeping
attempts: Mapped[int] = mapped_column(Integer, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, default=3)
run_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # retry backoff
locked_by: Mapped[str | None] = mapped_column(String(100)) # worker id while running
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # stale detection
class Upload(IdMixin, TimestampMixin, Base):
__tablename__ = "uploads"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
key: Mapped[str] = mapped_column(String(500), unique=True)
filename: Mapped[str] = mapped_column(String(300))
content_type: Mapped[str] = mapped_column(String(100))
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | complete
# Deleting an upload record removes its file from storage too, once the delete is committed (never on a rollback).
# This covers deletes made through the ORM; rows removed straight in the database are caught by the daily orphan
# sweep (app.services.uploads.sweep_orphan_uploads).
_PENDING_FILE_DELETES = "upload_files_to_delete"
@event.listens_for(Session, "after_flush")
def _note_deleted_uploads(session: Session, _ctx) -> None:
keys = [obj.key for obj in session.deleted if isinstance(obj, Upload) and obj.key]
if keys:
session.info.setdefault(_PENDING_FILE_DELETES, []).extend(keys)
@event.listens_for(Session, "after_commit")
def _delete_upload_files(session: Session) -> None:
keys = session.info.pop(_PENDING_FILE_DELETES, [])
if not keys:
return
from app.services.storage import storage
for key in keys:
try:
storage.delete(key)
except Exception: # left for the daily sweep
logging.getLogger("notestack.storage").warning("couldn't delete upload file %s", key, exc_info=True)
@event.listens_for(Session, "after_rollback")
def _forget_upload_files(session: Session) -> None:
session.info.pop(_PENDING_FILE_DELETES, None)
class CalendarItem(IdMixin, TimestampMixin, Base):
__tablename__ = "calendar_items"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
# What the post carries (a video, quote card, carousel...): never a Launch Kit, which is kit_id.
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
# The Launch Kit the post was written from, kept apart from the attachment (nothing of it is uploaded).
kit_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"))
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
platform: Mapped[str] = mapped_column(String(30)) # x | linkedin | bluesky | substack_notes
scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
# scheduled | publishing | posted | reminded | failed | draft
status: Mapped[str] = mapped_column(String(20), default="scheduled")
content: Mapped[str] = mapped_column(Text, default="")
thread: Mapped[list] = mapped_column(JSON, default=list) # extra posts after the first, for threads
social_account_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("social_accounts.id", ondelete="SET NULL")
)
remind_by_email: Mapped[bool] = mapped_column(Boolean, default=False)
external_id: Mapped[str | None] = mapped_column(String(200))
external_url: Mapped[str | None] = mapped_column(String(1000))
posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
error: Mapped[str | None] = mapped_column(Text)
attempts: Mapped[int] = mapped_column(Integer, default=0)
metrics: Mapped[dict] = mapped_column(JSON, default=dict) # likes, reposts, replies, impressions
class SocialAccount(IdMixin, TimestampMixin, Base):
"""A connected X, LinkedIn or Bluesky account. Tokens are Fernet encrypted (app/services/crypto.py)."""
__tablename__ = "social_accounts"
__table_args__ = (UniqueConstraint("workspace_id", "platform", "external_id"),)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
platform: Mapped[str] = mapped_column(String(20))
handle: Mapped[str] = mapped_column(String(200))
external_id: Mapped[str] = mapped_column(String(200))
access_token: Mapped[str] = mapped_column(Text)
refresh_token: Mapped[str | None] = mapped_column(Text)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scopes: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(20), default="active") # active | expired | revoked
avatar_url: Mapped[str | None] = mapped_column(String(1000))
class Topic(IdMixin, TimestampMixin, Base):
__tablename__ = "topics"
__table_args__ = (UniqueConstraint("workspace_id", "slug"),)
workspace_id: Mapped[uuid.UUID] = _ws_fk()
name: Mapped[str] = mapped_column(String(200))
slug: Mapped[str] = mapped_column(String(200))
summary: Mapped[str | None] = mapped_column(Text)
post_count: Mapped[int] = mapped_column(Integer, default=0)
class DocumentTopic(Base):
__tablename__ = "document_topics"
document_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
topic_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("topics.id", ondelete="CASCADE"), primary_key=True)
weight: Mapped[float] = mapped_column(Float, default=1.0)
class TrackedLink(IdMixin, TimestampMixin, Base):
__tablename__ = "tracked_links"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
slug: Mapped[str] = mapped_column(String(32), unique=True)
target_url: Mapped[str] = mapped_column(String(2000))
calendar_item_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("calendar_items.id", ondelete="SET NULL")
)
class EngagementEvent(IdMixin, TimestampMixin, Base):
__tablename__ = "engagement_events"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
tracked_link_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("tracked_links.id", ondelete="CASCADE")
)
kind: Mapped[str] = mapped_column(String(30)) # click | like | repost | reply | subscribe
value: Mapped[float] = mapped_column(Float, default=1.0)
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
class DspyTrace(IdMixin, TimestampMixin, Base):
__tablename__ = "dspy_traces"
workspace_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="SET NULL"))
module: Mapped[str] = mapped_column(String(60), index=True)
model: Mapped[str] = mapped_column(String(100))
inputs: Mapped[dict] = mapped_column(JSON, default=dict)
outputs: Mapped[dict] = mapped_column(JSON, default=dict)
score: Mapped[float | None] = mapped_column(Float)
feedback: Mapped[str | None] = mapped_column(Text)
# Only usable for optimization when the workspace opted in.
training_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
class UsageEvent(IdMixin, TimestampMixin, Base):
"""Metering: LLM tokens, TTS characters, render seconds. One row per billable call."""
__tablename__ = "usage_events"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
job_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("jobs.id", ondelete="SET NULL"))
kind: Mapped[str] = mapped_column(String(20)) # llm | tts | render
provider: Mapped[str] = mapped_column(String(40))
model: Mapped[str | None] = mapped_column(String(100))
quantity: Mapped[float] = mapped_column(Float) # tokens, characters or seconds
unit: Mapped[str] = mapped_column(String(20))
cost_usd: Mapped[float] = mapped_column(Float, default=0.0)