xmuruaga commited on
Commit
4f58ba4
·
verified ·
1 Parent(s): 81917a3

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +162 -19
  2. tooling.py +131 -0
  3. wikipedia_utils.py +52 -0
  4. youtube_utils.py +24 -0
app.py CHANGED
@@ -3,6 +3,16 @@ import gradio as gr
3
  import requests
4
  import inspect
5
  import pandas as pd
 
 
 
 
 
 
 
 
 
 
6
 
7
  # (Keep Constants as is)
8
  # --- Constants ---
@@ -10,25 +20,145 @@ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
10
 
11
  # --- Basic Agent Definition ---
12
  # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  class BasicAgent:
14
  def __init__(self):
15
  print("BasicAgent initialized.")
 
 
 
 
 
 
 
 
 
 
 
 
16
  def __call__(self, question: str) -> str:
17
  print(f"Agent received question (first 50 chars): {question[:50]}...")
18
- fixed_answer = "This is a default answer."
19
- print(f"Agent returning fixed answer: {fixed_answer}")
20
- return fixed_answer
21
 
22
- def run_and_submit_all( profile: gr.OAuthProfile | None):
 
23
  """
24
  Fetches all questions, runs the BasicAgent on them, submits all answers,
25
  and displays the results.
26
  """
27
  # --- Determine HF Space Runtime URL and Repo URL ---
28
- space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
29
 
30
  if profile:
31
- username= f"{profile.username}"
32
  print(f"User logged in: {username}")
33
  else:
34
  print("User not logged in.")
@@ -55,16 +185,16 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
55
  response.raise_for_status()
56
  questions_data = response.json()
57
  if not questions_data:
58
- print("Fetched questions list is empty.")
59
- return "Fetched questions list is empty or invalid format.", None
60
  print(f"Fetched {len(questions_data)} questions.")
61
  except requests.exceptions.RequestException as e:
62
  print(f"Error fetching questions: {e}")
63
  return f"Error fetching questions: {e}", None
64
  except requests.exceptions.JSONDecodeError as e:
65
- print(f"Error decoding JSON response from questions endpoint: {e}")
66
- print(f"Response text: {response.text[:500]}")
67
- return f"Error decoding server response for questions: {e}", None
68
  except Exception as e:
69
  print(f"An unexpected error occurred fetching questions: {e}")
70
  return f"An unexpected error occurred fetching questions: {e}", None
@@ -74,18 +204,31 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
74
  answers_payload = []
75
  print(f"Running agent on {len(questions_data)} questions...")
76
  for item in questions_data:
 
 
 
 
 
77
  task_id = item.get("task_id")
78
  question_text = item.get("question")
79
  if not task_id or question_text is None:
80
  print(f"Skipping item with missing task_id or question: {item}")
81
  continue
82
  try:
83
- submitted_answer = agent(question_text)
 
 
 
 
 
 
 
 
84
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
85
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
86
  except Exception as e:
87
- print(f"Error running agent on task {task_id}: {e}")
88
- results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"})
89
 
90
  if not answers_payload:
91
  print("Agent did not produce any answers to submit.")
@@ -172,10 +315,10 @@ with gr.Blocks() as demo:
172
  )
173
 
174
  if __name__ == "__main__":
175
- print("\n" + "-"*30 + " App Starting " + "-"*30)
176
  # Check for SPACE_HOST and SPACE_ID at startup for information
177
  space_host_startup = os.getenv("SPACE_HOST")
178
- space_id_startup = os.getenv("SPACE_ID") # Get SPACE_ID at startup
179
 
180
  if space_host_startup:
181
  print(f"✅ SPACE_HOST found: {space_host_startup}")
@@ -183,14 +326,14 @@ if __name__ == "__main__":
183
  else:
184
  print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
185
 
186
- if space_id_startup: # Print repo URLs if SPACE_ID is found
187
  print(f"✅ SPACE_ID found: {space_id_startup}")
188
  print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
189
  print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
190
  else:
191
  print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.")
192
 
193
- print("-"*(60 + len(" App Starting ")) + "\n")
194
 
195
  print("Launching Gradio Interface for Basic Agent Evaluation...")
196
- demo.launch(debug=True, share=False)
 
3
  import requests
4
  import inspect
5
  import pandas as pd
