11from fastapi import APIRouter , HTTPException , Depends , Request , BackgroundTasks
2- from pydantic import BaseModel
2+ from fastapi .exceptions import RequestValidationError
3+ from fastapi .responses import JSONResponse
4+ from pydantic import BaseModel , Field , validator
35from typing import Optional , List , Dict , Any
6+ from difflib import SequenceMatcher
47import logging
58import uuid
69
@@ -41,9 +44,19 @@ class SuggestionRequest(BaseModel):
4144 field_name : str
4245 field_label : Optional [str ] = None
4346 field_type : Optional [str ] = "text"
47+ field_options : Optional [List [Dict [str , Any ]]] = None
48+ is_dropdown : Optional [bool ] = None
4449 current_value : Optional [str ] = None
4550 n_results : int = 5
4651
52+ @validator ('field_type' , pre = True , always = True )
53+ def normalize_field_type (cls , v ):
54+ if v is None or str (v ).strip () == "" :
55+ return "text"
56+ v = str (v ).lower ()
57+ allowed = {"text" , "email" , "tel" , "phone" , "number" , "select" , "dropdown" , "textarea" , "url" , "date" , "radio" , "checkbox" }
58+ return v if v in allowed else "text"
59+
4760
4861class SuggestionResponse (BaseModel ):
4962 suggestions : List [str ]
@@ -54,12 +67,23 @@ class IntelligentSuggestionRequest(BaseModel):
5467 """Request for profile-based intelligent suggestions."""
5568 field_name : str
5669 field_label : Optional [str ] = None
57- field_type : Optional [str ] = " text"
70+ field_type : Optional [str ] = Field ( default = None , description = "Field input type ( text, email, select, textarea, etc.)" )
5871 form_purpose : Optional [str ] = "General"
59- previous_answers : Optional [Dict [str , str ]] = None
72+ previous_answers : Optional [Dict [str , Any ]] = None
6073 form_url : Optional [str ] = None
6174 all_field_labels : Optional [List [str ]] = None
6275 session_id : Optional [str ] = None
76+ field_options : Optional [List [Dict [str , Any ]]] = Field (default = None , description = "Dropdown options for this field" )
77+ is_dropdown : Optional [bool ] = Field (default = None , description = "True if field has a constrained options list" )
78+
79+ @validator ('field_type' , pre = True , always = True )
80+ def normalize_field_type (cls , v ):
81+ # Accept None/unknown; default to text and explicitly allow textarea
82+ if v is None or str (v ).strip () == "" :
83+ return "text"
84+ v = str (v ).lower ()
85+ allowed = {"text" , "email" , "tel" , "phone" , "number" , "select" , "dropdown" , "textarea" , "url" , "date" }
86+ return v if v in allowed else "text"
6387
6488
6589class IntelligentSuggestionItem (BaseModel ):
@@ -104,6 +128,8 @@ async def get_suggestions(
104128 "name" : data .field_name ,
105129 "label" : data .field_label or data .field_name ,
106130 "type" : data .field_type or "text" ,
131+ "options" : data .field_options or [],
132+ "is_dropdown" : data .is_dropdown if data .is_dropdown is not None else bool (data .field_options ),
107133 }
108134
109135 # Detect patterns from current value if provided
@@ -125,6 +151,12 @@ async def get_suggestions(
125151
126152 # Extract suggestion values
127153 suggestion_values = [s .suggested_value for s in suggestions if s .target_field == data .field_name ]
154+
155+ if data .is_dropdown or (data .field_options and len (data .field_options ) > 0 ):
156+ filtered_values = _filter_to_field_options (suggestion_values , data .field_options )
157+ if len (filtered_values ) != len (suggestion_values ):
158+ logger .info (f"🎛️ Dropdown guardrail filtered { len (suggestion_values ) - len (filtered_values )} invalid pattern suggestions for { data .field_name } " )
159+ suggestion_values = filtered_values
128160
129161 return SuggestionResponse (
130162 suggestions = suggestion_values [:data .n_results ],
@@ -199,6 +231,7 @@ async def get_smart_suggestions(
199231 "name" : data .field_name ,
200232 "label" : data .field_label or data .field_name ,
201233 "type" : data .field_type or "text" ,
234+ "options" : data .field_options or [],
202235 }
203236
204237 form_context = {
@@ -225,6 +258,16 @@ async def get_smart_suggestions(
225258
226259 # Determine tier used
227260 tier_used = suggestions [0 ].tier .value if suggestions else "pattern_only"
261+
262+ # Guardrail: for dropdowns, only allow values that match an existing option
263+ filtered = suggestions
264+ if data .is_dropdown or (data .field_options and len (data .field_options ) > 0 ):
265+ allowed_values = _filter_to_field_options ([s .value for s in suggestions ], data .field_options )
266+ allowed_set = {str (v ).strip ().lower () for v in allowed_values }
267+ filtered = [s for s in suggestions if str (s .value ).strip ().lower () in allowed_set ]
268+ if len (filtered ) != len (suggestions ):
269+ logger .info (f"🎛️ Dropdown guardrail filtered { len (suggestions ) - len (filtered )} invalid suggestions for { data .field_name } " )
270+ suggestions = filtered
228271
229272 return IntelligentSuggestionResponse (
230273 suggestions = [
@@ -258,6 +301,13 @@ async def get_smart_suggestions(
258301# Helper Functions
259302# =============================================================================
260303
304+ async def validation_exception_handler (request : Request , exc : RequestValidationError ):
305+ """Log full validation details for faster debugging."""
306+ body = getattr (exc , "body" , None )
307+ logger .error (f"❌ Validation error on { request .url .path } : { exc .errors ()} | body={ body } " )
308+ return JSONResponse (status_code = 422 , content = {"detail" : exc .errors (), "body" : body })
309+
310+
261311def _infer_field_pattern (field_name : str , field_label : str , field_type : str ) -> str :
262312 """
263313 Infer the field pattern/category for RAG lookup.
@@ -294,3 +344,41 @@ def _infer_field_pattern(field_name: str, field_label: str, field_type: str) ->
294344 return "website"
295345 else :
296346 return field_name .lower ()
347+
348+
349+ def _filter_to_field_options (values : List [str ], options : Optional [List [Dict [str , Any ]]]) -> List [str ]:
350+ """
351+ Restrict suggestions to provided options using case-insensitive fuzzy match.
352+ Returns canonical option labels/values when matched.
353+ """
354+ if not options :
355+ return values
356+
357+ option_labels = []
358+ for opt in options :
359+ label = opt .get ("label" )
360+ value = opt .get ("value" )
361+ if label :
362+ option_labels .append (str (label ).strip ())
363+ if value and value != label :
364+ option_labels .append (str (value ).strip ())
365+
366+ if not option_labels :
367+ return values
368+
369+ filtered : List [str ] = []
370+ for val in values :
371+ sval = str (val or "" ).strip ()
372+ sval_lower = sval .lower ()
373+ for opt in option_labels :
374+ opt_lower = opt .lower ()
375+ ratio = SequenceMatcher (None , sval_lower , opt_lower ).ratio ()
376+ if (
377+ sval_lower == opt_lower
378+ or sval_lower in opt_lower
379+ or opt_lower in sval_lower
380+ or ratio >= 0.82
381+ ):
382+ filtered .append (opt )
383+ break
384+ return filtered
0 commit comments