···7788Add a Constellation client to the Go API for enriching search results with social signals.
991010-- [ ] Constellation XRPC client (`internal/constellation/`) with `getBacklinksCount` and `getBacklinks`
1111-- [ ] User-agent header with project name and contact
1212-- [ ] Enrich search results with star counts from Constellation
1313-- [ ] Profile summary endpoint (`GET /profiles/{did}/summary`) with follower/following counts from Constellation
1414-- [ ] Cache Constellation responses with short TTL (star/follower counts change infrequently)
1010+- [x] Constellation XRPC client (`internal/constellation/`) with `getBacklinksCount` and `getBacklinks`
1111+- [x] User-agent header with project name and contact
1212+- [x] Enrich search results with star counts from Constellation
1313+- [x] Profile summary endpoint (`GET /profiles/{did}/summary`) with follower/following counts from Constellation
1414+- [x] Cache Constellation responses with short TTL (star/follower counts change infrequently)
15151616## API: Semantic Search Pipeline
1717
+78-9
packages/api/internal/api/api.go
···88 "net/http"
99 "strconv"
1010 "strings"
1111+ "sync"
1112 "time"
12131314 "tangled.org/desertthunder.dev/twister/internal/config"
1515+ "tangled.org/desertthunder.dev/twister/internal/constellation"
1416 "tangled.org/desertthunder.dev/twister/internal/reindex"
1517 "tangled.org/desertthunder.dev/twister/internal/search"
1618 "tangled.org/desertthunder.dev/twister/internal/store"
···19212022// Server is the HTTP search API server.
2123type Server struct {
2222- search *search.Repository
2323- store store.Store
2424- cfg *config.Config
2525- log *slog.Logger
2424+ search *search.Repository
2525+ store store.Store
2626+ cfg *config.Config
2727+ log *slog.Logger
2828+ constellation *constellation.Client
2629}
27302831// New creates a new API server.
2929-func New(searchRepo *search.Repository, st store.Store, cfg *config.Config, log *slog.Logger) *Server {
3232+func New(searchRepo *search.Repository, st store.Store, cfg *config.Config, log *slog.Logger, constellation *constellation.Client) *Server {
3033 return &Server{
3131- search: searchRepo,
3232- store: st,
3333- cfg: cfg,
3434- log: log,
3434+ search: searchRepo,
3535+ store: st,
3636+ cfg: cfg,
3737+ log: log,
3838+ constellation: constellation,
3539 }
3640}
3741···4751 mux.HandleFunc("GET /search/hybrid", s.handleNotImplemented)
48524953 mux.HandleFunc("GET /documents/{id}", s.handleGetDocument)
5454+ mux.HandleFunc("GET /profiles/{did}/summary", s.handleProfileSummary)
50555156 if s.cfg.EnableAdminEndpoints {
5257 mux.HandleFunc("POST /admin/reindex", s.handleAdminReindex)
···213218 return
214219 }
215220221221+ if s.constellation != nil {
222222+ s.enrichStarCounts(r.Context(), resp.Results)
223223+ }
224224+216225 writeJSON(w, http.StatusOK, resp)
217226}
218227···280289 "updated": result.Updated,
281290 "errors": result.Errors,
282291 })
292292+}
293293+294294+// enrichStarCounts fetches star counts from Constellation for repo results in parallel.
295295+// It is best-effort: failures are logged and results are returned without star counts.
296296+//
297297+// Uses a short deadline so enrichment doesn't stall the response.
298298+func (s *Server) enrichStarCounts(ctx context.Context, results []search.Result) {
299299+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
300300+ defer cancel()
301301+302302+ var wg sync.WaitGroup
303303+ for i := range results {
304304+ if results[i].RecordType != "repo" || results[i].ATURI == "" {
305305+ continue
306306+ }
307307+ wg.Add(1)
308308+ go func(i int) {
309309+ defer wg.Done()
310310+ n, err := s.constellation.GetBacklinksCount(ctx, constellation.BacklinksParams{
311311+ Subject: results[i].ATURI,
312312+ Source: constellation.SourceStarURI,
313313+ })
314314+ if err != nil {
315315+ s.log.Debug("constellation star count failed", slog.String("at_uri", results[i].ATURI), slog.String("error", err.Error()))
316316+ return
317317+ }
318318+ results[i].StarCount = &n
319319+ }(i)
320320+ }
321321+ wg.Wait()
322322+}
323323+324324+// handleProfileSummary returns follower count and other social signals for a DID.
325325+func (s *Server) handleProfileSummary(w http.ResponseWriter, r *http.Request) {
326326+ did := r.PathValue("did")
327327+ if did == "" {
328328+ writeJSON(w, http.StatusBadRequest, errorBody("invalid_parameter", "did is required"))
329329+ return
330330+ }
331331+332332+ type summaryResponse struct {
333333+ DID string `json:"did"`
334334+ FollowerCount int `json:"follower_count"`
335335+ }
336336+337337+ summary := summaryResponse{DID: did}
338338+339339+ if s.constellation != nil {
340340+ n, err := s.constellation.GetBacklinksCount(r.Context(), constellation.BacklinksParams{
341341+ Subject: did,
342342+ Source: constellation.SourceFollowDID,
343343+ })
344344+ if err != nil {
345345+ s.log.Debug("constellation follower count failed", slog.String("did", did), slog.String("error", err.Error()))
346346+ } else {
347347+ summary.FollowerCount = n
348348+ }
349349+ }
350350+351351+ writeJSON(w, http.StatusOK, summary)
283352}
284353285354func (s *Server) handleNotImplemented(w http.ResponseWriter, _ *http.Request) {
···29293030// Runner performs the enrichment operation.
3131type Runner struct {
3232- store store.Store
3333- xrpc *xrpc.Client
3434- log *slog.Logger
3232+ store store.Store
3333+ xrpc *xrpc.Client
3434+ log *slog.Logger
3535}
36363737// New creates a Runner.
···143143func (r *Runner) enrichDoc(ctx context.Context, doc *store.Document) bool {
144144 changed := false
145145146146- // Resolve author handle
147146 if doc.AuthorHandle == "" && doc.DID != "" {
148147 handle, err := r.store.GetIdentityHandle(ctx, doc.DID)
149148 if err == nil && handle != "" {
···163162 }
164163 }
165164166166- // Resolve repo name for repo-scoped records
167165 if doc.RepoDID != "" && doc.RepoName == "" {
168168- // Try to find the repo name from an existing repo document in the store
169166 repoName := r.findRepoNameFromStore(ctx, doc.RepoDID)
170167 if repoName != "" {
171168 doc.RepoName = repoName
···173170 }
174171 }
175172176176- // Resolve repo owner handle for WebURL
177173 ownerHandle := doc.AuthorHandle
178174 if doc.RepoDID != "" && doc.RepoDID != doc.DID {
179175 repoOwnerHandle, err := r.store.GetIdentityHandle(ctx, doc.RepoDID)
···187183 }
188184 }
189185190190- // Build WebURL
191186 if doc.WebURL == "" {
192187 webURL := xrpc.BuildWebURL(ownerHandle, doc.RepoName, doc.RecordType, doc.RKey)
193188 if webURL != "" {
···209204 if err != nil || len(docs) == 0 {
210205 return ""
211206 }
212212- // Return the first repo's title (which is the repo name)
213207 for _, d := range docs {
214208 if d.Title != "" {
215209 return d.Title
-12
packages/api/internal/ingest/ingest.go
···403403 return
404404 }
405405406406- // Resolve repo name if RepoDID is set but RepoName is empty
407406 if doc.RepoDID != "" && doc.RepoName == "" {
408408- // Extract the repo rkey from the RepoDID — the repo AT-URI typically encodes
409409- // the rkey as the last segment. We try to look up the repo record.
410410- // The RepoDID in the document refers to the repo owner's DID. The repo rkey
411411- // can be extracted from the document's AT-URI for repo-scoped records.
412407 repoRKey := extractRepoRKey(doc.ATURI, doc.Collection)
413408 if repoRKey != "" {
414409 name, err := r.xrpcClient.ResolveRepoName(ctx, doc.RepoDID, repoRKey)
···424419 }
425420 }
426421427427- // Resolve author handle if empty
428422 if doc.AuthorHandle == "" && doc.DID != "" {
429423 info, err := r.xrpcClient.ResolveIdentity(ctx, doc.DID)
430424 if err != nil {
···438432 }
439433 }
440434441441- // Build WebURL if we have enough data
442435 if doc.WebURL == "" {
443436 ownerHandle := doc.AuthorHandle
444437 if doc.RepoDID != "" && doc.RepoDID != doc.DID {
445445- // Repo owner may differ from author — try to resolve repo owner handle
446438 repoOwnerHandle, err := r.store.GetIdentityHandle(ctx, doc.RepoDID)
447439 if err == nil && repoOwnerHandle != "" {
448440 ownerHandle = repoOwnerHandle
···462454// at://did/collection/rkey but the repo is identified by RepoDID. We look
463455// for a stored repo document, or try common rkey patterns.
464456func extractRepoRKey(atURI, collection string) string {
465465- // For repo records themselves, the rkey IS the repo rkey
466457 if collection == "sh.tangled.repo" {
467458 parts := strings.SplitN(atURI, "/", 5)
468459 if len(parts) >= 5 {
469460 return parts[4]
470461 }
471462 }
472472- // For sub-collections like sh.tangled.repo.issue, the AT-URI contains the
473473- // issue rkey, not the repo rkey. We can't derive the repo rkey from the URI.
474474- // This will be resolved by the enrich command for existing documents.
475463 return ""
476464}
477465