AuthorBot commited on
Commit
b38f95c
Β·
1 Parent(s): cc73112

Update configurations and prepare for HF deployment

Browse files
Dockerfile DELETED
@@ -1,79 +0,0 @@
1
- # ============================================================
2
- # AuthorBot RAG API β€” Dockerfile (HuggingFace Spaces optimized)
3
- # ============================================================
4
- # Multi-stage build:
5
- # Stage 1: Build dependencies (cached layer)
6
- # Stage 2: Lean production image (~900MB)
7
- #
8
- # HuggingFace Spaces constraints:
9
- # - Must run on port 7860 or configure app_port in README.md
10
- # - No GPU by default (CPU-only BART + CrossEncoder)
11
- # - Persistent storage at /data (HF Datasets mount)
12
- # - Environment vars set via HF Space secrets
13
- # ============================================================
14
-
15
- FROM python:3.11-slim AS builder
16
-
17
- # System dependencies for compiled packages
18
- RUN apt-get update && apt-get install -y --no-install-recommends \
19
- build-essential \
20
- libpq-dev \
21
- curl \
22
- git \
23
- && rm -rf /var/lib/apt/lists/*
24
-
25
- WORKDIR /app
26
-
27
- # Copy and install Python dependencies (cached if requirements.txt unchanged)
28
- COPY backend/requirements.txt .
29
- RUN pip install --no-cache-dir --upgrade pip && \
30
- pip install --no-cache-dir -r requirements.txt
31
-
32
- # ── Production Stage ──────────────────────────────────────────
33
- FROM python:3.11-slim
34
-
35
- # Install runtime system deps + Redis server (runs inside container)
36
- RUN apt-get update && apt-get install -y --no-install-recommends \
37
- libpq5 \
38
- curl \
39
- redis-server \
40
- libmagic1 \
41
- && rm -rf /var/lib/apt/lists/*
42
-
43
- WORKDIR /app
44
-
45
- # Copy installed packages from builder
46
- COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
47
- COPY --from=builder /usr/local/bin /usr/local/bin
48
-
49
- # Copy backend application code
50
- COPY backend/ .
51
-
52
- # Create necessary directories (including /data for SQLite persistent storage)
53
- RUN mkdir -p /app/uploads /app/data/chroma /app/data/geo /app/logs /data
54
-
55
- # HuggingFace Spaces: run as non-root user (uid 1000)
56
- RUN adduser --disabled-password --gecos "" --uid 1000 appuser && \
57
- chown -R appuser:appuser /app /data
58
-
59
- # Configure Redis to not require root and use /data for persistence
60
- RUN mkdir -p /data/redis && chown -R appuser:appuser /data/redis
61
- RUN echo "dir /data/redis\nbind 127.0.0.1\nport 6379\ndaemonize yes\nlogfile /dev/null" > /app/redis.conf
62
-
63
- USER appuser
64
-
65
- # Pre-download models at build time (avoids cold-start delays)
66
- # These are small enough to include in the image
67
- RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" || true
68
- RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')" || true
69
-
70
- # Health check
71
- HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
72
- CMD curl -f http://localhost:8080/health || exit 1
73
-
74
- # HuggingFace Spaces port
75
- EXPOSE 8080
76
-
77
- # Startup: create redis data dir β†’ start Redis β†’ run Alembic migrations β†’ start Uvicorn
78
- CMD ["sh", "-c", "mkdir -p /data/redis && redis-server /app/redis.conf && sleep 1 && alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8080 --workers 2 --loop uvloop --http httptools"]
79
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,13 +1,3 @@
1
- ---
2
- title: AuthorBot RAG API
3
- emoji: ✨
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- app_port: 8080
9
- ---
10
-
11
  # AuthorBot RAG Chatbot SaaS
12
  ### Production-grade AI book advisor for author websites
13
 
 
 
 
 
 
 
 
 
 
 
 
1
  # AuthorBot RAG Chatbot SaaS
2
  ### Production-grade AI book advisor for author websites
3
 
backend/alembic.ini CHANGED
@@ -1,41 +1,5 @@
1
- [alembic]
2
  version_locations = %(here)s/versions
3
  script_location = alembic
4
  # URL is overridden at runtime by alembic/env.py using get_settings().DATABASE_URL
5
  # Do NOT hardcode credentials here β€” this file is committed to git
6
- sqlalchemy.url = sqlite+aiosqlite:////data/db.sqlite
7
-
8
- # Logging configuration
9
- [loggers]
10
- keys = root,sqlalchemy,alembic
11
-
12
- [handlers]
13
- keys = console
14
-
15
- [formatters]
16
- keys = generic
17
-
18
- [logger_root]
19
- level = WARN
20
- handlers = console
21
- qualname =
22
-
23
- [logger_sqlalchemy]
24
- level = WARN
25
- handlers =
26
- qualname = sqlalchemy.engine
27
-
28
- [logger_alembic]
29
- level = INFO
30
- handlers =
31
- qualname = alembic
32
-
33
- [handler_console]
34
- class = StreamHandler
35
- args = (sys.stderr,)
36
- level = NOTSET
37
- formatter = generic
38
-
39
- [formatter_generic]
40
- format = %(levelname)-5.5s [%(name)s] %(message)s
41
- datefmt = %H:%M:%S
 
 
1
  version_locations = %(here)s/versions
2
  script_location = alembic
3
  # URL is overridden at runtime by alembic/env.py using get_settings().DATABASE_URL
4
  # Do NOT hardcode credentials here β€” this file is committed to git
5
+ sqlalchemy.url = postgresql+asyncpg://placeholder:placeholder@localhost/placeholder
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/alembic/env.py CHANGED
@@ -1,13 +1,8 @@
1
  """Author RAG Chatbot SaaS β€” Alembic Migration Environment."""
2
 
3
  import asyncio
4
- import os
5
- import sys
6
  from logging.config import fileConfig
7
 
8
- # Ensure the root directory is in sys.path so 'app' can be imported
9
- sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
10
-
11
  from alembic import context
12
  from sqlalchemy import pool
13
  from sqlalchemy.ext.asyncio import async_engine_from_config
 
1
  """Author RAG Chatbot SaaS β€” Alembic Migration Environment."""
2
 
3
  import asyncio
 
 
4
  from logging.config import fileConfig
5
 
 
 
 
6
  from alembic import context
7
  from sqlalchemy import pool
8
  from sqlalchemy.ext.asyncio import async_engine_from_config
backend/alembic/versions/001_initial_schema.py CHANGED
@@ -30,7 +30,7 @@ def upgrade() -> None:
30
  sa.Column("id", sa.String(36), primary_key=True),
31
  sa.Column("email", sa.String(255), nullable=False, unique=True),
32
  sa.Column("password_hash", sa.String(255), nullable=False),
33
- sa.Column("role", sa.String(20), nullable=False, server_default="author"),
34
  sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
35
  sa.Column("failed_login_attempts", sa.Integer, nullable=False, server_default="0"),
36
  sa.Column("locked_until", sa.String(50), nullable=True),
@@ -47,7 +47,7 @@ def upgrade() -> None:
47
  server_default="Hi! I'm here to help you find your next great read."),
48
  sa.Column("fallback_message", sa.String(500), nullable=False,
49
  server_default="I want to give you the most accurate answer. Could you rephrase that?"),
50
- sa.Column("response_style", sa.String(20),
51
  nullable=False, server_default="balanced"),
52
  sa.Column("chatbot_is_active", sa.Boolean, nullable=False, server_default="true"),
53
  sa.Column("widget_theme", sa.String(50), nullable=False, server_default="indigo"),
@@ -138,7 +138,7 @@ def upgrade() -> None:
138
  sa.Column("id", sa.String(36), primary_key=True),
139
  sa.Column("author_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
140
  sa.Column("granted_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
141
- sa.Column("plan", sa.String(20), nullable=False),
142
  sa.Column("granted_at", sa.DateTime(timezone=True), nullable=False),
143
  sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
144
  sa.Column("token_hash", sa.String(255), nullable=False, unique=True),
@@ -273,4 +273,7 @@ def downgrade() -> None:
273
  op.drop_table("documents")
274
  op.drop_table("books")
275
  op.drop_table("users")
276
-
 
 
 
 
30
  sa.Column("id", sa.String(36), primary_key=True),
31
  sa.Column("email", sa.String(255), nullable=False, unique=True),
32
  sa.Column("password_hash", sa.String(255), nullable=False),
33
+ sa.Column("role", sa.Enum("author", "superadmin", name="user_role"), nullable=False, server_default="author"),
34
  sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
35
  sa.Column("failed_login_attempts", sa.Integer, nullable=False, server_default="0"),
36
  sa.Column("locked_until", sa.String(50), nullable=True),
 
47
  server_default="Hi! I'm here to help you find your next great read."),
48
  sa.Column("fallback_message", sa.String(500), nullable=False,
49
  server_default="I want to give you the most accurate answer. Could you rephrase that?"),
50
+ sa.Column("response_style", sa.Enum("concise", "balanced", "detailed", name="response_style"),
51
  nullable=False, server_default="balanced"),
52
  sa.Column("chatbot_is_active", sa.Boolean, nullable=False, server_default="true"),
53
  sa.Column("widget_theme", sa.String(50), nullable=False, server_default="indigo"),
 
138
  sa.Column("id", sa.String(36), primary_key=True),
139
  sa.Column("author_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
140
  sa.Column("granted_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
141
+ sa.Column("plan", sa.Enum("monthly", "quarterly", "semi_annual", "annual", name="subscription_plan"), nullable=False),
142
  sa.Column("granted_at", sa.DateTime(timezone=True), nullable=False),
143
  sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
144
  sa.Column("token_hash", sa.String(255), nullable=False, unique=True),
 
273
  op.drop_table("documents")
274
  op.drop_table("books")
275
  op.drop_table("users")
276
+ # Drop enums manually (Postgres keeps them after table drops)
277
+ op.execute("DROP TYPE IF EXISTS user_role")
278
+ op.execute("DROP TYPE IF EXISTS response_style")
279
+ op.execute("DROP TYPE IF EXISTS subscription_plan")
backend/app/config.py CHANGED
@@ -8,7 +8,7 @@ Import `get_settings()` wherever configuration is needed.
8
  from functools import lru_cache
9
  from typing import Literal
10
 
11
- from pydantic import EmailStr, Field, field_validator
12
  from pydantic_settings import BaseSettings, SettingsConfigDict
13
 
14
 
@@ -26,19 +26,21 @@ class Settings(BaseSettings):
26
  APP_NAME: str = "AuthorBot"
27
  APP_ENV: Literal["development", "staging", "production"] = "development"
28
  DEBUG: bool = False
29
- SECRET_KEY: str = Field(default="change-me-set-a-real-secret-key-in-hf-secrets-32chars") # HMAC signing key and JWT secret
30
- JWT_ALGORITHM: str = "HS256"
 
 
31
  ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
32
  REFRESH_TOKEN_EXPIRE_DAYS: int = 7
33
  MAX_CONCURRENT_SESSIONS: int = 3
34
 
35
  # ─── Database ─────────────────────────────────────────
36
- DATABASE_URL: str = Field(default="sqlite+aiosqlite:////data/db.sqlite")
37
  DB_POOL_SIZE: int = 10
38
  DB_MAX_OVERFLOW: int = 20
39
 
40
  # ─── Redis ────────────────────────────────────────────
41
- REDIS_URL: str = Field(default="redis://localhost:6379/0")
42
  REDIS_DECODE_RESPONSES: bool = True
43
 
44
  # ─── ChromaDB ─────────────────────────────────────────
@@ -47,7 +49,7 @@ class Settings(BaseSettings):
47
  CHROMA_PERSIST_DIR: str = "./chroma_data"
48
 
49
  # ─── OpenAI ───────────────────────────────────────────
50
- OPENAI_API_KEY: str = Field(default="")
51
  OPENAI_CHAT_MODEL: str = "gpt-4o" # Never change without approval
52
  OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
53
  OPENAI_MAX_RETRIES: int = 3
@@ -81,10 +83,10 @@ class Settings(BaseSettings):
81
  SMTP_HOST: str = "smtp.gmail.com"
82
  SMTP_PORT: int = 465
83
  SMTP_USE_SSL: bool = True
84
- SMTP_USERNAME: EmailStr | None = None
85
- SMTP_PASSWORD: str | None = None
86
  EMAIL_FROM_NAME: str = "AuthorBot"
87
- EMAIL_FROM_ADDRESS: EmailStr | None = None
88
 
89
  # ─── Rate Limiting ────────────────────────────────────
90
  RATE_LIMIT_CHAT_PER_MINUTE: int = 60
 
8
  from functools import lru_cache
9
  from typing import Literal
10
 
11
+ from pydantic import EmailStr, Field, PostgresDsn, RedisDsn, field_validator
12
  from pydantic_settings import BaseSettings, SettingsConfigDict
13
 
14
 
 
26
  APP_NAME: str = "AuthorBot"
27
  APP_ENV: Literal["development", "staging", "production"] = "development"
28
  DEBUG: bool = False
29
+ SECRET_KEY: str = Field(..., min_length=32) # HMAC signing key
30
+ JWT_PRIVATE_KEY: str = Field(...) # RS256 private key (PEM)
31
+ JWT_PUBLIC_KEY: str = Field(...) # RS256 public key (PEM)
32
+ JWT_ALGORITHM: str = "RS256"
33
  ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
34
  REFRESH_TOKEN_EXPIRE_DAYS: int = 7
35
  MAX_CONCURRENT_SESSIONS: int = 3
36
 
37
  # ─── Database ─────────────────────────────────────────
38
+ DATABASE_URL: PostgresDsn = Field(...)
39
  DB_POOL_SIZE: int = 10
40
  DB_MAX_OVERFLOW: int = 20
41
 
42
  # ─── Redis ────────────────────────────────────────────
43
+ REDIS_URL: RedisDsn = Field(...)
44
  REDIS_DECODE_RESPONSES: bool = True
45
 
46
  # ─── ChromaDB ─────────────────────────────────────────
 
49
  CHROMA_PERSIST_DIR: str = "./chroma_data"
50
 
51
  # ─── OpenAI ───────────────────────────────────────────
52
+ OPENAI_API_KEY: str = Field(...)
53
  OPENAI_CHAT_MODEL: str = "gpt-4o" # Never change without approval
54
  OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
55
  OPENAI_MAX_RETRIES: int = 3
 
83
  SMTP_HOST: str = "smtp.gmail.com"
84
  SMTP_PORT: int = 465
85
  SMTP_USE_SSL: bool = True
86
+ SMTP_USERNAME: EmailStr = Field(...)
87
+ SMTP_PASSWORD: str = Field(...) # Gmail App Password (not account pw)
88
  EMAIL_FROM_NAME: str = "AuthorBot"
89
+ EMAIL_FROM_ADDRESS: EmailStr = Field(...)
90
 
91
  # ─── Rate Limiting ────────────────────────────────────
92
  RATE_LIMIT_CHAT_PER_MINUTE: int = 60
backend/app/core/access/token_crypto.py CHANGED
@@ -44,7 +44,7 @@ def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None
44
  "type": "access",
45
  **(extra_claims or {}),
46
  }
47
- return jwt.encode(payload, cfg.SECRET_KEY, algorithm=cfg.JWT_ALGORITHM)
48
 
49
 
50
  def create_refresh_token(subject: str) -> str:
@@ -64,7 +64,7 @@ def create_refresh_token(subject: str) -> str:
64
  "exp": expire,
65
  "type": "refresh",
66
  }
67
- return jwt.encode(payload, cfg.SECRET_KEY, algorithm=cfg.JWT_ALGORITHM)
68
 
69
 
70
  def decode_jwt(token: str) -> dict[str, Any]:
@@ -81,7 +81,7 @@ def decode_jwt(token: str) -> dict[str, Any]:
81
  InvalidTokenError: If the token is malformed or signature is invalid.
82
  """
83
  try:
84
- payload = jwt.decode(token, cfg.SECRET_KEY, algorithms=[cfg.JWT_ALGORITHM])
85
  return payload
86
  except JWTError as e:
87
  error_msg = str(e).lower()
 
44
  "type": "access",
45
  **(extra_claims or {}),
46
  }
47
+ return jwt.encode(payload, cfg.JWT_PRIVATE_KEY, algorithm=cfg.JWT_ALGORITHM)
48
 
49
 
50
  def create_refresh_token(subject: str) -> str:
 
64
  "exp": expire,
65
  "type": "refresh",
66
  }
67
+ return jwt.encode(payload, cfg.JWT_PRIVATE_KEY, algorithm=cfg.JWT_ALGORITHM)
68
 
69
 
70
  def decode_jwt(token: str) -> dict[str, Any]:
 
81
  InvalidTokenError: If the token is malformed or signature is invalid.
82
  """
83
  try:
84
+ payload = jwt.decode(token, cfg.JWT_PUBLIC_KEY, algorithms=[cfg.JWT_ALGORITHM])
85
  return payload
86
  except JWTError as e:
87
  error_msg = str(e).lower()
backend/app/dependencies.py CHANGED
@@ -20,20 +20,13 @@ logger = structlog.get_logger(__name__)
20
 
21
  def _create_engine(cfg: Settings):
22
  """Create SQLAlchemy async engine from settings."""
23
- db_url = str(cfg.DATABASE_URL)
24
- is_sqlite = db_url.startswith("sqlite")
25
-
26
- engine_kwargs = {
27
- "echo": cfg.DEBUG,
28
- "pool_pre_ping": True,
29
- }
30
-
31
- if not is_sqlite:
32
- # Connection pool settings only apply to server-based databases
33
- engine_kwargs["pool_size"] = cfg.DB_POOL_SIZE
34
- engine_kwargs["max_overflow"] = cfg.DB_MAX_OVERFLOW
35
-
36
- return create_async_engine(db_url, **engine_kwargs)
37
 
38
 
39
  def _create_session_factory(cfg: Settings) -> async_sessionmaker:
 
20
 
21
  def _create_engine(cfg: Settings):
22
  """Create SQLAlchemy async engine from settings."""
23
+ return create_async_engine(
24
+ str(cfg.DATABASE_URL),
25
+ pool_size=cfg.DB_POOL_SIZE,
26
+ max_overflow=cfg.DB_MAX_OVERFLOW,
27
+ echo=cfg.DEBUG,
28
+ pool_pre_ping=True,
29
+ )
 
 
 
 
 
 
 
30
 
31
 
32
  def _create_session_factory(cfg: Settings) -> async_sessionmaker:
backend/app/models/client_access.py CHANGED
@@ -7,7 +7,7 @@ Revocation is instant via Redis blacklist + this record.
7
 
8
  from datetime import datetime
9
 
10
- from sqlalchemy import Boolean, DateTime, Integer, String, Text
11
  from sqlalchemy import ForeignKey
12
  from sqlalchemy.orm import Mapped, mapped_column, relationship
13
 
@@ -27,7 +27,7 @@ class ClientAccess(Base, TimestampMixin):
27
  String(36), ForeignKey("users.id"), nullable=False
28
  )
29
  plan: Mapped[str] = mapped_column(
30
- String(20),
31
  nullable=False,
32
  )
33
  granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
 
7
 
8
  from datetime import datetime
9
 
10
+ from sqlalchemy import Boolean, DateTime, Enum, Integer, String, Text
11
  from sqlalchemy import ForeignKey
12
  from sqlalchemy.orm import Mapped, mapped_column, relationship
13
 
 
27
  String(36), ForeignKey("users.id"), nullable=False
28
  )
29
  plan: Mapped[str] = mapped_column(
30
+ Enum("monthly", "quarterly", "semi_annual", "annual", name="subscription_plan"),
31
  nullable=False,
32
  )
33
  granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
backend/app/models/user.py CHANGED
@@ -4,7 +4,7 @@ Represents both Author (client) and SuperAdmin accounts.
4
  Role is enforced at the middleware and service layers.
5
  """
6
 
7
- from sqlalchemy import Boolean, Integer, String
8
  from sqlalchemy.orm import Mapped, mapped_column, relationship
9
 
10
  from app.models.base import Base, TimestampMixin, generate_uuid
@@ -19,7 +19,7 @@ class User(Base, TimestampMixin):
19
  email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
20
  password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
21
  role: Mapped[str] = mapped_column(
22
- String(20),
23
  nullable=False,
24
  default="author",
25
  )
@@ -48,7 +48,7 @@ class User(Base, TimestampMixin):
48
  nullable=False,
49
  )
50
  response_style: Mapped[str] = mapped_column(
51
- String(20),
52
  default="balanced",
53
  nullable=False,
54
  )
 
4
  Role is enforced at the middleware and service layers.
5
  """
6
 
7
+ from sqlalchemy import Boolean, Enum, Integer, String
8
  from sqlalchemy.orm import Mapped, mapped_column, relationship
9
 
10
  from app.models.base import Base, TimestampMixin, generate_uuid
 
19
  email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
20
  password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
21
  role: Mapped[str] = mapped_column(
22
+ Enum("author", "superadmin", name="user_role"),
23
  nullable=False,
24
  default="author",
25
  )
 
48
  nullable=False,
49
  )
50
  response_style: Mapped[str] = mapped_column(
51
+ Enum("concise", "balanced", "detailed", name="response_style"),
52
  default="balanced",
53
  nullable=False,
54
  )
backend/requirements.txt CHANGED
@@ -13,7 +13,6 @@ pydantic-settings==2.2.1
13
  # ── Database ──────────────────────────────────────────────
14
  sqlalchemy[asyncio]==2.0.30
15
  asyncpg==0.29.0
16
- aiosqlite==0.20.0
17
  alembic==1.13.1
18
  greenlet==3.0.3
19
 
 
13
  # ── Database ──────────────────────────────────────────────
14
  sqlalchemy[asyncio]==2.0.30
15
  asyncpg==0.29.0
 
16
  alembic==1.13.1
17
  greenlet==3.0.3
18