leads-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. company_discovery/__init__.py +4 -0
  2. company_discovery/adapters/__init__.py +5 -0
  3. company_discovery/adapters/apollo.py +189 -0
  4. company_discovery/adapters/exa.py +112 -0
  5. company_discovery/adapters/llm.py +118 -0
  6. company_discovery/adapters/protocols.py +58 -0
  7. company_discovery/adapters/website.py +154 -0
  8. company_discovery/bundled_skills/__init__.py +1 -0
  9. company_discovery/bundled_skills/company-discovery-operator/SKILL.md +72 -0
  10. company_discovery/bundled_skills/company-discovery-operator/agents/openai.yaml +4 -0
  11. company_discovery/bundled_skills/company-enrichment-operator/SKILL.md +94 -0
  12. company_discovery/bundled_skills/company-enrichment-operator/agents/openai.yaml +4 -0
  13. company_discovery/bundled_skills/company-search-spec-writer/SKILL.md +109 -0
  14. company_discovery/bundled_skills/company-search-spec-writer/agents/openai.yaml +4 -0
  15. company_discovery/bundled_skills/contact-discovery-operator/SKILL.md +80 -0
  16. company_discovery/bundled_skills/contact-discovery-operator/agents/openai.yaml +4 -0
  17. company_discovery/bundled_skills/contact-enrichment-operator/SKILL.md +86 -0
  18. company_discovery/bundled_skills/contact-enrichment-operator/agents/openai.yaml +4 -0
  19. company_discovery/bundled_skills/contact-search-spec-writer/SKILL.md +86 -0
  20. company_discovery/bundled_skills/contact-search-spec-writer/agents/openai.yaml +4 -0
  21. company_discovery/bundled_skills/leads-update-operator/SKILL.md +60 -0
  22. company_discovery/bundled_skills/leads-update-operator/agents/openai.yaml +4 -0
  23. company_discovery/cli.py +1789 -0
  24. company_discovery/db/__init__.py +5 -0
  25. company_discovery/db/contact_enrichment_repository.py +268 -0
  26. company_discovery/db/contact_repository.py +366 -0
  27. company_discovery/db/enrichment_repository.py +207 -0
  28. company_discovery/db/models.py +324 -0
  29. company_discovery/db/repository.py +363 -0
  30. company_discovery/db/session.py +48 -0
  31. company_discovery/domain/__init__.py +24 -0
  32. company_discovery/domain/contact_models.py +178 -0
  33. company_discovery/domain/contact_spec.py +86 -0
  34. company_discovery/domain/models.py +287 -0
  35. company_discovery/domain/spec.py +263 -0
  36. company_discovery/migrations.py +190 -0
  37. company_discovery/prompts/__init__.py +8 -0
  38. company_discovery/prompts/candidate_evaluation/system.md +13 -0
  39. company_discovery/prompts/company_enrichment/system.md +42 -0
  40. company_discovery/prompts/contact_evaluation/system.md +18 -0
  41. company_discovery/prompts/query_generation/system.md +10 -0
  42. company_discovery/release_manifest.json +7 -0
  43. company_discovery/reports/__init__.py +4 -0
  44. company_discovery/reports/contact_enrichment_exporter.py +108 -0
  45. company_discovery/reports/contact_exporter.py +132 -0
  46. company_discovery/reports/enrichment_exporter.py +125 -0
  47. company_discovery/reports/exporter.py +135 -0
  48. company_discovery/runtime.py +336 -0
  49. company_discovery/services/__init__.py +4 -0
  50. company_discovery/services/contact_enrichment_pipeline.py +344 -0
  51. company_discovery/services/contact_enrichment_progress.py +37 -0
  52. company_discovery/services/contact_evaluator.py +110 -0
  53. company_discovery/services/contact_pipeline.py +295 -0
  54. company_discovery/services/contact_progress.py +38 -0
  55. company_discovery/services/enrichment_extractor.py +61 -0
  56. company_discovery/services/enrichment_pipeline.py +526 -0
  57. company_discovery/services/enrichment_progress.py +20 -0
  58. company_discovery/services/enrichment_resolver.py +148 -0
  59. company_discovery/services/evaluator.py +40 -0
  60. company_discovery/services/hygiene.py +51 -0
  61. company_discovery/services/memory.py +150 -0
  62. company_discovery/services/normalization.py +98 -0
  63. company_discovery/services/pipeline.py +628 -0
  64. company_discovery/services/progress.py +48 -0
  65. company_discovery/services/query_planner.py +47 -0
  66. company_discovery/settings.py +152 -0
  67. company_discovery/skill_installer.py +197 -0
  68. company_discovery/update_plan.py +79 -0
  69. leads_cli-0.1.0.dist-info/METADATA +277 -0
  70. leads_cli-0.1.0.dist-info/RECORD +72 -0
  71. leads_cli-0.1.0.dist-info/WHEEL +4 -0
  72. leads_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+ from sqlalchemy import func, select
