Kacper Łukawski commited on
Commit
5ca31c1
·
1 Parent(s): 50e0013

Separate planning chunks from execution chunks

Browse files
Files changed (3) hide show
  1. app/agent.py +62 -29
  2. app/models.py +1 -0
  3. app/static/index.html +63 -18
app/agent.py CHANGED
@@ -3,39 +3,47 @@ from collections.abc import AsyncIterable
3
 
4
  from haystack.dataclasses import ChatMessage
5
  from haystack.components.agents import Agent
 
6
  from haystack_integrations.components.generators.nvidia import NvidiaChatGenerator
7
  from haystack_integrations.tools.mcp import MCPToolset, StreamableHttpServerInfo
8
 
9
  from app.models import TextChunk
10
  from app.queue import StreamingQueue
11
 
12
- SYSTEM_PROMPT = """\
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  You are an expert technical writer with access to Google Drive via MCP tools.
14
- When given a theme, search Google Drive for relevant files (meeting notes, agendas,
15
- slide decks, documents), read their contents, and synthesize the findings into a
16
- structured post-conference report in Markdown.
17
- Always search and retrieve files before writing the report.
18
  Always end the report with a "## Sources" section that lists every file you read,
19
- as a Markdown link using the file's real Google Drive URL.
20
 
21
- When you encounter a PDF file, before downloading or converting it, first search
22
- Google Drive for a Google Doc with the same or similar name. If one already exists,
23
- read the Google Doc instead of downloading and converting the PDF. Only download and
24
- convert a PDF when no corresponding Google Doc version can be found."""
25
 
26
- USER_PROMPT = """\
27
- Research the following theme: "{theme}"
28
 
29
- 1. Use the available Google Drive tools to search for files related to this theme.
30
- 2. For each PDF you find, first check whether a Google Doc version already exists
31
- (search by a similar name). If a Google Doc exists, read it directly. Only
32
- download and convert a PDF if no Google Doc equivalent is found.
33
- 3. Read the contents of the most relevant files you find.
34
- 4. Write a clear, well-structured Markdown report that covers the key findings.
35
- Use whatever sections and level of detail best fit the material — do not force
36
- a fixed template.
37
- 5. End the report with a "## Sources" section listing every file you referenced,
38
- as Markdown links: [File title](Google Drive URL)."""
39
 
