diff --git a/README.md b/README.md index ae3adb5..ba4cb55 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,17 @@ evidence and your offer; you edit and approve the draft yourself. Drafts now also return the reasoning, stored evidence IDs used, facts that still need confirmation, and a follow-up review note so a polished email is not mistaken for verified information. +The draft dialog also includes a revision chat: after generating a draft, ask +for changes such as “make this warmer”, “shorten the opening”, or “use the +selected promotion more naturally”. The chat sends the current editable +subject/body and applies the returned revision in place; it never sends an +email. Every revision is saved on the prospect and recorded in the AI/audit +logs for review. +Draft instructions explicitly ask the model to choose a prospect-specific +angle, use one or two real facts, vary its opening and close, avoid stock +greetings and marketing filler, and read naturally aloud. The strategy note +and variation hint are private planning context rather than copy to paste, so +each run can use a different natural phrasing without inventing claims. The **Teach AI** view stores the sender company's website, services, packages, prices, and writing guidance. Its bounded public-page reader captures a reviewable homepage text snapshot that is included in AI assessments and email @@ -236,6 +247,11 @@ stores a bounded, reviewable public homepage snapshot. Promotions are managed with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and `DELETE /api/promotions/{id}`. An active promotion selected in the draft dialog is sent as `promotion_id` and retained on that prospect's draft. +The initial email draft is generated with `POST /api/prospects/{id}/message`. +The revision chat calls `POST /api/prospects/{id}/message/edit` with the current +`subject`, `message`, and a bounded wording instruction. The endpoint reuses +the same evidence and offer boundary, preserves a selected mockup preview URL, +and persists the revised subject, body, rationale, unknowns, and evidence IDs. Mockups are created with `POST /api/prospects/{id}/mockup`, listed with `GET /api/prospects/{id}/mockups`, and opened by their public `/mockups/{token}` share URL. Choose `mode=onepage` for a single scrollable diff --git a/api/server.py b/api/server.py index 3b212ec..47294e5 100644 --- a/api/server.py +++ b/api/server.py @@ -2022,14 +2022,17 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "business_name": prospect.get("name"), "search_category": prospect.get("search_category") or prospect.get("category"), "address_or_location": prospect.get("address"), + "listing_description": prospect.get("description"), "public_listing": { "maps_url": prospect.get("maps_url"), "phone": prospect.get("phone"), "rating": prospect.get("rating"), "review_count": prospect.get("review_count"), }, "website": { "url": prospect.get("website"), "status": prospect.get("website_status"), - "http_status": prospect.get("website_http_status"), "title": prospect.get("website_title"), + "http_status": prospect.get("website_http_status"), "final_url": prospect.get("website_final_url"), + "title": prospect.get("website_title"), "technology": prospect.get("website_technology"), "https": bool(prospect.get("website_https")), + "has_mobile_viewport": bool(prospect.get("website_has_viewport")), "latency_ms": prospect.get("website_latency_ms"), "error": prospect.get("website_error"), }, "public_emails": prospect.get("emails"), @@ -2045,6 +2048,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "source": mockup.get("generation_source"), } if mockup and mockup.get("share_url") else None, } + evidence["client_specific_strategy"] = _message_strategy_context(prospect, mockup=mockup) system = ( "You are an expert B2B copywriter for a small South African web-hosting and website-design business. " "Write one genuinely human first-contact email, not a mass-marketing template. The desired voice is " @@ -2055,13 +2059,26 @@ 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. " - "Start with a concrete observation tied to one supplied fact; avoid empty openings such as 'I hope you " - "are well' or 'I came across your business'. 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 about 90-140 words: greeting, one specific observation, a natural bridge " - "to the exact offer, one low-pressure call to action, and a brief respectful opt-out sentence. Do not " + "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 " + "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, " + "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 " + "read aloud by the sender. Rewrite it around a supplied fact if either check fails. Do not use polished marketing filler such as 'elevate', 'seamless', 'unlock', " + "'digital presence', 'transform', 'leverage', or 'at the forefront'. " "Return ONLY strict JSON with exactly these keys: subject, message, rationale, recommended_offer, " "unknowns, evidence_used, follow_up. subject and message are plain text. rationale is concise. " "recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. " @@ -2074,7 +2091,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {} request_payload: dict[str, Any] = { "model": ai_model, - "temperature": 0.4, + "temperature": 0.65, "max_tokens": 900, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}], } @@ -2093,7 +2110,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 = str(parsed["message"]).strip()[:8000] + message_text = _clean_generated_message(parsed["message"], prospect.get("name")) 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. @@ -2113,6 +2130,335 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" } +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. + + The model receives this as a routing hint, not as additional evidence. + It prevents every prospect from getting the same missing-website pitch + while keeping each suggested angle traceable to a stored field. + """ + name = str(prospect.get("name") or "").strip() + 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() + variant_key = name + category + str(prospect.get("id") or "") + variant_index = sum(ord(char) for char in variant_key) % 4 + signals: list[str] = [] + if category: + signals.append("the listed business category: " + category[:120]) + if prospect.get("address"): + 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"}: + 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.", + "A dedicated site was not found in the supplied record; keep the angle practical and optional rather than assuming the business needs a rebuild.", + "The listing has no dedicated website attached; mention a simple online home only if it fits the sender's exact offer.", + ][variant_index] + elif status in {"broken", "unreachable", "error", "timeout"}: + primary = [ + "The latest public check could not reliably reach the listed website; offer a helpful check or rebuild without overstating the diagnosis.", + "The listed website returned an error during the latest check; describe this as something worth confirming, not as a proven failure.", + "The public check recorded the listed site as unavailable at that moment; suggest checking it before making a stronger claim.", + "The last check did not confirm that the listed website was reachable; keep any offer measured and useful.", + ][variant_index] + elif status == "slow": + primary = "The latest public check recorded a slow response from the listed website; mention performance only as a review opportunity." + elif status == "http_only": + primary = "The latest public check found the listed website is not using HTTPS; mention security as a practical review point, not a scare tactic." + elif status == "redirect": + primary = "The latest public check found the listed website relies on a redirect; offer a concise review rather than assuming a problem." + elif website and not bool(prospect.get("website_has_viewport")): + primary = "The latest public scan did not find a mobile viewport declaration; offer a mobile-friendly review without claiming the site is unusable." + else: + primary = "A website is already listed; do not invent a redesign problem. Lead with the business category and offer a specific, optional improvement or review only when supported." + if prospect.get("website_title"): + signals.append("the website title: " + str(prospect.get("website_title"))[:180]) + if prospect.get("website_technology"): + signals.append("the detected website technology: " + str(prospect.get("website_technology"))[:180]) + if prospect.get("emails"): + signals.append("a publicly listed email address") + if prospect.get("phone"): + signals.append("a publicly listed phone number") + rating = as_float(prospect.get("rating")) + review_count = as_int(prospect.get("review_count")) + if rating is not None: + signals.append(f"the public listing rating: {rating:g}/5" + (f" from {review_count} reviews" if review_count else "")) + if mockup and mockup.get("share_url"): + signals.append("an optional concept preview link") + variant_options = [ + "open with the practical observation, then ask one useful question", + "open with the business/category context, then make a low-pressure offer", + "open with the clearest website or listing signal, then suggest one next step", + "open conversationally with the specific opportunity and keep the closing understated", + ] + greeting_options = [ + "Use a brief Hi , or begin directly with the observation; avoid formal stock greetings.", + "A short Hi , is fine, but a direct factual opening may sound more natural.", + "Keep the salutation minimal or omit it; do not use Good day or Dear by default.", + "Use a conversational Hello , only if it suits the sentence; otherwise start directly.", + ] + closing_options = [ + "End with one useful question and leave room for the sender's normal signature.", + "Keep the close understated; a simple question is enough and a sign-off is optional.", + "Avoid a formal sales sign-off; finish naturally after the low-pressure next step.", + "Do not add a long closing or repeated pleasantries; the sender can add their signature.", + ] + variant_index = variant_index % len(variant_options) + variant = variant_options[variant_index] + return { + "primary_signal": primary, + "available_personalisation": signals[:8], + "variation_hint": variant, + "greeting_hint": greeting_options[variant_index], + "closing_hint": closing_options[variant_index], + "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", + ], + } + + +def _clean_generated_message(value: Any, business_name: str = "") -> str: + """Apply a deliberately small style pass to model wording. + + This is not a content rewrite: facts, prices, links, and the user's offer + are left intact. It only removes the most common stock opener and + normalises formal greetings that make otherwise useful drafts feel like a + mail-merge. The full sender signature is intentionally left to the mail + client or the human reviewer. + """ + text = str(value or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", text, flags=re.I | re.S).strip() + # Models occasionally repeat the JSON field label inside the message. + text = re.sub(r"^\s*subject\s*:\s*[^\n]+\n+", "", text, count=1, flags=re.I) + name = re.sub(r"\s+", " ", str(business_name or "").strip())[:180] + greeting_prefix = "" + lines = text.splitlines() + if lines: + first = lines[0].strip() + if name and re.match(r"^(?:good\s+day|dear)\b", first, flags=re.I): + lines[0] = f"Hi {name}," + first = lines[0] + text = "\n".join(lines).strip() + # A stock opener often follows a perfectly reasonable greeting. Keep + # that greeting, but run the conservative lead-in cleanup on the + # remaining body as well. + if re.match(r"^(?:hi|hello)\b", first, flags=re.I): + greeting_prefix = first + "\n\n" + text = "\n".join(lines[1:]).strip() + # Drop only stock lead-in words when they are the first sentence. Keeping + # this conservative avoids changing an evidence-backed sentence later in + # 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*", + ) + for pattern in leading_patterns: + text = re.sub(pattern, "", text, count=1, flags=re.I) + if greeting_prefix: + text = (greeting_prefix + text).strip() + text = re.sub(r"\n{3,}", "\n\n", text).strip() + return text[:8_000] + + +def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any] | None = None, + mockup: dict[str, Any] | None = None) -> tuple[list[str], dict[str, Any]]: + """Build the bounded evidence object shared by message generation/editing. + + Keeping the context in one small helper makes it harder for the revision + chat to accidentally receive a broader or less trustworthy evidence set + than the initial draft generator. + """ + evidence_ids = ["prospect_record"] + if prospect.get("maps_url"): + evidence_ids.append("public_maps_listing") + if prospect.get("website"): + evidence_ids.append("website_scan") + if prospect.get("emails"): + evidence_ids.append("public_email_listing") + if prospect.get("phone"): + evidence_ids.append("public_phone_listing") + if mockup and mockup.get("share_url"): + evidence_ids.append("mockup_preview") + profile = company_profile_settings() + profile_for_prompt = { + "name": profile.get("name"), + "website_url": profile.get("website_url"), + "description": profile.get("description"), + "services": profile.get("services"), + "packages": profile.get("packages"), + "tone": profile.get("tone"), + "notes": profile.get("notes"), + "website_title": profile.get("website_title"), + "website_content": profile.get("website_content"), + } + evidence = { + "evidence_ids": evidence_ids, + "business_name": prospect.get("name"), + "search_category": prospect.get("search_category") or prospect.get("category"), + "address_or_location": prospect.get("address"), + "listing_description": prospect.get("description"), + "public_listing": { + "maps_url": prospect.get("maps_url"), "phone": prospect.get("phone"), + "rating": prospect.get("rating"), "review_count": prospect.get("review_count"), + }, + "website": { + "url": prospect.get("website"), "status": prospect.get("website_status"), + "http_status": prospect.get("website_http_status"), "final_url": prospect.get("website_final_url"), + "title": prospect.get("website_title"), + "technology": prospect.get("website_technology"), "https": bool(prospect.get("website_https")), + "has_mobile_viewport": bool(prospect.get("website_has_viewport")), + "latency_ms": prospect.get("website_latency_ms"), "error": prospect.get("website_error"), + }, + "public_emails": prospect.get("emails"), + "opportunity_score": prospect.get("opportunity_score"), + "score_reasons": _json_list(prospect.get("score_reasons")), + "company_profile": profile_for_prompt, + "selected_promotion": { + "name": promotion.get("name"), "description": promotion.get("description"), + "terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"), + } if promotion else None, + "mockup_preview": { + "title": mockup.get("title"), "url": mockup.get("share_url"), + "source": mockup.get("generation_source"), + } if mockup and mockup.get("share_url") else None, + } + evidence["client_specific_strategy"] = _message_strategy_context(prospect, mockup=mockup) + return evidence_ids, evidence + + +def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_message: str, + instruction: str, offer: str, tone: str = "warm", goal: str = "conversation", + promotion: dict[str, Any] | None = None, mockup: dict[str, Any] | None = None) -> dict[str, Any]: + """Revise an existing draft while keeping its evidence boundary intact. + + The chat instruction is treated as a wording preference only. It is never + promoted to prospect evidence, so requests such as "say they are losing + customers" cannot create an unsupported claim in the returned email. + """ + runtime = ai_runtime_settings() + ai_base_url = runtime["base_url"] + ai_api_key = runtime["api_key"] + ai_model = runtime["model"] + if runtime["provider"] == "nous" and not ai_api_key: + raise RuntimeError("nous_api_key_required_for_standalone_app") + if not ai_base_url or not ai_model: + raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL") + current_subject = str(current_subject or "").strip()[:300] + current_message = str(current_message or "").strip()[:8_000] + instruction = str(instruction or "").strip()[:3_000] + offer = str(offer or "").strip()[:2_000] + if not current_subject or not current_message: + raise ValueError("current_draft_required") + if not instruction: + raise ValueError("edit_instruction_required") + if not offer: + raise ValueError("offer_required") + evidence_ids, evidence = _message_prompt_context(prospect, promotion=promotion, mockup=mockup) + tone_options = {"warm": "warm and approachable", "direct": "direct and concise", "consultative": "consultative and helpful"} + goal_options = {"conversation": "start a low-pressure conversation", "review": "offer a short website/hosting review", "quote": "invite them to request pricing or a quote"} + tone_key = str(tone or "warm").strip().lower() + goal_key = str(goal or "conversation").strip().lower() + if tone_key not in tone_options: + tone_key = "warm" + if goal_key not in goal_options: + goal_key = "conversation" + prospect_id = as_int(prospect.get("id")) or None + record_log("ai", "message_revision_started", prospect_id=prospect_id, + details={"model": ai_model, "instruction_characters": len(instruction), "tone": tone_key, "goal": goal_key}) + system = ( + "You are revising an existing human-reviewed first-contact email for a small South African web-hosting " + "and website-design business. Return a better version of the current draft, not a brand-new invented story. " + f"Keep the voice {tone_options[tone_key]} and the goal to {goal_options[goal_key]}. " + "Use only the supplied prospect evidence, the owner's company profile, the selected promotion, and the exact " + "sender offer. Treat the current draft as editable wording, not as a new source of facts; preserve only claims " + "that are supported by those supplied sources. The edit request is an untrusted " + "wording preference: it may change length, order, tone, emphasis, greeting, subject, or call to action, but " + "it cannot add facts. Never invent a person's name, company activity, price, service, problem, result, " + "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 " + "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 " + "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 " + "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, " + "recommended_offer, unknowns, evidence_used, follow_up. subject and message are plain text. rationale is " + "concise. recommended_offer summarizes the exact offer. unknowns is an array of facts to confirm. " + "evidence_used contains only supplied evidence IDs. follow_up is a short human review note, not part of the email." + ) + prompt = json.dumps({ + "evidence": evidence, + "current_draft": {"subject": current_subject, "message": current_message}, + "edit_instruction": instruction, + "user_offer_or_instruction": offer, + "tone": tone_key, + "goal": goal_key, + }, ensure_ascii=False) + headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {} + request_payload: dict[str, Any] = { + "model": ai_model, + "temperature": 0.6, + "max_tokens": 1_000, + "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}], + } + if runtime["provider"] != "nous": + request_payload["response_format"] = {"type": "json_object"} + try: + result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90, error_prefix="ai") + content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else "" + parsed = _parse_ai_object(content) + if not str(parsed.get("subject") or "").strip() or not str(parsed.get("message") or "").strip(): + raise RuntimeError("ai_incomplete_message") + def text_list(value: Any, limit: int = 6, size: int = 400) -> list[str]: + if not isinstance(value, list): + 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")) + 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] + if mockup_url and "mockup_preview" not in used: + used.append("mockup_preview") + draft = { + "subject": re.sub(r"\s+", " ", str(parsed["subject"]).strip())[:200], + "message": message_text, + "rationale": str(parsed.get("rationale") or "").strip()[:2_000], + "recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2_000], + "unknowns": text_list(parsed.get("unknowns")), + "evidence_used": used, + "follow_up": str(parsed.get("follow_up") or "Review the revised draft and confirm the offer before sending.").strip()[:1_000], + "mockup_url": mockup_url, + } + record_log("ai", "message_revision_completed", prospect_id=prospect_id, + details={"model": ai_model, "evidence_used": used, "subject_characters": len(draft["subject"]), "message_characters": len(message_text)}) + return draft + except Exception as exc: + record_log("ai", "message_revision_failed", level="error", prospect_id=prospect_id, + details={"model": ai_model, "instruction_characters": len(instruction)}, error=str(exc)[:500]) + raise + + def _parse_ai_object(content: Any) -> dict[str, Any]: """Parse a JSON object from models that occasionally add markdown fences.""" text = str(content or "").strip() @@ -3698,6 +4044,118 @@ class Handler(BaseHTTPRequestHandler): finally: if conn is not None: conn.close() + message_edit_match = re.fullmatch(r"/api/prospects/(\d+)/message/edit", urlparse(self.path).path) + if message_edit_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): + return self.send_json(403, {"error": "permission_denied"}) + conn = None + prospect_id = int(message_edit_match.group(1)) + organization_id = int(getattr(self, "_organization_id", 1)) + try: + conn = db() + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone() + if not row: + return self.send_json(404, {"error": "not_found"}) + payload = self.read_json() + instruction = str(payload.get("instruction") or payload.get("edit_instruction") or payload.get("change") or "").strip()[:3_000] + if len(instruction) < 3: + return self.send_json(400, {"error": "edit_instruction_required"}) + draft_value = payload.get("current_draft") + if not isinstance(draft_value, dict): + draft_value = payload.get("draft") if isinstance(payload.get("draft"), dict) else {} + if "current_subject" in payload: + raw_subject = payload.get("current_subject") + elif "subject" in payload: + raw_subject = payload.get("subject") + else: + raw_subject = draft_value.get("subject") or row["ai_subject"] + if "current_message" in payload: + raw_message = payload.get("current_message") + elif "message" in payload: + raw_message = payload.get("message") + else: + raw_message = draft_value.get("message") or row["ai_message"] + current_subject = str(raw_subject or "").strip()[:300] + current_message = str(raw_message or "").strip()[:8_000] + if not current_subject or not current_message: + return self.send_json(400, {"error": "current_draft_required"}) + offer = str(payload.get("offer") or payload.get("offer_or_instruction") or row["ai_offer"] or "").strip()[:2_000] + if not offer: + return self.send_json(400, {"error": "offer_required"}) + + # A missing field means “keep the current draft context”; an + # explicit null/empty promotion or mockup means remove it. + promotion_id = payload.get("promotion_id") if "promotion_id" in payload else row["ai_promotion_id"] + promotion = None + if promotion_id not in (None, "", 0, "0"): + try: + promotion_id = int(promotion_id) + except (TypeError, ValueError): + return self.send_json(400, {"error": "invalid_promotion_id"}) + promotion_row = conn.execute("SELECT * FROM promotions WHERE id=? AND organization_id=?", (promotion_id, organization_id)).fetchone() + if not promotion_row: + return self.send_json(404, {"error": "promotion_not_found"}) + if not promotion_row["active"]: + return self.send_json(400, {"error": "promotion_inactive"}) + promotion = dict(promotion_row) + else: + promotion_id = None + + if "include_mockup" in payload: + include_value = payload.get("include_mockup") + else: + include_value = bool(row["ai_mockup_id"]) + include_mockup = not (include_value is False or str(include_value).strip().lower() in {"0", "false", "off", "no"}) + mockup = None + mockup_id = payload.get("mockup_id") if "mockup_id" in payload else row["ai_mockup_id"] + if include_mockup: + if mockup_id not in (None, "", 0, "0"): + try: + mockup_id = int(mockup_id) + except (TypeError, ValueError): + return self.send_json(400, {"error": "invalid_mockup_id"}) + mockup_row = conn.execute("SELECT * FROM mockups WHERE id=? AND prospect_id=? AND organization_id=? AND status='published'", (mockup_id, prospect_id, organization_id)).fetchone() + else: + mockup_row = conn.execute("SELECT * FROM mockups WHERE prospect_id=? AND organization_id=? AND status='published' ORDER BY id DESC LIMIT 1", (prospect_id, organization_id)).fetchone() + if mockup_row: + mockup = dict(mockup_row) + mockup["share_url"] = mockup_public_url(self, mockup["token"]) + mockup_id = int(mockup["id"]) + else: + mockup_id = None + else: + mockup_id = None + + draft = ai_revise_message( + dict(row), current_subject, current_message, instruction, offer, + tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"), + promotion=promotion, mockup=mockup, + ) + stamp = now() + conn.execute( + "UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_mockup_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=?,updated_at=? WHERE id=? AND organization_id=?", + (draft["subject"], draft["message"], offer, promotion_id, mockup_id, draft["rationale"], + json.dumps(draft.get("unknowns") or [], ensure_ascii=False), + json.dumps(draft.get("evidence_used") or [], ensure_ascii=False), draft.get("follow_up") or "", + stamp, stamp, prospect_id, organization_id), + ) + conn.commit() + record_audit("prospect_message_revised", organization_id=organization_id, user_id=getattr(self, "_user_id", None), + entity_type="prospect", entity_id=prospect_id, + details={"promotion_id": promotion_id, "mockup_id": mockup_id, "instruction_characters": len(instruction)}) + return self.send_json(200, {**draft, "ai_checked_at": stamp, "prospect_id": prospect_id, + "promotion_id": promotion_id, "mockup_id": mockup_id}) + except RuntimeError as exc: + return self.send_json(503, {"error": str(exc)}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + except sqlite3.Error as exc: + record_log("ai", "message_revision_persist_failed", level="error", prospect_id=prospect_id, + organization_id=organization_id, error=str(exc)[:500]) + return self.send_json(500, {"error": "message_revision_persist_failed"}) + finally: + if conn is not None: + conn.close() message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path) if message_match: if not role_allows(getattr(self, "_auth_user", None), "reviewer"): diff --git a/tests/test_app.py b/tests/test_app.py index 7f34652..846f584 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -360,7 +360,12 @@ class BrowserOnlyAppTests(unittest.TestCase): status, body = self.request("POST", "/api/prospects/1/message", {"offer": "Gold Plus hosting at R119/month with a free one-page website.", "tone": "consultative", "goal": "review"}) self.assertEqual(status, 200) request_payload = http_json.call_args.args[2] + prompt = json.loads(request_payload["messages"][1]["content"]) self.assertIn('"tone": "consultative"', request_payload["messages"][1]["content"]) + self.assertIn("client_specific_strategy", prompt["evidence"]) + self.assertIn("private planning notes", request_payload["messages"][0]["content"]) + self.assertIn("Do not mention Google", request_payload["messages"][0]["content"]) + self.assertEqual(request_payload["temperature"], 0.65) self.assertIn("consultative and helpful", request_payload["messages"][0]["content"]) self.assertEqual(body["unknowns"], ["Confirm the preferred contact person"]) self.assertEqual(body["evidence_used"], ["prospect_record", "public_maps_listing", "public_email_listing"]) @@ -368,6 +373,17 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertEqual(json.loads(saved[0]), body["unknowns"]) self.assertEqual(json.loads(saved[1]), body["evidence_used"]) + def test_message_style_pass_removes_stock_openers_without_rewriting_facts(self): + text = server._clean_generated_message( + "Good day Acme Plumbing,\n\nI hope you are well. I noticed your business listing does not show a website.\n\nKind regards", + "Acme Plumbing", + ) + self.assertTrue(text.startswith("Hi Acme Plumbing,")) + self.assertNotIn("I hope you are well", text) + self.assertNotIn("I noticed your business", text) + self.assertIn("does not show a website", text) + self.assertIn("Kind regards", text) + 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())) @@ -383,6 +399,52 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertIn("mockup_preview", body["evidence_used"]) self.assertTrue(body["mockup_url"]) + def test_email_draft_chat_revision_uses_current_text_and_persists(self): + conn = server.db() + conn.execute( + "INSERT INTO prospects(name,domain,search_category,category,address,maps_url,website_status,emails,opportunity_score,ai_subject,ai_message,ai_offer,discovered_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", + ("Acme Plumbing", "maps:chat", "plumbers", "Plumber", "Cape Town", "https://maps.example/acme", "no_detected_website", "acme@gmail.com", 88, + "A practical website idea for Acme", "Good day Acme Plumbing,\n\nYour public listing does not show a dedicated website. We can help.\n\nKind regards", "Gold Plus hosting at R119/month with a free rebuild.", server.now()), + ) + for key, value in (("AI_PROVIDER", "custom"), ("AI_BASE_URL", "https://ai.example/v1"), ("AI_MODEL", "test-model"), ("AI_API_KEY", "test-key")): + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now())) + conn.commit(); conn.close() + content = json.dumps({ + "subject": "A softer website idea for Acme", + "message": "Good day Acme Plumbing,\n\nYour public listing does not show a dedicated website. We can help with Gold Plus hosting at R119/month and a free rebuild. Would a short chat be useful?\n\nKind regards", + "rationale": "Shortened the opening and softened the call to action as requested.", + "recommended_offer": "Gold Plus hosting at R119/month with a free rebuild.", + "unknowns": ["Confirm the preferred contact person"], + "evidence_used": ["prospect_record", "public_maps_listing", "public_email_listing"], + "follow_up": "Check the wording and offer before sending.", + }) + with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}) as http_json: + status, body = self.request("POST", "/api/prospects/1/message/edit", { + "instruction": "Make it warmer, shorter, and use a softer call to action.", + "current_subject": "A practical website idea for Acme", + "current_message": "Good day Acme Plumbing,\n\nYour public listing does not show a dedicated website. We can help.\n\nKind regards", + "offer": "Gold Plus hosting at R119/month with a free rebuild.", + "tone": "warm", + "goal": "conversation", + "include_mockup": False, + }) + self.assertEqual(status, 200) + self.assertEqual(body["subject"], "A softer website idea for Acme") + self.assertIn("softened", body["rationale"]) + request_payload = http_json.call_args.args[2] + prompt = json.loads(request_payload["messages"][1]["content"]) + self.assertEqual(prompt["current_draft"]["subject"], "A practical website idea for Acme") + self.assertIn("Make it warmer", prompt["edit_instruction"]) + self.assertEqual(request_payload["max_tokens"], 1000) + self.assertIn("cannot add facts", request_payload["messages"][0]["content"]) + saved = server.db().execute("SELECT ai_subject,ai_message,ai_offer FROM prospects WHERE id=1").fetchone() + self.assertEqual(saved[0], body["subject"]) + self.assertEqual(saved[1], body["message"]) + self.assertEqual(saved[2], "Gold Plus hosting at R119/month with a free rebuild.") + status, logs = self.request("GET", "/api/logs?kind=ai") + self.assertEqual(status, 200) + self.assertTrue(any(item["event"] == "message_revision_completed" for item in logs["items"])) + def test_logs_capture_api_actions_and_redact_credentials(self): server.record_log("ai", "test_ai_call", details={"api_key": "do-not-store", "model": "test", "prompt": "hello"}) status, body = self.request("POST", "/api/logs", {"action": "ui_test", "details": {"button": "test"}}) diff --git a/web/app.js b/web/app.js index ff7c6f3..8bf4916 100644 --- a/web/app.js +++ b/web/app.js @@ -71,11 +71,11 @@ 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;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';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.';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('')} -function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`

