devrel-origin 0.2.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. devrel_origin/__init__.py +15 -0
  2. devrel_origin/cli/__init__.py +92 -0
  3. devrel_origin/cli/_common.py +243 -0
  4. devrel_origin/cli/analytics.py +28 -0
  5. devrel_origin/cli/argus.py +497 -0
  6. devrel_origin/cli/auth.py +227 -0
  7. devrel_origin/cli/config.py +108 -0
  8. devrel_origin/cli/content.py +259 -0
  9. devrel_origin/cli/cost.py +108 -0
  10. devrel_origin/cli/cro.py +298 -0
  11. devrel_origin/cli/deliverables.py +65 -0
  12. devrel_origin/cli/docs.py +91 -0
  13. devrel_origin/cli/doctor.py +178 -0
  14. devrel_origin/cli/experiment.py +29 -0
  15. devrel_origin/cli/growth.py +97 -0
  16. devrel_origin/cli/init.py +472 -0
  17. devrel_origin/cli/intel.py +27 -0
  18. devrel_origin/cli/kb.py +96 -0
  19. devrel_origin/cli/listen.py +31 -0
  20. devrel_origin/cli/marketing.py +66 -0
  21. devrel_origin/cli/migrate.py +45 -0
  22. devrel_origin/cli/run.py +46 -0
  23. devrel_origin/cli/sales.py +57 -0
  24. devrel_origin/cli/schedule.py +62 -0
  25. devrel_origin/cli/synthesize.py +28 -0
  26. devrel_origin/cli/triage.py +29 -0
  27. devrel_origin/cli/video.py +35 -0
  28. devrel_origin/core/__init__.py +58 -0
  29. devrel_origin/core/agent_config.py +75 -0
  30. devrel_origin/core/argus.py +964 -0
  31. devrel_origin/core/atlas.py +1450 -0
  32. devrel_origin/core/base.py +372 -0
  33. devrel_origin/core/cyra.py +563 -0
  34. devrel_origin/core/dex.py +708 -0
  35. devrel_origin/core/echo.py +614 -0
  36. devrel_origin/core/growth/__init__.py +27 -0
  37. devrel_origin/core/growth/recommendations.py +219 -0
  38. devrel_origin/core/growth/target_kinds.py +51 -0
  39. devrel_origin/core/iris.py +513 -0
  40. devrel_origin/core/kai.py +1367 -0
  41. devrel_origin/core/llm.py +542 -0
  42. devrel_origin/core/llm_backends.py +274 -0
  43. devrel_origin/core/mox.py +514 -0
  44. devrel_origin/core/nova.py +349 -0
  45. devrel_origin/core/pax.py +1205 -0
  46. devrel_origin/core/rex.py +532 -0
  47. devrel_origin/core/sage.py +486 -0
  48. devrel_origin/core/sentinel.py +385 -0
  49. devrel_origin/core/types.py +98 -0
  50. devrel_origin/core/video/__init__.py +22 -0
  51. devrel_origin/core/video/assembler.py +131 -0
  52. devrel_origin/core/video/browser_recorder.py +118 -0
  53. devrel_origin/core/video/desktop_recorder.py +254 -0
  54. devrel_origin/core/video/overlay_renderer.py +143 -0
  55. devrel_origin/core/video/script_parser.py +147 -0
  56. devrel_origin/core/video/tts_engine.py +82 -0
  57. devrel_origin/core/vox.py +268 -0
  58. devrel_origin/core/watchdog.py +321 -0
  59. devrel_origin/project/__init__.py +1 -0
  60. devrel_origin/project/config.py +75 -0
  61. devrel_origin/project/cost_sink.py +61 -0
  62. devrel_origin/project/init.py +104 -0
  63. devrel_origin/project/paths.py +75 -0
  64. devrel_origin/project/state.py +241 -0
  65. devrel_origin/project/templates/__init__.py +4 -0
  66. devrel_origin/project/templates/config.toml +24 -0
  67. devrel_origin/project/templates/devrel.gitignore +10 -0
  68. devrel_origin/project/templates/slop-blocklist.md +45 -0
  69. devrel_origin/project/templates/style.md +24 -0
  70. devrel_origin/project/templates/voice.md +29 -0
  71. devrel_origin/quality/__init__.py +66 -0
  72. devrel_origin/quality/editorial.py +357 -0
  73. devrel_origin/quality/persona.py +84 -0
  74. devrel_origin/quality/readability.py +148 -0
  75. devrel_origin/quality/slop.py +167 -0
  76. devrel_origin/quality/style.py +110 -0
  77. devrel_origin/quality/voice.py +15 -0
  78. devrel_origin/tools/__init__.py +9 -0
  79. devrel_origin/tools/analytics.py +304 -0
  80. devrel_origin/tools/api_client.py +393 -0
  81. devrel_origin/tools/apollo_client.py +305 -0
  82. devrel_origin/tools/code_validator.py +428 -0
  83. devrel_origin/tools/github_tools.py +297 -0
  84. devrel_origin/tools/instantly_client.py +412 -0
  85. devrel_origin/tools/kb_harvester.py +340 -0
  86. devrel_origin/tools/mcp_server.py +578 -0
  87. devrel_origin/tools/notifications.py +245 -0
  88. devrel_origin/tools/run_report.py +193 -0
  89. devrel_origin/tools/scheduler.py +231 -0
  90. devrel_origin/tools/search_tools.py +321 -0
  91. devrel_origin/tools/self_improve.py +168 -0
  92. devrel_origin/tools/sheets.py +236 -0
  93. devrel_origin-0.2.14.dist-info/METADATA +354 -0
  94. devrel_origin-0.2.14.dist-info/RECORD +98 -0
  95. devrel_origin-0.2.14.dist-info/WHEEL +5 -0
  96. devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
  97. devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
  98. devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,563 @@
