From eeffaf4b6c5e12d2acc1b2d7e9bb12ec20ae6625 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Sun, 6 Sep 2026 00:46:28 +0200 Subject: [PATCH] deploy DOM search v8 update --- README.md | 20 +++- api/server.py | 252 ++++++++++++++++++++++++++++++++++++++++++++-- tests/test_app.py | 26 +++++ web/app.js | 44 ++++---- web/style.css | 3 + 5 files changed, 317 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 755d21d..ac38733 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,27 @@ the bundled [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scr worker, waits for its asynchronous job to finish, filters places already in the local catalogue, and presents the remaining places for human review. -The interface is split into Dashboard, Discover, Prospects, Runs, Logs, and Settings +The interface is split into Dashboard, Discover, Prospects, Runs, Logs, Teach AI, +Promotions, and Settings views. It includes website health and email evidence, explainable scoring, domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach automatically. The Prospects explorer has an **All high potential** tab and category tabs populated from each discovery run, with every tab sorted from -highest to lowest opportunity score. +highest to lowest opportunity score. It also provides search, potential-score, +website-health, public-email, and review-status filters; review status itself +is changed inside the prospect drawer so list rows stay focused on evidence. Optional AI can analyse a prospect and draft a subject/email from supplied 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 **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 +drafts. The **Promotions** view stores dated, active/inactive offers; an active +promotion can be selected in the email draft dialog and is passed to the AI +with its exact description and terms. The **Logs** view separates API calls, user actions, AI requests, searches, Browser Use/tool steps, and errors. AI request payloads and responses are truncated and credentials such as API keys, authorization headers, tokens, and @@ -197,6 +206,13 @@ variables above and accepts your exact offer/instructions (for example, the Gold Plus R119/month plan and a free rebuild). Suppressions are enforced before new records are saved and can be managed through `/api/suppressions`. +The Teach AI page saves a `company_profile` object through `POST /api/settings`. +Its **Read website now** action calls `POST /api/company-profile/learn` and +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. + ## Tests ```bash diff --git a/api/server.py b/api/server.py index cd330a5..5c1b6e4 100644 --- a/api/server.py +++ b/api/server.py @@ -20,16 +20,17 @@ import time import urllib.error import urllib.request from datetime import datetime, timezone +from html.parser import HTMLParser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable from urllib.parse import parse_qs, quote_plus, unquote, urlparse try: - from intelligence import domain_candidates, scan_website + from intelligence import domain_candidates, scan_website, _safe_url from browser_investigator import direct_search_candidates, investigate_websites except ImportError: # tests import the module as api.server - from api.intelligence import domain_candidates, scan_website + from api.intelligence import domain_candidates, scan_website, _safe_url from api.browser_investigator import direct_search_candidates, investigate_websites DB_PATH = os.environ.get("PROSPECT_DB", "/data/prospects.db") @@ -85,6 +86,87 @@ def _saved_settings() -> dict[str, str]: return {} +COMPANY_PROFILE_KEYS = { + "website_url": "COMPANY_WEBSITE_URL", + "name": "COMPANY_NAME", + "description": "COMPANY_DESCRIPTION", + "services": "COMPANY_SERVICES", + "packages": "COMPANY_PACKAGES", + "tone": "COMPANY_TONE", + "notes": "COMPANY_NOTES", + "website_content": "COMPANY_WEBSITE_CONTENT", + "website_title": "COMPANY_WEBSITE_TITLE", + "website_fetched_at": "COMPANY_WEBSITE_FETCHED_AT", +} + + +def company_profile_settings(saved: dict[str, str] | None = None) -> dict[str, Any]: + """Return the owner's reusable company knowledge for AI prompts/UI.""" + values = saved if saved is not None else _saved_settings() + profile = {field: str(values.get(key, ""))[:20_000] for field, key in COMPANY_PROFILE_KEYS.items()} + profile["configured"] = bool(profile.get("website_url") or profile.get("description") or profile.get("services") or profile.get("packages")) + return profile + + +class _CompanyPageParser(HTMLParser): + """Small bounded text extractor used by Teach AI; no scripts are retained.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.title: list[str] = [] + self.parts: list[str] = [] + self._tag_stack: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + self._tag_stack.append(tag) + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + for index in range(len(self._tag_stack) - 1, -1, -1): + if self._tag_stack[index] == tag: + del self._tag_stack[index:] + break + + def handle_data(self, data: str) -> None: + if any(tag in {"script", "style", "noscript", "svg", "template"} for tag in self._tag_stack): + return + clean = re.sub(r"\s+", " ", data).strip() + if not clean: + return + if "title" in self._tag_stack: + self.title.append(clean) + self.parts.append(clean) + + +def fetch_company_page(url: str) -> dict[str, str]: + """Fetch a public company page and extract a safe, bounded knowledge snapshot.""" + normalized = normalize_url(url) + if not normalized or urlparse(normalized).scheme not in {"http", "https"} or not _safe_url(normalized): + raise ValueError("public_http_website_url_required") + request = urllib.request.Request(normalized, headers={"User-Agent": OUTBOUND_USER_AGENT, "Accept": "text/html,application/xhtml+xml"}, method="GET") + started = time.monotonic() + try: + with urllib.request.urlopen(request, timeout=20) as response: + content_type = str(response.headers.get("Content-Type") or "").lower() + body = response.read(1_000_000) + final_url = str(response.geturl() or normalized) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"company_website_http_{exc.code}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"company_website_unreachable:{exc.reason}") from exc + if content_type and "html" not in content_type and "xhtml" not in content_type: + raise RuntimeError("company_website_not_html") + parser = _CompanyPageParser() + parser.feed(body.decode("utf-8", "replace")) + text = " ".join(parser.parts) + # Keep enough context for packages/services while preventing a huge prompt. + text = re.sub(r"\s+", " ", text).strip()[:20_000] + title = " ".join(parser.title).strip()[:300] + record_log("ai", "company_website_learned", details={"url": _safe_log_url(final_url), "title": title, "characters": len(text), "duration_ms": int((time.monotonic() - started) * 1000)}) + return {"website_url": final_url, "website_title": title, "website_content": text, "website_fetched_at": now()} + + def _setting_int(saved: dict[str, str], key: str, env_key: str, default: int, minimum: int, maximum: int) -> int: try: value = int(saved.get(key, os.environ.get(env_key, str(default)))) @@ -331,6 +413,17 @@ def db() -> sqlite3.Connection: details TEXT NOT NULL DEFAULT '{}', error TEXT NOT NULL DEFAULT '' )""") + conn.execute("""CREATE TABLE IF NOT EXISTS promotions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + terms TEXT NOT NULL DEFAULT '', + starts_at TEXT NOT NULL DEFAULT '', + ends_at TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") # Migrate databases created by v1-v5 without destroying the user's # catalogue. SQLite permits adding nullable/defaulted columns in place. @@ -371,6 +464,7 @@ def db() -> sqlite3.Connection: "ai_unknowns": "TEXT NOT NULL DEFAULT '[]'", "ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'", "ai_follow_up": "TEXT NOT NULL DEFAULT ''", + "ai_promotion_id": "INTEGER", "opportunity_score": "INTEGER NOT NULL DEFAULT 0", "raw_json": "TEXT NOT NULL DEFAULT '{}'", } @@ -1322,7 +1416,7 @@ def scheduler_loop() -> None: time.sleep(30) -def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation") -> dict[str, Any]: +def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None) -> dict[str, Any]: """Generate an evidence-grounded, human-editable outreach draft. ``tone`` and ``goal`` are deliberately small, validated controls rather @@ -1356,6 +1450,18 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" goal_key = "conversation" tone_instruction = tone_options[tone_key] goal_instruction = goal_options[goal_key] + 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"), @@ -1374,12 +1480,18 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "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, } 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 " f"{tone_instruction}; the goal is to {goal_instruction}. " - "Use only the stored evidence and the user's offer. Never invent a person's name, company activity, " + "Use only the stored prospect evidence, the owner's company profile, the selected promotion, and the user's offer. " + "The company profile describes the sender's business, not the prospect. Never invent a person's name, company activity, " "price, service, problem, result, testimonial, urgency, authority, or link. Do not mention scraping, " "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 " @@ -1395,7 +1507,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "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. " "evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, " - "not part of the email." + "not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule." ) prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False) headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {} @@ -1458,6 +1570,7 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]: raise RuntimeError("nous_api_key_required_for_standalone_app") if not runtime["base_url"] or not runtime["model"]: raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL") + profile = company_profile_settings() evidence = { "business": prospect.get("name"), "category": prospect.get("category"), "address": prospect.get("address"), "phone": prospect.get("phone"), @@ -1467,11 +1580,17 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]: "emails": prospect.get("emails"), "rating": prospect.get("rating"), "review_count": prospect.get("review_count"), "opportunity_score": prospect.get("opportunity_score"), "score_reasons": _json_list(prospect.get("score_reasons")), + "sender_company_profile": { + "name": profile.get("name"), "description": profile.get("description"), + "services": profile.get("services"), "packages": profile.get("packages"), + "website_url": profile.get("website_url"), "website_content": profile.get("website_content"), + }, } system = ( "You qualify prospects for a South African web hosting and web design business. " "Assess only the supplied public evidence. A high probability means the business appears active " - "and has a plausible hosting, domain, website repair, or redesign need. Do not invent facts, " + "and has a plausible need that matches the sender company's services. Use the sender company profile to judge fit, " + "but do not invent facts, " "intent, budget, decision-maker names, or contact details. Return strict JSON with exactly these " "keys: probability (integer 0-100), recommendation (one of contact, review, do_not_contact), " "reasoning (one concise paragraph)." @@ -1608,6 +1727,7 @@ class Handler(BaseHTTPRequestHandler): counts["errors"] = error_count return self.send_json(200, {"items": items, "total": total, "counts": counts}) if parsed.path == "/api/settings": + saved = _saved_settings() ai_runtime = ai_runtime_settings() models = nous_models() if ai_runtime["provider"] == "nous" else [] browser_runtime = browser_use_settings() @@ -1623,6 +1743,7 @@ class Handler(BaseHTTPRequestHandler): "browser_use": browser_runtime, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": scan_timeout}, + "company_profile": company_profile_settings(saved), }) if parsed.path == "/api/prospects": params = parse_qs(parsed.query) @@ -1649,6 +1770,9 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM suppressions ORDER BY id DESC")]}) if parsed.path == "/api/schedules": return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM schedules ORDER BY id DESC")]}) + if parsed.path == "/api/promotions": + rows = conn.execute("SELECT * FROM promotions ORDER BY active DESC, updated_at DESC, id DESC").fetchall() + return self.send_json(200, {"items": [dict(row) for row in rows]}) match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates", parsed.path) if match: prospect_id = int(match.group(1)) @@ -1731,11 +1855,13 @@ class Handler(BaseHTTPRequestHandler): browser = payload.get("browser_use") if isinstance(payload.get("browser_use"), dict) else {} maps = payload.get("maps") if isinstance(payload.get("maps"), dict) else {} limits = payload.get("limits") if isinstance(payload.get("limits"), dict) else {} + company = payload.get("company_profile") if isinstance(payload.get("company_profile"), dict) else {} conn = db() saved_keys: list[str] = [] def save_setting(key: str, value: Any) -> None: - text_value = str(value).strip()[:500] + max_length = 20_000 if key.startswith("COMPANY_") else 500 + text_value = str(value).strip()[:max_length] conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, text_value, now())) saved_keys.append(key) @@ -1766,6 +1892,13 @@ class Handler(BaseHTTPRequestHandler): if "website_scan_timeout_seconds" in limits: save_setting("WEBSITE_SCAN_TIMEOUT", max(2, min(60, int(limits.get("website_scan_timeout_seconds"))))) + for field, key in COMPANY_PROFILE_KEYS.items(): + # Website content/title/timestamp are written by the + # bounded Learn button, but accepting them here makes a + # full profile export/import round-trip cleanly. + if field in company: + save_setting(key, str(company.get(field) or "")[:20_000]) + conn.commit() conn.close() record_log("action", "settings_updated", details={"keys": saved_keys}) @@ -1777,9 +1910,54 @@ class Handler(BaseHTTPRequestHandler): "browser_use": browser_use_settings(), "maps": gmaps_runtime_settings(), "limits": {"website_scan_timeout_seconds": website_scan_timeout_seconds()}, + "company_profile": company_profile_settings(_saved_settings()), }) except Exception as exc: return self.send_json(400, {"error": str(exc)}) + if urlparse(self.path).path == "/api/company-profile/learn": + conn: sqlite3.Connection | None = None + try: + payload = self.read_json() + learned = fetch_company_page(str(payload.get("url") or "").strip()) + conn = db() + for field, value in learned.items(): + key = COMPANY_PROFILE_KEYS[field] + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, str(value)[:20_000], now())) + conn.commit() + profile = company_profile_settings(_saved_settings()) + return self.send_json(200, {"saved": True, "company_profile": profile}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + except RuntimeError as exc: + return self.send_json(502, {"error": str(exc)}) + except Exception as exc: + return self.send_json(502, {"error": "company_profile_learn_failed:" + str(exc)[:240]}) + finally: + if conn is not None: + conn.close() + if self.path == "/api/promotions": + conn: sqlite3.Connection | None = None + try: + payload = self.read_json() + name = str(payload.get("name") or "").strip()[:160] + description = str(payload.get("description") or "").strip()[:2_000] + if not name or not description: + return self.send_json(400, {"error": "promotion_name_and_description_required"}) + active = 0 if str(payload.get("active", True)).strip().lower() in {"0", "false", "no", "off"} else 1 + stamp = now() + conn = db() + cur = conn.execute("INSERT INTO promotions(name,description,terms,starts_at,ends_at,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (name, description, str(payload.get("terms") or "").strip()[:2_000], str(payload.get("starts_at") or "").strip()[:40], str(payload.get("ends_at") or "").strip()[:40], active, stamp, stamp)) + conn.commit() + row = conn.execute("SELECT * FROM promotions WHERE id=?", (cur.lastrowid,)).fetchone() + record_log("action", "promotion_created", details={"promotion_id": int(cur.lastrowid), "name": name}) + return self.send_json(201, dict(row)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + except Exception as exc: + return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: + conn.close() if urlparse(self.path).path == "/api/ai/test": try: runtime = ai_runtime_settings() @@ -1900,8 +2078,23 @@ class Handler(BaseHTTPRequestHandler): if not row: return self.send_json(404, {"error":"not_found"}) payload = self.read_json(); offer = str(payload.get("offer") or "").strip() if not offer: return self.send_json(400, {"error":"offer_required"}) - draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation")) - stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,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,int(message_match.group(1)))); conn.commit() + promotion = None + promotion_id = payload.get("promotion_id") + 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=?", (promotion_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 + draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"), promotion=promotion) + stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,promotion_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,int(message_match.group(1)))); conn.commit() return self.send_json(200, {**draft,"ai_checked_at":stamp,"prospect_id":int(message_match.group(1))}) 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)}) @@ -1941,6 +2134,37 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(400, {"error": str(exc)}) def do_PATCH(self) -> None: + promotion_match = re.fullmatch(r"/api/promotions/(\d+)", urlparse(self.path).path) + if promotion_match: + conn: sqlite3.Connection | None = None + try: + payload = self.read_json() + conn = db() + existing = conn.execute("SELECT * FROM promotions WHERE id=?", (int(promotion_match.group(1)),)).fetchone() + if not existing: + return self.send_json(404, {"error": "not_found"}) + fields: dict[str, Any] = {} + for field in ("name", "description", "terms", "starts_at", "ends_at"): + if field in payload: + fields[field] = str(payload.get(field) or "").strip()[:2_000] + if "name" in fields and not fields["name"]: + return self.send_json(400, {"error": "promotion_name_required"}) + if "description" in fields and not fields["description"]: + return self.send_json(400, {"error": "promotion_description_required"}) + if "active" in payload: + fields["active"] = 0 if str(payload.get("active")).strip().lower() in {"0", "false", "no", "off"} else 1 + fields["updated_at"] = now() + assignments = ",".join(f"{key}=?" for key in fields) + conn.execute("UPDATE promotions SET " + assignments + " WHERE id=?", (*fields.values(), int(promotion_match.group(1)))) + conn.commit() + row = conn.execute("SELECT * FROM promotions WHERE id=?", (int(promotion_match.group(1)),)).fetchone() + record_log("action", "promotion_updated", details={"promotion_id": int(promotion_match.group(1)), "fields": list(fields)}) + return self.send_json(200, dict(row)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: + conn.close() candidate_match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates/(\d+)", urlparse(self.path).path) if candidate_match: prospect_id, candidate_id = (int(candidate_match.group(1)), int(candidate_match.group(2))) @@ -2038,6 +2262,16 @@ class Handler(BaseHTTPRequestHandler): conn.close() def do_DELETE(self) -> None: + promotion_match = re.fullmatch(r"/api/promotions/(\d+)", urlparse(self.path).path) + if promotion_match: + conn = db() + try: + cur = conn.execute("DELETE FROM promotions WHERE id=?", (int(promotion_match.group(1)),)) + conn.commit() + record_log("action", "promotion_deleted", details={"promotion_id": int(promotion_match.group(1))}) + return self.send_json(200, {"deleted": bool(cur.rowcount)}) + finally: + conn.close() match = re.fullmatch(r"/api/suppressions/(\d+)", urlparse(self.path).path) if not match: return self.send_json(404, {"error":"not_found"}) conn=db() diff --git a/tests/test_app.py b/tests/test_app.py index 4499ce7..27452aa 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -268,6 +268,32 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertEqual(server.browser_use_limits(), (22, 900)) self.assertEqual(server.website_scan_timeout_seconds(), 15) + def test_company_profile_and_promotions_feed_email_draft(self): + status, profile = self.request("POST", "/api/settings", {"company_profile": { + "name": "Marco Web Studio", "description": "A South African hosting studio.", + "services": "Domains, hosting and website rebuilds.", "packages": "Gold Plus hosting at R119/month.", + }}) + self.assertEqual(status, 200) + self.assertTrue(profile["company_profile"]["configured"]) + self.request("POST", "/api/settings", {"ai": {"AI_PROVIDER": "custom", "AI_BASE_URL": "https://ai.example/v1", "AI_MODEL": "test-model", "AI_API_KEY": "test-key"}}) + status, promotion = self.request("POST", "/api/promotions", {"name": "Free rebuild", "description": "Free website rebuild when hosting moves to Gold Plus.", "terms": "New hosting customers only."}) + self.assertEqual(status, 201) + status, listed = self.request("GET", "/api/promotions") + self.assertEqual(status, 200) + self.assertEqual(listed["items"][0]["name"], "Free rebuild") + conn = server.db() + conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now())) + conn.commit(); conn.close() + content = json.dumps({"subject": "A free website rebuild", "message": "Good day Acme,\n\nWe can help.", "rationale": "Uses the supplied promotion.", "recommended_offer": "Free rebuild", "unknowns": [], "evidence_used": ["prospect_record"], "follow_up": "Review 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", {"offer": "Please mention our offer.", "promotion_id": promotion["id"]}) + self.assertEqual(status, 200) + prompt = http_json.call_args.args[2]["messages"][1]["content"] + self.assertIn("Free website rebuild", prompt) + self.assertIn("Gold Plus hosting at R119/month", prompt) + saved = server.db().execute("SELECT ai_promotion_id FROM prospects WHERE id=1").fetchone() + self.assertEqual(saved[0], promotion["id"]) + if __name__ == "__main__": unittest.main() diff --git a/web/app.js b/web/app.js index f44c544..a3a4bd6 100644 --- a/web/app.js +++ b/web/app.js @@ -3,9 +3,10 @@ let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null; let activeLogTab='all'; const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']]; function logAction(action,details={},level='info'){try{fetch('/api/logs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}} -function installLogsNav(){const nav=$('nav');if(!nav||nav.querySelector('[data-route="logs"]'))return;const link=document.createElement('a');link.href='#logs';link.dataset.route='logs';link.innerHTML='≋Logs';nav.append(link)} -installLogsNav(); +function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋']].forEach(([route,label,icon])=>{if(nav.querySelector(`[data-route="${route}"]`))return;const link=document.createElement('a');link.href='#'+route;link.dataset.route=route;link.innerHTML=`${icon}${label}`;nav.append(link)})} +installExtraNav(); let activeProspectCategory='all'; +let prospectScoreBand='all',prospectWebsiteFilter='all',prospectEmailFilter='all',prospectReviewFilter='all'; const HIGH_POTENTIAL_SCORE=70; async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b} let loadWarnings=[]; @@ -14,19 +15,23 @@ function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v) function layout(title,sub,body,actions=''){ $('pageName').textContent=title; return `
PROSPECTOS

${title}

${sub}

${actions}
${body}` } function empty(text){return `
⌕${text}
`} function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'} +function effectiveScore(p){const ai=Number(p?.ai_probability);return p?.ai_checked_at&&Number.isFinite(ai)?Math.round(Math.max(0,Math.min(100,ai))):Number(p?.opportunity_score||0)} +function scoreLabel(p){return p?.ai_checked_at&&Number.isFinite(Number(p?.ai_probability))?'AI contact probability':'Opportunity score'} function prospectCategory(p){const raw=String(p.search_category||p.category||'').trim();return raw?raw.replace(/\s+/g,' ').replace(/^./,c=>c.toUpperCase()):'Other'} -function prospectTabs(){const categories=[...new Set(prospects.map(prospectCategory))].sort((a,b)=>a.localeCompare(b));if(activeProspectCategory!=='all'&&!categories.includes(activeProspectCategory))activeProspectCategory='all';const count=category=>category==='all'?prospects.filter(p=>Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `
${categories.map(category=>``).join('')}

All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.

`} -function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const tabMatch=activeProspectCategory==='all'?Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;return tabMatch&&(!q||hay.includes(q))}).sort((a,b)=>Number(b.opportunity_score||0)-Number(a.opportunity_score||0)||String(a.name||'').localeCompare(String(b.name||'')))} -function prospectTable(){const rows=prospectRows();return `
${rows.map(p=>``).join('')}
ProspectWebsiteEmailPotential
${esc(p.name)}${esc(prospectCategory(p))} · ${esc(p.address||'')}${esc((p.website_status||'unknown').replaceAll('_',' '))}${p.website?`Open ↗`:'No detected website'}${p.emails?``:'Not found'}${p.opportunity_score||0}${Number(p.opportunity_score||0)>=85?'Hot':Number(p.opportunity_score||0)>=70?'High':Number(p.opportunity_score||0)>=45?'Medium':'Low'}
${rows.length?'':empty('No prospects match this tab and filter')}`} -function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>p.opportunity_score>=75).length,withEmail=prospects.filter(p=>p.emails).length;const latest=runs[0];$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`
CATALOGUE${total}saved prospects
NEEDS REVIEW${pending}awaiting your decision
HIGH OPPORTUNITY${hot}score 75 or higher
PUBLIC EMAILS${withEmail}evidence-backed contacts
NEXT ACTION

Review your strongest leads

Inspect the evidence before deciding whether to contact them.

Open explorer →
${prospects.slice(0,5).map(p=>``).join('')||empty('Run a discovery to build your catalogue')}
RECENT ACTIVITY

Runs

View all
${runs.slice(0,6).map(r=>`
#${r.id} · ${esc(r.query)}${esc(r.status)} · ${r.added||0} added
`).join('')||empty('No runs yet')}
`)} +function prospectTabs(){const categories=[...new Set(prospects.map(prospectCategory))].sort((a,b)=>a.localeCompare(b));if(activeProspectCategory!=='all'&&!categories.includes(activeProspectCategory))activeProspectCategory='all';const count=category=>category==='all'?prospects.filter(p=>effectiveScore(p)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `
${categories.map(category=>``).join('')}

All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.

`} +function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const score=effectiveScore(p);const websiteStatus=String(p.website_status||'unknown').toLowerCase();const tabMatch=activeProspectCategory==='all'?score>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;const scoreMatch=prospectScoreBand==='all'||(prospectScoreBand==='hot'&&score>=85)||(prospectScoreBand==='high'&&score>=70&&score<85)||(prospectScoreBand==='medium'&&score>=45&&score<70)||(prospectScoreBand==='low'&&score<45);const websiteMatch=prospectWebsiteFilter==='all'||(prospectWebsiteFilter==='missing'&&(!p.website||websiteStatus==='no_detected_website'))||(prospectWebsiteFilter==='broken'&&['broken','unreachable','error','timeout'].some(v=>websiteStatus.includes(v)))||(prospectWebsiteFilter==='healthy'&&websiteStatus==='healthy');const emailMatch=prospectEmailFilter==='all'||(prospectEmailFilter==='has_email'&&Boolean(String(p.emails||'').trim()))||(prospectEmailFilter==='no_email'&&!String(p.emails||'').trim());const reviewMatch=prospectReviewFilter==='all'||String(p.review_status||'pending')===prospectReviewFilter;return tabMatch&&scoreMatch&&websiteMatch&&emailMatch&&reviewMatch&&(!q||hay.includes(q))}).sort((a,b)=>effectiveScore(b)-effectiveScore(a)||String(a.name||'').localeCompare(String(b.name||'')))} +function prospectTable(){const rows=prospectRows();return `
${rows.map(p=>{const score=effectiveScore(p);return ``}).join('')}
ProspectWebsiteEmailPotential
${esc(p.name)}${esc(prospectCategory(p))} · ${esc(p.address||'')}${esc((p.website_status||'unknown').replaceAll('_',' '))}${p.website?`Open ↗`:'No detected website'}${p.emails?``:'Not found'}${score}${score>=85?'Hot':score>=70?'High':score>=45?'Medium':'Low'}${p.ai_checked_at?' · AI':''}
${rows.length?'':empty('No prospects match this tab and filter')}`} +function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>effectiveScore(p)>=75).length,withEmail=prospects.filter(p=>p.emails).length;const strongest=[...prospects].sort((a,b)=>effectiveScore(b)-effectiveScore(a)).slice(0,5);$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`
CATALOGUE${total}saved prospects
NEEDS REVIEW${pending}awaiting your decision
HIGH OPPORTUNITY${hot}score 75 or higher
PUBLIC EMAILS${withEmail}evidence-backed contacts
NEXT ACTION

Review your strongest leads

Inspect the evidence before deciding whether to contact them.

Open explorer →
${strongest.map(p=>{const score=effectiveScore(p);return ``}).join('')||empty('Run a discovery to build your catalogue')}
RECENT ACTIVITY

Runs

View all
${runs.slice(0,6).map(r=>`
#${r.id} · ${esc(r.query)}${esc(r.status)} · ${r.added||0} added
`).join('')||empty('No runs yet')}
`)} function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`
NEW RUN

Find potential customers

Search Google Maps, deduplicate the catalogue, then enrich each result.

Email collection always on
HOW IT WORKS

Evidence-first pipeline

  1. SearchMaps worker finds public business listings.
  2. EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
  3. ReviewYou decide what is relevant before any contact.
`)} -function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`
${prospectTabs()}

Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message.

${prospectTable()}
`)} +function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`
${prospectTabs()}

Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message. Review status is changed inside the prospect drawer.

${prospectTable()}
`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter} function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`
${runs.map(r=>``).join('')}
RunStatusResultsStartedSource
#${r.id}${esc(r.query)}${esc(r.status)}${esc(r.stage||'')}${r.added||0} added / ${r.found||0} found${date(r.created_at)}${esc(r.source||'google_maps')}
${runs.length?'':empty('No discovery runs yet')}
`)} function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)} function logTabs(counts={}){return `
${LOG_TABS.map(([key,label])=>``).join('')}
`} function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`
${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return ``}).join('')}
TimeTypeEventStatusDurationDetails
${esc(date(row.created_at))}${esc(row.kind||'system')}${esc(row.event||'')}${row.path?`${esc(row.method||'')} ${esc(row.path)}`:''}${row.error?`${esc(row.error)}`:''}${row.status?esc(row.status):'—'}${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}
View
${esc(details)}
`:empty('No log entries for this tab')} async function refreshLogs(){const status=$('logsStatus');if(status)status.textContent='Loading logs…';try{const params=new URLSearchParams({limit:'250'});if(activeLogTab==='errors')params.set('level','error');else if(activeLogTab!=='all')params.set('kind',activeLogTab);const data=await api('/api/logs?'+params.toString());logs=data.items||[];const tabs=$('logsTabs');if(tabs)tabs.innerHTML=logTabs(data.counts||{});const table=$('logsTable');if(table)table.innerHTML=renderLogTable();if(status)status.textContent=`Showing ${logs.length} of ${data.total||logs.length} entries · credentials are redacted`; }catch(error){if(status)status.textContent=error.message;if($('logsTable'))$('logsTable').innerHTML=empty('Logs could not be loaded')}} function renderLogs(){ $('view').innerHTML=layout('Logs','Inspect user actions, API calls, AI requests, searches, Browser Use steps, and errors in one place.',`
OPERATIONS JOURNAL

