chi.URLParam returns the raw, still-encoded path segment — unlike the OpenAPI-generated routes, which decode via runtime.BindStyledParameterWithOptions before the handler sees them. Slugs like "document:containers/101-jellyfin" (encoded by the frontend's encodeURIComponent) were arriving undecoded and matching no row. Found via a standalone chi repro, not by patching the live deploy checkout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
287 lines
8.6 KiB
Go
287 lines
8.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// serveKnowledgeContent returns the full markdown body for a document/
|
|
// investigation/runbook entity, by its own entity id or slug. Nothing else
|
|
// exposes knowledge_entities.content — GetEntityKnowledge (below) answers a
|
|
// different question ("what knowledge references THIS entity"), and
|
|
// SearchKnowledge only returns a short ts_headline snippet. The KB detail
|
|
// panel needs the entity's own full content when it IS a knowledge entity.
|
|
func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request) {
|
|
ctx := req.Context()
|
|
// chi.URLParam returns the raw, still-percent-encoded segment (unlike
|
|
// the OpenAPI-generated routes, which decode via
|
|
// runtime.BindStyledParameterWithOptions before reaching the handler) —
|
|
// slugs like "document:containers/101-jellyfin" arrive as
|
|
// "document%3Acontainers%2F101-jellyfin" and must be unescaped here.
|
|
idOrSlug, err := url.PathUnescape(chi.URLParam(req, "id"))
|
|
if err != nil {
|
|
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
|
return
|
|
}
|
|
|
|
var title, content, source string
|
|
var tags []string
|
|
var updatedAt string
|
|
err = s.pool.QueryRow(ctx, `
|
|
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
|
|
Scan(&title, &content, &source, &tags, &updatedAt)
|
|
if err != nil {
|
|
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
|
return
|
|
}
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"title": title,
|
|
"content": content,
|
|
"source": source,
|
|
"tags": tags,
|
|
"updated_at": updatedAt,
|
|
})
|
|
}
|
|
|
|
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.id, 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 id uuid.UUID
|
|
var slug, eType, title, source string
|
|
var tags []string
|
|
var rank float32
|
|
var snippet *string
|
|
|
|
if err := rows.Scan(&id, &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{
|
|
Id: id,
|
|
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.id, 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.id, 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 2`,
|
|
entitySlug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.KnowledgeHit{}
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var slug, eType, title, source string
|
|
var tags []string
|
|
|
|
if err := rows.Scan(&id, &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{
|
|
Id: id,
|
|
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
|
|
} |