This page shows a representative message sequence from when a user asks for flashcards about ATP.
+
A2UI enables agents to generate rich UI components as declarative JSON data, not executable code. The client application renders these using its own native components.
+
+
+
Browser Client
+ →
+
Local API Server (api-server.ts)
+ →
+
Remote Agent (Vertex AI Agent Engine)
+ →
+
Local API Server
+ →
+
Browser Renders
+
+
+
+
+
+
+
+
+
1
+ Client → Server
+ /api/chat (Intent Detection)
+
+
+
+ User types "Create flashcards for bond energy misconceptions". The orchestrator first sends this to Gemini to detect the user's intent.
+
+
+
+ Request Payload
+ JSON
+
+
+
+
+
+
+
+
+
+
2
+ Server → Client
+ /api/chat (Intent Classification)
+
+
+
+ Gemini classifies the intent as "flashcards". This tells the orchestrator to fetch A2UI content from the specialized agent.
+
+
+
+ Response
+ JSON
+
+
+
+
+
+
+
+
+
+
3
+ Client → Server
+ /a2ui-agent/a2a/query
+
+
+
+ The browser requests A2UI content generation. It specifies format: flashcards and includes context about what the user asked for.
+
+
+
+ Request Payload
+ JSON
+
+
+
+
+
+
+
+
+
+
4
+ Server → Remote Agent
+ Deployed to Vertex AI Agent Engine
+
+
+
+ The server forwards the request to a remote A2UI-generating agent deployed to Vertex AI Agent Engine. This agent is a separate service that specializes in generating A2UI content.
+
+ Key Point: This is the A2A (Agent-to-Agent) pattern described in the blog. The orchestrator delegates UI generation to a specialized remote agent deployed on Vertex AI Agent Engine - demonstrating cross-boundary agent collaboration.
+
+
+
+
+
+
+
+
5
+ Remote Agent → Server
+ Response from Vertex AI Agent Engine
+
+
+
+ The remote agent (deployed on Agent Engine) returns A2UI JSON - a declarative description of UI components. This is the core of what A2UI provides.
+
+
+
+ A2UI Messages (Parsed)
+ JSON • Scroll to see full payload
+
+
+
+
+
Understanding the A2UI Structure
+
beginRendering - Declares the surface and root component ID
+ surfaceUpdate - Contains the flat list of components with ID references
+ Components - Text, Column, Row, Flashcard - these are catalog components the client knows how to render
+
+
+
+
+
+
+
+
6
+ Server → Client
+ /a2ui-agent/a2a/query
+
+
+
+ The server sends the A2UI payload to the browser. The @a2ui/web-lib renderer processes this JSON and creates native web components.
+
+
+
+ Final Response to Browser
+ JSON
+
+
+
+
+ Result: The browser's A2UI renderer creates <a2ui-surface>, which contains <a2ui-flashcard> components styled to match the host application's theme.
+
+
+
+
+
+
+
+
+
diff --git a/samples/personalized_learning/agent/.env.template b/samples/personalized_learning/agent/.env.template
new file mode 100644
index 00000000..da5bc32a
--- /dev/null
+++ b/samples/personalized_learning/agent/.env.template
@@ -0,0 +1,15 @@
+# Personalized Learning Agent Configuration
+
+# Google Cloud Project (required)
+GOOGLE_CLOUD_PROJECT=your-project-id
+
+# Optional: GCS bucket for learner context data
+# If not set, loads from local learner_context/ directory
+# GCS_CONTEXT_BUCKET=your-bucket-name
+# GCS_CONTEXT_PREFIX=learner_context/
+
+# Model configuration
+LITELLM_MODEL=gemini-2.5-flash
+
+# Server configuration (for local development)
+PORT=8081
diff --git a/samples/personalized_learning/agent/Dockerfile b/samples/personalized_learning/agent/Dockerfile
new file mode 100644
index 00000000..3c2fd0c4
--- /dev/null
+++ b/samples/personalized_learning/agent/Dockerfile
@@ -0,0 +1,24 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Install dependencies
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy agent code
+COPY *.py ./
+
+# Create learner context directory
+RUN mkdir -p /app/learner_context
+
+# Set environment variables
+ENV PORT=8080
+# GOOGLE_CLOUD_PROJECT must be set at runtime
+ENV GOOGLE_CLOUD_LOCATION=us-central1
+
+# Expose port
+EXPOSE 8080
+
+# Run the server
+CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]
diff --git a/samples/personalized_learning/agent/a2ui_templates.py b/samples/personalized_learning/agent/a2ui_templates.py
new file mode 100644
index 00000000..4171aadb
--- /dev/null
+++ b/samples/personalized_learning/agent/a2ui_templates.py
@@ -0,0 +1,409 @@
+"""
+A2UI Templates for Learning Materials
+
+Provides templates and examples for generating A2UI JSON payloads
+for various learning material formats.
+"""
+
+SURFACE_ID = "learningContent"
+
+# Flashcard A2UI template example
+FLASHCARD_EXAMPLE = f"""
+Example A2UI JSON for a set of flashcards:
+
+[
+ {{"beginRendering": {{"surfaceId": "{SURFACE_ID}", "root": "mainColumn"}}}},
+ {{
+ "surfaceUpdate": {{
+ "surfaceId": "{SURFACE_ID}",
+ "components": [
+ {{
+ "id": "mainColumn",
+ "component": {{
+ "Column": {{
+ "children": {{"explicitList": ["headerText", "flashcardRow"]}},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}
+ }}
+ }},
+ {{
+ "id": "headerText",
+ "component": {{
+ "Text": {{
+ "text": {{"literalString": "Study Flashcards: ATP & Bond Energy"}},
+ "usageHint": "h2"
+ }}
+ }}
+ }},
+ {{
+ "id": "flashcardRow",
+ "component": {{
+ "Row": {{
+ "children": {{"explicitList": ["card1", "card2", "card3"]}},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}
+ }}
+ }},
+ {{
+ "id": "card1",
+ "component": {{
+ "Flashcard": {{
+ "front": {{"literalString": "What happens when ATP is hydrolyzed?"}},
+ "back": {{"literalString": "ATP + H2O → ADP + Pi + Energy. Energy is released because the products are MORE STABLE than ATP."}},
+ "category": {{"literalString": "Biochemistry"}}
+ }}
+ }}
+ }},
+ {{
+ "id": "card2",
+ "component": {{
+ "Flashcard": {{
+ "front": {{"literalString": "Does breaking a bond release or require energy?"}},
+ "back": {{"literalString": "Breaking ANY bond REQUIRES energy input. Energy is released when new, more stable bonds FORM."}},
+ "category": {{"literalString": "Chemistry"}}
+ }}
+ }}
+ }},
+ {{
+ "id": "card3",
+ "component": {{
+ "Flashcard": {{
+ "front": {{"literalString": "Why is 'energy stored in bonds' misleading?"}},
+ "back": {{"literalString": "Bonds don't store energy like batteries. Energy comes from the STABILITY DIFFERENCE between reactants and products."}},
+ "category": {{"literalString": "MCAT Concept"}}
+ }}
+ }}
+ }}
+ ]
+ }}
+ }}
+]
+"""
+
+# Audio/Podcast A2UI template
+AUDIO_EXAMPLE = f"""
+Example A2UI JSON for an audio player (podcast):
+
+[
+ {{"beginRendering": {{"surfaceId": "{SURFACE_ID}", "root": "audioCard"}}}},
+ {{
+ "surfaceUpdate": {{
+ "surfaceId": "{SURFACE_ID}",
+ "components": [
+ {{
+ "id": "audioCard",
+ "component": {{
+ "Card": {{
+ "child": "audioContent"
+ }}
+ }}
+ }},
+ {{
+ "id": "audioContent",
+ "component": {{
+ "Column": {{
+ "children": {{"explicitList": ["audioHeader", "audioPlayer", "audioDescription"]}},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}
+ }}
+ }},
+ {{
+ "id": "audioHeader",
+ "component": {{
+ "Row": {{
+ "children": {{"explicitList": ["audioIcon", "audioTitle"]}},
+ "distribution": "start",
+ "alignment": "center"
+ }}
+ }}
+ }},
+ {{
+ "id": "audioIcon",
+ "component": {{
+ "Icon": {{
+ "name": {{"literalString": "podcasts"}}
+ }}
+ }}
+ }},
+ {{
+ "id": "audioTitle",
+ "component": {{
+ "Text": {{
+ "text": {{"literalString": "ATP & Chemical Stability Podcast"}},
+ "usageHint": "h3"
+ }}
+ }}
+ }},
+ {{
+ "id": "audioPlayer",
+ "component": {{
+ "AudioPlayer": {{
+ "url": {{"literalString": "/assets/podcast.m4a"}},
+ "audioTitle": {{"literalString": "Understanding ATP Energy Release"}},
+ "audioDescription": {{"literalString": "A personalized podcast about ATP and chemical stability"}}
+ }}
+ }}
+ }},
+ {{
+ "id": "audioDescription",
+ "component": {{
+ "Text": {{
+ "text": {{"literalString": "This 10-minute podcast explains why 'energy stored in bonds' is a misconception and how to think about ATP correctly for the MCAT."}},
+ "usageHint": "body"
+ }}
+ }}
+ }}
+ ]
+ }}
+ }}
+]
+"""
+
+# Video A2UI template
+VIDEO_EXAMPLE = f"""
+Example A2UI JSON for a video player:
+
+[
+ {{"beginRendering": {{"surfaceId": "{SURFACE_ID}", "root": "videoCard"}}}},
+ {{
+ "surfaceUpdate": {{
+ "surfaceId": "{SURFACE_ID}",
+ "components": [
+ {{
+ "id": "videoCard",
+ "component": {{
+ "Card": {{
+ "child": "videoContent"
+ }}
+ }}
+ }},
+ {{
+ "id": "videoContent",
+ "component": {{
+ "Column": {{
+ "children": {{"explicitList": ["videoTitle", "videoPlayer", "videoDescription"]}},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}
+ }}
+ }},
+ {{
+ "id": "videoTitle",
+ "component": {{
+ "Text": {{
+ "text": {{"literalString": "Visual Explanation: Bond Energy vs. Stability"}},
+ "usageHint": "h3"
+ }}
+ }}
+ }},
+ {{
+ "id": "videoPlayer",
+ "component": {{
+ "Video": {{
+ "url": {{"literalString": "/assets/demo.mp4"}}
+ }}
+ }}
+ }},
+ {{
+ "id": "videoDescription",
+ "component": {{
+ "Text": {{
+ "text": {{"literalString": "Watch this animated explanation of why breaking bonds requires energy and how ATP hydrolysis actually works."}},
+ "usageHint": "body"
+ }}
+ }}
+ }}
+ ]
+ }}
+ }}
+]
+"""
+
+# QuizCard template - interactive quiz cards with immediate feedback
+QUIZ_EXAMPLE = f"""
+Example A2UI JSON for quiz cards (interactive multiple choice with feedback):
+
+[
+ {{"beginRendering": {{"surfaceId": "{SURFACE_ID}", "root": "mainColumn"}}}},
+ {{
+ "surfaceUpdate": {{
+ "surfaceId": "{SURFACE_ID}",
+ "components": [
+ {{
+ "id": "mainColumn",
+ "component": {{
+ "Column": {{
+ "children": {{"explicitList": ["headerText", "quizRow"]}},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}
+ }}
+ }},
+ {{
+ "id": "headerText",
+ "component": {{
+ "Text": {{
+ "text": {{"literalString": "Quick Quiz: ATP & Bond Energy"}},
+ "usageHint": "h3"
+ }}
+ }}
+ }},
+ {{
+ "id": "quizRow",
+ "component": {{
+ "Row": {{
+ "children": {{"explicitList": ["quiz1", "quiz2"]}},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}
+ }}
+ }},
+ {{
+ "id": "quiz1",
+ "component": {{
+ "QuizCard": {{
+ "question": {{"literalString": "What happens to energy when ATP is hydrolyzed?"}},
+ "options": [
+ {{"label": {{"literalString": "Energy stored in the phosphate bond is released"}}, "value": "a", "isCorrect": false}},
+ {{"label": {{"literalString": "Energy is released because products are more stable"}}, "value": "b", "isCorrect": true}},
+ {{"label": {{"literalString": "The bond breaking itself releases energy"}}, "value": "c", "isCorrect": false}},
+ {{"label": {{"literalString": "ATP's special bonds contain more electrons"}}, "value": "d", "isCorrect": false}}
+ ],
+ "explanation": {{"literalString": "ATP hydrolysis releases energy because the products (ADP + Pi) are MORE STABLE than ATP. The phosphate groups in ATP repel each other, creating strain. When this bond is broken, the products achieve better resonance stabilization - like releasing a compressed spring."}},
+ "category": {{"literalString": "Thermodynamics"}}
+ }}
+ }}
+ }},
+ {{
+ "id": "quiz2",
+ "component": {{
+ "QuizCard": {{
+ "question": {{"literalString": "Breaking a chemical bond requires or releases energy?"}},
+ "options": [
+ {{"label": {{"literalString": "Always releases energy"}}, "value": "a", "isCorrect": false}},
+ {{"label": {{"literalString": "Always requires energy input"}}, "value": "b", "isCorrect": true}},
+ {{"label": {{"literalString": "Depends on whether it's a high-energy bond"}}, "value": "c", "isCorrect": false}},
+ {{"label": {{"literalString": "Neither - bonds are energy neutral"}}, "value": "d", "isCorrect": false}}
+ ],
+ "explanation": {{"literalString": "Breaking ANY bond REQUIRES energy (it's endothermic). This is a common MCAT trap! Energy is only released when NEW bonds FORM. Think of it like pulling apart magnets - you have to put in effort to separate them."}},
+ "category": {{"literalString": "Bond Energy"}}
+ }}
+ }}
+ }}
+ ]
+ }}
+ }}
+]
+"""
+
+
+def get_system_prompt(format_type: str, context: str) -> str:
+ """
+ Generate the system prompt for A2UI generation.
+
+ Args:
+ format_type: Type of content to generate (flashcards, audio, video, quiz)
+ context: The learner context data
+
+ Returns:
+ System prompt for the LLM
+ """
+ examples = {
+ "flashcards": FLASHCARD_EXAMPLE,
+ "audio": AUDIO_EXAMPLE,
+ "podcast": AUDIO_EXAMPLE,
+ "video": VIDEO_EXAMPLE,
+ "quiz": QUIZ_EXAMPLE,
+ }
+
+ example = examples.get(format_type.lower(), FLASHCARD_EXAMPLE)
+
+ if format_type.lower() == "flashcards":
+ return f"""You are creating MCAT study flashcards for Maria, a pre-med student.
+
+## Maria's Profile
+{context}
+
+## Your Task
+Create 4-5 high-quality flashcards about ATP and bond energy that:
+1. Directly address her misconception that "energy is stored in bonds"
+2. Use sports/gym analogies she loves (compressed springs, holding planks, etc.)
+3. Are MCAT exam-focused with precise scientific language
+4. Have COMPLETE, THOUGHTFUL answers - not placeholders or vague hints
+
+## Flashcard Quality Standards
+GOOD flashcard back:
+"Breaking ANY chemical bond requires energy input - it's endothermic. When ATP is hydrolyzed, the energy released comes from the products (ADP + Pi) being MORE STABLE than ATP. Think of it like releasing a compressed spring - the spring doesn't 'contain' energy, it's just in a high-energy state."
+
+BAD flashcard back:
+"Energy is released because products are more stable." (too vague)
+"Think of ATP like a gym analogy..." (incomplete placeholder)
+
+## A2UI JSON Format
+{example}
+
+## Rules
+- Output ONLY valid JSON - no markdown, no explanation
+- Use surfaceId: "{SURFACE_ID}"
+- Each card needs unique id (card1, card2, etc.)
+- Front: Clear question that tests understanding
+- Back: Complete explanation with analogy where helpful
+- Category: One of "Biochemistry", "Chemistry", "MCAT Concept", "Common Trap"
+
+Generate the flashcards JSON:"""
+
+ if format_type.lower() == "quiz":
+ return f"""You are creating MCAT practice quiz questions for Maria, a pre-med student.
+
+## Maria's Profile
+{context}
+
+## Your Task
+Create 2-3 interactive quiz questions about ATP and bond energy that:
+1. Test her understanding of WHY ATP hydrolysis releases energy
+2. Include plausible wrong answers that reflect common misconceptions
+3. Provide detailed explanations using sports/gym analogies she loves
+4. Are MCAT exam-style with precise scientific language
+
+## QuizCard Component Structure
+Each QuizCard must have:
+- question: The question text
+- options: Array of 4 choices, each with label, value (a/b/c/d), and isCorrect (true/false)
+- explanation: Detailed explanation shown after answering
+- category: Topic category like "Thermodynamics", "Bond Energy", "MCAT Concept"
+
+## A2UI JSON Format
+{example}
+
+## Rules
+- Output ONLY valid JSON - no markdown, no explanation
+- Use surfaceId: "{SURFACE_ID}"
+- Each quiz card needs unique id (quiz1, quiz2, etc.)
+- Exactly ONE option per question should have isCorrect: true
+- Wrong answers should be plausible misconceptions students commonly have
+- Explanations should be thorough and include analogies
+
+Generate the quiz JSON:"""
+
+ return f"""You are an A2UI content generator for personalized learning materials.
+
+## Learner Context
+{context}
+
+## Output Format
+Output valid A2UI JSON starting with beginRendering.
+
+## Template for {format_type}:
+{example}
+
+## Rules:
+1. Use surfaceId: "{SURFACE_ID}"
+2. Address the learner's specific misconceptions
+3. Use sports/gym analogies for Maria
+4. Output ONLY valid JSON
+5. All component IDs must be unique
+
+Generate the A2UI JSON:"""
diff --git a/samples/personalized_learning/agent/agent.py b/samples/personalized_learning/agent/agent.py
new file mode 100644
index 00000000..bb06b387
--- /dev/null
+++ b/samples/personalized_learning/agent/agent.py
@@ -0,0 +1,392 @@
+"""
+Personalized Learning Agent
+
+A2A agent that generates A2UI JSON for personalized learning materials
+based on learner context data.
+
+This agent is designed to be deployed to Agent Engine and called remotely
+from the chat application.
+"""
+
+import json
+import logging
+import os
+from typing import AsyncIterator, Any
+
+from google import genai
+from google.genai import types
+
+from context_loader import get_combined_context, load_context_file
+from a2ui_templates import get_system_prompt, SURFACE_ID
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# Model configuration - use just the model name, SDK adds the path prefix
+# gemini-3-pro-preview requires special access, fallback to gemini-2.5-flash
+MODEL_ID = os.getenv("GENAI_MODEL", "gemini-2.5-flash")
+
+
+class LearningMaterialAgent:
+ """
+ Agent that generates A2UI learning materials.
+
+ Reads learner context and generates A2UI JSON for flashcards,
+ quizzes, and media references.
+ """
+
+ SUPPORTED_FORMATS = ["flashcards", "audio", "podcast", "video", "quiz"]
+
+ def __init__(self, init_client: bool = True):
+ """
+ Initialize the agent.
+
+ Args:
+ init_client: If True, initialize the Gemini client immediately.
+ If False, delay initialization until first use (for testing).
+ """
+ self._client = None
+ self._init_client = init_client
+ self._context_cache: dict[str, str] = {}
+
+ @property
+ def client(self):
+ """Lazily initialize the Gemini client using VertexAI."""
+ if self._client is None:
+ # Use VertexAI with Application Default Credentials
+ project = os.getenv("GOOGLE_CLOUD_PROJECT")
+ location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
+ self._client = genai.Client(
+ vertexai=True,
+ project=project,
+ location=location,
+ )
+ return self._client
+
+ def _get_context(self) -> str:
+ """Load and cache the context data."""
+ if not self._context_cache:
+ self._context_cache["combined"] = get_combined_context()
+ return self._context_cache.get("combined", "")
+
+ async def generate_content(
+ self,
+ format_type: str,
+ additional_context: str = "",
+ ) -> dict[str, Any]:
+ """
+ Generate A2UI content for the specified format.
+
+ Args:
+ format_type: Type of content (flashcards, audio, video, quiz)
+ additional_context: Additional context from the user's request
+
+ Returns:
+ Dict containing A2UI JSON and metadata
+ """
+ logger.info(f"Generating {format_type} content")
+
+ if format_type.lower() not in self.SUPPORTED_FORMATS:
+ return {
+ "error": f"Unsupported format: {format_type}",
+ "supported_formats": self.SUPPORTED_FORMATS,
+ }
+
+ # Handle pre-generated media references
+ if format_type.lower() in ["audio", "podcast"]:
+ return self._get_audio_reference()
+ elif format_type.lower() == "video":
+ return self._get_video_reference()
+
+ # Generate dynamic content with Gemini
+ context = self._get_context()
+ if additional_context:
+ context = f"{context}\n\nUser Request: {additional_context}"
+
+ system_prompt = get_system_prompt(format_type, context)
+
+ try:
+ response = self.client.models.generate_content(
+ model=MODEL_ID,
+ contents=f"Generate {format_type} for this learner.",
+ config=types.GenerateContentConfig(
+ system_instruction=system_prompt,
+ # Note: thinking_level requires a newer SDK or different API
+ # Using thinkingBudget instead for now
+ thinking_config=types.ThinkingConfig(thinkingBudget=1024),
+ response_mime_type="application/json",
+ ),
+ )
+
+ # Parse and validate the response
+ response_text = response.text.strip()
+
+ # Try to parse as JSON
+ try:
+ a2ui_json = json.loads(response_text)
+ logger.info(f"Successfully generated {format_type} A2UI JSON")
+ return {
+ "format": format_type,
+ "a2ui": a2ui_json,
+ "surfaceId": SURFACE_ID,
+ }
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse A2UI JSON: {e}")
+ logger.error(f"Response was: {response_text[:500]}")
+ return {
+ "error": "Failed to generate valid A2UI JSON",
+ "raw_response": response_text[:1000],
+ }
+
+ except Exception as e:
+ logger.error(f"Error generating content: {e}")
+ return {"error": str(e)}
+
+ def _get_audio_reference(self) -> dict[str, Any]:
+ """Return A2UI JSON for the pre-generated podcast."""
+ a2ui = [
+ {"beginRendering": {"surfaceId": SURFACE_ID, "root": "audioCard"}},
+ {
+ "surfaceUpdate": {
+ "surfaceId": SURFACE_ID,
+ "components": [
+ {
+ "id": "audioCard",
+ "component": {"Card": {"child": "audioContent"}},
+ },
+ {
+ "id": "audioContent",
+ "component": {
+ "Column": {
+ "children": {
+ "explicitList": [
+ "audioHeader",
+ "audioPlayer",
+ "audioDescription",
+ ]
+ },
+ "distribution": "start",
+ "alignment": "stretch",
+ }
+ },
+ },
+ {
+ "id": "audioHeader",
+ "component": {
+ "Row": {
+ "children": {
+ "explicitList": ["audioIcon", "audioTitle"]
+ },
+ "distribution": "start",
+ "alignment": "center",
+ }
+ },
+ },
+ {
+ "id": "audioIcon",
+ "component": {
+ "Icon": {"name": {"literalString": "podcasts"}}
+ },
+ },
+ {
+ "id": "audioTitle",
+ "component": {
+ "Text": {
+ "text": {
+ "literalString": "ATP & Chemical Stability: Correcting the Misconception"
+ },
+ "usageHint": "h3",
+ }
+ },
+ },
+ {
+ "id": "audioPlayer",
+ "component": {
+ "AudioPlayer": {
+ "url": {"literalString": "/assets/podcast.m4a"},
+ "audioTitle": {
+ "literalString": "Understanding ATP Energy Release"
+ },
+ "audioDescription": {
+ "literalString": "A personalized podcast about ATP and chemical stability"
+ },
+ }
+ },
+ },
+ {
+ "id": "audioDescription",
+ "component": {
+ "Text": {
+ "text": {
+ "literalString": "This personalized podcast explains why 'energy stored in bonds' is a common misconception. Using your preferred gym analogies, it walks through how ATP hydrolysis actually releases energy through stability differences, not bond breaking. Perfect for your MCAT prep!"
+ },
+ "usageHint": "body",
+ }
+ },
+ },
+ ],
+ }
+ },
+ ]
+
+ return {
+ "format": "audio",
+ "a2ui": a2ui,
+ "surfaceId": SURFACE_ID,
+ }
+
+ def _get_video_reference(self) -> dict[str, Any]:
+ """Return A2UI JSON for the pre-generated video."""
+ a2ui = [
+ {"beginRendering": {"surfaceId": SURFACE_ID, "root": "videoCard"}},
+ {
+ "surfaceUpdate": {
+ "surfaceId": SURFACE_ID,
+ "components": [
+ {
+ "id": "videoCard",
+ "component": {"Card": {"child": "videoContent"}},
+ },
+ {
+ "id": "videoContent",
+ "component": {
+ "Column": {
+ "children": {
+ "explicitList": [
+ "videoTitle",
+ "videoPlayer",
+ "videoDescription",
+ ]
+ },
+ "distribution": "start",
+ "alignment": "stretch",
+ }
+ },
+ },
+ {
+ "id": "videoTitle",
+ "component": {
+ "Text": {
+ "text": {
+ "literalString": "Visual Guide: ATP Energy & Stability"
+ },
+ "usageHint": "h3",
+ }
+ },
+ },
+ {
+ "id": "videoPlayer",
+ "component": {
+ "Video": {
+ "url": {"literalString": "/assets/demo.mp4"},
+ }
+ },
+ },
+ {
+ "id": "videoDescription",
+ "component": {
+ "Text": {
+ "text": {
+ "literalString": "This animated explainer uses the compressed spring analogy to show why ATP releases energy. See how electrostatic repulsion in ATP makes it 'want' to become the more stable ADP + Pi."
+ },
+ "usageHint": "body",
+ }
+ },
+ },
+ ],
+ }
+ },
+ ]
+
+ return {
+ "format": "video",
+ "a2ui": a2ui,
+ "surfaceId": SURFACE_ID,
+ }
+
+ async def stream(
+ self, request: str, session_id: str = "default"
+ ) -> AsyncIterator[dict[str, Any]]:
+ """
+ Stream response for A2A compatibility.
+
+ Args:
+ request: The request string (e.g., "flashcards:bond energy")
+ session_id: Session ID for context
+
+ Yields:
+ Progress updates and final A2UI response
+ """
+ # Parse the request
+ parts = request.split(":", 1)
+ format_type = parts[0].strip().lower()
+ additional_context = parts[1].strip() if len(parts) > 1 else ""
+
+ yield {
+ "is_task_complete": False,
+ "updates": f"Generating {format_type}...",
+ }
+
+ result = await self.generate_content(format_type, additional_context)
+
+ yield {
+ "is_task_complete": True,
+ "content": result,
+ }
+
+
+# Singleton instance
+_agent_instance = None
+
+
+def get_agent() -> LearningMaterialAgent:
+ """Get or create the agent singleton."""
+ global _agent_instance
+ if _agent_instance is None:
+ _agent_instance = LearningMaterialAgent()
+ return _agent_instance
+
+
+# Direct query function for testing
+async def generate_learning_material(
+ format_type: str, additional_context: str = ""
+) -> dict[str, Any]:
+ """
+ Convenience function to generate learning materials.
+
+ Args:
+ format_type: Type of content (flashcards, audio, video, quiz)
+ additional_context: Additional context from user
+
+ Returns:
+ A2UI response dict
+ """
+ agent = get_agent()
+ return await agent.generate_content(format_type, additional_context)
+
+
+if __name__ == "__main__":
+ import asyncio
+
+ async def test():
+ print("Testing LearningMaterialAgent...")
+ print("=" * 50)
+
+ agent = LearningMaterialAgent()
+
+ # Test flashcard generation
+ print("\n1. Testing flashcard generation:")
+ result = await agent.generate_content("flashcards", "focus on bond energy")
+ print(json.dumps(result, indent=2)[:1000])
+
+ # Test audio reference
+ print("\n2. Testing audio reference:")
+ result = await agent.generate_content("audio")
+ print(json.dumps(result, indent=2)[:500])
+
+ # Test video reference
+ print("\n3. Testing video reference:")
+ result = await agent.generate_content("video")
+ print(json.dumps(result, indent=2)[:500])
+
+ asyncio.run(test())
diff --git a/samples/personalized_learning/agent/context_loader.py b/samples/personalized_learning/agent/context_loader.py
new file mode 100644
index 00000000..c02f54a9
--- /dev/null
+++ b/samples/personalized_learning/agent/context_loader.py
@@ -0,0 +1,131 @@
+"""
+Context Loader for Learner Profile Data
+
+Loads learner context from GCS or local filesystem.
+This simulates the handoff from an upstream personalization pipeline.
+"""
+
+import os
+import logging
+from typing import Optional
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# GCS configuration
+GCS_BUCKET = os.getenv("GCS_CONTEXT_BUCKET", "a2ui-demo-context")
+GCS_PREFIX = os.getenv("GCS_CONTEXT_PREFIX", "learner_context/")
+
+# Local fallback path (relative to sample root)
+LOCAL_CONTEXT_PATH = Path(__file__).parent.parent / "learner_context"
+
+
+def _load_from_gcs(filename: str) -> Optional[str]:
+ """Load a context file from GCS."""
+ try:
+ from google.cloud import storage
+
+ client = storage.Client()
+ bucket = client.bucket(GCS_BUCKET)
+ blob = bucket.blob(f"{GCS_PREFIX}{filename}")
+
+ if blob.exists():
+ content = blob.download_as_text()
+ logger.info(f"Loaded {filename} from GCS bucket {GCS_BUCKET}")
+ return content
+ else:
+ logger.warning(f"File {filename} not found in GCS bucket {GCS_BUCKET}")
+ return None
+
+ except Exception as e:
+ logger.warning(f"Failed to load from GCS: {e}")
+ return None
+
+
+def _load_from_local(filename: str) -> Optional[str]:
+ """Load a context file from local filesystem."""
+ filepath = LOCAL_CONTEXT_PATH / filename
+
+ if filepath.exists():
+ content = filepath.read_text()
+ logger.info(f"Loaded {filename} from local path {filepath}")
+ return content
+ else:
+ logger.warning(f"File {filename} not found at local path {filepath}")
+ return None
+
+
+def load_context_file(filename: str) -> Optional[str]:
+ """
+ Load a context file, trying GCS first then falling back to local.
+
+ Args:
+ filename: Name of the context file (e.g., "01_maria_learner_profile.txt")
+
+ Returns:
+ File content as string, or None if not found
+ """
+ # Try GCS first
+ content = _load_from_gcs(filename)
+ if content:
+ return content
+
+ # Fall back to local
+ return _load_from_local(filename)
+
+
+def load_all_context() -> dict[str, str]:
+ """
+ Load all misconception vector context files.
+
+ Returns:
+ Dictionary mapping filename to content
+ """
+ context_files = [
+ "01_maria_learner_profile.txt",
+ "02_chemistry_bond_energy.txt",
+ "03_chemistry_thermodynamics.txt",
+ "04_biology_atp_cellular_respiration.txt",
+ "05_misconception_resolution.txt",
+ "06_mcat_practice_concepts.txt",
+ ]
+
+ context = {}
+ for filename in context_files:
+ content = load_context_file(filename)
+ if content:
+ context[filename] = content
+
+ logger.info(f"Loaded {len(context)} context files")
+ return context
+
+
+def get_learner_profile() -> Optional[str]:
+ """Get the learner profile context."""
+ return load_context_file("01_maria_learner_profile.txt")
+
+
+def get_misconception_context() -> Optional[str]:
+ """Get the misconception resolution context."""
+ return load_context_file("05_misconception_resolution.txt")
+
+
+def get_mcat_concepts() -> Optional[str]:
+ """Get the MCAT practice concepts."""
+ return load_context_file("06_mcat_practice_concepts.txt")
+
+
+def get_combined_context() -> str:
+ """
+ Get all context combined into a single string for prompting.
+
+ Returns:
+ Combined context string
+ """
+ all_context = load_all_context()
+
+ combined = []
+ for filename, content in sorted(all_context.items()):
+ combined.append(f"=== {filename} ===\n{content}\n")
+
+ return "\n".join(combined)
diff --git a/samples/personalized_learning/agent/deploy.py b/samples/personalized_learning/agent/deploy.py
new file mode 100644
index 00000000..ebcccb1f
--- /dev/null
+++ b/samples/personalized_learning/agent/deploy.py
@@ -0,0 +1,702 @@
+#!/usr/bin/env python3
+"""
+Personalized Learning Agent - Self-Contained Agent for Agent Engine Deployment
+
+This agent generates personalized A2UI learning materials (flashcards, audio, video)
+using content from OpenStax and learner context data.
+
+Required environment variables:
+ GOOGLE_CLOUD_PROJECT - Your GCP project ID
+
+Optional environment variables:
+ GOOGLE_CLOUD_LOCATION - GCP region (default: us-central1)
+ LITELLM_MODEL - Model to use (default: gemini-2.5-flash)
+"""
+
+import json
+import logging
+import os
+import re
+import urllib.request
+import urllib.parse
+from typing import Dict, List
+
+from dotenv import load_dotenv
+from google.adk.agents.llm_agent import LlmAgent
+from google.adk.models.lite_llm import LiteLlm
+from google.adk.tools.tool_context import ToolContext
+from google.genai import types
+from google import genai as genai_direct
+
+load_dotenv()
+
+logger = logging.getLogger(__name__)
+
+# ============================================================================
+# Configuration
+# ============================================================================
+
+LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini-2.5-flash")
+
+# ============================================================================
+# Context Data - Learner Profile and Curriculum
+# ============================================================================
+
+LEARNER_PROFILE = """
+## Learner Profile: Maria Santos
+
+**Background:**
+- Pre-med sophomore majoring in Biochemistry
+- Preparing for MCAT in 8 months
+- Works part-time as a pharmacy technician (20 hrs/week)
+
+**Learning Style:**
+- Visual-kinesthetic learner
+- Prefers analogies connecting to real-world applications
+- Responds well to gym/fitness metaphors (exercises regularly)
+- Benefits from spaced repetition for memorization
+
+**Current Progress:**
+- Completed: Cell structure, basic chemistry
+- In progress: Cellular energetics (ATP, metabolism)
+- Struggling with: Thermodynamics concepts, especially Gibbs free energy
+
+**Known Misconceptions:**
+- Believes "energy is stored in bonds" (common misconception)
+- Needs clarification that bond BREAKING releases energy in ATP hydrolysis
+"""
+
+CURRICULUM_CONTEXT = """
+## Current Topic: ATP and Cellular Energy
+
+**Learning Objectives:**
+1. Explain why ATP is considered the "energy currency" of cells
+2. Describe the structure of ATP and how it stores potential energy
+3. Understand that energy is released during hydrolysis due to product stability, not bond breaking
+4. Connect ATP usage to cellular processes like muscle contraction
+
+**Key Concepts:**
+- Adenosine triphosphate structure (adenine + ribose + 3 phosphate groups)
+- Phosphoanhydride bonds and electrostatic repulsion
+- Hydrolysis reaction: ATP + H2O → ADP + Pi + Energy
+- Gibbs free energy change (ΔG = -30.5 kJ/mol)
+- Coupled reactions in cellular metabolism
+
+**Common Misconceptions to Address:**
+- "Energy stored in bonds" - Actually, breaking bonds REQUIRES energy;
+ the energy released comes from forming more stable products (ADP + Pi)
+- ATP is not a long-term energy storage molecule (that's glycogen/fat)
+"""
+
+
+# ============================================================================
+# OpenStax Content Fetching with Intelligent Topic Matching
+# ============================================================================
+
+OPENSTAX_BIOLOGY_BASE = "https://openstax.org/books/biology-ap-courses/pages/"
+
+# Complete OpenStax Biology AP Courses chapter index (167 chapters)
+# Format: slug -> title
+OPENSTAX_CHAPTERS = {
+ "1-1-the-science-of-biology": "The Science of Biology",
+ "2-1-atoms-isotopes-ions-and-molecules-the-building-blocks": "Atoms, Isotopes, Ions, and Molecules",
+ "2-2-water": "Water", "2-3-carbon": "Carbon",
+ "3-2-carbohydrates": "Carbohydrates", "3-3-lipids": "Lipids", "3-4-proteins": "Proteins", "3-5-nucleic-acids": "Nucleic Acids",
+ "4-2-prokaryotic-cells": "Prokaryotic Cells", "4-3-eukaryotic-cells": "Eukaryotic Cells",
+ "4-4-the-endomembrane-system-and-proteins": "The Endomembrane System", "4-5-cytoskeleton": "Cytoskeleton",
+ "5-1-components-and-structure": "Cell Membrane Components and Structure",
+ "5-2-passive-transport": "Passive Transport", "5-3-active-transport": "Active Transport",
+ "6-1-energy-and-metabolism": "Energy and Metabolism",
+ "6-2-potential-kinetic-free-and-activation-energy": "Potential, Kinetic, Free, and Activation Energy",
+ "6-3-the-laws-of-thermodynamics": "The Laws of Thermodynamics",
+ "6-4-atp-adenosine-triphosphate": "ATP: Adenosine Triphosphate", "6-5-enzymes": "Enzymes",
+ "7-1-energy-in-living-systems": "Energy in Living Systems", "7-2-glycolysis": "Glycolysis",
+ "7-3-oxidation-of-pyruvate-and-the-citric-acid-cycle": "Oxidation of Pyruvate and the Citric Acid Cycle",
+ "7-4-oxidative-phosphorylation": "Oxidative Phosphorylation",
+ "7-5-metabolism-without-oxygen": "Metabolism Without Oxygen",
+ "7-7-regulation-of-cellular-respiration": "Regulation of Cellular Respiration",
+ "8-1-overview-of-photosynthesis": "Overview of Photosynthesis",
+ "8-2-the-light-dependent-reaction-of-photosynthesis": "The Light-Dependent Reactions",
+ "8-3-using-light-to-make-organic-molecules": "Using Light to Make Organic Molecules",
+ "9-1-signaling-molecules-and-cellular-receptors": "Signaling Molecules and Cellular Receptors",
+ "10-1-cell-division": "Cell Division", "10-2-the-cell-cycle": "The Cell Cycle",
+ "10-3-control-of-the-cell-cycle": "Control of the Cell Cycle",
+ "10-4-cancer-and-the-cell-cycle": "Cancer and the Cell Cycle",
+ "11-1-the-process-of-meiosis": "The Process of Meiosis", "11-2-sexual-reproduction": "Sexual Reproduction",
+ "12-1-mendels-experiments-and-the-laws-of-probability": "Mendel's Experiments",
+ "12-2-characteristics-and-traits": "Characteristics and Traits", "12-3-laws-of-inheritance": "Laws of Inheritance",
+ "13-1-chromosomal-theory-and-genetic-linkages": "Chromosomal Theory and Genetic Linkages",
+ "13-2-chromosomal-basis-of-inherited-disorders": "Chromosomal Basis of Inherited Disorders",
+ "14-2-dna-structure-and-sequencing": "DNA Structure and Sequencing",
+ "14-3-basics-of-dna-replication": "Basics of DNA Replication", "14-6-dna-repair": "DNA Repair",
+ "15-1-the-genetic-code": "The Genetic Code",
+ "15-2-prokaryotic-transcription": "Prokaryotic Transcription", "15-3-eukaryotic-transcription": "Eukaryotic Transcription",
+ "15-4-rna-processing-in-eukaryotes": "RNA Processing in Eukaryotes",
+ "15-5-ribosomes-and-protein-synthesis": "Ribosomes and Protein Synthesis",
+ "16-1-regulation-of-gene-expression": "Regulation of Gene Expression",
+ "16-7-cancer-and-gene-regulation": "Cancer and Gene Regulation",
+ "17-1-biotechnology": "Biotechnology", "17-2-mapping-genomes": "Mapping Genomes",
+ "17-3-whole-genome-sequencing": "Whole-Genome Sequencing",
+ "18-1-understanding-evolution": "Understanding Evolution", "18-2-formation-of-new-species": "Formation of New Species",
+ "19-1-population-evolution": "Population Evolution", "19-2-population-genetics": "Population Genetics",
+ "19-3-adaptive-evolution": "Adaptive Evolution",
+ "20-2-determining-evolutionary-relationships": "Determining Evolutionary Relationships",
+ "21-1-viral-evolution-morphology-and-classification": "Viral Evolution and Classification",
+ "21-2-virus-infection-and-hosts": "Virus Infection and Hosts",
+ "22-1-prokaryotic-diversity": "Prokaryotic Diversity", "22-4-bacterial-diseases-in-humans": "Bacterial Diseases",
+ "23-1-the-plant-body": "The Plant Body", "23-2-stems": "Stems", "23-3-roots": "Roots", "23-4-leaves": "Leaves",
+ "23-5-transport-of-water-and-solutes-in-plants": "Transport of Water and Solutes in Plants",
+ "24-1-animal-form-and-function": "Animal Form and Function", "24-3-homeostasis": "Homeostasis",
+ "25-1-digestive-systems": "Digestive Systems", "25-3-digestive-system-processes": "Digestive System Processes",
+ "26-1-neurons-and-glial-cells": "Neurons and Glial Cells", "26-2-how-neurons-communicate": "How Neurons Communicate",
+ "26-3-the-central-nervous-system": "The Central Nervous System",
+ "26-4-the-peripheral-nervous-system": "The Peripheral Nervous System",
+ "27-1-sensory-processes": "Sensory Processes", "27-5-vision": "Vision",
+ "27-4-hearing-and-vestibular-sensation": "Hearing and Vestibular Sensation",
+ "28-1-types-of-hormones": "Types of Hormones", "28-2-how-hormones-work": "How Hormones Work",
+ "28-5-endocrine-glands": "Endocrine Glands",
+ "29-1-types-of-skeletal-systems": "Types of Skeletal Systems", "29-2-bone": "Bone",
+ "29-4-muscle-contraction-and-locomotion": "Muscle Contraction and Locomotion",
+ "30-1-systems-of-gas-exchange": "Systems of Gas Exchange", "30-3-breathing": "Breathing",
+ "31-1-overview-of-the-circulatory-system": "Overview of the Circulatory System",
+ "31-2-components-of-the-blood": "Components of the Blood",
+ "31-3-mammalian-heart-and-blood-vessels": "Mammalian Heart and Blood Vessels",
+ "32-1-osmoregulation-and-osmotic-balance": "Osmoregulation and Osmotic Balance",
+ "32-2-the-kidneys-and-osmoregulatory-organs": "The Kidneys and Osmoregulatory Organs",
+ "32-3-excretion-systems": "Excretion Systems",
+ "33-1-innate-immune-response": "Innate Immune Response", "33-2-adaptive-immune-response": "Adaptive Immune Response",
+ "33-3-antibodies": "Antibodies",
+ "34-1-reproduction-methods": "Reproduction Methods",
+ "34-3-human-reproductive-anatomy-and-gametogenesis": "Human Reproductive Anatomy",
+ "34-5-fertilization-and-early-embryonic-development": "Fertilization and Early Embryonic Development",
+ "34-7-human-pregnancy-and-birth": "Human Pregnancy and Birth",
+ "35-1-the-scope-of-ecology": "The Scope of Ecology",
+ "35-3-terrestrial-biomes": "Terrestrial Biomes", "35-4-aquatic-biomes": "Aquatic Biomes",
+ "35-5-climate-and-the-effects-of-global-climate-change": "Climate and Global Climate Change",
+ "36-1-population-demography": "Population Demography",
+ "36-2-life-histories-and-natural-selection": "Life Histories and Natural Selection",
+ "36-3-environmental-limits-to-population-growth": "Environmental Limits to Population Growth",
+ "36-6-community-ecology": "Community Ecology",
+ "37-1-ecology-for-ecosystems": "Ecology for Ecosystems",
+ "37-2-energy-flow-through-ecosystems": "Energy Flow Through Ecosystems",
+ "37-3-biogeochemical-cycles": "Biogeochemical Cycles",
+ "38-1-the-biodiversity-crisis": "The Biodiversity Crisis", "38-4-preserving-biodiversity": "Preserving Biodiversity",
+}
+
+# Keyword hints for fast matching (avoids Gemini call for common topics)
+KEYWORD_HINTS = {
+ "atp": ["6-4-atp-adenosine-triphosphate"], "photosynthesis": ["8-1-overview-of-photosynthesis"],
+ "krebs": ["7-3-oxidation-of-pyruvate-and-the-citric-acid-cycle"], "citric acid": ["7-3-oxidation-of-pyruvate-and-the-citric-acid-cycle"],
+ "glycolysis": ["7-2-glycolysis"], "fermentation": ["7-5-metabolism-without-oxygen"],
+ "mitosis": ["10-1-cell-division"], "meiosis": ["11-1-the-process-of-meiosis"],
+ "cell cycle": ["10-2-the-cell-cycle"], "cancer": ["10-4-cancer-and-the-cell-cycle"],
+ "dna": ["14-2-dna-structure-and-sequencing"], "rna": ["15-4-rna-processing-in-eukaryotes"],
+ "transcription": ["15-2-prokaryotic-transcription"], "translation": ["15-5-ribosomes-and-protein-synthesis"],
+ "protein synthesis": ["15-5-ribosomes-and-protein-synthesis"], "enzyme": ["6-5-enzymes"],
+ "cell membrane": ["5-1-components-and-structure"], "membrane": ["5-1-components-and-structure"],
+ "osmosis": ["5-2-passive-transport"], "diffusion": ["5-2-passive-transport"],
+ "neuron": ["26-1-neurons-and-glial-cells"], "nervous system": ["26-1-neurons-and-glial-cells"],
+ "brain": ["26-3-the-central-nervous-system"], "action potential": ["26-2-how-neurons-communicate"],
+ "heart": ["31-1-overview-of-the-circulatory-system"], "blood": ["31-2-components-of-the-blood"],
+ "circulatory": ["31-1-overview-of-the-circulatory-system"],
+ "immune": ["33-1-innate-immune-response"], "antibod": ["33-3-antibodies"],
+ "respiration": ["30-1-systems-of-gas-exchange"], "breathing": ["30-3-breathing"],
+ "digestion": ["25-1-digestive-systems"], "hormone": ["28-1-types-of-hormones"],
+ "muscle": ["29-4-muscle-contraction-and-locomotion"], "bone": ["29-2-bone"],
+ "kidney": ["32-2-the-kidneys-and-osmoregulatory-organs"],
+ "evolution": ["18-1-understanding-evolution"], "darwin": ["18-1-understanding-evolution"],
+ "natural selection": ["19-3-adaptive-evolution"], "genetics": ["12-1-mendels-experiments-and-the-laws-of-probability"],
+ "mendel": ["12-1-mendels-experiments-and-the-laws-of-probability"],
+ "virus": ["21-1-viral-evolution-morphology-and-classification"], "bacteria": ["22-1-prokaryotic-diversity"],
+ "plant": ["23-1-the-plant-body"], "ecology": ["35-1-the-scope-of-ecology"],
+ "ecosystem": ["37-1-ecology-for-ecosystems"], "climate": ["35-5-climate-and-the-effects-of-global-climate-change"],
+ "biodiversity": ["38-1-the-biodiversity-crisis"],
+}
+
+def get_chapter_list_for_llm() -> str:
+ """Return formatted list of all chapters for LLM context."""
+ return "\n".join([f"- {slug}: {title}" for slug, title in OPENSTAX_CHAPTERS.items()])
+
+
+def match_topic_to_chapter(topic: str) -> str:
+ """
+ Use Gemini to intelligently match a user's topic query to the best OpenStax chapter.
+ Returns the chapter slug or empty string if no match.
+ """
+ topic_lower = topic.lower().strip()
+
+ # First, try quick keyword matching for common topics
+ for keyword, slugs in KEYWORD_HINTS.items():
+ if keyword in topic_lower:
+ logger.info(f"Quick match: '{topic}' -> {slugs[0]} (keyword: {keyword})")
+ return slugs[0]
+
+ # Check for direct slug match or title match
+ for slug, title in OPENSTAX_CHAPTERS.items():
+ if topic_lower in slug.replace("-", " ") or topic_lower in title.lower():
+ logger.info(f"Direct match: '{topic}' -> {slug}")
+ return slug
+
+ # Use Gemini for intelligent matching
+ logger.info(f"Using Gemini to match topic: '{topic}'")
+
+ chapter_list = get_chapter_list_for_llm()
+
+ prompt = f"""You are a biology textbook expert. Match the user's topic to the BEST OpenStax Biology chapter.
+
+User's topic: "{topic}"
+
+Available chapters (slug: title):
+{chapter_list}
+
+Instructions:
+1. Find the chapter that BEST covers this topic
+2. Consider synonyms and related concepts (e.g., "nerves" -> neurons, "breathing" -> respiration)
+3. Return ONLY the chapter slug (e.g., "26-1-neurons-and-glial-cells")
+4. If no chapter is relevant, return "NONE"
+
+Best matching chapter slug:"""
+
+ try:
+ project = os.getenv("GOOGLE_CLOUD_PROJECT")
+ location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
+ client = genai_direct.Client(vertexai=True, project=project, location=location)
+ response = client.models.generate_content(
+ model="gemini-2.0-flash",
+ contents=prompt,
+ )
+ result = response.text.strip()
+
+ # Clean up response
+ result = result.replace('"', '').replace("'", "").strip()
+
+ # Validate it's a real chapter
+ if result in OPENSTAX_CHAPTERS:
+ logger.info(f"Gemini matched: '{topic}' -> {result}")
+ return result
+ elif result != "NONE":
+ # Try partial match in case Gemini returned a close variant
+ for slug in OPENSTAX_CHAPTERS:
+ if result in slug or slug in result:
+ logger.info(f"Gemini partial match: '{topic}' -> {slug}")
+ return slug
+
+ logger.info(f"No Gemini match found for: '{topic}'")
+ return ""
+
+ except Exception as e:
+ logger.error(f"Gemini matching failed: {e}")
+ return ""
+
+
+def fetch_openstax_content(topic: str) -> Dict[str, str]:
+ """Fetch content from OpenStax Biology textbook for a given topic.
+
+ Returns:
+ Dict with 'content', 'url', and 'title' keys (empty strings if no match)
+ """
+ # Use intelligent matching to find the best chapter
+ chapter_slug = match_topic_to_chapter(topic)
+
+ if not chapter_slug:
+ logger.info(f"No chapter match for topic '{topic}'")
+ return {"content": "", "url": "", "title": ""}
+
+ url = OPENSTAX_BIOLOGY_BASE + chapter_slug
+ title = OPENSTAX_CHAPTERS.get(chapter_slug, "OpenStax Biology")
+ logger.info(f"Fetching OpenStax content from: {url}")
+
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
+ with urllib.request.urlopen(req, timeout=10) as response:
+ html = response.read().decode("utf-8")
+
+ # Extract text content (simple extraction - remove HTML tags)
+ # Remove script and style elements
+ html = re.sub(r"", "", html, flags=re.DOTALL)
+ html = re.sub(r"", "", html, flags=re.DOTALL)
+ # Remove HTML tags
+ text = re.sub(r"<[^>]+>", " ", html)
+ # Clean up whitespace
+ text = re.sub(r"\s+", " ", text).strip()
+ # Limit to first ~8000 chars for context window
+ return {"content": text[:8000], "url": url, "title": title}
+ except Exception as e:
+ logger.error(f"Failed to fetch OpenStax content: {e}")
+ return {"content": "", "url": url, "title": title}
+
+
+def generate_flashcards_from_content(topic: str, content: str, count: int = 5) -> List[Dict[str, str]]:
+ """Use Gemini to generate flashcards from textbook content."""
+ if not content:
+ return []
+
+ prompt = f"""Based on this educational content about {topic}, create {count} study flashcards.
+Each flashcard should have a question (front) and answer (back).
+Focus on key concepts, definitions, and important relationships.
+Make the answers concise but complete.
+
+Content:
+{content}
+
+Return ONLY a JSON array with this exact format (no markdown, no explanation):
+[
+ {{"front": "Question 1?", "back": "Answer 1"}},
+ {{"front": "Question 2?", "back": "Answer 2"}}
+]"""
+
+ try:
+ # Use google-genai SDK (newer API)
+ project = os.getenv("GOOGLE_CLOUD_PROJECT")
+ location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
+ client = genai_direct.Client(vertexai=True, project=project, location=location)
+ response = client.models.generate_content(
+ model="gemini-2.0-flash",
+ contents=prompt,
+ )
+ text = response.text.strip()
+
+ # Clean up markdown if present
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```$", "", text)
+
+ cards = json.loads(text)
+ return cards[:count] if isinstance(cards, list) else []
+ except Exception as e:
+ logger.error(f"Failed to generate flashcards with Gemini: {e}")
+ return []
+
+
+# ============================================================================
+# Tool Functions
+# ============================================================================
+
+def generate_flashcards(topic: str = "ATP", count: int = 5, tool_context: ToolContext = None) -> str:
+ """
+ Generate A2UI flashcards for the specified topic.
+ For ATP, uses pre-built content. For other topics, fetches from OpenStax and generates dynamically.
+
+ Args:
+ topic: The topic to generate flashcards for (default: ATP)
+ count: Number of flashcards to generate (default: 5)
+ tool_context: ADK tool context (optional)
+
+ Returns:
+ JSON string with A2UI content and source attribution
+ """
+ topic_lower = topic.lower()
+
+ # Track source info for attribution
+ source_url = ""
+ source_title = ""
+
+ # Check if this is an ATP-related topic (use pre-built content)
+ if "atp" in topic_lower or "bond energy" in topic_lower or "energy currency" in topic_lower:
+ source_url = "https://openstax.org/books/biology-ap-courses/pages/6-4-atp-adenosine-triphosphate"
+ source_title = "ATP: Adenosine Triphosphate"
+ flashcards_data = [
+ {
+ "front": "What is ATP and why is it called the 'energy currency' of cells?",
+ "back": "ATP (Adenosine Triphosphate) is a molecule that stores and transfers energy in cells. It's called 'energy currency' because cells 'spend' ATP to power cellular work, just like you spend money for goods and services."
+ },
+ {
+ "front": "Why is 'energy stored in bonds' a misconception about ATP?",
+ "back": "Breaking bonds actually REQUIRES energy, not releases it. ATP releases energy because the products (ADP + Pi) are more stable than ATP. Think of it like a compressed spring - ATP is 'tense' due to repelling phosphate groups."
+ },
+ {
+ "front": "What happens during ATP hydrolysis?",
+ "back": "ATP + H₂O → ADP + Pi + Energy (ΔG = -30.5 kJ/mol). Water breaks the terminal phosphate bond. Energy is released because ADP + Pi have less electrostatic repulsion and are more stable."
+ },
+ {
+ "front": "How is ATP like a rechargeable battery?",
+ "back": "Like a battery, ATP can be 'recharged': ADP + Pi + Energy → ATP. This happens during cellular respiration. The cell constantly cycles between ATP (charged) and ADP (discharged)."
+ },
+ {
+ "front": "Why do the phosphate groups in ATP repel each other?",
+ "back": "Each phosphate group has negative charges. Like charges repel, creating 'electrostatic stress' in ATP. When the terminal phosphate is removed, this stress is relieved, making ADP more stable."
+ },
+ ]
+ else:
+ # For other topics, try to fetch from OpenStax and generate dynamically
+ logger.info(f"Generating flashcards for non-ATP topic: {topic}")
+ openstax_result = fetch_openstax_content(topic)
+
+ if openstax_result["content"]:
+ logger.info(f"Fetched {len(openstax_result['content'])} chars from OpenStax")
+ source_url = openstax_result["url"]
+ source_title = openstax_result["title"]
+ flashcards_data = generate_flashcards_from_content(topic, openstax_result["content"], count)
+ else:
+ # Fallback: generate flashcards using Gemini without OpenStax content
+ logger.info(f"No OpenStax content found, generating from general knowledge")
+ flashcards_data = generate_flashcards_from_content(
+ topic,
+ f"Generate educational flashcards about {topic} for an AP Biology / MCAT student.",
+ count
+ )
+
+ if not flashcards_data:
+ # Ultimate fallback
+ flashcards_data = [
+ {"front": f"What is {topic}?", "back": f"This topic requires further study. Check your textbook for details on {topic}."}
+ ]
+
+ # Build A2UI components
+ actual_count = min(count, len(flashcards_data))
+ components = [
+ {"id": "flashcardsRow", "component": {"Row": {
+ "children": {"explicitList": [f"flashcard{i}" for i in range(actual_count)]},
+ "distribution": "center",
+ "alignment": "start",
+ "wrap": True
+ }}}
+ ]
+
+ for i, card in enumerate(flashcards_data[:actual_count]):
+ components.append({
+ "id": f"flashcard{i}",
+ "component": {"Flashcard": {
+ "front": {"literalString": card.get("front", "Question")},
+ "back": {"literalString": card.get("back", "Answer")}
+ }}
+ })
+
+ a2ui = [
+ {"beginRendering": {"surfaceId": "learningContent", "root": "flashcardsRow"}},
+ {"surfaceUpdate": {"surfaceId": "learningContent", "components": components}}
+ ]
+
+ # Return with source metadata for attribution
+ result = {
+ "a2ui": a2ui,
+ "source": {
+ "url": source_url,
+ "title": source_title,
+ "provider": "OpenStax Biology for AP Courses"
+ } if source_url else None
+ }
+
+ return json.dumps(result)
+
+
+def get_audio_content(tool_context: ToolContext = None) -> str:
+ """
+ Get the personalized podcast/audio content.
+
+ Args:
+ tool_context: ADK tool context (optional)
+
+ Returns:
+ A2UI JSON string for rendering the audio player
+ """
+ a2ui = [
+ {"beginRendering": {"surfaceId": "learningContent", "root": "audioCard"}},
+ {"surfaceUpdate": {"surfaceId": "learningContent", "components": [
+ {"id": "audioCard", "component": {"Card": {"child": "audioContent"}}},
+ {"id": "audioContent", "component": {"Column": {
+ "children": {"explicitList": ["audioHeader", "audioPlayer", "audioDescription"]},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}},
+ {"id": "audioHeader", "component": {"Row": {
+ "children": {"explicitList": ["audioIcon", "audioTitle"]},
+ "distribution": "start",
+ "alignment": "center"
+ }}},
+ {"id": "audioIcon", "component": {"Icon": {"name": {"literalString": "podcasts"}}}},
+ {"id": "audioTitle", "component": {"Text": {
+ "text": {"literalString": "ATP & Chemical Stability: Correcting the Misconception"},
+ "usageHint": "h3"
+ }}},
+ {"id": "audioPlayer", "component": {"AudioPlayer": {
+ "url": {"literalString": "/assets/podcast.m4a"},
+ "audioTitle": {"literalString": "Understanding ATP Energy Release"},
+ "audioDescription": {"literalString": "A personalized podcast about ATP and chemical stability"}
+ }}},
+ {"id": "audioDescription", "component": {"Text": {
+ "text": {"literalString": "This personalized podcast explains why 'energy stored in bonds' is a common misconception. Using gym analogies, it walks through how ATP hydrolysis actually releases energy through stability differences."},
+ "usageHint": "body"
+ }}}
+ ]}}
+ ]
+ return json.dumps(a2ui)
+
+
+def get_video_content(tool_context: ToolContext = None) -> str:
+ """
+ Get the educational video content.
+
+ Args:
+ tool_context: ADK tool context (optional)
+
+ Returns:
+ A2UI JSON string for rendering the video player
+ """
+ a2ui = [
+ {"beginRendering": {"surfaceId": "learningContent", "root": "videoCard"}},
+ {"surfaceUpdate": {"surfaceId": "learningContent", "components": [
+ {"id": "videoCard", "component": {"Card": {"child": "videoContent"}}},
+ {"id": "videoContent", "component": {"Column": {
+ "children": {"explicitList": ["videoTitle", "videoPlayer", "videoDescription"]},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}},
+ {"id": "videoTitle", "component": {"Text": {
+ "text": {"literalString": "Visual Guide: ATP Energy & Stability"},
+ "usageHint": "h3"
+ }}},
+ {"id": "videoPlayer", "component": {"Video": {
+ "url": {"literalString": "/assets/demo.mp4"}
+ }}},
+ {"id": "videoDescription", "component": {"Text": {
+ "text": {"literalString": "This animated explainer uses the compressed spring analogy to show why ATP releases energy. See how electrostatic repulsion in ATP makes it 'want' to become the more stable ADP + Pi."},
+ "usageHint": "body"
+ }}}
+ ]}}
+ ]
+ return json.dumps(a2ui)
+
+
+def get_learner_context(tool_context: ToolContext = None) -> str:
+ """
+ Get information about the current learner's profile.
+
+ Args:
+ tool_context: ADK tool context (optional)
+
+ Returns:
+ JSON string with learner profile information
+ """
+ return json.dumps({
+ "name": "Maria Santos",
+ "level": "Pre-med sophomore",
+ "current_topic": "ATP and Cellular Energy",
+ "learning_style": "Visual-kinesthetic, prefers gym/fitness analogies",
+ "known_misconceptions": ["Energy stored in bonds"],
+ "progress": {
+ "completed": ["Cell structure", "Basic chemistry"],
+ "in_progress": ["Cellular energetics"],
+ "struggling_with": ["Thermodynamics", "Gibbs free energy"]
+ }
+ }, indent=2)
+
+
+# ============================================================================
+# Agent Instruction
+# ============================================================================
+
+AGENT_INSTRUCTION = f"""You are a personalized learning material generator that creates A2UI JSON content for educational materials.
+
+## Learner Context
+{LEARNER_PROFILE}
+
+## Curriculum Context
+{CURRICULUM_CONTEXT}
+
+## Your Task
+When asked to generate learning materials, use the appropriate tool and return the A2UI JSON directly.
+Your response MUST be ONLY the A2UI JSON returned by the tools - no explanatory text, no markdown formatting.
+
+## Available Content Types
+1. **Flashcards** - Call `generate_flashcards` tool for spaced repetition cards
+2. **Audio/Podcast** - Call `get_audio_content` tool for the personalized podcast
+3. **Video** - Call `get_video_content` tool for the educational video
+
+## Response Format
+CRITICAL: Your response should be ONLY the raw A2UI JSON array returned by the tools.
+Do NOT wrap it in markdown code blocks.
+Do NOT add any explanatory text before or after.
+The JSON will be rendered as interactive UI components.
+
+## Example
+User: "Generate flashcards"
+You: [call generate_flashcards tool and return its output directly]
+
+## Personalization Notes
+- Use Maria's preferred gym/fitness analogies when possible
+- Address her misconception about "energy stored in bonds"
+- Keep content MCAT-focused
+- Make flashcard answers concise but complete
+"""
+
+
+# ============================================================================
+# Agent Builder Function
+# ============================================================================
+
+def build_root_agent() -> LlmAgent:
+ """
+ Build the root LLM agent for Agent Engine deployment.
+
+ Returns:
+ LlmAgent configured for personalized learning material generation.
+ """
+ return LlmAgent(
+ model=LiteLlm(model=LITELLM_MODEL),
+ name="personalized_learning_agent",
+ description="An agent that generates personalized A2UI learning materials including flashcards, audio, and video content.",
+ instruction=AGENT_INSTRUCTION,
+ tools=[generate_flashcards, get_audio_content, get_video_content, get_learner_context],
+ )
+
+
+# Export the root agent for Agent Engine deployment
+root_agent = build_root_agent()
+
+
+# ============================================================================
+# CLI for deployment
+# ============================================================================
+
+if __name__ == "__main__":
+ import argparse
+
+ parser = argparse.ArgumentParser(description="Deploy the Personalized Learning Agent to Agent Engine")
+ parser.add_argument("--project", type=str, default=os.getenv("GOOGLE_CLOUD_PROJECT"))
+ parser.add_argument("--location", type=str, default="us-central1")
+ parser.add_argument("--list", action="store_true", help="List deployed agents")
+
+ args = parser.parse_args()
+
+ if not args.project:
+ print("ERROR: --project flag or GOOGLE_CLOUD_PROJECT environment variable is required")
+ exit(1)
+
+ import vertexai
+ from vertexai import agent_engines
+
+ staging_bucket = f"gs://{args.project}_cloudbuild"
+ vertexai.init(project=args.project, location=args.location, staging_bucket=staging_bucket)
+
+ if args.list:
+ print(f"\nDeployed agents in {args.project} ({args.location}):")
+ for engine in agent_engines.list():
+ print(f" - {engine.display_name}: {engine.resource_name}")
+ else:
+ print(f"Deploying Personalized Learning Agent...")
+ print(f" Project: {args.project}")
+ print(f" Location: {args.location}")
+
+ # Wrap in AdkApp for proper method exposure
+ deployed_agent_app = agent_engines.AdkApp(
+ agent=root_agent,
+ enable_tracing=True,
+ )
+
+ # Deploy using agent_engines.create()
+ deployed = agent_engines.create(
+ deployed_agent_app,
+ display_name="Personalized Learning Agent",
+ requirements=[
+ "google-adk>=1.15.1",
+ "google-genai",
+ "cloudpickle==3.1.1",
+ "python-dotenv",
+ "litellm",
+ ],
+ )
+
+ print(f"\nDEPLOYMENT SUCCESSFUL!")
+ print(f"Resource Name: {deployed.resource_name}")
+ resource_id = deployed.resource_name.split("/")[-1]
+ print(f"Resource ID: {resource_id}")
diff --git a/samples/personalized_learning/agent/openstax_chapters.py b/samples/personalized_learning/agent/openstax_chapters.py
new file mode 100644
index 00000000..6d62ca53
--- /dev/null
+++ b/samples/personalized_learning/agent/openstax_chapters.py
@@ -0,0 +1,336 @@
+"""
+Complete OpenStax Biology AP Courses Chapter Index
+
+This module provides a comprehensive mapping of all chapters in the OpenStax
+Biology for AP Courses textbook, along with intelligent topic matching.
+"""
+
+# Complete chapter list with human-readable titles
+OPENSTAX_CHAPTERS = {
+ # Unit 1: The Chemistry of Life
+ "1-1-the-science-of-biology": "The Science of Biology",
+ "1-2-themes-and-concepts-of-biology": "Themes and Concepts of Biology",
+ "2-1-atoms-isotopes-ions-and-molecules-the-building-blocks": "Atoms, Isotopes, Ions, and Molecules: The Building Blocks",
+ "2-2-water": "Water",
+ "2-3-carbon": "Carbon",
+ "3-1-synthesis-of-biological-macromolecules": "Synthesis of Biological Macromolecules",
+ "3-2-carbohydrates": "Carbohydrates",
+ "3-3-lipids": "Lipids",
+ "3-4-proteins": "Proteins",
+ "3-5-nucleic-acids": "Nucleic Acids",
+
+ # Unit 2: The Cell
+ "4-1-studying-cells": "Studying Cells",
+ "4-2-prokaryotic-cells": "Prokaryotic Cells",
+ "4-3-eukaryotic-cells": "Eukaryotic Cells",
+ "4-4-the-endomembrane-system-and-proteins": "The Endomembrane System and Proteins",
+ "4-5-cytoskeleton": "Cytoskeleton",
+ "4-6-connections-between-cells-and-cellular-activities": "Connections Between Cells and Cellular Activities",
+ "5-1-components-and-structure": "Cell Membrane Components and Structure",
+ "5-2-passive-transport": "Passive Transport",
+ "5-3-active-transport": "Active Transport",
+ "5-4-bulk-transport": "Bulk Transport",
+ "6-1-energy-and-metabolism": "Energy and Metabolism",
+ "6-2-potential-kinetic-free-and-activation-energy": "Potential, Kinetic, Free, and Activation Energy",
+ "6-3-the-laws-of-thermodynamics": "The Laws of Thermodynamics",
+ "6-4-atp-adenosine-triphosphate": "ATP: Adenosine Triphosphate",
+ "6-5-enzymes": "Enzymes",
+ "7-1-energy-in-living-systems": "Energy in Living Systems",
+ "7-2-glycolysis": "Glycolysis",
+ "7-3-oxidation-of-pyruvate-and-the-citric-acid-cycle": "Oxidation of Pyruvate and the Citric Acid Cycle",
+ "7-4-oxidative-phosphorylation": "Oxidative Phosphorylation",
+ "7-5-metabolism-without-oxygen": "Metabolism Without Oxygen",
+ "7-6-connections-of-carbohydrate-protein-and-lipid-metabolic-pathways": "Connections of Carbohydrate, Protein, and Lipid Metabolic Pathways",
+ "7-7-regulation-of-cellular-respiration": "Regulation of Cellular Respiration",
+ "8-1-overview-of-photosynthesis": "Overview of Photosynthesis",
+ "8-2-the-light-dependent-reaction-of-photosynthesis": "The Light-Dependent Reactions of Photosynthesis",
+ "8-3-using-light-to-make-organic-molecules": "Using Light to Make Organic Molecules",
+ "9-1-signaling-molecules-and-cellular-receptors": "Signaling Molecules and Cellular Receptors",
+ "9-2-propagation-of-the-signal": "Propagation of the Signal",
+ "9-3-response-to-the-signal": "Response to the Signal",
+ "9-4-signaling-in-single-celled-organisms": "Signaling in Single-Celled Organisms",
+ "10-1-cell-division": "Cell Division",
+ "10-2-the-cell-cycle": "The Cell Cycle",
+ "10-3-control-of-the-cell-cycle": "Control of the Cell Cycle",
+ "10-4-cancer-and-the-cell-cycle": "Cancer and the Cell Cycle",
+ "10-5-prokaryotic-cell-division": "Prokaryotic Cell Division",
+
+ # Unit 3: Genetics
+ "11-1-the-process-of-meiosis": "The Process of Meiosis",
+ "11-2-sexual-reproduction": "Sexual Reproduction",
+ "12-1-mendels-experiments-and-the-laws-of-probability": "Mendel's Experiments and the Laws of Probability",
+ "12-2-characteristics-and-traits": "Characteristics and Traits",
+ "12-3-laws-of-inheritance": "Laws of Inheritance",
+ "13-1-chromosomal-theory-and-genetic-linkages": "Chromosomal Theory and Genetic Linkages",
+ "13-2-chromosomal-basis-of-inherited-disorders": "Chromosomal Basis of Inherited Disorders",
+ "14-1-historical-basis-of-modern-understanding": "Historical Basis of Modern Understanding of DNA",
+ "14-2-dna-structure-and-sequencing": "DNA Structure and Sequencing",
+ "14-3-basics-of-dna-replication": "Basics of DNA Replication",
+ "14-4-dna-replication-in-prokaryotes": "DNA Replication in Prokaryotes",
+ "14-5-dna-replication-in-eukaryotes": "DNA Replication in Eukaryotes",
+ "14-6-dna-repair": "DNA Repair",
+ "15-1-the-genetic-code": "The Genetic Code",
+ "15-2-prokaryotic-transcription": "Prokaryotic Transcription",
+ "15-3-eukaryotic-transcription": "Eukaryotic Transcription",
+ "15-4-rna-processing-in-eukaryotes": "RNA Processing in Eukaryotes",
+ "15-5-ribosomes-and-protein-synthesis": "Ribosomes and Protein Synthesis",
+ "16-1-regulation-of-gene-expression": "Regulation of Gene Expression",
+ "16-2-prokaryotic-gene-regulation": "Prokaryotic Gene Regulation",
+ "16-3-eukaryotic-epigenetic-gene-regulation": "Eukaryotic Epigenetic Gene Regulation",
+ "16-4-eukaryotic-transcriptional-gene-regulation": "Eukaryotic Transcriptional Gene Regulation",
+ "16-5-eukaryotic-post-transcriptional-gene-regulation": "Eukaryotic Post-transcriptional Gene Regulation",
+ "16-6-eukaryotic-translational-and-post-translational-gene-regulation": "Eukaryotic Translational and Post-translational Gene Regulation",
+ "16-7-cancer-and-gene-regulation": "Cancer and Gene Regulation",
+ "17-1-biotechnology": "Biotechnology",
+ "17-2-mapping-genomes": "Mapping Genomes",
+ "17-3-whole-genome-sequencing": "Whole-Genome Sequencing",
+ "17-4-applying-genomics": "Applying Genomics",
+ "17-5-genomics-and-proteomics": "Genomics and Proteomics",
+
+ # Unit 4: Evolutionary Processes
+ "18-1-understanding-evolution": "Understanding Evolution",
+ "18-2-formation-of-new-species": "Formation of New Species",
+ "18-3-reconnection-and-rates-of-speciation": "Reconnection and Rates of Speciation",
+ "19-1-population-evolution": "Population Evolution",
+ "19-2-population-genetics": "Population Genetics",
+ "19-3-adaptive-evolution": "Adaptive Evolution",
+ "20-1-organizing-life-on-earth": "Organizing Life on Earth",
+ "20-2-determining-evolutionary-relationships": "Determining Evolutionary Relationships",
+ "20-3-perspectives-on-the-phylogenetic-tree": "Perspectives on the Phylogenetic Tree",
+
+ # Unit 5: Biological Diversity
+ "21-1-viral-evolution-morphology-and-classification": "Viral Evolution, Morphology, and Classification",
+ "21-2-virus-infection-and-hosts": "Virus Infection and Hosts",
+ "21-3-prevention-and-treatment-of-viral-infections": "Prevention and Treatment of Viral Infections",
+ "21-4-other-acellular-entities-prions-and-viroids": "Other Acellular Entities: Prions and Viroids",
+ "22-1-prokaryotic-diversity": "Prokaryotic Diversity",
+ "22-2-structure-of-prokaryotes": "Structure of Prokaryotes",
+ "22-3-prokaryotic-metabolism": "Prokaryotic Metabolism",
+ "22-4-bacterial-diseases-in-humans": "Bacterial Diseases in Humans",
+ "22-5-beneficial-prokaryotes": "Beneficial Prokaryotes",
+
+ # Unit 6: Plant Structure and Function
+ "23-1-the-plant-body": "The Plant Body",
+ "23-2-stems": "Stems",
+ "23-3-roots": "Roots",
+ "23-4-leaves": "Leaves",
+ "23-5-transport-of-water-and-solutes-in-plants": "Transport of Water and Solutes in Plants",
+ "23-6-plant-sensory-systems-and-responses": "Plant Sensory Systems and Responses",
+
+ # Unit 7: Animal Structure and Function
+ "24-1-animal-form-and-function": "Animal Form and Function",
+ "24-2-animal-primary-tissues": "Animal Primary Tissues",
+ "24-3-homeostasis": "Homeostasis",
+ "25-1-digestive-systems": "Digestive Systems",
+ "25-2-nutrition-and-energy-production": "Nutrition and Energy Production",
+ "25-3-digestive-system-processes": "Digestive System Processes",
+ "25-4-digestive-system-regulation": "Digestive System Regulation",
+ "26-1-neurons-and-glial-cells": "Neurons and Glial Cells",
+ "26-2-how-neurons-communicate": "How Neurons Communicate",
+ "26-3-the-central-nervous-system": "The Central Nervous System",
+ "26-4-the-peripheral-nervous-system": "The Peripheral Nervous System",
+ "26-5-nervous-system-disorders": "Nervous System Disorders",
+ "27-1-sensory-processes": "Sensory Processes",
+ "27-2-somatosensation": "Somatosensation",
+ "27-3-taste-and-smell": "Taste and Smell",
+ "27-4-hearing-and-vestibular-sensation": "Hearing and Vestibular Sensation",
+ "27-5-vision": "Vision",
+ "28-1-types-of-hormones": "Types of Hormones",
+ "28-2-how-hormones-work": "How Hormones Work",
+ "28-3-regulation-of-body-processes": "Regulation of Body Processes",
+ "28-4-regulation-of-hormone-production": "Regulation of Hormone Production",
+ "28-5-endocrine-glands": "Endocrine Glands",
+ "29-1-types-of-skeletal-systems": "Types of Skeletal Systems",
+ "29-2-bone": "Bone",
+ "29-3-joints-and-skeletal-movement": "Joints and Skeletal Movement",
+ "29-4-muscle-contraction-and-locomotion": "Muscle Contraction and Locomotion",
+ "30-1-systems-of-gas-exchange": "Systems of Gas Exchange",
+ "30-2-gas-exchange-across-respiratory-surfaces": "Gas Exchange Across Respiratory Surfaces",
+ "30-3-breathing": "Breathing",
+ "30-4-transport-of-gases-in-human-bodily-fluids": "Transport of Gases in Human Bodily Fluids",
+ "31-1-overview-of-the-circulatory-system": "Overview of the Circulatory System",
+ "31-2-components-of-the-blood": "Components of the Blood",
+ "31-3-mammalian-heart-and-blood-vessels": "Mammalian Heart and Blood Vessels",
+ "31-4-blood-flow-and-blood-pressure-regulation": "Blood Flow and Blood Pressure Regulation",
+ "32-1-osmoregulation-and-osmotic-balance": "Osmoregulation and Osmotic Balance",
+ "32-2-the-kidneys-and-osmoregulatory-organs": "The Kidneys and Osmoregulatory Organs",
+ "32-3-excretion-systems": "Excretion Systems",
+ "32-4-nitrogenous-wastes": "Nitrogenous Wastes",
+ "32-5-hormonal-control-of-osmoregulatory-functions": "Hormonal Control of Osmoregulatory Functions",
+ "33-1-innate-immune-response": "Innate Immune Response",
+ "33-2-adaptive-immune-response": "Adaptive Immune Response",
+ "33-3-antibodies": "Antibodies",
+ "33-4-disruptions-in-the-immune-system": "Disruptions in the Immune System",
+ "34-1-reproduction-methods": "Reproduction Methods",
+ "34-2-fertilization": "Fertilization",
+ "34-3-human-reproductive-anatomy-and-gametogenesis": "Human Reproductive Anatomy and Gametogenesis",
+ "34-4-hormonal-control-of-human-reproduction": "Hormonal Control of Human Reproduction",
+ "34-5-fertilization-and-early-embryonic-development": "Fertilization and Early Embryonic Development",
+ "34-6-organogenesis-and-vertebrate-axis-formation": "Organogenesis and Vertebrate Axis Formation",
+ "34-7-human-pregnancy-and-birth": "Human Pregnancy and Birth",
+
+ # Unit 8: Ecology
+ "35-1-the-scope-of-ecology": "The Scope of Ecology",
+ "35-2-biogeography": "Biogeography",
+ "35-3-terrestrial-biomes": "Terrestrial Biomes",
+ "35-4-aquatic-biomes": "Aquatic Biomes",
+ "35-5-climate-and-the-effects-of-global-climate-change": "Climate and the Effects of Global Climate Change",
+ "36-1-population-demography": "Population Demography",
+ "36-2-life-histories-and-natural-selection": "Life Histories and Natural Selection",
+ "36-3-environmental-limits-to-population-growth": "Environmental Limits to Population Growth",
+ "36-4-population-dynamics-and-regulation": "Population Dynamics and Regulation",
+ "36-5-human-population-growth": "Human Population Growth",
+ "36-6-community-ecology": "Community Ecology",
+ "36-7-behavioral-biology-proximate-and-ultimate-causes-of-behavior": "Behavioral Biology: Proximate and Ultimate Causes of Behavior",
+ "37-1-ecology-for-ecosystems": "Ecology for Ecosystems",
+ "37-2-energy-flow-through-ecosystems": "Energy Flow Through Ecosystems",
+ "37-3-biogeochemical-cycles": "Biogeochemical Cycles",
+ "38-1-the-biodiversity-crisis": "The Biodiversity Crisis",
+ "38-2-the-importance-of-biodiversity-to-human-life": "The Importance of Biodiversity to Human Life",
+ "38-3-threats-to-biodiversity": "Threats to Biodiversity",
+ "38-4-preserving-biodiversity": "Preserving Biodiversity",
+}
+
+# Build a formatted string for LLM context
+def get_chapter_list_for_llm() -> str:
+ """Return a formatted list of all chapters for LLM context."""
+ lines = []
+ for slug, title in OPENSTAX_CHAPTERS.items():
+ lines.append(f"- {slug}: {title}")
+ return "\n".join(lines)
+
+
+# Pre-computed keyword hints for faster matching (optional optimization)
+KEYWORD_HINTS = {
+ # Energy & Metabolism
+ "atp": ["6-4-atp-adenosine-triphosphate", "6-1-energy-and-metabolism"],
+ "photosynthesis": ["8-1-overview-of-photosynthesis", "8-2-the-light-dependent-reaction-of-photosynthesis"],
+ "plants make food": ["8-1-overview-of-photosynthesis"],
+ "cellular respiration": ["7-1-energy-in-living-systems", "7-4-oxidative-phosphorylation"],
+ "glycolysis": ["7-2-glycolysis"],
+ "krebs": ["7-3-oxidation-of-pyruvate-and-the-citric-acid-cycle"],
+ "citric acid": ["7-3-oxidation-of-pyruvate-and-the-citric-acid-cycle"],
+ "electron transport": ["7-4-oxidative-phosphorylation"],
+ "fermentation": ["7-5-metabolism-without-oxygen"],
+
+ # Cell Division
+ "mitosis": ["10-1-cell-division", "10-2-the-cell-cycle"],
+ "meiosis": ["11-1-the-process-of-meiosis"],
+ "cell cycle": ["10-2-the-cell-cycle", "10-3-control-of-the-cell-cycle"],
+ "cell division": ["10-1-cell-division"],
+ "cancer": ["10-4-cancer-and-the-cell-cycle", "16-7-cancer-and-gene-regulation"],
+
+ # Molecular Biology
+ "dna": ["14-2-dna-structure-and-sequencing", "14-3-basics-of-dna-replication"],
+ "rna": ["15-4-rna-processing-in-eukaryotes", "3-5-nucleic-acids"],
+ "transcription": ["15-2-prokaryotic-transcription", "15-3-eukaryotic-transcription"],
+ "translation": ["15-5-ribosomes-and-protein-synthesis"],
+ "protein synthesis": ["15-5-ribosomes-and-protein-synthesis"],
+ "protein": ["3-4-proteins", "15-5-ribosomes-and-protein-synthesis"],
+ "enzyme": ["6-5-enzymes"],
+ "gene expression": ["16-1-regulation-of-gene-expression"],
+ "genetic code": ["15-1-the-genetic-code"],
+
+ # Cell Structure
+ "cell membrane": ["5-1-components-and-structure"],
+ "membrane": ["5-1-components-and-structure", "5-2-passive-transport"],
+ "osmosis": ["5-2-passive-transport", "32-1-osmoregulation-and-osmotic-balance"],
+ "diffusion": ["5-2-passive-transport"],
+ "active transport": ["5-3-active-transport"],
+ "cytoskeleton": ["4-5-cytoskeleton"],
+ "organelle": ["4-3-eukaryotic-cells", "4-4-the-endomembrane-system-and-proteins"],
+
+ # Nervous System
+ "neuron": ["26-1-neurons-and-glial-cells", "26-2-how-neurons-communicate"],
+ "nervous system": ["26-1-neurons-and-glial-cells", "26-3-the-central-nervous-system"],
+ "brain": ["26-3-the-central-nervous-system"],
+ "action potential": ["26-2-how-neurons-communicate"],
+ "synapse": ["26-2-how-neurons-communicate"],
+ "senses": ["27-1-sensory-processes"],
+ "vision": ["27-5-vision"],
+ "hearing": ["27-4-hearing-and-vestibular-sensation"],
+
+ # Circulatory System
+ "heart": ["31-1-overview-of-the-circulatory-system", "31-3-mammalian-heart-and-blood-vessels"],
+ "blood": ["31-2-components-of-the-blood", "31-1-overview-of-the-circulatory-system"],
+ "circulatory": ["31-1-overview-of-the-circulatory-system"],
+ "cardiovascular": ["31-1-overview-of-the-circulatory-system"],
+
+ # Immune System
+ "immune": ["33-1-innate-immune-response", "33-2-adaptive-immune-response"],
+ "antibod": ["33-3-antibodies"],
+ "infection": ["33-1-innate-immune-response"],
+ "vaccine": ["33-2-adaptive-immune-response"],
+
+ # Other Body Systems
+ "respiration": ["30-1-systems-of-gas-exchange", "30-3-breathing"],
+ "breathing": ["30-3-breathing"],
+ "lung": ["30-1-systems-of-gas-exchange"],
+ "digestion": ["25-1-digestive-systems", "25-3-digestive-system-processes"],
+ "stomach": ["25-1-digestive-systems"],
+ "intestine": ["25-3-digestive-system-processes"],
+ "hormone": ["28-1-types-of-hormones", "28-2-how-hormones-work"],
+ "endocrine": ["28-5-endocrine-glands"],
+ "muscle": ["29-4-muscle-contraction-and-locomotion"],
+ "bone": ["29-2-bone"],
+ "skeleton": ["29-1-types-of-skeletal-systems"],
+ "kidney": ["32-2-the-kidneys-and-osmoregulatory-organs"],
+ "excretion": ["32-3-excretion-systems"],
+ "reproduction": ["34-1-reproduction-methods", "34-3-human-reproductive-anatomy-and-gametogenesis"],
+ "pregnancy": ["34-7-human-pregnancy-and-birth"],
+ "embryo": ["34-5-fertilization-and-early-embryonic-development"],
+
+ # Evolution & Genetics
+ "evolution": ["18-1-understanding-evolution", "19-1-population-evolution"],
+ "darwin": ["18-1-understanding-evolution"],
+ "natural selection": ["19-3-adaptive-evolution", "36-2-life-histories-and-natural-selection"],
+ "speciation": ["18-2-formation-of-new-species"],
+ "genetics": ["12-1-mendels-experiments-and-the-laws-of-probability", "12-3-laws-of-inheritance"],
+ "mendel": ["12-1-mendels-experiments-and-the-laws-of-probability"],
+ "inheritance": ["12-3-laws-of-inheritance"],
+ "heredity": ["12-3-laws-of-inheritance"],
+ "mutation": ["14-6-dna-repair"],
+ "phylogen": ["20-2-determining-evolutionary-relationships"],
+
+ # Microorganisms
+ "virus": ["21-1-viral-evolution-morphology-and-classification", "21-2-virus-infection-and-hosts"],
+ "bacteria": ["22-1-prokaryotic-diversity", "22-4-bacterial-diseases-in-humans"],
+ "prokaryote": ["4-2-prokaryotic-cells", "22-1-prokaryotic-diversity"],
+ "eukaryote": ["4-3-eukaryotic-cells"],
+
+ # Plants
+ "plant": ["23-1-the-plant-body"],
+ "leaf": ["23-4-leaves"],
+ "root": ["23-3-roots"],
+ "stem": ["23-2-stems"],
+ "xylem": ["23-5-transport-of-water-and-solutes-in-plants"],
+ "phloem": ["23-5-transport-of-water-and-solutes-in-plants"],
+
+ # Ecology
+ "ecology": ["35-1-the-scope-of-ecology", "36-6-community-ecology"],
+ "ecosystem": ["37-1-ecology-for-ecosystems", "37-2-energy-flow-through-ecosystems"],
+ "food chain": ["37-2-energy-flow-through-ecosystems"],
+ "food web": ["37-2-energy-flow-through-ecosystems"],
+ "biome": ["35-3-terrestrial-biomes", "35-4-aquatic-biomes"],
+ "population": ["36-1-population-demography", "36-3-environmental-limits-to-population-growth"],
+ "climate": ["35-5-climate-and-the-effects-of-global-climate-change"],
+ "climate change": ["35-5-climate-and-the-effects-of-global-climate-change"],
+ "biodiversity": ["38-1-the-biodiversity-crisis", "38-4-preserving-biodiversity"],
+ "carbon cycle": ["37-3-biogeochemical-cycles"],
+ "nitrogen cycle": ["37-3-biogeochemical-cycles"],
+
+ # Chemistry Basics
+ "atom": ["2-1-atoms-isotopes-ions-and-molecules-the-building-blocks"],
+ "water": ["2-2-water"],
+ "carbon": ["2-3-carbon"],
+ "carbohydrate": ["3-2-carbohydrates"],
+ "lipid": ["3-3-lipids"],
+ "nucleic acid": ["3-5-nucleic-acids"],
+
+ # Biotechnology
+ "biotechnology": ["17-1-biotechnology"],
+ "crispr": ["17-1-biotechnology"],
+ "cloning": ["17-1-biotechnology"],
+ "genome": ["17-2-mapping-genomes", "17-3-whole-genome-sequencing"],
+ "genomics": ["17-4-applying-genomics", "17-5-genomics-and-proteomics"],
+}
diff --git a/samples/personalized_learning/agent/pyproject.toml b/samples/personalized_learning/agent/pyproject.toml
new file mode 100644
index 00000000..1f34087b
--- /dev/null
+++ b/samples/personalized_learning/agent/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "personalized-learning-agent"
+version = "0.1.0"
+description = "A2A Agent for generating personalized A2UI learning materials"
+requires-python = ">=3.11"
+dependencies = [
+ "google-adk>=0.3.0",
+ "google-genai>=1.0.0",
+ "google-cloud-storage>=2.10.0",
+ "python-dotenv>=1.0.0",
+ "uvicorn>=0.24.0",
+ "fastapi>=0.104.0",
+ "pydantic>=2.5.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+ "pytest-asyncio>=0.21.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
diff --git a/samples/personalized_learning/agent/requirements.txt b/samples/personalized_learning/agent/requirements.txt
new file mode 100644
index 00000000..4559f5b3
--- /dev/null
+++ b/samples/personalized_learning/agent/requirements.txt
@@ -0,0 +1,8 @@
+google-adk>=0.3.0
+google-genai>=1.0.0
+google-cloud-storage>=2.10.0
+a2a-sdk>=0.2.0
+python-dotenv>=1.0.0
+uvicorn>=0.24.0
+fastapi>=0.104.0
+pydantic>=2.5.0
diff --git a/samples/personalized_learning/agent/server.py b/samples/personalized_learning/agent/server.py
new file mode 100644
index 00000000..b7c71f20
--- /dev/null
+++ b/samples/personalized_learning/agent/server.py
@@ -0,0 +1,152 @@
+"""
+FastAPI Server for Personalized Learning Agent
+
+Provides HTTP endpoints for the A2A agent that generates A2UI learning materials.
+This can run locally or be deployed to Agent Engine.
+"""
+
+import json
+import logging
+import os
+from typing import Any
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from agent import get_agent, LearningMaterialAgent
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+app = FastAPI(
+ title="Personalized Learning Agent",
+ description="A2A agent for generating personalized A2UI learning materials",
+ version="0.1.0",
+)
+
+# CORS for local development
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+class GenerateRequest(BaseModel):
+ """Request model for content generation."""
+
+ format: str
+ context: str = ""
+ session_id: str = "default"
+
+
+class A2ARequest(BaseModel):
+ """A2A protocol request model."""
+
+ message: str
+ session_id: str = "default"
+ extensions: list[str] = []
+
+
+@app.get("/health")
+async def health_check():
+ """Health check endpoint."""
+ return {"status": "healthy", "agent": "personalized-learning-agent"}
+
+
+@app.get("/capabilities")
+async def get_capabilities():
+ """Return agent capabilities for A2A discovery."""
+ return {
+ "name": "Personalized Learning Agent",
+ "description": "Generates personalized A2UI learning materials",
+ "supported_formats": LearningMaterialAgent.SUPPORTED_FORMATS,
+ "extensions": [
+ {
+ "uri": "https://a2ui.org/a2a-extension/a2ui/v0.8",
+ "description": "Provides agent driven UI using the A2UI JSON format.",
+ }
+ ],
+ }
+
+
+@app.post("/generate")
+async def generate_content(request: GenerateRequest):
+ """
+ Generate A2UI content for the specified format.
+
+ Args:
+ request: Generation request with format and optional context
+
+ Returns:
+ A2UI JSON response
+ """
+ logger.info(f"Generate request: format={request.format}, context={request.context[:50]}...")
+
+ agent = get_agent()
+ result = await agent.generate_content(request.format, request.context)
+
+ if "error" in result:
+ raise HTTPException(status_code=400, detail=result["error"])
+
+ return result
+
+
+@app.post("/a2a/stream")
+async def a2a_stream(request: A2ARequest):
+ """
+ A2A-compatible streaming endpoint.
+
+ Args:
+ request: A2A request with message
+
+ Returns:
+ Streaming response with A2UI JSON
+ """
+ logger.info(f"A2A stream request: {request.message}")
+
+ agent = get_agent()
+
+ async def generate():
+ async for chunk in agent.stream(request.message, request.session_id):
+ yield f"data: {json.dumps(chunk)}\n\n"
+
+ return StreamingResponse(
+ generate(),
+ media_type="text/event-stream",
+ )
+
+
+@app.post("/a2a/query")
+async def a2a_query(request: A2ARequest):
+ """
+ A2A-compatible non-streaming endpoint.
+
+ Args:
+ request: A2A request with message in format "type:context"
+
+ Returns:
+ A2UI JSON response
+ """
+ logger.info(f"A2A query request: {request.message}")
+
+ # Parse message (format: "type:context" or just "type")
+ parts = request.message.split(":", 1)
+ format_type = parts[0].strip()
+ context = parts[1].strip() if len(parts) > 1 else ""
+
+ agent = get_agent()
+ result = await agent.generate_content(format_type, context)
+
+ return result
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ port = int(os.getenv("PORT", "8081"))
+ uvicorn.run(app, host="0.0.0.0", port=port)
diff --git a/samples/personalized_learning/agent/tests/test_agent.py b/samples/personalized_learning/agent/tests/test_agent.py
new file mode 100644
index 00000000..ddb9ed6c
--- /dev/null
+++ b/samples/personalized_learning/agent/tests/test_agent.py
@@ -0,0 +1,363 @@
+"""
+Unit and Integration Tests for Personalized Learning Agent
+
+Tests the context loader, A2UI templates, and agent functionality.
+"""
+
+import json
+import os
+import sys
+import asyncio
+from pathlib import Path
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from context_loader import (
+ load_context_file,
+ load_all_context,
+ get_learner_profile,
+ get_misconception_context,
+ get_combined_context,
+)
+from a2ui_templates import (
+ get_system_prompt,
+ FLASHCARD_EXAMPLE,
+ AUDIO_EXAMPLE,
+ VIDEO_EXAMPLE,
+ SURFACE_ID,
+)
+
+# =============================================================================
+# Test Results Tracking
+# =============================================================================
+
+passed = 0
+failed = 0
+
+
+def test(name):
+ """Decorator for test functions."""
+ def decorator(fn):
+ global passed, failed
+ try:
+ result = fn()
+ if asyncio.iscoroutine(result):
+ asyncio.run(result)
+ print(f"✓ {name}")
+ passed += 1
+ except AssertionError as e:
+ print(f"✗ {name}")
+ print(f" Error: {e}")
+ failed += 1
+ except Exception as e:
+ print(f"✗ {name}")
+ print(f" Exception: {type(e).__name__}: {e}")
+ failed += 1
+ return fn
+ return decorator
+
+
+# =============================================================================
+# Context Loader Tests
+# =============================================================================
+
+print("=" * 60)
+print("Personalized Learning Agent - Python Tests")
+print("=" * 60)
+print("\n--- Context Loader Tests ---\n")
+
+
+@test("load_context_file loads maria profile")
+def test_load_maria_profile():
+ content = load_context_file("01_maria_learner_profile.txt")
+ assert content is not None, "Content should not be None"
+ assert "Maria" in content, "Content should contain 'Maria'"
+ assert "MCAT" in content, "Content should contain 'MCAT'"
+
+
+@test("load_context_file loads misconception resolution")
+def test_load_misconception():
+ content = load_context_file("05_misconception_resolution.txt")
+ assert content is not None, "Content should not be None"
+ assert "ATP" in content, "Content should contain 'ATP'"
+ assert "bond" in content.lower(), "Content should mention bonds"
+
+
+@test("load_context_file returns None for missing file")
+def test_load_missing_file():
+ content = load_context_file("nonexistent_file.txt")
+ assert content is None, "Should return None for missing file"
+
+
+@test("load_all_context loads multiple files")
+def test_load_all_context():
+ context = load_all_context()
+ assert isinstance(context, dict), "Should return a dict"
+ assert len(context) >= 1, "Should load at least one file"
+ # Check that keys are filenames
+ for key in context.keys():
+ assert key.endswith(".txt"), f"Key {key} should be a .txt filename"
+
+
+@test("get_learner_profile returns Maria's profile")
+def test_get_learner_profile():
+ profile = get_learner_profile()
+ assert profile is not None, "Profile should not be None"
+ assert "Maria" in profile, "Profile should contain Maria"
+ assert "Cymbal" in profile, "Profile should mention Cymbal University"
+
+
+@test("get_misconception_context returns resolution content")
+def test_get_misconception_context():
+ content = get_misconception_context()
+ assert content is not None, "Content should not be None"
+ assert "misconception" in content.lower(), "Should discuss misconception"
+
+
+@test("get_combined_context combines all files")
+def test_get_combined_context():
+ combined = get_combined_context()
+ assert isinstance(combined, str), "Should return a string"
+ assert len(combined) > 1000, "Combined context should be substantial"
+ # Should contain section markers
+ assert "===" in combined, "Should contain section markers"
+
+
+# =============================================================================
+# A2UI Templates Tests
+# =============================================================================
+
+print("\n--- A2UI Templates Tests ---\n")
+
+
+@test("SURFACE_ID is set correctly")
+def test_surface_id():
+ assert SURFACE_ID == "learningContent", f"SURFACE_ID should be 'learningContent', got {SURFACE_ID}"
+
+
+@test("FLASHCARD_EXAMPLE contains valid A2UI structure")
+def test_flashcard_example():
+ assert "beginRendering" in FLASHCARD_EXAMPLE
+ assert "surfaceUpdate" in FLASHCARD_EXAMPLE
+ assert "Flashcard" in FLASHCARD_EXAMPLE
+ assert SURFACE_ID in FLASHCARD_EXAMPLE
+
+
+@test("AUDIO_EXAMPLE contains valid A2UI structure")
+def test_audio_example():
+ assert "beginRendering" in AUDIO_EXAMPLE
+ assert "surfaceUpdate" in AUDIO_EXAMPLE
+ assert "Audio" in AUDIO_EXAMPLE
+ assert "/assets/podcast.m4a" in AUDIO_EXAMPLE
+
+
+@test("VIDEO_EXAMPLE contains valid A2UI structure")
+def test_video_example():
+ assert "beginRendering" in VIDEO_EXAMPLE
+ assert "surfaceUpdate" in VIDEO_EXAMPLE
+ assert "Video" in VIDEO_EXAMPLE
+ assert "/assets/demo.mp4" in VIDEO_EXAMPLE
+
+
+@test("get_system_prompt generates flashcards prompt")
+def test_system_prompt_flashcards():
+ context = "Test context for Maria"
+ prompt = get_system_prompt("flashcards", context)
+ assert "flashcards" in prompt.lower()
+ assert context in prompt
+ assert SURFACE_ID in prompt
+ assert "Flashcard" in prompt
+
+
+@test("get_system_prompt generates audio prompt")
+def test_system_prompt_audio():
+ context = "Test context"
+ prompt = get_system_prompt("audio", context)
+ assert "audio" in prompt.lower() or "Audio" in prompt
+ assert context in prompt
+
+
+@test("get_system_prompt includes learner context")
+def test_system_prompt_includes_context():
+ context = "Maria is a pre-med student with ATP misconception"
+ prompt = get_system_prompt("flashcards", context)
+ assert "Maria" in prompt
+ assert "ATP" in prompt
+
+
+# =============================================================================
+# Agent Tests
+# =============================================================================
+
+print("\n--- Agent Tests ---\n")
+
+# Import agent after context tests to ensure dependencies work
+try:
+ from agent import LearningMaterialAgent, get_agent
+ AGENT_AVAILABLE = True
+except ImportError as e:
+ print(f" (Skipping agent tests: {e})")
+ AGENT_AVAILABLE = False
+
+
+if AGENT_AVAILABLE:
+ # Create a test agent without initializing the Gemini client
+ # This allows testing static methods without credentials
+ _test_agent = LearningMaterialAgent(init_client=False)
+
+ @test("LearningMaterialAgent has correct supported formats")
+ def test_agent_formats():
+ assert "flashcards" in LearningMaterialAgent.SUPPORTED_FORMATS
+ assert "audio" in LearningMaterialAgent.SUPPORTED_FORMATS
+ assert "podcast" in LearningMaterialAgent.SUPPORTED_FORMATS
+ assert "video" in LearningMaterialAgent.SUPPORTED_FORMATS
+ assert "quiz" in LearningMaterialAgent.SUPPORTED_FORMATS
+
+
+ @test("agent._get_audio_reference returns valid A2UI")
+ def test_audio_reference():
+ result = _test_agent._get_audio_reference()
+ assert result["format"] == "audio"
+ assert result["surfaceId"] == SURFACE_ID
+ assert isinstance(result["a2ui"], list)
+ assert len(result["a2ui"]) == 2
+ assert "beginRendering" in result["a2ui"][0]
+ assert "surfaceUpdate" in result["a2ui"][1]
+
+
+ @test("agent._get_video_reference returns valid A2UI")
+ def test_video_reference():
+ result = _test_agent._get_video_reference()
+ assert result["format"] == "video"
+ assert result["surfaceId"] == SURFACE_ID
+ assert isinstance(result["a2ui"], list)
+ assert len(result["a2ui"]) == 2
+
+
+ @test("audio A2UI has all required components")
+ def test_audio_components():
+ result = _test_agent._get_audio_reference()
+ components = result["a2ui"][1]["surfaceUpdate"]["components"]
+ component_ids = {c["id"] for c in components}
+
+ # Check all required components exist
+ required = {"audioCard", "audioContent", "audioHeader", "audioIcon",
+ "audioTitle", "audioPlayer", "audioDescription"}
+ missing = required - component_ids
+ assert not missing, f"Missing components: {missing}"
+
+
+ @test("video A2UI has all required components")
+ def test_video_components():
+ result = _test_agent._get_video_reference()
+ components = result["a2ui"][1]["surfaceUpdate"]["components"]
+ component_ids = {c["id"] for c in components}
+
+ required = {"videoCard", "videoContent", "videoTitle", "videoPlayer", "videoDescription"}
+ missing = required - component_ids
+ assert not missing, f"Missing components: {missing}"
+
+
+# =============================================================================
+# A2UI JSON Validation Tests
+# =============================================================================
+
+print("\n--- A2UI JSON Validation Tests ---\n")
+
+
+def validate_a2ui_message(message):
+ """Validate a single A2UI message structure."""
+ valid_keys = {"beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"}
+ message_keys = set(message.keys())
+ action_keys = message_keys & valid_keys
+
+ if len(action_keys) != 1:
+ return False, f"Expected exactly one action key, got {len(action_keys)}"
+
+ action = list(action_keys)[0]
+
+ if action == "beginRendering":
+ br = message["beginRendering"]
+ if "surfaceId" not in br or "root" not in br:
+ return False, "beginRendering missing surfaceId or root"
+
+ elif action == "surfaceUpdate":
+ su = message["surfaceUpdate"]
+ if "surfaceId" not in su:
+ return False, "surfaceUpdate missing surfaceId"
+ if "components" not in su or not isinstance(su["components"], list):
+ return False, "surfaceUpdate missing components array"
+ for comp in su["components"]:
+ if "id" not in comp or "component" not in comp:
+ return False, f"Component missing id or component: {comp}"
+
+ return True, "OK"
+
+
+def validate_a2ui_payload(messages):
+ """Validate a complete A2UI payload."""
+ if not isinstance(messages, list):
+ return False, "Payload must be a list"
+ if len(messages) == 0:
+ return False, "Payload cannot be empty"
+ if "beginRendering" not in messages[0]:
+ return False, "First message must be beginRendering"
+
+ for i, msg in enumerate(messages):
+ valid, error = validate_a2ui_message(msg)
+ if not valid:
+ return False, f"Message {i}: {error}"
+
+ # Validate component references
+ all_ids = set()
+ references = []
+
+ for msg in messages:
+ if "surfaceUpdate" in msg:
+ for comp in msg["surfaceUpdate"]["components"]:
+ all_ids.add(comp["id"])
+ comp_def = comp["component"]
+ comp_type = list(comp_def.keys())[0]
+ props = comp_def[comp_type]
+
+ if isinstance(props, dict):
+ if "child" in props and isinstance(props["child"], str):
+ references.append((comp["id"], props["child"]))
+ if "children" in props and isinstance(props["children"], dict):
+ if "explicitList" in props["children"]:
+ for child_id in props["children"]["explicitList"]:
+ references.append((comp["id"], child_id))
+
+ for parent_id, child_id in references:
+ if child_id not in all_ids:
+ return False, f"Component {parent_id} references non-existent child: {child_id}"
+
+ return True, "OK"
+
+
+if AGENT_AVAILABLE:
+ @test("audio reference passes A2UI validation")
+ def test_validate_audio():
+ result = _test_agent._get_audio_reference()
+ valid, error = validate_a2ui_payload(result["a2ui"])
+ assert valid, f"Audio A2UI validation failed: {error}"
+
+
+ @test("video reference passes A2UI validation")
+ def test_validate_video():
+ result = _test_agent._get_video_reference()
+ valid, error = validate_a2ui_payload(result["a2ui"])
+ assert valid, f"Video A2UI validation failed: {error}"
+
+
+# =============================================================================
+# Summary
+# =============================================================================
+
+print("\n" + "=" * 60)
+print(f"Python Tests Complete: {passed} passed, {failed} failed")
+print("=" * 60)
+
+if failed > 0:
+ sys.exit(1)
diff --git a/samples/personalized_learning/api-server.ts b/samples/personalized_learning/api-server.ts
new file mode 100644
index 00000000..7fc808e1
--- /dev/null
+++ b/samples/personalized_learning/api-server.ts
@@ -0,0 +1,669 @@
+/*
+ * API Server for Personalized Learning Demo
+ *
+ * Handles chat API requests using Gemini via VertexAI.
+ * Also proxies A2A requests to Agent Engine.
+ * Run with: npx tsx api-server.ts
+ *
+ * Required environment variables:
+ * GOOGLE_CLOUD_PROJECT - Your GCP project ID
+ * AGENT_ENGINE_PROJECT_NUMBER - Project number for Agent Engine
+ * AGENT_ENGINE_RESOURCE_ID - Resource ID of your deployed agent
+ *
+ * Optional environment variables:
+ * API_PORT - Server port (default: 8080)
+ * GOOGLE_CLOUD_LOCATION - GCP region (default: us-central1)
+ * GENAI_MODEL - Gemini model to use (default: gemini-2.5-flash)
+ */
+
+import { createServer } from "http";
+import { execSync } from "child_process";
+import { writeFileSync } from "fs";
+import { config } from "dotenv";
+
+// Load environment variables
+config();
+
+// =============================================================================
+// MESSAGE LOG - Captures all request/response traffic for demo purposes
+// =============================================================================
+const LOG_FILE = "./demo-message-log.json";
+let messageLog: Array<{
+ sequence: number;
+ timestamp: string;
+ direction: "CLIENT_TO_SERVER" | "SERVER_TO_AGENT" | "AGENT_TO_SERVER" | "SERVER_TO_CLIENT";
+ endpoint: string;
+ data: unknown;
+}> = [];
+let sequenceCounter = 0;
+
+function logMessage(
+ direction: "CLIENT_TO_SERVER" | "SERVER_TO_AGENT" | "AGENT_TO_SERVER" | "SERVER_TO_CLIENT",
+ endpoint: string,
+ data: unknown
+) {
+ const entry = {
+ sequence: ++sequenceCounter,
+ timestamp: new Date().toISOString(),
+ direction,
+ endpoint,
+ data,
+ };
+ messageLog.push(entry);
+
+ // Write to file after each message for real-time viewing
+ writeFileSync(LOG_FILE, JSON.stringify(messageLog, null, 2));
+ console.log(`[LOG] #${entry.sequence} ${direction} → ${endpoint}`);
+}
+
+function resetLog() {
+ messageLog = [];
+ sequenceCounter = 0;
+ writeFileSync(LOG_FILE, "[]");
+ console.log(`[LOG] Reset log file: ${LOG_FILE}`);
+}
+
+// Reset log on server start
+resetLog();
+
+const PORT = parseInt(process.env.API_PORT || "8080");
+const PROJECT = process.env.GOOGLE_CLOUD_PROJECT;
+const LOCATION = process.env.GOOGLE_CLOUD_LOCATION || "us-central1";
+const MODEL = process.env.GENAI_MODEL || "gemini-2.5-flash";
+
+// Validate required environment variables
+if (!PROJECT) {
+ console.error("ERROR: GOOGLE_CLOUD_PROJECT environment variable is required");
+ process.exit(1);
+}
+
+// Agent Engine Configuration - set via environment variables
+// See QUICKSTART.md for deployment instructions
+const AGENT_ENGINE_CONFIG = {
+ projectNumber: process.env.AGENT_ENGINE_PROJECT_NUMBER || "",
+ location: LOCATION,
+ resourceId: process.env.AGENT_ENGINE_RESOURCE_ID || "",
+};
+
+if (!AGENT_ENGINE_CONFIG.projectNumber || !AGENT_ENGINE_CONFIG.resourceId) {
+ console.warn("WARNING: AGENT_ENGINE_PROJECT_NUMBER and AGENT_ENGINE_RESOURCE_ID not set.");
+ console.warn(" Agent Engine features will not work. See QUICKSTART.md for setup.");
+}
+
+// Dynamic import for google genai (ESM)
+let genai: any = null;
+
+async function initGenAI() {
+ const { GoogleGenAI } = await import("@google/genai");
+ // Use VertexAI with Application Default Credentials
+ genai = new GoogleGenAI({
+ vertexai: true,
+ project: PROJECT,
+ location: LOCATION,
+ });
+ console.log(`[API Server] Using VertexAI: ${PROJECT}/${LOCATION}`);
+ console.log(`[API Server] Model: ${MODEL}`);
+}
+
+interface ChatMessage {
+ role: string;
+ parts: { text: string }[];
+}
+
+interface ChatRequest {
+ systemPrompt: string;
+ intentGuidance: string;
+ messages: ChatMessage[];
+ userMessage: string;
+}
+
+// Get Google Cloud access token
+function getAccessToken(): string {
+ try {
+ const token = execSync("gcloud auth print-access-token", {
+ encoding: "utf-8",
+ }).trim();
+ return token;
+ } catch (error) {
+ console.error("[API Server] Failed to get access token:", error);
+ throw new Error("Failed to get Google Cloud access token. Run: gcloud auth login");
+ }
+}
+
+// Query Agent Engine for A2UI content using streamQuery
+async function queryAgentEngine(format: string, context: string = ""): Promise {
+ const { projectNumber, location, resourceId } = AGENT_ENGINE_CONFIG;
+ // Use :streamQuery endpoint with stream_query method for ADK agents
+ const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectNumber}/locations/${location}/reasoningEngines/${resourceId}:streamQuery`;
+
+ const accessToken = getAccessToken();
+ const message = context ? `Generate ${format} for: ${context}` : `Generate ${format}`;
+
+ console.log(`[API Server] Querying Agent Engine: ${format}`);
+ console.log(`[API Server] URL: ${url}`);
+
+ // Build the request payload
+ const requestPayload = {
+ class_method: "stream_query",
+ input: {
+ user_id: "demo-user",
+ message: message,
+ },
+ };
+
+ // LOG: Server → Agent Engine request
+ logMessage("SERVER_TO_AGENT", "Vertex AI Agent Engine (A2UI Generator)", {
+ description: "Request to remote A2UI-generating agent",
+ agentUrl: url,
+ payload: requestPayload,
+ });
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(requestPayload),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("[API Server] Agent Engine error:", errorText);
+ throw new Error(`Agent Engine error: ${response.status}`);
+ }
+
+ // Parse the newline-delimited JSON response
+ const responseText = await response.text();
+ console.log("[API Server] Agent Engine response length:", responseText.length);
+ console.log("[API Server] Raw response (first 1000 chars):", responseText.substring(0, 1000));
+
+ // LOG: Agent Engine → Server raw response
+ logMessage("AGENT_TO_SERVER", "Vertex AI Agent Engine (raw)", {
+ description: "Raw streaming response from Agent Engine (newline-delimited JSON)",
+ responseLength: responseText.length,
+ rawChunks: responseText.trim().split("\n").slice(0, 5).map(chunk => {
+ try { return JSON.parse(chunk); } catch { return chunk.substring(0, 200); }
+ }),
+ note: "Showing first 5 chunks only",
+ });
+
+ // Extract text from all chunks
+ const chunks = responseText.trim().split("\n").filter((line: string) => line.trim());
+ let fullText = "";
+
+ let functionResponseResult = ""; // Prioritize function_response over text
+ let textParts = "";
+
+ for (const chunk of chunks) {
+ try {
+ const parsed = JSON.parse(chunk);
+ console.log("[API Server] Parsed chunk keys:", Object.keys(parsed));
+
+ // Extract from content.parts - can contain text, function_call, or function_response
+ if (parsed.content?.parts) {
+ for (const part of parsed.content.parts) {
+ // Check for function_response which contains the tool result (prioritize this)
+ if (part.function_response?.response?.result) {
+ const result = part.function_response.response.result;
+ console.log("[API Server] Found function_response result:", result.substring(0, 200));
+ functionResponseResult += result;
+ } else if (part.text) {
+ console.log("[API Server] Found text part:", part.text.substring(0, 100));
+ textParts += part.text;
+ }
+ }
+ }
+ } catch (e) {
+ console.warn("[API Server] Failed to parse chunk:", chunk.substring(0, 100));
+ }
+ }
+
+ // Prefer function_response result over text parts (agent text often just wraps the same data)
+ fullText = functionResponseResult || textParts;
+
+ console.log("[API Server] Extracted text:", fullText.substring(0, 300));
+
+ // Try to parse A2UI JSON from the response
+ try {
+ // Strip markdown code blocks if present
+ let cleaned = fullText.trim();
+ cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
+ cleaned = cleaned.trim();
+
+ console.log("[API Server] Cleaned text:", cleaned.substring(0, 200));
+
+ // Helper to extract A2UI content and source info from various formats
+ const extractA2UIWithSource = (text: string): { a2ui: unknown[] | null; source?: { url: string; title: string; provider: string } } => {
+ // Try parsing as raw JSON array (legacy format)
+ if (text.startsWith("[")) {
+ try {
+ const parsed = JSON.parse(text);
+ if (Array.isArray(parsed)) return { a2ui: parsed };
+ } catch {}
+ }
+
+ // Try parsing as object with a2ui and source (new format)
+ if (text.startsWith("{")) {
+ try {
+ const wrapper = JSON.parse(text);
+ // New format: {a2ui: [...], source: {...}}
+ if (wrapper.a2ui && Array.isArray(wrapper.a2ui)) {
+ return { a2ui: wrapper.a2ui, source: wrapper.source || undefined };
+ }
+ // Legacy format: {"result": "..."}
+ if (wrapper.result) {
+ const inner = typeof wrapper.result === 'string'
+ ? JSON.parse(wrapper.result)
+ : wrapper.result;
+ // Check if inner is the new format
+ if (inner && inner.a2ui && Array.isArray(inner.a2ui)) {
+ return { a2ui: inner.a2ui, source: inner.source || undefined };
+ }
+ if (Array.isArray(inner)) return { a2ui: inner };
+ }
+ } catch {}
+ }
+
+ // Try to find and extract JSON array from text
+ const arrayMatch = text.match(/\[\s*\{[\s\S]*?\}\s*\]/);
+ if (arrayMatch) {
+ try {
+ const parsed = JSON.parse(arrayMatch[0]);
+ if (Array.isArray(parsed)) return { a2ui: parsed };
+ } catch {}
+ }
+
+ // Try to extract result field with regex and parse its content
+ const resultMatch = text.match(/"result"\s*:\s*"((?:[^"\\]|\\.)*)"/);
+ if (resultMatch) {
+ try {
+ // Unescape the JSON string
+ const unescaped = resultMatch[1]
+ .replace(/\\"/g, '"')
+ .replace(/\\\\/g, '\\');
+ const parsed = JSON.parse(unescaped);
+ // Check if parsed is new format
+ if (parsed && parsed.a2ui && Array.isArray(parsed.a2ui)) {
+ return { a2ui: parsed.a2ui, source: parsed.source || undefined };
+ }
+ if (Array.isArray(parsed)) return { a2ui: parsed };
+ } catch {}
+ }
+
+ return { a2ui: null };
+ };
+
+ const extracted = extractA2UIWithSource(cleaned);
+ if (extracted.a2ui) {
+ const result = {
+ format,
+ surfaceId: "learningContent",
+ a2ui: extracted.a2ui,
+ source: extracted.source,
+ };
+
+ // LOG: Parsed A2UI content
+ logMessage("AGENT_TO_SERVER", "Vertex AI Agent Engine (parsed A2UI)", {
+ description: "Successfully parsed A2UI JSON from agent response",
+ format,
+ surfaceId: "learningContent",
+ source: extracted.source,
+ a2uiMessageCount: extracted.a2ui.length,
+ a2uiMessages: extracted.a2ui,
+ });
+
+ return result;
+ }
+
+ // Return raw text if no JSON found
+ return {
+ format,
+ surfaceId: "learningContent",
+ a2ui: [],
+ rawText: fullText,
+ };
+ } catch (e) {
+ console.error("[API Server] Failed to parse agent response:", e);
+ return {
+ format,
+ surfaceId: "learningContent",
+ a2ui: [],
+ rawText: fullText,
+ error: "Failed to parse A2UI JSON",
+ };
+ }
+}
+
+/**
+ * Generate QuizCard content locally using Gemini.
+ * This is used when Agent Engine doesn't have QuizCard support.
+ */
+async function generateLocalQuiz(topic: string): Promise {
+ const systemPrompt = `You are creating MCAT practice quiz questions for Maria, a pre-med student who loves sports/gym analogies.
+
+Create 2 interactive quiz questions about "${topic || 'ATP and bond energy'}" that:
+1. Test understanding with MCAT-style questions
+2. Include plausible wrong answers reflecting common misconceptions
+3. Provide detailed explanations using sports/gym analogies
+4. Use precise scientific language
+
+Output ONLY valid JSON in this EXACT format (no markdown, no explanation):
+
+[
+ {"beginRendering": {"surfaceId": "learningContent", "root": "mainColumn"}},
+ {
+ "surfaceUpdate": {
+ "surfaceId": "learningContent",
+ "components": [
+ {
+ "id": "mainColumn",
+ "component": {
+ "Column": {
+ "children": {"explicitList": ["headerText", "quizRow"]},
+ "distribution": "start",
+ "alignment": "stretch"
+ }
+ }
+ },
+ {
+ "id": "headerText",
+ "component": {
+ "Text": {
+ "text": {"literalString": "Quick Quiz: [TOPIC]"},
+ "usageHint": "h3"
+ }
+ }
+ },
+ {
+ "id": "quizRow",
+ "component": {
+ "Row": {
+ "children": {"explicitList": ["quiz1", "quiz2"]},
+ "distribution": "start",
+ "alignment": "stretch"
+ }
+ }
+ },
+ {
+ "id": "quiz1",
+ "component": {
+ "QuizCard": {
+ "question": {"literalString": "[QUESTION 1]"},
+ "options": [
+ {"label": {"literalString": "[OPTION A]"}, "value": "a", "isCorrect": false},
+ {"label": {"literalString": "[OPTION B - CORRECT]"}, "value": "b", "isCorrect": true},
+ {"label": {"literalString": "[OPTION C]"}, "value": "c", "isCorrect": false},
+ {"label": {"literalString": "[OPTION D]"}, "value": "d", "isCorrect": false}
+ ],
+ "explanation": {"literalString": "[DETAILED EXPLANATION WITH ANALOGY]"},
+ "category": {"literalString": "[CATEGORY]"}
+ }
+ }
+ },
+ {
+ "id": "quiz2",
+ "component": {
+ "QuizCard": {
+ "question": {"literalString": "[QUESTION 2]"},
+ "options": [
+ {"label": {"literalString": "[OPTION A]"}, "value": "a", "isCorrect": false},
+ {"label": {"literalString": "[OPTION B]"}, "value": "b", "isCorrect": false},
+ {"label": {"literalString": "[OPTION C - CORRECT]"}, "value": "c", "isCorrect": true},
+ {"label": {"literalString": "[OPTION D]"}, "value": "d", "isCorrect": false}
+ ],
+ "explanation": {"literalString": "[DETAILED EXPLANATION WITH ANALOGY]"},
+ "category": {"literalString": "[CATEGORY]"}
+ }
+ }
+ }
+ ]
+ }
+ }
+]
+
+Replace all [BRACKETED] placeholders with actual content. Vary which option is correct.`;
+
+ try {
+ const response = await genai.models.generateContent({
+ model: MODEL,
+ contents: [{ role: "user", parts: [{ text: `Generate quiz questions about: ${topic || 'ATP and bond energy'}` }] }],
+ config: {
+ systemInstruction: systemPrompt,
+ responseMimeType: "application/json",
+ },
+ });
+
+ const text = response.text?.trim() || "";
+ console.log("[API Server] Local quiz generation response:", text.substring(0, 500));
+
+ // Parse the JSON
+ let cleaned = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
+ let parsed = JSON.parse(cleaned);
+
+ // Handle case where Gemini wraps the A2UI array in an object
+ // We only want the A2UI messages array, not any wrapper object
+ let a2ui: unknown[];
+ if (Array.isArray(parsed)) {
+ a2ui = parsed;
+ } else if (parsed.a2ui && Array.isArray(parsed.a2ui)) {
+ a2ui = parsed.a2ui;
+ } else if (parsed.messages && Array.isArray(parsed.messages)) {
+ a2ui = parsed.messages;
+ } else {
+ console.error("[API Server] Unexpected quiz format from Gemini:", Object.keys(parsed));
+ return null;
+ }
+
+ // Always use OpenStax source - ignore any source from Gemini
+ return {
+ format: "quiz",
+ surfaceId: "learningContent",
+ a2ui: a2ui,
+ source: {
+ provider: "OpenStax Biology for AP Courses",
+ title: "Chapter 6: Metabolism",
+ url: "https://openstax.org/books/biology-ap-courses/pages/6-introduction",
+ },
+ };
+ } catch (error) {
+ console.error("[API Server] Local quiz generation failed:", error);
+ return null;
+ }
+}
+
+async function handleChatRequest(request: ChatRequest): Promise<{ text: string }> {
+ const { systemPrompt, intentGuidance, messages, userMessage } = request;
+
+ // Build the full system instruction
+ const fullSystemPrompt = `${systemPrompt}\n\n${intentGuidance}`;
+
+ // Convert messages to Gemini format
+ const contents = messages.map((m) => ({
+ role: m.role === "assistant" ? "model" : "user",
+ parts: m.parts,
+ }));
+
+ // Add the current user message
+ contents.push({
+ role: "user",
+ parts: [{ text: userMessage }],
+ });
+
+ try {
+ const response = await genai.models.generateContent({
+ model: MODEL,
+ contents,
+ config: {
+ systemInstruction: fullSystemPrompt,
+ },
+ });
+
+ const text = response.text || "I apologize, I couldn't generate a response.";
+ return { text };
+ } catch (error) {
+ console.error("[API Server] Error calling Gemini:", error);
+ throw error;
+ }
+}
+
+function parseBody(req: any): Promise {
+ return new Promise((resolve, reject) => {
+ let body = "";
+ req.on("data", (chunk: string) => {
+ body += chunk;
+ });
+ req.on("end", () => {
+ try {
+ resolve(JSON.parse(body));
+ } catch (e) {
+ reject(e);
+ }
+ });
+ req.on("error", reject);
+ });
+}
+
+async function main() {
+ console.log("[API Server] Initializing Gemini client...");
+ await initGenAI();
+
+ const server = createServer(async (req, res) => {
+ // CORS headers
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+
+ if (req.method === "OPTIONS") {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+
+ // Health check
+ if (req.url === "/health" && req.method === "GET") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ status: "healthy" }));
+ return;
+ }
+
+ // Reset message log (useful before demo)
+ if (req.url === "/reset-log" && req.method === "POST") {
+ resetLog();
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ status: "log reset", file: LOG_FILE }));
+ return;
+ }
+
+ // View current log
+ if (req.url === "/log" && req.method === "GET") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(messageLog, null, 2));
+ return;
+ }
+
+ // A2A Agent Engine endpoint
+ if (req.url === "/a2ui-agent/a2a/query" && req.method === "POST") {
+ try {
+ const body = await parseBody(req);
+ console.log("[API Server] A2A query received:", body.message);
+
+ // LOG: Client → Server request for A2UI content
+ logMessage("CLIENT_TO_SERVER", "/a2ui-agent/a2a/query", {
+ description: "Browser client requesting A2UI content generation",
+ requestBody: body,
+ });
+
+ // Parse format from message (e.g., "flashcards:context" or just "flashcards")
+ const parts = (body.message || "flashcards").split(":");
+ const format = parts[0].trim();
+ const context = parts.slice(1).join(":").trim();
+
+ let result = await queryAgentEngine(format, context);
+
+ // If quiz was requested but Agent Engine returned Flashcards or empty,
+ // generate quiz locally using Gemini
+ if (format.toLowerCase() === "quiz") {
+ const a2uiStr = JSON.stringify(result.a2ui || []);
+ const hasFlashcards = a2uiStr.includes("Flashcard");
+ const hasQuizCards = a2uiStr.includes("QuizCard");
+ const isEmpty = !result.a2ui || result.a2ui.length === 0;
+
+ if (isEmpty || (hasFlashcards && !hasQuizCards)) {
+ console.log("[API Server] Agent Engine doesn't support QuizCard, generating locally");
+ const localQuiz = await generateLocalQuiz(context);
+ if (localQuiz) {
+ result = localQuiz;
+ }
+ }
+ }
+
+ // LOG: Server → Client response with A2UI
+ logMessage("SERVER_TO_CLIENT", "/a2ui-agent/a2a/query", {
+ description: "Sending A2UI JSON payload to browser for rendering",
+ format: result.format,
+ surfaceId: result.surfaceId,
+ source: result.source,
+ a2uiMessageCount: result.a2ui?.length || 0,
+ a2uiPayload: result.a2ui,
+ });
+
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(result));
+ } catch (error: any) {
+ console.error("[API Server] A2A error:", error);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: error.message }));
+ }
+ return;
+ }
+
+ // Chat endpoint
+ if (req.url === "/api/chat" && req.method === "POST") {
+ try {
+ const body = await parseBody(req);
+ console.log("[API Server] Chat request received");
+
+ // LOG: Client → Server chat request
+ logMessage("CLIENT_TO_SERVER", "/api/chat", {
+ description: "Browser client sending chat message to Gemini",
+ userMessage: body.userMessage,
+ intentGuidance: body.intentGuidance?.substring(0, 100) + "...",
+ conversationLength: body.messages?.length || 0,
+ });
+
+ const result = await handleChatRequest(body);
+
+ // LOG: Server → Client chat response
+ logMessage("SERVER_TO_CLIENT", "/api/chat", {
+ description: "Gemini response text (conversational layer)",
+ responseText: result.text,
+ });
+
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(result));
+ } catch (error: any) {
+ console.error("[API Server] Error:", error);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: error.message }));
+ }
+ return;
+ }
+
+ // 404 for other routes
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Not found" }));
+ });
+
+ server.listen(PORT, () => {
+ console.log(`[API Server] Running on http://localhost:${PORT}`);
+ console.log(`[API Server] Chat endpoint: POST http://localhost:${PORT}/api/chat`);
+ console.log(`\n📋 MESSAGE LOG ENABLED`);
+ console.log(` Log file: ${LOG_FILE}`);
+ console.log(` View log: GET http://localhost:${PORT}/log`);
+ console.log(` Reset log: POST http://localhost:${PORT}/reset-log`);
+ console.log(`\n After running the demo, check ${LOG_FILE} for the full message sequence!\n`);
+ });
+}
+
+main().catch(console.error);
diff --git a/samples/personalized_learning/index.html b/samples/personalized_learning/index.html
new file mode 100644
index 00000000..582832f2
--- /dev/null
+++ b/samples/personalized_learning/index.html
@@ -0,0 +1,912 @@
+
+
+
+
+
+
+ Gemini for Government
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ auto_awesome
+ Gemini 3 Pro
+ expand_more
+
+
+
+
+
+
+
+
+
+
+ auto_awesome
+
+
Hello, Maria
+
+ Personalized learning chat: Bite-sized smart study tools right in the chat, based on your specific learning journey
+
+
+
+ lightbulb
+ ATP energy concepts
+
+
+ style
+ Bond energy flashcards
+
+
+ podcasts
+ Listen to podcast
+
+
+ quiz
+ Practice quiz
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/personalized_learning/learner_context/01_maria_learner_profile.txt b/samples/personalized_learning/learner_context/01_maria_learner_profile.txt
new file mode 100644
index 00000000..4342f6fd
--- /dev/null
+++ b/samples/personalized_learning/learner_context/01_maria_learner_profile.txt
@@ -0,0 +1,70 @@
+LEARNER PROFILE: MARIA
+=======================
+
+DEMOGRAPHICS
+- Name: Maria
+- Age: 20
+- Education: Pre-med student at Cymbal University
+- Goal: MCAT preparation, medical school admission
+
+LEARNING CONTEXT
+- Currently studying for the MCAT
+- Often studies while at the gym (audio learning preferred during workouts)
+- Visual-kinesthetic learner who benefits from real-world analogies
+- Athletic background - responds well to sports/fitness metaphors
+
+ACADEMIC STRENGTHS
+- AP Biology: 92% proficiency
+ - Strong understanding of cellular respiration
+ - Solid grasp of ATP's role as "energy currency"
+ - Understands metabolic pathways well
+
+ACADEMIC GAPS
+- Chemistry (Bond Energy): 65% proficiency
+ - Struggles with thermodynamic concepts
+ - Difficulty connecting chemistry to biology knowledge
+ - Needs help bridging disciplines
+
+THE MISCONCEPTION
+================
+
+What Maria learned (correctly):
+- "ATP is the energy currency of the cell"
+- "When ATP is hydrolyzed to ADP, energy is released"
+- "This energy powers cellular work like muscle contraction"
+
+How Maria interprets it (incorrectly):
+- "Energy must be STORED IN the phosphate bonds of ATP"
+- "Breaking the bond releases the stored energy, like a battery releasing charge"
+- "The bond itself contains energy that gets released"
+
+Why this matters:
+- This misconception will cause errors on MCAT chemistry questions
+- It prevents true understanding of thermodynamics
+- It creates confusion when learning about other exergonic reactions
+
+THE CORRECT UNDERSTANDING
+=========================
+
+Breaking ANY chemical bond requires energy input (bond dissociation energy).
+Energy is never "stored in" a bond.
+
+ATP hydrolysis releases energy because:
+1. The PRODUCTS (ADP + inorganic phosphate) are MORE STABLE than ATP
+2. Systems naturally move toward lower energy (more stable) states
+3. The energy difference between reactants and products is released as usable energy
+
+Think of it as: ATP is in a "strained" high-energy state. ADP + Pi is a "relaxed" low-energy state. The system releases energy when moving from strained to relaxed - not because the strain "stored" energy, but because relaxation is energetically favorable.
+
+LEARNING OBJECTIVES FOR THIS PODCAST
+====================================
+
+After listening, Maria should be able to:
+
+1. Explain why "energy stored in bonds" is misleading terminology
+2. State that breaking bonds requires energy input, not release
+3. Describe ATP hydrolysis in terms of product stability (not bond energy storage)
+4. Use Gibbs Free Energy (ΔG) to explain spontaneous reactions
+5. Apply the compressed spring analogy instead of the battery analogy
+6. Connect her biology knowledge to chemistry thermodynamics
+7. Correctly answer MCAT questions about reaction energetics
diff --git a/samples/personalized_learning/learner_context/02_chemistry_bond_energy.txt b/samples/personalized_learning/learner_context/02_chemistry_bond_energy.txt
new file mode 100644
index 00000000..b069258f
--- /dev/null
+++ b/samples/personalized_learning/learner_context/02_chemistry_bond_energy.txt
@@ -0,0 +1,83 @@
+CHEMISTRY: BOND ENERGY AND BOND STRENGTH
+Source: OpenStax Chemistry, Chapter 7 - Chemical Bonding and Molecular Geometry
+=========================================================================
+
+BOND ENERGY DEFINITION
+----------------------
+
+Bond energy (also called bond dissociation energy) is the energy REQUIRED to break a covalent bond in a gaseous substance.
+
+Key point: This is always a POSITIVE value. You must PUT IN energy to break a bond. Breaking bonds is an endothermic process.
+
+"The energy required to break a specific covalent bond in one mole of gaseous molecules is called the bond energy or the bond dissociation energy."
+
+CRITICAL INSIGHT: THE "ENERGY STORED IN BONDS" MYTH
+---------------------------------------------------
+
+The phrase "energy stored in bonds" is scientifically misleading.
+
+Bonds do NOT store energy like containers or batteries. Instead:
+- FORMING bonds RELEASES energy (exothermic)
+- BREAKING bonds REQUIRES energy (endothermic)
+
+When we say a molecule has "high energy bonds," we really mean that the molecule itself is in an unstable, high-energy configuration relative to its potential products.
+
+HOW REACTIONS RELEASE OR ABSORB ENERGY
+--------------------------------------
+
+In any chemical reaction:
+1. Energy is REQUIRED to break bonds in the reactants
+2. Energy is RELEASED when new bonds form in products
+
+The NET energy change depends on the balance:
+- If more energy is released forming products than required to break reactants → EXOTHERMIC (releases heat)
+- If more energy is required to break reactants than released forming products → ENDOTHERMIC (absorbs heat)
+
+EXAMPLE: Why does burning fuel release energy?
+
+When you burn methane (CH4 + 2O2 → CO2 + 2H2O):
+- Energy IN: Breaking C-H bonds and O=O bonds
+- Energy OUT: Forming C=O bonds and O-H bonds
+
+The C=O and O-H bonds that form are STRONGER (more stable) than the bonds that break. More energy is released forming products than consumed breaking reactants. The NET result is energy release.
+
+This is NOT because energy was "stored in" the methane bonds. It's because the products (CO2 and H2O) are more thermodynamically stable than the reactants.
+
+BOND STRENGTH AND STABILITY
+---------------------------
+
+Stronger bonds = more stable molecules = lower energy state
+
+When a reaction produces molecules with stronger bonds (more stable), energy is released because the system moves to a lower energy state.
+
+Think of it like a ball rolling downhill:
+- High position = high potential energy = less stable
+- Low position = low potential energy = more stable
+- The ball releases energy (kinetic) as it moves to the more stable position
+
+Similarly:
+- Reactants at higher energy = less stable configuration
+- Products at lower energy = more stable configuration
+- Energy is released as the system moves toward stability
+
+COMMON BOND ENERGIES (kJ/mol)
+-----------------------------
+- C-H: 414
+- C-C: 347
+- C=C: 611
+- C≡C: 837
+- O-H: 464
+- O=O: 498
+- C=O: 803
+- N≡N: 946 (very strong, very stable)
+
+Note: Triple bonds are strongest, then double, then single. This is why N2 gas is so unreactive - the triple bond is extremely stable.
+
+KEY TAKEAWAY FOR MARIA
+----------------------
+
+When your biology textbook says "ATP releases energy," it does NOT mean energy was stored in ATP's phosphate bonds like electricity in a battery.
+
+It means: The products of ATP hydrolysis (ADP + phosphate) are MORE STABLE than ATP. The system releases energy when moving to this more stable state.
+
+This is the same principle as burning fuel - it's all about relative stability of reactants vs. products, measured by Gibbs Free Energy (ΔG).
diff --git a/samples/personalized_learning/learner_context/03_chemistry_thermodynamics.txt b/samples/personalized_learning/learner_context/03_chemistry_thermodynamics.txt
new file mode 100644
index 00000000..5d4ce18f
--- /dev/null
+++ b/samples/personalized_learning/learner_context/03_chemistry_thermodynamics.txt
@@ -0,0 +1,165 @@
+CHEMISTRY: THERMODYNAMICS AND GIBBS FREE ENERGY
+Source: OpenStax Chemistry, Chapters 5 & 16 - Thermochemistry and Thermodynamics
+================================================================================
+
+WHAT IS THERMODYNAMICS?
+-----------------------
+
+Thermodynamics is the study of relationships between energy and work in chemical and physical processes. It tells us:
+- Whether a reaction will happen spontaneously
+- How much energy is involved
+- Which direction a reaction will go
+
+SPONTANEOUS PROCESSES
+---------------------
+
+A spontaneous process occurs naturally without continuous external energy input.
+
+Important: "Spontaneous" does NOT mean "fast." It means the process is thermodynamically favorable - the system naturally moves in that direction.
+
+Examples of spontaneous processes:
+- Ice melting at room temperature
+- A ball rolling downhill
+- Iron rusting
+- ATP hydrolysis to ADP + Pi
+
+What makes a process spontaneous? The system moves toward greater stability (lower free energy).
+
+ENTHALPY (ΔH)
+-------------
+
+Enthalpy change measures heat absorbed or released at constant pressure.
+
+- Negative ΔH = EXOTHERMIC = releases heat
+- Positive ΔH = ENDOTHERMIC = absorbs heat
+
+Calculation: ΔH = (Energy to break bonds in reactants) - (Energy released forming bonds in products)
+
+If products have stronger bonds than reactants, ΔH is negative (exothermic).
+
+ENTROPY (ΔS)
+------------
+
+Entropy is a measure of disorder or dispersal of energy and matter.
+
+- Systems tend toward higher entropy (more disorder)
+- Positive ΔS = increase in disorder = favorable
+- Negative ΔS = decrease in disorder = unfavorable
+
+Examples:
+- Gas expanding into vacuum → entropy increases (favorable)
+- Ice melting to liquid → entropy increases (favorable)
+- Organizing a messy room → entropy decreases (requires energy input)
+
+GIBBS FREE ENERGY (ΔG) - THE KEY CONCEPT
+----------------------------------------
+
+Gibbs Free Energy combines enthalpy and entropy to predict spontaneity:
+
+ ΔG = ΔH - TΔS
+
+Where:
+- ΔG = Gibbs free energy change
+- ΔH = enthalpy change
+- T = temperature (in Kelvin)
+- ΔS = entropy change
+
+THE RULE:
+- ΔG < 0 (negative) → SPONTANEOUS (favorable, releases free energy)
+- ΔG > 0 (positive) → NON-SPONTANEOUS (unfavorable, requires energy input)
+- ΔG = 0 → AT EQUILIBRIUM
+
+WHAT DOES NEGATIVE ΔG REALLY MEAN?
+----------------------------------
+
+When ΔG is negative, the PRODUCTS have LOWER FREE ENERGY than the REACTANTS.
+
+Lower free energy = more stable
+
+So a spontaneous reaction (negative ΔG) is one where the products are more stable than the reactants. Energy is released because the system moves "downhill" energetically to a more stable state.
+
+This is NOT because energy was "stored" somewhere and got released. It's because stability increased, and that stability difference manifests as released energy.
+
+THE SECOND LAW OF THERMODYNAMICS
+--------------------------------
+
+All spontaneous processes increase the entropy of the universe.
+
+For a process to be spontaneous:
+- Either it releases enough heat to compensate for any entropy decrease
+- Or it increases entropy enough to compensate for any heat absorbed
+- Or both (exothermic AND entropy increase = definitely spontaneous)
+
+ATP HYDROLYSIS IN THERMODYNAMIC TERMS
+-------------------------------------
+
+ATP + H2O → ADP + Pi
+
+ΔG° ≈ -30.5 kJ/mol (under cellular conditions)
+
+This negative ΔG tells us:
+- The reaction is SPONTANEOUS
+- Products (ADP + Pi) are MORE STABLE than reactants (ATP)
+- Energy is released because the system moves to a lower energy state
+
+The -30.5 kJ/mol is NOT "energy stored in the phosphate bond."
+It is the FREE ENERGY DIFFERENCE between ATP and its products.
+
+WHY IS ADP + Pi MORE STABLE THAN ATP?
+-------------------------------------
+
+Several factors make the products lower in free energy:
+
+1. ELECTROSTATIC REPULSION
+ - ATP has four negative charges crowded on three phosphate groups
+ - These negative charges repel each other
+ - Hydrolysis separates these charges, reducing repulsion
+ - Less repulsion = more stable = lower energy
+
+2. RESONANCE STABILIZATION
+ - Inorganic phosphate (Pi) has excellent resonance stabilization
+ - More resonance structures = more stable electron distribution
+ - ADP also has better resonance than ATP
+
+3. SOLVATION (HYDRATION)
+ - ADP and Pi are better stabilized by surrounding water molecules
+ - Better solvation = lower energy = more stable
+
+Combined, these factors make ADP + Pi about 30.5 kJ/mol more stable than ATP under cellular conditions.
+
+THE COMPRESSED SPRING ANALOGY
+-----------------------------
+
+Think of ATP like a COMPRESSED SPRING, not like a battery:
+
+Compressed spring:
+- High potential energy (strained state)
+- Unstable, wants to release
+- Energy not "stored in the metal"
+- Energy is in the CONFIGURATION
+
+When spring releases:
+- Moves to relaxed state
+- Lower potential energy (stable state)
+- Can do work during transition
+
+Similarly, ATP:
+- High free energy (strained molecular configuration)
+- Unstable due to charge repulsion
+- Energy not "stored in bonds"
+- Energy is in the unstable CONFIGURATION
+
+When ATP hydrolyzes:
+- Moves to stable state (ADP + Pi)
+- Lower free energy
+- Releases ~30.5 kJ/mol that can do cellular work
+
+KEY TAKEAWAYS
+-------------
+
+1. ΔG (Gibbs Free Energy) determines spontaneity
+2. Negative ΔG means products are MORE STABLE than reactants
+3. Energy is released when systems move toward stability
+4. ATP hydrolysis has ΔG = -30.5 kJ/mol because ADP + Pi is more stable
+5. This is NOT "energy stored in bonds" - it's a stability difference
+6. The compressed spring is a better analogy than a battery
diff --git a/samples/personalized_learning/learner_context/04_biology_atp_cellular_respiration.txt b/samples/personalized_learning/learner_context/04_biology_atp_cellular_respiration.txt
new file mode 100644
index 00000000..93879258
--- /dev/null
+++ b/samples/personalized_learning/learner_context/04_biology_atp_cellular_respiration.txt
@@ -0,0 +1,142 @@
+BIOLOGY: ATP AND CELLULAR RESPIRATION
+Source: OpenStax Biology, Chapter 6 - Metabolism & Chapter 7 - Cellular Respiration
+===================================================================================
+
+ATP: THE ENERGY CURRENCY OF THE CELL
+------------------------------------
+
+Adenosine triphosphate (ATP) is the primary energy carrier in all living cells.
+
+Structure:
+- Adenine (nitrogenous base)
+- Ribose (5-carbon sugar)
+- Three phosphate groups (α, β, γ)
+
+The phosphate groups are connected by phosphoanhydride bonds. The bond between the β and γ phosphates is the one typically hydrolyzed in energy-releasing reactions.
+
+ATP HYDROLYSIS
+--------------
+
+ATP + H2O → ADP + Pi + Energy
+
+When the terminal (γ) phosphate is removed:
+- ATP becomes ADP (adenosine diphosphate)
+- Inorganic phosphate (Pi) is released
+- Energy is released that can power cellular work
+
+This energy drives:
+- Muscle contraction
+- Active transport across membranes
+- Biosynthesis of molecules
+- Cell division
+- Nerve impulse transmission
+
+HOW MUCH ENERGY?
+----------------
+
+Under standard conditions: ΔG° = -30.5 kJ/mol
+Under cellular conditions: ΔG can be -50 to -65 kJ/mol (even more favorable!)
+
+This is significant - enough to drive many cellular processes.
+
+THE "ENERGY CURRENCY" METAPHOR
+------------------------------
+
+ATP is called "energy currency" because:
+- It's the common medium of energy exchange
+- Catabolic reactions (breaking down food) "deposit" energy by making ATP
+- Anabolic reactions (building molecules) "withdraw" energy by using ATP
+- It's constantly recycled (humans make ~40 kg of ATP per day!)
+
+However, the "currency" metaphor can be misleading if students think ATP "stores" energy like a wallet stores money. A better understanding:
+
+ATP is like a CHARGED BATTERY that powers devices, but the "charge" isn't stored IN the battery material - the battery is in a high-energy state that releases energy when it moves to a lower-energy state.
+
+Even better: ATP is like a COMPRESSED SPRING ready to release.
+
+CELLULAR RESPIRATION: MAKING ATP
+--------------------------------
+
+Cellular respiration harvests energy from glucose to make ATP:
+
+C6H12O6 + 6O2 → 6CO2 + 6H2O + ~30-32 ATP
+
+Three main stages:
+1. Glycolysis (cytoplasm): Glucose → 2 Pyruvate + 2 ATP
+2. Citric Acid Cycle (mitochondria): Pyruvate → CO2 + electron carriers
+3. Oxidative Phosphorylation (mitochondria): Electron carriers → ~26-28 ATP
+
+WHY DOES GLUCOSE OXIDATION RELEASE ENERGY?
+------------------------------------------
+
+The same principle as ATP hydrolysis:
+
+The products (CO2 + H2O) are MORE STABLE than the reactants (glucose + O2).
+
+- CO2 has very strong C=O double bonds (803 kJ/mol each)
+- H2O has strong O-H bonds (464 kJ/mol each)
+- These products are in a lower energy state than glucose
+
+The energy difference is captured by converting ADP + Pi → ATP.
+
+So glucose doesn't "contain" energy that gets released - rather, breaking down glucose produces more stable products, and that stability difference is captured as ATP.
+
+COUPLING REACTIONS
+------------------
+
+ATP hydrolysis is "coupled" to energy-requiring reactions:
+
+Example - Muscle contraction:
+- Myosin needs energy to change shape and pull actin
+- ATP binds to myosin
+- ATP hydrolysis releases energy
+- Energy drives the conformational change
+- Muscle contracts
+
+The spontaneous reaction (ATP → ADP + Pi, negative ΔG) drives the non-spontaneous reaction (muscle protein shape change, positive ΔG).
+
+Combined: ΔG_total = ΔG_ATP + ΔG_muscle = negative (spontaneous overall)
+
+ATP-ADP CYCLE
+-------------
+
+ATP is constantly recycled:
+
+ENERGY IN (from food):
+Cellular Respiration: ADP + Pi → ATP (requires energy)
+
+ENERGY OUT (for work):
+Cellular Work: ATP → ADP + Pi (releases energy)
+
+A typical cell turns over its entire ATP pool every 1-2 minutes!
+
+THE KEY INSIGHT FOR MARIA
+-------------------------
+
+Biology correctly teaches that:
+- ATP hydrolysis releases energy
+- This energy powers cellular work
+- ATP is constantly regenerated
+
+The chemistry clarification:
+- The energy isn't "stored IN" the phosphate bonds
+- ATP is in a high-energy, unstable molecular configuration
+- ADP + Pi is in a lower-energy, more stable configuration
+- The energy "released" is the difference between these states (ΔG)
+- This is measured by Gibbs Free Energy
+
+UNIFYING BIOLOGY AND CHEMISTRY
+------------------------------
+
+Both subjects teach the same fundamental principle:
+- Systems move toward lower energy (greater stability)
+- When they do, energy is released
+- This is true for glucose → CO2 + H2O
+- This is true for ATP → ADP + Pi
+- This is true for any spontaneous reaction
+
+The language differs:
+- Biology: "Energy is released from ATP"
+- Chemistry: "Products are more stable than reactants (negative ΔG)"
+
+Both are describing the same phenomenon. Maria already understands this from biology - she just needs to connect it to chemistry terminology.
diff --git a/samples/personalized_learning/learner_context/05_misconception_resolution.txt b/samples/personalized_learning/learner_context/05_misconception_resolution.txt
new file mode 100644
index 00000000..a767fa41
--- /dev/null
+++ b/samples/personalized_learning/learner_context/05_misconception_resolution.txt
@@ -0,0 +1,188 @@
+RESOLVING THE ATP BOND ENERGY MISCONCEPTION
+============================================
+
+THE MISCONCEPTION
+-----------------
+
+"Energy is stored in ATP's phosphate bonds, and when the bond breaks, that stored energy is released - like a battery releasing stored electrical charge."
+
+This is one of the most common misconceptions in biology education, reinforced by casual language in textbooks.
+
+WHY STUDENTS DEVELOP THIS MISCONCEPTION
+---------------------------------------
+
+1. Textbook language: "Energy stored in ATP" is a common shorthand
+2. Battery analogy: Students naturally compare ATP to batteries
+3. Intuition: It "feels right" that breaking something releases what was inside
+4. Incomplete explanation: Biology courses often skip the chemistry details
+
+WHY THE MISCONCEPTION IS WRONG
+------------------------------
+
+FACT: Breaking ANY chemical bond requires energy input.
+
+This is the definition of bond energy (bond dissociation energy) - the energy REQUIRED to break a bond. It's always positive. You must PUT energy INTO a system to break bonds.
+
+If breaking bonds released energy, molecules would spontaneously fall apart. They don't, because bonds represent stable, low-energy configurations of electrons.
+
+THE CORRECT UNDERSTANDING
+-------------------------
+
+ATP hydrolysis releases energy because:
+
+1. ATP is in an UNSTABLE, high-energy molecular configuration
+ - Four negative charges crowded on three phosphate groups
+ - Electrostatic repulsion makes this configuration strained
+
+2. ADP + Pi is a MORE STABLE, lower-energy configuration
+ - Charges are more separated
+ - Better resonance stabilization
+ - Better solvation by water
+
+3. Energy is released when the system moves from unstable → stable
+ - This is measured by Gibbs Free Energy (ΔG)
+ - ΔG = -30.5 kJ/mol for ATP hydrolysis
+ - Negative ΔG means products are more stable
+
+THE COMPRESSED SPRING ANALOGY (CORRECT)
+---------------------------------------
+
+Think of ATP as a COMPRESSED SPRING:
+
+Compressed spring:
+- High potential energy
+- In a strained, unstable state
+- Energy is NOT "stored in the metal"
+- Energy is in the CONFIGURATION
+
+When spring is released:
+- Moves to relaxed state
+- Releases energy
+- Can do work (push something)
+- Now in low-energy, stable state
+
+ATP similarly:
+- High free energy (strained configuration)
+- Phosphate charges repel each other
+- Energy is NOT "stored in the bond"
+- Energy is in the unstable CONFIGURATION
+
+When ATP hydrolyzes:
+- Moves to stable state (ADP + Pi)
+- Releases ~30.5 kJ/mol
+- Can do cellular work
+- Products are more stable
+
+WHY THE BATTERY ANALOGY IS WRONG
+--------------------------------
+
+Battery:
+- Stores charge in a concentrated form
+- Releasing charge provides energy
+- The stored entity (charge) IS the energy
+
+ATP:
+- Doesn't "store" energy in bonds
+- The phosphate bond itself is quite ordinary
+- Energy comes from RELATIVE STABILITY difference
+- Breaking the bond actually requires energy
+
+A better battery comparison: It's like saying a battery at the top of a hill has "more energy" than one at the bottom. The battery itself hasn't changed - its POSITION (configuration) determines its energy relative to surroundings.
+
+SPORTS/GYM ANALOGIES FOR MARIA
+------------------------------
+
+ANALOGY 1: The Plank Position
+
+Holding a plank:
+- Your body is in a high-energy, unstable position
+- It takes continuous effort to maintain
+- You feel the "strain" of the position
+
+Collapsing to the ground:
+- Your body moves to a low-energy, stable position
+- You release effort/energy
+- The ground didn't "store" energy - you just moved to stability
+
+ATP = your body in plank position (strained, unstable)
+ADP + Pi = your body on the ground (relaxed, stable)
+Energy released = the effort difference between positions
+
+ANALOGY 2: Stretching a Resistance Band
+
+Stretched band:
+- High potential energy
+- Unstable, wants to snap back
+- Energy in the stretched CONFIGURATION
+
+Released band:
+- Low potential energy
+- Stable, relaxed state
+- Can do work while releasing
+
+ATP = stretched resistance band
+ADP + Pi = relaxed band
+Energy = stored in the stretch, not "in the rubber"
+
+ANALOGY 3: Water Behind a Dam
+
+Water at high elevation:
+- High potential energy
+- Unstable relative to lower positions
+- Can do work (hydroelectric) when released
+
+Water at low elevation:
+- Low potential energy
+- More stable position
+- Energy was released during the fall
+
+ATP = water behind dam (high energy configuration)
+ADP + Pi = water in lower reservoir (stable configuration)
+Energy = the elevation difference, not "stored in water"
+
+CORRECTED LANGUAGE
+------------------
+
+INSTEAD OF: "ATP stores energy in its bonds"
+SAY: "ATP is in a high-energy, unstable configuration"
+
+INSTEAD OF: "Breaking ATP bonds releases stored energy"
+SAY: "ATP hydrolysis releases energy because the products are more stable"
+
+INSTEAD OF: "Energy comes from the phosphate bond"
+SAY: "Energy comes from the stability difference between ATP and ADP + Pi"
+
+MCAT IMPLICATIONS
+-----------------
+
+The MCAT tests this concept explicitly:
+
+Correct answers involve understanding:
+- Breaking bonds requires energy (endothermic)
+- Forming bonds releases energy (exothermic)
+- Net reaction energy depends on bond energies of reactants vs. products
+- ΔG determines spontaneity, not "stored energy"
+
+Wrong answers often include:
+- "Energy is released when bonds break"
+- "High-energy bonds store more energy"
+- "ATP releases energy because its bonds are weak"
+
+Maria's corrected understanding will help her avoid these traps.
+
+THE UNIFIED PRINCIPLE
+---------------------
+
+Both biology and chemistry teach the same core idea:
+
+SYSTEMS MOVE TOWARD LOWER ENERGY (GREATER STABILITY)
+
+When they do, energy is released that can do work.
+
+This applies to:
+- ATP → ADP + Pi (cellular energy)
+- Glucose → CO2 + H2O (respiration)
+- Food → smaller molecules (digestion)
+- Any spontaneous chemical reaction
+
+Maria already understands this intuitively from biology. The chemistry just gives it precise language (Gibbs Free Energy) and explains WHY ATP products are more stable (electrostatics, resonance, solvation).
diff --git a/samples/personalized_learning/learner_context/06_mcat_practice_concepts.txt b/samples/personalized_learning/learner_context/06_mcat_practice_concepts.txt
new file mode 100644
index 00000000..5e12639f
--- /dev/null
+++ b/samples/personalized_learning/learner_context/06_mcat_practice_concepts.txt
@@ -0,0 +1,157 @@
+MCAT PRACTICE: BOND ENERGY AND THERMODYNAMICS
+==============================================
+
+This document contains the key concepts and question patterns Maria should master for MCAT success.
+
+CORE MCAT CONCEPTS
+------------------
+
+1. BOND ENERGY IS ENERGY REQUIRED TO BREAK A BOND
+ - Always positive (endothermic)
+ - Higher bond energy = stronger bond = more stable
+ - Breaking bonds costs energy; forming bonds releases energy
+
+2. ENTHALPY OF REACTION (ΔH)
+ ΔH = Σ(bond energies broken) - Σ(bond energies formed)
+
+ - If ΔH < 0: Exothermic (more energy released forming bonds than consumed breaking bonds)
+ - If ΔH > 0: Endothermic (more energy consumed breaking bonds than released forming bonds)
+
+3. GIBBS FREE ENERGY (ΔG)
+ ΔG = ΔH - TΔS
+
+ - If ΔG < 0: Spontaneous (products more stable than reactants)
+ - If ΔG > 0: Non-spontaneous (products less stable than reactants)
+ - If ΔG = 0: At equilibrium
+
+4. RELATIONSHIP BETWEEN STABILITY AND ENERGY
+ - Lower energy = more stable
+ - Spontaneous reactions move toward stability (lower ΔG)
+ - Energy is released when systems become more stable
+
+COMMON MCAT QUESTION TYPES
+--------------------------
+
+TYPE 1: Calculate ΔH from bond energies
+
+Example:
+Given: C-H = 414 kJ/mol, O=O = 498 kJ/mol, C=O = 803 kJ/mol, O-H = 464 kJ/mol
+
+Calculate ΔH for: CH4 + 2O2 → CO2 + 2H2O
+
+Solution:
+Bonds broken: 4(C-H) + 2(O=O) = 4(414) + 2(498) = 2652 kJ
+Bonds formed: 2(C=O) + 4(O-H) = 2(803) + 4(464) = 3462 kJ
+ΔH = 2652 - 3462 = -810 kJ/mol (exothermic)
+
+Key insight: More energy released forming products than consumed breaking reactants.
+
+TYPE 2: Predict spontaneity
+
+Example:
+A reaction has ΔH = +50 kJ/mol and ΔS = +200 J/mol·K. At what temperature does it become spontaneous?
+
+Solution:
+Spontaneous when ΔG < 0
+ΔG = ΔH - TΔS < 0
+ΔH < TΔS
+T > ΔH/ΔS = 50,000 J / 200 J/K = 250 K
+
+Above 250 K, the entropy increase overcomes the positive enthalpy.
+
+TYPE 3: Compare stability
+
+Example:
+Why is ATP hydrolysis spontaneous?
+
+WRONG answer: "Because energy stored in the phosphate bond is released"
+CORRECT answer: "Because the products (ADP + Pi) are more thermodynamically stable than ATP due to reduced electrostatic repulsion, increased resonance stabilization, and improved solvation"
+
+TYPE 4: Coupled reactions
+
+Example:
+Given: ATP hydrolysis: ΔG = -30.5 kJ/mol
+ Glutamine synthesis: ΔG = +14.2 kJ/mol
+
+Is the coupled reaction spontaneous?
+
+Solution:
+ΔG_total = -30.5 + 14.2 = -16.3 kJ/mol
+Yes, spontaneous (negative ΔG)
+
+ATP hydrolysis drives the otherwise unfavorable synthesis.
+
+CRITICAL DISTINCTIONS
+---------------------
+
+BOND ENERGY vs. STABILITY
+- High bond energy = strong bond = stable molecule
+- It takes MORE energy to break strong bonds
+- Strong bonds don't "store" more energy - they're just harder to break
+
+EXOTHERMIC vs. SPONTANEOUS
+- Exothermic (ΔH < 0): Releases heat
+- Spontaneous (ΔG < 0): Thermodynamically favorable
+
+A reaction can be:
+- Exothermic AND spontaneous (most common for favorable reactions)
+- Endothermic AND spontaneous (if TΔS is large enough)
+- Exothermic AND non-spontaneous (if TΔS is negative enough)
+
+BREAKING vs. FORMING BONDS
+- Breaking bonds: ALWAYS requires energy (endothermic step)
+- Forming bonds: ALWAYS releases energy (exothermic step)
+- Net effect depends on the balance
+
+ATP-SPECIFIC MCAT POINTS
+------------------------
+
+1. ATP hydrolysis ΔG° ≈ -30.5 kJ/mol under standard conditions
+
+2. Why is ATP high-energy?
+ - Electrostatic repulsion between phosphate groups
+ - NOT because the P-O bond is "weak" or "stores energy"
+
+3. Why is ADP + Pi more stable?
+ - Charge separation reduces repulsion
+ - Better resonance in phosphate ion
+ - Better hydration of products
+
+4. ATP coupling
+ - Spontaneous ATP hydrolysis can drive non-spontaneous reactions
+ - Combined ΔG must be negative for the coupled reaction to proceed
+
+5. ATP is constantly recycled
+ - Humans produce ~40 kg ATP per day
+ - Same ATP molecules recycled thousands of times
+ - ATP ↔ ADP + Pi is a cycle, not a one-way consumption
+
+PRACTICE QUESTION FOR MARIA
+---------------------------
+
+Question:
+A student claims that "ATP is a high-energy molecule because it stores a lot of energy in its phosphate bonds, which is released when the bonds break."
+
+Which of the following best corrects this misconception?
+
+A) ATP bonds are actually weak and break easily, releasing stored energy.
+B) Breaking any chemical bond requires energy input; ATP hydrolysis releases energy because the products are more thermodynamically stable.
+C) ATP has more electrons than ADP, which is why breaking it releases energy.
+D) The energy comes from the adenine base, not the phosphate groups.
+
+Correct Answer: B
+
+Explanation: Breaking bonds ALWAYS requires energy input (positive bond dissociation energy). ATP hydrolysis releases energy because ADP + Pi (the products) are in a lower free energy state than ATP (more stable). This is due to reduced electrostatic repulsion, better resonance stabilization, and improved solvation - NOT because energy was "stored in" the bond.
+
+SUMMARY FOR MARIA
+-----------------
+
+Remember these key points for MCAT:
+
+1. Breaking bonds = costs energy (always)
+2. Forming bonds = releases energy (always)
+3. Net ΔH = bonds broken - bonds formed
+4. ΔG determines spontaneity (negative = spontaneous)
+5. ATP releases energy because products are MORE STABLE
+6. "High-energy bond" means the MOLECULE is unstable, not that the bond stores energy
+7. Compressed spring analogy > battery analogy
diff --git a/samples/personalized_learning/package.json b/samples/personalized_learning/package.json
new file mode 100644
index 00000000..1058ea59
--- /dev/null
+++ b/samples/personalized_learning/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "personalized-learning-demo",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A2UI sample demonstrating personalized educational content generation",
+ "type": "module",
+ "scripts": {
+ "dev": "concurrently \"npm run dev:api\" \"npm run dev:vite\"",
+ "dev:vite": "vite",
+ "dev:api": "tsx api-server.ts",
+ "dev:agent": "cd agent && python server.py",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "start:all": "concurrently \"npm run dev:api\" \"npm run dev:vite\" \"npm run dev:agent\"",
+ "test": "node tests/unit-tests.mjs && node tests/integration-tests.mjs",
+ "test:unit": "node tests/unit-tests.mjs",
+ "test:integration": "node tests/integration-tests.mjs",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@a2ui/web-lib": "file:../../renderers/lit",
+ "@google/genai": "^1.22.0",
+ "@lit-labs/signals": "^0.1.3",
+ "@lit/context": "^1.1.4",
+ "lit": "^3.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "^20.10.0",
+ "concurrently": "^8.2.2",
+ "dotenv": "^16.3.1",
+ "tsx": "^4.7.0",
+ "typescript": "^5.3.2",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/samples/personalized_learning/public/assets/.gitkeep b/samples/personalized_learning/public/assets/.gitkeep
new file mode 100644
index 00000000..ecb4871d
--- /dev/null
+++ b/samples/personalized_learning/public/assets/.gitkeep
@@ -0,0 +1,8 @@
+# This directory holds audio/video assets for the demo.
+# See NOTEBOOKLM_GUIDE.md for instructions on generating these files.
+#
+# Expected files:
+# - podcast.m4a (Audio podcast generated from NotebookLM)
+# - demo.mp4 (Educational video)
+#
+# These files are gitignored due to their size.
diff --git a/samples/personalized_learning/public/favicon.svg b/samples/personalized_learning/public/favicon.svg
new file mode 100644
index 00000000..dde03b96
--- /dev/null
+++ b/samples/personalized_learning/public/favicon.svg
@@ -0,0 +1,22 @@
+
diff --git a/samples/personalized_learning/public/maria-context.html b/samples/personalized_learning/public/maria-context.html
new file mode 100644
index 00000000..3cc88546
--- /dev/null
+++ b/samples/personalized_learning/public/maria-context.html
@@ -0,0 +1,467 @@
+
+
+
+
+
+ Learner Profile: Maria Thompson
+
+
+
+
+
+
+
+
+ psychology
+
+
+
Learner Context Profile
+
session_id: mcat-prep-2025-maria
+
+
+
+
+
+
MT
+
+
Maria Thompson
+
Pre-Med Student, Cymbal University
+
+ MCAT Prep
+ AP Biology: 92%
+ AP Chemistry: Struggling
+
+
+
+
+
+
Identified Misconception
+
+
+
+ warning
+
+
ATP & Bond Energy
+
+
+
+ Energy is stored in ATP bonds like a battery. When we break the phosphate bonds, that stored energy is released.
+
+
+ check_circle
+ Correct understanding: ATP releases energy because the products (ADP + Pi) are more thermodynamically stable, not because energy was "stored" in the bonds.
+
+
+
+
+
+
+
Learning Preferences
+
+
+
Learning Style
+
Visual-Kinesthetic
+
+
+
Preferred Analogies
+
Sports & Gym
+
+
+
Study Mode
+
Active recall, spaced repetition
+
+
+
Best Time
+
Morning sessions
+
+
+
+
+
+
Subject Proficiency
+
+
+ AP Biology
+
+
+
+ 92%
+
+
+ AP Chemistry
+
+
+
+ 68%
+
+
+ AP Physics
+
+
+
+ 85%
+
+
+
+
+
+
+
+
+
diff --git a/samples/personalized_learning/src/a2a-client.ts b/samples/personalized_learning/src/a2a-client.ts
new file mode 100644
index 00000000..76290aeb
--- /dev/null
+++ b/samples/personalized_learning/src/a2a-client.ts
@@ -0,0 +1,548 @@
+/*
+ * A2A Client
+ *
+ * Client for communicating with the A2A agent that generates A2UI content.
+ * This client talks to the remote agent (locally or on Agent Engine).
+ */
+
+export interface SourceInfo {
+ url: string;
+ title: string;
+ provider: string;
+}
+
+export interface A2UIResponse {
+ format: string;
+ a2ui: unknown[];
+ surfaceId: string;
+ source?: SourceInfo;
+ error?: string;
+}
+
+export class A2AClient {
+ private baseUrl: string;
+
+ constructor(baseUrl: string = "/a2ui-agent") {
+ this.baseUrl = baseUrl;
+ }
+
+ /**
+ * Generate A2UI content from the agent.
+ */
+ async generateContent(
+ format: string,
+ context: string = ""
+ ): Promise {
+ console.log(`[A2AClient] Requesting ${format} content`);
+
+ try {
+ const response = await fetch(`${this.baseUrl}/a2a/query`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ message: context ? `${format}:${context}` : format,
+ session_id: this.getSessionId(),
+ extensions: ["https://a2ui.org/a2a-extension/a2ui/v0.8"],
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Agent error: ${response.status}`);
+ }
+
+ const data = await response.json();
+ console.log(`[A2AClient] Received response:`, data);
+
+ // Check if the response has valid A2UI content
+ // If empty, has an error, or agent couldn't fulfill request, use fallback
+ if (
+ !data.a2ui ||
+ data.a2ui.length === 0 ||
+ data.error ||
+ data.rawText?.includes("cannot fulfill") ||
+ data.rawText?.includes("do not have the functionality")
+ ) {
+ console.log(`[A2AClient] Agent returned empty/error, using fallback for ${format}`);
+ return this.getFallbackContent(format);
+ }
+
+ // Special case: if we requested a quiz but agent returned flashcards,
+ // use our quiz fallback instead (agent doesn't know about QuizCard)
+ if (format.toLowerCase() === "quiz") {
+ const a2uiStr = JSON.stringify(data.a2ui);
+ if (a2uiStr.includes("Flashcard") && !a2uiStr.includes("QuizCard")) {
+ console.log(`[A2AClient] Agent returned Flashcards for quiz request, using QuizCard fallback`);
+ return this.getFallbackContent(format);
+ }
+ }
+
+ return data as A2UIResponse;
+ } catch (error) {
+ console.error("[A2AClient] Error calling agent:", error);
+
+ // Return fallback content for demo purposes
+ return this.getFallbackContent(format);
+ }
+ }
+
+ /**
+ * Stream A2UI content from the agent (for long-running generation).
+ */
+ async *streamContent(
+ format: string,
+ context: string = ""
+ ): AsyncGenerator<{ status: string; data?: A2UIResponse }> {
+ console.log(`[A2AClient] Streaming ${format} content`);
+
+ try {
+ const response = await fetch(`${this.baseUrl}/a2a/stream`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ message: context ? `${format}:${context}` : format,
+ session_id: this.getSessionId(),
+ extensions: ["https://a2ui.org/a2a-extension/a2ui/v0.8"],
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Agent error: ${response.status}`);
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error("No response body");
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Parse SSE events
+ const lines = buffer.split("\n\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ const data = JSON.parse(line.slice(6));
+ if (data.is_task_complete) {
+ yield { status: "complete", data: data.content };
+ } else {
+ yield { status: "processing" };
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("[A2AClient] Stream error:", error);
+ yield { status: "complete", data: this.getFallbackContent(format) };
+ }
+ }
+
+ /**
+ * Get or create a session ID.
+ */
+ private getSessionId(): string {
+ let sessionId = sessionStorage.getItem("a2ui_session_id");
+ if (!sessionId) {
+ sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ sessionStorage.setItem("a2ui_session_id", sessionId);
+ }
+ return sessionId;
+ }
+
+ /**
+ * Get fallback content for demo purposes when agent is unavailable.
+ */
+ private getFallbackContent(format: string): A2UIResponse {
+ const surfaceId = "learningContent";
+
+ switch (format.toLowerCase()) {
+ case "flashcards":
+ return {
+ format: "flashcards",
+ surfaceId,
+ a2ui: [
+ { beginRendering: { surfaceId, root: "mainColumn" } },
+ {
+ surfaceUpdate: {
+ surfaceId,
+ components: [
+ {
+ id: "mainColumn",
+ component: {
+ Column: {
+ children: { explicitList: ["headerText", "flashcardRow"] },
+ distribution: "start",
+ alignment: "stretch",
+ },
+ },
+ },
+ {
+ id: "headerText",
+ component: {
+ Text: {
+ text: { literalString: "Study Flashcards: ATP & Bond Energy" },
+ usageHint: "h3",
+ },
+ },
+ },
+ {
+ id: "flashcardRow",
+ component: {
+ Row: {
+ children: { explicitList: ["card1", "card2", "card3"] },
+ distribution: "start",
+ alignment: "stretch",
+ },
+ },
+ },
+ {
+ id: "card1",
+ component: {
+ Flashcard: {
+ front: { literalString: "Why does ATP hydrolysis release energy?" },
+ back: {
+ literalString:
+ "ATP hydrolysis releases energy because the products (ADP + Pi) are MORE STABLE than ATP. The phosphate groups in ATP repel each other due to negative charges. When the terminal phosphate is removed, this electrostatic strain is relieved, and the products achieve better resonance stabilization. It's like releasing a compressed spring - the energy comes from moving to a lower-energy state.",
+ },
+ category: { literalString: "Biochemistry" },
+ },
+ },
+ },
+ {
+ id: "card2",
+ component: {
+ Flashcard: {
+ front: { literalString: "Does breaking a chemical bond release energy?" },
+ back: {
+ literalString:
+ "NO - this is a common MCAT trap! Breaking ANY bond REQUIRES energy input (it's endothermic). Energy is only released when NEW bonds FORM. In ATP hydrolysis, the energy released comes from forming more stable bonds in the products, not from 'breaking' the phosphate bond.",
+ },
+ category: { literalString: "Common Trap" },
+ },
+ },
+ },
+ {
+ id: "card3",
+ component: {
+ Flashcard: {
+ front: {
+ literalString:
+ "What's wrong with saying 'ATP stores energy in its bonds'?",
+ },
+ back: {
+ literalString:
+ "Bonds don't 'store' energy like batteries. Think of it like holding a plank position at the gym - you're not storing energy in your muscles, you're in a high-energy unstable state. When you release to rest (like ATP → ADP + Pi), you move to a more stable, lower-energy state. The 'energy release' is really about the stability difference between reactants and products.",
+ },
+ category: { literalString: "MCAT Concept" },
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ case "podcast":
+ case "audio":
+ return {
+ format: "audio",
+ surfaceId,
+ a2ui: [
+ { beginRendering: { surfaceId, root: "audioCard" } },
+ {
+ surfaceUpdate: {
+ surfaceId,
+ components: [
+ {
+ id: "audioCard",
+ component: { Card: { child: "audioContent" } },
+ },
+ {
+ id: "audioContent",
+ component: {
+ Column: {
+ children: {
+ explicitList: ["audioHeader", "audioPlayer", "audioDescription"],
+ },
+ distribution: "start",
+ alignment: "stretch",
+ },
+ },
+ },
+ {
+ id: "audioHeader",
+ component: {
+ Row: {
+ children: { explicitList: ["audioIcon", "audioTitle"] },
+ distribution: "start",
+ alignment: "center",
+ },
+ },
+ },
+ {
+ id: "audioIcon",
+ component: {
+ Icon: { name: { literalString: "podcasts" } },
+ },
+ },
+ {
+ id: "audioTitle",
+ component: {
+ Text: {
+ text: {
+ literalString: "ATP & Chemical Stability: Correcting the Misconception",
+ },
+ usageHint: "h3",
+ },
+ },
+ },
+ {
+ id: "audioPlayer",
+ component: {
+ AudioPlayer: {
+ url: { literalString: "/assets/podcast.m4a" },
+ audioTitle: { literalString: "Understanding ATP Energy Release" },
+ audioDescription: { literalString: "A personalized podcast about ATP and chemical stability" },
+ },
+ },
+ },
+ {
+ id: "audioDescription",
+ component: {
+ Text: {
+ text: {
+ literalString:
+ "This personalized podcast uses gym analogies to explain why 'energy stored in bonds' is a misconception. Perfect for your MCAT prep!",
+ },
+ usageHint: "body",
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ case "video":
+ return {
+ format: "video",
+ surfaceId,
+ a2ui: [
+ { beginRendering: { surfaceId, root: "videoCard" } },
+ {
+ surfaceUpdate: {
+ surfaceId,
+ components: [
+ {
+ id: "videoCard",
+ component: { Card: { child: "videoContent" } },
+ },
+ {
+ id: "videoContent",
+ component: {
+ Column: {
+ children: {
+ explicitList: ["videoTitle", "videoPlayer", "videoDescription"],
+ },
+ distribution: "start",
+ alignment: "stretch",
+ },
+ },
+ },
+ {
+ id: "videoTitle",
+ component: {
+ Text: {
+ text: { literalString: "Visual Guide: ATP Energy & Stability" },
+ usageHint: "h3",
+ },
+ },
+ },
+ {
+ id: "videoPlayer",
+ component: {
+ Video: {
+ url: { literalString: "/assets/demo.mp4" },
+ },
+ },
+ },
+ {
+ id: "videoDescription",
+ component: {
+ Text: {
+ text: {
+ literalString:
+ "Watch the compressed spring analogy in action to understand why ATP releases energy through stability differences.",
+ },
+ usageHint: "body",
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ case "quiz":
+ return {
+ format: "quiz",
+ surfaceId,
+ a2ui: [
+ { beginRendering: { surfaceId, root: "mainColumn" } },
+ {
+ surfaceUpdate: {
+ surfaceId,
+ components: [
+ {
+ id: "mainColumn",
+ component: {
+ Column: {
+ children: { explicitList: ["headerText", "quizRow"] },
+ distribution: "start",
+ alignment: "stretch",
+ },
+ },
+ },
+ {
+ id: "headerText",
+ component: {
+ Text: {
+ text: { literalString: "Quick Quiz: ATP & Bond Energy" },
+ usageHint: "h3",
+ },
+ },
+ },
+ {
+ id: "quizRow",
+ component: {
+ Row: {
+ children: { explicitList: ["quiz1", "quiz2"] },
+ distribution: "start",
+ alignment: "stretch",
+ },
+ },
+ },
+ {
+ id: "quiz1",
+ component: {
+ QuizCard: {
+ question: {
+ literalString:
+ "What happens to the energy in bonds when ATP is hydrolyzed?",
+ },
+ options: [
+ {
+ label: {
+ literalString:
+ "Energy stored in the phosphate bond is released",
+ },
+ value: "a",
+ isCorrect: false,
+ },
+ {
+ label: {
+ literalString:
+ "Energy is released because products are more stable",
+ },
+ value: "b",
+ isCorrect: true,
+ },
+ {
+ label: {
+ literalString:
+ "The bond breaking itself releases energy",
+ },
+ value: "c",
+ isCorrect: false,
+ },
+ {
+ label: {
+ literalString: "ATP's special bonds contain more electrons",
+ },
+ value: "d",
+ isCorrect: false,
+ },
+ ],
+ explanation: {
+ literalString:
+ "ATP hydrolysis releases energy because the products (ADP + Pi) are MORE STABLE than ATP. The phosphate groups in ATP repel each other, creating strain. When this bond is broken, the products achieve better resonance stabilization - like releasing a compressed spring.",
+ },
+ category: { literalString: "Thermodynamics" },
+ },
+ },
+ },
+ {
+ id: "quiz2",
+ component: {
+ QuizCard: {
+ question: {
+ literalString:
+ "Breaking a chemical bond requires or releases energy?",
+ },
+ options: [
+ {
+ label: { literalString: "Always releases energy" },
+ value: "a",
+ isCorrect: false,
+ },
+ {
+ label: { literalString: "Always requires energy input" },
+ value: "b",
+ isCorrect: true,
+ },
+ {
+ label: {
+ literalString: "Depends on whether it's a high-energy bond",
+ },
+ value: "c",
+ isCorrect: false,
+ },
+ {
+ label: { literalString: "Neither - bonds are energy neutral" },
+ value: "d",
+ isCorrect: false,
+ },
+ ],
+ explanation: {
+ literalString:
+ "Breaking ANY bond REQUIRES energy (it's endothermic). This is a common MCAT trap! Energy is only released when NEW bonds FORM. Think of it like pulling apart magnets - you have to put in effort to separate them.",
+ },
+ category: { literalString: "Bond Energy" },
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ source: {
+ provider: "OpenStax Biology for AP Courses",
+ title: "Chapter 6: Metabolism",
+ url: "https://openstax.org/books/biology-ap-courses/pages/6-introduction",
+ },
+ };
+
+ default:
+ return {
+ format: "error",
+ surfaceId,
+ a2ui: [],
+ error: `Unknown format: ${format}`,
+ };
+ }
+ }
+}
diff --git a/samples/personalized_learning/src/a2ui-renderer.ts b/samples/personalized_learning/src/a2ui-renderer.ts
new file mode 100644
index 00000000..67b13aa7
--- /dev/null
+++ b/samples/personalized_learning/src/a2ui-renderer.ts
@@ -0,0 +1,141 @@
+/*
+ * A2UI Renderer
+ *
+ * Renders A2UI JSON payloads into the chat interface using the A2UI web components.
+ * Uses the signal-based model processor for proper reactivity.
+ */
+
+import { v0_8 } from "@a2ui/web-lib";
+import type { SourceInfo } from "./a2a-client";
+
+// Type alias for the processor - use InstanceType to get the instance type from the class
+type A2UIModelProcessorInstance = InstanceType;
+
+// Extended surface element type
+interface A2UISurfaceElement extends HTMLElement {
+ surfaceId: string;
+ surface: v0_8.Types.Surface;
+ processor: A2UIModelProcessorInstance;
+}
+
+export class A2UIRenderer {
+ private processors: Map = new Map();
+
+ constructor() {
+ console.log("[A2UIRenderer] Initialized with signal model processor");
+ }
+
+ /**
+ * Render A2UI JSON into a message element.
+ */
+ render(messageElement: HTMLDivElement, a2uiMessages: unknown[], source?: SourceInfo): void {
+ console.log("[A2UIRenderer] Rendering A2UI content:", a2uiMessages);
+
+ if (!a2uiMessages || a2uiMessages.length === 0) {
+ console.warn("[A2UIRenderer] No A2UI messages to render");
+ return;
+ }
+
+ // Create a container for the A2UI content
+ const container = document.createElement("div");
+ container.className = "a2ui-container";
+
+ // Find the message content element
+ const contentEl = messageElement.querySelector(".message-content");
+ if (!contentEl) {
+ console.error("[A2UIRenderer] Message content element not found");
+ return;
+ }
+
+ contentEl.appendChild(container);
+
+ // Create a model processor for this render
+ const processor = v0_8.Data.createSignalA2UIModelProcessor();
+
+ // Process all A2UI messages to build the model
+ try {
+ processor.processMessages(a2uiMessages as v0_8.Types.ServerToClientMessage[]);
+ } catch (error) {
+ console.error("[A2UIRenderer] Error processing messages:", error);
+ }
+
+ // Now render the surfaces
+ const surfaces = processor.getSurfaces();
+ console.log("[A2UIRenderer] Surfaces to render:", Array.from(surfaces.keys()));
+
+ for (const [surfaceId, surface] of surfaces.entries()) {
+ this.renderSurface(container, surfaceId, surface, processor);
+ this.processors.set(surfaceId, processor);
+ }
+
+ // Add source attribution if available
+ if (source && (source.url || source.provider)) {
+ this.renderSourceAttribution(container, source);
+ }
+ }
+
+ /**
+ * Render source attribution below the A2UI content.
+ */
+ private renderSourceAttribution(container: HTMLElement, source: SourceInfo): void {
+ const attribution = document.createElement("div");
+ attribution.className = "source-attribution";
+
+ // If we have a URL, make it a link; otherwise just show the text
+ if (source.url) {
+ attribution.innerHTML = `
+ Source:
+
+ ${source.title || source.provider}
+
+ — ${source.provider}
+ `;
+ } else {
+ attribution.innerHTML = `
+ Source:
+ ${source.title || source.provider}
+ — ${source.provider}
+ `;
+ }
+ container.appendChild(attribution);
+ }
+
+ /**
+ * Render a surface using a2ui-surface component.
+ */
+ private renderSurface(
+ container: HTMLElement,
+ surfaceId: string,
+ surface: v0_8.Types.Surface,
+ processor: A2UIModelProcessorInstance
+ ): void {
+ console.log("[A2UIRenderer] Rendering surface:", surfaceId);
+
+ // Create the a2ui-surface element
+ const surfaceEl = document.createElement("a2ui-surface") as A2UISurfaceElement;
+
+ surfaceEl.surfaceId = surfaceId;
+ surfaceEl.surface = surface;
+ surfaceEl.processor = processor;
+
+ // Add some styling for the container
+ surfaceEl.style.display = "block";
+ surfaceEl.style.marginTop = "16px";
+
+ container.appendChild(surfaceEl);
+ }
+
+ /**
+ * Get a processor for a surface ID.
+ */
+ getProcessor(surfaceId: string): A2UIModelProcessorInstance | undefined {
+ return this.processors.get(surfaceId);
+ }
+
+ /**
+ * Clear all rendered surfaces.
+ */
+ clear(): void {
+ this.processors.clear();
+ }
+}
diff --git a/samples/personalized_learning/src/chat-orchestrator.ts b/samples/personalized_learning/src/chat-orchestrator.ts
new file mode 100644
index 00000000..61ddd5d6
--- /dev/null
+++ b/samples/personalized_learning/src/chat-orchestrator.ts
@@ -0,0 +1,421 @@
+/*
+ * Chat Orchestrator
+ *
+ * Orchestrates the chat flow between the user, Gemini 3 Pro, and the A2A agent.
+ * Determines when to generate A2UI content and manages async artifact generation.
+ */
+
+import { A2UIRenderer } from "./a2ui-renderer";
+import { A2AClient } from "./a2a-client";
+
+// Types for conversation history
+interface Message {
+ role: "user" | "assistant";
+ content: string;
+ a2ui?: unknown[];
+}
+
+// Intent types the orchestrator can detect
+type Intent =
+ | "flashcards"
+ | "podcast"
+ | "audio"
+ | "video"
+ | "quiz"
+ | "general"
+ | "greeting";
+
+export class ChatOrchestrator {
+ private conversationHistory: Message[] = [];
+ private renderer: A2UIRenderer;
+ private a2aClient: A2AClient;
+
+ // System prompt for the main chat agent
+ private systemPrompt = `You are a personalized MCAT tutor helping Maria, a pre-med student at Cymbal University.
+You have access to her learning profile and know she struggles with understanding ATP and bond energy concepts.
+
+CRITICAL RULE - KEEP RESPONSES SHORT:
+When Maria asks for flashcards, podcasts, or videos, respond with ONLY 1-2 sentences.
+The actual content (flashcards, audio player, video player) will be rendered separately by the UI system.
+DO NOT write out flashcard content, podcast transcripts, or video descriptions. Just briefly acknowledge the request.
+
+Example good responses:
+- "Here are some flashcards to help you review!" (flashcards render below)
+- "Great idea! Here's a podcast that explains this concept." (audio player renders below)
+- "Check out this video explanation!" (video player renders below)
+
+Example BAD responses (DO NOT DO THIS):
+- Writing out "Front: ... Back: ..." for flashcards
+- Writing a podcast script or transcript
+- Describing video content in detail
+
+When Maria asks a general question (not requesting materials):
+1. ANSWER it directly and helpfully first
+2. Use analogies she relates to (sports, gym, fitness)
+3. Only AFTER explaining, you may OFFER additional resources if relevant
+
+Key facts about Maria:
+- Visual-kinesthetic learner who responds well to sports/gym analogies
+- Common misconception: thinking "energy is stored in ATP bonds"
+- Correct understanding: ATP releases energy because products (ADP + Pi) are MORE STABLE
+
+CONTENT SOURCE ATTRIBUTION:
+If asked about sources, say materials come from OpenStax Biology for AP Courses, a free peer-reviewed college textbook.
+
+Keep ALL responses:
+- Short and conversational (1-2 sentences for material requests, 2-3 for explanations)
+- Friendly and encouraging
+- NEVER include the actual content of flashcards, podcasts, or videos in your text`;
+
+ constructor(renderer: A2UIRenderer) {
+ this.renderer = renderer;
+ this.a2aClient = new A2AClient();
+ }
+
+ /**
+ * Process a user message and generate a response.
+ */
+ async processMessage(
+ userMessage: string,
+ messageElement: HTMLDivElement
+ ): Promise {
+ // Add to history
+ this.conversationHistory.push({ role: "user", content: userMessage });
+
+ // Detect intent using LLM
+ const intent = await this.detectIntentWithLLM(userMessage);
+ console.log(`[Orchestrator] Detected intent: ${intent}`);
+
+ // Generate main response
+ const response = await this.generateResponse(userMessage, intent);
+
+ // Update the message element with the response text
+ this.setMessageText(messageElement, response.text);
+
+ // If we need A2UI content, fetch and render it
+ if (intent !== "general" && intent !== "greeting") {
+ // Add processing placeholder
+ const placeholder = this.addProcessingPlaceholder(
+ messageElement,
+ intent
+ );
+
+ try {
+ // Fetch A2UI content from the agent
+ const a2uiResult = await this.a2aClient.generateContent(
+ intent,
+ userMessage
+ );
+
+ // Remove placeholder
+ placeholder.remove();
+
+ // Render A2UI content with source attribution
+ if (a2uiResult && a2uiResult.a2ui) {
+ this.renderer.render(messageElement, a2uiResult.a2ui, a2uiResult.source);
+ this.conversationHistory[this.conversationHistory.length - 1].a2ui =
+ a2uiResult.a2ui;
+ }
+ } catch (error) {
+ console.error("[Orchestrator] Error fetching A2UI content:", error);
+ placeholder.innerHTML = `
+ error
+ Failed to load content. Please try again.
+ `;
+ }
+ }
+
+ // Add assistant response to history
+ this.conversationHistory.push({ role: "assistant", content: response.text });
+ }
+
+ /**
+ * Detect the user's intent using Gemini LLM.
+ * Returns the detected intent for routing to appropriate content generation.
+ */
+ private async detectIntentWithLLM(message: string): Promise {
+ const recentContext = this.conversationHistory.slice(-4).map(m =>
+ `${m.role}: ${m.content}`
+ ).join("\n");
+
+ try {
+ const response = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ systemPrompt: `You are an intent classifier. Analyze the user's message and conversation context to determine their intent.
+
+IMPORTANT: Consider the CONVERSATION CONTEXT. If the user previously discussed flashcards/podcasts/videos and says things like "yes", "sure", "do it", "render them", "show me", "ya that works" - they are CONFIRMING a previous offer.
+
+Return ONLY ONE of these exact words (nothing else):
+- flashcards - if user wants study cards, review cards, flashcards, or is confirming a flashcard offer
+- podcast - if user wants audio content, podcast, or to listen to something
+- video - if user wants to watch something or see a video
+- quiz - if user wants to be tested or take a quiz
+- greeting - if user is just saying hello/hi
+- general - for questions, explanations, or general conversation
+
+Examples:
+- "make me some flashcards" → flashcards
+- "f'cards please" → flashcards
+- "ya that works" (after flashcard offer) → flashcards
+- "render them properly" (after flashcard discussion) → flashcards
+- "sure, show me" (after any content offer) → depends on what was offered
+- "explain ATP" → general
+- "hi there" → greeting`,
+ intentGuidance: "",
+ messages: [],
+ userMessage: `Recent conversation:\n${recentContext}\n\nCurrent message: "${message}"\n\nIntent:`,
+ }),
+ });
+
+ if (!response.ok) {
+ console.warn("[Orchestrator] Intent API failed, falling back to keyword detection");
+ return this.detectIntentKeyword(message);
+ }
+
+ const data = await response.json();
+ const intentText = (data.text || "general").toLowerCase().trim();
+
+ // Map response to valid intent
+ if (intentText.includes("flashcard")) return "flashcards";
+ if (intentText.includes("podcast") || intentText.includes("audio")) return "podcast";
+ if (intentText.includes("video")) return "video";
+ if (intentText.includes("quiz")) return "quiz";
+ if (intentText.includes("greeting")) return "greeting";
+
+ return "general";
+ } catch (error) {
+ console.error("[Orchestrator] Intent detection error:", error);
+ return this.detectIntentKeyword(message);
+ }
+ }
+
+ /**
+ * Fallback keyword-based intent detection.
+ */
+ private detectIntentKeyword(message: string): Intent {
+ const lower = message.toLowerCase();
+
+ if (lower.match(/^(hi|hello|hey|good morning|good afternoon|good evening)/i)) {
+ return "greeting";
+ }
+ if (lower.match(/flash\s*card|study\s*card|review\s*card|f'?card/i)) {
+ return "flashcards";
+ }
+ if (lower.match(/podcast|audio|listen/i)) {
+ return "podcast";
+ }
+ if (lower.match(/video|watch/i)) {
+ return "video";
+ }
+ if (lower.match(/quiz|test me/i)) {
+ return "quiz";
+ }
+ return "general";
+ }
+
+ /**
+ * Generate the main chat response using Gemini.
+ */
+ private async generateResponse(
+ userMessage: string,
+ intent: Intent
+ ): Promise<{ text: string }> {
+ // Build the conversation context
+ const messages = this.conversationHistory.slice(-10).map((m) => ({
+ role: m.role,
+ parts: [{ text: m.content }],
+ }));
+
+ // Add intent-specific guidance
+ let intentGuidance = "";
+ switch (intent) {
+ case "flashcards":
+ intentGuidance =
+ "The user wants flashcards. Respond with a SHORT (1-2 sentences) conversational acknowledgment. DO NOT include the flashcard content in your response - the flashcards will be rendered separately as interactive cards below your message. Just say something brief like 'Here are some flashcards to help you review!' or 'I've created some personalized flashcards for you.'";
+ break;
+ case "podcast":
+ case "audio":
+ intentGuidance =
+ "The user wants to listen to the podcast. Respond with a SHORT (1-2 sentences) introduction. DO NOT write out the podcast transcript or script - the audio player will be rendered separately below your message. Just say something brief like 'Here's a personalized podcast about ATP!' or 'I've got a podcast that explains this with gym analogies you'll love.'";
+ break;
+ case "video":
+ intentGuidance =
+ "The user wants to watch a video. Respond with a SHORT (1-2 sentences) introduction. DO NOT describe the video content in detail - the video player will be rendered separately below your message. Just say something brief like 'Here's a video that visualizes this concept!' or 'Check out this visual explanation.'";
+ break;
+ case "quiz":
+ intentGuidance =
+ "The user wants a quiz. Respond with a SHORT (1-2 sentences) introduction. DO NOT include the quiz questions or answers in your response - the interactive quiz cards will be rendered separately below your message. Just say something brief like 'Let's test your knowledge!' or 'Here's a quick quiz to check your understanding.'";
+ break;
+ case "greeting":
+ intentGuidance =
+ "The user is greeting you. Respond warmly in 1-2 sentences and briefly mention you can help with flashcards, podcasts, videos, and quizzes for their MCAT prep.";
+ break;
+ default:
+ intentGuidance =
+ "Respond helpfully but concisely (2-3 sentences max). If the question is about ATP, bond energy, or thermodynamics, provide a clear explanation and offer to create flashcards or play the podcast for deeper learning.";
+ }
+
+ try {
+ const response = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ systemPrompt: this.systemPrompt,
+ intentGuidance,
+ messages,
+ userMessage,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return { text: data.text || data.response || "I apologize, I couldn't generate a response." };
+ } catch (error) {
+ console.error("[Orchestrator] Error calling chat API:", error);
+
+ // Fallback responses based on intent
+ return this.getFallbackResponse(intent);
+ }
+ }
+
+ /**
+ * Get a fallback response if the API fails.
+ */
+ private getFallbackResponse(intent: Intent): { text: string } {
+ switch (intent) {
+ case "flashcards":
+ return {
+ text: "Here are some personalized flashcards to help you master these concepts!",
+ };
+ case "podcast":
+ case "audio":
+ return {
+ text: "Here's the personalized podcast I mentioned! It's designed specifically for you, Maria, using gym and fitness analogies to explain why 'energy stored in bonds' is a misconception. Perfect for listening during your workout!",
+ };
+ case "video":
+ return {
+ text: "Let me show you this visual explanation. It uses the compressed spring analogy to demonstrate how ATP releases energy through stability differences, not by 'releasing stored energy from bonds.'",
+ };
+ case "quiz":
+ return {
+ text: "Let's test your understanding! Here's a quick quiz on ATP and bond energy concepts.",
+ };
+ case "greeting":
+ return {
+ text: "Hey Maria! How's the MCAT studying going? What's on your mind today?",
+ };
+ default:
+ return {
+ text: "That's a great question! Let me help you think through this.",
+ };
+ }
+ }
+
+ /**
+ * Update the message element with text content.
+ */
+ private setMessageText(messageElement: HTMLDivElement, text: string): void {
+ const textEl = messageElement.querySelector(".message-text");
+ if (textEl) {
+ textEl.innerHTML = this.parseMarkdown(text);
+ }
+ }
+
+ /**
+ * Simple markdown parser for chat messages.
+ */
+ private parseMarkdown(text: string): string {
+ // Escape HTML first for security
+ let html = this.escapeHtml(text);
+
+ // Bold: **text** or __text__
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1');
+ html = html.replace(/__(.+?)__/g, '$1');
+
+ // Italic: *text* or _text_
+ html = html.replace(/\*([^*]+)\*/g, '$1');
+ html = html.replace(/(?$1');
+
+ // Inline code: `code`
+ html = html.replace(/`([^`]+)`/g, '$1');
+
+ // Convert bullet lists: lines starting with - or *
+ html = html.replace(/^[\-\*]\s+(.+)$/gm, '
$1
');
+ // Wrap consecutive
in
+ html = html.replace(/(
.*<\/li>\n?)+/g, '
$&
');
+
+ // Convert numbered lists: lines starting with 1. 2. etc
+ html = html.replace(/^\d+\.\s+(.+)$/gm, '
$1
');
+
+ // Convert double newlines to paragraph breaks
+ html = html.replace(/\n\n+/g, '
');
+
+ // Convert single newlines to (but not inside lists)
+ html = html.replace(/(?)\n(?!<)/g, ' ');
+
+ // Wrap in paragraph if not empty
+ if (html.trim()) {
+ html = `
${html}
`;
+ }
+
+ // Clean up empty paragraphs and fix list wrapping
+ html = html.replace(/
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "a2ui-quizcard": QuizCard;
+ }
+}
diff --git a/samples/personalized_learning/src/theme-provider.ts b/samples/personalized_learning/src/theme-provider.ts
new file mode 100644
index 00000000..7c2b310b
--- /dev/null
+++ b/samples/personalized_learning/src/theme-provider.ts
@@ -0,0 +1,82 @@
+/*
+ Copyright 2025 Google LLC
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+/**
+ * Theme Provider for A2UI Components
+ *
+ * This component wraps A2UI surfaces and provides the theme context
+ * required by the newer A2UI component library.
+ */
+
+import { SignalWatcher } from "@lit-labs/signals";
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { provide } from "@lit/context";
+import { v0_8 } from "@a2ui/web-lib";
+import * as UI from "@a2ui/web-lib/ui";
+import { theme as defaultTheme } from "./theme.js";
+
+// Import and register custom components
+import { Flashcard } from "./flashcard.js";
+import { QuizCard } from "./quiz-card.js";
+
+UI.componentRegistry.register("Flashcard", Flashcard as unknown as UI.CustomElementConstructorOf, "a2ui-flashcard");
+UI.componentRegistry.register("QuizCard", QuizCard as unknown as UI.CustomElementConstructorOf, "a2ui-quizcard");
+
+// Type alias for the processor - use the actual exported class name
+type A2UIModelProcessorInstance = InstanceType;
+
+@customElement("a2ui-theme-provider")
+export class A2UIThemeProvider extends SignalWatcher(LitElement) {
+ @provide({ context: UI.Context.themeContext })
+ theme: v0_8.Types.Theme = defaultTheme;
+
+ @property({ type: String })
+ surfaceId: string = "";
+
+ @property({ type: Object })
+ surface: v0_8.Types.Surface | undefined;
+
+ @property({ type: Object })
+ processor: A2UIModelProcessorInstance | undefined;
+
+ static styles = css`
+ :host {
+ display: block;
+ }
+ `;
+
+ render() {
+ if (!this.surface || !this.processor) {
+ return html``;
+ }
+
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "a2ui-theme-provider": A2UIThemeProvider;
+ }
+}
diff --git a/samples/personalized_learning/src/theme.ts b/samples/personalized_learning/src/theme.ts
new file mode 100644
index 00000000..97b1eee6
--- /dev/null
+++ b/samples/personalized_learning/src/theme.ts
@@ -0,0 +1,173 @@
+/*
+ Copyright 2025 Google LLC
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+/**
+ * Default theme for A2UI components in the personalized learning demo.
+ * Based on the shell sample's default-theme.ts but simplified.
+ */
+
+import { v0_8 } from "@a2ui/web-lib";
+
+// Minimal theme that provides all required component styles
+export const theme: v0_8.Types.Theme = {
+ additionalStyles: {},
+ components: {
+ AudioPlayer: {},
+ Button: {
+ "layout-pt-2": true,
+ "layout-pb-2": true,
+ "layout-pl-3": true,
+ "layout-pr-3": true,
+ "border-br-12": true,
+ "border-bw-0": true,
+ "color-bgc-p30": true,
+ },
+ Card: {
+ "border-br-9": true,
+ "layout-p-4": true,
+ "color-bgc-n100": true
+ },
+ CheckBox: {
+ element: {},
+ label: {},
+ container: {},
+ },
+ Column: {
+ "layout-g-2": true,
+ },
+ DateTimeInput: {
+ container: {},
+ label: {},
+ element: {},
+ },
+ Divider: {},
+ Image: {
+ all: {
+ "border-br-5": true,
+ "layout-w-100": true,
+ },
+ avatar: {},
+ header: {},
+ icon: {},
+ largeFeature: {},
+ mediumFeature: {},
+ smallFeature: {},
+ },
+ Icon: {},
+ List: {
+ "layout-g-4": true,
+ "layout-p-2": true,
+ },
+ Modal: {
+ backdrop: {},
+ element: {},
+ },
+ MultipleChoice: {
+ container: {},
+ label: {},
+ element: {},
+ },
+ Row: {
+ "layout-g-4": true,
+ },
+ Slider: {
+ container: {},
+ label: {},
+ element: {},
+ },
+ Tabs: {
+ container: {},
+ controls: { all: {}, selected: {} },
+ element: {},
+ },
+ Text: {
+ all: {
+ "layout-w-100": true,
+ },
+ h1: {
+ "typography-f-sf": true,
+ "typography-w-400": true,
+ "layout-m-0": true,
+ "typography-sz-hs": true,
+ },
+ h2: {
+ "typography-f-sf": true,
+ "typography-w-400": true,
+ "layout-m-0": true,
+ "typography-sz-tl": true,
+ },
+ h3: {
+ "typography-f-sf": true,
+ "typography-w-400": true,
+ "layout-m-0": true,
+ "typography-sz-tl": true,
+ },
+ h4: {
+ "typography-f-sf": true,
+ "typography-w-400": true,
+ "layout-m-0": true,
+ "typography-sz-bl": true,
+ },
+ h5: {
+ "typography-f-sf": true,
+ "typography-w-400": true,
+ "layout-m-0": true,
+ "typography-sz-bm": true,
+ },
+ body: {},
+ caption: {},
+ },
+ TextField: {
+ container: {},
+ label: {},
+ element: {},
+ },
+ Video: {
+ "border-br-5": true,
+ },
+ },
+ elements: {
+ a: {},
+ audio: {},
+ body: {},
+ button: {},
+ h1: {},
+ h2: {},
+ h3: {},
+ h4: {},
+ h5: {},
+ iframe: {},
+ input: {},
+ p: {},
+ pre: {},
+ textarea: {},
+ video: {},
+ },
+ markdown: {
+ p: [],
+ h1: [],
+ h2: [],
+ h3: [],
+ h4: [],
+ h5: [],
+ ul: [],
+ ol: [],
+ li: [],
+ a: [],
+ strong: [],
+ em: [],
+ },
+};
diff --git a/samples/personalized_learning/src/types.ts b/samples/personalized_learning/src/types.ts
new file mode 100644
index 00000000..6d8c4243
--- /dev/null
+++ b/samples/personalized_learning/src/types.ts
@@ -0,0 +1,29 @@
+/*
+ Copyright 2025 Google LLC
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+/**
+ * Shared types for custom A2UI components.
+ */
+
+/**
+ * StringValue type matching A2UI's primitives for dynamic string content.
+ * Supports literal strings or path-based data binding.
+ */
+export interface StringValue {
+ path?: string;
+ literalString?: string;
+ literal?: string;
+}
diff --git a/samples/personalized_learning/tsconfig.json b/samples/personalized_learning/tsconfig.json
index 8b174c5f..3fdbb7a4 100644
--- a/samples/personalized_learning/tsconfig.json
+++ b/samples/personalized_learning/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
- "useDefineForClassFields": true,
+ "useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
@@ -14,8 +14,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
- "experimentalDecorators": true,
- "useDefineForClassFields": false
+ "experimentalDecorators": true
},
"include": ["src"]
}
From 2ccd8e2f0bb895fa409600a43eda63eaa0989a38 Mon Sep 17 00:00:00 2001
From: Sam Goodgame
Date: Tue, 16 Dec 2025 21:33:08 -0500
Subject: [PATCH 04/10] minor demo upgrades
---
.../personalized_learning/Quickstart.ipynb | 525 ++++++++++--------
samples/personalized_learning/agent/deploy.py | 153 ++++-
.../agent/requirements.txt | 1 +
3 files changed, 438 insertions(+), 241 deletions(-)
diff --git a/samples/personalized_learning/Quickstart.ipynb b/samples/personalized_learning/Quickstart.ipynb
index cb8aaa14..f28b1d95 100644
--- a/samples/personalized_learning/Quickstart.ipynb
+++ b/samples/personalized_learning/Quickstart.ipynb
@@ -3,64 +3,47 @@
{
"cell_type": "markdown",
"metadata": {},
- "source": [
- "# Personalized Learning Demo - Quickstart\n",
- "\n",
- "A full-stack A2UI sample demonstrating personalized educational content generation.\n",
- "\n",
- "**Contributed by Google Public Sector's Rapid Innovation Team.**\n",
- "\n",
- "---\n",
- "\n",
- "## What This Demo Shows\n",
- "\n",
- "This demo showcases how A2UI enables AI agents to generate rich, interactive learning materials tailored to individual learners:\n",
- "\n",
- "- **Flashcards** - Generated from OpenStax textbook content\n",
- "- **Audio** - Personalized podcasts (via NotebookLM)\n",
- "- **Video** - Educational explainers\n",
- "- **Quizzes** - Interactive assessment\n",
- "\n",
- "### The Personalization Pipeline\n",
- "\n",
- "At Google Public Sector, we're developing approaches that combine LLMs, knowledge graphs, and learner performance data to produce personalized content across courses—and across a person's academic and professional life.\n",
- "\n",
- "For this demo, that personalization is represented by context files in `learner_context/` describing a fictional learner (Maria) and her learning needs.\n",
- "\n",
- "---\n",
- "\n",
- "## Prerequisites\n",
- "\n",
- "- Google Cloud project with billing enabled\n",
- "- `gcloud` CLI installed\n",
- "- Node.js 18+\n",
- "- Python 3.11+"
- ]
+ "source": "# Personalized Learning Demo - Quickstart\n\nA full-stack A2UI sample demonstrating personalized educational content generation.\n\n**Contributed by Google Public Sector's Rapid Innovation Team.**\n\n---\n\n## What You'll Learn\n\nThis demo showcases several key concepts for building agentic applications with A2UI:\n\n| Concept | What This Demo Shows |\n|---------|---------------------|\n| **Remote Agent Deployment** | Deploy an AI agent to Vertex AI Agent Engine that runs independently from your UI |\n| **A2A Protocol** | Use the Agent-to-Agent protocol to communicate between your frontend and the remote agent |\n| **Custom UI Components** | Extend A2UI with custom components (Flashcard, QuizCard) beyond the standard library |\n| **Dynamic Content Generation** | Generate personalized A2UI JSON on-the-fly based on user requests |\n| **Intelligent Content Matching** | Use LLMs to match user topics to relevant textbook content (167 OpenStax chapters) |\n\n### Why This Architecture Matters\n\nIn production agentic systems:\n- **Agents run remotely** - They scale independently, can be updated without redeploying the UI, and may be operated by third parties\n- **UI is decoupled** - The frontend renders whatever A2UI JSON the agent sends, without knowing the agent's implementation\n- **A2A enables interoperability** - Any A2A-compatible agent can power your UI, regardless of how it's built\n\nThis demo implements this full stack: a TypeScript/Lit frontend communicates via A2A with a Python agent running on Google Cloud.\n\n---\n\n## About This Notebook\n\nThis Jupyter notebook walks you through setting up and running the Personalized Learning demo. It's designed to \"just work\" - run each cell in order and you'll have a working demo.\n\n### What This Demo Shows\n\nThis demo showcases how A2UI enables AI agents to generate rich, interactive learning materials tailored to individual learners:\n\n- **Flashcards** - Generated from OpenStax textbook content\n- **Audio** - Personalized podcasts (via NotebookLM)\n- **Video** - Educational explainers\n- **Quizzes** - Interactive assessment\n\n### The Personalization Pipeline\n\nAt Google Public Sector, we're developing approaches that combine LLMs, knowledge graphs, and learner performance data to produce personalized content across courses—and across a person's academic and professional life.\n\nFor this demo, that personalization is represented by context files in `learner_context/` describing a fictional learner (Maria) and her learning needs.\n\n---\n\n## How This Notebook Is Organized\n\n| Section | What It Does |\n|---------|--------------|\n| **Step 1: Environment Setup** | Creates Python virtual environment and installs all dependencies |\n| **Step 2: Configuration** | Sets your GCP project ID |\n| **Step 3: GCP Authentication** | Authenticates with Google Cloud and enables required APIs |\n| **Step 4: Deploy Agent** | Deploys the AI agent to Vertex AI Agent Engine |\n| **Step 5: Configure & Run** | Creates config files and launches the demo |\n| **Step 6 (Optional)** | Generate audio/video content with NotebookLM |\n| **Appendix: Local Development** | Run entirely locally without cloud deployment |\n\n### Remote vs. Local Deployment\n\n**The default flow deploys the agent to Google Cloud.** This is intentional - the whole point of A2UI is to work with *remote* agents. In production, your UI runs in a browser while the agent runs on a server (possibly operated by a third party). This architecture enables:\n\n- Agents that scale independently of the UI\n- Agents that can be updated without redeploying the frontend\n- Multi-tenant scenarios where one agent serves many users\n- Integration with agents you don't control\n\nHowever, for quick local testing or if you don't have a GCP project, see **Appendix: Local Development** at the end of this notebook.\n\n---\n\n## Prerequisites\n\nBefore starting, ensure you have:\n\n- **Node.js 18+** - [Download](https://nodejs.org/)\n- **Python 3.11+** - [Download](https://www.python.org/downloads/)\n- **Google Cloud project with billing enabled** - [Console](https://console.cloud.google.com/)\n- **gcloud CLI installed** - [Install Guide](https://cloud.google.com/sdk/docs/install)"
+ },
+ {
+ "cell_type": "markdown",
+ "source": "## Imports\n\nRun this cell first to load all Python modules used throughout the notebook.",
+ "metadata": {}
+ },
+ {
+ "cell_type": "code",
+ "source": "import subprocess\nimport sys\nimport os",
+ "metadata": {},
+ "execution_count": null,
+ "outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Step 1: Set Your Project ID\n",
+ "## Step 1: Environment Setup\n",
+ "\n",
+ "First, we'll create an isolated Python environment and install all dependencies. This ensures the demo doesn't conflict with other Python projects on your system.\n",
"\n",
- "Replace with your GCP project ID:"
+ "### 1a. Create Python Virtual Environment"
]
},
{
"cell_type": "code",
- "execution_count": 15,
+ "execution_count": null,
"metadata": {},
"outputs": [],
- "source": [
- "PROJECT_ID = \"a2ui-learning\" # <-- CHANGE THIS\n",
- "LOCATION = \"us-central1\""
- ]
+ "source": "# Create virtual environment if it doesn't exist\nvenv_path = os.path.join(os.getcwd(), \".venv\")\nif not os.path.exists(venv_path):\n print(\"Creating Python virtual environment...\")\n subprocess.run([sys.executable, \"-m\", \"venv\", \".venv\"], check=True)\n print(f\"✅ Created virtual environment at {venv_path}\")\nelse:\n print(f\"✅ Virtual environment already exists at {venv_path}\")\n\nprint(\"\\n⚠️ IMPORTANT: Restart your Jupyter kernel to use the new environment!\")\nprint(\" In VS Code: Click the kernel selector (top right) → Select '.venv'\")\nprint(\" In JupyterLab: Kernel → Change Kernel → Python (.venv)\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Step 2: Authenticate & Enable APIs"
+ "### 1b. Install Python Dependencies\n",
+ "\n",
+ "After selecting the `.venv` kernel, run this cell to install all required Python packages.\n",
+ "\n",
+ "**Note:** We explicitly use `https://pypi.org/simple/` to ensure packages come from the official Python Package Index, avoiding issues with corporate proxies or custom registries."
]
},
{
@@ -68,11 +51,15 @@
"execution_count": null,
"metadata": {},
"outputs": [],
+ "source": "# Install Python dependencies using the canonical PyPI index\nprint(\"Installing Python dependencies from PyPI...\")\npackages = [\n \"google-adk>=0.3.0\",\n \"google-genai>=1.0.0\",\n \"google-cloud-storage>=2.10.0\",\n \"python-dotenv>=1.0.0\",\n \"litellm>=1.0.0\",\n \"vertexai\",\n]\n\nsubprocess.run([\n sys.executable, \"-m\", \"pip\", \"install\", \"-q\",\n \"--index-url\", \"https://pypi.org/simple/\",\n \"--trusted-host\", \"pypi.org\",\n \"--trusted-host\", \"files.pythonhosted.org\",\n *packages\n], check=True)\n\nprint(\"✅ Python dependencies installed\")"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
"source": [
- "# Authenticate with Google Cloud\n",
- "!gcloud auth login\n",
- "!gcloud config set project {PROJECT_ID}\n",
- "!gcloud auth application-default login"
+ "### 1c. Install Node.js Dependencies\n",
+ "\n",
+ "Now we'll install the frontend dependencies. This includes the A2UI renderer library and the demo's own packages."
]
},
{
@@ -80,12 +67,15 @@
"execution_count": null,
"metadata": {},
"outputs": [],
+ "source": "# Build the A2UI Lit renderer (using public npm registry)\nprint(\"Building A2UI Lit renderer...\")\nsubprocess.run(\n \"npm install --registry https://registry.npmjs.org/ && npm run build\",\n shell=True,\n cwd=\"../../renderers/lit\",\n check=True\n)\nprint(\"✅ A2UI renderer built\")\n\n# Install demo dependencies\nprint(\"\\nInstalling demo dependencies...\")\nsubprocess.run(\n \"npm install --registry https://registry.npmjs.org/\",\n shell=True,\n check=True\n)\nprint(\"✅ Demo dependencies installed\")"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
"source": [
- "# Enable required APIs\n",
- "!gcloud services enable aiplatform.googleapis.com\n",
- "!gcloud services enable cloudbuild.googleapis.com\n",
- "!gcloud services enable storage.googleapis.com\n",
- "!gcloud services enable cloudresourcemanager.googleapis.com"
+ "## Step 2: Configuration\n",
+ "\n",
+ "Set your Google Cloud project ID below. This is the project where the agent will be deployed."
]
},
{
@@ -94,144 +84,98 @@
"metadata": {},
"outputs": [],
"source": [
- "# Create staging bucket for Agent Engine (if it doesn't exist)\n",
- "!gsutil mb -l {LOCATION} gs://{PROJECT_ID}_cloudbuild 2>/dev/null || echo \"Bucket already exists\""
+ "PROJECT_ID = \"your-project-id\" # <-- CHANGE THIS to your GCP project ID\n",
+ "LOCATION = \"us-central1\""
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Step 3: Deploy the A2UI Agent\n",
+ "## Step 3: GCP Authentication & API Setup\n",
"\n",
- "The agent generates personalized learning content and runs on Vertex AI Agent Engine."
+ "Authenticate with Google Cloud and enable the required APIs. This will open browser windows for authentication."
]
},
{
"cell_type": "code",
- "execution_count": 17,
+ "execution_count": null,
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\n",
- "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m25.2\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m25.3\u001b[0m\n",
- "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49m/Users/samgoodgame/Desktop/student-pls/.venv/bin/python -m pip install --upgrade pip\u001b[0m\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
- "# Install Python dependencies (using PyPI explicitly to avoid corporate proxy issues)\n",
- "!pip install -q --index-url https://pypi.org/simple/ google-adk google-genai vertexai python-dotenv litellm"
+ "# Authenticate with Google Cloud\n",
+ "!gcloud auth login\n",
+ "!gcloud config set project {PROJECT_ID}\n",
+ "!gcloud auth application-default login"
]
},
{
"cell_type": "code",
- "execution_count": 28,
+ "execution_count": null,
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Deploying Personalized Learning Agent...\n",
- " Project: a2ui-learning\n",
- " Location: us-central1\n",
- "Identified the following requirements: {'cloudpickle': '3.1.2', 'pydantic': '2.12.5', 'google-cloud-aiplatform': '1.130.0'}\n",
- "The following requirements are missing: {'pydantic', 'google-cloud-aiplatform'}\n",
- "The following requirements are incompatible: {'cloudpickle==3.1.2 (required: ==3.1.1)'}\n",
- "The following requirements are appended: {'pydantic==2.12.5'}\n",
- "The final list of requirements: ['google-adk>=1.15.1', 'google-genai', 'cloudpickle==3.1.1', 'python-dotenv', 'litellm', 'pydantic==2.12.5']\n",
- "Using bucket a2ui-learning_cloudbuild\n",
- "Wrote to gs://a2ui-learning_cloudbuild/agent_engine/agent_engine.pkl\n",
- "Writing to gs://a2ui-learning_cloudbuild/agent_engine/requirements.txt\n",
- "Creating in-memory tarfile of extra_packages\n",
- "Writing to gs://a2ui-learning_cloudbuild/agent_engine/dependencies.tar.gz\n",
- "Creating AgentEngine\n",
- "Create AgentEngine backing LRO: projects/103904989366/locations/us-central1/reasoningEngines/5377071455685050368/operations/1370530712762974208\n",
- "View progress and logs at https://console.cloud.google.com/logs/query?project=a2ui-learning\n",
- "AgentEngine created. Resource name: projects/103904989366/locations/us-central1/reasoningEngines/5377071455685050368\n",
- "To use this AgentEngine in another session:\n",
- "agent_engine = vertexai.agent_engines.get('projects/103904989366/locations/us-central1/reasoningEngines/5377071455685050368')\n",
- "\n",
- "DEPLOYMENT SUCCESSFUL!\n",
- "Resource Name: projects/103904989366/locations/us-central1/reasoningEngines/5377071455685050368\n",
- "Resource ID: 5377071455685050368\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
- "# Deploy the agent (takes 2-5 minutes)\n",
- "!cd agent && ../.venv/bin/python deploy.py --project {PROJECT_ID} --location {LOCATION}"
+ "# Enable required APIs\n",
+ "!gcloud services enable aiplatform.googleapis.com\n",
+ "!gcloud services enable cloudbuild.googleapis.com\n",
+ "!gcloud services enable storage.googleapis.com\n",
+ "!gcloud services enable cloudresourcemanager.googleapis.com\n",
+ "\n",
+ "# Create staging bucket for Agent Engine (if it doesn't exist)\n",
+ "!gsutil mb -l {LOCATION} gs://{PROJECT_ID}_cloudbuild 2>/dev/null || echo \"Bucket already exists\"\n",
+ "\n",
+ "print(\"\\n✅ GCP APIs enabled and staging bucket ready\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "**Save the Resource ID from the output above!** You'll need it in the next step."
+ "## Step 4: Deploy the A2UI Agent\n",
+ "\n",
+ "The agent generates personalized learning content and runs on Vertex AI Agent Engine. Deployment takes 2-5 minutes.\n",
+ "\n",
+ "**Why deploy remotely?** A2UI is designed for remote agents - your UI runs in the browser while the agent runs on a server. This mirrors real-world architectures where agents scale independently and may even be operated by third parties."
]
},
{
"cell_type": "code",
- "execution_count": 31,
+ "execution_count": null,
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Project Number: 103904989366\n"
- ]
- }
- ],
- "source": [
- "# Get your project NUMBER (different from project ID)\n",
- "import subprocess\n",
- "result = subprocess.run([\"gcloud\", \"projects\", \"describe\", PROJECT_ID, \"--format=value(projectNumber)\"], \n",
- " capture_output=True, text=True)\n",
- "PROJECT_NUMBER = result.stdout.strip()\n",
- "print(f\"Project Number: {PROJECT_NUMBER}\")"
- ]
+ "outputs": [],
+ "source": "# Deploy the agent to Vertex AI Agent Engine (takes 2-5 minutes)\nprint(\"Deploying agent to Vertex AI Agent Engine...\")\nprint(\"This takes 2-5 minutes. Watch for the Resource ID at the end.\\n\")\n\nresult = subprocess.run(\n [sys.executable, \"deploy.py\", \"--project\", PROJECT_ID, \"--location\", LOCATION],\n cwd=\"agent\"\n)\n\nif result.returncode != 0:\n print(\"\\n❌ Deployment failed. Check the error messages above.\")\nelse:\n print(\"\\n✅ Deployment complete! Copy the Resource ID from the output above.\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Step 4: Configure Environment\n",
+ "## Step 5: Configure & Run\n",
"\n",
- "Fill in the Resource ID from the deployment output:"
+ "Fill in the Resource ID from the deployment output above, then create the configuration file."
]
},
{
"cell_type": "code",
- "execution_count": 32,
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": "# Get your project NUMBER (different from project ID)\nresult = subprocess.run(\n [\"gcloud\", \"projects\", \"describe\", PROJECT_ID, \"--format=value(projectNumber)\"], \n capture_output=True, text=True\n)\nPROJECT_NUMBER = result.stdout.strip()\nprint(f\"Project Number: {PROJECT_NUMBER}\")"
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
"metadata": {},
"outputs": [],
"source": [
- "AGENT_RESOURCE_ID = \"5377071455685050368\" # <-- PASTE YOUR RESOURCE ID HERE (from Step 3 output)"
+ "# Paste the Resource ID from the deployment output in Step 4\n",
+ "AGENT_RESOURCE_ID = \"YOUR_RESOURCE_ID_HERE\" # <-- PASTE YOUR RESOURCE ID HERE"
]
},
{
"cell_type": "code",
- "execution_count": 33,
+ "execution_count": null,
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Created .env file:\n",
- "# Generated by Quickstart.ipynb\n",
- "GOOGLE_CLOUD_PROJECT=a2ui-learning\n",
- "AGENT_ENGINE_PROJECT_NUMBER=103904989366\n",
- "AGENT_ENGINE_RESOURCE_ID=5377071455685050368\n",
- "\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"# Create .env file\n",
"env_content = f\"\"\"# Generated by Quickstart.ipynb\n",
@@ -244,95 +188,23 @@
" f.write(env_content)\n",
"\n",
"print(\"Created .env file:\")\n",
- "print(env_content)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Step 5: Install Frontend Dependencies"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 34,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\u001b[1mnpm\u001b[22m \u001b[33mwarn\u001b[39m \u001b[94mUnknown project config \"always-auth\" (//us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/:always-auth). This will stop working in the next major version of npm.\u001b[39m\n",
- "\u001b[1mnpm\u001b[22m \u001b[33mwarn\u001b[39m \u001b[94mUnknown global config \"always-auth\" (//us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/:always-auth). This will stop working in the next major version of npm.\u001b[39m\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K\n",
- "up to date, audited 99 packages in 453ms\n",
- "\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K\n",
- "\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K12 packages are looking for funding\n",
- "\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K run `npm fund` for details\n",
- "\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K\n",
- "found \u001b[32m\u001b[1m0\u001b[22m\u001b[39m vulnerabilities\n",
- "\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K\u001b[1mnpm\u001b[22m \u001b[33mwarn\u001b[39m \u001b[94mUnknown project config \"always-auth\" (//us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/:always-auth). This will stop working in the next major version of npm.\u001b[39m\n",
- "\u001b[1mnpm\u001b[22m \u001b[33mwarn\u001b[39m \u001b[94mUnknown global config \"always-auth\" (//us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/:always-auth). This will stop working in the next major version of npm.\u001b[39m\n",
- "\n",
- "> @a2ui/lit@0.8.1 build\n",
- "> wireit\n",
- "\n",
- "✅ Ran 0 scripts and skipped 2 in 0s.\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K"
- ]
- }
- ],
- "source": [
- "# Build the A2UI renderer first (using public npm registry)\n",
- "!cd ../../renderers/lit && npm install --registry https://registry.npmjs.org/ && npm run build"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 35,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\u001b[1mnpm\u001b[22m \u001b[33mwarn\u001b[39m \u001b[94mUnknown global config \"always-auth\" (//us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/:always-auth). This will stop working in the next major version of npm.\u001b[39m\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K\u001b[1mnpm\u001b[22m \u001b[33mwarn\u001b[39m \u001b[94mdeprecated\u001b[39m node-domexception@1.0.0: Use your platform's native DOMException instead\n",
- "\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K⠹\u001b[1G\u001b[0K⠸\u001b[1G\u001b[0K⠼\u001b[1G\u001b[0K⠴\u001b[1G\u001b[0K⠦\u001b[1G\u001b[0K⠧\u001b[1G\u001b[0K⠇\u001b[1G\u001b[0K⠏\u001b[1G\u001b[0K⠋\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K\n",
- "added 195 packages, and audited 197 packages in 10s\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K30 packages are looking for funding\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K run `npm fund` for details\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K\n",
- "2 \u001b[33m\u001b[1mmoderate\u001b[22m\u001b[39m severity vulnerabilities\n",
- "\n",
- "To address all issues (including breaking changes), run:\n",
- " npm audit fix --force\n",
- "\n",
- "Run `npm audit` for details.\n",
- "\u001b[1G\u001b[0K⠙\u001b[1G\u001b[0K"
- ]
- }
- ],
- "source": [
- "# Install demo dependencies (using public npm registry)\n",
- "!npm install --registry https://registry.npmjs.org/"
+ "print(env_content)\n",
+ "print(\"✅ Configuration complete!\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Step 6: Run the Demo\n",
+ "### Run the Demo\n",
"\n",
- "Run this in your terminal (not in the notebook):\n",
+ "Everything is set up! Run this command in your terminal (not in the notebook):\n",
"\n",
"```bash\n",
"npm run dev\n",
"```\n",
"\n",
- "Then open http://localhost:5174\n",
+ "Then open **http://localhost:5174**\n",
"\n",
"### Try These Prompts\n",
"\n",
@@ -340,50 +212,148 @@
"|--------|-------------|\n",
"| \"Help me understand ATP\" | Generates flashcards from OpenStax |\n",
"| \"Quiz me on bond energy\" | Interactive quiz cards |\n",
- "| \"Play the podcast\" | Audio player (requires Step 7) |\n",
- "| \"Show me a video\" | Video player (requires Step 7) |"
+ "| \"Play the podcast\" | Audio player (requires Step 6) |\n",
+ "| \"Show me a video\" | Video player (requires Step 6) |"
]
},
{
"cell_type": "markdown",
"metadata": {},
- "source": "---\n\n## Step 7 (Optional): Generate Audio & Video with NotebookLM\n\nThe demo includes audio and video players, but you need to generate the media files. NotebookLM can create personalized podcasts based on the learner context.\n\n### Prerequisites\n\n- A Google account with access to [NotebookLM](https://notebooklm.google.com/)\n- The `learner_context/` files from this demo\n\n---\n\n### Part A: Generate a Personalized Podcast\n\n**1. Create a NotebookLM Notebook**\n\nGo to [notebooklm.google.com](https://notebooklm.google.com/) and create a new notebook.\n\n**2. Upload Learner Context Files**\n\nUpload all files from the `learner_context/` directory:\n- `01_maria_learner_profile.txt` - Maria's background and learning preferences \n- `02_chemistry_bond_energy.txt` - Bond energy concepts\n- `03_chemistry_thermodynamics.txt` - Thermodynamics content\n- `04_biology_atp_cellular_respiration.txt` - ATP and cellular respiration\n- `05_misconception_resolution.txt` - Common misconceptions to address\n- `06_mcat_practice_concepts.txt` - MCAT-focused content\n\nThese files give NotebookLM the context to generate personalized content."
- },
- {
- "cell_type": "markdown",
- "source": "**3. Generate the Audio Overview**\n\n- Click **Notebook guide** in the right sidebar\n- Click **Audio Overview** → **Generate**\n- Wait for generation to complete (typically 2-5 minutes)\n- NotebookLM will create a podcast-style discussion about the uploaded content\n\n**4. Customize the Audio (Optional)**\n\nBefore generating, you can click **Customize** to provide specific instructions:\n\n```\nCreate a podcast for Maria, a pre-med student preparing for the MCAT. \nUse gym and fitness analogies since she loves working out.\nFocus on explaining why \"energy stored in bonds\" is a misconception.\nMake it conversational and engaging, about 5-7 minutes long.\n```\n\n**5. Download and Install the Podcast**\n\n- Once generated, click the **⋮** menu on the audio player\n- Select **Download**\n- Save the file as `podcast.m4a`\n- Copy to the demo's assets directory:",
- "metadata": {}
+ "source": [
+ "---\n",
+ "\n",
+ "## Step 6 (Optional): Generate Audio & Video with NotebookLM\n",
+ "\n",
+ "The demo includes audio and video players, but you need to generate the media files. NotebookLM can create personalized podcasts based on the learner context.\n",
+ "\n",
+ "### Prerequisites\n",
+ "\n",
+ "- A Google account with access to [NotebookLM](https://notebooklm.google.com/)\n",
+ "- The `learner_context/` files from this demo\n",
+ "\n",
+ "---\n",
+ "\n",
+ "### Part A: Generate a Personalized Podcast\n",
+ "\n",
+ "**1. Create a NotebookLM Notebook**\n",
+ "\n",
+ "Go to [notebooklm.google.com](https://notebooklm.google.com/) and create a new notebook.\n",
+ "\n",
+ "**2. Upload Learner Context Files**\n",
+ "\n",
+ "Upload all files from the `learner_context/` directory:\n",
+ "- `01_maria_learner_profile.txt` - Maria's background and learning preferences \n",
+ "- `02_chemistry_bond_energy.txt` - Bond energy concepts\n",
+ "- `03_chemistry_thermodynamics.txt` - Thermodynamics content\n",
+ "- `04_biology_atp_cellular_respiration.txt` - ATP and cellular respiration\n",
+ "- `05_misconception_resolution.txt` - Common misconceptions to address\n",
+ "- `06_mcat_practice_concepts.txt` - MCAT-focused content\n",
+ "\n",
+ "These files give NotebookLM the context to generate personalized content.\n",
+ "\n",
+ "**3. Generate the Audio Overview**\n",
+ "\n",
+ "- Click **Notebook guide** in the right sidebar\n",
+ "- Click **Audio Overview** → **Generate**\n",
+ "- Wait for generation to complete (typically 2-5 minutes)\n",
+ "- NotebookLM will create a podcast-style discussion about the uploaded content\n",
+ "\n",
+ "**4. Customize the Audio (Optional)**\n",
+ "\n",
+ "Before generating, you can click **Customize** to provide specific instructions:\n",
+ "\n",
+ "```\n",
+ "Create a podcast for Maria, a pre-med student preparing for the MCAT. \n",
+ "Use gym and fitness analogies since she loves working out.\n",
+ "Focus on explaining why \"energy stored in bonds\" is a misconception.\n",
+ "Make it conversational and engaging, about 5-7 minutes long.\n",
+ "```\n",
+ "\n",
+ "**5. Download and Install the Podcast**\n",
+ "\n",
+ "- Once generated, click the **⋮** menu on the audio player\n",
+ "- Select **Download**\n",
+ "- Save the file as `podcast.m4a`\n",
+ "- Copy to the demo's assets directory:"
+ ]
},
{
"cell_type": "code",
- "source": "# Copy your downloaded podcast to the assets directory\n# Replace ~/Downloads/podcast.m4a with your actual download path\n!cp ~/Downloads/podcast.m4a public/assets/podcast.m4a\n\n# Verify the file was copied\n!ls -la public/assets/",
- "metadata": {},
"execution_count": null,
- "outputs": []
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Copy your downloaded podcast to the assets directory\n",
+ "# Replace ~/Downloads/podcast.m4a with your actual download path\n",
+ "!cp ~/Downloads/podcast.m4a public/assets/podcast.m4a\n",
+ "\n",
+ "# Verify the file was copied\n",
+ "!ls -la public/assets/"
+ ]
},
{
"cell_type": "markdown",
- "source": "---\n\n### Part B: Create a Video (Two Options)\n\n#### Option 1: NotebookLM Briefing Document + Screen Recording\n\n**1. Generate a Briefing Document**\n\nIn NotebookLM with your learner context loaded:\n- Click **Notebook guide** → **Briefing doc**\n- This creates a structured summary you can use as a video script\n\n**2. Create the Video**\n\nUse the briefing document to create a video:\n- **Screen recording** - Record yourself walking through the concepts using slides or a whiteboard app\n- **AI video tools** - Use tools like Synthesia, HeyGen, or similar to generate a video from the script\n- **Slide presentation** - Create slides and record with voiceover using QuickTime, Loom, or similar\n\n**3. Export and Install**\n\nExport your video as MP4 and copy to the assets directory:",
- "metadata": {}
+ "metadata": {},
+ "source": [
+ "---\n",
+ "\n",
+ "### Part B: Create a Video (Two Options)\n",
+ "\n",
+ "#### Option 1: NotebookLM Briefing Document + Screen Recording\n",
+ "\n",
+ "**1. Generate a Briefing Document**\n",
+ "\n",
+ "In NotebookLM with your learner context loaded:\n",
+ "- Click **Notebook guide** → **Briefing doc**\n",
+ "- This creates a structured summary you can use as a video script\n",
+ "\n",
+ "**2. Create the Video**\n",
+ "\n",
+ "Use the briefing document to create a video:\n",
+ "- **Screen recording** - Record yourself walking through the concepts using slides or a whiteboard app\n",
+ "- **AI video tools** - Use tools like Synthesia, HeyGen, or similar to generate a video from the script\n",
+ "- **Slide presentation** - Create slides and record with voiceover using QuickTime, Loom, or similar\n",
+ "\n",
+ "**3. Export and Install**\n",
+ "\n",
+ "Export your video as MP4 and copy to the assets directory:"
+ ]
},
{
"cell_type": "code",
- "source": "# Copy your video to the assets directory\n# Replace ~/Downloads/demo.mp4 with your actual file path\n!cp ~/Downloads/demo.mp4 public/assets/demo.mp4\n\n# Verify both media files are in place\n!ls -la public/assets/",
- "metadata": {},
"execution_count": null,
- "outputs": []
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Copy your video to the assets directory\n",
+ "# Replace ~/Downloads/demo.mp4 with your actual file path\n",
+ "!cp ~/Downloads/demo.mp4 public/assets/demo.mp4\n",
+ "\n",
+ "# Verify both media files are in place\n",
+ "!ls -la public/assets/"
+ ]
},
{
"cell_type": "markdown",
- "source": "#### Option 2: Use Placeholder/Stock Content\n\nFor demo purposes, you can use any MP4 video file. Rename it to `demo.mp4` and place it in `public/assets/`.\n\n---\n\n### Verify Media Files\n\nAfter copying your files, verify they're accessible:",
- "metadata": {}
+ "metadata": {},
+ "source": [
+ "#### Option 2: Use Placeholder/Stock Content\n",
+ "\n",
+ "For demo purposes, you can use any MP4 video file. Rename it to `demo.mp4` and place it in `public/assets/`.\n",
+ "\n",
+ "---\n",
+ "\n",
+ "### Verify Media Files\n",
+ "\n",
+ "After copying your files, verify they're accessible:"
+ ]
},
{
"cell_type": "code",
- "source": "import os\n\nprint(\"Media files status:\")\nprint(\"-\" * 40)\n\npodcast_path = \"public/assets/podcast.m4a\"\nvideo_path = \"public/assets/demo.mp4\"\n\nif os.path.exists(podcast_path):\n size_mb = os.path.getsize(podcast_path) / (1024 * 1024)\n print(f\"✅ Podcast: {podcast_path} ({size_mb:.1f} MB)\")\nelse:\n print(f\"❌ Podcast: {podcast_path} NOT FOUND\")\n \nif os.path.exists(video_path):\n size_mb = os.path.getsize(video_path) / (1024 * 1024)\n print(f\"✅ Video: {video_path} ({size_mb:.1f} MB)\")\nelse:\n print(f\"❌ Video: {video_path} NOT FOUND\")\n\nprint(\"-\" * 40)\nprint(\"\\nRun 'npm run dev' and try:\")\nprint(' • \"Play the podcast\" - to hear the audio')\nprint(' • \"Show me a video\" - to watch the video')",
- "metadata": {},
"execution_count": null,
- "outputs": []
+ "metadata": {},
+ "outputs": [],
+ "source": "print(\"Media files status:\")\nprint(\"-\" * 40)\n\npodcast_path = \"public/assets/podcast.m4a\"\nvideo_path = \"public/assets/demo.mp4\"\n\nif os.path.exists(podcast_path):\n size_mb = os.path.getsize(podcast_path) / (1024 * 1024)\n print(f\"✅ Podcast: {podcast_path} ({size_mb:.1f} MB)\")\nelse:\n print(f\"❌ Podcast: {podcast_path} NOT FOUND\")\n \nif os.path.exists(video_path):\n size_mb = os.path.getsize(video_path) / (1024 * 1024)\n print(f\"✅ Video: {video_path} ({size_mb:.1f} MB)\")\nelse:\n print(f\"❌ Video: {video_path} NOT FOUND\")\n\nprint(\"-\" * 40)\nprint(\"\\nRun 'npm run dev' and try:\")\nprint(' • \"Play the podcast\" - to hear the audio')\nprint(' • \"Show me a video\" - to watch the video')"
},
{
"cell_type": "markdown",
@@ -405,6 +375,85 @@
"\n",
"> **Warning:** When building production applications, treat any agent outside your control as potentially untrusted. This demo connects to Agent Engine within your own GCP project. Always review agent code before deploying."
]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "---\n",
+ "\n",
+ "## Appendix: Local Development (No Cloud Required)\n",
+ "\n",
+ "If you don't have a GCP project or want to iterate quickly without deploying, you can run everything locally. The agent runs as a local Python server instead of on Vertex AI.\n",
+ "\n",
+ "**Important:** While local development is convenient for testing, remember that A2UI is designed for *remote* agents. The default cloud deployment flow demonstrates the real-world architecture where:\n",
+ "- The UI runs in the browser\n",
+ "- The agent runs on a remote server\n",
+ "- They communicate via the A2A protocol\n",
+ "\n",
+ "Local development is a shortcut for testing - production deployments should use remote agents.\n",
+ "\n",
+ "### Local Setup\n",
+ "\n",
+ "If you haven't already completed Step 1, run the environment setup cells first. Then configure your API key:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Create .env file for local development\n",
+ "# You'll need a Google AI API key from https://aistudio.google.com/apikey\n",
+ "\n",
+ "GOOGLE_API_KEY = \"YOUR_API_KEY_HERE\" # <-- CHANGE THIS\n",
+ "\n",
+ "env_content = f\"\"\"# Generated by Quickstart.ipynb (Local Development Mode)\n",
+ "GOOGLE_API_KEY={GOOGLE_API_KEY}\n",
+ "\"\"\"\n",
+ "\n",
+ "with open(\".env\", \"w\") as f:\n",
+ " f.write(env_content)\n",
+ "\n",
+ "# Also create agent/.env for the local agent server\n",
+ "with open(\"agent/.env\", \"w\") as f:\n",
+ " f.write(env_content)\n",
+ "\n",
+ "print(\"Created .env files for local development\")\n",
+ "print(\"\\n⚠️ Make sure to replace YOUR_API_KEY_HERE with your actual API key!\")\n",
+ "print(\" Get one at: https://aistudio.google.com/apikey\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Run Locally\n",
+ "\n",
+ "With the local `.env` configured, run all three servers (frontend, API proxy, and local agent):\n",
+ "\n",
+ "```bash\n",
+ "npm run start:all\n",
+ "```\n",
+ "\n",
+ "This starts:\n",
+ "- **Vite dev server** at http://localhost:5174 (frontend)\n",
+ "- **API server** at http://localhost:8080 (proxy)\n",
+ "- **Agent server** at http://localhost:8081 (local Python agent)\n",
+ "\n",
+ "Open **http://localhost:5174** to use the demo.\n",
+ "\n",
+ "### Differences from Cloud Deployment\n",
+ "\n",
+ "| Aspect | Cloud (Default) | Local |\n",
+ "|--------|-----------------|-------|\n",
+ "| Agent location | Vertex AI Agent Engine | Local Python process |\n",
+ "| Scalability | Auto-scales | Single process |\n",
+ "| API key | Uses ADC/service account | Requires GOOGLE_API_KEY |\n",
+ "| Startup time | ~5 min (one-time deploy) | Instant |\n",
+ "| Cost | Vertex AI pricing | Free (API usage only) |"
+ ]
}
],
"metadata": {
diff --git a/samples/personalized_learning/agent/deploy.py b/samples/personalized_learning/agent/deploy.py
index ebcccb1f..0f1d8e61 100644
--- a/samples/personalized_learning/agent/deploy.py
+++ b/samples/personalized_learning/agent/deploy.py
@@ -557,6 +557,152 @@ def get_video_content(tool_context: ToolContext = None) -> str:
return json.dumps(a2ui)
+def generate_quiz(topic: str = "ATP", count: int = 2, tool_context: ToolContext = None) -> str:
+ """
+ Generate A2UI quiz cards for the specified topic.
+ Uses intelligent OpenStax chapter matching for accurate source attribution.
+
+ Args:
+ topic: The topic to generate quiz questions for (default: ATP)
+ count: Number of quiz questions to generate (default: 2)
+ tool_context: ADK tool context (optional)
+
+ Returns:
+ JSON string with A2UI content and source attribution
+ """
+ # Fetch content from OpenStax with intelligent matching
+ openstax_result = fetch_openstax_content(topic)
+
+ source_url = openstax_result.get("url", "")
+ source_title = openstax_result.get("title", "")
+ content = openstax_result.get("content", "")
+
+ # Generate quiz questions using Gemini
+ quiz_data = generate_quiz_from_content(topic, content, count)
+
+ if not quiz_data:
+ # Fallback quiz
+ quiz_data = [
+ {
+ "question": f"What is {topic}?",
+ "options": [
+ {"label": "Option A", "value": "a", "isCorrect": False},
+ {"label": "The correct answer", "value": "b", "isCorrect": True},
+ {"label": "Option C", "value": "c", "isCorrect": False},
+ {"label": "Option D", "value": "d", "isCorrect": False},
+ ],
+ "explanation": f"This topic requires further study. Check your textbook for details on {topic}.",
+ "category": topic
+ }
+ ]
+
+ # Build A2UI components
+ actual_count = min(count, len(quiz_data))
+ quiz_ids = [f"quiz{i}" for i in range(actual_count)]
+
+ components = [
+ {"id": "mainColumn", "component": {"Column": {
+ "children": {"explicitList": ["headerText", "quizRow"]},
+ "distribution": "start",
+ "alignment": "stretch"
+ }}},
+ {"id": "headerText", "component": {"Text": {
+ "text": {"literalString": f"Quick Quiz: {topic}"},
+ "usageHint": "h3"
+ }}},
+ {"id": "quizRow", "component": {"Row": {
+ "children": {"explicitList": quiz_ids},
+ "distribution": "start",
+ "alignment": "stretch",
+ "wrap": True
+ }}}
+ ]
+
+ for i, quiz in enumerate(quiz_data[:actual_count]):
+ options = []
+ for opt in quiz.get("options", []):
+ options.append({
+ "label": {"literalString": opt.get("label", "")},
+ "value": opt.get("value", str(i)),
+ "isCorrect": opt.get("isCorrect", False)
+ })
+
+ components.append({
+ "id": f"quiz{i}",
+ "component": {"QuizCard": {
+ "question": {"literalString": quiz.get("question", "Question?")},
+ "options": options,
+ "explanation": {"literalString": quiz.get("explanation", "")},
+ "category": {"literalString": quiz.get("category", topic)}
+ }}
+ })
+
+ a2ui = [
+ {"beginRendering": {"surfaceId": "learningContent", "root": "mainColumn"}},
+ {"surfaceUpdate": {"surfaceId": "learningContent", "components": components}}
+ ]
+
+ result = {
+ "a2ui": a2ui,
+ "source": {
+ "url": source_url,
+ "title": source_title,
+ "provider": "OpenStax Biology for AP Courses"
+ } if source_url else None
+ }
+
+ return json.dumps(result)
+
+
+def generate_quiz_from_content(topic: str, content: str, count: int) -> List[Dict]:
+ """Use Gemini to generate quiz questions from textbook content."""
+ prompt = f"""Based on this educational content about {topic}, create {count} MCAT-style multiple choice quiz questions.
+
+Each question should:
+1. Test conceptual understanding, not just memorization
+2. Include 4 options (A, B, C, D) with plausible distractors
+3. Have exactly ONE correct answer
+4. Include a detailed explanation using gym/fitness analogies where possible
+
+Content:
+{content if content else f"General knowledge about {topic} for AP Biology / MCAT preparation."}
+
+Return ONLY a JSON array with this exact format (no markdown, no explanation):
+[
+ {{
+ "question": "Question text here?",
+ "options": [
+ {{"label": "Option A text", "value": "a", "isCorrect": false}},
+ {{"label": "Option B text", "value": "b", "isCorrect": true}},
+ {{"label": "Option C text", "value": "c", "isCorrect": false}},
+ {{"label": "Option D text", "value": "d", "isCorrect": false}}
+ ],
+ "explanation": "Detailed explanation of why B is correct...",
+ "category": "{topic}"
+ }}
+]"""
+
+ try:
+ project = os.getenv("GOOGLE_CLOUD_PROJECT")
+ location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
+ client = genai_direct.Client(vertexai=True, project=project, location=location)
+ response = client.models.generate_content(
+ model="gemini-2.0-flash",
+ contents=prompt,
+ )
+ text = response.text.strip()
+
+ # Clean up markdown if present
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```$", "", text)
+
+ questions = json.loads(text)
+ return questions[:count] if isinstance(questions, list) else []
+ except Exception as e:
+ logger.error(f"Failed to generate quiz with Gemini: {e}")
+ return []
+
+
def get_learner_context(tool_context: ToolContext = None) -> str:
"""
Get information about the current learner's profile.
@@ -599,8 +745,9 @@ def get_learner_context(tool_context: ToolContext = None) -> str:
## Available Content Types
1. **Flashcards** - Call `generate_flashcards` tool for spaced repetition cards
-2. **Audio/Podcast** - Call `get_audio_content` tool for the personalized podcast
-3. **Video** - Call `get_video_content` tool for the educational video
+2. **Quiz** - Call `generate_quiz` tool for MCAT-style multiple choice questions
+3. **Audio/Podcast** - Call `get_audio_content` tool for the personalized podcast
+4. **Video** - Call `get_video_content` tool for the educational video
## Response Format
CRITICAL: Your response should be ONLY the raw A2UI JSON array returned by the tools.
@@ -636,7 +783,7 @@ def build_root_agent() -> LlmAgent:
name="personalized_learning_agent",
description="An agent that generates personalized A2UI learning materials including flashcards, audio, and video content.",
instruction=AGENT_INSTRUCTION,
- tools=[generate_flashcards, get_audio_content, get_video_content, get_learner_context],
+ tools=[generate_flashcards, generate_quiz, get_audio_content, get_video_content, get_learner_context],
)
diff --git a/samples/personalized_learning/agent/requirements.txt b/samples/personalized_learning/agent/requirements.txt
index 4559f5b3..70959914 100644
--- a/samples/personalized_learning/agent/requirements.txt
+++ b/samples/personalized_learning/agent/requirements.txt
@@ -6,3 +6,4 @@ python-dotenv>=1.0.0
uvicorn>=0.24.0
fastapi>=0.104.0
pydantic>=2.5.0
+litellm>=1.0.0
From 3d983ae0143063a0cc77af2e3066df5a44d4cad6 Mon Sep 17 00:00:00 2001
From: Sam Goodgame
Date: Tue, 16 Dec 2025 22:58:50 -0500
Subject: [PATCH 05/10] minor demo and explanation improvements
---
.../personalized_learning/Quickstart.ipynb | 317 ++++++++++++------
samples/personalized_learning/README.md | 107 +-----
.../assets/architecture.jpg | Bin 0 -> 100946 bytes
samples/personalized_learning/assets/hero.png | Bin 0 -> 5460792 bytes
samples/personalized_learning/index.html | 4 +-
5 files changed, 232 insertions(+), 196 deletions(-)
create mode 100644 samples/personalized_learning/assets/architecture.jpg
create mode 100644 samples/personalized_learning/assets/hero.png
diff --git a/samples/personalized_learning/Quickstart.ipynb b/samples/personalized_learning/Quickstart.ipynb
index f28b1d95..1c3ee6f8 100644
--- a/samples/personalized_learning/Quickstart.ipynb
+++ b/samples/personalized_learning/Quickstart.ipynb
@@ -3,19 +3,114 @@
{
"cell_type": "markdown",
"metadata": {},
- "source": "# Personalized Learning Demo - Quickstart\n\nA full-stack A2UI sample demonstrating personalized educational content generation.\n\n**Contributed by Google Public Sector's Rapid Innovation Team.**\n\n---\n\n## What You'll Learn\n\nThis demo showcases several key concepts for building agentic applications with A2UI:\n\n| Concept | What This Demo Shows |\n|---------|---------------------|\n| **Remote Agent Deployment** | Deploy an AI agent to Vertex AI Agent Engine that runs independently from your UI |\n| **A2A Protocol** | Use the Agent-to-Agent protocol to communicate between your frontend and the remote agent |\n| **Custom UI Components** | Extend A2UI with custom components (Flashcard, QuizCard) beyond the standard library |\n| **Dynamic Content Generation** | Generate personalized A2UI JSON on-the-fly based on user requests |\n| **Intelligent Content Matching** | Use LLMs to match user topics to relevant textbook content (167 OpenStax chapters) |\n\n### Why This Architecture Matters\n\nIn production agentic systems:\n- **Agents run remotely** - They scale independently, can be updated without redeploying the UI, and may be operated by third parties\n- **UI is decoupled** - The frontend renders whatever A2UI JSON the agent sends, without knowing the agent's implementation\n- **A2A enables interoperability** - Any A2A-compatible agent can power your UI, regardless of how it's built\n\nThis demo implements this full stack: a TypeScript/Lit frontend communicates via A2A with a Python agent running on Google Cloud.\n\n---\n\n## About This Notebook\n\nThis Jupyter notebook walks you through setting up and running the Personalized Learning demo. It's designed to \"just work\" - run each cell in order and you'll have a working demo.\n\n### What This Demo Shows\n\nThis demo showcases how A2UI enables AI agents to generate rich, interactive learning materials tailored to individual learners:\n\n- **Flashcards** - Generated from OpenStax textbook content\n- **Audio** - Personalized podcasts (via NotebookLM)\n- **Video** - Educational explainers\n- **Quizzes** - Interactive assessment\n\n### The Personalization Pipeline\n\nAt Google Public Sector, we're developing approaches that combine LLMs, knowledge graphs, and learner performance data to produce personalized content across courses—and across a person's academic and professional life.\n\nFor this demo, that personalization is represented by context files in `learner_context/` describing a fictional learner (Maria) and her learning needs.\n\n---\n\n## How This Notebook Is Organized\n\n| Section | What It Does |\n|---------|--------------|\n| **Step 1: Environment Setup** | Creates Python virtual environment and installs all dependencies |\n| **Step 2: Configuration** | Sets your GCP project ID |\n| **Step 3: GCP Authentication** | Authenticates with Google Cloud and enables required APIs |\n| **Step 4: Deploy Agent** | Deploys the AI agent to Vertex AI Agent Engine |\n| **Step 5: Configure & Run** | Creates config files and launches the demo |\n| **Step 6 (Optional)** | Generate audio/video content with NotebookLM |\n| **Appendix: Local Development** | Run entirely locally without cloud deployment |\n\n### Remote vs. Local Deployment\n\n**The default flow deploys the agent to Google Cloud.** This is intentional - the whole point of A2UI is to work with *remote* agents. In production, your UI runs in a browser while the agent runs on a server (possibly operated by a third party). This architecture enables:\n\n- Agents that scale independently of the UI\n- Agents that can be updated without redeploying the frontend\n- Multi-tenant scenarios where one agent serves many users\n- Integration with agents you don't control\n\nHowever, for quick local testing or if you don't have a GCP project, see **Appendix: Local Development** at the end of this notebook.\n\n---\n\n## Prerequisites\n\nBefore starting, ensure you have:\n\n- **Node.js 18+** - [Download](https://nodejs.org/)\n- **Python 3.11+** - [Download](https://www.python.org/downloads/)\n- **Google Cloud project with billing enabled** - [Console](https://console.cloud.google.com/)\n- **gcloud CLI installed** - [Install Guide](https://cloud.google.com/sdk/docs/install)"
+ "source": [
+ "# Personalized Learning Demo - Quickstart\n",
+ "\n",
+ "A full-stack A2UI sample demonstrating personalized educational content generation.\n",
+ "\n",
+ "**Contributed by Google Public Sector's Rapid Innovation Team.**\n",
+ "\n",
+ "\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## The Scenario\n",
+ "\n",
+ "**Maria Thompson** is a pre-med student at Cymbal University preparing for the MCAT. She excels in biology (92%) but struggles with chemistry concepts—particularly the common misconception that \"energy is stored in ATP bonds.\"\n",
+ "\n",
+ "How we find that information about a student's misconceptions across multiple courses is actually part of a broader project at Google Public Sector. But for now, we're just running with information we have related to one of those misconceptions, represented by the text files in the `learner_context/` directory.\n",
+ "\n",
+ "This demo includes a [learner profile visualization](http://localhost:5174/maria-context.html) showing Maria's:\n",
+ "- Academic background and current proficiency levels\n",
+ "- Identified misconceptions to address\n",
+ "- Learning preferences (visual-kinesthetic, sports/gym analogies)\n",
+ "\n",
+ "This profile represents the kind of data a real personalization pipeline would generate from learning management systems, assessment results, and curriculum graphs. Once we have that data, how do we best use it to impact a student's learning trajectory?\n",
+ "\n",
+ "We think the A2UI framework enables an excellent learning experience, and this demo intends to show how.\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## How Content Is Generated\n",
+ "\n",
+ "**Content Source:** [OpenStax](https://openstax.org/) — free, peer-reviewed textbooks covering 167 chapters across biology, chemistry, physics, and more.\n",
+ "\n",
+ "**Generation Pipeline:**\n",
+ "- User requests a topic (e.g., \"Help me understand ATP\")\n",
+ "- The agent uses an LLM to match the topic to the most relevant OpenStax chapter\n",
+ "- Content is fetched and transformed into A2UI components (flashcards, quizzes)\n",
+ "- The frontend renders whatever A2UI JSON the agent returns\n",
+ "\n",
+ "**Learn More:**\n",
+ "- [How A2UI Works](http://localhost:5174/a2ui-primer.html) — interactive explanation in the demo\n",
+ "- [A2UI Specification](../../docs/) — canonical documentation in this repo\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## What You'll Learn\n",
+ "\n",
+ "| Concept | What This Demo Shows |\n",
+ "|---------|---------------------|\n",
+ "| **Remote Agent Deployment** | Deploy an AI agent to Vertex AI Agent Engine that runs independently from your UI |\n",
+ "| **A2A Protocol** | Use the Agent-to-Agent protocol to communicate between your frontend and the remote agent |\n",
+ "| **Custom UI Components** | Extend A2UI with custom components (Flashcard, QuizCard) beyond the standard library |\n",
+ "| **Dynamic Content Generation** | Generate personalized A2UI JSON on-the-fly based on user requests |\n",
+ "| **Intelligent Content Matching** | Use LLMs to match user topics to relevant textbook content (167 OpenStax chapters) |\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## Architecture\n",
+ "\n",
+ "\n",
+ "\n",
+ "In production agentic systems:\n",
+ "- **Agents run remotely** — they scale independently, can be updated without redeploying the UI, and may be operated by third parties\n",
+ "- **UI is decoupled** — the frontend renders whatever A2UI JSON the agent sends, without knowing the agent's implementation\n",
+ "- **A2A enables interoperability** — any A2A-compatible agent can power your UI, regardless of how it's built\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## How This Notebook Is Organized\n",
+ "\n",
+ "| Section | What It Does |\n",
+ "|---------|--------------|\n",
+ "| **Step 1: Environment Setup** | Creates Python virtual environment and installs all dependencies |\n",
+ "| **Step 2: Configuration** | Sets your GCP project ID |\n",
+ "| **Step 3: GCP Authentication** | Authenticates with Google Cloud and enables required APIs |\n",
+ "| **Step 4: Deploy Agent** | Deploys the AI agent to Vertex AI Agent Engine |\n",
+ "| **Step 5: Configure & Run** | Creates config files and launches the demo |\n",
+ "| **Step 6 (Optional)** | Generate audio/video content with NotebookLM |\n",
+ "| **Appendix: Local Development** | Run entirely locally without cloud deployment |\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## Prerequisites\n",
+ "\n",
+ "- **Node.js 18+** — [Download](https://nodejs.org/)\n",
+ "- **Python 3.11+** — [Download](https://www.python.org/downloads/)\n",
+ "- **Google Cloud project with billing enabled** — [Console](https://console.cloud.google.com/)\n",
+ "- **gcloud CLI installed** — [Install Guide](https://cloud.google.com/sdk/docs/install)"
+ ]
},
{
"cell_type": "markdown",
- "source": "## Imports\n\nRun this cell first to load all Python modules used throughout the notebook.",
- "metadata": {}
+ "metadata": {},
+ "source": [
+ "## Imports\n",
+ "\n",
+ "Run this cell first to load all Python modules used throughout the notebook."
+ ]
},
{
"cell_type": "code",
- "source": "import subprocess\nimport sys\nimport os",
- "metadata": {},
"execution_count": null,
- "outputs": []
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import subprocess\n",
+ "import sys\n",
+ "import os"
+ ]
},
{
"cell_type": "markdown",
@@ -33,7 +128,20 @@
"execution_count": null,
"metadata": {},
"outputs": [],
- "source": "# Create virtual environment if it doesn't exist\nvenv_path = os.path.join(os.getcwd(), \".venv\")\nif not os.path.exists(venv_path):\n print(\"Creating Python virtual environment...\")\n subprocess.run([sys.executable, \"-m\", \"venv\", \".venv\"], check=True)\n print(f\"✅ Created virtual environment at {venv_path}\")\nelse:\n print(f\"✅ Virtual environment already exists at {venv_path}\")\n\nprint(\"\\n⚠️ IMPORTANT: Restart your Jupyter kernel to use the new environment!\")\nprint(\" In VS Code: Click the kernel selector (top right) → Select '.venv'\")\nprint(\" In JupyterLab: Kernel → Change Kernel → Python (.venv)\")"
+ "source": [
+ "# Create virtual environment if it doesn't exist\n",
+ "venv_path = os.path.join(os.getcwd(), \".venv\")\n",
+ "if not os.path.exists(venv_path):\n",
+ " print(\"Creating Python virtual environment...\")\n",
+ " subprocess.run([sys.executable, \"-m\", \"venv\", \".venv\"], check=True)\n",
+ " print(f\"✅ Created virtual environment at {venv_path}\")\n",
+ "else:\n",
+ " print(f\"✅ Virtual environment already exists at {venv_path}\")\n",
+ "\n",
+ "print(\"\\n⚠️ IMPORTANT: Restart your Jupyter kernel to use the new environment!\")\n",
+ "print(\" In VS Code: Click the kernel selector (top right) → Select '.venv'\")\n",
+ "print(\" In JupyterLab: Kernel → Change Kernel → Python (.venv)\")"
+ ]
},
{
"cell_type": "markdown",
@@ -51,7 +159,28 @@
"execution_count": null,
"metadata": {},
"outputs": [],
- "source": "# Install Python dependencies using the canonical PyPI index\nprint(\"Installing Python dependencies from PyPI...\")\npackages = [\n \"google-adk>=0.3.0\",\n \"google-genai>=1.0.0\",\n \"google-cloud-storage>=2.10.0\",\n \"python-dotenv>=1.0.0\",\n \"litellm>=1.0.0\",\n \"vertexai\",\n]\n\nsubprocess.run([\n sys.executable, \"-m\", \"pip\", \"install\", \"-q\",\n \"--index-url\", \"https://pypi.org/simple/\",\n \"--trusted-host\", \"pypi.org\",\n \"--trusted-host\", \"files.pythonhosted.org\",\n *packages\n], check=True)\n\nprint(\"✅ Python dependencies installed\")"
+ "source": [
+ "# Install Python dependencies using the canonical PyPI index\n",
+ "print(\"Installing Python dependencies from PyPI...\")\n",
+ "packages = [\n",
+ " \"google-adk>=0.3.0\",\n",
+ " \"google-genai>=1.0.0\",\n",
+ " \"google-cloud-storage>=2.10.0\",\n",
+ " \"python-dotenv>=1.0.0\",\n",
+ " \"litellm>=1.0.0\",\n",
+ " \"vertexai\",\n",
+ "]\n",
+ "\n",
+ "subprocess.run([\n",
+ " sys.executable, \"-m\", \"pip\", \"install\", \"-q\",\n",
+ " \"--index-url\", \"https://pypi.org/simple/\",\n",
+ " \"--trusted-host\", \"pypi.org\",\n",
+ " \"--trusted-host\", \"files.pythonhosted.org\",\n",
+ " *packages\n",
+ "], check=True)\n",
+ "\n",
+ "print(\"✅ Python dependencies installed\")"
+ ]
},
{
"cell_type": "markdown",
@@ -67,7 +196,26 @@
"execution_count": null,
"metadata": {},
"outputs": [],
- "source": "# Build the A2UI Lit renderer (using public npm registry)\nprint(\"Building A2UI Lit renderer...\")\nsubprocess.run(\n \"npm install --registry https://registry.npmjs.org/ && npm run build\",\n shell=True,\n cwd=\"../../renderers/lit\",\n check=True\n)\nprint(\"✅ A2UI renderer built\")\n\n# Install demo dependencies\nprint(\"\\nInstalling demo dependencies...\")\nsubprocess.run(\n \"npm install --registry https://registry.npmjs.org/\",\n shell=True,\n check=True\n)\nprint(\"✅ Demo dependencies installed\")"
+ "source": [
+ "# Build the A2UI Lit renderer (using public npm registry)\n",
+ "print(\"Building A2UI Lit renderer...\")\n",
+ "subprocess.run(\n",
+ " \"npm install --registry https://registry.npmjs.org/ && npm run build\",\n",
+ " shell=True,\n",
+ " cwd=\"../../renderers/lit\",\n",
+ " check=True\n",
+ ")\n",
+ "print(\"✅ A2UI renderer built\")\n",
+ "\n",
+ "# Install demo dependencies\n",
+ "print(\"\\nInstalling demo dependencies...\")\n",
+ "subprocess.run(\n",
+ " \"npm install --registry https://registry.npmjs.org/\",\n",
+ " shell=True,\n",
+ " check=True\n",
+ ")\n",
+ "print(\"✅ Demo dependencies installed\")"
+ ]
},
{
"cell_type": "markdown",
@@ -143,7 +291,21 @@
"execution_count": null,
"metadata": {},
"outputs": [],
- "source": "# Deploy the agent to Vertex AI Agent Engine (takes 2-5 minutes)\nprint(\"Deploying agent to Vertex AI Agent Engine...\")\nprint(\"This takes 2-5 minutes. Watch for the Resource ID at the end.\\n\")\n\nresult = subprocess.run(\n [sys.executable, \"deploy.py\", \"--project\", PROJECT_ID, \"--location\", LOCATION],\n cwd=\"agent\"\n)\n\nif result.returncode != 0:\n print(\"\\n❌ Deployment failed. Check the error messages above.\")\nelse:\n print(\"\\n✅ Deployment complete! Copy the Resource ID from the output above.\")"
+ "source": [
+ "# Deploy the agent to Vertex AI Agent Engine (takes 2-5 minutes)\n",
+ "print(\"Deploying agent to Vertex AI Agent Engine...\")\n",
+ "print(\"This takes 2-5 minutes. Watch for the Resource ID at the end.\\n\")\n",
+ "\n",
+ "result = subprocess.run(\n",
+ " [sys.executable, \"deploy.py\", \"--project\", PROJECT_ID, \"--location\", LOCATION],\n",
+ " cwd=\"agent\"\n",
+ ")\n",
+ "\n",
+ "if result.returncode != 0:\n",
+ " print(\"\\n❌ Deployment failed. Check the error messages above.\")\n",
+ "else:\n",
+ " print(\"\\n✅ Deployment complete! Copy the Resource ID from the output above.\")"
+ ]
},
{
"cell_type": "markdown",
@@ -159,7 +321,15 @@
"execution_count": null,
"metadata": {},
"outputs": [],
- "source": "# Get your project NUMBER (different from project ID)\nresult = subprocess.run(\n [\"gcloud\", \"projects\", \"describe\", PROJECT_ID, \"--format=value(projectNumber)\"], \n capture_output=True, text=True\n)\nPROJECT_NUMBER = result.stdout.strip()\nprint(f\"Project Number: {PROJECT_NUMBER}\")"
+ "source": [
+ "# Get your project NUMBER (different from project ID)\n",
+ "result = subprocess.run(\n",
+ " [\"gcloud\", \"projects\", \"describe\", PROJECT_ID, \"--format=value(projectNumber)\"], \n",
+ " capture_output=True, text=True\n",
+ ")\n",
+ "PROJECT_NUMBER = result.stdout.strip()\n",
+ "print(f\"Project Number: {PROJECT_NUMBER}\")"
+ ]
},
{
"cell_type": "code",
@@ -198,9 +368,10 @@
"source": [
"### Run the Demo\n",
"\n",
- "Everything is set up! Run this command in your terminal (not in the notebook):\n",
+ "Everything is set up! Run these commands in your terminal (not in the notebook):\n",
"\n",
"```bash\n",
+ "cd samples/personalized_learning\n",
"npm run dev\n",
"```\n",
"\n",
@@ -297,24 +468,11 @@
"source": [
"---\n",
"\n",
- "### Part B: Create a Video (Two Options)\n",
- "\n",
- "#### Option 1: NotebookLM Briefing Document + Screen Recording\n",
- "\n",
- "**1. Generate a Briefing Document**\n",
+ "### Part B: Create a Video\n",
"\n",
"In NotebookLM with your learner context loaded:\n",
- "- Click **Notebook guide** → **Briefing doc**\n",
- "- This creates a structured summary you can use as a video script\n",
- "\n",
- "**2. Create the Video**\n",
- "\n",
- "Use the briefing document to create a video:\n",
- "- **Screen recording** - Record yourself walking through the concepts using slides or a whiteboard app\n",
- "- **AI video tools** - Use tools like Synthesia, HeyGen, or similar to generate a video from the script\n",
- "- **Slide presentation** - Create slides and record with voiceover using QuickTime, Loom, or similar\n",
- "\n",
- "**3. Export and Install**\n",
+ "- In the **studio** tab, click \"Video Overview\"\n",
+ "- This creates a video file you can view and export\n",
"\n",
"Export your video as MP4 and copy to the assets directory:"
]
@@ -353,7 +511,30 @@
"execution_count": null,
"metadata": {},
"outputs": [],
- "source": "print(\"Media files status:\")\nprint(\"-\" * 40)\n\npodcast_path = \"public/assets/podcast.m4a\"\nvideo_path = \"public/assets/demo.mp4\"\n\nif os.path.exists(podcast_path):\n size_mb = os.path.getsize(podcast_path) / (1024 * 1024)\n print(f\"✅ Podcast: {podcast_path} ({size_mb:.1f} MB)\")\nelse:\n print(f\"❌ Podcast: {podcast_path} NOT FOUND\")\n \nif os.path.exists(video_path):\n size_mb = os.path.getsize(video_path) / (1024 * 1024)\n print(f\"✅ Video: {video_path} ({size_mb:.1f} MB)\")\nelse:\n print(f\"❌ Video: {video_path} NOT FOUND\")\n\nprint(\"-\" * 40)\nprint(\"\\nRun 'npm run dev' and try:\")\nprint(' • \"Play the podcast\" - to hear the audio')\nprint(' • \"Show me a video\" - to watch the video')"
+ "source": [
+ "print(\"Media files status:\")\n",
+ "print(\"-\" * 40)\n",
+ "\n",
+ "podcast_path = \"public/assets/podcast.m4a\"\n",
+ "video_path = \"public/assets/demo.mp4\"\n",
+ "\n",
+ "if os.path.exists(podcast_path):\n",
+ " size_mb = os.path.getsize(podcast_path) / (1024 * 1024)\n",
+ " print(f\"✅ Podcast: {podcast_path} ({size_mb:.1f} MB)\")\n",
+ "else:\n",
+ " print(f\"❌ Podcast: {podcast_path} NOT FOUND\")\n",
+ " \n",
+ "if os.path.exists(video_path):\n",
+ " size_mb = os.path.getsize(video_path) / (1024 * 1024)\n",
+ " print(f\"✅ Video: {video_path} ({size_mb:.1f} MB)\")\n",
+ "else:\n",
+ " print(f\"❌ Video: {video_path} NOT FOUND\")\n",
+ "\n",
+ "print(\"-\" * 40)\n",
+ "print(\"\\nRun 'npm run dev' and try:\")\n",
+ "print(' • \"Play the podcast\" - to hear the audio')\n",
+ "print(' • \"Show me a video\" - to watch the video')"
+ ]
},
{
"cell_type": "markdown",
@@ -382,77 +563,21 @@
"source": [
"---\n",
"\n",
- "## Appendix: Local Development (No Cloud Required)\n",
- "\n",
- "If you don't have a GCP project or want to iterate quickly without deploying, you can run everything locally. The agent runs as a local Python server instead of on Vertex AI.\n",
- "\n",
- "**Important:** While local development is convenient for testing, remember that A2UI is designed for *remote* agents. The default cloud deployment flow demonstrates the real-world architecture where:\n",
- "- The UI runs in the browser\n",
- "- The agent runs on a remote server\n",
- "- They communicate via the A2A protocol\n",
- "\n",
- "Local development is a shortcut for testing - production deployments should use remote agents.\n",
- "\n",
- "### Local Setup\n",
- "\n",
- "If you haven't already completed Step 1, run the environment setup cells first. Then configure your API key:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "# Create .env file for local development\n",
- "# You'll need a Google AI API key from https://aistudio.google.com/apikey\n",
- "\n",
- "GOOGLE_API_KEY = \"YOUR_API_KEY_HERE\" # <-- CHANGE THIS\n",
+ "## Limitations & Known Issues\n",
"\n",
- "env_content = f\"\"\"# Generated by Quickstart.ipynb (Local Development Mode)\n",
- "GOOGLE_API_KEY={GOOGLE_API_KEY}\n",
- "\"\"\"\n",
- "\n",
- "with open(\".env\", \"w\") as f:\n",
- " f.write(env_content)\n",
- "\n",
- "# Also create agent/.env for the local agent server\n",
- "with open(\"agent/.env\", \"w\") as f:\n",
- " f.write(env_content)\n",
- "\n",
- "print(\"Created .env files for local development\")\n",
- "print(\"\\n⚠️ Make sure to replace YOUR_API_KEY_HERE with your actual API key!\")\n",
- "print(\" Get one at: https://aistudio.google.com/apikey\")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Run Locally\n",
- "\n",
- "With the local `.env` configured, run all three servers (frontend, API proxy, and local agent):\n",
- "\n",
- "```bash\n",
- "npm run start:all\n",
- "```\n",
+ "This is a demonstration, not a production system. Here's what can/will break:\n",
"\n",
- "This starts:\n",
- "- **Vite dev server** at http://localhost:5174 (frontend)\n",
- "- **API server** at http://localhost:8080 (proxy)\n",
- "- **Agent server** at http://localhost:8081 (local Python agent)\n",
+ "| What You Try | What Happens | Why |\n",
+ "|--------------|--------------|-----|\n",
+ "| **Ask for study materials across multiple topics at once** | Retrieval returns wrong content | The agent is designed to match to a single OpenStax chapter; multi-topic queries need more sophisticated retrieval. (There are many good ways to do this.) |\n",
+ "| **\"Play podcast about X\"** | Nothing plays (or wrong content) | Audio is pre-generated via NotebookLM, not dynamically created |\n",
+ "| **Sidebar navigation, settings, etc.** | Nothing happens | The UI is styled to resemble a Google product, but only the chat functionality is implemented |\n",
"\n",
- "Open **http://localhost:5174** to use the demo.\n",
+ "### What This Demo Is (and Isn't)\n",
"\n",
- "### Differences from Cloud Deployment\n",
+ "**Is:** A working example of A2UI's architecture—remote agent deployment, A2A protocol, custom components, and dynamic content generation.\n",
"\n",
- "| Aspect | Cloud (Default) | Local |\n",
- "|--------|-----------------|-------|\n",
- "| Agent location | Vertex AI Agent Engine | Local Python process |\n",
- "| Scalability | Auto-scales | Single process |\n",
- "| API key | Uses ADC/service account | Requires GOOGLE_API_KEY |\n",
- "| Startup time | ~5 min (one-time deploy) | Instant |\n",
- "| Cost | Vertex AI pricing | Free (API usage only) |"
+ "**Isn't:** A complete learning platform. The personalization pipeline, multi-topic retrieval, and non-chat UI elements are placeholders demonstrating where real implementations would go."
]
}
],
@@ -469,4 +594,4 @@
},
"nbformat": 4,
"nbformat_minor": 4
-}
\ No newline at end of file
+}
diff --git a/samples/personalized_learning/README.md b/samples/personalized_learning/README.md
index 509db978..7eb34f65 100644
--- a/samples/personalized_learning/README.md
+++ b/samples/personalized_learning/README.md
@@ -1,110 +1,21 @@
# Personalized Learning Demo
-A full-stack A2UI sample demonstrating personalized educational content generation.
+A full-stack A2UI sample demonstrating personalized educational content generation with remote AI agents.
**Contributed by Google Public Sector's Rapid Innovation Team.**
-## Overview
+
-This demo shows how A2UI enables AI agents to generate rich, interactive learning materials tailored to individual learners:
+## What This Demo Shows
-- **Flashcards** - Generated dynamically from OpenStax textbook content
-- **Audio** - Personalized podcasts (via NotebookLM)
-- **Video** - Educational explainers
-- **Quizzes** - Interactive assessment with explanations
+- **Remote Agent Deployment** - Deploy an AI agent to Vertex AI Agent Engine
+- **A2A Protocol** - Agent-to-Agent communication between frontend and cloud agent
+- **Custom UI Components** - Extend A2UI with Flashcard and QuizCard components
+- **Intelligent Content Matching** - LLM-powered mapping of topics to OpenStax textbook chapters
-### The Personalization Pipeline
+## Getting Started
-At Google Public Sector, we're developing approaches that combine LLMs, knowledge graphs, and learner performance data to produce personalized content across courses—and across a person's academic and professional life.
-
-For this demo, that personalization is represented by context files in `learner_context/` describing a fictional learner (Maria) and her learning needs.
-
-## Quick Start
-
-**Open [Quickstart.ipynb](Quickstart.ipynb)** and follow the steps. The notebook handles:
-
-1. GCP authentication and API setup
-2. Agent deployment to Vertex AI Agent Engine
-3. Environment configuration
-4. Frontend installation
-5. (Optional) Audio/video generation with NotebookLM
-
-Or manually:
-
-```bash
-# 1. Configure environment
-cp .env.template .env
-# Edit .env with your GCP project details
-
-# 2. Build the A2UI renderer
-cd ../../renderers/lit && npm install && npm run build && cd -
-
-# 3. Install and run
-npm install
-npm run dev
-```
-
-Then open http://localhost:5174
-
-## Demo Prompts
-
-| Try This | What Happens |
-|----------|--------------|
-| "Help me understand ATP" | Flashcards from OpenStax |
-| "Quiz me on bond energy" | Interactive quiz cards |
-| "Play the podcast" | Audio player |
-| "Show me a video" | Video player |
-
-## Generating Your Own Audio & Video
-
-The demo includes sample media files. To generate personalized content:
-
-1. **Generate a podcast** using [NotebookLM](https://notebooklm.google.com/):
- - Upload files from `learner_context/`
- - Generate an Audio Overview
- - Download and save as `public/assets/podcast.m4a`
-
-2. **Create a video** (screen recording, AI video tool, or slide presentation)
- - Save as `public/assets/demo.mp4`
-
-See **Step 7** in the [Quickstart notebook](Quickstart.ipynb) for detailed instructions.
-
-## Custom A2UI Components
-
-This demo demonstrates A2UI's extensibility by registering custom components:
-
-- **Flashcard** (`src/flashcard.ts`) - Flippable study cards with front/back content
-- **QuizCard** (`src/quiz-card.ts`) - Interactive multiple-choice with instant feedback
-
-To register custom components:
-
-```typescript
-import { Flashcard } from "./flashcard.js";
-import * as UI from "@a2ui/web-lib/ui";
-
-UI.componentRegistry.register("Flashcard", Flashcard, "a2ui-flashcard");
-```
-
-Then use in A2UI JSON:
-```json
-{
- "id": "card1",
- "component": {
- "Flashcard": {
- "front": {"literalString": "What is ATP?"},
- "back": {"literalString": "Adenosine Triphosphate..."}
- }
- }
-}
-```
-
-## Content Attribution
-
-Educational content sourced from [OpenStax Biology for AP® Courses](https://openstax.org/details/books/biology-ap-courses), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
-
-## Security Notice
-
-> **Warning:** When building production applications, treat any agent outside your control as potentially untrusted. This demo connects to Agent Engine within your own GCP project. Always review agent code before deploying.
+**Open [Quickstart.ipynb](Quickstart.ipynb)** - the notebook walks you through setup, deployment, and running the demo.
## License
diff --git a/samples/personalized_learning/assets/architecture.jpg b/samples/personalized_learning/assets/architecture.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..e55132a5115202bc841316e593425380ab7a3ac8
GIT binary patch
literal 100946
zcmb@t1yEeg^C-GVfWQU|<lVa9DzSa0u=YoW1P$(PNpKc-cM0z9nz#9W
z^8UB#y<2sw?riNjy=Qv5dwP0idiHexnfvn_fGsO2BMCr2000o+AHbhAfQ5vIr5OMq
zBf|hd0{{Ss0CogK04f}M3;$Y%A^$}Ku)x26i;|^@k+acXEI=G802%&-4Gd;OibDk8
z!Qqd8QQ!s42Ik~r2lKIWQ?h~i-t+RYal<(wQCPURIPkHu+Bvfrnc5peSWN6~Sv`y#
zSlLogmdoQyUefrKup5CZ{}`yn`6T!cyAH38Lzypl0G_
zZNh6xB`icK;KAo%>tG9UF{1RawXt*N^AM!^hd3V`{`;7fit-;67i&Q(EqNtMF?%Nn
zB_|6f3mX-@wUenCpNhETzuUrp2~z!ANOyO47IzL7dna>Nc3xgyRyHsz7|aaEV0QMj
zb20K@wsWTb4+U|Evx$?XgNvoT9pzt&M#lE8E`n5W&i{x6fh+jGbpMym|E>9__y0k4
zu&{Trceb#1`2WWLZ}tDuNXEg%