Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ MODEL_EVAL_FLAGS ?= --ollama-model gpt-oss-safeguard:20b
ADLINT_OLLAMA_TIMEOUT ?= 180
ADLINT_OLLAMA_NUM_PREDICT ?= 1024

.PHONY: api creative-assets-eval demo dev landing-page-consistency eval benchmark benchmark-data policy-coverage policy-coverage-validate rewrite-quality model-benchmark model-smoke model-usefulness pr-preflight real-cases real-cases-ci real-cases-hybrid real-cases-model-quality real-cases-validate real-world-blind-candidates real-world-blind-ci real-world-blind-validate real-world-blind real-world-blind-model-quality research-summary scan test install
.PHONY: api creative-assets-eval demo dev false-positive-near-miss landing-page-consistency eval benchmark benchmark-data policy-coverage policy-coverage-validate rewrite-quality model-benchmark model-smoke model-usefulness pr-preflight real-cases real-cases-ci real-cases-hybrid real-cases-model-quality real-cases-validate real-world-blind-candidates real-world-blind-ci real-world-blind-validate real-world-blind real-world-blind-model-quality research-summary scan test install

install: $(STAMP)

Expand Down Expand Up @@ -36,6 +36,9 @@ landing-page-consistency: $(STAMP)
creative-assets-eval: $(STAMP)
$(BIN)/python evals/run_eval.py evals/datasets/creative_asset_metadata_v1.jsonl --min-decision-accuracy 1.0 --output evals/results/creative_asset_metadata_v1.json --markdown-output evals/results/creative_asset_metadata_v1.md

false-positive-near-miss: $(STAMP)
$(BIN)/python evals/run_eval.py evals/datasets/false_positive_near_miss_v1.jsonl --min-decision-accuracy 1.0 --max-policy-false-positives 0 --summary-only --output evals/results/false_positive_near_miss_v1.json --markdown-output evals/results/false_positive_near_miss_v1.md

research-summary: $(STAMP)
$(BIN)/python evals/run_eval.py evals/datasets/seed_ads.jsonl --summary-only --summary-format json --min-decision-accuracy 0
$(BIN)/python evals/run_eval.py evals/datasets/rule_benchmark_v1.jsonl --summary-only --summary-format json --min-decision-accuracy 0
Expand Down
12 changes: 10 additions & 2 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,15 @@ The review target includes:
- Reports: JSON and Markdown reports include policy hits, evidence,
recommended actions, rewrites, landing-page details, and the decision-support
disclaimer.
- Review packet export: local batch archives or single report directories can
be packaged into a manifest, reviewer summary, and copied Markdown reports
for local handoff without adding raw creative or raw landing-page data to the
manifest.
- Rewrites: deterministic safer rewrite suggestions are generated for high-risk
or review-required hits where feasible.
- Seed evals: `evals/run_eval.py` scores the 50-example
`evals/datasets/seed_ads.jsonl` dataset.
- Seed and focused evals: `evals/run_eval.py` scores the seed dataset plus
focused landing-page, creative-asset, false-positive near-miss, real-case,
and blind-holdout datasets.
- Review hardening: tests cover CLI scanning, API endpoints, policy loading and
filtering, report generation, documented examples, the seed eval runner, and
opt-in logging behavior.
Expand All @@ -63,6 +68,9 @@ The review target includes:
when explicitly enabled.
- Opt-in SQLite metadata storage records scan and eval summaries only when
explicitly enabled, without raw ad or landing-page fields.
- Creative asset metadata validation checks declared MIME type, dimensions,
duration, file size, and asset-type compatibility without reading or
analyzing raw media.

### 2.2 Partial or future work

