package analysis import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "time" "go-places-api/internal/domain" ) // SourceRemote identifies badges produced by an external inference service. const SourceRemote = "remote" // RemoteAnalyzer forwards a picture to an external inference service that runs // a trained object or scene classifier, and turns the labels it returns into // badges. This is the path that recognises specific objects; the local analyzer // only produces scene-level categories. type RemoteAnalyzer struct { baseURL string apiKey string client *http.Client // maxBytes bounds the payload sent upstream so a large upload cannot be // forwarded without limit. maxBytes int64 } var _ Analyzer = (*RemoteAnalyzer)(nil) // NewRemote returns a RemoteAnalyzer calling baseURL. The timeout bounds the // whole request including reading the response. func NewRemote(baseURL, apiKey string, timeout time.Duration) *RemoteAnalyzer { if timeout <= 0 { timeout = 10 * time.Second } return &RemoteAnalyzer{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, client: &http.Client{Timeout: timeout}, maxBytes: 12 << 20, } } // remoteRequest is the payload sent to the inference service. type remoteRequest struct { ContentType string `json:"contentType"` ImageBase64 string `json:"imageBase64"` // Hint carries locally derived scene badges so the service can disambiguate // using the signals we already computed. Hint []string `json:"hint,omitempty"` } // remoteResponse is the expected reply. Width and height are optional because // the service may not report them. type remoteResponse struct { Width int `json:"width"` Height int `json:"height"` Labels []struct { Label string `json:"label"` Display string `json:"display"` Confidence float64 `json:"confidence"` } `json:"labels"` } // Analyze sends the picture upstream and converts the reply into badges. func (r *RemoteAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) { if len(in.Bytes) == 0 { return nil, fmt.Errorf("analysis: empty image payload") } if int64(len(in.Bytes)) > r.maxBytes { return nil, fmt.Errorf("analysis: payload of %d bytes exceeds remote limit of %d", len(in.Bytes), r.maxBytes) } body, err := json.Marshal(remoteRequest{ ContentType: in.ContentType, ImageBase64: base64.StdEncoding.EncodeToString(in.Bytes), }) if err != nil { return nil, fmt.Errorf("analysis: encode request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/v1/analyze", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("analysis: build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if r.apiKey != "" { req.Header.Set("Authorization", "Bearer "+r.apiKey) } resp, err := r.client.Do(req) if err != nil { return nil, fmt.Errorf("analysis: call inference service: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // Read a bounded amount of the error body to aid debugging without // letting an error response stream unbounded data. snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("analysis: inference service returned %s: %s", resp.Status, strings.TrimSpace(string(snippet))) } // Bound the response so a misbehaving service cannot exhaust memory. var decoded remoteResponse if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&decoded); err != nil { return nil, fmt.Errorf("analysis: decode inference response: %w", err) } res := &Result{Width: decoded.Width, Height: decoded.Height} for _, l := range decoded.Labels { label := strings.TrimSpace(strings.ToLower(l.Label)) if label == "" { continue } display := strings.TrimSpace(l.Display) if display == "" { display = Humanize(label) } res.Badges = append(res.Badges, domain.Badge{ Label: label, Display: display, Confidence: round2(clamp01(l.Confidence)), Source: SourceRemote, }) } return res, nil } // Name implements Analyzer. func (r *RemoteAnalyzer) Name() string { return SourceRemote }