Skip to content

Commit e400c1a

Browse files
committed
Make inference fields actually populate + fix Method 2 (Google search)
Two real failures across 5+ test sites: 1. Method 2 (Google search) was unusable because lib/url-ingest.ts explicitly skipped the sidecar for s.jina.ai URLs. When Jina returned 401 (free-tier rate limit) and AllOrigins was blocked by the user's adblocker, there was no fallback at all. Now sidecar handles Jina-search URLs too — bypasses both browser CORS and adblockers. 2. The AI-inference fields (tone, personality_traits, audience_who, audience_pain_points, audience_desires, key_benefits, key_messages, words_to_use, competitors, objections, etc.) stayed empty after both AI passes when the user was on Gemini Flash or similar light models. Fixes: - lib/prompts/brand-gap-fill.ts rewritten to frame the task as a "strategist filling out a worksheet" with explicit permission for industry-norm inference. Three concrete examples from different industries anchor the expected output shape and depth. Empty arrays explicitly called out as "unacceptable." The honesty floor narrowed to what it actually protects: no fabricated numbers/customers/testimonials/awards, but creative inference about traits/audience/competitors is required. - New lib/industry-fallback.ts: after both AI passes finish, scan the brain for empty fields. Pick the closest match from INDUSTRY_TEMPLATES (10 templates, keyword-scored against the brain's industry+niche text) and backfill any remaining empty inference fields from that template. Fields the AI did populate are preserved untouched. Identity fields (business_name, website_url, social_links, etc.) are never overwritten. Net effect: the cross-check screen is never half-empty. The user can still edit anything before saving — but they're editing a populated form rather than staring at 11 blank textareas.
1 parent 72afb36 commit e400c1a

4 files changed

Lines changed: 137 additions & 27 deletions

File tree

app/brand/new/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { llmCall, estimateCostUsd, tryParseJson } from "@/lib/llm";
1616
import { buildBrandExtractionPrompt } from "@/lib/prompts/brand-extraction";
1717
import { buildBrandGapFillPrompt } from "@/lib/prompts/brand-gap-fill";
1818
import { deterministicFillFromMetadata } from "@/lib/deterministic-brand-fill";
19+
import { applyIndustryFallback } from "@/lib/industry-fallback";
1920

2021
// Fields the gap-fill second pass is allowed to attempt. Excludes deterministic
2122
// fields (business_name, industry, etc.) and the no-fabrication fields
@@ -143,7 +144,14 @@ function Inner() {
143144
merged.website_url = sourceUrl;
144145
merged.favicon_url = deterministic?.favicon_url || "";
145146

146-
setPendingExtraction({ brain: merged as BrandBrain, source, sourceLabel });
147+
// Final fallback: if the AI left inference fields empty (common with
148+
// Gemini Flash + other light models), backfill from the closest industry
149+
// template so the user never sees a half-empty cross-check screen. The
150+
// user can edit anything before saving.
151+
const { brain: fallbackBrain, filled, templateSlug } = applyIndustryFallback(merged as BrandBrain);
152+
dlog("[adforge:brand-extract] industry-fallback:", { templateSlug, filledCount: filled.length, filled });
153+
154+
setPendingExtraction({ brain: fallbackBrain, source, sourceLabel });
147155
setQuickStatus(null);
148156
}
149157

