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
+287
View File
@@ -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 <key>.
// 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]
}