NoteStack-Test / backend /app /models /content.py
HumeraR's picture
feat: add MemoryNotes component to Settings page and enhance UI styles
b74c6ae
Raw History Blame
16.4 kB
import uuid
from datetime import datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
Uuid,
)
from sqlalchemy.orm import Mapped, 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"
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 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))
status: Mapped[str] = mapped_column(String(20), default="pending")
content_json: Mapped[dict] = mapped_column(JSON, default=dict)
storage_key: Mapped[str | None] = mapped_column(String(500))
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
class CalendarItem(IdMixin, TimestampMixin, Base):
__tablename__ = "calendar_items"
workspace_id: Mapped[uuid.UUID] = _ws_fk()
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
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)