Files
oikos/internal/httpapi/knowledge.go
dtoro 40999b0b40
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
fix: knowledge/recent returned empty items — timestamptz couldn't scan into string
Verified live immediately after deploying: the endpoint returned 200 with
correct-looking stats (total=56, agent_authored=2) but items=[] always,
regardless of limit/source. Root cause: pgx v5 can't scan a timestamptz
column directly into a Go string — Scan() errored on every single row, and
that error was silently swallowed by a bare `continue`, so every row was
dropped with no trace in the logs. Fixed by casting updated_at::text in the
SQL (matching how every other handler in this codebase already returns
timestamps) and logging scan failures instead of swallowing them, so this
class of bug can't hide silently again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:26:48 +02:00

234 lines
6.7 KiB
Go

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
}