image-classifier/build_code_embeddings.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ build_code_embeddings.py β€” Embed USPTO Design Codes for RAG retrieval
3
+ =======================================================================
4
+ One-time script that reads uspto_design_codes.json (from the scraper) and
5
+ embeds every code's description using Voyage AI. The output feeds the
6
+ RAG layer of design_code_classifier.py.
7
+
8
+ WHY THIS EXISTS:
9
+ The classifier needs to constrain Claude to USPTO's actual code vocabulary
10
+ (no hallucinating fake codes). Embedding every code's description once
11
+ lets us do fast vector search at classification time: Claude describes
12
+ the image, we retrieve the most semantically similar codes, Claude picks
13
+ from that validated menu.
14
+
15
+ SETUP:
16
+ pip install voyageai numpy python-dotenv
17
+
18
+ ENV VARS:
19
+ VOYAGE_API_KEY β€” Voyage AI API key (free tier covers 200M tokens)
20
+
21
+ USAGE:
22
+ python build_code_embeddings.py
23
+
24
+ # Re-embed with a different model:
25
+ python build_code_embeddings.py --model voyage-3-large
26
+
27
+ OUTPUT:
28
+ uspto_code_embeddings.pkl β€” pickle dict with:
29
+ - codes: list[str] ordered list of XX.YY.ZZ codes
30
+ - descriptions: list[str] parallel list of code descriptions
31
+ - categories: list[str] parent category for each code (XX)
32
+ - embeddings: np.ndarray shape (N, dim), float32
33
+ - metadata: dict model name, dim, timestamp
34
+
35
+ COST:
36
+ ~1,300 codes Γ— ~15 tokens each = ~20K tokens.
37
+ voyage-3.5 is ~$0.06 per 1M tokens β†’ effectively free under the
38
+ 200M-token free tier.
39
+ """
40
+
41
+ import os
42
+ import sys
43
+ import json
44
+ import pickle
45
+ import logging
46
+ from datetime import datetime, timezone
47
+ from pathlib import Path
48
+ from typing import List
49
+
50
+ import numpy as np
51
+ from dotenv import load_dotenv
52
+
53
+ try:
54
+ import voyageai
55
+ except ImportError:
56
+ print("ERROR: Voyage AI SDK not installed. Run:")
57
+ print(" pip install voyageai")
58
+ sys.exit(1)
59
+
60
+
61
+ # ============================================================================
62
+ # CONFIG
63
+ # ============================================================================
64
+
65
+ env_path = Path(__file__).parent / ".env"
66
+ load_dotenv(dotenv_path=env_path)
67
+
68
+ VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY")
69
+
70
+ DEFAULT_MODEL = "voyage-3.5" # current general-purpose default; voyage-3-large is the premium upgrade
71
+ DEFAULT_DIM = 1024 # default for voyage-3.5; do not change without re-embedding
72
+
73
+ # Voyage allows up to 1,000 texts per batch; we have ~1,300 codes, so 2 batches
74
+ BATCH_SIZE = 1000
75
+
76
+ INPUT_PATH = Path(__file__).parent / "uspto_design_codes.json"
77
+ OUTPUT_PATH = Path(__file__).parent / "uspto_code_embeddings.pkl"
78
+
79
+ logging.basicConfig(
80
+ level=logging.INFO,
81
+ format="%(asctime)s [%(levelname)s] %(message)s",
82
+ )
83
+ logger = logging.getLogger("embeddings")
84
+
85
+
86
+ # ============================================================================
87
+ # LOAD CODES FROM SCRAPED MANUAL
88
+ # ============================================================================
89
+
90
+ def load_codes() -> tuple[List[str], List[str], List[str]]:
91
+ """Read uspto_design_codes.json and flatten into parallel lists.
92
+
93
+ Returns:
94
+ (codes, descriptions, categories) β€” three lists of equal length,
95
+ where codes[i] corresponds to descriptions[i] from category categories[i].
96
+ """
97
+ if not INPUT_PATH.exists():
98
+ logger.error(f"❌ {INPUT_PATH} not found. Run scrape_uspto_design_codes.py first.")
99
+ sys.exit(1)
100
+
101
+ data = json.loads(INPUT_PATH.read_text())
102
+ codes_dict = data.get("categories", {})
103
+
104
+ codes: List[str] = []
105
+ descriptions: List[str] = []
106
+ categories: List[str] = []
107
+ seen = set() # deduplicate (the scraper produced some cross-listed codes)
108
+
109
+ for category_id, category_data in codes_dict.items():
110
+ for division_id, division_data in category_data.get("divisions", {}).items():
111
+ for section_code, section_data in division_data.get("sections", {}).items():
112
+ if section_code in seen:
113
+ continue
114
+ description = section_data.get("description", "").strip()
115
+ if not description:
116
+ continue
117
+ codes.append(section_code)
118
+ descriptions.append(description)
119
+ # Use the section's *real* category prefix (XX), not the JSON parent
120
+ # β€” this self-corrects the scraper's cross-listing duplicates
121
+ categories.append(section_code.split(".")[0])
122
+ seen.add(section_code)
123
+
124
+ logger.info(f"πŸ“‹ Loaded {len(codes)} unique codes from {INPUT_PATH.name}")
125
+ return codes, descriptions, categories
126
+
127
+
128
+ # ============================================================================
129
+ # EMBED VIA VOYAGE
130
+ # ============================================================================
131
+
132
+ def build_searchable_text(code: str, description: str) -> str:
133
+ """Construct the text that gets embedded for each code.
134
+
135
+ We include the code itself in the text β€” the digits give the embedding
136
+ a tiny extra signal of which category/division things belong to, which
137
+ helps when descriptions are very generic ("Other plants" appears in
138
+ multiple divisions and would otherwise be indistinguishable).
139
+ """
140
+ return f"USPTO Design Code {code}: {description}"
141
+
142
+
143
+ def embed_descriptions(
144
+ descriptions_with_codes: List[str],
145
+ model: str,
146
+ ) -> np.ndarray:
147
+ """Call Voyage to embed all descriptions. Returns (N, dim) float32 array."""
148
+ if not VOYAGE_API_KEY:
149
+ logger.error("❌ VOYAGE_API_KEY not set in .env")
150
+ sys.exit(1)
151
+
152
+ client = voyageai.Client(api_key=VOYAGE_API_KEY)
153
+
154
+ all_embeddings: List[List[float]] = []
155
+
156
+ for batch_idx in range(0, len(descriptions_with_codes), BATCH_SIZE):
157
+ batch = descriptions_with_codes[batch_idx : batch_idx + BATCH_SIZE]
158
+ logger.info(
159
+ f"πŸ”„ Embedding batch {batch_idx // BATCH_SIZE + 1} "
160
+ f"({len(batch)} texts, total tokens ~{sum(len(t.split()) for t in batch)})"
161
+ )
162
+
163
+ try:
164
+ result = client.embed(
165
+ texts=batch,
166
+ model=model,
167
+ input_type="document", # corpus side of retrieval
168
+ )
169
+ except Exception as e:
170
+ logger.error(f"❌ Voyage API error: {e}")
171
+ sys.exit(1)
172
+
173
+ all_embeddings.extend(result.embeddings)
174
+ logger.info(f" βœ… Batch returned {len(result.embeddings)} embeddings")
175
+
176
+ arr = np.array(all_embeddings, dtype=np.float32)
177
+ logger.info(f"πŸ“ Final embeddings shape: {arr.shape}")
178
+ return arr
179
+
180
+
181
+ # ============================================================================
182
+ # SAVE
183
+ # ============================================================================
184
+
185
+ def save_embeddings(
186
+ codes: List[str],
187
+ descriptions: List[str],
188
+ categories: List[str],
189
+ embeddings: np.ndarray,
190
+ model: str,
191
+ ):
192
+ """Persist everything to a single pickle for easy loading by the classifier."""
193
+ payload = {
194
+ "codes": codes,
195
+ "descriptions": descriptions,
196
+ "categories": categories,
197
+ "embeddings": embeddings,
198
+ "metadata": {
199
+ "model": model,
200
+ "dimension": embeddings.shape[1],
201
+ "code_count": len(codes),
202
+ "created_at": datetime.now(timezone.utc).isoformat(),
203
+ "source_file": INPUT_PATH.name,
204
+ },
205
+ }
206
+ with OUTPUT_PATH.open("wb") as f:
207
+ pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
208
+ logger.info(f"πŸ’Ύ Saved to {OUTPUT_PATH} ({OUTPUT_PATH.stat().st_size / 1024:.1f} KB)")
209
+
210
+
211
+ # ============================================================================
212
+ # CLI
213
+ # ============================================================================
214
+
215
+ def main():
216
+ import argparse
217
+ parser = argparse.ArgumentParser(description="Embed USPTO design codes via Voyage AI")
218
+ parser.add_argument(
219
+ "--model", default=DEFAULT_MODEL,
220
+ help=f"Voyage model to use (default: {DEFAULT_MODEL}). "
221
+ "Upgrade to voyage-3-large for marginal quality gains."
222
+ )
223
+ args = parser.parse_args()
224
+
225
+ codes, descriptions, categories = load_codes()
226
+ texts_to_embed = [
227
+ build_searchable_text(c, d) for c, d in zip(codes, descriptions)
228
+ ]
229
+ embeddings = embed_descriptions(texts_to_embed, model=args.model)
230
+
231
+ save_embeddings(codes, descriptions, categories, embeddings, args.model)
232
+
233
+ logger.info("\n" + "=" * 60)
234
+ logger.info("πŸ“Š EMBEDDING BUILD COMPLETE")
235
+ logger.info("=" * 60)
236
+ logger.info(f" Model: {args.model}")
237
+ logger.info(f" Codes: {len(codes):,}")
238
+ logger.info(f" Dimensions: {embeddings.shape[1]}")
239
+ logger.info(f" Output: {OUTPUT_PATH}")
240
+ logger.info("=" * 60)
241
+ logger.info("\nπŸ’‘ Next step: design_code_classifier.py will load this file at startup")
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
image-classifier/design_code_classifier.py ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ design_code_classifier.py β€” USPTO Design Search Code classifier (RAG version)
3
+ ==============================================================================
4
+ Uses a two-stage RAG flow to classify uploaded trademark images into USPTO
5
+ Design Search Codes β€” with the guarantee that returned codes are real USPTO
6
+ codes pulled from the official manual, not invented by the model.
7
+
8
+ ARCHITECTURE
9
+ ------------
10
+ Stage 1 (visual description):
11
+ Claude vision model looks at the image and produces a structured natural-
12
+ language description of what's in it.
13
+
14
+ Vector retrieval:
15
+ The description is embedded via Voyage and matched against pre-embedded
16
+ USPTO code descriptions. Top-K candidates are pulled.
17
+
18
+ Stage 2 (constrained selection):
19
+ Claude sees the image AGAIN alongside the candidate codes (with their
20
+ official descriptions) and selects which apply, with confidence scores
21
+ and rationales.
22
+
23
+ This means Claude is *picking from a verified menu*, not free-styling. No
24
+ hallucinated codes.
25
+
26
+ DESIGN PHILOSOPHY
27
+ -----------------
28
+ This module is fully STANDALONE β€” does not import from or modify
29
+ nst_comparison.py. The output is structured JSON; how it gets plugged into
30
+ the matching algorithm is a deliberate decision left to the caller.
31
+
32
+ REQUIREMENTS
33
+ ------------
34
+ pip install anthropic voyageai pillow numpy python-dotenv
35
+
36
+ ENV VARS
37
+ --------
38
+ ANTHROPIC_API_KEY β€” Anthropic API key
39
+ VOYAGE_API_KEY β€” Voyage AI API key (for query embedding only;
40
+ codes were pre-embedded by build_code_embeddings.py)
41
+ CLASSIFIER_MODEL β€” Optional: override Claude model (default: claude-sonnet-4-6)
42
+
43
+ PREREQUISITES
44
+ -------------
45
+ You must run scrape_uspto_design_codes.py and build_code_embeddings.py first,
46
+ producing uspto_code_embeddings.pkl in the same directory as this module.
47
+
48
+ QUICK CLI TEST
49
+ --------------
50
+ python design_code_classifier.py path/to/logo.jpg
51
+
52
+ INTEGRATION (the "subtle change" path for nst_comparison.py)
53
+ ------------------------------------------------------------
54
+ # ── At top of nst_comparison.py ──
55
+ from .design_code_classifier import classify_image
56
+
57
+ # ── Inside execute_model(), after np_img is created ──
58
+ classification = await classify_image(np_img)
59
+ high_conf_codes = classification.high_confidence_codes(threshold=0.7)
60
+
61
+ # ── Add design_search_codes to the SELECT, then filter results: ──
62
+ if high_conf_codes:
63
+ response.data = [
64
+ r for r in response.data
65
+ if r.get("design_search_codes") and any(
66
+ c in r["design_search_codes"] for c in high_conf_codes
67
+ )
68
+ ]
69
+ """
70
+
71
+ import os
72
+ import io
73
+ import json
74
+ import base64
75
+ import pickle
76
+ import logging
77
+ import asyncio
78
+ from pathlib import Path
79
+ from typing import Optional, Any, List
80
+ from dataclasses import dataclass, asdict, field
81
+
82
+ import numpy as np
83
+ from anthropic import AsyncAnthropic
84
+ from PIL import Image
85
+ from dotenv import load_dotenv
86
+
87
+ try:
88
+ import voyageai
89
+ except ImportError:
90
+ raise ImportError("Voyage AI SDK required. Run: pip install voyageai")
91
+
92
+ # Optional FastAPI types β€” only loaded if FastAPI is present
93
+ try:
94
+ from fastapi import UploadFile, HTTPException
95
+ HAS_FASTAPI = True
96
+ except ImportError:
97
+ HAS_FASTAPI = False
98
+ UploadFile = None # type: ignore
99
+ HTTPException = None # type: ignore
100
+
101
+ # ============================================================================
102
+ # CONFIG
103
+ # ============================================================================
104
+
105
+ env_path = Path(__file__).parent / ".env"
106
+ load_dotenv(dotenv_path=env_path)
107
+
108
+ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
109
+ VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY")
110
+
111
+ DEFAULT_MODEL = os.getenv("CLASSIFIER_MODEL", "claude-sonnet-4-6")
112
+ MAX_TOKENS_STAGE1 = 1024
113
+ MAX_TOKENS_STAGE2 = 2048
114
+
115
+ TOP_K_CANDIDATES = 30
116
+ MIN_CONFIDENCE = 0.5
117
+
118
+ MAX_IMAGE_DIMENSION = 1568
119
+
120
+ EMBEDDINGS_PATH = Path(__file__).parent / "uspto_code_embeddings.pkl"
121
+
122
+ logger = logging.getLogger("design_classifier")
123
+
124
+
125
+ # ============================================================================
126
+ # CODE INDEX (loads embeddings once, supports vector search)
127
+ # ============================================================================
128
+
129
+ class CodeIndex:
130
+ """In-memory vector index over USPTO design codes.
131
+
132
+ Loaded once on first use. All requests share the same index β€” no per-
133
+ request file I/O.
134
+ """
135
+
136
+ _instance: Optional["CodeIndex"] = None
137
+
138
+ def __init__(self):
139
+ if not EMBEDDINGS_PATH.exists():
140
+ raise FileNotFoundError(
141
+ f"{EMBEDDINGS_PATH.name} not found. "
142
+ "Run scrape_uspto_design_codes.py + build_code_embeddings.py first."
143
+ )
144
+
145
+ with EMBEDDINGS_PATH.open("rb") as f:
146
+ payload = pickle.load(f)
147
+
148
+ self.codes: List[str] = payload["codes"]
149
+ self.descriptions: List[str] = payload["descriptions"]
150
+ self.categories: List[str] = payload["categories"]
151
+ self.embeddings: np.ndarray = payload["embeddings"]
152
+ self.metadata: dict = payload["metadata"]
153
+
154
+ # Pre-normalize for fast cosine similarity (just dot product after this)
155
+ norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True)
156
+ norms[norms == 0] = 1.0
157
+ self.embeddings_normalized = self.embeddings / norms
158
+
159
+ self.code_to_description = dict(zip(self.codes, self.descriptions))
160
+
161
+ logger.info(
162
+ f"πŸ“š Loaded code index: {len(self.codes)} codes, "
163
+ f"{self.metadata['dimension']}-dim {self.metadata['model']} embeddings"
164
+ )
165
+
166
+ @classmethod
167
+ def get(cls) -> "CodeIndex":
168
+ if cls._instance is None:
169
+ cls._instance = CodeIndex()
170
+ return cls._instance
171
+
172
+ def search(self, query_embedding: np.ndarray, top_k: int = TOP_K_CANDIDATES) -> List[dict]:
173
+ """Find top-K most similar codes to the query embedding.
174
+
175
+ Returns list of {code, description, category, similarity} dicts,
176
+ sorted descending by similarity.
177
+ """
178
+ q = query_embedding / (np.linalg.norm(query_embedding) + 1e-12)
179
+ sims = self.embeddings_normalized @ q
180
+ top_idx = np.argpartition(sims, -top_k)[-top_k:]
181
+ top_idx = top_idx[np.argsort(-sims[top_idx])]
182
+
183
+ return [
184
+ {
185
+ "code": self.codes[i],
186
+ "description": self.descriptions[i],
187
+ "category": self.categories[i],
188
+ "similarity": float(sims[i]),
189
+ }
190
+ for i in top_idx
191
+ ]
192
+
193
+
194
+ # ============================================================================
195
+ # IMAGE NORMALIZATION
196
+ # ============================================================================
197
+
198
+ def _normalize_image(image_input: Any) -> tuple[str, str]:
199
+ """Convert various image input types to (base64_data, media_type).
200
+
201
+ Accepts: bytes, PIL.Image, numpy.ndarray, file path.
202
+ """
203
+ if hasattr(image_input, "shape") and hasattr(image_input, "dtype"):
204
+ arr = image_input
205
+ if arr.dtype != np.uint8:
206
+ if arr.max() <= 1.0:
207
+ arr = (arr * 255).astype(np.uint8)
208
+ else:
209
+ arr = arr.astype(np.uint8)
210
+ img = Image.fromarray(arr)
211
+ elif isinstance(image_input, bytes):
212
+ img = Image.open(io.BytesIO(image_input))
213
+ elif isinstance(image_input, Image.Image):
214
+ img = image_input
215
+ elif isinstance(image_input, (str, Path)):
216
+ img = Image.open(image_input)
217
+ else:
218
+ raise TypeError(
219
+ f"Unsupported image input type: {type(image_input)}. "
220
+ "Use bytes, PIL.Image, numpy.ndarray, or a file path."
221
+ )
222
+
223
+ if img.mode != "RGB":
224
+ img = img.convert("RGB")
225
+
226
+ if max(img.size) > MAX_IMAGE_DIMENSION:
227
+ img.thumbnail((MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION), Image.Resampling.LANCZOS)
228
+ logger.debug(f"Resized image to {img.size}")
229
+
230
+ buf = io.BytesIO()
231
+ img.save(buf, format="JPEG", quality=90)
232
+ base64_data = base64.standard_b64encode(buf.getvalue()).decode("utf-8")
233
+ return base64_data, "image/jpeg"
234
+
235
+
236
+ # ============================================================================
237
+ # RESULT TYPES
238
+ # ============================================================================
239
+
240
+ @dataclass
241
+ class DesignCode:
242
+ code: str
243
+ description: str
244
+ confidence: float
245
+ rationale: str
246
+
247
+
248
+ @dataclass
249
+ class ClassificationResult:
250
+ codes: List[DesignCode] = field(default_factory=list)
251
+ image_description: str = ""
252
+ primary_category: Optional[str] = None
253
+ candidate_codes_considered: List[str] = field(default_factory=list)
254
+
255
+ def high_confidence_codes(self, threshold: float = 0.7) -> List[str]:
256
+ """Return just the code strings above the confidence threshold."""
257
+ return [c.code for c in self.codes if c.confidence >= threshold]
258
+
259
+ def to_dict(self) -> dict:
260
+ return {
261
+ "codes": [asdict(c) for c in self.codes],
262
+ "image_description": self.image_description,
263
+ "primary_category": self.primary_category,
264
+ "candidate_codes_considered": self.candidate_codes_considered,
265
+ }
266
+
267
+
268
+ # ============================================================================
269
+ # STAGE 1 β€” IMAGE β†’ NATURAL LANGUAGE DESCRIPTION
270
+ # ============================================================================
271
+
272
+ STAGE1_SYSTEM_PROMPT = """You are an expert trademark image analyst. Your job is to describe trademark logos in a way that will help retrieve relevant USPTO Design Search Codes.
273
+
274
+ When you describe an image, focus on the visual elements a USPTO examiner would code:
275
+ - Living things: humans, animals, plants (be specific β€” "lion's head" not just "animal")
276
+ - Geometric shapes: circles, squares, triangles, lines (and their arrangement)
277
+ - Objects: tools, vehicles, buildings, food, clothing (be specific)
278
+ - Symbols: arrows, crosses, stars, hearts, mathematical symbols
279
+ - Text/letters: note their presence and style (stylized, in a frame, etc.)
280
+ - Colors: only if the color is a distinctive design element
281
+ - Composition: what's framing what, what's stylized vs. realistic
282
+
283
+ Output a single paragraph (3-6 sentences). Be precise and use concrete vocabulary that a search index would match. Don't editorialize about meaning or branding β€” just describe what's literally visible.
284
+
285
+ Output the description as plain text. No JSON, no prose preamble."""
286
+
287
+
288
+ async def stage1_describe_image(
289
+ base64_data: str,
290
+ media_type: str,
291
+ client: AsyncAnthropic,
292
+ model: str,
293
+ ) -> str:
294
+ """Have Claude describe the image in retrieval-optimized natural language."""
295
+ response = await client.messages.create(
296
+ model=model,
297
+ max_tokens=MAX_TOKENS_STAGE1,
298
+ system=STAGE1_SYSTEM_PROMPT,
299
+ messages=[{
300
+ "role": "user",
301
+ "content": [
302
+ {
303
+ "type": "image",
304
+ "source": {
305
+ "type": "base64",
306
+ "media_type": media_type,
307
+ "data": base64_data,
308
+ },
309
+ },
310
+ {"type": "text", "text": "Describe this trademark image."},
311
+ ],
312
+ }],
313
+ )
314
+ description = response.content[0].text.strip()
315
+ logger.info(f"Stage 1 description ({len(description)} chars): {description[:120]}...")
316
+ return description
317
+
318
+
319
+ # ============================================================================
320
+ # RETRIEVAL β€” VOYAGE EMBED + VECTOR SEARCH
321
+ # ============================================================================
322
+
323
+ def embed_query(text: str, voyage_client) -> np.ndarray:
324
+ """Embed a single query string with Voyage. Returns a 1D numpy array.
325
+
326
+ NOTE: model name MUST match what was used in build_code_embeddings.py.
327
+ Reading the metadata from the index ensures consistency.
328
+ """
329
+ index = CodeIndex.get()
330
+ embedding_model = index.metadata["model"]
331
+
332
+ result = voyage_client.embed(
333
+ texts=[text],
334
+ model=embedding_model,
335
+ input_type="query",
336
+ )
337
+ return np.array(result.embeddings[0], dtype=np.float32)
338
+
339
+
340
+ # ============================================================================
341
+ # STAGE 2 β€” IMAGE + CANDIDATES β†’ STRUCTURED CODE SELECTION
342
+ # ============================================================================
343
+
344
+ def _stage2_system_prompt(candidates: List[dict]) -> str:
345
+ """Build the system prompt with the candidate codes inline."""
346
+ candidate_lines = "\n".join(
347
+ f" {c['code']} β€” {c['description']}"
348
+ for c in candidates
349
+ )
350
+
351
+ return f"""You are an expert USPTO Trademark Design Search Code classifier.
352
+
353
+ Below is a list of candidate USPTO Design Search Codes that may apply to the image. Your job is to look at the image and select ONLY the codes that genuinely apply β€” based on what is actually visible.
354
+
355
+ CANDIDATE CODES:
356
+ {candidate_lines}
357
+
358
+ CRITICAL RULES:
359
+ - ONLY return codes from the candidate list above. Do not invent codes that are not in the list.
360
+ - Be precise: include a code only if the corresponding visual element is clearly present.
361
+ - Most logos use 2–6 codes total. Don't pad β€” quality over quantity.
362
+ - If the logo contains text or letters, ALWAYS look at category 27 codes in the candidates.
363
+ - If the logo has a geometric frame around the elements, ALWAYS look at category 26 codes in the candidates.
364
+
365
+ Return ONLY valid JSON in this exact structure:
366
+ {{
367
+ "codes": [
368
+ {{
369
+ "code": "XX.YY.ZZ",
370
+ "confidence": 0.95,
371
+ "rationale": "Brief explanation of what in the image triggered this code"
372
+ }}
373
+ ],
374
+ "primary_category": "XX"
375
+ }}
376
+
377
+ CONFIDENCE SCORING:
378
+ - 0.90–1.00: Element unmistakably present
379
+ - 0.70–0.89: Element clearly present, minor interpretation involved
380
+ - 0.50–0.69: Element likely present but ambiguous
381
+ - Below 0.50: DO NOT include the code
382
+
383
+ Output valid JSON only. No prose before or after."""
384
+
385
+
386
+ async def stage2_select_codes(
387
+ base64_data: str,
388
+ media_type: str,
389
+ candidates: List[dict],
390
+ client: AsyncAnthropic,
391
+ model: str,
392
+ ) -> dict:
393
+ """Have Claude pick the applicable codes from the candidate list."""
394
+ response = await client.messages.create(
395
+ model=model,
396
+ max_tokens=MAX_TOKENS_STAGE2,
397
+ system=_stage2_system_prompt(candidates),
398
+ messages=[{
399
+ "role": "user",
400
+ "content": [
401
+ {
402
+ "type": "image",
403
+ "source": {
404
+ "type": "base64",
405
+ "media_type": media_type,
406
+ "data": base64_data,
407
+ },
408
+ },
409
+ {
410
+ "type": "text",
411
+ "text": "Select the applicable codes from the candidates. Return JSON only.",
412
+ },
413
+ ],
414
+ }],
415
+ )
416
+
417
+ raw_text = response.content[0].text.strip()
418
+
419
+ # Strip markdown fences if present
420
+ if raw_text.startswith("```"):
421
+ lines = raw_text.split("\n")
422
+ if lines[-1].startswith("```"):
423
+ lines = lines[1:-1]
424
+ else:
425
+ lines = lines[1:]
426
+ raw_text = "\n".join(lines).strip()
427
+
428
+ try:
429
+ return json.loads(raw_text)
430
+ except json.JSONDecodeError as e:
431
+ logger.error(f"Stage 2 JSON parse error: {e}")
432
+ logger.error(f"Raw response: {raw_text[:500]}")
433
+ return {"codes": [], "primary_category": None}
434
+
435
+
436
+ # ============================================================================
437
+ # PUBLIC API
438
+ # ============================================================================
439
+
440
+ async def classify_image(
441
+ image_input: Any,
442
+ model: str = DEFAULT_MODEL,
443
+ min_confidence: float = MIN_CONFIDENCE,
444
+ top_k: int = TOP_K_CANDIDATES,
445
+ ) -> ClassificationResult:
446
+ """Classify a trademark image into USPTO Design Search Codes via two-stage RAG.
447
+
448
+ Args:
449
+ image_input: bytes, PIL.Image, file path, or numpy.ndarray
450
+ model: Claude model (default: claude-sonnet-4-6)
451
+ min_confidence: Drop codes below this confidence (default: 0.5)
452
+ top_k: How many candidates to surface to stage 2 (default: 30)
453
+
454
+ Returns:
455
+ ClassificationResult with verified codes only β€” Claude cannot return
456
+ codes that aren't in USPTO's actual vocabulary.
457
+ """
458
+ if not ANTHROPIC_API_KEY:
459
+ raise ValueError("ANTHROPIC_API_KEY environment variable not set")
460
+ if not VOYAGE_API_KEY:
461
+ raise ValueError("VOYAGE_API_KEY environment variable not set")
462
+
463
+ # Lazy-load the index (reused across all classifications)
464
+ index = CodeIndex.get()
465
+
466
+ # Normalize image once β€” used in both stages
467
+ base64_data, media_type = _normalize_image(image_input)
468
+
469
+ anthropic_client = AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
470
+ voyage_client = voyageai.Client(api_key=VOYAGE_API_KEY)
471
+
472
+ # ── Stage 1: describe ──
473
+ description = await stage1_describe_image(
474
+ base64_data, media_type, anthropic_client, model
475
+ )
476
+
477
+ # ── Retrieval: embed + vector search ──
478
+ query_emb = embed_query(description, voyage_client)
479
+ candidates = index.search(query_emb, top_k=top_k)
480
+ logger.info(
481
+ f"Retrieved {len(candidates)} candidate codes "
482
+ f"(top similarity: {candidates[0]['similarity']:.3f})"
483
+ )
484
+
485
+ # ── Stage 2: select ──
486
+ selection = await stage2_select_codes(
487
+ base64_data, media_type, candidates, anthropic_client, model
488
+ )
489
+
490
+ # ── Build result, validating that returned codes are in the candidate set ──
491
+ candidate_codes = {c["code"] for c in candidates}
492
+ result = ClassificationResult(
493
+ image_description=description,
494
+ primary_category=selection.get("primary_category"),
495
+ candidate_codes_considered=[c["code"] for c in candidates],
496
+ )
497
+
498
+ for sel in selection.get("codes", []):
499
+ code = sel.get("code", "").strip()
500
+ confidence = float(sel.get("confidence", 0))
501
+
502
+ if confidence < min_confidence:
503
+ continue
504
+ if code not in candidate_codes:
505
+ # Defense in depth: even if Claude tries to invent a code, refuse it
506
+ logger.warning(f"Claude returned code {code} not in candidate set β€” dropping")
507
+ continue
508
+
509
+ result.codes.append(DesignCode(
510
+ code=code,
511
+ description=index.code_to_description.get(code, ""),
512
+ confidence=confidence,
513
+ rationale=sel.get("rationale", ""),
514
+ ))
515
+
516
+ logger.info(
517
+ f"βœ… Classified into {len(result.codes)} verified code(s); "
518
+ f"primary category: {result.primary_category}"
519
+ )
520
+ return result
521
+
522
+
523
+ # ============================================================================
524
+ # OPTIONAL HELPERS
525
+ # ============================================================================
526
+
527
+ if HAS_FASTAPI:
528
+ async def classify_uploadfile(file: UploadFile, **kwargs) -> ClassificationResult:
529
+ """Convenience wrapper for FastAPI UploadFile inputs."""
530
+ if file.content_type not in ("image/jpeg", "image/png", "image/gif", "image/webp"):
531
+ raise HTTPException(
532
+ status_code=400,
533
+ detail=f"Unsupported image type: {file.content_type}",
534
+ )
535
+ image_bytes = await file.read()
536
+ return await classify_image(image_bytes, **kwargs)
537
+
538
+
539
+ # ============================================================================
540
+ # CLI
541
+ # ============================================================================
542
+
543
+ async def _cli():
544
+ import argparse
545
+ parser = argparse.ArgumentParser(description="Test the USPTO design code classifier")
546
+ parser.add_argument("image_path", help="Path to a trademark image")
547
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Claude model")
548
+ parser.add_argument("--threshold", type=float, default=MIN_CONFIDENCE)
549
+ parser.add_argument("--top-k", type=int, default=TOP_K_CANDIDATES)
550
+ args = parser.parse_args()
551
+
552
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
553
+
554
+ result = await classify_image(
555
+ args.image_path,
556
+ model=args.model,
557
+ min_confidence=args.threshold,
558
+ top_k=args.top_k,
559
+ )
560
+
561
+ print(json.dumps(result.to_dict(), indent=2))
562
+
563
+
564
+ if __name__ == "__main__":
565
+ asyncio.run(_cli())
image-classifier/uspto_code_embeddings.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ddb2a0c81b1457c910226fe0e0c9a24e2723d0467f49a22a65fe289decbe42f3
3
+ size 5814306
image-classifier/uspto_design_codes.json ADDED
The diff for this file is too large to render. See raw diff
 
