PMS Sync
Deploy 000b308 (flattened for HF Spaces)
4577459
Raw
History Blame Contribute Delete
10.8 kB
"""Autonomous study agent — plans and executes sessions with minimal user input."""
from __future__ import annotations
import json
import logging
import time
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any
from plane_mode_scholar.core import llm
from plane_mode_scholar.core.config import DEFAULT_USER_ID
from plane_mode_scholar.core.orchestration import Orchestrator
from plane_mode_scholar.gradio_ui.citations import linkify_citations
from plane_mode_scholar.memory.scheduler import get_due_reviews, pick_study_focus
from plane_mode_scholar.study.planner import SessionPlanner
from plane_mode_scholar.study.quiz import QuizGenerator
from plane_mode_scholar.storage.sqlite_store import SQLiteStore
logger = logging.getLogger(__name__)
AGENT_TOOLS = [
{"name": "ensure_pack", "description": "Load or create demo study materials"},
{"name": "start_session", "description": "Open a timed study session with memory recall"},
{"name": "explain_topic", "description": "Grounded explanation with [1] citations"},
{"name": "run_quiz", "description": "Generate and present a quiz question"},
{"name": "surface_memory", "description": "Show what the coach remembers about the learner"},
{"name": "plan_next", "description": "Decide the next study action from mastery + SRS"},
]
@dataclass
class AgentEvent:
phase: str
message: str
tool: str | None = None
data: dict[str, Any] = field(default_factory=dict)
cycle_id: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"phase": self.phase,
"message": self.message,
"tool": self.tool,
"data": self.data,
"cycle_id": self.cycle_id,
"timestamp": time.time(),
}
class StudyAgent:
"""Monitor → plan → act loop for autonomous study coaching."""
def __init__(
self,
store: SQLiteStore | None = None,
orchestrator: Orchestrator | None = None,
) -> None:
self.store = store or SQLiteStore()
self.orchestrator = orchestrator or Orchestrator(self.store)
self.planner = SessionPlanner(self.store)
self.quiz_gen = QuizGenerator(self.store)
self._cycle = 0
def _next_cycle(self) -> int:
self._cycle += 1
return self._cycle
def _emit(self, phase: str, message: str, tool: str | None = None, **data: Any) -> dict:
return AgentEvent(
phase=phase,
message=message,
tool=tool,
data=data,
cycle_id=self._next_cycle(),
).to_dict()
def plan_next_action(self, pack_id: str, user_id: str, session_id: str | None) -> dict:
due = get_due_reviews(self.store, pack_id, user_id)
weak = self.orchestrator.mastery.get_weak_topics(pack_id)
focus = pick_study_focus(self.store, pack_id, user_id, weak)
turns = self.store.list_turns(session_id) if session_id else []
if not session_id:
return {"action": "start_session", "topic": focus, "reason": "No active session"}
if due:
return {
"action": "explain_topic",
"topic": due[0].content[:80],
"reason": f"{len(due)} review(s) due from last session",
}
if len(turns) < 2:
return {
"action": "explain_topic",
"topic": focus,
"reason": "Opening explanation for weak focus area",
}
if len(turns) < 6:
return {
"action": "run_quiz",
"topic": focus,
"reason": "Check understanding before moving on",
}
return {
"action": "surface_memory",
"topic": focus,
"reason": "Session depth reached — show memory + suggest wrap-up",
}
def ensure_pack(self, user_id: str, pack_id: str | None = None) -> str:
if pack_id:
pack = self.store.get_pack(pack_id)
if pack:
return pack_id
packs = self.store.list_packs(user_id=user_id)
if packs:
return packs[0].pack_id
from plane_mode_scholar.gradio_ui.layout import load_demo_pack
load_demo_pack(user_id)
packs = self.store.list_packs(user_id=user_id)
if not packs:
raise RuntimeError("Could not create demo study pack")
return packs[0].pack_id
def start_session(
self, pack_id: str, user_id: str, goals: list[str] | None = None
) -> dict[str, Any]:
result = self.planner.start_session(
pack_id, goals=goals or ["Review key exam topics"], duration_minutes=45, user_id=user_id
)
return result
def run_autopilot(
self,
user_id: str = DEFAULT_USER_ID,
pack_id: str | None = None,
max_steps: int = 5,
) -> Iterator[dict[str, Any]]:
"""One-click autonomous study flow — yields telemetry for SwarmGrid-style UI."""
yield self._emit("BOOT", "Plane Mode Scholar agent online", tool="ensure_pack")
try:
pack_id = self.ensure_pack(user_id, pack_id)
pack = self.store.get_pack(pack_id)
pack_name = pack.name if pack else pack_id
yield self._emit(
"PACK",
f"Study pack ready: {pack_name}",
tool="ensure_pack",
pack_id=pack_id,
)
session_result = self.start_session(pack_id, user_id)
session_id = session_result["session"]["session_id"]
due_count = session_result.get("due_count", 0)
recall = session_result.get("retrieved_memories", [])
yield self._emit(
"SESSION",
f"Session started — {due_count} due review(s), {len(recall)} memories loaded",
tool="start_session",
session_id=session_id,
due_count=due_count,
recall_preview=[m.get("content", "")[:80] for m in recall[:3]],
recommended=session_result.get("recommended_start", ""),
)
steps_done = 0
while steps_done < max_steps:
plan = self.plan_next_action(pack_id, user_id, session_id)
action = plan["action"]
topic = plan.get("topic", "")
yield self._emit(
"PLAN",
f"Next: {action}{plan.get('reason', '')}",
tool="plan_next",
plan=plan,
)
if action == "explain_topic":
query = f"Explain {topic} clearly using my course materials"
prepared = self.orchestrator.prepare_turn(
query, pack_id, session_id, topic, user_id=user_id
)
yield self._emit(
"RETRIEVE",
f"Retrieved {len(prepared.chunks)} chunks, {len(prepared.memories)} memories",
tool="explain_topic",
chunks=len(prepared.chunks),
memories=len(prepared.memories),
)
accumulated = ""
t0 = time.time()
for partial in llm.generate_stream(prepared.prompt):
accumulated = partial
yield self._emit(
"STREAM",
accumulated,
tool="explain_topic",
delta=partial,
topic=topic,
)
result = self.orchestrator.finalize_turn(prepared, accumulated)
cited = linkify_citations(accumulated, prepared.chunks)
yield self._emit(
"EXPLAIN",
cited,
tool="explain_topic",
response=cited,
memory_writes=len(result.memory_writes),
inference_ms=int((time.time() - t0) * 1000),
)
elif action == "run_quiz":
questions = self.quiz_gen.generate_quiz(
pack_id,
num_questions=1,
topic=topic,
llm_generate=llm.generate,
user_id=user_id,
)
if questions:
q = questions[0]
yield self._emit(
"QUIZ",
q.get("question", "Quiz ready"),
tool="run_quiz",
question=q,
topic=topic,
)
else:
yield self._emit("QUIZ", "Could not generate quiz", tool="run_quiz")
elif action == "surface_memory":
active = self.store.list_memories(
pack_id=pack_id, user_id=user_id, status="active"
)
yield self._emit(
"MEMORY",
f"Coach remembers {len(active)} active memories",
tool="surface_memory",
memories=[m.to_dict() for m in active[:6]],
)
steps_done += 1
yield self._emit(
"DONE",
"Autopilot segment complete — continue chatting or tap Fly again",
tool=None,
session_id=session_id,
pack_id=pack_id,
)
except Exception as e:
logger.exception("Autopilot failed")
yield self._emit("ERROR", str(e), tool=None)
def agent_plan_json(self, pack_id: str, user_id: str, context: str) -> dict:
"""Optional LLM planner for Nemotron tool-calling style decisions."""
prompt = f"""You are a study coach agent. Pick the single best next action.
Tools: {json.dumps([t["name"] for t in AGENT_TOOLS])}
Context:
{context}
Pack has materials indexed. Reply JSON only: {{"action": "<tool>", "topic": "<topic>", "reason": "<short>"}}"""
raw = llm.generate(prompt, max_new_tokens=120)
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(raw[start:end])
except json.JSONDecodeError:
pass
return self.plan_next_action(pack_id, user_id, None)