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
+16
View File
@@ -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
+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"):
+62
View File
@@ -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"}})
+87 -3
View File
@@ -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=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).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=`<div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Start from verified prospect evidence, your company knowledge, and any selected promotion.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label><label for="messagePromotion">Promotion<select id="messagePromotion"><option value="">No promotion</option>${promotionOptions()}</select><small class="field-help">Only active promotions appear here.</small></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate tailored draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts, your company knowledge, and any selected promotion. You can edit every word before copying it.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label><label for="messagePromotion">Promotion<select id="messagePromotion"><option value="">No promotion</option>${promotionOptions()}</select><small class="field-help">Only active promotions appear here.</small></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate client-specific draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
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=`<div class="message-context-title"><span class="score ${scoreClass(score)}">${score}</span><div><b>${esc(selected.name)}</b><small>${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}</small></div></div><div class="message-context-facts"><span>${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}</span><span>${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}</span></div>`;$('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 `<div class="drawer-section review-section"><div class="section-heading"><h3>Review status</h3><span class="muted">Saved to this prospect</span></div><div class="drawer-review-tabs">${options.map(([value,label])=>`<button type="button" class="drawer-review-tab ${p.review_status===value?'active':''}" data-drawer-review="${p.id}" data-status="${value}">${label}</button>`).join('')}</div></div>`}
@@ -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?`<h3>AI assessment</h3><div class="detail-grid"><span>Contact probability<b>${Math.round(Number(p.ai_probability))}%</b></span><span>Recommendation<b>${esc(aiRecommendationLabel(p.ai_recommendation))}</b></span></div><p>${esc(p.ai_reasoning||'No rationale returned.')}</p><p class="muted">Assessed ${date(p.ai_checked_at)}</p><button class="secondary" data-assess="${p.id}">Reassess with AI</button>`:`<h3>AI assessment</h3><p>Use the collected evidence to estimate whether this company is worth contacting.</p><button class="secondary" data-assess="${p.id}">Assess with AI</button>`;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',`<section id="messageRevisionPanel" class="message-revision-panel"><div class="message-revision-head"><div><span class="eyebrow">DRAFT CHAT</span><h3>Ask the AI to refine it</h3><p>Describe a change and the current subject and email will be revised in place.</p></div><span class="message-chat-badge">Review before sending</span></div><div id="messageChatMessages" class="message-chat-messages" role="log" aria-live="polite"></div><div class="message-revision-composer"><label for="messageRevisionInput">Requested change</label><textarea id="messageRevisionInput" rows="3" maxlength="3000" placeholder="Make it shorter and less salesy, keep the R119/month offer, and use a softer call to action."></textarea><div id="messageRevisionStatus" class="notice">The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.</div><button type="button" class="primary" id="messageRevisionApply">Apply change <span>→</span></button></div></section>`);
};
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 <span>→</span>';}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);
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v5"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v5"></script></body></html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v7"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v7"></script></body></html>
+11
View File
@@ -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}}