Spaces:
Runtime error
Runtime error
| """Load plan and enriched content JSON files into PostgreSQL tables.""" | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from typing import Dict, List | |
| import psycopg | |
| LOGGER = logging.getLogger(__name__) | |
| SCHEMA_SQL = """ | |
| CREATE EXTENSION IF NOT EXISTS pgcrypto; | |
| CREATE TABLE IF NOT EXISTS learning_days ( | |
| day INT PRIMARY KEY, | |
| title TEXT NOT NULL, | |
| goal TEXT, | |
| estimated_minutes INT, | |
| topics JSONB NOT NULL | |
| ); | |
| CREATE TABLE IF NOT EXISTS day_content ( | |
| day INT PRIMARY KEY REFERENCES learning_days(day) ON DELETE CASCADE, | |
| overview TEXT, | |
| key_points JSONB, | |
| flashcards JSONB, | |
| practice JSONB, | |
| reflection_prompt TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS books ( | |
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| slug TEXT NOT NULL UNIQUE, | |
| title TEXT NOT NULL, | |
| description TEXT, | |
| cover_url TEXT, | |
| default_days INT, | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| CREATE TABLE IF NOT EXISTS book_topics ( | |
| id BIGSERIAL PRIMARY KEY, | |
| book_id UUID NOT NULL REFERENCES books(id) ON DELETE CASCADE, | |
| chapter_index INT NOT NULL, | |
| topic_index INT NOT NULL, | |
| payload JSONB NOT NULL, | |
| UNIQUE (book_id, chapter_index, topic_index) | |
| ); | |
| CREATE TABLE IF NOT EXISTS book_content ( | |
| id BIGSERIAL PRIMARY KEY, | |
| book_id UUID NOT NULL REFERENCES books(id) ON DELETE CASCADE, | |
| day INT NOT NULL, | |
| payload JSONB NOT NULL, | |
| UNIQUE (book_id, day) | |
| ); | |
| CREATE TABLE IF NOT EXISTS users ( | |
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| email TEXT NOT NULL UNIQUE, | |
| display_name TEXT, | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| CREATE TABLE IF NOT EXISTS study_plans ( | |
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| book_id UUID NOT NULL REFERENCES books(id) ON DELETE CASCADE, | |
| total_days INT NOT NULL, | |
| minutes_per_day INT, | |
| focus TEXT, | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| is_template BOOLEAN NOT NULL DEFAULT FALSE, | |
| template_key TEXT, | |
| template_parent_id UUID REFERENCES study_plans(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS plan_days ( | |
| id BIGSERIAL PRIMARY KEY, | |
| plan_id UUID NOT NULL REFERENCES study_plans(id) ON DELETE CASCADE, | |
| day_number INT NOT NULL, | |
| payload JSONB NOT NULL, | |
| UNIQUE (plan_id, day_number) | |
| ); | |
| CREATE TABLE IF NOT EXISTS plan_day_content ( | |
| id BIGSERIAL PRIMARY KEY, | |
| plan_day_id BIGINT NOT NULL UNIQUE REFERENCES plan_days(id) ON DELETE CASCADE, | |
| content JSONB NOT NULL | |
| ); | |
| CREATE UNIQUE INDEX IF NOT EXISTS idx_plan_day_content_plan_day_id | |
| ON plan_day_content(plan_day_id); | |
| ALTER TABLE study_plans | |
| ADD COLUMN IF NOT EXISTS is_template BOOLEAN NOT NULL DEFAULT FALSE; | |
| ALTER TABLE study_plans | |
| ADD COLUMN IF NOT EXISTS template_key TEXT; | |
| ALTER TABLE study_plans | |
| ADD COLUMN IF NOT EXISTS template_parent_id UUID REFERENCES study_plans(id); | |
| CREATE UNIQUE INDEX IF NOT EXISTS idx_study_plans_template_key_unique | |
| ON study_plans(template_key) | |
| WHERE is_template AND template_key IS NOT NULL; | |
| """ | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Persist plan and daily content into PostgreSQL") | |
| parser.add_argument("--plan", default="artifacts/plan.json", help="Path to plan JSON file") | |
| parser.add_argument( | |
| "--content", | |
| default="artifacts/daily_content.json", | |
| help="Path to enriched daily content JSON file", | |
| ) | |
| parser.add_argument( | |
| "--syllabus", | |
| default="artifacts/syllabus.json", | |
| help="Path to syllabus JSON file for book topic seeding", | |
| ) | |
| parser.add_argument( | |
| "--database-url", | |
| default=os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/learning"), | |
| help="PostgreSQL connection string", | |
| ) | |
| parser.add_argument("--book-slug", help="Slug identifier for the book (enables book seeding)") | |
| parser.add_argument("--book-title", help="Override title for the book row") | |
| parser.add_argument("--book-description", help="Optional marketing description") | |
| parser.add_argument("--book-cover-url", help="Optional cover image URL") | |
| parser.add_argument("--default-days", type=int, help="Default days suggested for the book") | |
| return parser.parse_args() | |
| def configure_logging() -> None: | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| def load_json(path: Path) -> Dict: | |
| if not path.exists(): | |
| raise FileNotFoundError(f"File not found: {path}") | |
| return json.loads(path.read_text(encoding="utf-8")) | |
| def ensure_schema(conn: psycopg.Connection) -> None: | |
| with conn.cursor() as cur: | |
| cur.execute(SCHEMA_SQL) | |
| conn.commit() | |
| def upsert_book(cur: psycopg.Cursor, args: argparse.Namespace, plan: Dict) -> str | None: | |
| if not args.book_slug: | |
| return None | |
| title = args.book_title or plan.get("book_title") or args.book_slug.replace("-", " ").title() | |
| description = args.book_description | |
| cover_url = args.book_cover_url | |
| default_days = args.default_days or len(plan.get("days", [])) or None | |
| cur.execute( | |
| """ | |
| INSERT INTO books (slug, title, description, cover_url, default_days) | |
| VALUES (%s, %s, %s, %s, %s) | |
| ON CONFLICT (slug) | |
| DO UPDATE SET | |
| title = EXCLUDED.title, | |
| description = EXCLUDED.description, | |
| cover_url = EXCLUDED.cover_url, | |
| default_days = EXCLUDED.default_days | |
| RETURNING id; | |
| """, | |
| (args.book_slug, title, description, cover_url, default_days), | |
| ) | |
| book_id = cur.fetchone()[0] | |
| return book_id | |
| def seed_book_topics(cur: psycopg.Cursor, book_id: str, syllabus_path: Path) -> None: | |
| if not book_id or not syllabus_path.exists(): | |
| return | |
| syllabus = load_json(syllabus_path) | |
| chapters = syllabus.get("chapters", []) | |
| for chapter_idx, chapter in enumerate(chapters, start=1): | |
| topics = chapter.get("topics", []) | |
| for topic_idx, topic in enumerate(topics, start=1): | |
| payload = json.dumps( | |
| { | |
| "title": chapter.get("title"), | |
| "topic": topic, | |
| }, | |
| ensure_ascii=False, | |
| ) | |
| cur.execute( | |
| """ | |
| INSERT INTO book_topics (book_id, chapter_index, topic_index, payload) | |
| VALUES (%s, %s, %s, %s::jsonb) | |
| ON CONFLICT (book_id, chapter_index, topic_index) | |
| DO UPDATE SET payload = EXCLUDED.payload; | |
| """, | |
| (book_id, chapter_idx, topic_idx, payload), | |
| ) | |
| def seed_book_content(cur: psycopg.Cursor, book_id: str, plan: Dict, enriched_index: Dict[int, Dict]) -> None: | |
| if not book_id: | |
| return | |
| for day in plan.get("days", []): | |
| day_number = day.get("day") | |
| payload = json.dumps( | |
| { | |
| "plan": day, | |
| "content": enriched_index.get(day_number), | |
| }, | |
| ensure_ascii=False, | |
| ) | |
| cur.execute( | |
| """ | |
| INSERT INTO book_content (book_id, day, payload) | |
| VALUES (%s, %s, %s::jsonb) | |
| ON CONFLICT (book_id, day) | |
| DO UPDATE SET payload = EXCLUDED.payload; | |
| """, | |
| (book_id, day_number, payload), | |
| ) | |
| def upsert_learning_day(cur: psycopg.Cursor, day: Dict) -> None: | |
| cur.execute( | |
| """ | |
| INSERT INTO learning_days (day, title, goal, estimated_minutes, topics) | |
| VALUES (%s, %s, %s, %s, %s::jsonb) | |
| ON CONFLICT (day) | |
| DO UPDATE SET | |
| title = EXCLUDED.title, | |
| goal = EXCLUDED.goal, | |
| estimated_minutes = EXCLUDED.estimated_minutes, | |
| topics = EXCLUDED.topics; | |
| """, | |
| ( | |
| day["day"], | |
| day.get("title"), | |
| day.get("goal"), | |
| day.get("estimated_minutes"), | |
| json.dumps(day.get("topics", []), ensure_ascii=False), | |
| ), | |
| ) | |
| def upsert_day_content(cur: psycopg.Cursor, day: int, content: Dict | None) -> None: | |
| payload = content or {} | |
| cur.execute( | |
| """ | |
| INSERT INTO day_content (day, overview, key_points, flashcards, practice, reflection_prompt) | |
| VALUES (%s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s) | |
| ON CONFLICT (day) | |
| DO UPDATE SET | |
| overview = EXCLUDED.overview, | |
| key_points = EXCLUDED.key_points, | |
| flashcards = EXCLUDED.flashcards, | |
| practice = EXCLUDED.practice, | |
| reflection_prompt = EXCLUDED.reflection_prompt; | |
| """, | |
| ( | |
| day, | |
| payload.get("overview"), | |
| json.dumps(payload.get("key_points", []), ensure_ascii=False), | |
| json.dumps(payload.get("flashcards", []), ensure_ascii=False), | |
| json.dumps(payload.get("practice", {}), ensure_ascii=False), | |
| payload.get("reflection_prompt"), | |
| ), | |
| ) | |
| def main() -> None: | |
| configure_logging() | |
| args = parse_args() | |
| plan = load_json(Path(args.plan)) | |
| enriched = load_json(Path(args.content)) if Path(args.content).exists() else {"days": []} | |
| enriched_index = {entry.get("day"): entry for entry in enriched.get("days", [])} | |
| LOGGER.info("Connecting to %s", args.database_url) | |
| with psycopg.connect(args.database_url) as conn: | |
| ensure_schema(conn) | |
| with conn.cursor() as cur: | |
| book_id = upsert_book(cur, args, plan) | |
| seed_book_topics(cur, book_id, Path(args.syllabus)) | |
| seed_book_content(cur, book_id, plan, enriched_index) | |
| for day in plan.get("days", []): | |
| upsert_learning_day(cur, day) | |
| upsert_day_content(cur, day.get("day"), enriched_index.get(day.get("day"))) | |
| conn.commit() | |
| LOGGER.info("Load complete: %s days persisted", len(plan.get("days", []))) | |
| if __name__ == "__main__": | |
| main() | |