6
+ from smolagents import DuckDuckGoSearchTool,GoogleSearchTool, HfApiModel, PythonInterpreterTool, VisitWebpageTool, CodeAgent,Tool, LiteLLMModel
7
+ import hashlib
8
+ import json
9
+ from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline, TransformersEngine
10
+ import wikipedia
11
+ from tooling import WikipediaPageFetcher,MathModelQuerer, YoutubeTranscriptFetcher, CodeModelQuerer
12
+ from langchain_community.agent_toolkits.load_tools import load_tools
13
+ import time
14
+ import torch
15
+
16
 
17
  # (Keep Constants as is)
18
  # --- Constants ---
 
20
 
21
  # --- Basic Agent Definition ---
22
  # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
23
+
24
+ cache = {}
25
+
26
+
27
+
28
+ class WebSearchTool(DuckDuckGoSearchTool):
29
+ name = "web_search_ddg"
30
+ description = "Search the web using DuckDuckGo"
31
+ web_search_ddf = WebSearchTool()
32
+ google_search = GoogleSearchTool(provider="serper")
33
+ python_interpreter = PythonInterpreterTool(authorized_imports = [
34
+ # standard library
35
+ 'os', # For file path manipulation, checking existence, deletion
36
+ 'glob', # Find files matching specific patterns
37
+ 'pathlib', # Alternative for path manipulation
38
+ 'sys',
39
+ 'math',
40
+ 'random',
41
+ 'datetime',
42
+ 'time',
43
+ 'json',
44
+ 'csv',
45
+ 're',
46
+ 'collections',
47
+ 'itertools',
48
+ 'functools',
49
+ 'io',
50
+ 'base64',
51
+ 'hashlib',
52
+ 'pathlib',
53
+ 'glob',
54
+
55
+ # Third-Party Libraries (ensure they are installed in the execution env)
56
+ 'pandas', # Data manipulation and analysis
57
+ 'numpy', # Numerical operations
58
+ 'scipy', # Scientific and technical computing (stats, optimize, etc.)
59
+ 'sklearn', # Machine learning
60
+
61
+ ])
62
+ visit_webpage_tool = VisitWebpageTool()
63
+ wiki_tool = WikipediaPageFetcher()
64
+ yt_transcript_fetcher = YoutubeTranscriptFetcher()
65
+ # math_model_querer = MathModelQuerer()
66
+ # code_model_querer = CodeModelQuerer()
67
+
68
+ # batch of tools fromm Langchain. Credits DataDiva88
69
+ lc_ddg_search = Tool.from_langchain(load_tools(["ddg-search"])[0])
70
+ lc_wikipedia = Tool.from_langchain(load_tools(["wikipedia"])[0])
71
+ lc_arxiv = Tool.from_langchain(load_tools(["arxiv"])[0])
72
+ lc_pubmed = Tool.from_langchain(load_tools(["pubmed"])[0])
73
+ lc_stackechange = Tool.from_langchain(load_tools(["stackexchange"])[0])
74
+
75
+
76
+ def load_cached_answer(question_id: str) -> str:
77
+ if question_id in cache.keys():
78
+ return cache[question_id]
79
+ else:
80
+ return None
81
+
82
+
83
+ def cache_answer(question_id: str, answer: str):
84
+ cache[question_id] = answer
85
+
86
+
87
+ # --- Model Setup ---
88
+ #MODEL_NAME = 'Qwen/Qwen2.5-3B-Instruct' # 'meta-llama/Llama-3.2-3B-Instruct'
89
+
90
+
91
+ # "Qwen/Qwen2.5-VL-3B-Instruct"#'meta-llama/Llama-2-7b-hf'#'meta-llama/Llama-3.1-8B-Instruct'#'TinyLlama/TinyLlama-1.1B-Chat-v1.0'#'mistralai/Mistral-7B-Instruct-v0.2'#'microsoft/DialoGPT-small'# 'EleutherAI/gpt-neo-2.7B'#'distilbert/distilgpt2'#'deepseek-ai/DeepSeek-R1-Distill-Qwen-7B'#'mistralai/Mistral-7B-Instruct-v0.2'
92
+
93
+
94
+ def load_model(model_name):
95
+ """Download and load the model and tokenizer."""
96
+ try:
97
+ print(f"Loading model {MODEL_NAME}...")
98
+ model = AutoModelForCausalLM.from_pretrained(MODEL_NAME)
99
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
100
+
101
+ if tokenizer.pad_token is None:
102
+ tokenizer.pad_token = tokenizer.eos_token
103
+
104
+ print(f"Model {MODEL_NAME} loaded successfully.")
105
+
106
+ transformers_engine = TransformersEngine(pipeline("text-generation", model=model, tokenizer=tokenizer))
107
+
108
+ return transformers_engine, model
109
+ except Exception as e:
110
+ print(f"Error loading model: {e}")
111
+ raise
112
+
113
+
114
+ # Load the model and tokenizer locally
115
+ # model, tokenizer = load_model()
116
+
117
+ #model_id = "meta-llama/Llama-3.1-8B-Instruct" # "microsoft/phi-2"# not working out of the box"google/gemma-2-2b-it" #toobig"Qwen/Qwen1.5-7B-Chat"#working but stupid: "meta-llama/Llama-3.2-3B-Instruct"
118
+ model = LiteLLMModel(model_id="anthropic/claude-3-5-sonnet-latest", temperature=0.2, max_tokens=512)
119
+ #from smolagents import TransformersModel
120
+ # model = TransformersModel(
121
+ # model_id=model_id,
122
+ # max_new_tokens=256)
123
+
124
+ # model = HfApiModel()
125
+ lc_ddg_search = Tool.from_langchain(load_tools(["ddg-search"])[0])
126
+ lc_wikipedia = Tool.from_langchain(load_tools(["wikipedia"])[0])
127
+ lc_arxiv = Tool.from_langchain(load_tools(["arxiv"])[0])
128
+ lc_pubmed = Tool.from_langchain(load_tools(["pubmed"])[0])
129
+
130
+
131
  class BasicAgent:
