pluckai / load_to_postgres.py
Anishss333's picture
clean repo assets
b92e027
Raw
History Blame Contribute Delete
10 kB
"""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()