Detailed activity

Requests and actions are timestamped. API keys, authorization headers, and passwords are never stored.

${logTabs()}
Loading logs…
`);refreshLogs()} +function renderTeachAI(){const p=settings.company_profile||{};$('view').innerHTML=layout('Teach AI','Give the assistant reliable context about your company, services, packages, and preferred voice. This is used when assessing prospects and writing drafts.',`
COMPANY KNOWLEDGE

Teach the assistant

Save the facts you want it to use. It will never invent prices, packages, or promises outside this profile and your draft instruction.

${p.configured?'Profile ready':'Not taught yet'}
${p.website_fetched_at?`Last read ${esc(date(p.website_fetched_at))}`:''}
LEARNED SNAPSHOT

What the assistant can reference

${p.website_title?esc(p.website_title):'No website snapshot yet'}${p.website_content?`${p.website_content.length.toLocaleString()} characters captured from the public page.`:'Use Read website now, then review the text before saving.'}
${p.website_content?`
View captured website text

${esc(p.website_content)}

`:'

Manual company details are always available, even without a website.

'}
`)} +function renderPromotions(){const items=Array.isArray(settings.promotions)?settings.promotions:[];$('view').innerHTML=layout('Promotions','Create offers once, then select an active promotion while drafting a prospect email. Inactive promotions stay in your history but cannot be used by the AI.',`
NEW PROMOTION

Set up an offer

Keep the exact wording, price, dates, and eligibility here so drafts stay accurate.

YOUR OFFERS

Saved promotions

${items.filter(x=>x.active).length} active · ${items.length} total

${items.map(item=>`
${esc(item.name)}

${esc(item.description)}

${item.terms?`Terms: ${esc(item.terms)}`:''}${item.starts_at||item.ends_at?`Valid ${esc(item.starts_at||'now')} → ${esc(item.ends_at||'ongoing')}`:'No dates set'}
`).join('')||'

No promotions yet. Add your first offer on the left.

'}
`)} function renderSettings(){ const ai=settings.ai||{}; const maps=settings.maps||{}; @@ -56,27 +61,29 @@ function renderSettings(){ } async function load(){ loadWarnings=[]; - const results=await Promise.allSettled([api('/api/prospects'),api('/api/runs'),api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]); + const results=await Promise.allSettled([api('/api/prospects'),api('/api/runs'),api('/api/settings'),api('/api/schedules'),api('/api/suppressions'),api('/api/promotions')]); const value=(result,label,fallback)=>{if(result.status==='fulfilled')return result.value;loadWarnings.push(`${label}: ${result.reason?.message||'request failed'}`);return fallback}; - const prospectsResponse=value(results[0],'Prospects',{}),runsResponse=value(results[1],'Runs',{}),settingsResponse=value(results[2],'Settings',{}),schedulesResponse=value(results[3],'Schedules',{}),suppressionsResponse=value(results[4],'Suppressions',{}); + const prospectsResponse=value(results[0],'Prospects',{}),runsResponse=value(results[1],'Runs',{}),settingsResponse=value(results[2],'Settings',{}),schedulesResponse=value(results[3],'Schedules',{}),suppressionsResponse=value(results[4],'Suppressions',{}),promotionsResponse=value(results[5],'Promotions',{}); prospects=Array.isArray(prospectsResponse.items)?prospectsResponse.items:[]; runs=Array.isArray(runsResponse.items)?runsResponse.items:[]; - settings={...settingsResponse,schedules:Array.isArray(schedulesResponse.items)?schedulesResponse.items:[],suppressions:Array.isArray(suppressionsResponse.items)?suppressionsResponse.items:[]}; + settings={...settingsResponse,schedules:Array.isArray(schedulesResponse.items)?schedulesResponse.items:[],suppressions:Array.isArray(suppressionsResponse.items)?suppressionsResponse.items:[],promotions:Array.isArray(promotionsResponse.items)?promotionsResponse.items:[]}; return loadWarnings; } 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';installLogsNav();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}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}} +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}[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 jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}} function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`AI review notes

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

${unknowns.length?`

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

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

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

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

${esc(d.follow_up)}

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

Tailor a message

Start from verified prospect evidence, then edit before sending.

`} -async function openMessage(id){ensureMessageDialog();selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`
${selected.opportunity_score||0}
${esc(selected.name)}${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}
${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}
`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';$('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()} +function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>``).join('')} +function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`

Tailor a message

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

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

Review status

Saved to this prospect
${options.map(([value,label])=>``).join('')}
`} -function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`
${p.opportunity_score||0}
Opportunity scoreExplainable triage signal, not an AI verdict
${reviewStatusTabs(p)}

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

${p.website?`Open website ↗`:''}
`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')} +function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;const score=effectiveScore(p);const assessed=Boolean(p.ai_checked_at&&Number.isFinite(Number(p.ai_probability)));$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`
${score}
${scoreLabel(p)}${assessed?'Updated from the latest AI assessment':'Explainable triage signal; assess with AI to update this score'}
${reviewStatusTabs(p)}

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

