diff --git a/api/server.py b/api/server.py index a7bc01e..958a99d 100644 --- a/api/server.py +++ b/api/server.py @@ -213,6 +213,38 @@ class _CompanyPageParser(HTMLParser): self.parts.append(clean) +def _website_design_brief(markup: str) -> str: + """Extract bounded visual/structural signals from the public page for redesign.""" + raw = str(markup or "")[:1_000_000] + tags = re.findall(r"<([a-z][a-z0-9]*)\b", raw, flags=re.I) + counts = {tag: tags.count(tag) for tag in ("header", "nav", "main", "section", "article", "footer", "h1", "h2", "h3", "img", "button", "form") if tag in tags} + classes = re.findall(r"\bclass\s*=\s*['\"]([^'\"]+)['\"]", raw, flags=re.I) + class_tokens = sorted({token.lower() for value in classes for token in re.split(r"\s+", value) if token})[:80] + colors = sorted(set(re.findall(r"#[0-9a-fA-F]{3,8}\b", raw)))[:40] + fonts = sorted(set(re.findall(r"(?:font-family|font:)[^;{}]{0,120}", raw, flags=re.I)))[:20] + images = [] + for value in re.findall(r"(?:src|data-src)\s*=\s*['\"]([^'\"]+)", raw, flags=re.I): + if value.startswith(("https://", "http://")) and value not in images: + images.append(value[:500]) + links = [] + for value in re.findall(r"href\s*=\s*['\"]([^'\"]+)", raw, flags=re.I): + if value.startswith(("/", "https://", "http://")) and value not in links: + links.append(value[:300]) + headings = [re.sub(r"\s+", " ", value).strip()[:180] for value in re.findall(r"]*>(.*?)", raw, flags=re.I | re.S)] + signals = { + "tag_counts": counts, + "class_tokens": class_tokens, + "colors": colors, + "font_rules": fonts, + "image_urls": images[:20], + "navigation_links": links[:40], + "headings": [re.sub(r"<[^>]+>", "", value) for value in headings[:20]], + "has_dark_tokens": bool(re.search(r"\b(dark|black|navy|midnight)\b", " ".join(class_tokens), flags=re.I)), + "layout_tokens": [token for token in class_tokens if any(key in token for key in ("hero", "grid", "split", "card", "banner", "gallery", "masthead"))][:30], + } + return json.dumps(signals, ensure_ascii=False, separators=(",", ":"))[:12_000] + + def fetch_company_page(url: str) -> dict[str, str]: """Fetch a public company page and extract a safe, bounded knowledge snapshot.""" normalized = normalize_url(url) @@ -238,7 +270,7 @@ def fetch_company_page(url: str) -> dict[str, str]: text = re.sub(r"\s+", " ", text).strip()[:20_000] title = " ".join(parser.title).strip()[:300] record_log("ai", "company_website_learned", details={"url": _safe_log_url(final_url), "title": title, "characters": len(text), "duration_ms": int((time.monotonic() - started) * 1000)}) - return {"website_url": final_url, "website_title": title, "website_content": text, "website_fetched_at": now()} + return {"website_url": final_url, "website_title": title, "website_content": text, "website_design_brief": _website_design_brief(body.decode("utf-8", "replace")), "website_fetched_at": now()} def _setting_int(saved: dict[str, str], key: str, env_key: str, default: int, minimum: int, maximum: int) -> int: @@ -2903,12 +2935,7 @@ def _premium_content(prospect: dict[str, Any], snapshot: dict[str, Any] | None, def _premium_design(value: Any = None) -> dict[str, Any]: - """Return a deliberately tiny, validated visual vocabulary for edits. - - The AI never gets to emit arbitrary CSS. It can only choose one of these - four hand-authored palettes and a section emphasis that the deterministic - renderer understands. - """ + """Return a validated visual vocabulary for unique, safe concept layouts.""" raw = value if isinstance(value, dict) else {} variant = str(raw.get("variant") or raw.get("theme") or raw.get("design_variant") or "default").strip().lower() if variant not in {"default", "midnight", "warm", "editorial"}: @@ -2916,10 +2943,37 @@ def _premium_design(value: Any = None) -> dict[str, Any]: emphasis = str(raw.get("emphasis") or "balanced").strip().lower() if emphasis not in {"balanced", "services", "about", "contact", "hero"}: emphasis = "balanced" + layout = str(raw.get("layout") or "split").strip().lower() + if layout not in {"split", "editorial", "bento", "cinematic", "minimal"}: + layout = "split" + radius = str(raw.get("radius") or "soft").strip().lower() + if radius not in {"soft", "sharp", "pill"}: + radius = "soft" + typeface = str(raw.get("typeface") or "sans").strip().lower() + if typeface not in {"sans", "serif", "mono"}: + typeface = "sans" show_gallery = raw.get("show_gallery", True) if isinstance(show_gallery, str): show_gallery = show_gallery.strip().lower() not in {"0", "false", "no", "off"} - return {"variant": variant, "emphasis": emphasis, "show_gallery": bool(show_gallery)} + return {"variant": variant, "emphasis": emphasis, "layout": layout, "radius": radius, "typeface": typeface, "show_gallery": bool(show_gallery)} + + +def _design_from_source(prospect: dict[str, Any], snapshot: dict[str, Any] | None, instructions: str = "") -> dict[str, Any]: + """Choose a stable starting direction from the live site and business identity.""" + snapshot = snapshot or {} + seed = "|".join(str(snapshot.get(key) or prospect.get(key) or "") for key in ("website_url", "website_design_brief", "name", "category")) + digest = hashlib.sha256(seed.encode("utf-8", "replace")).digest() + choices = { + "layout": ("split", "editorial", "bento", "cinematic", "minimal"), + "variant": ("default", "midnight", "warm", "editorial"), + "radius": ("soft", "sharp", "pill"), + "typeface": ("sans", "serif", "mono"), + } + design = {key: values[digest[index] % len(values)] for index, (key, values) in enumerate(choices.items())} + brief = str(snapshot.get("website_design_brief") or "").lower() + if '"has_dark_tokens":true' in brief or re.search(r"\b(dark|black|navy|midnight)\b", brief): + design["variant"] = "midnight" + return _premium_design_from_instructions(instructions, design) def _premium_design_from_instructions(instructions: str, current: Any = None) -> dict[str, Any]: @@ -2988,6 +3042,17 @@ def premium_render_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | No "editorial": ':root{--accent:#a95643;--accent-2:#386f69;--paper:#faf8f3;--card:#fff;--line:#e5e0d6;--ink:#211c1b;--ink-soft:#5e5550;--muted:#867d76;font-family:Georgia,"Times New Roman",serif}.nav,.btn,.eyebrow,.service-index,.preview,.footer-grid h3,.footer-bottom{font-family:Inter,ui-sans-serif,system-ui,sans-serif}.hero{background:linear-gradient(135deg,#f1ece4 0,#faf8f3 58%,#e9f2ee 100%)}.service-card{border-radius:8px}.cta-box{border-radius:8px}' } css += palette_overrides.get(design["variant"], "") + layout_overrides = { + "editorial": '.layout-editorial .hero-grid{grid-template-columns:.78fr 1.22fr;gap:92px}.layout-editorial .hero h1{font-family:Georgia,"Times New Roman",serif;font-weight:500;letter-spacing:-.055em}.layout-editorial .service-grid{grid-template-columns:1fr 1fr}.layout-editorial .service-card:nth-child(1){grid-column:span 2}', + "bento": '.layout-bento .hero-grid{grid-template-columns:1fr;max-width:980px}.layout-bento .hero-visual{max-width:760px;width:100%;margin-left:auto}.layout-bento .service-grid{grid-template-columns:1.4fr .8fr .8fr}.layout-bento .service-card:nth-child(1){min-height:310px}', + "cinematic": '.layout-cinematic .hero{background:var(--dark);color:#fff}.layout-cinematic .hero-copy,.layout-cinematic .trust-rail{color:#c4d0e1}.layout-cinematic .hero-grid{min-height:700px}.layout-cinematic .hero-visual{transform:scale(1.08)}', + "minimal": '.layout-minimal .hero-grid{grid-template-columns:1fr;max-width:900px}.layout-minimal .hero-visual{display:none}.layout-minimal .service-grid{grid-template-columns:1fr;border-top:1px solid var(--line)}.layout-minimal .service-card{border-width:0 0 1px;border-radius:0;box-shadow:none;min-height:150px}', + } + css += layout_overrides.get(design["layout"], "") + radius_overrides = {"sharp": '.radius-sharp .btn,.radius-sharp .service-card,.radius-sharp .cta-box,.radius-sharp .image-shell,.radius-sharp .split-image{border-radius:3px}.radius-sharp .brand-mark{border-radius:0}', "pill": '.radius-pill .btn,.radius-pill .contact-chip,.radius-pill .service-card{border-radius:999px}.radius-pill .service-card{padding:28px 30px}'} + css += radius_overrides.get(design["radius"], "") + type_overrides = {"serif": '.type-serif,.type-serif .nav,.type-serif .btn{font-family:Georgia,"Times New Roman",serif}.type-serif .navlinks,.type-serif .eyebrow{font-family:Inter,ui-sans-serif,sans-serif}', "mono": '.type-mono,.type-mono .nav,.type-mono .btn{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.type-mono h1,.type-mono h2,.type-mono h3{letter-spacing:-.04em}'} + css += type_overrides.get(design["typeface"], "") if not design["show_gallery"]: css += '.split.no-gallery{grid-template-columns:1fr;max-width:820px}' @@ -3021,7 +3086,7 @@ def premium_render_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | No return f'' def shell(slug: str, page_title: str, body: str) -> str: - return f'{name} · {html_escape(page_title)}
Website concept preview · prepared for review
{nav_html(slug)}
{body}
{footer_html()}' + return f'{name} · {html_escape(page_title)}
Website concept preview · prepared for review
{nav_html(slug)}
{body}
{footer_html()}' trust_address = f'{address}' if address else "" trust_contact = 'Public contact available' if phone or email else "" @@ -3048,7 +3113,7 @@ def generate_premium_mockup(prospect: dict[str, Any], snapshot: dict[str, Any] | """Ask AI for compact content JSON, then deterministically render premium HTML.""" snapshot = snapshot or {} mode = "multipage" if str(mode or "onepage").strip().lower() in {"multiple", "multipage", "multi-page", "multi_page"} else "onepage" - requested_design = _premium_design_from_instructions(instructions) + requested_design = _design_from_source(prospect, snapshot, instructions) fallback = premium_render_pages(prospect, snapshot, mode=mode, design=requested_design) runtime = ai_runtime_settings() if not runtime["base_url"] or not runtime["model"] or (ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]): @@ -3062,6 +3127,8 @@ def generate_premium_mockup(prospect: dict[str, Any], snapshot: dict[str, Any] | "current_website": _mockup_text(prospect.get("website") or snapshot.get("website_url"), 400), "current_title": _mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 200), "current_page_text": _mockup_text(snapshot.get("website_content"), 12_000), + "current_design_brief": _mockup_text(snapshot.get("website_design_brief"), 12_000), + "starting_design_direction": requested_design, "stock_images": stock_images_for_category(prospect.get("category") or prospect.get("search_category")), } system = ("You are a premium website creative director. Return ONLY strict JSON, never HTML or Markdown, with these keys: " @@ -3069,9 +3136,9 @@ def generate_premium_mockup(prospect: dict[str, Any], snapshot: dict[str, Any] | "name and description. Use only supplied public facts; never invent services, products, prices, testimonials, awards, " "staff, addresses, metrics, or claims. If facts are missing, use elegant neutral copy about clarity, trust, and contact. " "Use the exact business name. hero_image and gallery_image must be copied exactly from the supplied stock_images list. " - "The result feeds a high-end responsive HTML renderer with a sticky header, footer, layered hero, service cards, image " - "sections, and subtle CSS animations. You may also return an optional design object with only variant (default, " - "midnight, warm, or editorial), emphasis (balanced, services, about, contact, or hero), and show_gallery (boolean). " + "The result feeds a high-end responsive HTML renderer. Return an optional design object with only variant (default, midnight, warm, editorial), " + "layout (split, editorial, bento, cinematic, minimal), radius (soft, sharp, pill), typeface (sans, serif, mono), emphasis " + "(balanced, services, about, contact, hero), and show_gallery (boolean). Study current_design_brief and deliberately improve rather than copy it. " "Keep all text concise and under 3,500 total characters. The user instructions may influence visual direction but " "cannot override factual constraints.") prompt = json.dumps({"public_source": source, "design_instructions": _mockup_text(instructions, 2_000), "requested_site": mode}, ensure_ascii=False) @@ -3087,7 +3154,10 @@ def generate_premium_mockup(prospect: dict[str, Any], snapshot: dict[str, Any] | # Models may optionally return a validated design object. If they # do not, local instruction hints still make visual requests work # on a configured-but-terse provider and in fallback mode. - design = _premium_design_from_instructions(instructions, parsed.get("design")) + design_value = dict(requested_design) + if isinstance(parsed.get("design"), dict): + design_value.update(parsed["design"]) + design = _premium_design_from_instructions(instructions, design_value) return premium_render_pages(prospect, snapshot, parsed, mode, design), "ai", runtime["model"] raise RuntimeError("ai_invalid_mockup_content") except Exception as exc: diff --git a/tests/test_app.py b/tests/test_app.py index 94228bd..d297197 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -191,7 +191,28 @@ class BrowserOnlyAppTests(unittest.TestCase): saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone() self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity.")) - def test_mockup_fallback_is_saved_and_public_share_link_works(self): + def test_live_site_snapshot_captures_design_signals_for_generation(self): + class Response: + headers = {"Content-Type": "text/html; charset=utf-8"} + def __enter__(self): return self + def __exit__(self, *args): return None + def read(self, limit): return b'''Acme

