101 lines
2.9 KiB
Python
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
|