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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user