feat: implement Iteration 3 — product/receipt/dish recognition
Backend: - gemini/client.go: refactor to shared callGroq transport; add generateVisionContent using llama-3.2-11b-vision-preview model - gemini/recognition.go: RecognizeReceipt, RecognizeProducts, RecognizeDish (vision), ClassifyIngredient (text); shared parseJSON helper - ingredient/repository.go: add FuzzyMatch (wraps Search, returns best hit) - recognition/handler.go: POST /ai/recognize-receipt, /ai/recognize-products, /ai/recognize-dish; enrichItems with fuzzy match + AI classify fallback; parallel multi-image processing with deduplication - server.go + main.go: wire recognition handler under /ai routes Flutter: - pubspec.yaml: add image_picker ^1.1.0 - AndroidManifest.xml: add CAMERA and READ_EXTERNAL_STORAGE permissions - Info.plist: add NSCameraUsageDescription and NSPhotoLibraryUsageDescription - recognition_service.dart: RecognitionService wrapping /ai/* endpoints; RecognizedItem, ReceiptResult, DishResult models - scan_screen.dart: mode selector (receipt / products / dish / manual); image source picker; loading overlay; navigates to confirm or dish screen - recognition_confirm_screen.dart: editable list of recognized items; inline qty/unit editing; swipe-to-delete; batch-add to pantry - dish_result_screen.dart: dish name, KBZHU breakdown, similar dishes chips - app_router.dart: /scan, /scan/confirm, /scan/dish routes (no bottom nav) - products_screen.dart: FAB now shows bottom sheet with Manual / Scan options Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Groq — OpenAI-compatible API, free tier, no billing required.
|
||||
// groqAPIURL is the Groq OpenAI-compatible endpoint (free tier, no billing required).
|
||||
groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
|
||||
groqModel = "llama-3.3-70b-versatile"
|
||||
|
||||
// groqModel is the default text generation model.
|
||||
groqModel = "llama-3.3-70b-versatile"
|
||||
|
||||
// groqVisionModel supports image inputs in OpenAI vision format.
|
||||
groqVisionModel = "llama-3.2-11b-vision-preview"
|
||||
|
||||
maxRetries = 3
|
||||
)
|
||||
|
||||
@@ -28,16 +34,49 @@ func NewClient(apiKey string) *Client {
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Timeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateContent sends a user prompt to Groq and returns the assistant text.
|
||||
// generateContent sends text messages to the text-only model.
|
||||
func (c *Client) generateContent(ctx context.Context, messages []map[string]string) (string, error) {
|
||||
return c.callGroq(ctx, groqModel, 0.7, messages)
|
||||
}
|
||||
|
||||
// generateVisionContent sends an image + text prompt to the vision model.
|
||||
// imageBase64 must be the raw base64-encoded image data (no data URI prefix).
|
||||
// mimeType defaults to "image/jpeg" if empty.
|
||||
func (c *Client) generateVisionContent(ctx context.Context, prompt, imageBase64, mimeType string) (string, error) {
|
||||
if mimeType == "" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, imageBase64)
|
||||
|
||||
messages := []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]string{"url": dataURL},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return c.callGroq(ctx, groqVisionModel, 0.1, messages)
|
||||
}
|
||||
|
||||
// callGroq is the shared HTTP transport for all Groq requests.
|
||||
// messages can be []map[string]string (text) or []any (vision with image content).
|
||||
func (c *Client) callGroq(ctx context.Context, model string, temperature float64, messages any) (string, error) {
|
||||
body := map[string]any{
|
||||
"model": groqModel,
|
||||
"temperature": 0.7,
|
||||
"model": model,
|
||||
"temperature": temperature,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
|
||||
221
backend/internal/gemini/recognition.go
Normal file
221
backend/internal/gemini/recognition.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"context"
|
||||
)
|
||||
|
||||
// RecognizedItem is a food item identified in an image.
|
||||
type RecognizedItem struct {
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// UnrecognizedItem is text from a receipt that could not be identified as food.
|
||||
type UnrecognizedItem struct {
|
||||
RawText string `json:"raw_text"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
}
|
||||
|
||||
// ReceiptResult is the full result of receipt OCR.
|
||||
type ReceiptResult struct {
|
||||
Items []RecognizedItem `json:"items"`
|
||||
Unrecognized []UnrecognizedItem `json:"unrecognized"`
|
||||
}
|
||||
|
||||
// DishResult is the result of dish recognition.
|
||||
type DishResult struct {
|
||||
DishName string `json:"dish_name"`
|
||||
WeightGrams int `json:"weight_grams"`
|
||||
Calories float64 `json:"calories"`
|
||||
ProteinG float64 `json:"protein_g"`
|
||||
FatG float64 `json:"fat_g"`
|
||||
CarbsG float64 `json:"carbs_g"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SimilarDishes []string `json:"similar_dishes"`
|
||||
}
|
||||
|
||||
// IngredientClassification is the AI-produced classification of an unknown food item.
|
||||
type IngredientClassification struct {
|
||||
CanonicalName string `json:"canonical_name"`
|
||||
CanonicalNameRu string `json:"canonical_name_ru"`
|
||||
Category string `json:"category"`
|
||||
DefaultUnit string `json:"default_unit"`
|
||||
CaloriesPer100g *float64 `json:"calories_per_100g"`
|
||||
ProteinPer100g *float64 `json:"protein_per_100g"`
|
||||
FatPer100g *float64 `json:"fat_per_100g"`
|
||||
CarbsPer100g *float64 `json:"carbs_per_100g"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
Aliases []string `json:"aliases"`
|
||||
}
|
||||
|
||||
// RecognizeReceipt uses the vision model to extract food items from a receipt photo.
|
||||
func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ReceiptResult, error) {
|
||||
prompt := `Ты — OCR-система для чеков из продуктовых магазинов.
|
||||
|
||||
Проанализируй фото чека и извлеки список продуктов питания.
|
||||
Для каждого продукта определи:
|
||||
- name: название на русском языке (убери артикулы, коды, лишние символы)
|
||||
- quantity: количество (число)
|
||||
- unit: единица (г, кг, мл, л, шт, уп)
|
||||
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||||
- confidence: 0.0–1.0
|
||||
|
||||
Позиции, которые не являются едой (бытовая химия, табак, алкоголь) — пропусти.
|
||||
Позиции с нечитаемым текстом — добавь в unrecognized.
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без markdown:
|
||||
{
|
||||
"items": [
|
||||
{"name": "Молоко 2.5%", "quantity": 1, "unit": "л", "category": "dairy", "confidence": 0.95}
|
||||
],
|
||||
"unrecognized": [
|
||||
{"raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0}
|
||||
]
|
||||
}`
|
||||
|
||||
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recognize receipt: %w", err)
|
||||
}
|
||||
|
||||
var result ReceiptResult
|
||||
if err := parseJSON(text, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse receipt result: %w", err)
|
||||
}
|
||||
if result.Items == nil {
|
||||
result.Items = []RecognizedItem{}
|
||||
}
|
||||
if result.Unrecognized == nil {
|
||||
result.Unrecognized = []UnrecognizedItem{}
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RecognizeProducts uses the vision model to identify food items in a photo (fridge, shelf, etc.).
|
||||
func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]RecognizedItem, error) {
|
||||
prompt := `Ты — система распознавания продуктов питания.
|
||||
|
||||
Посмотри на фото и определи все видимые продукты питания.
|
||||
Для каждого продукта оцени:
|
||||
- name: название на русском языке
|
||||
- quantity: приблизительное количество (число)
|
||||
- unit: единица (г, кг, мл, л, шт)
|
||||
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||||
- confidence: 0.0–1.0
|
||||
|
||||
Только продукты питания. Пустые упаковки и несъедобные предметы — пропусти.
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без markdown:
|
||||
{
|
||||
"items": [
|
||||
{"name": "Яйца", "quantity": 10, "unit": "шт", "category": "dairy", "confidence": 0.9}
|
||||
]
|
||||
}`
|
||||
|
||||
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recognize products: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Items []RecognizedItem `json:"items"`
|
||||
}
|
||||
if err := parseJSON(text, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse products result: %w", err)
|
||||
}
|
||||
if result.Items == nil {
|
||||
return []RecognizedItem{}, nil
|
||||
}
|
||||
return result.Items, nil
|
||||
}
|
||||
|
||||
// RecognizeDish uses the vision model to identify a dish and estimate its nutritional content.
|
||||
func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*DishResult, error) {
|
||||
prompt := `Ты — диетолог и кулинарный эксперт.
|
||||
|
||||
Посмотри на фото блюда и определи:
|
||||
- dish_name: название блюда на русском языке
|
||||
- weight_grams: приблизительный вес порции в граммах
|
||||
- calories: калорийность порции (приблизительно)
|
||||
- protein_g, fat_g, carbs_g: БЖУ на порцию
|
||||
- confidence: 0.0–1.0
|
||||
- similar_dishes: до 3 похожих блюд (для поиска рецептов)
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без markdown:
|
||||
{
|
||||
"dish_name": "Паста Карбонара",
|
||||
"weight_grams": 350,
|
||||
"calories": 520,
|
||||
"protein_g": 22,
|
||||
"fat_g": 26,
|
||||
"carbs_g": 48,
|
||||
"confidence": 0.85,
|
||||
"similar_dishes": ["Паста с беконом", "Спагетти"]
|
||||
}`
|
||||
|
||||
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recognize dish: %w", err)
|
||||
}
|
||||
|
||||
var result DishResult
|
||||
if err := parseJSON(text, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse dish result: %w", err)
|
||||
}
|
||||
if result.SimilarDishes == nil {
|
||||
result.SimilarDishes = []string{}
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ClassifyIngredient uses the text model to classify an unknown food item
|
||||
// and build an ingredient_mappings record for it.
|
||||
func (c *Client) ClassifyIngredient(ctx context.Context, name string) (*IngredientClassification, error) {
|
||||
prompt := fmt.Sprintf(`Классифицируй продукт питания: "%s".
|
||||
|
||||
Ответь ТОЛЬКО валидным JSON без markdown:
|
||||
{
|
||||
"canonical_name": "turkey_breast",
|
||||
"canonical_name_ru": "грудка индейки",
|
||||
"category": "meat",
|
||||
"default_unit": "g",
|
||||
"calories_per_100g": 135,
|
||||
"protein_per_100g": 29,
|
||||
"fat_per_100g": 1,
|
||||
"carbs_per_100g": 0,
|
||||
"storage_days": 3,
|
||||
"aliases": ["грудка индейки", "филе индейки", "turkey breast"]
|
||||
}`, name)
|
||||
|
||||
messages := []map[string]string{
|
||||
{"role": "user", "content": prompt},
|
||||
}
|
||||
text, err := c.generateContent(ctx, messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classify ingredient: %w", err)
|
||||
}
|
||||
|
||||
var result IngredientClassification
|
||||
if err := parseJSON(text, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse classification: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// parseJSON strips optional markdown fences and unmarshals JSON.
|
||||
func parseJSON(text string, dst any) error {
|
||||
text = strings.TrimSpace(text)
|
||||
if strings.HasPrefix(text, "```") {
|
||||
text = strings.TrimPrefix(text, "```json")
|
||||
text = strings.TrimPrefix(text, "```")
|
||||
text = strings.TrimSuffix(text, "```")
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
return json.Unmarshal([]byte(text), dst)
|
||||
}
|
||||
@@ -94,6 +94,19 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
|
||||
return m, err
|
||||
}
|
||||
|
||||
// FuzzyMatch finds the single best matching ingredient mapping for a given name.
|
||||
// Returns nil, nil when no match is found.
|
||||
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
||||
results, err := r.Search(ctx, name, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
// Search finds ingredient mappings matching the query string.
|
||||
// Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity.
|
||||
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
|
||||
|
||||
303
backend/internal/recognition/handler.go
Normal file
303
backend/internal/recognition/handler.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package recognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/food-ai/backend/internal/gemini"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
)
|
||||
|
||||
// ingredientRepo is the subset of ingredient.Repository used by this handler.
|
||||
type ingredientRepo interface {
|
||||
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
||||
Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error)
|
||||
}
|
||||
|
||||
// Handler handles POST /ai/* recognition endpoints.
|
||||
type Handler struct {
|
||||
gemini *gemini.Client
|
||||
ingredientRepo ingredientRepo
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(geminiClient *gemini.Client, repo ingredientRepo) *Handler {
|
||||
return &Handler{gemini: geminiClient, ingredientRepo: repo}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// imageRequest is the common request body containing a single base64-encoded image.
|
||||
type imageRequest struct {
|
||||
ImageBase64 string `json:"image_base64"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// imagesRequest is the request body for multi-image endpoints.
|
||||
type imagesRequest struct {
|
||||
Images []imageRequest `json:"images"`
|
||||
}
|
||||
|
||||
// EnrichedItem is a recognized food item enriched with ingredient_mappings data.
|
||||
type EnrichedItem struct {
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
MappingID *string `json:"mapping_id"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
}
|
||||
|
||||
// ReceiptResponse is the response for POST /ai/recognize-receipt.
|
||||
type ReceiptResponse struct {
|
||||
Items []EnrichedItem `json:"items"`
|
||||
Unrecognized []gemini.UnrecognizedItem `json:"unrecognized"`
|
||||
}
|
||||
|
||||
// DishResponse is the response for POST /ai/recognize-dish.
|
||||
type DishResponse = gemini.DishResult
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RecognizeReceipt handles POST /ai/recognize-receipt.
|
||||
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
|
||||
func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
_ = userID // logged for tracing
|
||||
|
||||
var req imageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.gemini.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType)
|
||||
if err != nil {
|
||||
slog.Error("recognize receipt", "err", err)
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
enriched := h.enrichItems(r.Context(), result.Items)
|
||||
writeJSON(w, http.StatusOK, ReceiptResponse{
|
||||
Items: enriched,
|
||||
Unrecognized: result.Unrecognized,
|
||||
})
|
||||
}
|
||||
|
||||
// RecognizeProducts handles POST /ai/recognize-products.
|
||||
// Body: {"images": [{"image_base64": "...", "mime_type": "image/jpeg"}, ...]}
|
||||
func (h *Handler) RecognizeProducts(w http.ResponseWriter, r *http.Request) {
|
||||
var req imagesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Images) == 0 {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "at least one image is required")
|
||||
return
|
||||
}
|
||||
if len(req.Images) > 3 {
|
||||
req.Images = req.Images[:3] // cap at 3 photos as per spec
|
||||
}
|
||||
|
||||
// Process each image in parallel.
|
||||
allItems := make([][]gemini.RecognizedItem, len(req.Images))
|
||||
var wg sync.WaitGroup
|
||||
for i, img := range req.Images {
|
||||
wg.Add(1)
|
||||
go func(i int, img imageRequest) {
|
||||
defer wg.Done()
|
||||
items, err := h.gemini.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType)
|
||||
if err != nil {
|
||||
slog.Warn("recognize products from image", "index", i, "err", err)
|
||||
return
|
||||
}
|
||||
allItems[i] = items
|
||||
}(i, img)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
merged := mergeAndDeduplicate(allItems)
|
||||
enriched := h.enrichItems(r.Context(), merged)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": enriched})
|
||||
}
|
||||
|
||||
// RecognizeDish handles POST /ai/recognize-dish.
|
||||
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
|
||||
func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
|
||||
var req imageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.gemini.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType)
|
||||
if err != nil {
|
||||
slog.Error("recognize dish", "err", err)
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// enrichItems matches each recognized item against ingredient_mappings.
|
||||
// Items without a match trigger a Gemini classification call and upsert into the DB.
|
||||
func (h *Handler) enrichItems(ctx context.Context, items []gemini.RecognizedItem) []EnrichedItem {
|
||||
result := make([]EnrichedItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
enriched := EnrichedItem{
|
||||
Name: item.Name,
|
||||
Quantity: item.Quantity,
|
||||
Unit: item.Unit,
|
||||
Category: item.Category,
|
||||
Confidence: item.Confidence,
|
||||
StorageDays: 7, // sensible default
|
||||
}
|
||||
|
||||
mapping, err := h.ingredientRepo.FuzzyMatch(ctx, item.Name)
|
||||
if err != nil {
|
||||
slog.Warn("fuzzy match ingredient", "name", item.Name, "err", err)
|
||||
}
|
||||
|
||||
if mapping != nil {
|
||||
// Found existing mapping — use its canonical data.
|
||||
id := mapping.ID
|
||||
enriched.MappingID = &id
|
||||
if mapping.DefaultUnit != nil {
|
||||
enriched.Unit = *mapping.DefaultUnit
|
||||
}
|
||||
if mapping.StorageDays != nil {
|
||||
enriched.StorageDays = *mapping.StorageDays
|
||||
}
|
||||
if mapping.Category != nil {
|
||||
enriched.Category = *mapping.Category
|
||||
}
|
||||
} else {
|
||||
// No mapping — ask AI to classify and save for future reuse.
|
||||
classification, err := h.gemini.ClassifyIngredient(ctx, item.Name)
|
||||
if err != nil {
|
||||
slog.Warn("classify unknown ingredient", "name", item.Name, "err", err)
|
||||
} else {
|
||||
saved := h.saveClassification(ctx, classification)
|
||||
if saved != nil {
|
||||
id := saved.ID
|
||||
enriched.MappingID = &id
|
||||
}
|
||||
enriched.Category = classification.Category
|
||||
enriched.Unit = classification.DefaultUnit
|
||||
enriched.StorageDays = classification.StorageDays
|
||||
}
|
||||
}
|
||||
result = append(result, enriched)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// saveClassification upserts an AI-produced ingredient classification into the DB.
|
||||
func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientClassification) *ingredient.IngredientMapping {
|
||||
if c == nil || c.CanonicalName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
aliasesJSON, err := json.Marshal(c.Aliases)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := &ingredient.IngredientMapping{
|
||||
CanonicalName: c.CanonicalName,
|
||||
CanonicalNameRu: &c.CanonicalNameRu,
|
||||
Category: strPtr(c.Category),
|
||||
DefaultUnit: strPtr(c.DefaultUnit),
|
||||
CaloriesPer100g: c.CaloriesPer100g,
|
||||
ProteinPer100g: c.ProteinPer100g,
|
||||
FatPer100g: c.FatPer100g,
|
||||
CarbsPer100g: c.CarbsPer100g,
|
||||
StorageDays: intPtr(c.StorageDays),
|
||||
Aliases: aliasesJSON,
|
||||
}
|
||||
|
||||
saved, err := h.ingredientRepo.Upsert(ctx, m)
|
||||
if err != nil {
|
||||
slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err)
|
||||
return nil
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
// mergeAndDeduplicate combines results from multiple images.
|
||||
// Items sharing the same name (case-insensitive) have their quantities summed.
|
||||
func mergeAndDeduplicate(batches [][]gemini.RecognizedItem) []gemini.RecognizedItem {
|
||||
seen := make(map[string]*gemini.RecognizedItem)
|
||||
var order []string
|
||||
|
||||
for _, batch := range batches {
|
||||
for i := range batch {
|
||||
item := &batch[i]
|
||||
key := normalizeName(item.Name)
|
||||
if existing, ok := seen[key]; ok {
|
||||
existing.Quantity += item.Quantity
|
||||
// Keep the higher confidence estimate.
|
||||
if item.Confidence > existing.Confidence {
|
||||
existing.Confidence = item.Confidence
|
||||
}
|
||||
} else {
|
||||
seen[key] = item
|
||||
order = append(order, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]gemini.RecognizedItem, 0, len(order))
|
||||
for _, key := range order {
|
||||
result = append(result, *seen[key])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeName(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func intPtr(n int) *int {
|
||||
return &n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
"github.com/food-ai/backend/internal/recommendation"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
@@ -23,6 +24,7 @@ func NewRouter(
|
||||
savedRecipeHandler *savedrecipe.Handler,
|
||||
ingredientHandler *ingredient.Handler,
|
||||
productHandler *product.Handler,
|
||||
recognitionHandler *recognition.Handler,
|
||||
authMiddleware func(http.Handler) http.Handler,
|
||||
allowedOrigins []string,
|
||||
) *chi.Mux {
|
||||
@@ -71,6 +73,12 @@ func NewRouter(
|
||||
r.Put("/{id}", productHandler.Update)
|
||||
r.Delete("/{id}", productHandler.Delete)
|
||||
})
|
||||
|
||||
r.Route("/ai", func(r chi.Router) {
|
||||
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
|
||||
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
|
||||
r.Post("/recognize-dish", recognitionHandler.RecognizeDish)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
Reference in New Issue
Block a user