web: dashboard + settings editor (HTMX save) + pipeline observability

Adds three new routes on the FastAPI web app:
- GET  /dashboard        — pipeline counts by status (per-status drill-down
                           via filtered /?status=… links), recent activity
                           (latest 10 sources), and a settings snapshot
                           card with an "edit →" shortcut.
- GET  /settings         — full editing form for pipeline_settings.
- POST /settings/save    — HTMX endpoint that validates the partial form,
                           upserts the row, and returns the rerendered
                           card with a "Saved." or error flash. The card
                           is its own _settings_card.html partial so the
                           HTMX swap targets only the form region.

Form parsing lives in settings_store helpers (parse_time_or_none,
parse_int_or_none) — keeps the route thin and the empty-string-to-NULL
normalisation in one place. Validation rejects negative counts and
sub-1 GPU concurrency.

The base template grew a Dashboard + Settings nav so the new routes are
reachable without typing URLs. Added python-multipart to deps because
FastAPI's Form(...) raises at app import without it.
This commit is contained in:
Travis Herbranson 2026-05-25 08:15:29 -04:00
parent 09efbb5965
commit c92a40bce1
7 changed files with 497 additions and 1 deletions

View File

@ -15,6 +15,7 @@ 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,14 +14,21 @@ 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, HTTPException, Request from fastapi import FastAPI, Form, 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
@ -295,3 +302,176 @@ 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

@ -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>

View File

@ -16,8 +16,10 @@
<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

@ -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 %}

View 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
View File

@ -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" }, { 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"
@ -1584,6 +1593,7 @@ 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" },
@ -1617,6 +1627,7 @@ 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" },