diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7cc2b36..6ebf586 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -395,7 +395,9 @@ detectors cover the realistic failure modes; the trigger for adopting one is mul into Gold, where you can no longer reason about every writer. **On what the schedules actually process.** Scheduled runs read an explicit -`manifests/published/...` build-success marker, not today's date and not `MAX(dt)`. Presence of a +`manifests/published/...` build-success marker, not today's date and not `MAX(dt)`. Both +`gold_daily_kpi` and `gold_hourly_kpi` receive table-specific markers after the complete lake build. +Presence of a row proves only that writing started; it does not prove the partition closed. Both lake builders invalidate the old marker before replacing data and publish a new marker only after their verification queries succeed. diff --git a/README.md b/README.md index 86b7a74..27b968d 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ stack and cannot be created by the default `cdk deploy --all`. ### KPI / retention anomaly path -1. **[auto] Detect** — EventBridge runs daily DAU/GGR and weekly mature-cohort - retention checks against published Gold data. +1. **[auto] Detect** — EventBridge runs hourly site-usage checks from prepared + same-hour baselines and weekly mature-cohort retention checks against published Gold data. 2. **[auto] First look** — Module 1 publishes an SNS alert; Module 3 builds baseline, per-game, or retention evidence before an analyst starts. 3. **[human] Investigate** — an analyst checks the evidence, business context, @@ -169,7 +169,7 @@ from authenticated identity rather than a request body. ### Verification status -- **145 offline tests** plus Python compilation and CDK assertion coverage. +- **146 offline tests** plus Python compilation and CDK assertion coverage. - **Eight default stacks** synthesize without Kinesis, NAT Gateway, RDS, OpenSearch, or provisioned compute. - The earlier baseline was deployed and exercised against AWS on 2026-07-29. diff --git a/data-foundation/KPI_DEFINITIONS.md b/data-foundation/KPI_DEFINITIONS.md index 60169f5..cd6760d 100644 --- a/data-foundation/KPI_DEFINITIONS.md +++ b/data-foundation/KPI_DEFINITIONS.md @@ -5,7 +5,7 @@ registry) solves at the player-feature layer exists at the business-metric layer team computes "DAU" or "retention" with a slightly different query, two dashboards showing different numbers for the same day is not a data problem, it's a definitions problem. This file is the one place a standard metric's calculation logic is defined. **Gold-layer tables -(`gold_daily_kpi`, `gold_cohort_retention`) and anything built on top of them — dashboards, the +(`gold_daily_kpi`, `gold_hourly_kpi`, `gold_hourly_monitoring_features`, `gold_cohort_retention`) and anything built on top of them — dashboards, the Module 3 semantic layer's SQL templates, ad hoc analysis — must compute these metrics exactly as defined here, not re-derive them.** @@ -20,8 +20,8 @@ Same governance philosophy, different grain and audience: | | This file (`KPI_DEFINITIONS.md`) | `FEATURES.md` | |---|---|---| -| Grain | `date x client_site_id` (business reporting) | `snapshot_date x player_id` (analysis/ML) | -| Table | `gold_daily_kpi`, `gold_cohort_retention` | `gold_player_features` | +| Grain | daily site, hourly site/product/actor, and retention cohort | `snapshot_date x player_id` (analysis/ML) | +| Table | `gold_daily_kpi`, `gold_hourly_kpi`, `gold_hourly_monitoring_features`, `gold_cohort_retention` | `gold_player_features` | | Consumers | Dashboards, execs, Module 3's semantic layer | Module 1 (arbitrage), Module 2 (experiment analysis) | Both are built from the same `silver_events` — a metric defined here and a feature defined there @@ -94,6 +94,33 @@ later (D7). - Retention is defined relative to `client_site_id`, not `game_id` — a player who registers via one game and returns to play a different one on the same site still counts as retained. +## Hourly operational KPIs + +**Definition:** metrics use the same event filters and FX-normalized Silver columns as their daily +counterparts, grouped by `event_hour x client_site_id x game_id x player_id` and partitioned by +`dt`. + +**Computed in:** `gold_hourly_kpi` (`data-foundation/lake/ddl/05_gold_hourly_kpi.sql`). + +**Why the actor dimension remains:** a live experiment guardrail must join only actors with a +recorded treatment exposure. Aggregating away `player_id` before that join would mix treatment, +control, and non-exposed traffic and make the hourly safety check untrustworthy. + +**Caveats:** the current PoC refreshes this table during the batch lake build. A production path +would incrementally publish completed hourly partitions and advance the publication marker only +after late-arrival handling and quality checks finish. + +### Hourly monitoring features + +**Definition:** `active_users`, `sessions`, and `processed_events` are aggregated at +`event_hour x client_site_id`. For every hour-of-day and site, the baseline and normal range use +up to seven earlier observations at that same hour. This avoids comparing a partial hour with a +complete day or calculating history inside the alerting Lambda. + +**Computed in:** `gold_hourly_monitoring_features` +(`data-foundation/lake/ddl/06_gold_hourly_monitoring_features.sql`). Module 1 reads the latest +published row and compares the prepared actual value with its prepared bounds. + --- ## Adding or changing a metric @@ -106,4 +133,6 @@ later (D7). ### Changelog +- **v1.2** (2026-08-03): added site-level hourly monitoring features and same-hour baselines. +- **v1.1** (2026-08-02): added exposure-joinable hourly operational KPIs. - **v1** (2026-07-27): initial definitions — GGR, DAU, ARPU, D1/D7 retention. diff --git a/data-foundation/README.md b/data-foundation/README.md index 577907d..782e81a 100644 --- a/data-foundation/README.md +++ b/data-foundation/README.md @@ -50,12 +50,18 @@ event_simulator/ --(local JSONL)--> S3 bronze/ --(Athena CTAS)--> S3 silver/ 3. **Silver** (`s3:///silver/events/`): same grain as Bronze, but Parquet/Snappy, with `event_ts` parsed to a real timestamp and FX converted to USD **once** (`bet_amount_usd`, `win_amount_usd`, `amount_usd`) so every downstream query reuses it instead of re-deriving it. -4. **Gold**: small, purpose-built aggregate tables (not partitioned — a few hundred rows each, - so partitioning would only add overhead). Column-by-column definitions live in - `KPI_DEFINITIONS.md`, not repeated here: +4. **Gold**: purpose-built aggregate tables. The small daily and cohort summaries are not + partitioned; the larger hourly actor-level table is partitioned by `dt` so scheduled checks + can prune old data. Column-by-column definitions live in `KPI_DEFINITIONS.md`, not repeated here: - `gold_daily_kpi` — dt x client_site_id grain: DAU, sessions, new players, GGR, deposits, withdrawals, ARPU (all USD). Also the table `governance/`'s client-isolation demo filters. - `gold_cohort_retention` — registration_date x client_site_id grain: D1/D7 retention. + - `gold_hourly_kpi` — event_hour x client_site_id x game_id x player_id grain: hourly sessions + and normalized value metrics used by exposure-aware live experiment guardrails. + - `gold_hourly_monitoring_features` — event_hour x client_site_id grain: hourly active users, + sessions and processed events with a trailing same-hour baseline and normal range already + computed for Module 1. The detector reads one prepared row instead of rebuilding history at + alert time. ## Running it diff --git a/data-foundation/lake/build_lake.py b/data-foundation/lake/build_lake.py index 6f2aa90..a3a9137 100644 --- a/data-foundation/lake/build_lake.py +++ b/data-foundation/lake/build_lake.py @@ -3,7 +3,7 @@ 1. Uploads the local simulator output (data-foundation/event_simulator/output) to S3 as gzip-compressed Bronze JSONL. 2. Runs the DDL in data-foundation/lake/ddl/ in order (Bronze external table, - Silver Parquet CTAS, two Gold Parquet CTAS). + Silver Parquet CTAS, and purpose-built Gold Parquet CTAS tables). 3. Runs the example queries in data-foundation/lake/queries/ and prints the results, as an end-to-end sanity check. @@ -30,6 +30,10 @@ DDL_DIR = LAKE_DIR / "ddl" QUERIES_DIR = LAKE_DIR / "queries" DAILY_KPI_PUBLICATION_MANIFEST = "manifests/published/gold_daily_kpi.json" +HOURLY_KPI_PUBLICATION_MANIFEST = "manifests/published/gold_hourly_kpi.json" +HOURLY_MONITORING_PUBLICATION_MANIFEST = ( + "manifests/published/gold_hourly_monitoring_features.json" +) GOVERNANCE_SETUP = ( LAKE_DIR.parent / "governance" / "setup_client_isolation.py" ) @@ -113,7 +117,13 @@ def print_query_results(query_id: str, max_rows: int = 20): print(" " + " | ".join(values)) -def publish_completion_manifest(bucket: str, min_date: str, max_date: str): +def publish_completion_manifest( + bucket: str, + key: str, + table: str, + min_date: str, + max_date: str, +): """Publish only after every transform and verification query succeeds. Consumers use this marker instead of guessing completeness from MAX(dt). @@ -121,7 +131,7 @@ def publish_completion_manifest(bucket: str, min_date: str, max_date: str): finished writing it. """ body = { - "table": "gold_daily_kpi", + "table": table, "published_from": min_date, "published_through": max_date, "published_at": datetime.now(timezone.utc).isoformat(), @@ -129,11 +139,11 @@ def publish_completion_manifest(bucket: str, min_date: str, max_date: str): } s3.put_object( Bucket=bucket, - Key=DAILY_KPI_PUBLICATION_MANIFEST, + Key=key, Body=json.dumps(body, sort_keys=True).encode("utf-8"), ContentType="application/json", ) - print(f"Published completion marker through {max_date}.") + print(f"Published {table} completion marker through {max_date}.") def apply_client_isolation(): @@ -169,8 +179,12 @@ def main(): # failed rebuild must be visibly incomplete, never paired with a stale # marker that tells scheduled consumers it is safe to query. s3.delete_object(Bucket=bucket, Key=DAILY_KPI_PUBLICATION_MANIFEST) + s3.delete_object(Bucket=bucket, Key=HOURLY_KPI_PUBLICATION_MANIFEST) + s3.delete_object(Bucket=bucket, Key=HOURLY_MONITORING_PUBLICATION_MANIFEST) clear_prefix(bucket, "silver/events/") clear_prefix(bucket, "gold/daily_kpi/") + clear_prefix(bucket, "gold/hourly_kpi/") + clear_prefix(bucket, "gold/hourly_monitoring_features/") clear_prefix(bucket, "gold/cohort_retention/") clear_prefix(bucket, "athena-results/tables/") # staging debris from any previously failed CTAS @@ -189,7 +203,11 @@ def main(): label = code_lines[0][:60] if code_lines else statement[:60] print(f" {ddl_file.name}: {label} ...") run_query(statement, database, workgroup) - print("DDL complete: bronze_events, silver_events, gold_daily_kpi, gold_cohort_retention.") + print( + "DDL complete: bronze_events, silver_events, gold_daily_kpi, " + "gold_cohort_retention, gold_hourly_kpi, " + "gold_hourly_monitoring_features." + ) print("\nRunning example queries ...") for query_file in sorted(QUERIES_DIR.glob("*.sql")): @@ -200,7 +218,27 @@ def main(): print("\nReapplying Lake Formation client isolation ...") apply_client_isolation() - publish_completion_manifest(bucket, min_date, max_date) + publish_completion_manifest( + bucket, + DAILY_KPI_PUBLICATION_MANIFEST, + "gold_daily_kpi", + min_date, + max_date, + ) + publish_completion_manifest( + bucket, + HOURLY_KPI_PUBLICATION_MANIFEST, + "gold_hourly_kpi", + min_date, + max_date, + ) + publish_completion_manifest( + bucket, + HOURLY_MONITORING_PUBLICATION_MANIFEST, + "gold_hourly_monitoring_features", + min_date, + max_date, + ) if __name__ == "__main__": diff --git a/data-foundation/lake/ddl/05_gold_hourly_kpi.sql b/data-foundation/lake/ddl/05_gold_hourly_kpi.sql new file mode 100644 index 0000000..ba1d58e --- /dev/null +++ b/data-foundation/lake/ddl/05_gold_hourly_kpi.sql @@ -0,0 +1,42 @@ +-- Gold: hourly operational KPIs at event_hour x client_site_id x game_id x +-- player_id grain. Keeping the actor dimension is intentional: Module 2 can +-- join an experiment's recorded exposure cohort before aggregating a live +-- guardrail, instead of treating every eligible actor as exposed. +-- +-- The table is partitioned by dt because it is materially larger than the +-- daily site summary and live checks always constrain the current date. +DROP TABLE IF EXISTS gold_hourly_kpi; + +CREATE TABLE gold_hourly_kpi +WITH ( + format = 'PARQUET', + parquet_compression = 'SNAPPY', + partitioned_by = ARRAY['dt'], + external_location = 's3://$bucket/gold/hourly_kpi/' +) AS +SELECT + date_trunc('hour', event_ts) AS event_hour, + client_site_id, + game_id, + player_id, + COUNT(*) AS processed_events, + COUNT_IF(event_type = 'session_start') AS sessions, + COUNT_IF(event_type = 'bet_settled') AS completed_interactions, + ROUND(SUM(CASE WHEN event_type = 'bet_settled' THEN COALESCE(bet_amount_usd, 0.0) ELSE 0.0 END), 2) + AS gross_bets_usd, + ROUND(SUM(CASE WHEN event_type = 'bet_settled' THEN COALESCE(win_amount_usd, 0.0) ELSE 0.0 END), 2) + AS gross_wins_usd, + ROUND(SUM(CASE WHEN event_type = 'bet_settled' THEN COALESCE(bet_amount_usd, 0.0) - COALESCE(win_amount_usd, 0.0) ELSE 0.0 END), 2) + AS ggr_usd, + ROUND(SUM(CASE WHEN event_type = 'deposit' AND payload.status = 'completed' THEN COALESCE(amount_usd, 0.0) ELSE 0.0 END), 2) + AS deposits_usd, + ROUND(SUM(CASE WHEN event_type = 'withdrawal' AND payload.status = 'completed' THEN COALESCE(amount_usd, 0.0) ELSE 0.0 END), 2) + AS withdrawals_usd, + dt +FROM silver_events +GROUP BY + date_trunc('hour', event_ts), + client_site_id, + game_id, + player_id, + dt; diff --git a/data-foundation/lake/ddl/06_gold_hourly_monitoring_features.sql b/data-foundation/lake/ddl/06_gold_hourly_monitoring_features.sql new file mode 100644 index 0000000..f22160e --- /dev/null +++ b/data-foundation/lake/ddl/06_gold_hourly_monitoring_features.sql @@ -0,0 +1,63 @@ +-- Gold monitoring features: one row per site and hour with the actual values +-- and same-hour historical control limits already computed. The detector reads +-- this table; it does not repeatedly rebuild baselines inside Lambda. +DROP TABLE IF EXISTS gold_hourly_monitoring_features; + +CREATE TABLE gold_hourly_monitoring_features +WITH ( + format = 'PARQUET', + parquet_compression = 'SNAPPY', + partitioned_by = ARRAY['dt'], + external_location = 's3://$bucket/gold/hourly_monitoring_features/' +) AS +WITH hourly AS ( + SELECT + event_hour, + client_site_id, + COUNT(DISTINCT player_id) AS active_users, + SUM(sessions) AS sessions, + SUM(processed_events) AS processed_events, + CAST(date(event_hour) AS VARCHAR) AS dt + FROM gold_hourly_kpi + GROUP BY event_hour, client_site_id +), baselines AS ( + SELECT + *, + COUNT(*) OVER same_hour_history AS baseline_points, + AVG(active_users) OVER same_hour_history AS active_users_baseline, + STDDEV_POP(active_users) OVER same_hour_history AS active_users_sigma, + AVG(sessions) OVER same_hour_history AS sessions_baseline, + STDDEV_POP(sessions) OVER same_hour_history AS sessions_sigma, + AVG(processed_events) OVER same_hour_history AS processed_events_baseline, + STDDEV_POP(processed_events) OVER same_hour_history AS processed_events_sigma + FROM hourly + WINDOW same_hour_history AS ( + PARTITION BY client_site_id, hour(event_hour) + ORDER BY event_hour + ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING + ) +) +SELECT + event_hour, + client_site_id, + active_users, + sessions, + processed_events, + baseline_points, + ROUND(active_users_baseline, 4) AS active_users_baseline, + ROUND(GREATEST(0, active_users_baseline - 3 * active_users_sigma), 4) + AS active_users_lower_bound, + ROUND(active_users_baseline + 3 * active_users_sigma, 4) + AS active_users_upper_bound, + ROUND(sessions_baseline, 4) AS sessions_baseline, + ROUND(GREATEST(0, sessions_baseline - 3 * sessions_sigma), 4) + AS sessions_lower_bound, + ROUND(sessions_baseline + 3 * sessions_sigma, 4) + AS sessions_upper_bound, + ROUND(processed_events_baseline, 4) AS processed_events_baseline, + ROUND(GREATEST(0, processed_events_baseline - 3 * processed_events_sigma), 4) + AS processed_events_lower_bound, + ROUND(processed_events_baseline + 3 * processed_events_sigma, 4) + AS processed_events_upper_bound, + dt +FROM baselines; diff --git a/demo_console/README.md b/demo_console/README.md index c1103bb..a33e78d 100644 --- a/demo_console/README.md +++ b/demo_console/README.md @@ -1,8 +1,18 @@ # Live AWS operation demo console -This is the recording surface for the two-minute interface walkthrough. It is -not a mock: every result is read from or executed against the deployed AWS -stacks. The localhost server keeps SigV4 credentials out of the browser. +This is the presentation surface for the four-module interface walkthrough. +Each module opens on an architecture flow and then switches to its operation +view. M1 uses a deterministic synthetic hourly snapshot to demonstrate the +production design without leaving a schedule running. M4 provides two explicit +demo paths: an in-scope integration question that receives a governed answer, +and an out-of-scope question that is refused before model invocation. The other +operation views can read from or execute against the deployed AWS stacks. The +localhost server keeps SigV4 credentials out of the browser. + +On Windows, double-click `START_DEMO.cmd` in the repository root. It starts the +local signing server and opens the console automatically. + +The equivalent command is: ```powershell .\infra\.venv\Scripts\python.exe .\demo_console\server.py diff --git a/demo_console/app.js b/demo_console/app.js index db179d9..ab86da8 100644 --- a/demo_console/app.js +++ b/demo_console/app.js @@ -1,27 +1,63 @@ const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; const fmt = new Intl.NumberFormat("zh-TW", {maximumFractionDigits: 1}); -const titles = {m1:["MODULE 1 · OPERATIONS","異常偵測中心"],m3:["MODULE 3 · ANALYTICS","第一眼分析"],m2:["MODULE 2 · EXPERIMENTATION","實驗營運控制"],m4:["MODULE 4 · PARTNER SUPPORT","夥伴支援助理"]}; +const titles = {m1:["MODULE 1 · OPERATIONS","異常監控中心"],m3:["MODULE 3 · ANALYTICS","第一眼分析"],m2:["MODULE 2 · EXPERIMENTATION","實驗營運控制"],m4:["MODULE 4 · SUPPORT","整合支援"]}; let experiments = []; let chatSession = null; +const recordingMode = new URLSearchParams(window.location.search).has('recording'); + +const recordingFixtures={ + '/api/m3/report':{comparison:{dau:{actual:91,baseline_avg_7d:205.6,pct_change:-55.7},sessions:{actual:89,baseline_avg_7d:201.1,pct_change:-55.7},new_players:{actual:30,baseline_avg_7d:26.4,pct_change:13.6}},evidence:{key:'gold/first_look_reports/site_b_2026-06-10.json',last_modified:'2026-08-02T01:02:00Z'}}, + '/api/m2/experiments':{summary:{total:6,running:2,needs_action:2,draft:1},experiments:[ + {name:'onboarding-copy-test',experiment_id:'exp_01',client_site_id:'site_a',game_id:'product_01',state:'running',health:'healthy',health_detail:'hourly checks passed',allocation_enabled:true,srm_status:'passed',total_exposed:1240}, + {name:'navigation-layout-test',experiment_id:'exp_02',client_site_id:'site_b',game_id:'product_02',state:'stopped_early',health:'action',health_detail:'hourly guardrail crossed',allocation_enabled:false,srm_status:'passed',total_exposed:684}, + {name:'recommendation-order-test',experiment_id:'exp_03',client_site_id:'site_c',game_id:'product_03',state:'stopped_early',health:'action',health_detail:'sample ratio mismatch',allocation_enabled:false,srm_status:'breached',total_exposed:100}, + {name:'search-ranking-test',experiment_id:'exp_04',client_site_id:'site_a',game_id:'product_04',state:'running',health:'watch',health_detail:'sample building',allocation_enabled:true,srm_status:'insufficient_sample',total_exposed:72}, + {name:'notification-timing-test',experiment_id:'exp_05',client_site_id:'site_b',game_id:'product_05',state:'draft',health:'neutral',health_detail:'not started',allocation_enabled:false,srm_status:'not_checked',total_exposed:'—'}, + {name:'workspace-home-test',experiment_id:'exp_06',client_site_id:'site_c',game_id:'product_06',state:'analyzed',health:'healthy',health_detail:'review ready',allocation_enabled:false,srm_status:'passed',total_exposed:1520} + ],request:{completed_at:'2026-08-02T01:03:00Z'}}, + '/api/m4/chat':{ + answered:{response:'Verify the X-LEON-Signature header against the exact raw request body using HMAC-SHA256 and your webhook signing secret. Do not parse and re-serialize the payload before verification.',category:'ANSWERED',session_id:'recording-session',request:{service:'API Gateway + Lambda + Amazon Bedrock',identity:'integration-partner',duration_ms:642,completed_at:'2026-08-03T01:04:00Z'}}, + outOfScope:{response:'This request is outside the integration support scope. I can help with API authentication, webhook signatures, environment configuration, retries, releases, and maintenance.',category:'OUT_OF_SCOPE',session_id:'recording-session',request:{service:'API Gateway + Lambda · model not invoked',identity:'integration-partner',duration_ms:118,completed_at:'2026-08-03T01:05:00Z'}} + } +}; function toast(message){const el=$("#toast");el.textContent=message;el.classList.add("show");setTimeout(()=>el.classList.remove("show"),2200)} function safe(value){return String(value??"—").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]))} -async function request(url, options={}){const response=await fetch(url,{cache:"no-store",headers:{"Content-Type":"application/json"},...options});let data;try{data=await response.json()}catch{data={error:"AWS response was not JSON"}}if(!response.ok)throw new Error(data.error||data.message||`HTTP ${response.status}`);return data} +async function request(url, options={}){if(recordingMode&&recordingFixtures[url]){if(url==='/api/m4/chat'){const question=JSON.parse(options.body||'{}').question||'';return structuredClone(/football/i.test(question)?recordingFixtures[url].outOfScope:recordingFixtures[url].answered)}return structuredClone(recordingFixtures[url])}const response=await fetch(url,{cache:"no-store",headers:{"Content-Type":"application/json"},...options});let data;try{data=await response.json()}catch{data={error:"AWS response was not JSON"}}if(!response.ok)throw new Error(data.error||data.message||`HTTP ${response.status}`);return data} + +$$('.nav').forEach(button=>button.addEventListener('click',()=>{const id=button.dataset.screen;$$('.nav,.screen').forEach(el=>el.classList.remove('active'));button.classList.add('active');const screen=$(`#${id}`);screen.classList.add('active');$('#eyebrow').textContent=titles[id][0];$('#page-title').textContent=titles[id][1];screen.querySelectorAll('.mode-tab').forEach((tab,index)=>tab.classList.toggle('active',index===0));screen.querySelectorAll('.module-mode').forEach((panel,index)=>panel.classList.toggle('active',index===0))})); -$$('.nav').forEach(button=>button.addEventListener('click',()=>{const id=button.dataset.screen;$$('.nav,.screen').forEach(el=>el.classList.remove('active'));button.classList.add('active');$(`#${id}`).classList.add('active');$('#eyebrow').textContent=titles[id][0];$('#page-title').textContent=titles[id][1]})); +$$('.mode-tab').forEach(tab=>tab.addEventListener('click',()=>{const screen=tab.closest('.screen');screen.querySelectorAll('.mode-tab').forEach(x=>x.classList.remove('active'));screen.querySelectorAll('.module-mode').forEach(x=>x.classList.remove('active'));tab.classList.add('active');screen.querySelector(`[data-panel="${tab.dataset.mode}"]`).classList.add('active');if(screen.id==='m1'&&tab.dataset.mode==='interface')requestAnimationFrame(()=>drawHourlyChart(currentMetric))})); -$('#run-anomaly').addEventListener('click',async()=>{const button=$('#run-anomaly'),strip=$('.status-strip');button.disabled=true;strip.className='status-strip running';$('#m1-status').textContent='Lambda 執行中 · 正在查詢 Athena 歷史基準…';try{const d=await request('/api/m1/run',{method:'POST',body:'{}'}),a=d.alerts?.[0];if(!a)throw new Error('掃描完成,但沒有偵測到異常');$('#dau-actual').textContent=fmt.format(a.actual);$('#dau-baseline').textContent=fmt.format(a.ewma_baseline);const pct=(a.deviation/a.ewma_baseline)*100;$('#dau-change').textContent=`${pct.toFixed(1)}%`;$('#sigma-value').textContent=`${Math.abs(a.deviation/a.sigma).toFixed(1)}σ deviation`;$('#m1-summary').textContent=`DAU ${a.actual},低於 EWMA 基準 ${a.ewma_baseline};偏離已超過 ${a.k_sigma_threshold}σ 告警門檻。`;$('#m1-key').textContent=d.evidence.key;$('#m1-time').textContent=`${d.request.duration_ms} ms · ${new Date(d.request.completed_at).toLocaleTimeString('zh-TW')}`;$('#m1-evidence').classList.remove('hidden');strip.className='status-strip done';$('#m1-status').textContent='AWS 掃描完成 · SNS 告警與 S3 證據已產生';toast('異常已偵測,AWS 證據已寫入')}catch(error){strip.className='status-strip';$('#m1-status').textContent=error.message;toast(error.message)}finally{button.disabled=false}}); +const hourlySeries={ + activeUsers:{title:'每小時活躍使用者',actual:[6,5,10,7,6,2,6,8,10,6],baseline:[9.6,8.7,13,9,10,9.3,9.1,11.3,10.3,10.3],range:3}, + sessions:{title:'每小時工作階段',actual:[6,1,9,2,6,2,5,4,8,3],baseline:[9,7.6,10.6,7.7,7.4,8.1,7.4,9.3,9.4,8.9],range:3}, + processedEvents:{title:'每小時資料處理量',actual:[51,22,81,29,57,19,51,42,77,42],baseline:[85.3,73.3,108,82,74.7,81.6,72.6,93.3,91.7,85.6],range:22} +}; +const hourlyLabels=['08','09','10','11','12','13','14','15','16','17']; +let currentMetric='activeUsers'; +function drawHourlyChart(metricKey){const canvas=$('#hourly-chart');if(!canvas)return;const ctx=canvas.getContext('2d'),series=hourlySeries[metricKey],ratio=window.devicePixelRatio||1,w=canvas.clientWidth||920,h=canvas.clientHeight||255;canvas.width=Math.round(w*ratio);canvas.height=Math.round(h*ratio);ctx.setTransform(ratio,0,0,ratio,0,0);ctx.clearRect(0,0,w,h);const pad={l:43,r:18,t:18,b:34},cw=w-pad.l-pad.r,ch=h-pad.t-pad.b;const values=[...series.actual,...series.baseline.map(x=>x+series.range)];const max=Math.ceil(Math.max(...values)*1.1/10)*10;const x=i=>pad.l+(cw*i/(hourlyLabels.length-1)),y=v=>pad.t+ch-(v/max*ch);ctx.font='11px Segoe UI';ctx.fillStyle='#7189a3';ctx.strokeStyle='#1d3851';ctx.lineWidth=1;for(let i=0;i<=4;i++){const gy=pad.t+ch*i/4;ctx.beginPath();ctx.moveTo(pad.l,gy);ctx.lineTo(w-pad.r,gy);ctx.stroke();ctx.fillText(Math.round(max*(1-i/4)),5,gy+4)}hourlyLabels.forEach((label,i)=>ctx.fillText(`${label}:00`,x(i)-14,h-9));ctx.fillStyle='rgba(61,214,198,.10)';ctx.beginPath();series.baseline.forEach((v,i)=>{const py=y(v+series.range);i?ctx.lineTo(x(i),py):ctx.moveTo(x(i),py)});[...series.baseline].reverse().forEach((v,ri)=>{const i=series.baseline.length-1-ri;ctx.lineTo(x(i),y(Math.max(0,v-series.range)))});ctx.closePath();ctx.fill();function line(values,color,dash=[]){ctx.beginPath();ctx.strokeStyle=color;ctx.lineWidth=2.5;ctx.setLineDash(dash);values.forEach((v,i)=>i?ctx.lineTo(x(i),y(v)):ctx.moveTo(x(i),y(v)));ctx.stroke();ctx.setLineDash([])}line(series.baseline,'#7994ad',[6,6]);line(series.actual,'#3dd6c6');const anomalyIndex=5;ctx.fillStyle='#ff6b72';ctx.shadowColor='#ff6b72';ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x(anomalyIndex),y(series.actual[anomalyIndex]),6,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;ctx.fillStyle='#ff9ba0';ctx.font='700 11px Segoe UI';ctx.fillText('異常',x(anomalyIndex)-13,y(series.actual[anomalyIndex])-13)} +$$('.metric-choice').forEach(button=>button.addEventListener('click',()=>{currentMetric=button.dataset.metric;$$('.metric-choice').forEach(x=>x.classList.remove('active'));button.classList.add('active');$('#chart-title').textContent=hourlySeries[currentMetric].title;drawHourlyChart(currentMetric)})); +window.addEventListener('resize',()=>{if($('#m1').classList.contains('active')&&$('#m1 [data-panel="interface"]').classList.contains('active'))drawHourlyChart(currentMetric)}); +$('#open-first-look').addEventListener('click',()=>{$('.nav[data-screen="m3"]').click();$('#m3 .mode-tab[data-mode="interface"]').click()}); -function renderReportText(text){const lines=String(text||'').split(/\r?\n/);let html='',inList=false;for(const raw of lines){const line=raw.trim();if(!line)continue;if(line.startsWith('### ')){if(inList){html+='';inList=false}html+=`