132
  def __init__(self):
133
  print("BasicAgent initialized.")
134
+ self.agent = CodeAgent(
135
+ model=model,
136
+ tools=[google_search,web_search_ddf, python_interpreter, visit_webpage_tool, wiki_tool,lc_wikipedia,lc_arxiv,lc_pubmed,lc_stackechange],
137
+ max_steps=10,
138
+ verbosity_level=1,
139
+ grammar=None,
140
+ planning_interval=3,
141
+ add_base_tools=True,
142
+ additional_authorized_imports=['requests', 'wikipedia', 'pandas','datetime']
143
+
144
+ )
145
+
146
  def __call__(self, question: str) -> str:
147
  print(f"Agent received question (first 50 chars): {question[:50]}...")
148
+ answer = self.agent.run(question)
149
+ return answer
 
150
 
151
+
152
+ def run_and_submit_all(profile: gr.OAuthProfile | None):
153
  """
154
  Fetches all questions, runs the BasicAgent on them, submits all answers,
155
  and displays the results.
156
  """
157
  # --- Determine HF Space Runtime URL and Repo URL ---
158
+ space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
159
 
160
  if profile:
161
+ username = f"{profile.username}"
162
  print(f"User logged in: {username}")
163
  else:
164
  print("User not logged in.")
 
185
  response.raise_for_status()
186
  questions_data = response.json()
187
  if not questions_data:
188
+ print("Fetched questions list is empty.")
189
+ return "Fetched questions list is empty or invalid format.", None
190
  print(f"Fetched {len(questions_data)} questions.")
191
  except requests.exceptions.RequestException as e:
192
  print(f"Error fetching questions: {e}")
193
  return f"Error fetching questions: {e}", None
194
  except requests.exceptions.JSONDecodeError as e:
195
+ print(f"Error decoding JSON response from questions endpoint: {e}")
196
+ print(f"Response text: {response.text[:500]}")
197
+ return f"Error decoding server response for questions: {e}", None
198
  except Exception as e:
199
  print(f"An unexpected error occurred fetching questions: {e}")
200
  return f"An unexpected error occurred fetching questions: {e}", None
 
204
  answers_payload = []
205
  print(f"Running agent on {len(questions_data)} questions...")
206
  for item in questions_data:
207
+
208
+
209
+ time.sleep(60)
210
+
211
+
212
  task_id = item.get("task_id")
213
  question_text = item.get("question")
214
  if not task_id or question_text is None:
215
  print(f"Skipping item with missing task_id or question: {item}")
216
  continue
217
  try:
218
+ cached = load_cached_answer(task_id)
219
+ if cached:
220
+ submitted_answer = cached
221
+ print(f"Loaded cached answer for task {task_id}")
222
+ else:
223
+ submitted_answer = agent(question_text)
224
+ cache_answer(task_id, submitted_answer)
225
+ print(f"Generated and cached answer for task {task_id}")
226
+
227
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
228
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
229
  except Exception as e:
230
+ print(f"Error running agent on task {task_id}: {e}")
231
+ results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"})
232
 
233
  if not answers_payload:
234
  print("Agent did not produce any answers to submit.")
 
315
  )
316
 
317
  if __name__ == "__main__":
318
+ print("\n" + "-" * 30 + " App Starting " + "-" * 30)
319
  # Check for SPACE_HOST and SPACE_ID at startup for information
320
  space_host_startup = os.getenv("SPACE_HOST")
321
+ space_id_startup = os.getenv("SPACE_ID") # Get SPACE_ID at startup
322
 
323
  if space_host_startup:
324
  print(f"✅ SPACE_HOST found: {space_host_startup}")
 
326
  else:
327
  print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
328
 
329
+ if space_id_startup: # Print repo URLs if SPACE_ID is found
330
  print(f"✅ SPACE_ID found: {space_id_startup}")
331
  print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
332
  print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
333
  else:
334
  print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.")
335
 
336
+ print("-" * (60 + len(" App Starting ")) + "\n")
337
 
338
  print("Launching Gradio Interface for Basic Agent Evaluation...")
