Skip to content

Commit 4c73cce

Browse files
committed
refactor: replace pipeline with layered app runtime
Move API and CLI onto a shared composition root so runtime, media, RSS, and SQLite concerns flow through explicit use cases and adapters instead of a monolithic pipeline.
1 parent 87ea3fd commit 4c73cce

47 files changed

Lines changed: 2985 additions & 770 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

PROJECT_STRUCTURE.md

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Project Structure
22

3-
All source files are in the workspace root. Layering is preserved by filename prefix.
3+
The app now uses a packaged runtime architecture in `video_rss_aggregator/`, with a small set of root-level adapters and CLI entrypoints kept for compatibility.
44

55
/.gitignore
66
/.data/
@@ -14,8 +14,59 @@ All source files are in the workspace root. Layering is preserved by filename pr
1414
/service_ollama.py
1515
/service_transcribe.py
1616
/service_summarize.py
17-
/service_pipeline.py
1817
/adapter_gui.py
1918
/adapter_api.py
2019
/adapter_rss.py
2120
/adapter_storage.py
21+
/video_rss_aggregator/
22+
/__init__.py
23+
/api.py
24+
/bootstrap.py
25+
/rss.py
26+
/storage.py
27+
/application/
28+
/__init__.py
29+
/ports.py
30+
/use_cases/
31+
/__init__.py
32+
/ingest_feed.py
33+
/process_source.py
34+
/render_rss_feed.py
35+
/runtime.py
36+
/domain/
37+
/__init__.py
38+
/models.py
39+
/outcomes.py
40+
/publication.py
41+
/infrastructure/
42+
/__init__.py
43+
/feed_source.py
44+
/media_service.py
45+
/publication_renderer.py
46+
/runtime_adapters.py
47+
/sqlite_repositories.py
48+
/summarizer.py
49+
/tests/
50+
/adapters/
51+
/test_api_app.py
52+
/test_cli_commands.py
53+
/application/
54+
/test_ingest_feed.py
55+
/test_process_source.py
56+
/test_render_rss_feed.py
57+
/test_runtime_use_cases.py
58+
/domain/
59+
/test_outcomes.py
60+
/infrastructure/
61+
/test_feed_source.py
62+
/test_legacy_adapter_shims.py
63+
/test_media_service.py
64+
/test_publication_renderer.py
65+
/test_runtime_adapters.py
66+
/test_sqlite_repositories.py
67+
/test_summarizer.py
68+
/test_api_setup.py
69+
/test_config.py
70+
/test_ollama.py
71+
/test_project_layout.py
72+
/test_summarize_helpers.py

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ This project has been rebuilt around Qwen 3.5 multimodal models and a strict loc
77
- Storage: SQLite (`.data/vra.db`)
88
- API: FastAPI
99

10+
## Architecture
11+
12+
- `video_rss_aggregator/` contains the current runtime architecture.
13+
- `video_rss_aggregator/bootstrap.py` composes the application runtime and use cases.
14+
- `video_rss_aggregator/application/` holds use-case orchestration and ports.
15+
- `video_rss_aggregator/domain/` defines the core models and outcome types.
16+
- `video_rss_aggregator/infrastructure/` contains SQLite, RSS, media, summarization, and runtime adapters.
17+
- Root modules such as `adapter_api.py`, `adapter_rss.py`, `adapter_storage.py`, and `cli.py` remain as compatibility and entry-point surfaces around the packaged runtime.
18+
1019
## Design Goals
1120

1221
- Use Qwen 3.5 vision-capable small models for summarization quality.

adapter_api.py

Lines changed: 15 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,23 @@
11
from __future__ import annotations
22

