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.
- company_discovery/__init__.py +4 -0
- company_discovery/adapters/__init__.py +5 -0
- company_discovery/adapters/apollo.py +189 -0
- company_discovery/adapters/exa.py +112 -0
- company_discovery/adapters/llm.py +118 -0
- company_discovery/adapters/protocols.py +58 -0
- company_discovery/adapters/website.py +154 -0
- company_discovery/bundled_skills/__init__.py +1 -0
- company_discovery/bundled_skills/company-discovery-operator/SKILL.md +72 -0
- company_discovery/bundled_skills/company-discovery-operator/agents/openai.yaml +4 -0
- company_discovery/bundled_skills/company-enrichment-operator/SKILL.md +94 -0
- company_discovery/bundled_skills/company-enrichment-operator/agents/openai.yaml +4 -0
- company_discovery/bundled_skills/company-search-spec-writer/SKILL.md +109 -0
- company_discovery/bundled_skills/company-search-spec-writer/agents/openai.yaml +4 -0
- company_discovery/bundled_skills/contact-discovery-operator/SKILL.md +80 -0
- company_discovery/bundled_skills/contact-discovery-operator/agents/openai.yaml +4 -0
- company_discovery/bundled_skills/contact-enrichment-operator/SKILL.md +86 -0
- company_discovery/bundled_skills/contact-enrichment-operator/agents/openai.yaml +4 -0
- company_discovery/bundled_skills/contact-search-spec-writer/SKILL.md +86 -0
- company_discovery/bundled_skills/contact-search-spec-writer/agents/openai.yaml +4 -0
- company_discovery/bundled_skills/leads-update-operator/SKILL.md +60 -0
- company_discovery/bundled_skills/leads-update-operator/agents/openai.yaml +4 -0
- company_discovery/cli.py +1789 -0
- company_discovery/db/__init__.py +5 -0
- company_discovery/db/contact_enrichment_repository.py +268 -0
- company_discovery/db/contact_repository.py +366 -0
- company_discovery/db/enrichment_repository.py +207 -0
- company_discovery/db/models.py +324 -0
- company_discovery/db/repository.py +363 -0
- company_discovery/db/session.py +48 -0
- company_discovery/domain/__init__.py +24 -0
- company_discovery/domain/contact_models.py +178 -0
- company_discovery/domain/contact_spec.py +86 -0
- company_discovery/domain/models.py +287 -0
- company_discovery/domain/spec.py +263 -0
- company_discovery/migrations.py +190 -0
- company_discovery/prompts/__init__.py +8 -0
- company_discovery/prompts/candidate_evaluation/system.md +13 -0
- company_discovery/prompts/company_enrichment/system.md +42 -0
- company_discovery/prompts/contact_evaluation/system.md +18 -0
- company_discovery/prompts/query_generation/system.md +10 -0
- company_discovery/release_manifest.json +7 -0
- company_discovery/reports/__init__.py +4 -0
- company_discovery/reports/contact_enrichment_exporter.py +108 -0
- company_discovery/reports/contact_exporter.py +132 -0
- company_discovery/reports/enrichment_exporter.py +125 -0
- company_discovery/reports/exporter.py +135 -0
- company_discovery/runtime.py +336 -0
- company_discovery/services/__init__.py +4 -0
- company_discovery/services/contact_enrichment_pipeline.py +344 -0
- company_discovery/services/contact_enrichment_progress.py +37 -0
- company_discovery/services/contact_evaluator.py +110 -0
- company_discovery/services/contact_pipeline.py +295 -0
- company_discovery/services/contact_progress.py +38 -0
- company_discovery/services/enrichment_extractor.py +61 -0
- company_discovery/services/enrichment_pipeline.py +526 -0
- company_discovery/services/enrichment_progress.py +20 -0
- company_discovery/services/enrichment_resolver.py +148 -0
- company_discovery/services/evaluator.py +40 -0
- company_discovery/services/hygiene.py +51 -0
- company_discovery/services/memory.py +150 -0
- company_discovery/services/normalization.py +98 -0
- company_discovery/services/pipeline.py +628 -0
- company_discovery/services/progress.py +48 -0
- company_discovery/services/query_planner.py +47 -0
- company_discovery/settings.py +152 -0
- company_discovery/skill_installer.py +197 -0
- company_discovery/update_plan.py +79 -0
- leads_cli-0.1.0.dist-info/METADATA +277 -0
- leads_cli-0.1.0.dist-info/RECORD +72 -0
- leads_cli-0.1.0.dist-info/WHEEL +4 -0
- leads_cli-0.1.0.dist-info/entry_points.txt +2 -0
company_discovery/cli.py
ADDED
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import questionary
|
|
12
|
+
import typer
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
from questionary import Choice, Style
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from company_discovery.adapters.exa import ExaClient
|
|
21
|
+
from company_discovery.adapters.apollo import ApolloClient
|
|
22
|
+
from company_discovery.adapters.llm import OpenAICompatibleLLM
|
|
23
|
+
from company_discovery.adapters.website import WebsiteClient
|
|
24
|
+
from company_discovery.db.enrichment_repository import (
|
|
25
|
+
EnrichmentRepository,
|
|
26
|
+
EnrichmentRunNotFoundError,
|
|
27
|
+
)
|
|
28
|
+
from company_discovery.db.contact_repository import (
|
|
29
|
+
ContactDiscoveryRepository,
|
|
30
|
+
ContactNotFoundError,
|
|
31
|
+
ContactRunNotFoundError,
|
|
32
|
+
)
|
|
33
|
+
from company_discovery.db.contact_enrichment_repository import (
|
|
34
|
+
ContactEnrichmentRepository,
|
|
35
|
+
ContactEnrichmentRunNotFoundError,
|
|
36
|
+
)
|
|
37
|
+
from company_discovery.db.repository import CandidateNotFoundError, DiscoveryRepository, RunNotFoundError
|
|
38
|
+
from company_discovery.db.session import Database
|
|
39
|
+
from company_discovery.domain.models import EnrichmentSummary, RunSummary
|
|
40
|
+
from company_discovery.domain.contact_models import (
|
|
41
|
+
ContactDiscoverySummary,
|
|
42
|
+
ContactEnrichmentSummary,
|
|
43
|
+
)
|
|
44
|
+
from company_discovery.domain.contact_spec import ContactSearchSpec
|
|
45
|
+
from company_discovery.domain.spec import CompanySearchSpec
|
|
46
|
+
from company_discovery.reports.exporter import ArtifactExporter
|
|
47
|
+
from company_discovery.reports.enrichment_exporter import EnrichmentArtifactExporter
|
|
48
|
+
from company_discovery.reports.contact_exporter import ContactDiscoveryArtifactExporter
|
|
49
|
+
from company_discovery.reports.contact_enrichment_exporter import (
|
|
50
|
+
ContactEnrichmentArtifactExporter,
|
|
51
|
+
)
|
|
52
|
+
from company_discovery.services.contact_evaluator import ContactEvaluator
|
|
53
|
+
from company_discovery.services.contact_pipeline import ContactDiscoveryPipeline
|
|
54
|
+
from company_discovery.services.contact_progress import ContactProgressReporter
|
|
55
|
+
from company_discovery.services.contact_enrichment_pipeline import (
|
|
56
|
+
ContactEnrichmentOptions,
|
|
57
|
+
ContactEnrichmentPipeline,
|
|
58
|
+
)
|
|
59
|
+
from company_discovery.services.contact_enrichment_progress import (
|
|
60
|
+
ContactEnrichmentProgressReporter,
|
|
61
|
+
)
|
|
62
|
+
from company_discovery.services.enrichment_extractor import EnrichmentExtractor
|
|
63
|
+
from company_discovery.services.enrichment_pipeline import EnrichmentOptions, EnrichmentPipeline
|
|
64
|
+
from company_discovery.services.enrichment_progress import EnrichmentProgressReporter
|
|
65
|
+
from company_discovery.services.evaluator import CandidateEvaluator
|
|
66
|
+
from company_discovery.services.normalization import canonical_domain
|
|
67
|
+
from company_discovery.services.pipeline import DiscoveryPipeline
|
|
68
|
+
from company_discovery.services.progress import ProgressReporter
|
|
69
|
+
from company_discovery.services.query_planner import QueryPlanner
|
|
70
|
+
from company_discovery.settings import Settings, get_settings
|
|
71
|
+
from company_discovery import __distribution_name__, __version__
|
|
72
|
+
from company_discovery.migrations import MigrationError, apply_migrations, migration_status
|
|
73
|
+
from company_discovery.runtime import (
|
|
74
|
+
SCHEMA_VERSION,
|
|
75
|
+
SKILL_BUNDLE_VERSION,
|
|
76
|
+
configure_workspace_logging,
|
|
77
|
+
default_workspace_root,
|
|
78
|
+
ensure_workspace,
|
|
79
|
+
merge_dicts,
|
|
80
|
+
read_toml,
|
|
81
|
+
update_config_value,
|
|
82
|
+
write_workspace_pointer,
|
|
83
|
+
)
|
|
84
|
+
from company_discovery.skill_installer import (
|
|
85
|
+
detect_targets,
|
|
86
|
+
install_skills,
|
|
87
|
+
installed_target_keys,
|
|
88
|
+
skill_status,
|
|
89
|
+
)
|
|
90
|
+
from company_discovery.update_plan import build_update_check
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
app = typer.Typer(no_args_is_help=True, help="Company targeting and discovery.")
|
|
94
|
+
companies = typer.Typer(no_args_is_help=True, help="Discover and inspect target companies.")
|
|
95
|
+
contacts = typer.Typer(
|
|
96
|
+
no_args_is_help=True,
|
|
97
|
+
help="Discover current people and enrich their contact channels.",
|
|
98
|
+
)
|
|
99
|
+
config_app = typer.Typer(no_args_is_help=True, help="Inspect and update local configuration.")
|
|
100
|
+
skills_app = typer.Typer(no_args_is_help=True, help="Install and inspect bundled agent skills.")
|
|
101
|
+
app.add_typer(companies, name="companies")
|
|
102
|
+
app.add_typer(contacts, name="contacts")
|
|
103
|
+
app.add_typer(config_app, name="config")
|
|
104
|
+
app.add_typer(skills_app, name="skills")
|
|
105
|
+
console = Console()
|
|
106
|
+
|
|
107
|
+
ONBOARDING_STYLE = Style(
|
|
108
|
+
[
|
|
109
|
+
("qmark", "fg:#6ec6b8 bold"),
|
|
110
|
+
("question", "bold"),
|
|
111
|
+
("answer", "fg:#ffb000 bold"),
|
|
112
|
+
("pointer", "fg:#ffffff bold"),
|
|
113
|
+
("highlighted", "noreverse fg:#ffffff bold"),
|
|
114
|
+
("selected", "noreverse fg:#ffffff"),
|
|
115
|
+
("disabled", "fg:#8a9099"),
|
|
116
|
+
("instruction", "fg:#c8cdd4"),
|
|
117
|
+
("validation-toolbar", "noreverse fg:#ff6b6b bold"),
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
LLM_PROVIDER_CHOICES = [
|
|
123
|
+
{
|
|
124
|
+
"key": "openai",
|
|
125
|
+
"label": "OpenAI",
|
|
126
|
+
"base_url": "https://api.openai.com/v1",
|
|
127
|
+
"supported": True,
|
|
128
|
+
"models": ["gpt-5-mini", "gpt-5", "gpt-4.1-mini"],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"key": "deepseek",
|
|
132
|
+
"label": "DeepSeek",
|
|
133
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
134
|
+
"supported": True,
|
|
135
|
+
"models": ["deepseek-chat", "deepseek-reasoner"],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"key": "anthropic",
|
|
139
|
+
"label": "Anthropic Claude",
|
|
140
|
+
"base_url": "",
|
|
141
|
+
"supported": False,
|
|
142
|
+
"disabled": "native Anthropic API adapter is not implemented yet",
|
|
143
|
+
"models": [],
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"key": "google-gemini",
|
|
147
|
+
"label": "Google Gemini",
|
|
148
|
+
"base_url": "",
|
|
149
|
+
"supported": False,
|
|
150
|
+
"disabled": "native Gemini API adapter is not implemented yet",
|
|
151
|
+
"models": [],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"key": "custom",
|
|
155
|
+
"label": "Custom OpenAI-compatible endpoint",
|
|
156
|
+
"base_url": "https://api.openai.com/v1",
|
|
157
|
+
"supported": True,
|
|
158
|
+
"models": [],
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
CUSTOM_MODEL_VALUE = "__custom_model__"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class RichProgressReporter(ProgressReporter):
|
|
165
|
+
STYLES = {
|
|
166
|
+
"spec": ("SPEC", "blue"),
|
|
167
|
+
"memory": ("MEMORY", "green"),
|
|
168
|
+
"external": ("EXA", "bright_cyan"),
|
|
169
|
+
"evaluation": ("REVIEW", "yellow"),
|
|
170
|
+
"save": ("OUTPUT", "bright_green"),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
def __init__(self, *, verbose: bool = False) -> None:
|
|
174
|
+
self.verbose = verbose
|
|
175
|
+
self._style = "white"
|
|
176
|
+
|
|
177
|
+
def stage(self, number: int, total: int, name: str, kind: str) -> None:
|
|
178
|
+
label, self._style = self.STYLES[kind]
|
|
179
|
+
title = Text(f"[{number}/{total}] {name}", style=f"bold {self._style}")
|
|
180
|
+
console.print(Panel(Text(label, style=f"bold {self._style}"), title=title, expand=False))
|
|
181
|
+
|
|
182
|
+
def info(self, message: str) -> None:
|
|
183
|
+
console.print(f" [{self._style}]*[/{self._style}] {message}")
|
|
184
|
+
|
|
185
|
+
def detail(self, message: str) -> None:
|
|
186
|
+
if self.verbose:
|
|
187
|
+
console.print(f" [dim]{message}[/dim]")
|
|
188
|
+
|
|
189
|
+
def query(self, current: int, total: int, query: str, raw_total: int) -> None:
|
|
190
|
+
suffix = f": {query}" if self.verbose else ""
|
|
191
|
+
console.print(
|
|
192
|
+
f" [bright_cyan]SEARCH[/bright_cyan] query {current}/{total}; "
|
|
193
|
+
f"{raw_total} raw results{suffix}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def evaluation(
|
|
197
|
+
self,
|
|
198
|
+
current: int,
|
|
199
|
+
total: int,
|
|
200
|
+
selected: int,
|
|
201
|
+
reserve: int,
|
|
202
|
+
rejected: int,
|
|
203
|
+
detail: str | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
suffix = f"; {detail}" if self.verbose and detail else ""
|
|
206
|
+
console.print(
|
|
207
|
+
f" [yellow]REVIEW[/yellow] {current}/{total} | selected {selected} | "
|
|
208
|
+
f"reserve {reserve} | rejected {rejected}{suffix}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class RichEnrichmentProgressReporter(EnrichmentProgressReporter):
|
|
213
|
+
COLORS = {
|
|
214
|
+
"INHERITED": "blue",
|
|
215
|
+
"MEMORY": "green",
|
|
216
|
+
"WEBSITE": "bright_cyan",
|
|
217
|
+
"FALLBACK": "yellow",
|
|
218
|
+
"READY": "bright_green",
|
|
219
|
+
"REVIEW": "yellow",
|
|
220
|
+
"BLOCKED": "red",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def start(self, discovery_run_id: str, total: int, bucket: str) -> None:
|
|
224
|
+
console.print(
|
|
225
|
+
Panel(
|
|
226
|
+
f"Discovery run [bold]{discovery_run_id}[/bold]\n"
|
|
227
|
+
f"{total} companies from [bold]{bucket}[/bold]",
|
|
228
|
+
title="Company enrichment",
|
|
229
|
+
border_style="bright_cyan",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def company(self, current: int, total: int, name: str) -> None:
|
|
234
|
+
console.print(f"\n[bold bright_cyan][{current}/{total}] {name}[/bold bright_cyan]")
|
|
235
|
+
|
|
236
|
+
def event(self, label: str, message: str) -> None:
|
|
237
|
+
color = self.COLORS.get(label, "white")
|
|
238
|
+
console.print(f" [{color}]{label:<9}[/{color}] {message}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class RichContactProgressReporter(ContactProgressReporter):
|
|
242
|
+
def start(self, source_run_id: str, companies_count: int, roles: int) -> None:
|
|
243
|
+
console.print(
|
|
244
|
+
Panel(
|
|
245
|
+
f"Company enrichment run [bold]{source_run_id}[/bold]\n"
|
|
246
|
+
f"{companies_count} companies | {roles} role targets",
|
|
247
|
+
title="Contact discovery",
|
|
248
|
+
border_style="bright_cyan",
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def company(self, current: int, total: int, name: str, domain: str) -> None:
|
|
253
|
+
console.print(
|
|
254
|
+
f"\n[bold bright_cyan][{current}/{total}] {name}[/bold bright_cyan] "
|
|
255
|
+
f"[dim]{domain}[/dim]"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def memory(self, role: str, reused: int, target: int) -> None:
|
|
259
|
+
console.print(
|
|
260
|
+
f" [green]MEMORY[/green] {role}: reused {reused}/{target}; "
|
|
261
|
+
f"live gap {max(0, target - reused)}"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def search(self, role: str, current: int, total: int, results: int) -> None:
|
|
265
|
+
console.print(
|
|
266
|
+
f" [bright_cyan]LIVE WEB[/bright_cyan] {role}: query {current}/{total}; "
|
|
267
|
+
f"{results} unique results"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def evaluation(self, role: str, accepted: int, review: int, rejected: int) -> None:
|
|
271
|
+
console.print(
|
|
272
|
+
f" [yellow]VERIFY[/yellow] {role}: accepted {accepted} | "
|
|
273
|
+
f"review {review} | rejected {rejected}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def save(self, run_id: str) -> None:
|
|
277
|
+
console.print(f"\n [bright_green]OUTPUT[/bright_green] saved {run_id}")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class RichContactEnrichmentProgressReporter(ContactEnrichmentProgressReporter):
|
|
281
|
+
def start(self, source_run_id: str, contacts_count: int) -> None:
|
|
282
|
+
console.print(
|
|
283
|
+
Panel(
|
|
284
|
+
f"Contact discovery run [bold]{source_run_id}[/bold]\n"
|
|
285
|
+
f"{contacts_count} accepted contacts",
|
|
286
|
+
title="Apollo contact enrichment",
|
|
287
|
+
border_style="bright_cyan",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def memory(self, reused: int, pending: int) -> None:
|
|
292
|
+
console.print(
|
|
293
|
+
f" [green]MEMORY[/green] reused {reused} fresh profiles | "
|
|
294
|
+
f"Apollo gap {pending}"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def batch(self, current: int, total: int, size: int) -> None:
|
|
298
|
+
console.print(
|
|
299
|
+
f" [bright_cyan]APOLLO[/bright_cyan] batch {current}/{total} | {size} people"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def poll(self, request_id: str, attempt: int) -> None:
|
|
303
|
+
console.print(
|
|
304
|
+
f" [yellow]POLL[/yellow] {request_id} | attempt {attempt}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def outcome(self, name: str, outcome: str, flags: list[str]) -> None:
|
|
308
|
+
color = {"ready": "bright_green", "review": "yellow", "blocked": "red"}[outcome]
|
|
309
|
+
suffix = f" | {', '.join(flags)}" if flags else ""
|
|
310
|
+
console.print(f" [{color}]{outcome.upper():<7}[/{color}] {name}{suffix}")
|
|
311
|
+
|
|
312
|
+
def save(self, run_id: str) -> None:
|
|
313
|
+
console.print(f"\n [bright_green]OUTPUT[/bright_green] saved {run_id}")
|
|
314
|
+
|
|
315
|
+
def build_runtime(settings: Settings) -> tuple[Database, DiscoveryRepository, DiscoveryPipeline, list[object]]:
|
|
316
|
+
settings.prepare_directories()
|
|
317
|
+
database = Database(settings.resolved_database_url)
|
|
318
|
+
database.create_schema()
|
|
319
|
+
repository = DiscoveryRepository(database)
|
|
320
|
+
resources: list[object] = []
|
|
321
|
+
|
|
322
|
+
llm = None
|
|
323
|
+
if settings.llm_api_key:
|
|
324
|
+
llm = OpenAICompatibleLLM(settings)
|
|
325
|
+
resources.append(llm)
|
|
326
|
+
exa = None
|
|
327
|
+
if settings.exa_api_key:
|
|
328
|
+
exa = ExaClient(settings)
|
|
329
|
+
resources.append(exa)
|
|
330
|
+
|
|
331
|
+
pipeline = DiscoveryPipeline(
|
|
332
|
+
repository=repository,
|
|
333
|
+
exporter=ArtifactExporter(settings.artifacts_dir),
|
|
334
|
+
query_planner=QueryPlanner(llm, settings.query_count) if llm else None,
|
|
335
|
+
evaluator=CandidateEvaluator(llm) if llm else None,
|
|
336
|
+
search_provider=exa,
|
|
337
|
+
results_per_query=settings.exa_results_per_query,
|
|
338
|
+
)
|
|
339
|
+
return database, repository, pipeline, resources
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def build_enrichment_runtime(
|
|
343
|
+
settings: Settings,
|
|
344
|
+
) -> tuple[Database, EnrichmentRepository, EnrichmentPipeline, list[object]]:
|
|
345
|
+
settings.prepare_directories()
|
|
346
|
+
database = Database(settings.resolved_database_url)
|
|
347
|
+
database.create_schema()
|
|
348
|
+
repository = EnrichmentRepository(database)
|
|
349
|
+
resources: list[object] = []
|
|
350
|
+
|
|
351
|
+
llm = OpenAICompatibleLLM(settings) if settings.llm_api_key else None
|
|
352
|
+
if llm:
|
|
353
|
+
resources.append(llm)
|
|
354
|
+
exa = ExaClient(settings) if settings.exa_api_key else None
|
|
355
|
+
if exa:
|
|
356
|
+
resources.append(exa)
|
|
357
|
+
website = WebsiteClient(
|
|
358
|
+
timeout_seconds=settings.enrichment_website_timeout_seconds,
|
|
359
|
+
max_pages=settings.enrichment_max_pages,
|
|
360
|
+
)
|
|
361
|
+
resources.append(website)
|
|
362
|
+
pipeline = EnrichmentPipeline(
|
|
363
|
+
repository=repository,
|
|
364
|
+
exporter=EnrichmentArtifactExporter(settings.artifacts_dir),
|
|
365
|
+
website=website,
|
|
366
|
+
extractor=EnrichmentExtractor(llm) if llm else None,
|
|
367
|
+
fallback_search=exa,
|
|
368
|
+
freshness_days=settings.enrichment_freshness_days,
|
|
369
|
+
fallback_results=settings.enrichment_fallback_results,
|
|
370
|
+
)
|
|
371
|
+
return database, repository, pipeline, resources
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def build_contact_runtime(
|
|
375
|
+
settings: Settings,
|
|
376
|
+
) -> tuple[Database, ContactDiscoveryRepository, ContactDiscoveryPipeline, list[object]]:
|
|
377
|
+
settings.prepare_directories()
|
|
378
|
+
database = Database(settings.resolved_database_url)
|
|
379
|
+
database.create_schema()
|
|
380
|
+
repository = ContactDiscoveryRepository(database)
|
|
381
|
+
resources: list[object] = []
|
|
382
|
+
|
|
383
|
+
llm = OpenAICompatibleLLM(settings) if settings.llm_api_key else None
|
|
384
|
+
if llm:
|
|
385
|
+
resources.append(llm)
|
|
386
|
+
exa = ExaClient(settings) if settings.exa_api_key else None
|
|
387
|
+
if exa:
|
|
388
|
+
resources.append(exa)
|
|
389
|
+
pipeline = ContactDiscoveryPipeline(
|
|
390
|
+
repository=repository,
|
|
391
|
+
exporter=ContactDiscoveryArtifactExporter(settings.artifacts_dir),
|
|
392
|
+
search_provider=exa,
|
|
393
|
+
evaluator=ContactEvaluator(llm) if llm else None,
|
|
394
|
+
results_per_query=settings.contact_results_per_query,
|
|
395
|
+
)
|
|
396
|
+
return database, repository, pipeline, resources
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def build_contact_enrichment_runtime(
|
|
400
|
+
settings: Settings,
|
|
401
|
+
) -> tuple[
|
|
402
|
+
Database,
|
|
403
|
+
ContactEnrichmentRepository,
|
|
404
|
+
ContactEnrichmentPipeline,
|
|
405
|
+
list[object],
|
|
406
|
+
]:
|
|
407
|
+
settings.prepare_directories()
|
|
408
|
+
database = Database(settings.resolved_database_url)
|
|
409
|
+
database.create_schema()
|
|
410
|
+
repository = ContactEnrichmentRepository(database)
|
|
411
|
+
provider = ApolloClient(settings)
|
|
412
|
+
pipeline = ContactEnrichmentPipeline(
|
|
413
|
+
repository=repository,
|
|
414
|
+
exporter=ContactEnrichmentArtifactExporter(settings.artifacts_dir),
|
|
415
|
+
provider=provider,
|
|
416
|
+
freshness_days=settings.apollo_enrichment_freshness_days,
|
|
417
|
+
poll_interval_seconds=settings.apollo_poll_interval_seconds,
|
|
418
|
+
poll_timeout_seconds=settings.apollo_poll_timeout_seconds,
|
|
419
|
+
)
|
|
420
|
+
return database, repository, pipeline, [provider]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def build_contact_enrichment_repository(
|
|
424
|
+
settings: Settings,
|
|
425
|
+
) -> tuple[Database, ContactEnrichmentRepository]:
|
|
426
|
+
settings.prepare_directories()
|
|
427
|
+
database = Database(settings.resolved_database_url)
|
|
428
|
+
database.create_schema()
|
|
429
|
+
return database, ContactEnrichmentRepository(database)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def close_runtime(database: Database, resources: list[object]) -> None:
|
|
433
|
+
for resource in resources:
|
|
434
|
+
close = getattr(resource, "close", None)
|
|
435
|
+
if close:
|
|
436
|
+
close()
|
|
437
|
+
database.dispose()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _next_runs_archive_path(home: Path) -> Path:
|
|
441
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
442
|
+
base = home / f"runs-previousdb-{timestamp}"
|
|
443
|
+
candidate = base
|
|
444
|
+
suffix = 2
|
|
445
|
+
while candidate.exists():
|
|
446
|
+
candidate = home / f"{base.name}-{suffix}"
|
|
447
|
+
suffix += 1
|
|
448
|
+
return candidate
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _installed_cli_version() -> str:
|
|
452
|
+
try:
|
|
453
|
+
return metadata.version(__distribution_name__)
|
|
454
|
+
except metadata.PackageNotFoundError:
|
|
455
|
+
return __version__
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _mask_secrets(data: dict[str, object]) -> dict[str, object]:
|
|
459
|
+
masked = json.loads(json.dumps(data))
|
|
460
|
+
if isinstance(masked.get("llm"), dict) and masked["llm"].get("api_key"):
|
|
461
|
+
masked["llm"]["api_key"] = "********"
|
|
462
|
+
providers = masked.get("providers")
|
|
463
|
+
if isinstance(providers, dict):
|
|
464
|
+
for provider in providers.values():
|
|
465
|
+
if isinstance(provider, dict) and provider.get("api_key"):
|
|
466
|
+
provider["api_key"] = "********"
|
|
467
|
+
return masked
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _nested_value(data: dict[str, object], dotted_key: str, default: object = None) -> object:
|
|
471
|
+
cursor: object = data
|
|
472
|
+
for key in dotted_key.split("."):
|
|
473
|
+
if not isinstance(cursor, dict) or key not in cursor:
|
|
474
|
+
return default
|
|
475
|
+
cursor = cursor[key]
|
|
476
|
+
return cursor
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _provider_base_url(provider: str, current: str | None = None) -> str:
|
|
480
|
+
provider_choice = _provider_choice(provider)
|
|
481
|
+
if provider_choice and provider_choice["supported"] and provider_choice["key"] != "custom":
|
|
482
|
+
return str(provider_choice["base_url"])
|
|
483
|
+
return current or "https://api.openai.com/v1"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _provider_choice(provider: str) -> dict[str, object] | None:
|
|
487
|
+
normalized = provider.strip().lower()
|
|
488
|
+
return next(
|
|
489
|
+
(
|
|
490
|
+
choice
|
|
491
|
+
for choice in LLM_PROVIDER_CHOICES
|
|
492
|
+
if choice["key"] == normalized or str(choice["label"]).lower() == normalized
|
|
493
|
+
),
|
|
494
|
+
None,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _interactive_terminal() -> bool:
|
|
499
|
+
return sys.stdin.isatty() and sys.stdout.isatty() and not os.getenv("LEADS_TEXT_PROMPTS")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _desktop_workspace_root() -> Path:
|
|
503
|
+
return Path.home() / "Desktop" / "Leads"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _workspace_choices(recommended: Path) -> list[dict[str, object]]:
|
|
507
|
+
desktop = _desktop_workspace_root()
|
|
508
|
+
return [
|
|
509
|
+
{
|
|
510
|
+
"key": "recommended",
|
|
511
|
+
"label": f"Recommended path ({recommended})",
|
|
512
|
+
"path": recommended,
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
"key": "desktop",
|
|
516
|
+
"label": f"Desktop ({desktop})",
|
|
517
|
+
"path": desktop,
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
"key": "custom",
|
|
521
|
+
"label": "Custom path",
|
|
522
|
+
"path": None,
|
|
523
|
+
},
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _select_workspace_root(recommended: Path) -> Path:
|
|
528
|
+
choices = _workspace_choices(recommended)
|
|
529
|
+
selected = _select_choice("Workspace location", choices, default="recommended")
|
|
530
|
+
if selected == "custom":
|
|
531
|
+
return Path(_prompt_required("Custom workspace path")).expanduser()
|
|
532
|
+
choice = next(choice for choice in choices if choice["key"] == selected)
|
|
533
|
+
return Path(choice["path"]).expanduser()
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _select_choice(
|
|
537
|
+
message: str,
|
|
538
|
+
choices: list[dict[str, object]],
|
|
539
|
+
*,
|
|
540
|
+
default: str | None = None,
|
|
541
|
+
) -> str:
|
|
542
|
+
if _interactive_terminal():
|
|
543
|
+
questionary_choices = [
|
|
544
|
+
Choice(
|
|
545
|
+
title=str(choice["label"]),
|
|
546
|
+
value=str(choice["key"]),
|
|
547
|
+
disabled=str(choice["disabled"]) if choice.get("disabled") else None,
|
|
548
|
+
)
|
|
549
|
+
for choice in choices
|
|
550
|
+
]
|
|
551
|
+
answer = questionary.select(
|
|
552
|
+
message,
|
|
553
|
+
choices=questionary_choices,
|
|
554
|
+
default=default,
|
|
555
|
+
use_indicator=False,
|
|
556
|
+
use_shortcuts=False,
|
|
557
|
+
instruction="Use up/down and enter.",
|
|
558
|
+
style=ONBOARDING_STYLE,
|
|
559
|
+
).ask()
|
|
560
|
+
if answer is None:
|
|
561
|
+
raise typer.Exit(130)
|
|
562
|
+
return str(answer)
|
|
563
|
+
|
|
564
|
+
enabled = [choice for choice in choices if not choice.get("disabled")]
|
|
565
|
+
options = ", ".join(str(choice["label"]) for choice in enabled)
|
|
566
|
+
while True:
|
|
567
|
+
answer = typer.prompt(f"{message} ({options})", default=default or str(enabled[0]["key"]))
|
|
568
|
+
selected = next(
|
|
569
|
+
(
|
|
570
|
+
choice
|
|
571
|
+
for choice in choices
|
|
572
|
+
if str(choice["key"]).lower() == answer.strip().lower()
|
|
573
|
+
or str(choice["label"]).lower() == answer.strip().lower()
|
|
574
|
+
),
|
|
575
|
+
None,
|
|
576
|
+
)
|
|
577
|
+
if selected and not selected.get("disabled"):
|
|
578
|
+
return str(selected["key"])
|
|
579
|
+
if selected and selected.get("disabled"):
|
|
580
|
+
console.print(f"[yellow]{selected['label']} is not available yet: {selected['disabled']}[/yellow]")
|
|
581
|
+
else:
|
|
582
|
+
console.print(f"[yellow]Choose one of: {options}[/yellow]")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _select_model(provider: str, current_model: str) -> str:
|
|
586
|
+
provider_choice = _provider_choice(provider)
|
|
587
|
+
models = list((provider_choice or {}).get("models", []))
|
|
588
|
+
example = models[0] if models else current_model or "my-model-name"
|
|
589
|
+
model_choices = [{"key": model, "label": model} for model in models]
|
|
590
|
+
model_choices.append(
|
|
591
|
+
{
|
|
592
|
+
"key": CUSTOM_MODEL_VALUE,
|
|
593
|
+
"label": f"Write my own model (e.g. {example})",
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
default = current_model if any(choice["key"] == current_model for choice in model_choices) else CUSTOM_MODEL_VALUE
|
|
597
|
+
selected = _select_choice("Default model", model_choices, default=default)
|
|
598
|
+
if selected == CUSTOM_MODEL_VALUE:
|
|
599
|
+
return _prompt_required("Model name", default=current_model if current_model else None)
|
|
600
|
+
return selected
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _select_skill_targets(detected_targets: list[object], detected_keys: list[str]) -> list[str]:
|
|
604
|
+
if _interactive_terminal():
|
|
605
|
+
choices = [
|
|
606
|
+
Choice(
|
|
607
|
+
title=f"{target.label} ({target.key}) -> {target.root}",
|
|
608
|
+
value=target.key,
|
|
609
|
+
checked=target.key in detected_keys,
|
|
610
|
+
)
|
|
611
|
+
for target in detected_targets
|
|
612
|
+
]
|
|
613
|
+
answer = questionary.checkbox(
|
|
614
|
+
"Install skills into which agents?",
|
|
615
|
+
choices=choices,
|
|
616
|
+
instruction="Use up/down, space to select, enter to confirm.",
|
|
617
|
+
validate=lambda selected: bool(selected) or "Select at least one agent.",
|
|
618
|
+
style=ONBOARDING_STYLE,
|
|
619
|
+
).ask()
|
|
620
|
+
if answer is None:
|
|
621
|
+
raise typer.Exit(130)
|
|
622
|
+
return [str(value) for value in answer]
|
|
623
|
+
|
|
624
|
+
while True:
|
|
625
|
+
default_selection = "detected" if detected_keys else ""
|
|
626
|
+
raw = typer.prompt(
|
|
627
|
+
"Install skills into which targets? (detected, all, or comma-separated keys)",
|
|
628
|
+
default=default_selection,
|
|
629
|
+
show_default=bool(default_selection),
|
|
630
|
+
)
|
|
631
|
+
selected = _parse_target_selection(raw, detected_keys)
|
|
632
|
+
if selected:
|
|
633
|
+
return selected
|
|
634
|
+
console.print("[yellow]Select at least one agent target.[/yellow]")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _prompt_required(message: str, *, default: str | None = None, hide_input: bool = False) -> str:
|
|
638
|
+
while True:
|
|
639
|
+
value = typer.prompt(
|
|
640
|
+
message,
|
|
641
|
+
default=default or "",
|
|
642
|
+
hide_input=hide_input,
|
|
643
|
+
show_default=default is not None,
|
|
644
|
+
).strip()
|
|
645
|
+
if value:
|
|
646
|
+
return value
|
|
647
|
+
console.print("[yellow]This value is required.[/yellow]")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _prompt_masked_secret(message: str, *, required: bool = False) -> str:
|
|
651
|
+
if _interactive_terminal():
|
|
652
|
+
answer = questionary.password(
|
|
653
|
+
message,
|
|
654
|
+
validate=(lambda value: bool(value.strip()) or "This value is required.") if required else None,
|
|
655
|
+
style=ONBOARDING_STYLE,
|
|
656
|
+
).ask()
|
|
657
|
+
if answer is None:
|
|
658
|
+
raise typer.Exit(130)
|
|
659
|
+
return answer.strip()
|
|
660
|
+
return typer.prompt(message, default="", hide_input=True, show_default=False).strip()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _prompt_secret(label: str, *, existing: bool, required: bool = False) -> str | None:
|
|
664
|
+
suffix = " (leave blank to keep existing)" if existing else (" (required)" if required else " (optional)")
|
|
665
|
+
while True:
|
|
666
|
+
value = _prompt_masked_secret(f"{label}{suffix}", required=required and not existing)
|
|
667
|
+
if value or not required or existing:
|
|
668
|
+
break
|
|
669
|
+
console.print(f"[yellow]{label} is required.[/yellow]")
|
|
670
|
+
return value or None
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _parse_target_selection(raw: str, detected: list[str]) -> list[str]:
|
|
674
|
+
normalized = raw.strip().lower()
|
|
675
|
+
if not normalized or normalized == "none":
|
|
676
|
+
return []
|
|
677
|
+
if normalized == "all":
|
|
678
|
+
return [target.key for target in detect_targets()]
|
|
679
|
+
if normalized == "detected":
|
|
680
|
+
return detected
|
|
681
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@app.command("version")
|
|
685
|
+
def version(
|
|
686
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Show installed CLI, skill bundle, and schema versions."""
|
|
689
|
+
settings = get_settings()
|
|
690
|
+
payload = {
|
|
691
|
+
"product": "leads",
|
|
692
|
+
"cli_version": _installed_cli_version(),
|
|
693
|
+
"skill_bundle_version": SKILL_BUNDLE_VERSION,
|
|
694
|
+
"schema_version": SCHEMA_VERSION,
|
|
695
|
+
"workspace": str(settings.company_discovery_home),
|
|
696
|
+
}
|
|
697
|
+
if json_output:
|
|
698
|
+
console.print_json(data=payload)
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
table = Table(title="leads version", show_header=True, header_style="bold")
|
|
702
|
+
table.add_column("Component")
|
|
703
|
+
table.add_column("Version")
|
|
704
|
+
table.add_row("CLI", payload["cli_version"])
|
|
705
|
+
table.add_row("Skill bundle", payload["skill_bundle_version"])
|
|
706
|
+
table.add_row("DB schema", str(payload["schema_version"]))
|
|
707
|
+
table.add_row("Workspace", payload["workspace"])
|
|
708
|
+
console.print(table)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@app.command("doctor")
|
|
712
|
+
def doctor() -> None:
|
|
713
|
+
"""Check local workspace, configuration, and database readiness."""
|
|
714
|
+
settings = get_settings()
|
|
715
|
+
paths = ensure_workspace(settings.company_discovery_home)
|
|
716
|
+
checks = [
|
|
717
|
+
("Workspace", paths.root.exists(), paths.root),
|
|
718
|
+
("Config", paths.config_file.exists(), paths.config_file),
|
|
719
|
+
("Secrets", paths.secrets_file.exists(), paths.secrets_file),
|
|
720
|
+
("Runtime metadata", paths.runtime_file.exists(), paths.runtime_file),
|
|
721
|
+
("Database directory", paths.data_dir.exists(), paths.data_dir),
|
|
722
|
+
("Runs directory", paths.runs_dir.exists(), paths.runs_dir),
|
|
723
|
+
("Specs directory", paths.specs_dir.exists(), paths.specs_dir),
|
|
724
|
+
("Backups directory", paths.backups_dir.exists(), paths.backups_dir),
|
|
725
|
+
("Logs directory", paths.logs_dir.exists(), paths.logs_dir),
|
|
726
|
+
("Log file", (paths.logs_dir / "leads.log").exists(), paths.logs_dir / "leads.log"),
|
|
727
|
+
("Skills directory", paths.skills_dir.exists(), paths.skills_dir),
|
|
728
|
+
("LLM API key", bool(settings.llm_api_key), "configured" if settings.llm_api_key else "missing"),
|
|
729
|
+
("Exa API key", bool(settings.exa_api_key), "configured" if settings.exa_api_key else "optional"),
|
|
730
|
+
(
|
|
731
|
+
"Apollo API key",
|
|
732
|
+
bool(settings.apollo_api_key),
|
|
733
|
+
"configured" if settings.apollo_api_key else "optional",
|
|
734
|
+
),
|
|
735
|
+
]
|
|
736
|
+
table = Table(title="leads doctor", show_header=True, header_style="bold")
|
|
737
|
+
table.add_column("Check")
|
|
738
|
+
table.add_column("Status")
|
|
739
|
+
table.add_column("Detail")
|
|
740
|
+
for name, ok, detail in checks:
|
|
741
|
+
table.add_row(name, "[green]ok[/green]" if ok else "[yellow]attention[/yellow]", str(detail))
|
|
742
|
+
console.print(table)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@app.command("update")
|
|
746
|
+
def update(
|
|
747
|
+
check: Annotated[bool, typer.Option("--check", help="Inspect update requirements.")] = False,
|
|
748
|
+
apply_update: Annotated[
|
|
749
|
+
bool,
|
|
750
|
+
typer.Option("--apply", help="Reserved for the later safe apply workflow."),
|
|
751
|
+
] = False,
|
|
752
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
753
|
+
) -> None:
|
|
754
|
+
"""Guide or inspect the safe update workflow."""
|
|
755
|
+
settings = get_settings()
|
|
756
|
+
if check:
|
|
757
|
+
plan = build_update_check(settings)
|
|
758
|
+
if json_output:
|
|
759
|
+
console.print_json(data=plan)
|
|
760
|
+
return
|
|
761
|
+
table = Table(title="leads update check", show_header=True, header_style="bold")
|
|
762
|
+
table.add_column("Area")
|
|
763
|
+
table.add_column("Installed")
|
|
764
|
+
table.add_column("Target")
|
|
765
|
+
table.add_column("Action")
|
|
766
|
+
table.add_row(
|
|
767
|
+
"CLI",
|
|
768
|
+
plan["installed_cli_version"],
|
|
769
|
+
plan["latest_cli_version"],
|
|
770
|
+
"upgrade" if plan["cli_update_required"] else "none",
|
|
771
|
+
)
|
|
772
|
+
table.add_row(
|
|
773
|
+
"Skills",
|
|
774
|
+
str(plan["installed_skill_bundle_version"] or "not installed"),
|
|
775
|
+
plan["target_skill_bundle_version"],
|
|
776
|
+
"sync" if plan["skills_update_required"] else "none",
|
|
777
|
+
)
|
|
778
|
+
table.add_row(
|
|
779
|
+
"DB schema",
|
|
780
|
+
str(plan["current_db_schema_version"]),
|
|
781
|
+
str(plan["target_db_schema_version"]),
|
|
782
|
+
"migrate" if plan["migration_required"] else "none",
|
|
783
|
+
)
|
|
784
|
+
console.print(table)
|
|
785
|
+
console.print(f"Backup required: [bold]{'yes' if plan['backup_required'] else 'no'}[/bold]")
|
|
786
|
+
console.print(
|
|
787
|
+
f"User confirmation required: [bold]{'yes' if plan['confirmation_required'] else 'no'}[/bold]"
|
|
788
|
+
)
|
|
789
|
+
console.print(f"Risk summary: {plan['risk_summary']}")
|
|
790
|
+
return
|
|
791
|
+
|
|
792
|
+
if apply_update:
|
|
793
|
+
console.print(
|
|
794
|
+
Panel(
|
|
795
|
+
"The apply workflow is intentionally reserved for the migration and public install "
|
|
796
|
+
"phases. Run `leads update --check` first and review the plan before applying any "
|
|
797
|
+
"structural changes.",
|
|
798
|
+
title="Update apply not enabled yet",
|
|
799
|
+
border_style="yellow",
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
raise typer.Exit(2)
|
|
803
|
+
|
|
804
|
+
console.print(
|
|
805
|
+
Panel(
|
|
806
|
+
"We suggest updating this tool through one of your installed agents.\n\n"
|
|
807
|
+
"Why:\n"
|
|
808
|
+
"- the update may include CLI changes\n"
|
|
809
|
+
"- skills may need to be updated\n"
|
|
810
|
+
"- the database may need migration\n"
|
|
811
|
+
"- your agent can inspect the update plan and explain what will happen before applying changes\n\n"
|
|
812
|
+
"Suggested prompt:\n"
|
|
813
|
+
'"Please update my leads tool safely. First run `leads update --check`, explain whether '
|
|
814
|
+
"the CLI, skills, or database need changes, tell me what backups or migrations will "
|
|
815
|
+
'happen, and ask for confirmation before applying anything structural."',
|
|
816
|
+
title="Safe leads update",
|
|
817
|
+
border_style="bright_cyan",
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@app.command("migrate")
|
|
823
|
+
def migrate(
|
|
824
|
+
check: Annotated[bool, typer.Option("--check", help="Inspect database migration status.")] = False,
|
|
825
|
+
apply_migration: Annotated[
|
|
826
|
+
bool,
|
|
827
|
+
typer.Option("--apply", help="Apply the safe migration path when one is available."),
|
|
828
|
+
] = False,
|
|
829
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
830
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip interactive confirmation.")] = False,
|
|
831
|
+
) -> None:
|
|
832
|
+
"""Inspect or apply local database schema migrations."""
|
|
833
|
+
if check and apply_migration:
|
|
834
|
+
console.print("[bold red]Choose either --check or --apply, not both.[/bold red]")
|
|
835
|
+
raise typer.Exit(2)
|
|
836
|
+
|
|
837
|
+
settings = get_settings()
|
|
838
|
+
status = migration_status(settings)
|
|
839
|
+
if check or not apply_migration:
|
|
840
|
+
_render_migration_status(status.as_dict(), json_output=json_output)
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
if not status.can_apply:
|
|
844
|
+
_render_migration_status(status.as_dict(), json_output=json_output)
|
|
845
|
+
raise typer.Exit(2)
|
|
846
|
+
if status.backup_required and not yes:
|
|
847
|
+
confirmed = typer.confirm(
|
|
848
|
+
"This migration will create a timestamped database backup before applying. Continue?",
|
|
849
|
+
default=False,
|
|
850
|
+
)
|
|
851
|
+
if not confirmed:
|
|
852
|
+
console.print("Migration cancelled; nothing was changed.")
|
|
853
|
+
return
|
|
854
|
+
try:
|
|
855
|
+
result = apply_migrations(settings)
|
|
856
|
+
except MigrationError as exc:
|
|
857
|
+
console.print(f"[bold red]Migration failed:[/bold red] {exc}")
|
|
858
|
+
raise typer.Exit(1) from exc
|
|
859
|
+
if json_output:
|
|
860
|
+
console.print_json(data=result)
|
|
861
|
+
return
|
|
862
|
+
console.print(
|
|
863
|
+
Panel(
|
|
864
|
+
f"Action: [bold]{result['action']}[/bold]\n"
|
|
865
|
+
f"Schema: {result['current_schema_version']} -> {result['target_schema_version']}\n"
|
|
866
|
+
f"Backup: {result['backup_path'] or 'not required'}",
|
|
867
|
+
title="Migration complete",
|
|
868
|
+
border_style="bright_green",
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _render_migration_status(plan: dict[str, object], *, json_output: bool) -> None:
|
|
874
|
+
if json_output:
|
|
875
|
+
console.print_json(data=plan)
|
|
876
|
+
return
|
|
877
|
+
table = Table(title="leads migrate check", show_header=True, header_style="bold")
|
|
878
|
+
table.add_column("Area")
|
|
879
|
+
table.add_column("Current")
|
|
880
|
+
table.add_column("Target")
|
|
881
|
+
table.add_column("Action")
|
|
882
|
+
table.add_row(
|
|
883
|
+
"DB schema",
|
|
884
|
+
str(plan["current_schema_version"]),
|
|
885
|
+
str(plan["target_schema_version"]),
|
|
886
|
+
str(plan["action"]),
|
|
887
|
+
)
|
|
888
|
+
table.add_row(
|
|
889
|
+
"Database",
|
|
890
|
+
"present" if plan["database_exists"] else "missing",
|
|
891
|
+
str(plan["database_path"] or "not sqlite"),
|
|
892
|
+
"backup" if plan["backup_required"] else "none",
|
|
893
|
+
)
|
|
894
|
+
console.print(table)
|
|
895
|
+
console.print(f"Can apply: [bold]{'yes' if plan['can_apply'] else 'no'}[/bold]")
|
|
896
|
+
console.print(f"Risk summary: {plan['risk_summary']}")
|
|
897
|
+
console.print(f"Major-version behavior: {plan['major_version_behavior']}")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@config_app.command("show")
|
|
901
|
+
def config_show(
|
|
902
|
+
reveal_secrets: Annotated[
|
|
903
|
+
bool,
|
|
904
|
+
typer.Option("--reveal-secrets", help="Show secret values instead of masking them."),
|
|
905
|
+
] = False,
|
|
906
|
+
) -> None:
|
|
907
|
+
"""Show local workspace configuration."""
|
|
908
|
+
settings = get_settings()
|
|
909
|
+
paths = ensure_workspace(settings.company_discovery_home)
|
|
910
|
+
data = merge_dicts(read_toml(paths.config_file), read_toml(paths.secrets_file))
|
|
911
|
+
console.print_json(data=data if reveal_secrets else _mask_secrets(data))
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
@config_app.command("set")
|
|
915
|
+
def config_set(key: str, value: str) -> None:
|
|
916
|
+
"""Set a non-secret local configuration value, such as llm.model."""
|
|
917
|
+
settings = get_settings()
|
|
918
|
+
target = update_config_value(settings.company_discovery_home, key, value, secret=False)
|
|
919
|
+
get_settings.cache_clear()
|
|
920
|
+
console.print(f"Updated [bold]{key}[/bold] in [bold]{target}[/bold].")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
@config_app.command("set-secret")
|
|
924
|
+
def config_set_secret(
|
|
925
|
+
key: str,
|
|
926
|
+
value: Annotated[str | None, typer.Option("--value", help="Secret value. Prompts if omitted.")] = None,
|
|
927
|
+
) -> None:
|
|
928
|
+
"""Set a secret local configuration value, such as llm.api_key."""
|
|
929
|
+
settings = get_settings()
|
|
930
|
+
secret_value = value if value is not None else _prompt_masked_secret(f"Value for {key}", required=True)
|
|
931
|
+
target = update_config_value(settings.company_discovery_home, key, secret_value, secret=True)
|
|
932
|
+
get_settings.cache_clear()
|
|
933
|
+
console.print(f"Updated secret [bold]{key}[/bold] in [bold]{target}[/bold].")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@skills_app.command("list-targets")
|
|
937
|
+
def skills_list_targets(
|
|
938
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
939
|
+
) -> None:
|
|
940
|
+
"""List supported agent skill installation targets."""
|
|
941
|
+
targets = [
|
|
942
|
+
{
|
|
943
|
+
"target": target.key,
|
|
944
|
+
"label": target.label,
|
|
945
|
+
"path": str(target.root),
|
|
946
|
+
"detected": target.detected,
|
|
947
|
+
}
|
|
948
|
+
for target in detect_targets()
|
|
949
|
+
]
|
|
950
|
+
if json_output:
|
|
951
|
+
console.print_json(data={"targets": targets})
|
|
952
|
+
return
|
|
953
|
+
table = Table(title="Skill targets", show_header=True, header_style="bold")
|
|
954
|
+
table.add_column("Target")
|
|
955
|
+
table.add_column("Detected")
|
|
956
|
+
table.add_column("Path")
|
|
957
|
+
for target in targets:
|
|
958
|
+
table.add_row(
|
|
959
|
+
f"{target['label']} ({target['target']})",
|
|
960
|
+
"yes" if target["detected"] else "no",
|
|
961
|
+
target["path"],
|
|
962
|
+
)
|
|
963
|
+
console.print(table)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
@skills_app.command("status")
|
|
967
|
+
def skills_status(
|
|
968
|
+
json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
969
|
+
) -> None:
|
|
970
|
+
"""Show bundled skill and per-target install status."""
|
|
971
|
+
settings = get_settings()
|
|
972
|
+
status = skill_status(settings.company_discovery_home)
|
|
973
|
+
if json_output:
|
|
974
|
+
console.print_json(data=status)
|
|
975
|
+
return
|
|
976
|
+
table = Table(title="Skill install status", show_header=True, header_style="bold")
|
|
977
|
+
table.add_column("Target")
|
|
978
|
+
table.add_column("Detected")
|
|
979
|
+
table.add_column("Installed")
|
|
980
|
+
table.add_column("Path")
|
|
981
|
+
for target in status["targets"]:
|
|
982
|
+
table.add_row(
|
|
983
|
+
f"{target['label']} ({target['target']})",
|
|
984
|
+
"yes" if target["detected"] else "no",
|
|
985
|
+
"yes" if target["installed"] else "no",
|
|
986
|
+
target["path"],
|
|
987
|
+
)
|
|
988
|
+
console.print(f"Bundled skill version: [bold]{SKILL_BUNDLE_VERSION}[/bold]")
|
|
989
|
+
console.print(table)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@skills_app.command("install")
|
|
993
|
+
def skills_install(
|
|
994
|
+
targets: Annotated[
|
|
995
|
+
list[str],
|
|
996
|
+
typer.Option("--target", help="Target to install into. Repeat for multiple targets."),
|
|
997
|
+
] = [],
|
|
998
|
+
) -> None:
|
|
999
|
+
"""Install bundled skills into selected agent targets."""
|
|
1000
|
+
settings = get_settings()
|
|
1001
|
+
selected = targets or [target.key for target in detect_targets() if target.detected]
|
|
1002
|
+
if not selected:
|
|
1003
|
+
console.print("[yellow]No detected skill targets. Pass --target to choose one explicitly.[/yellow]")
|
|
1004
|
+
return
|
|
1005
|
+
metadata = install_skills(settings.company_discovery_home, selected)
|
|
1006
|
+
console.print(
|
|
1007
|
+
Panel(
|
|
1008
|
+
"\n".join(
|
|
1009
|
+
f"{install['label']}: {install['path']}"
|
|
1010
|
+
for install in metadata["installs"]
|
|
1011
|
+
if install["target"] in selected
|
|
1012
|
+
),
|
|
1013
|
+
title="Skills installed",
|
|
1014
|
+
border_style="bright_green",
|
|
1015
|
+
)
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@skills_app.command("reinstall")
|
|
1020
|
+
def skills_reinstall() -> None:
|
|
1021
|
+
"""Reinstall skills into previously installed targets."""
|
|
1022
|
+
settings = get_settings()
|
|
1023
|
+
selected = installed_target_keys(settings.company_discovery_home)
|
|
1024
|
+
if not selected:
|
|
1025
|
+
console.print("[yellow]No previous skill installs found. Use leads skills install first.[/yellow]")
|
|
1026
|
+
return
|
|
1027
|
+
metadata = install_skills(settings.company_discovery_home, selected)
|
|
1028
|
+
console.print(
|
|
1029
|
+
Panel(
|
|
1030
|
+
"\n".join(
|
|
1031
|
+
f"{install['label']}: {install['path']}"
|
|
1032
|
+
for install in metadata["installs"]
|
|
1033
|
+
if install["target"] in selected
|
|
1034
|
+
),
|
|
1035
|
+
title="Skills reinstalled",
|
|
1036
|
+
border_style="bright_green",
|
|
1037
|
+
)
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
@app.command("init")
|
|
1042
|
+
def init(
|
|
1043
|
+
workspace: Annotated[Path | None, typer.Option("--workspace", help="Workspace root.")] = None,
|
|
1044
|
+
llm_provider: Annotated[str, typer.Option("--llm-provider")] = "openai",
|
|
1045
|
+
llm_model: Annotated[str, typer.Option("--llm-model")] = "gpt-5-mini",
|
|
1046
|
+
llm_api_key: Annotated[str | None, typer.Option("--llm-api-key")] = None,
|
|
1047
|
+
exa_api_key: Annotated[str | None, typer.Option("--exa-api-key")] = None,
|
|
1048
|
+
apollo_api_key: Annotated[str | None, typer.Option("--apollo-api-key")] = None,
|
|
1049
|
+
apollo_webhook_url: Annotated[str | None, typer.Option("--apollo-webhook-url")] = None,
|
|
1050
|
+
targets: Annotated[
|
|
1051
|
+
list[str],
|
|
1052
|
+
typer.Option("--target", help="Skill target to install. Repeat for multiple targets."),
|
|
1053
|
+
] = [],
|
|
1054
|
+
skip_skills: Annotated[bool, typer.Option("--skip-skills", help="Do not install agent skills.")] = False,
|
|
1055
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Accept defaults for non-secret prompts.")] = False,
|
|
1056
|
+
) -> None:
|
|
1057
|
+
"""Create or repair the local leads workspace and first-run configuration."""
|
|
1058
|
+
env_workspace = os.getenv("LEADS_HOME") or os.getenv("COMPANY_DISCOVERY_HOME")
|
|
1059
|
+
if workspace:
|
|
1060
|
+
root = workspace.expanduser()
|
|
1061
|
+
elif env_workspace:
|
|
1062
|
+
root = Path(env_workspace).expanduser()
|
|
1063
|
+
else:
|
|
1064
|
+
root = default_workspace_root()
|
|
1065
|
+
if workspace is None and not yes:
|
|
1066
|
+
console.print(
|
|
1067
|
+
Panel(
|
|
1068
|
+
"This wizard will create one local workspace, configure your model provider, "
|
|
1069
|
+
"set optional data providers, install agent skills, and initialize the local database.",
|
|
1070
|
+
title="Welcome to leads",
|
|
1071
|
+
border_style="bright_cyan",
|
|
1072
|
+
)
|
|
1073
|
+
)
|
|
1074
|
+
root = _select_workspace_root(root)
|
|
1075
|
+
|
|
1076
|
+
paths = ensure_workspace(root)
|
|
1077
|
+
configure_workspace_logging(root)
|
|
1078
|
+
write_workspace_pointer(root)
|
|
1079
|
+
existing_config = read_toml(paths.config_file)
|
|
1080
|
+
existing_secrets = read_toml(paths.secrets_file)
|
|
1081
|
+
|
|
1082
|
+
selected_provider = llm_provider
|
|
1083
|
+
selected_model = llm_model
|
|
1084
|
+
selected_base_url = _provider_base_url(
|
|
1085
|
+
selected_provider,
|
|
1086
|
+
str(_nested_value(existing_config, "llm.base_url", "")) or None,
|
|
1087
|
+
)
|
|
1088
|
+
selected_llm_key = llm_api_key
|
|
1089
|
+
selected_exa_key = exa_api_key
|
|
1090
|
+
selected_apollo_key = apollo_api_key
|
|
1091
|
+
selected_apollo_webhook = apollo_webhook_url
|
|
1092
|
+
enable_exa = bool(exa_api_key)
|
|
1093
|
+
enable_apollo = bool(apollo_api_key or apollo_webhook_url)
|
|
1094
|
+
|
|
1095
|
+
if not yes:
|
|
1096
|
+
console.print("\n[bold bright_cyan]Model provider[/bold bright_cyan]")
|
|
1097
|
+
current_provider = str(_nested_value(existing_config, "llm.provider", selected_provider))
|
|
1098
|
+
selected_provider = _select_choice(
|
|
1099
|
+
"LLM provider",
|
|
1100
|
+
LLM_PROVIDER_CHOICES,
|
|
1101
|
+
default=current_provider if _provider_choice(current_provider) else "openai",
|
|
1102
|
+
)
|
|
1103
|
+
selected_base_url = _provider_base_url(
|
|
1104
|
+
selected_provider,
|
|
1105
|
+
str(_nested_value(existing_config, "llm.base_url", selected_base_url)),
|
|
1106
|
+
)
|
|
1107
|
+
if selected_provider == "custom":
|
|
1108
|
+
selected_base_url = typer.prompt("LLM base URL", default=selected_base_url)
|
|
1109
|
+
current_model = str(_nested_value(existing_config, "llm.model", selected_model))
|
|
1110
|
+
selected_model = _select_model(selected_provider, current_model)
|
|
1111
|
+
selected_llm_key = _prompt_secret(
|
|
1112
|
+
"LLM API key",
|
|
1113
|
+
existing=bool(_nested_value(existing_secrets, "llm.api_key", "")),
|
|
1114
|
+
required=True,
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
console.print("\n[bold bright_cyan]Search provider[/bold bright_cyan]")
|
|
1118
|
+
enable_exa = typer.confirm("Configure Exa for live web/company search?", default=bool(exa_api_key))
|
|
1119
|
+
if enable_exa:
|
|
1120
|
+
selected_exa_key = _prompt_secret(
|
|
1121
|
+
"Exa API key",
|
|
1122
|
+
existing=bool(_nested_value(existing_secrets, "providers.exa.api_key", "")),
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
console.print("\n[bold bright_cyan]Contact enrichment[/bold bright_cyan]")
|
|
1126
|
+
enable_apollo = typer.confirm(
|
|
1127
|
+
"Configure Apollo for contact email/phone enrichment?",
|
|
1128
|
+
default=bool(apollo_api_key or apollo_webhook_url),
|
|
1129
|
+
)
|
|
1130
|
+
if enable_apollo:
|
|
1131
|
+
selected_apollo_key = _prompt_secret(
|
|
1132
|
+
"Apollo API key",
|
|
1133
|
+
existing=bool(_nested_value(existing_secrets, "providers.apollo.api_key", "")),
|
|
1134
|
+
)
|
|
1135
|
+
selected_apollo_webhook = typer.prompt(
|
|
1136
|
+
"Apollo webhook URL for phone enrichment (blank for email-only)",
|
|
1137
|
+
default=str(_nested_value(existing_config, "providers.apollo.webhook_url", "")),
|
|
1138
|
+
show_default=False,
|
|
1139
|
+
) or None
|
|
1140
|
+
|
|
1141
|
+
update_config_value(root, "llm.provider", selected_provider, secret=False)
|
|
1142
|
+
update_config_value(root, "llm.base_url", selected_base_url, secret=False)
|
|
1143
|
+
update_config_value(root, "llm.model", selected_model, secret=False)
|
|
1144
|
+
if selected_llm_key:
|
|
1145
|
+
update_config_value(root, "llm.api_key", selected_llm_key, secret=True)
|
|
1146
|
+
update_config_value(root, "providers.exa.enabled", str(enable_exa).lower(), secret=False)
|
|
1147
|
+
if selected_exa_key:
|
|
1148
|
+
update_config_value(root, "providers.exa.api_key", selected_exa_key, secret=True)
|
|
1149
|
+
update_config_value(root, "providers.apollo.enabled", str(enable_apollo).lower(), secret=False)
|
|
1150
|
+
if selected_apollo_key:
|
|
1151
|
+
update_config_value(root, "providers.apollo.api_key", selected_apollo_key, secret=True)
|
|
1152
|
+
if selected_apollo_webhook is not None:
|
|
1153
|
+
update_config_value(root, "providers.apollo.webhook_url", selected_apollo_webhook, secret=False)
|
|
1154
|
+
|
|
1155
|
+
database = Database(f"sqlite:///{paths.database_file.resolve()}")
|
|
1156
|
+
try:
|
|
1157
|
+
database.create_schema()
|
|
1158
|
+
finally:
|
|
1159
|
+
database.dispose()
|
|
1160
|
+
|
|
1161
|
+
installed: list[dict[str, object]] = []
|
|
1162
|
+
if not skip_skills:
|
|
1163
|
+
detected_targets = detect_targets()
|
|
1164
|
+
detected_keys = [target.key for target in detected_targets if target.detected]
|
|
1165
|
+
selected_targets = targets or (detected_keys if yes else [])
|
|
1166
|
+
if not yes:
|
|
1167
|
+
console.print("\n[bold bright_cyan]Agent skills[/bold bright_cyan]")
|
|
1168
|
+
selected_targets = _select_skill_targets(detected_targets, detected_keys)
|
|
1169
|
+
if selected_targets:
|
|
1170
|
+
installed = install_skills(root, selected_targets)["installs"]
|
|
1171
|
+
|
|
1172
|
+
status_table = Table(show_header=False, box=None, padding=(0, 1))
|
|
1173
|
+
status_table.add_column("Item", style="bold")
|
|
1174
|
+
status_table.add_column("Value")
|
|
1175
|
+
status_table.add_row("Workspace", str(paths.root))
|
|
1176
|
+
status_table.add_row("Database", str(paths.database_file))
|
|
1177
|
+
status_table.add_row("LLM", f"{selected_provider} / {selected_model}")
|
|
1178
|
+
status_table.add_row("Exa", "configured" if enable_exa else "skipped")
|
|
1179
|
+
status_table.add_row("Apollo", "configured" if enable_apollo else "skipped")
|
|
1180
|
+
status_table.add_row(
|
|
1181
|
+
"Skills",
|
|
1182
|
+
", ".join(str(install["label"]) for install in installed) if installed else "none",
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
handoff = [
|
|
1186
|
+
"Setup complete.",
|
|
1187
|
+
]
|
|
1188
|
+
handoff.extend(
|
|
1189
|
+
[
|
|
1190
|
+
"",
|
|
1191
|
+
"Now use one of those agents to find the best leads with this system.",
|
|
1192
|
+
"",
|
|
1193
|
+
"Suggested test prompt:",
|
|
1194
|
+
'"Use the company search spec writer and company discovery operator to create a small '
|
|
1195
|
+
'test spec for 10 US companies in a niche I choose, run it, and summarize the selected leads."',
|
|
1196
|
+
]
|
|
1197
|
+
)
|
|
1198
|
+
console.print("\n")
|
|
1199
|
+
console.print(Panel(status_table, title="Configuration summary", border_style="bright_green"))
|
|
1200
|
+
console.print(Panel("\n".join(handoff), title="leads init", border_style="bright_green"))
|
|
1201
|
+
get_settings.cache_clear()
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
@app.command("init-db")
|
|
1205
|
+
def init_db() -> None:
|
|
1206
|
+
"""Create the database schema, optionally resetting an existing database."""
|
|
1207
|
+
settings = get_settings()
|
|
1208
|
+
database_path = settings.sqlite_database_path
|
|
1209
|
+
if database_path is None:
|
|
1210
|
+
console.print(
|
|
1211
|
+
"[bold red]Cannot initialize database:[/bold red] "
|
|
1212
|
+
"init-db requires an on-disk SQLite DATABASE_URL."
|
|
1213
|
+
)
|
|
1214
|
+
raise typer.Exit(2)
|
|
1215
|
+
|
|
1216
|
+
resetting = database_path.exists()
|
|
1217
|
+
if resetting and not typer.confirm(
|
|
1218
|
+
f"{database_path} already exists. Reset it and archive the current runs?",
|
|
1219
|
+
default=False,
|
|
1220
|
+
):
|
|
1221
|
+
console.print("Database reset cancelled; nothing was changed.")
|
|
1222
|
+
return
|
|
1223
|
+
|
|
1224
|
+
archived_runs: Path | None = None
|
|
1225
|
+
if resetting and settings.artifacts_dir.exists():
|
|
1226
|
+
archived_runs = _next_runs_archive_path(settings.company_discovery_home)
|
|
1227
|
+
settings.artifacts_dir.rename(archived_runs)
|
|
1228
|
+
|
|
1229
|
+
if resetting:
|
|
1230
|
+
database_path.unlink()
|
|
1231
|
+
for suffix in ("-wal", "-shm"):
|
|
1232
|
+
database_path.with_name(f"{database_path.name}{suffix}").unlink(missing_ok=True)
|
|
1233
|
+
|
|
1234
|
+
settings.prepare_directories()
|
|
1235
|
+
database = Database(settings.resolved_database_url)
|
|
1236
|
+
try:
|
|
1237
|
+
database.create_schema()
|
|
1238
|
+
finally:
|
|
1239
|
+
database.dispose()
|
|
1240
|
+
|
|
1241
|
+
message = f"Created a fresh database at [bold]{database_path}[/bold]."
|
|
1242
|
+
if archived_runs is not None:
|
|
1243
|
+
message += f"\nArchived previous run artifacts to [bold]{archived_runs}[/bold]."
|
|
1244
|
+
console.print(Panel(message, title="Database initialized", border_style="bright_green"))
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
@companies.command("discover")
|
|
1248
|
+
def discover(
|
|
1249
|
+
spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
|
|
1250
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
1251
|
+
) -> None:
|
|
1252
|
+
"""Discover companies from a validated JSON search spec."""
|
|
1253
|
+
try:
|
|
1254
|
+
spec = CompanySearchSpec.from_file(spec_path)
|
|
1255
|
+
except (ValueError, ValidationError) as exc:
|
|
1256
|
+
console.print(f"[bold red]Invalid search spec:[/bold red] {exc}")
|
|
1257
|
+
raise typer.Exit(2) from exc
|
|
1258
|
+
|
|
1259
|
+
settings = get_settings()
|
|
1260
|
+
database, _, pipeline, resources = build_runtime(settings)
|
|
1261
|
+
try:
|
|
1262
|
+
result = pipeline.discover(
|
|
1263
|
+
spec,
|
|
1264
|
+
source_spec_path=spec_path,
|
|
1265
|
+
progress=RichProgressReporter(verbose=verbose),
|
|
1266
|
+
)
|
|
1267
|
+
except Exception as exc:
|
|
1268
|
+
console.print(f"[bold red]Discovery failed:[/bold red] {exc}")
|
|
1269
|
+
raise typer.Exit(1) from exc
|
|
1270
|
+
finally:
|
|
1271
|
+
close_runtime(database, resources)
|
|
1272
|
+
|
|
1273
|
+
console.print(
|
|
1274
|
+
Panel(
|
|
1275
|
+
f"Run [bold]{result.run_id}[/bold]\n"
|
|
1276
|
+
f"Selected {result.summary.selected} | Reserve {result.summary.reserve} | "
|
|
1277
|
+
f"Rejected {result.summary.rejected}\n"
|
|
1278
|
+
f"Summary: {result.artifact_paths['summary']}",
|
|
1279
|
+
title="Discovery complete",
|
|
1280
|
+
border_style="bright_green",
|
|
1281
|
+
)
|
|
1282
|
+
)
|
|
1283
|
+
@companies.command("enrich")
|
|
1284
|
+
def enrich(
|
|
1285
|
+
discovery_run_id: str,
|
|
1286
|
+
bucket: Annotated[str, typer.Option("--bucket")] = "selected",
|
|
1287
|
+
limit: Annotated[int | None, typer.Option("--limit", min=1)] = None,
|
|
1288
|
+
refresh: Annotated[
|
|
1289
|
+
str,
|
|
1290
|
+
typer.Option(help="Refresh scope: none, contact, independence, or all."),
|
|
1291
|
+
] = "none",
|
|
1292
|
+
allow_unknown_independence: Annotated[
|
|
1293
|
+
bool,
|
|
1294
|
+
typer.Option(
|
|
1295
|
+
"--allow-unknown-independence",
|
|
1296
|
+
help="Allow complete profiles with unknown independence into enriched.csv.",
|
|
1297
|
+
),
|
|
1298
|
+
] = False,
|
|
1299
|
+
) -> None:
|
|
1300
|
+
"""Enrich companies selected by a completed discovery run."""
|
|
1301
|
+
if bucket not in {"selected", "reserve"}:
|
|
1302
|
+
console.print("[bold red]Invalid bucket:[/bold red] use selected or reserve")
|
|
1303
|
+
raise typer.Exit(2)
|
|
1304
|
+
if refresh not in {"none", "contact", "independence", "all"}:
|
|
1305
|
+
console.print(
|
|
1306
|
+
"[bold red]Invalid refresh scope:[/bold red] use none, contact, independence, or all"
|
|
1307
|
+
)
|
|
1308
|
+
raise typer.Exit(2)
|
|
1309
|
+
_execute_enrichment(
|
|
1310
|
+
discovery_run_id,
|
|
1311
|
+
EnrichmentOptions(
|
|
1312
|
+
bucket=bucket,
|
|
1313
|
+
limit=limit,
|
|
1314
|
+
refresh=refresh,
|
|
1315
|
+
allow_unknown_independence=allow_unknown_independence,
|
|
1316
|
+
),
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _execute_enrichment(discovery_run_id: str, options: EnrichmentOptions) -> None:
|
|
1321
|
+
settings = get_settings()
|
|
1322
|
+
database, _, pipeline, resources = build_enrichment_runtime(settings)
|
|
1323
|
+
try:
|
|
1324
|
+
result = pipeline.enrich(
|
|
1325
|
+
discovery_run_id,
|
|
1326
|
+
options=options,
|
|
1327
|
+
progress=RichEnrichmentProgressReporter(),
|
|
1328
|
+
)
|
|
1329
|
+
except Exception as exc:
|
|
1330
|
+
console.print(f"[bold red]Enrichment failed:[/bold red] {exc}")
|
|
1331
|
+
raise typer.Exit(1) from exc
|
|
1332
|
+
finally:
|
|
1333
|
+
close_runtime(database, resources)
|
|
1334
|
+
console.print(
|
|
1335
|
+
Panel(
|
|
1336
|
+
f"Run [bold]{result.run_id}[/bold]\n"
|
|
1337
|
+
f"Ready {result.summary.ready} | Review {result.summary.review} | "
|
|
1338
|
+
f"Blocked {result.summary.blocked} | Failed {result.summary.failed}\n"
|
|
1339
|
+
f"Output: {result.artifact_paths['enriched']}",
|
|
1340
|
+
title="Enrichment complete",
|
|
1341
|
+
border_style="bright_green",
|
|
1342
|
+
)
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
@companies.command("show-enrichment")
|
|
1347
|
+
def show_enrichment(run_id: str) -> None:
|
|
1348
|
+
"""Show counts, source run, and artifacts for an enrichment run."""
|
|
1349
|
+
database, repository, _, resources = build_enrichment_runtime(get_settings())
|
|
1350
|
+
try:
|
|
1351
|
+
payload = repository.get_run(run_id)
|
|
1352
|
+
summary = payload["summary"]
|
|
1353
|
+
console.print(
|
|
1354
|
+
Panel(
|
|
1355
|
+
f"Status: {payload['status']}\n"
|
|
1356
|
+
f"Discovery run: {payload['discovery_run_id']}\n"
|
|
1357
|
+
f"Input bucket: {payload['bucket']}\n"
|
|
1358
|
+
f"Processed: {summary.get('processed', 0)} | Ready: {summary.get('ready', 0)} | "
|
|
1359
|
+
f"Review: {summary.get('review', 0)} | Blocked: {summary.get('blocked', 0)}",
|
|
1360
|
+
title=f"Enrichment {run_id}",
|
|
1361
|
+
)
|
|
1362
|
+
)
|
|
1363
|
+
if payload["artifacts"]:
|
|
1364
|
+
console.print_json(json.dumps(payload["artifacts"], ensure_ascii=True))
|
|
1365
|
+
except EnrichmentRunNotFoundError as exc:
|
|
1366
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1367
|
+
raise typer.Exit(1) from exc
|
|
1368
|
+
finally:
|
|
1369
|
+
close_runtime(database, resources)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
@companies.command("inspect-enrichment")
|
|
1373
|
+
def inspect_enrichment(run_id: str, domain: Annotated[str, typer.Option("--domain")]) -> None:
|
|
1374
|
+
"""Inspect one enriched company including provenance, conflicts, and trace."""
|
|
1375
|
+
normalized = canonical_domain(domain)
|
|
1376
|
+
if normalized is None:
|
|
1377
|
+
console.print(f"[bold red]Invalid domain:[/bold red] {domain}")
|
|
1378
|
+
raise typer.Exit(2)
|
|
1379
|
+
database, repository, _, resources = build_enrichment_runtime(get_settings())
|
|
1380
|
+
try:
|
|
1381
|
+
console.print_json(json.dumps(repository.inspect_item(run_id, normalized), ensure_ascii=True))
|
|
1382
|
+
except (EnrichmentRunNotFoundError, CandidateNotFoundError) as exc:
|
|
1383
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1384
|
+
raise typer.Exit(1) from exc
|
|
1385
|
+
finally:
|
|
1386
|
+
close_runtime(database, resources)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
@companies.command("export-enrichment")
|
|
1390
|
+
def export_enrichment(run_id: str) -> None:
|
|
1391
|
+
"""Regenerate enrichment CSV, Markdown, and JSON artifacts."""
|
|
1392
|
+
settings = get_settings()
|
|
1393
|
+
database, repository, _, resources = build_enrichment_runtime(settings)
|
|
1394
|
+
try:
|
|
1395
|
+
payload = repository.get_run(run_id)
|
|
1396
|
+
if payload["status"] != "completed":
|
|
1397
|
+
raise ValueError(f"enrichment run {run_id} is {payload['status']}, not completed")
|
|
1398
|
+
summary = EnrichmentSummary.model_validate(payload["summary"])
|
|
1399
|
+
paths = EnrichmentArtifactExporter(settings.artifacts_dir).export(payload, summary)
|
|
1400
|
+
repository.set_artifacts(run_id, paths)
|
|
1401
|
+
console.print(f"Exported enrichment [bold]{run_id}[/bold] to {Path(paths['json']).parent}")
|
|
1402
|
+
except (EnrichmentRunNotFoundError, ValueError) as exc:
|
|
1403
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1404
|
+
raise typer.Exit(1) from exc
|
|
1405
|
+
finally:
|
|
1406
|
+
close_runtime(database, resources)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
@companies.command("validate-spec")
|
|
1410
|
+
def validate_spec(
|
|
1411
|
+
spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
|
|
1412
|
+
) -> None:
|
|
1413
|
+
"""Validate and print the normalized form of a search spec without running discovery."""
|
|
1414
|
+
try:
|
|
1415
|
+
spec = CompanySearchSpec.from_file(spec_path)
|
|
1416
|
+
except (ValueError, ValidationError) as exc:
|
|
1417
|
+
console.print(f"[bold red]Invalid search spec:[/bold red] {exc}")
|
|
1418
|
+
raise typer.Exit(2) from exc
|
|
1419
|
+
console.print("[bold green]Valid company search spec[/bold green]")
|
|
1420
|
+
console.print_json(spec.model_dump_json(indent=2))
|
|
1421
|
+
for condition in spec.missing_constraints:
|
|
1422
|
+
console.print(f"[yellow]Note:[/yellow] {condition}")
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
@companies.command("show-run")
|
|
1426
|
+
def show_run(run_id: str) -> None:
|
|
1427
|
+
"""Show the spec, queries, counts, and artifacts for a prior run."""
|
|
1428
|
+
database, repository, _, resources = build_runtime(get_settings())
|
|
1429
|
+
try:
|
|
1430
|
+
payload = repository.get_run(run_id)
|
|
1431
|
+
_render_run(payload)
|
|
1432
|
+
except RunNotFoundError as exc:
|
|
1433
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1434
|
+
raise typer.Exit(1) from exc
|
|
1435
|
+
finally:
|
|
1436
|
+
close_runtime(database, resources)
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
@companies.command("export")
|
|
1440
|
+
def export_run(run_id: str) -> None:
|
|
1441
|
+
"""Regenerate CSV, Markdown, and JSON artifacts for a prior run."""
|
|
1442
|
+
settings = get_settings()
|
|
1443
|
+
database, repository, _, resources = build_runtime(settings)
|
|
1444
|
+
try:
|
|
1445
|
+
payload = repository.get_run(run_id)
|
|
1446
|
+
if payload["status"] != "completed":
|
|
1447
|
+
raise ValueError(f"run {run_id} is {payload['status']}, not completed")
|
|
1448
|
+
summary = RunSummary.model_validate(payload["summary"])
|
|
1449
|
+
paths = ArtifactExporter(settings.artifacts_dir).export(payload, summary)
|
|
1450
|
+
repository.set_artifacts(run_id, paths)
|
|
1451
|
+
console.print(f"Exported run [bold]{run_id}[/bold] to {Path(paths['summary']).parent}")
|
|
1452
|
+
except (RunNotFoundError, ValueError) as exc:
|
|
1453
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1454
|
+
raise typer.Exit(1) from exc
|
|
1455
|
+
finally:
|
|
1456
|
+
close_runtime(database, resources)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
@companies.command("inspect")
|
|
1460
|
+
def inspect(run_id: str, domain: Annotated[str, typer.Option("--domain")]) -> None:
|
|
1461
|
+
"""Inspect one run candidate, its evidence, and its evaluation."""
|
|
1462
|
+
normalized = canonical_domain(domain)
|
|
1463
|
+
if normalized is None:
|
|
1464
|
+
console.print(f"[bold red]Invalid domain:[/bold red] {domain}")
|
|
1465
|
+
raise typer.Exit(2)
|
|
1466
|
+
database, repository, _, resources = build_runtime(get_settings())
|
|
1467
|
+
try:
|
|
1468
|
+
payload = repository.inspect_candidate(run_id, normalized)
|
|
1469
|
+
console.print_json(json.dumps(payload, ensure_ascii=True))
|
|
1470
|
+
except CandidateNotFoundError as exc:
|
|
1471
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1472
|
+
raise typer.Exit(1) from exc
|
|
1473
|
+
finally:
|
|
1474
|
+
close_runtime(database, resources)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
@companies.command("rerun")
|
|
1478
|
+
def rerun(
|
|
1479
|
+
run_id: str,
|
|
1480
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
1481
|
+
) -> None:
|
|
1482
|
+
"""Run a prior immutable spec again with its saved novelty policy."""
|
|
1483
|
+
settings = get_settings()
|
|
1484
|
+
database, repository, pipeline, resources = build_runtime(settings)
|
|
1485
|
+
try:
|
|
1486
|
+
prior = repository.get_run(run_id)
|
|
1487
|
+
spec = CompanySearchSpec.model_validate(prior["spec"])
|
|
1488
|
+
result = pipeline.discover(spec, progress=RichProgressReporter(verbose=verbose))
|
|
1489
|
+
console.print(
|
|
1490
|
+
f"Rerun complete: [bold]{result.run_id}[/bold] | selected {result.summary.selected} | "
|
|
1491
|
+
f"reserve {result.summary.reserve} | rejected {result.summary.rejected}"
|
|
1492
|
+
)
|
|
1493
|
+
except RunNotFoundError as exc:
|
|
1494
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1495
|
+
raise typer.Exit(1) from exc
|
|
1496
|
+
except Exception as exc:
|
|
1497
|
+
console.print(f"[bold red]Rerun failed:[/bold red] {exc}")
|
|
1498
|
+
raise typer.Exit(1) from exc
|
|
1499
|
+
finally:
|
|
1500
|
+
close_runtime(database, resources)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _render_run(payload: dict[str, object]) -> None:
|
|
1504
|
+
spec = payload["spec"]
|
|
1505
|
+
assert isinstance(spec, dict)
|
|
1506
|
+
summary = payload["summary"]
|
|
1507
|
+
assert isinstance(summary, dict)
|
|
1508
|
+
verticals = spec["verticals"]
|
|
1509
|
+
geography = spec["geography"]
|
|
1510
|
+
assert isinstance(verticals, list) and isinstance(geography, dict)
|
|
1511
|
+
vertical_labels = ", ".join(
|
|
1512
|
+
str(vertical["label"]) for vertical in verticals if isinstance(vertical, dict)
|
|
1513
|
+
)
|
|
1514
|
+
console.print(
|
|
1515
|
+
Panel(
|
|
1516
|
+
f"Status: {payload['status']}\n"
|
|
1517
|
+
f"Verticals: {vertical_labels}\n"
|
|
1518
|
+
f"Balance: {spec.get('balance_mode', 'soft')}\n"
|
|
1519
|
+
f"Novelty: {spec.get('novelty_mode', 'unused_memory')}\n"
|
|
1520
|
+
f"Geography: {geography['country']} / {', '.join(geography['states']) or 'all'}\n"
|
|
1521
|
+
f"Requested: {spec['count']}",
|
|
1522
|
+
title=f"Run {payload['run_id']}",
|
|
1523
|
+
)
|
|
1524
|
+
)
|
|
1525
|
+
table = Table("Metric", "Count")
|
|
1526
|
+
for key in ("memory_matched", "memory_reused", "external_gap", "raw_results", "selected", "reserve", "rejected"):
|
|
1527
|
+
table.add_row(key.replace("_", " ").title(), str(summary.get(key, 0)))
|
|
1528
|
+
console.print(table)
|
|
1529
|
+
queries = payload["queries"]
|
|
1530
|
+
assert isinstance(queries, list)
|
|
1531
|
+
if queries:
|
|
1532
|
+
console.print("[bold]Queries[/bold]")
|
|
1533
|
+
for query in queries:
|
|
1534
|
+
console.print(f" * {query}")
|
|
1535
|
+
artifacts = payload["artifacts"]
|
|
1536
|
+
assert isinstance(artifacts, dict)
|
|
1537
|
+
if artifacts:
|
|
1538
|
+
console.print("[bold]Artifacts[/bold]")
|
|
1539
|
+
for name, path in artifacts.items():
|
|
1540
|
+
console.print(f" {name}: {path}")
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
@contacts.command("validate-spec")
|
|
1544
|
+
def validate_contact_spec(
|
|
1545
|
+
spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
|
|
1546
|
+
) -> None:
|
|
1547
|
+
"""Validate and print a normalized contact discovery spec."""
|
|
1548
|
+
try:
|
|
1549
|
+
spec = ContactSearchSpec.from_file(spec_path)
|
|
1550
|
+
except (ValueError, ValidationError) as exc:
|
|
1551
|
+
console.print(f"[bold red]Invalid contact spec:[/bold red] {exc}")
|
|
1552
|
+
raise typer.Exit(2) from exc
|
|
1553
|
+
console.print("[bold green]Valid contact search spec[/bold green]")
|
|
1554
|
+
console.print_json(spec.model_dump_json(indent=2))
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
@contacts.command("discover")
|
|
1558
|
+
def discover_contacts(
|
|
1559
|
+
spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
|
|
1560
|
+
) -> None:
|
|
1561
|
+
"""Discover current role-matched people at enriched companies."""
|
|
1562
|
+
try:
|
|
1563
|
+
spec = ContactSearchSpec.from_file(spec_path)
|
|
1564
|
+
except (ValueError, ValidationError) as exc:
|
|
1565
|
+
console.print(f"[bold red]Invalid contact spec:[/bold red] {exc}")
|
|
1566
|
+
raise typer.Exit(2) from exc
|
|
1567
|
+
|
|
1568
|
+
database, _, pipeline, resources = build_contact_runtime(get_settings())
|
|
1569
|
+
try:
|
|
1570
|
+
result = pipeline.discover(
|
|
1571
|
+
spec,
|
|
1572
|
+
source_spec_path=spec_path,
|
|
1573
|
+
progress=RichContactProgressReporter(),
|
|
1574
|
+
)
|
|
1575
|
+
except Exception as exc:
|
|
1576
|
+
console.print(f"[bold red]Contact discovery failed:[/bold red] {exc}")
|
|
1577
|
+
raise typer.Exit(1) from exc
|
|
1578
|
+
finally:
|
|
1579
|
+
close_runtime(database, resources)
|
|
1580
|
+
|
|
1581
|
+
console.print(
|
|
1582
|
+
Panel(
|
|
1583
|
+
f"Run [bold]{result.run_id}[/bold]\n"
|
|
1584
|
+
f"Accepted {result.summary.accepted} | Review {result.summary.review} | "
|
|
1585
|
+
f"Rejected {result.summary.rejected}\n"
|
|
1586
|
+
f"Output: {result.artifact_paths['accepted']}",
|
|
1587
|
+
title="Contact discovery complete",
|
|
1588
|
+
border_style="bright_green",
|
|
1589
|
+
)
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
@contacts.command("show-run")
|
|
1594
|
+
def show_contact_run(run_id: str) -> None:
|
|
1595
|
+
"""Show scope, counts, queries, and artifacts for a contact discovery run."""
|
|
1596
|
+
database, repository, _, resources = build_contact_runtime(get_settings())
|
|
1597
|
+
try:
|
|
1598
|
+
payload = repository.get_run(run_id)
|
|
1599
|
+
summary = payload["summary"]
|
|
1600
|
+
console.print(
|
|
1601
|
+
Panel(
|
|
1602
|
+
f"Status: {payload['status']}\n"
|
|
1603
|
+
f"Company enrichment run: {payload['source_enrichment_run_id']}\n"
|
|
1604
|
+
f"Companies: {summary.get('companies_loaded', 0)} | "
|
|
1605
|
+
f"Memory: {summary.get('memory_reused', 0)} | "
|
|
1606
|
+
f"Queries: {summary.get('queries_run', 0)}\n"
|
|
1607
|
+
f"Accepted: {summary.get('accepted', 0)} | "
|
|
1608
|
+
f"Review: {summary.get('review', 0)} | "
|
|
1609
|
+
f"Rejected: {summary.get('rejected', 0)}",
|
|
1610
|
+
title=f"Contact run {run_id}",
|
|
1611
|
+
)
|
|
1612
|
+
)
|
|
1613
|
+
if payload["artifacts"]:
|
|
1614
|
+
console.print_json(json.dumps(payload["artifacts"], ensure_ascii=True))
|
|
1615
|
+
except ContactRunNotFoundError as exc:
|
|
1616
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1617
|
+
raise typer.Exit(1) from exc
|
|
1618
|
+
finally:
|
|
1619
|
+
close_runtime(database, resources)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
@contacts.command("inspect")
|
|
1623
|
+
def inspect_contact(
|
|
1624
|
+
run_id: str,
|
|
1625
|
+
person: Annotated[str, typer.Option("--person")],
|
|
1626
|
+
) -> None:
|
|
1627
|
+
"""Inspect one person's role decisions and live evidence."""
|
|
1628
|
+
database, repository, _, resources = build_contact_runtime(get_settings())
|
|
1629
|
+
try:
|
|
1630
|
+
console.print_json(
|
|
1631
|
+
json.dumps(repository.inspect_contact(run_id, person), ensure_ascii=True)
|
|
1632
|
+
)
|
|
1633
|
+
except (ContactRunNotFoundError, ContactNotFoundError) as exc:
|
|
1634
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1635
|
+
raise typer.Exit(1) from exc
|
|
1636
|
+
finally:
|
|
1637
|
+
close_runtime(database, resources)
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
@contacts.command("export")
|
|
1641
|
+
def export_contact_run(run_id: str) -> None:
|
|
1642
|
+
"""Regenerate contact discovery artifacts from the stored run."""
|
|
1643
|
+
settings = get_settings()
|
|
1644
|
+
database, repository, _, resources = build_contact_runtime(settings)
|
|
1645
|
+
try:
|
|
1646
|
+
payload = repository.get_run(run_id)
|
|
1647
|
+
if payload["status"] != "completed":
|
|
1648
|
+
raise ValueError(f"contact run {run_id} is {payload['status']}, not completed")
|
|
1649
|
+
summary = ContactDiscoverySummary.model_validate(payload["summary"])
|
|
1650
|
+
paths = ContactDiscoveryArtifactExporter(settings.artifacts_dir).export(payload, summary)
|
|
1651
|
+
repository.set_artifacts(run_id, paths)
|
|
1652
|
+
console.print(f"Exported contact run [bold]{run_id}[/bold] to {Path(paths['json']).parent}")
|
|
1653
|
+
except (ContactRunNotFoundError, ValueError) as exc:
|
|
1654
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1655
|
+
raise typer.Exit(1) from exc
|
|
1656
|
+
finally:
|
|
1657
|
+
close_runtime(database, resources)
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
@contacts.command("enrich")
|
|
1661
|
+
def enrich_contacts(
|
|
1662
|
+
contact_discovery_run_id: str,
|
|
1663
|
+
email: Annotated[
|
|
1664
|
+
bool,
|
|
1665
|
+
typer.Option("--email/--no-email", help="Request Apollo email enrichment."),
|
|
1666
|
+
] = True,
|
|
1667
|
+
phone: Annotated[
|
|
1668
|
+
bool,
|
|
1669
|
+
typer.Option("--phone/--no-phone", help="Request Apollo phone enrichment."),
|
|
1670
|
+
] = True,
|
|
1671
|
+
refresh: Annotated[
|
|
1672
|
+
bool,
|
|
1673
|
+
typer.Option("--refresh", help="Ignore fresh Apollo memory and query again."),
|
|
1674
|
+
] = False,
|
|
1675
|
+
) -> None:
|
|
1676
|
+
"""Enrich accepted discovered contacts with Apollo email and phone data."""
|
|
1677
|
+
if not email and not phone:
|
|
1678
|
+
console.print("[bold red]Enable at least one of --email or --phone.[/bold red]")
|
|
1679
|
+
raise typer.Exit(2)
|
|
1680
|
+
database: Database | None = None
|
|
1681
|
+
resources: list[object] = []
|
|
1682
|
+
try:
|
|
1683
|
+
database, _, pipeline, resources = build_contact_enrichment_runtime(get_settings())
|
|
1684
|
+
result = pipeline.enrich(
|
|
1685
|
+
contact_discovery_run_id,
|
|
1686
|
+
options=ContactEnrichmentOptions(
|
|
1687
|
+
reveal_email=email,
|
|
1688
|
+
reveal_phone=phone,
|
|
1689
|
+
refresh=refresh,
|
|
1690
|
+
),
|
|
1691
|
+
progress=RichContactEnrichmentProgressReporter(),
|
|
1692
|
+
)
|
|
1693
|
+
except Exception as exc:
|
|
1694
|
+
console.print(f"[bold red]Contact enrichment failed:[/bold red] {exc}")
|
|
1695
|
+
raise typer.Exit(1) from exc
|
|
1696
|
+
finally:
|
|
1697
|
+
if database is not None:
|
|
1698
|
+
close_runtime(database, resources)
|
|
1699
|
+
|
|
1700
|
+
console.print(
|
|
1701
|
+
Panel(
|
|
1702
|
+
f"Run [bold]{result.run_id}[/bold]\n"
|
|
1703
|
+
f"Ready {result.summary.ready} | Review {result.summary.review} | "
|
|
1704
|
+
f"Blocked {result.summary.blocked}\n"
|
|
1705
|
+
f"Output: {result.artifact_paths['ready']}",
|
|
1706
|
+
title="Contact enrichment complete",
|
|
1707
|
+
border_style="bright_green",
|
|
1708
|
+
)
|
|
1709
|
+
)
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
@contacts.command("show-enrichment")
|
|
1713
|
+
def show_contact_enrichment(run_id: str) -> None:
|
|
1714
|
+
"""Show counts and artifacts for an Apollo contact enrichment run."""
|
|
1715
|
+
database: Database | None = None
|
|
1716
|
+
try:
|
|
1717
|
+
database, repository = build_contact_enrichment_repository(get_settings())
|
|
1718
|
+
payload = repository.get_run(run_id)
|
|
1719
|
+
summary = payload["summary"]
|
|
1720
|
+
console.print(
|
|
1721
|
+
Panel(
|
|
1722
|
+
f"Status: {payload['status']}\n"
|
|
1723
|
+
f"Contact discovery run: {payload['source_contact_run_id']}\n"
|
|
1724
|
+
f"Contacts: {summary.get('contacts_loaded', 0)} | "
|
|
1725
|
+
f"Memory: {summary.get('memory_reused', 0)} | "
|
|
1726
|
+
f"Apollo requests: {summary.get('apollo_requests', 0)}\n"
|
|
1727
|
+
f"Ready: {summary.get('ready', 0)} | "
|
|
1728
|
+
f"Review: {summary.get('review', 0)} | "
|
|
1729
|
+
f"Blocked: {summary.get('blocked', 0)}",
|
|
1730
|
+
title=f"Contact enrichment {run_id}",
|
|
1731
|
+
)
|
|
1732
|
+
)
|
|
1733
|
+
if payload["artifacts"]:
|
|
1734
|
+
console.print_json(json.dumps(payload["artifacts"], ensure_ascii=True))
|
|
1735
|
+
except (ContactEnrichmentRunNotFoundError, ValueError) as exc:
|
|
1736
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1737
|
+
raise typer.Exit(1) from exc
|
|
1738
|
+
finally:
|
|
1739
|
+
if database is not None:
|
|
1740
|
+
database.dispose()
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
@contacts.command("inspect-enrichment")
|
|
1744
|
+
def inspect_contact_enrichment(
|
|
1745
|
+
run_id: str,
|
|
1746
|
+
person: Annotated[str, typer.Option("--person")],
|
|
1747
|
+
) -> None:
|
|
1748
|
+
"""Inspect Apollo fields, trust checks, and trace for one enriched person."""
|
|
1749
|
+
database: Database | None = None
|
|
1750
|
+
try:
|
|
1751
|
+
database, repository = build_contact_enrichment_repository(get_settings())
|
|
1752
|
+
console.print_json(
|
|
1753
|
+
json.dumps(repository.inspect_contact(run_id, person), ensure_ascii=True)
|
|
1754
|
+
)
|
|
1755
|
+
except (ContactEnrichmentRunNotFoundError, LookupError) as exc:
|
|
1756
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1757
|
+
raise typer.Exit(1) from exc
|
|
1758
|
+
finally:
|
|
1759
|
+
if database is not None:
|
|
1760
|
+
database.dispose()
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
@contacts.command("export-enrichment")
|
|
1764
|
+
def export_contact_enrichment(run_id: str) -> None:
|
|
1765
|
+
"""Regenerate Apollo contact enrichment artifacts from the stored run."""
|
|
1766
|
+
settings = get_settings()
|
|
1767
|
+
database: Database | None = None
|
|
1768
|
+
try:
|
|
1769
|
+
database, repository = build_contact_enrichment_repository(settings)
|
|
1770
|
+
payload = repository.get_run(run_id)
|
|
1771
|
+
if payload["status"] != "completed":
|
|
1772
|
+
raise ValueError(f"contact enrichment run {run_id} is {payload['status']}, not completed")
|
|
1773
|
+
summary = ContactEnrichmentSummary.model_validate(payload["summary"])
|
|
1774
|
+
paths = ContactEnrichmentArtifactExporter(settings.artifacts_dir).export(payload, summary)
|
|
1775
|
+
repository.set_artifacts(run_id, paths)
|
|
1776
|
+
console.print(
|
|
1777
|
+
f"Exported contact enrichment [bold]{run_id}[/bold] to "
|
|
1778
|
+
f"{Path(paths['json']).parent}"
|
|
1779
|
+
)
|
|
1780
|
+
except (ContactEnrichmentRunNotFoundError, ValueError) as exc:
|
|
1781
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
1782
|
+
raise typer.Exit(1) from exc
|
|
1783
|
+
finally:
|
|
1784
|
+
if database is not None:
|
|
1785
|
+
database.dispose()
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
if __name__ == "__main__":
|
|
1789
|
+
app()
|