339
+ demo.launch(debug=True, share=False)
tooling.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from smolagents import Tool
2
+ from transformers import AutoTokenizer, AutoModelForCausalLM, GenerationConfig
3
+ import torch
4
+ from wikipedia_utils import *
5
+ from youtube_utils import *
6
+
7
+
8
+ class MathModelQuerer(Tool):
9
+ name = "math_model"
10
+ description = "Solves advanced math problems using a pretrained\
11
+ large language model specialized in mathematics. Ideal for symbolic reasoning, \
12
+ calculus, algebra, and other technical math queries."
13
+
14
+ inputs = {
15
+ "problem": {
16
+ "type": "string",
17
+ "description": "Math problem to solve.",
18
+ }
19
+ }
20
+
21
+ output_type = "string"
22
+
23
+ def __init__(self, model_name="deepseek-ai/deepseek-math-7b-base"):
24
+ print(f"Loading math model: {model_name}")
25
+
26
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name)
27
+ print("loaded tokenizer")
28
+ self.model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.bfloat16)
29
+ print("loaded auto model")
30
+
31
+ self.model.generation_config = GenerationConfig.from_pretrained(model_name)
32
+ print("loaded coonfig")
33
+
34
+ self.model.generation_config.pad_token_id = self.model.generation_config.eos_token_id
35
+ print("loaded pad token")
36
+
37
+ def forward(self, problem: str) -> str:
38
+ try:
39
+ print(f"[MathModelTool] Question: {problem}")
40
+
41
+ inputs = self.tokenizer(problem, return_tensors="pt")
42
+ outputs = self.model.generate(**inputs, max_new_tokens=100)
43
+
44
+ result = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
45
+
46
+ return result
47
+ except:
48
+ return f"Failed using the tool {self.name}"
49
+
50
+
51
+ class CodeModelQuerer(Tool):
52
+ name = "code_querer"
53
+ description = "Generates code snippets based on a natural language description of a\
54
+ programming task using a powerful coding-focused language model. Suitable\
55
+ for solving coding problems, generating functions, or implementing algorithms."
56
+
57
+ inputs = {
58
+ "problem": {
59
+ "type": "string",
60
+ "description": "Description of a code sample to be generated",
61
+ }
62
+ }
63
+
64
+ output_type = "string"
65
+
66
+ def __init__(self, model_name="Qwen/Qwen2.5-Coder-32B-Instruct"):
67
+ from smolagents import HfApiModel
68
+ print(f"Loading llm for Code tool: {model_name}")
69
+ self.model = HfApiModel()
70
+
71
+ def forward(self, problem: str) -> str:
72
+ try:
73
+ return self.model.generate(problem, max_new_tokens=512)
74
+ except:
75
+ return f"Failed using the tool {self.name}"
76
+
77
+
78
+ class WikipediaPageFetcher(Tool):
79
+ name = "wiki_page_fetcher"
80
+ description =' Searches and fetches summaries from Wikipedia for any topic,\
81
+ across all supported languages and versions. Only a single query string is required as input.'
82
+
83
+
84
+
85
+ inputs = {
86
+ "query": {
87
+ "type": "string",
88
+ "description": "Topic of wikipedia search",
89
+ }
90
+ }
91
+
92
+ output_type = "string"
93
+
94
+ def forward(self, query: str) -> str:
95
+ try:
96
+ wiki_query = query(query)
97
+ wiki_page = fetch_wikipedia_page(wiki_query)
98
+ return wiki_page
99
+ except:
100
+ return f"Failed using the tool {self.name}"
101
+
102
+
103
+ class YoutubeTranscriptFetcher(Tool):
104
+ name = "youtube_transcript_fetcher"
105
+ description ="Fetches the English transcript of a YouTube video using either a direct video \
106
+ ID or a URL that includes one. Accepts a query containing the link or the raw video ID directly. Returns the transcript as plain text."
107
+
108
+ inputs = {
109
+ "query": {
110
+ "type": "string",
111
+ "description": "A query that includes youtube id."
112
+ },
113
+ "video_id" : {
114
+ "type" : "string",
115
+ "description" : "Optional string with video id from youtube.",
116
+ "nullable" : True
117
+ }
118
+ }
119
+
120
+ output_type = "string"
121
+
122
+ def forward(self, query: str, video_id=None) -> str:
123
+ try:
124
+ if video_id is None:
125
+ video_id = get_youtube_video_id(query)
126
+
127
+ fetched_transcript = fetch_transcript_english(video_id)
128
+
129
+ return post_process_transcript(fetched_transcript)
130
+ except:
131
+ return f"Failed using the tool {self.name}"
wikipedia_utils.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import wikipedia
2
+ import spacy
3
+
4
+
5
+ def get_wiki_query(query):
6
+ try:
7
+ ### spacy code
8
+
9
+ # Load the English model
10
+ nlp = spacy.load("en_core_web_sm")
11
+
12
+ # Parse the sentence
13
+ doc = nlp(query)
14
+
15
+ # Entity path (people, evenrs, books)
16
+ entities_components = [entity_substring.text for entity_substring in doc.ents]
17
+ if len(entities_components) > 0:
18
+ subject_of_the_query = ""
19
+ for substrings in entities_components:
20
+ subject_of_the_query = subject_of_the_query + substrings
21
+
22
+ if subject_of_the_query == "":
23
+ print("Entity query not parsed.")
24
+ return subject_of_the_query
25
+
26
+
27
+
28
+ else:
29
+ first_noun = next((t for t in doc if t.pos_ in {"NOUN", "PROPN"}), None).text
30
+ print("Returning first noun from the query.")
31
+ return first_noun
32
+
33
+
34
+
35
+
36
+ except Exception as e:
37
+ print("Failed parsing a query subject from query", query)
38
+ print(e)
39
+
40
+
41
+ def fetch_wikipedia_page(wiki_query):
42
+ try:
43
+ matched_articles = wikipedia.search(wiki_query)
44
+ if len(matched_articles) > 0:
45
+ used_article = matched_articles[0]
46
+ page_content = wikipedia.page(used_article, auto_suggest=False)
47
+ return page_content.content
48
+ else:
49
+ return ""
50
+ except Exception as e:
51
+ print("Could not fetch the wikipedia article using ", wiki_query)
52
+ print(e)
youtube_utils.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from youtube_transcript_api import YouTubeTranscriptApi
2
+ import re
3
+
4
+ def get_youtube_video_id(query):
5
+ try:
6
+ match = re.search(r'(?:youtu\.be/|youtube\.com/(?:watch\?v=|embed/|v/|shorts/))([\w-]{11})', query)
7
+ if match:
8
+ video_id = match.group(1)
9
+ print(video_id)
10
+ return video_id
11
+ except:
12
+ print("Did not find youtube video id from query ", query)
13
+
14
+ def fetch_transcript_english(video_id):
15
+ try:
16
+ ytt_api = YouTubeTranscriptApi()
17
+ transcript = ytt_api.fetch(video_id,languages=['en'])
18
+ return transcript
19
+ except:
20
+ print("Error ")
21
+
22
+ def post_process_transcript(transcript_snippets):
23
+ full_transcript = " ".join([transcript_snippet.text for transcript_snippet in transcript_snippets])
24
+ return full_transcript