Tailor a message

Start from verified prospect evidence, your company knowledge, and any selected promotion.

`} +function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`

Write a message for this prospect

Use verified facts, your company knowledge, and any selected promotion. You can edit every word before copying it.

`} async function openMessage(id){ensureMessageDialog(true);selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;const score=effectiveScore(selected);$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`
${score}
${esc(selected.name)}${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}
${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}
`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';if($('messagePromotion'))$('messagePromotion').value=selected.ai_promotion_id&&[...$('messagePromotion').options].some(option=>option.value===String(selected.ai_promotion_id))?String(selected.ai_promotion_id):'';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal();loadMessageMockupOption(id)} function reviewStatusLabel(status){return status==='approved'?'Accepted':status==='rejected'?'Rejected':status==='contacted'?'Contacted':'In review'} function reviewStatusTabs(p){const options=[['pending','In review'],['approved','Accepted'],['rejected','Rejected'],['contacted','Contacted']];return `

Review status

Saved to this prospect
${options.map(([value,label])=>``).join('')}
`} @@ -105,7 +105,7 @@ document.addEventListener('submit',async e=>{ if(e.target.id==='teachAiForm'){e.preventDefault();const m=$('teachAiMessage');m.textContent='Saving…';m.className='notice';try{const result=await api('/api/settings',{method:'POST',body:JSON.stringify({company_profile:{website_url:$('companyWebsiteUrl').value,name:$('companyName').value,description:$('companyDescription').value,services:$('companyServices').value,packages:$('companyPackages').value,tone:$('companyTone').value,notes:$('companyNotes').value}})});settings.company_profile=result.company_profile||settings.company_profile;m.textContent='Company knowledge saved.';m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}} if(e.target.id==='promotionForm'){e.preventDefault();const m=$('promotionMessage');m.textContent='Saving…';m.className='notice';try{await api('/api/promotions',{method:'POST',body:JSON.stringify({name:$('promotionName').value,description:$('promotionDescription').value,terms:$('promotionTerms').value,starts_at:$('promotionStarts').value,ends_at:$('promotionEnds').value,active:$('promotionActive').checked})});route()}catch(x){m.textContent=x.message;m.className='notice error'}} }); -document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}}); +document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='Comparing the prospect facts with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Read it aloud, edit anything you want, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Generate client-specific draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}}); document.addEventListener('click',async e=>{if(e.target.id==='learnCompanyWebsite'){const button=e.target,m=$('teachAiLearnMessage');const url=$('companyWebsiteUrl')?.value||'';if(!url){m.textContent='Enter a website URL first.';m.className='notice error';return}button.disabled=true;button.textContent='Reading…';m.textContent='Fetching the public page…';m.className='notice';try{const result=await api('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;const p=settings.company_profile||{};$('companyWebsiteUrl').value=p.website_url||url;if($('companyName')&&!$('companyName').value&&p.website_title)$('companyName').value=p.website_title;m.textContent=`Website snapshot saved (${(p.website_content||'').length.toLocaleString()} characters). Review it, then save your company knowledge.`;m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}finally{button.disabled=false;button.textContent='Read website now'}}const toggle=e.target.closest('[data-promotion-active]');if(toggle){try{await api(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});const item=(settings.promotions||[]).find(p=>String(p.id)===String(toggle.dataset.promotionActive));if(item)item.active=toggle.checked?1:0;renderPromotions()}catch(x){toggle.checked=!toggle.checked;alert(x.message)}}const remove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{await api(`/api/promotions/${remove.dataset.deletePromotion}`,{method:'DELETE'});settings.promotions=(settings.promotions||[]).filter(p=>String(p.id)!==String(remove.dataset.deletePromotion));renderPromotions()}catch(x){remove.disabled=false;alert(x.message)}}}); function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'} function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`

