2 Commits

Author SHA1 Message Date
0ed171507f Merge remote-tracking branch 'origin/main'
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-12 09:18:23 +02:00
e3cbaee534 fix(knowledge): populate hit id in search results
Search hits and entity-knowledge hits never selected an id column, so
every KnowledgeHit.Id defaulted to the zero UUID. The frontend's keyed
{#each results as hit (hit.id)} then had all-duplicate keys, which
silently broke Svelte 5's if-block branch swap for the results panel —
search would set searched=true (Clear button appeared) but the view
never switched away from "Recently learned". Select e.id in both
queries and key the each block on hit.slug (guaranteed unique) instead.
2026-07-12 09:16:03 +02:00
2 changed files with 12 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/google/uuid"
) )
// serveRecentKnowledge backs the Knowledge page's "what the system knows / has // serveRecentKnowledge backs the Knowledge page's "what the system knows / has
@@ -111,7 +112,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
limit := clampLimit(request.Params.Limit) limit := clampLimit(request.Params.Limit)
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags, 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_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1), ts_headline('english', ke.content, plainto_tsquery('english', $1),
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3, 'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
@@ -131,12 +132,13 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
items := []gen.KnowledgeHit{} items := []gen.KnowledgeHit{}
for rows.Next() { for rows.Next() {
var id uuid.UUID
var slug, eType, title, source string var slug, eType, title, source string
var tags []string var tags []string
var rank float32 var rank float32
var snippet *string var snippet *string
if err := rows.Scan(&slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil { if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil {
return nil, err return nil, err
} }
@@ -149,6 +151,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
} }
items = append(items, gen.KnowledgeHit{ items = append(items, gen.KnowledgeHit{
Id: id,
Slug: slug, Slug: slug,
Title: title, Title: title,
Type: hitType, Type: hitType,
@@ -172,7 +175,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
entitySlug := request.EntityId entitySlug := request.EntityId
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
FROM knowledge_entities ke FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id JOIN entities e ON e.id = ke.entity_id
JOIN entity_types et ON et.name = e.type JOIN entity_types et ON et.name = e.type
@@ -182,7 +185,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
AND r.valid_to IS NULL AND r.valid_to IS NULL
AND r.type IN ('documents', 'about') AND r.type IN ('documents', 'about')
UNION UNION
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
FROM knowledge_entities ke FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id JOIN entities e ON e.id = ke.entity_id
JOIN entity_types et ON et.name = e.type JOIN entity_types et ON et.name = e.type
@@ -191,7 +194,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL WHERE r.valid_to IS NULL
AND r.type = 'procedure-for' AND r.type = 'procedure-for'
ORDER BY 1`, ORDER BY 2`,
entitySlug) entitySlug)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -200,10 +203,11 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
items := []gen.KnowledgeHit{} items := []gen.KnowledgeHit{}
for rows.Next() { for rows.Next() {
var id uuid.UUID
var slug, eType, title, source string var slug, eType, title, source string
var tags []string var tags []string
if err := rows.Scan(&slug, &eType, &title, &source, &tags); err != nil { if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags); err != nil {
return nil, err return nil, err
} }
@@ -216,6 +220,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
} }
items = append(items, gen.KnowledgeHit{ items = append(items, gen.KnowledgeHit{
Id: id,
Slug: slug, Slug: slug,
Title: title, Title: title,
Type: hitType, Type: hitType,

View File

@@ -115,7 +115,7 @@
<p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'} for "{query}"</p> <p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'} for "{query}"</p>
<ScrollArea class="flex-1"> <ScrollArea class="flex-1">
<div class="flex flex-col gap-3 pr-4"> <div class="flex flex-col gap-3 pr-4">
{#each results as hit (hit.id)} {#each results as hit (hit.slug)}
<Card.Root class="transition-colors hover:bg-muted/50"> <Card.Root class="transition-colors hover:bg-muted/50">
<Card.Header> <Card.Header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">