Skip to content

Commit e190261

Browse files
committed
Prepare Iterglow A/B testing for the hub, switched off until keys are set
Vendors the Iterglow SDK 0.3.0, adds data/experiments.json (enabled: false) and assets/js/experiment.js, and lets Pseudo's greeting be the first experiment. While disabled the SDK is not loaded and no request leaves the page.
1 parent 0761e6f commit e190261

9 files changed

Lines changed: 455 additions & 4 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ PR을 올리기 전에 `python3 scripts/check_data.py`를 실행해 주세요.
2121
| `data/growth.json` | 기수별 누적 러너 수. 프로젝트 시트를 xlsx로 내려받아 `python scripts/runners_from_sheet.py 파일.xlsx > data/growth.json`으로 만듭니다. 이름은 파일에 남지 않습니다. |
2222
| `data/events.json` | Data Forum 같은 행사를 추가할 때. 다가오는 행사에는 `tagline`, `time`, `venue`, `address`, `signupStart`·`signupEnd`(신청 기간), `capacity`(정원), `code`(추천 코드 안내), `timetable`, `poster`·`timetableImage`(`assets/img/`의 이미지)를 적을 수 있습니다. 신청 기간에 따라 첫 화면 띠와 카드의 문구가 ‘신청 예정 · 받는 중 · 마감’으로 바뀝니다. 행사 전에는 `date`·`time`으로 캘린더 추가 링크(Google, .ics)를, `address`(없으면 `venue`)로 네이버 지도·카카오맵 링크를 카드에 만듭니다. `time`은 `13:30–17:30`처럼 한국 시간으로 적습니다. 행사 다음 날부터 6주 동안은 큰 카드가 ‘지난 행사’로 남고, `participants`(참가 인원)와 `recap`(`{"label": "후기 보기", "url": "https://..."}`)을 넣으면 카드에 함께 보입니다. |
2323
| `data/topics.json` | 자료실의 주제와 자료를 고칠 때. 주제마다 `shelf`(`causal` 인과추론, `exp` 온라인 실험), `name`, `about`, 검색어 `keys`, 자료 `items`를 적습니다. 자료는 `kind`(`video`·`read`·`code`·`slides`·`case`), `repo`(`repos.json`의 레포), `title`, `url`이고, 영상에 딸린 노트북은 `notebook`, 발표 자료 PDF는 `slides`로 붙입니다. 연재 글은 `articles.json`에 `topics`(주제 id 목록)를 적으면 공개일부터 그 주제에 함께 나옵니다. |
24+
| `data/experiments.json` | 허브에서 Iterglow(가짜연구소 실험 플랫폼)로 A/B 테스트를 돌릴 때. 기본은 꺼져 있습니다(`"enabled": false`). 켜려면 Iterglow 프로젝트의 공개 SDK 키(`pk_…`)를 `apiKey`에, 관리자가 만든 sdk_v1 실험의 `experimentId`와 연결된 flag의 `flagKey`를 실험마다 적습니다. 서버 키나 계정 세션은 절대 넣지 않습니다. `goals`는 허브가 이미 보내는 이벤트 이름을 Iterglow 지표 이름에 잇습니다. 켜져 있는 동안 SDK가 방문자 브라우저에 익명 ID(`experibase_uid`)를 만들고, 그 ID와 페이지 경로, 기기 종류를 Iterglow로 보냅니다. 첫 실험은 `pseudo-greeting`(Pseudo가 먼저 인사하기)이고, 코드는 `assets/js/experiment.js`에 있습니다. |
2425
| `data/pseudo.json` | Pseudo가 용어를 설명할 때 쓰는 한두 문장 풀이. 키는 `glossary.json`의 용어 id(`term-…`)입니다. 풀이가 없는 용어는 원어와 웹북 장만 알려 줍니다. 해요체로, 예시를 하나 넣어 짧게 씁니다. |
2526
| `assets/js/helper.js` | Pseudo(자료실 마스코트)의 답과 추천 질문, 마스코트가 먼저 거는 말(`LINES`: 페이지마다 첫 인사와 특정 섹션·스크롤 위치에서 한 번씩, 방문당 한 번, 닫으면 그 방문 동안 조용히)을 고칠 때. Pseudo는 `topics.json`의 이름·`keys`와 `glossary.json`의 용어로 답을 찾으므로, 잘 못 찾는 질문이 있으면 먼저 해당 주제의 `keys`를 늘려 보세요. 질문 내용은 `helper_ask` 이벤트(앞 100자)로만 남고 저장하지 않습니다. |
2627
| `data/glossary.json` | 직접 고치지 않습니다. 배포할 때 `scripts/build_glossary.py`가 웹북 용어집 페이지를 읽어 새로 만듭니다. 용어를 고치려면 Causal-Inference-with-Python의 `TRANSLATION_GUIDE.md` 용어표를 고치세요. 웹북이 다시 배포된 다음 허브 배포 때 반영됩니다. |

‎article.html‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<html lang="ko">
33
<head>
44
<meta charset="utf-8">
5-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com; img-src 'self' data: blob: https://github.com https://avatars.githubusercontent.com https://*.google-analytics.com https://*.googletagmanager.com https://www.google.com; connect-src 'self' https://api.github.com https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://www.google.com; object-src 'none'; base-uri 'self'; form-action 'self'">
5+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com; img-src 'self' data: blob: https://github.com https://avatars.githubusercontent.com https://*.google-analytics.com https://*.googletagmanager.com https://www.google.com; connect-src 'self' https://api.github.com https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://www.google.com https://iterglow.pseudolab-devfactory.com; object-src 'none'; base-uri 'self'; form-action 'self'">
66
<script src="assets/js/ga.js"></script>
77
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
88
<title>연재 | Causal Inference Hub</title>
@@ -62,6 +62,7 @@ <h2 id="more-title">다른 연재</h2>
6262
<script src="assets/js/article.js" defer></script>
6363
<script src="assets/js/track.js" defer></script>
6464
<script src="assets/js/passport.js" defer></script>
65+
<script src="assets/js/experiment.js" defer></script>
6566
<script src="assets/js/helper.js" defer></script>
6667
</body>
6768
</html>