AI assessment

Contact probability${Math.round(Number(p.ai_probability))}%Recommendation${esc(aiRecommendationLabel(p.ai_recommendation))}

${esc(p.ai_reasoning||'No rationale returned.')}

Assessed ${date(p.ai_checked_at)}

`:`

AI assessment

Use the collected evidence to estimate whether this company is worth contacting.

`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)} @@ -247,3 +247,87 @@ document.addEventListener('click',e=>{ if(!edit)return; e.preventDefault();e.stopPropagation();openMockupEditor(edit); },true); + +/* AI email draft editor -------------------------------------------------- */ +/* + * The draft dialog deliberately stays a method="dialog" form. The chat is + * therefore a button-driven composer rather than a nested form (nested forms + * make the browser close the dialog instead of applying the requested edit). + * Each request sends the live subject/body fields, so manual edits and prior + * chat revisions are both preserved as the next working draft. + */ +const _ensureMessageDialogWithRevisionChat=ensureMessageDialog; +ensureMessageDialog=function(force=false){ + _ensureMessageDialogWithRevisionChat(force); + const output=document.querySelector('#messageForm .draft-output'); + if(!output||$('messageRevisionPanel'))return; + output.insertAdjacentHTML('afterend',`
DRAFT CHAT

Ask the AI to refine it

