Rename history to state as it's now storing more than just the history
This commit is contained in:
parent
c93c5b9ecd
commit
78834f5319
5 changed files with 149 additions and 40 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,4 +1,4 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
config.json
|
config.json
|
||||||
history.json
|
state.json
|
||||||
.venv
|
.venv
|
10
matching.py
10
matching.py
|
@ -3,7 +3,7 @@ import logging
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
import history
|
import state
|
||||||
|
|
||||||
|
|
||||||
# Number of days to step forward from the start of history for each match attempt
|
# Number of days to step forward from the start of history for each match attempt
|
||||||
|
@ -88,7 +88,7 @@ def get_member_group_eligibility_score(member: Member,
|
||||||
|
|
||||||
|
|
||||||
def attempt_create_groups(matchees: list[Member],
|
def attempt_create_groups(matchees: list[Member],
|
||||||
hist: history.History,
|
hist: state.State,
|
||||||
oldest_relevant_ts: datetime,
|
oldest_relevant_ts: datetime,
|
||||||
per_group: int) -> tuple[bool, list[list[Member]]]:
|
per_group: int) -> tuple[bool, list[list[Member]]]:
|
||||||
"""History aware group matching"""
|
"""History aware group matching"""
|
||||||
|
@ -106,7 +106,7 @@ def attempt_create_groups(matchees: list[Member],
|
||||||
matchee_matches = hist.matchees.get(
|
matchee_matches = hist.matchees.get(
|
||||||
str(matchee.id), {}).get("matches", {})
|
str(matchee.id), {}).get("matches", {})
|
||||||
relevant_matches = list(int(id) for id, ts in matchee_matches.items()
|
relevant_matches = list(int(id) for id, ts in matchee_matches.items()
|
||||||
if history.ts_to_datetime(ts) >= oldest_relevant_ts)
|
if state.ts_to_datetime(ts) >= oldest_relevant_ts)
|
||||||
|
|
||||||
# Try every single group from the current group onwards
|
# Try every single group from the current group onwards
|
||||||
# Progressing through the groups like this ensures we slowly fill them up with compatible people
|
# Progressing through the groups like this ensures we slowly fill them up with compatible people
|
||||||
|
@ -144,7 +144,7 @@ def datetime_range(start_time: datetime, increment: timedelta, end: datetime):
|
||||||
|
|
||||||
|
|
||||||
def members_to_groups(matchees: list[Member],
|
def members_to_groups(matchees: list[Member],
|
||||||
hist: history.History = history.History(),
|
hist: state.State = state.State(),
|
||||||
per_group: int = 3,
|
per_group: int = 3,
|
||||||
allow_fallback: bool = False) -> list[list[Member]]:
|
allow_fallback: bool = False) -> list[list[Member]]:
|
||||||
"""Generate the groups from the set of matchees"""
|
"""Generate the groups from the set of matchees"""
|
||||||
|
@ -152,7 +152,7 @@ def members_to_groups(matchees: list[Member],
|
||||||
rand = random.Random(117) # Some stable randomness
|
rand = random.Random(117) # Some stable randomness
|
||||||
|
|
||||||
# Grab the oldest timestamp
|
# Grab the oldest timestamp
|
||||||
history_start = hist.oldest() or datetime.now()
|
history_start = hist.oldest_history() or datetime.now()
|
||||||
|
|
||||||
# Walk from the start of time until now using the timestep increment
|
# Walk from the start of time until now using the timestep increment
|
||||||
for oldest_relevant_datetime in datetime_range(history_start, _ATTEMPT_TIMESTEP_INCREMENT, datetime.now()):
|
for oldest_relevant_datetime in datetime_range(history_start, _ATTEMPT_TIMESTEP_INCREMENT, datetime.now()):
|
||||||
|
|
|
@ -5,7 +5,7 @@ import discord
|
||||||
import pytest
|
import pytest
|
||||||
import random
|
import random
|
||||||
import matching
|
import matching
|
||||||
import history
|
import state
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,16 +44,16 @@ class Member():
|
||||||
return self._id
|
return self._id
|
||||||
|
|
||||||
|
|
||||||
def inner_validate_members_to_groups(matchees: list[Member], hist: history.History, per_group: int):
|
def inner_validate_members_to_groups(matchees: list[Member], tmp_state: state.State, per_group: int):
|
||||||
"""Inner function to validate the main output of the groups function"""
|
"""Inner function to validate the main output of the groups function"""
|
||||||
groups = matching.members_to_groups(matchees, hist, per_group)
|
groups = matching.members_to_groups(matchees, tmp_state, per_group)
|
||||||
|
|
||||||
# We should always have one group
|
# We should always have one group
|
||||||
assert len(groups)
|
assert len(groups)
|
||||||
|
|
||||||
# Log the groups to history
|
# Log the groups to history
|
||||||
# This will validate the internals
|
# This will validate the internals
|
||||||
hist.log_groups_to_history(groups)
|
tmp_state.log_groups(groups)
|
||||||
|
|
||||||
# Ensure each group contains within the bounds of expected members
|
# Ensure each group contains within the bounds of expected members
|
||||||
for group in groups:
|
for group in groups:
|
||||||
|
@ -81,8 +81,8 @@ def inner_validate_members_to_groups(matchees: list[Member], hist: history.Histo
|
||||||
], ids=['single', "larger_groups", "100_members", "5_group", "pairs", "356_big_groups"])
|
], ids=['single', "larger_groups", "100_members", "5_group", "pairs", "356_big_groups"])
|
||||||
def test_members_to_groups_no_history(matchees, per_group):
|
def test_members_to_groups_no_history(matchees, per_group):
|
||||||
"""Test simple group matching works"""
|
"""Test simple group matching works"""
|
||||||
hist = history.History()
|
tmp_state = state.State()
|
||||||
inner_validate_members_to_groups(matchees, hist, per_group)
|
inner_validate_members_to_groups(matchees, tmp_state, per_group)
|
||||||
|
|
||||||
|
|
||||||
def items_found_in_lists(list_of_lists, items):
|
def items_found_in_lists(list_of_lists, items):
|
||||||
|
@ -95,8 +95,8 @@ def items_found_in_lists(list_of_lists, items):
|
||||||
|
|
||||||
@pytest.mark.parametrize("history_data, matchees, per_group, checks", [
|
@pytest.mark.parametrize("history_data, matchees, per_group, checks", [
|
||||||
# Slightly more difficult test
|
# Slightly more difficult test
|
||||||
# Describe a history where we previously matched up some people and ensure they don't get rematched
|
|
||||||
(
|
(
|
||||||
|
# Describe a history where we previously matched up some people and ensure they don't get rematched
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"ts": datetime.now() - timedelta(days=1),
|
"ts": datetime.now() - timedelta(days=1),
|
||||||
|
@ -156,13 +156,13 @@ def items_found_in_lists(list_of_lists, items):
|
||||||
], ids=['simple_history', 'fallback'])
|
], ids=['simple_history', 'fallback'])
|
||||||
def test_members_to_groups_with_history(history_data, matchees, per_group, checks):
|
def test_members_to_groups_with_history(history_data, matchees, per_group, checks):
|
||||||
"""Test more advanced group matching works"""
|
"""Test more advanced group matching works"""
|
||||||
hist = history.History()
|
tmp_state = state.State()
|
||||||
|
|
||||||
# Replay the history
|
# Replay the history
|
||||||
for d in history_data:
|
for d in history_data:
|
||||||
hist.log_groups_to_history(d["groups"], d["ts"])
|
tmp_state.log_groups(d["groups"], d["ts"])
|
||||||
|
|
||||||
groups = inner_validate_members_to_groups(matchees, hist, per_group)
|
groups = inner_validate_members_to_groups(matchees, tmp_state, per_group)
|
||||||
|
|
||||||
# Run the custom validate functions
|
# Run the custom validate functions
|
||||||
for check in checks:
|
for check in checks:
|
||||||
|
@ -208,8 +208,8 @@ def test_members_to_groups_stress_test():
|
||||||
rand.shuffle(history_data)
|
rand.shuffle(history_data)
|
||||||
|
|
||||||
# Replay the history
|
# Replay the history
|
||||||
hist = history.History()
|
tmp_state = state.State()
|
||||||
for d in history_data:
|
for d in history_data:
|
||||||
hist.log_groups_to_history(d["groups"], d["ts"])
|
tmp_state.log_groups(d["groups"], d["ts"])
|
||||||
|
|
||||||
inner_validate_members_to_groups(matchees, hist, per_group)
|
inner_validate_members_to_groups(matchees, tmp_state, per_group)
|
||||||
|
|
26
matchy.py
26
matchy.py
|
@ -6,13 +6,13 @@ import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
import matching
|
import matching
|
||||||
import history
|
import state
|
||||||
import config
|
import config
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
Config = config.load()
|
Config = config.load()
|
||||||
History = history.load()
|
State = state.load()
|
||||||
|
|
||||||
logger = logging.getLogger("matchy")
|
logger = logging.getLogger("matchy")
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
@ -68,22 +68,6 @@ async def close(ctx: commands.Context):
|
||||||
await bot.close()
|
await bot.close()
|
||||||
|
|
||||||
|
|
||||||
# @bot.tree.command(description="Sign up as a matchee in this server")
|
|
||||||
# @commands.guild_only()
|
|
||||||
# async def join(interaction: discord.Interaction):
|
|
||||||
# # TODO: Sign up
|
|
||||||
# await interaction.response.send_message(
|
|
||||||
# f"Awesome, great to have you on board {interaction.user.mention}!", ephemeral=True)
|
|
||||||
|
|
||||||
|
|
||||||
# @bot.tree.command(description="Leave the matchee list in this server")
|
|
||||||
# @commands.guild_only()
|
|
||||||
# async def leave(interaction: discord.Interaction):
|
|
||||||
# # TODO: Remove the user
|
|
||||||
# await interaction.response.send_message(
|
|
||||||
# f"No worries, see you soon {interaction.user.mention}!", ephemeral=True)
|
|
||||||
|
|
||||||
|
|
||||||
@bot.tree.command(description="Match up matchees")
|
@bot.tree.command(description="Match up matchees")
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
@app_commands.describe(members_min="Minimum matchees per match (defaults to 3)",
|
@app_commands.describe(members_min="Minimum matchees per match (defaults to 3)",
|
||||||
|
@ -114,7 +98,7 @@ async def match(interaction: discord.Interaction, members_min: int = None, match
|
||||||
matchees = list(
|
matchees = list(
|
||||||
m for m in interaction.channel.members if matchee in m.roles)
|
m for m in interaction.channel.members if matchee in m.roles)
|
||||||
groups = matching.members_to_groups(
|
groups = matching.members_to_groups(
|
||||||
matchees, History, members_min, allow_fallback=True)
|
matchees, State, members_min, allow_fallback=True)
|
||||||
|
|
||||||
# Post about all the groups with a button to send to the channel
|
# Post about all the groups with a button to send to the channel
|
||||||
groups_list = '\n'.join(matching.group_to_message(g) for g in groups)
|
groups_list = '\n'.join(matching.group_to_message(g) for g in groups)
|
||||||
|
@ -173,7 +157,7 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button],
|
||||||
matchees = list(
|
matchees = list(
|
||||||
m for m in interaction.channel.members if matchee in m.roles)
|
m for m in interaction.channel.members if matchee in m.roles)
|
||||||
groups = matching.members_to_groups(
|
groups = matching.members_to_groups(
|
||||||
matchees, History, self.min, allow_fallback=True)
|
matchees, State, self.min, allow_fallback=True)
|
||||||
|
|
||||||
# Send the groups
|
# Send the groups
|
||||||
for msg in (matching.group_to_message(g) for g in groups):
|
for msg in (matching.group_to_message(g) for g in groups):
|
||||||
|
@ -183,7 +167,7 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button],
|
||||||
await interaction.channel.send("That's all folks, happy matching and remember - DFTBA!")
|
await interaction.channel.send("That's all folks, happy matching and remember - DFTBA!")
|
||||||
|
|
||||||
# Save the groups to the history
|
# Save the groups to the history
|
||||||
History.save_groups_to_history(groups)
|
State.save_groups(groups)
|
||||||
|
|
||||||
logger.info("Done. Matched %s matchees into %s groups.",
|
logger.info("Done. Matched %s matchees into %s groups.",
|
||||||
len(matchees), len(groups))
|
len(matchees), len(groups))
|
||||||
|
|
125
state.py
Normal file
125
state.py
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
"""Store bot state"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from schema import Schema, And, Use, Optional
|
||||||
|
from typing import Protocol
|
||||||
|
import files
|
||||||
|
import copy
|
||||||
|
|
||||||
|
_FILE = "state.json"
|
||||||
|
|
||||||
|
# Warning: Changing any of the below needs proper thought to ensure backwards compatibility
|
||||||
|
_DEFAULT_DICT = {
|
||||||
|
"history": {},
|
||||||
|
"matchees": {}
|
||||||
|
}
|
||||||
|
_TIME_FORMAT = "%a %b %d %H:%M:%S %Y"
|
||||||
|
_SCHEMA = Schema(
|
||||||
|
{
|
||||||
|
Optional("history"): {
|
||||||
|
Optional(str): { # a datetime
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"members": [
|
||||||
|
# The ID of each matchee in the match
|
||||||
|
And(Use(int))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Optional("matchees"): {
|
||||||
|
Optional(str): {
|
||||||
|
Optional("matches"): {
|
||||||
|
# Matchee ID and Datetime pair
|
||||||
|
Optional(str): And(Use(str))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Member(Protocol):
|
||||||
|
@property
|
||||||
|
def id(self) -> int:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def ts_to_datetime(ts: str) -> datetime:
|
||||||
|
"""Convert a ts to datetime using the internal format"""
|
||||||
|
return datetime.strptime(ts, _TIME_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(dict: dict):
|
||||||
|
"""Initialise and validate the state"""
|
||||||
|
_SCHEMA.validate(dict)
|
||||||
|
|
||||||
|
|
||||||
|
class State():
|
||||||
|
def __init__(self, data: dict = _DEFAULT_DICT):
|
||||||
|
"""Initialise and validate the state"""
|
||||||
|
validate(data)
|
||||||
|
self.__dict__ = copy.deepcopy(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def history(self) -> list[dict]:
|
||||||
|
return self.__dict__["history"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def matchees(self) -> dict[str, dict]:
|
||||||
|
return self.__dict__["matchees"]
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Save out the state"""
|
||||||
|
files.save(_FILE, self.__dict__)
|
||||||
|
|
||||||
|
def oldest_history(self) -> datetime:
|
||||||
|
"""Grab the oldest timestamp in history"""
|
||||||
|
if not self.history:
|
||||||
|
return None
|
||||||
|
times = (ts_to_datetime(dt) for dt in self.history.keys())
|
||||||
|
return sorted(times)[0]
|
||||||
|
|
||||||
|
def log_groups(self, groups: list[list[Member]], ts: datetime = datetime.now()) -> None:
|
||||||
|
"""Log the groups"""
|
||||||
|
tmp_state = State(self.__dict__)
|
||||||
|
ts = datetime.strftime(ts, _TIME_FORMAT)
|
||||||
|
|
||||||
|
# Grab or create the hitory item for this set of groups
|
||||||
|
history_item = {}
|
||||||
|
tmp_state.history[ts] = history_item
|
||||||
|
history_item_groups = []
|
||||||
|
history_item["groups"] = history_item_groups
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
|
||||||
|
# Add the group data
|
||||||
|
history_item_groups.append({
|
||||||
|
"members": list(m.id for m in group)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update the matchee data with the matches
|
||||||
|
for m in group:
|
||||||
|
matchee = tmp_state.matchees.get(str(m.id), {})
|
||||||
|
matchee_matches = matchee.get("matches", {})
|
||||||
|
|
||||||
|
for o in (o for o in group if o.id != m.id):
|
||||||
|
matchee_matches[str(o.id)] = ts
|
||||||
|
|
||||||
|
matchee["matches"] = matchee_matches
|
||||||
|
tmp_state.matchees[str(m.id)] = matchee
|
||||||
|
|
||||||
|
# Validate before storing the result
|
||||||
|
validate(self.__dict__)
|
||||||
|
self.__dict__ = tmp_state.__dict__
|
||||||
|
|
||||||
|
def save_groups(self, groups: list[list[Member]]) -> None:
|
||||||
|
"""Save out the groups to the state file"""
|
||||||
|
self.log_groups(groups)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> State:
|
||||||
|
"""Load the state"""
|
||||||
|
return State(files.load(_FILE) if os.path.isfile(_FILE) else _DEFAULT_DICT)
|
Loading…
Add table
Reference in a new issue