ai_qc/backend/document_mode/dispatcher.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

438 lines
18 KiB
Python

"""Document-mode dispatcher.
Scope-aware routing. Each check declares its scope in the profile JSON; the
dispatcher then runs:
• document → checks.py registry function over the full ingest result
• targeted → same registry function, but with scope_args.pages resolved
to specific page numbers (e.g. "last", "first", [1,2])
• page_sample → existing batch dispatcher on N evenly-spaced page images
• page_each → existing batch dispatcher on every page image (Phase-1
legacy; unused by AXA profile after refactor)
• page_pair → reserved for Phase 3 old-vs-new diff (not yet implemented)
Document-scope checks bypass the LLM pipeline entirely (deterministic, $0).
Page-level checks plug into `process_checks_in_batches()` exactly as before.
"""
from datetime import datetime
from typing import Callable, Dict, List, Optional
from . import checks as doc_checks
def _grade(overall_score: float) -> str:
"""Same Pass/Fail rule as single-asset mode: avg per-check ≥ 6 = Pass."""
avg_individual = overall_score / 10
return 'Pass' if avg_individual >= 6 else 'Fail'
def _score_class(score: float) -> str:
if score >= 8:
return 'good'
if score >= 6:
return 'ok'
return 'bad'
def _resolve_scope(profile_config, check_name: str) -> tuple:
"""Return (scope, scope_args) for a check. Falls back to the registry's
declared scope, then to 'page_each' for legacy compat. Profile-level
scope/scope_args overrides whatever the registry declares.
"""
cfg = profile_config.checks.get(check_name)
if cfg and cfg.scope:
return cfg.scope, cfg.scope_args or {}
registry_entry = doc_checks.get_check(check_name)
if registry_entry:
return registry_entry['scope'], (cfg.scope_args if cfg else None) or {}
# Legacy: existing image-based checks default to running on every page
return 'page_each', {}
def _run_document_scope(check_name: str, ingest_result: Dict, scope_args: Dict) -> Dict:
"""Invoke a registered document-scope check function."""
entry = doc_checks.get_check(check_name)
if not entry:
return {
'check_name': check_name,
'scope': 'document',
'score': 0.0,
'pass': False,
'summary': f"Unknown document-scope check '{check_name}'.",
'findings': {},
'response': '',
}
try:
return entry['fn'](ingest_result, scope_args)
except Exception as e:
return {
'check_name': check_name,
'scope': 'document',
'score': 0.0,
'pass': False,
'summary': f"Check raised {type(e).__name__}: {e}",
'findings': {'error': str(e)},
'response': str(e),
}
def _evenly_spaced(total: int, n: int) -> List[int]:
"""Return n 1-indexed page numbers evenly spaced across [1, total]."""
if total <= 0 or n <= 0:
return []
if n >= total:
return list(range(1, total + 1))
step = total / n
return sorted({int(round(i * step)) + 1 for i in range(n)} & set(range(1, total + 1)))
def run_document_analysis(
*,
pdf_path: str,
profile_config,
profile_id: str,
profile_weights: Dict[str, float],
enabled_checks: List[str],
qc_apps: Dict,
brand_db,
analysis_reference_asset: Optional[str],
media_plan_context: Optional[str],
ocr_context: Optional[str],
progress_tracker: Dict,
session_id: str,
process_checks_in_batches: Callable,
ingest_pdf_fn: Callable,
pages_output_dir: str,
page_limit: int = 200,
) -> Dict:
"""Run scope-aware document-mode QC. See module docstring for routing."""
# ── Stage 1: ingest ──────────────────────────────────────────────────
progress_tracker[session_id].update({
'stage': 'ingesting_pdf',
'percentage': 2,
'current_check_display': 'Rendering PDF pages...',
})
def _ingest_progress(page_num, total):
pct = 2 + (page_num / total) * 8
progress_tracker[session_id].update({
'percentage': pct,
'current_check_display': f'Rendering page {page_num} of {total}',
})
ingest_result = ingest_pdf_fn(
pdf_path,
pages_output_dir,
page_limit=page_limit,
progress_callback=_ingest_progress,
)
pages = ingest_result['pages']
pages_processed = ingest_result['pages_processed']
page_count = ingest_result['page_count']
truncated = ingest_result['truncated']
if pages_processed == 0:
return {
'mode': 'document',
'profile_id': profile_id,
'profile_name': profile_config.name,
'page_count': page_count,
'pages_processed': 0,
'truncated': truncated,
'pages': [],
'check_results': {},
'document_summary': {
'overall_score': 0,
'grade': 'Fail',
'check_summaries': {},
'error': 'No pages could be rendered from the supplied PDF.',
},
'ingest_metadata': {'fonts_inventory': []},
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
}
# ── Stage 2: classify each enabled check by scope ─────────────────────
scope_buckets: Dict[str, List[tuple]] = {
'document': [], 'targeted': [], 'page_sample': [], 'page_each': [], 'page_pair': [],
}
for check_name in enabled_checks:
scope, scope_args = _resolve_scope(profile_config, check_name)
scope_buckets.setdefault(scope, []).append((check_name, scope_args))
progress_tracker[session_id].update({
'stage': 'analysing',
'percentage': 12,
'total_checks': len(enabled_checks),
'completed_checks': 0,
'total_pages': pages_processed,
'current_page': 0,
})
# ── Stage 3a: document-scope checks (deterministic, fast) ──────────────
check_results: Dict[str, Dict] = {}
completed = 0
for check_name, scope_args in scope_buckets['document']:
progress_tracker[session_id].update({
'current_check_display': f'Running {check_name}...',
})
check_results[check_name] = _run_document_scope(check_name, ingest_result, scope_args)
completed += 1
progress_tracker[session_id].update({
'completed_checks': completed,
'percentage': 12 + (completed / len(enabled_checks)) * 80,
})
# ── Stage 3b: targeted checks (specific pages) ────────────────────────
for check_name, scope_args in scope_buckets['targeted']:
progress_tracker[session_id].update({
'current_check_display': f'Running {check_name} on targeted pages...',
})
check_results[check_name] = _run_document_scope(check_name, ingest_result, scope_args)
completed += 1
progress_tracker[session_id].update({
'completed_checks': completed,
'percentage': 12 + (completed / len(enabled_checks)) * 80,
})
# ── Stage 3c: page-sample (LLM, sampled pages) ────────────────────────
page_level_results: Dict[str, Dict[int, Dict]] = {} # check → page_num → result
sample_buckets = scope_buckets['page_sample']
if sample_buckets:
for check_name, scope_args in sample_buckets:
n = (scope_args or {}).get('sample_size', 8)
page_nums = _evenly_spaced(pages_processed, n)
page_level_results[check_name] = {}
for page_num in page_nums:
page = pages[page_num - 1]
if not page.get('image_path'):
continue
progress_tracker[session_id].update({
'current_check_display': f'{check_name} on page {page_num} (sample)',
'current_page': page_num,
})
page_check_results = process_checks_in_batches(
enabled_checks=[check_name],
qc_apps=qc_apps,
profile_config=profile_config,
profile_weights=profile_weights,
file_path=page['image_path'],
analysis_reference_asset=analysis_reference_asset,
brand_db=brand_db,
progress_tracker=progress_tracker,
session_id=session_id,
batch_size=15,
media_plan_context=media_plan_context,
ocr_context=ocr_context,
)
page_level_results[check_name][page_num] = page_check_results.get(check_name, {})
# Aggregate the sampled results into the doc-level entry
page_scores = {p: (r.get('score') or 0) for p, r in page_level_results[check_name].items()}
scores = list(page_scores.values())
avg = round(sum(scores) / len(scores), 2) if scores else 0.0
check_results[check_name] = {
'check_name': check_name,
'scope': 'page_sample',
'score': avg,
'pass': avg >= 6,
'summary': f'{check_name} sampled across {len(scores)} pages: avg {avg}, min {min(scores) if scores else 0}, max {max(scores) if scores else 0}',
'findings': {
'pages_sampled': sorted(page_scores.keys()),
'page_scores': page_scores,
'failing_pages': sorted([p for p, s in page_scores.items() if s < 6]),
},
'response': '\n'.join(
f"Page {p}: score {s}\n{(page_level_results[check_name][p].get('response') or '')[:1500]}"
for p, s in page_scores.items()
),
}
completed += 1
progress_tracker[session_id].update({
'completed_checks': completed,
'percentage': 12 + (completed / len(enabled_checks)) * 80,
})
# ── Stage 3d: page_each — run check on every page in the document ──────
# Page-type-aware: results from non-artwork pages (cover/checklist/palette/
# notes) are surfaced for visibility but excluded from the per-check
# average that drives the headline score & grade. This implements the
# strict-grade exemption requested for Boots Production Packs without
# changing AXA-style profiles (which don't tag pages → all pages count).
page_type_map = {p['page_num']: p.get('page_type', 'artwork') for p in pages}
artwork_page_nums = {pn for pn, pt in page_type_map.items() if pt == 'artwork'}
if scope_buckets['page_each']:
for check_name, _scope_args in scope_buckets['page_each']:
page_level_results.setdefault(check_name, {})
for page in pages:
page_num = page['page_num']
if not page.get('image_path'):
continue
progress_tracker[session_id].update({
'current_check_display': f'{check_name} on page {page_num}',
'current_page': page_num,
})
page_check_results = process_checks_in_batches(
enabled_checks=[check_name],
qc_apps=qc_apps,
profile_config=profile_config,
profile_weights=profile_weights,
file_path=page['image_path'],
analysis_reference_asset=analysis_reference_asset,
brand_db=brand_db,
progress_tracker=progress_tracker,
session_id=session_id,
batch_size=15,
media_plan_context=media_plan_context,
ocr_context=ocr_context,
)
result_for_page = page_check_results.get(check_name, {})
# Tag the per-page result with its page_type so the report
# writer can group results by page category.
result_for_page['page_type'] = page_type_map.get(page_num, 'artwork')
page_level_results[check_name][page_num] = result_for_page
page_scores = {p: (r.get('score') or 0) for p, r in page_level_results[check_name].items()}
artwork_scores = {p: s for p, s in page_scores.items() if p in artwork_page_nums}
non_artwork_scores = {p: s for p, s in page_scores.items() if p not in artwork_page_nums}
# Headline score = average of artwork-page scores. If a profile
# has no artwork pages at all (extreme edge case), fall back to
# all pages so we don't return a 0-score Fail.
scoring_pool = artwork_scores if artwork_scores else page_scores
scores = list(scoring_pool.values())
avg = round(sum(scores) / len(scores), 2) if scores else 0.0
# Per-page response excerpts are captured in findings so the
# report renderer can show a per-page card without needing access
# to the doc-level page_level_results dict.
page_responses = {
p: ((page_level_results[check_name][p].get('response') or '')[:1500])
for p in page_scores.keys()
}
check_results[check_name] = {
'check_name': check_name,
'scope': 'page_each',
'score': avg,
'pass': avg >= 6,
'summary': (
f'{check_name} ran on {len(page_scores)} pages '
f'({len(artwork_scores)} artwork, {len(non_artwork_scores)} informational). '
f'Artwork avg {avg}.'
),
'findings': {
'page_scores': page_scores,
'artwork_page_scores': artwork_scores,
'informational_page_scores': non_artwork_scores,
'failing_artwork_pages': sorted([p for p, s in artwork_scores.items() if s < 6]),
'page_types': page_type_map,
'page_responses': page_responses,
},
'response': '\n'.join(
f"Page {p} [{page_type_map.get(p, 'artwork')}]: {s}\n"
f"{page_responses[p]}"
for p, s in sorted(page_scores.items())
),
}
completed += 1
progress_tracker[session_id].update({
'completed_checks': completed,
'percentage': 12 + (completed / len(enabled_checks)) * 80,
})
# ── Stage 4: aggregate document score ─────────────────────────────────
progress_tracker[session_id].update({
'stage': 'aggregating',
'percentage': 96,
'current_check_display': 'Aggregating findings...',
})
total_weighted = 0.0
total_weight = 0.0
check_summaries = {}
for check_name in enabled_checks:
weight = profile_weights.get(check_name, 1.0)
result = check_results.get(check_name) or {'score': 0.0}
total_weighted += result['score'] * weight
total_weight += weight
check_summaries[check_name] = {
'score': result.get('score', 0),
'pass': result.get('pass', False),
'scope': result.get('scope', 'unknown'),
'summary': result.get('summary', ''),
'weight': weight,
'findings': result.get('findings', {}),
}
if total_weight >= 10.0:
overall_score = round(min(total_weighted, 100), 2)
elif total_weight > 0:
overall_score = round(min((total_weighted / total_weight) * 10, 100), 2)
else:
overall_score = 0.0
# ── Strict-grade override (artwork pages only) ─────────────────────────
# Profiles with strict_grade=True (e.g. Boots PPack) Fail if ANY check
# scored <6 on ANY artwork page. Cover/checklist/palette/notes pages do
# not contribute to this check. AXA-style profiles leave strict_grade
# off and behave as before.
strict_grade = bool(getattr(profile_config, 'strict_grade', False))
strict_violations = []
if strict_grade:
for check_name, per_page in page_level_results.items():
for page_num, page_result in per_page.items():
if page_result.get('page_type', 'artwork') != 'artwork':
continue
page_score = page_result.get('score') or 0
if page_score < 6:
strict_violations.append({
'check': check_name,
'page': page_num,
'score': page_score,
})
if strict_grade and strict_violations:
grade = 'Fail'
else:
grade = _grade(overall_score)
fonts_inventory = sorted({
font for page in pages for font in (page.get('fonts_used') or [])
})
return {
'mode': 'document',
'profile_id': profile_id,
'profile_name': profile_config.name,
'page_count': page_count,
'pages_processed': pages_processed,
'truncated': truncated,
'pages': [
{
'page_num': p['page_num'],
'page_type': p.get('page_type', 'artwork'),
'fonts_used': p.get('fonts_used', []),
'image_path': p.get('image_path'),
}
for p in pages
],
'check_results': check_results,
'page_level_results': page_level_results,
'document_summary': {
'overall_score': overall_score,
'grade': grade,
'check_summaries': check_summaries,
'total_weight': total_weight,
'strict_grade': strict_grade,
'strict_violations': strict_violations,
},
'ingest_metadata': {
'fonts_inventory': fonts_inventory,
},
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
}