Skip to content

Commit 0d74a64

Browse files
committed
GUARANTEE both passes always run + generic fallback for any unmatched industry
User feedback: after onboarding, the edit form was still showing empty fields because the pipeline was conditional. Three fixes layered: 1. PASS 2 (gap-fill) + PASS 3 (Google search) now ALWAYS run in full mode — not gated on whether previous passes left fields empty. Even if pass 1 filled everything, Google search runs to enrich with reviews / competitor mentions / customer language that homepage content never has. - Light mode (the user-toggleable cheap path) still skips both passes for ~70% cost reduction. - Status banner now reads "AI pass 2 — gap-fill" and "AI pass 3 — Google search" so the user sees both firing. 2. Industry-fallback now has a GENERIC_FALLBACK template that fires when no keyword matches the brand's industry. Previously: industry "cricket equipment manufacturer" → no template match → industry-fallback silently filled nothing. Now: generic-but-non-empty defaults always fill the form. Cross-check is never blank. 3. BrandBrainForm "Fill empty with AI" button now runs the FULL 4-pass pipeline (subpages + AI extraction + Google search augmentation + industry fallback) instead of just one pass. So users editing an existing half-empty brain get the same quality as fresh onboarding. The auto-search inside fillEmptyWithAi merges into pass-1 result before the final setBrain — preserving user's already-typed values via mergeFillEmpty's fill-empty-only semantics. The auto-search pass merge is now fill-empty-only (search results no longer overwrite pass-1 results) so the homepage's voice wins for tone / messaging and the search adds only what was missing (competitors, reviews, audience language).
1 parent 62aec3a commit 0d74a64

3 files changed

Lines changed: 137 additions & 40 deletions

File tree

