"""Tier 3 QA verification: hand-compute readiness per demo persona and compare to `compute_gap_report`. Run via: ./venv/Scripts/python.exe manage.py shell < scripts/qa_verify_personas.py """ import os, sys, django # noqa os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') django.setup() from apps.accounts.models import User from apps.roles.models import UserTargetRole, RoleSkill from apps.skills.models import UserSkill from apps.analysis.services import compute_gap_report, LEVEL_THRESHOLDS, MANDATORY_CAP def hand_compute(user, role): """Independent re-derivation from the design doc §Module 4.""" role_skills = list(RoleSkill.objects.filter(role=role).select_related('skill')) if not role_skills: return {'readiness': 100.0, 'no_requirements': True, 'cap': False} user_prof = { us.skill_id: us.proficiency for us in UserSkill.objects.filter(user=user) } sat_sum = 0.0 weight_sum = 0.0 mandatory_short = False gap_rows = [] for rs in role_skills: T = LEVEL_THRESHOLDS[rs.required_level] U = user_prof.get(rs.skill_id, 0) S = min(U / T, 1.0) sat_sum += S * rs.weight weight_sum += rs.weight if rs.is_mandatory and S < 1.0: mandatory_short = True # severity gap = max(T - U, 0) if U >= T: gt, sev = 'MET', None elif U == 0: gt, sev = 'MISSING', ('CRITICAL' if rs.is_mandatory else 'MEDIUM') else: ratio = gap / T if rs.is_mandatory or ratio >= 0.5: sev = 'HIGH' elif ratio >= 0.25: sev = 'MEDIUM' else: sev = 'LOW' gt = 'INSUFFICIENT' gap_rows.append((rs.skill.skill_name, rs.required_level, U, S, rs.weight, rs.is_mandatory, gt, sev)) if weight_sum == 0: return {'readiness': 100.0, 'no_requirements': True, 'cap': False, 'raw': None, 'gaps': gap_rows} raw = sat_sum / weight_sum * 100.0 cap = mandatory_short and raw > MANDATORY_CAP final = MANDATORY_CAP if cap else raw return {'readiness': final, 'no_requirements': False, 'cap': cap, 'raw': raw, 'gaps': gap_rows} DEMO_EMAILS = [ 'demo.weak@gapguide.test', 'demo.partial@gapguide.test', 'demo.ready@gapguide.test', 'demo.closedloop@gapguide.test', ] mismatches = [] for email in DEMO_EMAILS: try: u = User.objects.get(email=email) except User.DoesNotExist: print(f'[MISS] {email} not in DB') continue target = UserTargetRole.objects.filter(user=u, is_active=True).select_related('role').first() if not target: print(f'[MISS] {email} has no active target') continue role = target.role expected = hand_compute(u, role) actual = compute_gap_report(u, role) exp_r = round(expected['readiness'], 2) act_r = round(actual.readiness, 2) ok = abs(exp_r - act_r) <= 0.1 and expected['cap'] == actual.mandatory_cap_applied print(f'\n=== {email} -> {role.role_name} ===') print(f' expected readiness: {exp_r} (raw={expected["raw"]}, cap={expected["cap"]})') print(f' actual readiness: {act_r} (band={actual.band}, cap={actual.mandatory_cap_applied})') print(f' match: {ok}') if not ok: mismatches.append((email, expected, actual)) # Also spot-check one severity vs hand-computed actual_severities = {(g.skill_name, g.severity) for g in actual.gaps} expected_severities = {(row[0], row[7]) for row in expected['gaps']} diff = actual_severities ^ expected_severities if diff: print(f' severity diff: {diff}') if mismatches: print(f'\n*** {len(mismatches)} persona readiness mismatches ***') sys.exit(1) else: print('\n*** All persona readiness values match hand computation ***')