Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import os | |
| import time | |
| from pathlib import Path | |
| from typing import TYPE_CHECKING, Any, Literal | |
| from app.schemas.commit_adaptation import CommitAdaptationReview | |
| if TYPE_CHECKING: | |
| from app.agents.cerebras_client import CerebrasClient | |
| _DEFAULT_ROOT = os.path.expanduser("~/.studybuddy/agent_control") | |
| _COMMIT_REVIEWER_SKILL_PATH = ( | |
| Path(__file__).resolve().parents[1] / "memory_skills" / "research-memory-reviewer" / "SKILL.md" | |
| ) | |
| ProfileScope = Literal["student", "project"] | |
| ProfileSource = Literal["manual", "inferred"] | |
| BASE_CONTRACT = ( | |
| "ResearchMate is local-first and grounded in the student's uploaded project material. " | |
| "It may adapt style and workflow, but it must not invent source facts, overwrite manual " | |
| "preferences, or hide memory failures from the project status surfaces." | |
| ) | |
| DEFAULT_AGENT_SKILLS = { | |
| "brain": "Organize project material into a research map shaped by project memory.", | |
| "tutor": "Explain from grounded evidence, adapting order and rigor to the student profile.", | |
| "pair_buddy": "Help the student think aloud, ask grounded follow-ups, and preserve developing ideas.", | |
| "recommender": "Recommend papers from uploaded project papers, verified citation context, and student interests.", | |
| "visualization": ( | |
| "Research missing evidence, expose assumptions and provenance, and never present illustrative values " | |
| "as observed data or trained-model weights." | |
| ), | |
| } | |
| def _profile_record() -> dict[str, Any]: | |
| return {"manual_text": "", "inferred_text": "", "updated_at": 0.0} | |
| class AgentControlService: | |
| def __init__(self, root: str | None = None, client: "CerebrasClient | None" = None) -> None: | |
| self.root = root or _DEFAULT_ROOT | |
| os.makedirs(self.root, exist_ok=True) | |
| self._client = client | |
| def _path(self, project_id: str) -> str: | |
| safe = (project_id or "global").replace("/", "_").replace("\\", "_").replace("..", "_") | |
| return os.path.join(self.root, f"{safe}.json") | |
| def _default_state(self, project_id: str) -> dict[str, Any]: | |
| return { | |
| "project_id": project_id, | |
| "student": _profile_record(), | |
| "project": _profile_record(), | |
| "skills": {agent_id: _profile_record() | {"default_text": text} for agent_id, text in DEFAULT_AGENT_SKILLS.items()}, | |
| "evolution": [], | |
| "updated_at": time.time(), | |
| } | |
| def _load(self, project_id: str) -> dict[str, Any]: | |
| path = self._path(project_id) | |
| if not os.path.exists(path): | |
| return self._default_state(project_id) | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| state = self._default_state(project_id) | json.load(f) | |
| state.setdefault("skills", {}) | |
| for agent_id, text in DEFAULT_AGENT_SKILLS.items(): | |
| state["skills"].setdefault(agent_id, _profile_record() | {"default_text": text}) | |
| return state | |
| except Exception: | |
| return self._default_state(project_id) | |
| def _save(self, project_id: str, state: dict[str, Any]) -> dict[str, Any]: | |
| state["updated_at"] = time.time() | |
| os.makedirs(self.root, exist_ok=True) | |
| with open(self._path(project_id), "w", encoding="utf-8") as f: | |
| json.dump(state, f, indent=2) | |
| return state | |
| def get_profile(self, project_id: str) -> dict[str, Any]: | |
| state = self._load(project_id) | |
| return { | |
| "project_id": project_id, | |
| "student": state["student"], | |
| "project": state["project"], | |
| "updated_at": state["updated_at"], | |
| } | |
| def update_profile(self, project_id: str, scope: ProfileScope, text: str, source: ProfileSource = "manual") -> dict[str, Any]: | |
| state = self._load(project_id) | |
| key = "manual_text" if source == "manual" else "inferred_text" | |
| state[scope][key] = text | |
| state[scope]["updated_at"] = time.time() | |
| self._save(project_id, state) | |
| return self.get_profile(project_id) | |
| def get_skills(self, project_id: str) -> dict[str, Any]: | |
| state = self._load(project_id) | |
| return {"project_id": project_id, "skills": state["skills"]} | |
| def update_skill(self, project_id: str, skill_id: str, text: str, source: ProfileSource = "manual") -> dict[str, Any]: | |
| state = self._load(project_id) | |
| state["skills"].setdefault(skill_id, _profile_record() | {"default_text": ""}) | |
| key = "manual_text" if source == "manual" else "inferred_text" | |
| state["skills"][skill_id][key] = text | |
| state["skills"][skill_id]["updated_at"] = time.time() | |
| self._save(project_id, state) | |
| return self.get_skills(project_id) | |
| def record_evolution(self, project_id: str, event_type: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: | |
| state = self._load(project_id) | |
| event = { | |
| "event_type": event_type, | |
| "message": message, | |
| "metadata": metadata or {}, | |
| "created_at": time.time(), | |
| } | |
| state["evolution"].append(event) | |
| self._save(project_id, state) | |
| return event | |
| def get_evolution(self, project_id: str) -> list[dict[str, Any]]: | |
| state = self._load(project_id) | |
| return sorted(state["evolution"], key=lambda row: row.get("created_at", 0), reverse=True) | |
| def _effective(record: dict[str, Any]) -> str: | |
| return record.get("manual_text") or record.get("inferred_text") or record.get("default_text", "") | |
| def preview(self, project_id: str, agent_id: str = "tutor") -> dict[str, Any]: | |
| state = self._load(project_id) | |
| skill = state["skills"].get(agent_id, _profile_record() | {"default_text": DEFAULT_AGENT_SKILLS.get(agent_id, "")}) | |
| layers = [ | |
| {"name": "Base Contract", "content": BASE_CONTRACT, "editable": False}, | |
| {"name": "Student Profile", "content": self._effective(state["student"]), "editable": True}, | |
| {"name": "Project Profile", "content": self._effective(state["project"]), "editable": True}, | |
| {"name": f"{agent_id} Skill", "content": self._effective(skill), "editable": True}, | |
| ] | |
| return { | |
| "project_id": project_id, | |
| "agent_id": agent_id, | |
| "layers": layers, | |
| "composed_prompt": "\n\n".join(layer["content"] for layer in layers if layer["content"]), | |
| } | |
| def reset(self, project_id: str, scope: str = "all") -> dict[str, Any]: | |
| state = self._load(project_id) | |
| if scope in {"all", "profiles"}: | |
| state["student"] = _profile_record() | |
| state["project"] = _profile_record() | |
| if scope in {"all", "skills"}: | |
| for agent_id, text in DEFAULT_AGENT_SKILLS.items(): | |
| state["skills"][agent_id] = _profile_record() | {"default_text": text} | |
| if scope == "all": | |
| state["evolution"] = [] | |
| self._save(project_id, state) | |
| return {"ok": True, "scope": scope} | |
| def compose_for_agent(self, project_id: str, agent_id: str) -> str: | |
| return self.preview(project_id, agent_id=agent_id)["composed_prompt"] | |
| def get_effective_text(self, *, project_id: str, agent_id: str) -> str: | |
| state = self._load(project_id) | |
| skill = state["skills"].get( | |
| agent_id, _profile_record() | {"default_text": DEFAULT_AGENT_SKILLS.get(agent_id, "")}, | |
| ) | |
| return self._effective(skill) | |
| def get_inferred_text(self, project_id: str) -> str: | |
| return self._load(project_id)["project"].get("inferred_text", "") | |
| def get_manual_text(self, project_id: str) -> str: | |
| return self._load(project_id)["project"].get("manual_text", "") | |
| def set_inferred_text(self, project_id: str, text: str) -> dict[str, Any]: | |
| return self.update_profile(project_id, "project", text, source="inferred") | |
| async def review_commit_with_llm( | |
| self, *, project_id: str, interactions_text: str, current_profile: str, | |
| current_persona: str, manual_persona: str, | |
| ) -> CommitAdaptationReview: | |
| del project_id # not yet part of the prompt; kept for a future per-project rubric override | |
| rubric = ( | |
| _COMMIT_REVIEWER_SKILL_PATH.read_text(encoding="utf-8") | |
| if _COMMIT_REVIEWER_SKILL_PATH.exists() else | |
| "Review recent student interactions and propose memory/persona updates." | |
| ) | |
| messages = [ | |
| {"role": "system", "content": rubric}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| f"STUDENT-AUTHORED INTERACTIONS SINCE LAST REVIEW:\n{interactions_text}\n\n" | |
| f"CURRENT DURABLE STUDENT PROFILE (Cognee):\n{current_profile}\n\n" | |
| f"CURRENT INFERRED PERSONA TEXT:\n{current_persona or 'None yet.'}\n\n" | |
| f"MANUAL PERSONA OVERRIDES (must not be contradicted):\n{manual_persona or 'None.'}\n\n" | |
| "Propose memory candidates with exact interaction quotes as evidence, and rewrite the " | |
| "complete inferred persona text (not a diff)." | |
| ), | |
| }, | |
| ] | |
| client = self._client | |
| if client is None: | |
| from app.agents.cerebras_client import CerebrasClient | |
| client = CerebrasClient() | |
| return client.structured_complete(messages, CommitAdaptationReview) | |
| # evolve_from_commit() was retired: it was a non-LLM string template | |
| # (truncate the Evaluator's session_summary into the tutor skill slot) | |
| # superseded by review_commit_with_llm() + run_commit_adaptation(), | |
| # which actually reasons about accepted memory candidates rather than | |
| # blindly excerpting text. See | |
| # docs/commit-memory-adaptation-architecture.md. | |