celltype-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.
- celltype_cli-0.1.0.dist-info/METADATA +267 -0
- celltype_cli-0.1.0.dist-info/RECORD +89 -0
- celltype_cli-0.1.0.dist-info/WHEEL +4 -0
- celltype_cli-0.1.0.dist-info/entry_points.txt +2 -0
- celltype_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ct/__init__.py +3 -0
- ct/agent/__init__.py +0 -0
- ct/agent/case_studies.py +426 -0
- ct/agent/config.py +523 -0
- ct/agent/doctor.py +544 -0
- ct/agent/knowledge.py +523 -0
- ct/agent/loop.py +99 -0
- ct/agent/mcp_server.py +478 -0
- ct/agent/orchestrator.py +733 -0
- ct/agent/runner.py +656 -0
- ct/agent/sandbox.py +481 -0
- ct/agent/session.py +145 -0
- ct/agent/system_prompt.py +186 -0
- ct/agent/trace_store.py +228 -0
- ct/agent/trajectory.py +169 -0
- ct/agent/types.py +182 -0
- ct/agent/workflows.py +462 -0
- ct/api/__init__.py +1 -0
- ct/api/app.py +211 -0
- ct/api/config.py +120 -0
- ct/api/engine.py +124 -0
- ct/cli.py +1448 -0
- ct/data/__init__.py +0 -0
- ct/data/compute_providers.json +59 -0
- ct/data/cro_database.json +395 -0
- ct/data/downloader.py +238 -0
- ct/data/loaders.py +252 -0
- ct/kb/__init__.py +5 -0
- ct/kb/benchmarks.py +147 -0
- ct/kb/governance.py +106 -0
- ct/kb/ingest.py +415 -0
- ct/kb/reasoning.py +129 -0
- ct/kb/schema_monitor.py +162 -0
- ct/kb/substrate.py +387 -0
- ct/models/__init__.py +0 -0
- ct/models/llm.py +370 -0
- ct/tools/__init__.py +195 -0
- ct/tools/_compound_resolver.py +297 -0
- ct/tools/biomarker.py +368 -0
- ct/tools/cellxgene.py +282 -0
- ct/tools/chemistry.py +1371 -0
- ct/tools/claude.py +390 -0
- ct/tools/clinical.py +1153 -0
- ct/tools/clue.py +249 -0
- ct/tools/code.py +1069 -0
- ct/tools/combination.py +397 -0
- ct/tools/compute.py +402 -0
- ct/tools/cro.py +413 -0
- ct/tools/data_api.py +2114 -0
- ct/tools/design.py +295 -0
- ct/tools/dna.py +575 -0
- ct/tools/experiment.py +604 -0
- ct/tools/expression.py +655 -0
- ct/tools/files.py +957 -0
- ct/tools/genomics.py +1387 -0
- ct/tools/http_client.py +146 -0
- ct/tools/imaging.py +319 -0
- ct/tools/intel.py +223 -0
- ct/tools/literature.py +743 -0
- ct/tools/network.py +422 -0
- ct/tools/notification.py +111 -0
- ct/tools/omics.py +3330 -0
- ct/tools/ops.py +1230 -0
- ct/tools/parity.py +649 -0
- ct/tools/pk.py +245 -0
- ct/tools/protein.py +678 -0
- ct/tools/regulatory.py +643 -0
- ct/tools/remote_data.py +179 -0
- ct/tools/report.py +181 -0
- ct/tools/repurposing.py +376 -0
- ct/tools/safety.py +1280 -0
- ct/tools/shell.py +178 -0
- ct/tools/singlecell.py +533 -0
- ct/tools/statistics.py +552 -0
- ct/tools/structure.py +882 -0
- ct/tools/target.py +901 -0
- ct/tools/translational.py +123 -0
- ct/tools/viability.py +218 -0
- ct/ui/__init__.py +0 -0
- ct/ui/markdown.py +31 -0
- ct/ui/status.py +258 -0
- ct/ui/suggestions.py +567 -0
- ct/ui/terminal.py +1456 -0
- ct/ui/traces.py +112 -0
ct/tools/cro.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CRO (Contract Research Organization) tools: search, match, compare, and contact CROs.
|
|
3
|
+
|
|
4
|
+
PLACEHOLDER IMPLEMENTATION: All CRO data comes from a static JSON file bundled with ct.
|
|
5
|
+
This is not a live database — CRO listings, pricing, and turnaround times may be
|
|
6
|
+
outdated or incomplete. A real implementation would integrate with CRO directories
|
|
7
|
+
or vendor APIs. Treat results as illustrative, not authoritative.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from ct.tools import registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Module-level cache for CRO database
|
|
16
|
+
_cro_db_cache = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_cro_db() -> list[dict]:
|
|
20
|
+
"""Load and cache the CRO database from JSON."""
|
|
21
|
+
global _cro_db_cache
|
|
22
|
+
if _cro_db_cache is not None:
|
|
23
|
+
return _cro_db_cache
|
|
24
|
+
db_path = Path(__file__).parent.parent / "data" / "cro_database.json"
|
|
25
|
+
with open(db_path) as f:
|
|
26
|
+
_cro_db_cache = json.load(f)
|
|
27
|
+
return _cro_db_cache
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _score_cro(cro: dict, assay_type: str, target: str | None,
|
|
31
|
+
compound: str | None, species: str, scale: str) -> float:
|
|
32
|
+
"""Score a CRO for a specific experiment.
|
|
33
|
+
|
|
34
|
+
Weights:
|
|
35
|
+
- service match: 0.4
|
|
36
|
+
- capability match: 0.3
|
|
37
|
+
- therapeutic area: 0.2
|
|
38
|
+
- specialty match: 0.1
|
|
39
|
+
"""
|
|
40
|
+
score = 0.0
|
|
41
|
+
|
|
42
|
+
# Service category match (0.4)
|
|
43
|
+
service_cats = [s["category"] for s in cro.get("services", [])]
|
|
44
|
+
if assay_type in service_cats:
|
|
45
|
+
score += 0.4
|
|
46
|
+
else:
|
|
47
|
+
# Partial match: check if assay_type words appear in any category
|
|
48
|
+
assay_words = set(assay_type.lower().replace("_", " ").split())
|
|
49
|
+
for cat in service_cats:
|
|
50
|
+
cat_words = set(cat.lower().replace("_", " ").split())
|
|
51
|
+
if assay_words & cat_words:
|
|
52
|
+
score += 0.2
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
# Capability match (0.3)
|
|
56
|
+
capabilities_text = " ".join(cro.get("capabilities", [])).lower()
|
|
57
|
+
keywords = []
|
|
58
|
+
if assay_type:
|
|
59
|
+
keywords.extend(assay_type.lower().replace("_", " ").split())
|
|
60
|
+
if target:
|
|
61
|
+
keywords.append(target.lower())
|
|
62
|
+
if compound:
|
|
63
|
+
keywords.append(compound.lower())
|
|
64
|
+
# Add TPD-relevant keywords
|
|
65
|
+
tpd_keywords = ["degradation", "protac", "molecular glue", "tpd",
|
|
66
|
+
"ubiquitin", "e3 ligase", "ternary", "neo-substrate"]
|
|
67
|
+
for kw in keywords:
|
|
68
|
+
if kw in capabilities_text:
|
|
69
|
+
score += 0.15
|
|
70
|
+
break
|
|
71
|
+
for kw in tpd_keywords:
|
|
72
|
+
if kw in capabilities_text:
|
|
73
|
+
score += 0.15
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
# Therapeutic area match (0.2) — oncology is default for TPD
|
|
77
|
+
therapeutic_areas = [t.lower() for t in cro.get("therapeutic_areas", [])]
|
|
78
|
+
if "oncology" in therapeutic_areas:
|
|
79
|
+
score += 0.2
|
|
80
|
+
|
|
81
|
+
# Specialty match (0.1)
|
|
82
|
+
specialties_text = " ".join(cro.get("specialties", [])).lower()
|
|
83
|
+
specialty_keywords = keywords + ["degradation", "tpd", "protac", "molecular glue"]
|
|
84
|
+
for kw in specialty_keywords:
|
|
85
|
+
if kw in specialties_text:
|
|
86
|
+
score += 0.1
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
# Scale bonus: large CROs score slightly higher for large-scale work
|
|
90
|
+
if scale == "large" and cro.get("size") == "large":
|
|
91
|
+
score += 0.05
|
|
92
|
+
elif scale == "small" and cro.get("size") in ("small", "medium"):
|
|
93
|
+
score += 0.05
|
|
94
|
+
|
|
95
|
+
return round(min(score, 1.0), 3)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@registry.register(
|
|
99
|
+
name="cro.search",
|
|
100
|
+
description="Search built-in CRO directory by keyword, service type, or therapeutic area",
|
|
101
|
+
category="cro",
|
|
102
|
+
parameters={
|
|
103
|
+
"query": "Free-text search across CRO names, capabilities, and specialties",
|
|
104
|
+
"service_type": "Filter by service category (e.g. cell_based_assay, structural_biology)",
|
|
105
|
+
"therapeutic_area": "Filter by therapeutic area (e.g. oncology, neuroscience)",
|
|
106
|
+
},
|
|
107
|
+
usage_guide="You need to find CROs that offer a specific service or work in a therapeutic area. Use for initial CRO discovery before match_experiment.",
|
|
108
|
+
)
|
|
109
|
+
def search(query: str, service_type: str = None,
|
|
110
|
+
therapeutic_area: str = None, **kwargs) -> dict:
|
|
111
|
+
"""Full-text search across CRO database with optional filtering."""
|
|
112
|
+
db = _load_cro_db()
|
|
113
|
+
query_lower = query.lower()
|
|
114
|
+
results = []
|
|
115
|
+
|
|
116
|
+
for cro in db:
|
|
117
|
+
# Build searchable text
|
|
118
|
+
searchable = " ".join([
|
|
119
|
+
cro["name"],
|
|
120
|
+
" ".join(cro.get("capabilities", [])),
|
|
121
|
+
" ".join(cro.get("specialties", [])),
|
|
122
|
+
" ".join(cro.get("therapeutic_areas", [])),
|
|
123
|
+
]).lower()
|
|
124
|
+
|
|
125
|
+
if query_lower not in searchable:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Filter by service_type
|
|
129
|
+
if service_type:
|
|
130
|
+
service_cats = [s["category"] for s in cro.get("services", [])]
|
|
131
|
+
if service_type not in service_cats:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Filter by therapeutic_area
|
|
135
|
+
if therapeutic_area:
|
|
136
|
+
areas = [t.lower() for t in cro.get("therapeutic_areas", [])]
|
|
137
|
+
if therapeutic_area.lower() not in areas:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
results.append({
|
|
141
|
+
"id": cro["id"],
|
|
142
|
+
"name": cro["name"],
|
|
143
|
+
"headquarters": cro["headquarters"],
|
|
144
|
+
"size": cro["size"],
|
|
145
|
+
"capabilities": cro.get("capabilities", []),
|
|
146
|
+
"specialties": cro.get("specialties", []),
|
|
147
|
+
"services": [s["category"] for s in cro.get("services", [])],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"summary": f"[PLACEHOLDER] CRO search for '{query}': {len(results)} matches from static directory (not live data)",
|
|
152
|
+
"placeholder": True,
|
|
153
|
+
"query": query,
|
|
154
|
+
"filters": {"service_type": service_type, "therapeutic_area": therapeutic_area},
|
|
155
|
+
"results": results,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@registry.register(
|
|
160
|
+
name="cro.match_experiment",
|
|
161
|
+
description="Rank CROs from built-in directory by fit for a specific experiment type, target, and compound",
|
|
162
|
+
category="cro",
|
|
163
|
+
parameters={
|
|
164
|
+
"assay_type": "Experiment/assay type (e.g. cell_based_assay, structural_biology, in_vivo_efficacy)",
|
|
165
|
+
"target": "Target protein or gene (optional)",
|
|
166
|
+
"compound": "Compound name or class (optional)",
|
|
167
|
+
"species": "Species for the experiment (default: human)",
|
|
168
|
+
"scale": "Scale: small, medium, large (default: small)",
|
|
169
|
+
},
|
|
170
|
+
usage_guide="You have a specific experiment in mind and need to find the best-fit CRO. Run after experiment.design_assay to match the assay to capable vendors.",
|
|
171
|
+
)
|
|
172
|
+
def match_experiment(assay_type: str, target: str = None,
|
|
173
|
+
compound: str = None, species: str = "human",
|
|
174
|
+
scale: str = "small", **kwargs) -> dict:
|
|
175
|
+
"""Score and rank all CROs for a specific experiment."""
|
|
176
|
+
db = _load_cro_db()
|
|
177
|
+
scored = []
|
|
178
|
+
|
|
179
|
+
for cro in db:
|
|
180
|
+
score = _score_cro(cro, assay_type, target, compound, species, scale)
|
|
181
|
+
if score > 0:
|
|
182
|
+
# Find matching service for pricing/turnaround
|
|
183
|
+
matching_service = None
|
|
184
|
+
for svc in cro.get("services", []):
|
|
185
|
+
if svc["category"] == assay_type:
|
|
186
|
+
matching_service = svc
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
entry = {
|
|
190
|
+
"id": cro["id"],
|
|
191
|
+
"name": cro["name"],
|
|
192
|
+
"score": score,
|
|
193
|
+
"headquarters": cro["headquarters"],
|
|
194
|
+
"size": cro["size"],
|
|
195
|
+
"relevant_capabilities": [
|
|
196
|
+
c for c in cro.get("capabilities", [])
|
|
197
|
+
if any(kw in c.lower() for kw in [
|
|
198
|
+
assay_type.lower().replace("_", " "),
|
|
199
|
+
"degradation", "protac", "tpd", "glue",
|
|
200
|
+
] + ([target.lower()] if target else []))
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
if matching_service:
|
|
204
|
+
entry["turnaround_days"] = matching_service["turnaround_days"]
|
|
205
|
+
entry["price_range"] = matching_service["price_range"]
|
|
206
|
+
|
|
207
|
+
scored.append(entry)
|
|
208
|
+
|
|
209
|
+
scored.sort(key=lambda x: x["score"], reverse=True)
|
|
210
|
+
|
|
211
|
+
top_names = ", ".join(s["name"] for s in scored[:3]) if scored else "none"
|
|
212
|
+
return {
|
|
213
|
+
"summary": (
|
|
214
|
+
f"[PLACEHOLDER] CRO matching for {assay_type}"
|
|
215
|
+
+ (f" (target={target})" if target else "")
|
|
216
|
+
+ (f" (compound={compound})" if compound else "")
|
|
217
|
+
+ f": {len(scored)} CROs scored from static directory (not live data), top matches: {top_names}"
|
|
218
|
+
),
|
|
219
|
+
"placeholder": True,
|
|
220
|
+
"assay_type": assay_type,
|
|
221
|
+
"target": target,
|
|
222
|
+
"compound": compound,
|
|
223
|
+
"species": species,
|
|
224
|
+
"scale": scale,
|
|
225
|
+
"ranked_cros": scored,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@registry.register(
|
|
230
|
+
name="cro.compare",
|
|
231
|
+
description="Side-by-side comparison of selected CROs from built-in directory on services, pricing, and capabilities",
|
|
232
|
+
category="cro",
|
|
233
|
+
parameters={
|
|
234
|
+
"cro_ids": "List of CRO IDs to compare (e.g. ['reaction-biology', 'promega'])",
|
|
235
|
+
},
|
|
236
|
+
usage_guide="You have shortlisted CROs and need to compare them on services, pricing, and capabilities before making a decision.",
|
|
237
|
+
)
|
|
238
|
+
def compare(cro_ids: list[str], **kwargs) -> dict:
|
|
239
|
+
"""Compare selected CROs side-by-side."""
|
|
240
|
+
db = _load_cro_db()
|
|
241
|
+
id_to_cro = {cro["id"]: cro for cro in db}
|
|
242
|
+
|
|
243
|
+
comparisons = []
|
|
244
|
+
not_found = []
|
|
245
|
+
|
|
246
|
+
for cro_id in cro_ids:
|
|
247
|
+
cro = id_to_cro.get(cro_id)
|
|
248
|
+
if not cro:
|
|
249
|
+
not_found.append(cro_id)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
services_summary = {
|
|
253
|
+
s["category"]: {
|
|
254
|
+
"turnaround_days": s["turnaround_days"],
|
|
255
|
+
"price_range": s["price_range"],
|
|
256
|
+
}
|
|
257
|
+
for s in cro.get("services", [])
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
comparisons.append({
|
|
261
|
+
"id": cro["id"],
|
|
262
|
+
"name": cro["name"],
|
|
263
|
+
"headquarters": cro["headquarters"],
|
|
264
|
+
"size": cro["size"],
|
|
265
|
+
"website": cro["website"],
|
|
266
|
+
"services": services_summary,
|
|
267
|
+
"capabilities": cro.get("capabilities", []),
|
|
268
|
+
"specialties": cro.get("specialties", []),
|
|
269
|
+
"therapeutic_areas": cro.get("therapeutic_areas", []),
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
# Build comparison matrix for common service categories
|
|
273
|
+
all_categories = set()
|
|
274
|
+
for comp in comparisons:
|
|
275
|
+
all_categories.update(comp["services"].keys())
|
|
276
|
+
|
|
277
|
+
names = [c["name"] for c in comparisons]
|
|
278
|
+
summary = f"[PLACEHOLDER] Comparison of {len(comparisons)} CROs from static directory (not live data): {', '.join(names)}"
|
|
279
|
+
if not_found:
|
|
280
|
+
summary += f" (not found: {', '.join(not_found)})"
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"summary": summary,
|
|
284
|
+
"placeholder": True,
|
|
285
|
+
"comparisons": comparisons,
|
|
286
|
+
"service_categories": sorted(all_categories),
|
|
287
|
+
"not_found": not_found,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@registry.register(
|
|
292
|
+
name="cro.draft_inquiry",
|
|
293
|
+
description="Draft a professional inquiry email to a CRO (from built-in directory) for a specific experiment",
|
|
294
|
+
category="cro",
|
|
295
|
+
parameters={
|
|
296
|
+
"cro_id": "CRO identifier (e.g. 'reaction-biology')",
|
|
297
|
+
"experiment_description": "Description of the experiment / assay needed",
|
|
298
|
+
"compound": "Compound name or identifier (optional)",
|
|
299
|
+
"target": "Target protein or gene (optional)",
|
|
300
|
+
"timeline": "Desired timeline (optional, e.g. '3 months')",
|
|
301
|
+
},
|
|
302
|
+
usage_guide="You've selected a CRO and need to draft a professional inquiry email. Run after cro.match_experiment to contact the top-ranked CRO.",
|
|
303
|
+
)
|
|
304
|
+
def draft_inquiry(cro_id: str, experiment_description: str,
|
|
305
|
+
compound: str = None, target: str = None,
|
|
306
|
+
timeline: str = None, **kwargs) -> dict:
|
|
307
|
+
"""Generate a professional inquiry email to a CRO."""
|
|
308
|
+
db = _load_cro_db()
|
|
309
|
+
id_to_cro = {cro["id"]: cro for cro in db}
|
|
310
|
+
|
|
311
|
+
cro = id_to_cro.get(cro_id)
|
|
312
|
+
if not cro:
|
|
313
|
+
return {"error": f"CRO '{cro_id}' not found in database", "summary": f"CRO '{cro_id}' not found in database"}
|
|
314
|
+
subject = f"Inquiry: {experiment_description[:60]}"
|
|
315
|
+
if compound:
|
|
316
|
+
subject = f"Inquiry: {experiment_description[:40]} — {compound}"
|
|
317
|
+
|
|
318
|
+
body_parts = [
|
|
319
|
+
f"Dear {cro['name']} Team,",
|
|
320
|
+
"",
|
|
321
|
+
f"I am writing to inquire about your services for the following study:",
|
|
322
|
+
"",
|
|
323
|
+
f"**Experiment:** {experiment_description}",
|
|
324
|
+
]
|
|
325
|
+
if target:
|
|
326
|
+
body_parts.append(f"**Target:** {target}")
|
|
327
|
+
if compound:
|
|
328
|
+
body_parts.append(f"**Compound:** {compound}")
|
|
329
|
+
if timeline:
|
|
330
|
+
body_parts.append(f"**Desired Timeline:** {timeline}")
|
|
331
|
+
|
|
332
|
+
body_parts.extend([
|
|
333
|
+
"",
|
|
334
|
+
"We are interested in understanding:",
|
|
335
|
+
"1. Your capacity and availability for this type of study",
|
|
336
|
+
"2. Estimated timeline and pricing",
|
|
337
|
+
"3. Any relevant experience with similar projects (particularly in targeted protein degradation)",
|
|
338
|
+
"4. Required compound quantity and format",
|
|
339
|
+
"",
|
|
340
|
+
"Could you please provide a preliminary quote and study outline? We would also welcome a call to discuss the details.",
|
|
341
|
+
"",
|
|
342
|
+
"Thank you for your time.",
|
|
343
|
+
"",
|
|
344
|
+
"Best regards",
|
|
345
|
+
])
|
|
346
|
+
|
|
347
|
+
body = "\n".join(body_parts)
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
"summary": f"[PLACEHOLDER] Drafted inquiry to {cro['name']} ({cro['contact_email']}) re: {experiment_description[:50]} — verify CRO contact details before sending",
|
|
351
|
+
"placeholder": True,
|
|
352
|
+
"cro_id": cro_id,
|
|
353
|
+
"cro_name": cro["name"],
|
|
354
|
+
"to_email": cro["contact_email"],
|
|
355
|
+
"subject": subject,
|
|
356
|
+
"body": body,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@registry.register(
|
|
361
|
+
name="cro.send_inquiry",
|
|
362
|
+
description="Send a drafted inquiry email to a CRO (dry_run by default)",
|
|
363
|
+
category="cro",
|
|
364
|
+
parameters={
|
|
365
|
+
"cro_id": "CRO identifier",
|
|
366
|
+
"subject": "Email subject line",
|
|
367
|
+
"body": "Email body text",
|
|
368
|
+
"dry_run": "If True (default), simulate sending without actually delivering",
|
|
369
|
+
},
|
|
370
|
+
usage_guide="You have a finalized inquiry email and want to send it to the CRO. Always runs in dry_run mode unless explicitly overridden.",
|
|
371
|
+
)
|
|
372
|
+
def send_inquiry(cro_id: str, subject: str, body: str,
|
|
373
|
+
dry_run: bool = True, **kwargs) -> dict:
|
|
374
|
+
"""Send an inquiry email to a CRO."""
|
|
375
|
+
db = _load_cro_db()
|
|
376
|
+
id_to_cro = {cro["id"]: cro for cro in db}
|
|
377
|
+
|
|
378
|
+
cro = id_to_cro.get(cro_id)
|
|
379
|
+
if not cro:
|
|
380
|
+
return {"error": f"CRO '{cro_id}' not found in database", "summary": f"CRO '{cro_id}' not found in database"}
|
|
381
|
+
to_email = cro["contact_email"]
|
|
382
|
+
|
|
383
|
+
if dry_run:
|
|
384
|
+
return {
|
|
385
|
+
"summary": f"[DRY RUN] Would send email to {cro['name']} ({to_email}): {subject}",
|
|
386
|
+
"dry_run": True,
|
|
387
|
+
"to_email": to_email,
|
|
388
|
+
"cro_name": cro["name"],
|
|
389
|
+
"subject": subject,
|
|
390
|
+
"body": body,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
# Attempt to send via notification module
|
|
394
|
+
try:
|
|
395
|
+
from ct.tools.notification import send_email
|
|
396
|
+
result = send_email(to=to_email, subject=subject, body=body)
|
|
397
|
+
return {
|
|
398
|
+
"summary": f"Sent inquiry to {cro['name']} ({to_email}): {subject}",
|
|
399
|
+
"dry_run": False,
|
|
400
|
+
"to_email": to_email,
|
|
401
|
+
"cro_name": cro["name"],
|
|
402
|
+
"subject": subject,
|
|
403
|
+
"send_result": result,
|
|
404
|
+
}
|
|
405
|
+
except ImportError:
|
|
406
|
+
return {
|
|
407
|
+
"summary": f"[FAILED] notification module not available — email not sent to {to_email}",
|
|
408
|
+
"dry_run": False,
|
|
409
|
+
"error": "notification.send_email not available. Install notification module or use dry_run=True.",
|
|
410
|
+
"to_email": to_email,
|
|
411
|
+
"subject": subject,
|
|
412
|
+
"body": body,
|
|
413
|
+
}
|