Spaces:
Sleeping
Sleeping
File size: 36,343 Bytes
2e818da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 | """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"
|