diff --git a/.gitignore b/.gitignore
index e5d277e..fbf40de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,3 +86,6 @@ 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/
diff --git a/backend/api_server.py b/backend/api_server.py
index 8f47d04..306dbe7 100755
--- a/backend/api_server.py
+++ b/backend/api_server.py
@@ -8,6 +8,7 @@ import sys
import json
import base64
import collections
+import html
import importlib
import traceback
import re
@@ -858,6 +859,8 @@ 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:
@@ -973,6 +976,12 @@ 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
@@ -1099,12 +1108,12 @@ def generate_html_content(report_data, filename, file_path=None):
Analysis Details:
-
{response_text.replace(chr(10), '
')}
+ {f'
{html.escape(json_data.get("summary", "") or "") if isinstance(json_data, dict) else ""}
{findings_html}' if findings_html is not None else f'
{response_text.replace(chr(10), "
")}
'}
"""
-
+
# 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
@@ -1163,6 +1172,16 @@ 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; }}
@@ -1256,6 +1275,45 @@ 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 'No findings — copy is clean.
'
+ 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(
+ ''
+ f'| {html.escape(priority.upper())} | '
+ f'{html.escape(f.get("category", "") or "")} | '
+ f'{html.escape(quote_raw)} | '
+ f'{html.escape(f.get("issue", "") or "")} | '
+ f'{html.escape(f.get("suggested_fix", "") or "")} | '
+ f'{html.escape(f.get("source_reference", "") or "")} | '
+ '
'
+ )
+ return (
+ ''
+ '| Priority | Category | Quote | '
+ 'Issue | Suggested fix | Source | '
+ '
'
+ + ''.join(rows) +
+ '
'
+ )
+
+
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'):
@@ -1340,7 +1398,14 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
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:
@@ -1367,7 +1432,7 @@ def generate_comprehensive_html_report(analysis_result, filename, file_path=None
Analysis Details:
-
{response.replace(chr(10), '
')}
+ {f'
{html.escape(json_data.get("summary", "") or "") if isinstance(json_data, dict) else ""}
{findings_html}' if findings_html is not None else f'
{response.replace(chr(10), "
")}
'}
@@ -1421,6 +1486,16 @@ 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; }}
@@ -1632,6 +1707,15 @@ 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:
@@ -4832,15 +4916,15 @@ def upload_brand_guideline():
).start()
file_record['processing_status'] = 'processing'
- # Trigger localization matrix parsing for Excel files
+ # Trigger Excel processing: try localization matrix first (existing
+ # clients), fall back to Source Messaging summary (HP and similar).
elif file_record.get('file_type') in ('.xlsx', '.xls'):
import threading
- def _process_localization_bg(fid, spath, fdir):
+ def _process_excel_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)
@@ -4855,25 +4939,34 @@ def upload_brand_guideline():
print(f"Localization matrix parsing complete for {fid}: "
f"{len(parsed.get('messages', {}))} messages, "
f"{len(parsed.get('countries', []))} countries")
- 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")
+ 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")
except Exception as e:
- print(f"Localization matrix parsing failed for {fid}: {e}")
+ print(f"Excel processing failed for {fid}: {e}")
brand_db.update_file_record(fid, {
'processed': 'error',
- 'processing_error': str(e)
+ 'processing_error': str(e),
})
threading.Thread(
- target=_process_localization_bg,
+ target=_process_excel_bg,
args=(file_record['id'], file_record['stored_path'],
str(brand_db.files_dir)),
- daemon=True
+ daemon=True,
).start()
file_record['processing_status'] = 'processing'
diff --git a/backend/client_config.py b/backend/client_config.py
index 8e8a653..67a07a6 100644
--- a/backend/client_config.py
+++ b/backend/client_config.py
@@ -63,9 +63,10 @@ CLIENT_PROFILES = {
},
'hp': {
'name': 'HP',
- 'profiles': ['static_general', 'video_general'],
+ 'profiles': ['hp_copy_review', 'static_general', 'video_general'],
'display_name': 'HP',
- 'description': 'Demo client — scope pending'
+ 'description': 'HP marketing copy QC graded against canonical Source Messaging',
+ 'default_profile': 'hp_copy_review',
},
'ferrero': {
'name': 'Ferrero',
diff --git a/backend/excel_processor.py b/backend/excel_processor.py
new file mode 100644
index 0000000..385db6a
--- /dev/null
+++ b/backend/excel_processor.py
@@ -0,0 +1,162 @@
+#!/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: \\n\\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
diff --git a/backend/profiles/hp_copy_review.json b/backend/profiles/hp_copy_review.json
new file mode 100644
index 0000000..c1c3064
--- /dev/null
+++ b/backend/profiles/hp_copy_review.json
@@ -0,0 +1,14 @@
+{
+ "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
+ }
+ }
+}
diff --git a/backend/visual_qc_apps/hp_copy_review/__init__.py b/backend/visual_qc_apps/hp_copy_review/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/visual_qc_apps/hp_copy_review/app.py b/backend/visual_qc_apps/hp_copy_review/app.py
new file mode 100644
index 0000000..3a462e7
--- /dev/null
+++ b/backend/visual_qc_apps/hp_copy_review/app.py
@@ -0,0 +1,179 @@
+"""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: `) 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: ` and `- Country: `. 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": ,
+ "summary": "",
+ "findings": [
+ {
+ "priority": "high" | "medium" | "low",
+ "category": "ksp" | "disclaimer" | "spec" | "variant" | "tone" | "brand-name" | "language" | "other",
+ "quote": "",
+ "issue": "",
+ "suggested_fix": "",
+ "source_reference": ""
+ }
+ ]
+}
+```
+
+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()
diff --git a/docs/superpowers/plans/2026-05-17-hp-cycle-1-onboarding.md b/docs/superpowers/plans/2026-05-17-hp-cycle-1-onboarding.md
new file mode 100644
index 0000000..f81c93e
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-17-hp-cycle-1-onboarding.md
@@ -0,0 +1,786 @@
+# 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: \\n\\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 /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: '. 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: '. 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:
+
+CANONICAL SOURCE MESSAGING:
+
+
+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": ,
+ "summary": "",
+ "findings": [
+ {
+ "priority": "high" | "medium" | "low",
+ "category": "ksp" | "disclaimer" | "spec" | "variant" | "tone" | "brand-name" | "language" | "other",
+ "quote": "",
+ "issue": "",
+ "suggested_fix": "",
+ "source_reference": ""
+ }
+ ]
+}
+
+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 `` 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 'No findings — copy is clean.
'
+ 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''
+ f'| {priority.upper()} | '
+ f'{f.get("category", "")} | '
+ f'{(f.get("quote") or "")[:200]} | '
+ f'{f.get("issue", "")} | '
+ f'{f.get("suggested_fix", "")} | '
+ f'{f.get("source_reference", "")} | '
+ f'
'
+ )
+ return (
+ ''
+ '| Priority | Category | Quote | '
+ 'Issue | Suggested fix | Source | '
+ '
'
+ + ''.join(rows) + '
'
+ )
+```
+
+- [ ] **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('' + 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).
diff --git a/docs/superpowers/specs/2026-05-17-hp-cycle-1-onboarding-design.md b/docs/superpowers/specs/2026-05-17-hp-cycle-1-onboarding-design.md
new file mode 100644
index 0000000..91ceae9
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-17-hp-cycle-1-onboarding-design.md
@@ -0,0 +1,280 @@
+# 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//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:
+
+CANONICAL SOURCE MESSAGING:
+
+
+MARKETING ASSET:
+
+
+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": "",
+ "issue": "",
+ "suggested_fix": "",
+ "source_reference": ""
+ },
+ ...
+]
+
+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: \n\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: ` 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 ...//status` and `POST ...//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.