[SOMA][MERGE] feat/infra → dev

This commit is contained in:
Ramakrishna Mamidi
2026-03-23 01:38:05 +05:30
19 changed files with 1026 additions and 1 deletions

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ uploads/
local_docs/
soma.code-workspace
soma-bot3.code-workspace

47
soma/.env.example Normal file
View File

@@ -0,0 +1,47 @@
# SOMA Environment Variables
# Copy to .env on your server. Never commit .env to any repository.
# ─── App ──────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 32
NEXTAUTH_SECRET=
NEXTAUTH_URL=https://yourdomain.com
ALLOW_REGISTRATION=true
# Generate with: openssl rand -hex 32
JWT_SECRET=
# ─── Database ─────────────────────────────────────────────────────────
DATABASE_URL=postgresql+asyncpg://soma:somapass@postgres:5432/somadb
POSTGRES_USER=soma
POSTGRES_PASSWORD=somapass
POSTGRES_DB=somadb
# ─── Redis ────────────────────────────────────────────────────────────
REDIS_URL=redis://redis:6379/0
# ─── LLM — Personal (internal Docker network, NEVER changes) ──────────
# Personal data stays on your server. This must always point to Ollama.
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=phi3:mini
# ─── LLM — Documents (book/PDF queries only, no personal context) ─────
GEMINI_API_KEY=
# ─── LLM — Research (web queries only, no personal context) ───────────
PERPLEXITY_API_KEY=
# ─── LLM — General (factual queries only, no personal context) ────────
GROQ_API_KEY=
# ─── Email ────────────────────────────────────────────────────────────
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=ap-south-1
SES_FROM_EMAIL=noreply@yourdomain.com
REPORT_TO_EMAIL=your@gmail.com
# ─── Config ───────────────────────────────────────────────────────────
SOMA_CONFIG_PATH=/app/config.yaml
# ─── Rate Limiting ────────────────────────────────────────────────────
LOGIN_RATE_LIMIT_ATTEMPTS=5
LOGIN_RATE_LIMIT_WINDOW=900

70
soma/Makefile Normal file
View File

@@ -0,0 +1,70 @@
.PHONY: up down logs restart build migrate seed ssl backup ollama-pull shell-backend shell-db
# Default: start all containers
up:
docker compose up -d
# Start and rebuild images
build:
docker compose up -d --build
# Stop all containers
down:
docker compose down
# Stop and remove all data volumes (DESTRUCTIVE — prompts for confirmation)
destroy:
@echo "WARNING: This will delete ALL data including the database. Type YES to confirm:"
@read confirm && [ "$$confirm" = "YES" ] || (echo "Aborted." && exit 1)
docker compose down -v
# View logs (all services, follow)
logs:
docker compose logs -f
# View logs for a specific service: make logs-backend
logs-%:
docker compose logs -f $*
# Restart a service: make restart-backend
restart-%:
docker compose restart $*
# Run database migrations
migrate:
docker compose exec backend alembic upgrade head
# Seed tasks from markdown file
# Usage: make seed FILE=/path/to/life_plan.md
seed:
docker compose exec backend python /app/scripts/seed_from_markdown.py --file $(FILE)
# Obtain/renew SSL certificate
# Usage: make ssl DOMAIN=rk.singularraritylabs.com EMAIL=your@email.com
ssl:
docker compose stop nginx
certbot certonly --standalone -d $(DOMAIN) --email $(EMAIL) --agree-tos --non-interactive
docker compose start nginx
# Manual database backup to S3
backup:
docker compose exec backend bash /app/scripts/backup_db.sh
# Pull Ollama model (run once after first deploy)
ollama-pull:
docker compose exec ollama ollama pull phi3:mini
# Shell access
shell-backend:
docker compose exec backend bash
shell-db:
docker compose exec postgres psql -U soma -d somadb
# Health check
health:
curl -s http://localhost/health | python3 -m json.tool
# Show container status
status:
docker compose ps

1
soma/README.md Normal file
View File

@@ -0,0 +1 @@
see docs/TID.md

30
soma/backend/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM python:3.11-slim
# System deps
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies before copying app code
# (layer cache: dependencies change less often than code)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create uploads directory
RUN mkdir -p /app/uploads
# Non-root user for security
RUN addgroup --system appgroup && adduser --system --group appgroup
RUN chown -R appgroup:appgroup /app
USER appgroup
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

41
soma/backend/alembic.ini Normal file
View File

