sara.mesquita commited on
Commit
f9f4f9a
·
1 Parent(s): 0d61aae

Changes nemotron model

Browse files
Files changed (2) hide show
  1. README.md +22 -13
  2. core/ai.py +110 -70
README.md CHANGED
@@ -16,13 +16,17 @@ tags:
16
  - small-models
17
  - gradio
18
  - build-small-hackathon
 
 
 
 
19
  - backyard-ai
20
  - leaflet
21
  - cosine-similarity
22
  models:
23
- - nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
24
- - sentence-transformers/all-MiniLM-L6-v2
25
  - meta-llama/Llama-3.2-11B-Vision-Instruct
 
 
26
  ---
27
 
28
  # PawMap 🐾 — Collaborative stray animal mapping with AI
@@ -34,6 +38,9 @@ Built for the [**Build Small Hackathon 2026**](https://huggingface.co/build-smal
34
 
35
  **Try it:** [live Space](https://huggingface.co/spaces/build-small-hackathon/viralata-mapper) · **Traces:** [dataset on the Hub](https://huggingface.co/datasets/build-small-hackathon/viralata-mapper-storage)
36
 
 
 
 
37
  ---
38
 
39
  ## The Problem
@@ -54,10 +61,10 @@ There are two AI-powered flows: **registering a sighting** and **recording help*
54
 
55
  ```mermaid
56
  flowchart TD
57
- A["📷 Photo from phone"] --> B["🤖 Vision AI\nLlama-3.2-11B or Nemotron Omni\n(serverless API)"]
58
  B -->|"is it an animal?"| C{Animal detected?}
59
  C -->|"No"| ERR["❌ Error message\n'No dog or cat identified'"]
60
- C -->|"Yes"| D["📝 Structured JSON\nspecies · breed · color · condition · marks"]
61
  D --> E["🔢 Semantic embedding\nall-MiniLM-L6-v2 · 384 dim\n(local, 22M params)"]
62
  E --> F{Cosine similarity\n≥ 0.80?}
63
  F -->|"Yes — known animal"| G["➕ New sighting\nadded to existing animal"]
@@ -99,14 +106,14 @@ The map uses color to signal urgency: **🟢 green** = dog · **🟠 orange** =
99
 
100
  | Component | Model / Library | Where it runs |
101
  |---|---|---|
102
- | Visual identification | **Nemotron Omni 30B** via NVIDIA NIM | remote API |
103
- | Vision fallback | **Llama-3.2-11B-Vision-Instruct** via HF Serverless | remote API |
104
- | Semantic embedding | **all-MiniLM-L6-v2** (384 dim) | CPU, local |
105
  | Animal matching | Cosine similarity (threshold 0.80) | CPU, local |
106
  | Database | SQLite | local / persistent |
107
  | Frontend | SPA via `gradio.Server` + Leaflet.js + Lucide Icons | browser |
108
 
109
- **Total parameters running locally: ~22M.** The vision model runs via serverless API — nothing heavy on the Space machine. The only local model is MiniLM for embeddings.
110
 
111
  ---
112
 
@@ -129,6 +136,8 @@ This submission earns the following hackathon bonus quests:
129
 
130
  - 🎨 **Off-Brand** — Fully custom interface via `gradio.Server`, no default Gradio components visible. SPA frontend with Leaflet.js for the map, Lucide Icons, and custom design.
131
  - 📡 **Sharing is Caring** — Full traces of every sighting (photo → AI analysis → embedding → matching result) are automatically published as a dataset on the Hub via `/admin/push-traces`.
 
 
132
 
133
  ---
134
 
@@ -139,10 +148,10 @@ git clone https://huggingface.co/spaces/build-small-hackathon/viralata-mapper
139
  cd viralata-mapper
140
  pip install -r requirements.txt
141
 
142
- # with HuggingFace Serverless
143
  HF_TOKEN=hf_... python app.py
144
 
145
- # or with NVIDIA NIM (takes precedence over HF_TOKEN)
146
  NVIDIA_API_KEY=nvapi_... python app.py
147
 
148
  # no key — runs in fallback mode without AI identification
@@ -162,7 +171,7 @@ On first run the app seeds the database with demo animals around Brasília so th
162
  | Secret | Description |
163
  |---|---|
164
  | `HF_TOKEN` | HuggingFace token for Llama-3.2-11B-Vision via Serverless Inference |
165
- | `NVIDIA_API_KEY` | Alternative: Nemotron Omni via NVIDIA NIM (takes precedence over HF_TOKEN) |
166
  | `MATCH_THRESHOLD` | Optional. Similarity threshold. Default: `0.80` |
167
  | `HF_DATASET_ID` | Optional. Hub dataset for trace publishing (e.g. `org/dataset-name`) |
168
 
@@ -214,7 +223,7 @@ Set up a **Persistent Storage Bucket** in the Space and mount it at `/data/` so
214
  ## Credits
215
 
216
  - **[Meta](https://ai.meta.com/llama/)** — Llama 3.2 11B Vision Instruct (Llama 3.2 Community License).
217
- - **[NVIDIA](https://build.nvidia.com/)** — Nemotron Omni 30B via NVIDIA NIM.
218
  - **[Hugging Face](https://huggingface.co/)** — `sentence-transformers/all-MiniLM-L6-v2`, Serverless Inference, Space hosting.
219
  - **[Leaflet.js](https://leafletjs.com/)** — interactive map.
220
  - **[Gradio](https://gradio.app/)** — `gradio.Server` for the custom frontend.
@@ -224,7 +233,7 @@ Set up a **Persistent Storage Bucket** in the Space and mount it at `/data/` so
224
 
225
  ## License
226
 
227
- MIT for the application code. Models follow their own licenses — see the Meta (Llama 3.2) and NVIDIA (Nemotron Omni) model cards for full terms.
228
 
229
  ---
230
 
 
16
  - small-models
17
  - gradio
18
  - build-small-hackathon
19
+ - track:backyard
20
+ - sponsor:nvidia
21
+ - achievement:offbrand
22
+ - achievement:sharing
23
  - backyard-ai
24
  - leaflet
25
  - cosine-similarity
26
  models:
 
 
27
  - meta-llama/Llama-3.2-11B-Vision-Instruct
28
+ - nvidia/Nemotron-Mini-4B-Instruct
29
+ - sentence-transformers/all-MiniLM-L6-v2
30
  ---
31
 
32
  # PawMap 🐾 — Collaborative stray animal mapping with AI
 
38
 
39
  **Try it:** [live Space](https://huggingface.co/spaces/build-small-hackathon/viralata-mapper) · **Traces:** [dataset on the Hub](https://huggingface.co/datasets/build-small-hackathon/viralata-mapper-storage)
40
 
41
+ > 🎬 **Demo video:** `TODO — paste your YouTube / Vimeo / Loom link here before submitting`
42
+ > 📣 **Social post:** `TODO — paste your X / LinkedIn / Bluesky / Mastodon post link here before submitting`
43
+
44
  ---
45
 
46
  ## The Problem
 
61
 
62
  ```mermaid
63
  flowchart TD
64
+ A["📷 Photo from phone"] --> B["🤖 Vision AI\nLlama-3.2-11B-Vision\n(HF Serverless)"]
65
  B -->|"is it an animal?"| C{Animal detected?}
66
  C -->|"No"| ERR["❌ Error message\n'No dog or cat identified'"]
67
+ C -->|"Yes"| D["📝 Structured JSON\nNemotron Mini 4B Instruct normalizes\nspecies · breed · color · condition · marks"]
68
  D --> E["🔢 Semantic embedding\nall-MiniLM-L6-v2 · 384 dim\n(local, 22M params)"]
69
  E --> F{Cosine similarity\n≥ 0.80?}
70
  F -->|"Yes — known animal"| G["➕ New sighting\nadded to existing animal"]
 
106
 
107
  | Component | Model / Library | Where it runs |
108
  |---|---|---|
109
+ | Visual identification | **Llama-3.2-11B-Vision-Instruct** (11B) via HF Serverless | remote API |
110
+ | Text reasoning / JSON | **Nemotron Mini 4B Instruct** via NVIDIA NIM | remote API |
111
+ | Semantic embedding | **all-MiniLM-L6-v2** (384 dim, 22M params) | CPU, local |
112
  | Animal matching | Cosine similarity (threshold 0.80) | CPU, local |
113
  | Database | SQLite | local / persistent |
114
  | Frontend | SPA via `gradio.Server` + Leaflet.js + Lucide Icons | browser |
115
 
116
+ **Total parameters running locally: ~22M** (MiniLM). The vision and reasoning models run via serverless API — nothing heavy on the Space machine. **Every model used is well under the 32B hackathon cap:** Llama-3.2-11B-Vision (11B), Nemotron Mini 4B Instruct (4B) and MiniLM-L6-v2 (22M).
117
 
118
  ---
119
 
 
136
 
137
  - 🎨 **Off-Brand** — Fully custom interface via `gradio.Server`, no default Gradio components visible. SPA frontend with Leaflet.js for the map, Lucide Icons, and custom design.
138
  - 📡 **Sharing is Caring** — Full traces of every sighting (photo → AI analysis → embedding → matching result) are automatically published as a dataset on the Hub via `/admin/push-traces`.
139
+ - 🤏 **Tiny Titan** — The only on-device model is `all-MiniLM-L6-v2` (22M params, far under 4B): all animal matching runs locally on a tiny embedding model.
140
+ - 🟩 **NVIDIA / Nemotron** — Structured identification is normalized by `Nemotron-Mini-4B-Instruct` via NVIDIA NIM — small (4B), well under the 32B cap.
141
 
142
  ---
143
 
 
148
  cd viralata-mapper
149
  pip install -r requirements.txt
150
 
151
+ # Vision via HuggingFace Serverless (Llama-3.2-11B-Vision)
152
  HF_TOKEN=hf_... python app.py
153
 
154
+ # Add NVIDIA NIM for Nemotron Mini 4B Instruct (structured-output normalization)
155
  NVIDIA_API_KEY=nvapi_... python app.py
156
 
157
  # no key — runs in fallback mode without AI identification
 
171
  | Secret | Description |
172
  |---|---|
173
  | `HF_TOKEN` | HuggingFace token for Llama-3.2-11B-Vision via Serverless Inference |
174
+ | `NVIDIA_API_KEY` | NVIDIA NIM key for Nemotron Mini 4B Instruct (structured-output normalization) |
175
  | `MATCH_THRESHOLD` | Optional. Similarity threshold. Default: `0.80` |
176
  | `HF_DATASET_ID` | Optional. Hub dataset for trace publishing (e.g. `org/dataset-name`) |
177
 
 
223
  ## Credits
224
 
225
  - **[Meta](https://ai.meta.com/llama/)** — Llama 3.2 11B Vision Instruct (Llama 3.2 Community License).
226
+ - **[NVIDIA](https://build.nvidia.com/)** — Nemotron Mini 4B Instruct via NVIDIA NIM.
227
  - **[Hugging Face](https://huggingface.co/)** — `sentence-transformers/all-MiniLM-L6-v2`, Serverless Inference, Space hosting.
228
  - **[Leaflet.js](https://leafletjs.com/)** — interactive map.
229
  - **[Gradio](https://gradio.app/)** — `gradio.Server` for the custom frontend.
 
233
 
234
  ## License
235
 
236
+ MIT for the application code. Models follow their own licenses — see the Meta (Llama 3.2) and NVIDIA (Nemotron Mini 4B Instruct) model cards for full terms.
237
 
238
  ---
239
 
core/ai.py CHANGED
@@ -1,12 +1,21 @@
1
  """
2
- ai.py — Visao via HuggingFace InferenceClient ou NVIDIA NIM.
3
 
4
- Variaveis de ambiente:
5
- HF_TOKEN — token HuggingFace (usa HF Serverless Inference)
6
- NVIDIA_API_KEY chave NVIDIA NIM (free tier em build.nvidia.com)
 
 
 
 
 
 
7
 
8
- Modelo HF: meta-llama/Llama-3.2-11B-Vision-Instruct (disponivel no HF Serverless)
9
- Modelo NIM: nvidia/nemotron-3-nano-omni-30b-a3b (Nemotron Omni via NVIDIA)
 
 
 
10
  """
11
  import base64
12
  import io
@@ -20,7 +29,9 @@ import numpy as np
20
  log = logging.getLogger(__name__)
21
 
22
  _HF_MODEL = "meta-llama/Llama-3.2-11B-Vision-Instruct"
23
- _NIM_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # Nemotron Omni VLM, NVIDIA NIM
 
 
24
 
25
  PROMPT = (
26
  "Examine this image carefully.\n"
@@ -47,41 +58,56 @@ PROMPT = (
47
  " \"description_text\": \"one concise English sentence describing this specific animal for identity matching\"}"
48
  )
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  class AnimalAI:
52
  def __init__(self):
53
- self.mode = None # "hf" | "nim"
54
- self.model = None
55
- self.client = None # OpenAI (NIM) ou InferenceClient (HF)
 
56
 
57
  hf_token = os.environ.get("HF_TOKEN", "")
58
  nvidia_key = os.environ.get("NVIDIA_API_KEY", "")
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  if nvidia_key:
61
  try:
62
  from openai import OpenAI
63
- self.mode = "nim"
64
- self.model = os.environ.get("NVIDIA_MODEL", _NIM_MODEL)
65
- self.client = OpenAI(
66
  base_url="https://integrate.api.nvidia.com/v1",
67
  api_key=nvidia_key,
68
  )
69
- log.info("AI: Nemotron Omni via NVIDIA NIM (%s)", self.model)
70
- except ImportError:
71
- log.warning("openai nao instalado")
72
-
73
- elif hf_token:
74
- try:
75
- from huggingface_hub import InferenceClient
76
- self.mode = "hf"
77
- self.model = os.environ.get("NVIDIA_MODEL", _HF_MODEL)
78
- self.client = InferenceClient(model=self.model, token=hf_token)
79
- log.info("AI: %s via HF InferenceClient", self.model)
80
  except ImportError:
81
- log.warning("huggingface_hub nao instalado")
82
-
83
- else:
84
- log.warning("Sem chave de API — IA desabilitada. Configure HF_TOKEN nos Secrets.")
85
 
86
  self.embedder = None
87
  try:
@@ -93,50 +119,26 @@ class AnimalAI:
93
 
94
  def analyze_image(self, image) -> dict:
95
  """Analisa imagem. _ai_success=False indica que a IA nao foi usada."""
96
- if self.client is None:
97
  return self._fallback()
98
 
99
  try:
100
  img_b64 = self._to_b64(image)
101
 
102
- if self.mode == "hf":
103
- # HuggingFace InferenceClient — suporte nativo a multimodal
104
- resp = self.client.chat_completion(
105
- messages=[{
106
- "role": "user",
107
- "content": [
108
- {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + img_b64}},
109
- {"type": "text", "text": PROMPT},
110
- ],
111
- }],
112
- max_tokens=400,
113
- temperature=0.1,
114
- )
115
- raw = resp.choices[0].message.content or ""
116
- else:
117
- # NVIDIA NIM — Nemotron Omni (reasoning model)
118
- resp = self.client.chat.completions.create(
119
- model=self.model,
120
- messages=[{
121
- "role": "user",
122
- "content": [
123
- {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + img_b64}},
124
- {"type": "text", "text": PROMPT},
125
- ],
126
- }],
127
- max_tokens=1024,
128
- temperature=0.6,
129
- top_p=0.95,
130
- extra_body={
131
- "chat_template_kwargs": {"enable_thinking": True},
132
- "reasoning_budget": 512,
133
- },
134
- )
135
- msg = resp.choices[0].message
136
- # Nemotron Omni: resposta pode estar em content ou reasoning_content
137
- raw = (msg.content or "") or (getattr(msg, "reasoning_content", "") or "")
138
-
139
- log.info("AI resposta: %s", raw[:200])
140
  result = self._parse(raw)
141
 
142
  # Rejeição explícita: a IA não viu nenhum animal
@@ -144,14 +146,44 @@ class AnimalAI:
144
  log.info("IA: nenhum animal detectado na imagem.")
145
  return {"is_animal": False, "_ai_success": True}
146
 
 
 
 
 
147
  result["is_animal"] = True
148
  result["_ai_success"] = True
149
  return result
150
 
151
  except Exception as e:
152
- log.error("Erro na API de IA: %s", e)
153
  return self._fallback()
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  def get_embedding(self, description: dict) -> list:
156
  """Embedding da descricao. Aleatorio se IA falhou (evita falsos matches)."""
157
  if not description.get("_ai_success", True):
@@ -177,13 +209,21 @@ class AnimalAI:
177
  return base64.b64encode(buf.getvalue()).decode()
178
 
179
  @staticmethod
180
- def _parse(raw: str) -> dict:
 
181
  m = re.search(r"\{.*\}", raw, re.DOTALL)
182
  if m:
183
  try:
184
  return json.loads(m.group())
185
  except json.JSONDecodeError:
186
- pass
 
 
 
 
 
 
 
187
  log.warning("JSON nao parseado — fallback")
188
  return AnimalAI._fallback()
189
 
 
1
  """
2
+ ai.py — Visao via HuggingFace InferenceClient + normalizacao via NVIDIA NIM.
3
 
4
+ Arquitetura (todos os modelos < 32B):
5
+ - Visao : meta-llama/Llama-3.2-11B-Vision-Instruct (11B, HF Serverless) -> precisa HF_TOKEN
6
+ - Texto : nvidia/nemotron-mini-4b-instruct (4B, NVIDIA NIM) -> precisa NVIDIA_API_KEY (opcional)
7
+ - Embed : sentence-transformers/all-MiniLM-L6-v2 (22M, local/CPU)
8
+
9
+ O Llama enxerga a foto e devolve a descricao bruta. Se NVIDIA_API_KEY estiver
10
+ configurada, o Nemotron Mini 4B Instruct (texto puro) normaliza esse JSON para o schema
11
+ estrito (raca da lista permitida, cores simples, condicao, frase de matching).
12
+ Sem NVIDIA_API_KEY, usa-se direto a saida do Llama.
13
 
14
+ Variaveis de ambiente:
15
+ HF_TOKEN — token HuggingFace (HF Serverless Inference) — obrigatorio p/ visao
16
+ NVIDIA_API_KEY — chave NVIDIA NIM (free tier em build.nvidia.com) — opcional
17
+ HF_VISION_MODEL — override do modelo de visao (default Llama-3.2-11B-Vision)
18
+ NVIDIA_MODEL — override do modelo NIM (default nvidia/nemotron-mini-4b-instruct)
19
  """
20
  import base64
21
  import io
 
29
  log = logging.getLogger(__name__)
30
 
31
  _HF_MODEL = "meta-llama/Llama-3.2-11B-Vision-Instruct"
32
+ # String do modelo na API NVIDIA NIM (integrate.api.nvidia.com).
33
+ # Confirme o id exato em build.nvidia.com; pode sobrescrever via NVIDIA_MODEL.
34
+ _NIM_MODEL = "nvidia/nemotron-mini-4b-instruct" # Nemotron Mini 4B Instruct (texto, 4B)
35
 
36
  PROMPT = (
37
  "Examine this image carefully.\n"
 
58
  " \"description_text\": \"one concise English sentence describing this specific animal for identity matching\"}"
59
  )
60
 
61
+ # Nemotron Mini 4B Instruct normaliza a saida da visao para o schema estrito (texto puro).
62
+ NORMALIZE_PROMPT = (
63
+ "You normalize raw animal descriptions for a stray-animal database.\n"
64
+ "Given the input JSON, return ONLY a cleaned JSON object with the SAME keys.\n"
65
+ "Rules:\n"
66
+ "- breed_estimate must be the single best match from the allowed dog/cat breed lists; "
67
+ "use SRD (dog) or Domestic Shorthair (cat) only if truly unidentifiable.\n"
68
+ "- species is 'dog' or 'cat'; size is 'small', 'medium' or 'large'.\n"
69
+ "- primary_color and secondary_colors are simple lowercase words.\n"
70
+ "- condition is exactly one of: healthy, thin, injured.\n"
71
+ "- description_text is ONE concise English sentence for identity matching.\n"
72
+ "- Do NOT invent details not present in the input. No markdown, no explanation.\n\n"
73
+ "Input JSON:\n"
74
+ )
75
+
76
 
77
  class AnimalAI:
78
  def __init__(self):
79
+ self.vision_model = _HF_MODEL
80
+ self.vision_client = None # InferenceClient (HF) — visao
81
+ self.nim_model = _NIM_MODEL
82
+ self.nim_client = None # OpenAI client -> NVIDIA NIM — texto
83
 
84
  hf_token = os.environ.get("HF_TOKEN", "")
85
  nvidia_key = os.environ.get("NVIDIA_API_KEY", "")
86
 
87
+ # Visao: Llama-3.2-11B-Vision via HF Serverless (obrigatorio para identificar foto)
88
+ if hf_token:
89
+ try:
90
+ from huggingface_hub import InferenceClient
91
+ self.vision_model = os.environ.get("HF_VISION_MODEL", _HF_MODEL)
92
+ self.vision_client = InferenceClient(model=self.vision_model, token=hf_token)
93
+ log.info("Visao: %s via HF InferenceClient", self.vision_model)
94
+ except ImportError:
95
+ log.warning("huggingface_hub nao instalado")
96
+ else:
97
+ log.warning("Sem HF_TOKEN — visao desabilitada. Configure HF_TOKEN nos Secrets.")
98
+
99
+ # Texto: Nemotron Mini 4B Instruct via NVIDIA NIM (opcional — normaliza o JSON)
100
  if nvidia_key:
101
  try:
102
  from openai import OpenAI
103
+ self.nim_model = os.environ.get("NVIDIA_MODEL", _NIM_MODEL)
104
+ self.nim_client = OpenAI(
 
105
  base_url="https://integrate.api.nvidia.com/v1",
106
  api_key=nvidia_key,
107
  )
108
+ log.info("Texto: Nemotron Mini 4B Instruct via NVIDIA NIM (%s)", self.nim_model)
 
 
 
 
 
 
 
 
 
 
109
  except ImportError:
110
+ log.warning("openai nao instalado — normalizacao Nemotron desabilitada")
 
 
 
111
 
112
  self.embedder = None
113
  try:
 
119
 
120
  def analyze_image(self, image) -> dict:
121
  """Analisa imagem. _ai_success=False indica que a IA nao foi usada."""
122
+ if self.vision_client is None:
123
  return self._fallback()
124
 
125
  try:
126
  img_b64 = self._to_b64(image)
127
 
128
+ # Visao multimodal — Llama-3.2-11B-Vision (HF InferenceClient)
129
+ resp = self.vision_client.chat_completion(
130
+ messages=[{
131
+ "role": "user",
132
+ "content": [
133
+ {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + img_b64}},
134
+ {"type": "text", "text": PROMPT},
135
+ ],
136
+ }],
137
+ max_tokens=400,
138
+ temperature=0.1,
139
+ )
140
+ raw = resp.choices[0].message.content or ""
141
+ log.info("Visao resposta: %s", raw[:200])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  result = self._parse(raw)
143
 
144
  # Rejeição explícita: a IA não viu nenhum animal
 
146
  log.info("IA: nenhum animal detectado na imagem.")
147
  return {"is_animal": False, "_ai_success": True}
148
 
149
+ # Normalização opcional via Nemotron Mini 4B Instruct (texto puro, NVIDIA NIM)
150
+ if self.nim_client is not None:
151
+ result = self._normalize_with_nemotron(result)
152
+
153
  result["is_animal"] = True
154
  result["_ai_success"] = True
155
  return result
156
 
157
  except Exception as e:
158
+ log.error("Erro na API de visao: %s", e)
159
  return self._fallback()
160
 
161
+ def _normalize_with_nemotron(self, result: dict) -> dict:
162
+ """Coage o JSON da visao para o schema estrito usando Nemotron Mini 4B Instruct (texto)."""
163
+ try:
164
+ payload = json.dumps(result, ensure_ascii=False)
165
+ resp = self.nim_client.chat.completions.create(
166
+ model=self.nim_model,
167
+ messages=[{"role": "user", "content": NORMALIZE_PROMPT + payload}],
168
+ max_tokens=400,
169
+ temperature=0.2,
170
+ top_p=0.9,
171
+ )
172
+ msg = resp.choices[0].message
173
+ raw = (msg.content or "") or (getattr(msg, "reasoning_content", "") or "")
174
+ cleaned = self._extract_json(raw)
175
+ if cleaned:
176
+ for k in ("species", "breed_estimate", "size", "primary_color",
177
+ "secondary_colors", "distinctive_marks", "condition",
178
+ "description_text"):
179
+ if k in cleaned and cleaned[k] not in (None, "", []):
180
+ result[k] = cleaned[k]
181
+ log.info("Nemotron 4B normalizou os campos.")
182
+ return result
183
+ except Exception as e:
184
+ log.warning("Nemotron normalize falhou, usando saida da visao: %s", e)
185
+ return result
186
+
187
  def get_embedding(self, description: dict) -> list:
188
  """Embedding da descricao. Aleatorio se IA falhou (evita falsos matches)."""
189
  if not description.get("_ai_success", True):
 
209
  return base64.b64encode(buf.getvalue()).decode()
210
 
211
  @staticmethod
212
+ def _extract_json(raw: str):
213
+ """Retorna dict do primeiro bloco JSON valido, ou None."""
214
  m = re.search(r"\{.*\}", raw, re.DOTALL)
215
  if m:
216
  try:
217
  return json.loads(m.group())
218
  except json.JSONDecodeError:
219
+ return None
220
+ return None
221
+
222
+ @staticmethod
223
+ def _parse(raw: str) -> dict:
224
+ parsed = AnimalAI._extract_json(raw)
225
+ if parsed is not None:
226
+ return parsed
227
  log.warning("JSON nao parseado — fallback")
228
  return AnimalAI._fallback()
229