Skip to content

Commit f453cf9

Browse files
이혁진이혁진
authored andcommitted
Let a project with no tracking in the door
analyze told agents to use audit_tracking when there was nothing to analyse, and audit_tracking required a CSV — so the advice led to a closed door, and the people who most need this tool were the ones who could not start. Its csv_path is optional now. Without data it reads what the code logs and answers the question that matters at that point: whether any of it could ever show that a user got value. Logging only open_app and page_view is a finding, not an error. It also returns how long to wait before analysing. Those numbers are not invented — 7 days is how long a user must be watched before the code will call them churned, 14 before regular and week-1 retention. Both were literals inside classify_users and are now named constants that tracking_plan reads, so the advice cannot drift from the rule it describes. That closes the loop the tool was missing: add logging, come back in a week for a first report, and the week after that history_compare has something to compare against.
1 parent aaa6256 commit f453cf9

3 files changed

Lines changed: 118 additions & 17 deletions

File tree

analysis.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
# classify_users와 find_aha_moments가 반드시 같은 기준을 쓴다 (리포트 안에서 숫자가 어긋나지 않게).
1818
REGULAR_DAYS_IN_2W = 10
1919

20+
# 유저를 판정하려면 그만큼 지켜본 뒤여야 한다. 이 두 숫자가 곧
21+
# "로깅을 심었으면 언제 다시 오면 되는가"의 답이기도 하다 (tracking_plan이 그대로 쓴다).
22+
CHURN_JUDGEMENT_DAYS = 7 # 이만큼 지나야 '한 번 쓰고 떠났다'고 말할 수 있다
23+
REGULAR_JUDGEMENT_DAYS = 14 # 이만큼 지나야 단골 판정과 1주차 리텐션이 나온다
24+
2025
# 허영 지표 — 가치를 증명하지 않는 이벤트. value_event로 못 쓰게 막고,
2126
# 아하 후보에서도 뺀다. 이름만 다른 같은 개념들을 모아둔다.
2227
VANITY_EVENTS = {
@@ -292,11 +297,11 @@ def classify_users(users: dict[str, UserRow], today: date) -> dict[str, list[str
292297
active = sorted(u.value_days)
293298
tenure = (today - u.signup).days + 1
294299
recent2w = [d for d in active if (today - d).days < 14]
295-
if len(active) <= 1 and tenure >= 7:
300+
if len(active) <= 1 and tenure >= CHURN_JUDGEMENT_DAYS:
296301
buckets["churned"].append(u.user_id)
297302
elif active and all(d.weekday() >= 5 for d in active) and len(active) >= 2:
298303
buckets["weekend_only"].append(u.user_id)
299-
elif tenure >= 14 and len(recent2w) >= REGULAR_DAYS_IN_2W:
304+
elif tenure >= REGULAR_JUDGEMENT_DAYS and len(recent2w) >= REGULAR_DAYS_IN_2W:
300305
buckets["regular"].append(u.user_id)
301306
else:
302307
buckets["casual"].append(u.user_id)
@@ -391,21 +396,54 @@ def top_aha(results: list[dict]) -> dict | None:
391396
)
392397

393398

394-
def audit_tracking(code_events: list[str], data_events: set[str]) -> dict:
395-
"""코드에서 찾은 이벤트 목록과 실제 데이터에 찍힌 이벤트를 대조한다.
399+
def audit_tracking(code_events: list[str], data_events: set[str] | None = None) -> dict:
400+
"""코드에서 찾은 이벤트를 점검한다. 데이터가 있으면 대조까지 한다.
401+
402+
data_events가 없어도 답할 게 있다 — 오히려 이 경우가 이 툴이 제일 필요한
403+
상황이다(추적을 안 해서 데이터가 없는 것). 코드만 봐도 "심어둔 게 전부
404+
허영 지표라 '유저가 가치를 얻었나'에 영원히 답할 수 없다"는 진단이 나온다.
396405
406+
데이터가 있으면 추가로:
397407
- 코드에만 있음 → 심어놨는데 한 번도 안 찍힘 (고장이거나, 아무도 안 쓰는 기능)
398408
- 데이터에만 있음 → 코드에서 못 찾음 (죽은 코드거나, 스캔 누락)
399-
- 양쪽 다 있음 → 정상 추적 중
400409
"""
401410
code = set(code_events)
402-
return {
411+
usable = sorted(e for e in code if not is_vanity(e))
412+
out = {
413+
"in_code": sorted(code),
414+
"usable_in_code": usable,
415+
"vanity_in_code": sorted(e for e in code if is_vanity(e)),
416+
"can_measure_value": bool(usable),
417+
}
418+
if data_events is None:
419+
return out
420+
return out | {
403421
"tracked_ok": sorted(code & data_events),
404422
"in_code_never_fired": sorted(code - data_events),
405423
"in_data_not_in_code": sorted(data_events - code),
406424
}
407425

408426

427+
def tracking_plan(code_events: list[str]) -> dict:
428+
"""추적이 부족한 사람에게 줄 답: 지금 뭐가 부족하고, 언제 다시 오면 되는가.
429+
430+
'며칠 뒤'는 지어낸 숫자가 아니라 판정 기준에서 그대로 나온다 —
431+
이탈은 CHURN_JUDGEMENT_DAYS, 단골과 1주차 리텐션은 REGULAR_JUDGEMENT_DAYS.
432+
"""
433+
audit = audit_tracking(code_events)
434+
return audit | {
435+
"come_back_in_days": {
436+
"first_signal": CHURN_JUDGEMENT_DAYS,
437+
"full_picture": REGULAR_JUDGEMENT_DAYS,
438+
},
439+
"why_those_days": (
440+
f"Calling a user churned needs {CHURN_JUDGEMENT_DAYS} days of watching them; "
441+
f"calling one a regular, and week-1 retention, needs {REGULAR_JUDGEMENT_DAYS}. "
442+
"Before that the code has nothing honest to say."
443+
),
444+
}
445+
446+
409447
def onboarding_funnel(users: dict[str, UserRow], today: date) -> dict:
410448
"""온보딩 퍼널: 새 유저가 어느 계단에서 떨어지는지.
411449

server.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ def analyze(
8181
"SELECT user_id, created_at::date AS date, 'purchase' AS event FROM orders "
8282
"UNION ALL SELECT user_id, added_at::date, 'add_to_wishlist' FROM wishlist_items",
8383
"4. Call analyze again with the CSV it wrote.",
84-
"If there is no database and nothing is tracked, say so plainly — this "
85-
"tool cannot help yet. Offer to add event logging instead: find the "
86-
"handlers for the product's core actions and use audit_tracking.",
84+
"If there is no database and nothing is tracked, say so plainly — "
85+
"this tool cannot report on a product that records nothing. Then do "
86+
"the useful thing instead: read the code for the core actions and "
87+
"call audit_tracking with them (no csv_path needed). It names the "
88+
"holes and tells you how many days of data are needed before "
89+
"analyze can say anything honest.",
8790
],
8891
}
8992

@@ -99,9 +102,10 @@ def analyze(
99102
"what_to_do": (
100103
"Nothing in this data looks like a value event: every action is "
101104
"either a vanity metric, done by too few users, or never repeated. "
102-
"That is a tracking problem, not a product problem. Tell the user "
103-
"which core actions are not being recorded and offer to add logging "
104-
"(audit_tracking has the procedure)."
105+
"That is a tracking problem, not a product problem. Read the code "
106+
"for the product's core actions and call audit_tracking with what "
107+
"you find — it works without data and comes back with the holes, "
108+
"and with how long to wait once logging is added."
105109
),
106110
}
107111
value_event = best["event"]
@@ -239,11 +243,12 @@ def find_aha_moments(csv_path: str, value_event: str) -> dict:
239243

240244

241245
@mcp.tool()
242-
def audit_tracking(code_events: list[str], csv_path: str) -> dict:
243-
"""Compare the events logged in the code against the events in the data.
246+
def audit_tracking(code_events: list[str], csv_path: str | None = None) -> dict:
247+
"""Check what the code logs, and — if there is data — whether it arrives.
244248
245-
This is also the tool for a project that tracks nothing yet — there is no
246-
data to analyse, but there is code to read.
249+
Use this when analyze says there is nothing to work with. A project that
250+
tracks nothing has no data to analyse, but it has code to read, so leave
251+
csv_path out and this still answers.
247252
248253
How to use it:
249254
1. Search the codebase for logging calls yourself. Common shapes:
@@ -260,10 +265,21 @@ def audit_tracking(code_events: list[str], csv_path: str) -> dict:
260265
that belongs in that file, in that function, matching the surrounding
261266
style, show it, and ask whether to add it. Follow the project's existing
262267
naming (follow_artist if it is snake_case, followArtist if camelCase).
268+
6. Once logging is in, tell them when to come back. come_back_in_days is in
269+
the result and is not a guess — it is how long the code must watch a user
270+
before it can honestly call them churned or a regular. Saying "run this
271+
again tomorrow" would produce a report with nothing in it.
263272
"""
273+
if csv_path is None:
274+
return analysis.tracking_plan(code_events)
275+
264276
events = analysis.load_events(csv_path)
265277
data_events = {e["event"] for e in events}
266-
return analysis.audit_tracking(code_events, data_events)
278+
result = analysis.audit_tracking(code_events, data_events)
279+
if not result["usable_in_code"]:
280+
# 데이터가 있어도 전부 허영 지표면 '가치를 얻었나'에 답할 수 없다
281+
result |= analysis.tracking_plan(code_events)
282+
return result
267283

268284

269285
@mcp.tool()

tests/test_pipeline.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,50 @@ def test_unmatched_columns_say_what_to_do(tmp_path):
299299
with pytest.raises(analysis.EventFileError) as e:
300300
analysis.load_events(path)
301301
assert "a" in str(e.value) and "AS date" in str(e.value)
302+
303+
304+
# ── 추적이 없는 사람도 들어올 수 있는가 ──────────────────────────────────
305+
306+
def test_audit_tracking_works_without_any_data():
307+
"""추적을 안 해서 온 사람인데 데이터를 요구하면 안 된다 — 이 툴이 제일
308+
필요한 상황이 바로 데이터가 없는 상황이다."""
309+
import server
310+
r = server.audit_tracking(["open_app", "page_view"])
311+
assert r["can_measure_value"] is False
312+
assert r["vanity_in_code"] == ["open_app", "page_view"]
313+
assert r["come_back_in_days"]["first_signal"] == analysis.CHURN_JUDGEMENT_DAYS
314+
assert r["come_back_in_days"]["full_picture"] == analysis.REGULAR_JUDGEMENT_DAYS
315+
316+
317+
def test_audit_tracking_still_compares_when_data_exists(tmp_path):
318+
import server
319+
path = write_csv(tmp_path / "e.csv", events_for(6, 10), ["user_id", "date", "event"])
320+
r = server.audit_tracking(["purchase", "share"], path)
321+
assert r["tracked_ok"] == ["purchase"]
322+
assert r["in_code_never_fired"] == ["share"]
323+
324+
325+
def test_come_back_days_come_from_the_judgement_thresholds():
326+
"""'며칠 뒤 오세요'가 지어낸 숫자면 안 된다. 유저를 그만큼 지켜봐야
327+
이탈·단골을 판정할 수 있다는 코드의 기준에서 나와야 한다."""
328+
plan = analysis.tracking_plan(["purchase"])
329+
assert plan["come_back_in_days"] == {
330+
"first_signal": analysis.CHURN_JUDGEMENT_DAYS,
331+
"full_picture": analysis.REGULAR_JUDGEMENT_DAYS,
332+
}
333+
assert plan["can_measure_value"] is True
334+
335+
336+
def test_analyze_sends_a_trackless_project_somewhere_reachable(tmp_path):
337+
"""막다른 길로 안내하면 안 된다 — analyze가 가리키는 곳이 실제로 열려 있어야."""
338+
import server
339+
for guidance in (
340+
" ".join(server.analyze()["what_to_do"]),
341+
server.analyze(
342+
write_csv(tmp_path / "e.csv", events_for(9, 20, event="page_view"),
343+
["user_id", "date", "event"]),
344+
output_path=str(tmp_path / "r.html"),
345+
)["what_to_do"],
346+
):
347+
assert "audit_tracking" in guidance
348+
server.audit_tracking(["page_view"]) # 안내받은 대로 부르면 열려야 한다

0 commit comments

Comments
 (0)