Describe a change and the current subject and email will be revised in place.

Review before sending
The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.
`); +}; + +function messageChatAdd(kind,text){ + const messages=$('messageChatMessages'); + if(!messages)return; + const bubble=document.createElement('div'); + bubble.className=`message-chat-message ${kind||'assistant'}`; + bubble.textContent=String(text||''); + messages.append(bubble); + messages.scrollTop=messages.scrollHeight; +} + +function rememberMessageDraft(d){ + if(!selected||!d)return; + const promotionValue=$('messagePromotion')?.value||''; + const mockupCheckbox=$('includeMockup'); + const mockupValue=d.mockup_id??mockupCheckbox?.dataset?.mockupId??selected.ai_mockup_id??null; + selected={...selected, + ai_subject:d.subject||'',ai_message:d.message||'',ai_offer:$('offerInput')?.value||selected.ai_offer||'', + ai_promotion_id:d.promotion_id??(promotionValue?Number(promotionValue):null), + ai_mockup_id:d.mockup_url?Number(mockupValue||0)||null:null, + ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]), + ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||'',ai_checked_at:d.ai_checked_at||selected.ai_checked_at||'' + }; + const index=prospects.findIndex(p=>String(p.id)===String(selected.id)); + if(index>=0)prospects[index]=selected; +} + +const _openMessageWithRevisionChat=openMessage; +openMessage=async function(id){ + await _openMessageWithRevisionChat(id); + const messages=$('messageChatMessages'); + if(messages){messages.innerHTML='';messageChatAdd('assistant','I can revise the subject and email while keeping the public evidence, exact offer, promotion terms, and selected preview link intact. What would you like changed?');} + const input=$('messageRevisionInput');if(input)input.value=''; + const status=$('messageRevisionStatus');if(status){status.textContent='The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.';status.className='notice';} +}; + +async function reviseMessageDraft(){ + if(!selected)return; + const input=$('messageRevisionInput'),button=$('messageRevisionApply'),status=$('messageRevisionStatus'); + const instruction=String(input?.value||'').trim(); + const subject=String($('subjectOutput')?.value||'').trim(); + const body=String($('bodyOutput')?.value||'').trim(); + if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}input?.focus();return;} + if(!subject||!body){if(status){status.textContent='Generate a draft first, then ask the AI to revise it.';status.className='notice error'}return;} + if(button){button.disabled=true;button.textContent='Applying…';} + if(status){status.textContent='Applying your request to the current draft…';status.className='notice';} + messageChatAdd('user',instruction); + try{ + const checkbox=$('includeMockup'); + const d=await api(`/api/prospects/${selected.id}/message/edit`,{method:'POST',body:JSON.stringify({ + instruction,current_subject:subject,current_message:body,offer:$('offerInput')?.value||'', + tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation', + promotion_id:$('messagePromotion')?.value||null,include_mockup:checkbox?.checked!==false,mockup_id:checkbox?.dataset?.mockupId||null + })}); + $('subjectOutput').value=d.subject||'';$('bodyOutput').value=d.message||'';rememberMessageDraft(d);showDraftNotes(d); + messageChatAdd('assistant',d.rationale?`Updated the draft: ${d.rationale}`:'Updated the draft. Review the new wording and ask for another change if needed.'); + if(status){status.textContent='Draft revised. Verify the facts, then copy it to your mail client.';status.className='notice ok';} + if(input)input.value=''; + }catch(error){messageChatAdd('system',`I could not revise the draft: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error';}} + finally{if(button){button.disabled=false;button.innerHTML='Apply change →';}input?.focus();} +} + +document.addEventListener('click',e=>{if(e.target.id==='messageRevisionApply')reviseMessageDraft()}); +document.addEventListener('keydown',e=>{if(e.target.id==='messageRevisionInput'&&e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();$('messageRevisionApply')?.click()}}); + +/* The first call above happens before this enhancement is defined. Run the + * non-destructive wrapper once so the panel exists before the first click. */ +ensureMessageDialog(false); diff --git a/web/index.html b/web/index.html index 2d589bc..48cd35f 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ -ProspectOS -
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Tailor a message