${p.website?`Open website ↗`:''}
`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')} document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}}); document.addEventListener('click',async e=>{const button=e.target.closest('[data-drawer-review]');if(!button)return;e.preventDefault();e.stopPropagation();const id=button.dataset.drawerReview,status=button.dataset.status;const panel=button.closest('.review-section');const buttons=panel?panel.querySelectorAll('[data-drawer-review]'):[];buttons.forEach(item=>item.disabled=true);try{const updated=await api(`/api/prospects/${id}/review`,{method:'PATCH',body:JSON.stringify({status})});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]=updated;selected=updated;openDrawer(id);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){buttons.forEach(item=>item.disabled=false);if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=`Could not save status: ${error.message}`;panel.append(notice)}}}); -document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch')$('prospectTable').innerHTML=prospectTable();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>``).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}}); +document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch'||['prospectScoreBand','prospectWebsiteFilter','prospectEmailFilter','prospectReviewFilter'].includes(e.target.id)){if(e.target.id==='prospectScoreBand')prospectScoreBand=e.target.value;if(e.target.id==='prospectWebsiteFilter')prospectWebsiteFilter=e.target.value;if(e.target.id==='prospectEmailFilter')prospectEmailFilter=e.target.value;if(e.target.id==='prospectReviewFilter')prospectReviewFilter=e.target.value;if($('prospectTable'))$('prospectTable').innerHTML=prospectTable();return}if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>``).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}}); +document.addEventListener('input',e=>{if(e.target.id==='prospectSearch'&&$('prospectTable'))$('prospectTable').innerHTML=prospectTable()}); document.addEventListener('submit',async e=>{ if(e.target.id==='discoverForm'){ e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…'; @@ -95,15 +102,18 @@ document.addEventListener('submit',async e=>{ } if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}} if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}} + 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'})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,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='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})});$('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_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==='learnCompanyWebsite'){const button=e.target,m=$('teachAiLearnMessage');const url=$('companyWebsiteUrl')?.value||'';if(!url){m.textContent='Enter a website URL first.';m.className='notice error';return}button.disabled=true;button.textContent='Reading…';m.textContent='Fetching the public page…';m.className='notice';try{const result=await api('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;const p=settings.company_profile||{};$('companyWebsiteUrl').value=p.website_url||url;if($('companyName')&&!$('companyName').value&&p.website_title)$('companyName').value=p.website_title;m.textContent=`Website snapshot saved (${(p.website_content||'').length.toLocaleString()} characters). Review it, then save your company knowledge.`;m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}finally{button.disabled=false;button.textContent='Read website now'}}const toggle=e.target.closest('[data-promotion-active]');if(toggle){try{await api(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});const item=(settings.promotions||[]).find(p=>String(p.id)===String(toggle.dataset.promotionActive));if(item)item.active=toggle.checked?1:0;renderPromotions()}catch(x){toggle.checked=!toggle.checked;alert(x.message)}}const remove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{await api(`/api/promotions/${remove.dataset.deletePromotion}`,{method:'DELETE'});settings.promotions=(settings.promotions||[]).filter(p=>String(p.id)!==String(remove.dataset.deletePromotion));renderPromotions()}catch(x){remove.disabled=false;alert(x.message)}}}); function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'} function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`

