initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

View file

@ -0,0 +1,139 @@
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 }