study-buddy / app /agents /brain_agent.py
GitHub Actions
deploy d092bea3608b7a29952f16357fda39b7a29e399b
2e818da
Raw
History Blame Contribute Delete
36.3 kB
"""Brain Agent -> derives the curriculum tree from student-uploaded chunks.
IMPORTANT: extract_curriculum reads RAG content to find topics.
It NEVER invents topics from model weights.
"""
import os
from typing import Any, Dict, List, Literal, Optional, Tuple
from pydantic import BaseModel, Field
# Structural memory tag -> what the student's prior-session Cognee memory did to
# this node. Populated by the model when memory context is provided, so the
# curriculum's SHAPE (not just an agent's prompt) responds to what the student
# already knows. See derive_root_and_sections / expand_section.
_MEMORY_TAG_DESC = (
"How the student's prior knowledge affects this: 'new' (they haven't engaged "
"it before), 'review' (prior sessions show they largely have this -> keep it "
"light or omit), or 'scaffold' (added/kept to shore up a gap or weakness their "
"history reveals). Use 'new' when no prior-knowledge context is given."
)
from app.agents.cerebras_client import CerebrasClient
from app.schemas.graph import NodeData
_GENERIC_LABELS = {"", "untitled", "document", "study session", "topic", "subject"}
def _is_generic_label(label: str) -> bool:
return not label or label.strip().lower() in _GENERIC_LABELS
def _fallback_topic_name(root_label: str, doc_names: Optional[List[str]] = None, smart_fallback: str = "") -> str:
"""Guarantee a real topic name even if the LLM didn't derive one from the documents.
The prompt asks the model to derive a subject name from the documents when no
topic_hint is given, but nothing enforces it -> this guardrail catches an empty
or generic root label. `smart_fallback` (a second, content-grounded LLM attempt,
e.g. via generate_session_title) is preferred when the caller supplies one, since
filenames (especially arXiv-style ids like "1706.03762v7") carry no real subject
information -> filename munging is the last resort, not the first.
"""
if not _is_generic_label(root_label):
return root_label.strip()
if not _is_generic_label(smart_fallback):
return smart_fallback.strip()
doc_names = doc_names or []
stems = [os.path.splitext(n)[0].replace("_", " ").replace("-", " ").strip() for n in doc_names[:2]]
joined = " & ".join(s.title() for s in stems if s)
return joined or "Study Session"
FAMILIARITY_NOTES: Dict[str, str] = {
"eli5": "Use purely sensory analogies, zero math, max 2-syllable words where possible.",
"high_school": "Introduce standard terminology but immediately define it. Use real-world examples.",
"graduate": "Assume baseline domain competence. Focus on edge cases and methodology.",
"expert": "Pure synthesis. Focus on literature gaps, proofs, and algorithmic details. Zero analogies.",
}
# Familiarity-specific tree shape -> controls how many nodes and how deep the tree goes
FAMILIARITY_TREE_SHAPE: Dict[str, Dict[str, Any]] = {
"eli5": {
"node_range": "4-8",
"max_depth": 2,
"guidance": (
"Create a SMALL, SIMPLE tree with only 4-8 nodes. "
"Use broad, everyday labels a child would understand. "
"Max depth 2 (root + one level of subtopics). "
"Keep labels under 4 words. No jargon."
),
},
"high_school": {
"node_range": "8-15",
"max_depth": 3,
"guidance": (
"Create a standard textbook-style tree with 8-15 nodes. "
"Use clear chapter/section/topic hierarchy. "
"Max depth 3 (root → sections → topics). "
"Labels should be concise standard terms."
),
},
"graduate": {
"node_range": "12-20",
"max_depth": 3,
"guidance": (
"Create a detailed tree with 12-20 nodes. "
"Include methodology-specific subtopics, key theorems, and techniques. "
"Max depth 3. Group by conceptual framework, not just textbook order."
),
},
"expert": {
"node_range": "15-25",
"max_depth": 4,
"guidance": (
"Create a fine-grained tree with 15-25 nodes. "
"Include edge cases, proof techniques, open problems, and advanced subtopics. "
"Max depth 4 (root → areas → topics → specifics). "
"Use precise technical terminology."
),
},
}
class _SyllabusNode(BaseModel):
id: str
label: str
description: str
depth: int = Field(1, ge=0, le=4)
complexity: int = Field(3, ge=1, le=5, description="Conceptual density: 1=simple definition, 3=moderate, 5=very complex/math-heavy")
parent_id: Optional[str] = None
merged_from_ids: List[str] = Field(
default_factory=list,
description="If this node merges 2+ input nodes that covered the same concept across "
"different source documents, list all their original ids here. Empty if unchanged.",
)
merge_summary: str = Field(
"", description="If merged_from_ids is non-empty: 1-2 sentences on what's shared across "
"the source documents' treatments of this concept, and what (if anything) differs. "
"Empty otherwise.",
)
class _SyllabusEdge(BaseModel):
source: str = Field(description="Source node id")
target: str = Field(description="Target node id")
relationship: str = Field(description="prerequisite | related | builds-on")
class _SyllabusBlueprint(BaseModel):
nodes: List[_SyllabusNode] = Field(
..., description="Ordered list of concept nodes, root first then macro to micro"
)
edges: List[_SyllabusEdge] = Field(
default_factory=list,
description="Explicit connections between nodes capturing how concepts relate",
)
class _SectionItem(BaseModel):
label: str = Field(description="≤6 word section title")
description: str = Field("", description="≤80 char summary")
complexity: int = Field(3, ge=1, le=5)
source_docs: List[int] = Field(
default_factory=lambda: [0],
description="0-based indices of ALL documents this section draws from. List more than "
"one if 2+ source documents cover substantially the same ground for this "
"section (e.g. both papers have a background/related-work section on the "
"same subfield, or both introduce the same foundational concept).",
)
memory_tag: Literal["new", "review", "scaffold"] = Field("new", description=_MEMORY_TAG_DESC)
class _RootAndSections(BaseModel):
root_label: str = Field(description="Overall subject/topic name")
root_description: str = Field("", description="≤80 char summary of the whole subject")
sections: List[_SectionItem] = Field(description="Top-level subtopics of the subject")
class _ExpansionChild(BaseModel):
label: str = Field(description="≤6 word concept title")
description: str = Field("", description="≤80 char summary")
complexity: int = Field(3, ge=1, le=5)
relationship: str = Field("prerequisite", description="prerequisite | related | builds-on")
source_docs: List[int] = Field(
default_factory=list,
description="0-based indices (from the section's available documents) of which specific "
"document(s) THIS CONCEPT is grounded in. If this exact concept is treated by "
"more than one of the section's source documents, list all of them -> this "
"marks it as a shared/overlapping concept. A section can span multiple "
"documents while most of its individual concepts are still document-specific.",
)
memory_tag: Literal["new", "review", "scaffold"] = Field("new", description=_MEMORY_TAG_DESC)
class _SectionExpansion(BaseModel):
children: List[_ExpansionChild] = Field(
default_factory=list, description="1-3 specific concepts under this section"
)
class BrainAgent:
def __init__(self, client: Optional[CerebrasClient] = None) -> None:
self._client = client or CerebrasClient()
# ------------------------------------------------------------------ #
# Streaming curriculum -> root-first, then parallel section expansion #
# ------------------------------------------------------------------ #
def derive_root_and_sections(
self,
structure_text: str,
familiarity: str,
topic_hint: str = "",
memory_context: str = "",
doc_names: Optional[List[str]] = None,
) -> _RootAndSections:
"""One fast call: the root subject + its top-level sections (no children yet).
When multiple documents are provided, each section is tagged with the index(es) of
the document(s) it draws from (source_docs), so the graph routes nodes to papers.
A section can list more than one document when they cover overlapping ground ->
that's the first, coarsest signal of cross-paper topic overlap.
"""
tree_shape = FAMILIARITY_TREE_SHAPE.get(familiarity, FAMILIARITY_TREE_SHAPE["high_school"])
familiarity_note = FAMILIARITY_NOTES.get(familiarity, "")
topic_note = f"The student wants to study: {topic_hint}\n" if topic_hint else ""
# Memory is LOAD-BEARING here: prior knowledge must reshape the tree's
# STRUCTURE, not just sit in context. Direct the model to omit/collapse
# mastered ground, scaffold gaps, and order from known -> new, and to tag
# each section (memory_tag) so the restructuring is explicit and visible.
memory_note = ""
if memory_context:
memory_note = (
"\nWHAT THIS STUDENT ALREADY KNOWS (from prior sessions -> use it to RESTRUCTURE, "
"not just to inform tone):\n"
f"{memory_context}\n"
"Restructure accordingly: OMIT or COLLAPSE sections on ground they've clearly "
"mastered (don't spend nodes re-teaching -> mark any light-touch review section "
"memory_tag='review'); INSERT a focused section to shore up any specific gap or "
"weakness their history reveals (memory_tag='scaffold'); ORDER sections to build "
"from what they already know toward what's genuinely new (memory_tag='new'). "
"The resulting tree should look different from what a first-time student would get.\n"
)
multi = doc_names and len(doc_names) > 1
doc_note = ""
if multi:
listing = "\n".join(f" [{i}] {n}" for i, n in enumerate(doc_names))
doc_note = (
f"\nThere are {len(doc_names)} source documents:\n{listing}\n"
"Set each section's source_docs to the 0-based index of every document it draws "
"from -> list 2+ indices if multiple documents cover substantially the same ground "
"for this section.\n"
)
messages = [
{
"role": "system",
"content": (
f"You are a curriculum organiser. {familiarity_note}\n{topic_note}{memory_note}\n"
"Identify the overall subject (root) and its top-level sections ONLY -> "
"do not list sub-concepts yet. "
f"GRANULARITY:\n{tree_shape['guidance']}\n"
f"Use ONLY topics evidenced in the material. Provide 4-8 sections.{doc_note}"
),
},
{
"role": "user",
"content": (
f"Document structure:\n{structure_text[:10000]}\n\n"
f"root_label should be '{topic_hint}' if provided, else the subject name. "
"List the top-level sections (each ≤6 words)."
+ (" Tag each with its source_docs indices." if multi else "")
),
},
]
return self._client.structured_complete(messages, _RootAndSections)
def expand_section(
self,
section_label: str,
doc_excerpts: List[Dict[str, Any]],
familiarity: str,
sibling_sections: Optional[List[str]] = None,
memory_context: str = "",
) -> _SectionExpansion:
"""Expand one section into 1-3 specific child concepts (one parallel call per section).
doc_excerpts is one dict per source document this section draws from:
{"index": int, "filename": str, "structure_text": str}. A section can span 2+
documents when they cover overlapping ground (see derive_root_and_sections) -> each
excerpt is kept individually capped so the total prompt still scales with section
count, not with how many documents happen to overlap on this one section. Each child
concept is asked to name which specific document(s) it came from (source_docs, using
the indices given here) so genuinely shared concepts can be told apart from concepts
that merely live in a section with mixed provenance.
sibling_sections lists the OTHER top-level sections in this same tree (generated
together, before any of them were expanded). Each section is still expanded by its
own independent call -> scales to many documents, unlike folding everything into one
call -> but naming the siblings lets the model steer away from topics that plainly
belong to one of them (e.g. don't put "AdaMax" under this section if "Adaptive
Variants" is a sibling), cutting down cross-section overlap that exact-label
deduplication alone can't catch.
"""
familiarity_note = FAMILIARITY_NOTES.get(familiarity, "")
sibling_note = ""
if sibling_sections:
sibling_note = (
"\nOther sections in this same curriculum (do NOT repeat their topics -> "
"if a concept clearly belongs to one of these instead, leave it out):\n"
+ "\n".join(f"- {s}" for s in sibling_sections) + "\n"
)
# Memory-aware child selection -> skip concepts the student has mastered,
# keep only what advances them, and tag each child's memory_tag (see
# derive_root_and_sections for the same load-bearing rationale).
memory_note = ""
if memory_context:
memory_note = (
"\nWhat this student already knows (from prior sessions):\n"
f"{memory_context}\n"
"Drop concepts they've clearly mastered; keep only what genuinely advances them, "
"and set each child's memory_tag ('new'/'review'/'scaffold') to match.\n"
)
multi_doc = len(doc_excerpts) > 1
excerpt_cap = 2000 if multi_doc else 4000
doc_index_note = ""
if multi_doc:
listing = "\n".join(f" [{d['index']}] {d['filename']}" for d in doc_excerpts)
doc_index_note = (
f"\nThis section draws from {len(doc_excerpts)} documents:\n{listing}\n"
"For each child concept, set source_docs to the index(es) of which document(s) "
"actually cover THAT specific concept -> list more than one only if the concept "
"genuinely appears in more than one of them.\n"
)
excerpt_text = "\n\n".join(
f"--- From: {d['filename']} (index {d['index']}) ---\n{d['structure_text'][:excerpt_cap]}"
for d in doc_excerpts
)
messages = [
{
"role": "system",
"content": (
f"You are a curriculum organiser. {familiarity_note}\n"
"Given ONE section of a subject, list 1-3 specific concepts a student must learn "
"within it. Use ONLY topics evidenced in the material. Keep labels ≤6 words."
f"{doc_index_note}{sibling_note}{memory_note}"
),
},
{
"role": "user",
"content": (
f"Section: {section_label}\n\n"
f"Document structure (for grounding):\n{excerpt_text}\n\n"
"List 1-3 child concepts for this section -> concepts that belong specifically "
"here, not to one of the other sections listed above."
+ (" Tag each with its source_docs indices." if multi_doc else "")
),
},
]
return self._client.structured_complete(messages, _SectionExpansion)
def _build_nodes_and_edges(
self, blueprint: _SyllabusBlueprint
) -> Tuple[List[NodeData], List[Dict]]:
"""Convert a _SyllabusBlueprint into (NodeData list, edge dicts)."""
node_ids = {sn.id for sn in blueprint.nodes}
nodes = [
NodeData(
id=sn.id,
label=sn.label,
description=sn.description,
depth=sn.depth,
complexity=sn.complexity,
parent_id=sn.parent_id,
status="ongoing",
)
for sn in blueprint.nodes
]
edges = [
{"source": e.source, "target": e.target, "relationship": e.relationship}
for e in blueprint.edges
if e.source in node_ids and e.target in node_ids
]
return nodes, edges
def cleanup_curriculum(
self,
nodes: List[NodeData],
edges: List[Dict],
doc_name_lookup: Optional[Dict[str, str]] = None,
) -> Tuple[List[NodeData], List[Dict]]:
"""Run a post-generation cleanup pass on the graph to merge semantic duplicates
and fix any structural issues introduced by parallel expansion.
doc_name_lookup maps each node's document_ids entries (file ids) to a human
filename, so the model can see which paper each node came from and specifically
look for the SAME concept appearing under different names across papers (not just
same-paper synonyms) -> that's the deliberate cross-paper merge this pass exists for.
"""
doc_name_lookup = doc_name_lookup or {}
def _doc_label(n: NodeData) -> str:
names = [doc_name_lookup.get(d, d) for d in (n.document_ids or [])]
return ", ".join(names) if names else "unspecified"
nodes_desc = "\n".join(
f"- {n.id}: \"{n.label}\" (depth={n.depth}, parent={n.parent_id or 'none'}, from: {_doc_label(n)})"
for n in nodes
)
edges_desc = "\n".join(
f"- {e['source']}{e['target']} ({e.get('relationship', 'related')})"
for e in edges
)
messages = [
{
"role": "system",
"content": (
"You are a curriculum organiser. Your job is to CLEAN UP a newly generated topic graph.\n"
"Because this graph was generated in parallel, it may contain semantic duplicates "
"(e.g., 'Math' and 'Mathematics', or 'Neural Networks' and 'Artificial Neural Networks').\n"
"RULES:\n"
"1. Merge nodes that mean the exact same thing. Keep the broader/better name.\n"
"2. Pay special attention to concepts that appear across MULTIPLE source documents "
"under different names or framings (e.g. one document calls it 'Attention Mechanism', "
"another calls it 'Self-Attention Layer', but they're the same underlying concept) -> "
"these are exactly the nodes that should merge, even more so than same-document synonyms.\n"
"3. Do NOT merge distinct sub-topics (e.g. 'Deep Learning' is distinct from 'Neural Networks').\n"
"4. When you merge two or more nodes, you MUST set merged_from_ids to all their original "
"ids, and write merge_summary: 1-2 sentences on what's shared across the source documents' "
"treatments, and what (if anything) differs (e.g. 'Both documents define this identically' "
"vs. 'Document A frames this as X; Document B extends it with Y'). Leave both empty if the "
"node is unchanged from a single input node.\n"
"5. Ensure the root node (n0) remains at depth=0 with parent_id=null.\n"
"6. Every other node MUST have a valid parent_id.\n"
"7. If you remove/merge a node, you MUST re-assign any edges or children that pointed to it.\n"
"8. Return the finalized list of nodes and edges."
),
},
{
"role": "user",
"content": (
f"CURRENT NODES:\n{nodes_desc}\n\n"
f"CURRENT EDGES:\n{edges_desc}\n\n"
"Return the clean graph. Nodes: id, label, description, depth, complexity (1-5), parent_id, "
"merged_from_ids, merge_summary.\n"
"Edges: source, target, relationship."
),
},
]
blueprint = self._client.structured_complete(messages, _SyllabusBlueprint)
# Preserve document_ids across the merge: union every contributing node's
# document_ids via merged_from_ids, rather than relying on id survival (the LLM
# may assign a merged node a brand-new id matching none of the originals).
doc_map = {n.id: list(getattr(n, "document_ids", [])) for n in nodes}
clean_nodes, clean_edges = self._build_nodes_and_edges(blueprint)
for cn, bn in zip(clean_nodes, blueprint.nodes):
contributing_ids = bn.merged_from_ids or [cn.id]
union_docs: List[str] = []
for cid in contributing_ids:
for d in doc_map.get(cid, []):
if d not in union_docs:
union_docs.append(d)
cn.document_ids = union_docs
cn.is_merged = len(bn.merged_from_ids) > 1
cn.merge_summary = bn.merge_summary
return clean_nodes, clean_edges
def extract_curriculum(
self,
chunks: List[Dict[str, Any]],
familiarity: str,
memory_context: str = "",
) -> Tuple[List[NodeData], List[Dict]]:
"""Derive topic graph from the student's uploaded content chunks.
Returns (nodes, edges) -> edges capture prerequisite and related-topic
relationships between nodes. All nodes start as ACTIVE (no locking).
"""
sample = "\n\n".join(
f"[{c.get('source', '?')}]: {c['text'][:300]}" for c in chunks[:20]
)
familiarity_note = FAMILIARITY_NOTES.get(familiarity, "")
tree_shape = FAMILIARITY_TREE_SHAPE.get(familiarity, FAMILIARITY_TREE_SHAPE["high_school"])
memory_note = (
f"\n\nPrior student knowledge from past sessions:\n{memory_context}"
if memory_context
else ""
)
messages = [
{
"role": "system",
"content": (
f"You are a curriculum organiser. {familiarity_note}{memory_note}\n\n"
"TREE STRUCTURE RULES:\n"
"1. The FIRST node (id=n0, depth=0) MUST be the overall topic/subject as a root node with parent_id=null.\n"
"2. Every other node MUST have a parent_id pointing to its parent in the hierarchy.\n"
"3. The tree flows top-down: root → major subtopics → specific concepts.\n"
"4. Edges capture cross-connections BETWEEN nodes (prerequisite, related, builds-on) -> these are IN ADDITION to the parent-child hierarchy.\n"
"5. Each node gets a complexity score (1-5): 1=simple definition/concept, 3=moderate understanding needed, 5=very complex/math-heavy/proof-based.\n\n"
f"GRANULARITY for this student's level:\n{tree_shape['guidance']}\n\n"
"DO NOT invent topics not evidenced in the text."
),
},
{
"role": "user",
"content": (
f"Content excerpts:\n{sample}\n\n"
"Build a hierarchical skill tree from this material.\n"
f"Node count: {tree_shape['node_range']} nodes. Max depth: {tree_shape['max_depth']}.\n"
"First node: id=n0, depth=0, label=overall topic name from the material, complexity=3, parent_id=null.\n"
"Remaining nodes: id (n1, n2...), label (≤6 words), brief description, depth, complexity (1-5), parent_id (REQUIRED).\n"
"Edges: source, target, relationship (prerequisite | related | builds-on). Include cross-links where real relationships exist."
),
},
]
blueprint = self._client.structured_complete(messages, _SyllabusBlueprint)
if blueprint.nodes:
doc_names = sorted({c.get("source", "") for c in chunks if c.get("source")})
smart_fallback = ""
if _is_generic_label(blueprint.nodes[0].label):
try:
smart_fallback = self.generate_session_title("", list(doc_names), familiarity)
except Exception:
pass
blueprint.nodes[0].label = _fallback_topic_name(blueprint.nodes[0].label, doc_names, smart_fallback)
return self._build_nodes_and_edges(blueprint)
def extract_curriculum_from_documents(
self,
document_overviews: List[Dict[str, str]],
familiarity: str,
topic_hint: str = "",
memory_context: str = "",
) -> Tuple[List[NodeData], List[Dict]]:
"""Instant graph generation from document structure (headings/TOC/first pages).
Returns (nodes, edges). All nodes start as ACTIVE -> no locking.
"""
all_structure = "\n\n---\n\n".join(
f"Document: {d['filename']}\n{d['structure_text'][:3000]}"
for d in document_overviews
)
all_structure = all_structure[:10000] # hard cap -> prevents JSON truncation at 32K MCL
familiarity_note = FAMILIARITY_NOTES.get(familiarity, "")
tree_shape = FAMILIARITY_TREE_SHAPE.get(familiarity, FAMILIARITY_TREE_SHAPE["high_school"])
topic_note = f"The student wants to study: {topic_hint}\n\n" if topic_hint else ""
memory_note = (
f"Prior student knowledge:\n{memory_context}\n\n" if memory_context else ""
)
multi_doc_note = ""
if len(document_overviews) > 1:
listing = "\n".join(f" - {d['filename']}" for d in document_overviews)
multi_doc_note = (
f"\nThere are {len(document_overviews)} source documents:\n{listing}\n"
"The tree MUST include topics from EVERY one of them, not just the first -> "
"a tree that only covers one document while ignoring the others is wrong.\n"
)
messages = [
{
"role": "system",
"content": (
f"You are a curriculum organiser. {familiarity_note}\n"
f"{topic_note}{memory_note}{multi_doc_note}\n"
"TREE STRUCTURE RULES:\n"
"1. The FIRST node (id=n0, depth=0) MUST be the overall topic/subject as a root node with parent_id=null.\n"
f" Its label should be: '{topic_hint}' if provided, otherwise derive the subject name from the documents.\n"
"2. Every other node MUST have a parent_id pointing to its parent in the hierarchy.\n"
"3. The tree flows top-down: root → major subtopics → specific concepts.\n"
"4. Edges capture cross-connections BETWEEN nodes -> these are IN ADDITION to parent-child links.\n"
"5. Each node gets a complexity score (1-5): 1=simple definition, 3=moderate, 5=very complex/math-heavy.\n\n"
f"GRANULARITY for this student's level:\n{tree_shape['guidance']}\n\n"
"Use ONLY topics evidenced in the documents."
),
},
{
"role": "user",
"content": (
f"Document structure:\n{all_structure}\n\n"
"Build a hierarchical skill tree from these documents.\n"
f"Node count: {tree_shape['node_range']} nodes. Max depth: {tree_shape['max_depth']}.\n"
"First node: id=n0, depth=0, label=overall topic, complexity=3, parent_id=null.\n"
"Remaining nodes: id (n1, n2...), label (≤6 words), description (≤80 chars), depth, complexity (1-5), parent_id (REQUIRED).\n"
"Edges: source, target, relationship (prerequisite | related | builds-on).\n"
"Keep descriptions short."
),
},
]
blueprint = self._client.structured_complete(messages, _SyllabusBlueprint)
if blueprint.nodes:
doc_names = [d["filename"] for d in document_overviews]
smart_fallback = ""
if _is_generic_label(blueprint.nodes[0].label):
try:
smart_fallback = self.generate_session_title(topic_hint, doc_names, familiarity)
except Exception:
pass
blueprint.nodes[0].label = _fallback_topic_name(blueprint.nodes[0].label, doc_names, smart_fallback)
return self._build_nodes_and_edges(blueprint)
def refine_curriculum(
self,
chunks: List[Dict[str, Any]],
familiarity: str,
user_feedback: str,
current_nodes: List[Dict[str, Any]],
current_edges: List[Dict[str, Any]],
) -> Tuple[List[NodeData], List[Dict]]:
"""Refine the existing curriculum tree based on student feedback.
Instead of generating a brand-new tree, this sends the current graph
structure to the LLM alongside user feedback so the model can make
targeted adjustments (add, remove, rename, restructure nodes).
"""
sample = "\n\n".join(
f"[{c.get('source', '?')}]: {c['text'][:300]}" for c in chunks[:20]
)
familiarity_note = FAMILIARITY_NOTES.get(familiarity, "")
tree_shape = FAMILIARITY_TREE_SHAPE.get(familiarity, FAMILIARITY_TREE_SHAPE["high_school"])
# Format existing tree as context
nodes_desc = "\n".join(
f"- {n['id']}: \"{n['label']}\" (depth={n.get('depth', 1)}, complexity={n.get('complexity', 3)}, parent={n.get('parent_id', 'none')}) -> {n.get('description', '')}"
for n in current_nodes
)
edges_desc = "\n".join(
f"- {e['source']}{e['target']} ({e.get('relationship', 'related')})"
for e in current_edges
)
current_tree = (
f"CURRENT NODES:\n{nodes_desc}\n\n"
f"CURRENT EDGES:\n{edges_desc}"
)
messages = [
{
"role": "system",
"content": (
f"You are a curriculum organiser. {familiarity_note}\n"
"You are REFINING an existing knowledge graph based on student feedback. "
"Do NOT regenerate from scratch. Instead:\n"
"- Keep the root node (n0, depth=0) -> it is the topic name\n"
"- Keep nodes that are still relevant\n"
"- Add new nodes if the student wants more coverage\n"
"- Remove nodes the student says are unnecessary\n"
"- Rename or restructure nodes as requested\n"
"- Update edges to reflect the new structure\n"
"- Every non-root node MUST have a parent_id\n"
"- Every node gets a complexity score (1-5)\n"
"All changes must stay grounded in the source material."
),
},
{
"role": "user",
"content": (
f"STUDENT FEEDBACK: {user_feedback}\n\n"
f"{current_tree}\n\n"
f"SOURCE MATERIAL EXCERPTS:\n{sample}\n\n"
"Apply the student's feedback to produce an updated set of nodes and edges.\n"
f"Target: {tree_shape['node_range']} nodes, max depth {tree_shape['max_depth']}.\n"
"Keep n0 as root (depth=0). Nodes: id, label, description, depth (0-4), complexity (1-5), parent_id.\n"
"Edges: source, target, relationship (prerequisite | related | builds-on).\n"
"Reuse existing node IDs where the concept is unchanged."
),
},
]
blueprint = self._client.structured_complete(messages, _SyllabusBlueprint)
if blueprint.nodes:
doc_names = sorted({c.get("source", "") for c in chunks if c.get("source")})
smart_fallback = ""
if _is_generic_label(blueprint.nodes[0].label):
try:
smart_fallback = self.generate_session_title("", list(doc_names), familiarity)
except Exception:
pass
blueprint.nodes[0].label = _fallback_topic_name(blueprint.nodes[0].label, doc_names, smart_fallback)
return self._build_nodes_and_edges(blueprint)
def identify_concepts(self, page_text: str, familiarity: str) -> List[str]:
"""Given a page of text, return a list of key concept phrases to highlight."""
messages = [
{
"role": "system",
"content": (
"You are a concept identifier. Extract 3-8 key concept phrases from the "
"provided text passage. Return only the exact phrases as they appear in the text -> "
"no paraphrase, no addition. Short noun phrases preferred."
),
},
{
"role": "user",
"content": f"Text passage:\n{page_text[:3000]}\n\nList key concept phrases (exact matches from the text).",
},
]
class _ConceptList(BaseModel):
concepts: List[str]
result = self._client.structured_complete(messages, _ConceptList)
return result.concepts
def build_rag_query(self, node_label: str, familiarity: str) -> str:
note = FAMILIARITY_NOTES.get(familiarity, "")
return f"{node_label} {note} explanation concepts"
def generate_session_title(self, topic: str, content_files: List[str], familiarity: str) -> str:
"""Short descriptive title for a Session History entry, generated once per
document set so a student can tell sessions apart at a glance.
"""
class _SessionTitle(BaseModel):
title: str = Field(description="A short (<=8 word) descriptive title for this study "
"session, capturing what it's about and its source "
"material -> NOT just the filenames verbatim.")
files_note = ", ".join(content_files) if content_files else "no files"
messages = [
{
"role": "system",
"content": (
"Generate a short, descriptive title for a study session based on its topic "
"and source material filenames, so a student can distinguish it from other "
"sessions in a history list at a glance. <=8 words. Describe what the session "
"is ABOUT, don't just restate the filenames."
),
},
{
"role": "user",
"content": f"Topic: {topic or '(none given)'}\nSource files: {files_note}\nFamiliarity level: {familiarity}",
},
]
try:
result = self._client.structured_complete(messages, _SessionTitle)
return result.title.strip() or (topic or "Study Session")
except Exception:
return topic or "Study Session"