AI assessment

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

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

Assessed ${date(p.ai_checked_at)}

`:`

AI assessment

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

`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)} function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}} function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`

Possible official websites

Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.

${rows.length?`
${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `
${esc(c.domain)} ↗${esc(status)}

Confidence ${Number(c.confidence||0)}%

${evidence.length?`
    ${evidence.map(item=>`
  • ${esc(item)}
  • `).join('')}
`:'

No evidence returned.

'}${status==='candidate'?`
`:''}
`}).join('')}
`:'

No plausible official website candidates found yet.

'}`} function addWebsitePanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`

Find a possible website

Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.

Requires a tested AI provider in Settings.

`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})} const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id)} -async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id)}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}} +async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id);if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}} document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}}); async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}} document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}}); diff --git a/web/style.css b/web/style.css index e87265f..9be35f9 100644 --- a/web/style.css +++ b/web/style.css @@ -139,3 +139,6 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color: .drawer-review-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.drawer-review-tab{min-height:36px;padding:7px 8px;border:1px solid #dfe4ed;border-radius:8px;background:#fff;color:#59677e;font:inherit;font-size:11px;font-weight:800;cursor:pointer}.drawer-review-tab:hover{border-color:#b9b5ff;background:#f7f6ff;color:#4f47df}.drawer-review-tab.active{border-color:#635bff;background:#635bff;color:#fff;box-shadow:0 5px 12px #635bff2b}.drawer-review-tab:disabled{cursor:wait;opacity:.6} .message-context{padding:14px;border:1px solid #e5e9f2;border-radius:11px;background:#fafbff}.message-context-title{display:flex;align-items:center;gap:10px}.message-context-title .score{flex:0 0 auto}.message-context-title b,.message-context-title small{display:block}.message-context-title b{font-size:14px;color:#27344b}.message-context-title small{margin-top:3px;color:#8995a8;font-size:11px}.message-context-facts{display:flex;flex-wrap:wrap;gap:7px;margin-top:11px}.message-context-facts span{padding:5px 8px;border-radius:999px;background:#eef1f7;color:#66748a;font-size:10px;font-weight:700}.message-controls{display:grid;grid-template-columns:1fr 1fr;gap:12px}.message-controls label{display:grid;gap:6px;font-size:11px;font-weight:800;color:#526078}.message-controls select{width:100%;margin:0}.message-dialog .field-help{display:block;margin-top:5px;font-size:10px;font-weight:500}.draft-generate{width:100%;min-height:42px}.draft-output{display:grid;gap:8px;padding-top:4px;border-top:1px solid #edf0f5}.draft-output label{display:flex;justify-content:space-between;align-items:center;color:#526078;font-size:11px;font-weight:800}.draft-output label small{color:#9aa4b5;font-size:10px;font-weight:500}.draft-output input,.draft-output textarea{margin:0}.draft-output textarea{min-height:235px;resize:vertical;line-height:1.55} @media(max-width:560px){.drawer-review-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.message-controls{grid-template-columns:1fr}} +.prospect-filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.prospect-filter-row .search{flex:1 1 260px;min-width:220px}.prospect-filter-row select{width:auto;min-width:140px;flex:0 1 170px;margin:0}.prospect-filter-row button{flex:0 0 auto}.knowledge-grid,.promotions-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:17px;align-items:start}.knowledge-form,.promotion-form{display:grid;gap:13px}.knowledge-form textarea,.promotion-form textarea{resize:vertical;line-height:1.45}.knowledge-preview{padding:22px}.knowledge-status{display:grid;gap:7px;padding:15px;border:1px solid #e5e9f2;border-radius:10px;background:#fafbff;color:#526078;font-size:12px}.knowledge-status span{color:#8b96a8;font-size:11px}.knowledge-preview details{margin-top:15px}.knowledge-preview summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.knowledge-preview details p{max-height:400px;overflow:auto;margin:9px 0 0;padding:11px;border-radius:8px;background:#f6f7fb;color:#627087;font-size:11px;line-height:1.55;white-space:pre-wrap}.promotion-list{display:grid;gap:10px}.promotion-card{display:flex;justify-content:space-between;gap:14px;padding:14px;border:1px solid #e5e9f2;border-radius:10px;background:#fff}.promotion-card.inactive{background:#fafbfc;opacity:.78}.promotion-card p{margin:7px 0;color:#627087;font-size:12px;line-height:1.45}.promotion-card small{display:block;margin-top:5px;color:#919bad;font-size:10px}.promotion-card-head{display:flex;align-items:center;gap:12px}.promotion-card-head b{color:#29364d;font-size:13px}.switch{display:inline-flex;align-items:center;gap:6px;color:#7d899c;font-size:10px;font-weight:800;cursor:pointer}.switch input{width:15px;height:15px;margin:0}.promotion-card>.secondary{align-self:flex-start;white-space:nowrap}.message-controls{grid-template-columns:repeat(3,1fr)} +@media(max-width:900px){.knowledge-grid,.promotions-grid{grid-template-columns:1fr}.message-controls{grid-template-columns:1fr 1fr}} +@media(max-width:680px){.prospect-filter-row .search{min-width:100%;flex-basis:100%}.prospect-filter-row select{flex:1 1 45%;min-width:0}.prospect-filter-row button{flex:1 1 45%}.promotion-card{display:block}.promotion-card>.secondary{margin-top:10px}.message-controls{grid-template-columns:1fr}}