Files
JKTV-online/database/L3/processors/narrative_processor.py
T

599 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from collections import defaultdict, deque
from datetime import datetime, timedelta, timezone
from itertools import combinations
import json
class NarrativeProcessor:
RECORD_FIELDS = (
('highest_rating', 'rating'),
('most_kills', 'kills'),
('highest_adr', 'adr'),
('highest_kd', 'kd_ratio'),
('most_headshots', 'headshot_count'),
)
AWARD_PERIODS = {
'daily': ('单日最佳', 1),
'weekly': ('星期最佳', 2),
'monthly': ('月度最佳', 5),
'quarterly': ('季度最佳', 10),
'yearly': ('年度最佳', 20),
}
@staticmethod
def rebuild(conn_l2, conn_l3, roster_ids):
for table in (
'dm_match_reports',
'dm_match_player_reports',
'dm_player_record_events',
'dm_team_season_stats',
'dm_player_awards',
):
conn_l3.execute(f'DELETE FROM {table}')
rows = NarrativeProcessor._load_player_rows(conn_l2, roster_ids)
record_events, record_keys = NarrativeProcessor._build_record_events(rows)
player_reports = NarrativeProcessor._build_player_reports(rows, record_keys)
match_reports = NarrativeProcessor._build_match_reports(rows, player_reports)
seasons = NarrativeProcessor._build_seasons(match_reports, player_reports)
awards = NarrativeProcessor._build_awards(rows)
NarrativeProcessor._insert_record_events(conn_l3, record_events)
NarrativeProcessor._insert_player_reports(conn_l3, player_reports)
NarrativeProcessor._insert_match_reports(conn_l3, match_reports)
NarrativeProcessor._insert_seasons(conn_l3, seasons)
NarrativeProcessor._insert_awards(conn_l3, awards)
return {
'match_reports': len(match_reports),
'player_reports': len(player_reports),
'record_events': len(record_events),
'seasons': len(seasons),
'awards': len(awards),
}
@staticmethod
def _load_player_rows(conn_l2, roster_ids):
if not roster_ids:
return []
placeholders = ','.join('?' for _ in roster_ids)
rows = conn_l2.execute(
f"""
SELECT
p.match_id,
p.steam_id_64,
CASE
WHEN p.group_id IN (1, 2) THEN p.group_id
WHEN p.team_id IN (1, 2) THEN p.team_id
END AS team_key,
p.rating,
p.kd_ratio,
p.adr,
p.kast,
p.kills,
p.deaths,
p.headshot_count,
p.first_kill,
p.first_death,
p.throw_harm,
p.flash_enemy,
p.is_win,
m.start_time,
m.map_name,
m.score_team1,
m.score_team2
FROM fact_match_players p
JOIN fact_matches m ON m.match_id = p.match_id
WHERE p.steam_id_64 IN ({placeholders})
ORDER BY m.start_time, p.match_id, p.steam_id_64
""",
roster_ids,
).fetchall()
return [dict(row) for row in rows]
@staticmethod
def _build_record_events(rows):
best_values = defaultdict(dict)
last_event = {}
events = []
record_keys = defaultdict(list)
for row in rows:
steam_id = str(row['steam_id_64'])
for record_key, field in NarrativeProcessor.RECORD_FIELDS:
raw_value = row.get(field)
if raw_value is None:
continue
value = float(raw_value)
previous = best_values[steam_id].get(record_key)
if previous is None or value > previous:
event = {
'steam_id_64': steam_id,
'record_key': record_key,
'match_id': row['match_id'],
'match_date': int(row['start_time']),
'map_name': row['map_name'],
'previous_value': previous,
'record_value': value,
'is_current_record': 0,
}
events.append(event)
best_values[steam_id][record_key] = value
last_event[(steam_id, record_key)] = event
if previous is not None:
record_keys[(row['match_id'], steam_id)].append(
record_key
)
for event in last_event.values():
event['is_current_record'] = 1
return events, record_keys
@staticmethod
def _average(history, field):
values = [
float(row[field])
for row in history
if row.get(field) is not None
]
return sum(values) / len(values) if values else None
@staticmethod
def _delta(value, baseline):
if value is None or baseline is None:
return None
return float(value) - float(baseline)
@staticmethod
def _performance_label(delta, baseline_matches):
if baseline_matches < 5 or delta is None:
return 'insufficient_sample'
if delta >= 0.25:
return 'surge'
if delta >= 0.10:
return 'above_form'
if delta <= -0.25:
return 'slump'
if delta <= -0.10:
return 'below_form'
return 'stable'
@staticmethod
def _build_player_reports(rows, record_keys):
histories = defaultdict(lambda: deque(maxlen=20))
reports = []
for row in rows:
steam_id = str(row['steam_id_64'])
history = histories[steam_id]
prior_rating = NarrativeProcessor._average(history, 'rating')
prior_kd = NarrativeProcessor._average(history, 'kd_ratio')
prior_adr = NarrativeProcessor._average(history, 'adr')
rating_delta = NarrativeProcessor._delta(row['rating'], prior_rating)
reports.append({
'match_id': row['match_id'],
'steam_id_64': steam_id,
'match_date': int(row['start_time']),
'rating': row['rating'],
'kd_ratio': row['kd_ratio'],
'adr': row['adr'],
'kills': row['kills'],
'deaths': row['deaths'],
'baseline_matches': len(history),
'prior_20_rating': prior_rating,
'prior_20_kd': prior_kd,
'prior_20_adr': prior_adr,
'rating_delta': rating_delta,
'kd_delta': NarrativeProcessor._delta(row['kd_ratio'], prior_kd),
'adr_delta': NarrativeProcessor._delta(row['adr'], prior_adr),
'performance_label': NarrativeProcessor._performance_label(
rating_delta,
len(history),
),
'record_keys_json': json.dumps(
record_keys.get((row['match_id'], steam_id), [])
),
'team_key': row['team_key'],
'is_win': row['is_win'],
'map_name': row['map_name'],
})
history.append(row)
return reports
@staticmethod
def _build_match_reports(rows, player_reports):
player_report_map = {
(row['match_id'], row['steam_id_64']): row
for row in player_reports
}
grouped = defaultdict(list)
for row in rows:
if row['team_key'] is not None:
grouped[(row['match_id'], row['team_key'])].append(row)
by_match = defaultdict(list)
for (match_id, team_key), team_rows in grouped.items():
by_match[match_id].append((team_key, team_rows))
reports = []
for match_id, candidates in by_match.items():
team_key, team_rows = max(
candidates,
key=lambda item: (len(item[1]), -int(item[0] or 0)),
)
roster_count = len(team_rows)
if roster_count == 0:
continue
mvp = max(
team_rows,
key=lambda row: (
float(row['rating'] or 0),
int(row['kills'] or 0),
),
)
detailed = [
player_report_map[(match_id, str(row['steam_id_64']))]
for row in team_rows
]
eligible_improvers = [
report for report in detailed
if report['baseline_matches'] >= 5
and report['rating_delta'] is not None
]
improver = (
max(eligible_improvers, key=lambda report: report['rating_delta'])
if eligible_improvers else None
)
strongest_duo = None
if roster_count >= 2:
duo = max(
combinations(team_rows, 2),
key=lambda pair: (
float(pair[0]['rating'] or 0)
+ float(pair[1]['rating'] or 0)
),
)
strongest_duo = {
'steam_ids': [
str(duo[0]['steam_id_64']),
str(duo[1]['steam_id_64']),
],
'avg_rating': (
float(duo[0]['rating'] or 0)
+ float(duo[1]['rating'] or 0)
) / 2,
}
record_count = sum(
len(json.loads(report['record_keys_json']))
for report in detailed
)
is_win = bool(team_rows[0]['is_win'])
team_rating = sum(
float(row['rating'] or 0) for row in team_rows
) / roster_count
result_text = '取胜' if is_win else '失利'
summary = (
f"本场{result_text},队内平均 Rating {team_rating:.2f}"
f"MVP Rating {float(mvp['rating'] or 0):.2f}"
)
if record_count:
summary += f",刷新 {record_count} 项个人纪录"
summary += '。'
reports.append({
'match_id': match_id,
'match_date': int(team_rows[0]['start_time']),
'map_name': team_rows[0]['map_name'],
'roster_count': roster_count,
'team_key': team_key,
'is_win': int(is_win),
'team_avg_rating': team_rating,
'team_avg_adr': sum(
float(row['adr'] or 0) for row in team_rows
) / roster_count,
'mvp_steam_id': str(mvp['steam_id_64']),
'mvp_rating': mvp['rating'],
'improver_steam_id': (
improver['steam_id_64'] if improver else None
),
'improver_delta': (
improver['rating_delta'] if improver else None
),
'strongest_duo_json': json.dumps(strongest_duo),
'record_break_count': record_count,
'summary_text': summary,
})
reports.sort(key=lambda row: (row['match_date'], row['match_id']))
return reports
@staticmethod
def _build_seasons(match_reports, player_reports):
team_reports = [
row for row in match_reports if row['roster_count'] >= 2
]
report_matches = {row['match_id'] for row in team_reports}
seasons = defaultdict(list)
for report in team_reports:
year = datetime.fromtimestamp(
report['match_date'],
timezone.utc,
).year
seasons[str(year)].append(report)
result = []
for season_key, reports in sorted(seasons.items()):
year = int(season_key)
start = int(datetime(year, 1, 1, tzinfo=timezone.utc).timestamp())
end = int(
datetime(year + 1, 1, 1, tzinfo=timezone.utc).timestamp()
) - 1
wins = sum(int(row['is_win']) for row in reports)
maps = defaultdict(lambda: {'matches': 0, 'wins': 0})
for row in reports:
item = maps[row['map_name'] or 'Unknown']
item['matches'] += 1
item['wins'] += int(row['is_win'])
best_map, best_map_data = max(
maps.items(),
key=lambda item: (
item[1]['wins'] / item[1]['matches'],
item[1]['matches'],
),
)
ratings = defaultdict(list)
for report in player_reports:
if report['match_id'] not in report_matches:
continue
report_year = datetime.fromtimestamp(
report['match_date'],
timezone.utc,
).year
if report_year == year and report['rating'] is not None:
ratings[report['steam_id_64']].append(
float(report['rating'])
)
top_player, top_values = max(
ratings.items(),
key=lambda item: (sum(item[1]) / len(item[1]), len(item[1])),
)
result.append({
'season_key': season_key,
'season_label': f'{year} Season',
'period_start': start,
'period_end': end,
'matches': len(reports),
'wins': wins,
'losses': len(reports) - wins,
'win_rate': wins / len(reports),
'avg_team_rating': sum(
row['team_avg_rating'] for row in reports
) / len(reports),
'best_map': best_map,
'best_map_win_rate': (
best_map_data['wins'] / best_map_data['matches']
),
'top_player_steam_id': top_player,
'top_player_rating': sum(top_values) / len(top_values),
})
return result
@staticmethod
def _period_identity(timestamp, award_type):
moment = datetime.fromtimestamp(timestamp, timezone.utc)
if award_type == 'daily':
start = datetime(moment.year, moment.month, moment.day, tzinfo=timezone.utc)
return start.strftime('%Y-%m-%d'), start, start + timedelta(days=1)
if award_type == 'weekly':
start = datetime(
moment.year,
moment.month,
moment.day,
tzinfo=timezone.utc,
) - timedelta(days=moment.weekday())
iso_year, iso_week, _ = start.isocalendar()
return f'{iso_year}-W{iso_week:02d}', start, start + timedelta(days=7)
if award_type == 'monthly':
start = datetime(moment.year, moment.month, 1, tzinfo=timezone.utc)
end = (
datetime(moment.year + 1, 1, 1, tzinfo=timezone.utc)
if moment.month == 12
else datetime(moment.year, moment.month + 1, 1, tzinfo=timezone.utc)
)
return start.strftime('%Y-%m'), start, end
if award_type == 'quarterly':
quarter = (moment.month - 1) // 3 + 1
start_month = (quarter - 1) * 3 + 1
start = datetime(moment.year, start_month, 1, tzinfo=timezone.utc)
end = (
datetime(moment.year + 1, 1, 1, tzinfo=timezone.utc)
if quarter == 4
else datetime(moment.year, start_month + 3, 1, tzinfo=timezone.utc)
)
return f'{moment.year}-Q{quarter}', start, end
start = datetime(moment.year, 1, 1, tzinfo=timezone.utc)
return str(moment.year), start, datetime(
moment.year + 1,
1,
1,
tzinfo=timezone.utc,
)
@staticmethod
def _build_awards(rows):
awards = []
for award_type, (award_name, min_matches) in (
NarrativeProcessor.AWARD_PERIODS.items()
):
periods = defaultdict(lambda: defaultdict(list))
boundaries = {}
for row in rows:
period_key, start, end = NarrativeProcessor._period_identity(
int(row['start_time']),
award_type,
)
periods[period_key][str(row['steam_id_64'])].append(row)
boundaries[period_key] = (start, end)
for period_key, player_rows in periods.items():
candidates = []
for steam_id, matches in player_rows.items():
if len(matches) < min_matches:
continue
wins = sum(int(row['is_win'] or 0) for row in matches)
kills = sum(int(row['kills'] or 0) for row in matches)
deaths = sum(int(row['deaths'] or 0) for row in matches)
avg_rating = sum(
float(row['rating'] or 0) for row in matches
) / len(matches)
avg_adr = sum(
float(row['adr'] or 0) for row in matches
) / len(matches)
avg_kd = kills / deaths if deaths else float(kills)
win_rate = wins / len(matches)
score = avg_rating + avg_adr * 0.001 + win_rate * 0.02
candidates.append({
'steam_id_64': steam_id,
'matches': len(matches),
'wins': wins,
'win_rate': win_rate,
'avg_rating': avg_rating,
'avg_kd': avg_kd,
'avg_adr': avg_adr,
'performance_score': score,
})
if not candidates:
continue
winner = max(
candidates,
key=lambda item: (
item['performance_score'],
item['matches'],
),
)
start, end = boundaries[period_key]
awards.append({
'award_type': award_type,
'period_key': period_key,
'period_label': f'{period_key} {award_name}',
'period_start': int(start.timestamp()),
'period_end': int(end.timestamp()) - 1,
**winner,
'sample_reliable': int(
winner['matches'] >= min_matches
),
})
awards.sort(
key=lambda row: (row['period_start'], row['award_type'])
)
return awards
@staticmethod
def _insert_record_events(conn, rows):
conn.executemany(
"""
INSERT INTO dm_player_record_events (
steam_id_64, record_key, match_id, match_date, map_name,
previous_value, record_value, is_current_record
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
row['steam_id_64'], row['record_key'], row['match_id'],
row['match_date'], row['map_name'], row['previous_value'],
row['record_value'], row['is_current_record'],
)
for row in rows
],
)
@staticmethod
def _insert_player_reports(conn, rows):
conn.executemany(
"""
INSERT INTO dm_match_player_reports (
match_id, steam_id_64, match_date, rating, kd_ratio, adr,
kills, deaths, baseline_matches, prior_20_rating,
prior_20_kd, prior_20_adr, rating_delta, kd_delta, adr_delta,
performance_label, record_keys_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
row['match_id'], row['steam_id_64'], row['match_date'],
row['rating'], row['kd_ratio'], row['adr'], row['kills'],
row['deaths'], row['baseline_matches'],
row['prior_20_rating'], row['prior_20_kd'],
row['prior_20_adr'], row['rating_delta'], row['kd_delta'],
row['adr_delta'], row['performance_label'],
row['record_keys_json'],
)
for row in rows
],
)
@staticmethod
def _insert_match_reports(conn, rows):
conn.executemany(
"""
INSERT INTO dm_match_reports (
match_id, match_date, map_name, roster_count, team_key,
is_win, team_avg_rating, team_avg_adr, mvp_steam_id,
mvp_rating, improver_steam_id, improver_delta,
strongest_duo_json, record_break_count, summary_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
tuple(row[key] for key in (
'match_id', 'match_date', 'map_name', 'roster_count',
'team_key', 'is_win', 'team_avg_rating', 'team_avg_adr',
'mvp_steam_id', 'mvp_rating', 'improver_steam_id',
'improver_delta', 'strongest_duo_json',
'record_break_count', 'summary_text',
))
for row in rows
],
)
@staticmethod
def _insert_seasons(conn, rows):
conn.executemany(
"""
INSERT INTO dm_team_season_stats (
season_key, season_label, period_start, period_end,
matches, wins, losses, win_rate, avg_team_rating,
best_map, best_map_win_rate, top_player_steam_id,
top_player_rating
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
tuple(row[key] for key in (
'season_key', 'season_label', 'period_start', 'period_end',
'matches', 'wins', 'losses', 'win_rate',
'avg_team_rating', 'best_map', 'best_map_win_rate',
'top_player_steam_id', 'top_player_rating',
))
for row in rows
],
)
@staticmethod
def _insert_awards(conn, rows):
conn.executemany(
"""
INSERT INTO dm_player_awards (
award_type, period_key, period_label, period_start,
period_end, steam_id_64, matches, wins, win_rate,
avg_rating, avg_kd, avg_adr, performance_score,
sample_reliable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
tuple(row[key] for key in (
'award_type', 'period_key', 'period_label',
'period_start', 'period_end', 'steam_id_64', 'matches',
'wins', 'win_rate', 'avg_rating', 'avg_kd', 'avg_adr',
'performance_score', 'sample_reliable',
))
for row in rows
],
)