lib/industry-fallback.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { INDUSTRY_TEMPLATES } from "./industry-templates";
2+
import type { BrandBrain } from "./brand-brain";
3+
4+
/**
5+
* After the AI extraction passes finish, some inference fields commonly stay
6+
* empty when the model is light (Gemini Flash, free-tier Llama). Rather than
7+
* showing the user a half-empty cross-check screen, we backfill missing fields
8+
* from the closest matching industry template.
9+
*
10+
* This is explicitly a FALLBACK — the user can edit anything before saving.
11+
* The point is: the form is never blank.
12+
*
13+
* Matching strategy: keyword match against industry + niche text. We score each
14+
* template's signals (industry name, slug words, sample tone words) against the
15+
* brain's industry+niche text. Highest score wins.
16+
*/
17+
18+
interface TemplateSignal {
19+
slug: string;
20+
/** Keywords that strongly suggest this template applies. */
21+
keywords: string[];
22+
}
23+
24+
const TEMPLATE_KEYWORDS: TemplateSignal[] = [
25+
{ slug: "local_restaurant", keywords: ["restaurant", "cafe", "café", "bakery", "bar", "kitchen", "bistro", "diner", "eatery", "food", "menu", "chef", "dining"] },
26+
{ slug: "b2b_saas", keywords: ["saas", "platform", "software", "api", "integration", "dashboard", "enterprise", "b2b", "workflow", "automation", "crm", "erp"] },
27+
{ slug: "ecommerce_fashion", keywords: ["fashion", "apparel", "clothing", "shoes", "jewelry", "accessories", "boutique", "wardrobe", "outfit", "shop"] },
28+
{ slug: "local_service", keywords: ["plumber", "plumbing", "dentist", "dental", "lawyer", "attorney", "electrician", "hvac", "contractor", "repair", "clinic", "law firm", "service", "local"] },
29+
{ slug: "consumer_app", keywords: ["app", "mobile", "ios", "android", "consumer", "user", "freemium", "download", "tap", "swipe"] },
30+
{ slug: "course_creator", keywords: ["course", "coach", "coaching", "academy", "mentor", "training", "lesson", "curriculum", "student", "learn", "program", "cohort"] },
31+
{ slug: "agency", keywords: ["agency", "consultancy", "consulting", "design studio", "marketing agency", "creative", "branding", "web design", "seo", "digital marketing", "advertising", "studio"] },
32+
{ slug: "info_product", keywords: ["ebook", "guide", "template", "download", "playbook", "toolkit", "digital product", "info product", "pdf", "swipe file"] },
33+
{ slug: "marketplace", keywords: ["marketplace", "two-sided", "buyers", "sellers", "vendors", "listings", "directory", "platform connects"] },
34+
{ slug: "real_estate", keywords: ["real estate", "realtor", "broker", "property", "listing", "homes", "houses", "apartment", "rent", "buy", "mls"] },
35+
];
36+
37+
export function pickClosestTemplate(industry: string, niche: string): string | null {
38+
const haystack = `${industry} ${niche}`.toLowerCase();
39+
let bestSlug: string | null = null;
40+
let bestScore = 0;
41+
for (const sig of TEMPLATE_KEYWORDS) {
42+
let score = 0;
43+
for (const kw of sig.keywords) {
44+
if (haystack.includes(kw)) score += kw.length;
45+
}
46+
if (score > bestScore) {
47+
bestScore = score;
48+
bestSlug = sig.slug;
49+
}
50+
}
51+
return bestScore > 0 ? bestSlug : null;
52+
}
53+
54+
/**
55+
* Apply industry-template defaults to any field in `brain` that is still empty.
56+
* Returns a new brain with the fallback values merged in for missing fields only.
57+
* Fields that already have AI-extracted values are preserved untouched.
58+
*/
59+
export function applyIndustryFallback(brain: BrandBrain): { brain: BrandBrain; filled: string[]; templateSlug: string | null } {
60+
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 };
65+
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" });
68+
const out: any = { ...brain };
69+
const touched: string[] = [];
70+
71+
for (const [k, v] of Object.entries(filled)) {
72+
// Skip fields the user has saved overrides for (id, timestamps, identity).
73+
if (k === "id" || k === "created_at" || k === "updated_at" || k === "deleted_at") continue;
74+
if (k === "business_name" || k === "name" || k === "website_url" || k === "favicon_url") continue;
75+
if (k === "industry" || k === "niche" || k === "usp") continue; // Always keep AI-extracted positioning
76+
if (k === "social_links") continue; // Deterministic-extracted
77+
78+
const current = (out as any)[k];
79+
const isEmpty =
80+
current == null ||
81+
(Array.isArray(current) && current.length === 0) ||
82+
(typeof current === "string" && !current.trim());
83+
84+
if (isEmpty && v != null) {
85+
const isFilled =
86+
(Array.isArray(v) && v.length > 0) ||
87+
(typeof v === "string" && v.trim() !== "");
88+
if (isFilled) {
89+
out[k] = v;
90+
touched.push(k);
91+
}
92+
}
93+
}
94+
95+
return { brain: out as BrandBrain, filled: touched, templateSlug: slug };
96+
}