3-
import platform
4-
import shutil
5-
import sys
6-
from datetime import datetime, timezone
7-
from importlib.util import find_spec
8-
9-
from fastapi import Depends, FastAPI, Header, HTTPException, Query
10-
from fastapi.responses import HTMLResponse, Response
11-
from pydantic import BaseModel
12-
13-
from adapter_gui import render_setup_page
143
from core_config import Config
15-
from service_media import runtime_dependency_report
16-
from service_pipeline import Pipeline
17-
18-
19-
class IngestRequest(BaseModel):
20-
feed_url: str
21-
process: bool = False
22-
max_items: int | None = None
23-
24-
25-
class ProcessRequest(BaseModel):
26-
source_url: str
27-
title: str | None = None
28-
29-
30-
def create_app(pipeline: Pipeline, config: Config) -> FastAPI:
31-
app = FastAPI(title="Video RSS Aggregator", version="0.1.0")
32-
33-
def _check_auth(
34-
authorization: str | None = Header(None), x_api_key: str | None = Header(None)
35-
):
36-
if config.api_key is None:
37-
return
38-
token = None
39-
if authorization:
40-
parts = authorization.split()
41-
if len(parts) == 2 and parts[0].lower() == "bearer":
42-
token = parts[1]
43-
if token is None:
44-
token = x_api_key
45-
if token != config.api_key:
46-
raise HTTPException(status_code=401, detail="unauthorized")
47-
48-
@app.get("/health")
49-
async def health():
50-
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
51-
52-
@app.get("/", response_class=HTMLResponse)
53-
async def setup_home():
54-
return render_setup_page(config)
55-
56-
@app.get("/setup/config")
57-
async def setup_config():
58-
return {
59-
"bind_address": f"{config.bind_host}:{config.bind_port}",
60-
"storage_dir": config.storage_dir,
61-
"database_path": config.database_path,
62-
"ollama_base_url": config.ollama_base_url,
63-
"model_priority": list(config.model_priority),
64-
"vram_budget_mb": config.vram_budget_mb,
65-
"model_selection_reserve_mb": config.model_selection_reserve_mb,
66-
"max_frames": config.max_frames,
67-
"frame_scene_detection": config.frame_scene_detection,
68-
"frame_scene_threshold": config.frame_scene_threshold,
69-
"frame_scene_min_frames": config.frame_scene_min_frames,
70-
"api_key_required": config.api_key is not None,
71-
"quick_commands": {
72-
"bootstrap": "python -m vra bootstrap",
73-
"status": "python -m vra status",
74-
"serve": "python -m vra serve --bind 127.0.0.1:8080",
75-
},
76-
}
77-
78-
@app.get("/setup/diagnostics")
79-
async def setup_diagnostics():
80-
media_tools = runtime_dependency_report()
81-
yt_dlp_cmd = shutil.which("yt-dlp")
82-
ytdlp = {
83-
"command": yt_dlp_cmd,
84-
"module_available": find_spec("yt_dlp") is not None,
85-
}
86-
ytdlp["available"] = bool(ytdlp["command"] or ytdlp["module_available"])
87-
88-
ollama: dict[str, object] = {
89-
"base_url": config.ollama_base_url,
90-
"reachable": False,
91-
"version": None,
92-
"models_found": 0,
93-
"error": None,
94-
}
95-
try:
96-
runtime = await pipeline.runtime_status()
97-
ollama["reachable"] = True
98-
ollama["version"] = runtime.get("ollama_version")
99-
local_models = runtime.get("local_models", {})
100-
ollama["models_found"] = len(local_models)
101-
except Exception as exc:
102-
ollama["error"] = str(exc)
103-
104-
ffmpeg_ok = bool(media_tools["ffmpeg"].get("available"))
105-
ffprobe_ok = bool(media_tools["ffprobe"].get("available"))
106-
ytdlp_ok = bool(ytdlp["available"])
107-
ollama_ok = bool(ollama["reachable"])
108-
109-
return {
110-
"platform": {
111-
"system": platform.system(),
112-
"release": platform.release(),
113-
"python_version": sys.version.split()[0],
114-
"python_executable": sys.executable,
115-
},
116-
"dependencies": {
117-
"ffmpeg": media_tools["ffmpeg"],
118-
"ffprobe": media_tools["ffprobe"],
119-
"yt_dlp": ytdlp,
120-
"ollama": ollama,
121-
},
122-
"ready": ffmpeg_ok and ffprobe_ok and ytdlp_ok and ollama_ok,
123-
}
124-
125-
@app.post("/setup/bootstrap")
126-
async def setup_bootstrap(_=Depends(_check_auth)):
127-
return await pipeline.bootstrap_models()
4+
from video_rss_aggregator.api import IngestRequest, ProcessRequest
5+
from video_rss_aggregator.api import create_app as create_runtime_app
6+
from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases, build_runtime
1287

