Response will appear here.+ +
Response will appear here.
diff --git a/.dockerignore b/.dockerignore index 9547660..233d333 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,9 +4,12 @@ __pycache__ *.pyd *.env *venv/ -*.git +.git .gitignore Dockerfile docker-compose.yml log.md -.vscode \ No newline at end of file +<<<<<<< HEAD +.vscode +======= +>>>>>>> b5a2b4e6d1b958dbb3ad702026889172514c1fd6 diff --git a/.vscode/launch.json b/.vscode/launch.json index 3eefcee..f624201 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,26 +1,26 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python Debugger: Flask", - "type": "debugpy", - "request": "launch", - "cwd": "./app", - "module": "flask", - "env": { - "FLASK_APP": "./app.py", - "FLASK_DEBUG": "1" - }, - "args": [ - "run", - "--debug", - "--no-reload" - ], - "jinja": true, - "autoStartBrowser": false - } - ] +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Flask", + "type": "debugpy", + "request": "launch", + "cwd": "./app", + "module": "flask", + "env": { + "FLASK_APP": "./app.py", + "FLASK_DEBUG": "1" + }, + "args": [ + "run", + "--debug", + "--no-reload" + ], + "jinja": true, + "autoStartBrowser": false + } + ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 8ec53f6..79c9a93 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ -{ - "html.autoClosingTags": true, - "html.format.enable": true, - "html.autoCreateQuotes": true +{ + "html.autoClosingTags": true, + "html.format.enable": true, + "html.autoCreateQuotes": true } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6a4caff..b9556a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use an official Python runtime as a parent image -FROM python:3.11-slim +FROM python:3.13-slim # Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 @@ -18,7 +18,7 @@ COPY requirements.txt . RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt # Copy application files -COPY . /app +COPY /app /app # Make start.sh executable RUN chmod +x /app/start.sh diff --git a/README.md b/README.md index 1dc2257..863c8a7 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ -This is simple web application that is made for a very specific purpose: to spite my 10th grade social studies teacher. -See, basically he didn't teach us anything, he wanted us to watch 10+ year old lectures on [his youtube channel](https://www.youtube.com/@mikebardonaro3227). -Yes, he's been doing this for so long that he can probably monatise his channel. -For each lecture, he also wanted us to take notes and create 5 Questions and Answers with a few critera. - -Now here's what I thought about this: -If he isn't actually going to teach us the content of the class that I have to physically attend, then why should I do anything but match the effort he's putting forth. - -So I made a Python script that took a video id and got the youtube transcript of it and then fed it to an OpenAI assistant to do it for me. -This was pretty pointless as you can also just copy the transcript and paste it into the assistant threads on OpenAI's playground platform but I still did it for the hell of it. -That got me through the year just fine. - -However, the next year I had a friend who also got into his class, and instead of having him repeatedly ask me for my old work, I figured why not let him create some..."original" work himself? -So I spent a few nights developing a web application with a very simple task, and here it is. -It is some pretty bad code. Like, actually "minimum to make it work" code. However, I've decided to use this as an oppurtunity to still learn some things and hopefully be able to do more dedicated things. -I still occasionally revist it to try to make it a little better, and I might even scale up the website a bit and make it so that anyone can use it. Of course, this would come at a cost but I feel like it would be relatively deserved for the teacher after more than a dozen years of not doing anything. - +This is simple web application that is made for a very specific purpose: to spite my 10th grade social studies teacher. +See, basically he didn't teach us anything, he wanted us to watch 10+ year old lectures on [his youtube channel](https://www.youtube.com/@mikebardonaro3227). +Yes, he's been doing this for so long that he can probably monatise his channel. +For each lecture, he also wanted us to take notes and create 5 Questions and Answers with a few critera. + +Now here's what I thought about this: +If he isn't actually going to teach us the content of the class that I have to physically attend, then why should I do anything but match the effort he's putting forth. + +So I made a Python script that took a video id and got the youtube transcript of it and then fed it to an OpenAI assistant to do it for me. +This was pretty pointless as you can also just copy the transcript and paste it into the assistant threads on OpenAI's playground platform but I still did it for the hell of it. +That got me through the year just fine. + +However, the next year I had a friend who also got into his class, and instead of having him repeatedly ask me for my old work, I figured why not let him create some..."original" work himself? +So I spent a few nights developing a web application with a very simple task, and here it is. +It is some pretty bad code. Like, actually "minimum to make it work" code. However, I've decided to use this as an oppurtunity to still learn some things and hopefully be able to do more dedicated things. +I still occasionally revist it to try to make it a little better, and I might even scale up the website a bit and make it so that anyone can use it. Of course, this would come at a cost but I feel like it would be relatively deserved for the teacher after more than a dozen years of not doing anything. + If I ever make this repository public, judge the hell out of me. Just know that, unfortunately for everyone who would be looking for it, I never commit any hardcoded API keys, or `.env`...sorry. \ No newline at end of file diff --git a/app/app.py b/app/app.py index 739ce92..b4ae13e 100644 --- a/app/app.py +++ b/app/app.py @@ -1,68 +1,90 @@ -import logging -import os -from flask import Flask, render_template, Response, request, session -from main import yoink, process, user_streams, stream_lock -import uuid # Import UUID - -app = Flask(__name__, static_folder="website/static", template_folder="website") -app.secret_key = os.urandom(24) # Necessary for using sessions - - -# Configure logging -logging.basicConfig( - filename='./logs/app.log', - level=logging.DEBUG, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) - -def create_session(): - session_id = str(uuid.uuid4()) - # This should never happen but I'm putting the logic there anyways - try: - if user_streams[session_id]: - session_id = create_session() - except KeyError: - pass - return session_id - - -@app.route('/') -def home(): - session_id = create_session() - session['id'] = session_id - logging.info(f"Home page accessed. Assigned initial session ID: {session_id}") - return render_template('index.html', session_id=session_id) - -@app.route('/process_url', methods=['POST']) -def process_url(): - session_id = session.get('id') - if not session_id: - session_id = create_session() - session['id'] = session_id - logging.info(f"No existing session. Created new session ID: {session_id}") - - url = request.form['url'] - logging.info(f"Received URL for processing from session {session_id}: {url}") - success, msg, status_code, = process(url, session_id) - - if success: - logging.info(f"Processing started successfully for session {session_id}.") - return Response("Processing started. Check /stream_output for updates.", content_type='text/plain', status=200) - else: - logging.error(f"Processing failed for session {session_id}: {msg}") - return Response(msg, content_type='text/plain', status=status_code) - -@app.route('/stream_output') -def stream_output(): - session_id = session.get('id') - if not session_id or session_id not in user_streams: - logging.warning(f"Stream requested without a valid session ID: {session_id}") - return Response("No active stream for this session.", content_type='text/plain', status=400) - logging.info(f"Streaming output requested for session {session_id}.") - return Response(yoink(session_id), content_type='text/plain', status=200) - -if __name__ == '__main__': - logging.info("Starting Flask application.") - app.run(debug=True, threaded=True) # Enable threaded to handle multiple requests - \ No newline at end of file +import logging +import os +import uuid +from flask import Flask, render_template, Response, request, session +from main import yoink, process, user_streams, stream_lock + +app = Flask(__name__, static_folder="website/static", template_folder="website") +app.secret_key = os.urandom(24) # Necessary for using sessions + +# Configure logging +logging.basicConfig( + filename='./logs/app.log', + level=logging.DEBUG, + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +def create_session(): + """ + Create a new session by generating a UUID and ensuring it does not collide + with an existing session in the user_streams global dictionary. + + Returns: + str: A unique session ID. + """ + session_id = str(uuid.uuid4()) + # Even though collisions are unlikely, we check for safety. + try: + if user_streams[session_id]: + session_id = create_session() + except KeyError: + pass + return session_id + +@app.route('/') +def home(): + """ + Render the home page and initialize a session. + + Returns: + Response: The rendered home page with a unique session id. + """ + session_id = create_session() + session['id'] = session_id + logging.info(f"Home page accessed. Assigned initial session ID: {session_id}") + return render_template('index.html', session_id=session_id) + +@app.route('/process_url', methods=['POST']) +def process_url(): + """ + Accept a YouTube URL (from a form submission), initialize the session if necessary, + and trigger the transcript retrieval and AI processing. + + Returns: + Response: Text response indicating start or error message. + """ + session_id = session.get('id') + if not session_id: + session_id = create_session() + session['id'] = session_id + logging.info(f"No existing session. Created new session ID: {session_id}") + url = request.form['url'] + logging.info(f"Received URL for processing from session {session_id}: {url}") + success, msg, status_code = process(url, session_id) + if success: + logging.info(f"Processing started successfully for session {session_id}.") + return Response("Processing started. Check /stream_output for updates.", content_type='text/plain', status=200) + else: + logging.error(f"Processing failed for session {session_id}: {msg}") + return Response(msg, content_type='text/plain', status=status_code) + +@app.route('/stream_output') +def stream_output(): + """ + Stream the AI processing output for the current session. + + Returns: + Response: A streaming response with text/plain content. + """ + session_id = session.get('id') + if not session_id or session_id not in user_streams: + logging.warning(f"Stream requested without a valid session ID: {session_id}") + return Response("No active stream for this session.", content_type='text/plain', status=400) + logging.info(f"Streaming output requested for session {session_id}.") + return Response(yoink(session_id), content_type='text/plain', status=200) + +if __name__ == '__main__': + logging.info("Starting Flask application.") + # Running with threaded=True to handle multiple requests concurrently. + app.run(debug=True, threaded=True) \ No newline at end of file diff --git a/app/main.py b/app/main.py index f165799..2f10315 100644 --- a/app/main.py +++ b/app/main.py @@ -1,244 +1,331 @@ -import re -import threading -import asyncio -from asyncio import sleep -from typing_extensions import override -from datetime import datetime -import pytz -import os -import logging -import uuid - -# Youtube Transcript imports -import youtube_transcript_api._errors -from youtube_transcript_api import YouTubeTranscriptApi -from youtube_transcript_api.formatters import TextFormatter - -# OpenAI API imports -from openai import AssistantEventHandler -from openai import OpenAI - -# Load environment variables -from dotenv import load_dotenv -load_dotenv() - -# Initialize user stream dictionary -user_streams = {} - -# Threading lock for thread safe stuff I think, idk it was used in the docs -stream_lock = threading.Lock() - -# Handle async outside of async functions -awaiter = asyncio.run - -# Configure logging -try: - logging.basicConfig( - filename='./logs/main.log', - level=logging.INFO, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) -except FileNotFoundError as e: - with open("./logs/main.log", "x"): - pass - logging.basicConfig( - filename='./logs/main.log', - level=logging.INFO, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - logging.info(f"No main.log file was found ({e}), so one was created.") - - -# The StreamOutput class to handle streaming -class StreamOutput: - def __init__(self): - self.delta: str = "" - self.response: str = "" - self.done: bool = False - self.buffer: list = [] - - def reset(self): - self.delta = "" - self.response = "" - self.done = False - self.buffer = [] - - def send_delta(self, delta): - awaiter(self.process_delta(delta)) - - async def process_delta(self, delta): - self.delta = delta - self.response += delta - - def get_index(lst): - if len(lst) == 0: - return 0 - else: - return len(lst) - 1 - - if self.buffer: - try: - if self.delta != self.buffer[get_index(self.buffer)]: - self.buffer.append(delta) - except IndexError as index_error: - logging.error(f"Caught IndexError: {str(index_error)}") - self.buffer.append(delta) - else: - self.buffer.append(delta) - return - -# OpenAI Config - -# Setting up OpenAI Client with API Key -client = OpenAI( - organization='org-7ANUFsqOVIXLLNju8Rvmxu3h', - project="proj_NGz8Kux8CSka7DRJucAlDCz6", - api_key=os.getenv("OPENAI_API_KEY") -) - -# Screw Bardo Assistant ID -asst_screw_bardo_id = "asst_JGFaX6uOIotqy5mIJnu3Yyp7" - -# Event Handler for OpenAI Assistant -class EventHandler(AssistantEventHandler): - - def __init__(self, output_stream: StreamOutput): - super().__init__() - self.output_stream = output_stream - - @override - def on_text_created(self, text) -> None: - self.output_stream.send_delta("Response Received:\n\nScrew-Bardo:\n\n") - logging.info("Text created event handled.") - - @override - def on_text_delta(self, delta, snapshot): - self.output_stream.send_delta(delta.value) - logging.debug(f"Text delta received: {delta.value}") - - def on_tool_call_created(self, tool_call): - error_msg = "Assistant shouldn't be calling tools." - logging.error(error_msg) - raise Exception(error_msg) - -def create_and_stream(transcript, session_id): - logging.info(f"Starting OpenAI stream thread for session {session_id}.") - event_handler = EventHandler(user_streams[session_id]['output_stream']) - try: - with client.beta.threads.create_and_run_stream( - assistant_id=asst_screw_bardo_id, - thread={ - "messages": [{"role": "user", "content": transcript}] - }, - event_handler=event_handler - ) as stream: - stream.until_done() - with stream_lock: - user_streams[session_id]['output_stream'].done = True - logging.info(f"OpenAI stream completed for session {session_id}.") - except Exception as e: - logging.exception(f"Exception occurred during create_and_stream for session {session_id}.") - -def yoink(session_id): - logging.info(f"Starting stream for session {session_id}...") - with stream_lock: - user_data = user_streams.get(session_id) - if not user_data: - logging.critical(f"User data not found for session id {session_id}?") - return # Session might have ended - output_stream: StreamOutput = user_data.get('output_stream') - thread: threading.Thread = user_data.get('thread') - thread.start() - while True: - - if not output_stream or not thread: - logging.error(f"No output stream/thread for session {session_id}.\nThread: {thread.name if thread else "None"}") - break - - if output_stream.done and not output_stream.buffer: - break - - try: - if output_stream.buffer: - delta = output_stream.buffer.pop(0) - yield bytes(delta, encoding="utf-8") - else: - asyncio.run(sleep(0.018)) - except Exception as e: - logging.exception(f"Exception occurred during streaming for session {session_id}: {e}") - break - - logging.info(f"Stream completed successfully for session {session_id}.") - logging.info(f"Completed Assistant Response for session {session_id}:\n{output_stream.response}") - with stream_lock: - thread.join() - del user_streams[session_id] - logging.info(f"Stream thread joined and resources cleaned up for session {session_id}.") - -def process(url, session_id): - # Should initialize the key in the dictionary - current_time = datetime.now(pytz.timezone('America/New_York')).strftime('%Y-%m-%d %H:%M:%S') - logging.info(f"New Entry at {current_time} for session {session_id}") - logging.info(f"URL: {url}") - - video_id = get_video_id(url) - if not video_id: - logging.warning(f"Could not parse video id from URL: {url}") - return (False, "Couldn't parse video ID from URL. (Are you sure you entered a valid YouTube.com or YouTu.be URL?)", 400) - logging.info(f"Parsed Video ID: {video_id}") - - # Get the transcript for that video ID - transcript = get_auto_transcript(video_id) - if not transcript: - logging.error(f"Error: could not retrieve transcript for session {session_id}. Assistant won't be called.") - return (False, "Successfully parsed video ID from URL, however the ID was either invalid, the transcript was disabled by the video owner, or some other error was raised because of YouTube.", 200) - user_streams[session_id] = { - 'output_stream': None, # Ensure output_stream is per user - 'thread': None - } - # Create a new StreamOutput for the session - with stream_lock: - user_streams[session_id]['output_stream'] = StreamOutput() - thread = threading.Thread( - name=f"create_stream_{session_id}", - target=create_and_stream, - args=(transcript, session_id) - ) - user_streams[session_id]['thread'] = thread - logging.info(f"Stream preparation complete for session {session_id}, sending reply.") - return (True, None, None) - -def get_video_id(url): - youtu_be = r'(?<=youtu.be/)([A-Za-z0-9_-]{11})' - youtube_com = r'(?<=youtube\.com\/watch\?v=)([A-Za-z0-9_-]{11})' - - id_match = re.search(youtu_be, url) - if not id_match: - id_match = re.search(youtube_com, url) - - if not id_match: - # Couldn't parse video ID from URL - logging.warning(f"Failed to parse video ID from URL: {url}") - return None - - return id_match.group(1) - -def get_auto_transcript(video_id): - trans_api_errors = youtube_transcript_api._errors - try: - transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=['en'], proxies=None, cookies=None, preserve_formatting=False) - except trans_api_errors.TranscriptsDisabled as e: - logging.exception(f"Exception while fetching transcript: {e}") - return None - - formatter = TextFormatter() # Ensure that you create an instance of TextFormatter - txt_transcript = formatter.format_transcript(transcript) - logging.info("Transcript successfully retrieved and formatted.") - return txt_transcript - -# Initialize output stream -output_stream = StreamOutput() - -logging.info(f"Main initialized at {datetime.now(pytz.timezone('America/New_York')).strftime('%Y-%m-%d %H:%M:%S')}. Presumably application starting.") \ No newline at end of file +""" +Main module that handles processing of YouTube transcripts and connecting to the AI service. +Each user session has its own output stream and thread to handle the asynchronous AI response. +""" + +import re +import threading +import asyncio +from asyncio import sleep +from datetime import datetime +import pytz +import os +import logging +import uuid + +# Youtube Transcript imports +import youtube_transcript_api._errors +from youtube_transcript_api import YouTubeTranscriptApi +from youtube_transcript_api.formatters import TextFormatter + +# OpenAI API imports +from openai import AssistantEventHandler +from openai import OpenAI + +from dotenv import load_dotenv +load_dotenv() + +# Global dict for per-user session streams. +user_streams = {} +# Lock to ensure thread-safe operations on shared memory. +stream_lock = threading.Lock() + +# For running async code in non-async functions. +awaiter = asyncio.run + +# Configure logging +try: + logging.basicConfig( + filename='./logs/main.log', + level=logging.INFO, + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) +except FileNotFoundError as e: + with open("./logs/main.log", "x"): + pass + logging.basicConfig( + filename='./logs/main.log', + level=logging.INFO, + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + logging.info(f"No main.log file was found ({e}), so one was created.") + +class StreamOutput: + """ + Class to encapsulate a session's streaming output. + + Attributes: + delta (str): Last delta update. + response (str): Cumulative response from the AI. + done (bool): Flag indicating if streaming is complete. + buffer (list): List of output delta strings pending streaming. + """ + def __init__(self): + self.delta: str = "" + self.response: str = "" + self.done: bool = False + self.buffer: list = [] + + def reset(self): + """ + Reset the stream output to its initial state. + """ + self.delta = "" + self.response = "" + self.done = False + self.buffer = [] + + def send_delta(self, delta): + """ + Process a new delta string. This method is a synchronous wrapper that calls the async + method process_delta. + + Args: + delta (str): The delta string to process. + """ + awaiter(self.process_delta(delta)) + + async def process_delta(self, delta): + """ + Process a new delta chunk asynchronously to update buffering. + + Args: + delta (str): The delta portion of the response. + """ + self.delta = delta + self.response += delta + + def get_index(lst): + return 0 if not lst else len(lst) - 1 + + if self.buffer: + try: + if self.delta != self.buffer[get_index(self.buffer)]: + self.buffer.append(delta) + except IndexError as index_error: + logging.error(f"Caught IndexError: {str(index_error)}") + self.buffer.append(delta) + else: + self.buffer.append(delta) + return + +# OpenAI Client configuration +client = OpenAI( + organization='org-7ANUFsqOVIXLLNju8Rvmxu3h', + project="proj_NGz8Kux8CSka7DRJucAlDCz6", + api_key=os.getenv("OPENAI_API_KEY") +) + +asst_screw_bardo_id = "asst_JGFaX6uOIotqy5mIJnu3Yyp7" # Assistant ID for processing + +class EventHandler(AssistantEventHandler): + """ + Event handler for processing OpenAI assistant events. + + Attributes: + output_stream (StreamOutput): The output stream to write updates to. + """ + def __init__(self, output_stream: StreamOutput): + """ + Initialize the event handler with a specific output stream. + + Args: + output_stream (StreamOutput): The session specific stream output instance. + """ + super().__init__() + self.output_stream = output_stream + + def on_text_created(self, text) -> None: + """ + Event triggered when text is first created. + + Args: + text (str): The initial response text. + """ + self.output_stream.send_delta("Response Received:\n\nScrew-Bardo:\n\n") + logging.info("Text created event handled.") + + def on_text_delta(self, delta, snapshot): + """ + Event triggered when a new text delta is available. + + Args: + delta (Any): Object that contains the new delta information. + snapshot (Any): A snapshot of the current output (if applicable). + """ + self.output_stream.send_delta(delta.value) + logging.debug(f"Text delta received: {delta.value}") + + def on_tool_call_created(self, tool_call): + """ + Handle the case when the assistant attempts to call a tool. + Raises an exception as this behavior is unexpected. + + Args: + tool_call (Any): The tool call info. + + Raises: + Exception: Always, since tool calls are not allowed. + """ + error_msg = "Assistant shouldn't be calling tools." + logging.error(error_msg) + raise Exception(error_msg) + +def create_and_stream(transcript, session_id): + """ + Create a new thread that runs the OpenAI stream for a given session and transcript. + + Args: + transcript (str): The transcript from the YouTube video. + session_id (str): The unique session identifier. + """ + logging.info(f"Starting OpenAI stream thread for session {session_id}.") + event_handler = EventHandler(user_streams[session_id]['output_stream']) + try: + with client.beta.threads.create_and_run_stream( + assistant_id=asst_screw_bardo_id, + thread={ + "messages": [{"role": "user", "content": transcript}] + }, + event_handler=event_handler + ) as stream: + stream.until_done() + with stream_lock: + user_streams[session_id]['output_stream'].done = True + logging.info(f"OpenAI stream completed for session {session_id}.") + except Exception as e: + logging.exception(f"Exception occurred during create_and_stream for session {session_id}.") + +def yoink(session_id): + """ + Generator that yields streaming output for a session. + + This function starts the AI response thread, then continuously yields data from the session's output buffer + until the response is marked as done. + + Args: + session_id (str): The unique session identifier. + + Yields: + bytes: Chunks of the AI generated response. + """ + logging.info(f"Starting stream for session {session_id}...") + with stream_lock: + user_data = user_streams.get(session_id) + if not user_data: + logging.critical(f"User data not found for session id {session_id}?") + return + output_stream: StreamOutput = user_data.get('output_stream') + thread: threading.Thread = user_data.get('thread') + thread.start() + while True: + if not output_stream or not thread: + logging.error(f"No output stream/thread for session {session_id}.") + break + # Stop streaming when done and there is no pending buffered output. + if output_stream.done and not output_stream.buffer: + break + try: + if output_stream.buffer: + delta = output_stream.buffer.pop(0) + yield bytes(delta, encoding="utf-8") + else: + # A short sleep before looping again + asyncio.run(sleep(0.018)) + except Exception as e: + logging.exception(f"Exception occurred during streaming for session {session_id}: {e}") + break + logging.info(f"Stream completed successfully for session {session_id}.") + logging.info(f"Completed Assistant Response for session {session_id}:\n{output_stream.response}") + with stream_lock: + thread.join() + # Clean up the session data once done. + del user_streams[session_id] + logging.info(f"Stream thread joined and resources cleaned up for session {session_id}.") + +def process(url, session_id): + """ + Process a YouTube URL: parse the video id, retrieve its transcript, and prepare the session for AI processing. + + Args: + url (str): The YouTube URL provided by the user. + session_id (str): The unique session identifier. + + Returns: + tuple: (success (bool), message (str or None), status_code (int or None)) + """ + current_time = datetime.now(pytz.timezone('America/New_York')).strftime('%Y-%m-%d %H:%M:%S') + logging.info(f"New Entry at {current_time} for session {session_id}") + logging.info(f"URL: {url}") + video_id = get_video_id(url) + if not video_id: + logging.warning(f"Could not parse video id from URL: {url}") + return (False, "Couldn't parse video ID from URL. (Are you sure you entered a valid YouTube.com or YouTu.be URL?)", 400) + logging.info(f"Parsed Video ID: {video_id}") + transcript = get_auto_transcript(video_id) + if not transcript: + logging.error(f"Error: could not retrieve transcript for session {session_id}. Assistant won't be called.") + return (False, "Successfully parsed video ID from URL, however the transcript was disabled by the video owner or invalid.", 200) + + # Initialize session data for streaming. + user_streams[session_id] = { + 'output_stream': None, + 'thread': None + } + with stream_lock: + user_streams[session_id]['output_stream'] = StreamOutput() + thread = threading.Thread( + name=f"create_stream_{session_id}", + target=create_and_stream, + args=(transcript, session_id) + ) + user_streams[session_id]['thread'] = thread + logging.info(f"Stream preparation complete for session {session_id}, sending reply.") + return (True, None, None) + +def get_video_id(url): + """ + Extract the YouTube video ID from a URL. + + Args: + url (str): The YouTube URL. + + Returns: + str or None: The video ID if found, otherwise None. + """ + youtu_be = r'(?<=youtu.be/)([A-Za-z0-9_-]{11})' + youtube_com = r'(?<=youtube\.com\/watch\?v=)([A-Za-z0-9_-]{11})' + id_match = re.search(youtu_be, url) + if not id_match: + id_match = re.search(youtube_com, url) + if not id_match: + logging.warning(f"Failed to parse video ID from URL: {url}") + return None + return id_match.group(1) + +def get_auto_transcript(video_id): + """ + Retrieve and format the transcript from a YouTube video. + + Args: + video_id (str): The YouTube video identifier. + + Returns: + str or None: The formatted transcript if successful; otherwise None. + """ + trans_api_errors = youtube_transcript_api._errors + try: + transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=['en'], proxies=None, cookies=None, preserve_formatting=False) + except trans_api_errors.TranscriptsDisabled as e: + logging.exception(f"Exception while fetching transcript: {e}") + return None + formatter = TextFormatter() + txt_transcript = formatter.format_transcript(transcript) + logging.info("Transcript successfully retrieved and formatted.") + return txt_transcript + +# Initialize a global output_stream just for main module logging (not used for per-session streaming). +output_stream = StreamOutput() +logging.info(f"Main initialized at {datetime.now(pytz.timezone('America/New_York')).strftime('%Y-%m-%d %H:%M:%S')}. Application starting.") \ No newline at end of file diff --git a/app/start.sh b/app/start.sh new file mode 100644 index 0000000..44ebc81 --- /dev/null +++ b/app/start.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec gunicorn -b 0.0.0.0:1986 -w 4 --thread 2 --log-level debug app:app --timeout 120 --worker-class gthread --access-logfile - --error-logfile - --capture-output \ No newline at end of file diff --git a/app/website/index.html b/app/website/index.html index fd97afb..7f3185a 100644 --- a/app/website/index.html +++ b/app/website/index.html @@ -7,19 +7,23 @@
Response will appear here.+ +
Response will appear here.