package knowledge import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) type SeedResult struct { Documents int Investigations int Runbooks int } func contentHash(s string) string { h := sha256.Sum256([]byte(s)) return hex.EncodeToString(h[:]) } func Ingest(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) { r := &SeedResult{} docs, _ := data["documents"].([]any) for _, raw := range docs { d, _ := raw.(map[string]any) if err := ingestDocument(ctx, tx, d); err != nil { return nil, fmt.Errorf("document %v: %w", str(d, "slug"), err) } r.Documents++ } invs, _ := data["investigations"].([]any) for _, raw := range invs { m, _ := raw.(map[string]any) if err := ingestInvestigation(ctx, tx, m); err != nil { return nil, fmt.Errorf("investigation %v: %w", str(m, "slug"), err) } r.Investigations++ } rbs, _ := data["runbooks"].([]any) for _, raw := range rbs { m, _ := raw.(map[string]any) if err := ingestRunbook(ctx, tx, m); err != nil { return nil, fmt.Errorf("runbook %v: %w", str(m, "slug"), err) } r.Runbooks++ } return r, nil } func str(m map[string]any, key string) string { s, _ := m[key].(string) return s } func strSlice(m map[string]any, key string) []string { raw, _ := m[key].([]any) var out []string for _, v := range raw { if s, ok := v.(string); ok { out = append(out, s) } } return out } func mapVal(m map[string]any, key string) map[string]any { v, _ := m[key].(map[string]any) return v } func ingestDocument(ctx context.Context, tx pgx.Tx, m map[string]any) error { slug := str(m, "slug") title := str(m, "title") content := str(m, "content") entitySlug := str(m, "entity_slug") tags := strSlice(m, "tags") atGlance, _ := m["at_glance"].(map[string]any) clRaw, _ := m["changelog"].([]any) entityDocSlug := "document:" + slug if err := upsertKnowledgeEntity(ctx, tx, entityDocSlug, "document", title, content, slug, tags); err != nil { return err } attrs := map[string]any{} if len(atGlance) > 0 { attrs["at_glance"] = atGlance } if len(clRaw) > 0 { attrs["changelog"] = clRaw } if len(attrs) > 0 { attrsBytes, _ := json.Marshal(attrs) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = attributes || $1, updated_at = now() WHERE slug = $2`, string(attrsBytes), entityDocSlug) if err != nil { return fmt.Errorf("update document attrs: %w", err) } } if entitySlug != "" { if err := createEdge(ctx, tx, entityDocSlug, entitySlug, "documents", nil); err != nil { return fmt.Errorf("link document: %w", err) } } if len(atGlance) > 0 && entitySlug != "" { backfillAttrs, _ := json.Marshal(atGlance) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = $1 || attributes, updated_at = now() WHERE slug = $2`, string(backfillAttrs), entitySlug) if err != nil { return fmt.Errorf("backfill entity attrs: %w", err) } } return nil } func ingestInvestigation(ctx context.Context, tx pgx.Tx, m map[string]any) error { slug := str(m, "slug") title := str(m, "title") content := str(m, "content") date := str(m, "date") status := str(m, "status") duration := str(m, "duration") aboutSlugs := strSlice(m, "about_slugs") tags := strSlice(m, "tags") entitySlug := "investigation:" + slug if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "investigation", title, content, slug, tags); err != nil { return err } attrs := map[string]any{} if date != "" { attrs["date"] = date } if status != "" { attrs["status"] = status } if duration != "" { attrs["duration"] = duration } if len(attrs) > 0 { attrsBytes, _ := json.Marshal(attrs) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = attributes || $1, updated_at = now() WHERE slug = $2`, string(attrsBytes), entitySlug) if err != nil { return fmt.Errorf("update investigation attrs: %w", err) } } for _, aboutSlug := range aboutSlugs { if err := createEdge(ctx, tx, entitySlug, aboutSlug, "about", nil); err != nil { return fmt.Errorf("link investigation about %s: %w", aboutSlug, err) } } return nil } func ingestRunbook(ctx context.Context, tx pgx.Tx, m map[string]any) error { slug := str(m, "slug") name := str(m, "name") riskClass := str(m, "risk_class") entityType := str(m, "entity_type") content := str(m, "content") tags := strSlice(m, "tags") procedure, _ := m["procedure"].(map[string]any) entitySlug := "runbook:" + slug if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "runbook", name, content, slug, tags); err != nil { return err } attrs := map[string]any{} if riskClass != "" { attrs["risk_class"] = riskClass } if entityType != "" { attrs["applies_to_type"] = entityType } if len(procedure) > 0 { attrs["procedure"] = procedure } if len(attrs) > 0 { attrsBytes, _ := json.Marshal(attrs) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = attributes || $1, updated_at = now() WHERE slug = $2`, string(attrsBytes), entitySlug) if err != nil { return fmt.Errorf("update runbook attrs: %w", err) } } return nil } func upsertKnowledgeEntity(ctx context.Context, tx pgx.Tx, slug, entityType, title, content, source string, tags []string) error { id, err := getOrCreateEntity(ctx, tx, slug, entityType, title) if err != nil { return err } hash := contentHash(content) tagArray := toPGArray(tags) _, err = tx.Exec(ctx, `INSERT INTO knowledge_entities (entity_id, title, content, source, tags, content_hash, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, now(), now()) ON CONFLICT (entity_id) DO UPDATE SET title = $2, content = $3, source = $4, tags = $5, content_hash = $6, updated_at = now()`, id, title, content, source, tagArray, hash) return err } func getOrCreateEntity(ctx context.Context, tx pgx.Tx, slug, entityType, name string) (uuid.UUID, error) { var id uuid.UUID err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id) if err == nil { return id, nil } if err != pgx.ErrNoRows { return uuid.Nil, fmt.Errorf("lookup entity %s: %w", slug, err) } id, err = uuid.NewV7() if err != nil { return uuid.Nil, fmt.Errorf("generate uuid: %w", err) } _, err = tx.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at) VALUES ($1, $2, $3, $4, NULL, '{}', 1, now(), now())`, id, slug, entityType, name) if err != nil { return uuid.Nil, fmt.Errorf("create entity %s: %w", slug, err) } return id, nil } func createEdge(ctx context.Context, tx pgx.Tx, sourceSlug, targetSlug, relType string, attrs map[string]any) error { var sourceID, targetID uuid.UUID if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", sourceSlug).Scan(&sourceID); err != nil { return fmt.Errorf("source %s: %w", sourceSlug, err) } if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil { return fmt.Errorf("target %s: %w", targetSlug, err) } attrsBytes, _ := json.Marshal(attrs) _, err := tx.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to) VALUES ($1, $2, $3, $4, now(), NULL) ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL DO UPDATE SET attributes = EXCLUDED.attributes`, sourceID, targetID, relType, string(attrsBytes)) return err } func toPGArray(tags []string) string { if len(tags) == 0 { return "{}" } out := "{" for i, t := range tags { if i > 0 { out += "," } out += `"` + t + `"` } out += "}" return out }