40
  toolset = MCPToolset(
41
  server_info=StreamableHttpServerInfo(
@@ -45,7 +53,10 @@ toolset = MCPToolset(
45
  )
46
 
47
 
48
- async def run_agent(theme: str) -> AsyncIterable[TextChunk]:
 
 
 
49
  streaming_queue = StreamingQueue()
50
  generator = NvidiaChatGenerator(
51
  model="nvidia/nemotron-3-super-120b-a12b",
@@ -57,14 +68,9 @@ async def run_agent(theme: str) -> AsyncIterable[TextChunk]:
57
  )
58
  agent = Agent(
59
  chat_generator=generator,
60
- tools=toolset,
61
  )
62
 
63
- messages = [
64
- ChatMessage.from_system(SYSTEM_PROMPT),
65
- ChatMessage.from_user(USER_PROMPT.format(theme=theme)),
66
- ]
67
-
68
  async def run() -> None:
69
  async with streaming_queue:
70
  await agent.run_async(messages=messages)
@@ -97,3 +103,30 @@ async def run_agent(theme: str) -> AsyncIterable[TextChunk]:
97
  # Flush any remaining buffered calls (shouldn't happen in normal flow)
98
  for data in pending_calls.values():
99
  yield TextChunk(type="tool_call", tool_name=data["name"], content=data["args"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  from haystack.dataclasses import ChatMessage
5
  from haystack.components.agents import Agent
6
+ from haystack.tools import SearchableToolset
7
  from haystack_integrations.components.generators.nvidia import NvidiaChatGenerator
8
  from haystack_integrations.tools.mcp import MCPToolset, StreamableHttpServerInfo
9
 
10
  from app.models import TextChunk
11
  from app.queue import StreamingQueue
12
 
13
+ PLANNING_SYSTEM_PROMPT = """\
14
+ You are a research planner with access to Google Drive via MCP tools.
15
+ When given a conference theme, search Google Drive to identify relevant files
16
+ (meeting notes, agendas, slide decks, documents).
17
+ Produce a structured, numbered research plan that lists each file you found:
18
+ its name, URL, file type, and what information to extract from it.
19
+ For each PDF you find, note whether a Google Doc version might exist.
20
+ Do NOT write the final report — output a research plan only."""
21
+
22
+ PLANNING_USER_PROMPT = """\
23
+ Build a research plan for the theme: "{theme}"
24
+
25
+ 1. Use the available Google Drive tools to search for files related to this theme.
26
+ 2. For each PDF you find, note that you should check for a Google Doc version first.
27
+ 3. List each relevant file with: name, URL, file type, and what to extract.
28
+ 4. Output a numbered list of files to read and what to look for in each."""
29
+
30
+ EXECUTION_SYSTEM_PROMPT = """\
31
  You are an expert technical writer with access to Google Drive via MCP tools.
32
+ You will receive a research plan. Execute it step-by-step: read each file listed,
33
+ then synthesize the findings into a structured post-conference report in Markdown.
34
+ For each PDF in the plan, first check whether a Google Doc version already exists
35
+ (search by similar name). Read the Google Doc if found; only download the PDF if no Doc exists.
36
  Always end the report with a "## Sources" section that lists every file you read,
37
+ as Markdown links: [File title](Google Drive URL)."""
38
 
39
+ EXECUTION_USER_PROMPT = """\
40
+ Execute this research plan for the theme: "{theme}"
 
 
41
 
42
+ {plan}
 
43
 
44
+ Read each file in the plan, extract the relevant information, and write a comprehensive,
45
+ well-structured Markdown report. Use sections and detail that best fit the material.
46
+ End with a "## Sources" section listing every file you referenced as Markdown links."""
 
 
 
 
 
 
 
47
 
48
  toolset = MCPToolset(
49
  server_info=StreamableHttpServerInfo(
 
53
  )
54
 
55
 
56
+ async def _run_phase(
57
+ tools: SearchableToolset,
58
+ messages: list[ChatMessage],
59
+ ) -> AsyncIterable[TextChunk]:
60
  streaming_queue = StreamingQueue()
61
  generator = NvidiaChatGenerator(
62
  model="nvidia/nemotron-3-super-120b-a12b",
 
68
  )
69
  agent = Agent(
70
  chat_generator=generator,
71
+ tools=tools,
72
  )
73
 
 
 
 
 
 
74
  async def run() -> None:
75
  async with streaming_queue:
76
  await agent.run_async(messages=messages)
 
103
  # Flush any remaining buffered calls (shouldn't happen in normal flow)
104
  for data in pending_calls.values():
105
  yield TextChunk(type="tool_call", tool_name=data["name"], content=data["args"])
106
+
107
+
108
+ async def run_agent(theme: str) -> AsyncIterable[TextChunk]:
109
+ # Phase 1: Planning — identify relevant files and produce a research plan
110
+ planning_tools = SearchableToolset(catalog=toolset)
111
+ planning_messages = [
112
+ ChatMessage.from_system(PLANNING_SYSTEM_PROMPT),
113
+ ChatMessage.from_user(PLANNING_USER_PROMPT.format(theme=theme)),
114
+ ]
115
+
116
+ plan_text = ""
117
+ async for chunk in _run_phase(planning_tools, planning_messages):
118
+ chunk.phase = "planning"
119
+ if chunk.type == "text":
120
+ plan_text += chunk.content
121
+ yield chunk
122
+
123
+ # Phase 2: Execution — follow the plan, read files, write the report
124
+ execution_tools = SearchableToolset(catalog=toolset)
125
+ execution_messages = [
126
+ ChatMessage.from_system(EXECUTION_SYSTEM_PROMPT),
127
+ ChatMessage.from_user(EXECUTION_USER_PROMPT.format(theme=theme, plan=plan_text)),
128
+ ]
129
+
130
+ async for chunk in _run_phase(execution_tools, execution_messages):
131
+ chunk.phase = "execution"
132
+ yield chunk
app/models.py CHANGED
@@ -11,3 +11,4 @@ class TextChunk(BaseModel):
11
  type: Literal["text", "tool_call", "tool_result"] = "text"
12
  content: str = ""
13
  tool_name: str | None = None
 
 
11
  type: Literal["text", "tool_call", "tool_result"] = "text"
12
  content: str = ""
13
  tool_name: str | None = None
14
+ phase: Literal["planning", "execution"] | None = None
app/static/index.html CHANGED
@@ -7,14 +7,14 @@
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
9
  <style>
10
- #output h1, #output h2, #output h3 { font-weight: 700; margin-top: 1.25rem; margin-bottom: 0.5rem; }
11
- #output h1 { font-size: 1.5rem; }
12
- #output h2 { font-size: 1.25rem; }
13
- #output ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0; }
14
- #output ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0; }
15
- #output li { margin-bottom: 0.25rem; }
16
- #output p { margin-bottom: 0.75rem; }
17
- #output strong { font-weight: 700; }
18
  @keyframes spin { to { transform: rotate(360deg); } }
19
  .spinner { animation: spin 0.8s linear infinite; }
20
  </style>
@@ -52,12 +52,25 @@
52
  <div id="activityLog" class="text-xs font-mono text-gray-400 space-y-0.5 max-h-48 overflow-y-auto"></div>
53
  </div>
54
 
 
 
 
 
 
 
 
 
 
 
55
  <div id="outputPanel" class="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-6 hidden">
56
  <div class="flex items-center justify-between mb-4">
57
- <h2 class="text-lg font-semibold text-gray-200">Generated Report</h2>
 
 
 
58
  <span id="status" class="text-xs text-gray-500"></span>
59
  </div>
60
- <div id="output" class="text-gray-100 text-sm leading-relaxed"></div>
61
  </div>
62
 
63
  <script>
@@ -68,7 +81,9 @@
68
  const activityPanel = document.getElementById('activityPanel');
69
  const activityLog = document.getElementById('activityLog');
70
  const spinnerEl = document.getElementById('spinner');
71
- const panel = document.getElementById('outputPanel');
 
 
72
  const outputEl = document.getElementById('output');
73
  const statusEl = document.getElementById('status');
74
 
@@ -79,6 +94,16 @@
79
  activityLog.scrollTop = activityLog.scrollHeight;
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
82
  btn.addEventListener('click', async () => {
83
  const theme = promptEl.value.trim();
84
  if (!theme) return;
@@ -87,11 +112,15 @@
87
  activityPanel.classList.remove('hidden');
88
  activityLog.innerHTML = '';
89
  spinnerEl.classList.remove('hidden');
90
- panel.classList.remove('hidden');
 
 
91
  outputEl.innerHTML = '';
92
  statusEl.textContent = 'Generating…';
93
 
94
- let accumulated = '';
 
 
95
 
96
  try {
97
  const response = await fetch('/agent', {
@@ -123,6 +152,17 @@
123
  let parsed;
124
  try { parsed = JSON.parse(line); } catch { continue; }
125
 
 
 
 
 
 
 
 
 
 
 
 
126
  if (parsed.type === 'tool_call') {
127
  let argsDisplay = '';
128
  if (parsed.content) {
@@ -138,16 +178,21 @@
138
  appendActivity(`<span class="text-indigo-400">→</span> <code class="text-yellow-300">${parsed.tool_name}</code>${argsDisplay}`);
139
  } else if (parsed.type === 'tool_result') {
140
  appendActivity(`<span class="text-green-400">↳ result:</span> ${parsed.content}`);
141
- } else {
142
- accumulated += parsed.content;
143
- outputEl.innerHTML = marked.parse(accumulated);
144
- outputEl.scrollIntoView({ block: 'end', behavior: 'smooth' });
 
 
 
 
 
145
  }
146
  }
147
  }
148
 
149
  statusEl.textContent = 'Done';
150
- outputEl.innerHTML = marked.parse(accumulated);
151
 
152
  } catch (err) {
153
  statusEl.textContent = 'Error';
 
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
9
  <style>
10
+ .prose h1, .prose h2, .prose h3 { font-weight: 700; margin-top: 1.25rem; margin-bottom: 0.5rem; }
11
+ .prose h1 { font-size: 1.5rem; }
12
+ .prose h2 { font-size: 1.25rem; }
13
+ .prose ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0; }
14
+ .prose ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0; }
15
+ .prose li { margin-bottom: 0.25rem; }
16
+ .prose p { margin-bottom: 0.75rem; }
17
+ .prose strong { font-weight: 700; }
18
  @keyframes spin { to { transform: rotate(360deg); } }
19
  .spinner { animation: spin 0.8s linear infinite; }
20
  </style>
 
52
  <div id="activityLog" class="text-xs font-mono text-gray-400 space-y-0.5 max-h-48 overflow-y-auto"></div>
53
  </div>
54
 
55
+ <div id="planPanel" class="w-full max-w-3xl rounded-xl border border-indigo-800 bg-gray-900 p-6 hidden">
56
+ <div class="flex items-center justify-between mb-4">
57
+ <div class="flex items-center gap-2">
58
+ <span class="text-xs font-semibold uppercase tracking-wider text-indigo-400 border border-indigo-700 rounded px-1.5 py-0.5">Planning</span>
59
+ <h2 class="text-lg font-semibold text-gray-200">Research Plan</h2>
60
+ </div>
61
+ </div>
62
+ <div id="plan" class="prose text-gray-100 text-sm leading-relaxed"></div>
63
+ </div>
64
+
65
  <div id="outputPanel" class="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-6 hidden">
66
  <div class="flex items-center justify-between mb-4">
67
+ <div class="flex items-center gap-2">
68
+ <span class="text-xs font-semibold uppercase tracking-wider text-emerald-400 border border-emerald-800 rounded px-1.5 py-0.5">Execution</span>
69
+ <h2 class="text-lg font-semibold text-gray-200">Generated Report</h2>
70
+ </div>
71
  <span id="status" class="text-xs text-gray-500"></span>
72
  </div>
73
+ <div id="output" class="prose text-gray-100 text-sm leading-relaxed"></div>
74
  </div>
75
 
76
  <script>
 
81
  const activityPanel = document.getElementById('activityPanel');
82
  const activityLog = document.getElementById('activityLog');
83
  const spinnerEl = document.getElementById('spinner');
84
+ const planPanel = document.getElementById('planPanel');
85
+ const planEl = document.getElementById('plan');
86
+ const outputPanel = document.getElementById('outputPanel');
87
  const outputEl = document.getElementById('output');
88
  const statusEl = document.getElementById('status');
89
 
 
94
  activityLog.scrollTop = activityLog.scrollHeight;
95
  }
96
 
97
+ function appendPhaseHeader(phase) {
98
+ const p = document.createElement('p');
99
+ p.className = 'mt-1';
100
+ const color = phase === 'planning' ? 'text-indigo-400' : 'text-emerald-400';
101
+ const label = phase === 'planning' ? 'Planning phase' : 'Execution phase';
102
+ p.innerHTML = `<span class="font-semibold ${color}">── ${label} ──</span>`;
103
+ activityLog.appendChild(p);
104
+ activityLog.scrollTop = activityLog.scrollHeight;
105
+ }
106
+
107
  btn.addEventListener('click', async () => {
108
  const theme = promptEl.value.trim();
109
  if (!theme) return;
 
112
  activityPanel.classList.remove('hidden');
113
  activityLog.innerHTML = '';
114
  spinnerEl.classList.remove('hidden');
115
+ planPanel.classList.add('hidden');
116
+ planEl.innerHTML = '';
117
+ outputPanel.classList.add('hidden');
118
  outputEl.innerHTML = '';
119
  statusEl.textContent = 'Generating…';
120
 
121
+ let planAccumulated = '';
122
+ let reportAccumulated = '';
123
+ let lastPhase = null;
124
 
125
  try {
126
  const response = await fetch('/agent', {
 
152
  let parsed;
153
  try { parsed = JSON.parse(line); } catch { continue; }
154
 
155
+ // Emit a phase header in the activity log on first chunk of each phase
156
+ if (parsed.phase && parsed.phase !== lastPhase) {
157
+ lastPhase = parsed.phase;
158
+ appendPhaseHeader(parsed.phase);
159
+ if (parsed.phase === 'planning') {
160
+ planPanel.classList.remove('hidden');
161
+ } else if (parsed.phase === 'execution') {
162
+ outputPanel.classList.remove('hidden');
163
+ }
164
+ }
165
+
166
  if (parsed.type === 'tool_call') {
167
  let argsDisplay = '';
168
  if (parsed.content) {
 
178
  appendActivity(`<span class="text-indigo-400">→</span> <code class="text-yellow-300">${parsed.tool_name}</code>${argsDisplay}`);
179
  } else if (parsed.type === 'tool_result') {
180
  appendActivity(`<span class="text-green-400">↳ result:</span> ${parsed.content}`);
181
+ } else if (parsed.type === 'text') {
182
+ if (parsed.phase === 'planning') {
183
+ planAccumulated += parsed.content;
184
+ planEl.innerHTML = marked.parse(planAccumulated);
185
+ } else {
186
+ reportAccumulated += parsed.content;
187
+ outputEl.innerHTML = marked.parse(reportAccumulated);
188
+ outputEl.scrollIntoView({ block: 'end', behavior: 'smooth' });
189
+ }
190
  }
191
  }
192
  }
193
 
194
  statusEl.textContent = 'Done';
195
+ outputEl.innerHTML = marked.parse(reportAccumulated);
196
 
197
  } catch (err) {
198
  statusEl.textContent = 'Error';