From 4b43dea766e0059a18c21af33ce8381f0b19a41c Mon Sep 17 00:00:00 2001 From: anusbutt Date: Fri, 24 Jul 2026 23:55:13 +0500 Subject: [PATCH 1/6] feat(008)!: remove Facebook as a communication channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email is now the tool's only outreach channel (Constitution v7.0.0, Principle I). Removed: - `prospector dm` (assisted-manual Messenger delivery), the clipboard helper, and the dedicated DM ledger + PROSPECTOR_DM_LEDGER config. - The messenger bucket: a blank / "messenger" / Facebook-URL / unparseable email field now means NO ADDRESS SUPPLIED rather than a second-channel route. - The deterministic Messenger DM template and its invariants. - fb_signal classification, variant selection, and FB link/widget/embed evidence extraction, plus the DDG Facebook-presence search. - `fb_signal` / `facebook_url` note frontmatter and the Messenger dashboard queue. - Channel, Variant, FbSignal enums and the four Dm* dataclasses. Behaviour changes: - Possessive channel claims ("your page", "your inbox") are now rejected unconditionally by the V13 validator. No channel signal is researched any more, so no recorded evidence could ever justify such a claim. - Note frontmatter converges on the current schema. merge_notes emits only FRONTMATTER_KEYS, so legacy fb_signal/facebook_url keys are dropped on the next re-run. Human-owned content (status, outcome, ## Log, custom sections) is preserved exactly, as before. Unchanged, deliberately: the Meta Pixel sourcing filter in `prospector source` and fetch.py's Facebook host guard. Removing Facebook as a channel does not remove the guard — it makes it the only thing constraining the pixel heuristic (Constitution v7.0.0, Principle II). Tests: 507 passing (was 532; the delta is coverage of deleted features). ~24 test files were reworked rather than deleted, and tests/unit/test_no_facebook_channel.py now guards the removal so it cannot silently regress. Co-Authored-By: Claude Opus 5 --- .env.example | 3 - .gitignore | 1 - prospector/agent_draft.py | 42 ++--- prospector/cli.py | 102 ++--------- prospector/clipboard.py | 41 ----- prospector/config.py | 3 - prospector/dm.py | 181 ------------------- prospector/draft.py | 54 +----- prospector/extract.py | 42 ----- prospector/ingest.py | 32 ++-- prospector/models.py | 115 ++---------- prospector/pipeline.py | 45 ++--- prospector/resolve.py | 55 +----- prospector/score.py | 29 ---- prospector/send.py | 6 +- prospector/source.py | 4 +- prospector/vault.py | 65 +------ tests/integration/test_batch_run.py | 20 --- tests/integration/test_source_feeds_run.py | 6 +- tests/integration/test_success_criteria.py | 10 -- tests/unit/test_agent_draft.py | 60 +++---- tests/unit/test_cli.py | 4 +- tests/unit/test_cli_dm.py | 50 ------ tests/unit/test_dashboard.py | 17 +- tests/unit/test_dm.py | 192 --------------------- tests/unit/test_dm_no_facebook_http.py | 54 ------ tests/unit/test_draft.py | 94 +++------- tests/unit/test_extract.py | 45 +---- tests/unit/test_ingest.py | 38 ++-- tests/unit/test_models.py | 10 +- tests/unit/test_no_facebook_channel.py | 85 +++++++++ tests/unit/test_resolve.py | 47 +---- tests/unit/test_score.py | 44 +---- tests/unit/test_vault.py | 10 +- tests/unit/test_vault_facebook_url.py | 69 -------- tests/unit/test_vault_freeze.py | 16 +- 36 files changed, 268 insertions(+), 1423 deletions(-) delete mode 100644 prospector/clipboard.py delete mode 100644 prospector/dm.py delete mode 100644 tests/unit/test_cli_dm.py delete mode 100644 tests/unit/test_dm.py delete mode 100644 tests/unit/test_dm_no_facebook_http.py create mode 100644 tests/unit/test_no_facebook_channel.py delete mode 100644 tests/unit/test_vault_facebook_url.py diff --git a/.env.example b/.env.example index bb46a58..e629026 100644 --- a/.env.example +++ b/.env.example @@ -40,9 +40,6 @@ OPENROUTER_API_KEY= #PROSPECTOR_SEND_DELAY=30,90 # Append-only send ledger path (gitignored). Default: send_ledger.jsonl #PROSPECTOR_LEDGER=send_ledger.jsonl -# --- Assisted-manual Messenger delivery (`prospector dm`) --- -# Append-only Messenger delivery ledger (gitignored). Default: dm_ledger.jsonl -#PROSPECTOR_DM_LEDGER=dm_ledger.jsonl # Gmail provider only: OAuth client secret + stored token (secrets/, gitignored). #PROSPECTOR_GMAIL_CLIENT=secrets/gmail_client_secret.json #PROSPECTOR_GMAIL_TOKEN=secrets/gmail_token.json diff --git a/.gitignore b/.gitignore index 9af9160..658fa05 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ dist/ secrets/ *client_secret*.json send_ledger.jsonl -dm_ledger.jsonl # Local data / scratch (not part of the distributed project) Vault/ diff --git a/prospector/agent_draft.py b/prospector/agent_draft.py index 132349d..ef27c89 100644 --- a/prospector/agent_draft.py +++ b/prospector/agent_draft.py @@ -39,7 +39,6 @@ Draft, DraftBlock, EvidenceRef, - FbSignal, Prospect, ResearchResult, ) @@ -67,13 +66,16 @@ # V13 (added 2026-07-20 after the first live run). Possessive channel phrasing # turns a PRODUCT fact into a claim about the prospect: "it answers your # Facebook page messages" asserts they have a page and that customers message -# it. Constitution Principle V permits that only at fb_signal `strong`, and -# only with the observed signal cited. +# it. # # This was found by the first three real drafts: two of two agent drafts wrote -# "your Facebook page" at fb_signal `weak`, citing only `offer`. V3 could not -# see it — the phrase contains no company, city, name, or hook token — which is -# exactly why a phrase-level rule is needed alongside the token-level one. +# "your Facebook page" citing only `offer`. V3 could not see it — the phrase +# contains no company, city, name, or hook token — which is exactly why a +# phrase-level rule is needed alongside the token-level one. +# +# 008 (v7.0.0): channel signals are no longer researched, so no evidence can +# ever support such a claim. The rule is therefore unconditional — describe what +# the product does, never what the prospect has. POSSESSIVE_CHANNEL_PHRASES = ( "your facebook page", "your fb page", @@ -86,9 +88,6 @@ "message your page", ) -FB_EVIDENCE_PREFIXES = ("fb_",) - - class AgentDraftError(Exception): """Agent path could not produce a usable draft. Always caught internally.""" @@ -103,7 +102,7 @@ def build_evidence_refs(research: ResearchResult) -> list[EvidenceRef]: ids and therefore byte-identical notes on re-run (FR-329). Readable ids matter: the operator reviewing a citation should be able to tell where it points without a lookup table.""" - records = list(research.name_evidence) + list(research.fb_evidence) + records = list(research.name_evidence) if research.hook_evidence is not None: records.append(research.hook_evidence) @@ -308,29 +307,20 @@ def validate_citations(response: AgentResponse, prospect: Prospect, refs: list[E def validate_channel_claims(response: AgentResponse, prospect: Prospect) -> list[str]: """Rule V13: possessive channel phrasing is a claim about the prospect. - Permitted only when the observed signal is `strong` AND the block making the - claim cites an `fb_*` evidence record. Anything less defaults down, per - Principle V's "when the signal is uncertain, default DOWN, never up".""" + Unconditionally rejected (008, Constitution v7.0.0 Principle IV): the tool no + longer researches any channel signal, so no recorded evidence could support + "your page" / "your inbox". Describe what the product does, never what the + prospect is asserted to have.""" errors: list[str] = [] for i, block in enumerate(response.blocks, start=1): lowered = block.text.lower() phrase = next((p for p in POSSESSIVE_CHANNEL_PHRASES if p in lowered), None) if phrase is None: continue - cites_fb = any( - c.startswith(FB_EVIDENCE_PREFIXES) for c in block.cites + errors.append( + f"block {i} claims the prospect's own channel ({phrase!r}) — " + f"describe what the product does, not what they have" ) - if prospect.fb_signal is not FbSignal.STRONG: - errors.append( - f"block {i} claims the prospect's own channel ({phrase!r}) " - f"but fb_signal is {prospect.fb_signal.value!r} — describe what the " - f"product does, not what they have" - ) - elif not cites_fb: - errors.append( - f"block {i} claims the prospect's own channel ({phrase!r}) " - f"without citing the observed fb_* signal" - ) return errors diff --git a/prospector/cli.py b/prospector/cli.py index c06f3a1..68d7d93 100644 --- a/prospector/cli.py +++ b/prospector/cli.py @@ -201,96 +201,6 @@ def _print_send_report(report) -> None: typer.echo(f" {r.slug.ljust(width)} {r.outcome.value:12} {r.detail}") -@app.command() -def dm( - real: bool = typer.Option(False, "--send", help="Assist a real send (clipboard + browser + confirm). Default is preview."), - limit: int = typer.Option(None, "--limit", help="Walk at most N approved messenger notes this run"), - vault: Path = typer.Option(None, "--vault", help="Vault folder (default: Vault/Outreach)"), - yes: bool = typer.Option(False, "--yes", help="Skip the one upfront confirmation (per-note confirms still apply)"), -): - """Assist manual Messenger delivery of approved messenger-channel notes - (Constitution v6.0.0 Principle I). The tool copies the draft to your - clipboard and opens the company's Facebook page in YOUR browser; you send it - yourself and confirm. Preview by default — nothing is opened or recorded.""" - import webbrowser - - from prospector import clipboard - from prospector.dm import run_dm - from prospector.models import DmCandidate - - settings = load_settings() - target = vault or settings.vault_dir - if not target.is_dir(): - typer.echo(f"error: vault folder not found: {target}", err=True) - raise typer.Exit(1) - - if real and not yes: - preview = run_dm(settings, vault_dir=target, dry_run=True, limit=limit) - typer.echo( - f"About to walk {preview.would_deliver} approved messenger note(s); " - f"you will confirm each send yourself." - ) - if not typer.confirm("Proceed?"): - typer.echo("aborted; nothing done.") - raise typer.Exit(0) - - def _confirm(cand: "DmCandidate", *, copied: bool, opened: bool) -> str: - typer.echo("") - typer.echo(f"── {cand.company} ({cand.slug}) ──") - if opened: - typer.echo(f" opened in your browser: {cand.facebook_url}") - elif cand.facebook_url: - typer.echo(f" could not open a browser — open this yourself: {cand.facebook_url}") - else: - typer.echo(" no Facebook link on file — locate the company manually") - if copied: - typer.echo(" draft copied to your clipboard — paste it into Messenger") - else: - typer.echo(" (clipboard unavailable — copy the message below)") - typer.echo("") - typer.echo(cand.body or "") - answer = typer.prompt(" Did you send this message? [y/N/q]", default="n", show_default=False) - return answer - - report = run_dm( - settings, - vault_dir=target, - dry_run=not real, - limit=limit, - confirm=_confirm, - opener=webbrowser.open, - copier=clipboard.copy_to_clipboard, - ) - _print_dm_report(report, target) - - -def _print_dm_report(report, vault_dir) -> None: - from prospector.models import DmOutcome - - mode = "ASSISTED SEND" if not report.dry_run else "WOULD WALK (preview)" - typer.echo(f"\nProspector dm [{mode}]") - typer.echo(f" vault: {vault_dir}") - head = "delivered" if not report.dry_run else "to walk" - head_n = report.delivered if not report.dry_run else report.would_deliver - typer.echo( - f" {head}: {head_n} skipped (already): {report.skipped_already} " - f"skipped (not sendable): {report.skipped_not_sendable} declined: {report.declined}" - ) - interesting = { - DmOutcome.DELIVERED, - DmOutcome.WOULD_DELIVER, - DmOutcome.SKIPPED_NOT_SENDABLE, - DmOutcome.SKIPPED_ALREADY_SENT, - } - rows = [r for r in report.results if r.outcome in interesting] - if rows: - typer.echo("") - width = max(len(r.slug) for r in rows) - for r in rows: - detail = r.facebook_url or r.detail - typer.echo(f" {r.slug.ljust(width)} {r.outcome.value:20} {detail}") - - def _print_sourcing_summary(summary) -> None: typer.echo(f"\nProspector source: {summary.metros_covered}/{summary.metros_total} metros covered") typer.echo(f" queries used: {summary.queries_used}/{summary.query_budget}") @@ -315,8 +225,18 @@ def _print_summary(summary: RunSummary) -> None: f" named high: {summary.named_high} medium: {summary.named_medium} none: {summary.named_none}" ) typer.echo( - f" messenger: {summary.messenger} duplicates: {summary.duplicates} needs review: {summary.needs_review}" + f" duplicates: {summary.duplicates} needs review: {summary.needs_review}" + ) + # 008 FR-010: no company is silently bucketed — each is either processed or + # named here as unreachable. + typer.echo( + f" email recovered: {summary.email_recovered} no email found: {summary.no_email_skipped}" ) + if summary.skipped_companies: + typer.echo("\n no email found:") + width = max(len(name) for name, _ in summary.skipped_companies) + for name, reason in summary.skipped_companies: + typer.echo(f" {name.ljust(width)} {reason}") typer.echo( f" drafted by agent: {summary.drafted_agent} by template: {summary.drafted_template}" ) diff --git a/prospector/clipboard.py b/prospector/clipboard.py deleted file mode 100644 index 00d40fc..0000000 --- a/prospector/clipboard.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Best-effort OS clipboard write for assisted-manual Messenger delivery (007). - -Constitution VI (smallest viable build) settles the stack: no third-party -clipboard dependency. We shell out to whatever native copier the platform -provides. This is deliberately best-effort — `copy_to_clipboard` NEVER raises and -returns False when no copier works, so the delivery loop degrades to showing the -draft in the terminal for manual copy rather than aborting (research.md R1, -spec Edge Cases). -""" - -import shutil -import subprocess - -# (executable, argv-after-exe) — first one present on PATH wins. -_COPIERS: tuple[tuple[str, list[str]], ...] = ( - ("clip.exe", []), # WSL / Windows - ("pbcopy", []), # macOS - ("wl-copy", []), # Wayland - ("xclip", ["-selection", "clipboard"]), # X11 - ("xsel", ["--clipboard", "--input"]), # X11 alt -) - - -def copy_to_clipboard(text: str) -> bool: - """Copy `text` to the OS clipboard. Return True on success, False if no - copier is available or the copy failed. Never raises.""" - for exe, args in _COPIERS: - if shutil.which(exe) is None: - continue - try: - proc = subprocess.run( - [exe, *args], - input=text.encode("utf-8"), - timeout=10, - check=False, - ) - except (OSError, subprocess.SubprocessError): - continue - if proc.returncode == 0: - return True - return False diff --git a/prospector/config.py b/prospector/config.py index 0b26b74..a84afca 100644 --- a/prospector/config.py +++ b/prospector/config.py @@ -17,7 +17,6 @@ DEFAULT_SEND_CAPS = "15,30,60,100" # weekly ramp; last value applies to week 4+ DEFAULT_SEND_DELAY = "30,90" # randomized seconds between real sends: min,max DEFAULT_LEDGER = "send_ledger.jsonl" -DEFAULT_DM_LEDGER = "dm_ledger.jsonl" # assisted-manual Messenger deliveries (007) DEFAULT_GMAIL_CLIENT = "secrets/gmail_client_secret.json" DEFAULT_GMAIL_TOKEN = "secrets/gmail_token.json" @@ -48,7 +47,6 @@ class Settings: send_caps: list[int] = field(default_factory=lambda: [15, 30, 60, 100]) send_delay: tuple[int, int] = (30, 90) ledger_path: Path = field(default_factory=lambda: Path(DEFAULT_LEDGER)) - dm_ledger_path: Path = field(default_factory=lambda: Path(DEFAULT_DM_LEDGER)) gmail_client_secret_path: Path = field( default_factory=lambda: Path(DEFAULT_GMAIL_CLIENT) ) @@ -198,7 +196,6 @@ def load_settings(env_file: str | Path = ".env") -> Settings: send_caps=_parse_caps(os.environ.get("PROSPECTOR_SEND_CAPS") or DEFAULT_SEND_CAPS), send_delay=_parse_delay(os.environ.get("PROSPECTOR_SEND_DELAY") or DEFAULT_SEND_DELAY), ledger_path=Path(os.environ.get("PROSPECTOR_LEDGER") or DEFAULT_LEDGER), - dm_ledger_path=Path(os.environ.get("PROSPECTOR_DM_LEDGER") or DEFAULT_DM_LEDGER), gmail_client_secret_path=Path( os.environ.get("PROSPECTOR_GMAIL_CLIENT") or DEFAULT_GMAIL_CLIENT ), diff --git a/prospector/dm.py b/prospector/dm.py deleted file mode 100644 index 725c452..0000000 --- a/prospector/dm.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Assisted-manual Messenger delivery (007; Constitution v6.0.0, Principle I -"Assisted-manual Messenger delivery"). - -Walks `approved` messenger-channel notes one at a time. In real mode it copies -the deterministic draft to the operator's clipboard and opens the note's -`facebook_url` in the operator's OWN browser, then — only after a per-note human -confirmation — records a human-performed delivery in a dedicated ledger and flips -the note `approved → sent`. Preview (dry-run) is the default and mutates nothing. - -The tool NEVER transmits a Messenger message, NEVER automates a browser, and -NEVER issues an HTTP request to Facebook: `webbrowser.open` merely hands the URL -to the operator's browser (Principle II clarification). No LLM call occurs; the -body delivered is the deterministic template verbatim. -""" - -import webbrowser -from datetime import date, datetime -from pathlib import Path - -from prospector import clipboard, ledger, vault -from prospector.config import Settings -from prospector.models import ( - Channel, - DmCandidate, - DmOutcome, - DmResult, - DmRunReport, - LedgerRecord, -) - - -def _unquote(value: str) -> str: - """Strip the surrounding double-quotes _yaml_value adds to values containing - a colon (full URLs like https://... are quoted on write).""" - value = value.strip() - if len(value) >= 2 and value[0] == '"' and value[-1] == '"': - value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\") - return value - - -def collect_dm_candidates( - vault_dir: str | Path, -) -> tuple[list[DmCandidate], list[DmResult]]: - """Return (deliverable approved messenger candidates, skip-results for - approved-but-unsendable). Non-approved and non-messenger notes are ignored - silently (not skip events), mirroring send.collect_candidates.""" - vault_dir = Path(vault_dir) - candidates: list[DmCandidate] = [] - skipped: list[DmResult] = [] - if not vault_dir.is_dir(): - return candidates, skipped - for path in sorted(vault_dir.glob("*.md")): - if path.name == "_Dashboard.md": - continue - text = path.read_text(encoding="utf-8") - fm, _ = vault.parse_note(text) - if fm.get("status") != "approved": - continue # only approved notes are considered - if (fm.get("channel") or "email") != Channel.MESSENGER.value: - continue # email-channel notes belong to the `send` path - fb = _unquote(fm.get("facebook_url") or "") - cand = DmCandidate( - slug=path.stem, - company=fm.get("company") or path.stem, - facebook_url=fb or None, - body=vault.parse_messenger_body(text), - note_path=path, - approved_at=path.stat().st_mtime, - ) - error = cand.sendable_error() - if error: - skipped.append( - DmResult(cand.slug, cand.facebook_url, DmOutcome.SKIPPED_NOT_SENDABLE, error) - ) - else: - candidates.append(cand) - return candidates, skipped - - -def already_delivered_slugs(dm_ledger_path: str | Path) -> set[str]: - """Slugs already recorded in the DM ledger. Every record in this ledger is a - confirmed human-performed delivery, so (unlike ledger.already_sent, which - filters result=='sent') we key on slug across all records (schema: - result=='dm_sent_manual').""" - return {r.slug for r in ledger.read_all(dm_ledger_path) if r.slug} - - -def _ts_now() -> str: - return datetime.now().isoformat(timespec="seconds") - - -def _default_confirm(cand: DmCandidate, *, copied: bool, opened: bool) -> str: - """Overridden by the CLI (which prints + prompts) and by tests. The default - is conservative: decline, so a mis-wired caller never records a send.""" - return "n" - - -def run_dm( - settings: Settings, - *, - vault_dir: str | Path | None = None, - dry_run: bool = True, - limit: int | None = None, - confirm=_default_confirm, - opener=webbrowser.open, - copier=clipboard.copy_to_clipboard, - today: date | None = None, -) -> DmRunReport: - """Core assisted-manual Messenger walk. In dry-run nothing is copied, opened, - ledgered, or flipped. `confirm(cand, copied=, opened=)` returns 'y'/'n'/'q'; - `opener`/`copier` are injectable so tests never open a browser or clipboard.""" - today = today or date.today() - vault_dir = Path(vault_dir or settings.vault_dir) - dm_ledger_path = settings.dm_ledger_path - - report = DmRunReport(dry_run=dry_run) - - candidates, skipped = collect_dm_candidates(vault_dir) - report.results.extend(skipped) - - # Drop already-delivered (DM ledger) and collapse duplicate targets in-run. - delivered = already_delivered_slugs(dm_ledger_path) - seen_targets: set[str] = set() - eligible: list[DmCandidate] = [] - for cand in candidates: - if cand.slug in delivered: - report.results.append( - DmResult(cand.slug, cand.facebook_url, DmOutcome.SKIPPED_ALREADY_SENT, "already in DM ledger") - ) - continue - target = (cand.facebook_url or "").strip().lower() - if target and target in seen_targets: - report.results.append( - DmResult(cand.slug, cand.facebook_url, DmOutcome.SKIPPED_ALREADY_SENT, "duplicate Facebook target in this run") - ) - continue - if target: - seen_targets.add(target) - eligible.append(cand) - - eligible.sort(key=lambda c: c.approved_at) # oldest-approved-first - if limit is not None: - eligible = eligible[: max(0, limit)] - - if dry_run: - for cand in eligible: - report.results.append( - DmResult(cand.slug, cand.facebook_url, DmOutcome.WOULD_DELIVER, "would deliver (preview)") - ) - return report - - for cand in eligible: - copied = copier(cand.body or "") - opened = bool(cand.facebook_url) and bool(opener(cand.facebook_url)) - decision = (confirm(cand, copied=copied, opened=opened) or "n").strip().lower() - if decision == "q": - break - if decision != "y": - report.results.append( - DmResult(cand.slug, cand.facebook_url, DmOutcome.DECLINED, "operator declined") - ) - continue - # Confirmed: ledger first (source of truth), THEN flip status. - ledger.append( - dm_ledger_path, - LedgerRecord( - ts=_ts_now(), slug=cand.slug, recipient=cand.facebook_url or "", - company=cand.company, message_id=None, result="dm_sent_manual", - error=None, from_account="", - ), - ) - vault.set_status( - cand.note_path, - "sent", - f"{today.isoformat()} messenger delivered manually (assisted)", - ) - report.results.append( - DmResult(cand.slug, cand.facebook_url, DmOutcome.DELIVERED, "delivered (manual)") - ) - - return report diff --git a/prospector/draft.py b/prospector/draft.py index b74f545..de387a1 100644 --- a/prospector/draft.py +++ b/prospector/draft.py @@ -41,9 +41,8 @@ # Offer (PRODUCT.md §8, rev. 2 2026-07-17, operator-supplied copy): the # Omniveer Duct Lead Qualifier, free 10-day pilot for 5 duct-cleaning # companies. The copy is CHANNEL-NEUTRAL — it makes no claim about the -# prospect's channels (or Facebook at all), so both fb_signal levels share -# this one body and the §7.5 honesty gate is trivially satisfied (Principle V, -# v4.0.1: defaulting down is always allowed; the signal is still recorded). +# prospect's channels (or Facebook at all), so it asserts nothing that would +# need evidence to support (Constitution v7.0.0, Principle IV). # Ends with the single promotional link (the product page carries the demo) # and a low-pressure close — no urgency, no guarantees. "Book a demo through # the page" refers to the page already linked: no second URL. @@ -71,15 +70,6 @@ {signature}""" ) -MESSENGER_DM_TEMPLATE = ( - "Hey! I'm giving 5 duct cleaning companies a free 10-day pilot of the " - "Omniveer Duct Lead Qualifier. It answers your page messages in seconds, " - "day or night. It checks customers are real{city_clause}, quotes your real " - "prices, and books them into open slots on your calendar. You just get the " - "finished lead. I set it all up for you. Want one of the 5 spots? " - "(See it working: " + PRODUCT_URL + ")" -) - # Invariant template prose that must survive assembly byte-for-byte (FR-015) EMAIL_INVARIANTS = ( EMAIL_OFFER_PARAGRAPH, @@ -88,13 +78,7 @@ "Reply to this email if you'd like one of the five pilot spots, or book a demo through the page.", ) -MESSENGER_INVARIANTS = ( - "Hey! I'm giving 5 duct cleaning companies a free 10-day pilot of the Omniveer Duct Lead Qualifier.", - "quotes your real prices, and books them into open slots on your calendar.", - "Want one of the 5 spots? (See it working: " + PRODUCT_URL + ")", -) - -# Ad-running is never observable and never claimed (Constitution V) +# Ad-running is never observable and never claimed (Constitution IV, v7.0.0) AD_CLAIM_SUBSTRINGS = ("your ads", "ad campaign", "running ads", "advertis", "your facebook ads", "ad spend") SYSTEM_PROMPT = """You fill two slots for a locked outreach email template. Reply with a JSON object only: @@ -159,12 +143,10 @@ def request_slots(prospect: Prospect, settings: Settings) -> dict: { "company": prospect.company.company, "name_or_team": expected_greeting(prospect), - "channel": prospect.company.channel.value, + "channel": prospect.company.channel, "hook": prospect.research.hook or "", "city": prospect.research.city or prospect.company.city or "", "angle": prospect.angle, - "fb_signal": prospect.fb_signal.value, - "variant": prospect.variant.value, "is_generic_inbox": is_generic_inbox(prospect.company.email), } ), @@ -191,9 +173,9 @@ def request_slots(prospect: Prospect, settings: Settings) -> dict: def assemble_email(prospect: Prospect, slots: dict) -> Draft: """Deterministic assembly from template constants + validated slot fills. - Rev. 2 (2026-07-17): one channel-neutral template for every fb_signal - level — the copy makes no claims about the prospect's channels, so the - §7.5 gate is trivially satisfied (the signal is still recorded).""" + Rev. 2 (2026-07-17): one channel-neutral template — the copy makes no + claims about the prospect's channels, so nothing in it requires evidence + (Constitution v7.0.0, Principle IV).""" greeting = str(slots.get("greeting_name", "")).strip() subject_company = str(slots.get("subject_company", "")).strip() or prospect.company.company @@ -203,28 +185,6 @@ def assemble_email(prospect: Prospect, slots: dict) -> Draft: return Draft(subject=subject, body=body, model="", validated=not errors, validation_errors=errors) -def build_messenger_draft(prospect: Prospect) -> Draft: - """Messenger DM (§8): fully deterministic — the only slot is the city, - which comes from recorded research. No LLM call needed.""" - city = prospect.research.city or prospect.company.city - city_clause = f", around {city}" if city else "" - body = MESSENGER_DM_TEMPLATE.format(city_clause=city_clause) - errors: list[str] = [] - for line in MESSENGER_INVARIANTS: - if line not in body: - errors.append(f"template prose altered: missing {line[:40]!r}...") - for banned in AD_CLAIM_SUBSTRINGS: - if banned in body.lower(): - errors.append(f"ad-running claim detected: {banned!r}") - # Link strategy (005): one promotional link — the product page — and never - # LinkedIn in the pitch. Same rules as the email validator. - if body.count("http") != 1 or PRODUCT_URL not in body: - errors.append("body must carry exactly one promotional link (the product page)") - if "linkedin.com" in body.lower(): - errors.append("LinkedIn link may not appear in the pitch") - return Draft(subject=None, body=body, model="deterministic", validated=not errors, validation_errors=errors) - - def validate_email_draft(subject: str, body: str, prospect: Prospect, slots: dict) -> list[str]: errors: list[str] = [] lowered = body.lower() diff --git a/prospector/extract.py b/prospector/extract.py index 34d3e7d..71674f7 100644 --- a/prospector/extract.py +++ b/prospector/extract.py @@ -197,48 +197,6 @@ def _find_hook(page: PageContent, text: str, outcome: ExtractOutcome) -> None: ) -FB_HOST_RE = re.compile(r"(?:^|//|\.)(?:facebook\.com|fb\.com|fb\.me)/", re.IGNORECASE) -FB_WIDGET_MARKERS = ("connect.facebook.net", "xfbml.customerchat", "fb-customerchat", "fb-messenger-checkbox") -FB_EMBED_MARKERS = ("facebook.com/plugins", "facebook.com/v2.0/plugins") - - -def detect_fb_evidence(pages: list[PageContent]) -> list[Evidence]: - """Open-web Facebook-usage signals (§7.5), string-detected from HTML we - already fetched. Nothing here ever fetches, executes, or renders anything - from a Facebook host (Constitution II). One Evidence per signal kind.""" - found: dict[EvidenceKind, Evidence] = {} - for page in pages: - tree = HTMLParser(page.html) - - if EvidenceKind.FB_LINK not in found: - for node in tree.css("a[href]"): - href = node.attributes.get("href") or "" - if FB_HOST_RE.search(href) and not any(m in href for m in FB_EMBED_MARKERS): - found[EvidenceKind.FB_LINK] = Evidence( - kind=EvidenceKind.FB_LINK, value=href, source=page.url, - excerpt="site links to a Facebook page", - ) - break - - if EvidenceKind.FB_EMBED not in found: - for node in tree.css("iframe[src]"): - src = node.attributes.get("src") or "" - if any(m in src for m in FB_EMBED_MARKERS): - found[EvidenceKind.FB_EMBED] = Evidence( - kind=EvidenceKind.FB_EMBED, value=src, source=page.url, - excerpt="Facebook page embedded on site (plugin iframe)", - ) - break - - if EvidenceKind.FB_WIDGET not in found and any(m in page.html for m in FB_WIDGET_MARKERS): - marker = next(m for m in FB_WIDGET_MARKERS if m in page.html) - found[EvidenceKind.FB_WIDGET] = Evidence( - kind=EvidenceKind.FB_WIDGET, value=marker, source=page.url, - excerpt="Messenger/FB chat widget markup on site", - ) - return list(found.values()) - - def _excerpt(text: str, position: int, width: int = 100) -> str: start = max(0, position - width // 2) return " ".join(text[start : position + width].split())[:200] diff --git a/prospector/ingest.py b/prospector/ingest.py index d210390..1ac1a4a 100644 --- a/prospector/ingest.py +++ b/prospector/ingest.py @@ -1,7 +1,10 @@ """Input parsing: CSV or markdown table -> normalized Company rows + warnings. -Bucketing (contracts/cli.md): valid email -> email channel; blank / "messenger" / -Facebook URL -> messenger; anything else -> messenger + needs_review. +Address classification (008 contracts/cli.md): a valid email is used as-is. +Blank / "messenger" / a Facebook URL / anything unparseable all mean NO ADDRESS +SUPPLIED — the company goes to email recovery in the pipeline and, if no address +is found there, is skipped and reported. Nothing is bucketed to another channel +(Constitution v7.0.0, Principle I). """ import csv @@ -10,7 +13,7 @@ from functools import lru_cache from pathlib import Path -from prospector.models import Channel, Company +from prospector.models import Company KNOWN_COLUMNS = ("company", "email", "website", "facebook_url", "city", "owner_name", "notes") REQUIRED_COLUMNS = ("company", "email") @@ -76,9 +79,12 @@ def _build_companies(header: list[str], rows: list[list[str]]) -> tuple[list[Com warnings.append(f"row {row_num}: missing company name, skipped") continue raw_email = values.get("email", "") - email, channel, reason, needs_review = _bucket_email(raw_email) + email, reason, needs_review = _classify_email(raw_email) if needs_review: - warnings.append(f"row {row_num}: unrecognized email field {raw_email!r}, routed to messenger and flagged") + warnings.append( + f"row {row_num}: unrecognized email field {raw_email!r}, " + "treated as no address supplied and flagged for review" + ) companies.append( Company( company=company_name, @@ -90,7 +96,6 @@ def _build_companies(header: list[str], rows: list[list[str]]) -> tuple[list[Com owner_name=values.get("owner_name") or None, notes=values.get("notes") or None, row_num=row_num, - channel=channel, bucket_reason=reason, needs_review=needs_review, ) @@ -98,18 +103,19 @@ def _build_companies(header: list[str], rows: list[list[str]]) -> tuple[list[Com return companies, warnings -def _bucket_email(raw: str) -> tuple[str | None, Channel, str | None, bool]: - """Returns (email, channel, bucket_reason, needs_review).""" +def _classify_email(raw: str) -> tuple[str | None, str | None, bool]: + """Returns (email, reason, needs_review). A None email means no address was + supplied; the pipeline attempts recovery before giving up (008 FR-003).""" value = raw.strip() if not value: - return None, Channel.MESSENGER, "blank email", False + return None, "blank email", False if value.lower() == "messenger": - return None, Channel.MESSENGER, "marked messenger", False + return None, "no email supplied (marked messenger)", False if FB_URL_RE.search(value): - return None, Channel.MESSENGER, "facebook url in email field", False + return None, "no email supplied (facebook url in email field)", False if EMAIL_RE.fullmatch(value): - return value.lower(), Channel.EMAIL, None, False - return None, Channel.MESSENGER, f"unrecognized email field: {value!r}", True + return value.lower(), None, False + return None, f"unrecognized email field: {value!r}", True @lru_cache(maxsize=1) diff --git a/prospector/models.py b/prospector/models.py index 98ce182..d324374 100644 --- a/prospector/models.py +++ b/prospector/models.py @@ -5,29 +5,12 @@ from pathlib import Path -class Channel(str, Enum): - EMAIL = "email" - MESSENGER = "messenger" - - class Confidence(str, Enum): HIGH = "high" MEDIUM = "medium" NONE = "none" -class FbSignal(str, Enum): - STRONG = "strong" - WEAK = "weak" - NONE = "none" - - -class Variant(str, Enum): - EMAIL_FB = "email_fb" - EMAIL_AGNOSTIC = "email_agnostic" - MESSENGER_DM = "messenger_dm" - - class EvidenceKind(str, Enum): OWNER_TEXT = "owner_text" ABOUT_PAGE = "about_page" @@ -36,11 +19,7 @@ class EvidenceKind(str, Enum): EMAIL_PATTERN = "email_pattern" INPUT = "input" HUNTER = "hunter" - FB_LINK = "fb_link" - FB_EMBED = "fb_embed" - FB_WIDGET = "fb_widget" - FB_SEARCH_ACTIVE = "fb_search_active" - FB_URL_INPUT = "fb_url_input" + EMAIL_PUBLISHED = "email_published" # 008: address found on the company's own page CITY_SOURCE = "city_source" HOOK_SOURCE = "hook_source" @@ -56,7 +35,7 @@ class Company: owner_name: str | None = None notes: str | None = None row_num: int = 0 - channel: Channel = Channel.EMAIL + channel: str = "email" # 008: email is the only channel bucket_reason: str | None = None duplicate_of: str | None = None slug: str = "" @@ -76,7 +55,6 @@ class ResearchResult: website: str | None = None gbp_city: str | None = None name_evidence: list[Evidence] = field(default_factory=list) - fb_evidence: list[Evidence] = field(default_factory=list) hook: str | None = None hook_evidence: Evidence | None = None city: str | None = None @@ -91,8 +69,6 @@ class Prospect: name_confidence: Confidence = Confidence.NONE name_used: str = "team" name_candidate: str | None = None - fb_signal: FbSignal = FbSignal.NONE - variant: Variant = Variant.EMAIL_AGNOSTIC angle: str = "offer-led" needs_review: bool = False @@ -181,7 +157,7 @@ class SendCandidate: def sendable_error(self) -> str | None: """Return a reason string if this candidate is NOT sendable, else None.""" - if self.channel != Channel.EMAIL.value: + if self.channel != "email": return "not an email-channel note" if not self.recipient or "@" not in self.recipient or "." not in self.recipient.split("@")[-1]: return "missing or invalid email address" @@ -245,79 +221,6 @@ def skipped(self) -> int: ) -class DmOutcome(str, Enum): - """Per-note result of the assisted-manual Messenger walk (007 data-model.md).""" - - DELIVERED = "delivered" # human confirmed a manual send; ledgered + status flipped - SKIPPED_NOT_SENDABLE = "skipped_not_sendable" # approved messenger note with no body - SKIPPED_ALREADY_SENT = "skipped_already_sent" # in DM ledger, or dup target in-run - DECLINED = "declined" # operator chose not to confirm; note stays approved - WOULD_DELIVER = "would_deliver" # preview/dry-run: eligible, nothing done - - -@dataclass -class DmCandidate: - """A deliverable view of one approved messenger-channel note (007 data-model.md). - - Distinct from SendCandidate: there is no email/subject, and a missing - facebook_url is NOT an error — it is delivered with a 'no link' notice (FR-019).""" - - slug: str - company: str - facebook_url: str | None - body: str | None - note_path: "Path" - approved_at: float = 0.0 # note mtime; oldest-approved-first ordering - - def sendable_error(self) -> str | None: - """Reason this note is NOT deliverable, else None. Channel is filtered - during collection; here only the body must be present (FR-010).""" - if not self.body or not self.body.strip(): - return "draft has no body" - return None - - -@dataclass -class DmResult: - slug: str - facebook_url: str | None - outcome: DmOutcome - detail: str = "" - - -@dataclass -class DmRunReport: - dry_run: bool = True - results: list["DmResult"] = field(default_factory=list) - - def count(self, outcome: DmOutcome) -> int: - return sum(1 for r in self.results if r.outcome == outcome) - - @property - def delivered(self) -> int: - return self.count(DmOutcome.DELIVERED) - - @property - def would_deliver(self) -> int: - return self.count(DmOutcome.WOULD_DELIVER) - - @property - def declined(self) -> int: - return self.count(DmOutcome.DECLINED) - - @property - def skipped_not_sendable(self) -> int: - return self.count(DmOutcome.SKIPPED_NOT_SENDABLE) - - @property - def skipped_already(self) -> int: - return self.count(DmOutcome.SKIPPED_ALREADY_SENT) - - @property - def skipped(self) -> int: - return self.skipped_not_sendable + self.skipped_already - - @dataclass class RunSummary: total: int = 0 @@ -326,15 +229,21 @@ class RunSummary: named_high: int = 0 named_medium: int = 0 named_none: int = 0 - messenger: int = 0 duplicates: int = 0 needs_review: int = 0 per_company: list[tuple[str, str, str]] = field(default_factory=list) # (slug, outcome, detail) + # 008 email recovery (FR-010): companies with no supplied address either gain + # one from their own pages or are skipped and named — there is no bucket. + email_recovered: int = 0 + no_email_skipped: int = 0 + skipped_companies: list[tuple[str, str]] = field(default_factory=list) # (company, reason) # 006 drafting-path visibility (FR-320). These need NOT sum to `processed`: - # messenger notes, --no-llm runs, and frozen notes are drafted by neither path. + # --no-llm runs and frozen notes are drafted by neither path. drafted_agent: int = 0 drafted_template: int = 0 fallback_reasons: list[tuple[str, str]] = field(default_factory=list) # (slug, reason) def reconciles(self) -> bool: - return self.total == self.processed + self.failed + # 008: skipped-for-no-email is a third terminal outcome alongside + # processed and failed. + return self.total == self.processed + self.failed + self.no_email_skipped diff --git a/prospector/pipeline.py b/prospector/pipeline.py index 1311f57..02e626a 100644 --- a/prospector/pipeline.py +++ b/prospector/pipeline.py @@ -14,17 +14,14 @@ from prospector.config import Settings from prospector.fetch import Fetcher, FetchError from prospector.models import ( - Channel, Company, Confidence, Draft, Evidence, EvidenceKind, - FbSignal, Prospect, ResearchResult, RunSummary, - Variant, ) @@ -115,15 +112,11 @@ def _process_company( # FR-326: approved/sent copy is never regenerated, so no drafting call # is made at all. ## Research still refreshes from the work above. return prospect, draft - if company.channel is Channel.EMAIL: - # 006: agent path with automatic template fallback. Never raises, so - # the batch cannot be aborted by a drafting failure (FR-318). - draft = agent_draft.draft_email(prospect, settings, instructions) - if draft.validation_errors: - research.failures.extend(draft.validation_errors) - else: - # FR-308: messenger DMs stay fully deterministic — no model call. - draft = drafting.build_messenger_draft(prospect) + # 006: agent path with automatic template fallback. Never raises, so the + # batch cannot be aborted by a drafting failure (FR-318). + draft = agent_draft.draft_email(prospect, settings, instructions) + if draft.validation_errors: + research.failures.extend(draft.validation_errors) if draft is not None and not draft.validated: prospect.needs_review = True return prospect, draft @@ -153,21 +146,9 @@ def _research(company: Company, settings: Settings, fetcher: Fetcher, *, verbose research.hook = outcome.hook research.hook_evidence = outcome.hook_evidence research.city = outcome.city - research.fb_evidence.extend(extracting.detect_fb_evidence(pages)) - search_evidence = resolve.fb_search_evidence(company, fetcher, info) - # fb_search_evidence appended to info; union with page-fetch entries, order-stable research.sources_consulted = list(dict.fromkeys(research.sources_consulted + info.sources_consulted)) research.failures = list(dict.fromkeys(research.failures + info.failures)) - if search_evidence: - research.fb_evidence.append(search_evidence) - if company.facebook_url: - research.fb_evidence.append( - Evidence( - kind=EvidenceKind.FB_URL_INPUT, value=company.facebook_url, - source=f"input row {company.row_num}", excerpt="facebook_url provided as input (never fetched)", - ) - ) research.city = company.city or research.city or research.gbp_city if not research.hook and research.city: @@ -194,7 +175,7 @@ def _fetch_page(url: str, fetcher: Fetcher, research: ResearchResult, *, check_r def _score(company: Company, research: ResearchResult, settings: Settings) -> Prospect: - """Name confidence per §7 (score.py). fb_signal §7.5 rules arrive in US3 (T018).""" + """Name confidence scoring (score.py).""" prospect = Prospect(company=company, research=research) email_inference = enrich.infer_from_email(company.email) @@ -213,8 +194,6 @@ def _score(company: Company, research: ResearchResult, settings: Settings) -> Pr research.name_evidence.append(hunter_inference.evidence) score.apply_name_scoring(prospect, email_inference, hunter_inference) - prospect.fb_signal = score.classify_fb_signal(research.fb_evidence) - prospect.variant = score.select_variant(company.channel, prospect.fb_signal) if research.failures and not research.website: prospect.needs_review = True return prospect @@ -223,7 +202,7 @@ def _score(company: Company, research: ResearchResult, settings: Settings) -> Pr def _write( prospect: Prospect, draft: Draft | None, vault_dir: Path, *, no_llm: bool, frozen: bool = False ) -> tuple[str, str]: - draft_md = vault.draft_markdown_for(draft, prospect, no_llm=no_llm and prospect.company.channel is Channel.EMAIL) + draft_md = vault.draft_markdown_for(draft, prospect, no_llm=no_llm) research_md = vault.build_research_markdown(prospect) # Rebuilt rather than carried: id assignment is deterministic over the same # research, so this reproduces exactly what the validator resolved against. @@ -237,7 +216,7 @@ def _write( if frozen: detail = f"draft frozen (approved/sent), research refreshed, note {result}" elif draft is not None and draft.validated: - detail = f"drafted ({prospect.variant.value}), note {result}" + detail = f"drafted ({draft.source}), note {result}" elif draft is not None: detail = f"draft failed validation, note {result}" else: @@ -248,14 +227,12 @@ def _write( def _count_drafting_path(summary: RunSummary, slug: str, draft: Draft | None) -> None: """Drafting-path visibility (FR-320). - Only email-channel drafts have a path: messenger DMs are deterministic and - frozen/--no-llm companies are not drafted at all, so neither is counted. + Frozen and --no-llm companies are not drafted at all, so they are not + counted. The fallback REASON is recorded, not just the count — a silent 40% fallback rate would otherwise look like slightly boring copy rather than a break.""" if draft is None or draft.source not in ("agent", "template"): return - if draft.model == "deterministic": # messenger DM - return if draft.source == "agent": summary.drafted_agent += 1 return @@ -275,8 +252,6 @@ def _count(summary: RunSummary, prospect: Prospect) -> None: summary.named_medium += 1 else: summary.named_none += 1 - if prospect.company.channel is Channel.MESSENGER: - summary.messenger += 1 if prospect.company.duplicate_of: summary.duplicates += 1 if prospect.needs_review or prospect.company.needs_review: diff --git a/prospector/resolve.py b/prospector/resolve.py index 766cc04..d021de4 100644 --- a/prospector/resolve.py +++ b/prospector/resolve.py @@ -14,7 +14,7 @@ from prospector.config import Settings from prospector.fetch import FetchError, Fetcher, is_blocked_host -from prospector.models import Company, Evidence, EvidenceKind +from prospector.models import Company PLACES_URL = "https://places.googleapis.com/v1/places:searchText" DDG_URL = "https://html.duckduckgo.com/html/" @@ -126,59 +126,6 @@ def _ddg_result_urls(html: str) -> list[str]: return urls -# Cues in a search snippet suggesting an actively used FB page (§7.5). -FB_ACTIVITY_CUES = ( - "review", "rating", "followers", "likes", "posted", "hours ago", - "days ago", "open now", "updated", -) - - -def fb_search_evidence(company: Company, fetcher: Fetcher, info: ResolveInfo) -> Evidence | None: - """DDG search for the company's Facebook presence. Only search-result - snippets are read — the Facebook page itself is never fetched - (Constitution II). Returns Evidence only for an active-looking page; - anything less defaults down to nothing (§7.5).""" - query = f'"{company.company}" facebook' - url = f"{DDG_URL}?q={quote_plus(query)}" - info.sources_consulted.append(f"ddg-search: {query}") - try: - response = fetcher.fetch(url) - except FetchError as exc: - info.failures.append(f"ddg fb search failed: {exc}") - return None - for result_url, snippet in _ddg_results_with_snippets(response.text): - host = (urlparse(result_url).hostname or "").lower() - if not (host == "facebook.com" or host.endswith(".facebook.com")): - continue - haystack = snippet.lower() - if any(cue in haystack for cue in FB_ACTIVITY_CUES): - return Evidence( - kind=EvidenceKind.FB_SEARCH_ACTIVE, value=result_url, - source=f"ddg-search: {query}", excerpt=snippet[:200], - ) - return None # FB page found but no activity cues: default down - return None - - -def _ddg_results_with_snippets(html: str) -> list[tuple[str, str]]: - tree = HTMLParser(html) - titles = tree.css("a.result__a") - snippets = tree.css("a.result__snippet, div.result__snippet") - results: list[tuple[str, str]] = [] - for i, node in enumerate(titles): - href = node.attributes.get("href") or "" - if href.startswith("//"): - href = "https:" + href - parsed = urlparse(href) - if parsed.hostname and parsed.hostname.endswith("duckduckgo.com"): - href = parse_qs(parsed.query).get("uddg", [""])[0] - snippet = snippets[i].text(separator=" ").strip() if i < len(snippets) else "" - title = node.text(separator=" ").strip() - if href: - results.append((href, f"{title} {snippet}".strip())) - return results - - def _is_non_company(url: str) -> bool: host = (urlparse(url).hostname or "").lower() return any(host == d or host.endswith("." + d) for d in NON_COMPANY_DOMAINS) diff --git a/prospector/score.py b/prospector/score.py index c16ebd6..279a06e 100644 --- a/prospector/score.py +++ b/prospector/score.py @@ -11,49 +11,20 @@ high -> greet by first name. medium -> "team" + name_candidate + needs_review. none -> "team". Never fabricate (Constitution IV). - -fb_signal classification (§7.5) arrives in US3 (T018). """ from prospector.enrich import NameInference from prospector.models import ( - Channel, Company, Confidence, Evidence, EvidenceKind, - FbSignal, Prospect, ResearchResult, - Variant, ) HIGH_SITE_KINDS = (EvidenceKind.OWNER_TEXT, EvidenceKind.ABOUT_PAGE, EvidenceKind.TEAM_PAGE) -# §7.5: signals that show the page is actually *used*, not merely linked -FB_ACTIVE_KINDS = (EvidenceKind.FB_WIDGET, EvidenceKind.FB_EMBED, EvidenceKind.FB_SEARCH_ACTIVE) - - -def classify_fb_signal(fb_evidence: list[Evidence]) -> FbSignal: - """§7.5, defaulting down: strong needs two or more signals of which at - least one is an active-usage cue; any lesser observation is weak; nothing - observed is none. Never up-ranks on uncertainty (Constitution V).""" - if len(fb_evidence) >= 2 and any(e.kind in FB_ACTIVE_KINDS for e in fb_evidence): - return FbSignal.STRONG - if fb_evidence: - return FbSignal.WEAK - return FbSignal.NONE - - -def select_variant(channel: Channel, fb_signal: FbSignal) -> Variant: - """Mechanical template selection (FR-014) — never a judgment call.""" - if channel is Channel.MESSENGER: - return Variant.MESSENGER_DM - if fb_signal is FbSignal.STRONG: - return Variant.EMAIL_FB - return Variant.EMAIL_AGNOSTIC - - def first_name_of(full_name: str) -> str: return full_name.strip().split()[0].capitalize() diff --git a/prospector/send.py b/prospector/send.py index c2aa2e3..a2cf385 100644 --- a/prospector/send.py +++ b/prospector/send.py @@ -69,9 +69,9 @@ def remaining(self, today: date, first_send: date | None, sent_today: int) -> in def collect_candidates(vault_dir: str | Path) -> tuple[list[SendCandidate], list[SendResult]]: """Return (approved & sendable candidates, skip-results for approved-but-unsendable). - Non-approved notes are ignored silently (not skip events). Approved notes that - are messenger-channel, lack a valid email, or lack a subject/body become - SKIPPED_NOT_SENDABLE results (FR-005, FR-013).""" + Non-approved notes are ignored silently (not skip events). Approved notes + that lack a valid email or a subject/body become SKIPPED_NOT_SENDABLE + results (FR-005, FR-013).""" vault_dir = Path(vault_dir) candidates: list[SendCandidate] = [] skipped: list[SendResult] = [] diff --git a/prospector/source.py b/prospector/source.py index df1cfd0..3f56a25 100644 --- a/prospector/source.py +++ b/prospector/source.py @@ -271,8 +271,8 @@ def fetch_homepage(candidate: Candidate, fetcher: Fetcher, summary: SourcingSumm # Conservative: word-ish local part, dotted domain, 2+ letter TLD. Misses exotic -# addresses on purpose — a missed email costs a messenger-bucket route, a wrong -# one costs a bounced send. +# addresses on purpose — a missed email costs a reported skip, a wrong one +# costs a bounced send. EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") ASSET_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg") diff --git a/prospector/vault.py b/prospector/vault.py index b43db6c..cfda426 100644 --- a/prospector/vault.py +++ b/prospector/vault.py @@ -9,27 +9,7 @@ from pathlib import Path from urllib.parse import urlparse -from prospector.models import Company, Draft, EvidenceKind, Prospect, ResearchResult - -# Evidence kinds whose `value` is a usable facebook.com page URL (not a script -# marker like FB_WIDGET/FB_EMBED) — the assisted-manual delivery target (007 R3). -_FB_TARGET_KINDS = ( - EvidenceKind.FB_URL_INPUT, - EvidenceKind.FB_SEARCH_ACTIVE, - EvidenceKind.FB_LINK, -) - - -def _resolve_facebook_url(company: Company, research: ResearchResult) -> str | None: - """The messenger delivery target (007 FR-017/R3): explicit input first, else - the first discovered facebook.com page URL, else None. No network access — - this only reads already-recorded fields (Constitution II).""" - if company.facebook_url: - return company.facebook_url - for ev in research.fb_evidence: - if ev.kind in _FB_TARGET_KINDS and "facebook.com" in (ev.value or "").lower(): - return ev.value - return None +from prospector.models import Company, Draft, Prospect FRONTMATTER_KEYS = ( "company", @@ -42,17 +22,12 @@ def _resolve_facebook_url(company: Company, research: ResearchResult) -> str | N "hook", "website", "angle", - "fb_signal", "duplicate_of", "needs_review", # 006: appended (not inserted) so existing notes keep their key order and # the first re-run produces content diffs, not 130 reorderings. "draft_source", "outcome", - # 007: appended (not inserted) so pre-007 notes keep their key order and the - # first re-run produces a content diff, not a full reordering. The assisted- - # manual Messenger delivery target; input/target only, never fetched (II). - "facebook_url", "tags", ) @@ -128,7 +103,7 @@ def render_note( values = { "company": company.company, "email": company.email, - "channel": company.channel.value, + "channel": company.channel, "status": status, "name_used": prospect.name_used, "name_confidence": prospect.name_confidence.value, @@ -136,15 +111,12 @@ def render_note( "hook": prospect.research.hook, "website": display_website(prospect.research.website or company.website), "angle": prospect.angle, - "fb_signal": prospect.fb_signal.value, "duplicate_of": company.duplicate_of, "needs_review": bool(prospect.needs_review or company.needs_review), # Empty when nothing was drafted this run (frozen, --no-llm). "draft_source": draft.source if draft is not None else None, # Human-owned: written empty on creation and never machine-set again. "outcome": None, - # 007: assisted-manual Messenger delivery target (never fetched — II). - "facebook_url": _resolve_facebook_url(company, prospect.research), "tags": None, # rendered specially below } lines = ["---"] @@ -286,21 +258,6 @@ def parse_draft(text: str) -> tuple[str, str] | None: return subject, body -def parse_messenger_body(text: str) -> str | None: - """Extract the body of a subject-less messenger draft's ## Draft section (007). - - Messenger DMs have no `**Subject:**` line (build_messenger_draft), so - parse_draft returns None for them. This returns the ## Draft body verbatim, - or None when the section is missing/empty (such notes are not deliverable — - FR-010).""" - _, sections = parse_note(text) - draft = dict(sections).get("Draft") - if draft is None: - return None - body = draft.strip("\n") - return body if body.strip() else None - - def set_status(path: str | Path, new_status: str, log_line: str) -> None: """Scoped, machine-owned status transition (Constitution v3.0.0, Principle I). @@ -425,9 +382,9 @@ def upsert_note( ## To-send queue ```dataview -TABLE company, hook, fb_signal +TABLE company, hook FROM #prospector -WHERE status = "to-send" AND channel = "email" AND !duplicate_of +WHERE status = "to-send" AND !duplicate_of ``` ## Needs review @@ -438,14 +395,6 @@ def upsert_note( WHERE needs_review = true OR name_confidence = "medium" ``` -## Messenger bucket - -```dataview -TABLE company, hook, fb_signal -FROM #prospector -WHERE channel = "messenger" -``` - ## Pipeline ```dataview @@ -495,17 +444,11 @@ def build_research_markdown(prospect: Prospect) -> str: hook_line = research.hook or "not found" if research.hook_evidence and research.hook_evidence.excerpt: hook_line += f' ({research.hook_evidence.source}: "{research.hook_evidence.excerpt}")' - fb_line = prospect.fb_signal.value - if research.fb_evidence: - fb_line += " — " + "; ".join(f"{e.kind.value}: {e.value}" for e in research.fb_evidence) - else: - fb_line += " — no FB link/widget/search presence found" failures = "; ".join(research.failures) if research.failures else "(none)" lines = [ f"- Owner name: {name_line}", f"- Sources: {sources}", f"- Hook: {hook_line}", - f"- fb_signal: {fb_line}", f"- Failures: {failures}", ] if prospect.company.duplicate_of: diff --git a/tests/integration/test_batch_run.py b/tests/integration/test_batch_run.py index 466d7cf..c6f15dc 100644 --- a/tests/integration/test_batch_run.py +++ b/tests/integration/test_batch_run.py @@ -36,7 +36,6 @@ def test_email_note_has_complete_draft(self, tmp_path, stubbed_network): assert fm["status"] == "to-send" assert fm["name_used"] == "Scott" # sourced from the /about page (US2) assert fm["name_confidence"] == "high" - assert fm["fb_signal"] == "none" assert "**Subject:** Free 10-day pilot for Acme Duct Cleaning" in note assert "Hi Scott," in note draft_section = note.split("## Draft")[1].split("## Research")[0] @@ -75,25 +74,6 @@ def test_resolved_website_via_ddg(self, tmp_path, stubbed_network): fm = frontmatter((vault_dir / "beta-air-systems.md").read_text()) assert fm["website"] == "betaair.com" - def test_messenger_row_gets_dm_draft(self, tmp_path, stubbed_network): - _, vault_dir = run_fixture_batch(tmp_path) - note = (vault_dir / "chat-only-cleaners.md").read_text() - fm = frontmatter(note) - assert fm["channel"] == "messenger" - assert fm["needs_review"] == "true" # website unresolved - assert "Hey! I'm giving 5 duct cleaning companies a free 10-day pilot of the Omniveer Duct Lead Qualifier." in note - assert "**Subject:**" not in note # DMs have no subject line - - def test_strong_fb_signal_recorded_but_body_stays_neutral(self, tmp_path, stubbed_network): - # rev. 2: the signal is still researched and recorded, but the email - # copy is channel-neutral at every level — no their-activity phrasing - _, vault_dir = run_fixture_batch(tmp_path) - note = (vault_dir / "delta-fresh-air.md").read_text() - fm = frontmatter(note) - assert fm["fb_signal"] == "strong" # widget (active) + footer link - assert "free 10-day pilot of the Omniveer Duct Lead Qualifier" in note - assert "messages your page" not in note.lower() - def test_no_hook_row_still_gets_note_and_draft(self, tmp_path, stubbed_network): # rev. 2 copy has no locator slot; a hook-less row still drafts cleanly _, vault_dir = run_fixture_batch(tmp_path) diff --git a/tests/integration/test_source_feeds_run.py b/tests/integration/test_source_feeds_run.py index a482c2b..1b36217 100644 --- a/tests/integration/test_source_feeds_run.py +++ b/tests/integration/test_source_feeds_run.py @@ -27,5 +27,7 @@ def test_sourced_csv_loads_into_001_ingest(tmp_path): assert emailed.email == "info@acme.com" assert emailed.website is not None and "acme.com" in emailed.website assert emailed.city == "Boston, MA" - # Blank email routes to the messenger bucket, unchanged 001 behavior. - assert silent.channel == "messenger" + # 008: a blank email means no address supplied — still email-channel, + # recovered or reported later; never bucketed. + assert silent.email is None + assert silent.channel == "email" diff --git a/tests/integration/test_success_criteria.py b/tests/integration/test_success_criteria.py index 9c240ad..93ac160 100644 --- a/tests/integration/test_success_criteria.py +++ b/tests/integration/test_success_criteria.py @@ -31,16 +31,6 @@ def test_sc003_duplicates_leave_one_to_send_per_inbox(self, tmp_path, stubbed_ne duplicates = [n for n, t in load_notes(vault_dir).items() if frontmatter(t)["duplicate_of"]] assert duplicates == ["acme-duct-south.md"] - def test_sc004_messenger_rows_get_dm_and_queue(self, tmp_path, stubbed_network): - _, vault_dir = run_fixture_batch(tmp_path) - messenger = { - n: t for n, t in load_notes(vault_dir).items() - if frontmatter(t)["channel"] == "messenger" - } - assert messenger, "fixture batch must include a messenger row" - for text in messenger.values(): - assert "Hey! I'm giving 5 duct cleaning companies a free 10-day pilot of the Omniveer Duct Lead Qualifier." in text - def test_sc005_channel_honesty(self, tmp_path, stubbed_network): _, vault_dir = run_fixture_batch(tmp_path) assert stubbed_network["blocked"].call_count == 0, "a request reached a Facebook host" diff --git a/tests/unit/test_agent_draft.py b/tests/unit/test_agent_draft.py index 14c9efa..c60852b 100644 --- a/tests/unit/test_agent_draft.py +++ b/tests/unit/test_agent_draft.py @@ -35,7 +35,6 @@ DraftBlock, Evidence, EvidenceKind, - FbSignal, Prospect, ResearchResult, ) @@ -75,14 +74,6 @@ def make_prospect(*, name_used="team", city="Dallas", hook="22 years in business excerpt="Owner Scott Brenner founded Acme in 2003", ) ) - research.fb_evidence.append( - Evidence( - kind=EvidenceKind.FB_LINK, - value="https://facebook.com/acmeduct", - source="https://acmeduct.com", - excerpt="site links to a Facebook page", - ) - ) research.hook_evidence = Evidence( kind=EvidenceKind.HOOK_SOURCE, value=hook, @@ -117,7 +108,7 @@ def good_response() -> AgentResponse: class TestEvidenceRefs: def test_ids_are_kind_and_ordinal(self): refs = build_evidence_refs(make_prospect().research) - assert [r.id for r in refs] == ["about_page_1", "fb_link_1", "hook_source_1"] + assert [r.id for r in refs] == ["about_page_1", "hook_source_1"] def test_evidence_ids_stable(self): """Identical research must yield identical ids (byte-idempotency).""" @@ -429,10 +420,8 @@ class TestChannelClaims: def refs(self, prospect): return build_evidence_refs(prospect.research) - def fb_prospect(self, signal): - p = make_prospect() - p.fb_signal = signal - return p + def fb_prospect(self): + return make_prospect() @pytest.mark.parametrize( "text", @@ -447,45 +436,40 @@ def fb_prospect(self, signal): "It clears your DMs overnight.", ], ) - def test_possessive_channel_claim_rejected_at_weak(self, text): - p = self.fb_prospect(FbSignal.WEAK) + def test_possessive_channel_claim_always_rejected(self, text): + p = self.fb_prospect() response = good_response() response.blocks[1] = DraftBlock(text, ["offer"]) errors = validate_channel_claims(response, p) assert any("claims the prospect's own channel" in e for e in errors), text - def test_possessive_channel_claim_rejected_at_none(self): - p = self.fb_prospect(FbSignal.NONE) + def test_possessive_channel_claim_rejected_without_signal(self): + p = self.fb_prospect() response = good_response() response.blocks[1] = DraftBlock("It answers your Facebook page messages.", ["offer"]) assert validate_channel_claims(response, p) def test_product_phrasing_is_always_allowed(self): - """Describing the product's capability is a product fact, at any signal.""" - for signal in (FbSignal.NONE, FbSignal.WEAK, FbSignal.STRONG): - p = self.fb_prospect(signal) - response = good_response() - response.blocks[1] = DraftBlock( - "It answers Facebook page messages in seconds, day or night.", ["offer"] - ) - assert validate_channel_claims(response, p) == [], signal - - def test_strong_signal_still_needs_the_citation(self): - p = self.fb_prospect(FbSignal.STRONG) + """Describing the product's capability is a product fact, always allowed.""" + p = self.fb_prospect() response = good_response() - response.blocks[1] = DraftBlock("It answers your Facebook page messages.", ["offer"]) - errors = validate_channel_claims(response, p) - assert any("without citing the observed fb_* signal" in e for e in errors) - - def test_strong_signal_with_fb_citation_is_allowed(self): - p = self.fb_prospect(FbSignal.STRONG) - response = good_response() - response.blocks[1] = DraftBlock("It answers your Facebook page messages.", ["fb_link_1"]) + response.blocks[1] = DraftBlock( + "It answers Facebook page messages in seconds, day or night.", ["offer"] + ) assert validate_channel_claims(response, p) == [] + def test_no_citation_can_justify_a_possessive_channel_claim(self): + """008: channel signals are no longer researched, so nothing in the + evidence set can support "your page" — the rejection is unconditional.""" + p = self.fb_prospect() + for cites in (["offer"], ["hook_source_1"], ["fb_link_1"]): + response = good_response() + response.blocks[1] = DraftBlock("It answers your Facebook page messages.", cites) + assert validate_channel_claims(response, p), cites + def test_live_all_pro_draft_would_now_fall_back(self): """End-to-end proof the escaped draft is caught by the full validator.""" - p = self.fb_prospect(FbSignal.WEAK) + p = self.fb_prospect() response = AgentResponse( subject="All Pro Duct - 10-day pilot", blocks=[ diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 1969032..103a02a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -81,10 +81,10 @@ def boom(*args, **kwargs): assert "unexpected error" in result.output def test_summary_table_printed(self, tmp_path, monkeypatch): - summary = RunSummary(total=2, processed=2, named_none=2, messenger=1) + summary = RunSummary(total=2, processed=2, named_none=2) summary.per_company = [("acme", "ok", "drafted"), ("beta", "ok", "no draft")] monkeypatch.setattr("prospector.pipeline.run_batch", lambda *a, **k: summary) result = runner.invoke(app, ["run", str(make_csv(tmp_path)), "--no-llm"]) assert result.exit_code == 0 assert "acme" in result.output and "beta" in result.output - assert "messenger: 1" in result.output + assert "no email found: 0" in result.output # 008: replaces the messenger count diff --git a/tests/unit/test_cli_dm.py b/tests/unit/test_cli_dm.py deleted file mode 100644 index 23c49e3..0000000 --- a/tests/unit/test_cli_dm.py +++ /dev/null @@ -1,50 +0,0 @@ -"""CLI `prospector dm` (007): preview is the default and mutates nothing; flags -are accepted. The real-send confirm loop is covered directly in test_dm.py (which -injects clipboard/browser); here we keep the browser out of the process by only -exercising preview + argument parsing.""" - -from typer.testing import CliRunner - -from prospector import vault -from prospector.cli import app -from test_dm import write_messenger_note - -runner = CliRunner() - - -def _prep(tmp_path, monkeypatch): - v = tmp_path / "vault" - dm_ledger = tmp_path / "dm_ledger.jsonl" - monkeypatch.setenv("PROSPECTOR_DM_LEDGER", str(dm_ledger)) - write_messenger_note(v, "acme-ducts", status="approved") - write_messenger_note(v, "pending", status="to-send") - return v, dm_ledger - - -def test_preview_is_default_and_inert(tmp_path, monkeypatch): - v, dm_ledger = _prep(tmp_path, monkeypatch) - note = v / "acme-ducts.md" - before = note.read_text(encoding="utf-8") - - result = runner.invoke(app, ["dm", "--vault", str(v)]) - - assert result.exit_code == 0 - assert "WOULD WALK (preview)" in result.output - assert "to walk: 1" in result.output - assert not dm_ledger.exists() # nothing recorded - assert note.read_text(encoding="utf-8") == before # note byte-identical - fm, _ = vault.parse_note(note.read_text(encoding="utf-8")) - assert fm["status"] == "approved" - - -def test_flags_accepted(tmp_path, monkeypatch): - v, _ = _prep(tmp_path, monkeypatch) - result = runner.invoke(app, ["dm", "--vault", str(v), "--limit", "1"]) - assert result.exit_code == 0 - - -def test_missing_vault_exits_1(tmp_path, monkeypatch): - monkeypatch.setenv("PROSPECTOR_DM_LEDGER", str(tmp_path / "dm.jsonl")) - result = runner.invoke(app, ["dm", "--vault", str(tmp_path / "nope")]) - assert result.exit_code == 1 - assert "vault folder not found" in result.output diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py index 8a47760..050b140 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_dashboard.py @@ -7,25 +7,26 @@ class TestDashboardContent: - def test_six_dataview_query_blocks(self): - # 4 original queues + 2 added by 006 (draft source, outcome by source) - assert DASHBOARD_CONTENT.count("```dataview") == 6 + def test_five_dataview_query_blocks(self): + # 3 original queues + 2 added by 006 (draft source, outcome by source). + # 008 removed the Messenger bucket: there is only one channel. + assert DASHBOARD_CONTENT.count("```dataview") == 5 def test_queries_filter_on_documented_fields(self): - # to-send queue excludes duplicates and non-email channels - assert 'WHERE status = "to-send" AND channel = "email" AND !duplicate_of' in DASHBOARD_CONTENT + # to-send queue excludes duplicates (008: every note is email-channel) + assert 'WHERE status = "to-send" AND !duplicate_of' in DASHBOARD_CONTENT # needs-review includes medium-confidence candidates assert 'WHERE needs_review = true OR name_confidence = "medium"' in DASHBOARD_CONTENT assert "name_candidate" in DASHBOARD_CONTENT - # messenger bucket - assert 'WHERE channel = "messenger"' in DASHBOARD_CONTENT + # 008: no Messenger bucket remains + assert "messenger" not in DASHBOARD_CONTENT.lower() # pipeline grouped by status assert "GROUP BY status" in DASHBOARD_CONTENT # 006 measurement queries assert DASHBOARD_CONTENT.count("GROUP BY draft_source") == 2 assert 'GROUP BY draft_source + " / " + outcome' in DASHBOARD_CONTENT # folder-agnostic tag scope on every query - assert DASHBOARD_CONTENT.count("FROM #prospector") == 6 + assert DASHBOARD_CONTENT.count("FROM #prospector") == 5 def test_plain_markdown_fallback_note_present(self): assert "Without Dataview this note still renders" in DASHBOARD_CONTENT diff --git a/tests/unit/test_dm.py b/tests/unit/test_dm.py deleted file mode 100644 index 58f3917..0000000 --- a/tests/unit/test_dm.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Assisted-manual Messenger walk (007): candidate collection, the confirm loop, -ledger dedupe, status flip, and graceful no-link handling. Clipboard/browser/ -confirm are injected — no real clipboard, no browser, no network.""" - -import os - -from prospector import dm as dm_mod -from prospector import ledger, vault -from prospector.config import Settings -from prospector.models import DmOutcome - - -def make_settings(tmp_path): - return Settings( - openrouter_key=None, - openrouter_model="m", - places_key=None, - hunter_key=None, - vault_dir=tmp_path / "vault", - dm_ledger_path=tmp_path / "dm_ledger.jsonl", - ) - - -def write_messenger_note(vault_dir, slug, *, status="approved", channel="messenger", - facebook_url="https://www.facebook.com/AcmeDucts/", - body="Hey! Free 10-day pilot. (https://www.omniveer.com/duct-lead-qualifier)", - mtime=None): - vault_dir.mkdir(parents=True, exist_ok=True) - fb_line = f'facebook_url: "{facebook_url}"\n' if facebook_url is not None else "facebook_url:\n" - body_block = f"{body}\n" if body is not None else "" - text = ( - "---\n" - f"company: {slug.replace('-', ' ').title()}\n" - "email:\n" - f"channel: {channel}\n" - f"status: {status}\n" - f"{fb_line}" - "tags: [outreach]\n" - "---\n\n" - "## Draft\n" - f"{body_block}\n" - "## Research\n- x\n\n" - "## Log\n-\n" - ) - p = vault_dir / f"{slug}.md" - p.write_text(text, encoding="utf-8") - if mtime is not None: - os.utime(p, (mtime, mtime)) - return p - - -class Confirm: - """Injected confirm stub returning a scripted decision per call.""" - - def __init__(self, *answers): - self.answers = list(answers) - self.seen = [] - - def __call__(self, cand, *, copied, opened): - self.seen.append((cand.slug, copied, opened)) - return self.answers.pop(0) if self.answers else "n" - - -# --- collection --- - -def test_collect_only_approved_messenger(tmp_path): - v = tmp_path / "vault" - write_messenger_note(v, "acme-ducts", status="approved") - write_messenger_note(v, "not-approved", status="to-send") - write_messenger_note(v, "an-email-note", channel="email") - cands, skipped = dm_mod.collect_dm_candidates(v) - assert [c.slug for c in cands] == ["acme-ducts"] - assert skipped == [] - - -def test_collect_flags_missing_body(tmp_path): - v = tmp_path / "vault" - write_messenger_note(v, "no-body", body=None) - cands, skipped = dm_mod.collect_dm_candidates(v) - assert cands == [] - assert len(skipped) == 1 - assert skipped[0].outcome is DmOutcome.SKIPPED_NOT_SENDABLE - assert "no body" in skipped[0].detail - - -def test_collect_unquotes_facebook_url(tmp_path): - v = tmp_path / "vault" - write_messenger_note(v, "acme-ducts", facebook_url="https://www.facebook.com/AcmeDucts/") - cands, _ = dm_mod.collect_dm_candidates(v) - assert cands[0].facebook_url == "https://www.facebook.com/AcmeDucts/" - - -# --- confirm loop / status + ledger (US1) --- - -def test_confirm_yes_ledgers_and_flips_status(tmp_path): - s = make_settings(tmp_path) - note = write_messenger_note(s.vault_dir, "acme-ducts") - copies, opens = [], [] - report = dm_mod.run_dm( - s, dry_run=False, - confirm=Confirm("y"), - opener=lambda url: opens.append(url) or True, - copier=lambda text: copies.append(text) or True, - ) - assert report.delivered == 1 - assert opens == ["https://www.facebook.com/AcmeDucts/"] - assert copies and copies[0].startswith("Hey!") - # ledger row written - recs = ledger.read_all(s.dm_ledger_path) - assert len(recs) == 1 - assert recs[0].result == "dm_sent_manual" and recs[0].message_id is None - assert recs[0].slug == "acme-ducts" - # status flipped + log bullet - fm, _ = vault.parse_note(note.read_text(encoding="utf-8")) - assert fm["status"] == "sent" - assert "messenger delivered manually" in note.read_text(encoding="utf-8") - - -def test_decline_records_nothing(tmp_path): - s = make_settings(tmp_path) - note = write_messenger_note(s.vault_dir, "acme-ducts") - report = dm_mod.run_dm( - s, dry_run=False, - confirm=Confirm("n"), - opener=lambda url: True, - copier=lambda text: True, - ) - assert report.declined == 1 and report.delivered == 0 - assert ledger.read_all(s.dm_ledger_path) == [] - fm, _ = vault.parse_note(note.read_text(encoding="utf-8")) - assert fm["status"] == "approved" - - -def test_quit_stops_the_walk(tmp_path): - s = make_settings(tmp_path) - write_messenger_note(s.vault_dir, "a-ducts", mtime=1) - write_messenger_note(s.vault_dir, "b-ducts", mtime=2) - report = dm_mod.run_dm( - s, dry_run=False, - confirm=Confirm("q"), - opener=lambda url: True, copier=lambda text: True, - ) - assert report.delivered == 0 - assert ledger.read_all(s.dm_ledger_path) == [] - - -# --- dedupe (US3) --- - -def test_ledgered_slug_is_skipped(tmp_path): - s = make_settings(tmp_path) - write_messenger_note(s.vault_dir, "acme-ducts") - # first confirmed delivery - dm_mod.run_dm(s, dry_run=False, confirm=Confirm("y"), - opener=lambda u: True, copier=lambda t: True) - # reset status back to approved and run again - note = s.vault_dir / "acme-ducts.md" - vault.set_status(note, "approved", "reopened") - opens = [] - report = dm_mod.run_dm(s, dry_run=False, confirm=Confirm("y"), - opener=lambda u: opens.append(u) or True, copier=lambda t: True) - assert report.skipped_already == 1 and report.delivered == 0 - assert opens == [] # no browser opened for an already-delivered note - - -def test_duplicate_target_within_run(tmp_path): - s = make_settings(tmp_path) - url = "https://www.facebook.com/Shared/" - write_messenger_note(s.vault_dir, "a-ducts", facebook_url=url, mtime=1) - write_messenger_note(s.vault_dir, "b-ducts", facebook_url=url, mtime=2) - report = dm_mod.run_dm(s, dry_run=False, confirm=Confirm("y", "y"), - opener=lambda u: True, copier=lambda t: True) - assert report.delivered == 1 and report.skipped_already == 1 - - -# --- graceful no-link (US5) --- - -def test_no_facebook_link_does_not_open_but_still_offers(tmp_path): - s = make_settings(tmp_path) - note = write_messenger_note(s.vault_dir, "no-link", facebook_url=None) - opens, copies = [], [] - confirm = Confirm("y") - report = dm_mod.run_dm( - s, dry_run=False, confirm=confirm, - opener=lambda u: opens.append(u) or True, - copier=lambda t: copies.append(t) or True, - ) - assert opens == [] # opener never called without a link - assert copies # draft still copied - assert confirm.seen and confirm.seen[0][2] is False # opened=False presented - assert report.delivered == 1 # y still ledgers + flips - fm, _ = vault.parse_note(note.read_text(encoding="utf-8")) - assert fm["status"] == "sent" diff --git a/tests/unit/test_dm_no_facebook_http.py b/tests/unit/test_dm_no_facebook_http.py deleted file mode 100644 index a9721df..0000000 --- a/tests/unit/test_dm_no_facebook_http.py +++ /dev/null @@ -1,54 +0,0 @@ -"""SC-004 / FR-007: the assisted-manual Messenger path issues ZERO HTTP requests -to any Facebook host. The tool only hands the URL to the operator's browser via -the injected opener; it never fetches. We assert that (a) the opener received the -target and (b) no httpx client is ever constructed during the walk.""" - -import httpx - -from prospector import dm as dm_mod -from test_dm import Confirm, make_settings, write_messenger_note - - -def test_run_dm_makes_no_facebook_request(tmp_path, monkeypatch): - s = make_settings(tmp_path) - write_messenger_note(s.vault_dir, "acme-ducts", - facebook_url="https://www.facebook.com/AcmeDucts/") - - # Trip-wire: any attempt to open an httpx client during the walk fails loudly. - def _boom(*a, **k): - raise AssertionError("dm made an HTTP request — it must never contact Facebook") - - monkeypatch.setattr(httpx, "Client", _boom) - monkeypatch.setattr(httpx, "get", _boom) - monkeypatch.setattr(httpx, "request", _boom) - - opens = [] - report = dm_mod.run_dm( - s, dry_run=False, - confirm=Confirm("y"), - opener=lambda url: opens.append(url) or True, - copier=lambda text: True, - ) - assert report.delivered == 1 - # The browser handoff is the ONLY Facebook interaction, and it goes through - # the injected opener (in production: webbrowser.open), never httpx. - assert opens == ["https://www.facebook.com/AcmeDucts/"] - - -def test_preview_opens_no_browser_and_makes_no_request(tmp_path, monkeypatch): - s = make_settings(tmp_path) - write_messenger_note(s.vault_dir, "acme-ducts") - - def _boom(*a, **k): - raise AssertionError("preview must make no external request") - - monkeypatch.setattr(httpx, "Client", _boom) - - opens = [] - report = dm_mod.run_dm( - s, dry_run=True, - opener=lambda url: opens.append(url) or True, - copier=lambda text: (_ for _ in ()).throw(AssertionError("preview copied clipboard")), - ) - assert report.would_deliver == 1 - assert opens == [] # preview never opens a browser diff --git a/tests/unit/test_draft.py b/tests/unit/test_draft.py index 635cbc3..5ceb6ba 100644 --- a/tests/unit/test_draft.py +++ b/tests/unit/test_draft.py @@ -10,13 +10,12 @@ DraftError, assemble_email, build_email_draft, - build_messenger_draft, expected_greeting, is_generic_inbox, request_slots, validate_email_draft, ) -from prospector.models import Company, FbSignal, Prospect, ResearchResult, Variant +from prospector.models import Company, Prospect, ResearchResult PRODUCT_URL = "https://www.omniveer.com/duct-lead-qualifier" SIGNATURE = "Anas\nFounder, Omniveer" @@ -80,61 +79,23 @@ def test_channel_neutral_no_facebook_mention(self): assert "messages your page" not in draft.body.lower() -class TestSignalNeutrality: - """Rev. 2: one channel-neutral body for every fb_signal level — the §7.5 - honesty gate is satisfied by making no channel claims at all.""" +class TestChannelNeutrality: + """008: there is one channel-neutral body. Channel signals are no longer + researched, so the copy asserts nothing about the prospect's channels and + never claims ad-running (Constitution v7.0.0, Principle IV).""" - def test_strong_signal_gets_the_same_neutral_body(self): - strong = make_prospect() - strong.fb_signal = FbSignal.STRONG - strong.variant = Variant.EMAIL_FB - neutral = assemble_email(make_prospect(), GOOD_SLOTS) - draft = assemble_email(strong, GOOD_SLOTS) - assert draft.validated, draft.validation_errors - assert draft.body == neutral.body - - def test_weak_and_none_signals_identical(self): - weak = make_prospect() - weak.fb_signal = FbSignal.WEAK - none_p = make_prospect() - assert assemble_email(weak, GOOD_SLOTS).body == assemble_email(none_p, GOOD_SLOTS).body - - def test_never_claims_ads_at_any_signal(self): - for signal in (FbSignal.STRONG, FbSignal.WEAK, FbSignal.NONE): - prospect = make_prospect() - prospect.fb_signal = signal - draft = assemble_email(prospect, GOOD_SLOTS) - for banned in ("your ads", "ad campaign", "running ads", "advertis"): - assert banned not in draft.body.lower() - - -class TestMessengerDm: - def test_dm_with_city_clause(self): - prospect = make_prospect() - prospect.variant = Variant.MESSENGER_DM - prospect.research.city = "Boston" - draft = build_messenger_draft(prospect) - assert draft.validated, draft.validation_errors - assert draft.subject is None - assert "It checks customers are real, around Boston, quotes your real prices" in draft.body - assert draft.body.startswith("Hey! I'm giving 5 duct cleaning companies") - assert draft.body.endswith("(See it working: https://www.omniveer.com/duct-lead-qualifier)") + def test_body_is_deterministic_for_identical_input(self): + assert assemble_email(make_prospect(), GOOD_SLOTS).body == assemble_email(make_prospect(), GOOD_SLOTS).body - def test_dm_without_city_drops_clause(self): - prospect = make_prospect() - prospect.variant = Variant.MESSENGER_DM - prospect.research.city = None - prospect.company.city = None - draft = build_messenger_draft(prospect) - assert draft.validated - assert ", around" not in draft.body - assert "It checks customers are real, quotes your real prices" in draft.body + def test_never_claims_ads(self): + draft = assemble_email(make_prospect(), GOOD_SLOTS) + for banned in ("your ads", "ad campaign", "running ads", "advertis", "ad spend"): + assert banned not in draft.body.lower() - def test_dm_is_deterministic_no_llm(self): - prospect = make_prospect() - prospect.variant = Variant.MESSENGER_DM - assert build_messenger_draft(prospect).body == build_messenger_draft(prospect).body - assert build_messenger_draft(prospect).model == "deterministic" + def test_makes_no_possessive_channel_claim(self): + body = assemble_email(make_prospect(), GOOD_SLOTS).body.lower() + for phrase in ("your facebook page", "your page", "your inbox", "your messenger"): + assert phrase not in body class TestValidator: @@ -197,30 +158,15 @@ def test_unsourced_name_rejected_even_when_greeting_matches(self): assert not draft.validated assert any("does not trace to a recorded source" in e for e in draft.validation_errors) - def test_sourced_name_accepted(self): - from prospector.models import Evidence, EvidenceKind - - prospect = make_prospect() - prospect.name_used = "Scott" - prospect.research.name_evidence.append( - Evidence(kind=EvidenceKind.ABOUT_PAGE, value="Scott Brown", source="https://acmeduct.com/about") - ) - slots = dict(GOOD_SLOTS, greeting_name="Scott") - draft = assemble_email(prospect, slots) - assert draft.validated, draft.validation_errors - assert draft.body.startswith("Hi Scott,") - class TestLinkStrategy: - """005 FR-201..205: Omniveer branding + exactly one promotional link (the - product page), no homepage combo, no LinkedIn in the pitch, no legacy brand.""" + """005: exactly one promotional link (the product page), no homepage combo, + no LinkedIn in the pitch, no legacy brand name. + + 008: only the email body remains — there is no second channel.""" def all_golden_bodies(self): - email = assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS) - dm_prospect = make_prospect() - dm_prospect.variant = Variant.MESSENGER_DM - dm = build_messenger_draft(dm_prospect) - return {"email": email, "dm": dm} + return {"email": assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS)} def test_nestaro_branding_is_gone(self): for name, draft in self.all_golden_bodies().items(): diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py index ec1c98a..0d3a58d 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -3,7 +3,7 @@ import httpx import respx -from prospector.extract import PageContent, detect_fb_evidence, discover_extra_pages, extract +from prospector.extract import PageContent, discover_extra_pages, extract from prospector.models import Company, EvidenceKind FIXTURES = Path(__file__).parent.parent / "fixtures" / "sites" @@ -100,49 +100,6 @@ def test_company_copyright_not_a_name(self): assert outcome.name_evidence == [] -class TestFbEvidence: - def page(self, site): - return [PageContent("homepage", f"https://{site}.test", fixture(site, "index.html"))] - - @respx.mock - def test_widget_site_yields_widget_and_link(self): - catch_all = respx.route().mock(return_value=httpx.Response(200)) - evidence = detect_fb_evidence(self.page("fb-widget")) - kinds = {e.kind for e in evidence} - assert EvidenceKind.FB_WIDGET in kinds - assert EvidenceKind.FB_LINK in kinds - assert catch_all.call_count == 0, "fb detection must never fetch" - - @respx.mock - def test_embed_site_yields_embed_not_link(self): - catch_all = respx.route().mock(return_value=httpx.Response(200)) - evidence = detect_fb_evidence(self.page("fb-embed")) - kinds = {e.kind for e in evidence} - assert kinds == {EvidenceKind.FB_EMBED} - assert catch_all.call_count == 0 - - @respx.mock - def test_bare_footer_link_yields_single_soft_signal(self): - catch_all = respx.route().mock(return_value=httpx.Response(200)) - evidence = detect_fb_evidence(self.page("fb-link")) - assert [e.kind for e in evidence] == [EvidenceKind.FB_LINK] - assert evidence[0].value == "https://www.facebook.com/zetavents" - assert catch_all.call_count == 0 - - @respx.mock - def test_no_fb_site_yields_nothing(self): - catch_all = respx.route().mock(return_value=httpx.Response(200)) - assert detect_fb_evidence(self.page("plain")) == [] - assert catch_all.call_count == 0 - - def test_one_evidence_per_kind_across_pages(self): - pages = self.page("fb-link") + [ - PageContent("about", "https://zeta.test/about", fixture("fb-link", "index.html")) - ] - evidence = detect_fb_evidence(pages) - assert len([e for e in evidence if e.kind is EvidenceKind.FB_LINK]) == 1 - - class TestCityAndHook: def test_city_from_city_state_pattern(self): company = Company(company="Beta Air Systems", email=None) diff --git a/tests/unit/test_ingest.py b/tests/unit/test_ingest.py index 89c0681..16aedec 100644 --- a/tests/unit/test_ingest.py +++ b/tests/unit/test_ingest.py @@ -1,7 +1,7 @@ import pytest from prospector.ingest import IngestError, load_companies, mark_duplicates -from prospector.models import Channel, Company +from prospector.models import Company from prospector.vault import assign_slugs @@ -19,7 +19,7 @@ def test_minimal_csv(self, tmp_path): c = companies[0] assert c.company == "Acme Duct" assert c.email == "info@acme.com" - assert c.channel is Channel.EMAIL + assert c.channel == "email" assert c.row_num == 2 def test_headers_case_insensitive_and_optionals(self, tmp_path): @@ -69,7 +69,7 @@ def test_markdown_table(self, tmp_path): companies, _ = load_companies(path) assert len(companies) == 2 assert companies[0].company == "Acme Duct" - assert companies[1].channel is Channel.MESSENGER + assert companies[1].email is None # 008: no address supplied, no bucket def test_no_table_fatal(self, tmp_path): path = write(tmp_path, "list.md", "just prose, no table\n") @@ -77,34 +77,40 @@ def test_no_table_fatal(self, tmp_path): load_companies(path) -class TestBucketing: +class TestAddressClassification: + """008: every row is email-channel. A blank / "messenger" / Facebook-URL / + unparseable field means NO ADDRESS SUPPLIED (recovered or skipped later), + never a second-channel bucket.""" + @pytest.mark.parametrize( - "email_value,channel,needs_review", + "email_value,expected_email,needs_review", [ - ("info@acme.com", Channel.EMAIL, False), - ("", Channel.MESSENGER, False), - ("messenger", Channel.MESSENGER, False), - ("Messenger", Channel.MESSENGER, False), - ("https://facebook.com/acmeduct", Channel.MESSENGER, False), - ("www.fb.com/acmeduct", Channel.MESSENGER, False), - ("not-an-email", Channel.MESSENGER, True), + ("info@acme.com", "info@acme.com", False), + ("", None, False), + ("messenger", None, False), + ("Messenger", None, False), + ("https://facebook.com/acmeduct", None, False), + ("www.fb.com/acmeduct", None, False), + ("not-an-email", None, True), ], ) - def test_bucketing_matrix(self, tmp_path, email_value, channel, needs_review): + def test_address_matrix(self, tmp_path, email_value, expected_email, needs_review): path = write(tmp_path, "list.csv", f'company,email\nAcme,"{email_value}"\n') companies, warnings = load_companies(path) c = companies[0] - assert c.channel is channel + assert c.email == expected_email + assert c.channel == "email" # 008: the only channel assert c.needs_review is needs_review assert c.raw_email_field == email_value if needs_review: assert any("unrecognized email field" in w for w in warnings) - def test_messenger_rows_have_no_email(self, tmp_path): + def test_facebook_url_is_not_an_address(self, tmp_path): path = write(tmp_path, "list.csv", "company,email\nAcme,https://facebook.com/acme\n") companies, _ = load_companies(path) assert companies[0].email is None - assert companies[0].bucket_reason == "facebook url in email field" + assert companies[0].channel == "email" + assert "no email supplied" in companies[0].bucket_reason def company(name, email, city=None): diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 75cb18a..fa314f2 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,7 +1,6 @@ from prospector.models import ( OFFER_CITE, AgentResponse, - Channel, Company, Confidence, Draft, @@ -9,20 +8,17 @@ Evidence, EvidenceKind, EvidenceRef, - FbSignal, Prospect, ResearchResult, RunSummary, - Variant, ) def test_enum_values_match_product_vocab(): # §6 frontmatter vocabulary - assert {c.value for c in Channel} == {"email", "messenger"} assert {c.value for c in Confidence} == {"high", "medium", "none"} - assert {s.value for s in FbSignal} == {"strong", "weak", "none"} - assert {v.value for v in Variant} == {"email_fb", "email_agnostic", "messenger_dm"} + # 008: email is the only channel, so it is a literal string, not an enum + assert Company(company="X", email=None).channel == "email" def test_construction_and_defaults(): @@ -31,8 +27,6 @@ def test_construction_and_defaults(): prospect = Prospect(company=company, research=research) assert prospect.name_used == "team" assert prospect.name_confidence is Confidence.NONE - assert prospect.fb_signal is FbSignal.NONE - assert prospect.variant is Variant.EMAIL_AGNOSTIC assert prospect.angle == "offer-led" assert not prospect.needs_review assert company.facebook_url is None diff --git a/tests/unit/test_no_facebook_channel.py b/tests/unit/test_no_facebook_channel.py new file mode 100644 index 0000000..7c73c0c --- /dev/null +++ b/tests/unit/test_no_facebook_channel.py @@ -0,0 +1,85 @@ +"""008 US1: Facebook is not a communication channel. + +These assertions are the standing guard on the removal. They fail loudly if a +Messenger/DM path, channel enum, or Facebook note field is ever reintroduced — +so the simplification cannot quietly regress (Constitution v7.0.0, Principle I). +""" + +import pytest +from typer.testing import CliRunner + +import prospector.models as models +from prospector.cli import app +from prospector.models import Company, Confidence, Draft, Prospect, ResearchResult +from prospector.vault import DASHBOARD_CONTENT, FRONTMATTER_KEYS, parse_note, render_note + +runner = CliRunner() + + +class TestRemovedSymbols: + @pytest.mark.parametrize("name", ["Channel", "Variant", "FbSignal", "DmCandidate", "DmOutcome", "DmResult", "DmRunReport"]) + def test_channel_types_are_gone(self, name): + assert not hasattr(models, name), f"{name} should not exist: email is the only channel" + + @pytest.mark.parametrize("name", ["FB_LINK", "FB_EMBED", "FB_WIDGET", "FB_SEARCH_ACTIVE", "FB_URL_INPUT"]) + def test_facebook_evidence_kinds_are_gone(self, name): + assert not hasattr(models.EvidenceKind, name) + + def test_modules_are_gone(self): + for mod in ("prospector.dm", "prospector.clipboard"): + with pytest.raises(ImportError): + __import__(mod) + + +class TestCommandSurface: + def test_dm_command_does_not_exist(self): + result = runner.invoke(app, ["dm", "--help"]) + assert result.exit_code != 0 + + def test_email_commands_still_exist(self): + for cmd in ("run", "source", "send", "dashboard"): + assert runner.invoke(app, [cmd, "--help"]).exit_code == 0, cmd + + +class TestNoteSchema: + def _note(self): + company = Company(company="Acme Duct", email="info@acme.com", raw_email_field="info@acme.com") + research = ResearchResult(website="https://acme.com", hook="Denver service area") + return render_note(Prospect(company=company, research=research), "d", "r") + + @pytest.mark.parametrize("key", ["fb_signal", "facebook_url"]) + def test_removed_keys_are_not_written(self, key): + assert key not in FRONTMATTER_KEYS + assert key not in parse_note(self._note())[0] + + def test_channel_is_always_email(self): + assert parse_note(self._note())[0]["channel"] == "email" + + +class TestDashboard: + def test_no_messenger_queue(self): + assert "messenger" not in DASHBOARD_CONTENT.lower() + + def test_to_send_queue_no_longer_filters_on_channel(self): + assert 'channel = "email"' not in DASHBOARD_CONTENT + + +class TestDraftingHasNoMessengerPath: + def test_messenger_template_is_gone(self): + import prospector.draft as draft + + for name in ("MESSENGER_DM_TEMPLATE", "MESSENGER_INVARIANTS", "build_messenger_draft"): + assert not hasattr(draft, name), name + + def test_possessive_channel_claims_are_unconditionally_rejected(self): + """With no channel signal researched, no evidence can justify "your page".""" + from prospector.agent_draft import validate_channel_claims + from prospector.models import AgentResponse, DraftBlock + + company = Company(company="Acme Duct", email="a@b.com", raw_email_field="a@b.com") + prospect = Prospect(company=company, research=ResearchResult()) + response = AgentResponse( + subject="s", + blocks=[DraftBlock("It answers your Facebook page messages.", ["offer"])], + ) + assert validate_channel_claims(response, prospect) diff --git a/tests/unit/test_resolve.py b/tests/unit/test_resolve.py index b3c875e..57f8a09 100644 --- a/tests/unit/test_resolve.py +++ b/tests/unit/test_resolve.py @@ -3,8 +3,8 @@ from prospector.config import Settings from prospector.fetch import Fetcher -from prospector.models import Company, EvidenceKind -from prospector.resolve import ResolveInfo, fb_search_evidence, resolve +from prospector.models import Company +from prospector.resolve import ResolveInfo, resolve from pathlib import Path @@ -138,46 +138,3 @@ def ddg_html_with_snippets(*results): return f"{blocks}" -class TestFbSearchEvidence: - @respx.mock - def test_active_fb_page_in_snippets_yields_evidence_without_fetching_fb(self): - respx.get(url__startswith="https://html.duckduckgo.com/html/").mock( - return_value=httpx.Response(200, text=ddg_html_with_snippets( - ("https://www.facebook.com/acmeduct", "Acme Duct Cleaning | Facebook", - "Acme Duct Cleaning. 312 likes. Posted 3 days ago: fall specials"), - )) - ) - fb = respx.get(url__startswith="https://www.facebook.com").mock(return_value=httpx.Response(200)) - info = ResolveInfo() - evidence = fb_search_evidence(ACME, instant_fetcher(), info) - assert evidence is not None - assert evidence.kind is EvidenceKind.FB_SEARCH_ACTIVE - assert evidence.value == "https://www.facebook.com/acmeduct" - assert fb.call_count == 0, "the FB page itself must never be fetched" - - @respx.mock - def test_fb_page_without_activity_cues_defaults_down_to_nothing(self): - respx.get(url__startswith="https://html.duckduckgo.com/html/").mock( - return_value=httpx.Response(200, text=ddg_html_with_snippets( - ("https://www.facebook.com/acmeduct", "Acme Duct", "A page on Facebook"), - )) - ) - assert fb_search_evidence(ACME, instant_fetcher(), ResolveInfo()) is None - - @respx.mock - def test_no_fb_result_yields_nothing(self): - respx.get(url__startswith="https://html.duckduckgo.com/html/").mock( - return_value=httpx.Response(200, text=ddg_html_with_snippets( - ("https://acmeduct.com", "Acme Duct Cleaning", "official site, 500 reviews"), - )) - ) - assert fb_search_evidence(ACME, instant_fetcher(), ResolveInfo()) is None - - @respx.mock - def test_search_failure_recorded_not_raised(self): - respx.get(url__startswith="https://html.duckduckgo.com/html/").mock( - side_effect=httpx.ConnectTimeout("down") - ) - info = ResolveInfo() - assert fb_search_evidence(ACME, instant_fetcher(), info) is None - assert any("ddg fb search failed" in f for f in info.failures) diff --git a/tests/unit/test_score.py b/tests/unit/test_score.py index baac151..fba4eee 100644 --- a/tests/unit/test_score.py +++ b/tests/unit/test_score.py @@ -2,17 +2,14 @@ from prospector.enrich import NameInference, infer_from_email from prospector.models import ( - Channel, Company, Confidence, Evidence, EvidenceKind, - FbSignal, Prospect, ResearchResult, - Variant, ) -from prospector.score import apply_name_scoring, classify_fb_signal, first_name_of, select_variant +from prospector.score import apply_name_scoring, first_name_of def make_prospect(email=None, owner_name=None, name_evidence=()): @@ -116,45 +113,6 @@ def fb_ev(kind): return Evidence(kind=kind, value="x", source="https://site.test") -class TestFbSignal: - def test_none_when_no_evidence(self): - assert classify_fb_signal([]) is FbSignal.NONE - - @pytest.mark.parametrize( - "kind", - [EvidenceKind.FB_LINK, EvidenceKind.FB_URL_INPUT, EvidenceKind.FB_WIDGET, EvidenceKind.FB_SEARCH_ACTIVE], - ) - def test_single_signal_is_weak_even_when_active(self, kind): - # default down: one signal alone never reaches strong (§7.5) - assert classify_fb_signal([fb_ev(kind)]) is FbSignal.WEAK - - def test_two_signals_with_active_cue_is_strong(self): - evidence = [fb_ev(EvidenceKind.FB_LINK), fb_ev(EvidenceKind.FB_WIDGET)] - assert classify_fb_signal(evidence) is FbSignal.STRONG - - def test_search_active_plus_input_url_is_strong(self): - evidence = [fb_ev(EvidenceKind.FB_URL_INPUT), fb_ev(EvidenceKind.FB_SEARCH_ACTIVE)] - assert classify_fb_signal(evidence) is FbSignal.STRONG - - def test_two_soft_signals_stay_weak(self): - # link + input url but no observed activity: default down - evidence = [fb_ev(EvidenceKind.FB_LINK), fb_ev(EvidenceKind.FB_URL_INPUT)] - assert classify_fb_signal(evidence) is FbSignal.WEAK - - -class TestVariantSelection: - def test_messenger_bucket_always_dm(self): - for signal in FbSignal: - assert select_variant(Channel.MESSENGER, signal) is Variant.MESSENGER_DM - - def test_strong_gets_fb_variant(self): - assert select_variant(Channel.EMAIL, FbSignal.STRONG) is Variant.EMAIL_FB - - def test_weak_and_none_get_agnostic(self): - assert select_variant(Channel.EMAIL, FbSignal.WEAK) is Variant.EMAIL_AGNOSTIC - assert select_variant(Channel.EMAIL, FbSignal.NONE) is Variant.EMAIL_AGNOSTIC - - class TestHelpers: def test_first_name_extraction(self): assert first_name_of("Scott Brown") == "Scott" diff --git a/tests/unit/test_vault.py b/tests/unit/test_vault.py index 7085958..b2e644c 100644 --- a/tests/unit/test_vault.py +++ b/tests/unit/test_vault.py @@ -1,4 +1,4 @@ -from prospector.models import Company, Confidence, Draft, FbSignal, Prospect, ResearchResult +from prospector.models import Company, Confidence, Draft, Prospect, ResearchResult from prospector.vault import ( assign_slugs, build_research_markdown, @@ -104,20 +104,16 @@ def test_golden_structure_matches_contract_example(self): "hook: Boston service area", "website: bostonairduct.com", "angle: offer-led", - "fb_signal: none", "duplicate_of:", "needs_review: false", # 006: appended before tags. Empty here — render_note was called # without a draft, so no path produced this note's copy. "draft_source:", "outcome:", - # 007: appended before tags. Empty here — email-channel note with no - # input facebook_url and no discovered facebook.com signal. - "facebook_url:", "tags: [outreach, duct-cleaning, prospector]", ] - assert lines[1:18] == expected_prefixes - assert lines[18] == "---" + assert lines[1:16] == expected_prefixes + assert lines[16] == "---" assert "## Draft" in note and "## Research" in note and "## Log" in note assert note.index("## Draft") < note.index("## Research") < note.index("## Log") assert note.endswith("## Log\n-\n") diff --git a/tests/unit/test_vault_facebook_url.py b/tests/unit/test_vault_facebook_url.py deleted file mode 100644 index 9bdfeef..0000000 --- a/tests/unit/test_vault_facebook_url.py +++ /dev/null @@ -1,69 +0,0 @@ -"""007 US4: messenger notes carry a machine-readable `facebook_url` header, -resolved from the input facebook_url or a discovered facebook.com signal, and -appended to the key order so pre-007 notes don't reorder on first re-run.""" - -from prospector.models import ( - Channel, - Company, - Evidence, - EvidenceKind, - Prospect, - ResearchResult, -) -from prospector.vault import FRONTMATTER_KEYS, parse_note, render_note - - -def _messenger_prospect(*, facebook_url=None, fb_evidence=None): - company = Company( - company="Acme Ducts", - email="", - channel=Channel.MESSENGER, - facebook_url=facebook_url, - ) - research = ResearchResult(website=None, hook="Denver service area") - if fb_evidence: - research.fb_evidence = fb_evidence - return Prospect(company=company, research=research) - - -def test_input_facebook_url_wins(tmp_path): - p = _messenger_prospect(facebook_url="https://facebook.com/AcmeDucts") - fm, _ = parse_note(render_note(p, "d", "r")) - assert fm["facebook_url"] == '"https://facebook.com/AcmeDucts"' # quoted (has ://) - - -def test_discovered_signal_used_when_no_input(tmp_path): - ev = Evidence( - kind=EvidenceKind.FB_SEARCH_ACTIVE, - value="https://www.facebook.com/AcmeDuctsCO/", - source="ddg", - excerpt="found", - ) - p = _messenger_prospect(fb_evidence=[ev]) - fm, _ = parse_note(render_note(p, "d", "r")) - assert "facebook.com/AcmeDuctsCO" in fm["facebook_url"] - - -def test_widget_marker_is_not_used_as_target(tmp_path): - # FB_WIDGET value is a script marker, not a page URL — must NOT be the target. - ev = Evidence( - kind=EvidenceKind.FB_WIDGET, - value="connect.facebook.net", - source="site", - excerpt="widget", - ) - p = _messenger_prospect(fb_evidence=[ev]) - fm, _ = parse_note(render_note(p, "d", "r")) - assert fm["facebook_url"] == "" # empty, not the marker - - -def test_empty_when_no_link(tmp_path): - p = _messenger_prospect() - fm, _ = parse_note(render_note(p, "d", "r")) - assert fm["facebook_url"] == "" - - -def test_field_appended_before_tags(): - keys = list(FRONTMATTER_KEYS) - assert keys.index("facebook_url") < keys.index("tags") - assert keys.index("outcome") < keys.index("facebook_url") # appended after 006 keys diff --git a/tests/unit/test_vault_freeze.py b/tests/unit/test_vault_freeze.py index fd1813c..e1a111c 100644 --- a/tests/unit/test_vault_freeze.py +++ b/tests/unit/test_vault_freeze.py @@ -131,7 +131,7 @@ def test_frozen_note_still_refreshes_research(self): def test_frozen_note_refreshes_other_machine_frontmatter(self): merged = merge_notes(EXISTING.format(status="sent"), FRESH, freeze_draft=True) frontmatter, _ = parse_note(merged) - assert frontmatter["fb_signal"] == "strong" # machine-owned, from fresh + assert frontmatter["hook"] == "25 years in business" # machine-owned, from fresh assert frontmatter["status"] == "sent" # human-owned, preserved def test_unfrozen_draft_is_replaced(self): @@ -154,13 +154,17 @@ def test_preexisting_key_order_is_preserved(self): before = list(parse_note(EXISTING.format(status="approved"))[0]) after = list(parse_note(merged)[0]) - assert [k for k in after if k in before] == before, "existing keys reordered" - # 006 appended draft_source/outcome; 007 appended facebook_url — all - # before tags, none reordering the keys the note already had. - assert set(after) - set(before) == {"draft_source", "outcome", "facebook_url"} + # Keys the note had AND the schema still defines keep their relative order. + surviving = [k for k in before if k in after] + assert [k for k in after if k in surviving] == surviving, "existing keys reordered" + # 006 appended draft_source/outcome. + assert set(after) - set(before) == {"draft_source", "outcome"} assert after.index("draft_source") < after.index("tags") assert after.index("outcome") < after.index("tags") - assert after.index("facebook_url") < after.index("tags") + # 008: fb_signal left the schema, so the merge no longer emits it. The + # note converges on the current schema; no HUMAN-owned value is touched + # (status/outcome/Log are asserted elsewhere in this class). + assert "fb_signal" not in after class TestUpsertFreeze: From 4139d5b4735a342001172eec9fe4ba28a15969fd Mon Sep 17 00:00:00 2001 From: anusbutt Date: Sat, 25 Jul 2026 00:09:43 +0500 Subject: [PATCH 2/6] feat(008): recover published emails instead of bucketing companies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A company with no supplied address is no longer routed to a second channel. The pages already fetched for research are searched for a published address; if one is found the company becomes an ordinary email prospect, and if not it is skipped with no note and reported by name in the run summary. - extract_public_email/_plausible_email move from source.py to extract.py so discovery and research share one definition of a usable address. - recover_email() ranks pages contact > about/team > homepage, then document order, so identical research always adopts the same address. It reads only pages already in hand — no extra request, no reachable blocked host. - The adopted address is recorded as EMAIL_PUBLISHED evidence naming the page it came from, and rendered in ## Research, so a wrong adoption is auditable. - NoEmailFound is distinct from a processing failure: nothing went wrong, the company simply cannot be reached. RunSummary gains email_recovered, no_email_skipped and skipped_companies; reconciliation widens accordingly. Domain-match guard (found by a live run, not by the tests): website resolution can land on the wrong site. An invented company was resolved via the DuckDuckGo fallback to an unrelated domain, and recovery adopted a stranger's personal Gmail published there — an address we would then have drafted outreach to. An address is now adopted only when its registrable domain matches the host of the page publishing it. A missed address costs a reported skip; a wrong one costs contacting someone who never published themselves as a business. Tests: 529 passing. tests/unit/test_email_recovery.py pins the precedence, determinism, evidence and domain-guard rules (including the exact live case); tests/integration/test_email_recovery_batch.py proves both end-to-end outcomes. Co-Authored-By: Claude Opus 5 --- prospector/extract.py | 91 +++++++++++- prospector/models.py | 3 + prospector/pipeline.py | 29 ++++ prospector/source.py | 33 +---- prospector/vault.py | 5 + tests/integration/test_batch_run.py | 8 +- .../integration/test_email_recovery_batch.py | 95 +++++++++++++ tests/integration/test_rerun.py | 5 +- tests/integration/test_success_criteria.py | 9 +- tests/unit/test_email_recovery.py | 134 ++++++++++++++++++ 10 files changed, 374 insertions(+), 38 deletions(-) create mode 100644 tests/integration/test_email_recovery_batch.py create mode 100644 tests/unit/test_email_recovery.py diff --git a/prospector/extract.py b/prospector/extract.py index 71674f7..6c3faa3 100644 --- a/prospector/extract.py +++ b/prospector/extract.py @@ -5,7 +5,7 @@ import re from dataclasses import dataclass, field -from urllib.parse import urljoin, urlparse +from urllib.parse import unquote, urljoin, urlparse import trafilatura from selectolax.parser import HTMLParser @@ -211,3 +211,92 @@ def _dedupe_names(outcome: ExtractOutcome) -> None: seen.add(key) unique.append(evidence) outcome.name_evidence = unique + + +# Conservative: word-ish local part, dotted domain, 2+ letter TLD. Misses exotic +# addresses on purpose — a missed email costs a reported skip, a wrong one +# costs a bounced send. +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") +ASSET_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg") + + +def extract_public_email(html: str) -> str | None: + """First publicly listed email: mailto links beat plaintext (research.md R4). + + Deterministic, never constructed: document order within each tier, + lowercased, mailto query params stripped, asset-name false positives dropped. + """ + tree = HTMLParser(html) + for node in tree.css("a[href]"): + href = node.attributes.get("href") or "" + if href.lower().startswith("mailto:"): + address = unquote(href[7:]).split("?", 1)[0].strip().lower() + if _plausible_email(address): + return address + text = tree.body.text(separator=" ") if tree.body else html + for match in EMAIL_RE.finditer(text): + address = match.group().lower() + if _plausible_email(address): + return address + return None + + +def _plausible_email(address: str) -> bool: + return bool(EMAIL_RE.fullmatch(address)) and not address.endswith(ASSET_SUFFIXES) + + +# Page priority for address recovery (008 research.md R2): an address published +# on a contact page is the intended point of contact; the homepage is the last +# resort. Ranking pages rather than trusting fetch order makes the choice +# deterministic — identical research always adopts the same address (FR-006). +_RECOVERY_PAGE_PRIORITY = ("contact", "about", "team", "homepage") + + +def _registrable(host: str) -> str: + """Last two labels of a host — a cheap same-organisation key. + + Deliberately naive (it treats "co.uk" as registrable); it only has to be + consistent, because it is used to compare a page's host with an address's + domain, not to parse the public suffix list.""" + parts = [p for p in (host or "").lower().split(".") if p] + return ".".join(parts[-2:]) if len(parts) >= 2 else ".".join(parts) + + +def recover_email(pages: list[PageContent]) -> tuple[str | None, Evidence | None]: + """Find a published address on pages ALREADY fetched for research. + + Returns (address, evidence) or (None, None). Reads only what it is given — + it issues no request, so recovery costs no extra traffic and cannot reach a + blocked host (FR-011). The adopted address is returned with an + EMAIL_PUBLISHED evidence record naming the page it came from, so a wrong + adoption is auditable in the note rather than invisible (FR-007). + + An address is adopted ONLY when its domain matches the host of the page it + was found on. Website resolution can land on the wrong site (a DuckDuckGo + fallback resolved an invented company to an unrelated domain and offered a + stranger's personal Gmail), and unlike a wrong `website` field a wrong + address is something we would actually mail. Defaulting down is the standing + rule here: a missed address costs a reported skip, a wrong one costs + contacting someone who never published themselves as a business.""" + ranked = sorted( + pages, + key=lambda p: ( + _RECOVERY_PAGE_PRIORITY.index(p.kind) + if p.kind in _RECOVERY_PAGE_PRIORITY + else len(_RECOVERY_PAGE_PRIORITY) + ), + ) + for page in ranked: + address = extract_public_email(page.html) + if address is None: + continue + page_domain = _registrable(urlparse(page.url).hostname or "") + if page_domain and _registrable(address.rsplit("@", 1)[-1]) != page_domain: + continue # published on this page, but not this organisation's address + return address, Evidence( + kind=EvidenceKind.EMAIL_PUBLISHED, + value=address, + source=page.url, + excerpt=f"published on the {page.kind}" if page.kind == "homepage" else f"published on the {page.kind} page", + ) + return None, None diff --git a/prospector/models.py b/prospector/models.py index d324374..5bc94ee 100644 --- a/prospector/models.py +++ b/prospector/models.py @@ -57,6 +57,9 @@ class ResearchResult: name_evidence: list[Evidence] = field(default_factory=list) hook: str | None = None hook_evidence: Evidence | None = None + # 008 FR-007: set when the address was recovered from the company's own + # pages rather than supplied in the input row. + email_evidence: "Evidence | None" = None city: str | None = None sources_consulted: list[str] = field(default_factory=list) failures: list[str] = field(default_factory=list) diff --git a/prospector/pipeline.py b/prospector/pipeline.py index 02e626a..9a39abd 100644 --- a/prospector/pipeline.py +++ b/prospector/pipeline.py @@ -29,6 +29,14 @@ def _log(message: str) -> None: print(message, file=sys.stderr) +class NoEmailFound(Exception): + """A company had no supplied address and none could be recovered (008 FR-009). + + Distinct from a processing failure: nothing went wrong, the company simply + cannot be reached by email. It is skipped without a note and reported by + name in the run summary — never bucketed to another channel.""" + + def run_batch( input_path: str | Path, settings: Settings, @@ -78,7 +86,18 @@ def run_batch( ) outcome, detail = _write(prospect, draft, vault_dir, no_llm=no_llm, frozen=frozen) _count_drafting_path(summary, company.slug, draft) + if prospect.research.email_evidence is not None: + summary.email_recovered += 1 summary.processed += 1 + except NoEmailFound as exc: + # FR-009: no note is written; the company is named in the summary so + # an unreachable prospect is visible rather than silently dropped. + if verbose: + _log(f"skipped {company.slug}: {exc}") + summary.no_email_skipped += 1 + summary.skipped_companies.append((company.company, str(exc))) + summary.per_company.append((company.slug, "skipped", f"no email found ({exc})")) + continue except Exception as exc: # per-company isolation (FR-021) _log(f"error: {company.slug}: {exc}") prospect = Prospect(company=company, research=ResearchResult(website=company.website)) @@ -104,6 +123,8 @@ def _process_company( instructions=None, ) -> tuple[Prospect, Draft | None]: research = _research(company, settings, fetcher, verbose=verbose) + if not company.email: + raise NoEmailFound(company.bucket_reason or "no email address found") prospect = _score(company, research, settings) draft: Draft | None = None if no_llm: @@ -140,6 +161,14 @@ def _research(company: Company, settings: Settings, fetcher: Fetcher, *, verbose if html is not None: pages.append(extracting.PageContent(kind, url, html)) + # 008 FR-006: no supplied address -> look for one the company publishes on + # the pages we already fetched. No new request is made (FR-011). + if not company.email and pages: + recovered, evidence = extracting.recover_email(pages) + if recovered: + company.email = recovered + research.email_evidence = evidence + if pages: outcome = extracting.extract(company, pages) research.name_evidence = outcome.name_evidence diff --git a/prospector/source.py b/prospector/source.py index 3f56a25..be3b04b 100644 --- a/prospector/source.py +++ b/prospector/source.py @@ -19,6 +19,7 @@ import httpx from prospector.config import ConfigError +from prospector.extract import EMAIL_RE, _plausible_email, extract_public_email from prospector.fetch import BlockedHostError, Fetcher, FetchError, is_blocked_host BUNDLED_METROS = "data/us_metros.txt" @@ -270,38 +271,6 @@ def fetch_homepage(candidate: Candidate, fetcher: Fetcher, summary: SourcingSumm return response.text -# Conservative: word-ish local part, dotted domain, 2+ letter TLD. Misses exotic -# addresses on purpose — a missed email costs a reported skip, a wrong one -# costs a bounced send. -EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") -ASSET_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg") - - -def extract_public_email(html: str) -> str | None: - """First publicly listed email: mailto links beat plaintext (research.md R4). - - Deterministic, never constructed: document order within each tier, - lowercased, mailto query params stripped, asset-name false positives dropped. - """ - tree = HTMLParser(html) - for node in tree.css("a[href]"): - href = node.attributes.get("href") or "" - if href.lower().startswith("mailto:"): - address = unquote(href[7:]).split("?", 1)[0].strip().lower() - if _plausible_email(address): - return address - text = tree.body.text(separator=" ") if tree.body else html - for match in EMAIL_RE.finditer(text): - address = match.group().lower() - if _plausible_email(address): - return address - return None - - -def _plausible_email(address: str) -> bool: - return bool(EMAIL_RE.fullmatch(address)) and not address.endswith(ASSET_SUFFIXES) - - def find_contact_link(html: str, base_url: str) -> str | None: """First same-host nav link whose path mentions 'contact' (one hop max, R4).""" tree = HTMLParser(html) diff --git a/prospector/vault.py b/prospector/vault.py index cfda426..d483c42 100644 --- a/prospector/vault.py +++ b/prospector/vault.py @@ -449,6 +449,11 @@ def build_research_markdown(prospect: Prospect) -> str: f"- Owner name: {name_line}", f"- Sources: {sources}", f"- Hook: {hook_line}", + ] + if research.email_evidence is not None: + ev = research.email_evidence + lines.append(f"- Email recovered: {ev.value} ({ev.source}: {ev.excerpt})") + lines += [ f"- Failures: {failures}", ] if prospect.company.duplicate_of: diff --git a/tests/integration/test_batch_run.py b/tests/integration/test_batch_run.py index c6f15dc..cb94420 100644 --- a/tests/integration/test_batch_run.py +++ b/tests/integration/test_batch_run.py @@ -7,17 +7,21 @@ class TestBatchRun: def test_one_note_per_valid_row(self, tmp_path, stubbed_network): summary, vault_dir = run_fixture_batch(tmp_path) + # 008: "Chat Only Cleaners" has no email AND no website, so there are no + # pages to recover an address from. It gets no note and is reported by + # name instead of being bucketed to a second channel (FR-009). assert company_notes(vault_dir) == [ "acme-duct-cleaning.md", "acme-duct-south.md", "beta-air-systems.md", - "chat-only-cleaners.md", "delta-fresh-air.md", "gamma-vent-care.md", "plain-ducts.md", ] assert summary.total == 7 # malformed row warned and skipped - assert summary.processed == 7 + assert summary.processed == 6 + assert summary.no_email_skipped == 1 + assert [name for name, _ in summary.skipped_companies] == ["Chat Only Cleaners"] assert summary.failed == 0 assert summary.reconciles() assert summary.named_high == 3 # acme + acme-south (about page), beta (scott@) diff --git a/tests/integration/test_email_recovery_batch.py b/tests/integration/test_email_recovery_batch.py new file mode 100644 index 0000000..2f8f679 --- /dev/null +++ b/tests/integration/test_email_recovery_batch.py @@ -0,0 +1,95 @@ +"""008 US2 end-to-end: a company with no supplied address either gains one from +its own pages or is reported by name. There is no third outcome and no bucket. + +Both companies below arrive with an empty email field — the state that used to +route them to the Messenger bucket. +""" + +import httpx +import pytest +import respx + +from helpers import settings +from prospector.pipeline import run_batch +from prospector.vault import parse_note + +CSV = """company,email,website,city +Recoverable Ducts,,recoverable.com, +Unreachable Ducts,,unreachable.com, +""" + +PUBLISHES_EMAIL = ( + "