@@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,3 @@
# SOMA Backend
# Phase 0 completion signal — this file's existence indicates the skeleton is ready.
# Bot 1 and Bot 2 may now begin parallel work.

View File

@@ -0,0 +1,22 @@
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy[asyncio]==2.0.35
alembic==1.13.0
asyncpg==0.29.0
redis[hiredis]==5.0.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pyotp==2.9.0
qrcode[pil]==7.4.2
pdfplumber==0.11.0
ollama==0.3.0
google-generativeai==0.8.0
groq==0.9.0
httpx==0.27.0
apscheduler==3.10.4
boto3==1.34.0
python-multipart==0.0.9
pydantic-settings==2.4.0
chromadb==0.5.0
psycopg2-binary==2.9.9
pytz==2024.1

95
soma/config.example.yaml Normal file
View File

@@ -0,0 +1,95 @@
# SOMA Configuration
# Copy to config.yaml and fill in your values.
# This file drives all personal behavior. Keep config.yaml private.
app:
name: "SOMA"
tagline: "Self-Organizing Mastery Architecture"
url: "https://yourdomain.com"
timezone: "Asia/Kolkata" # pytz timezone string
user:
display_name: "Your Name" # shown on dashboard greeting
email: "your@email.com"
llm:
personal:
provider: "ollama" # DO NOT CHANGE — personal data only
model: "phi3:mini" # phi3:mini recommended for t3.small RAM budget
base_url: "http://ollama:11434"
document:
provider: "gemini"
model: "gemini-1.5-flash-latest"
research:
provider: "perplexity"
model: "llama-3.1-sonar-large-128k-online"
general:
provider: "groq"
model: "llama-3.3-70b-versatile"
email:
provider: "ses" # ses | smtp
from_address: "noreply@yourdomain.com"
reports:
daily_time: "07:00" # IST, 24h format
weekly_day: "friday"
weekly_time: "18:00" # IST
monthly_day: 1
monthly_time: "08:00" # IST
kanban:
project_tags:
- name: "general"
color: "#6B7280"
# Add your project tags:
# - name: "openclaw"
# color: "#3b82f6"
# - name: "mak"
# color: "#a855f7"
# - name: "rarity-media"
# color: "#f97316"
# - name: "japan"
# color: "#ef4444"
# - name: "germany"
# color: "#22c55e"
# - name: "personal"
# color: "#14b8a6"
# - name: "habits"
# color: "#eab308"
seed_file: "" # absolute path to your life plan .md file
gamification:
enabled: true
xp:
task_low: 25
task_medium: 50
task_high: 100
task_critical: 200
journal_entry: 30
journal_streak_bonus: 100 # awarded every 7-day streak
habit_log: 20
habit_streak_bonus: 300 # awarded every 30-day streak
habits:
tracked: []
# Uncomment and customize:
# - name: "japanese" # must match habit_name used in API calls
# display: "Japanese Study" # shown in UI
# icon: "🗾"
# daily_xp: 20
# - name: "guitar"
# display: "Guitar Practice"
# icon: "🎸"
# daily_xp: 20
# - name: "exercise"
# display: "Exercise"
# icon: "💪"
# daily_xp: 20
reports:
include_quotes: true
quote_pool:
- "Small daily improvements over time lead to stunning results."
- "The quality of your life is determined by the quality of your daily habits."
- "Success is the sum of small efforts, repeated day in and day out."
- "Don't count the days. Make the days count."

View File

@@ -0,0 +1,21 @@
# Copy to docker-compose.override.yml for local development
# This file is gitignored
version: "3.9"
services:
backend:
volumes:
- ./backend:/app # live reload in development
environment:
- PYTHONDONTWRITEBYTECODE=1
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
volumes:
- ./frontend:/app # live reload in development
- /app/node_modules # preserve container node_modules
- /app/.next # preserve build cache
environment:
- NODE_ENV=development
command: npm run dev

153
soma/docker-compose.yml Normal file
View File

