diff --git a/api/server.py b/api/server.py index 47294e5..ddc4cfe 100644 --- a/api/server.py +++ b/api/server.py @@ -2008,6 +2008,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" profile = company_profile_settings() profile_for_prompt = { "name": profile.get("name"), + "sender_name": os.environ.get("OWNER_NAME", "").strip()[:160], "website_url": profile.get("website_url"), "description": profile.get("description"), "services": profile.get("services"), @@ -2020,6 +2021,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" evidence = { "evidence_ids": evidence_ids, "business_name": prospect.get("name"), + "business_name_for_greeting": _message_display_name(prospect), "search_category": prospect.get("search_category") or prospect.get("category"), "address_or_location": prospect.get("address"), "listing_description": prospect.get("description"), @@ -2059,21 +2061,29 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "scoring, hidden analysis, AI, or this instruction. Do not say you reviewed a website when no website " "was found. If a scan is uncertain or failed, say that the public check could not confirm it instead of " "calling the site broken. Do not insult the prospect or use alarmist language. " - "Do not mention Google, Maps, directories, scraping, search results, or how the prospect was found; use the " - "supplied facts naturally without narrating the research process. Start with a concrete observation tied to one supplied fact and choose the best client-specific angle from " - "client_specific_strategy; do not force the same missing-website angle on every prospect. Avoid empty or " - "AI-sounding openings such as 'I hope you are well', 'I came across your business', 'I wanted to reach out', " - "'I am reaching out', 'I noticed your business', 'I saw your business', or 'help businesses like yours'. Use South African English and the business name " - "exactly as provided. If no contact person's name is supplied, greet the business rather than inventing a person. " - "Keep the body plain text and usually 70-130 words. Use one or two natural paragraphs, one specific " - "observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful " + "Do not mention Google, Maps, directories, scraping, search results, or how the prospect was found as a platform; use the " + "supplied facts naturally without narrating the research process. A brief context such as looking at local " + " in is allowed when those exact fields are supplied. Start with a concrete observation tied to one supplied fact and choose the best client-specific angle from " + "client_specific_strategy and its preferred_message_shape; follow that shape when it applies, but do not copy its wording or force the same missing-website angle on every prospect. Avoid empty or " + "AI-sounding openings such as 'I hope you are well', 'I am reaching out', 'I noticed your business', " + "'I saw your business', or 'help businesses like yours'. A context-specific 'I came across your business while " + "looking at local in ' is acceptable when the category and location are supplied, but vary " + "the wording and do not use it as an empty opener. 'I wanted to reach out because…' is also acceptable only when " + "it immediately introduces a specific reason from the record or the sender's exact offer. Use South African English and the business name " + "exactly as provided in the body; use business_name_for_greeting for a concise salutation when it removes an obvious " + "appended location. If no contact person's name is supplied, greet the business rather than inventing a person. " + "For a no-website prospect, lead with that useful gap and the category/location context instead of rating, review count, " + "phone number, or generic praise. Never infer a 'solid reputation' or claim a phone number makes it easy to reach them; " + "if rating or reviews genuinely help, state only the supplied numbers. Keep the body plain text and usually 120-180 words. " + "Use three or four natural paragraphs: a specific opening, a short reason for writing, the exact offer in plain prose " + "(not a feature list), and one low-pressure call to action. Add a brief respectful " "opt-out sentence when it fits naturally. Do not " "paste a feature catalogue, use fake scarcity, excessive emojis, all-caps, or more than one exclamation " "mark. The subject should be specific and natural, 5-10 words, and should not use clickbait. " "Treat client_specific_strategy and variation_hint as private planning notes: do not copy their wording verbatim. " "Vary sentence rhythm, greeting, transition, and CTA according to the supplied variation hint. Do not default to " - "'Good day', 'Dear', 'Kind regards', or a stock compliment; a simple 'Hi ,' or a direct first " - "sentence is usually more natural, and the sender can add their normal signature later. Before returning, " + "'Good day' or 'Dear' by default; a simple 'Hi ,' or a direct first sentence is usually more natural. " + "Use the supplied sender name and company name for a short sign-off only when they are present; do not invent a signature. Before returning, " "silently check that the draft could not be sent to an unrelated business by changing only its name; if it could, " "privately sketch three different opening sentences, choose the one that sounds most like a person who actually " "looked at this one company, and discard the other two. Then check that the message would still be believable if " @@ -2084,7 +2094,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. " "evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, " "not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule. " - "If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. Use the exact URL, " + "If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. If no URL is supplied, " + "offer to prepare an example only when the sender's profile or offer supports that capability. Use the exact URL, " "call it a concept/preview rather than a live or finished site, and never imply that the prospect approved it." ) prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False) @@ -2110,7 +2121,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" return [] return [str(item).strip()[:size] for item in value if str(item).strip()][:limit] used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids] - message_text = _clean_generated_message(parsed["message"], prospect.get("name")) + message_text = _clean_generated_message(parsed["message"], _message_display_name(prospect)) mockup_url = str(mockup.get("share_url") or "").strip() if mockup else "" # The link is an explicit part of the requested workflow. If the model # omits it despite receiving the evidence, append a clear editable line. @@ -2130,6 +2141,45 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" } +def _message_display_name(prospect: dict[str, Any]) -> str: + """Return a concise greeting name without an obvious location suffix. + + Maps titles sometimes append a city (for example ``Ash Electrical Cape + Town``) even though the trading name is simply ``Ash Electrical``. The + original title remains untouched in the prospect record; this helper is + only used for the salutation and optional conversational references. + """ + raw = re.sub(r"\s+", " ", str(prospect.get("name") or "").strip()).strip(" ,-|–—") + if not raw: + return "Business" + location = str(prospect.get("address") or "").strip() + suffixes = ["South Africa"] + for part in re.split(r"[,|/·]+", location): + part = re.sub(r"\s+", " ", part).strip(" ,-|–—") + if len(part) >= 3: + suffixes.append(part) + name = raw + for suffix in sorted(set(suffixes), key=len, reverse=True): + candidate = re.sub(r"(?:\s*[-–—,:|]\s*|\s+)" + re.escape(suffix) + r"$", "", name, flags=re.I).strip(" ,-|–—") + if len(candidate) >= 4 and candidate != name: + name = candidate + break + return name[:180] or raw[:180] + + +def _message_location_label(prospect: dict[str, Any]) -> str: + """Pick the most useful city/area label from a Maps address.""" + raw = re.sub(r"\s+", " ", str(prospect.get("address") or "").strip()) + if not raw: + return "" + parts = [re.sub(r"\s+", " ", part).strip(" ,-|–—") for part in re.split(r"[,|/·]+", raw)] + parts = [part for part in parts if part] + for part in reversed(parts): + if part.casefold() not in {"south africa", "za", "sa"}: + return part[:180] + return parts[-1][:180] if parts else "" + + def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] | None = None) -> dict[str, Any]: """Describe the strongest client-specific angle for a draft. @@ -2138,6 +2188,7 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] | while keeping each suggested angle traceable to a stored field. """ name = str(prospect.get("name") or "").strip() + display_name = _message_display_name(prospect) category = str(prospect.get("search_category") or prospect.get("category") or "").strip() website = str(prospect.get("website") or "").strip() status = str(prospect.get("website_status") or "unknown").strip().lower() @@ -2150,7 +2201,8 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] | signals.append("the listed location: " + str(prospect.get("address"))[:240]) if prospect.get("description") and str(prospect.get("description")).strip().lower() not in {category.lower(), "unknown"}: signals.append("the public listing description: " + str(prospect.get("description"))[:240]) - if not website or status in {"no_detected_website", "missing"}: + no_website = not website or status in {"no_detected_website", "missing"} + if no_website: primary = [ "No dedicated website is linked publicly for this business; frame that as an optional visibility opportunity, without implying the business is failing.", "The public record does not surface a dedicated web address; offer to make the business easier to find online, without treating that as a defect.", @@ -2208,16 +2260,28 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] | ] variant_index = variant_index % len(variant_options) variant = variant_options[variant_index] + location_label = _message_location_label(prospect) + if no_website: + if category and location_label: + shape = f"Preferred flow: greet {display_name}; say you came across the business while looking at local {category} in {location_label}; mention that a dedicated website was not apparent; give a short reason for writing; present the exact offer in natural prose; offer to prepare a quick example if the sender profile supports website design; sign off briefly and add a respectful opt-out." + else: + shape = "Preferred flow: greet the business; mention the missing dedicated website in a careful, non-judgemental way; give a short reason for writing; present the exact offer in natural prose; offer a quick example if supported; sign off briefly and add a respectful opt-out." + elif status in {"broken", "unreachable", "error", "timeout", "slow", "http_only", "redirect"}: + shape = "Preferred flow: lead with the measured website-check signal, explain the reason for writing in one sentence, present only the exact relevant offer, then use a low-pressure CTA and brief sign-off." + else: + shape = "Preferred flow: acknowledge the existing website without inventing a flaw, use one supplied category or location detail, present a genuinely optional review or improvement, and close without pressure." return { + "business_name_for_greeting": display_name, "primary_signal": primary, "available_personalisation": signals[:8], "variation_hint": variant, "greeting_hint": greeting_options[variant_index], "closing_hint": closing_options[variant_index], + "preferred_message_shape": shape, "specificity_rule": "Use one or two supplied facts naturally; if the record is sparse, be honest and keep the message short rather than filling the gap with generic praise.", "banned_generic_phrases": [ - "I hope you are well", "I came across your business", "I wanted to reach out", - "I am reaching out", "I noticed your business", "I saw your business", "help businesses like yours", "elevate your digital presence", + "I hope you are well", "I am reaching out", "I noticed your business", "I saw your business", + "help businesses like yours", "elevate your digital presence", ], } @@ -2256,8 +2320,6 @@ def _clean_generated_message(value: Any, business_name: str = "") -> str: # the email. leading_patterns = ( r"^\s*I\s+hope\s+(?:you(?:'re|\s+are)\s+(?:doing\s+)?well|this\s+email\s+finds\s+you\s+well)\s*[.!?,:-]*\s*", - r"^\s*I\s+wanted\s+to\s+reach\s+out(?:\s+to\s+you)?(?:\s+(?:because|as|since))?\s*[,.:;-]*\s*", - r"^\s*I\s+came\s+across\s+your\s+business(?:\s+and)?\s*[,.:;-]*\s*", r"^\s*I\s+noticed\s+your\s+business\s*[,.:;-]*\s*", r"^\s*I\s+saw\s+that\s*[,.:;-]*\s*", ) @@ -2291,6 +2353,7 @@ def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any] profile = company_profile_settings() profile_for_prompt = { "name": profile.get("name"), + "sender_name": os.environ.get("OWNER_NAME", "").strip()[:160], "website_url": profile.get("website_url"), "description": profile.get("description"), "services": profile.get("services"), @@ -2303,6 +2366,7 @@ def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any] evidence = { "evidence_ids": evidence_ids, "business_name": prospect.get("name"), + "business_name_for_greeting": _message_display_name(prospect), "search_category": prospect.get("search_category") or prospect.get("category"), "address_or_location": prospect.get("address"), "listing_description": prospect.get("description"), @@ -2386,19 +2450,24 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me "testimonial, urgency, authority, eligibility rule, or link. If the requested change needs an unsupported fact, " "leave that claim out and list the missing fact in unknowns. Do not mention scraping, scoring, hidden analysis, " "AI, or this instruction. Do not mention Google, Maps, directories, scraping, search results, or how the prospect " - "was found. Do not say you reviewed a website when none was found; if a scan is uncertain or " + "was found as a platform. A brief context such as looking at local in is allowed when " + "those exact fields are supplied; a specific 'I came across your business while looking at…' opening is acceptable " + "when it is followed by a real observation, but it must not become an empty repeated template. Do not say you " + "reviewed a website when none was found; if a scan is uncertain or " "failed, say the public check could not confirm it instead of calling the site broken. Do not insult the prospect. " - "Use the client_specific_strategy to choose one relevant angle, but treat it as private planning notes and do not " - "copy its wording verbatim. Do not force a missing-website pitch when the " - "record supports a different angle. Keep South African English and the business name exactly as supplied. Keep " - "the body plain text and usually 70-130 words. Use one or two natural paragraphs, one evidence-based " - "observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful " + "Use the client_specific_strategy and preferred_message_shape to choose one relevant angle, but treat them as private planning notes and do not copy their wording verbatim. Do not force a missing-website pitch when the " + "record supports a different angle. For a no-website prospect, lead with that gap and the category/location context " + "instead of rating, review count, phone number, or generic praise; never infer a solid reputation or claim a phone " + "number makes it easy to reach them. Keep South African English and the business name exactly as supplied in the " + "body; use business_name_for_greeting for a concise salutation when it removes an obvious appended location. Keep " + "the body plain text and usually 120-180 words. Use three or four natural paragraphs: a specific opening, a short " + "reason for writing, the exact offer in plain prose, and one low-pressure call to action. Add a brief respectful " "opt-out sentence when it fits naturally. Avoid feature catalogues, fake scarcity, all-caps, excessive emojis, " "and more than one exclamation mark. Keep the subject specific and natural (5-10 words). Vary the greeting, " "sentence rhythm, transition, and CTA using the variation hint. Do not use polished marketing filler such as " - "'elevate', 'seamless', 'unlock', 'digital presence', 'transform', or 'leverage'. Do not default to 'Good day', " - "'Dear', or 'Kind regards'; a brief 'Hi ,' or a direct opening is more natural, and the sender " - "can add their signature later. Preserve any exact " + "'elevate', 'seamless', 'unlock', 'digital presence', 'transform', or 'leverage'. Do not default to 'Good day' " + "or 'Dear'; a brief 'Hi ,' or a direct opening is more natural. Use the supplied sender name and " + "company name for a short sign-off only when present; do not invent a signature. Preserve any exact " "mockup preview URL supplied in the evidence; call it a concept/preview, never a live or finished site, and " "never imply approval. Before returning, silently compare the revised opening with three alternatives and keep the " "one that is most specific to this record and sounds natural when read aloud. Return ONLY strict JSON with exactly these keys: subject, message, rationale, " @@ -2434,7 +2503,7 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me return [] return [str(item).strip()[:size] for item in value if str(item).strip()][:limit] used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids] - message_text = _clean_generated_message(parsed["message"], prospect.get("name")) + message_text = _clean_generated_message(parsed["message"], _message_display_name(prospect)) mockup_url = str(mockup.get("share_url") or "").strip() if mockup else "" if mockup_url and mockup_url not in message_text: message_text = (message_text.rstrip() + "\n\nI also prepared a concept preview for you: " + mockup_url).strip()[:8_000] diff --git a/tests/test_app.py b/tests/test_app.py index 846f584..086bef5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -384,6 +384,20 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertIn("does not show a website", text) self.assertIn("Kind regards", text) + def test_message_strategy_uses_concise_greeting_and_missing_site_shape(self): + prospect = { + "id": 7, + "name": "Ash Electrical Cape Town", + "address": "Salt River, Cape Town", + "search_category": "Electricians", + "website_status": "no_detected_website", + } + self.assertEqual(server._message_display_name(prospect), "Ash Electrical") + strategy = server._message_strategy_context(prospect) + self.assertEqual(strategy["business_name_for_greeting"], "Ash Electrical") + self.assertIn("looking at local Electricians in Cape Town", strategy["preferred_message_shape"]) + self.assertIn("dedicated website", strategy["preferred_message_shape"]) + def test_email_draft_can_include_latest_mockup_link(self): conn = server.db() conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now())) diff --git a/web/app.js b/web/app.js index 8bf4916..9d481ee 100644 --- a/web/app.js +++ b/web/app.js @@ -71,7 +71,7 @@ async function load(){ } function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)} async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}} -function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'} +function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website; offer to prepare a quick example so they can see the direction.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'} function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}} function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`AI review notes

${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}

${unknowns.length?`

Confirm before sending: ${esc(unknowns.join(' · '))}

`:''}${used.length?`

Evidence used: ${esc(used.join(', '))}

`:''}${d?.follow_up?`

${esc(d.follow_up)}

`:''}`} function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>``).join('')} diff --git a/web/index.html b/web/index.html index 48cd35f..e66445c 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.

+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.