···498498 db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`)
499499 db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`)
500500501501+ db.Exec(`CREATE TABLE IF NOT EXISTS publications (
502502+ uri TEXT PRIMARY KEY,
503503+ author_did TEXT NOT NULL,
504504+ url TEXT NOT NULL,
505505+ name TEXT NOT NULL,
506506+ description TEXT,
507507+ show_in_discover BOOLEAN NOT NULL DEFAULT true,
508508+ indexed_at ` + dateType + ` NOT NULL
509509+ )`)
510510+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_publications_author ON publications(author_did)`)
511511+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_publications_url ON publications(url)`)
512512+513513+ db.Exec(`CREATE TABLE IF NOT EXISTS documents (
514514+ uri TEXT PRIMARY KEY,
515515+ author_did TEXT NOT NULL,
516516+ site TEXT NOT NULL,
517517+ path TEXT,
518518+ title TEXT NOT NULL,
519519+ description TEXT,
520520+ text_content TEXT,
521521+ tags_json TEXT,
522522+ canonical_url TEXT,
523523+ published_at ` + dateType + ` NOT NULL,
524524+ indexed_at ` + dateType + ` NOT NULL
525525+ )`)
526526+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_author ON documents(author_did)`)
527527+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_site ON documents(site)`)
528528+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_canonical ON documents(canonical_url)`)
529529+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_published ON documents(published_at DESC)`)
530530+501531 db.runMigrations()
502532503533 return nil
+502
backend/internal/db/queries_recommendations.go
···11+package db
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "strconv"
77+ "strings"
88+ "time"
99+)
1010+1111+type Document struct {
1212+ URI string `json:"uri"`
1313+ AuthorDID string `json:"authorDid"`
1414+ Site string `json:"site"`
1515+ Path *string `json:"path,omitempty"`
1616+ Title string `json:"title"`
1717+ Description *string `json:"description,omitempty"`
1818+ TextContent *string `json:"textContent,omitempty"`
1919+ TagsJSON *string `json:"tags,omitempty"`
2020+ CanonicalURL string `json:"canonicalUrl"`
2121+ PublishedAt time.Time `json:"publishedAt"`
2222+ IndexedAt time.Time `json:"indexedAt"`
2323+}
2424+2525+type Publication struct {
2626+ URI string `json:"uri"`
2727+ AuthorDID string `json:"authorDid"`
2828+ URL string `json:"url"`
2929+ Name string `json:"name"`
3030+ Description *string `json:"description,omitempty"`
3131+ ShowInDiscover bool `json:"showInDiscover"`
3232+ IndexedAt time.Time `json:"indexedAt"`
3333+}
3434+3535+type DocumentEmbedding struct {
3636+ DocumentURI string `json:"documentUri"`
3737+ Embedding []float32 `json:"embedding"`
3838+ UpdatedAt time.Time `json:"updatedAt"`
3939+}
4040+4141+type AnnotationEmbedding struct {
4242+ AnnotationURI string `json:"annotationUri"`
4343+ AuthorDID string `json:"authorDid"`
4444+ DocumentURI *string `json:"documentUri,omitempty"`
4545+ Embedding []float32 `json:"embedding"`
4646+ UpdatedAt time.Time `json:"updatedAt"`
4747+}
4848+4949+type UserProfile struct {
5050+ AuthorDID string `json:"authorDid"`
5151+ Embedding []float32 `json:"embedding"`
5252+ TagAffinities string `json:"tagAffinities"`
5353+ AnnotationCount int `json:"annotationCount"`
5454+ UpdatedAt time.Time `json:"updatedAt"`
5555+}
5656+5757+func (db *DB) MigrateRecommendations() error {
5858+ dateType := "TIMESTAMP"
5959+ if db.driver == "sqlite3" {
6060+ dateType = "DATETIME"
6161+ }
6262+6363+ _, err := db.Exec(`
6464+ CREATE TABLE IF NOT EXISTS document_embeddings (
6565+ document_uri TEXT PRIMARY KEY,
6666+ embedding TEXT NOT NULL,
6767+ updated_at ` + dateType + ` NOT NULL
6868+ )`)
6969+ if err != nil {
7070+ return fmt.Errorf("create document_embeddings table: %w", err)
7171+ }
7272+7373+ _, err = db.Exec(`
7474+ CREATE TABLE IF NOT EXISTS annotation_embeddings (
7575+ annotation_uri TEXT PRIMARY KEY,
7676+ author_did TEXT NOT NULL,
7777+ document_uri TEXT,
7878+ embedding TEXT NOT NULL,
7979+ updated_at ` + dateType + ` NOT NULL
8080+ )`)
8181+ if err != nil {
8282+ return fmt.Errorf("create annotation_embeddings table: %w", err)
8383+ }
8484+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_ann_emb_author ON annotation_embeddings(author_did)`)
8585+ db.Exec(`CREATE INDEX IF NOT EXISTS idx_ann_emb_document ON annotation_embeddings(document_uri)`)
8686+8787+ _, err = db.Exec(`
8888+ CREATE TABLE IF NOT EXISTS user_profiles (
8989+ author_did TEXT PRIMARY KEY,
9090+ embedding TEXT NOT NULL,
9191+ tag_affinities TEXT DEFAULT '{}',
9292+ annotation_count INTEGER NOT NULL DEFAULT 0,
9393+ updated_at ` + dateType + ` NOT NULL
9494+ )`)
9595+ if err != nil {
9696+ return fmt.Errorf("create user_profiles table: %w", err)
9797+ }
9898+9999+ return nil
100100+}
101101+102102+func (db *DB) UpsertPublication(p *Publication) error {
103103+ query := `
104104+ INSERT INTO publications (uri, author_did, url, name, description, show_in_discover, indexed_at)
105105+ VALUES ($1, $2, $3, $4, $5, $6, $7)
106106+ ON CONFLICT(uri) DO UPDATE SET
107107+ name = EXCLUDED.name,
108108+ description = EXCLUDED.description,
109109+ show_in_discover = EXCLUDED.show_in_discover,
110110+ indexed_at = EXCLUDED.indexed_at
111111+ `
112112+ _, err := db.Exec(query, p.URI, p.AuthorDID, p.URL, p.Name, p.Description, p.ShowInDiscover, p.IndexedAt)
113113+ return err
114114+}
115115+116116+func (db *DB) DeletePublication(uri string) error {
117117+ _, err := db.Exec("DELETE FROM publications WHERE uri = $1", uri)
118118+ return err
119119+}
120120+121121+func (db *DB) GetPublicationByURL(url string) (*Publication, error) {
122122+ var p Publication
123123+ err := db.QueryRow(
124124+ "SELECT uri, author_did, url, name, description, show_in_discover, indexed_at FROM publications WHERE url = $1",
125125+ url,
126126+ ).Scan(&p.URI, &p.AuthorDID, &p.URL, &p.Name, &p.Description, &p.ShowInDiscover, &p.IndexedAt)
127127+ if err != nil {
128128+ return nil, err
129129+ }
130130+ return &p, nil
131131+}
132132+133133+func (db *DB) UpsertDocument(d *Document) error {
134134+ query := `
135135+ INSERT INTO documents (uri, author_did, site, path, title, description, text_content, tags_json, canonical_url, published_at, indexed_at)
136136+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
137137+ ON CONFLICT(uri) DO UPDATE SET
138138+ title = EXCLUDED.title,
139139+ description = EXCLUDED.description,
140140+ text_content = EXCLUDED.text_content,
141141+ tags_json = EXCLUDED.tags_json,
142142+ canonical_url = EXCLUDED.canonical_url,
143143+ indexed_at = EXCLUDED.indexed_at
144144+ `
145145+ _, err := db.Exec(query, d.URI, d.AuthorDID, d.Site, d.Path, d.Title, d.Description, d.TextContent, d.TagsJSON, d.CanonicalURL, d.PublishedAt, d.IndexedAt)
146146+ return err
147147+}
148148+149149+func (db *DB) DeleteDocument(uri string) error {
150150+ _, err := db.Exec("DELETE FROM documents WHERE uri = $1", uri)
151151+ return err
152152+}
153153+154154+func (db *DB) GetDocumentByCanonicalURL(canonicalURL string) (*Document, error) {
155155+ var d Document
156156+ err := db.QueryRow(
157157+ `SELECT uri, author_did, site, path, title, description, text_content, tags_json, canonical_url, published_at, indexed_at
158158+ FROM documents WHERE canonical_url = $1`,
159159+ canonicalURL,
160160+ ).Scan(&d.URI, &d.AuthorDID, &d.Site, &d.Path, &d.Title, &d.Description, &d.TextContent, &d.TagsJSON, &d.CanonicalURL, &d.PublishedAt, &d.IndexedAt)
161161+ if err != nil {
162162+ return nil, err
163163+ }
164164+ return &d, nil
165165+}
166166+167167+func (db *DB) GetDocumentByURI(uri string) (*Document, error) {
168168+ var d Document
169169+ err := db.QueryRow(
170170+ `SELECT uri, author_did, site, path, title, description, text_content, tags_json, canonical_url, published_at, indexed_at
171171+ FROM documents WHERE uri = $1`,
172172+ uri,
173173+ ).Scan(&d.URI, &d.AuthorDID, &d.Site, &d.Path, &d.Title, &d.Description, &d.TextContent, &d.TagsJSON, &d.CanonicalURL, &d.PublishedAt, &d.IndexedAt)
174174+ if err != nil {
175175+ return nil, err
176176+ }
177177+ return &d, nil
178178+}
179179+180180+func (db *DB) GetDocumentsWithoutEmbeddings(limit int) ([]Document, error) {
181181+ rows, err := db.Query(db.Rebind(`
182182+ SELECT d.uri, d.author_did, d.site, d.path, d.title, d.description, d.text_content, d.tags_json, d.canonical_url, d.published_at, d.indexed_at
183183+ FROM documents d
184184+ LEFT JOIN document_embeddings de ON d.uri = de.document_uri
185185+ WHERE de.document_uri IS NULL
186186+ ORDER BY d.indexed_at DESC
187187+ LIMIT ?
188188+ `), limit)
189189+ if err != nil {
190190+ return nil, err
191191+ }
192192+ defer rows.Close()
193193+ return scanDocuments(rows)
194194+}
195195+196196+func (db *DB) GetAnnotationsWithoutEmbeddings(limit int) ([]Annotation, error) {
197197+ rows, err := db.Query(db.Rebind(`
198198+ SELECT a.uri, a.author_did, a.motivation, a.body_value, a.body_format, a.body_uri, a.target_source, a.target_hash, a.target_title, a.selector_json, a.tags_json, a.created_at, a.indexed_at, a.cid
199199+ FROM annotations a
200200+ LEFT JOIN annotation_embeddings ae ON a.uri = ae.annotation_uri
201201+ WHERE ae.annotation_uri IS NULL AND a.motivation IN ('commenting', 'highlighting')
202202+ ORDER BY a.created_at DESC
203203+ LIMIT ?
204204+ `), limit)
205205+ if err != nil {
206206+ return nil, err
207207+ }
208208+ defer rows.Close()
209209+ return scanAnnotations(rows)
210210+}
211211+212212+type HighlightForEmbedding struct {
213213+ URI string
214214+ AuthorDID string
215215+ TargetSource string
216216+ TargetTitle *string
217217+ SelectorJSON *string
218218+ TagsJSON *string
219219+}
220220+221221+func (db *DB) GetHighlightsWithoutEmbeddings(limit int) ([]HighlightForEmbedding, error) {
222222+ rows, err := db.Query(db.Rebind(`
223223+ SELECT h.uri, h.author_did, h.target_source, h.target_title, h.selector_json, h.tags_json
224224+ FROM highlights h
225225+ LEFT JOIN annotation_embeddings ae ON h.uri = ae.annotation_uri
226226+ WHERE ae.annotation_uri IS NULL
227227+ ORDER BY h.created_at DESC
228228+ LIMIT ?
229229+ `), limit)
230230+ if err != nil {
231231+ return nil, err
232232+ }
233233+ defer rows.Close()
234234+235235+ var results []HighlightForEmbedding
236236+ for rows.Next() {
237237+ var h HighlightForEmbedding
238238+ if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetTitle, &h.SelectorJSON, &h.TagsJSON); err != nil {
239239+ return nil, err
240240+ }
241241+ results = append(results, h)
242242+ }
243243+ return results, nil
244244+}
245245+246246+func (db *DB) GetDistinctAnnotationAuthors() ([]string, error) {
247247+ rows, err := db.Query(`SELECT DISTINCT author_did FROM annotation_embeddings`)
248248+ if err != nil {
249249+ return nil, err
250250+ }
251251+ defer rows.Close()
252252+ var dids []string
253253+ for rows.Next() {
254254+ var did string
255255+ if err := rows.Scan(&did); err != nil {
256256+ return nil, err
257257+ }
258258+ dids = append(dids, did)
259259+ }
260260+ return dids, nil
261261+}
262262+263263+func scanDocuments(rows interface {
264264+ Next() bool
265265+ Scan(...interface{}) error
266266+}) ([]Document, error) {
267267+ var docs []Document
268268+ for rows.Next() {
269269+ var d Document
270270+ if err := rows.Scan(&d.URI, &d.AuthorDID, &d.Site, &d.Path, &d.Title, &d.Description, &d.TextContent, &d.TagsJSON, &d.CanonicalURL, &d.PublishedAt, &d.IndexedAt); err != nil {
271271+ return nil, err
272272+ }
273273+ docs = append(docs, d)
274274+ }
275275+ return docs, nil
276276+}
277277+278278+func (db *DB) GetRecentDocuments(limit, offset int) ([]Document, error) {
279279+ rows, err := db.Query(db.Rebind(`
280280+ SELECT uri, author_did, site, path, title, description, text_content, tags_json, canonical_url, published_at, indexed_at
281281+ FROM documents
282282+ ORDER BY published_at DESC
283283+ LIMIT ? OFFSET ?
284284+ `), limit, offset)
285285+ if err != nil {
286286+ return nil, err
287287+ }
288288+ defer rows.Close()
289289+ return scanDocuments(rows)
290290+}
291291+292292+func (db *DB) GetPopularDocuments(limit, offset int) ([]Document, error) {
293293+ rows, err := db.Query(db.Rebind(`
294294+ SELECT d.uri, d.author_did, d.site, d.path, d.title, d.description, d.text_content, d.tags_json, d.canonical_url, d.published_at, d.indexed_at
295295+ FROM documents d
296296+ LEFT JOIN annotations a ON a.target_source = d.canonical_url
297297+ GROUP BY d.uri
298298+ ORDER BY COUNT(a.uri) DESC, d.published_at DESC
299299+ LIMIT ? OFFSET ?
300300+ `), limit, offset)
301301+ if err != nil {
302302+ return nil, err
303303+ }
304304+ defer rows.Close()
305305+ return scanDocuments(rows)
306306+}
307307+308308+func (db *DB) GetDocumentCount() (int, error) {
309309+ var count int
310310+ err := db.QueryRow("SELECT COUNT(*) FROM documents").Scan(&count)
311311+ return count, err
312312+}
313313+314314+func (db *DB) UpsertDocumentEmbedding(documentURI string, embedding []float32) error {
315315+ vecStr := float32SliceToVectorString(embedding)
316316+ _, err := db.Exec(
317317+ `INSERT INTO document_embeddings (document_uri, embedding, updated_at) VALUES ($1, $2, $3)
318318+ ON CONFLICT(document_uri) DO UPDATE SET embedding = EXCLUDED.embedding, updated_at = EXCLUDED.updated_at`,
319319+ documentURI, vecStr, time.Now(),
320320+ )
321321+ return err
322322+}
323323+324324+func (db *DB) UpsertAnnotationEmbedding(annotationURI, authorDID string, documentURI *string, embedding []float32) error {
325325+ vecStr := float32SliceToVectorString(embedding)
326326+ _, err := db.Exec(
327327+ `INSERT INTO annotation_embeddings (annotation_uri, author_did, document_uri, embedding, updated_at) VALUES ($1, $2, $3, $4, $5)
328328+ ON CONFLICT(annotation_uri) DO UPDATE SET embedding = EXCLUDED.embedding, document_uri = EXCLUDED.document_uri, updated_at = EXCLUDED.updated_at`,
329329+ annotationURI, authorDID, documentURI, vecStr, time.Now(),
330330+ )
331331+ return err
332332+}
333333+334334+func (db *DB) DeleteAnnotationEmbedding(annotationURI string) error {
335335+ _, err := db.Exec("DELETE FROM annotation_embeddings WHERE annotation_uri = $1", annotationURI)
336336+ return err
337337+}
338338+339339+func (db *DB) UpsertUserProfile(authorDID string, embedding []float32, tagAffinities map[string]float64, annotationCount int) error {
340340+ vecStr := float32SliceToVectorString(embedding)
341341+ tagsJSON, _ := json.Marshal(tagAffinities)
342342+ _, err := db.Exec(
343343+ `INSERT INTO user_profiles (author_did, embedding, tag_affinities, annotation_count, updated_at) VALUES ($1, $2, $3, $4, $5)
344344+ ON CONFLICT(author_did) DO UPDATE SET embedding = EXCLUDED.embedding, tag_affinities = EXCLUDED.tag_affinities, annotation_count = EXCLUDED.annotation_count, updated_at = EXCLUDED.updated_at`,
345345+ authorDID, vecStr, string(tagsJSON), annotationCount, time.Now(),
346346+ )
347347+ return err
348348+}
349349+350350+func (db *DB) GetUserProfile(authorDID string) (*UserProfile, error) {
351351+ var p UserProfile
352352+ var embStr string
353353+ err := db.QueryRow(
354354+ `SELECT author_did, embedding, tag_affinities, annotation_count, updated_at FROM user_profiles WHERE author_did = $1`,
355355+ authorDID,
356356+ ).Scan(&p.AuthorDID, &embStr, &p.TagAffinities, &p.AnnotationCount, &p.UpdatedAt)
357357+ if err != nil {
358358+ return nil, err
359359+ }
360360+ p.Embedding = parseVectorString(embStr)
361361+ return &p, nil
362362+}
363363+364364+func (db *DB) GetAnnotationEmbeddingsByAuthor(authorDID string) ([]AnnotationEmbedding, error) {
365365+ rows, err := db.Query(
366366+ `SELECT annotation_uri, author_did, document_uri, embedding, updated_at FROM annotation_embeddings WHERE author_did = $1`,
367367+ authorDID,
368368+ )
369369+ if err != nil {
370370+ return nil, err
371371+ }
372372+ defer rows.Close()
373373+374374+ var results []AnnotationEmbedding
375375+ for rows.Next() {
376376+ var ae AnnotationEmbedding
377377+ var embStr string
378378+ if err := rows.Scan(&ae.AnnotationURI, &ae.AuthorDID, &ae.DocumentURI, &embStr, &ae.UpdatedAt); err != nil {
379379+ return nil, err
380380+ }
381381+ ae.Embedding = parseVectorString(embStr)
382382+ results = append(results, ae)
383383+ }
384384+ return results, nil
385385+}
386386+387387+func (db *DB) GetRecentAnnotationEmbeddingsByAuthor(authorDID string, limit int) ([]AnnotationEmbedding, error) {
388388+ rows, err := db.Query(
389389+ db.Rebind(`SELECT annotation_uri, author_did, document_uri, embedding, updated_at FROM annotation_embeddings WHERE author_did = ? ORDER BY updated_at DESC LIMIT ?`),
390390+ authorDID, limit,
391391+ )
392392+ if err != nil {
393393+ return nil, err
394394+ }
395395+ defer rows.Close()
396396+397397+ var results []AnnotationEmbedding
398398+ for rows.Next() {
399399+ var ae AnnotationEmbedding
400400+ var embStr string
401401+ if err := rows.Scan(&ae.AnnotationURI, &ae.AuthorDID, &ae.DocumentURI, &embStr, &ae.UpdatedAt); err != nil {
402402+ return nil, err
403403+ }
404404+ ae.Embedding = parseVectorString(embStr)
405405+ results = append(results, ae)
406406+ }
407407+ return results, nil
408408+}
409409+410410+type CandidateDocument struct {
411411+ URI string `json:"uri"`
412412+ AuthorDID string `json:"authorDid"`
413413+ Site string `json:"site"`
414414+ Path *string `json:"path,omitempty"`
415415+ Title string `json:"title"`
416416+ Description *string `json:"description,omitempty"`
417417+ TagsJSON *string `json:"tags,omitempty"`
418418+ CanonicalURL string `json:"canonicalUrl"`
419419+ PublishedAt time.Time `json:"publishedAt"`
420420+ Embedding []float32 `json:"-"`
421421+ Engagement int `json:"engagement"`
422422+}
423423+424424+func (db *DB) GetCandidateDocuments(userDID string, limit int) ([]CandidateDocument, error) {
425425+ rows, err := db.Query(db.Rebind(`
426426+ SELECT
427427+ d.uri, d.author_did, d.site, d.path, d.title, d.description, d.tags_json,
428428+ d.canonical_url, d.published_at, de.embedding,
429429+ COALESCE(eng.cnt, 0) AS engagement
430430+ FROM documents d
431431+ JOIN document_embeddings de ON d.uri = de.document_uri
432432+ LEFT JOIN (
433433+ SELECT document_uri, COUNT(DISTINCT author_did) AS cnt
434434+ FROM annotation_embeddings
435435+ WHERE document_uri IS NOT NULL
436436+ GROUP BY document_uri
437437+ ) eng ON eng.document_uri = d.uri
438438+ LEFT JOIN publications p ON d.site = p.uri OR d.site = p.url
439439+ WHERE d.author_did != ?
440440+ AND (p.show_in_discover IS NULL OR p.show_in_discover = true)
441441+ AND LENGTH(d.title) > 15
442442+ AND (LENGTH(COALESCE(d.description, '')) >= 30 OR LENGTH(COALESCE(d.text_content, '')) >= 100)
443443+ AND d.title !~* '(^test$|^test\\s|\\stest$|^testing|^hello\\sworld|^untitled|^draft|^asdf|^lorem|^foo$|^bar$|^placeholder)'
444444+ AND d.uri NOT IN (
445445+ SELECT DISTINCT document_uri FROM annotation_embeddings
446446+ WHERE author_did = ? AND document_uri IS NOT NULL
447447+ )
448448+ ORDER BY d.published_at DESC
449449+ LIMIT ?
450450+ `), userDID, userDID, limit)
451451+ if err != nil {
452452+ return nil, fmt.Errorf("candidate query: %w", err)
453453+ }
454454+ defer rows.Close()
455455+456456+ var results []CandidateDocument
457457+ for rows.Next() {
458458+ var c CandidateDocument
459459+ var embStr string
460460+ if err := rows.Scan(
461461+ &c.URI, &c.AuthorDID, &c.Site, &c.Path, &c.Title, &c.Description,
462462+ &c.TagsJSON, &c.CanonicalURL, &c.PublishedAt, &embStr, &c.Engagement,
463463+ ); err != nil {
464464+ return nil, err
465465+ }
466466+ c.Embedding = parseVectorString(embStr)
467467+ results = append(results, c)
468468+ }
469469+ return results, nil
470470+}
471471+472472+func (db *DB) MatchAnnotationToDocument(targetSource string) (*string, error) {
473473+ var uri string
474474+ err := db.QueryRow(`SELECT uri FROM documents WHERE canonical_url = $1`, targetSource).Scan(&uri)
475475+ if err != nil {
476476+ return nil, err
477477+ }
478478+ return &uri, nil
479479+}
480480+481481+func float32SliceToVectorString(v []float32) string {
482482+ parts := make([]string, len(v))
483483+ for i, f := range v {
484484+ parts[i] = fmt.Sprintf("%g", f)
485485+ }
486486+ return "[" + strings.Join(parts, ",") + "]"
487487+}
488488+489489+func parseVectorString(s string) []float32 {
490490+ s = strings.TrimPrefix(s, "[")
491491+ s = strings.TrimSuffix(s, "]")
492492+ if s == "" {
493493+ return nil
494494+ }
495495+ parts := strings.Split(s, ",")
496496+ result := make([]float32, len(parts))
497497+ for i, p := range parts {
498498+ f, _ := strconv.ParseFloat(strings.TrimSpace(p), 32)
499499+ result[i] = float32(f)
500500+ }
501501+ return result
502502+}
···11111212 <div class="prose prose-surface dark:prose-invert max-w-none">
1313 <h1 class="font-display font-bold text-3xl mb-2 text-surface-900 dark:text-white">Privacy Policy</h1>
1414- <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: February 24, 2026</p>
1414+ <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: March 4, 2026</p>
15151616 <section class="mb-8">
1717 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Overview</h2>
···4242 <li>Collections you organize content into</li>
4343 </ul>
44444545+ <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Discover & Recommendations</h3>
4646+ <p class="text-surface-700 dark:text-surface-300 mb-4">
4747+ To power the Discover page and personalized recommendations, we generate mathematical representations (embeddings) of:
4848+ </p>
4949+ <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1">
5050+ <li>Your annotations, highlights, and their associated tags</li>
5151+ <li>Publicly published documents from the AT Protocol network</li>
5252+ </ul>
5353+ <p class="text-surface-700 dark:text-surface-300 mb-4">
5454+ These embeddings are used to build an interest profile that helps us suggest relevant content. Your interest profile is stored on our server and is not shared with other users.
5555+ </p>
5656+4557 <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Authentication</h3>
4658 <p class="text-surface-700 dark:text-surface-300 mb-4">
4759 We store OAuth session tokens locally in your browser to keep you logged in. These tokens are used solely for authenticating API requests.
···5668 <li>Sync your content across devices</li>
5769 <li>Show your public annotations to other users</li>
5870 <li>Enable social features like replies and likes</li>
7171+ <li>Generate personalized content recommendations on the Discover page</li>
5972 </ul>
7373+7474+ <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Third-Party Services</h3>
7575+ <p class="text-surface-700 dark:text-surface-300 mb-4">
7676+ We use <strong>OpenAI</strong> to generate text embeddings for powering recommendations. When generating embeddings, the text content of your annotations and public documents is sent to OpenAI's API. OpenAI processes this data according to their <a href="https://openai.com/policies/api-data-usage-policies" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline" target="_blank" rel="noopener noreferrer">API data usage policy</a>, which states that API inputs are not used to train their models. No other third-party services receive your data.
7777+ </p>
6078 </section>
61796280 <section class="mb-8">