Compare commits

..

No commits in common. "7eafdf3e03065dea38b0ba46e38b9b1343e59dff" and "0d9ebc9cc7760ccfceb4b454e794f96ff32d626d" have entirely different histories.

11 changed files with 3 additions and 792 deletions

View File

@ -1,70 +0,0 @@
"""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")

View File

@ -15,7 +15,6 @@ dependencies = [
"psycopg-pool>=3.2", "psycopg-pool>=3.2",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
"tomli>=2.0.0", "tomli>=2.0.0",
"trafilatura>=1.9.0", "trafilatura>=1.9.0",

View File

@ -14,12 +14,11 @@ Tables:
from __future__ import annotations from __future__ import annotations
import enum import enum
from datetime import datetime, time, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import ( from sqlalchemy import (
JSON, JSON,
Boolean,
DateTime, DateTime,
Enum, Enum,
ForeignKey, ForeignKey,
@ -27,7 +26,6 @@ from sqlalchemy import (
MetaData, MetaData,
String, String,
Text, Text,
Time,
) )
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@ -195,64 +193,6 @@ class WikiPage(Base):
return f"WikiPage(id={self.id}, vault_path={self.vault_path!r})" 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__ = [ __all__ = [
"Base", "Base",
"SCHEMA", "SCHEMA",
@ -260,7 +200,6 @@ __all__ = [
"SourceStatus", "SourceStatus",
"SourceType", "SourceType",
"Extraction", "Extraction",
"PipelineSettings",
"WikiPage", "WikiPage",
"utcnow", "utcnow",
] ]

View File

@ -25,7 +25,6 @@ from datetime import datetime, time as dtime
from second_brain.config import Config from second_brain.config import Config
from second_brain.database import get_database from second_brain.database import get_database
from second_brain.models import Source, SourceStatus, utcnow from second_brain.models import Source, SourceStatus, utcnow
from second_brain.settings_store import get_settings, in_active_window
class Scheduler: class Scheduler:
@ -52,38 +51,6 @@ class Scheduler:
from second_brain.models import Extraction from second_brain.models import Extraction
db = get_database() 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) assembler = ContextAssembler(self.config)
ex_cfg = self.config.extractor ex_cfg = self.config.extractor
engine = ExtractionEngine( engine = ExtractionEngine(
@ -107,14 +74,10 @@ class Scheduler:
print( print(
f"[Scheduler] backend={self.backend} window " f"[Scheduler] backend={self.backend} window "
f"{_fmt(self.window_start)}{_fmt(self.window_end)} cap {cap_desc} " f"{_fmt(self.window_start)}{_fmt(self.window_end)} cap {cap_desc} "
f"gap {self.min_gap_seconds}s max_items={max_items or ''}" f"gap {self.min_gap_seconds}s"
) )
while self._in_window(): 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 # Reset hourly buckets
elapsed = time.time() - hour_start elapsed = time.time() - hour_start
if elapsed >= 3600: if elapsed >= 3600:
@ -228,8 +191,4 @@ def _fmt(t: dtime) -> str:
return t.strftime("%H:%M") 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"] __all__ = ["Scheduler"]

View File

@ -1,121 +0,0 @@
"""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",
]

View File

@ -14,21 +14,14 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from fastapi import FastAPI, Form, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func
from second_brain.config import DOMAINS from second_brain.config import DOMAINS
from second_brain.database import get_database from second_brain.database import get_database
from second_brain.models import Extraction, Source, SourceStatus 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 # App setup
@ -302,176 +295,3 @@ def _source_to_response(s: Source) -> SourceResponse:
ingested_at=s.ingested_at.isoformat() if s.ingested_at else "", ingested_at=s.ingested_at.isoformat() if s.ingested_at else "",
has_extraction=s.extraction is not None, 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 "",
}

View File

@ -1,130 +0,0 @@
{# 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>

View File

@ -16,10 +16,8 @@
<p class="text-indigo-200 text-sm mt-0.5">personal knowledge pipeline</p> <p class="text-indigo-200 text-sm mt-0.5">personal knowledge pipeline</p>
</div> </div>
<nav class="flex gap-4 text-sm font-medium"> <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="/" class="hover:text-indigo-200 transition-colors">Sources</a>
<a href="/queue" class="hover:text-indigo-200 transition-colors">Queue</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> </nav>
</div> </div>
</header> </header>

View File

@ -1,158 +0,0 @@
{% 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 %}

View File

@ -1,14 +0,0 @@
{% 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
View File

@ -1385,15 +1385,6 @@ 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" }, { 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]] [[package]]
name = "pytz" name = "pytz"
version = "2026.2" version = "2026.2"
@ -1593,7 +1584,6 @@ dependencies = [
{ name = "psycopg-pool" }, { name = "psycopg-pool" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "tomli" }, { name = "tomli" },
{ name = "trafilatura" }, { name = "trafilatura" },
@ -1627,7 +1617,6 @@ requires-dist = [
{ name = "psycopg-pool", specifier = ">=3.2" }, { name = "psycopg-pool", specifier = ">=3.2" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "tomli", specifier = ">=2.0.0" }, { name = "tomli", specifier = ">=2.0.0" },
{ name = "trafilatura", specifier = ">=1.9.0" }, { name = "trafilatura", specifier = ">=1.9.0" },