@@ -0,0 +1,153 @@
version: "3.9"
networks:
soma_network:
driver: bridge
volumes:
postgres_data:
redis_data:
ollama_data:
chroma_data:
uploads_data:
services:
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
networks:
- soma_network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
environment:
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- BACKEND_URL=http://backend:8000
- NEXT_PUBLIC_API_URL=/api/v1
depends_on:
backend:
condition: service_healthy
networks:
- soma_network
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- JWT_SECRET=${JWT_SECRET}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-phi3:mini}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- PERPLEXITY_API_KEY=${PERPLEXITY_API_KEY}
- GROQ_API_KEY=${GROQ_API_KEY}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_REGION=${AWS_REGION:-ap-south-1}
- SES_FROM_EMAIL=${SES_FROM_EMAIL}
- REPORT_TO_EMAIL=${REPORT_TO_EMAIL}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
- LOGIN_RATE_LIMIT_ATTEMPTS=${LOGIN_RATE_LIMIT_ATTEMPTS:-5}
- LOGIN_RATE_LIMIT_WINDOW=${LOGIN_RATE_LIMIT_WINDOW:-900}
- SOMA_CONFIG_PATH=/app/config.yaml
volumes:
- ./config.yaml:/app/config.yaml:ro
- uploads_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- soma_network
postgres:
image: postgres:15-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER:-soma}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-somapass}
- POSTGRES_DB=${POSTGRES_DB:-somadb}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-soma} -d ${POSTGRES_DB:-somadb}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- soma_network
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- soma_network
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
environment:
- OLLAMA_HOST=0.0.0.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks:
- soma_network
chromadb:
image: chromadb/chroma
restart: unless-stopped
volumes:
- chroma_data:/chroma/.chroma/index
environment:
- IS_PERSISTENT=TRUE
- ANONYMIZED_TELEMETRY=FALSE
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/heartbeat"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- soma_network

0
soma/docs/.gitkeep Normal file
View File

33
soma/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,56 @@
{
"name": "soma-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"next-auth": "5.0.0-beta",
"next-pwa": "5.6.0",
"@tanstack/react-query": "5.0.0",
"zustand": "4.5.0",
"framer-motion": "11.0.0",
"react-hot-toast": "2.4.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/react": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"eventsource-parser": "^1.1.2",
"date-fns": "^3.6.0",
"lucide-react": "^0.400.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0",
"class-variance-authority": "^0.7.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-sheet": "^1.0.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.0",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-skeleton": "^1.0.0"
},
"devDependencies": {
"typescript": "5.4.0",
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"tailwindcss": "3.4.0",
"postcss": "^8.4.38",
"autoprefixer": "^10.4.19",
"@tailwindcss/typography": "^0.5.13",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.0"
}
}

