2.0.0 Alpha: Data Refinery

This commit is contained in:
2026-08-08 21:31:56 +08:00
parent fa75081d4d
commit 562775e5db
48 changed files with 4172 additions and 661 deletions
View File
+16 -7
View File
@@ -14,13 +14,18 @@ import os
import json
import sqlite3
import glob
import argparse # Added
import argparse
import sys
# Paths
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUTPUT_ARENA_DIR = os.path.join(BASE_DIR, 'output_arena')
DB_DIR = os.path.join(BASE_DIR, 'database', 'L1')
DB_PATH = os.path.join(DB_DIR, 'L1.db')
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
from database.paths import L1_DB, L1_DIR, OUTPUT_ARENA
OUTPUT_ARENA_DIR = str(OUTPUT_ARENA)
DB_DIR = str(L1_DIR)
DB_PATH = str(L1_DB)
def init_db():
if not os.path.exists(DB_DIR):
@@ -65,6 +70,7 @@ def process_files():
count = 0
skipped = 0
errors = 0
for file_path in files:
try:
@@ -92,11 +98,14 @@ def process_files():
conn.commit()
except Exception as e:
errors += 1
print(f"Error processing {file_path}: {e}")
conn.commit()
conn.close()
print(f"Finished. Processed: {count}, Skipped: {skipped}.")
print(f"Finished. Processed: {count}, Skipped: {skipped}, Errors: {errors}.")
if errors:
raise RuntimeError(f"L1 ingestion failed for {errors} file(s)")
if __name__ == '__main__':
process_files()
process_files()
+19 -10
View File
@@ -1,16 +1,25 @@
L1A 5eplay平台网页爬虫原始数据。
# L1 Raw Match Store
## ETL Step 1:
从原始json数据库提取到L1A级数据库中。
`output_arena/*/iframe_network.json` -> `database/L1A/L1A.sqlite`
L1 stores one complete 5E network capture per match without transforming its
payload.
### 脚本说明
- **脚本位置**: `ETL/L1A.py`
- **功能**: 自动遍历 `output_arena` 目录下所有的 `iframe_network.json` 文件,提取原始内容并以 `match_id` (文件夹名) 为主键存入 `L1A.sqlite` 数据库的 `raw_iframe_network` 表中。
## Runtime Files
### 运行方式
使用项目指定的 Python 环境运行脚本:
- Database: `database/L1/L1.db`
- Builder: `database/L1/L1_Builder.py`
- Input: `output_arena/<match_id>/iframe_network.json`
- Primary key: `raw_iframe_network.match_id`
## Commands
```bash
C:/ProgramData/anaconda3/python.exe ETL/L1A.py
make l1
make pipeline
```
Normal ingestion is incremental. `--force` re-reads every capture currently
present in `output_arena`.
`L1A.db` and the historical `database/L1A/L1A.sqlite` path are retired. L1B is
reserved for a future demo-parser source and is not part of the runtime
pipeline.
BIN
View File
Binary file not shown.
+13 -3
View File
@@ -7,14 +7,20 @@ from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any, Tuple
from datetime import datetime
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from database.paths import L1_DB, L2_DB, L2_SCHEMA
# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Constants
L1A_DB_PATH = 'database/L1/L1.db'
L2_DB_PATH = 'database/L2/L2.db'
SCHEMA_PATH = 'database/L2/schema.sql'
L1A_DB_PATH = str(L1_DB)
L2_DB_PATH = str(L2_DB)
SCHEMA_PATH = str(L2_SCHEMA)
# --- Data Structures for Unification ---
@@ -1238,6 +1244,10 @@ def process_matches():
l1_conn.close()
l2_conn.close()
logger.info(f"\nDone. Processed {count} matches ({success_count} success, {error_count} errors).")
if error_count:
raise RuntimeError(
f"L2 build failed for {error_count}/{count} matches"
)
if __name__ == "__main__":
process_matches()
+21
View File
@@ -636,3 +636,24 @@ SELECT
FROM fact_match_players fmp
JOIN fact_matches fm ON fmp.match_id = fm.match_id
GROUP BY fmp.steam_id_64, fm.map_name;
-- ==========================================
-- Operational query indexes
-- ==========================================
CREATE INDEX IF NOT EXISTS idx_match_players_player_match
ON fact_match_players(steam_id_64, match_id);
CREATE INDEX IF NOT EXISTS idx_match_players_match_team
ON fact_match_players(match_id, team_id, steam_id_64);
CREATE INDEX IF NOT EXISTS idx_match_players_party
ON fact_match_players(match_id, match_team_id, steam_id_64);
CREATE INDEX IF NOT EXISTS idx_round_events_victim
ON fact_round_events(victim_steam_id, match_id);
CREATE INDEX IF NOT EXISTS idx_economy_player_match
ON fact_round_player_economy(steam_id_64, match_id, round_num);
CREATE INDEX IF NOT EXISTS idx_matches_map_time
ON fact_matches(map_name, start_time DESC);
BIN
View File
Binary file not shown.
+490 -12
View File
@@ -6,6 +6,8 @@ import sqlite3
import json
import argparse
import concurrent.futures
from collections import defaultdict, deque
from typing import Optional
# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -15,10 +17,14 @@ logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Points to database/ directory
PROJECT_ROOT = os.path.dirname(BASE_DIR) # Points to project root
sys.path.insert(0, PROJECT_ROOT) # Add project root to Python path
L2_DB_PATH = os.path.join(BASE_DIR, 'L2', 'L2.db')
L3_DB_PATH = os.path.join(BASE_DIR, 'L3', 'L3.db')
WEB_DB_PATH = os.path.join(BASE_DIR, 'Web', 'Web_App.sqlite')
SCHEMA_PATH = os.path.join(BASE_DIR, 'L3', 'schema.sql')
from database.paths import L2_DB, L3_DB, L3_SCHEMA, WEB_DB
L2_DB_PATH = str(L2_DB)
L3_DB_PATH = str(L3_DB)
L3_BACKUP_PATH = f"{L3_DB_PATH}.bak"
WEB_DB_PATH = str(WEB_DB)
SCHEMA_PATH = str(L3_SCHEMA)
def _get_existing_columns(conn, table_name):
cur = conn.execute(f"PRAGMA table_info({table_name})")
@@ -76,7 +82,28 @@ def _get_team_players():
try:
conn = sqlite3.connect(WEB_DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT player_ids_json FROM team_lineups")
columns = {
row[1] for row in cursor.execute("PRAGMA table_info(team_lineups)")
}
if 'is_active' in columns:
cursor.execute(
"""
SELECT player_ids_json
FROM team_lineups
WHERE is_active = 1
ORDER BY created_at DESC, id DESC
LIMIT 1
"""
)
else:
cursor.execute(
"""
SELECT player_ids_json
FROM team_lineups
ORDER BY created_at DESC, id DESC
LIMIT 1
"""
)
rows = cursor.fetchall()
steam_ids = set()
@@ -150,7 +177,25 @@ def _build_player_record(steam_id: str):
"error": str(e),
}
def main(force_all: bool = False, workers: int = 1):
def _backup_l3_database(source_path=L3_DB_PATH, backup_path=L3_BACKUP_PATH):
if not os.path.exists(source_path):
return None
source = sqlite3.connect(source_path)
backup = sqlite3.connect(backup_path)
try:
source.backup(backup)
result = backup.execute("PRAGMA quick_check").fetchone()[0]
if result != 'ok':
raise RuntimeError(f"L3 backup quick_check failed: {result}")
finally:
source.close()
backup.close()
logger.info("L3 backup created at %s", backup_path)
return backup_path
def main(force_all: bool = False, workers: int = 1, create_backup: bool = True):
"""
Main L3 feature building pipeline using modular processors
"""
@@ -158,6 +203,9 @@ def main(force_all: bool = False, workers: int = 1):
logger.info("Starting L3 Builder with 5-Tier Architecture")
logger.info("========================================")
if create_backup:
_backup_l3_database()
# 1. Ensure Schema is up to date
init_db()
@@ -181,6 +229,7 @@ def main(force_all: bool = False, workers: int = 1):
conn_l3 = sqlite3.connect(L3_DB_PATH)
try:
conn_l3.execute("BEGIN IMMEDIATE")
cursor_l2 = conn_l2.cursor()
if force_all:
logger.info("Force mode enabled: building L3 for all players in L2.")
@@ -240,7 +289,6 @@ def main(force_all: bool = False, workers: int = 1):
)
success_count += 1
if processed_count % 2 == 0:
conn_l3.commit()
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
else:
for idx, row in enumerate(players, 1):
@@ -268,10 +316,20 @@ def main(force_all: bool = False, workers: int = 1):
processed_count = idx
if processed_count % 2 == 0:
conn_l3.commit()
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
# Final commit
if error_count:
raise RuntimeError(
f"L3 feature build failed for {error_count}/{total_players} players"
)
processed_ids = [str(row[0]) for row in players]
_update_percentiles(conn_l3, processed_ids)
_rebuild_auxiliary_marts(conn_l2, conn_l3, processed_ids)
quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
if quick_check != 'ok':
raise RuntimeError(f"L3 quick_check failed before commit: {quick_check}")
conn_l3.commit()
logger.info("========================================")
@@ -283,9 +341,11 @@ def main(force_all: bool = False, workers: int = 1):
logger.info("========================================")
except Exception as e:
conn_l3.rollback()
logger.error(f"Fatal error during L3 build: {e}")
import traceback
traceback.print_exc()
raise
finally:
conn_l2.close()
@@ -313,7 +373,7 @@ def _get_round_count(steam_id: str, conn_l2: sqlite3.Connection) -> int:
def _upsert_features(conn_l3: sqlite3.Connection, steam_id: str, features: dict,
match_count: int, round_count: int, conn_l2: sqlite3.Connection | None,
match_count: int, round_count: int, conn_l2: Optional[sqlite3.Connection],
first_match_date=None, last_match_date=None):
"""
Insert or update player features in dm_player_features
@@ -353,12 +413,430 @@ def _upsert_features(conn_l3: sqlite3.Connection, steam_id: str, features: dict,
cursor_l3.execute(sql, values)
def _rebuild_auxiliary_marts(conn_l2, conn_l3, steam_ids):
"""Rebuild player-grain marts used by profiles and trend APIs."""
if not steam_ids:
return
logger.info("Rebuilding L3 match, map and weapon marts")
total_history = 0
total_maps = 0
total_weapons = 0
total_periods = 0
total_records = 0
for start in range(0, len(steam_ids), 400):
chunk = steam_ids[start:start + 400]
placeholders = ','.join('?' for _ in chunk)
for table in (
'dm_player_match_history',
'dm_player_map_stats',
'dm_player_weapon_stats',
'dm_player_period_stats',
'dm_player_records',
):
conn_l3.execute(
f"DELETE FROM {table} WHERE steam_id_64 IN ({placeholders})",
chunk,
)
history_rows = conn_l2.execute(
f"""
SELECT
mp.steam_id_64,
mp.match_id,
m.start_time,
mp.rating,
mp.kd_ratio,
mp.adr,
mp.kast,
mp.is_win,
m.map_name,
mp.kills,
mp.deaths,
mp.headshot_count,
(
SELECT AVG(teammate.rating)
FROM fact_match_players teammate
WHERE teammate.match_id = mp.match_id
AND teammate.team_id = mp.team_id
AND teammate.steam_id_64 != mp.steam_id_64
) AS teammate_avg_rating
FROM fact_match_players mp
JOIN fact_matches m ON m.match_id = mp.match_id
WHERE mp.steam_id_64 IN ({placeholders})
ORDER BY mp.steam_id_64, m.start_time, mp.match_id
""",
chunk,
).fetchall()
history_values = []
player_state = defaultdict(lambda: {
'sequence': 0,
'rating_sum': 0.0,
'recent': deque(maxlen=10),
})
for row in history_rows:
steam_id = str(row[0])
state = player_state[steam_id]
rating = float(row[3] or 0.0)
state['sequence'] += 1
state['rating_sum'] += rating
state['recent'].append(rating)
history_values.append((
steam_id,
row[1],
row[2],
state['sequence'],
row[3],
row[4],
row[5],
row[6],
row[7],
row[8],
None,
row[12],
state['rating_sum'] / state['sequence'],
sum(state['recent']) / len(state['recent']),
))
conn_l3.executemany(
"""
INSERT INTO dm_player_match_history (
steam_id_64, match_id, match_date, match_sequence,
rating, kd_ratio, adr, kast, is_win, map_name,
opponent_avg_elo, teammate_avg_rating,
cumulative_rating, rolling_10_rating
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
history_values,
)
total_history += len(history_values)
map_rows = conn_l2.execute(
f"""
SELECT
mp.steam_id_64,
m.map_name,
COUNT(*) AS matches,
SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) AS wins,
AVG(mp.rating) AS avg_rating,
AVG(mp.kd_ratio) AS avg_kd,
AVG(mp.adr) AS avg_adr,
AVG(mp.kast) AS avg_kast,
MAX(mp.rating) AS best_rating,
MIN(mp.rating) AS worst_rating
FROM fact_match_players mp
JOIN fact_matches m ON m.match_id = mp.match_id
WHERE mp.steam_id_64 IN ({placeholders})
AND m.map_name IS NOT NULL
AND m.map_name != ''
GROUP BY mp.steam_id_64, m.map_name
""",
chunk,
).fetchall()
map_values = [
tuple(row[:4]) + (
(row[3] or 0) / row[2] if row[2] else 0.0,
) + tuple(row[4:])
for row in map_rows
]
conn_l3.executemany(
"""
INSERT INTO dm_player_map_stats (
steam_id_64, map_name, matches, wins, win_rate,
avg_rating, avg_kd, avg_adr, avg_kast,
best_rating, worst_rating
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
map_values,
)
total_maps += len(map_values)
round_counts = {
str(row[0]): int(row[1] or 0)
for row in conn_l2.execute(
f"""
SELECT steam_id_64, SUM(round_total)
FROM fact_match_players
WHERE steam_id_64 IN ({placeholders})
GROUP BY steam_id_64
""",
chunk,
)
}
weapon_rows = conn_l2.execute(
f"""
SELECT
attacker_steam_id,
weapon,
COUNT(*) AS total_kills,
SUM(CASE WHEN is_headshot = 1 THEN 1 ELSE 0 END) AS total_headshots,
COUNT(DISTINCT match_id || ':' || round_num) AS usage_rounds
FROM fact_round_events
WHERE event_type = 'kill'
AND attacker_steam_id IN ({placeholders})
AND weapon IS NOT NULL
AND weapon != ''
GROUP BY attacker_steam_id, weapon
""",
chunk,
).fetchall()
weapon_values = []
for row in weapon_rows:
rounds = round_counts.get(str(row[0]), 0)
kills = int(row[2] or 0)
headshots = int(row[3] or 0)
usage_rounds = int(row[4] or 0)
hs_rate = headshots / kills if kills else 0.0
usage_rate = usage_rounds / rounds if rounds else 0.0
kills_per_round = kills / rounds if rounds else 0.0
effectiveness = kills / usage_rounds if usage_rounds else 0.0
weapon_values.append((
str(row[0]),
row[1],
kills,
headshots,
hs_rate,
usage_rounds,
usage_rate,
kills_per_round,
effectiveness,
))
conn_l3.executemany(
"""
INSERT INTO dm_player_weapon_stats (
steam_id_64, weapon_name, total_kills, total_headshots,
hs_rate, usage_rounds, usage_rate,
avg_kills_per_round, effectiveness_score
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
weapon_values,
)
total_weapons += len(weapon_values)
period_values = _calculate_period_rows(history_rows)
conn_l3.executemany(
"""
INSERT INTO dm_player_period_stats (
steam_id_64, period_key, period_label,
period_start, period_end, matches, wins, win_rate,
avg_rating, avg_kd, avg_adr, avg_kast,
total_kills, total_deaths, sample_reliable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
period_values,
)
total_periods += len(period_values)
record_values = _calculate_record_rows(history_rows)
conn_l3.executemany(
"""
INSERT INTO dm_player_records (
steam_id_64, record_key, record_label, record_value,
match_id, map_name, match_date
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
record_values,
)
total_records += len(record_values)
logger.info(
"Auxiliary marts rebuilt: %s history, %s map, %s weapon, "
"%s period, %s record rows",
total_history,
total_maps,
total_weapons,
total_periods,
total_records,
)
def _group_player_match_rows(history_rows):
grouped = defaultdict(list)
for row in history_rows:
grouped[str(row['steam_id_64'])].append(row)
for rows in grouped.values():
rows.sort(key=lambda row: (row['start_time'] or 0, row['match_id']))
return grouped
def _safe_average(rows, key):
values = [float(row[key]) for row in rows if row[key] is not None]
return sum(values) / len(values) if values else None
def _calculate_period_rows(history_rows):
result = []
for steam_id, all_rows in _group_player_match_rows(history_rows).items():
latest_time = max(int(row['start_time'] or 0) for row in all_rows)
period_groups = [
('career', '生涯', all_rows),
('last_10', '最近 10 场', all_rows[-10:]),
('last_20', '最近 20 场', all_rows[-20:]),
('last_30', '最近 30 场', all_rows[-30:]),
(
'days_30',
'最近 30 天',
[
row for row in all_rows
if int(row['start_time'] or 0) >= latest_time - 30 * 86400
],
),
(
'days_90',
'最近 90 天',
[
row for row in all_rows
if int(row['start_time'] or 0) >= latest_time - 90 * 86400
],
),
]
for period_key, period_label, rows in period_groups:
if not rows:
continue
matches = len(rows)
wins = sum(1 for row in rows if row['is_win'])
kills = sum(int(row['kills'] or 0) for row in rows)
deaths = sum(int(row['deaths'] or 0) for row in rows)
result.append((
steam_id,
period_key,
period_label,
min(int(row['start_time'] or 0) for row in rows),
max(int(row['start_time'] or 0) for row in rows),
matches,
wins,
wins / matches,
_safe_average(rows, 'rating'),
kills / deaths if deaths else float(kills),
_safe_average(rows, 'adr'),
_safe_average(rows, 'kast'),
kills,
deaths,
1 if matches >= 10 else 0,
))
return result
def _calculate_record_rows(history_rows):
result = []
metric_definitions = (
('highest_rating', '最高 Rating', 'rating'),
('most_kills', '单场最多击杀', 'kills'),
('highest_adr', '单场最高 ADR', 'adr'),
('highest_kd', '单场最高 K/D', 'kd_ratio'),
('most_headshots', '单场最多爆头', 'headshot_count'),
)
for steam_id, rows in _group_player_match_rows(history_rows).items():
for record_key, record_label, field in metric_definitions:
candidates = [row for row in rows if row[field] is not None]
if not candidates:
continue
best = max(
candidates,
key=lambda row: (
float(row[field]),
int(row['start_time'] or 0),
),
)
result.append((
steam_id,
record_key,
record_label,
float(best[field]),
best['match_id'],
best['map_name'],
best['start_time'],
))
longest_streak = 0
current_streak = 0
streak_end = None
for row in rows:
if row['is_win']:
current_streak += 1
if current_streak >= longest_streak:
longest_streak = current_streak
streak_end = row
else:
current_streak = 0
if streak_end is not None:
result.append((
steam_id,
'longest_win_streak',
'最长连胜',
float(longest_streak),
streak_end['match_id'],
streak_end['map_name'],
streak_end['start_time'],
))
return result
def _update_percentiles(conn_l3, steam_ids):
"""Calculate a real percentile among eligible players in this build."""
if not steam_ids:
return
score_rows = []
for start in range(0, len(steam_ids), 400):
chunk = steam_ids[start:start + 400]
placeholders = ','.join('?' for _ in chunk)
conn_l3.execute(
f"""
UPDATE dm_player_features
SET tier_percentile = NULL
WHERE steam_id_64 IN ({placeholders})
""",
chunk,
)
score_rows.extend(conn_l3.execute(
f"""
SELECT steam_id_64, score_overall
FROM dm_player_features
WHERE steam_id_64 IN ({placeholders})
AND score_overall > 0
""",
chunk,
).fetchall())
if not score_rows:
return
scores = [float(row[1]) for row in score_rows]
percentile_values = []
for row in score_rows:
score = float(row[1])
percentile = sum(value <= score for value in scores) / len(scores) * 100
percentile_values.append((round(percentile, 2), str(row[0])))
conn_l3.executemany(
"""
UPDATE dm_player_features
SET tier_percentile = ?
WHERE steam_id_64 = ?
""",
percentile_values,
)
logger.info("Updated percentiles for %s eligible players", len(score_rows))
def _parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--force", action="store_true")
parser.add_argument("--workers", type=int, default=1)
parser.add_argument("--no-backup", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
args = _parse_args()
main(force_all=args.force, workers=args.workers)
main(
force_all=args.force,
workers=args.workers,
create_backup=not args.no_backup,
)
+20 -26
View File
@@ -65,8 +65,8 @@ class CompositeProcessor(BaseFeatureProcessor):
# Classify tier based on overall score
features['tier_classification'] = CompositeProcessor._classify_tier(features['score_overall'])
# Percentile rank (placeholder - requires all players)
features['tier_percentile'] = min(features['score_overall'], 100.0)
# Filled by L3_Builder after every eligible player has been calculated.
features['tier_percentile'] = None
return features
@@ -266,13 +266,13 @@ class CompositeProcessor(BaseFeatureProcessor):
STABILITY Score (0-100) | 8%
"""
# Extract features
volatility = features.get('meta_rating_volatility', 0.0)
loss_rating = features.get('meta_loss_rating', 0.0)
consistency = features.get('meta_rating_consistency', 0.0)
tilt_resilience = features.get('int_pressure_tilt_resistance', 0.0)
map_stable = features.get('meta_map_stability', 0.0)
elo_stable = features.get('meta_elo_tier_stability', 0.0)
recent_form = features.get('meta_recent_form_rating', 0.0)
volatility = features.get('meta_rating_volatility') or 0.0
loss_rating = features.get('meta_loss_rating') or 0.0
consistency = features.get('meta_rating_consistency') or 0.0
tilt_resilience = features.get('int_pressure_tilt_resistance') or 0.0
map_stable = features.get('meta_map_stability') or 0.0
elo_stable = features.get('meta_elo_tier_stability') or 0.0
recent_form = features.get('meta_recent_form_rating') or 0.0
# Normalize
# Volatility: Reverse score. 100 - (Vol * 220)
@@ -281,8 +281,8 @@ class CompositeProcessor(BaseFeatureProcessor):
loss_score = min((loss_rating / 1.00) * 100, 100)
cons_score = min((consistency / 70) * 100, 100)
tilt_score = min((tilt_resilience / 0.80) * 100, 100)
map_score = min((map_stable / 0.25) * 100, 100)
elo_score = min((elo_stable / 0.48) * 100, 100)
map_score = max(0, min(100, 100 - (map_stable / 0.25) * 100))
elo_score = max(0, min(100, 100 - (elo_stable / 0.48) * 100))
recent_score = min((recent_form / 1.15) * 100, 100)
# Weighted Sum
@@ -337,12 +337,12 @@ class CompositeProcessor(BaseFeatureProcessor):
PACE Score (0-100) | 5%
"""
# Extract features
early_kill_pct = features.get('int_timing_early_kill_share', 0.0)
aggression = features.get('int_timing_aggression_index', 0.0)
trade_speed = features.get('int_trade_response_time', 0.0)
trade_kill = features.get('int_trade_kill_count', 0)
teamwork = features.get('int_teamwork_score', 0.0)
first_contact = features.get('int_timing_first_contact_time', 0.0)
early_kill_pct = features.get('int_timing_early_kill_share') or 0.0
aggression = features.get('int_timing_aggression_index') or 0.0
trade_speed = features.get('int_trade_response_time') or 0.0
trade_kill = features.get('int_trade_kill_count') or 0
teamwork = features.get('int_teamwork_score') or 0.0
first_contact = features.get('int_timing_first_contact_time') or 0.0
# Normalize
early_score = min((early_kill_pct / 0.44) * 100, 100)
@@ -353,7 +353,7 @@ class CompositeProcessor(BaseFeatureProcessor):
if trade_speed > 0.01:
trade_speed_score = min((2.0 / trade_speed) * 100, 100)
else:
trade_speed_score = 100 # Instant trade
trade_speed_score = 0
trade_kill_score = min((trade_kill / 650) * 100, 100)
teamwork_score = min((teamwork / 29) * 100, 100)
@@ -362,13 +362,7 @@ class CompositeProcessor(BaseFeatureProcessor):
if first_contact > 0.01:
first_contact_score = min((30 / first_contact) * 100, 100)
else:
first_contact_score = 0 # If 0, probably no data, safe to say 0? Or 100?
# 0 first contact time means instant damage.
# But "30 / Contact" means smaller contact time gives higher score.
# If contact time is 0, score explodes.
# Realistically first contact time is > 0.
# I will clamp it.
first_contact_score = 100 # Assume very fast
first_contact_score = 0
# Weighted Sum
pace_score = (
@@ -416,5 +410,5 @@ def _get_default_composite_features() -> Dict[str, Any]:
'score_pace': 0.0,
'score_overall': 0.0,
'tier_classification': 'Beginner',
'tier_percentile': 0.0,
'tier_percentile': None,
}
@@ -466,7 +466,8 @@ class IntelligenceProcessor(BaseFeatureProcessor):
- int_pos_spatial_iq_score
- int_pos_avg_distance_from_teammates
Note: Simplified implementation - full version requires DBSCAN clustering
Only geometry-independent values are calculated here. Metrics that
require map boundaries, paths or teammate positions remain NULL.
"""
cursor = conn_l2.cursor()
@@ -481,26 +482,23 @@ class IntelligenceProcessor(BaseFeatureProcessor):
has_position_data = cursor.fetchone()[0] > 0
if not has_position_data:
# Return placeholder values if no position data
return {
'int_pos_site_a_control_rate': 0.0,
'int_pos_site_b_control_rate': 0.0,
'int_pos_mid_control_rate': 0.0,
'int_pos_favorite_position': 'unknown',
'int_pos_position_diversity': 0.0,
'int_pos_rotation_speed': 0.0,
'int_pos_map_coverage': 0.0,
'int_pos_lurk_tendency': 0.0,
'int_pos_site_anchor_score': 0.0,
'int_pos_entry_route_diversity': 0.0,
'int_pos_retake_positioning': 0.0,
'int_pos_postplant_positioning': 0.0,
'int_pos_spatial_iq_score': 0.0,
'int_pos_avg_distance_from_teammates': 0.0,
'int_pos_site_a_control_rate': None,
'int_pos_site_b_control_rate': None,
'int_pos_mid_control_rate': None,
'int_pos_favorite_position': None,
'int_pos_position_diversity': None,
'int_pos_rotation_speed': None,
'int_pos_map_coverage': None,
'int_pos_lurk_tendency': None,
'int_pos_site_anchor_score': None,
'int_pos_entry_route_diversity': None,
'int_pos_retake_positioning': None,
'int_pos_postplant_positioning': None,
'int_pos_spatial_iq_score': None,
'int_pos_avg_distance_from_teammates': None,
}
# Simplified position analysis (proper implementation needs clustering)
# Calculate basic position variance as proxy for mobility
cursor.execute("""
SELECT
AVG(attacker_pos_x) as avg_x,
@@ -515,34 +513,24 @@ class IntelligenceProcessor(BaseFeatureProcessor):
pos_row = cursor.fetchone()
position_count = pos_row[3] if pos_row[3] else 1
# Position diversity based on unique grid cells visited
position_diversity = min(position_count / 50.0, 1.0) # Normalize to 0-1
# Map coverage (simplified)
map_coverage = position_diversity
# Site control rates CANNOT be calculated without map-specific geometry data
# Each map (Dust2, Mirage, Nuke, etc.) has different site boundaries
# Would require: CREATE TABLE map_boundaries (map_name, site_name, min_x, max_x, min_y, max_y)
# Commenting out these 3 features:
# - int_pos_site_a_control_rate
# - int_pos_site_b_control_rate
# - int_pos_mid_control_rate
return {
'int_pos_site_a_control_rate': 0.33, # Placeholder
'int_pos_site_b_control_rate': 0.33, # Placeholder
'int_pos_mid_control_rate': 0.34, # Placeholder
'int_pos_favorite_position': 'mid',
'int_pos_site_a_control_rate': None,
'int_pos_site_b_control_rate': None,
'int_pos_mid_control_rate': None,
'int_pos_favorite_position': None,
'int_pos_position_diversity': round(position_diversity, 3),
'int_pos_rotation_speed': 50.0,
'int_pos_rotation_speed': None,
'int_pos_map_coverage': round(map_coverage, 3),
'int_pos_lurk_tendency': 0.25,
'int_pos_site_anchor_score': 50.0,
'int_pos_lurk_tendency': None,
'int_pos_site_anchor_score': None,
'int_pos_entry_route_diversity': round(position_diversity, 3),
'int_pos_retake_positioning': 50.0,
'int_pos_postplant_positioning': 50.0,
'int_pos_retake_positioning': None,
'int_pos_postplant_positioning': None,
'int_pos_spatial_iq_score': round(position_diversity * 100, 2),
'int_pos_avg_distance_from_teammates': 500.0,
'int_pos_avg_distance_from_teammates': None,
}
@staticmethod
@@ -706,20 +694,20 @@ def _get_default_intelligence_features() -> Dict[str, Any]:
'int_pressure_big_moment_score': 0.0,
'int_pressure_tilt_resistance': 0.0,
# Position Mastery (14)
'int_pos_site_a_control_rate': 0.0,
'int_pos_site_b_control_rate': 0.0,
'int_pos_mid_control_rate': 0.0,
'int_pos_favorite_position': 'unknown',
'int_pos_position_diversity': 0.0,
'int_pos_rotation_speed': 0.0,
'int_pos_map_coverage': 0.0,
'int_pos_lurk_tendency': 0.0,
'int_pos_site_anchor_score': 0.0,
'int_pos_entry_route_diversity': 0.0,
'int_pos_retake_positioning': 0.0,
'int_pos_postplant_positioning': 0.0,
'int_pos_spatial_iq_score': 0.0,
'int_pos_avg_distance_from_teammates': 0.0,
'int_pos_site_a_control_rate': None,
'int_pos_site_b_control_rate': None,
'int_pos_mid_control_rate': None,
'int_pos_favorite_position': None,
'int_pos_position_diversity': None,
'int_pos_rotation_speed': None,
'int_pos_map_coverage': None,
'int_pos_lurk_tendency': None,
'int_pos_site_anchor_score': None,
'int_pos_entry_route_diversity': None,
'int_pos_retake_positioning': None,
'int_pos_postplant_positioning': None,
'int_pos_spatial_iq_score': None,
'int_pos_avg_distance_from_teammates': None,
# Trade Network (8)
'int_trade_kill_count': 0,
'int_trade_kill_rate': 0.0,
+34 -6
View File
@@ -60,10 +60,11 @@ class MetaProcessor(BaseFeatureProcessor):
# Get recent matches for volatility
cursor.execute("""
SELECT rating
FROM fact_match_players
WHERE steam_id_64 = ?
ORDER BY match_id DESC
SELECT p.rating
FROM fact_match_players p
JOIN fact_matches m ON m.match_id = p.match_id
WHERE p.steam_id_64 = ?
ORDER BY m.start_time DESC, p.match_id DESC
LIMIT 20
""", (steam_id,))
@@ -141,8 +142,35 @@ class MetaProcessor(BaseFeatureProcessor):
map_ratings = [row[1] for row in cursor.fetchall() if row[1] is not None]
map_stability = SafeAggregator.safe_stddev(map_ratings, 0.0)
# ELO tier stability (placeholder)
elo_tier_stability = rating_volatility # Simplified
cursor.execute("""
SELECT
CASE
WHEN p.origin_elo - opponent.avg_elo > 200 THEN 'lower'
WHEN p.origin_elo - opponent.avg_elo < -200 THEN 'higher'
ELSE 'similar'
END AS opponent_tier,
AVG(p.rating) AS avg_rating
FROM fact_match_players p
JOIN (
SELECT match_id, team_id, AVG(origin_elo) AS avg_elo
FROM fact_match_players
WHERE origin_elo IS NOT NULL
GROUP BY match_id, team_id
) opponent
ON opponent.match_id = p.match_id
AND opponent.team_id != p.team_id
WHERE p.steam_id_64 = ?
AND p.origin_elo IS NOT NULL
AND p.rating IS NOT NULL
GROUP BY opponent_tier
""", (steam_id,))
elo_tier_ratings = [
row[1] for row in cursor.fetchall() if row[1] is not None
]
elo_tier_stability = SafeAggregator.safe_stddev(
elo_tier_ratings,
0.0,
)
return {
'meta_rating_volatility': round(rating_volatility, 3),
+52
View File
@@ -378,6 +378,56 @@ CREATE TABLE IF NOT EXISTS dm_player_weapon_stats (
CREATE INDEX IF NOT EXISTS idx_player_weapon_stats_player ON dm_player_weapon_stats(steam_id_64);
CREATE INDEX IF NOT EXISTS idx_player_weapon_stats_weapon ON dm_player_weapon_stats(weapon_name);
-- ============================================================================
-- Profile Mart: Time-window statistics
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_player_period_stats (
steam_id_64 TEXT NOT NULL,
period_key TEXT NOT NULL,
period_label TEXT NOT NULL,
period_start INTEGER,
period_end INTEGER,
matches INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
win_rate REAL,
avg_rating REAL,
avg_kd REAL,
avg_adr REAL,
avg_kast REAL,
total_kills INTEGER NOT NULL DEFAULT 0,
total_deaths INTEGER NOT NULL DEFAULT 0,
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (steam_id_64, period_key),
FOREIGN KEY (steam_id_64)
REFERENCES dm_player_features(steam_id_64) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_player_period_player
ON dm_player_period_stats(steam_id_64, period_key);
-- ============================================================================
-- Profile Mart: Career records linked to the source match
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_player_records (
steam_id_64 TEXT NOT NULL,
record_key TEXT NOT NULL,
record_label TEXT NOT NULL,
record_value REAL,
match_id TEXT,
map_name TEXT,
match_date INTEGER,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (steam_id_64, record_key),
FOREIGN KEY (steam_id_64)
REFERENCES dm_player_features(steam_id_64) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_player_records_player
ON dm_player_records(steam_id_64, record_key);
-- ============================================================================
-- Schema Summary
-- ============================================================================
@@ -391,4 +441,6 @@ CREATE INDEX IF NOT EXISTS idx_player_weapon_stats_weapon ON dm_player_weapon_st
-- dm_player_match_history: Per-match snapshots for trend analysis
-- dm_player_map_stats: Map-level aggregations
-- dm_player_weapon_stats: Weapon usage statistics
-- dm_player_period_stats: Career/recent time-window aggregations
-- dm_player_records: Career record values and source matches
-- ============================================================================
+45
View File
@@ -0,0 +1,45 @@
# Database Governance
The repository intentionally keeps SQLite for the current private-team scale.
This directory separates four different responsibilities:
| Layer | Database | Grain | Owner |
|---|---|---|---|
| L1 | `L1/L1.db` | One raw network capture per match | Import pipeline |
| L2 | `L2/L2.db` | Normalized match, player, round and event facts | L2 Builder |
| L3 | `L3/L3.db` | Roster features and profile marts | L3 Builder |
| Web | `Web/Web_App.sqlite` | Lineups, comments, jobs and editorial data | Flask app |
## Rules
1. Paths are defined only in `database/paths.py`.
2. Schemas live next to their owning database.
3. Builders may read the previous layer and write only their own layer.
4. User-generated Web data is never restored as part of an ETL rollback.
5. A full import must run through `database/pipeline.py`.
6. Pipeline runs are serialized by `database/.pipeline.lock`.
7. L1/L2/L3 are backed up before a full pipeline run.
8. Missing metrics are stored as `NULL`, not fabricated zero values.
9. `Admin -> Data Integrity` is the operational source of truth.
10. Web schema changes increment `Config.WEB_SCHEMA_VERSION`.
## Entry Points
```bash
make l1 # Import output_arena JSON into L1
make l2 # Rebuild normalized facts
make l3 # Rebuild active-roster features
make pipeline # Run L1 -> L2 -> L3 with backup and validation
make check # Compile and run tests
```
## Directory Policy
- `L1/`, `L2/`, `L3/`, `Web/`: active code, schema and database.
- `backups/`: generated rollback snapshots; ignored by Git.
- `schema_bkp/`: historical schema research only; not used at runtime.
- `L1B/`: reserved demo-parser integration; not used at runtime.
- `L3/Roadmap/`: historical design notes; not used at runtime.
Large-scale directory moves are deliberately deferred until the legacy
builders no longer depend on their current module layout.
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
username TEXT,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
content TEXT NOT NULL,
likes INTEGER NOT NULL DEFAULT 0,
is_hidden INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_comments_target
ON comments(target_type, target_id, is_hidden, created_at DESC);
CREATE TABLE IF NOT EXISTS player_metadata (
steam_id_64 TEXT PRIMARY KEY,
notes TEXT,
tags TEXT NOT NULL DEFAULT '[]',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS strategy_boards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
map_name TEXT NOT NULL,
data_json TEXT NOT NULL,
created_by TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS team_lineups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
player_ids_json TEXT NOT NULL DEFAULT '[]',
is_active INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_lineups_single_active
ON team_lineups(is_active)
WHERE is_active = 1;
CREATE TABLE IF NOT EXISTS wiki_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
updated_by TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS etl_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
match_id TEXT,
input_path TEXT,
current_stage TEXT,
progress INTEGER NOT NULL DEFAULT 0
CHECK (progress >= 0 AND progress <= 100),
message TEXT,
log_text TEXT NOT NULL DEFAULT '',
created_by TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
finished_at TIMESTAMP,
duration_seconds REAL
);
CREATE INDEX IF NOT EXISTS idx_etl_jobs_created
ON etl_jobs(created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_etl_jobs_status
ON etl_jobs(status, created_at);
CREATE TABLE IF NOT EXISTS match_imports (
match_id TEXT PRIMARY KEY,
content_sha256 TEXT NOT NULL,
source_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
job_id INTEGER,
imported_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (job_id) REFERENCES etl_jobs(id) ON DELETE SET NULL
);
+2
View File
@@ -0,0 +1,2 @@
"""Database builders, schemas, maintenance tools and local data stores."""
+198
View File
@@ -0,0 +1,198 @@
import sqlite3
from typing import Any, Dict, List, Optional
from database.paths import WEB_DB
class JobStore:
def __init__(self, database_path=WEB_DB):
self.database_path = str(database_path)
def _connect(self):
db = sqlite3.connect(self.database_path, timeout=30)
db.row_factory = sqlite3.Row
db.execute('PRAGMA busy_timeout = 30000')
db.execute('PRAGMA foreign_keys = ON')
return db
def create_job(
self,
job_type: str,
match_id: Optional[str] = None,
input_path: Optional[str] = None,
created_by: Optional[str] = None,
) -> int:
db = self._connect()
try:
cursor = db.execute(
"""
INSERT INTO etl_jobs (
job_type, match_id, input_path, created_by
) VALUES (?, ?, ?, ?)
""",
[job_type, match_id, input_path, created_by],
)
db.commit()
return int(cursor.lastrowid)
finally:
db.close()
def get_job(self, job_id: int) -> Optional[Dict[str, Any]]:
db = self._connect()
try:
row = db.execute(
'SELECT * FROM etl_jobs WHERE id = ?',
[job_id],
).fetchone()
return dict(row) if row else None
finally:
db.close()
def list_jobs(self, limit: int = 20) -> List[Dict[str, Any]]:
db = self._connect()
try:
rows = db.execute(
"""
SELECT *
FROM etl_jobs
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
[max(1, min(int(limit), 100))],
).fetchall()
return [dict(row) for row in rows]
finally:
db.close()
def start_job(self, job_id: int, stage: str, message: str):
db = self._connect()
try:
db.execute(
"""
UPDATE etl_jobs
SET status = 'running',
current_stage = ?,
progress = 1,
message = ?,
started_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'queued'
""",
[stage, message, job_id],
)
db.commit()
finally:
db.close()
def update_progress(
self,
job_id: int,
stage: str,
progress: int,
message: str,
):
db = self._connect()
try:
db.execute(
"""
UPDATE etl_jobs
SET current_stage = ?, progress = ?, message = ?
WHERE id = ?
""",
[stage, max(0, min(int(progress), 100)), message, job_id],
)
db.commit()
finally:
db.close()
def append_log(self, job_id: int, text: str):
if not text:
return
db = self._connect()
try:
db.execute(
"""
UPDATE etl_jobs
SET log_text = substr(log_text || ?, -100000)
WHERE id = ?
""",
[text, job_id],
)
db.commit()
finally:
db.close()
def finish_job(
self,
job_id: int,
succeeded: bool,
message: str,
duration_seconds: float,
):
status = 'succeeded' if succeeded else 'failed'
db = self._connect()
try:
db.execute(
"""
UPDATE etl_jobs
SET status = ?,
current_stage = ?,
progress = ?,
message = ?,
finished_at = CURRENT_TIMESTAMP,
duration_seconds = ?
WHERE id = ?
""",
[
status,
'complete' if succeeded else 'failed',
100 if succeeded else 0,
message,
round(float(duration_seconds), 3),
job_id,
],
)
db.execute(
"""
UPDATE match_imports
SET status = ?,
imported_at = CASE
WHEN ? = 'succeeded' THEN CURRENT_TIMESTAMP
ELSE imported_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE job_id = ?
""",
[status, status, job_id],
)
db.commit()
finally:
db.close()
def upsert_match_import(
self,
match_id: str,
content_sha256: str,
source_path: str,
status: str,
job_id: int,
):
db = self._connect()
try:
db.execute(
"""
INSERT INTO match_imports (
match_id, content_sha256, source_path, status, job_id
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(match_id) DO UPDATE SET
content_sha256 = excluded.content_sha256,
source_path = excluded.source_path,
status = excluded.status,
job_id = excluded.job_id,
updated_at = CURRENT_TIMESTAMP
""",
[match_id, content_sha256, source_path, status, job_id],
)
db.commit()
finally:
db.close()
+140
View File
@@ -0,0 +1,140 @@
from datetime import datetime, timezone
import json
from pathlib import Path
import shutil
import sqlite3
from typing import Dict
from database.paths import BACKUP_ROOT, L1_DB, L2_DB, L3_DB
MANAGED_DATABASES = {
'l1': L1_DB,
'l2': L2_DB,
'l3': L3_DB,
}
def quick_check(path: Path) -> str:
if not path.exists():
return 'missing'
db = sqlite3.connect(str(path))
try:
return str(db.execute('PRAGMA quick_check').fetchone()[0])
finally:
db.close()
def backup_database(source_path: Path, backup_path: Path):
if not source_path.exists():
raise FileNotFoundError(f'Database does not exist: {source_path}')
backup_path.parent.mkdir(parents=True, exist_ok=True)
source = sqlite3.connect(str(source_path))
destination = sqlite3.connect(str(backup_path))
try:
source.backup(destination)
result = destination.execute('PRAGMA quick_check').fetchone()[0]
if result != 'ok':
raise RuntimeError(
f'Backup quick_check failed for {source_path.name}: {result}'
)
finally:
source.close()
destination.close()
def restore_database(backup_path: Path, target_path: Path):
if not backup_path.exists():
raise FileNotFoundError(f'Backup does not exist: {backup_path}')
source = sqlite3.connect(str(backup_path))
target = sqlite3.connect(str(target_path), timeout=30)
try:
source.backup(target)
result = target.execute('PRAGMA quick_check').fetchone()[0]
if result != 'ok':
raise RuntimeError(
f'Restored quick_check failed for {target_path.name}: {result}'
)
finally:
source.close()
target.close()
def create_backup_set(label: str) -> Path:
safe_label = ''.join(
character for character in str(label)
if character.isalnum() or character in {'-', '_'}
)
if not safe_label:
raise ValueError('Backup label is empty after sanitization')
backup_dir = BACKUP_ROOT / safe_label
backup_dir.mkdir(parents=True, exist_ok=True)
manifest: Dict[str, object] = {
'label': safe_label,
'created_at': datetime.now(timezone.utc).isoformat(),
'databases': {},
}
for name, source_path in MANAGED_DATABASES.items():
backup_path = backup_dir / source_path.name
backup_database(source_path, backup_path)
manifest['databases'][name] = {
'source': str(source_path),
'backup': str(backup_path),
'size_bytes': backup_path.stat().st_size,
'quick_check': quick_check(backup_path),
}
with (backup_dir / 'manifest.json').open('w', encoding='utf-8') as file:
json.dump(manifest, file, ensure_ascii=True, indent=2)
return backup_dir
def restore_backup_set(backup_dir: Path):
for name, target_path in MANAGED_DATABASES.items():
backup_path = backup_dir / target_path.name
restore_database(backup_path, target_path)
def prune_backup_sets(keep: int = 3):
BACKUP_ROOT.mkdir(parents=True, exist_ok=True)
backup_dirs = sorted(
[path for path in BACKUP_ROOT.iterdir() if path.is_dir()],
key=lambda path: path.stat().st_mtime,
reverse=True,
)
removed = []
for path in backup_dirs[max(int(keep), 0):]:
shutil.rmtree(path)
removed.append(str(path))
return removed
def backup_storage_status():
BACKUP_ROOT.mkdir(parents=True, exist_ok=True)
backup_dirs = [path for path in BACKUP_ROOT.iterdir() if path.is_dir()]
total_bytes = sum(
file.stat().st_size
for directory in backup_dirs
for file in directory.rglob('*')
if file.is_file()
)
return {
'sets': len(backup_dirs),
'total_bytes': total_bytes,
}
def check_managed_databases():
return {
name: {
'path': str(path),
'exists': path.exists(),
'size_bytes': path.stat().st_size if path.exists() else 0,
'quick_check': quick_check(path),
}
for name, path in MANAGED_DATABASES.items()
}
+36
View File
@@ -0,0 +1,36 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DATABASE_ROOT = PROJECT_ROOT / 'database'
L1_DIR = DATABASE_ROOT / 'L1'
L2_DIR = DATABASE_ROOT / 'L2'
L3_DIR = DATABASE_ROOT / 'L3'
WEB_DIR = DATABASE_ROOT / 'Web'
L1_DB = L1_DIR / 'L1.db'
L2_DB = L2_DIR / 'L2.db'
L3_DB = L3_DIR / 'L3.db'
WEB_DB = WEB_DIR / 'Web_App.sqlite'
L2_SCHEMA = L2_DIR / 'schema.sql'
L3_SCHEMA = L3_DIR / 'schema.sql'
WEB_SCHEMA = WEB_DIR / 'schema.sql'
OUTPUT_ARENA = PROJECT_ROOT / 'output_arena'
BACKUP_ROOT = DATABASE_ROOT / 'backups'
PIPELINE_LOCK = DATABASE_ROOT / '.pipeline.lock'
def ensure_runtime_directories():
for path in (
L1_DIR,
L2_DIR,
L3_DIR,
WEB_DIR,
OUTPUT_ARENA,
BACKUP_ROOT,
):
path.mkdir(parents=True, exist_ok=True)
+212
View File
@@ -0,0 +1,212 @@
import argparse
import fcntl
import os
import sqlite3
import subprocess
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from database.job_store import JobStore
from database.maintenance import (
check_managed_databases,
create_backup_set,
prune_backup_sets,
restore_backup_set,
)
from database.paths import L1_DB, L2_DB, PIPELINE_LOCK
STAGES = (
('l1', 15, Path('database/L1/L1_Builder.py')),
('l2', 55, Path('database/L2/L2_Builder.py')),
('l3', 85, Path('database/L3/L3_Builder.py')),
)
class PipelineError(RuntimeError):
pass
def _run_stage(store, job_id, stage, progress, script_path, replace=False):
store.update_progress(
job_id,
stage,
progress,
f'Running {stage.upper()} builder',
)
command = [sys.executable, str(PROJECT_ROOT / script_path)]
if stage == 'l1' and replace:
command.append('--force')
if stage == 'l3':
command.append('--no-backup')
started = time.monotonic()
result = subprocess.run(
command,
cwd=str(PROJECT_ROOT),
capture_output=True,
text=True,
timeout=1200,
)
duration = time.monotonic() - started
store.append_log(
job_id,
(
f'\n===== {stage.upper()} ({duration:.2f}s) =====\n'
f'{result.stdout}\n{result.stderr}'
),
)
if result.returncode != 0:
raise PipelineError(
f'{stage.upper()} builder exited with code {result.returncode}'
)
def _validate_pipeline_output(match_id=None):
database_status = check_managed_databases()
failures = [
f"{name}: {status['quick_check']}"
for name, status in database_status.items()
if status['quick_check'] != 'ok'
]
if failures:
raise PipelineError(
'Database quick_check failed: ' + ', '.join(failures)
)
if not match_id:
return
l1 = sqlite3.connect(str(L1_DB))
l2 = sqlite3.connect(str(L2_DB))
try:
raw_count = l1.execute(
'SELECT COUNT(*) FROM raw_iframe_network WHERE match_id = ?',
[match_id],
).fetchone()[0]
match_count = l2.execute(
'SELECT COUNT(*) FROM fact_matches WHERE match_id = ?',
[match_id],
).fetchone()[0]
player_count = l2.execute(
'SELECT COUNT(*) FROM fact_match_players WHERE match_id = ?',
[match_id],
).fetchone()[0]
round_count = l2.execute(
'SELECT COUNT(*) FROM fact_rounds WHERE match_id = ?',
[match_id],
).fetchone()[0]
finally:
l1.close()
l2.close()
if raw_count != 1:
raise PipelineError(f'L1 does not contain imported match {match_id}')
if match_count != 1:
raise PipelineError(f'L2 does not contain imported match {match_id}')
if player_count != 10:
raise PipelineError(
f'Imported match has {player_count} players; expected 10'
)
if round_count <= 0:
raise PipelineError('Imported match has no round facts')
def run_pipeline(job_id, match_id=None, replace=False):
store = JobStore()
job = store.get_job(job_id)
if not job:
raise PipelineError(f'Unknown ETL job: {job_id}')
started = time.monotonic()
backup_dir = None
PIPELINE_LOCK.parent.mkdir(parents=True, exist_ok=True)
lock_file = PIPELINE_LOCK.open('w')
try:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise PipelineError('Another database pipeline is already running') from exc
store.start_job(job_id, 'backup', 'Creating rollback snapshot')
backup_dir = create_backup_set(f'job-{job_id}')
store.append_log(job_id, f'Backup created: {backup_dir}\n')
for stage, progress, script_path in STAGES:
_run_stage(
store,
job_id,
stage,
progress,
script_path,
replace=replace,
)
store.update_progress(
job_id,
'validation',
95,
'Validating imported data',
)
_validate_pipeline_output(match_id)
removed_backups = prune_backup_sets(keep=3)
if removed_backups:
store.append_log(
job_id,
f"Pruned old backups: {', '.join(removed_backups)}\n",
)
duration = time.monotonic() - started
store.finish_job(
job_id,
True,
'Pipeline completed and validated',
duration,
)
return True
except Exception as exc:
store.append_log(job_id, f'\nPIPELINE FAILED: {exc}\n')
if backup_dir:
try:
restore_backup_set(backup_dir)
store.append_log(job_id, 'Rollback snapshot restored successfully\n')
except Exception as restore_exc:
store.append_log(
job_id,
f'ROLLBACK FAILED: {restore_exc}\n',
)
exc = PipelineError(f'{exc}; rollback also failed: {restore_exc}')
store.finish_job(
job_id,
False,
str(exc),
time.monotonic() - started,
)
return False
finally:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
finally:
lock_file.close()
def _parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('--job-id', type=int, required=True)
parser.add_argument('--match-id')
parser.add_argument('--replace', action='store_true')
return parser.parse_args()
if __name__ == '__main__':
args = _parse_args()
succeeded = run_pipeline(
args.job_id,
match_id=args.match_id,
replace=args.replace,
)
raise SystemExit(0 if succeeded else 1)