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
+45 -3
View File
@@ -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