project init
This commit is contained in:
commit
250ca9fd2f
62
CLAUDE.md
Normal file
62
CLAUDE.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# second-brain
|
||||||
|
|
||||||
|
A personal knowledge system: video/article extraction pipeline + LLM-maintained wiki.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three-layer system:
|
||||||
|
|
||||||
|
1. **Wiki** — long-term structured memory. Markdown files in an Obsidian vault, maintained by the wiki compiler.
|
||||||
|
2. **Context assembler** — builds the prompt bundle for each extraction. Phase 1 is thin: domain template + focus field + transcript.
|
||||||
|
3. **Extractor** — stateless single-shot LLM call per source. Pure function: bundle in, structured extraction out.
|
||||||
|
|
||||||
|
## Pipeline stages
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → pulled → transcribed → analyzed → accepted → published
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/second_brain/
|
||||||
|
├── adapters/ # Pull adapters (youtube.py, article.py)
|
||||||
|
├── context/ # Context assembler
|
||||||
|
├── extractor/ # LLM extraction engine + Pydantic schema
|
||||||
|
├── compiler/ # Wiki compiler (reads vault, writes pages, git commits)
|
||||||
|
├── scheduler/ # Rate-limit smoother, token-aware spacing
|
||||||
|
└── web/ # FastAPI + Jinja2 + HTMX UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config/settings.toml.example config/settings.toml
|
||||||
|
# Edit settings.toml with your paths
|
||||||
|
uv run second-brain serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI commands
|
||||||
|
|
||||||
|
- `second-brain add <url>` — queue a source URL (video or article)
|
||||||
|
- `second-brain process` — run the pipeline on pending items
|
||||||
|
- `second-brain serve` — launch the web UI (default port 8000)
|
||||||
|
- `second-brain compile` — run the wiki compiler
|
||||||
|
- `second-brain schedule` — start the overnight scheduler
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
- `ANTHROPIC_API_KEY` — required for extraction and wiki compilation
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
- `development` — software, programming, systems
|
||||||
|
- `content` — content creation strategy, video production
|
||||||
|
- `business` — entrepreneurship, marketing, operations
|
||||||
|
- `homelab` — self-hosted infrastructure, networking, DevOps
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `config/settings.toml` — vault path, DB path, scheduler window
|
||||||
|
- `prompts/<domain>.md` — per-domain extraction prompt templates
|
||||||
|
- `src/second_brain/extractor/schema.py` — canonical extraction output schema
|
||||||
50
README.md
Normal file
50
README.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# second-brain
|
||||||
|
|
||||||
|
A personal knowledge system built on a video/article extraction pipeline and an LLM-maintained wiki (inspired by the Karpathy LLM Wiki pattern).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. **Pull** — download YouTube videos or fetch web articles
|
||||||
|
2. **Transcribe** — extract transcripts via Whisper (videos) or trafilatura (articles)
|
||||||
|
3. **Extract** — single-shot Claude call produces structured notes per source
|
||||||
|
4. **Review** — web UI to accept or reject extractions
|
||||||
|
5. **Compile** — wiki compiler folds accepted extractions into a living Obsidian vault
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Copy and edit config
|
||||||
|
cp config/settings.toml config/settings.local.toml
|
||||||
|
|
||||||
|
# Queue a source
|
||||||
|
uv run second-brain add https://www.youtube.com/watch?v=...
|
||||||
|
|
||||||
|
# Run the pipeline
|
||||||
|
uv run second-brain process
|
||||||
|
|
||||||
|
# Review in the web UI
|
||||||
|
uv run second-brain serve
|
||||||
|
|
||||||
|
# Compile to wiki
|
||||||
|
uv run second-brain compile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
Extractions are tagged by domain so the right prompt template is used:
|
||||||
|
|
||||||
|
| Domain | Focus |
|
||||||
|
|--------|-------|
|
||||||
|
| `development` | Software, programming, systems |
|
||||||
|
| `content` | Content creation, video production |
|
||||||
|
| `business` | Entrepreneurship, marketing, ops |
|
||||||
|
| `homelab` | Self-hosted infra, networking, DevOps |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- `ANTHROPIC_API_KEY` environment variable
|
||||||
|
- ffmpeg (for Whisper audio extraction)
|
||||||
33
config/settings.toml
Normal file
33
config/settings.toml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# second-brain runtime configuration
|
||||||
|
# Copy this file and edit paths to match your setup.
|
||||||
|
# Override the config file path with SECOND_BRAIN_CONFIG env var.
|
||||||
|
|
||||||
|
# Path to the SQLite database
|
||||||
|
db_path = "~/.local/share/second-brain/second_brain.db"
|
||||||
|
|
||||||
|
# Path to your Obsidian vault (git-managed directory)
|
||||||
|
vault_path = "~/Documents/second-brain-vault"
|
||||||
|
|
||||||
|
# Directory where downloaded media files are stored
|
||||||
|
media_dir = "~/.local/share/second-brain/media"
|
||||||
|
|
||||||
|
# Directory where Whisper SRT transcripts are stored
|
||||||
|
subtitles_dir = "~/.local/share/second-brain/subtitles"
|
||||||
|
|
||||||
|
# Whisper model size: tiny | base | small | medium | large
|
||||||
|
# small is a good balance of speed and accuracy
|
||||||
|
whisper_model = "small"
|
||||||
|
|
||||||
|
# Active knowledge domains
|
||||||
|
domains = ["development", "content", "business", "homelab"]
|
||||||
|
|
||||||
|
[scheduler]
|
||||||
|
# Time window for overnight processing (24h format, may cross midnight)
|
||||||
|
window_start = "22:00"
|
||||||
|
window_end = "06:00"
|
||||||
|
|
||||||
|
# Max Claude API tokens to consume per hour
|
||||||
|
max_tokens_per_hour = 100_000
|
||||||
|
|
||||||
|
# Minimum seconds between extraction calls (rate-limit smoother)
|
||||||
|
min_gap_seconds = 120
|
||||||
22
prompts/business.md
Normal file
22
prompts/business.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
domain: business
|
||||||
|
description: Entrepreneurship, marketing, operations, growth, finance
|
||||||
|
---
|
||||||
|
|
||||||
|
You are extracting structured knowledge from a business source.
|
||||||
|
Domain: business
|
||||||
|
{focus_section}
|
||||||
|
Focus on: strategy decisions, growth levers, operational models, pricing, marketing
|
||||||
|
channels, hiring, and real-world lessons from building or running a business.
|
||||||
|
|
||||||
|
Return a JSON object with exactly these keys:
|
||||||
|
|
||||||
|
- "summary": 2-4 sentence overview of what this source covers and why it matters
|
||||||
|
- "key_points": list of 5-10 concrete takeaways applicable to building or running a business
|
||||||
|
- "entities": list of companies, people, frameworks, books, metrics, or concepts mentioned
|
||||||
|
- "claims": list of specific assertions about markets, strategies, or business outcomes
|
||||||
|
- "open_questions": list of questions left open or worth further research
|
||||||
|
- "contradictions": list of points that contradict conventional business wisdom or other sources
|
||||||
|
|
||||||
|
Source transcript / content:
|
||||||
|
{transcript}
|
||||||
23
prompts/content.md
Normal file
23
prompts/content.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
domain: content
|
||||||
|
description: Content creation strategy, video production, audience building, YouTube
|
||||||
|
---
|
||||||
|
|
||||||
|
You are extracting structured knowledge from a content creation source.
|
||||||
|
Domain: content
|
||||||
|
{focus_section}
|
||||||
|
Focus on: production decisions, audience management strategies, framing techniques,
|
||||||
|
meta-commentary on the creative process, educational philosophy, and lessons about
|
||||||
|
what works (or doesn't) for building an engaged audience.
|
||||||
|
|
||||||
|
Return a JSON object with exactly these keys:
|
||||||
|
|
||||||
|
- "summary": 2-4 sentence overview of what this source covers and why it matters
|
||||||
|
- "key_points": list of 5-10 concrete takeaways a content creator can act on
|
||||||
|
- "entities": list of notable creators, platforms, tools, concepts, or formats mentioned
|
||||||
|
- "claims": list of specific assertions about what works, audience behaviour, or platform dynamics
|
||||||
|
- "open_questions": list of questions raised but not resolved, or worth experimenting with
|
||||||
|
- "contradictions": list of points that contradict common content creation advice
|
||||||
|
|
||||||
|
Source transcript / content:
|
||||||
|
{transcript}
|
||||||
22
prompts/development.md
Normal file
22
prompts/development.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
domain: development
|
||||||
|
description: Software engineering, programming languages, systems design, DevOps
|
||||||
|
---
|
||||||
|
|
||||||
|
You are extracting structured knowledge from a software development source.
|
||||||
|
Domain: development
|
||||||
|
{focus_section}
|
||||||
|
Focus on: architectural decisions, trade-offs, implementation techniques, tooling,
|
||||||
|
language features, performance insights, and lessons learned from real-world systems.
|
||||||
|
|
||||||
|
Return a JSON object with exactly these keys:
|
||||||
|
|
||||||
|
- "summary": 2-4 sentence overview of what this source covers and why it matters
|
||||||
|
- "key_points": list of 5-10 concrete takeaways a developer can act on
|
||||||
|
- "entities": list of notable tools, frameworks, languages, companies, people, or concepts mentioned
|
||||||
|
- "claims": list of specific factual or opinionated assertions made (e.g. "X is faster than Y because Z")
|
||||||
|
- "open_questions": list of questions raised but not resolved, or worth investigating further
|
||||||
|
- "contradictions": list of points that contradict common practice or other known sources
|
||||||
|
|
||||||
|
Source transcript / content:
|
||||||
|
{transcript}
|
||||||
23
prompts/homelab.md
Normal file
23
prompts/homelab.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
domain: homelab
|
||||||
|
description: Self-hosted infrastructure, networking, home servers, DevOps, NAS, Proxmox, Docker
|
||||||
|
---
|
||||||
|
|
||||||
|
You are extracting structured knowledge from a homelab / self-hosted infrastructure source.
|
||||||
|
Domain: homelab
|
||||||
|
{focus_section}
|
||||||
|
Focus on: hardware choices, networking topology, software stacks, container and VM
|
||||||
|
management, storage solutions, security hardening, and operational patterns for
|
||||||
|
running services at home or on a small private cloud.
|
||||||
|
|
||||||
|
Return a JSON object with exactly these keys:
|
||||||
|
|
||||||
|
- "summary": 2-4 sentence overview of what this source covers and why it matters
|
||||||
|
- "key_points": list of 5-10 concrete takeaways a homelab operator can apply
|
||||||
|
- "entities": list of hardware, software, services, protocols, vendors, or people mentioned
|
||||||
|
- "claims": list of specific assertions about performance, security, reliability, or configuration
|
||||||
|
- "open_questions": list of questions raised but not resolved, or worth testing in the lab
|
||||||
|
- "contradictions": list of points that contradict common homelab practice or other known sources
|
||||||
|
|
||||||
|
Source transcript / content:
|
||||||
|
{transcript}
|
||||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "second-brain"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Personal knowledge system: video/article extraction pipeline + LLM-maintained wiki"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"anthropic>=0.40.0",
|
||||||
|
"click>=8.1.0",
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
|
"openai-whisper",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"sqlalchemy>=2.0.0",
|
||||||
|
"tomli>=2.0.0",
|
||||||
|
"trafilatura>=1.9.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"yt-dlp>=2024.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
second-brain = "second_brain.main:cli"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/second_brain"]
|
||||||
3
src/second_brain/__init__.py
Normal file
3
src/second_brain/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""second-brain — personal knowledge extraction pipeline."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
6
src/second_brain/adapters/__init__.py
Normal file
6
src/second_brain/adapters/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""Source pull adapters."""
|
||||||
|
|
||||||
|
from second_brain.adapters.youtube import YouTubeAdapter
|
||||||
|
from second_brain.adapters.article import ArticleAdapter
|
||||||
|
|
||||||
|
__all__ = ["YouTubeAdapter", "ArticleAdapter"]
|
||||||
81
src/second_brain/adapters/article.py
Normal file
81
src/second_brain/adapters/article.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Web article pull adapter (stub).
|
||||||
|
|
||||||
|
Uses trafilatura to fetch and clean article text.
|
||||||
|
Phase 1: basic fetch + text extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import trafilatura
|
||||||
|
|
||||||
|
from second_brain.config import Config
|
||||||
|
from second_brain.models import Source, SourceStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleAdapter:
|
||||||
|
"""Fetch and extract text from a web article URL."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def pull(self, source: Source) -> bool:
|
||||||
|
"""Fetch the article and extract its text content.
|
||||||
|
|
||||||
|
Sets source.transcript_text, source.title, and source.status.
|
||||||
|
Returns True on success.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
downloaded = trafilatura.fetch_url(source.url)
|
||||||
|
if not downloaded:
|
||||||
|
source.error_message = f"Could not fetch URL: {source.url}"
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = trafilatura.extract(
|
||||||
|
downloaded,
|
||||||
|
include_comments=False,
|
||||||
|
include_tables=True,
|
||||||
|
no_fallback=False,
|
||||||
|
)
|
||||||
|
if not text:
|
||||||
|
source.error_message = "trafilatura returned empty text"
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
return False
|
||||||
|
|
||||||
|
meta = trafilatura.extract_metadata(downloaded)
|
||||||
|
if meta:
|
||||||
|
source.title = source.title or meta.title
|
||||||
|
source.author = meta.author
|
||||||
|
if meta.date:
|
||||||
|
try:
|
||||||
|
source.published_at = datetime.fromisoformat(meta.date)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
source.transcript_text = text
|
||||||
|
source.status = SourceStatus.TRANSCRIBED # articles skip PULLED state
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
source.error_message = str(exc)
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def looks_like_article(url: str) -> bool:
|
||||||
|
"""Heuristic: does this URL look like a web article (not a video)?"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
video_hosts = {
|
||||||
|
"youtube.com", "www.youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"vimeo.com", "www.vimeo.com",
|
||||||
|
}
|
||||||
|
return parsed.netloc not in video_hosts
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ArticleAdapter"]
|
||||||
232
src/second_brain/adapters/youtube.py
Normal file
232
src/second_brain/adapters/youtube.py
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
YouTube pull + Whisper transcribe adapter.
|
||||||
|
|
||||||
|
Ported from xtract/main.py (VideoDownloader + SubtitleExtractor classes).
|
||||||
|
Adapted to work with the second-brain Source model and Config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import whisper
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
|
from second_brain.config import Config
|
||||||
|
from second_brain.models import Source, SourceStatus
|
||||||
|
|
||||||
|
|
||||||
|
class VideoDownloader:
|
||||||
|
"""Download a YouTube video (or playlist) via yt-dlp.
|
||||||
|
|
||||||
|
Ported from xtract VideoDownloader — same core yt-dlp options,
|
||||||
|
adapted to write into config.media_dir.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def download(self, url: str) -> list[Path]:
|
||||||
|
"""Download video(s) and return paths to the downloaded files."""
|
||||||
|
config = self.config
|
||||||
|
config.media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
"format": "bestvideo[height<=720]+bestaudio/best[height<=720]",
|
||||||
|
"outtmpl": str(config.media_dir / "%(id)s.%(ext)s"),
|
||||||
|
"writeinfojson": True,
|
||||||
|
"ignoreerrors": True,
|
||||||
|
"no_warnings": False,
|
||||||
|
"quiet": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
downloaded: list[Path] = []
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
print(f"[yt-dlp] Fetching: {url}")
|
||||||
|
info = ydl.extract_info(url, download=True)
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
print(f"[yt-dlp] Error: could not extract info from {url}")
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
if "entries" in info:
|
||||||
|
for entry in info["entries"]:
|
||||||
|
if entry:
|
||||||
|
p = self._find_media_file(entry.get("id", ""))
|
||||||
|
if p:
|
||||||
|
downloaded.append(p)
|
||||||
|
else:
|
||||||
|
p = self._find_media_file(info.get("id", ""))
|
||||||
|
if p:
|
||||||
|
downloaded.append(p)
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
def _find_media_file(self, video_id: str) -> Optional[Path]:
|
||||||
|
for f in self.config.media_dir.glob(f"{video_id}.*"):
|
||||||
|
if f.suffix in {".mp4", ".mkv", ".webm", ".m4a"}:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_metadata(self, url: str) -> dict:
|
||||||
|
"""Fetch video metadata without downloading the media file."""
|
||||||
|
ydl_opts = {"quiet": True, "skip_download": True}
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False) or {}
|
||||||
|
return {
|
||||||
|
"title": info.get("title"),
|
||||||
|
"author": info.get("uploader"),
|
||||||
|
"duration_seconds": info.get("duration"),
|
||||||
|
"published_at": _parse_upload_date(info.get("upload_date")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleExtractor:
|
||||||
|
"""Transcribe a video file to an SRT subtitle file using OpenAI Whisper.
|
||||||
|
|
||||||
|
Ported from xtract SubtitleExtractor — identical core logic,
|
||||||
|
writes SRTs to config.subtitles_dir.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._model = None
|
||||||
|
|
||||||
|
def _load_model(self) -> None:
|
||||||
|
if self._model is None:
|
||||||
|
print(f"[Whisper] Loading {self.config.whisper_model} model…")
|
||||||
|
self._model = whisper.load_model(self.config.whisper_model)
|
||||||
|
print("[Whisper] Model ready")
|
||||||
|
|
||||||
|
def extract(self, media_file: Path) -> Optional[Path]:
|
||||||
|
"""Transcribe *media_file* and return the path to the SRT file."""
|
||||||
|
self.config.subtitles_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
srt_file = self.config.subtitles_dir / f"{media_file.stem}.srt"
|
||||||
|
|
||||||
|
if srt_file.exists():
|
||||||
|
print(f"[Whisper] Already transcribed: {srt_file.name}")
|
||||||
|
return srt_file
|
||||||
|
|
||||||
|
self._load_model()
|
||||||
|
print(f"[Whisper] Transcribing: {media_file.name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._model.transcribe(
|
||||||
|
str(media_file),
|
||||||
|
verbose=False,
|
||||||
|
language=None,
|
||||||
|
)
|
||||||
|
self._write_srt(result, srt_file)
|
||||||
|
print(f"[Whisper] Saved: {srt_file.name}")
|
||||||
|
return srt_file
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Whisper] Error transcribing {media_file.name}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_text(self, media_file: Path) -> Optional[str]:
|
||||||
|
"""Return the plain-text transcript (no SRT formatting)."""
|
||||||
|
self._load_model()
|
||||||
|
try:
|
||||||
|
result = self._model.transcribe(str(media_file), verbose=False)
|
||||||
|
return " ".join(seg["text"].strip() for seg in result["segments"])
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Whisper] Error: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_srt(result: dict, output_file: Path) -> None:
|
||||||
|
with open(output_file, "w", encoding="utf-8") as fh:
|
||||||
|
for i, seg in enumerate(result["segments"], start=1):
|
||||||
|
start = _fmt_timestamp(seg["start"])
|
||||||
|
end = _fmt_timestamp(seg["end"])
|
||||||
|
fh.write(f"{i}\n{start} --> {end}\n{seg['text'].strip()}\n\n")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def read_srt(srt_file: Path) -> str:
|
||||||
|
"""Read an SRT file and return its raw text content."""
|
||||||
|
return srt_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# High-level adapter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAdapter:
|
||||||
|
"""Orchestrates pull + transcribe for a YouTube Source record."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.downloader = VideoDownloader(config)
|
||||||
|
self.transcriber = SubtitleExtractor(config)
|
||||||
|
|
||||||
|
def pull(self, source: Source) -> bool:
|
||||||
|
"""Download the video. Updates source in-place, returns success."""
|
||||||
|
try:
|
||||||
|
meta = self.downloader.extract_metadata(source.url)
|
||||||
|
source.title = source.title or meta.get("title")
|
||||||
|
source.author = meta.get("author")
|
||||||
|
source.duration_seconds = meta.get("duration_seconds")
|
||||||
|
source.published_at = meta.get("published_at")
|
||||||
|
|
||||||
|
files = self.downloader.download(source.url)
|
||||||
|
if not files:
|
||||||
|
source.error_message = "yt-dlp returned no files"
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
return False
|
||||||
|
|
||||||
|
source.media_path = str(files[0])
|
||||||
|
source.status = SourceStatus.PULLED
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
source.error_message = str(exc)
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
return False
|
||||||
|
|
||||||
|
def transcribe(self, source: Source) -> bool:
|
||||||
|
"""Transcribe the downloaded video. Updates source in-place."""
|
||||||
|
if not source.media_path:
|
||||||
|
source.error_message = "No media_path; run pull first"
|
||||||
|
return False
|
||||||
|
|
||||||
|
media_file = Path(source.media_path)
|
||||||
|
srt_file = self.transcriber.extract(media_file)
|
||||||
|
if srt_file is None:
|
||||||
|
source.error_message = "Whisper transcription failed"
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
return False
|
||||||
|
|
||||||
|
source.transcript_path = str(srt_file)
|
||||||
|
source.transcript_text = SubtitleExtractor.read_srt(srt_file)
|
||||||
|
source.status = SourceStatus.TRANSCRIBED
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_timestamp(seconds: float) -> str:
|
||||||
|
h = int(seconds // 3600)
|
||||||
|
m = int((seconds % 3600) // 60)
|
||||||
|
s = int(seconds % 60)
|
||||||
|
ms = int((seconds % 1) * 1000)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_upload_date(upload_date: Optional[str]) -> Optional[datetime]:
|
||||||
|
"""Parse yt-dlp's YYYYMMDD upload_date string."""
|
||||||
|
if not upload_date or len(upload_date) != 8:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(upload_date, "%Y%m%d")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["VideoDownloader", "SubtitleExtractor", "YouTubeAdapter"]
|
||||||
5
src/second_brain/compiler/__init__.py
Normal file
5
src/second_brain/compiler/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Wiki compiler — folds accepted extractions into the Obsidian vault."""
|
||||||
|
|
||||||
|
from second_brain.compiler.wiki import WikiCompiler
|
||||||
|
|
||||||
|
__all__ = ["WikiCompiler"]
|
||||||
244
src/second_brain/compiler/wiki.py
Normal file
244
src/second_brain/compiler/wiki.py
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Wiki compiler (Karpathy LLM Wiki pattern).
|
||||||
|
|
||||||
|
Reads the accepted extractions from the DB, reads the existing Obsidian vault
|
||||||
|
pages, calls Claude to decide what to create/update, then writes the pages and
|
||||||
|
git-commits the vault before and after.
|
||||||
|
|
||||||
|
Phase 1 implementation:
|
||||||
|
- Generates one Markdown page per accepted source (slug from title/url).
|
||||||
|
- Stubs the git commit logic with clear interfaces.
|
||||||
|
- Does NOT yet do cross-source synthesis (that is Phase 2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
from second_brain.config import Config
|
||||||
|
from second_brain.database import get_database
|
||||||
|
from second_brain.models import Extraction, Source, SourceStatus, WikiPage
|
||||||
|
|
||||||
|
|
||||||
|
class WikiCompiler:
|
||||||
|
"""
|
||||||
|
Compiles accepted extractions into Obsidian vault Markdown pages.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
compiler = WikiCompiler(config)
|
||||||
|
stats = compiler.run()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config, api_key: Optional[str] = None) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._api_key = api_key or config.anthropic_api_key
|
||||||
|
|
||||||
|
def run(self) -> dict:
|
||||||
|
"""
|
||||||
|
Main entry point. Processes all accepted (not yet published) sources.
|
||||||
|
|
||||||
|
Returns a stats dict: {processed, created, updated, errors}.
|
||||||
|
"""
|
||||||
|
vault = self.config.vault_path
|
||||||
|
vault.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
stats = {"processed": 0, "created": 0, "updated": 0, "errors": 0}
|
||||||
|
|
||||||
|
with db.session() as sess:
|
||||||
|
sources = (
|
||||||
|
sess.query(Source)
|
||||||
|
.filter(Source.status == SourceStatus.ACCEPTED)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sources:
|
||||||
|
print("[Compiler] No accepted sources to compile.")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
sha_before = self._git_sha(vault)
|
||||||
|
|
||||||
|
for source in sources:
|
||||||
|
try:
|
||||||
|
created = self._compile_source(source, sess, vault)
|
||||||
|
if created:
|
||||||
|
stats["created"] += 1
|
||||||
|
else:
|
||||||
|
stats["updated"] += 1
|
||||||
|
source.status = SourceStatus.PUBLISHED
|
||||||
|
source.updated_at = datetime.utcnow()
|
||||||
|
stats["processed"] += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Compiler] Error processing source {source.id}: {exc}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
sha_after = self._git_commit(vault, stats["processed"])
|
||||||
|
|
||||||
|
# Update git sha on all written WikiPage rows
|
||||||
|
if sha_after:
|
||||||
|
for page in sess.query(WikiPage).filter(
|
||||||
|
WikiPage.git_sha_after.is_(None)
|
||||||
|
).all():
|
||||||
|
page.git_sha_before = sha_before
|
||||||
|
page.git_sha_after = sha_after
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Per-source page generation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _compile_source(
|
||||||
|
self, source: Source, sess, vault: Path
|
||||||
|
) -> bool:
|
||||||
|
"""Write a vault page for *source*. Returns True if newly created."""
|
||||||
|
extraction: Optional[Extraction] = source.extraction
|
||||||
|
if extraction is None:
|
||||||
|
raise ValueError(f"Source {source.id} has no extraction")
|
||||||
|
|
||||||
|
slug = _slugify(source.title or source.url)
|
||||||
|
domain_dir = vault / source.domain
|
||||||
|
domain_dir.mkdir(exist_ok=True)
|
||||||
|
page_path = domain_dir / f"{slug}.md"
|
||||||
|
relative_path = str(page_path.relative_to(vault))
|
||||||
|
|
||||||
|
content = self._render_page(source, extraction)
|
||||||
|
|
||||||
|
created = not page_path.exists()
|
||||||
|
page_path.write_text(content, encoding="utf-8")
|
||||||
|
print(f"[Compiler] {'Created' if created else 'Updated'}: {relative_path}")
|
||||||
|
|
||||||
|
# Upsert WikiPage tracking row
|
||||||
|
wiki_row = sess.query(WikiPage).filter_by(vault_path=relative_path).first()
|
||||||
|
if wiki_row is None:
|
||||||
|
wiki_row = WikiPage(
|
||||||
|
vault_path=relative_path,
|
||||||
|
domain=source.domain,
|
||||||
|
title=source.title or slug,
|
||||||
|
)
|
||||||
|
sess.add(wiki_row)
|
||||||
|
else:
|
||||||
|
wiki_row.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _render_page(source: Source, extraction: Extraction) -> str:
|
||||||
|
"""Render the Markdown content for a vault page."""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
title = source.title or source.url
|
||||||
|
lines.append(f"# {title}\n")
|
||||||
|
|
||||||
|
# Front-matter block
|
||||||
|
lines.append("---")
|
||||||
|
lines.append(f"url: {source.url}")
|
||||||
|
lines.append(f"domain: {source.domain}")
|
||||||
|
lines.append(f"source_type: {source.source_type.value}")
|
||||||
|
if source.published_at:
|
||||||
|
lines.append(f"published: {source.published_at.date()}")
|
||||||
|
lines.append(f"ingested: {source.ingested_at.date()}")
|
||||||
|
if source.focus:
|
||||||
|
lines.append(f"focus: {source.focus}")
|
||||||
|
lines.append("---\n")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
if extraction.summary:
|
||||||
|
lines.append("## Summary\n")
|
||||||
|
lines.append(extraction.summary + "\n")
|
||||||
|
|
||||||
|
# Key points
|
||||||
|
if extraction.key_points:
|
||||||
|
lines.append("## Key Points\n")
|
||||||
|
for pt in extraction.key_points:
|
||||||
|
lines.append(f"- {pt}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Claims
|
||||||
|
if extraction.claims:
|
||||||
|
lines.append("## Claims\n")
|
||||||
|
for c in extraction.claims:
|
||||||
|
lines.append(f"- {c}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Entities
|
||||||
|
if extraction.entities:
|
||||||
|
lines.append("## Entities\n")
|
||||||
|
lines.append(", ".join(f"[[{e}]]" for e in extraction.entities) + "\n")
|
||||||
|
|
||||||
|
# Open questions
|
||||||
|
if extraction.open_questions:
|
||||||
|
lines.append("## Open Questions\n")
|
||||||
|
for q in extraction.open_questions:
|
||||||
|
lines.append(f"- {q}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Contradictions
|
||||||
|
if extraction.contradictions:
|
||||||
|
lines.append("## Contradictions\n")
|
||||||
|
for ct in extraction.contradictions:
|
||||||
|
lines.append(f"- {ct}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Git helpers (stubbed — interface is clear for later wiring)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _git_sha(vault: Path) -> Optional[str]:
|
||||||
|
"""Return HEAD sha of the vault git repo, or None if not a git repo."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "HEAD"],
|
||||||
|
cwd=vault,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _git_commit(vault: Path, count: int) -> Optional[str]:
|
||||||
|
"""Stage all changes and commit. Returns new HEAD sha or None."""
|
||||||
|
try:
|
||||||
|
subprocess.run(["git", "add", "-A"], cwd=vault, check=True)
|
||||||
|
msg = f"second-brain: compile {count} source(s) [{datetime.utcnow().date()}]"
|
||||||
|
subprocess.run(["git", "commit", "-m", msg], cwd=vault, check=True)
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "HEAD"],
|
||||||
|
cwd=vault,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(text: str) -> str:
|
||||||
|
"""Convert a title to a filesystem-safe slug."""
|
||||||
|
text = text.lower()
|
||||||
|
text = re.sub(r"[^\w\s-]", "", text)
|
||||||
|
text = re.sub(r"[\s_-]+", "-", text)
|
||||||
|
text = text.strip("-")
|
||||||
|
return text[:100]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["WikiCompiler"]
|
||||||
135
src/second_brain/config.py
Normal file
135
src/second_brain/config.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
Configuration loading for second-brain.
|
||||||
|
|
||||||
|
Settings are read from config/settings.toml (or a path set via SECOND_BRAIN_CONFIG).
|
||||||
|
All values have sensible defaults so the system works out of the box.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib # Python 3.11+
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
import tomli as tomllib # type: ignore[no-reuse-impl]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DEFAULTS: dict[str, Any] = {
|
||||||
|
"db_path": "~/.local/share/second-brain/second_brain.db",
|
||||||
|
"vault_path": "~/Documents/second-brain-vault",
|
||||||
|
"media_dir": "~/.local/share/second-brain/media",
|
||||||
|
"subtitles_dir": "~/.local/share/second-brain/subtitles",
|
||||||
|
"whisper_model": "small",
|
||||||
|
"domains": ["development", "content", "business", "homelab"],
|
||||||
|
"scheduler": {
|
||||||
|
"window_start": "22:00",
|
||||||
|
"window_end": "06:00",
|
||||||
|
"max_tokens_per_hour": 100_000,
|
||||||
|
"min_gap_seconds": 120,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMAINS = ["development", "content", "business", "homelab"]
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Holds resolved configuration values."""
|
||||||
|
|
||||||
|
def __init__(self, raw: dict[str, Any]) -> None:
|
||||||
|
self._raw = raw
|
||||||
|
|
||||||
|
# --- paths ---
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_path(self) -> Path:
|
||||||
|
return Path(self._raw.get("db_path", _DEFAULTS["db_path"])).expanduser()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vault_path(self) -> Path:
|
||||||
|
return Path(self._raw.get("vault_path", _DEFAULTS["vault_path"])).expanduser()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_dir(self) -> Path:
|
||||||
|
return Path(self._raw.get("media_dir", _DEFAULTS["media_dir"])).expanduser()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitles_dir(self) -> Path:
|
||||||
|
return Path(
|
||||||
|
self._raw.get("subtitles_dir", _DEFAULTS["subtitles_dir"])
|
||||||
|
).expanduser()
|
||||||
|
|
||||||
|
# --- extraction ---
|
||||||
|
|
||||||
|
@property
|
||||||
|
def whisper_model(self) -> str:
|
||||||
|
return self._raw.get("whisper_model", _DEFAULTS["whisper_model"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domains(self) -> list[str]:
|
||||||
|
return self._raw.get("domains", _DEFAULTS["domains"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anthropic_api_key(self) -> str | None:
|
||||||
|
return os.getenv("ANTHROPIC_API_KEY")
|
||||||
|
|
||||||
|
# --- scheduler ---
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheduler(self) -> dict[str, Any]:
|
||||||
|
return {**_DEFAULTS["scheduler"], **self._raw.get("scheduler", {})}
|
||||||
|
|
||||||
|
# --- prompts dir ---
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prompts_dir(self) -> Path:
|
||||||
|
"""Directory containing per-domain prompt templates."""
|
||||||
|
here = Path(__file__).parent.parent.parent.parent # project root
|
||||||
|
return here / "prompts"
|
||||||
|
|
||||||
|
def ensure_dirs(self) -> None:
|
||||||
|
"""Create runtime directories if they don't exist."""
|
||||||
|
for d in (self.db_path.parent, self.media_dir, self.subtitles_dir):
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Loader
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_config_instance: Config | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path | None = None) -> Config:
|
||||||
|
"""Load (and cache) configuration from a TOML file."""
|
||||||
|
global _config_instance
|
||||||
|
if _config_instance is not None:
|
||||||
|
return _config_instance
|
||||||
|
|
||||||
|
if path is None:
|
||||||
|
env_path = os.getenv("SECOND_BRAIN_CONFIG")
|
||||||
|
if env_path:
|
||||||
|
path = Path(env_path)
|
||||||
|
else:
|
||||||
|
# Walk up from CWD looking for config/settings.toml
|
||||||
|
cwd = Path.cwd()
|
||||||
|
for candidate in [cwd / "config" / "settings.toml", cwd / "settings.toml"]:
|
||||||
|
if candidate.exists():
|
||||||
|
path = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
raw: dict[str, Any] = {}
|
||||||
|
if path and path.exists():
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
raw = tomllib.load(fh)
|
||||||
|
|
||||||
|
_config_instance = Config(raw)
|
||||||
|
return _config_instance
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Config", "DOMAINS", "load_config"]
|
||||||
5
src/second_brain/context/__init__.py
Normal file
5
src/second_brain/context/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Context assembly layer."""
|
||||||
|
|
||||||
|
from second_brain.context.assembler import ContextAssembler
|
||||||
|
|
||||||
|
__all__ = ["ContextAssembler"]
|
||||||
88
src/second_brain/context/assembler.py
Normal file
88
src/second_brain/context/assembler.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Context assembler — Phase 1 (thin).
|
||||||
|
|
||||||
|
Builds the user-turn prompt bundle that gets sent to the extraction engine.
|
||||||
|
Phase 1 bundle = domain template + optional focus directive + transcript text.
|
||||||
|
|
||||||
|
Future phases can layer in wiki context, related sources, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from second_brain.config import Config
|
||||||
|
from second_brain.models import Source
|
||||||
|
|
||||||
|
|
||||||
|
_FALLBACK_TEMPLATE = """\
|
||||||
|
You are extracting structured knowledge from the following source.
|
||||||
|
Domain: {domain}
|
||||||
|
{focus_section}
|
||||||
|
|
||||||
|
Return a JSON object with these keys:
|
||||||
|
- summary (string): 2-4 sentence overview
|
||||||
|
- key_points (list of strings): 5-10 takeaways
|
||||||
|
- entities (list of strings): notable people, tools, products, concepts
|
||||||
|
- claims (list of strings): specific factual assertions
|
||||||
|
- open_questions (list of strings): questions raised but unanswered
|
||||||
|
- contradictions (list of strings): points that contradict known facts
|
||||||
|
|
||||||
|
Source transcript / content:
|
||||||
|
{transcript}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ContextAssembler:
|
||||||
|
"""Assembles the prompt bundle for a single source."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._template_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
def assemble(self, source: Source) -> str:
|
||||||
|
"""Return the full user-turn prompt string for the extraction engine."""
|
||||||
|
template = self._load_template(source.domain)
|
||||||
|
transcript = source.transcript_text or ""
|
||||||
|
|
||||||
|
focus_section = ""
|
||||||
|
if source.focus:
|
||||||
|
focus_section = f"Focus area: {source.focus}\n"
|
||||||
|
|
||||||
|
return template.format(
|
||||||
|
domain=source.domain,
|
||||||
|
focus_section=focus_section,
|
||||||
|
transcript=transcript,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_template(self, domain: str) -> str:
|
||||||
|
"""Load and cache the domain prompt template from prompts/<domain>.md."""
|
||||||
|
if domain in self._template_cache:
|
||||||
|
return self._template_cache[domain]
|
||||||
|
|
||||||
|
template_path = self.config.prompts_dir / f"{domain}.md"
|
||||||
|
if template_path.exists():
|
||||||
|
raw = template_path.read_text(encoding="utf-8")
|
||||||
|
template = self._extract_template_body(raw)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[Assembler] Warning: no prompt template for domain {domain!r}, "
|
||||||
|
"using fallback"
|
||||||
|
)
|
||||||
|
template = _FALLBACK_TEMPLATE
|
||||||
|
|
||||||
|
self._template_cache[domain] = template
|
||||||
|
return template
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_template_body(raw: str) -> str:
|
||||||
|
"""Strip YAML front-matter if present, return body."""
|
||||||
|
if raw.startswith("---"):
|
||||||
|
parts = raw.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
return parts[2].strip()
|
||||||
|
return raw.strip()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ContextAssembler"]
|
||||||
81
src/second_brain/database.py
Normal file
81
src/second_brain/database.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Database engine and session management for second-brain.
|
||||||
|
|
||||||
|
Ported and adapted from xtract/database.py.
|
||||||
|
Uses a singleton pattern: call get_database() everywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from second_brain.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""Manages the SQLAlchemy engine and session factory."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
self.db_path = db_path
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.engine = create_engine(
|
||||||
|
f"sqlite:///{self.db_path}",
|
||||||
|
echo=False,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.SessionLocal = sessionmaker(
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
bind=self.engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
def init_db(self) -> None:
|
||||||
|
"""Create all tables (idempotent)."""
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
|
||||||
|
def get_session(self) -> Session:
|
||||||
|
"""Return a new bare session (caller must close it)."""
|
||||||
|
return self.SessionLocal()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session(self) -> Generator[Session, None, None]:
|
||||||
|
"""Context-manager that commits on exit and rolls back on error."""
|
||||||
|
sess = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield sess
|
||||||
|
sess.commit()
|
||||||
|
except Exception:
|
||||||
|
sess.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_db_instance: Database | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database(db_path: Path | None = None) -> Database:
|
||||||
|
"""Return the singleton Database, creating and initialising it on first call."""
|
||||||
|
global _db_instance
|
||||||
|
if _db_instance is None:
|
||||||
|
if db_path is None:
|
||||||
|
from second_brain.config import load_config
|
||||||
|
|
||||||
|
db_path = load_config().db_path
|
||||||
|
_db_instance = Database(db_path)
|
||||||
|
_db_instance.init_db()
|
||||||
|
return _db_instance
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Database", "get_database"]
|
||||||
6
src/second_brain/extractor/__init__.py
Normal file
6
src/second_brain/extractor/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""Stateless LLM extraction layer."""
|
||||||
|
|
||||||
|
from second_brain.extractor.engine import ExtractionEngine
|
||||||
|
from second_brain.extractor.schema import ExtractionResult, SourceMeta
|
||||||
|
|
||||||
|
__all__ = ["ExtractionEngine", "ExtractionResult", "SourceMeta"]
|
||||||
140
src/second_brain/extractor/engine.py
Normal file
140
src/second_brain/extractor/engine.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Single-shot LLM extraction engine.
|
||||||
|
|
||||||
|
Ported/refactored from xtract/analyzer.py.
|
||||||
|
Pure function: context bundle in → ExtractionResult out.
|
||||||
|
Uses the Anthropic SDK with a single messages.create() call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
from second_brain.extractor.schema import ExtractionResult, SourceMeta
|
||||||
|
from second_brain.models import Source
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractionEngine:
|
||||||
|
"""
|
||||||
|
Stateless LLM extractor.
|
||||||
|
|
||||||
|
Call extract() with a pre-assembled context bundle (prompt + transcript).
|
||||||
|
Returns a validated ExtractionResult or raises on failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODEL = "claude-sonnet-4-6"
|
||||||
|
MAX_TOKENS = 4096
|
||||||
|
|
||||||
|
def __init__(self, api_key: Optional[str] = None) -> None:
|
||||||
|
key = api_key or os.getenv("ANTHROPIC_API_KEY")
|
||||||
|
if not key:
|
||||||
|
raise ValueError(
|
||||||
|
"ANTHROPIC_API_KEY is required. Set the environment variable or "
|
||||||
|
"pass api_key to ExtractionEngine."
|
||||||
|
)
|
||||||
|
self.client = anthropic.Anthropic(api_key=key)
|
||||||
|
|
||||||
|
def extract(self, prompt: str, source: Source) -> ExtractionResult:
|
||||||
|
"""
|
||||||
|
Run a single-shot extraction call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The fully assembled context bundle (system + transcript).
|
||||||
|
source: The Source row (used to populate SourceMeta).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated ExtractionResult.
|
||||||
|
"""
|
||||||
|
print(f"[Extractor] Calling Claude for source {source.id} ({source.domain})…")
|
||||||
|
|
||||||
|
response = self.client.messages.create(
|
||||||
|
model=self.MODEL,
|
||||||
|
max_tokens=self.MAX_TOKENS,
|
||||||
|
system=self._system_prompt(),
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_text = response.content[0].text
|
||||||
|
usage = response.usage
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[Extractor] Done — "
|
||||||
|
f"input={usage.input_tokens} output={usage.output_tokens} tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = self._parse(raw_text, source)
|
||||||
|
|
||||||
|
# Attach token counts to the result for the caller to store
|
||||||
|
parsed._input_tokens = usage.input_tokens
|
||||||
|
parsed._output_tokens = usage.output_tokens
|
||||||
|
parsed._raw_response = raw_text
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _system_prompt() -> str:
|
||||||
|
return (
|
||||||
|
"You are a knowledge extraction assistant. "
|
||||||
|
"Given a source (transcript or article text) and a domain-specific prompt, "
|
||||||
|
"extract structured insights and return them as a single JSON object. "
|
||||||
|
"Return ONLY the JSON object — no markdown fences, no preamble."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse(self, raw: str, source: Source) -> ExtractionResult:
|
||||||
|
"""Parse Claude's JSON response into an ExtractionResult."""
|
||||||
|
text = raw.strip()
|
||||||
|
# Strip accidental markdown fences
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("\n", 1)[-1]
|
||||||
|
text = text.rsplit("```", 1)[0].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not parse JSON from LLM response: {exc}\n"
|
||||||
|
f"Raw response (first 500 chars):\n{raw[:500]}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
meta = SourceMeta(
|
||||||
|
url=source.url,
|
||||||
|
title=source.title,
|
||||||
|
source_type=source.source_type.value,
|
||||||
|
duration_or_length=_fmt_duration(source.duration_seconds),
|
||||||
|
published_at=source.published_at.isoformat() if source.published_at else None,
|
||||||
|
ingested_at=source.ingested_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExtractionResult(
|
||||||
|
source=meta,
|
||||||
|
domain=source.domain,
|
||||||
|
focus=source.focus,
|
||||||
|
summary=data.get("summary", ""),
|
||||||
|
key_points=data.get("key_points", []),
|
||||||
|
entities=data.get("entities", []),
|
||||||
|
claims=data.get("claims", []),
|
||||||
|
open_questions=data.get("open_questions", []),
|
||||||
|
contradictions=data.get("contradictions", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_duration(seconds: Optional[int]) -> Optional[str]:
|
||||||
|
if seconds is None:
|
||||||
|
return None
|
||||||
|
h = seconds // 3600
|
||||||
|
m = (seconds % 3600) // 60
|
||||||
|
s = seconds % 60
|
||||||
|
if h:
|
||||||
|
return f"{h}:{m:02d}:{s:02d}"
|
||||||
|
return f"{m}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ExtractionEngine"]
|
||||||
82
src/second_brain/extractor/schema.py
Normal file
82
src/second_brain/extractor/schema.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schema for the extraction output contract.
|
||||||
|
|
||||||
|
This is the canonical shape returned by the extractor engine and
|
||||||
|
stored in the Extraction model as JSON columns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl
|
||||||
|
|
||||||
|
|
||||||
|
class SourceMeta(BaseModel):
|
||||||
|
"""Metadata about the source (populated from the Source row)."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
source_type: str # "video" | "article"
|
||||||
|
duration_or_length: Optional[str] = None # e.g. "32:14" or "~1800 words"
|
||||||
|
published_at: Optional[str] = None
|
||||||
|
ingested_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractionResult(BaseModel):
|
||||||
|
"""
|
||||||
|
Canonical extraction output.
|
||||||
|
|
||||||
|
Contract between the extractor engine and every downstream consumer
|
||||||
|
(web UI, wiki compiler, scheduler).
|
||||||
|
"""
|
||||||
|
|
||||||
|
source: SourceMeta
|
||||||
|
|
||||||
|
domain: str = Field(description="One of: development, content, business, homelab")
|
||||||
|
focus: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional focus directive supplied when the source was queued",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary: str = Field(description="2-4 sentence summary of the source")
|
||||||
|
|
||||||
|
key_points: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Bulleted key takeaways (5-10 items)",
|
||||||
|
)
|
||||||
|
|
||||||
|
entities: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="People, tools, products, companies, concepts mentioned",
|
||||||
|
)
|
||||||
|
|
||||||
|
claims: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Specific factual claims or assertions made by the source",
|
||||||
|
)
|
||||||
|
|
||||||
|
open_questions: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Questions raised but not answered, or worth investigating further",
|
||||||
|
)
|
||||||
|
|
||||||
|
contradictions: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Points that contradict known facts or other sources",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_db_dict(self) -> dict:
|
||||||
|
"""Return the subset of fields stored in the Extraction table."""
|
||||||
|
return {
|
||||||
|
"summary": self.summary,
|
||||||
|
"key_points": self.key_points,
|
||||||
|
"entities": self.entities,
|
||||||
|
"claims": self.claims,
|
||||||
|
"open_questions": self.open_questions,
|
||||||
|
"contradictions": self.contradictions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ExtractionResult", "SourceMeta"]
|
||||||
291
src/second_brain/main.py
Normal file
291
src/second_brain/main.py
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
second-brain CLI entry point.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
add — queue a source URL (video or article)
|
||||||
|
process — run the full pipeline on pending/pulled/transcribed sources
|
||||||
|
serve — launch the FastAPI web UI
|
||||||
|
compile — run the wiki compiler on accepted sources
|
||||||
|
schedule — start the overnight scheduler
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from second_brain.config import DOMAINS, load_config
|
||||||
|
from second_brain.database import get_database
|
||||||
|
from second_brain.models import Source, SourceStatus, SourceType
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI group
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli() -> None:
|
||||||
|
"""second-brain — personal knowledge extraction pipeline."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# add
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument("url")
|
||||||
|
@click.option(
|
||||||
|
"--domain",
|
||||||
|
"-d",
|
||||||
|
type=click.Choice(DOMAINS),
|
||||||
|
default="development",
|
||||||
|
show_default=True,
|
||||||
|
help="Knowledge domain for extraction.",
|
||||||
|
)
|
||||||
|
@click.option("--focus", "-f", default=None, help="Optional focus directive.")
|
||||||
|
@click.option("--title", "-t", default=None, help="Override title.")
|
||||||
|
def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> None:
|
||||||
|
"""Queue a source URL for processing."""
|
||||||
|
config = load_config()
|
||||||
|
config.ensure_dirs()
|
||||||
|
db = get_database(config.db_path)
|
||||||
|
|
||||||
|
source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO
|
||||||
|
|
||||||
|
with db.session() as sess:
|
||||||
|
existing = sess.query(Source).filter_by(url=url).first()
|
||||||
|
if existing:
|
||||||
|
click.echo(
|
||||||
|
f"[add] Already queued (id={existing.id}, status={existing.status.value})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
url=url,
|
||||||
|
title=title,
|
||||||
|
domain=domain,
|
||||||
|
focus=focus,
|
||||||
|
source_type=source_type,
|
||||||
|
status=SourceStatus.PENDING,
|
||||||
|
ingested_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
sess.add(source)
|
||||||
|
|
||||||
|
click.echo(f"[add] Queued {source_type.value}: {url}")
|
||||||
|
click.echo(f" domain={domain}" + (f" focus={focus}" if focus else ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# process
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--limit", "-n", default=None, type=int, help="Max sources to process.")
|
||||||
|
@click.option(
|
||||||
|
"--skip-pull", is_flag=True, default=False, help="Skip the pull/download step."
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--skip-transcribe",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip the transcription step.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--skip-extract", is_flag=True, default=False, help="Skip the LLM extraction step."
|
||||||
|
)
|
||||||
|
def process(
|
||||||
|
limit: Optional[int],
|
||||||
|
skip_pull: bool,
|
||||||
|
skip_transcribe: bool,
|
||||||
|
skip_extract: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Run the pipeline on pending sources."""
|
||||||
|
from second_brain.adapters.youtube import YouTubeAdapter
|
||||||
|
from second_brain.adapters.article import ArticleAdapter
|
||||||
|
from second_brain.context.assembler import ContextAssembler
|
||||||
|
from second_brain.extractor.engine import ExtractionEngine
|
||||||
|
from second_brain.models import Extraction
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
config.ensure_dirs()
|
||||||
|
db = get_database(config.db_path)
|
||||||
|
|
||||||
|
yt_adapter = YouTubeAdapter(config)
|
||||||
|
art_adapter = ArticleAdapter(config)
|
||||||
|
assembler = ContextAssembler(config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = ExtractionEngine(api_key=config.anthropic_api_key)
|
||||||
|
except ValueError as exc:
|
||||||
|
if not skip_extract:
|
||||||
|
click.echo(f"[process] Warning: {exc}")
|
||||||
|
click.echo("[process] Extraction step will be skipped.")
|
||||||
|
skip_extract = True
|
||||||
|
engine = None
|
||||||
|
|
||||||
|
with db.session() as sess:
|
||||||
|
query = sess.query(Source).filter(
|
||||||
|
Source.status.in_([
|
||||||
|
SourceStatus.PENDING,
|
||||||
|
SourceStatus.PULLED,
|
||||||
|
SourceStatus.TRANSCRIBED,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
sources = query.all()
|
||||||
|
|
||||||
|
if not sources:
|
||||||
|
click.echo("[process] No sources to process.")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"[process] Processing {len(sources)} source(s)…")
|
||||||
|
|
||||||
|
for source in sources:
|
||||||
|
click.echo(f"\n [{source.id}] {source.url[:80]}")
|
||||||
|
|
||||||
|
with db.session() as sess:
|
||||||
|
source = sess.query(Source).get(source.id)
|
||||||
|
|
||||||
|
# Pull step
|
||||||
|
if not skip_pull and source.status == SourceStatus.PENDING:
|
||||||
|
click.echo(" → pull")
|
||||||
|
if source.source_type == SourceType.VIDEO:
|
||||||
|
ok = yt_adapter.pull(source)
|
||||||
|
else:
|
||||||
|
ok = art_adapter.pull(source)
|
||||||
|
if not ok:
|
||||||
|
click.echo(f" ✗ pull failed: {source.error_message}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Transcribe step (videos only — articles come out transcribed)
|
||||||
|
if (
|
||||||
|
not skip_transcribe
|
||||||
|
and source.status == SourceStatus.PULLED
|
||||||
|
and source.source_type == SourceType.VIDEO
|
||||||
|
):
|
||||||
|
click.echo(" → transcribe")
|
||||||
|
ok = yt_adapter.transcribe(source)
|
||||||
|
if not ok:
|
||||||
|
click.echo(f" ✗ transcribe failed: {source.error_message}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract step
|
||||||
|
if not skip_extract and source.status == SourceStatus.TRANSCRIBED and engine:
|
||||||
|
click.echo(" → extract")
|
||||||
|
try:
|
||||||
|
prompt = assembler.assemble(source)
|
||||||
|
result = engine.extract(prompt, source)
|
||||||
|
|
||||||
|
existing_ext = sess.query(Extraction).filter_by(
|
||||||
|
source_id=source.id
|
||||||
|
).first()
|
||||||
|
if existing_ext:
|
||||||
|
for k, v in result.to_db_dict().items():
|
||||||
|
setattr(existing_ext, k, v)
|
||||||
|
existing_ext.raw_response = getattr(result, "_raw_response", None)
|
||||||
|
existing_ext.input_tokens = getattr(result, "_input_tokens", None)
|
||||||
|
existing_ext.output_tokens = getattr(result, "_output_tokens", None)
|
||||||
|
else:
|
||||||
|
extraction = Extraction(
|
||||||
|
source_id=source.id,
|
||||||
|
raw_response=getattr(result, "_raw_response", None),
|
||||||
|
input_tokens=getattr(result, "_input_tokens", None),
|
||||||
|
output_tokens=getattr(result, "_output_tokens", None),
|
||||||
|
**result.to_db_dict(),
|
||||||
|
)
|
||||||
|
sess.add(extraction)
|
||||||
|
|
||||||
|
source.status = SourceStatus.ANALYZED
|
||||||
|
source.updated_at = datetime.utcnow()
|
||||||
|
click.echo(" ✓ analyzed")
|
||||||
|
except Exception as exc:
|
||||||
|
source.error_message = str(exc)
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
click.echo(f" ✗ extraction failed: {exc}")
|
||||||
|
|
||||||
|
click.echo("\n[process] Done.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# serve
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--host", default="127.0.0.1", show_default=True)
|
||||||
|
@click.option("--port", default=8000, show_default=True, type=int)
|
||||||
|
@click.option("--reload", is_flag=True, default=False)
|
||||||
|
def serve(host: str, port: int, reload: bool) -> None:
|
||||||
|
"""Launch the web UI."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
click.echo(f"[serve] Starting on http://{host}:{port}")
|
||||||
|
uvicorn.run(
|
||||||
|
"second_brain.web.app:app",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
reload=reload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("compile")
|
||||||
|
def compile_wiki() -> None:
|
||||||
|
"""Run the wiki compiler on accepted sources."""
|
||||||
|
from second_brain.compiler.wiki import WikiCompiler
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
compiler = WikiCompiler(config)
|
||||||
|
stats = compiler.run()
|
||||||
|
click.echo(
|
||||||
|
f"[compile] processed={stats['processed']} "
|
||||||
|
f"created={stats['created']} updated={stats['updated']} "
|
||||||
|
f"errors={stats['errors']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# schedule
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--dry-run", is_flag=True, default=False)
|
||||||
|
def schedule(dry_run: bool) -> None:
|
||||||
|
"""Start the overnight scheduler."""
|
||||||
|
from second_brain.scheduler.runner import Scheduler
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
scheduler = Scheduler(config)
|
||||||
|
scheduler.run(dry_run=dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_article(url: str) -> bool:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
video_hosts = {
|
||||||
|
"youtube.com", "www.youtube.com", "youtu.be",
|
||||||
|
"vimeo.com", "www.vimeo.com",
|
||||||
|
}
|
||||||
|
return parsed.netloc not in video_hosts
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
170
src/second_brain/models.py
Normal file
170
src/second_brain/models.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy ORM models for second-brain.
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
- Source — a queued/processed URL (video or article)
|
||||||
|
- Extraction — structured LLM output for a source
|
||||||
|
- WikiPage — track which vault pages have been written / updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Shared declarative base."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enums
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SourceType(enum.Enum):
|
||||||
|
VIDEO = "video"
|
||||||
|
ARTICLE = "article"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceStatus(enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PULLED = "pulled"
|
||||||
|
TRANSCRIBED = "transcribed"
|
||||||
|
ANALYZED = "analyzed"
|
||||||
|
ACCEPTED = "accepted"
|
||||||
|
PUBLISHED = "published"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Source(Base):
|
||||||
|
"""A URL queued for processing."""
|
||||||
|
|
||||||
|
__tablename__ = "sources"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
url: Mapped[str] = mapped_column(String(2000), unique=True, nullable=False)
|
||||||
|
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
source_type: Mapped[SourceType] = mapped_column(
|
||||||
|
Enum(SourceType), nullable=False, default=SourceType.VIDEO
|
||||||
|
)
|
||||||
|
|
||||||
|
# Domain and focus
|
||||||
|
domain: Mapped[str] = mapped_column(String(50), nullable=False, default="development")
|
||||||
|
focus: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Pipeline status
|
||||||
|
status: Mapped[SourceStatus] = mapped_column(
|
||||||
|
Enum(SourceStatus), nullable=False, default=SourceStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# File paths (set after pull/transcribe steps)
|
||||||
|
media_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
|
||||||
|
transcript_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
|
||||||
|
transcript_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Metadata from the source
|
||||||
|
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
duration_seconds: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
author: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
ingested_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
extraction: Mapped[Optional["Extraction"]] = relationship(
|
||||||
|
"Extraction", back_populates="source", uselist=False, cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"Source(id={self.id}, domain={self.domain!r}, "
|
||||||
|
f"status={self.status.value}, url={self.url!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Extraction(Base):
|
||||||
|
"""Structured LLM extraction output for a source."""
|
||||||
|
|
||||||
|
__tablename__ = "extractions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
source_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Core fields (mirrors the extraction schema contract)
|
||||||
|
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
key_points: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
entities: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
claims: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
open_questions: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
contradictions: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Raw LLM response (for debugging / re-parsing)
|
||||||
|
raw_response: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Token usage
|
||||||
|
input_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
output_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
source: Mapped["Source"] = relationship("Source", back_populates="extraction")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Extraction(id={self.id}, source_id={self.source_id})"
|
||||||
|
|
||||||
|
|
||||||
|
class WikiPage(Base):
|
||||||
|
"""Tracks vault pages written or updated by the wiki compiler."""
|
||||||
|
|
||||||
|
__tablename__ = "wiki_pages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Relative path inside the vault
|
||||||
|
vault_path: Mapped[str] = mapped_column(String(500), unique=True, nullable=False)
|
||||||
|
domain: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
|
||||||
|
# Git commit shas before/after last compile
|
||||||
|
git_sha_before: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||||
|
git_sha_after: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"WikiPage(id={self.id}, vault_path={self.vault_path!r})"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"Source",
|
||||||
|
"SourceStatus",
|
||||||
|
"SourceType",
|
||||||
|
"Extraction",
|
||||||
|
"WikiPage",
|
||||||
|
]
|
||||||
5
src/second_brain/scheduler/__init__.py
Normal file
5
src/second_brain/scheduler/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Overnight scheduler — rate-limit smoother with token-aware spacing."""
|
||||||
|
|
||||||
|
from second_brain.scheduler.runner import Scheduler
|
||||||
|
|
||||||
|
__all__ = ["Scheduler"]
|
||||||
148
src/second_brain/scheduler/runner.py
Normal file
148
src/second_brain/scheduler/runner.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
Overnight scheduler — rate-limit smoother with token-aware spacing.
|
||||||
|
|
||||||
|
Spreads LLM extraction calls across a configurable overnight window,
|
||||||
|
enforcing a minimum gap between calls and a max-tokens-per-hour budget.
|
||||||
|
Idempotent: safe to restart mid-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, time as dtime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from second_brain.config import Config
|
||||||
|
from second_brain.database import get_database
|
||||||
|
from second_brain.models import Source, SourceStatus
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
"""
|
||||||
|
Runs the pipeline on pending sources within a time window.
|
||||||
|
|
||||||
|
Config keys (from settings.toml [scheduler] table):
|
||||||
|
window_start — "HH:MM" (default "22:00")
|
||||||
|
window_end — "HH:MM" (default "06:00") may cross midnight
|
||||||
|
max_tokens_per_hour — int (default 100_000)
|
||||||
|
min_gap_seconds — int (default 120)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
sched = config.scheduler
|
||||||
|
self.window_start: dtime = _parse_time(sched.get("window_start", "22:00"))
|
||||||
|
self.window_end: dtime = _parse_time(sched.get("window_end", "06:00"))
|
||||||
|
self.max_tokens_per_hour: int = int(sched.get("max_tokens_per_hour", 100_000))
|
||||||
|
self.min_gap_seconds: int = int(sched.get("min_gap_seconds", 120))
|
||||||
|
|
||||||
|
def run(self, dry_run: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Process pending sources respecting the window and token budget.
|
||||||
|
|
||||||
|
Runs until the window closes or there are no more pending sources.
|
||||||
|
"""
|
||||||
|
from second_brain.context.assembler import ContextAssembler
|
||||||
|
from second_brain.extractor.engine import ExtractionEngine
|
||||||
|
from second_brain.models import Extraction
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
assembler = ContextAssembler(self.config)
|
||||||
|
engine = ExtractionEngine(api_key=self.config.anthropic_api_key)
|
||||||
|
|
||||||
|
tokens_this_hour: int = 0
|
||||||
|
hour_start: float = time.time()
|
||||||
|
processed: int = 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[Scheduler] Starting. Window {_fmt(self.window_start)}–{_fmt(self.window_end)}, "
|
||||||
|
f"budget {self.max_tokens_per_hour:,} tokens/hr, "
|
||||||
|
f"gap {self.min_gap_seconds}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
while self._in_window():
|
||||||
|
# Reset hourly token bucket
|
||||||
|
elapsed = time.time() - hour_start
|
||||||
|
if elapsed >= 3600:
|
||||||
|
tokens_this_hour = 0
|
||||||
|
hour_start = time.time()
|
||||||
|
|
||||||
|
with db.session() as sess:
|
||||||
|
source = (
|
||||||
|
sess.query(Source)
|
||||||
|
.filter(Source.status == SourceStatus.ANALYZED)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if source is None:
|
||||||
|
print("[Scheduler] No pending sources. Done.")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Token budget check (rough: assume avg 2k tokens per extraction)
|
||||||
|
if tokens_this_hour + 2000 > self.max_tokens_per_hour:
|
||||||
|
wait = int(3600 - elapsed) + 5
|
||||||
|
print(f"[Scheduler] Token budget reached. Sleeping {wait}s…")
|
||||||
|
if not dry_run:
|
||||||
|
time.sleep(wait)
|
||||||
|
tokens_this_hour = 0
|
||||||
|
hour_start = time.time()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"[Scheduler] DRY RUN — would process source {source.id}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Run extraction
|
||||||
|
try:
|
||||||
|
prompt = assembler.assemble(source)
|
||||||
|
result = engine.extract(prompt, source)
|
||||||
|
|
||||||
|
extraction = Extraction(
|
||||||
|
source_id=source.id,
|
||||||
|
raw_response=getattr(result, "_raw_response", None),
|
||||||
|
input_tokens=getattr(result, "_input_tokens", None),
|
||||||
|
output_tokens=getattr(result, "_output_tokens", None),
|
||||||
|
**result.to_db_dict(),
|
||||||
|
)
|
||||||
|
sess.add(extraction)
|
||||||
|
source.status = SourceStatus.ANALYZED
|
||||||
|
source.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
used = (getattr(result, "_input_tokens", 0) or 0) + (
|
||||||
|
getattr(result, "_output_tokens", 0) or 0
|
||||||
|
)
|
||||||
|
tokens_this_hour += used
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Scheduler] Error on source {source.id}: {exc}")
|
||||||
|
source.error_message = str(exc)
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
|
||||||
|
# Enforce minimum gap between calls
|
||||||
|
time.sleep(self.min_gap_seconds)
|
||||||
|
|
||||||
|
print(f"[Scheduler] Finished. Processed {processed} source(s).")
|
||||||
|
|
||||||
|
def _in_window(self) -> bool:
|
||||||
|
"""Return True if current time is within the scheduler window."""
|
||||||
|
now = datetime.now().time().replace(second=0, microsecond=0)
|
||||||
|
start = self.window_start
|
||||||
|
end = self.window_end
|
||||||
|
|
||||||
|
if start <= end:
|
||||||
|
return start <= now <= end
|
||||||
|
else:
|
||||||
|
# Window crosses midnight (e.g., 22:00 → 06:00)
|
||||||
|
return now >= start or now <= end
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(s: str) -> dtime:
|
||||||
|
h, m = s.split(":")
|
||||||
|
return dtime(int(h), int(m))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(t: dtime) -> str:
|
||||||
|
return t.strftime("%H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Scheduler"]
|
||||||
1
src/second_brain/web/__init__.py
Normal file
1
src/second_brain/web/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""FastAPI web interface for second-brain."""
|
||||||
294
src/second_brain/web/app.py
Normal file
294
src/second_brain/web/app.py
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
"""
|
||||||
|
FastAPI web interface for second-brain.
|
||||||
|
|
||||||
|
Ported and extended from xtract/webapp.py.
|
||||||
|
New additions:
|
||||||
|
- Domain and status filter params on the source list
|
||||||
|
- Source detail shows the full ExtractionResult
|
||||||
|
- Accept / reject HTMX actions
|
||||||
|
- Queue view (pending + in-progress sources)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from second_brain.config import DOMAINS, load_config
|
||||||
|
from second_brain.database import get_database
|
||||||
|
from second_brain.models import Extraction, Source, SourceStatus, SourceType
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="second-brain",
|
||||||
|
description="Personal knowledge extraction pipeline",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
_templates_dir = Path(__file__).parent / "templates"
|
||||||
|
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic response models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SourceResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
url: str
|
||||||
|
title: Optional[str]
|
||||||
|
domain: str
|
||||||
|
status: str
|
||||||
|
source_type: str
|
||||||
|
focus: Optional[str]
|
||||||
|
ingested_at: str
|
||||||
|
has_extraction: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractionResponse(BaseModel):
|
||||||
|
summary: Optional[str]
|
||||||
|
key_points: Optional[list]
|
||||||
|
entities: Optional[list]
|
||||||
|
claims: Optional[list]
|
||||||
|
open_questions: Optional[list]
|
||||||
|
contradictions: Optional[list]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTML routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(
|
||||||
|
request: Request,
|
||||||
|
domain: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Main source list with optional domain/status filters."""
|
||||||
|
db = get_database()
|
||||||
|
with db.session() as sess:
|
||||||
|
q = sess.query(Source)
|
||||||
|
if domain and domain in DOMAINS:
|
||||||
|
q = q.filter(Source.domain == domain)
|
||||||
|
if status:
|
||||||
|
try:
|
||||||
|
q = q.filter(Source.status == SourceStatus[status.upper()])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
sources = q.order_by(Source.ingested_at.desc()).all()
|
||||||
|
source_list = [_source_to_dict(s) for s in sources]
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"sources": source_list,
|
||||||
|
"domains": DOMAINS,
|
||||||
|
"statuses": [s.value for s in SourceStatus],
|
||||||
|
"selected_domain": domain or "",
|
||||||
|
"selected_status": status or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sources/{source_id}", response_class=HTMLResponse)
|
||||||
|
async def source_detail(request: Request, source_id: int):
|
||||||
|
"""Source detail page with extraction results and accept/reject actions."""
|
||||||
|
db = get_database()
|
||||||
|
with db.session() as sess:
|
||||||
|
source = sess.query(Source).filter(Source.id == source_id).first()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
source_data = _source_to_dict(source)
|
||||||
|
extraction_data = _extraction_to_dict(source.extraction) if source.extraction else None
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"source_detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"source": source_data,
|
||||||
|
"extraction": extraction_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/queue", response_class=HTMLResponse)
|
||||||
|
async def queue_view(request: Request):
|
||||||
|
"""Queue view: pending and in-flight sources."""
|
||||||
|
db = get_database()
|
||||||
|
in_flight_statuses = [SourceStatus.PENDING, SourceStatus.PULLED, SourceStatus.TRANSCRIBED]
|
||||||
|
with db.session() as sess:
|
||||||
|
sources = (
|
||||||
|
sess.query(Source)
|
||||||
|
.filter(Source.status.in_(in_flight_statuses))
|
||||||
|
.order_by(Source.ingested_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
source_list = [_source_to_dict(s) for s in sources]
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"sources": source_list,
|
||||||
|
"domains": DOMAINS,
|
||||||
|
"statuses": [s.value for s in SourceStatus],
|
||||||
|
"selected_domain": "",
|
||||||
|
"selected_status": "",
|
||||||
|
"page_title": "Queue",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTMX action endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/sources/{source_id}/accept")
|
||||||
|
async def accept_source(request: Request, source_id: int):
|
||||||
|
"""Mark a source as accepted (ready for wiki compilation)."""
|
||||||
|
db = get_database()
|
||||||
|
with db.session() as sess:
|
||||||
|
source = sess.query(Source).filter(Source.id == source_id).first()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
source.status = SourceStatus.ACCEPTED
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"source_detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"source": _source_to_dict(source),
|
||||||
|
"extraction": _extraction_to_dict(source.extraction) if source.extraction else None,
|
||||||
|
"flash": "Accepted — will be included in next wiki compile.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/sources/{source_id}/reject")
|
||||||
|
async def reject_source(request: Request, source_id: int):
|
||||||
|
"""Mark a source as failed/rejected (excluded from wiki)."""
|
||||||
|
db = get_database()
|
||||||
|
with db.session() as sess:
|
||||||
|
source = sess.query(Source).filter(Source.id == source_id).first()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
source.status = SourceStatus.FAILED
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"source_detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"source": _source_to_dict(source),
|
||||||
|
"extraction": _extraction_to_dict(source.extraction) if source.extraction else None,
|
||||||
|
"flash": "Rejected.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON API endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sources", response_model=List[SourceResponse])
|
||||||
|
async def api_sources(
|
||||||
|
domain: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
):
|
||||||
|
db = get_database()
|
||||||
|
with db.session() as sess:
|
||||||
|
q = sess.query(Source)
|
||||||
|
if domain:
|
||||||
|
q = q.filter(Source.domain == domain)
|
||||||
|
if status:
|
||||||
|
try:
|
||||||
|
q = q.filter(Source.status == SourceStatus[status.upper()])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
sources = q.order_by(Source.ingested_at.desc()).all()
|
||||||
|
return [_source_to_response(s) for s in sources]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sources/{source_id}/extraction", response_model=ExtractionResponse)
|
||||||
|
async def api_extraction(source_id: int):
|
||||||
|
db = get_database()
|
||||||
|
with db.session() as sess:
|
||||||
|
extraction = sess.query(Extraction).filter_by(source_id=source_id).first()
|
||||||
|
if not extraction:
|
||||||
|
raise HTTPException(status_code=404, detail="No extraction for this source")
|
||||||
|
return ExtractionResponse(
|
||||||
|
summary=extraction.summary,
|
||||||
|
key_points=extraction.key_points,
|
||||||
|
entities=extraction.entities,
|
||||||
|
claims=extraction.claims,
|
||||||
|
open_questions=extraction.open_questions,
|
||||||
|
contradictions=extraction.contradictions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _source_to_dict(s: Source) -> dict:
|
||||||
|
return {
|
||||||
|
"id": s.id,
|
||||||
|
"url": s.url,
|
||||||
|
"title": s.title or s.url,
|
||||||
|
"domain": s.domain,
|
||||||
|
"status": s.status.value,
|
||||||
|
"source_type": s.source_type.value,
|
||||||
|
"focus": s.focus,
|
||||||
|
"ingested_at": s.ingested_at.strftime("%Y-%m-%d %H:%M") if s.ingested_at else "",
|
||||||
|
"has_extraction": s.extraction is not None,
|
||||||
|
"error_message": s.error_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extraction_to_dict(e: Optional[Extraction]) -> Optional[dict]:
|
||||||
|
if e is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"summary": e.summary,
|
||||||
|
"key_points": e.key_points or [],
|
||||||
|
"entities": e.entities or [],
|
||||||
|
"claims": e.claims or [],
|
||||||
|
"open_questions": e.open_questions or [],
|
||||||
|
"contradictions": e.contradictions or [],
|
||||||
|
"input_tokens": e.input_tokens,
|
||||||
|
"output_tokens": e.output_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _source_to_response(s: Source) -> SourceResponse:
|
||||||
|
return SourceResponse(
|
||||||
|
id=s.id,
|
||||||
|
url=s.url,
|
||||||
|
title=s.title,
|
||||||
|
domain=s.domain,
|
||||||
|
status=s.status.value,
|
||||||
|
source_type=s.source_type.value,
|
||||||
|
focus=s.focus,
|
||||||
|
ingested_at=s.ingested_at.isoformat() if s.ingested_at else "",
|
||||||
|
has_extraction=s.extraction is not None,
|
||||||
|
)
|
||||||
30
src/second_brain/web/templates/base.html
Normal file
30
src/second_brain/web/templates/base.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}second-brain{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
|
||||||
|
<header class="bg-gradient-to-r from-indigo-700 to-violet-700 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<a href="/" class="text-2xl font-bold tracking-tight hover:text-indigo-100">second-brain</a>
|
||||||
|
<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="/" class="hover:text-indigo-200 transition-colors">Sources</a>
|
||||||
|
<a href="/queue" class="hover:text-indigo-200 transition-colors">Queue</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
94
src/second_brain/web/templates/index.html
Normal file
94
src/second_brain/web/templates/index.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if page_title %}{{ page_title }} — {% endif %}second-brain{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-800">
|
||||||
|
{{ page_title or "Sources" }}
|
||||||
|
<span class="text-base font-normal text-gray-500 ml-2">({{ sources|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" action="/" class="bg-white rounded-lg shadow-sm p-4 mb-6 flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Domain</label>
|
||||||
|
<select name="domain" class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||||
|
<option value="">All domains</option>
|
||||||
|
{% for d in domains %}
|
||||||
|
<option value="{{ d }}" {% if selected_domain == d %}selected{% endif %}>{{ d }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||||
|
<select name="status" class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {% if selected_status == s %}selected{% endif %}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700 transition-colors">
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
<a href="/" class="px-4 py-1.5 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 transition-colors">
|
||||||
|
Clear
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Source list -->
|
||||||
|
{% if sources %}
|
||||||
|
<div class="grid gap-3">
|
||||||
|
{% for source in sources %}
|
||||||
|
{% 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'
|
||||||
|
} %}
|
||||||
|
<a href="/sources/{{ source.id }}"
|
||||||
|
class="block bg-white rounded-lg border border-gray-200 p-4 hover:border-indigo-400 hover:shadow-sm transition-all duration-150 group">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-gray-900 group-hover:text-indigo-700 truncate">
|
||||||
|
{{ source.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ source.url }}</p>
|
||||||
|
{% if source.focus %}
|
||||||
|
<p class="text-xs text-gray-500 mt-1 italic">Focus: {{ source.focus }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-1.5 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>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-20 bg-white rounded-lg border border-dashed border-gray-300">
|
||||||
|
<p class="text-gray-400 text-lg">No sources found.</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Run <code class="bg-gray-100 px-1 rounded">second-brain add <url></code> to queue one.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
179
src/second_brain/web/templates/source_detail.html
Normal file
179
src/second_brain/web/templates/source_detail.html
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ source.title }} — second-brain{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Back + title -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-3">
|
||||||
|
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
Back to sources
|
||||||
|
</a>
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900">{{ source.title }}</h2>
|
||||||
|
<a href="{{ source.url }}" target="_blank"
|
||||||
|
class="text-sm text-indigo-500 hover:text-indigo-700 break-all">{{ source.url }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if flash %}
|
||||||
|
<div class="mb-4 px-4 py-3 bg-green-50 border border-green-200 text-green-800 rounded text-sm">
|
||||||
|
{{ flash }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Meta row -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6">
|
||||||
|
{% 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'
|
||||||
|
} %}
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
{{ status_colors.get(source.status, 'bg-gray-100 text-gray-700') }}">
|
||||||
|
{{ source.status }}
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-700">
|
||||||
|
{{ source.domain }}
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-700">
|
||||||
|
{{ source.source_type }}
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 text-sm text-gray-500">
|
||||||
|
added {{ source.ingested_at }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if source.focus %}
|
||||||
|
<div class="mb-6 bg-indigo-50 border border-indigo-100 rounded p-3 text-sm text-indigo-800">
|
||||||
|
<span class="font-medium">Focus:</span> {{ source.focus }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if source.error_message %}
|
||||||
|
<div class="mb-6 bg-red-50 border border-red-200 rounded p-3 text-sm text-red-800">
|
||||||
|
<span class="font-medium">Error:</span> {{ source.error_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Accept / Reject actions (only when analyzed) -->
|
||||||
|
{% if source.status == 'analyzed' %}
|
||||||
|
<div class="flex gap-3 mb-8">
|
||||||
|
<button hx-post="/sources/{{ source.id }}/accept"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-5 py-2 bg-green-600 text-white text-sm font-semibold rounded hover:bg-green-700 transition-colors">
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button hx-post="/sources/{{ source.id }}/reject"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-5 py-2 bg-red-500 text-white text-sm font-semibold rounded hover:bg-red-600 transition-colors">
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Extraction results -->
|
||||||
|
{% if extraction %}
|
||||||
|
<div class="space-y-5">
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
{% if extraction.summary %}
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Summary</h3>
|
||||||
|
<p class="text-gray-800 leading-relaxed">{{ extraction.summary }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Key points -->
|
||||||
|
{% if extraction.key_points %}
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Key Points</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for pt in extraction.key_points %}
|
||||||
|
<li class="flex gap-2 text-gray-800 text-sm">
|
||||||
|
<span class="text-indigo-400 mt-0.5">•</span>
|
||||||
|
<span>{{ pt }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Claims -->
|
||||||
|
{% if extraction.claims %}
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Claims</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for c in extraction.claims %}
|
||||||
|
<li class="flex gap-2 text-gray-800 text-sm">
|
||||||
|
<span class="text-amber-400 mt-0.5">→</span>
|
||||||
|
<span>{{ c }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Entities -->
|
||||||
|
{% if extraction.entities %}
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Entities</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for e in extraction.entities %}
|
||||||
|
<span class="px-2.5 py-1 bg-gray-100 text-gray-700 rounded text-sm">{{ e }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Open questions -->
|
||||||
|
{% if extraction.open_questions %}
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Open Questions</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for q in extraction.open_questions %}
|
||||||
|
<li class="flex gap-2 text-gray-800 text-sm">
|
||||||
|
<span class="text-teal-400 mt-0.5">?</span>
|
||||||
|
<span>{{ q }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Contradictions -->
|
||||||
|
{% if extraction.contradictions %}
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Contradictions</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for ct in extraction.contradictions %}
|
||||||
|
<li class="flex gap-2 text-gray-800 text-sm">
|
||||||
|
<span class="text-red-400 mt-0.5">✗</span>
|
||||||
|
<span>{{ ct }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Token usage footer -->
|
||||||
|
{% if extraction.input_tokens or extraction.output_tokens %}
|
||||||
|
<p class="text-xs text-gray-400 text-right">
|
||||||
|
tokens: {{ extraction.input_tokens or 0 }} in / {{ extraction.output_tokens or 0 }} out
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-16 bg-white rounded-lg border border-dashed border-gray-300">
|
||||||
|
<p class="text-gray-400">No extraction yet.</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Run <code class="bg-gray-100 px-1 rounded">second-brain process</code> to analyze this source.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
Reference in New Issue
Block a user