Python Skills Resources (8 URLs) Video Source 1: FastAPI Video 1: Build lightweight, production-ready APIs 1 Documentation - FastAPI Tutorial: https://fastapi.tiangolo.com/tutorial/ Video Source 2: Async Programming: Speed up agents by handling multiple tasks asynchronously Video Source 3: Pydantic: Data validation and settings management Video Source 4: Logging: Debugging agents with good logs Video Source 5: Testing: Unit test Video Source 6: Testing: Integration test Video Source 7: Database Management (SQLAlchemy + Alembic) RAG (Retrieval-Augmented Generation) Skills (8 URLs) Video Source 8: Understanding RAG: Learn what RAG is Video Source 9: Text Embeddings: Foundation of search and retrieval Video Source 10: Vector Database: Store and retrieve embeddings Video Source 11: Chunking Strategies: Split data smartly Video Source 12: RAG with PostgreSQL: RAG without expensive vector DBs Video Source 13: RAG with LangChain: Chain together retrieval, LLMs, and prompts Video Source 14: RAG Evaluations: Know if your retrieval and answers are good Video Source 15: Advanced RAG Techniques: Fine-tune your RAG system AI Agent Framework & Tools (3 URLs) Video Source 16: LangGraph: Used in organisations to build production-ready AI Agents Video Source 17: Prompt engineering guide: Good to follow specific guides from LLM providers Guide: https://www.promptingguide.ai/ Video Source 18: LangFuse: AI Agent Monitoring Curated by: Shantanu Ladhwe FastAPI Basics FastAPI is a web framework designed specifically for building API apps using Python. To run your server, command should look like this: type "uvicorn" and then the name of your file, which should be "main", and then the name of your "app". And then you can use this "--reload" flag to make the server automatically refresh anytime you make changes to the file. uvicorn main:app --reload It's async by default FastAPI also supports "Pydantic" models Routes are going to be the different URLs that your app should respond to. You can create routes to handle different interactions. Users can access an endpoint by sending an HTTP POST request to a specific path, and it's going to accept input. This input can be a query parameter (like sending the item by using a query parameter at the end of the URL) or later, as a JSON payload . path parameters , like /items/{item_id} inside curly brackets. Whatever you put here as this index (e.g., 1 in /items/1 ) will become the variable (e.g., itemId ) in function, and then you can use that as a parameter. Pydantic models ( BaseModel ) allow you to structure your data and also provide additional validation. You can extend BaseModel to create classes for your data objects (like an Item with text and is_done attributes). Response Models We can model the response data using the same base model from Pydantic. All we have to do is add a new argument to the decorator called response_model . we just put the Pydantic class that you'd like returned from this response. This tells our server and our interfaces that the response from this endpoint would be conforming to this model. Exceptions For situations where an item doesn't exist, that's usually a client error. There's a universal set of HTTP response codes that you can use and everybody will understand. The 404 Not Found code is probably the way we'd want to respond in this situation. To do that, import HTTPException from FastAPI. In your handler, modify it to have a condition checking whether or not the item exists, and if it does, return it. Otherwise, you raise this HTTPException . You put the 404 status code in, and you can even use this detail parameter to give more information about why it wasn't found. When you use a modeled object like a Pydantic Item model as part of the argument in a POST request, it's going to expect that to be in the JSON payload of the request. Documentations Whenever you start a FastAPI server, you get a documentation page for free that you can actually interact with and use to test your API. if you go back to your local FastAPI server and then add this /docs to the end of your URL, you'll get taken to this Swagger UI page where you get to see all of your endpoints. You get to see which HTTP method they accept. And if you click into them, you can also look at the type of parameters they take. Asyncio Asyncio is about asynchronous programming in Python. Traditional synchronous programming is like moving step-by-step, waiting at each stop. Asynchronous programming lets me start multiple tasks even if others are waiting, like sending out multiple scouts at once instead of waiting for the first one to return. This makes code more efficient, especially when dealing with operations that involve waiting, such as loading a web page. It's about doing multiple things concurrently without unnecessary waiting. When to Use Asyncio: Best for tasks that wait a lot , like network requests or reading files. Excels at handling many tasks concurrently with low CPU usage, making applications more efficient and responsive when waiting. Choosing between Asyncio, threads, or processes depends on the concurrency model needed. Threads are suited for tasks that might wait but need to share data. They run in parallel within the same application and are useful for IO-bound tasks that are not CPU-intensive. Processes are for CPU-heavy tasks . Each process operates independently across multiple cores to maximize CPU usage, ideal for intensive computations. Five Key Concepts in Asyncio: 1. Event Loop: The core of Asyncio. It manages and distributes tasks . Tasks circle around it, waiting to execute. When a task awaits something (like waiting for data), it steps aside so another task can run, ensuring the loop is efficiently utilized. The loop resumes tasks once their awaited operations are complete, helping maintain a smooth and responsive program flow. use asyncio.run () to start the event loop by running a co-routine. It waits for that main co-routine to finish. Coroutines: Defined using the async def keyword. When call a coroutine function (e.g., main() ), it doesn't run the code inside immediately; it returns a coroutine object . The co-routine object needs to be awaited for its code to actually execute. import asyncio to write async code. pass the initial coroutine object to asyncio.run () to start the program and the event loop. Awaiting co-routines sequentially means one must finish before the next starts, which doesn't give performance benefits for concurrent IO. A coroutine doesn't start executing until it's awaited or wrapped in something like a task. 3. Tasks: A way to schedule a co-routine to run as soon as possible. Allows to run multiple co-routines concurrently . program switches between tasks when one is idle, blocked, or waiting (e.g., on a sleep ). The goal is to keep the program efficiently busy. Tasks don't run on multiple CPU cores simultaneously; the program switches between them when one is waiting on something not in control of the program. Ways to create and run tasks: asyncio.create_task(coroutine_object) : The simplest way to schedule a single co-routine to run concurrently. I still need to await the task later if I need its result or confirmation of completion. asyncio.gather(*coroutine_objects) : A quick way to run multiple co-routines concurrently . It takes co-routine objects, automatically schedules them, and collects results in a list in the order provided. Note: It's not great at error handling; an error in one task won't automatically cancel others. asyncio.TaskGroup : The preferred way for managing multiple tasks. It uses an asynchronous context manager : async with asyncio.TaskGroup() as tg: . I create tasks inside the block using tg.create_task(coroutine_object) . It provides built-in error handling . If any task fails, it automatically cancels all other tasks in the group. All tasks inside the group are executed, and the async with block completes only when all tasks in the group are finished. 4. Future: A more low-level concept , usually encountered in libraries I might use. Represents a promise of a future result . Awaiting a future waits for a specific value to become available. It doesn't necessarily mean the entire task or co-routine has finished. 5. Synchronization Primitives: Tools for synchronizing the execution of co-routines, useful in larger, more complicated programs. Lock: Ensures exclusive access to a shared resource (like a database or file). Only one coroutine can hold the lock and access the critical code block at a time. Prevents issues from simultaneous modifications. Acquired using async with lock: where lock is an asyncio.Lock() . The code inside this block is protected. The lock is held until the code within the async with block finishes, even if it involves await . This synchronizes co-routines so they can't execute the protected block while another is using it. Semaphore: Similar to a lock, but allows a limited number of co-routines to access a resource concurrently. Created with asyncio.Semaphore(value) , where value is the maximum allowed concurrent access. Used to throttle operations, preventing overloading a resource by limiting the number of concurrent requests (e e.g., limiting concurrent network requests). Access is acquired using async with semaphore: . Event: A simple synchronization tool acting like a boolean flag . A co-routine can await event.wait() to block until the event is set. Another co-routine can event.set() to unblock anything waiting. Allows waiting at a specific point until a condition (the event being set) is met. Pydantic This guide summarizes how to use the Pydantic module in Python for data validation and serialization, and compares it with Python's built-in dataclasses module. Why Use Pydantic? Python’s dynamic typing is flexible but can cause issues in larger applications: Harder to track variable types Ambiguous function arguments Increased likelihood of runtime errors Example: x = 10 x = "hello" In large codebases, this can lead to unexpected behavior and make debugging difficult. What is Pydantic? Pydantic is a data validation library designed to bring type safety and structured validation to Python. It is used in popular libraries such as: FastAPI LangChain HuggingFace Key Features: Data Validation : Ensures inputs meet defined constraints JSON Serialization/Deserialization : Easily convert models to and from JSON IDE Support : Improved type hinting and autocompletion Integration : Works seamlessly with modern Python frameworks Creating a Pydantic Model Define a model by creating a class that inherits from BaseModel : from pydantic import BaseModel class User(BaseModel): name: str email: str account_id: int Custom Data Validation with @validator Add custom logic using the @validator decorator: from pydantic import BaseModel, validator class User(BaseModel): name: str email: str account_id: int @validator("account_id") def account_id_must_be_positive(cls, value): if value <= 0: raise ValueError("Account ID must be positive") return value JSON Serialization and Deserialization Serialize to JSON: user = User(name="John Doe", email="john.doe@example.com", account_id=123) json_string = user.json() print(json_string) Deserialize from JSON: new_user = User.parse_raw(json_string) print(new_user) Convert to Dictionary: dict_data = user.dict() print(dict_data) Comparison: Pydantic vs. Dataclasses When to Use What Modern Python logging Python Logging: A Comprehensive Guide This guide provides structured notes based on a video tutorial about Python logging, covering various aspects from basic configuration to advanced techniques for handling large-scale applications. Basic Logging Concepts Log Records: A log record contains contextual information like the message, severity level, timestamp, thread ID, location, and source code. Loggers: Loggers are the entry points for logging messages. They determine the severity level of messages to be processed. Messages below the logger's set level are dropped. Handlers: Handlers receive log records from loggers and output them to different destinations (stdout, files, email, etc.). They can also filter messages based on level. Formatters: Formatters convert log records (which are Python objects) into strings for output. This allows customization of the message format (e.g., including level, timestamp, message). Logger Hierarchy and Propagation Root Logger: The root logger is the top-level logger in a tree-like structure. All other loggers are its children. Child Loggers: Loggers can be created with names (e.g., A.X ). A logger named A.X is a child of logger A . Propagation: By default, log records generated by a child logger are propagated up to its parent loggers. Handlers attached to parent loggers will also process these records. Disabling Propagation: Propagation can be disabled to prevent messages from reaching parent loggers. This provides more flexibility but can lead to complex configurations if not handled carefully. The recommendation is to avoid non-root handlers and place all handlers on the root logger for simplicity. Configuring Logging logging.getLogger(name) : Use this to get a logger instance. If the logger doesn't exist, it's created. Best Practices: For small to medium applications, a single non-root logger might suffice. For larger applications, consider one non-root logger per subcomponent. Avoid creating a logger for every file. dictConfig : This is a convenient way to configure logging using a dictionary. It's easier to understand and manage than other methods. Using dictConfig Basic Configuration: A simple dictConfig setup can be concise and readable. The example shows a basic configuration logging to stdout. version : This field in the config dictionary specifies the version of the logging configuration. It's crucial for backward compatibility. disable_existing_loggers : Setting this to False (the default) allows logging messages from third-party libraries. Formatters: Formatters define the output string format using a format string (similar to printf ). The documentation lists available variables. Handlers: Handlers specify the output destination (e.g., StreamHandler for stdout/stderr, RotatingFileHandler for files). Advanced Configuration with JSON or YAML External Configuration Files: Storing logging configuration in JSON or YAML files is recommended for larger applications. This allows easy modification without changing the application code. JSON: Python's built-in json module makes JSON configuration straightforward. YAML: YAML offers a more concise syntax but requires an external library ( PyYAML ). JSON Logging and Custom Filters JSON Lines (JSONL): Outputting each log record as a separate JSON line ( .jsonl ) makes parsing easier. extra Argument: The extra argument in logging calls allows adding custom key-value pairs to log records. Custom Formatters: Formatters can be customized to include data from the extra dictionary. Custom Filters: Custom filters allow more complex filtering logic beyond just log levels. A filter function receives a log record and returns True to accept it or False to reject it. Handling High-Volume Logs and Asynchronous Logging Performance Considerations: Logging can impact performance, especially in high-volume scenarios. Avoid logging within performance-critical sections. QueueHandler : The QueueHandler allows asynchronous logging. It stores log records in a queue, preventing blocking of the main thread. A separate listener thread processes the queue and sends the records to handlers. Advanced QueueHandler Configuration and Best Practices QueueHandler Configuration: The QueueHandler can be configured to handle multiple handlers. respect_handler_level : Setting this to True ensures that the handler's level is respected when processing from the queue. Starting the Listener Thread: The listener thread needs to be started manually. An atexit callback can be used to stop the thread gracefully when the program exits. Best Practices: Applications should configure logging, not libraries. Libraries should only use logging to record important events. Security Considerations and Conclusion Security Vulnerabilities: The video highlights the importance of secure logging practices, referencing the Log4j vulnerability as an example. Avoid logging sensitive user input directly. makeLogRecord : This function allows manual creation of log records. However, it should be used cautiously to avoid security risks. Key Takeaways: Python's logging module is powerful and flexible, allowing for a wide range of customization. Proper configuration is crucial for managing log output effectively, especially in large applications. Asynchronous logging techniques are essential for high-volume applications to avoid performance bottlenecks. Security is paramount; avoid logging sensitive data and be mindful of potential vulnerabilities. This guide provides a structured overview of the video's content, focusing on key concepts and best practices for Python logging. Remember to consult the official Python logging documentation for more detailed information. Modern Python logging Python Logging: A Comprehensive Guide This guide provides structured notes based on a video tutorial about Python logging. We'll cover setting up loggers, handlers, formatters, and filters for effective log management. Introduction to Logging Log Records: A log record contains contextual information like the message, severity, time, thread, location, and source code. Loggers: Loggers set the level (e.g., DEBUG, INFO, WARNING, ERROR, CRITICAL) and filter messages below that level. They pass log records to handlers. Handlers: Handlers send log records to various destinations (stdout, files, email, commercial services). They also have their own level filters. Formatters: Formatters convert log record objects into strings, allowing customization of individual messages (e.g., including level, timestamp, and message). Loggers and Handlers: Structure and Best Practices Logger Hierarchy: Loggers form a tree structure. A logger named A.X is a child of logger A . Child loggers propagate log records to their parents. Propagation: If a log record is generated by A.X , the handlers of A.X run first. If propagation is enabled, the record is then passed to A , and so on up the hierarchy. Best Practices: Avoid non-root handlers – place all handlers on the root logger for simplicity. This ensures that messages from third-party libraries are also logged. Use filters on the root logger and its handlers, but let messages propagate to the root. Configuring Logging with dictConfig dictConfig : This method simplifies logging configuration. It uses a dictionary to specify settings. Basic Configuration: A simple configuration might log to stdout. Structure: The configuration dictionary typically includes: version : (required, value 1) disable_existing_loggers : (set to False to log messages from third-party code) formatters : Defines the format of log messages. handlers : Specifies the handlers (e.g., StreamHandler for stdout/stderr, RotatingFileHandler for files). loggers : Configures loggers and their handlers. root : Configures the root logger. Configuration Examples and Best Practices Simple Configuration: A basic configuration can be concise, but a more verbose style can improve clarity. Storing Configuration: Keep the logging configuration as a literal dictionary in your Python source, or in a separate file (JSON or YAML). JSON Configuration: JSON is preferred due to its built-in support in Python, avoiding external dependencies. Advanced Configuration and JSON Logging JSON Configuration: Using JSON allows for easy loading and modification of the configuration. YAML Configuration: YAML is an alternative, but requires an external library ( pyyaml ). Rotating File Handler: The RotatingFileHandler allows for log file rotation based on size or time. JSON Lines (JSONL): Format each log message as a separate JSON object in the output file ( .jsonl extension) for easier parsing. Custom Formatters and Filters Custom Formatters: Extend the built-in formatter to include additional contextual information (e.g., using the extra argument in log calls). Custom Filters: Create custom filters to control which log records are processed (e.g., filtering out duplicate messages or censoring sensitive data). A filter is a function that takes a log record and returns True to keep it or False to drop it. QueueHandler for Asynchronous Logging Asynchronous Logging: For high-performance applications, use QueueHandler to avoid blocking the main thread. This collects log data in a queue and processes it in a separate thread. QueueHandler Configuration: The QueueHandler takes a list of handlers to which it dispatches messages. The respect_handler_level parameter controls whether the handler's level is respected. Best Practices and Security Considerations Library vs. Application Logging: Libraries should not configure logging; they should only use the logging module to create loggers and log messages. Applications should handle the configuration. Security: Avoid logging user input directly. Be mindful of potential security vulnerabilities (like the Log4j vulnerability). Conclusion This guide covered various aspects of Python logging, from basic setup to advanced asynchronous logging and security considerations. Effective logging is crucial for application monitoring, debugging, and security. Remember to choose the approach that best suits your application's needs and scale. Using dictConfig with JSON or YAML for configuration provides flexibility and maintainability. For high-performance applications, consider using QueueHandler for asynchronous logging. Always prioritize security and avoid logging sensitive information directly. Absolutely. Here's a clean and professional version of your content on "Understanding Embeddings in AI" , organized for clarity and without any emojis: Understanding Embeddings in AI This guide summarizes key insights from a video explaining embeddings in AI — a core concept that enables machines to understand and organize complex data such as text, images, and videos using numerical vectors. What Are Embeddings? Simple Definition: Embeddings represent data (text, images, videos, etc.) as vectors of numbers . These vectors encode semantic attributes of the data. How It Works: Think of a vector for a "cat" and another for a "dog". Both might have high values for attributes like "legs" and "fur". A "house" vector, on the other hand, would have high values for "roof" and "walls", and low values for "legs". The more similar two data points are, the closer their vectors are in high-dimensional space. Key Concepts: Dimensions : Vectors often contain thousands of numbers, each representing a different attribute. These are generated by embedding models or APIs. Deterministic vs. Non-deterministic : Deterministic embedding models return the same output for the same input. Non-deterministic models (like Gemini) might produce slightly different outputs for the same input. Multimodal Embeddings : These allow different data types (e.g., images and text) to be mapped into a common vector space , making it possible to compare them semantically. Using Embeddings in Practice Real-world Applications: Embeddings are used for: Search and recommendation systems Natural language understanding Clustering and classification Semantic similarity comparisons Database Integration (e.g., PGvector): PGvector is a PostgreSQL extension that stores and indexes vector embeddings. This enables efficient similarity search using simple SQL queries within a database. Python Integration: The video references Python code for generating and using embeddings. These likely use libraries that interface with embedding models and databases. Summary Table: Deterministic vs. Non-deterministic Models