417 lines
17 KiB
Python
417 lines
17 KiB
Python
from datetime import date, datetime, time
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
Time,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
"""Declarative base."""
|
|
|
|
|
|
class User(Base):
|
|
"""User entity."""
|
|
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
avatar_url: Mapped[str | None] = mapped_column(String(500))
|
|
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
stories: Mapped[list["Story"]] = relationship(
|
|
"Story", back_populates="user", cascade="all, delete-orphan"
|
|
)
|
|
child_profiles: Mapped[list["ChildProfile"]] = relationship(
|
|
"ChildProfile", back_populates="user", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class Story(Base):
|
|
"""Story entity."""
|
|
|
|
__tablename__ = "stories"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
child_profile_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("child_profiles.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
universe_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
story_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
pages: Mapped[list[dict] | None] = mapped_column(JSON, default=list)
|
|
cover_prompt: Mapped[str | None] = mapped_column(Text)
|
|
image_url: Mapped[str | None] = mapped_column(String(500))
|
|
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="generated")
|
|
generation_status: Mapped[str] = mapped_column(
|
|
String(32), nullable=False, default="narrative_ready"
|
|
)
|
|
text_status: Mapped[str] = mapped_column(
|
|
String(32), nullable=False, default="ready"
|
|
)
|
|
image_status: Mapped[str] = mapped_column(
|
|
String(32), nullable=False, default="not_requested"
|
|
)
|
|
audio_status: Mapped[str] = mapped_column(
|
|
String(32), nullable=False, default="not_requested"
|
|
)
|
|
audio_path: Mapped[str | None] = mapped_column(String(500))
|
|
last_error: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
user: Mapped["User"] = relationship("User", back_populates="stories")
|
|
child_profile: Mapped["ChildProfile | None"] = relationship("ChildProfile")
|
|
story_universe: Mapped["StoryUniverse | None"] = relationship("StoryUniverse")
|
|
|
|
@property
|
|
def retryable_assets(self) -> list[str]:
|
|
"""Assets that can be completed or retried from the current persisted state."""
|
|
|
|
assets: list[str] = []
|
|
|
|
image_is_busy_or_ready = self.image_status in {"ready", "generating"}
|
|
if not image_is_busy_or_ready:
|
|
if self.mode == "storybook":
|
|
pages = self.pages or []
|
|
has_missing_page_image = any(
|
|
isinstance(page, dict)
|
|
and page.get("image_prompt")
|
|
and not page.get("image_url")
|
|
for page in pages
|
|
)
|
|
if (self.cover_prompt and not self.image_url) or has_missing_page_image:
|
|
assets.append("image")
|
|
elif self.cover_prompt:
|
|
assets.append("image")
|
|
|
|
audio_is_busy_or_ready = self.audio_status in {"ready", "generating"}
|
|
if self.story_text and not audio_is_busy_or_ready:
|
|
assets.append("audio")
|
|
|
|
return assets
|
|
|
|
|
|
def _uuid() -> str:
|
|
return str(uuid4())
|
|
|
|
|
|
class GenerationJob(Base):
|
|
"""User-visible generation attempt that can be inspected after the request returns."""
|
|
|
|
__tablename__ = "generation_jobs"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
story_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
output_mode: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
input_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="running", index=True)
|
|
current_step: Mapped[str] = mapped_column(
|
|
String(64), nullable=False, default="request_accepted"
|
|
)
|
|
request_payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
result_snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
error_message: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class GenerationJobEvent(Base):
|
|
"""Append-only event emitted by a generation job."""
|
|
|
|
__tablename__ = "generation_job_events"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
job_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("generation_jobs.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
story_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
message: Mapped[str | None] = mapped_column(Text)
|
|
event_metadata: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
|
|
|
|
class VoiceSession(Base):
|
|
"""Voice co-creation session before it is finalized as a formal story."""
|
|
|
|
__tablename__ = "voice_sessions"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
child_profile_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("child_profiles.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
universe_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
final_story_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
target_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="story")
|
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", index=True)
|
|
current_turn_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
working_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
story_state: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
latest_user_transcript: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
latest_assistant_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class VoiceTurn(Base):
|
|
"""One turn of user input and assistant response within a voice session."""
|
|
|
|
__tablename__ = "voice_turns"
|
|
__table_args__ = (
|
|
UniqueConstraint("session_id", "turn_index", name="uq_voice_turn_session_turn_index"),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
session_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("voice_sessions.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
turn_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="received", index=True)
|
|
user_audio_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
user_audio_mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
user_audio_duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
user_transcript: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
transcript_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
detected_intent: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
|
|
intent_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
story_patch: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
assistant_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
assistant_audio_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
assistant_audio_duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class VoiceSessionEvent(Base):
|
|
"""Append-only event emitted by one voice co-creation session."""
|
|
|
|
__tablename__ = "voice_session_events"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
session_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("voice_sessions.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
turn_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("voice_turns.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
event_metadata: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
|
|
|
|
class ChildProfile(Base):
|
|
"""Child profile entity."""
|
|
|
|
__tablename__ = "child_profiles"
|
|
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_child_profile_user_name"),)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
avatar_url: Mapped[str | None] = mapped_column(String(500))
|
|
birth_date: Mapped[date | None] = mapped_column(Date)
|
|
gender: Mapped[str | None] = mapped_column(String(10))
|
|
|
|
interests: Mapped[list[str]] = mapped_column(JSON, default=list)
|
|
growth_themes: Mapped[list[str]] = mapped_column(JSON, default=list)
|
|
reading_preferences: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
|
|
stories_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
total_reading_time: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
user: Mapped["User"] = relationship("User", back_populates="child_profiles")
|
|
story_universes: Mapped[list["StoryUniverse"]] = relationship(
|
|
"StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan"
|
|
)
|
|
|
|
@property
|
|
def age(self) -> int | None:
|
|
if not self.birth_date:
|
|
return None
|
|
today = date.today()
|
|
return today.year - self.birth_date.year - (
|
|
(today.month, today.day) < (self.birth_date.month, self.birth_date.day)
|
|
)
|
|
|
|
|
|
class StoryUniverse(Base):
|
|
"""Story universe entity."""
|
|
|
|
__tablename__ = "story_universes"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
child_profile_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
protagonist: Mapped[dict] = mapped_column(JSON, nullable=False)
|
|
recurring_characters: Mapped[list] = mapped_column(JSON, default=list)
|
|
world_settings: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
achievements: Mapped[list] = mapped_column(JSON, default=list)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
child_profile: Mapped["ChildProfile"] = relationship(
|
|
"ChildProfile", back_populates="story_universes"
|
|
)
|
|
|
|
|
|
class ReadingEvent(Base):
|
|
"""Reading event entity."""
|
|
|
|
__tablename__ = "reading_events"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
child_profile_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
story_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
event_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
reading_time: Mapped[int] = mapped_column(Integer, default=0)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
|
|
|
|
class PushConfig(Base):
|
|
"""Push configuration entity."""
|
|
|
|
__tablename__ = "push_configs"
|
|
__table_args__ = (
|
|
UniqueConstraint("child_profile_id", name="uq_push_config_child"),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
child_profile_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
push_time: Mapped[time | None] = mapped_column(Time)
|
|
push_days: Mapped[list[int]] = mapped_column(JSON, default=list)
|
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class PushEvent(Base):
|
|
"""Push event entity."""
|
|
|
|
__tablename__ = "push_events"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
child_profile_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
trigger_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
reason: Mapped[str | None] = mapped_column(Text)
|
|
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
|
|
class MemoryItem(Base):
|
|
"""Memory item entity with time decay metadata."""
|
|
|
|
__tablename__ = "memory_items"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
|
child_profile_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
universe_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
value: Mapped[dict] = mapped_column(JSON, nullable=False)
|
|
base_weight: Mapped[float] = mapped_column(Float, default=1.0)
|
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
ttl_days: Mapped[int | None] = mapped_column(Integer)
|