Android_Retail/src/Pages/Product/Utils/EnhancedVoiceForm.jsx

493 lines
20 KiB
JavaScript

import { useState, useEffect, useRef, useCallback } from "react";
import { Mic, MicOff } from "lucide-react";
import { Messages } from "../../../Components/Notifications/Messages";
import { Tooltip } from "antd";
const EnhancedVoiceForm = ({ setVoiceData, uomNames }) => {
const [messageType, setMessageType] = useState(null);
const [messageData, setMessageData] = useState(null);
const [formData, setFormData] = useState({ product: "", qty: "", uom: "", price: "" });
const [transcript, setTranscript] = useState("");
const [isListening, setIsListening] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState("");
const [confidence, setConfidence] = useState(0);
const recognitionRef = useRef(null);
// Improved extractEntities function with better pattern recognition
// Improved extractEntities function with better pattern recognition
const extractEntities = (text) => {
if (!text?.trim()) return { product: "", qty: "", uom: "", price: "" };
const cleanText = text.toLowerCase().trim();
// UOM mappings (comprehensive list with all common units)
const uomMappings = {
// Existing units
nos: ["nos", "no", "number", "numbers", "piece", "pieces", "pcs", "pc", "unit", "units", "each"],
"m.w": ["m.w", "mw", "meter weight", "meter weight", "m weight", "m w"],
"l.w": ["l.w", "lw", "length width", "length and width", "l width", "l w", "length", "width"],
"h.w": ["h.w", "hw", "height width", "height and width", "h width", "h w", "height", "width"],
box: ["box", "boxes", "bx", "carton", "cartons", "case", "cases"],
plt: ["plt", "plate", "plates", "plat", "plats"],
pcs: ["pcs", "pc", "piece", "pieces", "unit", "units", "each"],
// Weight/Mass units
kgs: ["kgs", "kilos", "kilograms", "kilogrammes"],
kg: ["kg", "kilo", "kilogram", "kilogramme"],
gram: ["gram", "grams", "g", "gms", "gramme", "grammes"],
ton: ["ton", "tons", "tonne", "tonnes", "t"],
pound: ["lb", "lbs", "pound", "pounds"],
ounce: ["oz", "ounce", "ounces"],
// Volume units
ltr: ["ltr", "l", "liter", "liters", "litre", "litres", "liquid", "fluid"],
ml: ["ml", "mili", "milli", "millilitre", "millilitres", "milliliter", "milliliters", "mili litre", "mili liter", "milli litre", "milli liter"],
gallon: ["gal", "gallon", "gallons"],
pint: ["pt", "pint", "pints"],
cup: ["cup", "cups"],
tablespoon: ["tbsp", "tablespoon", "tablespoons", "table spoon", "table spoons"],
teaspoon: ["tsp", "teaspoon", "teaspoons", "tea spoon", "tea spoons"],
// Length/Distance units
meter: ["meter", "meters", "m", "mtr", "metre", "metres", "length", "distance"],
centimeter: ["cm", "centimeter", "centimeters", "centimetre", "centimetres", "centi meter", "centi meters"],
millimeter: ["mm", "millimeter", "millimeters", "millimetre", "millimetres", "milli meter", "milli meters"],
kilometer: ["km", "kilometer", "kilometers", "kilometre", "kilometres", "kilo meter", "kilo meters"],
inch: ["in", "inch", "inches"],
foot: ["ft", "foot", "feet"],
yard: ["yd", "yard", "yards"],
// Area units
sqm: ["sq m", "sqm", "square meter", "square meters", "square metre", "square metres"],
sqft: ["sq ft", "sqft", "square foot", "square feet"],
acre: ["acre", "acres"],
// Time units
second: ["sec", "second", "seconds", "s"],
minute: ["min", "minute", "minutes"],
hour: ["hr", "hour", "hours", "h"],
day: ["day", "days"],
week: ["week", "weeks"],
month: ["month", "months"],
year: ["year", "years", "yr", "yrs"],
// Temperature units
celsius: ["°C", "celsius", "centigrade", "c", "degree celsius", "degrees celsius"],
fahrenheit: ["°F", "fahrenheit", "f", "degree fahrenheit", "degrees fahrenheit"],
kelvin: ["K", "kelvin", "degree kelvin", "degrees kelvin"],
// Electrical units
watt: ["watt", "watts", "w", "power", "wt"],
volt: ["volt", "volts", "v", "voltage", "vt"],
ampere: ["amp", "amps", "ampere", "amperes", "a"],
ohm: ["ohm", "ohms", "Ω"],
// Other common units
dozen: ["dozen", "dozens", "dz"],
pair: ["pair", "pairs", "pr"],
set: ["set", "sets"],
pack: ["pack", "packs", "packet", "packets", 'pac'],
roll: ["roll", "rolls"],
sheet: ["sheet", "sheets"],
bottle: ["bottle", "bottles", "btl"],
can: ["can", "cans"],
jar: ["jar", "jars"],
tube: ["tube", "tubes"]
};
// Number words mapping (compound phrases first to match them before individual words)
const numberWords = {
"three quarters": "0.75",
"two quarters": "0.5",
"one quarter": "0.25",
"a quarter": "0.25",
"half": "0.5",
"one half": "0.5",
"a half": "0.5",
zero: "0", one: "1", two: "2", three: "3", four: "4", five: "5",
six: "6", seven: "7", eight: "8", nine: "9", ten: "10",
eleven: "11", twelve: "12", thirteen: "13", fourteen: "14", fifteen: "15",
sixteen: "16", seventeen: "17", eighteen: "18", nineteen: "19", twenty: "20",
thirty: "30", forty: "40", fifty: "50", sixty: "60", seventy: "70",
eighty: "80", ninety: "90", hundred: "100",
quarter: "0.25"
};
// Create reverse mapping for UOM lookup
const uomReverseMapping = {};
Object.entries(uomMappings).forEach(([standardUom, variations]) => {
variations.forEach(variation => {
uomReverseMapping[variation] = standardUom;
});
});
// Create regex pattern for all UOM variations (sorted by length desc to match longer ones first)
const allUomVariations = Object.keys(uomReverseMapping).sort((a, b) => b.length - a.length);
const uomPattern = allUomVariations.map(uom => uom.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
let qty = "";
let uom = "";
let product = "";
let price = "";
// --- IMPROVED PRICE EXTRACTION ---
// First, look for specific price patterns with actual numbers
const specificPricePatterns = [
/(?:₹|rs\.?|inr|rupees)\s*(\d+(?:\.\d+)?)/i, // ₹100, rs 100, inr 100, rupees 100
/(\d+(?:\.\d+)?)\s*(?:₹|rs\.?|inr|rupees)/i, // 100₹, 100 rs, 100 inr, 100 rupees
/price\s*(?:is\s*)?(?:₹|rs\.?|inr|rupees)?\s*(\d+(?:\.\d+)?)/i, // price is 100, price rs 100
/costs?\s*(?:₹|rs\.?|inr|rupees)?\s*(\d+(?:\.\d+)?)/i, // costs 100, cost rs 100
/for\s*(?:₹|rs\.?|inr|rupees)?\s*(\d+(?:\.\d+)?)/i // for 100, for rs 100
];
// Check for specific price patterns first
for (const pattern of specificPricePatterns) {
const priceMatch = cleanText.match(pattern);
if (priceMatch && priceMatch[1]) {
price = priceMatch[1];
break;
}
}
// Remove the entire price phrase from text for further processing
let textWithoutPrice = cleanText;
if (price) {
// Remove the matched price pattern
for (const pattern of specificPricePatterns) {
textWithoutPrice = textWithoutPrice.replace(pattern, " ");
}
}
// Also remove currency indicators that don't have specific prices (like "in rupees", "for money")
const currencyIndicators = [
/\bin\s+rupees?\b/gi,
/\bfor\s+money\b/gi,
/\bfor\s+rupees?\b/gi,
/\bwith\s+rupees?\b/gi,
/\bcurrency\s+rupees?\b/gi
];
currencyIndicators.forEach(pattern => {
textWithoutPrice = textWithoutPrice.replace(pattern, " ");
});
// Clean up extra spaces
textWithoutPrice = textWithoutPrice.replace(/\s+/g, " ").trim();
// --- REST OF EXTRACTION LOGIC (same as before but using textWithoutPrice) ---
// Pattern 1: Handle attached quantity-UOM (like "apple1kg", "milk2l", "water500ml")
const attachedPattern = new RegExp(`(.+?)(\\d+(?:\\.\\d+)?)(${uomPattern})(?:\\s|$)`, 'i');
const attachedMatch = textWithoutPrice.match(attachedPattern);
if (attachedMatch) {
const productPart = attachedMatch[1].trim();
const qtyPart = attachedMatch[2];
const uomPart = attachedMatch[3];
if (productPart && qtyPart && uomReverseMapping[uomPart]) {
product = productPart;
qty = qtyPart;
uom = uomReverseMapping[uomPart];
}
}
// Pattern 2: Spaced quantity-UOM patterns (like "apple 1 kg", "milk 2 liters", "water 500 ml")
if (!qty || !uom) {
const spacedPatterns = [
new RegExp(`(.+?)\\s+(\\d+(?:\\.\\d+)?)\\s+(${uomPattern})(?:\\s|$)`, 'i'),
new RegExp(`(\\d+(?:\\.\\d+)?)\\s+(${uomPattern})\\s+(.+)`, 'i'),
new RegExp(`(\\d+(?:\\.\\d+)?)\\s+(${uomPattern})\\s+of\\s+(.+)`, 'i')
];
for (const pattern of spacedPatterns) {
const match = textWithoutPrice.match(pattern);
if (match) {
if (match[3] && !match[1].match(/^\d/)) {
// Pattern: "product qty uom"
product = match[1].trim();
qty = match[2];
uom = uomReverseMapping[match[3]];
} else if (match[3]) {
// Pattern: "qty uom product" or "qty uom of product"
qty = match[1];
uom = uomReverseMapping[match[2]];
product = match[3].trim();
}
if (product && qty && uom) break;
}
}
}
// Pattern 3: Word number patterns (like "two kg apple", etc.)
if (!qty || !uom) {
const sortedNumberWords = Object.keys(numberWords).sort((a, b) => b.length - a.length);
for (const numberPhrase of sortedNumberWords) {
if (textWithoutPrice.includes(numberPhrase)) {
const escapedPhrase = numberPhrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!qty || !uom) {
const wordNumbers = Object.keys(numberWords).join('|');
const wordNumberPatterns = [
new RegExp(`(.+?)\\s+(${wordNumbers})\\s+(${uomPattern})(?:\\s|$)`, 'i'),
new RegExp(`(${wordNumbers})\\s+(${uomPattern})\\s+(.+)`, 'i'),
new RegExp(`(${wordNumbers})\\s+(${uomPattern})\\s+of\\s+(.+)`, 'i')
];
for (const pattern of wordNumberPatterns) {
const match = textWithoutPrice.match(pattern);
if (match) {
if (match[3] && !numberWords[match[1]]) {
product = match[1].trim();
qty = numberWords[match[2]];
uom = uomReverseMapping[match[3]];
} else if (match[3]) {
qty = numberWords[match[1]];
uom = uomReverseMapping[match[2]];
product = match[3].trim();
}
if (product && qty && uom) break;
}
}
}
const compoundPatterns = [
new RegExp(`(.+?)\\s+(${escapedPhrase})\\s+(${uomPattern})(?:\\s|$)`, 'i'),
new RegExp(`(${escapedPhrase})\\s+(${uomPattern})\\s+(.+)`, 'i'),
new RegExp(`(${escapedPhrase})\\s+(${uomPattern})\\s+of\\s+(.+)`, 'i')
];
for (const pattern of compoundPatterns) {
const match = textWithoutPrice.match(pattern);
if (match) {
if (match[3] && match[1] !== numberPhrase) {
product = match[1].trim();
qty = numberWords[numberPhrase];
uom = uomReverseMapping[match[3]];
} else if (match[3]) {
qty = numberWords[numberPhrase];
uom = uomReverseMapping[match[2]];
product = match[3].trim();
}
if (product && qty && uom) break;
}
}
if (product && qty && uom) break;
}
}
}
// Pattern 4: Fallback - look for any quantity and UOM separately
if (!qty) {
const qtyMatch = textWithoutPrice.match(/(\d+(?:\.\d+)?)/);
if (qtyMatch) {
qty = qtyMatch[1];
} else {
const sortedNumberWords = Object.keys(numberWords).sort((a, b) => b.length - a.length);
for (const numberPhrase of sortedNumberWords) {
if (textWithoutPrice.includes(numberPhrase)) {
qty = numberWords[numberPhrase];
break;
}
}
}
}
if (!uom) {
const words = textWithoutPrice.split(/\s+/);
for (const word of words) {
if (uomReverseMapping[word]) {
uom = uomReverseMapping[word];
break;
}
}
}
// Extract product if not found yet
if (!product) {
const words = textWithoutPrice.split(/\s+/);
const excludeWords = new Set();
if (qty) excludeWords.add(qty);
if (uom) {
excludeWords.add(uom);
if (uomMappings[uom]) {
uomMappings[uom].forEach(variation => excludeWords.add(variation));
}
}
Object.keys(numberWords).forEach(word => {
excludeWords.add(word);
word.split(' ').forEach(subword => excludeWords.add(subword));
});
Object.keys(uomReverseMapping).forEach(variation => excludeWords.add(variation));
['of', 'and', 'the', 'a', 'an'].forEach(word => excludeWords.add(word));
const productWords = words.filter(word =>
!excludeWords.has(word) &&
!excludeWords.has(word.toLowerCase()) &&
!/^\d+(\.\d+)?$/.test(word)
);
product = productWords.join(' ').trim();
}
// Clean up product name
product = product
.replace(/^\W+|\W+$/g, '')
.split(/\s+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
// Set default quantity if product exists but no quantity found
const finalQty = product && !qty ? "1" : qty;
const result = {
product: product || "",
qty: finalQty || "",
uom: uom || "",
price: price || ""
};
return result;
};
// Setup speech recognition (same as before)
useEffect(() => {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
setError("Speech Recognition not supported in this browser");
return;
}
const recognition = new SpeechRecognition();
recognition.lang = "en-US";
recognition.continuous = false;
recognition.interimResults = false;
recognition.maxAlternatives = 3;
recognition.onstart = () => {
setIsProcessing(false);
setError("");
};
recognition.onresult = (event) => {
setIsProcessing(true);
const results = event.results[0];
let bestResult = results[0];
for (let i = 1; i < results.length; i++) {
if (results[i].confidence > bestResult.confidence) {
bestResult = results[i];
}
}
const spokenText = bestResult.transcript;
const confidence = bestResult.confidence || 0;
setTranscript(spokenText);
setConfidence(confidence);
const { product, qty, uom, price } = extractEntities(spokenText);
setFormData({ product, qty, uom, price });
if (setVoiceData) {
setVoiceData({ product, qty, uom, price, transcript: spokenText, confidence });
}
setIsProcessing(false);
};
recognition.onerror = (event) => {
setIsProcessing(false);
setError(event.error);
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
setIsProcessing(false);
};
recognitionRef.current = recognition;
return () => {
recognitionRef.current?.abort();
};
}, [setVoiceData]);
useEffect(() => {
if (error) {
setMessageType("error");
setMessageData(`Recognition error: ${error}`);
}
}, [error]);
const startListening = async () => {
try {
setError("");
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => track.stop());
recognitionRef.current?.start();
setIsListening(true);
setTranscript("");
setFormData({ product: "", qty: "", uom: "" });
setConfidence(0);
} catch {
setError("Microphone access denied or not available");
setIsListening(false);
}
};
const stopListening = () => {
recognitionRef.current?.stop();
setIsListening(false);
setIsProcessing(false);
};
const onComplete = useCallback(() => {
setMessageData(null);
setMessageType(null);
}, []);
return (
<Tooltip
title={
<>
Voice Input: {isListening ? "Stop Listening" : "Start Listening"}
<br />
Examples:
<ul style={{ margin: 0, paddingLeft: "20px" }}>
<li>1 plate Chicken Biryani 200 rupees</li>
<li>2 cups of Coffee 30 rupees</li>
<li>12 rupees 1 cup tea</li>
<li>50 rupees 1 kg rice</li>
</ul>
</>
}
>
<div className="voice-wrapper flex items-center">
<Messages messageType={messageType} messageData={messageData} onComplete={onComplete} />
<div
className={`voice-search-btn ${isListening ? "on" : ""}`}
onClick={isListening ? stopListening : startListening}
title="Voice Search"
>
{isListening ? <Mic size={18} /> : <MicOff size={18} />}
{isListening && <span className="font-medium ml-1">Listening...</span>}
</div>
{/* Debug info */}
{/* {transcript && (
<div className="ml-4 text-sm text-gray-600">
Heard: "{transcript}" → Product: "{formData.product}", Qty: "{formData.qty}", UOM: "{formData.uom}", Price: "{formData.price}"
</div>
)} */}
</div>
</Tooltip>
);
};
export default EnhancedVoiceForm;