feat: add provider-neutral LLM layer with secrets, budgets, recording and prompts
Phase 7 (ADR-0004). Cognitive code asks the router for a role; the router maps roles to configured models with ordered fallbacks, applies global and per-mind daily token budgets and a concurrency limit, records every call (request, response, usage, latency, cost, seed, request hash) and can serve recordings back for exact replay. Providers are raw HTTP clients for OpenAI-compatible, Anthropic and Ollama endpoints, with embedding support for the embed role wired into memory retrieval. Secrets are references only: env:, file:, or enc: values encrypted with AES-256-GCM under NOEMA_MASTER_KEY; keys are resolved at call time and never rendered, logged or exported. Outbound HTTP refuses private and loopback addresses at dial time unless a provider explicitly allows it, bounds redirects and caps bodies. Provider errors are scrubbed. Prompts are named, versioned rows with system and user templates and an optional JSON schema; untrusted text enters only through delimited data blocks. Six defaults are seeded and never overwritten. The UI adds the LLM page (providers, models, role matrix, usage, errors, budgets), a provider page with connectivity test, and a prompt manager with editor, test runner, history, restore, clone and diff. CLI: provider, prompt.
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
"github.com/andydixon/noema/internal/security/secrets"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (u *UI) llmRoutes(m *http.ServeMux) {
|
||||
m.Handle("GET /llm", u.require(auth.RoleObserver, u.llmPage))
|
||||
m.Handle("POST /llm/providers", u.require(auth.RoleAdministrator, u.providerCreate))
|
||||
m.Handle("GET /llm/providers/{id}", u.require(auth.RoleObserver, u.providerPage))
|
||||
m.Handle("POST /llm/providers/{id}", u.require(auth.RoleAdministrator, u.providerSave))
|
||||
m.Handle("POST /llm/providers/{id}/test", u.require(auth.RoleOperator, u.providerTest))
|
||||
m.Handle("POST /llm/providers/{id}/clear-key", u.require(auth.RoleAdministrator, u.providerClearKey))
|
||||
m.Handle("POST /llm/providers/{id}/delete", u.require(auth.RoleAdministrator, u.providerDelete))
|
||||
m.Handle("POST /llm/models", u.require(auth.RoleAdministrator, u.modelCreate))
|
||||
m.Handle("POST /llm/models/{id}/delete", u.require(auth.RoleAdministrator, u.modelDelete))
|
||||
m.Handle("POST /llm/roles/{role}", u.require(auth.RoleAdministrator, u.roleSave))
|
||||
m.Handle("GET /prompts", u.require(auth.RoleObserver, u.promptsPage))
|
||||
m.Handle("POST /prompts", u.require(auth.RoleAdministrator, u.promptCreate))
|
||||
m.Handle("GET /prompts/{name}", u.require(auth.RoleObserver, u.promptPage))
|
||||
m.Handle("POST /prompts/{name}", u.require(auth.RoleAdministrator, u.promptSave))
|
||||
m.Handle("POST /prompts/{name}/test", u.require(auth.RoleOperator, u.promptTest))
|
||||
m.Handle("POST /prompts/{name}/clone", u.require(auth.RoleAdministrator, u.promptClone))
|
||||
m.Handle("POST /prompts/{name}/versions/{n}/restore", u.require(auth.RoleAdministrator, u.promptRestore))
|
||||
m.Handle("GET /prompts/{name}/diff", u.require(auth.RoleObserver, u.promptDiff))
|
||||
}
|
||||
|
||||
type roleRow struct {
|
||||
Info llm.RoleInfo
|
||||
Assignment router.RoleAssignment
|
||||
}
|
||||
|
||||
func (u *UI) llmPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
provs, _ := u.deps.LLMStore.ListProviders(ctx)
|
||||
for i := range provs {
|
||||
provs[i].KeyDescribed = secrets.Describe(provs[i].APIKeyRef)
|
||||
}
|
||||
models, _ := u.deps.LLMStore.ListModels(ctx)
|
||||
roles, _ := u.deps.LLMStore.ListRoles(ctx)
|
||||
byRole := map[llm.Role]router.RoleAssignment{}
|
||||
for _, a := range roles {
|
||||
byRole[a.Role] = a
|
||||
}
|
||||
var rows []roleRow
|
||||
for _, info := range llm.Roles {
|
||||
rows = append(rows, roleRow{Info: info, Assignment: byRole[info.Role]})
|
||||
}
|
||||
usage, _ := u.deps.LLMStore.Usage(ctx, time.Now().Add(-24*time.Hour), ids.Nil)
|
||||
errs, _ := u.deps.LLMStore.ListCalls(ctx, router.CallFilter{ErrorsOnly: true, Limit: 8})
|
||||
used, _ := u.deps.LLMStore.TokensSince(ctx, time.Now().Truncate(24*time.Hour), ids.Nil)
|
||||
u.render(w, r, "llm", "Language models", map[string]any{"Providers": provs, "Models": models, "Roles": rows, "Usage": usage, "Errors": errs,
|
||||
"GlobalBudget": u.deps.Config.DailyTokenBudget, "UsedToday": used, "MaxConcurrent": u.deps.Config.MaxConcurrentLLM})
|
||||
}
|
||||
|
||||
func (u *UI) keyRef(typed string) (string, error) {
|
||||
typed = strings.TrimSpace(typed)
|
||||
if typed == "" || secrets.IsReference(typed) {
|
||||
return typed, nil
|
||||
}
|
||||
return u.deps.Secrets.Encrypt(typed)
|
||||
}
|
||||
|
||||
func (u *UI) providerInput(r *http.Request) (router.ProviderInput, error) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
ref, err := u.keyRef(r.PostFormValue("api_key"))
|
||||
if err != nil {
|
||||
return router.ProviderInput{}, err
|
||||
}
|
||||
timeout, _ := strconv.Atoi(r.PostFormValue("timeout_ms"))
|
||||
enabled := r.PostFormValue("enabled") != "" || r.PostFormValue("enabled_default") == "1"
|
||||
return router.ProviderInput{Name: r.PostFormValue("name"), Kind: llm.ProviderKind(r.PostFormValue("kind")), Endpoint: r.PostFormValue("endpoint"), APIKeyRef: ref,
|
||||
AllowPrivate: r.PostFormValue("allow_private") != "", Timeout: time.Duration(timeout) * time.Millisecond, Enabled: enabled}, nil
|
||||
}
|
||||
|
||||
func (u *UI) providerCreate(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := u.providerInput(r)
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Cannot store key: "+err.Error()+". Set NOEMA_MASTER_KEY or use an env: reference.", "/llm")
|
||||
return
|
||||
}
|
||||
in.Enabled = true
|
||||
p, err := u.deps.LLMStore.CreateProvider(r.Context(), in)
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/llm")
|
||||
return
|
||||
}
|
||||
u.deps.LLM.Invalidate()
|
||||
u.deps.Audit.Log(r.Context(), "llm.provider.create", "llm_provider", p.ID.String(), map[string]any{"name": p.Name, "kind": p.Kind, "endpoint": p.Endpoint})
|
||||
u.flash(w, r, "ok", "Provider added. Add a model, then test it.", "/llm")
|
||||
}
|
||||
|
||||
func (u *UI) provider(w http.ResponseWriter, r *http.Request) (router.Provider, bool) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such provider.")
|
||||
return router.Provider{}, false
|
||||
}
|
||||
p, err := u.deps.LLMStore.GetProvider(r.Context(), id)
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such provider.")
|
||||
return router.Provider{}, false
|
||||
}
|
||||
p.KeyDescribed = secrets.Describe(p.APIKeyRef)
|
||||
return p, true
|
||||
}
|
||||
|
||||
func (u *UI) providerPage(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := u.provider(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
all, _ := u.deps.LLMStore.ListModels(r.Context())
|
||||
var mine []router.Model
|
||||
for _, m := range all {
|
||||
if m.ProviderID == p.ID {
|
||||
mine = append(mine, m)
|
||||
}
|
||||
}
|
||||
u.render(w, r, "provider", "Provider "+p.Name, map[string]any{"Provider": p, "Models": mine})
|
||||
}
|
||||
|
||||
func (u *UI) providerSave(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := u.provider(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, err := u.providerInput(r)
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Cannot store key: "+err.Error(), "/llm/providers/"+p.ID.String())
|
||||
return
|
||||
}
|
||||
if _, err := u.deps.LLMStore.UpdateProvider(r.Context(), p.ID, in); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/llm/providers/"+p.ID.String())
|
||||
return
|
||||
}
|
||||
u.deps.LLM.Invalidate()
|
||||
u.deps.Audit.Log(r.Context(), "llm.provider.update", "llm_provider", p.ID.String(), map[string]any{"name": in.Name, "enabled": in.Enabled, "key_changed": in.APIKeyRef != ""})
|
||||
u.flash(w, r, "ok", "Provider saved.", "/llm/providers/"+p.ID.String())
|
||||
}
|
||||
|
||||
func (u *UI) providerTest(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := u.provider(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := u.deps.LLM.TestProvider(r.Context(), p.ID, r.PostFormValue("model"))
|
||||
u.deps.Audit.Log(r.Context(), "llm.provider.test", "llm_provider", p.ID.String(), map[string]any{"ok": err == nil})
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Test failed: "+err.Error(), "/llm")
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", "Provider ok: "+resp.Model+" answered in "+strconv.FormatInt(resp.Latency.Milliseconds(), 10)+" ms.", "/llm")
|
||||
}
|
||||
|
||||
func (u *UI) providerClearKey(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := u.provider(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u.deps.LLMStore.ClearProviderKey(r.Context(), p.ID) //nolint:errcheck
|
||||
u.deps.LLM.Invalidate()
|
||||
u.deps.Audit.Log(r.Context(), "llm.provider.clear_key", "llm_provider", p.ID.String(), nil)
|
||||
u.flash(w, r, "ok", "Key reference removed.", "/llm/providers/"+p.ID.String())
|
||||
}
|
||||
|
||||
func (u *UI) providerDelete(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := u.provider(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := u.deps.LLMStore.DeleteProvider(r.Context(), p.ID); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/llm")
|
||||
return
|
||||
}
|
||||
u.deps.LLM.Invalidate()
|
||||
u.deps.Audit.Log(r.Context(), "llm.provider.delete", "llm_provider", p.ID.String(), map[string]any{"name": p.Name})
|
||||
u.flash(w, r, "ok", "Provider deleted.", "/llm")
|
||||
}
|
||||
|
||||
func (u *UI) modelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
pid, err := ids.Parse(r.PostFormValue("provider_id"))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Choose a provider.", "/llm")
|
||||
return
|
||||
}
|
||||
cw, _ := strconv.Atoi(r.PostFormValue("context_window"))
|
||||
in, _ := strconv.ParseFloat(r.PostFormValue("input_cost_per_m"), 64)
|
||||
out, _ := strconv.ParseFloat(r.PostFormValue("output_cost_per_m"), 64)
|
||||
m, err := u.deps.LLMStore.CreateModel(r.Context(), router.ModelInput{ProviderID: pid, Name: r.PostFormValue("name"), Kind: r.PostFormValue("kind"), ContextWindow: cw, InputCostPerM: in, OutputCostPerM: out, Enabled: true})
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/llm")
|
||||
return
|
||||
}
|
||||
u.deps.LLM.Invalidate()
|
||||
u.deps.Audit.Log(r.Context(), "llm.model.create", "llm_model", m.ID.String(), map[string]any{"name": m.Name, "provider": m.Provider})
|
||||
u.flash(w, r, "ok", "Model added.", "/llm")
|
||||
}
|
||||
|
||||
func (u *UI) modelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil || u.deps.LLMStore.DeleteModel(r.Context(), id) != nil {
|
||||
u.flash(w, r, "error", "Model not found.", "/llm")
|
||||
return
|
||||
}
|
||||
u.deps.LLM.Invalidate()
|
||||
u.deps.Audit.Log(r.Context(), "llm.model.delete", "llm_model", id.String(), nil)
|
||||
u.flash(w, r, "ok", "Model removed.", "/llm")
|
||||
}
|
||||
|
||||
func (u *UI) roleSave(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
a := router.RoleAssignment{Role: llm.Role(r.PathValue("role")), UpdatedBy: username(r), FallbackIDs: []ids.ID{}}
|
||||
if v := r.PostFormValue("model_id"); v != "" {
|
||||
if id, err := ids.Parse(v); err == nil {
|
||||
a.ModelID = &id
|
||||
}
|
||||
}
|
||||
for _, v := range r.PostForm["fallback_ids"] {
|
||||
if id, err := ids.Parse(v); err == nil && (a.ModelID == nil || id != *a.ModelID) {
|
||||
a.FallbackIDs = append(a.FallbackIDs, id)
|
||||
}
|
||||
}
|
||||
if v := r.PostFormValue("temperature"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
a.Temperature = &f
|
||||
}
|
||||
}
|
||||
if v := r.PostFormValue("max_tokens"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
a.MaxTokens = &n
|
||||
}
|
||||
}
|
||||
if err := u.deps.LLMStore.SetRole(r.Context(), a); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/llm")
|
||||
return
|
||||
}
|
||||
u.deps.LLM.Invalidate()
|
||||
u.deps.Memory.SetEmbedder(u.deps.LLM.Embedder(r.Context()))
|
||||
u.deps.Audit.Log(r.Context(), "llm.role.set", "llm_role", string(a.Role), map[string]any{"model_id": a.ModelID, "fallbacks": a.FallbackIDs})
|
||||
u.flash(w, r, "ok", "Role "+string(a.Role)+" saved.", "/llm")
|
||||
}
|
||||
|
||||
// ---- prompts ----
|
||||
|
||||
func (u *UI) promptsPage(w http.ResponseWriter, r *http.Request) {
|
||||
list, _ := u.deps.Prompts.List(r.Context())
|
||||
u.render(w, r, "prompts", "Prompts", map[string]any{"Prompts": list})
|
||||
}
|
||||
|
||||
func (u *UI) promptCreate(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
p, err := u.deps.Prompts.Save(r.Context(), prompts.Input{Name: r.PostFormValue("name"), Description: r.PostFormValue("description"), Role: llm.Role(r.PostFormValue("role")),
|
||||
Template: "{{data \"input\" .Text}}", Note: "created", By: username(r)})
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/prompts")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "prompt.save", "prompt", p.Name, map[string]any{"version": 1})
|
||||
http.Redirect(w, r, "/prompts/"+p.Name, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
var samples = map[string]string{
|
||||
"perceive.message": `{"Text":"Is atlas slow again since the deploy?","Actor":"andy","Trust":"user","Channel":"web"}`,
|
||||
"respond": `{"Brief":"You are Dani, an investigator.","Intent":"Explain that the deployment hypothesis is weak.","Beliefs":[{"Proposition":"the deployment caused the latency","Confidence":0.32,"Status":"hypothesis"}],"Memories":[],"Uncertainty":"Only two co-occurring observations.","Limitations":["I cannot access servers."],"Message":"Was it the deploy?"}`,
|
||||
"hypothesise": `{"Observations":["deployment at 09:10","CPU spike at 09:11","latency alert at 09:12"],"Beliefs":[],"Max":3}`,
|
||||
"summarise": `{"Text":"Long text to summarise…","MaxWords":40}`,
|
||||
"judge.equivalence": `{"A":"atlas is slow","B":"atlas is not slow"}`,
|
||||
"narrate": `{"Workspace":["latency alert on atlas"],"Recent":["hypothesis formed"],"Goals":[],"Contradictions":0}`,
|
||||
}
|
||||
|
||||
func (u *UI) promptPage(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := u.deps.Prompts.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such prompt.")
|
||||
return
|
||||
}
|
||||
vs, _ := u.deps.Prompts.Versions(r.Context(), p.Name)
|
||||
sample := samples[p.Name]
|
||||
if sample == "" {
|
||||
sample = `{"Text":"sample input"}`
|
||||
}
|
||||
u.render(w, r, "prompt", "Prompt "+p.Name, map[string]any{"Prompt": p, "Versions": vs, "Sample": sample, "RoleAvailable": u.deps.LLM.Available(r.Context(), p.Role)})
|
||||
}
|
||||
|
||||
func (u *UI) promptSave(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
name := r.PathValue("name")
|
||||
var schema json.RawMessage
|
||||
if s := strings.TrimSpace(r.PostFormValue("schema")); s != "" {
|
||||
schema = json.RawMessage(s)
|
||||
}
|
||||
p, err := u.deps.Prompts.Save(r.Context(), prompts.Input{Name: name, Description: r.PostFormValue("description"), System: r.PostFormValue("system"), Template: r.PostFormValue("template"), Schema: schema, Note: r.PostFormValue("note"), By: username(r)})
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/prompts/"+name)
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "prompt.save", "prompt", p.Name, map[string]any{"version": p.CurrentVersion, "note": r.PostFormValue("note")})
|
||||
u.flash(w, r, "ok", "Saved as version "+strconv.Itoa(p.CurrentVersion)+".", "/prompts/"+name)
|
||||
}
|
||||
|
||||
func (u *UI) promptTest(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
p, err := u.deps.Prompts.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such prompt.")
|
||||
return
|
||||
}
|
||||
var data map[string]any
|
||||
raw := r.PostFormValue("data")
|
||||
result := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
result["Error"] = "Sample data is not a JSON object: " + err.Error()
|
||||
} else if req, err := p.Render(data); err != nil {
|
||||
result["Error"] = err.Error()
|
||||
} else {
|
||||
result["Rendered"] = req
|
||||
if r.PostFormValue("run") == "1" {
|
||||
req.Purpose = "prompt test by " + username(r)
|
||||
resp, err := u.deps.LLM.Complete(r.Context(), req)
|
||||
if err != nil {
|
||||
result["Error"] = err.Error()
|
||||
} else {
|
||||
result["Response"] = resp
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "prompt.test", "prompt", p.Name, map[string]any{"ran": true, "ok": err == nil})
|
||||
}
|
||||
}
|
||||
vs, _ := u.deps.Prompts.Versions(r.Context(), p.Name)
|
||||
u.render(w, r, "prompt", "Prompt "+p.Name, map[string]any{"Prompt": p, "Versions": vs, "Sample": raw, "Test": result, "RoleAvailable": u.deps.LLM.Available(r.Context(), p.Role)})
|
||||
}
|
||||
|
||||
func (u *UI) promptClone(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := u.deps.Prompts.Clone(r.Context(), r.PathValue("name"), r.PostFormValue("name"), username(r))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/prompts/"+r.PathValue("name"))
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "prompt.clone", "prompt", p.Name, map[string]any{"from": r.PathValue("name")})
|
||||
http.Redirect(w, r, "/prompts/"+p.Name, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) promptRestore(w http.ResponseWriter, r *http.Request) {
|
||||
n, _ := strconv.Atoi(r.PathValue("n"))
|
||||
p, err := u.deps.Prompts.Restore(r.Context(), r.PathValue("name"), n, username(r))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/prompts/"+r.PathValue("name"))
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "prompt.restore", "prompt", p.Name, map[string]any{"restored": n, "version": p.CurrentVersion})
|
||||
u.flash(w, r, "ok", "Restored version "+strconv.Itoa(n)+" as version "+strconv.Itoa(p.CurrentVersion)+".", "/prompts/"+p.Name)
|
||||
}
|
||||
|
||||
type diffLine struct{ Class, Text string }
|
||||
|
||||
func (u *UI) promptDiff(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
a, _ := strconv.Atoi(r.URL.Query().Get("a"))
|
||||
b, _ := strconv.Atoi(r.URL.Query().Get("b"))
|
||||
va, errA := u.deps.Prompts.GetVersion(r.Context(), name, a)
|
||||
vb, errB := u.deps.Prompts.GetVersion(r.Context(), name, b)
|
||||
if errA != nil || errB != nil {
|
||||
u.errorPage(w, r, 404, "Version not found.")
|
||||
return
|
||||
}
|
||||
lines := lineDiff(va.System+"\n---\n"+va.Template, vb.System+"\n---\n"+vb.Template)
|
||||
u.render(w, r, "prompt_diff", "Prompt diff", map[string]any{"Name": name, "A": va, "B": vb, "Lines": lines})
|
||||
}
|
||||
|
||||
// lineDiff is a small LCS line diff; prompts are short so O(n·m) is fine.
|
||||
func lineDiff(a, b string) []diffLine {
|
||||
al, bl := strings.Split(a, "\n"), strings.Split(b, "\n")
|
||||
n, m := len(al), len(bl)
|
||||
lcs := make([][]int, n+1)
|
||||
for i := range lcs {
|
||||
lcs[i] = make([]int, m+1)
|
||||
}
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
for j := m - 1; j >= 0; j-- {
|
||||
if al[i] == bl[j] {
|
||||
lcs[i][j] = lcs[i+1][j+1] + 1
|
||||
} else if lcs[i+1][j] >= lcs[i][j+1] {
|
||||
lcs[i][j] = lcs[i+1][j]
|
||||
} else {
|
||||
lcs[i][j] = lcs[i][j+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []diffLine
|
||||
i, j := 0, 0
|
||||
for i < n && j < m {
|
||||
switch {
|
||||
case al[i] == bl[j]:
|
||||
out = append(out, diffLine{"ctx", " " + al[i]})
|
||||
i++
|
||||
j++
|
||||
case lcs[i+1][j] >= lcs[i][j+1]:
|
||||
out = append(out, diffLine{"del", "- " + al[i]})
|
||||
i++
|
||||
default:
|
||||
out = append(out, diffLine{"add", "+ " + bl[j]})
|
||||
j++
|
||||
}
|
||||
}
|
||||
for ; i < n; i++ {
|
||||
out = append(out, diffLine{"del", "- " + al[i]})
|
||||
}
|
||||
for ; j < m; j++ {
|
||||
out = append(out, diffLine{"add", "+ " + bl[j]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
+50
-8
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/version"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
@@ -83,13 +84,34 @@ func (u *UI) parse(fsys fs.FS) error {
|
||||
"duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() },
|
||||
"bytes": humanBytes,
|
||||
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
|
||||
"conf": func(f float64) string { return fmt.Sprintf("%.2f", f) },
|
||||
"inc": func(i int) int { return i + 1 },
|
||||
"deref": func(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
"conf": func(v any) string {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return fmt.Sprintf("%.2f", x)
|
||||
case float32:
|
||||
return fmt.Sprintf("%.2f", x)
|
||||
case *float64:
|
||||
if x != nil {
|
||||
return fmt.Sprintf("%.2f", *x)
|
||||
}
|
||||
}
|
||||
return *f
|
||||
return "–"
|
||||
},
|
||||
"inc": func(i int) int { return i + 1 },
|
||||
"deref": func(v any) any {
|
||||
switch x := v.(type) {
|
||||
case *float64:
|
||||
if x == nil {
|
||||
return 0.0
|
||||
}
|
||||
return *x
|
||||
case *bool:
|
||||
if x == nil {
|
||||
return false
|
||||
}
|
||||
return *x
|
||||
}
|
||||
return v
|
||||
},
|
||||
"title": func(s string) string { s = strings.ReplaceAll(s, "_", " "); return strings.ToUpper(s[:1]) + s[1:] },
|
||||
"valueKeys": func() []string { return minds.ValueKeys },
|
||||
@@ -97,6 +119,27 @@ func (u *UI) parse(fsys fs.FS) error {
|
||||
"memoryKinds": func() []concepts.MemoryKind { return concepts.AllMemoryKinds },
|
||||
"epistemicKinds": func() []concepts.Epistemic { return concepts.AllEpistemic },
|
||||
"relations": func() []concepts.Relation { return concepts.AllRelations },
|
||||
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
|
||||
"deref_id": func(id *ids.ID) ids.ID {
|
||||
if id == nil {
|
||||
return ids.Nil
|
||||
}
|
||||
return *id
|
||||
},
|
||||
"deref_int": func(i *int) int {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return *i
|
||||
},
|
||||
"has_id": func(list []ids.ID, id ids.ID) bool {
|
||||
for _, x := range list {
|
||||
if x == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
"beliefStatuses": func() []beliefs.Status {
|
||||
return []beliefs.Status{beliefs.StatusHypothesis, beliefs.StatusTentative, beliefs.StatusLikely, beliefs.StatusAccepted, beliefs.StatusContested, beliefs.StatusRejected, beliefs.StatusSuperseded}
|
||||
},
|
||||
@@ -253,11 +296,10 @@ func (u *UI) Register(m *http.ServeMux) {
|
||||
u.eventRoutes(m)
|
||||
u.memoryRoutes(m)
|
||||
u.beliefRoutes(m)
|
||||
u.llmRoutes(m)
|
||||
|
||||
for _, p := range []struct{ path, title, note string }{
|
||||
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
|
||||
{"/llm", "LLM", "Provider configuration arrives in phase 7."},
|
||||
{"/prompts", "Prompts", "The prompt manager arrives in phase 7."},
|
||||
{"/integrations", "Integrations", "Integrations arrive in phase 13."},
|
||||
{"/policies", "Policies", "The policy matrix arrives in phase 12."},
|
||||
} {
|
||||
|
||||
Reference in New Issue
Block a user