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.
425 lines
17 KiB
Go
425 lines
17 KiB
Go
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
|
|
}
|