Spaces:
Runtime error
Runtime error
Commit ·
94b5741
1
Parent(s): 00a4363
added search
Browse files- .env.example +3 -6
- .gitignore +1 -0
- README.md +3 -4
- backend/alembic/versions/0001_initial.py +2 -11
- backend/app/config.py +3 -6
- backend/app/corpus.py +265 -0
- backend/app/llm/embeddings.py +0 -46
- backend/app/llm/signatures.py +23 -16
- backend/app/models/__init__.py +0 -2
- backend/app/models/content.py +8 -24
- backend/app/pipeline/ingest.py +45 -54
- backend/app/pipeline/research.py +109 -0
- backend/app/pipeline/retrieval.py +0 -62
- backend/app/routers/notebooks.py +54 -20
- backend/requirements.txt +0 -2
- backend/tests/test_corpus.py +148 -0
- backend/tests/test_misc.py +3 -4
- docker-compose.yml +7 -1
- docs/DESIGN.md +60 -17
- docs/PHASE0.md +5 -4
- frontend/src/pages/NotebookView.tsx +34 -6
- frontend/src/styles/app.css +17 -0
.env.example
CHANGED
|
@@ -38,12 +38,9 @@ LLM_TEMPERATURE=1.0
|
|
| 38 |
LLM_MAX_TOKENS=16000
|
| 39 |
LLM_REASONING_EFFORT=low
|
| 40 |
|
| 41 |
-
# ----
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
EMBEDDING_DIM=1024
|
| 45 |
-
EMBEDDING_API_BASE=
|
| 46 |
-
EMBEDDING_API_KEY=
|
| 47 |
|
| 48 |
# ---- ElevenLabs (Phase 1 audio)
|
| 49 |
ELEVENLABS_API_KEY=
|
|
|
|
| 38 |
LLM_MAX_TOKENS=16000
|
| 39 |
LLM_REASONING_EFFORT=low
|
| 40 |
|
| 41 |
+
# ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
|
| 42 |
+
CORPUS_CACHE_DIR=.corpus-cache
|
| 43 |
+
RESEARCH_MAX_STEPS=10
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
# ---- ElevenLabs (Phase 1 audio)
|
| 46 |
ELEVENLABS_API_KEY=
|
.gitignore
CHANGED
|
@@ -8,3 +8,4 @@ __pycache__/
|
|
| 8 |
.ruff_cache/
|
| 9 |
out/
|
| 10 |
renderer/build/
|
|
|
|
|
|
| 8 |
.ruff_cache/
|
| 9 |
out/
|
| 10 |
renderer/build/
|
| 11 |
+
.corpus-cache/
|
README.md
CHANGED
|
@@ -18,7 +18,7 @@ docs/ Design doc and task lists
|
|
| 18 |
## Run locally
|
| 19 |
|
| 20 |
```bash
|
| 21 |
-
cp .env.example .env # fill LLM_API_KEY (Z.ai),
|
| 22 |
docker compose up --build
|
| 23 |
```
|
| 24 |
|
|
@@ -31,7 +31,7 @@ Without Docker:
|
|
| 31 |
|
| 32 |
```bash
|
| 33 |
cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
|
| 34 |
-
alembic upgrade head && uvicorn app.main:app --reload # needs Postgres
|
| 35 |
arq app.worker.WorkerSettings # second terminal
|
| 36 |
cd frontend && npm install && npm run dev
|
| 37 |
cd renderer && npm install && npm start # or: npm run studio
|
|
@@ -44,6 +44,7 @@ cd renderer && npm install && npm start # or: npm run studio
|
|
| 44 |
| Cloudflare R2 storage (keys, presign, uploads) | `backend/app/services/storage.py`, `backend/app/routers/storage.py` |
|
| 45 |
| Resend email + unsubscribe + campaigns | `backend/app/services/email.py`, `backend/app/worker.py` |
|
| 46 |
| Google + email code auth, JWT revocation | `backend/app/auth.py`, `backend/app/routers/auth.py` |
|
|
|
|
| 47 |
| LLM provider (swap by env) | `backend/app/llm/provider.py`, `.env` `LLM_*` |
|
| 48 |
| Three plans, billing off | `backend/app/services/plans.py`, `BILLING_ENABLED` |
|
| 49 |
| Blog posts | `frontend/src/content/blogPosts.ts` (`/blogs`, `/blogs/:slug`) |
|
|
@@ -61,8 +62,6 @@ LLM_MODEL=anthropic/claude-sonnet-5 LLM_API_BASE=
|
|
| 61 |
LLM_MODEL=openai/gpt-5 LLM_API_BASE=
|
| 62 |
```
|
| 63 |
|
| 64 |
-
Changing `EMBEDDING_DIM` needs a new migration and a re-embed.
|
| 65 |
-
|
| 66 |
## Rules
|
| 67 |
|
| 68 |
- No em dashes in any UI, email or generated copy. `npm run check:copy` and
|
|
|
|
| 18 |
## Run locally
|
| 19 |
|
| 20 |
```bash
|
| 21 |
+
cp .env.example .env # fill LLM_API_KEY (Z.ai), GOOGLE_CLIENT_ID, RESEND_API_KEY
|
| 22 |
docker compose up --build
|
| 23 |
```
|
| 24 |
|
|
|
|
| 31 |
|
| 32 |
```bash
|
| 33 |
cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
|
| 34 |
+
alembic upgrade head && uvicorn app.main:app --reload # needs Postgres + Redis
|
| 35 |
arq app.worker.WorkerSettings # second terminal
|
| 36 |
cd frontend && npm install && npm run dev
|
| 37 |
cd renderer && npm install && npm start # or: npm run studio
|
|
|
|
| 44 |
| Cloudflare R2 storage (keys, presign, uploads) | `backend/app/services/storage.py`, `backend/app/routers/storage.py` |
|
| 45 |
| Resend email + unsubscribe + campaigns | `backend/app/services/email.py`, `backend/app/worker.py` |
|
| 46 |
| Google + email code auth, JWT revocation | `backend/app/auth.py`, `backend/app/routers/auth.py` |
|
| 47 |
+
| Research over posts as files (no embeddings) | `backend/app/corpus.py`, `backend/app/pipeline/research.py` |
|
| 48 |
| LLM provider (swap by env) | `backend/app/llm/provider.py`, `.env` `LLM_*` |
|
| 49 |
| Three plans, billing off | `backend/app/services/plans.py`, `BILLING_ENABLED` |
|
| 50 |
| Blog posts | `frontend/src/content/blogPosts.ts` (`/blogs`, `/blogs/:slug`) |
|
|
|
|
| 62 |
LLM_MODEL=openai/gpt-5 LLM_API_BASE=
|
| 63 |
```
|
| 64 |
|
|
|
|
|
|
|
| 65 |
## Rules
|
| 66 |
|
| 67 |
- No em dashes in any UI, email or generated copy. `npm run check:copy` and
|
backend/alembic/versions/0001_initial.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""initial schema:
|
| 2 |
|
| 3 |
Initial revision builds from the models so schema and code start identical. Every later change
|
| 4 |
must be a normal autogenerated revision (alembic revision --autogenerate -m "...").
|
|
@@ -10,7 +10,6 @@ Create Date: 2026-09-22
|
|
| 10 |
from alembic import op
|
| 11 |
|
| 12 |
import app.models # noqa: F401
|
| 13 |
-
from app.config import settings
|
| 14 |
from app.db import Base
|
| 15 |
|
| 16 |
revision = "0001_initial"
|
|
@@ -20,15 +19,7 @@ depends_on = None
|
|
| 20 |
|
| 21 |
|
| 22 |
def upgrade() -> None:
|
| 23 |
-
op.
|
| 24 |
-
bind = op.get_bind()
|
| 25 |
-
Base.metadata.create_all(bind=bind)
|
| 26 |
-
# HNSW index for cosine search over chunk embeddings (pgvector supports HNSW up to 2000 dims).
|
| 27 |
-
if settings.embedding_dim <= 2000:
|
| 28 |
-
op.execute(
|
| 29 |
-
"CREATE INDEX IF NOT EXISTS ix_chunks_embedding_hnsw ON chunks "
|
| 30 |
-
"USING hnsw (embedding vector_cosine_ops)"
|
| 31 |
-
)
|
| 32 |
|
| 33 |
|
| 34 |
def downgrade() -> None:
|
|
|
|
| 1 |
+
"""initial schema: all Phase 0 tables
|
| 2 |
|
| 3 |
Initial revision builds from the models so schema and code start identical. Every later change
|
| 4 |
must be a normal autogenerated revision (alembic revision --autogenerate -m "...").
|
|
|
|
| 10 |
from alembic import op
|
| 11 |
|
| 12 |
import app.models # noqa: F401
|
|
|
|
| 13 |
from app.db import Base
|
| 14 |
|
| 15 |
revision = "0001_initial"
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def upgrade() -> None:
|
| 22 |
+
Base.metadata.create_all(bind=op.get_bind())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def downgrade() -> None:
|
backend/app/config.py
CHANGED
|
@@ -51,12 +51,9 @@ class Settings(BaseSettings):
|
|
| 51 |
# GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
|
| 52 |
llm_reasoning_effort: str = "low"
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
embedding_api_base: str = ""
|
| 58 |
-
embedding_api_key: str = ""
|
| 59 |
-
embedding_dim: int = 1024
|
| 60 |
|
| 61 |
# ElevenLabs
|
| 62 |
elevenlabs_api_key: str = ""
|
|
|
|
| 51 |
# GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
|
| 52 |
llm_reasoning_effort: str = "low"
|
| 53 |
|
| 54 |
+
# Corpus: posts as markdown files. R2 is the source of truth; each process keeps a disk cache.
|
| 55 |
+
corpus_cache_dir: str = ".corpus-cache"
|
| 56 |
+
research_max_steps: int = 10
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
# ElevenLabs
|
| 59 |
elevenlabs_api_key: str = ""
|
backend/app/corpus.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A workspace's archive as a file system.
|
| 2 |
+
|
| 3 |
+
Every post is one markdown file. The research agent navigates it with list/search/read tools, the
|
| 4 |
+
same way a coding agent navigates a repo, so there is no vector index to build or keep in sync.
|
| 5 |
+
|
| 6 |
+
Layout (paths relative to the corpus root):
|
| 7 |
+
|
| 8 |
+
INDEX.md one line per post: date | path | title | headings
|
| 9 |
+
sources/{source-slug}/{date}-{post-slug}.md front matter + clean markdown
|
| 10 |
+
|
| 11 |
+
R2 is the source of truth (ws/{id}/corpus/...). Each process keeps a disk cache under
|
| 12 |
+
CORPUS_CACHE_DIR and syncs it from R2 through manifest.json (path -> sha256), so the API and worker
|
| 13 |
+
containers see the same files without sharing a volume.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import hashlib
|
| 17 |
+
import json
|
| 18 |
+
import re
|
| 19 |
+
import threading
|
| 20 |
+
import unicodedata
|
| 21 |
+
import uuid
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from pathlib import Path, PurePosixPath
|
| 25 |
+
|
| 26 |
+
from app.config import settings
|
| 27 |
+
from app.services.storage import storage as default_storage
|
| 28 |
+
|
| 29 |
+
MANIFEST = "manifest.json"
|
| 30 |
+
INDEX = "INDEX.md"
|
| 31 |
+
MAX_READ_LINES = 160
|
| 32 |
+
MAX_SEARCH_HITS = 40
|
| 33 |
+
MAX_LINE_CHARS = 240
|
| 34 |
+
|
| 35 |
+
_locks: dict[uuid.UUID, threading.Lock] = {}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def slugify(text: str, max_len: int = 60) -> str:
|
| 39 |
+
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
| 40 |
+
text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
|
| 41 |
+
return text[:max_len].rstrip("-") or "untitled"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def post_path(source_slug: str, title: str, url: str, published_at: datetime | None) -> str:
|
| 45 |
+
# Prefer the URL slug (stable across title edits), fall back to the title.
|
| 46 |
+
tail = url.rstrip("/").rsplit("/", 1)[-1] if url else ""
|
| 47 |
+
slug = slugify(tail if tail and not tail.startswith("?") else title)
|
| 48 |
+
date = published_at.strftime("%Y-%m-%d") if published_at else "undated"
|
| 49 |
+
return f"sources/{source_slug}/{date}-{slug}.md"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def render_post(*, title: str, url: str, published_at: datetime | None, source: str,
|
| 53 |
+
sections: list[tuple[str | None, str]]) -> str:
|
| 54 |
+
safe_title = title.replace('"', "'")
|
| 55 |
+
lines = [
|
| 56 |
+
"---",
|
| 57 |
+
f'title: "{safe_title}"',
|
| 58 |
+
f"url: {url}",
|
| 59 |
+
f"published: {published_at.date().isoformat() if published_at else 'unknown'}",
|
| 60 |
+
f"source: {source}",
|
| 61 |
+
"---",
|
| 62 |
+
"",
|
| 63 |
+
f"# {title}",
|
| 64 |
+
"",
|
| 65 |
+
]
|
| 66 |
+
for heading, text in sections:
|
| 67 |
+
if heading:
|
| 68 |
+
lines += [f"## {heading}", ""]
|
| 69 |
+
for para in text.split("\n\n"):
|
| 70 |
+
lines += [para.strip(), ""]
|
| 71 |
+
return "\n".join(lines).rstrip() + "\n"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def index_line(path: str, title: str, published_at: datetime | None, headings: list[str]) -> str:
|
| 75 |
+
date = published_at.strftime("%Y-%m-%d") if published_at else "undated"
|
| 76 |
+
heads = "; ".join(h for h in headings if h)[:300]
|
| 77 |
+
return f"{date} | {path} | {title}" + (f" | {heads}" if heads else "")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class CorpusError(ValueError):
|
| 81 |
+
pass
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class Hit:
|
| 86 |
+
path: str
|
| 87 |
+
line: int
|
| 88 |
+
text: str
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class Corpus:
|
| 92 |
+
def __init__(self, workspace_id: uuid.UUID, store=None, cache_dir: str | None = None):
|
| 93 |
+
self.workspace_id = workspace_id
|
| 94 |
+
self.store = store or default_storage
|
| 95 |
+
self.root = Path(cache_dir or settings.corpus_cache_dir) / str(workspace_id)
|
| 96 |
+
self._lock = _locks.setdefault(workspace_id, threading.Lock())
|
| 97 |
+
|
| 98 |
+
# Keys and paths
|
| 99 |
+
|
| 100 |
+
def _key(self, rel: str) -> str:
|
| 101 |
+
return f"ws/{self.workspace_id}/corpus/{rel}"
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def clean_rel(rel: str) -> str:
|
| 105 |
+
rel = rel.strip().lstrip("/").replace("\\", "/")
|
| 106 |
+
parts = PurePosixPath(rel).parts
|
| 107 |
+
if not rel or any(p in ("..", "") for p in parts) or rel.startswith("."):
|
| 108 |
+
raise CorpusError(f"Invalid path: {rel!r}")
|
| 109 |
+
return str(PurePosixPath(*parts))
|
| 110 |
+
|
| 111 |
+
def _local(self, rel: str) -> Path:
|
| 112 |
+
return self.root / self.clean_rel(rel)
|
| 113 |
+
|
| 114 |
+
# Manifest
|
| 115 |
+
|
| 116 |
+
def _read_manifest(self, remote: bool) -> dict[str, str]:
|
| 117 |
+
if remote:
|
| 118 |
+
if not self.store.exists(self._key(MANIFEST)):
|
| 119 |
+
return {}
|
| 120 |
+
return json.loads(self.store.get_bytes(self._key(MANIFEST)))
|
| 121 |
+
local = self.root / MANIFEST
|
| 122 |
+
return json.loads(local.read_text("utf-8")) if local.exists() else {}
|
| 123 |
+
|
| 124 |
+
def _write_manifest(self, manifest: dict[str, str]) -> None:
|
| 125 |
+
data = json.dumps(manifest, sort_keys=True, indent=0)
|
| 126 |
+
self.store.put_text(self._key(MANIFEST), data, "application/json")
|
| 127 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 128 |
+
(self.root / MANIFEST).write_text(data, "utf-8")
|
| 129 |
+
|
| 130 |
+
# Writes (worker)
|
| 131 |
+
|
| 132 |
+
def write_files(self, files: dict[str, str], remove: set[str] | None = None) -> None:
|
| 133 |
+
"""Write/replace files and update the manifest once. Values are file contents."""
|
| 134 |
+
with self._lock:
|
| 135 |
+
manifest = self._read_manifest(remote=True)
|
| 136 |
+
for rel, text in files.items():
|
| 137 |
+
rel = self.clean_rel(rel)
|
| 138 |
+
self.store.put_text(self._key(rel), text, "text/markdown; charset=utf-8")
|
| 139 |
+
local = self._local(rel)
|
| 140 |
+
local.parent.mkdir(parents=True, exist_ok=True)
|
| 141 |
+
local.write_text(text, "utf-8")
|
| 142 |
+
manifest[rel] = hashlib.sha256(text.encode()).hexdigest()
|
| 143 |
+
for rel in remove or set():
|
| 144 |
+
rel = self.clean_rel(rel)
|
| 145 |
+
manifest.pop(rel, None)
|
| 146 |
+
self.store.delete(self._key(rel))
|
| 147 |
+
self._local(rel).unlink(missing_ok=True)
|
| 148 |
+
self._write_manifest(manifest)
|
| 149 |
+
|
| 150 |
+
# Sync (API / any reader)
|
| 151 |
+
|
| 152 |
+
def sync(self) -> dict[str, str]:
|
| 153 |
+
"""Bring the disk cache in line with R2. Downloads only files whose hash changed."""
|
| 154 |
+
with self._lock:
|
| 155 |
+
remote = self._read_manifest(remote=True)
|
| 156 |
+
local = self._read_manifest(remote=False)
|
| 157 |
+
for rel, digest in remote.items():
|
| 158 |
+
path = self._local(rel)
|
| 159 |
+
if local.get(rel) == digest and path.exists():
|
| 160 |
+
continue
|
| 161 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 162 |
+
path.write_bytes(self.store.get_bytes(self._key(rel)))
|
| 163 |
+
for rel in set(local) - set(remote):
|
| 164 |
+
self._local(rel).unlink(missing_ok=True)
|
| 165 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 166 |
+
(self.root / MANIFEST).write_text(json.dumps(remote, sort_keys=True, indent=0), "utf-8")
|
| 167 |
+
return remote
|
| 168 |
+
|
| 169 |
+
# Reads
|
| 170 |
+
|
| 171 |
+
def exists(self, rel: str) -> bool:
|
| 172 |
+
return self._local(rel).is_file()
|
| 173 |
+
|
| 174 |
+
def read_lines(self, rel: str) -> list[str]:
|
| 175 |
+
path = self._local(rel)
|
| 176 |
+
if not path.is_file():
|
| 177 |
+
raise CorpusError(f"No such file: {rel}")
|
| 178 |
+
return path.read_text("utf-8").splitlines()
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class ScopedTools:
|
| 182 |
+
"""The agent's tools, restricted to an allowlist of files (a notebook's posts).
|
| 183 |
+
|
| 184 |
+
Every call is logged so the UI can stream "Searching..." / "Reading..." steps and so we can
|
| 185 |
+
verify citations only point at files the agent actually opened or matched.
|
| 186 |
+
"""
|
| 187 |
+
|
| 188 |
+
def __init__(self, corpus: Corpus, allowed: dict[str, str], on_step=None):
|
| 189 |
+
# allowed: path -> title
|
| 190 |
+
self.corpus = corpus
|
| 191 |
+
self.allowed = allowed
|
| 192 |
+
self.on_step = on_step or (lambda kind, detail: None)
|
| 193 |
+
|
| 194 |
+
def _check(self, rel: str) -> str:
|
| 195 |
+
rel = Corpus.clean_rel(rel)
|
| 196 |
+
if rel != INDEX and rel not in self.allowed:
|
| 197 |
+
raise CorpusError(f"{rel} is not in this notebook. Use list_files to see what is.")
|
| 198 |
+
return rel
|
| 199 |
+
|
| 200 |
+
def list_files(self, contains: str = "") -> str:
|
| 201 |
+
"""List the posts in this notebook, one per line as `date | path | title`, newest first.
|
| 202 |
+
Optionally filter to lines containing `contains` (case insensitive)."""
|
| 203 |
+
self.on_step("list", contains or "all posts")
|
| 204 |
+
index = {}
|
| 205 |
+
if self.corpus.exists(INDEX):
|
| 206 |
+
for line in self.corpus.read_lines(INDEX):
|
| 207 |
+
parts = [p.strip() for p in line.split("|")]
|
| 208 |
+
if len(parts) >= 3 and parts[1] in self.allowed:
|
| 209 |
+
index[parts[1]] = line
|
| 210 |
+
rows = [index.get(p, f"undated | {p} | {t}") for p, t in self.allowed.items()]
|
| 211 |
+
if contains:
|
| 212 |
+
rows = [r for r in rows if contains.lower() in r.lower()]
|
| 213 |
+
rows.sort(reverse=True)
|
| 214 |
+
return "\n".join(rows[:300]) or "No matching posts."
|
| 215 |
+
|
| 216 |
+
def search(self, pattern: str, path: str = "") -> str:
|
| 217 |
+
"""Search post text with a case insensitive regular expression (like grep). Use `|` for
|
| 218 |
+
alternatives, e.g. `pric(e|ing)|paid tier|subscription cost`. Optionally limit to one `path`.
|
| 219 |
+
Returns `path:line: text` matches."""
|
| 220 |
+
self.on_step("search", pattern)
|
| 221 |
+
try:
|
| 222 |
+
rx = re.compile(pattern, re.IGNORECASE)
|
| 223 |
+
except re.error:
|
| 224 |
+
rx = re.compile(re.escape(pattern), re.IGNORECASE)
|
| 225 |
+
targets = [self._check(path)] if path else list(self.allowed)
|
| 226 |
+
hits: list[Hit] = []
|
| 227 |
+
total = 0
|
| 228 |
+
for rel in targets:
|
| 229 |
+
for n, line in enumerate(self.corpus.read_lines(rel), start=1):
|
| 230 |
+
if rx.search(line):
|
| 231 |
+
total += 1
|
| 232 |
+
if len(hits) < MAX_SEARCH_HITS:
|
| 233 |
+
hits.append(Hit(rel, n, line.strip()[:MAX_LINE_CHARS]))
|
| 234 |
+
if not hits:
|
| 235 |
+
return "No matches. Try synonyms or a broader pattern."
|
| 236 |
+
out = "\n".join(f"{h.path}:{h.line}: {h.text}" for h in hits)
|
| 237 |
+
if total > len(hits):
|
| 238 |
+
out += f"\n... {total - len(hits)} more matches. Narrow the pattern or pass a path."
|
| 239 |
+
return out
|
| 240 |
+
|
| 241 |
+
def read(self, path: str, start_line: int = 1, end_line: int = 0) -> str:
|
| 242 |
+
"""Read a post with line numbers. Reads at most 160 lines per call; pass start_line and
|
| 243 |
+
end_line to page through long posts. Cite answers with these line numbers."""
|
| 244 |
+
rel = self._check(path)
|
| 245 |
+
lines = self.corpus.read_lines(rel)
|
| 246 |
+
start = max(1, start_line)
|
| 247 |
+
end = min(len(lines), end_line if end_line >= start else start + MAX_READ_LINES - 1)
|
| 248 |
+
end = min(end, start + MAX_READ_LINES - 1)
|
| 249 |
+
self.on_step("read", f"{self.allowed.get(rel, rel)} (lines {start} to {end})")
|
| 250 |
+
body = "\n".join(f"{n}| {lines[n - 1]}" for n in range(start, end + 1))
|
| 251 |
+
more = f"\n[{len(lines) - end} more lines]" if end < len(lines) else ""
|
| 252 |
+
return f"{rel} ({len(lines)} lines)\n{body}{more}"
|
| 253 |
+
|
| 254 |
+
def quote(self, rel: str, start: int, end: int) -> str | None:
|
| 255 |
+
"""Text of a cited line range, or None if the citation does not point at real content."""
|
| 256 |
+
try:
|
| 257 |
+
rel = self._check(rel)
|
| 258 |
+
lines = self.corpus.read_lines(rel)
|
| 259 |
+
except CorpusError:
|
| 260 |
+
return None
|
| 261 |
+
if start < 1 or start > len(lines):
|
| 262 |
+
return None
|
| 263 |
+
end = max(start, min(end, len(lines), start + 40))
|
| 264 |
+
text = "\n".join(lines[start - 1 : end]).strip()
|
| 265 |
+
return text or None
|
backend/app/llm/embeddings.py
DELETED
|
@@ -1,46 +0,0 @@
|
|
| 1 |
-
"""Embeddings through LiteLLM. Always requests EMBEDDING_DIM so the pgvector column stays fixed."""
|
| 2 |
-
|
| 3 |
-
from dataclasses import dataclass
|
| 4 |
-
|
| 5 |
-
import litellm
|
| 6 |
-
|
| 7 |
-
from app.config import settings
|
| 8 |
-
|
| 9 |
-
BATCH = 64
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@dataclass
|
| 13 |
-
class EmbeddingResult:
|
| 14 |
-
vectors: list[list[float]]
|
| 15 |
-
tokens: int
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def embed_texts(texts: list[str]) -> EmbeddingResult:
|
| 19 |
-
vectors: list[list[float]] = []
|
| 20 |
-
tokens = 0
|
| 21 |
-
for i in range(0, len(texts), BATCH):
|
| 22 |
-
batch = [t[:8000] for t in texts[i : i + BATCH]]
|
| 23 |
-
resp = litellm.embedding(
|
| 24 |
-
model=settings.embedding_model,
|
| 25 |
-
input=batch,
|
| 26 |
-
api_base=settings.embedding_api_base or None,
|
| 27 |
-
api_key=settings.embedding_api_key or None,
|
| 28 |
-
dimensions=settings.embedding_dim,
|
| 29 |
-
)
|
| 30 |
-
vectors.extend(item["embedding"] for item in resp.data)
|
| 31 |
-
tokens += int(getattr(resp, "usage", None) and resp.usage.prompt_tokens or 0)
|
| 32 |
-
for v in vectors:
|
| 33 |
-
if len(v) != settings.embedding_dim:
|
| 34 |
-
raise ValueError(
|
| 35 |
-
f"Embedding model returned {len(v)} dims, expected {settings.embedding_dim}. "
|
| 36 |
-
"Check EMBEDDING_MODEL supports the dimensions parameter."
|
| 37 |
-
)
|
| 38 |
-
return EmbeddingResult(vectors=vectors, tokens=tokens)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def embedding_provider() -> str:
|
| 42 |
-
return settings.embedding_model.split("/", 1)[0]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def embed_query(text: str) -> list[float]:
|
| 46 |
-
return embed_texts([text]).vectors[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/llm/signatures.py
CHANGED
|
@@ -14,15 +14,16 @@ class Section(BaseModel):
|
|
| 14 |
text: str
|
| 15 |
|
| 16 |
|
| 17 |
-
class
|
| 18 |
-
|
| 19 |
-
chunk_ids: list[int] = Field(description="Indices of supporting passages from the context list")
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
unsupported: bool = Field(description="True if the passages do not contain enough to answer")
|
| 26 |
|
| 27 |
|
| 28 |
class VoiceProfileOut(BaseModel):
|
|
@@ -37,13 +38,13 @@ class VoiceProfileOut(BaseModel):
|
|
| 37 |
|
| 38 |
class Claim(BaseModel):
|
| 39 |
claim: str
|
| 40 |
-
|
| 41 |
|
| 42 |
|
| 43 |
class ScriptLine(BaseModel):
|
| 44 |
speaker: Literal["host_a", "host_b"]
|
| 45 |
text: str
|
| 46 |
-
|
| 47 |
|
| 48 |
|
| 49 |
class Scene(BaseModel):
|
|
@@ -72,14 +73,20 @@ class CleanAndSegment(dspy.Signature):
|
|
| 72 |
sections: list[Section] = dspy.OutputField()
|
| 73 |
|
| 74 |
|
| 75 |
-
class
|
| 76 |
-
"""
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
question: str = dspy.InputField()
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
class GroundednessJudge(dspy.Signature):
|
|
@@ -100,7 +107,7 @@ class BuildVoiceProfile(dspy.Signature):
|
|
| 100 |
class ExtractClaims(dspy.Signature):
|
| 101 |
"""List the key claims of the post, each with the passages that support it."""
|
| 102 |
|
| 103 |
-
passages: list[str] = dspy.InputField()
|
| 104 |
claims: list[Claim] = dspy.OutputField()
|
| 105 |
|
| 106 |
|
|
|
|
| 14 |
text: str
|
| 15 |
|
| 16 |
|
| 17 |
+
class SourceRef(BaseModel):
|
| 18 |
+
"""A line range in a corpus file. Every generated fact points at one of these."""
|
|
|
|
| 19 |
|
| 20 |
+
path: str = Field(description="Corpus path of the post, exactly as the tools showed it")
|
| 21 |
+
line_start: int
|
| 22 |
+
line_end: int
|
| 23 |
|
| 24 |
+
|
| 25 |
+
class FileCitation(SourceRef):
|
| 26 |
+
marker: int = Field(description="The n in the [n] marker used in the answer")
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
class VoiceProfileOut(BaseModel):
|
|
|
|
| 38 |
|
| 39 |
class Claim(BaseModel):
|
| 40 |
claim: str
|
| 41 |
+
sources: list[SourceRef]
|
| 42 |
|
| 43 |
|
| 44 |
class ScriptLine(BaseModel):
|
| 45 |
speaker: Literal["host_a", "host_b"]
|
| 46 |
text: str
|
| 47 |
+
sources: list[SourceRef] = Field(default_factory=list)
|
| 48 |
|
| 49 |
|
| 50 |
class Scene(BaseModel):
|
|
|
|
| 73 |
sections: list[Section] = dspy.OutputField()
|
| 74 |
|
| 75 |
|
| 76 |
+
class ResearchArchive(dspy.Signature):
|
| 77 |
+
"""You are a research assistant working inside a writer's archive, which is a folder of markdown
|
| 78 |
+
posts. Find the answer by exploring the files: list_files to see what exists, search (a regex
|
| 79 |
+
grep; try several phrasings and synonyms) to locate relevant lines, then read the surrounding
|
| 80 |
+
lines before relying on them. Answer ONLY from text you read. Every factual sentence needs a
|
| 81 |
+
[n] marker, and every marker needs a citation with the file path and the line range you read.
|
| 82 |
+
If the archive does not cover the question, say so plainly and set unsupported=true.
|
| 83 |
+
Write in plain, warm prose. Never use em dashes."""
|
| 84 |
|
| 85 |
question: str = dspy.InputField()
|
| 86 |
+
archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
|
| 87 |
+
answer: str = dspy.OutputField(desc="Answer with [n] citation markers")
|
| 88 |
+
citations: list[FileCitation] = dspy.OutputField()
|
| 89 |
+
unsupported: bool = dspy.OutputField()
|
| 90 |
|
| 91 |
|
| 92 |
class GroundednessJudge(dspy.Signature):
|
|
|
|
| 107 |
class ExtractClaims(dspy.Signature):
|
| 108 |
"""List the key claims of the post, each with the passages that support it."""
|
| 109 |
|
| 110 |
+
passages: list[str] = dspy.InputField(desc="Post text with path and line numbers")
|
| 111 |
claims: list[Claim] = dspy.OutputField()
|
| 112 |
|
| 113 |
|
backend/app/models/__init__.py
CHANGED
|
@@ -2,7 +2,6 @@ from app.models.content import (
|
|
| 2 |
Artifact,
|
| 3 |
CalendarItem,
|
| 4 |
Chat,
|
| 5 |
-
Chunk,
|
| 6 |
Citation,
|
| 7 |
Document,
|
| 8 |
DspyTrace,
|
|
@@ -26,7 +25,6 @@ __all__ = [
|
|
| 26 |
"AuthProvider",
|
| 27 |
"CalendarItem",
|
| 28 |
"Chat",
|
| 29 |
-
"Chunk",
|
| 30 |
"Citation",
|
| 31 |
"Document",
|
| 32 |
"DspyTrace",
|
|
|
|
| 2 |
Artifact,
|
| 3 |
CalendarItem,
|
| 4 |
Chat,
|
|
|
|
| 5 |
Citation,
|
| 6 |
Document,
|
| 7 |
DspyTrace,
|
|
|
|
| 25 |
"AuthProvider",
|
| 26 |
"CalendarItem",
|
| 27 |
"Chat",
|
|
|
|
| 28 |
"Citation",
|
| 29 |
"Document",
|
| 30 |
"DspyTrace",
|
backend/app/models/content.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
import uuid
|
| 2 |
from datetime import datetime
|
| 3 |
|
| 4 |
-
from pgvector.sqlalchemy import Vector
|
| 5 |
from sqlalchemy import (
|
| 6 |
JSON,
|
| 7 |
BigInteger,
|
|
@@ -17,7 +16,6 @@ from sqlalchemy import (
|
|
| 17 |
)
|
| 18 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 19 |
|
| 20 |
-
from app.config import settings
|
| 21 |
from app.db import Base
|
| 22 |
from app.models.base import IdMixin, TimestampMixin
|
| 23 |
|
|
@@ -49,29 +47,12 @@ class Document(IdMixin, TimestampMixin, Base):
|
|
| 49 |
url: Mapped[str] = mapped_column(String(1000))
|
| 50 |
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 51 |
raw_html_key: Mapped[str | None] = mapped_column(String(500)) # R2 key
|
|
|
|
|
|
|
| 52 |
clean_text: Mapped[str] = mapped_column(Text, default="")
|
| 53 |
content_hash: Mapped[str | None] = mapped_column(String(64))
|
| 54 |
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 55 |
|
| 56 |
-
chunks: Mapped[list["Chunk"]] = relationship(back_populates="document", cascade="all, delete-orphan")
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
class Chunk(IdMixin, TimestampMixin, Base):
|
| 60 |
-
__tablename__ = "chunks"
|
| 61 |
-
|
| 62 |
-
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 63 |
-
document_id: Mapped[uuid.UUID] = mapped_column(
|
| 64 |
-
Uuid, ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
| 65 |
-
)
|
| 66 |
-
position: Mapped[int] = mapped_column(Integer)
|
| 67 |
-
heading: Mapped[str | None] = mapped_column(String(500))
|
| 68 |
-
text: Mapped[str] = mapped_column(Text)
|
| 69 |
-
token_count: Mapped[int] = mapped_column(Integer, default=0)
|
| 70 |
-
embedding: Mapped[list[float] | None] = mapped_column(Vector(settings.embedding_dim))
|
| 71 |
-
entities: Mapped[list] = mapped_column(JSON, default=list)
|
| 72 |
-
|
| 73 |
-
document: Mapped[Document] = relationship(back_populates="chunks")
|
| 74 |
-
|
| 75 |
|
| 76 |
class Notebook(IdMixin, TimestampMixin, Base):
|
| 77 |
__tablename__ = "notebooks"
|
|
@@ -139,9 +120,12 @@ class Citation(IdMixin, Base):
|
|
| 139 |
__tablename__ = "citations"
|
| 140 |
|
| 141 |
message_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("messages.id", ondelete="CASCADE"))
|
| 142 |
-
|
| 143 |
marker: Mapped[int] = mapped_column(Integer) # [1], [2] in the answer text
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
|
| 147 |
class Artifact(IdMixin, TimestampMixin, Base):
|
|
@@ -241,7 +225,7 @@ class UsageEvent(IdMixin, TimestampMixin, Base):
|
|
| 241 |
|
| 242 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 243 |
job_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("jobs.id", ondelete="SET NULL"))
|
| 244 |
-
kind: Mapped[str] = mapped_column(String(20)) # llm |
|
| 245 |
provider: Mapped[str] = mapped_column(String(40))
|
| 246 |
model: Mapped[str | None] = mapped_column(String(100))
|
| 247 |
quantity: Mapped[float] = mapped_column(Float) # tokens, characters or seconds
|
|
|
|
| 1 |
import uuid
|
| 2 |
from datetime import datetime
|
| 3 |
|
|
|
|
| 4 |
from sqlalchemy import (
|
| 5 |
JSON,
|
| 6 |
BigInteger,
|
|
|
|
| 16 |
)
|
| 17 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 18 |
|
|
|
|
| 19 |
from app.db import Base
|
| 20 |
from app.models.base import IdMixin, TimestampMixin
|
| 21 |
|
|
|
|
| 47 |
url: Mapped[str] = mapped_column(String(1000))
|
| 48 |
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 49 |
raw_html_key: Mapped[str | None] = mapped_column(String(500)) # R2 key
|
| 50 |
+
# Corpus relative path of the clean markdown file, e.g. sources/ada-substack-com/2026-03-01-pricing.md
|
| 51 |
+
path: Mapped[str | None] = mapped_column(String(500), index=True)
|
| 52 |
clean_text: Mapped[str] = mapped_column(Text, default="")
|
| 53 |
content_hash: Mapped[str | None] = mapped_column(String(64))
|
| 54 |
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
class Notebook(IdMixin, TimestampMixin, Base):
|
| 58 |
__tablename__ = "notebooks"
|
|
|
|
| 120 |
__tablename__ = "citations"
|
| 121 |
|
| 122 |
message_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("messages.id", ondelete="CASCADE"))
|
| 123 |
+
document_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="CASCADE"))
|
| 124 |
marker: Mapped[int] = mapped_column(Integer) # [1], [2] in the answer text
|
| 125 |
+
path: Mapped[str] = mapped_column(String(500))
|
| 126 |
+
line_start: Mapped[int] = mapped_column(Integer)
|
| 127 |
+
line_end: Mapped[int] = mapped_column(Integer)
|
| 128 |
+
span: Mapped[str | None] = mapped_column(Text) # verified text read back from the file
|
| 129 |
|
| 130 |
|
| 131 |
class Artifact(IdMixin, TimestampMixin, Base):
|
|
|
|
| 225 |
|
| 226 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 227 |
job_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("jobs.id", ondelete="SET NULL"))
|
| 228 |
+
kind: Mapped[str] = mapped_column(String(20)) # llm | tts | render
|
| 229 |
provider: Mapped[str] = mapped_column(String(40))
|
| 230 |
model: Mapped[str | None] = mapped_column(String(100))
|
| 231 |
quantity: Mapped[float] = mapped_column(Float) # tokens, characters or seconds
|
backend/app/pipeline/ingest.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Ingestion: feed ->
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
import logging
|
|
@@ -11,22 +11,17 @@ from urllib.parse import urlparse
|
|
| 11 |
|
| 12 |
import feedparser
|
| 13 |
import httpx
|
| 14 |
-
import tiktoken
|
| 15 |
from bs4 import BeautifulSoup
|
| 16 |
from sqlalchemy import select
|
| 17 |
from sqlalchemy.orm import Session
|
| 18 |
|
| 19 |
-
from app.
|
| 20 |
-
from app.
|
| 21 |
-
from app.
|
| 22 |
-
from app.services.jobs import record_usage, update_job
|
| 23 |
from app.services.storage import keys, storage
|
| 24 |
|
| 25 |
log = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
-
CHUNK_TOKENS = 350
|
| 28 |
-
CHUNK_OVERLAP = 60
|
| 29 |
-
_enc = tiktoken.get_encoding("cl100k_base")
|
| 30 |
_DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I)
|
| 31 |
|
| 32 |
|
|
@@ -62,20 +57,6 @@ def html_to_sections(html: str) -> list[tuple[str | None, str]]:
|
|
| 62 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 63 |
|
| 64 |
|
| 65 |
-
def chunk_sections(sections: list[tuple[str | None, str]]) -> list[tuple[str | None, str, int]]:
|
| 66 |
-
out: list[tuple[str | None, str, int]] = []
|
| 67 |
-
for heading, text in sections:
|
| 68 |
-
tokens = _enc.encode(text)
|
| 69 |
-
start = 0
|
| 70 |
-
while start < len(tokens):
|
| 71 |
-
window = tokens[start : start + CHUNK_TOKENS]
|
| 72 |
-
out.append((heading, _enc.decode(window), len(window)))
|
| 73 |
-
if start + CHUNK_TOKENS >= len(tokens):
|
| 74 |
-
break
|
| 75 |
-
start += CHUNK_TOKENS - CHUNK_OVERLAP
|
| 76 |
-
return out
|
| 77 |
-
|
| 78 |
-
|
| 79 |
@dataclass
|
| 80 |
class FeedEntry:
|
| 81 |
title: str
|
|
@@ -103,25 +84,54 @@ def fetch_feed(feed_url: str, limit: int) -> tuple[str | None, list[FeedEntry]]:
|
|
| 103 |
return parsed.feed.get("title"), entries
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 107 |
source.sync_status = "syncing"
|
| 108 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
| 109 |
|
| 110 |
title, entries = fetch_feed(source.feed_url, max_posts)
|
| 111 |
source.title = source.title or title
|
|
|
|
|
|
|
| 112 |
total = max(len(entries), 1)
|
| 113 |
indexed = skipped = 0
|
| 114 |
-
|
| 115 |
|
|
|
|
|
|
|
|
|
|
| 116 |
for i, entry in enumerate(entries):
|
| 117 |
content_hash = hashlib.sha256(entry.html.encode()).hexdigest()
|
| 118 |
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 119 |
-
if doc and doc.content_hash == content_hash:
|
| 120 |
skipped += 1
|
| 121 |
continue
|
| 122 |
if not doc:
|
| 123 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 124 |
db.add(doc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
doc.title = entry.title
|
| 126 |
doc.published_at = entry.published_at
|
| 127 |
doc.content_hash = content_hash
|
|
@@ -130,37 +140,18 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
|
|
| 130 |
)
|
| 131 |
sections = html_to_sections(entry.html)
|
| 132 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 133 |
-
doc.
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
embed_tokens += result.tokens
|
| 138 |
-
for pos, ((heading, text, n_tokens), vec) in enumerate(zip(pieces, result.vectors, strict=True)):
|
| 139 |
-
doc.chunks.append(
|
| 140 |
-
Chunk(
|
| 141 |
-
workspace_id=source.workspace_id,
|
| 142 |
-
position=pos,
|
| 143 |
-
heading=heading,
|
| 144 |
-
text=text,
|
| 145 |
-
token_count=n_tokens,
|
| 146 |
-
embedding=vec,
|
| 147 |
-
)
|
| 148 |
-
)
|
| 149 |
indexed += 1
|
| 150 |
db.commit()
|
| 151 |
-
update_job(db, job, progress=0.05 + 0.
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
kind="embedding",
|
| 158 |
-
provider=embedding_provider(),
|
| 159 |
-
model=settings.embedding_model,
|
| 160 |
-
quantity=embed_tokens,
|
| 161 |
-
unit="tokens",
|
| 162 |
-
job=job,
|
| 163 |
-
)
|
| 164 |
source.sync_status = "ok"
|
| 165 |
source.sync_error = None
|
| 166 |
source.last_synced_at = datetime.now(UTC)
|
|
|
|
| 1 |
+
"""Ingestion: feed -> raw HTML to R2 -> clean markdown files in the workspace corpus -> INDEX.md."""
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
import logging
|
|
|
|
| 11 |
|
| 12 |
import feedparser
|
| 13 |
import httpx
|
|
|
|
| 14 |
from bs4 import BeautifulSoup
|
| 15 |
from sqlalchemy import select
|
| 16 |
from sqlalchemy.orm import Session
|
| 17 |
|
| 18 |
+
from app.corpus import INDEX, Corpus, index_line, post_path, render_post, slugify
|
| 19 |
+
from app.models import Document, Job, Source
|
| 20 |
+
from app.services.jobs import update_job
|
|
|
|
| 21 |
from app.services.storage import keys, storage
|
| 22 |
|
| 23 |
log = logging.getLogger(__name__)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
_DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I)
|
| 26 |
|
| 27 |
|
|
|
|
| 57 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
@dataclass
|
| 61 |
class FeedEntry:
|
| 62 |
title: str
|
|
|
|
| 84 |
return parsed.feed.get("title"), entries
|
| 85 |
|
| 86 |
|
| 87 |
+
def source_slug(source: Source) -> str:
|
| 88 |
+
return slugify(urlparse(source.site_url or source.feed_url).netloc or str(source.id), 50)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def rebuild_index(db: Session, workspace_id: uuid.UUID) -> str:
|
| 92 |
+
docs = db.scalars(
|
| 93 |
+
select(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None))
|
| 94 |
+
).all()
|
| 95 |
+
rows = sorted(
|
| 96 |
+
(index_line(d.path, d.title, d.published_at, d.metadata_json.get("headings", [])) for d in docs),
|
| 97 |
+
reverse=True,
|
| 98 |
+
)
|
| 99 |
+
header = "# Archive index\n\ndate | path | title | section headings\n\n"
|
| 100 |
+
return header + "\n".join(rows) + "\n"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 104 |
source.sync_status = "syncing"
|
| 105 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
| 106 |
|
| 107 |
title, entries = fetch_feed(source.feed_url, max_posts)
|
| 108 |
source.title = source.title or title
|
| 109 |
+
corpus = Corpus(source.workspace_id)
|
| 110 |
+
folder = source_slug(source)
|
| 111 |
total = max(len(entries), 1)
|
| 112 |
indexed = skipped = 0
|
| 113 |
+
files: dict[str, str] = {}
|
| 114 |
|
| 115 |
+
taken = set(
|
| 116 |
+
db.scalars(select(Document.path).where(Document.workspace_id == source.workspace_id)).all()
|
| 117 |
+
)
|
| 118 |
for i, entry in enumerate(entries):
|
| 119 |
content_hash = hashlib.sha256(entry.html.encode()).hexdigest()
|
| 120 |
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 121 |
+
if doc and doc.content_hash == content_hash and doc.path:
|
| 122 |
skipped += 1
|
| 123 |
continue
|
| 124 |
if not doc:
|
| 125 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 126 |
db.add(doc)
|
| 127 |
+
if not doc.path:
|
| 128 |
+
path = post_path(folder, entry.title, entry.url, entry.published_at)
|
| 129 |
+
n = 2
|
| 130 |
+
while path in taken:
|
| 131 |
+
path = path.removesuffix(".md").rsplit("--", 1)[0] + f"--{n}.md"
|
| 132 |
+
n += 1
|
| 133 |
+
doc.path = path
|
| 134 |
+
taken.add(path)
|
| 135 |
doc.title = entry.title
|
| 136 |
doc.published_at = entry.published_at
|
| 137 |
doc.content_hash = content_hash
|
|
|
|
| 140 |
)
|
| 141 |
sections = html_to_sections(entry.html)
|
| 142 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 143 |
+
doc.metadata_json = {**(doc.metadata_json or {}), "headings": [h for h, _ in sections if h]}
|
| 144 |
+
files[doc.path] = render_post(
|
| 145 |
+
title=entry.title, url=entry.url, published_at=entry.published_at, source=folder, sections=sections
|
| 146 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
indexed += 1
|
| 148 |
db.commit()
|
| 149 |
+
update_job(db, job, progress=0.05 + 0.85 * (i + 1) / total, message=f"{entry.title[:80]} is in orbit")
|
| 150 |
+
|
| 151 |
+
update_job(db, job, progress=0.93, message="Writing your archive index")
|
| 152 |
+
files[INDEX] = rebuild_index(db, source.workspace_id)
|
| 153 |
+
corpus.write_files(files)
|
| 154 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
source.sync_status = "ok"
|
| 156 |
source.sync_error = None
|
| 157 |
source.last_synced_at = datetime.now(UTC)
|
backend/app/pipeline/research.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agentic research over the file system corpus: the model explores files with list/search/read,
|
| 2 |
+
then every citation is checked against the real file before it reaches the writer."""
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
|
| 8 |
+
import dspy
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.corpus import Corpus, ScopedTools
|
| 12 |
+
from app.llm.postprocess import strip_em_dashes
|
| 13 |
+
from app.llm.provider import main_lm, track_usage
|
| 14 |
+
from app.llm.signatures import ResearchArchive
|
| 15 |
+
|
| 16 |
+
_MARKER = re.compile(r"\[(\d+)\]")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class VerifiedCitation:
|
| 21 |
+
marker: int
|
| 22 |
+
path: str
|
| 23 |
+
line_start: int
|
| 24 |
+
line_end: int
|
| 25 |
+
quote: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class ResearchResult:
|
| 30 |
+
text: str
|
| 31 |
+
unsupported: bool
|
| 32 |
+
citations: list[VerifiedCitation]
|
| 33 |
+
prompt_tokens: int = 0
|
| 34 |
+
completion_tokens: int = 0
|
| 35 |
+
steps: list[tuple[str, str]] = field(default_factory=list)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def archive_guide(tools: ScopedTools) -> str:
|
| 39 |
+
return (
|
| 40 |
+
f"This notebook has {len(tools.allowed)} posts stored as markdown files under sources/. "
|
| 41 |
+
"Each file starts with front matter (title, url, published date), then '# Title' and '## Section' "
|
| 42 |
+
"headings. Paths look like sources/<site>/<yyyy-mm-dd>-<slug>.md. "
|
| 43 |
+
"Line numbers from read() and search() are what you cite."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def verify_citations(tools: ScopedTools, answer: str, citations) -> tuple[str, list[VerifiedCitation]]:
|
| 48 |
+
"""Keep only citations that point at real lines, renumber them 1..n, and drop dangling markers."""
|
| 49 |
+
verified: list[VerifiedCitation] = []
|
| 50 |
+
remap: dict[int, int] = {}
|
| 51 |
+
for c in sorted(citations, key=lambda c: c.marker):
|
| 52 |
+
if c.marker in remap:
|
| 53 |
+
continue
|
| 54 |
+
quote = tools.quote(c.path, c.line_start, c.line_end)
|
| 55 |
+
if not quote:
|
| 56 |
+
continue
|
| 57 |
+
new = len(verified) + 1
|
| 58 |
+
remap[c.marker] = new
|
| 59 |
+
end = max(c.line_start, min(c.line_end, c.line_start + 40))
|
| 60 |
+
verified.append(VerifiedCitation(new, c.path, c.line_start, end, quote[:600]))
|
| 61 |
+
|
| 62 |
+
def swap(m: re.Match) -> str:
|
| 63 |
+
n = remap.get(int(m.group(1)))
|
| 64 |
+
return f"[{n}]" if n else ""
|
| 65 |
+
|
| 66 |
+
text = _MARKER.sub(swap, answer)
|
| 67 |
+
text = re.sub(r"\s+([.,;:!?])", r"\1", text)
|
| 68 |
+
return text.strip(), verified
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def research(
|
| 72 |
+
corpus: Corpus,
|
| 73 |
+
allowed: dict[str, str],
|
| 74 |
+
question: str,
|
| 75 |
+
on_step: Callable[[str, str], None] | None = None,
|
| 76 |
+
) -> ResearchResult:
|
| 77 |
+
if not allowed:
|
| 78 |
+
return ResearchResult("This notebook has no posts yet. Add some sources first.", True, [])
|
| 79 |
+
corpus.sync()
|
| 80 |
+
steps: list[tuple[str, str]] = []
|
| 81 |
+
|
| 82 |
+
def log(kind: str, detail: str) -> None:
|
| 83 |
+
steps.append((kind, detail))
|
| 84 |
+
if on_step:
|
| 85 |
+
on_step(kind, detail)
|
| 86 |
+
|
| 87 |
+
tools = ScopedTools(corpus, allowed, on_step=log)
|
| 88 |
+
agent = dspy.ReAct(
|
| 89 |
+
ResearchArchive,
|
| 90 |
+
tools=[tools.list_files, tools.search, tools.read],
|
| 91 |
+
max_iters=settings.research_max_steps,
|
| 92 |
+
)
|
| 93 |
+
lm = main_lm()
|
| 94 |
+
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 95 |
+
pred = agent(question=question, archive_guide=archive_guide(tools))
|
| 96 |
+
|
| 97 |
+
text, cites = verify_citations(tools, pred.answer, pred.citations or [])
|
| 98 |
+
unsupported = bool(pred.unsupported) or not cites
|
| 99 |
+
if not cites and not pred.unsupported:
|
| 100 |
+
# The model answered without anything we could verify: do not pass it off as grounded.
|
| 101 |
+
text = "I could not find support for that in these posts, so I would rather not guess."
|
| 102 |
+
return ResearchResult(
|
| 103 |
+
text=strip_em_dashes(text),
|
| 104 |
+
unsupported=unsupported,
|
| 105 |
+
citations=cites,
|
| 106 |
+
prompt_tokens=usage.prompt_tokens,
|
| 107 |
+
completion_tokens=usage.completion_tokens,
|
| 108 |
+
steps=steps,
|
| 109 |
+
)
|
backend/app/pipeline/retrieval.py
DELETED
|
@@ -1,62 +0,0 @@
|
|
| 1 |
-
"""Vector retrieval + grounded answering with citations."""
|
| 2 |
-
|
| 3 |
-
import uuid
|
| 4 |
-
from dataclasses import dataclass
|
| 5 |
-
|
| 6 |
-
import dspy
|
| 7 |
-
from sqlalchemy import select
|
| 8 |
-
from sqlalchemy.orm import Session
|
| 9 |
-
|
| 10 |
-
from app.llm.embeddings import embed_query
|
| 11 |
-
from app.llm.postprocess import strip_em_dashes
|
| 12 |
-
from app.llm.provider import main_lm, track_usage
|
| 13 |
-
from app.llm.signatures import GroundedAnswer
|
| 14 |
-
from app.models import Chunk, Document, NotebookDocument
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
@dataclass
|
| 18 |
-
class Retrieved:
|
| 19 |
-
chunk: Chunk
|
| 20 |
-
document: Document
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def retrieve(db: Session, workspace_id: uuid.UUID, query: str, notebook_id: uuid.UUID | None, k: int = 8):
|
| 24 |
-
vec = embed_query(query)
|
| 25 |
-
stmt = (
|
| 26 |
-
select(Chunk, Document)
|
| 27 |
-
.join(Document, Document.id == Chunk.document_id)
|
| 28 |
-
.where(Chunk.workspace_id == workspace_id, Chunk.embedding.is_not(None))
|
| 29 |
-
)
|
| 30 |
-
if notebook_id:
|
| 31 |
-
stmt = stmt.join(NotebookDocument, NotebookDocument.document_id == Document.id).where(
|
| 32 |
-
NotebookDocument.notebook_id == notebook_id
|
| 33 |
-
)
|
| 34 |
-
stmt = stmt.order_by(Chunk.embedding.cosine_distance(vec)).limit(k)
|
| 35 |
-
return [Retrieved(c, d) for c, d in db.execute(stmt).all()]
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
@dataclass
|
| 39 |
-
class Answer:
|
| 40 |
-
text: str
|
| 41 |
-
unsupported: bool
|
| 42 |
-
citations: list[tuple[int, Retrieved]] # (marker, retrieved)
|
| 43 |
-
prompt_tokens: int
|
| 44 |
-
completion_tokens: int
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def grounded_answer(question: str, hits: list[Retrieved]) -> Answer:
|
| 48 |
-
if not hits:
|
| 49 |
-
return Answer("I could not find anything about that in your posts yet.", True, [], 0, 0)
|
| 50 |
-
passages = [f"[{i + 1}] {h.document.title}: {h.chunk.text}" for i, h in enumerate(hits)]
|
| 51 |
-
lm = main_lm()
|
| 52 |
-
with track_usage(lm) as usage, dspy.context(lm=lm, adapter=dspy.JSONAdapter()):
|
| 53 |
-
pred = dspy.Predict(GroundedAnswer)(question=question, passages=passages)
|
| 54 |
-
result = pred.result
|
| 55 |
-
used = sorted({i for s in result.sentences for i in s.chunk_ids if 1 <= i <= len(hits)})
|
| 56 |
-
return Answer(
|
| 57 |
-
text=strip_em_dashes(result.answer),
|
| 58 |
-
unsupported=result.unsupported,
|
| 59 |
-
citations=[(i, hits[i - 1]) for i in used],
|
| 60 |
-
prompt_tokens=usage.prompt_tokens,
|
| 61 |
-
completion_tokens=usage.completion_tokens,
|
| 62 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/routers/notebooks.py
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
|
|
| 1 |
import json
|
| 2 |
import uuid
|
| 3 |
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
-
from fastapi.concurrency import run_in_threadpool
|
| 6 |
from fastapi.responses import StreamingResponse
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
from sqlalchemy import select
|
| 9 |
|
| 10 |
from app.auth import Ctx, get_ctx
|
| 11 |
from app.config import settings
|
|
|
|
| 12 |
from app.llm.provider import provider_name
|
| 13 |
from app.models import Chat, Citation, Document, Message, Notebook, NotebookDocument
|
| 14 |
-
from app.pipeline.
|
| 15 |
from app.services.jobs import record_usage
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/api/notebooks", tags=["notebooks"])
|
|
@@ -99,6 +100,9 @@ def _sse(event: str, data: dict) -> str:
|
|
| 99 |
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
| 102 |
@router.post("/{notebook_id}/chat")
|
| 103 |
async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)):
|
| 104 |
nb = _get(ctx, notebook_id)
|
|
@@ -111,41 +115,71 @@ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)
|
|
| 111 |
db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
|
| 112 |
db.commit()
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
async def stream():
|
| 115 |
-
yield _sse("status", {"chat_id": str(chat_row.id), "message": "
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
db.add(msg)
|
| 121 |
db.flush()
|
| 122 |
citations = []
|
| 123 |
-
for
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
citations.append({
|
| 126 |
-
"marker": marker,
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
| 132 |
-
"
|
|
|
|
| 133 |
})
|
| 134 |
db.commit()
|
| 135 |
-
if
|
| 136 |
record_usage(
|
| 137 |
db,
|
| 138 |
workspace_id=ctx.workspace.id,
|
| 139 |
kind="llm",
|
| 140 |
provider=provider_name(),
|
| 141 |
model=settings.llm_model,
|
| 142 |
-
quantity=
|
| 143 |
unit="tokens",
|
| 144 |
)
|
| 145 |
yield _sse("answer", {
|
| 146 |
"message_id": str(msg.id),
|
| 147 |
-
"text":
|
| 148 |
-
"unsupported":
|
| 149 |
"citations": citations,
|
| 150 |
})
|
| 151 |
yield _sse("done", {})
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
import json
|
| 3 |
import uuid
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
| 6 |
from fastapi.responses import StreamingResponse
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
from sqlalchemy import select
|
| 9 |
|
| 10 |
from app.auth import Ctx, get_ctx
|
| 11 |
from app.config import settings
|
| 12 |
+
from app.corpus import Corpus
|
| 13 |
from app.llm.provider import provider_name
|
| 14 |
from app.models import Chat, Citation, Document, Message, Notebook, NotebookDocument
|
| 15 |
+
from app.pipeline.research import research
|
| 16 |
from app.services.jobs import record_usage
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/notebooks", tags=["notebooks"])
|
|
|
|
| 100 |
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
| 101 |
|
| 102 |
|
| 103 |
+
_STEP_LABEL = {"list": "Scanning the archive", "search": "Searching for", "read": "Reading"}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
@router.post("/{notebook_id}/chat")
|
| 107 |
async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)):
|
| 108 |
nb = _get(ctx, notebook_id)
|
|
|
|
| 115 |
db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
|
| 116 |
db.commit()
|
| 117 |
|
| 118 |
+
docs = db.scalars(
|
| 119 |
+
select(Document)
|
| 120 |
+
.join(NotebookDocument, NotebookDocument.document_id == Document.id)
|
| 121 |
+
.where(NotebookDocument.notebook_id == nb.id, Document.path.is_not(None))
|
| 122 |
+
).all()
|
| 123 |
+
allowed = {d.path: d.title for d in docs}
|
| 124 |
+
by_path = {d.path: d for d in docs}
|
| 125 |
+
corpus = Corpus(ctx.workspace.id)
|
| 126 |
+
|
| 127 |
async def stream():
|
| 128 |
+
yield _sse("status", {"chat_id": str(chat_row.id), "message": "Opening the archive"})
|
| 129 |
+
loop = asyncio.get_running_loop()
|
| 130 |
+
steps: asyncio.Queue = asyncio.Queue()
|
| 131 |
+
|
| 132 |
+
def on_step(kind: str, detail: str) -> None:
|
| 133 |
+
loop.call_soon_threadsafe(steps.put_nowait, (kind, detail))
|
| 134 |
+
|
| 135 |
+
task = asyncio.create_task(asyncio.to_thread(research, corpus, allowed, body.question, on_step))
|
| 136 |
+
while not task.done() or not steps.empty():
|
| 137 |
+
try:
|
| 138 |
+
kind, detail = await asyncio.wait_for(steps.get(), timeout=0.25)
|
| 139 |
+
except TimeoutError:
|
| 140 |
+
continue
|
| 141 |
+
yield _sse("step", {"kind": kind, "message": f"{_STEP_LABEL.get(kind, kind)} {detail}".strip()})
|
| 142 |
+
try:
|
| 143 |
+
result = task.result()
|
| 144 |
+
except Exception:
|
| 145 |
+
yield _sse("error", {"message": "Lost signal while researching. Try again."})
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
msg = Message(chat_id=chat_row.id, role="assistant", content=result.text)
|
| 149 |
db.add(msg)
|
| 150 |
db.flush()
|
| 151 |
citations = []
|
| 152 |
+
for c in result.citations:
|
| 153 |
+
doc = by_path.get(c.path)
|
| 154 |
+
if not doc:
|
| 155 |
+
continue
|
| 156 |
+
db.add(Citation(message_id=msg.id, document_id=doc.id, marker=c.marker, path=c.path,
|
| 157 |
+
line_start=c.line_start, line_end=c.line_end, span=c.quote))
|
| 158 |
citations.append({
|
| 159 |
+
"marker": c.marker,
|
| 160 |
+
"document_id": str(doc.id),
|
| 161 |
+
"title": doc.title,
|
| 162 |
+
"url": doc.url,
|
| 163 |
+
"path": c.path,
|
| 164 |
+
"line_start": c.line_start,
|
| 165 |
+
"line_end": c.line_end,
|
| 166 |
+
"span": c.quote,
|
| 167 |
})
|
| 168 |
db.commit()
|
| 169 |
+
if result.prompt_tokens or result.completion_tokens:
|
| 170 |
record_usage(
|
| 171 |
db,
|
| 172 |
workspace_id=ctx.workspace.id,
|
| 173 |
kind="llm",
|
| 174 |
provider=provider_name(),
|
| 175 |
model=settings.llm_model,
|
| 176 |
+
quantity=result.prompt_tokens + result.completion_tokens,
|
| 177 |
unit="tokens",
|
| 178 |
)
|
| 179 |
yield _sse("answer", {
|
| 180 |
"message_id": str(msg.id),
|
| 181 |
+
"text": result.text,
|
| 182 |
+
"unsupported": result.unsupported,
|
| 183 |
"citations": citations,
|
| 184 |
})
|
| 185 |
yield _sse("done", {})
|
backend/requirements.txt
CHANGED
|
@@ -3,7 +3,6 @@ uvicorn[standard]==0.32.*
|
|
| 3 |
sqlalchemy==2.0.*
|
| 4 |
psycopg[binary]==3.2.*
|
| 5 |
alembic==1.14.*
|
| 6 |
-
pgvector==0.3.*
|
| 7 |
pydantic==2.*
|
| 8 |
pydantic-settings==2.*
|
| 9 |
email-validator==2.*
|
|
@@ -19,4 +18,3 @@ redis==5.*
|
|
| 19 |
dspy==2.6.*
|
| 20 |
feedparser==6.*
|
| 21 |
beautifulsoup4==4.*
|
| 22 |
-
tiktoken==0.8.*
|
|
|
|
| 3 |
sqlalchemy==2.0.*
|
| 4 |
psycopg[binary]==3.2.*
|
| 5 |
alembic==1.14.*
|
|
|
|
| 6 |
pydantic==2.*
|
| 7 |
pydantic-settings==2.*
|
| 8 |
email-validator==2.*
|
|
|
|
| 18 |
dspy==2.6.*
|
| 19 |
feedparser==6.*
|
| 20 |
beautifulsoup4==4.*
|
|
|
backend/tests/test_corpus.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import UTC, datetime
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from app.corpus import INDEX, Corpus, CorpusError, ScopedTools, index_line, post_path, render_post
|
| 7 |
+
from app.llm.signatures import FileCitation
|
| 8 |
+
from app.pipeline.research import verify_citations
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MemoryStore:
|
| 12 |
+
"""Stands in for R2."""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.objects: dict[str, bytes] = {}
|
| 16 |
+
|
| 17 |
+
def put_text(self, key, text, content_type=None):
|
| 18 |
+
self.objects[key] = text.encode()
|
| 19 |
+
return key
|
| 20 |
+
|
| 21 |
+
def get_bytes(self, key):
|
| 22 |
+
return self.objects[key]
|
| 23 |
+
|
| 24 |
+
def exists(self, key):
|
| 25 |
+
return key in self.objects
|
| 26 |
+
|
| 27 |
+
def delete(self, key):
|
| 28 |
+
self.objects.pop(key, None)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
PRICING = [("Why I raised prices", "I moved the paid tier from $5 to $8 a month.\n\nChurn stayed flat."),
|
| 32 |
+
("What readers said", "Most readers told me the archive alone was worth it.")]
|
| 33 |
+
HABITS = [(None, "I write every morning before email.")]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.fixture()
|
| 37 |
+
def corpus_pair(tmp_path):
|
| 38 |
+
store = MemoryStore()
|
| 39 |
+
ws = uuid.uuid4()
|
| 40 |
+
writer = Corpus(ws, store=store, cache_dir=str(tmp_path / "worker"))
|
| 41 |
+
d1 = datetime(2026, 3, 1, tzinfo=UTC)
|
| 42 |
+
d2 = datetime(2025, 11, 9, tzinfo=UTC)
|
| 43 |
+
p1 = post_path("ada-substack-com", "On pricing", "https://ada.substack.com/p/on-pricing", d1)
|
| 44 |
+
p2 = post_path("ada-substack-com", "Habits", "https://ada.substack.com/p/habits", d2)
|
| 45 |
+
writer.write_files({
|
| 46 |
+
p1: render_post(
|
| 47 |
+
title="On pricing", url="https://x/p/on-pricing", published_at=d1, source="ada", sections=PRICING
|
| 48 |
+
),
|
| 49 |
+
p2: render_post(title="Habits", url="https://x/p/habits", published_at=d2, source="ada", sections=HABITS),
|
| 50 |
+
INDEX: "\n".join([index_line(p1, "On pricing", d1, ["Why I raised prices"]), index_line(p2, "Habits", d2, [])]),
|
| 51 |
+
})
|
| 52 |
+
# A different process (the API container) with an empty cache
|
| 53 |
+
reader = Corpus(ws, store=store, cache_dir=str(tmp_path / "api"))
|
| 54 |
+
reader.sync()
|
| 55 |
+
return reader, p1, p2
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_paths_are_stable_and_readable():
|
| 59 |
+
p = post_path("ada-substack-com", "Any Title", "https://ada.substack.com/p/on-pricing/", datetime(2026, 3, 1))
|
| 60 |
+
assert p == "sources/ada-substack-com/2026-03-01-on-pricing.md"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_sync_mirrors_r2_into_a_fresh_cache(corpus_pair):
|
| 64 |
+
reader, p1, _ = corpus_pair
|
| 65 |
+
assert reader.exists(p1) and reader.exists(INDEX)
|
| 66 |
+
assert "# On pricing" in reader.read_lines(p1)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_tools_search_read_and_scope(corpus_pair):
|
| 70 |
+
reader, p1, p2 = corpus_pair
|
| 71 |
+
steps = []
|
| 72 |
+
tools = ScopedTools(reader, {p1: "On pricing"}, on_step=lambda k, d: steps.append(k))
|
| 73 |
+
|
| 74 |
+
listing = tools.list_files()
|
| 75 |
+
assert p1 in listing and p2 not in listing # notebook scope hides other posts
|
| 76 |
+
|
| 77 |
+
hits = tools.search(r"paid tier|price")
|
| 78 |
+
assert f"{p1}:" in hits and "$5 to $8" in hits
|
| 79 |
+
assert "Habits" not in hits
|
| 80 |
+
|
| 81 |
+
body = tools.read(p1, 1, 5)
|
| 82 |
+
assert "1| ---" in body
|
| 83 |
+
|
| 84 |
+
with pytest.raises(CorpusError):
|
| 85 |
+
tools.read(p2) # outside the notebook
|
| 86 |
+
with pytest.raises(CorpusError):
|
| 87 |
+
tools.read("../../etc/passwd")
|
| 88 |
+
assert tools.search("(unclosed") # bad regex falls back to a literal search
|
| 89 |
+
assert steps[:3] == ["list", "search", "read"]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_verify_citations_drops_fakes_and_renumbers(corpus_pair):
|
| 93 |
+
reader, p1, p2 = corpus_pair
|
| 94 |
+
tools = ScopedTools(reader, {p1: "On pricing"})
|
| 95 |
+
line = next(i for i, t in enumerate(reader.read_lines(p1), 1) if "$5 to $8" in t)
|
| 96 |
+
cites = [
|
| 97 |
+
FileCitation(marker=1, path=p2, line_start=1, line_end=3), # out of scope
|
| 98 |
+
FileCitation(marker=2, path=p1, line_start=line, line_end=line), # real
|
| 99 |
+
FileCitation(marker=3, path=p1, line_start=9999, line_end=10000), # past end of file
|
| 100 |
+
]
|
| 101 |
+
text, verified = verify_citations(tools, "Habits matter [1]. Prices went to $8 [2]. Churn fell [3].", cites)
|
| 102 |
+
assert [c.marker for c in verified] == [1]
|
| 103 |
+
assert "$5 to $8" in verified[0].quote
|
| 104 |
+
assert text == "Habits matter. Prices went to $8 [1]. Churn fell."
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_react_agent_end_to_end_with_scripted_model(corpus_pair, monkeypatch):
|
| 108 |
+
"""Drives the real dspy.ReAct loop with a fake LM: search, read, then answer with a citation."""
|
| 109 |
+
import dspy
|
| 110 |
+
from dspy.utils.dummies import DummyLM
|
| 111 |
+
|
| 112 |
+
import app.pipeline.research as research_mod
|
| 113 |
+
|
| 114 |
+
reader, p1, _ = corpus_pair
|
| 115 |
+
line = next(i for i, t in enumerate(reader.read_lines(p1), 1) if "$5 to $8" in t)
|
| 116 |
+
lm = DummyLM([
|
| 117 |
+
{"next_thought": "Search for pricing.", "next_tool_name": "search", "next_tool_args": {"pattern": "paid tier"}},
|
| 118 |
+
{"next_thought": "Read the post.", "next_tool_name": "read",
|
| 119 |
+
"next_tool_args": {"path": p1, "start_line": 1, "end_line": 30}},
|
| 120 |
+
{"next_thought": "I have what I need.", "next_tool_name": "finish", "next_tool_args": {}},
|
| 121 |
+
{"reasoning": "The post states the new price.",
|
| 122 |
+
"answer": "You raised the paid tier from $5 to $8 a month [1].",
|
| 123 |
+
"citations": [{"marker": 1, "path": p1, "line_start": line, "line_end": line}],
|
| 124 |
+
"unsupported": False},
|
| 125 |
+
])
|
| 126 |
+
monkeypatch.setattr(research_mod, "main_lm", lambda: lm)
|
| 127 |
+
monkeypatch.setattr(research_mod, "track_usage", _no_usage)
|
| 128 |
+
seen = []
|
| 129 |
+
with dspy.context(adapter=dspy.ChatAdapter()):
|
| 130 |
+
result = research_mod.research(
|
| 131 |
+
reader, {p1: "On pricing"}, "What did I charge?", on_step=lambda k, d: seen.append(k)
|
| 132 |
+
)
|
| 133 |
+
assert seen == ["search", "read"]
|
| 134 |
+
assert result.unsupported is False
|
| 135 |
+
assert result.text.endswith("[1].")
|
| 136 |
+
assert result.citations[0].path == p1 and "$5 to $8" in result.citations[0].quote
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class _Usage:
|
| 140 |
+
prompt_tokens = completion_tokens = 0
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
from contextlib import contextmanager # noqa: E402
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@contextmanager
|
| 147 |
+
def _no_usage(lm):
|
| 148 |
+
yield _Usage()
|
backend/tests/test_misc.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import uuid
|
| 2 |
|
| 3 |
from app.llm.postprocess import clean_output, strip_em_dashes
|
| 4 |
-
from app.pipeline.ingest import
|
| 5 |
from app.services.email import make_unsubscribe_token, verify_unsubscribe_token
|
| 6 |
from app.services.storage import keys, safe_filename, workspace_owns_key
|
| 7 |
|
|
@@ -19,12 +19,11 @@ def test_normalize_feed_url():
|
|
| 19 |
assert normalize_feed_url("https://blog.example.com/rss")[1] == "rss"
|
| 20 |
|
| 21 |
|
| 22 |
-
def
|
| 23 |
html = "<h2>Intro</h2><p>" + "word " * 900 + "</p><p>Subscribe now</p><h2>Two</h2><p>Short.</p>"
|
| 24 |
sections = html_to_sections(html)
|
| 25 |
assert [h for h, _ in sections] == ["Intro", "Two"]
|
| 26 |
-
|
| 27 |
-
assert len(chunks) >= 3 and all(n <= 350 for _, _, n in chunks)
|
| 28 |
|
| 29 |
|
| 30 |
def test_storage_keys_are_workspace_scoped():
|
|
|
|
| 1 |
import uuid
|
| 2 |
|
| 3 |
from app.llm.postprocess import clean_output, strip_em_dashes
|
| 4 |
+
from app.pipeline.ingest import html_to_sections, normalize_feed_url
|
| 5 |
from app.services.email import make_unsubscribe_token, verify_unsubscribe_token
|
| 6 |
from app.services.storage import keys, safe_filename, workspace_owns_key
|
| 7 |
|
|
|
|
| 19 |
assert normalize_feed_url("https://blog.example.com/rss")[1] == "rss"
|
| 20 |
|
| 21 |
|
| 22 |
+
def test_html_to_sections():
|
| 23 |
html = "<h2>Intro</h2><p>" + "word " * 900 + "</p><p>Subscribe now</p><h2>Two</h2><p>Short.</p>"
|
| 24 |
sections = html_to_sections(html)
|
| 25 |
assert [h for h, _ in sections] == ["Intro", "Two"]
|
| 26 |
+
assert "Subscribe now" not in str(sections)
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def test_storage_keys_are_workspace_scoped():
|
docker-compose.yml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# Local stack. MinIO stands in for Cloudflare R2 (same S3 API); in prod drop minio and set R2_* vars.
|
| 2 |
services:
|
| 3 |
postgres:
|
| 4 |
-
image:
|
| 5 |
environment:
|
| 6 |
POSTGRES_USER: notestack
|
| 7 |
POSTGRES_PASSWORD: notestack
|
|
@@ -35,6 +35,8 @@ services:
|
|
| 35 |
R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
|
| 36 |
RENDERER_URL: http://renderer:3100
|
| 37 |
API_URL: http://api:8000
|
|
|
|
|
|
|
| 38 |
ports: ["8000:8000"]
|
| 39 |
depends_on:
|
| 40 |
postgres: { condition: service_healthy }
|
|
@@ -50,6 +52,8 @@ services:
|
|
| 50 |
R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
|
| 51 |
RENDERER_URL: http://renderer:3100
|
| 52 |
API_URL: http://api:8000
|
|
|
|
|
|
|
| 53 |
depends_on: [api]
|
| 54 |
|
| 55 |
renderer:
|
|
@@ -72,3 +76,5 @@ services:
|
|
| 72 |
volumes:
|
| 73 |
pgdata:
|
| 74 |
miniodata:
|
|
|
|
|
|
|
|
|
| 1 |
# Local stack. MinIO stands in for Cloudflare R2 (same S3 API); in prod drop minio and set R2_* vars.
|
| 2 |
services:
|
| 3 |
postgres:
|
| 4 |
+
image: postgres:16-alpine
|
| 5 |
environment:
|
| 6 |
POSTGRES_USER: notestack
|
| 7 |
POSTGRES_PASSWORD: notestack
|
|
|
|
| 35 |
R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
|
| 36 |
RENDERER_URL: http://renderer:3100
|
| 37 |
API_URL: http://api:8000
|
| 38 |
+
CORPUS_CACHE_DIR: /cache/corpus
|
| 39 |
+
volumes: [apicache:/cache]
|
| 40 |
ports: ["8000:8000"]
|
| 41 |
depends_on:
|
| 42 |
postgres: { condition: service_healthy }
|
|
|
|
| 52 |
R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
|
| 53 |
RENDERER_URL: http://renderer:3100
|
| 54 |
API_URL: http://api:8000
|
| 55 |
+
CORPUS_CACHE_DIR: /cache/corpus
|
| 56 |
+
volumes: [workercache:/cache]
|
| 57 |
depends_on: [api]
|
| 58 |
|
| 59 |
renderer:
|
|
|
|
| 76 |
volumes:
|
| 77 |
pgdata:
|
| 78 |
miniodata:
|
| 79 |
+
apicache:
|
| 80 |
+
workercache:
|
docs/DESIGN.md
CHANGED
|
@@ -27,13 +27,13 @@ Every factual statement Notestack generates must trace back to the writer's own
|
|
| 27 |
| --- | --- |
|
| 28 |
| Backend | FastAPI + SQLAlchemy 2 + Alembic (same stack as blog2video) |
|
| 29 |
| Worker | arq on Redis (async jobs, progress events over Redis pub/sub, SSE to clients) |
|
| 30 |
-
| Database | Postgres 16
|
| 31 |
| Frontend | React + Vite + TypeScript, plain CSS with design tokens |
|
| 32 |
| Renderer | Node service running Remotion (`renderer/`) |
|
| 33 |
| Storage | Cloudflare R2 (S3 API) for raw HTML, audio, video, stills, compiled DSPy programs |
|
| 34 |
| Email | Resend, behind a provider interface (mirrors blog2video `EmailService`) |
|
| 35 |
| Auth | Google sign-in + email 6-digit code signup + password login, JWT access/refresh with `token_version` revocation (mirrors blog2video) |
|
| 36 |
-
| LLM | Provider agnostic through DSPy + LiteLLM. Default Z.ai GLM-5.3 (fast tier GLM-5.3-Flash) via its OpenAI compatible endpoint.
|
| 37 |
| Billing | Three tiers defined in code (`app/services/plans.py`). `BILLING_ENABLED=false` for now: everyone gets the top tier limits free, checkout returns 409, pricing page shows "Free during early access". |
|
| 38 |
| Blog | Typed `blogPosts.ts` content array, `/blogs` and `/blogs/:slug`, same shape as blog2video |
|
| 39 |
|
|
@@ -48,14 +48,14 @@ Every factual statement Notestack generates must trace back to the writer's own
|
|
| 48 |
| | enqueue (arq)
|
| 49 |
+--------+----------+ +---------v----------+
|
| 50 |
| Cloudflare R2 |<-------------------------| worker (arq) |
|
| 51 |
-
| notestack-assets | put objects | ingest,
|
| 52 |
-
+--------+----------+ |
|
| 53 |
^ +---------+----------+
|
| 54 |
| upload final render | POST /render
|
| 55 |
+--------+----------+ progress callbacks +---------v----------+
|
| 56 |
| renderer (Remotion)|------------------------>| api /internal/... |
|
| 57 |
+-------------------+ +--------------------+
|
| 58 |
-
Postgres
|
| 59 |
```
|
| 60 |
|
| 61 |
### 3.1 Storage pipeline (R2)
|
|
@@ -66,6 +66,9 @@ Key layout (`app/services/storage.py::keys`):
|
|
| 66 |
|
| 67 |
```
|
| 68 |
ws/{workspace_id}/sources/{source_id}/documents/{document_id}/raw.html
|
|
|
|
|
|
|
|
|
|
| 69 |
ws/{workspace_id}/uploads/{upload_id}/{filename}
|
| 70 |
ws/{workspace_id}/artifacts/{artifact_id}/{variant}.{ext} audio, video, stills
|
| 71 |
ws/{workspace_id}/tts-cache/{sha256(script|voice|settings)}.mp3
|
|
@@ -87,7 +90,50 @@ Flows:
|
|
| 87 |
The storage service is an S3 client with R2's endpoint, so MinIO in docker-compose stands in for R2
|
| 88 |
locally with zero code changes.
|
| 89 |
|
| 90 |
-
### 3.2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
`app/llm/provider.py` builds a `dspy.LM` from env:
|
| 93 |
|
|
@@ -97,8 +143,6 @@ LLM_FAST_MODEL=openai/glm-5.3-flash # judges, cleanup, cheap passes
|
|
| 97 |
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 98 |
LLM_API_KEY=...
|
| 99 |
LLM_REASONING_EFFORT=low # low | high | max
|
| 100 |
-
EMBEDDING_MODEL=openai/text-embedding-3-small
|
| 101 |
-
EMBEDDING_DIM=1024
|
| 102 |
```
|
| 103 |
|
| 104 |
Z.ai considerations baked in:
|
|
@@ -107,16 +151,15 @@ Z.ai considerations baked in:
|
|
| 107 |
`reasoning_effort=low` by default; modules that benefit (the groundedness judge) ask for
|
| 108 |
`main_lm("high")`. Reasoning tokens count against `max_tokens`, so the default is 16K to leave
|
| 109 |
room for the structured answer. Z.ai recommends temperature 1.0 for GLM-5.x.
|
| 110 |
-
- 1M token context on GLM-5.3 means whole-notebook prompts (summaries, podcast scripts)
|
| 111 |
-
|
| 112 |
-
-
|
| 113 |
-
|
| 114 |
-
fixed. Changing `EMBEDDING_DIM` requires a migration and re-embed.
|
| 115 |
- Token usage from every call is written to `usage_events` (metering from day one).
|
| 116 |
|
| 117 |
Swapping to Anthropic, OpenAI, Gemini or a local model is only an env change.
|
| 118 |
|
| 119 |
-
### 3.
|
| 120 |
|
| 121 |
Every long operation is a row in `jobs` (kind, status, progress, error, cost) plus an arq task.
|
| 122 |
Workers publish progress to Redis channel `job:{id}`; `GET /api/jobs/{id}/events` streams it as SSE.
|
|
@@ -128,9 +171,9 @@ Implemented in `backend/app/models` and `alembic/versions/0001_initial.py`.
|
|
| 128 |
|
| 129 |
- users, workspaces, workspace_members, subscriptions
|
| 130 |
- email_verification_codes, update_emails, update_email_sends
|
| 131 |
-
- sources, documents
|
| 132 |
- voice_profiles, voice_consents
|
| 133 |
-
- chats, messages, citations
|
| 134 |
- artifacts, jobs (covers render_jobs and tts_jobs via `kind`), uploads
|
| 135 |
- calendar_items, tracked_links, engagement_events
|
| 136 |
- dspy_traces, usage_events
|
|
@@ -138,7 +181,7 @@ Implemented in `backend/app/models` and `alembic/versions/0001_initial.py`.
|
|
| 138 |
## 5. DSPy program design
|
| 139 |
|
| 140 |
See brief Section 6. Signatures live in `backend/app/llm/signatures.py` with Pydantic output models.
|
| 141 |
-
Phase 1 ships CleanAndSegment,
|
| 142 |
signatures so the schema contract is fixed early.
|
| 143 |
|
| 144 |
## 6. Plans
|
|
|
|
| 27 |
| --- | --- |
|
| 28 |
| Backend | FastAPI + SQLAlchemy 2 + Alembic (same stack as blog2video) |
|
| 29 |
| Worker | arq on Redis (async jobs, progress events over Redis pub/sub, SSE to clients) |
|
| 30 |
+
| Database | Postgres 16 (no vector extension) |
|
| 31 |
| Frontend | React + Vite + TypeScript, plain CSS with design tokens |
|
| 32 |
| Renderer | Node service running Remotion (`renderer/`) |
|
| 33 |
| Storage | Cloudflare R2 (S3 API) for raw HTML, audio, video, stills, compiled DSPy programs |
|
| 34 |
| Email | Resend, behind a provider interface (mirrors blog2video `EmailService`) |
|
| 35 |
| Auth | Google sign-in + email 6-digit code signup + password login, JWT access/refresh with `token_version` revocation (mirrors blog2video) |
|
| 36 |
+
| LLM | Provider agnostic through DSPy + LiteLLM. Default Z.ai GLM-5.3 (fast tier GLM-5.3-Flash) via its OpenAI compatible endpoint. Retrieval is agentic search over a file system corpus, not embeddings (see 3.2). Any LiteLLM model string works by env var. |
|
| 37 |
| Billing | Three tiers defined in code (`app/services/plans.py`). `BILLING_ENABLED=false` for now: everyone gets the top tier limits free, checkout returns 409, pricing page shows "Free during early access". |
|
| 38 |
| Blog | Typed `blogPosts.ts` content array, `/blogs` and `/blogs/:slug`, same shape as blog2video |
|
| 39 |
|
|
|
|
| 48 |
| | enqueue (arq)
|
| 49 |
+--------+----------+ +---------v----------+
|
| 50 |
| Cloudflare R2 |<-------------------------| worker (arq) |
|
| 51 |
+
| notestack-assets | put objects | ingest, corpus, |
|
| 52 |
+
+--------+----------+ | DSPy, TTS |
|
| 53 |
^ +---------+----------+
|
| 54 |
| upload final render | POST /render
|
| 55 |
+--------+----------+ progress callbacks +---------v----------+
|
| 56 |
| renderer (Remotion)|------------------------>| api /internal/... |
|
| 57 |
+-------------------+ +--------------------+
|
| 58 |
+
Postgres (api, worker) Redis (queue, pub/sub, cache)
|
| 59 |
```
|
| 60 |
|
| 61 |
### 3.1 Storage pipeline (R2)
|
|
|
|
| 66 |
|
| 67 |
```
|
| 68 |
ws/{workspace_id}/sources/{source_id}/documents/{document_id}/raw.html
|
| 69 |
+
ws/{workspace_id}/corpus/manifest.json path -> sha256 of every corpus file
|
| 70 |
+
ws/{workspace_id}/corpus/INDEX.md one line per post
|
| 71 |
+
ws/{workspace_id}/corpus/sources/{site}/{date}-{slug}.md clean post as markdown
|
| 72 |
ws/{workspace_id}/uploads/{upload_id}/{filename}
|
| 73 |
ws/{workspace_id}/artifacts/{artifact_id}/{variant}.{ext} audio, video, stills
|
| 74 |
ws/{workspace_id}/tts-cache/{sha256(script|voice|settings)}.mp3
|
|
|
|
| 90 |
The storage service is an S3 client with R2's endpoint, so MinIO in docker-compose stands in for R2
|
| 91 |
locally with zero code changes.
|
| 92 |
|
| 93 |
+
### 3.2 Research corpus: file system instead of embeddings
|
| 94 |
+
|
| 95 |
+
Each post is one markdown file (front matter, `# Title`, `## Section` headings) and the workspace
|
| 96 |
+
has an `INDEX.md` with one line per post. Research is an agent loop (`dspy.ReAct`,
|
| 97 |
+
`app/pipeline/research.py`) with three tools scoped to the notebook's files (`app/corpus.py`):
|
| 98 |
+
|
| 99 |
+
| Tool | Does |
|
| 100 |
+
| --- | --- |
|
| 101 |
+
| `list_files(contains)` | Index lines for the notebook's posts: date, path, title, headings |
|
| 102 |
+
| `search(pattern, path)` | Case insensitive regex grep, returns `path:line: text` |
|
| 103 |
+
| `read(path, start_line, end_line)` | Numbered lines, 160 per call |
|
| 104 |
+
|
| 105 |
+
The model searches with several phrasings, reads around the hits, and answers with citations of
|
| 106 |
+
the form `(path, line_start, line_end)`. Before anything reaches the writer, every citation is read
|
| 107 |
+
back from the file: citations that point outside the notebook, past the end of a file or at empty
|
| 108 |
+
lines are dropped, markers are renumbered, and an answer left with no verified citation is replaced
|
| 109 |
+
by "not in your posts". The UI streams each step ("Searching for pricing|paid tier", "Reading On
|
| 110 |
+
pricing, lines 1 to 40").
|
| 111 |
+
|
| 112 |
+
Why this over embeddings:
|
| 113 |
+
- One vendor. Z.ai's international API has no embedding model, and there is no second key,
|
| 114 |
+
re-embedding job or dimension lock-in.
|
| 115 |
+
- Citations are exact line ranges the model actually read, which is stronger grounding than
|
| 116 |
+
nearest-neighbour chunks.
|
| 117 |
+
- Writers' archives are small (hundreds to a few thousand posts, a few MB of text), so grep over
|
| 118 |
+
local files takes milliseconds.
|
| 119 |
+
- The corpus is plain markdown, so the same files feed podcast scripts, launch kits and a future
|
| 120 |
+
MCP server without conversion.
|
| 121 |
+
|
| 122 |
+
Trade offs: each question costs several LLM steps instead of one (mitigated by
|
| 123 |
+
`LLM_REASONING_EFFORT=low` and the `RESEARCH_MAX_STEPS` cap), and paraphrase matching depends on
|
| 124 |
+
the model trying synonyms. If quality or latency needs it later, a Postgres full text index can be
|
| 125 |
+
added as a fourth tool without changing the corpus.
|
| 126 |
+
|
| 127 |
+
Storage and sync: R2 holds the corpus under `ws/{id}/corpus/` with a `manifest.json` of file hashes.
|
| 128 |
+
The worker writes files to R2 and its own cache in one pass. The API calls `Corpus.sync()` before
|
| 129 |
+
each research run, which reads the manifest and downloads only changed files into
|
| 130 |
+
`CORPUS_CACHE_DIR`. Containers share nothing but R2.
|
| 131 |
+
|
| 132 |
+
Brief items that assumed embeddings: voice similarity uses the LLM judge alone; the topic map is
|
| 133 |
+
built from LLM extracted entities per post (Phase 2); resurfacing and SEO internal links search the
|
| 134 |
+
corpus with the same tools.
|
| 135 |
+
|
| 136 |
+
### 3.3 LLM layer
|
| 137 |
|
| 138 |
`app/llm/provider.py` builds a `dspy.LM` from env:
|
| 139 |
|
|
|
|
| 143 |
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 144 |
LLM_API_KEY=...
|
| 145 |
LLM_REASONING_EFFORT=low # low | high | max
|
|
|
|
|
|
|
| 146 |
```
|
| 147 |
|
| 148 |
Z.ai considerations baked in:
|
|
|
|
| 151 |
`reasoning_effort=low` by default; modules that benefit (the groundedness judge) ask for
|
| 152 |
`main_lm("high")`. Reasoning tokens count against `max_tokens`, so the default is 16K to leave
|
| 153 |
room for the structured answer. Z.ai recommends temperature 1.0 for GLM-5.x.
|
| 154 |
+
- 1M token context on GLM-5.3 means whole-notebook prompts (summaries, podcast scripts) can read
|
| 155 |
+
the files directly; chat uses the research agent to keep citations tight and cost low.
|
| 156 |
+
- Tool calling runs through DSPy ReAct, so it works with any provider, not only ones with native
|
| 157 |
+
function calling.
|
|
|
|
| 158 |
- Token usage from every call is written to `usage_events` (metering from day one).
|
| 159 |
|
| 160 |
Swapping to Anthropic, OpenAI, Gemini or a local model is only an env change.
|
| 161 |
|
| 162 |
+
### 3.4 Jobs and progress
|
| 163 |
|
| 164 |
Every long operation is a row in `jobs` (kind, status, progress, error, cost) plus an arq task.
|
| 165 |
Workers publish progress to Redis channel `job:{id}`; `GET /api/jobs/{id}/events` streams it as SSE.
|
|
|
|
| 171 |
|
| 172 |
- users, workspaces, workspace_members, subscriptions
|
| 173 |
- email_verification_codes, update_emails, update_email_sends
|
| 174 |
+
- sources, documents (with corpus `path`), notebooks, notebook_documents
|
| 175 |
- voice_profiles, voice_consents
|
| 176 |
+
- chats, messages, citations (document, path, line range, verified quote)
|
| 177 |
- artifacts, jobs (covers render_jobs and tts_jobs via `kind`), uploads
|
| 178 |
- calendar_items, tracked_links, engagement_events
|
| 179 |
- dspy_traces, usage_events
|
|
|
|
| 181 |
## 5. DSPy program design
|
| 182 |
|
| 183 |
See brief Section 6. Signatures live in `backend/app/llm/signatures.py` with Pydantic output models.
|
| 184 |
+
Phase 1 ships CleanAndSegment, ResearchArchive (the file research agent) and BuildVoiceProfile. All grounded outputs cite `SourceRef(path, line_start, line_end)`. The rest are stubs with typed
|
| 185 |
signatures so the schema contract is fixed early.
|
| 186 |
|
| 187 |
## 6. Plans
|
docs/PHASE0.md
CHANGED
|
@@ -4,14 +4,14 @@ Legend: [x] done in this scaffold, [ ] open
|
|
| 4 |
|
| 5 |
## Repo and infra
|
| 6 |
- [x] Monorepo layout: `backend/`, `frontend/`, `renderer/`, `docs/`
|
| 7 |
-
- [x] docker-compose: api, worker, postgres
|
| 8 |
- [x] `.env.example` covering every service
|
| 9 |
- [x] CI: backend lint + tests, frontend typecheck + build, renderer typecheck
|
| 10 |
- [ ] Production deploy target (Fly, Render or DO App Platform) and R2 bucket + custom domain
|
| 11 |
- [ ] Sentry (api, worker, frontend)
|
| 12 |
|
| 13 |
## Backend core
|
| 14 |
-
- [x] Settings (`app/config.py`), DB session, Alembic initial migration
|
| 15 |
- [x] Full data model from design doc Section 4
|
| 16 |
- [x] Health endpoint, CORS, request id logging
|
| 17 |
- [x] Jobs table + arq worker + Redis progress pub/sub + SSE endpoint
|
|
@@ -39,8 +39,9 @@ Legend: [x] done in this scaffold, [ ] open
|
|
| 39 |
|
| 40 |
## LLM (agnostic, Z.ai first)
|
| 41 |
- [x] `dspy.LM` factory from env, Z.ai defaults
|
| 42 |
-
- [x]
|
| 43 |
-
- [x]
|
|
|
|
| 44 |
- [x] Em dash stripping post-processor
|
| 45 |
- [ ] Dev sets and first optimization run (Phase 1)
|
| 46 |
|
|
|
|
| 4 |
|
| 5 |
## Repo and infra
|
| 6 |
- [x] Monorepo layout: `backend/`, `frontend/`, `renderer/`, `docs/`
|
| 7 |
+
- [x] docker-compose: api, worker, postgres, redis, minio (local R2 stand-in), renderer, frontend
|
| 8 |
- [x] `.env.example` covering every service
|
| 9 |
- [x] CI: backend lint + tests, frontend typecheck + build, renderer typecheck
|
| 10 |
- [ ] Production deploy target (Fly, Render or DO App Platform) and R2 bucket + custom domain
|
| 11 |
- [ ] Sentry (api, worker, frontend)
|
| 12 |
|
| 13 |
## Backend core
|
| 14 |
+
- [x] Settings (`app/config.py`), DB session, Alembic initial migration
|
| 15 |
- [x] Full data model from design doc Section 4
|
| 16 |
- [x] Health endpoint, CORS, request id logging
|
| 17 |
- [x] Jobs table + arq worker + Redis progress pub/sub + SSE endpoint
|
|
|
|
| 39 |
|
| 40 |
## LLM (agnostic, Z.ai first)
|
| 41 |
- [x] `dspy.LM` factory from env, Z.ai defaults
|
| 42 |
+
- [x] File system corpus in R2 with manifest sync (`app/corpus.py`)
|
| 43 |
+
- [x] Research agent: list/search/read tools, verified line citations, live step streaming
|
| 44 |
+
- [x] Signatures + Pydantic outputs for CleanAndSegment, ResearchArchive, BuildVoiceProfile, stubs for the rest
|
| 45 |
- [x] Em dash stripping post-processor
|
| 46 |
- [ ] Dev sets and first optimization run (Phase 1)
|
| 47 |
|
frontend/src/pages/NotebookView.tsx
CHANGED
|
@@ -6,8 +6,16 @@ import { TelescopeIcon } from "../components/icons/Icons";
|
|
| 6 |
|
| 7 |
type Doc = { id: string; title: string; url: string; published_at: string | null };
|
| 8 |
type Notebook = { id: string; title: string; documents: Doc[] };
|
| 9 |
-
type Citation = {
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
export default function NotebookView() {
|
| 13 |
const { id = "" } = useParams();
|
|
@@ -38,6 +46,13 @@ export default function NotebookView() {
|
|
| 38 |
if (event === "status") {
|
| 39 |
if (d.chat_id) setChatId(d.chat_id as string);
|
| 40 |
patchLast({ status: d.message as string });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
} else if (event === "answer") {
|
| 42 |
patchLast({ text: d.text as string, citations: d.citations as Citation[], status: undefined });
|
| 43 |
}
|
|
@@ -77,7 +92,17 @@ export default function NotebookView() {
|
|
| 77 |
)}
|
| 78 |
{turns.map((t, i) => (
|
| 79 |
<div key={i} className={`turn turn-${t.role}`}>
|
| 80 |
-
{t.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
{t.text && <p>{t.text}</p>}
|
| 82 |
{t.citations && t.citations.length > 0 && (
|
| 83 |
<ol className="cites">
|
|
@@ -85,9 +110,12 @@ export default function NotebookView() {
|
|
| 85 |
<li key={c.marker} value={c.marker}>
|
| 86 |
<a href={c.url} target="_blank" rel="noreferrer">
|
| 87 |
{c.title}
|
| 88 |
-
|
| 89 |
-
<
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
| 91 |
</li>
|
| 92 |
))}
|
| 93 |
</ol>
|
|
|
|
| 6 |
|
| 7 |
type Doc = { id: string; title: string; url: string; published_at: string | null };
|
| 8 |
type Notebook = { id: string; title: string; documents: Doc[] };
|
| 9 |
+
type Citation = {
|
| 10 |
+
marker: number;
|
| 11 |
+
title: string;
|
| 12 |
+
url: string;
|
| 13 |
+
path: string;
|
| 14 |
+
line_start: number;
|
| 15 |
+
line_end: number;
|
| 16 |
+
span: string;
|
| 17 |
+
};
|
| 18 |
+
type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] };
|
| 19 |
|
| 20 |
export default function NotebookView() {
|
| 21 |
const { id = "" } = useParams();
|
|
|
|
| 46 |
if (event === "status") {
|
| 47 |
if (d.chat_id) setChatId(d.chat_id as string);
|
| 48 |
patchLast({ status: d.message as string });
|
| 49 |
+
} else if (event === "step") {
|
| 50 |
+
setTurns((t) => {
|
| 51 |
+
const last = t[t.length - 1];
|
| 52 |
+
return [...t.slice(0, -1), { ...last, status: d.message as string, steps: [...(last.steps ?? []), d.message as string] }];
|
| 53 |
+
});
|
| 54 |
+
} else if (event === "error") {
|
| 55 |
+
patchLast({ text: d.message as string, status: undefined });
|
| 56 |
} else if (event === "answer") {
|
| 57 |
patchLast({ text: d.text as string, citations: d.citations as Citation[], status: undefined });
|
| 58 |
}
|
|
|
|
| 92 |
)}
|
| 93 |
{turns.map((t, i) => (
|
| 94 |
<div key={i} className={`turn turn-${t.role}`}>
|
| 95 |
+
{t.steps && t.steps.length > 0 && (
|
| 96 |
+
<details className="agent-steps mono">
|
| 97 |
+
<summary>{t.status ? `${t.status}...` : `${t.steps.length} steps through the archive`}</summary>
|
| 98 |
+
<ol>
|
| 99 |
+
{t.steps.map((s, j) => (
|
| 100 |
+
<li key={j}>{s}</li>
|
| 101 |
+
))}
|
| 102 |
+
</ol>
|
| 103 |
+
</details>
|
| 104 |
+
)}
|
| 105 |
+
{t.status && !t.steps?.length && <p className="mono muted">{t.status}...</p>}
|
| 106 |
{t.text && <p>{t.text}</p>}
|
| 107 |
{t.citations && t.citations.length > 0 && (
|
| 108 |
<ol className="cites">
|
|
|
|
| 110 |
<li key={c.marker} value={c.marker}>
|
| 111 |
<a href={c.url} target="_blank" rel="noreferrer">
|
| 112 |
{c.title}
|
| 113 |
+
</a>{" "}
|
| 114 |
+
<span className="mono muted">
|
| 115 |
+
lines {c.line_start}
|
| 116 |
+
{c.line_end > c.line_start ? ` to ${c.line_end}` : ""}
|
| 117 |
+
</span>
|
| 118 |
+
<p className="muted cite-span">{c.span.length > 220 ? `${c.span.slice(0, 220)}...` : c.span}</p>
|
| 119 |
</li>
|
| 120 |
))}
|
| 121 |
</ol>
|
frontend/src/styles/app.css
CHANGED
|
@@ -1047,3 +1047,20 @@
|
|
| 1047 |
color: var(--white);
|
| 1048 |
opacity: 0.85;
|
| 1049 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
color: var(--white);
|
| 1048 |
opacity: 0.85;
|
| 1049 |
}
|
| 1050 |
+
|
| 1051 |
+
.agent-steps {
|
| 1052 |
+
font-size: 0.75rem;
|
| 1053 |
+
color: var(--text-2);
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
.agent-steps summary {
|
| 1057 |
+
cursor: pointer;
|
| 1058 |
+
color: var(--blue);
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
.agent-steps ol {
|
| 1062 |
+
margin: 8px 0 0;
|
| 1063 |
+
padding-left: 18px;
|
| 1064 |
+
display: grid;
|
| 1065 |
+
gap: 2px;
|
| 1066 |
+
}
|