111 lines
11 KiB
Python
111 lines
11 KiB
Python
"""Mockup editorial summaries using the saved report's game and UTC window."""
|
|
from collections import defaultdict
|
|
from datetime import timedelta
|
|
import statistics
|
|
|
|
|
|
def extend_report(conn,report,start,end):
|
|
from health_reports import number,change
|
|
from sample_api import stage_funnel
|
|
gid=report['gameId'];m=report['metrics'];prev=report['previousMetrics']
|
|
scope={'gid':gid,'start':start,'end':end}
|
|
pct=lambda v: 'N/A' if v is None else number(v)+'%'
|
|
minutes=lambda v: 'N/A' if v is None else number(v/60)+' min'
|
|
hours=lambda v: 'N/A' if v is None else number(v/3600)+' h'
|
|
money=lambda v,decimals=0: 'N/A' if v is None else 'R$ '+number(v,decimals)
|
|
timing=lambda v: 'N/A' if v is None else number(v)+' sec'
|
|
extra=conn.execute('''SELECT
|
|
(SELECT count(DISTINCT player_id) FROM player_active_days WHERE game_id=%(gid)s
|
|
AND active_day>=%(end)s::date-30 AND active_day<%(end)s::date)::int mau,
|
|
avg(extract(epoch FROM s.ended_at-s.started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date)::double precision new_session,
|
|
avg(extract(epoch FROM s.ended_at-s.started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date)::double precision returning_session
|
|
FROM sessions s JOIN players p ON p.game_id=s.game_id AND p.id=s.player_id
|
|
WHERE s.game_id=%(gid)s AND s.started_at>=%(start)s::date AND s.started_at<%(end)s::date''',scope).fetchone()
|
|
retained={}
|
|
for offset in (7,30):
|
|
r=conn.execute('''SELECT count(*)::int cohort,count(*) FILTER(WHERE EXISTS(SELECT 1 FROM sessions s
|
|
WHERE s.game_id=p.game_id AND s.player_id=p.id AND s.started_at>=(p.first_seen AT TIME ZONE 'UTC')::date+%(offset)s
|
|
AND s.started_at<(p.first_seen AT TIME ZONE 'UTC')::date+%(offset)s+1))::int returned
|
|
FROM players p WHERE p.game_id=%(gid)s AND p.first_seen>=%(start)s::date-%(offset)s
|
|
AND p.first_seen<%(end)s::date-%(offset)s''',scope|{'offset':offset}).fetchone()
|
|
retained[offset]=100*r['returned']/r['cohort'] if r['cohort'] else None
|
|
products=conn.execute('''SELECT p.id,p.name,sum(s.quantity)::int units,sum(s.unit_price_robux*s.quantity)::double precision robux
|
|
FROM product_sales s JOIN products p ON p.game_id=s.game_id AND p.id=s.product_id
|
|
WHERE s.game_id=%(gid)s AND s.purchased_at>=%(start)s::date AND s.purchased_at<%(end)s::date
|
|
GROUP BY p.id ORDER BY robux DESC,p.id''',scope).fetchall()
|
|
payers=conn.execute('''SELECT player_id,sum(unit_price_robux*quantity)::double precision robux FROM product_sales
|
|
WHERE game_id=%(gid)s AND purchased_at>=%(start)s::date AND purchased_at<%(end)s::date AND unit_price_robux>0
|
|
AND player_id IS NOT NULL GROUP BY player_id ORDER BY robux DESC,player_id''',scope).fetchall()
|
|
largest=100*payers[0]['robux']/m['robux'] if payers and m['robux'] and not m['unattributed'] else None
|
|
arppu=m['robux']/m['payers'] if m['payers'] and not m['unattributed'] else None
|
|
user_days=m['average_dau']*report['periodDays']
|
|
arpdau=m['robux']/user_days if user_days else None
|
|
prompts=conn.execute('''SELECT trigger_source source,count(*)::int prompts,count(*) FILTER(WHERE outcome='bought')::int bought,
|
|
count(*) FILTER(WHERE outcome='closed')::int closed,count(*) FILTER(WHERE outcome IS NULL)::int unresolved,
|
|
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='bought')::double precision buy_time,
|
|
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='closed')::double precision close_time
|
|
FROM interaction_events WHERE game_id=%(gid)s AND kind='product_prompt'
|
|
AND outcome IN ('bought','closed') AND ended_at IS NOT NULL AND trigger_source IN ('game','player')
|
|
AND started_at>=%(start)s::date AND started_at<%(end)s::date GROUP BY trigger_source''',scope).fetchall()
|
|
prompt_by_source={r['source']:dict(r,conversion=100*r['bought']/r['prompts'] if r['prompts'] else None) for r in prompts}
|
|
game_prompt=prompt_by_source.get('game',{});player_prompt=prompt_by_source.get('player',{})
|
|
definitions=conn.execute('SELECT * FROM activity_definitions WHERE game_id=%s ORDER BY id',(gid,)).fetchall()
|
|
runs=conn.execute('''SELECT definition_id,stage_progress,extract(epoch FROM ended_at-started_at)::double precision duration
|
|
FROM activity_runs WHERE game_id=%(gid)s AND ended_at IS NOT NULL AND outcome IS NOT NULL AND started_at>=%(start)s::date AND started_at<%(end)s::date''',scope).fetchall()
|
|
grouped=defaultdict(list)
|
|
for row in runs: grouped[row['definition_id']].append(row)
|
|
activity_lines=[];drops=[]
|
|
for definition in definitions:
|
|
members=grouped[definition['id']]
|
|
if not members: continue
|
|
times=[r['duration'] for r in members if r['duration'] is not None]
|
|
activity_lines.append(f"{definition['name']}: {number(len(members),0)} runs, {minutes(statistics.mean(times) if times else None)} average known duration.")
|
|
funnel=stage_funnel(members,definition)
|
|
for i,stage in enumerate(funnel[1:],1):
|
|
if stage['dropoffPercent'] is not None and funnel[i-1]['runs']:
|
|
drops.append({'activity':definition['name'],'from':funnel[i-1]['event'],'to':stage['event'],
|
|
'dropoff':stage['dropoffPercent'],'previousRuns':funnel[i-1]['runs'],'reach':stage['reachPercent']})
|
|
biggest=max(drops,key=lambda r:r['dropoff']) if drops else None
|
|
largest_drop=(f"The largest observed stage loss was in {biggest['activity']}: {pct(biggest['dropoff'])} from {biggest['from']} to {biggest['to']}, among {number(biggest['previousRuns'],0)} runs at the preceding stage. {pct(biggest['reach'])} of all starts reached that stage." if biggest else 'Stage drop-off is N/A because no complete ordered funnel observations are available.')
|
|
game=conn.execute('SELECT sample_key FROM games WHERE id=%s',(gid,)).fetchone()
|
|
angle={
|
|
'willow-v1':'For this social exploration game, inspect whether collection and exploration encourage another visit after the first session.',
|
|
'orbit-v1':'For this racing game, inspect how quickly a new player reaches a first race and whether the post-race flow encourages another round.',
|
|
'dungeon-v1':'For this progression game, inspect whether early crafting and quest rewards give new players a clear reason to return.'
|
|
}.get(game['sample_key'],'Inspect the onboarding and progression steps behind these observed changes.')
|
|
audience_grew=m['active_players']>prev['active_players']
|
|
revenue_grew=m['robux']>prev['robux']
|
|
assessment=('Audience and spending both grew.' if audience_grew and revenue_grew else
|
|
'Spending grew while the active audience contracted or stayed flat.' if revenue_grew else
|
|
'The audience grew while spending declined or stayed flat.' if audience_grew else
|
|
'Active reach and spending were flat or lower than the preceding period.')
|
|
top_product=(f"{products[0]['name']} led recorded product spending with {money(products[0]['robux'])} from {number(products[0]['units'],0)} units." if products else 'No product sales were reported in this window.')
|
|
prompt_lines=[]
|
|
for key,title in [('game','Game-induced'),('player','Player-induced')]:
|
|
p=prompt_by_source.get(key)
|
|
if not p: prompt_lines.append(f'{title}: no reported prompts.');continue
|
|
prompt_lines.append(f"{title}: {number(p['prompts'],0)} prompts, {pct(p['conversion'])} bought; average time to buy {timing(p['buy_time'])}, to close {timing(p['close_time'])}.")
|
|
engagement_text=f"{report['gameName']} recorded {number(m['sessions'],0)} session starts and {hours(m['total_playtime'])} of known playtime. Average completed sessions lasted {minutes(m['average_session'])} ({change(m['average_session'],prev['average_session'])} versus the prior period). New-user sessions averaged {minutes(extra['new_session'])}; returning-user sessions averaged {minutes(extra['returning_session'])}."
|
|
retention_text=f"Five-minute first-session retention was {pct(m['first_five_rate'])}. Mature exact-day returns were D1 {pct(m['retention'])}, D7 {pct(retained[7])}, and D30 {pct(retained[30])}; these use cohorts whose observation day fell inside this report window."
|
|
money_text=f"Reported spending reached {money(m['robux'])} ({change(m['robux'],prev['robux'])}), with {number(m['payers'],0) if not m['unattributed'] else 'N/A'} paying players and {pct(m['payer_conversion'])} period payer conversion. ARPPU was {money(arppu,2)}; Robux per active user-day was {money(arpdau,2)}."
|
|
concentration=f"The largest recorded payer contributed {pct(largest)} of period spending. Check whether revenue changes persist without that player before treating them as a broad spending trend." if largest is not None else 'Payer concentration is N/A when no attributed positive-price sales are available.'
|
|
investigations=[
|
|
{'title':'First-session experience','text':f"Five-minute first-session retention: {pct(m['first_five_rate'])}. {angle}"},
|
|
{'title':'Activity transition','text':largest_drop+' Review the preceding step before changing the entire activity.' if biggest else largest_drop},
|
|
{'title':'Offer placement','text':f"Player-induced prompts converted at {pct(player_prompt.get('conversion'))}; game-induced prompts at {pct(game_prompt.get('conversion'))}. Compare the same products and similar player groups before testing offer timing or frequency."}
|
|
]
|
|
sections=[
|
|
{'key':'health','title':'Game health','target':'overview','paragraphs':report['paragraphs']},
|
|
{'key':'engagement','title':'Engagement & retention','target':'sessions','paragraphs':[{'title':'Session quality','text':engagement_text},{'title':'Returning players','text':retention_text},{'title':'What to inspect','text':angle}]},
|
|
{'key':'monetization','title':'Monetization','target':'monetization','paragraphs':[{'title':'Spending & conversion','text':money_text},{'title':'Products','text':top_product},{'title':'Payer concentration','text':concentration}]},
|
|
{'key':'prompts','title':'Purchase prompts','target':'prompts','paragraphs':[{'title':'Source breakdown','text':' '.join(prompt_lines)},{'title':'Interpretation','text':'Source comparisons describe different reported audiences, not a proven effect of prompting. Use matching products and player segments to decide which placement to test.'}]},
|
|
{'key':'activities','title':'Activities & funnels','target':'activities','paragraphs':[{'title':'Activity use','text':' '.join(activity_lines) or 'No activity runs were reported in this window.'},{'title':'Stage drop-off','text':largest_drop}]},
|
|
{'key':'investigations','title':'Suggested investigations','target':None,'paragraphs':investigations}
|
|
]
|
|
evidence={'mau':extra['mau'],'d7':retained[7],'d30':retained[30],'arppu':arppu,'arpdau':arpdau,
|
|
'newSession':extra['new_session'],'returningSession':extra['returning_session'],
|
|
'largestPayerShare':largest,'topProducts':products,'promptSources':prompt_by_source,'largestDrop':biggest}
|
|
from overview_analysis import deep_analysis
|
|
executive=deep_analysis(conn,report,start,end,evidence)
|
|
return {'summaryVersion':4,'executive':executive,'summaries':sections,'summaryEvidence':evidence}
|