Compare commits
3 Commits
bb0e793e70
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac679bf482 | ||
|
|
6c31105113 | ||
|
|
f857845dd2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ uploads/
|
|||||||
local_docs/
|
local_docs/
|
||||||
|
|
||||||
soma.code-workspace
|
soma.code-workspace
|
||||||
|
soma-bot3.code-workspace
|
||||||
|
|||||||
47
soma/.env.example
Normal file
47
soma/.env.example
Normal 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
70
soma/Makefile
Normal 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
1
soma/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
see docs/TID.md
|
||||||
30
soma/backend/Dockerfile
Normal file
30
soma/backend/Dockerfile
Normal 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
41
soma/backend/alembic.ini
Normal 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
|
||||||
3
soma/backend/app/__init__.py
Normal file
3
soma/backend/app/__init__.py
Normal 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.
|
||||||
22
soma/backend/requirements.txt
Normal file
22
soma/backend/requirements.txt
Normal 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
95
soma/config.example.yaml
Normal 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."
|
||||||
21
soma/docker-compose.override.yml.example
Normal file
21
soma/docker-compose.override.yml.example
Normal 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
153
soma/docker-compose.yml
Normal 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
0
soma/docs/.gitkeep
Normal file
33
soma/frontend/Dockerfile
Normal file
33
soma/frontend/Dockerfile
Normal 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"]
|
||||||
56
soma/frontend/package.json
Normal file
56
soma/frontend/package.json
Normal 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
101
soma/nginx/nginx.conf
Normal 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
0
soma/nginx/ssl/.gitkeep
Normal file
114
soma/scripts/admin_recovery.py
Normal file
114
soma/scripts/admin_recovery.py
Normal 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
60
soma/scripts/backup_db.sh
Normal 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."
|
||||||
177
soma/scripts/seed_from_markdown.py
Normal file
177
soma/scripts/seed_from_markdown.py
Normal 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()
|
||||||
Reference in New Issue
Block a user