Recoverable Ducts

" + 'Email us' + "" +) +NO_EMAIL_ANYWHERE = "

Unreachable Ducts

Call us.

" + + +@pytest.fixture +def stubs(): + with respx.mock(assert_all_called=False) as mock: + mock.route( + host__regex=r".*(facebook\.com|fb\.com|fb\.me|fbcdn\.net|messenger\.com)$" + ).mock(return_value=httpx.Response(200)) + mock.post("https://openrouter.ai/api/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + json={"choices": [{"message": {"content": '{"greeting_name": "team", "subject_company": "X"}'}}]}, + ) + ) + mock.get(url__startswith="https://html.duckduckgo.com/html/").mock( + return_value=httpx.Response(200, text="no results") + ) + for host, html in (("recoverable.com", PUBLISHES_EMAIL), ("unreachable.com", NO_EMAIL_ANYWHERE)): + mock.get(f"https://{host}/robots.txt").mock(return_value=httpx.Response(404)) + mock.get(f"https://{host}/").mock(return_value=httpx.Response(200, text=html)) + yield mock + + +def run(tmp_path, stubs): + csv_path = tmp_path / "companies.csv" + csv_path.write_text(CSV, encoding="utf-8") + vault_dir = tmp_path / "vault" + summary = run_batch(csv_path, settings(vault_dir), vault_dir=vault_dir, no_llm=True) + return summary, vault_dir + + +def notes(vault_dir): + return sorted(p.name for p in vault_dir.glob("*.md") if p.name != "_Dashboard.md") + + +class TestRecoveryOutcomes: + def test_exactly_one_note_is_written(self, tmp_path, stubs): + _, vault_dir = run(tmp_path, stubs) + assert notes(vault_dir) == ["recoverable-ducts.md"] + + def test_counters_report_both_outcomes(self, tmp_path, stubs): + summary, _ = run(tmp_path, stubs) + assert summary.total == 2 + assert summary.processed == 1 + assert summary.email_recovered == 1 + assert summary.no_email_skipped == 1 + assert summary.reconciles() + + def test_unreachable_company_is_named(self, tmp_path, stubs): + summary, _ = run(tmp_path, stubs) + assert [name for name, _ in summary.skipped_companies] == ["Unreachable Ducts"] + + def test_recovered_address_lands_in_the_note(self, tmp_path, stubs): + _, vault_dir = run(tmp_path, stubs) + text = (vault_dir / "recoverable-ducts.md").read_text(encoding="utf-8") + frontmatter, _ = parse_note(text) + assert frontmatter["email"] == "hello@recoverable.com" + assert frontmatter["channel"] == "email" + + def test_recovery_is_recorded_as_evidence(self, tmp_path, stubs): + """FR-007: the adopted address cites the page it came from.""" + _, vault_dir = run(tmp_path, stubs) + text = (vault_dir / "recoverable-ducts.md").read_text(encoding="utf-8") + assert "Email recovered: hello@recoverable.com" in text + assert "https://recoverable.com" in text + + def test_no_note_for_the_unreachable_company(self, tmp_path, stubs): + _, vault_dir = run(tmp_path, stubs) + assert not (vault_dir / "unreachable-ducts.md").exists() diff --git a/tests/integration/test_rerun.py b/tests/integration/test_rerun.py index fafef76..0ada8f5 100644 --- a/tests/integration/test_rerun.py +++ b/tests/integration/test_rerun.py @@ -52,5 +52,8 @@ def test_human_edits_survive_while_new_research_lands(self, tmp_path, stubbed_ne def test_second_run_reports_all_unchanged(self, tmp_path, stubbed_network): run_fixture_batch(tmp_path) summary, _ = run_fixture_batch(tmp_path) - details = [detail for _, _, detail in summary.per_company] + # 008: skipped companies report "no email found", not "unchanged" — + # they have no note to compare against. + details = [d for _, outcome, d in summary.per_company if outcome != "skipped"] + assert details, "expected at least one processed company" assert all("unchanged" in d for d in details), details diff --git a/tests/integration/test_success_criteria.py b/tests/integration/test_success_criteria.py index 93ac160..1b50b11 100644 --- a/tests/integration/test_success_criteria.py +++ b/tests/integration/test_success_criteria.py @@ -16,9 +16,14 @@ def load_notes(vault_dir): class TestSuccessCriteria: - def test_sc002_every_valid_row_produces_a_note(self, tmp_path, stubbed_network): + def test_sc002_every_reachable_row_produces_a_note(self, tmp_path, stubbed_network): + """008: every company is either noted or named as unreachable — there is + no third outcome and nothing is silently bucketed (SC-003).""" summary, vault_dir = run_fixture_batch(tmp_path) - assert len(company_notes(vault_dir)) == summary.total == 7 + assert summary.total == 7 + assert len(company_notes(vault_dir)) == summary.processed + assert summary.processed + summary.failed + summary.no_email_skipped == summary.total + assert summary.reconciles() def test_sc003_duplicates_leave_one_to_send_per_inbox(self, tmp_path, stubbed_network): _, vault_dir = run_fixture_batch(tmp_path) diff --git a/tests/unit/test_email_recovery.py b/tests/unit/test_email_recovery.py new file mode 100644 index 0000000..d3634f2 --- /dev/null +++ b/tests/unit/test_email_recovery.py @@ -0,0 +1,134 @@ +"""008 US2: recovering a published address instead of bucketing the company. + +A company with no supplied address is not discarded — the pages already fetched +for research are searched for a published address. Selection must be +deterministic (research.md R2) and the adopted address must be recorded as +evidence with its source, like every other fact (FR-007). + +No page is fetched here: recovery only reads what research already has (FR-011). +""" + +from prospector.extract import PageContent, recover_email +from prospector.models import EvidenceKind + + +def page(kind, html, url=None): + return PageContent(kind, url or f"https://acme.com/{kind}", html) + + +def mailto(addr): + return f'write us' + + +class TestPagePriority: + """Contact page beats about/team, which beat the homepage (R2).""" + + def test_contact_page_wins_over_homepage(self): + pages = [ + page("homepage", mailto("home@acme.com"), "https://acme.com"), + page("contact", mailto("contact@acme.com")), + ] + found, _ = recover_email(pages) + assert found == "contact@acme.com" + + def test_about_beats_homepage(self): + pages = [ + page("homepage", mailto("home@acme.com"), "https://acme.com"), + page("about", mailto("about@acme.com")), + ] + found, _ = recover_email(pages) + assert found == "about@acme.com" + + def test_contact_beats_about(self): + pages = [ + page("about", mailto("about@acme.com")), + page("contact", mailto("contact@acme.com")), + ] + found, _ = recover_email(pages) + assert found == "contact@acme.com" + + def test_homepage_used_when_it_is_all_there_is(self): + found, _ = recover_email([page("homepage", mailto("home@acme.com"), "https://acme.com")]) + assert found == "home@acme.com" + + +class TestDeterminism: + def test_same_pages_yield_same_address(self): + pages = [ + page("homepage", mailto("a@acme.com"), "https://acme.com"), + page("contact", mailto("b@acme.com")), + page("about", mailto("c@acme.com")), + ] + assert recover_email(pages)[0] == recover_email(pages)[0] + + def test_page_order_does_not_change_the_choice(self): + a = page("homepage", mailto("home@acme.com"), "https://acme.com") + b = page("contact", mailto("contact@acme.com")) + assert recover_email([a, b])[0] == recover_email([b, a])[0] + + +class TestRejection: + def test_no_address_anywhere_returns_none(self): + found, evidence = recover_email([page("homepage", "no mail here")]) + assert found is None and evidence is None + + def test_asset_filename_is_not_an_address(self): + html = "logo@2x.png sprite@3x.jpg" + assert recover_email([page("homepage", html)])[0] is None + + def test_no_pages_at_all(self): + assert recover_email([]) == (None, None) + + +class TestEvidence: + def test_adopted_address_is_recorded_with_its_source(self): + found, evidence = recover_email([page("contact", mailto("info@acme.com"))]) + assert found == "info@acme.com" + assert evidence is not None + assert evidence.kind is EvidenceKind.EMAIL_PUBLISHED + assert evidence.value == "info@acme.com" + assert evidence.source == "https://acme.com/contact" + assert evidence.excerpt + + def test_no_evidence_when_nothing_found(self): + assert recover_email([page("homepage", "nope")])[1] is None + + +class TestDomainMatchGuard: + """An address is adopted only if it belongs to the site publishing it. + + Website resolution can land on the wrong company. A wrong `website` field is + cosmetic; a wrong ADDRESS is something we would actually mail — so recovery + defaults down (research.md R2, live finding 2026-07-25).""" + + def test_matching_domain_is_adopted(self): + found, _ = recover_email( + [page("contact", mailto("cliff@ductsunlimited.com"), "https://ductsunlimited.com/contact")] + ) + assert found == "cliff@ductsunlimited.com" + + def test_subdomain_still_counts_as_the_same_organisation(self): + found, _ = recover_email( + [page("contact", mailto("info@acme.com"), "https://www.acme.com/contact")] + ) + assert found == "info@acme.com" + + def test_stranger_gmail_on_an_unrelated_site_is_rejected(self): + """The live regression: a DDG-resolved wrong site offered a personal Gmail.""" + found, evidence = recover_email( + [page("homepage", mailto("christiancorrea26@gmail.com"), "https://auroramessenger.com/")] + ) + assert found is None and evidence is None + + def test_other_companys_address_is_rejected(self): + found, _ = recover_email( + [page("contact", mailto("sales@someoneelse.com"), "https://acme.com/contact")] + ) + assert found is None + + def test_a_matching_address_on_a_later_page_still_wins_over_nothing(self): + pages = [ + page("contact", mailto("stranger@gmail.com"), "https://acme.com/contact"), + page("homepage", mailto("info@acme.com"), "https://acme.com/"), + ] + assert recover_email(pages)[0] == "info@acme.com" From 52003f2df31c0451ff6c89bcec434aaa81fcf5a7 Mon Sep 17 00:00:00 2001 From: anusbutt Date: Sat, 25 Jul 2026 00:19:30 +0500 Subject: [PATCH 3/6] feat(008): add per-vertical offer profiles (selection + validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offer becomes selectable content instead of packaged code (Constitution v7.0.0, Principle VI): adding a vertical needs no code change. - prospector/profiles.py: Profile, search_paths(), discover(), load(). Resolution order is $PROSPECTOR_PROFILES -> ./profiles/ -> packaged, so the operator's own directory always wins and adding a vertical never means editing an installed package. - profiles/duct-cleaning/ is the reference profile: the four instruction files move out of prospector/agent/, plus a new fallback.md (locked template and the invariants that prove it was not paraphrased) and profile.toml (tags, signature, product_url, keywords, banned_claims). - instructions.py now loads from the selected profile's directory; there is no package-wide default, because instruction content is per-vertical. - Validation is a startup pre-flight: missing/empty file, malformed fallback.md, unparseable or incomplete profile.toml, or an oversized instruction assembly each raise ConfigError before any company is processed. A profile is never silently substituted — pitching one vertical's offer to another vertical's prospect is exactly the failure this project refuses. - `run` and `source` gain --profile, with an interactive picker when omitted. A non-interactive run (CI, pipe, cron) FAILS with the available names rather than blocking on a prompt nobody can answer. `source --keyword` now defaults to the profile's first keyword instead of a hardcoded "duct cleaning". Known gap, tracked as T029: SIGNATURE, PRODUCT_URL, AD_CLAIM_SUBSTRINGS and TAGS_LINE are still module constants in draft.py/vault.py and are duplicated in profile.toml. Selecting a different profile therefore changes the drafting INSTRUCTIONS (and so the model's copy) but not yet the signature, promotional link, banned-claim list or note tags. Threading those through the drafting validators touches the honesty-critical path and is left as its own change. Tests: 548 passing. tests/unit/test_profiles.py covers resolution order, discovery, parsed content and every validation failure in the contract. Co-Authored-By: Claude Opus 5 --- .../duct-cleaning}/CONSTRAINTS.md | 0 .../duct-cleaning}/IDENTITY.md | 0 .../agent => profiles/duct-cleaning}/OFFER.md | 0 profiles/duct-cleaning/fallback.md | 31 +++ profiles/duct-cleaning/profile.toml | 26 +++ .../duct-cleaning}/skills/write-cold-email.md | 0 prospector/cli.py | 45 ++++- prospector/config.py | 21 +- prospector/instructions.py | 28 ++- prospector/profiles.py | 181 ++++++++++++++++++ tests/conftest.py | 22 +++ tests/unit/test_instructions.py | 20 +- tests/unit/test_profiles.py | 161 ++++++++++++++++ 13 files changed, 500 insertions(+), 35 deletions(-) rename {prospector/agent => profiles/duct-cleaning}/CONSTRAINTS.md (100%) rename {prospector/agent => profiles/duct-cleaning}/IDENTITY.md (100%) rename {prospector/agent => profiles/duct-cleaning}/OFFER.md (100%) create mode 100644 profiles/duct-cleaning/fallback.md create mode 100644 profiles/duct-cleaning/profile.toml rename {prospector/agent => profiles/duct-cleaning}/skills/write-cold-email.md (100%) create mode 100644 prospector/profiles.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/test_profiles.py diff --git a/prospector/agent/CONSTRAINTS.md b/profiles/duct-cleaning/CONSTRAINTS.md similarity index 100% rename from prospector/agent/CONSTRAINTS.md rename to profiles/duct-cleaning/CONSTRAINTS.md diff --git a/prospector/agent/IDENTITY.md b/profiles/duct-cleaning/IDENTITY.md similarity index 100% rename from prospector/agent/IDENTITY.md rename to profiles/duct-cleaning/IDENTITY.md diff --git a/prospector/agent/OFFER.md b/profiles/duct-cleaning/OFFER.md similarity index 100% rename from prospector/agent/OFFER.md rename to profiles/duct-cleaning/OFFER.md diff --git a/profiles/duct-cleaning/fallback.md b/profiles/duct-cleaning/fallback.md new file mode 100644 index 0000000..7fa453f --- /dev/null +++ b/profiles/duct-cleaning/fallback.md @@ -0,0 +1,31 @@ +# Locked fallback copy — duct cleaning + +Answers whenever generated copy fails citation validation. Only bracketed +slots may be filled; the prose is never paraphrased or restyled. + +## Subject + +Free 10-day pilot for {subject_company} + +## Template + +Hi {greeting}, + +I'm giving 5 duct-cleaning companies a free 10-day pilot of the Omniveer Duct Lead Qualifier. + +It responds to new leads, qualifies them, books appointments when they're ready, sends the full details to your email, and keeps every lead organized in a dashboard. + +You can see the short demo here: +https://www.omniveer.com/duct-lead-qualifier + +Reply to this email if you'd like one of the five pilot spots, or book a demo through the page. + +{signature} + +## Invariants + +- I'm giving 5 duct-cleaning companies a free 10-day pilot of the Omniveer Duct Lead Qualifier. +- It responds to new leads, qualifies them, books appointments when they're ready, sends the full details to your email, and keeps every lead organized in a dashboard. +- You can see the short demo here: +https://www.omniveer.com/duct-lead-qualifier +- Reply to this email if you'd like one of the five pilot spots, or book a demo through the page. diff --git a/profiles/duct-cleaning/profile.toml b/profiles/duct-cleaning/profile.toml new file mode 100644 index 0000000..9b3e747 --- /dev/null +++ b/profiles/duct-cleaning/profile.toml @@ -0,0 +1,26 @@ +# Per-vertical constants for the duct-cleaning offer (008). +# Content, not code: reviewed like prose, never a place for secrets. + +tags = [ + "outreach", + "duct-cleaning", + "prospector", +] + +signature = "Anas\nFounder, Omniveer" + +product_url = "https://www.omniveer.com/duct-lead-qualifier" + +keywords = [ + "duct cleaning", + "air duct cleaning", +] + +banned_claims = [ + "your ads", + "ad campaign", + "running ads", + "advertis", + "your facebook ads", + "ad spend", +] diff --git a/prospector/agent/skills/write-cold-email.md b/profiles/duct-cleaning/skills/write-cold-email.md similarity index 100% rename from prospector/agent/skills/write-cold-email.md rename to profiles/duct-cleaning/skills/write-cold-email.md diff --git a/prospector/cli.py b/prospector/cli.py index 68d7d93..48d9622 100644 --- a/prospector/cli.py +++ b/prospector/cli.py @@ -30,9 +30,42 @@ def main(): """Prospector: research companies on the open web, draft outreach into an Obsidian vault.""" +def _resolve_profile(settings, requested: str | None): + """Select and validate the offer profile before any work happens (008). + + An explicit name wins; otherwise the operator is asked. A non-interactive + run (CI, a pipe, cron) must FAIL rather than block on a prompt nobody can + answer, so the available names are printed and the run stops.""" + import sys + + from prospector.profiles import discover + + name = requested or settings.profile + if not name: + available = discover() + if not available: + raise ConfigError( + "no profiles found. Create profiles// with OFFER.md, " + "IDENTITY.md, CONSTRAINTS.md, skills/write-cold-email.md, " + "fallback.md and profile.toml." + ) + if not sys.stdin.isatty(): + raise ConfigError( + f"no profile selected. Pass --profile " + f"(available: {', '.join(available)})." + ) + typer.echo("Available profiles:") + for i, candidate in enumerate(available, start=1): + typer.echo(f" {i}) {candidate}") + choice = typer.prompt("Which profile?", default="1") + name = available[int(choice) - 1] if choice.isdigit() else choice + return settings.require_profile(name) + + @app.command() def run( input: Path = typer.Argument(..., help="CSV or markdown-table file of companies"), + profile: str = typer.Option(None, "--profile", help="Offer profile to use (prompts when omitted)"), vault: Path = typer.Option(None, "--vault", help="Vault output folder (default: Vault/Outreach)"), limit: int = typer.Option(None, "--limit", help="Process only the first N companies"), only: str = typer.Option(None, "--only", help="Re-run a single company by slug"), @@ -45,11 +78,11 @@ def run( settings = load_settings() instructions = None try: + # FR-018: the profile is validated first — a broken one costs nothing. + selected = _resolve_profile(settings, profile) if not no_llm: settings.require_llm() - # FR-323: a missing or oversized instruction file stops the run - # before any company is processed and before anything is written. - instructions = settings.require_instructions() + instructions = selected.instructions if not input.is_file(): raise IngestError(f"input file not found: {input}") summary = run_batch( @@ -73,7 +106,8 @@ def run( @app.command() def source( - keyword: str = typer.Option("duct cleaning", "--keyword", help="Service keyword for the Places text query"), + profile: str = typer.Option(None, "--profile", help="Offer profile to use (prompts when omitted)"), + keyword: str = typer.Option(None, "--keyword", help="Service keyword (default: the profile's first keyword)"), metros: Path = typer.Option(None, "--metros", help="Metro list file (City, ST per line; default: bundled 30-metro list)"), out: Path = typer.Option(Path("candidates.csv"), "--out", help="Output CSV path"), keep_all: bool = typer.Option(False, "--all", help="Keep every discovered candidate (default: only ad_signal: pixel)"), @@ -86,6 +120,9 @@ def source( settings = load_settings() try: + selected = _resolve_profile(settings, profile) + if not keyword: + keyword = selected.keywords[0] settings.require_places() metro_list = load_metros(metros) out_parent = out.resolve().parent diff --git a/prospector/config.py b/prospector/config.py index a84afca..b028833 100644 --- a/prospector/config.py +++ b/prospector/config.py @@ -35,6 +35,7 @@ class Settings: places_key: str | None hunter_key: str | None vault_dir: Path + profile: str | None = None # 008: selected offer profile send_from: str | None = None # required for `send`; no hardcoded account send_provider: str = DEFAULT_SEND_PROVIDER send_name: str | None = None # From display name, e.g. "Anas from Omniveer" @@ -59,16 +60,19 @@ def require_llm(self) -> None: "or run with --no-llm to skip drafting." ) - def require_instructions(self): - """Pre-flight the drafting instruction files (006, FR-323/FR-325). + def require_profile(self, name: str | None = None): + """Pre-flight the selected profile (008, FR-018/FR-019). - Loads once per run and returns the InstructionSet, so `run` fails - before touching the network or writing a note when a file is missing - or the assembled context is oversized. Imported lazily: `--help` and - `--no-llm` should not pay for it.""" - from prospector.instructions import load_instructions + Loads and validates once per run — offer content, locked fallback and + per-vertical constants — so a malformed profile fails before any company + is processed and before anything is written. Imported lazily: `--help` + should not pay for it.""" + from prospector.profiles import load - return load_instructions() + chosen = name or self.profile + if not chosen: + raise ConfigError("no profile selected") + return load(chosen) def require_places(self) -> None: if not self.places_key: @@ -180,6 +184,7 @@ def load_settings(env_file: str | Path = ".env") -> Settings: places_key=os.environ.get("GOOGLE_PLACES_API_KEY") or None, hunter_key=os.environ.get("HUNTER_API_KEY") or None, vault_dir=Path(os.environ.get("PROSPECTOR_VAULT") or DEFAULT_VAULT), + profile=os.environ.get("PROSPECTOR_PROFILE") or None, send_from=os.environ.get("PROSPECTOR_SEND_FROM") or None, send_provider=(os.environ.get("PROSPECTOR_SEND_PROVIDER") or DEFAULT_SEND_PROVIDER) .strip() diff --git a/prospector/instructions.py b/prospector/instructions.py index 1f719b5..750b6ad 100644 --- a/prospector/instructions.py +++ b/prospector/instructions.py @@ -12,7 +12,6 @@ """ from dataclasses import dataclass, field -from importlib.resources import files from pathlib import Path from prospector.config import ConfigError @@ -21,10 +20,10 @@ # to write. Constraints deliberately precede the writing guidance so the rules # frame the craft advice rather than trailing it. REQUIRED_FILES = ( - "agent/IDENTITY.md", - "agent/OFFER.md", - "agent/CONSTRAINTS.md", - "agent/skills/write-cold-email.md", + "IDENTITY.md", + "OFFER.md", + "CONSTRAINTS.md", + "skills/write-cold-email.md", ) # ~5k tokens, leaving ample room for per-company evidence and the response @@ -47,25 +46,24 @@ def char_count(self) -> int: return len(self.text) -def _read(root: Path | None, relative: str) -> str: - """Read one instruction file from the package, or from `root` in tests.""" +def _read(root: Path, relative: str) -> str: + """Read one instruction file from the selected profile's directory.""" try: - if root is None: - return files("prospector").joinpath(relative).read_text(encoding="utf-8") return (Path(root) / relative).read_text(encoding="utf-8") - except (FileNotFoundError, NotADirectoryError, ModuleNotFoundError) as exc: + except (FileNotFoundError, NotADirectoryError) as exc: raise ConfigError( - f"instruction file not found: {relative}. It ships with the package " - f"and is required for drafting; restore it or reinstall." + f"instruction file not found: {relative}. It is required for " + f"drafting; add it to the profile." ) from exc except OSError as exc: raise ConfigError(f"instruction file could not be read: {relative} ({exc})") from exc -def load_instructions(root: Path | None = None) -> InstructionSet: +def load_instructions(root: Path) -> InstructionSet: """Load and bound every required instruction file. - `root` overrides the package location so tests can supply fixtures. + `root` is the selected profile's directory (008): instruction content is + per-vertical, so there is no package-wide default to fall back to. Raises ConfigError on a missing/unreadable file or an oversized assembly — never truncates, because a truncated CONSTRAINTS.md would silently drop hard rules, which is the worst available failure.""" @@ -82,7 +80,7 @@ def load_instructions(root: Path | None = None) -> InstructionSet: if len(text) > MAX_INSTRUCTION_CHARS: raise ConfigError( f"instruction context is {len(text):,} chars (max {MAX_INSTRUCTION_CHARS:,}). " - f"Trim the files in prospector/agent/ — they are never truncated " + f"Trim the files in {root} — they are never truncated " f"automatically, because dropping part of CONSTRAINTS.md would " f"silently weaken the honesty rules." ) diff --git a/prospector/profiles.py b/prospector/profiles.py new file mode 100644 index 0000000..4f22033 --- /dev/null +++ b/prospector/profiles.py @@ -0,0 +1,181 @@ +"""Per-vertical profiles: the offer as selectable content, not code (008). + +Constitution v7.0.0, Principle VI: the offer, sender identity, writing guidance, +locked fallback copy, note tags, promotional link, and default sourcing keywords +are supplied by a named profile. Adding a vertical MUST NOT require changing +application code, which is why profiles are resolved from a search path that +puts the operator's own directory ahead of anything shipped in the package. + +Everything is validated up front. A profile missing its locked fallback would +let a batch run with no safe answer available when generated copy fails +validation — so that is a startup ConfigError (exit 1, nothing written), the +same way every other missing-configuration case in this codebase behaves. +""" + +import os +import tomllib +from dataclasses import dataclass, field +from importlib.resources import files +from pathlib import Path + +from prospector.config import ConfigError +from prospector.instructions import InstructionSet, load_instructions + +REQUIRED_FILES = ( + "IDENTITY.md", + "OFFER.md", + "CONSTRAINTS.md", + "skills/write-cold-email.md", + "fallback.md", + "profile.toml", +) + +REQUIRED_KEYS = ("tags", "signature", "product_url", "keywords", "banned_claims") + +TEMPLATE_HEADING = "## Template" +INVARIANTS_HEADING = "## Invariants" + + +@dataclass +class Profile: + """One vertical's offer content, loaded and validated once per run.""" + + name: str + root: Path + instructions: InstructionSet + fallback_template: str + fallback_invariants: list[str] + tags: list[str] + signature: str + product_url: str + keywords: list[str] = field(default_factory=list) + banned_claims: list[str] = field(default_factory=list) + + @property + def tags_line(self) -> str: + """Frontmatter rendering of `tags`, e.g. "[outreach, hvac]".""" + return "[" + ", ".join(self.tags) + "]" + + +def search_paths() -> list[Path]: + """Directories searched for profiles, most specific first (research.md R3). + + The operator's own directory wins so that adding a vertical never means + editing an installed package.""" + paths: list[Path] = [] + env = os.environ.get("PROSPECTOR_PROFILES") + if env: + paths.append(Path(env)) + paths.append(Path.cwd() / "profiles") + try: + paths.append(Path(str(files("prospector").joinpath("profiles")))) + except (ModuleNotFoundError, TypeError): # pragma: no cover - packaging edge + pass + return paths + + +def discover() -> list[str]: + """Available profile names, de-duplicated, in search-path precedence.""" + names: list[str] = [] + for base in search_paths(): + if not base.is_dir(): + continue + for child in sorted(base.iterdir()): + if child.is_dir() and child.name not in names: + names.append(child.name) + return names + + +def _locate(name: str) -> Path: + for base in search_paths(): + candidate = base / name + if candidate.is_dir(): + return candidate + available = ", ".join(discover()) or "(none)" + raise ConfigError( + f"profile {name!r} not found. Available: {available}. " + f"Profiles are directories under ./profiles/ or $PROSPECTOR_PROFILES." + ) + + +def _read(root: Path, name: str, relative: str) -> str: + path = root / relative + try: + content = path.read_text(encoding="utf-8") + except (FileNotFoundError, NotADirectoryError) as exc: + raise ConfigError( + f"profile {name!r} is missing required file: {relative}" + ) from exc + except OSError as exc: + raise ConfigError(f"profile {name!r}: {relative} could not be read ({exc})") from exc + if not content.strip(): + raise ConfigError(f"profile {name!r} has an empty required file: {relative}") + return content + + +def _parse_fallback(name: str, text: str) -> tuple[str, list[str]]: + """Split fallback.md into its locked template and its invariants. + + Both sections are mandatory: the template is what answers when generated + copy fails validation, and the invariants are what prove it was not + paraphrased on the way out.""" + if TEMPLATE_HEADING not in text or INVARIANTS_HEADING not in text: + raise ConfigError( + f"profile {name!r}: fallback.md must contain " + f"'{TEMPLATE_HEADING}' and '{INVARIANTS_HEADING}'" + ) + _, rest = text.split(TEMPLATE_HEADING, 1) + template, invariant_block = rest.split(INVARIANTS_HEADING, 1) + template = template.strip("\n") + invariants = [ + line.lstrip("-").strip() + for line in invariant_block.splitlines() + if line.strip().startswith("-") + ] + if not template.strip(): + raise ConfigError(f"profile {name!r}: fallback.md has an empty {TEMPLATE_HEADING} section") + if not invariants: + raise ConfigError(f"profile {name!r}: fallback.md lists no invariants") + return template, invariants + + +def _parse_config(name: str, text: str) -> dict: + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"profile {name!r}: profile.toml is not valid TOML ({exc})") from exc + for key in REQUIRED_KEYS: + if key not in data: + raise ConfigError(f"profile {name!r}: profile.toml is missing required key: {key}") + return data + + +def load(name: str) -> Profile: + """Locate, read and validate a profile. Raises ConfigError on any problem. + + Callers run this as a pre-flight, before any company is processed, so a + malformed profile costs nothing and writes nothing (FR-018/FR-019).""" + root = _locate(name) + for relative in REQUIRED_FILES: + _read(root, name, relative) # presence + non-empty + + template, invariants = _parse_fallback(name, _read(root, name, "fallback.md")) + config = _parse_config(name, _read(root, name, "profile.toml")) + + try: + instructions = load_instructions(root) + except ConfigError as exc: + raise ConfigError(f"profile {name!r}: {exc}") from exc + + return Profile( + name=name, + root=root, + instructions=instructions, + fallback_template=template, + fallback_invariants=invariants, + tags=list(config["tags"]), + signature=str(config["signature"]), + product_url=str(config["product_url"]), + keywords=list(config["keywords"]), + banned_claims=list(config["banned_claims"]), + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c504a78 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Shared test setup. + +008: every run selects an offer profile. Tests that are not *about* profile +selection get the bundled reference profile, so they exercise the behaviour they +were written for rather than the selection prompt. Tests that ARE about +selection (test_profiles.py, and the CLI selection cases) override or delete +this environment variable themselves. +""" + +import os + +import pytest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +@pytest.fixture(autouse=True) +def default_profile(monkeypatch): + monkeypatch.setenv("PROSPECTOR_PROFILE", "duct-cleaning") + # Profiles resolve relative to the working directory; pin it to the repo so + # the bundled reference profile is found no matter where pytest was invoked. + monkeypatch.setenv("PROSPECTOR_PROFILES", os.path.join(REPO_ROOT, "profiles")) diff --git a/tests/unit/test_instructions.py b/tests/unit/test_instructions.py index e4b3935..a690102 100644 --- a/tests/unit/test_instructions.py +++ b/tests/unit/test_instructions.py @@ -3,6 +3,10 @@ import pytest from prospector.config import ConfigError +from pathlib import Path + +DUCT_PROFILE = Path("profiles/duct-cleaning") + from prospector.instructions import ( MAX_INSTRUCTION_CHARS, REQUIRED_FILES, @@ -37,13 +41,13 @@ def test_load_order_is_fixed(self, fixture_root): def test_real_package_files_load(self): """The shipped files must actually load — not just fixtures.""" - result = load_instructions() + result = load_instructions(DUCT_PROFILE) assert result.char_count > 1000 assert "Omniveer" in result.text assert "https://www.omniveer.com/duct-lead-qualifier" in result.text def test_real_package_files_are_within_bound(self): - assert load_instructions().char_count <= MAX_INSTRUCTION_CHARS + assert load_instructions(DUCT_PROFILE).char_count <= MAX_INSTRUCTION_CHARS class TestFailsLoudly: @@ -55,17 +59,17 @@ def test_missing_file_names_it(self, fixture_root, missing): assert missing in str(exc.value) def test_empty_file_names_it(self, fixture_root): - (fixture_root / "agent/OFFER.md").write_text(" \n", encoding="utf-8") + (fixture_root / "OFFER.md").write_text(" \n", encoding="utf-8") with pytest.raises(ConfigError) as exc: load_instructions(fixture_root) - assert "agent/OFFER.md" in str(exc.value) + assert "OFFER.md" in str(exc.value) assert "empty" in str(exc.value) def test_oversize_fails_loudly(self, fixture_root): """FR-325: exceeding the cap fails; it MUST NOT truncate silently. A truncated CONSTRAINTS.md would drop hard rules invisibly.""" - (fixture_root / "agent/OFFER.md").write_text("x" * (MAX_INSTRUCTION_CHARS + 1), encoding="utf-8") + (fixture_root / "OFFER.md").write_text("x" * (MAX_INSTRUCTION_CHARS + 1), encoding="utf-8") with pytest.raises(ConfigError) as exc: load_instructions(fixture_root) message = str(exc.value) @@ -73,7 +77,7 @@ def test_oversize_fails_loudly(self, fixture_root): assert "truncated" in message or "never truncated" in message def test_no_truncated_result_is_ever_returned(self, fixture_root): - (fixture_root / "agent/OFFER.md").write_text("x" * (MAX_INSTRUCTION_CHARS + 1), encoding="utf-8") + (fixture_root / "OFFER.md").write_text("x" * (MAX_INSTRUCTION_CHARS + 1), encoding="utf-8") with pytest.raises(ConfigError): load_instructions(fixture_root) @@ -83,12 +87,12 @@ def test_shipped_files_contain_no_secrets(self): """Constitution: instruction files MUST NOT contain credentials.""" import re - text = load_instructions().text + text = load_instructions(DUCT_PROFILE).text assert not re.search(r"(api[_-]?key|password|secret|bearer\s+\w)", text, re.I) def test_shipped_offer_carries_exactly_one_product_url(self): from prospector.instructions import _read - offer = _read(None, "agent/OFFER.md") + offer = _read(DUCT_PROFILE, "OFFER.md") assert offer.count("http") == 1 assert "https://www.omniveer.com/duct-lead-qualifier" in offer diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py new file mode 100644 index 0000000..9dedd73 --- /dev/null +++ b/tests/unit/test_profiles.py @@ -0,0 +1,161 @@ +"""008 US3/US4: per-vertical profiles supply the offer, and a broken one fails +before any company is processed. + +Adding a vertical must need no code change (Constitution v7.0.0, Principle VI), +so profiles are resolved from a search path and validated up front — the locked +fallback is the honesty floor Principle IV falls back to and may never be +silently absent. +""" + +import pytest + +from prospector.config import ConfigError +from prospector.profiles import discover, load, search_paths + +TOML = """tags = ["outreach", "hvac", "prospector"] +signature = "Sam\\nFounder, Example" +product_url = "https://example.com/thing" +keywords = ["hvac repair", "furnace"] +banned_claims = ["running ads", "your ads"] +""" + +FALLBACK = """## Template +Hi {greeting_name}, + +A locked sentence that must survive. + +{signature} + +## Invariants +- A locked sentence that must survive. +""" + + +def make_profile(root, name="hvac", *, omit=(), toml=TOML, fallback=FALLBACK): + d = root / name + (d / "skills").mkdir(parents=True, exist_ok=True) + files = { + "IDENTITY.md": "You are Sam.", + "OFFER.md": "A thing for HVAC companies.", + "CONSTRAINTS.md": "Never invent facts.", + "skills/write-cold-email.md": "Be brief.", + "fallback.md": fallback, + "profile.toml": toml, + } + for rel, content in files.items(): + if rel in omit: + continue + (d / rel).write_text(content, encoding="utf-8") + return d + + +class TestResolutionOrder: + def test_env_var_directory_wins(self, tmp_path, monkeypatch): + env_dir, local_dir = tmp_path / "env", tmp_path / "local" + make_profile(env_dir, "hvac") + make_profile(local_dir, "hvac") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(env_dir)) + monkeypatch.chdir(tmp_path) + (tmp_path / "profiles").mkdir(exist_ok=True) + assert load("hvac").root == env_dir / "hvac" + + def test_local_profiles_dir_is_used_when_no_env_var(self, tmp_path, monkeypatch): + make_profile(tmp_path / "profiles", "hvac") + monkeypatch.delenv("PROSPECTOR_PROFILES", raising=False) + monkeypatch.chdir(tmp_path) + assert load("hvac").root == tmp_path / "profiles" / "hvac" + + def test_packaged_profiles_are_on_the_search_path(self, tmp_path, monkeypatch): + monkeypatch.delenv("PROSPECTOR_PROFILES", raising=False) + monkeypatch.chdir(tmp_path) + assert any("prospector" in str(p) for p in search_paths()) + + def test_bundled_duct_cleaning_profile_loads(self, monkeypatch): + """The reference profile must keep working (FR-016).""" + monkeypatch.delenv("PROSPECTOR_PROFILES", raising=False) + assert "duct-cleaning" in discover() + assert load("duct-cleaning").product_url.startswith("http") + + +class TestDiscovery: + def test_discover_lists_available_names(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac") + make_profile(tmp_path / "p", "landscaping") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + assert {"hvac", "landscaping"} <= set(discover()) + + def test_unknown_name_lists_valid_ones(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + with pytest.raises(ConfigError) as exc: + load("nope") + assert "not found" in str(exc.value) and "hvac" in str(exc.value) + + +class TestLoadedContent: + def test_config_values_are_exposed(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + p = load("hvac") + assert p.name == "hvac" + assert p.tags == ["outreach", "hvac", "prospector"] + assert p.product_url == "https://example.com/thing" + assert p.keywords[0] == "hvac repair" + assert "running ads" in p.banned_claims + assert p.signature.startswith("Sam") + + def test_fallback_template_and_invariants_parsed(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + p = load("hvac") + assert "{greeting_name}" in p.fallback_template + assert p.fallback_invariants == ["A locked sentence that must survive."] + + def test_instructions_are_assembled_from_the_profile(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + text = load("hvac").instructions.text + assert "You are Sam." in text and "Be brief." in text + + +class TestValidationFailsEarly: + """Every failure below must raise before any company is processed (FR-019).""" + + @pytest.mark.parametrize( + "missing", + ["IDENTITY.md", "OFFER.md", "CONSTRAINTS.md", "skills/write-cold-email.md", + "fallback.md", "profile.toml"], + ) + def test_missing_required_file(self, tmp_path, monkeypatch, missing): + make_profile(tmp_path / "p", "hvac", omit=(missing,)) + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + with pytest.raises(ConfigError) as exc: + load("hvac") + assert "hvac" in str(exc.value) + + def test_fallback_without_sections_is_rejected(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac", fallback="just some prose\n") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + with pytest.raises(ConfigError) as exc: + load("hvac") + assert "Template" in str(exc.value) + + def test_missing_toml_key_is_rejected(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac", toml='tags = ["a"]\nsignature = "s"\n') + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + with pytest.raises(ConfigError) as exc: + load("hvac") + assert "product_url" in str(exc.value) + + def test_unparseable_toml_is_rejected(self, tmp_path, monkeypatch): + make_profile(tmp_path / "p", "hvac", toml="not = = toml\n") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + with pytest.raises(ConfigError): + load("hvac") + + def test_empty_required_file_is_rejected(self, tmp_path, monkeypatch): + d = make_profile(tmp_path / "p", "hvac") + (d / "OFFER.md").write_text("", encoding="utf-8") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "p")) + with pytest.raises(ConfigError): + load("hvac") From 880ca5a905db9e4a356965f8a47cb866fb9c3237 Mon Sep 17 00:00:00 2001 From: anusbutt Date: Sat, 25 Jul 2026 15:58:23 +0500 Subject: [PATCH 4/6] feat(008)!: source the offer from the selected profile Completes the profile system. Nothing about a specific offer is hardcoded any more: draft.py loses SIGNATURE, PRODUCT_URL, SUBJECT_TEMPLATE, EMAIL_TEMPLATE, EMAIL_INVARIANTS and AD_CLAIM_SUBSTRINGS, vault.py loses TAGS_LINE, and the values are threaded from the selected profile through assemble_email / validate_email_draft / build_email_draft, agent_draft's validators, and render_note's tags_line. draft.py now only knows how to assemble and validate. fallback.md gains a mandatory ## Subject section so the subject line is profile content too. Also fixes a packaging defect this work uncovered: package-data still listed agent/*.md, a directory removed when the instruction files moved into profiles/, and the root profiles/ dir was never packaged at all. An installed copy therefore shipped no profile and the search path's bundled tier could never resolve, contradicting contracts/profile.md and FR-016. The reference profile moves into the package and is packaged properly; verified by building a wheel and loading the profile from a non-editable install. Tests: 593 passing, from a 532 baseline. - test_profile_validation.py pins the contract's error strings and proves validation precedes all work (the pipeline is never entered, no vault is created, no HTTP is attempted). No production change was needed for the ordering itself -- profile resolution was already the first statement in both run and source -- so the tests now hold it there, including that a broken profile is reported ahead of a missing input file and ahead of the LLM key check. - test_vault_legacy_notes.py covers FR-023/FR-024: a pre-008 note carrying fb_signal/facebook_url and status: approved keeps its status, outcome, ## Log and custom sections byte-identical while converging on the current schema, and a second re-run is a no-op. Verified live against four real vault notes as well. - test_no_facebook_contact_any_command.py freezes the Meta Pixel filter's defaults (marker triple, GTM budget, CSV columns, pixel-only by default) and extends the zero-Facebook-request assertion to send and dashboard, so the "across every command" guarantee is tested as written. - test_profile_switching.py asserts identical input yields different copy, subject, link, signature and tags under two profiles, the second built from files alone. - A regression test keeps prospector/profiles.py from being shadowed as a namespace package by its new sibling prospector/profiles/ data directory -- a break invisible in a source checkout. BREAKING CHANGE: the bundled reference profile moved from profiles/duct-cleaning/ to prospector/profiles/duct-cleaning/. Operator profiles still resolve from $PROSPECTOR_PROFILES and ./profiles/, either of which shadows the bundled copy. Notes rendered without a profile now default to tags [outreach, prospector] rather than a duct-cleaning line. Co-Authored-By: Claude Opus 5 --- prospector/__init__.py | 2 +- prospector/agent_draft.py | 29 +-- prospector/cli.py | 1 + prospector/draft.py | 93 ++------ prospector/fetch.py | 2 +- prospector/pipeline.py | 22 +- prospector/profiles.py | 26 +- .../profiles}/duct-cleaning/CONSTRAINTS.md | 0 .../profiles}/duct-cleaning/IDENTITY.md | 0 .../profiles}/duct-cleaning/OFFER.md | 0 .../profiles}/duct-cleaning/fallback.md | 0 .../profiles}/duct-cleaning/profile.toml | 0 .../duct-cleaning/skills/write-cold-email.md | 0 prospector/vault.py | 5 +- pyproject.toml | 13 +- tests/conftest.py | 22 +- tests/integration/helpers.py | 5 + tests/integration/test_agent_fallback.py | 37 ++- .../test_no_facebook_contact_any_command.py | 222 +++++++++++++++++ tests/unit/test_agent_draft.py | 60 +++-- tests/unit/test_draft.py | 63 ++--- tests/unit/test_instructions.py | 6 +- tests/unit/test_profile_switching.py | 108 +++++++++ tests/unit/test_profile_validation.py | 223 ++++++++++++++++++ tests/unit/test_profiles.py | 34 ++- tests/unit/test_smoke.py | 2 +- tests/unit/test_vault.py | 4 +- tests/unit/test_vault_legacy_notes.py | 170 +++++++++++++ 28 files changed, 960 insertions(+), 189 deletions(-) rename {profiles => prospector/profiles}/duct-cleaning/CONSTRAINTS.md (100%) rename {profiles => prospector/profiles}/duct-cleaning/IDENTITY.md (100%) rename {profiles => prospector/profiles}/duct-cleaning/OFFER.md (100%) rename {profiles => prospector/profiles}/duct-cleaning/fallback.md (100%) rename {profiles => prospector/profiles}/duct-cleaning/profile.toml (100%) rename {profiles => prospector/profiles}/duct-cleaning/skills/write-cold-email.md (100%) create mode 100644 tests/integration/test_no_facebook_contact_any_command.py create mode 100644 tests/unit/test_profile_switching.py create mode 100644 tests/unit/test_profile_validation.py create mode 100644 tests/unit/test_vault_legacy_notes.py diff --git a/prospector/__init__.py b/prospector/__init__.py index 88e89e5..f7bd0f6 100644 --- a/prospector/__init__.py +++ b/prospector/__init__.py @@ -4,4 +4,4 @@ no fabricated names, no unbacked channel claims. """ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/prospector/agent_draft.py b/prospector/agent_draft.py index ef27c89..a248152 100644 --- a/prospector/agent_draft.py +++ b/prospector/agent_draft.py @@ -23,10 +23,7 @@ from prospector.config import Settings from prospector.draft import ( - AD_CLAIM_SUBSTRINGS, OPENROUTER_URL, - PRODUCT_URL, - SIGNATURE, DraftError, _strip_code_fences, build_email_draft, @@ -214,14 +211,14 @@ def parse_response(content: str) -> AgentResponse: # --- Assembly --------------------------------------------------------------- -def assemble_body(prospect: Prospect, response: AgentResponse) -> str: +def assemble_body(prospect: Prospect, response: AgentResponse, profile) -> str: """Greeting + blocks + signature, all in code (FR-306). The model's prose is never edited here — only accepted whole or rejected whole.""" parts = [f"Hi {expected_greeting(prospect)},"] parts.extend(block.text for block in response.blocks) - parts.append(SIGNATURE) + parts.append(profile.signature) return "\n\n".join(parts) @@ -324,25 +321,25 @@ def validate_channel_claims(response: AgentResponse, prospect: Prospect) -> list return errors -def validate_retained(subject: str, body: str, prospect: Prospect) -> list[str]: +def validate_retained(subject: str, body: str, prospect: Prospect, profile) -> list[str]: """Rules V5-V12 (§5.2): the checks that survive free prose, reusing - `draft.py`'s existing predicates and constants.""" + `draft.py`'s predicates and the selected profile's offer constants.""" errors: list[str] = [] lowered = body.lower() if re.search(r"\[[^\]\n]{1,60}\]", body) or re.search(r"\[[^\]\n]{1,60}\]", subject): errors.append("unfilled [slot] remains") - for banned in AD_CLAIM_SUBSTRINGS: + for banned in profile.banned_claims: if banned in lowered or banned in subject.lower(): errors.append(f"ad-running claim detected: {banned!r}") - if body.count("http") != 1 or PRODUCT_URL not in body: + if body.count("http") != 1 or profile.product_url not in body: errors.append("body must carry exactly one promotional link (the product page)") if "linkedin.com" in lowered: errors.append("LinkedIn link may not appear in the pitch") - if not body.rstrip().endswith(SIGNATURE): + if not body.rstrip().endswith(profile.signature): errors.append("signature altered or missing") expected = expected_greeting(prospect) @@ -387,20 +384,20 @@ def validate_retained(subject: str, body: str, prospect: Prospect) -> list[str]: return errors -def validate(response: AgentResponse, body: str, prospect: Prospect, refs: list[EvidenceRef]) -> list[str]: +def validate(response: AgentResponse, body: str, prospect: Prospect, refs: list[EvidenceRef], profile) -> list[str]: """All rules. Every failure is collected so the operator sees each one (FR-314) — no short-circuiting.""" return ( validate_citations(response, prospect, refs) + validate_channel_claims(response, prospect) - + validate_retained(response.subject, body, prospect) + + validate_retained(response.subject, body, prospect, profile) ) # --- Entry point ------------------------------------------------------------ -def draft_email(prospect: Prospect, settings: Settings, instructions=None) -> Draft: +def draft_email(prospect: Prospect, settings: Settings, instructions=None, profile=None) -> Draft: """Agent path with automatic fallback. NEVER raises (G1). Returns an agent-sourced Draft when the model produced validated, cited @@ -418,8 +415,8 @@ def draft_email(prospect: Prospect, settings: Settings, instructions=None) -> Dr else: try: response = request_draft(prospect, settings, instructions, refs) - body = assemble_body(prospect, response) - errors = validate(response, body, prospect, refs) + body = assemble_body(prospect, response, profile) + errors = validate(response, body, prospect, refs, profile) if not errors: return Draft( subject=response.subject, @@ -442,7 +439,7 @@ def draft_email(prospect: Prospect, settings: Settings, instructions=None) -> Dr # unvalidated Draft rather than raising keeps `draft_email` to one contract # (G1) — the pipeline already flags unvalidated drafts for review. try: - fallback = build_email_draft(prospect, settings) + fallback = build_email_draft(prospect, settings, profile) except DraftError as exc: return Draft( subject=None, diff --git a/prospector/cli.py b/prospector/cli.py index 48d9622..24730ee 100644 --- a/prospector/cli.py +++ b/prospector/cli.py @@ -94,6 +94,7 @@ def run( no_llm=no_llm, verbose=verbose, instructions=instructions, + profile=selected, ) except (ConfigError, IngestError) as exc: typer.echo(f"error: {exc}", err=True) diff --git a/prospector/draft.py b/prospector/draft.py index de387a1..9a50bdc 100644 --- a/prospector/draft.py +++ b/prospector/draft.py @@ -21,65 +21,10 @@ "team", "service", "bookings", "mail", "inquiries", "enquiries", } -# Founder-led, compact sign-off (PRODUCT.md §8, rev. 2 2026-07-17). The -# LinkedIn company link is deliberately omitted: it may only appear "when -# appropriate" and must never be forced into every email — a mechanical -# template cannot judge that, so the locked prose leaves it out (005 FR-204). -SIGNATURE = "Anas\nFounder, Omniveer" - -# Link strategy (PRODUCT.md §8, 005 FR-202..205): exactly ONE promotional link -# per body — the product page (it hosts the explanation and the demo, so no -# separate video link and no attachment). Homepage is for Omniveer-broad -# messages only and never combined with the product link. No booking link -# unless one is explicitly configured later. -PRODUCT_URL = "https://www.omniveer.com/duct-lead-qualifier" -COMPANY_URL = "https://www.omniveer.com" -LINKEDIN_COMPANY_URL = "https://www.linkedin.com/company/omniveer/" - -SUBJECT_TEMPLATE = "Free 10-day pilot for {subject_company}" - -# Offer (PRODUCT.md §8, rev. 2 2026-07-17, operator-supplied copy): the -# Omniveer Duct Lead Qualifier, free 10-day pilot for 5 duct-cleaning -# companies. The copy is CHANNEL-NEUTRAL — it makes no claim about the -# prospect's channels (or Facebook at all), so it asserts nothing that would -# need evidence to support (Constitution v7.0.0, Principle IV). -# Ends with the single promotional link (the product page carries the demo) -# and a low-pressure close — no urgency, no guarantees. "Book a demo through -# the page" refers to the page already linked: no second URL. -EMAIL_OFFER_PARAGRAPH = ( - "I'm giving 5 duct-cleaning companies a free 10-day pilot of the " - "Omniveer Duct Lead Qualifier." -) - -EMAIL_TEMPLATE = ( - """Hi {greeting}, - -""" - + EMAIL_OFFER_PARAGRAPH - + """ - -It responds to new leads, qualifies them, books appointments when they're ready, sends the full details to your email, and keeps every lead organized in a dashboard. - -You can see the short demo here: -""" - + PRODUCT_URL - + """ - -Reply to this email if you'd like one of the five pilot spots, or book a demo through the page. - -{signature}""" -) - -# Invariant template prose that must survive assembly byte-for-byte (FR-015) -EMAIL_INVARIANTS = ( - EMAIL_OFFER_PARAGRAPH, - "It responds to new leads, qualifies them, books appointments when they're ready, sends the full details to your email, and keeps every lead organized in a dashboard.", - "You can see the short demo here:\n" + PRODUCT_URL, - "Reply to this email if you'd like one of the five pilot spots, or book a demo through the page.", -) - -# Ad-running is never observable and never claimed (Constitution IV, v7.0.0) -AD_CLAIM_SUBSTRINGS = ("your ads", "ad campaign", "running ads", "advertis", "your facebook ads", "ad spend") +# The offer — signature, promotional link, locked fallback copy, invariants and +# banned-claim vocabulary — is per-vertical CONTENT supplied by the selected +# profile (008, Constitution v7.0.0 Principle VI). Nothing about a specific +# offer is hardcoded here; this module only knows how to assemble and validate. SYSTEM_PROMPT = """You fill two slots for a locked outreach email template. Reply with a JSON object only: {"greeting_name": ..., "subject_company": ...} @@ -170,45 +115,45 @@ def request_slots(prospect: Prospect, settings: Settings) -> dict: return slots -def assemble_email(prospect: Prospect, slots: dict) -> Draft: - """Deterministic assembly from template constants + validated slot fills. +def assemble_email(prospect: Prospect, slots: dict, profile) -> Draft: + """Deterministic assembly from the profile's locked copy + validated slots. - Rev. 2 (2026-07-17): one channel-neutral template — the copy makes no - claims about the prospect's channels, so nothing in it requires evidence - (Constitution v7.0.0, Principle IV).""" + The template is channel-neutral: it makes no claim about the prospect, so + nothing in it requires evidence (Constitution v7.0.0, Principle IV). Only + bracketed slots are filled — the prose is never paraphrased.""" greeting = str(slots.get("greeting_name", "")).strip() subject_company = str(slots.get("subject_company", "")).strip() or prospect.company.company - body = EMAIL_TEMPLATE.format(greeting=greeting, signature=SIGNATURE) - subject = SUBJECT_TEMPLATE.format(subject_company=subject_company) - errors = validate_email_draft(subject, body, prospect, slots) + body = profile.fallback_template.format(greeting=greeting, signature=profile.signature) + subject = profile.fallback_subject.format(subject_company=subject_company) + errors = validate_email_draft(subject, body, prospect, slots, profile) return Draft(subject=subject, body=body, model="", validated=not errors, validation_errors=errors) -def validate_email_draft(subject: str, body: str, prospect: Prospect, slots: dict) -> list[str]: +def validate_email_draft(subject: str, body: str, prospect: Prospect, slots: dict, profile) -> list[str]: errors: list[str] = [] lowered = body.lower() if re.search(r"\[[^\]\n]{1,60}\]", body) or re.search(r"\[[^\]\n]{1,60}\]", subject): errors.append("unfilled [slot] remains") - for line in EMAIL_INVARIANTS: + for line in profile.fallback_invariants: if line not in body: errors.append(f"template prose altered: missing {line[:40]!r}...") - for banned in AD_CLAIM_SUBSTRINGS: + for banned in profile.banned_claims: if banned in lowered or banned in subject.lower(): errors.append(f"ad-running claim detected: {banned!r}") # Link strategy (005 FR-202..205): exactly one promotional link — the # product page (demo lives there). This structurally blocks a homepage+ # product combo, second/video/booking links, and any link a slot smuggles in. - if body.count("http") != 1 or PRODUCT_URL not in body: + if body.count("http") != 1 or profile.product_url not in body: errors.append("body must carry exactly one promotional link (the product page)") if "linkedin.com" in lowered: errors.append("LinkedIn link may not appear in the pitch") - if not body.rstrip().endswith(SIGNATURE): + if not body.rstrip().endswith(profile.signature): errors.append("signature altered or missing") # Constitution Principle V: the rev.-2 template is channel-neutral — no @@ -243,8 +188,8 @@ def validate_email_draft(subject: str, body: str, prospect: Prospect, slots: dic return errors -def build_email_draft(prospect: Prospect, settings: Settings) -> Draft: +def build_email_draft(prospect: Prospect, settings: Settings, profile) -> Draft: slots = request_slots(prospect, settings) - draft = assemble_email(prospect, slots) + draft = assemble_email(prospect, slots, profile) draft.model = settings.openrouter_model return draft diff --git a/prospector/fetch.py b/prospector/fetch.py index 2f5b80d..962ed2b 100644 --- a/prospector/fetch.py +++ b/prospector/fetch.py @@ -14,7 +14,7 @@ # Facebook-owned surfaces: never contacted, under any circumstances. BLOCKED_HOSTS = ("facebook.com", "fb.com", "fb.me", "fbcdn.net", "messenger.com") -USER_AGENT = "Prospector/0.1 (open-web outreach research; +https://www.omniveer.com)" +USER_AGENT = "Prospector/0.2 (open-web outreach research; +https://www.omniveer.com)" TIMEOUT = httpx.Timeout(15.0, connect=10.0) MAX_RETRIES = 2 diff --git a/prospector/pipeline.py b/prospector/pipeline.py index 9a39abd..cb7dd8a 100644 --- a/prospector/pipeline.py +++ b/prospector/pipeline.py @@ -48,10 +48,12 @@ def run_batch( verbose: bool = False, fetcher: Fetcher | None = None, instructions=None, + profile=None, ) -> RunSummary: """Process a batch. Raises IngestError only for pre-flight problems. - `instructions` is the pre-flighted InstructionSet for agent drafting; when + `profile` is the pre-flighted offer profile (008) — it supplies the copy + constants and note tags. `instructions` is its InstructionSet; when None the drafting path falls back to the locked template (FR-315).""" vault_dir = Path(vault_dir) if vault_dir else settings.vault_dir fetcher = fetcher or Fetcher() @@ -83,8 +85,9 @@ def run_batch( verbose=verbose, frozen=frozen, instructions=instructions, + profile=profile, ) - outcome, detail = _write(prospect, draft, vault_dir, no_llm=no_llm, frozen=frozen) + outcome, detail = _write(prospect, draft, vault_dir, no_llm=no_llm, frozen=frozen, profile=profile) _count_drafting_path(summary, company.slug, draft) if prospect.research.email_evidence is not None: summary.email_recovered += 1 @@ -103,7 +106,7 @@ def run_batch( prospect = Prospect(company=company, research=ResearchResult(website=company.website)) prospect.needs_review = True prospect.research.failures.append(f"processing failed: {exc}") - _write(prospect, None, vault_dir, no_llm=no_llm, frozen=frozen) + _write(prospect, None, vault_dir, no_llm=no_llm, frozen=frozen, profile=profile) summary.failed += 1 outcome, detail = "failed", str(exc) _count(summary, prospect) @@ -121,6 +124,7 @@ def _process_company( verbose: bool, frozen: bool = False, instructions=None, + profile=None, ) -> tuple[Prospect, Draft | None]: research = _research(company, settings, fetcher, verbose=verbose) if not company.email: @@ -135,7 +139,7 @@ def _process_company( return prospect, draft # 006: agent path with automatic template fallback. Never raises, so the # batch cannot be aborted by a drafting failure (FR-318). - draft = agent_draft.draft_email(prospect, settings, instructions) + draft = agent_draft.draft_email(prospect, settings, instructions, profile) if draft.validation_errors: research.failures.extend(draft.validation_errors) if draft is not None and not draft.validated: @@ -229,7 +233,8 @@ def _score(company: Company, research: ResearchResult, settings: Settings) -> Pr def _write( - prospect: Prospect, draft: Draft | None, vault_dir: Path, *, no_llm: bool, frozen: bool = False + prospect: Prospect, draft: Draft | None, vault_dir: Path, *, no_llm: bool, + frozen: bool = False, profile=None, ) -> tuple[str, str]: draft_md = vault.draft_markdown_for(draft, prospect, no_llm=no_llm) research_md = vault.build_research_markdown(prospect) @@ -238,9 +243,10 @@ def _write( citations_md = vault.build_citations_markdown( draft, agent_draft.build_evidence_refs(prospect.research) ) - note = vault.render_note( - prospect, draft_md, research_md, draft=draft, citations_markdown=citations_md - ) + render_kwargs = {"draft": draft, "citations_markdown": citations_md} + if profile is not None: + render_kwargs["tags_line"] = profile.tags_line + note = vault.render_note(prospect, draft_md, research_md, **render_kwargs) result = vault.upsert_note(vault_dir, prospect.company.slug, note, freeze_draft=frozen) if frozen: detail = f"draft frozen (approved/sent), research refreshed, note {result}" diff --git a/prospector/profiles.py b/prospector/profiles.py index 4f22033..5c7e718 100644 --- a/prospector/profiles.py +++ b/prospector/profiles.py @@ -32,6 +32,7 @@ REQUIRED_KEYS = ("tags", "signature", "product_url", "keywords", "banned_claims") +SUBJECT_HEADING = "## Subject" TEMPLATE_HEADING = "## Template" INVARIANTS_HEADING = "## Invariants" @@ -43,6 +44,7 @@ class Profile: name: str root: Path instructions: InstructionSet + fallback_subject: str fallback_template: str fallback_invariants: list[str] tags: list[str] @@ -113,18 +115,21 @@ def _read(root: Path, name: str, relative: str) -> str: return content -def _parse_fallback(name: str, text: str) -> tuple[str, list[str]]: +def _parse_fallback(name: str, text: str) -> tuple[str, str, list[str]]: """Split fallback.md into its locked template and its invariants. Both sections are mandatory: the template is what answers when generated copy fails validation, and the invariants are what prove it was not paraphrased on the way out.""" - if TEMPLATE_HEADING not in text or INVARIANTS_HEADING not in text: - raise ConfigError( - f"profile {name!r}: fallback.md must contain " - f"'{TEMPLATE_HEADING}' and '{INVARIANTS_HEADING}'" - ) - _, rest = text.split(TEMPLATE_HEADING, 1) + for heading in (SUBJECT_HEADING, TEMPLATE_HEADING, INVARIANTS_HEADING): + if heading not in text: + raise ConfigError( + f"profile {name!r}: fallback.md must contain " + f"'{SUBJECT_HEADING}', '{TEMPLATE_HEADING}' and '{INVARIANTS_HEADING}'" + ) + _, after_subject = text.split(SUBJECT_HEADING, 1) + subject_block, rest = after_subject.split(TEMPLATE_HEADING, 1) + subject = subject_block.strip() template, invariant_block = rest.split(INVARIANTS_HEADING, 1) template = template.strip("\n") invariants = [ @@ -134,9 +139,11 @@ def _parse_fallback(name: str, text: str) -> tuple[str, list[str]]: ] if not template.strip(): raise ConfigError(f"profile {name!r}: fallback.md has an empty {TEMPLATE_HEADING} section") + if not subject: + raise ConfigError(f"profile {name!r}: fallback.md has an empty {SUBJECT_HEADING} section") if not invariants: raise ConfigError(f"profile {name!r}: fallback.md lists no invariants") - return template, invariants + return subject, template, invariants def _parse_config(name: str, text: str) -> dict: @@ -159,7 +166,7 @@ def load(name: str) -> Profile: for relative in REQUIRED_FILES: _read(root, name, relative) # presence + non-empty - template, invariants = _parse_fallback(name, _read(root, name, "fallback.md")) + subject, template, invariants = _parse_fallback(name, _read(root, name, "fallback.md")) config = _parse_config(name, _read(root, name, "profile.toml")) try: @@ -171,6 +178,7 @@ def load(name: str) -> Profile: name=name, root=root, instructions=instructions, + fallback_subject=subject, fallback_template=template, fallback_invariants=invariants, tags=list(config["tags"]), diff --git a/profiles/duct-cleaning/CONSTRAINTS.md b/prospector/profiles/duct-cleaning/CONSTRAINTS.md similarity index 100% rename from profiles/duct-cleaning/CONSTRAINTS.md rename to prospector/profiles/duct-cleaning/CONSTRAINTS.md diff --git a/profiles/duct-cleaning/IDENTITY.md b/prospector/profiles/duct-cleaning/IDENTITY.md similarity index 100% rename from profiles/duct-cleaning/IDENTITY.md rename to prospector/profiles/duct-cleaning/IDENTITY.md diff --git a/profiles/duct-cleaning/OFFER.md b/prospector/profiles/duct-cleaning/OFFER.md similarity index 100% rename from profiles/duct-cleaning/OFFER.md rename to prospector/profiles/duct-cleaning/OFFER.md diff --git a/profiles/duct-cleaning/fallback.md b/prospector/profiles/duct-cleaning/fallback.md similarity index 100% rename from profiles/duct-cleaning/fallback.md rename to prospector/profiles/duct-cleaning/fallback.md diff --git a/profiles/duct-cleaning/profile.toml b/prospector/profiles/duct-cleaning/profile.toml similarity index 100% rename from profiles/duct-cleaning/profile.toml rename to prospector/profiles/duct-cleaning/profile.toml diff --git a/profiles/duct-cleaning/skills/write-cold-email.md b/prospector/profiles/duct-cleaning/skills/write-cold-email.md similarity index 100% rename from profiles/duct-cleaning/skills/write-cold-email.md rename to prospector/profiles/duct-cleaning/skills/write-cold-email.md diff --git a/prospector/vault.py b/prospector/vault.py index d483c42..4a775ed 100644 --- a/prospector/vault.py +++ b/prospector/vault.py @@ -31,7 +31,7 @@ "tags", ) -TAGS_LINE = "[outreach, duct-cleaning, prospector]" +# 008: note tags are per-vertical content supplied by the selected profile. MAX_SLUG_LENGTH = 80 @@ -98,6 +98,7 @@ def render_note( log_markdown: str = "-", draft: Draft | None = None, citations_markdown: str = "", + tags_line: str = "[outreach, prospector]", ) -> str: company = prospect.company values = { @@ -122,7 +123,7 @@ def render_note( lines = ["---"] for key in FRONTMATTER_KEYS: if key == "tags": - lines.append(f"tags: {TAGS_LINE}") + lines.append(f"tags: {tags_line}") else: rendered = _yaml_value(values[key]) lines.append(f"{key}: {rendered}".rstrip()) diff --git a/pyproject.toml b/pyproject.toml index 238fd45..04f8c0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "prospector" -version = "0.1.0" -description = "Researches home-service companies on the open web and drafts personalized, evidence-cited outreach into an Obsidian vault, then delivers only what a human approves. Never contacts Facebook." +version = "0.2.0" +description = "Researches local-service companies on the open web and drafts personalized, evidence-cited outreach into an Obsidian vault, then delivers by email only what a human approves. The offer is a swappable per-vertical profile. Never contacts Facebook." license = {file = "LICENSE"} requires-python = ">=3.11" dependencies = [ @@ -32,7 +32,14 @@ prospector = "prospector.cli:app" include = ["prospector*"] [tool.setuptools.package-data] -prospector = ["data/*.txt", "agent/*.md", "agent/skills/*.md"] +prospector = [ + "data/*.txt", + # The bundled reference profile (008). Shipped so an installed copy has a + # working profile and the packaged tier of the profile search path resolves. + "profiles/*/*.md", + "profiles/*/*.toml", + "profiles/*/skills/*.md", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py index c504a78..b63b506 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,10 +13,26 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# The reference profile ships inside the package, so it resolves through the +# search path's packaged tier no matter what the working directory is. Tests +# still pin the env var explicitly rather than relying on that tier, so a test +# that chdirs into a tmp_path cannot accidentally pick up a stray ./profiles/. +BUNDLED_PROFILES = os.path.join(REPO_ROOT, "prospector", "profiles") + @pytest.fixture(autouse=True) def default_profile(monkeypatch): monkeypatch.setenv("PROSPECTOR_PROFILE", "duct-cleaning") - # Profiles resolve relative to the working directory; pin it to the repo so - # the bundled reference profile is found no matter where pytest was invoked. - monkeypatch.setenv("PROSPECTOR_PROFILES", os.path.join(REPO_ROOT, "profiles")) + monkeypatch.setenv("PROSPECTOR_PROFILES", BUNDLED_PROFILES) + + +@pytest.fixture +def duct_profile(): + """The bundled reference profile, for tests that exercise drafting. + + 008: the offer lives in the profile, so anything that assembles or + validates copy needs one. Loading the real reference profile (rather than a + stub) keeps these tests honest about the copy that actually ships.""" + from prospector.profiles import load + + return load("duct-cleaning") diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index fbace45..10ae2c5 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -9,6 +9,7 @@ from prospector.config import Settings from prospector.fetch import Fetcher from prospector.pipeline import run_batch +from prospector.profiles import load as load_profile FIXTURES = Path(__file__).parent.parent / "fixtures" SITES = FIXTURES / "sites" @@ -107,6 +108,10 @@ def run_fixture_batch(tmp_path, csv_content=CSV_CONTENT, **kwargs): csv_path.write_text(csv_content) vault_dir = tmp_path / "Vault" / "Outreach" fetcher = Fetcher(clock=lambda: 0.0, sleep=lambda s: None) + # 008: every run selects an offer profile; the reference profile keeps these + # fixtures asserting against the copy that actually ships. + kwargs.setdefault("profile", load_profile("duct-cleaning")) + kwargs.setdefault("instructions", kwargs["profile"].instructions) summary = run_batch(csv_path, settings(vault_dir), vault_dir=vault_dir, fetcher=fetcher, **kwargs) return summary, vault_dir diff --git a/tests/integration/test_agent_fallback.py b/tests/integration/test_agent_fallback.py index 73cc54f..8a577a8 100644 --- a/tests/integration/test_agent_fallback.py +++ b/tests/integration/test_agent_fallback.py @@ -12,7 +12,6 @@ from prospector.agent_draft import draft_email from prospector.config import Settings -from prospector.draft import PRODUCT_URL, SIGNATURE from prospector.instructions import InstructionSet from prospector.models import ( Company, @@ -22,6 +21,18 @@ ResearchResult, ) +# 008: the offer moved into the profile. Read the shipped values straight from +# the reference profile so these tests assert against the copy that actually +# ships, not a second copy that could drift. It resolves through the packaged +# tier of the search path, so no environment setup is needed here. +from prospector.profiles import load as _load_profile + +PROFILE = _load_profile("duct-cleaning") + +PRODUCT_URL = PROFILE.product_url +SIGNATURE = PROFILE.signature + + OPENROUTER = "https://openrouter.ai/api/v1/chat/completions" TEMPLATE_SLOTS = json.dumps({"greeting_name": "Acme Duct Cleaning team", "subject_company": "Acme Duct"}) @@ -81,7 +92,7 @@ class TestHappyPath: @respx.mock def test_valid_agent_response_is_used(self, settings, instructions): respx.post(OPENROUTER).mock(return_value=httpx.Response(200, json=agent_reply(VALID_BLOCKS))) - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "agent" assert draft.validated assert "Twenty-two years" in draft.body @@ -94,7 +105,7 @@ class TestNeverRaises: @respx.mock def test_transport_error(self, settings, instructions): respx.post(OPENROUTER).mock(side_effect=httpx.ConnectError("boom")) - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" assert any("agent call failed" in e for e in draft.validation_errors) @@ -102,7 +113,7 @@ def test_transport_error(self, settings, instructions): def test_http_500(self, settings, instructions): route = respx.post(OPENROUTER) route.side_effect = [httpx.Response(500, text="upstream down"), httpx.Response(200, json=template_reply())] - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" @respx.mock @@ -112,7 +123,7 @@ def test_malformed_json(self, settings, instructions): httpx.Response(200, json={"choices": [{"message": {"content": "not json at all"}}]}), httpx.Response(200, json=template_reply()), ] - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" assert any("malformed" in e for e in draft.validation_errors) @@ -123,7 +134,7 @@ def test_bad_block_count(self, settings, instructions): httpx.Response(200, json=agent_reply(VALID_BLOCKS * 3)), # 12 blocks httpx.Response(200, json=template_reply()), ] - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" assert any("expected 3-6" in e for e in draft.validation_errors) @@ -136,7 +147,7 @@ def test_citation_failure(self, settings, instructions): httpx.Response(200, json=agent_reply(bad)), httpx.Response(200, json=template_reply()), ] - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" assert any("unknown id 'about_page_9'" in e for e in draft.validation_errors) @@ -149,7 +160,7 @@ def test_retained_check_failure(self, settings, instructions): httpx.Response(200, json=agent_reply(bad)), httpx.Response(200, json=template_reply()), ] - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" assert any("ad-running claim" in e for e in draft.validation_errors) @@ -163,7 +174,7 @@ def test_never_raises_across_all_modes(self, settings, instructions): httpx.Response(200, text="{}"), ): respx.post(OPENROUTER).mock(side_effect=[side_effect, httpx.Response(200, json=template_reply())]) - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft is not None @@ -172,7 +183,7 @@ class TestNoRequestWhenPointless: def test_no_evidence_no_request(self, settings, instructions): """G3/FR-317: nothing to cite means the agent call is pure cost.""" route = respx.post(OPENROUTER).mock(return_value=httpx.Response(200, json=template_reply())) - draft = draft_email(make_prospect(with_evidence=False), settings, instructions) + draft = draft_email(make_prospect(with_evidence=False), settings, instructions, PROFILE) assert draft.source == "template" assert any("no evidence to cite" in e for e in draft.validation_errors) # exactly one call: the template's own slot fill, never the agent's @@ -181,7 +192,7 @@ def test_no_evidence_no_request(self, settings, instructions): @respx.mock def test_no_instructions_no_agent_request(self, settings): route = respx.post(OPENROUTER).mock(return_value=httpx.Response(200, json=template_reply())) - draft = draft_email(make_prospect(), settings, instructions=None) + draft = draft_email(make_prospect(), settings, instructions=None, profile=PROFILE) assert draft.source == "template" assert route.call_count == 1 @@ -195,7 +206,7 @@ def test_total_outage_matches_template_output(self, settings, instructions): records that no draft was produced. Equivalence with today is the bar — offline drafting was never the promise.""" respx.post(OPENROUTER).mock(side_effect=httpx.ConnectError("network unreachable")) - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" assert not draft.validated assert any("agent call failed" in e for e in draft.validation_errors) @@ -209,7 +220,7 @@ def test_template_still_works_when_only_agent_output_is_bad(self, settings, inst httpx.Response(200, json=agent_reply([{"text": "x", "cites": []}] * 4)), httpx.Response(200, json=template_reply()), ] - draft = draft_email(make_prospect(), settings, instructions) + draft = draft_email(make_prospect(), settings, instructions, PROFILE) assert draft.source == "template" assert draft.validated, "template copy itself must still be valid" assert "free 10-day pilot" in draft.body diff --git a/tests/integration/test_no_facebook_contact_any_command.py b/tests/integration/test_no_facebook_contact_any_command.py new file mode 100644 index 0000000..58df09c --- /dev/null +++ b/tests/integration/test_no_facebook_contact_any_command.py @@ -0,0 +1,222 @@ +"""008 US6: the pixel filter is unchanged, and NO command contacts Facebook. + +`test_source_pixel_fetch.py` already proves classification is correct and that +sourcing never requests a Facebook host. Two things it does not pin, and which +008 explicitly promises not to have changed, are covered here: + +- **SC-007 / FR-021**: the filter's *defaults* — the marker set, the container + budget, and the fact that a default run keeps only pixel-positive rows. 008 + removed Facebook as a channel while deliberately keeping Facebook-ad + infrastructure as a sourcing signal, so the exact place that line is drawn is + worth freezing. +- **SC-002 / FR-022**: "across EVERY command". The guarantee was previously + asserted for `run` and `source` only; `send` and `dashboard` are covered here + too, so the claim in the README safety table is tested as written. + +Every marker below is matched as a STRING in already-fetched markup. A Facebook +URL appearing in a page is read, never requested (Constitution v7.0.0, II). +""" + +from pathlib import Path + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +from prospector import source as source_mod +from prospector.cli import app +from prospector.config import Settings +from prospector.fetch import Fetcher +from prospector.source import PLACES_URL, run_sourcing + +runner = CliRunner() + +FB_HOST_PATTERN = r".*(facebook\.com|fb\.com|fb\.me|fbcdn\.net|messenger\.com)$" + +# Pixel markup AND an inline address, so no contact-page hop is needed. +PIXEL_HTML = ( + "" + "Acme mail" +) +# Facebook links, an embed, and a tracking-beacon URL — all read, none fetched. +FB_STUFFED_HTML = ( + "" + 'our page' + '' + 'mail' + "" +) + + +def settings(tmp_path=None): + return Settings( + openrouter_key=None, + openrouter_model="test/model", + places_key="places-x", + hunter_key=None, + vault_dir=Path(tmp_path or "Vault/Outreach"), + ) + + +def quick_fetcher(): + return Fetcher(client=httpx.Client(follow_redirects=True), host_interval=0.0, sleep=lambda s: None) + + +def place(pid, name, website): + return { + "id": pid, + "displayName": {"text": name}, + "websiteUri": website, + "formattedAddress": "1 Main St, Denver, CO 80202, USA", + } + + +def stub_two_candidates(): + """One pixel-positive, one Facebook-stuffed but pixel-free.""" + respx.post(PLACES_URL).mock( + return_value=httpx.Response( + 200, + json={ + "places": [ + place("p1", "Acme Duct", "https://acme.com"), + place("p2", "Plain Vents", "https://plain.com"), + ] + }, + ) + ) + respx.get("https://acme.com/").mock(return_value=httpx.Response(200, text=PIXEL_HTML)) + respx.get("https://plain.com/").mock(return_value=httpx.Response(200, text=FB_STUFFED_HTML)) + + +def signals(out: Path) -> dict[str, str]: + rows = out.read_text(encoding="utf-8").splitlines()[1:] + return {r.split(",")[0]: r.rsplit(",", 1)[1] for r in rows} + + +class TestPixelFilterDefaultsUnchanged: + """FR-021: behavior AND defaults are unchanged by this feature.""" + + def test_marker_set_is_frozen(self): + assert source_mod.PIXEL_MARKERS == ( + "connect.facebook.net", + "fbq(", + "facebook.com/tr", + ) + + def test_container_budget_is_frozen(self): + assert source_mod.MAX_GTM_CONTAINERS == 2 + + def test_csv_columns_are_frozen(self): + """Columns 1-4 stay exactly the `run` input format; ad_signal rides along.""" + assert source_mod.CSV_HEADER == ["company", "email", "website", "city", "ad_signal"] + + def test_a_facebook_link_alone_is_not_a_pixel(self): + """A page linking to Facebook is not a page running Meta ads.""" + assert source_mod.detect_pixel(FB_STUFFED_HTML) == "none" + + @respx.mock + def test_default_run_keeps_only_pixel_rows(self, tmp_path): + stub_two_candidates() + out = tmp_path / "c.csv" + summary = run_sourcing( + settings(), + keyword="duct cleaning", + metros=["Denver, CO"], + out=out, + fetcher=quick_fetcher(), + ) + assert signals(out) == {"Acme Duct": "pixel"} + assert summary.written == 1 + assert summary.pixel_positive == 1 + # The dropped candidate is still reported, so a 0-row run is explicable. + assert summary.kept_with_all == 2 + + @respx.mock + def test_all_flag_keeps_every_candidate(self, tmp_path): + stub_two_candidates() + out = tmp_path / "c.csv" + run_sourcing( + settings(), + keyword="duct cleaning", + metros=["Denver, CO"], + out=out, + keep_all=True, + fetcher=quick_fetcher(), + ) + assert signals(out) == {"Acme Duct": "pixel", "Plain Vents": "none"} + + def test_cli_defaults_are_frozen(self): + """--all off, 60-query budget, candidates.csv — unchanged by 008.""" + result = runner.invoke(app, ["source", "--help"]) + assert result.exit_code == 0 + assert "60" in result.output + assert "candidates.csv" in result.output + + +class TestNoCommandContactsFacebook: + """SC-002 / FR-022, asserted for every command the CLI exposes.""" + + @pytest.fixture + def blocked(self): + """A route that answers 200 — so a leak shows up as a call, not an error.""" + with respx.mock: + yield respx.route(host__regex=FB_HOST_PATTERN).mock( + return_value=httpx.Response(200) + ) + + def test_source_makes_no_facebook_request(self, tmp_path, blocked): + stub_two_candidates() + run_sourcing( + settings(), + keyword="duct cleaning", + metros=["Denver, CO"], + out=tmp_path / "c.csv", + keep_all=True, + fetcher=quick_fetcher(), + ) + assert blocked.call_count == 0 + + def test_run_makes_no_facebook_request(self, tmp_path, blocked, monkeypatch): + """A company whose only listed contact is a Facebook URL is skipped, + not fetched — the address field is read, never dialled.""" + from helpers import run_fixture_batch + + csv = ( + "company,email,website,city\n" + "Plain Vents,https://facebook.com/plain,,Denver\n" + ) + respx.get("https://html.duckduckgo.com/html/").mock( + return_value=httpx.Response(200, text="no results") + ) + respx.post("https://openrouter.ai/api/v1/chat/completions").mock( + return_value=httpx.Response(200, json={"choices": [{"message": {"content": "{}"}}]}) + ) + summary, _ = run_fixture_batch(tmp_path, csv_content=csv) + assert summary.no_email_skipped == 1 + assert blocked.call_count == 0 + + def test_dashboard_makes_no_facebook_request(self, tmp_path, blocked): + vault_dir = tmp_path / "Vault" / "Outreach" + vault_dir.mkdir(parents=True) + result = runner.invoke(app, ["dashboard", "--vault", str(vault_dir)]) + assert result.exit_code == 0 + assert blocked.call_count == 0 + + def test_send_dry_run_makes_no_facebook_request(self, tmp_path, blocked, monkeypatch): + """Dry-run also proves it opens no connection at all (FR-020).""" + vault_dir = tmp_path / "Vault" / "Outreach" + vault_dir.mkdir(parents=True) + (vault_dir / "plain-vents.md").write_text( + "---\ncompany: Plain Vents\nemail: hi@plain.com\nchannel: email\n" + "status: approved\ntags: [outreach, prospector]\n---\n\n" + "## Draft\n**Subject:** Hello\n\nA body.\n\n## Log\n-\n", + encoding="utf-8", + ) + monkeypatch.setenv("PROSPECTOR_SEND_PROVIDER", "gmail") + monkeypatch.setenv("PROSPECTOR_SEND_FROM", "outreach@example.com") + monkeypatch.setenv("PROSPECTOR_LEDGER", str(tmp_path / "ledger.jsonl")) + result = runner.invoke(app, ["send", "--vault", str(vault_dir)]) + assert result.exit_code == 0 + assert "WOULD SEND" in result.output + assert blocked.call_count == 0 diff --git a/tests/unit/test_agent_draft.py b/tests/unit/test_agent_draft.py index c60852b..7166d5d 100644 --- a/tests/unit/test_agent_draft.py +++ b/tests/unit/test_agent_draft.py @@ -26,7 +26,6 @@ validate_retained, ) from prospector.config import Settings -from prospector.draft import PRODUCT_URL, SIGNATURE from prospector.instructions import InstructionSet from prospector.models import ( AgentResponse, @@ -39,6 +38,17 @@ ResearchResult, ) +# 008: the offer moved into the profile. Load the shipped reference profile so +# these tests assert against the copy that actually ships, not a second copy +# that could drift. It resolves through the packaged tier of the search path, so +# no environment setup is needed here. +from prospector.profiles import load as _load_profile + +PROFILE = _load_profile("duct-cleaning") +PRODUCT_URL = PROFILE.product_url +SIGNATURE = PROFILE.signature + + OPENROUTER = "https://openrouter.ai/api/v1/chat/completions" @@ -226,7 +236,7 @@ def test_parse_tolerates_code_fences(self): class TestAssembly: def test_assembly_golden(self): - body = assemble_body(make_prospect(name_used="Scott"), good_response()) + body = assemble_body(make_prospect(name_used="Scott"), good_response(), PROFILE) assert body == ( "Hi Scott,\n\n" "Twenty-two years around Dallas is a long time.\n\n" @@ -237,7 +247,7 @@ def test_assembly_golden(self): ) def test_greeting_and_signature_come_from_code(self): - body = assemble_body(make_prospect(), good_response()) + body = assemble_body(make_prospect(), good_response(), PROFILE) assert body.startswith("Hi Acme Duct Cleaning team,") assert body.rstrip().endswith(SIGNATURE) @@ -335,75 +345,75 @@ def test_all_reasons_collected(self): class TestRetainedRules: def body_for(self, prospect, response=None): - return assemble_body(prospect, response or good_response()) + return assemble_body(prospect, response or good_response(), PROFILE) def test_retained_valid_body_passes(self): p = make_prospect() - assert validate_retained("Free 10-day pilot for Acme Duct", self.body_for(p), p) == [] + assert validate_retained("Free 10-day pilot for Acme Duct", self.body_for(p), p, PROFILE) == [] def test_retained_rejects_ad_claim(self): p = make_prospect() response = good_response() response.blocks[0].text = "Your ads are bringing in leads you cannot answer." - errors = validate_retained("Acme Duct", self.body_for(p, response), p) + errors = validate_retained("Acme Duct", self.body_for(p, response), p, PROFILE) assert any("ad-running claim" in e for e in errors) def test_retained_rejects_second_link(self): p = make_prospect() response = good_response() response.blocks[3].text = "Book here: https://cal.com/anas" - errors = validate_retained("Acme Duct", self.body_for(p, response), p) + errors = validate_retained("Acme Duct", self.body_for(p, response), p, PROFILE) assert any("exactly one promotional link" in e for e in errors) def test_retained_rejects_zero_links(self): p = make_prospect() response = good_response() response.blocks[2].text = "It books the job for you." - errors = validate_retained("Acme Duct", self.body_for(p, response), p) + errors = validate_retained("Acme Duct", self.body_for(p, response), p, PROFILE) assert any("exactly one promotional link" in e for e in errors) def test_retained_rejects_linkedin(self): p = make_prospect() response = good_response() response.blocks[3].text = "More: https://www.linkedin.com/company/omniveer/" - errors = validate_retained("Acme Duct", self.body_for(p, response), p) + errors = validate_retained("Acme Duct", self.body_for(p, response), p, PROFILE) assert any("LinkedIn" in e for e in errors) def test_retained_rejects_unfilled_slot(self): p = make_prospect() response = good_response() response.blocks[0].text = "Hello [Company Name], quick note." - errors = validate_retained("Acme Duct", self.body_for(p, response), p) + errors = validate_retained("Acme Duct", self.body_for(p, response), p, PROFILE) assert any("[slot]" in e for e in errors) def test_retained_rejects_missing_signature(self): p = make_prospect() body = self.body_for(p).replace(SIGNATURE, "Cheers, Anas") - assert any("signature" in e for e in validate_retained("Acme Duct", body, p)) + assert any("signature" in e for e in validate_retained("Acme Duct", body, p, PROFILE)) def test_retained_rejects_wrong_greeting(self): p = make_prospect() body = self.body_for(p).replace("Hi Acme Duct Cleaning team,", "Hi Bob,") - assert any("greeting must be" in e for e in validate_retained("Acme Duct", body, p)) + assert any("greeting must be" in e for e in validate_retained("Acme Duct", body, p, PROFILE)) def test_retained_rejects_subject_naming_a_different_company(self): """Revised rule (2026-07-20): originality is fine, invention is not.""" p = make_prospect() - errors = validate_retained("Quote for Zerorez Industries", self.body_for(p), p) + errors = validate_retained("Quote for Zerorez Industries", self.body_for(p), p, PROFILE) assert any("shares no word with the company name" in e for e in errors) def test_retained_allows_offer_vocabulary_in_subject(self): p = make_prospect() - assert validate_retained("Free 10-day pilot for Acme Duct", self.body_for(p), p) == [] + assert validate_retained("Free 10-day pilot for Acme Duct", self.body_for(p), p, PROFILE) == [] def test_retained_name_must_trace_to_evidence(self): p = make_prospect(name_used="Marcus") # not in any evidence record - errors = validate_retained("Acme Duct", self.body_for(p), p) + errors = validate_retained("Acme Duct", self.body_for(p), p, PROFILE) assert any("does not trace" in e for e in errors) def test_retained_accepts_sourced_name(self): p = make_prospect(name_used="Scott") # matches about_page_1 evidence - assert not any("does not trace" in e for e in validate_retained("Acme Duct", self.body_for(p), p)) + assert not any("does not trace" in e for e in validate_retained("Acme Duct", self.body_for(p), p, PROFILE)) # --- V13 possessive-channel guard (added after the first live run) ---------- @@ -484,8 +494,8 @@ def test_live_all_pro_draft_would_now_fall_back(self): ) from prospector.agent_draft import validate - body = assemble_body(p, response) - errors = validate(response, body, p, self.refs(p)) + body = assemble_body(p, response, PROFILE) + errors = validate(response, body, p, self.refs(p), PROFILE) assert any("claims the prospect's own channel" in e for e in errors) @@ -494,7 +504,7 @@ def test_live_all_pro_draft_would_now_fall_back(self): class TestSubjectRule: def body_for(self, prospect): - return assemble_body(prospect, good_response()) + return assemble_body(prospect, good_response(), PROFILE) @pytest.mark.parametrize( "subject", @@ -507,21 +517,21 @@ def body_for(self, prospect): ) def test_creative_subjects_are_allowed(self, subject): p = make_prospect() - errors = validate_retained(subject, self.body_for(p), p) + errors = validate_retained(subject, self.body_for(p), p, PROFILE) assert errors == [], f"{subject!r} -> {errors}" def test_subject_sharing_no_company_word_is_rejected(self): p = make_prospect() - errors = validate_retained("A quick question about your business", self.body_for(p), p) + errors = validate_retained("A quick question about your business", self.body_for(p), p, PROFILE) assert any("shares no word with the company name" in e for e in errors) def test_empty_subject_rejected(self): p = make_prospect() - assert any("empty" in e for e in validate_retained(" ", self.body_for(p), p)) + assert any("empty" in e for e in validate_retained(" ", self.body_for(p), p, PROFILE)) def test_overlong_subject_rejected(self): p = make_prospect() - errors = validate_retained("Acme Duct " + "x" * 100, self.body_for(p), p) + errors = validate_retained("Acme Duct " + "x" * 100, self.body_for(p), p, PROFILE) assert any("max 90" in e for e in errors) def test_curly_apostrophe_company_matches_straight_subject(self): @@ -529,8 +539,8 @@ def test_curly_apostrophe_company_matches_straight_subject(self): p = make_prospect() p.company.company = "Drew’s dryer vent cleaning" p.name_used = "team" - body = assemble_body(p, good_response()) - errors = validate_retained("Drew's inbox that answers itself", body, p) + body = assemble_body(p, good_response(), PROFILE) + errors = validate_retained("Drew's inbox that answers itself", body, p, PROFILE) assert not any("shares no word" in e for e in errors) diff --git a/tests/unit/test_draft.py b/tests/unit/test_draft.py index 5ceb6ba..e147199 100644 --- a/tests/unit/test_draft.py +++ b/tests/unit/test_draft.py @@ -17,8 +17,17 @@ ) from prospector.models import Company, Prospect, ResearchResult -PRODUCT_URL = "https://www.omniveer.com/duct-lead-qualifier" -SIGNATURE = "Anas\nFounder, Omniveer" +# 008: the offer moved into the profile. Load the shipped reference profile so +# these tests assert against the copy that actually ships, not a second copy +# that could drift. It resolves through the packaged tier of the search path, so +# no environment setup is needed here. +from prospector.profiles import load as _load_profile + +PROFILE = _load_profile("duct-cleaning") +PRODUCT_URL = PROFILE.product_url +SIGNATURE = PROFILE.signature + + def make_prospect(email="scott@acmeduct.com", hook="Boston service area"): @@ -43,7 +52,7 @@ def settings(): class TestAssembly: def test_golden_email_draft(self): """Rev. 2 (2026-07-17): the operator-supplied copy, locked byte-for-byte.""" - draft = assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS) + draft = assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS, PROFILE) assert draft.validated, draft.validation_errors assert draft.subject == "Free 10-day pilot for Acme Duct" assert draft.body == ( @@ -68,13 +77,13 @@ def test_golden_email_draft(self): def test_same_body_for_generic_and_direct_inboxes(self): # rev. 2 dropped the generic-inbox forward-line opener - generic = assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS) - direct = assemble_email(make_prospect(email="scott@acmeduct.com"), GOOD_SLOTS) + generic = assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS, PROFILE) + direct = assemble_email(make_prospect(email="scott@acmeduct.com"), GOOD_SLOTS, PROFILE) assert generic.body == direct.body assert "forward it to whoever" not in generic.body def test_channel_neutral_no_facebook_mention(self): - draft = assemble_email(make_prospect(), GOOD_SLOTS) + draft = assemble_email(make_prospect(), GOOD_SLOTS, PROFILE) assert "facebook" not in draft.body.lower() assert "messages your page" not in draft.body.lower() @@ -85,15 +94,15 @@ class TestChannelNeutrality: never claims ad-running (Constitution v7.0.0, Principle IV).""" def test_body_is_deterministic_for_identical_input(self): - assert assemble_email(make_prospect(), GOOD_SLOTS).body == assemble_email(make_prospect(), GOOD_SLOTS).body + assert assemble_email(make_prospect(), GOOD_SLOTS, PROFILE).body == assemble_email(make_prospect(), GOOD_SLOTS, PROFILE).body def test_never_claims_ads(self): - draft = assemble_email(make_prospect(), GOOD_SLOTS) + draft = assemble_email(make_prospect(), GOOD_SLOTS, PROFILE) for banned in ("your ads", "ad campaign", "running ads", "advertis", "ad spend"): assert banned not in draft.body.lower() def test_makes_no_possessive_channel_claim(self): - body = assemble_email(make_prospect(), GOOD_SLOTS).body.lower() + body = assemble_email(make_prospect(), GOOD_SLOTS, PROFILE).body.lower() for phrase in ("your facebook page", "your page", "your inbox", "your messenger"): assert phrase not in body @@ -101,26 +110,26 @@ def test_makes_no_possessive_channel_claim(self): class TestValidator: def test_unfilled_slot_rejected(self): slots = dict(GOOD_SLOTS, subject_company="Acme [Duct]") - draft = assemble_email(make_prospect(), slots) + draft = assemble_email(make_prospect(), slots, PROFILE) assert not draft.validated assert any("unfilled" in e for e in draft.validation_errors) def test_ad_claim_in_tampered_body_rejected(self): prospect = make_prospect() - good = assemble_email(prospect, GOOD_SLOTS) + good = assemble_email(prospect, GOOD_SLOTS, PROFILE) tampered = good.body.replace("It responds to new leads", "It responds to your ads") - errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS) + errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS, PROFILE) assert any("ad-running claim" in e for e in errors) def test_wrong_greeting_rejected(self): slots = dict(GOOD_SLOTS, greeting_name="Steve") - draft = assemble_email(make_prospect(), slots) + draft = assemble_email(make_prospect(), slots, PROFILE) assert not draft.validated assert any("greeting" in e for e in draft.validation_errors) def test_subject_company_with_foreign_words_rejected(self): slots = dict(GOOD_SLOTS, subject_company="Acme Duct Experts") - draft = assemble_email(make_prospect(), slots) + draft = assemble_email(make_prospect(), slots, PROFILE) assert not draft.validated assert any("subject_company" in e for e in draft.validation_errors) @@ -128,25 +137,25 @@ def test_their_page_activity_phrasing_always_rejected(self): # rev. 2 template is channel-neutral: their-activity phrasing may not # appear at ANY signal level, even via tampering prospect = make_prospect() - good = assemble_email(prospect, GOOD_SLOTS) + good = assemble_email(prospect, GOOD_SLOTS, PROFILE) tampered = good.body.replace( "It responds to new leads", "When someone messages your page, it responds" ) - errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS) + errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS, PROFILE) assert any("their-page-activity" in e for e in errors) def test_altered_template_prose_rejected(self): prospect = make_prospect() - good = assemble_email(prospect, GOOD_SLOTS) + good = assemble_email(prospect, GOOD_SLOTS, PROFILE) tampered = good.body.replace("free 10-day pilot", "free 30-day trial") - errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS) + errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS, PROFILE) assert any("template prose altered" in e for e in errors) def test_missing_signature_rejected(self): prospect = make_prospect() - good = assemble_email(prospect, GOOD_SLOTS) + good = assemble_email(prospect, GOOD_SLOTS, PROFILE) tampered = good.body.replace(SIGNATURE, "Cheers,\nThe Omniveer Team") - errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS) + errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS, PROFILE) assert any("signature" in e for e in errors) def test_unsourced_name_rejected_even_when_greeting_matches(self): @@ -154,7 +163,7 @@ def test_unsourced_name_rejected_even_when_greeting_matches(self): prospect = make_prospect() prospect.name_used = "Steve" slots = dict(GOOD_SLOTS, greeting_name="Steve") - draft = assemble_email(prospect, slots) + draft = assemble_email(prospect, slots, PROFILE) assert not draft.validated assert any("does not trace to a recorded source" in e for e in draft.validation_errors) @@ -166,7 +175,7 @@ class TestLinkStrategy: 008: only the email body remains — there is no second channel.""" def all_golden_bodies(self): - return {"email": assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS)} + return {"email": assemble_email(make_prospect(email="info@acmeduct.com"), GOOD_SLOTS, PROFILE)} def test_nestaro_branding_is_gone(self): for name, draft in self.all_golden_bodies().items(): @@ -191,20 +200,20 @@ def test_linkedin_never_in_any_pitch(self): def test_second_promotional_link_is_rejected(self): prospect = make_prospect(email="info@acmeduct.com") - good = assemble_email(prospect, GOOD_SLOTS) + good = assemble_email(prospect, GOOD_SLOTS, PROFILE) tampered = good.body.replace( PRODUCT_URL, PRODUCT_URL + " and https://www.omniveer.com" ) - errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS) + errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS, PROFILE) assert any("exactly one promotional link" in e for e in errors) def test_linkedin_injection_is_rejected(self): prospect = make_prospect(email="info@acmeduct.com") - good = assemble_email(prospect, GOOD_SLOTS) + good = assemble_email(prospect, GOOD_SLOTS, PROFILE) tampered = good.body.replace( PRODUCT_URL, "https://www.linkedin.com/company/omniveer/" ) - errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS) + errors = validate_email_draft(good.subject, tampered, prospect, GOOD_SLOTS, PROFILE) assert any("LinkedIn" in e for e in errors) def test_founder_led_signature_and_low_pressure_close(self): @@ -267,7 +276,7 @@ def test_build_email_draft_end_to_end(self): 200, json={"choices": [{"message": {"content": json.dumps(GOOD_SLOTS)}}]} ) ) - draft = build_email_draft(make_prospect(email="info@acmeduct.com"), settings()) + draft = build_email_draft(make_prospect(email="info@acmeduct.com"), settings(), PROFILE) assert draft.validated assert draft.model == "test/model" diff --git a/tests/unit/test_instructions.py b/tests/unit/test_instructions.py index a690102..e736752 100644 --- a/tests/unit/test_instructions.py +++ b/tests/unit/test_instructions.py @@ -5,7 +5,11 @@ from prospector.config import ConfigError from pathlib import Path -DUCT_PROFILE = Path("profiles/duct-cleaning") +import prospector + +# The reference profile ships inside the package (008), so locate it from the +# package itself rather than from the working directory. +DUCT_PROFILE = Path(prospector.__file__).parent / "profiles" / "duct-cleaning" from prospector.instructions import ( MAX_INSTRUCTION_CHARS, diff --git a/tests/unit/test_profile_switching.py b/tests/unit/test_profile_switching.py new file mode 100644 index 0000000..8e44cc9 --- /dev/null +++ b/tests/unit/test_profile_switching.py @@ -0,0 +1,108 @@ +"""008 US3: the same pipeline serves a different vertical by selecting a profile. + +This is the test that makes the feature true rather than merely plumbed. Given +identical input, two profiles must produce different copy, signature, +promotional link and note tags — and the second profile must need no code +change to exist (Constitution v7.0.0, Principle VI). +""" + +import pytest + +from prospector.draft import assemble_email +from prospector.models import Company, Prospect, ResearchResult +from prospector.profiles import load +from prospector.vault import parse_note, render_note + +# The greeting slot carries the WHOLE resolved greeting, not the bare fallback +# word: `request_slots` hands the model `expected_greeting(prospect)` and the +# validator re-derives the same string, so "team" alone would be rejected. +SLOTS = {"greeting_name": "Acme Services team", "subject_company": "Acme"} + +HVAC_TOML = """tags = ["outreach", "hvac", "prospector"] +signature = "Dana\\nFounder, Warmly" +product_url = "https://warmly.example/hvac-assistant" +keywords = ["hvac repair", "furnace install"] +banned_claims = ["running ads", "your ads"] +""" + +HVAC_FALLBACK = """## Subject +Two-week pilot for {subject_company} + +## Template +Hi {greeting}, + +We run a two-week pilot for independent HVAC shops. + +It answers after-hours calls and books the job. + +See it here: +https://warmly.example/hvac-assistant + +Reply if you would like a slot. + +{signature} + +## Invariants +- We run a two-week pilot for independent HVAC shops. +- It answers after-hours calls and books the job. +""" + + +@pytest.fixture +def hvac(tmp_path, monkeypatch): + """A second vertical, created with files only — no code change.""" + root = tmp_path / "profiles" / "hvac" + (root / "skills").mkdir(parents=True) + (root / "IDENTITY.md").write_text("You are Dana of Warmly.", encoding="utf-8") + (root / "OFFER.md").write_text("A two-week pilot for HVAC shops.", encoding="utf-8") + (root / "CONSTRAINTS.md").write_text("Never invent facts.", encoding="utf-8") + (root / "skills" / "write-cold-email.md").write_text("Be brief.", encoding="utf-8") + (root / "fallback.md").write_text(HVAC_FALLBACK, encoding="utf-8") + (root / "profile.toml").write_text(HVAC_TOML, encoding="utf-8") + monkeypatch.setenv("PROSPECTOR_PROFILES", str(tmp_path / "profiles")) + return load("hvac") + + +def prospect(): + company = Company(company="Acme Services", email="info@acme.com", raw_email_field="info@acme.com") + return Prospect(company=company, research=ResearchResult(website="https://acme.com")) + + +class TestSameInputDifferentProfile: + def test_bodies_differ(self, hvac, duct_profile): + duct = assemble_email(prospect(), SLOTS, duct_profile) + other = assemble_email(prospect(), SLOTS, hvac) + assert duct.body != other.body + + def test_each_body_carries_its_own_offer_and_link(self, hvac, duct_profile): + duct = assemble_email(prospect(), SLOTS, duct_profile) + other = assemble_email(prospect(), SLOTS, hvac) + assert duct_profile.product_url in duct.body + assert hvac.product_url in other.body + assert hvac.product_url not in duct.body + assert duct_profile.product_url not in other.body + + def test_each_body_ends_with_its_own_signature(self, hvac, duct_profile): + assert assemble_email(prospect(), SLOTS, duct_profile).body.rstrip().endswith(duct_profile.signature) + assert assemble_email(prospect(), SLOTS, hvac).body.rstrip().endswith(hvac.signature) + + def test_subjects_differ(self, hvac, duct_profile): + duct = assemble_email(prospect(), SLOTS, duct_profile) + other = assemble_email(prospect(), SLOTS, hvac) + assert duct.subject != other.subject + assert "Acme" in duct.subject and "Acme" in other.subject + + def test_both_validate_against_their_own_invariants(self, hvac, duct_profile): + """Each profile's copy is judged by its own locked prose, not the other's.""" + for profile in (duct_profile, hvac): + draft = assemble_email(prospect(), SLOTS, profile) + assert draft.validated, draft.validation_errors + + def test_note_tags_come_from_the_profile(self, hvac, duct_profile): + for profile in (duct_profile, hvac): + note = render_note(prospect(), "d", "r", tags_line=profile.tags_line) + assert parse_note(note)[0]["tags"] == profile.tags_line + + def test_sourcing_keywords_come_from_the_profile(self, hvac, duct_profile): + assert hvac.keywords[0] == "hvac repair" + assert hvac.keywords[0] != duct_profile.keywords[0] diff --git a/tests/unit/test_profile_validation.py b/tests/unit/test_profile_validation.py new file mode 100644 index 0000000..ce1336d --- /dev/null +++ b/tests/unit/test_profile_validation.py @@ -0,0 +1,223 @@ +"""008 US4: a broken profile aborts pre-flight, having written nothing. + +`test_profiles.py` proves each malformed profile raises. This file pins the two +things that make the guarantee *operationally* true (FR-018/FR-019): + +1. The error text matches contracts/profile.md, so an operator can act on it + without reading the source. +2. Validation happens before ANY company is processed — `run_batch` is never + entered, no vault appears, and no HTTP client is ever constructed. A profile + is the honesty floor the fallback path depends on (Constitution v7.0.0, + Principle IV), so discovering it is broken halfway through a batch would mean + notes already written against a profile that cannot answer. +""" + +import httpx +import pytest +from typer.testing import CliRunner + +from prospector.cli import app +from prospector.config import ConfigError +from prospector.instructions import MAX_INSTRUCTION_CHARS +from prospector.profiles import load +from test_profiles import TOML, make_profile + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + """Any outbound HTTP during a pre-flight failure is itself the bug. + + The transport methods are patched rather than `httpx.Client` itself: the + class object is still needed as a type annotation when `prospector.fetch` is + lazily imported.""" + + def forbidden(*args, **kwargs): + raise AssertionError("pre-flight failure must not touch the network") + + monkeypatch.setattr(httpx.Client, "send", forbidden) + monkeypatch.setattr(httpx.Client, "request", forbidden) + monkeypatch.setattr(httpx, "post", forbidden) + monkeypatch.setattr(httpx, "get", forbidden) + + +@pytest.fixture +def profiles_dir(tmp_path, monkeypatch): + """An isolated profiles root; the bundled one must not mask a broken fixture.""" + root = tmp_path / "profiles" + root.mkdir() + monkeypatch.setenv("PROSPECTOR_PROFILES", str(root)) + monkeypatch.delenv("PROSPECTOR_PROFILE", raising=False) + return root + + +class TestContractErrorMessages: + """Each row of the contracts/profile.md validation table.""" + + def test_profile_not_found_names_the_available_ones(self, profiles_dir): + make_profile(profiles_dir, "hvac") + with pytest.raises(ConfigError) as exc: + load("nope") + assert "profile 'nope' not found" in str(exc.value) + assert "Available: hvac" in str(exc.value) + + def test_missing_required_file_names_profile_and_file(self, profiles_dir): + make_profile(profiles_dir, "hvac", omit=("fallback.md",)) + with pytest.raises(ConfigError) as exc: + load("hvac") + assert str(exc.value) == "profile 'hvac' is missing required file: fallback.md" + + def test_empty_required_file_is_distinguished_from_missing(self, profiles_dir): + d = make_profile(profiles_dir, "hvac") + (d / "profile.toml").write_text(" \n", encoding="utf-8") + with pytest.raises(ConfigError) as exc: + load("hvac") + assert str(exc.value) == "profile 'hvac' has an empty required file: profile.toml" + + def test_fallback_missing_a_section_lists_all_three(self, profiles_dir): + make_profile(profiles_dir, "hvac", fallback="## Template\nHi {greeting_name},\n") + with pytest.raises(ConfigError) as exc: + load("hvac") + message = str(exc.value) + assert message.startswith("profile 'hvac': fallback.md must contain") + for heading in ("'## Subject'", "'## Template'", "'## Invariants'"): + assert heading in message + + def test_fallback_with_no_invariants_is_rejected(self, profiles_dir): + make_profile( + profiles_dir, + "hvac", + fallback="## Subject\nS for {subject_company}\n\n## Template\nHi {greeting_name},\n\n## Invariants\n", + ) + with pytest.raises(ConfigError) as exc: + load("hvac") + assert str(exc.value) == "profile 'hvac': fallback.md lists no invariants" + + def test_missing_toml_key_names_the_key(self, profiles_dir): + make_profile( + profiles_dir, + "hvac", + toml=TOML.replace('product_url = "https://example.com/thing"\n', ""), + ) + with pytest.raises(ConfigError) as exc: + load("hvac") + assert str(exc.value) == ( + "profile 'hvac': profile.toml is missing required key: product_url" + ) + + def test_unparseable_toml_names_the_profile_and_file(self, profiles_dir): + make_profile(profiles_dir, "hvac", toml="not = = toml\n") + with pytest.raises(ConfigError) as exc: + load("hvac") + assert "profile 'hvac': profile.toml is not valid TOML" in str(exc.value) + + def test_oversized_instructions_report_both_sizes(self, profiles_dir): + d = make_profile(profiles_dir, "hvac") + (d / "OFFER.md").write_text("x" * (MAX_INSTRUCTION_CHARS + 1), encoding="utf-8") + with pytest.raises(ConfigError) as exc: + load("hvac") + message = str(exc.value) + assert message.startswith("profile 'hvac': instruction context is") + assert f"max {MAX_INSTRUCTION_CHARS:,}" in message + + +class TestNothingIsWrittenOrFetched: + """FR-019: exit 1 with nothing fetched and nothing written.""" + + def _csv(self, tmp_path): + path = tmp_path / "list.csv" + path.write_text("company,email\nAcme,info@acme.com\n", encoding="utf-8") + return path + + @pytest.fixture(autouse=True) + def never_runs_a_batch(self, monkeypatch): + """Reaching the pipeline at all means validation ran too late.""" + import prospector.pipeline as pipeline + import prospector.source as source + + def forbidden(*args, **kwargs): + raise AssertionError("a broken profile must abort before any work") + + monkeypatch.setattr(pipeline, "run_batch", forbidden) + monkeypatch.setattr(source, "run_sourcing", forbidden) + + def test_run_exits_1_and_writes_no_vault(self, tmp_path, monkeypatch, profiles_dir): + make_profile(profiles_dir, "hvac", omit=("fallback.md",)) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-x") + vault = tmp_path / "vault" + result = runner.invoke( + app, + ["run", str(self._csv(tmp_path)), "--profile", "hvac", "--vault", str(vault)], + ) + assert result.exit_code == 1 + assert "missing required file: fallback.md" in result.output + assert not vault.exists() + + def test_run_reports_the_profile_before_the_missing_input(self, monkeypatch, profiles_dir): + """Ordering proof: the profile is validated first (FR-018).""" + make_profile(profiles_dir, "hvac", omit=("fallback.md",)) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-x") + result = runner.invoke(app, ["run", "no-such-file.csv", "--profile", "hvac"]) + assert result.exit_code == 1 + assert "fallback.md" in result.output + assert "input file not found" not in result.output + + def test_run_validates_the_profile_before_the_llm_key(self, monkeypatch, tmp_path, profiles_dir): + make_profile(profiles_dir, "hvac", omit=("fallback.md",)) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + result = runner.invoke( + app, ["run", str(self._csv(tmp_path)), "--profile", "hvac"] + ) + assert result.exit_code == 1 + assert "fallback.md" in result.output + assert "OPENROUTER_API_KEY" not in result.output + + def test_source_exits_1_and_writes_no_csv(self, tmp_path, monkeypatch, profiles_dir): + make_profile(profiles_dir, "hvac", toml="not = = toml\n") + monkeypatch.setenv("GOOGLE_PLACES_API_KEY", "pk-x") + out = tmp_path / "candidates.csv" + result = runner.invoke(app, ["source", "--profile", "hvac", "--out", str(out)]) + assert result.exit_code == 1 + assert "not valid TOML" in result.output + assert not out.exists() + + def test_unknown_profile_name_exits_1(self, tmp_path, monkeypatch, profiles_dir): + make_profile(profiles_dir, "hvac") + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-x") + result = runner.invoke( + app, ["run", str(self._csv(tmp_path)), "--profile", "landscaping"] + ) + assert result.exit_code == 1 + assert "not found" in result.output and "hvac" in result.output + + def test_no_profiles_anywhere_explains_how_to_create_one( + self, tmp_path, monkeypatch, profiles_dir + ): + """An empty search path is a setup error, not an empty prompt. + + A normal install cannot reach this: the reference profile ships inside + the package, so the search path's last tier always yields one. The tier + is emptied here deliberately, to prove the branch reports how to fix + itself rather than dropping into a prompt with no options.""" + import prospector.profiles as profiles_mod + + empty = tmp_path / "empty" + empty.mkdir() + monkeypatch.setattr(profiles_mod, "search_paths", lambda: [empty]) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-x") + result = runner.invoke(app, ["run", str(self._csv(tmp_path))]) + assert result.exit_code == 1 + assert "no profiles found" in result.output + assert "profile.toml" in result.output # names what a profile needs + + def test_non_interactive_run_without_a_profile_fails_instead_of_hanging( + self, tmp_path, monkeypatch, profiles_dir + ): + """contracts/cli.md: a pipe or cron job must not block on a prompt.""" + make_profile(profiles_dir, "hvac") + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-x") + result = runner.invoke(app, ["run", str(self._csv(tmp_path))], input="") + assert result.exit_code == 1 + assert "no profile selected" in result.output + assert "hvac" in result.output diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py index 9dedd73..b7f5f8c 100644 --- a/tests/unit/test_profiles.py +++ b/tests/unit/test_profiles.py @@ -7,6 +7,8 @@ silently absent. """ +from pathlib import Path + import pytest from prospector.config import ConfigError @@ -19,7 +21,10 @@ banned_claims = ["running ads", "your ads"] """ -FALLBACK = """## Template +FALLBACK = """## Subject +A subject for {subject_company} + +## Template Hi {greeting_name}, A locked sentence that must survive. @@ -70,11 +75,32 @@ def test_packaged_profiles_are_on_the_search_path(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert any("prospector" in str(p) for p in search_paths()) - def test_bundled_duct_cleaning_profile_loads(self, monkeypatch): - """The reference profile must keep working (FR-016).""" + def test_profiles_module_is_not_shadowed_by_the_profiles_data_dir(self): + """`prospector/profiles.py` and `prospector/profiles/` coexist. + + The data directory has no `__init__.py`, so the module wins over it as a + namespace package. Adding one would silently break every import of this + module in an installed (non-editable) copy, which is the kind of failure + that never shows up in a source checkout.""" + import prospector.profiles as module + + assert Path(module.__file__).name == "profiles.py" + assert not (Path(module.__file__).parent / "profiles" / "__init__.py").exists() + + def test_bundled_duct_cleaning_profile_loads(self, tmp_path, monkeypatch): + """The reference profile must keep working (FR-016), from anywhere. + + It ships inside the package, so it resolves with no env var and no + ./profiles/ in the working directory — which is what makes an installed + copy usable out of the box.""" + import prospector + monkeypatch.delenv("PROSPECTOR_PROFILES", raising=False) + monkeypatch.chdir(tmp_path) assert "duct-cleaning" in discover() - assert load("duct-cleaning").product_url.startswith("http") + profile = load("duct-cleaning") + assert profile.product_url.startswith("http") + assert profile.root.parent == Path(prospector.__file__).parent / "profiles" class TestDiscovery: diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py index fa3e43f..49e6b5b 100644 --- a/tests/unit/test_smoke.py +++ b/tests/unit/test_smoke.py @@ -2,4 +2,4 @@ def test_package_imports_with_version(): - assert prospector.__version__ == "0.1.0" + assert prospector.__version__ == "0.2.0" diff --git a/tests/unit/test_vault.py b/tests/unit/test_vault.py index b2e644c..c339026 100644 --- a/tests/unit/test_vault.py +++ b/tests/unit/test_vault.py @@ -110,7 +110,9 @@ def test_golden_structure_matches_contract_example(self): # without a draft, so no path produced this note's copy. "draft_source:", "outcome:", - "tags: [outreach, duct-cleaning, prospector]", + # 008: tags come from the selected profile; render_note falls back to + # a vertical-neutral default when called without one. + "tags: [outreach, prospector]", ] assert lines[1:16] == expected_prefixes assert lines[16] == "---" diff --git a/tests/unit/test_vault_legacy_notes.py b/tests/unit/test_vault_legacy_notes.py new file mode 100644 index 0000000..dd39770 --- /dev/null +++ b/tests/unit/test_vault_legacy_notes.py @@ -0,0 +1,170 @@ +"""008 US5: a note written under the pre-008 schema survives a re-run. + +There is no migration pass (FR-024). A legacy note — carrying the now-removed +`fb_signal` / `facebook_url` keys, and very possibly `status: approved` — simply +converges on the current schema the next time its company is processed. What it +MUST NOT lose in doing so is human-owned value (FR-023): `status`, `outcome`, +the `## Log`, and any section the operator added themselves. + +The removed keys are machine-owned observation, so convergence dropping them is +the specified outcome, not a regression — that distinction is what these tests +pin down. This is the vault half of SC-006; T038 checks it against real notes. +""" + +from prospector import vault +from prospector.models import Company, Confidence, Prospect, ResearchResult + +# A note exactly as feature 007 would have written it: fb_signal and +# facebook_url present, approved by a human, with an outcome, a real log and a +# hand-added section. +LEGACY_NOTE = """--- +company: Summit Duct Care +email: info@summitduct.example.com +channel: email +status: approved +name_used: Scott +name_confidence: high +name_candidate: +hook: Denver service area +website: summitduct.example.com +angle: offer-led +fb_signal: strong +facebook_url: https://facebook.com/summitduct +duplicate_of: +needs_review: false +draft_source: agent +outcome: replied - wants a call in August +tags: [outreach, duct-cleaning, prospector] +--- + +## Draft +**Subject:** Free 10-day pilot for Summit Duct Care + +Hi Scott, + +The approved copy a human already read. + +Anas +Founder, Omniveer + +## Research +- Owner name: Scott (about page) + +## Log +- 2026-07-18 approved by hand +- 2026-07-19 followed up + +## Notes from the call +Scott asked about pricing tiers. Call back after Labor Day. +Do not lose this. +""" + +CUSTOM_HEADING = "Notes from the call" + + +def legacy_prospect(): + company = Company( + company="Summit Duct Care", + email="info@summitduct.example.com", + raw_email_field="info@summitduct.example.com", + city="Denver", + ) + company.slug = "summit-duct-care" + research = ResearchResult(website="https://summitduct.example.com", hook="Denver service area") + prospect = Prospect(company=company, research=research) + prospect.name_used = "Scott" + prospect.name_confidence = Confidence.HIGH + return prospect + + +def rerun(vault_dir, *, freeze_draft=True) -> str: + """Write the legacy note, then re-process the same company over it.""" + path = vault_dir / "summit-duct-care.md" + path.write_text(LEGACY_NOTE, encoding="utf-8") + fresh = vault.render_note( + legacy_prospect(), + "**Subject:** Regenerated subject\n\nRegenerated body.", + "- Owner name: Scott (about page)", + tags_line="[outreach, duct-cleaning, prospector]", + ) + vault.upsert_note(vault_dir, "summit-duct-care", fresh, freeze_draft=freeze_draft) + return path.read_text(encoding="utf-8") + + +class TestHumanOwnedContentSurvives: + """FR-023: nothing the human owns may be lost.""" + + def test_status_is_preserved(self, tmp_path): + frontmatter, _ = vault.parse_note(rerun(tmp_path)) + assert frontmatter["status"] == "approved" + + def test_outcome_is_preserved(self, tmp_path): + frontmatter, _ = vault.parse_note(rerun(tmp_path)) + assert frontmatter["outcome"] == "replied - wants a call in August" + + def test_log_is_preserved_verbatim(self, tmp_path): + _, sections = vault.parse_note(rerun(tmp_path)) + log = dict(sections)["Log"].strip("\n") + assert log == "- 2026-07-18 approved by hand\n- 2026-07-19 followed up" + + def test_custom_section_is_preserved_verbatim(self, tmp_path): + _, sections = vault.parse_note(rerun(tmp_path)) + body = dict(sections)[CUSTOM_HEADING].strip("\n") + assert "Scott asked about pricing tiers" in body + assert "Do not lose this." in body + + def test_custom_section_stays_after_the_known_sections(self, tmp_path): + _, sections = vault.parse_note(rerun(tmp_path)) + headings = [h for h, _ in sections] + assert headings[-1] == CUSTOM_HEADING + + def test_approved_draft_is_not_regenerated(self, tmp_path): + """An approved note is frozen: re-drafting would send unreviewed words.""" + text = rerun(tmp_path) + assert "The approved copy a human already read." in text + assert "Regenerated body." not in text + assert "**Subject:** Free 10-day pilot for Summit Duct Care" in text + + +class TestConvergesOnTheCurrentSchema: + """FR-024: the removed fields leave; no migration pass is involved.""" + + def test_removed_facebook_keys_are_gone(self, tmp_path): + text = rerun(tmp_path) + frontmatter, _ = vault.parse_note(text) + assert "fb_signal" not in frontmatter + assert "facebook_url" not in frontmatter + assert "facebook" not in text.lower() + + def test_frontmatter_is_exactly_the_current_schema(self, tmp_path): + frontmatter, _ = vault.parse_note(rerun(tmp_path)) + assert tuple(frontmatter) == vault.FRONTMATTER_KEYS + + def test_research_still_refreshes(self, tmp_path): + """Research is machine-owned observation, so it is not frozen.""" + _, sections = vault.parse_note(rerun(tmp_path)) + assert "Scott (about page)" in dict(sections)["Research"] + + def test_a_second_rerun_is_a_no_op(self, tmp_path): + """Convergence is stable: once migrated, re-running changes no bytes.""" + first = rerun(tmp_path) + fresh = vault.render_note( + legacy_prospect(), + "**Subject:** Regenerated subject\n\nRegenerated body.", + "- Owner name: Scott (about page)", + tags_line="[outreach, duct-cleaning, prospector]", + ) + result = vault.upsert_note(tmp_path, "summit-duct-care", fresh, freeze_draft=True) + assert result == "unchanged" + assert (tmp_path / "summit-duct-care.md").read_text(encoding="utf-8") == first + + +class TestLegacyNoteStillSendable: + """A legacy approved note must remain deliverable (FR-020 unchanged send).""" + + def test_send_can_still_parse_an_approved_legacy_note(self, tmp_path): + rerun(tmp_path) + text = (tmp_path / "summit-duct-care.md").read_text(encoding="utf-8") + subject, body = vault.parse_draft(text) + assert subject == "Free 10-day pilot for Summit Duct Care" + assert "The approved copy a human already read." in body From 8df8e8e3057395f24271f4d3d395885eeea00a63 Mon Sep 17 00:00:00 2001 From: anusbutt Date: Sat, 25 Jul 2026 15:58:35 +0500 Subject: [PATCH 5/6] docs(008): drop the Messenger channel and document profiles README: remove the assisted-Messenger usage section, the fb_signal scoring table, the messenger review step and the fb_signal note field; add a Profiles section covering the directory layout, selection, resolution order, adding a vertical, and the pre-flight validation gate. The safety-guarantee table gains three rows that 008 made true -- email is the only channel, no company is silently dropped, a broken profile stops the run -- and the Facebook row now states plainly that Meta Pixel markup on a company's own site is read while the URLs inside it are never requested. The input-format section documents that anything which is not a valid address routes to email recovery rather than a bucket. CONTRIBUTING: restate the non-negotiable guarantees against the v7.0.0 principles by number, drop the assisted-Messenger guarantee, and record that Principle V is retired so a channel-fit signal is not reintroduced without amending the constitution first. Adds a section on profiles as reviewed content: they change what is said, never what the tool may do, they carry no secrets, and a new key ships with its validation in the same PR, because a key that silently defaults is a key that silently ships the wrong copy. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 81 ++++++++++++++------ README.md | 192 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 192 insertions(+), 81 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5057af..0f4fa81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,36 +14,75 @@ code and covered by the test suite. **A change that weakens any of them will not be merged**, even if it is otherwise useful. If you believe one genuinely needs to change, open an issue to discuss it first — don't work around it in a PR. -1. **Facebook is never contacted.** The tool makes no request to any Facebook or - Messenger host — no Graph API, no scraping, no fetching a `facebook_url`. All - outbound HTTP passes through a host guard that rejects those hosts before any - network activity. Facebook URLs are stored only as input/target signals. - *(See the fetch host-guard and its tests.)* - -2. **Sending is human-approved only.** The tool never sends anything a human has - not explicitly marked `status: approved`. Real sending requires an explicit - flag (dry-run is the default), is bound to the configured dedicated mailbox, - is capped, and every send is recorded in an append-only ledger that prevents +These correspond to the numbered principles in +`.specify/memory/constitution.md` (v7.0.0). + +1. **Human-approved sending only** *(Principle I)*. Email is the only + communication channel. The tool never sends anything a human has not + explicitly marked `status: approved`. Real sending requires an explicit flag + (dry-run is the default), is bound to the configured dedicated mailbox, is + capped, and every send is recorded in an append-only ledger that prevents duplicates. No copy is generated or altered on the send path. *(See `prospector/send.py` and its tests.)* -3. **Nothing is fabricated.** Names, personalization hooks, and any claim about a - prospect must be backed by captured evidence and validated deterministically - in plain Python. Copy that cannot be validated is discarded and replaced with - a locked fallback template. Validation is never delegated to a model. +2. **Open web only — Facebook is never accessed** *(Principle II)*. The tool + makes no request to any Facebook or Messenger host — no Graph API, no + scraping, no fetching a Facebook URL. All outbound HTTP passes through a host + guard that rejects those hosts before any network activity. Facebook-owned + markup found on a company's *own* site (the Meta Pixel) may be read as a + sourcing signal, but the URLs in it are never requested. + *(See `BLOCKED_HOSTS` in `prospector/fetch.py` and its tests.)* + +3. **Obsidian is the interface** *(Principle III)*. Output is plain Markdown + notes plus a dashboard note. No web UI, server, or GUI is added. + +4. **Evidence-bound copy — never fabricate** *(Principle IV)*. Names, + personalization hooks, and any claim about a prospect must be backed by + captured evidence and validated deterministically in plain Python. Copy that + cannot be validated is discarded and replaced with the selected profile's + locked fallback template. Validation is never delegated to a model. *(See `prospector/draft.py` / `prospector/agent_draft.py` and their tests.)* -4. **Assisted Messenger delivery is human-performed.** `prospector dm` may copy a - draft to your clipboard and open a page in *your own* browser, but the tool - never sends a Messenger message and never automates a browser. You send it. - *(See `prospector/dm.py` and its tests.)* +5. **Verified claims only** *(Principle VII)*. Nothing is asserted about a + prospect that the tool has not observed. In particular, Meta Pixel presence + is a targeting filter and is never presented as evidence that a company runs + advertising. -5. **The Obsidian vault is the interface.** Output is plain Markdown notes plus a - dashboard note. No web UI, server, or GUI is added. +Principle V (Channel Honesty) was **retired in v7.0.0** along with the Messenger +channel: the copy makes no claim about a prospect's channels, so the gate it +guarded is satisfied trivially. Do not reintroduce a channel-fit signal without +amending the constitution first. If your change touches any of these areas, please call out in your PR how the guarantee is preserved, and add or update the test that proves it. +## Profiles are content, and they are reviewed + +Everything vertical-specific — the offer, the sender identity, the writing +guidance, the locked fallback copy, the note tags, the promotional link, and the +default sourcing keywords — lives in a profile directory rather than in Python. +Adding a vertical must never require a code change. Operators keep their profiles +in `./profiles//`; the repo ships one reference profile inside the package +at `prospector/profiles/duct-cleaning/`. + +Profiles are **content, not configuration-as-escape-hatch**. Review them the way +you review prose, and hold them to two rules: + +- A profile cannot widen what the tool may do. It grants the drafting model no + tools, no network, and no filesystem access, and it cannot disable citation + validation, the locked-fallback rule, approval-gated sending, or the Facebook + host guard. If a proposed profile key would weaken a guarantee above, the + answer is no. +- A profile must never contain secrets, credentials, or a URL intended to be + fetched. Credentials come from `.env`. + +A profile is validated in full *before* any company is processed, so a malformed +one costs nothing and writes nothing. When you add a key, extend +`prospector/profiles.py` validation in the same PR — a key that silently defaults +is a key that silently ships the wrong copy. `prospector/profiles/duct-cleaning/` is +the bundled reference profile; keep it working, since the test suite asserts +against the copy it actually ships. + ## Getting set up Prospector requires **Python 3.11 or later**. @@ -64,7 +103,7 @@ environment. See the "Running the CLI" section of the [README](README.md). ```bash pytest # full suite pytest -q # quieter -pytest tests/unit/test_dm.py # a single file +pytest tests/unit/test_profiles.py # a single file pytest -k facebook # tests matching a keyword ``` diff --git a/README.md b/README.md index 3e35868..6318a34 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ The system is designed around human approval and verifiable claims. It never contacts Facebook, never invents a prospect's name, and never sends a message unless a user explicitly approves the corresponding note. -> Prospector was initially built for duct-cleaning outreach, but the pipeline -> can be adapted to other local-service verticals. +The offer itself is not built in. Each vertical is a **profile** — a directory of +content selected with `--profile` — so serving a new niche means adding files, +not changing code. A `duct-cleaning` profile ships with the package as a working +reference. ![Prospector architecture](docs/architecture.png) @@ -22,6 +24,7 @@ unless a user explicitly approves the corresponding note. - [Installation](#installation) - [Running the CLI](#running-the-cli) - [Configuration](#configuration) +- [Profiles](#profiles) - [Usage](#usage) - [Input format](#input-format) - [Review workflow](#review-workflow) @@ -33,19 +36,20 @@ unless a user explicitly approves the corresponding note. ## Capabilities - Ingest CSV files and Markdown tables. -- Deduplicate shared inboxes and classify email and Messenger prospects. +- Deduplicate shared inboxes. +- Recover a published address from a company's own pages when the input row has + none, and report by name any company that remains unreachable. - Resolve missing websites through Google Places or DuckDuckGo. - Research public company pages with bounded retries, host pacing, and `robots.txt` support. -- Extract names, locations, hooks, and open-web Facebook signals with evidence. +- Extract names, locations, and hooks with evidence. - Score evidence deterministically before it reaches the drafting model. - Produce cited drafts with a locked-template fallback. +- Serve any vertical from a selectable profile, with no code change. - Write one Markdown note per company and a Dataview-compatible dashboard. - Preserve human-owned content across repeated runs. - Deliver approved drafts through Gmail or authenticated SMTP with dry-run defaults, daily caps, pacing, and duplicate-send protection. -- Assist manual Messenger delivery: copy an approved draft to the clipboard and - open the prospect's Facebook page in your own browser — you send it yourself. ## Safety guarantees @@ -56,10 +60,13 @@ requirements: a change that breaks one must be rejected. See | Guarantee | Enforcement | | --- | --- | | Human approval is required | `prospector send` considers only notes with `status: approved`. It previews by default; real delivery requires `--send` and confirmation unless `--yes` is supplied. | -| Facebook is never contacted | All outbound HTTP traffic passes through a guard that rejects Facebook and Messenger hosts before network activity. Facebook URLs are stored only as input/target signals. Assisted Messenger delivery (`prospector dm`) never sends a message or automates a browser — it hands the URL to your own browser, and you send it yourself. | +| Email is the only channel | There is no Messenger or Facebook delivery path in the tool, and no command that opens one. | +| Facebook is never contacted | All outbound HTTP traffic passes through a guard that rejects Facebook and Messenger hosts before network activity. Meta Pixel markup on a company's *own* site is read as a sourcing signal; the URLs inside it are never requested. | | Names are never fabricated | Deterministic code extracts and scores names. Only high-confidence, source-backed names are used; the model does not choose the greeting. | +| No company is silently dropped | A row with no address gets an email-recovery attempt over pages already fetched; if that fails the company is named in the run summary. There is no third outcome. | | Prospect claims require evidence | Every agent-written prose block cites captured research records. A deterministic validator rejects missing or invalid citations. | -| Unsupported claims are rejected | Invalid or unverifiable copy is rejected and replaced with a locked template. | +| Unsupported claims are rejected | Invalid or unverifiable copy is rejected and replaced with the profile's locked template. | +| A broken profile stops the run | Profiles are validated in full before any company is processed; a malformed one exits 1 having fetched and written nothing. | | Sending is identity-bound | The authenticated identity must match the dedicated mailbox configured in `PROSPECTOR_SEND_FROM`. | | The vault is the interface | Research, drafts, approvals, and review queues remain in plain Markdown; there is no web application. | @@ -94,20 +101,23 @@ sections. ### Pipeline details -1. **Ingest and deduplicate** - Parse input, normalize rows, and group genuinely +1. **Select and validate the profile** - Resolve `--profile`, then validate it in + full before any company is touched. +2. **Ingest and deduplicate** - Parse input, normalize rows, and group genuinely shared inboxes. -2. **Classify channels** - Route valid emails to email. Blank values, - `messenger`, and Facebook URLs enter the Messenger draft queue. 3. **Resolve websites** - Use Google Places when configured, with a DuckDuckGo fallback during `run`. 4. **Fetch pages** - Read the homepage and relevant About, Team, and Contact pages. -5. **Extract evidence** - Identify name candidates, locations, hooks, and - Facebook usage signals with source excerpts. -6. **Score evidence** - Apply deterministic confidence and channel-fit rules. -7. **Draft and validate** - Send only structured evidence to OpenRouter, then +5. **Recover missing addresses** - For a row with no email, look for a published + address in the pages already fetched. No new requests are made. A company with + no usable address is skipped and named in the summary. +6. **Extract evidence** - Identify name candidates, locations, and hooks with + source excerpts. +7. **Score evidence** - Apply deterministic confidence rules. +8. **Draft and validate** - Send only structured evidence to OpenRouter, then validate every returned citation and claim. -8. **Write the vault** - Create or update company notes and `_Dashboard.md` +9. **Write the vault** - Create or update company notes and `_Dashboard.md` without overwriting human-owned content. ## Installation @@ -171,6 +181,8 @@ Secrets are loaded from the gitignored `.env` file. | --- | --- | --- | | `OPENROUTER_API_KEY` | For drafting | OpenRouter credential. Omit only with `--no-llm`. | | `OPENROUTER_MODEL` | No | Defaults to `anthropic/claude-sonnet-4.5`. | +| `PROSPECTOR_PROFILE` | No | Default profile name, so `--profile` can be omitted. | +| `PROSPECTOR_PROFILES` | No | Extra profile directory, searched before `./profiles/`. | | `GOOGLE_PLACES_API_KEY` | For `source` | Required for discovery. During `run`, its absence enables the DuckDuckGo fallback. | | `HUNTER_API_KEY` | No | Enables email-name enrichment at medium confidence. | | `PROSPECTOR_SEND_PROVIDER` | No | `gmail` (default) or `smtp`. | @@ -189,20 +201,96 @@ Secrets are loaded from the gitignored `.env` file. Gmail OAuth files live under `secrets/`; the send ledger remains local. Both locations are excluded from version control. +## Profiles + +A profile holds everything specific to one vertical and one offer. Nothing about +a particular offer is compiled into the code. + +```text +profiles// +├── IDENTITY.md who the sender is +├── OFFER.md what is being offered +├── CONSTRAINTS.md hard rules for the drafting model +├── skills/ +│ └── write-cold-email.md writing guidance +├── fallback.md the locked template: ## Subject, ## Template, ## Invariants +└── profile.toml tags, signature, product_url, keywords, banned_claims +``` + +Select one per run: + +```bash +prospector run companies.csv --profile duct-cleaning +prospector run companies.csv # lists profiles and asks +``` + +Omitting `--profile` prompts interactively. A non-interactive run (CI, a pipe, +cron) fails with the available names rather than blocking on a prompt nobody can +answer. Set `PROSPECTOR_PROFILE` to skip the question entirely. + +### Adding a vertical + +No code changes are required. Copy the bundled reference into your own +`./profiles/` directory and edit it: + +```bash +mkdir -p profiles +cp -r "$(python -c 'import prospector,pathlib;print(pathlib.Path(prospector.__file__).parent/"profiles"/"duct-cleaning")')" profiles/hvac +``` + +Edit the files in `profiles/hvac/`, then run it: + +```bash +prospector run leads.csv --profile hvac +``` + +`source` picks up the profile's first keyword as its default search term, notes +are tagged from `tags`, and the drafted copy carries that profile's signature and +its single promotional link. + +Profiles resolve from `$PROSPECTOR_PROFILES`, then `./profiles/`, then the +profiles bundled with the package — so your own directory always wins. A profile +you place in `./profiles/duct-cleaning/` shadows the bundled one; the bundled +copy is never modified, so it stays a clean reference. + +Every profile is validated in full before any company is processed. A missing +file, a `fallback.md` without its three sections, a missing `profile.toml` key, or +an oversized instruction assembly exits 1 naming the profile and the problem, +having fetched and written nothing. Validation never falls back to another +profile. + +Profiles are content, not a way around the guarantees: they cannot grant the +model tools, network, or filesystem access, and cannot disable citation +validation, the locked fallback, approval-gated sending, or the Facebook host +guard. They must never contain secrets. + ## Usage ### Process a company list ```bash -prospector run companies.csv -prospector run companies.csv --vault ~/Obsidian/Outreach -prospector run companies.csv --limit 3 -prospector run companies.csv --only summit-duct-care -prospector run companies.csv --no-llm +prospector run companies.csv --profile duct-cleaning +prospector run companies.csv --profile duct-cleaning --vault ~/Obsidian/Outreach +prospector run companies.csv --profile duct-cleaning --limit 3 +prospector run companies.csv --profile duct-cleaning --only summit-duct-care +prospector run companies.csv --profile duct-cleaning --no-llm ``` The default output directory is `Vault/Outreach`. +The run summary reports how many addresses were recovered and names every +company left without one: + +```text +Prospector run: 40 companies + processed: 33 failed: 0 + email recovered: 6 no email found: 7 + + no email found: + summit-duct-care no published address on any fetched page + peak-vent-cleaning no website could be resolved +``` + ### Refresh the dashboard ```bash @@ -213,12 +301,14 @@ prospector dashboard --vault ~/Obsidian/Outreach ### Discover companies ```bash -prospector source -prospector source --limit 2 --all --verbose +prospector source --profile duct-cleaning +prospector source --profile duct-cleaning --limit 2 --all --verbose prospector source --keyword 'air duct cleaning' --metros my_metros.txt prospector source --out candidates.csv --max-queries 30 ``` +Without `--keyword`, the profile's first keyword is used. + `source` uses Google Places Text Search, deduplicates results, fetches each candidate's own website, and checks retrieved markup for Meta Pixel signals without contacting Facebook. By default it writes pixel-positive candidates; @@ -237,24 +327,6 @@ prospector send --send --yes `prospector send` is a dry-run unless `--send` is present. -### Deliver approved Messenger drafts (assisted-manual) - -```bash -prospector dm -prospector dm --send -prospector dm --send --limit 5 -prospector dm --send --yes -``` - -`prospector dm` walks approved `channel: messenger` notes one at a time. With -`--send`, for each note it copies the draft to your clipboard and opens the -company's Facebook page in your own browser; you paste, send it yourself, then -confirm. Confirmed deliveries are recorded in `dm_ledger.jsonl` (so a prospect is -never queued twice) and the note flips to `sent`. Without `--send` it only -previews. The tool never sends a Messenger message, never automates a browser, -and never contacts Facebook — only your browser does. Notes with no Facebook link -on file are still shown so you can locate the company manually. - ### Exit codes | Command | Code | Meaning | @@ -269,16 +341,21 @@ on file are still shown so you can locate the company manually. ## Input format -Input may be CSV or a Markdown table. `company` and `email` are required; -`website`, `facebook_url`, `city`, `owner_name`, and `notes` are -optional. Headers are case-insensitive. Unknown columns produce a warning, and -malformed rows are reported without aborting the batch. +Input may be CSV or a Markdown table. `company` is required; `email`, `website`, +`city`, `owner_name`, and `notes` are optional. Headers are case-insensitive. +Unknown columns produce a warning, and malformed rows are reported without +aborting the batch. + +A row with no usable address is not dropped: if a website can be resolved, the +tool looks for a published address on the pages it already fetched. Anything in +the `email` column that is not a valid address — a blank, the word `messenger`, a +Facebook URL — is treated as "no address supplied" and takes that same path. ```csv company,email,website,city,owner_name,notes Summit Duct Care,info@summitduct.example.com,summitduct.example.com,Denver,, -Peak Vent Cleaning,messenger,,Boulder,,prefers DMs -Alpine Air Ducts,https://facebook.com/alpineairducts,,Fort Collins,, +Peak Vent Cleaning,,peakvent.example.com,Boulder,,address recovered from site +Alpine Air Ducts,,,Fort Collins,,no site; will be reported as skipped Mile High Ducts,scott@milehighducts.example.com,milehighducts.example.com,,Scott Bell,referral Mile High Dryer Vents,scott@milehighducts.example.com,milehighducts.example.com,Denver,,same owner ``` @@ -294,14 +371,9 @@ Mile High Dryer Vents,scott@milehighducts.example.com,milehighducts.example.com, Site-extracted candidates must also match the bundled US first-name list. Conservative rejection is preferred over an incorrect greeting. -| Facebook signal | Rule | Draft behavior | -| --- | --- | --- | -| `strong` | At least two signals, including an active-use indicator | Facebook-specific variant. | -| `weak` | One signal, or presence without activity | Channel-neutral copy with one conditional mention. | -| `none` | No observed signal | Channel-neutral copy without a Facebook mention. | - -Signals come from the open web; the Facebook page itself is never requested. -Uncertain evidence always scores down rather than up. +The drafted copy makes no claim about a prospect's own marketing channels, so +uncertain evidence about them cannot leak into a message. Uncertain evidence +always scores down rather than up. ## Review workflow @@ -313,8 +385,6 @@ Uncertain evidence always scores down rather than up. 5. Change `status: to-send` to `status: approved` when the message is ready. 6. Run `prospector send` to preview the batch. 7. Run `prospector send --send` to deliver it. -8. For `channel: messenger` notes, run `prospector dm --send` to be walked - through assisted-manual delivery (clipboard + your browser; you send). Prospector preserves user-edited statuses, `## Log` entries, and custom sections across research runs. During real delivery, the only automatic user-visible @@ -377,7 +447,6 @@ name_candidate: hook: Denver service area website: summitduct.example.com angle: offer-led -fb_signal: none duplicate_of: needs_review: false draft_source: agent @@ -415,6 +484,9 @@ prospect-specific claims. `draft_source` identifies the drafting path, and model controls phrasing, not factual acceptance. - **Evidence-limited drafting.** The model receives structured evidence rather than raw HTML; invalid output is discarded. +- **Offer as content.** The vertical, the offer, and the locked copy are files in + a profile, reviewed as prose. A profile changes what is said, never what the + tool is permitted to do. - **Human-owned state.** Approval, outcomes, logs, and custom content stay in readable Markdown and survive repeated runs. - **Safe degradation.** Optional-service failures use documented fallbacks or @@ -427,8 +499,8 @@ prospect-specific claims. `draft_source` identifies the drafting path, and - Name extraction depends on what public websites disclose; lower-confidence candidates are flagged rather than guessed. - Heuristics are optimized for English-language US local-service businesses. -- Drafting instructions in `prospector/agent/` and the fallback template are - currently tailored to the duct-cleaning offer. +- Email recovery only reads pages the run already fetched, so a company with no + resolvable website cannot be reached and is reported instead. - Meta Pixel markup does not prove current advertising activity and is never presented as such. - Deliverability depends on mailbox reputation, authentication, domain policy, From a95c654fc88761ae5e3a80bdb36fc9a573afd78d Mon Sep 17 00:00:00 2001 From: anusbutt Date: Sat, 25 Jul 2026 16:03:52 +0500 Subject: [PATCH 6/6] docs(008): restate the PR checklist guarantees for v7.0.0 The checklist still asked reviewers to confirm "Messenger delivery stays human-performed", a guarantee this feature removes along with the channel. Replace it with the current set: email is the only channel, and profiles stay content rather than capability. Co-Authored-By: Claude Opus 5 --- .github/PULL_REQUEST_TEMPLATE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 32f664e..98166d3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,9 +14,10 @@ - [ ] Change is focused (no unrelated refactors) - [ ] Secrets are not logged, printed, or committed - [ ] The safety guarantees in [CONTRIBUTING.md](../CONTRIBUTING.md) are preserved - (Facebook never contacted · sending stays human-approved · nothing - fabricated · Messenger delivery stays human-performed). If this PR touches - any of them, explain below how the guarantee is upheld. + (Facebook never contacted · email is the only channel · sending stays + human-approved · nothing fabricated · profiles stay content, not + capability). If this PR touches any of them, explain below how the + guarantee is upheld. ## Notes for reviewers