Compare commits
3 Commits
0d9ebc9cc7
...
7eafdf3e03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eafdf3e03 | ||
|
|
c92a40bce1 | ||
|
|
09efbb5965 |
70
alembic/versions/7b3e8a5c1f29_v2_pipeline_settings.py
Normal file
70
alembic/versions/7b3e8a5c1f29_v2_pipeline_settings.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""v2 second_brain: pipeline_settings table
|
||||
|
||||
Revision ID: 7b3e8a5c1f29
|
||||
Revises: 4a1e2f6c9d10
|
||||
Create Date: 2026-05-25 00:00:00.000000
|
||||
|
||||
Adds a single-row `pipeline_settings` table that the web dashboard edits
|
||||
and the workers (current dev-side scheduler + future tower-side transcribe
|
||||
worker) read at the start of each run. The transcription_* fields persist
|
||||
now but are only consumed once the transcribe worker exists — the
|
||||
extraction_* fields are wired into the existing scheduler in this commit.
|
||||
|
||||
Single-row enforcement: PK is fixed at `id=1` with a CHECK constraint, so
|
||||
INSERT-as-upsert-by-PK keeps the table free of stale rows.
|
||||
|
||||
Schema/permission notes mirror the v1 migration: lovebug lacks CREATE on
|
||||
the petalbrain database, so env.py has already bootstrapped the schema —
|
||||
this migration just SETs search_path and emits DDL inside it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "7b3e8a5c1f29"
|
||||
down_revision: str | Sequence[str] | None = "4a1e2f6c9d10"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("SET search_path TO second_brain")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE pipeline_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
transcription_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
transcription_active_hours_start TIME,
|
||||
transcription_active_hours_end TIME,
|
||||
transcription_max_concurrent_gpu_jobs INTEGER NOT NULL DEFAULT 1,
|
||||
transcription_max_video_length_seconds INTEGER,
|
||||
transcription_max_items_per_run INTEGER NOT NULL DEFAULT 10,
|
||||
extraction_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
extraction_active_hours_start TIME,
|
||||
extraction_active_hours_end TIME,
|
||||
extraction_max_items_per_run INTEGER NOT NULL DEFAULT 20,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT pipeline_settings_singleton CHECK (id = 1)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Seed the singleton row with the table's column defaults. Idempotent
|
||||
# via ON CONFLICT so re-running this migration on a partly-applied DB
|
||||
# (or a manual cherry-pick) is safe.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO pipeline_settings (id) VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("SET search_path TO second_brain")
|
||||
op.execute("DROP TABLE IF EXISTS pipeline_settings")
|
||||
@ -15,6 +15,7 @@ dependencies = [
|
||||
"psycopg-pool>=3.2",
|
||||
"pydantic>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"python-multipart>=0.0.20",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"tomli>=2.0.0",
|
||||
"trafilatura>=1.9.0",
|
||||
|
||||
@ -14,11 +14,12 @@ Tables:
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
@ -26,6 +27,7 @@ from sqlalchemy import (
|
||||
MetaData,
|
||||
String,
|
||||
Text,
|
||||
Time,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
@ -193,6 +195,64 @@ class WikiPage(Base):
|
||||
return f"WikiPage(id={self.id}, vault_path={self.vault_path!r})"
|
||||
|
||||
|
||||
class PipelineSettings(Base):
|
||||
"""Single-row config the web dashboard edits and workers read.
|
||||
|
||||
The transcription_* fields persist now and will be honored by the
|
||||
tower-side transcribe worker once it exists; the extraction_* fields
|
||||
are already wired into the dev-side scheduler. The row is pinned to
|
||||
`id=1` by a DB-level CHECK so upserts-by-PK keep the table singleton.
|
||||
"""
|
||||
|
||||
__tablename__ = "pipeline_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
|
||||
# Transcription (consumed by the tower-side worker — not yet built).
|
||||
transcription_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
transcription_active_hours_start: Mapped[Optional[time]] = mapped_column(
|
||||
Time, nullable=True
|
||||
)
|
||||
transcription_active_hours_end: Mapped[Optional[time]] = mapped_column(
|
||||
Time, nullable=True
|
||||
)
|
||||
transcription_max_concurrent_gpu_jobs: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=1
|
||||
)
|
||||
transcription_max_video_length_seconds: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
transcription_max_items_per_run: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=10
|
||||
)
|
||||
|
||||
# Extraction (consumed by the dev-side scheduler today).
|
||||
extraction_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
extraction_active_hours_start: Mapped[Optional[time]] = mapped_column(
|
||||
Time, nullable=True
|
||||
)
|
||||
extraction_active_hours_end: Mapped[Optional[time]] = mapped_column(
|
||||
Time, nullable=True
|
||||
)
|
||||
extraction_max_items_per_run: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=20
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"PipelineSettings(transcription_enabled={self.transcription_enabled}, "
|
||||
f"extraction_enabled={self.extraction_enabled})"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"SCHEMA",
|
||||
@ -200,6 +260,7 @@ __all__ = [
|
||||
"SourceStatus",
|
||||
"SourceType",
|
||||
"Extraction",
|
||||
"PipelineSettings",
|
||||
"WikiPage",
|
||||
"utcnow",
|
||||
]
|
||||
|
||||
@ -25,6 +25,7 @@ from datetime import datetime, time as dtime
|
||||
from second_brain.config import Config
|
||||
from second_brain.database import get_database
|
||||
from second_brain.models import Source, SourceStatus, utcnow
|
||||
from second_brain.settings_store import get_settings, in_active_window
|
||||
|
||||
|
||||
class Scheduler:
|
||||
@ -51,6 +52,38 @@ class Scheduler:
|
||||
from second_brain.models import Extraction
|
||||
|
||||
db = get_database()
|
||||
|
||||
# Pull the DB-side runtime knobs once at the top of the run. The
|
||||
# dashboard edits these; settings.toml is the static fallback. The
|
||||
# values are snapshotted for the duration of this run so a mid-run
|
||||
# toggle doesn't half-apply (consistent with the way max_calls is
|
||||
# tracked locally).
|
||||
with db.session() as sess:
|
||||
settings = get_settings(sess)
|
||||
extraction_enabled = settings.extraction_enabled
|
||||
db_window = (
|
||||
settings.extraction_active_hours_start,
|
||||
settings.extraction_active_hours_end,
|
||||
)
|
||||
max_items = int(settings.extraction_max_items_per_run or 0)
|
||||
|
||||
if not extraction_enabled:
|
||||
print("[Scheduler] extraction_enabled=false in pipeline_settings. Skipping run.")
|
||||
return
|
||||
|
||||
# DB window (if set) overrides the static settings.toml window. If
|
||||
# only one side is set, treat the other as unbounded (no constraint).
|
||||
if db_window[0] is not None or db_window[1] is not None:
|
||||
self.window_start = db_window[0] or dtime(0, 0)
|
||||
self.window_end = db_window[1] or dtime(23, 59)
|
||||
|
||||
if not in_active_window(datetime.now().time(), db_window[0], db_window[1]):
|
||||
print(
|
||||
f"[Scheduler] outside extraction active window "
|
||||
f"{_fmt_opt(db_window[0])}–{_fmt_opt(db_window[1])}. Skipping run."
|
||||
)
|
||||
return
|
||||
|
||||
assembler = ContextAssembler(self.config)
|
||||
ex_cfg = self.config.extractor
|
||||
engine = ExtractionEngine(
|
||||
@ -74,10 +107,14 @@ class Scheduler:
|
||||
print(
|
||||
f"[Scheduler] backend={self.backend} window "
|
||||
f"{_fmt(self.window_start)}–{_fmt(self.window_end)} cap {cap_desc} "
|
||||
f"gap {self.min_gap_seconds}s"
|
||||
f"gap {self.min_gap_seconds}s max_items={max_items or '∞'}"
|
||||
)
|
||||
|
||||
while self._in_window():
|
||||
# DB-side hard cap on items per run (0 = unlimited).
|
||||
if max_items and processed >= max_items:
|
||||
print(f"[Scheduler] reached extraction_max_items_per_run={max_items}. Done.")
|
||||
break
|
||||
# Reset hourly buckets
|
||||
elapsed = time.time() - hour_start
|
||||
if elapsed >= 3600:
|
||||
@ -191,4 +228,8 @@ def _fmt(t: dtime) -> str:
|
||||
return t.strftime("%H:%M")
|
||||
|
||||
|
||||
def _fmt_opt(t: dtime | None) -> str:
|
||||
return t.strftime("%H:%M") if t is not None else "—"
|
||||
|
||||
|
||||
__all__ = ["Scheduler"]
|
||||
|
||||
121
src/second_brain/settings_store.py
Normal file
121
src/second_brain/settings_store.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""Helpers for the singleton `pipeline_settings` row.
|
||||
|
||||
The web dashboard edits this row; workers read it at the start of each
|
||||
run to decide whether to do anything and how much. DB values override
|
||||
the static defaults in `settings.toml`.
|
||||
|
||||
All functions take a SQLAlchemy `Session` so the caller owns the
|
||||
transaction boundary — matches the rest of the codebase, which keeps
|
||||
session lifecycle inside `with db.session()` blocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from second_brain.models import PipelineSettings, utcnow
|
||||
|
||||
|
||||
# Field names allowed in `update_settings`. Anything else is rejected so a
|
||||
# malicious form post can't toggle id/updated_at/etc.
|
||||
_EDITABLE_FIELDS: tuple[str, ...] = (
|
||||
"transcription_enabled",
|
||||
"transcription_active_hours_start",
|
||||
"transcription_active_hours_end",
|
||||
"transcription_max_concurrent_gpu_jobs",
|
||||
"transcription_max_video_length_seconds",
|
||||
"transcription_max_items_per_run",
|
||||
"extraction_enabled",
|
||||
"extraction_active_hours_start",
|
||||
"extraction_active_hours_end",
|
||||
"extraction_max_items_per_run",
|
||||
)
|
||||
|
||||
|
||||
def get_settings(sess: Session) -> PipelineSettings:
|
||||
"""Return the singleton settings row, creating it on first call.
|
||||
|
||||
The v2 migration seeds the row with all DB-side defaults, so this
|
||||
"create on miss" path only kicks in when somebody bootstrapped the
|
||||
schema without applying the migration (e.g. an unconfigured test
|
||||
using `Base.metadata.create_all`).
|
||||
"""
|
||||
row = sess.get(PipelineSettings, 1)
|
||||
if row is None:
|
||||
row = PipelineSettings(id=1)
|
||||
sess.add(row)
|
||||
sess.flush()
|
||||
return row
|
||||
|
||||
|
||||
def update_settings(sess: Session, **fields: Any) -> PipelineSettings:
|
||||
"""Apply a partial update to the singleton row.
|
||||
|
||||
Unknown keys raise `KeyError`. Empty-string values for nullable fields
|
||||
are normalised to None so the HTML form's blank inputs (active hours,
|
||||
video-length cap) round-trip as NULL rather than as the strings "".
|
||||
"""
|
||||
for k in fields:
|
||||
if k not in _EDITABLE_FIELDS:
|
||||
raise KeyError(f"Unknown pipeline_settings field: {k!r}")
|
||||
|
||||
row = get_settings(sess)
|
||||
for k, v in fields.items():
|
||||
if v == "":
|
||||
v = None
|
||||
setattr(row, k, v)
|
||||
row.updated_at = utcnow()
|
||||
return row
|
||||
|
||||
|
||||
def parse_time_or_none(raw: str | None) -> time | None:
|
||||
"""Parse an HTML <input type=time> value (HH:MM) into a `time`.
|
||||
|
||||
Empty / missing values map to None — `update_settings` will store
|
||||
NULL, which workers interpret as "no window, always active".
|
||||
"""
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
parts = raw.split(":")
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Could not parse time: {raw!r}")
|
||||
h, m = int(parts[0]), int(parts[1])
|
||||
return time(h, m)
|
||||
|
||||
|
||||
def parse_int_or_none(raw: str | None) -> int | None:
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
return int(raw)
|
||||
|
||||
|
||||
def in_active_window(now_t: time, start: time | None, end: time | None) -> bool:
|
||||
"""Return True if `now_t` lies in the [start, end] window.
|
||||
|
||||
Either bound being None means "no constraint on that side", which
|
||||
matches the dashboard's "leave blank = always active" semantics.
|
||||
Handles overnight windows (start > end) by treating them as the
|
||||
complement that crosses midnight.
|
||||
"""
|
||||
if start is None and end is None:
|
||||
return True
|
||||
if start is None:
|
||||
return now_t <= end # type: ignore[operator]
|
||||
if end is None:
|
||||
return now_t >= start
|
||||
if start <= end:
|
||||
return start <= now_t <= end
|
||||
# crosses midnight
|
||||
return now_t >= start or now_t <= end
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_settings",
|
||||
"update_settings",
|
||||
"parse_time_or_none",
|
||||
"parse_int_or_none",
|
||||
"in_active_window",
|
||||
]
|
||||
@ -14,14 +14,21 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
|
||||
from second_brain.config import DOMAINS
|
||||
from second_brain.database import get_database
|
||||
from second_brain.models import Extraction, Source, SourceStatus
|
||||
from second_brain.settings_store import (
|
||||
get_settings,
|
||||
parse_int_or_none,
|
||||
parse_time_or_none,
|
||||
update_settings,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup
|
||||
@ -295,3 +302,176 @@ def _source_to_response(s: Source) -> SourceResponse:
|
||||
ingested_at=s.ingested_at.isoformat() if s.ingested_at else "",
|
||||
has_extraction=s.extraction is not None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard — pipeline observability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Overview: status counts, recent activity, and a settings snapshot."""
|
||||
db = get_database()
|
||||
with db.session() as sess:
|
||||
# Counts by status. Materialise inside the session so the keys stay
|
||||
# plain str → int and templates don't try to touch a closed session.
|
||||
rows = (
|
||||
sess.query(Source.status, func.count(Source.id))
|
||||
.group_by(Source.status)
|
||||
.all()
|
||||
)
|
||||
counts = {s.value: 0 for s in SourceStatus}
|
||||
for status, n in rows:
|
||||
counts[status.value] = int(n)
|
||||
total = sum(counts.values())
|
||||
|
||||
recent_rows = (
|
||||
sess.query(Source)
|
||||
.order_by(Source.ingested_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
recent = [_source_to_dict(s) for s in recent_rows]
|
||||
|
||||
settings_row = get_settings(sess)
|
||||
settings_summary = _settings_to_dict(settings_row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"counts": counts,
|
||||
"total": total,
|
||||
"recent": recent,
|
||||
"statuses_in_order": [s.value for s in SourceStatus],
|
||||
"settings": settings_summary,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings — view + HTMX save
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_view(request: Request):
|
||||
db = get_database()
|
||||
with db.session() as sess:
|
||||
row = get_settings(sess)
|
||||
ctx = _settings_to_dict(row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"settings.html",
|
||||
{"settings": ctx, "flash": None, "error": None},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/settings/save", response_class=HTMLResponse)
|
||||
async def settings_save(
|
||||
request: Request,
|
||||
transcription_enabled: Optional[str] = Form(None),
|
||||
transcription_active_hours_start: Optional[str] = Form(None),
|
||||
transcription_active_hours_end: Optional[str] = Form(None),
|
||||
transcription_max_concurrent_gpu_jobs: Optional[str] = Form(None),
|
||||
transcription_max_video_length_seconds: Optional[str] = Form(None),
|
||||
transcription_max_items_per_run: Optional[str] = Form(None),
|
||||
extraction_enabled: Optional[str] = Form(None),
|
||||
extraction_active_hours_start: Optional[str] = Form(None),
|
||||
extraction_active_hours_end: Optional[str] = Form(None),
|
||||
extraction_max_items_per_run: Optional[str] = Form(None),
|
||||
):
|
||||
"""HTMX save endpoint — returns the rendered settings card with a flash."""
|
||||
db = get_database()
|
||||
error: Optional[str] = None
|
||||
flash: Optional[str] = None
|
||||
|
||||
try:
|
||||
fields = {
|
||||
# Checkboxes only show up in the form payload when checked. The
|
||||
# template emits a hidden "_present" companion so we can tell
|
||||
# "unchecked" apart from "field missing because of a partial post".
|
||||
"transcription_enabled": transcription_enabled == "on",
|
||||
"transcription_active_hours_start": parse_time_or_none(
|
||||
transcription_active_hours_start
|
||||
),
|
||||
"transcription_active_hours_end": parse_time_or_none(
|
||||
transcription_active_hours_end
|
||||
),
|
||||
"transcription_max_concurrent_gpu_jobs": int(
|
||||
transcription_max_concurrent_gpu_jobs or "1"
|
||||
),
|
||||
"transcription_max_video_length_seconds": parse_int_or_none(
|
||||
transcription_max_video_length_seconds
|
||||
),
|
||||
"transcription_max_items_per_run": int(
|
||||
transcription_max_items_per_run or "10"
|
||||
),
|
||||
"extraction_enabled": extraction_enabled == "on",
|
||||
"extraction_active_hours_start": parse_time_or_none(
|
||||
extraction_active_hours_start
|
||||
),
|
||||
"extraction_active_hours_end": parse_time_or_none(
|
||||
extraction_active_hours_end
|
||||
),
|
||||
"extraction_max_items_per_run": int(extraction_max_items_per_run or "20"),
|
||||
}
|
||||
# Cheap inline validation — keep counts non-negative and gpu jobs >= 1.
|
||||
if fields["transcription_max_concurrent_gpu_jobs"] < 1:
|
||||
raise ValueError("transcription_max_concurrent_gpu_jobs must be ≥ 1")
|
||||
if fields["transcription_max_items_per_run"] < 0:
|
||||
raise ValueError("transcription_max_items_per_run must be ≥ 0")
|
||||
if fields["extraction_max_items_per_run"] < 0:
|
||||
raise ValueError("extraction_max_items_per_run must be ≥ 0")
|
||||
if (
|
||||
fields["transcription_max_video_length_seconds"] is not None
|
||||
and fields["transcription_max_video_length_seconds"] < 0
|
||||
):
|
||||
raise ValueError("transcription_max_video_length_seconds must be ≥ 0")
|
||||
|
||||
with db.session() as sess:
|
||||
row = update_settings(sess, **fields)
|
||||
ctx = _settings_to_dict(row)
|
||||
flash = "Saved."
|
||||
except (ValueError, KeyError) as exc:
|
||||
error = str(exc)
|
||||
# Re-render the form using whatever the user submitted so they don't
|
||||
# lose in-progress edits on a validation bounce.
|
||||
with db.session() as sess:
|
||||
ctx = _settings_to_dict(get_settings(sess))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"_settings_card.html",
|
||||
{"settings": ctx, "flash": flash, "error": error},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings serialiser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _settings_to_dict(row) -> dict:
|
||||
"""Render the settings row as a template-friendly dict (no ORM bleed)."""
|
||||
|
||||
def t(v):
|
||||
return v.strftime("%H:%M") if v is not None else ""
|
||||
|
||||
return {
|
||||
"transcription_enabled": row.transcription_enabled,
|
||||
"transcription_active_hours_start": t(row.transcription_active_hours_start),
|
||||
"transcription_active_hours_end": t(row.transcription_active_hours_end),
|
||||
"transcription_max_concurrent_gpu_jobs": row.transcription_max_concurrent_gpu_jobs,
|
||||
"transcription_max_video_length_seconds": row.transcription_max_video_length_seconds,
|
||||
"transcription_max_items_per_run": row.transcription_max_items_per_run,
|
||||
"extraction_enabled": row.extraction_enabled,
|
||||
"extraction_active_hours_start": t(row.extraction_active_hours_start),
|
||||
"extraction_active_hours_end": t(row.extraction_active_hours_end),
|
||||
"extraction_max_items_per_run": row.extraction_max_items_per_run,
|
||||
"updated_at": row.updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if row.updated_at
|
||||
else "",
|
||||
}
|
||||
|
||||
130
src/second_brain/web/templates/_settings_card.html
Normal file
130
src/second_brain/web/templates/_settings_card.html
Normal file
@ -0,0 +1,130 @@
|
||||
{# HTMX-replaceable inner card. Posted to /settings/save; swaps itself in place. #}
|
||||
<div id="settings-card" class="bg-white rounded-lg shadow-sm p-6">
|
||||
{% if flash %}
|
||||
<div class="mb-4 px-3 py-2 rounded bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||
✓ {{ flash }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="mb-4 px-3 py-2 rounded bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
✗ {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/settings/save"
|
||||
hx-target="#settings-card"
|
||||
hx-swap="outerHTML"
|
||||
class="space-y-8">
|
||||
|
||||
<!-- Transcription -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-800">Transcription</h3>
|
||||
<p class="text-xs text-gray-400">consumed by tower-side worker (not yet built)</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<!-- enabled -->
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" name="transcription_enabled"
|
||||
{% if settings.transcription_enabled %}checked{% endif %}
|
||||
class="peer sr-only">
|
||||
<span class="w-10 h-5 bg-gray-300 rounded-full peer-checked:bg-indigo-600 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5"></span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
</label>
|
||||
|
||||
<div class="md:col-span-1"></div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — start</label>
|
||||
<input type="time" name="transcription_active_hours_start"
|
||||
value="{{ settings.transcription_active_hours_start }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
<p class="text-xs text-gray-400 mt-1">blank = always active</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — end</label>
|
||||
<input type="time" name="transcription_active_hours_end"
|
||||
value="{{ settings.transcription_active_hours_end }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max concurrent GPU jobs</label>
|
||||
<input type="number" min="1" name="transcription_max_concurrent_gpu_jobs"
|
||||
value="{{ settings.transcription_max_concurrent_gpu_jobs }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max items per run</label>
|
||||
<input type="number" min="0" name="transcription_max_items_per_run"
|
||||
value="{{ settings.transcription_max_items_per_run }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max video length (seconds)</label>
|
||||
<input type="number" min="0" name="transcription_max_video_length_seconds"
|
||||
value="{{ settings.transcription_max_video_length_seconds if settings.transcription_max_video_length_seconds is not none else '' }}"
|
||||
placeholder="blank = no cap"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full max-w-xs">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Extraction -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-800">Extraction</h3>
|
||||
<p class="text-xs text-gray-400">consumed by the dev-side scheduler</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" name="extraction_enabled"
|
||||
{% if settings.extraction_enabled %}checked{% endif %}
|
||||
class="peer sr-only">
|
||||
<span class="w-10 h-5 bg-gray-300 rounded-full peer-checked:bg-indigo-600 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5"></span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
</label>
|
||||
|
||||
<div class="md:col-span-1"></div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — start</label>
|
||||
<input type="time" name="extraction_active_hours_start"
|
||||
value="{{ settings.extraction_active_hours_start }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
<p class="text-xs text-gray-400 mt-1">blank = always active</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — end</label>
|
||||
<input type="time" name="extraction_active_hours_end"
|
||||
value="{{ settings.extraction_active_hours_end }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max items per run</label>
|
||||
<input type="number" min="0" name="extraction_max_items_per_run"
|
||||
value="{{ settings.extraction_max_items_per_run }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<p class="text-xs text-gray-400">last saved {{ settings.updated_at }}</p>
|
||||
<button type="submit"
|
||||
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700 transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -16,8 +16,10 @@
|
||||
<p class="text-indigo-200 text-sm mt-0.5">personal knowledge pipeline</p>
|
||||
</div>
|
||||
<nav class="flex gap-4 text-sm font-medium">
|
||||
<a href="/dashboard" class="hover:text-indigo-200 transition-colors">Dashboard</a>
|
||||
<a href="/" class="hover:text-indigo-200 transition-colors">Sources</a>
|
||||
<a href="/queue" class="hover:text-indigo-200 transition-colors">Queue</a>
|
||||
<a href="/settings" class="hover:text-indigo-200 transition-colors">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
158
src/second_brain/web/templates/dashboard.html
Normal file
158
src/second_brain/web/templates/dashboard.html
Normal file
@ -0,0 +1,158 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard — second-brain{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set status_colors = {
|
||||
'pending': 'bg-gray-100 text-gray-700',
|
||||
'pulled': 'bg-blue-100 text-blue-700',
|
||||
'transcribed': 'bg-yellow-100 text-yellow-700',
|
||||
'analyzed': 'bg-orange-100 text-orange-700',
|
||||
'accepted': 'bg-green-100 text-green-700',
|
||||
'published': 'bg-purple-100 text-purple-700',
|
||||
'failed': 'bg-red-100 text-red-700'
|
||||
} %}
|
||||
{% set domain_colors = {
|
||||
'development': 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
'content': 'bg-pink-50 text-pink-700 border-pink-200',
|
||||
'business': 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'homelab': 'bg-teal-50 text-teal-700 border-teal-200'
|
||||
} %}
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold text-gray-800">Dashboard</h2>
|
||||
<span class="text-sm text-gray-500">total sources: {{ total }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline overview -->
|
||||
<section class="bg-white rounded-lg shadow-sm p-5 mb-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
|
||||
Pipeline
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
{% for status in statuses_in_order %}
|
||||
<a href="/?status={{ status }}"
|
||||
class="block rounded-lg border border-gray-200 hover:border-indigo-400 hover:shadow-sm transition-all px-3 py-3">
|
||||
<div class="text-2xl font-bold text-gray-900 leading-none">{{ counts.get(status, 0) }}</div>
|
||||
<div class="mt-1.5">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium
|
||||
{{ status_colors.get(status, 'bg-gray-100 text-gray-700') }}">
|
||||
{{ status }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings snapshot + Recent activity, side by side on wide screens -->
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Settings snapshot -->
|
||||
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-1">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
Settings
|
||||
</h3>
|
||||
<a href="/settings" class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
edit →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<dl class="text-sm space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600">Transcription</dt>
|
||||
<dd>
|
||||
{% if settings.transcription_enabled %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
|
||||
{% else %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> active window</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">
|
||||
{% if settings.transcription_active_hours_start or settings.transcription_active_hours_end %}
|
||||
{{ settings.transcription_active_hours_start or "—" }} – {{ settings.transcription_active_hours_end or "—" }}
|
||||
{% else %}
|
||||
always
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> GPU jobs</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_concurrent_gpu_jobs }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> max items/run</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_items_per_run }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-2 mt-2"></div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600">Extraction</dt>
|
||||
<dd>
|
||||
{% if settings.extraction_enabled %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
|
||||
{% else %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> active window</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">
|
||||
{% if settings.extraction_active_hours_start or settings.extraction_active_hours_end %}
|
||||
{{ settings.extraction_active_hours_start or "—" }} – {{ settings.extraction_active_hours_end or "—" }}
|
||||
{% else %}
|
||||
always
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> max items/run</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">{{ settings.extraction_max_items_per_run }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="text-xs text-gray-400 mt-4">
|
||||
updated {{ settings.updated_at }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-2">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
|
||||
Recent activity
|
||||
</h3>
|
||||
{% if recent %}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{% for source in recent %}
|
||||
<a href="/sources/{{ source.id }}"
|
||||
class="flex items-start justify-between gap-3 py-2.5 hover:bg-gray-50 -mx-2 px-2 rounded transition-colors">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ source.title }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ source.url }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium
|
||||
{{ status_colors.get(source.status, 'bg-gray-100 text-gray-700') }}">
|
||||
{{ source.status }}
|
||||
</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded border font-medium
|
||||
{{ domain_colors.get(source.domain, 'bg-gray-50 text-gray-600 border-gray-200') }}">
|
||||
{{ source.domain }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ source.ingested_at }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 py-8 text-center">No sources yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
14
src/second_brain/web/templates/settings.html
Normal file
14
src/second_brain/web/templates/settings.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings — second-brain{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-800">Settings</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Workers read these at the start of each run. DB values override <code class="bg-gray-100 px-1 rounded text-xs">settings.toml</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% include "_settings_card.html" %}
|
||||
{% endblock %}
|
||||
11
uv.lock
generated
11
uv.lock
generated
@ -1385,6 +1385,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
@ -1584,6 +1593,7 @@ dependencies = [
|
||||
{ name = "psycopg-pool" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "tomli" },
|
||||
{ name = "trafilatura" },
|
||||
@ -1617,6 +1627,7 @@ requires-dist = [
|
||||
{ name = "psycopg-pool", specifier = ">=3.2" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
{ name = "tomli", specifier = ">=2.0.0" },
|
||||
{ name = "trafilatura", specifier = ">=1.9.0" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user