Expand Down
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ make api # then open http://127.0.0.1:8000/ui/
a small stdlib HTML parser that can read inline HTML, local files, or
fetchable HTML URLs.
- Native image/video analysis. Creative asset metadata can be attached to
reports and supplied text metadata can be checked by text rules, but AdLint
does not yet run OCR, frame analysis, speech-to-text, or visual policy checks.
reports, declared metadata can be validated, and supplied text metadata can
be checked by text rules, but AdLint does not yet run OCR, frame analysis,
speech-to-text, or visual policy checks.
- Fine-tuning. Local model support is available for decision support, but
deterministic rules are the production baseline until live evals prove
incremental value.
Expand Down Expand Up @@ -149,6 +150,7 @@ make benchmark # run the 213-row synthetic policy regression benchmark
make policy-coverage # refresh docs/policy_coverage_matrix.md
make policy-coverage-validate # check the committed coverage matrix
make rewrite-quality # run the deterministic rewrite-quality rubric eval
make false-positive-near-miss # guard benign near-misses against overbroad policy hits
make pr-preflight # verify generated eval assets before opening an eval PR
make real-cases # run balanced public-source real-case diagnostics
make real-cases-ci # run the strict real-case CI gate
Expand Down Expand Up @@ -266,7 +268,10 @@ those fields so the existing text rules can inspect them:
```

Reports include sanitized asset metadata such as `filename`, dimensions, media
type, and booleans showing which text metadata fields were supplied. Batch
type, metadata-validation status, and booleans showing which text metadata
fields were supplied. Metadata validation checks only declared fields such as
MIME type, dimensions, duration, file size, and asset-type compatibility. It
does not read, upload, OCR, transcribe, or visually classify raw media. Batch
summaries omit the `creative_assets` column by default; per-row reports remain
local when `--output-dir` is used.

Expand Down Expand Up @@ -300,6 +305,27 @@ With `--output-dir`, AdLint writes:
The batch summary intentionally omits raw ad copy and landing-page HTML. Per-row
reports stay local and contain the evidence snippets needed for review.

### Review packet export

Use `adlint review-packet` to turn a local batch archive or single scan report
directory into a reviewer handoff packet:

```bash
adlint batch examples/batch_campaigns.csv --output-dir reports/batch
adlint review-packet reports/batch --output-dir reports/review-packet
```

The packet includes:

- `manifest.json` with decisions, risk scores, policy IDs, policy source links,
relative Markdown report paths, and generation metadata.
- `review-packet.md` with a compact reviewer summary.
- copied per-row Markdown reports under `reports/`.

The packet manifest omits raw ad copy, landing-page URLs, raw landing-page HTML,
raw creative paths, and raw media. It is for local reviewer handoff only and
does not change AdLint's decision-support boundary.

## Scoring configuration

By default, AdLint uses the built-in MVP scoring weights and thresholds. To
Expand Down Expand Up @@ -494,6 +520,18 @@ for clarity, risk reduction, policy fit, and intent preservation. It reports a
Deterministic rewrites remain the baseline before any model-generated rewrites
are introduced.

Run the false-positive near-miss guardrail before broadening policy signals:

```bash
make false-positive-near-miss
```

`false_positive_near_miss_v1` covers benign health-adjacent copy, soft
professional claims, offer/discount language, creator disclosures, and
platform-specific review labels. The target requires 1.000 rule-only decision
accuracy and zero policy false positives, and the report includes a separate
false-positive pressure section with reviewer notes.

Run the public-source real-case diagnostics:

```bash
Expand Down
17 changes: 17 additions & 0 deletions adlint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from adlint.config import load_config
from adlint.engine import analyze
from adlint.reports import to_markdown
from adlint.review_packet import build_review_packet


DEMO_CONFIG = {
Expand Down Expand Up @@ -122,6 +123,17 @@ def main(argv: Sequence[str] | None = None) -> int:
help="Format printed to stdout.",
)

packet_parser = subparsers.add_parser("review-packet", help="Build a local reviewer handoff packet.")
packet_parser.add_argument(
"source_dir",
help="Directory containing an AdLint batch archive or single scan reports.",
)
packet_parser.add_argument(
"--output-dir",
required=True,
help="Write the review packet manifest, summary, and copied Markdown reports to this directory.",
)

args = parser.parse_args(argv)

if args.command == "scan":
Expand Down Expand Up @@ -174,5 +186,10 @@ def main(argv: Sequence[str] | None = None) -> int:
print(to_markdown(result))
return 0

if args.command == "review-packet":
packet = build_review_packet(args.source_dir, args.output_dir)
print(json.dumps(packet, indent=2, sort_keys=True))
return 0

parser.error(f"Unknown command: {args.command}")
return 2
122 changes: 116 additions & 6 deletions adlint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ def to_dict(self) -> dict[str, Any]:
}


@dataclass(frozen=True)
class CreativeAssetValidationFinding:
field: str
severity: str
message: str

def to_dict(self) -> dict[str, str]:
return {
"field": self.field,
"severity": self.severity,
"message": self.message,
}


@dataclass(frozen=True)
class CreativeAsset:
asset_id: str
Expand All @@ -133,24 +147,40 @@ class CreativeAsset:
transcript_excerpt: str = ""
alt_text: str = ""
labels: tuple[str, ...] = ()
validation_findings: tuple[CreativeAssetValidationFinding, ...] = ()

@classmethod
def from_dict(cls, raw: dict[str, Any], *, index: int = 1) -> "CreativeAsset":
filename = _basename(raw.get("filename") or raw.get("file_name") or raw.get("path") or raw.get("url"))
asset_id = str(raw.get("asset_id") or raw.get("id") or filename or f"asset-{index}")
asset_type = _asset_type(raw.get("asset_type") or raw.get("kind") or raw.get("type") or raw.get("media_type"))
mime_type = _optional_str(raw.get("mime_type") or raw.get("content_type"))
width = _optional_int(raw.get("width"))
height = _optional_int(raw.get("height"))
duration_seconds = _optional_float(raw.get("duration_seconds") or raw.get("duration"))
file_size_bytes = _optional_int(raw.get("file_size_bytes") or raw.get("size_bytes"))
return cls(
asset_id=_safe_asset_id(asset_id, fallback=f"asset-{index}"),
asset_type=_asset_type(raw.get("asset_type") or raw.get("kind") or raw.get("type") or raw.get("media_type")),
asset_type=asset_type,
filename=filename,
mime_type=_optional_str(raw.get("mime_type") or raw.get("content_type")),
width=_optional_int(raw.get("width")),
height=_optional_int(raw.get("height")),
duration_seconds=_optional_float(raw.get("duration_seconds") or raw.get("duration")),
file_size_bytes=_optional_int(raw.get("file_size_bytes") or raw.get("size_bytes")),
mime_type=mime_type,
width=width,
height=height,
duration_seconds=duration_seconds,
file_size_bytes=file_size_bytes,
text_overlay=_optional_str(raw.get("text_overlay") or raw.get("ocr_text")) or "",
transcript_excerpt=_optional_str(raw.get("transcript_excerpt") or raw.get("audio_transcript")) or "",
alt_text=_optional_str(raw.get("alt_text") or raw.get("description")) or "",
labels=tuple(str(item).strip() for item in _raw_list(raw.get("labels")) if str(item).strip()),
validation_findings=_validate_creative_asset_metadata(
raw,
asset_type=asset_type,
mime_type=mime_type,
width=width,
height=height,
duration_seconds=duration_seconds,
file_size_bytes=file_size_bytes,
),
)

