Загрузка данных


"""
Учебная программа для объяснения дерева решений на занятии.

Тема примера: подбор аниме на вечер.

Запуск:
    python3 anime_decision_tree_demo.py

Консольный режим:
    python3 anime_decision_tree_demo.py --console
"""

from dataclasses import dataclass
import sys
import textwrap


@dataclass
class DecisionNode:
    """Один узел дерева решений."""
    node_id: str
    title: str
    explanation: str
    answers: list[tuple[str, str]]
    result: str = ""

    @property
    def is_leaf(self) -> bool:
        return bool(self.result)


def build_tree() -> dict[str, DecisionNode]:
    """База знаний в виде дерева. Знания отделены от алгоритма обхода."""
    return {
        # ===== КОРЕНЬ =====
        "root": DecisionNode(
            node_id="root",
            title="Какое у вас настроение?",
            explanation=(
                "Первый вопрос должен быть самым общим. Настроение определяет "
                "тон и жанр аниме, поэтому оно хорошо подходит для корня дерева."
            ),
            answers=[("Весёлое", "fun_time"), ("Серьёзное", "serious_genre")],
        ),
        # ===== ВЕТКА «ВЕСЁЛОЕ» =====
        "fun_time": DecisionNode(
            node_id="fun_time",
            title="Сколько у вас времени?",
            explanation=(
                "После весёлого настроения уточняем время. Один и тот же жанр "
                "может быть коротким (пара серий) или длинным (сериал)."
            ),
            answers=[("До 1 часа", "fun_short"), ("Больше 1 часа", "fun_long")],
        ),
        "fun_short": DecisionNode(
            node_id="fun_short",
            title="С кем смотрите?",
            explanation=(
                "Компания меняет рекомендацию: для группы лучше что-то понятное "
                "и заводное, для одного — более личное."
            ),
            answers=[("Один", "fun_short_alone"), ("С компанией", "fun_short_company")],
        ),
        "fun_long": DecisionNode(
            node_id="fun_long",
            title="С кем смотрите?",
            explanation=(
                "Если времени много, компания снова важна: сериалы по-разному "
                "смотрятся в одиночку и вместе."
            ),
            answers=[("Один", "fun_long_alone"), ("С компанией", "fun_long_company")],
        ),
        "fun_short_alone": DecisionNode(
            node_id="fun_short_alone",
            title="Что важнее?",
            explanation="Последний уточняющий вопрос: посмеяться или отправиться в приключение.",
            answers=[
                ("Комедия и лёгкость", "l1_one_punch"),
                ("Приключения", "l2_seven_deadly"),
            ],
        ),
        "fun_short_company": DecisionNode(
            node_id="fun_short_company",
            title="Что важнее?",
            explanation="Последний уточняющий вопрос: подобрать комедию или что-то с приключением.",
            answers=[
                ("Комедия и лёгкость", "l3_konosuba"),
                ("Приключения", "l4_gintama_short"),
            ],
        ),
        "fun_long_alone": DecisionNode(
            node_id="fun_long_alone",
            title="Что важнее?",
            explanation="Уточняем предпочтение: сериал-комедия или длинное приключение.",
            answers=[
                ("Комедия и лёгкость", "l5_one_piece"),
                ("Приключения", "l6_hunter"),
            ],
        ),
        "fun_long_company": DecisionNode(
            node_id="fun_long_company",
            title="Что важнее?",
            explanation="Для длинного просмотра компанией важно понять, что зайдёт: комедия или приключения.",
            answers=[
                ("Комедия и лёгкость", "l7_gintama_series"),
                ("Приключения", "l8_fairy_tail"),
            ],
        ),
        # ===== ВЕТКА «СЕРЬЁЗНОЕ» =====
        "serious_genre": DecisionNode(
            node_id="serious_genre",
            title="Какой жанр ближе?",
            explanation=(
                "Серьёзное настроение не даёт точного жанра. Драма и психология "
                "требуют разного темпа и атмосферы."
            ),
            answers=[("Драма", "serious_drama"), ("Психология", "serious_psychology")],
        ),
        "serious_drama": DecisionNode(
            node_id="serious_drama",
            title="Сколько у вас времени?",
            explanation=(
                "Драма может быть короткой (полнометражка) или длинной (сериал). "
                "Время здесь — ключевой признак."
            ),
            answers=[
                ("До 1 часа", "serious_drama_short"),
                ("Больше 1 часа", "serious_drama_long"),
            ],
        ),
        "serious_psychology": DecisionNode(
            node_id="serious_psychology",
            title="Сколько у вас времени?",
            explanation=(
                "Психологические истории тоже бывают разной длины. Это влияет "
                "на глубину погружения."
            ),
            answers=[
                ("До 1 часа", "serious_psychology_short"),
                ("Больше 1 часа", "serious_psychology_long"),
            ],
        ),
        "serious_drama_short": DecisionNode(
            node_id="serious_drama_short",
            title="С кем смотрите?",
            explanation="Короткая драма по-разному воспринимается одной и в компании. Уточняем.",
            answers=[
                ("Один", "l9_shape_of_voice"),
                ("С компанией", "l10_your_lie_short"),
            ],
        ),
        "serious_drama_long": DecisionNode(
            node_id="serious_drama_long",
            title="С кем смотрите?",
            explanation="Длинная драма — эмоциональный марафон. Компания меняет ощущение.",
            answers=[
                ("Один", "l11_your_lie_series"),
                ("С компанией", "l12_clannad"),
            ],
        ),
        "serious_psychology_short": DecisionNode(
            node_id="serious_psychology_short",
            title="С кем смотрите?",
            explanation="Психология короткого формата — либо наедине, либо в обсуждении с кем-то.",
            answers=[
                ("Один", "l13_perfect_blue"),
                ("С компанией", "l14_monster_short"),
            ],
        ),
        "serious_psychology_long": DecisionNode(
            node_id="serious_psychology_long",
            title="С кем смотрите?",
            explanation="Долгое психологическое погружение. Один или с кем-то — принципиально разные вечера.",
            answers=[
                ("Один", "l15_monster_series"),
                ("С компанией", "l16_evangelion"),
            ],
        ),
        # ===== ЛИСТЬЯ =====
        "l1_one_punch": DecisionNode("l1_one_punch", "Лист", "Конечный узел.",
            [], "«Ванпанчмен» — 1–2 серии. Лёгкий юмор и сатира на супергероев."),
        "l2_seven_deadly": DecisionNode("l2_seven_deadly", "Лист", "Конечный узел.",
            [], "«Семь смертных грехов» — 1–2 серии. Лёгкие приключения."),
        "l3_konosuba": DecisionNode("l3_konosuba", "Лист", "Конечный узел.",
            [], "«Коносуба» — 1–2 серии. Пародийный исэкай, хорошо заходит компанией."),
        "l4_gintama_short": DecisionNode("l4_gintama_short", "Лист", "Конечный узел.",
            [], "«Гинтама» — 1–2 серии. Приключения с фирменным юмором."),
        "l5_one_piece": DecisionNode("l5_one_piece", "Лист", "Конечный узел.",
            [], "«Ван-Пис» — сериал. Много серий, надолго."),
        "l6_hunter": DecisionNode("l6_hunter", "Лист", "Конечный узел.",
            [], "«Хантер х Хантер» — сериал. Классические приключения."),
        "l7_gintama_series": DecisionNode("l7_gintama_series", "Лист", "Конечный узел.",
            [], "«Гинтама» — сериал. Комедия, идеально на компанию."),
        "l8_fairy_tail": DecisionNode("l8_fairy_tail", "Лист", "Конечный узел.",
            [], "«Fairy Tail» — сериал. Дружба, магия, приключения."),
        "l9_shape_of_voice": DecisionNode("l9_shape_of_voice", "Лист", "Конечный узел.",
            [], "«Форма голоса» — полнометражка. Трогательная драма о принятии себя."),
        "l10_your_lie_short": DecisionNode("l10_your_lie_short", "Лист", "Конечный узел.",
            [], "«Твоя апрельская ложь» — 1–2 серии. Драма с музыкой."),
        "l11_your_lie_series": DecisionNode("l11_your_lie_series", "Лист", "Конечный узел.",
            [], "«Твоя апрельская ложь» — сериал. Полная история, сильные эмоции."),
        "l12_clannad": DecisionNode("l12_clannad", "Лист", "Конечный узел.",
            [], "«Кланнад» — сериал. Семейная драма, много эмоций."),
        "l13_perfect_blue": DecisionNode("l13_perfect_blue", "Лист", "Конечный узел.",
            [], "«Идеальная грусть» — 1–2 серии. Психологическая драма."),
        "l14_monster_short": DecisionNode("l14_monster_short", "Лист", "Конечный узел.",
            [], "«Монстр» — 1–2 серии. Психологический триллер."),
        "l15_monster_series": DecisionNode("l15_monster_series", "Лист", "Конечный узел.",
            [], "«Монстр» — сериал. Глубокое психологическое погружение."),
        "l16_evangelion": DecisionNode("l16_evangelion", "Лист", "Конечный узел.",
            [], "«Евангелион» — сериал. Психология и философия."),
    }


