Phase 1-3 + 6: pluggable filter system, app factory, test harness

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>
This commit is contained in:
2026-07-03 02:29:46 -05:00
parent cdba720a1c
commit 6cf35ca034
64 changed files with 3902 additions and 3776 deletions

View File

@@ -171,16 +171,6 @@
</div>
</div>
</div>
<div class="admin-section">
<h3 class="section-title">Content Actions</h3>
<form method="POST" action="{{ url_for('admin_regenerate_content') }}">
<button type="submit" class="btn btn-primary">Regenerate All Content</button>
<p style="margin-top: 8px; font-size: 0.85rem; color: var(--text-secondary);">
This will regenerate all HTML files with current templates and filters.
</p>
</form>
</div>
</div>
<!-- System Tab -->

View File

@@ -136,7 +136,7 @@ async function loadBookmarks(page = 1) {
try {
document.getElementById('loading').style.display = 'block';
const response = await fetch(`/api/bookmarks?page=${page}&per_page=20`);
const response = await fetch(`/api/v1/bookmarks?page=${page}&per_page=20`);
const data = await response.json();
if (!response.ok) {
@@ -226,7 +226,7 @@ async function removeBookmark(postId, button) {
button.disabled = true;
button.textContent = 'Removing...';
const response = await fetch('/api/bookmark', {
const response = await fetch('/api/v1/bookmark', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -708,7 +708,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Load platform configuration and communities
async function loadPlatformConfig() {
try {
const response = await fetch('/api/platforms');
const response = await fetch('/api/v1/platforms');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -736,7 +736,7 @@ async function loadPlatformConfig() {
// Load available filters
async function loadFilters() {
try {
const response = await fetch('/api/filters');
const response = await fetch('/api/v1/filters');
const data = await response.json();
filtersData = data.filters || [];
@@ -832,7 +832,7 @@ async function loadPosts(page = 1, community = '', platform = '', append = false
if (filter || currentFilter) params.append('filter', filter || currentFilter);
if (currentSearchQuery) params.append('q', currentSearchQuery);
const response = await fetch(`/api/posts?${params}`);
const response = await fetch(`/api/v1/posts?${params}`);
const data = await response.json();
const newPosts = data.posts || [];
paginationData = data.pagination || {};
@@ -1195,7 +1195,7 @@ function setupAutoRefresh() {
if (currentPage === 1 && !currentCommunity && !currentPlatform) {
try {
// Check if new content is available by checking timestamp
const response = await fetch('/api/content-timestamp');
const response = await fetch('/api/v1/content-timestamp');
const data = await response.json();
const lastContentUpdate = data.timestamp;