deploy ProspectOS workspace v7 final

This commit is contained in:
Marco0300
2026-09-08 09:55:58 +02:00
parent 181b83fc5e
commit b9ef1e4dc7
6 changed files with 644 additions and 13 deletions
+466 -8
View File
@@ -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 <business name>,' 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 <business name>, or begin directly with the observation; avoid formal stock greetings.",
"A short Hi <business name>, 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 <business name>, 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 <business name>,' 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"):