# Координаты узлов для GUI-режима.
NODE_POSITIONS = {
    "root": (800, 60),
    "fun_time": (400, 180), "serious_genre": (1200, 180),
    "fun_short": (200, 310), "fun_long": (600, 310),
    "serious_drama": (1000, 310), "serious_psychology": (1400, 310),
    "fun_short_alone": (100, 450), "fun_short_company": (300, 450),
    "fun_long_alone": (500, 450), "fun_long_company": (700, 450),
    "serious_drama_short": (900, 450), "serious_drama_long": (1100, 450),
    "serious_psychology_short": (1300, 450), "serious_psychology_long": (1500, 450),
    "l1_one_punch": (50, 600), "l2_seven_deadly": (150, 600),
    "l3_konosuba": (250, 600), "l4_gintama_short": (350, 600),
    "l5_one_piece": (450, 600), "l6_hunter": (550, 600),
    "l7_gintama_series": (650, 600), "l8_fairy_tail": (750, 600),
    "l9_shape_of_voice": (850, 600), "l10_your_lie_short": (950, 600),
    "l11_your_lie_series": (1050, 600), "l12_clannad": (1150, 600),
    "l13_perfect_blue": (1250, 600), "l14_monster_short": (1350, 600),
    "l15_monster_series": (1450, 600), "l16_evangelion": (1550, 600),
}


