File size: 5,519 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
"""Report Agent -> streams textbook-style report sections from highlighted context.

Used by the Live-Compiling Research Report Canvas. Each highlighted passage becomes
a formatted section; a background pass may attach a grounded visual to it.
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field

from app.agents.cerebras_client import CerebrasClient


class NoteInsight(BaseModel):
    concept: str = Field(description="The concept/topic this note is about (≤8 words)")
    summary: str = Field(description="What the student captured, grounded in the source (2-4 sentences)")
    key_points: List[str] = Field(default_factory=list, description="0-4 atomic facts worth keeping")


class ReportAgent:
    def __init__(self, client: Optional[CerebrasClient] = None) -> None:
        self._client = client or CerebrasClient()

    # ------------------------------------------------------------------ #
    # Notes → report: process each note statelessly, then synthesize      #
    # ------------------------------------------------------------------ #

    def process_note(
        self,
        note_text: str,
        snippet_text: str,
        source_context: str,
        familiarity: str,
        extracted_content: str = "",
    ) -> NoteInsight:
        """One stateless pass over a single note → a grounded insight (pooled later).

        This is the per-note unit a per-PDF agent runs over every annotation; the output
        is what gets pooled (and, at scale, persisted to the PDF's Cognee cluster).
        """
        messages = [
            {
                "role": "system",
                "content": (
                    "You are extracting a clean, grounded insight from ONE of a student's notes on a "
                    "research paper. Use the student's note and the surrounding source context. "
                    "Capture what the note is really about -> do not invent beyond the source. "
                    f"Tailor wording to the {familiarity} level."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"STUDENT NOTE:\n{note_text or '(no written note -> the student marked this region)'}\n\n"
                    f"HIGHLIGHTED PASSAGE:\n{snippet_text[:1200]}\n\n"
                    f"PINNED/EXTRACTED CONTENT:\n{extracted_content[:800]}\n\n"
                    f"SURROUNDING SOURCE CONTEXT:\n{source_context[:2000]}"
                ),
            },
        ]
        return self._client.structured_complete(messages, NoteInsight)

    async def synthesize_report(
        self,
        insights: List[NoteInsight],
        topic: str,
        toc_labels: List[str],
        familiarity: str,
        knowledge_mode: str = "content_only",
        edit_instruction: str = "",
        web_context: str = "",
    ):
        """Stream the final report, amalgamating all pooled note-insights into one document."""
        pooled = "\n\n".join(
            f"### {ins.concept}\n{ins.summary}\n"
            + "".join(f"- {p}\n" for p in ins.key_points)
            for ins in insights
        )
        toc = "\n".join(f"- {t}" for t in toc_labels) if toc_labels else "(none)"

        if knowledge_mode == "net_support":
            grounding = (
                "Ground the report strictly in the pooled notes and the WEB CONTEXT provided below -> "
                "you may reorganize and reword for clarity, but never add a fact, figure, or claim "
                "that is not present in the notes or web context."
            )
        else:
            grounding = "Base the report strictly on the pooled notes and their source. Never fabricate."

        edit_note = f"REVISION REQUEST (apply to the whole report): {edit_instruction}\n\n" if edit_instruction else ""
        web_block = f"\n\nWEB CONTEXT:\n{web_context}" if web_context else ""

        system = (
            "You are compiling a student's personal research report from THEIR OWN notes on a paper. "
            "You are given pooled insights (one per note) and the paper's auto-generated table of "
            f"contents. {grounding} Tailor depth to the {familiarity} level.\n\n"
            "Your job: amalgamate, de-duplicate, reorganize, and reword the pooled insights into a "
            "single coherent, textbook-style report that follows the table of contents where it helps. "
            "It should read as the student's distilled understanding, not a list of notes.\n\n"
            "FORMAT (Markdown):\n"
            "- A '# ' title, then '## ' sections.\n"
            "- Flowing prose with **bold** key terms and bullet lists where useful.\n"
            "- LaTeX for math: $...$ and $$...$$.\n"
            "- You MAY include a ```mermaid diagram (plain-text labels, quote labels with parentheses) "
            "or a ```plotly JSON block for real data from the notes.\n"
            "- No [Source: ...] citations."
        )
        user = (
            f"{edit_note}"
            f"PAPER TOPIC: {topic or '(derive from the notes)'}\n\n"
            f"TABLE OF CONTENTS:\n{toc}\n\n"
            f"POOLED NOTE INSIGHTS:\n{pooled or '(no notes yet)'}{web_block}\n\n"
            "Write the full report now."
        )
        messages = [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ]
        async for token in self._client.stream_complete(messages):
            yield token