8
+ from sqlalchemy.exc import IntegrityError
9
+ from sqlalchemy.orm import joinedload
10
+
11
+ from company_discovery.db.models import (
12
+ CandidateEvaluationRow,
13
+ CompanyCandidateRow,
14
+ DiscoveryRunRow,
15
+ EnrichmentFactRow,
16
+ EnrichmentItemRow,
17
+ EnrichmentRunRow,
18
+ )
19
+ from company_discovery.db.repository import CandidateNotFoundError, RunNotFoundError
20
+ from company_discovery.db.session import Database
21
+ from company_discovery.domain.models import EnrichmentItem, EnrichmentProfile, EnrichmentSummary
22
+
23
+
24
+ class EnrichmentRunNotFoundError(LookupError):
25
+ pass
26
+
27
+
28
+ class EnrichmentRepository:
29
+ RUN_ID_PREFIX = "company-enrich-"
30
+ CREATE_RUN_ATTEMPTS = 5
31
+
32
+ def __init__(self, database: Database) -> None:
33
+ self.database = database
34
+
35
+ def discovery_candidates(self, run_id: str, bucket: str, limit: int | None) -> list[dict[str, Any]]:
36
+ with self.database.session() as session:
37
+ run = session.get(DiscoveryRunRow, run_id)
38
+ if run is None:
39
+ raise RunNotFoundError(f"run not found: {run_id}")
40
+ if run.status != "completed":
41
+ raise ValueError(f"discovery run {run_id} is {run.status}, not completed")
42
+ statement = (
43
+ select(CandidateEvaluationRow, CompanyCandidateRow)
44
+ .join(CompanyCandidateRow)
45
+ .where(
46
+ CandidateEvaluationRow.run_id == run_id,
47
+ CandidateEvaluationRow.bucket == bucket,
48
+ )
49
+ .order_by(CandidateEvaluationRow.id)
50
+ )
51
+ if limit is not None:
52
+ statement = statement.limit(limit)
53
+ rows = session.execute(statement).all()
54
+ return [
55
+ {
56
+ "candidate_id": candidate.id,
57
+ "company": candidate.normalized_payload,
58
+ "evaluation": evaluation.evaluation_payload,
59
+ "bucket": evaluation.bucket,
60
+ "source": evaluation.source,
61
+ "spec": run.spec_payload,
62
+ }
63
+ for evaluation, candidate in rows
64
+ ]
65
+
66
+ def create_run(self, discovery_run_id: str, bucket: str, options: dict[str, Any]) -> str:
67
+ for _ in range(self.CREATE_RUN_ATTEMPTS):
68
+ try:
69
+ with self.database.session() as session:
70
+ if session.get(DiscoveryRunRow, discovery_run_id) is None:
71
+ raise RunNotFoundError(f"run not found: {discovery_run_id}")
72
+ run_id = self._new_run_id()
73
+ session.add(
74
+ EnrichmentRunRow(
75
+ id=run_id,
76
+ discovery_run_id=discovery_run_id,
77
+ bucket=bucket,
78
+ options_payload=options,
79
+ )
80
+ )
81
+ return run_id
82
+ except IntegrityError:
83
+ continue
84
+ raise RuntimeError("unable to allocate a unique enrichment run id")
85
+
86
+ def fresh_profile(self, candidate_id: int, freshness_days: int) -> EnrichmentProfile:
87
+ cutoff = datetime.now(UTC) - timedelta(days=freshness_days)
88
+ latest = (
89
+ select(
90
+ EnrichmentFactRow.fact_kind,
91
+ func.max(EnrichmentFactRow.id).label("latest_id"),
92
+ )
93
+ .where(
94
+ EnrichmentFactRow.candidate_id == candidate_id,
95
+ EnrichmentFactRow.observed_at >= cutoff,
96
+ )
97
+ .group_by(EnrichmentFactRow.fact_kind)
98
+ .subquery()
99
+ )
100
+ with self.database.session() as session:
101
+ facts = session.scalars(
102
+ select(EnrichmentFactRow).join(latest, EnrichmentFactRow.id == latest.c.latest_id)
103
+ ).all()
104
+ payload = {fact.fact_kind: fact.fact_payload for fact in facts}
105
+ return EnrichmentProfile.model_validate(payload)
106
+
107
+ def save_item(self, run_id: str, item: EnrichmentItem) -> None:
108
+ with self.database.session() as session:
109
+ if session.get(CompanyCandidateRow, item.company_id) is None:
110
+ raise CandidateNotFoundError(f"candidate not found: {item.company_id}")
111
+ session.add(
112
+ EnrichmentItemRow(
113
+ run_id=run_id,
114
+ candidate_id=item.company_id,
115
+ discovery_snapshot=item.discovery,
116
+ enrichment_payload=item.enrichment.model_dump(mode="json"),
117
+ inherited_status={key: value.value for key, value in item.inherited_status.items()},
118
+ outcome=item.outcome.value,
119
+ conflicts=item.conflicts,
120
+ review_flags=item.review_flags,
121
+ trace_payload=item.trace,
122
+ )
123
+ )
124
+ for kind in ("phone", "location", "independence", "linkedin"):
125
+ fact = getattr(item.enrichment, kind)
126
+ if fact is not None:
127
+ session.add(
128
+ EnrichmentFactRow(
129
+ candidate_id=item.company_id,
130
+ enrichment_run_id=run_id,
131
+ fact_kind=kind,
132
+ fact_payload=fact.model_dump(mode="json"),
133
+ observed_at=fact.observed_at,
134
+ )
135
+ )
136
+
137
+ def complete_run(self, run_id: str, summary: EnrichmentSummary, paths: dict[str, str]) -> None:
138
+ with self.database.session() as session:
139
+ row = self._require_run(session, run_id)
140
+ row.status = "completed"
141
+ row.summary_payload = summary.model_dump(mode="json")
142
+ row.artifact_paths = paths
143
+ row.completed_at = datetime.now(UTC)
144
+
145
+ def fail_run(self, run_id: str, error: Exception) -> None:
146
+ with self.database.session() as session:
147
+ row = self._require_run(session, run_id)
148
+ row.status = "failed"
149
+ row.error_message = str(error)
150
+ row.completed_at = datetime.now(UTC)
151
+
152
+ def set_artifacts(self, run_id: str, paths: dict[str, str]) -> None:
153
+ with self.database.session() as session:
154
+ self._require_run(session, run_id).artifact_paths = paths
155
+
156
+ def get_run(self, run_id: str) -> dict[str, Any]:
157
+ with self.database.session() as session:
158
+ row = session.scalar(
159
+ select(EnrichmentRunRow)
160
+ .options(joinedload(EnrichmentRunRow.items))
161
+ .where(EnrichmentRunRow.id == run_id)
162
+ )
163
+ if row is None:
164
+ raise EnrichmentRunNotFoundError(f"enrichment run not found: {run_id}")
165
+ return {
166
+ "run_id": row.id,
167
+ "discovery_run_id": row.discovery_run_id,
168
+ "bucket": row.bucket,
169
+ "options": row.options_payload,
170
+ "status": row.status,
171
+ "summary": row.summary_payload,
172
+ "artifacts": row.artifact_paths,
173
+ "error": row.error_message,
174
+ "created_at": row.created_at.isoformat(),
175
+ "completed_at": row.completed_at.isoformat() if row.completed_at else None,
176
+ "items": [
177
+ {
178
+ "company_id": item.candidate_id,
179
+ "discovery": item.discovery_snapshot,
180
+ "enrichment": item.enrichment_payload,
181
+ "inherited_status": item.inherited_status,
182
+ "outcome": item.outcome,
183
+ "conflicts": item.conflicts,
184
+ "review_flags": item.review_flags,
185
+ "trace": item.trace_payload,
186
+ }
187
+ for item in row.items
188
+ ],
189
+ }
190
+
191
+ def inspect_item(self, run_id: str, domain: str) -> dict[str, Any]:
192
+ payload = self.get_run(run_id)
193
+ for item in payload["items"]:
194
+ if item["discovery"]["domain"] == domain:
195
+ return item
196
+ raise CandidateNotFoundError(f"domain {domain} was not enriched in run {run_id}")
197
+
198
+ @staticmethod
199
+ def _require_run(session: Any, run_id: str) -> EnrichmentRunRow:
200
+ row = session.get(EnrichmentRunRow, run_id)
201
+ if row is None:
202
+ raise EnrichmentRunNotFoundError(f"enrichment run not found: {run_id}")
203
+ return row
204
+
205
+ @classmethod
206
+ def _new_run_id(cls) -> str:
207
+ return f"{cls.RUN_ID_PREFIX}{uuid4().hex[:12]}"
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
7
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
8
+
9
+
10
+ def utc_now() -> datetime:
11
+ return datetime.now(UTC)
12
+
13
+
14
+ class Base(DeclarativeBase):
15
+ pass
16
+
17
+
18
+ class DiscoveryRunRow(Base):
19
+ __tablename__ = "company_discovery_runs"
20
+
21
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
22
+ spec_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
23
+ source_spec_path: Mapped[str | None] = mapped_column(Text)
24
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="running")
25
+ summary_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
26
+ artifact_paths: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False, default=dict)
27
+ error_message: Mapped[str | None] = mapped_column(Text)
28
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
29
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
30
+
31
+ queries: Mapped[list[DiscoveryQueryRow]] = relationship(
32
+ back_populates="run", cascade="all, delete-orphan", order_by="DiscoveryQueryRow.query_order"
33
+ )
34
+ evaluations: Mapped[list[CandidateEvaluationRow]] = relationship(
35
+ back_populates="run", cascade="all, delete-orphan"
36
+ )
37
+
38
+
39
+ class DiscoveryQueryRow(Base):
40
+ __tablename__ = "company_discovery_queries"
41
+ __table_args__ = (UniqueConstraint("run_id", "query_order"),)
42
+
43
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
44
+ run_id: Mapped[str] = mapped_column(ForeignKey("company_discovery_runs.id", ondelete="CASCADE"), index=True)
45
+ query_text: Mapped[str] = mapped_column(Text, nullable=False)
46
+ query_order: Mapped[int] = mapped_column(Integer, nullable=False)
47
+ rationale: Mapped[str] = mapped_column(Text, default="", nullable=False)
48
+ result_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
49
+ cost_dollars: Mapped[float] = mapped_column(Float, default=0, nullable=False)
50
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
51
+
52
+ run: Mapped[DiscoveryRunRow] = relationship(back_populates="queries")
53
+ raw_results: Mapped[list[RawResultRow]] = relationship(
54
+ back_populates="query", cascade="all, delete-orphan"
55
+ )
56
+
57
+
58
+ class RawResultRow(Base):
59
+ __tablename__ = "company_discovery_raw_results"
60
+
61
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
62
+ run_id: Mapped[str] = mapped_column(ForeignKey("company_discovery_runs.id", ondelete="CASCADE"), index=True)
63
+ query_id: Mapped[int] = mapped_column(ForeignKey("company_discovery_queries.id", ondelete="CASCADE"), index=True)
64
+ result_position: Mapped[int] = mapped_column(Integer, nullable=False)
65
+ observed_url: Mapped[str] = mapped_column(Text, nullable=False)
66
+ observed_title: Mapped[str] = mapped_column(Text, nullable=False)
67
+ raw_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
68
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
69
+
70
+ query: Mapped[DiscoveryQueryRow] = relationship(back_populates="raw_results")
71
+
72
+
73
+ class CompanyCandidateRow(Base):
74
+ __tablename__ = "company_candidates"
75
+ __table_args__ = (
76
+ Index("ix_company_candidates_market", "vertical", "country", "state"),
77
+ )
78
+
79
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
80
+ canonical_name: Mapped[str] = mapped_column(Text, nullable=False)
81
+ domain: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
82
+ dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
83
+ normalized_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
84
+ vertical: Mapped[str | None] = mapped_column(String(128), index=True)
85
+ country: Mapped[str | None] = mapped_column(String(2), index=True)
86
+ state: Mapped[str | None] = mapped_column(String(8), index=True)
87
+ employee_min: Mapped[int | None] = mapped_column(Integer)
88
+ employee_max: Mapped[int | None] = mapped_column(Integer)
89
+ ownership_type: Mapped[str | None] = mapped_column(String(128), index=True)
90
+ prior_bucket: Mapped[str | None] = mapped_column(String(32), index=True)
91
+ prior_reason: Mapped[str | None] = mapped_column(Text)
92
+ excluded: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
93
+ first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
94
+ last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
95
+ last_evaluated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
96
+
97
+ evaluations: Mapped[list[CandidateEvaluationRow]] = relationship(back_populates="candidate")
98
+
99
+
100
+ class CandidateEvaluationRow(Base):
101
+ __tablename__ = "company_candidate_evaluations"
102
+ __table_args__ = (UniqueConstraint("run_id", "candidate_id"),)
103
+
104
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
105
+ run_id: Mapped[str] = mapped_column(ForeignKey("company_discovery_runs.id", ondelete="CASCADE"), index=True)
106
+ candidate_id: Mapped[int] = mapped_column(ForeignKey("company_candidates.id"), index=True)
107
+ evaluation_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
108
+ fit_outcome: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
109
+ bucket: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
110
+ reason: Mapped[str] = mapped_column(Text, nullable=False)
111
+ reason_codes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
112
+ source: Mapped[str] = mapped_column(String(32), nullable=False)
113
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
114
+
115
+ run: Mapped[DiscoveryRunRow] = relationship(back_populates="evaluations")
116
+ candidate: Mapped[CompanyCandidateRow] = relationship(back_populates="evaluations")
117
+
118
+
119
+ class EnrichmentRunRow(Base):
120
+ __tablename__ = "company_enrichment_runs"
121
+
122
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
123
+ discovery_run_id: Mapped[str] = mapped_column(
124
+ ForeignKey("company_discovery_runs.id"), nullable=False, index=True
125
+ )
126
+ bucket: Mapped[str] = mapped_column(String(32), nullable=False, default="selected")
127
+ options_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
128
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="running")
129
+ summary_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
130
+ artifact_paths: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False, default=dict)
131
+ error_message: Mapped[str | None] = mapped_column(Text)
132
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
133
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
134
+
135
+ items: Mapped[list[EnrichmentItemRow]] = relationship(
136
+ back_populates="run", cascade="all, delete-orphan", order_by="EnrichmentItemRow.id"
137
+ )
138
+
139
+
140
+ class EnrichmentItemRow(Base):
141
+ __tablename__ = "company_enrichment_items"
142
+ __table_args__ = (UniqueConstraint("run_id", "candidate_id"),)
143
+
144
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
145
+ run_id: Mapped[str] = mapped_column(
146
+ ForeignKey("company_enrichment_runs.id", ondelete="CASCADE"), index=True
147
+ )
148
+ candidate_id: Mapped[int] = mapped_column(ForeignKey("company_candidates.id"), index=True)
149
+ discovery_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
150
+ enrichment_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
151
+ inherited_status: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False)
152
+ outcome: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
153
+ conflicts: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
154
+ review_flags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
155
+ trace_payload: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
156
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
157
+
158
+ run: Mapped[EnrichmentRunRow] = relationship(back_populates="items")
159
+
160
+
161
+ class EnrichmentFactRow(Base):
162
+ __tablename__ = "company_enrichment_facts"
163
+ __table_args__ = (Index("ix_enrichment_fact_latest", "candidate_id", "fact_kind", "observed_at"),)
164
+
165
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
166
+ candidate_id: Mapped[int] = mapped_column(ForeignKey("company_candidates.id"), index=True)
167
+ enrichment_run_id: Mapped[str] = mapped_column(ForeignKey("company_enrichment_runs.id"), index=True)
168
+ fact_kind: Mapped[str] = mapped_column(String(32), nullable=False)
169
+ fact_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
170
+ observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
171
+
172
+
173
+ class ContactDiscoveryRunRow(Base):
174
+ __tablename__ = "contact_discovery_runs"
175
+
176
+ id: Mapped[str] = mapped_column(String(40), primary_key=True)
177
+ enrichment_run_id: Mapped[str] = mapped_column(
178
+ ForeignKey("company_enrichment_runs.id"), nullable=False, index=True
179
+ )
180
+ spec_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
181
+ source_spec_path: Mapped[str | None] = mapped_column(Text)
182
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="running")
183
+ summary_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
184
+ artifact_paths: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False, default=dict)
185
+ error_message: Mapped[str | None] = mapped_column(Text)
186
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
187
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
188
+
189
+ queries: Mapped[list[ContactDiscoveryQueryRow]] = relationship(
190
+ back_populates="run",
191
+ cascade="all, delete-orphan",
192
+ order_by="ContactDiscoveryQueryRow.id",
193
+ )
194
+ evaluations: Mapped[list[ContactEvaluationRow]] = relationship(
195
+ back_populates="run", cascade="all, delete-orphan"
196
+ )
197
+
198
+
199
+ class ContactDiscoveryQueryRow(Base):
200
+ __tablename__ = "contact_discovery_queries"
201
+
202
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
203
+ run_id: Mapped[str] = mapped_column(
204
+ ForeignKey("contact_discovery_runs.id", ondelete="CASCADE"), index=True
205
+ )
206
+ company_domain: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
207
+ role_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
208
+ query_text: Mapped[str] = mapped_column(Text, nullable=False)
209
+ result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
210
+ cost_dollars: Mapped[float] = mapped_column(Float, nullable=False, default=0)
211
+ raw_results: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
212
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
213
+
214
+ run: Mapped[ContactDiscoveryRunRow] = relationship(back_populates="queries")
215
+
216
+
217
+ class ContactCandidateRow(Base):
218
+ __tablename__ = "contact_candidates"
219
+ __table_args__ = (
220
+ UniqueConstraint("company_domain", "identity_key"),
221
+ Index("ix_contact_candidates_company", "company_domain", "normalized_name"),
222
+ )
223
+
224
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
225
+ company_candidate_id: Mapped[int] = mapped_column(
226
+ ForeignKey("company_candidates.id"), nullable=False, index=True
227
+ )
228
+ company_name: Mapped[str] = mapped_column(Text, nullable=False)
229
+ company_domain: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
230
+ full_name: Mapped[str] = mapped_column(Text, nullable=False)
231
+ normalized_name: Mapped[str] = mapped_column(String(255), nullable=False)
232
+ identity_key: Mapped[str] = mapped_column(String(512), nullable=False)
233
+ title: Mapped[str] = mapped_column(Text, nullable=False)
234
+ linkedin_url: Mapped[str | None] = mapped_column(Text)
235
+ source_urls: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
236
+ evidence: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
237
+ first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
238
+ last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
239
+
240
+ evaluations: Mapped[list[ContactEvaluationRow]] = relationship(back_populates="candidate")
241
+
242
+
243
+ class ContactEvaluationRow(Base):
244
+ __tablename__ = "contact_evaluations"
245
+ __table_args__ = (UniqueConstraint("run_id", "candidate_id", "role_key"),)
246
+
247
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
248
+ run_id: Mapped[str] = mapped_column(
249
+ ForeignKey("contact_discovery_runs.id", ondelete="CASCADE"), index=True
250
+ )
251
+ candidate_id: Mapped[int] = mapped_column(
252
+ ForeignKey("contact_candidates.id"), nullable=False, index=True
253
+ )
254
+ role_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
255
+ verdict: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
256
+ reason: Mapped[str] = mapped_column(Text, nullable=False)
257
+ current_company_match: Mapped[str] = mapped_column(String(16), nullable=False)
258
+ role_match: Mapped[str] = mapped_column(String(16), nullable=False)
259
+ identity_clear: Mapped[bool] = mapped_column(Boolean, nullable=False)
260
+ source: Mapped[str] = mapped_column(String(32), nullable=False)
261
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
262
+
263
+ run: Mapped[ContactDiscoveryRunRow] = relationship(back_populates="evaluations")
264
+ candidate: Mapped[ContactCandidateRow] = relationship(back_populates="evaluations")
265
+
266
+
267
+ class ContactEnrichmentRunRow(Base):
268
+ __tablename__ = "contact_enrichment_runs"
269
+
270
+ id: Mapped[str] = mapped_column(String(48), primary_key=True)
271
+ contact_discovery_run_id: Mapped[str] = mapped_column(
272
+ ForeignKey("contact_discovery_runs.id"), nullable=False, index=True
273
+ )
274
+ options_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
275
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="running")
276
+ summary_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
277
+ artifact_paths: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False, default=dict)
278
+ error_message: Mapped[str | None] = mapped_column(Text)
279
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
280
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
281
+
282
+ items: Mapped[list[ContactEnrichmentItemRow]] = relationship(
283
+ back_populates="run", cascade="all, delete-orphan", order_by="ContactEnrichmentItemRow.id"
284
+ )
285
+
286
+
287
+ class ContactEnrichmentItemRow(Base):
288
+ __tablename__ = "contact_enrichment_items"
289
+ __table_args__ = (UniqueConstraint("run_id", "candidate_id"),)
290
+
291
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
292
+ run_id: Mapped[str] = mapped_column(
293
+ ForeignKey("contact_enrichment_runs.id", ondelete="CASCADE"), index=True
294
+ )
295
+ candidate_id: Mapped[int] = mapped_column(
296
+ ForeignKey("contact_candidates.id"), nullable=False, index=True
297
+ )
298
+ discovery_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
299
+ channels_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
300
+ outcome: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
301
+ review_flags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
302
+ trace_payload: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
303
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
304
+
305
+ run: Mapped[ContactEnrichmentRunRow] = relationship(back_populates="items")
306
+
307
+
308
+ class ContactEnrichmentFactRow(Base):
309
+ __tablename__ = "contact_enrichment_facts"
310
+ __table_args__ = (
311
+ Index("ix_contact_enrichment_fact_latest", "candidate_id", "observed_at"),
312
+ )
313
+
314
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
315
+ candidate_id: Mapped[int] = mapped_column(
316
+ ForeignKey("contact_candidates.id"), nullable=False, index=True
317
+ )
318
+ enrichment_run_id: Mapped[str] = mapped_column(
319
+ ForeignKey("contact_enrichment_runs.id"), nullable=False, index=True
320
+ )
321
+ channels_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
322
+ outcome: Mapped[str] = mapped_column(String(16), nullable=False)
323
+ review_flags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
324
+ observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)