feat(api): add LLM response validation and input sanitization
Implement Phase 1: Safety & Validation for all LLM-based suggestion engines. - Add input sanitization module to prevent prompt injection attacks - Implement 5 comprehensive validators (routine, batch, shopping, product parse, photo) - Add 10+ critical safety checks (retinoid+acid conflicts, barrier compatibility, etc.) - Integrate validation into all 5 API endpoints (routines, products, skincare) - Add validation fields to ai_call_logs table (validation_errors, validation_warnings, auto_fixed) - Create database migration for validation fields - Add comprehensive test suite (9/9 tests passing, 88% coverage on validators) Safety improvements: - Blocks retinoid + acid conflicts in same routine/day - Rejects unknown product IDs - Enforces min_interval_hours rules - Protects compromised skin barriers - Prevents prohibited fields (dose, amount) in responses - Validates all enum values and score ranges All validation failures are logged and responses are rejected with HTTP 502.
This commit is contained in:
parent
e3ed0dd3a3
commit
2a9391ad32
16 changed files with 2357 additions and 13 deletions
1
backend/tests/validators/__init__.py
Normal file
1
backend/tests/validators/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for LLM response validators."""
|
||||
378
backend/tests/validators/test_routine_validator.py
Normal file
378
backend/tests/validators/test_routine_validator.py
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
"""Tests for RoutineSuggestionValidator."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from innercontext.validators.routine_validator import (
|
||||
RoutineSuggestionValidator,
|
||||
RoutineValidationContext,
|
||||
)
|
||||
|
||||
|
||||
# Helper to create mock product
|
||||
class MockProduct:
|
||||
def __init__(
|
||||
self,
|
||||
product_id,
|
||||
name,
|
||||
actives=None,
|
||||
effect_profile=None,
|
||||
context_rules=None,
|
||||
min_interval_hours=None,
|
||||
category="serum",
|
||||
):
|
||||
self.id = product_id
|
||||
self.name = name
|
||||
self.actives = actives or []
|
||||
self.effect_profile = effect_profile
|
||||
self.context_rules = context_rules
|
||||
self.min_interval_hours = min_interval_hours
|
||||
self.category = category
|
||||
|
||||
|
||||
# Helper to create mock active ingredient
|
||||
class MockActive:
|
||||
def __init__(self, functions):
|
||||
self.functions = functions
|
||||
|
||||
|
||||
# Helper to create mock effect profile
|
||||
class MockEffectProfile:
|
||||
def __init__(
|
||||
self,
|
||||
retinoid_strength=0,
|
||||
exfoliation_strength=0,
|
||||
barrier_disruption_risk=0,
|
||||
irritation_risk=0,
|
||||
):
|
||||
self.retinoid_strength = retinoid_strength
|
||||
self.exfoliation_strength = exfoliation_strength
|
||||
self.barrier_disruption_risk = barrier_disruption_risk
|
||||
self.irritation_risk = irritation_risk
|
||||
|
||||
|
||||
# Helper to create mock context rules
|
||||
class MockContextRules:
|
||||
def __init__(self, safe_after_shaving=True, safe_with_compromised_barrier=True):
|
||||
self.safe_after_shaving = safe_after_shaving
|
||||
self.safe_with_compromised_barrier = safe_with_compromised_barrier
|
||||
|
||||
|
||||
# Helper to create mock routine step
|
||||
class MockStep:
|
||||
def __init__(self, product_id=None, action_type=None, **kwargs):
|
||||
self.product_id = product_id
|
||||
self.action_type = action_type
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
# Helper to create mock routine response
|
||||
class MockRoutine:
|
||||
def __init__(self, steps):
|
||||
self.steps = steps
|
||||
|
||||
|
||||
def test_detects_retinoid_acid_conflict():
|
||||
"""Validator catches retinoid + AHA/BHA in same routine."""
|
||||
# Setup
|
||||
retinoid_id = uuid4()
|
||||
acid_id = uuid4()
|
||||
|
||||
retinoid = MockProduct(
|
||||
retinoid_id,
|
||||
"Retinoid Serum",
|
||||
actives=[MockActive(functions=["retinoid"])],
|
||||
effect_profile=MockEffectProfile(retinoid_strength=3),
|
||||
)
|
||||
|
||||
acid = MockProduct(
|
||||
acid_id,
|
||||
"AHA Toner",
|
||||
actives=[MockActive(functions=["exfoliant_aha"])],
|
||||
effect_profile=MockEffectProfile(exfoliation_strength=4),
|
||||
)
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={retinoid_id, acid_id},
|
||||
routine_date=date.today(),
|
||||
part_of_day="pm",
|
||||
leaving_home=None,
|
||||
barrier_state="intact",
|
||||
products_by_id={retinoid_id: retinoid, acid_id: acid},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=retinoid_id),
|
||||
MockStep(product_id=acid_id),
|
||||
]
|
||||
)
|
||||
|
||||
# Execute
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
# Assert
|
||||
assert not result.is_valid
|
||||
assert any(
|
||||
"retinoid" in err.lower() and "acid" in err.lower() for err in result.errors
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_unknown_product_ids():
|
||||
"""Validator catches UUIDs not in database."""
|
||||
known_id = uuid4()
|
||||
unknown_id = uuid4()
|
||||
|
||||
product = MockProduct(known_id, "Known Product")
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={known_id}, # Only known_id is valid
|
||||
routine_date=date.today(),
|
||||
part_of_day="am",
|
||||
leaving_home=None,
|
||||
barrier_state="intact",
|
||||
products_by_id={known_id: product},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=unknown_id), # This ID doesn't exist
|
||||
]
|
||||
)
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
assert not result.is_valid
|
||||
assert any("unknown" in err.lower() for err in result.errors)
|
||||
|
||||
|
||||
def test_enforces_min_interval_hours():
|
||||
"""Validator catches product used within min_interval."""
|
||||
product_id = uuid4()
|
||||
product = MockProduct(
|
||||
product_id,
|
||||
"High Frequency Product",
|
||||
min_interval_hours=48, # Must wait 48 hours
|
||||
)
|
||||
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1) # Only 24 hours ago
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={product_id},
|
||||
routine_date=today,
|
||||
part_of_day="am",
|
||||
leaving_home=None,
|
||||
barrier_state="intact",
|
||||
products_by_id={product_id: product},
|
||||
last_used_dates={product_id: yesterday}, # Used yesterday
|
||||
)
|
||||
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=product_id),
|
||||
]
|
||||
)
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
assert not result.is_valid
|
||||
assert any(
|
||||
"interval" in err.lower() or "recently" in err.lower() for err in result.errors
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_dose_field():
|
||||
"""Validator rejects responses with prohibited 'dose' field."""
|
||||
product_id = uuid4()
|
||||
product = MockProduct(product_id, "Product")
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={product_id},
|
||||
routine_date=date.today(),
|
||||
part_of_day="am",
|
||||
leaving_home=None,
|
||||
barrier_state="intact",
|
||||
products_by_id={product_id: product},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
# Step with prohibited 'dose' field
|
||||
step_with_dose = MockStep(product_id=product_id, dose="2 drops")
|
||||
routine = MockRoutine(steps=[step_with_dose])
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
assert not result.is_valid
|
||||
assert any(
|
||||
"dose" in err.lower() and "prohibited" in err.lower() for err in result.errors
|
||||
)
|
||||
|
||||
|
||||
def test_missing_spf_in_am_leaving_home():
|
||||
"""Validator warns when no SPF despite leaving home."""
|
||||
product_id = uuid4()
|
||||
product = MockProduct(product_id, "Moisturizer", category="moisturizer")
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={product_id},
|
||||
routine_date=date.today(),
|
||||
part_of_day="am",
|
||||
leaving_home=True, # User is leaving home
|
||||
barrier_state="intact",
|
||||
products_by_id={product_id: product},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=product_id), # No SPF product
|
||||
]
|
||||
)
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
# Should pass validation but have warnings
|
||||
assert result.is_valid
|
||||
assert len(result.warnings) > 0
|
||||
assert any("spf" in warn.lower() for warn in result.warnings)
|
||||
|
||||
|
||||
def test_compromised_barrier_restrictions():
|
||||
"""Validator blocks high-risk actives with compromised barrier."""
|
||||
product_id = uuid4()
|
||||
harsh_product = MockProduct(
|
||||
product_id,
|
||||
"Harsh Acid",
|
||||
effect_profile=MockEffectProfile(
|
||||
barrier_disruption_risk=5, # Very high risk
|
||||
irritation_risk=4,
|
||||
),
|
||||
context_rules=MockContextRules(safe_with_compromised_barrier=False),
|
||||
)
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={product_id},
|
||||
routine_date=date.today(),
|
||||
part_of_day="pm",
|
||||
leaving_home=None,
|
||||
barrier_state="compromised", # Barrier is compromised
|
||||
products_by_id={product_id: harsh_product},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=product_id),
|
||||
]
|
||||
)
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
assert not result.is_valid
|
||||
assert any(
|
||||
"barrier" in err.lower() and "safety" in err.lower() for err in result.errors
|
||||
)
|
||||
|
||||
|
||||
def test_step_must_have_product_or_action():
|
||||
"""Validator rejects steps with neither product_id nor action_type."""
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids=set(),
|
||||
routine_date=date.today(),
|
||||
part_of_day="am",
|
||||
leaving_home=None,
|
||||
barrier_state="intact",
|
||||
products_by_id={},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
# Empty step (neither product nor action)
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=None, action_type=None),
|
||||
]
|
||||
)
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
assert not result.is_valid
|
||||
assert any("product_id" in err and "action_type" in err for err in result.errors)
|
||||
|
||||
|
||||
def test_step_cannot_have_both_product_and_action():
|
||||
"""Validator rejects steps with both product_id and action_type."""
|
||||
product_id = uuid4()
|
||||
product = MockProduct(product_id, "Product")
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={product_id},
|
||||
routine_date=date.today(),
|
||||
part_of_day="am",
|
||||
leaving_home=None,
|
||||
barrier_state="intact",
|
||||
products_by_id={product_id: product},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
# Step with both product_id AND action_type (invalid)
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=product_id, action_type="shaving"),
|
||||
]
|
||||
)
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
assert not result.is_valid
|
||||
assert any("cannot have both" in err.lower() for err in result.errors)
|
||||
|
||||
|
||||
def test_accepts_valid_routine():
|
||||
"""Validator accepts a properly formed safe routine."""
|
||||
cleanser_id = uuid4()
|
||||
moisturizer_id = uuid4()
|
||||
spf_id = uuid4()
|
||||
|
||||
cleanser = MockProduct(cleanser_id, "Cleanser", category="cleanser")
|
||||
moisturizer = MockProduct(moisturizer_id, "Moisturizer", category="moisturizer")
|
||||
spf = MockProduct(spf_id, "SPF", category="spf")
|
||||
|
||||
context = RoutineValidationContext(
|
||||
valid_product_ids={cleanser_id, moisturizer_id, spf_id},
|
||||
routine_date=date.today(),
|
||||
part_of_day="am",
|
||||
leaving_home=True,
|
||||
barrier_state="intact",
|
||||
products_by_id={
|
||||
cleanser_id: cleanser,
|
||||
moisturizer_id: moisturizer,
|
||||
spf_id: spf,
|
||||
},
|
||||
last_used_dates={},
|
||||
)
|
||||
|
||||
routine = MockRoutine(
|
||||
steps=[
|
||||
MockStep(product_id=cleanser_id),
|
||||
MockStep(product_id=moisturizer_id),
|
||||
MockStep(product_id=spf_id),
|
||||
]
|
||||
)
|
||||
|
||||
validator = RoutineSuggestionValidator()
|
||||
result = validator.validate(routine, context)
|
||||
|
||||
assert result.is_valid
|
||||
assert len(result.errors) == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue