Compare commits
No commits in common. "main" and "feature/docs-restructure" have entirely different histories.
main
...
feature/do
60 changed files with 344 additions and 4846 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/
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -8,7 +8,7 @@ When the user tells you the work is for a specific client (or you can infer it f
|
|||
|
||||
## Project Overview
|
||||
|
||||
Visual AI QC is a 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 through **60+ specialized QC checks** across **15 profiles**, serving **12 clients** (Diageo, Unilever, L'Oreal, Amazon, Boots, Honda, AXA, Rank, Google, HP, Ferrero, General).
|
||||
Visual AI QC is a 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 through **80+ specialized QC checks** across **18 profiles**, serving **10 clients** (Diageo, Unilever, L'Oreal, Amazon, Boots, Dow Jones, Honda, AXA, Rank, General).
|
||||
|
||||
## Core Architecture
|
||||
|
||||
|
|
@ -98,12 +98,10 @@ Profiles define check sets, weights, and LLM assignments. Profiles can be marked
|
|||
| L'Oreal | `loreal_static` (4, strict-grade) | [CLAUDE_LOREAL.md](CLAUDE_LOREAL.md) |
|
||||
| Amazon | `amazon_static` (6) | [CLAUDE_AMAZON.md](CLAUDE_AMAZON.md) |
|
||||
| Boots | `boots_static` (5, strict-grade), `boots_ppack` (7, document-mode, strict-grade w/ artwork-page exemption) | [CLAUDE_BOOTS.md](CLAUDE_BOOTS.md) |
|
||||
| AXA | `axa_policy_document` (7, document-mode), `axa_accessibility` (1, document-mode, strict-grade), `axa_policy_document_diff` (1, document_diff) | [CLAUDE_AXA.md](CLAUDE_AXA.md) |
|
||||
| Dow Jones | `dow_jones_static` (5), `marketwatch_static` (6), `wsj_static` (6), `wsj_podcast` (7) | [CLAUDE_DOW_JONES.md](CLAUDE_DOW_JONES.md) |
|
||||
| AXA | `axa_policy_document` (8, document-mode), `axa_policy_document_diff` (1, document_diff) | [CLAUDE_AXA.md](CLAUDE_AXA.md) |
|
||||
| Honda | generic only | [CLAUDE_HONDA.md](CLAUDE_HONDA.md) |
|
||||
| Rank | generic only | [CLAUDE_RANK.md](CLAUDE_RANK.md) |
|
||||
| Google | generic only | _scope pending_ |
|
||||
| HP | generic only | _scope pending_ |
|
||||
| Ferrero | generic only | _scope pending_ |
|
||||
| General | generic only | [CLAUDE_GENERAL.md](CLAUDE_GENERAL.md) |
|
||||
|
||||
### Scoring
|
||||
|
|
@ -200,7 +198,7 @@ Before ending any session, 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']:
|
||||
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']:
|
||||
prof = get_profile(p); print(f'OK {prof.name} ({len(prof.get_enabled_checks())} checks)')
|
||||
"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@
|
|||
|
||||
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`.
|
||||
**Status (2026-05-06):** Phases 1, 3, 4, 5 merged to `develop`. Not yet shown to AXA — gated on AXA show-and-tell. The full plan and remaining phases are in `backend/AXA_DOCUMENT_MODE_PLAN.md`.
|
||||
|
||||
## AXA Profiles
|
||||
|
||||
### `axa_policy_document` — single-document mode (7 checks)
|
||||
### `axa_policy_document` — single-document mode (8 checks)
|
||||
|
||||
Multi-page policy document QC. `mode: document`, scopes vary per check. Accessibility validation lives in the dedicated `axa_accessibility` profile, not here.
|
||||
Multi-page policy document QC. `mode: document`, scopes vary per check.
|
||||
|
||||
| Check | What it does | Weight |
|
||||
|------|--------------|--------|
|
||||
|
|
@ -20,18 +20,11 @@ Multi-page policy document QC. `mode: document`, scopes vary per check. Accessib
|
|||
| `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_pdf_accessibility` | Tagged-PDF / accessibility checks | 2.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.
|
||||
|
|
@ -51,39 +44,15 @@ AXA's document-mode subsystem is the foundation for all multi-page PDF QC in thi
|
|||
|
||||
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/profiles/axa_policy_document.json`, `axa_policy_document_diff.json`
|
||||
- `backend/document_mode/data/axa_bold_words_seed.json` — bold-word seed list
|
||||
|
|
|
|||
|
|
@ -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,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.
|
||||
|
|
@ -7,8 +7,6 @@ import os
|
|||
import sys
|
||||
import json
|
||||
import base64
|
||||
import collections
|
||||
import html
|
||||
import importlib
|
||||
import traceback
|
||||
import re
|
||||
|
|
@ -65,8 +63,6 @@ from llm_config import run_visual_qc, get_model_info
|
|||
from profile_config import QC_CHECKS, PROFILES, get_profile, get_check_llm_map
|
||||
from brand_guidelines_db import BrandGuidelinesDB
|
||||
from auth_middleware import AuthMiddleware
|
||||
from technical_check import inspect as technical_inspect, format_for_llm_prompt as technical_to_prompt
|
||||
import box_jwt_client
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
|
|
@ -454,13 +450,6 @@ def process_single_check(check_name, qc_apps, profile_config, profile_weights, f
|
|||
if ocr_context and ocr_enabled:
|
||||
final_prompt = final_prompt + "\n" + ocr_context
|
||||
|
||||
tech_report = progress_tracker.get(session_id, {}).get('technical_report')
|
||||
if tech_report:
|
||||
try:
|
||||
final_prompt = technical_to_prompt(tech_report) + "\n\n" + final_prompt
|
||||
except Exception:
|
||||
pass # Pre-flight context is best-effort; never block the check on it.
|
||||
|
||||
print(f"Running check {check_index + 1}/{total_checks}: {check_name}")
|
||||
|
||||
result = run_visual_qc(
|
||||
|
|
@ -859,8 +848,6 @@ def get_client_from_profile(profile_id):
|
|||
return 'amazon'
|
||||
elif profile_lower.startswith('boots'):
|
||||
return 'boots'
|
||||
elif profile_lower.startswith('hp_'):
|
||||
return 'hp'
|
||||
elif profile_lower.startswith(('dow_jones', 'dj_', 'marketwatch', 'mw_', 'wsj')):
|
||||
return 'dow_jones'
|
||||
else:
|
||||
|
|
@ -976,12 +963,6 @@ def generate_html_content(report_data, filename, file_path=None):
|
|||
json_data = check_data.get('json_data', {})
|
||||
response_text = ""
|
||||
|
||||
# Structured findings (e.g. hp_copy_review) render as a table
|
||||
# instead of the default response-text block. If absent, falls
|
||||
# back to the existing text rendering below.
|
||||
findings = (json_data or {}).get('findings') if isinstance(json_data, dict) else None
|
||||
findings_html = _render_findings_table(findings) if findings is not None else None
|
||||
|
||||
# Try to extract detailed analysis from JSON data
|
||||
if json_data:
|
||||
# Look for common detailed fields in the JSON
|
||||
|
|
@ -1108,12 +1089,12 @@ def generate_html_content(report_data, filename, file_path=None):
|
|||
</div>
|
||||
<div class="analysis-section">
|
||||
<h4>Analysis Details:</h4>
|
||||
{f'<div class="response-text">{html.escape(json_data.get("summary", "") or "") if isinstance(json_data, dict) else ""}</div>{findings_html}' if findings_html is not None else f'<div class="response-text">{response_text.replace(chr(10), "<br>")}</div>'}
|
||||
<div class="response-text">{response_text.replace(chr(10), '<br>')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# Get summary score result
|
||||
overall_score = report_data['summary']['overall_score']
|
||||
overall_result, overall_color = get_score_result(overall_score/10) # Normalize to 0-10 scale
|
||||
|
|
@ -1124,9 +1105,7 @@ def generate_html_content(report_data, filename, file_path=None):
|
|||
score_total = 120
|
||||
else:
|
||||
score_total = 100
|
||||
|
||||
technical_html = _render_technical_section_html(report_data.get('technical_report', {}))
|
||||
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
|
@ -1151,9 +1130,6 @@ def generate_html_content(report_data, filename, file_path=None):
|
|||
.summary {{ background: linear-gradient(135deg, #FFF9E6 0%, #FFFBF0 100%); padding: 25px; border-radius: 15px; margin: 30px 0; border-left: 5px solid #FFC407; }}
|
||||
.summary-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 15px; }}
|
||||
.summary-item {{ background: white; padding: 15px; border-radius: 10px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }}
|
||||
.technical {{ background: linear-gradient(135deg, #e3f2fd 0%, #f0f7ff 100%); padding: 25px; border-radius: 15px; margin: 30px 0; border-left: 5px solid #1565c0; }}
|
||||
.technical-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 8px 24px; margin-top: 12px; }}
|
||||
.tech-row {{ padding: 4px 0; color: #495057; font-size: 0.95em; word-break: break-word; }}
|
||||
.score-display {{ font-size: 2.5em; font-weight: bold; color: {overall_color}; margin-bottom: 5px; }}
|
||||
.grade {{ font-size: 1.3em; font-weight: bold; color: #495057; }}
|
||||
.expandable-section {{ margin-bottom: 15px; border: 2px solid #e9ecef; border-radius: 12px; overflow: hidden; background: white; }}
|
||||
|
|
@ -1172,16 +1148,6 @@ def generate_html_content(report_data, filename, file_path=None):
|
|||
.json-toggle {{ cursor: pointer; color: #FFC407; text-decoration: underline; padding: 15px; text-align: center; font-weight: bold; }}
|
||||
.json-view {{ display: none; margin-top: 20px; }}
|
||||
.json-view pre {{ background-color: #2d3748; color: #e2e8f0; padding: 20px; border-radius: 10px; overflow-x: auto; font-size: 0.9em; }}
|
||||
.findings-table {{ width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.92em; }}
|
||||
.findings-table th {{ background: #f1f3f5; color: #495057; text-align: left; padding: 8px 10px; border-bottom: 2px solid #dee2e6; font-weight: 600; }}
|
||||
.findings-table td {{ padding: 8px 10px; border-bottom: 1px solid #e9ecef; vertical-align: top; word-break: break-word; }}
|
||||
.findings-table tr:last-child td {{ border-bottom: none; }}
|
||||
.findings-table code {{ background: #f8f9fa; padding: 2px 5px; border-radius: 4px; font-family: 'SFMono-Regular', Consolas, Menlo, monospace; font-size: 0.9em; color: #c7254e; }}
|
||||
.priority-pill {{ display: inline-block; padding: 3px 8px; border-radius: 10px; color: white; font-weight: 600; font-size: 0.78em; letter-spacing: 0.03em; }}
|
||||
.priority-high {{ background-color: #dc3545; }}
|
||||
.priority-medium {{ background-color: #fd7e14; }}
|
||||
.priority-low {{ background-color: #28a745; }}
|
||||
.muted {{ color: #6c757d; font-size: 0.9em; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -1227,9 +1193,7 @@ def generate_html_content(report_data, filename, file_path=None):
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{technical_html}
|
||||
|
||||
|
||||
<h2>🔍 Detailed Analysis Results</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 20px; font-style: italic;">
|
||||
Click on any section below to expand and view detailed analysis
|
||||
|
|
@ -1275,105 +1239,6 @@ def generate_html_response(report_data, filename, save_to_file=False, session_id
|
|||
else:
|
||||
return Response(html_content, mimetype='text/html')
|
||||
|
||||
def _render_findings_table(findings):
|
||||
"""Render an hp_copy_review-style findings array as an HTML table.
|
||||
|
||||
Each finding dict is expected to carry: priority (high|medium|low),
|
||||
category, quote, issue, suggested_fix, source_reference. All string
|
||||
fields are HTML-escaped before interpolation. An empty/None findings
|
||||
list renders a friendly "clean copy" note instead of an empty table.
|
||||
"""
|
||||
if not findings:
|
||||
return '<p class="muted">No findings — copy is clean.</p>'
|
||||
rows = []
|
||||
for f in findings:
|
||||
priority = (f.get('priority') or 'low').lower()
|
||||
pri_class = {
|
||||
'high': 'priority-high',
|
||||
'medium': 'priority-medium',
|
||||
'low': 'priority-low',
|
||||
}.get(priority, 'priority-low')
|
||||
quote_raw = (f.get('quote') or '')[:200]
|
||||
rows.append(
|
||||
'<tr>'
|
||||
f'<td><span class="priority-pill {pri_class}">{html.escape(priority.upper())}</span></td>'
|
||||
f'<td><code>{html.escape(f.get("category", "") or "")}</code></td>'
|
||||
f'<td><code>{html.escape(quote_raw)}</code></td>'
|
||||
f'<td>{html.escape(f.get("issue", "") or "")}</td>'
|
||||
f'<td>{html.escape(f.get("suggested_fix", "") or "")}</td>'
|
||||
f'<td class="muted">{html.escape(f.get("source_reference", "") or "")}</td>'
|
||||
'</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>'
|
||||
)
|
||||
|
||||
|
||||
def _render_technical_section_html(report):
|
||||
"""Render the technical pre-flight report as an HTML block. Empty string if no report."""
|
||||
if not report or report.get('kind') in (None, 'unknown'):
|
||||
return ''
|
||||
kind = report['kind']
|
||||
rows = []
|
||||
size_mb = report.get('file_size_mb')
|
||||
if size_mb is not None:
|
||||
rows.append(f'<div class="tech-row"><strong>File size:</strong> {size_mb} MB</div>')
|
||||
dims = report.get('dimensions')
|
||||
if dims:
|
||||
rows.append(f'<div class="tech-row"><strong>Dimensions:</strong> {dims["width"]} × {dims["height"]}</div>')
|
||||
fmt = report.get('format')
|
||||
if fmt:
|
||||
rows.append(f'<div class="tech-row"><strong>Format:</strong> {fmt}</div>')
|
||||
dpi = report.get('dpi')
|
||||
if dpi:
|
||||
rows.append(f'<div class="tech-row"><strong>DPI:</strong> {dpi[0]} × {dpi[1]}</div>')
|
||||
mode = report.get('mode')
|
||||
if mode:
|
||||
rows.append(f'<div class="tech-row"><strong>Color mode:</strong> {mode}</div>')
|
||||
pc = report.get('page_count')
|
||||
if pc is not None:
|
||||
rows.append(f'<div class="tech-row"><strong>Pages:</strong> {pc}</div>')
|
||||
pdf_ver = report.get('pdf_version')
|
||||
if pdf_ver:
|
||||
rows.append(f'<div class="tech-row"><strong>PDF version:</strong> {pdf_ver}</div>')
|
||||
duration = report.get('duration_seconds')
|
||||
if duration is not None:
|
||||
rows.append(f'<div class="tech-row"><strong>Duration:</strong> {duration}s</div>')
|
||||
codec = report.get('video_codec')
|
||||
if codec:
|
||||
rows.append(f'<div class="tech-row"><strong>Video codec:</strong> {codec}</div>')
|
||||
fps = report.get('fps')
|
||||
if fps:
|
||||
rows.append(f'<div class="tech-row"><strong>Frame rate:</strong> {fps} fps</div>')
|
||||
fonts = report.get('embedded_fonts')
|
||||
if fonts:
|
||||
suffix = ' …' if len(fonts) > 8 else ''
|
||||
rows.append(f'<div class="tech-row"><strong>Embedded fonts:</strong> {", ".join(fonts[:8])}{suffix}</div>')
|
||||
fm = report.get('filename_match')
|
||||
if fm:
|
||||
if fm['match']:
|
||||
badge = '<span style="background:#28a745;color:white;padding:4px 10px;border-radius:12px;font-size:0.85em;">✓ Matches filename</span>'
|
||||
else:
|
||||
badge = '<span style="background:#dc3545;color:white;padding:4px 10px;border-radius:12px;font-size:0.85em;">⚠ Filename mismatch</span>'
|
||||
rows.append(f'<div class="tech-row" style="margin-top:8px;">{badge} <span style="color:#6c757d;font-size:0.9em;margin-left:8px;">{fm["detail"]}</span></div>')
|
||||
errors = report.get('errors', [])
|
||||
if errors:
|
||||
rows.append(f'<div class="tech-row" style="color:#856404;font-style:italic;"><strong>Inspection notes:</strong> {"; ".join(errors)}</div>')
|
||||
if not rows:
|
||||
return ''
|
||||
return f'''
|
||||
<div class="technical">
|
||||
<h2>🔧 Technical Details <small style="color:#6c757d;font-size:0.6em;font-weight:normal;">(machine-inspected, no AI)</small></h2>
|
||||
<div class="technical-grid">{''.join(rows)}</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
|
||||
def generate_comprehensive_html_report(analysis_result, filename, file_path=None):
|
||||
"""Generate comprehensive HTML report similar to the web UI format"""
|
||||
summary = analysis_result.get('summary', {})
|
||||
|
|
@ -1392,20 +1257,13 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
|
|||
# Generate check results HTML
|
||||
check_results_html = ''
|
||||
for check_name, result in check_results.items():
|
||||
if result.get('status') in ('success', 'completed'):
|
||||
if result.get('status') == 'completed':
|
||||
score = result.get('score', 0)
|
||||
result_text = "Pass" if score >= 6 else "Fail"
|
||||
score_color = '#28a745' if score >= 6 else '#dc3545'
|
||||
response = result.get('response', 'No response available')
|
||||
display_name = check_name.replace('_', ' ').replace(chr(32).join([w.capitalize() for w in check_name.split('_')]), check_name.replace('_', ' ').title())
|
||||
|
||||
# Structured findings (e.g. hp_copy_review) render as a table
|
||||
# instead of the default response-text block. Fallback to the
|
||||
# existing response rendering when 'findings' is absent.
|
||||
json_data = result.get('json_data') if isinstance(result, dict) else None
|
||||
findings = json_data.get('findings') if isinstance(json_data, dict) else None
|
||||
findings_html = _render_findings_table(findings) if findings is not None else None
|
||||
|
||||
|
||||
# Remove JSON blocks for cleaner display and handle empty responses
|
||||
response = re.sub(r'```json.*?```', '', response, flags=re.DOTALL).strip()
|
||||
if not response:
|
||||
|
|
@ -1432,7 +1290,7 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
|
|||
</div>
|
||||
<div class="analysis-section">
|
||||
<h4>Analysis Details:</h4>
|
||||
{f'<div class="response-text">{html.escape(json_data.get("summary", "") or "") if isinstance(json_data, dict) else ""}</div>{findings_html}' if findings_html is not None else f'<div class="response-text">{response.replace(chr(10), "<br>")}</div>'}
|
||||
<div class="response-text">{response.replace(chr(10), '<br>')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1442,9 +1300,7 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
|
|||
avg_individual_score = overall_score / 10 # Normalize to 1-10 scale
|
||||
grade_text = 'Pass' if avg_individual_score >= 6 else 'Fail'
|
||||
score_color = '#28a745' if avg_individual_score >= 6 else '#dc3545'
|
||||
|
||||
technical_html = _render_technical_section_html(analysis_result.get('technical_report', {}))
|
||||
|
||||
|
||||
return f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -1468,9 +1324,6 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
|
|||
.summary {{ background: linear-gradient(135deg, #FFF9E6 0%, #FFFBF0 100%); padding: 25px; border-radius: 15px; margin: 30px 0; border-left: 5px solid #FFC407; }}
|
||||
.summary-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 15px; }}
|
||||
.summary-item {{ background: white; padding: 15px; border-radius: 10px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }}
|
||||
.technical {{ background: linear-gradient(135deg, #e3f2fd 0%, #f0f7ff 100%); padding: 25px; border-radius: 15px; margin: 30px 0; border-left: 5px solid #1565c0; }}
|
||||
.technical-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 8px 24px; margin-top: 12px; }}
|
||||
.tech-row {{ padding: 4px 0; color: #495057; font-size: 0.95em; word-break: break-word; }}
|
||||
.score-display {{ font-size: 2.5em; font-weight: bold; color: {score_color}; margin-bottom: 5px; }}
|
||||
.grade {{ font-size: 1.3em; font-weight: bold; color: #495057; }}
|
||||
.expandable-section {{ margin-bottom: 15px; border: 2px solid #e9ecef; border-radius: 12px; overflow: hidden; background: white; }}
|
||||
|
|
@ -1486,16 +1339,6 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
|
|||
.check-metadata {{ background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 15px; }}
|
||||
.analysis-section h4 {{ color: #495057; margin-bottom: 10px; }}
|
||||
.response-text {{ background: #f8f9fa; padding: 15px; border-radius: 8px; line-height: 1.6; font-family: 'Montserrat', Georgia, serif; }}
|
||||
.findings-table {{ width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.92em; }}
|
||||
.findings-table th {{ background: #f1f3f5; color: #495057; text-align: left; padding: 8px 10px; border-bottom: 2px solid #dee2e6; font-weight: 600; }}
|
||||
.findings-table td {{ padding: 8px 10px; border-bottom: 1px solid #e9ecef; vertical-align: top; word-break: break-word; }}
|
||||
.findings-table tr:last-child td {{ border-bottom: none; }}
|
||||
.findings-table code {{ background: #f8f9fa; padding: 2px 5px; border-radius: 4px; font-family: 'SFMono-Regular', Consolas, Menlo, monospace; font-size: 0.9em; color: #c7254e; }}
|
||||
.priority-pill {{ display: inline-block; padding: 3px 8px; border-radius: 10px; color: white; font-weight: 600; font-size: 0.78em; letter-spacing: 0.03em; }}
|
||||
.priority-high {{ background-color: #dc3545; }}
|
||||
.priority-medium {{ background-color: #fd7e14; }}
|
||||
.priority-low {{ background-color: #28a745; }}
|
||||
.muted {{ color: #6c757d; font-size: 0.9em; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -1541,9 +1384,7 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{technical_html}
|
||||
|
||||
|
||||
<h2>🔍 Detailed Analysis Results</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 20px; font-style: italic;">
|
||||
Click on any section below to expand and view detailed analysis
|
||||
|
|
@ -1707,15 +1548,6 @@ def get_reference_asset_content(reference_asset_id):
|
|||
reference_content += f"\nLocalization Matrix: Contains {', '.join(loc_messages)} "
|
||||
reference_content += f"for {len(loc_countries)} markets ({', '.join(loc_countries[:10])}).\n"
|
||||
reference_content += "Expected copy will be cross-referenced with the media plan during analysis.\n"
|
||||
elif file_record.get('summary_path'):
|
||||
# Source-messaging Excel (HP and similar) — inject the Gemini-generated Markdown summary
|
||||
try:
|
||||
with open(file_record['summary_path'], 'r', encoding='utf-8') as f:
|
||||
summary = f.read()
|
||||
reference_content += f"\nSource Messaging Summary (extracted from {original_filename}):\n{summary}\n"
|
||||
except Exception as e:
|
||||
print(f"Failed to read source-messaging summary at {file_record['summary_path']}: {e}")
|
||||
reference_content += f"\nReference file ({file_ext}) uploaded but summary unreadable.\n"
|
||||
else:
|
||||
reference_content += f"\nReference file ({file_ext}) uploaded as reference.\n"
|
||||
else:
|
||||
|
|
@ -1913,11 +1745,6 @@ def start_analysis():
|
|||
file_path = os.path.join(session_folder, file.filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Machine-side technical pre-flight (PIL/PyMuPDF/ffprobe, no LLM).
|
||||
# Stored on progress_tracker so process_single_check can prepend it to
|
||||
# every LLM prompt, and surfaced in result_data for the UI.
|
||||
technical_report = technical_inspect(file_path)
|
||||
|
||||
# Derive client from profile if not provided
|
||||
client = request.form.get('client_id', request.form.get('client', 'general')).lower()
|
||||
if not client or client == 'general':
|
||||
|
|
@ -1968,8 +1795,7 @@ def start_analysis():
|
|||
'stage': 'setup',
|
||||
'percentage': 0,
|
||||
'session_id': session_id,
|
||||
'status': 'started',
|
||||
'technical_report': technical_report,
|
||||
'status': 'started'
|
||||
}
|
||||
|
||||
# Start analysis in background thread with explicit parameters
|
||||
|
|
@ -2189,8 +2015,7 @@ def start_analysis():
|
|||
'total_weighted_score': total_weighted_score,
|
||||
'total_weight': total_weight,
|
||||
'grade': determine_grade(overall_score)
|
||||
},
|
||||
'technical_report': progress_tracker[session_id].get('technical_report', {}),
|
||||
}
|
||||
}
|
||||
|
||||
# L'Oreal Static override: fail if ANY individual check fails (score < 6)
|
||||
|
|
@ -2350,9 +2175,6 @@ def start_document_analysis():
|
|||
file_path = os.path.join(session_folder, file.filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Machine-side technical pre-flight (PyMuPDF for PDFs, no LLM).
|
||||
technical_report = technical_inspect(file_path)
|
||||
|
||||
# Pre-render directory for per-page PNGs lives alongside the PDF
|
||||
pages_dir = os.path.join(session_folder, 'pages')
|
||||
|
||||
|
|
@ -2381,7 +2203,6 @@ def start_document_analysis():
|
|||
'session_id': session_id,
|
||||
'status': 'started',
|
||||
'mode': 'document',
|
||||
'technical_report': technical_report,
|
||||
}
|
||||
|
||||
def run_document(session_id, file_path, filename, profile_id, client, output_mode, reference_asset, user_info):
|
||||
|
|
@ -2454,7 +2275,6 @@ def start_document_analysis():
|
|||
'pages_processed': doc_result.get('pages_processed', 0),
|
||||
'page_count': doc_result.get('page_count', 0),
|
||||
},
|
||||
'technical_report': progress_tracker[session_id].get('technical_report', {}),
|
||||
}
|
||||
|
||||
if paths.get('html'):
|
||||
|
|
@ -3014,83 +2834,6 @@ def list_all_clients_endpoint():
|
|||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/clients/<client_id>/default_profile', methods=['GET'])
|
||||
@auth.require_auth
|
||||
def get_client_default_profile_endpoint(client_id):
|
||||
"""Return the effective default profile for a client (override or static).
|
||||
|
||||
Auth-required (any signed-in user) — read-only, no admin gate.
|
||||
"""
|
||||
try:
|
||||
from client_config import get_all_clients, get_default_profile, get_client_profiles
|
||||
clients = get_all_clients()
|
||||
if client_id not in clients:
|
||||
return jsonify({'status': 'error', 'message': f'unknown client: {client_id}'}), 404
|
||||
access_err = _require_client_access(client_id)
|
||||
if access_err:
|
||||
return access_err
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'client_id': client_id,
|
||||
'profiles': get_client_profiles(client_id),
|
||||
'default_profile': get_default_profile(client_id),
|
||||
'static_default': clients[client_id].get('default_profile'),
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/clients/<client_id>/default_profile', methods=['PUT'])
|
||||
@auth.require_auth
|
||||
def set_client_default_profile_endpoint(client_id):
|
||||
"""Admin-only: set the default profile for a client (persisted as a runtime override).
|
||||
|
||||
Body: {"profile_id": "<id>"}. The profile must already be in the client's
|
||||
`profiles` list — we don't allow defaulting to a profile the client can't see.
|
||||
Posts to backend/client_defaults.json so a bad write can never break server boot.
|
||||
"""
|
||||
actor_email, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
profile_id = (body.get('profile_id') or '').strip()
|
||||
if not profile_id:
|
||||
return jsonify({'status': 'error', 'message': 'profile_id is required'}), 400
|
||||
from client_config import set_default_profile
|
||||
ok, reason = set_default_profile(client_id, profile_id)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': reason}), 400
|
||||
print(f'Admin {actor_email}: set default_profile for {client_id} → {profile_id}')
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'client_id': client_id,
|
||||
'default_profile': profile_id,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/clients/<client_id>/default_profile', methods=['DELETE'])
|
||||
@auth.require_auth
|
||||
def clear_client_default_profile_endpoint(client_id):
|
||||
"""Admin-only: clear the runtime override so the static default applies again."""
|
||||
actor_email, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
from client_config import clear_default_profile_override, get_default_profile
|
||||
clear_default_profile_override(client_id)
|
||||
print(f'Admin {actor_email}: cleared default_profile override for {client_id}')
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'client_id': client_id,
|
||||
'default_profile': get_default_profile(client_id),
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/access_request', methods=['POST'])
|
||||
@auth.require_auth
|
||||
def request_client_access():
|
||||
|
|
@ -4916,15 +4659,15 @@ def upload_brand_guideline():
|
|||
).start()
|
||||
file_record['processing_status'] = 'processing'
|
||||
|
||||
# Trigger Excel processing: try localization matrix first (existing
|
||||
# clients), fall back to Source Messaging summary (HP and similar).
|
||||
# Trigger localization matrix parsing for Excel files
|
||||
elif file_record.get('file_type') in ('.xlsx', '.xls'):
|
||||
import threading
|
||||
def _process_excel_bg(fid, spath, fdir):
|
||||
def _process_localization_bg(fid, spath, fdir):
|
||||
try:
|
||||
from localization_processor import parse_localization_matrix
|
||||
parsed = parse_localization_matrix(spath)
|
||||
if parsed:
|
||||
# Save parsed JSON
|
||||
json_path = os.path.join(fdir, f"{fid}_localization.json")
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(parsed, f, indent=2, ensure_ascii=False)
|
||||
|
|
@ -4939,34 +4682,25 @@ def upload_brand_guideline():
|
|||
print(f"Localization matrix parsing complete for {fid}: "
|
||||
f"{len(parsed.get('messages', {}))} messages, "
|
||||
f"{len(parsed.get('countries', []))} countries")
|
||||
return
|
||||
|
||||
# Not a localization matrix — process as Source Messaging
|
||||
# (HP-style structured Markdown summary via Gemini).
|
||||
from excel_processor import process_excel_file
|
||||
summary_text, summary_path = process_excel_file(spath, fid)
|
||||
brand_db.update_file_record(fid, {
|
||||
'processed': True,
|
||||
'processed_at': datetime.now().isoformat(),
|
||||
'summary_path': summary_path,
|
||||
'summary_length': len(summary_text),
|
||||
'cover_image_path': None,
|
||||
'asset_type': 'source_messaging',
|
||||
})
|
||||
print(f"Source-messaging summary complete for {fid}: "
|
||||
f"{len(summary_text)} chars")
|
||||
else:
|
||||
brand_db.update_file_record(fid, {
|
||||
'processed': True,
|
||||
'processed_at': datetime.now().isoformat(),
|
||||
'asset_type': 'excel_file',
|
||||
})
|
||||
print(f"Excel file {fid} is not a localization matrix, stored as-is")
|
||||
except Exception as e:
|
||||
print(f"Excel processing failed for {fid}: {e}")
|
||||
print(f"Localization matrix parsing failed for {fid}: {e}")
|
||||
brand_db.update_file_record(fid, {
|
||||
'processed': 'error',
|
||||
'processing_error': str(e),
|
||||
'processing_error': str(e)
|
||||
})
|
||||
|
||||
threading.Thread(
|
||||
target=_process_excel_bg,
|
||||
target=_process_localization_bg,
|
||||
args=(file_record['id'], file_record['stored_path'],
|
||||
str(brand_db.files_dir)),
|
||||
daemon=True,
|
||||
daemon=True
|
||||
).start()
|
||||
file_record['processing_status'] = 'processing'
|
||||
|
||||
|
|
@ -6132,339 +5866,6 @@ def _box_redirect_uri():
|
|||
return f'{request.scheme}://{host}/auth/box/callback'
|
||||
|
||||
|
||||
# ---------- Box JWT service-account: webhook ingestion + workflow helper ----------
|
||||
|
||||
# Bounded in-memory dedup for box-delivery-id. Box uses at-least-once delivery;
|
||||
# a 200 from us tells it not to retry, so duplicates are rare. The maxlen keeps
|
||||
# memory tiny while still catching the common retry window.
|
||||
_box_recent_deliveries = collections.deque(maxlen=500)
|
||||
_box_recent_deliveries_set = set()
|
||||
_box_recent_deliveries_lock = threading.Lock()
|
||||
|
||||
# Extensions accepted from a Box upload. Keeps the webhook from kicking off QC
|
||||
# on Word docs, ZIPs, etc. Mirrors what technical_check.inspect knows how to read.
|
||||
_BOX_QC_EXTS = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.pdf',
|
||||
'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm',
|
||||
}
|
||||
|
||||
|
||||
def _box_remember_delivery(delivery_id):
|
||||
"""Return True if first time seeing this delivery_id; False if duplicate."""
|
||||
with _box_recent_deliveries_lock:
|
||||
if delivery_id in _box_recent_deliveries_set:
|
||||
return False
|
||||
_box_recent_deliveries.append(delivery_id)
|
||||
_box_recent_deliveries_set.add(delivery_id)
|
||||
if len(_box_recent_deliveries_set) > _box_recent_deliveries.maxlen:
|
||||
_box_recent_deliveries_set.clear()
|
||||
_box_recent_deliveries_set.update(_box_recent_deliveries)
|
||||
return True
|
||||
|
||||
|
||||
def _run_box_triggered_analysis(client_id, profile_id, file_id, filename, session_id):
|
||||
"""Background worker for the Box webhook flow.
|
||||
|
||||
Downloads the Box file, runs the technical pre-flight + LLM check pipeline,
|
||||
writes the HTML report to disk under output/<client>/, and uploads the same
|
||||
report back to the client's box_reports_folder_id (or box_folder_id as a
|
||||
fallback). Uses a synthetic 'box_webhook' user for usage tracking.
|
||||
|
||||
Skips media-plan and localization context — those are user-UI concepts that
|
||||
don't have a meaningful source in a webhook-triggered run.
|
||||
"""
|
||||
try:
|
||||
from client_config import get_all_clients as _get_all_clients
|
||||
client_cfg = _get_all_clients().get(client_id, {})
|
||||
session_folder = os.path.join(app.config['UPLOAD_FOLDER'], session_id)
|
||||
os.makedirs(session_folder, exist_ok=True)
|
||||
file_path = os.path.join(session_folder, filename)
|
||||
|
||||
# 1. Download the asset from Box.
|
||||
box_jwt_client.download_file(file_id, file_path)
|
||||
print(f'Box webhook: downloaded {filename} → {file_path}')
|
||||
|
||||
# 2. Technical pre-flight (same as user-uploaded flow).
|
||||
technical_report = technical_inspect(file_path)
|
||||
|
||||
# 3. Init progress tracker.
|
||||
progress_tracker[session_id] = {
|
||||
'total_checks': 25,
|
||||
'completed_checks': 0,
|
||||
'current_check': 'Initializing',
|
||||
'current_check_display': 'Box-triggered analysis',
|
||||
'stage': 'setup',
|
||||
'percentage': 0,
|
||||
'session_id': session_id,
|
||||
'status': 'started',
|
||||
'source': 'box_webhook',
|
||||
'box_file_id': file_id,
|
||||
'technical_report': technical_report,
|
||||
}
|
||||
|
||||
# 4. Log analysis start with a synthetic system user.
|
||||
try:
|
||||
from usage_tracker import log_analysis_start
|
||||
log_analysis_start(
|
||||
session_id, client_id, profile_id,
|
||||
{'user_id': 'box_webhook', 'email': 'box_webhook@system', 'name': 'Box Webhook'},
|
||||
{'filename': filename, 'size': os.path.getsize(file_path)},
|
||||
)
|
||||
except Exception as log_err:
|
||||
print(f'WARNING: usage log_analysis_start failed: {log_err}')
|
||||
|
||||
# 5. Resolve profile + enabled checks.
|
||||
profile_config = get_profile(profile_id)
|
||||
if not profile_config:
|
||||
raise Exception(f'Profile {profile_id} not found')
|
||||
enabled_checks = [c for c in profile_config.get_enabled_checks() if c in qc_apps]
|
||||
if not enabled_checks:
|
||||
raise Exception(f'No enabled checks for profile {profile_id}')
|
||||
profile_weights = profile_config.get_check_weights()
|
||||
progress_tracker[session_id].update({
|
||||
'total_checks': len(enabled_checks),
|
||||
'stage': 'qc_analysis',
|
||||
'percentage': 10,
|
||||
})
|
||||
|
||||
# 6. Run check batches (no media plan / localization / OCR in webhook MVP).
|
||||
check_results = process_checks_in_batches(
|
||||
enabled_checks, qc_apps, profile_config, profile_weights,
|
||||
file_path, None, brand_db, progress_tracker,
|
||||
session_id, batch_size=15, media_plan_context=None, ocr_context=None,
|
||||
)
|
||||
|
||||
# 7. Score aggregation.
|
||||
total_weighted_score = 0
|
||||
total_weight = 0
|
||||
completed_checks = 0
|
||||
failed_checks = 0
|
||||
for _check_name, result in check_results.items():
|
||||
w = result.get('weight', 0.1)
|
||||
total_weight += w
|
||||
if result.get('status') == 'success':
|
||||
completed_checks += 1
|
||||
s = result.get('score')
|
||||
if s is not None:
|
||||
total_weighted_score += s * w
|
||||
else:
|
||||
failed_checks += 1
|
||||
if total_weight >= 10.0:
|
||||
overall_score = min(total_weighted_score, 100)
|
||||
else:
|
||||
overall_score = min(total_weighted_score * 10, 100)
|
||||
|
||||
# 8. Result envelope matching the user-flow shape.
|
||||
result_data = {
|
||||
'status': 'success',
|
||||
'session_id': session_id,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'filename': filename,
|
||||
'profile': profile_id,
|
||||
'profile_id': profile_id,
|
||||
'profile_name': profile_config.name,
|
||||
'model': 'Profile-based selection',
|
||||
'results': check_results,
|
||||
'profile_selection': {
|
||||
'selected_profile': profile_id,
|
||||
'profile_source': 'box_webhook',
|
||||
'brand': client_id,
|
||||
'format_suffix': profile_id,
|
||||
'reference_asset': None,
|
||||
'reference_asset_used': False,
|
||||
},
|
||||
'qc_analysis': {
|
||||
'profile_used': profile_id,
|
||||
'total_checks': len(enabled_checks),
|
||||
'completed_checks': completed_checks,
|
||||
'failed_checks': failed_checks,
|
||||
'check_results': check_results,
|
||||
},
|
||||
'summary': {
|
||||
'overall_score': round(overall_score, 1),
|
||||
'profile': profile_config.name,
|
||||
'checks_count': completed_checks,
|
||||
'total_checks': len(enabled_checks),
|
||||
'total_weighted_score': total_weighted_score,
|
||||
'total_weight': total_weight,
|
||||
'grade': determine_grade(overall_score),
|
||||
},
|
||||
'technical_report': progress_tracker[session_id].get('technical_report', {}),
|
||||
'source': 'box_webhook',
|
||||
'box_file_id': file_id,
|
||||
}
|
||||
|
||||
# Strict-grade override applies the same way for webhook-triggered runs.
|
||||
if getattr(profile_config, 'strict_grade', False):
|
||||
for _cn, cd in check_results.items():
|
||||
if cd.get('status') == 'success':
|
||||
cs = cd.get('score', 0)
|
||||
if cs is not None and cs < 6:
|
||||
result_data['summary']['grade'] = 'Fail'
|
||||
break
|
||||
|
||||
# 9. Write HTML report to disk so the UI's saved-files listing shows it.
|
||||
report_filename = f'QC_Report_{session_id}_{os.path.splitext(filename)[0]}.html'
|
||||
client_folder = ensure_client_output_folder(client_id)
|
||||
report_path = os.path.join(client_folder, report_filename)
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(generate_comprehensive_html_report(result_data, filename, file_path=file_path))
|
||||
result_data['output_file'] = {
|
||||
'path': report_path,
|
||||
'filename': report_filename,
|
||||
'url': f'/output/{client_id}/{report_filename}',
|
||||
}
|
||||
|
||||
# 10. Upload the report back to Box. Prefer the dedicated reports folder if
|
||||
# configured; fall back to the same folder the source lived in.
|
||||
reports_folder = client_cfg.get('box_reports_folder_id') or client_cfg.get('box_folder_id')
|
||||
report_uploaded_ok = False
|
||||
if reports_folder:
|
||||
try:
|
||||
uploaded = box_jwt_client.upload_file(report_path, str(reports_folder), name=report_filename)
|
||||
result_data['box_report_upload'] = {
|
||||
'box_file_id': uploaded.get('id'),
|
||||
'box_file_name': uploaded.get('name'),
|
||||
'box_folder_id': str(reports_folder),
|
||||
}
|
||||
report_uploaded_ok = True
|
||||
print(f"Box webhook: uploaded report {report_filename} → folder {reports_folder} (id={uploaded.get('id')})")
|
||||
except Exception as up_err:
|
||||
print(f'WARNING: failed to upload report to Box: {up_err}')
|
||||
result_data['box_report_upload_error'] = str(up_err)
|
||||
else:
|
||||
print(f'Box webhook: no box_reports_folder_id (or box_folder_id) on client {client_id}; report stays local only')
|
||||
|
||||
# 10b. Move the source file out of INCOMING into a `_PROCESSED` subfolder so the
|
||||
# next upload of the same filename triggers a fresh FILE.UPLOADED event (Box's
|
||||
# V2 webhook doesn't fire on same-name version replacements; freeing the name
|
||||
# is the cleanest workaround). Only runs if the report made it back to Box —
|
||||
# if upload failed, we want the source to stay so the user can retry by simply
|
||||
# re-uploading. Failures here are non-fatal: log, record, continue.
|
||||
source_folder_id = client_cfg.get('box_folder_id')
|
||||
if report_uploaded_ok and source_folder_id:
|
||||
try:
|
||||
processed_folder_id = box_jwt_client.find_or_create_subfolder(
|
||||
str(source_folder_id), '_PROCESSED'
|
||||
)
|
||||
processed_name = f'{session_id}_{filename}'
|
||||
moved = box_jwt_client.move_file(file_id, processed_folder_id, new_name=processed_name)
|
||||
result_data['box_source_moved'] = {
|
||||
'box_file_id': moved.get('id'),
|
||||
'box_folder_id': processed_folder_id,
|
||||
'box_file_name': moved.get('name'),
|
||||
}
|
||||
print(f'Box webhook: moved source → _PROCESSED/{processed_name} (folder {processed_folder_id})')
|
||||
except Exception as mv_err:
|
||||
print(f'WARNING: failed to move source file to _PROCESSED: {mv_err}')
|
||||
result_data['box_source_move_error'] = str(mv_err)
|
||||
|
||||
# 11. Mark complete on progress_tracker for /api/progress consumers.
|
||||
progress_tracker[session_id]['result'] = result_data
|
||||
progress_tracker[session_id]['status'] = 'completed'
|
||||
progress_tracker[session_id]['stage'] = 'complete'
|
||||
progress_tracker[session_id]['percentage'] = 100
|
||||
|
||||
# 12. Usage tracker completion event.
|
||||
try:
|
||||
from usage_tracker import log_analysis_complete
|
||||
log_analysis_complete(
|
||||
session_id, client_id, profile_id,
|
||||
{'user_id': 'box_webhook', 'email': 'box_webhook@system', 'name': 'Box Webhook'},
|
||||
{'checks_completed': completed_checks, 'overall_score': overall_score,
|
||||
'status': 'success', 'source': 'box_webhook'},
|
||||
)
|
||||
except Exception as log_err:
|
||||
print(f'WARNING: usage log_analysis_complete failed: {log_err}')
|
||||
|
||||
print(f'Box webhook: analysis complete for session {session_id}, score {overall_score}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'ERROR in Box-triggered analysis (session {session_id}): {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
if session_id in progress_tracker:
|
||||
progress_tracker[session_id]['status'] = 'error'
|
||||
progress_tracker[session_id]['stage'] = 'error'
|
||||
progress_tracker[session_id]['error'] = str(e)
|
||||
|
||||
|
||||
@app.route('/api/box/webhook', methods=['POST'])
|
||||
def box_webhook():
|
||||
"""Receive a Box V2 webhook. Authenticated by HMAC signature on every request.
|
||||
|
||||
Box expects a 200 within ~10 seconds. We verify the signature, ack, and run
|
||||
the analysis on a background thread.
|
||||
"""
|
||||
raw_body = request.get_data(cache=True)
|
||||
headers = {k.lower(): v for k, v in request.headers.items()}
|
||||
|
||||
primary_key = os.environ.get('BOX_WEBHOOK_PRIMARY_KEY')
|
||||
secondary_key = os.environ.get('BOX_WEBHOOK_SECONDARY_KEY')
|
||||
if not primary_key and not secondary_key:
|
||||
print('Box webhook: no signing keys in env (set BOX_WEBHOOK_PRIMARY_KEY); refusing all deliveries')
|
||||
return jsonify({'status': 'error', 'message': 'webhook signing not configured'}), 503
|
||||
|
||||
if not box_jwt_client.verify_webhook_signature(raw_body, headers, primary_key, secondary_key):
|
||||
print('Box webhook: signature verification failed')
|
||||
return jsonify({'status': 'error', 'message': 'invalid signature'}), 401
|
||||
|
||||
delivery_id = headers.get('box-delivery-id', '')
|
||||
if delivery_id and not _box_remember_delivery(delivery_id):
|
||||
return jsonify({'status': 'ok', 'message': 'duplicate'}), 200
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'invalid JSON'}), 400
|
||||
|
||||
trigger = payload.get('trigger', '')
|
||||
if trigger != 'FILE.UPLOADED':
|
||||
return jsonify({'status': 'ok', 'message': f'ignored trigger {trigger}'}), 200
|
||||
|
||||
source = payload.get('source') or {}
|
||||
if source.get('type') != 'file':
|
||||
return jsonify({'status': 'ok', 'message': 'not a file event'}), 200
|
||||
|
||||
file_id = str(source.get('id', ''))
|
||||
filename = source.get('name', '')
|
||||
parent = source.get('parent') or {}
|
||||
parent_folder_id = str(parent.get('id', ''))
|
||||
if not file_id or not parent_folder_id or not filename:
|
||||
return jsonify({'status': 'error', 'message': 'malformed payload'}), 400
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in _BOX_QC_EXTS:
|
||||
print(f'Box webhook: skipping non-QC extension {ext} ({filename})')
|
||||
return jsonify({'status': 'ok', 'message': f'unsupported extension {ext}'}), 200
|
||||
|
||||
from client_config import get_client_by_box_folder, get_all_clients, get_default_profile
|
||||
client_id = get_client_by_box_folder(parent_folder_id)
|
||||
if not client_id:
|
||||
print(f'Box webhook: no client configured for Box folder {parent_folder_id}')
|
||||
return jsonify({'status': 'ok', 'message': 'no client mapping'}), 200
|
||||
|
||||
client_cfg = get_all_clients().get(client_id, {})
|
||||
profile_id = get_default_profile(client_id) or (client_cfg.get('profiles') or ['static_general'])[0]
|
||||
|
||||
session_id = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
print(f'Box webhook: dispatching session={session_id} client={client_id} profile={profile_id} file_id={file_id} name={filename}')
|
||||
|
||||
threading.Thread(
|
||||
target=_run_box_triggered_analysis,
|
||||
args=(client_id, profile_id, file_id, filename, session_id),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'session_id': session_id,
|
||||
'client_id': client_id,
|
||||
'profile_id': profile_id,
|
||||
}), 200
|
||||
|
||||
|
||||
@app.route('/auth/box/login', methods=['GET'])
|
||||
@auth.require_auth
|
||||
def box_login():
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -37,6 +34,12 @@ CLIENT_PROFILES = {
|
|||
'display_name': 'Boots',
|
||||
'description': 'Boots retail promotional artwork compliance checks'
|
||||
},
|
||||
'dow_jones': {
|
||||
'name': 'Dow Jones',
|
||||
'profiles': ['dow_jones_static', 'marketwatch_static', 'wsj_static', 'wsj_podcast', 'static_general', 'video_general'],
|
||||
'display_name': 'Dow Jones',
|
||||
'description': 'Dow Jones brand profiles for corporate, MarketWatch, and WSJ sub-brands'
|
||||
},
|
||||
'honda': {
|
||||
'name': 'Honda',
|
||||
'profiles': ['static_general', 'video_general'],
|
||||
|
|
@ -45,7 +48,7 @@ CLIENT_PROFILES = {
|
|||
},
|
||||
'axa': {
|
||||
'name': 'AXA',
|
||||
'profiles': ['axa_policy_document', 'axa_policy_document_diff', 'axa_accessibility', 'static_general', 'video_general'],
|
||||
'profiles': ['axa_policy_document', 'axa_policy_document_diff', 'static_general', 'video_general'],
|
||||
'display_name': 'AXA',
|
||||
'description': 'AXA brand profiles, including multi-page policy document QC for AXA Ireland'
|
||||
},
|
||||
|
|
@ -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,7 +36,7 @@ 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)
|
||||
# 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)
|
||||
|
|
@ -46,17 +46,4 @@ REPORT_EMAILS=admin@your-domain.com
|
|||
# to set BOX_REDIRECT_URI per server — uncomment only as an override.
|
||||
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=
|
||||
43
backend/config/development.env
Normal file
43
backend/config/development.env
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# 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)
|
||||
# Redirect URI is computed from each request — no need to hardcode it per server.
|
||||
# Set BOX_REDIRECT_URI here only as an override if request-based detection fails.
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
42
backend/config/production.env
Normal file
42
backend/config/production.env
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# 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)
|
||||
# Redirect URI is computed from each request — no need to hardcode it per server.
|
||||
# Set BOX_REDIRECT_URI here only as an override if request-based detection fails.
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
|
|
@ -1,46 +1,35 @@
|
|||
"""PDF accessibility checks aligned to PDF/UA-1.
|
||||
"""PDF accessibility checks aligned to PDF/UA-1 + WCAG-AAA-relevant subset.
|
||||
|
||||
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 Python implementation using PyMuPDF — no Java/veraPDF needed
|
||||
to ship Phase 4. Once veraPDF is installed on the host, _run_verapdf() can
|
||||
be wired in as an additional validation layer (see __doc__ for that fn).
|
||||
|
||||
Deterministic criteria:
|
||||
Criteria checked (subset of the 30+ rules in PDF/UA-1 §7):
|
||||
• 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
|
||||
• C7 PDF version — 1.5+ recommended (older versions can't carry full
|
||||
accessibility tagging features)
|
||||
• C8 XMP UA-conformance — XMP metadata declares pdfuaid:part
|
||||
• C9 Image alt text — sampled images have /Alt or /ActualText
|
||||
• C9 Image alt text — sampled images have /Alt or /ActualText in the
|
||||
structure tree (heuristic: looks for /Alt anywhere in the catalog
|
||||
graph; not a full structure-tree walk).
|
||||
|
||||
Each criterion gets a pass/fail and a short observation. The check's
|
||||
overall score = (passing_criteria / total_criteria) * 10.
|
||||
"""
|
||||
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -248,11 +237,10 @@ def _check_alt_text_sampling(doc: fitz.Document) -> Dict:
|
|||
|
||||
|
||||
def axa_pdf_accessibility(ingest_result: Dict, scope_args: Optional[Dict] = None) -> Dict:
|
||||
"""Run PDF/UA-1 accessibility validation on the ingested PDF.
|
||||
"""Run the full PDF/UA-aligned check set 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.
|
||||
Requires `pdf_path` on ingest_result (set by the dispatcher). Falls
|
||||
back to a structured-error result if PDF can't be opened.
|
||||
"""
|
||||
pdf_path = ingest_result.get('pdf_path')
|
||||
if not pdf_path:
|
||||
|
|
@ -294,24 +282,22 @@ def axa_pdf_accessibility(ingest_result: Dict, scope_args: Optional[Dict] = None
|
|||
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)
|
||||
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
|
||||
|
||||
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)
|
||||
if pass_flag:
|
||||
summary = f'All {total} accessibility criteria passed.'
|
||||
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).'
|
||||
summary = f'{len(failed)} of {total} accessibility criteria failed.'
|
||||
|
||||
response = _build_response_text(summary, criteria, verapdf if verapdf_ok else None)
|
||||
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_pdf_accessibility',
|
||||
|
|
@ -321,182 +307,32 @@ def axa_pdf_accessibility(ingest_result: Dict, scope_args: Optional[Dict] = None
|
|||
'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,
|
||||
'criteria_total': total,
|
||||
'criteria_passed': len(passed),
|
||||
'criteria_failed': len(failed),
|
||||
'verapdf_run': False, # set to True when veraPDF subprocess is wired in
|
||||
},
|
||||
'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
|
||||
# veraPDF integration stub — wire when Java is on the host
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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.
|
||||
"""Stub for veraPDF subprocess validation.
|
||||
|
||||
To enable:
|
||||
1. Install veraPDF on the host: https://verapdf.org/software/
|
||||
(requires JRE 8+; ~150MB total).
|
||||
2. Ensure `verapdf` binary is on PATH or set VERAPDF_BIN env var.
|
||||
3. Replace this stub with subprocess.run([verapdf, '--format', 'json',
|
||||
'--profile', 'ua1', pdf_path], capture_output=True). Parse the
|
||||
JSON output and merge into axa_pdf_accessibility's findings.
|
||||
4. Set findings['verapdf_run'] = True so the report shows it ran.
|
||||
|
||||
Currently returns None so callers know veraPDF was not invoked.
|
||||
"""
|
||||
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,
|
||||
}
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -15,20 +15,12 @@ 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
|
||||
|
|
@ -207,47 +199,6 @@ def run_document_analysis(
|
|||
'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']
|
||||
|
|
@ -255,24 +206,30 @@ def run_document_analysis(
|
|||
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',
|
||||
})
|
||||
|
||||
for page_num in page_nums:
|
||||
page = pages[page_num - 1]
|
||||
if not page.get('image_path'):
|
||||
continue
|
||||
progress_tracker[session_id].update({
|
||||
'current_check_display': f'{check_name} on page {page_num} (sample)',
|
||||
'current_page': page_num,
|
||||
})
|
||||
page_check_results = process_checks_in_batches(
|
||||
enabled_checks=[check_name],
|
||||
qc_apps=qc_apps,
|
||||
profile_config=profile_config,
|
||||
profile_weights=profile_weights,
|
||||
file_path=page['image_path'],
|
||||
analysis_reference_asset=analysis_reference_asset,
|
||||
brand_db=brand_db,
|
||||
progress_tracker=progress_tracker,
|
||||
session_id=session_id,
|
||||
batch_size=15,
|
||||
media_plan_context=media_plan_context,
|
||||
ocr_context=ocr_context,
|
||||
)
|
||||
page_level_results[check_name][page_num] = page_check_results.get(check_name, {})
|
||||
# Aggregate the sampled results into the doc-level entry
|
||||
page_scores = {p: (r.get('score') or 0) for p, r in page_level_results[check_name].items()}
|
||||
scores = list(page_scores.values())
|
||||
|
|
@ -305,27 +262,39 @@ def run_document_analysis(
|
|||
# average that drives the headline score & grade. This implements the
|
||||
# strict-grade exemption requested for Boots Production Packs without
|
||||
# changing AXA-style profiles (which don't tag pages → all pages count).
|
||||
page_type_map = {p['page_num']: p.get('page_type', 'artwork') for p in pages}
|
||||
artwork_page_nums = {pn for pn, pt in page_type_map.items() if pt == 'artwork'}
|
||||
|
||||
if scope_buckets['page_each']:
|
||||
for check_name, _scope_args in scope_buckets['page_each']:
|
||||
page_level_results.setdefault(check_name, {})
|
||||
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',
|
||||
})
|
||||
for page in pages:
|
||||
page_num = page['page_num']
|
||||
if not page.get('image_path'):
|
||||
continue
|
||||
progress_tracker[session_id].update({
|
||||
'current_check_display': f'{check_name} on page {page_num}',
|
||||
'current_page': page_num,
|
||||
})
|
||||
page_check_results = process_checks_in_batches(
|
||||
enabled_checks=[check_name],
|
||||
qc_apps=qc_apps,
|
||||
profile_config=profile_config,
|
||||
profile_weights=profile_weights,
|
||||
file_path=page['image_path'],
|
||||
analysis_reference_asset=analysis_reference_asset,
|
||||
brand_db=brand_db,
|
||||
progress_tracker=progress_tracker,
|
||||
session_id=session_id,
|
||||
batch_size=15,
|
||||
media_plan_context=media_plan_context,
|
||||
ocr_context=ocr_context,
|
||||
)
|
||||
result_for_page = page_check_results.get(check_name, {})
|
||||
# Tag the per-page result with its page_type so the report
|
||||
# writer can group results by page category.
|
||||
result_for_page['page_type'] = page_type_map.get(page_num, 'artwork')
|
||||
page_level_results[check_name][page_num] = result_for_page
|
||||
|
||||
page_scores = {p: (r.get('score') or 0) for p, r in page_level_results[check_name].items()}
|
||||
artwork_scores = {p: s for p, s in page_scores.items() if p in artwork_page_nums}
|
||||
|
|
|
|||
|
|
@ -235,60 +235,14 @@ def _render_pdf_accessibility(findings: Dict) -> str:
|
|||
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}
|
||||
<strong>{passed} / {total}</strong> PDF/UA-aligned criteria passed
|
||||
· veraPDF: {'<span class="ok">enabled</span>' if verapdf_run else '<span class="muted">not run (Java not installed)</span>'}
|
||||
</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>'
|
||||
|
|
@ -307,7 +261,7 @@ def _render_pdf_accessibility(findings: Dict) -> str:
|
|||
</tr>
|
||||
""")
|
||||
|
||||
return head + verapdf_block + f"""
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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,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,6 +1,6 @@
|
|||
{
|
||||
"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.",
|
||||
"description": "Multi-page PDF QC for AXA Ireland policy documents. 8 deterministic checks: font inventory, phone inventory, bold-words enforcement, page numbering, print code, OMG versioning, PDF/UA-aligned accessibility, 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.",
|
||||
"mode": "document",
|
||||
"checks": {
|
||||
"axa_font_inventory": {
|
||||
|
|
@ -23,6 +23,11 @@
|
|||
"enabled": true,
|
||||
"scope": "document"
|
||||
},
|
||||
"axa_pdf_accessibility": {
|
||||
"weight": 2.0,
|
||||
"enabled": true,
|
||||
"scope": "document"
|
||||
},
|
||||
"axa_print_preflight": {
|
||||
"weight": 1.0,
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
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
|
||||
43
config/development.env
Normal file
43
config/development.env
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# 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)
|
||||
# Redirect URI is computed from each request — no need to hardcode it per server.
|
||||
# Set BOX_REDIRECT_URI here only as an override if request-based detection fails.
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
42
config/production.env
Normal file
42
config/production.env
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# 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)
|
||||
# Redirect URI is computed from each request — no need to hardcode it per server.
|
||||
# Set BOX_REDIRECT_URI here only as an override if request-based detection fails.
|
||||
BOX_CLIENT_ID=o9zxyl6j917q0bkndrwfi2x5zbdeanh5
|
||||
BOX_CLIENT_SECRET=yejdbWTeBOcdsDImpNQ7nvLJZad3e0Jm
|
||||
|
|
@ -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.
|
||||
202
web_ui.html
202
web_ui.html
|
|
@ -2993,7 +2993,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 +3112,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 +3533,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 +3607,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