2.0.0 Alpha: Data Refinery
This commit is contained in:
+490
-12
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user