diff --git a/backend/python/README.md b/backend/python/README.md index 1522f5e23..cafc0367c 100644 --- a/backend/python/README.md +++ b/backend/python/README.md @@ -1,13 +1,697 @@ -# Interneers Lab - Backend in Python +# WEEK 1 + +### What I built + +A **GET endpoint** that returns a greeting message using a query parameter: + +- `GET /hello-world/` → `{"message": "Hello, World!"}` +- `GET /hello-world/?name=Kreesh` → `{"message": "Hello, Kreesh!"}` + +### Hexagonal architecture overview + +The implementation is organized into clear layers to keep business logic independent of Django: + +- **domain/**: Pure business logic (e.g., formatting/validating the greeting). No Django imports. +- **application/**: Use-case layer that coordinates the feature (calls domain and returns a result). +- **ports/**: Contract boundary for the use-case (defines what the core exposes). +- **adapters/api/**: Django HTTP layer (views + urls) that translates HTTP requests into application calls and returns JSON. + +--- + +# WEEK 2 + +### What I built + +In Week 2, I built a **Product CRUD API** using **Django REST Framework (DRF)**. + +This API supports: + +- **Create** a product +- **List** all products +- **Get** one product by ID +- **Update** a product by ID +- **Delete** a product by ID + +Current endpoints: + +- `GET /week2/products/` +- `POST /week2/products/` +- `GET /week2/products//` +- `PUT /week2/products//` +- `DELETE /week2/products//` + +Important note: for Week 2, the implementation uses **in-memory storage**, so products are stored in a Python dictionary while the server is running. Data is **not persisted** to the database yet. + +## Quick theory: what is CRUD? + +CRUD stands for: + +- **Create** +- **Read** +- **Update** +- **Delete** + +In this project: + +- `POST /week2/products/` → Create +- `GET /week2/products/` → Read all +- `GET /week2/products//` → Read one +- `PUT /week2/products//` → Update +- `DELETE /week2/products//` → Delete + +--- + +## Week 2 architecture overview + +The Week 2 implementation is split into small layers so each file has one responsibility. + +### `models.py` + +Defines the `Product` object structure. + +This is a **plain Python class**, not a Django ORM model in this week’s version. + +Responsibilities: + +- define product fields +- hold product data +- convert object into dictionary using `to_dict()` + +### `store.py` + +Acts as a **temporary in-memory database**. + +Responsibilities: + +- store products in a dictionary +- generate product IDs +- create/get/list/update/delete products + +### `serializers.py` + +Handles validation and API data structure. + +Responsibilities: + +- validate incoming request data +- convert incoming data into cleaned Python data +- define the API shape for Product + +### `views.py` + +Handles HTTP requests and responses. + +Responsibilities: + +- receive request +- call serializer +- call store +- return DRF `Response` + +### `urls.py` + +Connects API endpoints to views. + +### `django_app/urls.py` + +Includes `week2.urls` under `/week2/`. + +--- + +## Architecture flow + +```text +Client (Browser / Postman / Frontend) + ↓ +HTTP Request + ↓ +request.data = { + "name": "Wireless Mouse", + "description": "2.4 GHz ergonomic mouse", + "category": "Electronics", + "price": "799.00", + "brand": "Logitech", + "quantity": 25 +} + ↓ +django_app/urls.py + ↓ +week2/urls.py + ↓ +views.py + ↓ +serializers.py + ↓ +serializer.validated_data = { + "name": "Wireless Mouse", + "description": "2.4 GHz ergonomic mouse", + "category": "Electronics", + "price": Decimal("799.00"), + "brand": "Logitech", + "quantity": 25 +} + ↓ +store.py + ↓ +Product( + id=1, + name="Wireless Mouse", + description="2.4 GHz ergonomic mouse", + category="Electronics", + price=Decimal("799.00"), + brand="Logitech", + quantity=25 +) + ↓ +to_dict() +↓ +{ + "id": 1, + "name": "Wireless Mouse", + "description": "2.4 GHz ergonomic mouse", + "category": "Electronics", + "price": "799.00", + "brand": "Logitech", + "quantity": 25 +} + ↓ +serializer.data / Response(...) + ↓ +JSON Response back to client +``` + +--- + +# Week 3 + +## What I Built + +In Week 3, I refactored the Week 2 in-memory Product CRUD API into a more structured backend architecture using a thin controller layer, service layer, repository layer, MongoDB for persistent storage, and MongoEngine for model-based database interaction. + +Unlike Week 2, where products were stored only in memory, this version stores product data in MongoDB, so data persists even after restarting the server. + +--- + +## Architecture Flows + +### Flow 1 — Request Handling Flow + +```text +Client (Browser / Postman / Frontend) + ↓ +HTTP Request + ↓ +django_app/urls.py + ↓ +week3/urls.py + ↓ +views.py + ↓ +serializers.py + ↓ +services.py + ↓ +repository.py + ↓ +models.py + ↓ +MongoDB + ↓ +repository.py + ↓ +services.py + ↓ +views.py + ↓ +DRF Response + ↓ +JSON Response back to client +``` + +--- + +### Flow 2 — Docker Compose Flow + +```text +docker compose up -d + ↓ +Read compose.yml + ↓ +Find services: + - app + - db + ↓ +Create default network + ↓ +Create db volume + ↓ +Pull postgres image + ↓ +Build app image from Dockerfile + ↓ +Create db container + ↓ +Start db container + ↓ +Create app container + ↓ +Start app container + ↓ +app talks to db using service name: db + ↓ +Both keep running in background +``` + +--- + +## Flow 3 — Django Server Startup Flow + +```text +python manage.py runserver + ↓ +manage.py sets DJANGO_SETTINGS_MODULE + ↓ +django.core.management executes the runserver command + ↓ +Django imports settings.py + ↓ +App configuration is loaded + ↓ +A MongoDB setup module / connection block is imported + ↓ +mongoengine.connect(...) + ↓ +Connection is established to the MongoDB container/server + ↓ +MongoEngine Document models become usable + ↓ +Repository layer can now perform DB operations + ↓ +Service layer can call repository methods + ↓ +View layer can safely handle API requests + ↓ +Server startup completes + ↓ +Application is ready for Product CRUD with MongoDB persistence +``` + +--- + +## Example Endpoints + +| Method | Endpoint | Description | +| -------- | ----------------------- | ------------------ | +| `POST` | `/week3/products/` | Create product | +| `GET` | `/week3/products/` | Fetch all products | +| `GET` | `/week3/products//` | Fetch one product | +| `PUT` | `/week3/products//` | Update product | +| `DELETE` | `/week3/products//` | Delete product | + +--- + +## Final Takeaway + +Week 3 was about moving from a basic CRUD project to a realistic, layered backend architecture — separating responsibilities into view, service, repository, and model layers, keeping controllers thin, and using MongoDB for real persistence. + +--- + +# Week 4 + +## API Reference + +--- + +## 1. Product APIs + +| Method | URL | Description | +| ------ | ----------------------- | ---------------------- | +| GET | `/week4/products/` | List all products | +| POST | `/week4/products/` | Create a new product | +| GET | `/week4/products//` | Get a product by ID | +| PUT | `/week4/products//` | Update a product by ID | +| DELETE | `/week4/products//` | Delete a product by ID | + +--- + +## 2. Category APIs + +| Method | URL | Description | +| ------ | ------------------------- | ----------------------- | +| GET | `/week4/categories/` | List all categories | +| POST | `/week4/categories/` | Create a new category | +| GET | `/week4/categories//` | Get a category by ID | +| PUT | `/week4/categories//` | Update a category by ID | +| DELETE | `/week4/categories//` | Delete a category by ID | + +--- + +## 3. Category–Product Relation APIs + +| Method | URL | Description | +| ------ | ---------------------------------------- | -------------------------------- | +| GET | `/week4/categories//products/` | List all products in a category | +| POST | `/week4/categories//add-product/` | Add a product to a category | +| POST | `/week4/categories//remove-product/` | Remove a product from a category | + +--- + +## 4. Bulk Upload + +| Method | URL | Description | +| ------ | ------------------------------ | ---------------------------------------- | +| POST | `/week4/products/bulk-upload/` | Upload a CSV to create multiple products | + +--- + +## 5. Filtering, Sorting & Pagination + +### Products — `/week4/products/` + +| Param | Example | Description | +| ------------------------------- | ------------------------------------- | --------------------------- | +| `name` | `?name=rice` | Filter by name | +| `brand` | `?brand=dove` | Filter by brand | +| `min_price` / `max_price` | `?min_price=20&max_price=200` | Filter by price range | +| `min_quantity` / `max_quantity` | `?min_quantity=5&max_quantity=50` | Filter by quantity range | +| `category_id` | `?category_id=1` | Filter by category | +| `sort_by` | `?sort_by=price` or `?sort_by=-price` | Sort ascending / descending | +| `page` / `page_size` | `?page=1&page_size=5` | Paginate results | + +### Categories — `/week4/categories/` + +| Param | Example | Description | +| -------------------- | ---------------------- | --------------------------- | +| `title` | `?title=food` | Filter by title | +| `sort_by` | `?sort_by=-created_at` | Sort ascending / descending | +| `page` / `page_size` | `?page=1&page_size=5` | Paginate results | + +--- + +## 6. Key Business Rules + +- `brand` is **required** for all product operations +- Products without a `category_id` are assigned to **Miscellaneous** +- Category **titles must be unique** +- A category **cannot be deleted** if products are assigned to it +- CSV bulk upload **validates all rows** before creating any product + +--- + +## 7. Old Product Migration + +A one-time migration script is included to update older product records created before category and strict brand handling were introduced. + +The script does the following: + +- Connects to MongoDB +- Checks whether the default category `Miscellaneous` exists, and creates it if needed +- Finds old products with missing category +- Finds old products with missing or blank brand +- Updates only the affected records +- Prints a summary of how many products were checked and fixed + +### How to run + +Start the services first: + +```bash +docker compose up -d +``` + +Then run the migration script: + +```bash +python -m week4.migrate_old_products +``` + +### Notes + +- This is a manual one-time migration script. +- It is mainly intended for old records created before the Week 4 category changes. +- Running it again is safe because already fixed products will not be modified again. + +--- + +## 8. Startup Seeding & Auto Migration + +On every Django server startup, the app automatically seeds and migrates the database via `AppConfig.ready()`: + +- Ensures the default category **Miscellaneous** exists +- Assigns `Miscellaneous` to products with a missing category +- Assigns `"Unknown"` to products with a missing or blank brand + +This runs automatically — no manual step needed. The process is idempotent: already-fixed records are never modified again. + +--- + +## 9. Bulk CSV Upload via Postman + +1. **Prepare CSV** — Create a `.csv` file with columns: `name, description, price, brand, quantity, category_id`. Leave `category_id` blank to assign to _Miscellaneous_. +2. **Set request** — Method: `POST`, URL: `http://127.0.0.1:8000/week4/products/bulk-upload/` +3. **Set body** — Go to `Body → form-data`, add a key named `file`, change type to `File`, and select your CSV. +4. **Send** — Click Send. All rows are validated before any product is created. + +--- + +# Week 5 - Interactive Data Tools + +## Features + +- Display inventory in a table format using Streamlit +- Add and remove products directly from UI +- Sidebar filter by product category +- Stock alert for low-quantity items +- Jupyter Notebook for MongoEngine queries and data visualization + +## Files + +- `dashboard.py` → Streamlit inventory dashboard +- `notebook/week5_inventory_analysis.ipynb` → Data analysis and visualization + +--- + +# Week 6 - Structured LLM Output — Validation & JSON with Pydantic + +LLMs return plain text. Even when asked for JSON, they can produce malformed output, include markdown fences, omit fields, use wrong types, or return the wrong number of items. Without validation, this silently breaks your application. + +--- + +## Pydantic Schemas + +Pydantic schemas define the exact shape and constraints data must meet — field types, min/max lengths, numeric ranges, and optional vs required fields. If the LLM response doesn't match, a `ValidationError` is raised immediately and nothing bad reaches the database. + +--- + +## Two Layers of Enforcement + +**Prompt level** — the prompt shows the exact JSON structure, uses concrete numeric examples, forbids markdown, and states field rules explicitly. + +**API level** — Gemini's `response_mime_type` and `response_schema` config options constrain the model's output at generation time, before it even reaches your code. + +Both layers together are more reliable than either one alone. + +--- + +## Parse → Validate → Save + +1. `model_validate_json()` parses the raw response text and validates all fields in one step. +2. Defensive checks (`.strip()`, range checks) catch edge cases that Pydantic technically allows but would produce bad data in the database. +3. `model_dump()` converts validated Pydantic objects back to plain Python dicts for JSON export or database saving. + +--- + +## Flow + +``` +Prompt → Gemini API → Raw JSON → model_validate_json() → Defensive checks → MongoDB / JSON file +``` + +--- + +# Week 7 – Semantic Search & Evaluation + +## Overview + +This week extends the inventory system with semantic search using embeddings, evaluation metrics, and interactive comparison via Streamlit dashboard. + +--- + +## Key Concepts + +### 1. Embeddings (SBERT) + +- **Model:** `all-MiniLM-L6-v2` +- Converts text → vector representation +- Used for semantic similarity + +### 2. Cosine Similarity + +Used to measure similarity between: + +- Query embedding vs product embeddings +- Product vs product (for recommendations) + +--- + +## Core Functionalities + +### Semantic Search + +``` +Query → Embedding → Compare with Product Embeddings → Rank → Top K +``` + +Implemented in `semantic_search.py` → `semantic_search()` + +### Similar Products _(Advanced Task)_ + +- Uses product-to-product similarity +- Triggered via **"Find Similar Products"** button in dashboard +- `find_similar_products(product_id)` + +### Evaluation _(Task 4)_ + +| Metric | Formula | +| --------------- | ------------------------------- | +| **Precision@K** | Relevant / Retrieved | +| **Recall@K** | Relevant Found / Total Relevant | +| **Hit@K** | At least one relevant result | +| **Fallout@K** | Irrelevant retrieved | + +### Model Comparison _(Adv Task 2)_ + +| Model | Characteristic | +| ------------------- | -------------- | +| `all-MiniLM-L6-v2` | Fast | +| `all-mpnet-base-v2` | Better quality | + +Evaluated on speed (latency) and result quality (manual inspection). + +### Streamlit Dashboard _(Task 5)_ + +- Keyword Search +- Semantic Search +- Side-by-side comparison +- Similar product recommendations + +--- + +## Important Implementation Details + +### LRU Cache + +- Used for embedding model loading +- Avoids repeated model initialization + +### Combined Text for Embedding + +Each product is converted to: + +``` +name + description + brand + category +``` + +This improves semantic understanding. + +### Unique Keys in Streamlit + +Used for widgets and buttons inside loops to prevent duplicate element errors. + +``` +keyword_similar_button_16 +semantic_similar_button_16 +``` + +--- + +# Week 8 — RAG Powered Inventory Expert + +A **Retrieval-Augmented Generation (RAG)** system built on top of the existing inventory project. Ask natural language questions and get grounded answers backed by real documents and live stock data. + +## How It Works + +1. Loads knowledge files (Product Manual, Return Policy, Vendor FAQ) +2. Splits them into chunks using **LangChain** +3. Stores embeddings in **ChromaDB** +4. Retrieves the most relevant chunks for a user query +5. Sends grounded context to **Gemini** +6. Returns an answer based only on retrieved documents +7. Optionally combines the answer with live stock data from **MongoDB** +8. Supports **LangSmith tracing** for observability + +## Project Structure + +``` +week8/ +├── config.py # Central settings +├── knowledge_base.py # Loads text documents +├── text_chunker.py # Splits documents into chunks +├── vector_store.py # ChromaDB indexing and search +├── ingest_documents.py # Runs the full ingestion pipeline +├── retriever.py # Retrieves relevant chunks +├── prompt_builder.py # Builds grounded prompt +├── llm_client.py # Calls Gemini +├── rag_pipeline.py # End-to-end RAG flow +├── stock_lookup.py # Fetches stock data from MongoDB +├── ask_expert_service.py # Combines RAG + stock lookup +├── eval_retrieval.py # Evaluates retrieval quality +├── eval_rag.py # Evaluates final RAG answers +├── langsmith_setup.py # Enables LangSmith tracing +├── dashboard.py # Streamlit UI +└── knowledge/ + ├── product_manual.txt + ├── return_policy.txt + └── vendor_faq.txt +``` + +--- + +# Week 9/10 — AI Quote Agent + +An autonomous LLM agent built on top of the existing inventory project. Customers describe what they want in natural language, and the agent identifies the product, checks stock, applies tiered discounts within a strict policy cap, and returns a structured Quote Invoice. + +## How It Works + +1. Receives a natural language quote request (e.g. _"I need 60 building blocks for a school project"_) +2. Uses **LangChain** to orchestrate a tool-calling agent backed by **Gemini** +3. The agent identifies the product via **semantic search** (reused from Week 7) +4. Checks current stock level from **MongoDB** +5. Caps the quoted quantity at available stock if the request exceeds it +6. Applies a **tiered discount** (0% / 5% / 10% / 15%) based on quantity +7. Enforces a hard **20% policy cap** on any discount (deterministic Python guard) +8. Produces both a customer-facing answer and a validated **JSON Quote Invoice** (Pydantic) +9. Supports **LangSmith tracing** for full observability of every agent run +10. Includes an automated eval suite covering simple and complex scenarios + +## Project Structure + +``` +week9_10/ +├── config.py # Central settings (model, discount tiers, policy cap) +├── schema.py # QuoteInvoiceSchema - Pydantic model + validation +├── policy.py # Discount cap enforcement +├── llm_client.py # ChatGoogleGenerativeAI wrapper +├── tools.py # The four @tool functions the agent can call +├── agent.py # LangChain AgentExecutor + system prompt +├── service.py # Cleanup layer + structured invoice builder +├── eval_agent.py # Rate-limit-aware test suite +├── tool_tests.ipynb # Jupyter notebook testing each tool in isolation +└── langsmith_setup.py # Enables LangSmith tracing +``` + +--- + + diff --git a/backend/python/django_app/adapters/api/urls.py b/backend/python/django_app/adapters/api/urls.py new file mode 100644 index 000000000..ae37aa5b0 --- /dev/null +++ b/backend/python/django_app/adapters/api/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import hello_world + +urlpatterns = [ + path("hello-world/", hello_world, name="hello-world") +] diff --git a/backend/python/django_app/adapters/api/views.py b/backend/python/django_app/adapters/api/views.py new file mode 100644 index 000000000..9549b907b --- /dev/null +++ b/backend/python/django_app/adapters/api/views.py @@ -0,0 +1,9 @@ +from django.http import JsonResponse +from django_app.application.greeter_service import greet + + +def hello_world(request): + # url is /hello-world/?name="Kreesh" it returns Kreesh + name = request.GET.get("name") + message = greet(name) + return JsonResponse({"message": message}) diff --git a/backend/python/django_app/application/greeter_service.py b/backend/python/django_app/application/greeter_service.py new file mode 100644 index 000000000..b35ceadc0 --- /dev/null +++ b/backend/python/django_app/application/greeter_service.py @@ -0,0 +1,8 @@ +# implement port contract using domain logic +# use case, what should happen when someone calls this feature + +from django_app.domain.greeting import greeting + + +def greet(name): + return greeting(name) diff --git a/backend/python/django_app/domain/greeting.py b/backend/python/django_app/domain/greeting.py new file mode 100644 index 000000000..829cad28b --- /dev/null +++ b/backend/python/django_app/domain/greeting.py @@ -0,0 +1,10 @@ +# domain : core logic + +def greeting(name): + if name == None: + name = "" + name = name.strip() + if (name == ""): + return "Hello, World!" + else: + return "Hello, "+name+"!" diff --git a/backend/python/django_app/ports/greeter.py b/backend/python/django_app/ports/greeter.py new file mode 100644 index 000000000..046d30ff5 --- /dev/null +++ b/backend/python/django_app/ports/greeter.py @@ -0,0 +1,4 @@ +# ports : core sys communicates with outside world + +def greet(name): + return "PORT ONLY - implement in application" diff --git a/backend/python/django_app/settings.py b/backend/python/django_app/settings.py index 2d7ea95db..7502adf69 100644 --- a/backend/python/django_app/settings.py +++ b/backend/python/django_app/settings.py @@ -37,6 +37,11 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "rest_framework", + "week2", + "week3.apps.Week3Config", + "django_filters", + "week4.apps.Week4Config" ] MIDDLEWARE = [ @@ -121,3 +126,8 @@ # https://docs.djangoproject.com/en/6.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "week4.pagination.Week4Pagination", + "PAGE_SIZE": 5, +} diff --git a/backend/python/django_app/urls.py b/backend/python/django_app/urls.py index 0418448be..caa7e2928 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -1,11 +1,28 @@ +# django_app/urls.py + from django.contrib import admin -from django.urls import path -from django.http import HttpResponse +from django.urls import path, include +from django.http import JsonResponse + + +def hello_name(request): + """ + A simple view that returns 'Hello, {name}' in JSON format. + Uses a query parameter named 'name'. + """ + # Get 'name' from the query string, default to 'World' if missing + name = request.GET.get("name", "World") + return JsonResponse({"message": f"Hello, {name}!"}) -def hello_world(request): - return HttpResponse("Hello, world! This is our interneers-lab Django server.") urlpatterns = [ path('admin/', admin.site.urls), - path('hello/', hello_world), + path('hello/', hello_name), + # Example usage: /hello/?name=Bob + # returns {"message": "Hello, Bob!"} + path("", include("django_app.adapters.api.urls")), + path("week2/", include("week2.urls")), + path("week3/", include("week3.urls")), + path("week4/", include("week4.urls")), + ] diff --git a/backend/python/requirements.txt b/backend/python/requirements.txt index 52591db3e..299715ce8 100644 --- a/backend/python/requirements.txt +++ b/backend/python/requirements.txt @@ -1,2 +1,20 @@ Django==6.0.2 pymongo==4.16.0 +streamlit +pandas +numpy +matplotlib +scikit-learn +sentence-transformers +mongoengine +python-dotenv +google-genai +pydantic +langchain +langchain-core +langchain-community +langchain-text-splitters +langchain-chroma +langsmith +chromadb +langchain-google-genai \ No newline at end of file diff --git a/backend/python/week2/__init__.py b/backend/python/week2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week2/admin.py b/backend/python/week2/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/backend/python/week2/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/python/week2/apps.py b/backend/python/week2/apps.py new file mode 100644 index 000000000..975b782db --- /dev/null +++ b/backend/python/week2/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class Week2Config(AppConfig): + name = "week2" diff --git a/backend/python/week2/migrations/__init__.py b/backend/python/week2/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week2/models.py b/backend/python/week2/models.py new file mode 100644 index 000000000..7cfe09405 --- /dev/null +++ b/backend/python/week2/models.py @@ -0,0 +1,24 @@ +from django.db import models +from decimal import Decimal + + +class Product: + def __init__(self, id, name, description, category, price, brand, quantity): + self.id = id + self.name = name + self.description = description + self.category = category + self.price = Decimal(str(price)) + self.brand = brand + self.quantity = quantity + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "description": self.description, + "category": self.category, + "price": str(self.price), + "brand": self.brand, + "quantity": self.quantity, + } diff --git a/backend/python/week2/serializers.py b/backend/python/week2/serializers.py new file mode 100644 index 000000000..59d363494 --- /dev/null +++ b/backend/python/week2/serializers.py @@ -0,0 +1,47 @@ +# serializer : +# 1.> validate incoming API data +# 2.> converts data into clean python format + + +from rest_framework import serializers + + +class ProductSerializer(serializers.Serializer): + # id is generated by store therefore read only therefore user should not send ID + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(max_length=200) + description = serializers.CharField() + category = serializers.CharField(max_length=100) + price = serializers.DecimalField(max_digits=10, decimal_places=2) + # brand is optional even if client sends {brand = ""} then also it will work + brand = serializers.CharField( + max_length=100, required=False, allow_blank=True) + quantity = serializers.IntegerField() + + # custom validations + + # {name = { }} should be rejected + def validate_name(self, value): + if not value.strip(): + raise serializers.ValidationError("Name cannot be empty") + return value + + def validate_category(self, value): + if not value.strip(): + raise serializers.ValidationError("Category cannot be empty.") + return value + + def validate_price(self, value): + if value <= 0: + raise serializers.ValidationError("Price must be greater than 0.") + return value + + def validate_quantity(self, value): + if value < 0: + raise serializers.ValidationError("Quantity cannot be negative.") + return value + + def validate_brand(self, value): + if value != "" and not value.strip(): + raise serializers.ValidationError("Brand cannot be only spaces.") + return value diff --git a/backend/python/week2/store.py b/backend/python/week2/store.py new file mode 100644 index 000000000..30e6b03f7 --- /dev/null +++ b/backend/python/week2/store.py @@ -0,0 +1,61 @@ +# store.py : temporary in-memory database + +from .models import Product + + +class ProductStore: + def __init__(self): + self.products = {} + self.next_id = 1 + + # creating new data + + def create(self, data): + # it is python object not JSON + product = Product( + id=self.next_id, + name=data["name"], + description=data["description"], + category=data["category"], + price=data["price"], + brand=data.get("brand", ""), + quantity=data["quantity"], + ) + self.products[self.next_id] = product + self.next_id += 1 + return product + + # getting existing data + + def get(self, product_id): + return self.products.get(product_id) + + # listing all data + + def list_all(self): + return list(self.products.values()) + + # updating data + + def update(self, product_id, data): + product = self.products.get(product_id) + if not product: + return None + product.name = data["name"] + product.description = data["description"] + product.category = data["category"] + product.price = data["price"] + product.brand = data.get("brand", "") + product.quantity = data["quantity"] + return product + + # deleting data + + def delete(self, product_id): + if product_id in self.products: + del self.products[product_id] + return True + return False + + +product_store = ProductStore() diff --git a/backend/python/week2/tests.py b/backend/python/week2/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/backend/python/week2/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/python/week2/urls.py b/backend/python/week2/urls.py new file mode 100644 index 000000000..519edc5f5 --- /dev/null +++ b/backend/python/week2/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import ProductDetailAPIView, ProductListCreateAPIView + +urlpatterns = [ + path("products/", ProductListCreateAPIView.as_view(), + name="product-list-create"), + path("products//", + ProductDetailAPIView.as_view(), name="product-detail"), +] diff --git a/backend/python/week2/views.py b/backend/python/week2/views.py new file mode 100644 index 000000000..20841c8d4 --- /dev/null +++ b/backend/python/week2/views.py @@ -0,0 +1,117 @@ +# view : code that runs when a client hits an endpoint i.e receives http request and decides what to do +# eg GET/products/, PUT/products/1 + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from .serializers import ProductSerializer +from .store import product_store + + +class ProductListCreateAPIView(APIView): + # list all product + def get(self, request): + products = product_store.list_all() + # manual pagination + page = request.query_params.get("page", "1") + page_size = request.query_params.get("page_size", "5") + if not page.isdigit() or not page_size.isdigit(): + return Response( + {"error": "page and page_size must be integers"}, status=status.HTTP_400_BAD_REQUEST + ) + + page = int(page) + page_size = int(page_size) + if page <= 0 or page_size <= 0: + return Response( + {"error": "page and page_size must be greater than 0"}, status=status.HTTP_400_BAD_REQUEST + ) + + total_items = len(products) + start = (page-1)*page_size + end = start + page_size + paginated_products = products[start: end] + product_data = [] + + for product in paginated_products: + product_data.append(product.to_dict()) + + serializer = ProductSerializer(product_data, many=True) + + if end < total_items: + next_page = page + 1 + else: + next_page = None + + if page > 1: + previous_page = page-1 + else: + previous_page = None + + return Response( + { + "count": total_items, + "page": page, + "page_size": page_size, + "next_page": next_page, + "previous_page": previous_page, + "results": serializer.data, + }, status=status.HTTP_200_OK + ) + + # product_data = [] + # for product in products: + # product_data.append(product.to_dict()) + # # output serialisation + # # without many = true DRF would expect one product + # serializer = ProductSerializer(product_data, many=True) + # # Response(product_data, status=status.HTTP_200_OK) is also valid + # return Response(serializer.data, status=status.HTTP_200_OK) + + # create a product + + def post(self, request): + # input serialisation + serializer = ProductSerializer(data=request.data) + if serializer.is_valid(): + product = product_store.create(serializer.validated_data) + response_serializer = ProductSerializer(product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProductDetailAPIView(APIView): + # get specific product + def get(self, request, product_id): + product = product_store.get(product_id) + if not product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + else: + serializer = ProductSerializer(product.to_dict()) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update one product + def put(self, request, product_id): + existing_product = product_store.get(product_id) + if not existing_product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND,) + else: + # validate new data first + serializer = ProductSerializer(data=request.data) + if (serializer.is_valid()): + updated_product = product_store.update( + product_id, serializer.validated_data) + response_serializer = ProductSerializer( + updated_product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # delete data + def delete(self, request, product_id): + deleted = product_store.delete(product_id) + if not deleted: + return Response({"error": "Product not found."}, status=status.HTTP_404_NOT_FOUND,) + else: + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/python/week3/.cph/.db_connection.py_e2e76d89979cabfcefabda2cc23e759c.prob b/backend/python/week3/.cph/.db_connection.py_e2e76d89979cabfcefabda2cc23e759c.prob new file mode 100644 index 000000000..d1077f0a2 --- /dev/null +++ b/backend/python/week3/.cph/.db_connection.py_e2e76d89979cabfcefabda2cc23e759c.prob @@ -0,0 +1 @@ +{"name":"Local: db_connection","url":"c:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\\week3\\db_connection.py","tests":[{"id":1773758364094,"input":"","output":""}],"interactive":false,"memoryLimit":1024,"timeLimit":3000,"srcPath":"c:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\\week3\\db_connection.py","group":"local","local":true} \ No newline at end of file diff --git a/backend/python/week3/__init__.py b/backend/python/week3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week3/admin.py b/backend/python/week3/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/backend/python/week3/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/python/week3/apps.py b/backend/python/week3/apps.py new file mode 100644 index 000000000..3a7f6e0ea --- /dev/null +++ b/backend/python/week3/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig +from .db_connection import initialize_mongo + + +class Week3Config(AppConfig): + name = "week3" + + def ready(self): + initialize_mongo() diff --git a/backend/python/week3/db_connection.py b/backend/python/week3/db_connection.py new file mode 100644 index 000000000..86362bf86 --- /dev/null +++ b/backend/python/week3/db_connection.py @@ -0,0 +1,24 @@ +# bridge between django file and mongoDB + +import os +from dotenv import load_dotenv +from mongoengine import connect + +load_dotenv() + + +def initialize_mongo(): + mongo_user = os.getenv("MONGO_USER", "root") + mongo_pass = os.getenv("MONGO_PASS", "example") + mongo_host = os.getenv("MONGO_HOST", "localhost") + mongo_port = os.getenv("MONGO_PORT", "27019") + mongo_db = os.getenv("MONGO_DB", "interneers_lab_week3") + + mongo_uri = (f"mongodb://{mongo_user}:{mongo_pass}" + f"@{mongo_host}:{mongo_port}/{mongo_db}?authSource=admin") + + connect( + db=mongo_db, + host=mongo_uri, + alias="default", + ) diff --git a/backend/python/week3/migrations/__init__.py b/backend/python/week3/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week3/models.py b/backend/python/week3/models.py new file mode 100644 index 000000000..447b0631f --- /dev/null +++ b/backend/python/week3/models.py @@ -0,0 +1,33 @@ +from datetime import datetime +from mongoengine import Document, SequenceField, StringField, DecimalField, IntField, DateTimeField + + +class Product(Document): + # seq field is used for auto incrementing numeric id's + id = SequenceField(primary_key=True) + name = StringField(required=True, max_length=200) + description = StringField(required=True) + category = StringField(required=True, max_length=100) + price = DecimalField(required=True, precision=2, min_value=0) + brand = StringField(max_length=100, default="") + quantity = IntField(required=True, min_value=0) + created_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=datetime.utcnow) + + # you are telling MongoEngine to put all Product objects inside the products folder/bucket + meta = { + "collection": "products" + } + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "description": self.description, + "category": self.category, + "price": str(self.price), + "brand": self.brand, + "quantity": self.quantity, + "created_at": self.created_at, + "updated_at": self.updated_at, + } diff --git a/backend/python/week3/repository.py b/backend/python/week3/repository.py new file mode 100644 index 000000000..e3c6c78be --- /dev/null +++ b/backend/python/week3/repository.py @@ -0,0 +1,57 @@ +# replacement for store in week 2 +# here i replaced in memory store with repository layer having same CRUD interface but with mongoDB using mongoengine +from datetime import datetime +from .models import Product + + +class ProductRepository: + # create new product + def create(self, data): + product = Product( + name=data["name"], + description=data["description"], + category=data["category"], + price=data["price"], + brand=data.get("brand", ""), + quantity=data["quantity"], + updated_at=datetime.utcnow(), + ) + # tells mongoengine to persist this document in mongodb + # so mongoengine looks at the model, sees meta = {"" : ""} connects through default mongodb connection and inserts an new doc into the product collection + product.save() + return product + + # get specific product by id + def get(self, id): + return Product.objects(id=id).first() + + # list all products + def list_all(self): + return Product.objects.all() + + # update product + def update(self, id, data): + product = self.get(id) + if not product: + return None + + product.name = data["name"] + product.description = data["description"] + product.category = data["category"] + product.price = data["price"] + product.brand = data.get("brand", "") + product.quantity = data["quantity"] + product.updated_at = datetime.utcnow() + product.save() + return product + + # delete product + def delete(self, id): + product = self.get(id) + if not product: + return False + product.delete() + return True + + +product_repository = ProductRepository() diff --git a/backend/python/week3/serializers.py b/backend/python/week3/serializers.py new file mode 100644 index 000000000..5f34b36f1 --- /dev/null +++ b/backend/python/week3/serializers.py @@ -0,0 +1,40 @@ +# same as week 2 +from rest_framework import serializers + + +class ProductSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(max_length=200) + description = serializers.CharField() + category = serializers.CharField(max_length=100) + price = serializers.DecimalField(max_digits=10, decimal_places=2) + brand = serializers.CharField( + max_length=100, required=False, allow_blank=True) + quantity = serializers.IntegerField() + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + + def validate_name(self, value): + if not value.strip(): + raise serializers.ValidationError("Name cannot be empty.") + return value + + def validate_category(self, value): + if not value.strip(): + raise serializers.ValidationError("Category cannot be empty.") + return value + + def validate_price(self, value): + if value <= 0: + raise serializers.ValidationError("Price must be greater than 0.") + return value + + def validate_quantity(self, value): + if value < 0: + raise serializers.ValidationError("Quantity cannot be negative.") + return value + + def validate_brand(self, value): + if value != "" and not value.strip(): + raise serializers.ValidationError("Brand cannot be only spaces.") + return value diff --git a/backend/python/week3/service.py b/backend/python/week3/service.py new file mode 100644 index 000000000..3e97b7a13 --- /dev/null +++ b/backend/python/week3/service.py @@ -0,0 +1,22 @@ +# bridge between views and repository +from .repository import product_repository + + +class ProductService: + def create(self, data): + return product_repository.create(data) + + def get(self, id): + return product_repository.get(id) + + def list_all(self): + return product_repository.list_all() + + def update(self, id, data): + return product_repository.update(id, data) + + def delete(self, id): + return product_repository.delete(id) + + +product_service = ProductService() diff --git a/backend/python/week3/tests.py b/backend/python/week3/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/backend/python/week3/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/python/week3/urls.py b/backend/python/week3/urls.py new file mode 100644 index 000000000..c94af330c --- /dev/null +++ b/backend/python/week3/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .views import ProductDetailAPIView, ProductListCreateAPIView + +urlpatterns = [ + path("products/", ProductListCreateAPIView.as_view(), + name="product-list-create"), + path("products//", + ProductDetailAPIView.as_view(), name="product-detail"), +] diff --git a/backend/python/week3/views.py b/backend/python/week3/views.py new file mode 100644 index 000000000..151b1161c --- /dev/null +++ b/backend/python/week3/views.py @@ -0,0 +1,63 @@ +# same as week 2, only request passes through service rather than directly to store +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from .serializers import ProductSerializer +from .service import product_service + + +class ProductListCreateAPIView(APIView): + # list all product + def get(self, request): + products = product_service.list_all() + product_data = [] + for product in products: + product_data.append(product.to_dict()) + + serializer = ProductSerializer(product_data, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + # create a product + def post(self, request): + serializer = ProductSerializer(data=request.data) + if serializer.is_valid(): + product = product_service.create(serializer.validated_data) + response_serializer = ProductSerializer(product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProductDetailAPIView(APIView): + # get one product + def get(self, request, id): + product = product_service.get(id) + if not product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + else: + serializer = ProductSerializer(product.to_dict()) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update one product + def put(self, request, id): + existing_product = product_service.get(id) + if not existing_product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + else: + serializer = ProductSerializer(data=request.data) + if serializer.is_valid(): + updated_product = product_service.update( + id, serializer.validated_data) + response_serializer = ProductSerializer( + updated_product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # delete product + def delete(self, request, id): + deleted = product_service.delete(id) + if not deleted: + return Response({"error": "Product not found."}, status=status.HTTP_404_NOT_FOUND) + else: + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/python/week4/__init__.py b/backend/python/week4/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week4/admin.py b/backend/python/week4/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/backend/python/week4/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/python/week4/apps.py b/backend/python/week4/apps.py new file mode 100644 index 000000000..0b701ab78 --- /dev/null +++ b/backend/python/week4/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig +from .db_connection import initialize_mongo +from .seed import startup_seed_and_migration + + +class Week4Config(AppConfig): + name = "week4" + + def ready(self): + initialize_mongo() + startup_seed_and_migration() diff --git a/backend/python/week4/bulk_csv_upload.txt b/backend/python/week4/bulk_csv_upload.txt new file mode 100644 index 000000000..eeb4f913a --- /dev/null +++ b/backend/python/week4/bulk_csv_upload.txt @@ -0,0 +1,4 @@ +name,description,price,brand,quantity,category_id +Demo Teddy Bear,Soft plush bear for kids,499,DemoToys,15,1 +Demo Wooden Puzzle,Educational 25-piece puzzle,299,DemoToys,30,1 +Demo Toy Drum,Mini hand drum for toddlers,199,DemoToys,20,1 \ No newline at end of file diff --git a/backend/python/week4/csv_helper.py b/backend/python/week4/csv_helper.py new file mode 100644 index 000000000..eded9b4db --- /dev/null +++ b/backend/python/week4/csv_helper.py @@ -0,0 +1,49 @@ +import csv +import io + + +def read_csv_file(uploaded_file, requiered_columns=None): + # check if file exists + if uploaded_file is None: + raise ValueError("No file was uploaded") + + # check that file is not empty + file_size = uploaded_file.read() + if not file_size: + raise ValueError("CSV file is empty") + + # decode it into text + try: + decoded_file = file_size.decode("utf-8") + except UnicodeDecodeError: + raise ValueError("CSV file not properly formatted") + + # make text behave like file + file_stream = io.StringIO(decoded_file) + + # parsing + reader = csv.DictReader(file_stream) + + if reader.fieldnames is None: + raise ValueError("CSV file is missing header row") + + # removing spaces in header + cleaned_fieldnames = [] + + for field in reader.fieldnames: + cleaned_field = field.strip() + cleaned_fieldnames.append(cleaned_field) + + reader.fieldnames = cleaned_fieldnames + + if requiered_columns: + missing_column = [] + for col in requiered_columns: + if col not in reader.fieldnames: + missing_column.append(col) + # returning which headers were missing + if missing_column: + raise ValueError( + f"Missing CSV columns : {', '.join(missing_column)}") + + return reader diff --git a/backend/python/week4/db_connection.py b/backend/python/week4/db_connection.py new file mode 100644 index 000000000..2d9f57bb9 --- /dev/null +++ b/backend/python/week4/db_connection.py @@ -0,0 +1,25 @@ +import os +from dotenv import load_dotenv +from mongoengine import connect + +load_dotenv() + + +def initialize_mongo(): + mongo_user = os.getenv("MONGO_USER", "root") + mongo_pass = os.getenv("MONGO_PASS", "example") + mongo_host = os.getenv("MONGO_HOST", "localhost") + mongo_port = os.getenv("MONGO_PORT", "27019") + mongo_db = os.getenv("MONGO_DB", "interneers_lab_week4") + + # mongodb://root:example@localhost:27019/interneers_lab_week4?authSource=admin + mongo_uri = ( + f"mongodb://{mongo_user}:{mongo_pass}" + f"@{mongo_host}:{mongo_port}/{mongo_db}?authSource=admin" + ) + + connect( + db=mongo_db, + host=mongo_uri, + alias="default", + ) diff --git a/backend/python/week4/migrate_old_products.py b/backend/python/week4/migrate_old_products.py new file mode 100644 index 000000000..ae072ec0b --- /dev/null +++ b/backend/python/week4/migrate_old_products.py @@ -0,0 +1,80 @@ +from datetime import datetime +from .db_connection import initialize_mongo +from .models import Product, ProductCategory + +DEFAULT_CATEGORY_TITLE = "Miscellaneous" +DEFAULT_CATEGORY_DESCRIPTION = "Default category for uncategorized products" +DEFAULT_BRAND = "Unknown" + + +# creating default category +def create_default_category(): + category = ProductCategory.objects( + title__iexact=DEFAULT_CATEGORY_TITLE).first() + if not category: + category = ProductCategory( + title=DEFAULT_CATEGORY_TITLE, + description=DEFAULT_CATEGORY_DESCRIPTION, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + category.save() + print(f"Created default category: {DEFAULT_CATEGORY_TITLE}") + + else: + print(f"Default category already exists: {DEFAULT_CATEGORY_TITLE}") + + return category + + +def migrate_product(): + # connect mongodb + initialize_mongo() + print("Connected to MongoDB") + + # ensuring default categories + default_category = create_default_category() + + # finding and fixing all products + tot_prod_checked = 0 + cat_fix_count = 0 + brand_fix_count = 0 + tot_prod_upd = 0 + + products = Product.objects + + for product in products: + tot_prod_checked += 1 + changed = False + + # fix missing category + if product.category is None: + product.category = default_category + cat_fix_count += 1 + changed = True + + # fix missing brand + if product.brand is not None: + current_brand = product.brand + else: + current_brand = "" + if not str(current_brand).strip(): + product.brand = DEFAULT_BRAND + brand_fix_count += 1 + changed = True + + # save only if something is changed + if changed: + product.updated_at = datetime.utcnow() + product.save() + tot_prod_upd += 1 + + print("\nMigration completed successfully") + print(f"Total products checked : {tot_prod_checked}") + print(f"Products updated : {tot_prod_upd}") + print(f"Missing category fixed : {cat_fix_count}") + print(f"Missing brand fixed : {brand_fix_count}") + + +if __name__ == "__main__": + migrate_product() diff --git a/backend/python/week4/migrations/__init__.py b/backend/python/week4/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week4/models.py b/backend/python/week4/models.py new file mode 100644 index 000000000..d30469b3c --- /dev/null +++ b/backend/python/week4/models.py @@ -0,0 +1,64 @@ +from datetime import datetime +from mongoengine import Document, SequenceField, StringField, DecimalField, IntField, DateTimeField, ReferenceField, NULLIFY + +# one category can contain many products but one product belongs to one category + + +# this represents a category like food, kitchen essentials, electornics, etc +class ProductCategory(Document): + id = SequenceField(primary_key=True) + # no 2 category should have same title + title = StringField(required=True, unique=True, max_length=100) + description = StringField(default="", max_length=300) + created_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=datetime.utcnow) + + # just like week3 this tells mongoengine which mongodb collection name to use + meta = { + "collection": "week4_product_categories" + } + + def to_dict(self): + return { + "id": self.id, + "title": self.title, + "description": self.description, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + +# this represents actual product like TV, fridge, rice, etc +class Product(Document): + id = SequenceField(primary_key=True) + name = StringField(required=True, max_length=200) + description = StringField(required=True) + price = DecimalField(required=True, precision=2, min_value=0) + # at model level, brand is optional because old products may already exist without brand + # if model makes it compulsary then old record may cause problems + # for new products we have enforced rules in serializer and service + brand = StringField(required=False, max_length=100, default="") + quantity = IntField(required=True, min_value=0) + # reverse delete rule = null bcz if ref category is deleted do not delete product + category = ReferenceField( + ProductCategory, required=False, null=True, reverse_delete_rule=NULLIFY) + created_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=datetime.utcnow) + + meta = { + "collection": "week4_products" + } + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "description": self.description, + "price": str(self.price), + "brand": self.brand, + "quantity": self.quantity, + "category": self.category.to_dict() if self.category else None, + "category_id": self.category.id if self.category else None, + "created_at": self.created_at, + "updated_at": self.updated_at, + } diff --git a/backend/python/week4/pagination.py b/backend/python/week4/pagination.py new file mode 100644 index 000000000..059dd27d9 --- /dev/null +++ b/backend/python/week4/pagination.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class Week4Pagination(PageNumberPagination): + page_size = 5 + page_size_query_param = "page_size" + max_page_size = 100 diff --git a/backend/python/week4/repository.py b/backend/python/week4/repository.py new file mode 100644 index 000000000..dd3020ccd --- /dev/null +++ b/backend/python/week4/repository.py @@ -0,0 +1,220 @@ +# repository.py : bridge between service and databse +from datetime import datetime +from .models import Product, ProductCategory + + +class ProductCategoryRepository: + # create category + def create(self, data): + category = ProductCategory( + title=data["title"], + description=data.get("description", ""), + updated_at=datetime.utcnow(), + ) + category.save() + return category + + # get category with id + def get(self, id): + return ProductCategory.objects(id=id).first() + + # get category with title + def get_by_title(self, title): + return ProductCategory.objects(title__iexact=title.strip()).first() + + # list all categories + def list_all(self, filters=None, sort_by=None): + queryset = ProductCategory.objects + # repository.py will only do filtering+sorting and returning queryset, pagination is done in views.py + + # manual filtering is added (icontains : case sensitive substring match) + if filters: + if filters.get("title"): + queryset = queryset.filter(title__icontains=filters["title"]) + + # manual sorting is added + allowed_sort_fields = ["id", "title", "created_at", "updated_at"] + if sort_by and sort_by.lstrip("-") in allowed_sort_fields: + queryset = queryset.order_by(sort_by) + else: + queryset = queryset.order_by("id") + + return queryset + + # update category + def update(self, id, data): + category = self.get(id) + if not category: + return None + + category.title = data["title"] + category.description = data.get("description", "") + category.updated_at = datetime.utcnow() + category.save() + return category + + # delete category + def delete(self, id): + category = self.get(id) + if not category: + return False + + category.delete() + return True + + +class ProductRepository: + # create product + def create(self, data): + product = Product( + name=data["name"], + description=data["description"], + price=data["price"], + brand=data["brand"], + quantity=data["quantity"], + category=data.get("category"), + updated_at=datetime.utcnow(), + ) + product.save() + return product + + # get product with id + def get(self, id): + return Product.objects(id=id).first() + + # list all products + def list_all(self, filters=None, sort_by=None): + queryset = Product.objects + # filtering + if filters: + if filters.get("name"): + queryset = queryset.filter(name__icontains=filters["name"]) + + if filters.get("brand"): + queryset = queryset.filter(brand__icontains=filters["brand"]) + + if filters.get("min_price") is not None: + queryset = queryset.filter(price__gte=filters["min_price"]) + + if filters.get("max_price") is not None: + queryset = queryset.filter(price__lte=filters["max_price"]) + + if filters.get("min_quantity") is not None: + queryset = queryset.filter( + quantity__gte=filters["min_quantity"]) + + if filters.get("max_quantity") is not None: + queryset = queryset.filter( + quantity__lte=filters["max_quantity"]) + + if filters.get("category"): + queryset = queryset.filter(category=filters["category"]) + + # sorting + allowed_sort_fields = ["id", "name", "price", + "brand", "quantity", "created_at", "updated_at"] + + if sort_by and sort_by.lstrip("-") in allowed_sort_fields: + queryset = queryset.order_by(sort_by) + else: + queryset = queryset.order_by("id") + + return queryset + + # update product + def update(self, id, data): + product = self.get(id) + if not product: + return None + + product.name = data["name"] + product.description = data["description"] + product.price = data["price"] + product.brand = data["brand"] + product.quantity = data["quantity"] + product.category = data.get("category") + product.updated_at = datetime.utcnow() + product.save() + return product + + # delete product + def delete(self, id): + product = self.get(id) + if not product: + return False + + product.delete() + return True + + # list all products belonging to one category + def list_by_category(self, category, filters=None, sort_by=None): + queryset = Product.objects(category=category) + + if filters: + if filters.get("name"): + queryset = queryset.filter(name__icontains=filters["name"]) + + if filters.get("brand"): + queryset = queryset.filter(brand__icontains=filters["brand"]) + + if filters.get("min_price") is not None: + queryset = queryset.filter(price__gte=filters["min_price"]) + + if filters.get("max_price") is not None: + queryset = queryset.filter(price__lte=filters["max_price"]) + + if filters.get("min_quantity") is not None: + queryset = queryset.filter( + quantity__gte=filters["min_quantity"]) + + if filters.get("max_quantity") is not None: + queryset = queryset.filter( + quantity__lte=filters["max_quantity"]) + + allowed_sort_fields = ["id", "name", "price", + "brand", "quantity", "created_at", "updated_at"] + + if sort_by and sort_by.lstrip("-") in allowed_sort_fields: + queryset = queryset.order_by(sort_by) + else: + queryset = queryset.order_by("id") + + return queryset + + # assign product to category + def assign_to_category(self, product, category): + product.category = category + product.updated_at = datetime.utcnow() + product.save() + return product + + # remove product from category + def remove_from_category(self, product): + product.category = None + product.updated_at = datetime.utcnow() + product.save() + return product + + # bulk create many products in case of CSV + def bulk_create(self, products_data): + created_products = [] + + for data in products_data: + product = Product( + name=data["name"], + description=data["description"], + price=data["price"], + # brand consistency is taken care in serializers and service so need to check here again + brand=data["brand"], + quantity=data["quantity"], + category=data.get("category"), + updated_at=datetime.utcnow(), + ) + product.save() + created_products.append(product) + + return created_products + + +product_repository = ProductRepository() +product_category_repository = ProductCategoryRepository() diff --git a/backend/python/week4/seed.py b/backend/python/week4/seed.py new file mode 100644 index 000000000..ac0aaf5a9 --- /dev/null +++ b/backend/python/week4/seed.py @@ -0,0 +1,99 @@ +from datetime import datetime +from .models import Product, ProductCategory + +DEFAULT_CATEGORY_TITLE = "Miscellaneous" +DEFAULT_CATEGORY_DESCRIPTION = "Default category for uncategorized products" +DEFAULT_BRAND = "Unknown" + + +# ensures default category exists, it is also safe to run multiple time (idempotency) +def seed_prod_category(): + created_count = 0 + existing = ProductCategory.objects( + title__iexact=DEFAULT_CATEGORY_TITLE + ).first() + + if not existing: + ProductCategory( + title=DEFAULT_CATEGORY_TITLE, + description=DEFAULT_CATEGORY_DESCRIPTION, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ).save() + created_count += 1 + + return created_count + + +# returns the miscellaneos categorym creats if it doesnt exist +def get_default_category(): + category = ProductCategory.objects( + title__iexact=DEFAULT_CATEGORY_TITLE + ).first() + + if not category: + category = ProductCategory( + title=DEFAULT_CATEGORY_TITLE, + description=DEFAULT_CATEGORY_DESCRIPTION, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + category.save() + + return category + + +# fixing old products +def migrate_products(): + default_category = get_default_category() + tot_prod_check = 0 + cat_fix_count = 0 + brand_fix_count = 0 + tot_prod_upd = 0 + + products = Product.objects + + for product in products: + tot_prod_check += 1 + changed = False + + # fix missing category + if product.category is None: + product.category = default_category + cat_fix_count += 1 + changed = True + + # fix missing brand + current_brand = product.brand if product.brand is not None else "" + if not str(current_brand).strip(): + product.brand = DEFAULT_BRAND + brand_fix_count += 1 + changed = True + + if changed: + product.updated_at = datetime.utcnow() + product.save() + tot_prod_upd += 1 + + return { + "tot_prod_check": tot_prod_check, + "cat_fix_count": cat_fix_count, + "brand_fix_count": brand_fix_count, + "tot_prod_upd": tot_prod_upd, + } + + +def startup_seed_and_migration(): + categories_created = seed_prod_category() + migration_summary = migrate_products() + + print("\nStartup seed/migration summary") + print(f"Categories created : {categories_created}") + print( + f"Products checked : {migration_summary['tot_prod_check']}") + print( + f"Products updated : {migration_summary['tot_prod_upd']}") + print( + f"Category fixes applied : {migration_summary['cat_fix_count']}") + print( + f"Brand fixes applied : {migration_summary['brand_fix_count']}") diff --git a/backend/python/week4/serializers.py b/backend/python/week4/serializers.py new file mode 100644 index 000000000..9e42c65c1 --- /dev/null +++ b/backend/python/week4/serializers.py @@ -0,0 +1,87 @@ +from rest_framework import serializers + + +# cheking for category API +class ProductCategorySerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + title = serializers.CharField(max_length=100) + description = serializers.CharField( + required=False, allow_blank=True, default="", max_length=300) + # output fields only + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + + # check whether title is not empty + def validate_title(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Title cannot be empty.") + return value + + +# checking for products API +class ProductSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(max_length=200) + description = serializers.CharField() + price = serializers.DecimalField(max_digits=10, decimal_places=2) + # here brand is made compulsary not optional + brand = serializers.CharField(max_length=100) + quantity = serializers.IntegerField() + # client need not send whole data just say attach it to category number 1 + category_id = serializers.IntegerField(required=False, allow_null=True) + # category is nested output field +# { +# "id": 1, +# "name": "Rice", +# "brand": "India Gate", +# "category_id": 2, +# "category": { this is from nested giving better output +# "id": 2, +# "title": "Food", +# "description": "Daily grocery items" +# } +# } + category = ProductCategorySerializer(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + + # custom serialization + def validate_name(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Name cannot be empty.") + return value + + def validate_description(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Description cannot be empty.") + return value + + def validate_price(self, value): + if value <= 0: + raise serializers.ValidationError("Price must be greater than 0.") + return value + + def validate_quantity(self, value): + if value < 0: + raise serializers.ValidationError("Quantity cannot be negative.") + return value + + def validate_brand(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Brand is required.") + return value + + +# for API like add product to category or remove from category +class ProductCategoryActionSerializer(serializers.Serializer): + product_id = serializers.IntegerField() + + def validate_product_id(self, value): + if value <= 0: + raise serializers.ValidationError( + "product_id must be a positive integer.") + return value diff --git a/backend/python/week4/service.py b/backend/python/week4/service.py new file mode 100644 index 000000000..77fdef759 --- /dev/null +++ b/backend/python/week4/service.py @@ -0,0 +1,236 @@ +# bridge between views and repository ( business logic layer ) +from .repository import product_category_repository, product_repository +from .csv_helper import read_csv_file + + +# some rules used +# 1. brand is compulsary now +# 2. if category is missing use Miscellaneous +# 3. category title should be unique +# 4. category cannot be deleted if products belong to it +# 5. CSV rows should be validated before insertion + + +# category related business logic +class ProductCategoryService: + # creating category + def create(self, data): + existing_category = product_category_repository.get_by_title( + data["title"]) + if existing_category: + return {"error": "Category with this title already exists"} + + return product_category_repository.create(data) + + # get category by id + def get(self, id): + return product_category_repository.get(id) + + # list all categories + def list_all(self, filters=None, sort_by=None): + return product_category_repository.list_all(filters=filters, sort_by=sort_by) + + # update category + def update(self, id, data): + category = product_category_repository.get(id) + if not category: + return {"error": "Category not found"} + # avoid duplicating + existing_category = product_category_repository.get_by_title( + data["title"]) + if existing_category and existing_category.id != id: + return {"error": "Another category with this title already exists"} + else: + return product_category_repository.update(id, data) + + # delete category + def delete(self, id): + category = product_category_repository.get(id) + if not category: + return {"error": "Category not found"} + product_in_category = product_repository.list_by_category(category) + if product_in_category.count() > 0: + return {"error": "Cannot delete category because products are assigned to it"} + else: + return product_category_repository.delete(id) + + # get products belonging to same category_id + def get_products(self, category_id, filters=None, sort_by=None): + category = product_category_repository.get(category_id) + if not category: + return {"error": "Category not found"} + else: + return product_repository.list_by_category(category, filters=filters, sort_by=sort_by) + + # add products to a category + def add_product_to_category(self, category_id, product_id): + category = product_category_repository.get(category_id) + if not category: + return {"error": "Category not found"} + else: + product = product_repository.get(product_id) + if not product: + return {"error": "Product not found"} + else: + return product_repository.assign_to_category(product, category) + + # remove product from category + def remove_product_category(self, category_id, product_id): + category = product_category_repository.get(category_id) + if not category: + return {"error": "Category not found"} + else: + product = product_repository.get(product_id) + if not product: + return {"error": "Product not found"} + if not product.category or product.category.id != category.id: + return {"error": "Product does not belong to this category"} + return product_repository.remove_from_category(product) + + +# product related business logic +class ProductService: + # creating Miscellaneous category for "no category" products + def get_default_category(self): + default_category = product_category_repository.get_by_title( + "Miscellaneous") + if not default_category: + default_category = product_category_repository.create({ + "title": "Miscellaneous", + "description": "Default category for uncategorized products" + }) + return default_category + + # creating products + def create(self, data): + # brand is compulsary now + if not data.get("brand") or not str(data["brand"]).strip(): + return {"error": "Brand is required"} + category_id = data.get("category_id") + if category_id is not None: + category_obj = product_category_repository.get(category_id) + if not category_obj: + return {"error": "Category not found"} + else: + # if category is not defined then go to misc + category_obj = self.get_default_category() + product_data = { + "name": data["name"], + "description": data["description"], + "price": data["price"], + "brand": data["brand"].strip(), + "quantity": data["quantity"], + "category": category_obj, + } + return product_repository.create(product_data) + + # get product by id + def get(self, id): + return product_repository.get(id) + + # list all products + def list_all(self, filters=None, sort_by=None): + return product_repository.list_all(filters=filters, sort_by=sort_by) + + # update product + def update(self, id, data): + existing_product = product_repository.get(id) + if not existing_product: + return None + if not data.get("brand") or not str(data["brand"]).strip(): + return {"error": "Brand is required"} + category_id = data.get("category_id") + if category_id is not None: + category_obj = product_category_repository.get(category_id) + if not category_obj: + return {"error": "Category not found"} + else: + category_obj = self.get_default_category() + + product_data = { + "name": data["name"], + "description": data["description"], + "price": data["price"], + "brand": data["brand"].strip(), + "quantity": data["quantity"], + "category": category_obj + } + return product_repository.update(id, product_data) + + # delete product + def delete(self, id): + return product_repository.delete(id) + + def create_from_csv(self, uploaded_file): + # these handles must exist in CSV + required_col = ["name", "description", "price", + "brand", "quantity", "category_id"] + try: + reader = read_csv_file( + uploaded_file, requiered_columns=required_col) + except ValueError as error: + return {"error": str(error)} + + products_to_create = [] + + # start from 2 since row 1 is header line + for row_number, row in enumerate(reader, start=2): + name = (row.get("name") or "").strip() + description = (row.get("description") or "").strip() + price = (row.get("price") or "").strip() + brand = (row.get("brand") or "").strip() + quantity = (row.get("quantity") or "").strip() + category_id = (row.get("category_id") or "").strip() + + if not name: + return {"error": f"Row {row_number}: name cannot be empty"} + + if not description: + return {"error": f"Row {row_number}: description cannot be empty"} + + if not brand: + # brand is compulsary now + return {"error": f"Row {row_number}: brand is required"} + + try: + price = float(price) + except ValueError: + return {"error": f"Row {row_number}: invalid price"} + + if price <= 0: + return {"error": f"Row {row_number}: price must be greater than 0"} + + try: + quantity = int(quantity) + except ValueError: + return {"error": f"Row {row_number}: invalid quantity"} + + if quantity < 0: + return {"error": f"Row {row_number}: quantity cannot be negative"} + + if category_id: + try: + category_id = int(category_id) + except ValueError: + return {"error": f"Row {row_number}: invalid category_id"} + + category_obj = product_category_repository.get(category_id) + if not category_obj: + return {"error": f"Row {row_number}: category not found"} + else: + category_obj = self.get_default_category() + + products_to_create.append({ + "name": name, + "description": description, + "price": price, + "brand": brand, + "quantity": quantity, + "category": category_obj, + }) + + return product_repository.bulk_create(products_to_create) + + +product_service = ProductService() +product_category_service = ProductCategoryService() diff --git a/backend/python/week4/tests.py b/backend/python/week4/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/backend/python/week4/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/python/week4/urls.py b/backend/python/week4/urls.py new file mode 100644 index 000000000..c07e95cde --- /dev/null +++ b/backend/python/week4/urls.py @@ -0,0 +1,26 @@ +from django.urls import path +from .views import ProductListCreateAPIView, ProductDetailAPIView, ProductCategoryDetailAPIView, ProductCategoryListCreateAPIView, CategoryProductsAPIView, AddProductToCategoryAPIView, RemoveProductFromCategoryAPIView, BulkProductUploadAPIView + + +urlpatterns = [ + # CRUD product + path("products/", ProductListCreateAPIView.as_view(), + name="product-list-create"), + path("products//", ProductDetailAPIView.as_view(), name="product-detail"), + # CRUD category + path("categories/", ProductCategoryListCreateAPIView.as_view(), + name="category-list-create"), + path("categories//", ProductCategoryDetailAPIView.as_view(), + name="category-detail"), + # products belonging to same category + path("categories//products/", + CategoryProductsAPIView.as_view(), name="category-products"), + # add or remove products from category + path("categories//add-product/", + AddProductToCategoryAPIView.as_view(), name="add-product-to-category"), + path("categories//remove-product/", + RemoveProductFromCategoryAPIView.as_view(), name="remove-product-from-category"), + # csv upload + path("products/bulk-upload/", BulkProductUploadAPIView.as_view(), + name="product-bulk-upload") +] diff --git a/backend/python/week4/views.py b/backend/python/week4/views.py new file mode 100644 index 000000000..17a6b1604 --- /dev/null +++ b/backend/python/week4/views.py @@ -0,0 +1,340 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.parsers import MultiPartParser, FormParser +from .serializers import ProductCategoryActionSerializer, ProductCategorySerializer, ProductSerializer +from .service import product_category_service, product_service +from .repository import product_category_repository +from .pagination import Week4Pagination + + +class ProductListCreateAPIView(APIView): + # listing all products with sorting, filtering and pagination + def get(self, request): + sort_by = request.query_params.get("sort_by", "id") + + filters = {} + + name = request.query_params.get("name") + brand = request.query_params.get("brand") + min_price = request.query_params.get("min_price") + max_price = request.query_params.get("max_price") + min_quantity = request.query_params.get("min_quantity") + max_quantity = request.query_params.get("max_quantity") + category_id = request.query_params.get("category_id") + + if name: + filters["name"] = name + if brand: + filters["brand"] = brand + + if min_price is not None: + try: + filters["min_price"] = float(min_price) + except ValueError: + return Response({"error": "min_price must be a number"}, status=status.HTTP_400_BAD_REQUEST) + + if max_price is not None: + try: + filters["max_price"] = float(max_price) + except ValueError: + return Response({"error": "max_price must be a number"}, status=status.HTTP_400_BAD_REQUEST) + + if min_quantity is not None: + try: + filters["min_quantity"] = int(min_quantity) + except ValueError: + return Response({"error": "min_quantity must be an integer."}, status=status.HTTP_400_BAD_REQUEST) + + if max_quantity is not None: + try: + filters["max_quantity"] = int(max_quantity) + except ValueError: + return Response({"error": "max_quantity must be an integer."}, status=status.HTTP_400_BAD_REQUEST) + + if category_id is not None: + try: + category_id = int(category_id) + except ValueError: + return Response({"error": "category_id must be an integer."}, status=status.HTTP_400_BAD_REQUEST) + + category = product_category_repository.get(category_id) + if not category: + return Response({"error": "Category not found."}, status=status.HTTP_404_NOT_FOUND) + + filters["category"] = category + + products = product_service.list_all(filters=filters, sort_by=sort_by) + paginator = Week4Pagination() + paginated_products = paginator.paginate_queryset( + products, request, view=self) + + product_data = [] + for product in paginated_products: + product_data.append(product.to_dict()) + + serializer = ProductSerializer(product_data, many=True) + return paginator.get_paginated_response(serializer.data) + + # create one product + def post(self, request): + serializer = ProductSerializer(data=request.data) + if serializer.is_valid(): + product = product_service.create(serializer.validated_data) + # we have to use isinstance bcz service now returns error instead of true/false like in week2/3 + if isinstance(product, dict) and "error" in product: + return Response(product, status=status.HTTP_400_BAD_REQUEST) + response_serializer = ProductSerializer(product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProductDetailAPIView(APIView): + # get product with id + def get(self, request, id): + product = product_service.get(id) + if not product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = ProductSerializer(product.to_dict()) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update product + def put(self, request, id): + existing_product = product_service.get(id) + if not existing_product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = ProductSerializer(data=request.data) + if serializer.is_valid(): + updated_product = product_service.update( + id, serializer.validated_data) + + if isinstance(updated_product, dict) and "error" in updated_product: + return Response(updated_product, status=status.HTTP_400_BAD_REQUEST) + + response_serializer = ProductSerializer(updated_product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # delete product + def delete(self, request, id): + deleted = product_service.delete(id) + if not deleted: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + else: + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProductCategoryListCreateAPIView(APIView): + # list all category with pagination, sorting and filtering + def get(self, request): + sort_by = request.query_params.get("sort_by", "id") + filters = {} + title = request.query_params.get("title") + + if title: + filters["title"] = title + + categories = product_category_service.list_all( + filters=filters, sort_by=sort_by) + + paginator = Week4Pagination() + paginated_categories = paginator.paginate_queryset( + categories, request, view=self) + + category_data = [] + for category in paginated_categories: + category_data.append(category.to_dict()) + + serializer = ProductCategorySerializer(category_data, many=True) + return paginator.get_paginated_response(serializer.data) + + # create one category + def post(self, request): + serializer = ProductCategorySerializer(data=request.data) + if serializer.is_valid(): + category = product_category_service.create( + serializer.validated_data) + if isinstance(category, dict) and "error" in category: + return Response(category, status=status.HTTP_400_BAD_REQUEST) + + response_serializer = ProductCategorySerializer(category.to_dict()) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProductCategoryDetailAPIView(APIView): + # get one category + def get(self, request, id): + category = product_category_service.get(id) + if not category: + return Response({"error": "Category not found"}, status=status.HTTP_404_NOT_FOUND) + else: + serializer = ProductCategorySerializer(category.to_dict()) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update category + def put(self, request, id): + existing_category = product_category_service.get(id) + if not existing_category: + return Response({"error": "Category not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = ProductCategorySerializer(data=request.data) + if serializer.is_valid(): + updated_category = product_category_service.update( + id, serializer.validated_data) + if isinstance(updated_category, dict) and "error" in updated_category: + return Response(updated_category, status=status.HTTP_400_BAD_REQUEST) + + response_serializer = ProductCategorySerializer( + updated_category.to_dict()) + return Response(response_serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # delete category + + def delete(self, request, id): + deleted = product_category_service.delete(id) + if isinstance(deleted, dict) and "error" in deleted: + if "Category not found" in deleted["error"]: + return Response(deleted, status=status.HTTP_404_NOT_FOUND) + else: + return Response(deleted, status=status.HTTP_400_BAD_REQUEST) + else: + return Response(status=status.HTTP_204_NO_CONTENT) + + +# list all products of one category with filter + sort + paginate +class CategoryProductsAPIView(APIView): + def get(self, request, category_id): + sort_by = request.query_params.get("sort_by", "id") + + filters = {} + + name = request.query_params.get("name") + brand = request.query_params.get("brand") + min_price = request.query_params.get("min_price") + max_price = request.query_params.get("max_price") + min_quantity = request.query_params.get("min_quantity") + max_quantity = request.query_params.get("max_quantity") + + if name: + filters["name"] = name + + if brand: + filters["brand"] = brand + + if min_price is not None: + try: + filters["min_price"] = float(min_price) + except ValueError: + return Response({"error": "min_price must be a number."}, status=status.HTTP_400_BAD_REQUEST) + + if max_price is not None: + try: + filters["max_price"] = float(max_price) + except ValueError: + return Response({"error": "max_price must be a number."}, status=status.HTTP_400_BAD_REQUEST) + + if min_quantity is not None: + try: + filters["min_quantity"] = int(min_quantity) + except ValueError: + return Response({"error": "min_quantity must be an integer."}, status=status.HTTP_400_BAD_REQUEST) + + if max_quantity is not None: + try: + filters["max_quantity"] = int(max_quantity) + except ValueError: + return Response({"error": "max_quantity must be an integer."}, status=status.HTTP_400_BAD_REQUEST) + + products = product_category_service.get_products( + category_id, + filters=filters, + sort_by=sort_by, + ) + + if isinstance(products, dict) and "error" in products: + return Response(products, status=status.HTTP_404_NOT_FOUND) + + paginator = Week4Pagination() + paginated_products = paginator.paginate_queryset( + products, request, view=self) + + product_data = [] + for product in paginated_products: + product_data.append(product.to_dict()) + + serializer = ProductSerializer(product_data, many=True) + return paginator.get_paginated_response(serializer.data) + + +# add product to a category +class AddProductToCategoryAPIView(APIView): + def post(self, request, category_id): + serializer = ProductCategoryActionSerializer(data=request.data) + + if serializer.is_valid(): + product = product_category_service.add_product_to_category( + category_id, serializer.validated_data["product_id"]) + if isinstance(product, dict) and "error" in product: + if product["error"] in ["Category not found", "Product not found"]: + return Response(product, status=status.HTTP_404_NOT_FOUND) + else: + return Response(product, status=status.HTTP_400_BAD_REQUEST) + response_serializer = ProductSerializer(product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +# remove product from category +class RemoveProductFromCategoryAPIView(APIView): + def post(self, request, category_id): + serializer = ProductCategoryActionSerializer(data=request.data) + + if serializer.is_valid(): + product = product_category_service.remove_product_category( + category_id, serializer.validated_data["product_id"]) + + if isinstance(product, dict) and "error" in product: + if product["error"] in ["Category not found", "Product not found"]: + return Response(product, status=status.HTTP_404_NOT_FOUND) + else: + return Response(product, status=status.HTTP_400_BAD_REQUEST) + + response_serializer = ProductSerializer(product.to_dict()) + return Response(response_serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +# upload csv and create many products +class BulkProductUploadAPIView(APIView): + # normally DRF expects JSON bodies but it is not coming in form of JSON so we need to parse it + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + # get uploaded file first + uploaded_file = request.FILES.get("file") + if not uploaded_file: + return Response({"error": "CSV file is required with key 'file'"}, status=status.HTTP_400_BAD_REQUEST) + + # send file to service layer + result = product_service.create_from_csv(uploaded_file) + + if isinstance(result, dict) and "error" in result: + return Response(result, status=status.HTTP_400_BAD_REQUEST) + + # if service did not return error prepare dict to upload + product_data = [] + for product in result: + product_data.append(product.to_dict()) + + serializer = ProductSerializer(product_data, many=True) + return Response({ + "message": f"{len(product_data)} products created successfully.", + "products": serializer.data + }, status=status.HTTP_201_CREATED) diff --git a/backend/python/week5/__init__.py b/backend/python/week5/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week5/dashboard.py b/backend/python/week5/dashboard.py new file mode 100644 index 000000000..a92837f00 --- /dev/null +++ b/backend/python/week5/dashboard.py @@ -0,0 +1,155 @@ +import streamlit as st +import pandas as pd +from week4.db_connection import initialize_mongo +from week4.models import Product, ProductCategory +from decimal import Decimal + + +initialize_mongo() + + +# page settings +st.set_page_config(page_title="Week 5 Inventory Dashboard", layout="wide") +st.title("Week 5: Interactive Data Tools") +st.write("Streamlit dashboard connected directly to MongoDB using MongoEngine") + + +# helpers +def get_all_categories(): + return list(ProductCategory.objects().order_by("title")) + + +def fetch_products(selected_category_id=None): + if selected_category_id is None: + products = Product.objects() + else: + products = Product.objects(category=selected_category_id) + + rows = [] + + for product in products: + rows.append({ + "ID": product.id, + "Name": product.name, + "Description": product.description, + "Price": float(product.price), + "Brand": product.brand, + "Quantity": product.quantity, + "Category": product.category.title if product.category else "No Category" + }) + + return pd.DataFrame(rows) + + +# creating sidebar category filter +st.sidebar.header("Filter Inventory") + +all_category = get_all_categories() +# creating dictionary where all maps to none so that no conflict arises +category_options = {"All": None} + +for category in all_category: + category_options[category.title] = category.id + +selected_category_title = st.sidebar.selectbox( + "Select Product Category", list(category_options.keys())) + +selected_category_id = category_options[selected_category_title] + + +# showing inventory table for current id +@st.fragment +def inventory_table(category_id): + st.subheader("Current Inventory") + df = fetch_products(category_id) + if df.empty: + st.info("No products found in this category") + else: + st.dataframe(df, use_container_width=True) + return df + + +# showing stock alert +@st.fragment +def stock_alert(df): + st.subheader("Stock Alert") + if not df.empty: + low_stock_row = [] + for index, row in df.iterrows(): + if row["Quantity"] < 5: + low_stock_row.append(row) + low_stock_df = pd.DataFrame(low_stock_row) + + if not low_stock_df.empty: + st.error("Some products are running low in stock") + st.dataframe(low_stock_df, use_container_width=True) + else: + st.success("No products are running low in stock") + else: + st.info("No products found in this category") + + +# adding product +@st.fragment +def add_product(categories): + st.subheader("Add Product") + category_title = [] + for category in categories: + category_title.append(category.title) + + with st.form("add_product_form"): + new_name = st.text_input("Name") + new_description = st.text_area("Description") + new_price = st.number_input( + "Price", min_value=0.0, step=1.0, format="%.2f") + new_brand = st.text_input("Brand") + new_quantity = st.number_input( + "Quantity", min_value=0, step=1) + new_category_title = st.selectbox("Category", category_title if category_title else [ + "No categories found "]) + submitted = st.form_submit_button("Add Product") + + if submitted: + if not new_name.strip(): + st.error("Product name cannot be empty") + elif not new_description.strip(): + st.error("Description cannot be empty") + elif not new_brand.strip(): + st.error("Brand cannot be empty") + elif not new_category_title: + st.error("No category exist. Pls create a category first in week 4") + else: + selected_category = ProductCategory.objects( + title=new_category_title).first() + Product(name=new_name, description=new_description, + price=Decimal(str(new_price)), brand=new_brand, quantity=int(new_quantity), category=selected_category).save() + st.success(f"Product '{new_name}' added successfully") + st.rerun() + + +# removing product +@st.fragment +def remove_product(): + st.subheader("Remove Product") + all_products = Product.objects().order_by("name") + product_options = {} + for product in all_products: + label = f"{product.name} (ID: {product.id})" + product_options[label] = product.id + + if product_options: + selected_product_label = st.selectbox( + "Select Product to remove", list(product_options.keys())) + if st.button("Remove Product"): + selected_product_id = product_options[selected_product_label] + Product.objects(id=selected_product_id).delete() + st.success("Product removed successfully") + st.rerun() + else: + st.info("No products found") + + +df = inventory_table(selected_category_id) +stock_alert(df) +add_product(all_category) +remove_product() diff --git a/backend/python/week5/notebook/week5_inventory_analysis.ipynb b/backend/python/week5/notebook/week5_inventory_analysis.ipynb new file mode 100644 index 000000000..e45016be9 --- /dev/null +++ b/backend/python/week5/notebook/week5_inventory_analysis.ipynb @@ -0,0 +1,839 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "87b2d0cc", + "metadata": {}, + "source": [ + "# Week 5 Inventory Analysis " + ] + }, + { + "cell_type": "code", + "execution_count": 164, + "id": "1975c454", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Project root added: c:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\n" + ] + } + ], + "source": [ + "# since the file is week5/notebook it is unable to access week4 so adding this to access all weeks\n", + "import os\n", + "import sys\n", + "\n", + "project_root = os.path.abspath(\"../..\")\n", + "\n", + "if project_root not in sys.path:\n", + " sys.path.insert(0, project_root)\n", + "\n", + "print(\"Project root added:\", project_root)" + ] + }, + { + "cell_type": "code", + "execution_count": 165, + "id": "4175ca63", + "metadata": {}, + "outputs": [], + "source": [ + "from week4.db_connection import initialize_mongo\n", + "from week4.models import Product, ProductCategory\n", + "\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import seaborn as sns" + ] + }, + { + "cell_type": "code", + "execution_count": 166, + "id": "c645f265", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Database connected successfully\n" + ] + } + ], + "source": [ + "initialize_mongo()\n", + "print(\"Database connected successfully\")" + ] + }, + { + "cell_type": "markdown", + "id": "8230e45e", + "metadata": {}, + "source": [ + "## Fetch All Products\n", + "\n", + "This cell runs a raw MongoEngine query to get all products directly from MongoDB." + ] + }, + { + "cell_type": "code", + "execution_count": 167, + "id": "2e158954", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total products: 12\n", + "{'id': 2, 'name': 'Soap', 'description': 'Bath soap', 'price': '50.00', 'brand': 'Dove', 'quantity': 15, 'category': {'id': 1, 'title': 'Food', 'description': 'Daily grocery items', 'created_at': datetime.datetime(2026, 3, 22, 21, 54, 16, 429000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 54, 16, 429000)}, 'category_id': 1, 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 840000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 58, 55, 445000)}\n", + "{'id': 3, 'name': 'Rice', 'description': 'Basmati rice', 'price': '400.00', 'brand': 'India Gate', 'quantity': 10, 'category': {'id': 1, 'title': 'Food', 'description': 'Daily grocery items', 'created_at': datetime.datetime(2026, 3, 22, 21, 54, 16, 429000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 54, 16, 429000)}, 'category_id': 1, 'created_at': datetime.datetime(2026, 3, 22, 22, 7, 6, 402000), 'updated_at': datetime.datetime(2026, 3, 22, 22, 7, 6, 402000)}\n", + "{'id': 5, 'name': 'Pan', 'description': 'Non-stick pan', 'price': '999.00', 'brand': 'Prestige', 'quantity': 5, 'category': {'id': 2, 'title': 'Electronics', 'description': 'Electronic products', 'created_at': datetime.datetime(2026, 3, 22, 21, 54, 27, 395000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 54, 27, 394000)}, 'category_id': 2, 'created_at': datetime.datetime(2026, 3, 22, 22, 7, 6, 420000), 'updated_at': datetime.datetime(2026, 3, 22, 22, 7, 6, 420000)}\n", + "{'id': 6, 'name': 'Soap', 'description': 'Bath soap', 'price': '45.00', 'brand': 'Dove', 'quantity': 20, 'category': {'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}, 'category_id': 3, 'created_at': datetime.datetime(2026, 3, 22, 22, 7, 6, 430000), 'updated_at': datetime.datetime(2026, 3, 22, 22, 7, 6, 430000)}\n", + "{'id': ObjectId('69c76070d11850d8ae737d65'), 'name': 'Test Product 1', 'description': 'No category no brand', 'price': '100.00', 'brand': 'Unknown', 'quantity': 5, 'category': {'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}, 'category_id': 3, 'created_at': datetime.datetime(2026, 3, 30, 20, 56, 58, 433837), 'updated_at': datetime.datetime(2026, 3, 28, 5, 1, 47, 363000)}\n", + "{'id': ObjectId('69c76078d11850d8ae737d67'), 'name': 'Test Product 2', 'description': 'No category but has brand', 'price': '200.00', 'brand': 'Nike', 'quantity': 10, 'category': {'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}, 'category_id': 3, 'created_at': datetime.datetime(2026, 3, 30, 20, 56, 58, 433837), 'updated_at': datetime.datetime(2026, 3, 28, 5, 1, 47, 365000)}\n", + "{'id': ObjectId('69c7607ed11850d8ae737d69'), 'name': 'Test Product 3', 'description': 'Has category but no brand', 'price': '150.00', 'brand': 'Unknown', 'quantity': 8, 'category': {'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}, 'category_id': 3, 'created_at': datetime.datetime(2026, 3, 30, 20, 56, 58, 434465), 'updated_at': datetime.datetime(2026, 3, 28, 5, 1, 47, 368000)}\n", + "{'id': ObjectId('69c7728dd11850d8ae737d6c'), 'name': 'Legacy Product 1', 'description': 'Missing category only', 'price': '100.00', 'brand': 'Nike', 'quantity': 5, 'category': {'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}, 'category_id': 3, 'created_at': datetime.datetime(2026, 3, 30, 20, 56, 58, 434465), 'updated_at': datetime.datetime(2026, 3, 28, 6, 19, 39, 669000)}\n", + "{'id': ObjectId('69c77295d11850d8ae737d6e'), 'name': 'Legacy Product 2', 'description': 'Missing brand only', 'price': '120.00', 'brand': 'Unknown', 'quantity': 8, 'category': {'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}, 'category_id': 3, 'created_at': datetime.datetime(2026, 3, 30, 20, 56, 58, 434465), 'updated_at': datetime.datetime(2026, 3, 28, 6, 19, 39, 673000)}\n", + "{'id': ObjectId('69c772a8d11850d8ae737d70'), 'name': 'Legacy Product 3', 'description': 'Missing both brand and category', 'price': '150.00', 'brand': 'Unknown', 'quantity': 10, 'category': {'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}, 'category_id': 3, 'created_at': datetime.datetime(2026, 3, 30, 20, 56, 58, 434465), 'updated_at': datetime.datetime(2026, 3, 28, 6, 19, 39, 675000)}\n" + ] + } + ], + "source": [ + "products = Product.objects()\n", + "print(\"Total products:\", products.count())\n", + "for product in products[:10]:\n", + " print(product.to_dict())" + ] + }, + { + "cell_type": "markdown", + "id": "fbacba48", + "metadata": {}, + "source": [ + "## Fetch All Categories\n", + "\n", + "This cell gets all product categories from MongoDB." + ] + }, + { + "cell_type": "code", + "execution_count": 168, + "id": "bb270c30", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total categories: 4\n", + "{'id': 1, 'title': 'Food', 'description': 'Daily grocery items', 'created_at': datetime.datetime(2026, 3, 22, 21, 54, 16, 429000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 54, 16, 429000)}\n", + "{'id': 2, 'title': 'Electronics', 'description': 'Electronic products', 'created_at': datetime.datetime(2026, 3, 22, 21, 54, 27, 395000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 54, 27, 394000)}\n", + "{'id': 3, 'title': 'Miscellaneous', 'description': 'Default category for uncategorized products', 'created_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 835000), 'updated_at': datetime.datetime(2026, 3, 22, 21, 56, 22, 834000)}\n", + "{'id': 4, 'title': 'Kitchen', 'description': 'Kitchen items', 'created_at': datetime.datetime(2026, 3, 22, 22, 3, 25, 203000), 'updated_at': datetime.datetime(2026, 3, 22, 22, 3, 25, 203000)}\n" + ] + } + ], + "source": [ + "categories = ProductCategory.objects()\n", + "print(\"Total categories:\", categories.count())\n", + "for category in categories[:5]:\n", + " print(category.to_dict())" + ] + }, + { + "cell_type": "markdown", + "id": "55dad7d9", + "metadata": {}, + "source": [ + "## Convert Product Data into a DataFrame\n", + "\n", + "MongoEngine returns document objects. \n", + "This cell converts product data into tabular form so it becomes easier to analyze and visualize." + ] + }, + { + "cell_type": "code", + "execution_count": 169, + "id": "05aab583", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
idnamedescriptionpricebrandquantitycategory
02SoapBath soap50.0Dove15Food
13RiceBasmati rice400.0India Gate10Food
25PanNon-stick pan999.0Prestige5Electronics
36SoapBath soap45.0Dove20Miscellaneous
469c76070d11850d8ae737d65Test Product 1No category no brand100.0Unknown5Miscellaneous
569c76078d11850d8ae737d67Test Product 2No category but has brand200.0Nike10Miscellaneous
669c7607ed11850d8ae737d69Test Product 3Has category but no brand150.0Unknown8Miscellaneous
769c7728dd11850d8ae737d6cLegacy Product 1Missing category only100.0Nike5Miscellaneous
869c77295d11850d8ae737d6eLegacy Product 2Missing brand only120.0Unknown8Miscellaneous
969c772a8d11850d8ae737d70Legacy Product 3Missing both brand and category150.0Unknown10Miscellaneous
1069c772b5d11850d8ae737d72Legacy Product 4Already correct200.0Dove12Miscellaneous
118Pancooking pan1000.0prestige4Kitchen
\n", + "
" + ], + "text/plain": [ + " id name \\\n", + "0 2 Soap \n", + "1 3 Rice \n", + "2 5 Pan \n", + "3 6 Soap \n", + "4 69c76070d11850d8ae737d65 Test Product 1 \n", + "5 69c76078d11850d8ae737d67 Test Product 2 \n", + "6 69c7607ed11850d8ae737d69 Test Product 3 \n", + "7 69c7728dd11850d8ae737d6c Legacy Product 1 \n", + "8 69c77295d11850d8ae737d6e Legacy Product 2 \n", + "9 69c772a8d11850d8ae737d70 Legacy Product 3 \n", + "10 69c772b5d11850d8ae737d72 Legacy Product 4 \n", + "11 8 Pan \n", + "\n", + " description price brand quantity \\\n", + "0 Bath soap 50.0 Dove 15 \n", + "1 Basmati rice 400.0 India Gate 10 \n", + "2 Non-stick pan 999.0 Prestige 5 \n", + "3 Bath soap 45.0 Dove 20 \n", + "4 No category no brand 100.0 Unknown 5 \n", + "5 No category but has brand 200.0 Nike 10 \n", + "6 Has category but no brand 150.0 Unknown 8 \n", + "7 Missing category only 100.0 Nike 5 \n", + "8 Missing brand only 120.0 Unknown 8 \n", + "9 Missing both brand and category 150.0 Unknown 10 \n", + "10 Already correct 200.0 Dove 12 \n", + "11 cooking pan 1000.0 prestige 4 \n", + "\n", + " category \n", + "0 Food \n", + "1 Food \n", + "2 Electronics \n", + "3 Miscellaneous \n", + "4 Miscellaneous \n", + "5 Miscellaneous \n", + "6 Miscellaneous \n", + "7 Miscellaneous \n", + "8 Miscellaneous \n", + "9 Miscellaneous \n", + "10 Miscellaneous \n", + "11 Kitchen " + ] + }, + "execution_count": 169, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rows = []\n", + "for product in products:\n", + " rows.append({\n", + " \"id\": product.id,\n", + " \"name\": product.name,\n", + " \"description\": product.description,\n", + " \"price\": float(product.price),\n", + " \"brand\": product.brand,\n", + " \"quantity\": product.quantity,\n", + " \"category\": product.category.title if product.category else \"No Category\"\n", + " })\n", + "df = pd.DataFrame(rows)\n", + "df" + ] + }, + { + "cell_type": "markdown", + "id": "09aa46ed", + "metadata": {}, + "source": [ + "## Basic Data Inspection\n", + "\n", + "This helps us understand the structure of the inventory dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 170, + "id": "9f321f59", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RangeIndex: 12 entries, 0 to 11\n", + "Data columns (total 7 columns):\n", + " # Column Non-Null Count Dtype \n", + "--- ------ -------------- ----- \n", + " 0 id 12 non-null object \n", + " 1 name 12 non-null object \n", + " 2 description 12 non-null object \n", + " 3 price 12 non-null float64\n", + " 4 brand 12 non-null object \n", + " 5 quantity 12 non-null int64 \n", + " 6 category 12 non-null object \n", + "dtypes: float64(1), int64(1), object(5)\n", + "memory usage: 804.0+ bytes\n" + ] + } + ], + "source": [ + "df.info()" + ] + }, + { + "cell_type": "code", + "execution_count": 171, + "id": "df40dff6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
idnamedescriptionpricebrandquantitycategory
count12.0121212.0000001212.00000012
unique12.01011NaN6NaN4
top2.0SoapBath soapNaNUnknownNaNMiscellaneous
freq1.022NaN4NaN8
meanNaNNaNNaN292.833333NaN9.333333NaN
stdNaNNaNNaN342.837049NaN4.696872NaN
minNaNNaNNaN45.000000NaN4.000000NaN
25%NaNNaNNaN100.000000NaN5.000000NaN
50%NaNNaNNaN150.000000NaN9.000000NaN
75%NaNNaNNaN250.000000NaN10.500000NaN
maxNaNNaNNaN1000.000000NaN20.000000NaN
\n", + "
" + ], + "text/plain": [ + " id name description price brand quantity category\n", + "count 12.0 12 12 12.000000 12 12.000000 12\n", + "unique 12.0 10 11 NaN 6 NaN 4\n", + "top 2.0 Soap Bath soap NaN Unknown NaN Miscellaneous\n", + "freq 1.0 2 2 NaN 4 NaN 8\n", + "mean NaN NaN NaN 292.833333 NaN 9.333333 NaN\n", + "std NaN NaN NaN 342.837049 NaN 4.696872 NaN\n", + "min NaN NaN NaN 45.000000 NaN 4.000000 NaN\n", + "25% NaN NaN NaN 100.000000 NaN 5.000000 NaN\n", + "50% NaN NaN NaN 150.000000 NaN 9.000000 NaN\n", + "75% NaN NaN NaN 250.000000 NaN 10.500000 NaN\n", + "max NaN NaN NaN 1000.000000 NaN 20.000000 NaN" + ] + }, + "execution_count": 171, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.describe(include=\"all\")" + ] + }, + { + "cell_type": "markdown", + "id": "7b842818", + "metadata": {}, + "source": [ + "## Category-wise Product Count\n", + "\n", + "This counts how many products belong to each category." + ] + }, + { + "cell_type": "code", + "execution_count": 172, + "id": "633db4db", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "category\n", + "Miscellaneous 8\n", + "Food 2\n", + "Electronics 1\n", + "Kitchen 1\n", + "Name: count, dtype: int64" + ] + }, + "execution_count": 172, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df[\"category\"].value_counts()" + ] + }, + { + "cell_type": "markdown", + "id": "17182206", + "metadata": {}, + "source": [ + "## Total Stock by Category using Matplotlib\n", + "\n", + "This shows the total available quantity for each category." + ] + }, + { + "cell_type": "code", + "execution_count": 173, + "id": "ee3ecf73", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA0kAAAIXCAYAAABJihVzAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAVQZJREFUeJzt3QmcjeX///HP2Ma+L2MnkaVNlPWbEkkoS6FUQiVZQogKEZGyRKEkS+WrVJSESqVkLNlbCNnKnmWKLD9z/o/39fvd53+PJUMzzpk5r+fjcTJzzpkz15yZ07nf9/W5PldUIBAIGAAAAADASfO//wAAAAAACEkAAAAAcBpmkgAAAADAh5AEAAAAAD6EJAAAAADwISQBAAAAgA8hCQAAAAB8CEkAAAAA4ENIAgAAAAAfQhIARJCvv/7aoqKi3L8pcdzvv//+Jf1aAEBkIiQBQDLTAXpiLokJLs8//7zNmjXrkvzO1q1bZ3fddZcVL17cMmbMaIULF7a6devamDFjQjamlGD16tV23333WdGiRS06Otpy585tderUsUmTJtmpU6cu+PF4fgHg0ksXgu8JABHlrbfeSvD51KlT7fPPPz/j+nLlyiXqgFnBpXHjxpacFi9ebDfffLMVK1bMHn74YYuJibEdO3bYkiVL7OWXX7bOnTtf8jGlBG+88YY9+uijVqBAAbv//vutdOnS9ueff9qCBQusXbt2tmvXLnvqqacu6DF5fgHg0iMkAUAy06yCn4KGQtLp14eTwYMHW44cOWz58uWWM2fOBLft3bs3ZOMKZ/q9KiBVq1bNPv30U8uWLVvwtq5du9r3339vP/zwg6VWR44csSxZsoR6GACQJCi3A4AwOcB84okngiVaV1xxhb300ksWCASC91FJnu43ZcqUYInegw8+6G7btm2bPfbYY+7rMmXKZHny5LG7777btm7delHj2bx5s1WoUOGMgCT58+dP1Jhk1apVVr9+fcuePbtlzZrVbrnlFhcmTnfo0CHr1q2blShRwv38RYoUsQceeMD2799/zjEeP37cGjZs6MKcZr7OR6VumsXRrJgO5u+44w43O+bp37+/pU+f3vbt23fG1z7yyCPuuTh27Ng5H3/AgAHu53/nnXcSBCRP5cqVEzw3+v1Wr17d/a70O6tUqdIZ66bO9/z+/vvv1rZtWzdzpedNv7M333zzjO+tvw/9vPq59fvTcz1//vyzlnnOmDHDjUVjyps3rwvz+j5+GoN+n/o7uf32293P26pVq3/9HAJAuGAmCQBCTEFIB7BfffWVK8m69tpr3QFsz5493cHpyJEj3f1UnvfQQw/ZDTfc4A44pVSpUu5fzfgoKLRs2dIFDIWjcePG2U033WQ//fSTZc6c+YLGpHVIsbGxbubjyiuvPOf9/mlMP/74o/3nP/9xAalXr17u4Pm1115zY1q4cKFVqVLF3e+vv/5y9/v555/dAf91113nwtHHH39sv/32mztQP93ff/9td955p5ud+eKLL+z6669P1OyYQsGTTz7pZsNGjRrl1gppDZECgcrjBg4caO+++6516tQp+HUnTpxw4aVZs2ZubdbZHD161JXU3Xjjja5EMTFUtqjfu8KFvsf06dNdsP3kk0+sQYMG531+9+zZY1WrVnU/k8abL18+mzt3rvsbiouLc7NXopBVu3ZtV+r3+OOPu5A4bdo09/d2usmTJ1ubNm3c8zlkyBD3PTTO7777zgVef2j+n//5H6tXr57VrFnTBT79jWkW7WKfQwAIKwEAwCXVsWNHTQ8FP581a5b7fNCgQQnud9dddwWioqICmzZtCl6XJUuWQOvWrc94zKNHj55xXWxsrHvcqVOnBq/76quv3HX695989tlngbRp07pLtWrVAr169QrMnz8/cOLEiTPue64xNW7cOJAhQ4bA5s2bg9ft3LkzkC1btsCNN94YvK5fv35uTB9++OEZjxEfH59g3DNmzAj8+eefgVq1agXy5s0bWLVq1T/+HP6vLVy4cCAuLi54/Xvvveeuf/nll4PX6WetUqVKgq/XuM73nK1Zs8bd5/HHHw8k1um/Mz23V155ZaB27dqJen7btWsXKFiwYGD//v0Jrm/ZsmUgR44cwccfPny4G5v+zjx///13oGzZsgl+Ln3//PnzuzHods8nn3zi7qffk0fj0XW9e/c+Y1wX+xwCQDih3A4AQkzrV9KmTWtdunRJcL3K7zTLpNmB89FMiOfkyZP2xx9/2OWXX+7O/K9cufKCx6QudppJ0kzHmjVrbNiwYW7WQB3uNMOTmNK2zz77zDVzuOyyy4LXFyxY0O69915btGiRm+2QDz74wK655hpr0qTJGY+jWRK/w4cP26233mrr1693ZWKadUssle/5y+DUbELj0fPvv8/SpUtd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oX2Oateu7WaUFJhEb3T16tVzFwBA5DZp0GbhK1ascKXYMTEx1rx5c3ei7cYbb0zQ9XTy5Mm2d+9ed1INKRchCUDEUEBS5yG18tZZPy8geeuMtAO69kaqW7euffrpp65bUfr06a13797Bxbb+lq8AgNRP/8//4IMPXBc7hSJVFSgk3XXXXW5d69atW23ChAluc1hVGqj76TfffGOFCxcO9dDxLxCSAEQUlUqULFnSldJ99913wTVJL7/8stvXokKFCu4+mjlSUKpfv74LSlqbpDbfBCQAiCyrVq2yxx57zIYNG+a2i/BonWrPnj3tww8/tD59+ljx4sXd3nqacbrqqqtCOmb8ezRuABBxVC+us4HqVqc3tFmzZtnbb79tt956a4LZoqNHj9qmTZtcSCpXrlyohw0ACIE333zTtffWnkhq93365rGyf/9+t/+Rrvfug5SNxg0AIo7qxDVzpLasCkdq0qCApHDkBSRtAKg9LUqVKkVAAoAIdujQITt8+HCCffG8gKSyOnW6y5s3rzvxlilTphCOFEmJkAQgImn383Hjxtl//vMfW7BggX377bcuHOnSr18/V1738ccfW5YsWUI9VADAJe5it2XLluB1OlmmtUcKRH4KTe+//757r/C+jpLs1IOQBCBi6Y1PmwDqzU1NHFR3rppztfxWTXnlypVDPUQAwCXiVRIo9DRq1MjGjBnjrr/zzjutTZs2du+997rb1LlO7b+feuope/fdd11XVMJR6sOaJAART2uUunfvbsuWLXOb/sXGxlqlSpUi/nkBgEjgbQ8hWqOqMKSTZQo/3npUrVFVabZafatrnfY+UliaPXs2++alUoQkADCzDRs2WK9evez55593He4AAKmbKgaqVq0aXF+0b98+u+OOO9w+SNpH7+TJk27tqjYZVyc7da9Tafbvv//uup3qa73tIZD60AIcAMzsiiuucLXl6mQHAEjdtOWDNn197733LE+ePO46BSLtgaSmPQpIKsNWR7vVq1e7jnUzZ85061gRGViTBAD/h4AEAKmb16GucePGLigpIG3fvt2FomLFitmNN95oDz74oBUpUsSFo7vvvtuOHDnibtMmsYgczCQBAAAgYtYebd682davX28NGjRwXevuv/9+a9WqlT3++ONuewhtCaHQpIDktfRWtQGldZGFkAQAAIBUTwFJ5XRaS5Q/f343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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "category_stock = df.groupby(\"category\")[\"quantity\"].sum().sort_values()\n", + "plt.figure(figsize=(10,5))\n", + "category_stock.plot(kind=\"bar\")\n", + "plt.title(\"Total Stock by Category\")\n", + "plt.xlabel(\"Category\")\n", + "plt.ylabel(\"Total Quantity\")\n", + "plt.xticks(rotation=45)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "3237d30c", + "metadata": {}, + "source": [ + "## Total Stock by Category using Seaborn\n", + "\n", + "This visualizes the same stock information using seaborn." + ] + }, + { + "cell_type": "code", + "execution_count": 174, + "id": "2844664a", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA0kAAAIXCAYAAABJihVzAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAVQBJREFUeJzt3QmcjeX///HP2Ma+77ukrEVk/6YsSSiRpRShzb6EUiEikl2hJEvlq1SUCpVKyVhSSpuQUPYsU2T5mfN/vK/f7z7/eywZmplzZs7r+XiczJxz5sw1Z+Z07vd9fa7PFRUIBAIGAAAAAHDS/O8/AAAAAABCEgAAAACcgZkkAAAAAPAhJAEAAACADyEJAAAAAHwISQAAAADgQ0gCAAAAAB9CEgAAAAD4EJIAAAAAwIeQBAAR5NNPP7WoqCj3b0oc9xtvvJGsXwsAiEyEJABIYjpAT8glIcHlqaeeskWLFiXL72zjxo12++23W4kSJSxjxoxWpEgRa9SokU2ZMiVkY0oJNmzYYHfddZcVK1bMoqOjLXfu3NawYUObNWuWnT59+qIfj+cXAJJfuhB8TwCIKC+//HK8z+fOnWsffvjhWdeXK1cuQQfMCi4tWrSwpLRq1Sq74YYbrHjx4nbfffdZwYIFbefOnbZ69WqbNGmS9ezZM9nHlBK8+OKL9uCDD1qBAgXs7rvvtjJlytiff/5py5cvty5dutju3bvt0UcfvajH5PkFgORHSAKAJKZZBT8FDYWkM68PJyNHjrQcOXLYunXrLGfOnPFu27dvX8jGFc70e1VAqlWrlr3//vuWLVu24G19+vSxL7/80r777jtLrY4ePWpZsmQJ9TAAIFFQbgcAYXKA+dBDDwVLtK688kobO3asBQKB4H1Ukqf7zZkzJ1iid88997jbtm/fbt26dXNflylTJsuTJ4+1bt3afv3110saz9atW61ChQpnBSTJnz9/gsYkX3/9tTVp0sSyZ89uWbNmtQYNGrgwcabDhw9b3759rWTJku7nL1q0qHXo0MEOHDhw3jGeOHHCmjVr5sKcZr4uRKVumsXRrJgO5m+55RY3O+YZOnSopU+f3vbv33/W195///3uuTh+/Ph5H3/YsGHu53/11VfjBSRPtWrV4j03+v3Wrl3b/a70O6tatepZ66Yu9Pz+/vvv1rlzZzdzpedNv7OXXnrprO+tvw/9vPq59fvTc71s2bJzlnkuWLDAjUVjyps3rwvz+j5+GoN+n/o7ufnmm93P2759+3/9HAJAuGAmCQBCTEFIB7CffPKJK8mqXLmyO4AdMGCAOzidMGGCu5/K8+69916rXr26O+CU0qVLu38146Og0K5dOxcwFI6mTZtm119/vf3www+WOXPmixqT1iHFxMS4mY+KFSue937/NKbvv//e/vOf/7iANHDgQHfw/Pzzz7sxrVixwmrUqOHu99dff7n7/fjjj+6A/5prrnHh6J133rHffvvNHaif6e+//7Zbb73Vzc589NFHdu211yZodkyh4OGHH3azYRMnTnRrhbSGSIFA5XHDhw+31157zXr06BH8upMnT7rw0qpVK7c261yOHTvmSuquu+46V6KYECpb1O9d4ULfY/78+S7Yvvvuu9a0adMLPr979+61mjVrup9J482XL58tWbLE/Q3Fxsa62StRyKpfv74r9evdu7cLifPmzXN/b2eaPXu2derUyT2fo0aNct9D4/ziiy9c4PWH5v/5n/+xxo0bW926dV3g09+YZtEu9TkEgLASAAAkq+7du2t6KPj5okWL3OcjRoyId7/bb789EBUVFdiyZUvwuixZsgQ6dux41mMeO3bsrOtiYmLc486dOzd43SeffOKu07//5IMPPgikTZvWXWrVqhUYOHBgYNmyZYGTJ0+edd/zjalFixaBDBkyBLZu3Rq8bteuXYFs2bIFrrvuuuB1Q4YMcWN66623znqMuLi4eONesGBB4M8//wzUq1cvkDdv3sDXX3/9jz+H/2uLFCkSiI2NDV7/+uuvu+snTZoUvE4/a40aNeJ9vcZ1oefsm2++cffp3bt3IKHO/J3pua1YsWKgfv36CXp+u3TpEihUqFDgwIED8a5v165dIEeOHMHHHzdunBub/s48f//9d6Bs2bLxfi59//z587sx6HbPu+++6+6n35NH49F1jzzyyFnjutTnEADCCeV2ABBiWr+SNm1a69WrV7zrVX6nWSbNDlyIZkI8p06dsj/++MMuv/xyd+b/q6++uugxqYudZpI00/HNN9/YmDFj3KyBOtxphichpW0ffPCBa+Zw2WWXBa8vVKiQ3XnnnbZy5Uo32yFvvvmmXX311Xbbbbed9TiaJfE7cuSI3XjjjfbTTz+5MjHNuiWUyvf8ZXBqNqHx6Pn332fNmjWujMyj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/db69u3rDvzuu+8+dyAoWmeh+xw8eNDGjx/v1iIBkUKzQ2pk8sQTT5x1kmDBggWuxXfWrFnd4tszy1IBAJG1jYqOofS5li/oZNtXX33lKnW016TK6/x7jbF0IXSYSUKQpnjl+PHjwev04syZM6d7weqFfNVVV7kQpMW5alPpzSgpOKn19zvvvENAQsTQ60OvBV20p8WSJUvsm2++ibfnhdrkq42ryvJUZqfXEQAgcniB57333nOdgOvVq2e1a9cOnoC+9tprXbm2mjY0btzYvbd461xZ2x06hCT8/z+GNGlc60ktJP/www/ddTqg83dRUZC6+uqrXatXHRjqzLj2gpEsWbK4+logtfNCkDo9qgxC7b41k6SmDA888ICbcfUHpdtvv90mTpzoSvK8unMAQGScfFbg0T557dq1c2tW9V6gipubb745eLJZQUmVB+qOqjJuhB4hCfGoFeVvv/3myuc0/etv56qDOwUpr45WtbI6O64zI+rAAkTSGUG9sd16662ui51OLuj1odeMwpI6PmpGSZ588knr16+fNWzY0EqWLBnq4QMAkpg6m4p3zKRjJe0vqfcLrd++/PLL3ZrvvHnzujWtCkdSrVo1t6+kTqoh9FiThLOovbfKgjQzpKlfHRRq0zK92HVRkNJBosqL1P1OjRy8Dc6ASKCZVpVMaDNlBSW1yNesq4LS4cOHrXr16u5jLcaNiYlxb3p68wMApG6vvfaajRkzxvr37++a+cjWrVtd1U3Pnj1dA6wbbrjB6tat6zaIVUm2Zo90ctq7P8IDM0k4i85wTJo0yf7++29XTqSglDlzZldKpDPmWrOkjnd60WujWQISIoVOGKg7nc76qXGJLjoT6C+t0Bo+veEpPFWpUsXWrFlDQAKACKEyOi1TmDVrVnA5gvbKu/vuu12jBpXaad9JzRZlzJjRNWtQ2bbWduvYCuGDnT5xTldeeaU7S96nTx9XctetWzcaMiDiaQY1ffr0rpTC29/Ia9fqrTXauXOnm0FSZ0hd75VbAABSL605UnMrtezWDJGOn9TgSu8DmiFSS281ZPj555+tVKlSruOp6L3jjTfecPshKUQhfPDujX+cUVInOx3kaUfozz//PN7t/oXpQKTQGT+FILW711k/zR4pPOn1oID09NNPu5JVXUdAAoDUb/369W6t0WOPPebWdV9xxRWufC46OtotSfBmlNSpTtU3L7/8stsaonPnzm6fJF3nb5KF8EBIwj/SC10vZJ09HzhwoCsd8njtKYHUyjsRoO51CkVqUKLSU236t2jRIleW6pVH6PXwwgsv2MqVK916PgBAZNAs0P333+9OlCko6V9V5HhBSTNKanYlo0ePduXY06dPd0satC+STkoj/NC4AQny008/2eDBg10traaMgUjpYqe9v0aMGOEalqgpw8MPP2z33HOP626nf9XCVR0gdYZQ+yR9+umnbi0SACD1UzWBVzWgMjvNGmkNkt43VHWgIKT1RnoPUZBq27atu+/evXtdyR0n1cIXIQkJ5m8HDkSCZcuWWcuWLd2bnToQaX8wNStZunSpa+mtQPTuu++68jq9Kd57771Wrly5UA8bAJCMtNbI2/RV67nV4U6zQ2cGJd1P65M6derE7ycFICQBgJlry+qd0fM2AOzYsaMVKFDA7W+hOvMGDRrY9ddfb88///xZM05eC3AAQGTw/v9/ZlCaNm2aW3ekLnZeUFLDBoWjfPny2dy5c2nSkAKwJglAxFMnugceeMD27Nnzv/9jTJPGBSW1v9eMkQJUjRo14gUkvcmpy533BklAAoDIC0jaN08NGFR6rbXbWo+kDcXV8ltbpTz++OPBZg5z5sxx67zpYpcyEJIARCxvxujqq692i2q1G7oXlHRGUOUSmkVSS9fbbrvNvbmJ9hBbuHCha/nqPQYAIHIoIL399tuuJFsNfVSSPXPmTLvrrrvcvpIKSu3atXMn23r16uX2mdR7imaVkDIQkgBE9GJbzQapC9GKFSvcLNGwYcPcm5loga0W12bLls1t/OetyVP5xDfffGOtWrWizTcARCC9Nzz55JPuooYNauSjk2vXXHONFSpUyN2nR48e1rRpU3dijY7AKQ9rkgBEbEDasGGD1alTx7Vk7dmzp2vIoDc0NWDQdQpH6uj46quvup3Rr732Wtu9e7cLVB999BFd7AAggkOS3i8+/vhjO3TokNWuXdt9rq0gRI19VKItuj1XrlwhHjEuFjNJACIyIGkmSAFJZRAKSKovv+mmm+yDDz5wm/+p1ffx48ddRyJ1tatYsaIrodBC3FWrVhGQACAC981Tkx7v8z/++MMWLFjg1q42a9bMpk6d6m7bvHmz21hcQUkISCnT/7bhAIAICkjffvutO+vXp08fGzlypLtNpRDa56h+/fpuRkmBSfRG17hxY3cBAERukwZtFr5+/XpXil2wYEFr06aNO9F23XXXxet6Onv2bNu3b587qYaUi5AEIGIoIKnzkFp566yfF5C8dUbaAV17IzVq1Mjef/99160offr09sgjjwQX2/pbvgIAUj/9P//NN990XewUilRVoJB0++23u3Wtv/76q82YMcNtDqtKA3U//eyzz6xIkSKhHjr+BUISgIiiUolSpUq5UrovvvgiuCZp0qRJbl+LChUquPto5khBqUmTJi4oaW2S2nwTkAAgsnz99dfWrVs3GzNmjNsuwqN1qgMGDLC33nrLBg0aZCVKlHB762nGqVKlSiEdM/49GjcAiDiqF9fZQHWr0xvaokWL7JVXXrEbb7wx3mzRsWPHbMuWLS4klStXLtTDBgCEwEsvveTae2tPJLX7PnPzWDlw4IDb/0jXe/dBykbjBgARR3XimjlSW1aFIzVpUEBSOPICkjYA1J4WpUuXJiABQAQ7fPiwHTlyJN6+eF5AUlmdOt3lzZvXnXjLlClTCEeKxERIAhCRtPv5tGnT7D//+Y8tX77cPv/8cxeOdBkyZIgrr3vnnXcsS5YsoR4qACCZu9ht27YteJ1OlmntkQKRn0LTG2+84d4rvK+jJDv1ICQBiFh649MmgHpzUxMH1Z2r5lwtv1VTXq1atVAPEQCQTLxKAoWe5s2b25QpU9z1t956q3Xq1MnuvPNOd5s616n996OPPmqvvfaa64pKOEp9WJMEIOJpjVK/fv1s7dq1btO/mJgYq1q1asQ/LwAQCbztIURrVBWGdLJM4cdbj6o1qirNVqtvda3T3kcKS4sXL2bfvFSKkAQAZrZp0yYbOHCgPfXUU67DHQAgdVPFQM2aNYPri/bv32+33HKL2wdJ++idOnXKrV3VJuPqZKfudSrN/v333123U32ttz0EUh9agAOAmV155ZWutlyd7AAAqZu2fNCmr6+//rrlyZPHXadApD2Q1LRHAUll2Opot2HDBtexbuHChW4dKyIDa5IA4P8QkAAgdfM61LVo0cIFJQWkHTt2uFBUvHhxu+666+yee+6xokWLunDUunVrO3r0qLtNm8QicjCTBAAAgIhZe7R161b76aefrGnTpq5r3d13323t27e33r17u+0htCWEQpMCktfSW9UGlNZFFkISAAAAUj0FJJXTaS1R/vz53QyRZpS0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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plt.figure(figsize=(10, 5))\n", + "sns.barplot(x=category_stock.index, y=category_stock.values)\n", + "plt.title(\"Total Stock by Category\")\n", + "plt.xlabel(\"Category\")\n", + "plt.ylabel(\"Total Quantity\")\n", + "plt.xticks(rotation=45)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "d8be90cf", + "metadata": {}, + "source": [ + "## Low Stock Products\n", + "\n", + "This identifies products whose quantity is below 5." + ] + }, + { + "cell_type": "code", + "execution_count": 175, + "id": "bb67fc22", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Number of low-stock products: 1\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
idnamedescriptionpricebrandquantitycategory
118Pancooking pan1000.0prestige4Kitchen
\n", + "
" + ], + "text/plain": [ + " id name description price brand quantity category\n", + "11 8 Pan cooking pan 1000.0 prestige 4 Kitchen" + ] + }, + "execution_count": 175, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "low_stock_rows = []\n", + "for index,row in df.iterrows():\n", + " if row[\"quantity\"]<5:\n", + " low_stock_rows.append(row)\n", + "\n", + "low_stock_df = pd.DataFrame(low_stock_rows)\n", + "print(\"Number of low-stock products:\", len(low_stock_df))\n", + "low_stock_df" + ] + }, + { + "cell_type": "markdown", + "id": "46cb042b", + "metadata": {}, + "source": [ + "## Conclusion\n", + "\n", + "This notebook demonstrates:\n", + "\n", + "- raw MongoEngine queries using `Product.objects()` and `ProductCategory.objects()`\n", + "- conversion of MongoDB documents into a pandas DataFrame\n", + "- category-wise inventory analysis\n", + "- stock visualization using matplotlib and seaborn\n", + "- identification of low-stock products" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.12.7)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/backend/python/week6/__init__.py b/backend/python/week6/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week6/dashboard.py b/backend/python/week6/dashboard.py new file mode 100644 index 000000000..1af6158b1 --- /dev/null +++ b/backend/python/week6/dashboard.py @@ -0,0 +1,405 @@ +import os +from dotenv import load_dotenv +import streamlit as st +import pandas as pd +from week4.db_connection import initialize_mongo +from week4.models import Product, ProductCategory +from week6.schema import ProductListSchema, FutureStockEventListSchema +from decimal import Decimal +from google import genai + + +load_dotenv() + + +initialize_mongo() + + +client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + +# page settings +st.set_page_config( + page_title="Week 5 + Week 6 Inventory Dashboard", layout="wide") +st.title("Week 5 + Week 6: Interactive Data Tools") +st.write("Streamlit dashboard connected to MongoDB using MongoEngine and Gemini") + + +# helpers +def get_all_categories(): + return list(ProductCategory.objects().order_by("title")) + + +def fetch_products(selected_category_id=None): + if selected_category_id is None: + products = Product.objects() + else: + products = Product.objects(category=selected_category_id) + + rows = [] + + for product in products: + rows.append({ + "ID": product.id, + "Name": product.name, + "Description": product.description, + "Price": float(product.price), + "Brand": product.brand, + "Quantity": product.quantity, + "Category": product.category.title if product.category else "No Category" + }) + + return pd.DataFrame(rows) + + +def get_or_create_category(category_title): + cat_title = (category_title or "").strip() + + if not cat_title: + cat_title = "Miscellaneous" + + category = ProductCategory.objects(title=cat_title).first() + if category: + return category + + category = ProductCategory( + title=cat_title, + description=f"Auto-created from AI generated data for {cat_title}", + ) + category.save() + return category + + +def product_already_exists(name, brand): + return Product.objects(name=name, brand=brand).first() is not None + + +def save_ai_products(validated_data): + saved_count = 0 + skipped_count = 0 + + for item in validated_data.products: + name = item.name.strip() + description = item.description.strip() + category_title = item.category.strip() + brand = item.brand.strip() + price = item.price + quantity = item.quantity + + if not name: + skipped_count += 1 + continue + + if not description: + skipped_count += 1 + continue + + if not brand: + skipped_count += 1 + continue + + if price is None or price <= 0: + skipped_count += 1 + continue + + if quantity is None or quantity < 0: + skipped_count += 1 + continue + + if product_already_exists(name, brand): + skipped_count += 1 + continue + + category_obj = get_or_create_category(category_title) + + product = Product( + name=name, + description=description, + price=Decimal(str(price)), + brand=brand, + quantity=int(quantity), + category=category_obj, + ).save() + saved_count += 1 + + return saved_count, skipped_count + + +def generate_products_from_ai(product_count=10, theme="general toy store"): + prompt = f""" +Generate exactly {product_count} products for a toy store. + +Theme: {theme} + +Return valid JSON only in this structure: +{{ + "products": [ + {{ + "name": "string", + "description": "string", + "category": "string", + "brand": "string", + "price": 10.99, + "quantity": 25 + }} + ] +}} + +Rules: +- Include exactly {product_count} products +- Product name must never be empty +- Brand must never be empty +- Description must never be empty +- Category must never be empty +- Use realistic toy store categories +- Match the requested theme where possible +- price must be a float greater than or equal to 0.01 +- quantity must be an integer greater than or equal to 0 +- do not include markdown +- output valid JSON only +""" + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.3, + "response_mime_type": "application/json", + "response_schema": ProductListSchema, + }, + ) + + validated = ProductListSchema.model_validate_json(response.text) + return validated, response.text + + +def generate_future_events(event_count=5, theme="general toy store"): + prompt = f""" +Generate exactly {event_count} future stock events for a toy store. + +Theme: {theme} + +Return valid JSON only in this structure: +{{ + "events": [ + {{ + "title": "string", + "event_type": "string", + "expected_date": "YYYY-MM-DD", + "product_name": "string", + "quantity_change": 10, + "note": "string" + }} + ] +}} + +Rules: +- Include exactly {event_count} events +- All dates must be future dates +- Keep events realistic for toy inventory +- Match the requested theme where possible +- event_type can be: incoming_shipment, seasonal_spike, low_stock_warning, preorder_arrival, supplier_delay, warehouse_transfer +- title must not be empty +- product_name must not be empty +- quantity_change must be an integer +- output valid JSON only +- do not include markdown +""" + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.6, + "response_mime_type": "application/json", + "response_schema": FutureStockEventListSchema, + }, + ) + + validated_data = FutureStockEventListSchema.model_validate_json( + response.text) + return validated_data, response.text + + +# creating sidebar category filter + +st.sidebar.header("Filter Inventory") + +all_category = get_all_categories() +# creating dictionary where all maps to none so that no conflict arises +category_options = {"All": None} + +for category in all_category: + category_options[category.title] = category.id + +selected_category_title = st.sidebar.selectbox( + "Select Product Category", list(category_options.keys())) + +selected_category_id = category_options[selected_category_title] + + +# showing inventory table for current id + +@st.fragment +def inventory_table(category_id): + st.subheader("Current Inventory") + df = fetch_products(category_id) + if df.empty: + st.info("No products found in this category") + else: + st.dataframe(df, use_container_width=True) + return df + + +# showing stock alert + +@st.fragment +def stock_alert(df): + st.subheader("Stock Alert") + if not df.empty: + low_stock_row = [] + for index, row in df.iterrows(): + if row["Quantity"] < 5: + low_stock_row.append(row) + low_stock_df = pd.DataFrame(low_stock_row) + + if not low_stock_df.empty: + st.error("Some products are running low in stock") + st.dataframe(low_stock_df, use_container_width=True) + else: + st.success("No products are running low in stock") + else: + st.info("No products found in this category") + + +# adding product + +@st.fragment +def add_product(categories): + st.subheader("Add Product") + category_title = [] + for category in categories: + category_title.append(category.title) + + with st.form("add_product_form"): + new_name = st.text_input("Name") + new_description = st.text_area("Description") + new_price = st.number_input( + "Price", min_value=0.0, step=1.0, format="%.2f") + new_brand = st.text_input("Brand") + new_quantity = st.number_input( + "Quantity", min_value=0, step=1) + if category_title: + new_category_title = st.selectbox("Category", category_title) + else: + new_category_title = None + st.warning("No categories found. Please create a category first.") + submitted = st.form_submit_button("Add Product") + + if submitted: + if not new_name.strip(): + st.error("Product name cannot be empty") + elif not new_description.strip(): + st.error("Description cannot be empty") + elif not new_brand.strip(): + st.error("Brand cannot be empty") + elif not new_category_title: + st.error("No category exist. Pls create a category first in week 4") + else: + selected_category = ProductCategory.objects( + title=new_category_title).first() + Product(name=new_name, description=new_description, + price=Decimal(str(new_price)), brand=new_brand, quantity=int(new_quantity), category=selected_category).save() + st.success(f"Product '{new_name}' added successfully") + + +# removing product + +@st.fragment +def remove_product(): + st.subheader("Remove Product") + all_products = Product.objects().order_by("name") + product_options = {} + for product in all_products: + label = f"{product.name} (ID: {product.id})" + product_options[label] = product.id + + if product_options: + selected_product_label = st.selectbox( + "Select Product to remove", list(product_options.keys())) + if st.button("Remove Product"): + selected_product_id = product_options[selected_product_label] + Product.objects(id=selected_product_id).delete() + st.success("Product removed successfully") + else: + st.info("No products found") + + +# AI generator + +@st.fragment +def ai_scenario_generator(): + st.subheader("Week 6 Advanced: AI Scenario Generator") + + scenario_options = [ + "Holiday Rush", + "Summer Sale", + "Back to School", + "New Year Restock", + "Outdoor Play Season", + "Educational Toys Campaign", + ] + + selected_scenario = st.selectbox("Choose Scenario", scenario_options) + + product_count = st.number_input( + "How many products to generate?", + min_value=1, + max_value=50, + value=10, + step=1, + ) + + event_count = st.number_input( + "How many future events to generate?", + min_value=1, + max_value=20, + value=5, + step=1, + ) + + col1, col2 = st.columns(2) + + with col1: + if st.button("Generate AI Products"): + validated_products, raw_product_json = generate_products_from_ai( + product_count=product_count, + theme=selected_scenario, + ) + + saved_count, skipped_count = save_ai_products(validated_products) + + st.success(f"{saved_count} AI products saved successfully") + + if skipped_count > 0: + st.warning(f"{skipped_count} products were skipped") + + st.text_area("Generated Product JSON", + raw_product_json, height=300) + + with col2: + if st.button("Generate Future Events"): + validated_events, raw_event_json = generate_future_events( + event_count=event_count, + theme=selected_scenario, + ) + + st.success( + f"{len(validated_events.events)} future events generated successfully") + st.text_area("Generated Event JSON", raw_event_json, height=300) + + +df = inventory_table(selected_category_id) +stock_alert(df) +ai_scenario_generator() +add_product(all_category) +remove_product() diff --git a/backend/python/week6/future_stock_events.json b/backend/python/week6/future_stock_events.json new file mode 100644 index 000000000..189b96553 --- /dev/null +++ b/backend/python/week6/future_stock_events.json @@ -0,0 +1,84 @@ +{ + "events": [ + { + "title": "Action Figures Q4 Restock", + "event_type": "incoming_shipment", + "expected_date": "2023-11-05", + "product_name": "Galaxy Guardians Action Figure Set", + "quantity_change": 300, + "note": "Expected delivery from distributor. High demand item." + }, + { + "title": "Holiday Building Blocks Rush", + "event_type": "seasonal_spike", + "expected_date": "2023-12-10", + "product_name": "MegaBuild Creator Blocks 1000pc", + "quantity_change": 500, + "note": "Anticipating increased sales for holiday season." + }, + { + "title": "Critical Stock: Strategy Board Game", + "event_type": "low_stock_warning", + "expected_date": "2023-11-01", + "product_name": "Quest for the Crystal Throne", + "quantity_change": 100, + "note": "Current stock levels are critically low. Reorder immediately." + }, + { + "title": "New Doll Series Preorder Fulfillment", + "event_type": "preorder_arrival", + "expected_date": "2023-11-15", + "product_name": "Enchanted Friends Collectible Doll", + "quantity_change": 250, + "note": "All preorders for Wave 1 ready for dispatch." + }, + { + "title": "Electronic Gadget Shipment Delay", + "event_type": "supplier_delay", + "expected_date": "2023-11-20", + "product_name": "RoboPet Interactive Companion", + "quantity_change": 0, + "note": "Original delivery date of 2023-11-10 pushed back due to manufacturing issues." + }, + { + "title": "Plush Toy Transfer from Warehouse B", + "event_type": "warehouse_transfer", + "expected_date": "2023-11-07", + "product_name": "CuddleBuddy Plush Bear", + "quantity_change": 100, + "note": "Moving excess stock from off-site warehouse to main store." + }, + { + "title": "Educational Toys New Arrivals", + "event_type": "incoming_shipment", + "expected_date": "2023-11-25", + "product_name": "STEM Explorer Science Kit", + "quantity_change": 180, + "note": "New line of educational toys arriving for holiday season." + }, + { + "title": "Spring Outdoor Play Equipment Demand", + "event_type": "seasonal_spike", + "expected_date": "2024-03-15", + "product_name": "Adventure Playground Swing Set", + "quantity_change": 75, + "note": "Forecasting high demand for outdoor items in early spring." + }, + { + "title": "Low Stock: Premium Art Supplies", + "event_type": "low_stock_warning", + "expected_date": "2023-11-03", + "product_name": "Artist's Deluxe Paint Set", + "quantity_change": 50, + "note": "Running low on popular art sets. Need to reorder." + }, + { + "title": "Limited Edition Model Car Preorder", + "event_type": "preorder_arrival", + "expected_date": "2023-12-01", + "product_name": "Vintage Racer Die-Cast Model", + "quantity_change": 50, + "note": "Exclusive release, all preorders allocated." + } + ] +} \ No newline at end of file diff --git a/backend/python/week6/generated_products.json b/backend/python/week6/generated_products.json new file mode 100644 index 000000000..78ee26978 --- /dev/null +++ b/backend/python/week6/generated_products.json @@ -0,0 +1,444 @@ +{ + "products": [ + { + "name": "Deluxe City Building Blocks Set", + "description": "A large set of colorful interlocking blocks for creative construction, perfect for developing motor skills and imagination.", + "category": "building blocks", + "brand": "BlockMaster", + "price": 49.99, + "quantity": 75 + }, + { + "name": "Mini Robot Construction Kit", + "description": "Build your own small, functional robot with this engaging construction kit, featuring easy-to-follow instructions.", + "category": "building blocks", + "brand": "RoboBuild", + "price": 29.5, + "quantity": 120 + }, + { + "name": "Wooden Farm Animal Blocks", + "description": "Chunky wooden blocks shaped like farm animals, ideal for toddlers to stack, sort, and play.", + "category": "building blocks", + "brand": "EcoPlay", + "price": 22.75, + "quantity": 90 + }, + { + "name": "Magnetic Tile Creativity Set", + "description": "Geometric magnetic tiles that connect to build 2D and 3D structures, fostering STEM learning.", + "category": "building blocks", + "brand": "MagnaTiles", + "price": 65.0, + "quantity": 60 + }, + { + "name": "Interlocking Gear System", + "description": "A set of colorful gears and connectors to create intricate moving machines, promoting understanding of mechanics.", + "category": "building blocks", + "brand": "GearWorks", + "price": 35.25, + "quantity": 85 + }, + { + "name": "Princess Royal Doll", + "description": "A beautiful doll with a shimmering gown and accessories, ready for imaginative royal adventures.", + "category": "dolls", + "brand": "Dreamland Dolls", + "price": 18.99, + "quantity": 150 + }, + { + "name": "Doctor Play Doll Set", + "description": "An articulated doll dressed as a doctor, complete with medical tools for role-playing and learning.", + "category": "dolls", + "brand": "Career Dolls", + "price": 24.5, + "quantity": 110 + }, + { + "name": "Baby Cuddle Doll", + "description": "A soft-bodied baby doll perfect for cuddling and nurturing play, comes with a pacifier and blanket.", + "category": "dolls", + "brand": "Sweet Dreams Babies", + "price": 15.75, + "quantity": 130 + }, + { + "name": "Fashionista Doll Collection", + "description": "A stylish doll with multiple outfits and accessories for endless fashion fun and creative styling.", + "category": "dolls", + "brand": "Glamour Girls", + "price": 29.0, + "quantity": 95 + }, + { + "name": "Fantasy Fairy Doll", + "description": "A magical fairy doll with sparkling wings and a whimsical outfit, inspiring enchanting stories.", + "category": "dolls", + "brand": "Enchanted Toys", + "price": 21.25, + "quantity": 105 + }, + { + "name": "Superhero Action Figure", + "description": "Highly detailed action figure of a popular superhero, featuring multiple points of articulation and accessories.", + "category": "action figures", + "brand": "Heroic Legends", + "price": 14.99, + "quantity": 180 + }, + { + "name": "Space Explorer Figure", + "description": "An astronaut action figure with a removable helmet and space tools, perfect for cosmic adventures.", + "category": "action figures", + "brand": "Galaxy Quest", + "price": 12.5, + "quantity": 160 + }, + { + "name": "Mythical Beast Figure", + "description": "A fearsome mythical creature action figure with intricate sculpting and dynamic pose.", + "category": "action figures", + "brand": "Fantasy Fighters", + "price": 17.0, + "quantity": 140 + }, + { + "name": "Ninja Warrior Figure", + "description": "Agile ninja action figure with authentic weapons and martial arts poses for epic battles.", + "category": "action figures", + "brand": "Shadow Strike", + "price": 13.75, + "quantity": 170 + }, + { + "name": "Robot Defender Figure", + "description": "A futuristic robot action figure with light-up features and interchangeable weapon attachments.", + "category": "action figures", + "brand": "Mech Warriors", + "price": 19.25, + "quantity": 125 + }, + { + "name": "Classic Family Board Game", + "description": "A timeless board game for 2-4 players, involving strategy and luck, fun for all ages.", + "category": "board games", + "brand": "GameNight Classics", + "price": 25.99, + "quantity": 100 + }, + { + "name": "Mystery Detective Game", + "description": "Solve a thrilling mystery by gathering clues and interrogating suspects in this engaging board game.", + "category": "board games", + "brand": "Whodunit Games", + "price": 32.0, + "quantity": 80 + }, + { + "name": "Fantasy Adventure Board Game", + "description": "Embark on an epic quest in a magical land, battling monsters and collecting treasures.", + "category": "board games", + "brand": "Epic Journeys", + "price": 45.5, + "quantity": 55 + }, + { + "name": "Strategy City Builder Game", + "description": "Build and manage your own city, making strategic decisions to grow your empire.", + "category": "board games", + "brand": "Urban Planners", + "price": 38.75, + "quantity": 70 + }, + { + "name": "Cooperative Storytelling Game", + "description": "Work together with friends to create a unique story, fostering creativity and teamwork.", + "category": "board games", + "brand": "Narrative Play", + "price": 28.0, + "quantity": 90 + }, + { + "name": "Animal Kingdom Puzzle (1000 pieces)", + "description": "A challenging 1000-piece jigsaw puzzle featuring a vibrant illustration of various animals in their habitat.", + "category": "puzzles", + "brand": "PuzzleMania", + "price": 19.99, + "quantity": 110 + }, + { + "name": "World Map Floor Puzzle (50 pieces)", + "description": "A large, durable floor puzzle depicting a colorful world map, perfect for young learners.", + "category": "puzzles", + "brand": "GeoPuzzles", + "price": 15.5, + "quantity": 130 + }, + { + "name": "3D Eiffel Tower Puzzle", + "description": "Construct a detailed 3D model of the Eiffel Tower using interlocking plastic pieces.", + "category": "puzzles", + "brand": "Structure Puzzles", + "price": 27.0, + "quantity": 80 + }, + { + "name": "Alphabet Learning Puzzle", + "description": "A wooden peg puzzle designed to help toddlers learn letters and improve fine motor skills.", + "category": "puzzles", + "brand": "SmartStart Toys", + "price": 12.25, + "quantity": 150 + }, + { + "name": "Space Exploration Jigsaw (500 pieces)", + "description": "A captivating 500-piece puzzle showcasing astronauts and celestial bodies in outer space.", + "category": "puzzles", + "brand": "Cosmic Puzzles", + "price": 16.75, + "quantity": 120 + }, + { + "name": "Kids First Microscope Kit", + "description": "An easy-to-use microscope designed for children to explore the micro-world, includes slides and tools.", + "category": "educational toys", + "brand": "Science Explorers", + "price": 39.99, + "quantity": 70 + }, + { + "name": "Coding Robot for Kids", + "description": "Introduce basic coding concepts with this programmable robot, featuring interactive challenges.", + "category": "educational toys", + "brand": "Code & Play", + "price": 55.0, + "quantity": 45 + }, + { + "name": "Human Anatomy Model Kit", + "description": "A detailed, buildable model of the human body, helping children understand biology.", + "category": "educational toys", + "brand": "BodyWorks", + "price": 34.5, + "quantity": 60 + }, + { + "name": "Chemistry Lab Set", + "description": "Perform safe and exciting experiments with this comprehensive chemistry set, includes goggles and chemicals.", + "category": "educational toys", + "brand": "Mad Scientist Labs", + "price": 48.25, + "quantity": 50 + }, + { + "name": "Interactive Globe with Pen", + "description": "A talking globe that provides facts about countries, capitals, and cultures when touched with a special pen.", + "category": "educational toys", + "brand": "World Discoverer", + "price": 69.99, + "quantity": 35 + }, + { + "name": "Giant Teddy Bear", + "description": "An extra-large, super soft teddy bear, perfect for big hugs and comforting companionship.", + "category": "plush toys", + "brand": "Cuddle Buddies", + "price": 45.0, + "quantity": 65 + }, + { + "name": "Unicorn Plush Toy", + "description": "A magical plush unicorn with a rainbow mane and sparkling horn, soft and cuddly.", + "category": "plush toys", + "brand": "Fantasy Friends", + "price": 22.5, + "quantity": 100 + }, + { + "name": "Puppy Dog Plush", + "description": "An adorable plush puppy dog with floppy ears and a wagging tail, realistic and huggable.", + "category": "plush toys", + "brand": "Pet Pals", + "price": 18.0, + "quantity": 120 + }, + { + "name": "Dinosaur Stuffed Animal", + "description": "A friendly plush dinosaur, soft and colorful, great for prehistoric adventures.", + "category": "plush toys", + "brand": "Dino Buddies", + "price": 25.75, + "quantity": 90 + }, + { + "name": "Mini Animal Plush Assortment", + "description": "A collection of small, cute plush animals, perfect for party favors or collecting.", + "category": "plush toys", + "brand": "Tiny Treasures", + "price": 9.99, + "quantity": 200 + }, + { + "name": "High-Speed RC Race Car", + "description": "A fast and agile remote control race car with full-function steering and durable design.", + "category": "remote control toys", + "brand": "Speed Racers", + "price": 39.99, + "quantity": 80 + }, + { + "name": "Flying Drone with Camera", + "description": "An easy-to-fly drone equipped with a camera for aerial photography and video recording.", + "category": "remote control toys", + "brand": "SkyView Drones", + "price": 75.0, + "quantity": 40 + }, + { + "name": "RC Monster Truck", + "description": "A rugged remote control monster truck with oversized tires, capable of tackling various terrains.", + "category": "remote control toys", + "brand": "Off-Road RCs", + "price": 55.25, + "quantity": 60 + }, + { + "name": "Remote Control Helicopter", + "description": "A stable and easy-to-control RC helicopter, perfect for indoor flying fun.", + "category": "remote control toys", + "brand": "Heli-Fun", + "price": 29.5, + "quantity": 95 + }, + { + "name": "RC Robot Fighter", + "description": "A remote control robot that can walk, talk, and even battle other robots with soft projectiles.", + "category": "remote control toys", + "brand": "Robot Rumble", + "price": 62.0, + "quantity": 50 + }, + { + "name": "Kids Outdoor Swing Set", + "description": "A sturdy and safe swing set for backyard fun, includes two swings and a glider.", + "category": "outdoor toys", + "brand": "Backyard Adventures", + "price": 199.99, + "quantity": 20 + }, + { + "name": "Water Blaster Super Soaker", + "description": "A powerful water blaster for epic water fights, featuring a large capacity tank.", + "category": "outdoor toys", + "brand": "AquaBlast", + "price": 14.99, + "quantity": 150 + }, + { + "name": "Giant Bubble Wand Kit", + "description": "Create enormous bubbles with this special wand and solution, perfect for outdoor play.", + "category": "outdoor toys", + "brand": "Bubble Magic", + "price": 12.5, + "quantity": 180 + }, + { + "name": "Kids Soccer Goal Set", + "description": "A portable soccer goal set, easy to assemble and perfect for practicing soccer skills in the yard.", + "category": "outdoor toys", + "brand": "Sporty Kids", + "price": 35.0, + "quantity": 70 + }, + { + "name": "Flying Disc Set", + "description": "A set of durable flying discs for throwing and catching games, ideal for parks and beaches.", + "category": "outdoor toys", + "brand": "Disc Fun", + "price": 8.75, + "quantity": 200 + }, + { + "name": "Sand Play Set with Molds", + "description": "A complete sand play set including buckets, shovels, and various molds for creative sandcastles.", + "category": "outdoor toys", + "brand": "Beach Builders", + "price": 16.0, + "quantity": 130 + }, + { + "name": "Jump Rope with Counter", + "description": "A classic jump rope with a built-in counter to track jumps, great for exercise and coordination.", + "category": "outdoor toys", + "brand": "Active Play", + "price": 9.5, + "quantity": 160 + }, + { + "name": "Kite Flying Kit", + "description": "An easy-to-assemble kite with a long string, perfect for windy days at the park.", + "category": "outdoor toys", + "brand": "Wind Riders", + "price": 11.25, + "quantity": 140 + }, + { + "name": "Hula Hoop Assortment", + "description": "Colorful hula hoops in various sizes, promoting active play and coordination.", + "category": "outdoor toys", + "brand": "Hoop Stars", + "price": 7.99, + "quantity": 220 + }, + { + "name": "Outdoor Ring Toss Game", + "description": "A fun and simple ring toss game for all ages, perfect for picnics and backyard parties.", + "category": "outdoor toys", + "brand": "Garden Games", + "price": 19.0, + "quantity": 100 + }, + { + "name": "Starlight Princess Doll", + "description": "A beautiful doll with shimmering gown and magical accessories, perfect for imaginative play.", + "category": "Dolls", + "brand": "Dreamland Toys", + "price": 29.99, + "quantity": 50 + }, + { + "name": "Galactic Warrior Action Figure", + "description": "Highly detailed action figure with multiple points of articulation and interchangeable weapons for epic space battles.", + "category": "Action Figures", + "brand": "Cosmic Play", + "price": 19.5, + "quantity": 75 + }, + { + "name": "Enchanted Forest Jigsaw Puzzle", + "description": "A 1000-piece jigsaw puzzle featuring a vibrant illustration of a magical forest scene, challenging and fun for all ages.", + "category": "Puzzles", + "brand": "Mind Bender Puzzles", + "price": 15.75, + "quantity": 30 + }, + { + "name": "Robo-Racer Remote Control Car", + "description": "High-speed remote control car with durable design and responsive controls, ideal for indoor and outdoor racing.", + "category": "Remote Control Toys", + "brand": "Speedy Wheels", + "price": 45.0, + "quantity": 20 + }, + { + "name": "Giant Plush Teddy Bear", + "description": "An extra-large, super soft teddy bear, perfect for cuddling and a wonderful companion for children.", + "category": "Plush Toys", + "brand": "Cuddle Buddies", + "price": 35.99, + "quantity": 40 + } + ] +} \ No newline at end of file diff --git a/backend/python/week6/schema.py b/backend/python/week6/schema.py new file mode 100644 index 000000000..9f20dcdda --- /dev/null +++ b/backend/python/week6/schema.py @@ -0,0 +1,28 @@ +from typing import Annotated, List, Optional +from pydantic import BaseModel, Field + + +class ProductSchema(BaseModel): + name: Annotated[str, Field(min_length=1, max_length=200)] + description: Annotated[str, Field(min_length=1, max_length=500)] + category: Annotated[str, Field(min_length=1, max_length=100)] + brand: Annotated[str, Field(min_length=1, max_length=100)] + price: Annotated[float, Field(ge=0.01)] + quantity: Annotated[int, Field(ge=0)] + + +class ProductListSchema(BaseModel): + products: List[ProductSchema] + + +class FutureStockEventSchema(BaseModel): + title: Annotated[str, Field(min_length=3, max_length=120)] + event_type: Annotated[str, Field(min_length=3, max_length=50)] + expected_date: str + product_name: Annotated[str, Field(min_length=1, max_length=100)] + quantity_change: int + note: Optional[str] = None + + +class FutureStockEventListSchema(BaseModel): + events: List[FutureStockEventSchema] diff --git a/backend/python/week6/synthetic_products.ipynb b/backend/python/week6/synthetic_products.ipynb new file mode 100644 index 000000000..09b3eea8b --- /dev/null +++ b/backend/python/week6/synthetic_products.ipynb @@ -0,0 +1,340 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 11, + "id": "0bbaf6bc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Project root added to sys.path\n", + "C:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\n" + ] + } + ], + "source": [ + "import os\n", + "import sys\n", + "\n", + "project_root = r\"C:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\"\n", + "\n", + "if project_root not in sys.path:\n", + " sys.path.append(project_root)\n", + "\n", + "print(\"Project root added to sys.path\")\n", + "print(sys.path[-1])" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "bf39fe54", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import json\n", + "from decimal import Decimal\n", + "from dotenv import load_dotenv\n", + "from google import genai\n", + "from week4.db_connection import initialize_mongo\n", + "from week4.models import Product, ProductCategory\n", + "from week6.schema import ProductListSchema" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "b0afde86", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gemini and MongoDB initialized successfully\n" + ] + } + ], + "source": [ + "load_dotenv()\n", + "initialize_mongo()\n", + "\n", + "client = genai.Client(api_key=os.getenv(\"GEMINI_API_KEY\"))\n", + "print(\"Gemini and MongoDB initialized successfully\")" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "c787254a", + "metadata": {}, + "outputs": [], + "source": [ + "prompt = \"\"\"\n", + "Generate exactly 5 products for a toy store.\n", + "\n", + "Return valid JSON only in this structure:\n", + "{\n", + " \"products\": [\n", + " {\n", + " \"name\": \"string\",\n", + " \"description\": \"string\",\n", + " \"category\": \"string\",\n", + " \"brand\": \"string\",\n", + " \"price\": 10.99,\n", + " \"quantity\": 25\n", + " }\n", + " ]\n", + "}\n", + "\n", + "Rules:\n", + "- Include exactly 5 products\n", + "- Product name must never be empty\n", + "- Brand must never be empty\n", + "- Description must never be empty\n", + "- Use realistic toy store categories like puzzles, dolls, action figures, board games, educational toys, building blocks, plush toys, remote control toys, outdoor toys\n", + "- price must be a float greater than 0\n", + "- quantity must be an integer greater than or equal to 0\n", + "- do not include markdown\n", + "- output valid JSON only\n", + "\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "f51fe41c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\"products\":[{\"name\":\"Starlight Princess Doll\",\"description\":\"A beautiful doll with shimmering gown and magical accessories, perfect for imaginative play.\",\"category\":\"Dolls\",\"brand\":\"Dreamland Toys\",\"price\":29.99,\"quantity\":50},{\"name\":\"Galactic Warrior Action Figure\",\"description\":\"Highly detailed action figure with multiple points of articulation and interchangeable weapons for epic space battles.\",\"category\":\"Action Figures\",\"brand\":\"Cosmic Play\",\"price\":19.50,\"quantity\":75},{\"name\":\"Enchanted Forest Jigsaw Puzzle\",\"description\":\"A 1000-piece jigsaw puzzle featuring a vibrant illustration of a magical forest scene, challenging and fun for all ages.\",\"category\":\"Puzzles\",\"brand\":\"Mind Bender Puzzles\",\"price\":15.75,\"quantity\":30},{\"name\":\"Robo-Racer Remote Control Car\",\"description\":\"High-speed remote control car with durable design and responsive controls, ideal for indoor and outdoor racing.\",\"category\":\"Remote Control Toys\",\"brand\":\"Speedy Wheels\",\"price\":45.00,\"quantity\":20},{\"name\":\"Giant Plush Teddy Bear\",\"description\":\"An extra-large, super soft teddy bear, perfect for cuddling and a wonderful companion for children.\",\"category\":\"Plush Toys\",\"brand\":\"Cuddle Buddies\",\"price\":35.99,\"quantity\":40}]}\n" + ] + } + ], + "source": [ + "response = client.models.generate_content(\n", + " model=\"gemini-2.5-flash\",\n", + " contents=prompt,\n", + " config={\n", + " \"temperature\": 0.3,\n", + " \"response_mime_type\": \"application/json\",\n", + " \"response_schema\": ProductListSchema,\n", + " }\n", + ")\n", + "\n", + "print(response.text[:2000])" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d120b2b2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Validated product count: 5\n" + ] + } + ], + "source": [ + "validated_products = ProductListSchema.model_validate_json(response.text)\n", + "print(\"Validated product count:\", len(validated_products.products))" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "7d73e424", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'name': 'Starlight Princess Doll',\n", + " 'description': 'A beautiful doll with shimmering gown and magical accessories, perfect for imaginative play.',\n", + " 'category': 'Dolls',\n", + " 'brand': 'Dreamland Toys',\n", + " 'price': 29.99,\n", + " 'quantity': 50},\n", + " {'name': 'Galactic Warrior Action Figure',\n", + " 'description': 'Highly detailed action figure with multiple points of articulation and interchangeable weapons for epic space battles.',\n", + " 'category': 'Action Figures',\n", + " 'brand': 'Cosmic Play',\n", + " 'price': 19.5,\n", + " 'quantity': 75},\n", + " {'name': 'Enchanted Forest Jigsaw Puzzle',\n", + " 'description': 'A 1000-piece jigsaw puzzle featuring a vibrant illustration of a magical forest scene, challenging and fun for all ages.',\n", + " 'category': 'Puzzles',\n", + " 'brand': 'Mind Bender Puzzles',\n", + " 'price': 15.75,\n", + " 'quantity': 30},\n", + " {'name': 'Robo-Racer Remote Control Car',\n", + " 'description': 'High-speed remote control car with durable design and responsive controls, ideal for indoor and outdoor racing.',\n", + " 'category': 'Remote Control Toys',\n", + " 'brand': 'Speedy Wheels',\n", + " 'price': 45.0,\n", + " 'quantity': 20},\n", + " {'name': 'Giant Plush Teddy Bear',\n", + " 'description': 'An extra-large, super soft teddy bear, perfect for cuddling and a wonderful companion for children.',\n", + " 'category': 'Plush Toys',\n", + " 'brand': 'Cuddle Buddies',\n", + " 'price': 35.99,\n", + " 'quantity': 40}]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "product_preview = [product.model_dump() for product in validated_products.products[:5]]\n", + "product_preview" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "4eb0cd0d", + "metadata": {}, + "outputs": [], + "source": [ + "def get_or_create_category(category_title):\n", + " cat_title = category_title.strip()\n", + "\n", + " if not cat_title:\n", + " cat_title = \"Miscellaneous\"\n", + "\n", + " category = ProductCategory.objects(title=cat_title).first()\n", + " if category:\n", + " return category\n", + "\n", + " category = ProductCategory(\n", + " title=cat_title,\n", + " description=f\"Auto-created from Gemini synthetic inventory data for {cat_title}\"\n", + " )\n", + " category.save()\n", + " return category" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "4029f01a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saved products: 5\n" + ] + } + ], + "source": [ + "saved_count = 0\n", + "\n", + "for item in validated_products.products:\n", + " new_name = item.name.strip()\n", + " new_brand = item.brand.strip()\n", + " new_description = item.description.strip()\n", + " new_category = item.category.strip()\n", + " new_price = item.price\n", + " new_quantity = item.quantity\n", + " if not new_name:\n", + " print(\"Skipped one product because name was empty after stripping.\")\n", + " continue\n", + "\n", + " if not new_brand:\n", + " print(f\"Skipped product '{new_name}' because brand was empty after stripping.\")\n", + " continue\n", + "\n", + " if not new_description:\n", + " print(f\"Skipped product '{new_name}' because description was empty after stripping.\")\n", + " continue\n", + "\n", + " if new_price is None or new_price <= 0:\n", + " print(f\"Skipped product '{new_name}' because price was invalid: {new_price}\")\n", + " continue\n", + "\n", + " if new_quantity is None or new_quantity < 0:\n", + " print(f\"Skipped product '{new_name}' because quantity was invalid: {new_quantity}\")\n", + " continue\n", + "\n", + " category_obj = get_or_create_category(new_category)\n", + "\n", + " product = Product(\n", + " name=new_name,\n", + " description=new_description,\n", + " price=Decimal(str(item.price)),\n", + " brand=new_brand,\n", + " quantity=int(item.quantity),\n", + " category=category_obj\n", + " )\n", + " product.save()\n", + " saved_count += 1\n", + "\n", + "print(\"Saved products:\", saved_count)" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "1fb53d7e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exported validated products to week6/generated_products.json\n" + ] + } + ], + "source": [ + "import os\n", + "os.makedirs(\"week6\", exist_ok=True)\n", + "\n", + "with open(\"week6/generated_products.json\", \"w\", encoding=\"utf-8\") as file:\n", + " json.dump(\n", + " {\"products\": [product.model_dump() for product in validated_products.products]},\n", + " file,\n", + " indent=2\n", + " )\n", + "\n", + "print(\"Exported validated products to week6/generated_products.json\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.12.7)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/backend/python/week6/task_1.py b/backend/python/week6/task_1.py new file mode 100644 index 000000000..69b8c1448 --- /dev/null +++ b/backend/python/week6/task_1.py @@ -0,0 +1,66 @@ +import os +from dotenv import load_dotenv +from google import genai + +load_dotenv() + +client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + + +# official gemini 2.5 flash prices +INPUT_PRICE_PER_1M = 0.30 +OUTPUT_PRICE_PER_1M = 2.50 + + +def calculate_cost(prompt_tokens, total_tokens): + output_tokens = total_tokens - prompt_tokens + inp_cost = (prompt_tokens/1000000)*INPUT_PRICE_PER_1M + out_cost = (output_tokens/1000000)*OUTPUT_PRICE_PER_1M + tot_cost = inp_cost + out_cost + return inp_cost, out_cost, tot_cost, output_tokens + + +def run_prompt(temperature): + response = client.models.generate_content( + model="gemini-2.5-flash", + contents="Generate 5 product names for a toy store. Return only a numbered list.", + # extra settings for temperature + config={ + "temperature": temperature + } + ) + print(f"\n===TEMPERATURE: {temperature}===") + print("\nTEXT RESPONSE:") + print(response.text) + print("\nFULL RESPONSE:") + print(response) + # metadata : data about data + usage = response.usage_metadata + # number of tokens in input prompt + prompt_tokens = getattr(usage, "prompt_token_count", 0) + # total tokens used in whole request + total_tokens = getattr(usage, "total_token_count", 0) + # number of tokens in visible response text + candidate_tokens = getattr(usage, "candidates_token_count", 0) + # number of tokens used in thinking and internal reasoning + thoughts_tokens = getattr(usage, "thoughts_token_count", 0) + print("\nTOKEN USAGE:") + print("Prompt tokens:", prompt_tokens) + print("Candidate tokens:", candidate_tokens) + print("Thoughts tokens:", thoughts_tokens) + print("Total tokens:", total_tokens) + inp_cost, out_cost, tot_cost, output_tokens = calculate_cost( + prompt_tokens, total_tokens) + print("\nCOST BREAKDOWN:") + print("Output tokens:", output_tokens) + print(f"Input cost: ${inp_cost:.8f}") + print(f"Output cost: ${out_cost:.8f}") + print(f"Total cost: ${tot_cost:.8f}") + + +# temperature controls randomness in token selection +# low temperature => less randomness => more stable outcomes +run_prompt(0.0) +run_prompt(0.5) +run_prompt(1) +run_prompt(1.5) diff --git a/backend/python/week6/task_2.py b/backend/python/week6/task_2.py new file mode 100644 index 000000000..b0df13c06 --- /dev/null +++ b/backend/python/week6/task_2.py @@ -0,0 +1,147 @@ +import os +import json +from decimal import Decimal +from dotenv import load_dotenv +from google import genai +from week4.db_connection import initialize_mongo +from week4.models import Product, ProductCategory +from week6.schema import ProductListSchema + +load_dotenv() +initialize_mongo() + +client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + + +# creating category if it does not exist +def get_create_category(category_title): + cat_title = category_title.strip() + if not cat_title: + cat_title = "Miscellaneous" + category = ProductCategory.objects(title=cat_title).first() + if category: + return category + category = ProductCategory( + title=cat_title, + description=f"Auto-created from Gemini synthetic inventory data for {cat_title}" + ) + category.save() + return category + + +# creating products with gemini +def generate_products_with_gemini(): + prompt = """ +Generate exactly 5 products for a toy store. + +Return valid JSON only in this structure: +{ + "products": [ + { + "name": "string", + "description": "string", + "category": "string", + "brand": "string", + "price": 10.99, + "quantity": 25 + } + ] +} + +Rules: +- Include exactly 5 products +- Product name must never be empty +- Brand must never be empty +- Description must never be empty +- Use realistic toy store categories like puzzles, dolls, action figures, board games, educational toys, building blocks, plush toys, remote control toys, outdoor toys +- price must be a float greater than 0 +- quantity must be an integer greater than or equal to 0 +- do not include markdown +- output valid JSON only +""" + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.3, + "response_mime_type": "application/json", + "response_schema": ProductListSchema, + } + ) + print("\nRAW GEMINI RESPONSE:\n") + print(response.text) + validated_data = ProductListSchema.model_validate_json(response.text) + print(f"\nValidated product count: {len(validated_data.products)}") + return validated_data + + +# exporting products as json +def export_product_to_json(validated_data, filepath: str = "week6/generated_products.json"): + product_as_dicts = [] + for product in validated_data.products: + # .model_dump converts each product into dictionary (from pydantic form) + product_dict = product.model_dump() + product_as_dicts.append(product_dict) + with open(filepath, "w", encoding="utf-8") as file: + json.dump({"products": product_as_dicts}, file, indent=2) + print(f"\nValidated products exported to {filepath}") + + +# saving products to week4 db +def save_products_to_week4(validated_data): + saved_count = 0 + + for item in validated_data.products: + new_name = item.name.strip() + new_brand = item.brand.strip() + new_description = item.description.strip() + new_category = item.category.strip() + new_price = item.price + new_quantity = item.quantity + + if not new_name: + print("Skipped one product because name was empty after stripping.") + continue + + if not new_brand: + print( + f"Skipped product '{new_name}' because brand was empty after stripping.") + continue + + if not new_description: + print( + f"Skipped product '{new_name}' because description was empty after stripping.") + continue + + if new_price is None or new_price <= 0: + print( + f"Skipped product '{new_name}' because price was invalid: {new_price}") + continue + + if new_quantity is None or new_quantity < 0: + print( + f"Skipped product '{new_name}' because quantity was invalid: {new_quantity}") + continue + + category_obj = get_create_category(new_category) + + product = Product( + name=new_name, + description=new_description, + price=Decimal(str(new_price)), + brand=new_brand, + quantity=int(new_quantity), + category=category_obj, + ) + product.save() + saved_count += 1 + + return saved_count + + +# run the block only if file is executed directly +if __name__ == "__main__": + validated_data = generate_products_with_gemini() + export_product_to_json(validated_data) + saved = save_products_to_week4(validated_data) + print(f"\nSaved {saved} products into Week 4 MongoDB collection.") diff --git a/backend/python/week6/task_4.py b/backend/python/week6/task_4.py new file mode 100644 index 000000000..4180a862b --- /dev/null +++ b/backend/python/week6/task_4.py @@ -0,0 +1,76 @@ +import os +import json +from dotenv import load_dotenv +from google import genai +from week6.schema import FutureStockEventListSchema + +load_dotenv() + +client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + + +def generate_future_stock_events(): + prompt = """ +Generate exactly 10 future stock events for a toy store. + +Return valid JSON only in this structure: +{ + "events": [ + { + "title": "string", + "event_type": "string", + "expected_date": "YYYY-MM-DD", + "product_name": "string", + "quantity_change": 10, + "note": "string" + } + ] +} + +Rules: +- Include exactly 10 events +- All dates must be future dates +- Keep events realistic for toy inventory +- event_type can be: + incoming_shipment, + seasonal_spike, + low_stock_warning, + preorder_arrival, + supplier_delay, + warehouse_transfer +- title must not be empty +- product_name must not be empty +- quantity_change must be an integer +- output valid JSON only +- do not include markdown +""" + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.3, + "response_mime_type": "application/json", + "response_schema": FutureStockEventListSchema, + } + ) + print("\nRAW GEMINI RESPONSE:\n") + print(response.text) + validated_events = FutureStockEventListSchema.model_validate_json( + response.text) + print(f"\nValidated event count: {len(validated_events.events)}") + return validated_events + + +def export_events_to_json(validated_events, filepath: str = "week6/future_stock_events.json"): + events_as_dicts = [] + for event in validated_events.events: + event_dict = event.model_dump() + events_as_dicts.append(event_dict) + with open(filepath, "w", encoding="utf-8") as file: + json.dump({"events": events_as_dicts}, file, indent=2) + print(f"\nValidated events exported to {filepath}") + + +if __name__ == "__main__": + validated_events = generate_future_stock_events() + export_events_to_json(validated_events) diff --git a/backend/python/week6/test_gemini.py b/backend/python/week6/test_gemini.py new file mode 100644 index 000000000..ee3327d82 --- /dev/null +++ b/backend/python/week6/test_gemini.py @@ -0,0 +1,14 @@ +import os +from dotenv import load_dotenv +from google import genai + +load_dotenv() + +client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + +response = client.models.generate_content( + model="gemini-2.5-flash", + contents="Say hello in one short line." +) + +print(response.text) diff --git a/backend/python/week6/week6/generated_products.json b/backend/python/week6/week6/generated_products.json new file mode 100644 index 000000000..9524a632b --- /dev/null +++ b/backend/python/week6/week6/generated_products.json @@ -0,0 +1,44 @@ +{ + "products": [ + { + "name": "Starlight Princess Doll", + "description": "A beautiful doll with shimmering gown and magical accessories, perfect for imaginative play.", + "category": "Dolls", + "brand": "Dreamland Toys", + "price": 29.99, + "quantity": 50 + }, + { + "name": "Galactic Warrior Action Figure", + "description": "Highly detailed action figure with multiple points of articulation and interchangeable weapons for epic space battles.", + "category": "Action Figures", + "brand": "Cosmic Play", + "price": 19.5, + "quantity": 75 + }, + { + "name": "Enchanted Forest Jigsaw Puzzle", + "description": "A 1000-piece jigsaw puzzle featuring a vibrant illustration of a magical forest scene, challenging and fun for all ages.", + "category": "Puzzles", + "brand": "Mind Bender Puzzles", + "price": 15.75, + "quantity": 30 + }, + { + "name": "Robo-Racer Remote Control Car", + "description": "High-speed remote control car with durable design and responsive controls, ideal for indoor and outdoor racing.", + "category": "Remote Control Toys", + "brand": "Speedy Wheels", + "price": 45.0, + "quantity": 20 + }, + { + "name": "Giant Plush Teddy Bear", + "description": "An extra-large, super soft teddy bear, perfect for cuddling and a wonderful companion for children.", + "category": "Plush Toys", + "brand": "Cuddle Buddies", + "price": 35.99, + "quantity": 40 + } + ] +} \ No newline at end of file diff --git a/backend/python/week7/Figure_1.png b/backend/python/week7/Figure_1.png new file mode 100644 index 000000000..5b62fa1e3 Binary files /dev/null and b/backend/python/week7/Figure_1.png differ diff --git a/backend/python/week7/__init__.py b/backend/python/week7/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week7/comparing_models.py b/backend/python/week7/comparing_models.py new file mode 100644 index 000000000..e894bcc8b --- /dev/null +++ b/backend/python/week7/comparing_models.py @@ -0,0 +1,692 @@ +import time +from week7.semantic_search import semantic_search + +GENERAL_TEST_QUERIES = [ + "construction toys", + "gifts for toddlers", + "pretend play toys", + "science and learning toys", + "soft cuddly toys", +] + +MANUAL_RATING_QUERY = "toys for 5-year-olds" + + +def print_result_for_model(model_name, query, top_k=5): + start_time = time.time() + + results = semantic_search( + query=query, + top_k=top_k, + model_name=model_name, + ) + + end_time = time.time() + elapsed_time = end_time - start_time + + print("\n" + "=" * 50) + print(f"MODEL: {model_name}") + print(f"QUERY: {query}") + print(f"TIME : {elapsed_time:.4f} seconds") + print("=" * 50) + + if not results: + print("No results found") + return results, elapsed_time + + print("Top Results:") + print("-" * 50) + + for rank, item in enumerate(results, start=1): + print(f"{rank}. {item['name']}") + print(f" Score : {item['semantic_score']:.4f}") + print(f" Description: {item['description']}") + print(f" Category : {item['category']}") + print(f" Brand : {item['brand']}") + print(f" Price : {item['price']}") + print(f" Quantity : {item['quantity']}") + print() + + return results, elapsed_time + + +def general_comparision(): + model_name = [ + "all-MiniLM-L6-v2", + "all-mpnet-base-v2", + ] + top_k = 5 + for model_name in model_name: + print("\n" + "#"*50) + print(f"MODEL: {model_name}") + print("#"*50) + total_time = 0.0 + for query in GENERAL_TEST_QUERIES: + results, elapsed_time = print_result_for_model( + model_name=model_name, + query=query, + top_k=top_k + ) + total_time += elapsed_time + print(f"Total Time for {model_name}: {total_time:.4f} seconds") + + +def manual_comparision(): + model_name = [ + "all-MiniLM-L6-v2", + "all-mpnet-base-v2", + ] + top_k = 5 + print("\n" + "#"*50) + print("Manual Rating Query") + print("#"*50) + for model_name in model_name: + result, elapsed_time = print_result_for_model( + model_name=model_name, + query=MANUAL_RATING_QUERY, + top_k=top_k + ) + if not result: + print("NO RESULTS FOUND") + continue + print(f"Elapsed Time for {model_name}: {elapsed_time:.4f} seconds") + + +if __name__ == "__main__": + general_comparision() + manual_comparision() + + +# ================================================================================= +# Output +# ================================================================================= + +# ################################################## +# MODEL: all-MiniLM-L6-v2 +# ################################################## +# == == == == == == == == == == == == == == == == == == == == == == == == == +# MODEL: all-MiniLM-L6-v2 +# QUERY: construction toys +# TIME: 6.4946 seconds +# == == == == == == == == == == == == == == == == == == == == == == == == == +# Top Results: +# -------------------------------------------------- +# 1. Geometry Shape Sorter Toy +# Score: 0.4708 +# Description: A classic wooden toy that helps children learn about different shapes and develop fine motor skills. +# Category: Early Learning +# Brand: ShapeUp +# Price: 18.75 +# Quantity: 95 + +# 2. Mini Robot Construction Kit +# Score: 0.4696 +# Description: Build your own small, functional robot with this engaging construction kit, featuring easy-to-follow instructions. +# Category: building blocks +# Brand: RoboBuild +# Price: 29.5 +# Quantity: 120 + +# 3. Lego Castle +# Score: 0.4670 +# Description: brick set for building a castle with towers and walls for kids +# Category: building blocks +# Brand: LEGO +# Price: 499.0 +# Quantity: 8 + +# 4. Kids Soccer Goal Set +# Score: 0.4580 +# Description: A portable soccer goal set, easy to assemble and perfect for practicing soccer skills in the yard. +# Category: outdoor toys +# Brand: Sporty Kids +# Price: 35.0 +# Quantity: 70 + +# 5. Sand Play Set with Molds +# Score: 0.4563 +# Description: A complete sand play set including buckets, shovels, and various molds for creative sandcastles. +# Category: outdoor toys +# Brand: Beach Builders +# Price: 16.0 +# Quantity: 130 + + +# == == == == == == == == == == == == == == == == == == == == == == == == == +# MODEL: all-MiniLM-L6-v2 +# QUERY: gifts for toddlers +# TIME: 0.5178 seconds +# == == == == == == == == == == == == == == == == == == == == == == == == == +# Top Results: +# -------------------------------------------------- +# 1. Backpack & School Accessories for Dolls +# Score: 0.4935 +# Description: A cute set of miniature school accessories including a backpack, books, and pretend lunch for 18-inch dolls. +# Category: Dolls & Accessories +# Brand: DollieDreams +# Price: 15.25 +# Quantity: 110 + +# 2. Mini Animal Plush Assortment +# Score: 0.4768 +# Description: A collection of small, cute plush animals, perfect for party favors or collecting. +# Category: plush toys +# Brand: Tiny Treasures +# Price: 9.99 +# Quantity: 200 + +# 3. Alphabet Learning Puzzle +# Score: 0.4707 +# Description: A wooden peg puzzle designed to help toddlers learn letters and improve fine motor skills. +# Category: puzzles +# Brand: SmartStart Toys +# Price: 12.25 +# Quantity: 150 + +# 4. Kids Soccer Goal Set +# Score: 0.4578 +# Description: A portable soccer goal set, easy to assemble and perfect for practicing soccer skills in the yard. +# Category: outdoor toys +# Brand: Sporty Kids +# Price: 35.0 +# Quantity: 70 + +# 5. Kids First Microscope Kit +# Score: 0.4465 +# Description: An easy-to-use microscope designed for children to explore the micro-world, includes slides and tools. +# Category: educational toys +# Brand: Science Explorers +# Price: 39.99 +# Quantity: 70 + + +# == == == == == == == == == == == == == == == == == == == == == == == == == +# MODEL: all-MiniLM-L6-v2 +# QUERY: pretend play toys +# TIME: 0.5558 seconds +# == == == == == == == == == == == == == == == == == == == == == == == == == +# Top Results: +# -------------------------------------------------- +# 1. Teacher Role Play Set +# Score: 0.5873 +# Description: A fun costume and accessory set for kids to pretend play as a teacher, complete with glasses, pointer, and chalk. +# Category: Role Play +# Brand: PretendPro +# Price: 28.0 +# Quantity: 65 + +# 2. Play-Doh School Days Set +# Score: 0.4818 +# Description: A themed Play-Doh set with molds and tools to create school-related objects like pencils, books, and apples. +# Category: Creative Play +# Brand: DohFun +# Price: 16.99 +# Quantity: 115 + +# 3. Lunchbox & Thermos Pretend Play Set +# Score: 0.4449 +# Description: A realistic play set featuring a lunchbox, thermos, and pretend food items for imaginative mealtime scenarios. +# Category: Role Play +# Brand: KitchenKids +# Price: 13.5 +# Quantity: 125 + +# 4. Sand Play Set with Molds +# Score: 0.4253 +# Description: A complete sand play set including buckets, shovels, and various molds for creative sandcastles. +# Category: outdoor toys +# Brand: Beach Builders +# Price: 16.0 +# Quantity: 130 + +# 5. Doctor Play Doll Set +# Score: 0.4177 +# Description: An articulated doll dressed as a doctor, complete with medical tools for role-playing and learning. +# Category: dolls +# Brand: Career Dolls +# Price: 24.5 +# Quantity: 110 + + +# == == == == == == == == == == == == == == == == == == == == == == == == == +# MODEL: all-MiniLM-L6-v2 +# QUERY: science and learning toys +# TIME: 0.5539 seconds +# == == == == == == == == == == == == == == == == == == == == == == == == == +# Top Results: +# -------------------------------------------------- +# 1. Educational Robot Kit +# Score: 0.5905 +# Description: A fun and interactive robot kit designed to introduce kids to basic coding and STEM principles, perfect for back-to-school learning. +# Category: Educational Toys +# Brand: RoboKids +# Price: 49.99 +# Quantity: 75 + +# 2. Kids' Learning Tablet Toy +# Score : 0.5537 +# Description: An interactive electronic tablet with educational games and activities for letters, numbers, and shapes. +# Category : Electronic Learning +# Brand : SmartStart +# Price : 32.0 +# Quantity : 70 + +# 3. Science Experiment Lab Kit +# Score : 0.5438 +# Description: An exciting kit with safe experiments for kids to explore chemistry and physics concepts at home. +# Category : Science Kits +# Brand : LabWonders +# Price : 39.0 +# Quantity : 60 + +# 4. Geometry Shape Sorter Toy +# Score : 0.5408 +# Description: A classic wooden toy that helps children learn about different shapes and develop fine motor skills. +# Category : Early Learning +# Brand : ShapeUp +# Price : 18.75 +# Quantity : 95 + +# 5. Kids First Microscope Kit +# Score : 0.5295 +# Description: An easy-to-use microscope designed for children to explore the micro-world, includes slides and tools. +# Category : educational toys +# Brand : Science Explorers +# Price : 39.99 +# Quantity : 70 + + +# ================================================== +# MODEL: all-MiniLM-L6-v2 +# QUERY: soft cuddly toys +# TIME : 0.5704 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Baby Cuddle Doll +# Score : 0.6768 +# Description: A soft-bodied baby doll perfect for cuddling and nurturing play, comes with a pacifier and blanket. +# Category : dolls +# Brand : Sweet Dreams Babies +# Price : 15.75 +# Quantity : 130 + +# 2. Giant Plush Teddy Bear +# Score : 0.6066 +# Description: An extra-large, super soft teddy bear, perfect for cuddling and a wonderful companion for children. +# Category : Plush Toys +# Brand : Cuddle Buddies +# Price : 35.99 +# Quantity : 40 + +# 3. Snuggle Buddy Teddy Bear +# Score : 0.6034 +# Description: Super soft and cuddly teddy bear, perfect for hugs and comfort. +# Category : Plush Toys +# Brand : CuddleCo +# Price : 15.5 +# Quantity : 75 + +# 4. Puppy Dog Plush +# Score : 0.5862 +# Description: An adorable plush puppy dog with floppy ears and a wagging tail, realistic and huggable. +# Category : plush toys +# Brand : Pet Pals +# Price : 18.0 +# Quantity : 120 + +# 5. Giant Teddy Bear +# Score : 0.5728 +# Description: An extra-large, super soft teddy bear, perfect for big hugs and comforting companionship. +# Category : plush toys +# Brand : Cuddle Buddies +# Price : 45.0 +# Quantity : 65 + +# Total Time for all-MiniLM-L6-v2: 8.6925 seconds + +# ################################################## +# MODEL: all-mpnet-base-v2 +# ################################################## +# ================================================== +# MODEL: all-mpnet-base-v2 +# QUERY: construction toys +# TIME : 49.8138 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Mini Robot Construction Kit +# Score : 0.6087 +# Description: Build your own small, functional robot with this engaging construction kit, featuring easy-to-follow instructions. +# Category : building blocks +# Brand : RoboBuild +# Price : 29.5 +# Quantity : 120 + +# 2. Play-Doh School Days Set +# Score : 0.5336 +# Description: A themed Play-Doh set with molds and tools to create school-related objects like pencils, books, and apples. +# Category : Creative Play +# Brand : DohFun +# Price : 16.99 +# Quantity : 115 + +# 3. School Bus Building Blocks Set +# Score : 0.5230 +# Description: A large building block set allowing children to construct their own school bus and other school-themed structures. +# Category : Building Blocks +# Brand : BrickMaster +# Price : 35.75 +# Quantity : 90 + +# 4. Educational Robot Kit +# Score : 0.4986 +# Description: A fun and interactive robot kit designed to introduce kids to basic coding and STEM principles, perfect for back-to-school learning. +# Category : Educational Toys +# Brand : RoboKids +# Price : 49.99 +# Quantity : 75 + +# 5. Deluxe City Building Blocks Set +# Score : 0.4981 +# Description: A large set of colorful interlocking blocks for creative construction, perfect for developing motor skills and imagination. +# Category : building blocks +# Brand : BlockMaster +# Price : 49.99 +# Quantity : 75 + + +# ================================================== +# MODEL: all-mpnet-base-v2 +# QUERY: gifts for toddlers +# TIME : 3.1088 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Kids' Learning Tablet Toy +# Score : 0.4435 +# Description: An interactive electronic tablet with educational games and activities for letters, numbers, and shapes. +# Category : Electronic Learning +# Brand : SmartStart +# Price : 32.0 +# Quantity : 70 + +# 2. Alphabet Learning Puzzle +# Score : 0.4324 +# Description: A wooden peg puzzle designed to help toddlers learn letters and improve fine motor skills. +# Category : puzzles +# Brand : SmartStart Toys +# Price : 12.25 +# Quantity : 150 + +# 3. Kids First Microscope Kit +# Score : 0.4260 +# Description: An easy-to-use microscope designed for children to explore the micro-world, includes slides and tools. +# Category : educational toys +# Brand : Science Explorers +# Price : 39.99 +# Quantity : 70 + +# 4. Mini Animal Plush Assortment +# Score : 0.4192 +# Description: A collection of small, cute plush animals, perfect for party favors or collecting. +# Category : plush toys +# Brand : Tiny Treasures +# Price : 9.99 +# Quantity : 200 + +# 5. Backpack & School Accessories for Dolls +# Score : 0.4050 +# Description: A cute set of miniature school accessories including a backpack, books, and pretend lunch for 18-inch dolls. +# Category : Dolls & Accessories +# Brand : DollieDreams +# Price : 15.25 +# Quantity : 110 + + +# ================================================== +# MODEL: all-mpnet-base-v2 +# QUERY: pretend play toys +# TIME : 3.3789 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Play-Doh School Days Set +# Score : 0.6218 +# Description: A themed Play-Doh set with molds and tools to create school-related objects like pencils, books, and apples. +# Category : Creative Play +# Brand : DohFun +# Price : 16.99 +# Quantity : 115 + +# 2. Teacher Role Play Set +# Score : 0.5782 +# Description: A fun costume and accessory set for kids to pretend play as a teacher, complete with glasses, pointer, and chalk. +# Category : Role Play +# Brand : PretendPro +# Price : 28.0 +# Quantity : 65 + +# 3. Backpack & School Accessories for Dolls +# Score : 0.5737 +# Description: A cute set of miniature school accessories including a backpack, books, and pretend lunch for 18-inch dolls. +# Category : Dolls & Accessories +# Brand : DollieDreams +# Price : 15.25 +# Quantity : 110 + +# 4. Doctor Play Doll Set +# Score : 0.5435 +# Description: An articulated doll dressed as a doctor, complete with medical tools for role-playing and learning. +# Category : dolls +# Brand : Career Dolls +# Price : 24.5 +# Quantity : 110 + +# 5. Lunchbox & Thermos Pretend Play Set +# Score : 0.5394 +# Description: A realistic play set featuring a lunchbox, thermos, and pretend food items for imaginative mealtime scenarios. +# Category : Role Play +# Brand : KitchenKids +# Price : 13.5 +# Quantity : 125 + + +# ================================================== +# MODEL: all-mpnet-base-v2 +# QUERY: science and learning toys +# TIME : 3.0360 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Educational Robot Kit +# Score : 0.6326 +# Description: A fun and interactive robot kit designed to introduce kids to basic coding and STEM principles, perfect for back-to-school learning. +# Category : Educational Toys +# Brand : RoboKids +# Price : 49.99 +# Quantity : 75 + +# 2. Science Experiment Lab Kit +# Score : 0.6261 +# Description: An exciting kit with safe experiments for kids to explore chemistry and physics concepts at home. +# Category : Science Kits +# Brand : LabWonders +# Price : 39.0 +# Quantity : 60 + +# 3. Kids' Learning Tablet Toy +# Score : 0.6214 +# Description: An interactive electronic tablet with educational games and activities for letters, numbers, and shapes. +# Category : Electronic Learning +# Brand : SmartStart +# Price : 32.0 +# Quantity : 70 + +# 4. Kids' First Microscope Kit +# Score : 0.5826 +# Description: An easy-to-use microscope designed for young scientists to explore the microscopic world. +# Category : Science Kits +# Brand : MicroDiscovery +# Price : 29.95 +# Quantity : 70 + +# 5. Kids First Microscope Kit +# Score : 0.5676 +# Description: An easy-to-use microscope designed for children to explore the micro-world, includes slides and tools. +# Category : educational toys +# Brand : Science Explorers +# Price : 39.99 +# Quantity : 70 + + +# ================================================== +# MODEL: all-mpnet-base-v2 +# QUERY: soft cuddly toys +# TIME : 2.9562 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Baby Cuddle Doll +# Score : 0.7015 +# Description: A soft-bodied baby doll perfect for cuddling and nurturing play, comes with a pacifier and blanket. +# Category : dolls +# Brand : Sweet Dreams Babies +# Price : 15.75 +# Quantity : 130 + +# 2. Snuggle Buddy Teddy Bear +# Score : 0.6119 +# Description: Super soft and cuddly teddy bear, perfect for hugs and comfort. +# Category : Plush Toys +# Brand : CuddleCo +# Price : 15.5 +# Quantity : 75 + +# 3. Mini Animal Plush Assortment +# Score : 0.5654 +# Description: A collection of small, cute plush animals, perfect for party favors or collecting. +# Category : plush toys +# Brand : Tiny Treasures +# Price : 9.99 +# Quantity : 200 + +# 4. Giant Plush Teddy Bear +# Score : 0.5555 +# Description: An extra-large, super soft teddy bear, perfect for cuddling and a wonderful companion for children. +# Category : Plush Toys +# Brand : Cuddle Buddies +# Price : 35.99 +# Quantity : 40 + +# 5. Backpack & School Accessories for Dolls +# Score : 0.5008 +# Description: A cute set of miniature school accessories including a backpack, books, and pretend lunch for 18-inch dolls. +# Category : Dolls & Accessories +# Brand : DollieDreams +# Price : 15.25 +# Quantity : 110 + +# Total Time for all-mpnet-base-v2: 62.2937 seconds + +# ################################################## +# Manual Rating Query +# ################################################## + +# ================================================== +# MODEL: all-MiniLM-L6-v2 +# QUERY: toys for 5-year-olds +# TIME : 0.6343 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Geometry Shape Sorter Toy +# Score : 0.5365 +# Description: A classic wooden toy that helps children learn about different shapes and develop fine motor skills. +# Category : Early Learning +# Brand : ShapeUp +# Price : 18.75 +# Quantity : 95 + +# 2. Kids' Learning Tablet Toy +# Score : 0.5357 +# Description: An interactive electronic tablet with educational games and activities for letters, numbers, and shapes. +# Category : Electronic Learning +# Brand : SmartStart +# Price : 32.0 +# Quantity : 70 + +# 3. Backpack & School Accessories for Dolls +# Score : 0.5091 +# Description: A cute set of miniature school accessories including a backpack, books, and pretend lunch for 18-inch dolls. +# Category : Dolls & Accessories +# Brand : DollieDreams +# Price : 15.25 +# Quantity : 110 + +# 4. Kids Soccer Goal Set +# Score : 0.5017 +# Description: A portable soccer goal set, easy to assemble and perfect for practicing soccer skills in the yard. +# Category : outdoor toys +# Brand : Sporty Kids +# Price : 35.0 +# Quantity : 70 + +# 5. Kids First Microscope Kit +# Score : 0.5002 +# Description: An easy-to-use microscope designed for children to explore the micro-world, includes slides and tools. +# Category : educational toys +# Brand : Science Explorers +# Price : 39.99 +# Quantity : 70 + +# Elapsed Time for all-MiniLM-L6-v2: 0.6343 seconds + +# ================================================== +# MODEL: all-mpnet-base-v2 +# QUERY: toys for 5-year-olds +# TIME : 2.9731 seconds +# ================================================== +# Top Results: +# -------------------------------------------------- +# 1. Play-Doh School Days Set +# Score : 0.4717 +# Description: A themed Play-Doh set with molds and tools to create school-related objects like pencils, books, and apples. +# Category : Creative Play +# Brand : DohFun +# Price : 16.99 +# Quantity : 115 + +# 2. Kids' Learning Tablet Toy +# Score : 0.4425 +# Description: An interactive electronic tablet with educational games and activities for letters, numbers, and shapes. +# Category : Electronic Learning +# Brand : SmartStart +# Price : 32.0 +# Quantity : 70 + +# 3. Sand Play Set with Molds +# Score : 0.4365 +# Description: A complete sand play set including buckets, shovels, and various molds for creative sandcastles. +# Category : outdoor toys +# Brand : Beach Builders +# Price : 16.0 +# Quantity : 130 + +# 4. Hula Hoop Assortment +# Score : 0.4302 +# Description: Colorful hula hoops in various sizes, promoting active play and coordination. +# Category : outdoor toys +# Brand : Hoop Stars +# Price : 7.99 +# Quantity : 220 + +# 5. Backpack & School Accessories for Dolls +# Score : 0.4293 +# Description: A cute set of miniature school accessories including a backpack, books, and pretend lunch for 18-inch dolls. +# Category : Dolls & Accessories +# Brand : DollieDreams +# Price : 15.25 +# Quantity : 110 + +# Elapsed Time for all-mpnet-base-v2: 2.9731 seconds diff --git a/backend/python/week7/dashboard.py b/backend/python/week7/dashboard.py new file mode 100644 index 000000000..5408c5353 --- /dev/null +++ b/backend/python/week7/dashboard.py @@ -0,0 +1,579 @@ +import os +from dotenv import load_dotenv +import streamlit as st +import pandas as pd +from week4.db_connection import initialize_mongo +from week4.models import Product, ProductCategory +from week6.schema import ProductListSchema, FutureStockEventListSchema +from decimal import Decimal +from google import genai +from week7.semantic_search import semantic_search, find_similar_products + + +load_dotenv() + + +initialize_mongo() + + +client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + +# page settings +st.set_page_config( + page_title="Week 5 + Week 6 + Week 7: Inventory Dashboard", layout="wide") +st.title("Week 5 + Week 6 + Week 7: Interactive Data Tools") +st.write("Streamlit dashboard connected to MongoDB using MongoEngine and Gemini") + + +# helpers +def get_all_categories(): + return list(ProductCategory.objects().order_by("title")) + + +def fetch_products(selected_category_id=None): + if selected_category_id is None: + products = Product.objects() + else: + products = Product.objects(category=selected_category_id) + + rows = [] + + for product in products: + rows.append({ + "ID": product.id, + "Name": product.name, + "Description": product.description, + "Price": float(product.price), + "Brand": product.brand, + "Quantity": product.quantity, + "Category": product.category.title if product.category else "No Category" + }) + + return pd.DataFrame(rows) + + +def get_or_create_category(category_title): + cat_title = (category_title or "").strip() + + if not cat_title: + cat_title = "Miscellaneous" + + category = ProductCategory.objects(title=cat_title).first() + if category: + return category + + category = ProductCategory( + title=cat_title, + description=f"Auto-created from AI generated data for {cat_title}", + ) + category.save() + return category + + +def product_already_exists(name, brand): + return Product.objects(name=name, brand=brand).first() is not None + + +def save_ai_products(validated_data): + saved_count = 0 + skipped_count = 0 + + for item in validated_data.products: + name = item.name.strip() + description = item.description.strip() + category_title = item.category.strip() + brand = item.brand.strip() + price = item.price + quantity = item.quantity + + if not name: + skipped_count += 1 + continue + + if not description: + skipped_count += 1 + continue + + if not brand: + skipped_count += 1 + continue + + if price is None or price <= 0: + skipped_count += 1 + continue + + if quantity is None or quantity < 0: + skipped_count += 1 + continue + + if product_already_exists(name, brand): + skipped_count += 1 + continue + + category_obj = get_or_create_category(category_title) + + product = Product( + name=name, + description=description, + price=Decimal(str(price)), + brand=brand, + quantity=int(quantity), + category=category_obj, + ).save() + saved_count += 1 + + return saved_count, skipped_count + + +def generate_products_from_ai(product_count=10, theme="general toy store"): + prompt = f""" +Generate exactly {product_count} products for a toy store. + +Theme: {theme} + +Return valid JSON only in this structure: +{{ + "products": [ + {{ + "name": "string", + "description": "string", + "category": "string", + "brand": "string", + "price": 10.99, + "quantity": 25 + }} + ] +}} + +Rules: +- Include exactly {product_count} products +- Product name must never be empty +- Brand must never be empty +- Description must never be empty +- Category must never be empty +- Use realistic toy store categories +- Match the requested theme where possible +- price must be a float greater than or equal to 0.01 +- quantity must be an integer greater than or equal to 0 +- do not include markdown +- output valid JSON only +""" + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.3, + "response_mime_type": "application/json", + "response_schema": ProductListSchema, + }, + ) + + validated = ProductListSchema.model_validate_json(response.text) + return validated, response.text + + +def generate_future_events(event_count=5, theme="general toy store"): + prompt = f""" +Generate exactly {event_count} future stock events for a toy store. + +Theme: {theme} + +Return valid JSON only in this structure: +{{ + "events": [ + {{ + "title": "string", + "event_type": "string", + "expected_date": "YYYY-MM-DD", + "product_name": "string", + "quantity_change": 10, + "note": "string" + }} + ] +}} + +Rules: +- Include exactly {event_count} events +- All dates must be future dates +- Keep events realistic for toy inventory +- Match the requested theme where possible +- event_type can be: incoming_shipment, seasonal_spike, low_stock_warning, preorder_arrival, supplier_delay, warehouse_transfer +- title must not be empty +- product_name must not be empty +- quantity_change must be an integer +- output valid JSON only +- do not include markdown +""" + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.6, + "response_mime_type": "application/json", + "response_schema": FutureStockEventListSchema, + }, + ) + + validated_data = FutureStockEventListSchema.model_validate_json( + response.text) + return validated_data, response.text + + +# performs tradional keyword search +def keyword_search_products(query, selected_category_id=None): + query = (query or "").strip().lower() + + if not query: + return [] + + if selected_category_id is None: + products = Product.objects() + else: + products = Product.objects(category=selected_category_id) + + results = [] + + for product in products: + searchable_text = " ".join([ + str(product.name or ""), + str(product.description or ""), + str(product.brand or ""), + str(product.category.title if product.category else ""), + ]).lower() + + if query in searchable_text: + results.append({ + "id": product.id, + "name": product.name, + "description": product.description, + "brand": product.brand, + "price": float(product.price), + "quantity": product.quantity, + "category": product.category.title if product.category else "No Category", + }) + + return results + + +# display one product nicely as card +def render_product_card(product_data, show_semantic_score=False, key_prefix="default"): + with st.container(border=True): + st.markdown(f"### {product_data['name']}") + st.write(product_data["description"]) + st.write(f"**Brand:** {product_data['brand']}") + st.write(f"**Category:** {product_data['category']}") + st.write(f"**Price:** {product_data['price']}") + st.write(f"**Quantity:** {product_data['quantity']}") + + if show_semantic_score and "semantic_score" in product_data: + st.write(f"**Semantic Score:** {product_data['semantic_score']}") + + button_key = f"{key_prefix}_similar_button_{product_data['id']}" + + if st.button("Find Similar Products", key=button_key): + similar_results = find_similar_products( + product_id=product_data["id"], + top_k=5, + model_name="all-MiniLM-L6-v2", + ) + + st.markdown("#### Similar Products") + + if not similar_results: + st.info("No similar products found.") + else: + for item in similar_results: + with st.container(border=True): + st.write(f"**Name:** {item['name']}") + st.write(f"**Description:** {item['description']}") + st.write(f"**Brand:** {item['brand']}") + st.write(f"**Category:** {item['category']}") + st.write(f"**Price:** {item['price']}") + st.write(f"**Quantity:** {item['quantity']}") + st.write( + f"**Similarity Score:** {item['semantic_score']}") + + +# creating sidebar category filter +st.sidebar.header("Filter Inventory") + +all_category = get_all_categories() +# creating dictionary where all maps to none so that no conflict arises +category_options = {"All": None} + +for category in all_category: + category_options[category.title] = category.id + +selected_category_title = st.sidebar.selectbox( + "Select Product Category", list(category_options.keys())) + +selected_category_id = category_options[selected_category_title] + + +# showing inventory table for current id + +@st.fragment +def inventory_table(category_id): + st.subheader("Current Inventory") + df = fetch_products(category_id) + if df.empty: + st.info("No products found in this category") + else: + st.dataframe(df, use_container_width=True) + return df + + +# showing stock alert + +@st.fragment +def stock_alert(df): + st.subheader("Stock Alert") + if not df.empty: + low_stock_row = [] + for index, row in df.iterrows(): + if row["Quantity"] < 5: + low_stock_row.append(row) + low_stock_df = pd.DataFrame(low_stock_row) + + if not low_stock_df.empty: + st.error("Some products are running low in stock") + st.dataframe(low_stock_df, use_container_width=True) + else: + st.success("No products are running low in stock") + else: + st.info("No products found in this category") + + +# adding product + +@st.fragment +def add_product(categories): + st.subheader("Add Product") + category_title = [] + for category in categories: + category_title.append(category.title) + + with st.form("add_product_form"): + new_name = st.text_input("Name") + new_description = st.text_area("Description") + new_price = st.number_input( + "Price", min_value=0.0, step=1.0, format="%.2f") + new_brand = st.text_input("Brand") + new_quantity = st.number_input( + "Quantity", min_value=0, step=1) + if category_title: + new_category_title = st.selectbox("Category", category_title) + else: + new_category_title = None + st.warning("No categories found. Please create a category first.") + submitted = st.form_submit_button("Add Product") + + if submitted: + if not new_name.strip(): + st.error("Product name cannot be empty") + elif not new_description.strip(): + st.error("Description cannot be empty") + elif not new_brand.strip(): + st.error("Brand cannot be empty") + elif not new_category_title: + st.error("No category exist. Pls create a category first in week 4") + else: + selected_category = ProductCategory.objects( + title=new_category_title).first() + Product(name=new_name, description=new_description, + price=Decimal(str(new_price)), brand=new_brand, quantity=int(new_quantity), category=selected_category).save() + st.success(f"Product '{new_name}' added successfully") + + +# removing product + +@st.fragment +def remove_product(): + st.subheader("Remove Product") + all_products = Product.objects().order_by("name") + product_options = {} + for product in all_products: + label = f"{product.name} (ID: {product.id})" + product_options[label] = product.id + + if product_options: + selected_product_label = st.selectbox( + "Select Product to remove", list(product_options.keys())) + if st.button("Remove Product"): + selected_product_id = product_options[selected_product_label] + Product.objects(id=selected_product_id).delete() + st.success("Product removed successfully") + else: + st.info("No products found") + + +# AI generator + +@st.fragment +def ai_scenario_generator(): + st.subheader("Week 6 Advanced: AI Scenario Generator") + + scenario_options = [ + "Holiday Rush", + "Summer Sale", + "Back to School", + "New Year Restock", + "Outdoor Play Season", + "Educational Toys Campaign", + ] + + selected_scenario = st.selectbox("Choose Scenario", scenario_options) + + product_count = st.number_input( + "How many products to generate?", + min_value=1, + max_value=50, + value=10, + step=1, + ) + + event_count = st.number_input( + "How many future events to generate?", + min_value=1, + max_value=20, + value=5, + step=1, + ) + + col1, col2 = st.columns(2) + + with col1: + if st.button("Generate AI Products"): + validated_products, raw_product_json = generate_products_from_ai( + product_count=product_count, + theme=selected_scenario, + ) + + saved_count, skipped_count = save_ai_products(validated_products) + + st.success(f"{saved_count} AI products saved successfully") + + if skipped_count > 0: + st.warning(f"{skipped_count} products were skipped") + + st.text_area("Generated Product JSON", + raw_product_json, height=300) + + with col2: + if st.button("Generate Future Events"): + validated_events, raw_event_json = generate_future_events( + event_count=event_count, + theme=selected_scenario, + ) + + st.success( + f"{len(validated_events.events)} future events generated successfully") + st.text_area("Generated Event JSON", raw_event_json, height=300) + + +# key is unique internal ID for a streamlit widget +# it uses it to identify widgets, store their value and avoid conflicts + +# traditional search and semantic search +@st.fragment +def week7_search_tools(selected_category_id): + st.subheader("Week 7: Search Tools") + + col1, col2 = st.columns(2) + + with col1: + keyword_query = st.text_input( + "Traditional Keyword Search", + placeholder="Try: blocks, doll, puzzle, plush", + key="week7_keyword_query" + ) + + keyword_top_k = st.number_input( + "Keyword Top Results", + min_value=1, + max_value=10, + value=5, + step=1, + key="week7_keyword_top_k" + ) + + if st.button("Run Keyword Search", key="week7_keyword_button"): + if not keyword_query.strip(): + st.warning("Please enter a keyword query") + else: + keyword_results = keyword_search_products( + query=keyword_query, + selected_category_id=selected_category_id + ) + st.session_state["week7_keyword_results"] = keyword_results[:int( + keyword_top_k)] + + with col2: + semantic_query = st.text_input( + "Semantic Search", + placeholder="Try: construction toys, gifts for toddlers, pretend play", + key="week7_semantic_query" + ) + + semantic_top_k = st.number_input( + "Semantic Top Results", + min_value=1, + max_value=10, + value=5, + step=1, + key="week7_semantic_top_k" + ) + + if st.button("Run Semantic Search", key="week7_semantic_button"): + if not semantic_query.strip(): + st.warning("Please enter a semantic query") + else: + semantic_results = semantic_search( + query=semantic_query, + top_k=int(semantic_top_k), + model_name="all-MiniLM-L6-v2", + selected_category_id=selected_category_id, + ) + st.session_state["week7_semantic_results"] = semantic_results + + st.markdown("---") + + result_col1, result_col2 = st.columns(2) + + with result_col1: + st.markdown("### Keyword Search Results") + keyword_results = st.session_state.get("week7_keyword_results", []) + + if not keyword_results: + st.info("No keyword search results yet") + else: + for item in keyword_results: + render_product_card( + item, + show_semantic_score=False, + key_prefix="keyword" + ) + + with result_col2: + st.markdown("### Semantic Search Results") + semantic_results = st.session_state.get("week7_semantic_results", []) + + if not semantic_results: + st.info("No semantic search results yet") + else: + for item in semantic_results: + render_product_card( + item, + show_semantic_score=True, + key_prefix="semantic" + ) + + +df = inventory_table(selected_category_id) +stock_alert(df) +week7_search_tools(selected_category_id) +ai_scenario_generator() +add_product(all_category) +remove_product() diff --git a/backend/python/week7/embeddings.py b/backend/python/week7/embeddings.py new file mode 100644 index 000000000..76b29836f --- /dev/null +++ b/backend/python/week7/embeddings.py @@ -0,0 +1,85 @@ +# this files takes product data or query text and convert it to embeddings +# it prepares the vector representation needed +from functools import lru_cache +from typing import List, Dict, Any +from sentence_transformers import SentenceTransformer +from week4.db_connection import initialize_mongo +from week4.models import Product + + +def initiate_db_connection(): + initialize_mongo() + + +# this func loads the model, loading is expensive, hence with cache: model loads first time and next time same model is reused for search, query and comparision +# it can remember 4 models at one time, although we require only 1 all-MiniLM-L6-v2 +@lru_cache(maxsize=4) +def load_embedding_model(model_name: str = "all-MiniLM-L6-v2"): + # sentence transformer loads SBERT style models like all-MiniLM-L6-v2, all-mpnet-base-v2 + return SentenceTransformer(model_name) + + +# converts one product object into one text string bcz embedding models take text input not raw databse objects +def build_product_text(product) -> str: + parts = [] + if product.name: + parts.append(product.name) + if product.description: + parts.append(product.description) + if product.brand: + parts.append(product.brand) + if product.category and product.category.title: + parts.append(product.category.title) + return " | ".join(parts) + + +# fetches all products from mongoDB by category +def fetch_products_by_category(selected_category_id=None) -> List: + initiate_db_connection() + if selected_category_id is None: + return list(Product.objects().order_by("id")) + else: + return list(Product.objects(category=selected_category_id).order_by("id")) + + +# converts raw mongoDB product objects into python dict +def build_product_records(products: List) -> List[Dict[str, Any]]: + records = [] + for product in products: + records.append({ + "id": product.id, + "name": product.name, + "description": product.description, + "brand": product.brand, + "price": float(product.price), + "quantity": product.quantity, + "category": product.category.title if product.category else "No Category", + "text": build_product_text(product), + }) + return records + + +def generate_embeddings_for_texts(texts: List[str], model_name: str = "all-MiniLM-L6-v2"): + model = load_embedding_model(model_name) + return model.encode(texts, convert_to_numpy=True) + + +# 1. fetch products from DB +# 2. convert them into clean records +# 3. extract text from each record +# 4. generate embeddings +def get_product_records_and_embeddings(selected_category_id=None, model_name: str = "all-MiniLM-L6-v2"): + products = fetch_products_by_category(selected_category_id) + records = build_product_records(products) + if not records: + return [], None + texts = [] + for record in records: + texts.append(record["text"]) + embeddings = generate_embeddings_for_texts(texts, model_name=model_name) + return records, embeddings + + +def generate_query_embedding(query: str, model_name: str = "all-MiniLM-L6-v2"): + model = load_embedding_model(model_name) + return model.encode(query, convert_to_numpy=True) diff --git a/backend/python/week7/eval_dataset.py b/backend/python/week7/eval_dataset.py new file mode 100644 index 000000000..1df1964b6 --- /dev/null +++ b/backend/python/week7/eval_dataset.py @@ -0,0 +1,124 @@ +SEARCH_TEST_CASES = [ + { + "query": "construction toys", + "relevant_products": [ + "deluxe city building blocks set", + "mini robot construction kit", + "magnetic tile creativity set", + "interlocking gear system", + "wooden farm animal blocks", + ], + "irrelevant_products": [ + "superhero action figure", + "giant teddy bear", + "princess royal doll", + "classic family board game", + ], + }, + { + "query": "gifts for toddlers", + "relevant_products": [ + "wooden farm animal blocks", + "baby cuddle doll", + "alphabet learning puzzle", + "mini animal plush assortment", + "puppy dog plush", + ], + "irrelevant_products": [ + "mystery detective game", + "high-speed rc race car", + "flying drone with camera", + "galactic warrior action figure", + ], + }, + { + "query": "pretend play toys", + "relevant_products": [ + "princess royal doll", + "doctor play doll set", + "baby cuddle doll", + "fantasy fairy doll", + "superhero action figure", + "space explorer figure", + "ninja warrior figure", + "starlight princess doll", + "galactic warrior action figure", + ], + "irrelevant_products": [ + "animal kingdom puzzle (1000 pieces)", + "kids first microscope kit", + "deluxe city building blocks set", + "classic family board game", + ], + }, + { + "query": "science and learning toys", + "relevant_products": [ + "kids first microscope kit", + "coding robot for kids", + "human anatomy model kit", + "chemistry lab set", + "interactive globe with pen", + ], + "irrelevant_products": [ + "giant teddy bear", + "princess royal doll", + "superhero action figure", + "water blaster super soaker", + ], + }, + { + "query": "soft cuddly toys", + "relevant_products": [ + "giant teddy bear", + "giant plush teddy bear", + "unicorn plush toy", + "puppy dog plush", + "dinosaur stuffed animal", + "mini animal plush assortment", + ], + "irrelevant_products": [ + "mini robot construction kit", + "mystery detective game", + "remote control helicopter", + "chemistry lab set", + ], + }, + { + "query": "remote control vehicles and flying toys", + "relevant_products": [ + "high-speed rc race car", + "flying drone with camera", + "rc monster truck", + "remote control helicopter", + "robo-racer remote control car", + ], + "irrelevant_products": [ + "outdoor ring toss game", + "princess royal doll", + "animal kingdom puzzle (1000 pieces)", + "giant teddy bear", + ], + }, + { + "query": "outdoor play toys", + "relevant_products": [ + "kids outdoor swing set", + "water blaster super soaker", + "giant bubble wand kit", + "kids soccer goal set", + "flying disc set", + "sand play set with molds", + "jump rope with counter", + "kite flying kit", + "hula hoop assortment", + "outdoor ring toss game", + ], + "irrelevant_products": [ + "interactive globe with pen", + "fashionista doll collection", + "superhero action figure", + "3d eiffel tower puzzle", + ], + }, +] diff --git a/backend/python/week7/semantic_search.py b/backend/python/week7/semantic_search.py new file mode 100644 index 000000000..aad8b7287 --- /dev/null +++ b/backend/python/week7/semantic_search.py @@ -0,0 +1,107 @@ +import numpy as np +from week7.embeddings import get_product_records_and_embeddings, generate_query_embedding + + +def cosine_similarity_manually(vec1, vec2) -> float: + vec1 = np.array(vec1, dtype=float) + vec2 = np.array(vec2, dtype=float) + + # computing magnitude + vec1_norm = np.linalg.norm(vec1) + vec2_norm = np.linalg.norm(vec2) + + if vec1_norm == 0 or vec2_norm == 0: + return 0.0 + + # cosine similarity formula + similarity = np.dot(vec1, vec2) / (vec1_norm * vec2_norm) + return float(similarity) + + +# take a user search query and return top k most semantically similar products +def semantic_search(query: str, top_k: int = 5, model_name: str = "all-MiniLM-L6-v2", selected_category_id=None): + if not query or not query.strip(): + return [] + + product_records, product_embeddings = get_product_records_and_embeddings( + selected_category_id=selected_category_id, + model_name=model_name + ) + + if not product_records or product_embeddings is None: + return [] + + query_embedding = generate_query_embedding( + query=query.strip(), + model_name=model_name + ) + + scored_results = [] + + # record gives product info and index gives access to matching embedding + # record = product_records[index] and embedding = product_embeddings[index] + for index, record in enumerate(product_records): + score = cosine_similarity_manually( + query_embedding, + product_embeddings[index] + ) + + scored_results.append({ + "id": record["id"], + "name": record["name"], + "description": record["description"], + "brand": record["brand"], + "price": record["price"], + "quantity": record["quantity"], + "category": record["category"], + "semantic_score": round(score, 6), + }) + + scored_results.sort(key=lambda x: x["semantic_score"], reverse=True) + return scored_results[:top_k] + + +def find_similar_products(product_id: int, top_k: int = 5, model_name: str = "all-MiniLM-L6-v2"): + product_records, product_embeddings = get_product_records_and_embeddings( + selected_category_id=None, + model_name=model_name + ) + + if not product_records or product_embeddings is None: + return [] + + target_index = None + + for index, record in enumerate(product_records): + if record["id"] == product_id: + target_index = index + break + + if target_index is None: + return [] + + target_embedding = product_embeddings[target_index] + scored_results = [] + + for index, record in enumerate(product_records): + if record["id"] == product_id: + continue + + score = cosine_similarity_manually( + target_embedding, + product_embeddings[index] + ) + + scored_results.append({ + "id": record["id"], + "name": record["name"], + "description": record["description"], + "brand": record["brand"], + "price": record["price"], + "quantity": record["quantity"], + "category": record["category"], + "semantic_score": round(score, 6), + }) + + scored_results.sort(key=lambda x: x["semantic_score"], reverse=True) + return scored_results[:top_k] diff --git a/backend/python/week7/task_2.py b/backend/python/week7/task_2.py new file mode 100644 index 000000000..b2178cc39 --- /dev/null +++ b/backend/python/week7/task_2.py @@ -0,0 +1,55 @@ +import matplotlib.pyplot as plt +from sklearn.decomposition import PCA +from week7.embeddings import generate_embeddings_for_texts +from week7.semantic_search import cosine_similarity_manually + +# use of PCA : it converts 384D of embedding to 2D which makes it easy for us to plot + + +def main(): + product_name = [ + "Lego Castle", + "Wooden Blocks", + "Action Figure", + ] + # embedding models understand meaning from context, not just names + product_texts = [ + "Lego Castle brick set for building a castle with towers and walls for kids", + "Wooden Blocks stackable wooden pieces for making shapes, houses, and small structures", + "Action Figure superhero character toy for roleplay, battles, and imaginative adventures", + ] + embeddings = generate_embeddings_for_texts( + product_texts, model_name="all-MiniLM-L6-v2") + sim_lego_wooden = cosine_similarity_manually( + embeddings[0], embeddings[1]) + sim_lego_action = cosine_similarity_manually( + embeddings[0], embeddings[2]) + sim_wooden_action = cosine_similarity_manually( + embeddings[1], embeddings[2]) + print("\nPairwise Cosine Similarity") + print("----------------------------------------------------------------------------------------------------------------------------") + print(f"Lego Castle vs Wooden Blocks : {sim_lego_wooden:.4f}") + print(f"Lego Castle vs Action Figure : {sim_lego_action:.4f}") + print(f"Wooden Blocks vs Action Figure : {sim_wooden_action:.4f}") + + pca = PCA(n_components=2) + embeddings_2d = pca.fit_transform(embeddings) + plt.figure(figsize=(8, 6)) + plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1], s=120) + for index, name in enumerate(product_name): + plt.annotate( + name, + (embeddings_2d[index, 0], embeddings_2d[index, 1]), + xytext=(8, 8), + textcoords="offset points", + ) + plt.title("PCA Projection of 3 Product Embeddings") + plt.xlabel("PCA Component 1") + plt.ylabel("PCA Component 2") + plt.grid(True) + plt.tight_layout() + plt.show() + + +if __name__ == "__main__": + main() diff --git a/backend/python/week7/task_3.py b/backend/python/week7/task_3.py new file mode 100644 index 000000000..2724a415d --- /dev/null +++ b/backend/python/week7/task_3.py @@ -0,0 +1,17 @@ +from week7.semantic_search import semantic_search + +results = semantic_search( + query="construction toys", + top_k=5, + model_name="all-MiniLM-L6-v2" +) + + +print("\nSemantic Search Results") +print("-" * 50) + +for index, item in enumerate(results, start=1): + print(f"{index}. {item['name']} | score={item['semantic_score']}") + print(f" Description: {item['description']}") + print(f" Category : {item['category']}") + print() diff --git a/backend/python/week7/task_4.py b/backend/python/week7/task_4.py new file mode 100644 index 000000000..45979fb7b --- /dev/null +++ b/backend/python/week7/task_4.py @@ -0,0 +1,284 @@ +# we will find precision, hit and recall +from week7.eval_dataset import SEARCH_TEST_CASES +from week7.semantic_search import semantic_search + + +# to make product name consistent +def normalize_name(text): + return text.strip().lower() + + +# out of top k results returned how many were actually relavant +def precision_at_k(retrieved_names, relevant_names, k): + top_k_names = retrieved_names[:k] + + if k == 0: + return 0.0 + + relevant_found = 0 + + for name in top_k_names: + if name in relevant_names: + relevant_found += 1 + + return relevant_found / k + + +# out of all relevant products that should have been found, how many did we retrieve in top k +def recall_at_k(retrieved_names, relevant_names, k): + if not relevant_names: + return 0.0 + + top_k_names = retrieved_names[:k] + relevant_found = 0 + + for name in top_k_names: + if name in relevant_names: + relevant_found += 1 + + return relevant_found / len(relevant_names) + + +# did at least one relevant item appear in k +def hit_at_k(retrieved_names, relevant_names, k): + top_k_names = retrieved_names[:k] + + for name in top_k_names: + if name in relevant_names: + return 1.0 + + return 0.0 + + +# total irrelevant found/ actual irrelevant +def fallout_at_k(retrieved_names, irrelevant_names, k): + if not irrelevant_names: + return 0.0 + + top_k_names = retrieved_names[:k] + irrelevant_found = 0 + + for name in top_k_names: + if name in irrelevant_names: + irrelevant_found += 1 + + return irrelevant_found / len(irrelevant_names) + + +def evaluate_semantic_search(top_k=5, model_name="all-MiniLM-L6-v2"): + all_precisions = [] + all_recalls = [] + all_hits = [] + all_fallouts = [] + + print("\nSemantic Search Evaluation") + print("=" * 50) + + # case_number = 1,2,3,... + # test_case = dict containing query + relevant + irrelevant products + for case_number, test_case in enumerate(SEARCH_TEST_CASES, start=1): + query = test_case["query"] + + relevant_products = set() + for name in test_case["relevant_products"]: + relevant_products.add(normalize_name(name)) + + irrelevant_products = set() + for name in test_case["irrelevant_products"]: + irrelevant_products.add(normalize_name(name)) + + results = semantic_search( + query=query, + top_k=top_k, + model_name=model_name + ) + + retrieved_names = [] + for item in results: + retrieved_names.append(normalize_name(item["name"])) + + p_at_k = precision_at_k(retrieved_names, relevant_products, top_k) + r_at_k = recall_at_k(retrieved_names, relevant_products, top_k) + h_at_k = hit_at_k(retrieved_names, relevant_products, top_k) + f_at_k = fallout_at_k(retrieved_names, irrelevant_products, top_k) + + all_precisions.append(p_at_k) + all_recalls.append(r_at_k) + all_hits.append(h_at_k) + all_fallouts.append(f_at_k) + + print(f"\nTest Case {case_number}") + print("-" * 50) + print(f"Query : {query}") + print(f"Relevant Products : {sorted(relevant_products)}") + print(f"Irrelevant Products: {sorted(irrelevant_products)}") + print("Top Results :") + + if not results: + print(" No results found.") + else: + for rank, result in enumerate(results, start=1): + print( + f" {rank}. {result['name']} " + f"(score={result['semantic_score']:.4f}, category={result['category']})" + ) + + print(f"Precision: {p_at_k:.4f}") + print(f"Recall : {r_at_k:.4f}") + print(f"Hit : {h_at_k:.4f}") + print(f"Fallout : {f_at_k:.4f}") + + avg_precision = 0.0 + avg_recall = 0.0 + avg_hit = 0.0 + avg_fallout = 0.0 + + if all_precisions: + avg_precision = sum(all_precisions) / len(all_precisions) + + if all_recalls: + avg_recall = sum(all_recalls) / len(all_recalls) + + if all_hits: + avg_hit = sum(all_hits) / len(all_hits) + + if all_fallouts: + avg_fallout = sum(all_fallouts) / len(all_fallouts) + + print("\nOverall Summary") + print("=" * 50) + print(f"Average Precision: {avg_precision:.4f}") + print(f"Average Recall : {avg_recall:.4f}") + print(f"Average Hit : {avg_hit:.4f}") + print(f"Average Fallout : {avg_fallout:.4f}") + + +if __name__ == "__main__": + evaluate_semantic_search(top_k=5, model_name="all-MiniLM-L6-v2") + + +# =========== +# OUTPUT +# =========== + + +# Semantic Search Evaluation +# == == == == == == == == == == == == == == == == == == == == == == == == == +# Test Case 1 +# -------------------------------------------------- +# Query: construction toys +# Relevant Products: ['deluxe city building blocks set', 'interlocking gear system', 'magnetic tile creativity set', 'mini robot construction kit', 'wooden farm animal blocks'] +# Irrelevant Products: ['classic family board game', 'giant teddy bear', 'princess royal doll', 'superhero action figure'] +# Top Results: +# 1. Geometry Shape Sorter Toy(score=0.4708, category=Early Learning) +# 2. Mini Robot Construction Kit(score=0.4696, category=building blocks) +# 3. Lego Castle(score=0.4670, category=building blocks) +# 4. Kids Soccer Goal Set(score=0.4580, category=outdoor toys) +# 5. Sand Play Set with Molds(score=0.4563, category=outdoor toys) +# Precision: 0.2000 +# Recall: 0.2000 +# Hit: 1.0000 +# Fallout: 0.0000 + +# Test Case 2 +# -------------------------------------------------- +# Query: gifts for toddlers +# Relevant Products: ['alphabet learning puzzle', 'baby cuddle doll', 'mini animal plush assortment', 'puppy dog plush', 'wooden farm animal blocks'] +# Irrelevant Products: ['flying drone with camera', 'galactic warrior action figure', 'high-speed rc race car', 'mystery detective game'] +# Top Results: +# 1. Backpack & School Accessories for Dolls(score=0.4935, category=Dolls & Accessories) +# 2. Mini Animal Plush Assortment(score=0.4768, category=plush toys) +# 3. Alphabet Learning Puzzle(score=0.4707, category=puzzles) +# 4. Kids Soccer Goal Set(score=0.4578, category=outdoor toys) +# 5. Kids First Microscope Kit(score=0.4465, category=educational toys) +# Precision: 0.4000 +# Recall: 0.4000 +# Hit: 1.0000 +# Fallout: 0.0000 + +# Test Case 3 +# -------------------------------------------------- +# Query: pretend play toys +# Relevant Products: ['baby cuddle doll', 'doctor play doll set', 'fantasy fairy doll', 'galactic warrior action figure', 'ninja warrior figure', 'princess royal doll', 'space explorer figure', 'starlight princess doll', 'superhero action figure'] +# Irrelevant Products: ['animal kingdom puzzle (1000 pieces)', 'classic family board game', 'deluxe city building blocks set', 'kids first microscope kit'] +# Top Results: +# 1. Teacher Role Play Set(score=0.5873, category=Role Play) +# 2. Play-Doh School Days Set(score=0.4818, category=Creative Play) +# 3. Lunchbox & Thermos Pretend Play Set(score=0.4449, category=Role Play) +# 4. Sand Play Set with Molds(score=0.4253, category=outdoor toys) +# 5. Doctor Play Doll Set(score=0.4177, category=dolls) +# Precision: 0.2000 +# Recall: 0.1111 +# Hit: 1.0000 +# Fallout: 0.0000 + +# Test Case 4 +# -------------------------------------------------- +# Query: science and learning toys +# Relevant Products: ['chemistry lab set', 'coding robot for kids', 'human anatomy model kit', 'interactive globe with pen', 'kids first microscope kit'] +# Irrelevant Products: ['giant teddy bear', 'princess royal doll', 'superhero action figure', 'water blaster super soaker'] +# Top Results: +# 1. Educational Robot Kit(score=0.5905, category=Educational Toys) +# 2. Kids' Learning Tablet Toy (score=0.5537, category=Electronic Learning) +# 3. Science Experiment Lab Kit (score=0.5438, category=Science Kits) +# 4. Geometry Shape Sorter Toy (score=0.5408, category=Early Learning) +# 5. Kids First Microscope Kit (score=0.5295, category=educational toys) +# Precision: 0.2000 +# Recall : 0.2000 +# Hit : 1.0000 +# Fallout : 0.0000 + +# Test Case 5 +# -------------------------------------------------- +# Query : soft cuddly toys +# Relevant Products : ['dinosaur stuffed animal', 'giant plush teddy bear', 'giant teddy bear', 'mini animal plush assortment', 'puppy dog plush', 'unicorn plush toy'] +# Irrelevant Products: ['chemistry lab set', 'mini robot construction kit', 'mystery detective game', 'remote control helicopter'] +# Top Results : +# 1. Baby Cuddle Doll (score=0.6768, category=dolls) +# 2. Giant Plush Teddy Bear (score=0.6066, category=Plush Toys) +# 3. Snuggle Buddy Teddy Bear (score=0.6034, category=Plush Toys) +# 4. Puppy Dog Plush (score=0.5862, category=plush toys) +# 5. Giant Teddy Bear (score=0.5728, category=plush toys) +# Precision: 0.6000 +# Recall : 0.5000 +# Hit : 1.0000 +# Fallout : 0.0000 + +# Test Case 6 +# -------------------------------------------------- +# Query : remote control vehicles and flying toys +# Relevant Products : ['flying drone with camera', 'high-speed rc race car', 'rc monster truck', 'remote control helicopter', 'robo-racer remote control car'] +# Irrelevant Products: ['animal kingdom puzzle (1000 pieces)', 'giant teddy bear', 'outdoor ring toss game', 'princess royal doll'] +# Top Results : +# 1. Remote Control Helicopter (score=0.6012, category=remote control toys) +# 2. Flying Disc Set (score=0.5843, category=outdoor toys) +# 3. Flying Drone with Camera (score=0.5793, category=remote control toys) +# 4. Turbo Racer Remote Control Car (score=0.5728, category=Remote Control Toys) +# 5. Robo-Racer Remote Control Car (score=0.5656, category=Remote Control Toys) +# Precision: 0.6000 +# Recall : 0.6000 +# Hit : 1.0000 +# Fallout : 0.0000 + +# Test Case 7 +# -------------------------------------------------- +# Query : outdoor play toys +# Relevant Products : ['flying disc set', 'giant bubble wand kit', 'hula hoop assortment', 'jump rope with counter', 'kids outdoor swing set', 'kids soccer goal set', 'kite flying kit', 'outdoor ring toss game', 'sand play set with molds', 'water blaster super soaker'] +# Irrelevant Products: ['3d eiffel tower puzzle', 'fashionista doll collection', 'interactive globe with pen', 'superhero action figure'] +# Top Results : +# 1. Kids Outdoor Swing Set (score=0.6322, category=outdoor toys) +# 2. Sand Play Set with Molds (score=0.6291, category=outdoor toys) +# 3. Kids Soccer Goal Set (score=0.5970, category=outdoor toys) +# 4. Outdoor Ring Toss Game (score=0.5838, category=outdoor toys) +# 5. Hula Hoop Assortment (score=0.5267, category=outdoor toys) +# Precision: 1.0000 +# Recall : 0.5000 +# Hit : 1.0000 +# Fallout : 0.0000 + +# Overall Summary +# ================================================== +# Average Precision: 0.4571 +# Average Recall : 0.3587 +# Average Hit : 1.0000 +# Average Fallout : 0.0000 diff --git a/backend/python/week8/ask_expert_service.py b/backend/python/week8/ask_expert_service.py new file mode 100644 index 000000000..7ee15aea3 --- /dev/null +++ b/backend/python/week8/ask_expert_service.py @@ -0,0 +1,54 @@ +# this file is also for adv task, it combines rag pipelines and stock lookup +from week8.rag_pipeline import run_rag_pipeline +from week8.stock_lookup import get_product_stock +from week8.config import DEFAULT_TOP_K + +# it helps in answering questions like : "is car in stock and what is the warranty?" + + +def ask_expert(user_query, product_name: str | None = None, top_k: int = DEFAULT_TOP_K): + rag_result = run_rag_pipeline(user_query, top_k=top_k) + stock_result = None + if product_name: + stock_result = get_product_stock(product_name) + final_answer = rag_result["answer"] + if stock_result: + stock_text = ( + f"\n\nCurrent Stock Information:\n" + f"- Product: {stock_result['name']}\n" + f"- Brand: {stock_result['brand']}\n" + f"- Category: {stock_result['category']}\n" + f"- Price: {stock_result['price']}\n" + f"- Quantity Available: {stock_result['quantity']}" + ) + final_answer += stock_text + return { + "query": user_query, + "answer": final_answer, + "rag_answer": rag_result["answer"], + "rag_prompt": rag_result["prompt"], + "retrieved_chunks": rag_result["retrieved_chunks"], + "stock_result": stock_result, + } + + +if __name__ == "__main__": + user_query = input("Enter your question: ").strip() + product_name = input( + "Enter product name for stock lookup (or press Enter to skip): ").strip() + if not user_query: + print("No query entered.") + else: + result = ask_expert( + user_query=user_query, product_name=product_name if product_name else None, top_k=DEFAULT_TOP_K) + print("\nFinal Answer:") + print(result["answer"]) + + print("\nRetrieved Chunks:") + for index, chunk in enumerate(result["retrieved_chunks"], start=1): + print("=" * 80) + print(f"Result #{index}") + print(f"Source: {chunk['source']}") + print(f"Title: {chunk['title']}") + print(f"Chunk Index: {chunk['chunk_index']}") + print(chunk["text"]) diff --git a/backend/python/week8/config.py b/backend/python/week8/config.py new file mode 100644 index 000000000..96de343c3 --- /dev/null +++ b/backend/python/week8/config.py @@ -0,0 +1,30 @@ +# central setting file for week 8 +# it keeps all fixed value of the project in one place +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +CHROMA_PERSIST_DIR = DATA_DIR / "chroma_db" +CHROMA_COLLECTION_NAME = "week8_inventory_knowledge" +EMBEDDING_MODEL_NAME = "all-MiniLM-L6-v2" +CHUNK_SIZE = 700 +CHUNK_OVERLAP = 120 +CHUNK_OVERLAP = 120 +DEFAULT_TOP_K = 3 +GEMINI_MODEL_NAME = "gemini-2.5-flash" +GEMINI_TEMPERATURE = 0.2 +TRACING_PROJECT_NAME = "interneers-lab-week8" +DOCUMENT_FILE_MAP = { + "product_manual.txt": { + "doc_type": "product_manual", + "title": "Product Manual", + }, + "return_policy.txt": { + "doc_type": "return_policy", + "title": "Return Policy", + }, + "vendor_faq.txt": { + "doc_type": "vendor_faq", + "title": "Vendor FAQ", + }, +} diff --git a/backend/python/week8/dashboard.py b/backend/python/week8/dashboard.py new file mode 100644 index 000000000..4cc1e1c0c --- /dev/null +++ b/backend/python/week8/dashboard.py @@ -0,0 +1,645 @@ +import os +from dotenv import load_dotenv +import streamlit as st +import pandas as pd +from week4.db_connection import initialize_mongo +from week4.models import Product, ProductCategory +from week6.schema import ProductListSchema, FutureStockEventListSchema +from decimal import Decimal +from google import genai +from week7.semantic_search import semantic_search, find_similar_products +from week8.ask_expert_service import ask_expert +from week8.knowledge_base import list_available_sources +from week8.langsmith_setup import setup_langsmith_tracing, is_langsmith_enabled + + +load_dotenv() + + +initialize_mongo() + + +setup_langsmith_tracing() + + +client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + +# page settings +st.set_page_config( + page_title="Week 5 + Week 6 + Week 7 + Week 8: Inventory Dashboard", layout="wide") +st.title("Week 5 + Week 6 + Week 7 + Week 8: Interactive Data Tools") +st.write("Streamlit dashboard connected to MongoDB, Gemini, semantic search, and RAG") + + +# helpers +def get_all_categories(): + return list(ProductCategory.objects().order_by("title")) + + +def fetch_products(selected_category_id=None): + if selected_category_id is None: + products = Product.objects() + else: + products = Product.objects(category=selected_category_id) + + rows = [] + + for product in products: + rows.append({ + "ID": product.id, + "Name": product.name, + "Description": product.description, + "Price": float(product.price), + "Brand": product.brand, + "Quantity": product.quantity, + "Category": product.category.title if product.category else "No Category" + }) + + return pd.DataFrame(rows) + + +def get_or_create_category(category_title): + cat_title = (category_title or "").strip() + + if not cat_title: + cat_title = "Miscellaneous" + + category = ProductCategory.objects(title=cat_title).first() + if category: + return category + + category = ProductCategory( + title=cat_title, + description=f"Auto-created from AI generated data for {cat_title}", + ) + category.save() + return category + + +def product_already_exists(name, brand): + return Product.objects(name=name, brand=brand).first() is not None + + +def save_ai_products(validated_data): + saved_count = 0 + skipped_count = 0 + + for item in validated_data.products: + name = item.name.strip() + description = item.description.strip() + category_title = item.category.strip() + brand = item.brand.strip() + price = item.price + quantity = item.quantity + + if not name: + skipped_count += 1 + continue + + if not description: + skipped_count += 1 + continue + + if not brand: + skipped_count += 1 + continue + + if price is None or price <= 0: + skipped_count += 1 + continue + + if quantity is None or quantity < 0: + skipped_count += 1 + continue + + if product_already_exists(name, brand): + skipped_count += 1 + continue + + category_obj = get_or_create_category(category_title) + + product = Product( + name=name, + description=description, + price=Decimal(str(price)), + brand=brand, + quantity=int(quantity), + category=category_obj, + ).save() + saved_count += 1 + + return saved_count, skipped_count + + +def generate_products_from_ai(product_count=10, theme="general toy store"): + prompt = f""" +Generate exactly {product_count} products for a toy store. + +Theme: {theme} + +Return valid JSON only in this structure: +{{ + "products": [ + {{ + "name": "string", + "description": "string", + "category": "string", + "brand": "string", + "price": 10.99, + "quantity": 25 + }} + ] +}} + +Rules: +- Include exactly {product_count} products +- Product name must never be empty +- Brand must never be empty +- Description must never be empty +- Category must never be empty +- Use realistic toy store categories +- Match the requested theme where possible +- price must be a float greater than or equal to 0.01 +- quantity must be an integer greater than or equal to 0 +- do not include markdown +- output valid JSON only +""" + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.3, + "response_mime_type": "application/json", + "response_schema": ProductListSchema, + }, + ) + + validated = ProductListSchema.model_validate_json(response.text) + return validated, response.text + + +def generate_future_events(event_count=5, theme="general toy store"): + prompt = f""" +Generate exactly {event_count} future stock events for a toy store. + +Theme: {theme} + +Return valid JSON only in this structure: +{{ + "events": [ + {{ + "title": "string", + "event_type": "string", + "expected_date": "YYYY-MM-DD", + "product_name": "string", + "quantity_change": 10, + "note": "string" + }} + ] +}} + +Rules: +- Include exactly {event_count} events +- All dates must be future dates +- Keep events realistic for toy inventory +- Match the requested theme where possible +- event_type can be: incoming_shipment, seasonal_spike, low_stock_warning, preorder_arrival, supplier_delay, warehouse_transfer +- title must not be empty +- product_name must not be empty +- quantity_change must be an integer +- output valid JSON only +- do not include markdown +""" + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config={ + "temperature": 0.6, + "response_mime_type": "application/json", + "response_schema": FutureStockEventListSchema, + }, + ) + + validated_data = FutureStockEventListSchema.model_validate_json( + response.text) + return validated_data, response.text + + +# performs tradional keyword search +def keyword_search_products(query, selected_category_id=None): + query = (query or "").strip().lower() + + if not query: + return [] + + if selected_category_id is None: + products = Product.objects() + else: + products = Product.objects(category=selected_category_id) + + results = [] + + for product in products: + searchable_text = " ".join([ + str(product.name or ""), + str(product.description or ""), + str(product.brand or ""), + str(product.category.title if product.category else ""), + ]).lower() + + if query in searchable_text: + results.append({ + "id": product.id, + "name": product.name, + "description": product.description, + "brand": product.brand, + "price": float(product.price), + "quantity": product.quantity, + "category": product.category.title if product.category else "No Category", + }) + + return results + + +# display one product nicely as card +def render_product_card(product_data, show_semantic_score=False, key_prefix="default"): + with st.container(border=True): + st.markdown(f"### {product_data['name']}") + st.write(product_data["description"]) + st.write(f"**Brand:** {product_data['brand']}") + st.write(f"**Category:** {product_data['category']}") + st.write(f"**Price:** {product_data['price']}") + st.write(f"**Quantity:** {product_data['quantity']}") + + if show_semantic_score and "semantic_score" in product_data: + st.write(f"**Semantic Score:** {product_data['semantic_score']}") + + button_key = f"{key_prefix}_similar_button_{product_data['id']}" + + if st.button("Find Similar Products", key=button_key): + similar_results = find_similar_products( + product_id=product_data["id"], + top_k=5, + model_name="all-MiniLM-L6-v2", + ) + + st.markdown("#### Similar Products") + + if not similar_results: + st.info("No similar products found.") + else: + for item in similar_results: + with st.container(border=True): + st.write(f"**Name:** {item['name']}") + st.write(f"**Description:** {item['description']}") + st.write(f"**Brand:** {item['brand']}") + st.write(f"**Category:** {item['category']}") + st.write(f"**Price:** {item['price']}") + st.write(f"**Quantity:** {item['quantity']}") + st.write( + f"**Similarity Score:** {item['semantic_score']}") + + +# creating sidebar category filter +st.sidebar.header("Filter Inventory") + +all_category = get_all_categories() +# creating dictionary where all maps to none so that no conflict arises +category_options = {"All": None} + +for category in all_category: + category_options[category.title] = category.id + +selected_category_title = st.sidebar.selectbox( + "Select Product Category", list(category_options.keys())) + +selected_category_id = category_options[selected_category_title] + + +# showing inventory table for current id + +@st.fragment +def inventory_table(category_id): + st.subheader("Current Inventory") + df = fetch_products(category_id) + if df.empty: + st.info("No products found in this category") + else: + st.dataframe(df, use_container_width=True) + return df + + +# showing stock alert + +@st.fragment +def stock_alert(df): + st.subheader("Stock Alert") + if not df.empty: + low_stock_row = [] + for index, row in df.iterrows(): + if row["Quantity"] < 5: + low_stock_row.append(row) + low_stock_df = pd.DataFrame(low_stock_row) + + if not low_stock_df.empty: + st.error("Some products are running low in stock") + st.dataframe(low_stock_df, use_container_width=True) + else: + st.success("No products are running low in stock") + else: + st.info("No products found in this category") + + +# adding product + +@st.fragment +def add_product(categories): + st.subheader("Add Product") + category_title = [] + for category in categories: + category_title.append(category.title) + + with st.form("add_product_form"): + new_name = st.text_input("Name") + new_description = st.text_area("Description") + new_price = st.number_input( + "Price", min_value=0.0, step=1.0, format="%.2f") + new_brand = st.text_input("Brand") + new_quantity = st.number_input( + "Quantity", min_value=0, step=1) + if category_title: + new_category_title = st.selectbox("Category", category_title) + else: + new_category_title = None + st.warning("No categories found. Please create a category first.") + submitted = st.form_submit_button("Add Product") + + if submitted: + if not new_name.strip(): + st.error("Product name cannot be empty") + elif not new_description.strip(): + st.error("Description cannot be empty") + elif not new_brand.strip(): + st.error("Brand cannot be empty") + elif not new_category_title: + st.error("No category exist. Pls create a category first in week 4") + else: + selected_category = ProductCategory.objects( + title=new_category_title).first() + Product(name=new_name, description=new_description, + price=Decimal(str(new_price)), brand=new_brand, quantity=int(new_quantity), category=selected_category).save() + st.success(f"Product '{new_name}' added successfully") + + +# removing product + +@st.fragment +def remove_product(): + st.subheader("Remove Product") + all_products = Product.objects().order_by("name") + product_options = {} + for product in all_products: + label = f"{product.name} (ID: {product.id})" + product_options[label] = product.id + + if product_options: + selected_product_label = st.selectbox( + "Select Product to remove", list(product_options.keys())) + if st.button("Remove Product"): + selected_product_id = product_options[selected_product_label] + Product.objects(id=selected_product_id).delete() + st.success("Product removed successfully") + else: + st.info("No products found") + + +# AI generator + +@st.fragment +def ai_scenario_generator(): + st.subheader("Week 6 Advanced: AI Scenario Generator") + + scenario_options = [ + "Holiday Rush", + "Summer Sale", + "Back to School", + "New Year Restock", + "Outdoor Play Season", + "Educational Toys Campaign", + ] + + selected_scenario = st.selectbox("Choose Scenario", scenario_options) + + product_count = st.number_input( + "How many products to generate?", + min_value=1, + max_value=50, + value=10, + step=1, + ) + + event_count = st.number_input( + "How many future events to generate?", + min_value=1, + max_value=20, + value=5, + step=1, + ) + + col1, col2 = st.columns(2) + + with col1: + if st.button("Generate AI Products"): + validated_products, raw_product_json = generate_products_from_ai( + product_count=product_count, + theme=selected_scenario, + ) + + saved_count, skipped_count = save_ai_products(validated_products) + + st.success(f"{saved_count} AI products saved successfully") + + if skipped_count > 0: + st.warning(f"{skipped_count} products were skipped") + + st.text_area("Generated Product JSON", + raw_product_json, height=300) + + with col2: + if st.button("Generate Future Events"): + validated_events, raw_event_json = generate_future_events( + event_count=event_count, + theme=selected_scenario, + ) + + st.success( + f"{len(validated_events.events)} future events generated successfully") + st.text_area("Generated Event JSON", raw_event_json, height=300) + + +# key is unique internal ID for a streamlit widget +# it uses it to identify widgets, store their value and avoid conflicts + +# traditional search and semantic search +@st.fragment +def week7_search_tools(selected_category_id): + st.subheader("Week 7: Search Tools") + + col1, col2 = st.columns(2) + + with col1: + keyword_query = st.text_input( + "Traditional Keyword Search", + placeholder="Try: blocks, doll, puzzle, plush", + key="week7_keyword_query" + ) + + keyword_top_k = st.number_input( + "Keyword Top Results", + min_value=1, + max_value=10, + value=5, + step=1, + key="week7_keyword_top_k" + ) + + if st.button("Run Keyword Search", key="week7_keyword_button"): + if not keyword_query.strip(): + st.warning("Please enter a keyword query") + else: + keyword_results = keyword_search_products( + query=keyword_query, + selected_category_id=selected_category_id + ) + st.session_state["week7_keyword_results"] = keyword_results[:int( + keyword_top_k)] + + with col2: + semantic_query = st.text_input( + "Semantic Search", + placeholder="Try: construction toys, gifts for toddlers, pretend play", + key="week7_semantic_query" + ) + + semantic_top_k = st.number_input( + "Semantic Top Results", + min_value=1, + max_value=10, + value=5, + step=1, + key="week7_semantic_top_k" + ) + + if st.button("Run Semantic Search", key="week7_semantic_button"): + if not semantic_query.strip(): + st.warning("Please enter a semantic query") + else: + semantic_results = semantic_search( + query=semantic_query, + top_k=int(semantic_top_k), + model_name="all-MiniLM-L6-v2", + selected_category_id=selected_category_id, + ) + st.session_state["week7_semantic_results"] = semantic_results + + st.markdown("---") + + result_col1, result_col2 = st.columns(2) + + with result_col1: + st.markdown("### Keyword Search Results") + keyword_results = st.session_state.get("week7_keyword_results", []) + + if not keyword_results: + st.info("No keyword search results yet") + else: + for item in keyword_results: + render_product_card( + item, + show_semantic_score=False, + key_prefix="keyword" + ) + + with result_col2: + st.markdown("### Semantic Search Results") + semantic_results = st.session_state.get("week7_semantic_results", []) + + if not semantic_results: + st.info("No semantic search results yet") + else: + for item in semantic_results: + render_product_card( + item, + show_semantic_score=True, + key_prefix="semantic" + ) + + +# I bought the Coding Robot for Kids. It is not responding properly, so I want to know what troubleshooting or warranty guidance exists, and whether the product is currently in stock. +# Coding Robot for Kids +# week 8 rag + db for product number +@st.fragment +def week8_ask_expert(): + st.subheader("Week 8: Ask the expert") + st.write("Ask questions from Product Manual, Return Policy, and Vendor FAQ.") + source_files = list_available_sources() + if source_files: + st.caption(f"Knowledge Base Sources: {', '.join(source_files)}") + if is_langsmith_enabled(): + st.success("LangSmith tracing is enabled.") + else: + st.warning("LangSmith tracing is disabled.") + user_query = st.text_input( + "Ask a question", placeholder="Try: What's the return policy for damaged items?", key="week8_user_query") + product_name = st.text_input("Optional product name for stock lookup", + placeholder="Try: Coding Robot for Kids", key="week8_product_name") + top_k = st.number_input( + "Top K Retieved Chunks", + min_value=1, + max_value=10, + value=3, + step=1, + key="week8_top_k" + ) + if st.button("Ask Expert", key="week8_ask_expert_button"): + if not user_query.strip(): + st.warning("Please enter a query") + else: + try: + result = ask_expert( + user_query=user_query.strip(), + product_name=product_name.strip() if product_name else None, + top_k=top_k + ) + st.session_state["week8_result"] = result + except Exception as error: + st.error(f"An error occurred: {error}") + result = st.session_state.get("week8_result", None) + if result: + st.markdown("### Answer") + st.write(result["answer"]) + with st.expander("RAG Only Answer"): + st.write(result["rag_answer"]) + if result["stock_result"]: + with st.expander("Stock Lookup Result"): + st.json(result["stock_result"]) + with st.expander("Retrieved Chunks"): + for index, chunk in enumerate(result["retrieved_chunks"], start=1): + with st.container(border=True): + st.markdown(f"**Result #{index}**") + st.write(f"**Source:** {chunk['source']}") + st.write(f"**Title:** {chunk['title']}") + st.write(f"**Doc Type:** {chunk['doc_type']}") + st.write(f"**Chunk Index:** {chunk['chunk_index']}") + st.write(chunk["text"]) + + +df = inventory_table(selected_category_id) +stock_alert(df) +week7_search_tools(selected_category_id) +week8_ask_expert() +ai_scenario_generator() +add_product(all_category) +remove_product() diff --git a/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/data_level0.bin b/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/data_level0.bin new file mode 100644 index 000000000..48f02b636 Binary files /dev/null and b/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/data_level0.bin differ diff --git a/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/header.bin b/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/header.bin new file mode 100644 index 000000000..bb5479262 Binary files /dev/null and b/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/header.bin differ diff --git a/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/length.bin b/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/length.bin new file mode 100644 index 000000000..bc32739d5 Binary files /dev/null and b/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/length.bin differ diff --git a/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/link_lists.bin b/backend/python/week8/data/chroma_db/7620c8db-0237-44eb-aabe-0371f5bf4152/link_lists.bin new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week8/data/product_manual.txt b/backend/python/week8/data/product_manual.txt new file mode 100644 index 000000000..f009d47ad --- /dev/null +++ b/backend/python/week8/data/product_manual.txt @@ -0,0 +1,261 @@ +Toy Store Product Manual and Care Guide + +This manual provides product guidance, usage instructions, care information, safety notes, and warranty details for selected products in our inventory. + +======================================== +1. Deluxe City Building Blocks Set +Brand: BlockMaster +Category: Building Blocks +Recommended Age: 5 years and above +Warranty: 12-month limited warranty against manufacturing defects + +Product Overview: +The Deluxe City Building Blocks Set is a large set of colorful interlocking blocks designed for creative construction play. It supports imagination, spatial thinking, and motor skill development. + +Box Contents: +- Assorted interlocking blocks +- Wheel pieces +- Window and door pieces +- Starter build guide + +Usage Instructions: +- Sort pieces by size and color before building. +- Use a flat surface for larger structures. +- Follow the starter build guide or create custom designs. + +Safety Notes: +- Not suitable for children under 3 years due to small parts. +- Adult supervision is recommended for younger children. +- Do not place pieces near heat sources. + +Care Instructions: +- Wipe pieces with a soft damp cloth. +- Do not use harsh chemicals. +- Dry completely before storing. + +Support Notes: +- Missing pieces reported within 7 days of delivery may qualify for replacement. +- Cosmetic scratches from normal use are not considered defects. + +======================================== +2. Mini Robot Construction Kit +Brand: RoboBuild +Category: Building Blocks +Recommended Age: 7 years and above +Warranty: 12-month limited warranty against manufacturing defects + +Product Overview: +The Mini Robot Construction Kit lets children assemble a small robot model using guided instructions. It is designed for hands-on building and beginner engineering play. + +Box Contents: +- Robot body pieces +- Connectors and joints +- Instruction booklet + +Assembly Instructions: +- Open all parts and compare with the instruction sheet. +- Build the base first, then the body, then the arms and head. +- Press connectors firmly but do not force them. + +Safety Notes: +- Small components present a choking hazard for young children. +- Keep parts away from pets and toddlers. + +Care Instructions: +- Store unused pieces in the box after play. +- Clean plastic pieces with a dry cloth or lightly damp cloth. + +Support Notes: +- Missing or broken parts due to manufacturing issues may be replaced if reported within 7 days of receipt. +- Damage caused by forced assembly is not covered under warranty. + +======================================== +3. Princess Royal Doll +Brand: Dreamland Dolls +Category: Dolls +Recommended Age: 4 years and above +Warranty: 6-month limited warranty against stitching or accessory defects + +Product Overview: +The Princess Royal Doll includes a decorative gown and themed accessories for imaginative royal storytelling. + +Box Contents: +- 1 doll +- 1 gown +- 1 accessory set + +Usage Guidance: +- Handle accessories gently to avoid bending small decorative parts. +- Suitable for pretend play, display, and themed gift use. + +Safety Notes: +- Contains small accessories that should be kept away from children under 3. +- Do not machine wash the doll or clothing. + +Care Instructions: +- Clean surface gently with a soft cloth. +- Keep away from excessive moisture. +- Store accessories in a pouch or box after play. + +Support Notes: +- Loose seams, detached accessories on arrival, or damaged packaging affecting the product should be reported within 48 hours. + +======================================== +4. Kids First Microscope Kit +Brand: Science Explorers +Category: Educational Toys +Recommended Age: 8 years and above +Warranty: 12-month limited warranty against manufacturing defects + +Product Overview: +The Kids First Microscope Kit is an entry-level science toy designed to help children explore slides, textures, and tiny objects safely. + +Box Contents: +- Microscope unit +- Sample slides +- Basic observation tools +- Introductory guide + +Usage Instructions: +- Place the microscope on a stable table. +- Insert a prepared slide. +- Adjust the focus slowly until the sample becomes clear. +- Use under good lighting conditions. + +Safety Notes: +- This is a learning toy, not a lab-grade scientific instrument. +- Do not point reflective parts toward direct sunlight. +- Use included tools carefully and under adult guidance. + +Care Instructions: +- Wipe lens area gently using a microfiber cloth only. +- Keep slides in a dry place. +- Do not drop the microscope. + +Support Notes: +- Blurred viewing caused by improper focus is not considered a defect. +- Cracked body, broken knobs, or damaged slides on arrival should be reported immediately. + +======================================== +5. Coding Robot for Kids +Brand: Code & Play +Category: Educational Toys +Recommended Age: 8 years and above +Warranty: 12-month limited warranty against manufacturing defects + +Product Overview: +The Coding Robot for Kids introduces basic programming logic through interactive challenges and programmable play patterns. + +Box Contents: +- Coding robot +- Programming activity guide +- Charging or battery instructions card + +Power and Battery Guidance: +- Use only the recommended battery type or charging method listed on the package. +- Remove batteries if the robot will not be used for a long period. +- Battery leakage damage is not covered under warranty. + +Usage Instructions: +- Turn on the robot using the main power switch. +- Follow the activity guide to enter beginner command patterns. +- Test movement on a smooth indoor surface. + +Safety Notes: +- Keep electronics away from water. +- Do not use damaged batteries. +- Adult help is recommended during first-time setup. + +Care Instructions: +- Clean outer body with a dry cloth. +- Avoid dropping the unit. +- Store in a cool and dry place. + +Support Notes: +- Software-like behavior issues should first be tested with fresh batteries or a full charge. +- Non-working motion or unresponsive controls on arrival may qualify for replacement review. + +======================================== +6. High-Speed RC Race Car +Brand: Speed Racers +Category: Remote Control Toys +Recommended Age: 8 years and above +Warranty: 6-month limited warranty against manufacturing defects + +Product Overview: +The High-Speed RC Race Car is designed for fast-paced driving with responsive steering and durable body construction. + +Box Contents: +- RC car +- Remote controller +- User instruction leaflet + +Power Guidance: +- Battery requirements depend on the included packaging instructions. +- Use only recommended battery types. +- Remove batteries after extended non-use. + +Usage Instructions: +- Use on clean and mostly dry surfaces. +- Switch on the remote first if instructed, then the car. +- Avoid collisions with walls, curbs, and water. + +Safety Notes: +- Not intended for road use. +- Avoid use in rain or standing water. +- Keep fingers away from moving wheels. + +Care Instructions: +- Wipe tires and body after use. +- Allow the car to cool before storage after long play sessions. +- Do not store with leaking batteries. + +Support Notes: +- Damage from crashes, water exposure, or incorrect batteries is not covered. +- Steering or motor defects on arrival should be reported within 48 hours. + +======================================== +7. Kids Outdoor Swing Set +Brand: Backyard Adventures +Category: Outdoor Toys +Recommended Age: 5 years and above with adult supervision +Warranty: 12-month limited warranty against manufacturing defects + +Product Overview: +The Kids Outdoor Swing Set is a large backyard play product that includes two swings and a glider for supervised outdoor use. + +Box Contents: +- Swing frame parts +- Two swing seats +- One glider +- Assembly hardware +- Instruction manual + +Assembly Instructions: +- Assemble only on level ground. +- Follow the hardware sequence in the instruction manual. +- Check bolt tightness after assembly and at regular intervals. + +Safety Notes: +- Adult assembly is required. +- Adult supervision is required during use. +- Do not exceed the stated weight and age guidance on the packaging. +- Do not install on concrete or unsafe surfaces without proper ground protection. + +Care Instructions: +- Inspect joints, bolts, and seats regularly. +- Store or cover components during severe weather when possible. +- Surface wear from outdoor exposure should be monitored regularly. + +Support Notes: +- Rust or wear caused by long-term weather exposure is considered maintenance-related unless present on delivery. +- Missing hardware must be reported within 7 days of delivery. + +======================================== +General Warranty Policy Summary + +1. Warranty covers manufacturing defects only. +2. Warranty does not cover accidental damage, misuse, battery leakage, water exposure, forced assembly, or normal wear and tear. +3. Proof of purchase may be required for warranty support. +4. For missing parts, damaged-on-arrival issues, or immediate performance problems, customers should contact support as early as possible. +5. Warranty periods may vary by product category and are listed in the relevant product section above. \ No newline at end of file diff --git a/backend/python/week8/data/return_policy.txt b/backend/python/week8/data/return_policy.txt new file mode 100644 index 000000000..3b35cb4a8 --- /dev/null +++ b/backend/python/week8/data/return_policy.txt @@ -0,0 +1,150 @@ +Return Policy + +This return policy explains how returns, replacements, refunds, and exchanges are handled for toy store inventory, including damaged items, missing parts, defective products, unopened returns, and special-category products. + +======================================== +1. Standard Return Window + +1. Most eligible items may be returned within 14 days of delivery. +2. To qualify, the item should normally be returned with original packaging, accessories, manuals, and included parts. +3. Proof of purchase or order confirmation may be required. + +======================================== +2. Damaged or Defective Items + +1. If an item arrives damaged, defective, or non-functional, the customer should report it as soon as possible. +2. For damaged-on-arrival products, photo evidence of the item and packaging may be requested. +3. Depending on review, the customer may receive: + - a replacement + - a refund + - missing parts support + - store credit, if applicable +4. Damaged items caused during shipping should be reported promptly for faster resolution. + +======================================== +3. Missing Parts or Incomplete Box Claims + +1. Missing parts for products such as building sets, science kits, RC toys, and outdoor play equipment should be reported within 7 days of delivery. +2. The customer should provide: + - order details + - product name + - description of missing part(s) + - photos if requested +3. In many cases, replacement parts may be sent instead of processing a full return. + +======================================== +4. Unopened and Unused Items + +1. Unopened, unused items in resalable condition are generally eligible for return within the standard return window. +2. Items with heavily damaged retail packaging may be reviewed before approval. +3. Refunds for unopened items are generally processed to the original payment method after inspection. + +======================================== +5. Opened Items + +1. Opened items may still be reviewed for return if they are defective, incomplete on arrival, or damaged in transit. +2. Opened items used heavily, altered, or returned without key accessories may not qualify for a full refund. +3. If the product is functional and shows signs of regular use, the return may be rejected or partially adjusted. + +======================================== +6. Exchanges + +1. Exchanges may be offered for the same item when stock is available. +2. If the same item is unavailable, a refund or alternate resolution may be offered. +3. Exchange approval depends on product condition, stock availability, and return reason. + +======================================== +7. Refund Timelines + +1. Once the returned item is received and inspected, refund processing may take several business days. +2. Final posting time depends on the payment provider or bank. +3. Shipping charges may or may not be refunded depending on the reason for return. + +======================================== +8. Non-Returnable or Restricted Cases + +The following cases may not qualify for return or refund: +1. Damage caused by misuse, rough handling, crashes, forced assembly, or water exposure. +2. Battery leakage damage caused by incorrect storage or use. +3. Normal wear and tear from regular use. +4. Products returned without essential components. +5. Items marked final sale, clearance, or non-returnable at the time of purchase. +6. Chemistry, experiment, or learning kits that are opened and used may be restricted depending on condition and safety concerns. + +======================================== +9. Outdoor and Large Product Returns + +1. Large outdoor products such as swing sets must be inspected promptly after delivery. +2. Missing hardware, broken structural parts on arrival, or major shipping damage should be reported immediately. +3. Damage caused by weather exposure after installation is not considered a returnable defect. +4. Incorrect installation or assembly damage does not qualify for standard return coverage. + +======================================== +10. Electronics and Remote Control Items + +1. Remote control products and electronic toys should be tested soon after delivery. +2. Non-working units on arrival may qualify for replacement or refund review. +3. Damage caused by wrong battery type, leaked batteries, or water exposure is not covered. +4. If a troubleshooting step is required, the customer may be asked to try battery replacement, charging, pairing, or reset guidance first. + +======================================== +11. Plush Toys and Fabric-Based Products + +1. Plush or fabric-based products returned for preference reasons should be clean and unused. +2. Items with signs of washing, staining, odor, or heavy use may not qualify. +3. Manufacturing issues such as seam problems on arrival may be reviewed as defects. + +======================================== +12. Puzzles, Board Games, and Multi-Part Sets + +1. Missing pieces should be reported as early as possible. +2. The customer may be asked whether the seal was intact at delivery. +3. Depending on stock and supplier support, replacement pieces or a full replacement may be offered. + +======================================== +13. Holiday and Gift Returns + +1. Gift purchases made during major holiday periods may be reviewed under an extended return timeline if that policy is active at the time of sale. +2. Gift receipts may allow exchange or store credit depending on internal policy. + +======================================== +14. Bulk Orders and Commercial Purchases + +1. Bulk orders may be subject to a different return review process. +2. Large-volume returns may require approval before shipment back to the warehouse. +3. Restocking fees may apply in some approved commercial-return cases. + +======================================== +15. Lost or Missing Shipment Cases + +1. If tracking shows delivery issues or the package cannot be located, the customer should report the issue promptly. +2. Resolution may involve shipment investigation before refund or replacement is approved. + +======================================== +16. How to Start a Return + +To request a return or replacement, provide: +- Order number +- Product name +- Reason for return +- Photos, if the item is damaged, defective, or incomplete +- Whether you want a refund, replacement, or exchange + +======================================== +17. Quick Examples + +Example 1: +Question: What is the return policy for damaged items? +Answer: Damaged or defective items should be reported quickly, preferably with photos. Depending on the case, the customer may receive a replacement, refund, missing parts support, or store credit after review. + +Example 2: +Question: Can I return a swing set after outdoor weather damage? +Answer: Weather-related wear after installation is not treated as a returnable defect. + +Example 3: +Question: What if my building blocks set is missing parts? +Answer: Missing-part claims should be reported within 7 days of delivery and may be resolved through replacement parts rather than a full return. + +Example 4: +Question: Can I return an RC car that stopped working after using the wrong batteries? +Answer: Damage caused by incorrect battery use is not covered under the standard return policy. \ No newline at end of file diff --git a/backend/python/week8/data/vendor_faq.txt b/backend/python/week8/data/vendor_faq.txt new file mode 100644 index 000000000..664c110c1 --- /dev/null +++ b/backend/python/week8/data/vendor_faq.txt @@ -0,0 +1,129 @@ +Vendor FAQ + +This FAQ explains common vendor and supply questions related to toy inventory, restocking, packaging, damaged shipments, documentation, and replacement support. + +======================================== +General Supply Questions + +Q1. How often do vendors restock products? +A. Restock frequency depends on the product line. Fast-moving categories such as dolls, action figures, plush toys, and outdoor toys may be replenished more often than large or specialized items. + +Q2. Which products usually take longer to restock? +A. Large items, educational kits with many parts, and remote control toys often have longer lead times than small standard products. + +Q3. Are all products restocked automatically? +A. No. Some products are reordered based on demand, seasonality, supplier availability, and current stock levels. + +Q4. Can discontinued products still be reordered? +A. Discontinued products are generally not restocked. Vendors may instead recommend a similar replacement item in the same category. + +Q5. Do vendors provide expected lead times? +A. Yes. Lead times are usually estimated, not guaranteed. Shipment delays can happen because of manufacturer schedules, customs, weather, or carrier issues. + +======================================== +Product Availability Questions + +Q6. Why is a product showing low stock? +A. Low stock may result from high sales, delayed supplier shipments, seasonal demand, or limited production runs. + +Q7. Can vendors reserve stock for large orders? +A. Reservation depends on supplier policy and available inventory. Large-volume requests may require advance notice and confirmation. + +Q8. Are backorders allowed? +A. Backorder support depends on the vendor and the product. Some popular items may be backordered if the supplier confirms incoming stock. + +Q9. Are seasonal products harder to source? +A. Yes. Outdoor toys and holiday gift items may experience demand spikes, which can increase lead times or reduce availability. + +Q10. Which categories are most affected by seasonal demand? +A. Outdoor toys, dolls, plush toys, building blocks, and educational toys may all see seasonal demand changes depending on school periods, holidays, and weather. + +======================================== +Packaging and Shipment Questions + +Q11. What should we do if vendor cartons arrive damaged? +A. Record the issue immediately, keep photos of the outer carton and inner packaging, and report the damage for supplier review. + +Q12. Can products still be accepted if only the outer carton is damaged? +A. Yes, if the item itself is unaffected and packaging damage is minor. If retail presentation is important, the issue should still be logged. + +Q13. What if a shipment has missing units? +A. Verify the packing list, recount received units, and report shortages along with purchase order details. + +Q14. Do vendors supply replacement parts for missing components? +A. Some vendors support replacement parts for items such as building kits, microscopes, swing hardware, and RC accessories. Approval depends on the product line and claim window. + +Q15. Are shipments insured? +A. Shipping terms vary by supplier. Some vendors offer carrier-supported claims, while others require immediate inspection and reporting after receipt. + +======================================== +Quality and Safety Questions + +Q16. Do vendors provide safety documentation? +A. Many vendors provide safety and compliance information relevant to toys and educational products. Availability depends on supplier standards and region. + +Q17. What if a toy arrives with a manufacturing defect? +A. The issue should be documented with product name, batch or shipment reference if available, photos, and a clear description of the defect. + +Q18. Are batteries included with electronic or remote control products? +A. Battery inclusion depends on the supplier and the product packaging. Always verify packaging notes before customer communication. + +Q19. Are chemistry or science kits handled differently? +A. Yes. Educational kits such as microscope and chemistry products may have extra handling instructions, packaging checks, or safety inserts. + +Q20. Can product manuals be requested from vendors? +A. Yes. Digital manuals, instruction leaflets, or care notes may be available from the supplier for supported products. + +======================================== +Pricing and Ordering Questions + +Q21. Do vendors offer bulk discounts? +A. Some vendors offer volume-based pricing for larger orders, especially on repeat or seasonal purchases. + +Q22. Can prices change after a purchase order is placed? +A. Approved purchase orders are usually honored, but future orders may reflect updated supplier pricing, freight costs, or seasonal adjustments. + +Q23. Can vendors support promotional bundles? +A. In some cases, vendors can suggest bundle-friendly products or coordinated stock planning for campaigns. + +Q24. Is there a minimum order quantity? +A. Minimum order quantity varies by vendor and product category. Larger items or imported items may have stricter order thresholds. + +Q25. Can urgent restock requests be expedited? +A. Some vendors can prioritize urgent restocks, but this depends on available stock, transport options, and internal supplier approval. + +======================================== +Product-Specific Questions + +Q26. Can missing block pieces be reordered for building sets? +A. Some suppliers may support part replacement for building products if the claim is made within the allowed support window. + +Q27. What if a coding robot stops responding after setup? +A. Vendors typically ask for troubleshooting first, such as checking batteries, charge state, and reset steps, before processing a defect claim. + +Q28. Can remote control toy accessories be replaced separately? +A. Depending on the supplier, controllers, wheels, antenna components, or charging accessories may be available as service parts. + +Q29. What support exists for large outdoor items like swing sets? +A. Vendors may provide hardware lists, assembly guidance, and missing-part support, but installation issues caused by incorrect assembly are usually excluded. + +Q30. Are educational products like microscopes and globes supported with instruction guides? +A. Yes, many learning products include or can provide digital guides to help with setup and first use. + +======================================== +Escalation Questions + +Q31. When should a vendor issue be escalated? +A. Escalate when there is repeated shipment damage, unresolved shortages, recurring defects, delayed replacements, or major lead time failures. + +Q32. What information should be shared during escalation? +A. Include product name, quantity affected, shipment or order reference, photos if relevant, issue summary, and expected resolution. + +Q33. Can vendors replace stock instead of issuing a credit? +A. Depending on the situation, suppliers may offer replacement units, spare parts, credit notes, or a future shipment adjustment. + +Q34. How quickly should shortages or damages be reported? +A. As soon as possible after receipt, ideally within the reporting window set by the vendor or internal receiving process. + +Q35. Can vendor responses differ by product category? +A. Yes. Support processes often differ for electronics, outdoor equipment, plush products, educational kits, and multi-part construction toys. \ No newline at end of file diff --git a/backend/python/week8/eval_rag.py b/backend/python/week8/eval_rag.py new file mode 100644 index 000000000..1ad211aa6 --- /dev/null +++ b/backend/python/week8/eval_rag.py @@ -0,0 +1,74 @@ +# this file checks whether the ans behaves sensibly end to end +from week8.rag_pipeline import run_rag_pipeline +from week8.config import DEFAULT_TOP_K + + +RAG_TEST_CASES = [ + { + "query": "What's the return policy for damaged items?", + "expected_keywords": ["damaged", "replacement", "refund"], + }, + { + "query": "What is the warranty period for Coding Robot for Kids?", + "expected_keywords": ["12-month", "warranty"], + }, + { + "query": "Can vendors provide replacement parts for missing components?", + "expected_keywords": ["replacement parts", "vendors", "components"], + }, + { + "query": "If I use the wrong batteries and my toy stops working, is that covered?", + "expected_keywords": ["battery", "not covered"], + }, + { + "query": "Why do educational kits take longer to restock sometimes?", + "expected_keywords": ["lead times", "educational", "restock"], + }, +] + + +def evaluate_rag(top_k: int = DEFAULT_TOP_K): + results_summary = [] + for test_case in RAG_TEST_CASES: + query = test_case["query"] + expected_keywords = test_case["expected_keywords"] + rag_result = run_rag_pipeline(query, top_k=top_k) + answer = rag_result["answer"].lower() + matched_keywords = [ + keyword for keyword in expected_keywords if keyword.lower() in answer + ] + all_keywords_found = len(matched_keywords) == len(expected_keywords) + results_summary.append( + { + "query": query, + "expected_keywords": expected_keywords, + "matched_keywords": matched_keywords, + "all_keywords_found": all_keywords_found, + "answer": rag_result["answer"], + "retrieved_chunks": rag_result["retrieved_chunks"], + } + ) + return results_summary + + +if __name__ == "__main__": + evaluation_results = evaluate_rag(top_k=DEFAULT_TOP_K) + total_cases = len(evaluation_results) + passed_case = 0 + print("RAG Evaluation Results\n") + for index, result in enumerate(evaluation_results, start=1): + print("=" * 100) + print(f"Test Case #{index}") + print(f"Query: {result['query']}") + print(f"Answer: {result['answer']}") + print(f"Expected Keywords: {result['expected_keywords']}") + print(f"Matched Keywords: {result['matched_keywords']}") + print(f"All Keywords Found: {result['all_keywords_found']}") + if result["all_keywords_found"]: + passed_case += 1 + + score = passed_case/total_cases if total_cases else 0 + print("\n" + "=" * 100) + print(f"Total Test Cases: {total_cases}") + print(f"Passed Cases: {passed_case}") + print(f"RAG Score: {score:.2f}") diff --git a/backend/python/week8/eval_retrieval.py b/backend/python/week8/eval_retrieval.py new file mode 100644 index 000000000..6bd0ffe03 --- /dev/null +++ b/backend/python/week8/eval_retrieval.py @@ -0,0 +1,74 @@ +# this is for task 3c +# it just checks whether retrieval is actually returning the correct source document or not +from week8.retriever import retrieve_relavant_chunks +from week8.config import DEFAULT_TOP_K + + +RETRIEVAL_TEST_CASES = [ + { + "query": "What's the return policy for damaged items?", + "expected_source": "return_policy.txt", + }, + { + "query": "What is the warranty period for Coding Robot for Kids?", + "expected_source": "product_manual.txt", + }, + { + "query": "Can vendors provide replacement parts for missing components?", + "expected_source": "vendor_faq.txt", + }, + { + "query": "If a swing set part gets damaged after weather exposure, is it covered?", + "expected_source": "return_policy.txt", + }, + { + "query": "Why do educational kits and remote-control toys sometimes take longer to restock?", + "expected_source": "vendor_faq.txt", + }, +] + + +def evaluate_retrieval(top_k: int = DEFAULT_TOP_K): + results_summary = [] + for test_case in RETRIEVAL_TEST_CASES: + query = test_case["query"] + expected_source = test_case["expected_source"] + retrieved_chunks = retrieve_relavant_chunks(query, top_k=top_k) + retrieved_sources = [chunk["source"] for chunk in retrieved_chunks] + is_match = False + if expected_source in retrieved_sources: + is_match = True + results_summary.append( + { + "query": query, + "expected_source": expected_source, + "retrieved_sources": retrieved_sources, + "match_found": is_match, + } + ) + return results_summary + + +if __name__ == "__main__": + evaluation_results = evaluate_retrieval(top_k=DEFAULT_TOP_K) + correct_matches = 0 + print("Retrieval Evaluation Results\n") + + for index, result in enumerate(evaluation_results, start=1): + print("=" * 100) + print(f"Test Case #{index}") + print(f"Query: {result['query']}") + print(f"Expected Source: {result['expected_source']}") + print(f"Retrieved Sources: {result['retrieved_sources']}") + print(f"Match Found: {result['match_found']}") + + if result["match_found"]: + correct_matches += 1 + + total_cases = len(evaluation_results) + accuracy = correct_matches / total_cases if total_cases else 0 + + print("\n" + "=" * 100) + print(f"Total Test Cases: {total_cases}") + print(f"Correct Matches: {correct_matches}") + print(f"Retrieval Accuracy: {accuracy:.2f}") diff --git a/backend/python/week8/ingest_documents.py b/backend/python/week8/ingest_documents.py new file mode 100644 index 000000000..f79d5d373 --- /dev/null +++ b/backend/python/week8/ingest_documents.py @@ -0,0 +1,28 @@ +# it connects knowlege_base, text_chunker, vector_store +from week8.knowledge_base import load_text_documents, list_available_sources +from week8.text_chunker import split_documents +from week8.vector_store import index_documents + +# 1. load the source file +# 2. split them into chunks +# 3. store those chunks in chromaDB + + +def ingest_knowledge_base(): + documents = load_text_documents() + chunked_documents = split_documents(documents) + vector_store = index_documents(chunked_documents) + return { + "source_files": list_available_sources(), + "document_count": len(documents), + "chunk_count": len(chunked_documents), + "vector_store": vector_store, + } + + +if __name__ == "__main__": + result = ingest_knowledge_base() + print("Knowledge base ingestion completed.") + print(f"Source files: {result['source_files']}") + print(f"Loaded documents: {result['document_count']}") + print(f"Created chunks: {result['chunk_count']}") diff --git a/backend/python/week8/knowledge_base.py b/backend/python/week8/knowledge_base.py new file mode 100644 index 000000000..35b70e43b --- /dev/null +++ b/backend/python/week8/knowledge_base.py @@ -0,0 +1,49 @@ +# this file loads the source doc in a clean, structured way before chunkuing +from pathlib import Path +from langchain_core.documents import Document +from week8.config import DATA_DIR, DOCUMENT_FILE_MAP + + +# 1. go to week8/data +# 2. find all .txt files +# 3. read their content +# 4. attach metadata +# 5. returns them in a structured format + + +# loads .txt files from week8/data and returns a list of langchain document object +def load_text_documents(data_dir: Path = DATA_DIR): + documents = [] + + for file_path in sorted(data_dir.glob("*.txt")): + text = file_path.read_text(encoding="utf-8").strip() + if not text: + continue + + doc_info = DOCUMENT_FILE_MAP.get( + file_path.name, + { + "doc_type": "generic_text", + "title": file_path.stem.replace("_", " ").title(), + }, + ) + documents.append( + Document( + page_content=text, + metadata={ + "source": file_path.name, + "doc_type": doc_info["doc_type"], + "title": doc_info["title"], + }, + ) + ) + + return documents + + +# return the name of .txt file available +def list_available_sources(data_dir: Path = DATA_DIR): + sources = [] + for file_path in sorted(data_dir.glob("*.txt")): + sources.append(file_path.name) + return sources diff --git a/backend/python/week8/langsmith_setup.py b/backend/python/week8/langsmith_setup.py new file mode 100644 index 000000000..f1976f2b9 --- /dev/null +++ b/backend/python/week8/langsmith_setup.py @@ -0,0 +1,35 @@ +# this prepares the env so that week8 can send traces to LangSmith +import os +from dotenv import load_dotenv +from week8.config import TRACING_PROJECT_NAME + +load_dotenv() + + +def setup_langsmith_tracing(): + api_key = os.getenv("LANGSMITH_API_KEY") + if not api_key: + return False + os.environ["LANGSMITH_API_KEY"] = api_key + os.environ["LANGSMITH_TRACING"] = "true" + os.environ["LANGSMITH_PROJECT"] = TRACING_PROJECT_NAME + endpoint = os.getenv("LANGSMITH_ENDPOINT") + if endpoint: + os.environ["LANGSMITH_ENDPOINT"] = endpoint + + return True + + +def is_langsmith_enabled(): + return os.getenv("LANGSMITH_TRACING", "").lower() == "true" + + +if __name__ == "__main__": + enabled = setup_langsmith_tracing() + if enabled: + print("LangSmith tracing is enabled.") + print(f"Project: {os.getenv('LANGSMITH_PROJECT')}") + if os.getenv("LANGSMITH_ENDPOINT"): + print(f"Endpoint: {os.getenv('LANGSMITH_ENDPOINT')}") + else: + print("LangSmith tracing is disabled because LANGSMITH_API_KEY was not found.") diff --git a/backend/python/week8/llm_client.py b/backend/python/week8/llm_client.py new file mode 100644 index 000000000..a7c54f197 --- /dev/null +++ b/backend/python/week8/llm_client.py @@ -0,0 +1,50 @@ +# this file communicates with gemini +import os +from dotenv import load_dotenv +from google import genai +from week8.config import GEMINI_MODEL_NAME, GEMINI_TEMPERATURE +from langsmith import traceable + + +load_dotenv() + +# retriever => retrieval onlu +# prompt_builder => prompt only +# llm_client => gemini call only + +# 1. load gemini api key +# 2. create gemini client +# 3. send prompt to gemini +# 4. get back final ans text + + +def get_gemini_client(): + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + raise ValueError("GEMINI_API_KEY is missing in the environment.") + return genai.Client(api_key=api_key) + + +@traceable( + run_type="llm", + name="gemini_generate_answer", + metadata={ + "ls_provider": "google_genai", + "ls_model_name": GEMINI_MODEL_NAME, + }, +) +def generate_answer(prompt: str) -> str: + client = get_gemini_client() + + response = client.models.generate_content( + model=GEMINI_MODEL_NAME, + contents=prompt, + config={ + "temperature": GEMINI_TEMPERATURE, + }, + ) + + if not response.text: + return "I could not generate a response." + + return response.text.strip() diff --git a/backend/python/week8/prompt_builder.py b/backend/python/week8/prompt_builder.py new file mode 100644 index 000000000..706ed3cda --- /dev/null +++ b/backend/python/week8/prompt_builder.py @@ -0,0 +1,46 @@ +# bridge between retieval and LLM answer generation + +# this file takes user question and retrievd chunks and combines them into one final prompt string that will be sent to gemini + + +def build_context_blocks(retrieved_chunks): + context_parts = [] + for index, chunk in enumerate(retrieved_chunks, start=1): + context_parts.append( + f"""Source #{index} +Title: {chunk.get("title", "Unknown")} +Source File: {chunk.get("source", "unknown")} +Document Type: {chunk.get("doc_type", "unknown")} +Chunk Index: {chunk.get("chunk_index", -1)} +Content: +{chunk.get("text", "")} +""" + ) + + return "\n" + ("\n" + "-" * 80 + "\n").join(context_parts) + + +def build_rag_prompt(user_query, retrieved_chunks): + context_block = build_context_blocks(retrieved_chunks) + prompt = f""" +You are an inventory support assistant for a toy store. + +Answer the user's question only from the provided context. +Do not make up facts. +Do not use outside knowledge. +If the answer is not clearly present in the context, say: +"I could not find that information in the provided documents." + +Be clear, concise, and helpful. +If multiple sources support the answer, combine them carefully. +If relevant, mention the source title in your answer. + +User Question: +{user_query} + +Retrieved Context: +{context_block} + +Final Answer: +""" + return prompt.strip() diff --git a/backend/python/week8/rag_pipeline.py b/backend/python/week8/rag_pipeline.py new file mode 100644 index 000000000..0ca75e4f6 --- /dev/null +++ b/backend/python/week8/rag_pipeline.py @@ -0,0 +1,39 @@ +from week8.prompt_builder import build_rag_prompt +from week8.retriever import retrieve_relavant_chunks +from week8.llm_client import generate_answer +from week8.config import DEFAULT_TOP_K +from langsmith import traceable +from week8.langsmith_setup import setup_langsmith_tracing + +setup_langsmith_tracing() + + +@traceable(run_type="chain", name="week8_rag_pipeline") +def run_rag_pipeline(user_query, top_k: int = DEFAULT_TOP_K): + retrieved_chunks = retrieve_relavant_chunks(user_query, top_k=top_k) + prompt = build_rag_prompt(user_query, retrieved_chunks) + answer = generate_answer(prompt) + return { + "query": user_query, + "answer": answer, + "retrieved_chunks": retrieved_chunks, + "prompt": prompt + } + + +if __name__ == "__main__": + query = input("Enter your question: ").strip() + if not query: + print("No query entered.") + else: + result = run_rag_pipeline(query) + print(f"Query: {result['query']}\n") + print(f"Answer: {result['answer']}\n") + print("\nRetrieved Chunks:") + for index, chunk in enumerate(result["retrieved_chunks"], start=1): + print("=" * 80) + print(f"Result #{index}") + print(f"Source: {chunk['source']}") + print(f"Title: {chunk['title']}") + print(f"Chunk Index: {chunk['chunk_index']}") + print(chunk["text"]) diff --git a/backend/python/week8/retriever.py b/backend/python/week8/retriever.py new file mode 100644 index 000000000..0c9672c24 --- /dev/null +++ b/backend/python/week8/retriever.py @@ -0,0 +1,36 @@ +from week8.vector_store import search_similar_chunks +from week8.config import DEFAULT_TOP_K +from langsmith import traceable + + +@traceable(run_type="retriever", name="week8_retrieve_relevant_chunks") +def retrieve_relavant_chunks(query, top_k: int = DEFAULT_TOP_K): + results = search_similar_chunks(query, top_k) + retrieved_chunks = [] + for doc in results: + retrieved_chunks.append( + { + "text": doc.page_content, + "source": doc.metadata.get("source", "unknown"), + "doc_type": doc.metadata.get("doc_type", "unknown"), + "title": doc.metadata.get("title", "Unknown"), + "chunk_index": doc.metadata.get("chunk_index", -1), + } + ) + return retrieved_chunks + + +if __name__ == "__main__": + query = "What's the return policy for damaged items?" + chunks = retrieve_relavant_chunks(query) + print(f"Query: {query}\n") + for index, chunk in enumerate(chunks, start=1): + print("=" * 80) + print(f"Result #{index}") + print(f"Source: {chunk['source']}") + print(f"Title: {chunk['title']}") + print(f"Doc Type: {chunk['doc_type']}") + print(f"Chunk Index: {chunk['chunk_index']}") + print("Text:") + print(chunk["text"]) + print() diff --git a/backend/python/week8/stock_lookup.py b/backend/python/week8/stock_lookup.py new file mode 100644 index 000000000..73b0873e5 --- /dev/null +++ b/backend/python/week8/stock_lookup.py @@ -0,0 +1,38 @@ +# for adv task we have to retrieve stock level as well +from week4.db_connection import initialize_mongo +from week4.models import Product + + +def get_product_stock(product_name): + initialize_mongo() + product = Product.objects(name__icontains=product_name).first() + if not product: + return None + return { + "id": product.id, + "name": product.name, + "brand": product.brand, + "category": product.category.title if product.category else None, + "price": float(product.price), + "quantity": product.quantity, + "description": product.description, + } + + +if __name__ == "__main__": + query_name = input("Enter product name to check stock: ").strip() + if not query_name: + print("No Product name entered.") + else: + result = get_product_stock(query_name) + if not result: + print("Product not found in the database.") + else: + print("Product found:") + print(f"ID: {result['id']}") + print(f"Name: {result['name']}") + print(f"Brand: {result['brand']}") + print(f"Category: {result['category']}") + print(f"Price: {result['price']}") + print(f"Quantity: {result['quantity']}") + print(f"Description: {result['description']}") diff --git a/backend/python/week8/testing.py b/backend/python/week8/testing.py new file mode 100644 index 000000000..e6bb1347a --- /dev/null +++ b/backend/python/week8/testing.py @@ -0,0 +1,18 @@ +from week8.knowledge_base import load_text_documents +from week8.text_chunker import split_documents + +documents = load_text_documents() +chunks = split_documents(documents) + +print("Total chunks:", len(chunks)) +print() + +for chunk in chunks: + print("=" * 80) + print("SOURCE:", chunk.metadata["source"]) + print("DOC TYPE:", chunk.metadata["doc_type"]) + print("TITLE:", chunk.metadata["title"]) + print("CHUNK INDEX:", chunk.metadata["chunk_index"]) + print("TEXT:") + print(chunk.page_content) + print() diff --git a/backend/python/week8/text_chunker.py b/backend/python/week8/text_chunker.py new file mode 100644 index 000000000..057dffd38 --- /dev/null +++ b/backend/python/week8/text_chunker.py @@ -0,0 +1,21 @@ +# knowledeg_base loads full document +# now this file will break them into pieces +from langchain_core.documents import Document +from langchain_text_splitters import RecursiveCharacterTextSplitter +from week8.config import CHUNK_SIZE, CHUNK_OVERLAP + +# RecursiveCharacterTextSplitter takes long documents and split them into smaller pieces, preserves metadata while splitting + + +def split_documents(documents: list[Document]): + # try splliting by para, if needed split by line, if needed split by sentence boundarym, if needed split by word spaces, if nothing works split by character wise + # here we are just setting the rules that how to split text + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, separators=[ + "\n\n", "\n", ". ", " ", ""] + ) + # this gives call to split text + split_docs = text_splitter.split_documents(documents) + for index, doc in enumerate(split_docs): + doc.metadata["chunk_index"] = index + 1 + return split_docs diff --git a/backend/python/week8/vector_store.py b/backend/python/week8/vector_store.py new file mode 100644 index 000000000..1fbd0ee0c --- /dev/null +++ b/backend/python/week8/vector_store.py @@ -0,0 +1,54 @@ +# this file is the bridge between chunked texts and searchable semantic retrieval +from functools import lru_cache +from pathlib import Path +from langchain_chroma import Chroma +from langchain_core.documents import Document +from langchain_core.embeddings import Embeddings +from sentence_transformers import SentenceTransformer +from week8.config import CHROMA_COLLECTION_NAME, CHROMA_PERSIST_DIR, EMBEDDING_MODEL_NAME, DEFAULT_TOP_K + +# 1. it connects chunked document to chromaDB +# 2. it gives a way to search similar chunks + + +@lru_cache(maxsize=1) +def load_embedding_model(): + return SentenceTransformer(EMBEDDING_MODEL_NAME) + + +# this class converts plain sentencetransformer model into langchain compatible embedding object +class LocalSentenceTransformerEmbeddings(Embeddings): + def __init__(self, model_name: str = EMBEDDING_MODEL_NAME): + self.model = load_embedding_model() + self.model_name = model_name + + def embed_documents(self, texts: list[str]): + embeddings = self.model.encode(texts, convert_to_numpy=True) + return embeddings.tolist() + + def embed_query(self, text): + embedding = self.model.encode(text, convert_to_numpy=True) + return embedding.tolist() + + +# creates or loads chroma vector store +def get_vector_store(persist_directory: Path = CHROMA_PERSIST_DIR): + persist_directory.mkdir(parents=True, exist_ok=True) + return Chroma( + collection_name=CHROMA_COLLECTION_NAME, + persist_directory=str(persist_directory), + embedding_function=LocalSentenceTransformerEmbeddings(), + ) + + +# takes chunked documents and adds them to chroma.db +def index_documents(documents: list[Document]): + vector_store = get_vector_store() + vector_store.add_documents(documents) + return vector_store + + +# searches the vector using a user query +def search_similar_chunks(query: str, top_k: int = DEFAULT_TOP_K): + vector_store = get_vector_store() + return vector_store.similarity_search(query, k=top_k) diff --git a/backend/python/week9_10/__init__.py b/backend/python/week9_10/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/week9_10/agent.py b/backend/python/week9_10/agent.py new file mode 100644 index 000000000..56b75ec6b --- /dev/null +++ b/backend/python/week9_10/agent.py @@ -0,0 +1,127 @@ +# this file builds a langchain tool-calling agent backed by gemini +# the agent decides which tools to call based on the user's natural language request + +from langchain_classic.agents import AgentExecutor, create_tool_calling_agent +from langchain_core.prompts import ChatPromptTemplate +from week9_10.langsimth_setup import setup_langsmith_tracing +from week9_10.config import AGENT_MAX_ITERATIONS +from week9_10.llm_client import get_chat_model +from week9_10.tools import ( + find_product_by_query, + get_product_info, + check_inventory, + calculate_quote, +) + +# create_tool_calling_agent : function that builds an agent from llm, list of tools, and prompt template. It returns an object that knows how to decide which tool to call next. +# AgentExecuter : runtime that actually executes the agent's decisions + + +setup_langsmith_tracing() + + +PROMPT = """You are an inventory quote assistant for a toy store. + +Your job is to produce accurate price quotes for customers who describe what they want. + +You have access to four tools: +- find_product_by_query: search inventory by natural language description, returns ranked candidates +- get_product_info: fetch full details by product ID +- check_inventory: fetch the current stock level for a product ID +- calculate_quote: compute the final price with discount and policy cap + +Workflow: +1. If the user did not give a product ID, call find_product_by_query first to identify the product. +2. Call check_inventory to see the current stock level. +3. Determine the quantity to quote: + - If the requested quantity is less than or equal to current stock, use the requested quantity. + - If the requested quantity is greater than current stock, use the stock quantity instead. +4. Call calculate_quote with the chosen quantity. +5. Produce a clear final answer for the customer. + +In the final answer always include: +- Product name and ID +- Requested quantity (what the customer asked for) +- Quoted quantity (the quantity calculate_quote was actually called with) +- Unit price and subtotal +- Discount rate, label, and amount (or note "no discount" if rate is 0%) +- Total price +- Stock available +- A clear stock_warning whenever the quoted quantity is less than the requested quantity. + Explain that the quote was adjusted because stock is insufficient for the full request. +- A policy_warning whenever the calculate_quote result includes one. + +Rules you must follow: +- If find_product_by_query returns multiple candidates, pick the most relevant one based on the user's intent. +- If a tool returns an error dict, retry with corrected arguments or ask the user to clarify. +- Never invent products, prices, or discounts. Use only values returned by the tools. +- Never call calculate_quote with a quantity greater than the available stock. +- Keep the final answer professional and easy to read. +""" + + +prompt = ChatPromptTemplate.from_messages([ + ("system", PROMPT), + ("human", "{input}"), + ("placeholder", "{agent_scratchpad}"), +]) + + +agent_tools = [ + find_product_by_query, + get_product_info, + check_inventory, + calculate_quote, +] + + +def build_quote_agent(): + llm = get_chat_model() + agent = create_tool_calling_agent(llm, agent_tools, prompt) + executor = AgentExecutor( + agent=agent, + tools=agent_tools, + max_iterations=AGENT_MAX_ITERATIONS, + verbose=False, + return_intermediate_steps=True, + ) + return executor + + +def run_quote_agent(user_query: str): + executor = build_quote_agent() + result = executor.invoke({"input": user_query}) + # executor.invoke looks like + # { + # "input": "I need 60 building block ....", + # "output": "Here is your quote: ....." + # "intermediate_steps": [ + # "", + # "", + # ] + # } + return result + + +if __name__ == "__main__": + query = "I need 60 building blocks for a school project, can I get a deal?" + print(f"User query: {query}") + print() + + result = run_quote_agent(query) + + print() + print("=" * 60) + print("Final answer:") + print("=" * 60) + print(result["output"]) + print() + print("=" * 60) + print("Intermediate steps (what the agent did):") + print("=" * 60) + for index, step in enumerate(result["intermediate_steps"], start=1): + action, observation = step + print(f"\nStep {index}:") + print(f" Tool called: {action.tool}") + print(f" Arguments: {action.tool_input}") + print(f" Result: {observation}") diff --git a/backend/python/week9_10/config.py b/backend/python/week9_10/config.py new file mode 100644 index 000000000..1e99fac3f --- /dev/null +++ b/backend/python/week9_10/config.py @@ -0,0 +1,23 @@ +# central setting file for week 9/10 +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +GEMINI_MODEL_NAME = "gemini-2.5-flash" +GEMINI_TEMPERATURE = 0.2 +TRACING_PROJECT_NAME = "interneers-lab-week9-10" +# discounts for different cases +DISCOUNT_TIERS = [ + {"min_qty": 1, "max_qty": 19, "discount_rate": 0.00, "label": "no discount"}, + {"min_qty": 20, "max_qty": 49, "discount_rate": 0.05, + "label": "5% bulk discount"}, + {"min_qty": 50, "max_qty": 99, "discount_rate": 0.10, + "label": "10% bulk discount"}, + {"min_qty": 100, "max_qty": None, "discount_rate": 0.15, + "label": "15% bulk discount"}, +] +# as given in adv task, max discount should be less than 20 percent +MAX_DISCOUNT_RATE = 0.20 +# if the agent keeps calling tools then we can get some timeout errors +AGENT_MAX_ITERATIONS = 6 +PRODUCT_SEARCH_TOP_K = 3 +PRODUCT_SEARCH_MODEL_NAME = "all-MiniLM-L6-v2" diff --git a/backend/python/week9_10/eval_agent.py b/backend/python/week9_10/eval_agent.py new file mode 100644 index 000000000..585146109 --- /dev/null +++ b/backend/python/week9_10/eval_agent.py @@ -0,0 +1,208 @@ +from week9_10.service import run_quote_service +from week9_10.policy import is_within_policy + +EVAL_TEST_CASES = [ + # SIMPLE SCENARIOS - clean inputs, expected smooth flow + { + "name": "simple_tier1_no_discount", + "query": "I want a quote for 5 building blocks", + "expected_tools": ["find_product_by_query", "check_inventory", "calculate_quote"], + "expected_discount": 0.00, + "expected_quantity": 5, + "expected_keywords": ["building blocks"], + "should_have_invoice": True, + "should_have_stock_warning": False, + }, + { + "name": "simple_tier2_five_percent", + "query": "Can I get pricing for 25 building blocks?", + "expected_tools": ["find_product_by_query", "check_inventory", "calculate_quote"], + "expected_discount": 0.05, + "expected_quantity": 25, + "expected_keywords": ["building blocks", "5%"], + "should_have_invoice": True, + "should_have_stock_warning": False, + }, + { + "name": "simple_tier3_ten_percent_spec_example", + "query": "I need 60 building blocks for a school project", + "expected_tools": ["find_product_by_query", "check_inventory", "calculate_quote"], + "expected_discount": 0.10, + "expected_quantity": None, # depends on stock, allow either 60 or capped + "expected_keywords": ["building blocks", "10%"], + "should_have_invoice": True, + "should_have_stock_warning": None, # unknown without knowing stock + }, + { + "name": "simple_tier4_fifteen_percent", + "query": "Quote for 120 building blocks please", + "expected_tools": ["find_product_by_query", "check_inventory", "calculate_quote"], + "expected_discount": 0.15, + "expected_quantity": None, # may be capped by stock + "expected_keywords": ["building blocks", "15%"], + "should_have_invoice": True, + "should_have_stock_warning": None, + }, + { + "name": "simple_tier2_alternate_phrasing", + "query": "How much would 30 lego cost?", + "expected_tools": ["find_product_by_query", "check_inventory", "calculate_quote"], + "expected_discount": 0.05, + "expected_quantity": 30, + "expected_keywords": ["5%"], + "should_have_invoice": True, + "should_have_stock_warning": False, + }, + + # COMPLEX SCENARIOS - edge cases that stress the agent + { + "name": "complex_quantity_above_stock", + "query": "I need 9999 building blocks", + "expected_tools": ["find_product_by_query", "check_inventory", "calculate_quote"], + "expected_discount": None, # tier depends on the capped quantity + "expected_quantity": None, # whatever stock currently is + "expected_keywords": ["stock"], # answer should mention stock issue + "should_have_invoice": True, + "should_have_stock_warning": True, # this is the key check + }, + { + "name": "complex_boundary_exactly_50", + "query": "I want a quote for exactly 50 building blocks", + "expected_tools": ["find_product_by_query", "check_inventory", "calculate_quote"], + "expected_discount": 0.10, # 50 falls in 50-99 tier + "expected_quantity": None, # may equal stock if stock=50 + "expected_keywords": ["10%"], + "should_have_invoice": True, + "should_have_stock_warning": None, + }, + { + "name": "complex_non_existent_product", + "query": "I need 20 spaceships from Mars please", + # at minimum, search must run + "expected_tools": ["find_product_by_query"], + "expected_discount": None, + "expected_quantity": None, + "expected_keywords": [], # we don't enforce wording on graceful failures + "should_have_invoice": False, # no real product = no invoice + "should_have_stock_warning": None, + }, +] + + +def evaluate_case(case): + query = case["query"] + result = run_quote_service(query) + invoice = result["invoice"] + answer = result["answer"] + tools_called = [step["tool"] for step in result["intermediate_steps"]] + checks = {} + + # check 1 : were the expected tools called ? + expected_tools = set(case["expected_tools"]) + tools_called_set = set(tools_called) + checks["expected_tools_called"] = expected_tools.issubset(tools_called_set) + + # check 2 : is invoive matching expectation ? + has_invoice = invoice is not None + checks["invoice_presence"] = has_invoice == case["should_have_invoice"] + + # check 3 : discount rates + if invoice and case["expected_discount"] is not None: + checks["discount_rate"] = invoice["discount_rate"] == case["expected_discount"] + else: + checks["discount_rate"] = True + + # check 4 : quoted quantity + if invoice and case["expected_quantity"] is not None: + checks["quoted_quantity"] = invoice["quantity"] == case["expected_quantity"] + else: + checks["quoted_quantity"] = True + + # check 5 : expected keywork appears in answer text + answer_lower = answer.lower() + missing_keywords = [kw for kw in case["expected_keywords"] + if kw.lower() not in answer_lower] + checks["expected_keywords"] = len(missing_keywords) == 0 + + # check 6 : stock warning + if invoice and case["should_have_stock_warning"] is not None: + has_stock_warning = invoice.get("stock_warning") is not None + checks["stock_warning"] = has_stock_warning == case["should_have_stock_warning"] + else: + checks["stock_warning"] = True + + # check 7 : policy should never be violated + if invoice: + checks["policy_within_bounds"] = is_within_policy( + invoice["discount_rate"]) + else: + checks["policy_within_bounds"] = True # skipped + + overall_passed = all(checks.values()) + + return { + "name": case["name"], + "query": query, + "checks": checks, + "missing_keywords": missing_keywords if not checks["expected_keywords"] else [], + "tools_called": tools_called, + "discount_rate": invoice["discount_rate"] if invoice else None, + "quoted_quantity": invoice["quantity"] if invoice else None, + "stock_warning": invoice.get("stock_warning") if invoice else None, + "overall_passed": overall_passed, + } + + +def run_eval_suite(): + print("=" * 80) + print("AI QUOTE AGENT - EVAL SUITE") + print("=" * 80) + + results = [] + for case in EVAL_TEST_CASES: + print(f"\nRunning: {case['name']}") + print(f" Query: {case['query']}") + try: + result = evaluate_case(case) + except Exception as e: + print(f" ERROR during evaluation: {e}") + result = { + "name": case["name"], + "query": case["query"], + "checks": {}, + "overall_passed": False, + "error": str(e), + } + results.append(result) + + status = "PASS" if result["overall_passed"] else "FAIL" + print(f" Status: {status}") + if not result["overall_passed"]: + failed_checks = [k for k, v in result["checks"].items() if not v] + print(f" Failed checks: {failed_checks}") + if result.get("missing_keywords"): + print(f" Missing keywords: {result['missing_keywords']}") + print(f" Tools called: {result.get('tools_called')}") + print(f" Discount rate: {result.get('discount_rate')}") + print(f" Quoted quantity: {result.get('quoted_quantity')}") + print(f" Stock warning: {result.get('stock_warning')}") + + # summary + total = len(results) + passed = sum(1 for r in results if r["overall_passed"]) + score = passed / total if total else 0 + + print() + print("=" * 80) + print("SUMMARY") + print("=" * 80) + print(f"Total cases: {total}") + print(f"Passed: {passed}") + print(f"Failed: {total - passed}") + print(f"Score: {score:.2%}") + + return results + + +if __name__ == "__main__": + run_eval_suite() diff --git a/backend/python/week9_10/langsimth_setup.py b/backend/python/week9_10/langsimth_setup.py new file mode 100644 index 000000000..bb6caad8c --- /dev/null +++ b/backend/python/week9_10/langsimth_setup.py @@ -0,0 +1,37 @@ +# enables langsmith tracing for the week 9 / 10 quote agent +# pulls the project name from week 9_10 config so traces land in a separate + +import os +from dotenv import load_dotenv +from week9_10.config import TRACING_PROJECT_NAME + + +load_dotenv() + + +def setup_langsmith_tracing(): + api_key = os.getenv("LANGSMITH_API_KEY") + if not api_key: + return False + os.environ["LANGSMITH_API_KEY"] = api_key + os.environ["LANGSMITH_TRACING"] = "true" + os.environ["LANGSMITH_PROJECT"] = TRACING_PROJECT_NAME + endpoint = os.getenv("LANGSMITH_ENDPOINT") + if endpoint: + os.environ["LANGSMITH_ENDPOINT"] = endpoint + return True + + +def is_langsmith_enabled(): + return os.getenv("LANGSMITH_TRACING", "").lower() == "true" + + +if __name__ == "__main__": + enabled = setup_langsmith_tracing() + if enabled: + print("LangSmith tracing is enabled.") + print(f"Project: {os.getenv('LANGSMITH_PROJECT')}") + if os.getenv("LANGSMITH_ENDPOINT"): + print(f"Endpoint: {os.getenv('LANGSMITH_ENDPOINT')}") + else: + print("LangSmith tracing is disabled because LANGSMITH_API_KEY was not found.") diff --git a/backend/python/week9_10/llm_client.py b/backend/python/week9_10/llm_client.py new file mode 100644 index 000000000..1533c479d --- /dev/null +++ b/backend/python/week9_10/llm_client.py @@ -0,0 +1,28 @@ +# adapter bw code and gemini (langchain cover) +# produces langchain compatible chat model backed by gemini +import os +from dotenv import load_dotenv +from langchain_google_genai import ChatGoogleGenerativeAI +from week9_10.config import GEMINI_MODEL_NAME, GEMINI_TEMPERATURE + +load_dotenv() + + +# we are using langchain wrapper not direct gemini to directly trace langchain whenever needed +def get_chat_model(): + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + raise ValueError("GEMINI_API_KEY is missing in the environment.") + else: + return ChatGoogleGenerativeAI( + model=GEMINI_MODEL_NAME, + temperature=GEMINI_TEMPERATURE, + google_api_key=api_key, + ) + + +# just to test whether this file is working or not i.e calls are made or not and responses are received or not +if __name__ == "__main__": + llm = get_chat_model() + response = llm.invoke("Say hello in 5 words.") + print(response.content) diff --git a/backend/python/week9_10/policy.py b/backend/python/week9_10/policy.py new file mode 100644 index 000000000..43063e48f --- /dev/null +++ b/backend/python/week9_10/policy.py @@ -0,0 +1,26 @@ +# this file enforces business rules +# right now there is only 1 rule that no discount should exceed max_discount_rate +from week9_10.config import MAX_DISCOUNT_RATE + + +def apply_policy_cap(discount_rate: float): + if discount_rate <= MAX_DISCOUNT_RATE: + return discount_rate, None + else: + warning = ( + f"Discount rate {discount_rate:.2f} exceeded the policy cap of " + f"{MAX_DISCOUNT_RATE:.2f} and was reduced to {MAX_DISCOUNT_RATE:.2f}." + ) + return MAX_DISCOUNT_RATE, warning + + +def is_within_policy(discount_rate: float): + return discount_rate <= MAX_DISCOUNT_RATE + + +# just to test apply_policy_cap +if __name__ == "__main__": + test_rates = [0.00, 0.05, 0.15, 0.20, 0.25, 0.50] + for rate in test_rates: + capped, warning = apply_policy_cap(rate) + print(f"input={rate:.2f} -> capped={capped:.2f} warning={warning}") diff --git a/backend/python/week9_10/schema.py b/backend/python/week9_10/schema.py new file mode 100644 index 000000000..2434a3d84 --- /dev/null +++ b/backend/python/week9_10/schema.py @@ -0,0 +1,24 @@ +from datetime import datetime +from pydantic import BaseModel, Field +from week9_10.config import MAX_DISCOUNT_RATE + + +class QuoteInvoiceSchema(BaseModel): + product_id: int = Field(ge=1) + product_name: str = Field(min_length=1, max_length=100) + brand: str = Field(min_length=1, max_length=100) + requested_quantity: int = Field(ge=1) + + quantity: int = Field(ge=1) + unit_price: float = Field(gt=0) + subtotal: float = Field(ge=0) + discount_rate: float = Field(ge=0.0, le=MAX_DISCOUNT_RATE) + discount_label: str = Field(min_length=1) + discount_amount: float = Field(ge=0) + total: float = Field(ge=0) + stock_available: int = Field(ge=0) + # optional warning : they get displayed only when something is wrong + stock_warning: str | None = None + policy_warning: str | None = None + # default factory recalls everytime a new invoice is constructed + generated_at: datetime = Field(default_factory=datetime.now) diff --git a/backend/python/week9_10/service.py b/backend/python/week9_10/service.py new file mode 100644 index 000000000..88038ca7d --- /dev/null +++ b/backend/python/week9_10/service.py @@ -0,0 +1,125 @@ +# cleanup and packaging layer +# sits between agent and final result +import re +import json +from week9_10.schema import QuoteInvoiceSchema +from week9_10.agent import run_quote_agent + + +# gemini sometime returns [{"type":"text","text":""....}] +# this fnc will append all the readable part into a single string +def agent_output(raw_output): + if isinstance(raw_output, str): + return raw_output + if isinstance(raw_output, list): + parts = [] + for item in raw_output: + if isinstance(item, dict) and "text" in item: + parts.append(item["text"]) + elif isinstance(item, str): + parts.append(item) + return "".join(parts) + return str(raw_output) + + +# figures out customer's originally requested quantity +# 1. "Requested Quantity: N" pattern in the agent's final text +# 2. first integer in the original user query +# 3. fallback (typically the quoted quantity = no adjustment) +def extract_req_quantity(answer_text, user_query, fallback): + # first try that the agent write "Requested Quantity:60" in its final ans + match = re.search( + r"requested\s+quantity[\s\W]*(\d+)", answer_text, re.IGNORECASE) + if match: + return int(match.group(1)) + # then write the first integer in the users natural langauge query + match = re.search(r"\b(\d+)\b", user_query) + if match: + return int(match.group(1)) + return fallback + + +# func that goes through intermediate steps and pulls out quote and inventory results +def extract_quote_from_steps(intermediate_steps): + quote_result = None + stock_result = None + for action, observation in intermediate_steps: + if action.tool == "calculate_quote": + quote_result = observation + elif action.tool == "check_inventory": + stock_result = observation + return quote_result, stock_result + + +# main function that takes input and produces ans +def run_quote_service(user_query): + # S-1 run the agent + agent_result = run_quote_agent(user_query) + # S-2 clean up gemini multi ouput into one string + answer_text = agent_output(agent_result["output"]) + # S-3 extract results out of intermediate steps + quote_result, stock_result = extract_quote_from_steps( + agent_result["intermediate_steps"]) + # S-4 build structures invoice if a valid quote was produced + invoice = None + if quote_result and "error" not in quote_result: + quoted_quantity = quote_result["quantity"] + stock_available = stock_result["quantity_in_stock"] if stock_result else 0 + requested_quantity = extract_req_quantity( + answer_text=answer_text, user_query=user_query, fallback=quoted_quantity) + stock_warning = None + if requested_quantity > quoted_quantity: + stock_warning = ( + f"Requested {requested_quantity} units but only {stock_available} in stock. " + f"Quote adjusted to {quoted_quantity} units." + ) + # any invalid value will immediately raise validation error + invoice = QuoteInvoiceSchema( + product_id=quote_result["product_id"], + product_name=quote_result["product_name"], + brand=quote_result["brand"], + requested_quantity=requested_quantity, + quantity=quoted_quantity, + unit_price=quote_result["unit_price"], + subtotal=quote_result["subtotal"], + discount_rate=quote_result["discount_rate"], + discount_label=quote_result["discount_label"], + discount_amount=quote_result["discount_amount"], + total=quote_result["total"], + stock_available=stock_available, + stock_warning=stock_warning, + policy_warning=quote_result.get("policy_warning"), + ) + return { + "query": user_query, + "answer": answer_text, + "invoice": invoice.model_dump() if invoice else None, + "intermediate_steps": [ + {"tool": action.tool, "arguments": action.tool_input, "result": observation} + for action, observation in agent_result["intermediate_steps"] + ], + } + + +if __name__ == "__main__": + query = input("Enter your quote request: ").strip() + if not query: + print("No query entered.") + exit(0) + result = run_quote_service(query) + print() + print("=" * 60) + print("CLEAN ANSWER TEXT:") + print("=" * 60) + print(result["answer"]) + print() + print("=" * 60) + print("JSON INVOICE :") + print("=" * 60) + print(json.dumps(result["invoice"], indent=2, default=str)) + print() + print("=" * 60) + print("TOOL CALL TRACE:") + print("=" * 60) + for index, step in enumerate(result["intermediate_steps"], start=1): + print(f"Step {index}: {step['tool']}({step['arguments']})") diff --git a/backend/python/week9_10/task1.png b/backend/python/week9_10/task1.png new file mode 100644 index 000000000..e9a7a8828 Binary files /dev/null and b/backend/python/week9_10/task1.png differ diff --git a/backend/python/week9_10/task3.png b/backend/python/week9_10/task3.png new file mode 100644 index 000000000..ed39b14ec Binary files /dev/null and b/backend/python/week9_10/task3.png differ diff --git a/backend/python/week9_10/task3_final_output.png b/backend/python/week9_10/task3_final_output.png new file mode 100644 index 000000000..6ec32ba18 Binary files /dev/null and b/backend/python/week9_10/task3_final_output.png differ diff --git a/backend/python/week9_10/tools.py b/backend/python/week9_10/tools.py new file mode 100644 index 000000000..334e0b1d5 --- /dev/null +++ b/backend/python/week9_10/tools.py @@ -0,0 +1,185 @@ +# this file creates the 4 langchain tools that the quote agent can call + +from langchain_core.tools import tool +from week4.db_connection import initialize_mongo +from week4.repository import product_repository +from week7.semantic_search import semantic_search +from week9_10.config import DISCOUNT_TIERS, PRODUCT_SEARCH_TOP_K, PRODUCT_SEARCH_MODEL_NAME +from week9_10.policy import apply_policy_cap + + +initialize_mongo() + + +# helper function to create dict of products +def product_to_dict(product): + return { + "id": product.id, + "name": product.name, + "description": product.description, + "brand": product.brand, + "price": float(product.price), + "quantity": product.quantity, + "category": product.category.title if product.category else None, + } + + +# case 1 (manual .invoke() with the args) +# langchain runs the function body +# product_repository.get(1) actually hits mongoDB +# function returns the dict to langchain +# langChain returns the dict to us + +# case 2 (LLM triggers call) +# LLM sees the user message + tool descriptions +# LLM outputs: "please call get_product_info with product_id=1" — this is a request, not a result +# LangChain reads that request and dispatches: get_product_info.invoke({"product_id": 1}) +# LangChain runs the function body — same as case 1 +# product_repository.get(1) actually hits MongoDB +# Function returns the dict to LangChain +# LangChain takes the dict and sends it to the LLM in the next API call as a "tool result" +# LLM uses that dict to decide what to do next (call another tool, or generate the final answer) + + +# when product id is not given +@tool +def find_product_by_query(query: str) -> list: + """Search the inventory using semantic similarity and return top matches. + + Use this tool first when the user describes a product in natural language + instead of giving a product ID. Returns the top candidates ranked by relevance. + + Args: + query: A natural language description of the product (e.g. "building blocks", + "gift for toddler", "Lego Castle"). + + Returns: + A list of product candidates. Each candidate contains id, name, brand, price, + category, and semantic_score. Pick the most relevant one and use its id for + further tool calls. + """ + results = semantic_search( + query=query, + top_k=PRODUCT_SEARCH_TOP_K, + model_name=PRODUCT_SEARCH_MODEL_NAME, + ) + return results + + +@tool +def get_product_info(product_id: int) -> dict: + """Get full details of a product by its integer ID. + + Args: + product_id: The unique integer ID of the product. + + Returns: + A dict with id, name, description, brand, price, quantity, and category. + Returns an error dict if the product does not exist. + """ + product = product_repository.get(product_id) + if not product: + return {"error": f"Product with id {product_id} not found."} + return product_to_dict(product) + + +@tool +def check_inventory(product_id: int) -> dict: + """Check the current stock level for a product. + + Use this tool to confirm availability before generating a quote. + + Args: + product_id: The unique integer ID of the product. + + Returns: + A dict with product_id, product_name, quantity_in_stock, and is_in_stock. + Returns an error dict if the product does not exist. + """ + product = product_repository.get(product_id) + if not product: + return {"error": f"Product with id {product_id} not found."} + return { + "product_id": product.id, + "product_name": product.name, + "quantity_in_stock": product.quantity, + "is_in_stock": product.quantity > 0, + } + + +@tool +def calculate_quote(product_id: int, quantity: int) -> dict: + """Compute the final quote for a product and a requested quantity. + + Applies the tiered discount from the discount tier table, then runs the result + through the policy guard which caps any rate above the maximum allowed discount. + + Args: + product_id: The unique integer ID of the product. + quantity: The number of units the customer wants to order. Must be at least 1. + + Returns: + A dict with unit_price, subtotal, discount_rate, discount_label, + discount_amount, total, and policy_warning (None when within policy). + Returns an error dict if the product is missing or quantity is invalid. + """ + if quantity < 1: + return {"error": f"Quantity must be at least 1, got {quantity}."} + + product = product_repository.get(product_id) + if not product: + return {"error": f"Product with id {product_id} not found."} + matched_tier = None + for tier in DISCOUNT_TIERS: + min_q = tier["min_qty"] + max_q = tier["max_qty"] + if quantity >= min_q and (max_q is None or quantity <= max_q): + matched_tier = tier + break + + if matched_tier is None: + return {"error": f"No discount tier matches quantity {quantity}."} + + raw_rate = matched_tier["discount_rate"] + label = matched_tier["label"] + final_rate, policy_warning = apply_policy_cap(raw_rate) + unit_price = float(product.price) + subtotal = unit_price * quantity + discount_amount = subtotal * final_rate + total = subtotal - discount_amount + return { + "product_id": product.id, + "product_name": product.name, + "brand": product.brand, + "quantity": quantity, + "unit_price": unit_price, + "subtotal": round(subtotal, 2), + "discount_rate": final_rate, + "discount_label": label, + "discount_amount": round(discount_amount, 2), + "total": round(total, 2), + "policy_warning": policy_warning, + } + + +if __name__ == "__main__": + # tools are called using invoke, it is common in langchain although ig we can use directly get_product_info(1) + print('-'*60) + print("find_product_by_query") + print('-'*60) + print(find_product_by_query.invoke({"query": "building blocks"})) + print() + print('-'*60) + print("get_product_info") + print('-'*60) + print(get_product_info.invoke({"product_id": 62})) + print() + print('-'*60) + print("check_inventory") + print('-'*60) + print(check_inventory.invoke({"product_id": 62})) + print() + print('-'*60) + print("calculate_quote (qty 60)") + print('-'*60) + print(calculate_quote.invoke({"product_id": 62, "quantity": 60})) diff --git a/backend/python/week9_10/tools_testing.ipynb b/backend/python/week9_10/tools_testing.ipynb new file mode 100644 index 000000000..01650ff7c --- /dev/null +++ b/backend/python/week9_10/tools_testing.ipynb @@ -0,0 +1,513 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "39822498", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Project root added to sys.path\n", + "C:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\n" + ] + } + ], + "source": [ + "import os\n", + "import sys\n", + "\n", + "project_root = r\"C:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\"\n", + "\n", + "if project_root not in sys.path:\n", + " sys.path.append(project_root)\n", + "\n", + "print(\"Project root added to sys.path\")\n", + "print(sys.path[-1])" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6ea791da", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "c:\\Users\\Kreesh\\OneDrive\\Desktop\\Rippling\\interneers-lab\\backend\\python\\venv\\Lib\\site-packages\\tqdm\\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "from week9_10.tools import find_product_by_query, get_product_info, check_inventory, calculate_quote" + ] + }, + { + "cell_type": "markdown", + "id": "77423ec8", + "metadata": {}, + "source": [ + "Section 1: Inspecting what @tool created.\n", + "This shows what the @tool decorator stored under the hood.\n", + "This is exactly what LangChain will ship to Gemini when the agent runs." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "08d5cab7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 1: What @tool stored\n", + "============================================================\n", + "Tool name: get_product_info\n", + "\n", + "Tool description:\n", + "Get full details of a product by its integer ID.\n", + "\n", + " Args:\n", + " product_id: The unique integer ID of the product.\n", + "\n", + " Returns:\n", + " A dict with id, name, description, brand, price, quantity, and category.\n", + " Returns an error dict if the product does not exist.\n", + "\n", + "Args schema:\n", + "{'description': 'Get full details of a product by its integer ID.\\n\\nArgs:\\n product_id: The unique integer ID of the product.\\n\\nReturns:\\n A dict with id, name, description, brand, price, quantity, and category.\\n Returns an error dict if the product does not exist.', 'properties': {'product_id': {'title': 'Product Id', 'type': 'integer'}}, 'required': ['product_id'], 'title': 'get_product_info', 'type': 'object'}\n", + "\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 1: What @tool stored\")\n", + "print(\"=\" * 60)\n", + " \n", + "print(\"Tool name:\", get_product_info.name)\n", + "print()\n", + "print(\"Tool description:\")\n", + "print(get_product_info.description)\n", + "print()\n", + "print(\"Args schema:\")\n", + "print(get_product_info.args_schema.model_json_schema())\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "id": "db67a432", + "metadata": {}, + "source": [ + "Section 2: find_product_by_query" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "97bf2e05", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 2: find_product_by_query\n", + "============================================================\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.\n", + "Loading weights: 100%|██████████| 103/103 [00:00<00:00, 5040.94it/s]\n", + "\u001b[1mBertModel LOAD REPORT\u001b[0m from: sentence-transformers/all-MiniLM-L6-v2\n", + "Key | Status | | \n", + "------------------------+------------+--+-\n", + "embeddings.position_ids | UNEXPECTED | | \n", + "\n", + "Notes:\n", + "- UNEXPECTED:\tcan be ignored when loading from different task/architecture; not ok if you expect identical arch.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Query: 'building blocks'\n", + " id= 10 name=Deluxe Creative Building Blocks Set score=0.6179\n", + " id= 15 name=Deluxe City Building Blocks Set score=0.5998\n", + " id= 101 name=Lego Castle score=0.5469\n", + "\n", + "Query: 'gift for toddler'\n", + " id= 80 name=Backpack & School Accessories for Dolls score=0.4516\n", + " id= 103 name=Little Gardener's Deluxe Tool Kit score=0.4480\n", + " id= 38 name=Alphabet Learning Puzzle score=0.4451\n", + "\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 2: find_product_by_query\")\n", + "print(\"=\" * 60)\n", + " \n", + "results = find_product_by_query.invoke({\"query\": \"building blocks\"})\n", + "print(\"Query: 'building blocks'\")\n", + "for product in results:\n", + " print(f\" id={product['id']:>4} name={product['name']:<30} score={product['semantic_score']:.4f}\")\n", + "print()\n", + " \n", + "results = find_product_by_query.invoke({\"query\": \"gift for toddler\"})\n", + "print(\"Query: 'gift for toddler'\")\n", + "for product in results:\n", + " print(f\" id={product['id']:>4} name={product['name']:<30} score={product['semantic_score']:.4f}\")\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "id": "5c308c48", + "metadata": {}, + "source": [ + "Section 3: get_product_info" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "44ddc6e7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 3: get_product_info\n", + "============================================================\n", + "get_product_info(62):\n", + "{'id': 62, 'name': 'Kite Flying Kit', 'description': 'An easy-to-assemble kite with a long string, perfect for windy days at the park.', 'brand': 'Wind Riders', 'price': 11.25, 'quantity': 140, 'category': 'outdoor toys'}\n", + "\n", + "get_product_info(99999):\n", + "{'error': 'Product with id 99999 not found.'}\n", + "\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 3: get_product_info\")\n", + "print(\"=\" * 60)\n", + " \n", + "print(\"get_product_info(62):\")\n", + "print(get_product_info.invoke({\"product_id\": 62}))\n", + "print()\n", + " \n", + "print(\"get_product_info(99999):\")\n", + "print(get_product_info.invoke({\"product_id\": 99999}))\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "id": "4e66f378", + "metadata": {}, + "source": [ + "Section 4: check_inventory" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2d4e26fd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 4: check_inventory\n", + "============================================================\n", + "check_inventory(62):\n", + "{'product_id': 62, 'product_name': 'Kite Flying Kit', 'quantity_in_stock': 140, 'is_in_stock': True}\n", + "\n", + "check_inventory(99999):\n", + "{'error': 'Product with id 99999 not found.'}\n", + "\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 4: check_inventory\")\n", + "print(\"=\" * 60)\n", + " \n", + "print(\"check_inventory(62):\")\n", + "print(check_inventory.invoke({\"product_id\": 62}))\n", + "print()\n", + " \n", + "print(\"check_inventory(99999):\")\n", + "print(check_inventory.invoke({\"product_id\": 99999}))\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "id": "b26c79e6", + "metadata": {}, + "source": [ + "Section 5: calculate_quote — discount tiers" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "4ab6037e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 5: calculate_quote — discount tiers\n", + "============================================================\n", + "qty=10 rate=0.00% label=no discount total=112.5\n", + "qty=30 rate=5.00% label=5% bulk discount total=320.62\n", + "qty=60 rate=10.00% label=10% bulk discount total=607.5\n", + "qty=150 rate=15.00% label=15% bulk discount total=1434.38\n", + "\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 5: calculate_quote — discount tiers\")\n", + "print(\"=\" * 60)\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 10})\n", + "print(f\"qty=10 rate={q['discount_rate']:.2%} label={q['discount_label']:<20} total={q['total']}\")\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 30})\n", + "print(f\"qty=30 rate={q['discount_rate']:.2%} label={q['discount_label']:<20} total={q['total']}\")\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 60})\n", + "print(f\"qty=60 rate={q['discount_rate']:.2%} label={q['discount_label']:<20} total={q['total']}\")\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 150})\n", + "print(f\"qty=150 rate={q['discount_rate']:.2%} label={q['discount_label']:<20} total={q['total']}\")\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "id": "745f2513", + "metadata": {}, + "source": [ + "Section 6: calculate_quote — boundary checks" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1f446a0c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 6: calculate_quote — boundary checks\n", + "============================================================\n", + "qty=19 rate=0.00% (expected 0%)\n", + "qty=20 rate=5.00% (expected 5%)\n", + "qty=50 rate=10.00% (expected 10%)\n", + "qty=100 rate=15.00% (expected 15%)\n", + "\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 6: calculate_quote — boundary checks\")\n", + "print(\"=\" * 60)\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 19})\n", + "print(f\"qty=19 rate={q['discount_rate']:.2%} (expected 0%)\")\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 20})\n", + "print(f\"qty=20 rate={q['discount_rate']:.2%} (expected 5%)\")\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 50})\n", + "print(f\"qty=50 rate={q['discount_rate']:.2%} (expected 10%)\")\n", + " \n", + "q = calculate_quote.invoke({\"product_id\": 62, \"quantity\": 100})\n", + "print(f\"qty=100 rate={q['discount_rate']:.2%} (expected 15%)\")\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "id": "d4751c67", + "metadata": {}, + "source": [ + "Section 7: calculate_quote — error cases" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "1ac9f487", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 7: calculate_quote — error cases\n", + "============================================================\n", + "qty=0:\n", + "{'error': 'Quantity must be at least 1, got 0.'}\n", + "qty=-5:\n", + "{'error': 'Quantity must be at least 1, got -5.'}\n", + "product not found:\n", + "{'error': 'Product with id 99999 not found.'}\n", + "\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 7: calculate_quote — error cases\")\n", + "print(\"=\" * 60)\n", + " \n", + "print(\"qty=0:\")\n", + "print(calculate_quote.invoke({\"product_id\": 62, \"quantity\": 0}))\n", + " \n", + "print(\"qty=-5:\")\n", + "print(calculate_quote.invoke({\"product_id\": 62, \"quantity\": -5}))\n", + " \n", + "print(\"product not found:\")\n", + "print(calculate_quote.invoke({\"product_id\": 99999, \"quantity\": 10}))\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "id": "336d50c1", + "metadata": {}, + "source": [ + "Section 8: End-to-end flow" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "51e0eede", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================================================\n", + "SECTION 8: End-to-end flow\n", + "============================================================\n", + "Step 1 - top match: Deluxe Creative Building Blocks Set (id=10)\n", + "\n", + "Step 2 - full info:\n", + " id: 10\n", + " name: Deluxe Creative Building Blocks Set\n", + " description: A large set of colorful interlocking blocks for endless creative construction.\n", + " brand: Imagination Builders\n", + " price: 39.99\n", + " quantity: 50\n", + " category: Building Blocks\n", + "\n", + "Step 3 - stock:\n", + " product_id: 10\n", + " product_name: Deluxe Creative Building Blocks Set\n", + " quantity_in_stock: 50\n", + " is_in_stock: True\n", + "\n", + "Step 4 - quote:\n", + " product_id: 10\n", + " product_name: Deluxe Creative Building Blocks Set\n", + " brand: Imagination Builders\n", + " quantity: 60\n", + " unit_price: 39.99\n", + " subtotal: 2399.4\n", + " discount_rate: 0.1\n", + " discount_label: 10% bulk discount\n", + " discount_amount: 239.94\n", + " total: 2159.46\n", + " policy_warning: None\n" + ] + } + ], + "source": [ + "print(\"=\" * 60)\n", + "print(\"SECTION 8: End-to-end flow\")\n", + "print(\"=\" * 60)\n", + " \n", + "candidates = find_product_by_query.invoke({\"query\": \"building blocks\"})\n", + "top_match = candidates[0]\n", + "print(f\"Step 1 - top match: {top_match['name']} (id={top_match['id']})\")\n", + "print()\n", + " \n", + "info = get_product_info.invoke({\"product_id\": top_match[\"id\"]})\n", + "print(f\"Step 2 - full info:\")\n", + "for k, v in info.items():\n", + " print(f\" {k}: {v}\")\n", + "print()\n", + " \n", + "stock = check_inventory.invoke({\"product_id\": top_match[\"id\"]})\n", + "print(f\"Step 3 - stock:\")\n", + "for k, v in stock.items():\n", + " print(f\" {k}: {v}\")\n", + "print()\n", + " \n", + "quote = calculate_quote.invoke({\"product_id\": top_match[\"id\"], \"quantity\": 60})\n", + "print(f\"Step 4 - quote:\")\n", + "for k, v in quote.items():\n", + " print(f\" {k}: {v}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.12.7)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}