package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "time" ) // llmClient calls Anthropic via the OpenAI-compatible chat-completions // endpoint (default) or the native Messages API (LLM_API_STYLE=anthropic). // Plain net/http, no SDK (§9.1). type llmClient struct { cfg config log *slog.Logger http *http.Client } func newLLMClient(cfg config, log *slog.Logger) *llmClient { return &llmClient{cfg: cfg, log: log, http: &http.Client{Timeout: 110 * time.Second}} } const atomizeSystem = `You are an expert software project planner. You split one ticket into small, well-scoped, independently deliverable developer tasks. Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching: {"tasks":[{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.25}],"notes":"short advice for the consultant"} Rules: between MIN and MAX tasks; every effortCoefficient is a number in (0,1]; all effortCoefficients MUST sum to exactly 1.0; titles are concise; descriptions are self-contained instructions; each task has 1-4 testable acceptance criteria.` const extendSystem = `You are an expert software project planner. You design exactly ONE additional sibling task that extends the given task's functionality per the extension note. Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching: {"task":{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.4},"notes":"short advice"} Rules: effortCoefficient is the effort RELATIVE to the source task, a number in (0,2].` func ticketPrompt(req atomizeRequest) string { var sb strings.Builder fmt.Fprintf(&sb, "TICKET: %s\n\nDESCRIPTION:\n%s\n", req.Title, req.Description) if len(req.AcceptanceCriteria) > 0 { sb.WriteString("\nACCEPTANCE CRITERIA:\n") for _, c := range req.AcceptanceCriteria { fmt.Fprintf(&sb, "- %s\n", c) } } if len(req.Links) > 0 { fmt.Fprintf(&sb, "\nLINKS: %s\n", strings.Join(req.Links, ", ")) } for _, a := range req.Attachments { fmt.Fprintf(&sb, "ATTACHMENT: %s (%s) %s\n", a.Name, a.MimeType, a.URL) } return sb.String() } func (l *llmClient) atomize(ctx context.Context, req atomizeRequest, minT, maxT int) ([]subTask, string, string, error) { if l.cfg.apiKey == "" { return fallbackAtomize(req, minT, maxT), "offline-fallback", "Deterministic split (no ANTHROPIC_API_KEY configured).", nil } system := strings.NewReplacer("MIN", fmt.Sprint(minT), "MAX", fmt.Sprint(maxT)).Replace(atomizeSystem) user := ticketPrompt(req) if req.SubdivisionNote != "" { user += "\nCONSULTANT NOTE: " + req.SubdivisionNote } var parsed struct { Tasks []subTask `json:"tasks"` Notes string `json:"notes"` } raw, err := l.completeWithRetry(ctx, system, user, func(text string) error { if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil { return err } if len(parsed.Tasks) == 0 { return fmt.Errorf("no tasks in response") } for _, t := range parsed.Tasks { if t.Title == "" || t.EffortCoefficient <= 0 { return fmt.Errorf("invalid task entry") } } return nil }) if err != nil { l.log.Warn("llm atomize failed, using fallback", "err", err) return fallbackAtomize(req, minT, maxT), "offline-fallback", "LLM unavailable (" + err.Error() + "); deterministic split.", nil } _ = raw return parsed.Tasks, l.cfg.model, parsed.Notes, nil } func (l *llmClient) extend(ctx context.Context, req atomizeRequest) (subTask, string, string, error) { if l.cfg.apiKey == "" { return fallbackExtend(req), "offline-fallback", "Deterministic extension (no ANTHROPIC_API_KEY configured).", nil } user := ticketPrompt(req) + "\nEXTENSION NOTE (required scope): " + req.ExtensionNote var parsed struct { Task subTask `json:"task"` Notes string `json:"notes"` } _, err := l.completeWithRetry(ctx, extendSystem, user, func(text string) error { if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil { return err } if parsed.Task.Title == "" { return fmt.Errorf("no task in response") } return nil }) if err != nil { l.log.Warn("llm extend failed, using fallback", "err", err) return fallbackExtend(req), "offline-fallback", "LLM unavailable (" + err.Error() + "); deterministic extension.", nil } return parsed.Task, l.cfg.model, parsed.Notes, nil } // completeWithRetry runs the chat completion and retries ONCE on parse // failure with a corrective hint (§9.1). func (l *llmClient) completeWithRetry(ctx context.Context, system, user string, validate func(string) error) (string, error) { text, err := l.complete(ctx, system, user) if err != nil { return "", err } if vErr := validate(text); vErr == nil { return text, nil } else { l.log.Warn("llm response failed validation, retrying once", "err", vErr) } text, err = l.complete(ctx, system, user+"\n\nIMPORTANT: your previous reply was not valid JSON matching the schema. Reply with ONLY the JSON object.") if err != nil { return "", err } if vErr := validate(text); vErr != nil { return "", fmt.Errorf("invalid JSON after retry: %w", vErr) } return text, nil } func (l *llmClient) complete(ctx context.Context, system, user string) (string, error) { if l.cfg.apiStyle == "anthropic" { return l.completeNative(ctx, system, user) } return l.completeOpenAI(ctx, system, user) } // completeOpenAI uses Anthropic's OpenAI-compatible endpoint. func (l *llmClient) completeOpenAI(ctx context.Context, system, user string) (string, error) { body, _ := json.Marshal(map[string]any{ "model": l.cfg.model, "max_tokens": 4096, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.cfg.baseURL+"/chat/completions", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+l.cfg.apiKey) data, err := l.do(req) if err != nil { return "", err } var out struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(data, &out); err != nil { return "", fmt.Errorf("decode chat completion: %w", err) } if len(out.Choices) == 0 { return "", fmt.Errorf("no choices in response") } return out.Choices[0].Message.Content, nil } // completeNative uses the native Anthropic Messages API (flip via // LLM_API_STYLE=anthropic if the compatibility endpoint misbehaves, §9.1). func (l *llmClient) completeNative(ctx context.Context, system, user string) (string, error) { body, _ := json.Marshal(map[string]any{ "model": l.cfg.model, "max_tokens": 4096, "system": system, "messages": []map[string]string{{"role": "user", "content": user}}, }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.cfg.baseURL+"/messages", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", l.cfg.apiKey) req.Header.Set("anthropic-version", "2023-06-01") data, err := l.do(req) if err != nil { return "", err } var out struct { Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } if err := json.Unmarshal(data, &out); err != nil { return "", fmt.Errorf("decode messages response: %w", err) } for _, c := range out.Content { if c.Type == "text" { return c.Text, nil } } return "", fmt.Errorf("no text content in response") } func (l *llmClient) do(req *http.Request) ([]byte, error) { resp, err := l.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("llm api status %d: %.300s", resp.StatusCode, data) } return data, nil } // stripFences defensively removes markdown code fences and surrounding prose // (§9.1) by slicing from the first '{' to the last '}'. func stripFences(s string) string { s = strings.TrimSpace(s) if i := strings.IndexByte(s, '{'); i >= 0 { if j := strings.LastIndexByte(s, '}'); j > i { return s[i : j+1] } } return s }