# app.py import os import time import json import uuid import sqlite3 from datetime import datetime import requests import streamlit as st from fpdf import FPDF import re # ----------------------- # Config # ----------------------- APP_TITLE = "L&D Studio AI" DB_PATH = "history.db" # prefer repo asset path; the app will try multiple locations DEFAULT_ASSET_REL_PATH = "assets/logo.png" UPLOADED_LOCAL_PATH = '/mnt/data/A_logo_for_"L&D_Studio_AI"_is_displayed_in_the_ima.png' GROQ_API_KEY = os.getenv("GROQ_API_KEY", "") # set this in environment / platform secrets GROQ_ENDPOINT = "https://api.groq.com/openai/v1/chat/completions" GROQ_MODELS_TO_TRY = ["llama-3.1-8b-instant"] # ----------------------- # DB helpers (history) # ----------------------- def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(''' CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, timestamp TEXT, user TEXT, name TEXT, industry TEXT, payload TEXT, exported INTEGER ) ''') conn.commit() conn.close() def save_session(name, industry, payload, user="demo", exported=0): conn = sqlite3.connect(DB_PATH) c = conn.cursor() sid = str(uuid.uuid4()) # table has 7 columns -> provide exported value c.execute('INSERT INTO sessions VALUES (?,?,?,?,?,?,?)', (sid, datetime.utcnow().isoformat(), user, name, industry, json.dumps(payload), exported)) conn.commit() conn.close() return sid def fetch_history(filter_name=None, limit=100): conn = sqlite3.connect(DB_PATH) c = conn.cursor() if filter_name: c.execute("SELECT id,timestamp,user,name,industry FROM sessions WHERE name LIKE ? ORDER BY timestamp DESC LIMIT ?", (f"%{filter_name}%", limit)) else: c.execute("SELECT id,timestamp,user,name,industry FROM sessions ORDER BY timestamp DESC LIMIT ?", (limit,)) rows = c.fetchall() conn.close() return rows # ----------------------- # Groq LLM call with retries & JSON validation # ----------------------- def call_groq_chat(prompt_messages, max_tokens=1200, temperature=0.2, max_retries=3): """ prompt_messages: list of dicts like {"role": "system|user", "content": "..."} Tries several models in GROQ_MODELS_TO_TRY if model-specific errors occur. Returns the assistant content string. Raises RuntimeError on final failure with helpful message. """ if not GROQ_API_KEY: raise EnvironmentError("GROQ_API_KEY not set. Add it to your environment or platform secrets as GROQ_API_KEY.") headers = { "Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json" } last_exception = None for model in GROQ_MODELS_TO_TRY: payload = { "model": model, "messages": prompt_messages, "max_tokens": max_tokens, "temperature": temperature } attempt = 0 while attempt < max_retries: try: resp = requests.post(GROQ_ENDPOINT, headers=headers, json=payload, timeout=30) # Debug: if you need logs, uncomment: # print("DEBUG: POST", GROQ_ENDPOINT, "model:", model, "status:", resp.status_code) # print("DEBUG: response text:", resp.text[:1000]) resp.raise_for_status() data = resp.json() # attempt to extract assistant content robustly if "choices" in data and len(data["choices"])>0: choice = data["choices"][0] # OpenAI-compatible: choice["message"]["content"] if isinstance(choice.get("message"), dict) and "content" in choice["message"]: content = choice["message"]["content"] # content can be string or dict if isinstance(content, str): return content elif isinstance(content, dict): # common pattern with text key if "text" in content: return content["text"] else: return json.dumps(content) # fallback to choice["text"] if "text" in choice: return choice["text"] # fallback top-level text if "text" in data: return data["text"] # if nothing found, raise to attempt retry raise RuntimeError("LLM response did not contain expected 'choices' content.") except Exception as e: last_exception = e attempt += 1 time.sleep(1 + attempt * 0.3) # try next model if available # all attempts failed raise RuntimeError(f"Groq call failed after trying models {GROQ_MODELS_TO_TRY}. Last error: {last_exception}") # ----------------------- # PDF Exporter # ----------------------- def export_pdf(activity_dict, filename="activity_pack.pdf"): pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=12) pdf.add_page() pdf.set_font("Arial", size=14) pdf.cell(0, 8, f"{APP_TITLE} — {activity_dict.get('activity_name', 'Activity')}", ln=True) pdf.ln(4) pdf.set_font("Arial", size=11) pdf.multi_cell(0, 6, f"Objective: {activity_dict.get('objective','')}") pdf.ln(2) pdf.multi_cell(0, 6, f"Why this works:\n{activity_dict.get('why','')}") pdf.ln(2) pdf.multi_cell(0, 6, f"Scenario:\n{activity_dict.get('scenario','')}") pdf.ln(2) pdf.multi_cell(0, 6, "Character sketches:") for ch in activity_dict.get('character_sketches', []): pdf.multi_cell(0, 6, f"- {ch.get('role')}: {ch.get('description')}") pdf.ln(2) pdf.multi_cell(0, 6, "Trainer Notes:") for step in activity_dict.get('trainer_notes', []): pdf.multi_cell(0, 6, f"- {step}") pdf.ln(2) pdf.multi_cell(0, 6, "Debrief Questions:") for q in activity_dict.get('debrief_questions', []): pdf.multi_cell(0, 6, f"- {q}") pdf.ln(4) pdf.multi_cell(0, 6, "Success indicators:") for s in activity_dict.get('success_indicators', []): pdf.multi_cell(0, 6, f"- {s}") pdf.output(filename) return filename # ----------------------- # UI helpers # ----------------------- def card(title, subtitle): st.markdown( f"""
{title}
{subtitle}
""", unsafe_allow_html=True) def get_asset_file(): # Try repository relative path first, then uploaded local path, then placeholder candidates = [ DEFAULT_ASSET_REL_PATH, os.path.join(os.getcwd(), DEFAULT_ASSET_REL_PATH), UPLOADED_LOCAL_PATH ] for p in candidates: if p and os.path.exists(p): return p return None # ----------------------- # Main # ----------------------- def main(): init_db() st.set_page_config(page_title=APP_TITLE, layout="centered") st.sidebar.title(APP_TITLE) username = st.sidebar.text_input("User (prototype)", value="demo_user") st.sidebar.markdown("**Note**: This is prototype history stored locally. Add GROQ_API_KEY to Secrets for LLM calls.") asset_file = get_asset_file() if asset_file: st.image(asset_file, width=220) else: st.warning("Logo not found. Add assets/logo.png to the repo or set the uploaded local path.") st.image("https://via.placeholder.com/440x220.png?text=L%26D+Studio+AI", width=220) st.title(APP_TITLE) st.caption("AI-Powered Trainer Companion — mobile-first prototype") st.markdown("### Idea Generator") with st.form("idea_form"): industry = st.selectbox("Industry (context)", ["General / Cross-industry", "Pharmaceutical", "Healthcare", "Insurance", "Technology", "Education", "Other"]) category = st.selectbox("Activity Category", ["Ice Breaking", "Activities During Workshop", "Learning Solution", "Energizer"]) message = st.text_area("Message You Want to Drive (one-line)", placeholder="e.g., Active listening, feedback culture", height=70) audience = st.multiselect("Audience Profile", ["First-line managers", "Senior managers", "Sales reps", "New joiners", "Cross-functional teams", "Leadership team"]) batch_size = st.text_input("Batch Size (e.g., 15–20)", value="15-20") duration = st.selectbox("Activity Duration", ["5-10 mins", "10-20 mins", "20-30 mins", "30-60 mins"]) delivery = st.radio("Delivery Format", ["In-person", "Virtual"]) training_stage = st.selectbox("Training Stage", ["Ice-breaker", "Mid-session energizer", "Concept introduction", "Deep-dive practice", "Reflection/closing"]) scenario_context = st.text_area("Scenario Context (brief)", placeholder="Describe setting, culture, common challenges", height=80) desired_outcome = st.text_area("Desired Outcome (what should participants do differently?)", height=60) include_dialogues = st.checkbox("Include sample dialogues in output (roleplay only)", value=False) submitted = st.form_submit_button("Generate Activity Pack") if submitted: # mandatory check required = { "Message": message, "Audience": audience, "Batch size": batch_size, "Duration": duration, "Delivery": delivery, "Training stage": training_stage, "Scenario context": scenario_context, "Desired outcome": desired_outcome } for k, v in required.items(): if not v or (isinstance(v, list) and len(v) == 0): st.error(f"Please complete: {k}") st.stop() # build messages for Groq chat system_prompt = { "role": "system", "content": ( "You are an L&D assistant that generates trainer-ready activity packs. " "Output only a JSON object (no extra commentary) with keys: activity_name, objective, why, scenario, " "character_sketches (list of objects with role+description), trainer_notes (list), props (list), " "debrief_questions (3-5), kirkpatrick (dict), success_indicators (list), export_text, dialogues (optional)." ) } user_content = ( f"Industry: {industry}\nCategory: {category}\nMessage: {message}\nAudience: {', '.join(audience)}\n" f"Batch size: {batch_size}\nDuration: {duration}\nDelivery: {delivery}\nStage: {training_stage}\n" f"Scenario context: {scenario_context}\nDesired outcome: {desired_outcome}\nInclude dialogues: {include_dialogues}" ) user_message = {"role": "user", "content": user_content} with st.spinner("Calling LLM (Groq)…"): try: llm_text = call_groq_chat([system_prompt, user_message], max_tokens=2000) except Exception as e: st.error(f"LLM call failed: {e}") st.stop() # attempt to parse JSON from the response (works even if LLM wraps text) parsed = None try: parsed = json.loads(llm_text) except Exception: m = re.search(r'\{(?:.|\n)*\}', str(llm_text)) if m: try: parsed = json.loads(m.group(0)) except Exception: parsed = None if not parsed: st.error("Failed to parse JSON from LLM output. Raw output is shown for debugging.") st.code(llm_text) st.stop() # show results st.markdown("### Activity Pack — Primary Recommendation") st.markdown(f"**{parsed.get('activity_name')}**") st.markdown(f"*Objective:* {parsed.get('objective')}") st.markdown(f"*Why this works:* {parsed.get('why')}") with st.expander("Scenario & Character Sketches"): st.write(parsed.get('scenario')) for cs in parsed.get('character_sketches', []): st.write(f"**{cs.get('role')}** — {cs.get('description')}") if include_dialogues and parsed.get('dialogues'): st.write("**Sample dialogues:**") st.write(parsed.get('dialogues')) with st.expander("Trainer Notes & Run-sheet"): for i, n in enumerate(parsed.get('trainer_notes', []), 1): st.write(f"{i}. {n}") with st.expander("Debrief Questions"): for q in parsed.get('debrief_questions', []): st.write(f"- {q}") with st.expander("Kirkpatrick & Success indicators"): st.json(parsed.get('kirkpatrick', {})) st.write("Success indicators:") for s in parsed.get('success_indicators', []): st.write(f"- {s}") # Save and export sid = save_session(parsed.get('activity_name', 'unnamed'), industry, {"form": {"category": category, "message": message}, "result": parsed}, user=username) st.success("Saved to History (local).") if st.button("Export as PDF"): fname = f"{APP_TITLE.replace(' ', '_')}_{sid}.pdf" path = export_pdf(parsed, filename=fname) st.success(f"Exported PDF: {path}") # update exported flag (simple) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("UPDATE sessions SET exported=1 WHERE id=?", (sid,)) conn.commit() conn.close() if st.button("Download .txt (full pack)"): st.download_button("Download Pack as TXT", json.dumps(parsed, indent=2), file_name=f"{sid}.txt") st.markdown("---") st.subheader("History") q = st.text_input("Filter by activity name", "") rows = fetch_history(filter_name=q) if rows: for r in rows: st.write(f"- {r[3]} · {r[1][:19]} · {r[4]} · id:{r[0]}") else: st.write("No history yet.") st.caption("Prototype note: secrets (GROQ_API_KEY) should be stored in environment or platform secrets, not in code.") if __name__ == "__main__": main()