Arslan1997 commited on
Commit
94b5741
·
1 Parent(s): 00a4363

added search

Browse files
.env.example CHANGED
@@ -38,12 +38,9 @@ LLM_TEMPERATURE=1.0
38
  LLM_MAX_TOKENS=16000
39
  LLM_REASONING_EFFORT=low
40
 
41
- # ---- Embeddings (separate provider: Z.ai's international API has no embedding model)
42
- # Must support a `dimensions` parameter so the pgvector column stays EMBEDDING_DIM wide.
43
- EMBEDDING_MODEL=openai/text-embedding-3-small
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), EMBEDDING_API_KEY, GOOGLE_CLIENT_ID, RESEND_API_KEY
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 (pgvector) + 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,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: pgvector + 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,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.execute("CREATE EXTENSION IF NOT EXISTS vector")
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
- # Embeddings are a separate provider: Z.ai's international API has no embedding model.
55
- # LiteLLM picks up OPENAI_API_KEY etc. from env when embedding_api_key is empty.
56
- embedding_model: str = "openai/text-embedding-3-small"
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 CitedSentence(BaseModel):
18
- sentence: str
19
- chunk_ids: list[int] = Field(description="Indices of supporting passages from the context list")
20
 
 
 
 
21
 
22
- class GroundedAnswerOut(BaseModel):
23
- answer: str = Field(description="Answer text with [n] markers that refer to passage indices")
24
- sentences: list[CitedSentence]
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
- chunk_ids: list[int]
41
 
42
 
43
  class ScriptLine(BaseModel):
44
  speaker: Literal["host_a", "host_b"]
45
  text: str
46
- chunk_ids: list[int] = Field(default_factory=list)
47
 
48
 
49
  class Scene(BaseModel):
@@ -72,14 +73,20 @@ class CleanAndSegment(dspy.Signature):
72
  sections: list[Section] = dspy.OutputField()
73
 
74
 
75
- class GroundedAnswer(dspy.Signature):
76
- """Answer the question using ONLY the numbered passages. Every factual sentence must cite at least
77
- one passage with [n]. If the passages do not support an answer, say so and set unsupported=true.
78
- Never use em dashes."""
 
 
 
 
79
 
80
  question: str = dspy.InputField()
81
- passages: list[str] = dspy.InputField(desc="Numbered passages from the writer's own posts")
82
- result: GroundedAnswerOut = dspy.OutputField()
 
 
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
- chunk_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("chunks.id", ondelete="CASCADE"))
143
  marker: Mapped[int] = mapped_column(Integer) # [1], [2] in the answer text
144
- span: Mapped[str | None] = mapped_column(Text) # quoted supporting text
 
 
 
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 | embedding | tts | render
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 -> documents (raw HTML to R2) -> clean text -> chunks -> embeddings."""
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.config import settings
20
- from app.llm.embeddings import embed_texts, embedding_provider
21
- from app.models import Chunk, Document, Job, Source
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
- embed_tokens = 0
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.chunks.clear()
134
- pieces = chunk_sections(sections)
135
- if pieces:
136
- result = embed_texts([f"{entry.title}\n{h or ''}\n{t}" for h, t, _ in pieces])
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.9 * (i + 1) / total, message=f"{entry.title[:80]} is in orbit")
152
-
153
- if embed_tokens:
154
- record_usage(
155
- db,
156
- workspace_id=source.workspace_id,
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.retrieval import grounded_answer, retrieve
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": "Scanning your archive"})
116
- hits = await run_in_threadpool(retrieve, db, ctx.workspace.id, body.question, nb.id)
117
- yield _sse("status", {"message": f"Found {len(hits)} relevant passages"})
118
- answer = await run_in_threadpool(grounded_answer, body.question, hits)
119
- msg = Message(chat_id=chat_row.id, role="assistant", content=answer.text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  db.add(msg)
121
  db.flush()
122
  citations = []
123
- for marker, hit in answer.citations:
124
- db.add(Citation(message_id=msg.id, chunk_id=hit.chunk.id, marker=marker, span=hit.chunk.text[:500]))
 
 
 
 
125
  citations.append({
126
- "marker": marker,
127
- "chunk_id": str(hit.chunk.id),
128
- "document_id": str(hit.document.id),
129
- "title": hit.document.title,
130
- "url": hit.document.url,
131
- "heading": hit.chunk.heading,
132
- "span": hit.chunk.text[:500],
 
133
  })
134
  db.commit()
135
- if answer.prompt_tokens or answer.completion_tokens:
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=answer.prompt_tokens + answer.completion_tokens,
143
  unit="tokens",
144
  )
145
  yield _sse("answer", {
146
  "message_id": str(msg.id),
147
- "text": answer.text,
148
- "unsupported": answer.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 chunk_sections, 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,12 +19,11 @@ def test_normalize_feed_url():
19
  assert normalize_feed_url("https://blog.example.com/rss")[1] == "rss"
20
 
21
 
22
- def test_html_to_sections_and_chunking():
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
- chunks = chunk_sections(sections)
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: pgvector/pgvector:pg16
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 + pgvector |
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. Embeddings use a separate provider (OpenAI text-embedding-3-small by default). 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,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, chunk, |
52
- +--------+----------+ | embed, DSPy, TTS |
53
  ^ +---------+----------+
54
  | upload final render | POST /render
55
  +--------+----------+ progress callbacks +---------v----------+
56
  | renderer (Remotion)|------------------------>| api /internal/... |
57
  +-------------------+ +--------------------+
58
- Postgres + pgvector (api, worker) Redis (queue, pub/sub, cache)
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 LLM layer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) rarely
111
- need map-reduce; retrieval is still used for chat to keep citations tight and cost low.
112
- - Z.ai's international API does not offer an embedding model, so embeddings are configured
113
- separately. The chosen model must accept a `dimensions` parameter so the pgvector column stays
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.3 Jobs and progress
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, chunks (pgvector `embedding`), notebooks, notebook_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, GroundedAnswer and BuildVoiceProfile. The rest are stubs with typed
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 (pgvector), 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 with pgvector
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] Embeddings client with fixed dimensions
43
- - [x] Signatures + Pydantic outputs for CleanAndSegment, GroundedAnswer, BuildVoiceProfile, stubs for the rest
 
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 = { marker: number; title: string; url: string; heading: string | null; span: string };
10
- type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string };
 
 
 
 
 
 
 
 
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.status && <p className="mono muted">{t.status}...</p>}
 
 
 
 
 
 
 
 
 
 
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
- {c.heading ? `: ${c.heading}` : ""}
89
- </a>
90
- <p className="muted cite-span">{c.span.slice(0, 220)}...</p>
 
 
 
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
+ }