File size: 7,224 Bytes
ff3c2e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"""
check_qualify.py — run the qualification funnel end to end and audit what it wrote.

This is the real integration test for phases 3-6: grounded discovery, the site
scan, contact gathering, validation, scoring, and the "reject, don't persist"
rule. It costs provider tokens and does real scans, so keep --max small.

    uv run python scripts/check_qualify.py
    uv run python scripts/check_qualify.py --icp golf --max 2
    uv run python scripts/check_qualify.py --keep     # don't delete the leads it creates

By default every lead it creates is DELETED at the end (along with its contacts),
so it is safe to run against the live database repeatedly. Pass --keep to inspect
them in the UI afterwards.

What it asserts:
  * discovery was grounded (the run reports a provider + model, not an error)
  * every persisted lead has qualification_status="qualified"
  * every persisted lead has a resolved website and at least one usable contact
  * no persisted lead has has_3d=True (those must be rejected at the fit stage)
  * rejected candidates were NOT written to the leads table
  * the rejected list explains itself (each entry has a stage and a reason)
"""

from __future__ import annotations

import argparse
import asyncio
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from sqlalchemy import select  # noqa: E402

from app.database import SessionLocal  # noqa: E402
from app.models import Lead, LeadContact, RevealRequest  # noqa: E402
from app.services import qualify  # noqa: E402

ICP_PRESETS = {
    "golf": (
        "US custom golf cart and luxury utility vehicle builders doing $1M-$10M revenue "
        "whose websites still show static photo galleries instead of an interactive 3D "
        "configurator"
    ),
    "kitchens": "Luxury bespoke kitchen cabinet manufacturers in the UAE or UK without a 3D configurator",
    "yachts": "Custom boat and small-yacht builders in the US selling configurable vessels with static photos only",
}


class Suite:
    def __init__(self) -> None:
        self.failed = 0
        self.ran = 0

    def check(self, label: str, cond: bool, extra: str = "") -> None:
        self.ran += 1
        self.failed += not cond
        print(f"  [{'ok  ' if cond else 'FAIL'}] {label}" + (f"  — {extra}" if extra else ""))


async def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--icp", default="golf", help="preset name or free text")
    ap.add_argument("--max", type=int, default=3)
    ap.add_argument("--provider", default="")
    ap.add_argument("--keep", action="store_true", help="keep the leads this run creates")
    args = ap.parse_args()
    icp = ICP_PRESETS.get(args.icp.strip().lower(), args.icp)

    print(f"ICP: {icp}")
    print(f"max_leads: {args.max}   provider: {args.provider or 'auto'}\n")

    def progress(cur, total, msg):
        print(f"  [{cur}/{total}] {msg}")

    outcome = await qualify.run(icp, args.max, progress=progress, provider_name=args.provider)

    print("\n" + "=" * 64)
    print(f"provider           : {outcome['provider']} ({outcome['model']})")
    print(f"discovered         : {outcome['discovered']}")
    print(f"qualified          : {outcome['qualified']}")
    print(f"skipped duplicates : {outcome['skipped_duplicates']}")
    print(f"rejected           : {len(outcome['rejected'])}")
    for r in outcome["rejected"]:
        print(f"    - {r['company']:32} [{r['stage']}] {r['reason']}")
    if outcome["error"]:
        print(f"error              : {outcome['error']}")

    s = Suite()
    db = SessionLocal()
    created_ids = list(outcome["saved_ids"])
    try:
        print("\nassertions")
        s.check("discovery produced a provider + model (was grounded)",
                bool(outcome["provider"]) and not outcome["error"],
                outcome["error"] or "")
        s.check("every rejection names a stage and a reason",
                all(r.get("stage") and r.get("reason") for r in outcome["rejected"]))

        rejected_names = {r["company"].strip().lower() for r in outcome["rejected"]}

        leads = db.scalars(select(Lead).where(Lead.id.in_(created_ids))).all() if created_ids else []
        s.check("saved_ids all resolve to real rows", len(leads) == len(created_ids),
                f"{len(leads)}/{len(created_ids)}")

        for lead in leads:
            tag = f"lead {lead.id} ({lead.company_name})"
            s.check(f"{tag}: qualification_status = qualified",
                    lead.qualification_status == "qualified", lead.qualification_status)
            s.check(f"{tag}: has a resolved website", bool((lead.company_website or "").startswith("http")),
                    lead.company_website)
            s.check(f"{tag}: has_3d is False (a configurator site must be rejected at 'fit')",
                    lead.has_3d is False)
            s.check(f"{tag}: site_verdict is not 'mismatch'", lead.site_verdict != "mismatch",
                    lead.site_verdict)
            contacts = db.scalars(select(LeadContact).where(LeadContact.lead_id == lead.id)).all()
            has_usable = any((c.email or c.phone) for c in contacts)
            s.check(f"{tag}: at least one contact with an email or phone", has_usable,
                    f"{len(contacts)} contact rows")
            s.check(f"{tag}: every contact row carries a source",
                    all((c.source or "").strip() for c in contacts))
            s.check(f"{tag}: lead_score reflects the funnel (1..100)",
                    1 <= int(lead.lead_score or 0) <= 100, str(lead.lead_score))
            s.check(f"{tag}: was not also in the rejected list",
                    lead.company_name.strip().lower() not in rejected_names)

        # The core guarantee: a rejected candidate is not in the leads table.
        if rejected_names:
            all_company_names = {
                (n or "").strip().lower()
                for n in db.scalars(select(Lead.company_name)).all()
            }
            leaked = rejected_names & all_company_names
            s.check("no rejected candidate was persisted to leads", not leaked,
                    ", ".join(sorted(leaked)) if leaked else "")

    finally:
        if created_ids and not args.keep:
            db.query(RevealRequest).filter(RevealRequest.lead_id.in_(created_ids)).delete(
                synchronize_session=False
            )
            db.query(LeadContact).filter(LeadContact.lead_id.in_(created_ids)).delete(
                synchronize_session=False
            )
            db.query(Lead).filter(Lead.id.in_(created_ids)).delete(synchronize_session=False)
            db.commit()
            print(f"\ncleaned up {len(created_ids)} lead(s): {created_ids}")
        elif created_ids:
            print(f"\nkept {len(created_ids)} lead(s): {created_ids}")
        db.close()

    print()
    if s.failed:
        print(f"FAILED — {s.failed} of {s.ran} assertions")
        return 1
    print(f"PASS — {s.ran} assertions, {outcome['qualified']} qualified, "
          f"{len(outcome['rejected'])} rejected, none leaked into the table.")
    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))