493 lines
20 KiB
JavaScript
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; |