Compare commits
No commits in common. "main" and "feature/box-oauth" have entirely different histories.
main
...
feature/bo
87 changed files with 2128 additions and 11043 deletions
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -74,18 +74,3 @@ backend/media_plans/
|
|||
backend/usage_logs/
|
||||
backend/user_access.json
|
||||
backend/box_tokens.json
|
||||
backend/client_defaults.json
|
||||
backend/config/development.env
|
||||
backend/config/production.env
|
||||
backend/config/box_jwt_config.json
|
||||
|
||||
# Legacy env paths (pre-config/ refactor) still in use on older deploys.
|
||||
# Untracked 2026-05-17 — git reset --hard during deploys was overwriting
|
||||
# rotated secrets with the historical (compromised) values.
|
||||
config.env
|
||||
backend/config.env
|
||||
config/development.env
|
||||
config/production.env
|
||||
|
||||
# Local test fixtures (real HP Source Messaging files; not for commit)
|
||||
backend/tests/fixtures/
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
# AXA Client Documentation
|
||||
|
||||
> Referenced from main CLAUDE.md. Detailed AXA QC profile descriptions, document-mode pipeline notes, and status.
|
||||
|
||||
## Overview
|
||||
|
||||
AXA QC is built around **document-mode** — multi-page PDF analysis (policy documents, forms, brochures), not single-asset image checks. The document-mode subsystem (`backend/document_mode/`) was built for AXA and is now reused by Boots Production Pack.
|
||||
|
||||
**Status (2026-05-10):** Phases 1, 3, 4, 5, 6 merged to `develop` and live on dev (`https://optical-dev.oliver.solutions/ai_qc/`). Phase 6 wires veraPDF into the accessibility check (PAC-equivalent PDF/UA-1 validation) and splits accessibility into its own dedicated profile. Email to AXA pending — explains Adobe vs PAC + veraPDF parity findings + requests the original `axa-transaction-charges-100326.pdf` so we can run a true apples-to-apples comparison. Not yet on prod — held for AXA show-and-tell + email response. Full plan in `backend/AXA_DOCUMENT_MODE_PLAN.md`.
|
||||
|
||||
## AXA Profiles
|
||||
|
||||
### `axa_policy_document` — single-document mode (7 checks)
|
||||
|
||||
Multi-page policy document QC. `mode: document`, scopes vary per check. Accessibility validation lives in the dedicated `axa_accessibility` profile, not here.
|
||||
|
||||
| Check | What it does | Weight |
|
||||
|------|--------------|--------|
|
||||
| `axa_font_inventory` | Per-page font extraction + brand-font compliance against AXA's approved font list | 1.0 |
|
||||
| `axa_phone_inventory` | Extracts phone numbers across pages, validates format and approved-list membership | 1.0 |
|
||||
| `axa_bold_words_definitions` | Bold-word inventory + definition cross-check (seed list at `backend/document_mode/data/axa_bold_words_seed.json`) | 2.0 |
|
||||
| `axa_page_numbering` | Page numbering format and continuity | 1.0 |
|
||||
| `axa_print_preflight` | Print-preflight checks (color space, embedded fonts, image resolution) | 1.0 |
|
||||
| `axa_print_code` | Print code presence + format | 1.0 |
|
||||
| `axa_omg_versioning` | OMG version footer/header presence and consistency | 1.0 |
|
||||
|
||||
### `axa_accessibility` — accessibility-only mode (1 check, strict-grade)
|
||||
|
||||
`mode: document`, `strict_grade: true`. Standalone PDF/UA-1 validation for users who only need to check accessibility compliance without the full policy-document content suite. Mirrors how axes4 PAC is used — single-purpose, binary verdict.
|
||||
|
||||
| Check | What it does | Weight |
|
||||
|------|--------------|--------|
|
||||
| `axa_pdf_accessibility` | PDF/UA-1 validation via veraPDF (matches axes4 PAC), with deterministic PyMuPDF fallback if veraPDF is not installed | 1.0 |
|
||||
|
||||
### `axa_policy_document_diff` — old-vs-new diff mode (1 check)
|
||||
|
||||
`mode: document_diff` — compares two PDFs (old vs new policy version) and reports structured changes.
|
||||
|
||||
| Check | What it does | Weight |
|
||||
|------|--------------|--------|
|
||||
| `axa_pdf_diff` | Detects added/removed/modified pages, paragraphs, defined terms, phone numbers | 1.0 |
|
||||
|
||||
## Document-mode infrastructure
|
||||
|
||||
AXA's document-mode subsystem is the foundation for all multi-page PDF QC in this app:
|
||||
- `document_mode/ingest.py` — PDF ingestion, page rendering, span/font/color extraction via PyMuPDF
|
||||
- `document_mode/dispatcher.py` — Orchestrates per-check execution against pages, supports scopes: `document` / `targeted` / `page_sample` / `page_pair` / `page_each`
|
||||
- `document_mode/checks.py`, `print_preflight_checks.py`, `accessibility_checks.py` — AXA check implementations
|
||||
- `document_mode/diff_engine.py`, `diff_report_writer.py` — Old-vs-new diff handling
|
||||
- `document_mode/result_writer.py` — HTML report rendering with per-page sections
|
||||
|
||||
Boots Production Pack reuses this entire spine — so any infra changes here affect both clients.
|
||||
|
||||
## AI usage across AXA tools
|
||||
|
||||
For client-facing context: **8 of 9 AXA tools are deterministic** (no LLM, $0 cost, runs in seconds). Only `axa_pdf_diff` uses AI — Gemini 2.5 Pro vision-LLM page-pair comparison at ~$0.40-0.80 per pair. The accessibility check uses veraPDF, which is a rule-based open-source PDF/UA-1 validator — not AI. This framing matters when clients conflate "automation" with "AI".
|
||||
|
||||
| Tool | Type | Engine |
|
||||
|---|---|---|
|
||||
| `axa_font_inventory`, `axa_phone_inventory`, `axa_bold_words_definitions`, `axa_page_numbering`, `axa_print_code`, `axa_omg_versioning` | Deterministic | PyMuPDF (text + font extraction, regex) |
|
||||
| `axa_print_preflight` | Deterministic | PyMuPDF (page geometry, image colour spaces, DPI, transparency, PDF/X) |
|
||||
| `axa_pdf_accessibility` | Deterministic (rule-based) | veraPDF subprocess (PDF/UA-1 / Matterhorn Protocol) + PyMuPDF fallback |
|
||||
| `axa_pdf_diff` | **AI** | Gemini 2.5 Pro vision-LLM, page-pair diff |
|
||||
|
||||
## Open items
|
||||
|
||||
- AXA show-and-tell pending — feedback will drive the next round of tuning
|
||||
- Awaiting `axa-transaction-charges-100326.pdf` from AXA (the file PAC was run against) — needed to fully confirm veraPDF↔PAC parity on the Structure Elements rule bucket
|
||||
- Phase 2 (any further check expansion) deferred until after show-and-tell
|
||||
- Canonical AXA font list / approved phone list / OMG version reference data may need expansion as test PDFs surface gaps
|
||||
- Prod deployment of veraPDF + `axa_accessibility` profile — held until AXA confirms findings on dev
|
||||
|
||||
## veraPDF deployment
|
||||
|
||||
`axa_pdf_accessibility` runs the **veraPDF** PDF/UA-1 validator as a subprocess when the binary is available. veraPDF implements the Matterhorn Protocol — the same rule set axes4 PAC uses — so its verdict is the closest open-source equivalent to PAC.
|
||||
|
||||
Binary resolution order (in `accessibility_checks._resolve_verapdf_binary`):
|
||||
1. `VERAPDF_BIN` env var
|
||||
2. `verapdf` on PATH
|
||||
3. `/opt/ai_qc/vendor/verapdf/verapdf` (project-local production install)
|
||||
|
||||
If veraPDF isn't installed the check falls back to the 9-criterion deterministic PyMuPDF layer — no breakage, just less depth. **Production install pattern** is a project-local bundled-JRE tarball under `/opt/ai_qc/vendor/verapdf/` to avoid touching system Java or other projects on shared servers.
|
||||
|
||||
## Key files
|
||||
|
||||
- `backend/AXA_DOCUMENT_MODE_PLAN.md` — full design plan and phase breakdown
|
||||
- `backend/document_mode/` — pipeline implementation
|
||||
- `backend/profiles/axa_policy_document.json`, `axa_accessibility.json`, `axa_policy_document_diff.json`
|
||||
- `backend/document_mode/data/axa_bold_words_seed.json` — bold-word seed list
|
||||
|
|
@ -13,55 +13,15 @@ Boots is a retail client with promotional artwork compliance checks. Unlike othe
|
|||
|
||||
## Boots QC Tools
|
||||
|
||||
Seven checks for Boots retail promotional artwork compliance. Profiles: `boots_static` (single-asset, 5 checks) and `boots_ppack` (multi-page production-pack document mode, all 7 checks).
|
||||
Five checks for Boots retail promotional artwork compliance. All checks use equal weight (2.0 each, 100-point scale). Profile: `boots_static`.
|
||||
|
||||
| Tool | Source Document(s) | What it checks |
|
||||
|------|-------------------|----------------|
|
||||
| `boots_caveat_compliance` | ASTERISK RULES | Caveat ordering (* -> dagger -> ** -> double dagger -> triangle -> clover), sizing per context (1/3 headline, 1/2 sub-headline, same as body), **NO SUPERSCRIPT** (critical), font weight matching, plus orphan-asterisk detection (smoke test caught a `*` in T&Cs with no matching marker in main copy) |
|
||||
| `boots_brand_name_accuracy` | BRAND NAMES (3 pages) | Exact spelling of ~170 brand + product names including accents, apostrophes, hyphens, casing. Closed-world list — brands not on the list are surfaced in `names_not_on_list` for manual review and DO NOT cause a Fail; only spelling errors against listed brands fail. |
|
||||
| `boots_caveat_compliance` | ASTERISK RULES | Caveat ordering (* -> dagger -> ** -> double dagger -> triangle -> clover), sizing per context (1/3 headline, 1/2 sub-headline, same as body), **NO SUPERSCRIPT** (critical), font weight matching |
|
||||
| `boots_brand_name_accuracy` | BRAND NAMES (3 pages) | Exact spelling of ~170 brand + product names including accents, apostrophes, hyphens, casing. Full approved list embedded in prompt |
|
||||
| `boots_offer_mechanics` | OFFER ROUNDELS + VALUE MECHANICS | Offer roundel format matches approved categories (price reductions, multibuys, threshold spend, FREE/GWP, points), spaced-caps styling, "Our best..." approved phrases |
|
||||
| `boots_tandc_wording` | OFFER T&Cs + CLICK AND COLLECT + LOCK-UP T&Cs | Standard offer T&C wording (3FOR2, BOGOF, etc.), C&C exact text + font weight + hierarchy, lock-up T&Cs (Advantage Card, Parenting Club, Price Advantage, Pyramid), offer date formatting. Font weight is best-effort — flagged via `font_weight_caveat` field for manual verification. |
|
||||
| `boots_tandc_wording` | OFFER T&Cs + CLICK AND COLLECT + LOCK-UP T&Cs | Standard offer T&C wording (3FOR2, BOGOF, etc.), C&C exact text + font weight + hierarchy, lock-up T&Cs (Advantage Card, Parenting Club, Price Advantage, Pyramid), offer date formatting |
|
||||
| `boots_currency_locale` | Agent prompt cross-cutting rules | Currency: GBP for UK / EUR for ROI, URLs: boots.com / boots.ie, consistent locale throughout asset |
|
||||
| `boots_logo_compliance` | Built from PPack observation (no formal Boots logo guideline supplied) | Three-path scoring: A) **master wordmark** (strict — typeface, colour, orientation, distortion, clear space), B) **partner / production lock-up** (lenient — "OLIVER x BOOTS" footers etc. follow lock-up conventions, NOT master wordmark rules), C) **no Boots branding** (N/A neutral). |
|
||||
| `boots_colour_palette` | Boots canonical palette derived from creative-guidance pages | Two modes: A) **creative-guidance pages** verify CMYK/RGB/Hex spec values match Boots Blue (#05054b), Health Primary Blue (#5dc4e9), Offer Red (#d3072a); B) **artwork pages** sanity-check dominant brand colours visually. |
|
||||
|
||||
## Boots Production Pack (`boots_ppack`) profile — multi-page document mode
|
||||
|
||||
For multi-page production packs (4-18 pages each, exported from PowerPoint as PDF). Built on top of AXA's document-mode infrastructure; all 7 checks run at `scope: page_each` with strict-grade override.
|
||||
|
||||
**Page classifier** (`backend/document_mode/page_classifier.py`): heuristic tags every page as `cover` / `checklist` / `palette` / `notes` / `artwork`. Decision order:
|
||||
1. Strong palette (≥3 of CMYK/RGB/Hexadecimal headings + ≥2 hex colours) → palette
|
||||
2. Strong checklist (≥3 of "Asset suitable", "Fonts present", "Resolution fine", etc.) → checklist
|
||||
3. Artwork signals (T&Cs, offer mechanics, prices, GSL barcode) → artwork
|
||||
4. Yellow Notes / Client Queries with no artwork signals → notes
|
||||
5. Sparse Production Pack title block → cover (doubles as brief / context page)
|
||||
6. Default → artwork (fail-safe: false positives on artwork are recoverable)
|
||||
|
||||
**Strict-grade exemption** (`Profile.strict_grade=True` in `profile_config.py`): only artwork-classified pages count towards Pass/Fail. Cover, checklist, palette, and notes pages are scored and surfaced in the report as **informational** but cannot trigger a Fail. The strict-grade banner in the HTML report lists exactly which artwork-page checks fell below 6.
|
||||
|
||||
**Cost per pack:** 7 checks × pages = roughly £0.05-0.30 per pack. 4-page packs ~£0.10, 18-page packs ~£0.30.
|
||||
|
||||
**Smoke-test results (2026-05-05):** all three test packs Fail by strict-grade — but the remaining violations are genuine compliance issues, not noise. Across three rounds of prompt tuning, Easter Overlay (the noisiest 18-page pack) climbed from 72.38 → 78.97 → **80.04**. Strict-grade violations dropped from 27 → 18 → 14 across 10 pages.
|
||||
|
||||
| Pack | Pages | Final overall | Strict-grade violations |
|
||||
|---|---|---|---|
|
||||
| Remington (1.8MB, 4 pages) | 4 | 70.75 | 3 (orphan asterisk, T&C wording deviations) |
|
||||
| Easter Overlay (3MB, 18 pages) | 18 | 80.04 | 14 (real catches across brand_name / T&C / offer_mechanics / currency_locale) |
|
||||
| Grenade (5.9MB, 7 pages) | 7 | 78.0 | 3 (caveat orphan, meal-deal format) |
|
||||
|
||||
The strongest real catch in the dataset: Easter Overlay page 14 is labelled for the ROI market in production notes but uses £ instead of € on the artwork — caught by `boots_currency_locale`. That's exactly the kind of pre-press error worth surfacing.
|
||||
|
||||
**Vision-LLM limitations explicitly handled in prompts** (so the Boots team understands what's reliable vs best-effort):
|
||||
- Font weight (Boots Sharp Regular vs Light) at small sizes — surfaced via `font_weight_caveat` (T&C check) and `weight_caveat` (caveat check)
|
||||
- Asterisk superscript at small sizes — surfaced via `superscript_caveat` (asterisk glyph naturally sits high; only flag when raised AND shrunk)
|
||||
- Caveat size comparison at small sizes — surfaced via `sizing_caveat` (1-2pt differences below detection threshold)
|
||||
- Subtle accent marks on brand names — `accent_marks_verifiable` flag
|
||||
|
||||
**Tuning patterns that worked** (worth knowing for future client onboards):
|
||||
- "Closed-world list" semantics — when an approved-list reference is incomplete (third-party brands, font lists, etc.), absence from list ≠ failure. Surface for manual review at neutral 7/10, flag misspellings of listed items as Fail.
|
||||
- "ALL CAPS retail convention" exception — brand names rendered in caps (L'OREAL, ESTEE LAUDER) are typographic choices, not spelling errors.
|
||||
- "Stylised brand logotype" exception — known logomarks like `17` for SEVENTEEN are Pass.
|
||||
- "Best-effort with manual_check flag" pattern — for vision-LLM limitations, score 7-8 with explicit caveat field rather than confident-but-wrong Fail.
|
||||
|
||||
## Guidance Document Summary
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
# Diageo Client Documentation
|
||||
|
||||
> Referenced from main CLAUDE.md. Detailed Diageo QC profile descriptions and check inventories.
|
||||
|
||||
## Overview
|
||||
|
||||
Diageo has two specialised profiles for its core asset types: **Key Visual** (campaign creative) and **Packaging** (label/pack design). Both run against generic visual checks shared with other CPG-style brand profiles (Unilever uses an overlapping check set).
|
||||
|
||||
## Diageo Profiles
|
||||
|
||||
### `diageo_key_visual` — 11 checks
|
||||
|
||||
Campaign key-visual QC. Uses generic shared visual checks at brand-tuned weights.
|
||||
|
||||
| Check | What it does | Weight |
|
||||
|-------|--------------|--------|
|
||||
| `background_contrast` | Product/text contrast against background | 0.115 |
|
||||
| `brand_assets_visibility` | Brand assets clearly visible | 0.077 |
|
||||
| `call_to_action` | CTA presence and clarity | 0.115 |
|
||||
| `face_gaze_direction` | If a face is present, gaze direction guides toward product/CTA | 0.038 |
|
||||
| `face_visibility` | Face presence and visibility | 0.077 |
|
||||
| `imperative_verb` | Headline uses imperative verb | 0.077 |
|
||||
| `logo_visibility` | Brand logo clearly visible | 0.115 |
|
||||
| `text_readability` | Text legibility | 0.115 |
|
||||
| `visual_elements_count` | Element count not overwhelming | 0.077 |
|
||||
| `visual_hierarchy` | Clear visual hierarchy | 0.115 |
|
||||
| `word_count` | Headline word count appropriate | 0.077 |
|
||||
|
||||
### `diageo_packaging` — 13 checks
|
||||
|
||||
Packaging design QC. Adds packaging-specific checks (curved edges, color format) to a similar base.
|
||||
|
||||
| Check | What it does | Weight |
|
||||
|-------|--------------|--------|
|
||||
| `background_contrast` | Visibility of design elements | 0.087 |
|
||||
| `brand_assets_visibility` | Brand assets visible on pack | 0.13 |
|
||||
| `call_to_action` | CTA on pack (if applicable) | 0.043 |
|
||||
| `color_format` | Color mode appropriate for print | 0.043 |
|
||||
| `curved_edges` | Pack curve treatment | 0.087 |
|
||||
| `face_gaze_direction` | Gaze direction (if face) | 0.043 |
|
||||
| `face_visibility` | Face visibility | 0.043 |
|
||||
| `logo_visibility` | Brand logo on pack | 0.13 |
|
||||
| `lowercase_text` | Lowercase usage rules | 0.043 |
|
||||
| `new_visibility` | "NEW" tag visibility (if present) | 0.087 |
|
||||
| `product_visibility` | Product clearly visible | 0.13 |
|
||||
| `text_readability` | Text legibility | 0.087 |
|
||||
| `visual_elements_count` | Element count appropriate | 0.043 |
|
||||
|
||||
## Status
|
||||
|
||||
No formal prompt-tuning rounds have been run on Diageo profiles in this repo's history. Profiles use generic shared checks, so tuning is captured in the underlying `visual_qc_apps/<check>/app.py` prompts rather than client-specific check modules.
|
||||
|
||||
If Diageo-specific tuning is required (specific brand families, region rules, etc.), introduce dedicated `diageo_*` checks in `visual_qc_apps/` following the Boots / Amazon pattern.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# General / Other Client Documentation
|
||||
|
||||
> Referenced from main CLAUDE.md. The "General / Other" tile is the catch-all for users without a brand-specific client assignment.
|
||||
|
||||
## Overview
|
||||
|
||||
`general` is the default client. Every authenticated user is granted access to it via `default_clients: ["general"]` in `backend/user_access.json`. It's the safe sandbox where new users can run analyses without an admin granting brand-specific access.
|
||||
|
||||
## Profiles available
|
||||
|
||||
| Profile | Notes |
|
||||
|---------|-------|
|
||||
| `static_general` | 10-check baseline static QC profile |
|
||||
| `video_general` | Generic video QC profile |
|
||||
| `inclusive_accessibility` | 2-check accessibility-focused profile (accessibility + inclusive design) |
|
||||
|
||||
## Notes
|
||||
|
||||
- The `general` client is intentionally generic — no client-specific tuning happens here.
|
||||
- New profiles created with `visibility: "all"` automatically appear in this client's profile list.
|
||||
- For client-specific work, set up a dedicated client tile and use a `client_specific` profile rather than adding to `general`.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Honda Client Documentation
|
||||
|
||||
> Referenced from main CLAUDE.md. Honda has no client-specific profiles or checks at present.
|
||||
|
||||
## Overview
|
||||
|
||||
Honda is set up as a client tile in the platform but uses the **generic** `static_general` and `video_general` profiles only. No client-specific QC tools, profiles, or prompt tuning have been built for Honda.
|
||||
|
||||
## Profiles available
|
||||
|
||||
| Profile | Notes |
|
||||
|---------|-------|
|
||||
| `static_general` | 10-check baseline static QC profile shared with all clients |
|
||||
| `video_general` | Generic video QC profile |
|
||||
|
||||
## Adding Honda-specific work
|
||||
|
||||
If Honda-specific QC needs arise (brand guidelines, dealer-template compliance, etc.), follow the established client pattern:
|
||||
1. Create `honda_*` check modules under `backend/visual_qc_apps/`
|
||||
2. Create a `honda_static.json` (or similar) profile in `backend/profiles/`
|
||||
3. Update `client_config.py` to add the profile to the Honda client's profile list
|
||||
4. Capture tuning history and known limitations in this file
|
||||
|
||||
See `CLAUDE_AMAZON.md` and `CLAUDE_BOOTS.md` for examples of full client builds.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Rank Client Documentation
|
||||
|
||||
> Referenced from main CLAUDE.md. Rank has no client-specific profiles or checks at present.
|
||||
|
||||
## Overview
|
||||
|
||||
Rank is set up as a client tile in the platform but uses the **generic** `static_general` and `video_general` profiles only. No client-specific QC tools, profiles, or prompt tuning have been built for Rank.
|
||||
|
||||
## Profiles available
|
||||
|
||||
| Profile | Notes |
|
||||
|---------|-------|
|
||||
| `static_general` | 10-check baseline static QC profile shared with all clients |
|
||||
| `video_general` | Generic video QC profile |
|
||||
|
||||
## Adding Rank-specific work
|
||||
|
||||
If Rank-specific QC needs arise, follow the established client pattern:
|
||||
1. Create `rank_*` check modules under `backend/visual_qc_apps/`
|
||||
2. Create a `rank_static.json` (or similar) profile in `backend/profiles/`
|
||||
3. Update `client_config.py` to add the profile to the Rank client's profile list
|
||||
4. Capture tuning history and known limitations in this file
|
||||
|
||||
See `CLAUDE_AMAZON.md` and `CLAUDE_BOOTS.md` for examples of full client builds.
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
# Unilever Client Documentation
|
||||
|
||||
> Referenced from main CLAUDE.md. Detailed Unilever QC profile descriptions, profile-specific scoring logic, and check inventories.
|
||||
|
||||
## Overview
|
||||
|
||||
Unilever has two specialised profiles: **Key Visual** (campaign creative) and **Packaging** (label/pack design). Both share most checks with Diageo (generic CPG-style visual checks) but include a small number of **bonus checks** with profile-specific zero-scoring behaviour for missing critical elements.
|
||||
|
||||
## Unilever Profiles
|
||||
|
||||
### `unilever_key_visual` — 15 checks (120-point scale)
|
||||
|
||||
Campaign key-visual QC.
|
||||
|
||||
| Check | What it does | Weight |
|
||||
|-------|--------------|--------|
|
||||
| `background_contrast` | Product/text contrast against background | 0.10 |
|
||||
| `brand_assets_visibility` | Brand assets visible | 0.12 |
|
||||
| `call_to_action` | CTA presence and clarity | 0.03 |
|
||||
| `curved_edges` | Curved-edge treatment | 0.04 |
|
||||
| `face_gaze_direction` | Gaze direction guides toward product/CTA *(bonus / zero-score)* | 0.06 |
|
||||
| `face_visibility` | Face presence and visibility *(bonus / zero-score)* | 0.07 |
|
||||
| `imperative_verb` | Headline uses imperative verb | 0.02 |
|
||||
| `logo_visibility` | Brand logo visible | 0.14 |
|
||||
| `lowercase_text` | Lowercase usage rules | 0.10 |
|
||||
| `new_visibility` | "NEW" tag visibility *(bonus / zero-score)* | 0.07 |
|
||||
| `supporting_images` | Supporting imagery quality | 0.10 |
|
||||
| `text_readability` | Text legibility (deprecated, now part of inheritance) | (n/a) |
|
||||
| `visual_elements_count` | Element count not overwhelming | 0.14 |
|
||||
| `visual_hierarchy` | Clear visual hierarchy | 0.10 |
|
||||
| `visuals_left_text_right` | Visuals left, text right composition | 0.06 |
|
||||
| `word_count` | Headline word count | 0.05 |
|
||||
|
||||
### `unilever_packaging` — 17 checks
|
||||
|
||||
Packaging design QC. Same base as Key Visual plus print-related checks (`crop_marks`, `color_format`).
|
||||
|
||||
## Profile-specific scoring logic — bonus / zero-score
|
||||
|
||||
The Unilever Key Visual profile implements **zero-score behaviour** for three checks tied to the presence of specific creative elements:
|
||||
|
||||
| Check | Trigger | Behaviour |
|
||||
|-------|---------|-----------|
|
||||
| `face_visibility` | `face_present == false` | Score forced to **0** |
|
||||
| `new_visibility` | `new_present == false` | Score forced to **0** |
|
||||
| `face_gaze_direction` | `face_present == false` | Score forced to **0** |
|
||||
|
||||
This ensures that creatives missing critical brand-mandated elements (a face, the "NEW" tag) cannot pass on the back of high scores from other checks. The zero-score logic lives in `api_server.py:extract_score_from_result()` and is gated by `profile_config.get('name') == 'Unilever Key Visual'`.
|
||||
|
||||
The Unilever Key Visual profile uses a **120-point scale** (total weight slightly above 1.0) — the bonus checks add headroom rather than being equally weighted with the core checks.
|
||||
|
||||
## Status
|
||||
|
||||
No formal client-driven prompt-tuning rounds in this repo's history. The profile-specific scoring logic was added as a system enhancement to handle the bonus-check pattern.
|
||||
|
||||
If Unilever-specific tuning is required (specific brand families, regional rules, etc.), introduce dedicated `unilever_*` checks in `visual_qc_apps/` following the Boots / Amazon pattern.
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
# AXA Document-Mode QC — Build Plan
|
||||
|
||||
Multi-page PDF QC pipeline for AXA Ireland. Different from every other client onboard because the QC *target* is an 80-page policy PDF, not a single image/video. Sources of requirements:
|
||||
|
||||
- `axa_ireland/AXA build guide new tools required.txt` — original ask + first scoping
|
||||
- `axa_ireland/Exampole Folders explained.rtf` — what the example folders represent
|
||||
- `axa_ireland/Example 1/` — old vs new (post brand-refresh) Home Insurance policy + the human QC checklist (`Policy documents QC CHECKS.docx`)
|
||||
- `axa_ireland/Example 2/` — Landlord Insurance v1 (shipped with errors), V10 (corrected), V1 2025 amends, original master. **Phase-3 diff test pair: V1 vs V10.**
|
||||
|
||||
## Sequencing (updated 2026-05-01)
|
||||
|
||||
Inverted from "discover everything → build" to "build minimum demo → show client → drive deeper discovery from their reactions".
|
||||
|
||||
1. **Phase 1 refactor** ✅ DONE on `feature/axa-document-mode` 2026-05-01 — scope-aware dispatcher, 6 deterministic checks, $0 LLM cost, runs in seconds. Replaces broken Phase-1 stub.
|
||||
2. **Phase 3 build** (next) — old-vs-new diff using vision-LLM page-pair, no LlamaParse account needed.
|
||||
3. **Show-and-tell with AXA** — demonstrate Phases 1 + 3 working, show costs, gather requirements (font list, bold-words list, phone numbers, WCAG target, print preflight scope).
|
||||
4. **Phases 2, 4, 5** in order, with local testing between each.
|
||||
5. Local-only until show-and-tell; do not push `feature/axa-document-mode` to dev or merge to develop yet.
|
||||
|
||||
## Phase 0 discovery — answered 2026-05-01 (provisional, pending client confirmation)
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Approved Monotype font list | **Not yet supplied.** Until then, `axa_font_inventory` lists fonts only (informational). When list arrives, becomes `axa_font_compliance`. |
|
||||
| Bold-words dictionary | **Bootstrap from Example 1 General Definitions** (pages 8–10) until AXA supplies canonical list. 35 terms extracted, saved to `backend/document_mode/data/axa_bold_words_seed.json`. Some short terms (`you`, `your`, `we`) produce false positives — accept until canonical list lands. |
|
||||
| Approved phone number list | **Not yet supplied.** `axa_phone_inventory` lists numbers only. Becomes `axa_phone_compliance` when list arrives. |
|
||||
| WCAG target | **AAA** (Phase 4 veraPDF profile setting) |
|
||||
| Print preflight scope | **"Is it print-ready?"** simple version. Expand later if needed. |
|
||||
| Page sampling defaults | **N=8** for visual sanity, **N=5** for print sanity |
|
||||
| Volume expectation | Still pending — drives later cost decisions |
|
||||
|
||||
## Architectural choice
|
||||
|
||||
**One web UI, isolated backend module.** Doc-mode shares the existing shell (auth, client picker, Settings, Reporting, Admin, user-access, output history) and runs as a third mode alongside static + video. New code lives in `backend/document_mode/` with new endpoints under `/api/document/*`, gated by `mode: "document"` on the profile JSON. Existing single-asset clients are not touched.
|
||||
|
||||
Rejected: a separate `/ai_qc/documents/` page. Would re-implement the shell for one client and fork future doc-mode work per-client.
|
||||
|
||||
## Scope-aware dispatcher (Phase 1 refactor, 2026-05-01)
|
||||
|
||||
Each check declares its `scope` in the profile JSON. The dispatcher routes by scope:
|
||||
|
||||
| Scope | Behaviour | Cost shape | Phase 1 uses? |
|
||||
|---|---|---|---|
|
||||
| `document` | Run once over the full ingest result. Deterministic checks live in `document_mode/checks.py`. | $0, milliseconds | ✅ all 4 deterministic doc-checks |
|
||||
| `targeted` | Run once on specific pages (`scope_args.pages`: `first`, `last`, `first-N`, `last-N`, or list). | $0, milliseconds | ✅ print_code, omg_versioning |
|
||||
| `page_sample` | Run on N evenly-spaced pages via existing batch dispatcher. | N × LLM call | Phase 4/5 |
|
||||
| `page_pair` | Run on aligned old/new page pairs. | M × LLM call | Phase 3 |
|
||||
| `page_each` | Run on every page (legacy / very expensive). | N × LLM call per check | Avoid |
|
||||
|
||||
Profile JSON shape:
|
||||
```json
|
||||
"checks": {
|
||||
"axa_font_inventory": {"weight": 1.0, "scope": "document", "enabled": true},
|
||||
"axa_print_code": {"weight": 1.0, "scope": "targeted", "scope_args": {"pages": "last"}, "enabled": true}
|
||||
}
|
||||
```
|
||||
|
||||
The scope field is optional in the QCCheckConfig dataclass — defaults to None, which the doc-mode dispatcher treats as `page_each` for legacy compatibility. Asset-mode pipeline ignores scope entirely.
|
||||
|
||||
## Phased delivery
|
||||
|
||||
Each phase ships as its own tag so we can demo / rollback in slices.
|
||||
|
||||
### Phase 0 — Discovery (you, before further code)
|
||||
|
||||
Need from AXA before building Phase 2+:
|
||||
|
||||
- [ ] Approved **Monotype font list** (family + weights). Brand refresh moved them off old BOX-licensed fonts; we need the canonical list for `axa_font_compliance`.
|
||||
- [ ] **Bold-words dictionary** for General Definitions (Example 2 says 70+).
|
||||
- [ ] 2–3 more old/new PDF pairs beyond Examples 1 & 2 (ideally Motor Insurance for diversity).
|
||||
- [ ] WCAG target — AA or AAA. EAA scope confirmation.
|
||||
- [ ] Print preflight scope — "is it print-ready?" or full PDF/X-1a/4 compliance.
|
||||
- [ ] Volume expectation — 80 pages × how many docs/month.
|
||||
|
||||
### Phase 1 — Document-mode plumbing + deterministic checks — REFACTORED 2026-05-01 ✅
|
||||
|
||||
**Original Phase 1 (2026-04-29):** spine only, ran existing image-based `accessibility` per page on all 86 pages. Smoke test ran in ~70min for ~$0.50. Output report revealed every "failing page" failed for the same false-positive reason: *"document is presented as an image of text / WCAG 1.4.5"* — the LLM was critiquing our PNG rendering pipeline, not AXA's actual PDF. Result: noisy 67.7/Pass with no actionable findings.
|
||||
|
||||
**Refactor (2026-05-01) on `feature/axa-document-mode`:** scope-aware dispatcher + 6 deterministic doc-scope checks. Same Home Insurance PDF now scores 81.4/Pass in seconds at $0 cost, with real findings: font inventory, phone-number inventory, 132 non-bold defined-term occurrences flagged across 53 pages, 5 page-numbering discontinuities, print code "AG400 11/25" detected.
|
||||
|
||||
Files added:
|
||||
- `backend/document_mode/__init__.py`
|
||||
- `backend/document_mode/ingest.py` — multi-page PDF → per-page PNGs + per-span structured text (font, size, bold flag, italic flag, bbox). Uses PyMuPDF. Bold detection = `flags & 16` OR font name contains `bold|black|heavy`. Default zoom 2.0×, max dim 1600 px, page cap 200.
|
||||
- `backend/document_mode/dispatcher.py` — **scope-aware** routing. `document` and `targeted` checks bypass LLM; `page_sample` / `page_each` use existing batch dispatcher; `page_pair` reserved for Phase 3.
|
||||
- `backend/document_mode/checks.py` — registry of 6 deterministic doc-scope checks (see table below). Each returns `{check_name, scope, score, pass, summary, findings, response}`.
|
||||
- `backend/document_mode/data/axa_bold_words_seed.json` — bootstrap dictionary, 35 terms extracted from Example 1 General Definitions (pages 8-10).
|
||||
- `backend/document_mode/result_writer.py` — writes JSON + self-contained HTML with: at-a-glance findings table, per-check sections with structured renderers (font/phone/bold-words/page-numbering/print-code/OMG each get their own table), per-page summary strip. Reports collapsed by default.
|
||||
- `backend/profiles/axa_policy_document.json` — production profile with `mode: "document"`, 6 deterministic checks, `visibility: client_specific, visible_to_clients: ["axa"]`.
|
||||
|
||||
### The 6 Phase-1 deterministic checks
|
||||
|
||||
| Check | Scope | What it does | Becomes (when client supplies data) |
|
||||
|---|---|---|---|
|
||||
| `axa_font_inventory` | document | Lists every unique font + per-page distribution | `axa_font_compliance` (flags non-approved) |
|
||||
| `axa_phone_inventory` | document | Regex-extracts every phone-shaped number, dedup, with page refs | `axa_phone_compliance` (flags non-approved) |
|
||||
| `axa_bold_words_definitions` | document | Scans for seed-dictionary terms, flags non-bold occurrences | Same — replace seed dict with AXA's canonical list |
|
||||
| `axa_page_numbering` | document | Detects standalone-line integers near top/bottom, flags discontinuities | Same |
|
||||
| `axa_print_code` | targeted: last | Finds back-page print/version line components (code + ref + date + version) | Same — refine regex once AXA confirms format |
|
||||
| `axa_omg_versioning` | targeted: last | Finds OMG code + date format on back page | Same |
|
||||
|
||||
Files modified:
|
||||
- `backend/profile_config.py` — `Profile.mode` field defaults to `"asset"`. `QCCheckConfig` gains `scope` and `scope_args` fields, both optional. Persisted only when non-default.
|
||||
- `backend/api_server.py` — `POST /api/document/start_analysis` endpoint. The `enabled_checks` filter accepts checks from the document-mode registry (`is_document_scope_check`) in addition to the legacy `qc_apps` registry, so deterministic AXA checks aren't filtered out.
|
||||
- `backend/client_config.py` — AXA client gains `axa_policy_document` as first profile.
|
||||
- `web_ui.html` — doc-mode banner under upload area, file-input `accept` swapped to PDF-only, `performAnalysisWithProgress` routes to `/api/document/start_analysis` with `client_id`.
|
||||
|
||||
**Smoke-tested 2026-05-01 (post-refactor) against same Home Insurance PDF:**
|
||||
- Score: 81.4 / 100 (Pass)
|
||||
- Total runtime: a few seconds (deterministic only, no LLM calls)
|
||||
- Total cost: $0
|
||||
- Findings: 10 fonts, 8 phone numbers, 132 non-bold defined-term occurrences across 53 pages, 5 page-number discontinuities, print code "AG400" + "11/25" detected on back page, no OMG present.
|
||||
- Smoke-test report: `backend/output-dev/axa/PHASE1_REFACTOR_smoke_test_report.html`
|
||||
|
||||
**Local test plan (after `./scripts/run-local.sh`):**
|
||||
1. Pick AXA client → AXA Policy Document profile
|
||||
2. Upload `axa_ireland/Example 1/6317047 - AXA - Home Insurance Policy 2025 V8 final new brand.pdf`
|
||||
3. Verify: doc-mode banner, PDF-only picker, progress completes in seconds, report appears with at-a-glance table + per-check sections + structured findings.
|
||||
|
||||
**Known gotchas to surface during demo:**
|
||||
- Bold-words bootstrap dictionary contains short terms (`you`, `your`, `we`, `us`) which produce false positives in normal pronoun usage. Mitigated by Phase-2 work (canonical list from AXA).
|
||||
- Page-numbering heuristic catches TOC-page numbers as false-positive "page numbers" (5 such hits in this doc). Surface as data, score gently.
|
||||
- Print-code regex tuned to "AG400 11/25" pattern observed in Example 1; may need tuning for other docs.
|
||||
|
||||
### Phase 2 — Deterministic checks (~3–5 days)
|
||||
|
||||
The cheap, accurate wins. No LLM cost.
|
||||
|
||||
- `backend/document_mode/font_compliance.py` — reads PDF font inventory, flags anything not on AXA's approved list. Per-page failure log. Plugs into `process_single_check` via the same early-branch pattern as `dj_file_naming` (line ~384 in `api_server.py`).
|
||||
- `backend/document_mode/bold_words.py` — scans pages for AXA's bold-words dictionary, flags any occurrence not rendered bold.
|
||||
- `backend/document_mode/print_code.py` — extracts back-page print code, optionally compares to brief-supplied value.
|
||||
- `backend/document_mode/omg_versioning.py` — confirms back-page OMG number + date format compliance (regex pattern, similar to `file_naming_validator`).
|
||||
- New Settings → "AXA Configuration" tab for uploading approved fonts list + bold-words list per client (same pattern as media plan upload).
|
||||
|
||||
### Phase 3 — Old-vs-new diff — DONE on `feature/axa-document-mode` 2026-05-01 ✅
|
||||
|
||||
Vision-LLM-based page-pair diff. Validates the original Example-2 promise: catches the bold-formatting fixes, structural changes, definition updates, and content additions/removals that V1 missed and V10 fixed.
|
||||
|
||||
**Files added:**
|
||||
- `backend/document_mode/diff_engine.py` — page alignment via difflib SequenceMatcher (windowed fuzzy match, threshold 0.4) + parallel page-pair vision-LLM diff via Gemini 2.5 Pro (8 concurrent). Returns alignment map + structured diff JSON per pair (added/removed/modified/moved/style_changes/severity).
|
||||
- `backend/document_mode/diff_report_writer.py` — diff-specific HTML/JSON. Versions card, at-a-glance grid (page count delta, severity counts), full alignment table, per-pair cards with severity pills + categorised diff blocks.
|
||||
- `backend/profiles/axa_policy_document_diff.json` — `mode: document_diff` profile.
|
||||
|
||||
**Files modified:**
|
||||
- `backend/api_server.py` — new `POST /api/document/start_diff` endpoint accepting `old_file` + `new_file`. Reuses `_require_client_access`, `progress_tracker`, `ensure_client_output_folder`, `usage_tracker`.
|
||||
- `backend/client_config.py` — AXA profile list gains `axa_policy_document_diff`.
|
||||
- `web_ui.html` — third `mode: document_diff` UX path. Two-slot drop-zone (old + new). `applyProfileMode()` swaps between asset/document/document_diff. `wireDiffPickers()` wires the dual file pickers. `startAnalysis()` + `performAnalysisWithProgress()` route diff-mode submissions to `/api/document/start_diff`.
|
||||
|
||||
**Smoke-test 2026-05-01 — V1 (68 pages, broken) vs V10 (74 pages, corrected):**
|
||||
- Wall: 214 seconds (3:34)
|
||||
- Tokens: 214,342 (cost ≈ $0.50–$0.70)
|
||||
- 63 matched pairs · 11 pages added in V10 · 5 pages removed
|
||||
- 61 pages with differences flagged · 2 unchanged
|
||||
- Severity: 25 high, 32 medium, 4 low
|
||||
- Score 0/100, "Major changes" — correct call
|
||||
- Smoke-test report: `backend/output-dev/axa/phase3_smoke_*_diff_report.html`
|
||||
|
||||
**Caught the Example-2-class defects:**
|
||||
- Bold-formatting changes (e.g. *"the terms 'us', 'we', and 'adviser' are now bolded"*, *"the term 'your' is now bolded"*) — exactly the missed-bold issue that motivated this build.
|
||||
- New Section F: Legal Protection added in V10 — structural insertion caught.
|
||||
- New "Period of Insurance" definition added — defined-term addition caught.
|
||||
- "Employee" definition expanded by a sub-point — definition modification caught.
|
||||
- Wording fix: *"supply your own expense" → "supply at your own expense"* — body-text correction caught.
|
||||
- 11 pages flagged as added, 5 as removed — page-count delta and structural restructure caught.
|
||||
|
||||
**Cost dial (for show-and-tell):** ~$0.40–0.70 per diff against a typical 70-80-page policy.
|
||||
|
||||
**Local test plan (UI):**
|
||||
1. `./scripts/run-local.sh`
|
||||
2. AXA → AXA Policy Document — Old vs New Diff
|
||||
3. Pick V1.pdf as old, V10.pdf as new (both from `axa_ireland/Example 2/`)
|
||||
4. Click analyse. Wait ~3-5 minutes. Report lands in saved files.
|
||||
|
||||
### Phase 4 — PDF accessibility — DONE on `feature/axa-document-mode` 2026-05-01 ✅
|
||||
|
||||
**Pure-Python implementation.** Original plan was veraPDF subprocess (Java dependency, ~150MB install). Built deterministic PyMuPDF-based check instead — no Java needed for the demo, with veraPDF as an optional add-on later.
|
||||
|
||||
**Files added:**
|
||||
- `backend/document_mode/accessibility_checks.py` — 9 PDF/UA-aligned criteria checked deterministically:
|
||||
- **C1** Tagged PDF (StructTreeRoot present)
|
||||
- **C2** Marked content (/MarkInfo /Marked true)
|
||||
- **C3** Document title metadata
|
||||
- **C4** Document language (/Lang)
|
||||
- **C5** No password protection blocking AT
|
||||
- **C6** All fonts embedded
|
||||
- **C7** PDF version ≥ 1.5
|
||||
- **C8** XMP UA-conformance declaration
|
||||
- **C9** Alt text on images (sampling)
|
||||
- Plus a `_run_verapdf()` stub for future veraPDF integration
|
||||
|
||||
**Files modified:**
|
||||
- `backend/document_mode/checks.py` — `axa_pdf_accessibility` registered.
|
||||
- `backend/document_mode/ingest.py` — `pdf_path` added to ingest_result so doc-scope checks can read raw PDF structure.
|
||||
- `backend/document_mode/result_writer.py` — `_render_pdf_accessibility` structured renderer (criteria checklist with pass/fail markers).
|
||||
- `backend/profiles/axa_policy_document.json` — `axa_pdf_accessibility` added at weight 2.0.
|
||||
|
||||
**Smoke-test 2026-05-01 against Example 1 Home Insurance V8 (Adobe InDesign output):**
|
||||
- Overall AXA Policy Document score: 80.6 / Pass (7 checks, $0 cost, runs in seconds)
|
||||
- Accessibility check: 7.78 / 10 (7 of 9 criteria passed)
|
||||
- Real gaps caught:
|
||||
- **C7 fail:** PDF 1.4 — should be 1.5+ for full accessibility tagging support
|
||||
- **C8 fail:** No PDF/UA-1 conformance flag in XMP metadata
|
||||
- Pass: tagged structure, marked content, title set, /Lang=en, no encryption, all 10 fonts embedded, alt-text entries detected
|
||||
|
||||
**veraPDF integration plan (when ready):**
|
||||
1. Install veraPDF on host: https://verapdf.org/software/ (requires JRE 8+, ~150MB)
|
||||
2. Ensure `verapdf` binary on PATH or set `VERAPDF_BIN` env var
|
||||
3. Replace `_run_verapdf()` stub with `subprocess.run([verapdf, '--format', 'json', '--profile', 'ua1', pdf_path], capture_output=True)` and merge JSON findings into `axa_pdf_accessibility`'s output
|
||||
4. Set `findings['verapdf_run'] = True`
|
||||
|
||||
### Phase 5 — Print preflight — DONE on `feature/axa-document-mode` 2026-05-01 ✅
|
||||
|
||||
**Pure-Python implementation.** Original plan was Ghostscript-based; built deterministic PyMuPDF checks instead — same approach as Phase 4. Ghostscript can plug in later for total-ink-coverage / registration-black if scope grows.
|
||||
|
||||
**Files added:**
|
||||
- `backend/document_mode/print_preflight_checks.py` — 7 deterministic preflight criteria:
|
||||
- **PP1** Page geometry consistency (single MediaBox size across all pages)
|
||||
- **PP2** Bleed area defined (TrimBox/BleedBox differ from MediaBox)
|
||||
- **PP3** Image colour spaces (flag DeviceRGB; press wants CMYK/Gray)
|
||||
- **PP4** Image effective DPI (raw pixels / rendered inches; flag < 150)
|
||||
- **PP5** Transparency / soft-mask usage (flag for flattening)
|
||||
- **PP6** PDF/X conformance (XMP `pdfxid:GTS_PDFXVersion`)
|
||||
- **PP7** Spot colour usage (flag /Separation, /DeviceN)
|
||||
|
||||
**Files modified:**
|
||||
- `backend/document_mode/checks.py` — `axa_print_preflight` registered.
|
||||
- `backend/document_mode/result_writer.py` — `_render_print_preflight` structured renderer with low-DPI image list, colour-space breakdown, spot-colour list, page-size detail.
|
||||
- `backend/profiles/axa_policy_document.json` — `axa_print_preflight` added at weight 1.0.
|
||||
|
||||
**Smoke-test 2026-05-01 against Example 1 Home Insurance V8:**
|
||||
- Print preflight: 5.71 / 10 (4 of 7 criteria pass) — correctly flags as digital-intent
|
||||
- ✓ PP1 — All 86 pages 210×297 mm (A4), consistent
|
||||
- ✗ PP2 — No bleed authored (digital intent — correct finding for an electronic policy)
|
||||
- ✓ PP3 — Only 1 grayscale image, no RGB
|
||||
- ✓ PP4 — Image renders at 279 DPI (above 150 threshold)
|
||||
- ✗ PP5 — 85 of 86 pages use transparency / soft-masks (Adobe InDesign default; would need flattening for press)
|
||||
- ✗ PP6 — No PDF/X conformance flag in XMP
|
||||
- ✓ PP7 — No spot colour spaces
|
||||
- Updated full-profile score: 78.25 / Pass (8 checks now)
|
||||
|
||||
**Demo conversation:** *"If you're distributing electronically, this PDF is fine. If you're going to press, you need to (1) author bleed in InDesign, (2) flatten transparency on export, (3) declare PDF/X-1a or PDF/X-4 conformance."*
|
||||
|
||||
## All phases status (2026-05-01)
|
||||
|
||||
| Phase | Scope | Status | Cost | Wall |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Spine + 6 deterministic doc-scope checks | ✅ Done | $0 | seconds |
|
||||
| 2 | Compliance variants (font/phone/bold lists) | Blocked on AXA | — | — |
|
||||
| 3 | Old-vs-new diff (vision LLM page-pair) | ✅ Done | ~$0.50/run | ~3-5 min |
|
||||
| 4 | PDF accessibility (PyMuPDF, veraPDF stub) | ✅ Done | $0 | seconds |
|
||||
| 5 | Print preflight (PyMuPDF, Ghostscript later) | ✅ Done | $0 | seconds |
|
||||
|
||||
**Demo-ready as of 2026-05-01.** All work is on `feature/axa-document-mode`, local-only, no commits or pushes yet.
|
||||
|
||||
## Things to flag before any further build
|
||||
|
||||
1. **80 pages × multiple LLM checks = serious cost.** A doc with the existing static checks running per-page would be ~$5–10 in Gemini/OpenAI calls. We should decide which LLM checks need per-page vs once over a sampled set. Most should be deterministic-only.
|
||||
2. **veraPDF is Java.** Adds a JRE dependency to GCP boxes.
|
||||
3. **PDF mode breaks "one upload = one report" assumption.** Decide what to save: full per-page JSON, summary only, or both. (Phase 1 saves both.)
|
||||
4. **Reporting/billing.** An 80-page doc is one analysis but 80× the LLM work. We should bill it as one analysis but track total checks separately. `usage_tracker.log_analysis_complete` already gets `pages_processed` in doc mode.
|
||||
|
||||
## How doc-mode plugs into existing pipeline
|
||||
|
||||
For maintenance — the integration map:
|
||||
|
||||
| Doc-mode component | Reuses existing | Where |
|
||||
|---|---|---|
|
||||
| Per-page check execution | `process_checks_in_batches()` | `api_server.py:498` |
|
||||
| Per-check dispatch | `process_single_check()` | `api_server.py:377` |
|
||||
| LLM call | `run_visual_qc()` | `llm_config.py` |
|
||||
| Auth + client access | `auth.require_auth`, `_require_client_access()` | `api_server.py:4883` |
|
||||
| Progress polling | `/api/progress/<id>` | `api_server.py:1695` |
|
||||
| Output serving | `/output/<client>/<filename>` | `api_server.py:2121` |
|
||||
| Output listing | `/api/output_files` | `api_server.py:2168` |
|
||||
| Output folder | `ensure_client_output_folder()` | `api_server.py:856` |
|
||||
| Profile loading | `profile_config.get_profile()` | `profile_config.py:219` |
|
||||
| Profile visibility | `client_config.get_profiles_with_visibility()` | `client_config.py:82` |
|
||||
| Usage logging | `usage_tracker.log_analysis_start/complete()` | `usage_tracker.py:73,100` |
|
||||
|
||||
Future deterministic doc-mode checks should follow `_run_dj_file_naming_check()` (`api_server.py:348`) — short-circuit at the top of `process_single_check` before any LLM dispatch.
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
# Box Client Onboarding Runbook
|
||||
|
||||
Adds a new client to the Box-webhook-driven QC pipeline (Phase 4). Run through this once per client. Most steps need ~5 minutes; total ~30 minutes including Box admin turnaround for collaborator invites.
|
||||
|
||||
Architectural reference: the JWT auth + webhook endpoint live in `backend/box_jwt_client.py` and `backend/api_server.py` (search for `_run_box_triggered_analysis`). The admin CLI is `backend/scripts/box_setup.py`. The JWT auth coexists with an older per-user OAuth flow in `backend/box_client.py` — different code path, dormant scaffolding, not used by this pipeline.
|
||||
|
||||
---
|
||||
|
||||
## What you need before starting
|
||||
|
||||
- **Box admin access** (or someone who can act as one) — to create folders and invite the service account.
|
||||
- **SSH access to the dev server** (`optical-production-dev`) — to run the bootstrap CLI and tail logs.
|
||||
- **Repo write access** — to land the `client_config.py` change as a PR.
|
||||
- **The client's profile decisions** — which profile should be the unattended-run default? (Pick from the client's existing `profiles` list.)
|
||||
|
||||
Already done at the platform level (don't redo per-client):
|
||||
- JWT config JSON at `/opt/ai_qc/backend/config/box_jwt_config.json` on each server
|
||||
- `BOX_WEBHOOK_PRIMARY_KEY` + `BOX_WEBHOOK_SECONDARY_KEY` in each server's env file
|
||||
- ffmpeg installed (for video pre-flight)
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Box-side prep (admin task)
|
||||
|
||||
For client `<CLIENT>` (e.g. Diageo):
|
||||
|
||||
1. **Create two folders in Box:**
|
||||
- `AI-QC > INCOMING > AI QC <CLIENT> IN` — where source assets land
|
||||
- `AI-QC > REPORTS > AI QC <CLIENT> REPORTS` — where QC reports land
|
||||
|
||||
2. **Invite the JWT service account as a collaborator on BOTH folders.** Role: **Editor** or higher. (Editor lets it read uploads, write reports, and move files into the auto-created `_PROCESSED` subfolder. Co-owner also works.)
|
||||
|
||||
3. **Capture the folder IDs.** Box shows them in the URL when you open a folder, or you can list them programmatically once invites are in:
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
venv/bin/python backend/scripts/box_setup.py list-folder <parent_AI-QC_folder_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Code change
|
||||
|
||||
Edit `backend/client_config.py`, add three optional fields to the client entry:
|
||||
|
||||
```python
|
||||
'<client_id>': {
|
||||
'name': 'Client Display Name',
|
||||
'profiles': ['client_specific_profile', 'static_general', 'video_general'],
|
||||
'display_name': 'Client Display Name',
|
||||
'description': '...',
|
||||
'box_folder_id': '<INCOMING folder ID>',
|
||||
'box_reports_folder_id': '<REPORTS folder ID>',
|
||||
'default_profile': '<one of the profiles above>',
|
||||
},
|
||||
```
|
||||
|
||||
Then:
|
||||
- Push as a small PR → merge to `develop`
|
||||
- On the dev server: `cd /opt/ai_qc && ./backend/scripts/deploy.sh dev`
|
||||
- No env-file backup dance needed (this is a code-only change)
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Verify the service account got access
|
||||
|
||||
Before registering webhooks, sanity-check that the service account can actually read the folders the admin invited it to:
|
||||
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
venv/bin/python backend/scripts/box_setup.py list-folder <INCOMING folder ID>
|
||||
venv/bin/python backend/scripts/box_setup.py list-folder <REPORTS folder ID>
|
||||
```
|
||||
|
||||
Expected: both print `Folder <id> contains N items:` even if empty.
|
||||
|
||||
**If you get `Access Denied` / HTTP 403**: the service account isn't actually a collaborator yet. Box admin needs to retry the invite. Common causes:
|
||||
- Invite went to the wrong identity (Box has separate "user" and "app" identities — the JWT app is an app)
|
||||
- Invite is pending acceptance somewhere
|
||||
- Folder was created but invite wasn't applied at the right level
|
||||
|
||||
Don't proceed until both `list-folder` calls succeed.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Register the V2 webhook
|
||||
|
||||
**Option A: CLI (recommended)** — idempotent, batch-able, lives in version control:
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
venv/bin/python backend/scripts/box_setup.py register-all-clients \
|
||||
https://optical-dev.oliver.solutions/ai_qc/api/box/webhook
|
||||
```
|
||||
|
||||
The script:
|
||||
- Scans `client_config.py` for every client with `box_folder_id` set
|
||||
- For each, checks Box for an existing webhook on that folder pointing at the given URL
|
||||
- Skips ones that already exist
|
||||
- Creates webhooks for any that are missing
|
||||
- Prints `<client> (<folder_id>): CREATED webhook id=<id>` or `SKIP — webhook already exists`
|
||||
|
||||
Safe to re-run any time; it won't duplicate.
|
||||
|
||||
**Option B: Box Developer Console UI** — useful for one-off testing:
|
||||
- Box Developer Console → your Custom App → **Webhooks** tab → **Create Webhook**
|
||||
- URL: `https://optical-dev.oliver.solutions/ai_qc/api/box/webhook`
|
||||
- Content Type: **Folder** → search/pick the client's INCOMING folder
|
||||
- Event Triggers: tick **`FILE.UPLOADED`** only (do not tick others — they'd trigger spurious webhook deliveries)
|
||||
- Save
|
||||
|
||||
No new signing keys to generate — they're app-level, configured once for the whole Custom App.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — End-to-end test
|
||||
|
||||
Open one terminal:
|
||||
```bash
|
||||
sudo journalctl -u ai-qc.service -f
|
||||
```
|
||||
|
||||
In Box: upload a small test asset (image, PDF, or video) to the client's INCOMING folder.
|
||||
|
||||
Within a few seconds you should see (timestamps abbreviated):
|
||||
```
|
||||
Box webhook: dispatching session=<ts> client=<client_id> profile=<default_profile> file_id=...
|
||||
Box webhook: downloaded <file> → uploads-dev/<ts>/<file>
|
||||
Running check 1/N: <check_name>
|
||||
...
|
||||
Box webhook: uploaded report QC_Report_<ts>_<file>.html → folder <REPORTS folder ID>
|
||||
Box webhook: moved source → _PROCESSED/<ts>_<file>
|
||||
Box webhook: analysis complete for session <ts>, score <N>
|
||||
```
|
||||
|
||||
Then in Box, verify:
|
||||
- A new `QC_Report_<ts>_<original-filename>.html` exists in the REPORTS folder
|
||||
- The source file has been moved into the auto-created `_PROCESSED` subfolder inside INCOMING. Its new name has the session_id prefix, which ties back to the corresponding report.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — (Optional) Tune the default profile from the UI
|
||||
|
||||
If the team finds that the static `default_profile` in code doesn't match how they want webhook-triggered runs to behave, an admin can change it without a code deploy:
|
||||
|
||||
1. Open the app → pick the client in the picker
|
||||
2. ⚙️ **Settings** → **Default Profile** tab
|
||||
3. Click a different profile → **Set as default**
|
||||
|
||||
The override is persisted to `backend/client_defaults.json` (gitignored, per-server) and takes effect immediately on the next webhook run. **Revert to static default** clears the override.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Promote to prod
|
||||
|
||||
After the dev test passes:
|
||||
|
||||
1. PR `develop → main` on Bitbucket. Merge.
|
||||
2. Tag main: e.g. `v1.2.0`, push the tag.
|
||||
3. On the prod server (`optical-production`):
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
./backend/scripts/deploy.sh prod v1.2.0
|
||||
```
|
||||
4. Once-per-environment prod prerequisites (you only do these the first time prod gets Phase 4, never again):
|
||||
- JWT config JSON at `/opt/ai_qc/backend/config/box_jwt_config.json` (scp from your laptop, `chmod 600`)
|
||||
- `BOX_WEBHOOK_PRIMARY_KEY` + `BOX_WEBHOOK_SECONDARY_KEY` in `production.env` — these are the same app-level keys as dev
|
||||
- `sudo apt install ffmpeg` (for video pre-flight)
|
||||
5. Register webhooks pointing at the prod URL (different from dev's URL — each webhook is bound to one address):
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
venv/bin/python backend/scripts/box_setup.py register-all-clients \
|
||||
https://optical-prod.oliver.solutions/ai_qc/api/box/webhook
|
||||
```
|
||||
|
||||
The Box folders themselves are shared — you don't create new prod-only folders. Both dev and prod webhooks fire on the same client folders. If you don't want prod handling uploads yet, just don't register the prod webhooks until you're ready.
|
||||
|
||||
---
|
||||
|
||||
## Common gotchas
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| 403 from `list-folder` | Service account isn't a collaborator on that folder yet | Box admin re-invites with Editor role |
|
||||
| `Box webhook: signature verification failed` in logs | Signing keys in env don't match what the Custom App has | Box Developer Console → Manage Signature Keys → regenerate → update env on each server → restart service |
|
||||
| `Box webhook: no client configured for Box folder <id>` | The folder ID Box sent doesn't match any `box_folder_id` in `client_config.py` | Check `client_config.py` against the actual Box folder ID; they're strings, must match exactly |
|
||||
| `Box webhook: skipping non-QC extension <ext>` | User uploaded a file type we don't QC (e.g. `.docx`, `.zip`) | Working as intended; document for the client |
|
||||
| Webhook fires correctly but source file stays in INCOMING | The report-upload step failed earlier; the move is gated on a successful report upload so the user can retry by re-uploading | Look upstream in the log for `failed to upload report to Box: <error>` and fix the cause (usually a permissions issue on the REPORTS folder) |
|
||||
| Re-uploading the same filename doesn't trigger a fresh webhook | This is normal Box V2 behavior — same-name "replace" uploads create new versions of the existing file, which the folder-scoped webhook doesn't fire on | The auto-move-to-`_PROCESSED` step solves this for the happy path. If a file got stuck in INCOMING because of a previous failure, move/delete it manually so the next upload is a genuinely-new file |
|
||||
| Reports folder fills up indefinitely | No auto-cleanup of old reports — by design | Manual cleanup, or add an age-based pruning script as a follow-up |
|
||||
| `_PROCESSED` folder not auto-created | Service account doesn't have Editor (Viewer can't create subfolders) | Box admin upgrades the collaborator role to Editor |
|
||||
|
||||
---
|
||||
|
||||
## What this onboarding does NOT cover
|
||||
|
||||
- **Removing a client from the integration** — to stop processing: delete the webhook in the Box Developer Console (or `box_setup.py delete-webhook <webhook_id>`), then remove the `box_folder_id` field from `client_config.py` in a PR. Existing reports in the REPORTS folder are left alone.
|
||||
- **Multiple webhook-triggered profiles per client** — current schema is one default profile per client. If a client needs `FILE.UPLOADED` in one folder to run profile A and a different folder to run profile B, that's a schema change (one `client_config.py` entry per folder, or extend the schema to `{folder_id: profile_id}` maps).
|
||||
- **Webhook health monitoring** — there's no alert if Box stops delivering. If you suspect webhooks are silent, drop a fresh test asset and watch logs; if nothing fires, check Box Developer Console → Webhooks → the webhook's `App Diagnostics` tab.
|
||||
|
|
@ -1,18 +1,933 @@
|
|||
# CLAUDE.md (backend/)
|
||||
# CLAUDE.md
|
||||
|
||||
This file used to duplicate the project-wide guidance and is now stale. Read **`../CLAUDE.md`** at the repo root for current project-wide guidance, and the relevant **`../CLAUDE_<CLIENT>.md`** when working on client-specific code.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Quick pointers for backend work
|
||||
## Project Overview
|
||||
|
||||
- API server entry point: `api_server.py`
|
||||
- QC check modules: `visual_qc_apps/{check_name}/app.py`
|
||||
- Document-mode pipeline (multi-page PDF): `document_mode/`
|
||||
- Profile JSONs: `profiles/`
|
||||
- Profile loading + check discovery: `profile_config.py`
|
||||
- Client ↔ profile mapping: `client_config.py`
|
||||
- LLM config: `llm_config.py`
|
||||
- User access control: `user_access.py` + `user_access.json` (gitignored)
|
||||
- Usage logs: `usage_logs/<YYYY-MM-DD>.jsonl`
|
||||
- Deploy scripts: `scripts/deploy.sh`, `scripts/rollback.sh`, `scripts/health-check.sh`
|
||||
Visual AI QC is a Python Flask-based AI-powered quality control platform for analyzing marketing materials and design assets using OpenAI GPT-4o and Google Gemini 2.5 Pro. It evaluates visual and video content against brand guidelines and design best practices through **75 specialized QC checks** across **14 profiles**, serving **10 clients** (Diageo, Unilever, L'Oreal, Amazon, Boots, Dow Jones, Honda, AXA, Rank, General).
|
||||
|
||||
For everything else (architecture, auth, deployment, branch strategy, troubleshooting, pre-session checklist) see `../CLAUDE.md`.
|
||||
## Core Architecture
|
||||
|
||||
### Main Components
|
||||
|
||||
- **`api_server.py`** - Main Flask server with async processing and parallel execution
|
||||
- **`visual_qc_apps/`** - Modular QC check system with 65 individual check modules
|
||||
- **`profiles/`** - JSON configuration files defining QC check combinations and weights
|
||||
- **`brand_guidelines/`** - Reference asset storage and brand guideline database
|
||||
- **`llm_config.py`** - Centralized LLM configuration and API interaction
|
||||
- **`profile_config.py`** - Profile loading and QC check discovery system
|
||||
- **`usage_tracker.py`** - Usage tracking and cost estimation system
|
||||
- **`generate_usage_report.py`** - Command-line tool for generating usage reports
|
||||
- **`client_config.py`** - Client-profile relationship management with visibility control
|
||||
- **`pdf_processor.py`** - PDF text extraction, LLM summarization for brand guidelines
|
||||
- **`media_plan_processor.py`** - Excel media plan parsing, filename matching, spec validation
|
||||
- **`web_ui.html`** - Single-page web interface for uploads and analysis
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
- **Modular QC Checks**: Each check lives in `visual_qc_apps/{check_name}/app.py` with standardized interface
|
||||
- **Profile-Based Configuration**: QC profiles define which checks run, their weights, and LLM assignments
|
||||
- **Parallel Batch Processing**: Checks execute in parallel batches of 15 for performance
|
||||
- **Async Progress Tracking**: Non-blocking analysis with real-time progress updates
|
||||
- **Reference Asset Integration**: Brand guidelines enhance analysis accuracy through prompt augmentation
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Running the Application
|
||||
|
||||
#### Development Environment (Recommended)
|
||||
```bash
|
||||
# Quick start with development environment
|
||||
./scripts/run-local.sh
|
||||
|
||||
# Access web interface at http://localhost:7183
|
||||
```
|
||||
|
||||
#### Legacy/Manual Setup
|
||||
```bash
|
||||
# Start the Flask server directly
|
||||
python api_server.py
|
||||
|
||||
# Or with environment variable
|
||||
export ENVIRONMENT=development
|
||||
python api_server.py
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
#### New Environment System (Recommended)
|
||||
The application now supports separate development and production environments:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configure development environment
|
||||
cp config/.env.template config/development.env
|
||||
# Edit config/development.env with:
|
||||
# OPENAI_API_KEY, GOOGLE_API_KEY, AZURE_CLIENT_ID, etc.
|
||||
|
||||
# Configure production environment
|
||||
cp config/.env.template config/production.env
|
||||
# Edit config/production.env with production settings
|
||||
```
|
||||
|
||||
#### Environment Structure
|
||||
```
|
||||
config/
|
||||
├── development.env # Local development settings
|
||||
├── production.env # Production server settings
|
||||
└── .env.template # Template for new environments
|
||||
|
||||
uploads-dev/ # Development uploads (separate from production)
|
||||
output-dev/ # Development output (separate from production)
|
||||
|
||||
scripts/
|
||||
├── run-local.sh # Start local development
|
||||
├── deploy-to-prod.sh # Deploy to production
|
||||
└── test-system.sh # Validate system before deployment
|
||||
```
|
||||
|
||||
#### Legacy Environment Setup
|
||||
```bash
|
||||
# Fallback to legacy config.env (still supported)
|
||||
cp config.env.example config.env
|
||||
# Edit config.env with OPENAI_API_KEY and GOOGLE_API_KEY
|
||||
```
|
||||
|
||||
### Adding New QC Checks
|
||||
1. Create directory: `visual_qc_apps/{check_name}/`
|
||||
2. Create `app.py` with standardized interface using `flask_app_template.py`
|
||||
3. Register in profile configurations
|
||||
4. Restart server to activate
|
||||
|
||||
### Code Quality Checks
|
||||
|
||||
#### Comprehensive Testing (Recommended)
|
||||
```bash
|
||||
# Run full system validation
|
||||
./scripts/test-system.sh
|
||||
|
||||
# This includes:
|
||||
# - Python syntax validation
|
||||
# - Core module import testing
|
||||
# - Profile system validation (all 14 profiles)
|
||||
# - QC module testing
|
||||
# - Configuration validation
|
||||
# - Brand guidelines database testing
|
||||
```
|
||||
|
||||
#### Manual Testing
|
||||
```bash
|
||||
# Run syntax check on all Python files
|
||||
python -m py_compile **/*.py
|
||||
|
||||
# Import all modules to check for runtime issues
|
||||
python -c "import api_server, llm_config, profile_config"
|
||||
|
||||
# Test authentication modules
|
||||
python -c "import jwt_validator, auth_middleware; print('Authentication modules imported successfully')"
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
|
||||
#### Local Development Process
|
||||
1. **Start Development Server**: `./scripts/run-local.sh`
|
||||
2. **Make Changes**: Edit code, profiles, or configurations
|
||||
3. **Test Locally**: Verify functionality at http://localhost:7183
|
||||
4. **Run Validation**: `./scripts/test-system.sh` before deployment
|
||||
5. **Deploy to Production**: `./scripts/deploy-to-prod.sh` when ready
|
||||
|
||||
#### Environment Detection
|
||||
The application automatically detects which environment to use:
|
||||
1. **`ENVIRONMENT` environment variable** (development/production)
|
||||
2. **Config file existence** in `config/` folder
|
||||
3. **Fallback to legacy** `config.env` if new structure not found
|
||||
|
||||
#### Benefits of New Setup
|
||||
- ✅ **Safe Testing**: Changes don't affect production
|
||||
- ✅ **Separate Data**: Dev uploads/output don't mix with production
|
||||
- ✅ **Easy Deployment**: One command to push to production
|
||||
- ✅ **Automated Testing**: Validation before deployment
|
||||
- ✅ **Quick Rollback**: Automatic backups before deployment
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
├── api_server.py # Main Flask application
|
||||
├── visual_qc_apps/ # QC check modules
|
||||
│ ├── utils.py # Shared utilities
|
||||
│ ├── flask_app_template.py # Template for new checks
|
||||
│ └── {check_name}/app.py # Individual QC checks
|
||||
├── profiles/ # QC profile configurations (14 total)
|
||||
│ ├── general_check.json # General purpose profile (10 checks)
|
||||
│ ├── static_general.json # Static general profile (10 checks)
|
||||
│ ├── unilever_key_visual.json # Unilever key visual profile (15 checks)
|
||||
│ ├── unilever_packaging.json # Unilever packaging profile (17 checks)
|
||||
│ ├── diageo_key_visual.json # Diageo key visual profile (11 checks)
|
||||
│ ├── diageo_packaging.json # Diageo packaging profile (13 checks)
|
||||
│ ├── loreal_static.json # L'Oreal static profile (2 checks)
|
||||
│ ├── amazon_static.json # Amazon ASD 2025 profile (6 checks)
|
||||
│ └── inclusive_accessibility.json # Accessibility profile (2 checks)
|
||||
├── brand_guidelines/ # Reference assets
|
||||
│ └── guidelines_db.json # Asset metadata
|
||||
├── config/ # Environment configurations (NEW)
|
||||
│ ├── development.env # Development environment settings
|
||||
│ ├── production.env # Production environment settings
|
||||
│ └── .env.template # Template for new environments
|
||||
├── scripts/ # Deployment and testing scripts (NEW)
|
||||
│ ├── run-local.sh # Start local development server
|
||||
│ ├── deploy-to-prod.sh # Deploy to production server
|
||||
│ └── test-system.sh # Comprehensive system validation
|
||||
├── uploads/ # Production file uploads
|
||||
├── uploads-dev/ # Development file uploads (NEW)
|
||||
├── output/ # Production generated reports
|
||||
├── output-dev/ # Development generated reports (NEW)
|
||||
├── config.env # Legacy API keys and configuration (DEPRECATED)
|
||||
├── DEV_PROD_SETUP.md # Development/Production setup guide (NEW)
|
||||
└── web_ui.html # Web interface
|
||||
```
|
||||
|
||||
## Important Configuration Files
|
||||
|
||||
### New Environment System
|
||||
- **`config/development.env`** - Development environment API keys and Flask configuration
|
||||
- **`config/production.env`** - Production environment API keys and Flask configuration
|
||||
- **`config/.env.template`** - Template for creating new environment configurations
|
||||
- **`scripts/run-local.sh`** - Local development startup script
|
||||
- **`scripts/test-system.sh`** - Comprehensive system validation script
|
||||
- **`scripts/deploy-to-prod.sh`** - Production deployment script
|
||||
- **`DEV_PROD_SETUP.md`** - Detailed setup and deployment guide
|
||||
|
||||
### Core Application Files
|
||||
- **`config.env`** - Legacy API keys and Flask configuration (DEPRECATED but still supported)
|
||||
- **`requirements.txt`** - Python dependencies for OpenAI, Google AI, Flask, PIL, PyMuPDF
|
||||
- **`profiles/*.json`** - QC check configurations with weights and LLM assignments
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
### LLM Configuration (`llm_config.py`)
|
||||
- Manages OpenAI GPT-4 and Google Gemini API interactions
|
||||
- Handles model switching and error handling
|
||||
- Converts images to base64 for API consumption
|
||||
|
||||
### Profile System (`profile_config.py`)
|
||||
- Dynamically discovers available QC checks
|
||||
- Loads profile configurations from JSON files
|
||||
- Maps checks to specific LLM models
|
||||
|
||||
### Parallel Processing Architecture
|
||||
- Uses ThreadPoolExecutor for concurrent API calls
|
||||
- Batches of 15 checks for optimal performance
|
||||
- Real-time progress tracking with batch indicators
|
||||
|
||||
## Authentication System
|
||||
|
||||
### MSAL/PKCE Implementation
|
||||
The application implements Microsoft Authentication Library (MSAL) with Proof Key for Code Exchange (PKCE) flow for secure user authentication:
|
||||
|
||||
- **Frontend**: MSAL Browser Library v2.38.3+ with popup-based authentication
|
||||
- **Backend**: Python JWT validation using PyJWT library
|
||||
- **Session Management**: httpOnly cookies with security flags
|
||||
- **Token Validation**: Real-time validation against Azure AD JWKS
|
||||
|
||||
### Authentication Components
|
||||
|
||||
#### Core Files
|
||||
- **`jwt_validator.py`** - Azure AD JWT token validation with JWKS verification
|
||||
- **`auth_middleware.py`** - Flask authentication middleware with httpOnly cookie management
|
||||
- **Authentication endpoints** in `api_server.py` - `/auth/login`, `/auth/logout`, `/auth/status`
|
||||
- **Frontend integration** in `web_ui.html` - MSAL configuration and popup authentication
|
||||
|
||||
#### Configuration Requirements
|
||||
```bash
|
||||
# Required environment variables in config.env
|
||||
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
```
|
||||
|
||||
#### Dependencies
|
||||
- PyJWT>=2.8.0 for JWT token validation
|
||||
- cryptography>=41.0.0 for cryptographic operations
|
||||
- requests for HTTPS calls to Azure AD endpoints
|
||||
|
||||
### Protected Endpoints
|
||||
The following API endpoints require authentication:
|
||||
- `/api/start_analysis` - File analysis initiation
|
||||
- `/api/analyze` - Smart analysis with triage
|
||||
- `/api/process_file` - Direct file processing
|
||||
- `/api/process_triaged_file` - Triaged file processing
|
||||
- `/api/profiles` (POST/PUT/DELETE) - Profile management
|
||||
- `/api/brand_guidelines` (POST/DELETE) - Brand guidelines management
|
||||
|
||||
### Authentication Flow
|
||||
1. **Frontend**: User clicks "Sign In with Microsoft" → MSAL popup authentication
|
||||
2. **Azure AD**: User authenticates → Authorization code with PKCE validation
|
||||
3. **Token Exchange**: MSAL exchanges code for ID/access tokens
|
||||
4. **Server Validation**: Python validates JWT against Azure AD JWKS
|
||||
5. **Session Creation**: Valid tokens stored in httpOnly cookies
|
||||
6. **API Access**: Authenticated requests include cookie for validation
|
||||
|
||||
### Security Features
|
||||
- **httpOnly Cookies**: Prevent XSS access to authentication tokens
|
||||
- **PKCE Flow**: Enhanced security for single-page applications
|
||||
- **Real-time Validation**: Every request validates token against Azure AD
|
||||
- **Secure Headers**: Cookies use Secure, SameSite=Lax flags
|
||||
- **Server-side Validation**: No client-side security dependencies
|
||||
|
||||
## QC Profile System
|
||||
|
||||
### Available Profiles
|
||||
|
||||
The system includes 14 focused QC profiles designed for different use cases:
|
||||
|
||||
1. **General Check** (10 checks, 100-point scale)
|
||||
- Purpose: Streamlined general-purpose QC analysis
|
||||
- Checks: Essential design and technical standards
|
||||
- Weighting: Even distribution (10% each)
|
||||
- Requirements: No reference assets needed
|
||||
- Scoring: Individual scores 1-10, final score 0-100
|
||||
|
||||
2. **Static General** (10 checks, 100-point scale)
|
||||
- Purpose: Comprehensive digital static asset QC
|
||||
- Checks: Text readability, contrast, language, hierarchy, alignment, product/logo visibility, CTA, accessibility, inclusive
|
||||
- Used by: All clients as a baseline profile
|
||||
|
||||
3. **Unilever Key Visual** (15 checks, 120-point scale)
|
||||
- Purpose: Unilever brand guidelines for key visual materials
|
||||
- Special Logic: Bonus checks with zero-scoring for missing elements
|
||||
- Requirements: Brand guidelines recommended
|
||||
- Scoring: Weighted distribution, 120-point maximum
|
||||
|
||||
4. **Unilever Packaging** (17 checks)
|
||||
- Purpose: Unilever packaging design standards
|
||||
- Requirements: Brand guidelines recommended
|
||||
|
||||
5. **Diageo Key Visual** (11 checks)
|
||||
- Purpose: Diageo brand guidelines for key visuals
|
||||
- Requirements: Brand guidelines recommended
|
||||
|
||||
6. **Diageo Packaging** (13 checks)
|
||||
- Purpose: Diageo packaging design standards
|
||||
- Requirements: Brand guidelines recommended
|
||||
|
||||
7. **L'Oreal Static** (3 checks, 100-point scale)
|
||||
- Purpose: Focused L'Oreal QC for digital static marketing materials
|
||||
- Checks: language_consistency, text_readability, background_contrast
|
||||
- Scoring: Equal weight distribution (3.33 each), any individual check <6 = overall Fail
|
||||
- Note: text_readability scores 7/10 neutral for product-only shots (no marketing text)
|
||||
- Note: background_contrast focuses on actual visibility, not theoretical colour similarity
|
||||
|
||||
8. **Amazon Static** (6 checks, 100-point scale)
|
||||
- Purpose: Amazon ASD 2025 design guidelines compliance
|
||||
- Checks: Required elements, logo/country compliance, typography, headline layout, margins, box placement
|
||||
- Requirements: Guidelines embedded in check prompts from ASD 2025 PDF
|
||||
- Scoring: Weighted distribution (element/logo checks weighted higher)
|
||||
|
||||
9. **Inclusive Accessibility** (2 checks)
|
||||
- Purpose: Focused accessibility compliance
|
||||
- Checks: Accessibility and inclusive design
|
||||
- Requirements: No reference assets needed
|
||||
|
||||
### Client Configuration
|
||||
|
||||
| Client | Display Name | Profiles |
|
||||
|--------|-------------|----------|
|
||||
| diageo | Diageo | diageo_key_visual, diageo_packaging, static_general, video_general |
|
||||
| unilever | Unilever | unilever_key_visual, unilever_packaging, static_general, video_general |
|
||||
| loreal | L'Oreal | loreal_static, static_general, video_general |
|
||||
| amazon | Amazon | amazon_static, static_general, video_general |
|
||||
| boots | Boots | boots_static, static_general, video_general |
|
||||
| dow_jones | Dow Jones | dow_jones_static, marketwatch_static, wsj_static, static_general, video_general |
|
||||
| honda | Honda | static_general, video_general |
|
||||
| axa | AXA | static_general, video_general |
|
||||
| rank | Rank | static_general, video_general |
|
||||
| general | General / Other | static_general, video_general, inclusive_accessibility |
|
||||
|
||||
### Profile Selection Guidelines
|
||||
|
||||
- **General content analysis**: Use Static General or General Check
|
||||
- **Brand-specific analysis**: Use appropriate brand profile
|
||||
- **Amazon ASD 2025 compliance**: Use Amazon Static
|
||||
- **Dow Jones corporate**: Use Dow Jones Static
|
||||
- **MarketWatch assets**: Use MarketWatch Static
|
||||
- **WSJ assets**: Use WSJ Static
|
||||
- **Accessibility focus**: Use Inclusive Accessibility
|
||||
- **Mixed requirements**: Profiles can be combined in multi-profile analysis
|
||||
|
||||
## Recent System Enhancements
|
||||
|
||||
### Unilever Profile-Specific Scoring Logic
|
||||
The **Unilever Key Visual** profile now implements specialized scoring logic for enhanced quality control:
|
||||
|
||||
#### Zero-Score Implementation
|
||||
- **Face Visibility Check**: Automatically sets score to 0 when `face_present` = false in JSON response
|
||||
- **New Visibility Check**: Automatically sets score to 0 when `new_present` = false in JSON response
|
||||
- **Face Gaze Direction Check**: Automatically sets score to 0 when `face_present` = false in JSON response
|
||||
|
||||
#### Implementation Details (`api_server.py:extract_score_from_result()`)
|
||||
```python
|
||||
# Unilever Key Visual profile specific logic
|
||||
if (profile_config and profile_config.get('name') == 'Unilever Key Visual' and
|
||||
check_name in ['face_visibility', 'new_visibility', 'face_gaze_direction']):
|
||||
|
||||
# Check for zero score conditions based on missing elements
|
||||
if check_name == 'face_visibility' and json_data.get('face_present') == False:
|
||||
return 0
|
||||
elif check_name == 'new_visibility' and json_data.get('new_present') == False:
|
||||
return 0
|
||||
elif check_name == 'face_gaze_direction' and json_data.get('face_present') == False:
|
||||
return 0
|
||||
```
|
||||
|
||||
This ensures that missing critical elements (faces, "new" text) result in zero scores, providing more stringent quality control for Unilever key visual assets.
|
||||
|
||||
### Scoring System Enhancements
|
||||
The scoring calculation system has been improved to handle different profile weight structures correctly:
|
||||
|
||||
#### Multi-Scale Scoring Support
|
||||
- **100-Point Scale**: General Check profile with total weight 10.0 uses direct weighted scores
|
||||
- **Other Scales**: Profiles with lower total weights use scaled scoring (weighted_score × 10)
|
||||
- **Brand-Specific Scales**: Unilever Key Visual uses 120-point maximum scale
|
||||
|
||||
#### Fixed Calculation Logic (`api_server.py`)
|
||||
```python
|
||||
# Smart scoring calculation based on profile weight structure
|
||||
if total_weight >= 10.0:
|
||||
overall_score = total_weighted_score # Direct score for high-weight profiles
|
||||
else:
|
||||
overall_score = total_weighted_score * 10 # Scale up for traditional profiles
|
||||
```
|
||||
|
||||
#### JSON Response Merging
|
||||
Enhanced JSON extraction to merge multiple JSON blocks from LLM responses:
|
||||
- Combines metadata (face_present, new_present) with scoring data
|
||||
- Enables proper bonus check logic for Unilever profiles
|
||||
- Maintains backward compatibility with single JSON responses
|
||||
|
||||
### Enhanced Saved Files Management
|
||||
The output file system has been significantly improved for better user experience:
|
||||
|
||||
#### Automatic Date Sorting (`api_server.py:list_output_files()`)
|
||||
- Files now automatically sorted by creation date (newest first)
|
||||
- Backend sorts using file timestamps before sending to frontend
|
||||
- No more manual sorting needed in the UI
|
||||
|
||||
#### Smart Refresh System (`web_ui.html`)
|
||||
- **Progressive Retry Mechanism**: Attempts refresh at 1s, 3s, and 5s intervals after analysis
|
||||
- **File Count Detection**: Compares before/after file counts to detect new files
|
||||
- **Early Success Exit**: Stops retrying immediately when new files are detected
|
||||
- **Visual Loading Indicators**: Shows "🔄 Checking for new files..." during refresh
|
||||
- **New File Highlighting**: Latest files highlighted with green background and "NEW" badge
|
||||
- **Auto-cleanup**: Visual highlights fade after 5 seconds
|
||||
|
||||
#### Implementation Features
|
||||
```javascript
|
||||
// Enhanced refresh with progressive delays
|
||||
const refreshAttempts = [1000, 3000, 5000]; // 1s, 3s, 5s delays
|
||||
|
||||
// Visual feedback for new files
|
||||
displaySavedFiles(data.files, shouldHighlight);
|
||||
|
||||
// Smart detection logic
|
||||
if (newFileCount > previousFileCount) {
|
||||
console.log('New file(s) detected, refresh complete');
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### MSAL Authentication System Improvements
|
||||
Enhanced the Microsoft Authentication Library implementation for better reliability:
|
||||
|
||||
#### Robust Error Handling (`web_ui.html`)
|
||||
- **MSAL Initialization Check**: Validates MSAL library loaded before initialization
|
||||
- **Authentication State Tracking**: `msalInitialized` flag prevents undefined access
|
||||
- **Fallback CDN Support**: Secondary CDN source if primary fails to load
|
||||
- **User-Friendly Error Messages**: Clear error messages when authentication unavailable
|
||||
|
||||
#### Enhanced Security
|
||||
```javascript
|
||||
// Safe authentication with validation
|
||||
if (!msalInitialized || !myMSALObj) {
|
||||
console.error('MSAL not initialized properly');
|
||||
alert('Authentication system not available. Please check your connection.');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
#### MSAL Concurrent Sign-In Protection
|
||||
Fixed interaction_in_progress error by implementing concurrent sign-in prevention:
|
||||
- **Sign-In Flag**: `isSigningIn` flag prevents multiple simultaneous authentication attempts
|
||||
- **Storage Cleanup**: Clears MSAL localStorage/sessionStorage before authentication to remove stuck state
|
||||
- **Proper Reset**: Uses finally block to reset flag on both success and failure
|
||||
|
||||
```javascript
|
||||
let isSigningIn = false; // Prevent concurrent sign-in attempts
|
||||
|
||||
async function signIn() {
|
||||
if (isSigningIn) {
|
||||
console.log('Sign-in already in progress, ignoring duplicate request');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSigningIn = true;
|
||||
// Clear any pending MSAL interactions
|
||||
localStorage.removeItem('msal.interaction.status');
|
||||
sessionStorage.removeItem('msal.interaction.status');
|
||||
// ... authentication logic
|
||||
} finally {
|
||||
isSigningIn = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Tracking and Reporting System (NEW)
|
||||
The system now includes comprehensive usage tracking and report generation capabilities:
|
||||
|
||||
#### Usage Tracking Features
|
||||
- **Automatic Logging**: All analyses automatically logged with detailed metadata
|
||||
- **Cost Estimation**: Real-time cost estimates based on LLM usage (OpenAI & Gemini)
|
||||
- **User Activity**: Track which users perform analyses and their usage patterns
|
||||
- **Client Breakdown**: Usage statistics per client (diageo, unilever, loreal, general)
|
||||
- **Profile Usage**: Track which profiles are most frequently used
|
||||
- **Daily Logs**: Usage data stored in daily JSONL files for easy processing
|
||||
|
||||
#### Usage Report Generator (`backend/generate_usage_report.py`)
|
||||
Command-line tool to generate comprehensive usage reports:
|
||||
|
||||
```bash
|
||||
# Generate report for last 7 days
|
||||
python backend/generate_usage_report.py --last-days 7
|
||||
|
||||
# Generate monthly report
|
||||
python backend/generate_usage_report.py --last-days 30 --output monthly_report.txt
|
||||
|
||||
# Filter by specific client
|
||||
python backend/generate_usage_report.py --client diageo --last-days 30
|
||||
|
||||
# Generate CSV for Excel
|
||||
python backend/generate_usage_report.py --last-days 30 --format csv --output report.csv
|
||||
|
||||
# Generate JSON for API integration
|
||||
python backend/generate_usage_report.py --last-days 30 --format json --output report.json
|
||||
```
|
||||
|
||||
**Report Sections**:
|
||||
- Summary: Total analyses, checks, estimated costs, averages
|
||||
- By Client: Usage breakdown per client with top profiles
|
||||
- By User: Individual user statistics and activity
|
||||
- By Profile: Profile usage across clients
|
||||
- By Date: Daily breakdown of activity and costs
|
||||
|
||||
**Output Formats**: Text (human-readable), JSON (machine-readable), CSV (spreadsheet)
|
||||
|
||||
**Documentation**: See `backend/USAGE_REPORTS.md` for detailed usage guide
|
||||
|
||||
#### Usage Log Storage
|
||||
- **Location**: `backend/usage_logs/`
|
||||
- **Format**: JSONL (JSON Lines) - one log entry per line
|
||||
- **Naming**: `YYYY-MM-DD.jsonl` (daily files)
|
||||
- **Retention**: Logs kept indefinitely (consider archiving after 1 year)
|
||||
|
||||
### Profile Auto-Versioning System (NEW)
|
||||
The system now implements automatic version control when profiles are edited:
|
||||
|
||||
#### How It Works
|
||||
1. **Original Profile**: `my_profile.json` (version 1)
|
||||
2. **First Edit**: Creates `my_profile_v2.json` (version 2), keeps original unchanged
|
||||
3. **Second Edit**: Creates `my_profile_v3.json` (version 3), keeps v1 and v2 unchanged
|
||||
4. **Client Configs**: Automatically updated to use latest version
|
||||
|
||||
#### Benefits
|
||||
- ✅ **Safety**: Original profiles never overwritten
|
||||
- ✅ **History**: Complete version history preserved
|
||||
- ✅ **Rollback**: Easy to revert to previous versions
|
||||
- ✅ **Audit Trail**: Track who made changes and when
|
||||
- ✅ **Testing**: Test new versions without affecting production
|
||||
|
||||
#### Version Metadata
|
||||
Each profile version includes:
|
||||
- `version`: Version number (1, 2, 3, ...)
|
||||
- `created_at`: ISO timestamp of creation
|
||||
- `created_by`: Email of user who created profile
|
||||
- `modified_at`: ISO timestamp of last modification (if edited)
|
||||
- `modified_by`: Email of user who edited profile
|
||||
- `previous_version`: Profile ID of previous version (if edited)
|
||||
|
||||
#### API Behavior
|
||||
- **POST /api/profiles**: Creates new profile with version 1
|
||||
- **PUT /api/profiles/<id>**: Creates new version automatically
|
||||
- **DELETE /api/profiles/<id>**: Deletes specific version only
|
||||
- **GET /api/profiles**: Returns all versions (filtered by client visibility)
|
||||
|
||||
**Documentation**: See `backend/PROFILE_MANAGEMENT.md` for detailed usage guide
|
||||
|
||||
### Profile Visibility Control System (NEW)
|
||||
Profiles can now be configured with granular visibility settings:
|
||||
|
||||
#### Visibility Options
|
||||
|
||||
**1. All Clients (Default)**
|
||||
```json
|
||||
{
|
||||
"visibility": "all",
|
||||
"visible_to_clients": []
|
||||
}
|
||||
```
|
||||
Profile visible to all clients in the system (diageo, unilever, loreal, general).
|
||||
|
||||
**2. Client-Specific**
|
||||
```json
|
||||
{
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["diageo", "unilever"]
|
||||
}
|
||||
```
|
||||
Profile visible only to specified clients.
|
||||
|
||||
#### Use Cases
|
||||
- **All Clients**: General-purpose profiles, standard QC checks, accessibility compliance
|
||||
- **Client-Specific**: Brand-specific profiles, custom checks, confidential QC criteria
|
||||
|
||||
#### Implementation
|
||||
- **Profile Creation**: Set visibility during creation via API or Web UI
|
||||
- **Client Filtering**: Users only see profiles available to their selected client
|
||||
- **Dynamic Loading**: `client_config.py` automatically updated based on visibility
|
||||
- **Backward Compatible**: Existing profiles default to "all" visibility
|
||||
|
||||
#### Web UI Integration
|
||||
- **Create Profile**: Checkbox for "Reveal to All Clients"
|
||||
- Checked: Visible to all clients
|
||||
- Unchecked: Show client selector for specific clients
|
||||
- **Profile List**: Shows visibility status with icons
|
||||
- 🌍 All Clients
|
||||
- 🔒 Specific Clients (with client list)
|
||||
|
||||
**Available Client IDs**: `diageo`, `unilever`, `loreal`, `amazon`, `boots`, `general`
|
||||
|
||||
**Documentation**: See `backend/PROFILE_MANAGEMENT.md` for detailed configuration guide
|
||||
|
||||
### Amazon ASD 2025 QC Tools
|
||||
Six specialized checks for Amazon Sale Day design compliance, with guidelines from the ASD 2025 PDF embedded directly in each tool's prompt:
|
||||
|
||||
| Tool | What it checks |
|
||||
|------|---------------|
|
||||
| `amazon_required_elements` | All required elements present (Headline, Box, Subhead, Date, Legal line) |
|
||||
| `amazon_logo_country` | Correct Amazon/URL logo per country (established vs emerging locales) |
|
||||
| `amazon_typography` | Ember Modern Standard Display font, leading/tracking, size ratios (subhead 30-60%, date 20-45%), ligatures |
|
||||
| `amazon_headline_layout` | Headline left-aligned, largest element, natural line splits |
|
||||
| `amazon_margins` | 7% shortest side (10% wide, 20%/10% very wide+small formats) |
|
||||
| `amazon_element_placement` | Element placement (box, bag, logo), positioning rules, cropping rules (tape NEVER cropped) |
|
||||
|
||||
### Client-Scoped Reporting Dashboard
|
||||
Reporting has been moved from the Settings modal into a dedicated "Reporting" tab within each client's main view:
|
||||
|
||||
- **Date range filtering**: Start/end date pickers for custom report periods
|
||||
- **Summary cards**: Total Analyses, Unique Users, Total Checks Run, Estimated Cost
|
||||
- **Detail table**: Per-analysis breakdown with date, user, profile, checks, score, cost
|
||||
- **Client isolation**: Reports only show data for the currently selected client
|
||||
- **API endpoint**: `GET /api/client_usage_stats?client={id}&start_date={}&end_date={}`
|
||||
|
||||
### Admin Panel
|
||||
View-only administration panel for platform user management:
|
||||
|
||||
- **Access**: Dedicated "Admin" button in header, visible only to admin users
|
||||
- **Full page**: Separate section (not a popup), with "Back to App" navigation
|
||||
- **Summary stats**: Total Users, Total Platform Analyses, Total Estimated Cost
|
||||
- **User table**: Name, Email, Analyses, Total Checks, Clients Used, Last Active, Est. Cost
|
||||
- **Admin config**: `ADMIN_USERS` list in `backend/client_config.py`
|
||||
- **API endpoints**: `GET /api/admin/check`, `GET /api/admin/users`
|
||||
|
||||
### User Login Tracking
|
||||
All authenticated user visits are now logged:
|
||||
|
||||
- **Event type**: `user_login` logged on every `/auth/status` check
|
||||
- **Data captured**: user_id, user_email, user_name, timestamp
|
||||
- **Storage**: Same JSONL usage logs in `backend/usage_logs/`
|
||||
- **Purpose**: Enables admin panel to show all users who have visited, not just those who ran analyses
|
||||
|
||||
### PDF Reference Asset Processing
|
||||
Multi-page PDF brand guidelines are now fully processed on upload:
|
||||
|
||||
- **Text extraction**: All pages extracted using PyMuPDF (`pdf_processor.py`)
|
||||
- **LLM summarization**: Extracted text sent to Gemini 2.5 Pro for structured brand guidelines summary (2000-4000 words covering colors, typography, layout, do's/don'ts, QC specs)
|
||||
- **Cover image**: Page 1 extracted as PNG for visual reference in QC checks
|
||||
- **Storage**: `{file_id}_summary.txt` and `{file_id}_cover.png` in `brand_guidelines/files/`
|
||||
- **QC integration**: Summary text included in check prompts, cover image sent as visual reference
|
||||
- **Fallback chain**: LLM summary → raw text (8000 chars) → inline extraction → metadata only
|
||||
- **Auto-backfill**: Existing unprocessed PDFs processed on server startup
|
||||
- **API endpoints**: `GET /api/brand_guidelines/<id>/status`, `POST /api/brand_guidelines/<id>/reprocess`
|
||||
|
||||
### Media Plan System
|
||||
Excel media plans can be uploaded per client for automatic asset validation:
|
||||
|
||||
- **Upload**: Settings → Media Plan tab, accepts .xlsx/.xls files
|
||||
- **Parsing**: Extracts asset specs from all channel sheets (Display, OLV, OOH, TV, Print, Audio) using openpyxl
|
||||
- **Filename matching**: Automatic fuzzy matching (exact → case-insensitive → starts-with → contains → fuzzy >70%)
|
||||
- **Validation**: Checks uploaded asset dimensions and file type against media plan spec
|
||||
- **QC context**: Matched asset metadata (country, language, placement, vendor, dimensions) injected into all check prompts
|
||||
- **Storage**: `backend/media_plans/` directory with parsed JSON cache
|
||||
- **API endpoints**: `POST /api/media_plan`, `GET /api/media_plan?client={id}`, `DELETE /api/media_plan/<client_id>`
|
||||
- **Module**: `media_plan_processor.py` - `parse_media_plan()`, `find_matching_asset()`, `validate_asset_specs()`, `build_media_plan_context()`
|
||||
|
||||
### User Access Control System
|
||||
Default-deny per-user client access, with admin grant/revoke via the admin panel's User Access tab. Enforced server-side on every client-scoped endpoint.
|
||||
|
||||
**Storage:** `backend/user_access.json` — auto-bootstrapped on first server start with `nick.viljoen@brandtech.plus` as the sole admin. Never commit this file (it's in `.gitignore`).
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"default_clients": ["general"],
|
||||
"admins": ["nick.viljoen@brandtech.plus"],
|
||||
"users": {
|
||||
"alice@example.com": {
|
||||
"clients": ["general", "diageo"],
|
||||
"updated_at": "2026-04-22T14:30:00Z",
|
||||
"updated_by": "nick.viljoen@brandtech.plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Module:** `backend/user_access.py`
|
||||
- `get_user_clients(email)` — returns granted clients (admins see all)
|
||||
- `set_user_clients(email, clients, actor_email)` — grant/revoke; validates against client_config
|
||||
- `is_admin(email)` — used everywhere; `client_config.is_admin` now delegates here
|
||||
- `promote_admin(email, actor)` / `demote_admin(email, actor)` — demote blocked if last admin
|
||||
- `list_access_entries()` — for the admin panel
|
||||
|
||||
**Enforcement points in `api_server.py`:**
|
||||
- `GET /api/clients` — returns only clients the user can see (admins see all)
|
||||
- `_require_admin()` helper — gates the 4 `/api/admin/user_access*` endpoints
|
||||
- `_require_client_access(client_id)` helper — applied to `start_analysis`, `output_files`, `media_plan` (GET/POST/DELETE), `client_usage_stats`, `/output/<client>/<filename>`. Returns 403 with `"code": "client_access_denied"` on denial.
|
||||
|
||||
**Audit trail:** `log_access_change(audit_entry)` in `usage_tracker.py` writes `event: "access_change"` records into the daily JSONL usage logs. Captures actor, target, action (grant/revoke/promote_admin/demote_admin), and clients_before/after.
|
||||
|
||||
**Frontend (`web_ui.html`):** Admin panel has two tabs — Usage Overview and User Access. Access tab: searchable user table, inline editor with per-client checkboxes, admin toggle, + Add User (pre-grants access before someone has signed in). `handleClientAccessDenied()` helper bounces revoked users back to the client picker with a red toast.
|
||||
|
||||
### Self-service Client Access Requests
|
||||
A "Request Client Access" tile on the client picker lets signed-in users ask admins for additional client access without going through Slack/email side-channels.
|
||||
|
||||
- **Tile:** appended after the user's existing client tiles in `populateClientSelector()` (web_ui.html). Always visible — if the user already has every client, the modal short-circuits with a friendly "you already have everything" alert.
|
||||
- **Modal:** auto-fills name + email from `currentUser` (read-only — identity always taken from the verified MSAL session, never the body), checkbox list of clients the user does **not** already have, optional reason textarea.
|
||||
- **Endpoints (`api_server.py`):**
|
||||
- `GET /api/all_clients` — auth-required, returns the full client catalogue so the form can offer clients the user can't currently see.
|
||||
- `POST /api/access_request` — auth-required. Validates requested client IDs, looks up admin recipients via `user_access.list_access_entries()`, sends a plaintext + HTML email through `email_service.send_email()` with `Reply-To` set to the requester. Logs an `access_request` event to the daily JSONL usage log via `usage_tracker.log_access_request()`. Returns 502 if email delivery fails (request still logged with `email_sent: false`).
|
||||
- **Email transport (`backend/email_service.py`):** thin SMTP wrapper using STARTTLS. Reads `SMTP_SERVER`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SENDER_EMAIL` from env. Currently wired to Mailgun via the `twist@mail.dev.oliver.solutions` SMTP user.
|
||||
|
||||
### Settings Modal UX (Apr 2026)
|
||||
- **Reference Assets tab:** the Brand Name + Tags + Description form was collapsed to a single "Name" field. The user-entered name is what now drives the dropdown label on the main configuration page (falls back to `original_filename` for legacy records that pre-date the change).
|
||||
- **Media Plan tab:** added a "Name" field. The backend stores `display_name` on the media plan record; both the active-plan card and the main-page dropdown prefer `display_name` and fall back to `original_filename` for old plans.
|
||||
- **Modal footer is context-aware:** "Save Profile" + "Cancel" show only on the Profile / Create Profile tabs. Reference Assets / QC Tools / Media Plan tabs show a single green "Save" button that simply closes the modal — the upload buttons within those tabs are the actual save action.
|
||||
|
||||
## Deployment Environments
|
||||
|
||||
| Env | URL | Branch tracked | Server | Service | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| Local | `http://localhost:7183` | any | your laptop | none (Flask dev) | — |
|
||||
| Dev | `https://optical-dev.oliver.solutions/ai_qc/` | `develop` | `optical-production-dev` (GCP VM, europe-west2-b) | `ai-qc.service` | **Live** |
|
||||
| Prod | `https://optical-prod.oliver.solutions/ai_qc/` | tags on `main` | `optical-production` (GCP VM, europe-west2-c) | `ai-qc.service` | **Live** (currently `v1.1.0`) |
|
||||
| Legacy sandbox | older URL | `main` (direct) | older VM, runs as `www-data` | `ai_qc.service` | Still alive as fallback |
|
||||
|
||||
Both new-style envs (dev + prod):
|
||||
- App lives at `/opt/ai_qc`, runs as `nick.viljoen`
|
||||
- systemd unit `ai-qc.service` running Waitress on `127.0.0.1:7183`
|
||||
- Apache reverse-proxy include at `/opt/ai_qc/deploy/apache-ai-qc.conf`, pulled into the main `optical-dev.oliver.solutions.conf` vhost
|
||||
- TLS terminated at the GCP load balancer (no certbot on the box)
|
||||
- Each server has its own SSH key for Bitbucket pulls (kept in `~/.ssh/bitbucket_ai_qc`, host alias `bitbucket-ai-qc`)
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
- **`develop`** = what's deployed to the dev server. Push to `develop` → run `deploy.sh dev` on optical-dev.
|
||||
- **`main`** = what's deployed to prod. Never push directly; merge `develop → main` via PR, then tag (`v1.0.0`). Deploy the tag with `deploy.sh prod v1.0.0`.
|
||||
- **Feature branches** (`feature/<name>`) branch from `main`, PR into `develop`. Keep merged feature branches around as history or delete once main catches up.
|
||||
|
||||
## Deploy Scripts
|
||||
|
||||
All in `backend/scripts/`, run on the target server:
|
||||
|
||||
| Script | Usage | What it does |
|
||||
|---|---|---|
|
||||
| `deploy.sh dev` | `backend/scripts/deploy.sh dev [--dry-run]` | Fetch, show diff, confirm, `git reset --hard origin/develop`, pip install if `requirements.txt` changed, `sudo systemctl restart ai-qc.service`, smoke test via `/health`, auto-rollback on failure |
|
||||
| `deploy.sh prod <tag>` | `backend/scripts/deploy.sh prod v1.2.0 [--dry-run]` | Same flow but checks out a specific tag |
|
||||
| `rollback.sh` | `backend/scripts/rollback.sh last` or `... <commit-hash>` | Revert to the checkpoint written by the most recent deploy, or to any specific commit |
|
||||
| `health-check.sh` | `backend/scripts/health-check.sh` | One-line "is the app alive?" — `curl /health`, exits 0/1 |
|
||||
|
||||
The deploy script writes the pre-deploy HEAD to `.last_deploy_rollback` in the app dir before changing anything, so `rollback.sh last` always knows where to go back to.
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Critical Production Issues and Solutions
|
||||
|
||||
#### Issue 1: Web UI 404 Error ("Web UI not found")
|
||||
|
||||
**Symptom**: Backend API runs successfully, but accessing the root URL returns `{"error":"Web UI not found"}` with 404 status.
|
||||
|
||||
**Root Cause**: The `serve_web_ui()` function in both `api_server.py` and `backend/api_server.py` used relative path `'web_ui.html'` which only works when Flask starts from the project root directory. Production servers (Waitress, systemd) often run from different working directories.
|
||||
|
||||
**Solution**: Use absolute paths relative to the script location:
|
||||
|
||||
```python
|
||||
@app.route('/', methods=['GET'])
|
||||
def serve_web_ui():
|
||||
"""Serve the web UI"""
|
||||
try:
|
||||
# Root api_server.py - web_ui.html is in same directory
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
web_ui_path = os.path.join(base_dir, 'web_ui.html')
|
||||
|
||||
# Backend api_server.py - web_ui.html is in parent directory
|
||||
# base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# web_ui_path = os.path.join(os.path.dirname(base_dir), 'web_ui.html')
|
||||
|
||||
with open(web_ui_path, 'r') as f:
|
||||
html_content = f.read()
|
||||
return Response(html_content, mimetype='text/html')
|
||||
except FileNotFoundError:
|
||||
return jsonify({'error': 'Web UI not found'}), 404
|
||||
```
|
||||
|
||||
**Files Fixed**:
|
||||
- `/api_server.py` (line 1306-1310)
|
||||
- `/backend/api_server.py` (line 1306-1310)
|
||||
|
||||
#### Issue 2: Apache ProxyPass Not Working (Auth Endpoints 404)
|
||||
|
||||
**Symptom**: Backend accessible via localhost, but web URL returns 404 for `/auth/*` and other API endpoints. ProxyPass rules appear correct in Apache config.
|
||||
|
||||
**Root Cause**: Apache checks for static files/directories BEFORE applying ProxyPass rules. If a directory like `/var/www/html/ai_qc/` exists, Apache tries to serve files from that directory first and never triggers the ProxyPass rule.
|
||||
|
||||
**Solution**: Remove or rename static directory that matches ProxyPass path:
|
||||
|
||||
```bash
|
||||
# Check for conflicting static directory
|
||||
ls -la /var/www/html/ai_qc/
|
||||
|
||||
# Rename as backup (safer than deleting)
|
||||
sudo mv /var/www/html/ai_qc /var/www/html/ai_qc.backup.$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# Test that ProxyPass now works
|
||||
curl -I https://your-domain.com/ai_qc/auth/status
|
||||
```
|
||||
|
||||
**Apache ProxyPass Order**: Place more specific paths before general paths:
|
||||
|
||||
```apache
|
||||
# In /etc/apache2/apache2.conf or site config
|
||||
# More specific paths first
|
||||
ProxyPass /ai_qc/auth http://localhost:7183/auth
|
||||
ProxyPassReverse /ai_qc/auth http://localhost:7183/auth
|
||||
|
||||
# General path last
|
||||
ProxyPass /ai_qc http://localhost:7183
|
||||
ProxyPassReverse /ai_qc http://localhost:7183
|
||||
```
|
||||
|
||||
**Key Lesson**: When using Apache ProxyPass, do NOT create a static directory with the same name as the proxy path. The backend serves everything through the proxy.
|
||||
|
||||
#### Issue 3: MSAL Authentication "interaction_in_progress" Error
|
||||
|
||||
**Symptom**: Clicking "Sign In with Microsoft" throws `BrowserAuthError: interaction_in_progress` and authentication fails.
|
||||
|
||||
**Root Cause**:
|
||||
1. Multiple sign-in buttons (header and auth-required screen) could trigger concurrent authentication
|
||||
2. Previous failed authentication left MSAL state in localStorage/sessionStorage
|
||||
3. No protection against double-clicks on sign-in button
|
||||
|
||||
**Solution**: Implement concurrent sign-in protection (see MSAL section above)
|
||||
|
||||
**Testing After Fix**: Clear browser cache or use incognito window to test, as old JavaScript and MSAL state may be cached.
|
||||
|
||||
### Production Deployment Checklist
|
||||
|
||||
1. **Code Deployment**
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
git pull origin main
|
||||
sudo systemctl restart ai_qc.service
|
||||
```
|
||||
|
||||
2. **Verify No Static Directory Conflicts**
|
||||
```bash
|
||||
# Check for conflicting directories
|
||||
ls -la /var/www/html/ | grep ai_qc
|
||||
|
||||
# Should NOT exist if using ProxyPass
|
||||
```
|
||||
|
||||
3. **Test Backend Directly**
|
||||
```bash
|
||||
curl -I http://localhost:7183/
|
||||
curl -I http://localhost:7183/auth/status
|
||||
curl -I http://localhost:7183/health
|
||||
```
|
||||
|
||||
4. **Test Through Apache Proxy**
|
||||
```bash
|
||||
curl -I https://your-domain.com/ai_qc/
|
||||
curl -I https://your-domain.com/ai_qc/auth/status
|
||||
```
|
||||
|
||||
5. **Test in Browser**
|
||||
- Open in incognito/private window (avoids cache issues)
|
||||
- Verify web UI loads
|
||||
- Test Microsoft authentication
|
||||
- Upload and analyze a test file
|
||||
|
||||
6. **Monitor Logs**
|
||||
```bash
|
||||
# Flask application logs
|
||||
sudo journalctl -u ai_qc.service -f
|
||||
|
||||
# Apache logs
|
||||
sudo tail -f /var/log/apache2/ai_qc_ssl_error.log
|
||||
sudo tail -f /var/log/apache2/ai_qc_ssl_access.log
|
||||
```
|
||||
|
||||
### Common Production Issues
|
||||
|
||||
| Issue | Check | Solution |
|
||||
|-------|-------|----------|
|
||||
| 404 on web UI | `curl localhost:7183/` | Use absolute paths in serve_web_ui() |
|
||||
| 404 on /auth/* | Check `/var/www/html/ai_qc/` | Remove static directory conflicting with ProxyPass |
|
||||
| MSAL errors | Browser console | Clear browser cache, check concurrent sign-in protection |
|
||||
| Backend not starting | `systemctl status ai_qc` | Check Python environment, dependencies, port conflicts |
|
||||
| Permission errors | File ownership | Ensure www-data owns necessary directories |
|
||||
| Permission denied on new dirs | `git pull` resets ownership | `sudo chown -R www-data:www-data uploads output media_plans brand_guidelines usage_logs` |
|
||||
|
||||
## Pre-Session Completion Checklist
|
||||
Before ending any session, ALWAYS run these Python syntax and import checks:
|
||||
1. **Syntax Check**: Run `python -m py_compile **/*.py` to verify all Python files compile without syntax errors
|
||||
2. **Import Check**: Run `python -c "import api_server, llm_config, profile_config"` to verify core modules import successfully
|
||||
3. **Authentication Check**: Run `python -c "import jwt_validator, auth_middleware; print('Authentication modules imported successfully')"` to verify authentication system
|
||||
4. **QC Module Check**: Test import of any modified QC modules in `visual_qc_apps/`
|
||||
5. **Profile System Check**: Verify all 14 profiles load correctly:
|
||||
```bash
|
||||
python -c "
|
||||
from profile_config import get_profile
|
||||
profiles = ['general_check', 'static_general', 'unilever_key_visual', 'unilever_packaging', 'diageo_key_visual', 'diageo_packaging', 'loreal_static', 'amazon_static', 'boots_static', 'inclusive_accessibility', 'dow_jones_static', 'marketwatch_static', 'wsj_static', 'video_general']
|
||||
for p in profiles:
|
||||
profile = get_profile(p)
|
||||
print(f'✅ {profile.name} ({len(profile.get_enabled_checks())} checks)')
|
||||
"
|
||||
```
|
||||
6. **Client Config Check**: Verify all 10 clients load correctly:
|
||||
```bash
|
||||
python -c "
|
||||
from client_config import get_all_clients
|
||||
for cid, c in get_all_clients().items():
|
||||
print(f'✅ {c[\"display_name\"]}: {c[\"profiles\"]}')
|
||||
"
|
||||
```
|
||||
7. **Enhanced System Check**: Verify recent enhancements work correctly:
|
||||
- Test General Check profile 100-point scoring system
|
||||
- Test Unilever profile zero-scoring logic with face/new visibility checks
|
||||
- Test saved files are client-scoped (only show for selected client)
|
||||
- Test client-scoped reporting dashboard with date range filters
|
||||
- Test admin panel shows all platform users (admin users only)
|
||||
- Test MSAL authentication initialization and error handling
|
||||
- Verify scoring calculation handles different weight structures correctly
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
# Dow Jones — Archived 2026-05-14
|
||||
|
||||
**Reason:** Client offboarded. No longer using Visual AI QC.
|
||||
|
||||
## Contents
|
||||
|
||||
- `CLAUDE_DOW_JONES.md` — per-client documentation (originally at repo root)
|
||||
- `profiles/` — 4 brand profile JSONs (originally `backend/profiles/`)
|
||||
- `dow_jones_static.json`
|
||||
- `marketwatch_static.json`
|
||||
- `wsj_static.json`
|
||||
- `wsj_podcast.json`
|
||||
- `visual_qc_apps/` — 22 QC check directories (originally `backend/visual_qc_apps/`)
|
||||
- 6 × `dj_*` (corporate Dow Jones brand)
|
||||
- 6 × `mw_*` (MarketWatch sub-brand)
|
||||
- 6 × `wsj_*` (WSJ static)
|
||||
- 4 × `wsj_podcast_*` (WSJ podcast variants)
|
||||
|
||||
## Restoring
|
||||
|
||||
If Dow Jones returns:
|
||||
|
||||
1. Move `profiles/*.json` back to `backend/profiles/`.
|
||||
2. Move every `visual_qc_apps/<name>/` directory back to `backend/visual_qc_apps/<name>/`.
|
||||
3. Move `CLAUDE_DOW_JONES.md` back to the repo root.
|
||||
4. Re-add the client entry to `backend/client_config.py`:
|
||||
|
||||
```python
|
||||
'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'
|
||||
},
|
||||
```
|
||||
|
||||
5. Re-add the Dow Jones row to the client table in `CLAUDE.md` (repo root).
|
||||
6. Add `'dow_jones_static','marketwatch_static','wsj_static','wsj_podcast'` back to the inline profile list in the `CLAUDE.md` pre-session checklist.
|
||||
7. Restart the server.
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
{
|
||||
"name": "WSJ Podcast",
|
||||
"description": "Wall Street Journal Podcast asset QC profile. Evaluates podcast key-art and platform-asset compliance against the WSJ Podcasts template system: top-left two-line headline, bottom-right WSJ wordmark, safe area, format compliance, plus the underlying WSJ brand-system colour and typography rules. Includes the Dow Jones file-naming convention check.",
|
||||
"checks": {
|
||||
"wsj_podcast_headline_layout": {
|
||||
"enabled": true,
|
||||
"weight": 1.5,
|
||||
"llm": "Gemini",
|
||||
"description": "Top-left placement, max 2 lines, bold/light weight pairing, sans-serif WSJ headline grammar"
|
||||
},
|
||||
"wsj_podcast_logo_placement": {
|
||||
"enabled": true,
|
||||
"weight": 1.5,
|
||||
"llm": "Gemini",
|
||||
"description": "WSJ wordmark present and anchored bottom-right, in WSJ red / white / black with correct integrity"
|
||||
},
|
||||
"wsj_podcast_safe_area": {
|
||||
"enabled": true,
|
||||
"weight": 1.5,
|
||||
"llm": "Gemini",
|
||||
"description": "Headline, logo and key art sit inside the safe area for the asset's slot; nothing clipped or under UI chrome"
|
||||
},
|
||||
"wsj_podcast_format_compliance": {
|
||||
"enabled": true,
|
||||
"weight": 1.5,
|
||||
"llm": "Gemini",
|
||||
"description": "Aspect ratio matches an approved podcast slot (Apple, Spotify, WSJ Platform, House Ad, Social Static) and layout matches the expected structure"
|
||||
},
|
||||
"wsj_color_usage": {
|
||||
"enabled": true,
|
||||
"weight": 1.5,
|
||||
"llm": "Gemini",
|
||||
"description": "Heritage / Jewel / Pop colour system usage and accessible contrast"
|
||||
},
|
||||
"wsj_typography_hierarchy": {
|
||||
"enabled": true,
|
||||
"weight": 1.5,
|
||||
"llm": "Gemini",
|
||||
"description": "WSJ serif wordmark vs sans-serif headline pairing, size contrast and hierarchy"
|
||||
},
|
||||
"dj_file_naming": {
|
||||
"enabled": true,
|
||||
"weight": 1.0,
|
||||
"llm": "Internal",
|
||||
"description": "Dow Jones / OLIVER file naming convention compliance — deterministic regex check, no LLM call"
|
||||
}
|
||||
},
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["dow_jones"]
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"""
|
||||
Dow Jones / OLIVER File Naming Convention check.
|
||||
|
||||
This is a deterministic check, NOT an LLM check. The actual validation logic lives in
|
||||
backend/file_naming_validator.py. The check is intercepted in api_server.py:process_single_check
|
||||
(and the triage variant) before any LLM call is made — so the prompt below is never sent to
|
||||
a model. It exists only so:
|
||||
1. profile_config.discover_qc_checks() finds the check directory and registers the name
|
||||
2. Profile JSONs can include 'dj_file_naming' as a regular check entry
|
||||
3. The QC Tools UI can display a description for it
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
class DjFileNamingApp(FlaskAppTemplate):
|
||||
"""
|
||||
Dow Jones / OLIVER File Naming Convention check.
|
||||
Validates the asset filename against the playbook spec:
|
||||
[OMGID] - [Domain]-[Subteam?]-[Brand]-[Event/Initiative?]-[YY]-[Sequence]_[AssetName]_v##
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
prompt = (
|
||||
"This check is run deterministically by the file_naming_validator module — "
|
||||
"it does not consult an LLM. If you are reading this prompt as an LLM, the "
|
||||
"dispatch interception in api_server.py has failed and this check should be "
|
||||
"treated as a no-op."
|
||||
)
|
||||
super().__init__(__name__, prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app_instance = DjFileNamingApp()
|
||||
app_instance.run()
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
class WsjPodcastFormatComplianceApp(FlaskAppTemplate):
|
||||
"""
|
||||
WSJ Podcast - Format Compliance Check
|
||||
Verifies the asset is built to one of the approved WSJ podcast slot dimensions and that its
|
||||
aspect ratio / orientation matches the intended placement.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
prompt = """You are performing a visual quality-control check on a Wall Street Journal (WSJ) podcast marketing asset.
|
||||
|
||||
Your task is to determine whether the asset matches one of the approved WSJ podcast slot dimensions / aspect ratios, and whether its visual structure matches that intended placement.
|
||||
|
||||
APPROVED WSJ PODCAST SLOT SIZES:
|
||||
|
||||
Master / Key Art:
|
||||
- 3000x3000 (square key art master)
|
||||
|
||||
Apple Podcasts:
|
||||
- 2048x2732 (portrait Full Page Show Art)
|
||||
- 4320x1080 (horizontal hero)
|
||||
- 1080x1920 (Apple Asset, vertical)
|
||||
|
||||
Spotify:
|
||||
- 1032x672 (Spotify standard)
|
||||
- 630x920 (Spotify card)
|
||||
- 750x760 (Spotify playlist tile)
|
||||
- 2660x1496 (Spotify playlist hero)
|
||||
|
||||
WSJ Platform thumbnails:
|
||||
- 1280x640
|
||||
- 1280x853
|
||||
- 2305x1538
|
||||
- 220x220
|
||||
- 207x138
|
||||
- 287x191
|
||||
|
||||
House Ads (display):
|
||||
- 970x250 (Billboard)
|
||||
- 300x250 (MPU)
|
||||
- 300x600 (Double MPU)
|
||||
- 728x90 (Leaderboard)
|
||||
- 160x600 (skyscraper)
|
||||
- 320x50 (mobile banner)
|
||||
|
||||
Social Static:
|
||||
- 1080x1080 (square)
|
||||
- 1080x1920 (story / vertical)
|
||||
- 1920x1080 (horizontal video frame / social landscape)
|
||||
- 400x500 (Owned Static)
|
||||
|
||||
VISUAL STRUCTURE EXPECTATIONS:
|
||||
- Square assets (1:1) should follow the key-art layout: top-left headline, bottom-right WSJ logo, photographic or illustrative center.
|
||||
- Vertical assets (e.g. 1080x1920, 2048x2732) should follow the same key-art layout but with more vertical breathing room and may include "Listen Now" calls to action below the key art.
|
||||
- Wide / landscape assets (e.g. 4320x1080, 970x250) should split into a key-art tile on one side + headline / supporting copy on the other side, OR present an extended horizontal layout with the WSJ wordmark anchored in a logical right-aligned position.
|
||||
- Tall thin formats (160x600, 300x600) need the headline at top, key art in the middle, WSJ wordmark at the bottom.
|
||||
|
||||
WHAT YOU'RE LOOKING FOR:
|
||||
- Does the asset's apparent dimension and orientation match a recognised slot above?
|
||||
- Does its layout match the structural expectation for that slot?
|
||||
- Is the asset stretched / squashed / built for the wrong aspect ratio (e.g. a square key art being used in a horizontal billboard slot without re-layout)?
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Estimate the asset's aspect ratio and approximate dimensions from the image.
|
||||
2. Match it to the closest approved slot from the list above.
|
||||
3. Check whether the visual layout is appropriate for that slot.
|
||||
4. Flag if the asset appears to be a square master force-fit into a non-square slot, or vice versa.
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- 9-10: Aspect ratio matches a recognised slot exactly, layout matches the structural expectation for that slot.
|
||||
- 7-8: Slot match is plausible, layout is mostly right, minor compositional issues.
|
||||
- 5-6: Slot match unclear OR layout doesn't quite fit the expected slot structure.
|
||||
- 3-4: Aspect ratio doesn't match any recognised slot, OR the asset appears to be a wrong-format version of another slot.
|
||||
- 1-2: Aspect ratio is clearly off (e.g. square content shown stretched into a wide banner), or no recognisable WSJ podcast structure.
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON inside a code block:
|
||||
{
|
||||
"matched_slot": "e.g. 1080x1080_social_square or 970x250_billboard or 2048x2732_apple_portrait or unknown",
|
||||
"aspect_ratio_category": "square" or "portrait" or "landscape" or "tall_thin" or "wide_thin" or "other",
|
||||
"layout_matches_slot_expectation": true or false,
|
||||
"appears_stretched_or_squashed": true or false,
|
||||
"issues_found": ["list of specific issues, empty if none"],
|
||||
"explanation": "Detailed reasoning for the score",
|
||||
"recommendations": ["specific recommendations, empty array if none"]
|
||||
}"""
|
||||
|
||||
super().__init__(__name__, prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app_instance = WsjPodcastFormatComplianceApp()
|
||||
app_instance.run()
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
class WsjPodcastHeadlineLayoutApp(FlaskAppTemplate):
|
||||
"""
|
||||
WSJ Podcast - Headline Layout Check
|
||||
Verifies the podcast key art / asset follows the WSJ podcast headline grammar:
|
||||
top-left placement, two-line maximum, bold first line + light/regular second line, sans-serif.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
prompt = """You are performing a visual quality-control check on a Wall Street Journal (WSJ) podcast marketing asset (key art, podcast tile, social card, house ad, or platform thumbnail).
|
||||
|
||||
Your task is to verify the headline follows the WSJ Podcast headline layout grammar.
|
||||
|
||||
WSJ PODCAST HEADLINE RULES:
|
||||
1. PLACEMENT: The headline must sit in the TOP-LEFT region of the asset (or the top-left of the key-art square inside a larger frame). It should not be centered, right-aligned, or sit at the bottom.
|
||||
2. LINE COUNT: Maximum TWO lines. A single-line title is acceptable. Three or more lines is a fail.
|
||||
3. WEIGHT PAIRING: When two lines are used, the first line is set in a BOLD weight and the second line is set in a LIGHT or REGULAR weight (or vice versa — the key is contrast between the two lines). Both lines in the same weight is a soft fail.
|
||||
4. TYPEFACE: Sans-serif (Retina or similar WSJ sans-serif). The serif WSJ wordmark is the brand logo, NOT the headline. Headlines should not be set in the serif WSJ display face.
|
||||
5. CASE: Title Case is the WSJ standard (e.g. "Bold Names", "Minute Briefing", "Bad Bets", "Tech News Briefing"). Sentence case is acceptable for longer phrases. ALL CAPS headlines are not the standard look.
|
||||
6. COLOR: Either WSJ red on a light/neutral background, OR white on a dark/photographic background, OR a show-specific brand color that has been deliberately chosen. The headline must have strong contrast against its background.
|
||||
7. LENGTH: Headlines are short — typically a podcast show name (1-3 words per line). Long body-copy paragraphs in the headline slot are a fail.
|
||||
|
||||
REFERENCE EXAMPLES (these are correct):
|
||||
- "Bad Bets" — bold "Bad" / regular "Bets", top-left, white on dark gradient
|
||||
- "Bold Names" — bold "Bold" / light "Names", top-left, white on dark photographic bg
|
||||
- "Minute Briefing" — bold "Minute" / regular "Briefing", top-left, white on dark
|
||||
- "Your Money Briefing" — bold "Your Money" / regular "Briefing", three lines is acceptable for this specific show because it is the show's established lockup, but two lines is the cleaner default
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Locate the headline (the dominant title text — not the WSJ logo and not body copy).
|
||||
2. Confirm it sits in the top-left of the key-art area.
|
||||
3. Count lines. Is it 1 or 2?
|
||||
4. If 2 lines: check there is a clear weight contrast between line 1 and line 2.
|
||||
5. Confirm sans-serif typeface (not the WSJ serif used for the wordmark).
|
||||
6. Check case and color against the rules above.
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- 9-10: Headline top-left, max 2 lines, bold/light pairing visible, sans-serif, strong contrast.
|
||||
- 7-8: Mostly correct, minor issues (e.g. slightly off the top-left corner, weight contrast subtle).
|
||||
- 5-6: Noticeable issues (3 lines, headline not in the top-left, both lines same weight).
|
||||
- 3-4: Significant issues (headline center or bottom, set in the WSJ serif, low contrast).
|
||||
- 1-2: No identifiable headline, or headline placement completely violates the system.
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON inside a code block:
|
||||
{
|
||||
"headline_present": true or false,
|
||||
"placement": "top_left" or "top_center" or "top_right" or "center" or "bottom" or "other",
|
||||
"line_count": 1 or 2 or 3 or 4,
|
||||
"weight_pairing_present": true or false,
|
||||
"typeface_family": "sans_serif" or "serif" or "mixed" or "unclear",
|
||||
"case": "title_case" or "sentence_case" or "all_caps" or "mixed",
|
||||
"issues_found": ["list of specific issues, empty if none"],
|
||||
"explanation": "Detailed reasoning for the score",
|
||||
"recommendations": ["specific recommendations, empty array if none"]
|
||||
}"""
|
||||
|
||||
super().__init__(__name__, prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app_instance = WsjPodcastHeadlineLayoutApp()
|
||||
app_instance.run()
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
class WsjPodcastLogoPlacementApp(FlaskAppTemplate):
|
||||
"""
|
||||
WSJ Podcast - Logo Placement Check
|
||||
Verifies the WSJ wordmark is present and anchored in the bottom-right corner of the key-art area,
|
||||
rendered in either WSJ red or black/white per the podcast template system.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
prompt = """You are performing a visual quality-control check on a Wall Street Journal (WSJ) podcast marketing asset (key art, podcast tile, social card, house ad, or platform thumbnail).
|
||||
|
||||
Your task is to verify the WSJ wordmark logo is present, correctly placed, and correctly rendered.
|
||||
|
||||
WSJ PODCAST LOGO RULES:
|
||||
1. PRESENCE: The WSJ wordmark (the serif "WSJ" lockup) MUST appear on every podcast asset. Missing logo is a hard fail.
|
||||
2. PLACEMENT: The logo must be anchored in the BOTTOM-RIGHT corner of the key-art area. Other corners or center placement are not the WSJ podcast standard.
|
||||
3. TYPEFACE: The logo is the WSJ serif display lockup (the iconic serif "WSJ" with the distinctive J terminal). It is NOT a sans-serif rendering and NOT a redrawn approximation.
|
||||
4. COLOR: The logo appears in either:
|
||||
- WSJ red, when sitting on a light or neutral background
|
||||
- WHITE, when sitting on a dark or photographic background
|
||||
- BLACK, when sitting on a light background as an alternate to red
|
||||
It should not appear in arbitrary brand colors (purple, green, etc.) unless the entire palette of the asset has been deliberately re-cast for a specific show.
|
||||
5. SIZE: The logo should be substantial enough to be legible at the asset's smallest expected display size, but should not overpower the headline or compete with show artwork. As a rough guide, the logo cap height is typically ~10-18% of the key-art area's shortest side.
|
||||
6. CLEAR SPACE: The logo must have breathing room around it — it should not touch or cross the safe-area edge, and it should not be obscured by other graphic elements.
|
||||
7. INTEGRITY: The logo must not be distorted, rotated, skewed, recoloured arbitrarily, given drop shadows / outlines, or otherwise modified.
|
||||
|
||||
REFERENCE EXAMPLES (these are correct):
|
||||
- Bad Bets key art: WSJ wordmark in WHITE, bottom-right of the dark key-art square.
|
||||
- Bold Names key art: WSJ wordmark in WHITE, bottom-right of the dark photographic key-art square.
|
||||
- Minute Briefing key art: WSJ wordmark in WHITE, bottom-right of the dark key-art square.
|
||||
- Apple platform thumbnail: WSJ wordmark in WHITE, bottom-right of the artwork tile (NOT the OS chrome).
|
||||
- Two-line headline templates: WSJ wordmark in RED, bottom-right of the white key-art square.
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Locate the WSJ wordmark (the serif "WSJ" lockup). If you cannot find it, the check fails.
|
||||
2. Identify which corner / region it sits in.
|
||||
3. Check its color against the background it sits on.
|
||||
4. Check its size relative to the rest of the asset.
|
||||
5. Check for any distortion, recolouring, or effects.
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- 9-10: Logo present, bottom-right corner of the key-art area, correct color (red / white / black), proportional size, clean integrity.
|
||||
- 7-8: Logo present and mostly correct, minor placement or sizing drift.
|
||||
- 5-6: Logo present but with noticeable issues (wrong corner, off-brand color, too small / too large, cropped tightly).
|
||||
- 3-4: Logo present but with significant issues (obviously wrong placement, distorted, incorrect colour treatment).
|
||||
- 1-2: Logo missing entirely OR unrecognisable.
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON inside a code block:
|
||||
{
|
||||
"logo_present": true or false,
|
||||
"logo_position": "bottom_right" or "bottom_left" or "top_right" or "top_left" or "center" or "other",
|
||||
"logo_color": "red" or "white" or "black" or "other",
|
||||
"size_appropriate": true or false,
|
||||
"integrity_intact": true or false,
|
||||
"issues_found": ["list of specific issues, empty if none"],
|
||||
"explanation": "Detailed reasoning for the score",
|
||||
"recommendations": ["specific recommendations, empty array if none"]
|
||||
}"""
|
||||
|
||||
super().__init__(__name__, prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app_instance = WsjPodcastLogoPlacementApp()
|
||||
app_instance.run()
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
class WsjPodcastSafeAreaApp(FlaskAppTemplate):
|
||||
"""
|
||||
WSJ Podcast - Safe Area Check
|
||||
Verifies critical content (headline, WSJ logo, key brand artwork) sits within the WSJ
|
||||
podcast template safe-area / crop-area, with adequate margins from the asset edges.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
prompt = """You are performing a visual quality-control check on a Wall Street Journal (WSJ) podcast marketing asset.
|
||||
|
||||
Your task is to verify all critical content sits within the safe area of the asset and that the asset respects the WSJ podcast template margin system.
|
||||
|
||||
WSJ PODCAST SAFE-AREA RULES:
|
||||
1. CRITICAL CONTENT must remain inside the safe area. Critical content is:
|
||||
- The headline text
|
||||
- The WSJ wordmark logo
|
||||
- Any key visual element that identifies the show (key art square, central illustration, central photograph)
|
||||
2. SAFE AREA: For a 3000x3000 master tile, the safe area sits roughly 5-10% inside each edge. For platform asset templates (Apple App Store, Spotify, House Ads, WSJ Platform), the safe area follows the platform's standard safe zone — UI chrome (back arrow, follow button, navigation tabs, gradient overlays) must NOT cover the headline or logo.
|
||||
3. CROP AREA: For container formats (Apple App Store hero, full-page Spotify card), the asset must extend bleed past the visible crop. Critical content must stay inside the inner safe rectangle, never past the outer edge.
|
||||
4. APPLE APP STORE PORTRAIT (e.g. 2048x2732): The asset has a "Full Page Show Art" zone. The headline and WSJ logo must sit inside that zone — not behind the Apple top-bar (status bar, back arrow, "+ Follow" button) and not behind the bottom "Latest Episode" button.
|
||||
5. SPOTIFY CARD / PLAYLIST: The visible artwork tile is a sub-rectangle of the file. Critical content must sit inside that visible tile, not in the bleed.
|
||||
6. HOUSE AD MPU / BILLBOARD / LEADERBOARD: Critical content must respect a roughly 5% inner margin. Text or logo touching the edge is a fail.
|
||||
|
||||
WHAT YOU'RE LOOKING FOR:
|
||||
- Is the headline cropped or running off any edge?
|
||||
- Is the WSJ logo cropped, touching an edge, or overlapped by UI chrome?
|
||||
- Are the key-art square's important visual elements (face, product, central icon) cropped or covered?
|
||||
- Is there visible breathing room (margin) between the critical content and the nearest edge?
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Identify the asset format (square key art / portrait Apple / wide Spotify / banner / etc.).
|
||||
2. Estimate where the safe area sits for that format.
|
||||
3. Check whether headline, WSJ logo, and any central key-art element fit inside that safe area.
|
||||
4. Check for any element that is cropped, runs off the edge, or sits in a UI-chrome zone.
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- 9-10: All critical content inside safe area, comfortable margins, nothing clipped, nothing under UI chrome.
|
||||
- 7-8: Mostly safe, small drift toward an edge but nothing actually clipped.
|
||||
- 5-6: One critical element (headline, logo, or key art) is too close to an edge or sits inside a UI-chrome zone.
|
||||
- 3-4: Significant safe-area violation — element is clipped or obscured by UI chrome.
|
||||
- 1-2: Multiple critical elements clipped or sitting on the bleed.
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON inside a code block:
|
||||
{
|
||||
"format_identified": "square_key_art" or "apple_portrait" or "spotify_card" or "house_ad_billboard" or "house_ad_mpu" or "wsj_platform_thumbnail" or "social_static" or "other",
|
||||
"headline_inside_safe_area": true or false,
|
||||
"logo_inside_safe_area": true or false,
|
||||
"key_art_inside_safe_area": true or false,
|
||||
"elements_clipped_or_obscured": ["list of clipped/obscured elements, empty if none"],
|
||||
"issues_found": ["list of specific issues, empty if none"],
|
||||
"explanation": "Detailed reasoning for the score",
|
||||
"recommendations": ["specific recommendations, empty array if none"]
|
||||
}"""
|
||||
|
||||
super().__init__(__name__, prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app_instance = WsjPodcastSafeAreaApp()
|
||||
app_instance.run()
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -43,15 +43,17 @@ def _client_secret() -> str:
|
|||
return os.environ.get('BOX_CLIENT_SECRET', '')
|
||||
|
||||
|
||||
def _redirect_uri() -> str:
|
||||
return os.environ.get('BOX_REDIRECT_URI', '')
|
||||
|
||||
|
||||
def _secret_key() -> str:
|
||||
# Same secret used for MSAL session signing; avoids a separate key to manage.
|
||||
return os.environ.get('SECRET_KEY', 'dev-secret-key-change-me')
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""Whether the Box client + secret are configured. The redirect URI is now
|
||||
derived from each request, so we don't gate on it here."""
|
||||
return bool(_client_id() and _client_secret())
|
||||
return bool(_client_id() and _client_secret() and _redirect_uri())
|
||||
|
||||
|
||||
def make_state(user_email: str) -> str:
|
||||
|
|
@ -78,23 +80,19 @@ def verify_state(state: str) -> Optional[str]:
|
|||
return email if email else None
|
||||
|
||||
|
||||
def build_authorize_url(state: str, redirect_uri: str) -> str:
|
||||
def build_authorize_url(state: str) -> str:
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': _client_id(),
|
||||
'redirect_uri': redirect_uri,
|
||||
'redirect_uri': _redirect_uri(),
|
||||
'state': state,
|
||||
'scope': ' '.join(DEFAULT_SCOPES),
|
||||
}
|
||||
return BOX_AUTHORIZE_URL + '?' + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
def exchange_code_for_tokens(code: str, redirect_uri: str) -> dict:
|
||||
"""Trade an authorization code for an access + refresh token pair.
|
||||
|
||||
`redirect_uri` MUST match the one used in the authorize call — Box rejects
|
||||
the exchange otherwise.
|
||||
"""
|
||||
def exchange_code_for_tokens(code: str) -> dict:
|
||||
"""Trade an authorization code for an access + refresh token pair."""
|
||||
resp = requests.post(
|
||||
BOX_TOKEN_URL,
|
||||
data={
|
||||
|
|
@ -102,7 +100,7 @@ def exchange_code_for_tokens(code: str, redirect_uri: str) -> dict:
|
|||
'code': code,
|
||||
'client_id': _client_id(),
|
||||
'client_secret': _client_secret(),
|
||||
'redirect_uri': redirect_uri,
|
||||
'redirect_uri': _redirect_uri(),
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
"""
|
||||
Box JWT service-account client.
|
||||
|
||||
Authenticates as a Box Custom App with Server Authentication (JWT) — i.e. the
|
||||
app has its own Box identity rather than acting on behalf of a logged-in user.
|
||||
Used for webhook-driven, unattended workflows where the QC pipeline needs to
|
||||
read files from Box and write reports back without a human in the loop.
|
||||
|
||||
The service account must be invited as a collaborator on each client folder
|
||||
before any read/write succeeds. Folder IDs live in `client_config.py`.
|
||||
|
||||
Co-exists with the older per-user OAuth flow in `box_client.py` — different
|
||||
auth model, different use cases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
_CONFIG_PATH_ENV = 'BOX_JWT_CONFIG_PATH'
|
||||
_DEFAULT_CONFIG_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'config', 'box_jwt_config.json'
|
||||
)
|
||||
_TOKEN_URL = 'https://api.box.com/oauth2/token'
|
||||
_API_BASE = 'https://api.box.com/2.0'
|
||||
_UPLOAD_BASE = 'https://upload.box.com/api/2.0'
|
||||
|
||||
_token_lock = threading.Lock()
|
||||
_cached_token: Optional[Dict[str, Any]] = None # {'access_token': str, 'expires_at': float}
|
||||
|
||||
|
||||
class BoxJWTError(RuntimeError):
|
||||
"""Any failure while talking to Box via the JWT service account."""
|
||||
|
||||
|
||||
def _config_path() -> str:
|
||||
return os.environ.get(_CONFIG_PATH_ENV) or _DEFAULT_CONFIG_PATH
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""True iff the JWT config JSON exists at the expected path."""
|
||||
return os.path.exists(_config_path())
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
path = _config_path()
|
||||
if not os.path.exists(path):
|
||||
raise BoxJWTError(
|
||||
f'Box JWT config not found at {path}. '
|
||||
f'Drop the JSON Box gave you for the Custom App at that path, or set {_CONFIG_PATH_ENV}.'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _build_assertion(config: Dict[str, Any]) -> str:
|
||||
box_app = config['boxAppSettings']
|
||||
app_auth = box_app['appAuth']
|
||||
private_key = load_pem_private_key(
|
||||
app_auth['privateKey'].encode('utf-8'),
|
||||
password=app_auth['passphrase'].encode('utf-8'),
|
||||
)
|
||||
claims = {
|
||||
'iss': box_app['clientID'],
|
||||
'sub': config['enterpriseID'],
|
||||
'box_sub_type': 'enterprise',
|
||||
'aud': _TOKEN_URL,
|
||||
'jti': secrets.token_urlsafe(16),
|
||||
'exp': int(time.time()) + 45, # Box caps assertion lifetime at 60s
|
||||
}
|
||||
return jwt.encode(
|
||||
claims, private_key, algorithm='RS256', headers={'kid': app_auth['publicKeyID']}
|
||||
)
|
||||
|
||||
|
||||
def _fetch_new_token() -> Dict[str, Any]:
|
||||
config = _load_config()
|
||||
assertion = _build_assertion(config)
|
||||
response = requests.post(
|
||||
_TOKEN_URL,
|
||||
data={
|
||||
'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion': assertion,
|
||||
'client_id': config['boxAppSettings']['clientID'],
|
||||
'client_secret': config['boxAppSettings']['clientSecret'],
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise BoxJWTError(
|
||||
f'Box token exchange failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
data = response.json()
|
||||
# Refresh 5 minutes before expiry to absorb clock skew + network latency.
|
||||
return {
|
||||
'access_token': data['access_token'],
|
||||
'expires_at': time.time() + data['expires_in'] - 300,
|
||||
}
|
||||
|
||||
|
||||
def get_service_account_token() -> str:
|
||||
"""Return a currently-valid service-account access token, refreshing if needed."""
|
||||
global _cached_token
|
||||
with _token_lock:
|
||||
if _cached_token and time.time() < _cached_token['expires_at']:
|
||||
return _cached_token['access_token']
|
||||
_cached_token = _fetch_new_token()
|
||||
return _cached_token['access_token']
|
||||
|
||||
|
||||
def _auth_headers() -> Dict[str, str]:
|
||||
return {'Authorization': f'Bearer {get_service_account_token()}'}
|
||||
|
||||
|
||||
# ---------- File / folder operations ----------
|
||||
|
||||
def list_folder_items(folder_id: str, fields: Optional[List[str]] = None, limit: int = 1000) -> List[Dict[str, Any]]:
|
||||
"""List items in a folder. Returns the `entries` array from the Box API."""
|
||||
params = {'limit': limit}
|
||||
if fields:
|
||||
params['fields'] = ','.join(fields)
|
||||
response = requests.get(
|
||||
f'{_API_BASE}/folders/{folder_id}/items',
|
||||
headers=_auth_headers(),
|
||||
params=params,
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise BoxJWTError(
|
||||
f'list_folder_items({folder_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
return response.json().get('entries', [])
|
||||
|
||||
|
||||
def get_file_metadata(file_id: str) -> Dict[str, Any]:
|
||||
"""Return Box file metadata (name, size, parent, etc.)."""
|
||||
response = requests.get(
|
||||
f'{_API_BASE}/files/{file_id}',
|
||||
headers=_auth_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise BoxJWTError(
|
||||
f'get_file_metadata({file_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
def download_file(file_id: str, dest_path: str) -> str:
|
||||
"""Stream a Box file to dest_path. Returns dest_path on success."""
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
with requests.get(
|
||||
f'{_API_BASE}/files/{file_id}/content',
|
||||
headers=_auth_headers(),
|
||||
stream=True,
|
||||
timeout=300,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
raise BoxJWTError(
|
||||
f'download_file({file_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return dest_path
|
||||
|
||||
|
||||
def upload_file(local_path: str, parent_folder_id: str, name: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Upload a local file into a Box folder. Returns the new file's metadata."""
|
||||
if not os.path.exists(local_path):
|
||||
raise BoxJWTError(f'upload_file: local file not found: {local_path}')
|
||||
upload_name = name or os.path.basename(local_path)
|
||||
attributes = {'name': upload_name, 'parent': {'id': parent_folder_id}}
|
||||
with open(local_path, 'rb') as f:
|
||||
response = requests.post(
|
||||
f'{_UPLOAD_BASE}/files/content',
|
||||
headers=_auth_headers(),
|
||||
data={'attributes': json.dumps(attributes)},
|
||||
files={'file': (upload_name, f)},
|
||||
timeout=300,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise BoxJWTError(
|
||||
f'upload_file({upload_name} → {parent_folder_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
entries = response.json().get('entries', [])
|
||||
return entries[0] if entries else {}
|
||||
|
||||
|
||||
def find_subfolder_by_name(parent_folder_id: str, name: str) -> Optional[str]:
|
||||
"""Return the Box folder ID of a child folder named `name`, or None if not found.
|
||||
|
||||
Box allows duplicate folder names within a parent; if multiple match, returns
|
||||
the first one encountered.
|
||||
"""
|
||||
for item in list_folder_items(parent_folder_id, fields=['id', 'name', 'type']):
|
||||
if item.get('type') == 'folder' and item.get('name') == name:
|
||||
return str(item['id'])
|
||||
return None
|
||||
|
||||
|
||||
def create_subfolder(parent_folder_id: str, name: str) -> str:
|
||||
"""Create a new folder named `name` under `parent_folder_id`. Returns its ID."""
|
||||
payload = {'name': name, 'parent': {'id': parent_folder_id}}
|
||||
response = requests.post(
|
||||
f'{_API_BASE}/folders',
|
||||
headers={**_auth_headers(), 'Content-Type': 'application/json'},
|
||||
data=json.dumps(payload),
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise BoxJWTError(
|
||||
f'create_subfolder({name} under {parent_folder_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
return str(response.json()['id'])
|
||||
|
||||
|
||||
def find_or_create_subfolder(parent_folder_id: str, name: str) -> str:
|
||||
"""Idempotent: return existing subfolder ID, or create + return new one."""
|
||||
existing = find_subfolder_by_name(parent_folder_id, name)
|
||||
if existing:
|
||||
return existing
|
||||
return create_subfolder(parent_folder_id, name)
|
||||
|
||||
|
||||
def move_file(file_id: str, target_folder_id: str, new_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Move (and optionally rename) a Box file. Returns the updated file metadata.
|
||||
|
||||
Pass `new_name` to also rename in-flight — useful for collision-avoidance when
|
||||
moving into a folder that may already contain a file with the same name.
|
||||
"""
|
||||
payload: Dict[str, Any] = {'parent': {'id': str(target_folder_id)}}
|
||||
if new_name:
|
||||
payload['name'] = new_name
|
||||
response = requests.put(
|
||||
f'{_API_BASE}/files/{file_id}',
|
||||
headers={**_auth_headers(), 'Content-Type': 'application/json'},
|
||||
data=json.dumps(payload),
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise BoxJWTError(
|
||||
f'move_file({file_id} → {target_folder_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------- Webhook (V2) management ----------
|
||||
|
||||
def create_webhook(target_type: str, target_id: str, address: str, triggers: List[str]) -> Dict[str, Any]:
|
||||
"""Register a V2 webhook on a file or folder. Returns the webhook record (id + signing keys)."""
|
||||
if target_type not in ('file', 'folder'):
|
||||
raise BoxJWTError(f'target_type must be "file" or "folder", got {target_type!r}')
|
||||
payload = {
|
||||
'target': {'type': target_type, 'id': target_id},
|
||||
'address': address,
|
||||
'triggers': triggers,
|
||||
}
|
||||
response = requests.post(
|
||||
f'{_API_BASE}/webhooks',
|
||||
headers={**_auth_headers(), 'Content-Type': 'application/json'},
|
||||
data=json.dumps(payload),
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise BoxJWTError(
|
||||
f'create_webhook({target_type}/{target_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
def list_webhooks() -> List[Dict[str, Any]]:
|
||||
"""List V2 webhooks visible to the service account."""
|
||||
response = requests.get(
|
||||
f'{_API_BASE}/webhooks', headers=_auth_headers(), timeout=30
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise BoxJWTError(
|
||||
f'list_webhooks failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
return response.json().get('entries', [])
|
||||
|
||||
|
||||
def delete_webhook(webhook_id: str) -> None:
|
||||
"""Delete a V2 webhook by ID."""
|
||||
response = requests.delete(
|
||||
f'{_API_BASE}/webhooks/{webhook_id}', headers=_auth_headers(), timeout=30
|
||||
)
|
||||
if response.status_code not in (200, 204):
|
||||
raise BoxJWTError(
|
||||
f'delete_webhook({webhook_id}) failed: HTTP {response.status_code} — {response.text[:300]}'
|
||||
)
|
||||
|
||||
|
||||
# ---------- Webhook payload verification ----------
|
||||
|
||||
def verify_webhook_signature(
|
||||
body: bytes,
|
||||
headers: Dict[str, str],
|
||||
primary_key: Optional[str],
|
||||
secondary_key: Optional[str],
|
||||
) -> bool:
|
||||
"""Verify the signature on an incoming Box webhook payload.
|
||||
|
||||
Box V2 webhooks sign `body + delivery_timestamp` with HMAC-SHA256 using two
|
||||
rotating keys (primary + secondary). Either key matching = valid signature.
|
||||
Pass `body` as raw request bytes — JSON-serializing first will reorder keys
|
||||
and break verification.
|
||||
"""
|
||||
if headers.get('box-signature-version') != '1':
|
||||
return False
|
||||
if headers.get('box-signature-algorithm') != 'HmacSHA256':
|
||||
return False
|
||||
timestamp = headers.get('box-delivery-timestamp', '')
|
||||
if not timestamp:
|
||||
return False
|
||||
expected_primary = headers.get('box-signature-primary')
|
||||
expected_secondary = headers.get('box-signature-secondary')
|
||||
|
||||
message = body + timestamp.encode('utf-8')
|
||||
|
||||
def _matches(key: Optional[str], expected: Optional[str]) -> bool:
|
||||
if not key or not expected:
|
||||
return False
|
||||
computed = base64.b64encode(
|
||||
hmac.new(key.encode('utf-8'), message, hashlib.sha256).digest()
|
||||
).decode('utf-8')
|
||||
return hmac.compare_digest(computed, expected)
|
||||
|
||||
return _matches(primary_key, expected_primary) or _matches(secondary_key, expected_secondary)
|
||||
|
|
@ -20,10 +20,7 @@ CLIENT_PROFILES = {
|
|||
'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",
|
||||
'box_folder_id': '381501258415',
|
||||
'box_reports_folder_id': '382076841334',
|
||||
'default_profile': 'loreal_static',
|
||||
'description': "L'Oreal brand profiles with focused and comprehensive static QC checks"
|
||||
},
|
||||
'amazon': {
|
||||
'name': 'Amazon',
|
||||
|
|
@ -33,10 +30,16 @@ CLIENT_PROFILES = {
|
|||
},
|
||||
'boots': {
|
||||
'name': 'Boots',
|
||||
'profiles': ['boots_static', 'boots_ppack', 'static_general', 'video_general'],
|
||||
'profiles': ['boots_static', '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', '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'],
|
||||
|
|
@ -45,9 +48,9 @@ CLIENT_PROFILES = {
|
|||
},
|
||||
'axa': {
|
||||
'name': 'AXA',
|
||||
'profiles': ['axa_policy_document', 'axa_policy_document_diff', 'axa_accessibility', 'static_general', 'video_general'],
|
||||
'profiles': ['static_general', 'video_general'],
|
||||
'display_name': 'AXA',
|
||||
'description': 'AXA brand profiles, including multi-page policy document QC for AXA Ireland'
|
||||
'description': 'AXA brand profiles for marketing QC checks'
|
||||
},
|
||||
'rank': {
|
||||
'name': 'Rank',
|
||||
|
|
@ -55,25 +58,6 @@ CLIENT_PROFILES = {
|
|||
'display_name': 'Rank',
|
||||
'description': 'Rank brand profiles for marketing QC checks'
|
||||
},
|
||||
'google': {
|
||||
'name': 'Google',
|
||||
'profiles': ['static_general', 'video_general'],
|
||||
'display_name': 'Google',
|
||||
'description': 'Demo client — scope pending'
|
||||
},
|
||||
'hp': {
|
||||
'name': 'HP',
|
||||
'profiles': ['hp_copy_review', 'static_general', 'video_general'],
|
||||
'display_name': 'HP',
|
||||
'description': 'HP marketing copy QC graded against canonical Source Messaging',
|
||||
'default_profile': 'hp_copy_review',
|
||||
},
|
||||
'ferrero': {
|
||||
'name': 'Ferrero',
|
||||
'profiles': ['static_general', 'video_general'],
|
||||
'display_name': 'Ferrero',
|
||||
'description': 'Demo client — scope pending'
|
||||
},
|
||||
'general': {
|
||||
'name': 'General',
|
||||
'profiles': ['static_general', 'video_general', 'inclusive_accessibility'],
|
||||
|
|
@ -90,105 +74,6 @@ def get_all_clients():
|
|||
"""Get all available clients"""
|
||||
return CLIENT_PROFILES
|
||||
|
||||
|
||||
def get_client_by_box_folder(folder_id):
|
||||
"""Reverse-lookup: which client owns this Box folder_id?
|
||||
|
||||
Used by the Box webhook handler. Returns the client_id (key) or None.
|
||||
Folder IDs are compared as strings since the Box API returns them as such.
|
||||
"""
|
||||
target = str(folder_id) if folder_id is not None else None
|
||||
if not target:
|
||||
return None
|
||||
for cid, cfg in CLIENT_PROFILES.items():
|
||||
if str(cfg.get('box_folder_id', '') or '') == target:
|
||||
return cid
|
||||
return None
|
||||
|
||||
|
||||
def get_clients_with_box_folder():
|
||||
"""Return [(client_id, client_config_dict), ...] for clients with a Box folder configured."""
|
||||
return [
|
||||
(cid, cfg) for cid, cfg in CLIENT_PROFILES.items()
|
||||
if cfg.get('box_folder_id')
|
||||
]
|
||||
|
||||
|
||||
# ---------- Runtime override for default_profile ----------
|
||||
#
|
||||
# The static `default_profile` field on each client in CLIENT_PROFILES is the
|
||||
# baseline value, set in code at deploy time. Admins can override it at runtime
|
||||
# via the Settings UI; overrides persist to backend/client_defaults.json
|
||||
# (gitignored, per-server). This separation means a buggy override write can
|
||||
# never break server boot — worst case the override is ignored and the static
|
||||
# value applies.
|
||||
|
||||
import os as _os
|
||||
import json as _json
|
||||
|
||||
_DEFAULTS_OVERRIDE_PATH = _os.path.join(
|
||||
_os.path.dirname(_os.path.abspath(__file__)), 'client_defaults.json'
|
||||
)
|
||||
|
||||
|
||||
def _load_default_overrides():
|
||||
"""Return the override dict {client_id: profile_id}; empty dict if no file or unreadable."""
|
||||
if not _os.path.exists(_DEFAULTS_OVERRIDE_PATH):
|
||||
return {}
|
||||
try:
|
||||
with open(_DEFAULTS_OVERRIDE_PATH, 'r') as f:
|
||||
data = _json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, _json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_default_overrides(data):
|
||||
"""Persist the override dict. Writes to a temp path then renames for atomicity."""
|
||||
tmp_path = _DEFAULTS_OVERRIDE_PATH + '.tmp'
|
||||
with open(tmp_path, 'w') as f:
|
||||
_json.dump(data, f, indent=2, sort_keys=True)
|
||||
_os.replace(tmp_path, _DEFAULTS_OVERRIDE_PATH)
|
||||
|
||||
|
||||
def get_default_profile(client_id):
|
||||
"""Return the effective default profile for a client.
|
||||
|
||||
Resolution order: runtime override → static `default_profile` field → None.
|
||||
Used by the Box webhook handler (no logged-in user, needs a profile to run).
|
||||
"""
|
||||
overrides = _load_default_overrides()
|
||||
if client_id in overrides:
|
||||
return overrides[client_id]
|
||||
cfg = CLIENT_PROFILES.get(client_id, {})
|
||||
return cfg.get('default_profile')
|
||||
|
||||
|
||||
def set_default_profile(client_id, profile_id):
|
||||
"""Persist a runtime override for a client's default profile.
|
||||
|
||||
Validates that the client exists and the profile is one of the client's
|
||||
allowed profiles. Returns (True, None) on success or (False, reason) on
|
||||
rejection.
|
||||
"""
|
||||
if client_id not in CLIENT_PROFILES:
|
||||
return False, f'unknown client: {client_id}'
|
||||
allowed = get_client_profiles(client_id)
|
||||
if profile_id not in allowed:
|
||||
return False, f"profile '{profile_id}' is not in client {client_id}'s profile list"
|
||||
overrides = _load_default_overrides()
|
||||
overrides[client_id] = profile_id
|
||||
_save_default_overrides(overrides)
|
||||
return True, None
|
||||
|
||||
|
||||
def clear_default_profile_override(client_id):
|
||||
"""Remove a runtime override so the static default applies again."""
|
||||
overrides = _load_default_overrides()
|
||||
if client_id in overrides:
|
||||
del overrides[client_id]
|
||||
_save_default_overrides(overrides)
|
||||
|
||||
def validate_client_profile(client_id, profile_id):
|
||||
"""Validate that a profile belongs to a client"""
|
||||
client_profiles = get_client_profiles(client_id)
|
||||
|
|
|
|||
10
backend/config.env
Executable file
10
backend/config.env
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
OPENAI_API_KEY=sk-svcacct-HSREzGYDnN-vCVGAh6LhYqlNcJVF2oefMrY9oCsdDsQFmyVJyHpLb1eSb_mp_vP4YPl4T3BlbkFJzKaOrPghIzx76_22K8VjwO6j2JnoDEvrYDrgfrnA4WjD5sTMnhOqGHXximwGXFhUoYgA
|
||||
GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
|
||||
|
||||
# Azure AD / MSAL Authentication Configuration
|
||||
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
|
||||
# Flask Security Configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
|
|
@ -36,27 +36,9 @@ SENDER_EMAIL=noreply@your-domain.com
|
|||
ERROR_EMAIL=admin@your-domain.com
|
||||
REPORT_EMAILS=admin@your-domain.com
|
||||
|
||||
# Box.com OAuth (per-creator user authentication — legacy/dormant scaffolding)
|
||||
# Register a Custom App with OAuth 2.0 (User Authentication) in Box Developer Console.
|
||||
# In the app's Configuration tab, add ALL the redirect URIs you'll use:
|
||||
# http://localhost:7183/auth/box/callback (local dev)
|
||||
# https://optical-dev.oliver.solutions/ai_qc/auth/box/callback (dev server)
|
||||
# https://optical-prod.oliver.solutions/ai_qc/auth/box/callback (prod server)
|
||||
# The backend computes the redirect URI from each request, so you don't need
|
||||
# to set BOX_REDIRECT_URI per server — uncomment only as an override.
|
||||
# Box.com OAuth (per-creator user authentication for automation folders)
|
||||
# Register a Custom App with OAuth 2.0 (User Authentication) in Box Developer Console;
|
||||
# add a redirect URI matching your environment.
|
||||
BOX_CLIENT_ID=your-box-client-id
|
||||
BOX_CLIENT_SECRET=your-box-client-secret
|
||||
# BOX_REDIRECT_URI=
|
||||
|
||||
# Box.com JWT (service-account auth — used by /api/box/webhook for unattended QC)
|
||||
# Drop the JSON Box gives you for the "Custom App with Server Authentication (JWT)"
|
||||
# at backend/config/box_jwt_config.json (gitignored, scp'd onto each server).
|
||||
# Override the path with BOX_JWT_CONFIG_PATH if you store it elsewhere.
|
||||
# BOX_JWT_CONFIG_PATH=/opt/ai_qc/backend/config/box_jwt_config.json
|
||||
|
||||
# Box V2 webhook signing keys (one app-level pair, used by every webhook the
|
||||
# Custom App owns). Get them from Box Developer Console → Custom App → Webhooks
|
||||
# tab → "Manage Signature Keys". Both are valid simultaneously — Box uses a
|
||||
# rolling-rotate model so you can rotate one at a time without downtime.
|
||||
BOX_WEBHOOK_PRIMARY_KEY=
|
||||
BOX_WEBHOOK_SECONDARY_KEY=
|
||||
BOX_REDIRECT_URI=http://localhost:7183/auth/box/callback
|
||||
42
backend/config/development.env
Normal file
42
backend/config/development.env
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Development Environment Configuration
|
||||
# This file is used for local development testing
|
||||
|
||||
# OpenAI Configuration
|
||||
OPENAI_API_KEY=sk-svcacct-HSREzGYDnN-vCVGAh6LhYqlNcJVF2oefMrY9oCsdDsQFmyVJyHpLb1eSb_mp_vP4YPl4T3BlbkFJzKaOrPghIzx76_22K8VjwO6j2JnoDEvrYDrgfrnA4WjD5sTMnhOqGHXximwGXFhUoYgA
|
||||
GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
|
||||
|
||||
# Azure AD / MSAL Authentication Configuration (Development App Registration)
|
||||
# NOTE: You'll need to create a separate app registration for development
|
||||
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
AZURE_REDIRECT_URI=http://localhost:7183
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=dev-secret-key-change-this-for-security
|
||||
DEBUG_MODE=true
|
||||
PORT=7183
|
||||
|
||||
# Application Configuration
|
||||
ENVIRONMENT=development
|
||||
BASE_URL=http://localhost:7183
|
||||
UPLOAD_FOLDER=uploads-dev
|
||||
OUTPUT_FOLDER=output-dev
|
||||
|
||||
# Development-specific settings
|
||||
LOG_LEVEL=DEBUG
|
||||
ENABLE_DEBUG_ENDPOINTS=true
|
||||
|
||||
# Mailgun / SMTP (for email notifications)
|
||||
SMTP_SERVER=smtp.mailgun.org
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=twist@mail.dev.oliver.solutions
|
||||
SMTP_PASSWORD=102115e9f3b9d7332d0cd1d4329bc0d4-77751bfc-ca066b71
|
||||
SENDER_EMAIL=TWIST-UK-SERVER@oliver.agency
|
||||
ERROR_EMAIL=nick.viljoen@brandtech.plus
|
||||
REPORT_EMAILS=nick.viljoen@brandtech.plus
|
||||
|
||||
# Box.com OAuth (per-creator user authentication for automation folders)
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
BOX_REDIRECT_URI=http://localhost:7183/auth/box/callback
|
||||
41
backend/config/production.env
Normal file
41
backend/config/production.env
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Production Environment Configuration
|
||||
# This file is used for production deployment on the web server
|
||||
|
||||
# OpenAI Configuration
|
||||
OPENAI_API_KEY=sk-svcacct-HSREzGYDnN-vCVGAh6LhYqlNcJVF2oefMrY9oCsdDsQFmyVJyHpLb1eSb_mp_vP4YPl4T3BlbkFJzKaOrPghIzx76_22K8VjwO6j2JnoDEvrYDrgfrnA4WjD5sTMnhOqGHXximwGXFhUoYgA
|
||||
GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
|
||||
|
||||
# Azure AD / MSAL Authentication Configuration (Production)
|
||||
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
AZURE_REDIRECT_URI=https://ai-sandbox.oliver.solutions/ai_qc/
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=prod-ai-qc-oliver-solutions-2025-secure-key-9f8e7d6c5b4a3
|
||||
DEBUG_MODE=false
|
||||
PORT=7184
|
||||
|
||||
# Application Configuration
|
||||
ENVIRONMENT=production
|
||||
BASE_URL=https://ai-sandbox.oliver.solutions/ai_qc
|
||||
UPLOAD_FOLDER=uploads
|
||||
OUTPUT_FOLDER=output
|
||||
|
||||
# Production-specific settings
|
||||
LOG_LEVEL=INFO
|
||||
ENABLE_DEBUG_ENDPOINTS=false
|
||||
|
||||
# Mailgun / SMTP (for email notifications)
|
||||
SMTP_SERVER=smtp.mailgun.org
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=twist@mail.dev.oliver.solutions
|
||||
SMTP_PASSWORD=102115e9f3b9d7332d0cd1d4329bc0d4-77751bfc-ca066b71
|
||||
SENDER_EMAIL=TWIST-UK-SERVER@oliver.agency
|
||||
ERROR_EMAIL=nick.viljoen@brandtech.plus
|
||||
REPORT_EMAILS=nick.viljoen@brandtech.plus
|
||||
|
||||
# Box.com OAuth (per-creator user authentication for automation folders)
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
BOX_REDIRECT_URI=https://optical-prod.oliver.solutions/ai_qc/auth/box/callback
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""Document-mode QC pipeline for multi-page PDFs.
|
||||
|
||||
Lives alongside the existing single-asset pipeline. Selected per-profile via
|
||||
`mode: "document"` in profile JSON.
|
||||
"""
|
||||
|
|
@ -1,502 +0,0 @@
|
|||
"""PDF accessibility checks aligned to PDF/UA-1.
|
||||
|
||||
Two layers, applied in order:
|
||||
1. veraPDF subprocess — full PDF/UA-1 (ISO 14289-1) validation via the
|
||||
Matterhorn Protocol. This is the same protocol PAC uses, so its
|
||||
verdict is the authoritative one when veraPDF is available on the
|
||||
host. When it runs, its result drives the score and pass flag.
|
||||
2. Deterministic PyMuPDF criteria (C1-C9) — fast surface checks that
|
||||
run regardless. They give the AXA team a quick visual sanity-pass
|
||||
(tagged? language set? fonts embedded?) and are the sole source of
|
||||
truth when veraPDF is not installed.
|
||||
|
||||
Deterministic criteria:
|
||||
• C1 Tagged PDF — document has a /StructTreeRoot
|
||||
• C2 Marked — /MarkInfo /Marked is true
|
||||
• C3 Title — metadata /Title set and non-empty
|
||||
• C4 Language — document /Lang specified
|
||||
• C5 No password protection — /Encrypt absent or accessibility-friendly
|
||||
• C6 Fonts embedded — every font flagged as embedded
|
||||
• C7 PDF version — 1.5+ recommended
|
||||
• C8 XMP UA-conformance — XMP metadata declares pdfuaid:part
|
||||
• C9 Image alt text — sampled images have /Alt or /ActualText
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
|
||||
# Project-local install path for the production server (see vendor dir
|
||||
# under /opt/ai_qc/vendor/verapdf/). Falls back to PATH lookup or
|
||||
# VERAPDF_BIN env var.
|
||||
_VERAPDF_VENDOR_PATH = '/opt/ai_qc/vendor/verapdf/verapdf'
|
||||
_VERAPDF_TIMEOUT_SECONDS = 180
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _catalog_object(doc: fitz.Document) -> str:
|
||||
"""Return the catalog object dump as a string (PyMuPDF returns the
|
||||
PDF dictionary as a text representation we can grep)."""
|
||||
try:
|
||||
return doc.xref_object(doc.pdf_catalog())
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _xmp_metadata(doc: fitz.Document) -> str:
|
||||
"""Return the XMP metadata stream as a string, or '' if absent."""
|
||||
try:
|
||||
meta = doc.get_xml_metadata()
|
||||
return meta or ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _criterion(code: str, title: str, passed: bool, note: str = '', detail: Optional[Dict] = None) -> Dict:
|
||||
return {
|
||||
'code': code,
|
||||
'title': title,
|
||||
'passed': passed,
|
||||
'note': note,
|
||||
'detail': detail or {},
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Criterion implementations
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_tagged(doc: fitz.Document) -> Dict:
|
||||
catalog = _catalog_object(doc)
|
||||
has_struct = '/StructTreeRoot' in catalog
|
||||
return _criterion(
|
||||
'C1', 'Tagged PDF (StructTreeRoot present)',
|
||||
has_struct,
|
||||
'StructTreeRoot found in catalog.' if has_struct
|
||||
else 'PDF has no structure tree — screen readers will fall back to raw text. PDF/UA fail.',
|
||||
)
|
||||
|
||||
|
||||
def _check_marked(doc: fitz.Document) -> Dict:
|
||||
catalog = _catalog_object(doc)
|
||||
has_markinfo = '/MarkInfo' in catalog
|
||||
# /Marked must be true within /MarkInfo. PyMuPDF dump returns it as a
|
||||
# nested dict; we look for the literal "Marked true" pattern.
|
||||
is_marked = bool(re.search(r'/Marked\s+true', catalog))
|
||||
if has_markinfo and is_marked:
|
||||
return _criterion('C2', 'Marked content (/MarkInfo /Marked true)', True,
|
||||
'/MarkInfo /Marked = true.')
|
||||
if has_markinfo:
|
||||
return _criterion('C2', 'Marked content (/MarkInfo /Marked true)', False,
|
||||
'/MarkInfo present but /Marked is not true.')
|
||||
return _criterion('C2', 'Marked content (/MarkInfo /Marked true)', False,
|
||||
'/MarkInfo dictionary missing.')
|
||||
|
||||
|
||||
def _check_title(doc: fitz.Document) -> Dict:
|
||||
md = doc.metadata or {}
|
||||
title = (md.get('title') or '').strip()
|
||||
if title:
|
||||
return _criterion('C3', 'Document title metadata', True,
|
||||
f'Title: "{title[:80]}"')
|
||||
return _criterion('C3', 'Document title metadata', False,
|
||||
'Title metadata missing or empty.')
|
||||
|
||||
|
||||
def _check_language(doc: fitz.Document) -> Dict:
|
||||
lang = (doc.language or '').strip()
|
||||
if not lang:
|
||||
# Sometimes language is in the catalog but not exposed via doc.language
|
||||
catalog = _catalog_object(doc)
|
||||
m = re.search(r'/Lang\s*\(([^)]+)\)', catalog) or re.search(r'/Lang\s*<([^>]+)>', catalog)
|
||||
if m:
|
||||
lang = m.group(1)
|
||||
if lang:
|
||||
return _criterion('C4', 'Document language (/Lang)', True,
|
||||
f'Language: {lang}')
|
||||
return _criterion('C4', 'Document language (/Lang)', False,
|
||||
'/Lang missing — assistive tech cannot pick a voice/locale.')
|
||||
|
||||
|
||||
def _check_no_blocking_encryption(doc: fitz.Document) -> Dict:
|
||||
if doc.is_encrypted and doc.needs_pass:
|
||||
return _criterion('C5', 'No password protection blocking AT', False,
|
||||
'Document is password-protected — assistive tech cannot read.')
|
||||
return _criterion('C5', 'No password protection blocking AT', True,
|
||||
'No password block; assistive tech can read.')
|
||||
|
||||
|
||||
def _check_font_embedding(doc: fitz.Document) -> Dict:
|
||||
"""Walk every page, list every font, flag any not embedded."""
|
||||
seen: Dict[str, bool] = {}
|
||||
not_embedded: List[str] = []
|
||||
for i in range(doc.page_count):
|
||||
for f in doc.get_page_fonts(i):
|
||||
# PyMuPDF tuple: (xref, ext, type, basefont, name, encoding, embedded)
|
||||
basefont = f[3]
|
||||
ext = f[1] # '' if not embedded, file extension if embedded
|
||||
embedded = bool(ext)
|
||||
if basefont not in seen:
|
||||
seen[basefont] = embedded
|
||||
if not embedded:
|
||||
not_embedded.append(basefont)
|
||||
total = len(seen)
|
||||
embedded_count = sum(1 for v in seen.values() if v)
|
||||
if total == 0:
|
||||
return _criterion('C6', 'Fonts embedded', True, 'No fonts present.')
|
||||
if not_embedded:
|
||||
return _criterion('C6', 'Fonts embedded', False,
|
||||
f'{len(not_embedded)} of {total} fonts are not embedded.',
|
||||
{'not_embedded': not_embedded, 'total_fonts': total,
|
||||
'embedded_count': embedded_count})
|
||||
return _criterion('C6', 'Fonts embedded', True,
|
||||
f'All {total} fonts embedded.',
|
||||
{'total_fonts': total, 'embedded_count': embedded_count})
|
||||
|
||||
|
||||
def _check_pdf_version(doc: fitz.Document) -> Dict:
|
||||
md = doc.metadata or {}
|
||||
fmt = (md.get('format') or '').strip()
|
||||
m = re.search(r'PDF\s+(\d+\.\d+)', fmt)
|
||||
version = m.group(1) if m else None
|
||||
if not version:
|
||||
return _criterion('C7', 'PDF version', False, 'Could not determine PDF version.')
|
||||
try:
|
||||
version_num = float(version)
|
||||
except ValueError:
|
||||
return _criterion('C7', 'PDF version', False, f'Could not parse version: {fmt}')
|
||||
# PDF 1.5+ supports compressed cross-reference streams + most accessibility features
|
||||
if version_num >= 1.5:
|
||||
return _criterion('C7', 'PDF version', True, f'PDF {version} — supports modern tagging features.')
|
||||
return _criterion('C7', 'PDF version', False,
|
||||
f'PDF {version} is older than 1.5 — may not support full accessibility tagging.')
|
||||
|
||||
|
||||
def _check_xmp_ua_conformance(doc: fitz.Document) -> Dict:
|
||||
xmp = _xmp_metadata(doc)
|
||||
if not xmp:
|
||||
return _criterion('C8', 'XMP UA conformance declaration', False,
|
||||
'No XMP metadata stream found.')
|
||||
# PDF/UA-1 conformance is declared via pdfuaid:part = 1 in XMP
|
||||
if re.search(r'pdfuaid:part\s*[>=]\s*[\'"]?1', xmp):
|
||||
return _criterion('C8', 'XMP UA conformance declaration', True,
|
||||
'XMP declares PDF/UA-1 conformance.')
|
||||
if 'pdfuaid' in xmp:
|
||||
return _criterion('C8', 'XMP UA conformance declaration', False,
|
||||
'XMP mentions pdfuaid namespace but does not declare PDF/UA-1.')
|
||||
return _criterion('C8', 'XMP UA conformance declaration', False,
|
||||
'No PDF/UA conformance flag in XMP metadata.')
|
||||
|
||||
|
||||
def _check_alt_text_sampling(doc: fitz.Document) -> Dict:
|
||||
"""Sample-check the structure tree for /Alt entries when images are
|
||||
present. Heuristic: count images on the first 10 pages, and look for
|
||||
/Alt strings anywhere in the catalog graph. Not a full S→Figure walk,
|
||||
but a useful early signal — a doc with images and zero /Alt entries
|
||||
is almost certainly missing alt text.
|
||||
"""
|
||||
image_count = 0
|
||||
pages_with_images = 0
|
||||
for i in range(min(doc.page_count, 30)):
|
||||
imgs = doc.get_page_images(i)
|
||||
if imgs:
|
||||
pages_with_images += 1
|
||||
image_count += len(imgs)
|
||||
|
||||
if image_count == 0:
|
||||
return _criterion('C9', 'Alt text on images (sampling)', True,
|
||||
'No raster images detected in first 30 pages — no alt-text needed.')
|
||||
|
||||
# Search the catalog graph for /Alt(...) entries — coarse but effective
|
||||
alt_hits = 0
|
||||
sample_xrefs = list(range(1, min(doc.xref_length(), 500)))
|
||||
for xref in sample_xrefs:
|
||||
try:
|
||||
obj = doc.xref_object(xref)
|
||||
except Exception:
|
||||
continue
|
||||
if '/Alt' in obj or '/ActualText' in obj:
|
||||
alt_hits += 1
|
||||
|
||||
if alt_hits == 0:
|
||||
return _criterion('C9', 'Alt text on images (sampling)', False,
|
||||
f'{image_count} images detected but no /Alt or /ActualText found in sampled '
|
||||
f'structure objects.',
|
||||
{'image_count': image_count, 'pages_with_images': pages_with_images})
|
||||
return _criterion('C9', 'Alt text on images (sampling)', True,
|
||||
f'{image_count} images detected; {alt_hits} alt-text entries found in sampled objects.',
|
||||
{'image_count': image_count, 'pages_with_images': pages_with_images,
|
||||
'alt_hits': alt_hits})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Top-level entry point
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def axa_pdf_accessibility(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""Run PDF/UA-1 accessibility validation on the ingested PDF.
|
||||
|
||||
When veraPDF is installed on the host, its PDF/UA-1 verdict is the
|
||||
authoritative score driver. The deterministic PyMuPDF criteria run
|
||||
in either case as a quick sanity layer.
|
||||
"""
|
||||
pdf_path = ingest_result.get('pdf_path')
|
||||
if not pdf_path:
|
||||
return {
|
||||
'check_name': 'axa_pdf_accessibility',
|
||||
'scope': 'document',
|
||||
'score': 0.0,
|
||||
'pass': False,
|
||||
'summary': 'Cannot run — pdf_path missing from ingest_result.',
|
||||
'findings': {'error': 'pdf_path_missing'},
|
||||
'response': '',
|
||||
}
|
||||
|
||||
try:
|
||||
doc = fitz.open(pdf_path)
|
||||
except Exception as e:
|
||||
return {
|
||||
'check_name': 'axa_pdf_accessibility',
|
||||
'scope': 'document',
|
||||
'score': 0.0,
|
||||
'pass': False,
|
||||
'summary': f'Failed to open PDF: {e}',
|
||||
'findings': {'error': str(e)},
|
||||
'response': '',
|
||||
}
|
||||
|
||||
try:
|
||||
criteria = [
|
||||
_check_tagged(doc),
|
||||
_check_marked(doc),
|
||||
_check_title(doc),
|
||||
_check_language(doc),
|
||||
_check_no_blocking_encryption(doc),
|
||||
_check_font_embedding(doc),
|
||||
_check_pdf_version(doc),
|
||||
_check_xmp_ua_conformance(doc),
|
||||
_check_alt_text_sampling(doc),
|
||||
]
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
crit_passed = [c for c in criteria if c['passed']]
|
||||
crit_failed = [c for c in criteria if not c['passed']]
|
||||
crit_total = len(criteria)
|
||||
|
||||
verapdf = _run_verapdf(pdf_path)
|
||||
verapdf_ok = bool(verapdf and verapdf.get('available') and not verapdf.get('error'))
|
||||
|
||||
if verapdf_ok:
|
||||
score, pass_flag, summary = _score_from_verapdf(verapdf)
|
||||
else:
|
||||
score = round((len(crit_passed) / crit_total) * 10, 2) if crit_total else 0.0
|
||||
pass_flag = len(crit_failed) == 0
|
||||
if pass_flag:
|
||||
summary = f'All {crit_total} fast accessibility criteria passed (veraPDF unavailable — install for full PDF/UA-1 validation).'
|
||||
else:
|
||||
summary = f'{len(crit_failed)} of {crit_total} fast accessibility criteria failed (veraPDF unavailable).'
|
||||
|
||||
response = _build_response_text(summary, criteria, verapdf if verapdf_ok else None)
|
||||
|
||||
return {
|
||||
'check_name': 'axa_pdf_accessibility',
|
||||
'scope': 'document',
|
||||
'score': score,
|
||||
'pass': pass_flag,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'criteria': criteria,
|
||||
'criteria_total': crit_total,
|
||||
'criteria_passed': len(crit_passed),
|
||||
'criteria_failed': len(crit_failed),
|
||||
'verapdf_run': verapdf_ok,
|
||||
'verapdf': verapdf if verapdf else None,
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
def _score_from_verapdf(verapdf: Dict) -> tuple:
|
||||
"""Map veraPDF UA-1 verdict to (score, pass_flag, summary).
|
||||
|
||||
Severity ladder: any rule failure means the document is not PDF/UA-1,
|
||||
so pass_flag is False whenever veraPDF marks the file non-compliant.
|
||||
Score grades the depth of failure so partially-compliant documents
|
||||
still produce a meaningful number for trend tracking.
|
||||
"""
|
||||
if verapdf.get('compliant'):
|
||||
n_rules = verapdf.get('passed_rules', 0)
|
||||
return 10.0, True, f'PDF/UA-1 compliant per veraPDF ({n_rules} rules passed).'
|
||||
|
||||
n_failed = verapdf.get('failed_rules', 0)
|
||||
n_failed_checks = verapdf.get('failed_checks', 0)
|
||||
if n_failed <= 1:
|
||||
score = 5.0
|
||||
elif n_failed == 2:
|
||||
score = 3.0
|
||||
else:
|
||||
score = 0.0
|
||||
summary = (
|
||||
f'PDF/UA-1 non-compliant per veraPDF: {n_failed} rule(s) failed '
|
||||
f'across {n_failed_checks} individual check(s).'
|
||||
)
|
||||
return score, False, summary
|
||||
|
||||
|
||||
def _build_response_text(summary: str, criteria: List[Dict], verapdf: Optional[Dict]) -> str:
|
||||
"""Plain-text response shown in the QC report's response block."""
|
||||
lines = [summary, '']
|
||||
|
||||
if verapdf:
|
||||
lines.append('── veraPDF PDF/UA-1 ──')
|
||||
verdict = 'COMPLIANT' if verapdf.get('compliant') else 'NOT COMPLIANT'
|
||||
lines.append(f' Verdict: {verdict}')
|
||||
lines.append(
|
||||
f' Rules: {verapdf.get("passed_rules", 0)} passed / '
|
||||
f'{verapdf.get("failed_rules", 0)} failed'
|
||||
)
|
||||
lines.append(
|
||||
f' Checks: {verapdf.get("passed_checks", 0)} passed / '
|
||||
f'{verapdf.get("failed_checks", 0)} failed'
|
||||
)
|
||||
for r in verapdf.get('failed_rule_details', []):
|
||||
tag_str = ', '.join(r.get('tags') or []) or '—'
|
||||
lines.append('')
|
||||
lines.append(
|
||||
f' ✗ Clause {r["clause"]}-{r["test_number"]} '
|
||||
f'(×{r["failed_checks"]}, {tag_str})'
|
||||
)
|
||||
lines.append(f' {r["description"]}')
|
||||
for s in r.get('sample_errors', [])[:1]:
|
||||
lines.append(f' e.g. {s}')
|
||||
lines.append('')
|
||||
|
||||
lines.append('── Fast deterministic criteria ──')
|
||||
for c in criteria:
|
||||
marker = '✓' if c['passed'] else '✗'
|
||||
lines.append(f" {marker} {c['code']} — {c['title']}: {c['note']}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# veraPDF integration
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_verapdf_binary() -> Optional[str]:
|
||||
"""Locate the veraPDF executable. Order: VERAPDF_BIN env > PATH >
|
||||
project-local vendor install. Returns None if veraPDF is not
|
||||
installed; the check then falls back to deterministic-only mode.
|
||||
"""
|
||||
env_path = os.environ.get('VERAPDF_BIN')
|
||||
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||
return env_path
|
||||
path_lookup = shutil.which('verapdf')
|
||||
if path_lookup:
|
||||
return path_lookup
|
||||
if os.path.isfile(_VERAPDF_VENDOR_PATH) and os.access(_VERAPDF_VENDOR_PATH, os.X_OK):
|
||||
return _VERAPDF_VENDOR_PATH
|
||||
return None
|
||||
|
||||
|
||||
def _run_verapdf(pdf_path: str) -> Optional[Dict]:
|
||||
"""Run veraPDF PDF/UA-1 validation. Returns a structured result dict
|
||||
or None when veraPDF is not installed. Returns a dict with 'error'
|
||||
populated if the subprocess ran but failed in some recoverable way.
|
||||
"""
|
||||
binary = _resolve_verapdf_binary()
|
||||
if not binary:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[binary, '-f', 'ua1', '--format', 'xml', '--maxfailuresdisplayed', '3', pdf_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_VERAPDF_TIMEOUT_SECONDS,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'available': True, 'binary': binary, 'error': f'veraPDF timed out after {_VERAPDF_TIMEOUT_SECONDS}s'}
|
||||
except Exception as e:
|
||||
return {'available': True, 'binary': binary, 'error': f'veraPDF subprocess failed: {e}'}
|
||||
|
||||
if not result.stdout:
|
||||
return {
|
||||
'available': True,
|
||||
'binary': binary,
|
||||
'error': 'veraPDF produced no output',
|
||||
'stderr': (result.stderr or '')[:500],
|
||||
}
|
||||
|
||||
try:
|
||||
root = ET.fromstring(result.stdout)
|
||||
except ET.ParseError as e:
|
||||
return {
|
||||
'available': True,
|
||||
'binary': binary,
|
||||
'error': f'Could not parse veraPDF XML: {e}',
|
||||
}
|
||||
|
||||
vr = root.find('.//validationReport')
|
||||
if vr is None:
|
||||
return {
|
||||
'available': True,
|
||||
'binary': binary,
|
||||
'error': 'No validationReport in veraPDF output',
|
||||
}
|
||||
|
||||
details = vr.find('details')
|
||||
rules: List[Dict] = []
|
||||
if details is not None:
|
||||
for rule in details.findall('rule'):
|
||||
tags = (rule.get('tags') or '').split(',')
|
||||
tags = [t for t in tags if t]
|
||||
rules.append({
|
||||
'specification': rule.get('specification'),
|
||||
'clause': rule.get('clause'),
|
||||
'test_number': rule.get('testNumber'),
|
||||
'tags': tags,
|
||||
'failed_checks': int(rule.get('failedChecks') or 0),
|
||||
'description': (rule.findtext('description') or '').strip(),
|
||||
'sample_errors': [
|
||||
(c.findtext('errorMessage') or '').strip()
|
||||
for c in rule.findall('check')[:2]
|
||||
],
|
||||
})
|
||||
|
||||
def _detail_int(name: str) -> int:
|
||||
if details is None:
|
||||
return 0
|
||||
try:
|
||||
return int(details.get(name) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
return {
|
||||
'available': True,
|
||||
'binary': binary,
|
||||
'compliant': vr.get('isCompliant') == 'true',
|
||||
'profile': vr.get('profileName', 'PDF/UA-1'),
|
||||
'statement': vr.get('statement', ''),
|
||||
'passed_rules': _detail_int('passedRules'),
|
||||
'failed_rules': _detail_int('failedRules'),
|
||||
'passed_checks': _detail_int('passedChecks'),
|
||||
'failed_checks': _detail_int('failedChecks'),
|
||||
'failed_rule_details': rules,
|
||||
}
|
||||
|
|
@ -1,531 +0,0 @@
|
|||
"""Document-scope deterministic checks for AXA policy QC.
|
||||
|
||||
Each check is a function that takes (ingest_result, scope_args) and returns
|
||||
a result dict. None of them call an LLM — they operate on the structured
|
||||
text + font data already produced by ingest.py, so they're $0 and run in
|
||||
milliseconds. Registered in CHECK_REGISTRY for the dispatcher to look up.
|
||||
|
||||
Result schema:
|
||||
{
|
||||
'check_name': str,
|
||||
'scope': str,
|
||||
'score': float, # 0-10, used for the overall doc score
|
||||
'pass': bool, # findings-driven
|
||||
'summary': str, # one-line headline for the report
|
||||
'findings': dict, # structured payload (lists, counts, etc.)
|
||||
'response': str, # human-readable longform for the report
|
||||
}
|
||||
|
||||
For now, "list-only" checks (font_inventory, phone_inventory) score 10/10 —
|
||||
they're informational. Once approved-list configs land, they'll flip to
|
||||
compliance scoring.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
_DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
|
||||
|
||||
|
||||
def _load_bold_words_seed() -> Dict[str, Any]:
|
||||
path = os.path.join(_DATA_DIR, 'axa_bold_words_seed.json')
|
||||
if not os.path.exists(path):
|
||||
return {'terms': [], 'source': 'missing'}
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Checks
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def axa_font_inventory(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""List every unique font found in the document, with per-page distribution.
|
||||
|
||||
Until AXA provides an approved Monotype font list, this is informational
|
||||
(score 10). When the list arrives, this check becomes axa_font_compliance
|
||||
and flags non-approved fonts.
|
||||
"""
|
||||
font_pages: Dict[str, List[int]] = defaultdict(list)
|
||||
for page in ingest_result.get('pages', []):
|
||||
for font in page.get('fonts_used') or []:
|
||||
font_pages[font].append(page['page_num'])
|
||||
|
||||
fonts_sorted = sorted(font_pages.keys())
|
||||
distribution = [
|
||||
{'font': f, 'page_count': len(font_pages[f]), 'pages': font_pages[f]}
|
||||
for f in fonts_sorted
|
||||
]
|
||||
|
||||
summary = f"Found {len(fonts_sorted)} unique fonts across {ingest_result.get('pages_processed', 0)} pages."
|
||||
response_lines = [summary, '']
|
||||
for d in distribution:
|
||||
response_lines.append(f" • {d['font']} — {d['page_count']} pages")
|
||||
response = '\n'.join(response_lines)
|
||||
|
||||
return {
|
||||
'check_name': 'axa_font_inventory',
|
||||
'scope': 'document',
|
||||
'score': 10.0,
|
||||
'pass': True,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'unique_fonts': fonts_sorted,
|
||||
'total_unique': len(fonts_sorted),
|
||||
'distribution': distribution,
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
def axa_phone_inventory(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""Extract every phone number found in the document, deduplicated.
|
||||
|
||||
Targets common Irish/UK formats: 1800/1850 freephones, +353 international,
|
||||
01 / 021 etc. landlines. Until an approved list is supplied, this is
|
||||
informational (score 10). Becomes axa_phone_compliance when list lands.
|
||||
"""
|
||||
# Capture: optional + country code, optional spaces/dashes/parens between groups
|
||||
# Conservative regex — Irish/UK shapes with at least 8 total digits.
|
||||
pattern = re.compile(
|
||||
r"\(?\+?\d{1,3}\)?[\s\-.]?(?:\(?\d{2,5}\)?[\s\-.]?){1,3}\d{2,4}"
|
||||
)
|
||||
raw_finds: Dict[str, List[int]] = defaultdict(list)
|
||||
for page in ingest_result.get('pages', []):
|
||||
text = page.get('raw_text') or ''
|
||||
for match in pattern.finditer(text):
|
||||
number = re.sub(r'[\s\-.()]+', ' ', match.group()).strip()
|
||||
digits = re.sub(r'\D', '', number)
|
||||
# Filter: must have ≥ 8 digits to count as a phone number
|
||||
if len(digits) < 8 or len(digits) > 15:
|
||||
continue
|
||||
raw_finds[number].append(page['page_num'])
|
||||
|
||||
numbers_sorted = sorted(raw_finds.keys())
|
||||
distribution = [
|
||||
{'number': n, 'occurrences': len(raw_finds[n]), 'pages': sorted(set(raw_finds[n]))}
|
||||
for n in numbers_sorted
|
||||
]
|
||||
|
||||
summary = f"Found {len(numbers_sorted)} unique phone-like numbers in the document."
|
||||
response_lines = [summary, '']
|
||||
for d in distribution:
|
||||
response_lines.append(f" • {d['number']} — {d['occurrences']} occurrences (pages {d['pages']})")
|
||||
response = '\n'.join(response_lines) if numbers_sorted else f"{summary}\n(No phone-like numbers detected.)"
|
||||
|
||||
return {
|
||||
'check_name': 'axa_phone_inventory',
|
||||
'scope': 'document',
|
||||
'score': 10.0,
|
||||
'pass': True,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'unique_numbers': numbers_sorted,
|
||||
'total_unique': len(numbers_sorted),
|
||||
'distribution': distribution,
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
def axa_bold_words_definitions(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""For each defined term in the seed dictionary, scan all pages: every
|
||||
occurrence outside the definitions section should be rendered bold. Flag
|
||||
any non-bold occurrences — these are the Example-2-class misses (70+
|
||||
bold definitions that shipped not bolded).
|
||||
"""
|
||||
seed = _load_bold_words_seed()
|
||||
terms = seed.get('terms', [])
|
||||
if not terms:
|
||||
return {
|
||||
'check_name': 'axa_bold_words_definitions',
|
||||
'scope': 'document',
|
||||
'score': 0.0,
|
||||
'pass': False,
|
||||
'summary': 'No bold-words seed dictionary found — check disabled.',
|
||||
'findings': {'error': 'seed_missing'},
|
||||
'response': 'Cannot run — backend/document_mode/data/axa_bold_words_seed.json missing or empty.',
|
||||
}
|
||||
|
||||
# Pre-compile case-insensitive whole-word patterns
|
||||
term_patterns = [
|
||||
(term, re.compile(r'\b' + re.escape(term) + r'\b', re.IGNORECASE))
|
||||
for term in terms
|
||||
]
|
||||
|
||||
# Pages where the definitions section itself lives — by convention exclude
|
||||
# them from the violation count (the term is defined there, not used).
|
||||
definitions_pages = set(
|
||||
s.get('source_page') for s in seed.get('sources', []) if s.get('source_page')
|
||||
)
|
||||
if scope_args and scope_args.get('exclude_pages'):
|
||||
definitions_pages.update(scope_args['exclude_pages'])
|
||||
|
||||
violations: List[Dict] = []
|
||||
bold_occurrences = 0
|
||||
non_bold_occurrences = 0
|
||||
|
||||
for page in ingest_result.get('pages', []):
|
||||
page_num = page['page_num']
|
||||
if page_num in definitions_pages:
|
||||
continue
|
||||
spans = page.get('spans') or []
|
||||
|
||||
for span in spans:
|
||||
span_text = span.get('text') or ''
|
||||
if not span_text:
|
||||
continue
|
||||
for term, pattern in term_patterns:
|
||||
if pattern.search(span_text):
|
||||
if span.get('bold'):
|
||||
bold_occurrences += 1
|
||||
else:
|
||||
non_bold_occurrences += 1
|
||||
violations.append({
|
||||
'page': page_num,
|
||||
'term': term,
|
||||
'context': span_text,
|
||||
'font': span.get('font'),
|
||||
'size': span.get('size'),
|
||||
})
|
||||
|
||||
total = bold_occurrences + non_bold_occurrences
|
||||
pass_flag = non_bold_occurrences == 0
|
||||
|
||||
if total == 0:
|
||||
score = 10.0
|
||||
summary = f"No occurrences of {len(terms)} defined terms found outside the definitions section."
|
||||
elif pass_flag:
|
||||
score = 10.0
|
||||
summary = f"All {bold_occurrences} occurrences of {len(terms)} defined terms are correctly rendered bold."
|
||||
else:
|
||||
ratio = bold_occurrences / total
|
||||
score = round(max(0.0, min(10.0, ratio * 10)), 2)
|
||||
summary = (
|
||||
f"{non_bold_occurrences} non-bold occurrences of defined terms found "
|
||||
f"(across {len({v['page'] for v in violations})} pages). "
|
||||
f"{bold_occurrences} occurrences correctly bold."
|
||||
)
|
||||
|
||||
response_lines = [summary, '']
|
||||
if violations:
|
||||
response_lines.append('Non-bold violations (first 50 shown):')
|
||||
for v in violations[:50]:
|
||||
ctx = v['context'][:80] + ('…' if len(v['context']) > 80 else '')
|
||||
response_lines.append(f" • Page {v['page']}: '{v['term']}' in: \"{ctx}\"")
|
||||
if len(violations) > 50:
|
||||
response_lines.append(f' ...and {len(violations) - 50} more.')
|
||||
response = '\n'.join(response_lines)
|
||||
|
||||
return {
|
||||
'check_name': 'axa_bold_words_definitions',
|
||||
'scope': 'document',
|
||||
'score': score,
|
||||
'pass': pass_flag,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'dictionary_size': len(terms),
|
||||
'definitions_pages_excluded': sorted(definitions_pages),
|
||||
'bold_occurrences': bold_occurrences,
|
||||
'non_bold_occurrences': non_bold_occurrences,
|
||||
'violations': violations,
|
||||
'pages_with_violations': sorted({v['page'] for v in violations}),
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
def axa_page_numbering(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""Verify the document's printed page numbering is continuous (1, 2, 3 …)
|
||||
by parsing the first integer found near the top or bottom of each page's
|
||||
raw text. Catches Example-2's 'missing page' defect.
|
||||
|
||||
NB: this is heuristic — relies on the page number being a standalone
|
||||
digit on its own line. Skips pages where no candidate is found.
|
||||
"""
|
||||
found_numbers: List[Optional[int]] = []
|
||||
candidates_by_page: List[Dict] = []
|
||||
|
||||
for page in ingest_result.get('pages', []):
|
||||
raw = page.get('raw_text') or ''
|
||||
# Only inspect the first and last 200 chars (where page numbers usually live)
|
||||
head = raw[:200]
|
||||
tail = raw[-200:] if len(raw) > 400 else raw
|
||||
candidate = None
|
||||
# Look for standalone-line integers
|
||||
for chunk in (tail, head): # tail first — footer numbering is more common
|
||||
for line in chunk.splitlines():
|
||||
line_clean = line.strip()
|
||||
if line_clean.isdigit():
|
||||
n = int(line_clean)
|
||||
if 0 < n < 1000:
|
||||
candidate = n
|
||||
break
|
||||
if candidate is not None:
|
||||
break
|
||||
found_numbers.append(candidate)
|
||||
candidates_by_page.append({'page_index': page['page_num'], 'detected_number': candidate})
|
||||
|
||||
# Walk the sequence: expect each detected number to equal previous + 1
|
||||
issues = []
|
||||
expected = None
|
||||
for entry in candidates_by_page:
|
||||
n = entry['detected_number']
|
||||
if n is None:
|
||||
continue # skip pages with no detectable number
|
||||
if expected is not None and n != expected:
|
||||
issues.append({
|
||||
'page_index': entry['page_index'],
|
||||
'expected': expected,
|
||||
'detected': n,
|
||||
})
|
||||
expected = n + 1
|
||||
|
||||
detected_count = sum(1 for e in candidates_by_page if e['detected_number'] is not None)
|
||||
|
||||
# Insurance docs often have unnumbered TOC / divider pages, so isolated
|
||||
# discontinuities are normal. Score gently — surface the data, let the
|
||||
# reviewer judge whether a gap is a real missing-page defect or a
|
||||
# legitimate unnumbered section divider.
|
||||
if detected_count == 0:
|
||||
score = 5.0
|
||||
summary = 'No page numbers detected — cannot validate continuity.'
|
||||
pass_flag = False
|
||||
elif issues:
|
||||
# Cap penalty: 1 discontinuity → 8/10, 5+ → 5/10
|
||||
score = round(max(5.0, 10 - len(issues) * 0.8), 2)
|
||||
summary = (
|
||||
f'{len(issues)} page-number discontinuit{"y" if len(issues) == 1 else "ies"} '
|
||||
f'detected (heuristic — review against the doc to confirm).'
|
||||
)
|
||||
pass_flag = False
|
||||
else:
|
||||
score = 10.0
|
||||
summary = f'Page numbering continuous across {detected_count} pages with detectable numbers.'
|
||||
pass_flag = True
|
||||
|
||||
response_lines = [summary, '']
|
||||
if issues:
|
||||
response_lines.append('Discontinuities:')
|
||||
for i in issues:
|
||||
response_lines.append(
|
||||
f" • Page index {i['page_index']}: expected {i['expected']}, found {i['detected']}"
|
||||
)
|
||||
response = '\n'.join(response_lines)
|
||||
|
||||
return {
|
||||
'check_name': 'axa_page_numbering',
|
||||
'scope': 'document',
|
||||
'score': score,
|
||||
'pass': pass_flag,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'pages_total': ingest_result.get('pages_processed', 0),
|
||||
'pages_with_detected_number': detected_count,
|
||||
'discontinuities': issues,
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Targeted checks (specific page or page set)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_pages(scope_args: Optional[Dict], ingest_result: Dict) -> List[int]:
|
||||
"""Resolve a scope_args.pages spec to actual page numbers.
|
||||
|
||||
Supported specs: "first", "last", "first-N", "last-N", or an explicit list of ints.
|
||||
"""
|
||||
pages_processed = ingest_result.get('pages_processed', 0)
|
||||
if pages_processed == 0:
|
||||
return []
|
||||
if not scope_args or 'pages' not in scope_args:
|
||||
return [pages_processed] # default: last page
|
||||
spec = scope_args['pages']
|
||||
if isinstance(spec, list):
|
||||
return [p for p in spec if 1 <= p <= pages_processed]
|
||||
if spec == 'first':
|
||||
return [1]
|
||||
if spec == 'last':
|
||||
return [pages_processed]
|
||||
if isinstance(spec, str) and spec.startswith('first-'):
|
||||
n = int(spec.split('-', 1)[1])
|
||||
return list(range(1, min(n, pages_processed) + 1))
|
||||
if isinstance(spec, str) and spec.startswith('last-'):
|
||||
n = int(spec.split('-', 1)[1])
|
||||
return list(range(max(1, pages_processed - n + 1), pages_processed + 1))
|
||||
return [pages_processed]
|
||||
|
||||
|
||||
def _collect_text_for_pages(ingest_result: Dict, page_nums: List[int]) -> str:
|
||||
text_chunks = []
|
||||
for page in ingest_result.get('pages', []):
|
||||
if page['page_num'] in page_nums:
|
||||
text_chunks.append(page.get('raw_text') or '')
|
||||
return '\n'.join(text_chunks)
|
||||
|
||||
|
||||
def axa_print_code(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""Find and report the print code on the targeted page(s) — usually back
|
||||
page only. AXA print codes look like 'AXA-XX-NNNN' or '1234-5678' in
|
||||
documented practice; we'll surface anything that matches a code-like
|
||||
pattern in the page footer/back text and let the user confirm.
|
||||
"""
|
||||
pages = _resolve_pages(scope_args, ingest_result)
|
||||
text = _collect_text_for_pages(ingest_result, pages)
|
||||
|
||||
# AXA Ireland back-page print line — observed real format on Example 1:
|
||||
# "AG400 11/25 6317047 V8"
|
||||
# Pattern: 2-4 letter prefix + 2-5 digits, optionally followed by date + ref + version
|
||||
code_pattern = re.compile(r'\b[A-Z]{2,4}\d{2,5}\b')
|
||||
date_pattern = re.compile(r'\b\d{1,2}[-/]\d{2,4}\b')
|
||||
version_pattern = re.compile(r'\bV\d{1,3}\b')
|
||||
ref_pattern = re.compile(r'\b\d{6,8}\b')
|
||||
|
||||
code_matches = list(dict.fromkeys(m.group() for m in code_pattern.finditer(text)))
|
||||
date_matches = list(dict.fromkeys(m.group() for m in date_pattern.finditer(text)))
|
||||
version_matches = list(dict.fromkeys(m.group() for m in version_pattern.finditer(text)))
|
||||
ref_matches = list(dict.fromkeys(m.group() for m in ref_pattern.finditer(text)))
|
||||
|
||||
matches = code_matches + ref_matches + date_matches + version_matches
|
||||
|
||||
has_code = bool(code_matches)
|
||||
has_date = bool(date_matches)
|
||||
has_version = bool(version_matches)
|
||||
component_count = sum([has_code, has_date, has_version])
|
||||
|
||||
if component_count >= 2:
|
||||
score = 10.0
|
||||
pass_flag = True
|
||||
summary = (
|
||||
f'Print/version line found on page(s) {pages}: code={code_matches}, '
|
||||
f'date={date_matches}, version={version_matches}.'
|
||||
)
|
||||
elif component_count == 1:
|
||||
score = 6.0
|
||||
pass_flag = False
|
||||
summary = f'Partial print/version line on page(s) {pages} — some components missing.'
|
||||
else:
|
||||
score = 3.0
|
||||
pass_flag = False
|
||||
summary = f'No print-code-shaped content found on page(s) {pages}.'
|
||||
|
||||
response_lines = [summary, '']
|
||||
response_lines.append(f'Code candidates: {code_matches or "(none)"}')
|
||||
response_lines.append(f'Document refs: {ref_matches or "(none)"}')
|
||||
response_lines.append(f'Date candidates: {date_matches or "(none)"}')
|
||||
response_lines.append(f'Version candidates: {version_matches or "(none)"}')
|
||||
response = '\n'.join(response_lines)
|
||||
|
||||
return {
|
||||
'check_name': 'axa_print_code',
|
||||
'scope': 'targeted',
|
||||
'score': score,
|
||||
'pass': pass_flag,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'pages_inspected': pages,
|
||||
'code_candidates': code_matches,
|
||||
'doc_refs': ref_matches,
|
||||
'date_candidates': date_matches,
|
||||
'version_candidates': version_matches,
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
def axa_omg_versioning(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""OMG number + date format check on the targeted page(s) (back page).
|
||||
|
||||
OMG codes — per AXA convention — look like 'OMG-XXXXX' or 'OMG XXXXX'.
|
||||
Date formats expected: dd/mm/yyyy or 'Month YYYY' on back page.
|
||||
"""
|
||||
pages = _resolve_pages(scope_args, ingest_result)
|
||||
text = _collect_text_for_pages(ingest_result, pages)
|
||||
|
||||
omg_pattern = re.compile(r'\bOMG[\s-]?[A-Z0-9]{2,8}\b', re.IGNORECASE)
|
||||
date_patterns = [
|
||||
re.compile(r'\b\d{1,2}/\d{1,2}/\d{2,4}\b'),
|
||||
re.compile(r'\b\d{1,2}-\d{1,2}-\d{2,4}\b'),
|
||||
re.compile(r'\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}\b'),
|
||||
]
|
||||
|
||||
omg_matches = list({m.group() for m in omg_pattern.finditer(text)})
|
||||
date_matches = []
|
||||
for p in date_patterns:
|
||||
date_matches.extend(m.group() for m in p.finditer(text))
|
||||
date_matches = list(dict.fromkeys(date_matches)) # dedup, preserve order
|
||||
|
||||
if omg_matches and date_matches:
|
||||
score = 10.0
|
||||
pass_flag = True
|
||||
summary = f'OMG code + date format both present on page(s) {pages}.'
|
||||
elif omg_matches:
|
||||
score = 7.0
|
||||
pass_flag = False
|
||||
summary = 'OMG code found, but no recognisable date format on the targeted page(s).'
|
||||
elif date_matches:
|
||||
score = 5.0
|
||||
pass_flag = False
|
||||
summary = 'Date format present, but no OMG code found on the targeted page(s).'
|
||||
else:
|
||||
score = 3.0
|
||||
pass_flag = False
|
||||
summary = 'Neither OMG code nor date format detected on the targeted page(s).'
|
||||
|
||||
response_lines = [summary, '']
|
||||
response_lines.append(f'OMG matches: {omg_matches or "(none)"}')
|
||||
response_lines.append(f'Date matches: {date_matches or "(none)"}')
|
||||
response = '\n'.join(response_lines)
|
||||
|
||||
return {
|
||||
'check_name': 'axa_omg_versioning',
|
||||
'scope': 'targeted',
|
||||
'score': score,
|
||||
'pass': pass_flag,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'pages_inspected': pages,
|
||||
'omg_matches': omg_matches,
|
||||
'date_matches': date_matches,
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Registry
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
from .accessibility_checks import axa_pdf_accessibility
|
||||
from .print_preflight_checks import axa_print_preflight
|
||||
|
||||
|
||||
CHECK_REGISTRY = {
|
||||
'axa_font_inventory': {'fn': axa_font_inventory, 'scope': 'document'},
|
||||
'axa_phone_inventory': {'fn': axa_phone_inventory, 'scope': 'document'},
|
||||
'axa_bold_words_definitions': {'fn': axa_bold_words_definitions, 'scope': 'document'},
|
||||
'axa_page_numbering': {'fn': axa_page_numbering, 'scope': 'document'},
|
||||
'axa_print_code': {'fn': axa_print_code, 'scope': 'targeted'},
|
||||
'axa_omg_versioning': {'fn': axa_omg_versioning, 'scope': 'targeted'},
|
||||
'axa_pdf_accessibility': {'fn': axa_pdf_accessibility, 'scope': 'document'},
|
||||
'axa_print_preflight': {'fn': axa_print_preflight, 'scope': 'document'},
|
||||
}
|
||||
|
||||
|
||||
def get_check(check_name: str):
|
||||
"""Return registry entry for a check name, or None if unknown."""
|
||||
return CHECK_REGISTRY.get(check_name)
|
||||
|
||||
|
||||
def is_document_scope_check(check_name: str) -> bool:
|
||||
"""True if this check is one of our document-mode deterministic checks."""
|
||||
return check_name in CHECK_REGISTRY
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
{
|
||||
"source": "Example 1 (Home Insurance V8) \u2014 General Definitions pages 8-10",
|
||||
"extracted": "2026-05-01",
|
||||
"note": "Bootstrap dictionary until AXA provides canonical bold-words list. Add/remove terms here.",
|
||||
"terms": [
|
||||
"Accidental damage",
|
||||
"Buildings",
|
||||
"schedule",
|
||||
"proposal form",
|
||||
"statement of",
|
||||
"fact",
|
||||
"outbuildings",
|
||||
"home",
|
||||
"Contents",
|
||||
"you",
|
||||
"your",
|
||||
"home office equipment",
|
||||
"personal belongings",
|
||||
"valuables",
|
||||
"Domestic purposes",
|
||||
"home working",
|
||||
"Endorsement",
|
||||
"Europe",
|
||||
"Excess",
|
||||
"Flood",
|
||||
"Period of insurance",
|
||||
"your policy",
|
||||
"your schedule",
|
||||
"your statement of fact/proposal form",
|
||||
"endorsements",
|
||||
"Powered Personal Transporters (PPT\u2019s)",
|
||||
"excesses",
|
||||
"Statement of fact / Proposal form",
|
||||
"Storm",
|
||||
"Subsidence",
|
||||
"Unfurnished",
|
||||
"Unoccupied",
|
||||
"your home",
|
||||
"We, our, us",
|
||||
"You, your, the insured"
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"term": "Accidental damage",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "Buildings",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "schedule",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "proposal form",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "statement of",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "fact",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "outbuildings",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "home",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "Contents",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "you",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "your",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "home office equipment",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "personal belongings",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "valuables",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "Domestic purposes",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "home working",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "Endorsement",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "Europe",
|
||||
"source_page": 8
|
||||
},
|
||||
{
|
||||
"term": "Excess",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "Flood",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "Period of insurance",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "your policy",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "your schedule",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "your statement of fact/proposal form",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "endorsements",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "Powered Personal Transporters (PPT\u2019s)",
|
||||
"source_page": 9
|
||||
},
|
||||
{
|
||||
"term": "excesses",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "Statement of fact / Proposal form",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "Storm",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "Subsidence",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "Unfurnished",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "Unoccupied",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "your home",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "We, our, us",
|
||||
"source_page": 10
|
||||
},
|
||||
{
|
||||
"term": "You, your, the insured",
|
||||
"source_page": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,524 +0,0 @@
|
|||
"""Old-vs-new PDF diff engine for AXA Policy Document Diff.
|
||||
|
||||
Two-stage pipeline:
|
||||
|
||||
1. Page alignment — fuzzy text matching (difflib.SequenceMatcher) between
|
||||
old.pages and new.pages. Each old page either pairs with a new page
|
||||
(above similarity threshold) or is marked as removed; new pages with
|
||||
no old counterpart are marked as added.
|
||||
|
||||
2. Vision LLM page-pair diff — for each aligned pair, send both rendered
|
||||
PNGs to Gemini with a structured prompt asking what changed. Output
|
||||
is parsed JSON: added/removed/moved/style-changes plus a severity tag.
|
||||
|
||||
Cost shape: ~1 LLM call per aligned page-pair. For an 80-page policy that's
|
||||
~$0.40-0.80 with Gemini 2.5 Pro. Pairs run in parallel via ThreadPoolExecutor
|
||||
(max 8 concurrent — conservative, room to tune).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# Similarity threshold for considering two pages "the same page modified"
|
||||
# vs "an inserted/removed page". Tuned for policy docs where page-level text
|
||||
# fingerprint is highly distinctive (section headers + body para). 0.4 is
|
||||
# permissive enough to handle small-paragraph rewrites; below that we
|
||||
# treat the page as inserted/removed rather than modified.
|
||||
SIMILARITY_THRESHOLD = 0.4
|
||||
|
||||
# Window — how many positions ahead/behind to scan for the best new-page
|
||||
# match for each old page. Avoids O(N²) blowup on long docs while still
|
||||
# accommodating moderate page-shift caused by inserts/removes.
|
||||
ALIGNMENT_WINDOW = 8
|
||||
|
||||
# Max images sent to vision LLM in parallel
|
||||
PARALLEL_PAIRS = 8
|
||||
|
||||
|
||||
def _fingerprint(raw_text: str, length: int = 1000) -> str:
|
||||
"""Normalise text for similarity comparison. Lowercased, whitespace
|
||||
collapsed, truncated to first N chars (page header + first paragraph
|
||||
is usually distinctive enough)."""
|
||||
if not raw_text:
|
||||
return ''
|
||||
norm = re.sub(r'\s+', ' ', raw_text.lower()).strip()
|
||||
return norm[:length]
|
||||
|
||||
|
||||
def _text_similarity(a: str, b: str) -> float:
|
||||
"""0.0–1.0 similarity ratio between two normalised page texts."""
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
|
||||
def align_pages(old_pages: List[Dict], new_pages: List[Dict]) -> List[Dict]:
|
||||
"""Greedy page alignment with windowed lookahead.
|
||||
|
||||
Returns a list of alignment entries describing how old/new pages map
|
||||
onto each other:
|
||||
|
||||
[
|
||||
{'old_page': 1, 'new_page': 1, 'similarity': 0.99, 'status': 'matched'},
|
||||
{'old_page': null, 'new_page': 5, 'status': 'added'},
|
||||
{'old_page': 47, 'new_page': null, 'status': 'removed'},
|
||||
...
|
||||
]
|
||||
|
||||
Algorithm:
|
||||
• Walk old pages 1..N. For each, scan new pages within
|
||||
[last_matched_new + 1, last_matched_new + 1 + WINDOW] for the best
|
||||
text-similarity match.
|
||||
• If best match ≥ threshold: pair them and advance both cursors.
|
||||
• If best match < threshold: mark old page as 'removed', advance only
|
||||
the old cursor.
|
||||
• Any new pages skipped over by the cursor are marked as 'added'.
|
||||
"""
|
||||
old_fps = [_fingerprint(p.get('raw_text') or '') for p in old_pages]
|
||||
new_fps = [_fingerprint(p.get('raw_text') or '') for p in new_pages]
|
||||
|
||||
alignment: List[Dict] = []
|
||||
new_cursor = 0 # Next unmatched new page index
|
||||
new_consumed = set()
|
||||
|
||||
for old_idx, old_fp in enumerate(old_fps):
|
||||
best_score = -1.0
|
||||
best_new_idx = -1
|
||||
|
||||
# Search forward from new_cursor up to WINDOW pages ahead
|
||||
scan_end = min(len(new_fps), new_cursor + ALIGNMENT_WINDOW + 1)
|
||||
for j in range(new_cursor, scan_end):
|
||||
if j in new_consumed:
|
||||
continue
|
||||
score = _text_similarity(old_fp, new_fps[j])
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_new_idx = j
|
||||
|
||||
if best_score >= SIMILARITY_THRESHOLD:
|
||||
# Any new pages between new_cursor and best_new_idx are inserts
|
||||
for k in range(new_cursor, best_new_idx):
|
||||
if k not in new_consumed:
|
||||
alignment.append({
|
||||
'old_page': None,
|
||||
'new_page': new_pages[k]['page_num'],
|
||||
'similarity': None,
|
||||
'status': 'added',
|
||||
})
|
||||
alignment.append({
|
||||
'old_page': old_pages[old_idx]['page_num'],
|
||||
'new_page': new_pages[best_new_idx]['page_num'],
|
||||
'similarity': round(best_score, 3),
|
||||
'status': 'matched',
|
||||
})
|
||||
new_consumed.add(best_new_idx)
|
||||
new_cursor = best_new_idx + 1
|
||||
else:
|
||||
# No good match found — old page was removed
|
||||
alignment.append({
|
||||
'old_page': old_pages[old_idx]['page_num'],
|
||||
'new_page': None,
|
||||
'similarity': round(best_score, 3) if best_score >= 0 else None,
|
||||
'status': 'removed',
|
||||
})
|
||||
|
||||
# Any remaining unconsumed new pages are inserts at the end
|
||||
for j in range(new_cursor, len(new_fps)):
|
||||
if j not in new_consumed:
|
||||
alignment.append({
|
||||
'old_page': None,
|
||||
'new_page': new_pages[j]['page_num'],
|
||||
'similarity': None,
|
||||
'status': 'added',
|
||||
})
|
||||
|
||||
return alignment
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Vision LLM page-pair diff
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_DIFF_PROMPT = """You are a quality-control reviewer comparing two versions of the same insurance policy document page. The first image is the OLD version. The second image is the NEW version.
|
||||
|
||||
Your job: identify every meaningful difference between the two pages. Be specific — quote the actual text where you can.
|
||||
|
||||
Respond with ONLY a JSON object in this exact schema (no markdown fences, no commentary):
|
||||
|
||||
{
|
||||
"differences_found": true|false,
|
||||
"added": ["specific text or element added in NEW that wasn't in OLD"],
|
||||
"removed": ["specific text or element removed in NEW that was in OLD"],
|
||||
"modified": ["specific text or element changed in wording, formatting, bolding, color, or font"],
|
||||
"moved": ["element repositioned (e.g. blue box moved from top-right to bottom-left)"],
|
||||
"style_changes": ["color, font, size, bold/italic, layout shift not covered above"],
|
||||
"severity": "high|medium|low|none",
|
||||
"summary": "one-sentence overview of what changed on this page"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- "high" severity = content changes that affect cover/exclusions/customer-facing terms (added paragraphs, removed sections, definition changes)
|
||||
- "medium" = formatting changes that affect readability or compliance (un-bolded defined terms, moved key elements)
|
||||
- "low" = cosmetic only (slight color tweak, kerning adjustment)
|
||||
- "none" = pages are visually identical
|
||||
- If unsure whether something is a difference, INCLUDE it — better false positive than missed defect
|
||||
- Empty arrays are OK if a category has no findings
|
||||
- Always return all fields, even if empty"""
|
||||
|
||||
|
||||
def _parse_diff_response(text: str) -> Dict:
|
||||
"""Extract the JSON object from the LLM response. Handles cases where
|
||||
the model wraps it in ```json fences despite instructions, and falls
|
||||
back to a structured-error result if parsing fails."""
|
||||
if not text:
|
||||
return _empty_diff('Empty response')
|
||||
|
||||
# Strip code fences if present
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith('```'):
|
||||
cleaned = re.sub(r'^```[a-z]*\n?', '', cleaned)
|
||||
cleaned = re.sub(r'\n?```\s*$', '', cleaned)
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
# Find the first { and matching last }
|
||||
first = cleaned.find('{')
|
||||
last = cleaned.rfind('}')
|
||||
if first == -1 or last == -1 or last <= first:
|
||||
return _empty_diff(f'No JSON object in response: {text[:200]}')
|
||||
|
||||
try:
|
||||
data = json.loads(cleaned[first:last + 1])
|
||||
except json.JSONDecodeError as e:
|
||||
return _empty_diff(f'JSON parse failed: {e}; raw: {text[:200]}')
|
||||
|
||||
return {
|
||||
'differences_found': bool(data.get('differences_found', False)),
|
||||
'added': data.get('added') or [],
|
||||
'removed': data.get('removed') or [],
|
||||
'modified': data.get('modified') or [],
|
||||
'moved': data.get('moved') or [],
|
||||
'style_changes': data.get('style_changes') or [],
|
||||
'severity': data.get('severity') or 'none',
|
||||
'summary': data.get('summary') or '',
|
||||
}
|
||||
|
||||
|
||||
def _empty_diff(error: Optional[str] = None) -> Dict:
|
||||
return {
|
||||
'differences_found': False,
|
||||
'added': [],
|
||||
'removed': [],
|
||||
'modified': [],
|
||||
'moved': [],
|
||||
'style_changes': [],
|
||||
'severity': 'none',
|
||||
'summary': '',
|
||||
'error': error,
|
||||
}
|
||||
|
||||
|
||||
def _diff_one_pair(
|
||||
old_page: Dict,
|
||||
new_page: Dict,
|
||||
call_gemini_vision_fn,
|
||||
model_version: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Run vision LLM on a single page-pair. Returns diff dict + token usage.
|
||||
|
||||
Wraps call_gemini_vision so the dispatcher doesn't have to know the
|
||||
LLM-call signature.
|
||||
"""
|
||||
try:
|
||||
old_img = Image.open(old_page['image_path']).convert('RGB')
|
||||
new_img = Image.open(new_page['image_path']).convert('RGB')
|
||||
except Exception as e:
|
||||
return {
|
||||
'old_page': old_page['page_num'],
|
||||
'new_page': new_page['page_num'],
|
||||
'diff': _empty_diff(f'Image load failed: {e}'),
|
||||
'token_usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0},
|
||||
}
|
||||
|
||||
try:
|
||||
response_text, token_usage = call_gemini_vision_fn(
|
||||
_DIFF_PROMPT, old_img, new_img, model_version=model_version
|
||||
)
|
||||
except Exception as e:
|
||||
return {
|
||||
'old_page': old_page['page_num'],
|
||||
'new_page': new_page['page_num'],
|
||||
'diff': _empty_diff(f'LLM call failed: {e}'),
|
||||
'token_usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0},
|
||||
}
|
||||
|
||||
diff = _parse_diff_response(response_text)
|
||||
return {
|
||||
'old_page': old_page['page_num'],
|
||||
'new_page': new_page['page_num'],
|
||||
'diff': diff,
|
||||
'token_usage': token_usage,
|
||||
}
|
||||
|
||||
|
||||
def run_page_pair_diff(
|
||||
*,
|
||||
old_ingest: Dict,
|
||||
new_ingest: Dict,
|
||||
call_gemini_vision_fn,
|
||||
progress_callback=None,
|
||||
model_version: Optional[str] = None,
|
||||
parallel_pairs: int = PARALLEL_PAIRS,
|
||||
) -> Dict:
|
||||
"""Top-level entrypoint. Aligns pages, then diffs each matched pair via
|
||||
vision LLM in parallel. Returns:
|
||||
|
||||
{
|
||||
'alignment': [...],
|
||||
'pair_diffs': {pair_key: diff_result, ...},
|
||||
'totals': {pages_added, pages_removed, pages_matched, ...},
|
||||
'token_usage': {prompt_tokens, completion_tokens, total_tokens},
|
||||
}
|
||||
"""
|
||||
old_pages = old_ingest.get('pages') or []
|
||||
new_pages = new_ingest.get('pages') or []
|
||||
|
||||
alignment = align_pages(old_pages, new_pages)
|
||||
|
||||
# Index pages by page_num for fast lookup in the diff loop
|
||||
old_by_num = {p['page_num']: p for p in old_pages}
|
||||
new_by_num = {p['page_num']: p for p in new_pages}
|
||||
|
||||
# Build diff tasks for matched pairs only
|
||||
matched_entries = [e for e in alignment if e['status'] == 'matched']
|
||||
total_pairs = len(matched_entries)
|
||||
|
||||
pair_diffs: Dict[str, Dict] = {}
|
||||
aggregate_tokens = {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
|
||||
completed = 0
|
||||
|
||||
def _run(entry):
|
||||
old_p = old_by_num.get(entry['old_page'])
|
||||
new_p = new_by_num.get(entry['new_page'])
|
||||
if not old_p or not new_p or not old_p.get('image_path') or not new_p.get('image_path'):
|
||||
return entry, None
|
||||
result = _diff_one_pair(old_p, new_p, call_gemini_vision_fn, model_version)
|
||||
return entry, result
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_pairs) as pool:
|
||||
futures = [pool.submit(_run, e) for e in matched_entries]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
entry, result = future.result()
|
||||
completed += 1
|
||||
if result is not None:
|
||||
key = f"{entry['old_page']}->{entry['new_page']}"
|
||||
pair_diffs[key] = result
|
||||
tu = result.get('token_usage') or {}
|
||||
for k in aggregate_tokens:
|
||||
aggregate_tokens[k] += tu.get(k, 0)
|
||||
if progress_callback:
|
||||
try:
|
||||
progress_callback(completed, total_pairs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tally up the deltas
|
||||
pages_added = sum(1 for e in alignment if e['status'] == 'added')
|
||||
pages_removed = sum(1 for e in alignment if e['status'] == 'removed')
|
||||
pages_matched = sum(1 for e in alignment if e['status'] == 'matched')
|
||||
pages_modified = sum(
|
||||
1 for d in pair_diffs.values()
|
||||
if d['diff'].get('differences_found') and d['diff'].get('severity') != 'none'
|
||||
)
|
||||
pages_unchanged = pages_matched - pages_modified
|
||||
|
||||
severity_counts = {'high': 0, 'medium': 0, 'low': 0, 'none': 0}
|
||||
for d in pair_diffs.values():
|
||||
sev = d['diff'].get('severity') or 'none'
|
||||
if sev in severity_counts:
|
||||
severity_counts[sev] += 1
|
||||
|
||||
return {
|
||||
'alignment': alignment,
|
||||
'pair_diffs': pair_diffs,
|
||||
'totals': {
|
||||
'old_page_count': len(old_pages),
|
||||
'new_page_count': len(new_pages),
|
||||
'pages_matched': pages_matched,
|
||||
'pages_added': pages_added,
|
||||
'pages_removed': pages_removed,
|
||||
'pages_modified': pages_modified,
|
||||
'pages_unchanged': pages_unchanged,
|
||||
'severity_counts': severity_counts,
|
||||
},
|
||||
'token_usage': aggregate_tokens,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Top-level orchestrator (called from /api/document/start_diff)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_document_diff_analysis(
|
||||
*,
|
||||
old_pdf_path: str,
|
||||
new_pdf_path: str,
|
||||
old_filename: str,
|
||||
new_filename: str,
|
||||
profile_config,
|
||||
profile_id: str,
|
||||
progress_tracker: Dict,
|
||||
session_id: str,
|
||||
ingest_pdf_fn,
|
||||
call_gemini_vision_fn,
|
||||
pages_output_dir_old: str,
|
||||
pages_output_dir_new: str,
|
||||
page_limit: int = 200,
|
||||
parallel_pairs: int = PARALLEL_PAIRS,
|
||||
) -> Dict:
|
||||
"""Full diff pipeline: ingest both PDFs → align → page-pair vision diff."""
|
||||
from datetime import datetime
|
||||
|
||||
progress_tracker[session_id].update({
|
||||
'stage': 'ingesting_old',
|
||||
'percentage': 2,
|
||||
'current_check_display': f'Rendering old version ({old_filename})...',
|
||||
})
|
||||
|
||||
def _old_progress(p, t):
|
||||
progress_tracker[session_id].update({
|
||||
'percentage': 2 + (p / t) * 8,
|
||||
'current_check_display': f'Old version: page {p}/{t}',
|
||||
})
|
||||
|
||||
old_ingest = ingest_pdf_fn(
|
||||
old_pdf_path, pages_output_dir_old,
|
||||
page_limit=page_limit, progress_callback=_old_progress,
|
||||
)
|
||||
|
||||
progress_tracker[session_id].update({
|
||||
'stage': 'ingesting_new',
|
||||
'percentage': 10,
|
||||
'current_check_display': f'Rendering new version ({new_filename})...',
|
||||
})
|
||||
|
||||
def _new_progress(p, t):
|
||||
progress_tracker[session_id].update({
|
||||
'percentage': 10 + (p / t) * 8,
|
||||
'current_check_display': f'New version: page {p}/{t}',
|
||||
})
|
||||
|
||||
new_ingest = ingest_pdf_fn(
|
||||
new_pdf_path, pages_output_dir_new,
|
||||
page_limit=page_limit, progress_callback=_new_progress,
|
||||
)
|
||||
|
||||
progress_tracker[session_id].update({
|
||||
'stage': 'aligning_pages',
|
||||
'percentage': 18,
|
||||
'current_check_display': 'Aligning pages between versions...',
|
||||
})
|
||||
|
||||
def _diff_progress(completed, total):
|
||||
if total <= 0:
|
||||
return
|
||||
progress_tracker[session_id].update({
|
||||
'stage': 'page_pair_diff',
|
||||
'percentage': 20 + (completed / total) * 75,
|
||||
'completed_checks': completed,
|
||||
'total_checks': total,
|
||||
'current_check_display': f'Diffing page pair {completed}/{total}',
|
||||
})
|
||||
|
||||
diff_result = run_page_pair_diff(
|
||||
old_ingest=old_ingest,
|
||||
new_ingest=new_ingest,
|
||||
call_gemini_vision_fn=call_gemini_vision_fn,
|
||||
progress_callback=_diff_progress,
|
||||
parallel_pairs=parallel_pairs,
|
||||
)
|
||||
|
||||
progress_tracker[session_id].update({
|
||||
'stage': 'aggregating',
|
||||
'percentage': 96,
|
||||
'current_check_display': 'Compiling diff report...',
|
||||
})
|
||||
|
||||
overall_score, grade = _diff_score(diff_result['totals'])
|
||||
|
||||
return {
|
||||
'mode': 'document_diff',
|
||||
'profile_id': profile_id,
|
||||
'profile_name': profile_config.name,
|
||||
'old_pdf': {
|
||||
'filename': old_filename,
|
||||
'page_count': old_ingest['page_count'],
|
||||
'pages_processed': old_ingest['pages_processed'],
|
||||
'truncated': old_ingest['truncated'],
|
||||
},
|
||||
'new_pdf': {
|
||||
'filename': new_filename,
|
||||
'page_count': new_ingest['page_count'],
|
||||
'pages_processed': new_ingest['pages_processed'],
|
||||
'truncated': new_ingest['truncated'],
|
||||
},
|
||||
'alignment': diff_result['alignment'],
|
||||
'pair_diffs': diff_result['pair_diffs'],
|
||||
'totals': diff_result['totals'],
|
||||
'token_usage': diff_result['token_usage'],
|
||||
'document_summary': {
|
||||
'overall_score': overall_score,
|
||||
'grade': grade,
|
||||
},
|
||||
'old_pages_meta': [
|
||||
{'page_num': p['page_num'], 'fonts_used': p.get('fonts_used', []),
|
||||
'image_path': p.get('image_path')}
|
||||
for p in old_ingest.get('pages', [])
|
||||
],
|
||||
'new_pages_meta': [
|
||||
{'page_num': p['page_num'], 'fonts_used': p.get('fonts_used', []),
|
||||
'image_path': p.get('image_path')}
|
||||
for p in new_ingest.get('pages', [])
|
||||
],
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
}
|
||||
|
||||
|
||||
def _diff_score(totals: Dict) -> Tuple[float, str]:
|
||||
"""Convert diff totals into an informational score.
|
||||
|
||||
A diff doesn't really pass/fail the way a QC check does — it's a
|
||||
*report*. We score as: 100 if no diffs found, scaling down with
|
||||
severity. Grade is informational ("Clean diff" / "Changes detected").
|
||||
"""
|
||||
high = totals['severity_counts']['high']
|
||||
medium = totals['severity_counts']['medium']
|
||||
low = totals['severity_counts']['low']
|
||||
pages_added = totals['pages_added']
|
||||
pages_removed = totals['pages_removed']
|
||||
|
||||
structural_changes = pages_added + pages_removed
|
||||
if (high + medium + low + structural_changes) == 0:
|
||||
return 100.0, 'Identical'
|
||||
|
||||
# 100 - (10 per high) - (3 per medium) - (1 per low) - (5 per structural)
|
||||
score = max(0.0, 100.0 - 10 * high - 3 * medium - 1 * low - 5 * structural_changes)
|
||||
|
||||
if high > 0 or structural_changes > 2:
|
||||
grade = 'Major changes'
|
||||
elif medium > 0 or structural_changes > 0:
|
||||
grade = 'Notable changes'
|
||||
else:
|
||||
grade = 'Minor changes'
|
||||
|
||||
return round(score, 2), grade
|
||||
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
"""Diff report writer for AXA Old-vs-New document mode.
|
||||
|
||||
Distinct from result_writer.py — the diff report's shape is fundamentally
|
||||
different (alignment table, page-pair cards, severity breakdowns) so it
|
||||
gets its own module rather than overloading the single-doc writer.
|
||||
|
||||
Outputs:
|
||||
<output_dir>/<session_id>_<old_basename>_vs_<new_basename>_diff_data.json
|
||||
<output_dir>/<session_id>_<old_basename>_vs_<new_basename>_diff_report.html
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
base = os.path.splitext(os.path.basename(name))[0]
|
||||
return base.replace(' ', '_').replace('/', '_')[:60]
|
||||
|
||||
|
||||
def _severity_class(sev: str) -> str:
|
||||
return {
|
||||
'high': 'sev-high',
|
||||
'medium': 'sev-medium',
|
||||
'low': 'sev-low',
|
||||
'none': 'sev-none',
|
||||
}.get(sev, 'sev-none')
|
||||
|
||||
|
||||
def _status_class(status: str) -> str:
|
||||
return {
|
||||
'matched': 'status-matched',
|
||||
'added': 'status-added',
|
||||
'removed': 'status-removed',
|
||||
}.get(status, '')
|
||||
|
||||
|
||||
def _render_alignment_table(alignment: List[Dict]) -> str:
|
||||
rows = []
|
||||
for entry in alignment:
|
||||
old = entry.get('old_page')
|
||||
new = entry.get('new_page')
|
||||
sim = entry.get('similarity')
|
||||
sim_str = f"{sim:.2f}" if isinstance(sim, (int, float)) else '—'
|
||||
status = entry.get('status', '')
|
||||
rows.append(f"""
|
||||
<tr class='{_status_class(status)}'>
|
||||
<td>{old if old is not None else '—'}</td>
|
||||
<td>{new if new is not None else '—'}</td>
|
||||
<td>{sim_str}</td>
|
||||
<td><span class='status-pill {_status_class(status)}'>{html.escape(status)}</span></td>
|
||||
</tr>
|
||||
""")
|
||||
return f"""
|
||||
<table class='alignment-table'>
|
||||
<thead><tr><th>Old page</th><th>New page</th><th>Similarity</th><th>Status</th></tr></thead>
|
||||
<tbody>{''.join(rows)}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _render_diff_list(items: List[str], css_class: str, label: str, icon: str) -> str:
|
||||
if not items:
|
||||
return ''
|
||||
bullets = ''.join(f"<li>{html.escape(it)}</li>" for it in items)
|
||||
return f"""
|
||||
<div class='diff-block {css_class}'>
|
||||
<div class='diff-label'>{icon} {label}</div>
|
||||
<ul>{bullets}</ul>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _render_pair_card(entry: Dict, pair_diffs: Dict) -> str:
|
||||
old = entry['old_page']
|
||||
new = entry['new_page']
|
||||
status = entry['status']
|
||||
sim = entry.get('similarity')
|
||||
|
||||
# Added or removed entire pages — different shape
|
||||
if status == 'added':
|
||||
return f"""
|
||||
<div class='pair-card status-added'>
|
||||
<div class='pair-header'>
|
||||
<span class='page-label'>+ Page added in new version</span>
|
||||
<span class='page-coords'>new page <strong>{new}</strong></span>
|
||||
</div>
|
||||
<div class='pair-body'>
|
||||
<em class='muted'>This page exists in the new version but had no counterpart in the old version.</em>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
if status == 'removed':
|
||||
return f"""
|
||||
<div class='pair-card status-removed'>
|
||||
<div class='pair-header'>
|
||||
<span class='page-label'>− Page removed in new version</span>
|
||||
<span class='page-coords'>old page <strong>{old}</strong></span>
|
||||
</div>
|
||||
<div class='pair-body'>
|
||||
<em class='muted'>This page was in the old version but is not in the new version.</em>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Matched pair — render diff result
|
||||
key = f"{old}->{new}"
|
||||
pair = pair_diffs.get(key, {}).get('diff') or {}
|
||||
sev = pair.get('severity', 'none')
|
||||
summary = pair.get('summary', '')
|
||||
differences_found = pair.get('differences_found', False)
|
||||
|
||||
if not differences_found and not pair.get('error'):
|
||||
return f"""
|
||||
<details class='pair-card status-matched-clean'>
|
||||
<summary>
|
||||
<span class='page-label'>= No differences detected</span>
|
||||
<span class='page-coords'>old <strong>{old}</strong> ↔ new <strong>{new}</strong> · sim {sim:.2f}</span>
|
||||
<span class='sev-pill sev-none'>identical</span>
|
||||
</summary>
|
||||
<div class='pair-body'><em class='muted'>{html.escape(summary or "Pages compared as visually identical.")}</em></div>
|
||||
</details>
|
||||
"""
|
||||
|
||||
blocks = []
|
||||
blocks.append(_render_diff_list(pair.get('added') or [], 'block-added', 'Added', '➕'))
|
||||
blocks.append(_render_diff_list(pair.get('removed') or [], 'block-removed', 'Removed', '➖'))
|
||||
blocks.append(_render_diff_list(pair.get('modified') or [], 'block-modified', 'Modified', '✎'))
|
||||
blocks.append(_render_diff_list(pair.get('moved') or [], 'block-moved', 'Moved', '↔'))
|
||||
blocks.append(_render_diff_list(pair.get('style_changes') or [], 'block-style', 'Style changes', '🎨'))
|
||||
|
||||
error_block = ''
|
||||
if pair.get('error'):
|
||||
error_block = f"<div class='diff-error'>⚠️ {html.escape(pair['error'])}</div>"
|
||||
|
||||
return f"""
|
||||
<details class='pair-card' open>
|
||||
<summary>
|
||||
<span class='page-label'>old <strong>{old}</strong> ↔ new <strong>{new}</strong></span>
|
||||
<span class='page-coords'>sim {sim:.2f}</span>
|
||||
<span class='sev-pill {_severity_class(sev)}'>{html.escape(sev)}</span>
|
||||
</summary>
|
||||
<div class='pair-body'>
|
||||
<p class='pair-summary'>{html.escape(summary or '')}</p>
|
||||
{error_block}
|
||||
{''.join(blocks)}
|
||||
</div>
|
||||
</details>
|
||||
"""
|
||||
|
||||
|
||||
def _render_at_a_glance(totals: Dict, doc_summary: Dict) -> str:
|
||||
sev = totals.get('severity_counts', {})
|
||||
return f"""
|
||||
<div class='glance-grid'>
|
||||
<div class='glance-card'>
|
||||
<div class='glance-num'>{totals.get('old_page_count', 0)} → {totals.get('new_page_count', 0)}</div>
|
||||
<div class='glance-label'>Page count</div>
|
||||
</div>
|
||||
<div class='glance-card status-added'>
|
||||
<div class='glance-num'>{totals.get('pages_added', 0)}</div>
|
||||
<div class='glance-label'>Pages added</div>
|
||||
</div>
|
||||
<div class='glance-card status-removed'>
|
||||
<div class='glance-num'>{totals.get('pages_removed', 0)}</div>
|
||||
<div class='glance-label'>Pages removed</div>
|
||||
</div>
|
||||
<div class='glance-card'>
|
||||
<div class='glance-num'>{totals.get('pages_modified', 0)}</div>
|
||||
<div class='glance-label'>Pages modified</div>
|
||||
</div>
|
||||
<div class='glance-card'>
|
||||
<div class='glance-num'>{totals.get('pages_unchanged', 0)}</div>
|
||||
<div class='glance-label'>Pages unchanged</div>
|
||||
</div>
|
||||
<div class='glance-card sev-high'>
|
||||
<div class='glance-num'>{sev.get('high', 0)}</div>
|
||||
<div class='glance-label'>High severity</div>
|
||||
</div>
|
||||
<div class='glance-card sev-medium'>
|
||||
<div class='glance-num'>{sev.get('medium', 0)}</div>
|
||||
<div class='glance-label'>Medium severity</div>
|
||||
</div>
|
||||
<div class='glance-card sev-low'>
|
||||
<div class='glance-num'>{sev.get('low', 0)}</div>
|
||||
<div class='glance-label'>Low severity</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _render_html(result: Dict) -> str:
|
||||
old_pdf = result.get('old_pdf', {})
|
||||
new_pdf = result.get('new_pdf', {})
|
||||
totals = result.get('totals', {})
|
||||
doc_summary = result.get('document_summary', {})
|
||||
alignment = result.get('alignment', [])
|
||||
pair_diffs = result.get('pair_diffs', {})
|
||||
score = doc_summary.get('overall_score', 0)
|
||||
grade = doc_summary.get('grade', '')
|
||||
|
||||
glance = _render_at_a_glance(totals, doc_summary)
|
||||
alignment_table = _render_alignment_table(alignment)
|
||||
pair_cards = '\n'.join(_render_pair_card(entry, pair_diffs) for entry in alignment)
|
||||
|
||||
title = f"Diff Report — {old_pdf.get('filename', 'old')} vs {new_pdf.get('filename', 'new')}"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; background: #f5f6f8; color: #222; }}
|
||||
.wrap {{ max-width: 1200px; margin: 0 auto; padding: 24px; }}
|
||||
h1 {{ margin: 0 0 4px; font-size: 22px; }}
|
||||
h2 {{ margin: 24px 0 8px; font-size: 16px; color: #111; }}
|
||||
.meta {{ color: #666; font-size: 13px; margin-bottom: 20px; }}
|
||||
.muted {{ color: #888; }}
|
||||
|
||||
.versions-card {{ background: white; border-radius: 8px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 16px; }}
|
||||
.versions-card .vs-row {{ display: flex; align-items: center; gap: 16px; font-size: 14px; }}
|
||||
.versions-card .vs-old, .versions-card .vs-new {{ flex: 1; padding: 10px 14px; border-radius: 6px; }}
|
||||
.versions-card .vs-old {{ background: #fceac0; }}
|
||||
.versions-card .vs-new {{ background: #d6f0d8; }}
|
||||
.versions-card .vs-arrow {{ font-size: 24px; color: #888; }}
|
||||
|
||||
.overall-card {{ background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 16px; display: flex; align-items: center; gap: 24px; }}
|
||||
.overall-score {{ font-size: 48px; font-weight: 600; color: #111; }}
|
||||
.grade-badge {{ padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: 0.5px; background: #eef2f7; color: #2a4060; }}
|
||||
|
||||
.glance-grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; }}
|
||||
.glance-card {{ background: white; border-radius: 8px; padding: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); text-align: center; }}
|
||||
.glance-card.status-added {{ background: #d6f0d8; }}
|
||||
.glance-card.status-removed {{ background: #f4d4d4; }}
|
||||
.glance-card.sev-high {{ background: #f4d4d4; }}
|
||||
.glance-card.sev-medium {{ background: #fceac0; }}
|
||||
.glance-card.sev-low {{ background: #eef2f7; }}
|
||||
.glance-num {{ font-size: 26px; font-weight: 700; color: #222; }}
|
||||
.glance-label {{ font-size: 12px; color: #555; margin-top: 4px; }}
|
||||
|
||||
.alignment-table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 12px; font-size: 13px; }}
|
||||
.alignment-table th, .alignment-table td {{ text-align: left; padding: 6px 12px; border-bottom: 1px solid #eee; }}
|
||||
.alignment-table th {{ background: #fafafa; font-weight: 600; color: #555; }}
|
||||
.alignment-table tr:last-child td {{ border-bottom: none; }}
|
||||
.alignment-table tr.status-added td {{ background: #f4fcf5; }}
|
||||
.alignment-table tr.status-removed td {{ background: #fdf3f3; }}
|
||||
|
||||
.status-pill {{ font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; background: #eef2f7; color: #4a5a72; }}
|
||||
.status-pill.status-matched {{ background: #eef5ff; color: #2c4f8c; }}
|
||||
.status-pill.status-added {{ background: #d6f0d8; color: #1f6a2a; }}
|
||||
.status-pill.status-removed {{ background: #f4d4d4; color: #8a1f1f; }}
|
||||
|
||||
.pair-card {{ background: white; border-radius: 8px; padding: 14px 18px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 3px solid transparent; }}
|
||||
.pair-card[open] {{ padding-bottom: 18px; }}
|
||||
.pair-card.status-matched-clean {{ border-left-color: #ccc; opacity: 0.85; }}
|
||||
.pair-card.status-added {{ border-left-color: #2a8a3a; background: #f4fcf5; }}
|
||||
.pair-card.status-removed {{ border-left-color: #b53030; background: #fdf3f3; }}
|
||||
.pair-card summary {{ cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 14px; list-style: none; }}
|
||||
.pair-card summary::-webkit-details-marker {{ display: none; }}
|
||||
.pair-card summary::before {{ content: '▸'; display: inline-block; transition: transform .15s; margin-right: 6px; color: #888; }}
|
||||
.pair-card[open] summary::before {{ transform: rotate(90deg); }}
|
||||
.pair-header {{ display: flex; align-items: center; gap: 12px; flex: 1; }}
|
||||
.page-label {{ font-weight: 600; }}
|
||||
.page-coords {{ color: #888; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }}
|
||||
.pair-body {{ padding-left: 18px; padding-top: 8px; }}
|
||||
.pair-summary {{ color: #444; font-size: 13px; margin: 4px 0 12px; }}
|
||||
|
||||
.sev-pill {{ font-size: 11px; padding: 2px 10px; border-radius: 999px; font-weight: 600; }}
|
||||
.sev-pill.sev-high {{ background: #f4d4d4; color: #8a1f1f; }}
|
||||
.sev-pill.sev-medium {{ background: #fceac0; color: #7a5a00; }}
|
||||
.sev-pill.sev-low {{ background: #eef2f7; color: #4a5a72; }}
|
||||
.sev-pill.sev-none {{ background: #e8efe8; color: #4a6a4a; }}
|
||||
|
||||
.diff-block {{ background: #fafbfc; border-left: 3px solid #ccc; padding: 8px 14px; margin: 8px 0; border-radius: 4px; }}
|
||||
.diff-block.block-added {{ border-left-color: #2a8a3a; }}
|
||||
.diff-block.block-removed {{ border-left-color: #b53030; }}
|
||||
.diff-block.block-modified {{ border-left-color: #b58a00; }}
|
||||
.diff-block.block-moved {{ border-left-color: #2c4f8c; }}
|
||||
.diff-block.block-style {{ border-left-color: #8a4ab8; }}
|
||||
.diff-label {{ font-weight: 600; font-size: 13px; margin-bottom: 4px; }}
|
||||
.diff-block ul {{ margin: 4px 0; padding-left: 22px; }}
|
||||
.diff-block li {{ font-size: 13px; line-height: 1.45; margin: 2px 0; }}
|
||||
.diff-error {{ background: #fdf3f3; color: #8a1f1f; padding: 8px 12px; border-radius: 4px; font-size: 13px; margin: 8px 0; }}
|
||||
|
||||
.filter-bar {{ background: white; border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); font-size: 13px; }}
|
||||
.filter-bar label {{ cursor: pointer; margin-right: 12px; }}
|
||||
|
||||
.cost-line {{ font-size: 12px; color: #666; margin-top: 4px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='wrap'>
|
||||
<h1>Old vs New Diff — {html.escape(result.get('profile_name', ''))}</h1>
|
||||
<div class='meta'>{html.escape(result.get('timestamp', ''))}</div>
|
||||
|
||||
<div class='versions-card'>
|
||||
<div class='vs-row'>
|
||||
<div class='vs-old'>
|
||||
<strong>OLD:</strong> {html.escape(old_pdf.get('filename', ''))}<br>
|
||||
<span class='muted'>{old_pdf.get('pages_processed', 0)} pages</span>
|
||||
</div>
|
||||
<div class='vs-arrow'>→</div>
|
||||
<div class='vs-new'>
|
||||
<strong>NEW:</strong> {html.escape(new_pdf.get('filename', ''))}<br>
|
||||
<span class='muted'>{new_pdf.get('pages_processed', 0)} pages</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='overall-card'>
|
||||
<div>
|
||||
<div class='overall-score'>{score}</div>
|
||||
<div style='font-size:12px;color:#666;'>Diff score (100 = identical)</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class='grade-badge'>{html.escape(grade)}</span>
|
||||
</div>
|
||||
<div class='cost-line muted'>
|
||||
Tokens: {result.get('token_usage', {}).get('total_tokens', 0):,}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>At a glance</h2>
|
||||
{glance}
|
||||
|
||||
<h2>Page alignment map</h2>
|
||||
{alignment_table}
|
||||
|
||||
<h2>Page-by-page differences</h2>
|
||||
{pair_cards}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Public entrypoint
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def write_diff_report(
|
||||
result: Dict,
|
||||
old_filename: str,
|
||||
new_filename: str,
|
||||
session_id: str,
|
||||
output_dir: str,
|
||||
output_mode: str = 'both',
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""Write JSON + HTML diff reports.
|
||||
|
||||
Returns: {'json': path or None, 'html': path or None}
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
base = f"{session_id}_{_slug(old_filename)}_vs_{_slug(new_filename)}_diff"
|
||||
paths: Dict[str, Optional[str]] = {'json': None, 'html': None}
|
||||
|
||||
if output_mode in ('json', 'both'):
|
||||
json_path = os.path.join(output_dir, f"{base}_data.json")
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2, default=str)
|
||||
paths['json'] = json_path
|
||||
|
||||
if output_mode in ('html', 'both'):
|
||||
html_path = os.path.join(output_dir, f"{base}_report.html")
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(_render_html(result))
|
||||
paths['html'] = html_path
|
||||
|
||||
return paths
|
||||
|
|
@ -1,469 +0,0 @@
|
|||
"""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 concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from . import checks as doc_checks
|
||||
|
||||
|
||||
# Max concurrent page-level LLM calls within a single check, used by Stage 3c
|
||||
# (page_sample) and Stage 3d (page_each). Was sequential; that pinned a 4-page
|
||||
# × 7-check Boots PPack run at ~15 min. Bump if larger docs / paid-tier rate
|
||||
# limits make it safe; keep modest to stay well under Gemini's per-key quota.
|
||||
_PAGE_PARALLEL_WORKERS = 4
|
||||
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
# ── Page-type map (shared by 3c page_sample and 3d page_each) ──────────
|
||||
# For profiles that don't tag pages (e.g. AXA), every page is 'artwork',
|
||||
# so the page-type-aware aggregation in Stage 3d falls through cleanly.
|
||||
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'}
|
||||
|
||||
# ── Per-page dispatch helper (used by 3c and 3d in parallel) ───────────
|
||||
# process_checks_in_batches is already reentrant — asset-mode runs it
|
||||
# under its own ThreadPoolExecutor — so it's safe to call concurrently
|
||||
# from a pool. progress_tracker writes are GIL-safe and racy by design
|
||||
# (visual only). page_level_results writes happen from the main thread
|
||||
# after future.result(), so no races on that dict either.
|
||||
def _run_check_on_page(check_name: str, page: Dict):
|
||||
page_num = page['page_num']
|
||||
try:
|
||||
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, {})
|
||||
except Exception as e:
|
||||
result_for_page = {
|
||||
'check_name': check_name,
|
||||
'score': 0.0,
|
||||
'pass': False,
|
||||
'response': f'Check raised {type(e).__name__}: {e}',
|
||||
'findings': {'error': str(e)},
|
||||
}
|
||||
result_for_page['page_type'] = page_type_map.get(page_num, 'artwork')
|
||||
return page_num, result_for_page
|
||||
|
||||
# ── 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)
|
||||
eligible = [pages[pn - 1] for pn in page_nums if pages[pn - 1].get('image_path')]
|
||||
page_level_results[check_name] = {}
|
||||
|
||||
progress_tracker[session_id].update({
|
||||
'current_check_display': f'{check_name} (sampling {len(eligible)} pages)...',
|
||||
})
|
||||
|
||||
pages_done = 0
|
||||
with ThreadPoolExecutor(max_workers=_PAGE_PARALLEL_WORKERS) as pool:
|
||||
futures = [pool.submit(_run_check_on_page, check_name, p) for p in eligible]
|
||||
for fut in as_completed(futures):
|
||||
pn, result = fut.result()
|
||||
page_level_results[check_name][pn] = result
|
||||
pages_done += 1
|
||||
progress_tracker[session_id].update({
|
||||
'current_check_display': f'{check_name}: {pages_done} of {len(eligible)} pages',
|
||||
})
|
||||
|
||||
# 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).
|
||||
|
||||
if scope_buckets['page_each']:
|
||||
for check_name, _scope_args in scope_buckets['page_each']:
|
||||
page_level_results.setdefault(check_name, {})
|
||||
eligible_pages = [p for p in pages if p.get('image_path')]
|
||||
|
||||
progress_tracker[session_id].update({
|
||||
'current_check_display': f'{check_name} across {len(eligible_pages)} pages...',
|
||||
'current_page': 0,
|
||||
})
|
||||
|
||||
pages_done = 0
|
||||
with ThreadPoolExecutor(max_workers=_PAGE_PARALLEL_WORKERS) as pool:
|
||||
futures = [pool.submit(_run_check_on_page, check_name, p) for p in eligible_pages]
|
||||
for fut in as_completed(futures):
|
||||
pn, result = fut.result()
|
||||
page_level_results[check_name][pn] = result
|
||||
pages_done += 1
|
||||
progress_tracker[session_id].update({
|
||||
'current_check_display': f'{check_name}: {pages_done} of {len(eligible_pages)} pages',
|
||||
})
|
||||
|
||||
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'),
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
"""PDF ingestion for document-mode QC.
|
||||
|
||||
Renders each page of a multi-page PDF to a PNG, extracts per-page text spans
|
||||
with font name + weight + size, and returns a structured list the dispatcher
|
||||
loops over. Phase-1 LLM checks consume only the rendered page image; the
|
||||
text-span data is captured here so Phase-2 deterministic checks (font
|
||||
compliance, bold-words) can plug in without a re-ingest pass.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image
|
||||
|
||||
|
||||
PYMUPDF_BOLD_FLAG = 16 # bit 4 of span['flags']
|
||||
PYMUPDF_ITALIC_FLAG = 2 # bit 1 of span['flags']
|
||||
DEFAULT_RENDER_ZOOM = 2.0 # ≈150 DPI — matches pdf_processor.extract_cover_image
|
||||
DEFAULT_MAX_DIMENSION = 1600 # px — slightly larger than reference-asset thumbnails so per-page text stays legible to the LLM
|
||||
DEFAULT_PAGE_LIMIT = 200 # safety cap; AXA policy docs are ~80 pages
|
||||
|
||||
|
||||
def _span_is_bold(span: Dict) -> bool:
|
||||
"""A span counts as bold if PyMuPDF's flags say so OR the font name signals it."""
|
||||
flags = span.get('flags', 0)
|
||||
if flags & PYMUPDF_BOLD_FLAG:
|
||||
return True
|
||||
font = (span.get('font') or '').lower()
|
||||
return any(token in font for token in ('bold', 'black', 'heavy'))
|
||||
|
||||
|
||||
def _span_is_italic(span: Dict) -> bool:
|
||||
flags = span.get('flags', 0)
|
||||
if flags & PYMUPDF_ITALIC_FLAG:
|
||||
return True
|
||||
font = (span.get('font') or '').lower()
|
||||
return 'italic' in font or 'oblique' in font
|
||||
|
||||
|
||||
def _extract_page_spans(page: fitz.Page) -> List[Dict]:
|
||||
"""Flatten PyMuPDF's blocks→lines→spans into a list of QC-relevant span dicts."""
|
||||
spans = []
|
||||
try:
|
||||
text_dict = page.get_text("dict")
|
||||
except Exception as e:
|
||||
print(f" [ingest] get_text(dict) failed on page {page.number + 1}: {e}")
|
||||
return spans
|
||||
|
||||
for block in text_dict.get('blocks', []):
|
||||
if block.get('type') != 0: # 0 = text block, 1 = image
|
||||
continue
|
||||
for line in block.get('lines', []):
|
||||
for span in line.get('spans', []):
|
||||
text = (span.get('text') or '').strip()
|
||||
if not text:
|
||||
continue
|
||||
spans.append({
|
||||
'text': text,
|
||||
'font': span.get('font'),
|
||||
'size': round(span.get('size', 0), 2),
|
||||
'bold': _span_is_bold(span),
|
||||
'italic': _span_is_italic(span),
|
||||
'bbox': span.get('bbox'), # (x0, y0, x1, y1) in PDF points
|
||||
'flags': span.get('flags', 0),
|
||||
})
|
||||
return spans
|
||||
|
||||
|
||||
def _render_page(page: fitz.Page, output_path: str, zoom: float, max_dim: int) -> Optional[str]:
|
||||
"""Render a single page to PNG. Returns saved path or None on failure."""
|
||||
try:
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
||||
img.thumbnail((max_dim, max_dim), Image.LANCZOS)
|
||||
img.save(output_path, "PNG")
|
||||
return output_path
|
||||
except Exception as e:
|
||||
print(f" [ingest] render failed on page {page.number + 1}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_page_count(pdf_path: str) -> int:
|
||||
"""Page count without rendering anything. Returns 0 on failure."""
|
||||
try:
|
||||
doc = fitz.open(pdf_path)
|
||||
count = doc.page_count
|
||||
doc.close()
|
||||
return count
|
||||
except Exception as e:
|
||||
print(f" [ingest] page count failed for {pdf_path}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def ingest_pdf(
|
||||
pdf_path: str,
|
||||
output_dir: str,
|
||||
page_limit: int = DEFAULT_PAGE_LIMIT,
|
||||
progress_callback=None,
|
||||
) -> Dict:
|
||||
"""Render every page of a PDF and capture per-page structured text.
|
||||
|
||||
Args:
|
||||
pdf_path: source PDF path.
|
||||
output_dir: directory to write page PNGs into. Created if missing.
|
||||
page_limit: hard cap on pages processed. Pages beyond the cap are skipped.
|
||||
progress_callback: optional callable(page_num, total) for live progress.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'page_count': int, # total pages in source PDF
|
||||
'pages_processed': int, # pages we actually rendered
|
||||
'truncated': bool, # True if page_count > page_limit
|
||||
'pages': [
|
||||
{
|
||||
'page_num': 1-indexed int,
|
||||
'image_path': str,
|
||||
'raw_text': str,
|
||||
'spans': [{ text, font, size, bold, italic, bbox, flags }, ...],
|
||||
'fonts_used': sorted list of unique font names,
|
||||
},
|
||||
...
|
||||
],
|
||||
}
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
doc = fitz.open(pdf_path)
|
||||
total_pages = doc.page_count
|
||||
pages_to_process = min(total_pages, page_limit)
|
||||
truncated = total_pages > page_limit
|
||||
if truncated:
|
||||
print(f" [ingest] PDF has {total_pages} pages, processing first {page_limit} only")
|
||||
|
||||
pages: List[Dict] = []
|
||||
|
||||
# Page classifier is optional — only used by document-mode profiles that
|
||||
# need a strict-grade exemption for non-artwork pages (e.g. Boots PPack).
|
||||
# Importing locally keeps it out of the hot path for AXA-style profiles.
|
||||
from .page_classifier import classify_page
|
||||
|
||||
for i in range(pages_to_process):
|
||||
page_num = i + 1 # 1-indexed
|
||||
page = doc.load_page(i)
|
||||
|
||||
image_filename = f"page_{page_num:04d}.png"
|
||||
image_path = os.path.join(output_dir, image_filename)
|
||||
rendered = _render_page(page, image_path, DEFAULT_RENDER_ZOOM, DEFAULT_MAX_DIMENSION)
|
||||
|
||||
spans = _extract_page_spans(page)
|
||||
raw_text = page.get_text().strip()
|
||||
fonts_used = sorted({s['font'] for s in spans if s.get('font')})
|
||||
|
||||
page_record = {
|
||||
'page_num': page_num,
|
||||
'image_path': rendered,
|
||||
'raw_text': raw_text,
|
||||
'spans': spans,
|
||||
'fonts_used': fonts_used,
|
||||
}
|
||||
page_record['page_type'] = classify_page(page_record)
|
||||
pages.append(page_record)
|
||||
|
||||
if progress_callback:
|
||||
try:
|
||||
progress_callback(page_num, pages_to_process)
|
||||
except Exception as e:
|
||||
print(f" [ingest] progress callback raised on page {page_num}: {e}")
|
||||
|
||||
doc.close()
|
||||
|
||||
return {
|
||||
'pdf_path': pdf_path,
|
||||
'page_count': total_pages,
|
||||
'pages_processed': pages_to_process,
|
||||
'truncated': truncated,
|
||||
'pages': pages,
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
"""Heuristic page classifier for Boots Production Packs.
|
||||
|
||||
Tags each page of a multi-page production pack with a `page_type` so the
|
||||
dispatcher can:
|
||||
|
||||
• run the same QC checks on every page (the user wants cover-page logo /
|
||||
typography / brand-name compliance to be QC'd, not skipped)
|
||||
• flag non-artwork pages as exempt from the strict-grade override
|
||||
(a cover page with no offer roundel shouldn't tank the overall grade
|
||||
just because `boots_offer_mechanics` finds nothing to evaluate)
|
||||
|
||||
Categories observed across the 10 sample packs in
|
||||
`/Users/nickviljoen/Desktop/AI_QC_Bitbucket/boots/PPacks to Test/`:
|
||||
|
||||
cover — title page: "Production Pack / P7B IMODIUM / 21.11.25"
|
||||
checklist — asset suitability tick-list ("Asset suitable", "Fonts present", ...)
|
||||
palette — creative-guidance colour spec page (CMYK / RGB / Hex blocks)
|
||||
notes — copywriter / yellow-notes commentary page
|
||||
artwork — actual ad layout (default — anything that isn't one of the above)
|
||||
|
||||
Only `artwork` counts towards strict-grade Pass/Fail. Everything else is
|
||||
informational. The classifier never raises — unknown pages default to
|
||||
`artwork` because false positives there are recoverable (an extra check
|
||||
on a cover page just produces an N/A score) whereas missing artwork is not.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
PAGE_TYPES = ('cover', 'checklist', 'palette', 'notes', 'artwork')
|
||||
|
||||
|
||||
# ── Regex patterns derived from observed text on the 10 sample packs ──────────
|
||||
# These are intentionally conservative — they only fire when the page is
|
||||
# unambiguously one of the admin types. Anything fuzzy falls through to artwork.
|
||||
|
||||
_RE_PRODUCTION_PACK_TITLE = re.compile(r'\bProduction Pack\b', re.IGNORECASE)
|
||||
_RE_VERSION_LINE = re.compile(r'^\s*Version\s+\d+\s*$', re.IGNORECASE | re.MULTILINE)
|
||||
_RE_JOB_NUMBER = re.compile(r'\bJob number:\s*\d', re.IGNORECASE)
|
||||
|
||||
# Asset checklist markers — the suitability tick-list
|
||||
_CHECKLIST_TOKENS = (
|
||||
'Asset suitable', 'Assets suitable',
|
||||
'Fonts present',
|
||||
'Resolution fine',
|
||||
'Print ready asset',
|
||||
'Asset low res',
|
||||
'Supplied visual',
|
||||
'More info required',
|
||||
'Content complete',
|
||||
'Colours resolved',
|
||||
'IS PACK A', # appears on No7 Bundle Wobbler P9 cover/checklist combo
|
||||
)
|
||||
|
||||
# Creative guidance / colour palette markers
|
||||
_PALETTE_TOKENS = ('CMYK', 'RGB', 'Hexadecimal')
|
||||
_RE_HEX_COLOUR = re.compile(r'#[0-9a-fA-F]{6}\b')
|
||||
|
||||
# Copywriter / yellow-notes markers
|
||||
_RE_YELLOW_NOTES = re.compile(r'\bYellow Notes\b', re.IGNORECASE)
|
||||
_RE_CLIENT_QUERIES = re.compile(r'\bCLIENT QUERIES\b', re.IGNORECASE)
|
||||
|
||||
# Strong artwork signals — text that only appears on real ad layouts.
|
||||
# Used to override notes/checklist classification when an artwork page
|
||||
# happens to include a Yellow Notes footer or a supplied-assets sidebar.
|
||||
_RE_PRICE = re.compile(r'£\s*\d')
|
||||
_RE_OFFER_MECHANIC = re.compile(
|
||||
r'\b(?:3\s*FOR\s*2|BOGOF|2\s*FOR\s*£|BUY\s+\d|FREE|GIFT|MULTIBUY)\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_OFFER_DATES = re.compile(r'\bOffer valid (?:from|on)\b', re.IGNORECASE)
|
||||
_RE_CLICK_COLLECT = re.compile(r'\bClick\s*&\s*Collect\b', re.IGNORECASE)
|
||||
_RE_GSL_BARCODE = re.compile(r'\bGSL\b\s*\d', re.IGNORECASE)
|
||||
|
||||
|
||||
def _has_artwork_signals(text: str) -> bool:
|
||||
"""True if the page contains at least one strong artwork-only signal."""
|
||||
return any(rx.search(text) for rx in (
|
||||
_RE_PRICE,
|
||||
_RE_OFFER_MECHANIC,
|
||||
_RE_OFFER_DATES,
|
||||
_RE_CLICK_COLLECT,
|
||||
_RE_GSL_BARCODE,
|
||||
))
|
||||
|
||||
|
||||
def classify_page(page: Dict) -> str:
|
||||
"""Classify a single ingested page dict.
|
||||
|
||||
Decision order:
|
||||
1. Strong palette match (multi-token + hex colours) → palette
|
||||
2. Strong checklist match (≥3 suitability tokens) → checklist
|
||||
3. Pages that pass _has_artwork_signals() → artwork
|
||||
(catches Maybelline p5 that has Yellow Notes footer + T&Cs)
|
||||
4. Yellow Notes / Client Queries with no artwork signals → notes
|
||||
5. Sparse Production Pack title block → cover (covers brief / context too)
|
||||
6. Fallthrough → artwork
|
||||
|
||||
Strong palette/checklist precede artwork signals because some palette
|
||||
and checklist pages render thumbnail previews of the artwork (e.g.
|
||||
Nicorette's "£3 off offer ellipse" sample on the palette page) that
|
||||
otherwise look like artwork.
|
||||
|
||||
Defaults to 'artwork' on any uncertainty — false positives there are
|
||||
cheap (an N/A score on a non-applicable check) whereas missing artwork
|
||||
means a real compliance issue would slip through.
|
||||
"""
|
||||
text = (page.get('raw_text') or '').strip()
|
||||
if not text:
|
||||
return 'artwork'
|
||||
|
||||
# ── 1. Palette ────────────────────────────────────────────────────────
|
||||
palette_tokens_found = sum(1 for tok in _PALETTE_TOKENS if tok in text)
|
||||
hex_count = len(_RE_HEX_COLOUR.findall(text))
|
||||
if palette_tokens_found >= 3 and hex_count >= 2:
|
||||
return 'palette'
|
||||
# Looser palette match for short palette pages with only one swatch
|
||||
if palette_tokens_found >= 2 and hex_count >= 1 and 'COLOUR PALETTE' in text.upper():
|
||||
return 'palette'
|
||||
|
||||
# ── 2. Checklist ──────────────────────────────────────────────────────
|
||||
checklist_tokens_found = sum(1 for tok in _CHECKLIST_TOKENS if tok in text)
|
||||
if checklist_tokens_found >= 3:
|
||||
return 'checklist'
|
||||
|
||||
# ── 3. Artwork signals (T&Cs, offer mechanics, prices) ─────────────────
|
||||
if _has_artwork_signals(text):
|
||||
return 'artwork'
|
||||
|
||||
# ── 4. Notes / client queries ─────────────────────────────────────────
|
||||
if _RE_YELLOW_NOTES.search(text) or _RE_CLIENT_QUERIES.search(text):
|
||||
return 'notes'
|
||||
|
||||
# ── 5. Cover / brief / context page ───────────────────────────────────
|
||||
line_count = len([ln for ln in text.splitlines() if ln.strip()])
|
||||
if (
|
||||
line_count <= 15
|
||||
and _RE_PRODUCTION_PACK_TITLE.search(text)
|
||||
and _RE_VERSION_LINE.search(text)
|
||||
and _RE_JOB_NUMBER.search(text)
|
||||
):
|
||||
return 'cover'
|
||||
|
||||
# ── 6. Fallthrough ────────────────────────────────────────────────────
|
||||
return 'artwork'
|
||||
|
||||
|
||||
def classify_pages(pages: List[Dict]) -> List[str]:
|
||||
"""Classify every page in an ingest_pdf result. Returns parallel list."""
|
||||
return [classify_page(p) for p in pages]
|
||||
|
||||
|
||||
def is_artwork(page_type: str) -> bool:
|
||||
"""Convenience predicate for the strict-grade override."""
|
||||
return page_type == 'artwork'
|
||||
|
||||
|
||||
# Human-readable labels for report rendering.
|
||||
PAGE_TYPE_LABELS = {
|
||||
'cover': 'Cover',
|
||||
'checklist': 'Asset Checklist',
|
||||
'palette': 'Creative Guidance / Colour Palette',
|
||||
'notes': 'Yellow Notes',
|
||||
'artwork': 'Artwork',
|
||||
}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
"""Print preflight checks — "is this PDF print-ready?".
|
||||
|
||||
Deterministic Python implementation using PyMuPDF. Covers the high-impact
|
||||
preflight signals that catch the most common press surprises without
|
||||
requiring Ghostscript or veraPDF (PDF/X) tooling.
|
||||
|
||||
Criteria checked:
|
||||
• PP1 Page geometry consistency — every page has the same MediaBox size
|
||||
• PP2 Bleed area defined — TrimBox/BleedBox differ from MediaBox
|
||||
• PP3 Image colour spaces — flag RGB images (press wants CMYK/Gray)
|
||||
• PP4 Image effective DPI — flag images rendering below 150 DPI
|
||||
• PP5 Transparency / overprint — flag pages using transparency (smask, ExtGState)
|
||||
• PP6 PDF/X conformance — XMP declares pdfxid:GTS_PDFXVersion or pdfx:GTS_*
|
||||
• PP7 Spot colour usage — flag /Separation or /DeviceN colour spaces (Pantone)
|
||||
|
||||
Phase-5 scope is "is it print-ready?" — simple yes/no with drill-down.
|
||||
Future expansion (Ghostscript-based total ink coverage, registration black,
|
||||
crop-mark detection, full PDF/X conformance) goes here when scope grows.
|
||||
|
||||
Note: many AXA policy PDFs are digital-intent (no bleed, RGB OK). For those,
|
||||
several of these criteria will fail — that's correct, not a bug. The check
|
||||
surfaces the data; the reviewer judges whether print-readiness is required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
|
||||
# DPI thresholds (industry conventions)
|
||||
DPI_OFFSET_MIN = 300 # commercial offset / glossy stock
|
||||
DPI_NEWSPRINT_MIN = 150 # newspaper / coated stock
|
||||
DPI_DANGER = 150 # below this, we flag as definite risk
|
||||
|
||||
|
||||
def _criterion(code: str, title: str, passed: bool, note: str = '', detail: Optional[Dict] = None) -> Dict:
|
||||
return {
|
||||
'code': code,
|
||||
'title': title,
|
||||
'passed': passed,
|
||||
'note': note,
|
||||
'detail': detail or {},
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Criterion implementations
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_page_geometry(doc: fitz.Document) -> Dict:
|
||||
"""Every page should have the same MediaBox dimensions. Mixed page
|
||||
sizes are valid PDF but a press red flag — usually an authoring error."""
|
||||
sizes: List[Tuple[float, float]] = []
|
||||
for i in range(doc.page_count):
|
||||
p = doc.load_page(i)
|
||||
w = round(p.mediabox.width, 1)
|
||||
h = round(p.mediabox.height, 1)
|
||||
# Normalise orientation — landscape vs portrait of same size = same
|
||||
sizes.append(tuple(sorted((w, h))))
|
||||
distinct = sorted(set(sizes))
|
||||
if len(distinct) == 1:
|
||||
w, h = distinct[0]
|
||||
# Convert to mm for human readability (1 pt = 0.3528 mm)
|
||||
w_mm = round(w * 0.3528, 1)
|
||||
h_mm = round(h * 0.3528, 1)
|
||||
return _criterion(
|
||||
'PP1', 'Page geometry consistency', True,
|
||||
f'All {doc.page_count} pages are {w_mm} × {h_mm} mm.',
|
||||
)
|
||||
return _criterion(
|
||||
'PP1', 'Page geometry consistency', False,
|
||||
f'{len(distinct)} different page sizes found across {doc.page_count} pages.',
|
||||
{'distinct_sizes_pts': [list(s) for s in distinct]},
|
||||
)
|
||||
|
||||
|
||||
def _check_bleed_defined(doc: fitz.Document) -> Dict:
|
||||
"""For print, BleedBox should extend ~3mm beyond TrimBox, and TrimBox
|
||||
should be inset from MediaBox. If MediaBox == TrimBox == BleedBox, no
|
||||
bleed has been authored — page edge artwork will white out on press.
|
||||
|
||||
Heuristic: if any page has BleedBox > MediaBox or TrimBox != MediaBox,
|
||||
treat as "bleed defined". Otherwise fail.
|
||||
"""
|
||||
pages_with_bleed = 0
|
||||
for i in range(doc.page_count):
|
||||
p = doc.load_page(i)
|
||||
media = p.mediabox
|
||||
trim = p.trimbox
|
||||
bleed = p.bleedbox
|
||||
# Compare areas — even sub-mm differences count
|
||||
if (round(trim.width, 2) != round(media.width, 2)
|
||||
or round(trim.height, 2) != round(media.height, 2)
|
||||
or round(bleed.width, 2) != round(media.width, 2)
|
||||
or round(bleed.height, 2) != round(media.height, 2)):
|
||||
pages_with_bleed += 1
|
||||
|
||||
if pages_with_bleed == 0:
|
||||
return _criterion(
|
||||
'PP2', 'Bleed area defined', False,
|
||||
'No page has TrimBox/BleedBox different from MediaBox — bleed not authored.',
|
||||
)
|
||||
return _criterion(
|
||||
'PP2', 'Bleed area defined', True,
|
||||
f'{pages_with_bleed} of {doc.page_count} pages have bleed/trim authored.',
|
||||
{'pages_with_bleed': pages_with_bleed},
|
||||
)
|
||||
|
||||
|
||||
def _check_image_colorspaces(doc: fitz.Document) -> Dict:
|
||||
"""Walk every image, count by colour space. Flag RGB count > 0 — those
|
||||
will go through driver-side conversion on press, with risk of colour
|
||||
shift. CMYK / DeviceGray / Indexed (palette) are press-safe.
|
||||
"""
|
||||
cs_counts: Dict[str, int] = {}
|
||||
rgb_pages: List[int] = []
|
||||
total = 0
|
||||
for i in range(doc.page_count):
|
||||
for img in doc.get_page_images(i, full=True):
|
||||
cs = img[5] or 'Unknown'
|
||||
cs_counts[cs] = cs_counts.get(cs, 0) + 1
|
||||
total += 1
|
||||
if cs == 'DeviceRGB' and (i + 1) not in rgb_pages:
|
||||
rgb_pages.append(i + 1)
|
||||
|
||||
if total == 0:
|
||||
return _criterion(
|
||||
'PP3', 'Image colour spaces', True,
|
||||
'No raster images — colour-space risk does not apply.',
|
||||
)
|
||||
|
||||
rgb_count = cs_counts.get('DeviceRGB', 0)
|
||||
cmyk_count = cs_counts.get('DeviceCMYK', 0)
|
||||
gray_count = cs_counts.get('DeviceGray', 0)
|
||||
|
||||
if rgb_count > 0:
|
||||
return _criterion(
|
||||
'PP3', 'Image colour spaces', False,
|
||||
f'{rgb_count} of {total} images are DeviceRGB — press will perform colour conversion.',
|
||||
{'colorspace_counts': cs_counts, 'rgb_pages': rgb_pages, 'total_images': total},
|
||||
)
|
||||
return _criterion(
|
||||
'PP3', 'Image colour spaces', True,
|
||||
f'No RGB images. Breakdown: CMYK={cmyk_count}, Gray={gray_count}, '
|
||||
f'other={total - cmyk_count - gray_count}.',
|
||||
{'colorspace_counts': cs_counts, 'total_images': total},
|
||||
)
|
||||
|
||||
|
||||
def _check_image_dpi(doc: fitz.Document) -> Dict:
|
||||
"""Sample every placed image, compute its effective DPI (raw pixels /
|
||||
rendered inches). Flag any below DPI_DANGER (150 DPI).
|
||||
"""
|
||||
low_dpi: List[Dict] = []
|
||||
sampled = 0
|
||||
for i in range(doc.page_count):
|
||||
page = doc.load_page(i)
|
||||
# Build a quick lookup: xref → raw pixel size
|
||||
raw_lookup: Dict[int, Tuple[int, int]] = {}
|
||||
for img in doc.get_page_images(i, full=True):
|
||||
raw_lookup[img[0]] = (img[2], img[3])
|
||||
for info in page.get_image_info(xrefs=True):
|
||||
xref = info.get('xref')
|
||||
bbox = info.get('bbox')
|
||||
if xref not in raw_lookup or not bbox:
|
||||
continue
|
||||
raw_w, raw_h = raw_lookup[xref]
|
||||
width_in = (bbox[2] - bbox[0]) / 72.0
|
||||
height_in = (bbox[3] - bbox[1]) / 72.0
|
||||
if width_in <= 0 or height_in <= 0:
|
||||
continue
|
||||
dpi_x = raw_w / width_in
|
||||
dpi_y = raw_h / height_in
|
||||
effective = min(dpi_x, dpi_y)
|
||||
sampled += 1
|
||||
if effective < DPI_DANGER:
|
||||
low_dpi.append({
|
||||
'page': i + 1,
|
||||
'xref': xref,
|
||||
'effective_dpi': round(effective, 0),
|
||||
'raw_pixels': [raw_w, raw_h],
|
||||
'rendered_inches': [round(width_in, 2), round(height_in, 2)],
|
||||
})
|
||||
|
||||
if sampled == 0:
|
||||
return _criterion(
|
||||
'PP4', 'Image effective DPI', True,
|
||||
'No raster images to inspect.',
|
||||
)
|
||||
if low_dpi:
|
||||
return _criterion(
|
||||
'PP4', 'Image effective DPI', False,
|
||||
f'{len(low_dpi)} of {sampled} images render below {DPI_DANGER} DPI.',
|
||||
{'low_dpi_images': low_dpi, 'sampled': sampled, 'threshold': DPI_DANGER},
|
||||
)
|
||||
return _criterion(
|
||||
'PP4', 'Image effective DPI', True,
|
||||
f'All {sampled} images render at ≥ {DPI_DANGER} DPI.',
|
||||
{'sampled': sampled, 'threshold': DPI_DANGER},
|
||||
)
|
||||
|
||||
|
||||
def _check_transparency(doc: fitz.Document) -> Dict:
|
||||
"""Detect transparency / soft-mask usage. Inspect ExtGState dictionaries
|
||||
and image SMask references. Live transparency on press = unpredictable
|
||||
colour blending unless explicitly flattened.
|
||||
"""
|
||||
transparent_pages = 0
|
||||
for i in range(doc.page_count):
|
||||
page = doc.load_page(i)
|
||||
# Check ExtGState resources for non-1.0 alpha or SMask
|
||||
# PyMuPDF's get_text("dict") doesn't expose this — peek via xref
|
||||
try:
|
||||
page_obj = doc.xref_object(page.xref)
|
||||
except Exception:
|
||||
continue
|
||||
if '/ExtGState' in page_obj or '/SMask' in page_obj:
|
||||
# Could be benign; do a tighter check by scanning resources
|
||||
resources_match = re.search(r'/Resources\s*(\d+)\s*0\s*R', page_obj)
|
||||
if resources_match:
|
||||
try:
|
||||
res_obj = doc.xref_object(int(resources_match.group(1)))
|
||||
except Exception:
|
||||
res_obj = ''
|
||||
if 'CA' in res_obj or 'ca' in res_obj or 'SMask' in res_obj:
|
||||
transparent_pages += 1
|
||||
continue
|
||||
transparent_pages += 1
|
||||
|
||||
if transparent_pages == 0:
|
||||
return _criterion(
|
||||
'PP5', 'Transparency / overprint', True,
|
||||
'No transparency or soft-mask usage detected.',
|
||||
)
|
||||
return _criterion(
|
||||
'PP5', 'Transparency / overprint', False,
|
||||
f'{transparent_pages} of {doc.page_count} pages use transparency / soft-masks.',
|
||||
{'transparent_pages_count': transparent_pages},
|
||||
)
|
||||
|
||||
|
||||
def _check_pdfx_conformance(doc: fitz.Document) -> Dict:
|
||||
"""PDF/X is the print-industry conformance standard (PDF/X-1a, 3, 4).
|
||||
Look for the XMP declaration of pdfxid:GTS_PDFXVersion or pdfx:GTS_*.
|
||||
"""
|
||||
try:
|
||||
xmp = doc.get_xml_metadata() or ''
|
||||
except Exception:
|
||||
xmp = ''
|
||||
if not xmp:
|
||||
return _criterion(
|
||||
'PP6', 'PDF/X conformance', False,
|
||||
'No XMP metadata stream found.',
|
||||
)
|
||||
if re.search(r'pdfxid:GTS_PDFXVersion|pdfx:GTS_PDFXVersion', xmp):
|
||||
m = re.search(r'GTS_PDFXVersion[^>]*>([^<]+)<', xmp)
|
||||
version = m.group(1).strip() if m else '(version not parsed)'
|
||||
return _criterion(
|
||||
'PP6', 'PDF/X conformance', True,
|
||||
f'PDF/X conformance declared: {version}',
|
||||
)
|
||||
return _criterion(
|
||||
'PP6', 'PDF/X conformance', False,
|
||||
'No PDF/X conformance flag in XMP metadata.',
|
||||
)
|
||||
|
||||
|
||||
def _check_spot_colors(doc: fitz.Document) -> Dict:
|
||||
"""Look for /Separation (single spot, e.g. Pantone) or /DeviceN (multi-
|
||||
channel spot) colour spaces in the catalog graph. Spot colours are
|
||||
print-meaningful but require explicit handling on press; flag presence
|
||||
so the reviewer can confirm the spot list is intentional.
|
||||
"""
|
||||
found_spaces: List[str] = []
|
||||
sample_xrefs = list(range(1, min(doc.xref_length(), 1000)))
|
||||
for xref in sample_xrefs:
|
||||
try:
|
||||
obj = doc.xref_object(xref)
|
||||
except Exception:
|
||||
continue
|
||||
if '/Separation' in obj:
|
||||
# Pull the spot name token if present
|
||||
m = re.search(r'/Separation\s*/([A-Za-z0-9_#=-]+)', obj)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
if name not in found_spaces:
|
||||
found_spaces.append(name)
|
||||
if '/DeviceN' in obj and 'DeviceN' not in found_spaces:
|
||||
found_spaces.append('DeviceN(multi-spot)')
|
||||
|
||||
if not found_spaces:
|
||||
return _criterion(
|
||||
'PP7', 'Spot colour usage', True,
|
||||
'No spot colour spaces detected — pure CMYK/RGB/Gray.',
|
||||
)
|
||||
return _criterion(
|
||||
'PP7', 'Spot colour usage', False,
|
||||
f'{len(found_spaces)} spot colour spaces detected — confirm spot list is intentional.',
|
||||
{'spot_spaces': found_spaces},
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Top-level entry
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def axa_print_preflight(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""Run the full deterministic print-preflight check set on the ingested PDF."""
|
||||
pdf_path = ingest_result.get('pdf_path')
|
||||
if not pdf_path:
|
||||
return {
|
||||
'check_name': 'axa_print_preflight',
|
||||
'scope': 'document',
|
||||
'score': 0.0,
|
||||
'pass': False,
|
||||
'summary': 'Cannot run — pdf_path missing from ingest_result.',
|
||||
'findings': {'error': 'pdf_path_missing'},
|
||||
'response': '',
|
||||
}
|
||||
|
||||
try:
|
||||
doc = fitz.open(pdf_path)
|
||||
except Exception as e:
|
||||
return {
|
||||
'check_name': 'axa_print_preflight',
|
||||
'scope': 'document',
|
||||
'score': 0.0,
|
||||
'pass': False,
|
||||
'summary': f'Failed to open PDF: {e}',
|
||||
'findings': {'error': str(e)},
|
||||
'response': '',
|
||||
}
|
||||
|
||||
try:
|
||||
criteria = [
|
||||
_check_page_geometry(doc),
|
||||
_check_bleed_defined(doc),
|
||||
_check_image_colorspaces(doc),
|
||||
_check_image_dpi(doc),
|
||||
_check_transparency(doc),
|
||||
_check_pdfx_conformance(doc),
|
||||
_check_spot_colors(doc),
|
||||
]
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
passed = [c for c in criteria if c['passed']]
|
||||
failed = [c for c in criteria if not c['passed']]
|
||||
total = len(criteria)
|
||||
score = round((len(passed) / total) * 10, 2) if total else 0.0
|
||||
pass_flag = len(failed) == 0
|
||||
|
||||
if pass_flag:
|
||||
summary = f'All {total} print-preflight criteria passed — print-ready.'
|
||||
elif len(failed) <= 2:
|
||||
summary = f'{len(failed)} of {total} criteria failed — likely digital-intent or minor preflight gaps.'
|
||||
else:
|
||||
summary = f'{len(failed)} of {total} criteria failed — not print-ready as-is.'
|
||||
|
||||
response_lines = [summary, '']
|
||||
for c in criteria:
|
||||
marker = '✓' if c['passed'] else '✗'
|
||||
response_lines.append(f" {marker} {c['code']} — {c['title']}: {c['note']}")
|
||||
response = '\n'.join(response_lines)
|
||||
|
||||
return {
|
||||
'check_name': 'axa_print_preflight',
|
||||
'scope': 'document',
|
||||
'score': score,
|
||||
'pass': pass_flag,
|
||||
'summary': summary,
|
||||
'findings': {
|
||||
'criteria': criteria,
|
||||
'criteria_total': total,
|
||||
'criteria_passed': len(passed),
|
||||
'criteria_failed': len(failed),
|
||||
},
|
||||
'response': response,
|
||||
}
|
||||
|
|
@ -1,718 +0,0 @@
|
|||
"""Result serialisation for document-mode QC.
|
||||
|
||||
Writes both a structured JSON file (full per-check + per-page drilldown) and
|
||||
a self-contained HTML report optimised for fast triage:
|
||||
|
||||
• Top "Findings at a glance" panel — one line per check
|
||||
• Per-check sections with structured findings tables
|
||||
• Per-page accordion (collapsed by default; "show only failing" filter)
|
||||
|
||||
Filename convention mirrors single-asset mode:
|
||||
<output_dir>/<session_id>_<filename>_data.json
|
||||
<output_dir>/<session_id>_<filename>_report.html
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def _slugify_filename(name: str) -> str:
|
||||
base = os.path.splitext(os.path.basename(name))[0]
|
||||
return base.replace(' ', '_').replace('/', '_')
|
||||
|
||||
|
||||
def _score_class(score: float) -> str:
|
||||
if score >= 8:
|
||||
return 'score-good'
|
||||
if score >= 6:
|
||||
return 'score-ok'
|
||||
return 'score-bad'
|
||||
|
||||
|
||||
def _pill(text: str, kind: str = 'neutral') -> str:
|
||||
classes = {
|
||||
'good': 'pill pill-good',
|
||||
'ok': 'pill pill-ok',
|
||||
'bad': 'pill pill-bad',
|
||||
'neutral': 'pill',
|
||||
}
|
||||
return f'<span class="{classes.get(kind, classes["neutral"])}">{html.escape(str(text))}</span>'
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Findings renderers — each understands a specific check's structured payload
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _render_font_inventory(findings: Dict) -> str:
|
||||
distribution = findings.get('distribution') or []
|
||||
if not distribution:
|
||||
return '<p>No fonts captured.</p>'
|
||||
rows = ''.join(
|
||||
f"<tr><td>{html.escape(d['font'])}</td><td>{d['page_count']}</td>"
|
||||
f"<td>{html.escape(', '.join(str(p) for p in d['pages'][:30]))}{'…' if len(d['pages']) > 30 else ''}</td></tr>"
|
||||
for d in distribution
|
||||
)
|
||||
return f"""
|
||||
<table class='findings-table'>
|
||||
<thead><tr><th>Font</th><th>Pages with this font</th><th>Page list (first 30)</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _render_phone_inventory(findings: Dict) -> str:
|
||||
distribution = findings.get('distribution') or []
|
||||
if not distribution:
|
||||
return '<p>No phone numbers detected.</p>'
|
||||
rows = ''.join(
|
||||
f"<tr><td>{html.escape(d['number'])}</td><td>{d['occurrences']}</td>"
|
||||
f"<td>{html.escape(', '.join(str(p) for p in d['pages'][:30]))}{'…' if len(d['pages']) > 30 else ''}</td></tr>"
|
||||
for d in distribution
|
||||
)
|
||||
return f"""
|
||||
<table class='findings-table'>
|
||||
<thead><tr><th>Number</th><th>Occurrences</th><th>Pages</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _render_bold_words_violations(findings: Dict) -> str:
|
||||
if findings.get('error') == 'seed_missing':
|
||||
return '<p class="muted">No bold-words seed dictionary present.</p>'
|
||||
violations = findings.get('violations') or []
|
||||
bold_n = findings.get('bold_occurrences', 0)
|
||||
non_bold_n = findings.get('non_bold_occurrences', 0)
|
||||
excluded = findings.get('definitions_pages_excluded') or []
|
||||
dict_size = findings.get('dictionary_size', 0)
|
||||
|
||||
head = f"""
|
||||
<p>
|
||||
<strong>{dict_size}</strong> defined terms scanned ·
|
||||
<span class='ok'>{bold_n}</span> correctly bold ·
|
||||
<span class='bad'>{non_bold_n}</span> non-bold ·
|
||||
excluding definitions pages {excluded or '(none)'}.
|
||||
</p>
|
||||
"""
|
||||
|
||||
if not violations:
|
||||
return head + '<p class="ok">No non-bold occurrences detected.</p>'
|
||||
|
||||
# Group violations by term for readability
|
||||
by_term: Dict[str, List[Dict]] = {}
|
||||
for v in violations:
|
||||
by_term.setdefault(v['term'], []).append(v)
|
||||
|
||||
sections = []
|
||||
for term, items in sorted(by_term.items(), key=lambda kv: -len(kv[1])):
|
||||
rows = ''.join(
|
||||
f"<tr><td>{v['page']}</td><td>{html.escape((v['context'] or '')[:200])}</td>"
|
||||
f"<td><code>{html.escape(v.get('font') or '?')}</code></td></tr>"
|
||||
for v in items[:25]
|
||||
)
|
||||
more = f"<tr><td colspan=3 class='muted'>…and {len(items) - 25} more occurrences.</td></tr>" if len(items) > 25 else ''
|
||||
sections.append(f"""
|
||||
<details class='violation-block'>
|
||||
<summary><strong>{html.escape(term)}</strong> — {len(items)} non-bold occurrence{'s' if len(items) != 1 else ''}</summary>
|
||||
<table class='findings-table'>
|
||||
<thead><tr><th>Page</th><th>Context (truncated)</th><th>Font</th></tr></thead>
|
||||
<tbody>{rows}{more}</tbody>
|
||||
</table>
|
||||
</details>
|
||||
""")
|
||||
|
||||
return head + '\n'.join(sections)
|
||||
|
||||
|
||||
def _render_page_numbering(findings: Dict) -> str:
|
||||
issues = findings.get('discontinuities') or []
|
||||
detected = findings.get('pages_with_detected_number', 0)
|
||||
total = findings.get('pages_total', 0)
|
||||
head = f"<p>Detected page numbers on {detected}/{total} pages.</p>"
|
||||
if not issues:
|
||||
return head + '<p class="ok">No discontinuities detected.</p>'
|
||||
rows = ''.join(
|
||||
f"<tr><td>{i['page_index']}</td><td>{i['expected']}</td><td>{i['detected']}</td></tr>"
|
||||
for i in issues
|
||||
)
|
||||
return head + f"""
|
||||
<table class='findings-table'>
|
||||
<thead><tr><th>Page index</th><th>Expected</th><th>Detected</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
<p class="muted">Heuristic — TOC pages and section dividers can produce false positives.</p>
|
||||
"""
|
||||
|
||||
|
||||
def _render_print_code(findings: Dict) -> str:
|
||||
pages = findings.get('pages_inspected') or []
|
||||
code = findings.get('code_candidates') or []
|
||||
refs = findings.get('doc_refs') or []
|
||||
dates = findings.get('date_candidates') or []
|
||||
versions = findings.get('version_candidates') or []
|
||||
return f"""
|
||||
<p>Inspected page(s): {pages}</p>
|
||||
<p>
|
||||
<strong>Code candidates:</strong> {', '.join(code) or '<em>none</em>'}<br>
|
||||
<strong>Document refs:</strong> {', '.join(refs) or '<em>none</em>'}<br>
|
||||
<strong>Date candidates:</strong> {', '.join(dates) or '<em>none</em>'}<br>
|
||||
<strong>Version candidates:</strong> {', '.join(versions) or '<em>none</em>'}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def _render_omg_versioning(findings: Dict) -> str:
|
||||
pages = findings.get('pages_inspected') or []
|
||||
omg = findings.get('omg_matches') or []
|
||||
dates = findings.get('date_matches') or []
|
||||
return f"""
|
||||
<p>Inspected page(s): {pages}</p>
|
||||
<p>
|
||||
<strong>OMG codes:</strong> {', '.join(omg) or '<em>none</em>'}<br>
|
||||
<strong>Date formats:</strong> {', '.join(dates) or '<em>none</em>'}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def _render_print_preflight(findings: Dict) -> str:
|
||||
if findings.get('error'):
|
||||
return f"<p class='bad'>Error: {html.escape(str(findings['error']))}</p>"
|
||||
|
||||
criteria = findings.get('criteria') or []
|
||||
passed = findings.get('criteria_passed', 0)
|
||||
total = findings.get('criteria_total', 0)
|
||||
|
||||
head = f"<p><strong>{passed} / {total}</strong> print preflight criteria passed.</p>"
|
||||
|
||||
rows = []
|
||||
for c in criteria:
|
||||
marker = '<span class="ok">✓</span>' if c['passed'] else '<span class="bad">✗</span>'
|
||||
detail_extra = ''
|
||||
d = c.get('detail') or {}
|
||||
if d.get('low_dpi_images'):
|
||||
items = ''.join(
|
||||
f"<li>Page {x['page']}, xref {x['xref']}: {x['effective_dpi']} DPI "
|
||||
f"(rendered {x['rendered_inches'][0]} × {x['rendered_inches'][1]} in)</li>"
|
||||
for x in d['low_dpi_images'][:10]
|
||||
)
|
||||
more = (f"<li class='muted'>…and {len(d['low_dpi_images']) - 10} more.</li>"
|
||||
if len(d['low_dpi_images']) > 10 else '')
|
||||
detail_extra = f"<ul style='margin:4px 0 0;padding-left:18px;font-size:12px'>{items}{more}</ul>"
|
||||
elif d.get('colorspace_counts'):
|
||||
cs = d['colorspace_counts']
|
||||
cs_summary = ', '.join(f"{k}: {v}" for k, v in sorted(cs.items()))
|
||||
detail_extra = f"<br><code>{html.escape(cs_summary)}</code>"
|
||||
elif d.get('spot_spaces'):
|
||||
detail_extra = f"<br><code>{html.escape(', '.join(d['spot_spaces']))}</code>"
|
||||
elif d.get('distinct_sizes_pts'):
|
||||
sizes = '; '.join(f"{round(s[0]*0.3528,1)}×{round(s[1]*0.3528,1)}mm" for s in d['distinct_sizes_pts'])
|
||||
detail_extra = f"<br><code>{html.escape(sizes)}</code>"
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td class='center' style='width:50px'>{marker}</td>
|
||||
<td><code>{html.escape(c['code'])}</code></td>
|
||||
<td>{html.escape(c['title'])}</td>
|
||||
<td>{html.escape(c['note'])}{detail_extra}</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
return head + f"""
|
||||
<table class='findings-table'>
|
||||
<thead><tr><th></th><th>Code</th><th>Criterion</th><th>Observation</th></tr></thead>
|
||||
<tbody>{''.join(rows)}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _render_pdf_accessibility(findings: Dict) -> str:
|
||||
if findings.get('error'):
|
||||
return f"<p class='bad'>Error: {html.escape(str(findings['error']))}</p>"
|
||||
|
||||
criteria = findings.get('criteria') or []
|
||||
passed = findings.get('criteria_passed', 0)
|
||||
total = findings.get('criteria_total', 0)
|
||||
verapdf_run = findings.get('verapdf_run', False)
|
||||
verapdf = findings.get('verapdf') or {}
|
||||
|
||||
if verapdf_run:
|
||||
verapdf_label = '<span class="ok">enabled</span>'
|
||||
elif verapdf.get('error'):
|
||||
verapdf_label = f'<span class="bad">error: {html.escape(verapdf["error"])}</span>'
|
||||
else:
|
||||
verapdf_label = '<span class="muted">not installed on host</span>'
|
||||
|
||||
head = f"""
|
||||
<p>
|
||||
<strong>{passed} / {total}</strong> fast criteria passed
|
||||
· veraPDF PDF/UA-1: {verapdf_label}
|
||||
</p>
|
||||
"""
|
||||
|
||||
verapdf_block = ''
|
||||
if verapdf_run:
|
||||
compliant = verapdf.get('compliant')
|
||||
verdict_html = (
|
||||
"<span class='ok'>COMPLIANT</span>" if compliant
|
||||
else "<span class='bad'>NOT COMPLIANT</span>"
|
||||
)
|
||||
rule_rows = []
|
||||
for r in verapdf.get('failed_rule_details') or []:
|
||||
tags = ', '.join(r.get('tags') or []) or '—'
|
||||
samples = r.get('sample_errors') or []
|
||||
sample_html = ''
|
||||
if samples:
|
||||
sample_html = (
|
||||
"<br><code>e.g. " + html.escape(samples[0]) + "</code>"
|
||||
)
|
||||
rule_rows.append(f"""
|
||||
<tr>
|
||||
<td><code>{html.escape(str(r.get('clause', '')))}-{html.escape(str(r.get('test_number', '')))}</code></td>
|
||||
<td class='center'>{r.get('failed_checks', 0)}</td>
|
||||
<td><code>{html.escape(tags)}</code></td>
|
||||
<td>{html.escape(r.get('description', ''))}{sample_html}</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
verapdf_block = f"""
|
||||
<p><strong>veraPDF verdict:</strong> {verdict_html} ·
|
||||
{verapdf.get('passed_rules', 0)} rules passed / {verapdf.get('failed_rules', 0)} failed ·
|
||||
{verapdf.get('passed_checks', 0)} checks passed / {verapdf.get('failed_checks', 0)} failed</p>
|
||||
"""
|
||||
if rule_rows:
|
||||
verapdf_block += f"""
|
||||
<table class='findings-table'>
|
||||
<thead><tr><th>Clause</th><th>Failures</th><th>Tags</th><th>Description</th></tr></thead>
|
||||
<tbody>{''.join(rule_rows)}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
rows = []
|
||||
for c in criteria:
|
||||
marker = '<span class="ok">✓</span>' if c['passed'] else '<span class="bad">✗</span>'
|
||||
detail_extra = ''
|
||||
d = c.get('detail') or {}
|
||||
if d.get('not_embedded'):
|
||||
detail_extra = f"<br><code>Non-embedded: {html.escape(', '.join(d['not_embedded']))}</code>"
|
||||
elif d.get('image_count') is not None:
|
||||
detail_extra = f"<br><code>{d.get('image_count', 0)} images on {d.get('pages_with_images', 0)} pages (first 30)</code>"
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td class='center' style='width:50px'>{marker}</td>
|
||||
<td><code>{html.escape(c['code'])}</code></td>
|
||||
<td>{html.escape(c['title'])}</td>
|
||||
<td>{html.escape(c['note'])}{detail_extra}</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
return head + verapdf_block + f"""
|
||||
<table class='findings-table'>
|
||||
<thead><tr><th></th><th>Code</th><th>Criterion</th><th>Observation</th></tr></thead>
|
||||
<tbody>{''.join(rows)}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _render_generic(findings: Dict, response: str) -> str:
|
||||
"""Fallback renderer for checks without a custom structured view —
|
||||
just show the response as preformatted text."""
|
||||
if response:
|
||||
return f"<pre class='response-block'>{html.escape(response)}</pre>"
|
||||
return f"<pre class='response-block'>{html.escape(json.dumps(findings, indent=2, default=str))}</pre>"
|
||||
|
||||
|
||||
# Human-readable labels for page_type tags. Kept in sync with
|
||||
# document_mode.page_classifier.PAGE_TYPE_LABELS.
|
||||
_PAGE_TYPE_LABELS = {
|
||||
'cover': 'Cover',
|
||||
'checklist': 'Asset Checklist',
|
||||
'palette': 'Creative Guidance',
|
||||
'notes': 'Yellow Notes',
|
||||
'artwork': 'Artwork',
|
||||
}
|
||||
|
||||
|
||||
def _page_type_pill(page_type: str) -> str:
|
||||
label = _PAGE_TYPE_LABELS.get(page_type, page_type or 'artwork')
|
||||
cls = 'page-type-artwork' if page_type == 'artwork' else 'page-type-info'
|
||||
return f"<span class='page-type-pill {cls}'>{html.escape(label)}</span>"
|
||||
|
||||
|
||||
def _render_page_each(findings: Dict, response: str) -> str:
|
||||
"""Per-page breakdown for any page_each-scope check.
|
||||
|
||||
Renders a table of pages (page_num, type, score, status) followed by
|
||||
expandable per-page response cards. Used by the Boots PPack profile
|
||||
where every check runs on every page.
|
||||
"""
|
||||
page_scores = findings.get('page_scores') or {}
|
||||
page_types = findings.get('page_types') or {}
|
||||
page_responses = findings.get('page_responses') or {}
|
||||
artwork_scores = findings.get('artwork_page_scores') or {}
|
||||
informational_scores = findings.get('informational_page_scores') or {}
|
||||
failing_artwork = set(findings.get('failing_artwork_pages') or [])
|
||||
|
||||
if not page_scores:
|
||||
# Fall back to the generic response dump if the dispatcher didn't
|
||||
# populate per-page data (e.g. a check raised mid-run).
|
||||
return _render_generic(findings, response)
|
||||
|
||||
# Headline summary line
|
||||
head = f"""
|
||||
<p>
|
||||
Ran on <strong>{len(page_scores)}</strong> pages —
|
||||
<span class='ok'>{len(artwork_scores)}</span> artwork,
|
||||
<span class='muted'>{len(informational_scores)}</span> informational
|
||||
(informational pages don't affect Pass/Fail).
|
||||
</p>
|
||||
"""
|
||||
|
||||
rows = []
|
||||
for page_num, score in sorted(page_scores.items()):
|
||||
ptype = page_types.get(page_num, 'artwork')
|
||||
is_artwork = ptype == 'artwork'
|
||||
score_cls = _score_class(score)
|
||||
if is_artwork:
|
||||
status_pill = (
|
||||
'<span class="pass-pill fail">Below threshold</span>'
|
||||
if page_num in failing_artwork
|
||||
else '<span class="pass-pill pass">OK</span>'
|
||||
)
|
||||
else:
|
||||
status_pill = '<span class="pass-pill" style="background:#eef2f7;color:#4a5a72;">Informational</span>'
|
||||
|
||||
response_text = page_responses.get(page_num, '')
|
||||
body = (
|
||||
f"<details class='page-response'><summary class='muted'>Show details</summary>"
|
||||
f"<pre class='response-block'>{html.escape(response_text)}</pre>"
|
||||
f"</details>"
|
||||
if response_text else ''
|
||||
)
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td>Page {page_num}</td>
|
||||
<td>{_page_type_pill(ptype)}</td>
|
||||
<td class='{score_cls}'><strong>{score}</strong></td>
|
||||
<td>{status_pill}</td>
|
||||
<td>{body}</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
return head + f"""
|
||||
<table class='findings-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page</th><th>Page type</th><th>Score</th><th>Status</th><th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{''.join(rows)}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
_FINDINGS_RENDERERS = {
|
||||
'axa_font_inventory': _render_font_inventory,
|
||||
'axa_phone_inventory': _render_phone_inventory,
|
||||
'axa_bold_words_definitions': _render_bold_words_violations,
|
||||
'axa_page_numbering': _render_page_numbering,
|
||||
'axa_print_code': _render_print_code,
|
||||
'axa_omg_versioning': _render_omg_versioning,
|
||||
'axa_pdf_accessibility': _render_pdf_accessibility,
|
||||
'axa_print_preflight': _render_print_preflight,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main HTML report
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _render_check_section(check_name: str, check_result: Dict) -> str:
|
||||
score = check_result.get('score', 0) or 0
|
||||
summary = check_result.get('summary', '')
|
||||
findings = check_result.get('findings', {}) or {}
|
||||
response = check_result.get('response', '') or ''
|
||||
scope = check_result.get('scope', '?')
|
||||
pass_flag = check_result.get('pass', False)
|
||||
|
||||
renderer = _FINDINGS_RENDERERS.get(check_name)
|
||||
if renderer:
|
||||
body = renderer(findings)
|
||||
elif scope == 'page_each':
|
||||
body = _render_page_each(findings, response)
|
||||
else:
|
||||
body = _render_generic(findings, response)
|
||||
|
||||
return f"""
|
||||
<details class='check-section' open>
|
||||
<summary>
|
||||
<span class='check-name'>{html.escape(check_name)}</span>
|
||||
<span class='check-meta'>
|
||||
<span class='scope-pill'>scope: {html.escape(scope)}</span>
|
||||
<span class='score-pill {_score_class(score)}'>{score}</span>
|
||||
{('<span class="pass-pill pass">Pass</span>' if pass_flag else '<span class="pass-pill fail">Fail</span>')}
|
||||
</span>
|
||||
</summary>
|
||||
<p class='check-summary'>{html.escape(summary)}</p>
|
||||
<div class='check-body'>{body}</div>
|
||||
</details>
|
||||
"""
|
||||
|
||||
|
||||
def _render_at_a_glance(check_summaries: Dict[str, Dict]) -> str:
|
||||
rows = []
|
||||
for name, s in check_summaries.items():
|
||||
score = s.get('score', 0) or 0
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td><a href='#check-{html.escape(name)}'>{html.escape(name)}</a></td>
|
||||
<td><span class='scope-pill'>{html.escape(s.get('scope', '?'))}</span></td>
|
||||
<td class='{_score_class(score)}'><strong>{score}</strong></td>
|
||||
<td>{('<span class="pass-pill pass">Pass</span>' if s.get('pass') else '<span class="pass-pill fail">Fail</span>')}</td>
|
||||
<td>{html.escape(s.get('summary', ''))}</td>
|
||||
</tr>
|
||||
""")
|
||||
return f"""
|
||||
<table class='glance-table'>
|
||||
<thead><tr><th>Check</th><th>Scope</th><th>Score</th><th>Status</th><th>Headline finding</th></tr></thead>
|
||||
<tbody>{''.join(rows)}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def _render_page_strip(pages: List[Dict]) -> str:
|
||||
"""Per-page strip showing fonts found on each page (and image link if available)."""
|
||||
if not pages:
|
||||
return ''
|
||||
rows = []
|
||||
for p in pages:
|
||||
fonts = p.get('fonts_used') or []
|
||||
ptype = p.get('page_type', 'artwork')
|
||||
rows.append(f"""
|
||||
<details class='page-row'>
|
||||
<summary>
|
||||
<span class='page-num'>Page {p['page_num']}</span>
|
||||
{_page_type_pill(ptype)}
|
||||
<span class='font-count'>{len(fonts)} fonts</span>
|
||||
</summary>
|
||||
<p class='fonts-on-page'>{html.escape(', '.join(fonts))}</p>
|
||||
</details>
|
||||
""")
|
||||
return ''.join(rows)
|
||||
|
||||
|
||||
def _render_html(result: Dict, original_filename: str) -> str:
|
||||
summary = result.get('document_summary', {})
|
||||
overall_score = summary.get('overall_score', 0)
|
||||
grade = summary.get('grade', '')
|
||||
check_summaries = summary.get('check_summaries', {})
|
||||
check_results = result.get('check_results', {})
|
||||
pages = result.get('pages', [])
|
||||
fonts_inventory = (result.get('ingest_metadata') or {}).get('fonts_inventory', [])
|
||||
|
||||
truncated_banner = ''
|
||||
if result.get('truncated'):
|
||||
truncated_banner = f"""
|
||||
<div class='banner banner-warn'>
|
||||
⚠️ Document has {result.get('page_count')} pages — only the first {result.get('pages_processed')} were processed.
|
||||
</div>
|
||||
"""
|
||||
|
||||
strict_banner = ''
|
||||
if summary.get('strict_grade'):
|
||||
violations = summary.get('strict_violations') or []
|
||||
if violations:
|
||||
# Group violations by page for readability
|
||||
by_page: Dict[int, List[Dict]] = {}
|
||||
for v in violations:
|
||||
by_page.setdefault(v['page'], []).append(v)
|
||||
page_rows = []
|
||||
for page_num, vs in sorted(by_page.items()):
|
||||
check_list = ', '.join(
|
||||
f"<code>{html.escape(v['check'])}</code> ({v['score']})"
|
||||
for v in vs
|
||||
)
|
||||
page_rows.append(f"<li>Page {page_num}: {check_list}</li>")
|
||||
strict_banner = f"""
|
||||
<div class='banner banner-fail'>
|
||||
<strong>Strict-grade override triggered.</strong>
|
||||
{len(violations)} artwork-page check{'s' if len(violations) != 1 else ''} scored below 6 — overall grade forced to Fail.
|
||||
<ul style='margin:6px 0 0 18px;padding:0;'>{''.join(page_rows)}</ul>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
strict_banner = """
|
||||
<div class='banner banner-ok'>
|
||||
✓ Strict-grade override active — no artwork-page checks scored below the threshold.
|
||||
</div>
|
||||
"""
|
||||
|
||||
glance = _render_at_a_glance(check_summaries)
|
||||
check_sections = '\n'.join(
|
||||
f"<a id='check-{html.escape(name)}'></a>{_render_check_section(name, check_results.get(name, {}))}"
|
||||
for name in check_summaries.keys()
|
||||
)
|
||||
|
||||
fonts_pill_strip = ''.join(
|
||||
f"<span class='font-pill'>{html.escape(f)}</span>" for f in fonts_inventory
|
||||
) or '<em class="muted">No fonts captured.</em>'
|
||||
|
||||
page_strip = _render_page_strip(pages)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<title>QC Report — {html.escape(original_filename)}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; background: #f5f6f8; color: #222; }}
|
||||
.wrap {{ max-width: 1200px; margin: 0 auto; padding: 24px; }}
|
||||
h1 {{ margin: 0 0 4px; font-size: 22px; }}
|
||||
h2 {{ margin: 28px 0 10px; font-size: 16px; color: #111; }}
|
||||
.meta {{ color: #666; font-size: 13px; margin-bottom: 20px; }}
|
||||
.ok {{ color: #2a8a3a; }}
|
||||
.bad {{ color: #b53030; }}
|
||||
.muted {{ color: #888; }}
|
||||
.center {{ text-align: center; }}
|
||||
code {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; }}
|
||||
.overall-card {{ background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 16px; display: flex; align-items: center; gap: 24px; }}
|
||||
.overall-score {{ font-size: 48px; font-weight: 600; }}
|
||||
.overall-score.score-good {{ color: #2a8a3a; }}
|
||||
.overall-score.score-ok {{ color: #b58a00; }}
|
||||
.overall-score.score-bad {{ color: #b53030; }}
|
||||
.grade-badge {{ padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: 0.5px; }}
|
||||
.grade-Pass {{ background: #d6f0d8; color: #1f6a2a; }}
|
||||
.grade-Fail {{ background: #f4d4d4; color: #8a1f1f; }}
|
||||
.glance-table, .findings-table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 12px; }}
|
||||
.glance-table th, .glance-table td, .findings-table th, .findings-table td {{ text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }}
|
||||
.glance-table th, .findings-table th {{ background: #fafafa; font-weight: 600; color: #555; }}
|
||||
.glance-table tr:last-child td, .findings-table tr:last-child td {{ border-bottom: none; }}
|
||||
.glance-table a {{ color: #0a5fc2; text-decoration: none; }}
|
||||
.glance-table a:hover {{ text-decoration: underline; }}
|
||||
.score-good, td.score-good {{ color: #2a8a3a; }}
|
||||
.score-ok, td.score-ok {{ color: #b58a00; }}
|
||||
.score-bad, td.score-bad {{ color: #b53030; }}
|
||||
.score-pill {{ font-weight: 700; padding: 3px 10px; border-radius: 6px; background: #f0f0f0; font-size: 14px; }}
|
||||
.score-pill.score-good {{ background: #d6f0d8; color: #1f6a2a; }}
|
||||
.score-pill.score-ok {{ background: #fceac0; color: #7a5a00; }}
|
||||
.score-pill.score-bad {{ background: #f4d4d4; color: #8a1f1f; }}
|
||||
.scope-pill {{ font-size: 11px; background: #eef2f7; color: #4a5a72; padding: 2px 8px; border-radius: 999px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }}
|
||||
.pass-pill {{ font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; }}
|
||||
.pass-pill.pass {{ background: #d6f0d8; color: #1f6a2a; }}
|
||||
.pass-pill.fail {{ background: #f4d4d4; color: #8a1f1f; }}
|
||||
.check-section {{ background: white; border-radius: 8px; padding: 14px 18px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }}
|
||||
.check-section[open] {{ padding-bottom: 18px; }}
|
||||
.check-section summary {{ cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 12px; list-style: none; }}
|
||||
.check-section summary::-webkit-details-marker {{ display: none; }}
|
||||
.check-section summary::before {{ content: '▸'; display: inline-block; transition: transform .15s; margin-right: 6px; color: #888; }}
|
||||
.check-section[open] summary::before {{ transform: rotate(90deg); }}
|
||||
.check-section .check-name {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; flex: 1; }}
|
||||
.check-section .check-meta {{ display: flex; align-items: center; gap: 8px; }}
|
||||
.check-summary {{ color: #444; font-size: 13px; margin: 8px 0 14px; padding-left: 18px; }}
|
||||
.check-body {{ padding-left: 18px; }}
|
||||
.violation-block {{ background: #fafbfc; border-left: 3px solid #b53030; padding: 8px 12px; margin: 6px 0; border-radius: 4px; }}
|
||||
.violation-block summary {{ cursor: pointer; font-size: 13px; }}
|
||||
.violation-block[open] summary {{ margin-bottom: 8px; }}
|
||||
.response-block {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: #fafafa; padding: 10px; border-radius: 6px; max-height: 400px; overflow: auto; white-space: pre-wrap; }}
|
||||
.font-pill {{ display: inline-block; padding: 2px 8px; background: #eef2f7; border-radius: 999px; margin: 2px 4px 2px 0; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }}
|
||||
.banner {{ padding: 12px 16px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }}
|
||||
.banner-warn {{ background: #fff4d6; color: #7a5a00; border: 1px solid #f0d77b; }}
|
||||
.banner-fail {{ background: #fbe5e5; color: #8a1f1f; border: 1px solid #e9b2b2; }}
|
||||
.banner-ok {{ background: #e2f4e4; color: #1f6a2a; border: 1px solid #b2d9b8; }}
|
||||
.page-type-pill {{ display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.3px; }}
|
||||
.page-type-pill.page-type-artwork {{ background: #d6f0d8; color: #1f6a2a; }}
|
||||
.page-type-pill.page-type-info {{ background: #eef2f7; color: #4a5a72; }}
|
||||
.page-response summary {{ cursor: pointer; font-size: 12px; padding: 4px 0; }}
|
||||
.page-row {{ background: white; padding: 6px 12px; margin-bottom: 4px; border-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }}
|
||||
.page-row summary {{ display: flex; justify-content: space-between; cursor: pointer; font-size: 13px; }}
|
||||
.page-num {{ font-weight: 600; }}
|
||||
.font-count {{ color: #888; font-size: 12px; }}
|
||||
.fonts-on-page {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #555; margin: 6px 0 0; }}
|
||||
.filter-bar {{ background: white; border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); font-size: 13px; }}
|
||||
.filter-bar label {{ cursor: pointer; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='wrap'>
|
||||
<h1>QC Report — {html.escape(original_filename)}</h1>
|
||||
<div class='meta'>
|
||||
Profile: <strong>{html.escape(result.get('profile_name', ''))}</strong>
|
||||
· Pages processed: {result.get('pages_processed', 0)} / {result.get('page_count', 0)}
|
||||
· {html.escape(result.get('timestamp', ''))}
|
||||
</div>
|
||||
{truncated_banner}
|
||||
{strict_banner}
|
||||
|
||||
<div class='overall-card'>
|
||||
<div>
|
||||
<div class='overall-score {_score_class(overall_score / 10 if overall_score else 0)}'>{overall_score}</div>
|
||||
<div style='font-size:12px;color:#666;'>Overall score (0-100)</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class='grade-badge grade-{grade}'>{grade}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Findings at a glance</h2>
|
||||
{glance}
|
||||
|
||||
<h2>Check details</h2>
|
||||
{check_sections}
|
||||
|
||||
<h2>Fonts inventory</h2>
|
||||
<div class='overall-card' style='display:block;'>
|
||||
{fonts_pill_strip}
|
||||
</div>
|
||||
|
||||
<h2>Per-page summary</h2>
|
||||
{page_strip}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Public entrypoint
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def write_document_report(
|
||||
result: Dict,
|
||||
original_filename: str,
|
||||
session_id: str,
|
||||
output_dir: str,
|
||||
output_mode: str = 'both',
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""Write JSON + HTML reports for a document-mode analysis.
|
||||
|
||||
Args:
|
||||
result: aggregated dict from dispatcher.run_document_analysis().
|
||||
original_filename: source PDF filename (for naming + display).
|
||||
session_id: session id, used as the filename prefix.
|
||||
output_dir: pre-created client-scoped output directory.
|
||||
output_mode: 'json', 'html', or 'both'.
|
||||
|
||||
Returns:
|
||||
{ 'json': path or None, 'html': path or None }
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
slug = _slugify_filename(original_filename)
|
||||
paths: Dict[str, Optional[str]] = {'json': None, 'html': None}
|
||||
|
||||
if output_mode in ('json', 'both'):
|
||||
json_path = os.path.join(output_dir, f"{session_id}_{slug}_data.json")
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2, default=str)
|
||||
paths['json'] = json_path
|
||||
|
||||
if output_mode in ('html', 'both'):
|
||||
html_path = os.path.join(output_dir, f"{session_id}_{slug}_report.html")
|
||||
html_doc = _render_html(result, original_filename)
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_doc)
|
||||
paths['html'] = html_path
|
||||
|
||||
return paths
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Excel reference-asset processor for HP Source Messaging files.
|
||||
|
||||
Mirrors pdf_processor.py: openpyxl extracts raw cell content from every
|
||||
sheet, Gemini 2.5 Pro summarises the result into structured Markdown
|
||||
under brand_guidelines/files/{file_id}_summary.md. The hp_copy_review
|
||||
check pulls that Markdown into its prompt at QC time.
|
||||
|
||||
Public surface:
|
||||
process_excel_file(file_path, file_id) -> (summary_text, summary_path)
|
||||
|
||||
Behaviour mirrors pdf_processor.summarize_brand_guidelines: on Gemini
|
||||
failure we write a degraded summary containing the raw extraction so
|
||||
the reference asset stays usable downstream. The function does not
|
||||
raise — failures are logged and surfaced via the degraded payload.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Tuple
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
BRAND_GUIDELINES_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'brand_guidelines', 'files'
|
||||
)
|
||||
|
||||
# Cap raw extraction at ~50K chars to keep the summary prompt bounded.
|
||||
# A 30-row, 12-column workbook is ~10-15K chars in practice; this leaves
|
||||
# headroom for HP's larger source files without blowing the prompt budget.
|
||||
_RAW_EXTRACTION_CAP = 50_000
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """You're processing an HP Source Messaging Excel into a structured Markdown reference. Output these sections exactly, in this order:
|
||||
|
||||
## Product / Variant
|
||||
(brand, product line, variant if any — e.g. "HP OmniDesk Mini — Core")
|
||||
|
||||
## Key Selling Points (KSPs)
|
||||
For each KSP: heading, value proposition, supporting body copy, message-length variants (ultra-short / short / medium / long if present in the source).
|
||||
|
||||
## Disclaimers / Footnotes
|
||||
Numbered list, exact wording, what claim each footnote anchors to.
|
||||
|
||||
## Approved Brand and Product Names
|
||||
Exact spellings, including trademark glyphs (™, ®, ©).
|
||||
|
||||
## Variant Notes / Watch-outs
|
||||
Anything explicitly marked variant-specific (e.g. "Mainstream only", "Core only", "must not appear in entry tier").
|
||||
|
||||
## Verboten Phrasing
|
||||
Any explicitly disallowed or deprecated phrasing called out in the source.
|
||||
|
||||
Be exhaustive but concise. Quote exactly where the source is explicit. If a section has no content in this source, write 'None specified' under it — do not omit the section heading."""
|
||||
|
||||
|
||||
def process_excel_file(file_path: str, file_id: str) -> Tuple[str, str]:
|
||||
"""Extract + summarise an HP Source Messaging Excel.
|
||||
|
||||
Args:
|
||||
file_path: Path to the .xlsx file on disk.
|
||||
file_id: Stable identifier used for the output filename.
|
||||
|
||||
Returns:
|
||||
Tuple of (summary_text, summary_path). Summary is written to
|
||||
BRAND_GUIDELINES_DIR/{file_id}_summary.md.
|
||||
|
||||
Never raises. On Gemini failure, writes a degraded summary that
|
||||
embeds the raw extraction so the reference asset stays usable.
|
||||
"""
|
||||
try:
|
||||
raw_text = _extract_workbook_text(file_path)
|
||||
except Exception as e:
|
||||
print(f" Excel extraction failed for {file_id}: {type(e).__name__}: {e}")
|
||||
summary = (
|
||||
f"# {os.path.basename(file_path)} (degraded — extraction failed)\n\n"
|
||||
f"openpyxl extraction failed: {type(e).__name__}: {e}\n"
|
||||
)
|
||||
raw_text = ''
|
||||
else:
|
||||
try:
|
||||
summary = _summarise_with_gemini(raw_text, os.path.basename(file_path))
|
||||
except Exception as e:
|
||||
print(f" Gemini summarisation failed for {file_id}: {type(e).__name__}: {e}")
|
||||
summary = (
|
||||
f"# {os.path.basename(file_path)} (degraded — summary failed)\n\n"
|
||||
f"Gemini summarisation failed: {type(e).__name__}: {e}\n\n"
|
||||
f"## Raw extraction\n\n```\n{raw_text}\n```\n"
|
||||
)
|
||||
|
||||
os.makedirs(BRAND_GUIDELINES_DIR, exist_ok=True)
|
||||
summary_path = os.path.join(BRAND_GUIDELINES_DIR, f"{file_id}_summary.md")
|
||||
with open(summary_path, 'w', encoding='utf-8') as f:
|
||||
f.write(summary)
|
||||
return summary, summary_path
|
||||
|
||||
|
||||
def _extract_workbook_text(file_path: str) -> str:
|
||||
"""Read every sheet, dump as 'Sheet: <name>\\n<tab-aligned rows>\\n\\n'.
|
||||
|
||||
Empty rows are skipped. Output is capped at _RAW_EXTRACTION_CAP chars;
|
||||
when exceeded, a truncation marker is appended and the rest is dropped.
|
||||
"""
|
||||
wb = load_workbook(file_path, data_only=True, read_only=True)
|
||||
try:
|
||||
parts = []
|
||||
total_chars = 0
|
||||
for sheet in wb.worksheets:
|
||||
header = f"Sheet: {sheet.title}\n"
|
||||
parts.append(header)
|
||||
total_chars += len(header)
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
if not any((c is not None and str(c).strip()) for c in row):
|
||||
continue
|
||||
line = '\t'.join(('' if c is None else str(c)) for c in row)
|
||||
parts.append(line + '\n')
|
||||
total_chars += len(line) + 1
|
||||
if total_chars >= _RAW_EXTRACTION_CAP:
|
||||
parts.append(
|
||||
f"\n[truncated — exceeded {_RAW_EXTRACTION_CAP}-char cap]\n"
|
||||
)
|
||||
return ''.join(parts)
|
||||
parts.append('\n')
|
||||
total_chars += 1
|
||||
return ''.join(parts)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _summarise_with_gemini(raw_text: str, source_filename: str) -> str:
|
||||
"""Send the extracted workbook text to Gemini 2.5 Pro for summarisation.
|
||||
|
||||
Mirrors pdf_processor.summarize_brand_guidelines: uses
|
||||
google.generativeai directly with MODEL_VERSIONS.gemini_vision
|
||||
(currently gemini-2.5-pro). Raises on any failure; the caller
|
||||
converts failures into a degraded summary.
|
||||
"""
|
||||
import google.generativeai as genai
|
||||
from llm_config import MODEL_VERSIONS
|
||||
|
||||
api_key = os.getenv("GOOGLE_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError("GOOGLE_API_KEY not configured")
|
||||
|
||||
genai.configure(api_key=api_key)
|
||||
model = genai.GenerativeModel(MODEL_VERSIONS.gemini_vision)
|
||||
|
||||
prompt = (
|
||||
f"{_SYSTEM_PROMPT}\n\n"
|
||||
f"Source filename: {source_filename}\n\n"
|
||||
f"Raw cell content:\n\n```\n{raw_text}\n```"
|
||||
)
|
||||
response = model.generate_content(prompt)
|
||||
|
||||
# Mirror pdf_processor's safety-block handling: surface a useful error.
|
||||
if not getattr(response, 'parts', None):
|
||||
feedback = getattr(response, 'prompt_feedback', 'No specific feedback provided.')
|
||||
raise RuntimeError(
|
||||
f"Gemini response blocked or empty. Feedback: {feedback}"
|
||||
)
|
||||
|
||||
return response.text
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
"""
|
||||
Dow Jones / OLIVER file naming convention validator.
|
||||
|
||||
Implements the playbook spec (slides 38-39):
|
||||
[OMGID] - [Domain]-[Subteam]?-[Brand or Event]-[Initiative or Event]?-[YY]-[Sequence]_[AssetName]_v##
|
||||
|
||||
Examples from the playbook:
|
||||
000000 - PMKT-ACQ-WSJ-BIC-26-01_MetaBanner_v1
|
||||
000001 - PMKT-ENGRT-WSJ-BIC-26-01_Email_v1
|
||||
000002 - EVNT-WSJ-GFF-26-02_Agenda_v3
|
||||
|
||||
This module is called by api_server.process_single_check when check_name == 'dj_file_naming'.
|
||||
It runs deterministically (no LLM call) and returns a result dict shaped like an LLM check
|
||||
result so it slots into the existing scoring + report pipeline.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
DOMAINS = {"PMKT", "BRND", "EVNT", "B2B", "UX"}
|
||||
DOMAINS_WITH_SUBTEAM = {"PMKT", "B2B"}
|
||||
SUBTEAMS = {"ACQ", "CMKT", "ENGRT", "ENT"}
|
||||
|
||||
BRANDS = {
|
||||
"WSJ", "WSJ+", "BAR", "MW", "DF", "DJE", "FAC", "FE",
|
||||
"GRI", "NWS", "OA", "RSK", "RSKC", "DJRJ", "R&C",
|
||||
}
|
||||
|
||||
EVENTS = {
|
||||
"GH", "DJRJS", "WECR", "FEOE", "FOH", "GFF", "JH", "TL",
|
||||
"TLQ", "TLCYB", "FOE", "WSJIL", "BODC", "CCOC", "CEOC",
|
||||
"CFOC", "CMOC", "CPOC", "TECC", "WSJLI",
|
||||
}
|
||||
|
||||
|
||||
def _strip_extension(filename):
|
||||
base, _ = os.path.splitext(filename)
|
||||
return base
|
||||
|
||||
|
||||
def validate_filename(filename):
|
||||
"""
|
||||
Validate a filename against the Dow Jones / OLIVER naming convention.
|
||||
|
||||
Returns a dict containing:
|
||||
score (int 1-10)
|
||||
passed (bool) — score >= 6
|
||||
parts (dict) — extracted structural pieces
|
||||
issues (list[str]) — human-readable problems
|
||||
explanation (str) — summary suitable for the QC report
|
||||
recommendations (list[str])
|
||||
"""
|
||||
base = _strip_extension(filename)
|
||||
|
||||
issues = []
|
||||
parts = {
|
||||
"omg_id": None,
|
||||
"domain": None,
|
||||
"subteam": None,
|
||||
"brand": None,
|
||||
"event_or_initiative": None,
|
||||
"year": None,
|
||||
"sequence": None,
|
||||
"asset_name": None,
|
||||
"version": None,
|
||||
}
|
||||
|
||||
# Top-level shape: "<OMGID> - <TAG_BLOCK>_<AssetName>_v##"
|
||||
head_match = re.match(r"^(\d{4,})\s*-\s*(.+)$", base)
|
||||
if not head_match:
|
||||
issues.append(
|
||||
"Filename must start with the OMG ID followed by ' - ' "
|
||||
"(e.g. '2382033 - PMKT-WSJ-26_AssetName_v1')."
|
||||
)
|
||||
return _build_result(score=1, parts=parts, issues=issues, base=base)
|
||||
|
||||
parts["omg_id"] = head_match.group(1)
|
||||
remainder = head_match.group(2)
|
||||
|
||||
# Split tag block from "_AssetName_v##" suffix.
|
||||
suffix_match = re.search(r"_([^_]+)_v(\d+)$", remainder)
|
||||
if suffix_match:
|
||||
parts["asset_name"] = suffix_match.group(1)
|
||||
parts["version"] = f"v{suffix_match.group(2)}"
|
||||
tag_block = remainder[: suffix_match.start()]
|
||||
else:
|
||||
# No clean version suffix — try a looser match for "_AssetName" only.
|
||||
loose_match = re.search(r"_([^_]+)$", remainder)
|
||||
if loose_match:
|
||||
parts["asset_name"] = loose_match.group(1)
|
||||
tag_block = remainder[: loose_match.start()]
|
||||
issues.append(
|
||||
"Missing version suffix '_v##' (e.g. '_v1', '_v3'). "
|
||||
"Every asset filename should end with a version number."
|
||||
)
|
||||
else:
|
||||
tag_block = remainder
|
||||
issues.append(
|
||||
"Missing the '_AssetName_v##' suffix. "
|
||||
"Asset name and version number must be appended."
|
||||
)
|
||||
|
||||
# Tag block: hyphen-separated pieces.
|
||||
tags = [t for t in tag_block.split("-") if t]
|
||||
if not tags:
|
||||
issues.append(
|
||||
"Tag block is empty. Expected Domain-[Subteam-]Brand-[Event/Initiative-]YY-Sequence."
|
||||
)
|
||||
return _build_result(score=2, parts=parts, issues=issues, base=base)
|
||||
|
||||
# First tag is always the domain.
|
||||
domain = tags.pop(0)
|
||||
parts["domain"] = domain
|
||||
if domain not in DOMAINS:
|
||||
issues.append(
|
||||
f"Unrecognised domain code '{domain}'. "
|
||||
f"Allowed domains: {sorted(DOMAINS)}."
|
||||
)
|
||||
|
||||
# Optional subteam (only for PMKT / B2B).
|
||||
if domain in DOMAINS_WITH_SUBTEAM and tags and tags[0] in SUBTEAMS:
|
||||
parts["subteam"] = tags.pop(0)
|
||||
elif domain in DOMAINS_WITH_SUBTEAM:
|
||||
issues.append(
|
||||
f"Domain '{domain}' requires a subteam ({sorted(SUBTEAMS)}) "
|
||||
"between the domain and the brand."
|
||||
)
|
||||
|
||||
# Brand (required).
|
||||
if not tags:
|
||||
issues.append("Missing brand code (e.g. WSJ, BAR, MW).")
|
||||
else:
|
||||
brand = tags.pop(0)
|
||||
parts["brand"] = brand
|
||||
if brand not in BRANDS:
|
||||
issues.append(
|
||||
f"Unrecognised brand code '{brand}'. "
|
||||
f"Allowed brands: {sorted(BRANDS)}."
|
||||
)
|
||||
|
||||
# Last two tags are conventionally YY-Sequence (2-digit year + sequence).
|
||||
if len(tags) >= 2 and re.fullmatch(r"\d{2}", tags[-2]) and re.fullmatch(r"\d{1,3}", tags[-1]):
|
||||
parts["sequence"] = tags.pop()
|
||||
parts["year"] = tags.pop()
|
||||
else:
|
||||
issues.append(
|
||||
"Missing or malformed 'YY-Sequence' tail (e.g. '26-01' for fiscal year 2026, sequence 01)."
|
||||
)
|
||||
|
||||
# Anything left between brand and YY-Sequence is the event-or-initiative slot.
|
||||
if tags:
|
||||
token = tags.pop(0)
|
||||
parts["event_or_initiative"] = token
|
||||
if domain == "EVNT" and token not in EVENTS:
|
||||
issues.append(
|
||||
f"Domain is 'EVNT' but '{token}' is not a recognised event code. "
|
||||
f"Allowed events: {sorted(EVENTS)}."
|
||||
)
|
||||
|
||||
# Score: start at 10, deduct for each issue, floor at 1.
|
||||
score = max(1, 10 - 2 * len(issues))
|
||||
return _build_result(score=score, parts=parts, issues=issues, base=base)
|
||||
|
||||
|
||||
def _build_result(score, parts, issues, base):
|
||||
passed = score >= 6
|
||||
if passed and not issues:
|
||||
explanation = (
|
||||
f"Filename '{base}' matches the Dow Jones / OLIVER naming convention. "
|
||||
f"Detected: OMG {parts['omg_id']}, "
|
||||
f"{parts['domain']}"
|
||||
f"{'-' + parts['subteam'] if parts['subteam'] else ''}"
|
||||
f"{'-' + parts['brand'] if parts['brand'] else ''}"
|
||||
f"{'-' + parts['event_or_initiative'] if parts['event_or_initiative'] else ''}"
|
||||
f"{'-' + parts['year'] if parts['year'] else ''}"
|
||||
f"{'-' + parts['sequence'] if parts['sequence'] else ''}"
|
||||
f", asset '{parts['asset_name']}', version {parts['version']}."
|
||||
)
|
||||
else:
|
||||
joined = " ".join(issues) if issues else "No structural issues found."
|
||||
explanation = (
|
||||
f"Filename '{base}' was checked against the Dow Jones / OLIVER naming convention. "
|
||||
f"{joined}"
|
||||
)
|
||||
|
||||
recommendations = []
|
||||
if issues:
|
||||
recommendations.append(
|
||||
"Update the filename to match: "
|
||||
"'<OMGID> - <Domain>-<Subteam?>-<Brand>-<Event/Initiative?>-<YY>-<Sequence>_<AssetName>_v##'."
|
||||
)
|
||||
recommendations.append(
|
||||
"Naming helper: https://ai-sandbox.oliver.solutions/wsj-filenaming/index.php"
|
||||
)
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"passed": passed,
|
||||
"parts": parts,
|
||||
"issues": issues,
|
||||
"explanation": explanation,
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
|
@ -99,13 +99,6 @@ class QCCheckConfig:
|
|||
weight: float = 0.0
|
||||
llm: str = "Gemini" # Default to Gemini
|
||||
enabled: bool = True
|
||||
# Document-mode only: scope determines how the dispatcher runs the check.
|
||||
# One of: "document" (run once on the whole PDF), "targeted" (specific
|
||||
# pages — see scope_args.pages), "page_sample" (N evenly-spaced pages),
|
||||
# "page_pair" (Phase 3 old-vs-new diff), "page_each" (every page — costly).
|
||||
# Ignored in asset mode. None falls back to "page_each" for backwards compat.
|
||||
scope: Optional[str] = None
|
||||
scope_args: Optional[Dict[str, Any]] = None
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
|
|
@ -114,13 +107,6 @@ class Profile:
|
|||
description: str
|
||||
checks: Dict[str, QCCheckConfig] = field(default_factory=dict)
|
||||
pre_analysis_instructions: Optional[str] = None
|
||||
mode: str = "asset" # "asset" (default, single image/video) or "document" (multi-page PDF)
|
||||
# Strict-grade override: when True, ANY check scoring <6 forces an
|
||||
# overall Fail. In document mode this only applies to artwork-classified
|
||||
# pages (cover/checklist/palette/notes pages are exempt). Used by Boots
|
||||
# Production Pack profile to mirror the asset-mode strict-grade rule
|
||||
# already used by L'Oreal Static and Boots Static.
|
||||
strict_grade: bool = False
|
||||
|
||||
def get_enabled_checks(self) -> List[str]:
|
||||
"""Get list of enabled check names"""
|
||||
|
|
@ -161,16 +147,12 @@ def load_profiles():
|
|||
profile_description = profile_data.get('description', '')
|
||||
profile_checks = profile_data.get('checks', {})
|
||||
pre_analysis_instructions = profile_data.get('pre_analysis_instructions', None)
|
||||
profile_mode = profile_data.get('mode', 'asset')
|
||||
profile_strict_grade = profile_data.get('strict_grade', False)
|
||||
|
||||
|
||||
# Create a new Profile instance
|
||||
profile = Profile(
|
||||
name=profile_name,
|
||||
description=profile_description,
|
||||
pre_analysis_instructions=pre_analysis_instructions,
|
||||
mode=profile_mode,
|
||||
strict_grade=profile_strict_grade,
|
||||
pre_analysis_instructions=pre_analysis_instructions
|
||||
)
|
||||
|
||||
# Add each check configuration
|
||||
|
|
@ -178,9 +160,7 @@ def load_profiles():
|
|||
profile.checks[check_name] = QCCheckConfig(
|
||||
weight=check_config.get('weight', 0.0),
|
||||
llm=check_config.get('llm', 'Gemini'),
|
||||
enabled=check_config.get('enabled', True),
|
||||
scope=check_config.get('scope'),
|
||||
scope_args=check_config.get('scope_args'),
|
||||
enabled=check_config.get('enabled', True)
|
||||
)
|
||||
|
||||
# Add profile to the PROFILES dictionary
|
||||
|
|
@ -220,26 +200,14 @@ def save_profile(profile_id: str, profile: Profile):
|
|||
# Add pre_analysis_instructions if it exists
|
||||
if profile.pre_analysis_instructions:
|
||||
profile_data['pre_analysis_instructions'] = profile.pre_analysis_instructions
|
||||
|
||||
# Persist mode only when it diverges from the default to keep existing JSONs untouched
|
||||
if profile.mode and profile.mode != 'asset':
|
||||
profile_data['mode'] = profile.mode
|
||||
if profile.strict_grade:
|
||||
profile_data['strict_grade'] = True
|
||||
|
||||
# Add each check configuration
|
||||
for check_name, check_config in profile.checks.items():
|
||||
check_data = {
|
||||
profile_data['checks'][check_name] = {
|
||||
'weight': check_config.weight,
|
||||
'llm': check_config.llm,
|
||||
'enabled': check_config.enabled
|
||||
}
|
||||
# Persist scope only when set, to keep existing single-asset profiles untouched
|
||||
if check_config.scope:
|
||||
check_data['scope'] = check_config.scope
|
||||
if check_config.scope_args:
|
||||
check_data['scope_args'] = check_config.scope_args
|
||||
profile_data['checks'][check_name] = check_data
|
||||
|
||||
# Save to a JSON file
|
||||
profile_file = os.path.join(PROFILES_DIR, f"{profile_id.lower()}.json")
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"name": "AXA Accessibility",
|
||||
"description": "Standalone PDF/UA-1 accessibility validation for AXA Ireland documents. Runs the axa_pdf_accessibility check only — veraPDF (PDF/UA-1 / Matterhorn Protocol) when the binary is installed, deterministic PyMuPDF criteria as fallback. Use this profile when the QC objective is purely accessibility compliance against axes4 PAC, without the policy-document content checks.",
|
||||
"mode": "document",
|
||||
"checks": {
|
||||
"axa_pdf_accessibility": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "document"
|
||||
}
|
||||
},
|
||||
"strict_grade": true,
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["axa"]
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
{
|
||||
"name": "AXA Policy Document",
|
||||
"description": "Multi-page PDF QC for AXA Ireland policy documents. 7 deterministic checks: font inventory, phone inventory, bold-words enforcement, page numbering, print code, OMG versioning, print preflight. Runs in seconds with $0 LLM cost. Becomes compliance-driven once AXA supplies approved font list, bold-words dictionary, and approved phone numbers. Accessibility validation lives in the dedicated 'AXA Accessibility' profile.",
|
||||
"mode": "document",
|
||||
"checks": {
|
||||
"axa_font_inventory": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "document"
|
||||
},
|
||||
"axa_phone_inventory": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "document"
|
||||
},
|
||||
"axa_bold_words_definitions": {
|
||||
"weight": 2.0,
|
||||
"enabled": true,
|
||||
"scope": "document"
|
||||
},
|
||||
"axa_page_numbering": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "document"
|
||||
},
|
||||
"axa_print_preflight": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "document"
|
||||
},
|
||||
"axa_print_code": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "targeted",
|
||||
"scope_args": {"pages": "last"}
|
||||
},
|
||||
"axa_omg_versioning": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "targeted",
|
||||
"scope_args": {"pages": "last"}
|
||||
}
|
||||
},
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["axa"]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"name": "AXA Policy Document — Old vs New Diff",
|
||||
"description": "Old-vs-new policy PDF diff for AXA Ireland. Aligns pages between two versions, then uses vision LLM to flag added/removed/moved content per page-pair. Catches the Example-2-class defects: missing bold definitions, added paragraphs, removed sections, moved blue boxes, missing pages.",
|
||||
"mode": "document_diff",
|
||||
"checks": {
|
||||
"axa_pdf_diff": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "page_pair"
|
||||
}
|
||||
},
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["axa"]
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
{
|
||||
"name": "Boots Production Pack",
|
||||
"description": "Multi-page Boots production-pack QC. Runs the full Boots compliance suite (caveats, brand names, offer mechanics, T&Cs, currency/locale) plus brand-identity checks (logo, colour palette) on every page. Cover, asset checklist, colour-palette and yellow-notes pages are auto-detected and exempted from the strict-grade override; only artwork pages count towards Pass/Fail.",
|
||||
"mode": "document",
|
||||
"strict_grade": true,
|
||||
"checks": {
|
||||
"boots_caveat_compliance": {
|
||||
"weight": 1.5,
|
||||
"enabled": true,
|
||||
"scope": "page_each"
|
||||
},
|
||||
"boots_brand_name_accuracy": {
|
||||
"weight": 1.5,
|
||||
"enabled": true,
|
||||
"scope": "page_each"
|
||||
},
|
||||
"boots_offer_mechanics": {
|
||||
"weight": 1.5,
|
||||
"enabled": true,
|
||||
"scope": "page_each"
|
||||
},
|
||||
"boots_tandc_wording": {
|
||||
"weight": 1.5,
|
||||
"enabled": true,
|
||||
"scope": "page_each"
|
||||
},
|
||||
"boots_currency_locale": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
"scope": "page_each"
|
||||
},
|
||||
"boots_logo_compliance": {
|
||||
"weight": 1.5,
|
||||
"enabled": true,
|
||||
"scope": "page_each"
|
||||
},
|
||||
"boots_colour_palette": {
|
||||
"weight": 1.5,
|
||||
"enabled": true,
|
||||
"scope": "page_each"
|
||||
}
|
||||
},
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["boots"]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"name": "HP Copy Review",
|
||||
"description": "Marketing copy graded against canonical HP Source Messaging",
|
||||
"mode": "asset",
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["hp"],
|
||||
"checks": {
|
||||
"hp_copy_review": {
|
||||
"weight": 10.0,
|
||||
"llm": "Gemini",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Box JWT service-account admin CLI.
|
||||
|
||||
One-off script for setting up + inspecting the Box V2 webhooks that drive the
|
||||
QC pipeline. Run on the dev/prod server once the Box admin has invited the
|
||||
service account to each client folder, with the JWT config JSON in place at
|
||||
backend/config/box_jwt_config.json (or BOX_JWT_CONFIG_PATH).
|
||||
|
||||
Subcommands:
|
||||
list-webhooks
|
||||
Show every webhook the service account can see.
|
||||
list-folder <folder_id>
|
||||
List the items in a Box folder. Sanity-check the service account
|
||||
can actually read the folder (otherwise it isn't a collaborator yet).
|
||||
list-clients
|
||||
Print which clients in client_config.py have a box_folder_id set.
|
||||
create-webhook <folder_id> <address>
|
||||
Register a FILE.UPLOADED V2 webhook on a folder. Address is the public
|
||||
URL of /api/box/webhook (e.g. https://optical-dev.oliver.solutions/ai_qc/api/box/webhook).
|
||||
delete-webhook <webhook_id>
|
||||
Remove a single webhook by id.
|
||||
register-all-clients <address>
|
||||
For every client with a box_folder_id, ensure a FILE.UPLOADED webhook
|
||||
pointing at <address> exists on that folder. Idempotent — already-present
|
||||
webhooks are left alone.
|
||||
|
||||
After registering, set the signing keys (Box Developer Console → Custom App →
|
||||
Webhooks Settings) in the env file as BOX_WEBHOOK_PRIMARY_KEY and
|
||||
BOX_WEBHOOK_SECONDARY_KEY, then restart ai-qc.service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
BACKEND_DIR = os.path.dirname(THIS_DIR)
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
import box_jwt_client
|
||||
from client_config import get_clients_with_box_folder
|
||||
|
||||
|
||||
WEBHOOK_TRIGGERS_DEFAULT = ['FILE.UPLOADED']
|
||||
|
||||
|
||||
def cmd_list_webhooks(_args):
|
||||
webhooks = box_jwt_client.list_webhooks()
|
||||
if not webhooks:
|
||||
print('No webhooks visible to this service account.')
|
||||
return 0
|
||||
for wh in webhooks:
|
||||
target = wh.get('target') or {}
|
||||
print(
|
||||
f" id={wh.get('id')} target={target.get('type')}/{target.get('id')}"
|
||||
f" address={wh.get('address')} triggers={wh.get('triggers')}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list_folder(args):
|
||||
items = box_jwt_client.list_folder_items(args.folder_id, fields=['id', 'name', 'type', 'size'])
|
||||
print(f'Folder {args.folder_id} contains {len(items)} items:')
|
||||
for it in items:
|
||||
size = f" ({it.get('size')} bytes)" if it.get('size') is not None else ''
|
||||
print(f" {it.get('type')}/{it.get('id')} {it.get('name')}{size}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list_clients(_args):
|
||||
configured = get_clients_with_box_folder()
|
||||
if not configured:
|
||||
print('No clients have box_folder_id set in client_config.py.')
|
||||
return 0
|
||||
for cid, cfg in configured:
|
||||
print(
|
||||
f" {cid}: source_folder={cfg.get('box_folder_id')} "
|
||||
f"reports_folder={cfg.get('box_reports_folder_id') or '(falls back to source)'} "
|
||||
f"default_profile={cfg.get('default_profile') or '(first profile in list)'}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create_webhook(args):
|
||||
wh = box_jwt_client.create_webhook(
|
||||
target_type='folder',
|
||||
target_id=args.folder_id,
|
||||
address=args.address,
|
||||
triggers=WEBHOOK_TRIGGERS_DEFAULT,
|
||||
)
|
||||
print('Created webhook:')
|
||||
print(json.dumps(wh, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delete_webhook(args):
|
||||
box_jwt_client.delete_webhook(args.webhook_id)
|
||||
print(f'Deleted webhook {args.webhook_id}.')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_register_all_clients(args):
|
||||
"""Idempotent: skip folders that already have a webhook pointing at this address."""
|
||||
existing = box_jwt_client.list_webhooks()
|
||||
existing_by_folder = {}
|
||||
for wh in existing:
|
||||
target = wh.get('target') or {}
|
||||
if target.get('type') == 'folder':
|
||||
existing_by_folder.setdefault(str(target.get('id')), []).append(wh)
|
||||
|
||||
configured = get_clients_with_box_folder()
|
||||
if not configured:
|
||||
print('No clients have box_folder_id set. Nothing to register.')
|
||||
return 0
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
for cid, cfg in configured:
|
||||
folder_id = str(cfg['box_folder_id'])
|
||||
already = existing_by_folder.get(folder_id, [])
|
||||
matching = [w for w in already if w.get('address') == args.address]
|
||||
if matching:
|
||||
print(f" {cid} ({folder_id}): SKIP — webhook already exists (id={matching[0].get('id')})")
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
wh = box_jwt_client.create_webhook(
|
||||
target_type='folder',
|
||||
target_id=folder_id,
|
||||
address=args.address,
|
||||
triggers=WEBHOOK_TRIGGERS_DEFAULT,
|
||||
)
|
||||
print(f" {cid} ({folder_id}): CREATED webhook id={wh.get('id')}")
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
print(f" {cid} ({folder_id}): ERROR — {exc}")
|
||||
errors += 1
|
||||
|
||||
print()
|
||||
print(f'Summary: {created} created, {skipped} already present, {errors} errored.')
|
||||
if errors:
|
||||
print('Common causes for errors: service account not invited as collaborator on the folder yet,')
|
||||
print('or the folder_id in client_config.py is wrong.')
|
||||
return 0 if errors == 0 else 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Box JWT admin CLI')
|
||||
sub = parser.add_subparsers(dest='cmd', required=True)
|
||||
|
||||
sub.add_parser('list-webhooks').set_defaults(func=cmd_list_webhooks)
|
||||
|
||||
p_lf = sub.add_parser('list-folder', help='List items in a Box folder')
|
||||
p_lf.add_argument('folder_id')
|
||||
p_lf.set_defaults(func=cmd_list_folder)
|
||||
|
||||
sub.add_parser('list-clients').set_defaults(func=cmd_list_clients)
|
||||
|
||||
p_cw = sub.add_parser('create-webhook', help='Create a single FILE.UPLOADED webhook')
|
||||
p_cw.add_argument('folder_id')
|
||||
p_cw.add_argument('address', help='Public URL of /api/box/webhook')
|
||||
p_cw.set_defaults(func=cmd_create_webhook)
|
||||
|
||||
p_dw = sub.add_parser('delete-webhook', help='Delete a webhook by id')
|
||||
p_dw.add_argument('webhook_id')
|
||||
p_dw.set_defaults(func=cmd_delete_webhook)
|
||||
|
||||
p_ra = sub.add_parser('register-all-clients', help='Create FILE.UPLOADED webhooks for every client with a box_folder_id')
|
||||
p_ra.add_argument('address', help='Public URL of /api/box/webhook')
|
||||
p_ra.set_defaults(func=cmd_register_all_clients)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not box_jwt_client.is_configured():
|
||||
print(f'ERROR: Box JWT config not found. Drop the JSON from Box at '
|
||||
f'{os.path.join(BACKEND_DIR, "config", "box_jwt_config.json")} or set BOX_JWT_CONFIG_PATH.',
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
return args.func(args) or 0
|
||||
except box_jwt_client.BoxJWTError as exc:
|
||||
print(f'Box API error: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -96,13 +96,8 @@ fi
|
|||
echo "Target: $TARGET_SHORT $(git log -1 --format='%s' "$TARGET_REF")"
|
||||
echo ""
|
||||
echo "Commits to apply:"
|
||||
# Use git's own line limit (`-n 20`) rather than `| head -20`: piping to head
|
||||
# closes the pipe after 20 lines and makes git log exit with SIGPIPE (141),
|
||||
# which `set -o pipefail` propagates and `set -e` then uses to kill the
|
||||
# script silently. Only bites when the deploy batch is >20 commits — i.e.
|
||||
# real prod releases. First hit observed on the v1.3.0 prod deploy.
|
||||
git log --oneline -n 20 "$CURRENT_REV..$TARGET_REV"
|
||||
CHANGE_COUNT=$(git rev-list --count "$CURRENT_REV..$TARGET_REV")
|
||||
git log --oneline "$CURRENT_REV..$TARGET_REV" | head -20
|
||||
CHANGE_COUNT=$(git log --oneline "$CURRENT_REV..$TARGET_REV" | wc -l | tr -d ' ')
|
||||
if [[ $CHANGE_COUNT -gt 20 ]]; then
|
||||
echo " ... and $((CHANGE_COUNT - 20)) more"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,248 +0,0 @@
|
|||
"""
|
||||
Machine-side technical pre-flight inspection for uploaded assets.
|
||||
|
||||
Runs before any LLM analysis. Extracts dimensions, format, page count,
|
||||
duration, codec, etc. via PIL/PyMuPDF/ffprobe. Also opportunistically
|
||||
parses dimension hints from the filename and compares them to the actual
|
||||
file. Returns a JSON-serializable dict. Never raises — errors land in
|
||||
`errors` so the caller can still surface partial results.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image
|
||||
import fitz # PyMuPDF
|
||||
|
||||
|
||||
_DIMS_RE = re.compile(r'(\d{2,5})\s*[xX×]\s*(\d{2,5})')
|
||||
|
||||
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp'}
|
||||
PDF_EXTENSIONS = {'.pdf'}
|
||||
VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm'}
|
||||
|
||||
MIME_BY_EXT: Dict[str, str] = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.gif': 'image/gif', '.bmp': 'image/bmp', '.tiff': 'image/tiff',
|
||||
'.tif': 'image/tiff', '.webp': 'image/webp', '.pdf': 'application/pdf',
|
||||
'.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
|
||||
'.mkv': 'video/x-matroska', '.wmv': 'video/x-ms-wmv', '.flv': 'video/x-flv',
|
||||
'.webm': 'video/webm',
|
||||
}
|
||||
|
||||
|
||||
def parse_filename_specs(filename: str) -> Dict[str, Any]:
|
||||
"""Extract dimension hints from a filename — pattern like '1920x1080'.
|
||||
|
||||
Returns {} when nothing parseable is found.
|
||||
"""
|
||||
hints: Dict[str, Any] = {}
|
||||
base = os.path.splitext(os.path.basename(filename))[0]
|
||||
match = _DIMS_RE.search(base)
|
||||
if match:
|
||||
w, h = int(match.group(1)), int(match.group(2))
|
||||
if 50 <= w <= 50000 and 50 <= h <= 50000:
|
||||
hints['width'] = w
|
||||
hints['height'] = h
|
||||
return hints
|
||||
|
||||
|
||||
def compare_filename_to_actual(
|
||||
hints: Dict[str, Any], actual: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Compare filename-extracted dimensions against actual file dimensions."""
|
||||
if not hints or 'width' not in hints or 'height' not in hints:
|
||||
return None
|
||||
actual_dims = actual.get('dimensions')
|
||||
if not actual_dims:
|
||||
return None
|
||||
fw, fh = hints['width'], hints['height']
|
||||
aw, ah = actual_dims['width'], actual_dims['height']
|
||||
match = (fw == aw and fh == ah)
|
||||
return {
|
||||
'checked': True,
|
||||
'match': match,
|
||||
'filename_says': f'{fw}x{fh}',
|
||||
'actual_is': f'{aw}x{ah}',
|
||||
'detail': (
|
||||
f'Filename suggests {fw}x{fh}; file is {aw}x{ah}'
|
||||
+ (' — match' if match else ' — MISMATCH')
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _inspect_image(file_path: str) -> Dict[str, Any]:
|
||||
report: Dict[str, Any] = {'kind': 'image'}
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
report['dimensions'] = {'width': img.width, 'height': img.height}
|
||||
report['format'] = img.format
|
||||
report['mode'] = img.mode
|
||||
report['has_alpha'] = img.mode in ('RGBA', 'LA') or 'transparency' in img.info
|
||||
dpi = img.info.get('dpi')
|
||||
if dpi:
|
||||
report['dpi'] = [int(round(dpi[0])), int(round(dpi[1]))]
|
||||
except Exception as exc:
|
||||
report.setdefault('errors', []).append(f'image inspection failed: {exc}')
|
||||
return report
|
||||
|
||||
|
||||
def _inspect_pdf(file_path: str) -> Dict[str, Any]:
|
||||
report: Dict[str, Any] = {'kind': 'pdf'}
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
report['page_count'] = doc.page_count
|
||||
if doc.metadata and doc.metadata.get('format'):
|
||||
report['pdf_version'] = doc.metadata['format'].replace('PDF ', '')
|
||||
page_dims = []
|
||||
fonts = set()
|
||||
has_text = False
|
||||
for page in doc:
|
||||
rect = page.rect
|
||||
page_dims.append({'width': round(rect.width, 1), 'height': round(rect.height, 1)})
|
||||
if not has_text and page.get_text().strip():
|
||||
has_text = True
|
||||
for font_info in page.get_fonts(full=False):
|
||||
if len(font_info) > 3 and font_info[3]:
|
||||
fonts.add(font_info[3])
|
||||
report['page_dimensions'] = page_dims
|
||||
report['embedded_fonts'] = sorted(fonts)
|
||||
report['has_text'] = has_text
|
||||
if page_dims:
|
||||
report['dimensions'] = {
|
||||
'width': int(round(page_dims[0]['width'])),
|
||||
'height': int(round(page_dims[0]['height'])),
|
||||
}
|
||||
doc.close()
|
||||
except Exception as exc:
|
||||
report.setdefault('errors', []).append(f'pdf inspection failed: {exc}')
|
||||
return report
|
||||
|
||||
|
||||
def _inspect_video(file_path: str) -> Dict[str, Any]:
|
||||
report: Dict[str, Any] = {'kind': 'video'}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
'ffprobe', '-v', 'error', '-print_format', 'json',
|
||||
'-show_format', '-show_streams', file_path,
|
||||
],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
report.setdefault('errors', []).append(
|
||||
f'ffprobe error: {result.stderr.strip()[:200]}'
|
||||
)
|
||||
return report
|
||||
data = json.loads(result.stdout)
|
||||
fmt = data.get('format', {})
|
||||
if 'duration' in fmt:
|
||||
report['duration_seconds'] = round(float(fmt['duration']), 2)
|
||||
if 'bit_rate' in fmt:
|
||||
report['bitrate_kbps'] = int(int(fmt['bit_rate']) / 1000)
|
||||
v_streams = [s for s in data.get('streams', []) if s.get('codec_type') == 'video']
|
||||
a_streams = [s for s in data.get('streams', []) if s.get('codec_type') == 'audio']
|
||||
if v_streams:
|
||||
v = v_streams[0]
|
||||
w, h = v.get('width'), v.get('height')
|
||||
if w and h:
|
||||
report['dimensions'] = {'width': w, 'height': h}
|
||||
report['video_codec'] = v.get('codec_name')
|
||||
fps_raw = v.get('avg_frame_rate', '0/0')
|
||||
if '/' in fps_raw:
|
||||
num, den = fps_raw.split('/')
|
||||
try:
|
||||
if int(den) > 0:
|
||||
report['fps'] = round(int(num) / int(den), 2)
|
||||
except ValueError:
|
||||
pass
|
||||
report['audio_codec'] = a_streams[0].get('codec_name') if a_streams else None
|
||||
except FileNotFoundError:
|
||||
report.setdefault('errors', []).append('ffprobe not installed on this server')
|
||||
except subprocess.TimeoutExpired:
|
||||
report.setdefault('errors', []).append('ffprobe timed out after 30s')
|
||||
except Exception as exc:
|
||||
report.setdefault('errors', []).append(f'video inspection failed: {exc}')
|
||||
return report
|
||||
|
||||
|
||||
def inspect(file_path: str) -> Dict[str, Any]:
|
||||
"""Inspect any uploaded asset. Never raises."""
|
||||
report: Dict[str, Any] = {
|
||||
'kind': 'unknown',
|
||||
'mime_type': None,
|
||||
'file_size_bytes': None,
|
||||
'file_size_mb': None,
|
||||
'errors': [],
|
||||
}
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
report['errors'].append(f'file not found: {file_path}')
|
||||
return report
|
||||
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
report['file_size_bytes'] = size_bytes
|
||||
report['file_size_mb'] = round(size_bytes / (1024 * 1024), 3)
|
||||
except OSError as exc:
|
||||
report['errors'].append(f'stat failed: {exc}')
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
report['mime_type'] = MIME_BY_EXT.get(ext)
|
||||
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
report.update(_inspect_image(file_path))
|
||||
elif ext in PDF_EXTENSIONS:
|
||||
report.update(_inspect_pdf(file_path))
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
report.update(_inspect_video(file_path))
|
||||
else:
|
||||
report['errors'].append(f'unsupported extension: {ext}')
|
||||
|
||||
hints = parse_filename_specs(os.path.basename(file_path))
|
||||
if hints:
|
||||
report['filename_hints'] = hints
|
||||
verdict = compare_filename_to_actual(hints, report)
|
||||
if verdict is not None:
|
||||
report['filename_match'] = verdict
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def format_for_llm_prompt(report: Dict[str, Any]) -> str:
|
||||
"""Render the technical report as a short Markdown block for LLM prompts."""
|
||||
lines = ['**Technical metadata (machine-inspected, pre-LLM):**']
|
||||
kind = report.get('kind', 'unknown')
|
||||
lines.append(f'- File kind: {kind}')
|
||||
size_mb = report.get('file_size_mb')
|
||||
if size_mb is not None:
|
||||
lines.append(f'- File size: {size_mb} MB')
|
||||
dims = report.get('dimensions')
|
||||
if dims:
|
||||
lines.append(f"- Dimensions: {dims['width']} × {dims['height']}")
|
||||
dpi = report.get('dpi')
|
||||
if dpi:
|
||||
lines.append(f'- DPI: {dpi[0]} × {dpi[1]}')
|
||||
pc = report.get('page_count')
|
||||
if pc is not None:
|
||||
lines.append(f'- Pages: {pc}')
|
||||
duration = report.get('duration_seconds')
|
||||
if duration is not None:
|
||||
lines.append(f'- Duration: {duration}s')
|
||||
codec = report.get('video_codec')
|
||||
if codec:
|
||||
lines.append(f'- Video codec: {codec}')
|
||||
fonts = report.get('embedded_fonts')
|
||||
if fonts:
|
||||
suffix = ' …' if len(fonts) > 8 else ''
|
||||
lines.append(f"- Embedded fonts: {', '.join(fonts[:8])}{suffix}")
|
||||
fm = report.get('filename_match')
|
||||
if fm:
|
||||
verdict = 'MATCHES filename' if fm['match'] else 'DOES NOT match filename'
|
||||
lines.append(f"- Filename check: {verdict} ({fm['detail']})")
|
||||
if report.get('errors'):
|
||||
lines.append(f"- Inspection notes: {'; '.join(report['errors'])}")
|
||||
return '\n'.join(lines)
|
||||
|
|
@ -18,34 +18,14 @@ class BootsBrandNameAccuracyApp(FlaskAppTemplate):
|
|||
|
||||
SCOPE: Check compliance and technical specs ONLY. Do NOT assess creative quality, aesthetics, or subjective design choices.
|
||||
|
||||
IMPORTANT: Brand names must match the approved lists below in spelling and structure, but several common retail-artwork conventions are EXPLICITLY ACCEPTABLE and must NOT be flagged as errors. See the "ACCEPTABLE RENDERING CONVENTIONS" section below before scoring.
|
||||
|
||||
What you MUST flag (real spelling/structure errors):
|
||||
- Wrong accent marks (é, è, ô, â, ï, etc. — missing or substituted)
|
||||
- Wrong apostrophes or their placement (Burt's, Palmer's, Johnson's)
|
||||
- Wrong hyphens (Bio-Oil, Anti-Hist, La Roche-Posay — substituted with spaces or omitted)
|
||||
- Wrong internal capitalisation in a way that ALTERS LETTER STRUCTURE (e.g. `Babyliss` vs `BaByliss` — the second `B` is intentional and structural; getting it wrong is a real error)
|
||||
- Wrong ampersands vs "and" (Soap & Glory ≠ Soap and Glory)
|
||||
- Wrong dots, colons, special characters (e.l.f., A.Vogel, So...?, L;ft)
|
||||
|
||||
ACCEPTABLE RENDERING CONVENTIONS — DO NOT flag these as errors:
|
||||
|
||||
1. **ALL-CAPS RETAIL CONVENTION**: Brand names commonly appear in ALL CAPS on retail artwork (shelf-edge fins, headlines, packaging mockups) for visual impact. If the brand on the approved list is shown in ALL CAPS on the asset, this is acceptable as long as the LETTER STRUCTURE matches. Examples:
|
||||
- `L'OREAL` is an acceptable rendering of `L'Oreal` (apostrophe still required)
|
||||
- `ESTEE LAUDER` is an acceptable rendering of `Estee Lauder`
|
||||
- `MAYBELLINE` is an acceptable rendering of `Maybelline` (if it were on the list)
|
||||
- `LANCOME` is an acceptable rendering of `Lancome`
|
||||
- `BABYLISS` is **NOT** an acceptable rendering of `BaByliss` — the structural intercap `B` is lost when forced to ALL CAPS, BUT this is unavoidable in caps-only contexts and should be flagged as `manual_review_recommended` rather than a Fail.
|
||||
|
||||
The principle: ALL CAPS is a typographic choice, not a spelling error. Score normally if the ALL CAPS version preserves all letters, accents, hyphens, apostrophes, and special characters. Only flag if those structural elements are missing or wrong.
|
||||
|
||||
2. **STYLIZED BRAND LOGOTYPES**: Some approved brands use a known logomark that differs from their textual spelling. When you see the brand's recognised logotype, this is the brand's official mark and is a Pass — not a misspelling. Examples:
|
||||
- `17` (numeral) is the official logomark for `SEVENTEEN` — accept as Pass.
|
||||
- `&SISTERS` may appear with a stylised or oversized ampersand — accept.
|
||||
- `So...?` may appear with the dots and question mark stylised — accept.
|
||||
- `e.l.f.` may appear with varying dot weights or kerning — accept as long as the dots are present.
|
||||
|
||||
When in doubt about whether something is a stylised logotype vs a misspelling, set `manual_review_recommended=true` and do NOT fail the score.
|
||||
IMPORTANT: Brand names must appear EXACTLY as shown in the approved lists below. Pay close attention to:
|
||||
- Accent marks (é, è, ô, â, ï, etc.)
|
||||
- Apostrophes and their placement (Burt's, Palmer's, Johnson's)
|
||||
- Hyphens (Bio-Oil, Anti-Hist, La Roche-Posay)
|
||||
- Internal capitalisation / casing (BaByliss, SkinActive, MacuShield, SlimFast, DenTek)
|
||||
- Ampersands vs "and" (Soap & Glory, Baylis & Harding)
|
||||
- Dots, colons, special characters (e.l.f., A.Vogel, So...?, L;ft)
|
||||
- ALL CAPS vs mixed case (SEVENTEEN, NARS, LEGO, JUUL, VITHIT)
|
||||
|
||||
APPROVED BRAND NAMES:
|
||||
Accu-Chek, Acnecide + Purifide, AlcoSense, Alka-Seltzer, Anais Anais, Anti-Hist, Avene, A.Vogel, BaByliss, Baylis & Harding, Beaba, BetterYou, BioGaia, Bio-Kult, Bio-Oil, Bio&Me, Biore, BlanX, Boucleme, Burt's Bees, CanesBiotics, CanesMeno, CeraVe, Ciate, Chilly's, Chloe, Coca-Cola, ColdZyme, Cow & Gate, Curel, DenTek, DioraHydrate, Dr Teal's, DulcoEase, e.l.f., Embr Wave, Estee Lauder, EVOLUTION_18, Filter by Molly-Mae, FyboCalm, GAVINatura, Glaceau, Gu, Hermes, Hismile, InStyler, Isoclean, iWhite, Jack N' Jill, Johnson's, JUUL, Kiehl's, King C. Gillette, Kool 'n' Soothe, La Roche-Posay, Lancome, Lil-Lets, LEGO, L'Oreal, MAC Cosmetics, MacuShield, McCoy's, Montblanc, MyOva, Myprotein, Nala's Baby, NARS, Natures Aid, Nature's Bounty, NicAssist, Nice'n Easy, NiQuitin, Nestle, Nuby, NytEase, NYX Professional Makeup, O'Keeffe's, Oral-B, O.R.S, PaediaSure, Palmer's, Parla, Physicians Formula, Pre-Seed, ProVen, Purdey's, Ray-Ban, Reese's, SanDisk, SEVENTEEN, Shot2go, Sleek MakeUP, SlimFast, Soap & Glory, Solpa-Extra, So...?, Sterimar, St. Moriz, St.Tropez, TePe, The INKEY List, TheraTears, Tiffany & Co., Toni & Guy, Tony's Chocolonely, Tresemme, ViDrate, VITHIT, VitSpritz, VO5, WaterWipes, WetBrush, Wet n Wild, WoodWick, Woody's, WooWoo, Wrigley's, XLS-Medical, YourGoodSkin, Zarbee's, &SISTERS, 7UP
|
||||
|
|
@ -77,54 +57,38 @@ ActivScan (Braun), Acqua Di Gio (Armani), AquaRelease (No7), BabyRub (Vicks), Ba
|
|||
NOTE ON PRODUCT NAME ACCENTS: Many product names also require accent marks on actual artwork:
|
||||
- Acqua Di Gio → Acqua Di Giò, Extreme → Extrême, Feria → Féria, Gelee → Gelée, Genifique → Génifique, Hypnose → Hypnôse, Ideal → Idéal, Idealia → Idéalia, Idole → Idôle, Intensement → Intensément, Irresistible → Irrésistible, Legere → Légère, Lumiere → Lumière, Memoire → Mémoire, Mineral → Minéral, Olympea → Olympéa, Plenitude → Plénitude, Preference → Préférence, Prive → Privé, Purete → Pureté, Recital → Récital, Renergie → Rénergie, Romer → Römer, Si → Sì, Silk-epil → Silk-épil, Slow Age → Slow Âge, Sucre → Sucré, Tolerance → Tolérance, Touche Eclat → Touche Éclat, Tresor → Trésor
|
||||
|
||||
IMPORTANT: HOW TO HANDLE BRANDS NOT ON THE APPROVED LIST
|
||||
The approved lists above are CLOSED-WORLD reference lists provided by the Boots brand team. They are known to be incomplete — third-party brands that are legitimately stocked by Boots (for example: Remington, Imodium, Nicorette, Maybelline, Grenade, Shaken Udder, Napralief) may legitimately appear in artwork without being on the lists. **An absence from the list is NOT proof of error.**
|
||||
|
||||
Therefore:
|
||||
- A brand name that IS on the approved list and is spelled CORRECTLY → Pass (high score).
|
||||
- A brand name that IS on the approved list but is spelled INCORRECTLY (wrong casing, missing accent, wrong hyphen, etc.) → Fail (low score). This is the failure mode this check exists to catch.
|
||||
- A brand name that is NOT on the approved list → treat as **not applicable** (N/A neutral). Surface it in `names_not_on_list` and set `manual_review_recommended=true` so a human can confirm whether it's a legitimate Boots-stocked brand or a genuine compliance issue. Do NOT fail the check on this basis alone.
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Read all text visible in the asset and identify any brand names or product names.
|
||||
2. For each name found, compare against the approved lists above (case-insensitively to identify the brand, then check structural elements).
|
||||
3. For names that ARE on the approved list, verify ONLY the structural elements that matter: accent marks, hyphens, apostrophes, ampersands, special characters, and intercap structure (where preserved). Do NOT flag pure casing differences (ALL CAPS vs title case) — those are acceptable retail conventions per the section above.
|
||||
4. For known brand logotypes (e.g. `17` for SEVENTEEN), accept as Pass — these are the brand's official marks.
|
||||
5. For names that are NOT on the approved list, note them in `names_not_on_list` for manual review — do not flag as misspelled.
|
||||
6. If no brand or product names are visible at all, score 7/10 as neutral (not applicable).
|
||||
2. For each name found, compare against the approved lists above.
|
||||
3. Check spelling, casing, hyphens, apostrophes, ampersands, and special characters.
|
||||
4. Where possible, check for correct accent marks (note: this may be difficult to verify visually for subtle marks).
|
||||
5. Flag any name that does not match the approved spelling.
|
||||
6. If no brand or product names are visible, score 7/10 as neutral (not applicable).
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- Score 9-10: Every approved-list brand found has correct structural elements (accents, hyphens, apostrophes, special characters). Casing differences in retail context are acceptable.
|
||||
- Score 7-8: All listed names structurally correct; one or more names are off-list (manual review needed) OR a brand logotype is used (e.g. `17` for SEVENTEEN) but no errors against the approved list.
|
||||
- Score 7: No brand or product names visible OR all visible names are off-list (not applicable — neutral score, manual review recommended).
|
||||
- Score 5-6: One clear structural error against an approved-list brand (missing accent, missing hyphen, wrong apostrophe placement).
|
||||
- Score 3-4: Multiple structural errors against approved-list brands.
|
||||
- Score 1-2: Major structural errors throughout the asset (fundamentally wrong spellings — letters substituted, accents missing across multiple brands).
|
||||
|
||||
DO NOT lower the score because:
|
||||
- A brand is missing from the approved list (use names_not_on_list instead)
|
||||
- A brand is rendered in ALL CAPS when the list shows title case (acceptable retail convention)
|
||||
- A brand uses a known stylised logotype like `17` for SEVENTEEN (these are Pass)
|
||||
- Score 9-10: All visible brand/product names match approved spellings exactly.
|
||||
- Score 7-8: Names mostly correct, one minor issue (e.g., a subtle accent mark that's hard to verify visually).
|
||||
- Score 7: No brand or product names visible in the asset (not applicable — neutral score).
|
||||
- Score 5-6: One clear spelling error (wrong casing, missing hyphen, wrong apostrophe placement).
|
||||
- Score 3-4: Multiple spelling errors or a critical brand name misspelled.
|
||||
- Score 1-2: Major brand name errors throughout the asset (fundamentally wrong spellings).
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON:
|
||||
{
|
||||
"brand_name_check": "Pass" or "Fail" or "N/A",
|
||||
"names_found": ["list of brand/product names identified in the asset"],
|
||||
"names_verified_correct": ["list of names that ARE on the approved list AND match approved spelling"],
|
||||
"names_verified_correct": ["list of names that match approved spelling"],
|
||||
"names_with_issues": [
|
||||
{
|
||||
"found": "name as it appears in asset",
|
||||
"expected": "correct approved spelling",
|
||||
"issue": "description of the discrepancy — only STRUCTURAL errors (missing/wrong accents, hyphens, apostrophes, special characters). DO NOT include casing-only differences (ALL CAPS vs title case) or known brand logotypes like '17' for SEVENTEEN."
|
||||
"issue": "description of the discrepancy"
|
||||
}
|
||||
],
|
||||
"names_not_on_list": ["list of brand/product names found on the asset that are NOT on the approved lists — for manual review, NOT counted as failures"],
|
||||
"logotype_observations": ["list of acceptable stylised logotypes observed (e.g. '17' rendered as SEVENTEEN's logomark). For transparency in the report; not counted against the score."],
|
||||
"manual_review_recommended": true or false,
|
||||
"accent_marks_verifiable": true or false,
|
||||
"issues_found": ["list of specific spelling issues against approved-list brands, empty array if none"],
|
||||
"explanation": "Detailed description of brand name accuracy assessment, distinguishing approved-list mismatches from off-list names that need human verification.",
|
||||
"issues_found": ["list of specific issues, empty array if none"],
|
||||
"explanation": "Detailed description of brand name accuracy assessment",
|
||||
"recommendations": ["List specific recommendations if applicable, else an empty array"]
|
||||
}"""
|
||||
|
||||
|
|
|
|||
|
|
@ -38,29 +38,12 @@ If the asset uses multiple different caveat symbols, they MUST follow this order
|
|||
3. CRITICAL RULE — NO SUPERSCRIPT:
|
||||
Caveats must NEVER be rendered as superscript. They should sit on the baseline or be vertically centred within the text, but must NOT be raised/shrunk as superscript formatting. This is a critical compliance failure if violated.
|
||||
|
||||
IMPORTANT VISION-LLM LIMITATION ON SUPERSCRIPT DETECTION:
|
||||
The asterisk (`*`) glyph naturally sits HIGH in its line by typographic design — most fonts position the asterisk near the cap height or x-height level rather than on the baseline. Daggers (†, ‡) similarly sit high. Do NOT confuse the natural high glyph position with actual superscript formatting.
|
||||
|
||||
Real superscript has TWO distinguishing features that you must BOTH observe before flagging:
|
||||
(a) The mark is visibly RAISED above where the same character would naturally sit relative to the surrounding text (i.e. the mark's BOTTOM is well above the cap height of the adjacent letters), AND
|
||||
(b) The mark is visibly SMALLER than the surrounding text size — typically around 50-60% of the body text size, not just slightly smaller.
|
||||
|
||||
If you only observe one of these two features, OR if the rendering is ambiguous at the small text sizes typical of T&C copy, treat it as `needs_manual_check` and populate the `superscript_caveat` field — do NOT auto-flag as superscript. Do NOT score the asset 1-2/10 on a single ambiguous superscript observation.
|
||||
|
||||
When you genuinely see both (a) and (b) — and especially when the asterisk or dagger is the size of typical web superscript like 5pt against 10pt body text — flag it as a real critical violation.
|
||||
|
||||
4. SIZE CONSISTENCY:
|
||||
The caveat mark in the main copy must NOT be smaller than its matching reference in the T&Cs section. If the T&Cs caveat is a certain size, the one in the headline/body that references it must be at least that size or larger (following the sizing rules above).
|
||||
|
||||
IMPORTANT VISION-LLM LIMITATION ON SIZE COMPARISON:
|
||||
Comparing the size of a single asterisk in a roundel against the size of an asterisk in T&C copy on a rendered page image is approximate. A 1-2 point size difference is BELOW the reliable detection threshold of vision-LLM analysis. Only flag size violations when the size difference is OBVIOUS and PRONOUNCED (e.g. caveat in the roundel is roughly half the size or smaller than the T&Cs caveat, or vice versa). For borderline cases where the sizes look "similar but not identical", set `sizing_compliant: "needs_manual_check"` and populate `sizing_caveat` — do NOT score the asset as a sizing violation on a borderline observation.
|
||||
|
||||
5. FONT WEIGHT MATCHING:
|
||||
The caveat symbol and its matching T&Cs reference must use the same font weight. If the T&Cs use a Light weight, the caveat in the headline must also use Light weight (not Bold or Regular).
|
||||
|
||||
IMPORTANT VISION-LLM LIMITATION ON WEIGHT MATCHING (mirrors the T&C wording check):
|
||||
Vision-LLM analysis cannot reliably distinguish Boots Sharp Light from Boots Sharp Regular at the small point sizes typical of caveats and T&C copy. The visual difference between Light and Regular at 5-7pt is below the reliable detection threshold. Only flag weight mismatches when the difference is OBVIOUS (e.g. caveat is clearly Bold against a clearly Light T&C reference). For Light vs Regular distinctions or any borderline weight comparison, set `weight_matching: "needs_manual_check"` and populate `weight_caveat` — do NOT score the asset as a weight violation on a borderline observation.
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Identify all caveat/reference marks visible in the asset (*, †, **, ††, ▲, ✤, or any other marks used as caveats).
|
||||
2. If multiple different caveat types are used, verify they follow the mandatory ordering sequence.
|
||||
|
|
@ -72,13 +55,11 @@ STEPS TO EVALUATE:
|
|||
|
||||
SCORING GUIDANCE:
|
||||
- Score 9-10: All caveats follow correct order, sizing rules met per context, no superscript, weights match.
|
||||
- Score 7-8: Caveats mostly correct AND/OR ambiguous observations flagged for manual check. Set `*_detected: "needs_manual_check"` for any borderline superscript / sizing / weight observation. This is the correct band when the LLM is uncertain — do NOT default to a lower score.
|
||||
- Score 7-8: Caveats mostly correct, minor sizing inconsistency that doesn't affect compliance.
|
||||
- Score 7: No caveats present in the asset (not applicable — neutral score).
|
||||
- Score 5-6: One CLEAR, OBVIOUS sizing or ordering issue (e.g. roundel asterisk visibly half the size of T&C asterisk), OR an orphan caveat (asterisk in T&Cs with no matching reference in the main copy).
|
||||
- Score 3-4: Multiple CLEAR, OBVIOUS sizing/ordering/weight violations — reserve for cases where the violations are pronounced and confidently observable, not borderline.
|
||||
- Score 1-2: Caveats UNAMBIGUOUSLY rendered as superscript (both raised AND shrunk per the strict criteria above — not just visually high), or completely wrong ordering across the asset. Reserve this band for clear-cut critical violations only.
|
||||
|
||||
SCORING PRINCIPLE — when in doubt, prefer 7-8 with manual_check flags over a lower confident-violation score. The Boots team needs accurate signal, not noise. A borderline observation surfaced for manual review is more valuable than a confident-but-wrong Fail.
|
||||
- Score 5-6: One noticeable sizing or ordering issue, but no superscript.
|
||||
- Score 3-4: Multiple sizing/ordering violations, or font weight mismatch between caveat and T&Cs.
|
||||
- Score 1-2: Caveats rendered as superscript (critical failure), or completely wrong ordering across the asset.
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON:
|
||||
|
|
@ -87,13 +68,10 @@ Format your response as JSON:
|
|||
"caveats_present": true or false,
|
||||
"caveat_types_found": ["list of caveat symbols found, e.g. '*', '†', '**'"],
|
||||
"ordering_correct": true or false or "single_type_only",
|
||||
"superscript_detected": true or false or "needs_manual_check",
|
||||
"superscript_caveat": "Free-text note when superscript_detected is 'needs_manual_check' — describe what you observed and why you weren't confident. Empty string otherwise.",
|
||||
"sizing_compliant": true or false or "needs_manual_check" or "not_applicable",
|
||||
"sizing_caveat": "Free-text note when sizing_compliant is 'needs_manual_check' — describe the borderline size observation. Empty string otherwise.",
|
||||
"weight_matching": true or false or "needs_manual_check" or "not_applicable",
|
||||
"weight_caveat": "Free-text note when weight_matching is 'needs_manual_check' — describe the borderline weight observation (e.g. Light vs Regular distinction at small size). Empty string otherwise.",
|
||||
"issues_found": ["list of specific issues, empty array if none — only include items you are confident are violations, not ambiguous observations"],
|
||||
"superscript_detected": true or false,
|
||||
"sizing_compliant": true or false or "not_applicable",
|
||||
"weight_matching": true or false or "not_applicable",
|
||||
"issues_found": ["list of specific issues, empty array if none"],
|
||||
"explanation": "Detailed description of caveat compliance assessment",
|
||||
"recommendations": ["List specific recommendations if applicable, else an empty array"]
|
||||
}"""
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path to import shared modules
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
class BootsColourPaletteApp(FlaskAppTemplate):
|
||||
"""
|
||||
Boots - Colour Palette Compliance Check
|
||||
|
||||
Two distinct evaluation modes:
|
||||
• Creative-guidance / palette pages: verify the printed CMYK / RGB / Hex
|
||||
spec values match the canonical Boots palette (Boots Blue #05054b,
|
||||
Health Primary Blue #5dc4e9, Offer Red #d3072a). Mismatched specs
|
||||
are a hard fail.
|
||||
• Artwork pages: verify the dominant brand colours used in the layout
|
||||
are visually consistent with the canonical palette. Best-effort —
|
||||
exact colour-picking from a rasterised page is not reliable.
|
||||
|
||||
The LLM decides which mode applies based on what is visible on the page.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
prompt = """You are performing a visual quality-control check on a Boots retail promotional asset. Your task is to verify that the colour palette in use matches Boots' approved brand colours.
|
||||
|
||||
SCOPE: Check brand colour compliance ONLY. Do NOT assess creative quality.
|
||||
|
||||
CANONICAL BOOTS COLOUR PALETTE (the source of truth — verify against these):
|
||||
|
||||
1. BOOTS BLUE
|
||||
- Hex: #05054b
|
||||
- CMYK: 100 / 095 / 004 / 042
|
||||
- RGB: 005 / 005 / 075
|
||||
- Use: primary brand colour, master logo, headers, brand backgrounds.
|
||||
|
||||
2. HEALTH PRIMARY BLUE
|
||||
- Hex: #5dc4e9
|
||||
- CMYK: 060 / 000 / 005 / 000
|
||||
- RGB: 093 / 197 / 234
|
||||
- Use: Health-category artwork accents.
|
||||
|
||||
3. OFFER RED
|
||||
- Hex: #d3072a
|
||||
- CMYK: 000 / 100 / 080 / 010
|
||||
- RGB: 211 / 007 / 042
|
||||
- Use: offer roundels, sale callouts, price highlights.
|
||||
|
||||
(Some packs also reference RICH BLACK — CMYK 070 / 060 / 060 / 100 — for premium/black artwork. This is an accepted secondary value.)
|
||||
|
||||
EVALUATION MODE — which type of page is this?
|
||||
|
||||
MODE A — CREATIVE GUIDANCE / COLOUR PALETTE PAGE
|
||||
Indicators: page contains a "COLOUR PALETTE" or "Creative guidance" header, lists CMYK / RGB / Hexadecimal blocks, names colours like "Boots Blue", "Health Primary Blue", "Offer Red".
|
||||
What to check: Each listed colour block's CMYK, RGB, and Hex values must EXACTLY match the canonical values above. Any mismatch is a hard fail (these are spec values being communicated to print, so accuracy is critical).
|
||||
|
||||
MODE B — ARTWORK PAGE (offer roundel, T&Cs, product layout, etc.)
|
||||
Indicators: visible offer mechanic (e.g. "2 FOR £6", "3FOR2"), price callouts, T&Cs ("Available through Click & Collect"), product imagery, retail layout.
|
||||
What to check: The dominant brand-coloured elements (offer roundel background, header bar, key text) should visually match Boots Blue, Health Primary Blue, or Offer Red as appropriate. This is a best-effort visual check — colour picking from a rendered raster is approximate. Flag obvious deviations only (e.g. an offer roundel in orange instead of red, a header in teal instead of Boots Blue).
|
||||
|
||||
MODE C — NOT APPLICABLE
|
||||
Indicators: page is a cover / brief / asset checklist / yellow notes page with no palette spec and no significant brand-coloured artwork elements.
|
||||
What to check: nothing — score 7/10 as neutral and report mode="not_applicable".
|
||||
|
||||
LIMITATIONS YOU MUST DISCLOSE:
|
||||
- Visual colour assessment from a rasterised page image is approximate. Exact CMYK / RGB readings from print-ready files require dedicated tools and cannot be derived reliably here.
|
||||
- For Mode A spec verification, read the printed values exactly as shown on the page; if any digit is unclear in the rendered image, flag it for manual verification rather than guessing.
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- Score 9-10: Mode A — every spec value matches canonical exactly. Mode B — dominant brand colours all visually correct. Mode C — neutral 7/10.
|
||||
- Score 7-8: Mode A — one minor digit mismatch in a single channel (e.g. C 060 vs C 061). Mode B — colours mostly correct with one borderline shade.
|
||||
- Score 7: Mode C, not applicable.
|
||||
- Score 5-6: Mode A — one spec value clearly wrong (full hex mismatch). Mode B — one obvious colour deviation (e.g. wrong red shade on the offer roundel).
|
||||
- Score 3-4: Mode A — multiple spec values wrong. Mode B — multiple obvious colour deviations.
|
||||
- Score 1-2: Mode A — palette page is fundamentally wrong (different colour names, completely different values). Mode B — almost no Boots brand colours present where expected.
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON:
|
||||
{
|
||||
"palette_check": "Pass" or "Fail" or "N/A",
|
||||
"mode": "creative_guidance" or "artwork" or "not_applicable",
|
||||
"named_colours_found": ["names of colours identified on the page, e.g. 'Boots Blue', 'Offer Red'"],
|
||||
"spec_mismatches": [
|
||||
{
|
||||
"colour_name": "name as shown on page",
|
||||
"channel": "CMYK" or "RGB" or "Hex",
|
||||
"found": "value as shown on page",
|
||||
"expected": "canonical Boots value",
|
||||
"note": "any clarifying context"
|
||||
}
|
||||
],
|
||||
"visual_deviations": ["list of obvious colour deviations on artwork pages, empty if none"],
|
||||
"issues_found": ["list of specific issues, empty array if none"],
|
||||
"explanation": "Detailed description of which mode applied and what was checked.",
|
||||
"manual_verification_recommended": true or false,
|
||||
"recommendations": ["List specific recommendations if applicable, else an empty array"]
|
||||
}"""
|
||||
|
||||
super().__init__(__name__, prompt)
|
||||
|
||||
# Run the app if executed directly
|
||||
if __name__ == "__main__":
|
||||
app_instance = BootsColourPaletteApp()
|
||||
app_instance.run()
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path to import shared modules
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
class BootsLogoComplianceApp(FlaskAppTemplate):
|
||||
"""
|
||||
Boots - Logo Compliance Check (Option 1 — derived from production-pack observation)
|
||||
|
||||
Note: A formal Boots brand-identity guideline (logo versions, clear space,
|
||||
minimum size) was not supplied when this check was authored. The check is
|
||||
therefore intentionally conservative — it verifies the things that are
|
||||
visually observable without a reference: presence, orientation, distortion,
|
||||
recolouring, and reasonable clear space. Tighten this prompt once a
|
||||
canonical Boots logo guideline becomes available.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
prompt = """You are performing a visual quality-control check on a Boots retail promotional asset. Your task is to verify that any Boots brand logo present on the asset is used correctly.
|
||||
|
||||
SCOPE: Check brand identity compliance ONLY. Do NOT assess creative quality or unrelated design choices.
|
||||
|
||||
WHAT THE BOOTS LOGO LOOKS LIKE:
|
||||
- The Boots master wordmark is the lower-case word "boots" set in a custom rounded sans-serif (Boots Sharp). It is most commonly rendered in Boots Blue (#05054b) on a white or light background, or reversed out in white on a darker (often Boots Blue) background.
|
||||
- It always reads horizontally, left-to-right. It must NOT appear rotated, mirrored, set vertically, or stylised in a different typeface.
|
||||
- The wordmark must NEVER appear in a colour outside the Boots brand palette (e.g. red, green, gradient, photographic fill).
|
||||
|
||||
FIRST DECIDE WHAT YOU ARE EVALUATING:
|
||||
|
||||
A. **MASTER WORDMARK** — the standalone "boots" lower-case rounded sans-serif logo, presented on its own as the primary brand identifier (e.g. on a retail aisle fin, shelf-edge strip, window vinyl, etc.). This is what the strict rules below apply to.
|
||||
|
||||
B. **PARTNER / AGENCY / PRODUCTION LOCK-UP** — a composite signature line such as "OLIVER x BOOTS" in a footer, "Boots Advantage Card" branding, or a co-branded cell with a third-party brand. These lock-ups follow their own typographic conventions and ARE NOT REQUIRED to use the master wordmark's exact typeface, casing, or colour. Production-pack metadata strips ("OLIVER x BOOTS / Production Pack") are signature furniture, not the master logo.
|
||||
|
||||
C. **NO BOOTS BRANDING VISIBLE** — possible on cover / brief pages or on co-branded artwork dominated by a third-party brand (e.g. Remington, Imodium). Score 7/10 N/A neutral.
|
||||
|
||||
If you see only B (partner / production lock-up) and no A (master wordmark), evaluate it under the LOCK-UP path below — do NOT score it against master wordmark rules.
|
||||
|
||||
CHECKS TO PERFORM ON THE MASTER WORDMARK (path A):
|
||||
|
||||
1. LOGO PRESENCE — Is a Boots master wordmark visible anywhere on the asset?
|
||||
- On retail artwork (e.g. shelf-edge fins, aisle fins, window vinyls), a master wordmark is generally expected.
|
||||
- If no master wordmark is visible but a partner lock-up IS present, drop to the LOCK-UP path below.
|
||||
|
||||
2. ORIENTATION — Set horizontally, reading left-to-right? Tilted, rotated, vertical, or mirrored = clear violation.
|
||||
|
||||
3. DISTORTION — Aspect ratio preserved? Letters not stretched/squashed. Letter shapes balanced and natural.
|
||||
|
||||
4. COLOUR — Rendered in Boots Blue (#05054b) or white reversed out on a dark/branded background. Red, green, gradients, photographic fills, or non-brand colours = violation.
|
||||
|
||||
5. CLEAR SPACE — Reasonable breathing room. Not crowded by text, prices, product imagery, or page edges. Best-effort visual judgement only.
|
||||
|
||||
6. SIZE — Legible at viewing distance. Best-effort visual judgement only.
|
||||
|
||||
CHECKS TO PERFORM ON A PARTNER / PRODUCTION LOCK-UP (path B):
|
||||
|
||||
Partner lock-ups have **looser, separate rules**. The master wordmark spec does NOT apply. Only flag the following genuine violations:
|
||||
- The lock-up text is **mirrored, rotated, or fundamentally illegible**.
|
||||
- The lock-up appears to be **broken / cut off / overlapping other elements** in a way that suggests a layout error.
|
||||
- The lock-up is in a colour that **clashes hard** with the rest of the artwork (e.g. fluorescent pink in a black-and-white production pack header).
|
||||
|
||||
Differences in typeface (e.g. uppercase "BOOTS" in a sans-serif rather than the lower-case Boots Sharp wordmark) and colour (e.g. black/grey production-pack furniture rather than Boots Blue) are **acceptable on partner / production lock-ups** and should NOT be scored as violations. These are conventions of the partner-lock-up format.
|
||||
|
||||
LIMITATIONS YOU MUST DISCLOSE:
|
||||
- This check operates without a formal Boots logo guideline. Conclusions about clear space, minimum size, and exact colour values are best-effort visual estimates and may need manual verification by the Boots brand team.
|
||||
- Partner / production lock-up assessment is intentionally lenient. If you suspect a lock-up violation that doesn't fit the explicit "genuine violation" list above, set `manual_verification_recommended=true` rather than failing the score.
|
||||
|
||||
SCORING GUIDANCE — MASTER WORDMARK (path A):
|
||||
- Score 9-10: Master wordmark present, correctly orientated, undistorted, in an approved colour, with reasonable clear space and size.
|
||||
- Score 7-8: Master wordmark correct overall, one minor issue (slightly tight clear space, slightly small but still legible).
|
||||
- Score 5-6: One clear issue (recoloured to an unapproved colour, OR visibly distorted, OR no clear space at all).
|
||||
- Score 3-4: Multiple master-wordmark violations (e.g. wrong colour AND distorted, or rotated AND crowded).
|
||||
- Score 1-2: Master wordmark fundamentally wrong — wrong typeface, mirrored, replaced with placeholder, or in a non-brand colour throughout.
|
||||
|
||||
SCORING GUIDANCE — PARTNER / PRODUCTION LOCK-UP (path B):
|
||||
- Score 9-10: Lock-up legible, correctly placed, no obvious typographic or layout faults.
|
||||
- Score 7-8: Lock-up legible with a minor visual quirk (e.g. slightly tight spacing) — default score for any well-formed lock-up where the master wordmark spec doesn't apply.
|
||||
- Score 5-6: Lock-up has a real issue per the "genuine violations" list above (e.g. cut-off text, illegible at intended viewing distance, hard-clashing colour).
|
||||
- Score 1-4: Reserved for serious lock-up problems (mirrored, rotated, fundamentally broken). Do NOT use this band just because the lock-up isn't the master wordmark.
|
||||
|
||||
SCORING GUIDANCE — NO BOOTS BRANDING (path C):
|
||||
- Score 7: No Boots branding visible — N/A neutral.
|
||||
|
||||
YOUR OUTPUT:
|
||||
Format your response as JSON:
|
||||
{
|
||||
"logo_check": "Pass" or "Fail" or "N/A",
|
||||
"evaluation_path": "master_wordmark" or "partner_lockup" or "none_visible",
|
||||
"logo_present": true or false,
|
||||
"orientation_correct": true or false or "not_applicable",
|
||||
"aspect_ratio_correct": true or false or "not_applicable",
|
||||
"colour_approved": true or false or "not_applicable",
|
||||
"clear_space_reasonable": true or false or "not_applicable",
|
||||
"size_reasonable": true or false or "not_applicable",
|
||||
"lockup_observations": "Free-text note when path B applied — describe the lock-up you assessed and any minor observations (typeface differences, casing, colour) that are normal for lock-ups and were NOT counted as violations. Empty string for paths A and C.",
|
||||
"issues_found": ["list of genuine violations per the path-specific rules, empty array if none"],
|
||||
"explanation": "Detailed description of which path applied (master wordmark / partner lock-up / no branding) and what was checked.",
|
||||
"manual_verification_recommended": true or false,
|
||||
"recommendations": ["List specific recommendations if applicable, else an empty array"]
|
||||
}"""
|
||||
|
||||
super().__init__(__name__, prompt)
|
||||
|
||||
# Run the app if executed directly
|
||||
if __name__ == "__main__":
|
||||
app_instance = BootsLogoComplianceApp()
|
||||
app_instance.run()
|
||||
|
|
@ -97,9 +97,6 @@ When multiple T&Cs appear together, they follow this order:
|
|||
1. Click & Collect T&Cs (FIRST — in Boots Sharp Regular)
|
||||
2. All other T&Cs (in Boots Sharp Light)
|
||||
|
||||
IMPORTANT LIMITATION ON FONT WEIGHT:
|
||||
Vision-LLM analysis cannot reliably distinguish Boots Sharp Regular from Boots Sharp Light at the small point sizes typical of T&C copy. Treat any font-weight assessment (`click_collect_font_correct`, hierarchy weight reasoning) as a best-effort observation that should be confirmed by manual review. When you are not confident, set the font field to "needs_manual_check" rather than guessing — and include a corresponding note in `font_weight_caveat` so the report surfaces this limitation to the user.
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Identify all T&C text visible in the asset.
|
||||
2. If multibuy offers are present, check for correct standard T&C wording (Section A).
|
||||
|
|
@ -125,11 +122,10 @@ Format your response as JSON:
|
|||
"tandc_sections_found": ["list of T&C types identified: 'offer', 'click_collect', 'advantage_card', 'parenting_club', 'price_advantage', 'pyramid', 'offer_dates'"],
|
||||
"offer_tandc_correct": true or false or "not_applicable",
|
||||
"click_collect_correct": true or false or "not_applicable",
|
||||
"click_collect_font_correct": true or false or "needs_manual_check" or "not_applicable",
|
||||
"click_collect_font_correct": true or false or "not_applicable",
|
||||
"lockup_tandc_correct": true or false or "not_applicable",
|
||||
"date_format_correct": true or false or "not_applicable",
|
||||
"hierarchy_correct": true or false or "not_applicable",
|
||||
"font_weight_caveat": "Free-text note flagging any font-weight observation that is best-effort and may need manual verification. Empty string if not relevant.",
|
||||
"issues_found": ["list of specific issues, empty array if none"],
|
||||
"explanation": "Detailed description of T&C wording compliance assessment",
|
||||
"recommendations": ["List specific recommendations if applicable, else an empty array"]
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
"""HP Copy Review — single-call LLM grader against canonical Source Messaging.
|
||||
|
||||
This check compares all visible copy on an HP marketing asset (claims,
|
||||
headlines, body, disclaimers, footnotes, spec call-outs, brand mentions)
|
||||
against the canonical Source Messaging summaries attached as reference
|
||||
assets (.xlsx → Markdown summary via excel_processor).
|
||||
|
||||
It returns a structured JSON object with a 0-10 score, a one-paragraph
|
||||
summary, and a `findings` array (priority / category / quote / issue /
|
||||
suggested_fix / source_reference). Empty findings on a clean asset is a
|
||||
valid result (score 9-10). When no Source Messaging is attached, the
|
||||
LLM is instructed to return score 0 with an explanatory message rather
|
||||
than grade blind.
|
||||
|
||||
Reference assets and media-plan context (including `language`) are
|
||||
injected by `process_single_check` in `api_server.py` — this module
|
||||
exposes only the static prompt template. A standalone `build_prompt()`
|
||||
helper is provided for unit-style smoke tests and for any future caller
|
||||
that wants to assemble the full prompt outside the production path.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Iterable, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
# Add parent directory to path so we can import shared template
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from visual_qc_apps.flask_app_template import FlaskAppTemplate
|
||||
|
||||
|
||||
# --- Canonical prompt template ------------------------------------------------
|
||||
#
|
||||
# The reference-asset summary block ("CANONICAL SOURCE MESSAGING") is
|
||||
# prepended by `process_single_check` in `api_server.py` via
|
||||
# `get_reference_asset_content()`. Likewise the media-plan context block
|
||||
# ("=== MEDIA PLAN CONTEXT ===" with `- Language: <value>`) is appended
|
||||
# by `process_single_check`. We embed instructions that *reference* both
|
||||
# blocks so the LLM knows where to look.
|
||||
|
||||
HP_COPY_REVIEW_PROMPT = """You are a copy reviewer for HP marketing materials. Your job is to compare the marketing asset against the canonical Source Messaging that has been attached as a reference asset, and report every copy discrepancy as a structured finding.
|
||||
|
||||
WHAT YOU WILL BE GIVEN:
|
||||
1. One or more canonical Source Messaging summaries, attached above as REFERENCE ASSET GUIDELINES. Each Source Messaging file (e.g. `messi_core.xlsx`, `messi_mainstream.xlsx`) has been pre-summarised into Markdown and is the single source of truth for product claims, KSPs, disclaimers, spec call-outs, variant naming, and approved tone.
|
||||
2. A media-plan context block (appended below the prompt) which may include `- Language: <value>` and `- Country: <value>`. Treat the language value as the PRODUCT LANGUAGE the asset should be using (e.g. "UK English", "US English", "French (France)").
|
||||
3. The marketing asset image itself.
|
||||
|
||||
WHAT TO DO:
|
||||
For every claim, headline, body line, disclaimer, footnote, spec call-out, and brand mention visible on the asset, evaluate it against the canonical Source Messaging. Flag:
|
||||
- Wording that disagrees with an approved KSP or claim.
|
||||
- Missing or incorrect mandatory disclaimers / legal footnotes / asterisked notes.
|
||||
- Spec call-outs that contradict the canonical spec (wrong number, wrong unit, wrong product variant).
|
||||
- Variant / product-name errors (e.g. "OmniDesk Mini" vs "OmniDesk Mini Core").
|
||||
- Tone / phrasing drift from the approved brand voice described in the source.
|
||||
- Brand-name misuse (HP, sub-brand capitalisation, trademark glyph misuse).
|
||||
- Language / locale mismatch against the media-plan PRODUCT LANGUAGE (e.g. "color" appearing in a UK English asset, or French copy on an asset specified as US English).
|
||||
|
||||
OUTPUT — return ONE JSON object, and nothing else (no prose, no markdown fences outside the JSON code block). The shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"score": <number 0-10>,
|
||||
"summary": "<one-paragraph headline finding>",
|
||||
"findings": [
|
||||
{
|
||||
"priority": "high" | "medium" | "low",
|
||||
"category": "ksp" | "disclaimer" | "spec" | "variant" | "tone" | "brand-name" | "language" | "other",
|
||||
"quote": "<exact quote from the asset>",
|
||||
"issue": "<what's wrong>",
|
||||
"suggested_fix": "<what it should say, citing the canonical source>",
|
||||
"source_reference": "<where in the source messaging this comes from, e.g. file name + section heading>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
RULES:
|
||||
- If no Source Messaging reference asset is attached (i.e. there is no "REFERENCE ASSET GUIDELINES" block above describing canonical HP messaging), return EXACTLY:
|
||||
{"score": 0, "summary": "No HP Source Messaging reference was attached — cannot grade copy without a canonical source.", "findings": []}
|
||||
Do not attempt to grade copy from prior knowledge.
|
||||
- High-priority findings (factually-wrong claims, missing mandatory disclaimers, wrong product variant, wrong language) weight the score most heavily. A single high-priority finding should typically pull the score below 6.
|
||||
- Medium-priority findings are wording drift that changes nuance but not meaning, or missing optional supporting copy.
|
||||
- Low-priority findings are tone / style nits.
|
||||
- An empty `findings` array is a valid and expected result for a clean asset — in that case score 9 or 10 and write a short, positive summary.
|
||||
- The `quote` field must be the EXACT visible text from the asset, including punctuation. If you can read it, quote it.
|
||||
- `source_reference` should make it easy for a reviewer to verify the finding — name the Source Messaging file and the section/heading you matched against.
|
||||
- Return ONLY the JSON object inside a single ```json ... ``` code block. No surrounding prose, no explanations outside the JSON.
|
||||
"""
|
||||
|
||||
|
||||
def build_prompt(
|
||||
reference_summaries: Optional[Sequence[Tuple[str, str]]] = None,
|
||||
media_plan_row: Optional[Mapping[str, str]] = None,
|
||||
base_prompt: str = HP_COPY_REVIEW_PROMPT,
|
||||
) -> str:
|
||||
"""Assemble a fully-rendered HP copy-review prompt for testing / inspection.
|
||||
|
||||
In production, `process_single_check` (api_server.py) does this
|
||||
assembly itself: it prepends `get_reference_asset_content(...)` and
|
||||
appends `build_media_plan_context(...)`. This helper mirrors that
|
||||
flow so we can smoke-test the prompt assembly without running the
|
||||
full server, and so callers that want to render the exact prompt
|
||||
text for logging / debugging have a single entry point.
|
||||
|
||||
Args:
|
||||
reference_summaries: List of (filename, markdown_summary) tuples,
|
||||
one per attached Source Messaging .xlsx. Each summary is
|
||||
already a Markdown string produced by `excel_processor`.
|
||||
None or [] means "no canonical source attached" — in that
|
||||
case we still build the prompt but omit the canonical block,
|
||||
and the LLM will fall back to the score-0 rule.
|
||||
media_plan_row: Mapping with optional `language`, `country`,
|
||||
`placement`, etc. Only `language` and `country` are
|
||||
rendered into the prompt here; the production flow uses
|
||||
`build_media_plan_context` and includes more fields.
|
||||
base_prompt: Override for the canonical prompt template (used
|
||||
in tests where we want to inject a shorter stub).
|
||||
|
||||
Returns:
|
||||
The fully-assembled prompt string, with the canonical source
|
||||
messaging block (if any) prepended, the media-plan language /
|
||||
country line(s) appended, and the base template in between.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# 1. Canonical source messaging block — mirrors the shape of
|
||||
# `get_reference_asset_content` so the LLM sees a consistent
|
||||
# "REFERENCE ASSET GUIDELINES" heading whether it's running in
|
||||
# production or via this helper.
|
||||
if reference_summaries:
|
||||
ref_lines = ["\n\n=== REFERENCE ASSET GUIDELINES ===",
|
||||
"CANONICAL SOURCE MESSAGING:"]
|
||||
for filename, summary in reference_summaries:
|
||||
ref_lines.append(f"\n--- File: {filename} ---\n{summary}")
|
||||
ref_lines.append("=== END REFERENCE ASSET GUIDELINES ===\n")
|
||||
parts.append("\n".join(ref_lines))
|
||||
|
||||
# 2. The static prompt template itself.
|
||||
parts.append(base_prompt)
|
||||
|
||||
# 3. Media-plan context (language / country). Production appends
|
||||
# the full `build_media_plan_context` block; here we render just
|
||||
# the language + country fields, which is what Step 5.6 asserts.
|
||||
if media_plan_row:
|
||||
mp_lines = ["\n=== MEDIA PLAN CONTEXT ==="]
|
||||
if media_plan_row.get('language'):
|
||||
mp_lines.append(f"- Language: {media_plan_row['language']}")
|
||||
if media_plan_row.get('country'):
|
||||
mp_lines.append(f"- Country: {media_plan_row['country']}")
|
||||
mp_lines.append("=== END MEDIA PLAN CONTEXT ===")
|
||||
parts.append("\n".join(mp_lines))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
class HpCopyReviewApp(FlaskAppTemplate):
|
||||
"""HP Copy Review — single-call LLM copy grader against Source Messaging.
|
||||
|
||||
Subclasses `FlaskAppTemplate` so the check is auto-discovered by
|
||||
`load_qc_apps()` in `api_server.py`. The class instance exposes
|
||||
`self.prompt` (the canonical template plus the standard scoring
|
||||
instructions appended by the template base class).
|
||||
|
||||
Reference asset summaries and media-plan context are injected at
|
||||
runtime by `process_single_check` — this class does NOT call Gemini
|
||||
directly. Response parsing is handled by
|
||||
`extract_json_from_response` / `extract_score_from_result` in
|
||||
api_server.py, which will lift `score`, `summary`, and `findings`
|
||||
out of the JSON code block returned by the LLM.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(__name__, HP_COPY_REVIEW_PROMPT)
|
||||
|
||||
|
||||
# Allow running this check standalone for ad-hoc testing
|
||||
if __name__ == "__main__":
|
||||
app_instance = HpCopyReviewApp()
|
||||
app_instance.run()
|
||||
|
|
@ -54,13 +54,6 @@ RULE 5 — SUBHEADERS AND BODY COPY:
|
|||
- Always 2 spaces before a price in body copy.
|
||||
- Price text is bolded (except the period if it ends with one).
|
||||
|
||||
IMPORTANT VISION-LLM LIMITATION ON RULE 5 — PRICE FORMATTING:
|
||||
The "two spaces before a price" rule is a typesetting concern that is NOT reliably verifiable from a rendered PNG. The visual difference between one space and two spaces at body-copy size is below the reliable detection threshold of vision-LLM analysis. Do NOT flag space-count violations — set `price_spacing_correct: "needs_manual_check"` and populate `price_formatting_caveat` instead.
|
||||
|
||||
The "price bolded" rule IS visually verifiable when the weight difference is OBVIOUS (e.g. price clearly heavier than surrounding body). For borderline weight observations or when the price already appears bolded but ambiguity exists, set `price_bolded_correct: "needs_manual_check"` and populate `price_formatting_caveat` — do NOT confidently flag a bolded price as un-bolded.
|
||||
|
||||
Do NOT score the asset 5-6/10 on Rule 5 violations alone. Headline rules (1-4) are the dominant factor.
|
||||
|
||||
RULE 6 — EYEBROWS AND CTAs:
|
||||
- Eyebrow text: ALL CAPS (e.g., "BREAKING NEWS").
|
||||
- CTA/Button text: ALL CAPS (e.g., "SUBSCRIBE NOW").
|
||||
|
|
@ -70,26 +63,14 @@ If the asset contains very little text (e.g., just a logo), score this check neu
|
|||
|
||||
DECISION TREE — HOW TO EVALUATE EACH HEADLINE:
|
||||
Before scoring, you MUST classify each headline correctly:
|
||||
|
||||
A) Read the headline text.
|
||||
|
||||
B) **CHECK THE EXPLICIT WHITELIST FIRST.** If the headline matches any of these patterns or is one of these exact examples, it is a COMPLETE SENTENCE in sentence case and is CORRECT — do NOT flag it as a Title Case error:
|
||||
- "Insight that informs every decision." (subject + relative clause + period — correct sentence case)
|
||||
- "Find clarity in world-class coverage." (imperative complete sentence — correct sentence case)
|
||||
- "Stay ahead of the top stories." (imperative complete sentence — correct sentence case)
|
||||
- "We report on the news you can't afford to ignore." (subject + verb — correct sentence case)
|
||||
- "Follow WSJ for your news." (imperative — correct sentence case)
|
||||
- Any imperative starting with a verb (Find, Stay, Follow, Discover, Read, See, Get, Make, Start, Subscribe…) followed by an object → COMPLETE SENTENCE → sentence case is correct.
|
||||
- Any clause beginning with a noun followed by "that"/"which"/"who" + verb → COMPLETE SENTENCE → sentence case is correct.
|
||||
|
||||
C) If not on the whitelist, classify by structure: Does it contain BOTH a subject AND a verb (i.e., is it a complete sentence)?
|
||||
B) Does it contain BOTH a subject AND a verb (i.e., is it a complete sentence)?
|
||||
- YES → it is a COMPLETE SENTENCE → expect SENTENCE CASE (only first word and proper nouns capitalised).
|
||||
Examples: "Insight that informs every decision." / "Find clarity in world-class coverage." / "Stay ahead of the top stories."
|
||||
- NO → it is an INCOMPLETE SENTENCE (phrase or fragment) → expect TITLE CASE.
|
||||
Examples: "Journalism Held to a Higher Standard." / "Business Journalism, Uncompromised." / "Reporting Without Bias."
|
||||
|
||||
D) Does it follow the "Make ___ Your Business." pattern? → always TITLE CASE regardless.
|
||||
|
||||
CRITICAL: Sentence case on a complete sentence is the CORRECT rule, not an error. Do NOT score `title_case_correct: false` or flag a capitalization error simply because a complete-sentence headline is in sentence case. When in doubt about whether a headline is complete or incomplete, default to assuming it is COMPLETE (sentence case is correct) — the cost of a false-positive Title Case flag is high.
|
||||
C) Does it follow the "Make ___ Your Business." pattern? → always TITLE CASE regardless.
|
||||
Do NOT penalise a complete sentence for using sentence case — that IS the correct rule.
|
||||
|
||||
STEPS TO EVALUATE:
|
||||
1. Identify all visible text elements in the asset (headlines, subheaders, body copy, eyebrows, CTAs).
|
||||
|
|
@ -120,10 +101,7 @@ Format your response as JSON:
|
|||
"sentence_case_correct": true or false or "not_applicable",
|
||||
"eyebrows_all_caps": true or false or "no_eyebrows",
|
||||
"ctas_all_caps": true or false or "no_ctas",
|
||||
"price_spacing_correct": true or false or "needs_manual_check" or "no_price",
|
||||
"price_bolded_correct": true or false or "needs_manual_check" or "no_price",
|
||||
"price_formatting_caveat": "Free-text note when price_spacing_correct or price_bolded_correct is 'needs_manual_check' — describe the borderline observation. Empty string otherwise.",
|
||||
"capitalization_errors": ["list of specific errors found, empty array if none — only include items you are confident are violations, not borderline observations"],
|
||||
"capitalization_errors": ["list of specific errors found, empty array if none"],
|
||||
"explanation": "Detailed description of capitalization and punctuation assessment",
|
||||
"recommendations": ["List specific recommendations if applicable, else an empty array"]
|
||||
}"""
|
||||
|
|
@ -20,11 +20,10 @@ The Wall Street Journal uses four logotype formats, all rendered ONLY in black o
|
|||
|
||||
1. PRIMARY HORIZONTAL LOGOTYPE - The full "The Wall Street Journal" wordmark in a single horizontal line.
|
||||
- Used on most creatives, especially those with messaging about journalism or the IYB (It's Your Business) campaign.
|
||||
- Placement (default): Center-aligned on the creative.
|
||||
- Placement (split / asymmetric layouts): On 50/50 split layouts, side-by-side image+copy compositions, or any clearly asymmetric campaign creative, the horizontal logo is permitted to anchor to the copy block (e.g. bottom of the copy column) rather than the canvas center. This is an accepted WSJ placement convention — do NOT penalise a horizontal logo anchored to the copy block on a split/asymmetric layout. Treat as `placement_correct: true`.
|
||||
- Placement: Always center-aligned on the creative.
|
||||
- Minimum clear space: Half of the cap-height of the logo text on all sides.
|
||||
- Standalone/logo-only usage: 60% of the asset's shortest side.
|
||||
- Marketing assets with other content (headlines, body copy, imagery): Use 30% of the longest side. This is the STANDARD sizing for campaign creatives — most marketing assets will use this rule, not the 60% standalone rule. This 30% rule is a HARD constraint — flag oversized logos (visibly larger than ~30% of the longest side) as a real sizing violation, even on otherwise on-brand creatives.
|
||||
- Marketing assets with other content (headlines, body copy, imagery): Use 30% of the longest side. This is the STANDARD sizing for campaign creatives — most marketing assets will use this rule, not the 60% standalone rule.
|
||||
|
||||
2. PRIMARY IYB (IT'S YOUR BUSINESS) LOGOTYPE LOCKUP - The WSJ wordmark with the "It's Your Business." tagline.
|
||||
- Used on creatives with messaging about the value of the news product or the IYB campaign.
|
||||
|
|
@ -49,43 +48,20 @@ CRITICAL RULES:
|
|||
- Adequate clear space must be maintained around the logo.
|
||||
- Logo must not disrupt the visual hierarchy of the creative.
|
||||
|
||||
============================================================
|
||||
MANDATORY SIZING ASSESSMENT — DO THIS FIRST, INDEPENDENTLY
|
||||
============================================================
|
||||
For every Primary Horizontal Logotype or IYB Lockup on a marketing creative with other content, you MUST perform an explicit sizing assessment BEFORE forming an overall opinion. This is a HARD constraint and CANNOT be waived because the rest of the creative is on-brand.
|
||||
|
||||
How to assess:
|
||||
1. Identify the LONGEST side of the asset (in portrait creatives this is the HEIGHT, in landscape creatives this is the WIDTH).
|
||||
2. Estimate the logo's width as a percentage of the longest side.
|
||||
3. Apply the threshold:
|
||||
- **Logo width ≤ 30% of longest side** → `size_appropriate: true`. On-brand sizing.
|
||||
- **Logo width > 30% of longest side** → `size_appropriate: false`. This is a real sizing violation that MUST be flagged in `issues_found` and reflected in the score.
|
||||
|
||||
Worked examples — internalise these BEFORE scoring:
|
||||
- Asset 1080w × 1920h portrait. Longest side = 1920. 30% threshold = 576px. A logo spanning ~80% of the asset WIDTH (≈864px) = ~45% of the longest side → **VIOLATION**, must flag, must score ≤6 on this check.
|
||||
- Asset 1200w × 628h landscape. Longest side = 1200. 30% threshold = 360px. A logo ~300px wide = 25% of longest side → on-brand sizing, can score 9-10 if other rules pass.
|
||||
- Asset 1080w × 1920h portrait with a logo ~500px wide = ~26% of longest side → on-brand sizing.
|
||||
|
||||
Common failure pattern: in TALL PORTRAIT creatives the logo often spans most of the WIDTH at the bottom, which feels visually balanced but mathematically violates the 30%-of-longest-side rule because the longest side is the (much larger) height. Do NOT excuse this — flag it.
|
||||
|
||||
Score cap: a logo that visibly exceeds ~30% of the longest side CANNOT score 9-10 on this check, regardless of how good color/placement/clear space are. The maximum permitted score for an oversized logo is 6/10.
|
||||
|
||||
============================================================
|
||||
|
||||
STEPS TO EVALUATE (in this order):
|
||||
STEPS TO EVALUATE:
|
||||
1. Identify whether a WSJ logo is present in the asset.
|
||||
2. Determine which logo format is being used (Horizontal, IYB Lockup, Shortform Bug, or Stacked).
|
||||
3. **Perform the MANDATORY SIZING ASSESSMENT above for Primary Horizontal / IYB Lockup logos. Set `size_appropriate` based strictly on the 30%-of-longest-side rule.**
|
||||
4. Verify the logo is rendered in black or white only.
|
||||
5. Check placement rules specific to the logo format being used.
|
||||
6. Assess whether adequate clear space is maintained around the logo.
|
||||
3. Verify the logo is rendered in black or white only.
|
||||
4. Check placement rules specific to the logo format being used.
|
||||
5. Assess whether adequate clear space is maintained around the logo.
|
||||
6. Evaluate whether the logo size appears proportional to the asset dimensions.
|
||||
7. Check for any distortion, effects, or improper treatments.
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- Score 9-10: Logo present, correct format for the context, proper placement, adequate clear space, correct color (black or white), AND `size_appropriate: true` (within the 30% rule). All four must hold.
|
||||
- Score 7-8: Logo present and mostly correct, minor placement or clear-space issues, sizing within the 30% rule.
|
||||
- Score 5-6: Logo present but with noticeable issues — wrong placement for the format, tight clear space, OR `size_appropriate: false` (oversized — 30%-of-longest-side rule violated). An oversized logo caps the score at 6 even if everything else is perfect.
|
||||
- Score 3-4: Logo present but with significant issues (wrong color, distorted, severely misplaced, or oversized AND additional violations).
|
||||
- Score 9-10: Logo present, correct format for the context, proper placement, adequate clear space, correct color (black or white), appropriate size.
|
||||
- Score 7-8: Logo present and mostly correct, minor placement or sizing issues.
|
||||
- Score 5-6: Logo present but with noticeable issues (wrong placement for the format, tight clear space, slightly off-brand rendering).
|
||||
- Score 3-4: Logo present but with significant issues (wrong color, distorted, severely misplaced).
|
||||
- Score 1-2: Logo missing entirely or unrecognisable.
|
||||
|
||||
YOUR OUTPUT:
|
||||
|
|
@ -71,21 +71,6 @@ STEPS TO EVALUATE:
|
|||
FORMAT AWARENESS:
|
||||
Very small formats (320x50 banners, 300x250 boxes, 480x320) have extremely limited space and will naturally have FEWER hierarchy levels. A banner may only have room for a headline + CTA + logo — this is acceptable. Score based on whether the elements that ARE present follow WSJ typographic rules (serif headline, sans-serif body/CTA, period on headline), not on the number of hierarchy levels shown. A small-format asset with correct serif headline + sans-serif CTA + period can score 9-10.
|
||||
|
||||
GRAPHIC/ILLUSTRATIVE HEADLINE AWARENESS — CRITICAL:
|
||||
WSJ campaign creative often uses a large stylised serif price, number, word, or graphic element as the visual hero/headline rather than a traditional sentence-style headline. Examples:
|
||||
- A giant compressed-serif "$4.00" price graphic dominating the composition
|
||||
- A large stylised "%" or numerical figure as the visual focal point
|
||||
- A bold typographic treatment of a single word or short phrase
|
||||
- A graphic price-tag composition where the price IS the headline
|
||||
|
||||
When you see a large, dominant compressed-serif graphic of this kind, IT is the WSJ display headline — do NOT treat surrounding smaller sans-serif marketing copy as "the headline" and then penalise it for being sans-serif. The smaller copy is the SUBHEAD or supporting body, and sans-serif is correct for those levels.
|
||||
|
||||
Decision rule: identify the LARGEST and most visually dominant text/graphic element first. If it is a serif treatment (compressed serif, Escrow-style), that is the headline and the typeface rule is satisfied — even if it is a price, number, or single word rather than a full sentence. Surrounding smaller sans-serif text is then correctly classified as subhead/body/CTA.
|
||||
|
||||
Period rule on graphic headlines: stylised price/number graphics ($4.00, $11.25, 50% etc.) are NOT required to end with a period — the period rule applies to sentence-style headlines, not to standalone numeric/price treatments. Do NOT flag a graphic price headline for "missing period".
|
||||
|
||||
Period detection on sentence-style headlines: if you see a period at the end of the sentence, the rule is satisfied. Do NOT flag "missing period" when a period is visibly present.
|
||||
|
||||
SCORING GUIDANCE:
|
||||
- Score 9-10: Clear serif/sans-serif hierarchy, strong size contrast (40%+), headlines end with period, no ligatures, proper alignment, eyebrows in ALL CAPS with wide tracking. Small formats: correct typefaces and rules for the elements present.
|
||||
- Score 7-8: Good hierarchy and mostly correct, minor issues (e.g., size contrast slightly under 40%, or one headline missing a period).
|
||||
10
config.env
Executable file
10
config.env
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
OPENAI_API_KEY=sk-svcacct-HSREzGYDnN-vCVGAh6LhYqlNcJVF2oefMrY9oCsdDsQFmyVJyHpLb1eSb_mp_vP4YPl4T3BlbkFJzKaOrPghIzx76_22K8VjwO6j2JnoDEvrYDrgfrnA4WjD5sTMnhOqGHXximwGXFhUoYgA
|
||||
GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
|
||||
|
||||
# Azure AD / MSAL Authentication Configuration
|
||||
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
|
||||
# Flask Security Configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
|
|
@ -37,13 +37,8 @@ ERROR_EMAIL=admin@your-domain.com
|
|||
REPORT_EMAILS=admin@your-domain.com
|
||||
|
||||
# Box.com OAuth (per-creator user authentication for automation folders)
|
||||
# Register a Custom App with OAuth 2.0 (User Authentication) in Box Developer Console.
|
||||
# In the app's Configuration tab, add ALL the redirect URIs you'll use:
|
||||
# http://localhost:7183/auth/box/callback (local dev)
|
||||
# https://optical-dev.oliver.solutions/ai_qc/auth/box/callback (dev server)
|
||||
# https://optical-prod.oliver.solutions/ai_qc/auth/box/callback (prod server)
|
||||
# The backend computes the redirect URI from each request, so you don't need
|
||||
# to set BOX_REDIRECT_URI per server — uncomment only as an override.
|
||||
# Register a Custom App with OAuth 2.0 (User Authentication) in Box Developer Console;
|
||||
# add a redirect URI matching your environment.
|
||||
BOX_CLIENT_ID=your-box-client-id
|
||||
BOX_CLIENT_SECRET=your-box-client-secret
|
||||
# BOX_REDIRECT_URI=
|
||||
BOX_REDIRECT_URI=http://localhost:7183/auth/box/callback
|
||||
42
config/development.env
Normal file
42
config/development.env
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Development Environment Configuration
|
||||
# This file is used for local development testing
|
||||
|
||||
# OpenAI Configuration
|
||||
OPENAI_API_KEY=sk-svcacct-HSREzGYDnN-vCVGAh6LhYqlNcJVF2oefMrY9oCsdDsQFmyVJyHpLb1eSb_mp_vP4YPl4T3BlbkFJzKaOrPghIzx76_22K8VjwO6j2JnoDEvrYDrgfrnA4WjD5sTMnhOqGHXximwGXFhUoYgA
|
||||
GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
|
||||
|
||||
# Azure AD / MSAL Authentication Configuration (Development App Registration)
|
||||
# NOTE: You'll need to create a separate app registration for development
|
||||
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
AZURE_REDIRECT_URI=http://localhost:7183
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=dev-secret-key-change-this-for-security
|
||||
DEBUG_MODE=true
|
||||
PORT=7183
|
||||
|
||||
# Application Configuration
|
||||
ENVIRONMENT=development
|
||||
BASE_URL=http://localhost:7183
|
||||
UPLOAD_FOLDER=uploads-dev
|
||||
OUTPUT_FOLDER=output-dev
|
||||
|
||||
# Development-specific settings
|
||||
LOG_LEVEL=DEBUG
|
||||
ENABLE_DEBUG_ENDPOINTS=true
|
||||
|
||||
# Mailgun / SMTP (for email notifications)
|
||||
SMTP_SERVER=smtp.mailgun.org
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=twist@mail.dev.oliver.solutions
|
||||
SMTP_PASSWORD=102115e9f3b9d7332d0cd1d4329bc0d4-77751bfc-ca066b71
|
||||
SENDER_EMAIL=TWIST-UK-SERVER@oliver.agency
|
||||
ERROR_EMAIL=nick.viljoen@brandtech.plus
|
||||
REPORT_EMAILS=nick.viljoen@brandtech.plus
|
||||
|
||||
# Box.com OAuth (per-creator user authentication for automation folders)
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
BOX_REDIRECT_URI=http://localhost:7183/auth/box/callback
|
||||
41
config/production.env
Normal file
41
config/production.env
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Production Environment Configuration
|
||||
# This file is used for production deployment on the web server
|
||||
|
||||
# OpenAI Configuration
|
||||
OPENAI_API_KEY=sk-svcacct-HSREzGYDnN-vCVGAh6LhYqlNcJVF2oefMrY9oCsdDsQFmyVJyHpLb1eSb_mp_vP4YPl4T3BlbkFJzKaOrPghIzx76_22K8VjwO6j2JnoDEvrYDrgfrnA4WjD5sTMnhOqGHXximwGXFhUoYgA
|
||||
GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
|
||||
|
||||
# Azure AD / MSAL Authentication Configuration (Production)
|
||||
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
AZURE_REDIRECT_URI=https://ai-sandbox.oliver.solutions/ai_qc/
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=prod-ai-qc-oliver-solutions-2025-secure-key-9f8e7d6c5b4a3
|
||||
DEBUG_MODE=false
|
||||
PORT=7184
|
||||
|
||||
# Application Configuration
|
||||
ENVIRONMENT=production
|
||||
BASE_URL=https://ai-sandbox.oliver.solutions/ai_qc
|
||||
UPLOAD_FOLDER=uploads
|
||||
OUTPUT_FOLDER=output
|
||||
|
||||
# Production-specific settings
|
||||
LOG_LEVEL=INFO
|
||||
ENABLE_DEBUG_ENDPOINTS=false
|
||||
|
||||
# Mailgun / SMTP (for email notifications)
|
||||
SMTP_SERVER=smtp.mailgun.org
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=twist@mail.dev.oliver.solutions
|
||||
SMTP_PASSWORD=102115e9f3b9d7332d0cd1d4329bc0d4-77751bfc-ca066b71
|
||||
SENDER_EMAIL=TWIST-UK-SERVER@oliver.agency
|
||||
ERROR_EMAIL=nick.viljoen@brandtech.plus
|
||||
REPORT_EMAILS=nick.viljoen@brandtech.plus
|
||||
|
||||
# Box.com OAuth (per-creator user authentication for automation folders)
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
BOX_REDIRECT_URI=https://optical-prod.oliver.solutions/ai_qc/auth/box/callback
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
# Phase 1 — Remove Dow Jones Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove Dow Jones from Visual AI QC by archiving profiles, checks, and the per-client doc into `backend/_archive/dow_jones/` and stripping the client from active code paths. Implements the design in `docs/superpowers/specs/2026-05-14-phase1-remove-dow-jones-design.md`.
|
||||
|
||||
**Architecture:** Move-then-strip refactor on a feature branch already created (`feature/remove-dow-jones`, branched from `develop`). All filesystem moves use `git mv` to preserve history. Loader/discovery code requires no changes because the archive lives outside the directories that get scanned. Three logical commits: archive moves, client_config removal, doc updates.
|
||||
|
||||
**Tech Stack:** Bash, Python (verification probes via stdlib + `profile_config`/`client_config`), git.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Archive Dow Jones profiles, checks, and per-client doc
|
||||
|
||||
**Files:**
|
||||
- Create dirs: `backend/_archive/dow_jones/`, `backend/_archive/dow_jones/profiles/`, `backend/_archive/dow_jones/visual_qc_apps/`
|
||||
- Create: `backend/_archive/dow_jones/README.md`
|
||||
- Move (via `git mv`): `CLAUDE_DOW_JONES.md`, 4 profile JSONs under `backend/profiles/`, 22 check directories under `backend/visual_qc_apps/`
|
||||
|
||||
- [ ] **Step 1: Verify clean working tree on feature branch**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Branch: `feature/remove-dow-jones`
|
||||
- Working tree: clean (the spec commit already landed)
|
||||
|
||||
If branch is wrong or working tree is dirty, stop and report — do not proceed.
|
||||
|
||||
- [ ] **Step 2: Create archive directory skeleton**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
mkdir -p backend/_archive/dow_jones/profiles
|
||||
mkdir -p backend/_archive/dow_jones/visual_qc_apps
|
||||
ls -la backend/_archive/dow_jones/
|
||||
```
|
||||
|
||||
Expected output: two directories listed (`profiles/`, `visual_qc_apps/`).
|
||||
|
||||
- [ ] **Step 3: Move the per-client doc**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git mv CLAUDE_DOW_JONES.md backend/_archive/dow_jones/CLAUDE_DOW_JONES.md
|
||||
```
|
||||
|
||||
Expected: no output (silent success).
|
||||
|
||||
- [ ] **Step 4: Move the four profile JSONs**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git mv backend/profiles/dow_jones_static.json backend/_archive/dow_jones/profiles/
|
||||
git mv backend/profiles/marketwatch_static.json backend/_archive/dow_jones/profiles/
|
||||
git mv backend/profiles/wsj_static.json backend/_archive/dow_jones/profiles/
|
||||
git mv backend/profiles/wsj_podcast.json backend/_archive/dow_jones/profiles/
|
||||
ls backend/_archive/dow_jones/profiles/
|
||||
```
|
||||
|
||||
Expected output (alphabetical):
|
||||
```
|
||||
dow_jones_static.json
|
||||
marketwatch_static.json
|
||||
wsj_podcast.json
|
||||
wsj_static.json
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Move the 22 check directories**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
for d in backend/visual_qc_apps/dj_* backend/visual_qc_apps/mw_* backend/visual_qc_apps/wsj_*; do
|
||||
git mv "$d" "backend/_archive/dow_jones/visual_qc_apps/$(basename "$d")"
|
||||
done
|
||||
ls backend/_archive/dow_jones/visual_qc_apps/ | wc -l
|
||||
```
|
||||
|
||||
Expected: `22`
|
||||
|
||||
If the count is not 22, stop and investigate — the survey identified exactly 22 directories (6 `dj_*` + 6 `mw_*` + 6 `wsj_*` non-podcast + 4 `wsj_podcast_*`).
|
||||
|
||||
- [ ] **Step 6: Confirm no `dj_`/`mw_`/`wsj_` directories remain under visual_qc_apps**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
ls -d backend/visual_qc_apps/dj_* backend/visual_qc_apps/mw_* backend/visual_qc_apps/wsj_* 2>&1 | head -5
|
||||
```
|
||||
|
||||
Expected: each glob expands to "No such file or directory" (zsh) or the literal glob (bash with nullglob off). No directories listed.
|
||||
|
||||
- [ ] **Step 7: Write the archive README**
|
||||
|
||||
Create `backend/_archive/dow_jones/README.md` with this exact content:
|
||||
|
||||
```markdown
|
||||
# Dow Jones — Archived 2026-05-14
|
||||
|
||||
**Reason:** Client offboarded. No longer using Visual AI QC.
|
||||
|
||||
## Contents
|
||||
|
||||
- `CLAUDE_DOW_JONES.md` — per-client documentation (originally at repo root)
|
||||
- `profiles/` — 4 brand profile JSONs (originally `backend/profiles/`)
|
||||
- `dow_jones_static.json`
|
||||
- `marketwatch_static.json`
|
||||
- `wsj_static.json`
|
||||
- `wsj_podcast.json`
|
||||
- `visual_qc_apps/` — 22 QC check directories (originally `backend/visual_qc_apps/`)
|
||||
- 6 × `dj_*` (corporate Dow Jones brand)
|
||||
- 6 × `mw_*` (MarketWatch sub-brand)
|
||||
- 6 × `wsj_*` (WSJ static)
|
||||
- 4 × `wsj_podcast_*` (WSJ podcast variants)
|
||||
|
||||
## Restoring
|
||||
|
||||
If Dow Jones returns:
|
||||
|
||||
1. Move `profiles/*.json` back to `backend/profiles/`.
|
||||
2. Move every `visual_qc_apps/<name>/` directory back to `backend/visual_qc_apps/<name>/`.
|
||||
3. Move `CLAUDE_DOW_JONES.md` back to the repo root.
|
||||
4. Re-add the client entry to `backend/client_config.py`:
|
||||
|
||||
```python
|
||||
'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'
|
||||
},
|
||||
```
|
||||
|
||||
5. Re-add the Dow Jones row to the client table in `CLAUDE.md` (repo root).
|
||||
6. Add `'dow_jones_static','marketwatch_static','wsj_static','wsj_podcast'` back to the inline profile list in the `CLAUDE.md` pre-session checklist.
|
||||
7. Restart the server.
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Stage the README and confirm the full Task 1 staging area**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add backend/_archive/dow_jones/README.md
|
||||
git status --short | sort
|
||||
```
|
||||
|
||||
Expected (28 staged changes — 1 new README, 1 renamed doc, 4 renamed profiles, 22 renamed check dirs):
|
||||
- 1 line starting with `A` for the new README
|
||||
- 27 lines starting with `R` (renames)
|
||||
|
||||
If the rename count is off, stop and investigate.
|
||||
|
||||
- [ ] **Step 9: Commit Task 1**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(dow-jones): archive profiles, checks, and per-client doc
|
||||
|
||||
Moves the Dow Jones / MarketWatch / WSJ profile JSONs (4), check apps
|
||||
(22), and CLAUDE_DOW_JONES.md into backend/_archive/dow_jones/. All
|
||||
moves use git mv so history follows. Adds a restore-instructions
|
||||
README. No loader changes needed — the archive lives outside the
|
||||
scanned directories.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git log --oneline -2
|
||||
```
|
||||
|
||||
Expected: new commit on top, message as above.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Remove `dow_jones` from `client_config.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/client_config.py:37-42`
|
||||
|
||||
- [ ] **Step 1: Confirm the current Dow Jones block**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
sed -n '36,43p' backend/client_config.py
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```python
|
||||
},
|
||||
'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': {
|
||||
```
|
||||
|
||||
If the block doesn't match this exactly, stop — the file has drifted since the design and the spec needs updating.
|
||||
|
||||
- [ ] **Step 2: Remove the `dow_jones` block**
|
||||
|
||||
Use the Edit tool. `old_string`:
|
||||
```
|
||||
'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': {
|
||||
```
|
||||
|
||||
`new_string`:
|
||||
```
|
||||
'honda': {
|
||||
```
|
||||
|
||||
(Removes the entire `dow_jones` dict block including its trailing comma; the `'honda':` line is preserved as the anchor.)
|
||||
|
||||
- [ ] **Step 3: Verify client_config loads and Dow Jones is gone**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
from client_config import get_all_clients
|
||||
clients = get_all_clients()
|
||||
assert 'dow_jones' not in clients, 'FAIL: dow_jones still in client_config'
|
||||
assert len(clients) == 9, f'FAIL: expected 9 clients, got {len(clients)}'
|
||||
for cid, c in clients.items():
|
||||
print(f'OK {c[\"display_name\"]}: {c[\"profiles\"]}')
|
||||
" && cd ..
|
||||
```
|
||||
|
||||
Expected: 9 `OK <name>: [...]` lines, no AssertionError.
|
||||
|
||||
- [ ] **Step 4: Syntax check**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -m py_compile backend/client_config.py
|
||||
echo "exit code: $?"
|
||||
```
|
||||
|
||||
Expected: `exit code: 0` (no compile output).
|
||||
|
||||
- [ ] **Step 5: Commit Task 2**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add backend/client_config.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(dow-jones): remove client_config entry
|
||||
|
||||
Drops the 'dow_jones' block from CLIENT_PROFILES. After this, the
|
||||
client picker no longer renders Dow Jones; the four archived profiles
|
||||
are unreachable from user flows. Nine clients remain.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update main `CLAUDE.md`
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (repo root) — client/profile table (~line 102) and pre-session profile-load checklist (~line 200)
|
||||
|
||||
- [ ] **Step 1: Confirm the Dow Jones row in the client table**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "Dow Jones" CLAUDE.md
|
||||
```
|
||||
|
||||
Expected: at least one line referencing Dow Jones in the table around line 102. Note the exact line number — confirms the file hasn't drifted.
|
||||
|
||||
- [ ] **Step 2: Remove the Dow Jones row from the client table**
|
||||
|
||||
Use the Edit tool. `old_string`:
|
||||
```
|
||||
| Dow Jones | `dow_jones_static` (5), `marketwatch_static` (6), `wsj_static` (6), `wsj_podcast` (7) | [CLAUDE_DOW_JONES.md](CLAUDE_DOW_JONES.md) |
|
||||
```
|
||||
|
||||
`new_string`: (empty)
|
||||
|
||||
If Edit reports the string is not unique or not found, stop and grep for the actual current line — the row format may have drifted.
|
||||
|
||||
- [ ] **Step 3: Remove orphan blank line if one was left behind**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "^$" CLAUDE.md | head -20
|
||||
sed -n '98,108p' CLAUDE.md
|
||||
```
|
||||
|
||||
Visually confirm the table reads `Boots | ... | ...` then `AXA | ... | ...` with no double-blank between them. If there's a stray empty line where the Dow Jones row was, remove it with Edit on the surrounding context.
|
||||
|
||||
- [ ] **Step 4: Remove the four Dow Jones profile names from the pre-session checklist**
|
||||
|
||||
Use the Edit tool. `old_string`:
|
||||
```
|
||||
for p in ['general_check','static_general','unilever_key_visual','unilever_packaging','diageo_key_visual','diageo_packaging','loreal_static','amazon_static','boots_static','boots_ppack','inclusive_accessibility','dow_jones_static','marketwatch_static','wsj_static','wsj_podcast','video_general','axa_policy_document','axa_policy_document_diff','axa_accessibility']:
|
||||
```
|
||||
|
||||
`new_string`:
|
||||
```
|
||||
for p in ['general_check','static_general','unilever_key_visual','unilever_packaging','diageo_key_visual','diageo_packaging','loreal_static','amazon_static','boots_static','boots_ppack','inclusive_accessibility','video_general','axa_policy_document','axa_policy_document_diff','axa_accessibility']:
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the edits**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -c "Dow Jones" CLAUDE.md
|
||||
grep -c "dow_jones_static\|marketwatch_static\|wsj_static\|wsj_podcast" CLAUDE.md
|
||||
```
|
||||
|
||||
Expected: both `0`.
|
||||
|
||||
- [ ] **Step 6: Commit Task 3**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(dow-jones): update CLAUDE.md after offboarding
|
||||
|
||||
Removes the Dow Jones row from the client/profile table and the four
|
||||
Dow Jones profile names from the pre-session profile-load checklist
|
||||
so the documentation matches the post-archive code state.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git log --oneline -4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Full verification
|
||||
|
||||
**Files:** none modified. This task only runs the spec's verification commands plus a server-boot smoke test. No commit.
|
||||
|
||||
- [ ] **Step 1: Syntax-check the touched Python files**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -m py_compile backend/api_server.py backend/client_config.py backend/profile_config.py
|
||||
echo "exit code: $?"
|
||||
```
|
||||
|
||||
Expected: `exit code: 0`.
|
||||
|
||||
- [ ] **Step 2: Client config loads correctly**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
from client_config import get_all_clients
|
||||
clients = get_all_clients()
|
||||
assert 'dow_jones' not in clients, 'dow_jones still in client_config'
|
||||
assert len(clients) == 9, f'expected 9 clients, got {len(clients)}'
|
||||
for cid, c in clients.items(): print(f'OK {c[\"display_name\"]}: {c[\"profiles\"]}')
|
||||
" && cd ..
|
||||
```
|
||||
|
||||
Expected: 9 `OK` lines, no assertion errors.
|
||||
|
||||
- [ ] **Step 3: Remaining profiles still load**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
from profile_config import get_profile
|
||||
for p in ['general_check','static_general','unilever_key_visual','unilever_packaging',
|
||||
'diageo_key_visual','diageo_packaging','loreal_static','amazon_static',
|
||||
'boots_static','boots_ppack','inclusive_accessibility','video_general',
|
||||
'axa_policy_document','axa_policy_document_diff','axa_accessibility']:
|
||||
prof = get_profile(p); print(f'OK {prof.name} ({len(prof.get_enabled_checks())} checks)')
|
||||
" && cd ..
|
||||
```
|
||||
|
||||
Expected: 15 `OK <name> (N checks)` lines.
|
||||
|
||||
- [ ] **Step 4: Archived profiles are no longer loadable**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
from profile_config import get_profile
|
||||
for archived in ['dow_jones_static','marketwatch_static','wsj_static','wsj_podcast']:
|
||||
try:
|
||||
get_profile(archived)
|
||||
print(f'FAIL: {archived} still loadable')
|
||||
except Exception as e:
|
||||
print(f'OK archived ({archived}): {type(e).__name__}')
|
||||
" && cd ..
|
||||
```
|
||||
|
||||
Expected: 4 `OK archived (<name>): <ExceptionType>` lines. No FAIL.
|
||||
|
||||
- [ ] **Step 5: Repo-level test-system script**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
./scripts/test-system.sh
|
||||
```
|
||||
|
||||
Expected: passes (per CLAUDE.md, this runs syntax + imports + profile load). If it complains about a Dow Jones profile, the move was incomplete — investigate.
|
||||
|
||||
- [ ] **Step 6: Local server smoke test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
./scripts/run-local.sh &
|
||||
SERVER_PID=$!
|
||||
sleep 4
|
||||
curl -sf http://localhost:7183/health && echo " [health OK]"
|
||||
curl -s http://localhost:7183/api/profiles 2>&1 | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
names = [p.get('id') or p.get('name') for p in data] if isinstance(data, list) else []
|
||||
banned = [n for n in names if n and any(x in n for x in ['dow_jones','marketwatch','wsj_'])]
|
||||
print(f'profile count: {len(names)}, dow-jones leftovers: {banned}')
|
||||
assert not banned, 'Dow Jones profile still surfaced via API'
|
||||
"
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: `[health OK]` and `dow-jones leftovers: []`.
|
||||
|
||||
If any verification step fails, **do not proceed**. Investigate root cause, fix it, re-run the verification chain from Step 1. Do not push a broken branch.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Push branch and open PR — requires user confirmation
|
||||
|
||||
**Files:** none modified. Remote-affecting actions only.
|
||||
|
||||
**This task creates remote state. Each step requires explicit user confirmation before executing.**
|
||||
|
||||
- [ ] **Step 1: Show the user what will be pushed**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git log --oneline origin/develop..HEAD
|
||||
git diff --stat origin/develop..HEAD | tail -5
|
||||
```
|
||||
|
||||
Show output to user. Ask: "Push `feature/remove-dow-jones` to origin? (Y/n)"
|
||||
|
||||
Do not proceed until user explicitly approves.
|
||||
|
||||
- [ ] **Step 2: Push the branch (after user approval)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git push -u origin feature/remove-dow-jones
|
||||
```
|
||||
|
||||
Expected: branch created on remote, tracking set.
|
||||
|
||||
- [ ] **Step 3: Draft the PR body and confirm with user**
|
||||
|
||||
Show the user this draft and ask "Open PR with this body? (Y/n)":
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
- Removes Dow Jones from Visual AI QC (client offboarded).
|
||||
- Archives 4 profile JSONs and 22 check apps under `backend/_archive/dow_jones/` (recoverable by moving folders back; no git surgery needed).
|
||||
- Drops the `dow_jones` entry from `client_config.py` and the corresponding rows/lists from `CLAUDE.md`.
|
||||
- Implements design in `docs/superpowers/specs/2026-05-14-phase1-remove-dow-jones-design.md`.
|
||||
|
||||
## Test plan
|
||||
- [x] `python -m py_compile backend/api_server.py backend/client_config.py backend/profile_config.py`
|
||||
- [x] `client_config.get_all_clients()` returns 9 clients, no `dow_jones`
|
||||
- [x] All 15 non-Dow-Jones profiles load via `profile_config.get_profile`
|
||||
- [x] All 4 archived profiles raise on load (proof archive isn't being scanned)
|
||||
- [x] `./scripts/test-system.sh` passes
|
||||
- [x] Local server boots, `/health` returns 200, `/api/profiles` has no Dow Jones leftovers
|
||||
|
||||
## Not touched
|
||||
- `backend/usage_logs/*.jsonl` (audit history, immutable)
|
||||
- Uploaded media plans and brand-guideline PDFs (user data)
|
||||
- `backend/user_access.json` on dev/prod (stale `dow_jones` grants harmless; client no longer in picker)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Open the PR (after user approval)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
gh pr create --base develop --title "Phase 1: remove Dow Jones" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Removes Dow Jones from Visual AI QC (client offboarded).
|
||||
- Archives 4 profile JSONs and 22 check apps under `backend/_archive/dow_jones/` (recoverable by moving folders back; no git surgery needed).
|
||||
- Drops the `dow_jones` entry from `client_config.py` and the corresponding rows/lists from `CLAUDE.md`.
|
||||
- Implements design in `docs/superpowers/specs/2026-05-14-phase1-remove-dow-jones-design.md`.
|
||||
|
||||
## Test plan
|
||||
- [x] `python -m py_compile backend/api_server.py backend/client_config.py backend/profile_config.py`
|
||||
- [x] `client_config.get_all_clients()` returns 9 clients, no `dow_jones`
|
||||
- [x] All 15 non-Dow-Jones profiles load via `profile_config.get_profile`
|
||||
- [x] All 4 archived profiles raise on load (proof archive isn't being scanned)
|
||||
- [x] `./scripts/test-system.sh` passes
|
||||
- [x] Local server boots, `/health` returns 200, `/api/profiles` has no Dow Jones leftovers
|
||||
|
||||
## Not touched
|
||||
- `backend/usage_logs/*.jsonl` (audit history, immutable)
|
||||
- Uploaded media plans and brand-guideline PDFs (user data)
|
||||
- `backend/user_access.json` on dev/prod (stale `dow_jones` grants harmless; client no longer in picker)
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: `gh` prints the PR URL. Report the URL to the user.
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope (deliberately deferred)
|
||||
|
||||
- Deploying to dev server (`./backend/scripts/deploy.sh dev`) — wait for PR review + merge.
|
||||
- Cleaning up `backend/user_access.json` on live servers — harmless to leave; revisit if it ever causes confusion.
|
||||
- Updating `MEMORY.md` (`project_state.md` mentions 10 clients) — opportunistic update after merge, not part of this PR.
|
||||
- Promoting develop → main → prod — tracked separately; this PR only targets develop.
|
||||
|
|
@ -1,786 +0,0 @@
|
|||
# HP Onboarding — Cycle 1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement the `hp_copy_review` check and its supporting infrastructure per `docs/superpowers/specs/2026-05-17-hp-cycle-1-onboarding-design.md`, replacing the deprecated `hp-copy` PHP/Make.com POC.
|
||||
|
||||
**Architecture:** New `excel_processor.py` mirrors `pdf_processor.py` to convert HP Source Messaging Excels into structured Markdown summaries at upload time. A single new `hp_copy_review` QC check assembles those summaries + media-plan language metadata + the asset image into one Gemini prompt and returns a structured findings list. HP gets a real client config entry, a dedicated profile, and routing for `.xlsx` uploads through the existing `/api/brand_guidelines` endpoint.
|
||||
|
||||
**Tech Stack:**
|
||||
- openpyxl 3.x (existing dep, used by `media_plan_processor.py`)
|
||||
- Gemini 2.5 Pro via `llm_config.py` (existing)
|
||||
- Existing reference-asset / brand-guidelines flow
|
||||
- Existing media-plan processor
|
||||
- No new external dependencies
|
||||
|
||||
**Branch:** `feature/hp-cycle-1-onboarding` from `develop`.
|
||||
|
||||
**Testing posture:** This project does not use pytest. Verification matches `backend/scripts/test-system.sh`: `py_compile`, import checks, profile-load tests, and real-asset smoke runs on the dev server. Inline `python3 -c "..."` snippets stand in for unit tests where helpful.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New files:**
|
||||
- `backend/excel_processor.py` — Excel ingestion + Gemini summarisation
|
||||
- `backend/profiles/hp_copy_review.json` — new profile
|
||||
- `backend/visual_qc_apps/hp_copy_review/app.py` — new QC check
|
||||
- `backend/visual_qc_apps/hp_copy_review/__init__.py` — empty module marker
|
||||
|
||||
**Modified files:**
|
||||
- `backend/client_config.py` — HP entry promoted from placeholder
|
||||
- `backend/api_server.py` — `.xlsx` dispatch on `/api/brand_guidelines` POST + findings-table rendering in both HTML generators
|
||||
- `backend/media_plan_processor.py` — `language` column extraction + metadata surfacing
|
||||
- `CLAUDE.md` — HP row updated from "_scope pending_" to the new doc reference (small)
|
||||
|
||||
**Test fixtures (placed manually on disk, not committed):**
|
||||
- `backend/tests/fixtures/hp/messi_core_source_messaging.xlsx`
|
||||
- `backend/tests/fixtures/hp/messi_mainstream_source_messaging.xlsx`
|
||||
- `backend/tests/fixtures/hp/gaston_source_messaging.xlsx`
|
||||
|
||||
The user-provided originals live at `/Users/nickviljoen/Desktop/AI_QC_Bitbucket/hp/recieved_docs/excel/` — those get *copied* (not symlinked) into `backend/tests/fixtures/hp/` for repeatable local verification. The directory is gitignored.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Excel processor module
|
||||
|
||||
Implement `excel_processor.py` mirroring `pdf_processor.py`. This is the most foundational change and the largest single module of new code.
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/excel_processor.py`
|
||||
- Create: `backend/tests/fixtures/hp/` (gitignored)
|
||||
- Modify: `.gitignore` (add `backend/tests/fixtures/`)
|
||||
|
||||
- [ ] **Step 1.1: Set up the fixtures directory**
|
||||
|
||||
```bash
|
||||
mkdir -p backend/tests/fixtures/hp
|
||||
cp '/Users/nickviljoen/Desktop/AI_QC_Bitbucket/hp/recieved_docs/excel/26C2 Messi Core HP OmniDesk Mini Desktop PC Source Messaging 04-10 (1).xlsx' backend/tests/fixtures/hp/messi_core.xlsx
|
||||
cp '/Users/nickviljoen/Desktop/AI_QC_Bitbucket/hp/recieved_docs/excel/26C2 Messi Mainstream HP OmniDesk Mini Desktop PC Source Messaging 04-10 (1).xlsx' backend/tests/fixtures/hp/messi_mainstream.xlsx
|
||||
cp '/Users/nickviljoen/Desktop/AI_QC_Bitbucket/hp/recieved_docs/excel/HP AluminiumBook Source Messaging - Gaston 05-06.xlsx' backend/tests/fixtures/hp/gaston.xlsx
|
||||
ls backend/tests/fixtures/hp/
|
||||
```
|
||||
|
||||
Expected: three `.xlsx` files listed.
|
||||
|
||||
- [ ] **Step 1.2: Add gitignore rule for fixtures**
|
||||
|
||||
Add to `.gitignore` near the existing legacy-env block:
|
||||
|
||||
```
|
||||
# Local test fixtures (real HP Source Messaging files; not for commit)
|
||||
backend/tests/fixtures/
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3: Read `pdf_processor.py` as the pattern source**
|
||||
|
||||
```bash
|
||||
wc -l backend/pdf_processor.py
|
||||
```
|
||||
|
||||
Read the file end-to-end. Identify: public surface (`process_pdf_file`), helper for raw extraction, helper for LLM summarisation, file path conventions (`brand_guidelines/files/{file_id}_summary.txt`), error handling shape, retry pattern, return tuple `(summary_text, summary_path)`.
|
||||
|
||||
- [ ] **Step 1.4: Create `excel_processor.py` skeleton**
|
||||
|
||||
Create `backend/excel_processor.py` with:
|
||||
|
||||
```python
|
||||
"""Excel reference-asset processor for HP Source Messaging files.
|
||||
|
||||
Mirrors pdf_processor.py: openpyxl extracts raw cell content from
|
||||
every sheet, Gemini summarises the result into structured Markdown
|
||||
under brand_guidelines/files/{file_id}_summary.md. The check
|
||||
hp_copy_review pulls that Markdown into its prompt at QC time.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Tuple
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from llm_config import call_gemini_text # adjust to actual export name
|
||||
|
||||
BRAND_GUIDELINES_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'brand_guidelines', 'files'
|
||||
)
|
||||
|
||||
# Cap raw extraction at ~50K chars to keep the summary prompt bounded.
|
||||
# A 30-row, 12-column workbook is ~10-15K chars in practice; this leaves
|
||||
# headroom for HP's larger source files without blowing the prompt budget.
|
||||
_RAW_EXTRACTION_CAP = 50_000
|
||||
|
||||
|
||||
def process_excel_file(file_path: str, file_id: str) -> Tuple[str, str]:
|
||||
"""Extract + summarise an HP Source Messaging Excel.
|
||||
|
||||
Returns (summary_text, summary_path). Saves the summary as
|
||||
{file_id}_summary.md under BRAND_GUIDELINES_DIR. Never raises —
|
||||
on failure, writes a degraded summary containing the raw extraction
|
||||
so the reference asset is still usable, and returns that.
|
||||
"""
|
||||
raw_text = _extract_workbook_text(file_path)
|
||||
try:
|
||||
summary = _summarise_with_gemini(raw_text, os.path.basename(file_path))
|
||||
except Exception as e:
|
||||
summary = (
|
||||
f"# {os.path.basename(file_path)} (degraded — summary failed)\n\n"
|
||||
f"Gemini summarisation failed: {type(e).__name__}: {e}\n\n"
|
||||
f"## Raw extraction\n\n```\n{raw_text}\n```\n"
|
||||
)
|
||||
|
||||
os.makedirs(BRAND_GUIDELINES_DIR, exist_ok=True)
|
||||
summary_path = os.path.join(BRAND_GUIDELINES_DIR, f"{file_id}_summary.md")
|
||||
with open(summary_path, 'w', encoding='utf-8') as f:
|
||||
f.write(summary)
|
||||
return summary, summary_path
|
||||
```
|
||||
|
||||
- [ ] **Step 1.5: Implement `_extract_workbook_text`**
|
||||
|
||||
Append:
|
||||
|
||||
```python
|
||||
def _extract_workbook_text(file_path: str) -> str:
|
||||
"""Read every sheet, dump as 'Sheet: <name>\\n<tab-aligned rows>\\n\\n'."""
|
||||
wb = load_workbook(file_path, data_only=True, read_only=True)
|
||||
parts = []
|
||||
total_chars = 0
|
||||
for sheet in wb.worksheets:
|
||||
parts.append(f"Sheet: {sheet.title}\n")
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
# Skip rows where every cell is None/empty
|
||||
if not any((c is not None and str(c).strip()) for c in row):
|
||||
continue
|
||||
line = '\t'.join(('' if c is None else str(c)) for c in row)
|
||||
parts.append(line + '\n')
|
||||
total_chars += len(line) + 1
|
||||
if total_chars >= _RAW_EXTRACTION_CAP:
|
||||
parts.append(f"\n[truncated — exceeded {_RAW_EXTRACTION_CAP}-char cap]\n")
|
||||
return ''.join(parts)
|
||||
parts.append('\n')
|
||||
wb.close()
|
||||
return ''.join(parts)
|
||||
```
|
||||
|
||||
- [ ] **Step 1.6: Implement `_summarise_with_gemini`**
|
||||
|
||||
Append:
|
||||
|
||||
```python
|
||||
_SYSTEM_PROMPT = """You're processing an HP Source Messaging Excel into a structured Markdown reference. Output these sections exactly, in this order:
|
||||
|
||||
## Product / Variant
|
||||
(brand, product line, variant if any — e.g. "HP OmniDesk Mini — Core")
|
||||
|
||||
## Key Selling Points (KSPs)
|
||||
For each KSP: heading, value proposition, supporting body copy, message-length variants (ultra-short / short / medium / long if present in the source).
|
||||
|
||||
## Disclaimers / Footnotes
|
||||
Numbered list, exact wording, what claim each footnote anchors to.
|
||||
|
||||
## Approved Brand and Product Names
|
||||
Exact spellings, including trademark glyphs (™, ®, ©).
|
||||
|
||||
## Variant Notes / Watch-outs
|
||||
Anything explicitly marked variant-specific (e.g. "Mainstream only", "Core only", "must not appear in entry tier").
|
||||
|
||||
## Verboten Phrasing
|
||||
Any explicitly disallowed or deprecated phrasing called out in the source.
|
||||
|
||||
Be exhaustive but concise. Quote exactly where the source is explicit. If a section has no content in this source, write 'None specified' under it — do not omit the section heading."""
|
||||
|
||||
|
||||
def _summarise_with_gemini(raw_text: str, source_filename: str) -> str:
|
||||
user_prompt = (
|
||||
f"Source filename: {source_filename}\n\n"
|
||||
f"Raw cell content:\n\n```\n{raw_text}\n```"
|
||||
)
|
||||
# call_gemini_text is the existing text-only Gemini wrapper in llm_config.
|
||||
# If the actual export name differs, adjust in Step 1.7 verification.
|
||||
return call_gemini_text(
|
||||
system_prompt=_SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
model='gemini-2.5-pro',
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 1.7: Verify llm_config exports a usable text-only Gemini wrapper**
|
||||
|
||||
```bash
|
||||
grep -nE "def (call_gemini|gemini_text|generate.*gemini)" backend/llm_config.py | head -20
|
||||
```
|
||||
|
||||
If `call_gemini_text` doesn't exist under that name, find the closest analogue (look at how `pdf_processor.py` calls Gemini) and update the import + call site in `excel_processor.py` accordingly.
|
||||
|
||||
- [ ] **Step 1.8: Syntax + import verification**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -m py_compile excel_processor.py && python3 -c "import excel_processor; print('OK', excel_processor.BRAND_GUIDELINES_DIR)"
|
||||
```
|
||||
|
||||
Expected: `OK <path>/brand_guidelines/files`
|
||||
|
||||
- [ ] **Step 1.9: Run the processor against the Messi-Core fixture**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, '.')
|
||||
from excel_processor import process_excel_file
|
||||
summary, path = process_excel_file('tests/fixtures/hp/messi_core.xlsx', 'test-messi-core')
|
||||
print('summary_path:', path)
|
||||
print('summary_len:', len(summary))
|
||||
print('first 800 chars:')
|
||||
print(summary[:800])
|
||||
"
|
||||
```
|
||||
|
||||
Expected: summary is 1500–4000 chars, contains `## Key Selling Points`, `## Disclaimers`, `## Approved Brand and Product Names`, and at least one KSP-level content snippet referencing "OmniDesk" or "Mini".
|
||||
|
||||
- [ ] **Step 1.10: Commit Task 1**
|
||||
|
||||
```bash
|
||||
git add backend/excel_processor.py .gitignore
|
||||
git commit -m "feat(excel-processor): add openpyxl + Gemini summary pipeline for HP Source Messaging
|
||||
|
||||
Mirrors pdf_processor.py — public process_excel_file() reads any HP
|
||||
Source Messaging Excel, extracts cells via openpyxl (skipping empty
|
||||
rows, capped at 50K chars), and summarises into structured Markdown
|
||||
via Gemini 2.5 Pro. Output saved as brand_guidelines/files/{file_id}_summary.md.
|
||||
|
||||
On Gemini failure the processor writes a degraded summary containing
|
||||
the raw extraction so the reference asset stays usable. Test fixtures
|
||||
(real HP Excels) live under backend/tests/fixtures/hp/ and are gitignored."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `.xlsx` dispatch on the reference asset upload endpoint
|
||||
|
||||
Wire `excel_processor.process_excel_file` into the `/api/brand_guidelines` POST handler at `backend/api_server.py:4771` so `.xlsx` uploads route correctly.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/api_server.py` (around the existing `/api/brand_guidelines` POST handler near line 4771)
|
||||
|
||||
- [ ] **Step 2.1: Read the existing handler to find the PDF dispatch**
|
||||
|
||||
```bash
|
||||
sed -n '4760,4900p' backend/api_server.py
|
||||
```
|
||||
|
||||
Identify: where the extension is checked, where `pdf_processor.process_pdf_file` is called, and what's returned to the client.
|
||||
|
||||
- [ ] **Step 2.2: Add the `.xlsx` branch**
|
||||
|
||||
Edit the POST handler to dispatch by extension. The exact change depends on the existing code shape — pattern is:
|
||||
|
||||
- Where the handler currently checks for `.pdf` and calls `pdf_processor.process_pdf_file(...)`, add an `elif filename.lower().endswith('.xlsx')` branch that imports `excel_processor` and calls `excel_processor.process_excel_file(...)` with the same arg signature.
|
||||
- The DB record / response shape should be identical to the PDF path — same `file_id`, same `status`, same return JSON.
|
||||
- Cover image: PDF has one; Excel doesn't. If the DB record assigns a `cover_path`, set it to `None` for Excels.
|
||||
|
||||
- [ ] **Step 2.3: Syntax + import verification**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -m py_compile api_server.py && python3 -c "import api_server; print('api_server OK')"
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Commit Task 2**
|
||||
|
||||
```bash
|
||||
git add backend/api_server.py
|
||||
git commit -m "feat(brand-guidelines): route .xlsx uploads to excel_processor
|
||||
|
||||
The /api/brand_guidelines POST handler now dispatches by extension:
|
||||
.pdf → pdf_processor.process_pdf_file (existing), .xlsx →
|
||||
excel_processor.process_excel_file (new). Same DB record shape;
|
||||
cover image is null for Excel since there's no first-page analogue."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Media plan `language` column
|
||||
|
||||
Add `language` to the media-plan column extraction and surface it into the prompt context.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/media_plan_processor.py`
|
||||
|
||||
- [ ] **Step 3.1: Locate the column-extraction logic**
|
||||
|
||||
```bash
|
||||
grep -n -E "country|placement|vendor|dimensions" backend/media_plan_processor.py | head -10
|
||||
```
|
||||
|
||||
These are the existing matched-row metadata fields. The `language` field will live alongside them.
|
||||
|
||||
- [ ] **Step 3.2: Add `language` to the case-insensitive header match list**
|
||||
|
||||
Edit the column-mapping section to recognise `Language` / `language` / `LANGUAGE` headers and store the value in the matched-row dict under the key `language`.
|
||||
|
||||
- [ ] **Step 3.3: Surface `language` in the prompt context block**
|
||||
|
||||
Locate where the matched-row dict is rendered as text injected into check prompts (the function that returns the "media plan context" string used by `process_single_check`). Add a line:
|
||||
|
||||
```python
|
||||
if row.get('language'):
|
||||
lines.append(f"Language: {row['language']}")
|
||||
```
|
||||
|
||||
— preserving the existing structure (no line if absent).
|
||||
|
||||
- [ ] **Step 3.4: Syntax + import verification**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -m py_compile media_plan_processor.py && python3 -c "import media_plan_processor; print('OK')"
|
||||
```
|
||||
|
||||
- [ ] **Step 3.5: Quick functional test with a synthetic plan**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
# Mock test: build a minimal row dict with a language field and confirm the
|
||||
# prompt-context formatter emits 'Language: <value>'. Exact function name to
|
||||
# locate during Step 3.3 — adjust below.
|
||||
from media_plan_processor import format_matched_row_for_prompt # adjust if named differently
|
||||
row = {'country': 'UK', 'language': 'UK English', 'placement': 'eTail tile'}
|
||||
print(format_matched_row_for_prompt(row))
|
||||
"
|
||||
```
|
||||
|
||||
Expected: output includes a line `Language: UK English`.
|
||||
|
||||
- [ ] **Step 3.6: Commit Task 3**
|
||||
|
||||
```bash
|
||||
git add backend/media_plan_processor.py
|
||||
git commit -m "feat(media-plan): extract and surface 'language' column
|
||||
|
||||
Adds case-insensitive 'language' header recognition to the media-plan
|
||||
column mapper. When present in a matched row, the value flows into
|
||||
the prompt context block as 'Language: <value>'. Absent → no line
|
||||
(graceful no-op for clients whose plans don't include the field).
|
||||
Enables multilingual support for hp_copy_review (Cycle 1) and any
|
||||
future check that wants to reason about asset language."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: HP client config + profile
|
||||
|
||||
Promote HP from placeholder. Create the `hp_copy_review` profile JSON. Ensure the profile loader picks it up.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/client_config.py`
|
||||
- Create: `backend/profiles/hp_copy_review.json`
|
||||
|
||||
- [ ] **Step 4.1: Update the HP entry in `CLIENT_PROFILES`**
|
||||
|
||||
Edit `backend/client_config.py`. Replace the existing `'hp'` entry with:
|
||||
|
||||
```python
|
||||
'hp': {
|
||||
'name': 'HP',
|
||||
'profiles': ['hp_copy_review', 'static_general', 'video_general'],
|
||||
'display_name': 'HP',
|
||||
'description': 'HP marketing copy QC graded against canonical Source Messaging',
|
||||
'default_profile': 'hp_copy_review',
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: Create the profile JSON**
|
||||
|
||||
Create `backend/profiles/hp_copy_review.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "HP Copy Review",
|
||||
"description": "Marketing copy graded against canonical HP Source Messaging",
|
||||
"mode": "asset",
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["hp"],
|
||||
"checks": {
|
||||
"hp_copy_review": {
|
||||
"weight": 10.0,
|
||||
"llm": "gemini",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3: Verify client config**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
from client_config import get_client_profiles, get_default_profile
|
||||
print('profiles:', get_client_profiles('hp'))
|
||||
print('default:', get_default_profile('hp'))
|
||||
"
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
profiles: ['hp_copy_review', 'static_general', 'video_general']
|
||||
default: hp_copy_review
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4: Verify profile load**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
from profile_config import get_profile
|
||||
p = get_profile('hp_copy_review')
|
||||
print('name:', p.name)
|
||||
print('mode:', getattr(p, 'mode', 'asset'))
|
||||
print('enabled checks:', p.get_enabled_checks())
|
||||
print('strict_grade:', getattr(p, 'strict_grade', False))
|
||||
"
|
||||
```
|
||||
|
||||
Expected: profile loads, mode is `asset`, enabled_checks lists `['hp_copy_review']`. (The check itself doesn't exist yet → may emit a "Loaded profile" line but the check loader fails for `hp_copy_review`; that's expected at this task boundary.)
|
||||
|
||||
- [ ] **Step 4.5: Commit Task 4**
|
||||
|
||||
```bash
|
||||
git add backend/client_config.py backend/profiles/hp_copy_review.json
|
||||
git commit -m "feat(hp): promote HP client + add hp_copy_review profile
|
||||
|
||||
HP is no longer a placeholder. The client gets a new hp_copy_review
|
||||
profile (single weighted check, client-specific visibility) as its
|
||||
default, plus the generic static_general and video_general profiles
|
||||
it already had visibility into."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `hp_copy_review` check module
|
||||
|
||||
The actual QC check — single LLM call per asset.
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/visual_qc_apps/hp_copy_review/__init__.py` (empty)
|
||||
- Create: `backend/visual_qc_apps/hp_copy_review/app.py`
|
||||
|
||||
- [ ] **Step 5.1: Read `flask_app_template.py` and a comparable real check**
|
||||
|
||||
```bash
|
||||
ls backend/flask_app_template.py 2>/dev/null && wc -l backend/flask_app_template.py
|
||||
ls backend/visual_qc_apps/boots_tandc_wording/app.py && wc -l backend/visual_qc_apps/boots_tandc_wording/app.py
|
||||
```
|
||||
|
||||
Read both. The boots_tandc_wording check is the closest analogue (copy-against-reference, image input, structured findings output). Use it as the implementation pattern.
|
||||
|
||||
- [ ] **Step 5.2: Create the directory + empty `__init__.py`**
|
||||
|
||||
```bash
|
||||
mkdir -p backend/visual_qc_apps/hp_copy_review
|
||||
touch backend/visual_qc_apps/hp_copy_review/__init__.py
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Create `app.py` with the standard check skeleton**
|
||||
|
||||
Copy the structure from `boots_tandc_wording/app.py` (Flask blueprint pattern, `run_check(...)` or equivalent entry point, the reference-asset summary injection, the media-plan context injection). Adapt the prompt to:
|
||||
|
||||
```
|
||||
You are a copy reviewer for HP marketing materials. Compare the
|
||||
marketing asset against the canonical Source Messaging provided.
|
||||
|
||||
PRODUCT LANGUAGE: <from media plan, or "not specified">
|
||||
|
||||
CANONICAL SOURCE MESSAGING:
|
||||
<one or more Markdown summaries from attached Excel reference assets,
|
||||
concatenated, each preceded by a header like "--- File: messi_core.xlsx ---">
|
||||
|
||||
MARKETING ASSET:
|
||||
[image]
|
||||
|
||||
For every claim, headline, body line, disclaimer, footnote, spec
|
||||
call-out, and brand mention visible on the asset, evaluate against
|
||||
the canonical source. Output a JSON object with this shape:
|
||||
|
||||
{
|
||||
"score": <number 0-10>,
|
||||
"summary": "<one-paragraph headline finding>",
|
||||
"findings": [
|
||||
{
|
||||
"priority": "high" | "medium" | "low",
|
||||
"category": "ksp" | "disclaimer" | "spec" | "variant" | "tone" | "brand-name" | "language" | "other",
|
||||
"quote": "<exact quote from the asset>",
|
||||
"issue": "<what's wrong>",
|
||||
"suggested_fix": "<what it should say, citing the canonical source>",
|
||||
"source_reference": "<where in source messaging this comes from>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- If no Source Messaging is attached, return {"score": 0, "summary": "No HP Source Messaging reference was attached — cannot grade copy without a canonical source.", "findings": []}
|
||||
- High-priority findings weight the score most heavily
|
||||
- Empty findings (clean asset) is a valid result; score 9-10
|
||||
- Return ONLY the JSON object, no surrounding prose
|
||||
```
|
||||
|
||||
- [ ] **Step 5.4: Implement response parsing**
|
||||
|
||||
The check function must parse the LLM's JSON response. Handle:
|
||||
- Valid JSON with the expected shape → extract `score`, `summary`, `findings` and return them in the standard check result shape (`{'score': ..., 'response': ..., 'findings': ...}` — match the existing checks' return shape so the report renderer can pick up `findings` later).
|
||||
- Malformed JSON → score 0, response = raw LLM text, findings = `[]`, summary = "Failed to parse check output".
|
||||
- The `findings` array gets attached to the check result dict so the report renderer in Task 6 can detect it.
|
||||
|
||||
- [ ] **Step 5.5: Syntax + import + profile load verification**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -m py_compile visual_qc_apps/hp_copy_review/app.py && python3 -c "
|
||||
from profile_config import get_profile
|
||||
from app_discovery import discover_qc_apps # or the actual loader path
|
||||
apps = discover_qc_apps()
|
||||
print('hp_copy_review in apps:', 'hp_copy_review' in apps)
|
||||
p = get_profile('hp_copy_review')
|
||||
print('profile enabled checks:', p.get_enabled_checks())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: `hp_copy_review in apps: True`, profile lists it as enabled.
|
||||
|
||||
- [ ] **Step 5.6: Dry-run prompt-assembly test (no LLM call)**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
# Smoke test: instantiate the check, call its prompt-assembly helper
|
||||
# (without invoking Gemini) with mock reference summaries and a mock
|
||||
# media-plan row including language='UK English'. Confirm output prompt
|
||||
# contains 'Language: UK English', 'CANONICAL SOURCE MESSAGING', and
|
||||
# the findings-format instructions.
|
||||
from visual_qc_apps.hp_copy_review.app import build_prompt # adjust if named differently
|
||||
prompt = build_prompt(
|
||||
reference_summaries=[('messi_core.xlsx', '## Product\nHP OmniDesk Mini Core')],
|
||||
media_plan_row={'language': 'UK English', 'country': 'UK'},
|
||||
)
|
||||
assert 'Language: UK English' in prompt, 'language missing from prompt'
|
||||
assert 'CANONICAL SOURCE MESSAGING' in prompt
|
||||
assert 'findings' in prompt
|
||||
print('prompt assembly OK')
|
||||
"
|
||||
```
|
||||
|
||||
- [ ] **Step 5.7: Commit Task 5**
|
||||
|
||||
```bash
|
||||
git add backend/visual_qc_apps/hp_copy_review/
|
||||
git commit -m "feat(hp_copy_review): single-check LLM grader against Source Messaging
|
||||
|
||||
Single Gemini call per asset. Prompt assembles attached Source
|
||||
Messaging summaries + media-plan language context + the asset image.
|
||||
Returns structured JSON with score, summary, and a findings array
|
||||
(priority, category, quote, issue, suggested fix, source reference).
|
||||
Empty findings = clean asset; missing reference → score 0 with a
|
||||
clear message rather than running blind."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Findings-table rendering in both HTML report generators
|
||||
|
||||
Both HTML generators need a small case to render `findings` as a table.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/api_server.py` (`generate_html_content` and `generate_comprehensive_html_report` — see [[feedback_multi_html_generators]])
|
||||
|
||||
- [ ] **Step 6.1: Locate both generators**
|
||||
|
||||
```bash
|
||||
grep -n "def generate_html_content\|def generate_comprehensive_html_report" backend/api_server.py
|
||||
```
|
||||
|
||||
Expected: two function definitions, both render check results to HTML.
|
||||
|
||||
- [ ] **Step 6.2: Identify where each renders a per-check response**
|
||||
|
||||
In each generator, find the section that renders the per-check `response` text (often inside an expandable `<details>` block). The new case goes *before* that fallback: if the check's result dict contains a `findings` array, render the table; else fall back to the text response.
|
||||
|
||||
- [ ] **Step 6.3: Implement a shared helper `_render_findings_table(findings)`**
|
||||
|
||||
Add near the existing CSS/render helpers in `api_server.py`:
|
||||
|
||||
```python
|
||||
def _render_findings_table(findings):
|
||||
"""Render an hp_copy_review-style findings array as an HTML table."""
|
||||
if not findings:
|
||||
return '<p class="muted">No findings — copy is clean.</p>'
|
||||
rows = []
|
||||
for f in findings:
|
||||
priority = f.get('priority', 'low')
|
||||
pri_class = {'high': 'score-bad', 'medium': 'score-ok', 'low': 'score-good'}.get(priority, 'muted')
|
||||
rows.append(
|
||||
f'<tr>'
|
||||
f'<td><span class="score-pill {pri_class}">{priority.upper()}</span></td>'
|
||||
f'<td><code>{f.get("category", "")}</code></td>'
|
||||
f'<td><code>{(f.get("quote") or "")[:200]}</code></td>'
|
||||
f'<td>{f.get("issue", "")}</td>'
|
||||
f'<td>{f.get("suggested_fix", "")}</td>'
|
||||
f'<td class="muted">{f.get("source_reference", "")}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
return (
|
||||
'<table class="findings-table"><thead><tr>'
|
||||
'<th>Priority</th><th>Category</th><th>Quote</th>'
|
||||
'<th>Issue</th><th>Suggested fix</th><th>Source</th>'
|
||||
'</tr></thead><tbody>'
|
||||
+ ''.join(rows) + '</tbody></table>'
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6.4: Wire the helper into both generators**
|
||||
|
||||
In each generator, where it renders a check's response block, add (in pseudocode):
|
||||
|
||||
```python
|
||||
findings = check_result.get('findings')
|
||||
if findings is not None:
|
||||
body_html += _render_findings_table(findings)
|
||||
else:
|
||||
body_html += render_response_text(check_result.get('response', ''))
|
||||
```
|
||||
|
||||
Match the exact variable names and HTML scaffolding used by each generator.
|
||||
|
||||
- [ ] **Step 6.5: Syntax verification + manual HTML inspection**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -m py_compile api_server.py && python3 -c "
|
||||
from api_server import _render_findings_table
|
||||
html = _render_findings_table([
|
||||
{'priority': 'high', 'category': 'disclaimer', 'quote': 'must be linked to a boots.com account', 'issue': 'Wrong account type', 'suggested_fix': '...linked to an Advantage Card account...', 'source_reference': 'Messi Core T&Cs row 18'},
|
||||
{'priority': 'low', 'category': 'tone', 'quote': 'a tiny powerhouse', 'issue': 'Not approved phrasing', 'suggested_fix': 'Use \"compact and capable\"', 'source_reference': 'KSP 1'},
|
||||
])
|
||||
with open('/tmp/findings_preview.html', 'w') as f:
|
||||
f.write('<!DOCTYPE html><html><head><style>table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:6px}</style></head><body>' + html + '</body></html>')
|
||||
print('wrote /tmp/findings_preview.html')
|
||||
"
|
||||
open /tmp/findings_preview.html
|
||||
```
|
||||
|
||||
Eye-check: table renders, priority pills coloured correctly, quote in monospace.
|
||||
|
||||
- [ ] **Step 6.6: Commit Task 6**
|
||||
|
||||
```bash
|
||||
git add backend/api_server.py
|
||||
git commit -m "feat(report): render hp_copy_review findings as a structured table
|
||||
|
||||
Both HTML report generators (generate_html_content and
|
||||
generate_comprehensive_html_report) get a small case: when a check
|
||||
result has a 'findings' array, render it as a priority-coloured
|
||||
table with quote/issue/suggested-fix/source columns instead of the
|
||||
default response-text block. Fallback to text rendering when
|
||||
findings is absent — every existing check is unaffected."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Dev smoke test + deployment
|
||||
|
||||
End-to-end verification on the dev server with real assets and real LLM calls.
|
||||
|
||||
- [ ] **Step 7.1: Run the full pre-session checklist**
|
||||
|
||||
```bash
|
||||
cd backend && python3 -c "
|
||||
from profile_config import get_profile
|
||||
for p in ['general_check','static_general','unilever_key_visual','unilever_packaging','diageo_key_visual','diageo_packaging','loreal_static','amazon_static','boots_static','boots_ppack','inclusive_accessibility','video_general','axa_policy_document','axa_policy_document_diff','axa_accessibility','hp_copy_review']:
|
||||
prof = get_profile(p)
|
||||
print(f'OK {prof.name} ({len(prof.get_enabled_checks())} checks)')
|
||||
"
|
||||
cd .. && python3 -m py_compile backend/**/*.py
|
||||
python3 -c "
|
||||
import sys; sys.path.insert(0, 'backend')
|
||||
import api_server, llm_config, profile_config, jwt_validator, auth_middleware
|
||||
print('all imports OK')
|
||||
"
|
||||
```
|
||||
|
||||
Expected: every profile (including new `hp_copy_review`) loads; all syntax + imports green.
|
||||
|
||||
- [ ] **Step 7.2: Push the feature branch**
|
||||
|
||||
```bash
|
||||
git push -u origin feature/hp-cycle-1-onboarding
|
||||
```
|
||||
|
||||
- [ ] **Step 7.3: Open PR `feature/hp-cycle-1-onboarding → develop` via Bitbucket**
|
||||
|
||||
URL: `https://bitbucket.org/zlalani/ai_qc/pull-requests/new?source=feature/hp-cycle-1-onboarding&t=1`. Destination = `develop`. Title: "feat(hp): cycle 1 — hp_copy_review check + excel processor + language field". Body links to the spec.
|
||||
|
||||
- [ ] **Step 7.4: Merge PR, then deploy to dev**
|
||||
|
||||
SSH to `optical-production-dev`:
|
||||
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
backend/scripts/deploy.sh dev
|
||||
sudo journalctl -u ai-qc -n 30 --no-pager
|
||||
```
|
||||
|
||||
Confirm clean deploy + service healthy.
|
||||
|
||||
- [ ] **Step 7.5: Manually upload Source Messaging fixtures to dev**
|
||||
|
||||
Via the UI at `optical-dev.oliver.solutions/ai_qc/`:
|
||||
1. Sign in (admin).
|
||||
2. Settings → Reference Assets (for client `hp`).
|
||||
3. Upload `messi_core.xlsx`, `messi_mainstream.xlsx`, `gaston.xlsx` (from the original locations under `~/Desktop/AI_QC_Bitbucket/hp/recieved_docs/excel/`).
|
||||
4. Watch the status badge — each should flip to `ready` within 60s. If degraded, inspect the saved `_summary.md` to see what failed.
|
||||
|
||||
- [ ] **Step 7.6: Run an HP marketing asset through `hp_copy_review`**
|
||||
|
||||
1. From the HP team, get a real Messi or Gaston marketing image (PNG/JPG).
|
||||
2. Open a QC session as client `hp`, profile `hp_copy_review`.
|
||||
3. Attach the relevant Source Messaging reference (e.g. `messi_core` for a Core-targeted asset).
|
||||
4. (Optional) Upload a media plan with a `language` column populated so the prompt picks it up.
|
||||
5. Run the QC.
|
||||
6. Inspect the report: confirm findings table renders, priority pills coloured correctly, quotes are real text from the asset.
|
||||
|
||||
If output structure is wrong (e.g. LLM returns prose instead of JSON), iterate the prompt — small follow-up PRs against `develop`.
|
||||
|
||||
- [ ] **Step 7.7: PR `develop → main` and tag**
|
||||
|
||||
Once HP-side smoke testing confirms the output is useful:
|
||||
|
||||
```bash
|
||||
# (laptop) sync local develop, open PR via Bitbucket UI:
|
||||
# https://bitbucket.org/zlalani/ai_qc/pull-requests/new?source=develop&dest=main&t=1
|
||||
```
|
||||
|
||||
After merge:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git tag -a v1.4.0 origin/main -m "v1.4.0 — HP onboarding cycle 1 (hp_copy_review check + excel processor + media-plan language field)"
|
||||
git push origin v1.4.0
|
||||
git rev-parse v1.4.0^{commit}; git rev-parse origin/main # should match
|
||||
```
|
||||
|
||||
- [ ] **Step 7.8: Deploy v1.4.0 to prod**
|
||||
|
||||
SSH to `optical-production`:
|
||||
|
||||
```bash
|
||||
cd /opt/ai_qc
|
||||
backend/scripts/deploy.sh prod v1.4.0
|
||||
sudo journalctl -u ai-qc -n 30 --no-pager
|
||||
```
|
||||
|
||||
No env-file backup dance needed — env files are now permanently gitignored (since v1.3.2).
|
||||
|
||||
- [ ] **Step 7.9: Upload Source Messaging files to prod**
|
||||
|
||||
Repeat Step 7.5 against the prod UI (`optical-prod.oliver.solutions/ai_qc/`). Source Messaging files are *per-server* — they live in `brand_guidelines/files/` on disk and don't sync between dev and prod.
|
||||
|
||||
- [ ] **Step 7.10: Hand off to HP team**
|
||||
|
||||
Confirm HP has access (via per-user client access — `Nick.Viljoen@oliver.agency` adds the HP team's email(s)). Walk them through:
|
||||
1. Where to upload Source Messaging files (Settings → Reference Assets).
|
||||
2. How to run a QC (select hp_copy_review, attach the right reference).
|
||||
3. What feedback to send back (findings missed, findings wrong, output format suggestions).
|
||||
|
||||
Collect first-week feedback before opening Cycle 2 (Word/PPT processor).
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
# Phase 1 — Remove Dow Jones from Visual AI QC
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Author:** Nick Viljoen (design via Claude Code brainstorm)
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Dow Jones is no longer a Visual AI QC client. Remove the client entry, the four Dow Jones brand profiles, and the 22 supporting QC check apps from the live code paths. Preserve everything on disk inside `backend/_archive/dow_jones/` so any of it can be revived later by moving folders back to their original locations — no git surgery needed.
|
||||
|
||||
This is a self-contained cleanup. No behavioural changes to any other client.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not touching `backend/usage_logs/*.jsonl` — those events are immutable audit history.
|
||||
- Not touching uploaded media plans or uploaded brand-guideline PDFs on dev/prod servers.
|
||||
- Not modifying `backend/user_access.json` on the live servers — stale `dow_jones` grants are harmless once the client disappears from the picker.
|
||||
- Not removing any QC check that lives **outside** the dj_/mw_/wsj_/wsj_podcast_ namespaces (even if it looks Dow-Jones-adjacent — none were found).
|
||||
|
||||
## Archive layout
|
||||
|
||||
Single archive root at the backend, organized by departed client:
|
||||
|
||||
```
|
||||
backend/_archive/
|
||||
└── dow_jones/
|
||||
├── README.md
|
||||
├── CLAUDE_DOW_JONES.md
|
||||
├── profiles/
|
||||
│ ├── dow_jones_static.json
|
||||
│ ├── marketwatch_static.json
|
||||
│ ├── wsj_podcast.json
|
||||
│ └── wsj_static.json
|
||||
└── visual_qc_apps/
|
||||
├── dj_color_palette/
|
||||
├── dj_file_naming/
|
||||
├── dj_logo_compliance/
|
||||
├── dj_photography_style/
|
||||
├── dj_square_motif/
|
||||
├── dj_typography_hierarchy/
|
||||
├── mw_art_direction/
|
||||
├── mw_color_palette/
|
||||
├── mw_image_treatment/
|
||||
├── mw_layout_composition/
|
||||
├── mw_logo_compliance/
|
||||
├── mw_typography_hierarchy/
|
||||
├── wsj_capitalization_punctuation/
|
||||
├── wsj_color_usage/
|
||||
├── wsj_imagery_expression/
|
||||
├── wsj_layout_composition/
|
||||
├── wsj_logo_compliance/
|
||||
├── wsj_podcast_format_compliance/
|
||||
├── wsj_podcast_headline_layout/
|
||||
├── wsj_podcast_logo_placement/
|
||||
├── wsj_podcast_safe_area/
|
||||
└── wsj_typography_hierarchy/
|
||||
```
|
||||
|
||||
**Why this layout**
|
||||
|
||||
- `backend/_archive/` is outside `backend/profiles/` and `backend/visual_qc_apps/`, so the existing profile loader (`profile_config.py`) and check discovery code do not scan it. **No loader-side code changes needed** — moving the files is sufficient to deactivate them.
|
||||
- One client per subfolder under `_archive/` makes the pattern reusable for any future client offboarding.
|
||||
- Inside the client subfolder, the original `profiles/` and `visual_qc_apps/` directory names are preserved verbatim, so restoring is a `git mv` back into place.
|
||||
|
||||
## File moves
|
||||
|
||||
All moves performed with `git mv` so history is preserved (`git log --follow` keeps working).
|
||||
|
||||
### Repo-root doc → archive
|
||||
|
||||
| From | To |
|
||||
|---|---|
|
||||
| `CLAUDE_DOW_JONES.md` | `backend/_archive/dow_jones/CLAUDE_DOW_JONES.md` |
|
||||
|
||||
### Profile JSONs → archive
|
||||
|
||||
| From | To |
|
||||
|---|---|
|
||||
| `backend/profiles/dow_jones_static.json` | `backend/_archive/dow_jones/profiles/dow_jones_static.json` |
|
||||
| `backend/profiles/marketwatch_static.json` | `backend/_archive/dow_jones/profiles/marketwatch_static.json` |
|
||||
| `backend/profiles/wsj_podcast.json` | `backend/_archive/dow_jones/profiles/wsj_podcast.json` |
|
||||
| `backend/profiles/wsj_static.json` | `backend/_archive/dow_jones/profiles/wsj_static.json` |
|
||||
|
||||
### Check directories → archive
|
||||
|
||||
22 directories under `backend/visual_qc_apps/`:
|
||||
|
||||
- `dj_color_palette/`, `dj_file_naming/`, `dj_logo_compliance/`, `dj_photography_style/`, `dj_square_motif/`, `dj_typography_hierarchy/`
|
||||
- `mw_art_direction/`, `mw_color_palette/`, `mw_image_treatment/`, `mw_layout_composition/`, `mw_logo_compliance/`, `mw_typography_hierarchy/`
|
||||
- `wsj_capitalization_punctuation/`, `wsj_color_usage/`, `wsj_imagery_expression/`, `wsj_layout_composition/`, `wsj_logo_compliance/`, `wsj_typography_hierarchy/`
|
||||
- `wsj_podcast_format_compliance/`, `wsj_podcast_headline_layout/`, `wsj_podcast_logo_placement/`, `wsj_podcast_safe_area/`
|
||||
|
||||
Each moves to `backend/_archive/dow_jones/visual_qc_apps/<dir>/` keeping its internal structure (typically `app.py` and any prompt/config files) intact.
|
||||
|
||||
### New file
|
||||
|
||||
A one-page `backend/_archive/dow_jones/README.md` written to record:
|
||||
- Date archived (2026-05-14)
|
||||
- One-line reason ("Client offboarded — no longer using Visual AI QC")
|
||||
- Restore instructions ("Move `profiles/*.json` back to `backend/profiles/`, move `visual_qc_apps/*` back to `backend/visual_qc_apps/`, re-add the client entry to `backend/client_config.py`, restart the server.")
|
||||
|
||||
## Code edits
|
||||
|
||||
### `backend/client_config.py`
|
||||
|
||||
Remove the `"dow_jones": {...}` block. No other consumers of the file reference it (verified via project survey). After the edit, `get_all_clients()` must return 9 clients (Diageo, Unilever, L'Oreal, Amazon, Boots, Honda, AXA, Rank, General).
|
||||
|
||||
### `CLAUDE.md` (repo root)
|
||||
|
||||
Two edits:
|
||||
|
||||
1. **Client/profile table** — delete the Dow Jones row:
|
||||
```
|
||||
| Dow Jones | `dow_jones_static` (5), `marketwatch_static` (6), `wsj_static` (6), `wsj_podcast` (7) | [CLAUDE_DOW_JONES.md](CLAUDE_DOW_JONES.md) |
|
||||
```
|
||||
|
||||
2. **Pre-Session Completion Checklist** (item 5, "Profile load") — drop the four Dow Jones profile names from the inline list. The remaining list keeps loading every other profile.
|
||||
|
||||
No edits needed to `profile_config.py`, `api_server.py`, `auth_middleware.py`, `usage_tracker.py`, `media_plan_processor.py`, `pdf_processor.py`, or any other backend module. The archive lives outside the scanned directories, so loaders ignore it without code changes.
|
||||
|
||||
## Things explicitly NOT touched
|
||||
|
||||
| Surface | Reason |
|
||||
|---|---|
|
||||
| `backend/usage_logs/*.jsonl` | Immutable audit trail. Removing Dow Jones events would falsify history and break date-range reports. |
|
||||
| Uploaded media plans (`backend/media_plans/`) | User data. Belongs to historical analyses. |
|
||||
| Uploaded brand guideline PDFs (`backend/brand_guidelines/`) | User data. Same as above. |
|
||||
| `backend/user_access.json` on dev + prod | Gitignored, per-server. Stale `dow_jones` entries are harmless because the client no longer exists in `client_config.py` — the picker won't render it. Cleaning these requires SSH and is not worth the risk for zero functional benefit. |
|
||||
| Memory file `project_state.md` | Will update opportunistically once Phase 1 ships, not as part of the change. |
|
||||
|
||||
## Verification
|
||||
|
||||
Run after the moves + edits, before commit:
|
||||
|
||||
```bash
|
||||
# 1. Syntax
|
||||
python -m py_compile backend/api_server.py backend/client_config.py backend/profile_config.py
|
||||
|
||||
# 2. Client config: Dow Jones gone, 9 clients remain
|
||||
cd backend && python3 -c "
|
||||
from client_config import get_all_clients
|
||||
clients = get_all_clients()
|
||||
assert 'dow_jones' not in clients, 'dow_jones still in client_config'
|
||||
assert len(clients) == 9, f'expected 9 clients, got {len(clients)}'
|
||||
for cid, c in clients.items(): print(f'OK {c[\"display_name\"]}: {c[\"profiles\"]}')
|
||||
"
|
||||
|
||||
# 3. All remaining profiles still load (Dow Jones four dropped)
|
||||
cd backend && python3 -c "
|
||||
from profile_config import get_profile
|
||||
for p in ['general_check','static_general','unilever_key_visual','unilever_packaging',
|
||||
'diageo_key_visual','diageo_packaging','loreal_static','amazon_static',
|
||||
'boots_static','boots_ppack','inclusive_accessibility','video_general',
|
||||
'axa_policy_document','axa_policy_document_diff','axa_accessibility']:
|
||||
prof = get_profile(p); print(f'OK {prof.name} ({len(prof.get_enabled_checks())} checks)')
|
||||
"
|
||||
|
||||
# 4. Archived profiles no longer loadable
|
||||
cd backend && python3 -c "
|
||||
from profile_config import get_profile
|
||||
for archived in ['dow_jones_static','marketwatch_static','wsj_static','wsj_podcast']:
|
||||
try:
|
||||
get_profile(archived)
|
||||
print(f'FAIL: {archived} still loadable')
|
||||
except Exception as e:
|
||||
print(f'OK archived ({archived}): {type(e).__name__}')
|
||||
"
|
||||
|
||||
# 5. Server boots
|
||||
./scripts/test-system.sh
|
||||
```
|
||||
|
||||
All five must pass before the implementation is considered complete.
|
||||
|
||||
## Commit hygiene
|
||||
|
||||
- One commit per logical group makes review easy:
|
||||
1. `chore(dow-jones): archive profiles and check apps`
|
||||
2. `chore(dow-jones): remove client_config entry`
|
||||
3. `docs(dow-jones): update CLAUDE.md after offboarding + add archive README`
|
||||
- All file moves use `git mv` so `git log --follow` continues to work on any moved file.
|
||||
- Branch off `develop` as `feature/remove-dow-jones`, PR into `develop`, then deploy to dev via `backend/scripts/deploy.sh dev` per the standard flow.
|
||||
|
||||
## Open risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| A profile JSON outside the four named ones still references a dj_/mw_/wsj_ check | Low | Verification step 3 catches it — the remaining profiles fail to load if a referenced check directory has been moved out from under them. |
|
||||
| Live server `user_access.json` references Dow Jones in a way that crashes the access middleware once the client is gone | Low | `auth_middleware.py` access checks are keyed by client_id; an absent client just returns "not granted." Confirmed during design survey. |
|
||||
| `git mv` of 22 directories produces a noisy PR | Certain | Expected. The PR will show 22 renames plus a few small edits. Reviewer can use `git log --follow` on any moved file for history. |
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
# AI QC Database Design
|
||||
|
||||
**Goal:** Introduce a PostgreSQL database to the AI QC app, dual-written alongside the existing JSONL usage logs, to support a future exec + drill-down dashboard.
|
||||
|
||||
**Architecture:** Postgres 16 in a Docker container on each of the two app VMs (`optical-production-dev`, `optical-production`), accessed by the Flask app via SQLAlchemy + psycopg. Alembic for schema migrations. Every analysis write goes to BOTH the existing JSONL log AND the database; JSONL remains source-of-truth for this cycle. A daily `pg_dump → GCS` job protects against VM loss. After dual-write has run for ~1 week and parity is verified, a one-shot script backfills historical JSONL data.
|
||||
|
||||
**Tech stack:** PostgreSQL 16, SQLAlchemy 2.x, psycopg 3, Alembic, Docker / docker-compose, `gsutil` (for backups).
|
||||
|
||||
**Status:** Phase 5 of Nick's roadmap, cycle 1 of 3 (DB → Docker → Dashboard). Cycles 2 and 3 are out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Today, the AI QC app records every analysis event as a line in `backend/usage_logs/YYYY-MM-DD.jsonl`. Readers of those files include `backend/generate_usage_report.py` (CLI), the `/api/client_usage_stats` endpoint (powering the "Reporting" tab), and the audit trail for `access_change` / `access_request` events. The JSONL store is battle-tested but limits us in three ways that motivate moving to a relational DB:
|
||||
|
||||
1. **Querying** — anything beyond simple per-client/per-day aggregates means writing one-off scripts. A dashboard with filters, drill-down, and trending will not scale on flat files.
|
||||
2. **Cross-event joins** — "for this analysis, list all checks that scored below 6" requires correlating multiple JSONL events. Trivial in SQL.
|
||||
3. **Schema evolution** — JSONL is shape-free; the DB makes us define what we record and forces consistency across writers.
|
||||
|
||||
The dashboard itself is cycle 3 of Phase 5 — out of scope for this cycle. This cycle just lands the data.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope (this cycle)
|
||||
|
||||
1. Postgres 16 container running on each VM under compose project name `ai-qc`, with a named volume for data persistence.
|
||||
2. Schema: two tables (`analyses`, `analysis_checks`) plus indexes — design locked during brainstorm (see "Schema" below).
|
||||
3. SQLAlchemy models, a thin repository module for typed writes, an engine factory that reads `DB_URL` from env.
|
||||
4. Alembic baseline migration; the deploy script learns to run `alembic upgrade head` before restarting the service.
|
||||
5. Dual-write integration at every analysis event site in `api_server.py` (UI upload flow, document-mode flow, Box webhook flow, per-check writes, completion writes).
|
||||
6. Feature flag `DB_DUAL_WRITE_ENABLED` (env var) — defaults to `true`. If set to `false`, the app skips DB writes entirely and behaves exactly as it does today. This is the rollback escape hatch.
|
||||
7. Daily `pg_dump → GCS` backup via a systemd timer on each VM. 30-day retention via a GCS bucket lifecycle policy.
|
||||
8. Parity verification script (`verify_dual_write_parity.py`) that compares JSONL records vs DB records for a given date range.
|
||||
9. One-shot backfill script (`backfill_from_jsonl.py`) — idempotent — to populate the DB with historical JSONL records once dual-write parity has been confirmed.
|
||||
|
||||
### Out of scope (handled in follow-up cycles or follow-up tasks)
|
||||
|
||||
- Containerising the Flask app itself (Phase 5 cycle 2 — Docker).
|
||||
- Building the dashboard UI (Phase 5 cycle 3 — Dashboard).
|
||||
- Migrating `generate_usage_report.py` and `/api/client_usage_stats` to read from the DB. This is the natural follow-up to this cycle but ships separately, once the DB has proven itself.
|
||||
- Replicating access events (`access_change`, `access_request`, `access_denied`) to the DB. Stays in JSONL for now.
|
||||
- Moving to managed Cloud SQL. Reasonable future move, but it's a procurement + GCP-admin conversation, not a code change.
|
||||
- Connection pooling, HA / read replicas, encryption-at-rest beyond Docker volume defaults — all defer until we have a real need.
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
Two tables, plus indexes. JSON columns are PostgreSQL `jsonb` (queryable, indexable later if needed). All timestamps are `timestamptz` (UTC). The `analyses.id` reuses the existing in-pipeline `session_id` (a UUID generated at upload time) so JSONL and DB rows share the same primary key.
|
||||
|
||||
### `analyses`
|
||||
|
||||
One row per QC run.
|
||||
|
||||
| column | type | nullable | notes |
|
||||
|---|---|---|---|
|
||||
| `id` | `uuid` (pk) | no | matches pipeline `session_id` |
|
||||
| `client_id` | `text` | no | e.g. `loreal`, `boots` |
|
||||
| `user_email` | `text` | yes | `null` for Box webhook runs |
|
||||
| `source_origin` | `text` | no | `ui_upload` \| `box_webhook` |
|
||||
| `profile_id` | `text` | no | e.g. `loreal_static` |
|
||||
| `mode` | `text` | no | `asset` \| `document` \| `document_diff` |
|
||||
| `source_file_name` | `text` | no | original filename |
|
||||
| `source_file_size_bytes` | `bigint` | yes | |
|
||||
| `source_file_type` | `text` | yes | `image` / `video` / `pdf` / etc. |
|
||||
| `started_at` | `timestamptz` | no | |
|
||||
| `completed_at` | `timestamptz` | yes | |
|
||||
| `status` | `text` | no | `pending` \| `running` \| `success` \| `failed` |
|
||||
| `overall_score` | `numeric` | yes | 100-pt scale (120 for Unilever KV) |
|
||||
| `overall_verdict` | `text` | yes | `pass` \| `fail` |
|
||||
| `technical_report` | `jsonb` | yes | Phase 3 output (file inspection result) |
|
||||
| `media_plan_match` | `jsonb` | yes | matched row from media plan if any |
|
||||
| `total_tokens` | `bigint` | yes | sum across all checks |
|
||||
| `estimated_cost_usd` | `numeric` | yes | |
|
||||
| `report_html_path` | `text` | yes | on-disk path to the generated report |
|
||||
| `box_report_file_id` | `text` | yes | Box file id when uploaded back |
|
||||
| `error_message` | `text` | yes | populated when `status = 'failed'` |
|
||||
|
||||
### `analysis_checks`
|
||||
|
||||
One row per check executed. Cascade-delete with the parent analysis.
|
||||
|
||||
| column | type | nullable | notes |
|
||||
|---|---|---|---|
|
||||
| `id` | `uuid` (pk) | no | |
|
||||
| `analysis_id` | `uuid` (fk → `analyses.id`, on delete cascade) | no | |
|
||||
| `check_name` | `text` | no | e.g. `brand_logo_check` |
|
||||
| `llm_provider` | `text` | no | `gemini` \| `openai` |
|
||||
| `status` | `text` | no | `success` \| `failed` |
|
||||
| `score` | `numeric` | yes | |
|
||||
| `weight` | `numeric` | no | profile-defined weight |
|
||||
| `tokens_used` | `integer` | yes | |
|
||||
| `duration_ms` | `integer` | yes | |
|
||||
| `details` | `jsonb` | no | full LLM response — supports drill-down |
|
||||
| `started_at` | `timestamptz` | no | |
|
||||
| `completed_at` | `timestamptz` | yes | |
|
||||
|
||||
The `details` column is the heaviest field by far; it preserves the whole structured LLM response so future drill-down views need no schema change. PostgreSQL TOAST compression handles the storage cost; we can prune the column later if it becomes a problem.
|
||||
|
||||
### Indexes
|
||||
|
||||
- `analyses(client_id, started_at desc)` — primary index for client-scoped chronological queries (the exec dashboard's main shape).
|
||||
- `analyses(user_email, started_at desc)` — for user-scoped views.
|
||||
- `analyses(started_at desc)` — for global trending queries.
|
||||
- `analysis_checks(analysis_id)` — drill-down lookups.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### `backend/db/` (new module)
|
||||
|
||||
- `__init__.py` — public surface: `get_session()` (context manager), `init_engine()`.
|
||||
- `engine.py` — engine factory. Reads `DB_URL` from env. Singleton engine across the Flask app.
|
||||
- `models.py` — SQLAlchemy ORM models for `Analysis` and `AnalysisCheck`.
|
||||
- `repository.py` — typed write helpers, the only surface `api_server.py` touches:
|
||||
- `record_analysis_start(session, *, analysis_id, client_id, user_email, ...) -> Analysis`
|
||||
- `record_analysis_complete(session, analysis_id, *, overall_score, overall_verdict, status, total_tokens, ...) -> None`
|
||||
- `record_analysis_failed(session, analysis_id, *, error_message) -> None`
|
||||
- `record_check_result(session, analysis_id, *, check_name, llm_provider, status, score, weight, details, ...) -> AnalysisCheck`
|
||||
- `parity.py` — pure helpers consumed by both `verify_dual_write_parity.py` and `backfill_from_jsonl.py`. Parses a JSONL line into the same shape the repository functions accept.
|
||||
|
||||
### `backend/migrations/` (new Alembic env)
|
||||
|
||||
Standard Alembic layout. Baseline revision creates both tables + indexes.
|
||||
|
||||
### `backend/scripts/` (additions)
|
||||
|
||||
- `backfill_from_jsonl.py` — one-shot, idempotent. Reads `usage_logs/*.jsonl`, looks up each `session_id` in `analyses`, inserts when missing. Per-row try/except so a single malformed line doesn't abort the run. Logs a summary at the end (read / inserted / skipped / errored).
|
||||
- `verify_dual_write_parity.py` — for a given date range, counts events in JSONL vs rows in DB and surfaces drift. Exits non-zero when drift is detected.
|
||||
- `pg_backup_to_gcs.sh` — `pg_dump` of the AI QC database, gzip, `gsutil cp` to the configured GCS bucket under `pgdumps/<env>/<date>.sql.gz`. Logs to journal via systemd.
|
||||
|
||||
### `deploy/docker-compose.db.yml` (new)
|
||||
|
||||
```yaml
|
||||
name: ai-qc
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # not exposed externally
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: ai-qc-pgdata
|
||||
```
|
||||
|
||||
The top-level `name: ai-qc` and the explicit volume `name:` are belt-and-braces against the CLAUDE.md compose-collision warning, even though the AI QC VMs are dedicated.
|
||||
|
||||
### `backend/config/*.env` additions
|
||||
|
||||
Both `development.env` and `production.env` (gitignored) get:
|
||||
|
||||
```
|
||||
# Database
|
||||
DB_URL=postgresql+psycopg://aiqc:<password>@127.0.0.1:5432/aiqc
|
||||
POSTGRES_USER=aiqc
|
||||
POSTGRES_PASSWORD=<generated>
|
||||
POSTGRES_DB=aiqc
|
||||
DB_DUAL_WRITE_ENABLED=true
|
||||
|
||||
# Backups
|
||||
BACKUP_GCS_BUCKET=ai-qc-pg-backups-<env> # name to be confirmed when bucket is provisioned
|
||||
```
|
||||
|
||||
### `api_server.py` modifications
|
||||
|
||||
Five touchpoints, all dual-write. The pattern at every site:
|
||||
|
||||
```python
|
||||
from db import get_session
|
||||
from db.repository import record_analysis_start
|
||||
|
||||
# After the existing JSONL write:
|
||||
if os.environ.get("DB_DUAL_WRITE_ENABLED", "true").lower() == "true":
|
||||
try:
|
||||
with get_session() as db_session:
|
||||
record_analysis_start(db_session, analysis_id=session_id, ...)
|
||||
except Exception:
|
||||
logger.exception("DB write failed (analysis_id=%s); JSONL is authoritative", session_id)
|
||||
```
|
||||
|
||||
Touchpoints:
|
||||
|
||||
1. `/api/start_analysis` (asset analysis kickoff) — `record_analysis_start`.
|
||||
2. `/api/document/start_analysis` (document-mode kickoff) — `record_analysis_start` with `mode='document'`.
|
||||
3. `_run_box_triggered_analysis` (Box webhook flow) — `record_analysis_start` with `source_origin='box_webhook'`, `user_email=None`.
|
||||
4. `process_single_check` (per-check completion path) — `record_check_result`.
|
||||
5. Analysis completion / failure paths — `record_analysis_complete` or `record_analysis_failed`. (Note: per the [[feedback_multi_html_generators]] memory, there are parallel HTML generators; both completion paths need wiring.)
|
||||
|
||||
### Backup setup (per VM, manual one-off)
|
||||
|
||||
- Create GCS bucket `ai-qc-pg-backups-dev` and `ai-qc-pg-backups-prod` (or names agreed with infra).
|
||||
- Apply a 30-day lifecycle deletion rule on each bucket.
|
||||
- Create a GCP service account with `roles/storage.objectCreator` on its corresponding bucket. Key file at `/etc/ai-qc/gcs-backup-sa.json`, chmod 600, owned by the service user.
|
||||
- Install `pg_backup_to_gcs.sh` to `/opt/ai_qc/backend/scripts/`.
|
||||
- Create systemd unit `ai-qc-pg-backup.service` + timer `ai-qc-pg-backup.timer` firing daily at 02:00 UTC.
|
||||
|
||||
The bucket creation and IAM steps depend on Nick's boss / infra owner (Nick isn't a GCP admin). The script and systemd units are in code and reusable across the two envs.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Normal asset analysis (UI upload):**
|
||||
|
||||
1. User uploads → `/api/start_analysis`.
|
||||
2. JSONL: write `{event: "analysis_start", session_id, client_id, user_email, profile_id, ...}` to today's log file.
|
||||
3. DB: insert row in `analyses` (`status='pending'`, `started_at=now`, all metadata fields populated; `overall_score` / `completed_at` left null).
|
||||
4. Pipeline runs checks in batches. For each check completion:
|
||||
- JSONL: write `{event: "check_complete", session_id, check_name, status, score, ...}`.
|
||||
- DB: insert row in `analysis_checks` (full `details` jsonb, score, tokens, duration).
|
||||
5. All checks done, final scoring + report generation:
|
||||
- JSONL: write `{event: "analysis_complete", session_id, overall_score, overall_verdict, total_tokens, ...}`.
|
||||
- DB: update the `analyses` row (`status='success'`, `completed_at`, `overall_score`, `overall_verdict`, `total_tokens`, `report_html_path`).
|
||||
|
||||
**Document mode and Box webhook flows:** Same dual-write pattern; different entry points. `source_origin` and (for Box) `user_email=None` differentiate the rows.
|
||||
|
||||
**DB write failure (any step):** caught, logged with `exc_info`, swallowed. JSONL has already succeeded; the analysis continues normally and produces the same user-visible output it does today.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling and Rollback
|
||||
|
||||
- **DB connection failure on any single write** — caught, logged, swallowed. JSONL is authoritative.
|
||||
- **DB connection unavailable at app startup** — app still boots. The engine is initialised lazily; any DB write will fail-and-log per the rule above. This means a misconfigured DB never takes the production service down.
|
||||
- **Schema migration failure during deploy** — `deploy.sh` runs `alembic upgrade head` *before* restarting the systemd unit. If migration fails, deploy aborts before restart, and the existing app keeps running on the unmigrated DB (which it doesn't read from yet, so no harm). Existing rollback logic in `deploy.sh` already handles `git reset --hard` of the app code.
|
||||
- **Rollback escape hatch** — flip `DB_DUAL_WRITE_ENABLED=false` and restart the service. The app reverts to the exact pre-cycle behaviour (JSONL only). This is the no-code-changes-required panic button.
|
||||
- **Backup script failure** — logged to journal. No automated alerting in this cycle (see "Out of scope"). Weekly manual spot-check that the latest dump exists in the bucket.
|
||||
- **Parity drift detected** — `verify_dual_write_parity.py` exits non-zero and prints a diff. We investigate manually before proceeding with the backfill.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests run against a dedicated `ai-qc-test` database, brought up via the same docker-compose with a separate database name. No SQLite in tests (per CLAUDE.md Postgres-only policy).
|
||||
|
||||
- **Unit tests** for repository functions — each test creates and tears down its rows in a transaction.
|
||||
- **Migration tests** — `alembic upgrade head` followed by `alembic downgrade base` should both succeed cleanly on an empty DB.
|
||||
- **Dual-write integration test** — mock a fake analysis end-to-end with the DB enabled; assert that both the JSONL file and the DB rows reflect the expected events, and that they agree.
|
||||
- **Parity test** — feed a known JSONL fixture into the backfill script against an empty DB; assert the resulting DB rows match expectations.
|
||||
- **Failure-resilience test** — point `DB_URL` at an unreachable host and run an analysis. Verify the analysis still completes successfully and that the failure is logged.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Deploys land in this order on each VM:
|
||||
|
||||
1. Update env file with new variables (`DB_URL`, `POSTGRES_*`, `DB_DUAL_WRITE_ENABLED`, `BACKUP_GCS_BUCKET`).
|
||||
2. Bring up the Postgres container: `docker compose -f deploy/docker-compose.db.yml -p ai-qc up -d`.
|
||||
3. `alembic upgrade head` against the new DB (run from the app venv).
|
||||
4. Deploy the new app code via existing `deploy.sh dev` / `deploy.sh prod <tag>`. Existing flow already runs `pip install` on `requirements.txt` change.
|
||||
5. Smoke-test: trigger one analysis via the UI; confirm the row appears in both `backend/usage_logs/<today>.jsonl` and `analyses`.
|
||||
6. Set up the GCS bucket + service account + systemd timer for backups (per "Backup setup" above) — can be done in parallel with the above once bucket is provisioned.
|
||||
7. Let dual-write run for ~1 week. Run `verify_dual_write_parity.py --last-days 7`. If clean, proceed to step 8; if drift, debug and re-run.
|
||||
8. Run `backfill_from_jsonl.py` once. Re-run `verify_dual_write_parity.py` over the full historical range to confirm.
|
||||
|
||||
The follow-up cycle (migrating the readers to query the DB instead of JSONL) starts only after step 8 lands cleanly on prod.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- Both VMs running Postgres 16 in a Docker container under `name: ai-qc` with a named volume.
|
||||
- Alembic migrations applied; both tables and all indexes present.
|
||||
- App dual-writing on every analysis touchpoint; failures swallowed and logged.
|
||||
- `DB_DUAL_WRITE_ENABLED=false` confirmed to restore pre-cycle behaviour.
|
||||
- Daily backup job running on both VMs and producing readable dumps in GCS.
|
||||
- Parity script clean over the last 7 days of live traffic.
|
||||
- Historical JSONL backfilled into the DB and parity-verified across the full history.
|
||||
- This spec + the implementation plan committed under `docs/superpowers/`.
|
||||
|
||||
---
|
||||
|
||||
## Deferred decisions (worth surfacing at resume)
|
||||
|
||||
- **GCS bucket naming + provisioning** — depends on infra owner.
|
||||
- **Backup verification automation** — manual spot-check is fine to start; if we add alerting, this is where it goes.
|
||||
- **Connection pooling tuning** — defaults are fine; revisit if the app gets containerised + scaled out (cycle 2 of Phase 5).
|
||||
- **Move to Cloud SQL** — eventual win on managed backups + encryption at rest; out of scope until a clear procurement decision is made.
|
||||
- **Replicating access events to DB** — would let the dashboard surface access history; defer until the dashboard cycle is being scoped.
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
# HP Onboarding — Cycle 1: `hp_copy_review` Check
|
||||
|
||||
**Goal:** Onboard HP onto the AI QC platform with a Source-Messaging-grounded copy review check, replacing the existing `hp-copy` PHP/Make.com POC tool.
|
||||
|
||||
**Architecture:** Single new QC check `hp_copy_review` grades an HP marketing asset's on-asset copy against canonical Source Messaging Excel files uploaded as reference assets. A new `excel_processor.py` mirrors `pdf_processor.py`: openpyxl extracts raw cell content at upload time, Gemini summarises into structured Markdown, saved alongside the file under `brand_guidelines/files/`. At QC time the check prompt assembles the Markdown summary(s) + media-plan language metadata + the asset image and returns a structured findings list. HP gets a real client config entry plus the generic profiles it already has visibility into.
|
||||
|
||||
**Tech stack:** openpyxl 3.x (already a project dep — used by `media_plan_processor.py`), existing `llm_config.py` Gemini integration, existing brand-guidelines flow, existing media-plan processor. **No new external dependencies.**
|
||||
|
||||
**Status:** Cycle 1 of 3 in HP onboarding. Cycles 2 (Word/PPT ingestion) and 3 (Box file picker) are independent and ship later. This cycle is independently shippable.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
HP's existing `hp-copy` is a PHP UI wrapping a Make.com webhook (opaque). The PM raised seven concerns; Dave's decision is to deprecate the POC and migrate HP onto AI QC. Of the seven concerns:
|
||||
|
||||
- **Solved natively by AI QC today:** stability, configurable rule sets, accuracy (LLM + reference assets eliminate the false-positives-on-brand-names class of bugs because the canonical source list comes from the Excels), bulk processing (local upload supports multi-file out of the box).
|
||||
- **Cycle 1 (this spec) addresses:** the HP-specific check, the Source-Messaging Excel ingestion pipeline, and multilingual via a media-plan `language` field.
|
||||
- **Other cycles:** Word/PPT support (Cycle 2), Box file picker (Cycle 3).
|
||||
|
||||
The user-visible flow Day 1 after this cycle ships:
|
||||
1. HP user uploads Source Messaging `.xlsx` files (Messi-Core, Messi-Mainstream, Gaston) once via Settings → Reference Assets.
|
||||
2. HP user uploads marketing asset(s) via local upload — same UX as Boots/AXA/LOREAL.
|
||||
3. HP user selects the `hp_copy_review` profile and attaches the relevant Source Messaging reference(s).
|
||||
4. The check returns a structured findings table matching the Messi Copy Review document format (priority, quote, issue, suggested fix, source citation).
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope (this cycle)
|
||||
|
||||
1. **HP client config** promoted from `_scope pending_` to a real entry with `hp_copy_review` as the default profile.
|
||||
2. **`hp_copy_review` profile JSON** — single weighted check, client-specific visibility.
|
||||
3. **`hp_copy_review` QC check** at `backend/visual_qc_apps/hp_copy_review/app.py`.
|
||||
4. **`backend/excel_processor.py`** — new module mirroring `pdf_processor.py`. openpyxl extraction → Gemini summary → Markdown saved as `{file_id}_summary.md`.
|
||||
5. **Reference-asset upload routing** — `.xlsx` uploads route to `excel_processor.process_excel_file`. Existing endpoints (`POST /api/brand_guidelines`, `GET /api/brand_guidelines/<id>/status`, `POST .../reprocess`) work without modification beyond the dispatch line.
|
||||
6. **Media plan `language` field** — free-form text column; surfaced in matched-row metadata; included in the check prompt when present; absent → graceful no-op.
|
||||
7. **Report rendering** — small case in the two HTML report generators so the findings JSON renders as a priority-coloured table instead of a wall of text.
|
||||
8. **Unit + smoke tests** as listed under Testing.
|
||||
|
||||
### Out of scope (other cycles or deferred)
|
||||
|
||||
- Word / PPT ingestion as reference assets — Cycle 2.
|
||||
- Box file picker UI — Cycle 3.
|
||||
- HP master brand guidelines reference — HP hasn't provided one yet.
|
||||
- Briefs (`.pptx`) as reference assets — depends on Cycle 2.
|
||||
- Multi-language Source Messaging variants — HP currently has English-only files. If they later provide Spanish / Dutch versions, no code change is needed; they upload as separate reference assets.
|
||||
- Strict-grade enforcement — the HP Copy Review is a nuanced priority-tiered (High / Medium / Low) review, not pass/fail. Standard 0–100 weighted scoring.
|
||||
- Replacing or modifying the existing `hp-copy` PHP tool. We leave it running; HP migrates traffic at their own pace.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### `backend/client_config.py` — HP entry
|
||||
|
||||
Promote HP from placeholder to a real entry. Add `hp_copy_review` to the profile list, set as default:
|
||||
|
||||
```python
|
||||
'hp': {
|
||||
'name': 'HP',
|
||||
'profiles': ['hp_copy_review', 'static_general', 'video_general'],
|
||||
'display_name': 'HP',
|
||||
'description': 'HP marketing copy QC graded against canonical Source Messaging',
|
||||
'default_profile': 'hp_copy_review',
|
||||
},
|
||||
```
|
||||
|
||||
`box_folder_id` / `box_reports_folder_id` deferred to Cycle 3.
|
||||
|
||||
### `backend/profiles/hp_copy_review.json` — new profile
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "HP Copy Review",
|
||||
"description": "Marketing copy graded against canonical HP Source Messaging",
|
||||
"mode": "asset",
|
||||
"visibility": "client_specific",
|
||||
"visible_to_clients": ["hp"],
|
||||
"checks": {
|
||||
"hp_copy_review": {
|
||||
"weight": 10.0,
|
||||
"llm": "gemini",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Total weight = 10.0 → scoring uses the `weighted_score × 10` path, max 100. Single check carries the whole score. No `strict_grade`.
|
||||
|
||||
### `backend/visual_qc_apps/hp_copy_review/app.py` — new check
|
||||
|
||||
Standard QC app module following `flask_app_template.py`. Single Gemini call. Returns: `score` (0–10), `summary` (one-paragraph headline), and `findings` (JSON list).
|
||||
|
||||
**Prompt structure** (starting point — expect tuning during smoke testing):
|
||||
|
||||
```
|
||||
You are a copy reviewer for HP marketing materials. Compare the
|
||||
marketing asset against the canonical Source Messaging provided.
|
||||
|
||||
PRODUCT LANGUAGE: <from media plan, or "not specified">
|
||||
|
||||
CANONICAL SOURCE MESSAGING:
|
||||
<one or more Markdown summaries from attached Excel reference assets,
|
||||
concatenated with a `---` separator and a file-name header>
|
||||
|
||||
MARKETING ASSET:
|
||||
<image>
|
||||
|
||||
For every claim, headline, body line, disclaimer, footnote, spec
|
||||
call-out, and brand mention visible on the asset, evaluate against
|
||||
the canonical source. Output a structured findings array:
|
||||
|
||||
[
|
||||
{
|
||||
"priority": "high" | "medium" | "low",
|
||||
"category": "ksp" | "disclaimer" | "spec" | "variant" |
|
||||
"tone" | "brand-name" | "language" | "other",
|
||||
"quote": "<exact quote from the asset>",
|
||||
"issue": "<what's wrong>",
|
||||
"suggested_fix": "<what it should say, citing the canonical source>",
|
||||
"source_reference": "<where in source messaging this comes from,
|
||||
e.g. 'Core sheet row 12 KSP 3'>"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Then provide a score from 0–10 reflecting overall copy quality
|
||||
(10 = no issues, 0 = severe and pervasive issues). Score should
|
||||
weight high-priority issues most heavily.
|
||||
|
||||
If no Source Messaging is attached, return score 0 with a clear
|
||||
summary explaining that no canonical source was provided.
|
||||
```
|
||||
|
||||
**Empty-findings case** (clean asset): valid result — score 9–10, `findings: []`, summary "no issues identified".
|
||||
|
||||
**No-reference-attached case**: check returns score 0 with the explanatory message, rather than running blind against an empty source.
|
||||
|
||||
### `backend/excel_processor.py` — new module
|
||||
|
||||
Mirrors `pdf_processor.py`. Public surface:
|
||||
|
||||
- `process_excel_file(file_path, file_id) -> tuple[str, str]` — reads `.xlsx`, returns `(summary_text, summary_path)`. Saves `{file_id}_summary.md` under `brand_guidelines/files/`.
|
||||
|
||||
Internal helpers:
|
||||
|
||||
- `_extract_workbook_text(path) -> str` — openpyxl, iterates all sheets, dumps as `"Sheet: <name>\n<row-by-row tab-aligned cell values>\n\n"`. Skips empty rows. Caps at a reasonable cell budget (e.g. 50K chars) to bound prompt size.
|
||||
- `_summarise_with_gemini(raw_text, source_filename) -> str` — Gemini 2.5 Pro call with HP-tuned system prompt (below) producing a structured Markdown summary, ~1500–3000 words.
|
||||
|
||||
**Summary prompt** (Excel-specific):
|
||||
|
||||
```
|
||||
You're processing an HP Source Messaging Excel into a structured
|
||||
Markdown reference. Output these sections:
|
||||
|
||||
## Product / Variant
|
||||
(brand, product line, variant if any — e.g. "HP OmniDesk Mini — Core")
|
||||
|
||||
## Key Selling Points (KSPs)
|
||||
For each KSP: heading, value proposition, supporting body copy,
|
||||
message-length variants (ultra-short / short / medium / long if
|
||||
present in the source).
|
||||
|
||||
## Disclaimers / Footnotes
|
||||
Numbered list, exact wording, what claim each footnote anchors to.
|
||||
|
||||
## Approved Brand and Product Names
|
||||
Exact spellings, including trademark glyphs (™, ®, ©).
|
||||
|
||||
## Variant Notes / Watch-outs
|
||||
Anything explicitly marked variant-specific (e.g. "Mainstream only",
|
||||
"Core only", "must not appear in entry tier").
|
||||
|
||||
## Verboten Phrasing
|
||||
Any explicitly disallowed or deprecated phrasing called out in the source.
|
||||
|
||||
Be exhaustive but concise. Quote exactly where the source is explicit.
|
||||
```
|
||||
|
||||
No cover image (Excel has no analogous concept). The reference-asset DB record schema already permits a null `cover_path`.
|
||||
|
||||
### `backend/media_plan_processor.py` — `language` column
|
||||
|
||||
When parsing media-plan Excel sheets, extract `language` (case-insensitive header match: `language`, `Language`, `LANGUAGE`) into the matched-row metadata dict. The existing media-plan-context block injected into prompts gains a `Language: <value>` line when the field is present; if absent, the line is omitted entirely (graceful no-op for clients whose media plans don't include language).
|
||||
|
||||
### `api_server.py` — reference asset upload routing
|
||||
|
||||
Existing `/api/brand_guidelines` POST routes `.pdf` → `pdf_processor.process_pdf_file`. Extend the dispatch: `.xlsx` → `excel_processor.process_excel_file`. Reuse the existing DB-record shape and the existing `GET .../<id>/status` and `POST .../<id>/reprocess` endpoints unchanged — they're agnostic to processor type.
|
||||
|
||||
### Report rendering — findings table
|
||||
|
||||
Per the [[feedback_multi_html_generators]] memory, there are two HTML generators (`generate_html_content` and `generate_comprehensive_html_report`). Both need a small case for `hp_copy_review`: when the check response contains a `findings` array, render as a table with columns for **Priority** (red/amber/green pill), **Category** (pill), **Quote** (monospace), **Issue**, **Suggested fix**, **Source**. Falls back to the existing plain-text response renderer if `findings` is absent (e.g. malformed LLM response).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Reference asset upload (one-time per Source Messaging file):**
|
||||
|
||||
1. HP user uploads `.xlsx` via Settings → Reference Assets.
|
||||
2. `api_server.py` routes by extension to `excel_processor.process_excel_file`.
|
||||
3. openpyxl extracts raw cell content from all sheets.
|
||||
4. Gemini summarises into structured Markdown via the HP-specific summary prompt.
|
||||
5. Summary saved at `brand_guidelines/files/{file_id}_summary.md`.
|
||||
6. DB record updated; status flips to `ready`.
|
||||
|
||||
**QC run (per analysis):**
|
||||
|
||||
1. HP user uploads marketing asset (image).
|
||||
2. Selects `hp_copy_review` profile.
|
||||
3. Selects one or more Source Messaging reference assets (Core / Mainstream / Gaston as applicable).
|
||||
4. (Optional) The asset's filename matches a media plan row containing a `language` value.
|
||||
5. `process_single_check` for `hp_copy_review` assembles the prompt: system instructions + concatenated Markdown summaries + media-plan context (with language if present) + asset image.
|
||||
6. Single Gemini call returns score + summary + findings JSON.
|
||||
7. Report renderer presents findings as a Messi-Review-style table.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Excel parse failure** (corrupt file, password-protected, etc.) — processor returns an error; DB status = `failed`; user sees the error in the reference-assets list. No app crash.
|
||||
- **Gemini summarisation failure at upload** — retry once with exponential backoff; if still failing, save the raw extraction as the summary and mark status = `degraded`. The check can still use a degraded summary (lower fidelity) rather than blocking.
|
||||
- **Check-time LLM failure or malformed findings JSON** — existing `process_single_check` exception handling captures and records a score-0 result with the error in the response. Standard pattern, no new surface.
|
||||
- **Empty findings** (clean asset) — valid result; score 9–10, `findings: []`, summary "no issues identified".
|
||||
- **No reference asset attached** — check returns score 0 with a clear message ("No HP Source Messaging reference selected — attach a Source Messaging Excel to compare against"). Doesn't run blind.
|
||||
- **Excel processing concurrency** — uploads are independent files; `pdf_processor.py` already handles concurrent uploads safely (per-file_id artefact paths). Same pattern applies.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests run against the project's existing pytest setup. Real Source Messaging Excels live under `tests/fixtures/hp/` (copied from the user-provided originals).
|
||||
|
||||
- **Unit tests** — `excel_processor`:
|
||||
- Happy path: Messi-Core / Messi-Mainstream / Gaston Excels each yield a non-empty `.md` summary containing the expected section headers (`## Key Selling Points`, `## Disclaimers / Footnotes`, etc.) and at least one KSP-level content snippet.
|
||||
- Corrupt file: error returned, no crash.
|
||||
- Empty workbook: graceful degradation with a sensible message.
|
||||
- **Unit tests** — `hp_copy_review/app.py`:
|
||||
- Prompt assembly: given mock reference summaries and a mock media-plan row with `language: "UK English"`, assert the assembled prompt contains the language line, the source-messaging block delimiter, and the findings-format instructions.
|
||||
- Response parsing: given a known Gemini-shape JSON response (fixture), assert findings list extracted correctly with all six fields per finding.
|
||||
- Empty references: score 0 + the explanatory message.
|
||||
- **Integration smoke test**: end-to-end with a real Messi asset (sample PNG of an OmniDesk eTail tile) + the Messi-Core Source Messaging reference attached. Assert the check runs to completion, returns a valid score, returns at least one finding (the Messi Copy Review found 34 — Gemini should surface at least 3 in the deterministic ones).
|
||||
- **Profile load** in the pre-session checklist: add `hp_copy_review` to the loader test.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Code-only changes — no infrastructure work, no requirements changes (openpyxl already installed).
|
||||
|
||||
1. PR `feature/hp-cycle-1-onboarding → develop`. Deploy to dev via `deploy.sh dev`.
|
||||
2. **One-time data step on dev:** HP team (or Nick on their behalf) uploads the three Source Messaging Excel files (Messi-Core, Messi-Mainstream, Gaston-v2) via the UI. These land in `brand_guidelines/files/` on dev only — uploads are not synced between dev and prod; the prod uploads happen separately.
|
||||
3. Dev smoke test: run an HP marketing image through `hp_copy_review` with the Messi-Core reference attached. Verify output structure mirrors the Messi Copy Review doc.
|
||||
4. PR `develop → main`. Tag `v1.4.0` (minor — new client capability). Deploy to prod via `deploy.sh prod v1.4.0`.
|
||||
5. HP team uploads Source Messaging files on prod, runs first real QC, provides feedback. Prompt tuning iterations are post-deploy LLM-prompt changes — small follow-up PRs as needed, no spec changes.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- `hp_copy_review` profile loads cleanly (pre-session checklist passes with the new profile in the loader script).
|
||||
- `client_config.get_client_profiles('hp')` returns `['hp_copy_review', 'static_general', 'video_general']`.
|
||||
- `client_config.get_default_profile('hp')` returns `'hp_copy_review'`.
|
||||
- Uploading a Source Messaging `.xlsx` produces a non-empty `_summary.md` within 60s of upload.
|
||||
- Running `hp_copy_review` on a known Messi asset with the Messi-Core reference attached returns findings overlapping with at least 3 of the 34 issues in the HP-provided Messi Copy Review doc (rough qualitative bar — Gemini scoring varies run-to-run, but the major issues should be detected).
|
||||
- Report renders the findings as a structured table, not free-text.
|
||||
- Media plan parsing extracts `language` when present; the check prompt includes a `Language:` line in that case.
|
||||
- Standard pre-session checklist all green on develop tip.
|
||||
|
||||
---
|
||||
|
||||
## Deferred decisions (worth surfacing at follow-up)
|
||||
|
||||
- **Strict-grade for HP?** Not in V1. If HP wants any High-priority finding to force overall Fail, add `strict_grade: true` to the profile and extend the scoring path (small retrofit).
|
||||
- **HP master brand guidelines** — none today. Whenever HP provides a master brand guide PDF (colour palette, logo usage, typography), it can be attached as an additional reference asset alongside Source Messaging. No code change.
|
||||
- **Prompt template tuning** — the templates above are starting points. Live HP usage will surface what to refine. Iterate via small prompt-only PRs.
|
||||
- **Non-English Source Messaging** — if HP later provides Spanish / Dutch versions, they upload as separate reference assets and select the relevant one(s) per QC run. Works without code change.
|
||||
- **Findings-output schema versioning** — if HP wants additional fields per finding (e.g. screenshot crop region, suggested approval routing), add to the JSON shape and bump renderer.
|
||||
- **Briefs as reference assets** — depends on Cycle 2 (Word/PPT ingestion). Once that lands, HP can attach Gaston/Messi `.pptx` briefs alongside the Excel sources.
|
||||
368
web_ui.html
368
web_ui.html
|
|
@ -725,34 +725,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="file-info" id="fileInfo"></div>
|
||||
|
||||
<div id="documentModeBanner" style="display:none; margin-top:10px; padding:12px 14px; background:#eef2f7; border-left:4px solid #3a6bb1; border-radius:6px; font-size:13px; color:#243a5e;">
|
||||
<strong>Document mode</strong> — multi-page PDF policy QC. Each page is rendered and scored individually. PDFs only.
|
||||
</div>
|
||||
|
||||
<!-- Diff mode: two-slot upload (only shown when picked profile has mode=document_diff) -->
|
||||
<div id="diffUploadArea" style="display:none; margin-top:14px;">
|
||||
<div style="padding:12px 14px; background:#fff4d6; border-left:4px solid #b58a00; border-radius:6px; font-size:13px; color:#5a4500; margin-bottom:14px;">
|
||||
<strong>Diff mode</strong> — upload the OLD and NEW versions of the same policy. Pages are aligned by text similarity, then each matched pair is diffed by Gemini.
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||
<div>
|
||||
<label for="diffOldFileInput" style="font-weight:600; display:block; margin-bottom:6px;">Old version (PDF)</label>
|
||||
<div id="diffOldDrop" style="border:2px dashed #ccc; border-radius:8px; padding:16px; text-align:center; cursor:pointer; background:#fafafa;">
|
||||
<input type="file" id="diffOldFileInput" accept="application/pdf,.pdf" style="display:none;">
|
||||
<span id="diffOldLabel" style="color:#666;">Click to choose old PDF…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="diffNewFileInput" style="font-weight:600; display:block; margin-bottom:6px;">New version (PDF)</label>
|
||||
<div id="diffNewDrop" style="border:2px dashed #ccc; border-radius:8px; padding:16px; text-align:center; cursor:pointer; background:#fafafa;">
|
||||
<input type="file" id="diffNewFileInput" accept="application/pdf,.pdf" style="display:none;">
|
||||
<span id="diffNewLabel" style="color:#666;">Click to choose new PDF…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Queue Display -->
|
||||
<div id="fileQueue" style="margin-top: 20px; display: none;">
|
||||
<h4 style="color: #495057; margin-bottom: 15px;">File Queue (<span id="queueCount">0</span> files)</h4>
|
||||
|
|
@ -2150,8 +2123,7 @@
|
|||
|
||||
// Profile change
|
||||
profileSelect.addEventListener('change', checkFormValidity);
|
||||
profileSelect.addEventListener('change', applyProfileMode);
|
||||
|
||||
|
||||
// Analyze button
|
||||
analyzeBtn.addEventListener('click', startAnalysis);
|
||||
|
||||
|
|
@ -2166,13 +2138,7 @@
|
|||
|
||||
// Update cost display when profile changes
|
||||
profileSelect.addEventListener('change', updateCostDisplay);
|
||||
|
||||
// Initial mode application in case a profile is pre-selected
|
||||
applyProfileMode();
|
||||
|
||||
// Wire up diff-mode pickers (idempotent on subsequent calls but only needs once)
|
||||
wireDiffPickers();
|
||||
|
||||
|
||||
console.log('Event listeners setup complete');
|
||||
}
|
||||
|
||||
|
|
@ -2424,19 +2390,9 @@
|
|||
|
||||
// Check if form is valid and update cost
|
||||
function checkFormValidity() {
|
||||
// Diff mode: enable when BOTH diff files are picked + a profile is selected
|
||||
const profileMeta = availableProfiles && availableProfiles[profileSelect.value];
|
||||
const isDiffMode = !!(profileMeta && profileMeta.mode === 'document_diff');
|
||||
if (isDiffMode) {
|
||||
const oldInput = document.getElementById('diffOldFileInput');
|
||||
const newInput = document.getElementById('diffNewFileInput');
|
||||
const hasBoth = oldInput && newInput && oldInput.files.length && newInput.files.length;
|
||||
analyzeBtn.disabled = !(hasBoth && profileSelect.value);
|
||||
} else {
|
||||
// START QC ANALYSIS button - only for single file uploads
|
||||
const hasSingleFile = selectedFile !== null && fileQueue.length === 0;
|
||||
analyzeBtn.disabled = !(hasSingleFile && profileSelect.value);
|
||||
}
|
||||
// START QC ANALYSIS button - only for single file uploads
|
||||
const hasSingleFile = selectedFile !== null && fileQueue.length === 0;
|
||||
analyzeBtn.disabled = !(hasSingleFile && profileSelect.value);
|
||||
|
||||
// Process Queue button - only for pending files in queue
|
||||
const processBtn = document.getElementById('processQueueBtn');
|
||||
|
|
@ -2448,62 +2404,6 @@
|
|||
updateCostDisplay();
|
||||
}
|
||||
|
||||
// Toggle the document-mode UI based on the selected profile.
|
||||
// Three modes: asset (default), document (single PDF), document_diff (two PDFs)
|
||||
function applyProfileMode() {
|
||||
const profileId = profileSelect ? profileSelect.value : '';
|
||||
const docBanner = document.getElementById('documentModeBanner');
|
||||
const diffArea = document.getElementById('diffUploadArea');
|
||||
const singleFileArea = document.getElementById('fileUploadArea');
|
||||
const fileInputEl = document.getElementById('file-input');
|
||||
const profileMode = profileId && availableProfiles && availableProfiles[profileId]
|
||||
? availableProfiles[profileId].mode
|
||||
: 'asset';
|
||||
const isDoc = profileMode === 'document';
|
||||
const isDiff = profileMode === 'document_diff';
|
||||
|
||||
if (docBanner) docBanner.style.display = isDoc ? 'block' : 'none';
|
||||
if (diffArea) diffArea.style.display = isDiff ? 'block' : 'none';
|
||||
if (singleFileArea) singleFileArea.style.display = isDiff ? 'none' : 'block';
|
||||
|
||||
if (fileInputEl) {
|
||||
fileInputEl.accept = isDoc
|
||||
? 'application/pdf,.pdf'
|
||||
: 'image/*,.pdf,video/mp4,video/quicktime,video/x-msvideo,video/webm,.mp4,.mov,.avi,.webm,.mkv';
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up the diff-mode file pickers to update their labels when files are chosen.
|
||||
function wireDiffPickers() {
|
||||
const oldDrop = document.getElementById('diffOldDrop');
|
||||
const oldInput = document.getElementById('diffOldFileInput');
|
||||
const oldLabel = document.getElementById('diffOldLabel');
|
||||
const newDrop = document.getElementById('diffNewDrop');
|
||||
const newInput = document.getElementById('diffNewFileInput');
|
||||
const newLabel = document.getElementById('diffNewLabel');
|
||||
if (!oldDrop || !oldInput || !newDrop || !newInput) return;
|
||||
oldDrop.addEventListener('click', () => oldInput.click());
|
||||
newDrop.addEventListener('click', () => newInput.click());
|
||||
oldInput.addEventListener('change', () => {
|
||||
if (oldInput.files.length) {
|
||||
oldLabel.textContent = oldInput.files[0].name;
|
||||
oldLabel.style.color = '#222';
|
||||
oldDrop.style.borderColor = '#3a6bb1';
|
||||
oldDrop.style.background = '#eef2f7';
|
||||
}
|
||||
checkFormValidity();
|
||||
});
|
||||
newInput.addEventListener('change', () => {
|
||||
if (newInput.files.length) {
|
||||
newLabel.textContent = newInput.files[0].name;
|
||||
newLabel.style.color = '#222';
|
||||
newDrop.style.borderColor = '#3a6bb1';
|
||||
newDrop.style.background = '#eef2f7';
|
||||
}
|
||||
checkFormValidity();
|
||||
});
|
||||
}
|
||||
|
||||
// Update cost display based on selected profile
|
||||
function updateCostDisplay() {
|
||||
const costDisplay = document.getElementById('costDisplay');
|
||||
|
|
@ -2525,36 +2425,20 @@
|
|||
|
||||
// Start analysis
|
||||
async function startAnalysis() {
|
||||
const profileMeta = availableProfiles && availableProfiles[profileSelect.value];
|
||||
const isDiffMode = !!(profileMeta && profileMeta.mode === 'document_diff');
|
||||
|
||||
if (!profileSelect.value) {
|
||||
alert('Please select a profile before starting analysis.');
|
||||
if (!selectedFile || !profileSelect.value) {
|
||||
alert('Please select a file and profile before starting analysis.');
|
||||
return;
|
||||
}
|
||||
if (isDiffMode) {
|
||||
const oldInput = document.getElementById('diffOldFileInput');
|
||||
const newInput = document.getElementById('diffNewFileInput');
|
||||
if (!oldInput || !oldInput.files.length || !newInput || !newInput.files.length) {
|
||||
alert('Diff mode requires both an OLD and a NEW PDF.');
|
||||
return;
|
||||
}
|
||||
} else if (!selectedFile) {
|
||||
alert('Please select a file before starting analysis.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Show progress
|
||||
progressContainer.style.display = 'block';
|
||||
resultsContainer.style.display = 'none';
|
||||
analyzeBtn.disabled = true;
|
||||
|
||||
|
||||
try {
|
||||
// Prepare form data
|
||||
const formData = new FormData();
|
||||
if (!isDiffMode) {
|
||||
formData.append('file', selectedFile);
|
||||
}
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('profile', profileSelect.value);
|
||||
console.log('DEBUG: outputMode.value =', outputMode.value);
|
||||
formData.append('mode', outputMode.value);
|
||||
|
|
@ -2618,32 +2502,8 @@
|
|||
// Start the analysis and get session ID for progress tracking
|
||||
progressFill.style.width = '10%';
|
||||
currentApp.textContent = 'Submitting file for analysis...';
|
||||
|
||||
const profileMeta = availableProfiles && availableProfiles[profile];
|
||||
const isDocumentMode = !!(profileMeta && profileMeta.mode === 'document');
|
||||
const isDiffMode = !!(profileMeta && profileMeta.mode === 'document_diff');
|
||||
if ((isDocumentMode || isDiffMode) && selectedClient) {
|
||||
formData.set('client_id', selectedClient);
|
||||
}
|
||||
|
||||
// For diff mode the formData was built with a single 'file' — replace
|
||||
// with old_file + new_file from the dedicated diff pickers.
|
||||
if (isDiffMode) {
|
||||
formData.delete('file');
|
||||
const oldInput = document.getElementById('diffOldFileInput');
|
||||
const newInput = document.getElementById('diffNewFileInput');
|
||||
if (!oldInput || !newInput || !oldInput.files.length || !newInput.files.length) {
|
||||
throw new Error('Diff mode requires both an old and a new PDF.');
|
||||
}
|
||||
formData.append('old_file', oldInput.files[0]);
|
||||
formData.append('new_file', newInput.files[0]);
|
||||
}
|
||||
|
||||
const analysisEndpoint = isDiffMode
|
||||
? 'api/document/start_diff'
|
||||
: (isDocumentMode ? 'api/document/start_analysis' : 'api/start_analysis');
|
||||
|
||||
const response = await fetch(`${BASE_PATH}${analysisEndpoint}`, {
|
||||
|
||||
const response = await fetch(`${BASE_PATH}api/start_analysis`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
|
|
@ -2993,7 +2853,6 @@
|
|||
'<button class="tab-btn" onclick="showTab(\'assets\')">Reference Assets</button>' +
|
||||
'<button class="tab-btn" onclick="showTab(\'tools\')">QC Tools</button>' +
|
||||
'<button class="tab-btn" onclick="showTab(\'mediaplan\')">Media Plan</button>' +
|
||||
'<button class="tab-btn" onclick="showTab(\'defaults\')">Default Profile</button>' +
|
||||
'</div>' +
|
||||
'<div id="existing-tab" class="tab-content active">' +
|
||||
'<div style="margin-bottom: 15px;">' +
|
||||
|
|
@ -3113,21 +2972,6 @@
|
|||
'<div id="mediaPlanFileInfo" style="margin-top: 15px; padding: 10px; background: #d4edda; border-radius: 6px; display: none;"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="defaults-tab" class="tab-content">' +
|
||||
'<div style="margin-bottom: 20px;">' +
|
||||
'<h4 style="color: #495057; margin-bottom: 10px;">Default Profile</h4>' +
|
||||
'<p style="color: #6c757d; font-size: 0.9em; margin-bottom: 0;">Used for unattended QC runs — e.g. files arriving via the Box webhook. Pick one of this client\\\'s profiles as the default. Admin only.</p>' +
|
||||
'</div>' +
|
||||
'<div id="defaultProfileClientContext" style="margin-bottom: 12px; color: #495057; font-size: 0.95em;"></div>' +
|
||||
'<div id="defaultProfileList" style="border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; background: #fafafa;">' +
|
||||
'<p style="text-align: center; color: #6c757d; margin: 0;">Loading...</p>' +
|
||||
'</div>' +
|
||||
'<div style="margin-top: 15px;">' +
|
||||
'<button id="setDefaultProfileBtn" onclick="saveDefaultProfile()" disabled style="background: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 600; margin-right: 10px;">Set as default</button>' +
|
||||
'<button id="clearDefaultOverrideBtn" onclick="clearDefaultProfileOverride()" style="background: #6c757d; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; display: none;">Revert to static default</button>' +
|
||||
'</div>' +
|
||||
'<div id="defaultProfileMessage" style="margin-top: 15px; padding: 10px; border-radius: 6px; display: none;"></div>' +
|
||||
'</div>' +
|
||||
'<div style="text-align: right; padding-top: 15px; border-top: 1px solid #dee2e6;">' +
|
||||
'<button onclick="saveProfile()" id="saveProfileBtn" style="background: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; margin-right: 10px;">Save Profile</button>' +
|
||||
'<button onclick="deleteProfile()" id="deleteProfileBtn" style="background: #dc3545; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; margin-right: 10px; display: none;">Delete Profile</button>' +
|
||||
|
|
@ -3549,11 +3393,6 @@
|
|||
loadMediaPlanStatus();
|
||||
setupMediaPlanFileInput();
|
||||
}
|
||||
|
||||
// Load default-profile settings when that tab is shown
|
||||
if (tabName === 'defaults') {
|
||||
loadDefaultProfileSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the read-only QC Tools reference list
|
||||
|
|
@ -3628,187 +3467,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- Default Profile per client (admin-managed override; used by Box webhook) ---
|
||||
|
||||
function showDefaultProfileMessage(text, kind) {
|
||||
const el = document.getElementById('defaultProfileMessage');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
const palette = kind === 'error'
|
||||
? { bg: '#f8d7da', color: '#721c24', border: '#f5c6cb' }
|
||||
: { bg: '#d4edda', color: '#155724', border: '#c3e6cb' };
|
||||
el.style.background = palette.bg;
|
||||
el.style.color = palette.color;
|
||||
el.style.border = '1px solid ' + palette.border;
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => { el.style.display = 'none'; }, 6000);
|
||||
}
|
||||
|
||||
function _setSimpleMessage(parent, text, color) {
|
||||
// Helper: replace `parent` content with a single message paragraph. Pure text, no HTML.
|
||||
while (parent.firstChild) parent.removeChild(parent.firstChild);
|
||||
const p = document.createElement('p');
|
||||
p.textContent = text;
|
||||
p.style.textAlign = 'center';
|
||||
p.style.color = color || '#6c757d';
|
||||
p.style.margin = '0';
|
||||
parent.appendChild(p);
|
||||
}
|
||||
|
||||
async function loadDefaultProfileSettings() {
|
||||
const listEl = document.getElementById('defaultProfileList');
|
||||
const contextEl = document.getElementById('defaultProfileClientContext');
|
||||
const setBtn = document.getElementById('setDefaultProfileBtn');
|
||||
const clearBtn = document.getElementById('clearDefaultOverrideBtn');
|
||||
if (!listEl) return;
|
||||
|
||||
if (!selectedClient) {
|
||||
contextEl.textContent = '';
|
||||
_setSimpleMessage(listEl, 'Pick a client first.');
|
||||
setBtn.disabled = true;
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
_setSimpleMessage(listEl, 'Loading...');
|
||||
try {
|
||||
const resp = await fetch(`${BASE_PATH}api/clients/${encodeURIComponent(selectedClient)}/default_profile`, { credentials: 'include' });
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.json().catch(() => ({ message: resp.statusText }));
|
||||
_setSimpleMessage(listEl, 'Failed to load: ' + (errBody.message || resp.status), '#dc3545');
|
||||
setBtn.disabled = true;
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const profiles = data.profiles || [];
|
||||
const effective = data.default_profile;
|
||||
const staticDefault = data.static_default;
|
||||
const isOverridden = effective && staticDefault && effective !== staticDefault;
|
||||
|
||||
// Build context line with safe DOM construction
|
||||
while (contextEl.firstChild) contextEl.removeChild(contextEl.firstChild);
|
||||
const clientStrong = document.createElement('strong');
|
||||
clientStrong.textContent = 'Client: ';
|
||||
contextEl.appendChild(clientStrong);
|
||||
contextEl.appendChild(document.createTextNode(String(selectedClient)));
|
||||
if (staticDefault) {
|
||||
contextEl.appendChild(document.createTextNode(' · static default in code: '));
|
||||
const code = document.createElement('code');
|
||||
code.textContent = staticDefault;
|
||||
contextEl.appendChild(code);
|
||||
}
|
||||
if (isOverridden) {
|
||||
const overrideNote = document.createElement('span');
|
||||
overrideNote.style.color = '#0c5460';
|
||||
overrideNote.textContent = ' · currently overridden';
|
||||
contextEl.appendChild(overrideNote);
|
||||
}
|
||||
|
||||
if (!profiles.length) {
|
||||
_setSimpleMessage(listEl, 'This client has no profiles configured.');
|
||||
setBtn.disabled = true;
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||||
profiles.forEach(pid => {
|
||||
const label = document.createElement('label');
|
||||
label.style.cssText = 'display:flex;align-items:center;padding:8px 6px;border-radius:6px;cursor:pointer;';
|
||||
const isDefault = pid === effective;
|
||||
const isStatic = pid === staticDefault;
|
||||
if (isDefault) label.style.background = '#e8f5e9';
|
||||
|
||||
const radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'defaultProfileChoice';
|
||||
radio.value = pid;
|
||||
radio.style.marginRight = '10px';
|
||||
if (isDefault) {
|
||||
radio.checked = true;
|
||||
radio.setAttribute('data-current', '1'); // sentinel for onDefaultProfileChange comparison
|
||||
}
|
||||
radio.addEventListener('change', onDefaultProfileChange);
|
||||
label.appendChild(radio);
|
||||
|
||||
const code = document.createElement('code');
|
||||
code.textContent = pid;
|
||||
code.style.fontSize = '0.95em';
|
||||
label.appendChild(code);
|
||||
|
||||
if (isDefault) {
|
||||
const badge = document.createElement('span');
|
||||
badge.textContent = 'CURRENT DEFAULT';
|
||||
badge.style.cssText = 'background:#28a745;color:white;padding:2px 8px;border-radius:10px;font-size:0.75em;margin-left:8px;';
|
||||
label.appendChild(badge);
|
||||
} else if (isStatic) {
|
||||
const badge = document.createElement('span');
|
||||
badge.textContent = 'static';
|
||||
badge.style.cssText = 'background:#6c757d;color:white;padding:2px 8px;border-radius:10px;font-size:0.75em;margin-left:8px;';
|
||||
label.appendChild(badge);
|
||||
}
|
||||
|
||||
listEl.appendChild(label);
|
||||
});
|
||||
|
||||
clearBtn.style.display = isOverridden ? 'inline-block' : 'none';
|
||||
setBtn.disabled = true; // re-enabled by onDefaultProfileChange when user picks a different value
|
||||
} catch (err) {
|
||||
_setSimpleMessage(listEl, 'Error: ' + err.message, '#dc3545');
|
||||
setBtn.disabled = true;
|
||||
clearBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function onDefaultProfileChange() {
|
||||
const setBtn = document.getElementById('setDefaultProfileBtn');
|
||||
const chosen = document.querySelector('input[name="defaultProfileChoice"]:checked');
|
||||
const currentDefault = document.querySelector('input[name="defaultProfileChoice"][data-current="1"]');
|
||||
setBtn.disabled = !chosen || (currentDefault && chosen.value === currentDefault.value);
|
||||
}
|
||||
|
||||
async function saveDefaultProfile() {
|
||||
const chosen = document.querySelector('input[name="defaultProfileChoice"]:checked');
|
||||
if (!chosen || !selectedClient) return;
|
||||
try {
|
||||
const resp = await fetch(`${BASE_PATH}api/clients/${encodeURIComponent(selectedClient)}/default_profile`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: chosen.value }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
showDefaultProfileMessage('Failed: ' + (data.message || resp.statusText), 'error');
|
||||
return;
|
||||
}
|
||||
showDefaultProfileMessage('Default profile for ' + selectedClient + ' set to ' + data.default_profile, 'ok');
|
||||
loadDefaultProfileSettings();
|
||||
} catch (err) {
|
||||
showDefaultProfileMessage('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDefaultProfileOverride() {
|
||||
if (!selectedClient) return;
|
||||
try {
|
||||
const resp = await fetch(`${BASE_PATH}api/clients/${encodeURIComponent(selectedClient)}/default_profile`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
showDefaultProfileMessage('Failed: ' + (data.message || resp.statusText), 'error');
|
||||
return;
|
||||
}
|
||||
showDefaultProfileMessage('Override cleared. Default reverted to ' + (data.default_profile || '(none)'), 'ok');
|
||||
loadDefaultProfileSettings();
|
||||
} catch (err) {
|
||||
showDefaultProfileMessage('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMediaPlanStatus() {
|
||||
const container = document.getElementById('mediaPlanCurrent');
|
||||
if (!container || !selectedClient) return;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue