diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..247beb9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Container-owned data: unreadable by the build user, and stat'ing it fails the +# context walk outright. +.volumes/ +backups/ + +.git/ +.env +.env.* +!.env.example + +# Local working backups. +.bak-*/ +**/.bak-*/ diff --git a/.env.example b/.env.example index e00cb81..3232951 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,12 @@ ADMIN_INITIAL_PASSWORD=change-me-now ATOMIZER_BASE_URL=http://atomizer-mock:8090 ATOMIZER_TOKEN=change-me-atomizer ATOMIZER_TIMEOUT_SEC=120 +# Optional: serve Subdivide from the Anypreta Issue Atomizer instead of the +# §5.1 service above. Both vars are required to enable it. Extend and Summarize +# have no counterpart in that API and always stay on ATOMIZER_BASE_URL, so the +# service above must keep running either way. +ATOMIZER_REMOTE_BASE_URL= +ATOMIZER_REMOTE_API_KEY= WORK_PERFORMER_BASE_URL=http://work-performer:8091 WORK_PERFORMER_TOKEN=change-me-performer # Job submission/polling only; jobs themselves are async. diff --git a/cmd/app/main.go b/cmd/app/main.go index 77d3fc6..3c33ee5 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -98,6 +98,13 @@ func run() error { } return cfg.AtomizerBaseURL }, cfg.AtomizerToken, cfg.AtomizerTimeout) + if cfg.AtomizerRemoteBaseURL != "" { + // Subdivide is served by the Anypreta Issue Atomizer; Extend and + // Summarize have no counterpart there and stay on ATOMIZER_BASE_URL. + atomClient.UseRemote(cfg.AtomizerRemoteBaseURL, cfg.AtomizerRemoteAPIKey, cfg.AtomizerTimeout) + log.Info("atomizer: subdivide served by remote backend", + "url", cfg.AtomizerRemoteBaseURL, "extend_summarize", cfg.AtomizerBaseURL) + } srv.SetAtomizerInfo(atomClient) srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy}) diff --git a/internal/atomize/anypreta.go b/internal/atomize/anypreta.go new file mode 100644 index 0000000..ef84776 --- /dev/null +++ b/internal/atomize/anypreta.go @@ -0,0 +1,287 @@ +package atomize + +import ( + "context" + "fmt" + "math" + "net/http" + "strings" + "time" + "unicode/utf8" + + "bountyboard/internal/extsvc" +) + +// Backend for the Anypreta "Issue Atomizer" API (OpenAPI 3.1, v0.1.0). Its +// model is two-level — issue → features → tasks — where ours is one-level +// (task → children), and only *features* carry an effort weight +// (`estimation`); its tasks carry none at all. A board child therefore maps to +// a Feature and Subdivide calls POST /v1/atomize-issue. +// +// Extend and Summarize have no counterpart here (/v1/extend-feature-tasks adds +// tasks *inside* one feature and returns the whole set), so those stay on the +// §5.1 service. The API declares no security scheme; it authenticates with +// Authorization: Bearer . + +// Field caps from the spec — exceeded values are 422s, so trim rather than fail. +const ( + maxIssueLen = 50000 + maxGuidanceLen = 10000 + maxSystemIDLen = 256 + maxSystemDesc = 2000 +) + +type apComponent struct { + ID string `json:"id"` + Description string `json:"description"` + SubComponents []apComponent `json:"sub_components,omitempty"` +} + +type apSystem struct { + ID string `json:"id"` + Description string `json:"description"` + Components []apComponent `json:"components,omitempty"` +} + +type apAtomizeIssueRequest struct { + System apSystem `json:"system"` + Issue string `json:"issue"` + GuidanceNote string `json:"guidance_note,omitempty"` +} + +type apCriterion struct { + Description string `json:"description"` +} + +type apFeature struct { + ID string `json:"id"` + Title string `json:"title"` + StakeholderSummary string `json:"stakeholder_summary"` + Description string `json:"description"` + AcceptanceCriteria []apCriterion `json:"acceptance_criteria"` + Priority int `json:"priority"` + RelatedComponents []string `json:"related_components"` + DependsOn []string `json:"depends_on"` + // Estimation is optional and nullable in the spec; observed to sum to 1 + // across a feature set, but that is not promised. + Estimation *float64 `json:"estimation"` +} + +type apAtomizeIssueResponse struct { + FeatureSet struct { + Features []apFeature `json:"features"` + } `json:"feature_set"` +} + +// remote is the Anypreta-backed Atomize path. It reuses extsvc for the bearer +// auth, retry and circuit breaker; the base URL carries the /v1 prefix so +// extsvc's /healthz probe lands on the API's /v1/healthz. +type remote struct { + c *extsvc.Client +} + +func newRemote(baseURL, apiKey string, timeout time.Duration) *remote { + base := strings.TrimRight(baseURL, "/") + "/v1" + return &remote{c: extsvc.NewClient("atomizer-remote", + func() string { return base }, apiKey, timeout)} +} + +// Atomize maps a subdivide request onto POST /v1/atomize-issue and maps the +// returned features back onto board children. +func (r *remote) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) { + body := apAtomizeIssueRequest{ + System: systemFor(req), + Issue: truncate(issueText(req), maxIssueLen), + GuidanceNote: truncate(guidanceNote(req), maxGuidanceLen), + } + var resp apAtomizeIssueResponse + if err := r.c.Call(ctx, http.MethodPost, "/atomize-issue", body, &resp); err != nil { + return nil, err + } + features := resp.FeatureSet.Features + if len(features) == 0 { + return nil, fmt.Errorf("%w: empty feature set", ErrBadCoefficients) + } + + coeffs, estimated, err := featureCoefficients(features) + if err != nil { + return nil, err + } + tasks := make([]SubTask, len(features)) + for i, f := range features { + criteria := make([]string, 0, len(f.AcceptanceCriteria)) + for _, c := range f.AcceptanceCriteria { + if d := strings.TrimSpace(c.Description); d != "" { + criteria = append(criteria, d) + } + } + tasks[i] = SubTask{ + Title: strings.TrimSpace(f.Title), + Description: strings.TrimSpace(f.Description), + AcceptanceCriteria: criteria, + EffortCoefficient: coeffs[i], + } + } + out := &AtomizeResponse{Tasks: tasks, Model: "anypreta/issue-atomizer"} + if !estimated { + out.Notes = "The atomizer returned no effort estimation, so the budget was split evenly across the tasks." + } + return out, nil +} + +// featureCoefficients turns feature estimations into effort coefficients +// summing to exactly 1. Estimations are optional in the spec, so an incomplete +// set falls back to an even split (reported via the second return value) +// rather than failing the job — bounties still need *some* weight. +func featureCoefficients(features []apFeature) ([]float64, bool, error) { + raw := make([]float64, len(features)) + estimated := true + for i, f := range features { + if f.Estimation == nil || *f.Estimation <= 0 || math.IsNaN(*f.Estimation) { + estimated = false + break + } + raw[i] = *f.Estimation + } + if !estimated { + even := 1.0 / float64(len(features)) + for i := range raw { + raw[i] = even + } + } + norm, err := normalizeToOne(raw) + if err != nil { + return nil, false, err + } + return norm, estimated, nil +} + +// normalizeToOne rescales positive weights proportionally so they sum to +// exactly 1. Unlike NormalizeCoefficients (§5.1) it accepts any positive sum: +// this API never promises a normalized feature set, so a deviation is expected +// rather than a service fault. +func normalizeToOne(weights []float64) ([]float64, error) { + if len(weights) == 0 { + return nil, fmt.Errorf("%w: empty", ErrBadCoefficients) + } + sum := 0.0 + for _, w := range weights { + if math.IsNaN(w) || math.IsInf(w, 0) || w <= 0 { + return nil, fmt.Errorf("%w: weight %v is not positive", ErrBadCoefficients, w) + } + sum += w + } + out := make([]float64, len(weights)) + largest, total := 0, 0.0 + for i, w := range weights { + out[i] = math.Round(w/sum*10000) / 10000 + total += out[i] + if out[i] > out[largest] { + largest = i + } + } + out[largest] = math.Round((out[largest]+1-total)*10000) / 10000 + return out, nil +} + +// defaultComponents is the component tree we send for every customer. +// +// The spec marks `components` optional, but the service only emits features for +// components it considers *in scope*: an empty tree yields zero features and a +// 422 ("no in-scope components produced features"). The board has no component +// model of its own, so we send a generic tree covering the usual surfaces of a +// software change. Components irrelevant to a ticket simply produce nothing, so +// a broad tree costs nothing and a narrow one under-splits — a single catch-all +// component collapses every ticket into one feature. +var defaultComponents = []apComponent{ + {ID: "frontend", Description: "User interface: pages, views, client-side behaviour and styling."}, + {ID: "backend", Description: "Server-side application logic, APIs and background jobs."}, + {ID: "data", Description: "Data storage: schema, migrations, queries and data integrity."}, + {ID: "integrations", Description: "Integrations with external systems, third-party APIs and notifications."}, +} + +// systemFor derives the required system object from the customer, which is the +// only project-level context the board has. +func systemFor(req AtomizeRequest) apSystem { + id := strings.TrimSpace(req.CustomerID) + if id == "" { + id = "bountyboard" + } + name := strings.TrimSpace(req.CustomerName) + if name == "" { + name = "this project" + } + return apSystem{ + ID: truncate(id, maxSystemIDLen), + Description: truncate(fmt.Sprintf("Software project %q. Work is delivered as small, independently deliverable tasks picked up by freelance developers.", name), maxSystemDesc), + Components: defaultComponents, + } +} + +// issueText flattens the ticket into the single `issue` string the API takes — +// it has no fields for acceptance criteria, links or attachments. +func issueText(req AtomizeRequest) string { + var b strings.Builder + if t := strings.TrimSpace(req.Title); t != "" { + fmt.Fprintf(&b, "%s\n\n", t) + } + if d := strings.TrimSpace(req.Description); d != "" { + fmt.Fprintf(&b, "%s\n", d) + } + if len(req.AcceptanceCriteria) > 0 { + b.WriteString("\nAcceptance criteria:\n") + for _, c := range req.AcceptanceCriteria { + fmt.Fprintf(&b, "- %s\n", c) + } + } + if len(req.Links) > 0 { + b.WriteString("\nLinks:\n") + for _, l := range req.Links { + fmt.Fprintf(&b, "- %s\n", l) + } + } + if len(req.Attachments) > 0 { + b.WriteString("\nAttachments:\n") + for _, a := range req.Attachments { + fmt.Fprintf(&b, "- %s (%s): %s\n", a.Name, a.MimeType, a.URL) + } + } + s := strings.TrimSpace(b.String()) + if s == "" { + s = "(no description provided)" + } + return s +} + +// guidanceNote carries the consultant's note plus our task-count constraints, +// which the API has no dedicated field for. +func guidanceNote(req AtomizeRequest) string { + parts := make([]string, 0, 2) + if n := strings.TrimSpace(req.SubdivisionNote); n != "" { + parts = append(parts, n) + } + if c := req.Constraints; c != nil { + switch { + case c.MinTasks > 0 && c.MaxTasks > 0: + parts = append(parts, fmt.Sprintf("Produce between %d and %d features.", c.MinTasks, c.MaxTasks)) + case c.MinTasks > 0: + parts = append(parts, fmt.Sprintf("Produce at least %d features.", c.MinTasks)) + case c.MaxTasks > 0: + parts = append(parts, fmt.Sprintf("Produce at most %d features.", c.MaxTasks)) + } + } + return strings.Join(parts, "\n\n") +} + +// truncate caps a string at max bytes without splitting a rune. Byte length is +// a safe proxy for the spec's character limits: it is never smaller. +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + cut := max + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} diff --git a/internal/atomize/anypreta_test.go b/internal/atomize/anypreta_test.go new file mode 100644 index 0000000..2f75779 --- /dev/null +++ b/internal/atomize/anypreta_test.go @@ -0,0 +1,203 @@ +package atomize + +import ( + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func ptr(f float64) *float64 { return &f } + +func sum(xs []float64) float64 { + t := 0.0 + for _, x := range xs { + t += x + } + return t +} + +func TestFeatureCoefficientsUsesEstimations(t *testing.T) { + // The shape the live API returns: estimations already summing to 1. + got, estimated, err := featureCoefficients([]apFeature{ + {Estimation: ptr(0.3636363636)}, + {Estimation: ptr(0.6363636364)}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !estimated { + t.Fatal("expected estimations to be used") + } + if math.Abs(sum(got)-1) > 1e-9 { + t.Fatalf("coefficients must sum to exactly 1, got %v (sum %v)", got, sum(got)) + } + if math.Abs(got[0]-0.3636) > 1e-9 { + t.Fatalf("estimation not preserved: %v", got) + } +} + +func TestFeatureCoefficientsRescalesUnnormalized(t *testing.T) { + // Nothing in the spec promises a normalized feature set. + got, estimated, err := featureCoefficients([]apFeature{ + {Estimation: ptr(0.2)}, {Estimation: ptr(0.2)}, {Estimation: ptr(0.4)}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !estimated { + t.Fatal("expected estimations to be used") + } + if math.Abs(sum(got)-1) > 1e-9 { + t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got)) + } + if math.Abs(got[2]-0.5) > 1e-9 { + t.Fatalf("want proportional rescale (0.25/0.25/0.5), got %v", got) + } +} + +func TestFeatureCoefficientsFallsBackToEvenSplit(t *testing.T) { + // estimation is optional and nullable — a missing one must not fail the + // job, and must be reported so the consultant knows the split is not real. + got, estimated, err := featureCoefficients([]apFeature{ + {Estimation: ptr(0.5)}, {Estimation: nil}, {Estimation: ptr(0.2)}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if estimated { + t.Fatal("expected fallback to even split when an estimation is missing") + } + if math.Abs(sum(got)-1) > 1e-9 { + t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got)) + } + for _, c := range got { + if math.Abs(c-1.0/3.0) > 1e-3 { + t.Fatalf("want even split, got %v", got) + } + } +} + +func TestNormalizeToOneRejectsNonPositive(t *testing.T) { + for _, bad := range [][]float64{{0.5, 0}, {0.5, -0.1}, {math.NaN()}, {}} { + if _, err := normalizeToOne(bad); err == nil { + t.Fatalf("want error for %v", bad) + } + } +} + +func TestIssueTextCarriesFieldsTheAPILacks(t *testing.T) { + // The API takes a single `issue` string: criteria, links and attachments + // have no fields of their own and must survive in the text. + got := issueText(AtomizeRequest{ + Title: "Add CSV export", + Description: "Customers cannot export.", + AcceptanceCriteria: []string{"Downloads a .csv"}, + Links: []string{"https://tracker/1"}, + Attachments: []Attachment{{Name: "mock.png", MimeType: "image/png", URL: "https://app/f/1"}}, + }) + for _, want := range []string{"Add CSV export", "Customers cannot export.", "Downloads a .csv", "https://tracker/1", "mock.png", "https://app/f/1"} { + if !strings.Contains(got, want) { + t.Errorf("issue text lost %q:\n%s", want, got) + } + } +} + +func TestGuidanceNoteFoldsInConstraints(t *testing.T) { + got := guidanceNote(AtomizeRequest{ + SubdivisionNote: "Keep it small.", + Constraints: &Constraints{MinTasks: 2, MaxTasks: 5}, + }) + if !strings.Contains(got, "Keep it small.") || !strings.Contains(got, "between 2 and 5") { + t.Fatalf("want note + constraints, got %q", got) + } +} + +func TestTruncateDoesNotSplitRunes(t *testing.T) { + if got := truncate("héllo", 2); got != "h" { + t.Fatalf("want %q, got %q", "h", got) + } +} + +// Exercises the full remote path against a stub speaking the Anypreta wire +// format, asserting both the request we send and the children we build. +func TestRemoteAtomizeMapsFeaturesToChildren(t *testing.T) { + var got apAtomizeIssueRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/atomize-issue" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer k" { + t.Errorf("wrong auth header: %q", auth) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"feature_set":{"features":[ + {"id":"f1","title":"UI","stakeholder_summary":"s","description":"d1", + "acceptance_criteria":[{"description":"c1"},{"description":"c2"}], + "priority":1,"related_components":["web"],"estimation":0.25}, + {"id":"f2","title":"API","stakeholder_summary":"s","description":"d2", + "acceptance_criteria":[{"description":"c3"}], + "priority":2,"related_components":["api"],"estimation":0.75}]}}`)) + })) + defer srv.Close() + + resp, err := newRemote(srv.URL, "k", 5*time.Second).Atomize(t.Context(), AtomizeRequest{ + TaskID: "t1", Title: "Export", Description: "desc", + CustomerID: "cust1", CustomerName: "Acme", + }) + if err != nil { + t.Fatalf("Atomize: %v", err) + } + if got.System.ID != "cust1" || !strings.Contains(got.System.Description, "Acme") { + t.Errorf("system not derived from customer: %+v", got.System) + } + // An empty component tree makes the live service return zero features + // (422), so never let the tree go missing. + if len(got.System.Components) == 0 { + t.Error("system.components must not be empty: the service only emits features for in-scope components") + } + if len(resp.Tasks) != 2 { + t.Fatalf("want 2 children, got %d", len(resp.Tasks)) + } + if resp.Tasks[0].Title != "UI" || resp.Tasks[0].Description != "d1" { + t.Errorf("bad child mapping: %+v", resp.Tasks[0]) + } + if len(resp.Tasks[0].AcceptanceCriteria) != 2 || resp.Tasks[0].AcceptanceCriteria[0] != "c1" { + t.Errorf("acceptance criteria not mapped: %+v", resp.Tasks[0].AcceptanceCriteria) + } + if resp.Tasks[0].EffortCoefficient != 0.25 || resp.Tasks[1].EffortCoefficient != 0.75 { + t.Errorf("estimation not mapped to effort coefficient: %v / %v", + resp.Tasks[0].EffortCoefficient, resp.Tasks[1].EffortCoefficient) + } + if resp.Notes != "" { + t.Errorf("no even-split note expected when estimations are present: %q", resp.Notes) + } +} + +// A remote-backed client must still serve Extend and Summarize from the §5.1 +// service — the Anypreta API has no counterpart for either. +func TestRemoteDoesNotHijackExtend(t *testing.T) { + var hit string + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"task":{"title":"t","description":"d","acceptanceCriteria":[],"effortCoefficient":0.5}}`)) + })) + defer mock.Close() + + c := New(func() string { return mock.URL }, "tok", 5*time.Second) + c.UseRemote("http://remote.invalid", "k", 5*time.Second) + + if _, err := c.Extend(t.Context(), ExtendRequest{TaskID: "t1", ExtensionNote: "more"}); err != nil { + t.Fatalf("Extend: %v", err) + } + if hit != "/v1/extend" { + t.Fatalf("Extend must go to the §5.1 service, hit %q", hit) + } +} diff --git a/internal/atomize/client.go b/internal/atomize/client.go index 9b8eae3..5d1b35f 100644 --- a/internal/atomize/client.go +++ b/internal/atomize/client.go @@ -33,6 +33,11 @@ type AtomizeRequest struct { Links []string `json:"links"` SubdivisionNote string `json:"subdivisionNote,omitempty"` Constraints *Constraints `json:"constraints,omitempty"` + // CustomerID / CustomerName describe the project the ticket belongs to. + // The §5.1 service ignores them; the Anypreta backend requires them to + // build its mandatory `system` object. + CustomerID string `json:"customerId,omitempty"` + CustomerName string `json:"customerName,omitempty"` } type ExtendRequest struct { @@ -70,6 +75,10 @@ var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficien type Client struct { c *extsvc.Client + // remote, when set, serves Atomize from the Anypreta Issue Atomizer + // instead of the §5.1 service. Extend and Summarize have no counterpart + // there and always go to c. See anypreta.go. + remote *remote } // New builds the client. baseURL is resolved per call so the admin settings @@ -78,13 +87,46 @@ func New(baseURL func() string, token string, timeout time.Duration) *Client { return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)} } -func (a *Client) BreakerState() string { return a.c.BreakerState() } -func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) } +// UseRemote routes Atomize to the Anypreta Issue Atomizer at baseURL. +func (a *Client) UseRemote(baseURL, apiKey string, timeout time.Duration) { + a.remote = newRemote(baseURL, apiKey, timeout) +} + +// RemoteEnabled reports whether Subdivide is served by the Anypreta backend. +func (a *Client) RemoteEnabled() bool { return a.remote != nil } + +func (a *Client) BreakerState() string { + if a.remote != nil { + if s := a.remote.c.BreakerState(); s != "closed" { + return s + } + } + return a.c.BreakerState() +} + +// Healthy probes every backend an atomization can touch, so readiness and the +// admin service panel go degraded if either half is down. +func (a *Client) Healthy(ctx context.Context) error { + if err := a.c.Healthy(ctx); err != nil { + return err + } + if a.remote != nil { + if err := a.remote.c.Healthy(ctx); err != nil { + return fmt.Errorf("anypreta atomizer: %w", err) + } + } + return nil +} // Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients // must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized, -// anything worse is rejected as a service error. +// anything worse is rejected as a service error. With a remote backend +// configured the call is served by the Anypreta API instead, which normalizes +// its own feature estimations. func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) { + if a.remote != nil { + return a.remote.Atomize(ctx, req) + } var resp AtomizeResponse if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil { return nil, err diff --git a/internal/atomize/service.go b/internal/atomize/service.go index f4d2fde..ec288e1 100644 --- a/internal/atomize/service.go +++ b/internal/atomize/service.go @@ -99,6 +99,15 @@ func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error { Attachments: s.requestAttachments(t), Links: taskLinks(t), SubdivisionNote: p.Note, + CustomerID: t.CustomerID, + } + // Project context for backends that need it (the Anypreta `system` + // object). Not worth failing an atomization over. + if cust, err := s.st.CustomerByID(ctx, t.CustomerID); err == nil { + req.CustomerName = cust.Name + } else { + s.log.Warn("subdivide: customer lookup failed, sending id only", + "task", t.ID, "customer", t.CustomerID, "err", err) } if p.MinTasks > 0 || p.MaxTasks > 0 { req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks} diff --git a/internal/config/config.go b/internal/config/config.go index 5b85229..2175a03 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,9 +54,14 @@ type Config struct { AdminEmail string AdminInitialPassword string - AtomizerBaseURL string - AtomizerToken string - AtomizerTimeout time.Duration + AtomizerBaseURL string + AtomizerToken string + AtomizerTimeout time.Duration + // AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue + // Atomizer as the Subdivide backend. Both must be set; Extend and + // Summarize stay on ATOMIZER_BASE_URL either way. + AtomizerRemoteBaseURL string + AtomizerRemoteAPIKey string WorkPerformerBaseURL string WorkPerformerToken string WorkPerformerHTTPTimeout time.Duration @@ -115,10 +120,12 @@ func Load() (*Config, error) { AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))), AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"), - AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"), - AtomizerToken: os.Getenv("ATOMIZER_TOKEN"), - WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"), - WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"), + AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"), + AtomizerToken: os.Getenv("ATOMIZER_TOKEN"), + AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"), + AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"), + WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"), + WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"), SMTP: SMTP{ Host: os.Getenv("SMTP_HOST"), @@ -155,6 +162,14 @@ func Load() (*Config, error) { fail("%s: %q is not an absolute URL", name, v) } } + if c.AtomizerRemoteBaseURL != "" { + if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" { + fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL) + } + if c.AtomizerRemoteAPIKey == "" { + fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set") + } + } if len(c.SessionSecret) < 16 { fail("SESSION_SECRET: must be at least 16 characters of random data") } diff --git a/internal/extsvc/client.go b/internal/extsvc/client.go index cdb0c1c..0d08202 100644 --- a/internal/extsvc/client.go +++ b/internal/extsvc/client.go @@ -121,13 +121,34 @@ func (c *Client) callOnce(ctx context.Context, method, path string, in, out any) if resp.StatusCode >= 400 { se := &ServiceError{Status: resp.StatusCode} var env struct { + // §5 envelope: {"error":{"code":…,"message":…}}. Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` + // Flat envelope, used by the Anypreta atomizer: + // {"error_code":…,"message":…}. FastAPI's own validation + // errors carry {"detail":…} instead. + ErrorCode string `json:"error_code"` + Message string `json:"message"` + Detail json.RawMessage `json:"detail"` } if json.Unmarshal(data, &env) == nil { se.Code, se.Message = env.Error.Code, env.Error.Message + if se.Code == "" { + se.Code = env.ErrorCode + } + if se.Message == "" { + se.Message = env.Message + } + if se.Message == "" && len(env.Detail) > 0 { + se.Message = string(truncate(env.Detail, 200)) + } + } + if se.Message == "" { + // Never swallow the failure: an unrecognized envelope still + // has to reach the logs and the consultant's error toast. + se.Message = string(truncate(data, 200)) } return se // 4xx is not retryable }