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:
Piotr Oleszczyk 2026-03-06 10:16:47 +01:00
parent e3ed0dd3a3
commit 2a9391ad32
16 changed files with 2357 additions and 13 deletions

View file

@ -0,0 +1 @@
"""Tests for LLM response validators."""

View 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