deploy ProspectOS workspace v7 final
This commit is contained in:
+466
-8
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user