1
+ """Cyra: CRO (conversion rate optimization) auditor.
2
+
3
+ Pulls funnel time-series from PostHog, identifies the step with the worst
4
+ week-over-week drop-off, and asks Sonnet for 3 ICE-scored A/B test
5
+ hypotheses per worst-step. Emits Recommendation rows to the shared
6
+ analytics_recommendations table for Mox to materialize as test variants.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ from dataclasses import asdict, dataclass, field
14
+ from pathlib import Path
15
+
16
+ from devrel_origin.core.base import strip_markdown_fences
17
+ from devrel_origin.core.growth import (
18
+ Pillar,
19
+ Recommendation,
20
+ TargetKind,
21
+ persist_recommendation,
22
+ )
23
+ from devrel_origin.core.llm import LLMClient
24
+ from devrel_origin.tools.api_client import PostHogClient
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class FunnelStep:
31
+ name: str
32
+ index: int
33
+ count: int
34
+ conversion_rate: float # share of users who reached this step from step 0
35
+
36
+ def to_dict(self) -> dict:
37
+ return asdict(self)
38
+
39
+ @classmethod
40
+ def from_dict(cls, d: dict) -> "FunnelStep":
41
+ return cls(**d)
42
+
43
+
44
+ @dataclass
45
+ class DropOff:
46
+ from_step: str
47
+ to_step: str
48
+ from_count: int
49
+ to_count: int
50
+ conversion_rate: float # share of from_count that progressed
51
+ pp_delta_vs_prior: float # percentage-point change WoW; negative = worse
52
+ sample_size: int # = from_count
53
+
54
+ @property
55
+ def absolute_drop(self) -> int:
56
+ return self.from_count - self.to_count
57
+
58
+ @property
59
+ def is_significant_deterioration(self) -> bool:
60
+ return self.pp_delta_vs_prior <= -0.05 # >=5pp worse than prior period
61
+
62
+
63
+ @dataclass
64
+ class Hypothesis:
65
+ title: str
66
+ rationale: str
67
+ impact: int # 1-10
68
+ confidence: int # 1-10
69
+ effort: int # 1-10 (lower = less effort)
70
+
71
+ @property
72
+ def ice_score(self) -> float:
73
+ return (self.impact * self.confidence) / max(self.effort, 1)
74
+
75
+ def to_dict(self) -> dict:
76
+ return {**asdict(self), "ice_score": self.ice_score}
77
+
78
+
79
+ @dataclass
80
+ class CroReport:
81
+ period_end: str
82
+ funnel_id: str
83
+ funnel: list[FunnelStep]
84
+ dropoffs: list[DropOff]
85
+ hypotheses_by_step: dict[str, list[Hypothesis]] = field(default_factory=dict)
86
+ recommendations: list[Recommendation] = field(default_factory=list)
87
+ sources_ok: bool = True
88
+
89
+
90
+ # PostHog system events to exclude from auto-detected funnels.
91
+ # Defense-in-depth: the $-prefix filter below already excludes these, but
92
+ # this set is kept in case PostHog introduces non-$-prefixed system events.
93
+ _SYSTEM_EVENTS = frozenset(
94
+ {
95
+ "$identify",
96
+ "$pageleave",
97
+ "$autocapture",
98
+ "$rageclick",
99
+ "$set",
100
+ "$create_alias",
101
+ "$opt_in",
102
+ "$exception",
103
+ }
104
+ )
105
+
106
+
107
+ _HYPOTHESIS_PROMPT = """You are a CRO analyst. Drop-off detected on a conversion funnel:
108
+
109
+ Step: {from_step} -> {to_step}
110
+ Sample size: {sample_size:,}
111
+ Current conversion: {current_rate:.1%}
112
+ Week-over-week change: {pp_delta:+.1%}
113
+
114
+ Page HTML (truncated to 4KB):
115
+
116
+ {page_html}
117
+
118
+ User-reported friction (from Sage):
119
+ {sage_friction}
120
+
121
+ Recurring themes (from Iris):
122
+ {iris_themes}
123
+
124
+ Generate exactly {n} A/B test hypotheses. Return JSON only:
125
+
126
+ {{
127
+ "hypotheses": [
128
+ {{
129
+ "title": "<short imperative, <=80 chars>",
130
+ "rationale": "<2 sentences: why this drop is happening + why this test should help>",
131
+ "impact": <1-10, expected lift if winner>,
132
+ "confidence": <1-10, certainty in the hypothesis>,
133
+ "effort": <1-10, dev work required (lower = less)>
134
+ }}
135
+ ]
136
+ }}
137
+
138
+ Score impact/confidence/effort honestly. False confidence skews ICE rankings.
139
+ Return ONLY the JSON, no markdown fences, no explanation."""
140
+
141
+
142
+ class Cyra:
143
+ """CRO auditor agent.
144
+
145
+ Inputs: PostHog (funnel time-series), optional Iris/Sage priors for
146
+ hypothesis ranking, optional `[growth].cro_funnel` override.
147
+
148
+ Outputs: Recommendation rows in `analytics_recommendations` (pillar=cro),
149
+ Mox-ready briefs at `.devrel/deliverables/cro-brief-*.md`.
150
+ """
151
+
152
+ def __init__(
153
+ self,
154
+ *,
155
+ posthog_client: PostHogClient,
156
+ llm_client: LLMClient,
157
+ db_path: Path,
158
+ funnel_override: list[str] | None = None,
159
+ funnel_id: str = "default",
160
+ min_sample_size: int = 500,
161
+ hypothesis_count: int = 3,
162
+ ):
163
+ self.posthog = posthog_client
164
+ self.llm = llm_client
165
+ self.db_path = db_path
166
+ self.funnel_override = funnel_override or []
167
+ self.funnel_id = funnel_id
168
+ self.min_sample_size = min_sample_size
169
+ self.hypothesis_count = hypothesis_count
170
+
171
+ async def _autodetect_funnel(self, days: int = 7) -> list[str]:
172
+ """Pick the highest-volume `$pageview` to custom_event chain.
173
+
174
+ Heuristic: first event is always `$pageview` (top of funnel for any
175
+ web product); subsequent events are the top non-system events by
176
+ volume in descending order. We cap the chain at 5 steps:
177
+ deeper funnels rarely have enough sample size at the tail.
178
+ """
179
+ if self.funnel_override:
180
+ return self.funnel_override
181
+
182
+ volumes = await self.posthog.event_volumes(days=days, limit=50)
183
+ custom = [
184
+ (name, count)
185
+ for name, count in volumes
186
+ if name not in _SYSTEM_EVENTS and not name.startswith("$")
187
+ ]
188
+ if not custom:
189
+ logger.warning("Cyra: no custom events found in PostHog; funnel auto-detect failed")
190
+ return []
191
+
192
+ return ["$pageview"] + [name for name, _ in custom[:4]]
193
+
194
+ async def _compute_dropoffs(
195
+ self,
196
+ *,
197
+ funnel: list[str],
198
+ days: int = 7,
199
+ ) -> list[DropOff]:
200
+ """Compute step-by-step drop-offs comparing current vs prior period.
201
+
202
+ Returns dropoffs sorted by `absolute_drop` desc (largest drop first).
203
+ WoW deterioration flagged when current pp - prior pp <= -0.05.
204
+ """
205
+ if len(funnel) < 2:
206
+ return []
207
+
208
+ current = await self.posthog.funnel_query(events=funnel, days=days)
209
+ prior = await self.posthog.funnel_query(events=funnel, days=days * 2)
210
+
211
+ dropoffs: list[DropOff] = []
212
+ for i in range(len(current) - 1):
213
+ from_step = current[i]
214
+ to_step = current[i + 1]
215
+ conv = (to_step["count"] / from_step["count"]) if from_step["count"] else 0.0
216
+
217
+ # Prior-period conversion rate for the same step
218
+ if len(prior) > i + 1 and prior[i]["count"]:
219
+ prior_conv = prior[i + 1]["count"] / prior[i]["count"]
220
+ else:
221
+ prior_conv = conv # no baseline -> pp_delta = 0
222
+
223
+ dropoffs.append(
224
+ DropOff(
225
+ from_step=from_step["name"],
226
+ to_step=to_step["name"],
227
+ from_count=from_step["count"],
228
+ to_count=to_step["count"],
229
+ conversion_rate=conv,
230
+ # round to 10dp to avoid IEEE 754 drift at the -0.05 significance boundary
231
+ pp_delta_vs_prior=round(conv - prior_conv, 10),
232
+ sample_size=from_step["count"],
233
+ )
234
+ )
235
+
236
+ return sorted(dropoffs, key=lambda d: d.absolute_drop, reverse=True)
237
+
238
+ async def _generate_hypotheses(
239
+ self,
240
+ *,
241
+ dropoff: DropOff,
242
+ page_html: str,
243
+ iris_themes: list[str] | None = None,
244
+ sage_friction: list[str] | None = None,
245
+ ) -> list[Hypothesis]:
246
+ """Ask Sonnet for `hypothesis_count` ICE-scored A/B hypotheses."""
247
+ prompt = _HYPOTHESIS_PROMPT.format(
248
+ from_step=dropoff.from_step,
249
+ to_step=dropoff.to_step,
250
+ sample_size=dropoff.sample_size,
251
+ current_rate=dropoff.conversion_rate,
252
+ pp_delta=dropoff.pp_delta_vs_prior,
253
+ page_html=page_html[:4000],
254
+ iris_themes="\n".join(f"- {t}" for t in (iris_themes or [])) or "(none)",
255
+ sage_friction="\n".join(f"- {f}" for f in (sage_friction or [])) or "(none)",
256
+ n=self.hypothesis_count,
257
+ )
258
+
259
+ text = await self.llm.generate(
260
+ system_prompt="You are a CRO analyst.",
261
+ user_prompt=prompt,
262
+ temperature=0.4,
263
+ max_tokens=1500,
264
+ )
265
+ # Strip any stray fences (defensive: the model is told not to use them)
266
+ text = strip_markdown_fences(text)
267
+ data = json.loads(text)
268
+
269
+ hyps = [
270
+ Hypothesis(
271
+ title=h["title"],
272
+ rationale=h["rationale"],
273
+ impact=int(h["impact"]),
274
+ confidence=int(h["confidence"]),
275
+ effort=int(h["effort"]),
276
+ )
277
+ for h in data["hypotheses"]
278
+ ]
279
+ return sorted(hyps, key=lambda h: h.ice_score, reverse=True)
280
+
281
+ async def _cohort_split(
282
+ self,
283
+ *,
284
+ funnel: list[str],
285
+ segments: list[tuple[str, str]],
286
+ days: int = 7,
287
+ ) -> dict[str, dict]:
288
+ """Per-segment conversion breakdown for the funnel.
289
+
290
+ `segments` is a list of (utm_source, device_type) pairs. Suppresses
291
+ segments below `self.min_sample_size`.
292
+ """
293
+ breakdown: dict[str, dict] = {}
294
+ for utm_source, device_type in segments:
295
+ # In a real implementation we'd add `properties` filters to the
296
+ # funnel query for utm_source and device_type. For Wave 1 we
297
+ # follow the contract: the test stubs return per-segment data.
298
+ steps = await self.posthog.funnel_query(events=funnel, days=days)
299
+ if not steps:
300
+ continue
301
+ sample = steps[0]["count"]
302
+ if sample < self.min_sample_size:
303
+ continue
304
+ conv = (steps[-1]["count"] / sample) if sample else 0.0
305
+ key = f"{utm_source}|{device_type}"
306
+ breakdown[key] = {
307
+ "sample_size": sample,
308
+ "conversion_rate": conv,
309
+ "final_count": steps[-1]["count"],
310
+ }
311
+ return breakdown
312
+
313
+ def _action_for_dropoff(self, dropoff: DropOff) -> str:
314
+ """Map a drop-off pattern to an action verb.
315
+
316
+ - significant deterioration (>=5pp WoW worse): 'retest'
317
+ - significant improvement (>=5pp better): 'double_down'
318
+ - low sample without trend or everything else: 'investigate'
319
+ """
320
+ if dropoff.is_significant_deterioration:
321
+ return "retest"
322
+ if dropoff.pp_delta_vs_prior >= 0.05:
323
+ return "double_down"
324
+ return "investigate"
325
+
326
+ def _db_writable(self) -> bool:
327
+ """True iff self.db_path points at a real SQLite file we can write to.
328
+
329
+ The Atlas Stage 5c fallback constructs Cyra with `Path("/dev/null")` when
330
+ no project_paths is available; recommendations and funnel metrics then
331
+ have nowhere to land. Detect that up-front so persist methods no-op
332
+ cleanly instead of FK-violating or corrupting /dev/null.
333
+ """
334
+ return self.db_path is not None and self.db_path.is_file()
335
+
336
+ def _persist_funnel_metrics(self, report: CroReport) -> None:
337
+ """Write one cro_funnel_metrics row per FunnelStep.
338
+
339
+ Without this, `devrel cro history` and `devrel cro diff` (which both
340
+ query cro_funnel_metrics) return empty rows even after Cyra has run.
341
+ INSERT OR REPLACE is intentional: re-running on the same period
342
+ replaces stale conversion-rate snapshots with the freshest read.
343
+ """
344
+ if not self._db_writable() or not report.funnel:
345
+ return
346
+ import sqlite3
347
+
348
+ with sqlite3.connect(self.db_path) as conn:
349
+ conn.executemany(
350
+ "INSERT OR REPLACE INTO cro_funnel_metrics "
351
+ "(funnel_id, step_index, period_end, conversion_rate, sample_size, "
352
+ "segment_breakdown_json) VALUES (?, ?, ?, ?, ?, ?)",
353
+ [
354
+ (
355
+ report.funnel_id,
356
+ step.index,
357
+ report.period_end,
358
+ step.conversion_rate,
359
+ step.count,
360
+ "{}",
361
+ )
362
+ for step in report.funnel
363
+ ],
364
+ )
365
+ conn.commit()
366
+
367
+ def _persist(self, report: CroReport, *, report_id: int) -> None:
368
+ """Convert dropoffs + hypotheses into Recommendation rows and append to report.
369
+
370
+ No-ops when the analytics_reports anchor row is missing (report_id <= 0)
371
+ or the project state DB isn't writable: persist_recommendation would
372
+ otherwise FK-violate under PRAGMA foreign_keys=ON. Funnel metrics are
373
+ independent and persist via _persist_funnel_metrics regardless.
374
+ """
375
+ if report_id <= 0 or not self._db_writable():
376
+ logger.warning(
377
+ "Cyra: skipping recommendation persistence (report_id=%d, db_writable=%s)",
378
+ report_id,
379
+ self._db_writable(),
380
+ )
381
+ return
382
+ for d in report.dropoffs:
383
+ action = self._action_for_dropoff(d)
384
+ hypotheses = report.hypotheses_by_step.get(d.to_step, [])
385
+ # Encode hypotheses into source_ids (denormalized; cheap)
386
+ source_ids = [json.dumps(h.to_dict()) for h in hypotheses]
387
+ confidence = (
388
+ sum(h.confidence for h in hypotheses) / (10 * len(hypotheses))
389
+ if hypotheses
390
+ else 0.5
391
+ )
392
+ rec = Recommendation(
393
+ pillar=Pillar.CRO,
394
+ action=action,
395
+ target=d.to_step,
396
+ target_kind=TargetKind.FUNNEL_STEP,
397
+ confidence=confidence,
398
+ source_ids=source_ids,
399
+ first_seen_period=report.period_end,
400
+ )
401
+ persist_recommendation(self.db_path, report_id, rec)
402
+ report.recommendations.append(rec)
403
+
404
+ def _write_briefs(self, report: CroReport, deliverables_dir: Path) -> None:
405
+ """Write one .md brief per recommendation for Mox to pick up."""
406
+ deliverables_dir.mkdir(parents=True, exist_ok=True)
407
+ for rec in report.recommendations:
408
+ hypotheses = report.hypotheses_by_step.get(rec.target, [])
409
+ dropoff = next(
410
+ (d for d in report.dropoffs if d.to_step == rec.target),
411
+ None,
412
+ )
413
+
414
+ md_lines = [
415
+ f"# Cyra brief: {rec.action} `{rec.target}`",
416
+ "",
417
+ f"**Period:** {report.period_end}",
418
+ "**Pillar:** cro",
419
+ f"**Funnel:** {report.funnel_id}",
420
+ f"**Confidence:** {rec.confidence:.2f}",
421
+ "",
422
+ "## Drop-off context",
423
+ "",
424
+ ]
425
+ if dropoff:
426
+ md_lines.extend(
427
+ [
428
+ f"- From step: `{dropoff.from_step}` ({dropoff.from_count:,} users)",
429
+ f"- To step: `{dropoff.to_step}` ({dropoff.to_count:,} users)",
430
+ f"- Conversion rate: {dropoff.conversion_rate:.1%}",
431
+ f"- WoW delta: {dropoff.pp_delta_vs_prior:+.1%}",
432
+ "",
433
+ ]
434
+ )
435
+
436
+ if hypotheses:
437
+ md_lines.extend(
438
+ [
439
+ "## A/B hypotheses (ICE-ranked)",
440
+ "",
441
+ "| Title | Impact | Confidence | Effort | ICE | Rationale |",
442
+ "|-------|-------:|-----------:|-------:|----:|-----------|",
443
+ ]
444
+ )
445
+ for h in hypotheses:
446
+ md_lines.append(
447
+ f"| {h.title} | {h.impact} | {h.confidence} | {h.effort} | "
448
+ f"{h.ice_score:.1f} | {h.rationale} |"
449
+ )
450
+ md_lines.append("")
451
+
452
+ md_lines.extend(
453
+ [
454
+ "## Next steps",
455
+ "",
456
+ "- Mox: pick the highest-ICE hypothesis above and draft the test variant.",
457
+ "- Nova: validate sample-size + duration for the planned uplift target.",
458
+ "",
459
+ ]
460
+ )
461
+
462
+ slug = rec.target.replace("/", "-").replace(" ", "-")
463
+ path = deliverables_dir / f"cro-brief-{report.period_end}-{rec.action}-{slug}.md"
464
+ path.write_text("\n".join(md_lines))
465
+ logger.info(f"Cyra wrote brief: {path}")
466
+
467
+ async def execute(
468
+ self,
469
+ *,
470
+ period_end: str,
471
+ report_id: int,
472
+ page_html_by_url: dict[str, str] | None = None,
473
+ iris_themes: list[str] | None = None,
474
+ sage_friction: list[str] | None = None,
475
+ deliverables_dir: Path | None = None,
476
+ ) -> CroReport:
477
+ """Run a full Cyra cycle.
478
+
479
+ Stages: detect funnel, compute dropoffs, hypothesize worst step,
480
+ persist, write briefs. `page_html_by_url` keys by step name (e.g.,
481
+ 'signup_started'); when a step matches, we feed the corresponding
482
+ HTML into the hypothesis prompt.
483
+ """
484
+ page_html_by_url = page_html_by_url or {}
485
+ iris_themes = iris_themes or []
486
+ sage_friction = sage_friction or []
487
+
488
+ funnel = await self._autodetect_funnel(days=7)
489
+ if not funnel:
490
+ return CroReport(
491
+ period_end=period_end,
492
+ funnel_id=self.funnel_id,
493
+ funnel=[],
494
+ dropoffs=[],
495
+ sources_ok=False,
496
+ )
497
+
498
+ dropoffs = await self._compute_dropoffs(funnel=funnel, days=7)
499
+ if not dropoffs:
500
+ return CroReport(
501
+ period_end=period_end,
502
+ funnel_id=self.funnel_id,
503
+ funnel=[],
504
+ dropoffs=[],
505
+ sources_ok=True,
506
+ )
507
+
508
+ # Build FunnelStep view for the report.
509
+ # Look up dropoffs by from_step (dropoffs are sorted by absolute_drop, not funnel order).
510
+ dropoff_by_from = {d.from_step: d for d in dropoffs}
511
+ first_count = dropoff_by_from[funnel[0]].from_count if funnel[0] in dropoff_by_from else 0
512
+ funnel_steps: list[FunnelStep] = []
513
+ for i, ev in enumerate(funnel):
514
+ if i == 0:
515
+ funnel_steps.append(
516
+ FunnelStep(name=ev, index=0, count=first_count, conversion_rate=1.0)
517
+ )
518
+ else:
519
+ prev_event = funnel[i - 1]
520
+ d = dropoff_by_from.get(prev_event)
521
+ if d is None:
522
+ # No dropoff found for this transition (unexpected; fall back to 0)
523
+ count = 0
524
+ conversion_rate = 0.0
525
+ else:
526
+ count = d.to_count
527
+ conversion_rate = (count / first_count) if first_count else 0.0
528
+ funnel_steps.append(
529
+ FunnelStep(name=ev, index=i, count=count, conversion_rate=conversion_rate)
530
+ )
531
+
532
+ # Hypothesize the worst-deterioration step (or worst absolute drop if no deterioration)
533
+ worst = next((d for d in dropoffs if d.is_significant_deterioration), dropoffs[0])
534
+ hypotheses_by_step: dict[str, list[Hypothesis]] = {}
535
+ try:
536
+ page_html = page_html_by_url.get(worst.to_step, "")
537
+ hyps = await self._generate_hypotheses(
538
+ dropoff=worst,
539
+ page_html=page_html,
540
+ iris_themes=iris_themes,
541
+ sage_friction=sage_friction,
542
+ )
543
+ hypotheses_by_step[worst.to_step] = hyps
544
+ except Exception as e:
545
+ logger.warning(f"Cyra: hypothesis generation failed: {e}")
546
+
547
+ report = CroReport(
548
+ period_end=period_end,
549
+ funnel_id=self.funnel_id,
550
+ funnel=funnel_steps,
551
+ dropoffs=dropoffs,
552
+ hypotheses_by_step=hypotheses_by_step,
553
+ )
554
+
555
+ # Funnel metrics persist independently of report_id (no FK to
556
+ # analytics_reports), so devrel cro history/diff can render trends
557
+ # even when the recommendations path no-ops on a missing report row.
558
+ self._persist_funnel_metrics(report)
559
+ self._persist(report, report_id=report_id)
560
+ if deliverables_dir is not None:
561
+ self._write_briefs(report, deliverables_dir)
562
+
563
+ return report