def text_fields(self, *, index: int) -> dict[str, str]:
Expand Down Expand Up @@ -187,6 +217,13 @@ def to_dict(self) -> dict[str, Any]:
):
if value not in (None, ""):
payload[key] = value
if self.validation_findings:
payload["metadata_validation"] = {
"status": "needs_review",
"findings": [finding.to_dict() for finding in self.validation_findings],
}
else:
payload["metadata_validation"] = {"status": "passed", "findings": []}
return payload


Expand Down Expand Up @@ -318,6 +355,79 @@ def _asset_type(value: Any) -> str:
return "unknown"


def _validate_creative_asset_metadata(
raw: dict[str, Any],
*,
asset_type: str,
mime_type: str | None,
width: int | None,
height: int | None,
duration_seconds: float | None,
file_size_bytes: int | None,
) -> tuple[CreativeAssetValidationFinding, ...]:
findings: list[CreativeAssetValidationFinding] = []

for field, keys, parsed in (
("width", ("width",), width),
("height", ("height",), height),
("duration_seconds", ("duration_seconds", "duration"), duration_seconds),
("file_size_bytes", ("file_size_bytes", "size_bytes"), file_size_bytes),
):
if _has_any(raw, keys) and parsed is None:
findings.append(_asset_finding(field, "Declared value must be a non-negative number."))

for field, value in (
("width", width),
("height", height),
("duration_seconds", duration_seconds),
("file_size_bytes", file_size_bytes),
):
if value is not None and value <= 0:
findings.append(_asset_finding(field, "Declared value must be greater than zero."))

if (width is None) != (height is None):
findings.append(_asset_finding("dimensions", "Width and height should be supplied together."))

if mime_type:
findings.extend(_validate_mime_type(asset_type, mime_type))

if asset_type == "image" and duration_seconds is not None:
findings.append(_asset_finding("duration_seconds", "Image assets should not declare duration."))
if asset_type == "audio" and (width is not None or height is not None):
findings.append(_asset_finding("dimensions", "Audio assets should not declare visual dimensions."))

return tuple(findings)


def _validate_mime_type(asset_type: str, mime_type: str) -> list[CreativeAssetValidationFinding]:
normalized = mime_type.strip().lower()
findings: list[CreativeAssetValidationFinding] = []
if "/" not in normalized:
findings.append(_asset_finding("mime_type", "Declared MIME type should use type/subtype format."))
return findings

expected: dict[str, tuple[str, ...]] = {
"image": ("image/",),
"video": ("video/",),
"audio": ("audio/",),
"display": ("image/", "text/html", "application/zip", "application/x-zip-compressed"),
"html5": ("text/html", "application/zip", "application/x-zip-compressed"),
}
prefixes = expected.get(asset_type)
if prefixes and not any(normalized.startswith(prefix) for prefix in prefixes):
allowed = ", ".join(prefix.rstrip("/") for prefix in prefixes)
findings.append(_asset_finding("mime_type", f"{asset_type} assets should use one of: {allowed}."))
return findings


def _asset_finding(field: str, message: str) -> CreativeAssetValidationFinding:
return CreativeAssetValidationFinding(field=field, severity="warning", message=message)


def _has_any(raw: dict[str, Any], keys: tuple[str, ...]) -> bool:
return any(key in raw and raw[key] not in (None, "") for key in keys)


def _basename(value: Any) -> str | None:
text = _optional_str(value)
if not text:
Expand Down
6 changes: 6 additions & 0 deletions adlint/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ def to_markdown(result: AnalysisResult) -> str:
if asset.duration_seconds is not None:
details.append(f"{asset.duration_seconds:g}s")
lines.append(f"- Asset {index}: {' | '.join(details)}")
if asset.validation_findings:
lines.append(" - Metadata validation: needs review")
for finding in asset.validation_findings:
lines.append(f" - `{finding.field}`: {finding.message}")
else:
lines.append(" - Metadata validation: passed")
text_metadata = asset.to_dict()["text_metadata"]
if any(text_metadata.values()):
lines.append(" - Text metadata supplied for local rule checks; text contents are not reprinted in this section.")
Expand Down
Loading