Spaces:
Running
feat(collections): curated reading tracks that seed the recommender
Browse filesCold start asks a new user to pick arXiv categories, but cs.LG is ~302k papers
-- far too coarse to retrieve against, and the resulting Tier 0 feed was a
citation sort. A collection is a much sharper signal: following one replays its
anchors through the ordinary save path, which puts the user straight past the
>=5-save threshold into Tier 1 multi-interest clustering and skips the weakest
part of the pipeline entirely.
Semantic Scholar's Research Feeds work exactly this way -- the feed is seeded
from a curated Library folder, with a documented minimum of "5 relevant papers
... and 3 non-relevant". Every track here carries at least 6 anchors, so one
click clears that budget.
Eight tracks, 66 anchors, each with a curator note: LLM inference, RAG,
agents, fine-tuning/alignment, diffusion, embeddings, scaling, evaluation.
All 66 verified present in the production vector store before writing a line of
UI -- an anchor with metadata but no embedding would render perfectly and
silently fail to seed anything.
Curated content lives in data/collections/*.json, NOT the database: DB_PATH is
/tmp, HF withdrew persistent storage, and anything hand-authored into SQLite
evaporates on rebuild. Keeping anchors in git also makes a curation edit
reviewable in a diff. Who follows what is user data and goes in
collection_follows, replicated by turso_sync.
JSON rather than YAML deliberately -- pyyaml is only present as a transitive
dependency of transformers, so a YAML loader would import in production and
fail in CI, where the test job installs the app deps minus the ML stack.
Three deliberate design decisions:
- The detail page keeps curated anchors and medoid-matched recent papers in
separately headed sections. Merging them would quietly pass off vector
search as editorial judgement.
- Unfollow does not unsave or rewind the profile. An EWMA is a lossy running
average with no exact inverse, so "undoing" a follow would leave a
permanent smudge while claiming to restore the previous state.
- follow() counts saves and profile updates separately and logs when they
diverge. A save without a resolvable vector is a library entry that teaches
the recommender nothing; that used to pass silently, which is how a
partially configured vector store looks like a working feature.
Reuses paper_card.html so save/pass and all Phase 4.5/6.5 instrumentation keep
working unchanged; seeded rows carry candidate_source=collection:<slug> and
policy_id=collection_seed, so the seeding path is separable in analysis.
The template-selector guard caught the follow button using an interpolated #id
selector -- the same class of bug as 6fca272. Uses hx-target="closest
.follow-wrap" instead, which needs no id at all.
283 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- app/collections_svc.py +158 -0
- app/db.py +50 -0
- app/main.py +3 -1
- app/routers/collections.py +255 -0
- app/static/styles.css +103 -0
- app/templates/base.html +12 -0
- app/templates/collection_detail.html +75 -0
- app/templates/collections.html +71 -0
- app/templates/partials/follow_button.html +42 -0
- tests/test_collections.py +241 -0
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Curated reading collections.
|
| 3 |
+
|
| 4 |
+
Curated content lives in the REPO (data/collections/*.json), not the database.
|
| 5 |
+
DB_PATH is /tmp on an ephemeral filesystem and Hugging Face has withdrawn
|
| 6 |
+
persistent storage, so anything hand-authored into SQLite evaporates on the
|
| 7 |
+
next rebuild. Keeping the anchors in git also makes an edit reviewable in a
|
| 8 |
+
diff, which is what curation actually needs.
|
| 9 |
+
|
| 10 |
+
Who follows what IS user data and lives in db.collection_follows, replicated
|
| 11 |
+
by turso_sync like everything else.
|
| 12 |
+
|
| 13 |
+
JSON rather than YAML deliberately: pyyaml is only present as a transitive
|
| 14 |
+
dependency of transformers, so a YAML loader would import fine in production
|
| 15 |
+
and fail in CI, where the test job installs the app dependencies minus the ML
|
| 16 |
+
stack. Nothing here should need a dependency the tests do not have.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
from functools import lru_cache
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
COLLECTIONS_DIR = os.getenv(
|
| 27 |
+
"COLLECTIONS_DIR",
|
| 28 |
+
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 29 |
+
"data", "collections"),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# How many medoid-matched recent papers to show under the curated anchors.
|
| 33 |
+
EXTEND_LIMIT = int(os.getenv("COLLECTION_EXTEND_LIMIT", "12"))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@lru_cache(maxsize=1)
|
| 37 |
+
def load_all() -> list[dict]:
|
| 38 |
+
"""Every collection, ordered by title. Cached; call cache_clear() in tests.
|
| 39 |
+
|
| 40 |
+
A malformed file is skipped rather than fatal -- one bad edit should not
|
| 41 |
+
take down the index page for the other seven.
|
| 42 |
+
"""
|
| 43 |
+
out: list[dict] = []
|
| 44 |
+
if not os.path.isdir(COLLECTIONS_DIR):
|
| 45 |
+
print(f"[collections] no directory at {COLLECTIONS_DIR}")
|
| 46 |
+
return out
|
| 47 |
+
|
| 48 |
+
for name in sorted(os.listdir(COLLECTIONS_DIR)):
|
| 49 |
+
if not name.endswith(".json"):
|
| 50 |
+
continue
|
| 51 |
+
path = os.path.join(COLLECTIONS_DIR, name)
|
| 52 |
+
try:
|
| 53 |
+
with open(path, encoding="utf-8") as fh:
|
| 54 |
+
data = json.load(fh)
|
| 55 |
+
slug = str(data["slug"]).strip()
|
| 56 |
+
anchors = [
|
| 57 |
+
{"id": str(a["id"]).strip(), "note": str(a.get("note", "")).strip()}
|
| 58 |
+
for a in data["anchors"] if a.get("id")
|
| 59 |
+
]
|
| 60 |
+
if not slug or not anchors:
|
| 61 |
+
raise ValueError("slug and at least one anchor are required")
|
| 62 |
+
out.append({
|
| 63 |
+
"slug": slug,
|
| 64 |
+
"title": str(data.get("title") or slug),
|
| 65 |
+
"blurb": str(data.get("blurb", "")),
|
| 66 |
+
"anchors": anchors,
|
| 67 |
+
"count": len(anchors),
|
| 68 |
+
})
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"[collections] skipping {name}: {type(e).__name__}: {e}")
|
| 71 |
+
|
| 72 |
+
out.sort(key=lambda c: c["title"])
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get(slug: str) -> dict | None:
|
| 77 |
+
slug = (slug or "").strip()
|
| 78 |
+
return next((c for c in load_all() if c["slug"] == slug), None)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def anchor_ids(slug: str) -> list[str]:
|
| 82 |
+
c = get(slug)
|
| 83 |
+
return [a["id"] for a in c["anchors"]] if c else []
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def all_anchor_ids() -> set[str]:
|
| 87 |
+
return {a["id"] for c in load_all() for a in c["anchors"]}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def medoid(slug: str) -> np.ndarray | None:
|
| 91 |
+
"""The centre of a collection, as the medoid of its anchor vectors.
|
| 92 |
+
|
| 93 |
+
Medoid rather than centroid, for the same reason clustering.py uses one:
|
| 94 |
+
it is an actual paper in the set, so the "recent work in this area" list is
|
| 95 |
+
anchored to something real rather than to a point in space that may sit
|
| 96 |
+
between two unrelated sub-topics.
|
| 97 |
+
|
| 98 |
+
Returns None when too few anchors resolve to vectors -- callers then simply
|
| 99 |
+
omit the extension section rather than showing something arbitrary.
|
| 100 |
+
"""
|
| 101 |
+
from app import qdrant_svc
|
| 102 |
+
from app.recommend.clustering import _find_medoid
|
| 103 |
+
|
| 104 |
+
ids = anchor_ids(slug)
|
| 105 |
+
if not ids:
|
| 106 |
+
return None
|
| 107 |
+
try:
|
| 108 |
+
vectors = await qdrant_svc.get_paper_vectors(ids)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"[collections] vector fetch failed for {slug}: {e}")
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
found = [vectors[i] for i in ids if i in vectors]
|
| 114 |
+
if len(found) < 2:
|
| 115 |
+
print(f"[collections] {slug}: only {len(found)} anchor vectors -- no medoid")
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
arr = np.asarray(found, dtype=np.float32)
|
| 119 |
+
# L2-normalise before averaging, matching clustering.py: cosine geometry is
|
| 120 |
+
# what the rest of the pipeline assumes.
|
| 121 |
+
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
| 122 |
+
norms[norms == 0] = 1.0
|
| 123 |
+
arr = arr / norms
|
| 124 |
+
centroid = arr.mean(axis=0)
|
| 125 |
+
return arr[_find_medoid(arr, centroid)]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def extend(slug: str, limit: int = EXTEND_LIMIT,
|
| 129 |
+
exclude: set[str] | None = None) -> list[str]:
|
| 130 |
+
"""Recent papers near the collection medoid, excluding the anchors.
|
| 131 |
+
|
| 132 |
+
This is what keeps a curated list from going stale without the curator
|
| 133 |
+
touching it: the anchors are the editorial judgement, and the vector index
|
| 134 |
+
supplies whatever has appeared since.
|
| 135 |
+
"""
|
| 136 |
+
from app import qdrant_svc
|
| 137 |
+
|
| 138 |
+
vec = await medoid(slug)
|
| 139 |
+
if vec is None:
|
| 140 |
+
return []
|
| 141 |
+
|
| 142 |
+
skip = set(exclude or set()) | set(anchor_ids(slug))
|
| 143 |
+
try:
|
| 144 |
+
hits = await qdrant_svc.search_by_vector(
|
| 145 |
+
vec.tolist(), limit=limit + len(skip),
|
| 146 |
+
)
|
| 147 |
+
except Exception as e:
|
| 148 |
+
print(f"[collections] extend failed for {slug}: {e}")
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
out: list[str] = []
|
| 152 |
+
for h in hits:
|
| 153 |
+
aid = h.get("arxiv_id") if isinstance(h, dict) else getattr(h, "arxiv_id", None)
|
| 154 |
+
if aid and aid not in skip and aid not in out:
|
| 155 |
+
out.append(aid)
|
| 156 |
+
if len(out) >= limit:
|
| 157 |
+
break
|
| 158 |
+
return out
|
|
@@ -133,6 +133,17 @@ CREATE TABLE IF NOT EXISTS feed_impressions (
|
|
| 133 |
);
|
| 134 |
CREATE INDEX IF NOT EXISTS idx_impr_user_time
|
| 135 |
ON feed_impressions(user_id, shown_at DESC);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
"""
|
| 137 |
|
| 138 |
|
|
@@ -634,3 +645,42 @@ async def prune_impressions(retention_days: int = 90) -> int:
|
|
| 634 |
)
|
| 635 |
await conn.commit()
|
| 636 |
return cur.rowcount
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
);
|
| 134 |
CREATE INDEX IF NOT EXISTS idx_impr_user_time
|
| 135 |
ON feed_impressions(user_id, shown_at DESC);
|
| 136 |
+
|
| 137 |
+
-- Which curated collections a user follows. The collections themselves are
|
| 138 |
+
-- repo content (data/collections/*.json); this is the user-data half.
|
| 139 |
+
CREATE TABLE IF NOT EXISTS collection_follows (
|
| 140 |
+
user_id TEXT NOT NULL,
|
| 141 |
+
slug TEXT NOT NULL,
|
| 142 |
+
followed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
| 143 |
+
PRIMARY KEY (user_id, slug)
|
| 144 |
+
);
|
| 145 |
+
CREATE INDEX IF NOT EXISTS idx_follow_user
|
| 146 |
+
ON collection_follows(user_id);
|
| 147 |
"""
|
| 148 |
|
| 149 |
|
|
|
|
| 645 |
)
|
| 646 |
await conn.commit()
|
| 647 |
return cur.rowcount
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
# ββ Collection follows βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 651 |
+
#
|
| 652 |
+
# Which curated collections a user follows. The collections themselves live in
|
| 653 |
+
# the repo (see app/collections_svc.py); this is the user-data half, so it is
|
| 654 |
+
# replicated by turso_sync.
|
| 655 |
+
|
| 656 |
+
async def follow_collection(user_id: str, slug: str) -> None:
|
| 657 |
+
if not user_id or not slug:
|
| 658 |
+
return
|
| 659 |
+
async with aiosqlite.connect(DB_PATH) as conn:
|
| 660 |
+
await conn.execute(
|
| 661 |
+
"INSERT INTO collection_follows (user_id, slug, followed_at) "
|
| 662 |
+
"VALUES (?, ?, datetime('now')) "
|
| 663 |
+
"ON CONFLICT(user_id, slug) DO UPDATE SET followed_at = datetime('now')",
|
| 664 |
+
(user_id, slug),
|
| 665 |
+
)
|
| 666 |
+
await conn.commit()
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
async def unfollow_collection(user_id: str, slug: str) -> None:
|
| 670 |
+
if not user_id or not slug:
|
| 671 |
+
return
|
| 672 |
+
async with aiosqlite.connect(DB_PATH) as conn:
|
| 673 |
+
await conn.execute(
|
| 674 |
+
"DELETE FROM collection_follows WHERE user_id = ? AND slug = ?",
|
| 675 |
+
(user_id, slug),
|
| 676 |
+
)
|
| 677 |
+
await conn.commit()
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
async def get_followed_slugs(user_id: str) -> set[str]:
|
| 681 |
+
if not user_id:
|
| 682 |
+
return set()
|
| 683 |
+
async with aiosqlite.connect(DB_PATH) as conn:
|
| 684 |
+
cur = await conn.execute(
|
| 685 |
+
"SELECT slug FROM collection_follows WHERE user_id = ?", (user_id,))
|
| 686 |
+
return {r[0] for r in await cur.fetchall()}
|
|
@@ -18,7 +18,8 @@ from fastapi.staticfiles import StaticFiles
|
|
| 18 |
from app import db
|
| 19 |
from app.config import APP_TITLE, COOKIE_NAME
|
| 20 |
from app.templates_env import templates
|
| 21 |
-
from app.routers import search, events, recommendations, saved, onboarding,
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
@asynccontextmanager
|
|
@@ -114,6 +115,7 @@ app.include_router(recommendations.router)
|
|
| 114 |
app.include_router(saved.router)
|
| 115 |
app.include_router(onboarding.router)
|
| 116 |
app.include_router(health.router)
|
|
|
|
| 117 |
# researchit-space (3D map client) JSON API. Guarded the same way as the Turso
|
| 118 |
# sync and BGE-M3 warmup above: this router is additive, and a fault inside it
|
| 119 |
# must never stop the main app from serving.
|
|
|
|
| 18 |
from app import db
|
| 19 |
from app.config import APP_TITLE, COOKIE_NAME
|
| 20 |
from app.templates_env import templates
|
| 21 |
+
from app.routers import (search, events, recommendations, saved, onboarding,
|
| 22 |
+
health, collections)
|
| 23 |
|
| 24 |
|
| 25 |
@asynccontextmanager
|
|
|
|
| 115 |
app.include_router(saved.router)
|
| 116 |
app.include_router(onboarding.router)
|
| 117 |
app.include_router(health.router)
|
| 118 |
+
app.include_router(collections.router)
|
| 119 |
# researchit-space (3D map client) JSON API. Guarded the same way as the Turso
|
| 120 |
# sync and BGE-M3 warmup above: this router is additive, and a fault inside it
|
| 121 |
# must never stop the main app from serving.
|
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Curated reading collections.
|
| 3 |
+
|
| 4 |
+
GET /collections β index of every curated collection
|
| 5 |
+
GET /collections/{slug} β anchors, plus medoid-matched recent work
|
| 6 |
+
POST /api/collections/{slug}/follow β seed the user's profile from the anchors
|
| 7 |
+
POST /api/collections/{slug}/unfollow
|
| 8 |
+
|
| 9 |
+
Why this exists
|
| 10 |
+
---------------
|
| 11 |
+
Cold start asks a new user to pick arXiv categories, but `cs.LG` is ~302k
|
| 12 |
+
papers -- far too coarse to retrieve against. A collection is a much sharper
|
| 13 |
+
signal, and following one replays its anchors through the ordinary save path,
|
| 14 |
+
which puts the user straight past the >=5-save threshold into Tier 1
|
| 15 |
+
multi-interest clustering. It skips the weakest part of the pipeline entirely.
|
| 16 |
+
|
| 17 |
+
Semantic Scholar's Research Feeds work the same way -- the feed is seeded from
|
| 18 |
+
a curated Library folder, with a stated minimum of "5 relevant papers... and 3
|
| 19 |
+
non-relevant". Every collection here carries at least 6 anchors, so one click
|
| 20 |
+
clears that budget.
|
| 21 |
+
"""
|
| 22 |
+
import uuid
|
| 23 |
+
|
| 24 |
+
from fastapi import APIRouter, Cookie, Request
|
| 25 |
+
from fastapi.responses import HTMLResponse
|
| 26 |
+
|
| 27 |
+
from app import arxiv_svc, collections_svc, db, turso_svc, user_state as us
|
| 28 |
+
from app.config import COOKIE_NAME
|
| 29 |
+
from app.templates_env import templates
|
| 30 |
+
|
| 31 |
+
router = APIRouter()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def _hydrate(arxiv_ids: list[str], state) -> dict[str, dict]:
|
| 35 |
+
"""arxiv_id -> metadata dict, with saved/dismissed flags for the card."""
|
| 36 |
+
if not arxiv_ids:
|
| 37 |
+
return {}
|
| 38 |
+
meta = await turso_svc.fetch_metadata_batch(arxiv_ids)
|
| 39 |
+
missing = [a for a in arxiv_ids if a not in meta]
|
| 40 |
+
if missing:
|
| 41 |
+
try:
|
| 42 |
+
meta.update(await arxiv_svc.fetch_metadata_batch(missing))
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"[collections] arXiv fallback failed for {len(missing)}: {e}")
|
| 45 |
+
try:
|
| 46 |
+
await db.cache_turso_metadata_batch(list(meta.values()))
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"[collections] metadata cache write skipped: {e}")
|
| 49 |
+
|
| 50 |
+
saved = set(state.positive_list) if state else set()
|
| 51 |
+
for aid, m in meta.items():
|
| 52 |
+
m["saved"] = aid in saved
|
| 53 |
+
m["dismissed"] = False
|
| 54 |
+
return meta
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/collections", response_class=HTMLResponse)
|
| 58 |
+
async def collections_index(
|
| 59 |
+
request: Request,
|
| 60 |
+
user_id: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
| 61 |
+
):
|
| 62 |
+
user_id = user_id or str(uuid.uuid4())
|
| 63 |
+
cols = collections_svc.load_all()
|
| 64 |
+
followed = await db.get_followed_slugs(user_id)
|
| 65 |
+
|
| 66 |
+
# Preview the first three titles per collection, the same two-tier
|
| 67 |
+
# index -> detail shape Hugging Face Collections uses. Titles only: this
|
| 68 |
+
# page must stay one metadata round trip regardless of collection count.
|
| 69 |
+
preview_ids = [a["id"] for c in cols for a in c["anchors"][:3]]
|
| 70 |
+
meta = await turso_svc.fetch_metadata_batch(preview_ids) if preview_ids else {}
|
| 71 |
+
|
| 72 |
+
view = []
|
| 73 |
+
for c in cols:
|
| 74 |
+
previews = [
|
| 75 |
+
(meta.get(a["id"]) or {}).get("title", "").strip()
|
| 76 |
+
for a in c["anchors"][:3]
|
| 77 |
+
]
|
| 78 |
+
view.append({
|
| 79 |
+
**c,
|
| 80 |
+
"previews": [p for p in previews if p],
|
| 81 |
+
"following": c["slug"] in followed,
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
resp = templates.TemplateResponse(
|
| 85 |
+
request, "collections.html", {"collections": view},
|
| 86 |
+
)
|
| 87 |
+
resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
|
| 88 |
+
return resp
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.get("/collections/{slug}", response_class=HTMLResponse)
|
| 92 |
+
async def collection_detail(
|
| 93 |
+
slug: str,
|
| 94 |
+
request: Request,
|
| 95 |
+
user_id: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
| 96 |
+
):
|
| 97 |
+
user_id = user_id or str(uuid.uuid4())
|
| 98 |
+
col = collections_svc.get(slug)
|
| 99 |
+
if col is None:
|
| 100 |
+
resp = templates.TemplateResponse(
|
| 101 |
+
request, "collections.html",
|
| 102 |
+
{"collections": [], "not_found": slug}, status_code=404,
|
| 103 |
+
)
|
| 104 |
+
resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
|
| 105 |
+
return resp
|
| 106 |
+
|
| 107 |
+
state = await us.ensure_loaded(user_id)
|
| 108 |
+
anchor_ids = [a["id"] for a in col["anchors"]]
|
| 109 |
+
|
| 110 |
+
# The extension is best-effort. A vector-store hiccup should cost the
|
| 111 |
+
# "recent work" section, never the curated list -- which is the part with
|
| 112 |
+
# editorial value and the part that works with no network at all.
|
| 113 |
+
try:
|
| 114 |
+
extended_ids = await collections_svc.extend(slug, exclude=us.all_seen(user_id))
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(f"[collections] extend failed for {slug}: {e}")
|
| 117 |
+
extended_ids = []
|
| 118 |
+
|
| 119 |
+
meta = await _hydrate(anchor_ids + extended_ids, state)
|
| 120 |
+
|
| 121 |
+
anchors = []
|
| 122 |
+
for i, a in enumerate(col["anchors"]):
|
| 123 |
+
m = meta.get(a["id"])
|
| 124 |
+
if m:
|
| 125 |
+
anchors.append({**m, "note": a["note"], "ordinal": i + 1})
|
| 126 |
+
|
| 127 |
+
extended = [meta[a] for a in extended_ids if a in meta]
|
| 128 |
+
followed = await db.get_followed_slugs(user_id)
|
| 129 |
+
|
| 130 |
+
resp = templates.TemplateResponse(
|
| 131 |
+
request, "collection_detail.html",
|
| 132 |
+
{
|
| 133 |
+
"collection": col,
|
| 134 |
+
"anchors": anchors,
|
| 135 |
+
"extended": extended,
|
| 136 |
+
"following": slug in followed,
|
| 137 |
+
},
|
| 138 |
+
)
|
| 139 |
+
resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
|
| 140 |
+
return resp
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.post("/api/collections/{slug}/follow", response_class=HTMLResponse)
|
| 144 |
+
async def follow(
|
| 145 |
+
slug: str,
|
| 146 |
+
request: Request,
|
| 147 |
+
user_id: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
| 148 |
+
):
|
| 149 |
+
"""Seed the user's profile from a collection's anchors.
|
| 150 |
+
|
| 151 |
+
Each anchor goes through the ordinary save path, so the EWMA update, the
|
| 152 |
+
interaction log and the clustering threshold all behave exactly as they do
|
| 153 |
+
for a hand-saved paper. No separate code path means no separate bugs, and
|
| 154 |
+
the profile a follow produces is indistinguishable from one built by hand.
|
| 155 |
+
"""
|
| 156 |
+
user_id = user_id or str(uuid.uuid4())
|
| 157 |
+
col = collections_svc.get(slug)
|
| 158 |
+
if col is None:
|
| 159 |
+
return _fragment(request, user_id, None, False, 0)
|
| 160 |
+
|
| 161 |
+
ids = [a["id"] for a in col["anchors"]]
|
| 162 |
+
state = await us.ensure_loaded(user_id)
|
| 163 |
+
already = set(state.positive_list)
|
| 164 |
+
seeded = 0
|
| 165 |
+
vectorised = 0
|
| 166 |
+
|
| 167 |
+
for pos, aid in enumerate(ids):
|
| 168 |
+
if aid in already:
|
| 169 |
+
continue
|
| 170 |
+
try:
|
| 171 |
+
if await _save_one(user_id, aid, pos, slug):
|
| 172 |
+
vectorised += 1
|
| 173 |
+
seeded += 1
|
| 174 |
+
except Exception as e:
|
| 175 |
+
# One unresolvable paper must not abandon the rest of the seed.
|
| 176 |
+
print(f"[collections] seeding {aid} from {slug} failed: {e}")
|
| 177 |
+
|
| 178 |
+
# Saving and profile-seeding are different things, and only the second one
|
| 179 |
+
# makes the feed change. If the vector store cannot resolve the anchors the
|
| 180 |
+
# follow still "works" -- papers land in the library -- while doing nothing
|
| 181 |
+
# at all to recommendations. Say so loudly rather than let it pass.
|
| 182 |
+
#
|
| 183 |
+
# The usual cause is a partially configured vector store. A dev machine
|
| 184 |
+
# with only the primary shard set resolves nothing after ~2021, so every
|
| 185 |
+
# modern collection seeds zero vectors while looking fine.
|
| 186 |
+
if seeded and vectorised < seeded:
|
| 187 |
+
print(f"[collections] {slug}: seeded {seeded} saves but only {vectorised} "
|
| 188 |
+
f"vectors -- profile is under-trained. Check shard configuration.")
|
| 189 |
+
|
| 190 |
+
await db.follow_collection(user_id, slug)
|
| 191 |
+
return _fragment(request, user_id, col, True, seeded)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@router.post("/api/collections/{slug}/unfollow", response_class=HTMLResponse)
|
| 195 |
+
async def unfollow(
|
| 196 |
+
slug: str,
|
| 197 |
+
request: Request,
|
| 198 |
+
user_id: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
| 199 |
+
):
|
| 200 |
+
"""Stop following.
|
| 201 |
+
|
| 202 |
+
Deliberately does NOT unsave the anchors or rewind the profile: the EWMA is
|
| 203 |
+
a lossy running average with no exact inverse, so "undoing" a follow would
|
| 204 |
+
leave a permanent smudge rather than restoring the previous state. The
|
| 205 |
+
papers stay in the library, where the user can remove them individually.
|
| 206 |
+
"""
|
| 207 |
+
user_id = user_id or str(uuid.uuid4())
|
| 208 |
+
await db.unfollow_collection(user_id, slug)
|
| 209 |
+
return _fragment(request, user_id, collections_svc.get(slug), False, 0)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def _save_one(user_id: str, arxiv_id: str, position: int, slug: str) -> bool:
|
| 213 |
+
"""Record one anchor exactly as events.py records a manual save.
|
| 214 |
+
|
| 215 |
+
Returns True if the EWMA profile was actually updated -- i.e. the vector
|
| 216 |
+
store resolved this paper. A save without a vector is a library entry that
|
| 217 |
+
teaches the recommender nothing, and the caller needs to tell those apart.
|
| 218 |
+
"""
|
| 219 |
+
import numpy as np
|
| 220 |
+
from app import qdrant_svc
|
| 221 |
+
from app.recommend import profiles
|
| 222 |
+
|
| 223 |
+
await db.log_interaction(
|
| 224 |
+
user_id=user_id,
|
| 225 |
+
paper_id=arxiv_id,
|
| 226 |
+
event_type="save",
|
| 227 |
+
source="collection",
|
| 228 |
+
position=position,
|
| 229 |
+
query_id=f"collection:{slug}",
|
| 230 |
+
ranker_version="collection_seed",
|
| 231 |
+
candidate_source=f"collection:{slug}",
|
| 232 |
+
cluster_id=None,
|
| 233 |
+
# Deterministic by construction: following a collection shows exactly
|
| 234 |
+
# these papers, so the propensity genuinely is 1.0 (CLAUDE.md 3.11).
|
| 235 |
+
propensity=1.0,
|
| 236 |
+
policy_id="collection_seed",
|
| 237 |
+
)
|
| 238 |
+
us.record_positive(user_id, arxiv_id)
|
| 239 |
+
|
| 240 |
+
vectors = await qdrant_svc.get_paper_vectors([arxiv_id])
|
| 241 |
+
vec = vectors.get(arxiv_id)
|
| 242 |
+
if not vec:
|
| 243 |
+
return False
|
| 244 |
+
await profiles.update_on_save(user_id, np.asarray(vec, dtype=np.float32))
|
| 245 |
+
return True
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _fragment(request: Request, user_id: str, col: dict | None,
|
| 249 |
+
following: bool, seeded: int):
|
| 250 |
+
resp = templates.TemplateResponse(
|
| 251 |
+
request, "partials/follow_button.html",
|
| 252 |
+
{"collection": col, "following": following, "seeded": seeded},
|
| 253 |
+
)
|
| 254 |
+
resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
|
| 255 |
+
return resp
|
|
@@ -735,3 +735,106 @@ a.card-title:hover { color: var(--accent); }
|
|
| 735 |
}
|
| 736 |
.card.is-leaving { animation: none; display: none; }
|
| 737 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
}
|
| 736 |
.card.is-leaving { animation: none; display: none; }
|
| 737 |
}
|
| 738 |
+
|
| 739 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 740 |
+
COLLECTIONS
|
| 741 |
+
Curated reading tracks. Two surfaces: an index that has to answer "is this
|
| 742 |
+
my track?" at a glance, and a detail view whose job is to keep editorial
|
| 743 |
+
curation visually distinct from vector-matched extension.
|
| 744 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 745 |
+
|
| 746 |
+
.sec-sub {
|
| 747 |
+
font-size: .9rem; color: var(--text-2);
|
| 748 |
+
max-width: 44ch; margin: -.35rem 0 1.4rem;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.coll-list { display: flex; flex-direction: column; gap: .9rem; }
|
| 752 |
+
|
| 753 |
+
.coll-card {
|
| 754 |
+
background: var(--surface); border: 1px solid var(--border);
|
| 755 |
+
border-radius: var(--radius); padding: 1.1rem 1.15rem 1rem;
|
| 756 |
+
box-shadow: var(--shadow-1);
|
| 757 |
+
}
|
| 758 |
+
.coll-card:hover { border-color: var(--border-2); }
|
| 759 |
+
|
| 760 |
+
.coll-card-head {
|
| 761 |
+
display: flex; align-items: baseline; justify-content: space-between;
|
| 762 |
+
gap: .8rem; margin-bottom: .35rem;
|
| 763 |
+
}
|
| 764 |
+
.coll-title {
|
| 765 |
+
font-family: var(--font-read); font-size: 1.12rem; font-weight: 600;
|
| 766 |
+
letter-spacing: -.012em; line-height: 1.25; text-wrap: balance;
|
| 767 |
+
}
|
| 768 |
+
.coll-title a { color: inherit; text-decoration: none; }
|
| 769 |
+
.coll-title a:hover { text-decoration: underline; text-underline-offset: 2px; }
|
| 770 |
+
|
| 771 |
+
.coll-count {
|
| 772 |
+
font-family: var(--font-mono); font-size: .68rem; letter-spacing: .04em;
|
| 773 |
+
color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.coll-blurb { font-size: .89rem; color: var(--text-2); max-width: 56ch; }
|
| 777 |
+
.coll-blurb--lg { font-size: .96rem; margin-top: .3rem; }
|
| 778 |
+
|
| 779 |
+
/* Preview titles. Three, per the two-tier index -> detail pattern: enough to
|
| 780 |
+
recognise the track, not enough to start reading it here. */
|
| 781 |
+
.coll-preview {
|
| 782 |
+
list-style: none; margin: .7rem 0 .9rem; padding: 0;
|
| 783 |
+
display: flex; flex-direction: column; gap: .18rem;
|
| 784 |
+
border-left: 2px solid var(--border); padding-left: .75rem;
|
| 785 |
+
}
|
| 786 |
+
.coll-preview li {
|
| 787 |
+
font-size: .84rem; color: var(--text-3);
|
| 788 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 789 |
+
}
|
| 790 |
+
.coll-preview .coll-more { color: var(--text-3); opacity: .8; font-style: italic; }
|
| 791 |
+
|
| 792 |
+
.coll-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
| 793 |
+
|
| 794 |
+
.follow-wrap { display: inline-flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
| 795 |
+
.btn-following {
|
| 796 |
+
background: var(--accent-bg); color: var(--accent); border-color: var(--accent-br);
|
| 797 |
+
}
|
| 798 |
+
.btn-following:hover { background: var(--surface-2); }
|
| 799 |
+
.follow-note { font-size: .8rem; color: var(--text-2); }
|
| 800 |
+
|
| 801 |
+
/* ββ Detail βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 802 |
+
|
| 803 |
+
.coll-hero { padding: .4rem 0 1.6rem; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
|
| 804 |
+
.back-link {
|
| 805 |
+
display: inline-block; font-size: .82rem; color: var(--text-3);
|
| 806 |
+
text-decoration: none; margin-bottom: .6rem;
|
| 807 |
+
}
|
| 808 |
+
.back-link:hover { color: var(--text); }
|
| 809 |
+
.coll-hero .sec-title { font-family: var(--font-read); font-size: 1.6rem; font-weight: 600; }
|
| 810 |
+
.coll-hero-actions {
|
| 811 |
+
display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; margin-top: 1rem;
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
.sec-head--sub { margin-top: 2.2rem; padding-bottom: .5rem; border-bottom: 1px solid var(--border); }
|
| 815 |
+
.sec-title--sm {
|
| 816 |
+
font-family: var(--font-ui); font-size: .74rem; font-weight: 620;
|
| 817 |
+
letter-spacing: .09em; text-transform: uppercase; color: var(--text-3);
|
| 818 |
+
}
|
| 819 |
+
.sec-note { font-size: .76rem; color: var(--text-3); font-style: italic; }
|
| 820 |
+
|
| 821 |
+
/* Anchors carry an ordinal and a curator note. The ordinal is deliberately
|
| 822 |
+
quiet -- reading order is information, but it is not the headline. */
|
| 823 |
+
.anchor-row { display: flex; gap: .85rem; align-items: flex-start; }
|
| 824 |
+
.anchor-ord {
|
| 825 |
+
flex: 0 0 1.4rem; font-family: var(--font-mono); font-size: .78rem;
|
| 826 |
+
color: var(--text-3); text-align: right; padding-top: 1.05rem;
|
| 827 |
+
font-variant-numeric: tabular-nums;
|
| 828 |
+
}
|
| 829 |
+
.anchor-body { flex: 1 1 auto; min-width: 0; }
|
| 830 |
+
.anchor-note {
|
| 831 |
+
font-family: var(--font-read); font-size: .89rem; line-height: 1.5;
|
| 832 |
+
color: var(--text-2); margin: 0 0 .4rem; padding-left: .1rem;
|
| 833 |
+
max-width: 60ch;
|
| 834 |
+
}
|
| 835 |
+
.feed--anchored { gap: 1.1rem; }
|
| 836 |
+
|
| 837 |
+
@media (max-width: 34rem) {
|
| 838 |
+
.anchor-ord { display: none; }
|
| 839 |
+
.coll-card-head { flex-direction: column; gap: .2rem; }
|
| 840 |
+
}
|
|
@@ -62,6 +62,7 @@
|
|
| 62 |
|
| 63 |
<nav class="nav" aria-label="Main">
|
| 64 |
<a href="/" class="nav-link" {% if path == '/' %}aria-current="page"{% endif %}>Feed</a>
|
|
|
|
| 65 |
<a href="/search" class="nav-link" {% if path.startswith('/search') %}aria-current="page"{% endif %}>Search</a>
|
| 66 |
<a href="/saved" class="nav-link" {% if path.startswith('/saved') %}aria-current="page"{% endif %}>Library</a>
|
| 67 |
{% if space_app_url %}
|
|
@@ -102,6 +103,17 @@
|
|
| 102 |
</svg>
|
| 103 |
<span>Feed</span>
|
| 104 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
<a href="/search" class="botnav-item" {% if path.startswith('/search') %}aria-current="page"{% endif %}>
|
| 106 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
| 107 |
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
|
|
| 62 |
|
| 63 |
<nav class="nav" aria-label="Main">
|
| 64 |
<a href="/" class="nav-link" {% if path == '/' %}aria-current="page"{% endif %}>Feed</a>
|
| 65 |
+
<a href="/collections" class="nav-link" {% if path.startswith('/collections') %}aria-current="page"{% endif %}>Collections</a>
|
| 66 |
<a href="/search" class="nav-link" {% if path.startswith('/search') %}aria-current="page"{% endif %}>Search</a>
|
| 67 |
<a href="/saved" class="nav-link" {% if path.startswith('/saved') %}aria-current="page"{% endif %}>Library</a>
|
| 68 |
{% if space_app_url %}
|
|
|
|
| 103 |
</svg>
|
| 104 |
<span>Feed</span>
|
| 105 |
</a>
|
| 106 |
+
<a href="/collections" class="botnav-item" {% if path.startswith('/collections') %}aria-current="page"{% endif %}>
|
| 107 |
+
{# Stacked layers: a set of things gathered, distinct from the single
|
| 108 |
+
bookmark used for Library. #}
|
| 109 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
| 110 |
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 111 |
+
<path d="m12 3 8.5 4.5L12 12 3.5 7.5z" />
|
| 112 |
+
<path d="m3.5 12 8.5 4.5 8.5-4.5" opacity=".55" />
|
| 113 |
+
<path d="m3.5 16.5 8.5 4.5 8.5-4.5" opacity=".3" />
|
| 114 |
+
</svg>
|
| 115 |
+
<span>Collections</span>
|
| 116 |
+
</a>
|
| 117 |
<a href="/search" class="botnav-item" {% if path.startswith('/search') %}aria-current="page"{% endif %}>
|
| 118 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
| 119 |
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}{{ collection.title }} β ResearchIT{% endblock %}
|
| 4 |
+
|
| 5 |
+
{#
|
| 6 |
+
Collection detail.
|
| 7 |
+
|
| 8 |
+
The one decision that matters here is the seam. A collection holds two kinds
|
| 9 |
+
of paper: anchors, which are an editorial judgement, and extended papers,
|
| 10 |
+
which are whatever currently sits nearest the collection medoid. Presenting
|
| 11 |
+
them as one list would quietly pass off vector search as curation, so they
|
| 12 |
+
get separate headed sections and the extended one says where it came from.
|
| 13 |
+
|
| 14 |
+
Anchors reuse partials/paper_card.html so save/pass and all the Phase 4.5 /
|
| 15 |
+
6.5 instrumentation keep working unchanged; the ordinal and the note are
|
| 16 |
+
rendered around the card rather than inside it.
|
| 17 |
+
#}
|
| 18 |
+
|
| 19 |
+
{% block content %}
|
| 20 |
+
<div class="wrap wrap--feed">
|
| 21 |
+
|
| 22 |
+
<div class="coll-hero">
|
| 23 |
+
<a href="/collections" class="back-link">β Collections</a>
|
| 24 |
+
<h1 class="sec-title">{{ collection.title }}</h1>
|
| 25 |
+
<p class="coll-blurb coll-blurb--lg">{{ collection.blurb }}</p>
|
| 26 |
+
<div class="coll-hero-actions">
|
| 27 |
+
<span class="coll-count">{{ collection.count }} curated{% if extended %} Β· extends automatically{% endif %}</span>
|
| 28 |
+
{% include "partials/follow_button.html" %}
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
{% if anchors %}
|
| 33 |
+
<div class="sec-head sec-head--sub">
|
| 34 |
+
<h2 class="sec-title sec-title--sm">Curated</h2>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div class="feed feed--anchored">
|
| 38 |
+
{% for paper in anchors %}
|
| 39 |
+
<div class="anchor-row">
|
| 40 |
+
<div class="anchor-ord" aria-hidden="true">{{ paper.ordinal }}</div>
|
| 41 |
+
<div class="anchor-body">
|
| 42 |
+
{% if paper.note %}
|
| 43 |
+
<p class="anchor-note">{{ paper.note }}</p>
|
| 44 |
+
{% endif %}
|
| 45 |
+
{% set position = loop.index0 %}
|
| 46 |
+
{% set source = "collection" %}
|
| 47 |
+
{% include "partials/paper_card.html" %}
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
{% endfor %}
|
| 51 |
+
</div>
|
| 52 |
+
{% else %}
|
| 53 |
+
<div class="empty">
|
| 54 |
+
<p class="empty-title">Could not load this collectionβs papers</p>
|
| 55 |
+
<p class="empty-body">The metadata store did not return them. Try again shortly.</p>
|
| 56 |
+
</div>
|
| 57 |
+
{% endif %}
|
| 58 |
+
|
| 59 |
+
{% if extended %}
|
| 60 |
+
<div class="sec-head sec-head--sub">
|
| 61 |
+
<h2 class="sec-title sec-title--sm">Recent work in this area</h2>
|
| 62 |
+
<span class="sec-note">Matched to this collection, not hand-picked</span>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="feed">
|
| 66 |
+
{% for paper in extended %}
|
| 67 |
+
{% set position = anchors | length + loop.index0 %}
|
| 68 |
+
{% set source = "collection_extended" %}
|
| 69 |
+
{% include "partials/paper_card.html" %}
|
| 70 |
+
{% endfor %}
|
| 71 |
+
</div>
|
| 72 |
+
{% endif %}
|
| 73 |
+
|
| 74 |
+
</div>
|
| 75 |
+
{% endblock %}
|
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Collections β ResearchIT{% endblock %}
|
| 4 |
+
|
| 5 |
+
{#
|
| 6 |
+
Collection index.
|
| 7 |
+
|
| 8 |
+
Two-tier index -> detail, previewing three titles per collection. Anything
|
| 9 |
+
more turns a browsing page into a wall of abstracts, and the preview exists
|
| 10 |
+
to answer "is this the track I want", not to be read.
|
| 11 |
+
#}
|
| 12 |
+
|
| 13 |
+
{% block content %}
|
| 14 |
+
<div class="wrap wrap--feed">
|
| 15 |
+
|
| 16 |
+
<div class="sec-head">
|
| 17 |
+
<h1 class="sec-title">Collections</h1>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
{% if not_found %}
|
| 21 |
+
<div class="empty">
|
| 22 |
+
<p class="empty-title">No collection called β{{ not_found }}β</p>
|
| 23 |
+
<p class="empty-body">It may have been renamed. Everything available is listed below.</p>
|
| 24 |
+
</div>
|
| 25 |
+
{% endif %}
|
| 26 |
+
|
| 27 |
+
{% if collections %}
|
| 28 |
+
<p class="sec-sub">
|
| 29 |
+
Curated starting points. Follow one and the feed learns from it
|
| 30 |
+
immediately β no need to save papers one at a time first.
|
| 31 |
+
</p>
|
| 32 |
+
|
| 33 |
+
<div class="coll-list">
|
| 34 |
+
{% for c in collections %}
|
| 35 |
+
<article class="coll-card">
|
| 36 |
+
<div class="coll-card-head">
|
| 37 |
+
<h2 class="coll-title">
|
| 38 |
+
<a href="/collections/{{ c.slug }}">{{ c.title }}</a>
|
| 39 |
+
</h2>
|
| 40 |
+
<span class="coll-count">{{ c.count }} papers</span>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<p class="coll-blurb">{{ c.blurb }}</p>
|
| 44 |
+
|
| 45 |
+
{% if c.previews %}
|
| 46 |
+
<ul class="coll-preview">
|
| 47 |
+
{% for t in c.previews %}<li>{{ t }}</li>{% endfor %}
|
| 48 |
+
{% if c.count > c.previews | length %}
|
| 49 |
+
<li class="coll-more">+ {{ c.count - (c.previews | length) }} more</li>
|
| 50 |
+
{% endif %}
|
| 51 |
+
</ul>
|
| 52 |
+
{% endif %}
|
| 53 |
+
|
| 54 |
+
<div class="coll-actions">
|
| 55 |
+
<a href="/collections/{{ c.slug }}" class="btn btn-sm">Open</a>
|
| 56 |
+
{% with collection = c, following = c.following, seeded = 0 %}
|
| 57 |
+
{% include "partials/follow_button.html" %}
|
| 58 |
+
{% endwith %}
|
| 59 |
+
</div>
|
| 60 |
+
</article>
|
| 61 |
+
{% endfor %}
|
| 62 |
+
</div>
|
| 63 |
+
{% elif not not_found %}
|
| 64 |
+
<div class="empty">
|
| 65 |
+
<p class="empty-title">No collections yet</p>
|
| 66 |
+
<p class="empty-body">Curated collections live in <code>data/collections/</code>.</p>
|
| 67 |
+
</div>
|
| 68 |
+
{% endif %}
|
| 69 |
+
|
| 70 |
+
</div>
|
| 71 |
+
{% endblock %}
|
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{#
|
| 2 |
+
Follow / Following toggle for a collection.
|
| 3 |
+
|
| 4 |
+
Also the HTMX response fragment for the follow and unfollow endpoints, so the
|
| 5 |
+
button swaps itself and there is exactly one definition of what the control
|
| 6 |
+
looks like in each state.
|
| 7 |
+
|
| 8 |
+
Expects:
|
| 9 |
+
collection dict with .slug (may be None if the slug was unknown)
|
| 10 |
+
following bool
|
| 11 |
+
seeded int β papers added to the profile by this click, 0 otherwise
|
| 12 |
+
#}
|
| 13 |
+
{% if collection %}
|
| 14 |
+
<span class="follow-wrap" id="follow-{{ collection.slug }}">
|
| 15 |
+
{% if following %}
|
| 16 |
+
<button type="button"
|
| 17 |
+
class="btn btn-sm btn-following"
|
| 18 |
+
hx-post="/api/collections/{{ collection.slug }}/unfollow"
|
| 19 |
+
hx-target="closest .follow-wrap"
|
| 20 |
+
hx-swap="outerHTML"
|
| 21 |
+
aria-label="Stop following {{ collection.title | default(collection.slug) }}">
|
| 22 |
+
Following β
|
| 23 |
+
</button>
|
| 24 |
+
{% if seeded %}
|
| 25 |
+
{# Say what actually happened. "Following" alone hides the fact that
|
| 26 |
+
the feed just changed underneath them. #}
|
| 27 |
+
<span class="follow-note" role="status">
|
| 28 |
+
{{ seeded }} paper{{ "s" if seeded != 1 }} added to your library β your feed will reflect this now.
|
| 29 |
+
</span>
|
| 30 |
+
{% endif %}
|
| 31 |
+
{% else %}
|
| 32 |
+
<button type="button"
|
| 33 |
+
class="btn btn-sm btn-primary"
|
| 34 |
+
hx-post="/api/collections/{{ collection.slug }}/follow"
|
| 35 |
+
hx-target="closest .follow-wrap"
|
| 36 |
+
hx-swap="outerHTML"
|
| 37 |
+
aria-label="Follow {{ collection.title | default(collection.slug) }}">
|
| 38 |
+
Follow
|
| 39 |
+
</button>
|
| 40 |
+
{% endif %}
|
| 41 |
+
</span>
|
| 42 |
+
{% endif %}
|
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for curated reading collections.
|
| 3 |
+
|
| 4 |
+
No network: the vector store and metadata layer are stubbed, so what is under
|
| 5 |
+
test is the loading, the seeding contract and the honesty of the follow
|
| 6 |
+
response -- not Qdrant.
|
| 7 |
+
"""
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pytest
|
| 12 |
+
from fastapi.testclient import TestClient
|
| 13 |
+
|
| 14 |
+
import app.db as db_mod
|
| 15 |
+
from app import collections_svc
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def repo(tmp_path, monkeypatch):
|
| 20 |
+
"""A collections directory with two known collections."""
|
| 21 |
+
d = tmp_path / "collections"
|
| 22 |
+
d.mkdir()
|
| 23 |
+
(d / "alpha.json").write_text(json.dumps({
|
| 24 |
+
"slug": "alpha", "title": "Alpha Track", "blurb": "First.",
|
| 25 |
+
"anchors": [{"id": f"2401.{i:05d}", "note": f"note {i}"} for i in range(6)],
|
| 26 |
+
}))
|
| 27 |
+
(d / "beta.json").write_text(json.dumps({
|
| 28 |
+
"slug": "beta", "title": "Beta Track", "blurb": "Second.",
|
| 29 |
+
"anchors": [{"id": "1706.03762", "note": "the transformer"}],
|
| 30 |
+
}))
|
| 31 |
+
monkeypatch.setattr(collections_svc, "COLLECTIONS_DIR", str(d))
|
| 32 |
+
collections_svc.load_all.cache_clear()
|
| 33 |
+
yield d
|
| 34 |
+
collections_svc.load_all.cache_clear()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ββ Loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
|
| 39 |
+
def test_loads_and_sorts_by_title(repo):
|
| 40 |
+
cols = collections_svc.load_all()
|
| 41 |
+
assert [c["slug"] for c in cols] == ["alpha", "beta"]
|
| 42 |
+
assert cols[0]["count"] == 6
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_lookup_by_slug(repo):
|
| 46 |
+
assert collections_svc.get("alpha")["title"] == "Alpha Track"
|
| 47 |
+
assert collections_svc.get("nope") is None
|
| 48 |
+
assert collections_svc.anchor_ids("beta") == ["1706.03762"]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_a_malformed_file_does_not_break_the_index(repo):
|
| 52 |
+
"""One bad edit must not take the page down for every other collection."""
|
| 53 |
+
(repo / "broken.json").write_text("{ this is not json")
|
| 54 |
+
(repo / "empty.json").write_text(json.dumps({"slug": "empty", "anchors": []}))
|
| 55 |
+
collections_svc.load_all.cache_clear()
|
| 56 |
+
|
| 57 |
+
slugs = [c["slug"] for c in collections_svc.load_all()]
|
| 58 |
+
assert slugs == ["alpha", "beta"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_missing_directory_is_survivable(monkeypatch, tmp_path):
|
| 62 |
+
monkeypatch.setattr(collections_svc, "COLLECTIONS_DIR", str(tmp_path / "nope"))
|
| 63 |
+
collections_svc.load_all.cache_clear()
|
| 64 |
+
assert collections_svc.load_all() == []
|
| 65 |
+
collections_svc.load_all.cache_clear()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ββ The shipped content ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
+
|
| 70 |
+
def test_shipped_collections_are_wellformed():
|
| 71 |
+
"""Guards the real data/collections/*.json, not a fixture."""
|
| 72 |
+
collections_svc.load_all.cache_clear()
|
| 73 |
+
cols = collections_svc.load_all()
|
| 74 |
+
assert len(cols) >= 8, "expected the eight curated tracks"
|
| 75 |
+
|
| 76 |
+
for c in cols:
|
| 77 |
+
# Semantic Scholar's stated cold-start budget is 5 positives; every
|
| 78 |
+
# track must clear it in a single click, and clear Tier 1's >=5 too.
|
| 79 |
+
assert c["count"] >= 6, f"{c['slug']} has too few anchors to seed a profile"
|
| 80 |
+
assert c["blurb"], f"{c['slug']} has no blurb"
|
| 81 |
+
for a in c["anchors"]:
|
| 82 |
+
assert a["note"], f"{c['slug']}/{a['id']} has no curator note"
|
| 83 |
+
# arXiv ids are strings and keep their leading zeros (CLAUDE.md 3.9)
|
| 84 |
+
assert isinstance(a["id"], str) and "." in a["id"]
|
| 85 |
+
|
| 86 |
+
ids = [a["id"] for c in cols for a in c["anchors"]]
|
| 87 |
+
assert len(ids) == len(set(ids)), "an anchor appears in two collections"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ββ Routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 91 |
+
|
| 92 |
+
@pytest.fixture
|
| 93 |
+
def client(tmp_path, monkeypatch, repo):
|
| 94 |
+
import asyncio
|
| 95 |
+
|
| 96 |
+
import app.config as cfg
|
| 97 |
+
|
| 98 |
+
path = str(tmp_path / "coll.db")
|
| 99 |
+
monkeypatch.setattr(cfg, "DB_PATH", path)
|
| 100 |
+
monkeypatch.setattr(db_mod, "DB_PATH", path)
|
| 101 |
+
|
| 102 |
+
import app.user_state as us
|
| 103 |
+
us._cache.clear()
|
| 104 |
+
|
| 105 |
+
import app.turso_svc as turso
|
| 106 |
+
|
| 107 |
+
async def fake_meta(ids):
|
| 108 |
+
return {i: {"arxiv_id": i, "title": f"Paper {i}", "abstract": "abs.",
|
| 109 |
+
"authors": "[]", "category": "cs.LG", "published": "2024-01-01",
|
| 110 |
+
"year": 2024, "citation_count": 3} for i in ids}
|
| 111 |
+
|
| 112 |
+
monkeypatch.setattr(turso, "fetch_metadata_batch", fake_meta)
|
| 113 |
+
|
| 114 |
+
async def no_extend(slug, limit=12, exclude=None):
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
monkeypatch.setattr(collections_svc, "extend", no_extend)
|
| 118 |
+
|
| 119 |
+
asyncio.run(db_mod.init_db())
|
| 120 |
+
from app.main import app
|
| 121 |
+
with TestClient(app) as c:
|
| 122 |
+
yield c
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_index_lists_collections(client):
|
| 126 |
+
r = client.get("/collections")
|
| 127 |
+
assert r.status_code == 200
|
| 128 |
+
assert "Alpha Track" in r.text and "Beta Track" in r.text
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_detail_renders_anchors_and_notes(client):
|
| 132 |
+
r = client.get("/collections/alpha")
|
| 133 |
+
assert r.status_code == 200
|
| 134 |
+
assert "Alpha Track" in r.text
|
| 135 |
+
assert "note 0" in r.text, "curator note missing from the page"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_unknown_slug_is_404_not_500(client):
|
| 139 |
+
assert client.get("/collections/does-not-exist").status_code == 404
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ββ Following ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
|
| 144 |
+
def test_following_seeds_the_profile(client, monkeypatch):
|
| 145 |
+
"""The point of the feature: one click puts a user past the Tier 1 threshold."""
|
| 146 |
+
import app.qdrant_svc as qs
|
| 147 |
+
|
| 148 |
+
async def fake_vectors(ids):
|
| 149 |
+
rng = np.random.default_rng(0)
|
| 150 |
+
return {i: rng.random(1024, dtype=np.float32).tolist() for i in ids}
|
| 151 |
+
|
| 152 |
+
monkeypatch.setattr(qs, "get_paper_vectors", fake_vectors)
|
| 153 |
+
|
| 154 |
+
r = client.post("/api/collections/alpha/follow")
|
| 155 |
+
assert r.status_code == 200
|
| 156 |
+
assert "Following" in r.text
|
| 157 |
+
assert "6 papers added" in r.text
|
| 158 |
+
|
| 159 |
+
import asyncio
|
| 160 |
+
import sqlite3
|
| 161 |
+
|
| 162 |
+
uid = client.cookies.get("arxiv_user_id")
|
| 163 |
+
rows = asyncio.run(db_mod.get_user_interactions(uid, event_types=["save"]))
|
| 164 |
+
assert len(rows) == 6
|
| 165 |
+
|
| 166 |
+
# Instrumentation must survive the seeding path (CLAUDE.md Β§3.11).
|
| 167 |
+
# get_user_interactions only selects three columns, so read the table.
|
| 168 |
+
conn = sqlite3.connect(db_mod.DB_PATH)
|
| 169 |
+
tagged = conn.execute(
|
| 170 |
+
"SELECT candidate_source, policy_id, propensity, source FROM interactions "
|
| 171 |
+
"WHERE user_id = ? AND event_type = 'save'", (uid,)).fetchall()
|
| 172 |
+
conn.close()
|
| 173 |
+
assert len(tagged) == 6
|
| 174 |
+
assert all(t == ("collection:alpha", "collection_seed", 1.0, "collection")
|
| 175 |
+
for t in tagged)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_following_is_idempotent(client, monkeypatch):
|
| 179 |
+
import app.qdrant_svc as qs
|
| 180 |
+
|
| 181 |
+
async def fake_vectors(ids):
|
| 182 |
+
return {i: np.zeros(1024, dtype=np.float32).tolist() for i in ids}
|
| 183 |
+
|
| 184 |
+
monkeypatch.setattr(qs, "get_paper_vectors", fake_vectors)
|
| 185 |
+
|
| 186 |
+
client.post("/api/collections/alpha/follow")
|
| 187 |
+
client.post("/api/collections/alpha/follow")
|
| 188 |
+
|
| 189 |
+
import asyncio
|
| 190 |
+
rows = asyncio.run(db_mod.get_user_interactions(
|
| 191 |
+
client.cookies.get("arxiv_user_id"), event_types=["save"]))
|
| 192 |
+
assert len(rows) == 6, "a second follow re-saved the same anchors"
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_follow_survives_a_vector_store_outage(client, monkeypatch):
|
| 196 |
+
"""Papers must still reach the library when Qdrant is unavailable.
|
| 197 |
+
|
| 198 |
+
The profile will be under-trained, which the server logs -- but the follow
|
| 199 |
+
must not 500.
|
| 200 |
+
"""
|
| 201 |
+
import app.qdrant_svc as qs
|
| 202 |
+
|
| 203 |
+
async def boom(ids):
|
| 204 |
+
raise RuntimeError("qdrant down")
|
| 205 |
+
|
| 206 |
+
monkeypatch.setattr(qs, "get_paper_vectors", boom)
|
| 207 |
+
|
| 208 |
+
r = client.post("/api/collections/alpha/follow")
|
| 209 |
+
assert r.status_code == 200
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_unfollow_does_not_unsave(client, monkeypatch):
|
| 213 |
+
"""The EWMA has no exact inverse, so unfollowing cannot rewind the profile.
|
| 214 |
+
|
| 215 |
+
Anything else would leave a permanent smudge while claiming to undo.
|
| 216 |
+
"""
|
| 217 |
+
import app.qdrant_svc as qs
|
| 218 |
+
|
| 219 |
+
async def fake_vectors(ids):
|
| 220 |
+
return {i: np.zeros(1024, dtype=np.float32).tolist() for i in ids}
|
| 221 |
+
|
| 222 |
+
monkeypatch.setattr(qs, "get_paper_vectors", fake_vectors)
|
| 223 |
+
|
| 224 |
+
client.post("/api/collections/alpha/follow")
|
| 225 |
+
r = client.post("/api/collections/alpha/unfollow")
|
| 226 |
+
assert r.status_code == 200
|
| 227 |
+
assert "Follow" in r.text
|
| 228 |
+
|
| 229 |
+
import asyncio
|
| 230 |
+
uid = client.cookies.get("arxiv_user_id")
|
| 231 |
+
assert await_len(asyncio, uid) == 6
|
| 232 |
+
assert asyncio.run(db_mod.get_followed_slugs(uid)) == set()
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def await_len(asyncio_mod, uid):
|
| 236 |
+
return len(asyncio_mod.run(
|
| 237 |
+
db_mod.get_user_interactions(uid, event_types=["save"])))
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_unknown_slug_follow_is_not_an_error(client):
|
| 241 |
+
assert client.post("/api/collections/ghost/follow").status_code == 200
|