101
soma/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,101 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
client_max_body_size 50M; # PDF uploads up to 50MB
# Gzip
gzip on;
gzip_types text/plain application/json application/javascript text/css;
# Security headers applied to all responses
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always;
upstream frontend {
server frontend:3000;
}
upstream backend {
server backend:8000;
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# Main HTTPS server
server {
listen 443 ssl http2;
server_name _; # replace with your domain
ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self';" always;
# Health check — no auth, no log
location = /health {
proxy_pass http://backend;
proxy_set_header Host $host;
access_log off;
}
# Backend API — all /api/v1/* routes
location /api/v1/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE streaming — disable buffering
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s; # long timeout for SSE streams
}
# Frontend — everything else
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Static assets cache
location /_next/static/ {
proxy_pass http://frontend;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
}

0
soma/nginx/ssl/.gitkeep Normal file
View File

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
admin_recovery.py
Emergency TOTP reset tool. Run ONLY via SSH on the EC2 instance.
This script is NOT exposed via any HTTP endpoint.
It resets the TOTP secret for the admin account, prints a new QR code
and backup codes, which must be scanned in Google Authenticator.
Usage (on EC2):
docker compose exec backend python /app/scripts/admin_recovery.py
Or directly:
ssh ubuntu@your-ec2-ip
cd ~/soma
docker compose exec backend python /app/scripts/admin_recovery.py
"""
import asyncio
import sys
import os
import secrets
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
async def reset_totp():
from app.database import AsyncSessionLocal
from app.models.user import User
from app.config import settings
from sqlalchemy import select
import pyotp
import qrcode
import io
async with AsyncSessionLocal() as db:
# Find the admin user (single-tenant: only one user exists)
result = await db.execute(select(User).limit(1))
user = result.scalar_one_or_none()
if not user:
print("ERROR: No user found in database. Run the setup flow first.")
sys.exit(1)
print(f"\nResetting TOTP for: {user.email}")
print("=" * 60)
# Confirm action
confirm = input("Type 'RESET' to confirm TOTP reset: ").strip()
if confirm != "RESET":
print("Aborted.")
sys.exit(0)
# Generate new TOTP secret
new_secret = pyotp.random_base32()
# Generate new backup codes (8 codes, 8 chars each)
backup_codes = [secrets.token_hex(4).upper() for _ in range(8)]
# Generate QR code URL
totp = pyotp.TOTP(new_secret)
provisioning_uri = totp.provisioning_uri(
name=user.email,
issuer_name="SOMA"
)
# Print QR code as ASCII art (requires 'qrcode' library)
try:
qr = qrcode.QRCode(version=1, box_size=1, border=1)
qr.add_data(provisioning_uri)
qr.make(fit=True)
print("\nScan this QR code with Google Authenticator / Authy:")
print("=" * 60)
qr.print_ascii(invert=True)
print("=" * 60)
except Exception:
print(f"\nProvisioning URI (paste into authenticator app):")
print(provisioning_uri)
print(f"\nManual entry secret: {new_secret}")
print("\nBACKUP CODES (save these offline — one-time use):")
print("-" * 40)
for i, code in enumerate(backup_codes, 1):
print(f" {i:2}. {code}")
print("-" * 40)
# Verify the new TOTP code before saving
print("\nEnter a TOTP code from your authenticator app to verify setup:")
test_code = input("6-digit code: ").strip()
if not totp.verify(test_code, valid_window=1):
print("ERROR: TOTP code verification failed. TOTP NOT reset.")
sys.exit(1)
# Save to database
user.totp_secret = new_secret
user.totp_backup_codes = backup_codes # stored as plain text; hash in production if desired
# Invalidate all existing sessions
from app.models.user import AuthSession
from sqlalchemy import update
await db.execute(
update(AuthSession)
.where(AuthSession.user_id == user.id)
.values(is_active=False)
)
await db.commit()
print("\nTOTP reset successful. All existing sessions have been invalidated.")
print("Log in at your SOMA URL to start a new session.")
if __name__ == '__main__':
asyncio.run(reset_totp())

60
soma/scripts/backup_db.sh Normal file
View File

@@ -0,0 +1,60 @@
#!/bin/bash
# backup_db.sh
# Dumps SOMA PostgreSQL database, compresses it, uploads to S3 with timestamp.
# Run inside the backend container or on the EC2 host with Docker access.
#
# Required environment variables (from .env):
# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB
# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
# S3_BACKUP_BUCKET (set this in .env — not in TID default, add it)
#
# Usage: bash /app/scripts/backup_db.sh
set -euo pipefail
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_DIR="/tmp/soma_backups"
FILENAME="somadb_${TIMESTAMP}.sql.gz"
FILEPATH="${BACKUP_DIR}/${FILENAME}"
# Defaults if env not set
POSTGRES_USER="${POSTGRES_USER:-soma}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-somapass}"
POSTGRES_DB="${POSTGRES_DB:-somadb}"
POSTGRES_HOST="${POSTGRES_HOST:-postgres}"
S3_BUCKET="${S3_BACKUP_BUCKET:-soma-backups}"
mkdir -p "${BACKUP_DIR}"
echo "[backup] Starting database dump at ${TIMESTAMP}"
# Dump and compress
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
-h "${POSTGRES_HOST}" \
-U "${POSTGRES_USER}" \
-d "${POSTGRES_DB}" \
--no-password \
--format=plain \
--clean \
--if-exists \
| gzip > "${FILEPATH}"
FILESIZE=$(du -sh "${FILEPATH}" | cut -f1)
echo "[backup] Dump complete: ${FILENAME} (${FILESIZE})"
# Upload to S3
if command -v aws &> /dev/null; then
S3_KEY="backups/${TIMESTAMP:0:6}/${FILENAME}"
aws s3 cp "${FILEPATH}" "s3://${S3_BUCKET}/${S3_KEY}" \
--sse AES256 \
--region "${AWS_REGION:-ap-south-1}"
echo "[backup] Uploaded to s3://${S3_BUCKET}/${S3_KEY}"
else
echo "[backup] WARNING: aws CLI not found. Backup saved locally at ${FILEPATH}"
echo "[backup] Install aws CLI or add boto3 call to upload manually."
fi
# Keep only last 7 local backups
find "${BACKUP_DIR}" -name "somadb_*.sql.gz" -mtime +7 -delete
echo "[backup] Done."

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
seed_from_markdown.py
Reads a Markdown file, extracts all unchecked task items (- [ ] lines),
parses optional metadata from the same line or the preceding heading,
and inserts them as tasks into the SOMA database.
Usage:
python seed_from_markdown.py --file /path/to/life_plan.md [--dry-run]
Format recognized:
# Project Name
## Sub-section
- [ ] Task title @tag #priority due:2026-04-01
- [x] Already done task (skipped)
Metadata extraction:
@tag → maps to project_tag (e.g., @openclaw, @mak)
#priority → maps to priority (low|medium|high|critical)
due:YYYY-MM-DD → maps to due_date
Default tag: derived from nearest parent heading (slugified, max 50 chars)
Default priority: medium
"""
import argparse
import re
import sys
import os
import asyncio
from datetime import date
# Add parent directory to path so we can import app modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
def parse_markdown_tasks(filepath: str) -> list[dict]:
"""Parse all unchecked [ ] tasks from a markdown file."""
tasks = []
current_tag = "general"
current_section = ""
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in lines:
line = line.rstrip()
# Track headings for default tag
heading_match = re.match(r'^#{1,3}\s+(.+)$', line)
if heading_match:
heading_text = heading_match.group(1).strip()
# Slugify heading: lowercase, replace spaces/special chars with -
slug = re.sub(r'[^a-z0-9]+', '-', heading_text.lower()).strip('-')[:50]
# Map common heading words to known tags
tag_map = {
'openclaw': 'openclaw', 'mak': 'mak', 'rarity': 'rarity-media',
'japan': 'japan', 'germany': 'germany', 'personal': 'personal',
'habit': 'habits', 'retreat': 'retreat'
}
mapped = False
for keyword, tag in tag_map.items():
if keyword in heading_text.lower():
current_tag = tag
mapped = True
break
if not mapped:
current_tag = slug if slug else "general"
current_section = heading_text
continue
# Match unchecked task: - [ ] or * [ ]
task_match = re.match(r'^\s*[-*]\s+\[ \]\s+(.+)$', line)
if not task_match:
continue
raw_title = task_match.group(1).strip()
# Extract metadata from title
tag = current_tag
priority = "medium"
due_date = None
# @tag
tag_match = re.search(r'@(\S+)', raw_title)
if tag_match:
tag = tag_match.group(1)
raw_title = raw_title.replace(tag_match.group(0), '').strip()
# #priority
priority_match = re.search(r'#(low|medium|high|critical)', raw_title, re.IGNORECASE)
if priority_match:
priority = priority_match.group(1).lower()
raw_title = raw_title.replace(priority_match.group(0), '').strip()
# due:YYYY-MM-DD
due_match = re.search(r'due:(\d{4}-\d{2}-\d{2})', raw_title)
if due_match:
try:
due_date = due_match.group(1)
date.fromisoformat(due_date) # validate
except ValueError:
due_date = None
raw_title = raw_title.replace(due_match.group(0), '').strip()
# Clean up extra whitespace
title = re.sub(r'\s+', ' ', raw_title).strip()
if not title:
continue
# XP based on priority
xp_map = {'low': 25, 'medium': 50, 'high': 100, 'critical': 200}
tasks.append({
'title': title,
'description': f"Imported from: {current_section}" if current_section else None,
'status': 'backlog',
'priority': priority,
'project_tag': tag,
'due_date': due_date,
'xp_reward': xp_map[priority],
'position': 0,
'checklist': [],
})
return tasks
async def insert_tasks(tasks: list[dict], dry_run: bool = False):
"""Insert parsed tasks into the database."""
from app.database import AsyncSessionLocal
from app.models.task import Task
import uuid
if dry_run:
print(f"DRY RUN: Would insert {len(tasks)} tasks:")
for i, t in enumerate(tasks, 1):
print(f" {i:3}. [{t['priority'].upper():8}] [{t['project_tag']:20}] {t['title']}")
return
async with AsyncSessionLocal() as db:
inserted = 0
skipped = 0
for i, task_data in enumerate(tasks):
# Set position based on insertion order within each status+tag
task_data['position'] = i
task = Task(id=uuid.uuid4(), **task_data)
db.add(task)
inserted += 1
await db.commit()
print(f"Inserted {inserted} tasks, skipped {skipped}.")
def main():
parser = argparse.ArgumentParser(description='Seed SOMA tasks from a Markdown file')
parser.add_argument('--file', required=True, help='Path to the Markdown file')
parser.add_argument('--dry-run', action='store_true', help='Parse only, do not insert')
args = parser.parse_args()
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
print(f"Parsing: {args.file}")
tasks = parse_markdown_tasks(args.file)
print(f"Found {len(tasks)} unchecked tasks")
if not tasks:
print("No tasks found. Check that the file contains '- [ ] ' lines.")
sys.exit(0)
asyncio.run(insert_tasks(tasks, dry_run=args.dry_run))
if __name__ == '__main__':
main()