ai_qc/backend/client_config.py
nickviljoen 50d0063b37 Add Boots Production Pack profile (multi-page document mode)
New profile boots_ppack for QCing multi-page Boots production packs
(PowerPoint-exported PDFs, 4-18 pages each). Built on top of AXA's
document-mode infrastructure — branched off feature/axa-document-mode
because it reuses the dispatcher, ingest, and result writer.

New checks:
- boots_logo_compliance — three-path scoring (master wordmark / partner
  lock-up / no branding) so OLIVER x BOOTS-style footer lock-ups aren't
  scored against master wordmark rules. Conservative without a formal
  Boots logo guideline.
- boots_colour_palette — verifies CMYK/RGB/Hex spec values on creative-
  guidance pages against canonical Boots Blue / Health Primary Blue /
  Offer Red, plus visual sanity-check on artwork pages.

Existing checks tuned:
- boots_brand_name_accuracy: closed-world list semantics. Brands not on
  the approved list now go to names_not_on_list (manual review) instead
  of failing — the list is sourced from the original 7 docs and is known
  incomplete (Remington, Imodium, Maybelline etc. are legitimate Boots-
  stocked brands not on it).
- boots_tandc_wording: explicit font-weight caveat — Boots Sharp Regular
  vs Light isn't reliably distinguishable by vision LLM at small sizes.
  Surfaced via font_weight_caveat field + needs_manual_check value.

Page classifier (document_mode/page_classifier.py):
Heuristic tags each page as cover / checklist / palette / notes /
artwork. Validated on all 10 sample packs.

Strict-grade exemption (Profile.strict_grade flag):
Only artwork-classified pages count towards Pass/Fail. Cover, checklist,
palette, and notes pages are still QC'd and reported as Informational
but cannot trigger a Fail. Banner shows exactly which artwork-page
checks fell below 6.

Result writer extended:
- Per-page table with score + page_type pill for any page_each-scope
  check (auto-applied as fallback)
- Strict-grade banner (red on violation, green when clean)
- Page_type pills throughout the per-page strip

Smoke-test result (Remington 4-page pack, 2026-05-05):
Overall 70.75/100, strict-grade Fail. After two iterations of prompt
tuning, all three remaining strict-grade violations are real catches:
orphan asterisk in T&Cs, "they may not be stocked" wording deviation,
missing "Charges may apply". brand_name_accuracy 7.0 (was 3.0 before
list fix), logo_compliance 9.5 (was 1.5 before lock-up path fix).

Local-only — not pushed to dev or merged to develop until after Boots
show-and-tell. Same posture as feature/axa-document-mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:47:13 +02:00

141 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""
Client configuration module for managing client-profile relationships
"""
CLIENT_PROFILES = {
'diageo': {
'name': 'Diageo',
'profiles': ['diageo_key_visual', 'diageo_packaging', 'static_general', 'video_general'],
'display_name': 'Diageo',
'description': 'Diageo brand profiles for key visuals and packaging'
},
'unilever': {
'name': 'Unilever',
'profiles': ['unilever_key_visual', 'unilever_packaging', 'static_general', 'video_general'],
'display_name': 'Unilever',
'description': 'Unilever brand profiles for key visuals and packaging'
},
'loreal': {
'name': "L'Oreal",
'profiles': ['loreal_static', 'static_general', 'video_general'],
'display_name': "L'Oreal",
'description': "L'Oreal brand profiles with focused and comprehensive static QC checks"
},
'amazon': {
'name': 'Amazon',
'profiles': ['amazon_static', 'static_general', 'video_general'],
'display_name': 'Amazon',
'description': 'Amazon brand profiles with design guideline QC checks'
},
'boots': {
'name': 'Boots',
'profiles': ['boots_static', 'boots_ppack', 'static_general', 'video_general'],
'display_name': 'Boots',
'description': 'Boots retail promotional artwork compliance checks'
},
'dow_jones': {
'name': 'Dow Jones',
'profiles': ['dow_jones_static', 'marketwatch_static', 'wsj_static', 'wsj_podcast', 'static_general', 'video_general'],
'display_name': 'Dow Jones',
'description': 'Dow Jones brand profiles for corporate, MarketWatch, and WSJ sub-brands'
},
'honda': {
'name': 'Honda',
'profiles': ['static_general', 'video_general'],
'display_name': 'Honda',
'description': 'Honda brand profiles for automotive marketing QC checks'
},
'axa': {
'name': 'AXA',
'profiles': ['axa_policy_document', 'axa_policy_document_diff', 'static_general', 'video_general'],
'display_name': 'AXA',
'description': 'AXA brand profiles, including multi-page policy document QC for AXA Ireland'
},
'rank': {
'name': 'Rank',
'profiles': ['static_general', 'video_general'],
'display_name': 'Rank',
'description': 'Rank brand profiles for marketing QC checks'
},
'general': {
'name': 'General',
'profiles': ['static_general', 'video_general', 'inclusive_accessibility'],
'display_name': 'General / Other',
'description': 'General purpose profiles and accessibility checks'
}
}
def get_client_profiles(client_id):
"""Get profiles available for a specific client"""
return CLIENT_PROFILES.get(client_id, {}).get('profiles', [])
def get_all_clients():
"""Get all available clients"""
return CLIENT_PROFILES
def validate_client_profile(client_id, profile_id):
"""Validate that a profile belongs to a client"""
client_profiles = get_client_profiles(client_id)
return profile_id in client_profiles
def get_profiles_with_visibility(client_id):
"""
Get all profiles available to a client considering visibility settings
Args:
client_id: Client ID to get profiles for
Returns:
List of profile IDs available to this client
"""
import os
import json
import glob
available_profiles = []
# First, get the profiles assigned to this client in CLIENT_PROFILES
# This is our baseline for backward compatibility
baseline_profiles = get_client_profiles(client_id)
# Get profiles directory
profiles_dir = os.path.join(os.path.dirname(__file__), 'profiles')
profile_files = glob.glob(os.path.join(profiles_dir, '*.json'))
for profile_file in profile_files:
try:
with open(profile_file, 'r') as f:
profile_data = json.load(f)
profile_id = os.path.basename(profile_file).replace('.json', '')
# Check if profile has visibility settings
has_visibility_settings = 'visibility' in profile_data
if has_visibility_settings:
# Use new visibility system if configured
visibility = profile_data.get('visibility', 'all')
visible_to_clients = profile_data.get('visible_to_clients', [])
if visibility == 'all':
available_profiles.append(profile_id)
elif visibility == 'client_specific' and client_id in visible_to_clients:
available_profiles.append(profile_id)
else:
# Fall back to CLIENT_PROFILES mapping for backward compatibility
if profile_id in baseline_profiles:
available_profiles.append(profile_id)
except (json.JSONDecodeError, FileNotFoundError):
continue
return available_profiles
# Admin membership now lives in backend/user_access.json.
# Kept as a thin shim so any older callers keep working.
def is_admin(email):
"""Deprecated — delegates to user_access.is_admin()."""
from user_access import is_admin as _is_admin
return _is_admin(email)