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"