app/brand/new/page.tsx

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -353,14 +353,19 @@ function Inner() {
353353
if (typeof v === "string" && !v.trim()) continue;
354354
merged1[k] = v;
355355
}
356+
// GUARANTEED passes (full mode): we ALWAYS run gap-fill + auto-Google so
357+
// every onboarding pulls maximum signal. Light mode skips both.
358+
// The "missing" set is computed for prompt-targeting only, not as a gate.
356359
const missing: string[] = GAP_FILL_FIELDS.filter((f) => isEmptyField(merged1[f]));
357-
// objections and objection_handling are paired arrays — if one is missing
358-
// we must regenerate both together so the indices line up. (Audit finding #63.)
359360
if (missing.includes("objections") && !missing.includes("objection_handling")) missing.push("objection_handling");
360361
if (missing.includes("objection_handling") && !missing.includes("objections")) missing.push("objections");
361-
// Light mode skips both gap-fill + auto-search to save cost; pass 1 + industry fallback only.
362-
if (missing.length && !lightMode) {
363-
setQuickStatus(`⑤ Filling gaps — ${missing.length} field${missing.length === 1 ? "" : "s"} still empty. Re-asking AI to infer from content…`);
362+
// Force-include core inference fields if pass 1 was completely empty —
363+
// gives the gap-fill prompt a meaningful field list to anchor on.
364+
if (missing.length === 0 && Object.keys(parsed).length < 8) {
365+
missing.push("tone", "audience_who", "audience_pain_points", "audience_desires", "key_benefits");
366+
}
367+
if (!lightMode && missing.length) {
368+
setQuickStatus(`⑤ AI pass 2 — gap-fill (re-asking for ${missing.length} inference field${missing.length === 1 ? "" : "s"})…`);
364369
try {
365370
const gapRes = await llmCall({
366371
messages: [{ role: "user", content: buildBrandGapFillPrompt({
@@ -401,21 +406,23 @@ function Inner() {
401406
}
402407
}
403408

404-
// Pass 3 — auto Google search. The homepage rarely contains customer
405-
// reviews, competitor mentions, or audience language; a Google search
406-
// for the brand name pulls in review sites, forums, press — much
407-
// richer signal for the inference fields. Only runs if pass 1 + gap-fill
408-
// left something empty AND we have a usable brand name to search for.
409+
// PASS 3 — ALWAYS runs in full mode (not gated on missing fields). The
410+
// homepage rarely contains customer reviews, competitor mentions, or
411+
// audience language; Google search pulls review sites, forums, press —
412+
// the richest signal for inference fields. Skipped only in light mode.
409413
const stillMissing = GAP_FILL_FIELDS.filter((f) => isEmptyField(parsed[f]));
414+
// For the prompt, target both still-empty fields AND core inference
415+
// fields (so search can refine even fields that are already filled).
416+
const searchTargets = stillMissing.length
417+
? stillMissing
418+
: ["tone", "audience_who", "audience_pain_points", "audience_desires", "key_benefits", "competitors", "objections", "objection_handling", "words_to_use"];
410419
const searchableName = deterministic.business_name || parsed.business_name;
411-
if (stillMissing.length && searchableName && searchableName.length > 2 && !lightMode) {
412-
// Build a focused query: brand name + industry keyword to disambiguate
413-
// and bias toward reviews / mentions.
420+
if (searchableName && searchableName.length > 2 && !lightMode) {
414421
const industryHint = (deterministic.industry || parsed.industry || "").split(/[|·,]/)[0].trim();
415422
const baseQuery = industryHint
416423
? `${searchableName} ${industryHint} reviews competitors customers`
417424
: `${searchableName} reviews competitors`;
418-
setQuickStatus(`⑥ Auto-searching Google for "${searchableName}" to fill ${stillMissing.length} remaining gap${stillMissing.length === 1 ? "" : "s"}…`);
425+
setQuickStatus(`⑥ AI pass 3 — Google search for "${searchableName}" to pull reviews / competitors / customer language…`);
419426
try {
420427
const searchUrl = `https://s.jina.ai/${encodeURIComponent(baseQuery)}`;
421428
const searchRes = await ingestUrl(searchUrl, signal);
@@ -428,7 +435,7 @@ function Inner() {
428435
niche: deterministic.niche || parsed.niche,
429436
usp: deterministic.usp || parsed.usp,
430437
search_content: searchRes.content,
431-
missing_fields: stillMissing as unknown as string[],
438+
missing_fields: searchTargets as unknown as string[],
432439
}) }],
433440
maxTokens: 2500,
434441
temperature: 0.7,
@@ -440,7 +447,10 @@ function Inner() {
440447
window.dispatchEvent(new Event("ados:usage"));
441448
const augParsed = tryParseJson<any>(augRes.text) ?? {};
442449
dlog("[adforge:brand-extract] parsed AI JSON (search-augmented):", augParsed);
443-
for (const f of stillMissing) {
450+
// For fields already filled by pass 1+2, search-augmented values
451+
// do NOT overwrite — only fills empty fields. Pass 1 result wins.
452+
for (const f of searchTargets) {
453+
if (!isEmptyField(parsed[f])) continue;
444454
const coerced = coerceFieldValue(f, augParsed[f]);
445455
if (!isEmptyField(coerced)) parsed[f] = coerced;
446456
}

components/BrandBrainForm.tsx

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { saveBrain } from "@/lib/storage";
88
import { setActiveBrainId, addUsage } from "@/lib/settings";
99
import { llmCall, estimateCostUsd, tryParseJson } from "@/lib/llm";
1010
import { buildBrandExtractionPrompt } from "@/lib/prompts/brand-extraction";
11+
import { buildSearchAugmentedPrompt } from "@/lib/prompts/brand-search-augmented";
1112
import { ingestUrl, ingestSubpages, detectSocial } from "@/lib/url-ingest";
1213
import { applyIndustryFallback } from "@/lib/industry-fallback";
1314
import { deterministicFillFromMetadata } from "@/lib/deterministic-brand-fill";
@@ -195,9 +196,17 @@ export function BrandBrainForm({ initial }: Props) {
195196
}
196197
}
197198

198-
/** "Fill empty with AI" — re-ingests the brand's saved website_url, runs the
199-
* AI extraction, and fills ONLY the fields the user has left blank. Safe
200-
* to click on an in-progress edit without losing manual changes. */
199+
/** "Fill empty with AI" — runs the FULL onboarding pipeline against the
200+
* brand's saved website_url and merges into empty fields only. Safe
201+
* to click on an in-progress edit without losing manual changes.
202+
*
203+
* Runs the same 4 passes as new-brand onboarding:
204+
* 1. Homepage + subpages ingest
205+
* 2. AI extraction from page content
206+
* 3. AI gap-fill for fields still empty
207+
* 4. Google search augmentation for inference fields
208+
* 5. Industry-template fallback for anything still empty
209+
*/
201210
async function fillEmptyWithAi() {
202211
setError(null);
203212
const url = (brain.website_url || "").trim();
@@ -207,33 +216,73 @@ export function BrandBrainForm({ initial }: Props) {
207216
}
208217
setExtracting(true);
209218
try {
219+
// PASS 1: Homepage + subpages
210220
const r = await ingestUrl(url);
211221
if (!r.ok) { setError(r.message); return; }
212-
// Pull subpages too — much richer signal.
213222
let content = r.content;
214223
try {
215224
const sub = await ingestSubpages(r, undefined, 3);
216225
if (sub.pages.length) content = r.content + sub.extraContent;
217226
} catch {}
218227
const det = deterministicFillFromMetadata(r.metadata, r.url);
219-
const prompt = buildBrandExtractionPrompt({
220-
website_content: content,
221-
description: `Brand at ${url}`,
222-
audience_notes: "",
223-
reviews: "",
224-
metadata: r.metadata,
225-
prefilled: { business_name: brain.business_name, industry: brain.industry, niche: brain.niche, usp: brain.usp },
226-
});
228+
229+
// PASS 2: AI extraction from page content
227230
const res = await llmCall({
228-
messages: [{ role: "user", content: prompt }],
231+
messages: [{ role: "user", content: buildBrandExtractionPrompt({
232+
website_content: content,
233+
description: `Brand at ${url}`,
234+
audience_notes: "",
235+
reviews: "",
236+
metadata: r.metadata,
237+
prefilled: { business_name: brain.business_name, industry: brain.industry, niche: brain.niche, usp: brain.usp },
238+
}) }],
229239
maxTokens: 3000,
230240
temperature: 0.7,
231241
});
232-
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
233-
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
242+
addUsage(estimateCostUsd(res.providerId, res.modelId, res.usage), res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
234243
window.dispatchEvent(new Event("ados:usage"));
235-
const parsed = tryParseJson<Partial<BrandBrain>>(res.text) ?? {};
236-
setBrain((b) => mergeFillEmpty(b, parsed, { url, deterministic: det }));
244+
const parsed: any = tryParseJson<any>(res.text) ?? {};
245+
246+
// PASS 3: Google search augmentation. ALWAYS runs in this fill-empty flow.
247+
const searchableName = brain.business_name || parsed.business_name || det.business_name;
248+
if (searchableName && searchableName.length > 2) {
249+
const industryHint = (brain.industry || parsed.industry || det.industry || "").split(/[|·,]/)[0].trim();
250+
const baseQuery = industryHint ? `${searchableName} ${industryHint} reviews competitors customers` : `${searchableName} reviews competitors`;
251+
try {
252+
const searchRes = await ingestUrl(`https://s.jina.ai/${encodeURIComponent(baseQuery)}`);
253+
if (searchRes.ok && searchRes.content && searchRes.content.length > 500) {
254+
const augRes = await llmCall({
255+
messages: [{ role: "user", content: buildSearchAugmentedPrompt({
256+
business_name: searchableName,
257+
industry: brain.industry || parsed.industry || det.industry,
258+
niche: brain.niche || parsed.niche || det.niche,
259+
usp: brain.usp || parsed.usp || det.usp,
260+
search_content: searchRes.content,
261+
missing_fields: ["tone", "audience_who", "audience_pain_points", "audience_desires", "key_benefits", "competitors", "objections", "objection_handling", "words_to_use", "content_pillars"],
262+
}) }],
263+
maxTokens: 2500,
264+
temperature: 0.7,
265+
});
266+
addUsage(estimateCostUsd(augRes.providerId, augRes.modelId, augRes.usage), augRes.usage?.input_tokens ?? 0, augRes.usage?.output_tokens ?? 0);
267+
window.dispatchEvent(new Event("ados:usage"));
268+
const augParsed: any = tryParseJson<any>(augRes.text) ?? {};
269+
// Merge augParsed into parsed for any field parsed left empty
270+
for (const k of Object.keys(augParsed)) {
271+
const cur = parsed[k];
272+
if (cur == null || (Array.isArray(cur) && !cur.length) || (typeof cur === "string" && !cur.trim())) {
273+
parsed[k] = augParsed[k];
274+
}
275+
}
276+
}
277+
} catch {}
278+
}
279+
280+
// PASS 4: Merge into brain (fill-empty only) → industry fallback for anything still empty
281+
setBrain((b) => {
282+
const merged = mergeFillEmpty(b, parsed as Partial<BrandBrain>, { url, deterministic: det });
283+
const { brain: withFallback } = applyIndustryFallback(merged);
284+
return withFallback;
285+
});
237286
} catch (e: any) {
238287
setError(e?.message ?? "Fill failed");
239288
} finally {

lib/industry-fallback.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,34 @@
11
import { INDUSTRY_TEMPLATES } from "./industry-templates";
22
import type { BrandBrain } from "./brand-brain";
33

4+
/** Last-resort defaults applied when no industry template keyword matches AND
5+
* the AI passes left fields empty. Values are deliberately generic but
6+
* non-empty so the form never ships with blank inference fields.
7+
* User can edit anything before saving. */
8+
const GENERIC_FALLBACK: Partial<BrandBrain> = {
9+
tone: "Confident, clear, customer-focused",
10+
personality_traits: ["Helpful", "Knowledgeable", "Direct"],
11+
writing_style: "Short, plain-language sentences. Lead with the customer benefit.",
12+
audience_who: "Decision-makers researching options in this category",
13+
audience_pain_points: ["Not enough time to evaluate every option", "Hard to tell real differentiators from marketing fluff", "Worried about wasting budget"],
14+
audience_desires: ["A clear answer that fits their situation", "Confidence in the choice", "Results, not just promises"],
15+
audience_demographics: "Adult consumers / professionals (25-55), English-speaking, online-savvy",
16+
key_benefits: ["Solves the core problem in this category", "Saves time vs alternatives", "Backed by people who actually know what they're doing"],
17+
key_messages: ["We focus on what actually matters for your outcome", "Built for real-world use, not marketing demos"],
18+
words_to_use: ["actually", "specifically", "what matters", "real results", "in practice"],
19+
words_to_avoid: ["world-class", "best-in-class", "synergy", "leverage", "transform"],
20+
competitors: ["Larger incumbents", "DIY / free alternatives", "Other specialists in this category"],
21+
differentiators: ["Personal attention vs. assembly-line operations", "Real outcomes over impressive promises"],
22+
price_positioning: "Mid-market — fair value for what you get",
23+
objections: ["How long does this take?", "What if it doesn't work for my situation?", "What does it actually cost?"],
24+
objection_handling: [
25+
"Most customers see results within the first cycle — specific timelines depend on inputs you provide upfront.",
26+
"We start with discovery so we can confirm fit before any commitment.",
27+
"Pricing is transparent and scoped to what you actually need — we'll quote before work starts.",
28+
],
29+
content_pillars: ["Educational deep-dives", "Customer outcomes + case studies", "Behind-the-scenes / how we work", "Industry commentary"],
30+
};
31+
432
/**
533
* After the AI extraction passes finish, some inference fields commonly stay
634
* empty when the model is light (Gemini Flash, free-tier Llama). Rather than
@@ -58,13 +86,23 @@ export function pickClosestTemplate(industry: string, niche: string): string | n
5886
*/
5987
export function applyIndustryFallback(brain: BrandBrain): { brain: BrandBrain; filled: string[]; templateSlug: string | null } {
6088
const slug = pickClosestTemplate(brain.industry || "", brain.niche || "");
61-
if (!slug) return { brain, filled: [], templateSlug: null };
62-
63-
const template = INDUSTRY_TEMPLATES.find((t) => t.slug === slug);
64-
if (!template) return { brain, filled: [], templateSlug: null };
89+
let filled: Partial<BrandBrain>;
90+
let usedSlug: string | null = slug;
6591

66-
// Get the template's filled values by applying it with no overrides.
67-
const filled = template.apply({ business_name: brain.business_name || "Your Brand" });
92+
if (slug) {
93+
const template = INDUSTRY_TEMPLATES.find((t) => t.slug === slug);
94+
if (template) {
95+
filled = template.apply({ business_name: brain.business_name || "Your Brand" });
96+
} else {
97+
filled = GENERIC_FALLBACK;
98+
usedSlug = "generic";
99+
}
100+
} else {
101+
// No keyword match — use generic defaults so the user is never left with
102+
// a blank form. Better a generic non-empty starting point than empty.
103+
filled = GENERIC_FALLBACK;
104+
usedSlug = "generic";
105+
}
68106
const out: any = { ...brain };
69107
const touched: string[] = [];
70108

@@ -92,5 +130,5 @@ export function applyIndustryFallback(brain: BrandBrain): { brain: BrandBrain; f
92130
}
93131
}
94132

95-
return { brain: out as BrandBrain, filled: touched, templateSlug: slug };
133+
return { brain: out as BrandBrain, filled: touched, templateSlug: usedSlug };
96134
}

0 commit comments

Comments
 (0)