siddhm11 Claude Opus 5 commited on
Commit
f283a87
Β·
1 Parent(s): 9a920f8

feat(collections): curated reading tracks that seed the recommender

Browse files

Cold 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 ADDED
@@ -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
app/db.py CHANGED
@@ -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()}
app/main.py CHANGED
@@ -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, health
 
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.
app/routers/collections.py ADDED
@@ -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
app/static/styles.css CHANGED
@@ -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
+ }
app/templates/base.html CHANGED
@@ -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">
app/templates/collection_detail.html ADDED
@@ -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 %}
app/templates/collections.html ADDED
@@ -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 %}
app/templates/partials/follow_button.html ADDED
@@ -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 %}
tests/test_collections.py ADDED
@@ -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