‎assets/js/experiment.js‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* A/B tests on the hub through Iterglow (Pseudo-Lab's experiment platform).
2+
Off until data/experiments.json has "enabled": true, the project's public SDK key, and for each
3+
experiment the flag key and the sdk_v1 experiment id an Iterglow admin created. While off, the SDK
4+
is never loaded and nothing leaves the page.
5+
6+
Other scripts ask for a variant and report the moment the variant could be seen:
7+
const v = await window.hubExperiment.variant("pseudo-greeting", 1200); // "control", "treatment", ... or null
8+
window.hubExperiment.expose("pseudo-greeting");
9+
null means "no experiment": keep the page's normal behaviour. Goal events are the hub's own
10+
hubTrack events, mapped per experiment in experiments.json, and count only after an exposure. */
11+
(() => {
12+
"use strict";
13+
const state = new Map(); // id -> { decision, exposure }
14+
let ready = null, sdk = null, cfg = null;
15+
16+
function start() {
17+
if (ready) return ready;
18+
ready = fetch("data/experiments.json")
19+
.then((r) => (r.ok ? r.json() : null))
20+
.then(async (c) => {
21+
if (!c || !c.enabled || !/^pk_/.test(c.apiKey || "") || !/^https:\/\//.test(c.baseUrl || "")) return null;
22+
cfg = c;
23+
const { IterglowSDK } = await import("../vendor/iterglow-sdk-0.3.0.js");
24+
sdk = new IterglowSDK({ apiKey: c.apiKey, baseUrl: c.baseUrl, eventModel: "sdk_v1" });
25+
return sdk;
26+
})
27+
.catch(() => null);
28+
return ready;
29+
}
30+
const find = (id) => (cfg && (cfg.experiments || []).find((x) => x.id === id && x.flagKey && x.experimentId)) || null;
31+
32+
/* the variant for this visitor, or null when the experiment is off, unknown, slow or failing */
33+
async function variant(id, timeout = 1200) {
34+
const timer = new Promise((res) => setTimeout(() => res(null), timeout));
35+
const ask = (async () => {
36+
if (!(await start())) return null;
37+
const x = find(id);
38+
if (!x) return null;
39+
if (state.has(id)) return state.get(id).decision.variant;
40+
const d = await sdk.decide(x.flagKey, { experimentId: x.experimentId });
41+
if (!d || d.key !== x.flagKey || d.type !== "flag" || !d.variant || !d.tracking_context) return null;
42+
state.set(id, { decision: d, exposure: null });
43+
return d.variant;
44+
})().catch(() => null);
45+
const v = await Promise.race([ask, timer]);
46+
/* a decision that arrives after the timeout was never shown, so it is dropped rather than exposed */
47+
if (v === null && state.has(id) && !state.get(id).exposure) state.delete(id);
48+
return v;
49+
}
50+
51+
/* call when the variant could actually be seen, in every arm, control included */
52+
async function expose(id) {
53+
const s = state.get(id);
54+
if (!s || s.exposure) return;
55+
try { s.exposure = await sdk.expose(s.decision); } catch (e) { /* not recorded; goals stay unattributed */ }
56+
}
57+
58+
/* goals: the hub's own events, forwarded once an experiment has been exposed */
59+
function goal(name) {
60+
if (!cfg || !sdk) return;
61+
state.forEach((s, id) => {
62+
const x = find(id), g = x && x.goals && x.goals[name];
63+
if (!g || !s.exposure) return;
64+
sdk.track(g, { hub_event: name }, { tracking_context: s.exposure, eventId: crypto.randomUUID() }).catch(() => {});
65+
});
66+
}
67+
const wrap = () => {
68+
const orig = window.hubTrack;
69+
if (typeof orig !== "function" || orig.__exp) return;
70+
const wrapped = (name, params) => { orig(name, params); goal(name); };
71+
wrapped.__exp = true;
72+
window.hubTrack = wrapped;
73+
};
74+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wrap); else wrap();
75+
76+
window.hubExperiment = { variant, expose };
77+
})();

‎assets/js/helper.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,19 @@
400400
}
401401
function listen() {
402402
(LINES[page] || []).forEach((line) => {
403-
if (!line.when) { setTimeout(() => speak(line), 1600); return; }
403+
if (!line.when) {
404+
/* the greeting is the first A/B test on the hub (experiment "pseudo-greeting" in data/experiments.json).
405+
Both arms are exposed at the same moment and under the same conditions; only treatment speaks.
406+
With the experiment off, variant() is null and Pseudo greets as usual. */
407+
const exp = window.hubExperiment;
408+
const gate = exp ? exp.variant("pseudo-greeting", 1200) : Promise.resolve(null);
409+
setTimeout(() => gate.then((v) => {
410+
const eligible = !quiet() && panel.hidden && !spoke && !said().includes(`${page}:${line.id}`);
411+
if (v && eligible) exp.expose("pseudo-greeting");
412+
if (v !== "control") speak(line);
413+
}), 1600);
414+
return;
415+
}
404416
if (line.when === "half") {
405417
const onScroll = () => {
406418
if (scrollY + innerHeight < document.documentElement.scrollHeight * 0.55) return;

0 commit comments

Comments
 (0)