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