feat(atomize): serve Subdivide via Anypreta Issue Atomizer

Add an optional remote atomization backend (the Anypreta "Issue Atomizer"
API) for the Subdivide flow, selected by ATOMIZER_REMOTE_BASE_URL /
ATOMIZER_REMOTE_API_KEY. When set, POST /v1/atomize is served by the remote
/v1/atomize-issue endpoint; its two-level issue→features→tasks model is mapped
onto our one-level task→children model by treating each Feature as a child and
its `estimation` as the effort coefficient (renormalized to sum to 1, even
split as a reported fallback when estimations are absent).

Extend and Summarize have no counterpart in the remote API and always stay on
ATOMIZER_BASE_URL, so the §5.1 service must keep running; readiness now probes
both backends. The mandatory `system` object is derived from the customer,
with a generic component tree (an empty tree makes the service return zero
features).

extsvc also learns the remote's flat error envelope ({error_code,message} and
FastAPI {detail}) and never swallows an unrecognized 4xx body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-07-15 17:49:41 +02:00
parent 39ec958f04
commit 33c9c39676
9 changed files with 613 additions and 10 deletions
+203
View File
@@ -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)
}
}