package httpapi import ( "context" "encoding/json" "log/slog" "net/http" "strconv" "github.com/dtoro/oikos/internal/httpapi/gen" ) // serveRecentKnowledge backs the Knowledge page's "what the system knows / has // learned" view (a custom route, not part of the generated OpenAPI surface). // It returns recency-ordered knowledge with a small stats header so the // operator can literally watch the knowledge base grow — especially the notes // Nomos writes itself via upsert_knowledge (source='nomos-agent'), which is // the concrete evidence of "the system is getting better." Optional ?source= // and ?limit= query params. func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request) { ctx := req.Context() limit := 50 if l := req.URL.Query().Get("limit"); l != "" { if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 { limit = n } } source := req.URL.Query().Get("source") // "" = all, "nomos-agent" = agent-authored only type item struct { Slug string `json:"slug"` Title string `json:"title"` Kind string `json:"kind"` Source string `json:"source"` Tags []string `json:"tags"` UpdatedAt string `json:"updated_at"` AgentAuthored bool `json:"agent_authored"` } // updated_at is cast to text in SQL — pgx v5 can't scan a timestamptz // directly into a Go string (needs time.Time or an explicit cast), and // that scan error was being silently swallowed below (every row skipped, // endpoint returned 200 with an empty list and correct-looking stats // since the stats query doesn't scan any timestamp column — found live). rows, err := s.pool.Query(ctx, ` SELECT e.slug, ke.title, e.type, COALESCE(ke.source,''), ke.tags, ke.updated_at::text FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE ($1 = '' OR ke.source = $1) ORDER BY ke.updated_at DESC LIMIT $2`, source, limit) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) return } defer rows.Close() items := []item{} for rows.Next() { var it item var src string if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &src, &it.Tags, &it.UpdatedAt); err != nil { slog.Error("httpapi: knowledge/recent row scan failed", "error", err) continue } it.Source = src it.AgentAuthored = src == "nomos-agent" if it.Tags == nil { it.Tags = []string{} } items = append(items, it) } // Stats header: total, by kind, agent-authored, and how many changed in the // last 7 days (the "still learning" signal). var total, agentAuthored, last7d int byKind := map[string]int{} srows, err := s.pool.Query(ctx, ` SELECT e.type, COUNT(*), COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'), COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days') FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id GROUP BY e.type`) if err == nil { defer srows.Close() for srows.Next() { var kind string var c, a, l int if srows.Scan(&kind, &c, &a, &l) == nil { byKind[kind] = c total += c agentAuthored += a last7d += l } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "stats": map[string]any{ "total": total, "by_kind": byKind, "agent_authored": agentAuthored, "last_7d": last7d, }, "items": items, }) } func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) { q := request.Params.Q limit := clampLimit(request.Params.Limit) rows, err := s.pool.Query(ctx, ` SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags, ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, ts_headline('english', ke.content, plainto_tsquery('english', $1), 'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3, FragmentDelimiter=" ... "') AS snippet FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id JOIN entity_types et ON et.name = e.type WHERE ke.search @@ plainto_tsquery('english', $1) ORDER BY rank DESC LIMIT $2`, q, limit) if err != nil { return nil, err } defer rows.Close() items := []gen.KnowledgeHit{} for rows.Next() { var slug, eType, title, source string var tags []string var rank float32 var snippet *string if err := rows.Scan(&slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil { return nil, err } hitType := gen.Document switch eType { case "investigation": hitType = gen.Investigation case "runbook": hitType = gen.Runbook } items = append(items, gen.KnowledgeHit{ Slug: slug, Title: title, Type: hitType, Rank: &rank, Snippet: snippet, SourcePath: &source, }) } if rows.Err() != nil { return nil, rows.Err() } if items == nil { items = []gen.KnowledgeHit{} } return gen.SearchKnowledge200JSONResponse{Items: items}, nil } func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKnowledgeRequestObject) (gen.GetEntityKnowledgeResponseObject, error) { entitySlug := request.EntityId rows, err := s.pool.Query(ctx, ` SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id JOIN entity_types et ON et.name = e.type JOIN relationships r ON r.source_id = ke.entity_id JOIN entities target ON target.id = r.target_id WHERE target.slug = $1 AND r.valid_to IS NULL AND r.type IN ('documents', 'about') UNION SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id JOIN entity_types et ON et.name = e.type JOIN relationships r ON r.source_id = ke.entity_id JOIN entity_types target_type ON target_type.name = r.target_id::text JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 WHERE r.valid_to IS NULL AND r.type = 'procedure-for' ORDER BY 1`, entitySlug) if err != nil { return nil, err } defer rows.Close() items := []gen.KnowledgeHit{} for rows.Next() { var slug, eType, title, source string var tags []string if err := rows.Scan(&slug, &eType, &title, &source, &tags); err != nil { return nil, err } hitType := gen.Document switch eType { case "investigation": hitType = gen.Investigation case "runbook": hitType = gen.Runbook } items = append(items, gen.KnowledgeHit{ Slug: slug, Title: title, Type: hitType, SourcePath: &source, }) } if rows.Err() != nil { return nil, rows.Err() } if items == nil { items = []gen.KnowledgeHit{} } return gen.GetEntityKnowledge200JSONResponse{Items: items}, nil }