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>
234 lines
6.7 KiB
Go
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
|
|
} |