File size: 12,745 Bytes
e7b5120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376edbc
 
e7b5120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
"""
Product Condition Classification Service using Groq Vision API
Analyzes product images to determine if items are resellable, refurbishable, or scrap.

Switched from Google Gemini to Groq to avoid free-tier rate limits.
Uses meta-llama/llama-4-scout-17b-16e-instruct (with Maverick fallback) via the Groq chat completions endpoint.
"""

import os
import sys
import base64
import logging
import mimetypes
import re
from typing import Literal
from io import BytesIO

logger = logging.getLogger(__name__)

# Ensure backend root is importable so we can read voice_config
_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if _project_root not in sys.path:
    sys.path.insert(0, _project_root)

try:
    from groq import Groq
    GROQ_AVAILABLE = True
except ImportError:
    GROQ_AVAILABLE = False
    logger.warning("groq package not installed. Install with: pip install groq")

try:
    from voice_config import GROQ_API_KEY
except ImportError:
    GROQ_API_KEY = os.environ.get('GROQ_API_KEY', '')


ProductCondition = Literal["resale", "refurb", "scrap"]



# ────────────────────────────────────────────────────────────────────
#  Keyword extraction helper (unchanged from original)
# ────────────────────────────────────────────────────────────────────

def _extract_classification_from_text(text: str) -> ProductCondition:
    """
    Extract classification from natural language response using regex / keyword matching.
    Returns one of: "resale", "refurb", or "scrap".
    """
    text_lower = text.lower()

    scrap_patterns = [
        r'\bscrap\b', r'\bscrapped\b', r'\btoo damaged\b',
        r'\bbeyond repair\b', r'\bcannot be repaired\b',
        r'\bseverely damaged\b', r'\bcompletely broken\b',
        r'\bnot repairable\b', r'\bwrite[\s-]?off\b',
        r'\bdispose\b', r'\bunusable\b',
    ]
    resale_patterns = [
        r'\bresale\b', r'\bresell\b', r'\bresellable\b',
        r'\bperfect condition\b', r'\bexcellent condition\b',
        r'\blike new\b', r'\bmint condition\b', r'\bpristine\b',
        r'\bno damage\b', r'\bflawless\b', r'\bnew condition\b',
    ]
    refurb_patterns = [
        r'\brefurb\b', r'\brefurbish\b', r'\brefurbished\b',
        r'\brefurbishment\b', r'\bneeds repair\b',
        r'\bcan be repaired\b', r'\brepairable\b',
        r'\bminor damage\b', r'\bfixable\b', r'\bcan be fixed\b',
        r'\bneeds work\b', r'\bneeds restoration\b',
    ]

    scrap_score = sum(1 for p in scrap_patterns if re.search(p, text_lower))
    resale_score = sum(1 for p in resale_patterns if re.search(p, text_lower))
    refurb_score = sum(1 for p in refurb_patterns if re.search(p, text_lower))

    logger.info(f"Classification scores - scrap: {scrap_score}, resale: {resale_score}, refurb: {refurb_score}")

    if scrap_score > 0 and scrap_score >= resale_score and scrap_score >= refurb_score:
        return "scrap"
    if resale_score > 0 and resale_score > scrap_score and resale_score >= refurb_score:
        return "resale"
    if refurb_score > 0:
        return "refurb"

    # Simple substring fallback
    if 'scrap' in text_lower:
        return "scrap"
    if 'resale' in text_lower or 'resell' in text_lower:
        return "resale"
    if 'refurb' in text_lower:
        return "refurb"

    logger.warning(f"Could not extract classification from: '{text}'. Defaulting to 'refurb'")
    return "refurb"


# ────────────────────────────────────────────────────────────────────
#  Groq-based classification service
# ────────────────────────────────────────────────────────────────────

