33c9c39676
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>
288 lines
9.4 KiB
Go
288 lines
9.4 KiB
Go
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]
|
|
}
|