image-classifier/validate_classifier.py ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ validate_classifier.py β€” Test the design code classifier against examiner ground truth
3
+ ========================================================================================
4
+ Pulls a sample of image marks from your Supabase database that already have
5
+ USPTO examiner-assigned design codes, runs each through the classifier, and
6
+ compares the classifier's output against the examiner's codes.
7
+
8
+ The output tells you concretely whether the classifier is good enough to
9
+ ship β€” or whether we need to iterate before exposing it to attorney-tier
10
+ customers.
11
+
12
+ WHAT IT MEASURES
13
+ ----------------
14
+ For each test image, four agreement levels:
15
+ - Section-exact: XX.YY.ZZ matches exactly (strictest)
16
+ - Division-level: XX.YY matches (same code family)
17
+ - Category-level: XX matches (same broad category)
18
+ - Any overlap: at least one code in common
19
+
20
+ Plus retrieval-style metrics:
21
+ - Precision: of codes the classifier returned, what fraction were correct?
22
+ - Recall: of examiner codes, what fraction did the classifier find?
23
+ - F1: harmonic mean
24
+
25
+ WHAT TO LOOK FOR
26
+ ----------------
27
+ For an MVP shipping to entrepreneurs at $49/mo:
28
+ - Category-level agreement >= 80% is reasonable
29
+ - Recall >= 60% means we catch most relevant matches
30
+
31
+ For attorney-tier customers at $250+/mo:
32
+ - Section-exact agreement >= 70%
33
+ - Recall >= 80% (missing codes is a liability risk)
34
+
35
+ If the numbers come in lower, look at the per-image CSV β€” usually the
36
+ classifier is right but using a slightly different code than the examiner,
37
+ or vice versa. That's data we can use to tune the prompt.
38
+
39
+ REQUIREMENTS
40
+ ------------
41
+ Same as design_code_classifier.py, plus:
42
+ pip install httpx supabase
43
+
44
+ USAGE
45
+ -----
46
+ # Quick test β€” 20 random samples
47
+ python validate_classifier.py --samples 20
48
+
49
+ # Full validation β€” 100 samples, slower but more reliable
50
+ python validate_classifier.py --samples 100
51
+
52
+ # Resume after a crash
53
+ python validate_classifier.py --resume
54
+
55
+ OUTPUT
56
+ ------
57
+ validation_results.csv β€” per-image: serial, examiner codes, classifier codes, scores
58
+ validation_summary.json β€” aggregate metrics
59
+ """
60
+
61
+ import os
62
+ import sys
63
+ import csv
64
+ import json
65
+ import asyncio
66
+ import logging
67
+ import random
68
+ from pathlib import Path
69
+ from datetime import datetime, timezone
70
+ from typing import List, Dict, Any
71
+
72
+ import httpx
73
+ from dotenv import load_dotenv
74
+ from supabase import create_client, Client
75
+
76
+ # Local import β€” must be in same directory
77
+ from design_code_classifier import classify_image
78
+
79
+
80
+ # ============================================================================
81
+ # CONFIG
82
+ # ============================================================================
83
+
84
+ env_path = Path(__file__).parent / ".env"
85
+ load_dotenv(dotenv_path=env_path)
86
+
87
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
88
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
89
+
90
+ CONFIDENCE_THRESHOLD = 0.7 # only count classifier codes at or above this confidence
91
+
92
+ OUTPUT_CSV = Path(__file__).parent / "validation_results.csv"
93
+ OUTPUT_JSON = Path(__file__).parent / "validation_summary.json"
94
+ CHECKPOINT_PATH = Path(__file__).parent / ".validation_checkpoint.json"
95
+
96
+ # Politeness β€” don't hammer your DB or Claude API
97
+ DELAY_BETWEEN_SAMPLES_S = 1.0
98
+
99
+ logging.basicConfig(
100
+ level=logging.INFO,
101
+ format="%(asctime)s [%(levelname)s] %(message)s",
102
+ )
103
+ logger = logging.getLogger("validate")
104
+
105
+
106
+ # ============================================================================
107
+ # CHECKPOINT (resume after crash)
108
+ # ============================================================================
109
+
110
+ def load_checkpoint() -> Dict[str, Any]:
111
+ if CHECKPOINT_PATH.exists():
112
+ try:
113
+ return json.loads(CHECKPOINT_PATH.read_text())
114
+ except Exception as e:
115
+ logger.warning(f"Checkpoint unreadable, starting fresh: {e}")
116
+ return {"completed_serials": [], "results": []}
117
+
118
+
119
+ def save_checkpoint(state: Dict[str, Any]):
120
+ state["updated_at"] = datetime.now(timezone.utc).isoformat()
121
+ CHECKPOINT_PATH.write_text(json.dumps(state, indent=2))
122
+
123
+
124
+ # ============================================================================
125
+ # SAMPLE FETCHING
126
+ # ============================================================================
127
+
128
+ async def fetch_sample(supabase: Client, n: int, exclude_serials: set) -> List[Dict]:
129
+ """Pull N random image marks that have both an image and examiner codes.
130
+
131
+ We over-fetch and randomize client-side because Postgres ORDER BY RANDOM()
132
+ on millions of rows is brutally slow.
133
+ """
134
+ # Fetch a pool of candidates with codes + image (skip NOT_FOUND)
135
+ pool_size = min(n * 20, 5000) # 20x oversample, capped
136
+ logger.info(f"πŸ“‘ Fetching {pool_size} candidate image marks from Supabase...")
137
+
138
+ response = (
139
+ supabase.table("trademarks_images")
140
+ .select("serial_number,image_url,design_search_codes,mark_text")
141
+ .neq("image_url", "NOT_FOUND")
142
+ .not_.is_("image_url", "null")
143
+ .not_.is_("design_search_codes", "null")
144
+ .neq("design_search_codes", "")
145
+ .limit(pool_size)
146
+ .execute()
147
+ )
148
+
149
+ pool = [
150
+ r for r in response.data
151
+ if r["serial_number"] not in exclude_serials
152
+ and r.get("image_url")
153
+ and r.get("design_search_codes")
154
+ ]
155
+ logger.info(f" Pool size after filtering: {len(pool)}")
156
+
157
+ # Random sample
158
+ random.shuffle(pool)
159
+ return pool[:n]
160
+
161
+
162
+ # ============================================================================
163
+ # IMAGE FETCHING
164
+ # ============================================================================
165
+
166
+ async def fetch_image_bytes(url: str, http_client: httpx.AsyncClient) -> bytes:
167
+ """Download a Supabase Storage image."""
168
+ resp = await http_client.get(url, timeout=20.0)
169
+ resp.raise_for_status()
170
+ return resp.content
171
+
172
+
173
+ # ============================================================================
174
+ # COMPARISON METRICS
175
+ # ============================================================================
176
+
177
+ def normalize_codes(codes_str: str) -> set:
178
+ """Parse a comma-separated codes string into a set of XX.YY.ZZ codes.
179
+
180
+ USPTO bulk XML stores codes as 6-digit strings without separators
181
+ (e.g., "260121"). The classifier returns dotted format (e.g., "26.01.21").
182
+ This function accepts either format and normalizes everything to dotted
183
+ XX.YY.ZZ so set comparison works correctly.
184
+ """
185
+ if not codes_str:
186
+ return set()
187
+ normalized = set()
188
+ for c in codes_str.split(","):
189
+ c = c.strip()
190
+ if not c:
191
+ continue
192
+ # Already-dotted format passes through unchanged
193
+ if "." in c:
194
+ normalized.add(c)
195
+ continue
196
+ # 6-digit USPTO format β†’ insert dots: "260121" β†’ "26.01.21"
197
+ if len(c) == 6 and c.isdigit():
198
+ normalized.add(f"{c[0:2]}.{c[2:4]}.{c[4:6]}")
199
+ continue
200
+ # Anything else: keep as-is (will simply not match, which is correct)
201
+ normalized.add(c)
202
+ return normalized
203
+
204
+
205
+ def compare_codes(examiner: set, classifier: set) -> Dict[str, Any]:
206
+ """Compute multi-level agreement between two code sets."""
207
+ examiner_divisions = {".".join(c.split(".")[:2]) for c in examiner}
208
+ examiner_categories = {c.split(".")[0] for c in examiner}
209
+ classifier_divisions = {".".join(c.split(".")[:2]) for c in classifier}
210
+ classifier_categories = {c.split(".")[0] for c in classifier}
211
+
212
+ section_overlap = examiner & classifier
213
+ division_overlap = examiner_divisions & classifier_divisions
214
+ category_overlap = examiner_categories & classifier_categories
215
+
216
+ return {
217
+ "examiner_count": len(examiner),
218
+ "classifier_count": len(classifier),
219
+ "section_exact_matches": len(section_overlap),
220
+ "division_matches": len(division_overlap),
221
+ "category_matches": len(category_overlap),
222
+ "any_section_overlap": bool(section_overlap),
223
+ "any_division_overlap": bool(division_overlap),
224
+ "any_category_overlap": bool(category_overlap),
225
+ # Retrieval metrics (treat examiner codes as ground truth)
226
+ "precision": len(section_overlap) / max(len(classifier), 1),
227
+ "recall": len(section_overlap) / max(len(examiner), 1),
228
+ }
229
+
230
+
231
+ def f1_score(precision: float, recall: float) -> float:
232
+ if precision + recall == 0:
233
+ return 0.0
234
+ return 2 * precision * recall / (precision + recall)
235
+
236
+
237
+ # ============================================================================
238
+ # PER-SAMPLE RUN
239
+ # ============================================================================
240
+
241
+ async def validate_one(
242
+ record: Dict,
243
+ http_client: httpx.AsyncClient,
244
+ ) -> Dict[str, Any]:
245
+ """Run the classifier on one image and compare to examiner codes."""
246
+ serial = record["serial_number"]
247
+ image_url = record["image_url"]
248
+ examiner_codes_raw = record["design_search_codes"]
249
+ examiner_codes = normalize_codes(examiner_codes_raw)
250
+
251
+ try:
252
+ image_bytes = await fetch_image_bytes(image_url, http_client)
253
+ except Exception as e:
254
+ return {
255
+ "serial_number": serial,
256
+ "status": "image_fetch_failed",
257
+ "error": str(e),
258
+ }
259
+
260
+ try:
261
+ result = await classify_image(image_bytes)
262
+ except Exception as e:
263
+ return {
264
+ "serial_number": serial,
265
+ "status": "classification_failed",
266
+ "error": str(e),
267
+ }
268
+
269
+ classifier_codes = set(result.high_confidence_codes(threshold=CONFIDENCE_THRESHOLD))
270
+ metrics = compare_codes(examiner_codes, classifier_codes)
271
+
272
+ return {
273
+ "serial_number": serial,
274
+ "mark_text": record.get("mark_text", ""),
275
+ "image_url": image_url,
276
+ "examiner_codes": sorted(examiner_codes),
277
+ "classifier_codes": sorted(classifier_codes),
278
+ "image_description": result.image_description,
279
+ "status": "ok",
280
+ **metrics,
281
+ }
282
+
283
+
284
+ # ============================================================================
285
+ # MAIN
286
+ # ============================================================================
287
+
288
+ async def run(n_samples: int, resume: bool):
289
+ if not all([SUPABASE_URL, SUPABASE_KEY]):
290
+ logger.error("❌ SUPABASE_URL / SUPABASE_KEY not set in .env")
291
+ sys.exit(1)
292
+
293
+ state = load_checkpoint() if resume else {"completed_serials": [], "results": []}
294
+ completed = set(state["completed_serials"])
295
+ results: List[Dict] = state["results"]
296
+
297
+ if resume and completed:
298
+ logger.info(f"πŸ“‹ Resuming β€” {len(completed)} samples already done")
299
+ n_samples = max(0, n_samples - len(completed))
300
+ if n_samples == 0:
301
+ logger.info("βœ… Sample target already met from checkpoint")
302
+
303
+ supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
304
+
305
+ if n_samples > 0:
306
+ samples = await fetch_sample(supabase, n_samples, exclude_serials=completed)
307
+ logger.info(f"🎯 Will validate {len(samples)} new samples")
308
+ else:
309
+ samples = []
310
+
311
+ async with httpx.AsyncClient() as http_client:
312
+ for idx, record in enumerate(samples, 1):
313
+ serial = record["serial_number"]
314
+ logger.info(f"\n[{idx}/{len(samples)}] Validating {serial} ({record.get('mark_text', '')[:40]})")
315
+
316
+ try:
317
+ result = await validate_one(record, http_client)
318
+ results.append(result)
319
+ completed.add(serial)
320
+
321
+ if result["status"] == "ok":
322
+ logger.info(
323
+ f" Examiner: {result['examiner_codes']}\n"
324
+ f" Classifier: {result['classifier_codes']}\n"
325
+ f" P={result['precision']:.2f} R={result['recall']:.2f} "
326
+ f"category_match={result['any_category_overlap']}"
327
+ )
328
+ else:
329
+ logger.warning(f" ⚠️ {result['status']}: {result.get('error', '')[:200]}")
330
+ except Exception as e:
331
+ logger.error(f" ❌ Unexpected error: {e}")
332
+ results.append({
333
+ "serial_number": serial,
334
+ "status": "unexpected_error",
335
+ "error": str(e),
336
+ })
337
+ completed.add(serial)
338
+
339
+ # Checkpoint after each sample
340
+ state["completed_serials"] = list(completed)
341
+ state["results"] = results
342
+ save_checkpoint(state)
343
+
344
+ await asyncio.sleep(DELAY_BETWEEN_SAMPLES_S)
345
+
346
+ # ── Aggregate ──
347
+ ok_results = [r for r in results if r.get("status") == "ok"]
348
+ if not ok_results:
349
+ logger.error("❌ No successful validations to aggregate")
350
+ return
351
+
352
+ avg_precision = sum(r["precision"] for r in ok_results) / len(ok_results)
353
+ avg_recall = sum(r["recall"] for r in ok_results) / len(ok_results)
354
+ avg_f1 = f1_score(avg_precision, avg_recall)
355
+
356
+ section_match_rate = sum(1 for r in ok_results if r["any_section_overlap"]) / len(ok_results)
357
+ division_match_rate = sum(1 for r in ok_results if r["any_division_overlap"]) / len(ok_results)
358
+ category_match_rate = sum(1 for r in ok_results if r["any_category_overlap"]) / len(ok_results)
359
+
360
+ summary = {
361
+ "generated_at": datetime.now(timezone.utc).isoformat(),
362
+ "total_samples": len(results),
363
+ "successful_samples": len(ok_results),
364
+ "failed_samples": len(results) - len(ok_results),
365
+ "confidence_threshold": CONFIDENCE_THRESHOLD,
366
+ "metrics": {
367
+ "section_exact_match_rate": section_match_rate,
368
+ "division_match_rate": division_match_rate,
369
+ "category_match_rate": category_match_rate,
370
+ "avg_precision": avg_precision,
371
+ "avg_recall": avg_recall,
372
+ "avg_f1": avg_f1,
373
+ },
374
+ }
375
+
376
+ OUTPUT_JSON.write_text(json.dumps(summary, indent=2))
377
+
378
+ # CSV β€” per-sample, easy to sort/filter in a spreadsheet
379
+ with OUTPUT_CSV.open("w", newline="", encoding="utf-8") as f:
380
+ writer = csv.writer(f)
381
+ writer.writerow([
382
+ "serial_number", "mark_text", "image_url",
383
+ "examiner_codes", "classifier_codes",
384
+ "section_overlap", "division_overlap", "category_overlap",
385
+ "precision", "recall", "image_description", "status",
386
+ ])
387
+ for r in results:
388
+ if r.get("status") != "ok":
389
+ writer.writerow([r.get("serial_number"), "", "", "", "", "", "", "", "", "", "", r.get("status")])
390
+ continue
391
+ writer.writerow([
392
+ r["serial_number"],
393
+ r["mark_text"],
394
+ r["image_url"],
395
+ ",".join(r["examiner_codes"]),
396
+ ",".join(r["classifier_codes"]),
397
+ r["any_section_overlap"],
398
+ r["any_division_overlap"],
399
+ r["any_category_overlap"],
400
+ f"{r['precision']:.3f}",
401
+ f"{r['recall']:.3f}",
402
+ r["image_description"],
403
+ "ok",
404
+ ])
405
+
406
+ # ── Print summary ──
407
+ logger.info("\n" + "=" * 70)
408
+ logger.info("πŸ“Š VALIDATION RESULTS")
409
+ logger.info("=" * 70)
410
+ logger.info(f" Samples: {len(ok_results)} successful, {len(results) - len(ok_results)} failed")
411
+ logger.info(f" Confidence threshold: {CONFIDENCE_THRESHOLD}")
412
+ logger.info("")
413
+ logger.info(f" Section-exact match rate: {section_match_rate:.1%} (any XX.YY.ZZ in common)")
414
+ logger.info(f" Division match rate: {division_match_rate:.1%} (any XX.YY in common)")
415
+ logger.info(f" Category match rate: {category_match_rate:.1%} (any XX in common)")
416
+ logger.info("")
417
+ logger.info(f" Avg precision: {avg_precision:.1%} (classifier codes that were right)")
418
+ logger.info(f" Avg recall: {avg_recall:.1%} (examiner codes the classifier found)")
419
+ logger.info(f" Avg F1: {avg_f1:.3f}")
420
+ logger.info("")
421
+ logger.info(f" Per-sample CSV: {OUTPUT_CSV}")
422
+ logger.info(f" Summary JSON: {OUTPUT_JSON}")
423
+ logger.info("=" * 70)
424
+
425
+
426
+ # ============================================================================
427
+ # CLI
428
+ # ============================================================================
429
+
430
+ async def main():
431
+ import argparse
432
+ parser = argparse.ArgumentParser(description="Validate the design code classifier")
433
+ parser.add_argument(
434
+ "--samples", type=int, default=20,
435
+ help="How many random image marks to test against (default: 20)"
436
+ )
437
+ parser.add_argument(
438
+ "--resume", action="store_true",
439
+ help="Skip samples already in .validation_checkpoint.json"
440
+ )
441
+ parser.add_argument(
442
+ "--reset-checkpoint", action="store_true",
443
+ help="Delete checkpoint and start fresh"
444
+ )
445
+ args = parser.parse_args()
446
+
447
+ if args.reset_checkpoint and CHECKPOINT_PATH.exists():
448
+ CHECKPOINT_PATH.unlink()
449
+ logger.info("πŸ—‘οΈ Checkpoint cleared")
450
+
451
+ await run(n_samples=args.samples, resume=args.resume)
452
+
453
+
454
+ if __name__ == "__main__":
455
+ try:
456
+ asyncio.run(main())
457
+ except KeyboardInterrupt:
458
+ logger.info("\n⚠️ Interrupted β€” checkpoint saved, safe to re-run with --resume")
459
+ sys.exit(0)