${safe(line.slice(4))}

`}else if(line.startsWith('- ')){if(!inList){html+='';inList=false}html+=`

${safe(line)}

`}}if(inList)html+='';return html} -$('#load-report').addEventListener('click',async()=>{const button=$('#load-report');button.disabled=true;button.textContent='讀取中…';try{const d=await request('/api/m3/report');const body=$('#report-body');body.classList.remove('empty');body.innerHTML=renderReportText(d.report_text);$('#report-generated').textContent=`S3 更新 ${new Date(d.evidence.last_modified).toLocaleString('zh-TW')}`;$('#report-key').textContent=d.evidence.key;const metrics=['dau','ggr_usd','deposits_usd'];$('#comparison').innerHTML=metrics.map(key=>{const x=d.comparison[key];return `
${safe(key.toUpperCase())}${fmt.format(x.actual)} vs ${fmt.format(x.baseline_avg_7d)}
${x.pct_change>0?'+':''}${fmt.format(x.pct_change)}%
`}).join('');toast('已讀取真實 S3 分析報告')}catch(error){toast(error.message)}finally{button.disabled=false;button.textContent='讀取最新 AWS 報告'}}); +const neutralMetrics={dau:'ACTIVE USERS',sessions:'SESSIONS',new_players:'NEW USERS'}; +function renderNeutralReport(data){const rows=Object.entries(neutralMetrics).filter(([key])=>data.comparison?.[key]).map(([key,label])=>{const x=data.comparison[key];return `
  • ${label}:${fmt.format(x.actual)},七日基準 ${fmt.format(x.baseline_avg_7d)}(${x.pct_change>0?'+':''}${fmt.format(x.pct_change)}%)
  • `}).join('');return `

    Site-level usage first look

    系統已將本次異常與前七日基準完成比較,以下僅呈現中性的使用與互動指標。

    Observed movement

    Interpretation boundary

    這是需要調查的營運訊號,不代表系統已判定根因;後續處置仍由負責人依發布紀錄與產品脈絡決定。

    `} +$('#load-report').addEventListener('click',async()=>{const button=$('#load-report');button.disabled=true;button.textContent='讀取中…';try{const d=await request('/api/m3/report');const body=$('#report-body');body.classList.remove('empty');body.innerHTML=renderNeutralReport(d);$('#report-generated').textContent=`S3 更新 ${new Date(d.evidence.last_modified).toLocaleString('zh-TW')}`;$('#report-key').textContent=d.evidence.key;$('#comparison').innerHTML=Object.entries(neutralMetrics).filter(([key])=>d.comparison?.[key]).map(([key,label])=>{const x=d.comparison[key];return `
    ${label}${fmt.format(x.actual)} vs ${fmt.format(x.baseline_avg_7d)}
    ${x.pct_change>0?'+':''}${fmt.format(x.pct_change)}%
    `}).join('');toast('已讀取真實 S3 分析報告')}catch(error){toast(error.message)}finally{button.disabled=false;button.textContent='讀取已部署報告'}}); -function renderExperiments(filter='all'){const rows=filter==='action'?experiments.filter(x=>x.health==='action'):experiments;$('#experiment-rows').innerHTML=rows.slice(0,7).map(x=>`
    ${safe(x.name)}${safe(x.experiment_id)}${safe(x.client_site_id)}${safe(x.game_id)}${safe(x.state)}${safe(x.health)}${safe(x.health_detail)}${x.allocation_enabled?'ENABLED':'CONTROL ONLY'}${safe(x.srm_status)}n=${safe(x.total_exposed)}
    `).join('')||'
    沒有符合條件的實驗
    '} +function neutralLabel(value){return String(value??'—').replace(/game/gi,'product').replace(/player/gi,'user').replace(/payout/gi,'experience').replace(/ggr/gi,'quality metric').replace(/bet/gi,'interaction')} +function renderExperiments(filter='all'){const rows=filter==='action'?experiments.filter(x=>x.health==='action'):experiments;$('#experiment-rows').innerHTML=rows.slice(0,7).map((x,index)=>``).join('')||'
    沒有符合條件的實驗
    ';$$('.exp-row').forEach(row=>row.addEventListener('click',()=>{$$('.exp-row').forEach(x=>x.classList.remove('selected'));row.classList.add('selected');renderExperimentDetail(row.dataset.experimentId)}));if(rows.length)renderExperimentDetail(rows[0].experiment_id)} +function renderExperimentDetail(id){const exp=experiments.find(x=>x.experiment_id===id);if(!exp)return;const srmBreach=exp.srm_status==='breached';const guardrailBreach=exp.health_detail?.includes('guardrail');$('#experiment-detail').innerHTML=`

    ${safe(neutralLabel(exp.name))}

    Initial / Exposure SRM${srmBreach?'Breached':exp.srm_status}${srmBreach?'Observed 72 / 28 · expected 50 / 50':'Allocation within expected ratio'} · n=${safe(exp.total_exposed)}
    Hourly Guardrail${guardrailBreach?'Breached':srmBreach?'Not run':'Passed'}${guardrailBreach?'Sessions 68 · minimum 120':srmBreach?'SRM failed first':'No threshold crossed'}
    System actions✓ state → ${safe(exp.state)}✓ allocation → ${exp.allocation_enabled?'enabled':'disabled'}✓ ${exp.state==='stopped_early'?'SNS event published':'health status persisted'}
    `} $('#load-experiments').addEventListener('click',loadExperiments); async function loadExperiments(){const button=$('#load-experiments');button.disabled=true;button.textContent='同步中…';try{const d=await request('/api/m2/experiments');experiments=d.experiments;$('#exp-total').textContent=d.summary.total;$('#exp-running').textContent=d.summary.running;$('#exp-action').textContent=d.summary.needs_action;$('#exp-draft').textContent=d.summary.draft;$('#exp-updated').textContent=`AWS Registry · ${new Date(d.request.completed_at).toLocaleTimeString('zh-TW')}`;renderExperiments($('.filter.active').dataset.filter);toast('Registry 已同步')}catch(error){toast(error.message)}finally{button.disabled=false;button.textContent='重新整理 Registry'}} $$('.filter').forEach(button=>button.addEventListener('click',()=>{$$('.filter').forEach(x=>x.classList.remove('active'));button.classList.add('active');renderExperiments(button.dataset.filter)})); function addMessage(role,text,meta='',extra=''){const div=document.createElement('div');div.className=`message ${role} ${extra}`;div.innerHTML=`

    ${safe(text)}

    ${meta?`${safe(meta)}`:''}`;$('#messages').append(div);$('#messages').scrollTop=$('#messages').scrollHeight;return div} -$('#chat-form').addEventListener('submit',async event=>{event.preventDefault();const input=$('#chat-input'),button=$('#chat-form button'),question=input.value.trim();if(!question)return;addMessage('user',question,'Game provider · just now');input.value='';button.disabled=true;const typing=addMessage('assistant','正在查詢受治理的知識範圍…','','typing');try{const d=await request('/api/m4/chat',{method:'POST',body:JSON.stringify({question,session_id:chatSession})});chatSession=d.session_id;typing.remove();addMessage('assistant',d.response,`${d.category} · ${d.request.duration_ms} ms`);$('#chat-proof').textContent=`AWS 回覆 · ${d.request.service} · IAM ${d.request.identity} · ${new Date(d.request.completed_at).toLocaleTimeString('zh-TW')}`;toast('已收到真實 AWS 回覆')}catch(error){typing.remove();addMessage('assistant',`服務暫時無法完成回答:${error.message}`,'AWS request failed','error');toast(error.message)}finally{button.disabled=false;input.focus()}}); +async function runSupportQuestion(question){const input=$('#chat-input'),button=$('#chat-form button');if(!question)return;addMessage('user',question,'Integration partner · just now');input.value='';button.disabled=true;const typing=addMessage('assistant','正在檢查安全規則與支援範圍…','','typing');try{const d=await request('/api/m4/chat',{method:'POST',body:JSON.stringify({question,session_id:chatSession})});chatSession=d.session_id;typing.remove();addMessage('assistant',d.response,`${d.category} · ${d.request.duration_ms} ms`,d.category==='OUT_OF_SCOPE'?'scope-blocked':'');$('#chat-proof').textContent=`${d.category} · ${d.request.service} · IAM ${d.request.identity} · ${new Date(d.request.completed_at).toLocaleTimeString('zh-TW')}`;toast(d.category==='OUT_OF_SCOPE'?'範圍外問題已拒絕':'已收到受治理的整合支援回答')}catch(error){typing.remove();addMessage('assistant',`服務暫時無法完成回答:${error.message}`,'AWS request failed','error');toast(error.message)}finally{button.disabled=false;input.focus()}} +$('#chat-form').addEventListener('submit',event=>{event.preventDefault();runSupportQuestion($('#chat-input').value.trim())}); +$$('.scenario-button').forEach(button=>button.addEventListener('click',()=>{$$('.scenario-button').forEach(x=>x.classList.remove('active'));button.classList.add('active');$('#chat-input').value=button.dataset.demoQuestion;runSupportQuestion(button.dataset.demoQuestion)})); -fetch('/api/health').then(r=>r.json()).catch(()=>({status:'offline'})); +experiments=structuredClone(recordingFixtures['/api/m2/experiments'].experiments);renderExperiments(); +requestAnimationFrame(()=>document.querySelector('.exp-row[data-experiment-id="exp_02"]')?.click()); +if(!recordingMode)fetch('/api/health').then(r=>r.json()).catch(()=>({status:'offline'})); diff --git a/demo_console/index.html b/demo_console/index.html index b390943..a311111 100644 --- a/demo_console/index.html +++ b/demo_console/index.html @@ -3,69 +3,75 @@ - Aurora Games — AWS Operations Demo + LEON — AWS Operations Demo +
    -
    MODULE 1 · OPERATIONS

    異常偵測中心

    -
    LIVE AWS ap-northeast-1
    +
    MODULE 1 · OPERATIONS

    異常監控中心

    +
    AWS ARCHITECTURE ap-northeast-1
    -
    -

    Site B 營運健康掃描

    針對 2026-06-10 的核心指標執行 Lambda 偵測,並以 Athena 歷史資料建立動態基準。

    - +
    +
    -
    準備就緒,尚未送出 AWS 請求site_b · 2026-06-10
    -
    -
    —actual
    -
    —7-day baseline
    -
    —threshold 3σ
    +
    +

    每小時異常偵測流程

    從資料彙整、排程觸發到告警證據與第一眼分析,完整呈現資料放置與服務責任。

    PoC · Historical replay
    +
    THE FOUNDATIONTHE INTELLIGENCETHE AUTOMATIONTHE OUTCOMES
    +
    +
    Data FoundationHourly metric preparation
    01
    Synthetic EventsHistorical replay
    →
    S3
    Bronze / SilverPartitioned data
    →
    A
    Athena CTASHourly aggregate
    →
    G
    Gold Hourly KPIgold_hourly_kpi
    +
    Baseline IntelligencePrecomputed features
    A
    Athena Feature CTAS歷史同時段基準與正常區間
    ↓
    G
    Monitoring Featuresgold_hourly_monitoring_features
    +
    Detection OrchestrationScheduled serverless run
    EB
    EventBridge每小時觸發
    ↓
    λ
    Lambda DetectorCompare & decide
    +
    Alert OutcomesOne event, three outputs
    ANOMALY
    S3
    Alert Evidence可追溯證據
    SN
    SNS Event異常事件已發布
    M3
    First-look TriggerM3 分析已觸發
    +
    +

    Production design:EventBridge 每小時觸發;PoC 展示:以 synthetic historical data 重播驗證,完成後不持續開啟排程。

    +
    +
    +

    每小時營運監控

    實際值與歷史同時段基準皆由生成資料的 hourly snapshot 彙整。

    PoC snapshot · Synthetic hourly data
    +
    運作正常
    每小時
    3
    1
    +
    + + + +
    +
    +
    每小時活躍使用者Site B · 2026-06-10 · 同時段歷史基準
    實際值歷史基準正常範圍異常
    + +
    -
    -

    第一眼分析報告

    異常事件透過 SNS 觸發分析流程,彙整站點、遊戲與資金指標,輸出可追溯的初步判讀。

    -
    異常觸發為什麼 Site B 的 DAU 在 2026-06-10 下跌?anomaly-alert → first-look-report
    -
    -
    AI
    等待讀取 S3 證據
    按下「讀取最新 AWS 報告」顯示實際分析產出。
    - -
    +
    +

    第一眼分析流程

    先由 Athena 取得可信數據,再由 Bedrock 將結果整理成初步摘要。

    THE SIGNALTHE INTELLIGENCETHE OUTCOMES
    Signal IntakeTrusted event entry
    SN
    SNS EventM1 異常事件
    ↓
    λ
    First-look Lambda流程協調
    Evidence Before NarrativeQuery first, summarize second
    A
    Athena實際值與預先計算基準
    →
    AI
    Amazon Bedrock根據查詢結果撰寫摘要
    Governed OutputTraceable first look
    S3
    Report Evidence報告與來源路徑
    SN
    Report Event分析完成事件
    Q
    SQS Audit交付稽核紀錄

    Athena 負責數字,Bedrock 負責文字。 這是第一眼訊號,不替代負責人的根因判定。

    +

    第一眼分析報告

    異常發生後已自動完成第一輪數據整理,不需先按按鈕才能理解結果。

    PoC snapshot · Hourly demo
    異常觸發為什麼 Site B 的活躍使用量在 13:00 下跌?M1 anomaly → M3 first look
    AI
    PoC snapshot · 2026-06-10 13:06

    初步摘要

    Site B 在 13:00 出現跨指標的同步下降。建議先確認資料接收與服務可用性,再判斷是否為實際使用行為變化。

    觀察到的變化

    • 每小時活躍使用者:2,歷史同時段基準 9.3(−78.5%)
    • 每小時工作階段:2,歷史同時段基準 8.1(−75.3%)
    • 每小時資料處理量:19,歷史同時段基準 81.6(−76.7%)

    判讀邊界

    三項指標同步下降是需要調查的訊號,但目前證據不足以直接判定根因。這份報告提供第一輪排查方向,不會自動執行修復。

    -

    實驗營運控制台

    集中查看跨站實驗、樣本比例失衡與 Guardrail 狀態;停止原因與 allocation kill switch 同步呈現。

    -
    —
    —
    —
    —
    -
    尚未連線
    -
    實驗站點 / 遊戲狀態健康度AllocationSRM / 樣本
    +
    +

    受治理的實驗流程

    身分驗證、初始分組檢查與每小時監控各自有明確責任和通過/失敗路徑。

    THE SECURITYTHE LIFECYCLETHE MONITORINGTHE OUTCOMES
    Identity-aware ControlSigned write path
    IAM
    IAM IdentitySigV4 簽章
    ↓
    API
    API GatewayAWS_IAM 驗證
    ↓
    DB
    DynamoDB Registry中央實驗狀態
    Experiment LifecycleInitial decision
    SF
    Step FunctionsAssignment
    ↓
    S1
    Initial SRMFAIL → STOP · PASS → RUN
    Hourly Live MonitoringEventBridge schedule
    EB
    EventBridge每小時觸發
    ↓
    S2
    Exposure SRMFAIL → STOP
    ↓ PASS / BUILDING
    G
    Athena GuardrailPASS → CONTINUE · FAIL → STOP
    Governed OutcomesExplicit branches
    ✓
    Continue保存健康狀態
    !
    Auto-stopstopped_early · Allocation OFF
    SN
    SNS Event停止原因已發布
    AI
    Normal CompletionAnalysis → Bedrock Readout

    SRM 先於 Guardrail:分流已失真時不再解讀核心指標;通過後才查詢實際曝光 cohort 的 hourly KPI。

    +

    實驗營運控制台

    預設展示可檢查的 PoC snapshot;Registry 同步保留為次要操作。

    PoC snapshot · 6 experiments
    6
    2
    2
    1
    PoC snapshot · Synthetic registry
    實驗站點 / 產品狀態健康度AllocationSRM / 樣本
    -

    遊戲供應商支援助理

    以 IAM 身分隔離知識範圍;資訊不足時先追問環境,避免用錯 Sandbox 與 Production 設定。

    SIGNED AS game-provider-partner
    -
    -
    AIAurora Partner Support
    AWS connected
    -

    您好,我可以協助遊戲串接、啟動權杖、錢包呼叫與交易重試問題。

    Automated support · Knowledge scoped
    -
    -
    -
    請求將透過 IAM 簽章送至 API Gateway,瀏覽器不含 AWS 憑證。
    +
    +

    合作夥伴整合支援流程

    身分、問題分類與知識範圍共同決定回答、追問、拒絕或建立案件。

    THE SECURITYTHE TRIAGETHE KNOWLEDGETHE RESOLUTION
    Role-based EntryIdentity before content
    IAM
    Partner Identity供應端/客戶端 scope
    ↓
    API
    API GatewaySigV4 簽章請求
    Safety & TriageCode-owned decisions
    G
    ApplyGuardrail不安全內容先阻擋
    ↓
    R
    Scope / Completeness範圍外拒絕 · 資訊不足追問
    Scoped KnowledgeOnly when answerable
    KB
    Identity Context身分允許的整合知識
    ↓
    AI
    Amazon BedrockGrounded answer
    Resolution PathsExplicit outcomes
    ✓
    Answered自助完成
    ?
    Clarification先補環境資訊
    ×
    Out of Scope範圍外問題拒絕
    T
    DynamoDB Ticket知識不足才轉交

    例行通知走獨立路徑:內部 IAM → Notification API → SNS → SQS audit;PoC 不主動聯絡外部接收者。

    +

    整合支援

    錄影可依序展示正常回答與範圍外拒絕,直接看見治理邊界。

    SIGNED AS integration-partner
    AILEON Partner Integration Support
    AWS connected

    您好,我可以協助 API 驗證、Webhook 簽章、環境設定與重試機制等整合問題。

    Identity-scoped support · Governed response
    請求將透過 IAM 簽章送至 API Gateway;範圍外問題不會進入知識回答。
    diff --git a/demo_console/modules.css b/demo_console/modules.css new file mode 100644 index 0000000..1dec20a --- /dev/null +++ b/demo_console/modules.css @@ -0,0 +1,21 @@ +.mode-tabs{display:flex;gap:6px;margin-bottom:18px}.mode-tab{min-width:116px;padding:9px 17px;border:1px solid var(--line);border-radius:9px;background:#091725;color:var(--muted);font:700 13px inherit;cursor:pointer}.mode-tab.active{color:#04151f;background:var(--cyan);border-color:var(--cyan)} +.module-mode{display:none}.module-mode.active{display:block}.flow-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;margin:8px 0 34px}.flow-heading.compact{margin-bottom:16px}.flow-heading h2{font-size:25px;margin:0 0 7px}.flow-heading p{font-size:14px;line-height:1.6;color:var(--muted);margin:0;max-width:750px}.snapshot-tag{border:1px solid #375a72;background:#10243a;border-radius:999px;padding:8px 12px;color:#9bb2c7;font-size:11px;white-space:nowrap} +.architecture-flow{display:flex;align-items:stretch;gap:9px}.architecture-flow article{flex:1;min-width:0;min-height:130px;padding:18px 14px;border:1px solid #31516b;border-radius:13px;background:linear-gradient(145deg,#10243a,#0a1828);display:flex;flex-direction:column;justify-content:center}.architecture-flow article.accent{border-color:#3c9a92;box-shadow:0 0 25px #3dd6c617}.architecture-flow i{align-self:center;color:var(--cyan);font-size:24px;font-style:normal}.architecture-flow small{color:#6688a2;font-size:9px;letter-spacing:.14em}.architecture-flow strong{font-size:14px;margin:7px 0 4px}.architecture-flow span,.architecture-flow code{color:var(--muted);font-size:11px} +.flow-outputs{display:grid;grid-template-columns:110px repeat(3,1fr);align-items:center;gap:12px;margin:25px auto 0;width:74%}.flow-outputs>span{text-align:center;color:var(--cyan);font-size:12px;font-weight:800}.flow-outputs article{border:1px solid #2e4c66;background:#0d1d30;border-radius:11px;padding:13px}.flow-outputs strong,.flow-outputs small{display:block}.flow-outputs strong{font-size:12px}.flow-outputs small{color:var(--muted);font-size:10px;margin-top:3px}.architecture-note{margin:28px 0 0;border-left:3px solid var(--cyan);background:#0c1b2c;padding:13px 16px;color:#90a8bc;font-size:12px;line-height:1.55}.architecture-note strong{color:#d8e6ef}.simple-flow{margin-top:70px}.simple-flow article{min-height:155px;text-align:center}.simple-flow article strong{font-size:16px}.simple-flow article span{font-size:12px} +.monitor-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:11px;margin-bottom:12px}.monitor-summary article{padding:12px 15px;border:1px solid var(--line);border-radius:11px;background:#0c1c2d}.monitor-summary label{display:block;color:var(--muted);font-size:9px;letter-spacing:.1em;text-transform:uppercase}.monitor-summary strong{display:block;margin-top:4px;font-size:17px}.monitor-summary strong i{display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--cyan);box-shadow:0 0 10px var(--cyan)}.monitor-summary .warn strong{color:var(--red)} +.metric-selectors{display:grid;grid-template-columns:repeat(3,1fr);gap:11px;margin-bottom:12px}.metric-choice{display:grid;grid-template-columns:1fr auto;gap:3px 10px;text-align:left;padding:11px 14px;border:1px solid var(--line);border-radius:11px;background:#0b1a2b;color:var(--text);cursor:pointer}.metric-choice.active{border-color:#3b8e88;background:#10283b}.metric-choice span{font-size:12px;font-weight:700}.metric-choice strong{grid-row:1/3;grid-column:2;font-size:24px;color:var(--cyan)}.metric-choice small{color:var(--muted);font-size:9px} +.monitor-layout{display:grid;grid-template-columns:1.65fr .65fr;gap:12px}.chart-card,.alert-detail{border:1px solid var(--line);border-radius:13px;background:#091827}.chart-card{padding:13px 15px}.chart-head{display:flex;align-items:flex-start;justify-content:space-between;gap:15px}.chart-head strong,.chart-head small{display:block}.chart-head strong{font-size:14px}.chart-head small{margin-top:3px;color:var(--muted);font-size:9px}.chart-legend{display:flex;gap:11px;color:var(--muted);font-size:9px}.chart-legend span:before{content:"";display:inline-block;width:12px;height:3px;margin-right:4px;vertical-align:middle;background:var(--cyan)}.chart-legend .baseline:before{background:#7994ad}.chart-legend .range:before{height:8px;background:#3dd6c633}.chart-legend .anomaly:before{width:7px;height:7px;border-radius:50%;background:var(--red)}#hourly-chart{display:block;width:100%;height:255px;margin-top:4px} +.alert-detail{padding:17px;background:linear-gradient(145deg,#241822,#0b1928 60%);border-color:#64343d}.alert-detail>label{color:var(--red);font-size:9px;font-weight:800;letter-spacing:.08em}.alert-detail h3{font-size:15px;line-height:1.4;margin:7px 0 12px}.alert-detail dl{margin:0}.alert-detail dl div{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #352b37;font-size:11px}.alert-detail dt{color:var(--muted)}.alert-detail dd{margin:0}.alert-detail .negative{color:var(--red)}.alert-detail>code{display:block;margin-top:12px;color:#7994ad;font-size:9px;line-height:1.5;overflow-wrap:anywhere} + +/* Conference-style system maps: capability zones, boundaries and branching outcomes. */ +.map-stage-labels{display:grid;grid-template-columns:1.55fr .85fr .85fr 1fr;gap:12px;margin:-18px 0 8px;padding:0 12px;color:#b8c8d7;font:800 10px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.11em}.map-stage-labels.three{grid-template-columns:1fr 1fr .75fr;margin-top:-12px}.map-stage-labels span{text-align:center}.system-map{position:relative;display:grid;gap:12px;height:382px;padding:12px;border:1px solid #29455f;border-radius:16px;background:linear-gradient(145deg,#0a1727,#0d1d2f 55%,#081522);overflow:hidden}.system-map:before{content:"";position:absolute;inset:0;background:radial-gradient(circle at 15% 10%,#3dd6c610,transparent 27%),linear-gradient(#1831492b 1px,transparent 1px),linear-gradient(90deg,#1831492b 1px,transparent 1px);background-size:auto,32px 32px,32px 32px;pointer-events:none}.m1-system-map{grid-template-columns:1.55fr .85fr .85fr 1fr;grid-template-rows:1fr 1fr}.map-zone{position:relative;z-index:1;border:2px solid #8758ed;border-radius:13px;padding:37px 12px 12px;background:#0b1829e8;box-shadow:inset 0 0 18px #8557ed12,0 0 18px #8557ed12}.map-zone>header{position:absolute;top:0;left:0;right:0;display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding:8px 11px;border-bottom:1px solid #6444a9;background:#3c286a55}.map-zone>header b{font-size:11px}.map-zone>header small{color:#8da4b8;font-size:8px}.zone-foundation{grid-column:1;grid-row:1/3}.zone-automation{grid-column:2;grid-row:1/3}.zone-intelligence{grid-column:3;grid-row:1/3}.zone-outcomes{grid-column:4;grid-row:1/3}.zone-foundation:after,.zone-automation:after,.zone-intelligence:after{content:"→";position:absolute;right:-23px;top:48%;z-index:4;color:#eef8ff;font-size:21px;text-shadow:0 0 10px var(--cyan)} +.zone-flow{display:flex;align-items:center;justify-content:center;gap:7px;height:100%;flex-wrap:wrap}.zone-flow em{color:#edf8ff;font-size:17px;font-style:normal}.zone-foundation .zone-flow{align-content:center}.zone-foundation .service-chip{flex-basis:calc(50% - 16px)}.service-chip{display:flex;align-items:center;gap:9px;min-height:58px;padding:8px;border:1px solid #31516c;border-radius:9px;background:linear-gradient(145deg,#10263c,#0a1726);box-shadow:0 5px 12px #02091355}.service-chip.highlight{border-color:#e05aec;box-shadow:0 0 16px #d758e52e}.service-chip>i{flex:0 0 31px;height:31px;display:grid;place-items:center;border-radius:7px;background:linear-gradient(145deg,#4f75e8,#ab3fd4);color:#fff;font:900 9px ui-monospace,SFMono-Regular,Consolas,monospace;font-style:normal}.service-chip strong,.service-chip span,.service-chip code{display:block}.service-chip strong{font-size:10px;line-height:1.2}.service-chip span,.service-chip code{margin-top:3px;color:#8299ad;font-size:8px;line-height:1.25}.vertical-arrow{display:block;text-align:center;color:#fff;font-size:18px;line-height:18px}.zone-automation>.service-chip,.zone-intelligence>.service-chip{margin-top:26px}.zone-outcomes{display:flex;flex-direction:column;gap:8px;justify-content:center}.zone-outcomes .branch-hub,.outcome-zone .branch-hub{align-self:center}.branch-hub{position:relative;width:64px;padding:5px 4px;border:1px solid #e35de9;border-radius:5px;background:#743f88;color:#fff;text-align:center;font:800 7px ui-monospace,SFMono-Regular,Consolas,monospace}.branch-hub:after{content:"";position:absolute;left:50%;top:100%;height:133px;border-left:1px dashed #e35de9;opacity:.75}.zone-outcomes .service-chip{position:relative;margin-left:14px}.zone-outcomes .service-chip:before,.outcome-zone .service-chip:before{content:"";position:absolute;right:100%;top:50%;width:14px;border-top:1px dashed #e35de9} +.compact-map{grid-template-columns:1fr 1fr .75fr;height:360px}.compact-map>.map-zone{display:flex;flex-direction:column;justify-content:center;gap:8px}.compact-map>.map-zone:not(:last-child):after{content:"→";position:absolute;right:-23px;top:48%;z-index:4;color:#eef8ff;font-size:21px;text-shadow:0 0 10px var(--cyan)}.compact-map .zone-flow{height:auto}.compact-map .service-chip{min-height:66px}.intelligence-zone{border-color:#4d7cf0}.intelligence-zone>header{border-color:#3859a8;background:#263d7655}.outcome-zone{border-color:#d654d8}.outcome-zone>header{border-color:#8e3b90;background:#6b286c4d}.outcome-zone .branch-hub:after{height:94px}.outcome-zone .service-chip{position:relative;margin-left:15px} +.system-map+.architecture-note{margin-top:14px}.flow-heading:has(+.map-stage-labels){margin-bottom:34px} +.report-actions{display:flex;align-items:center;gap:9px}.report-actions .snapshot-tag{font-size:9px;padding:7px 10px}.detail-link{width:100%;margin:10px 0 2px;padding:8px;border:1px solid #e05aec;border-radius:8px;background:#6d337255;color:#ffdfff;font:700 10px inherit;cursor:pointer} +.governance-map,.m4-system-map{grid-template-columns:repeat(4,1fr);height:382px}.governance-map>.map-zone,.m4-system-map>.map-zone{display:flex;flex-direction:column;justify-content:center;gap:6px}.governance-map>.map-zone:not(:last-child):after,.m4-system-map>.map-zone:not(:last-child):after{content:"→";position:absolute;right:-23px;top:48%;z-index:4;color:#eef8ff;font-size:21px;text-shadow:0 0 10px var(--cyan)}.governance-map .service-chip,.m4-system-map .service-chip{min-height:54px}.governance-map .vertical-arrow,.m4-system-map .vertical-arrow{font-size:13px;line-height:13px}.pass-chip{border-color:#2d7c70}.pass-chip>i{background:linear-gradient(145deg,#2b8f81,#3dd6c6);color:#052019}.stop-chip{border-color:#873b48}.stop-chip>i{background:linear-gradient(145deg,#a33d52,#ff6b72)} +.experiment-console{display:grid;grid-template-columns:1.7fr .72fr;gap:12px}.experiment-console .experiment-table{height:315px;overflow:auto}.exp-row{width:100%;border-width:0 0 1px;background:transparent;color:var(--text);text-align:left;font-family:inherit;cursor:pointer}.exp-row.selected{background:#122b42;box-shadow:inset 3px 0 var(--cyan)}.experiment-detail{border:1px solid #633743;border-radius:13px;background:linear-gradient(145deg,#241822,#0b1928 65%);padding:15px;overflow:auto}.experiment-detail>label{color:var(--red);font-size:9px;font-weight:800;letter-spacing:.1em}.experiment-detail h3{margin:5px 0 10px;font-size:15px}.decision-step{display:grid;grid-template-columns:1fr auto;gap:3px 8px;padding:8px 0;border-bottom:1px solid #352b37}.decision-step b,.decision-step span{font-size:10px}.decision-step small{grid-column:1/3;color:var(--muted);font-size:8px}.decision-step.pass span{color:var(--cyan)}.decision-step.fail span{color:var(--red)}.decision-step.muted-step{opacity:.6}.system-actions{display:flex;flex-direction:column;gap:4px;margin-top:10px}.system-actions b{font-size:10px;margin-bottom:2px}.system-actions span{color:#9eb2c3;font-size:9px} +.support-scenarios{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:14px 0 10px}.scenario-button{display:grid;gap:3px;padding:10px 13px;border:1px solid var(--line);border-radius:10px;background:#0b1a2b;color:var(--text);text-align:left;cursor:pointer}.scenario-button.active{border-color:var(--cyan);background:#10283b}.scenario-button b{font-size:11px}.scenario-button span{color:var(--muted);font-size:9px}.support-chat{margin-top:0;height:395px}.support-chat .messages{padding-top:14px;padding-bottom:14px}.message.scope-blocked{border-color:#70404a;background:#2b1820}.message.scope-blocked small{color:#ff9ca2} + +/* M1 prepares the baseline before the scheduler invokes the detector. */ +.m1-system-map .zone-intelligence{grid-column:2}.m1-system-map .zone-automation{grid-column:3} diff --git a/demo_console/server.py b/demo_console/server.py index bb385ab..9024150 100644 --- a/demo_console/server.py +++ b/demo_console/server.py @@ -136,7 +136,7 @@ def partner_chat(question: str, session_id: str | None = None) -> tuple[int, dic session_id = session_id or f"video-{uuid.uuid4().hex[:12]}" started = time.perf_counter() status, result = signed_post( - role_session("aurora-games-game-provider-partner"), + role_session("aurora-games-client-operator-partner"), f"{outputs['ChatApiUrl']}chat", {"question": question, "session_id": session_id}, timeout=60, @@ -144,7 +144,7 @@ def partner_chat(question: str, session_id: str | None = None) -> tuple[int, dic result["session_id"] = session_id result["request"] = { "service": "API Gateway + Lambda + Amazon Bedrock", - "identity": "game-provider-partner", + "identity": "integration-partner", "region": REGION, "duration_ms": round((time.perf_counter() - started) * 1000), "completed_at": datetime.now(timezone.utc).isoformat(), diff --git a/demo_console/styles.css b/demo_console/styles.css index 6a55882..005b1d0 100644 --- a/demo_console/styles.css +++ b/demo_console/styles.css @@ -1 +1 @@ -:root{color-scheme:dark;--bg:#06101d;--panel:#0c1a2b;--panel2:#102238;--line:#213953;--text:#f2f7fb;--muted:#91a5ba;--cyan:#3dd6c6;--blue:#4ca8ff;--amber:#f5b942;--red:#ff6b72;--rail:224px}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:#030a12;color:var(--text);font-family:"Noto Sans TC","Microsoft JhengHei",Inter,Segoe UI,sans-serif}body{display:grid;place-items:center}.shell{width:min(100vw,177.78vh);height:min(100vh,56.25vw);min-height:720px;min-width:1280px;background:radial-gradient(circle at 10% 0,#142d48 0,var(--bg) 40%);display:grid;grid-template-columns:var(--rail) 1fr;overflow:hidden}.rail{background:linear-gradient(180deg,#081526,#07111f);border-right:1px solid var(--line);padding:30px 18px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:14px;font-weight:800;letter-spacing:.04em;line-height:1.1;padding:0 10px 30px}.brand-mark{width:42px;height:42px;border-radius:11px;display:grid;place-items:center;background:linear-gradient(145deg,var(--cyan),#2484c7);color:#05111c;font-size:25px;font-weight:900;box-shadow:0 0 28px #3dd6c633}nav{display:flex;flex-direction:column;gap:8px}.nav{border:1px solid transparent;background:transparent;color:var(--muted);display:grid;grid-template-columns:34px 1fr;gap:10px;align-items:center;text-align:left;padding:14px 12px;border-radius:12px;cursor:pointer;font:inherit}.nav b{font-size:12px;letter-spacing:.1em}.nav span{font-size:15px;font-weight:700}.nav small{display:block;font-size:11px;font-weight:500;margin-top:2px;color:#7189a3}.nav.active{color:var(--text);background:#11253b;border-color:#244969;box-shadow:inset 3px 0 var(--cyan)}.nav.active b{color:var(--cyan)}.rail-foot{margin-top:auto;padding:0 11px;color:#59708a;font-size:11px;letter-spacing:.13em;text-transform:uppercase}.workspace{padding:34px 42px 38px;overflow:hidden}.topbar{height:90px;display:flex;justify-content:space-between;align-items:flex-start;border-bottom:1px solid var(--line)}.eyebrow{font-size:12px;color:var(--cyan);font-weight:800;letter-spacing:.17em;margin-bottom:7px}.topbar h1{font-size:30px;margin:0;letter-spacing:-.03em}.live-badge{display:flex;align-items:center;gap:8px;border:1px solid #26554f;border-radius:999px;padding:8px 12px;color:var(--cyan);font-size:12px;font-weight:800}.live-badge>span{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 12px var(--cyan)}.live-badge small{color:var(--muted);font-weight:600;margin-left:3px}.screen{display:none;padding-top:27px;height:calc(100% - 90px)}.screen.active{display:block}.hero-row{display:flex;justify-content:space-between;align-items:center;gap:30px}.hero-row h2{font-size:25px;margin:0 0 7px}.hero-row p{font-size:15px;line-height:1.6;color:var(--muted);margin:0;max-width:760px}.primary,.secondary{border:0;border-radius:11px;padding:13px 19px;font:700 14px inherit;cursor:pointer;white-space:nowrap}.primary{background:linear-gradient(135deg,var(--cyan),#46a6e8);color:#03131d;box-shadow:0 8px 28px #3dd6c633}.secondary{color:var(--text);background:#142a42;border:1px solid #31506d}.primary:disabled,.secondary:disabled{opacity:.55;cursor:wait}.play{font-size:11px;margin-right:6px}.status-strip{margin-top:24px;background:#0a1727;border:1px solid var(--line);border-radius:11px;padding:12px 15px;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}.status-strip code{margin-left:auto;color:#6f8eaa}.status-dot{width:8px;height:8px;border-radius:50%;background:#547086}.status-strip.running .status-dot{background:var(--amber);box-shadow:0 0 12px var(--amber)}.status-strip.done .status-dot{background:var(--cyan);box-shadow:0 0 12px var(--cyan)}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:20px 0}.metric{background:linear-gradient(145deg,#10233a,#0b192a);border:1px solid var(--line);border-radius:15px;padding:22px}.metric label,.summary-grid label,.report label,.proof span{display:block;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:.13em}.metric strong{display:block;font-size:45px;margin:8px 0 2px}.metric small{color:#6c8299}.metric.alert strong{color:var(--red)}.evidence-panel{background:linear-gradient(90deg,#2a1620,#0d1929 60%);border:1px solid #66323c;border-radius:15px;padding:20px;display:grid;grid-template-columns:48px 1fr 330px;align-items:center;gap:17px}.evidence-panel.hidden{opacity:.32;filter:saturate(.5)}.evidence-icon{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;background:#ff6b7222;border:1px solid #ff6b7266;color:var(--red);font-size:24px;font-weight:900}.evidence-panel label{color:var(--red);font-size:11px;font-weight:900;letter-spacing:.12em}.evidence-panel h3{font-size:18px;margin:4px 0}.evidence-panel p{color:var(--muted);margin:0;font-size:13px}.proof{text-align:right}.proof code{display:block;font-size:11px;color:#b8c9d8;margin:7px 0}.proof small{color:#69849e}.question-card{margin-top:20px;padding:14px 18px;border:1px solid #2d4e6b;background:#102238;border-radius:12px;display:grid;grid-template-columns:110px 1fr auto;align-items:center;gap:15px}.question-card span{font-size:11px;color:var(--cyan);font-weight:800;letter-spacing:.1em}.question-card strong{font-size:16px}.question-card code{color:#66839e;font-size:11px}.report-layout{display:grid;grid-template-columns:1.6fr .8fr;gap:18px;margin-top:17px;height:445px}.report,.impact-list{background:#0c1a2b;border:1px solid var(--line);border-radius:15px;overflow:hidden}.report-head{padding:17px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px}.bot-avatar{width:38px;height:38px;border-radius:10px;display:inline-grid;place-items:center;background:linear-gradient(145deg,var(--cyan),var(--blue));color:#03131d;font-size:12px;font-weight:900}.report-head small{display:block;color:#6f879e;margin-top:3px}.report-body{padding:19px 22px;font-size:13px;line-height:1.55;max-height:368px;overflow:auto}.report-body.empty{display:grid;place-items:center;height:320px;color:#658099}.report-body h3{color:var(--cyan);font-size:14px;margin:12px 0 5px}.report-body p,.report-body li{color:#c7d5e2}.report-body ul{margin:6px 0;padding-left:20px}.impact-list{padding:19px}.impact-list h3{margin:0 0 14px;font-size:16px}.comparison-row{padding:12px 0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:1fr auto;gap:8px}.comparison-row strong{font-size:14px}.comparison-row span{font-size:13px;color:var(--red)}.comparison-row small{display:block;color:var(--muted);margin-top:3px}.impact-list footer{margin-top:17px}.impact-list footer span{display:block;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em}.impact-list footer code{display:block;color:#708ba4;font-size:10px;margin-top:5px;overflow-wrap:anywhere}.muted{color:var(--muted)}.summary-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin:20px 0 15px}.summary-grid article{background:#0d1d30;border:1px solid var(--line);border-radius:13px;padding:15px 18px}.summary-grid strong{font-size:28px;display:block;margin-top:2px}.summary-grid .warn strong{color:var(--red)}.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}.filter{background:#0c1929;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:7px 12px;font:600 12px inherit;cursor:pointer}.filter.active{background:#16334d;color:var(--cyan);border-color:#2d625d}.toolbar span{margin-left:auto;color:#607b94;font-size:11px}.experiment-table{border:1px solid var(--line);border-radius:13px;overflow:hidden;background:#0b1828}.tr{display:grid;grid-template-columns:2fr 1.1fr .8fr 1.4fr 1fr .9fr;align-items:center;min-height:57px;border-bottom:1px solid var(--line);padding:0 15px;gap:10px;font-size:12px}.tr:last-child{border-bottom:0}.tr.th{min-height:36px;background:#091522;color:#70869c;text-transform:uppercase;font-size:9px;letter-spacing:.1em}.tr strong{font-size:13px}.tr small{display:block;color:#6f859b;margin-top:2px}.pill{display:inline-flex;padding:4px 8px;border:1px solid var(--line);border-radius:999px;font-size:10px}.pill.action{color:var(--red);border-color:#65323a}.pill.healthy{color:var(--cyan);border-color:#2d615c}.pill.watch{color:var(--amber);border-color:#66542b}.identity{border:1px solid #2c4e6b;background:#0c1a2b;border-radius:10px;padding:9px 12px;font-size:10px;color:#69859e;letter-spacing:.08em}.identity strong{display:block;color:var(--cyan);font-size:12px;margin-top:2px}.chat-shell{margin-top:18px;border:1px solid var(--line);border-radius:15px;background:#081523;overflow:hidden;height:494px;display:grid;grid-template-rows:55px 1fr 66px}.chat-head{padding:0 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;background:#0d1c2e}.chat-head>div{display:flex;gap:10px;align-items:center}.chat-head .bot-avatar{width:32px;height:32px}.online{font-size:11px;color:#718ba3}.online i{display:inline-block;width:7px;height:7px;background:var(--cyan);border-radius:50%;margin-right:5px}.messages{padding:20px 25px;overflow:auto;display:flex;flex-direction:column;gap:15px}.message{max-width:72%;padding:13px 16px;border-radius:14px;font-size:14px;line-height:1.55}.message p{margin:0;white-space:pre-wrap}.message small{display:block;margin-top:6px;color:#6c859e;font-size:10px}.message.assistant{align-self:flex-start;background:#12283f;border:1px solid #244864;border-bottom-left-radius:4px}.message.user{align-self:flex-end;background:linear-gradient(135deg,#227c78,#236491);border-bottom-right-radius:4px}.message.error{border-color:#6b343c;color:#ffd2d5}.typing{color:#7f9bb4}.composer{margin:0;padding:10px 13px;border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 44px;gap:9px;background:#0d1b2c}.composer input{background:#081522;border:1px solid #29445e;border-radius:10px;color:var(--text);padding:0 14px;font:14px inherit;outline:0}.composer input:focus{border-color:var(--cyan)}.composer button{border:0;border-radius:10px;background:var(--cyan);color:#051521;font-size:21px;cursor:pointer}.chat-proof{font-size:11px;color:#5e7891;margin-top:8px}.toast{position:fixed;bottom:24px;left:50%;transform:translate(-50%,20px);background:#182a3b;border:1px solid #34516b;padding:10px 15px;border-radius:9px;color:#dbe7f0;font-size:12px;opacity:0;pointer-events:none;transition:.2s}.toast.show{opacity:1;transform:translate(-50%,0)}button:hover{filter:brightness(1.08)}button:focus-visible,input:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}@media(max-width:1300px),(max-height:760px){.shell{transform:scale(.9)}} +:root{color-scheme:dark;--bg:#06101d;--panel:#0c1a2b;--panel2:#102238;--line:#213953;--text:#f2f7fb;--muted:#91a5ba;--cyan:#3dd6c6;--blue:#4ca8ff;--amber:#f5b942;--red:#ff6b72;--rail:224px}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:#030a12;color:var(--text);font-family:"Noto Sans TC","Microsoft JhengHei",Inter,Segoe UI,sans-serif}body{display:grid;place-items:center}.shell{width:min(100vw,177.78vh);height:min(100vh,56.25vw);min-height:720px;min-width:1280px;background:radial-gradient(circle at 10% 0,#142d48 0,var(--bg) 40%);display:grid;grid-template-columns:var(--rail) 1fr;overflow:hidden}.rail{background:linear-gradient(180deg,#081526,#07111f);border-right:1px solid var(--line);padding:30px 18px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:14px;font-weight:800;letter-spacing:.04em;line-height:1.1;padding:0 10px 30px}.brand small{display:block;margin-top:3px;color:#7189a3;font-size:8px;letter-spacing:.08em}.brand-mark{width:42px;height:42px;border-radius:11px;display:grid;place-items:center;background:linear-gradient(145deg,var(--cyan),#2484c7);color:#05111c;font-size:25px;font-weight:900;box-shadow:0 0 28px #3dd6c633}nav{display:flex;flex-direction:column;gap:8px}.nav{border:1px solid transparent;background:transparent;color:var(--muted);display:grid;grid-template-columns:34px 1fr;gap:10px;align-items:center;text-align:left;padding:14px 12px;border-radius:12px;cursor:pointer;font:inherit}.nav b{font-size:12px;letter-spacing:.1em}.nav span{font-size:15px;font-weight:700}.nav small{display:block;font-size:11px;font-weight:500;margin-top:2px;color:#7189a3}.nav.active{color:var(--text);background:#11253b;border-color:#244969;box-shadow:inset 3px 0 var(--cyan)}.nav.active b{color:var(--cyan)}.rail-foot{margin-top:auto;padding:0 11px;color:#59708a;font-size:11px;letter-spacing:.13em;text-transform:uppercase}.workspace{padding:34px 42px 38px;overflow:hidden}.topbar{height:90px;display:flex;justify-content:space-between;align-items:flex-start;border-bottom:1px solid var(--line)}.eyebrow{font-size:12px;color:var(--cyan);font-weight:800;letter-spacing:.17em;margin-bottom:7px}.topbar h1{font-size:30px;margin:0;letter-spacing:-.03em}.live-badge{display:flex;align-items:center;gap:8px;border:1px solid #26554f;border-radius:999px;padding:8px 12px;color:var(--cyan);font-size:12px;font-weight:800}.live-badge>span{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 12px var(--cyan)}.live-badge small{color:var(--muted);font-weight:600;margin-left:3px}.screen{display:none;padding-top:27px;height:calc(100% - 90px)}.screen.active{display:block}.hero-row{display:flex;justify-content:space-between;align-items:center;gap:30px}.hero-row h2{font-size:25px;margin:0 0 7px}.hero-row p{font-size:15px;line-height:1.6;color:var(--muted);margin:0;max-width:760px}.primary,.secondary{border:0;border-radius:11px;padding:13px 19px;font:700 14px inherit;cursor:pointer;white-space:nowrap}.primary{background:linear-gradient(135deg,var(--cyan),#46a6e8);color:#03131d;box-shadow:0 8px 28px #3dd6c633}.secondary{color:var(--text);background:#142a42;border:1px solid #31506d}.primary:disabled,.secondary:disabled{opacity:.55;cursor:wait}.play{font-size:11px;margin-right:6px}.status-strip{margin-top:24px;background:#0a1727;border:1px solid var(--line);border-radius:11px;padding:12px 15px;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}.status-strip code{margin-left:auto;color:#6f8eaa}.status-dot{width:8px;height:8px;border-radius:50%;background:#547086}.status-strip.running .status-dot{background:var(--amber);box-shadow:0 0 12px var(--amber)}.status-strip.done .status-dot{background:var(--cyan);box-shadow:0 0 12px var(--cyan)}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:20px 0}.metric{background:linear-gradient(145deg,#10233a,#0b192a);border:1px solid var(--line);border-radius:15px;padding:22px}.metric label,.summary-grid label,.report label,.proof span{display:block;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:.13em}.metric strong{display:block;font-size:45px;margin:8px 0 2px}.metric small{color:#6c8299}.metric.alert strong{color:var(--red)}.evidence-panel{background:linear-gradient(90deg,#2a1620,#0d1929 60%);border:1px solid #66323c;border-radius:15px;padding:20px;display:grid;grid-template-columns:48px 1fr 330px;align-items:center;gap:17px}.evidence-panel.hidden{opacity:.32;filter:saturate(.5)}.evidence-icon{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;background:#ff6b7222;border:1px solid #ff6b7266;color:var(--red);font-size:24px;font-weight:900}.evidence-panel label{color:var(--red);font-size:11px;font-weight:900;letter-spacing:.12em}.evidence-panel h3{font-size:18px;margin:4px 0}.evidence-panel p{color:var(--muted);margin:0;font-size:13px}.proof{text-align:right}.proof code{display:block;font-size:11px;color:#b8c9d8;margin:7px 0}.proof small{color:#69849e}.question-card{margin-top:20px;padding:14px 18px;border:1px solid #2d4e6b;background:#102238;border-radius:12px;display:grid;grid-template-columns:110px 1fr auto;align-items:center;gap:15px}.question-card span{font-size:11px;color:var(--cyan);font-weight:800;letter-spacing:.1em}.question-card strong{font-size:16px}.question-card code{color:#66839e;font-size:11px}.report-layout{display:grid;grid-template-columns:1.6fr .8fr;gap:18px;margin-top:17px;height:445px}.report,.impact-list{background:#0c1a2b;border:1px solid var(--line);border-radius:15px;overflow:hidden}.report-head{padding:17px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px}.bot-avatar{width:38px;height:38px;border-radius:10px;display:inline-grid;place-items:center;background:linear-gradient(145deg,var(--cyan),var(--blue));color:#03131d;font-size:12px;font-weight:900}.report-head small{display:block;color:#6f879e;margin-top:3px}.report-body{padding:19px 22px;font-size:13px;line-height:1.55;max-height:368px;overflow:auto}.report-body.empty{display:grid;place-items:center;height:320px;color:#658099}.report-body h3{color:var(--cyan);font-size:14px;margin:12px 0 5px}.report-body p,.report-body li{color:#c7d5e2}.report-body ul{margin:6px 0;padding-left:20px}.impact-list{padding:19px}.impact-list h3{margin:0 0 14px;font-size:16px}.comparison-row{padding:12px 0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:1fr auto;gap:8px}.comparison-row strong{font-size:14px}.comparison-row span{font-size:13px;color:var(--red)}.comparison-row small{display:block;color:var(--muted);margin-top:3px}.impact-list footer{margin-top:17px}.impact-list footer span{display:block;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em}.impact-list footer code{display:block;color:#708ba4;font-size:10px;margin-top:5px;overflow-wrap:anywhere}.muted{color:var(--muted)}.summary-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin:20px 0 15px}.summary-grid article{background:#0d1d30;border:1px solid var(--line);border-radius:13px;padding:15px 18px}.summary-grid strong{font-size:28px;display:block;margin-top:2px}.summary-grid .warn strong{color:var(--red)}.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}.filter{background:#0c1929;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:7px 12px;font:600 12px inherit;cursor:pointer}.filter.active{background:#16334d;color:var(--cyan);border-color:#2d625d}.toolbar span{margin-left:auto;color:#607b94;font-size:11px}.experiment-table{border:1px solid var(--line);border-radius:13px;overflow:hidden;background:#0b1828}.tr{display:grid;grid-template-columns:2fr 1.1fr .8fr 1.4fr 1fr .9fr;align-items:center;min-height:57px;border-bottom:1px solid var(--line);padding:0 15px;gap:10px;font-size:12px}.tr:last-child{border-bottom:0}.tr.th{min-height:36px;background:#091522;color:#70869c;text-transform:uppercase;font-size:9px;letter-spacing:.1em}.tr strong{font-size:13px}.tr small{display:block;color:#6f859b;margin-top:2px}.pill{display:inline-flex;padding:4px 8px;border:1px solid var(--line);border-radius:999px;font-size:10px}.pill.action{color:var(--red);border-color:#65323a}.pill.healthy{color:var(--cyan);border-color:#2d615c}.pill.watch{color:var(--amber);border-color:#66542b}.identity{border:1px solid #2c4e6b;background:#0c1a2b;border-radius:10px;padding:9px 12px;font-size:10px;color:#69859e;letter-spacing:.08em}.identity strong{display:block;color:var(--cyan);font-size:12px;margin-top:2px}.chat-shell{margin-top:18px;border:1px solid var(--line);border-radius:15px;background:#081523;overflow:hidden;height:494px;display:grid;grid-template-rows:55px 1fr 66px}.chat-head{padding:0 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;background:#0d1c2e}.chat-head>div{display:flex;gap:10px;align-items:center}.chat-head .bot-avatar{width:32px;height:32px}.online{font-size:11px;color:#718ba3}.online i{display:inline-block;width:7px;height:7px;background:var(--cyan);border-radius:50%;margin-right:5px}.messages{padding:20px 25px;overflow:auto;display:flex;flex-direction:column;gap:15px}.message{max-width:72%;padding:13px 16px;border-radius:14px;font-size:14px;line-height:1.55}.message p{margin:0;white-space:pre-wrap}.message small{display:block;margin-top:6px;color:#6c859e;font-size:10px}.message.assistant{align-self:flex-start;background:#12283f;border:1px solid #244864;border-bottom-left-radius:4px}.message.user{align-self:flex-end;background:linear-gradient(135deg,#227c78,#236491);border-bottom-right-radius:4px}.message.error{border-color:#6b343c;color:#ffd2d5}.typing{color:#7f9bb4}.composer{margin:0;padding:10px 13px;border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 44px;gap:9px;background:#0d1b2c}.composer input{background:#081522;border:1px solid #29445e;border-radius:10px;color:var(--text);padding:0 14px;font:14px inherit;outline:0}.composer input:focus{border-color:var(--cyan)}.composer button{border:0;border-radius:10px;background:var(--cyan);color:#051521;font-size:21px;cursor:pointer}.chat-proof{font-size:11px;color:#5e7891;margin-top:8px}.toast{position:fixed;bottom:24px;left:50%;transform:translate(-50%,20px);background:#182a3b;border:1px solid #34516b;padding:10px 15px;border-radius:9px;color:#dbe7f0;font-size:12px;opacity:0;pointer-events:none;transition:.2s}.toast.show{opacity:1;transform:translate(-50%,0)}button:hover{filter:brightness(1.08)}button:focus-visible,input:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}@media(max-width:1300px),(max-height:760px){.shell{transform:scale(.9)}} diff --git a/infra/infra/analytics_assistant_stack.py b/infra/infra/analytics_assistant_stack.py index 02cb661..7475802 100644 --- a/infra/infra/analytics_assistant_stack.py +++ b/infra/infra/analytics_assistant_stack.py @@ -247,7 +247,11 @@ def __init__(self, scope: Construct, construct_id: str, lake_bucket: s3.IBucket, self.first_look_fn, filter_policy={ "alert_type": sns.SubscriptionFilter.string_filter( - allowlist=["data_anomaly", "retention_anomaly"] + allowlist=[ + "data_anomaly", + "hourly_data_anomaly", + "retention_anomaly", + ] ), }, dead_letter_queue=sqs.Queue( diff --git a/infra/infra/anomaly_stack.py b/infra/infra/anomaly_stack.py index 343ec23..842daaf 100644 --- a/infra/infra/anomaly_stack.py +++ b/infra/infra/anomaly_stack.py @@ -20,10 +20,10 @@ class AnomalyStack(Stack): - """Batch anomaly detection (data_anomaly) and rule-based arbitrage + """Hourly operational anomaly detection and rule-based risk detection. detection (arbitrage_detection) - both scheduled Lambdas reading only - from the shared Gold tables (gold_daily_kpi, gold_player_features, - silver_events), never recomputing their own aggregates. See + from shared, purpose-built Gold tables, never recomputing repeated + aggregates inside Lambda. See module1-anomaly-detection/README.md.""" def __init__(self, scope: Construct, construct_id: str, lake_bucket: s3.IBucket, **kwargs) -> None: @@ -35,7 +35,9 @@ def __init__(self, scope: Construct, construct_id: str, lake_bucket: s3.IBucket, "AnomalyDetector", MODULE1_DIR / "data_anomaly" / "lambda", lake_bucket, - schedule_description="Daily EWMA anomaly check across all client sites", + schedule_description="Hourly operational anomaly check across all client sites", + schedule_hours=1, + scheduled_event={"scheduled": True, "cadence": "hourly"}, ) # Retention is intentionally weekly: daily cohorts are too small and # D7 outcomes are incomplete until seven days later. Reuse the same @@ -69,7 +71,15 @@ def __init__(self, scope: Construct, construct_id: str, lake_bucket: s3.IBucket, CfnOutput(self, "AlertsTopicArn", value=self.alerts_topic.topic_arn) - def _make_detector(self, name: str, lambda_dir: Path, lake_bucket: s3.IBucket, schedule_description: str) -> _lambda.Function: + def _make_detector( + self, + name: str, + lambda_dir: Path, + lake_bucket: s3.IBucket, + schedule_description: str, + schedule_hours: int = 24, + scheduled_event: dict | None = None, + ) -> _lambda.Function: layer = _lambda.LayerVersion( self, f"{name}CommonLayer", @@ -120,8 +130,18 @@ def _make_detector(self, name: str, lambda_dir: Path, lake_bucket: s3.IBucket, s ) self.alerts_topic.grant_publish(fn) - rule = events.Rule(self, f"{name}Schedule", description=schedule_description, schedule=events.Schedule.rate(Duration.hours(24))) - rule.add_target(targets.LambdaFunction(fn, event=events.RuleTargetInput.from_object({"scheduled": True}))) + rule = events.Rule( + self, + f"{name}Schedule", + description=schedule_description, + schedule=events.Schedule.rate(Duration.hours(schedule_hours)), + ) + rule.add_target(targets.LambdaFunction( + fn, + event=events.RuleTargetInput.from_object( + scheduled_event or {"scheduled": True} + ), + )) CfnOutput(self, f"{name}FunctionName", value=fn.function_name) return fn diff --git a/module1-anomaly-detection/README.md b/module1-anomaly-detection/README.md index 348b762..a281345 100644 --- a/module1-anomaly-detection/README.md +++ b/module1-anomaly-detection/README.md @@ -1,8 +1,9 @@ # Module 1 — Anomaly & Arbitrage Detection -Latest local increment (deployment pending): the existing detector now runs a -weekly mature-cohort D1/D7 retention check and sends hits through the same -first-look workflow as daily DAU/GGR anomalies. +Latest local increment (deployment pending): the detector now runs an hourly +site-usage check from precomputed same-hour baselines, while the mature-cohort +retention check remains weekly. Both send hits through the same first-look +workflow. ## Pain Point @@ -27,6 +28,7 @@ Three components, in increasing order of "how real-time": | | Reads from | Cadence | Signal | |---|---|---|---| +| [`data_anomaly/`](data_anomaly/) | `gold_hourly_monitoring_features` | Hourly (EventBridge) | Prepared normal range for active users, sessions and processed events per site | | [`data_anomaly/`](data_anomaly/) | `gold_daily_kpi` | Daily (EventBridge) | EWMA-based control limit on DAU/GGR per site | | [`data_anomaly/`](data_anomaly/) | `gold_cohort_retention` | Weekly (same Lambda, EventBridge) | Mature weekly D1/D7 retention versus a pooled, sample-weighted baseline | | [`arbitrage_detection/`](arbitrage_detection/) | `silver_events` (device fan-out) + `gold_player_features` (behavior) | Daily (EventBridge) | Shared-device fan-out **combined with** abnormal cash-out ratio | @@ -78,14 +80,16 @@ baseline for extended incidents - out of scope here. `ARCHITECTURE.md` for the full batch-vs-streaming trade-off (event-time vs processing-time, late-arriving data, duplicate events, false-alert avoidance). - **Both batch detectors reuse governed data products rather than rebuilding feature logic.** - EWMA reads `gold_daily_kpi`; arbitrage combines `gold_player_features` with a narrow Silver query - for device fan-out because that grain does not exist in Gold. The exception is explicit rather - than hidden behind an inaccurate "Gold-only" claim. + Hourly monitoring reads prepared baselines and bounds from + `gold_hourly_monitoring_features`; the legacy daily EWMA reads `gold_daily_kpi`; arbitrage + combines `gold_player_features` with a narrow Silver query for device fan-out because that + grain does not exist in Gold. The exception is explicit rather than hidden behind an + inaccurate "Gold-only" claim. - **Same dual-mode Lambda pattern used throughout this project** (see Module 2's - `monitoring_check`): `{"scheduled": true}` is the EventBridge-driven path (discovers every site - and reads an explicit transform-success publication marker); an explicit - `{"client_site_id", "as_of_date"}` lets the demo replay a historical day. The marker is written - only after the lake/feature build succeeds — `MAX(dt)` is not treated as proof of completeness. + `monitoring_check`): `{"scheduled": true, "cadence": "hourly"}` is the hourly EventBridge path + (discovers every site and reads the monitoring-feature publication marker); explicit + `event_hour` or `as_of_date` inputs let a demo replay historical data. Markers are written only + after the lake/feature build succeeds — `MAX(dt)` is not treated as proof of completeness. ## Running the demo diff --git a/module1-anomaly-detection/data_anomaly/lambda/detector/handler.py b/module1-anomaly-detection/data_anomaly/lambda/detector/handler.py index ba40080..9f5741c 100644 --- a/module1-anomaly-detection/data_anomaly/lambda/detector/handler.py +++ b/module1-anomaly-detection/data_anomaly/lambda/detector/handler.py @@ -1,8 +1,11 @@ -"""Batch anomaly detection for daily business KPIs and mature retention cohorts. +"""Scheduled anomaly detection for hourly operational KPIs, daily business +KPIs, and mature retention cohorts. Three invocation modes share the same publication boundary: -- Daily scheduled (``{"scheduled": true}``): checks DAU/GGR with the - existing EWMA control limits. +- Hourly scheduled (``{"scheduled": true, "cadence": "hourly"}``): reads + precomputed same-hour baselines and control limits from Gold. +- Explicit daily (``{"client_site_id", "as_of_date"}``): retains the + historical EWMA replay used by the original PoC. - Weekly scheduled (``{"scheduled": true, "cadence": "weekly"}``): pools fully matured registration cohorts by calendar week, then checks D1/D7 retention with a two-proportion z-score. D7 cohorts are not evaluated until @@ -44,6 +47,12 @@ PUBLICATION_MANIFEST_KEY = "manifests/published/gold_daily_kpi.json" CONSUMPTION_MARKER_KEY = "manifests/consumed/data_anomaly.json" RETENTION_CONSUMPTION_MARKER_KEY = "manifests/consumed/retention_anomaly.json" +HOURLY_PUBLICATION_MANIFEST_KEY = ( + "manifests/published/gold_hourly_monitoring_features.json" +) +HOURLY_CONSUMPTION_MARKER_KEY = "manifests/consumed/hourly_data_anomaly.json" +HOURLY_METRICS = ("active_users", "sessions", "processed_events") +MIN_HOURLY_BASELINE_POINTS = 7 RETENTION_METRICS = { "d1_retention_rate": "d1_retained", @@ -189,6 +198,132 @@ def _publish_alert(result: dict): ) +def _hourly_publication_manifest() -> dict: + obj = s3.get_object(Bucket=BUCKET, Key=HOURLY_PUBLICATION_MANIFEST_KEY) + manifest = json.loads(obj["Body"].read()) + if manifest.get("table") != "gold_hourly_monitoring_features": + raise ValueError(f"unexpected hourly publication manifest: {manifest}") + if not manifest.get("published_through") or not manifest.get("published_at"): + raise ValueError("hourly monitoring publication manifest is incomplete") + return manifest + + +def _discover_hourly_sites() -> list: + rows = fetch_all_rows(run_athena_query( + "SELECT DISTINCT client_site_id FROM gold_hourly_monitoring_features" + )) + return [row["client_site_id"] for row in rows] + + +def _fetch_hourly_feature(site: str, event_hour: str | None = None) -> dict | None: + hour_filter = f"AND event_hour = TIMESTAMP '{event_hour}'" if event_hour else "" + rows = fetch_all_rows(run_athena_query(f""" + SELECT * + FROM gold_hourly_monitoring_features + WHERE client_site_id = '{site}' {hour_filter} + ORDER BY event_hour DESC + LIMIT 1 + """)) + return rows[0] if rows else None + + +def _publish_hourly_alert(result: dict) -> None: + compact_hour = result["event_hour"].replace(" ", "T").replace(":00:00.000", "") + key = f"gold/anomaly_alerts/{result['client_site_id']}_{compact_hour}.json" + body = {**result, "detected_at": _now_iso(), "evidence_s3_key": key} + encoded = json.dumps(body).encode("utf-8") + s3.put_object( + Bucket=BUCKET, + Key=key, + Body=encoded, + ContentType="application/json", + ) + sns.publish( + TopicArn=ALERTS_TOPIC_ARN, + Subject=f"Hourly anomaly: {result['client_site_id']} at {compact_hour}", + Message=encoded.decode("utf-8"), + MessageAttributes={ + "alert_type": { + "DataType": "String", + "StringValue": "hourly_data_anomaly", + }, + "schema_version": {"DataType": "String", "StringValue": "1"}, + "client_site_id": { + "DataType": "String", + "StringValue": result["client_site_id"], + }, + "as_of_date": { + "DataType": "String", + "StringValue": result["event_hour"][:10], + }, + "event_hour": { + "DataType": "String", + "StringValue": result["event_hour"], + }, + }, + ) + + +def _check_hourly_site(site: str, event_hour: str | None = None) -> dict: + feature = _fetch_hourly_feature(site, event_hour) + if not feature: + return {"client_site_id": site, "event_hour": event_hour, "skipped": "no hourly data"} + result_hour = feature["event_hour"] + baseline_points = int(feature.get("baseline_points") or 0) + if baseline_points < MIN_HOURLY_BASELINE_POINTS: + return { + "client_site_id": site, + "event_hour": result_hour, + "skipped": "insufficient same-hour history", + "baseline_points": baseline_points, + } + alerts = [] + for metric in HOURLY_METRICS: + actual = float(feature[metric]) + baseline = float(feature[f"{metric}_baseline"]) + lower = float(feature[f"{metric}_lower_bound"]) + upper = float(feature[f"{metric}_upper_bound"]) + if actual < lower or actual > upper: + alerts.append({ + "metric": metric, + "actual": round(actual, 4), + "baseline": round(baseline, 4), + "lower_bound": round(lower, 4), + "upper_bound": round(upper, 4), + "deviation_pct": ( + round((actual - baseline) / baseline * 100, 2) + if baseline else None + ), + }) + result = { + "client_site_id": site, + "event_hour": result_hour, + "baseline_points": baseline_points, + "alerts": alerts, + } + if alerts: + _publish_hourly_alert(result) + return result + + +def _run_hourly() -> dict: + publication = _hourly_publication_manifest() + if _already_processed(publication, HOURLY_CONSUMPTION_MARKER_KEY): + return { + "checked": [], + "skipped": "publication already processed", + "published_at": publication["published_at"], + "cadence": "hourly", + } + checked = [_check_hourly_site(site) for site in _discover_hourly_sites()] + _mark_processed( + publication, + HOURLY_CONSUMPTION_MARKER_KEY, + cadence="hourly", + ) + return {"checked": checked, "cadence": "hourly"} + + def _latest_complete_retention_week(published_through: str) -> tuple[str, str]: """Return the latest Monday-Sunday cohort week with mature D7 outcomes.""" latest_mature_day = ( @@ -408,6 +543,8 @@ def _run_weekly_retention(publication: dict) -> dict: def handler(event, context): if event.get("scheduled"): + if event.get("cadence") == "hourly": + return _run_hourly() publication = _publication_manifest() if event.get("cadence") == "weekly": return _run_weekly_retention(publication) @@ -424,4 +561,6 @@ def handler(event, context): _mark_processed(publication) return {"checked": checked} + if event.get("event_hour"): + return _check_hourly_site(event["client_site_id"], event["event_hour"]) return _check_site(event["client_site_id"], event["as_of_date"]) diff --git a/module2-experimentation-platform/orchestration/README.md b/module2-experimentation-platform/orchestration/README.md index b7f7742..20b6a1f 100644 --- a/module2-experimentation-platform/orchestration/README.md +++ b/module2-experimentation-platform/orchestration/README.md @@ -8,7 +8,7 @@ operation modes after assignment and its preflight balance check: assignment -> balance check --+ +-> analysis -> readout +-- live: Wait until planned end -------+ -EventBridge hourly -> actual exposure SRM + guardrails +EventBridge hourly -> actual exposure SRM + `gold_hourly_kpi` guardrails -> breach: allocation_enabled=false -> stop execution -> SNS ``` @@ -29,7 +29,7 @@ EventBridge hourly -> actual exposure SRM + guardrails every currently-running experiment against today's real date - that's the actual always-on production path. In live mode it queries immutable product exposure events, waits for at least 100 exposures before applying the `p < 0.01` SRM rule, - and joins guardrails to the exposed treatment cohort rather than the + and joins `gold_hourly_kpi` guardrails to the exposed treatment cohort rather than the eligibility assignment. On a breach it atomically sets `allocation_enabled=false`, records structured monitoring status, stops the waiting execution, and publishes SNS. diff --git a/module2-experimentation-platform/orchestration/lambda/monitoring_check/handler.py b/module2-experimentation-platform/orchestration/lambda/monitoring_check/handler.py index 3eba6d4..4f7c7a7 100644 --- a/module2-experimentation-platform/orchestration/lambda/monitoring_check/handler.py +++ b/module2-experimentation-platform/orchestration/lambda/monitoring_check/handler.py @@ -95,27 +95,57 @@ def _check_exposure_srm(experiment: dict) -> dict: } +LIVE_METRIC_COLUMNS = { + "sessions_7d": "sessions", + "ggr_usd_7d": "ggr_usd", +} + + def _check_guardrails( experiment_id: str, check_date: str, guardrail_metrics: list, cohort_table: str, + execution_mode: str = "replay", + client_site_id: str | None = None, + game_id: str | None = None, ) -> list: breaches = [] for guardrail in guardrail_metrics: metric = guardrail["metric"] direction = guardrail["direction"] threshold = float(guardrail["threshold"]) - sql = f""" - SELECT AVG(pf.{metric}) AS value - FROM gold_player_features pf - JOIN ( - SELECT DISTINCT player_id - FROM {cohort_table} - WHERE experiment_id = '{experiment_id}' AND variant = 'treatment' - ) cohort ON cohort.player_id = pf.player_id - WHERE pf.snapshot_date = '{check_date}' - """ + if execution_mode == "live": + live_column = LIVE_METRIC_COLUMNS.get(metric, metric) + filters = [ + f"hk.dt = '{check_date}'", + f"cohort.experiment_id = '{experiment_id}'", + "cohort.variant = 'treatment'", + ] + if client_site_id: + filters.append(f"hk.client_site_id = '{client_site_id}'") + if game_id: + filters.append(f"hk.game_id = '{game_id}'") + sql = f""" + SELECT SUM(hk.{live_column}) AS value + FROM gold_hourly_kpi hk + JOIN ( + SELECT DISTINCT player_id, experiment_id, variant + FROM {cohort_table} + ) cohort ON cohort.player_id = hk.player_id + WHERE {' AND '.join(filters)} + """ + else: + sql = f""" + SELECT AVG(pf.{metric}) AS value + FROM gold_player_features pf + JOIN ( + SELECT DISTINCT player_id + FROM {cohort_table} + WHERE experiment_id = '{experiment_id}' AND variant = 'treatment' + ) cohort ON cohort.player_id = pf.player_id + WHERE pf.snapshot_date = '{check_date}' + """ rows = fetch_all_rows(run_athena_query(sql)) value = ( float(rows[0]["value"]) @@ -277,6 +307,9 @@ def _check_experiment( check_date, guardrail_metrics, cohort_table, + execution_mode=execution_mode, + client_site_id=experiment.get("client_site_id"), + game_id=experiment.get("game_id"), ) status = { "checked_at": now_iso(), diff --git a/module3-analytics-assistant/README.md b/module3-analytics-assistant/README.md index 68d8735..9e5c29f 100644 --- a/module3-analytics-assistant/README.md +++ b/module3-analytics-assistant/README.md @@ -89,15 +89,16 @@ fires the guardrail; see the verified demo output below. ## Capability B: first-look drill-down report -Subscribed to the same SNS topic Module 1's `data_anomaly` and +Subscribed to the same SNS topic Module 1's `hourly_data_anomaly`, `data_anomaly`, and `retention_anomaly` paths publish to -(`aurora-games-anomaly-alerts`), reading `client_site_id`/`as_of_date` from the SNS message's -`MessageAttributes` (a small, additive change to Module 1's publisher — see +(`aurora-games-anomaly-alerts`), reading `client_site_id` and `event_hour` or `as_of_date` from +the SNS message's `MessageAttributes` (an additive contract in Module 1's publisher — see `module1-anomaly-detection/data_anomaly/lambda/detector/handler.py`'s `_publish_alert`). On each alert: -1. **Site-level baseline comparison** — today's DAU/GGR/sessions/new_players/deposits/withdrawals - vs. the trailing 7-day average, from `gold_daily_kpi`. +1. **Site-level baseline comparison** — hourly alerts read actuals, same-hour baselines and normal + ranges already prepared in `gold_hourly_monitoring_features`; legacy daily alerts compare the + current day with the trailing 7-day average from `gold_daily_kpi`. 2. **Per-game GGR breakdown** — deliberately reads `silver_events` directly rather than Gold, because `gold_daily_kpi` has no per-game grain. A documented, narrow exception to "always read from Gold": this is a one-off investigative drill-down, not a repeated dashboard metric that diff --git a/module3-analytics-assistant/lambda/common/python/diagnostics.py b/module3-analytics-assistant/lambda/common/python/diagnostics.py index 1c9c545..b25337b 100644 --- a/module3-analytics-assistant/lambda/common/python/diagnostics.py +++ b/module3-analytics-assistant/lambda/common/python/diagnostics.py @@ -11,6 +11,36 @@ "deposits_usd", "withdrawals_usd", ] +HOURLY_METRICS = ["active_users", "sessions", "processed_events"] + + +def hourly_baseline_comparison(site: str, event_hour: str) -> dict: + """Read detector-ready evidence; no baseline is recomputed here.""" + rows = fetch_all_rows(run_athena_query(f""" + SELECT * + FROM gold_hourly_monitoring_features + WHERE client_site_id = '{site}' + AND event_hour = TIMESTAMP '{event_hour}' + LIMIT 1 + """)) + if not rows: + return {} + row = rows[0] + comparison = {} + for metric in HOURLY_METRICS: + actual = float(row[metric]) + baseline = float(row[f"{metric}_baseline"]) + comparison[metric] = { + "actual": round(actual, 4), + "baseline": round(baseline, 4), + "lower_bound": round(float(row[f"{metric}_lower_bound"]), 4), + "upper_bound": round(float(row[f"{metric}_upper_bound"]), 4), + "pct_change": ( + round((actual - baseline) / baseline * 100, 2) + if baseline else None + ), + } + return comparison def site_baseline_comparison(site: str, as_of_date: str) -> dict: diff --git a/module3-analytics-assistant/lambda/first_look_report/handler.py b/module3-analytics-assistant/lambda/first_look_report/handler.py index 270cc60..e5ed13b 100644 --- a/module3-analytics-assistant/lambda/first_look_report/handler.py +++ b/module3-analytics-assistant/lambda/first_look_report/handler.py @@ -27,6 +27,7 @@ import boto3 from diagnostics import ( game_breakdown as _game_breakdown, + hourly_baseline_comparison as _hourly_baseline_comparison, render_report as _render_report, site_baseline_comparison as _site_baseline_comparison, ) @@ -120,6 +121,36 @@ def _render_retention_report(payload: dict) -> tuple[str, str]: return report, headline +def _render_hourly_report( + site: str, + event_hour: str, + comparison: dict, + headline: str, +) -> str: + labels = { + "active_users": "active users", + "sessions": "sessions", + "processed_events": "processed events", + } + lines = "\n".join( + f"- {labels[metric]}: {values['actual']} vs same-hour baseline " + f"{values['baseline']} ({values['pct_change']}%)" + for metric, values in comparison.items() + ) + return ( + f"### Hourly First-Look: {site} at {event_hour}\n\n" + f"### Headline\n{headline}\n\n" + f"### Same-Hour Comparison\n{lines}\n\n" + f"### Interpretation Boundary\n" + f"This is an investigation signal, not a confirmed root cause. " + f"Check ingestion and service health before attributing the movement " + f"to user behaviour.\n\n" + f"### Suggested Next Steps\n" + f"- Check data ingestion completeness for the affected hour.\n" + f"- Check service health and recent releases for {site}." + ) + + def _store_and_publish( site: str, as_of_date: str, @@ -128,8 +159,12 @@ def _store_and_publish( report_type: str, evidence: dict, ) -> dict: - suffix = "retention" if report_type == "RETENTION_FIRST_LOOK" else "daily" - key = f"gold/first_look_reports/{site}_{as_of_date}_{suffix}.json" + suffix = { + "RETENTION_FIRST_LOOK": "retention", + "HOURLY_FIRST_LOOK": "hourly", + }.get(report_type, "daily") + key_time = as_of_date.replace(" ", "T").replace(":00:00.000", "") + key = f"gold/first_look_reports/{site}_{key_time}_{suffix}.json" s3.put_object( Bucket=BUCKET, Key=key, @@ -191,7 +226,11 @@ def handler(event, context): site = (attrs.get("client_site_id") or {}).get("Value") as_of_date = (attrs.get("as_of_date") or {}).get("Value") if ( - alert_type not in {"data_anomaly", "retention_anomaly"} + alert_type not in { + "data_anomaly", + "hourly_data_anomaly", + "retention_anomaly", + } or not site or not as_of_date ): @@ -232,6 +271,29 @@ def handler(event, context): ) continue + if alert_type == "hourly_data_anomaly": + event_hour = (attrs.get("event_hour") or {}).get("Value") + if not event_hour: + print(json.dumps({ + "skipped": True, + "reason": "hourly alert missing event_hour", + })) + continue + comparison = _hourly_baseline_comparison(site, event_hour) + headline = _headline(site, event_hour, comparison) + report_text = _render_hourly_report( + site, event_hour, comparison, headline + ) + reports.append(_store_and_publish( + site, + event_hour, + report_text, + headline, + "HOURLY_FIRST_LOOK", + {"event_hour": event_hour, "comparison": comparison}, + )) + continue + comparison = _site_baseline_comparison(site, as_of_date) breakdown = _game_breakdown(site, as_of_date) headline = _headline(site, as_of_date, comparison) diff --git a/module4-partner-support-chatbot/README.md b/module4-partner-support-chatbot/README.md index bbdb58c..8b60df2 100644 --- a/module4-partner-support-chatbot/README.md +++ b/module4-partner-support-chatbot/README.md @@ -8,14 +8,14 @@ locally, but has not been deployed as part of this change. ## Pain Point -Aurora Games' integration engineers spend a large share of their week answering the same partner +LEON Game Data Platform's integration engineers spend a large share of their week answering the same partner questions — why a webhook signature fails, which environment a credential belongs to, when sandbox resets. Each one is individually cheap and collectively expensive, and the questions that genuinely need an engineer get queued behind the ones that don't. There are two different integration directions: game providers connect launch -and wallet/round APIs into Aurora Games, while 2C client operators consume -Aurora Games authentication, webhook, settlement, release, and maintenance +and wallet/round APIs into LEON Game Data Platform, while 2C client operators consume +LEON Game Data Platform authentication, webhook, settlement, release, and maintenance interfaces. Their documentation is deliberately separate. A bot that answers the easy ones is straightforward. The hard part, and the actual subject of this @@ -198,7 +198,7 @@ did it anyway on escalation, which is precisely the argument for enforcing it in This is the sharpest difference from the rest of the project. - **User-facing:** no document names, IDs, section numbers, or filenames — ever. An external partner - gets at most a neutral *"based on Aurora Games' official integration documentation"*. Exposing the + gets at most a neutral *"based on LEON Game Data Platform's official integration documentation"*. Exposing the structure of an internal knowledge base to a vendor is an information leak, and it clutters the conversation with detail no partner asked for. - **Audit track:** full provenance for *every* response — documents loaded, relevance scores, @@ -219,7 +219,7 @@ instructions** and produced an internal document ID: ``` model output (audit track, never shown to the partner): "Webhook signature verification is covered in section 3.2 of the - Aurora Games Partner Integration Guide (Document ID: AG-INT-001)." + LEON Game Data Platform Partner Integration Guide (Document ID: AG-INT-001)." validation : passed=False problems=['internal_identifier_leak'] patterns matched: ['AG-[A-Z]{3}-\d{3}', 'Document ID'] diff --git a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/faq.md b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/faq.md index df4ac91..57ae2a0 100644 --- a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/faq.md +++ b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/faq.md @@ -1,4 +1,4 @@ -# Aurora Games Partner Integration FAQ +# LEON Data Platform Integration FAQ Document ID: AG-FAQ-002 | Version 2.8 | Audience: integration partners @@ -53,7 +53,7 @@ midnight and settled after are attributed to the day they settled. Sandbox resets every Sunday at 02:00 UTC. This is expected and is not a fault. -### §4.2 Can I load my own test players into sandbox +### §4.2 Can I load my own test users into sandbox -Yes, through the partner portal's test-data tool. Loaded players are also cleared by the weekly +Yes, through the partner portal's test-data tool. Loaded users are also cleared by the weekly reset. diff --git a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/integration_guide.md b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/integration_guide.md index 9a32a5c..c41679a 100644 --- a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/integration_guide.md +++ b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/integration_guide.md @@ -1,4 +1,4 @@ -# Aurora Games Partner Integration Guide +# LEON Data Platform Integration Guide Document ID: AG-INT-001 | Version 4.2 | Audience: integration partners @@ -29,12 +29,12 @@ return `403 environment_mismatch`. ### §2.1 Sandbox -Sandbox is a full-featured test environment seeded with synthetic players and games. Balances are -not real, settlement is simulated, and data is reset every Sunday at 02:00 UTC. +Sandbox is a full-featured test environment seeded with synthetic users and workspaces. External +effects are simulated, and data is reset every Sunday at 02:00 UTC. ### §2.2 Production -Production carries real player balances and real settlement. Access requires completing +Production carries live customer traffic. Access requires completing certification (see §5) before your partner account is enabled. ### §2.3 Environment selection @@ -52,7 +52,7 @@ HTTPS, must respond within 5 seconds, and must return a 2xx status to acknowledg ### §3.2 Signature verification -Every webhook request carries an `X-Aurora-Signature` header containing an HMAC-SHA256 of the raw +Every webhook request carries an `X-LEON-Signature` header containing an HMAC-SHA256 of the raw request body, computed with your **webhook signing secret**. The signing secret is distinct from your `client_secret`. Always verify the signature against the raw body before parsing — verifying against re-serialized JSON will fail. @@ -71,7 +71,7 @@ A settlement file is published per client site each day at 04:00 UTC covering th ### §4.2 Reconciliation -Reconcile the settlement file against your own transaction records using `round_id` as the join +Reconcile the daily delivery file against your own records using `record_id` as the join key. Discrepancies should be raised within 5 business days. ## §5 Certification diff --git a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/maintenance_calendar.md b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/maintenance_calendar.md index 94f9c66..8d95f05 100644 --- a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/maintenance_calendar.md +++ b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/maintenance_calendar.md @@ -1,4 +1,4 @@ -# Aurora Games Maintenance Calendar +# LEON Data Platform Maintenance Calendar Document ID: AG-MNT-004 | Version 2026.07 | Audience: integration partners diff --git a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/release_notes.md b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/release_notes.md index d0a168e..18db75a 100644 --- a/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/release_notes.md +++ b/module4-partner-support-chatbot/lambda/chat/knowledge_base/client_operator/release_notes.md @@ -1,4 +1,4 @@ -# Aurora Games Platform Release Notes +# LEON Data Platform Release Notes Document ID: AG-REL-003 | Version 2026.07 | Audience: integration partners @@ -6,8 +6,8 @@ Document ID: AG-REL-003 | Version 2026.07 | Audience: integration partners ### §1.1 Added -- Settlement files now include a `game_provider_id` column, allowing partners operating multiple - providers to split reconciliation without a separate lookup. +- Daily delivery files now include a `source_system_id` column, allowing partners operating multiple + source systems to split reconciliation without a separate lookup. - Webhook payloads now carry `event_id` on every event type. Previously some event types omitted it, which made de-duplication unreliable. @@ -25,7 +25,7 @@ Document ID: AG-REL-003 | Version 2026.07 | Audience: integration partners ### §2.1 Added -- Sandbox test-data tool in the partner portal, allowing partners to load their own test players. +- Sandbox test-data tool in the partner portal, allowing partners to load their own test users. ### §2.2 Fixed diff --git a/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_faq.md b/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_faq.md index 028df7a..c88ba41 100644 --- a/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_faq.md +++ b/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_faq.md @@ -1,4 +1,4 @@ -# Aurora Games Game Provider Integration FAQ +# LEON Game Data Platform Game Provider Integration FAQ Document ID: AG-GPF-102 | Version 1.0 | Audience: game providers diff --git a/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_integration_guide.md b/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_integration_guide.md index 70b51cf..302874e 100644 --- a/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_integration_guide.md +++ b/module4-partner-support-chatbot/lambda/chat/knowledge_base/game_provider/provider_integration_guide.md @@ -1,15 +1,15 @@ -# Aurora Games Game Provider Integration Guide +# LEON Game Data Platform Game Provider Integration Guide Document ID: AG-GPI-101 | Version 1.0 | Audience: game providers ## §1 Integration direction -This interface is for a game provider connecting game content to Aurora Games. It is not the +This interface is for a game provider connecting game content to LEON Game Data Platform. It is not the client-operator API used by 2C partners for settlement files and outbound webhooks. ## §2 Game launch -Aurora Games creates a short-lived launch token and calls the provider's launch endpoint with +LEON Game Data Platform creates a short-lived launch token and calls the provider's launch endpoint with `player_id`, `game_id`, `currency`, `language`, and `return_url`. The provider must validate the token before creating a game session. A launch token is single-use and expires after 60 seconds. @@ -41,6 +41,6 @@ maintenance contact, and proposed release date at least 10 business days before ## §5 Operational notices -Providers must notify Aurora Games before planned maintenance and identify the affected game IDs, +Providers must notify LEON Game Data Platform before planned maintenance and identify the affected game IDs, start and end time in UTC, and expected player impact. Emergency incidents should include a stable incident reference so status updates can be correlated. diff --git a/module4-partner-support-chatbot/lambda/chat/prompts/answer_body_v1.md b/module4-partner-support-chatbot/lambda/chat/prompts/answer_body_v1.md index 5aade48..e69fa47 100644 --- a/module4-partner-support-chatbot/lambda/chat/prompts/answer_body_v1.md +++ b/module4-partner-support-chatbot/lambda/chat/prompts/answer_body_v1.md @@ -9,7 +9,7 @@ Owns: slot 3 of 5 (answer_body) only. This is the ONLY slot an LLM is allowed rejects those and the response falls back to a pure template. --> -You are a support assistant for Aurora Games' partner integration team. You answer questions from +You are a documentation assistant for LEON Data Platform's integration team. You answer questions from external integration partners. Answer using ONLY the reference material provided below. It is the complete set of material diff --git a/module4-partner-support-chatbot/lambda/chat/prompts/fixed_copy_v1.py b/module4-partner-support-chatbot/lambda/chat/prompts/fixed_copy_v1.py index 2505cbc..9a18975 100644 --- a/module4-partner-support-chatbot/lambda/chat/prompts/fixed_copy_v1.py +++ b/module4-partner-support-chatbot/lambda/chat/prompts/fixed_copy_v1.py @@ -19,7 +19,7 @@ """ GREETING = ( - "Thanks for contacting Aurora Games partner integration support." + "Thanks for contacting LEON Data Platform integration guidance." ) # Selected by code from a deterministic signal (whether the partner is @@ -28,7 +28,7 @@ ACKNOWLEDGMENT_INFO_REQUEST = "Happy to help with that." CLOSING_NORMAL = ( - "This answer is based on Aurora Games' official integration documentation. " + "This answer is based on LEON Data Platform's official integration documentation. " "If anything is still unclear, just ask." ) @@ -41,13 +41,13 @@ CLOSING_CLARIFICATION = "Once I know that, I can give you an exact answer." CLOSING_OUT_OF_SCOPE = ( - "I'm here specifically for Aurora Games integration topics - authentication, webhooks, " + "I'm here specifically for LEON Data Platform integration topics - authentication, webhooks, " "settlement, environments and releases. Ask me anything in that area." ) # BLOCKED CONTENT: fixed refusal, deliberately uninformative about the cause. BLOCKED_RESPONSE = ( - "I'm not able to help with that request. If you have a question about your Aurora Games " + "I'm not able to help with that request. If you have a question about your LEON Data Platform " "integration, I'm glad to help with that instead." ) diff --git a/site/app.js b/site/app.js index 3e62ede..3d7e306 100644 --- a/site/app.js +++ b/site/app.js @@ -11,7 +11,7 @@ const translations = { heroEyebrow: "LEON LAI · BUSINESS-FIRST AWS SA PORTFOLIO", heroTitle1: "一套雲端系統架構,", heroTitle2: "解決四個實際營運痛點。", - heroLede: "以 Serverless 為主的設計,建構於資料共用、權限邊界與成本控制之上,解決異常偵測、實驗運營、臨時分析與合作夥伴支援問題。", + heroLede: "以 Serverless 為主的設計,建構於資料共用、權限邊界與成本控制之上,解決異常偵測、實驗運營、臨時分析與合作夥伴整合支援問題。", seeArchitecture: "理解系統架構", seeDemo: "查看 Demo 路徑", heroScope: "Synthetic data · Verified PoC · 不宣稱 production workload 經驗", @@ -25,22 +25,22 @@ const translations = { problemsKicker: "THE OPERATING REALITY", problemsTitle: "雲端 AI 技術鏈接實務痛點", problemsIntro: "針對過去重複耗損資源的四大情境,進行架構設計與 PoC 驗證。", - m1Short: "異常/風險訊號", + m1Short: "異常/品質訊號", p1Title: "沒有主動監控,只能被動發現。", - p1Body: "數據缺乏主動監控;一旦留存或營收下滑,通常要等到人為發現才開始處理。已知套利風險也缺少一致、可回溯的證據格式,調查品質容易依賴個人經驗。", + p1Body: "數據缺乏主動監控;一旦活躍度或轉換表現下滑,通常要等到人為發現才開始處理。已知資料品質訊號也缺少一致、可回溯的證據格式,調查品質容易依賴個人經驗。", m2Short: "實驗運營", p2Title: "並行實驗的狀態存在每個人的腦中。", - p2Body: "不同遊戲的 A/B testing 同時進行時,沒有中央化方式查看並行實驗狀態,得一個一個問人,或等到站會才知道。", + p2Body: "不同產品體驗的 A/B testing 同時進行時,沒有中央化方式查看並行實驗狀態,得一個一個問人,或等到站會才知道。", p2SecondaryIntro: "這個核心問題同時還伴隨兩個次要、但會持續增加營運成本的問題:", p2Secondary1: "SRM、guardrail 依賴人工抽查,異常實驗停止有延遲。", p2Secondary2: "相同 data feature 因為由不同組員負責而重複開發。", m3Short: "臨時分析", p3Title: "Dashboard 回答不了突然出現的「為什麼」。", - p3Body: "Dashboard 可以回答固定問題,但當老闆、業務或客戶突然詢問「今天營收為什麼掉了?」時,現有 dashboard 往往無法直接回答。", + p3Body: "Dashboard 可以回答固定問題,但當老闆、業務或客戶突然詢問「今天核心指標為什麼掉了?」時,現有 dashboard 往往無法直接回答。", p3Impact: "這類提問通常伴隨急迫性,又會在不可預期的時間出現,進一步加重分析人員的工作負擔;當客戶數量增加,問題頻率也會隨之上升。", - m4Short: "合作夥伴支援", - p4Title: "重複性客戶問題消耗客服與開發資源。", - p4Body: "不同時區的合作夥伴經常反覆詢問類似的產品對接與整合問題:一方面客服需要重複回答;另一方面,若客服無法處理,問題就會升級到工程師層面,進一步增加開發負擔。", + m4Short: "整合支援", + p4Title: "重複的整合問題消耗支援與工程資源。", + p4Body: "不同時區的合作夥伴經常詢問相似的 API 驗證、Webhook、環境設定、服務更新與維護資訊;常見問題需要反覆回覆,複雜案件則占用工程團隊時間。", architectureKicker: "SYSTEM SHAPE & BOUNDARIES", architectureTitle: "三條工作流共用資料湖;第四條走獨立知識路徑。", architectureIntro: "整合統一資料定義、多租戶隔離與 RAG 輔助決策的完整系統型態。", @@ -52,8 +52,8 @@ const translations = { directBatchBadge: "Direct batch write · 零閒置運算", transformStageBody: "Schema cast · 清理 · 可重跑轉換", silverStageBody: "Typed Parquet events", - silverBadge: "分析與已知風險特徵來源", - goldStageBody: "KPI · 留存 · 玩家特徵 · 實驗資料", + silverBadge: "分析與已知品質特徵來源", + goldStageBody: "每日/每小時 KPI · 留存 · 實體特徵 · 實驗資料", publicationBadge: "Publication marker 驗證完整版本", catalogControl: "固定 Bronze → Silver → Gold 結構與欄位定義", tenantControl: "依 client_site_id 套用 row-level data cell filter", @@ -61,16 +61,16 @@ const translations = { definitionControl: "同一套指標與特徵定義供 M1、M2、M3 使用", moduleMapKicker: "AUTOMATION & DECISION PATHS", moduleMapTitle: "四個模組如何消費資料並交付結果", - architectureM1Title: "異常與已知風險偵測", - architectureM1Body: "每日 DAU/GGR、每週留存與雙訊號風險檢查;告警自動觸發 M3 first-look,flagged players 另交人工審查。", + architectureM1Title: "使用異常與品質訊號偵測", + architectureM1Body: "每日活躍度、每週留存與雙訊號品質檢查;告警自動觸發 M3 first-look,需要判讀的紀錄另交人工審查。", architectureM2Title: "實驗生命週期與安全停止", architectureM2Body: "Lambda 執行 assignment、SRM、guardrail、analysis 與 readout;Gold features 與 registry state 一起決定實驗是否繼續。", architectureM2Branch: "SRM hard fail · hourly guardrail · allocation kill switch", architectureM3Title: "受治理問答與事件初判", architectureM3Body: "Guardrails 與 allow-listed templates 限制問題範圍;所有數字由 SQL 產生,無法回答的工作寫入 DynamoDB ticket。", architectureM3Branch: "Grounded answer · first-look report · durable ticket", - architectureM4Title: "身份隔離的 RAG 輔助支援", - architectureM4Body: "IAM 身份先選定 provider 或 operator 文檔;相關性與 code validator 通過才回覆,否則澄清或建立 OPEN ticket。", + architectureM4Title: "身份隔離的整合支援", + architectureM4Body: "IAM 身份先限定可用的支援知識;問題通過範圍與安全檢查後才產生回答,資訊不足時要求澄清,不支援的問題則拒絕回答或建立 OPEN ticket。", architectureM4Branch: "不讀取 Gold tables · SNS/SQS 僅作帳號內稽核", sharedRailTitle: "SHARED CONTROL PLANE", sharedIdentity: "身份決定可見範圍", @@ -85,28 +85,28 @@ const translations = { beforeLabel: "原本怎麼做", systemLabel: "系統接手", humanLabel: "人保留決策", - wf1Title: "KPI 異常與已知風險訊號", - wf1Before: "依賴人主動查看報表,發現下滑後才開始追查;已知套利訊號的調查證據也不一致。", - wf1System: "每日檢查 DAU/GGR 與已知雙訊號風險模式;每週檢查成熟的 D1/D7 cohort 留存,並附上 baseline、threshold 與證據。", - wf1Human: "判斷根因、採取營運處置;風險玩家只進入人工審查,不由模型自動定罪。", + wf1Title: "KPI 異常與已知品質訊號", + wf1Before: "依賴人主動查看報表,發現下滑後才開始追查;已知資料品質訊號的調查證據也不一致。", + wf1System: "每日檢查活躍度與互動量的異常模式;每週檢查成熟的 D1/D7 cohort 留存,並附上 baseline、threshold 與證據。", + wf1Human: "判斷根因、採取營運處置;需要判讀的紀錄進入人工審查,不由系統自動下結論。", wf1OpsLabel: "掃描與告警窗口", - wf1Ops: "DAU/GGR 每 24 小時只檢查最新完整發布日,使用前 20 天建立 baseline;成功完成後以 published_at 記錄消費進度。7/31 告警 7/30 後,8/1 正常只檢查 7/31;舊日期重發為新版本時才重新評估。", + wf1Ops: "每日 KPI 每 24 小時只檢查最新完整發布日,使用前 20 天建立 baseline;成功完成後以 published_at 記錄消費進度。每小時 Guardrail 讀取 gold_hourly_kpi;舊資料重發為新版本時才重新評估。", wf3Title: "Analytics NL Assistant", wf3Before: "突發的 what/why 問題打斷分析師;得先算過去常態是多少、再拆開看是哪幾款產品出問題,最後交叉比對各個相關變數之間是哪裡出錯。", wf3System: "允許清單 SQL 回答 what;first-look 與 diagnose 組合證據回答 why;答不了就建 ticket。", wf3Human: "判讀營運脈絡、確認根因,並決定後續處置。", wf3ScopeLabel: "答案品質控制", - wf3Scope: "答案只從受治理 KPI 與 allow-listed templates 產生;無法回答就建立 ticket,不生成任意 SQL,也不碰未治理的 country/player 維度。", + wf3Scope: "答案只從受治理 KPI 與 allow-listed templates 產生;無法回答就建立 ticket,不生成任意 SQL,也不碰未治理的地區/個別使用者維度。", wf2Title: "實驗運營平台", wf2Before: "並行實驗狀態散落在人與站會中;SRM、guardrail 靠人工抽查,共用 feature 重複開發。", wf2System: "中央註冊表、IAM 身分權限歸屬、數據品質檢查(SRM)、安全護欄監控、緊急熔斷開關與共享特徵註冊表。", wf2Human: "決定假設、指標、是否採納結果,以及異常實驗後的產品處置。", wf2StateLabel: "狀態觸發與儲存", wf2State: "實驗需要有正規的自動化與治理流程:具備專案權限的負責人,透過身分驗證的 API 或 CLI 指令建立實驗草稿,設定相關實驗參數後正式啟動,而不是由工程師簡單上傳一包數據。DynamoDB 保存當下最新狀態,並透過 Streams 即時同步至 S3/Athena snapshot,供 Dashboard 每 15 秒刷新。啟動後由 Step Functions 接管正常工作流,同時搭配每小時監控進行防護。目前可查建立、啟動、停止與分析完成時間,但尚未保存每次狀態變更的完整不可竄改事件紀錄。", - wf4Title: "RAG-style 合作夥伴支援", - wf4Before: "客服重複回答整合問題;無法處理時再把缺乏脈絡的問題轉給工程師。", - wf4System: "系統先依 IAM 權限選定可讀取的文檔庫,經相關性門檻過濾後將合格文檔交給 AWS Bedrock 產生解答;若文檔資料不足,則請使用者澄清說明或自動開單轉人工處理。", - wf4Human: "當文檔無法回答、AI 回覆未通過安全驗證,或案件需要產品/工程判斷時,系統會建立 OPEN ticket 供人工接手。目前 PoC 只將工單存進 DynamoDB,尚未串接 Email、Slack 或 CRM 通知。", + wf4Title: "受治理的整合支援", + wf4Before: "合作夥伴反覆詢問相似的整合與服務資訊;複雜問題常在缺少結構化脈絡時直接占用工程資源。", + wf4System: "系統先以 IAM 確認合作夥伴身份,再檢查問題是否屬於可支援範圍。合格問題由受限制的知識內容與 AWS Bedrock 產生回答;資訊不足時要求澄清,範圍外問題則明確拒絕。", + wf4Human: "只有需要產品/工程判斷、或安全驗證未通過的案件才建立 OPEN ticket 供人工接手。目前 PoC 將工單存進 DynamoDB,尚未串接 Email、Slack 或 CRM 通知。", wf4RagLabel: "RAG 路徑與上線準備", wf4Rag: "現在做 PoC 只是拿少量的測試檔案給 AI 看;未來正式上線時,要幫真實檔案加上「版本」與「權限」標籤。若文件量持續增長,再導入 Chunking 與向量搜尋,形成正規的大型 RAG 架構。", demoKicker: "FOUR CHAPTERS · TWO MINUTES", @@ -121,7 +121,7 @@ const translations = { demoM3Time: "22 秒", demoM2: "M2 · Experiment Ops", demoM2Time: "32 秒", - demoM4: "M4 · Support", + demoM4: "M4 · 整合支援", demoM4Time: "27 秒", demoPainLabel: "PAIN", demoResultLabel: "RESULT", @@ -131,7 +131,7 @@ const translations = { loopInvestigate: "Investigate", loopAct: "Act", loopValidate: "Validate", - loopNote: "這條閉環只適用 KPI/留存異常;已知風險訊號走獨立的 flagged players → human review;M2 只在適合以受控實驗驗證時介入。", + loopNote: "這條閉環只適用 KPI/留存異常;已知品質訊號走獨立的 flagged records → human review;M2 只在適合以受控實驗驗證時介入。", decisionsKicker: "SYSTEM DESIGN TRADE-OFFS & COST OPTIMIZATION", decisionsTitle: "系統設計取捨與成本最佳化。", decisionsIntro: "從使用頻率、延遲需求與服務完整度三個問題,決定現階段最合適的 AWS 能力;量測條件改變時再升級。", @@ -211,7 +211,7 @@ const translations = { heroEyebrow: "LEON LAI · BUSINESS-FIRST AWS SA PORTFOLIO", heroTitle1: "One cloud system architecture,", heroTitle2: "solving four practical operating pain points.", - heroLede: "Built primarily on serverless services, the design combines shared data, access boundaries and cost controls to address anomaly detection, experiment operations, ad-hoc analytics and partner support.", + heroLede: "Built primarily on serverless services, the design combines shared data, access boundaries and cost controls to address anomaly detection, experiment operations, ad-hoc analytics and partner integration support.", seeArchitecture: "Understand the architecture", seeDemo: "Explore the demo path", heroScope: "Synthetic data · Verified PoC · No production-workload claim", @@ -225,22 +225,22 @@ const translations = { problemsKicker: "THE OPERATING REALITY", problemsTitle: "Cloud and AI technology, connected to practical pain points.", problemsIntro: "Architecture design and PoC validation focus on four recurring situations that historically consumed team capacity.", - m1Short: "Anomaly / risk signals", + m1Short: "Anomaly / quality signals", p1Title: "Without active monitoring, discovery stays reactive.", - p1Body: "Data was not actively monitored. When retention or revenue dropped, the team waited for someone to notice before investigation began. Reviews of known arbitrage signals also lacked a consistent, traceable evidence format.", + p1Body: "Data was not actively monitored. When engagement or conversion dropped, the team waited for someone to notice before investigation began. Reviews of known data-quality signals also lacked a consistent, traceable evidence format.", m2Short: "Experiment operations", p2Title: "Parallel experiment status lived in people's heads.", - p2Body: "When different games ran A/B tests at the same time, there was no central way to see their status. People had to ask each owner or wait for the stand-up.", + p2Body: "When different product experiences ran A/B tests at the same time, there was no central way to see their status. People had to ask each owner or wait for the stand-up.", p2SecondaryIntro: "That core problem came with two smaller issues that kept adding operating cost:", p2Secondary1: "SRM and guardrails were spot-checked manually, delaying abnormal experiment stops.", p2Secondary2: "Shared data features were rebuilt by different owners.", m3Short: "Ad-hoc analytics", p3Title: "Dashboards did not answer a sudden “why?”", - p3Body: "Dashboards handled fixed questions, but they could not directly answer an executive, commercial lead or client asking, “Why did revenue drop today?”", + p3Body: "Dashboards handled fixed questions, but they could not directly answer an executive, commercial lead or client asking, “Why did the core KPI drop today?”", p3Impact: "These questions were urgent and arrived at unpredictable times, increasing analyst workload. As the client base grew, so did their frequency.", - m4Short: "Partner support", - p4Title: "Repeated customer questions consume support and engineering capacity.", - p4Body: "Partners across time zones repeatedly asked similar product-integration questions. Support had to answer the same issues, and anything it could not resolve escalated to engineers, adding development workload.", + m4Short: "Integration support", + p4Title: "Repeated integration questions consume support and engineering capacity.", + p4Body: "Partners across time zones repeatedly ask about API authentication, webhooks, environment setup, service updates and maintenance. Common questions require repeated answers, while complex cases consume engineering time.", architectureKicker: "SYSTEM SHAPE & BOUNDARIES", architectureTitle: "Three workflows share the lake. The fourth follows a separate knowledge path.", architectureIntro: "A complete system shape integrating shared data definitions, multi-tenant isolation and RAG-assisted decision support.", @@ -252,8 +252,8 @@ const translations = { directBatchBadge: "Direct batch write · zero idle compute", transformStageBody: "Schema cast · cleaning · rerunnable transforms", silverStageBody: "Typed Parquet events", - silverBadge: "Source for analytics and known-risk features", - goldStageBody: "KPI · retention · player features · experiment data", + silverBadge: "Source for analytics and known quality features", + goldStageBody: "daily/hourly KPI · retention · entity features · experiment data", publicationBadge: "Publication marker proves a complete version", catalogControl: "Fixes the Bronze → Silver → Gold structure and field definitions", tenantControl: "Applies row-level data cell filters by client_site_id", @@ -261,16 +261,16 @@ const translations = { definitionControl: "One metric and feature definition set serves M1, M2 and M3", moduleMapKicker: "AUTOMATION & DECISION PATHS", moduleMapTitle: "How four modules consume data and deliver outcomes", - architectureM1Title: "Anomaly and known-risk detection", - architectureM1Body: "Daily DAU/GGR, weekly retention and two-signal risk checks; alerts automatically trigger M3 first-look, while flagged players follow a separate human-review path.", + architectureM1Title: "Usage anomaly and quality-signal detection", + architectureM1Body: "Daily engagement, weekly retention and two-signal quality checks; alerts automatically trigger M3 first-look, while ambiguous records follow a separate human-review path.", architectureM2Title: "Experiment lifecycle and safety stops", architectureM2Body: "Lambda runs assignment, SRM, guardrail, analysis and readout; Gold features and registry state together determine whether an experiment continues.", architectureM2Branch: "SRM hard fail · hourly guardrail · allocation kill switch", architectureM3Title: "Governed Q&A and incident first look", architectureM3Body: "Guardrails and allow-listed templates bound the question space. SQL owns every number; unsupported work becomes a DynamoDB ticket.", architectureM3Branch: "Grounded answer · first-look report · durable ticket", - architectureM4Title: "Identity-isolated RAG-assisted support", - architectureM4Body: "IAM identity selects the provider or operator corpus. Only answers that pass relevance and code validation are returned; otherwise the system clarifies or opens a ticket.", + architectureM4Title: "Identity-isolated integration support", + architectureM4Body: "IAM identity limits the available support knowledge. In-scope questions must pass scope and safety checks before an answer is generated; insufficient input triggers clarification, while unsupported questions are refused or opened as a ticket.", architectureM4Branch: "No Gold-table access · SNS/SQS is account-local audit only", sharedRailTitle: "SHARED CONTROL PLANE", sharedIdentity: "Identity determines scope", @@ -285,28 +285,28 @@ const translations = { beforeLabel: "BEFORE", systemLabel: "SYSTEM TAKES OVER", humanLabel: "PEOPLE DECIDE", - wf1Title: "KPI anomalies & known risk signals", - wf1Before: "People checked reports and began investigation after noticing a drop; known arbitrage reviews also lacked consistent evidence.", - wf1System: "Daily DAU/GGR and known two-signal risk checks, plus weekly mature D1/D7 cohort retention checks, emit baselines, thresholds and evidence.", - wf1Human: "People identify root cause and choose the operational response; flagged players enter human review and are never auto-convicted by a model.", + wf1Title: "KPI anomalies & known quality signals", + wf1Before: "People checked reports and began investigation after noticing a drop; known data-quality reviews also lacked consistent evidence.", + wf1System: "Daily engagement and known two-signal quality checks, plus weekly mature D1/D7 cohort retention checks, emit baselines, thresholds and evidence.", + wf1Human: "People identify root cause and choose the operational response; ambiguous records enter human review and are never automatically judged by the system.", wf1OpsLabel: "SCAN & ALERT WINDOW", - wf1Ops: "Every 24 hours, DAU/GGR checks only the latest complete published date and uses up to 20 prior days as baseline. A successful run records published_at progress: after alerting on 7/30 on 7/31, the 8/1 run normally checks 7/31 only. A newly republished version is re-evaluated.", + wf1Ops: "Daily KPI checks inspect only the latest complete publication and use up to 20 prior days as baseline. Hourly guardrails read gold_hourly_kpi. A successful run records published_at progress, and only a newly republished version is re-evaluated.", wf3Title: "Analytics NL Assistant", wf3Before: "Sudden what/why questions interrupted analysts. They first had to calculate the normal baseline, split the issue by product, and then cross-check related variables to locate what went wrong.", wf3System: "Allow-listed SQL answers what; first-look and diagnose assemble evidence for why; unsupported work becomes a durable ticket.", wf3Human: "People interpret operating context, confirm root cause and choose what to do next.", wf3ScopeLabel: "ANSWER QUALITY CONTROL", - wf3Scope: "Answers come only from governed KPIs and allow-listed templates. Unsupported work becomes a ticket instead of arbitrary SQL, and ungoverned country/player dimensions stay out of scope.", + wf3Scope: "Answers come only from governed KPIs and allow-listed templates. Unsupported work becomes a ticket instead of arbitrary SQL, and ungoverned region/individual-user dimensions stay out of scope.", wf2Title: "Experiment operations platform", wf2Before: "Parallel experiment status lived in people and stand-ups; SRM and guardrails were manual, and shared features were rebuilt.", wf2System: "A central registry, IAM-based ownership, sample-ratio mismatch checks (SRM), safety guardrail monitoring, an emergency allocation kill switch and a shared feature registry.", wf2Human: "People choose hypotheses and metrics, decide whether to trust a result, and own the product response.", wf2StateLabel: "STATE TRIGGERS & STORAGE", wf2State: "Experiments follow a governed automated workflow rather than an engineer uploading a data package. An authorized owner uses an authenticated API or CLI to create a draft, configure the experiment and start it. DynamoDB stores the latest state, while Streams sync an S3/Athena snapshot for the dashboard to refresh every 15 seconds. Step Functions then runs the workflow with hourly protection checks. The current PoC records created, started, stopped and analyzed timestamps, but it does not yet retain a complete immutable event history for every state transition.", - wf4Title: "RAG-style partner support", - wf4Before: "Support repeated integration answers and forwarded unresolved questions to engineers without enough context.", - wf4System: "The system uses IAM permissions to select the document library the caller may read, filters it through a relevance threshold, and gives qualified documents to AWS Bedrock to produce an answer. If the documents are insufficient, it asks for clarification or opens a ticket for human handling.", - wf4Human: "The system creates an OPEN ticket when documents cannot answer, an AI response fails safety validation, or product/engineering judgement is required. The PoC stores the ticket in DynamoDB but does not yet send Email, Slack or CRM notifications.", + wf4Title: "Governed integration support", + wf4Before: "Partners repeated the same integration and service questions, while complex requests reached engineering without structured context.", + wf4System: "The system authenticates the partner with IAM and checks whether the question is within the supported scope. Qualified questions use restricted knowledge and AWS Bedrock to produce an answer; missing details trigger clarification, while out-of-scope questions are explicitly refused.", + wf4Human: "An OPEN ticket is created only when product or engineering judgement is required, or when safety validation fails. The PoC stores tickets in DynamoDB but does not yet send Email, Slack or CRM notifications.", wf4RagLabel: "RAG PATH & PRODUCTION SWAP", wf4Rag: "The PoC simply gives a small set of test files to the AI. Production content needs version and permission tags; as the document set grows, Chunking and vector search can form a full-scale RAG architecture.", demoKicker: "FOUR CHAPTERS · TWO MINUTES", @@ -321,7 +321,7 @@ const translations = { demoM3Time: "22 sec", demoM2: "M2 · Experiment Ops", demoM2Time: "32 sec", - demoM4: "M4 · Support", + demoM4: "M4 · Integration Support", demoM4Time: "27 sec", demoPainLabel: "PAIN", demoResultLabel: "RESULT", @@ -331,7 +331,7 @@ const translations = { loopInvestigate: "Investigate", loopAct: "Act", loopValidate: "Validate", - loopNote: "This loop covers KPI and retention anomalies only. Known risk signals follow a separate flagged players → human review path; M2 appears only when a controlled experiment is the right validation method.", + loopNote: "This loop covers KPI and retention anomalies only. Known quality signals follow a separate flagged records → human review path; M2 appears only when a controlled experiment is the right validation method.", decisionsKicker: "SYSTEM DESIGN TRADE-OFFS & COST OPTIMIZATION", decisionsTitle: "System design trade-offs and cost optimization.", decisionsIntro: "Usage frequency, latency needs and service completeness determine the right AWS capability now; measured changes trigger the next upgrade.", @@ -405,34 +405,34 @@ const demoChapters = { zh: { m1: { title: "M1 / VERIFIED DEMO OUTPUT", - pain: "留存/營收下滑需要等人發現;已知風險訊號缺少一致、可解釋的調查證據。", - result: "首日 KPI 下滑被標示;6 個 scripted ring players 全數進入 REVIEW_REQUIRED。", + pain: "活躍度/轉換表現下滑需要等人發現;已知品質訊號缺少一致、可解釋的調查證據。", + result: "首日 KPI 下滑被標示;6 筆 scripted quality records 全數進入 REVIEW_REQUIRED。", terminal: [ "$ run module1 demo --as-of 2026-06-10", "site_b / dau", "actual=91 · EWMA baseline=204 · deviation≈3.9σ", "status=ALERT · evidence_window=preserved", "", - "arbitrage review", + "data-quality review", "flagged=6/6 · decision=REVIEW_REQUIRED", ].join("\n"), - human: "確認根因、選擇營運處置,並審查被標示玩家。", + human: "確認根因、選擇營運處置,並審查需要判讀的紀錄。", detailLabel: "掃描與告警窗口", - detail: "DAU/GGR 每 24 小時只檢查最新完整發布日;相同 published_at 不重複。成熟 D1/D7 留存另於每週一檢查最近的完整 cohort 週。", + detail: "每日 KPI 每 24 小時只檢查最新完整發布日;相同 published_at 不重複。成熟 D1/D7 留存另於每週一檢查最近的完整 cohort 週。", }, m3: { title: "M3 / GOVERNED ANALYTICS OUTPUT", pain: "急迫的 what/why 問題會打斷分析師,得重新計算常態、拆分產品,再交叉比對相關變數。", result: "Allow-listed SQL 回答數字;first-look 在 alert 後自動整理基線與共變動。", terminal: [ - '$ ask "What was GGR for site_a in the last week?"', + '$ ask "What was DAU for site_b on 2026-06-10?"', "category=answerable", - "answer=891.83 USD", - "direct Athena cross-check=891.83 USD · MATCH", + "answer=91 active users", + "direct Athena cross-check=91 · MATCH", "", "site_b first-look", "DAU 91 vs 7d avg 205.5714 (-55.73%)", - "DAU and GGR moved in the same direction", + "active users and sessions moved together", ].join("\n"), human: "結合營運脈絡判斷根因,再決定行動。", detailLabel: "答案品質控制", @@ -446,8 +446,8 @@ const demoChapters = { "$ run 3 concurrent experiments", "clean_winner state=analyzed · lift=431.58%", " caveats=SMALL_SAMPLE,LARGE_EFFECT", - "guardrail_stop state=stopped_early", - " ggr_usd_7d=-101.6125 < 0", + "hourly_guardrail state=stopped_early", + " source=gold_hourly_kpi · threshold crossed", "srm_violation state=stopped_early", " p=0.0 · analysis skipped", "grounding_check_passed=true", @@ -457,11 +457,11 @@ const demoChapters = { detail: "具備權限的負責人透過驗證 API/CLI 建立與啟動;DynamoDB 保存最新狀態,Step Functions 與每小時監控寫入後續轉換。現有關鍵時間欄位可查,但還沒有完整不可竄改的狀態事件歷史。", }, m4: { - title: "M4 / RAG-STYLE SUPPORT & LEAKAGE CONTROL", - pain: "重複整合問題消耗客服;處理不了的問題缺乏結構就轉給工程師。", + title: "M4 / 受治理的整合支援", + pain: "重複的整合問題消耗支援資源;複雜案件缺少結構化脈絡就轉給工程師。", result: "系統先澄清、再回答或建立 ticket;模型洩漏被 code validator 擋下。", terminal: [ - "$ run partner-support demo", + "$ run integration-support demo", "ambiguous question → NEEDS_CLARIFICATION", "engineering case → ESCALATION · ticket=created", "out-of-scope → deterministic refusal", @@ -478,34 +478,34 @@ const demoChapters = { en: { m1: { title: "M1 / VERIFIED DEMO OUTPUT", - pain: "Retention and revenue drops waited for human discovery; known risk-signal reviews lacked consistent, explainable evidence.", - result: "The first-day KPI drop was flagged; all six scripted ring players entered REVIEW_REQUIRED.", + pain: "Engagement and conversion drops waited for human discovery; known quality-signal reviews lacked consistent, explainable evidence.", + result: "The first-day KPI drop was flagged; all six scripted quality records entered REVIEW_REQUIRED.", terminal: [ "$ run module1 demo --as-of 2026-06-10", "site_b / dau", "actual=91 · EWMA baseline=204 · deviation≈3.9σ", "status=ALERT · evidence_window=preserved", "", - "arbitrage review", + "data-quality review", "flagged=6/6 · decision=REVIEW_REQUIRED", ].join("\n"), - human: "Confirm root cause, choose an operating response and review flagged players.", + human: "Confirm root cause, choose an operating response and review ambiguous records.", detailLabel: "SCAN & ALERT WINDOW", - detail: "DAU/GGR runs every 24 hours against the latest complete published date and does not repeat the same published_at. Mature D1/D7 retention checks the latest complete cohort week every Monday.", + detail: "Daily KPI checks run against the latest complete published date and do not repeat the same published_at. Mature D1/D7 retention checks the latest complete cohort week every Monday.", }, m3: { title: "M3 / GOVERNED ANALYTICS OUTPUT", pain: "Urgent what/why questions interrupted analysts, forcing them to recalculate normal baselines, split results by product and cross-check related variables.", result: "Allow-listed SQL owns the number; first-look assembles baseline and co-movement evidence after an alert.", terminal: [ - '$ ask "What was GGR for site_a in the last week?"', + '$ ask "What was DAU for site_b on 2026-06-10?"', "category=answerable", - "answer=891.83 USD", - "direct Athena cross-check=891.83 USD · MATCH", + "answer=91 active users", + "direct Athena cross-check=91 · MATCH", "", "site_b first-look", "DAU 91 vs 7d avg 205.5714 (-55.73%)", - "DAU and GGR moved in the same direction", + "active users and sessions moved together", ].join("\n"), human: "Combine operating context with evidence, identify root cause and decide what to do.", detailLabel: "ANSWER QUALITY CONTROL", @@ -519,8 +519,8 @@ const demoChapters = { "$ run 3 concurrent experiments", "clean_winner state=analyzed · lift=431.58%", " caveats=SMALL_SAMPLE,LARGE_EFFECT", - "guardrail_stop state=stopped_early", - " ggr_usd_7d=-101.6125 < 0", + "hourly_guardrail state=stopped_early", + " source=gold_hourly_kpi · threshold crossed", "srm_violation state=stopped_early", " p=0.0 · analysis skipped", "grounding_check_passed=true", @@ -530,11 +530,11 @@ const demoChapters = { detail: "An authorized owner creates and starts experiments through an authenticated API/CLI. DynamoDB stores the latest state, while Step Functions and hourly monitoring write later transitions. Key timestamps exist, but a complete immutable state-event history does not yet.", }, m4: { - title: "M4 / RAG-STYLE SUPPORT & LEAKAGE CONTROL", - pain: "Repeated integration questions consumed support; unresolved cases reached engineers without structure.", + title: "M4 / GOVERNED INTEGRATION SUPPORT", + pain: "Repeated integration questions consumed support capacity; complex cases reached engineers without structured context.", result: "The system clarifies, answers or creates a ticket; code blocks a model-authored internal identifier.", terminal: [ - "$ run partner-support demo", + "$ run integration-support demo", "ambiguous question → NEEDS_CLARIFICATION", "engineering case → ESCALATION · ticket=created", "out-of-scope → deterministic refusal", diff --git a/site/index.html b/site/index.html index 2bbf4a4..c6b6e80 100644 --- a/site/index.html +++ b/site/index.html @@ -68,7 +68,7 @@

    解決四個實際營運痛點。

    - 以 Serverless 為主的設計,建構於資料共用、權限邊界與成本控制之上,解決異常偵測、實驗運營、臨時分析與合作夥伴支援問題。 + 以 Serverless 為主的設計,建構於資料共用、權限邊界與成本控制之上,解決異常偵測、實驗運營、臨時分析與合作夥伴整合支援問題。

    @@ -99,7 +99,7 @@

    operating workflows

    - 145 + 146 offline tests
    @@ -133,11 +133,11 @@

    雲端 AI 技術鏈接實務痛點

    M1 - 異常/風險訊號 + 異常/品質訊號

    沒有主動監控,只能被動發現。

    - 數據缺乏主動監控;一旦留存或營收下滑,通常要等到人為發現才開始處理。已知套利風險也缺少一致、可回溯的證據格式,調查品質容易依賴個人經驗。 + 數據缺乏主動監控;一旦活躍度或轉換表現下滑,通常要等到人為發現才開始處理。已知資料品質訊號也缺少一致、可回溯的證據格式,調查品質容易依賴個人經驗。

    @@ -148,7 +148,7 @@

    沒有主動監控,只能被動發現。

    並行實驗的狀態存在每個人的腦中。

    - 不同遊戲的 A/B testing 同時進行時,沒有中央化方式查看並行實驗狀態,得一個一個問人,或等到站會才知道。 + 不同產品體驗的 A/B testing 同時進行時,沒有中央化方式查看並行實驗狀態,得一個一個問人,或等到站會才知道。

    這個核心問題同時還伴隨兩個次要、但會持續增加營運成本的問題: @@ -166,7 +166,7 @@

    並行實驗的狀態存在每個人的腦中。

    Dashboard 回答不了突然出現的「為什麼」。

    - Dashboard 可以回答固定問題,但當老闆、業務或客戶突然詢問「今天營收為什麼掉了?」時,現有 dashboard 往往無法直接回答。 + Dashboard 可以回答固定問題,但當老闆、業務或客戶突然詢問「今天核心指標為什麼掉了?」時,現有 dashboard 往往無法直接回答。

    這類提問通常伴隨急迫性,又會在不可預期的時間出現,進一步加重分析人員的工作負擔;當客戶數量增加,問題頻率也會隨之上升。 @@ -176,11 +176,11 @@

    Dashboard 回答不了突然出現的「為什麼」。<
    M4 - 合作夥伴支援 + 整合支援
    -

    重複性客戶問題消耗客服與開發資源。

    +

    重複的整合問題消耗支援與工程資源。

    - 不同時區的合作夥伴經常反覆詢問類似的產品對接與整合問題:一方面客服需要重複回答;另一方面,若客服無法處理,問題就會升級到工程師層面,進一步增加開發負擔。 + 不同時區的合作夥伴經常詢問相似的 API 驗證、Webhook、環境設定、服務更新與維護資訊;常見問題需要反覆回覆,複雜案件則占用工程團隊時間。

    @@ -200,142 +200,129 @@

    三條工作流共用資料湖;第四條走
    -
    -
    - 01 -
    - END-TO-END DATA FOUNDATION -

    資料生成、治理與發布路徑

    -
    -
    - -
    -
    - INPUT - event_simulator - 模擬 client SDK 事件與可重播情境 - Synthetic Python CLI -
    - -
    - INGEST · RAW - Amazon S3 · Bronze - Raw JSON · 日期/租戶分區 - Direct batch write · 零閒置運算 -
    - -
    - TRANSFORM - Amazon Athena · CTAS - Schema cast · 清理 · 可重跑轉換 - AWS Glue Data Catalog -
    - -
    - CURATED - Amazon S3 · Silver - Typed Parquet events - 分析與已知風險特徵來源 -
    - -
    - PUBLISHED - Amazon S3 · Gold - KPI · 留存 · 玩家特徵 · 實驗資料 - Publication marker 驗證完整版本 -
    -
    - -
    -
    - CATALOG - AWS Glue Data Catalog - 固定 Bronze → Silver → Gold 結構與欄位定義 -
    -
    - TENANT BOUNDARY - AWS Lake Formation - 依 client_site_id 套用 row-level data cell filter -
    -
    - QUERY CONTROL - Amazon Athena Workgroup - 查詢結果隔離與 1 GB 單次掃描上限 -
    -
    - DEFINITION OF RECORD - KPI_DEFINITIONS · FEATURES - 同一套指標與特徵定義供 M1、M2、M3 使用 -
    -
    -
    + -
    -
    - 02 -
    - AUTOMATION & DECISION PATHS -

    四個模組如何消費資料並交付結果

    -
    -
    - -
    -
    -
    M1
    DETECT

    異常與已知風險偵測

    -
    - TRIGGEREventBridge→ - COMPUTEAWS Lambda→ - QUERYAmazon Athena→ - ALERTAmazon SNS +
    +
    +
    + 01 +
    + END-TO-END DATA FOUNDATION +

    資料生成、治理與發布路徑

    -

    每日 DAU/GGR、每週留存與雙訊號風險檢查;告警自動觸發 M3 first-look,flagged players 另交人工審查。

    -
    OPT-IN REAL-TIME DEMOKinesis → Lambda → DynamoDB TTL → SNS
    -
    - -
    -
    M2
    EXPERIMENT OPS

    實驗生命週期與安全停止

    -
    - ENTRYAPI Gateway→ - STATEDynamoDB→ - ORCHESTRATEStep Functions→ - READOUTBedrock + + +
    +
    + INPUT + Client events + 模擬 client SDK 事件與可重播情境 +
    + +
    + DATA PREPARATION +
    +
    Amazon S3Bronze · Raw JSON
    + +
    Athena CTASSchema cast · 清理 · 可重跑轉換
    + +
    Amazon S3Silver · Typed Parquet
    +
    -

    Lambda 執行 assignment、SRM、guardrail、analysis 與 readout;Gold features 與 registry state 一起決定實驗是否繼續。

    -
    CONTROLSRM hard fail · hourly guardrail · allocation kill switch
    -
    - -
    -
    M3
    INVESTIGATE

    受治理問答與事件初判

    -
    - ENTRYAPI GW / SNS→ - CLASSIFYBedrock→ - FACTSAthena SQL→ - DELIVERS3 · SNS/SQS + +
    + PUBLISHED GOLD DATA PRODUCTS +
    + gold_daily_kpiDaily operations + gold_hourly_kpiHourly guardrails + cohort_retentionMature cohorts + entity_featuresAnalysis / ML +
    +

    Publication marker 驗證完整版本

    -

    Guardrails 與 allow-listed templates 限制問題範圍;所有數字由 SQL 產生,無法回答的工作寫入 DynamoDB ticket。

    -
    HUMAN OUTPUTGrounded answer · first-look report · durable ticket
    -
    +
    +
    + + -
    -
    M4
    PARTNER SUPPORT

    身份隔離的 RAG 輔助支援

    -
    - IDENTITYIAM / API GW→ - POLICYBedrock Guardrails→ - ANSWERNova Lite⇢ - ESCALATEDynamoDB +
    +
    + 02 +
    + AUTOMATION & DECISION PATHS +

    四個模組如何消費資料並交付結果

    -

    IAM 身份先選定 provider 或 operator 文檔;相關性與 code validator 通過才回覆,否則澄清或建立 OPEN ticket。

    -
    SEPARATE KNOWLEDGE PATH不讀取 Gold tables · SNS/SQS 僅作帳號內稽核
    +
    + + +
    +
    +
    M1
    DETECT

    使用異常與品質訊號偵測

    +

    EventBridge → Lambda → Athena → SNS

    +

    每日活躍度、每週留存與雙訊號品質檢查;告警自動觸發 M3 first-look,需要判讀的紀錄另交人工審查。

    + ALERT + EVIDENCE +
    + +
    +
    M3
    INVESTIGATE

    受治理問答與事件初判

    +

    API / SNS → Bedrock → Athena SQL

    +

    Guardrails 與 allow-listed templates 限制問題範圍;所有數字由 SQL 產生,無法回答的工作寫入 DynamoDB ticket。

    + Grounded answer · first-look report · durable ticket +
    +
    +
    + +
    +
    CURATED DOCUMENTSVersioned integration guidance
    + +
    +
    M4
    INTEGRATION SUPPORT

    身份隔離的整合支援

    +

    IAM → Relevance → Bedrock Guardrails → Code validator

    + IAM 身份先限定可用的支援知識;問題通過範圍與安全檢查後才產生回答,資訊不足時要求澄清,不支援的問題則拒絕回答或建立 OPEN ticket。
    -
    - - -
    -

    SHARED CONTROL PLANE

    -
    - 01Identity determines scope - 02Code owns facts and routing - 03Default deployment is cost-bounded + +
    DELIVERYAnswer / Clarify / OPEN ticket不讀取 Gold tables · SNS/SQS 僅作帳號內稽核
    + + +
    +
    + USERS + OPSOperations + ANAnalytics + PDProduct + IPIntegration +
    + +
    GOVERNED INTERFACESDashboard · API · Reports · Integration support
    + +
    HUMAN-OWNED OUTCOMESInvestigate · Decide · Escalate · Validate
    +
    + +
    +

    SHARED CONTROL PLANE

    +
    + 01Identity determines scope + 02Code owns facts and routing + 03Default deployment is cost-bounded +
    @@ -368,25 +355,25 @@

    四個工作流,改變四種人的工作方式 01
    M1 · DETECTION -

    KPI 異常與已知風險訊號

    +

    KPI 異常與已知品質訊號

    原本怎麼做 -

    依賴人主動查看報表,發現下滑後才開始追查;已知套利訊號的調查證據也不一致。

    +

    依賴人主動查看報表,發現下滑後才開始追查;已知資料品質訊號的調查證據也不一致。

    系統接手 -

    每日檢查 DAU/GGR 與已知雙訊號風險模式;每週檢查成熟的 D1/D7 cohort 留存,並附上 baseline、threshold 與證據。

    +

    每日檢查活躍度與互動量的異常模式;每週檢查成熟的 D1/D7 cohort 留存,並附上 baseline、threshold 與證據。

    人保留決策 -

    判斷根因、採取營運處置;風險玩家只進入人工審查,不由模型自動定罪。

    +

    判斷根因、採取營運處置;需要判讀的紀錄進入人工審查,不由系統自動下結論。

    掃描與告警窗口 -

    DAU/GGR 每 24 小時只檢查最新完整發布日,使用前 20 天建立 baseline;成功完成後以 published_at 記錄消費進度。7/31 告警 7/30 後,8/1 正常只檢查 7/31;舊日期重發為新版本時才重新評估。

    +

    每日 KPI 每 24 小時只檢查最新完整發布日,使用前 20 天建立 baseline;成功完成後以 published_at 記錄消費進度。每小時 Guardrail 讀取 gold_hourly_kpi;舊資料重發為新版本時才重新評估。

    @@ -414,7 +401,7 @@

    Analytics NL Assistant

    答案品質控制 -

    答案只從受治理 KPI 與 allow-listed templates 產生;無法回答就建立 ticket,不生成任意 SQL,也不碰未治理的 country/player 維度。

    +

    答案只從受治理 KPI 與 allow-listed templates 產生;無法回答就建立 ticket,不生成任意 SQL,也不碰未治理的地區/個別使用者維度。

    @@ -451,22 +438,22 @@

    實驗運營平台

    04
    - M4 · PARTNER SUPPORT -

    RAG-style 合作夥伴支援

    + M4 · INTEGRATION SUPPORT +

    受治理的整合支援

    原本怎麼做 -

    客服重複回答整合問題;無法處理時再把缺乏脈絡的問題轉給工程師。

    +

    合作夥伴反覆詢問相似的整合與服務資訊;複雜問題常在缺少結構化脈絡時直接占用工程資源。

    系統接手 -

    系統先依 IAM 權限選定可讀取的文檔庫,經相關性門檻過濾後將合格文檔交給 AWS Bedrock 產生解答;若文檔資料不足,則請使用者澄清說明或自動開單轉人工處理。

    +

    系統先以 IAM 確認合作夥伴身份,再檢查問題是否屬於可支援範圍。合格問題由受限制的知識內容與 AWS Bedrock 產生回答;資訊不足時要求澄清,範圍外問題則明確拒絕。

    人保留決策 -

    當文檔無法回答、AI 回覆未通過安全驗證,或案件需要產品/工程判斷時,系統會建立 OPEN ticket 供人工接手。目前 PoC 只將工單存進 DynamoDB,尚未串接 Email、Slack 或 CRM 通知。

    +

    只有需要產品/工程判斷、或安全驗證未通過的案件才建立 OPEN ticket 供人工接手。目前 PoC 將工單存進 DynamoDB,尚未串接 Email、Slack 或 CRM 通知。

    RAG 路徑與上線準備 @@ -534,7 +521,7 @@

    兩分鐘實機操作:四組工作流實際運行

    @@ -568,7 +555,7 @@

    兩分鐘實機操作:四組工作流實際運行05Validate

    - 這條閉環只適用 KPI/留存異常;已知風險訊號走獨立的 flagged players → human review;M2 只在適合以受控實驗驗證時介入。 + 這條閉環只適用 KPI/留存異常;已知品質訊號走獨立的 flagged records → human review;M2 只在適合以受控實驗驗證時介入。

    @@ -750,7 +737,7 @@

    工程驗證與架構文件

    VERIFICATION01
    - 145 / 145 + 146 / 146 測試通過率(100%) 離線單元測試、資安邊界與 CDK assertions
    diff --git a/site/styles.css b/site/styles.css index 3722d7f..12b8607 100644 --- a/site/styles.css +++ b/site/styles.css @@ -887,6 +887,444 @@ blockquote { font-weight: 800; } +.architecture-stage-rail { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.architecture-stage-rail span { + padding: 10px 12px; + color: #f5ddff; + background: linear-gradient(90deg, rgba(117, 77, 255, 0.4), rgba(226, 63, 209, 0.33)); + border: 1px solid rgba(220, 132, 255, 0.72); + box-shadow: 0 0 18px rgba(171, 72, 255, 0.14); + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 850; + letter-spacing: 0.09em; + text-align: center; +} + +.system-blueprint { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 14px; + padding: 18px; + background: + linear-gradient(rgba(76, 116, 142, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(76, 116, 142, 0.08) 1px, transparent 1px), + #071723; + background-size: 32px 32px; + border: 1px solid rgba(132, 164, 186, 0.26); +} + +.blueprint-lane, +.security-guardian, +.knowledge-lane, +.people-lane { + position: relative; + background: rgba(8, 27, 40, 0.9); + border: 1px solid rgba(196, 105, 255, 0.66); + box-shadow: inset 0 0 0 1px rgba(72, 220, 205, 0.08), 0 0 24px rgba(149, 64, 231, 0.08); +} + +.data-lane { + grid-column: 1 / 10; + padding: 19px; +} + +.security-guardian { + grid-column: 10 / 13; + padding: 18px; + border-color: rgba(231, 173, 60, 0.72); +} + +.intelligence-lane, +.knowledge-lane, +.people-lane, +.system-blueprint > .shared-rail { + grid-column: 1 / 13; +} + +.intelligence-lane { + padding: 19px; +} + +.blueprint-heading { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 16px; +} + +.blueprint-heading > span { + display: grid; + width: 38px; + height: 38px; + flex: 0 0 auto; + color: #081722; + background: var(--amber); + place-items: center; + font-family: var(--mono); + font-size: 0.78rem; + font-weight: 900; +} + +.blueprint-heading small, +.pipeline-track > div > small, +.security-guardian > strong, +.knowledge-lane small, +.people-lane small { + color: #dba5ff; + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 850; + letter-spacing: 0.08em; +} + +.blueprint-heading h3 { + margin: 2px 0 0; + color: #fff8e9; + font-family: var(--display); + font-size: 1.35rem; + line-height: 1.05; +} + +.pipeline-track { + display: grid; + grid-template-columns: minmax(120px, 0.7fr) auto minmax(350px, 2fr) auto minmax(220px, 1.15fr); + gap: 10px; + align-items: stretch; +} + +.blueprint-arrow, +.pipeline-track > i, +.knowledge-lane > i, +.people-lane > i { + align-self: center; + color: #f7f9fa; + font-size: 1.35rem; + font-style: normal; + font-weight: 900; +} + +.blueprint-node { + display: grid; + gap: 6px; + min-width: 0; + padding: 12px; + background: rgba(24, 58, 77, 0.72); + border: 1px solid rgba(72, 220, 205, 0.46); +} + +.blueprint-node small { + color: var(--amber); + font-family: var(--mono); + font-size: 0.66rem; + letter-spacing: 0.07em; +} + +.blueprint-node strong, +.blueprint-node b { + color: #f5fbff; + font-family: var(--display); + font-size: 0.98rem; + line-height: 1.15; +} + +.blueprint-node span { + color: rgba(255, 255, 255, 0.65); + font-size: 0.77rem; + line-height: 1.35; +} + +.input-node { + align-content: center; + border-color: rgba(231, 173, 60, 0.58); +} + +.preparation-zone, +.gold-zone { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 10px; + padding: 12px; + background: rgba(92, 46, 145, 0.13); + border: 1px solid rgba(196, 105, 255, 0.52); +} + +.preparation-zone > div { + display: grid; + grid-template-columns: 1fr auto 1fr auto 1fr; + gap: 6px; + align-items: stretch; +} + +.preparation-zone > div > i { + align-self: center; + color: var(--coral); + font-style: normal; + font-weight: 900; +} + +.gold-zone > div { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.gold-zone > div > span { + display: grid; + gap: 3px; + min-width: 0; + padding: 8px; + background: rgba(21, 163, 154, 0.12); + border: 1px solid rgba(72, 220, 205, 0.35); +} + +.gold-zone .hourly-product { + border-color: rgba(231, 173, 60, 0.78); + box-shadow: inset 3px 0 var(--amber); +} + +.gold-zone b { + color: #e7fff8; + font-family: var(--mono); + font-size: 0.62rem; + overflow-wrap: anywhere; +} + +.gold-zone i { + color: rgba(255, 255, 255, 0.52); + font-size: 0.63rem; + font-style: normal; +} + +.gold-zone > p { + margin: 0; + color: var(--amber); + font-family: var(--mono); + font-size: 0.61rem; +} + +.security-guardian > strong { + display: block; + margin-bottom: 12px; + color: var(--amber); +} + +.security-guardian > div { + display: grid; + gap: 8px; +} + +.security-guardian span { + display: grid; + gap: 4px; + padding: 9px 10px; + background: rgba(231, 173, 60, 0.08); + border: 1px solid rgba(231, 173, 60, 0.28); +} + +.security-guardian b { + color: #fff4d6; + font-size: 0.78rem; +} + +.security-guardian i { + color: rgba(255, 255, 255, 0.57); + font-size: 0.69rem; + font-style: normal; + line-height: 1.35; +} + +.gold-distribution { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: -2px 4% 4px; + color: rgba(72, 220, 205, 0.8); + text-align: center; +} + +.gold-distribution span { + grid-column: 1 / -1; + justify-self: center; + padding: 4px 12px; + color: var(--night-deep); + background: var(--teal-bright); + font-family: var(--mono); + font-size: 0.62rem; + font-weight: 900; +} + +.gold-distribution i { + font-style: normal; +} + +.consumer-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.consumer-node { + display: grid; + grid-template-rows: auto auto 1fr auto; + min-width: 0; + min-height: 230px; + padding: 14px; + background: rgba(24, 58, 77, 0.66); + border: 1px solid rgba(196, 105, 255, 0.5); +} + +.consumer-node.featured-consumer { + border-color: rgba(231, 173, 60, 0.72); +} + +.consumer-node header, +.knowledge-agent header { + display: flex; + gap: 9px; + align-items: center; +} + +.consumer-node header > b, +.knowledge-agent header > b { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + color: #091822; + background: #dba5ff; + place-items: center; + font-family: var(--mono); + font-size: 0.68rem; +} + +.consumer-node header small, +.knowledge-agent header small { + color: var(--teal-bright); + font-family: var(--mono); + font-size: 0.58rem; +} + +.consumer-node h4, +.knowledge-agent h4 { + margin: 2px 0 0; + color: #fff8e9; + font-family: var(--display); + font-size: 1rem; + line-height: 1.08; +} + +.consumer-node > p, +.knowledge-agent > span { + color: rgba(255, 255, 255, 0.65); + font-size: 0.75rem; + line-height: 1.4; +} + +.consumer-node .service-chain, +.knowledge-agent > p { + margin: 12px 0 0; + color: #bff6e8; + font-family: var(--mono); + font-size: 0.62rem; +} + +.consumer-output { + align-self: end; + padding: 7px 8px; + color: var(--amber); + background: rgba(231, 173, 60, 0.08); + border: 1px dashed rgba(231, 173, 60, 0.45); + font-family: var(--mono); + font-size: 0.59rem; + line-height: 1.35; +} + +.knowledge-lane, +.people-lane { + display: grid; + grid-template-columns: minmax(170px, 0.8fr) auto minmax(330px, 1.8fr) auto minmax(210px, 1fr); + gap: 10px; + align-items: stretch; + padding: 14px; +} + +.knowledge-source, +.knowledge-agent, +.knowledge-output, +.people-group, +.interface-group, +.decision-group { + display: grid; + gap: 7px; + align-content: center; + min-width: 0; + padding: 12px; + background: rgba(24, 58, 77, 0.66); + border: 1px solid rgba(72, 220, 205, 0.34); +} + +.knowledge-source b, +.knowledge-output b, +.interface-group b, +.decision-group b { + color: #f4fbff; + font-size: 0.78rem; + line-height: 1.35; +} + +.knowledge-output span { + color: rgba(255, 255, 255, 0.5); + font-size: 0.65rem; + line-height: 1.35; +} + +.people-lane { + border-style: dashed; + border-color: rgba(255, 255, 255, 0.44); +} + +.people-group { + grid-template-columns: repeat(4, 1fr); +} + +.people-group > small { + grid-column: 1 / -1; +} + +.people-group span { + display: grid; + justify-items: center; + gap: 4px; +} + +.people-group b { + display: grid; + width: 32px; + height: 32px; + color: #071723; + background: var(--teal-bright); + border-radius: 50%; + place-items: center; + font-family: var(--mono); + font-size: 0.57rem; +} + +.people-group i { + color: rgba(255, 255, 255, 0.58); + font-size: 0.57rem; + font-style: normal; +} + +.system-blueprint > .shared-rail { + margin-top: 0; +} + .architecture-legend { display: flex; gap: 22px; @@ -1836,6 +2274,15 @@ footer a { display: none; } + .data-lane, + .security-guardian { + grid-column: 1 / 13; + } + + .pipeline-track { + grid-template-columns: minmax(120px, 0.65fr) auto minmax(330px, 1.7fr) auto minmax(210px, 1fr); + } + .workflow-grid { grid-template-columns: 1fr 1fr; } @@ -1906,6 +2353,31 @@ footer a { grid-template-columns: 1fr; } + .architecture-stage-rail { + grid-template-columns: 1fr 1fr; + } + + .pipeline-track, + .knowledge-lane, + .people-lane { + grid-template-columns: 1fr; + } + + .pipeline-track > i, + .knowledge-lane > i, + .people-lane > i { + justify-self: center; + transform: rotate(90deg); + } + + .consumer-grid { + grid-template-columns: 1fr; + } + + .gold-distribution { + display: none; + } + .foundation-flow .gold-service { grid-column: auto; } @@ -2052,6 +2524,29 @@ footer a { padding: 13px; } + .system-blueprint { + padding: 9px; + } + + .architecture-stage-rail { + grid-template-columns: 1fr; + } + + .preparation-zone > div, + .gold-zone > div, + .people-group { + grid-template-columns: 1fr; + } + + .preparation-zone > div > i { + justify-self: center; + transform: rotate(90deg); + } + + .people-group > small { + grid-column: auto; + } + .foundation-map, .module-map { padding: 16px; diff --git a/tests/test_cdk_guardrails.py b/tests/test_cdk_guardrails.py index 4696af2..12aebea 100644 --- a/tests/test_cdk_guardrails.py +++ b/tests/test_cdk_guardrails.py @@ -157,6 +157,7 @@ def test_first_look_subscription_filters_on_alert_type(stacks): policy = resource["Properties"].get("FilterPolicy") assert policy and policy.get("alert_type") == [ "data_anomaly", + "hourly_data_anomaly", "retention_anomaly", ], ( f"{logical_id} filter policy is {policy!r}, expected business alert types" diff --git a/tests/test_demo_console_presentation.py b/tests/test_demo_console_presentation.py new file mode 100644 index 0000000..80af6f6 --- /dev/null +++ b/tests/test_demo_console_presentation.py @@ -0,0 +1,78 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_modules_open_with_architecture_and_operation_views(): + html = (ROOT / "demo_console" / "index.html").read_text(encoding="utf-8") + + assert html.count('data-mode="architecture"') == 4 + assert html.count('data-mode="interface"') == 4 + assert html.count('data-panel="architecture"') == 4 + assert "gold_hourly_kpi" in html + assert "gold_hourly_monitoring_features" in html + assert html.count('class="system-map') == 4 + assert "THE FOUNDATION" in html + assert "One event, three outputs" in html + + +def test_m1_is_hourly_monitoring_not_a_manual_scan_tool(): + html = (ROOT / "demo_console" / "index.html").read_text(encoding="utf-8") + script = (ROOT / "demo_console" / "app.js").read_text(encoding="utf-8") + + assert "每小時活躍使用者" in html + assert 'id="hourly-chart"' in html + assert "PoC snapshot · Synthetic hourly data" in html + assert "執行異常掃描" not in html + assert "run-anomaly" not in html + assert "run-anomaly" not in script + assert "每小時資料處理量" in html + assert 'id="open-first-look"' in html + + +def test_m3_architecture_and_default_report_match_the_hourly_flow(): + html = (ROOT / "demo_console" / "index.html").read_text(encoding="utf-8") + + m3 = html[html.index('id="m3"'):html.index('id="m2"')] + assert m3.index("Athena") < m3.index("Bedrock") + assert "PoC snapshot · Hourly demo" in m3 + assert "2 vs 9.3" in m3 + assert "SQS Audit" in m3 + + +def test_m2_shows_identity_checks_and_both_decision_branches(): + html = (ROOT / "demo_console" / "index.html").read_text(encoding="utf-8") + + m2 = html[html.index('id="m2"'):html.index('id="m4"')] + for expected in ( + "IAM", + "Initial SRM", + "Exposure SRM", + "Hourly Guardrail", + "Continue", + "Auto-stop", + 'id="experiment-detail"', + ): + assert expected in m2 + + +def test_m4_is_integration_support_with_answer_and_refusal_scenarios(): + html = (ROOT / "demo_console" / "index.html").read_text(encoding="utf-8") + script = (ROOT / "demo_console" / "app.js").read_text(encoding="utf-8") + + m4 = html[html.index('id="m4"'):] + assert "整合支援" in m4 + assert m4.count("data-demo-question=") == 2 + assert "正常整合問題" in m4 + assert "範圍外問題" in m4 + assert "OUT_OF_SCOPE" in script + assert "model not invoked" in script + assert "文件助理" not in html + + +def test_public_console_copy_uses_neutral_metric_names(): + html = (ROOT / "demo_console" / "index.html").read_text(encoding="utf-8") + + for disallowed in ("投注", "下注", "賭", "博弈", "客服"): + assert disallowed not in html diff --git a/tests/test_module1_hourly.py b/tests/test_module1_hourly.py new file mode 100644 index 0000000..c528fe4 --- /dev/null +++ b/tests/test_module1_hourly.py @@ -0,0 +1,105 @@ +"""Hourly monitoring consumes prepared same-hour features and publishes evidence.""" +import io +import json + +from conftest import REPO_ROOT, load_handler + +M1 = REPO_ROOT / "module1-anomaly-detection" +hourly = load_handler( + "m1_hourly_handler", + M1 / "data_anomaly" / "lambda" / "detector", + extra_paths=[M1 / "data_anomaly" / "lambda" / "common" / "python"], + env={ + "LAKE_BUCKET_NAME": "test-lake", + "ALERTS_TOPIC_ARN": "arn:aws:sns:ap-northeast-1:123:test", + "GLUE_DATABASE_NAME": "test", + "ATHENA_WORKGROUP_NAME": "test", + "AWS_DEFAULT_REGION": "ap-northeast-1", + }, +) + + +class FakeS3: + class exceptions: + class NoSuchKey(Exception): + pass + + def __init__(self, objects=None): + self.objects = objects or {} + + def get_object(self, Bucket, Key): + if Key not in self.objects: + raise self.exceptions.NoSuchKey() + value = self.objects[Key] + return {"Body": io.BytesIO(json.dumps(value).encode())} + + def put_object(self, Bucket, Key, Body, **kwargs): + self.objects[Key] = json.loads(Body) + + +class FakeSns: + def __init__(self): + self.messages = [] + + def publish(self, **kwargs): + self.messages.append(kwargs) + + +def _feature(): + row = { + "event_hour": "2026-06-10 13:00:00.000", + "client_site_id": "site_b", + "baseline_points": "7", + "active_users": "2", + "active_users_baseline": "9.3", + "active_users_lower_bound": "5", + "active_users_upper_bound": "14", + "sessions": "8", + "sessions_baseline": "8.1", + "sessions_lower_bound": "5", + "sessions_upper_bound": "12", + "processed_events": "19", + "processed_events_baseline": "81.6", + "processed_events_lower_bound": "40", + "processed_events_upper_bound": "123", + } + return row + + +def test_hourly_check_uses_precomputed_bounds_and_publishes(monkeypatch): + fake_s3 = FakeS3() + fake_sns = FakeSns() + monkeypatch.setattr(hourly, "s3", fake_s3) + monkeypatch.setattr(hourly, "sns", fake_sns) + monkeypatch.setattr(hourly, "_fetch_hourly_feature", lambda *_: _feature()) + + result = hourly._check_hourly_site("site_b", "2026-06-10 13:00:00.000") + + assert [alert["metric"] for alert in result["alerts"]] == [ + "active_users", + "processed_events", + ] + assert result["alerts"][0]["baseline"] == 9.3 + assert len(fake_sns.messages) == 1 + attrs = fake_sns.messages[0]["MessageAttributes"] + assert attrs["alert_type"]["StringValue"] == "hourly_data_anomaly" + assert attrs["event_hour"]["StringValue"] == "2026-06-10 13:00:00.000" + assert any(key.startswith("gold/anomaly_alerts/site_b_2026-06-10T13") for key in fake_s3.objects) + + +def test_hourly_schedule_consumes_each_publication_once(monkeypatch): + publication = { + "table": "gold_hourly_monitoring_features", + "published_at": "2026-08-03T01:00:00Z", + "published_through": "2026-06-17", + } + fake_s3 = FakeS3({hourly.HOURLY_PUBLICATION_MANIFEST_KEY: publication}) + monkeypatch.setattr(hourly, "s3", fake_s3) + monkeypatch.setattr(hourly, "_discover_hourly_sites", lambda: ["site_b"]) + monkeypatch.setattr(hourly, "_check_hourly_site", lambda site: {"client_site_id": site}) + + first = hourly.handler({"scheduled": True, "cadence": "hourly"}, None) + second = hourly.handler({"scheduled": True, "cadence": "hourly"}, None) + + assert first == {"checked": [{"client_site_id": "site_b"}], "cadence": "hourly"} + assert second["skipped"] == "publication already processed" diff --git a/tests/test_module2_live_exposure.py b/tests/test_module2_live_exposure.py index 3d37169..39c2f7f 100644 --- a/tests/test_module2_live_exposure.py +++ b/tests/test_module2_live_exposure.py @@ -145,12 +145,19 @@ def fake_run(sql): "2026-07-29", [{"metric": "sessions_7d", "direction": "min", "threshold": 3}], "gold_experiment_exposures", + execution_mode="live", + client_site_id="site_a", + game_id="game_01", ) assert len(breaches) == 1 assert "FROM gold_experiment_exposures" in captured[0] assert "variant = 'treatment'" in captured[0] assert "gold_experiment_assignments" not in captured[0] + assert "FROM gold_hourly_kpi" in captured[0] + assert "SUM(hk.sessions)" in captured[0] + assert "hk.client_site_id = 'site_a'" in captured[0] + assert "hk.game_id = 'game_01'" in captured[0] def test_central_view_surfaces_parallel_health_and_kill_switch(): diff --git a/tests/test_module3_diagnostics.py b/tests/test_module3_diagnostics.py index cdd639a..9626dbe 100644 --- a/tests/test_module3_diagnostics.py +++ b/tests/test_module3_diagnostics.py @@ -159,3 +159,73 @@ def test_retention_alert_becomes_first_look_without_model_call(monkeypatch): assert result["reports"][0]["report_type"] == "RETENTION_FIRST_LOOK" assert "Sample-Weighted Comparison" in result["reports"][0]["report_text"] assert len(fake_sns.messages) == 1 + + +def test_hourly_alert_reads_precomputed_evidence_before_headline(monkeypatch): + comparison = { + "active_users": { + "actual": 2, + "baseline": 9.3, + "lower_bound": 5, + "upper_bound": 14, + "pct_change": -78.49, + }, + "sessions": { + "actual": 2, + "baseline": 8.1, + "lower_bound": 4, + "upper_bound": 12, + "pct_change": -75.31, + }, + "processed_events": { + "actual": 19, + "baseline": 81.6, + "lower_bound": 40, + "upper_bound": 123, + "pct_change": -76.72, + }, + } + calls = [] + monkeypatch.setattr( + first_look, + "_hourly_baseline_comparison", + lambda site, event_hour: calls.append((site, event_hour)) or comparison, + ) + monkeypatch.setattr( + first_look, + "_headline", + lambda site, event_hour, evidence: ( + calls.append(("headline", evidence)) + or "Site usage fell materially below its same-hour baseline." + ), + ) + fake_s3 = FakeS3() + fake_sns = FakeSns() + monkeypatch.setattr(first_look, "s3", fake_s3) + monkeypatch.setattr(first_look, "sns", fake_sns) + event = { + "Records": [{ + "Sns": { + "Message": json.dumps({"alerts": [{"metric": "active_users"}]}), + "MessageAttributes": { + "alert_type": {"Value": "hourly_data_anomaly"}, + "client_site_id": {"Value": "site_b"}, + "as_of_date": {"Value": "2026-06-10"}, + "event_hour": {"Value": "2026-06-10 13:00:00.000"}, + }, + } + }] + } + + result = first_look.handler(event, None) + + report = result["reports"][0] + assert calls[0] == ("site_b", "2026-06-10 13:00:00.000") + assert calls[1] == ("headline", comparison) + assert report["report_type"] == "HOURLY_FIRST_LOOK" + assert "Same-Hour Comparison" in report["report_text"] + assert ( + "gold/first_look_reports/site_b_2026-06-10T13_hourly.json" + in fake_s3.objects + ) + assert len(fake_sns.messages) == 1 diff --git a/tests/test_module4_classifier.py b/tests/test_module4_classifier.py index e2c0b34..b580e3c 100644 --- a/tests/test_module4_classifier.py +++ b/tests/test_module4_classifier.py @@ -103,7 +103,7 @@ def test_bare_in_domain_term_is_underspecified_not_out_of_scope(): class TestLeakageGuard: @pytest.mark.parametrize("text", [ - "Covered in the Aurora Games Partner Integration Guide (Document ID: AG-INT-001).", + "Covered in the LEON Game Data Platform Partner Integration Guide (Document ID: AG-INT-001).", "See section §3.2 for signature verification.", "This is described in integration_guide.md.", ]) diff --git a/tests/test_project_walkthrough.py b/tests/test_project_walkthrough.py index 14ab4d7..be05ae8 100644 --- a/tests/test_project_walkthrough.py +++ b/tests/test_project_walkthrough.py @@ -122,14 +122,14 @@ def test_project_walkthrough_is_self_contained_bilingual_and_publishable() -> No "尚未串接 Email、Slack 或 CRM 通知", "每 24 小時", "Dashboard 每 15 秒", - "RAG-style", + "受治理的整合支援", "系統設計取捨與成本最佳化", "目前的省錢/輕量做法", "工程驗證與架構文件", "測試通過率(100%)", "本專案的核心價值在於", "雲端 AI 技術鏈接實務痛點", - "重複性客戶問題消耗客服與開發資源", + "重複的整合問題消耗支援與工程資源", "資料生成、治理與發布路徑", "四個模組如何消費資料並交付結果", "兩分鐘實機操作:四組工作流實際運行", @@ -139,10 +139,19 @@ def test_project_walkthrough_is_self_contained_bilingual_and_publishable() -> No "以 Serverless 為主的設計", "實機操作字幕版 · 02:00", "2026-08-02 實際操作 AWS 路徑", + "gold_hourly_kpi", ): assert required_copy in html assert required_copy in javascript + for architecture_stage in ( + "THE FOUNDATION", + "THE GOVERNANCE", + "THE INTELLIGENCE", + "THE DELIVERY", + ): + assert architecture_stage in html + for required_english_copy in ( "One cloud system architecture", "solving four practical operating pain points", @@ -150,6 +159,18 @@ def test_project_walkthrough_is_self_contained_bilingual_and_publishable() -> No ): assert required_english_copy in javascript + for prohibited_public_term in ( + "投注", + "遊戲", + "GGR", + "ggr_", + "arbitrage", + "flagged players", + "game-provider-partner", + ): + assert prohibited_public_term not in html + assert prohibited_public_term not in javascript + assert html.count('