agentic-ads-engine 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/bin/agentic-ads +49 -0
- package/bin/agentic-ads-mcp +48 -0
- package/package.json +14 -4
- package/src/formats/index.js +36 -0
- package/src/formats/library.json +230 -0
- package/src/hooks/index.js +82 -0
- package/src/hooks/library.json +44 -0
- package/src/http.js +104 -0
- package/src/index.js +64 -0
- package/src/policy/index.js +112 -0
- package/src/providers/index.js +56 -0
- package/src/qa/index.js +16 -0
- package/src/qa/rules.json +28 -0
- package/src/templates/index.js +26 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { listRules, getRule } from './qa/index.js';
|
|
2
|
+
export { listRules, getRule, checklistFor, costRatchet } from './qa/index.js';
|
|
3
|
+
import { listFormats, getFormat, planFormat } from './formats/index.js';
|
|
4
|
+
export { listFormats, getFormat, planFormat };
|
|
5
|
+
import { templates } from './templates/index.js';
|
|
6
|
+
import { hooks, selectFor, listHooks, getHook, pickHooks, emotions } from './hooks/index.js';
|
|
7
|
+
import { lint } from './policy/index.js';
|
|
8
|
+
import { templateProvider, guardCost } from './providers/index.js';
|
|
9
|
+
export { listHooks, getHook, pickHooks, templates, hooks, selectFor, lint, templateProvider, guardCost };
|
|
10
|
+
export { readScores, writeScores } from './hooks/index.js';
|
|
11
|
+
const string = { type: 'string' };
|
|
12
|
+
const hookFilters = { format: string, emotion: { type: 'string', enum: emotions }, housing: { type: 'boolean' } };
|
|
13
|
+
const fields = {
|
|
14
|
+
list_hooks: hookFilters, pick_hooks: hookFilters, get_hook: { id: string },
|
|
15
|
+
list_qa_rules: {}, get_qa_rule: { id: string },
|
|
16
|
+
list_formats: { costClass: { type: 'string', enum: ['free', 'cheap', 'mid', 'expensive'] }, risk: { type: 'string', enum: ['low', 'medium', 'high'] } },
|
|
17
|
+
get_format: { id: string },
|
|
18
|
+
plan_format: { format: string, site: string, goal: string, dryRun: { type: 'boolean' } },
|
|
19
|
+
'list-templates': {}, 'list-hooks': {},
|
|
20
|
+
lint: { copy: string, category: { enum: ['housing', 'none'], type: 'string' } },
|
|
21
|
+
plan: { site: string, goal: string, template: string, ratio: { enum: ['4:5', '9:16'], type: 'string' }, copy: string, category: { enum: ['housing', 'none'], type: 'string' }, apply: { type: 'boolean' }, dryRun: { type: 'boolean' } },
|
|
22
|
+
};
|
|
23
|
+
export const tools = Object.entries(fields).map(([name, properties]) => ({
|
|
24
|
+
name, description: ({ list_hooks: 'List hooks with optional filters.', pick_hooks: 'Pick matching hooks in stable score order.', get_hook: 'Get one hook.', list_qa_rules: 'List owner feedback QA rules.', get_qa_rule: 'Get an owner feedback QA rule.', list_formats: 'List video formats with optional cost and risk filters.', get_format: 'Get a video format descriptor.', plan_format: 'Plan video stages offline. Apply is unavailable.', 'list-templates': 'List starter template descriptors.', 'list-hooks': 'List hook patterns and risks.', lint: 'Check ad copy; findings always require review.', plan: 'Preview a zero-cost plan. Apply is unavailable.' })[name],
|
|
25
|
+
inputSchema: { type: 'object', properties, additionalProperties: false, ...({ pick_hooks: ['format'], get_hook: ['id'], lint: ['copy'], get_qa_rule: ['id'], get_format: ['id'], plan_format: ['format', 'site'] }[name] ? { required: { pick_hooks: ['format'], get_hook: ['id'], lint: ['copy'], get_qa_rule: ['id'], get_format: ['id'], plan_format: ['format', 'site'] }[name] } : {}) },
|
|
26
|
+
}));
|
|
27
|
+
/** Shared CLI/MCP dispatch. Unknown inputs fail before any action. */
|
|
28
|
+
export function execute(action, args = {}) {
|
|
29
|
+
if (!Object.hasOwn(fields, action)) throw new Error('Unknown action');
|
|
30
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Invalid arguments');
|
|
31
|
+
if (args.apply === true && ['plan', 'plan_format'].includes(action)) throw new Error('Apply is unavailable in this offline slice');
|
|
32
|
+
for (const key of tools.find(t => t.name === action).inputSchema.required ?? []) if (args[key] === undefined) throw new Error('Missing required argument: ' + key);
|
|
33
|
+
for (const [key, value] of Object.entries(args)) {
|
|
34
|
+
const field = fields[action][key];
|
|
35
|
+
if (!Object.hasOwn(fields[action], key) || typeof value !== field.type || (field.enum && !field.enum.includes(value))) throw new Error('Invalid argument');
|
|
36
|
+
if (typeof value === 'string' && value.length > 20000) throw new Error('Argument too long');
|
|
37
|
+
}
|
|
38
|
+
if (action === 'list_hooks') return listHooks(args);
|
|
39
|
+
if (action === 'pick_hooks') return pickHooks(args);
|
|
40
|
+
if (action === 'get_hook') return getHook(args.id);
|
|
41
|
+
if (action === 'list_qa_rules') return listRules();
|
|
42
|
+
if (action === 'get_qa_rule') return getRule(args.id);
|
|
43
|
+
if (action === 'list_formats') return listFormats(args);
|
|
44
|
+
if (action === 'get_format') return getFormat(args.id);
|
|
45
|
+
if (action === 'plan_format') return planFormat(args);
|
|
46
|
+
if (action === 'list-templates') return structuredClone(templates);
|
|
47
|
+
if (action === 'list-hooks') return structuredClone(hooks);
|
|
48
|
+
if (action === 'lint') return lint(args.copy, args);
|
|
49
|
+
if (args.apply === true) throw new Error('Apply is unavailable in this offline slice');
|
|
50
|
+
if (args.dryRun === false) throw new Error('Use dry-run; apply is unavailable');
|
|
51
|
+
const descriptor = templates.find(t => t.id === (args.template ?? 'door-open-teaser'));
|
|
52
|
+
if (!descriptor) throw new Error('Unknown template');
|
|
53
|
+
const site = args.site ?? 'demo';
|
|
54
|
+
const hook = selectFor({ id: site }, args.goal ?? 'discovery');
|
|
55
|
+
if (!hook) throw new Error('No low-risk hook matches this goal');
|
|
56
|
+
const copy = args.copy ?? hook.pattern.replace('{{subject}}', 'available listings');
|
|
57
|
+
const policy = lint(copy, args);
|
|
58
|
+
const estimate = templateProvider.estimate();
|
|
59
|
+
return { dryRun: true, applied: false, site, template: descriptor.id, ratio: args.ratio ?? '4:5', hook,
|
|
60
|
+
copy, policy, estimate, costGuard: guardCost({ costMinor: 0, adSpentMinor: 0, monthSpentMinor: 0, perAdMinor: 0, perMonthMinor: 0 }),
|
|
61
|
+
requiredSlots: descriptor.slots.map(s => s.name),
|
|
62
|
+
warnings: ['No image is rendered or campaign launched.', 'Supply verified slots and brand tokens to the provider.', ...descriptor.risk === 'high' ? ['High-risk template requires additional review.'] : []],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// This lint is a warning tool, not legal advice. It misses cases.
|
|
2
|
+
// Every result still needs a person to review it.
|
|
3
|
+
// Normalize invisible separators and common Latin lookalikes before all checks.
|
|
4
|
+
const lookalikes = { ı: 'i', а: 'a', е: 'e', о: 'o', р: 'p', с: 'c', х: 'x', у: 'y', і: 'i', ј: 'j', ѕ: 's', к: 'k', м: 'm', т: 't', в: 'b', н: 'h', ι: 'i', α: 'a', ε: 'e', ο: 'o', ρ: 'p', χ: 'x', υ: 'y', κ: 'k', τ: 't', ν: 'v' };
|
|
5
|
+
function normalize(copy) {
|
|
6
|
+
return copy.normalize('NFKC').toLowerCase().normalize('NFD').replace(/\p{M}/gu, '')
|
|
7
|
+
.replace(/[\u00ad\u034f\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g, '')
|
|
8
|
+
.replace(/[ıаеорсхуіјѕкмтвнαεορχυκτνι]/g, c => lookalikes[c]);
|
|
9
|
+
}
|
|
10
|
+
function hasContact(text) {
|
|
11
|
+
// Each token/run is scanned once. Do not retry an unbounded suffix at every character.
|
|
12
|
+
if (text.split(/\s+/).some(token => /^[^@]+@[^@.]+\.[^@]+$/.test(token))) return true;
|
|
13
|
+
const withoutDates = text
|
|
14
|
+
.replace(/\b(?:parcel(?:[ -]+id)?|assessor[ -]+parcel)[ :#-]*[\d-]+\b/g, ' ')
|
|
15
|
+
.replace(/\b(?:\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{4})\b/g, ' ');
|
|
16
|
+
for (const match of withoutDates.matchAll(/[+\d][\d\s().-]*/g)) {
|
|
17
|
+
const digits = match[0].replace(/\D/g, '').length;
|
|
18
|
+
if (digits >= 10) return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function hasPlaceholder(text) {
|
|
23
|
+
const start = text.indexOf('{{');
|
|
24
|
+
return start !== -1 && text.indexOf('}}', start + 2) !== -1;
|
|
25
|
+
}
|
|
26
|
+
function hasHousing(text) {
|
|
27
|
+
const subject = text.replace(/\b(?:apartment design games?|auction software|art listings?|lease accounting software|flat tires?|flat rates?|home page|home runs?|house music)\b/g, '');
|
|
28
|
+
return /\b(?:homes?|housing|houses?|condos?|condominiums?|apartments?|leases?|leasing|renting|rented|townhouses?|duplex(?:es)?|landlords?|tenants?|bedrooms?|studios?|\d+br|flats?|real estate|foreclosures?|rent(?:al|als)?|propert(?:y|ies)|mortgages?|auctions?|listings?)\b/.test(subject);
|
|
29
|
+
}
|
|
30
|
+
// Bounded connectors keep cues attached to people, without scanning arbitrary prose.
|
|
31
|
+
const gap = '[\\s-]+';
|
|
32
|
+
const spaced = pattern => pattern.replaceAll(' ', gap);
|
|
33
|
+
const people = spaced('(?:(?:tenants?|residents?|applicants?|people|persons?|households?|roommates?|family|families|professionals?|with|or|and|pets|grad|job|on|are|is) ){0,4}');
|
|
34
|
+
const modifier = spaced('(?:(?:a|an) )?(?:(?:young|single|mature|professional|traditional|able bodied) )?');
|
|
35
|
+
const cueBefore = spaced('(?:only|no|not|not for|wanted|prefer|prefers|seeking|seeks|preferred|ideal for|great for|perfect for|must be|must have|must speak|exclude|excluding|not suitable for)');
|
|
36
|
+
const cueAfter = spaced('(?:only|preferred|wanted|required|excluded|exclusive|need not apply|not welcome|not accepted|not allowed|not permitted|prohibited|banned)');
|
|
37
|
+
function preference(subject, welcomeSignals = false) {
|
|
38
|
+
const group = spaced(subject);
|
|
39
|
+
// 'X welcome' is inclusive for income, family, age, marital status and disability. For religion,
|
|
40
|
+
// race, origin and sex it signals a preference, so those rules pass welcomeSignals = true.
|
|
41
|
+
const after = welcomeSignals ? '(?:' + cueAfter + '|welcome)' : cueAfter;
|
|
42
|
+
return new RegExp('\\b(?:' + cueBefore + gap + people + modifier + '(?:' + group + ')|(?:' + group + ')' + gap + people + after + ')\\b');
|
|
43
|
+
}
|
|
44
|
+
// Keep age numbers bounded, including common written ages.
|
|
45
|
+
const ageNumber = '(?:\\d{1,2}|(?:twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)(?:[ -](?:one|two|three|four|five|six|seven|eight|nine))?|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen)';
|
|
46
|
+
const ageSpan = ageNumber + '\\s*(?:(?:to|thru|through|[-\\u2013])\\s*' + ageNumber + '|\\+|plus)(?!\\w)';
|
|
47
|
+
const ageRange = new RegExp('\\b(?:age[sd]?(?:[ -]+range)?[\\s-]*' + ageSpan + '|must[ -]+be[ -]+(?:under|over|younger than|older than)[ -]+' + ageNumber + '\\b)');
|
|
48
|
+
const childAmenity = new RegExp('\\b(?:kids|children)[ -]+age[sd]?[\\s-]*' + ageSpan + '[ -]+(?:play[ -]+area|playground)\\b', 'g');
|
|
49
|
+
const ageRestriction = {
|
|
50
|
+
// Remove only the amenity phrase. Other restrictions in the copy still apply.
|
|
51
|
+
test: text => ageRange.test(text.replace(childAmenity, ' ')),
|
|
52
|
+
};
|
|
53
|
+
const postalTerm = '(?:(?:zip|postal)(?:[\\s-]*codes?)?|postcodes?)';
|
|
54
|
+
const postalRestriction = new RegExp('\\b(?:' + postalTerm + '[\\s-]+only|(?:only|just)[\\s-]+(?:in[\\s-]+)?(?:these[\\s-]+|the[\\s-]+)?' + postalTerm + '|' + postalTerm + '[\\s-]*(?:targeting|restricted)|' + postalTerm + '[ -]+(?:\\d{5}(?:-\\d{4})?|[a-z]{1,2}\\d[a-z\\d]?[ -]+\\d[a-z]{2})[ -]+(?:residents?[ -]+)?only)\\b');
|
|
55
|
+
const housingRules = [
|
|
56
|
+
['targeting_age', preference('young(?: professionals?| adults?)?|seniors?|retirees?|millennials?|students?|mature|elderly|teens?|teenagers?|toddlers?|babies|infants?'), 'Remove age or student restrictions and preferences.'],
|
|
57
|
+
['targeting_familial_status', preference('children|child|kids|kiddos|family|families|parents?|mothers?|fathers?|pregnant'), 'Remove family-status restrictions and preferences.'],
|
|
58
|
+
['targeting_familial_status', preference('adults?|empty nesters'), 'Remove family-status restrictions and preferences.'],
|
|
59
|
+
['targeting_marital_status', preference('singles?|bachelors?|couples?|married|unmarried|divorced'), 'Remove marital-status restrictions and preferences.'],
|
|
60
|
+
['targeting_religion', preference('christians?|muslims?|jewish|jews?|hindus?|buddhists?|catholics?|protestants?|believers|religious', true), 'Remove religious targeting or preferences.'],
|
|
61
|
+
['targeting_income', preference('section (?:8|eight)|vouchers?|hud|housing assistance|public assistance|income source|job income|welfare|ssi|ssdi|disability income|social security'), 'Remove source-of-income restrictions or preferences.'],
|
|
62
|
+
['targeting_sex', preference('men|women|males?|females?|boys?|girls?|gentlemen|ladies|sex|gender|gay|straight', true), 'Remove sex or gender restrictions and preferences.'],
|
|
63
|
+
['targeting_origin', preference('americans?|canadians?|chinese|indians?|mexicans?|british|french|arabs?|hispanics?|latinos?|latinas?|africans?|asians?|(?:us )?citizens?|native speakers?|english(?: speakers?)?|immigrants?|foreigners?|national origin|ethnicity|race|whites?|blacks?|minorities', true), 'Remove race or national-origin restrictions and preferences.'],
|
|
64
|
+
['targeting_disability', preference('disabled|handicapped|deaf|blind|disability|disabilities|mentally ill|wheelchairs?(?: users?)?|service animals?|able bodied|non disabled|wheelchair accessible'), 'Remove disability restrictions and preferences.'],
|
|
65
|
+
// Indirect occupancy language needs review even without an explicit restriction.
|
|
66
|
+
['targeting_familial_status', /\b(?:traditional[\s-]+family[\s-]+values[\s-]+neighbou?rhood|adult[\s-]+community|only[\s-]+one[\s-]+child)\b/, 'Review possible family-status or occupancy preferences.', 'warning'],
|
|
67
|
+
['targeting_religion', /\b(?:christian|muslim|jewish|hindu|buddhist|catholic|protestant)[\s-]+home\b/, 'Review possible religious occupancy preferences.', 'warning'],
|
|
68
|
+
['targeting_disability', /\bmust[\s-]+be[\s-]+able[\s-]+to[\s-]+climb[\s-]+stairs\b/, 'Review possible disability restrictions in stair-access requirements.', 'warning'],
|
|
69
|
+
// These phrases describe eligibility or preferred occupants, rather than a bare class word.
|
|
70
|
+
['targeting_age', /\b(?:(?:over|under|older than|younger than)[ -]+\d{1,3}[ -]+(?:community|residents?|tenants?|only)|(?:\d{1,3}|fifty[ -]five)[ -]*(?:\+|plus\b|(?:and|or)[ -]+(?:over|older|up)\b|years?[ -]+(?:old|(?:and|or)[ -]+(?:over|older))\b)|senior living|retirement community)\b/, 'Remove age eligibility language.'],
|
|
71
|
+
['targeting_age', /\b\d{1,3}\+(?=[ -]+(?:community|residents?|tenants?|only)\b)/, 'Remove age eligibility language.'],
|
|
72
|
+
['targeting_familial_status', /\b(?:family[ -]friendly|child[ -]free)\b/, 'Remove family-status preferences.'],
|
|
73
|
+
['targeting_religion', /\b(?:christian|muslim|jewish|hindu|buddhist|catholic|protestant)[ -]+(?:neighbou?rhood|community|building)(?!\s+(?:cent(?:er|re)|hall|school|hospital|museum|library|church|temple|mosque|synagogue))\b/, 'Remove religious neighborhood preferences.'],
|
|
74
|
+
['targeting_sex', /\b(?:gay|straight)[ -]friendly\b/, 'Remove sex or gender preferences.'],
|
|
75
|
+
['targeting_area', /\b(?:zip|postal)[ -]+(?:code[ -]+)?\d{1,10}[ -]+only\b|\b(?:target|targeting|exclude|excluding)\s+(?:residents?\s+)?(?:in\s+)?(?:(?:this|that|the|these|specific|selected)\s+)?(?:areas?|neighbou?rhoods?|districts?)\b|\b(?:locals?|area residents?)\s+only\b/, 'Remove postal or area targeting language.'],
|
|
76
|
+
['targeting_age', ageRestriction, 'Remove age ranges or limits.'],
|
|
77
|
+
['targeting_area', postalRestriction, 'Remove ZIP-code targeting.'],
|
|
78
|
+
];
|
|
79
|
+
/** Conservative copy checks, not platform approval or factual verification.
|
|
80
|
+
* @param {string} copy
|
|
81
|
+
* @param {{category?:string}} options
|
|
82
|
+
*/
|
|
83
|
+
export function lint(copy, options = {}) {
|
|
84
|
+
if (typeof copy !== 'string' || copy.length > 20000) throw new Error('Copy must be a string of at most 20000 characters');
|
|
85
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Invalid lint options');
|
|
86
|
+
const { category = 'housing' } = options;
|
|
87
|
+
if (!['housing', 'none'].includes(category)) throw new Error('Unsupported category');
|
|
88
|
+
const text = normalize(copy);
|
|
89
|
+
// Preserve punctuation for contact and date checks. Normalize it only for targeting.
|
|
90
|
+
const targetingText = text.replace(/\bu\.s\.(?=[\s_.-])/g, 'us').replace(/[_.]/g, ' ');
|
|
91
|
+
const findings = [];
|
|
92
|
+
const add = (code, message, severity = 'error') => findings.push({ code, severity, message });
|
|
93
|
+
if (!text.trim()) add('empty', 'Supply ad copy.');
|
|
94
|
+
const housing = category === 'housing' || hasHousing(text);
|
|
95
|
+
if (category !== 'housing' && housing) add('housing_category', 'Declare the housing Special Ad Category.');
|
|
96
|
+
const rules = [
|
|
97
|
+
...(housing ? housingRules : []),
|
|
98
|
+
['deceptive', /\b(guarantee\w*|risk[ -]?free|zero risk|no risk|double your money|instant profit)\b/, 'Remove promises of certain outcomes.'],
|
|
99
|
+
['superlative', /\b(best(?![ -]street\b)|top(?)|largest|fastest|cheapest|unbeatable|number one)\b|#\s*1\b/, 'Remove banned superlatives.'],
|
|
100
|
+
['below_market', /\b(?:below|under|off|less[ -]+than|cheaper[ -]+than|discount(?:ed)?[ -]+(?:from|against|to))[ -]+(?:(?:the|current|local|fair|estimated|average|prevailing|open)[ -]+){0,5}(?:market|appraisal|appraised[ -]+value|comps)\b|\bmarket\s+(?:value|rates?|prices?)\s+(?:minus|less|discount)/, 'A below-market claim needs independent evidence and review.'],
|
|
101
|
+
['urgency', /\b(only today|ending soon|limited time|act now|last chance|only \d+ left)\b/, 'Verify scarcity and timing before use.'],
|
|
102
|
+
['news', /\b(breaking(?![ -]down\b)|news alert)\b/, 'News framing needs review and a clear ad disclosure.'],
|
|
103
|
+
['staging', /\b(before.{0,12}after|virtually staged|virtual staging|ai.staged)\b/, 'Verify the source images and staging disclosure.'],
|
|
104
|
+
];
|
|
105
|
+
for (const [code, pattern, message, severity] of rules) {
|
|
106
|
+
if (pattern.test(code.startsWith('targeting_') ? targetingText : text)) add(code, message, severity);
|
|
107
|
+
}
|
|
108
|
+
if (hasContact(text)) add('private_data', 'Remove contact data.');
|
|
109
|
+
if (hasPlaceholder(text)) add('placeholder', 'Replace unresolved slots before review.');
|
|
110
|
+
add('human_review', 'This lint is a warning tool, not legal advice. It misses cases. Every result needs a person to review facts, consent, disclosures, and platform rules.', 'warning');
|
|
111
|
+
return { status: findings.some(f => f.severity === 'error') ? 'blocked' : 'review', category, findings };
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { templates } from '../templates/index.js';
|
|
3
|
+
/** @typedef {{capabilities:Function, estimate:Function, run:Function}} Provider */
|
|
4
|
+
/** Pure cap check in integer minor units of one caller-selected currency.
|
|
5
|
+
* Existing spend must include reservations and uncertain charges.
|
|
6
|
+
*/
|
|
7
|
+
export function guardCost(input) {
|
|
8
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid cost or cap');
|
|
9
|
+
const values = {};
|
|
10
|
+
for (const key of ['costMinor', 'adSpentMinor', 'monthSpentMinor', 'perAdMinor', 'perMonthMinor']) {
|
|
11
|
+
if (!Object.hasOwn(input, key)) throw new Error(`Invalid cost or cap: ${key} must be an own property`);
|
|
12
|
+
const value = input[key];
|
|
13
|
+
values[key] = value;
|
|
14
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid cost or cap');
|
|
15
|
+
}
|
|
16
|
+
if ('apply' in input && !Object.hasOwn(input, 'apply')) throw new Error('Invalid cost or cap: apply must be an own property');
|
|
17
|
+
const { costMinor, adSpentMinor, monthSpentMinor, perAdMinor, perMonthMinor } = values;
|
|
18
|
+
const apply = input.apply;
|
|
19
|
+
const reasons = [];
|
|
20
|
+
if (costMinor > 0 && apply !== true) reasons.push('apply_required');
|
|
21
|
+
if (costMinor > perAdMinor - adSpentMinor) reasons.push('per_ad_cap');
|
|
22
|
+
if (costMinor > perMonthMinor - monthSpentMinor) reasons.push('monthly_cap');
|
|
23
|
+
return { ok: reasons.length === 0, reasons };
|
|
24
|
+
}
|
|
25
|
+
const escape = value => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
26
|
+
/** HTML-to-image adapter stub. It emits deterministic HTML only, never a raster image. */
|
|
27
|
+
export const templateProvider = {
|
|
28
|
+
id: 'html-template-stub',
|
|
29
|
+
capabilities: () => ({ formats: ['text/html'], aspectRatios: ['4:5', '9:16'], imageRendering: false }),
|
|
30
|
+
estimate: () => ({ costMinor: 0, currency: 'USD', confidence: 'exact' }),
|
|
31
|
+
run(input) {
|
|
32
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid provider input');
|
|
33
|
+
const { template, ratio = '4:5', slots, brand = {} } = input;
|
|
34
|
+
const descriptor = templates.find(t => t.id === template);
|
|
35
|
+
if (!descriptor || !descriptor.aspectRatios.includes(ratio)) throw new Error('Invalid template or ratio');
|
|
36
|
+
if (!slots || typeof slots !== 'object' || Array.isArray(slots)) throw new Error('Invalid slots');
|
|
37
|
+
if (!brand || typeof brand !== 'object' || Array.isArray(brand)) throw new Error('Invalid brand');
|
|
38
|
+
if (Object.keys(slots).some(k => !descriptor.slots.some(s => s.name === k))) throw new Error('Unknown slot');
|
|
39
|
+
if (Object.keys(brand).some(k => !descriptor.brandTokens.includes(k))) throw new Error('Unknown brand token');
|
|
40
|
+
const colors = { primary: '#2455cc', background: '#ffffff', foreground: '#151515' };
|
|
41
|
+
for (const key of Object.keys(colors)) {
|
|
42
|
+
colors[key] = brand[key] ?? colors[key];
|
|
43
|
+
if (typeof colors[key] !== 'string' || !/^#[0-9a-f]{6}$/i.test(colors[key])) throw new Error('Invalid brand color');
|
|
44
|
+
}
|
|
45
|
+
const name = brand.name ?? 'Demo';
|
|
46
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 80) throw new Error('Invalid brand name');
|
|
47
|
+
const body = descriptor.slots.map(slot => {
|
|
48
|
+
const value = Object.hasOwn(slots, slot.name) ? slots[slot.name] : undefined;
|
|
49
|
+
if (typeof value !== 'string' || !value.trim() || value.length > slot.maxChars) throw new Error('Invalid required slot');
|
|
50
|
+
return `<section data-slot="${slot.name}">${escape(value)}</section>`;
|
|
51
|
+
}).join('');
|
|
52
|
+
const width = 1080, height = ratio === '4:5' ? 1350 : 1920;
|
|
53
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Ad preview</title><style>body{margin:0;width:${width}px;height:${height}px;box-sizing:border-box;padding:80px;background:${colors.background};color:${colors.foreground};font:48px sans-serif}header{color:${colors.primary}}section{margin:40px 0;overflow-wrap:anywhere}</style></head><body data-template="${descriptor.id}"><header>${escape(name)}</header>${body}</body></html>`;
|
|
54
|
+
return { html, mime: 'text/html', width, height, image: null, sha256: createHash('sha256').update(html).digest('hex'), costMinor: 0, warnings: ['Stub: HTML only; image rendering is unavailable.'] };
|
|
55
|
+
},
|
|
56
|
+
};
|
package/src/qa/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const rules = JSON.parse(readFileSync(new URL('./rules.json', import.meta.url), 'utf8'));
|
|
3
|
+
export function listRules() { return structuredClone(rules); }
|
|
4
|
+
export function getRule(id) {
|
|
5
|
+
const rule = rules.find(r => r.id === id);
|
|
6
|
+
if (!rule) throw new Error('Unknown QA rule: ' + String(id) + '. Use qa-rules to list available IDs.');
|
|
7
|
+
return structuredClone(rule);
|
|
8
|
+
}
|
|
9
|
+
/** Accept a format descriptor. Review status never implies that checks ran. */
|
|
10
|
+
export function checklistFor(format) {
|
|
11
|
+
return format.feedbackRules.map(id => {
|
|
12
|
+
const { title, check } = getRule(id);
|
|
13
|
+
return { id, title, check, status: 'NOT CHECKED' };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export const costRatchet = "Set a hard cap before you spend. One writer per spend log.\nReuse faces, sets, stills, clips and voice ids from earlier videos first.\nPick one lip-sync model for a format and keep it. Do not repeat a bake-off.\nDo not make the same still twice. Do not pay for an unused line.\nWrite `COST_NOTES.md`. Show cost per stage and against the last video of the format.\nA video that costs more than the last one of its format needs a one-line reason.";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"id": "R1", "title": "Wild emotional hook in the first second.", "check": "Watch 0 to 1 s with sound off. Record what frame 0.0 s shows. It must be a strong emotion or an odd event. In a format with a person, R20 applies.", "origin": "Round 3 hook was \"not intriguing\".", "severity": "blocking", "applies": ["all"]},
|
|
3
|
+
{"id": "R2", "title": "Cause comes before effect on screen. Show the action, then the result, then who reacts. In a format with a person, the reaction face of R20 comes first. Then the order is action, result, reaction, all by 3 s.", "check": "Step through 0 to 3 s. No character appears without a reason. No object is already broken.", "origin": "Sitcom: door fell, people appeared at 0:02.", "severity": "blocking", "applies": ["story"]},
|
|
4
|
+
{"id": "R3", "title": "Change the camera angle, not only the zoom.", "check": "Look at the frames: the scene-cut filter cannot see a zoom. About one new angle or pose every 2.5 s. Cuts differ in height, pose or distance. A side change stays on one side of the 180-degree line (R21).", "origin": "\"Different left and right views, not slow zooms.\"", "severity": "blocking", "applies": ["character"]},
|
|
5
|
+
{"id": "R4", "title": "The same person must show different poses and angles.", "check": "Same face, outfit and hair across all shots. At least 3 poses.", "origin": "Marble lady needed jump cuts.", "severity": "blocking", "applies": ["character"]},
|
|
6
|
+
{"id": "R5", "title": "Show problem, agitation, then solution. Make the agitation hurt and be funny.", "check": "Mark the three beats on the timeline. Agitation has at least 3 quick cuts, one prop each.", "origin": "\"Highlight problem and solution agitation.\"", "severity": "blocking", "applies": ["story"]},
|
|
7
|
+
{"id": "R6", "title": "No dead gaps. No silent still over 0.3 s. Tail after the last sound under 0.3 s.", "check": "`freezedetect=n=-60dB:d=0.3` crossed with `silencedetect=n=-35dB:d=0.25`. A range in both is a dead gap.", "origin": "Round 4 had dead gaps.", "severity": "blocking", "applies": ["all"]},
|
|
8
|
+
{"id": "R7", "title": "Speed up the picture, not the voice. Normal speech pace.", "check": "Words per second from whisper: 2 to 3. Average shot length: 1.5 to 2.2 s for a lo-fi story (the gate in `qa.md`), 1.2 to 1.5 s only for a fast punch edit. No shot over 2.5 s.", "origin": "\"Sound speed too much, pace better.\"", "severity": "blocking", "applies": ["all"]},
|
|
9
|
+
{"id": "R8", "title": "Voice, face and ethnicity must match.", "check": "A person listens to each voice with the face. Log the voice id per character.", "origin": "\"Ethnicity of source does not match.\"", "severity": "blocking", "applies": ["character"]},
|
|
10
|
+
{"id": "R9", "title": "A story the viewer can follow. It is personal and coherent, in the first person, as a dramatization. It makes no factual claim.", "check": "Read the captions alone. They tell one story in the first person.", "origin": "Meltdown: \"random words\".", "severity": "blocking", "applies": ["story"]},
|
|
11
|
+
{"id": "R10", "title": "Captions have spaces. At most 3 words. Time them to the audio.", "check": "Measure the pixel gap: at least 14 px of clear gap between words at 1080 px. Compare caption start with the whisper word start (under 0.15 s).", "origin": "Caption spacing and sync.", "severity": "blocking", "applies": ["captions"]},
|
|
12
|
+
{"id": "R11", "title": "Mouth movement matches speech. Mouths close in laugh breaks.", "check": "Manual spot check until measured: mouth movement starts within 100 to 200 ms of voice onset (see `ad-video-editing` qa.md section 6). Where a lip-landmark tool ran, give its correlation (0.4 or more) and the tool name. List each failing line.", "origin": "Sitcom v2 and v3.", "severity": "blocking", "applies": ["dialogue", "speech"]},
|
|
13
|
+
{"id": "R12", "title": "Never cut to a static image that makes no sense.", "check": "Check each cut. Every shot has motion or a reason to hold.", "origin": "Sitcom v2 feedback.", "severity": "blocking", "applies": ["all"]},
|
|
14
|
+
{"id": "R13", "title": "Back-and-forth dialogue is wanted. Keep it and add more.", "check": "Count speaker changes. At least 4 in a sitcom.", "origin": "\"Love the dialogue.\"", "severity": "blocking", "applies": ["dialogue"]},
|
|
15
|
+
{"id": "R14", "title": "Sound is required. The ad also works muted.", "check": "Play with sound off. The story and the offer still read. Ship both files.", "origin": "Round 3 had no sound.", "severity": "blocking", "applies": ["all"]},
|
|
16
|
+
{"id": "R15", "title": "Hold the object identity. A phone stays a phone.", "check": "Compare held objects in frames across the clip.", "origin": "Phone turned into a notepad.", "severity": "blocking", "applies": ["character"]},
|
|
17
|
+
{"id": "R16", "title": "Lo-fi organic look. No logo. No text except captions, the dramatization tag and one end card with the offer.", "check": "Look at the frames. No logo, no studio polish.", "origin": "Polished images were rejected.", "severity": "blocking", "applies": ["lo-fi"]},
|
|
18
|
+
{"id": "R17", "title": "Housing safety. \"dramatization\" label. No invented numbers. No real people. No children.", "check": "Run the policy lint on all text and captions.", "origin": "Housing special ad category.", "severity": "blocking", "applies": ["all"]},
|
|
19
|
+
{"id": "R18", "title": "Cost goes down each time. Report cost per video by stage.", "check": "Compare with the last video of the same format. Explain any rise.", "origin": "Goal: cut cost 50 to 75 percent.", "severity": "blocking", "applies": ["all"]},
|
|
20
|
+
{"id": "R19", "title": "Do not show an event the video model cannot render. Show damage another way.", "check": "Render one clip first. Look at 3 frames of the event. No duplicate objects (two doors), no melting. If it fails, change the beat (cracking wall, falling plaster).", "origin": "\"No crash, two doors, AI artifact.\"", "severity": "blocking", "applies": ["all"]},
|
|
21
|
+
{"id": "R20", "title": "In a format with a person, the first frame is that person's reaction face. The cause follows in the next shot or two (see R2). This replaces the 0.0 s check in R1 for those formats.", "check": "Frame 0.0 s: a face, eyes and mouth readable, emotion matches the hook. The cause is on screen by 3 s.", "origin": "\"Shocked face in the first frame.\"", "severity": "blocking", "applies": ["character"]},
|
|
22
|
+
{"id": "R21", "title": "Keep screen direction. Each person stays on the same side of the frame in every shot. Change the angle on one side of the 180-degree line. Do not cross it.", "check": "List left/right per shot for each person. No swap, including cutaways.", "origin": "Street interview: host moved from right to left.", "severity": "blocking", "applies": ["dialogue"]},
|
|
23
|
+
{"id": "R22", "title": "A conversation looks like one: eyelines, head turns, small hand gestures, a slow push-in on the punchline. Do the push-in in the edit as a crop. Ask the model for small gestures only.", "check": "Per talking shot: where does the person look? Count eyeline-matched reverse shots (at least 1). Note the crop push-in. OWNER CHECK for how natural it looks.", "origin": "\"Talk as if to someone, more hand movement, zoom in on the guy.\"", "severity": "blocking", "applies": ["dialogue"]},
|
|
24
|
+
{"id": "R23", "title": "Captions never look jumbled.", "check": "Compile `swiftc -O ocr.swift` (macOS Vision, `VNRecognizeTextRequest`, accurate, language correction off). Run it on one frame per caption from the final render. Retry once on an `e5rt` crash. Character error rate (edit distance divided by expected length, upper case, spaces collapsed) 0.10 or less. Do not run `swift ocr.swift` (it hangs).", "origin": "\"Some letters get jumbled.\"", "severity": "blocking", "applies": ["captions"]},
|
|
25
|
+
{"id": "R24", "title": "Show the tool of the format. A street interview shows the microphone in the host's hand. Name the mic in the motion prompt and include it in the R15 frame comparison.", "check": "Frame check: mic visible in every host shot, same shape in each.", "origin": "Interview had no visible mic.", "severity": "blocking", "applies": ["interview"]},
|
|
26
|
+
{"id": "R25", "title": "A horror-comic variant needs a scare beat: something moves behind a door, a sting, a reaction. Then it resolves as a joke.", "check": "Mark the beat on the timeline. A sting starts within 0.2 s of the beat, then a reaction shot. OWNER CHECK for how scary it feels.", "origin": "\"Feel a little bit scared.\"", "severity": "blocking", "applies": ["horror"]},
|
|
27
|
+
{"id": "R26", "title": "A personal story has a clear hook: the loss, the place, the trust that broke, the surprise. The offer is on the end card. The narrator never says the product worked. See R9 and R17.", "check": "Caption read: one sentence per beat, in that order. No line claims a result.", "origin": "\"I spent all my savings on this house ... a slick agent gave me a lien ... someone lives there.\"", "severity": "blocking", "applies": ["story"]}
|
|
28
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** @typedef {{name:string, required:boolean, maxChars:number, source:string}} Slot */
|
|
2
|
+
/** @typedef {{id:string, version:number, name:string, aspectRatios:string[], slots:Slot[], beats:string[], risk:string}} Template */
|
|
3
|
+
const slot = (name, source = 'source') => ({ name, source, required: true, maxChars: 240 });
|
|
4
|
+
/** @type {Template[]} */
|
|
5
|
+
export const templates = [
|
|
6
|
+
['door-open-teaser', 'Door-open teaser', ['detail'], ['Door reveal', 'Show the actual condition', 'Invite a closer look'], 'low'],
|
|
7
|
+
['ugc-first-person', 'UGC first-person', ['experience', 'disclosure'], ['First-person introduction', 'Verified experience', 'Ad disclosure and invitation'], 'medium'],
|
|
8
|
+
['honest-before-after', 'Honest before/after', ['before', 'after', 'disclosure'], ['Same view before', 'Same view after', 'Explain actual or virtual changes'], 'medium'],
|
|
9
|
+
['breaking-news-frame', 'Breaking-news frame', ['fact', 'source', 'disclosure'], ['Brand update banner', 'Dated and sourced fact', 'Clearly identify this as an ad'], 'high'],
|
|
10
|
+
['help-urgency', 'Help/urgency', ['problem', 'help', 'timing'], ['Name the task', 'Offer practical help', 'Use only verified timing'], 'medium'],
|
|
11
|
+
].map(([id, name, extra, beats, risk]) => ({
|
|
12
|
+
id, name, version: 1, aspectRatios: ['4:5', '9:16'], risk, beats,
|
|
13
|
+
slots: [slot('headline', 'hook'), ...extra.map(x => slot(x)), slot('cta', 'cta')],
|
|
14
|
+
brandTokens: ['name', 'primary', 'background', 'foreground'],
|
|
15
|
+
renderer: 'template', costClass: 'free', policy: { textExact: true, imageMayContainText: false },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Freeze every nested descriptor so callers cannot change rendering limits or risk.
|
|
19
|
+
function deepFreeze(value) {
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
22
|
+
Object.freeze(value);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
deepFreeze(templates);
|