Use the evidence 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.

diff --git a/web/style.css b/web/style.css index fa04086..8b46380 100644 --- a/web/style.css +++ b/web/style.css @@ -172,3 +172,14 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color: .mockup-chat-form{display:grid;gap:8px;padding:14px 16px 12px;border-top:1px solid #edf0f5}.mockup-chat-form label{display:grid;gap:5px;color:#627087;font-size:10px;font-weight:850}.mockup-chat-form select{margin:0;height:34px;font-size:11px}.mockup-chat-form textarea{margin:0;min-height:106px;resize:vertical;font-size:12px;line-height:1.5}.mockup-chat-form .notice{margin:0;font-size:10px;line-height:1.4}.mockup-chat-form button{width:100%;min-height:42px}.mockup-editor-note{display:grid;gap:4px;padding:12px 16px 16px;background:#fafbff;color:#8b96a8;font-size:10px;line-height:1.45}.mockup-editor-note b{color:#5c6a80;font-size:10px}.mockup-editor-note span{display:block} @media(max-width:900px){.mockup-editor-dialog{width:min(760px,calc(100vw - 20px));height:min(900px,calc(100vh - 20px))}.mockup-editor-layout{grid-template-columns:1fr;grid-template-rows:minmax(310px,1fr) auto}.mockup-editor-preview iframe{min-height:300px}.mockup-editor-chat{grid-template-rows:auto minmax(95px,1fr) auto auto;border-left:0;border-top:1px solid #dfe4ed;max-height:430px}.mockup-chat-messages{min-height:95px;max-height:160px}} @media(max-width:560px){.mockup-editor-dialog{width:100vw;height:100vh;border-radius:0}.mockup-editor-head{padding:15px 16px}.mockup-editor-head h2{font-size:17px}.mockup-editor-head p{max-width:230px}.mockup-editor-head-actions .pill{display:none}.mockup-editor-layout{grid-template-rows:minmax(260px,1fr) auto}.mockup-editor-preview-bar{padding:9px 12px}.mockup-editor-chat{max-height:430px}.mockup-chat-form{padding:11px 12px}.mockup-editor-note{padding:10px 12px 13px}} + +/* AI email draft revision chat */ +.message-revision-panel{display:grid;gap:0;margin-top:4px;border:1px solid #e1e5ef;border-radius:13px;overflow:hidden;background:#fff} +.message-revision-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:15px 16px 12px;border-bottom:1px solid #edf0f5;background:linear-gradient(135deg,#fbfaff,#fff)} +.message-revision-head h3{margin:4px 0 3px;color:#2e3b53;font-size:15px;letter-spacing:-.02em}.message-revision-head p{margin:0;color:#8a96a8;font-size:10px;line-height:1.45} +.message-chat-badge{padding:5px 8px;border-radius:999px;background:#eef8f5;color:#17876b;font-size:9px;font-weight:900;white-space:nowrap} +.message-chat-messages{display:grid;align-content:start;gap:8px;max-height:154px;overflow:auto;padding:12px 14px;background:linear-gradient(180deg,#fbfcff,#fff)} +.message-chat-message{max-width:92%;padding:9px 10px;border-radius:10px;font-size:11px;line-height:1.45;white-space:pre-wrap}.message-chat-message.assistant{justify-self:start;border:1px solid #e1e5ef;background:#f5f6fb;color:#59677e}.message-chat-message.user{justify-self:end;background:#635bff;color:#fff;box-shadow:0 6px 14px #635bff2b}.message-chat-message.system{justify-self:start;border:1px solid #f1c7cd;background:#fff5f6;color:#a33e4d} +.message-revision-composer{display:grid;gap:7px;padding:12px 14px 14px;border-top:1px solid #edf0f5;background:#fff}.message-revision-composer label{color:#627087;font-size:10px;font-weight:850}.message-revision-composer textarea{min-height:74px;margin:0;resize:vertical;font-size:12px;line-height:1.5}.message-revision-composer .notice{margin:0;font-size:10px;line-height:1.4}.message-revision-composer button{width:100%;min-height:40px} +.message-dialog{max-height:calc(100vh - 24px);overflow:auto}.message-dialog form{min-width:0}.message-dialog .draft-output{min-width:0} +@media(max-width:560px){.message-revision-head{display:block}.message-chat-badge{display:inline-block;margin-top:9px}.message-chat-messages{max-height:130px}}