Quizard/Quizard.py
2025-04-15 22:09:15 +03:00

346 lines
11 KiB
Python

import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import (Dict, Generic, List, Optional, Protocol, Set, Tuple,
TypeVar, Union, overload, runtime_checkable)
from option import Err, Option, Result, Some
from utils.utils import indent
C = TypeVar("C", default=str)
V = TypeVar("V", default=str)
@dataclass
class Tags(Generic[C, V]):
_dict: Dict[C, Set[V]] = field(default_factory=dict)
@staticmethod
def from_list(tags: List[Tuple[C, V]]) -> "Tags[C, V]":
tag_dict: Dict[C, Set[V]] = {}
for cat, val in tags:
tag_dict.setdefault(cat, set()).add(val)
return Tags(tag_dict)
@overload
def has_tag(self, category: C, value: V) -> bool: ...
@overload
def has_tag(self, category: Tuple[C, V]) -> bool: ...
def has_tag(
self, category: Union[C, Tuple[C, V]], value: Optional[V] = None
) -> bool:
if isinstance(category, tuple):
cat, val = category
return val in self._dict.get(cat, set())
else:
assert (
value is not None
), "Value must be provided if category is not a tuple"
return value in self._dict.get(category, set())
def add_tag(self, category: C, value: V) -> None:
self._dict.setdefault(category, set()).add(value)
def __str__(self) -> str:
if len(self._dict) == 0:
return "No tags"
lines = []
for category, values in self._dict.items():
cat_str = category.value if isinstance(category, Enum) else str(category)
val_strs = sorted(
[v.value if isinstance(v, Enum) else str(v) for v in values]
)
lines.append(f"{cat_str}: {', '.join(val_strs)}")
return "\n".join(lines)
def __iter__(self):
for category, values in self._dict.items():
for value in values:
yield category, value
@dataclass(frozen=True)
class QuizTaskGeneratorMetadata(Generic[C, V]):
name: Option[str] = Option.maybe(None)
description: Option[str] = Option.maybe(None)
id: uuid.UUID = uuid.uuid4()
@staticmethod
def from_values(
name: Optional[str] = None,
description: Optional[str] = None,
) -> "QuizTaskGeneratorMetadata[C, V]":
return QuizTaskGeneratorMetadata(
name=Option.maybe(name),
description=Option.maybe(description),
)
@dataclass
class QuizTask(Generic[C, V]):
question: str
answer: str
tags: Tags[C, V] = Tags[C, V]()
generator_metadata: QuizTaskGeneratorMetadata[C, V] = QuizTaskGeneratorMetadata[
C, V
].from_values()
id: uuid.UUID = uuid.uuid4()
def __init__(
self,
question: str,
answer: str,
tags: Optional[Union[Tags[C, V], List[Tuple[C, V]]]] = None,
):
self.question = question
self.answer = answer
if isinstance(tags, List):
self.tags = Tags[C, V].from_list(tags)
elif isinstance(tags, Tags):
self.tags = tags
def __str__(self):
return f"Question:\n{indent(self.question)}\nAnswer:\n{indent(self.answer)}\nTags:\n{indent(self.tags.map_or(lambda t: str(t), "{}"))}"
@runtime_checkable
class TaskGenerator(Protocol, Generic[C, V]):
metadata: QuizTaskGeneratorMetadata[C, V]
default_amount: Option[int] = Option.maybe(None)
def generate(self) -> QuizTask[C, V]: ...
class TaskGeneratorPool(List[TaskGenerator[C, V]], Generic[C, V]): ...
@dataclass
class TaskPool(List[QuizTask[C, V]], Generic[C, V]): ...
class QuizVariant[C, V]:
tasks: list[QuizTask[C, V]]
type VariantList[C, V] = List[QuizVariant[C, V]]
@runtime_checkable
class TaskStaticConstraint(Protocol[C, V]):
# dull func to distinct dynamic and static types
def _sta(self):
return None
def is_satisfied(self, task: QuizTask[C, V]) -> bool: ...
def negative(self) -> "TaskStaticConstraint[C, V]":
self.is_satisfied = lambda task: not self.is_satisfied(task)
return self
type TaskOrGenerator[C, V] = Union[TaskGenerator[C, V], QuizTask[C, V]]
class MustBeAnyConstrain(TaskStaticConstraint[C, V]):
must_be_generated_by: List[TaskGenerator[C, V]] = []
must_be: List[QuizTask[C, V]] = []
@overload
def __init__(self, item: List[TaskOrGenerator[C, V]]):
...
@overload
def __init__(self, item: TaskOrGenerator[C, V], **kwargs: TaskOrGenerator[C, V]):
...
def __init__(self, item: Union[List[TaskOrGenerator[C, V]], TaskOrGenerator[C, V]], **kwargs: Union[TaskGenerator[C, V], QuizTask[C, V]]):
all_items = []
if isinstance(item, List):
all_items.extend(item)
else:
all_items.append(item)
all_items.extend(kwargs.values())
self.must_be_generated_by = [
v for v in all_items if isinstance(v, TaskGenerator)
]
self.must_be = [v for v in all_items if isinstance(v, QuizTask)]
def is_satisfied(self, task: QuizTask[C, V]) -> bool:
return any(
[
task.generator_metadata.id == g.metadata.id
for g in self.must_be_generated_by
]
) or any([task.id == t.id for t in self.must_be])
class MustIncludeAnyTag(TaskStaticConstraint[C, V]):
def __init__(self, tags: Tags[C, V]):
self.tag_to_be_included = tags
def is_satisfied(self, task: QuizTask[C, V]) -> bool:
return any(task.tags.has_tag(tag) for tag in self.tag_to_be_included)
class MustIncludeAllTags(TaskStaticConstraint[C, V]):
def __init__(self, tags: Tags[C, V]):
self.tags_to_be_included = tags
def is_satisfied(self, task: QuizTask[C, V]) -> bool:
return all(task.tags.has_tag(tag) for tag in self.tags_to_be_included)
class MustNotStatic(TaskStaticConstraint[C, V]):
def __init__(self, constrain: TaskStaticConstraint[C, V]):
self.constrain = constrain
def is_satisfied(self, task: QuizTask[C, V]) -> bool:
return not self.constrain.is_satisfied(task)
@runtime_checkable
class TaskDynamicConstraint(Protocol[C, V]):
# dull func to distinct dynamic and static types
def _dyn(self):
return None
def is_satisfied(
self,
task: QuizTask[C, V],
variant_set: VariantList[C, V],
current_variant: QuizVariant[C, V],
) -> bool: ...
class VariantConstraint(Protocol[C, V]):
def check_dynamic_criteria(self, variant_set: VariantList[C, V]) -> bool: ...
class VariantTask(Generic[C, V]):
static_constrains: List[TaskStaticConstraint[C, V]] = []
dynamic_constrains: List[TaskDynamicConstraint[C, V]] = []
class Not:
def __init__(self, variant_task: "VariantTask[C, V]"):
self._variant_task = variant_task
def add_constrain(
self, constrain: Union[TaskStaticConstraint[C, V], TaskDynamicConstraint[C, V]]
):
if isinstance(constrain, TaskStaticConstraint):
self.static_constrains.append(constrain)
elif isinstance(constrain, TaskDynamicConstraint):
self.dynamic_constrains.append(constrain)
@overload
def must_be_any(
self, items: List[Union[TaskGenerator[C, V], QuizTask[C, V]]]
) -> "VariantTask[C, V]": ...
@overload
def must_be_any(
self,
**kwargs: Union[TaskGenerator[C, V], QuizTask[C, V]],
) -> "VariantTask[C, V]": ...
def must_be_any(
self,
items: Union[
List[Union[TaskGenerator[C, V], QuizTask[C, V]]],
TaskGenerator[C, V],
QuizTask[C, V],
],
**kwargs: Union[TaskGenerator[C, V], QuizTask[C, V]],
) -> "VariantTask[C, V]":
if items:
self.add_constrain(MustBeAnyConstrain(*items))
elif kwargs:
self.add_constrain(MustBeAnyConstrain(**kwargs))
return self
@overload
def must_include_any_tag(self, tags: Tags[C, V]) -> "VariantTask[C, V]": ...
@overload
def must_include_any_tag(self, tags: Tuple[C, V], **kwargs: Tuple[C, V]) -> "VariantTask[C, V]": ...
def must_include_any_tag(
self, tags: Union[Tags[C, V], Tuple[C, V]], **kwargs: Tuple[C, V]
) -> "VariantTask[C, V]":
if isinstance(tags, Tuple):
self.add_constrain(MustIncludeAnyTag(Tags[C, V].from_list([tags])))
elif isinstance(tags, Tags):
self.add_constrain(MustIncludeAnyTag(tags))
if kwargs:
self.add_constrain(
MustIncludeAnyTag(Tags[C, V].from_list(list(kwargs.values())))
)
return self
def must_include_tag(self, tag: Tuple[C, V]) -> "VariantTask[C, V]":
return self.must_include_any_tag(tag)
@overload
def must_include_all_tags(self, tags: Tags[C, V]) -> "VariantTask[C, V]": ...
@overload
def must_include_all_tags(self, tags: Tuple[C, V], **kwargs: Tuple[C, V]) -> "VariantTask[C, V]": ...
def must_include_all_tags(
self, tags: Union[Tags[C, V], Tuple[C, V]], **kwargs: Tuple[C, V]
) -> "VariantTask[C, V]":
if isinstance(tags, List):
self.add_constrain(MustIncludeAllTags(Tags[C, V].from_list(tags)))
elif isinstance(tags, Tags):
self.add_constrain(MustIncludeAllTags(tags))
if kwargs:
self.add_constrain(
MustIncludeAllTags(Tags[C, V].from_list(list(kwargs.values())))
)
return self
vt = VariantTask()
vt.must_be_any()
class OrderedVariantGenerator(Generic[C, V]):
tasks: List[VariantTask[C, V]]
def __init__(
self, number_of_tasks: int, task_generator_pool: TaskGeneratorPool[C, V]
):
self.tasks = [VariantTask[C, V]() for _ in range(number_of_tasks)]
self.task_generator_pool = task_generator_pool
class Quizard(Generic[C, V]):
taskPool = TaskPool[C, V]()
def __init__(self, taskGenerators: List[TaskGenerator[C, V]], default_amount: int):
self.taskGenerators = taskGenerators
self.default_amount = default_amount
def fillTaskPool(self):
for taskGenerator in self.taskGenerators:
n = (
self.default_amount
if taskGenerator.default_amount.is_none
else taskGenerator.default_amount.value
)
for _ in range(n):
task = taskGenerator.generate()
task.generator_metadata = taskGenerator.metadata
self.taskPool.append(task)
def shuffle_tasks_to_variants(
self, tasks: TaskPool[C, V], number_of_variants: int, seed: int = 0
) -> Result[VariantSet, str]:
if len(tasks) == 0:
return Err("There must be at least one task in the pool")
return Err("Not implemented")