class GroqClassificationService:
    """Product condition classification via Groq Vision API."""

    # Models tried in order of preference (vision-capable)
    _MODEL_PRIORITY = [
        "meta-llama/llama-4-scout-17b-16e-instruct",     # recommended replacement, fast
        "meta-llama/llama-4-maverick-17b-128e-instruct",  # higher quality fallback
    ]

    def __init__(self, api_key: str | None = None):
        if not GROQ_AVAILABLE:
            raise ImportError("groq package not installed. Install with: pip install groq")

        self.api_key = api_key or GROQ_API_KEY or os.environ.get('GROQ_API_KEY', '')
        if not self.api_key:
            raise ValueError("GROQ_API_KEY not found. Set it in voice_config.py or as an env var.")

        self.client = Groq(api_key=self.api_key)
        self.model_name = self._MODEL_PRIORITY[0]
        logger.info(f"GroqClassificationService initialized (model: {self.model_name})")

    # ── helpers ────────────────────────────────────────────────────

    @staticmethod
    def _image_to_data_url(image_file) -> str:
        """Read a Django UploadedFile into a base64 data-URL."""
        image_file.seek(0)
        raw = image_file.read()

        # Detect MIME type
        mime = getattr(image_file, 'content_type', None)
        if not mime:
            fname = getattr(image_file, 'name', 'image.jpg')
            mime, _ = mimetypes.guess_type(fname)
        if not mime or not mime.startswith('image/'):
            mime = 'image/jpeg'

        b64 = base64.b64encode(raw).decode('utf-8')
        return f"data:{mime};base64,{b64}"

    def _call_vision(self, image_file, prompt: str, max_tokens: int = 500) -> str:
        """Send an image + text prompt to Groq vision and return the text reply."""
        data_url = self._image_to_data_url(image_file)

        messages = [
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {"url": data_url},
                    },
                    {
                        "type": "text",
                        "text": prompt,
                    },
                ],
            }
        ]

        last_err = None
        for model in self._MODEL_PRIORITY:
            try:
                response = self.client.chat.completions.create(
                    model=model,
                    messages=messages,
                    temperature=0.3,
                    max_tokens=max_tokens,
                )
                text = response.choices[0].message.content.strip()
                logger.info(f"Groq vision ({model}) response: {text[:200]}")
                return text
            except Exception as e:
                last_err = e
                logger.warning(f"Groq model {model} failed: {e}")
                continue

        raise last_err  # all models failed

    # ── public classification ──────────────────────────────────────

    def classify_product_condition(self, image_file) -> ProductCondition:
        """
        Classify product condition from image.
        Returns one of: "resale", "refurb", or "scrap".
        """
        prompt = (
            "You are a product condition expert. "
            "Look at this product image carefully.\n\n"
            "Your response MUST start with one of these words:\n"
            "- resale  (if excellent / like-new condition)\n"
            "- refurb  (if damaged but fixable)\n"
            "- scrap   (if too damaged to repair economically)\n\n"
            "Format: \"[classification] - [explanation of damage/condition]\"\n\n"
            "Analyze and respond:"
        )
        try:
            result_text = self._call_vision(image_file, prompt)
            classification = _extract_classification_from_text(result_text)
            logger.info(f"Extracted classification: {classification}")
            return classification
        except Exception as e:
            logger.error(f"Error during classification: {e}", exc_info=True)
            logger.warning("Falling back to 'refurb' due to error")
            return "refurb"

    def verify_product_name_matches_image(
        self, image_file, product_name: str, threshold: float = 0.6
    ) -> bool:
        """
        Verify if uploaded image likely matches product name.
        Returns True (match) or False.
        Defaults to True when the LLM is unreachable (lenient).
        """
        if not product_name or not product_name.strip():
            logger.error("Product name is empty")
            return False

        prompt = (
            f"Look at this product image.\n\n"
            f"The customer says this image is of: \"{product_name}\".\n\n"
            f"Does this image show the SAME GENERAL TYPE/CATEGORY of product? "
            f"For example, if the product name says 'router' the image should show a router or networking device. "
            f"If the product name says 'headphones' the image should show headphones or earbuds.\n\n"
            f"Be LENIENT β€” brand, color, or minor details don't matter. "
            f"Only say NO if the image clearly shows a COMPLETELY DIFFERENT type of product "
            f"(e.g., image shows clothing but product name says electronics).\n\n"
            f"Respond with ONLY 'YES' or 'NO'."
        )

        try:
            result_text = self._call_vision(image_file, prompt, max_tokens=50)
            upper = result_text.upper()
            logger.info(f"Product match raw response: {result_text}")

            # Check for explicit YES/NO (whole word only)
            # Use word-boundary regex to avoid matching substrings like "NOISE" as "NO"
            has_yes = bool(re.search(r'\bYES\b', upper))
            has_no = bool(re.search(r'\bNO\b', upper))

            if has_yes and not has_no:
                return True
            if has_no and not has_yes:
                return False

            # Both YES and NO present β€” use position (whichever comes first)
            if has_yes and has_no:
                yes_pos = re.search(r'\bYES\b', upper).start()
                no_pos = re.search(r'\bNO\b', upper).start()
                return yes_pos < no_pos

            # Try to parse a numeric confidence
            match = re.search(r'(\d+)', upper)
            if match:
                confidence = int(match.group(1)) / 100.0
                logger.info(f"Parsed confidence: {confidence}")
                return confidence >= threshold

            # If we can't parse the answer, be lenient and allow it
            logger.warning("Could not parse response - defaulting to True (lenient)")
            return True

        except Exception as e:
            logger.error(f"Error during product name verification: {e}", exc_info=True)
            return True  # lenient on errors


# ────────────────────────────────────────────────────────────────────
#  Singleton & convenience functions (same public API as before)
# ────────────────────────────────────────────────────────────────────

_service_instance = None


def get_classification_service() -> GroqClassificationService:
    """Get or create singleton instance of classification service."""
    global _service_instance
    if _service_instance is None:
        try:
            _service_instance = GroqClassificationService()
            logger.info("Created new GroqClassificationService instance")
        except Exception as e:
            logger.error(f"Failed to initialize classification service: {e}")
            raise
    return _service_instance


def classify_product_image(image_file) -> ProductCondition:
    """Convenience: classify a product image -> 'resale' | 'refurb' | 'scrap'."""
    service = get_classification_service()
    return service.classify_product_condition(image_file)


def verify_product_name_matches_image(
    image_file, product_name: str, threshold: float = 0.6
) -> bool:
    """Convenience: check if uploaded image matches product_name."""
    service = get_classification_service()
    return service.verify_product_name_matches_image(image_file, product_name, threshold)