Add scheduled refresh + agent-facing insight library (MCP + JSON CLI)

Part A — incremental `refresh`:
- clawlibrary/refresh.py: in-process orchestrator chaining the already-idempotent
  stages (harvest papers → ingest videos → extract → summarize → embed) on only-new
  items; one run_id, per-stage StageResult, NewItem capture, and a "what's new" feed
  (vault/refresh-log.jsonl + vault/digests/{date}.md).
- clawlibrary/lock.py: O_EXCL file_lock (auto-breaks stale/dead locks) so overlapping
  runs can't corrupt state.json/index.json.
- summarize.append_insight_once/appended_dois: idempotent insights.md (no dup sections
  on re-run); cmd_summarize switched to it.
- cmd_refresh CLI (--source/--skip-youtube/--no-whisper/--max/--model/--dry-run);
  scheduled YouTube allows Whisper GPU fallback. scripts/refresh.sh + cron docs.

Part B — agent access:
- clawlibrary/insights_api.py: load-once LibraryContext wrapping embedstore.search /
  rag.ask; search_vault/ask_library/get_insight/list_topics/list_recent; get_insight
  reads sidecars (index.json is summary-less). VaultUnavailable/BackendDown errors.
- clawlibrary/mcp_server.py: FastMCP stdio server (clawlibrary-mcp), [mcp] extra.
- --json on search/ask + new insight/catalog commands, sharing the same layer.

