Implements the parallel.md workstreams (Agents A-E) toward REFACTOR_GOAL.md. Phase 1 — app factory + blueprints + /api/v1: - app.py -> create_app() factory (no module-level app); entrypoints updated - routes/ (auth, pages, settings, admin, assets) + blueprints/api.py at /api/v1 - config.py / extensions.py / security.py extracted; services/ layer added - endpoint names preserved so template url_for() calls keep resolving (static check: all 27 template url_for endpoints are defined routes) Phase 2 — one pluggable filter system: - filter_pipeline/registry.py: @register_stage / @register_plugin + discover_modules - engine._init_stages() instantiates registered stages (no hardcoded dict); process_batch is AI-aware: only short-circuits to the AI-disabled path when a filterset's stages declare requires_ai, so offline filtersets run with AI off - BaseFilterPlugin gets a consumer (stages/plugins.py); Keyword/Quality re-enabled via filter_config.json plugins config - comment tree modes ported to stages/comment_filter.py + shared rules.py; wired into /api/v1/posts/<uuid> and /api/v1/comments/<uuid> via FilterEngine.filter_comments() (fails open) - offline quality_filter filterset exercises plugins+ranker without AI - legacy filter_lib / comment_lib / html_generation_lib / generate_html / active_html path deleted Phase 3 prep — pluggable fetchers + Postgres models: - Post / Comment SQLAlchemy models added to models.py - migrate_content_to_db.py backfill (idempotent by uuid, batched, --dry-run) - platforms/ fetcher registry (extension point) - live reads/writes still go through PostService (disk JSON); cutover deferred Phase 6 — test harness: - pytest.ini + tests/ (conftest with in-memory SQLite fixture, no Postgres; stubbed polling/filter singletons) - test_app_factory.py (route registration, no module-level app), test_api_contracts.py (posts/post_detail/comments/filters shape with monkeypatched post_service + get_filter_engine), test_filter_pipeline.py + test_plugin_contract.py (Flask-free; validated locally 12/12 incl. drop-in stage/plugin discovered with zero core edits) Other: .gitignore added (__pycache__, data/, secrets); pytest in requirements. Verification: py_compile clean across the project; the Flask-free filter-pipeline and plugin-contract tests pass locally. App-factory / API-contract tests need deps+docker to run; runtime flask routes / Auth0-repeated-create_app also gated on docker. Co-Authored-By: Claude <noreply@anthropic.com>
169 lines
5.4 KiB
Python
169 lines
5.4 KiB
Python
#!/usr/bin/env python
|
|
"""Backfill on-disk content JSON into the Postgres ``posts``/``comments`` tables.
|
|
|
|
Phase 3 prep (Agent D): reads ``data/posts/*.json`` and ``data/comments/*.json``
|
|
and upserts them into the ``Post`` / ``Comment`` models. Live reads/writes still
|
|
go through ``PostService`` (disk JSON) — this script only populates the DB so a
|
|
later cutover has data to read. It is idempotent: existing rows are skipped by
|
|
``uuid`` (run it again after collecting new content to backfill only the new
|
|
files).
|
|
|
|
Usage::
|
|
|
|
python migrate_content_to_db.py [--data-dir data] [--batch-size 500] [--dry-run]
|
|
|
|
Requires the full Flask/Postgres stack (``DATABASE_URL`` or the POSTGRES_*
|
|
env vars) — run inside the docker compose environment, not locally.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from app import create_app
|
|
from database import db
|
|
from models import Comment, Post
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
|
)
|
|
logger = logging.getLogger("migrate_content_to_db")
|
|
|
|
|
|
def _load_json(path: Path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning("Skipping unreadable file %s: %s", path, e)
|
|
return None
|
|
|
|
|
|
def _existing_uuids(model, uuids):
|
|
"""Return the subset of ``uuids`` already present in the table."""
|
|
if not uuids:
|
|
return set()
|
|
found = set()
|
|
# Chunk to avoid huge IN clauses.
|
|
for i in range(0, len(uuids), 500):
|
|
chunk = uuids[i:i + 500]
|
|
rows = db.session.query(model.uuid).filter(model.uuid.in_(chunk)).all()
|
|
found.update(r[0] for r in rows)
|
|
return found
|
|
|
|
|
|
def backfill_posts(posts_dir: Path, batch_size: int, dry_run: bool) -> int:
|
|
files = sorted(posts_dir.glob("*.json")) if posts_dir.exists() else []
|
|
if not files:
|
|
logger.info("No post files found in %s", posts_dir)
|
|
return 0
|
|
|
|
records = []
|
|
for pf in files:
|
|
data = _load_json(pf)
|
|
if not data or not data.get("uuid"):
|
|
continue
|
|
records.append(data)
|
|
|
|
seen = _existing_uuids(Post, [r["uuid"] for r in records])
|
|
inserted = 0
|
|
batch = []
|
|
for r in records:
|
|
if r["uuid"] in seen:
|
|
continue
|
|
batch.append(Post(
|
|
uuid=r["uuid"],
|
|
external_id=r.get("id"),
|
|
platform=r.get("platform", "") or "",
|
|
source=r.get("source", "") or "",
|
|
title=(r.get("title") or "")[:500],
|
|
author=r.get("author"),
|
|
url=r.get("url"),
|
|
content=r.get("content"),
|
|
score=int(r.get("score", 0) or 0),
|
|
timestamp=int(r.get("timestamp", 0) or 0),
|
|
tags=r.get("tags"),
|
|
moderation_uuid=r.get("moderation_uuid"),
|
|
))
|
|
inserted += 1
|
|
if len(batch) >= batch_size:
|
|
_flush(batch, dry_run)
|
|
batch = []
|
|
_flush(batch, dry_run)
|
|
logger.info("Posts: backfilled %d new (%d already present)", inserted, len(seen))
|
|
return inserted
|
|
|
|
|
|
def backfill_comments(comments_dir: Path, batch_size: int, dry_run: bool) -> int:
|
|
files = sorted(comments_dir.glob("*.json")) if comments_dir.exists() else []
|
|
if not files:
|
|
logger.info("No comment files found in %s", comments_dir)
|
|
return 0
|
|
|
|
records = []
|
|
for cf in files:
|
|
data = _load_json(cf)
|
|
if not data or not data.get("uuid"):
|
|
continue
|
|
records.append(data)
|
|
|
|
seen = _existing_uuids(Comment, [r["uuid"] for r in records])
|
|
inserted = 0
|
|
batch = []
|
|
for r in records:
|
|
if r["uuid"] in seen:
|
|
continue
|
|
batch.append(Comment(
|
|
uuid=r["uuid"],
|
|
post_uuid=r.get("post_uuid") or "",
|
|
platform=r.get("platform"),
|
|
parent_comment_uuid=r.get("parent_comment_uuid"),
|
|
comment_id=r.get("comment_id"),
|
|
author=r.get("author"),
|
|
content=r.get("content"),
|
|
score=int(r.get("score", 0) or 0),
|
|
timestamp=int(r.get("timestamp", 0) or 0),
|
|
depth=int(r.get("depth", 0) or 0),
|
|
moderation_uuid=r.get("moderation_uuid"),
|
|
))
|
|
inserted += 1
|
|
if len(batch) >= batch_size:
|
|
_flush(batch, dry_run)
|
|
batch = []
|
|
_flush(batch, dry_run)
|
|
logger.info("Comments: backfilled %d new (%d already present)", inserted, len(seen))
|
|
return inserted
|
|
|
|
|
|
def _flush(batch, dry_run):
|
|
if not batch:
|
|
return
|
|
if dry_run:
|
|
logger.info("[dry-run] would insert %d rows", len(batch))
|
|
return
|
|
db.session.bulk_save_objects(batch)
|
|
db.session.commit()
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description="Backfill content JSON into Postgres.")
|
|
parser.add_argument("--data-dir", default="data", help="Root data directory.")
|
|
parser.add_argument("--batch-size", type=int, default=500, help="Insert batch size.")
|
|
parser.add_argument("--dry-run", action="store_true", help="Log counts without writing.")
|
|
args = parser.parse_args(argv)
|
|
|
|
app = create_app()
|
|
with app.app_context():
|
|
posts_dir = Path(args.data_dir) / "posts"
|
|
comments_dir = Path(args.data_dir) / "comments"
|
|
p = backfill_posts(posts_dir, args.batch_size, args.dry_run)
|
|
c = backfill_comments(comments_dir, args.batch_size, args.dry_run)
|
|
logger.info("Done. posts=%d comments=%d", p, c)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|