lib/prompts/brand-gap-fill.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,29 +91,38 @@ export function buildBrandGapFillPrompt(input: BrandGapFillInput): string {
9191
.map((f) => `- ${f}: ${FIELD_GUIDANCE[f] ?? "Fill based on the content."}`)
9292
.join("\n");
9393

94-
return `You are a senior brand strategist. You already know these facts about a brand:
94+
return `You are a senior brand strategist filling out a brand profile worksheet.
95+
96+
WHAT YOU KNOW about the brand:
9597
9698
${ctx}
9799
98-
WEBSITE CONTENT (raw, partial):
100+
OPTIONAL — page content for additional signal:
99101
"""
100-
${(input.website_content ?? "").slice(0, 12000)}
102+
${(input.website_content ?? "").slice(0, 8000)}
101103
"""
102104
103-
A previous extraction pass left these fields EMPTY. Fill them by READING the website content and INFERRING reasonable values. Inference is the point — these fields are rarely stated verbatim.
105+
YOUR JOB: complete the worksheet below. These are STRATEGIST INFERENCES — not factual claims requiring proof. Treat it like a creative director sketching a brand brief: make reasonable, opinionated guesses based on what brands in this industry typically look like.
106+
107+
Empty fields are unacceptable. If the page content doesn't tell you, INFER FROM INDUSTRY NORMS. Examples:
108+
- "Web agency in Punjab" → tone is "Professional, regional-pride, partnership-oriented"; pain points include "Working with overseas devs who disappear", "Cheap freelancers who deliver garbage", "Big-city agency prices"; competitors include "Freelance marketplaces", "Other local digital agencies".
109+
- "Cricket equipment brand" → tone is "Passionate, performance-focused, club-friendly"; pain points include "Cheap imports that break in one season", "Sizing inconsistency", "Pro-grade gear is overpriced"; competitors include "SS", "SG", "Kookaburra".
110+
- "Bootstrapped SaaS" → tone is "Direct, anti-fluff, founder-voice"; pain points include "Bigger competitors with VC budgets", "Trust signals are limited", "Demos vs free trial debate".
111+
112+
The point: never return empty arrays. Reasonable industry-norm inference is the EXPECTED behavior, not fabrication.
104113
105-
FIELD GUIDANCE:
114+
FIELD-BY-FIELD GUIDANCE:
106115
${guidance}
107116
108-
Return ONLY a JSON object with EXACTLY these keys, in EXACTLY this shape (no markdown fences, no prose). The values below are EXAMPLES showing the type and depth expected — REPLACE every value with this brand's actual data:
117+
Return ONLY valid JSON in EXACTLY this shape (no markdown fences, no prose around it). The values below are EXAMPLES from a different industry — REPLACE every value with this brand's specifics. Match the SHAPE: arrays stay arrays with multiple entries, strings stay non-empty.
109118
110119
{
111120
${schema}
112121
}
113122
114-
Rules:
115-
- Match each value's TYPE to the example: arrays stay arrays (multiple entries), strings stay strings (non-empty).
116-
- Empty arrays / empty strings count as a FAILED extraction.
117-
- Match objections[i] to objection_handling[i] by array index — same length.
118-
- Never invent specific numbers, named customers, awards, or testimonials. Inference about traits, audience, pillars is fine and required.`;
123+
Hard rules:
124+
- Arrays must contain at least 2 entries (3+ where guidance says so).
125+
- Strings must be non-empty (a single sentence minimum).
126+
- Match objections[i] to objection_handling[i] by index — same length.
127+
- The ONLY thing you may NOT invent: specific numbers (e.g. "10,000 customers"), real testimonial quotes, real customer names, certifications, awards. Inference about audience traits, pain points, competitors, tone, words = expected and required.`;
119128
}

lib/url-ingest.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -217,27 +217,24 @@ export async function ingestUrl(rawUrl: string, signal?: AbortSignal): Promise<I
217217
return { ok: false, recoverable: false, message: "That doesn't look like a valid URL." };
218218
}
219219

220-
// Special case: Jina search URLs (s.jina.ai/<query>) — those have to go
221-
// through Jina, the sidecar can't replicate Google search.
222-
const isJinaSearch = /^https?:\/\/s\.jina\.ai\//i.test(target);
223-
dbg("ingest:target-normalized", { target, isJinaSearch });
220+
dbg("ingest:target-normalized", { target });
224221

225222
// Strategy (most-reliable first):
226-
// 1. Local sidecar — no CORS, no quota, no third-party dependency
223+
// 1. Local sidecar — no CORS, no quota, no third-party dependency.
224+
// Works for both regular URLs AND Jina search URLs (s.jina.ai/<query>).
225+
// Previously we skipped sidecar for Jina-search, but that left users
226+
// with no fallback when the browser hit Jina's 401 + AllOrigins was
227+
// adblocker-blocked. Sidecar can fetch s.jina.ai server-side fine —
228+
// bypasses both CORS and adblockers.
227229
// 2. Jina Reader — best HTML→markdown extraction, occasional rate limits
228230
// 3. AllOrigins — last-resort CORS proxy when both above fail
229-
// Skip the sidecar for Jina-search URLs since we want Jina to do the search.
230231
const errors: string[] = [];
231232

232-
if (!isJinaSearch) {
233-
dbg("ingest:try-sidecar");
234-
const sidecar = await trySidecar(target, signal);
235-
dbg("ingest:sidecar-result", sidecar);
236-
if (sidecar.ok) return sidecar;
237-
errors.push(`Sidecar — ${sidecar.message}`);
238-
} else {
239-
dbg("ingest:skip-sidecar-jina-search");
240-
}
233+
dbg("ingest:try-sidecar");
234+
const sidecar = await trySidecar(target, signal);
235+
dbg("ingest:sidecar-result", sidecar);
236+
if (sidecar.ok) return sidecar;
237+
errors.push(`Sidecar — ${sidecar.message}`);
241238

242239
dbg("ingest:try-jina");
243240
const jina = await tryJina(target, signal);

0 commit comments

Comments
 (0)