Add exterior/interior pairing check and Ranger/CV series permutation support

- New check: exterior_interior_pairing_check — validates that every exterior base asset has a matching interior base asset (same feature set, excluding paint/trim codes), handles MEC 2d-background items separately
- check_series_permutations: added Ranger/CV model detection via header.ptvl (TR[AB]## pattern), uses SE#/ABM codes instead of VS/ACM for those packs
- unzip_and_verify_check: added fullLoad header validation (must be "Y")
- profiles/ford_bnp.json: added exterior_interior_pairing_check to production profile
- html_reporter.py: added friendly name mappings and HTML formatter for new pairing check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-16 14:55:03 +00:00
parent 1002ebc61b
commit 44de833c45
5 changed files with 419 additions and 47 deletions

View file

@ -1,17 +1,31 @@
import os
import re
import json
from typing import Dict, List, Set, Tuple, Any
def _is_ranger_model(linking_data: dict) -> bool:
"""
Detect if this is a Ranger (CV) model pack by checking header.ptvl.
Ranger packs use SE#/ABM codes instead of VS/ACM codes.
:param linking_data: Parsed linkingrecord.json data
:return: True if this is a Ranger/CV model, False otherwise
"""
ptvl = linking_data.get('header', {}).get('ptvl', '')
return bool(re.match(r'^TR[AB]\d{2}$', str(ptvl), re.IGNORECASE))
def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Validate that series images exist for every unique VS/ACM combination found in exterior and showroom sections.
Validate that series images exist for every unique VS/ACM (or SE#/ABM for Ranger) combination
found in exterior and showroom sections.
This check validates that the "series" section contains an image for every unique combination of
VS- (Visual Selection) and ACM (Accessory Content Management) codes that appears in the
exterior and showroom sections. The validation creates a "source of truth" by extracting all
unique [ACM, VS] pairs from exterior and showroom records, then ensures the series section
provides an image for each combination.
For standard packs: validates VS- (Visual Selection) and ACM (Accessory Content Management) pairs.
For Ranger/CV packs (header.ptvl matches TR[AB]##): validates SE# and ABM code pairs instead.
The validation creates a "source of truth" by extracting all unique code pairs from exterior
and showroom records, then ensures the series section provides an image for each combination.
:param config: Configuration dictionary containing:
- working_dir: Directory where linkingrecord.json and extracted files are located
@ -62,7 +76,34 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
"error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list."
}
# STEP 1-3: Build "Source of Truth" - Extract unique [ACM, VS] pairs from exterior and showroom
# Detect model type to determine which code prefixes to use
is_ranger = _is_ranger_model(linkingrecord)
if is_ranger:
# CV/Ranger model: uses SE# + ABM codes
primary_prefix = "se#"
secondary_prefix = "abm"
code_label = "SE#/ABM"
else:
# Standard model: uses VS + ACM codes
primary_prefix = "vs-"
secondary_prefix = "acm"
code_label = "VS/ACM"
def _extract_code_pair(features):
"""Extract the primary (VS/SE#) and secondary (ACM/ABM) code from a feature list."""
primary_code = None
secondary_code = None
for feature in features:
if isinstance(feature, str):
fl = feature.lower()
if fl.startswith(primary_prefix):
primary_code = feature
elif fl.startswith(secondary_prefix):
secondary_code = feature
return primary_code, secondary_code
# STEP 1-3: Build "Source of Truth" - Extract unique code pairs from exterior and showroom
source_of_truth = set()
for item in linkingrecord["items"]:
@ -98,33 +139,26 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(features, list):
continue
# Extract the actual [ACM, VS] pair from this record's features
# Extract the code pair from this record's features
# Each record represents ONE combination, not a cross-product
vs_code = None
acm_code = None
primary_code, secondary_code = _extract_code_pair(features)
for feature in features:
if isinstance(feature, str):
if feature.startswith("vs-"):
vs_code = feature
elif feature.startswith("acm"):
acm_code = feature
# If this record has both ACM and VS, add the pair to source of truth
if vs_code and acm_code:
source_of_truth.add((acm_code, vs_code))
# If this record has both codes, add the pair to source of truth
if primary_code and secondary_code:
source_of_truth.add((secondary_code, primary_code))
# Check if we found any valid combinations
if not source_of_truth:
return {
"status": "passed",
"details": {
"message": "No VS/ACM combinations found in exterior or showroom sections. Check may not be applicable to this pack type.",
"source_combinations_count": 0
"message": f"No {code_label} combinations found in exterior or showroom sections. Check may not be applicable to this pack type.",
"source_combinations_count": 0,
"model_type": "Ranger/CV" if is_ranger else "Standard"
}
}
# STEP 4: Extract actual [ACM, VS] pairs from series section
# STEP 4: Extract actual code pairs from series section
series_combinations = set()
series_section_found = False
@ -154,30 +188,22 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(features, list):
continue
# Extract the actual [ACM, VS] pair from this series record
# Each series record represents ONE combination, not a cross-product
vs_code = None
acm_code = None
# Extract the code pair from this series record
primary_code, secondary_code = _extract_code_pair(features)
for feature in features:
if isinstance(feature, str):
if feature.startswith("vs-"):
vs_code = feature
elif feature.startswith("acm"):
acm_code = feature
# If this record has both ACM and VS, add the pair
if vs_code and acm_code:
series_combinations.add((acm_code, vs_code))
# If this record has both codes, add the pair
if primary_code and secondary_code:
series_combinations.add((secondary_code, primary_code))
# Check if series section exists
if not series_section_found:
return {
"status": "failed",
"details": {
"message": "No series section found (viewtype='carousel', imagetype='series') in linkingrecord.",
"message": f"No series section found (viewtype='carousel', imagetype='series') in linkingrecord.",
"source_combinations_count": len(source_of_truth),
"source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)]
"model_type": "Ranger/CV" if is_ranger else "Standard",
"source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)]
}
}
@ -188,17 +214,18 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
# Generate detailed report
if missing_combinations:
error_details = {
"message": "Series validation failed: Some VS/ACM combinations from exterior/showroom are missing series images.",
"message": f"Series validation failed: Some {code_label} combinations from exterior/showroom are missing series images.",
"model_type": "Ranger/CV" if is_ranger else "Standard",
"source_combinations_count": len(source_of_truth),
"series_combinations_count": len(series_combinations),
"missing_count": len(missing_combinations),
"missing_combinations": [f"({acm}, {vs})" for acm, vs in sorted(missing_combinations)]
"missing_combinations": [f"({sec}, {pri})" for sec, pri in sorted(missing_combinations)]
}
if extra_combinations:
error_details["extra_count"] = len(extra_combinations)
error_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)]
error_details["extra_note"] = "Series section contains combinations not found in exterior/showroom."
error_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)]
error_details["extra_note"] = f"Series section contains {code_label} combinations not found in exterior/showroom."
return {
"status": "failed",
@ -207,14 +234,15 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
# All required combinations are present in series
success_details = {
"message": "All VS/ACM combinations from exterior and showroom have corresponding series images.",
"message": f"All {code_label} combinations from exterior and showroom have corresponding series images.",
"model_type": "Ranger/CV" if is_ranger else "Standard",
"validated_combinations_count": len(source_of_truth),
"source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)]
"source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)]
}
if extra_combinations:
success_details["note"] = f"{len(extra_combinations)} additional series combinations found that don't appear in exterior/showroom."
success_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)]
success_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)]
return {
"status": "passed",

View file

@ -0,0 +1,221 @@
import os
import json
from typing import Dict, Any, FrozenSet, Set, List
def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Validate that every exterior base asset has a matching interior base asset with the same
feature set (excluding paint and trim codes), and vice versa.
For each exterior item (viewtype="exterior", base imagetype/layer), the set of features
minus paint codes must match at least one interior item's feature set minus trim codes.
Both directions are checked: exterior items without an interior partner, and interior items
without an exterior partner.
For MEC packs, items with experienceCondition="2d-background" are validated separately.
:param config: Configuration dictionary containing:
- working_dir: Directory where linkingrecord.json is located
- linkingrecord_filename: The linking record filename (default: 'linkingrecord.json')
- paint_code_prefix: Prefix for paint codes to exclude from exterior features (default: 'pn')
- trim_code_prefix: Prefix for trim codes to exclude from interior features (default: 'tr-')
:return: Dictionary with validation results:
- status: "passed", "failed", or "error"
- details: Breakdown of mismatches by direction and MEC condition
"""
working_dir = config.get("working_dir", "working")
linkingrecord_filename = config.get("linkingrecord_filename", "linkingrecord.json")
paint_prefix = config.get("paint_code_prefix", "pn").lower()
trim_prefix = config.get("trim_code_prefix", "tr-").lower()
linkingrecord_path = os.path.join(working_dir, linkingrecord_filename)
if not os.path.exists(linkingrecord_path):
return {
"status": "error",
"error_message": f"Linking record file '{linkingrecord_filename}' not found in {working_dir}."
}
try:
with open(linkingrecord_path, 'r', encoding='utf-8') as f:
linkingrecord = json.load(f)
except json.JSONDecodeError as e:
return {
"status": "error",
"error_message": f"Malformed JSON in '{linkingrecord_filename}': {str(e)}"
}
except Exception as e:
return {
"status": "error",
"error_message": f"Error reading '{linkingrecord_filename}': {str(e)}"
}
if not isinstance(linkingrecord, dict):
return {
"status": "error",
"error_message": "Invalid linkingrecord.json structure: root is not an object."
}
if "items" not in linkingrecord or not isinstance(linkingrecord["items"], list):
return {
"status": "error",
"error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list."
}
def _is_base_item(item: dict) -> bool:
"""A base item has no imagetype (None) or imagetype='base', and layer=0 or no layer."""
conditions = item.get("conditions", {})
if not isinstance(conditions, dict):
return False
imagetype = conditions.get("imagetype")
layer = conditions.get("layer", 0)
return imagetype in (None, "base", "") and (layer == 0 or layer is None)
def _strip_paint_codes(features: List[str]) -> FrozenSet[str]:
"""Remove paint codes (pn prefix) from exterior feature list."""
return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(paint_prefix))
def _strip_trim_codes(features: List[str]) -> FrozenSet[str]:
"""Remove trim codes (configurable prefix) from interior feature list."""
return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(trim_prefix))
def _collect_feature_sets(items_list, viewtype_val: str, strip_fn, mec_only: bool = False) -> Set[FrozenSet[str]]:
"""Collect normalised feature sets for all base records of the given viewtype."""
result = set()
for item in items_list:
if not isinstance(item, dict):
continue
conditions = item.get("conditions", {})
if not isinstance(conditions, dict):
continue
if conditions.get("viewtype") != viewtype_val:
continue
if not _is_base_item(item):
continue
exp_condition = conditions.get("experienceCondition", "")
is_mec = exp_condition == "2d-background"
if mec_only and not is_mec:
continue
if not mec_only and is_mec:
continue
for record in item.get("records", []):
if not isinstance(record, dict):
continue
features = record.get("features", [])
if not isinstance(features, list):
continue
normalised = strip_fn(features)
if normalised:
result.add(normalised)
return result
def _run_pairing_check(items_list, mec_only: bool = False) -> dict:
"""Compare exterior and interior feature sets and return mismatch details."""
ext_sets = _collect_feature_sets(items_list, "exterior", _strip_paint_codes, mec_only)
int_sets = _collect_feature_sets(items_list, "interior", _strip_trim_codes, mec_only)
if not ext_sets and not int_sets:
return {"applicable": False}
ext_only = ext_sets - int_sets
int_only = int_sets - ext_sets
return {
"applicable": True,
"exterior_count": len(ext_sets),
"interior_count": len(int_sets),
"exterior_without_interior": len(ext_only),
"interior_without_exterior": len(int_only),
"ext_only_feature_sets": [sorted(list(s)) for s in sorted(ext_only, key=lambda x: sorted(x))],
"int_only_feature_sets": [sorted(list(s)) for s in sorted(int_only, key=lambda x: sorted(x))],
}
items = linkingrecord["items"]
# Standard pairing check (excluding 2d-background MEC items)
standard = _run_pairing_check(items, mec_only=False)
# MEC 2d-background pairing check
mec = _run_pairing_check(items, mec_only=True)
# Determine if any items had the MEC condition
has_mec_items = mec.get("applicable", False)
# Evaluate overall pass/fail
standard_pass = not standard.get("applicable") or (
standard.get("exterior_without_interior", 0) == 0 and
standard.get("interior_without_exterior", 0) == 0
)
mec_pass = not mec.get("applicable") or (
mec.get("exterior_without_interior", 0) == 0 and
mec.get("interior_without_exterior", 0) == 0
)
if not standard.get("applicable") and not has_mec_items:
return {
"status": "passed",
"details": {
"message": "No exterior or interior base items found. Check not applicable to this pack.",
"applicable": False
}
}
overall_pass = standard_pass and mec_pass
status = "passed" if overall_pass else "failed"
details: Dict[str, Any] = {}
if standard.get("applicable"):
details["standard"] = {
"exterior_base_sets": standard["exterior_count"],
"interior_base_sets": standard["interior_count"],
"exterior_without_interior_partner": standard["exterior_without_interior"],
"interior_without_exterior_partner": standard["interior_without_exterior"],
}
if standard["exterior_without_interior"] > 0:
details["standard"]["exterior_only_feature_sets"] = standard["ext_only_feature_sets"]
if standard["interior_without_exterior"] > 0:
details["standard"]["interior_only_feature_sets"] = standard["int_only_feature_sets"]
if has_mec_items:
details["mec_2d_background"] = {
"exterior_base_sets": mec["exterior_count"],
"interior_base_sets": mec["interior_count"],
"exterior_without_interior_partner": mec["exterior_without_interior"],
"interior_without_exterior_partner": mec["interior_without_exterior"],
}
if mec["exterior_without_interior"] > 0:
details["mec_2d_background"]["exterior_only_feature_sets"] = mec["ext_only_feature_sets"]
if mec["interior_without_exterior"] > 0:
details["mec_2d_background"]["interior_only_feature_sets"] = mec["int_only_feature_sets"]
if overall_pass:
msg_parts = []
if standard.get("applicable"):
msg_parts.append(f"all {standard['exterior_count']} exterior/interior base pairs matched")
if has_mec_items:
msg_parts.append(f"all {mec['exterior_count']} MEC 2d-background pairs matched")
details["message"] = "Exterior/interior pairing passed: " + "; ".join(msg_parts) + "."
else:
msg_parts = []
if standard.get("applicable") and not standard_pass:
ext_miss = standard["exterior_without_interior"]
int_miss = standard["interior_without_exterior"]
if ext_miss:
msg_parts.append(f"{ext_miss} exterior base set(s) with no interior partner")
if int_miss:
msg_parts.append(f"{int_miss} interior base set(s) with no exterior partner")
if has_mec_items and not mec_pass:
ext_miss = mec["exterior_without_interior"]
int_miss = mec["interior_without_exterior"]
if ext_miss:
msg_parts.append(f"{ext_miss} MEC exterior base set(s) with no interior partner")
if int_miss:
msg_parts.append(f"{int_miss} MEC interior base set(s) with no exterior partner")
details["message"] = "Exterior/interior pairing failed: " + "; ".join(msg_parts) + "."
details["paint_code_prefix"] = paint_prefix
details["trim_code_prefix"] = trim_prefix
return {"status": status, "details": details}

View file

@ -172,7 +172,9 @@ class HTMLReporter:
"image_linking_check": "Image Link References Check",
"business_data_check": "Business Data Validation",
"image_format_check": "Image Format Validation",
"layer_depth_check": "Layer Depth Validation"
"layer_depth_check": "Layer Depth Validation",
"check_series_permutations": "Series Permutations Validation",
"exterior_interior_pairing_check": "Exterior / Interior Base Asset Pairing"
}
# Return the friendly name or format the module name if not in the mapping
@ -326,6 +328,8 @@ class HTMLReporter:
elif 'record_results' in details and 'overall_coverage' in details:
# Extra carousel validation check
formatted_parts.append(HTMLReporter._format_extra_carousel_validation(details))
elif check_type == "exterior_interior_pairing_check" or 'standard' in details or 'mec_2d_background' in details:
formatted_parts.append(HTMLReporter._format_exterior_interior_pairing(details))
# If no special formatting applied so far, use generic formatting
elif not formatted_parts:
formatted_parts.append(f'''
@ -1576,6 +1580,107 @@ class HTMLReporter:
'''
@staticmethod
def _format_exterior_interior_pairing(details: dict) -> str:
"""Format exterior/interior base asset pairing check results."""
try:
def _feature_set_table(feature_sets: list, label: str, badge_class: str) -> str:
if not feature_sets:
return ""
rows = "".join(
f"<tr><td>{i+1}</td><td><code>{', '.join(fs)}</code></td></tr>"
for i, fs in enumerate(feature_sets)
)
return f'''
<div class="mb-3">
<h6 class="text-danger">{label} ({len(feature_sets)})</h6>
<table class="table table-sm table-hover table-striped">
<thead class="{badge_class}">
<tr><th>#</th><th>Feature Set (normalised)</th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
'''
def _render_section(section_data: dict, title: str) -> str:
if not section_data:
return ""
ext_count = section_data.get("exterior_base_sets", 0)
int_count = section_data.get("interior_base_sets", 0)
ext_miss = section_data.get("exterior_without_interior_partner", 0)
int_miss = section_data.get("interior_without_exterior_partner", 0)
passed = ext_miss == 0 and int_miss == 0
header_class = "table-success" if passed else "table-danger"
summary = f'''
<div class="mb-4">
<h6>{title}</h6>
<table class="table table-sm table-bordered" style="width:auto">
<thead class="{header_class}">
<tr><th>Metric</th><th>Count</th></tr>
</thead>
<tbody>
<tr><td>Exterior base sets</td><td>{ext_count}</td></tr>
<tr><td>Interior base sets</td><td>{int_count}</td></tr>
<tr><td>Exterior without interior partner</td>
<td><span class="badge {'bg-success' if ext_miss == 0 else 'bg-danger'}">{ext_miss}</span></td></tr>
<tr><td>Interior without exterior partner</td>
<td><span class="badge {'bg-success' if int_miss == 0 else 'bg-danger'}">{int_miss}</span></td></tr>
</tbody>
</table>
'''
tables = ""
if "exterior_only_feature_sets" in section_data:
tables += _feature_set_table(
section_data["exterior_only_feature_sets"],
"Exterior Sets Without an Interior Partner",
"table-danger"
)
if "interior_only_feature_sets" in section_data:
tables += _feature_set_table(
section_data["interior_only_feature_sets"],
"Interior Sets Without an Exterior Partner",
"table-warning"
)
return summary + tables + "</div>"
parts = []
standard = details.get("standard")
if standard:
parts.append(_render_section(standard, "Standard Base Items"))
mec = details.get("mec_2d_background")
if mec:
parts.append(_render_section(mec, "MEC 2D-Background Items"))
if not parts:
return '<div class="alert alert-info">No pairing data available.</div>'
paint_prefix = details.get("paint_code_prefix", "pn")
trim_prefix = details.get("trim_code_prefix", "tr-")
parts.append(f'''
<div class="alert alert-info mt-2">
<small>Paint codes excluded from exterior features: prefix <code>{paint_prefix}</code> &nbsp;|&nbsp;
Trim codes excluded from interior features: prefix <code>{trim_prefix}</code></small>
</div>
''')
return "".join(parts)
except Exception as e:
logging.warning(f"Error formatting exterior/interior pairing: {e}")
return f'''
<div class="alert alert-warning">
Error formatting exterior/interior pairing: {str(e)}
</div>
<pre>{json.dumps(details, indent=2, default=str)}</pre>
'''
if __name__ == "__main__":
import sys
if len(sys.argv) != 3:

View file

@ -107,6 +107,15 @@ def validate_linkingrecord_header(linkingrecord_data):
"error": "Must be a string value"
})
# Validate fullLoad (must always be "Y")
full_load = header.get("fullLoad", "")
if full_load != "Y":
errors.append({
"field": "fullLoad",
"value": full_load,
"error": "Must be exactly 'Y' (full load packs only)"
})
return len(errors) == 0, errors
def validate_linking_record_filetypes(linkingrecord_data):

View file

@ -97,5 +97,14 @@
"working_dir": "__WORKING_DIR__",
"linkingrecord_filename": "linkingrecord.json"
}
},
{
"script": "checks.exterior_interior_pairing_check",
"config": {
"working_dir": "__WORKING_DIR__",
"linkingrecord_filename": "linkingrecord.json",
"paint_code_prefix": "pn",
"trim_code_prefix": "tr-"
}
}
]