def run_console_mode() -> None:
    """Текстовый режим для онлайн-компилятора или демонстрации без GUI."""
    nodes = build_tree()
    current_id = "root"
    path = []

    print("Дерево решений: подбор аниме на вечер")
    print("Отвечайте цифрой. Чтобы выйти, введите q.\n")

    while True:
        node = nodes[current_id]

        if node.is_leaf:
            print("Итоговая рекомендация:")
            print(textwrap.fill(node.result, width=80))
            print("\nПуть по дереву:")
            for step in path:
                print(" -", step)
            return

        print("Вопрос:", node.title)
        print("Пояснение:", textwrap.fill(node.explanation, width=80))
        for index, (answer, _nid) in enumerate(node.answers, start=1):
            print(f"{index}. {answer}")

        raw = input("Ваш выбор: ").strip().lower()
        if raw == "q":
            print("Выход.")
            return

        try:
            answer_index = int(raw) - 1
            answer, next_id = node.answers[answer_index]
        except (ValueError, IndexError):
            print("Такого варианта нет. Попробуйте ещё раз.\n")
            continue

        path.append(f"{node.title} → {answer}")
        current_id = next_id
        print()


def run_gui_mode() -> None:
    """Графический режим с визуальным деревом и подсветкой пути."""
    import tkinter as tk
    from tkinter import ttk
    from tkinter.scrolledtext import ScrolledText

    nodes = build_tree()

    class DecisionTreeApp:
        def __init__(self, root: tk.Tk):
            self.root = root
            self.root.title("Дерево решений: подбор аниме на вечер")
            self.root.geometry("1620x900")

            self.current_id = "root"
            self.path_edges: list[tuple[str, str]] = []
            self.path_text: list[str] = []
            self.show_full_tree = tk.BooleanVar(value=False)

            self.canvas = tk.Canvas(root, width=1600, height=700, bg="#f7fbff")
            self.canvas.pack(fill=tk.BOTH, expand=True, padx=10, pady=(10, 5))

            bottom = ttk.Frame(root)
            bottom.pack(fill=tk.X, padx=10, pady=(0, 10))

            self.info = ScrolledText(bottom, height=10, wrap=tk.WORD, font=("Arial", 11))
            self.info.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))

            controls = ttk.Frame(bottom)
            controls.pack(side=tk.RIGHT, fill=tk.Y)

            self.answer_frame = ttk.LabelFrame(controls, text="Варианты ответа")
            self.answer_frame.pack(fill=tk.X, pady=(0, 8))

            ttk.Button(controls, text="Начать заново", command=self.reset).pack(fill=tk.X, pady=2)
            ttk.Checkbutton(
                controls, text="Показать все дерево",
                variable=self.show_full_tree, command=self.draw_tree,
            ).pack(fill=tk.X, pady=2)
            ttk.Button(
                controls, text="План объяснения у доски",
                command=self.show_board_plan,
            ).pack(fill=tk.X, pady=2)

            self.draw_tree()
            self.update_panel()

        def reset(self) -> None:
            self.current_id = "root"
            self.path_edges.clear()
            self.path_text.clear()
            self.draw_tree()
            self.update_panel()

        def choose_answer(self, answer: str, next_id: str) -> None:
            self.path_edges.append((self.current_id, next_id))
            self.path_text.append(f"{nodes[self.current_id].title} → {answer}")
            self.current_id = next_id
            self.draw_tree()
            self.update_panel()

        def draw_tree(self) -> None:
            self.canvas.delete("all")
            self.canvas.create_text(
                800, 22,
                text="Визуальное построение дерева решений: подбор аниме",
                font=("Arial", 18, "bold"), fill="#16324f",
            )

            visible_nodes = self.get_visible_nodes()

            for node_id, node in nodes.items():
                if node_id not in visible_nodes:
                    continue
                x1, y1 = NODE_POSITIONS[node_id]
                for answer, next_id in node.answers:
                    if next_id not in visible_nodes:
                        continue
                    x2, y2 = NODE_POSITIONS[next_id]
                    active = (node_id, next_id) in self.path_edges
                    preview = node_id == self.current_id and not active
                    color = "#1f7a4d" if active else "#b8c4d2" if preview else "#7a8ca3"
                    width = 4 if active else 2
                    self.canvas.create_line(
                        x1, y1 + 34, x2, y2 - 34,
                        arrow=tk.LAST, fill=color, width=width, smooth=True,
                    )
                    self.canvas.create_text(
                        (x1 + x2) / 2, (y1 + y2) / 2 - 8,
                        text=answer, font=("Arial", 9),
                        fill="#26364a", width=110,
                    )

            for node_id, node in nodes.items():
                if node_id not in visible_nodes:
                    continue
                self.draw_node(node_id, node)

        def get_visible_nodes(self) -> set:
            if self.show_full_tree.get():
                return set(nodes)
            visible = {"root", self.current_id}
            for from_id, to_id in self.path_edges:
                visible.add(from_id)
                visible.add(to_id)
            for _a, next_id in nodes[self.current_id].answers:
                visible.add(next_id)
            return visible

        def draw_node(self, node_id: str, node: DecisionNode) -> None:
            x, y = NODE_POSITIONS[node_id]
            width = 85 if node.is_leaf else 170
            height = 58 if node.is_leaf else 68
            x1, y1 = x - width / 2, y - height / 2
            x2, y2 = x + width / 2, y + height / 2

            if node_id == self.current_id:
                fill, outline, lw = "#ffefb0", "#c88719", 4
            elif node_id in {e[1] for e in self.path_edges} or node_id == "root":
                fill = "#dff4e8" if node.is_leaf else "#dbeafe"
                outline, lw = "#1f7a4d", 3
            else:
                fill = "#eef7ee" if node.is_leaf else "#e8f1fb"
                outline, lw = "#51769c", 2

            self.canvas.create_rectangle(x1, y1, x2, y2, fill=fill, outline=outline, width=lw)
            text = node.result.split(".")[0] if node.is_leaf else node.title
            self.canvas.create_text(
                x, y, text=text, width=width - 14,
                justify=tk.CENTER,
                font=("Arial", 9, "bold" if not node.is_leaf else "normal"),
                fill="#102033",
            )

        def update_panel(self) -> None:
            for child in self.answer_frame.winfo_children():
                child.destroy()

            node = nodes[self.current_id]
            self.info.delete("1.0", tk.END)

            if node.is_leaf:
                text = (
                    "Мы пришли в лист дерева.\n\n" + node.result + "\n\n"
                    "Как объяснить у доски:\n"
                    "Покажите путь от верхнего вопроса до зелёного листа. "
                    "Каждый переход — это ответ пользователя. Поэтому итог можно "
                    "объяснить конкретной цепочкой условий.\n\n"
                    "Путь:\n"
                    + "\n".join(f"{i}. {s}" for i, s in enumerate(self.path_text, 1))
                )
                ttk.Label(self.answer_frame, text="Результат получен").pack(padx=8, pady=8)
            else:
                text = (
                    "Текущий узел: " + node.title + "\n\n"
                    + node.explanation + "\n\n"
                    "Что сказать студентам:\n"
                    "Сейчас программа проверяет значение признака. "
                    "Ответ пользователя выбирает ветвь, а ветвь ведёт к следующему "
                    "вопросу или к результату.\n\n"
                    "Пройденный путь:\n"
                    + ("\n".join(f"{i}. {s}" for i, s in enumerate(self.path_text, 1))
                       if self.path_text else "пока не выбран ни один ответ")
                )
                for answer, next_id in node.answers:
                    ttk.Button(
                        self.answer_frame, text=answer,
                        command=lambda a=answer, n=next_id: self.choose_answer(a, n),
                    ).pack(fill=tk.X, padx=8, pady=4)

            self.info.insert(tk.END, text)

        def show_board_plan(self) -> None:
            self.info.delete("1.0", tk.END)
            self.info.insert(
                tk.END,
                "План объяснения у доски\n\n"
                "1. Нарисуйте верхний узел и подпишите его как первый вопрос.\n"
                "2. Объясните, что этот вопрос проверяет самый общий признак — настроение.\n"
                "3. От каждого варианта ответа проведите отдельную ветвь.\n"
                "4. На следующем уровне задайте уточняющие вопросы (время, жанр, компания).\n"
                "5. Внизу покажите листья — готовые рекомендации аниме.\n"
                "6. Пройдите один пример от корня до листа.\n"
                "7. Скажите, что объяснимость дерева состоит в видимом пути решения.\n\n"
                "Фраза для завершения:\n"
                "Дерево решений превращает сложный выбор в цепочку маленьких понятных "
                "проверок. Если путь от первого вопроса до результата можно объяснить, "
                "значит модель построена логично."
            )

    root = tk.Tk()
    DecisionTreeApp(root)
    root.mainloop()


if __name__ == "__main__":
    if "--console" in sys.argv:
        run_console_mode()
    else:
        run_gui_mode()