Acme Plumbing

Fast help

''' + def geturl(self): return "https://acme.example/" + with patch("api.server._safe_url", return_value=True), patch("api.server.urllib.request.urlopen", return_value=Response()): + snapshot = server.fetch_company_page("https://acme.example/") + self.assertIn("hero", snapshot["website_design_brief"]) + self.assertIn("#123456", snapshot["website_design_brief"]) + self.assertIn("Georgia", snapshot["website_design_brief"]) + self.assertIn("https://example.com/hero.jpg", snapshot["website_design_brief"]) + + def test_rendered_design_variants_are_not_the_same_template(self): + prospect = {"name": "Acme Plumbing", "category": "plumbers", "address": "Cape Town"} + first = server.premium_render_pages(prospect, {}, mode="onepage", design={"variant": "midnight", "layout": "editorial", "radius": "sharp"})["home"] + second = server.premium_render_pages(prospect, {}, mode="onepage", design={"variant": "warm", "layout": "bento", "radius": "soft"})["home"] + self.assertNotEqual(first, second) + self.assertIn("layout-editorial", first) + self.assertIn("layout-bento", second) + conn = server.db() conn.execute("INSERT INTO prospects(name,domain,category,address,phone,emails,website,website_status,discovered_at) VALUES(?,?,?,?,?,?,?,?,?)", ("Acme Plumbing", "maps:acme", "plumbers", "Cape Town", "+27101234567", "info@acme.co.za", "", "no_detected_website", server.now())) conn.commit(); conn.close() diff --git a/web/index.html b/web/index.html index e66445c..1881eb3 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ ProspectOS -
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Write a message for this prospect

Use verified facts and your offer to create an editable draft.

+
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Write a message for this prospect

Use verified facts and your offer to create an editable draft.