129-
@app.post("/ingest")
130-
async def ingest(req: IngestRequest, _=Depends(_check_auth)):
131-
report = await pipeline.ingest_feed(req.feed_url, req.process, req.max_items)
132-
return {
133-
"feed_title": report.feed_title,
134-
"item_count": report.item_count,
135-
"processed_count": report.processed_count,
136-
}
1378

138-
@app.post("/process")
139-
async def process(req: ProcessRequest, _=Depends(_check_auth)):
140-
report = await pipeline.process_source(req.source_url, req.title)
141-
return {
142-
"source_url": report.source_url,
143-
"title": report.title,
144-
"transcript_chars": report.transcript_chars,
145-
"frame_count": report.frame_count,
146-
"summary": {
147-
"summary": report.summary.summary,
148-
"key_points": report.summary.key_points,
149-
"visual_highlights": report.summary.visual_highlights,
150-
"model_used": report.summary.model_used,
151-
"vram_mb": report.summary.vram_mb,
152-
"error": report.summary.error,
153-
},
154-
}
9+
def create_app(runtime: AppRuntime | None = None, config: Config | None = None):
10+
if runtime is not None and not isinstance(runtime, AppRuntime):
11+
raise TypeError("create_app expects an AppRuntime or None")
15512

156-
@app.get("/rss")
157-
async def rss_feed(limit: int = Query(20, ge=1, le=200)):
158-
xml = await pipeline.rss_feed(limit)
159-
return Response(content=xml, media_type="application/rss+xml")
13+
return create_runtime_app(runtime=runtime, config=config)
16014

161-
@app.get("/runtime")
162-
async def runtime(_=Depends(_check_auth)):
163-
return await pipeline.runtime_status()
16415

165-
return app
16+
__all__ = [
17+
"AppRuntime",
18+
"AppUseCases",
19+
"IngestRequest",
20+
"ProcessRequest",
21+
"build_runtime",
22+
"create_app",
23+
]

adapter_rss.py

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,3 @@
1-
from __future__ import annotations
1+
from video_rss_aggregator.rss import render_feed
22

3-
from datetime import datetime, timezone
4-
from xml.etree.ElementTree import Element, SubElement, tostring
5-
6-
from adapter_storage import SummaryRecord
7-
8-
9-
def render_feed(
10-
title: str,
11-
link: str,
12-
description: str,
13-
records: list[SummaryRecord],
14-
) -> str:
15-
rss = Element("rss", version="2.0")
16-
channel = SubElement(rss, "channel")
17-
SubElement(channel, "title").text = title
18-
SubElement(channel, "link").text = link
19-
SubElement(channel, "description").text = description
20-
21-
for rec in records:
22-
item = SubElement(channel, "item")
23-
SubElement(item, "title").text = rec.title or "Untitled video"
24-
SubElement(item, "link").text = rec.source_url
25-
26-
desc_parts = [rec.summary]
27-
if rec.key_points:
28-
bullets = "\n".join(f"- {p}" for p in rec.key_points)
29-
desc_parts.append(bullets)
30-
if rec.visual_highlights:
31-
visuals = "\n".join(f"- {p}" for p in rec.visual_highlights)
32-
desc_parts.append(f"Visual Highlights:\n{visuals}")
33-
if rec.model_used:
34-
desc_parts.append(f"Model: {rec.model_used} (VRAM {rec.vram_mb:.2f} MB)")
35-
36-
SubElement(item, "description").text = "\n\n".join(desc_parts)
37-
38-
if rec.published_at:
39-
SubElement(item, "pubDate").text = _rfc2822(rec.published_at)
40-
41-
return '<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(
42-
rss, encoding="unicode"
43-
)
44-
45-
46-
def _rfc2822(dt: datetime) -> str:
47-
if dt.tzinfo is None:
48-
dt = dt.replace(tzinfo=timezone.utc)
49-
return dt.strftime("%a, %d %b %Y %H:%M:%S %z")
3+
__all__ = ["render_feed"]

0 commit comments

Comments
 (0)