25 new tests (97 passed, 1 skipped where mcp absent); ruff clean. README updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 16:17:28 -05:00
co-authored by Claude Opus 4.8
parent 8b731f241a
commit 9af17456f4
15 changed files with 1484 additions and 38 deletions
+66
View File
@@ -124,6 +124,72 @@ clawlibrary ask "What did the talks say about agentic planning?"
(only), `whisper` (force). On a GPU node, `--whisper-device auto` picks CUDA; (only), `whisper` (force). On a GPU node, `--whisper-device auto` picks CUDA;
it falls back to CPU/int8 when no GPU is present. it falls back to CPU/int8 when no GPU is present.
## Scheduled refresh (keeping the vault current)
`clawlibrary refresh` re-polls every tracked domain and folds only the **new**
content into the insight layer — in one idempotent pass:
```
harvest new papers (OpenAlex/arXiv) → ingest new videos (youtube.yaml)
→ extract → summarize → embed
```
Each stage already skips what it's done, so re-running is cheap and safe. The run
is guarded by a lockfile (`state.json.lock`) so overlapping invocations can't
corrupt state, and it emits a **"what's new" feed**:
- `vault/refresh-log.jsonl` — one JSON line per run (counts + new items); the
machine-readable stream an agent can tail.
- `vault/digests/{YYYYMMDD}.md` — a skimmable page of new papers/videos with
one-line summaries.
```bash
clawlibrary refresh --source both --dry-run # what would be added; writes nothing
clawlibrary refresh --source both # do it (Whisper fallback ON for videos)
clawlibrary refresh --source both --no-whisper --skip-youtube # papers only, no GPU
```
**Cron** (on the GPU node `tank`) — `scripts/refresh.sh` activates the venv, runs
the refresh, logs to `logs/refresh-{date}.log`, and restarts the MCP server so
agents pick up fresh embeddings:
```cron
30 3 * * * /home/osobh/clawlibrary/scripts/refresh.sh
```
## Agent access — MCP server + JSON CLI
The vault is exposed to agents two ways, both sharing one query layer
(`clawlibrary/insights_api.py`, which loads the embedding matrix once).
**MCP server** (recommended for agents) — `pip install -e ".[mcp]"`, then register
the `clawlibrary-mcp` stdio server. Tools: `search_vault`, `ask_library`,
`get_insight`, `list_topics`, `list_recent`; resources: `clawlibrary://insights`,
`clawlibrary://catalog`. Run it on `tank` (keeps the matrix local to the GPU box):
```json
{
"mcpServers": {
"clawlibrary": {
"command": "/home/osobh/clawlibrary/.venv/bin/clawlibrary-mcp",
"cwd": "/home/osobh/clawlibrary",
"env": { "CLAWLIBRARY_CONFIG": "config.yaml", "OLLAMA_HOST": "http://localhost:11434" }
}
}
}
```
**JSON CLI** (shell/cron fallback) — `--json` emits clean JSON on stdout:
```bash
clawlibrary search "agentic planning" --json | jq .
clawlibrary ask "How does the surface code reach threshold?" --json
clawlibrary insight 10.48550/arxiv.2401.01234 --json
clawlibrary catalog --topics --json
```
Exit codes: `0` ok, `1` not-found (`insight`), `2` vault/ollama unavailable.
## Subscription content (TDM) ## Subscription content (TDM)
For papers with no open-access copy, the legitimate path is your institution's For papers with no open-access copy, the legitimate path is your institution's
+197 -37
View File
@@ -7,9 +7,12 @@ Commands:
extract [--limit N] [--vlm-fallback] extract full text from vault PDFs extract [--limit N] [--vlm-fallback] extract full text from vault PDFs
summarize [--limit N] [--model M] LLM structured summaries (gemma4) summarize [--limit N] [--model M] LLM structured summaries (gemma4)
embed [--limit N] embed documents for semantic search embed [--limit N] embed documents for semantic search
search QUERY [--k N] semantic search across the vault search QUERY [--k N] [--json] semantic search across the vault
ask QUESTION [--k N] RAG answer from retrieved passages ask QUESTION [--k N] [--json] RAG answer from retrieved passages
insight DOI [--json] structured insight for one document
catalog [--topics|--recent N] [--json] vault catalog slices
youtube [--queue F] [--url U --topic T] ingest YouTube videos as transcripts youtube [--queue F] [--url U --topic T] ingest YouTube videos as transcripts
refresh [--source both] [--no-whisper] incremental: harvest+ingest+process new
""" """
from __future__ import annotations from __future__ import annotations
@@ -189,7 +192,7 @@ def cmd_summarize(args: argparse.Namespace) -> int:
from .extract import iter_documents from .extract import iter_documents
from .ollama_client import OllamaClient from .ollama_client import OllamaClient
from .summarize import append_insight, has_summary, summarize_one from .summarize import append_insight_once, appended_dois, has_summary, summarize_one
config, _ = _load(args.config, args.topics) config, _ = _load(args.config, args.topics)
oc = OllamaClient(text_model=args.model) oc = OllamaClient(text_model=args.model)
@@ -197,6 +200,7 @@ def cmd_summarize(args: argparse.Namespace) -> int:
console.print("[red]Ollama not reachable.[/red]") console.print("[red]Ollama not reachable.[/red]")
return 2 return 2
insights = Path(config.vault.root) / "insights.md" insights = Path(config.vault.root) / "insights.md"
seen_insights = appended_dois(insights) # dedup digest sections across runs
docs = list(iter_documents(Path(config.vault.root))) docs = list(iter_documents(Path(config.vault.root)))
if args.limit: if args.limit:
docs = docs[: args.limit] docs = docs[: args.limit]
@@ -215,7 +219,7 @@ def cmd_summarize(args: argparse.Namespace) -> int:
console.print(f"[red]✗[/red] {doc.name}: {e}") console.print(f"[red]✗[/red] {doc.name}: {e}")
continue continue
if summary is not None: if summary is not None:
append_insight(insights, _json.loads(side.read_text())) append_insight_once(insights, _json.loads(side.read_text()), seen=seen_insights)
done += 1 done += 1
if i % 10 == 0: if i % 10 == 0:
console.print(f" summarize {i}/{total} (done {done}, skip {skipped}, fail {failed})") console.print(f" summarize {i}/{total} (done {done}, skip {skipped}, fail {failed})")
@@ -276,55 +280,126 @@ def cmd_embed(args: argparse.Namespace) -> int:
def cmd_search(args: argparse.Namespace) -> int: def cmd_search(args: argparse.Namespace) -> int:
from .embedstore import EmbedStore, search import json as _json
from .ollama_client import OllamaClient
config, _ = _load(args.config, args.topics) from .insights_api import BackendDown, LibraryContext, VaultUnavailable, search_vault
store = EmbedStore.load(Path(config.vault.root)) from .ollama_client import OllamaError
if store.vectors.size == 0:
console.print("[yellow]No embeddings yet. Run `clawlibrary embed` first.[/yellow]") ctx = LibraryContext.create(args.config)
return 0 try:
oc = OllamaClient() rows = search_vault(ctx, args.query, k=args.k)
if not oc.is_up(): except (VaultUnavailable, BackendDown, OllamaError) as e:
console.print("[red]Ollama not reachable.[/red]") if args.json:
print(_json.dumps({"error": str(e)}))
else:
console.print(f"[red]{e}[/red]")
return 2 return 2
hits = search(store, oc, args.query, k=args.k) if args.json:
print(_json.dumps(rows))
return 0
table = Table(title=f'Search: "{args.query}"', header_style="bold") table = Table(title=f'Search: "{args.query}"', header_style="bold")
table.add_column("Score", justify="right") table.add_column("Score", justify="right")
table.add_column("Title") table.add_column("Title")
table.add_column("Topic", style="dim") table.add_column("Topic", style="dim")
for h in hits: for r in rows:
table.add_row(f"{h.score:.3f}", h.title[:70], h.topic[:30]) table.add_row(f"{r['score']:.3f}", r["title"][:70], r["topic"][:30])
console.print(table) console.print(table)
if args.snippets: if args.snippets:
for h in hits: for r in rows:
console.print(f"\n[bold]{h.title[:80]}[/bold] [dim]{h.doi}[/dim]") console.print(f"\n[bold]{r['title'][:80]}[/bold] [dim]{r['doi']}[/dim]")
console.print(f" {h.snippet}") console.print(f" {r['snippet']}")
return 0
def cmd_insight(args: argparse.Namespace) -> int:
import json as _json
from .insights_api import LibraryContext, get_insight
ctx = LibraryContext.create(args.config)
data = get_insight(ctx, args.doi)
if data is None:
if args.json:
print(_json.dumps({"error": "not found"}))
else:
console.print(f"[yellow]No insight for {args.doi}[/yellow]")
return 1
if args.json:
print(_json.dumps(data))
return 0
s = data.get("summary") or {}
console.print(f"[bold]{data.get('title', '')}[/bold] [dim]{data.get('doi', '')}[/dim]")
console.print(f"[dim]{data.get('topic', '')} · {data.get('source', '')} · "
f"{data.get('year', '')}[/dim]")
if s.get("problem"):
console.print(f"\n[bold]Problem:[/bold] {s['problem']}")
if s.get("method"):
console.print(f"[bold]Method:[/bold] {s['method']}")
if s.get("key_findings"):
console.print("[bold]Key findings:[/bold]")
for f in s["key_findings"]:
console.print(f" - {f}")
if s.get("relevance"):
console.print(f"[bold]Relevance:[/bold] {s['relevance']}")
if s.get("keywords"):
console.print(f"[bold]Keywords:[/bold] {', '.join(s['keywords'])}")
return 0
def cmd_catalog(args: argparse.Namespace) -> int:
import json as _json
from .insights_api import LibraryContext, list_recent, list_topics
ctx = LibraryContext.create(args.config)
rows = list_topics(ctx) if args.topics else list_recent(ctx, n=args.recent)
if args.json:
print(_json.dumps(rows))
return 0
if args.topics:
table = Table(title="Topics", header_style="bold")
table.add_column("Topic")
table.add_column("Chunks", justify="right")
for r in rows:
table.add_row(r["topic"][:50], str(r["chunks"]))
else:
table = Table(title=f"{len(rows)} most recent", header_style="bold")
table.add_column("Title")
table.add_column("Topic", style="dim")
table.add_column("Source")
for r in rows:
table.add_row((r.get("title") or "")[:60], (r.get("topic") or "")[:25],
r.get("source") or "")
console.print(table)
return 0 return 0
def cmd_ask(args: argparse.Namespace) -> int: def cmd_ask(args: argparse.Namespace) -> int:
from .embedstore import EmbedStore import json as _json
from .ollama_client import OllamaClient
from .rag import ask
config, _ = _load(args.config, args.topics) from .insights_api import BackendDown, LibraryContext, VaultUnavailable, ask_library
store = EmbedStore.load(Path(config.vault.root)) from .ollama_client import OllamaError
if store.vectors.size == 0:
console.print("[yellow]No embeddings yet. Run `clawlibrary embed` first.[/yellow]") ctx = LibraryContext.create(args.config)
return 0 if args.model:
oc = OllamaClient(text_model=args.model) ctx.client.text_model = args.model
if not oc.is_up(): try:
console.print("[red]Ollama not reachable.[/red]") result = ask_library(ctx, args.question, k=args.k)
except (VaultUnavailable, BackendDown, OllamaError) as e:
if args.json:
print(_json.dumps({"error": str(e)}))
else:
console.print(f"[red]{e}[/red]")
return 2 return 2
with console.status("Thinking..."): if args.json:
result = ask(store, oc, args.question, k=args.k) print(_json.dumps(result))
return 0
console.print(f"\n[bold]{args.question}[/bold]\n") console.print(f"\n[bold]{args.question}[/bold]\n")
console.print(result.answer) console.print(result["answer"])
if result.sources: if result["sources"]:
console.print("\n[dim]Sources:[/dim]") console.print("\n[dim]Sources:[/dim]")
for s in result.sources: for s in result["sources"]:
console.print(f" [cyan][{s.n}][/cyan] {s.title[:75]} [dim]{s.doi}[/dim]") console.print(f" [cyan][{s['n']}][/cyan] {s['title'][:75]} [dim]{s['doi']}[/dim]")
return 0 return 0
@@ -422,6 +497,65 @@ def cmd_youtube(args: argparse.Namespace) -> int:
return 0 return 0
def _print_refresh_summary(stats, *, dry_run: bool) -> None:
verb = "Would add" if dry_run else "Added"
table = Table(title=f"Refresh {stats.run_id}", header_style="bold")
table.add_column("Stage")
table.add_column(verb, justify="right")
table.add_column("Skipped", justify="right")
table.add_column("Failed", justify="right")
table.add_column("Status")
for s in stats.stages:
status = ("[red]error[/red]" if s.error
else "skipped" if not s.ran else "[green]ok[/green]")
pending = s.detail.get("pending")
added = str(pending) if (dry_run and pending is not None) else str(s.added)
table.add_row(s.name, added, str(s.skipped), str(s.failed), status)
console.print(table)
papers = sum(1 for i in stats.new_items if i.kind == "paper")
videos = sum(1 for i in stats.new_items if i.kind == "video")
if dry_run:
console.print("[dim]Dry run — nothing written.[/dim]")
else:
console.print(
f"[bold]New this run:[/bold] {papers} papers, {videos} videos "
f"[dim]→ vault/digests/{stats.run_id[:8]}.md[/dim]"
)
def cmd_refresh(args: argparse.Namespace) -> int:
from .lock import LockError
from .refresh import refresh
config, topics = _load(args.config, args.topics)
def on_event(kind: str, **data):
if kind == "topic_start":
console.print(f"[bold cyan]→ {data['topic']}[/bold cyan]")
elif kind == "downloaded":
console.print(f" [green]✓[/green] {data.get('title', '')[:80]} "
f"[dim]({data.get('source', '')})[/dim]")
elif kind == "would_download":
console.print(f" [yellow]·[/yellow] {data.get('title', '')[:80]} "
f"[dim]({data.get('source', '')})[/dim]")
elif kind == "stage_error":
console.print(f"[red]Stage {data['stage']} failed:[/red] {data['error']}")
elif kind == "refresh_start":
console.print("[bold]Refreshing all tracked domains…[/bold]")
try:
stats = refresh(
config, topics, source=args.source, skip_youtube=args.skip_youtube,
youtube_queue=args.queue, max_override=args.max, no_whisper=args.no_whisper,
model=args.model, dry_run=args.dry_run, on_event=on_event,
)
except LockError as e:
console.print(f"[red]Refresh already running:[/red] {e}")
return 3
_print_refresh_summary(stats, dry_run=args.dry_run)
return 1 if stats.had_error else 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="clawlibrary", description=__doc__) p = argparse.ArgumentParser(prog="clawlibrary", description=__doc__)
p.add_argument("--config", default="config.yaml", help="path to config.yaml") p.add_argument("--config", default="config.yaml", help="path to config.yaml")
@@ -465,14 +599,27 @@ def build_parser() -> argparse.ArgumentParser:
pq.add_argument("query", help="natural-language query") pq.add_argument("query", help="natural-language query")
pq.add_argument("--k", type=int, default=10, help="number of results") pq.add_argument("--k", type=int, default=10, help="number of results")
pq.add_argument("--snippets", action="store_true", help="show matching snippets") pq.add_argument("--snippets", action="store_true", help="show matching snippets")
pq.add_argument("--json", action="store_true", help="emit JSON to stdout (for agents)")
pq.set_defaults(func=cmd_search) pq.set_defaults(func=cmd_search)
pa = sub.add_parser("ask", help="ask a question, answered from retrieved passages (RAG)") pa = sub.add_parser("ask", help="ask a question, answered from retrieved passages (RAG)")
pa.add_argument("question", help="natural-language question") pa.add_argument("question", help="natural-language question")
pa.add_argument("--k", type=int, default=6, help="passages to retrieve") pa.add_argument("--k", type=int, default=6, help="passages to retrieve")
pa.add_argument("--model", default="gemma4:E4B", help="Ollama model for the answer") pa.add_argument("--model", default="gemma4:E4B", help="Ollama model for the answer")
pa.add_argument("--json", action="store_true", help="emit JSON to stdout (for agents)")
pa.set_defaults(func=cmd_ask) pa.set_defaults(func=cmd_ask)
pin = sub.add_parser("insight", help="structured insight for one DOI / youtube:<id>")
pin.add_argument("doi", help="DOI or youtube:<id> key")
pin.add_argument("--json", action="store_true", help="emit JSON to stdout (for agents)")
pin.set_defaults(func=cmd_insight)
pcat = sub.add_parser("catalog", help="vault catalog: topics or recent items")
pcat.add_argument("--topics", action="store_true", help="list topics + counts (else recent)")
pcat.add_argument("--recent", type=int, default=20, help="number of recent items")
pcat.add_argument("--json", action="store_true", help="emit JSON to stdout (for agents)")
pcat.set_defaults(func=cmd_catalog)
py = sub.add_parser("youtube", help="ingest YouTube videos as transcripts into the vault") py = sub.add_parser("youtube", help="ingest YouTube videos as transcripts into the vault")
py.add_argument("--queue", default="youtube.yaml", help="path to the youtube.yaml queue") py.add_argument("--queue", default="youtube.yaml", help="path to the youtube.yaml queue")
py.add_argument("--url", help="ad-hoc channel/playlist/video URL (requires --topic)") py.add_argument("--url", help="ad-hoc channel/playlist/video URL (requires --topic)")
@@ -488,6 +635,19 @@ def build_parser() -> argparse.ArgumentParser:
py.add_argument("--whisper-compute", default=None, py.add_argument("--whisper-compute", default=None,
help="ctranslate2 compute type (e.g. float16, int8); default by device") help="ctranslate2 compute type (e.g. float16, int8); default by device")
py.set_defaults(func=cmd_youtube) py.set_defaults(func=cmd_youtube)
prf = sub.add_parser("refresh",
help="incremental harvest→ingest→extract→summarize→embed of new content")
prf.add_argument("--source", choices=["openalex", "arxiv", "both"], default="openalex",
help="paper discovery source (scheduler uses 'both')")
prf.add_argument("--skip-youtube", action="store_true", help="papers only, skip video queue")
prf.add_argument("--queue", default="youtube.yaml", help="path to the youtube.yaml queue")
prf.add_argument("--max", type=int, default=None, help="cap new items per topic/source")
prf.add_argument("--no-whisper", action="store_true",
help="captions-only YouTube (no GPU whisper) for cheap scheduled runs")
prf.add_argument("--model", default="gemma4:E4B", help="Ollama model for summaries")
prf.add_argument("--dry-run", action="store_true", help="report would-be additions; no writes")
prf.set_defaults(func=cmd_refresh)
return p return p
+152
View File
@@ -0,0 +1,152 @@
"""Agent-facing query layer over the vault: load-once retrieval + structured insights.
Transport-agnostic. Both the MCP server (`mcp_server.py`) and the CLI `--json`
modes call these functions, so retrieval is defined exactly once — they reuse
`embedstore.search` / `rag.ask`, never reimplement it.
`LibraryContext` loads the (~41k-vector) embedding matrix and an Ollama client
ONCE and reuses them across calls; the MCP server holds a single context for its
whole lifetime, while the CLI builds one per invocation.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from .config import load_config
from .embedstore import EmbedStore, search
from .ollama_client import OllamaClient
from .rag import ask as rag_ask
class VaultUnavailable(RuntimeError):
"""No embeddings on disk yet (run `clawlibrary embed`)."""
class BackendDown(RuntimeError):
"""Ollama is not reachable."""
@dataclass
class LibraryContext:
root: Path
store: EmbedStore
client: OllamaClient
@classmethod
def create(cls, config_path: str = "config.yaml", *, host: str | None = None) -> LibraryContext:
cfg = load_config(config_path)
root = Path(cfg.vault.root)
store = EmbedStore.load(root) # one np.load + jsonl parse, reused thereafter
client = OllamaClient(host=host) if host else OllamaClient()
return cls(root=root, store=store, client=client)
def require_ready(self) -> None:
if self.store.vectors.size == 0:
raise VaultUnavailable("no embeddings yet; run `clawlibrary embed` first")
if not self.client.is_up():
raise BackendDown("ollama not reachable")
# --------------------------------------------------------------------------- #
# Tool implementations — return plain JSON-ready dict/list
# --------------------------------------------------------------------------- #
def search_vault(ctx: LibraryContext, query: str, k: int = 10) -> list[dict]:
ctx.require_ready()
hits = search(ctx.store, ctx.client, query, k=k)
return [
{"score": round(h.score, 4), "title": h.title, "doi": h.doi,
"topic": h.topic, "snippet": h.snippet}
for h in hits
]
def ask_library(ctx: LibraryContext, question: str, k: int = 6) -> dict:
ctx.require_ready()
ans = rag_ask(ctx.store, ctx.client, question, k=k)
return {
"answer": ans.answer,
"sources": [
{"n": s.n, "title": s.title, "doi": s.doi, "topic": s.topic}
for s in ans.sources
],
}
def get_insight(ctx: LibraryContext, doi: str) -> dict | None:
"""Structured per-paper/video insight from its sidecar (NOT index.json, which
carries no summary). Returns None if the DOI isn't in the vault."""
side = _sidecar_for_doi(ctx, doi)
if side is None:
return None
try:
data = json.loads(side.read_text())
except (OSError, json.JSONDecodeError):
return None
return {
"doi": data.get("doi"), "title": data.get("title"), "topic": data.get("topic"),
"year": data.get("year"), "authors": data.get("authors", []),
"source": data.get("source"), "summary": data.get("summary", {}),
"word_count": data.get("word_count"),
}
def list_topics(ctx: LibraryContext) -> list[dict]:
counts: dict[str, int] = {}
for m in ctx.store.meta:
t = m.get("topic", "")
if t:
counts[t] = counts.get(t, 0) + 1
return [{"topic": t, "chunks": n} for t, n in sorted(counts.items(), key=lambda kv: -kv[1])]
def list_recent(ctx: LibraryContext, n: int = 20) -> list[dict]:
entries = _load_index(ctx.root)
entries.sort(key=lambda e: e.get("downloaded_at", ""), reverse=True)
return [
{"doi": e.get("doi"), "title": e.get("title"), "topic": e.get("topic"),
"year": e.get("year"), "source": e.get("source")}
for e in entries[:n]
]
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _sidecar_for_doi(ctx: LibraryContext, doi: str) -> Path | None:
target = doi.lower().strip()
# Fast path: meta rows already in memory carry text_path.
for m in ctx.store.meta:
if (m.get("doi") or "").lower() == target:
side = Path(m["text_path"]).with_suffix(".json")
if side.exists():
return side
# Fallback: doi→sidecar map built by globbing sidecars (cached per root).
return _doi_index(ctx.root).get(target)
@lru_cache(maxsize=4)
def _doi_index(root: Path) -> dict[str, Path]:
out: dict[str, Path] = {}
for side in root.glob("*/*.json"):
try:
d = json.loads(side.read_text())
except (OSError, json.JSONDecodeError):
continue
key = (d.get("doi") or d.get("id") or "").lower()
if key:
out[key] = side
return out
def _load_index(root: Path) -> list[dict]:
p = root / "index.json"
if not p.exists():
return []
try:
return json.loads(p.read_text())
except (OSError, json.JSONDecodeError):
return []
+96
View File
@@ -0,0 +1,96 @@
"""A tiny, dependency-free cross-run lockfile.
Guards the files a `refresh` rewrites (state.json, vault/index.json) against a
second overlapping run — a manual invocation racing the scheduled one, say.
Atomic create via O_EXCL; auto-breaks a lock left by a dead process (same host)
or one older than `stale_after_seconds` (covers a crash on another host).
"""
from __future__ import annotations
import json
import os
import socket
import time
from contextlib import contextmanager
from pathlib import Path
class LockError(RuntimeError):
pass
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True # exists, owned by someone else
return True
def _read_lock(path: Path) -> dict:
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return {}
def _is_stale(info: dict, *, stale_after_seconds: float) -> bool:
started = float(info.get("started_at_epoch", 0) or 0)
if started and (time.time() - started) > stale_after_seconds:
return True
# Same-host liveness check (can't probe a remote pid).
if info.get("host") == socket.gethostname():
pid = int(info.get("pid", 0) or 0)
if pid and not _pid_alive(pid):
return True
return False
@contextmanager
def file_lock(path: str | Path, *, stale_after_seconds: float = 6 * 3600):
"""Hold an exclusive lock at `path` for the duration of the context.
Raises LockError if a live, non-stale lock is already held.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
info = {
"pid": os.getpid(),
"host": socket.gethostname(),
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"started_at_epoch": time.time(),
}
payload = json.dumps(info)
def _acquire() -> int:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
os.write(fd, payload.encode("utf-8"))
return fd
try:
fd = _acquire()
except FileExistsError:
existing = _read_lock(path)
if _is_stale(existing, stale_after_seconds=stale_after_seconds):
try:
path.unlink()
except FileNotFoundError:
pass
try:
fd = _acquire()
except FileExistsError as e: # lost a race to break it
raise LockError(f"lock held at {path}: {existing}") from e
else:
raise LockError(f"lock held at {path}: {existing}") from None
try:
os.close(fd)
yield path
finally:
try:
path.unlink()
except FileNotFoundError:
pass
+94
View File
@@ -0,0 +1,94 @@
"""ClawLibrary MCP server (stdio) — exposes the insight vault to agents.
Tools: search_vault, ask_library, get_insight, list_topics, list_recent.
Resources: clawlibrary://insights (insights.md), clawlibrary://catalog (index.json).
The embedding matrix + Ollama client are loaded ONCE at startup (a single
LibraryContext) and reused for every call. Install with `pip install -e '.[mcp]'`
and register the `clawlibrary-mcp` entry point with your agent (see README).
Env: CLAWLIBRARY_CONFIG (default config.yaml), OLLAMA_HOST (e.g. http://tank:11434).
"""
from __future__ import annotations
import os
from .insights_api import (
BackendDown,
LibraryContext,
VaultUnavailable,
ask_library,
get_insight,
list_recent,
list_topics,
search_vault,
)
from .ollama_client import OllamaError
def _build_server():
from mcp.server.fastmcp import FastMCP # noqa: PLC0415 — optional [mcp] dependency
mcp = FastMCP("clawlibrary")
ctx = LibraryContext.create(
config_path=os.environ.get("CLAWLIBRARY_CONFIG", "config.yaml"),
host=os.environ.get("OLLAMA_HOST"),
)
def _safe(fn, *a):
try:
return fn(ctx, *a)
except (VaultUnavailable, BackendDown, OllamaError) as e:
return {"error": str(e)}
@mcp.tool()
def search_vault_tool(query: str, k: int = 10) -> list[dict] | dict:
"""Semantic search across the research vault (papers + video transcripts).
Returns scored {title, doi, topic, snippet}."""
return _safe(search_vault, query, k)
@mcp.tool()
def ask_library_tool(question: str, k: int = 6) -> dict:
"""Answer a question grounded in retrieved vault passages, with bracketed
citations and a sources list."""
return _safe(ask_library, question, k)
@mcp.tool()
def get_insight_tool(doi: str) -> dict:
"""Structured insight for one paper/video: problem, method, key_findings,
relevance, keywords. Accepts a DOI or a youtube:<id> key."""
r = _safe(get_insight, doi)
return r if r else {"error": f"no insight for doi {doi!r}"}
@mcp.tool()
def list_topics_tool() -> list[dict] | dict:
"""All topics in the vault with their chunk counts."""
return _safe(list_topics)
@mcp.tool()
def list_recent_tool(n: int = 20) -> list[dict] | dict:
"""The most recently ingested papers/videos."""
return _safe(list_recent, n)
@mcp.resource("clawlibrary://insights")
def insights_digest() -> str:
"""Human-readable insight digest (vault/insights.md)."""
path = ctx.root / "insights.md"
return path.read_text() if path.exists() else "(no insights yet)"
@mcp.resource("clawlibrary://catalog")
def catalog() -> str:
"""Full vault catalog (vault/index.json)."""
path = ctx.root / "index.json"
return path.read_text() if path.exists() else "[]"
return mcp
def main() -> None:
_build_server().run()
if __name__ == "__main__":
main()
+401
View File
@@ -0,0 +1,401 @@
"""Incremental refresh: harvest → ingest → extract → summarize → embed.
One command that re-polls every tracked domain (OpenAlex/arXiv topics +
youtube.yaml channels) and folds only the NEW items into the insight layer. The
underlying stages are already idempotent (state.json dedup, already_extracted /
has_summary / store.dois), so this is orchestration: it runs them in order under
a single lockfile, captures what was newly added, and emits a "what's new"
digest an agent or human can review.
Each stage is wrapped so a failure is recorded and the chain continues —
extract/summarize/embed reprocess whatever is on disk, so a harvest hiccup is
not fatal.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from loguru import logger
from . import pipeline
from .config import Config, Topic
from .lock import file_lock
from .pipeline import _setup_logging, generate_run_id
# --------------------------------------------------------------------------- #
# Result types (mirror pipeline.RunStats style)
# --------------------------------------------------------------------------- #
@dataclass
class StageResult:
name: str
ran: bool = True # False when skipped via flag (e.g. --skip-youtube)
added: int = 0
skipped: int = 0
failed: int = 0
error: str | None = None # set if the whole stage raised / was unavailable
detail: dict = field(default_factory=dict)
@dataclass
class NewItem:
kind: str # "paper" | "video"
doi: str
title: str
source: str
topic: str = ""
summary_one_line: str = ""
@dataclass
class RefreshStats:
run_id: str
started_at: str
finished_at: str | None = None
dry_run: bool = False
stages: list[StageResult] = field(default_factory=list)
new_items: list[NewItem] = field(default_factory=list)
def stage(self, name: str) -> StageResult | None:
return next((s for s in self.stages if s.name == name), None)
@property
def had_error(self) -> bool:
return any(s.error for s in self.stages)
def _utcnow_iso() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
# --------------------------------------------------------------------------- #
# Stages — each reuses existing library functions, never reimplements them
# --------------------------------------------------------------------------- #
def stage_papers(
config: Config, topics: list[Topic], *, source: str, max_override: int | None,
dry_run: bool, emit,
) -> tuple[StageResult, list[NewItem]]:
new_items: list[NewItem] = []
def on_ev(kind: str, **data):
if kind in ("downloaded", "would_download"):
new_items.append(NewItem(
kind="paper", doi=(data.get("doi") or ""),
title=data.get("title", ""), source=data.get("source", "") or "",
))
emit(kind, **data)
stats = pipeline.run(
config, topics, dry_run=dry_run, max_override=max_override,
source=source, on_event=on_ev,
)
sr = StageResult(
name="papers",
added=(stats.would_download if dry_run else stats.downloaded),
skipped=stats.skipped_dedup, failed=stats.failed,
detail={"skipped_no_pdf": stats.skipped_no_pdf, "suspended": stats.suspended},
)
return sr, new_items
def stage_youtube(
config: Config, *, queue_path: str, mode: str, max_override: int | None,
dry_run: bool, run_id: str, emit,
) -> tuple[StageResult, list[NewItem]]:
from . import youtube as yt
sr = StageResult(name="youtube")
new_items: list[NewItem] = []
try:
_defaults, sources = yt.load_queue(queue_path)
except (FileNotFoundError, ValueError) as e:
sr.ran = False
sr.error = str(e)
return sr, new_items
root = Path(config.vault.root)
transcribe = yt.make_transcriber(mode=mode)
for s in sources:
cap = max_override or s.max_videos
try:
videos = yt.list_source_videos(s.url, max_videos=cap)
except Exception as e: # noqa: BLE001 — one source must not abort the stage
sr.failed += 1
emit("yt_list_failed", url=s.url, error=str(e))
continue
for v in videos:
try:
res = yt.ingest_video(
v["url"], s.topic, root, run_id=run_id,
transcribe_fn=transcribe, dry_run=dry_run,
)
except Exception as e: # noqa: BLE001
sr.failed += 1
emit("failed", title=v.get("title", v.get("id", "")), reason=str(e))
continue
st = res["status"]
if st in ("ingested", "planned"):
sr.added += 1
if st == "ingested":
new_items.append(NewItem(
kind="video", doi=f"youtube:{res['video_id']}",
title=res.get("title", ""), source="youtube", topic=s.topic,
))
emit("downloaded" if st == "ingested" else "would_download",
title=res.get("title", v.get("title", "")), source="youtube")
elif st == "dedup":
sr.skipped += 1
else:
sr.detail[st] = sr.detail.get(st, 0) + 1
return sr, new_items
def stage_extract(config: Config, *, dry_run: bool, emit) -> StageResult:
from .extract import already_extracted, extract_one, iter_pdfs
root = Path(config.vault.root)
pending = [p for p in iter_pdfs(root) if not already_extracted(p)]
sr = StageResult(name="extract")
if dry_run:
sr.detail["pending"] = len(pending)
return sr
for pdf in pending:
try:
extract_one(pdf)
sr.added += 1
except Exception as e: # noqa: BLE001
sr.failed += 1
emit("extract_failed", file=pdf.name, error=str(e))
return sr
def stage_summarize(config: Config, *, model: str, dry_run: bool, emit) -> StageResult:
from .extract import iter_documents
from .ollama_client import OllamaClient
from .summarize import append_insight_once, appended_dois, has_summary, summarize_one
root = Path(config.vault.root)
pending = [d for d in iter_documents(root) if not has_summary(d.with_suffix(".json"))]
sr = StageResult(name="summarize")
if dry_run:
sr.detail["pending"] = len(pending)
return sr
oc = OllamaClient(text_model=model)
if not oc.is_up():
sr.error = "ollama not reachable"
return sr
insights = root / "insights.md"
seen = appended_dois(insights)
for doc in pending:
try:
summary = summarize_one(doc, oc)
except Exception as e: # noqa: BLE001 — one doc must not abort the batch
sr.failed += 1
emit("summarize_failed", file=doc.name, error=str(e))
continue
if summary is not None:
append_insight_once(insights, json.loads(doc.with_suffix(".json").read_text()),
seen=seen)
sr.added += 1
return sr
def stage_embed(config: Config, *, dry_run: bool, emit) -> StageResult:
from .embedstore import EmbedStore, add_document, merge
from .extract import iter_documents
from .ollama_client import OllamaClient
root = Path(config.vault.root)
store = EmbedStore.load(root)
seen = store.dois
pending: list[tuple[Path, dict, str]] = []
for d in iter_documents(root):
data = json.loads(d.with_suffix(".json").read_text())
doi = data.get("doi") or data.get("id") or d.stem
if doi not in seen:
pending.append((d, data, doi))
sr = StageResult(name="embed")
if dry_run:
sr.detail["pending"] = len(pending)
return sr
oc = OllamaClient()
if not oc.is_up():
sr.error = "ollama not reachable"
return sr
for d, data, doi in pending:
try:
vecs, rows = add_document(
oc, text=d.read_text(), doi=doi, title=data.get("title", ""),
topic=data.get("topic", ""), text_path=str(d),
)
except Exception as e: # noqa: BLE001
sr.failed += 1
emit("embed_failed", file=d.name, error=str(e))
continue
store = merge(store, vecs, rows)
seen.add(doi)
sr.added += 1
if sr.added % 20 == 0:
store.save(root) # periodic checkpoint
store.save(root)
return sr
# --------------------------------------------------------------------------- #
# Digest writers — the "what's new" feed
# --------------------------------------------------------------------------- #
def write_refresh_log(vault_root: Path, stats: RefreshStats) -> Path:
"""Append one JSON line to vault/refresh-log.jsonl (the agent-readable stream)."""
vault_root.mkdir(parents=True, exist_ok=True)
path = vault_root / "refresh-log.jsonl"
rec = {
"run_id": stats.run_id,
"started_at": stats.started_at,
"finished_at": stats.finished_at,
"stages": [asdict(s) for s in stats.stages],
"new_items": [asdict(i) for i in stats.new_items],
}
with open(path, "a") as f:
f.write(json.dumps(rec) + "\n")
return path
def write_digest_markdown(vault_root: Path, stats: RefreshStats, *, date: str) -> Path:
"""Write/append vault/digests/{date}.md — a skimmable page of what was added."""
ddir = vault_root / "digests"
ddir.mkdir(parents=True, exist_ok=True)
path = ddir / f"{date}.md"
papers = [i for i in stats.new_items if i.kind == "paper"]
videos = [i for i in stats.new_items if i.kind == "video"]
lines = [
f"## Refresh {stats.run_id} — {stats.finished_at}",
"",
f"**New:** {len(papers)} papers, {len(videos)} videos",
"",
]
for it in stats.new_items:
topic = f" · {it.topic}" if it.topic else ""
lines.append(f"- **[{it.kind}]** {it.title}{topic} · `{it.doi}`")
if it.summary_one_line:
lines.append(f" - {it.summary_one_line}")
if not stats.new_items:
lines.append("- _(nothing new this run)_")
lines.append("")
with open(path, "a") as f:
f.write("\n".join(lines) + "\n")
return path
def _enrich_new_items(vault_root: Path, items: list[NewItem]) -> None:
"""Backfill topic + one-line summary on new items from their sidecars."""
if not items:
return
index: dict[str, dict] = {}
for side in vault_root.glob("*/*.json"):
try:
data = json.loads(side.read_text())
except (OSError, json.JSONDecodeError):
continue
key = (data.get("doi") or data.get("id") or "").lower()
if key:
index[key] = data
for it in items:
data = index.get(it.doi.lower())
if not data:
continue
if not it.topic:
it.topic = data.get("topic", "")
summ = data.get("summary") or {}
it.summary_one_line = (summ.get("problem") or summ.get("relevance") or "")[:300]
# --------------------------------------------------------------------------- #
# Orchestrator
# --------------------------------------------------------------------------- #
def refresh(
config: Config,
topics: list[Topic],
*,
source: str = "openalex",
skip_youtube: bool = False,
youtube_queue: str = "youtube.yaml",
max_override: int | None = None,
no_whisper: bool = False,
model: str = "gemma4:E4B",
dry_run: bool = False,
on_event=None,
) -> RefreshStats:
"""Run the full incremental chain under a lockfile. Raises LockError if a
refresh is already running. Returns a RefreshStats with per-stage results and
the list of newly added items."""
run_id = generate_run_id()
stats = RefreshStats(run_id=run_id, started_at=_utcnow_iso(), dry_run=dry_run)
root = Path(config.vault.root)
lock_path = Path(f"{config.state.state_file}.lock")
sink_id = _setup_logging(config, run_id)
def emit(kind: str, **data):
logger.bind(run_id=run_id, **data).info(kind)
if on_event:
on_event(kind, **data)
try:
with file_lock(lock_path):
emit("refresh_start", source=source, dry_run=dry_run,
skip_youtube=skip_youtube, no_whisper=no_whisper)
# 1) papers
try:
sr, items = stage_papers(
config, topics, source=source, max_override=max_override,
dry_run=dry_run, emit=emit,
)
except Exception as e: # noqa: BLE001
sr, items = StageResult(name="papers", error=str(e)), []
emit("stage_error", stage="papers", error=str(e))
stats.stages.append(sr)
stats.new_items += items
# 2) youtube
if skip_youtube:
stats.stages.append(StageResult(name="youtube", ran=False))
else:
mode = "captions" if no_whisper else "auto"
try:
sr, items = stage_youtube(
config, queue_path=youtube_queue, mode=mode,
max_override=max_override, dry_run=dry_run, run_id=run_id, emit=emit,
)
except Exception as e: # noqa: BLE001
sr, items = StageResult(name="youtube", error=str(e)), []
emit("stage_error", stage="youtube", error=str(e))
stats.stages.append(sr)
stats.new_items += items
# 3) extract → 4) summarize → 5) embed
for name, thunk in (
("extract", lambda: stage_extract(config, dry_run=dry_run, emit=emit)),
("summarize", lambda: stage_summarize(
config, model=model, dry_run=dry_run, emit=emit)),
("embed", lambda: stage_embed(config, dry_run=dry_run, emit=emit)),
):
try:
stats.stages.append(thunk())
except Exception as e: # noqa: BLE001
stats.stages.append(StageResult(name=name, error=str(e)))
emit("stage_error", stage=name, error=str(e))
stats.finished_at = _utcnow_iso()
_enrich_new_items(root, stats.new_items)
if not dry_run:
write_refresh_log(root, stats)
write_digest_markdown(root, stats, date=run_id[:8])
emit("refresh_end", new_items=len(stats.new_items),
stages={s.name: s.added for s in stats.stages})
finally:
logger.remove(sink_id)
return stats
+52 -1
View File
@@ -94,7 +94,7 @@ def summarize_one(pdf_path: Path, client: OllamaClient) -> dict | None:
return summary return summary
def append_insight(insights_path: Path, sidecar_data: dict) -> None: def _insight_lines(sidecar_data: dict) -> list[str]:
s = sidecar_data.get("summary", {}) s = sidecar_data.get("summary", {})
title = sidecar_data.get("title", "(untitled)") title = sidecar_data.get("title", "(untitled)")
doi = sidecar_data.get("doi", "") doi = sidecar_data.get("doi", "")
@@ -114,5 +114,56 @@ def append_insight(insights_path: Path, sidecar_data: dict) -> None:
if s.get("keywords"): if s.get("keywords"):
lines.append(f"- **Keywords:** {', '.join(s['keywords'])}") lines.append(f"- **Keywords:** {', '.join(s['keywords'])}")
lines.append("") lines.append("")
return lines
def append_insight(insights_path: Path, sidecar_data: dict) -> None:
with open(insights_path, "a") as f:
f.write("\n".join(_insight_lines(sidecar_data)) + "\n")
_MARKER_PREFIX = "<!-- doi:"
_MARKER_SUFFIX = "-->"
def _insight_key(sidecar_data: dict) -> str:
"""Stable identity for a document in the digest (matches the embed convention)."""
key = sidecar_data.get("doi") or sidecar_data.get("id") or sidecar_data.get("title", "")
return key.lower().strip()
def appended_dois(insights_path: Path) -> set[str]:
"""The set of document keys already present in insights.md (via section markers)."""
if not insights_path.exists():
return set()
out: set[str] = set()
for line in insights_path.read_text().splitlines():
line = line.strip()
if line.startswith(_MARKER_PREFIX) and line.endswith(_MARKER_SUFFIX):
out.add(line[len(_MARKER_PREFIX) : -len(_MARKER_SUFFIX)].strip())
return out
def append_insight_once(
insights_path: Path, sidecar_data: dict, *, seen: set[str] | None = None
) -> bool:
"""Append a digest section only if this document isn't already in the file.
Writes a hidden ``<!-- doi: {key} -->`` marker so future runs detect it.
`seen` is an optional in-memory set the caller maintains across a batch (it is
updated in place); when omitted it is seeded from the file once. Returns True
if a section was written, False if skipped as a duplicate.
"""
key = _insight_key(sidecar_data)
if not key: # no identity — fall back to unconditional append
append_insight(insights_path, sidecar_data)
return True
if seen is None:
seen = appended_dois(insights_path)
if key in seen:
return False
lines = [f"{_MARKER_PREFIX} {key} {_MARKER_SUFFIX}", *_insight_lines(sidecar_data)]
with open(insights_path, "a") as f: with open(insights_path, "a") as f:
f.write("\n".join(lines) + "\n") f.write("\n".join(lines) + "\n")
seen.add(key)
return True
+6
View File
@@ -34,9 +34,15 @@ youtube = [
"yt-dlp>=2024.1", "yt-dlp>=2024.1",
"faster-whisper>=1.0", "faster-whisper>=1.0",
] ]
# MCP server: exposes the insight vault to agents over stdio. Install on the
# serving node (tank) to enable `clawlibrary-mcp`.
mcp = [
"mcp>=1.2",
]
[project.scripts] [project.scripts]
clawlibrary = "clawlibrary.__main__:main" clawlibrary = "clawlibrary.__main__:main"
clawlibrary-mcp = "clawlibrary.mcp_server:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["clawlibrary"] packages = ["clawlibrary"]
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Scheduled incremental refresh for ClawLibrary (run by cron).
# Harvests new papers (OpenAlex + arXiv) and new videos (youtube.yaml, Whisper
# fallback ON), then extracts/summarizes/embeds only the new items. Safe to run
# concurrently — the app holds a lockfile (state.json.lock).
#
# Install (on the GPU node `tank`):
# crontab -e
# 30 3 * * * /home/osobh/clawlibrary/scripts/refresh.sh
set -euo pipefail
CLAW_HOME="${CLAW_HOME:-$HOME/clawlibrary}"
cd "$CLAW_HOME"
# shellcheck disable=SC1091
source .venv/bin/activate
mkdir -p logs
LOG="logs/refresh-$(date +%F).log"
{
echo "===== refresh $(date -u +%FT%TZ) ====="
python -m clawlibrary refresh --source both
rc=$?
echo "----- refresh exit: ${rc} -----"
# Pick up the freshly-written embeddings in any running MCP server so agents
# query current data (no-op if the server isn't running under systemd --user).
systemctl --user restart clawlibrary-mcp 2>/dev/null || true
exit "${rc}"
} >> "${LOG}" 2>&1
+76
View File
@@ -82,3 +82,79 @@ def test_cli_export_csv_and_json(project, tmp_path):
def test_cli_missing_config_returns_2(tmp_path, monkeypatch): def test_cli_missing_config_returns_2(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
assert cli.main(["stats"]) == 2 assert cli.main(["stats"]) == 2
def test_cli_refresh_dispatches(project, monkeypatch):
import clawlibrary.refresh as R
def fake_refresh(config, topics, **kwargs):
assert kwargs["dry_run"] is True
return R.RefreshStats(run_id="20260604T000000-abc", started_at="t")
monkeypatch.setattr(R, "refresh", fake_refresh)
assert cli.main(["refresh", "--dry-run"]) == 0
def test_cli_refresh_lock_returns_3(project, monkeypatch):
import clawlibrary.lock as L
import clawlibrary.refresh as R
def boom(*a, **k):
raise L.LockError("held")
monkeypatch.setattr(R, "refresh", boom)
assert cli.main(["refresh"]) == 3
# ---- JSON CLI surface (agent-facing) ---------------------------------------
import types # noqa: E402
def _fake_ctx():
return types.SimpleNamespace(
client=types.SimpleNamespace(text_model="gemma4:E4B"), root="vault",
)
def _patch_api(monkeypatch, **fns):
import clawlibrary.insights_api as API
monkeypatch.setattr(API.LibraryContext, "create", lambda *a, **k: _fake_ctx())
for name, fn in fns.items():
monkeypatch.setattr(API, name, fn)
def test_cli_search_json(monkeypatch, capsys):
_patch_api(monkeypatch, search_vault=lambda ctx, q, k=10: [
{"score": 0.9, "title": "T", "doi": "10.1/x", "topic": "t", "snippet": "s"}])
assert cli.main(["search", "rust", "--json"]) == 0
out = json.loads(capsys.readouterr().out)
assert out[0]["doi"] == "10.1/x"
def test_cli_ask_json(monkeypatch, capsys):
_patch_api(monkeypatch, ask_library=lambda ctx, q, k=6: {
"answer": "yes [1]", "sources": [{"n": 1, "title": "T", "doi": "10.1/x", "topic": "t"}]})
assert cli.main(["ask", "is rust safe?", "--json"]) == 0
out = json.loads(capsys.readouterr().out)
assert out["answer"] == "yes [1]" and out["sources"][0]["n"] == 1
def test_cli_insight_json(monkeypatch, capsys):
_patch_api(monkeypatch, get_insight=lambda ctx, doi: {
"doi": doi, "title": "T", "summary": {"problem": "P"}})
assert cli.main(["insight", "10.1/x", "--json"]) == 0
out = json.loads(capsys.readouterr().out)
assert out["summary"]["problem"] == "P"
def test_cli_search_backend_down(monkeypatch, capsys):
from clawlibrary.insights_api import BackendDown
def boom(ctx, q, k=10):
raise BackendDown("ollama not reachable")
_patch_api(monkeypatch, search_vault=boom)
assert cli.main(["search", "rust", "--json"]) == 2
out = json.loads(capsys.readouterr().out)
assert "error" in out
+104
View File
@@ -0,0 +1,104 @@
import json
import numpy as np
import pytest
from clawlibrary.embedstore import EmbedStore
from clawlibrary.insights_api import (
BackendDown,
LibraryContext,
VaultUnavailable,
ask_library,
get_insight,
list_recent,
list_topics,
search_vault,
)
class FakeClient:
def __init__(self, up=True):
self.up = up
self.text_model = "gemma4:E4B"
def is_up(self):
return self.up
def embed_query(self, text):
return [1.0, 0.0] # aligns with the first stored vector
def generate(self, prompt, **kw):
return '{"answer": "Rust is memory safe [1]."}'
def _ctx(tmp_path, *, up=True, empty=False):
root = tmp_path / "vault"
(root / "t").mkdir(parents=True)
side = root / "t" / "p.json"
side.write_text(json.dumps({
"doi": "10.1/a", "title": "A", "topic": "rust", "year": 2024, "source": "arxiv",
"authors": ["Z"], "word_count": 3,
"summary": {"problem": "P", "method": "M", "key_findings": ["k"],
"relevance": "R", "keywords": ["x"]},
}))
txt = root / "t" / "p.txt"
txt.write_text("rust memory safety")
# index.json for list_recent
(root / "index.json").write_text(json.dumps([
{"doi": "10.1/a", "title": "A", "topic": "rust", "year": 2024,
"source": "arxiv", "downloaded_at": "2026-06-01T00:00:00Z"},
]))
if empty:
store = EmbedStore.empty()
else:
vectors = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32)
meta = [
{"doi": "10.1/a", "title": "A", "topic": "rust", "chunk": 0,
"snippet": "rust memory", "text_path": str(txt)},
{"doi": "10.1/b", "title": "B", "topic": "quantum", "chunk": 0,
"snippet": "qubit", "text_path": str(root / "t" / "missing.txt")},
]
store = EmbedStore(vectors=vectors, meta=meta)
return LibraryContext(root=root, store=store, client=FakeClient(up=up))
def test_search_vault_shape(tmp_path):
ctx = _ctx(tmp_path)
rows = search_vault(ctx, "rust memory", k=2)
assert rows and rows[0]["doi"] == "10.1/a"
assert set(rows[0]) == {"score", "title", "doi", "topic", "snippet"}
def test_ask_library_shape(tmp_path):
ctx = _ctx(tmp_path)
res = ask_library(ctx, "Is rust safe?", k=2)
assert "[1]" in res["answer"]
assert res["sources"] and set(res["sources"][0]) == {"n", "title", "doi", "topic"}
def test_get_insight(tmp_path):
ctx = _ctx(tmp_path)
data = get_insight(ctx, "10.1/a")
assert data["summary"]["problem"] == "P"
assert data["title"] == "A"
assert get_insight(ctx, "10.1/nope") is None
def test_list_topics_and_recent(tmp_path):
ctx = _ctx(tmp_path)
topics = list_topics(ctx)
assert {t["topic"] for t in topics} == {"rust", "quantum"}
recent = list_recent(ctx, n=5)
assert recent[0]["doi"] == "10.1/a"
def test_vault_unavailable(tmp_path):
ctx = _ctx(tmp_path, empty=True)
with pytest.raises(VaultUnavailable):
search_vault(ctx, "anything")
def test_backend_down(tmp_path):
ctx = _ctx(tmp_path, up=False)
with pytest.raises(BackendDown):
ask_library(ctx, "anything")
+32
View File
@@ -0,0 +1,32 @@
import json
import pytest
from clawlibrary.lock import LockError, file_lock
def test_file_lock_basic(tmp_path):
p = tmp_path / "state.json.lock"
with file_lock(p):
assert p.exists()
info = json.loads(p.read_text())
assert "pid" in info and "host" in info
assert not p.exists() # released on exit
def test_file_lock_contended_raises(tmp_path):
p = tmp_path / "state.json.lock"
with file_lock(p):
with pytest.raises(LockError):
with file_lock(p):
pass
def test_file_lock_breaks_stale(tmp_path):
p = tmp_path / "state.json.lock"
# A lock from 1970 on another host => stale, should be broken and re-acquired.
p.write_text(json.dumps({"pid": 1, "host": "otherhost", "started_at_epoch": 1.0}))
with file_lock(p, stale_after_seconds=1):
assert p.exists()
assert json.loads(p.read_text())["host"] != "otherhost"
assert not p.exists()
+28
View File
@@ -0,0 +1,28 @@
"""Smoke test for the MCP server wiring.
Skipped where the optional `mcp` package isn't installed (e.g. the dev box);
runs on the serving node where `pip install -e '.[mcp]'` has been done.
"""
import pytest
from clawlibrary.embedstore import EmbedStore
def test_build_server_smoke(tmp_path, monkeypatch):
pytest.importorskip("mcp")
import clawlibrary.mcp_server as M
from clawlibrary.insights_api import LibraryContext
class _Client:
text_model = "gemma4:E4B"
def is_up(self):
return True
fake = LibraryContext(root=tmp_path, store=EmbedStore.empty(), client=_Client())
monkeypatch.setattr(M.LibraryContext, "create", lambda *a, **k: fake)
server = M._build_server()
assert server is not None
assert hasattr(server, "run")
+114
View File
@@ -0,0 +1,114 @@
import pytest
import clawlibrary.lock as L
import clawlibrary.refresh as R
from clawlibrary.config import Config, LoggingConfig, StateConfig, Topic, VaultConfig
def _cfg(tmp_path) -> tuple[Config, list[Topic]]:
config = Config(
vault=VaultConfig(root=tmp_path / "vault"),
state=StateConfig(state_file=tmp_path / "state.json"),
logging=LoggingConfig(log_dir=tmp_path / "logs"),
)
return config, [Topic(query="rust")]
def _patch_stages(monkeypatch, calls, *, extras=None):
"""Replace the five stage fns with fakes that record call order."""
extras = {} if extras is None else extras
def papers(config, topics, *, source, max_override, dry_run, emit):
calls.append("papers")
return (R.StageResult(name="papers", added=2),
[R.NewItem(kind="paper", doi="10.1/x", title="X", source="arxiv")])
def youtube(config, *, queue_path, mode, max_override, dry_run, run_id, emit):
calls.append("youtube")
extras["yt_mode"] = mode
return (R.StageResult(name="youtube", added=1),
[R.NewItem(kind="video", doi="youtube:abc", title="V", source="youtube")])
def extract(config, *, dry_run, emit):
calls.append("extract")
return R.StageResult(name="extract", added=2)
def summarize(config, *, model, dry_run, emit):
calls.append("summarize")
return R.StageResult(name="summarize", added=3)
def embed(config, *, dry_run, emit):
calls.append("embed")
return R.StageResult(name="embed", added=3)
monkeypatch.setattr(R, "stage_papers", extras.get("papers", papers))
monkeypatch.setattr(R, "stage_youtube", extras.get("youtube", youtube))
monkeypatch.setattr(R, "stage_extract", extras.get("extract", extract))
monkeypatch.setattr(R, "stage_summarize", extras.get("summarize", summarize))
monkeypatch.setattr(R, "stage_embed", extras.get("embed", embed))
def test_refresh_chains_stages_in_order(tmp_path, monkeypatch):
config, topics = _cfg(tmp_path)
calls: list[str] = []
_patch_stages(monkeypatch, calls)
stats = R.refresh(config, topics, dry_run=False)
assert calls == ["papers", "youtube", "extract", "summarize", "embed"]
assert [s.name for s in stats.stages] == ["papers", "youtube", "extract", "summarize", "embed"]
assert {i.kind for i in stats.new_items} == {"paper", "video"}
# digest + event stream written when not a dry run
assert (tmp_path / "vault" / "refresh-log.jsonl").exists()
assert list((tmp_path / "vault" / "digests").glob("*.md"))
assert not stats.had_error
def test_refresh_skip_youtube(tmp_path, monkeypatch):
config, topics = _cfg(tmp_path)
calls: list[str] = []
_patch_stages(monkeypatch, calls)
stats = R.refresh(config, topics, skip_youtube=True, dry_run=True)
assert "youtube" not in calls
yt = stats.stage("youtube")
assert yt is not None and yt.ran is False
def test_refresh_no_whisper_forces_captions(tmp_path, monkeypatch):
config, topics = _cfg(tmp_path)
calls: list[str] = []
extras: dict = {}
_patch_stages(monkeypatch, calls, extras=extras)
R.refresh(config, topics, no_whisper=True, dry_run=True)
assert extras["yt_mode"] == "captions"
def test_refresh_dry_run_writes_no_files(tmp_path, monkeypatch):
config, topics = _cfg(tmp_path)
_patch_stages(monkeypatch, [])
R.refresh(config, topics, dry_run=True)
assert not (tmp_path / "vault" / "refresh-log.jsonl").exists()
assert not (tmp_path / "vault" / "digests").exists()
def test_refresh_stage_failure_continues(tmp_path, monkeypatch):
config, topics = _cfg(tmp_path)
calls: list[str] = []
def boom(config, *, dry_run, emit):
raise RuntimeError("disk full")
_patch_stages(monkeypatch, calls, extras={"extract": boom})
stats = R.refresh(config, topics, dry_run=True)
ex = stats.stage("extract")
assert ex is not None and ex.error and "disk full" in ex.error
# chain still reached the later stages
assert "summarize" in calls and "embed" in calls
assert stats.had_error
def test_refresh_lock_contention(tmp_path, monkeypatch):
config, topics = _cfg(tmp_path)
_patch_stages(monkeypatch, [])
lock_path = f"{config.state.state_file}.lock"
with L.file_lock(lock_path):
with pytest.raises(L.LockError):
R.refresh(config, topics, dry_run=True)
+37
View File
@@ -0,0 +1,37 @@
from clawlibrary.summarize import append_insight_once, appended_dois
def _sidecar(doi="10.1/x"):
return {
"doi": doi, "title": "T", "topic": "t",
"summary": {"problem": "P", "method": "M", "key_findings": ["a"],
"relevance": "R", "keywords": ["k"]},
}
def test_append_insight_once_dedups(tmp_path):
p = tmp_path / "insights.md"
assert append_insight_once(p, _sidecar()) is True
assert append_insight_once(p, _sidecar()) is False # second time: skipped
text = p.read_text()
assert text.count("<!-- doi: 10.1/x -->") == 1
assert text.count("## T") == 1
def test_appended_dois_parses_markers(tmp_path):
p = tmp_path / "insights.md"
append_insight_once(p, _sidecar("10.1/a"))
append_insight_once(p, _sidecar("10.1/b"))
assert appended_dois(p) == {"10.1/a", "10.1/b"}
def test_append_insight_once_uses_seen_set(tmp_path):
p = tmp_path / "insights.md"
seen: set[str] = set()
assert append_insight_once(p, _sidecar("10.1/x"), seen=seen) is True
assert "10.1/x" in seen
assert append_insight_once(p, _sidecar("10.1/x"), seen=seen) is False
def test_appended_dois_missing_file(tmp_path):
assert appended_dois(tmp_path / "nope.md") == set()