Spaces:
Running
Running
multiple issues resolves
Browse files- app/constants.py +5 -0
- app/crud/call_events.py +22 -8
- app/crud/leads.py +12 -0
- app/schemas/lead.py +1 -1
- app/services/agent_core.py +25 -3
- app/services/qualify.py +15 -2
- scripts/backfill_country_codes.py +81 -0
- scripts/check_recent_jobs.py +77 -0
- scripts/check_recent_leads.py +60 -0
app/constants.py
CHANGED
|
@@ -42,6 +42,11 @@ CALL_OUTCOMES = [
|
|
| 42 |
"not_interested",
|
| 43 |
"callback_scheduled",
|
| 44 |
"receptionist",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
]
|
| 46 |
|
| 47 |
# The emoji is part of the stored key, not decoration. Requires utf8mb4.
|
|
|
|
| 42 |
"not_interested",
|
| 43 |
"callback_scheduled",
|
| 44 |
"receptionist",
|
| 45 |
+
"decision_maker",
|
| 46 |
+
"no_answer",
|
| 47 |
+
"hang_up",
|
| 48 |
+
"wrong_number",
|
| 49 |
+
"closed",
|
| 50 |
]
|
| 51 |
|
| 52 |
# The emoji is part of the stored key, not decoration. Requires utf8mb4.
|
app/crud/call_events.py
CHANGED
|
@@ -23,14 +23,23 @@ from app.models import CallEvent, Lead
|
|
| 23 |
|
| 24 |
_ALL_CALL_OUTCOMES = (
|
| 25 |
"meeting_booked", "voicemail", "dead_number", "not_interested",
|
| 26 |
-
"callback_scheduled", "receptionist",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
-
# Outcomes that mean a live person picked up, decision maker or not.
|
| 29 |
-
_CONNECTED_OUTCOMES = ("not_interested", "callback_scheduled", "meeting_booked", "receptionist")
|
| 30 |
-
# Outcomes that mean an actual decision-maker conversation happened.
|
| 31 |
-
_DECISION_MAKER_OUTCOMES = ("not_interested", "callback_scheduled", "meeting_booked")
|
| 32 |
-
# Outcomes that route a lead back into the follow-up queue.
|
| 33 |
-
_FOLLOWUP_OUTCOMES = ("voicemail", "callback_scheduled", "receptionist")
|
| 34 |
|
| 35 |
|
| 36 |
def _now() -> datetime:
|
|
@@ -130,7 +139,12 @@ def _metrics_for_window(db: Session, start: datetime, end: datetime) -> dict:
|
|
| 130 |
"disconnected": _count_outcomes(db, start, end, ("dead_number",)),
|
| 131 |
"voicemail": _count_outcomes(db, start, end, ("voicemail",)),
|
| 132 |
"receptionist": _count_outcomes(db, start, end, ("receptionist",)),
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
|
|
|
|
| 23 |
|
| 24 |
_ALL_CALL_OUTCOMES = (
|
| 25 |
"meeting_booked", "voicemail", "dead_number", "not_interested",
|
| 26 |
+
"callback_scheduled", "receptionist", "decision_maker",
|
| 27 |
+
"no_answer", "hang_up", "wrong_number", "closed",
|
| 28 |
+
)
|
| 29 |
+
# Outcomes that mean a live person picked up, decision maker or not. Not
|
| 30 |
+
# no_answer/hang_up (no conversation) or wrong_number (never actually reached
|
| 31 |
+
# the company at all, same reasoning as dead_number). "closed" IS a real,
|
| 32 |
+
# positive conversation (the client is closing the deal), so it counts.
|
| 33 |
+
_CONNECTED_OUTCOMES = (
|
| 34 |
+
"not_interested", "callback_scheduled", "meeting_booked", "receptionist",
|
| 35 |
+
"decision_maker", "closed",
|
| 36 |
+
)
|
| 37 |
+
# Outcomes that route a lead back into the follow-up queue -- matches
|
| 38 |
+
# _OUTCOME_STAGE in crud/leads.py exactly.
|
| 39 |
+
_FOLLOWUP_OUTCOMES = (
|
| 40 |
+
"voicemail", "callback_scheduled", "receptionist", "decision_maker",
|
| 41 |
+
"no_answer", "hang_up",
|
| 42 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
def _now() -> datetime:
|
|
|
|
| 139 |
"disconnected": _count_outcomes(db, start, end, ("dead_number",)),
|
| 140 |
"voicemail": _count_outcomes(db, start, end, ("voicemail",)),
|
| 141 |
"receptionist": _count_outcomes(db, start, end, ("receptionist",)),
|
| 142 |
+
# A real disposition button now (Cold Call Desk), same as
|
| 143 |
+
# Receptionist -- not derived from other outcomes anymore, per user
|
| 144 |
+
# request 2026-09-17 (the old derived count from
|
| 145 |
+
# not_interested/callback_scheduled/meeting_booked was confusing:
|
| 146 |
+
# "yeh kya chiz hai, hum kaise add kar rahe hain").
|
| 147 |
+
"decision_maker": _count_outcomes(db, start, end, ("decision_maker",)),
|
| 148 |
}
|
| 149 |
|
| 150 |
|
app/crud/leads.py
CHANGED
|
@@ -304,6 +304,18 @@ _OUTCOME_STAGE = {
|
|
| 304 |
"not_interested": "lost",
|
| 305 |
"callback_scheduled": "followup_due",
|
| 306 |
"receptionist": "followup_due",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
}
|
| 308 |
|
| 309 |
|
|
|
|
| 304 |
"not_interested": "lost",
|
| 305 |
"callback_scheduled": "followup_due",
|
| 306 |
"receptionist": "followup_due",
|
| 307 |
+
"decision_maker": "followup_due",
|
| 308 |
+
# Didn't reach anyone -- worth trying again, same as voicemail.
|
| 309 |
+
"no_answer": "followup_due",
|
| 310 |
+
"hang_up": "followup_due",
|
| 311 |
+
# This number doesn't reach the company at all -- a dead end, same as
|
| 312 |
+
# dead_number. (Renamed from "irrelevant" per user request 2026-09-17.)
|
| 313 |
+
"wrong_number": "lost",
|
| 314 |
+
# Client is closing the deal (confirmed positive, not yet final) --
|
| 315 |
+
# confirmed live 2026-09-17: NOT "business shut down" (that reading was
|
| 316 |
+
# wrong). Advances to Proposal Sent, same as the deal actually
|
| 317 |
+
# progressing, rather than sitting in the call queue.
|
| 318 |
+
"closed": "proposal_sent",
|
| 319 |
}
|
| 320 |
|
| 321 |
|
app/schemas/lead.py
CHANGED
|
@@ -32,7 +32,7 @@ PhoneStatus = Literal[
|
|
| 32 |
]
|
| 33 |
CallOutcome = Literal[
|
| 34 |
"meeting_booked", "voicemail", "dead_number", "not_interested", "callback_scheduled",
|
| 35 |
-
"receptionist",
|
| 36 |
]
|
| 37 |
|
| 38 |
assert set(STAGE_IDS) == set(PipelineStage.__args__) # keeps constants.py honest
|
|
|
|
| 32 |
]
|
| 33 |
CallOutcome = Literal[
|
| 34 |
"meeting_booked", "voicemail", "dead_number", "not_interested", "callback_scheduled",
|
| 35 |
+
"receptionist", "decision_maker", "no_answer", "hang_up", "wrong_number", "closed",
|
| 36 |
]
|
| 37 |
|
| 38 |
assert set(STAGE_IDS) == set(PipelineStage.__args__) # keeps constants.py honest
|
app/services/agent_core.py
CHANGED
|
@@ -42,7 +42,7 @@ log = _logging.getLogger("outreach.agent_core")
|
|
| 42 |
|
| 43 |
from app.config import settings
|
| 44 |
from app.constants import STANDARD_CATEGORIES
|
| 45 |
-
from app.services import validation
|
| 46 |
from app.services.providers.grok import GROQ_BASE_URL, get_groq_key
|
| 47 |
from app.services.secrets import (
|
| 48 |
get_apollo_key,
|
|
@@ -1006,11 +1006,24 @@ Identify {max_leads} real commercial businesses/brands/exhibitors matching this
|
|
| 1006 |
candidates = _generate_json(prompt_content, system_instruction=SYSTEM_PROMPT, temperature=0.3)
|
| 1007 |
|
| 1008 |
if not candidates or not isinstance(candidates, list):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
return {
|
| 1010 |
"saved": 0,
|
| 1011 |
"skipped_duplicates": [],
|
| 1012 |
"leads": [],
|
| 1013 |
-
"error":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1014 |
}
|
| 1015 |
|
| 1016 |
candidates = candidates[:max_leads]
|
|
@@ -1038,7 +1051,16 @@ Identify {max_leads} real commercial businesses/brands/exhibitors matching this
|
|
| 1038 |
# actually found -- showing under "Unknown", never under the country
|
| 1039 |
# actually searched for. Same fallback as qualify.py: the model's
|
| 1040 |
# own answer wins, else guess from the resolved website's domain.
|
| 1041 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1042 |
deal_val = float(c.get("deal_value") or 25000.0)
|
| 1043 |
subject = c.get("subject", f"Question regarding {company_name}")
|
| 1044 |
raw_body = c.get("body", "")
|
|
|
|
| 42 |
|
| 43 |
from app.config import settings
|
| 44 |
from app.constants import STANDARD_CATEGORIES
|
| 45 |
+
from app.services import countries, validation
|
| 46 |
from app.services.providers.grok import GROQ_BASE_URL, get_groq_key
|
| 47 |
from app.services.secrets import (
|
| 48 |
get_apollo_key,
|
|
|
|
| 1006 |
candidates = _generate_json(prompt_content, system_instruction=SYSTEM_PROMPT, temperature=0.3)
|
| 1007 |
|
| 1008 |
if not candidates or not isinstance(candidates, list):
|
| 1009 |
+
# Confirmed live 2026-09-17: this fires whenever the whole cascade
|
| 1010 |
+
# fails, and the single most common real cause is the Gemini
|
| 1011 |
+
# free-tier daily quota (20 requests/day PER MODEL) being exhausted
|
| 1012 |
+
# across all 5 cascade models, with the Groq fallback then also
|
| 1013 |
+
# failing to return parseable JSON. The generic message below used to
|
| 1014 |
+
# give no hint of this -- a rep just saw "0 leads" with no way to
|
| 1015 |
+
# tell an actual outage apart from a quota that resets tomorrow.
|
| 1016 |
+
# Full detail is always in the server logs either way.
|
| 1017 |
return {
|
| 1018 |
"saved": 0,
|
| 1019 |
"skipped_duplicates": [],
|
| 1020 |
"leads": [],
|
| 1021 |
+
"error": (
|
| 1022 |
+
"No companies found -- Gemini and Groq both failed to respond. "
|
| 1023 |
+
"The most common cause is Gemini's free-tier daily quota being "
|
| 1024 |
+
"exhausted; check the server logs for the exact reason, or try "
|
| 1025 |
+
"again later / after the quota resets."
|
| 1026 |
+
),
|
| 1027 |
}
|
| 1028 |
|
| 1029 |
candidates = candidates[:max_leads]
|
|
|
|
| 1051 |
# actually found -- showing under "Unknown", never under the country
|
| 1052 |
# actually searched for. Same fallback as qualify.py: the model's
|
| 1053 |
# own answer wins, else guess from the resolved website's domain.
|
| 1054 |
+
#
|
| 1055 |
+
# Confirmed live 2026-09-17 (same bug as qualify.py's payload build):
|
| 1056 |
+
# the model doesn't reliably return a real ISO2 code -- "UK" or
|
| 1057 |
+
# "United Kingdom" both slip through as literal text, and the Country
|
| 1058 |
+
# filter compares against real codes ("GB"), so a lead like that
|
| 1059 |
+
# matches no filter selection. name_to_iso2() catches common
|
| 1060 |
+
# names/aliases; only an unrecognized value falls back to the raw
|
| 1061 |
+
# slice (covers an already-correct code the dict doesn't translate).
|
| 1062 |
+
raw_country = (c.get("country") or "").strip()
|
| 1063 |
+
country = countries.name_to_iso2(raw_country) or raw_country.upper()[:2]
|
| 1064 |
deal_val = float(c.get("deal_value") or 25000.0)
|
| 1065 |
subject = c.get("subject", f"Question regarding {company_name}")
|
| 1066 |
raw_body = c.get("body", "")
|
app/services/qualify.py
CHANGED
|
@@ -32,7 +32,7 @@ from datetime import datetime
|
|
| 32 |
from app.crud import contacts as crud_contacts
|
| 33 |
from app.crud import leads as crud_leads
|
| 34 |
from app.database import SessionLocal
|
| 35 |
-
from app.services import agent_core, contact_ingest, scraper, signalhire, validation
|
| 36 |
from app.services.providers import get_provider
|
| 37 |
from app.services.providers.base import Candidate
|
| 38 |
from app.services.urlguard import UnsafeUrlError, ensure_public_http_url
|
|
@@ -336,7 +336,20 @@ async def _qualify_one(
|
|
| 336 |
# prompt (providers/grok.py INSTRUCTIONS rule 7) -- region (the
|
| 337 |
# website-TLD guess computed above for phone parsing) is only the
|
| 338 |
# fallback when the model left it blank.
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
"reason": (cand.why_fit or "")[:2000],
|
| 341 |
"source_prompt": f"qualify: {icp_prompt}"[:500],
|
| 342 |
"has_3d": False,
|
|
|
|
| 32 |
from app.crud import contacts as crud_contacts
|
| 33 |
from app.crud import leads as crud_leads
|
| 34 |
from app.database import SessionLocal
|
| 35 |
+
from app.services import agent_core, contact_ingest, countries, scraper, signalhire, validation
|
| 36 |
from app.services.providers import get_provider
|
| 37 |
from app.services.providers.base import Candidate
|
| 38 |
from app.services.urlguard import UnsafeUrlError, ensure_public_http_url
|
|
|
|
| 336 |
# prompt (providers/grok.py INSTRUCTIONS rule 7) -- region (the
|
| 337 |
# website-TLD guess computed above for phone parsing) is only the
|
| 338 |
# fallback when the model left it blank.
|
| 339 |
+
#
|
| 340 |
+
# Confirmed live 2026-09-17: the model doesn't reliably return a
|
| 341 |
+
# real ISO2 code -- "UK" or "United Kingdom" both slipped through
|
| 342 |
+
# as literal text before (a bare .upper()[:2] on "United Kingdom"
|
| 343 |
+
# produces "UN", garbage; even "UK" itself is not the real code,
|
| 344 |
+
# which is "GB"). The country filter dropdown/Cold Call Desk
|
| 345 |
+
# compares against real ISO2 codes, so a lead saved with anything
|
| 346 |
+
# else silently never matches ANY filter selection and looks like
|
| 347 |
+
# it vanished. name_to_iso2() catches common names/aliases (incl.
|
| 348 |
+
# "uk" -> "GB"); only an unrecognized value falls back to the old
|
| 349 |
+
# raw-slice behavior (covers the case where the model already
|
| 350 |
+
# returned a correct code countries.py doesn't need to translate).
|
| 351 |
+
"country": countries.name_to_iso2(cand.country)
|
| 352 |
+
or (cand.country or region).strip().upper()[:2],
|
| 353 |
"reason": (cand.why_fit or "")[:2000],
|
| 354 |
"source_prompt": f"qualify: {icp_prompt}"[:500],
|
| 355 |
"has_3d": False,
|
scripts/backfill_country_codes.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backfill_country_codes.py — fix leads saved with a non-standard country
|
| 3 |
+
value instead of a real ISO 3166-1 alpha-2 code.
|
| 4 |
+
|
| 5 |
+
Confirmed live 2026-09-17: plain AI Discovery (app/services/qualify.py
|
| 6 |
+
_qualify_one, before this fix) trusted the model's raw `country` output --
|
| 7 |
+
"UK" or "United Kingdom" text both got saved as-is (or mangled to "UN" by a
|
| 8 |
+
bare .upper()[:2] on the full name). The Country filter (Cold Call Desk,
|
| 9 |
+
Pipeline, Contacts) compares against real codes ("GB" for United Kingdom),
|
| 10 |
+
so a lead like that silently matched NO filter selection and looked like it
|
| 11 |
+
had vanished. qualify.py now runs every candidate's country through
|
| 12 |
+
countries.name_to_iso2() before saving; this script fixes leads that were
|
| 13 |
+
already written wrong before that fix shipped.
|
| 14 |
+
|
| 15 |
+
Only touches leads where name_to_iso2(lead.country) resolves to something
|
| 16 |
+
DIFFERENT from what's already stored -- an already-correct code like "GB"
|
| 17 |
+
isn't a name/alias in that dict and is left untouched.
|
| 18 |
+
|
| 19 |
+
Dry-run by default -- prints exactly what it would change and changes
|
| 20 |
+
nothing in the database. Re-run with --apply to actually change it.
|
| 21 |
+
|
| 22 |
+
uv run python scripts/backfill_country_codes.py # dry run
|
| 23 |
+
uv run python scripts/backfill_country_codes.py --apply # apply it
|
| 24 |
+
|
| 25 |
+
Always run without --apply first and read the output before adding --apply.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import argparse
|
| 31 |
+
import sys
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 35 |
+
|
| 36 |
+
from sqlalchemy import select # noqa: E402
|
| 37 |
+
|
| 38 |
+
from app.database import SessionLocal # noqa: E402
|
| 39 |
+
from app.models import Lead # noqa: E402
|
| 40 |
+
from app.services.countries import name_to_iso2 # noqa: E402
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def main() -> int:
|
| 44 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 45 |
+
parser.add_argument("--apply", action="store_true", help="actually fix country (default: dry run only)")
|
| 46 |
+
args = parser.parse_args()
|
| 47 |
+
|
| 48 |
+
db = SessionLocal()
|
| 49 |
+
try:
|
| 50 |
+
rows = list(db.scalars(select(Lead).where(Lead.country != "")).all())
|
| 51 |
+
print(f"Scanning {len(rows)} lead(s) with a country set...")
|
| 52 |
+
|
| 53 |
+
changes: list[tuple[Lead, str, str]] = []
|
| 54 |
+
for lead in rows:
|
| 55 |
+
fixed = name_to_iso2(lead.country)
|
| 56 |
+
if fixed and fixed != lead.country:
|
| 57 |
+
changes.append((lead, lead.country, fixed))
|
| 58 |
+
|
| 59 |
+
if not changes:
|
| 60 |
+
print("Nothing to backfill -- every stored country is already a real ISO2 code.")
|
| 61 |
+
return 0
|
| 62 |
+
|
| 63 |
+
print(f"\n{len(changes)} lead(s) have a non-standard country value:")
|
| 64 |
+
for lead, old, new in changes:
|
| 65 |
+
print(f" #{lead.id:<6} {lead.company_name[:40]:<40} {old!r:<10} -> {new}")
|
| 66 |
+
|
| 67 |
+
if not args.apply:
|
| 68 |
+
print("\nDry run only -- re-run with --apply to write these changes.")
|
| 69 |
+
return 0
|
| 70 |
+
|
| 71 |
+
for lead, _old, new in changes:
|
| 72 |
+
lead.country = new
|
| 73 |
+
db.commit()
|
| 74 |
+
print(f"\nApplied: {len(changes)} lead(s) updated.")
|
| 75 |
+
return 0
|
| 76 |
+
finally:
|
| 77 |
+
db.close()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
sys.exit(main())
|
scripts/check_recent_jobs.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_recent_jobs.py — read-only diagnostic for "I ran a Discover search and
|
| 3 |
+
some leads seem to be missing."
|
| 4 |
+
|
| 5 |
+
Prints the most recent N jobs of a given kind (default "discover") with
|
| 6 |
+
their status, requested count vs. actually saved count, skipped duplicates,
|
| 7 |
+
and any error -- the definitive source of truth for what a run actually did,
|
| 8 |
+
instead of guessing from lead timestamps alone.
|
| 9 |
+
|
| 10 |
+
uv run python scripts/check_recent_jobs.py # last 10 discover jobs
|
| 11 |
+
uv run python scripts/check_recent_jobs.py --kind qualify
|
| 12 |
+
uv run python scripts/check_recent_jobs.py --limit 20
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 22 |
+
|
| 23 |
+
from sqlalchemy import select # noqa: E402
|
| 24 |
+
|
| 25 |
+
from app.database import SessionLocal # noqa: E402
|
| 26 |
+
from app.models import Job # noqa: E402
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> int:
|
| 30 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 31 |
+
parser.add_argument("--kind", default="discover")
|
| 32 |
+
parser.add_argument("--limit", type=int, default=10)
|
| 33 |
+
args = parser.parse_args()
|
| 34 |
+
|
| 35 |
+
db = SessionLocal()
|
| 36 |
+
try:
|
| 37 |
+
rows = list(
|
| 38 |
+
db.scalars(
|
| 39 |
+
select(Job)
|
| 40 |
+
.where(Job.kind == args.kind)
|
| 41 |
+
.order_by(Job.created_at.desc())
|
| 42 |
+
.limit(args.limit)
|
| 43 |
+
).all()
|
| 44 |
+
)
|
| 45 |
+
if not rows:
|
| 46 |
+
print(f"No '{args.kind}' jobs found at all.")
|
| 47 |
+
return 0
|
| 48 |
+
|
| 49 |
+
print(f"Most recent {len(rows)} '{args.kind}' job(s):\n")
|
| 50 |
+
for job in rows:
|
| 51 |
+
print(f"job {job.id} status={job.status} created_at={job.created_at}")
|
| 52 |
+
print(f" message: {job.message!r}")
|
| 53 |
+
if job.error:
|
| 54 |
+
print(f" ERROR: {job.error}")
|
| 55 |
+
result = job.result or {}
|
| 56 |
+
if isinstance(result, dict):
|
| 57 |
+
saved = result.get("saved")
|
| 58 |
+
skipped = result.get("skipped_duplicates") or []
|
| 59 |
+
rejected = result.get("rejected") or []
|
| 60 |
+
err = result.get("error")
|
| 61 |
+
print(f" saved={saved} skipped_duplicates={len(skipped)} rejected={len(rejected)}")
|
| 62 |
+
if skipped:
|
| 63 |
+
print(f" skipped (already existed): {skipped}")
|
| 64 |
+
if rejected:
|
| 65 |
+
print(" rejected:")
|
| 66 |
+
for r in rejected:
|
| 67 |
+
print(f" - {r}")
|
| 68 |
+
if err:
|
| 69 |
+
print(f" result error: {err}")
|
| 70 |
+
print()
|
| 71 |
+
return 0
|
| 72 |
+
finally:
|
| 73 |
+
db.close()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
sys.exit(main())
|
scripts/check_recent_leads.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_recent_leads.py — read-only diagnostic for "I just generated a lead
|
| 3 |
+
and it's not showing where I expect."
|
| 4 |
+
|
| 5 |
+
Prints the most recent N leads with every field a filter/queue check reads:
|
| 6 |
+
pipeline_stage and phone_status (Cold Call Desk's CALL_QUEUE_STAGES filter),
|
| 7 |
+
industry_tag (Category filter), country (Country filter), and source_prompt
|
| 8 |
+
(the Source filter's leadSource() derivation).
|
| 9 |
+
|
| 10 |
+
uv run python scripts/check_recent_leads.py # last 10
|
| 11 |
+
uv run python scripts/check_recent_leads.py --limit 25
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 21 |
+
|
| 22 |
+
from sqlalchemy import select # noqa: E402
|
| 23 |
+
|
| 24 |
+
from app.database import SessionLocal # noqa: E402
|
| 25 |
+
from app.models import Lead # noqa: E402
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main() -> int:
|
| 29 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 30 |
+
parser.add_argument("--limit", type=int, default=10)
|
| 31 |
+
args = parser.parse_args()
|
| 32 |
+
|
| 33 |
+
db = SessionLocal()
|
| 34 |
+
try:
|
| 35 |
+
rows = list(
|
| 36 |
+
db.scalars(
|
| 37 |
+
select(Lead).order_by(Lead.created_at.desc(), Lead.id.desc()).limit(args.limit)
|
| 38 |
+
).all()
|
| 39 |
+
)
|
| 40 |
+
if not rows:
|
| 41 |
+
print("No leads in the database at all.")
|
| 42 |
+
return 0
|
| 43 |
+
|
| 44 |
+
print(f"Most recent {len(rows)} lead(s):\n")
|
| 45 |
+
for lead in rows:
|
| 46 |
+
print(f"#{lead.id} {lead.company_name}")
|
| 47 |
+
print(f" created_at: {lead.created_at}")
|
| 48 |
+
print(f" pipeline_stage: {lead.pipeline_stage!r}")
|
| 49 |
+
print(f" phone_status: {lead.phone_status!r}")
|
| 50 |
+
print(f" industry_tag: {lead.industry_tag!r}")
|
| 51 |
+
print(f" country: {lead.country!r}")
|
| 52 |
+
print(f" source_prompt: {lead.source_prompt!r}")
|
| 53 |
+
print()
|
| 54 |
+
return 0
|
| 55 |
+
finally:
|
| 56 |
+
db.close()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
sys.exit(main())
|