Didactopus/src/didactopus/planner.py

101 lines
2.9 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from math import inf
from .concept_graph import ConceptGraph
from .semantic_similarity import concept_similarity
@dataclass
class PlannerWeights:
readiness_bonus: float = 2.0
target_distance_weight: float = 1.0
weak_dimension_bonus: float = 1.2
fragile_review_bonus: float = 1.5
project_unlock_bonus: float = 0.8
semantic_similarity_weight: float = 1.0
def _distance_bonus(graph: ConceptGraph, concept: str, targets: list[str]) -> float:
pg = graph.prerequisite_subgraph()
best = inf
for target in targets:
try:
dist = len(__import__("networkx").shortest_path(pg, concept, target)) - 1
best = min(best, dist)
except Exception:
continue
if best is inf:
return 0.0
return 1.0 / (1.0 + best)
def _project_unlock_bonus(concept: str, project_catalog: list[dict]) -> float:
count = 0
for project in project_catalog:
if concept in project.get("prerequisites", []):
count += 1
return float(count)
def _semantic_bonus(graph: ConceptGraph, concept: str, targets: list[str]) -> float:
data_a = graph.graph.nodes[concept]
best = 0.0
for target in targets:
if target not in graph.graph.nodes:
continue
data_b = graph.graph.nodes[target]
best = max(best, concept_similarity(data_a, data_b))
return best
def rank_next_concepts(
graph: ConceptGraph,
mastered: set[str],
targets: list[str],
weak_dimensions_by_concept: dict[str, list[str]],
fragile_concepts: set[str],
project_catalog: list[dict],
weights: PlannerWeights,
) -> list[dict]:
ready = graph.ready_concepts(mastered)
ranked = []
for concept in ready:
score = 0.0
components = {}
readiness = weights.readiness_bonus
score += readiness
components["readiness"] = readiness
distance = weights.target_distance_weight * _distance_bonus(graph, concept, targets)
score += distance
components["target_distance"] = distance
weak = weights.weak_dimension_bonus * len(weak_dimensions_by_concept.get(concept, []))
score += weak
components["weak_dimensions"] = weak
fragile = weights.fragile_review_bonus if concept in fragile_concepts else 0.0
score += fragile
components["fragile_review"] = fragile
project = weights.project_unlock_bonus * _project_unlock_bonus(concept, project_catalog)
score += project
components["project_unlock"] = project
semantic = weights.semantic_similarity_weight * _semantic_bonus(graph, concept, targets)
score += semantic
components["semantic_similarity"] = semantic
ranked.append({
"concept": concept,
"score": score,
"components": components,
})
ranked.sort(key=lambda item: item["score"], reverse=True)
return ranked