2 changed files with 372 additions and 0 deletions
@ -0,0 +1,364 @@ |
|||||
|
from collections import defaultdict, Counter |
||||
|
import math, traceback |
||||
|
|
||||
|
from discord.ext import tasks |
||||
|
import discord |
||||
|
import asyncio |
||||
|
import aiohttp |
||||
|
import os |
||||
|
|
||||
|
# ============================================================ |
||||
|
# ХРАНИЛИЩЕ ДАННЫХ (in-memory, наполняется при инициализации) |
||||
|
# ============================================================ |
||||
|
class DataStore: |
||||
|
def __init__(self): |
||||
|
self.reports = {} # id -> report dict |
||||
|
self.actions = defaultdict(list) # report_id -> [action, ...] |
||||
|
# индексы для быстрого поиска по обвиняемому |
||||
|
self.by_accused = defaultdict(list) # r_steam']['steam2'] -> [report_id] |
||||
|
self.by_author = defaultdict(list) # a_steam']['steam2'] -> [report_id] |
||||
|
|
||||
|
def load(self, reports): |
||||
|
"""Полная загрузка/перезагрузка данных от главного приложения.""" |
||||
|
self.reports.clear() |
||||
|
self.actions.clear() |
||||
|
self.by_accused.clear() |
||||
|
self.by_author.clear() |
||||
|
|
||||
|
permitions = [] |
||||
|
for r in reports: |
||||
|
try: |
||||
|
if r['r_permition'] not in permitions: |
||||
|
permitions.append(r['r_permition']) |
||||
|
|
||||
|
if r['a_permition'] not in permitions: |
||||
|
permitions.append(r['a_permition']) |
||||
|
|
||||
|
self.reports[r['id']] = r |
||||
|
if r['r_steam']: |
||||
|
self.by_accused[r['r_steam']['steam2']].append(r['id']) |
||||
|
if r['a_steam']: |
||||
|
self.by_author[r['a_steam']['steam2']].append(r['id']) |
||||
|
except: |
||||
|
print(r) |
||||
|
traceback.print_exc() |
||||
|
raise Exception |
||||
|
|
||||
|
for a in reports: |
||||
|
self.actions[a['id']] = a['actions'] |
||||
|
print(permitions) |
||||
|
|
||||
|
def add_report(self, report): |
||||
|
"""Добавить новую жалобу (после того как модератор принял решение).""" |
||||
|
self.reports[report['id']] = report |
||||
|
self.by_accused[report['r_steam']['steam2']].append(report['id']) |
||||
|
self.by_author[report['a_steam']['steam2']].append(report['id']) |
||||
|
|
||||
|
def add_action(self, report_id, action): |
||||
|
self.actions[report_id].append(action) |
||||
|
|
||||
|
class KamazAi2: |
||||
|
action_translate = { |
||||
|
'ban': "забанить игрока", |
||||
|
'inspect': "проверить профиль стима и решить что делать дальше", |
||||
|
'kick': "кикнуть игрока", |
||||
|
'ban30': "легкий бан на 30 минут", |
||||
|
'ban120': "бан на пару часов", |
||||
|
'noreason': "не вводить причину", |
||||
|
'author_kick': "кикнуть автора репорта", |
||||
|
'mute': "замьютить игрока", |
||||
|
'unban': "разбанить игрока если тот в бане, ебанутое решение", |
||||
|
'author_inspect': 'глянуть профиль автора репорта', |
||||
|
'none': 'ничего не делать, лучше не лезть' |
||||
|
} |
||||
|
# ============================================================ |
||||
|
# КЛАССИФИКАЦИЯ ACTION |
||||
|
# ============================================================ |
||||
|
# Действия делятся на: применённые к обвиняемому, к автору, нейтральные. |
||||
|
ACTION_GROUPS = { |
||||
|
# против обвиняемого (жалоба обоснована) |
||||
|
'ban': {'target': 'accused', 'severity': 3, 'guilty': True}, |
||||
|
'kick': {'target': 'accused', 'severity': 2, 'guilty': True}, |
||||
|
'inspect': {'target': 'accused', 'severity': 1, 'guilty': None}, |
||||
|
# против автора (жалоба ложная / абуз репорта) |
||||
|
'author_kick': {'target': 'author', 'severity': 2, 'guilty': False}, |
||||
|
'author_inspect': {'target': 'author', 'severity': 1, 'guilty': None}, |
||||
|
# нейтральные / отклонение |
||||
|
'noreason': {'target': 'none', 'severity': 0, 'guilty': False}, |
||||
|
'none': {"target": 'none', 'severity': 0, 'guilty': False} |
||||
|
} |
||||
|
|
||||
|
def permition_translate(self, permition): |
||||
|
if not permition or permition == 'DONT_HAVE': |
||||
|
return 0 |
||||
|
if permition in ['VIP', 'FREE']: |
||||
|
return 1 |
||||
|
if permition in ['MODERATOR']: |
||||
|
return 4 |
||||
|
if permition in ['ADMIN', 'ROOT']: |
||||
|
return 5 |
||||
|
|
||||
|
return 0 |
||||
|
|
||||
|
def __init__(self, reports): |
||||
|
self.store = DataStore() |
||||
|
self.store.load(reports) |
||||
|
|
||||
|
# ============================================================ |
||||
|
# ЭВРИСТИКИ ПО ИГРОВЫМ МЕТРИКАМ |
||||
|
# ============================================================ |
||||
|
async def kd_ratio(self, kills, deads): |
||||
|
try: |
||||
|
return kills / max(deads, 1) |
||||
|
except Exception as e: |
||||
|
return 1 |
||||
|
|
||||
|
|
||||
|
async def analyze_metrics(self, report): |
||||
|
""" |
||||
|
Возвращает список факторов-сигналов (флагов) с весом и пояснением. |
||||
|
Положительный вес score -> жалоба скорее обоснована (вина обвиняемого). |
||||
|
Отрицательный -> жалоба скорее ложная. |
||||
|
""" |
||||
|
factors = [] |
||||
|
|
||||
|
a_kd = await self.kd_ratio(report['a_kills'], report['a_deads']) |
||||
|
r_kd = await self.kd_ratio(report['r_kills'], report['r_deads']) |
||||
|
reason = (report.get('reasons') or '').lower() |
||||
|
|
||||
|
# --- Подозрение на читы: аномально высокий KD у обвиняемого --- |
||||
|
if any(w in reason for w in ['чит', 'cheat', 'аим', 'aim', 'wh', 'хак']): |
||||
|
if r_kd >= 5 and report['r_kills'] >= 30: |
||||
|
factors.append((+0.4, f"Обвиняемый имеет очень высокий KD ({r_kd:.1f}) " |
||||
|
f"при {report['r_kills']} убийствах — признак читов")) |
||||
|
elif r_kd < 2: |
||||
|
factors.append((-0.3, f"Жалоба на читы, но KD обвиняемого низкий ({r_kd:.1f}) " |
||||
|
f"— читы маловероятны")) |
||||
|
else: |
||||
|
factors.append((+0.1, f"Жалоба на читы, KD обвиняемого {r_kd:.1f} — требуется проверка")) |
||||
|
|
||||
|
# --- Абуз прав (VIP/FreeVIP абуз) --- |
||||
|
if 'абуз' in reason or 'vip' in reason: |
||||
|
if self.permition_translate(report['r_permition']) > 0: |
||||
|
factors.append((+0.2, f"Обвиняемый имеет права (permition={report['r_permition']}), " |
||||
|
f"жалоба на абуз прав правдоподобна")) |
||||
|
else: |
||||
|
factors.append((-0.4, "Жалоба на абуз прав, но у обвиняемого нет прав — необоснованно")) |
||||
|
|
||||
|
# --- Обвиняемый — администратор/модератор (защита от ложных жалоб) --- |
||||
|
if self.permition_translate(report['r_permition']) >= 4: |
||||
|
factors.append((-0.3, f"Обвиняемый имеет высокие права (permition={report['r_permition']}) " |
||||
|
f"— возможна ложная жалоба от обиженного игрока")) |
||||
|
|
||||
|
# --- Автор играет плохо и жалуется на сильного игрока (тильт-репорт) --- |
||||
|
if a_kd < 0.5 and r_kd > 2 and 'чит' not in reason: |
||||
|
factors.append((-0.2, f"Автор играет слабо (KD {a_kd:.1f}) против сильного оппонента " |
||||
|
f"(KD {r_kd:.1f}) — возможен репорт «на эмоциях»")) |
||||
|
|
||||
|
# --- Слишком мало времени на сервере у обвиняемого (нечего оценивать) --- |
||||
|
if report['r_seconds'] < 120: |
||||
|
factors.append((-0.15, f"Обвиняемый провёл на сервере мало времени " |
||||
|
f"({report['r_seconds']} сек) — мало данных")) |
||||
|
|
||||
|
return factors |
||||
|
|
||||
|
|
||||
|
# ============================================================ |
||||
|
# ПОИСК ПОХОЖИХ ИСТОРИЧЕСКИХ ЖАЛОБ |
||||
|
# ============================================================ |
||||
|
async def reason_similarity(self, r1, r2): |
||||
|
"""Простое сравнение причин по словам (Жаккар).""" |
||||
|
s1 = set((r1 or '').lower().split()) |
||||
|
s2 = set((r2 or '').lower().split()) |
||||
|
if not s1 or not s2: |
||||
|
return 0.0 |
||||
|
return len(s1 & s2) / len(s1 | s2) |
||||
|
|
||||
|
|
||||
|
async def similarity(self, new_report, old_report): |
||||
|
"""Оценка схожести двух жалоб (0..1).""" |
||||
|
score = 0.0 |
||||
|
|
||||
|
# тот же обвиняемый — очень сильный сигнал |
||||
|
if new_report['r_steam'] and old_report['r_steam'] and new_report['r_steam']['steam2'] == old_report['r_steam']['steam2']: |
||||
|
score += 0.5 |
||||
|
# тот же автор |
||||
|
if new_report['a_steam'] and old_report['a_steam'] and new_report['a_steam']['steam2'] == old_report['a_steam']['steam2']: |
||||
|
score += 0.15 |
||||
|
# похожая причина |
||||
|
score += 0.25 * await self.reason_similarity(new_report.get('reasons'), old_report.get('reasons')) |
||||
|
|
||||
|
# схожесть KD обвиняемого |
||||
|
new_kd = await self.kd_ratio(new_report['r_kills'], new_report['r_deads']) |
||||
|
old_kd = await self.kd_ratio(old_report['r_kills'], old_report['r_deads']) |
||||
|
kd_diff = abs(new_kd - old_kd) |
||||
|
score += 0.10 * math.exp(-kd_diff / 3.0) # затухание |
||||
|
|
||||
|
return min(score, 1.0) |
||||
|
|
||||
|
|
||||
|
async def find_similar(self, new_report, top_k=5, min_sim=0.15): |
||||
|
results = [] |
||||
|
for rid, old in self.store.reports.items(): |
||||
|
acts = self.store.actions.get(rid) |
||||
|
if not acts: |
||||
|
continue # без решения история бесполезна |
||||
|
sim = await self.similarity(new_report, old) |
||||
|
if sim >= min_sim: |
||||
|
results.append((sim, rid, old, acts)) |
||||
|
results.sort(key=lambda x: x[0], reverse=True) |
||||
|
return results[:top_k] |
||||
|
|
||||
|
|
||||
|
# ============================================================ |
||||
|
# ИТОГОВОЕ РЕШЕНИЕ |
||||
|
# ============================================================ |
||||
|
async def decide(self, new_report): |
||||
|
# 1. Метрические факторы |
||||
|
factors = await self.analyze_metrics(new_report) |
||||
|
metric_score = sum(w for w, _ in factors) |
||||
|
|
||||
|
# 2. Похожие прошлые жалобы |
||||
|
similar = await self.find_similar(new_report) |
||||
|
|
||||
|
# Взвешенное голосование по action из похожих кейсов |
||||
|
action_votes = Counter() |
||||
|
guilty_score = 0.0 |
||||
|
sim_total = 0.0 |
||||
|
|
||||
|
similar_payload = [] |
||||
|
for sim, rid, old, acts in similar: |
||||
|
sim_total += sim |
||||
|
for act in acts: |
||||
|
action_votes[act] += sim |
||||
|
meta = self.ACTION_GROUPS.get(act, {}) |
||||
|
if meta.get('guilty') is True: |
||||
|
guilty_score += sim |
||||
|
elif meta.get('guilty') is False: |
||||
|
guilty_score -= sim |
||||
|
similar_payload.append({ |
||||
|
'report_id': rid, |
||||
|
'similarity': round(sim, 3), |
||||
|
'reasons': old.get('reasons'), |
||||
|
'r_steam2': old['r_steam']['steam2'], |
||||
|
'actions': acts, |
||||
|
}) |
||||
|
|
||||
|
# Нормализуем вклад истории |
||||
|
history_score = (guilty_score / sim_total) if sim_total > 0 else 0.0 |
||||
|
|
||||
|
# 3. Финальный счёт: история весит больше, чем эвристики |
||||
|
final_score = 0.6 * history_score + 0.4 * metric_score |
||||
|
|
||||
|
# 4. Рекомендованное действие |
||||
|
if action_votes: |
||||
|
recommended_action = action_votes.most_common(1)[0][0] |
||||
|
else: |
||||
|
recommended_action = None |
||||
|
|
||||
|
# Маппинг финального счёта в вердикт |
||||
|
if final_score >= 0.35: |
||||
|
verdict = 'GUILTY' # жалоба обоснована — наказать обвиняемого |
||||
|
if not recommended_action or self.ACTION_GROUPS.get(recommended_action, {}).get('target') != 'accused': |
||||
|
recommended_action = 'inspect' |
||||
|
elif final_score <= -0.25: |
||||
|
verdict = 'FALSE_REPORT' # ложная жалоба |
||||
|
if not recommended_action or self.ACTION_GROUPS.get(recommended_action, {}).get('target') == 'accused': |
||||
|
recommended_action = 'none' |
||||
|
else: |
||||
|
verdict = 'UNCERTAIN' |
||||
|
recommended_action = recommended_action or 'inspect' |
||||
|
|
||||
|
confidence = min(abs(final_score) + 0.1 * len(similar), 1.0) |
||||
|
|
||||
|
return { |
||||
|
'verdict': verdict, |
||||
|
'recommended_action': recommended_action, |
||||
|
'action_translate': self.action_translate.get(recommended_action, recommended_action), |
||||
|
'confidence': round(confidence, 3), |
||||
|
'final_score': round(final_score, 3), |
||||
|
'metric_score': round(metric_score, 3), |
||||
|
'history_score': round(history_score, 3), |
||||
|
'action_distribution': dict(action_votes), |
||||
|
'explanations': [text for _, text in factors], |
||||
|
'similar_reports': similar_payload, |
||||
|
} |
||||
|
|
||||
|
class Extension: |
||||
|
core = None |
||||
|
|
||||
|
def __init__(self, core): |
||||
|
self.core = core |
||||
|
self.kamazai2 = None |
||||
|
self.reports_list = [] |
||||
|
|
||||
|
async def task(self, timeout = 15): |
||||
|
if os.getenv('BACKEND_URL') and os.getenv("BACKEND_SECRETKEY"): |
||||
|
pass |
||||
|
else: |
||||
|
print("Cannot init kamazAI2, BACKEND_URL or BACKEND_SECRETKEY is missing in env") |
||||
|
return |
||||
|
|
||||
|
await self.core.wait_until_ready() |
||||
|
while True: |
||||
|
await self.updater() |
||||
|
await asyncio.sleep(timeout) |
||||
|
|
||||
|
async def updater(self): |
||||
|
try: |
||||
|
if self.kamazai2 == None: |
||||
|
print("Sync report list") |
||||
|
async with aiohttp.ClientSession(cookies={ |
||||
|
"secretkey":os.getenv("BACKEND_SECRETKEY")}) as session: |
||||
|
async with session.get(f"{os.getenv('BACKEND_URL')}/api/discord/report/s", ssl = False) as response: |
||||
|
self.reports_list = await response.json() |
||||
|
self.kamazai2 = KamazAi2(self.reports_list) |
||||
|
print("KamazAI2 Enabled") |
||||
|
except: |
||||
|
traceback.print_exc() |
||||
|
|
||||
|
async def __call__(self, message: discord.Message = None): |
||||
|
if self.kamazai2 == None: |
||||
|
print("call kamazAi2 but he is not init") |
||||
|
return await message.reply(content=f'KamazAI2 не иницилизирован') |
||||
|
|
||||
|
try: |
||||
|
report_id = message.embeds[0].color.value |
||||
|
#print("Found report id", report_id) |
||||
|
except: |
||||
|
return await message.reply(content=f'KamazAI2 не может получить индификатор репорта') |
||||
|
|
||||
|
async with aiohttp.ClientSession(cookies={ |
||||
|
"secretkey":os.getenv("BACKEND_SECRETKEY")}) as session: |
||||
|
async with session.get(f"{os.getenv('BACKEND_URL')}/api/discord/report/{report_id}", ssl = False) as response: |
||||
|
report = await response.json() |
||||
|
|
||||
|
result = await self.kamazai2.decide(report) |
||||
|
|
||||
|
content = f'KamazAI2 решил что с этим репортом надо сделать: \n' + result['action_translate'] + '\n' + 'Обоснование: ' + "\n".join(result['explanations']) + "\nОцените решение камаза где :thumbsup: - норм или :thumbsdown: - не очень" |
||||
|
response = await message.reply(content=content) |
||||
|
try: |
||||
|
await response.add_reaction('👍') |
||||
|
await response.add_reaction('👎') |
||||
|
except: |
||||
|
pass |
||||
|
|
||||
|
return response |
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
async def run(): |
||||
|
print("run") |
||||
|
from json import load |
||||
|
with open("/Users/gsd/Downloads/reports.json", "r", encoding="utf8") as report_list: |
||||
|
kamazAi = KamazAi2(load(report_list)) |
||||
|
|
||||
|
test_report1 = {"id":23059,"a_nickname":"серийный чувак","a_permition":"DONT_HAVE","a_kills":29,"a_deads":38,"a_seconds":3237,"r_nickname":"Корабль Бомж 1","r_permition":"DONT_HAVE","r_kills":44,"r_deads":31,"r_seconds":3598,"reasons":"Читы","utime":1780842262,"srv":"srv9","online":18,"type":"IN_GAME","actions":[],"serverName":"Норильск 2019","a_steam":{"steam3":"[U:1:1675616295]","steam2":"STEAM_0:1:837808147","steam64":"76561199635882023","community_url":"https://steamcommunity.com/profiles/76561199635882023","account_id":1675616295},"r_steam":{"steam3":"[U:1:1546493291]","steam2":"STEAM_0:1:773246645","steam64":"76561199506759019","community_url":"https://steamcommunity.com/profiles/76561199506759019","account_id":1546493291}} |
||||
|
test_report2 = {"id":23048,"a_nickname":"Евгений Задротов","a_permition":"FREE","a_kills":63,"a_deads":68,"a_seconds":5031,"r_nickname":"Kapusta_KvSH","r_permition":"VIP","r_kills":11,"r_deads":2,"r_seconds":436,"reasons":"VIP абуз","utime":1780837315,"srv":"srv5","online":21,"type":"IN_GAME","actions":[],"serverName":"Завод Ultimate","a_steam":{"steam3":"[U:1:1623272121]","steam2":"STEAM_0:1:811636060","steam64":"76561199583537849","community_url":"https://steamcommunity.com/profiles/76561199583537849","account_id":1623272121},"r_steam":{"steam3":"[U:1:196806785]","steam2":"STEAM_0:1:98403392","steam64":"76561198157072513","community_url":"https://steamcommunity.com/profiles/76561198157072513","account_id":196806785}} |
||||
|
|
||||
|
print(test_report1) |
||||
|
print(await kamazAi.decide(test_report1)) |
||||
|
print() |
||||
|
print(test_report2) |
||||
|
print(await kamazAi.decide(test_report2)) |
||||
|
import asyncio |
||||
|
asyncio.run(run()) |
||||
Loading…
Reference in new issue