rairo commited on
Commit
951bcdd
·
verified ·
1 Parent(s): 5aa9788

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +165 -164
app.py CHANGED
@@ -1,5 +1,5 @@
1
  ###############################################################################
2
- # app.py – AI Report & Slides Demo
3
  ###############################################################################
4
  import os, re, json, uuid, tempfile, asyncio, base64, io, hashlib
5
  from pathlib import Path
@@ -20,51 +20,55 @@ except ImportError:
20
  from pptx import Presentation
21
  from pptx.util import Inches, Pt
22
 
23
- # ------------- Google APIs --------------------------------------------------
24
  from google.oauth2.service_account import Credentials
25
  from googleapiclient.discovery import build
26
 
27
- # ------------- Gemini / ADK -------------------------------------------------
28
  from google import genai
29
  from google.genai import types
30
- from google.adk.agents import LlmAgent, SequentialAgent
31
- from google.adk.runners import Runner
32
  from google.adk.sessions import InMemorySessionService
33
  from langchain_experimental.agents import create_pandas_dataframe_agent
34
- from langchain_google_genai import ChatGoogleGenerativeAI
35
 
36
  ###############################################################################
37
- # 1. Global config & constants
38
  ###############################################################################
39
- FONT_DIR = Path(__file__).parent
40
- FONT_NAME = "NotoSans"
41
- FONT_REGULAR_TTF = FONT_DIR / "NotoSans-Regular.ttf"
42
- FONT_BOLD_TTF = FONT_DIR / "NotoSans-Bold.ttf"
43
 
44
- SLIDE_COUNT = 7
45
- TTS_MODEL = "gemini-2.5-flash-preview-tts"
46
- GOOGLE_CREDS_JSON = os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON") # optional
47
 
48
  os.environ["STREAMLIT_CONFIG_DIR"] = tempfile.gettempdir()
49
  os.environ["MPLCONFIGDIR"] = tempfile.gettempdir()
50
 
51
  ###############################################################################
52
- # 2. Utility helpers
53
  ###############################################################################
54
  def _hash_df(df: pd.DataFrame) -> str:
55
  return hashlib.sha1(pd.util.hash_pandas_object(df, index=True).values).hexdigest()
56
 
57
  @st.cache_resource(show_spinner=False)
58
- def _get_pandas_agent(df_hash, api_key):
59
- df = st.session_state["dataframe"]
60
  return create_pandas_dataframe_agent(
61
  llm=ChatGoogleGenerativeAI(model="gemini-2.0-flash",
62
  google_api_key=api_key),
63
- df=df, verbose=False, allow_dangerous_code=True)
 
 
 
 
 
 
64
 
65
  def generate_tts_audio(client, text):
66
- if len(text) > 500:
67
- text = text[:500] + "..."
68
  try:
69
  resp = client.models.generate_content(
70
  model=TTS_MODEL,
@@ -81,66 +85,87 @@ def generate_tts_audio(client, text):
81
  st.error(f"TTS error: {e}")
82
  return None, None
83
 
84
- def _convert_pcm_to_wav(raw_bytes: bytes, sample_rate=24000, width=2):
85
  import wave, contextlib
86
  buf = io.BytesIO()
87
  with contextlib.closing(wave.open(buf, "wb")) as wf:
88
- wf.setnchannels(1)
89
- wf.setsampwidth(width)
90
- wf.setframerate(sample_rate)
91
- wf.writeframes(raw_bytes)
92
  return buf.getvalue()
93
 
94
- def build_pdf(markdown_src: str) -> bytes:
95
- """Return final PDF bytes with charts & tables formatted."""
96
- # Markdown HTML
97
- if MarkdownIt:
98
- html = MarkdownIt("commonmark", {"breaks": True}).enable("table").render(markdown_src)
99
- else:
100
- html = markdown(markdown_src, extensions=["tables"])
101
-
102
- # Dummy pdf for width calcs
103
- _tmp = FPDF(); page_w_mm = _tmp.w - 2 * _tmp.l_margin; px = int(page_w_mm * _tmp.k)
104
-
105
- # Fix tables
106
- html = re.sub(r'(<table[^>]*>)', r'\1<font size="8">', html)
107
- html = html.replace('</table>', '</font></table>')
108
- html = re.sub(r'<table(?![^>]*\bwidth=)', lambda m: f'<table width="{px}"', html)
109
 
110
- # ─ Scale images & clean stray bullets
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  html = re.sub(r'<img\s+src="([^"]+\.png)"\s*/?>',
112
- lambda m: f'<img src="{m.group(1)}" width="{int(page_w_mm)}" />', html)
113
- html = html.replace("\x95", "•")
114
-
115
- # ─ Build PDF
116
  class PDF(FPDF, HTMLMixin): pass
117
  pdf = PDF(); pdf.set_auto_page_break(auto=True, margin=15)
118
- pdf.add_font(FONT_NAME, "", str(FONT_REGULAR_TTF), uni=True)
119
- pdf.add_font(FONT_NAME, "B", str(FONT_BOLD_TTF), uni=True)
120
- pdf.add_font(FONT_NAME, "I", str(FONT_REGULAR_TTF), uni=True)
121
- pdf.add_font(FONT_NAME, "BI", str(FONT_BOLD_TTF), uni=True)
122
  pdf.set_fallback_fonts([FONT_NAME])
123
-
124
  pdf.add_page(); pdf.set_font(FONT_NAME, "B", 18)
125
  pdf.cell(0, 12, "AI-Generated Business Report", ln=True); pdf.ln(4)
126
  pdf.set_font(FONT_NAME, "", 11); pdf.write_html(html)
127
  return bytes(pdf.output(dest="S"))
128
 
129
- def upload_pptx_to_slides(path: Path) -> str | None:
130
- if not GOOGLE_CREDS_JSON:
131
- st.warning("Service-account JSON env var not set; skipping Slides upload.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  return None
133
  creds = Credentials.from_service_account_file(
134
- GOOGLE_CREDS_JSON,
135
  scopes=["https://www.googleapis.com/auth/drive.file",
136
  "https://www.googleapis.com/auth/presentations"])
137
- drive = build("drive", "v3", credentials=creds)
138
- meta = {"name": f"AI-Slides-{uuid.uuid4().hex[:6]}",
139
- "mimeType": "application/vnd.google-apps.presentation"}
140
  media = {"mimeType": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
141
- "body": open(path, "rb")}
142
- file = drive.files().create(body=meta, media_body=media).execute()
143
- return f"https://docs.google.com/presentation/d/{file['id']}"
144
 
145
  ###############################################################################
146
  # 3. Streamlit UI
@@ -148,49 +173,46 @@ def upload_pptx_to_slides(path: Path) -> str | None:
148
  st.set_page_config(page_title="AI Report & Slides", layout="wide")
149
  st.title("📊 AI-Generated Report & Presentation")
150
 
151
- api_key = os.getenv("GEMINI_API_KEY")
152
- if not api_key:
153
- st.error("Set `GEMINI_API_KEY` env var."); st.stop()
154
- client = genai.Client(api_key=api_key)
155
 
156
- MODE = st.radio("Choose output:", ["Report", "Presentation", "Both"], horizontal=True)
157
  uploaded = st.file_uploader("Upload CSV / XLSX", ["csv", "xlsx"])
158
- user_ctx = st.text_area("Optional business context")
159
- run_btn = st.button("🚀 Generate")
160
 
161
  ###############################################################################
162
- # 4. Pipeline
163
  ###############################################################################
164
  if run_btn:
165
- if not uploaded:
166
- st.warning("Upload a dataset."); st.stop()
167
-
168
- # 4.1 Load data
169
  try:
170
  df = pd.read_excel(uploaded) if uploaded.name.lower().endswith(".xlsx") else pd.read_csv(uploaded)
171
  except Exception as e:
172
  st.error(f"Read error: {e}"); st.stop()
173
- st.session_state["dataframe"] = df
174
  df_hash = _hash_df(df)
175
 
176
- # 4.2 Build agents
177
  report_agent = LlmAgent(
178
  name="ReportAgent", model="gemini-2.5-flash",
179
- instruction="""You are a senior business analyst. Write an executive-level Markdown report with charts tags like <generate_chart: "histogram of sales">.""",
180
- description="Writes report")
181
 
182
- presentation_agent = LlmAgent(
183
  name="PresentationAgent", model="gemini-2.5-flash",
184
- instruction=f"""Create exactly {SLIDE_COUNT} presentation slides. Each slide starts with 'Slide X:' then bullet/paragraph. Use <generate_chart: "..."> tags where helpful.""",
185
- description="Writes deck")
186
 
187
- sub_agents = []
188
- if MODE in ("Report", "Both"): sub_agents.append(report_agent)
189
- if MODE in ("Presentation", "Both"): sub_agents.append(presentation_agent)
 
190
 
191
- root_agent = SequentialAgent(name="Pipeline", sub_agents=sub_agents)
192
-
193
- # 4.3 Context
194
  ctx = {"dataset_info": {"shape": df.shape,
195
  "columns": list(df.columns),
196
  "dtypes": df.dtypes.astype(str).to_dict(),
@@ -198,117 +220,96 @@ if run_btn:
198
  "user_context": user_ctx,
199
  "preview": df.head().to_dict()}
200
 
201
- # 4.4 Run ADK pipeline
202
- svc = InMemorySessionService()
203
- sid = str(uuid.uuid4())
204
- asyncio.run(svc.create_session(app_name="app", user_id="user1", session_id=sid))
205
- runner = Runner(agent=root_agent, app_name="app", session_service=svc)
206
 
207
- final_texts = {}
208
- async def _run():
209
  async for ev in runner.run_async(
210
- user_id="user1",
211
- session_id=sid,
212
  new_message=types.Content(role="user",
213
  parts=[types.Part(text=json.dumps(ctx))])):
214
  if ev.is_final_response():
215
- final_texts[ev.author] = ev.content.parts[0].text
216
- asyncio.run(_run())
217
 
218
- # 4.5 Build charts
219
- chart_tags = re.findall(r'<generate_chart:\s*"([^"]+)"\s*>', "\n".join(final_texts.values()))
220
- desc2path = {}
221
- if chart_tags:
222
- pandas_agent = _get_pandas_agent(df_hash, api_key)
223
- for desc in chart_tags:
224
- if desc in desc2path: continue
225
- pandas_agent.run(f"Create a {desc} using matplotlib")
226
- fig = plt.gcf(); p = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
227
- fig.savefig(p, dpi=300, bbox_inches="tight"); plt.close()
228
- desc2path[desc] = str(p)
229
 
230
  # 4.6 Tabs
231
- if MODE == "Both":
232
  tab_report, tab_pres = st.tabs(["📄 Report", "📑 Presentation"])
233
  else:
234
  tab_report = tab_pres = st
235
 
236
  #######################################################################
237
- # Report tab
238
  #######################################################################
239
- if MODE in ("Report", "Both"):
240
- report_md = final_texts["ReportAgent"]
241
- for d, pth in desc2path.items():
242
- report_md = re.sub(rf'<generate_chart:\s*"{re.escape(d)}"\s*>',
243
- f'<img src="{pth}" />', report_md)
 
 
 
 
 
 
244
 
245
  with tab_report:
246
  st.markdown("### Report Preview")
247
- st.markdown(report_md, unsafe_allow_html=True)
248
  st.download_button("⬇️ Download PDF",
249
- build_pdf(report_md),
250
- file_name="business_report.pdf",
251
  mime="application/pdf")
252
 
253
  #######################################################################
254
- # Presentation tab
255
  #######################################################################
256
- if MODE in ("Presentation", "Both"):
257
- raw = final_texts["PresentationAgent"]
258
- slides_text = [s.strip() for s in re.split(r'\bSlide\s+\d+:', raw) if s.strip()][:SLIDE_COUNT]
259
-
260
- st.session_state.app_state = {"steps": slides_text, "current": 0}
261
-
262
- def render_step(idx, text):
263
- tot = len(slides_text)
264
- st.markdown(f"#### Slide {idx+1} / {tot}")
265
- st.write(text)
266
- if st.button(f"🔊 Narrate", key=f"tts_{idx}"):
267
  with st.spinner("TTS…"):
268
- aud, mime = generate_tts_audio(client, text)
269
  if aud:
270
  if 'pcm' in mime or 'L16' in mime:
271
- st.audio(_convert_pcm_to_wav(aud), format="audio/wav")
272
  else:
273
  st.audio(aud, format=mime)
274
 
275
  with tab_pres:
276
- col_l, col_r = st.columns([1,1])
277
- if col_l.button("⬅️ Back", disabled=st.session_state.app_state['current']==0):
278
- st.session_state.app_state['current'] -= 1
279
- if col_r.button("Next ➡️",
280
- disabled=st.session_state.app_state['current']==len(slides_text)-1):
281
- st.session_state.app_state['current'] += 1
282
- render_step(st.session_state.app_state['current'],
283
- slides_text[st.session_state.app_state['current']])
284
-
285
- # -- PPTX build (cached) ----------------------------------------
286
- @st.cache_resource(show_spinner=False)
287
- def _build_pptx(txts, d2p):
288
- prs = Presentation(); blank = prs.slide_layouts[6]
289
- for i, txt in enumerate(txts):
290
- sl = prs.slides.add_slide(blank)
291
- title = sl.shapes.add_textbox(Inches(0.5), Inches(0.4),
292
- Inches(9), Inches(1))
293
- title.text_frame.text = f"Slide {i+1}"
294
- body = sl.shapes.add_textbox(Inches(0.5), Inches(1.4),
295
- Inches(9), Inches(3.5))
296
- body.text_frame.text = re.sub(r'<.*?>', '', txt)
297
- m = re.search(r'<generate_chart:\s*"([^"]+)"\s*>', txt)
298
- if m and m.group(1) in d2p:
299
- sl.shapes.add_picture(d2p[m.group(1)],
300
- Inches(1), Inches(3.5), width=Inches(8))
301
- out = Path(tempfile.gettempdir()) / f"slides_{uuid.uuid4().hex}.pptx"
302
- prs.save(out); return out
303
- pptx_path = _build_pptx(slides_text, desc2path)
304
  st.download_button("⬇️ Download PPTX",
305
- pptx_path.read_bytes(),
306
- file_name="presentation.pptx",
307
  mime=("application/vnd.openxmlformats-officedocument."
308
  "presentationml.presentation"))
309
  if st.button("⬆️ Upload to Google Slides"):
310
- url = upload_pptx_to_slides(pptx_path)
311
- if url: st.success(f"[Open Slides]({url})")
 
312
  ###############################################################################
313
- # End of file
314
  ###############################################################################
 
1
  ###############################################################################
2
+ # app.py – AI Report & Slides Demo (stable 2025-06-23)
3
  ###############################################################################
4
  import os, re, json, uuid, tempfile, asyncio, base64, io, hashlib
5
  from pathlib import Path
 
20
  from pptx import Presentation
21
  from pptx.util import Inches, Pt
22
 
23
+ # ── Google APIs ──────────────────────────────────────────────────────────────
24
  from google.oauth2.service_account import Credentials
25
  from googleapiclient.discovery import build
26
 
27
+ # ── Gemini & ADK ─────────────────────────────────────────────────────────────
28
  from google import genai
29
  from google.genai import types
30
+ from google.adk.agents import LlmAgent, SequentialAgent
31
+ from google.adk.runners import Runner
32
  from google.adk.sessions import InMemorySessionService
33
  from langchain_experimental.agents import create_pandas_dataframe_agent
34
+ from langchain_google_genai import ChatGoogleGenerativeAI
35
 
36
  ###############################################################################
37
+ # 1. Constants & env
38
  ###############################################################################
39
+ FONT_DIR = Path(__file__).parent
40
+ FONT_NAME = "NotoSans"
41
+ FONT_REG = FONT_DIR / "NotoSans-Regular.ttf"
42
+ FONT_BOLD = FONT_DIR / "NotoSans-Bold.ttf"
43
 
44
+ SLIDES = 7
45
+ TTS_MODEL = "gemini-2.5-flash-preview-tts"
46
+ CREDS_JSON = os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON") # optional
47
 
48
  os.environ["STREAMLIT_CONFIG_DIR"] = tempfile.gettempdir()
49
  os.environ["MPLCONFIGDIR"] = tempfile.gettempdir()
50
 
51
  ###############################################################################
52
+ # 2. Utility helpers (all cached where heavy)
53
  ###############################################################################
54
  def _hash_df(df: pd.DataFrame) -> str:
55
  return hashlib.sha1(pd.util.hash_pandas_object(df, index=True).values).hexdigest()
56
 
57
  @st.cache_resource(show_spinner=False)
58
+ def get_pandas_agent(df_hash: str, api_key: str):
 
59
  return create_pandas_dataframe_agent(
60
  llm=ChatGoogleGenerativeAI(model="gemini-2.0-flash",
61
  google_api_key=api_key),
62
+ df=st.session_state.df,
63
+ verbose=False,
64
+ allow_dangerous_code=True)
65
+
66
+ def fix_bullets(text: str) -> str:
67
+ """Convert stray Win-1252 bullet (0x95) → proper Unicode bullet."""
68
+ return text.replace("\x95", "•")
69
 
70
  def generate_tts_audio(client, text):
71
+ text = (text[:500] + "...") if len(text) > 500 else text
 
72
  try:
73
  resp = client.models.generate_content(
74
  model=TTS_MODEL,
 
85
  st.error(f"TTS error: {e}")
86
  return None, None
87
 
88
+ def pcm_to_wav(raw: bytes, rate=24000, width=2):
89
  import wave, contextlib
90
  buf = io.BytesIO()
91
  with contextlib.closing(wave.open(buf, "wb")) as wf:
92
+ wf.setnchannels(1); wf.setsampwidth(width); wf.setframerate(rate); wf.writeframes(raw)
 
 
 
93
  return buf.getvalue()
94
 
95
+ @st.cache_resource(show_spinner=False)
96
+ def build_charts(chart_descs: tuple, df_hash: str, api_key: str):
97
+ """Return {desc:strpath:str} for unique chart descriptions."""
98
+ agent = get_pandas_agent(df_hash, api_key)
99
+ desc2path = {}
100
+ for desc in chart_descs:
101
+ if desc in desc2path: continue
102
+ agent.run(f"Create a {desc} using matplotlib")
103
+ fig = plt.gcf()
104
+ p = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
105
+ fig.savefig(p, dpi=300, bbox_inches="tight"); plt.close()
106
+ desc2path[desc] = str(p)
107
+ return desc2path
 
 
108
 
109
+ @st.cache_resource(show_spinner=False)
110
+ def build_pdf(markdown_src: str) -> bytes:
111
+ """Return final PDF bytes with fonts, charts, tables fixed."""
112
+ markdown_src = fix_bullets(markdown_src)
113
+ # 1 Markdown → HTML
114
+ html = (MarkdownIt("commonmark", {"breaks": True}).enable("table").render(markdown_src)
115
+ if MarkdownIt else
116
+ markdown(markdown_src, extensions=["tables"]))
117
+ # 2 Dummy pdf for metrics
118
+ tmp = FPDF(); w_mm, px = tmp.w - 2*tmp.l_margin, lambda mm: int(mm*tmp.k)
119
+ # 3 Tables
120
+ html = re.sub(r'(<table[^>]*>)', r'\1<font size="8">', html)\
121
+ .replace('</table>', '</font></table>')
122
+ html = re.sub(r'<table(?![^>]*\bwidth=)', lambda m: f'<table width="{px(w_mm)}"', html)
123
+ # 4 Scale imgs
124
  html = re.sub(r'<img\s+src="([^"]+\.png)"\s*/?>',
125
+ lambda m: f'<img src="{m.group(1)}" width="{int(w_mm)}" />', html)
126
+ # 5 PDF build
 
 
127
  class PDF(FPDF, HTMLMixin): pass
128
  pdf = PDF(); pdf.set_auto_page_break(auto=True, margin=15)
129
+ for style, path in [("",FONT_REG), ("B",FONT_BOLD), ("I",FONT_REG), ("BI",FONT_BOLD)]:
130
+ pdf.add_font(FONT_NAME, style, str(path), uni=True)
 
 
131
  pdf.set_fallback_fonts([FONT_NAME])
 
132
  pdf.add_page(); pdf.set_font(FONT_NAME, "B", 18)
133
  pdf.cell(0, 12, "AI-Generated Business Report", ln=True); pdf.ln(4)
134
  pdf.set_font(FONT_NAME, "", 11); pdf.write_html(html)
135
  return bytes(pdf.output(dest="S"))
136
 
137
+ @st.cache_resource(show_spinner=False)
138
+ def build_pptx(slide_texts: tuple, desc2path: dict) -> Path:
139
+ prs = Presentation(); blank = prs.slide_layouts[6]
140
+ for i, txt in enumerate(slide_texts):
141
+ txt = fix_bullets(txt)
142
+ slide = prs.slides.add_slide(blank)
143
+ slide.shapes.add_textbox(Inches(0.6), Inches(0.4), Inches(9), Inches(0.8))\
144
+ .text_frame.text = f"Slide {i+1}"
145
+ body = slide.shapes.add_textbox(Inches(0.6), Inches(1.4), Inches(9), Inches(3.2))
146
+ body.text_frame.text = re.sub(r'<.*?>', '', txt)
147
+ m = re.search(r'<generate_chart:\s*"([^"]+)"\s*>', txt)
148
+ if m and m.group(1) in desc2path:
149
+ slide.shapes.add_picture(desc2path[m.group(1)],
150
+ Inches(1), Inches(3.7), width=Inches(8))
151
+ out = Path(tempfile.gettempdir()) / f"slides_{uuid.uuid4().hex}.pptx"
152
+ prs.save(out); return out
153
+
154
+ def upload_to_slides(pptx_path: Path) -> str | None:
155
+ if not CREDS_JSON:
156
+ st.warning("Service-account JSON not configured; skipping Slides upload.")
157
  return None
158
  creds = Credentials.from_service_account_file(
159
+ CREDS_JSON,
160
  scopes=["https://www.googleapis.com/auth/drive.file",
161
  "https://www.googleapis.com/auth/presentations"])
162
+ drive = build("drive", "v3", credentials=creds)
163
+ meta = {"name": f"AI-Slides-{uuid.uuid4().hex[:6]}",
164
+ "mimeType": "application/vnd.google-apps.presentation"}
165
  media = {"mimeType": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
166
+ "body": open(pptx_path, "rb")}
167
+ f = drive.files().create(body=meta, media_body=media).execute()
168
+ return f"https://docs.google.com/presentation/d/{f['id']}"
169
 
170
  ###############################################################################
171
  # 3. Streamlit UI
 
173
  st.set_page_config(page_title="AI Report & Slides", layout="wide")
174
  st.title("📊 AI-Generated Report & Presentation")
175
 
176
+ API_KEY = os.getenv("GEMINI_API_KEY")
177
+ if not API_KEY:
178
+ st.error("Set `GEMINI_API_KEY` in env."); st.stop()
179
+ client = genai.Client(api_key=API_KEY)
180
 
181
+ mode = st.radio("Output:", ["Report", "Presentation", "Both"], horizontal=True)
182
  uploaded = st.file_uploader("Upload CSV / XLSX", ["csv", "xlsx"])
183
+ user_ctx = st.text_area("Optional business context / objectives")
184
+ run_btn = st.button("🚀 Generate")
185
 
186
  ###############################################################################
187
+ # 4. Main pipeline
188
  ###############################################################################
189
  if run_btn:
190
+ if not uploaded: st.warning("Please upload a dataset."); st.stop()
191
+ # 4.1 Load
 
 
192
  try:
193
  df = pd.read_excel(uploaded) if uploaded.name.lower().endswith(".xlsx") else pd.read_csv(uploaded)
194
  except Exception as e:
195
  st.error(f"Read error: {e}"); st.stop()
196
+ st.session_state.df = df
197
  df_hash = _hash_df(df)
198
 
199
+ # 4.2 Agents
200
  report_agent = LlmAgent(
201
  name="ReportAgent", model="gemini-2.5-flash",
202
+ instruction=("You are a senior business analyst. Write an executive-level Markdown "
203
+ "report with <generate_chart: \"...\"> placeholders."))
204
 
205
+ pres_agent = LlmAgent(
206
  name="PresentationAgent", model="gemini-2.5-flash",
207
+ instruction=(f"Create exactly {SLIDES} slides. Each slide starts with 'Slide X:' "
208
+ "and may include <generate_chart: \"...\"> tags."))
209
 
210
+ subs = []
211
+ if mode in ("Report", "Both"): subs.append(report_agent)
212
+ if mode in ("Presentation", "Both"): subs.append(pres_agent)
213
+ pipeline = SequentialAgent(name="Pipeline", sub_agents=subs)
214
 
215
+ # 4.3 Context → Gemini
 
 
216
  ctx = {"dataset_info": {"shape": df.shape,
217
  "columns": list(df.columns),
218
  "dtypes": df.dtypes.astype(str).to_dict(),
 
220
  "user_context": user_ctx,
221
  "preview": df.head().to_dict()}
222
 
223
+ # 4.4 Run ADK
224
+ svc, sid = InMemorySessionService(), str(uuid.uuid4())
225
+ asyncio.run(svc.create_session(app_name="app", user_id="user", session_id=sid))
226
+ runner = Runner(agent=pipeline, app_name="app", session_service=svc)
 
227
 
228
+ final = {}
229
+ async def _collect():
230
  async for ev in runner.run_async(
231
+ user_id="user", session_id=sid,
 
232
  new_message=types.Content(role="user",
233
  parts=[types.Part(text=json.dumps(ctx))])):
234
  if ev.is_final_response():
235
+ final[ev.author] = ev.content.parts[0].text
236
+ asyncio.run(_collect())
237
 
238
+ # 4.5 Charts
239
+ chart_tags = re.findall(r'<generate_chart:\s*"([^"]+)"\s*>', "\n".join(final.values()))
240
+ desc2path = build_charts(tuple(chart_tags), df_hash, API_KEY)
 
 
 
 
 
 
 
 
241
 
242
  # 4.6 Tabs
243
+ if mode == "Both":
244
  tab_report, tab_pres = st.tabs(["📄 Report", "📑 Presentation"])
245
  else:
246
  tab_report = tab_pres = st
247
 
248
  #######################################################################
249
+ # Report
250
  #######################################################################
251
+ if mode in ("Report", "Both"):
252
+ md = fix_bullets(final["ReportAgent"])
253
+ # Replace tags: base-64 for preview + file path for PDF
254
+ preview_md = md; pdf_md = md
255
+ for d,pth in desc2path.items():
256
+ with open(pth, "rb") as f:
257
+ b64 = base64.b64encode(f.read()).decode()
258
+ embed = f'<img src="data:image/png;base64,{b64}" style="max-width:100%;margin:8px 0;" />'
259
+ preview_md = re.sub(rf'<generate_chart:\s*"{re.escape(d)}"\s*>', embed, preview_md)
260
+ pdf_md = re.sub(rf'<generate_chart:\s*"{re.escape(d)}"\s*>',
261
+ f'<img src="{pth}" />', pdf_md)
262
 
263
  with tab_report:
264
  st.markdown("### Report Preview")
265
+ st.markdown(preview_md, unsafe_allow_html=True)
266
  st.download_button("⬇️ Download PDF",
267
+ build_pdf(pdf_md),
268
+ "business_report.pdf",
269
  mime="application/pdf")
270
 
271
  #######################################################################
272
+ # Presentation
273
  #######################################################################
274
+ if mode in ("Presentation", "Both"):
275
+ raw = fix_bullets(final["PresentationAgent"])
276
+ slides_txt = [s.strip() for s in re.split(r'\bSlide\s+\d+:', raw) if s.strip()][:SLIDES]
277
+ st.session_state.slides = slides_txt
278
+ st.session_state.curr = 0
279
+
280
+ def render(idx: int):
281
+ txt = st.session_state.slides[idx]
282
+ st.markdown(f"#### Slide {idx+1} / {len(slides_txt)}")
283
+ st.write(txt)
284
+ if st.button("🔊 Narrate", key=f"tts_{idx}"):
285
  with st.spinner("TTS…"):
286
+ aud,mime = generate_tts_audio(client, txt)
287
  if aud:
288
  if 'pcm' in mime or 'L16' in mime:
289
+ st.audio(pcm_to_wav(aud), format="audio/wav")
290
  else:
291
  st.audio(aud, format=mime)
292
 
293
  with tab_pres:
294
+ col1,col2 = st.columns(2)
295
+ if col1.button("⬅️ Back", disabled=st.session_state.curr==0):
296
+ st.session_state.curr -= 1
297
+ if col2.button("Next ➡️",
298
+ disabled=st.session_state.curr==len(slides_txt)-1):
299
+ st.session_state.curr += 1
300
+ render(st.session_state.curr)
301
+
302
+ # PPTX build (cached)
303
+ pptx = build_pptx(tuple(slides_txt), desc2path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  st.download_button("⬇️ Download PPTX",
305
+ pptx.read_bytes(),
306
+ "presentation.pptx",
307
  mime=("application/vnd.openxmlformats-officedocument."
308
  "presentationml.presentation"))
309
  if st.button("⬆️ Upload to Google Slides"):
310
+ url = upload_to_slides(pptx)
311
+ if url:
312
+ st.success(f"[Open Slides]({url})")
313
  ###############################################################################
314
+ # End
315
  ###############################################################################