Some checks failed
release-tag / Resolve release metadata (push) Successful in 30s
release-tag / Build knowledge (push) Failing after 4m51s
release-tag / Build control (push) Failing after 5m0s
release-tag / Build agent (push) Failing after 5m0s
release-tag / Build agent-data-init (push) Failing after 5m5s
release-tag / Build neuroforge-worker (push) Failing after 5m7s
release-tag / Build neuroforge (push) Failing after 5m9s
285 lines
8.8 KiB
Go
285 lines
8.8 KiB
Go
package learning
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type TicketOutcome struct {
|
|
ID string `json:"id"`
|
|
RunID string `json:"run_id"`
|
|
TicketID int64 `json:"ticket_id"`
|
|
Decision string `json:"decision"` // accepted | corrected
|
|
TicketInput string `json:"ticket_input"`
|
|
ProposedReply string `json:"proposed_reply,omitempty"`
|
|
ConfirmedReply string `json:"confirmed_reply"`
|
|
CategoryID int64 `json:"category_id,omitempty"`
|
|
CategoryName string `json:"category_name,omitempty"`
|
|
KnowledgeID string `json:"knowledge_id,omitempty"`
|
|
Actor string `json:"actor"`
|
|
Note string `json:"note,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
NeuroForgeID string `json:"neuroforge_memory_id,omitempty"`
|
|
SyncStatus string `json:"sync_status"` // pending | learned | failed
|
|
SyncError string `json:"sync_error,omitempty"`
|
|
SupersedesID string `json:"supersedes_id,omitempty"`
|
|
}
|
|
|
|
type OutcomeStore struct {
|
|
mu sync.RWMutex
|
|
path string
|
|
max int
|
|
items []TicketOutcome
|
|
}
|
|
|
|
func OpenOutcomes(dataDir string, max int) (*OutcomeStore, error) {
|
|
if max < 1 {
|
|
max = 2000
|
|
}
|
|
s := &OutcomeStore{path: filepath.Join(dataDir, "ticket-outcomes.json"), max: max}
|
|
if b, err := os.ReadFile(s.path); err == nil {
|
|
if err := json.Unmarshal(b, &s.items); err != nil {
|
|
return nil, fmt.Errorf("parse ticket outcomes: %w", err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if len(s.items) > s.max {
|
|
s.items = s.items[len(s.items)-s.max:]
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *OutcomeStore) Add(x TicketOutcome) (TicketOutcome, error) {
|
|
x.RunID = strings.TrimSpace(x.RunID)
|
|
x.Decision = strings.ToLower(strings.TrimSpace(x.Decision))
|
|
x.TicketInput = strings.TrimSpace(x.TicketInput)
|
|
x.ProposedReply = strings.TrimSpace(x.ProposedReply)
|
|
x.ConfirmedReply = strings.TrimSpace(x.ConfirmedReply)
|
|
x.CategoryName = strings.TrimSpace(x.CategoryName)
|
|
x.KnowledgeID = strings.TrimSpace(x.KnowledgeID)
|
|
x.Actor = strings.TrimSpace(x.Actor)
|
|
x.Note = strings.TrimSpace(x.Note)
|
|
if x.RunID == "" || x.TicketID <= 0 || x.TicketInput == "" || x.ConfirmedReply == "" || x.Actor == "" {
|
|
return x, fmt.Errorf("run, ticket, input, confirmed reply and actor are required")
|
|
}
|
|
if x.Decision != "accepted" && x.Decision != "corrected" {
|
|
return x, fmt.Errorf("decision must be accepted or corrected")
|
|
}
|
|
if x.Decision == "accepted" && x.ProposedReply == "" {
|
|
return x, fmt.Errorf("accepted outcome requires proposed reply")
|
|
}
|
|
if x.ID == "" {
|
|
x.ID = outcomeID()
|
|
}
|
|
if x.CreatedAt.IsZero() {
|
|
x.CreatedAt = time.Now().UTC()
|
|
}
|
|
if x.SyncStatus == "" {
|
|
x.SyncStatus = "pending"
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := len(s.items) - 1; i >= 0; i-- {
|
|
prev := s.items[i]
|
|
if prev.RunID != x.RunID {
|
|
continue
|
|
}
|
|
// Repeating the exact same human decision is idempotent. This also
|
|
// lets a previously failed NeuroForge sync be retried without
|
|
// manufacturing a second human decision.
|
|
if prev.Decision == x.Decision && strings.TrimSpace(prev.ConfirmedReply) == x.ConfirmedReply {
|
|
return prev, nil
|
|
}
|
|
// A later correction/confirmation is a new immutable audit record.
|
|
// Preserve the previous decision and make the revision chain explicit.
|
|
x.SupersedesID = prev.ID
|
|
break
|
|
}
|
|
s.items = append(s.items, x)
|
|
if len(s.items) > s.max {
|
|
s.items = s.items[len(s.items)-s.max:]
|
|
}
|
|
return x, s.saveLocked()
|
|
}
|
|
|
|
func (s *OutcomeStore) UpdateSync(id, status, memoryID, syncErr string) (TicketOutcome, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.items {
|
|
if s.items[i].ID != id {
|
|
continue
|
|
}
|
|
s.items[i].SyncStatus = status
|
|
s.items[i].NeuroForgeID = memoryID
|
|
s.items[i].SyncError = syncErr
|
|
return s.items[i], s.saveLocked()
|
|
}
|
|
return TicketOutcome{}, os.ErrNotExist
|
|
}
|
|
|
|
func (s *OutcomeStore) List() []TicketOutcome {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := append([]TicketOutcome(nil), s.items...)
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
|
return out
|
|
}
|
|
func (s *OutcomeStore) saveLocked() error {
|
|
b, err := json.MarshalIndent(s.items, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o640); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
func outcomeID() string { b := make([]byte, 12); _, _ = rand.Read(b); return hex.EncodeToString(b) }
|
|
|
|
type OutcomeSink interface {
|
|
LearnOutcome(context.Context, TicketOutcome) (string, error)
|
|
}
|
|
|
|
type OutcomeEvidence struct {
|
|
MemoryID string
|
|
OutcomeID string
|
|
Decision string
|
|
Text string
|
|
Similarity float64
|
|
Source string
|
|
TicketID string
|
|
KnowledgeID string
|
|
}
|
|
|
|
type OutcomeRetriever interface {
|
|
SearchOutcomes(context.Context, string, int, float64) ([]OutcomeEvidence, error)
|
|
}
|
|
|
|
type NeuroForgeOutcomeSink struct {
|
|
baseURL, apiKey string
|
|
http *http.Client
|
|
}
|
|
|
|
func NewNeuroForgeOutcomeSink(baseURL, apiKey string, timeout time.Duration) (*NeuroForgeOutcomeSink, error) {
|
|
raw := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return nil, fmt.Errorf("invalid neuroforge URL %q", raw)
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = 15 * time.Second
|
|
}
|
|
return &NeuroForgeOutcomeSink{baseURL: raw, apiKey: strings.TrimSpace(apiKey), http: &http.Client{Timeout: timeout}}, nil
|
|
}
|
|
func (c *NeuroForgeOutcomeSink) LearnOutcome(ctx context.Context, x TicketOutcome) (string, error) {
|
|
body, err := json.Marshal(map[string]any{"outcome_id": x.ID, "run_id": x.RunID, "ticket_id": x.TicketID, "decision": x.Decision, "ticket_input": x.TicketInput, "proposed_reply": x.ProposedReply, "confirmed_reply": x.ConfirmedReply, "category_id": x.CategoryID, "category_name": x.CategoryName, "knowledge_id": x.KnowledgeID, "supersedes_id": x.SupersedesID, "actor": x.Actor, "note": x.Note})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/integrations/outcomes", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
|
return "", fmt.Errorf("neuroforge outcome learning failed: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
|
}
|
|
var out struct {
|
|
Memory struct {
|
|
ID string `json:"id"`
|
|
} `json:"memory"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
if out.Memory.ID == "" {
|
|
return "", fmt.Errorf("neuroforge returned no memory id")
|
|
}
|
|
return out.Memory.ID, nil
|
|
}
|
|
|
|
func (c *NeuroForgeOutcomeSink) SearchOutcomes(ctx context.Context, text string, k int, minSimilarity float64) ([]OutcomeEvidence, error) {
|
|
if k <= 0 {
|
|
k = 8
|
|
}
|
|
body, err := json.Marshal(map[string]any{"text": strings.TrimSpace(text), "k": k, "min_similarity": minSimilarity})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/integrations/outcomes/search", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
|
return nil, fmt.Errorf("neuroforge outcome search failed: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
|
}
|
|
var raw []struct {
|
|
Memory struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text"`
|
|
Tags []string `json:"tags"`
|
|
Provenance struct {
|
|
Source string `json:"source"`
|
|
SourceID string `json:"source_id"`
|
|
} `json:"provenance"`
|
|
} `json:"memory"`
|
|
Similarity float64 `json:"similarity"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]OutcomeEvidence, 0, len(raw))
|
|
for _, h := range raw {
|
|
e := OutcomeEvidence{MemoryID: h.Memory.ID, OutcomeID: h.Memory.Provenance.SourceID, Text: h.Memory.Text, Similarity: h.Similarity, Source: h.Memory.Provenance.Source}
|
|
for _, tag := range h.Memory.Tags {
|
|
switch {
|
|
case strings.HasPrefix(tag, "outcome:"):
|
|
e.Decision = strings.TrimPrefix(tag, "outcome:")
|
|
case strings.HasPrefix(tag, "ticket:"):
|
|
e.TicketID = strings.TrimPrefix(tag, "ticket:")
|
|
case strings.HasPrefix(tag, "knowledge:"):
|
|
e.KnowledgeID = strings.TrimPrefix(tag, "knowledge:")
|
|
}
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, nil
|
|
}
|