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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- 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
|