Shurem commited on
Commit
d3bc588
·
1 Parent(s): efd661e

multiple issues resolves

Browse files
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
- "decision_maker": _count_outcomes(db, start, end, _DECISION_MAKER_OUTCOMES),
 
 
 
 
 
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": "No companies found from Gemini or Groq",
 
 
 
 
 
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
- country = (c.get("country") or "").strip().upper()[:2]
 
 
 
 
 
 
 
 
 
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
- "country": (cand.country or region).strip().upper()[:2],
 
 
 
 
 
 
 
 
 
 
 
 
 
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())