slide-narrator 5.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- narrator/__init__.py +18 -0
- narrator/database/__init__.py +8 -0
- narrator/database/cli.py +222 -0
- narrator/database/migrations/__init__.py +6 -0
- narrator/database/models.py +70 -0
- narrator/database/storage_backend.py +624 -0
- narrator/database/thread_store.py +282 -0
- narrator/models/__init__.py +9 -0
- narrator/models/attachment.py +386 -0
- narrator/models/message.py +512 -0
- narrator/models/thread.py +467 -0
- narrator/storage/__init__.py +7 -0
- narrator/storage/file_store.py +536 -0
- narrator/utils/__init__.py +9 -0
- narrator/utils/logging.py +52 -0
- slide_narrator-5.5.0.dist-info/METADATA +558 -0
- slide_narrator-5.5.0.dist-info/RECORD +20 -0
- slide_narrator-5.5.0.dist-info/WHEEL +4 -0
- slide_narrator-5.5.0.dist-info/entry_points.txt +2 -0
- slide_narrator-5.5.0.dist-info/licenses/LICENSE +21 -0
narrator/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Narrator - Thread and file storage components for conversational AI
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .database.thread_store import ThreadStore
|
|
6
|
+
from .storage.file_store import FileStore
|
|
7
|
+
from .models.thread import Thread
|
|
8
|
+
from .models.message import Message
|
|
9
|
+
from .models.attachment import Attachment
|
|
10
|
+
|
|
11
|
+
__version__ = "5.5.0"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ThreadStore",
|
|
14
|
+
"FileStore",
|
|
15
|
+
"Thread",
|
|
16
|
+
"Message",
|
|
17
|
+
"Attachment",
|
|
18
|
+
]
|
narrator/database/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Database CLI for Tyler Stores"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import click
|
|
5
|
+
import functools
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from .thread_store import ThreadStore
|
|
11
|
+
from ..utils.logging import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def main():
|
|
17
|
+
"""Narrator CLI - Database management commands"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@main.command()
|
|
21
|
+
@click.option('--database-url', help='Database URL for initialization')
|
|
22
|
+
def init(database_url):
|
|
23
|
+
"""Initialize database tables"""
|
|
24
|
+
async def _init():
|
|
25
|
+
try:
|
|
26
|
+
# Use provided URL or check environment variable
|
|
27
|
+
url = database_url or os.environ.get('NARRATOR_DATABASE_URL')
|
|
28
|
+
|
|
29
|
+
if url:
|
|
30
|
+
store = await ThreadStore.create(url)
|
|
31
|
+
else:
|
|
32
|
+
# Use in-memory storage
|
|
33
|
+
store = await ThreadStore.create()
|
|
34
|
+
|
|
35
|
+
logger.info("Database initialized successfully")
|
|
36
|
+
click.echo("Database initialized successfully")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Failed to initialize database: {e}")
|
|
39
|
+
click.echo(f"Error: Failed to initialize database: {e}")
|
|
40
|
+
raise click.Abort()
|
|
41
|
+
|
|
42
|
+
asyncio.run(_init())
|
|
43
|
+
|
|
44
|
+
@main.command()
|
|
45
|
+
@click.option('--database-url', help='Database URL')
|
|
46
|
+
def status(database_url):
|
|
47
|
+
"""Check database status"""
|
|
48
|
+
async def _status():
|
|
49
|
+
try:
|
|
50
|
+
# Use provided URL or check environment variable
|
|
51
|
+
url = database_url or os.environ.get('NARRATOR_DATABASE_URL')
|
|
52
|
+
|
|
53
|
+
if url:
|
|
54
|
+
store = await ThreadStore.create(url)
|
|
55
|
+
else:
|
|
56
|
+
store = await ThreadStore.create()
|
|
57
|
+
|
|
58
|
+
# Get some basic stats
|
|
59
|
+
threads = await store.list_recent(limit=5)
|
|
60
|
+
click.echo(f"Database connection: OK")
|
|
61
|
+
click.echo(f"Recent threads count: {len(threads)}")
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Database status check failed: {e}")
|
|
65
|
+
click.echo(f"Error: Database status check failed: {e}")
|
|
66
|
+
raise click.Abort()
|
|
67
|
+
|
|
68
|
+
asyncio.run(_status())
|
|
69
|
+
|
|
70
|
+
@main.command()
|
|
71
|
+
@click.option('--port', help='Port to expose PostgreSQL on (default: 5432 or NARRATOR_DB_PORT)')
|
|
72
|
+
@click.option('--detach/--no-detach', default=True, help='Run container in background (default: True)')
|
|
73
|
+
def docker_start(port, detach):
|
|
74
|
+
"""Start a PostgreSQL container for Narrator"""
|
|
75
|
+
# Use environment variables with defaults matching docker-compose.yml
|
|
76
|
+
db_name = os.environ.get('NARRATOR_DB_NAME', 'narrator')
|
|
77
|
+
db_user = os.environ.get('NARRATOR_DB_USER', 'narrator')
|
|
78
|
+
db_password = os.environ.get('NARRATOR_DB_PASSWORD', 'narrator_dev')
|
|
79
|
+
db_port = port or os.environ.get('NARRATOR_DB_PORT', '5432')
|
|
80
|
+
|
|
81
|
+
docker_compose_content = f"""services:
|
|
82
|
+
postgres:
|
|
83
|
+
image: postgres:16
|
|
84
|
+
container_name: narrator-postgres
|
|
85
|
+
environment:
|
|
86
|
+
POSTGRES_DB: {db_name}
|
|
87
|
+
POSTGRES_USER: {db_user}
|
|
88
|
+
POSTGRES_PASSWORD: {db_password}
|
|
89
|
+
ports:
|
|
90
|
+
- "{db_port}:5432"
|
|
91
|
+
volumes:
|
|
92
|
+
- narrator_postgres_data:/var/lib/postgresql/data
|
|
93
|
+
healthcheck:
|
|
94
|
+
test: ["CMD-SHELL", "pg_isready -U {db_user}"]
|
|
95
|
+
interval: 5s
|
|
96
|
+
timeout: 5s
|
|
97
|
+
retries: 5
|
|
98
|
+
|
|
99
|
+
volumes:
|
|
100
|
+
narrator_postgres_data:
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# Create a temporary directory for docker-compose.yml
|
|
104
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
105
|
+
compose_file = Path(tmpdir) / "docker-compose.yml"
|
|
106
|
+
compose_file.write_text(docker_compose_content)
|
|
107
|
+
|
|
108
|
+
# Check if docker is available
|
|
109
|
+
try:
|
|
110
|
+
subprocess.run(["docker", "--version"], capture_output=True, check=True)
|
|
111
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
112
|
+
click.echo("ā Docker is not installed or not available in PATH")
|
|
113
|
+
raise click.Abort()
|
|
114
|
+
|
|
115
|
+
# Check if docker-compose or docker compose is available
|
|
116
|
+
compose_cmd = None
|
|
117
|
+
try:
|
|
118
|
+
subprocess.run(["docker", "compose", "version"], capture_output=True, check=True)
|
|
119
|
+
compose_cmd = ["docker", "compose"]
|
|
120
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
121
|
+
try:
|
|
122
|
+
subprocess.run(["docker-compose", "version"], capture_output=True, check=True)
|
|
123
|
+
compose_cmd = ["docker-compose"]
|
|
124
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
125
|
+
click.echo("ā Docker Compose is not installed")
|
|
126
|
+
raise click.Abort()
|
|
127
|
+
|
|
128
|
+
# Start the container
|
|
129
|
+
click.echo("š¦ Starting PostgreSQL container...")
|
|
130
|
+
cmd = compose_cmd + ["up"]
|
|
131
|
+
if detach:
|
|
132
|
+
cmd.append("-d")
|
|
133
|
+
|
|
134
|
+
result = subprocess.run(cmd, cwd=tmpdir)
|
|
135
|
+
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
click.echo("ā Failed to start PostgreSQL container")
|
|
138
|
+
raise click.Abort()
|
|
139
|
+
|
|
140
|
+
if detach:
|
|
141
|
+
# Wait for PostgreSQL to be ready
|
|
142
|
+
click.echo("ā³ Waiting for PostgreSQL to be ready...")
|
|
143
|
+
for i in range(30):
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
["docker", "exec", "narrator-postgres", "pg_isready", "-U", db_user],
|
|
146
|
+
capture_output=True
|
|
147
|
+
)
|
|
148
|
+
if result.returncode == 0:
|
|
149
|
+
click.echo("ā
PostgreSQL is ready!")
|
|
150
|
+
click.echo(f"\nš Database available at:")
|
|
151
|
+
click.echo(f" postgresql+asyncpg://{db_user}:{db_password}@localhost:{db_port}/{db_name}")
|
|
152
|
+
return
|
|
153
|
+
time.sleep(1)
|
|
154
|
+
|
|
155
|
+
click.echo("ā PostgreSQL failed to start after 30 seconds")
|
|
156
|
+
raise click.Abort()
|
|
157
|
+
|
|
158
|
+
@main.command()
|
|
159
|
+
@click.option('--remove-volumes', is_flag=True, help='Remove data volumes (destroys all data)')
|
|
160
|
+
def docker_stop(remove_volumes):
|
|
161
|
+
"""Stop the PostgreSQL container"""
|
|
162
|
+
# Check if docker is available
|
|
163
|
+
try:
|
|
164
|
+
subprocess.run(["docker", "--version"], capture_output=True, check=True)
|
|
165
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
166
|
+
click.echo("ā Docker is not installed or not available in PATH")
|
|
167
|
+
raise click.Abort()
|
|
168
|
+
|
|
169
|
+
# Check if container exists
|
|
170
|
+
result = subprocess.run(
|
|
171
|
+
["docker", "ps", "-a", "--format", "{{.Names}}"],
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if "narrator-postgres" not in result.stdout:
|
|
177
|
+
click.echo("ā¹ļø No Narrator PostgreSQL container found")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
click.echo("š Stopping PostgreSQL container...")
|
|
181
|
+
|
|
182
|
+
# Stop the container
|
|
183
|
+
subprocess.run(["docker", "stop", "narrator-postgres"], check=False)
|
|
184
|
+
subprocess.run(["docker", "rm", "narrator-postgres"], check=False)
|
|
185
|
+
|
|
186
|
+
if remove_volumes:
|
|
187
|
+
click.echo("šļø Removing data volume...")
|
|
188
|
+
subprocess.run(["docker", "volume", "rm", "narrator_postgres_data"], check=False)
|
|
189
|
+
click.echo("ā
Container and data removed")
|
|
190
|
+
else:
|
|
191
|
+
click.echo("ā
Container stopped (data preserved)")
|
|
192
|
+
|
|
193
|
+
@main.command()
|
|
194
|
+
@click.option('--port', help='Port to expose PostgreSQL on (default: 5432 or NARRATOR_DB_PORT)')
|
|
195
|
+
def docker_setup(port):
|
|
196
|
+
"""One-command Docker setup: start PostgreSQL and initialize tables"""
|
|
197
|
+
# Start PostgreSQL
|
|
198
|
+
ctx = click.get_current_context()
|
|
199
|
+
ctx.invoke(docker_start, port=port, detach=True)
|
|
200
|
+
|
|
201
|
+
# Get database configuration from environment or defaults
|
|
202
|
+
db_name = os.environ.get('NARRATOR_DB_NAME', 'narrator')
|
|
203
|
+
db_user = os.environ.get('NARRATOR_DB_USER', 'narrator')
|
|
204
|
+
db_password = os.environ.get('NARRATOR_DB_PASSWORD', 'narrator_dev')
|
|
205
|
+
db_port = port or os.environ.get('NARRATOR_DB_PORT', '5432')
|
|
206
|
+
|
|
207
|
+
# Set up database URL
|
|
208
|
+
database_url = f"postgresql+asyncpg://{db_user}:{db_password}@localhost:{db_port}/{db_name}"
|
|
209
|
+
os.environ['NARRATOR_DATABASE_URL'] = database_url
|
|
210
|
+
|
|
211
|
+
# Initialize tables
|
|
212
|
+
click.echo("\nš§ Initializing database tables...")
|
|
213
|
+
ctx.invoke(init, database_url=database_url)
|
|
214
|
+
|
|
215
|
+
click.echo("\nš Setup complete! Your database is ready.")
|
|
216
|
+
click.echo("\nTo use in your code:")
|
|
217
|
+
click.echo(f'export NARRATOR_DATABASE_URL="{database_url}"')
|
|
218
|
+
click.echo("\nTo stop the container: narrator docker-stop")
|
|
219
|
+
click.echo("To remove all data: narrator docker-stop --remove-volumes")
|
|
220
|
+
|
|
221
|
+
if __name__ == '__main__':
|
|
222
|
+
main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from sqlalchemy.types import TypeDecorator, TEXT, JSON
|
|
3
|
+
|
|
4
|
+
"""Database models for SQLAlchemy"""
|
|
5
|
+
from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Integer
|
|
6
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
7
|
+
from sqlalchemy.orm import declarative_base
|
|
8
|
+
from sqlalchemy.orm import relationship
|
|
9
|
+
from datetime import datetime, UTC
|
|
10
|
+
|
|
11
|
+
class JSONBCompat(TypeDecorator):
|
|
12
|
+
impl = TEXT
|
|
13
|
+
cache_ok = True
|
|
14
|
+
|
|
15
|
+
def load_dialect_impl(self, dialect):
|
|
16
|
+
if dialect.name == 'postgresql':
|
|
17
|
+
return dialect.type_descriptor(JSONB())
|
|
18
|
+
else:
|
|
19
|
+
return dialect.type_descriptor(JSON())
|
|
20
|
+
|
|
21
|
+
def process_bind_param(self, value, dialect):
|
|
22
|
+
if dialect.name == 'postgresql':
|
|
23
|
+
return value
|
|
24
|
+
if value is not None:
|
|
25
|
+
return value
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
def process_result_value(self, value, dialect):
|
|
29
|
+
if dialect.name == 'postgresql':
|
|
30
|
+
return value
|
|
31
|
+
if value is not None:
|
|
32
|
+
return value
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
Base = declarative_base()
|
|
36
|
+
|
|
37
|
+
class ThreadRecord(Base):
|
|
38
|
+
__tablename__ = 'threads'
|
|
39
|
+
|
|
40
|
+
id = Column(String, primary_key=True)
|
|
41
|
+
title = Column(String, nullable=True)
|
|
42
|
+
attributes = Column(JSONBCompat, nullable=False, default={})
|
|
43
|
+
platforms = Column(JSONBCompat, nullable=True)
|
|
44
|
+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
45
|
+
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
|
46
|
+
|
|
47
|
+
messages = relationship("MessageRecord", back_populates="thread", cascade="all, delete-orphan")
|
|
48
|
+
|
|
49
|
+
class MessageRecord(Base):
|
|
50
|
+
__tablename__ = 'messages'
|
|
51
|
+
|
|
52
|
+
id = Column(String, primary_key=True)
|
|
53
|
+
thread_id = Column(String, ForeignKey('threads.id', ondelete='CASCADE'), nullable=False)
|
|
54
|
+
sequence = Column(Integer, nullable=False)
|
|
55
|
+
turn = Column(Integer, nullable=True)
|
|
56
|
+
role = Column(String, nullable=False)
|
|
57
|
+
content = Column(Text, nullable=True)
|
|
58
|
+
reasoning_content = Column(Text, nullable=True)
|
|
59
|
+
name = Column(String, nullable=True)
|
|
60
|
+
tool_call_id = Column(String, nullable=True)
|
|
61
|
+
tool_calls = Column(JSONBCompat, nullable=True)
|
|
62
|
+
attributes = Column(JSONBCompat, nullable=False, default={})
|
|
63
|
+
timestamp = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
64
|
+
source = Column(JSONBCompat, nullable=True)
|
|
65
|
+
platforms = Column(JSONBCompat, nullable=True)
|
|
66
|
+
attachments = Column(JSONBCompat, nullable=True)
|
|
67
|
+
metrics = Column(JSONBCompat, nullable=False, default={})
|
|
68
|
+
reactions = Column(JSONBCompat, nullable=True)
|
|
69
|
+
|
|
70
|
+
thread = relationship("ThreadRecord", back_populates="messages")
|