Spaces:
Running on Zero
Running on Zero
Deploy updated app and community contributions
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +5 -0
- _hf_capstone_repo/.gitattributes +2 -0
- _hf_capstone_repo/README.md +21 -0
- _hf_capstone_repo/agents/agent.py +33 -0
- _hf_capstone_repo/agents/autonomous_planning_agent.py +181 -0
- _hf_capstone_repo/agents/deals.py +134 -0
- _hf_capstone_repo/agents/deep_neural_network.py +101 -0
- _hf_capstone_repo/agents/ensemble_agent.py +51 -0
- _hf_capstone_repo/agents/evaluator.py +218 -0
- _hf_capstone_repo/agents/frontier_agent.py +86 -0
- _hf_capstone_repo/agents/items.py +52 -0
- _hf_capstone_repo/agents/messaging_agent.py +79 -0
- _hf_capstone_repo/agents/neural_network_agent.py +29 -0
- _hf_capstone_repo/agents/planning_agent.py +57 -0
- _hf_capstone_repo/agents/preprocessor.py +50 -0
- _hf_capstone_repo/agents/scanner_agent.py +176 -0
- _hf_capstone_repo/agents/specialist_agent.py +90 -0
- _hf_capstone_repo/app.py +25 -0
- _hf_capstone_repo/deal_agent_framework.py +119 -0
- _hf_capstone_repo/env_utils.py +14 -0
- _hf_capstone_repo/free_config.py +37 -0
- _hf_capstone_repo/log_utils.py +35 -0
- _hf_capstone_repo/memory.json +29 -0
- _hf_capstone_repo/price_is_right.py +208 -0
- _hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/data_level0.bin +3 -0
- _hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/header.bin +3 -0
- _hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/index_metadata.pickle +3 -0
- _hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/length.bin +3 -0
- _hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/link_lists.bin +3 -0
- _hf_capstone_repo/products_vectorstore/chroma.sqlite3 +3 -0
- _hf_capstone_repo/requirements.txt +17 -0
- _hf_deploy/.gitattributes +2 -0
- _hf_deploy/README.md +21 -0
- _hf_deploy/agents/agent.py +33 -0
- _hf_deploy/agents/autonomous_planning_agent.py +181 -0
- _hf_deploy/agents/deals.py +134 -0
- _hf_deploy/agents/deep_neural_network.py +101 -0
- _hf_deploy/agents/ensemble_agent.py +51 -0
- _hf_deploy/agents/evaluator.py +218 -0
- _hf_deploy/agents/frontier_agent.py +86 -0
- _hf_deploy/agents/items.py +52 -0
- _hf_deploy/agents/messaging_agent.py +79 -0
- _hf_deploy/agents/neural_network_agent.py +29 -0
- _hf_deploy/agents/planning_agent.py +57 -0
- _hf_deploy/agents/preprocessor.py +50 -0
- _hf_deploy/agents/scanner_agent.py +176 -0
- _hf_deploy/agents/specialist_agent.py +90 -0
- _hf_deploy/app.py +25 -0
- _hf_deploy/deal_agent_framework.py +119 -0
- _hf_deploy/env_utils.py +14 -0
.gitattributes
CHANGED
|
@@ -34,3 +34,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
products_vectorstore/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
products_vectorstore/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
_hf_capstone_repo/products_vectorstore/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
_hf_deploy/products_vectorstore/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
community_contributions/images/gui.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
community_contributions/images/metrics.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
space_staging/products_vectorstore/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
_hf_capstone_repo/.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
products_vectorstore/** filter=lfs diff=lfs merge=lfs -text
|
_hf_capstone_repo/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Capstone - The Price Is Right
|
| 3 |
+
emoji: 💰
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "5.47.2"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Capstone — The Price Is Right
|
| 14 |
+
|
| 15 |
+
Multi-agent deal hunting AI for the LLM Engineering capstone project.
|
| 16 |
+
|
| 17 |
+
- Scans DealNews RSS feeds for deals
|
| 18 |
+
- Estimates true product prices with RAG + Groq + neural network
|
| 19 |
+
- Surfaces bargains in a live Gradio dashboard
|
| 20 |
+
|
| 21 |
+
**Secrets required:** `GROQ_API_KEY`, `HF_TOKEN`, `USE_MODAL_SPECIALIST=false`, `GRADIO_SERVER_NAME=0.0.0.0`
|
_hf_capstone_repo/agents/agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
class Agent:
|
| 4 |
+
"""
|
| 5 |
+
An abstract superclass for Agents
|
| 6 |
+
Used to log messages in a way that can identify each Agent
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# Foreground colors
|
| 10 |
+
RED = '\033[31m'
|
| 11 |
+
GREEN = '\033[32m'
|
| 12 |
+
YELLOW = '\033[33m'
|
| 13 |
+
BLUE = '\033[34m'
|
| 14 |
+
MAGENTA = '\033[35m'
|
| 15 |
+
CYAN = '\033[36m'
|
| 16 |
+
WHITE = '\033[37m'
|
| 17 |
+
|
| 18 |
+
# Background color
|
| 19 |
+
BG_BLACK = '\033[40m'
|
| 20 |
+
|
| 21 |
+
# Reset code to return to default color
|
| 22 |
+
RESET = '\033[0m'
|
| 23 |
+
|
| 24 |
+
name: str = ""
|
| 25 |
+
color: str = '\033[37m'
|
| 26 |
+
|
| 27 |
+
def log(self, message):
|
| 28 |
+
"""
|
| 29 |
+
Log this as an info message, identifying the agent
|
| 30 |
+
"""
|
| 31 |
+
color_code = self.BG_BLACK + self.color
|
| 32 |
+
message = f"[{self.name}] {message}"
|
| 33 |
+
logging.info(color_code + message + self.RESET)
|
_hf_capstone_repo/agents/autonomous_planning_agent.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List, Dict
|
| 2 |
+
from agents.agent import Agent
|
| 3 |
+
from agents.deals import Deal, Opportunity
|
| 4 |
+
from agents.scanner_agent import ScannerAgent
|
| 5 |
+
from agents.ensemble_agent import EnsembleAgent
|
| 6 |
+
from agents.messaging_agent import MessagingAgent
|
| 7 |
+
from openai import OpenAI
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AutonomousPlanningAgent(Agent):
|
| 12 |
+
name = "Autonomous Planning Agent"
|
| 13 |
+
color = Agent.GREEN
|
| 14 |
+
MODEL = "gpt-5.1"
|
| 15 |
+
|
| 16 |
+
def __init__(self, collection):
|
| 17 |
+
"""
|
| 18 |
+
Create instances of the 3 Agents that this planner coordinates across
|
| 19 |
+
"""
|
| 20 |
+
self.log("Autonomous Planning Agent is initializing")
|
| 21 |
+
self.scanner = ScannerAgent()
|
| 22 |
+
self.ensemble = EnsembleAgent(collection)
|
| 23 |
+
self.messenger = MessagingAgent()
|
| 24 |
+
self.openai = OpenAI()
|
| 25 |
+
self.memory = None
|
| 26 |
+
self.opportunity = None
|
| 27 |
+
self.log("Autonomous Planning Agent is ready")
|
| 28 |
+
|
| 29 |
+
def scan_the_internet_for_bargains(self) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Run the tool to scan
|
| 32 |
+
"""
|
| 33 |
+
self.log("Autonomous Planning agent is calling scanner")
|
| 34 |
+
results = self.scanner.scan(memory=self.memory)
|
| 35 |
+
return results.model_dump_json() if results else "No deals found"
|
| 36 |
+
|
| 37 |
+
def estimate_true_value(self, description: str) -> str:
|
| 38 |
+
"""
|
| 39 |
+
Run the tool to estimate true value
|
| 40 |
+
"""
|
| 41 |
+
self.log("Autonomous Planning agent is estimating value via Ensemble Agent")
|
| 42 |
+
estimate = self.ensemble.price(description)
|
| 43 |
+
return f"The estimated true value of {description} is {estimate}"
|
| 44 |
+
|
| 45 |
+
def notify_user_of_deal(
|
| 46 |
+
self, description: str, deal_price: float, estimated_true_value: float, url: str
|
| 47 |
+
) -> Dict:
|
| 48 |
+
"""
|
| 49 |
+
Run the tool to notify the user
|
| 50 |
+
"""
|
| 51 |
+
if self.opportunity:
|
| 52 |
+
self.log("Autonomous Planning agent is trying to notify the user a 2nd time; ignoring")
|
| 53 |
+
else:
|
| 54 |
+
self.log("Autonomous Planning agent is notifying user")
|
| 55 |
+
self.messenger.notify(description, deal_price, estimated_true_value, url)
|
| 56 |
+
deal = Deal(product_description=description, price=deal_price, url=url)
|
| 57 |
+
discount = estimated_true_value - deal_price
|
| 58 |
+
self.opportunity = Opportunity(
|
| 59 |
+
deal=deal, estimate=estimated_true_value, discount=discount
|
| 60 |
+
)
|
| 61 |
+
return "Notification sent ok"
|
| 62 |
+
|
| 63 |
+
scan_function = {
|
| 64 |
+
"name": "scan_the_internet_for_bargains",
|
| 65 |
+
"description": "Returns top bargains scraped from the internet along with the price each item is being offered for",
|
| 66 |
+
"parameters": {
|
| 67 |
+
"type": "object",
|
| 68 |
+
"properties": {},
|
| 69 |
+
"required": [],
|
| 70 |
+
"additionalProperties": False,
|
| 71 |
+
},
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
estimate_function = {
|
| 75 |
+
"name": "estimate_true_value",
|
| 76 |
+
"description": "Given the description of an item, estimate how much it is actually worth",
|
| 77 |
+
"parameters": {
|
| 78 |
+
"type": "object",
|
| 79 |
+
"properties": {
|
| 80 |
+
"description": {
|
| 81 |
+
"type": "string",
|
| 82 |
+
"description": "The description of the item to be estimated",
|
| 83 |
+
},
|
| 84 |
+
},
|
| 85 |
+
"required": ["description"],
|
| 86 |
+
"additionalProperties": False,
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
notify_function = {
|
| 91 |
+
"name": "notify_user_of_deal",
|
| 92 |
+
"description": "Send the user a push notification about the single most compelling deal; only call this one time",
|
| 93 |
+
"parameters": {
|
| 94 |
+
"type": "object",
|
| 95 |
+
"properties": {
|
| 96 |
+
"description": {
|
| 97 |
+
"type": "string",
|
| 98 |
+
"description": "The description of the item itself scraped from the internet",
|
| 99 |
+
},
|
| 100 |
+
"deal_price": {
|
| 101 |
+
"type": "number",
|
| 102 |
+
"description": "The price offered by this deal scraped from the internet",
|
| 103 |
+
},
|
| 104 |
+
"estimated_true_value": {
|
| 105 |
+
"type": "number",
|
| 106 |
+
"description": "The estimated actual value that this is worth",
|
| 107 |
+
},
|
| 108 |
+
"url": {
|
| 109 |
+
"type": "string",
|
| 110 |
+
"description": "The URL of this deal as scraped from the internet",
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
"required": ["description", "deal_price", "estimated_true_value", "url"],
|
| 114 |
+
"additionalProperties": False,
|
| 115 |
+
},
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def get_tools(self):
|
| 119 |
+
"""
|
| 120 |
+
Return the json for the tools to be used
|
| 121 |
+
"""
|
| 122 |
+
return [
|
| 123 |
+
{"type": "function", "function": self.scan_function},
|
| 124 |
+
{"type": "function", "function": self.estimate_function},
|
| 125 |
+
{"type": "function", "function": self.notify_function},
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
def handle_tool_call(self, message):
|
| 129 |
+
"""
|
| 130 |
+
Actually call the tools associated with this message
|
| 131 |
+
"""
|
| 132 |
+
mapping = {
|
| 133 |
+
"scan_the_internet_for_bargains": self.scan_the_internet_for_bargains,
|
| 134 |
+
"estimate_true_value": self.estimate_true_value,
|
| 135 |
+
"notify_user_of_deal": self.notify_user_of_deal,
|
| 136 |
+
}
|
| 137 |
+
results = []
|
| 138 |
+
for tool_call in message.tool_calls:
|
| 139 |
+
tool_name = tool_call.function.name
|
| 140 |
+
arguments = json.loads(tool_call.function.arguments)
|
| 141 |
+
tool = mapping.get(tool_name)
|
| 142 |
+
result = tool(**arguments) if tool else ""
|
| 143 |
+
results.append({"role": "tool", "content": result, "tool_call_id": tool_call.id})
|
| 144 |
+
return results
|
| 145 |
+
|
| 146 |
+
system_message = "You find great deals on bargain products using your tools, and notify the user of the best bargain."
|
| 147 |
+
user_message = """
|
| 148 |
+
First, use your tool to scan the internet for bargain deals. Then for each deal, use your tool to estimate its true value.
|
| 149 |
+
Then pick the single most compelling deal where the price is much lower than the estimated true value, and use your tool to notify the user.
|
| 150 |
+
Then just reply OK to indicate success.
|
| 151 |
+
"""
|
| 152 |
+
messages = [
|
| 153 |
+
{"role": "system", "content": system_message},
|
| 154 |
+
{"role": "user", "content": user_message},
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
def plan(self, memory: List[str] = []) -> Optional[Opportunity]:
|
| 158 |
+
"""
|
| 159 |
+
Run the full workflow, providing the LLM with tools to surface scraped deals to the user
|
| 160 |
+
:param memory: a list of URLs that have been surfaced in the past
|
| 161 |
+
:return: an Opportunity if one was surfaced, otherwise None
|
| 162 |
+
"""
|
| 163 |
+
self.log("Autonomous Planning Agent is kicking off a run")
|
| 164 |
+
self.memory = memory
|
| 165 |
+
self.opportunity = None
|
| 166 |
+
messages = self.messages[:]
|
| 167 |
+
done = False
|
| 168 |
+
while not done:
|
| 169 |
+
response = self.openai.chat.completions.create(
|
| 170 |
+
model=self.MODEL, messages=messages, tools=self.get_tools()
|
| 171 |
+
)
|
| 172 |
+
if response.choices[0].finish_reason == "tool_calls":
|
| 173 |
+
message = response.choices[0].message
|
| 174 |
+
results = self.handle_tool_call(message)
|
| 175 |
+
messages.append(message)
|
| 176 |
+
messages.extend(results)
|
| 177 |
+
else:
|
| 178 |
+
done = True
|
| 179 |
+
reply = response.choices[0].message.content
|
| 180 |
+
self.log(f"Autonomous Planning Agent completed with: {reply}")
|
| 181 |
+
return self.opportunity
|
_hf_capstone_repo/agents/deals.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Dict, Self
|
| 3 |
+
from bs4 import BeautifulSoup
|
| 4 |
+
import re
|
| 5 |
+
import feedparser
|
| 6 |
+
from tqdm import tqdm
|
| 7 |
+
import requests
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
feeds = [
|
| 11 |
+
"https://www.dealnews.com/c142/Electronics/?rss=1",
|
| 12 |
+
"https://www.dealnews.com/c39/Computers/?rss=1",
|
| 13 |
+
"https://www.dealnews.com/f1912/Smart-Home/?rss=1",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
# You could also add: "https://www.dealnews.com/c238/Automotive/?rss=1"
|
| 17 |
+
# "https://www.dealnews.com/c196/Home-Garden/?rss=1"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def extract(html_snippet: str) -> str:
|
| 21 |
+
"""
|
| 22 |
+
Use Beautiful Soup to clean up this HTML snippet and extract useful text
|
| 23 |
+
"""
|
| 24 |
+
soup = BeautifulSoup(html_snippet, "html.parser")
|
| 25 |
+
snippet_div = soup.find("div", class_="snippet summary")
|
| 26 |
+
|
| 27 |
+
if snippet_div:
|
| 28 |
+
description = snippet_div.get_text(strip=True)
|
| 29 |
+
description = BeautifulSoup(description, "html.parser").get_text()
|
| 30 |
+
description = re.sub("<[^<]+?>", "", description)
|
| 31 |
+
result = description.strip()
|
| 32 |
+
else:
|
| 33 |
+
result = html_snippet
|
| 34 |
+
return result.replace("\n", " ")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ScrapedDeal:
|
| 38 |
+
"""
|
| 39 |
+
A class to represent a Deal retrieved from an RSS feed
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
category: str
|
| 43 |
+
title: str
|
| 44 |
+
summary: str
|
| 45 |
+
url: str
|
| 46 |
+
details: str
|
| 47 |
+
features: str
|
| 48 |
+
|
| 49 |
+
def __init__(self, entry: Dict[str, str]):
|
| 50 |
+
"""
|
| 51 |
+
Populate this instance based on the provided dict
|
| 52 |
+
"""
|
| 53 |
+
self.title = entry["title"]
|
| 54 |
+
self.summary = extract(entry["summary"])
|
| 55 |
+
self.url = entry["links"][0]["href"]
|
| 56 |
+
stuff = requests.get(self.url).content
|
| 57 |
+
soup = BeautifulSoup(stuff, "html.parser")
|
| 58 |
+
content = soup.find("div", class_="content-section").get_text()
|
| 59 |
+
content = content.replace("\nmore", "").replace("\n", " ")
|
| 60 |
+
if "Features" in content:
|
| 61 |
+
self.details, self.features = content.split("Features", 1)
|
| 62 |
+
else:
|
| 63 |
+
self.details = content
|
| 64 |
+
self.features = ""
|
| 65 |
+
self.truncate()
|
| 66 |
+
|
| 67 |
+
def truncate(self):
|
| 68 |
+
"""
|
| 69 |
+
Limit the fields to a sensible length to avoid sending too much info to the model
|
| 70 |
+
"""
|
| 71 |
+
self.title = self.title[:100]
|
| 72 |
+
self.details = self.details[:500]
|
| 73 |
+
self.features = self.features[:500]
|
| 74 |
+
|
| 75 |
+
def __repr__(self):
|
| 76 |
+
"""
|
| 77 |
+
Return a string to describe this deal
|
| 78 |
+
"""
|
| 79 |
+
return f"<{self.title}>"
|
| 80 |
+
|
| 81 |
+
def describe(self):
|
| 82 |
+
"""
|
| 83 |
+
Return a longer string to describe this deal for use in calling a model
|
| 84 |
+
"""
|
| 85 |
+
return f"Title: {self.title}\nDetails: {self.details.strip()}\nFeatures: {self.features.strip()}\nURL: {self.url}"
|
| 86 |
+
|
| 87 |
+
@classmethod
|
| 88 |
+
def fetch(cls, show_progress: bool = False) -> List[Self]:
|
| 89 |
+
"""
|
| 90 |
+
Retrieve all deals from the selected RSS feeds
|
| 91 |
+
"""
|
| 92 |
+
deals = []
|
| 93 |
+
feed_iter = tqdm(feeds) if show_progress else feeds
|
| 94 |
+
for feed_url in feed_iter:
|
| 95 |
+
feed = feedparser.parse(feed_url)
|
| 96 |
+
for entry in feed.entries[:10]:
|
| 97 |
+
deals.append(cls(entry))
|
| 98 |
+
time.sleep(0.05)
|
| 99 |
+
return deals
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class Deal(BaseModel):
|
| 103 |
+
"""
|
| 104 |
+
A class to Represent a Deal with a summary description
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
product_description: str = Field(
|
| 108 |
+
description="Your clearly expressed summary of the product in 3-4 sentences. Details of the item are much more important than why it's a good deal. Avoid mentioning discounts and coupons; focus on the item itself. There should be a short paragraph of text for each item you choose."
|
| 109 |
+
)
|
| 110 |
+
price: float = Field(
|
| 111 |
+
description="The actual price of this product, as advertised in the deal. Be sure to give the actual price; for example, if a deal is described as $100 off the usual $300 price, you should respond with $200"
|
| 112 |
+
)
|
| 113 |
+
url: str = Field(description="The URL of the deal, as provided in the input")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class DealSelection(BaseModel):
|
| 117 |
+
"""
|
| 118 |
+
A class to Represent a list of Deals
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
deals: List[Deal] = Field(
|
| 122 |
+
description="Your selection of the 5 deals that have the most detailed, high quality description and the most clear price. You should be confident that the price reflects the deal, that it is a good deal, with a clear description"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class Opportunity(BaseModel):
|
| 127 |
+
"""
|
| 128 |
+
A class to represent a possible opportunity: a Deal where we estimate
|
| 129 |
+
it should cost more than it's being offered
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
deal: Deal
|
| 133 |
+
estimate: float
|
| 134 |
+
discount: float
|
_hf_capstone_repo/agents/deep_neural_network.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from tqdm.notebook import tqdm
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.optim as optim
|
| 6 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 7 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR
|
| 8 |
+
from sklearn.feature_extraction.text import HashingVectorizer
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ResidualBlock(nn.Module):
|
| 13 |
+
def __init__(self, hidden_size, dropout_prob):
|
| 14 |
+
super(ResidualBlock, self).__init__()
|
| 15 |
+
self.block = nn.Sequential(
|
| 16 |
+
nn.Linear(hidden_size, hidden_size),
|
| 17 |
+
nn.LayerNorm(hidden_size),
|
| 18 |
+
nn.ReLU(),
|
| 19 |
+
nn.Dropout(dropout_prob),
|
| 20 |
+
nn.Linear(hidden_size, hidden_size),
|
| 21 |
+
nn.LayerNorm(hidden_size),
|
| 22 |
+
)
|
| 23 |
+
self.relu = nn.ReLU()
|
| 24 |
+
|
| 25 |
+
def forward(self, x):
|
| 26 |
+
residual = x
|
| 27 |
+
out = self.block(x)
|
| 28 |
+
out += residual # Skip connection
|
| 29 |
+
return self.relu(out)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class DeepNeuralNetwork(nn.Module):
|
| 33 |
+
def __init__(self, input_size, num_layers=10, hidden_size=4096, dropout_prob=0.2):
|
| 34 |
+
super(DeepNeuralNetwork, self).__init__()
|
| 35 |
+
|
| 36 |
+
# First layer
|
| 37 |
+
self.input_layer = nn.Sequential(
|
| 38 |
+
nn.Linear(input_size, hidden_size),
|
| 39 |
+
nn.LayerNorm(hidden_size),
|
| 40 |
+
nn.ReLU(),
|
| 41 |
+
nn.Dropout(dropout_prob),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Residual blocks
|
| 45 |
+
self.residual_blocks = nn.ModuleList()
|
| 46 |
+
for i in range(num_layers - 2):
|
| 47 |
+
self.residual_blocks.append(ResidualBlock(hidden_size, dropout_prob))
|
| 48 |
+
|
| 49 |
+
# Output layer
|
| 50 |
+
self.output_layer = nn.Linear(hidden_size, 1)
|
| 51 |
+
|
| 52 |
+
def forward(self, x):
|
| 53 |
+
x = self.input_layer(x)
|
| 54 |
+
|
| 55 |
+
for block in self.residual_blocks:
|
| 56 |
+
x = block(x)
|
| 57 |
+
|
| 58 |
+
return self.output_layer(x)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
Y_STD = 1.0328539609909058
|
| 62 |
+
Y_MEAN = 4.434937953948975
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class DeepNeuralNetworkInference:
|
| 66 |
+
def __init__(self):
|
| 67 |
+
self.vectorizer = None
|
| 68 |
+
self.model = None
|
| 69 |
+
self.device = None
|
| 70 |
+
|
| 71 |
+
np.random.seed(42)
|
| 72 |
+
torch.manual_seed(42)
|
| 73 |
+
torch.cuda.manual_seed(42)
|
| 74 |
+
|
| 75 |
+
def setup(self):
|
| 76 |
+
self.vectorizer = HashingVectorizer(n_features=5000, stop_words="english", binary=True)
|
| 77 |
+
self.model = DeepNeuralNetwork(5000)
|
| 78 |
+
if torch.cuda.is_available():
|
| 79 |
+
self.device = torch.device("cuda")
|
| 80 |
+
elif torch.backends.mps.is_available():
|
| 81 |
+
self.device = torch.device("mps")
|
| 82 |
+
else:
|
| 83 |
+
self.device = torch.device("cpu")
|
| 84 |
+
|
| 85 |
+
logging.info(f"Neural Network is using {self.device}")
|
| 86 |
+
|
| 87 |
+
self.model.to(self.device)
|
| 88 |
+
|
| 89 |
+
def load(self, path):
|
| 90 |
+
self.model.load_state_dict(torch.load(path, map_location=self.device))
|
| 91 |
+
self.model.to(self.device)
|
| 92 |
+
|
| 93 |
+
def inference(self, text):
|
| 94 |
+
self.model.eval()
|
| 95 |
+
with torch.no_grad():
|
| 96 |
+
vector = self.vectorizer.transform([text])
|
| 97 |
+
vector = torch.FloatTensor(vector.toarray()).to(self.device)
|
| 98 |
+
pred = self.model(vector)[0]
|
| 99 |
+
result = torch.exp(pred * Y_STD + Y_MEAN) - 1
|
| 100 |
+
result = result.item()
|
| 101 |
+
return max(0, result)
|
_hf_capstone_repo/agents/ensemble_agent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.agent import Agent
|
| 2 |
+
from agents.specialist_agent import SpecialistAgent
|
| 3 |
+
from agents.frontier_agent import FrontierAgent
|
| 4 |
+
from agents.neural_network_agent import NeuralNetworkAgent
|
| 5 |
+
from agents.preprocessor import Preprocessor
|
| 6 |
+
from free_config import WEIGHTS_WITH_SPECIALIST, WEIGHTS_WITHOUT_SPECIALIST
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class EnsembleAgent(Agent):
|
| 10 |
+
name = "Ensemble Agent"
|
| 11 |
+
color = Agent.YELLOW
|
| 12 |
+
|
| 13 |
+
def __init__(self, collection):
|
| 14 |
+
"""
|
| 15 |
+
Create an instance of Ensemble, by creating each of the models
|
| 16 |
+
And loading the weights of the Ensemble
|
| 17 |
+
"""
|
| 18 |
+
self.log("Initializing Ensemble Agent")
|
| 19 |
+
self.specialist = SpecialistAgent()
|
| 20 |
+
self.frontier = FrontierAgent(collection)
|
| 21 |
+
self.neural_network = NeuralNetworkAgent()
|
| 22 |
+
self.preprocessor = Preprocessor()
|
| 23 |
+
self.use_specialist = self.specialist.available
|
| 24 |
+
if self.use_specialist:
|
| 25 |
+
mode = getattr(self.specialist, "mode", "on")
|
| 26 |
+
self.log(f"Ensemble Agent is ready (Frontier + Specialist [{mode}] + Neural Network)")
|
| 27 |
+
else:
|
| 28 |
+
self.log("Ensemble Agent is ready (Frontier + Neural Network only)")
|
| 29 |
+
|
| 30 |
+
def price(self, description: str) -> float:
|
| 31 |
+
"""
|
| 32 |
+
Run this ensemble model
|
| 33 |
+
Ask each of the models to price the product
|
| 34 |
+
Then return the weighted average price
|
| 35 |
+
"""
|
| 36 |
+
self.log("Running Ensemble Agent - preprocessing text")
|
| 37 |
+
rewrite = self.preprocessor.preprocess(description)
|
| 38 |
+
self.log(f"Pre-processed text using {self.preprocessor.model_name}")
|
| 39 |
+
frontier = self.frontier.price(rewrite)
|
| 40 |
+
neural_network = self.neural_network.price(rewrite)
|
| 41 |
+
|
| 42 |
+
if self.use_specialist:
|
| 43 |
+
specialist = self.specialist.price(rewrite)
|
| 44 |
+
wf, ws, wn = WEIGHTS_WITH_SPECIALIST
|
| 45 |
+
combined = frontier * wf + specialist * ws + neural_network * wn
|
| 46 |
+
else:
|
| 47 |
+
wf, wn = WEIGHTS_WITHOUT_SPECIALIST
|
| 48 |
+
combined = frontier * wf + neural_network * wn
|
| 49 |
+
|
| 50 |
+
self.log(f"Ensemble Agent complete - returning ${combined:.2f}")
|
| 51 |
+
return combined
|
_hf_capstone_repo/agents/evaluator.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from sklearn.metrics import mean_squared_error, r2_score
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import plotly.express as px
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
+
from itertools import accumulate
|
| 7 |
+
import math
|
| 8 |
+
from tqdm.notebook import tqdm
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
|
| 11 |
+
GREEN = "\033[92m"
|
| 12 |
+
YELLOW = "\033[93m"
|
| 13 |
+
RED = "\033[91m"
|
| 14 |
+
RESET = "\033[0m"
|
| 15 |
+
COLOR_MAP = {"red": RED, "orange": YELLOW, "green": GREEN}
|
| 16 |
+
|
| 17 |
+
WORKERS = 5
|
| 18 |
+
DEFAULT_SIZE = 200
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Tester:
|
| 22 |
+
def __init__(self, predictor, data, title=None, size=DEFAULT_SIZE, workers=WORKERS):
|
| 23 |
+
self.predictor = predictor
|
| 24 |
+
self.data = data
|
| 25 |
+
self.title = title or self.make_title(predictor)
|
| 26 |
+
self.size = size
|
| 27 |
+
self.titles = []
|
| 28 |
+
self.guesses = []
|
| 29 |
+
self.truths = []
|
| 30 |
+
self.errors = []
|
| 31 |
+
self.colors = []
|
| 32 |
+
self.workers = workers
|
| 33 |
+
|
| 34 |
+
@staticmethod
|
| 35 |
+
def make_title(predictor) -> str:
|
| 36 |
+
return predictor.__name__.replace("__", ".").replace("_", " ").title().replace("Gpt", "GPT")
|
| 37 |
+
|
| 38 |
+
@staticmethod
|
| 39 |
+
def post_process(value):
|
| 40 |
+
if isinstance(value, str):
|
| 41 |
+
value = value.replace("$", "").replace(",", "")
|
| 42 |
+
match = re.search(r"[-+]?\d*\.\d+|\d+", value)
|
| 43 |
+
return float(match.group()) if match else 0
|
| 44 |
+
else:
|
| 45 |
+
return value
|
| 46 |
+
|
| 47 |
+
def color_for(self, error, truth):
|
| 48 |
+
if error < 40 or error / truth < 0.2:
|
| 49 |
+
return "green"
|
| 50 |
+
elif error < 80 or error / truth < 0.4:
|
| 51 |
+
return "orange"
|
| 52 |
+
else:
|
| 53 |
+
return "red"
|
| 54 |
+
|
| 55 |
+
def run_datapoint(self, i):
|
| 56 |
+
datapoint = self.data[i]
|
| 57 |
+
value = self.predictor(datapoint)
|
| 58 |
+
guess = self.post_process(value)
|
| 59 |
+
truth = datapoint.price
|
| 60 |
+
error = abs(guess - truth)
|
| 61 |
+
color = self.color_for(error, truth)
|
| 62 |
+
title = datapoint.title if len(datapoint.title) <= 40 else datapoint.title[:40] + "..."
|
| 63 |
+
return title, guess, truth, error, color
|
| 64 |
+
|
| 65 |
+
def chart(self, title):
|
| 66 |
+
df = pd.DataFrame(
|
| 67 |
+
{
|
| 68 |
+
"truth": self.truths,
|
| 69 |
+
"guess": self.guesses,
|
| 70 |
+
"title": self.titles,
|
| 71 |
+
"error": self.errors,
|
| 72 |
+
"color": self.colors,
|
| 73 |
+
}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Pre-format hover text
|
| 77 |
+
df["hover"] = [
|
| 78 |
+
f"{t}\nGuess=${g:,.2f} Actual=${y:,.2f}"
|
| 79 |
+
for t, g, y in zip(df["title"], df["guess"], df["truth"])
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
max_val = float(max(df["truth"].max(), df["guess"].max()))
|
| 83 |
+
|
| 84 |
+
fig = px.scatter(
|
| 85 |
+
df,
|
| 86 |
+
x="truth",
|
| 87 |
+
y="guess",
|
| 88 |
+
color="color",
|
| 89 |
+
color_discrete_map={"green": "green", "orange": "orange", "red": "red"},
|
| 90 |
+
title=title,
|
| 91 |
+
labels={"truth": "Actual Price", "guess": "Predicted Price"},
|
| 92 |
+
width=1000,
|
| 93 |
+
height=800,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Assign customdata per trace (one color/category = one trace)
|
| 97 |
+
for tr in fig.data:
|
| 98 |
+
mask = df["color"] == tr.name
|
| 99 |
+
tr.customdata = df.loc[mask, ["hover"]].to_numpy()
|
| 100 |
+
tr.hovertemplate = "%{customdata[0]}<extra></extra>"
|
| 101 |
+
tr.marker.update(size=6)
|
| 102 |
+
|
| 103 |
+
# Reference line y=x
|
| 104 |
+
fig.add_trace(
|
| 105 |
+
go.Scatter(
|
| 106 |
+
x=[0, max_val],
|
| 107 |
+
y=[0, max_val],
|
| 108 |
+
mode="lines",
|
| 109 |
+
line=dict(width=2, dash="dash", color="deepskyblue"),
|
| 110 |
+
name="y = x",
|
| 111 |
+
hoverinfo="skip",
|
| 112 |
+
showlegend=False,
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
fig.update_xaxes(range=[0, max_val])
|
| 117 |
+
fig.update_yaxes(range=[0, max_val])
|
| 118 |
+
fig.update_layout(showlegend=False)
|
| 119 |
+
fig.show()
|
| 120 |
+
|
| 121 |
+
def error_trend_chart(self):
|
| 122 |
+
n = len(self.errors)
|
| 123 |
+
|
| 124 |
+
# Running mean and std (pure Python)
|
| 125 |
+
running_sums = list(accumulate(self.errors))
|
| 126 |
+
x = list(range(1, n + 1))
|
| 127 |
+
running_means = [s / i for s, i in zip(running_sums, x)]
|
| 128 |
+
|
| 129 |
+
running_squares = list(accumulate(e * e for e in self.errors))
|
| 130 |
+
running_stds = [
|
| 131 |
+
math.sqrt((sq_sum / i) - (mean**2)) if i > 1 else 0
|
| 132 |
+
for i, sq_sum, mean in zip(x, running_squares, running_means)
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
# 95% confidence interval for mean
|
| 136 |
+
ci = [1.96 * (sd / math.sqrt(i)) if i > 1 else 0 for i, sd in zip(x, running_stds)]
|
| 137 |
+
upper = [m + c for m, c in zip(running_means, ci)]
|
| 138 |
+
lower = [m - c for m, c in zip(running_means, ci)]
|
| 139 |
+
|
| 140 |
+
# Plot
|
| 141 |
+
fig = go.Figure()
|
| 142 |
+
|
| 143 |
+
# Shaded confidence interval band
|
| 144 |
+
fig.add_trace(
|
| 145 |
+
go.Scatter(
|
| 146 |
+
x=x + x[::-1],
|
| 147 |
+
y=upper + lower[::-1],
|
| 148 |
+
fill="toself",
|
| 149 |
+
fillcolor="rgba(128,128,128,0.2)",
|
| 150 |
+
line=dict(color="rgba(255,255,255,0)"),
|
| 151 |
+
hoverinfo="skip",
|
| 152 |
+
showlegend=False,
|
| 153 |
+
name="95% CI",
|
| 154 |
+
)
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Main line with hover text showing CI
|
| 158 |
+
fig.add_trace(
|
| 159 |
+
go.Scatter(
|
| 160 |
+
x=x,
|
| 161 |
+
y=running_means,
|
| 162 |
+
mode="lines",
|
| 163 |
+
line=dict(width=3, color="firebrick"),
|
| 164 |
+
name="Cumulative Avg Error",
|
| 165 |
+
customdata=list(
|
| 166 |
+
zip(
|
| 167 |
+
ci,
|
| 168 |
+
)
|
| 169 |
+
),
|
| 170 |
+
hovertemplate=(
|
| 171 |
+
"n=%{x}<br>"
|
| 172 |
+
"Avg Error=$%{y:,.2f}<br>"
|
| 173 |
+
"±95% CI=$%{customdata[0]:,.2f}<extra></extra>"
|
| 174 |
+
),
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# Title with final stats
|
| 179 |
+
final_mean = running_means[-1]
|
| 180 |
+
final_ci = ci[-1]
|
| 181 |
+
title = f"{self.title} Error: ${final_mean:,.2f} ± ${final_ci:,.2f}"
|
| 182 |
+
|
| 183 |
+
fig.update_layout(
|
| 184 |
+
title=title,
|
| 185 |
+
xaxis_title="Number of Datapoints",
|
| 186 |
+
yaxis_title="Average Absolute Error ($)",
|
| 187 |
+
width=1000,
|
| 188 |
+
height=360,
|
| 189 |
+
template="plotly_white",
|
| 190 |
+
showlegend=False,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
fig.show()
|
| 194 |
+
|
| 195 |
+
def report(self):
|
| 196 |
+
average_error = sum(self.errors) / self.size
|
| 197 |
+
mse = mean_squared_error(self.truths, self.guesses)
|
| 198 |
+
r2 = r2_score(self.truths, self.guesses) * 100
|
| 199 |
+
title = f"{self.title} results<br><b>Error:</b> ${average_error:,.2f} <b>MSE:</b> {mse:,.0f} <b>r²:</b> {r2:.1f}%"
|
| 200 |
+
self.error_trend_chart()
|
| 201 |
+
self.chart(title)
|
| 202 |
+
|
| 203 |
+
def run(self):
|
| 204 |
+
with ThreadPoolExecutor(max_workers=self.workers) as ex:
|
| 205 |
+
for title, guess, truth, error, color in tqdm(
|
| 206 |
+
ex.map(self.run_datapoint, range(self.size)), total=self.size
|
| 207 |
+
):
|
| 208 |
+
self.titles.append(title)
|
| 209 |
+
self.guesses.append(guess)
|
| 210 |
+
self.truths.append(truth)
|
| 211 |
+
self.errors.append(error)
|
| 212 |
+
self.colors.append(color)
|
| 213 |
+
print(f"{COLOR_MAP[color]}${error:.0f} ", end="")
|
| 214 |
+
self.report()
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def evaluate(function, data, size=DEFAULT_SIZE, workers=WORKERS):
|
| 218 |
+
Tester(function, data, size=size, workers=workers).run()
|
_hf_capstone_repo/agents/frontier_agent.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
|
| 5 |
+
from groq import Groq
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
|
| 8 |
+
from agents.agent import Agent
|
| 9 |
+
from free_config import GROQ_MODEL
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class FrontierAgent(Agent):
|
| 13 |
+
name = "Frontier Agent"
|
| 14 |
+
color = Agent.BLUE
|
| 15 |
+
|
| 16 |
+
def __init__(self, collection):
|
| 17 |
+
"""
|
| 18 |
+
Set up this instance by connecting to Groq, the Chroma datastore,
|
| 19 |
+
and the local sentence-transformer embedding model.
|
| 20 |
+
"""
|
| 21 |
+
self.log("Initializing Frontier Agent")
|
| 22 |
+
self.client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 23 |
+
self.model = GROQ_MODEL
|
| 24 |
+
self.log(f"Frontier Agent is setting up with Groq ({self.model})")
|
| 25 |
+
self.collection = collection
|
| 26 |
+
self.encoder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
|
| 27 |
+
self.log("Frontier Agent is ready")
|
| 28 |
+
|
| 29 |
+
def make_context(self, similars: List[str], prices: List[float]) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Create context that can be inserted into the prompt
|
| 32 |
+
"""
|
| 33 |
+
message = "To provide some context, here are some other items that might be similar to the item you need to estimate.\n\n"
|
| 34 |
+
for similar, price in zip(similars, prices):
|
| 35 |
+
message += f"Potentially related product:\n{similar}\nPrice is ${price:.2f}\n\n"
|
| 36 |
+
return message
|
| 37 |
+
|
| 38 |
+
def messages_for(
|
| 39 |
+
self, description: str, similars: List[str], prices: List[float]
|
| 40 |
+
) -> List[Dict[str, str]]:
|
| 41 |
+
"""
|
| 42 |
+
Create the message list for the Groq chat completion call
|
| 43 |
+
"""
|
| 44 |
+
message = f"Estimate the price of this product. Respond with the price only, no explanation.\n\n{description}\n\n"
|
| 45 |
+
message += self.make_context(similars, prices)
|
| 46 |
+
return [{"role": "user", "content": message}]
|
| 47 |
+
|
| 48 |
+
def find_similars(self, description: str):
|
| 49 |
+
"""
|
| 50 |
+
Return a list of items similar to the given one by looking in the Chroma datastore
|
| 51 |
+
"""
|
| 52 |
+
self.log(
|
| 53 |
+
"Frontier Agent is performing a RAG search of the Chroma datastore to find 5 similar products"
|
| 54 |
+
)
|
| 55 |
+
vector = self.encoder.encode([description])
|
| 56 |
+
results = self.collection.query(query_embeddings=vector.astype(float).tolist(), n_results=5)
|
| 57 |
+
documents = results["documents"][0][:]
|
| 58 |
+
prices = [m["price"] for m in results["metadatas"][0][:]]
|
| 59 |
+
self.log("Frontier Agent has found similar products")
|
| 60 |
+
return documents, prices
|
| 61 |
+
|
| 62 |
+
def get_price(self, s) -> float:
|
| 63 |
+
"""
|
| 64 |
+
A utility that plucks a floating point number out of a string
|
| 65 |
+
"""
|
| 66 |
+
s = s.replace("$", "").replace(",", "")
|
| 67 |
+
match = re.search(r"[-+]?\d*\.\d+|\d+", s)
|
| 68 |
+
return float(match.group()) if match else 0.0
|
| 69 |
+
|
| 70 |
+
def price(self, description: str) -> float:
|
| 71 |
+
"""
|
| 72 |
+
Call Groq to estimate the price using RAG context from similar products.
|
| 73 |
+
"""
|
| 74 |
+
documents, prices = self.find_similars(description)
|
| 75 |
+
self.log(
|
| 76 |
+
f"Frontier Agent is about to call Groq ({self.model}) with context including 5 similar products"
|
| 77 |
+
)
|
| 78 |
+
response = self.client.chat.completions.create(
|
| 79 |
+
model=self.model,
|
| 80 |
+
messages=self.messages_for(description, documents, prices),
|
| 81 |
+
temperature=0,
|
| 82 |
+
)
|
| 83 |
+
reply = response.choices[0].message.content
|
| 84 |
+
result = self.get_price(reply)
|
| 85 |
+
self.log(f"Frontier Agent completed - predicting ${result:.2f}")
|
| 86 |
+
return result
|
_hf_capstone_repo/agents/items.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from datasets import Dataset, DatasetDict, load_dataset
|
| 3 |
+
from typing import Optional, Self
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
PREFIX = "Price is $"
|
| 7 |
+
QUESTION = "What does this cost to the nearest dollar?"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Item(BaseModel):
|
| 11 |
+
"""
|
| 12 |
+
An Item is a data-point of a Product with a Price
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
title: str
|
| 16 |
+
category: str
|
| 17 |
+
price: float
|
| 18 |
+
full: Optional[str] = None
|
| 19 |
+
weight: Optional[float] = None
|
| 20 |
+
summary: Optional[str] = None
|
| 21 |
+
prompt: Optional[str] = None
|
| 22 |
+
id: Optional[int] = None
|
| 23 |
+
|
| 24 |
+
def make_prompt(self, text: str):
|
| 25 |
+
self.prompt = f"{QUESTION}\n\n{text}\n\n{PREFIX}{round(self.price)}.00"
|
| 26 |
+
|
| 27 |
+
def test_prompt(self) -> str:
|
| 28 |
+
return self.prompt.split(PREFIX)[0] + PREFIX
|
| 29 |
+
|
| 30 |
+
def __repr__(self) -> str:
|
| 31 |
+
return f"<{self.title} = ${self.price}>"
|
| 32 |
+
|
| 33 |
+
@staticmethod
|
| 34 |
+
def push_to_hub(dataset_name: str, train: list[Self], val: list[Self], test: list[Self]):
|
| 35 |
+
"""Push Item lists to HuggingFace Hub"""
|
| 36 |
+
DatasetDict(
|
| 37 |
+
{
|
| 38 |
+
"train": Dataset.from_list([item.model_dump() for item in train]),
|
| 39 |
+
"validation": Dataset.from_list([item.model_dump() for item in val]),
|
| 40 |
+
"test": Dataset.from_list([item.model_dump() for item in test]),
|
| 41 |
+
}
|
| 42 |
+
).push_to_hub(dataset_name)
|
| 43 |
+
|
| 44 |
+
@classmethod
|
| 45 |
+
def from_hub(cls, dataset_name: str) -> tuple[list[Self], list[Self], list[Self]]:
|
| 46 |
+
"""Load from HuggingFace Hub and reconstruct Items"""
|
| 47 |
+
ds = load_dataset(dataset_name)
|
| 48 |
+
return (
|
| 49 |
+
[cls.model_validate(row) for row in ds["train"]],
|
| 50 |
+
[cls.model_validate(row) for row in ds["validation"]],
|
| 51 |
+
[cls.model_validate(row) for row in ds["test"]],
|
| 52 |
+
)
|
_hf_capstone_repo/agents/messaging_agent.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
from litellm import completion
|
| 5 |
+
|
| 6 |
+
from agents.deals import Opportunity
|
| 7 |
+
from agents.agent import Agent
|
| 8 |
+
from free_config import MESSAGING_MODEL, PUSHOVER_CONFIGURED
|
| 9 |
+
|
| 10 |
+
pushover_url = "https://api.pushover.net/1/messages.json"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MessagingAgent(Agent):
|
| 14 |
+
name = "Messaging Agent"
|
| 15 |
+
color = Agent.WHITE
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
"""
|
| 19 |
+
Set up Pushover notifications (optional) and Groq for message crafting.
|
| 20 |
+
"""
|
| 21 |
+
self.log("Messaging Agent is initializing")
|
| 22 |
+
self.pushover_user = os.getenv("PUSHOVER_USER", "")
|
| 23 |
+
self.pushover_token = os.getenv("PUSHOVER_TOKEN", "")
|
| 24 |
+
self.model = MESSAGING_MODEL
|
| 25 |
+
if PUSHOVER_CONFIGURED:
|
| 26 |
+
self.log("Messaging Agent has Pushover configured")
|
| 27 |
+
else:
|
| 28 |
+
self.log("Messaging Agent: Pushover not configured — alerts will be logged only")
|
| 29 |
+
|
| 30 |
+
def push(self, text):
|
| 31 |
+
"""
|
| 32 |
+
Send a Push Notification using the Pushover API, or log if not configured.
|
| 33 |
+
"""
|
| 34 |
+
if not PUSHOVER_CONFIGURED:
|
| 35 |
+
self.log(f"Deal alert (Pushover skipped): {text[:120]}...")
|
| 36 |
+
return
|
| 37 |
+
self.log("Messaging Agent is sending a push notification")
|
| 38 |
+
payload = {
|
| 39 |
+
"user": self.pushover_user,
|
| 40 |
+
"token": self.pushover_token,
|
| 41 |
+
"message": text,
|
| 42 |
+
"sound": "cashregister",
|
| 43 |
+
}
|
| 44 |
+
requests.post(pushover_url, data=payload)
|
| 45 |
+
|
| 46 |
+
def alert(self, opportunity: Opportunity):
|
| 47 |
+
"""
|
| 48 |
+
Make an alert about the specified Opportunity
|
| 49 |
+
"""
|
| 50 |
+
text = f"Deal Alert! Price=${opportunity.deal.price:.2f}, "
|
| 51 |
+
text += f"Estimate=${opportunity.estimate:.2f}, "
|
| 52 |
+
text += f"Discount=${opportunity.discount:.2f} :"
|
| 53 |
+
text += opportunity.deal.product_description[:10] + "... "
|
| 54 |
+
text += opportunity.deal.url
|
| 55 |
+
self.push(text)
|
| 56 |
+
self.log("Messaging Agent has completed")
|
| 57 |
+
|
| 58 |
+
def craft_message(
|
| 59 |
+
self, description: str, deal_price: float, estimated_true_value: float
|
| 60 |
+
) -> str:
|
| 61 |
+
user_prompt = "Please summarize this great deal in 2-3 sentences to be sent as an exciting push notification alerting the user about this deal.\n"
|
| 62 |
+
user_prompt += f"Item Description: {description}\nOffered Price: {deal_price}\nEstimated true value: {estimated_true_value}"
|
| 63 |
+
user_prompt += "\n\nRespond only with the 2-3 sentence message which will be used to alert & excite the user about this deal"
|
| 64 |
+
response = completion(
|
| 65 |
+
model=self.model,
|
| 66 |
+
messages=[
|
| 67 |
+
{"role": "user", "content": user_prompt},
|
| 68 |
+
],
|
| 69 |
+
)
|
| 70 |
+
return response.choices[0].message.content
|
| 71 |
+
|
| 72 |
+
def notify(self, description: str, deal_price: float, estimated_true_value: float, url: str):
|
| 73 |
+
"""
|
| 74 |
+
Make an alert about the specified details
|
| 75 |
+
"""
|
| 76 |
+
self.log(f"Messaging Agent is using Groq ({self.model}) to craft the message")
|
| 77 |
+
text = self.craft_message(description, deal_price, estimated_true_value)
|
| 78 |
+
self.push(text[:200] + "... " + url)
|
| 79 |
+
self.log("Messaging Agent has completed")
|
_hf_capstone_repo/agents/neural_network_agent.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.agent import Agent
|
| 2 |
+
from agents.deep_neural_network import DeepNeuralNetworkInference
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class NeuralNetworkAgent(Agent):
|
| 6 |
+
name = "Neural Network Agent"
|
| 7 |
+
color = Agent.MAGENTA
|
| 8 |
+
|
| 9 |
+
def __init__(self):
|
| 10 |
+
"""
|
| 11 |
+
Initialize this object by loading in the saved model weights
|
| 12 |
+
and the SentenceTransformer vector encoding model
|
| 13 |
+
"""
|
| 14 |
+
self.log("Neural Network Agent is initializing")
|
| 15 |
+
self.neural_network = DeepNeuralNetworkInference()
|
| 16 |
+
self.neural_network.setup()
|
| 17 |
+
self.neural_network.load("deep_neural_network.pth")
|
| 18 |
+
self.log("Neural Network Agent is ready and weights are loaded")
|
| 19 |
+
|
| 20 |
+
def price(self, description: str) -> float:
|
| 21 |
+
"""
|
| 22 |
+
Use the Deep Neural Network to estimate the price of the described item
|
| 23 |
+
:param description: the product to be estimated
|
| 24 |
+
:return: the price as a float
|
| 25 |
+
"""
|
| 26 |
+
self.log("Neural Network Agent is starting a prediction")
|
| 27 |
+
result = self.neural_network.inference(description)
|
| 28 |
+
self.log(f"Neural Network Agent completed - predicting ${result:.2f}")
|
| 29 |
+
return result
|
_hf_capstone_repo/agents/planning_agent.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List
|
| 2 |
+
from agents.agent import Agent
|
| 3 |
+
from agents.deals import ScrapedDeal, DealSelection, Deal, Opportunity
|
| 4 |
+
from agents.scanner_agent import ScannerAgent
|
| 5 |
+
from agents.ensemble_agent import EnsembleAgent
|
| 6 |
+
from agents.messaging_agent import MessagingAgent
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class PlanningAgent(Agent):
|
| 10 |
+
|
| 11 |
+
name = "Planning Agent"
|
| 12 |
+
color = Agent.GREEN
|
| 13 |
+
DEAL_THRESHOLD = 50
|
| 14 |
+
|
| 15 |
+
def __init__(self, collection):
|
| 16 |
+
"""
|
| 17 |
+
Create instances of the 3 Agents that this planner coordinates across
|
| 18 |
+
"""
|
| 19 |
+
self.log("Planning Agent is initializing")
|
| 20 |
+
self.scanner = ScannerAgent()
|
| 21 |
+
self.ensemble = EnsembleAgent(collection)
|
| 22 |
+
self.messenger = MessagingAgent()
|
| 23 |
+
self.log("Planning Agent is ready")
|
| 24 |
+
|
| 25 |
+
def run(self, deal: Deal) -> Opportunity:
|
| 26 |
+
"""
|
| 27 |
+
Run the workflow for a particular deal
|
| 28 |
+
:param deal: the deal, summarized from an RSS scrape
|
| 29 |
+
:returns: an opportunity including the discount
|
| 30 |
+
"""
|
| 31 |
+
self.log("Planning Agent is pricing up a potential deal")
|
| 32 |
+
estimate = self.ensemble.price(deal.product_description)
|
| 33 |
+
discount = estimate - deal.price
|
| 34 |
+
self.log(f"Planning Agent has processed a deal with discount ${discount:.2f}")
|
| 35 |
+
return Opportunity(deal=deal, estimate=estimate, discount=discount)
|
| 36 |
+
|
| 37 |
+
def plan(self, memory: List[str] = []) -> Optional[Opportunity]:
|
| 38 |
+
"""
|
| 39 |
+
Run the full workflow:
|
| 40 |
+
1. Use the ScannerAgent to find deals from RSS feeds
|
| 41 |
+
2. Use the EnsembleAgent to estimate them
|
| 42 |
+
3. Use the MessagingAgent to send a notification of deals
|
| 43 |
+
:param memory: a list of URLs that have been surfaced in the past
|
| 44 |
+
:return: an Opportunity if one was surfaced, otherwise None
|
| 45 |
+
"""
|
| 46 |
+
self.log("Planning Agent is kicking off a run")
|
| 47 |
+
selection = self.scanner.scan(memory=memory)
|
| 48 |
+
if selection:
|
| 49 |
+
opportunities = [self.run(deal) for deal in selection.deals[:5]]
|
| 50 |
+
opportunities.sort(key=lambda opp: opp.discount, reverse=True)
|
| 51 |
+
best = opportunities[0]
|
| 52 |
+
self.log(f"Planning Agent has identified the best deal has discount ${best.discount:.2f}")
|
| 53 |
+
if best.discount > self.DEAL_THRESHOLD:
|
| 54 |
+
self.messenger.alert(best)
|
| 55 |
+
self.log("Planning Agent has completed a run")
|
| 56 |
+
return best if best.discount > self.DEAL_THRESHOLD else None
|
| 57 |
+
return None
|
_hf_capstone_repo/agents/preprocessor.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from litellm import completion
|
| 5 |
+
|
| 6 |
+
from free_config import PREPROCESSOR_MODEL
|
| 7 |
+
|
| 8 |
+
load_dotenv(override=True)
|
| 9 |
+
|
| 10 |
+
DEFAULT_REASONING_EFFORT = "low" if "gpt-oss" in PREPROCESSOR_MODEL else None
|
| 11 |
+
|
| 12 |
+
SYSTEM_PROMPT = """Create a concise description of a product. Respond only in this format. Do not include part numbers.
|
| 13 |
+
Title: Rewritten short precise title
|
| 14 |
+
Category: eg Electronics
|
| 15 |
+
Brand: Brand name
|
| 16 |
+
Description: 1 sentence description
|
| 17 |
+
Details: 1 sentence on features"""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Preprocessor:
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
model_name=PREPROCESSOR_MODEL,
|
| 24 |
+
reasoning_effort=DEFAULT_REASONING_EFFORT,
|
| 25 |
+
base_url=None,
|
| 26 |
+
):
|
| 27 |
+
self.total_input_tokens = 0
|
| 28 |
+
self.total_output_tokens = 0
|
| 29 |
+
self.total_cost = 0
|
| 30 |
+
self.model_name = model_name
|
| 31 |
+
self.reasoning_effort = reasoning_effort
|
| 32 |
+
self.base_url = base_url
|
| 33 |
+
if "ollama" in model_name and not base_url:
|
| 34 |
+
self.base_url = "http://localhost:11434"
|
| 35 |
+
|
| 36 |
+
def messages_for(self, text: str) -> list[dict]:
|
| 37 |
+
return [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": text}]
|
| 38 |
+
|
| 39 |
+
def preprocess(self, text: str) -> str:
|
| 40 |
+
messages = self.messages_for(text)
|
| 41 |
+
response = completion(
|
| 42 |
+
messages=messages,
|
| 43 |
+
model=self.model_name,
|
| 44 |
+
reasoning_effort=self.reasoning_effort,
|
| 45 |
+
api_base=self.base_url,
|
| 46 |
+
)
|
| 47 |
+
self.total_input_tokens += response.usage.prompt_tokens
|
| 48 |
+
self.total_output_tokens += response.usage.completion_tokens
|
| 49 |
+
self.total_cost += response._hidden_params["response_cost"]
|
| 50 |
+
return response.choices[0].message.content
|
_hf_capstone_repo/agents/scanner_agent.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
|
| 6 |
+
from groq import BadRequestError, Groq
|
| 7 |
+
|
| 8 |
+
from agents.deals import ScrapedDeal, DealSelection
|
| 9 |
+
from agents.agent import Agent
|
| 10 |
+
from free_config import GROQ_MODEL
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ScannerAgent(Agent):
|
| 14 |
+
MAX_DEALS_PER_REQUEST = 15
|
| 15 |
+
|
| 16 |
+
JSON_FORMAT_HINT = (
|
| 17 |
+
'\n\nRespond with JSON only, using this shape: '
|
| 18 |
+
'{"deals": [{"product_description": "...", "price": 99.99, "url": "https://..."}]}'
|
| 19 |
+
'\nThe JSON must be valid: no double-quote characters inside product_description '
|
| 20 |
+
'(write "6.9 inches" instead of 6.9"). No trailing commas. '
|
| 21 |
+
'Close the deals array with a single ] and the object with a single }.'
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
SYSTEM_PROMPT = """You identify and summarize the 5 most detailed deals from a list, by selecting deals that have the most detailed, high quality description and the most clear price.
|
| 25 |
+
You should provide the price as a number derived from the description. If the price of a deal isn't clear, do not include that deal in your response.
|
| 26 |
+
Most important is that you respond with the 5 deals that have the most detailed product description with price. It's not important to mention the terms of the deal; most important is a thorough description of the product.
|
| 27 |
+
Be careful with products that are described as "$XXX off" or "reduced by $XXX" - this isn't the actual price of the product. Only respond with products when you are highly confident about the price.
|
| 28 |
+
Never use the double-quote character inside product_description text. Spell out inch measurements as words, e.g. "55 inches" not 55".
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
USER_PROMPT_PREFIX = """Respond with the most promising 5 deals from this list, selecting those which have the most detailed, high quality product description and a clear price that is greater than 0.
|
| 32 |
+
You should rephrase the description to be a summary of the product itself, not the terms of the deal.
|
| 33 |
+
Remember to respond with a short paragraph of text in the product_description field for each of the 5 items that you select.
|
| 34 |
+
Be careful with products that are described as "$XXX off" or "reduced by $XXX" - this isn't the actual price of the product. Only respond with products when you are highly confident about the price.
|
| 35 |
+
|
| 36 |
+
Deals:
|
| 37 |
+
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
USER_PROMPT_SUFFIX = "\n\nInclude exactly 5 deals, no more."
|
| 41 |
+
|
| 42 |
+
name = "Scanner Agent"
|
| 43 |
+
color = Agent.CYAN
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
self.log("Scanner Agent is initializing")
|
| 47 |
+
self.client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 48 |
+
self.model = GROQ_MODEL
|
| 49 |
+
self.log(f"Scanner Agent is ready (Groq / {self.model})")
|
| 50 |
+
|
| 51 |
+
def fetch_deals(self, memory) -> List[ScrapedDeal]:
|
| 52 |
+
"""
|
| 53 |
+
Look up deals published on RSS feeds
|
| 54 |
+
Return any new deals that are not already in the memory provided
|
| 55 |
+
"""
|
| 56 |
+
self.log("Scanner Agent is about to fetch deals from RSS feed")
|
| 57 |
+
urls = [opp.deal.url for opp in memory]
|
| 58 |
+
scraped = ScrapedDeal.fetch()
|
| 59 |
+
result = [scrape for scrape in scraped if scrape.url not in urls]
|
| 60 |
+
self.log(f"Scanner Agent received {len(result)} deals not already scraped")
|
| 61 |
+
return result
|
| 62 |
+
|
| 63 |
+
def make_user_prompt(self, scraped) -> str:
|
| 64 |
+
"""
|
| 65 |
+
Create a user prompt based on the scraped deals provided
|
| 66 |
+
"""
|
| 67 |
+
user_prompt = self.USER_PROMPT_PREFIX
|
| 68 |
+
user_prompt += "\n\n".join(
|
| 69 |
+
[scrape.describe() for scrape in scraped[: self.MAX_DEALS_PER_REQUEST]]
|
| 70 |
+
)
|
| 71 |
+
user_prompt += self.USER_PROMPT_SUFFIX
|
| 72 |
+
return user_prompt
|
| 73 |
+
|
| 74 |
+
@staticmethod
|
| 75 |
+
def _repair_deals_json(text: str) -> str:
|
| 76 |
+
text = text.strip()
|
| 77 |
+
text = re.sub(r'(\d+(?:\.\d+)?)"(\s+(?=[a-zA-Z0-9]))', r"\1 inches\2", text)
|
| 78 |
+
text = re.sub(r'("url"\s*:\s*"[^"]*")\s*\]', r"\1\n }", text)
|
| 79 |
+
text = re.sub(
|
| 80 |
+
r'("url"\s*:\s*"[^"]*")\s*\}\s*\n\s*\]\s*\n\s*\}\}',
|
| 81 |
+
r"\1\n }\n ]\n}",
|
| 82 |
+
text,
|
| 83 |
+
)
|
| 84 |
+
text = re.sub(r"(\])\s*\]\s*\}\s*\}", r"\1}", text)
|
| 85 |
+
text = re.sub(r"(\])\s*\]\s*\}", r"\1}", text)
|
| 86 |
+
text = re.sub(r"\}\s*\}\s*$", r"}", text)
|
| 87 |
+
return text
|
| 88 |
+
|
| 89 |
+
def _parse_deals_json(self, content: str) -> DealSelection:
|
| 90 |
+
last_error = None
|
| 91 |
+
for candidate in (content, self._repair_deals_json(content)):
|
| 92 |
+
try:
|
| 93 |
+
result = DealSelection.model_validate(json.loads(candidate))
|
| 94 |
+
result.deals = [deal for deal in result.deals if deal.price > 0]
|
| 95 |
+
return result
|
| 96 |
+
except (json.JSONDecodeError, ValueError) as exc:
|
| 97 |
+
last_error = exc
|
| 98 |
+
raise ValueError(f"Could not parse deals JSON: {last_error}")
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def _extract_failed_generation(exc: BadRequestError) -> Optional[str]:
|
| 102 |
+
body = getattr(exc, "body", None)
|
| 103 |
+
if isinstance(body, dict):
|
| 104 |
+
return body.get("error", {}).get("failed_generation")
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
def _call_groq(self, user_prompt: str) -> DealSelection:
|
| 108 |
+
messages = [
|
| 109 |
+
{"role": "system", "content": self.SYSTEM_PROMPT + self.JSON_FORMAT_HINT},
|
| 110 |
+
{"role": "user", "content": user_prompt},
|
| 111 |
+
]
|
| 112 |
+
try:
|
| 113 |
+
response = self.client.chat.completions.create(
|
| 114 |
+
model=self.model,
|
| 115 |
+
messages=messages,
|
| 116 |
+
response_format={"type": "json_object"},
|
| 117 |
+
temperature=0,
|
| 118 |
+
)
|
| 119 |
+
return self._parse_deals_json(response.choices[0].message.content)
|
| 120 |
+
except BadRequestError as exc:
|
| 121 |
+
failed = self._extract_failed_generation(exc)
|
| 122 |
+
if failed:
|
| 123 |
+
self.log("Scanner Agent repairing malformed JSON from Groq response")
|
| 124 |
+
return self._parse_deals_json(failed)
|
| 125 |
+
raise
|
| 126 |
+
|
| 127 |
+
def scan(self, memory: List[str] = []) -> Optional[DealSelection]:
|
| 128 |
+
"""
|
| 129 |
+
Call Groq to provide a high potential list of deals with good descriptions and prices
|
| 130 |
+
:param memory: a list of URLs representing deals already raised
|
| 131 |
+
:return: a selection of good deals, or None if there aren't any
|
| 132 |
+
"""
|
| 133 |
+
scraped = self.fetch_deals(memory)
|
| 134 |
+
if not scraped:
|
| 135 |
+
return None
|
| 136 |
+
user_prompt = self.make_user_prompt(scraped)
|
| 137 |
+
self.log(f"Scanner Agent is calling Groq ({self.model})")
|
| 138 |
+
try:
|
| 139 |
+
result = self._call_groq(user_prompt)
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
self.log(f"Scanner Agent failed to parse Groq response: {exc}")
|
| 142 |
+
return None
|
| 143 |
+
self.log(
|
| 144 |
+
f"Scanner Agent received {len(result.deals)} selected deals with price>0 from Groq"
|
| 145 |
+
)
|
| 146 |
+
return result
|
| 147 |
+
|
| 148 |
+
def test_scan(self, memory: List[str] = []) -> Optional[DealSelection]:
|
| 149 |
+
"""
|
| 150 |
+
Return a test DealSelection, to be used during testing
|
| 151 |
+
"""
|
| 152 |
+
results = {
|
| 153 |
+
"deals": [
|
| 154 |
+
{
|
| 155 |
+
"product_description": "The Hisense R6 Series 55R6030N is a 55-inch 4K UHD Roku Smart TV that offers stunning picture quality with 3840x2160 resolution. It features Dolby Vision HDR and HDR10 compatibility, ensuring a vibrant and dynamic viewing experience. The TV runs on Roku's operating system, allowing easy access to streaming services and voice control compatibility with Google Assistant and Alexa. With three HDMI ports available, connecting multiple devices is simple and efficient.",
|
| 156 |
+
"price": 178,
|
| 157 |
+
"url": "https://www.dealnews.com/products/Hisense/Hisense-R6-Series-55-R6030-N-55-4-K-UHD-Roku-Smart-TV/484824.html?iref=rss-c142",
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"product_description": "The Poly Studio P21 is a 21.5-inch LED personal meeting display designed specifically for remote work and video conferencing. With a native resolution of 1080p, it provides crystal-clear video quality, featuring a privacy shutter and stereo speakers. This display includes a 1080p webcam with manual pan, tilt, and zoom control, along with an ambient light sensor to adjust the vanity lighting as needed. It also supports 5W wireless charging for mobile devices, making it an all-in-one solution for home offices.",
|
| 161 |
+
"price": 30,
|
| 162 |
+
"url": "https://www.dealnews.com/products/Poly-Studio-P21-21-5-1080-p-LED-Personal-Meeting-Display/378335.html?iref=rss-c39",
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"product_description": "The Lenovo IdeaPad Slim 5 laptop is powered by a 7th generation AMD Ryzen 5 8645HS 6-core CPU, offering efficient performance for multitasking and demanding applications. It features a 16-inch touch display with a resolution of 1920x1080, ensuring bright and vivid visuals. Accompanied by 16GB of RAM and a 512GB SSD, the laptop provides ample speed and storage for all your files. This model is designed to handle everyday tasks with ease while delivering an enjoyable user experience.",
|
| 166 |
+
"price": 446,
|
| 167 |
+
"url": "https://www.dealnews.com/products/Lenovo/Lenovo-Idea-Pad-Slim-5-7-th-Gen-Ryzen-5-16-Touch-Laptop/485068.html?iref=rss-c39",
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"product_description": "The Dell G15 gaming laptop is equipped with a 6th-generation AMD Ryzen 5 7640HS 6-Core CPU, providing powerful performance for gaming and content creation. It features a 15.6-inch 1080p display with a 120Hz refresh rate, allowing for smooth and responsive gameplay. With 16GB of RAM and a substantial 1TB NVMe M.2 SSD, this laptop ensures speedy performance and plenty of storage for games and applications. Additionally, it includes the Nvidia GeForce RTX 3050 GPU for enhanced graphics and gaming experiences.",
|
| 171 |
+
"price": 650,
|
| 172 |
+
"url": "https://www.dealnews.com/products/Dell/Dell-G15-Ryzen-5-15-6-Gaming-Laptop-w-Nvidia-RTX-3050/485067.html?iref=rss-c39",
|
| 173 |
+
},
|
| 174 |
+
]
|
| 175 |
+
}
|
| 176 |
+
return DealSelection(**results)
|
_hf_capstone_repo/agents/specialist_agent.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from agents.agent import Agent
|
| 5 |
+
from free_config import GROQ_MODEL, USE_MODAL_SPECIALIST
|
| 6 |
+
|
| 7 |
+
QUESTION = "What does this cost to the nearest dollar?"
|
| 8 |
+
PREFIX = "Price is $"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SpecialistAgent(Agent):
|
| 12 |
+
"""
|
| 13 |
+
Prices products using either:
|
| 14 |
+
1. Fine-tuned Llama on Modal (best quality, uses GPU credits), or
|
| 15 |
+
2. Groq fallback (free — same prompt format as the fine-tuned model)
|
| 16 |
+
|
| 17 |
+
Set USE_MODAL_SPECIALIST=true only if you have Modal credits to spare.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
name = "Specialist Agent"
|
| 21 |
+
color = Agent.RED
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
self.pricer = None
|
| 25 |
+
self.groq_client = None
|
| 26 |
+
self.mode = "off"
|
| 27 |
+
self.model = GROQ_MODEL
|
| 28 |
+
|
| 29 |
+
if USE_MODAL_SPECIALIST:
|
| 30 |
+
self._try_modal()
|
| 31 |
+
|
| 32 |
+
if self.mode == "off" and os.getenv("GROQ_API_KEY"):
|
| 33 |
+
from groq import Groq
|
| 34 |
+
|
| 35 |
+
self.groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 36 |
+
self.mode = "groq"
|
| 37 |
+
self.log(f"Specialist Agent using Groq fallback ({self.model}) — no Modal GPU needed")
|
| 38 |
+
|
| 39 |
+
if self.mode == "off":
|
| 40 |
+
self.log("Specialist Agent disabled — ensemble will use Frontier + Neural Network only")
|
| 41 |
+
|
| 42 |
+
def _try_modal(self):
|
| 43 |
+
try:
|
| 44 |
+
import modal
|
| 45 |
+
|
| 46 |
+
self.log("Specialist Agent is initializing - connecting to Modal")
|
| 47 |
+
Pricer = modal.Cls.from_name("pricer-service", "Pricer")
|
| 48 |
+
self.pricer = Pricer()
|
| 49 |
+
self.mode = "modal"
|
| 50 |
+
self.log("Specialist Agent connected to Modal pricer-service")
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
self.log(f"Modal unavailable ({exc}) — will try Groq fallback if configured")
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def available(self) -> bool:
|
| 56 |
+
return self.mode != "off"
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def _parse_price(text: str) -> float:
|
| 60 |
+
if PREFIX in text:
|
| 61 |
+
text = text.split(PREFIX, 1)[1]
|
| 62 |
+
text = text.replace("$", "").replace(",", "")
|
| 63 |
+
match = re.search(r"[-+]?\d*\.\d+|\d+", text)
|
| 64 |
+
return float(match.group()) if match else 0.0
|
| 65 |
+
|
| 66 |
+
def _price_with_groq(self, description: str) -> float:
|
| 67 |
+
prompt = f"{QUESTION}\n\n{description}\n\n{PREFIX}"
|
| 68 |
+
response = self.groq_client.chat.completions.create(
|
| 69 |
+
model=self.model,
|
| 70 |
+
messages=[{"role": "user", "content": prompt}],
|
| 71 |
+
temperature=0,
|
| 72 |
+
max_tokens=8,
|
| 73 |
+
)
|
| 74 |
+
reply = response.choices[0].message.content or ""
|
| 75 |
+
return self._parse_price(reply)
|
| 76 |
+
|
| 77 |
+
def price(self, description: str) -> float:
|
| 78 |
+
if self.mode == "modal":
|
| 79 |
+
self.log("Specialist Agent is calling remote fine-tuned model on Modal")
|
| 80 |
+
result = self.pricer.price.remote(description)
|
| 81 |
+
self.log(f"Specialist Agent completed - predicting ${result:.2f}")
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
if self.mode == "groq":
|
| 85 |
+
self.log(f"Specialist Agent is calling Groq ({self.model})")
|
| 86 |
+
result = self._price_with_groq(description)
|
| 87 |
+
self.log(f"Specialist Agent completed - predicting ${result:.2f}")
|
| 88 |
+
return result
|
| 89 |
+
|
| 90 |
+
return 0.0
|
_hf_capstone_repo/app.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Spaces entry point.
|
| 3 |
+
|
| 4 |
+
Upload the entire week8/ folder into the Space repo root (this file becomes app.py),
|
| 5 |
+
or copy week8/* into the Space and rename/move as needed.
|
| 6 |
+
|
| 7 |
+
Required Space secrets: GROQ_API_KEY, HF_TOKEN, USE_MODAL_SPECIALIST=false
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
# When week8 is the Space root, agents/ and deal_agent_framework.py are siblings.
|
| 15 |
+
ROOT = Path(__file__).resolve().parent
|
| 16 |
+
if str(ROOT) not in sys.path:
|
| 17 |
+
sys.path.insert(0, str(ROOT))
|
| 18 |
+
|
| 19 |
+
os.environ.setdefault("GRADIO_SERVER_NAME", "0.0.0.0")
|
| 20 |
+
os.environ.setdefault("PRICER_PREPROCESSOR_MODEL", "groq/llama-3.1-8b-instant")
|
| 21 |
+
|
| 22 |
+
from price_is_right import App
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
App().run()
|
_hf_capstone_repo/deal_agent_framework.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import logging
|
| 4 |
+
import json
|
| 5 |
+
from typing import List
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
import chromadb
|
| 8 |
+
from agents.planning_agent import PlanningAgent
|
| 9 |
+
from agents.deals import Opportunity
|
| 10 |
+
from sklearn.manifold import TSNE
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
load_dotenv(override=True)
|
| 14 |
+
|
| 15 |
+
# Colors for logging
|
| 16 |
+
BG_BLUE = "\033[44m"
|
| 17 |
+
WHITE = "\033[37m"
|
| 18 |
+
RESET = "\033[0m"
|
| 19 |
+
|
| 20 |
+
# Colors for plot
|
| 21 |
+
CATEGORIES = [
|
| 22 |
+
"Appliances",
|
| 23 |
+
"Automotive",
|
| 24 |
+
"Cell_Phones_and_Accessories",
|
| 25 |
+
"Electronics",
|
| 26 |
+
"Musical_Instruments",
|
| 27 |
+
"Office_Products",
|
| 28 |
+
"Tools_and_Home_Improvement",
|
| 29 |
+
"Toys_and_Games",
|
| 30 |
+
]
|
| 31 |
+
COLORS = ["red", "blue", "brown", "orange", "yellow", "green", "purple", "cyan"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def init_logging():
|
| 35 |
+
root = logging.getLogger()
|
| 36 |
+
root.setLevel(logging.INFO)
|
| 37 |
+
|
| 38 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 39 |
+
handler.setLevel(logging.INFO)
|
| 40 |
+
formatter = logging.Formatter(
|
| 41 |
+
"[%(asctime)s] [Agents] [%(levelname)s] %(message)s",
|
| 42 |
+
datefmt="%Y-%m-%d %H:%M:%S %z",
|
| 43 |
+
)
|
| 44 |
+
handler.setFormatter(formatter)
|
| 45 |
+
root.addHandler(handler)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class DealAgentFramework:
|
| 49 |
+
DB = "products_vectorstore"
|
| 50 |
+
MEMORY_FILENAME = "memory.json"
|
| 51 |
+
|
| 52 |
+
def __init__(self):
|
| 53 |
+
init_logging()
|
| 54 |
+
client = chromadb.PersistentClient(path=self.DB)
|
| 55 |
+
self.memory = self.read_memory()
|
| 56 |
+
self.collection = client.get_or_create_collection("products")
|
| 57 |
+
self.planner = None
|
| 58 |
+
|
| 59 |
+
def init_agents_as_needed(self):
|
| 60 |
+
if not self.planner:
|
| 61 |
+
self.log("Initializing Agent Framework")
|
| 62 |
+
self.planner = PlanningAgent(self.collection)
|
| 63 |
+
self.log("Agent Framework is ready")
|
| 64 |
+
|
| 65 |
+
def read_memory(self) -> List[Opportunity]:
|
| 66 |
+
if os.path.exists(self.MEMORY_FILENAME):
|
| 67 |
+
with open(self.MEMORY_FILENAME, "r") as file:
|
| 68 |
+
data = json.load(file)
|
| 69 |
+
opportunities = [Opportunity(**item) for item in data]
|
| 70 |
+
return opportunities
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
def write_memory(self) -> None:
|
| 74 |
+
data = [opportunity.model_dump() for opportunity in self.memory]
|
| 75 |
+
with open(self.MEMORY_FILENAME, "w") as file:
|
| 76 |
+
json.dump(data, file, indent=2)
|
| 77 |
+
|
| 78 |
+
@classmethod
|
| 79 |
+
def reset_memory(cls) -> None:
|
| 80 |
+
data = []
|
| 81 |
+
if os.path.exists(cls.MEMORY_FILENAME):
|
| 82 |
+
with open(cls.MEMORY_FILENAME, "r") as file:
|
| 83 |
+
data = json.load(file)
|
| 84 |
+
truncated = data[:2]
|
| 85 |
+
with open(cls.MEMORY_FILENAME, "w") as file:
|
| 86 |
+
json.dump(truncated, file, indent=2)
|
| 87 |
+
|
| 88 |
+
def log(self, message: str):
|
| 89 |
+
text = BG_BLUE + WHITE + "[Agent Framework] " + message + RESET
|
| 90 |
+
logging.info(text)
|
| 91 |
+
|
| 92 |
+
def run(self) -> List[Opportunity]:
|
| 93 |
+
self.init_agents_as_needed()
|
| 94 |
+
logging.info("Kicking off Planning Agent")
|
| 95 |
+
result = self.planner.plan(memory=self.memory)
|
| 96 |
+
logging.info(f"Planning Agent has completed and returned: {result}")
|
| 97 |
+
if result:
|
| 98 |
+
self.memory.append(result)
|
| 99 |
+
self.write_memory()
|
| 100 |
+
return self.memory
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def get_plot_data(cls, max_datapoints=2000):
|
| 104 |
+
client = chromadb.PersistentClient(path=cls.DB)
|
| 105 |
+
collection = client.get_or_create_collection("products")
|
| 106 |
+
result = collection.get(
|
| 107 |
+
include=["embeddings", "documents", "metadatas"], limit=max_datapoints
|
| 108 |
+
)
|
| 109 |
+
vectors = np.array(result["embeddings"])
|
| 110 |
+
documents = result["documents"]
|
| 111 |
+
categories = [metadata["category"] for metadata in result["metadatas"]]
|
| 112 |
+
colors = [COLORS[CATEGORIES.index(c)] for c in categories]
|
| 113 |
+
tsne = TSNE(n_components=3, random_state=42, n_jobs=-1)
|
| 114 |
+
reduced_vectors = tsne.fit_transform(vectors)
|
| 115 |
+
return documents, reduced_vectors, colors
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
DealAgentFramework().run()
|
_hf_capstone_repo/env_utils.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load .env from repo root or week8 folder."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
WEEK8_DIR = Path(__file__).resolve().parent
|
| 8 |
+
REPO_ROOT = WEEK8_DIR.parent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_project_env() -> None:
|
| 12 |
+
for path in (REPO_ROOT / ".env", WEEK8_DIR / ".env"):
|
| 13 |
+
if path.exists():
|
| 14 |
+
load_dotenv(path, override=True)
|
_hf_capstone_repo/free_config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Free-tier configuration for Week 8 — The Price is Right.
|
| 3 |
+
|
| 4 |
+
Set GROQ_API_KEY in .env for cloud LLM calls (local run + Hugging Face Spaces).
|
| 5 |
+
Set USE_MODAL_SPECIALIST=true only if you want the GPU fine-tuned model on Modal.
|
| 6 |
+
By default Modal is OFF — Specialist uses Groq instead (free).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
from env_utils import load_project_env
|
| 12 |
+
|
| 13 |
+
load_project_env()
|
| 14 |
+
|
| 15 |
+
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
|
| 16 |
+
LITELLM_GROQ_MODEL = f"groq/{GROQ_MODEL}"
|
| 17 |
+
|
| 18 |
+
# Default false: Modal free credits are often only ~$1/month — not enough for regular GPU use.
|
| 19 |
+
USE_MODAL_SPECIALIST = os.getenv("USE_MODAL_SPECIALIST", "false").lower() in ("true", "1", "yes")
|
| 20 |
+
|
| 21 |
+
# Groq when an API key is present; otherwise local Ollama (local dev only).
|
| 22 |
+
PREPROCESSOR_MODEL = os.getenv(
|
| 23 |
+
"PRICER_PREPROCESSOR_MODEL",
|
| 24 |
+
LITELLM_GROQ_MODEL if os.getenv("GROQ_API_KEY") else "ollama/llama3.2",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
MESSAGING_MODEL = os.getenv("MESSAGING_MODEL", LITELLM_GROQ_MODEL)
|
| 28 |
+
|
| 29 |
+
# Ensemble weights when Modal specialist is unavailable.
|
| 30 |
+
WEIGHTS_WITH_SPECIALIST = (0.8, 0.1, 0.1) # frontier, specialist, neural_network
|
| 31 |
+
WEIGHTS_WITHOUT_SPECIALIST = (0.85, 0.15) # frontier, neural_network
|
| 32 |
+
|
| 33 |
+
PUSHOVER_CONFIGURED = bool(
|
| 34 |
+
os.getenv("PUSHOVER_USER")
|
| 35 |
+
and os.getenv("PUSHOVER_TOKEN")
|
| 36 |
+
and not os.getenv("PUSHOVER_USER", "").startswith("your-")
|
| 37 |
+
)
|
_hf_capstone_repo/log_utils.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Foreground colors
|
| 2 |
+
RED = '\033[31m'
|
| 3 |
+
GREEN = '\033[32m'
|
| 4 |
+
YELLOW = '\033[33m'
|
| 5 |
+
BLUE = '\033[34m'
|
| 6 |
+
MAGENTA = '\033[35m'
|
| 7 |
+
CYAN = '\033[36m'
|
| 8 |
+
WHITE = '\033[37m'
|
| 9 |
+
|
| 10 |
+
# Background color
|
| 11 |
+
BG_BLACK = '\033[40m'
|
| 12 |
+
BG_BLUE = '\033[44m'
|
| 13 |
+
|
| 14 |
+
# Reset code to return to default color
|
| 15 |
+
RESET = '\033[0m'
|
| 16 |
+
|
| 17 |
+
mapper = {
|
| 18 |
+
BG_BLACK+RED: "#dd0000",
|
| 19 |
+
BG_BLACK+GREEN: "#00dd00",
|
| 20 |
+
BG_BLACK+YELLOW: "#dddd00",
|
| 21 |
+
BG_BLACK+BLUE: "#0000ee",
|
| 22 |
+
BG_BLACK+MAGENTA: "#aa00dd",
|
| 23 |
+
BG_BLACK+CYAN: "#00dddd",
|
| 24 |
+
BG_BLACK+WHITE: "#87CEEB",
|
| 25 |
+
BG_BLUE+WHITE: "#ff7800"
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def reformat(message):
|
| 30 |
+
for key, value in mapper.items():
|
| 31 |
+
message = message.replace(key, f'<span style="color: {value}">')
|
| 32 |
+
message = message.replace(RESET, '</span>')
|
| 33 |
+
return message
|
| 34 |
+
|
| 35 |
+
|
_hf_capstone_repo/memory.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"deal": {
|
| 4 |
+
"product_description": "The Samsung Galaxy Watch Ultra is a premium 47mm LTE Titanium smartwatch designed for both style and functionality. It features a circular display made with durable materials suitable for outdoor activities, providing GPS tracking, health monitoring, and custom apps for various needs. The robust design integrates a range of smart features including notifications, music control, and heart rate tracking, making it an ideal companion for fitness enthusiasts and tech-savvy users alike.",
|
| 5 |
+
"price": 350.0,
|
| 6 |
+
"url": "https://www.dealnews.com/Samsung-Galaxy-Watch-Ultra-47-mm-LTE-Titanium-Smartwatch-up-to-350-off-w-Trade-in-free-shipping/21663266.html?iref=rss-c142"
|
| 7 |
+
},
|
| 8 |
+
"estimate": 773.8138460593241,
|
| 9 |
+
"discount": 423.8138460593241
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"deal": {
|
| 13 |
+
"product_description": "The Refurbished Unlocked Apple iPhone 14 Pro Max offers an impressive 256GB storage and a huge display, perfect for both media consumption and productivity. Enjoy advanced camera technology for stunning photos. This model is designed to provide a seamless user experience with 5G capabilities for faster downloads and streaming. Refurbished to high standards, it comes in various colors and can support all the latest apps from the App Store, accommodating any Apple enthusiast's needs.",
|
| 14 |
+
"price": 705.0,
|
| 15 |
+
"url": "https://www.dealnews.com/products/Apple/Unlocked-Apple-iPhone-14-Pro-Max-256-GB-Smartphone/462808.html?iref=rss-c142"
|
| 16 |
+
},
|
| 17 |
+
"estimate": 930.8824204895075,
|
| 18 |
+
"discount": 225.88242048950747
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"deal": {
|
| 22 |
+
"product_description": "This Vizio VQM55C-10 55 inch 4K QLED UHD Smart TV features 4K resolution with HDR support, Apple AirPlay 2, Google Cast, and a voice remote.",
|
| 23 |
+
"price": 248.0,
|
| 24 |
+
"url": "https://www.dealnews.com/Vizio-VQM55-C-10-55-4-K-QLED-UHD-Smart-TV-for-248-free-shipping/21998056.html?iref=rss-c142"
|
| 25 |
+
},
|
| 26 |
+
"estimate": 592.7400194091798,
|
| 27 |
+
"discount": 344.7400194091798
|
| 28 |
+
}
|
| 29 |
+
]
|
_hf_capstone_repo/price_is_right.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import queue
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
from deal_agent_framework import DealAgentFramework
|
| 12 |
+
from log_utils import reformat
|
| 13 |
+
|
| 14 |
+
load_dotenv(override=True)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class QueueHandler(logging.Handler):
|
| 18 |
+
def __init__(self, log_queue):
|
| 19 |
+
super().__init__()
|
| 20 |
+
self.log_queue = log_queue
|
| 21 |
+
|
| 22 |
+
def emit(self, record):
|
| 23 |
+
self.log_queue.put(self.format(record))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def html_for(log_data):
|
| 27 |
+
output = "<br>".join(log_data[-18:])
|
| 28 |
+
return f"""
|
| 29 |
+
<div id="scrollContent" style="height: 400px; overflow-y: auto; border: 1px solid #ccc; background-color: #222229; padding: 10px;">
|
| 30 |
+
{output}
|
| 31 |
+
</div>
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def setup_logging(log_queue):
|
| 36 |
+
handler = QueueHandler(log_queue)
|
| 37 |
+
formatter = logging.Formatter(
|
| 38 |
+
"[%(asctime)s] %(message)s",
|
| 39 |
+
datefmt="%Y-%m-%d %H:%M:%S %z",
|
| 40 |
+
)
|
| 41 |
+
handler.setFormatter(formatter)
|
| 42 |
+
logger = logging.getLogger()
|
| 43 |
+
logger.addHandler(handler)
|
| 44 |
+
logger.setLevel(logging.INFO)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class App:
|
| 48 |
+
def __init__(self):
|
| 49 |
+
self.agent_framework = None
|
| 50 |
+
|
| 51 |
+
def get_agent_framework(self):
|
| 52 |
+
if not self.agent_framework:
|
| 53 |
+
self.agent_framework = DealAgentFramework()
|
| 54 |
+
return self.agent_framework
|
| 55 |
+
|
| 56 |
+
def run(self):
|
| 57 |
+
with gr.Blocks(title="The Price is Right", fill_width=True) as ui:
|
| 58 |
+
log_data = gr.State([])
|
| 59 |
+
|
| 60 |
+
def table_for(opps):
|
| 61 |
+
return [
|
| 62 |
+
[
|
| 63 |
+
opp.deal.product_description,
|
| 64 |
+
f"${opp.deal.price:.2f}",
|
| 65 |
+
f"${opp.estimate:.2f}",
|
| 66 |
+
f"${opp.discount:.2f}",
|
| 67 |
+
opp.deal.url,
|
| 68 |
+
]
|
| 69 |
+
for opp in opps
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
def update_output(log_data, log_queue, result_queue):
|
| 73 |
+
initial_result = table_for(self.get_agent_framework().memory)
|
| 74 |
+
final_result = None
|
| 75 |
+
while True:
|
| 76 |
+
try:
|
| 77 |
+
message = log_queue.get_nowait()
|
| 78 |
+
log_data.append(reformat(message))
|
| 79 |
+
yield log_data, html_for(log_data), final_result or initial_result
|
| 80 |
+
except queue.Empty:
|
| 81 |
+
try:
|
| 82 |
+
final_result = result_queue.get_nowait()
|
| 83 |
+
yield log_data, html_for(log_data), final_result or initial_result
|
| 84 |
+
except queue.Empty:
|
| 85 |
+
if final_result is not None:
|
| 86 |
+
break
|
| 87 |
+
time.sleep(0.1)
|
| 88 |
+
|
| 89 |
+
def get_initial_plot():
|
| 90 |
+
fig = go.Figure()
|
| 91 |
+
fig.update_layout(
|
| 92 |
+
title="Loading vector DB...",
|
| 93 |
+
height=400,
|
| 94 |
+
)
|
| 95 |
+
return fig
|
| 96 |
+
|
| 97 |
+
def get_plot():
|
| 98 |
+
documents, vectors, colors = DealAgentFramework.get_plot_data(max_datapoints=800)
|
| 99 |
+
# Create the 3D scatter plot
|
| 100 |
+
fig = go.Figure(
|
| 101 |
+
data=[
|
| 102 |
+
go.Scatter3d(
|
| 103 |
+
x=vectors[:, 0],
|
| 104 |
+
y=vectors[:, 1],
|
| 105 |
+
z=vectors[:, 2],
|
| 106 |
+
mode="markers",
|
| 107 |
+
marker=dict(size=2, color=colors, opacity=0.7),
|
| 108 |
+
)
|
| 109 |
+
]
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
fig.update_layout(
|
| 113 |
+
scene=dict(
|
| 114 |
+
xaxis_title="x",
|
| 115 |
+
yaxis_title="y",
|
| 116 |
+
zaxis_title="z",
|
| 117 |
+
aspectmode="manual",
|
| 118 |
+
aspectratio=dict(x=2.2, y=2.2, z=1), # Make x-axis twice as long
|
| 119 |
+
camera=dict(
|
| 120 |
+
eye=dict(x=1.6, y=1.6, z=0.8) # Adjust camera position
|
| 121 |
+
),
|
| 122 |
+
),
|
| 123 |
+
height=400,
|
| 124 |
+
margin=dict(r=5, b=1, l=5, t=2),
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return fig
|
| 128 |
+
|
| 129 |
+
def do_run():
|
| 130 |
+
new_opportunities = self.get_agent_framework().run()
|
| 131 |
+
table = table_for(new_opportunities)
|
| 132 |
+
return table
|
| 133 |
+
|
| 134 |
+
def run_with_logging(initial_log_data):
|
| 135 |
+
log_queue = queue.Queue()
|
| 136 |
+
result_queue = queue.Queue()
|
| 137 |
+
setup_logging(log_queue)
|
| 138 |
+
|
| 139 |
+
def worker():
|
| 140 |
+
result = do_run()
|
| 141 |
+
result_queue.put(result)
|
| 142 |
+
|
| 143 |
+
thread = threading.Thread(target=worker)
|
| 144 |
+
thread.start()
|
| 145 |
+
|
| 146 |
+
for log_data, output, final_result in update_output(
|
| 147 |
+
initial_log_data, log_queue, result_queue
|
| 148 |
+
):
|
| 149 |
+
yield log_data, output, final_result
|
| 150 |
+
|
| 151 |
+
def do_select(selected_index: gr.SelectData):
|
| 152 |
+
opportunities = self.get_agent_framework().memory
|
| 153 |
+
row = selected_index.index[0]
|
| 154 |
+
opportunity = opportunities[row]
|
| 155 |
+
self.get_agent_framework().planner.messenger.alert(opportunity)
|
| 156 |
+
|
| 157 |
+
with gr.Row():
|
| 158 |
+
gr.Markdown(
|
| 159 |
+
'<div style="text-align: center;font-size:24px"><strong>The Price is Right</strong> - Autonomous Agent Framework that hunts for deals</div>'
|
| 160 |
+
)
|
| 161 |
+
with gr.Row():
|
| 162 |
+
gr.Markdown(
|
| 163 |
+
'<div style="text-align: center;font-size:14px">Multi-agent deal hunter: Groq LLM + RAG + optional Modal fine-tuned pricer. Finds online deals and estimates true product value.</div>'
|
| 164 |
+
)
|
| 165 |
+
with gr.Row():
|
| 166 |
+
opportunities_dataframe = gr.Dataframe(
|
| 167 |
+
headers=["Deals found so far", "Price", "Estimate", "Discount", "URL"],
|
| 168 |
+
wrap=True,
|
| 169 |
+
column_widths=[6, 1, 1, 1, 3],
|
| 170 |
+
row_count=10,
|
| 171 |
+
col_count=5,
|
| 172 |
+
max_height=400,
|
| 173 |
+
)
|
| 174 |
+
with gr.Row():
|
| 175 |
+
with gr.Column(scale=1):
|
| 176 |
+
logs = gr.HTML()
|
| 177 |
+
with gr.Column(scale=1):
|
| 178 |
+
plot = gr.Plot(value=get_plot(), show_label=False)
|
| 179 |
+
|
| 180 |
+
ui.load(
|
| 181 |
+
run_with_logging,
|
| 182 |
+
inputs=[log_data],
|
| 183 |
+
outputs=[log_data, logs, opportunities_dataframe],
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
timer = gr.Timer(value=300, active=True)
|
| 187 |
+
timer.tick(
|
| 188 |
+
run_with_logging,
|
| 189 |
+
inputs=[log_data],
|
| 190 |
+
outputs=[log_data, logs, opportunities_dataframe],
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
opportunities_dataframe.select(do_select)
|
| 194 |
+
|
| 195 |
+
share = os.getenv("GRADIO_SHARE", "false").lower() in ("true", "1", "yes")
|
| 196 |
+
server_name = os.getenv("GRADIO_SERVER_NAME", "127.0.0.1")
|
| 197 |
+
server_port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
|
| 198 |
+
inbrowser = server_name in ("127.0.0.1", "localhost") and not share
|
| 199 |
+
ui.launch(
|
| 200 |
+
share=share,
|
| 201 |
+
server_name=server_name,
|
| 202 |
+
server_port=server_port,
|
| 203 |
+
inbrowser=inbrowser,
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
App().run()
|
_hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/data_level0.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c03e1e12c5de42db678c5308d0f09ad904f04c7dd8ee5be04be405dfc8d46c42
|
| 3 |
+
size 33520000
|
_hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/header.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4a8615225934162b4e8f0cae7ce25caa91aa27f6f3c15d95e448ce5434c8c406
|
| 3 |
+
size 100
|
_hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/index_metadata.pickle
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a4cac2085f7a9d810683f02dc31dc76daaf7b5b6658e6e322863829d174c7f84
|
| 3 |
+
size 737988
|
_hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/length.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:881cb357be5036b37fd074699068c05e500cf3f2e934057c3214059a8655f886
|
| 3 |
+
size 80000
|
_hf_capstone_repo/products_vectorstore/c8498423-ab24-462f-a1fc-d9ef5751968d/link_lists.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a5736f0e6ca098dc3cf00ae4ec55862a15c1a8dd03a4fb751506dc5b6419cbee
|
| 3 |
+
size 171800
|
_hf_capstone_repo/products_vectorstore/chroma.sqlite3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1b3cedf0c5c60f537a29524c6d05669bd74d343dd18bb95f5e247567d94b2f2a
|
| 3 |
+
size 59838464
|
_hf_capstone_repo/requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.47.2,<6.0
|
| 2 |
+
chromadb>=1.1.0
|
| 3 |
+
sentence-transformers>=5.1.1
|
| 4 |
+
groq>=0.33.0
|
| 5 |
+
litellm>=1.77.5
|
| 6 |
+
python-dotenv>=1.1.1
|
| 7 |
+
pydantic>=2.0
|
| 8 |
+
beautifulsoup4>=4.14.2
|
| 9 |
+
feedparser>=6.0.12
|
| 10 |
+
requests>=2.32.5
|
| 11 |
+
numpy>=2.3.3
|
| 12 |
+
scikit-learn>=1.7.2
|
| 13 |
+
plotly>=6.3.0
|
| 14 |
+
pandas>=2.3.3
|
| 15 |
+
torch>=2.8.0
|
| 16 |
+
modal>=1.1.4
|
| 17 |
+
tqdm>=4.67.1
|
_hf_deploy/.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
products_vectorstore/** filter=lfs diff=lfs merge=lfs -text
|
_hf_deploy/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Capstone - The Price Is Right
|
| 3 |
+
emoji: 💰
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "5.47.2"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Capstone — The Price Is Right
|
| 14 |
+
|
| 15 |
+
Multi-agent deal hunting AI for the LLM Engineering capstone project.
|
| 16 |
+
|
| 17 |
+
- Scans DealNews RSS feeds for deals
|
| 18 |
+
- Estimates true product prices with RAG + Groq + neural network
|
| 19 |
+
- Surfaces bargains in a live Gradio dashboard
|
| 20 |
+
|
| 21 |
+
**Secrets required:** `GROQ_API_KEY`, `HF_TOKEN`, `USE_MODAL_SPECIALIST=false`, `GRADIO_SERVER_NAME=0.0.0.0`
|
_hf_deploy/agents/agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
class Agent:
|
| 4 |
+
"""
|
| 5 |
+
An abstract superclass for Agents
|
| 6 |
+
Used to log messages in a way that can identify each Agent
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# Foreground colors
|
| 10 |
+
RED = '\033[31m'
|
| 11 |
+
GREEN = '\033[32m'
|
| 12 |
+
YELLOW = '\033[33m'
|
| 13 |
+
BLUE = '\033[34m'
|
| 14 |
+
MAGENTA = '\033[35m'
|
| 15 |
+
CYAN = '\033[36m'
|
| 16 |
+
WHITE = '\033[37m'
|
| 17 |
+
|
| 18 |
+
# Background color
|
| 19 |
+
BG_BLACK = '\033[40m'
|
| 20 |
+
|
| 21 |
+
# Reset code to return to default color
|
| 22 |
+
RESET = '\033[0m'
|
| 23 |
+
|
| 24 |
+
name: str = ""
|
| 25 |
+
color: str = '\033[37m'
|
| 26 |
+
|
| 27 |
+
def log(self, message):
|
| 28 |
+
"""
|
| 29 |
+
Log this as an info message, identifying the agent
|
| 30 |
+
"""
|
| 31 |
+
color_code = self.BG_BLACK + self.color
|
| 32 |
+
message = f"[{self.name}] {message}"
|
| 33 |
+
logging.info(color_code + message + self.RESET)
|
_hf_deploy/agents/autonomous_planning_agent.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List, Dict
|
| 2 |
+
from agents.agent import Agent
|
| 3 |
+
from agents.deals import Deal, Opportunity
|
| 4 |
+
from agents.scanner_agent import ScannerAgent
|
| 5 |
+
from agents.ensemble_agent import EnsembleAgent
|
| 6 |
+
from agents.messaging_agent import MessagingAgent
|
| 7 |
+
from openai import OpenAI
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AutonomousPlanningAgent(Agent):
|
| 12 |
+
name = "Autonomous Planning Agent"
|
| 13 |
+
color = Agent.GREEN
|
| 14 |
+
MODEL = "gpt-5.1"
|
| 15 |
+
|
| 16 |
+
def __init__(self, collection):
|
| 17 |
+
"""
|
| 18 |
+
Create instances of the 3 Agents that this planner coordinates across
|
| 19 |
+
"""
|
| 20 |
+
self.log("Autonomous Planning Agent is initializing")
|
| 21 |
+
self.scanner = ScannerAgent()
|
| 22 |
+
self.ensemble = EnsembleAgent(collection)
|
| 23 |
+
self.messenger = MessagingAgent()
|
| 24 |
+
self.openai = OpenAI()
|
| 25 |
+
self.memory = None
|
| 26 |
+
self.opportunity = None
|
| 27 |
+
self.log("Autonomous Planning Agent is ready")
|
| 28 |
+
|
| 29 |
+
def scan_the_internet_for_bargains(self) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Run the tool to scan
|
| 32 |
+
"""
|
| 33 |
+
self.log("Autonomous Planning agent is calling scanner")
|
| 34 |
+
results = self.scanner.scan(memory=self.memory)
|
| 35 |
+
return results.model_dump_json() if results else "No deals found"
|
| 36 |
+
|
| 37 |
+
def estimate_true_value(self, description: str) -> str:
|
| 38 |
+
"""
|
| 39 |
+
Run the tool to estimate true value
|
| 40 |
+
"""
|
| 41 |
+
self.log("Autonomous Planning agent is estimating value via Ensemble Agent")
|
| 42 |
+
estimate = self.ensemble.price(description)
|
| 43 |
+
return f"The estimated true value of {description} is {estimate}"
|
| 44 |
+
|
| 45 |
+
def notify_user_of_deal(
|
| 46 |
+
self, description: str, deal_price: float, estimated_true_value: float, url: str
|
| 47 |
+
) -> Dict:
|
| 48 |
+
"""
|
| 49 |
+
Run the tool to notify the user
|
| 50 |
+
"""
|
| 51 |
+
if self.opportunity:
|
| 52 |
+
self.log("Autonomous Planning agent is trying to notify the user a 2nd time; ignoring")
|
| 53 |
+
else:
|
| 54 |
+
self.log("Autonomous Planning agent is notifying user")
|
| 55 |
+
self.messenger.notify(description, deal_price, estimated_true_value, url)
|
| 56 |
+
deal = Deal(product_description=description, price=deal_price, url=url)
|
| 57 |
+
discount = estimated_true_value - deal_price
|
| 58 |
+
self.opportunity = Opportunity(
|
| 59 |
+
deal=deal, estimate=estimated_true_value, discount=discount
|
| 60 |
+
)
|
| 61 |
+
return "Notification sent ok"
|
| 62 |
+
|
| 63 |
+
scan_function = {
|
| 64 |
+
"name": "scan_the_internet_for_bargains",
|
| 65 |
+
"description": "Returns top bargains scraped from the internet along with the price each item is being offered for",
|
| 66 |
+
"parameters": {
|
| 67 |
+
"type": "object",
|
| 68 |
+
"properties": {},
|
| 69 |
+
"required": [],
|
| 70 |
+
"additionalProperties": False,
|
| 71 |
+
},
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
estimate_function = {
|
| 75 |
+
"name": "estimate_true_value",
|
| 76 |
+
"description": "Given the description of an item, estimate how much it is actually worth",
|
| 77 |
+
"parameters": {
|
| 78 |
+
"type": "object",
|
| 79 |
+
"properties": {
|
| 80 |
+
"description": {
|
| 81 |
+
"type": "string",
|
| 82 |
+
"description": "The description of the item to be estimated",
|
| 83 |
+
},
|
| 84 |
+
},
|
| 85 |
+
"required": ["description"],
|
| 86 |
+
"additionalProperties": False,
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
notify_function = {
|
| 91 |
+
"name": "notify_user_of_deal",
|
| 92 |
+
"description": "Send the user a push notification about the single most compelling deal; only call this one time",
|
| 93 |
+
"parameters": {
|
| 94 |
+
"type": "object",
|
| 95 |
+
"properties": {
|
| 96 |
+
"description": {
|
| 97 |
+
"type": "string",
|
| 98 |
+
"description": "The description of the item itself scraped from the internet",
|
| 99 |
+
},
|
| 100 |
+
"deal_price": {
|
| 101 |
+
"type": "number",
|
| 102 |
+
"description": "The price offered by this deal scraped from the internet",
|
| 103 |
+
},
|
| 104 |
+
"estimated_true_value": {
|
| 105 |
+
"type": "number",
|
| 106 |
+
"description": "The estimated actual value that this is worth",
|
| 107 |
+
},
|
| 108 |
+
"url": {
|
| 109 |
+
"type": "string",
|
| 110 |
+
"description": "The URL of this deal as scraped from the internet",
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
"required": ["description", "deal_price", "estimated_true_value", "url"],
|
| 114 |
+
"additionalProperties": False,
|
| 115 |
+
},
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def get_tools(self):
|
| 119 |
+
"""
|
| 120 |
+
Return the json for the tools to be used
|
| 121 |
+
"""
|
| 122 |
+
return [
|
| 123 |
+
{"type": "function", "function": self.scan_function},
|
| 124 |
+
{"type": "function", "function": self.estimate_function},
|
| 125 |
+
{"type": "function", "function": self.notify_function},
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
def handle_tool_call(self, message):
|
| 129 |
+
"""
|
| 130 |
+
Actually call the tools associated with this message
|
| 131 |
+
"""
|
| 132 |
+
mapping = {
|
| 133 |
+
"scan_the_internet_for_bargains": self.scan_the_internet_for_bargains,
|
| 134 |
+
"estimate_true_value": self.estimate_true_value,
|
| 135 |
+
"notify_user_of_deal": self.notify_user_of_deal,
|
| 136 |
+
}
|
| 137 |
+
results = []
|
| 138 |
+
for tool_call in message.tool_calls:
|
| 139 |
+
tool_name = tool_call.function.name
|
| 140 |
+
arguments = json.loads(tool_call.function.arguments)
|
| 141 |
+
tool = mapping.get(tool_name)
|
| 142 |
+
result = tool(**arguments) if tool else ""
|
| 143 |
+
results.append({"role": "tool", "content": result, "tool_call_id": tool_call.id})
|
| 144 |
+
return results
|
| 145 |
+
|
| 146 |
+
system_message = "You find great deals on bargain products using your tools, and notify the user of the best bargain."
|
| 147 |
+
user_message = """
|
| 148 |
+
First, use your tool to scan the internet for bargain deals. Then for each deal, use your tool to estimate its true value.
|
| 149 |
+
Then pick the single most compelling deal where the price is much lower than the estimated true value, and use your tool to notify the user.
|
| 150 |
+
Then just reply OK to indicate success.
|
| 151 |
+
"""
|
| 152 |
+
messages = [
|
| 153 |
+
{"role": "system", "content": system_message},
|
| 154 |
+
{"role": "user", "content": user_message},
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
def plan(self, memory: List[str] = []) -> Optional[Opportunity]:
|
| 158 |
+
"""
|
| 159 |
+
Run the full workflow, providing the LLM with tools to surface scraped deals to the user
|
| 160 |
+
:param memory: a list of URLs that have been surfaced in the past
|
| 161 |
+
:return: an Opportunity if one was surfaced, otherwise None
|
| 162 |
+
"""
|
| 163 |
+
self.log("Autonomous Planning Agent is kicking off a run")
|
| 164 |
+
self.memory = memory
|
| 165 |
+
self.opportunity = None
|
| 166 |
+
messages = self.messages[:]
|
| 167 |
+
done = False
|
| 168 |
+
while not done:
|
| 169 |
+
response = self.openai.chat.completions.create(
|
| 170 |
+
model=self.MODEL, messages=messages, tools=self.get_tools()
|
| 171 |
+
)
|
| 172 |
+
if response.choices[0].finish_reason == "tool_calls":
|
| 173 |
+
message = response.choices[0].message
|
| 174 |
+
results = self.handle_tool_call(message)
|
| 175 |
+
messages.append(message)
|
| 176 |
+
messages.extend(results)
|
| 177 |
+
else:
|
| 178 |
+
done = True
|
| 179 |
+
reply = response.choices[0].message.content
|
| 180 |
+
self.log(f"Autonomous Planning Agent completed with: {reply}")
|
| 181 |
+
return self.opportunity
|
_hf_deploy/agents/deals.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Dict, Self
|
| 3 |
+
from bs4 import BeautifulSoup
|
| 4 |
+
import re
|
| 5 |
+
import feedparser
|
| 6 |
+
from tqdm import tqdm
|
| 7 |
+
import requests
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
feeds = [
|
| 11 |
+
"https://www.dealnews.com/c142/Electronics/?rss=1",
|
| 12 |
+
"https://www.dealnews.com/c39/Computers/?rss=1",
|
| 13 |
+
"https://www.dealnews.com/f1912/Smart-Home/?rss=1",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
# You could also add: "https://www.dealnews.com/c238/Automotive/?rss=1"
|
| 17 |
+
# "https://www.dealnews.com/c196/Home-Garden/?rss=1"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def extract(html_snippet: str) -> str:
|
| 21 |
+
"""
|
| 22 |
+
Use Beautiful Soup to clean up this HTML snippet and extract useful text
|
| 23 |
+
"""
|
| 24 |
+
soup = BeautifulSoup(html_snippet, "html.parser")
|
| 25 |
+
snippet_div = soup.find("div", class_="snippet summary")
|
| 26 |
+
|
| 27 |
+
if snippet_div:
|
| 28 |
+
description = snippet_div.get_text(strip=True)
|
| 29 |
+
description = BeautifulSoup(description, "html.parser").get_text()
|
| 30 |
+
description = re.sub("<[^<]+?>", "", description)
|
| 31 |
+
result = description.strip()
|
| 32 |
+
else:
|
| 33 |
+
result = html_snippet
|
| 34 |
+
return result.replace("\n", " ")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ScrapedDeal:
|
| 38 |
+
"""
|
| 39 |
+
A class to represent a Deal retrieved from an RSS feed
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
category: str
|
| 43 |
+
title: str
|
| 44 |
+
summary: str
|
| 45 |
+
url: str
|
| 46 |
+
details: str
|
| 47 |
+
features: str
|
| 48 |
+
|
| 49 |
+
def __init__(self, entry: Dict[str, str]):
|
| 50 |
+
"""
|
| 51 |
+
Populate this instance based on the provided dict
|
| 52 |
+
"""
|
| 53 |
+
self.title = entry["title"]
|
| 54 |
+
self.summary = extract(entry["summary"])
|
| 55 |
+
self.url = entry["links"][0]["href"]
|
| 56 |
+
stuff = requests.get(self.url).content
|
| 57 |
+
soup = BeautifulSoup(stuff, "html.parser")
|
| 58 |
+
content = soup.find("div", class_="content-section").get_text()
|
| 59 |
+
content = content.replace("\nmore", "").replace("\n", " ")
|
| 60 |
+
if "Features" in content:
|
| 61 |
+
self.details, self.features = content.split("Features", 1)
|
| 62 |
+
else:
|
| 63 |
+
self.details = content
|
| 64 |
+
self.features = ""
|
| 65 |
+
self.truncate()
|
| 66 |
+
|
| 67 |
+
def truncate(self):
|
| 68 |
+
"""
|
| 69 |
+
Limit the fields to a sensible length to avoid sending too much info to the model
|
| 70 |
+
"""
|
| 71 |
+
self.title = self.title[:100]
|
| 72 |
+
self.details = self.details[:500]
|
| 73 |
+
self.features = self.features[:500]
|
| 74 |
+
|
| 75 |
+
def __repr__(self):
|
| 76 |
+
"""
|
| 77 |
+
Return a string to describe this deal
|
| 78 |
+
"""
|
| 79 |
+
return f"<{self.title}>"
|
| 80 |
+
|
| 81 |
+
def describe(self):
|
| 82 |
+
"""
|
| 83 |
+
Return a longer string to describe this deal for use in calling a model
|
| 84 |
+
"""
|
| 85 |
+
return f"Title: {self.title}\nDetails: {self.details.strip()}\nFeatures: {self.features.strip()}\nURL: {self.url}"
|
| 86 |
+
|
| 87 |
+
@classmethod
|
| 88 |
+
def fetch(cls, show_progress: bool = False) -> List[Self]:
|
| 89 |
+
"""
|
| 90 |
+
Retrieve all deals from the selected RSS feeds
|
| 91 |
+
"""
|
| 92 |
+
deals = []
|
| 93 |
+
feed_iter = tqdm(feeds) if show_progress else feeds
|
| 94 |
+
for feed_url in feed_iter:
|
| 95 |
+
feed = feedparser.parse(feed_url)
|
| 96 |
+
for entry in feed.entries[:10]:
|
| 97 |
+
deals.append(cls(entry))
|
| 98 |
+
time.sleep(0.05)
|
| 99 |
+
return deals
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class Deal(BaseModel):
|
| 103 |
+
"""
|
| 104 |
+
A class to Represent a Deal with a summary description
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
product_description: str = Field(
|
| 108 |
+
description="Your clearly expressed summary of the product in 3-4 sentences. Details of the item are much more important than why it's a good deal. Avoid mentioning discounts and coupons; focus on the item itself. There should be a short paragraph of text for each item you choose."
|
| 109 |
+
)
|
| 110 |
+
price: float = Field(
|
| 111 |
+
description="The actual price of this product, as advertised in the deal. Be sure to give the actual price; for example, if a deal is described as $100 off the usual $300 price, you should respond with $200"
|
| 112 |
+
)
|
| 113 |
+
url: str = Field(description="The URL of the deal, as provided in the input")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class DealSelection(BaseModel):
|
| 117 |
+
"""
|
| 118 |
+
A class to Represent a list of Deals
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
deals: List[Deal] = Field(
|
| 122 |
+
description="Your selection of the 5 deals that have the most detailed, high quality description and the most clear price. You should be confident that the price reflects the deal, that it is a good deal, with a clear description"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class Opportunity(BaseModel):
|
| 127 |
+
"""
|
| 128 |
+
A class to represent a possible opportunity: a Deal where we estimate
|
| 129 |
+
it should cost more than it's being offered
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
deal: Deal
|
| 133 |
+
estimate: float
|
| 134 |
+
discount: float
|
_hf_deploy/agents/deep_neural_network.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from tqdm.notebook import tqdm
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.optim as optim
|
| 6 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 7 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR
|
| 8 |
+
from sklearn.feature_extraction.text import HashingVectorizer
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ResidualBlock(nn.Module):
|
| 13 |
+
def __init__(self, hidden_size, dropout_prob):
|
| 14 |
+
super(ResidualBlock, self).__init__()
|
| 15 |
+
self.block = nn.Sequential(
|
| 16 |
+
nn.Linear(hidden_size, hidden_size),
|
| 17 |
+
nn.LayerNorm(hidden_size),
|
| 18 |
+
nn.ReLU(),
|
| 19 |
+
nn.Dropout(dropout_prob),
|
| 20 |
+
nn.Linear(hidden_size, hidden_size),
|
| 21 |
+
nn.LayerNorm(hidden_size),
|
| 22 |
+
)
|
| 23 |
+
self.relu = nn.ReLU()
|
| 24 |
+
|
| 25 |
+
def forward(self, x):
|
| 26 |
+
residual = x
|
| 27 |
+
out = self.block(x)
|
| 28 |
+
out += residual # Skip connection
|
| 29 |
+
return self.relu(out)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class DeepNeuralNetwork(nn.Module):
|
| 33 |
+
def __init__(self, input_size, num_layers=10, hidden_size=4096, dropout_prob=0.2):
|
| 34 |
+
super(DeepNeuralNetwork, self).__init__()
|
| 35 |
+
|
| 36 |
+
# First layer
|
| 37 |
+
self.input_layer = nn.Sequential(
|
| 38 |
+
nn.Linear(input_size, hidden_size),
|
| 39 |
+
nn.LayerNorm(hidden_size),
|
| 40 |
+
nn.ReLU(),
|
| 41 |
+
nn.Dropout(dropout_prob),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Residual blocks
|
| 45 |
+
self.residual_blocks = nn.ModuleList()
|
| 46 |
+
for i in range(num_layers - 2):
|
| 47 |
+
self.residual_blocks.append(ResidualBlock(hidden_size, dropout_prob))
|
| 48 |
+
|
| 49 |
+
# Output layer
|
| 50 |
+
self.output_layer = nn.Linear(hidden_size, 1)
|
| 51 |
+
|
| 52 |
+
def forward(self, x):
|
| 53 |
+
x = self.input_layer(x)
|
| 54 |
+
|
| 55 |
+
for block in self.residual_blocks:
|
| 56 |
+
x = block(x)
|
| 57 |
+
|
| 58 |
+
return self.output_layer(x)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
Y_STD = 1.0328539609909058
|
| 62 |
+
Y_MEAN = 4.434937953948975
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class DeepNeuralNetworkInference:
|
| 66 |
+
def __init__(self):
|
| 67 |
+
self.vectorizer = None
|
| 68 |
+
self.model = None
|
| 69 |
+
self.device = None
|
| 70 |
+
|
| 71 |
+
np.random.seed(42)
|
| 72 |
+
torch.manual_seed(42)
|
| 73 |
+
torch.cuda.manual_seed(42)
|
| 74 |
+
|
| 75 |
+
def setup(self):
|
| 76 |
+
self.vectorizer = HashingVectorizer(n_features=5000, stop_words="english", binary=True)
|
| 77 |
+
self.model = DeepNeuralNetwork(5000)
|
| 78 |
+
if torch.cuda.is_available():
|
| 79 |
+
self.device = torch.device("cuda")
|
| 80 |
+
elif torch.backends.mps.is_available():
|
| 81 |
+
self.device = torch.device("mps")
|
| 82 |
+
else:
|
| 83 |
+
self.device = torch.device("cpu")
|
| 84 |
+
|
| 85 |
+
logging.info(f"Neural Network is using {self.device}")
|
| 86 |
+
|
| 87 |
+
self.model.to(self.device)
|
| 88 |
+
|
| 89 |
+
def load(self, path):
|
| 90 |
+
self.model.load_state_dict(torch.load(path, map_location=self.device))
|
| 91 |
+
self.model.to(self.device)
|
| 92 |
+
|
| 93 |
+
def inference(self, text):
|
| 94 |
+
self.model.eval()
|
| 95 |
+
with torch.no_grad():
|
| 96 |
+
vector = self.vectorizer.transform([text])
|
| 97 |
+
vector = torch.FloatTensor(vector.toarray()).to(self.device)
|
| 98 |
+
pred = self.model(vector)[0]
|
| 99 |
+
result = torch.exp(pred * Y_STD + Y_MEAN) - 1
|
| 100 |
+
result = result.item()
|
| 101 |
+
return max(0, result)
|
_hf_deploy/agents/ensemble_agent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.agent import Agent
|
| 2 |
+
from agents.specialist_agent import SpecialistAgent
|
| 3 |
+
from agents.frontier_agent import FrontierAgent
|
| 4 |
+
from agents.neural_network_agent import NeuralNetworkAgent
|
| 5 |
+
from agents.preprocessor import Preprocessor
|
| 6 |
+
from free_config import WEIGHTS_WITH_SPECIALIST, WEIGHTS_WITHOUT_SPECIALIST
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class EnsembleAgent(Agent):
|
| 10 |
+
name = "Ensemble Agent"
|
| 11 |
+
color = Agent.YELLOW
|
| 12 |
+
|
| 13 |
+
def __init__(self, collection):
|
| 14 |
+
"""
|
| 15 |
+
Create an instance of Ensemble, by creating each of the models
|
| 16 |
+
And loading the weights of the Ensemble
|
| 17 |
+
"""
|
| 18 |
+
self.log("Initializing Ensemble Agent")
|
| 19 |
+
self.specialist = SpecialistAgent()
|
| 20 |
+
self.frontier = FrontierAgent(collection)
|
| 21 |
+
self.neural_network = NeuralNetworkAgent()
|
| 22 |
+
self.preprocessor = Preprocessor()
|
| 23 |
+
self.use_specialist = self.specialist.available
|
| 24 |
+
if self.use_specialist:
|
| 25 |
+
mode = getattr(self.specialist, "mode", "on")
|
| 26 |
+
self.log(f"Ensemble Agent is ready (Frontier + Specialist [{mode}] + Neural Network)")
|
| 27 |
+
else:
|
| 28 |
+
self.log("Ensemble Agent is ready (Frontier + Neural Network only)")
|
| 29 |
+
|
| 30 |
+
def price(self, description: str) -> float:
|
| 31 |
+
"""
|
| 32 |
+
Run this ensemble model
|
| 33 |
+
Ask each of the models to price the product
|
| 34 |
+
Then return the weighted average price
|
| 35 |
+
"""
|
| 36 |
+
self.log("Running Ensemble Agent - preprocessing text")
|
| 37 |
+
rewrite = self.preprocessor.preprocess(description)
|
| 38 |
+
self.log(f"Pre-processed text using {self.preprocessor.model_name}")
|
| 39 |
+
frontier = self.frontier.price(rewrite)
|
| 40 |
+
neural_network = self.neural_network.price(rewrite)
|
| 41 |
+
|
| 42 |
+
if self.use_specialist:
|
| 43 |
+
specialist = self.specialist.price(rewrite)
|
| 44 |
+
wf, ws, wn = WEIGHTS_WITH_SPECIALIST
|
| 45 |
+
combined = frontier * wf + specialist * ws + neural_network * wn
|
| 46 |
+
else:
|
| 47 |
+
wf, wn = WEIGHTS_WITHOUT_SPECIALIST
|
| 48 |
+
combined = frontier * wf + neural_network * wn
|
| 49 |
+
|
| 50 |
+
self.log(f"Ensemble Agent complete - returning ${combined:.2f}")
|
| 51 |
+
return combined
|
_hf_deploy/agents/evaluator.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from sklearn.metrics import mean_squared_error, r2_score
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import plotly.express as px
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
+
from itertools import accumulate
|
| 7 |
+
import math
|
| 8 |
+
from tqdm.notebook import tqdm
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
|
| 11 |
+
GREEN = "\033[92m"
|
| 12 |
+
YELLOW = "\033[93m"
|
| 13 |
+
RED = "\033[91m"
|
| 14 |
+
RESET = "\033[0m"
|
| 15 |
+
COLOR_MAP = {"red": RED, "orange": YELLOW, "green": GREEN}
|
| 16 |
+
|
| 17 |
+
WORKERS = 5
|
| 18 |
+
DEFAULT_SIZE = 200
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Tester:
|
| 22 |
+
def __init__(self, predictor, data, title=None, size=DEFAULT_SIZE, workers=WORKERS):
|
| 23 |
+
self.predictor = predictor
|
| 24 |
+
self.data = data
|
| 25 |
+
self.title = title or self.make_title(predictor)
|
| 26 |
+
self.size = size
|
| 27 |
+
self.titles = []
|
| 28 |
+
self.guesses = []
|
| 29 |
+
self.truths = []
|
| 30 |
+
self.errors = []
|
| 31 |
+
self.colors = []
|
| 32 |
+
self.workers = workers
|
| 33 |
+
|
| 34 |
+
@staticmethod
|
| 35 |
+
def make_title(predictor) -> str:
|
| 36 |
+
return predictor.__name__.replace("__", ".").replace("_", " ").title().replace("Gpt", "GPT")
|
| 37 |
+
|
| 38 |
+
@staticmethod
|
| 39 |
+
def post_process(value):
|
| 40 |
+
if isinstance(value, str):
|
| 41 |
+
value = value.replace("$", "").replace(",", "")
|
| 42 |
+
match = re.search(r"[-+]?\d*\.\d+|\d+", value)
|
| 43 |
+
return float(match.group()) if match else 0
|
| 44 |
+
else:
|
| 45 |
+
return value
|
| 46 |
+
|
| 47 |
+
def color_for(self, error, truth):
|
| 48 |
+
if error < 40 or error / truth < 0.2:
|
| 49 |
+
return "green"
|
| 50 |
+
elif error < 80 or error / truth < 0.4:
|
| 51 |
+
return "orange"
|
| 52 |
+
else:
|
| 53 |
+
return "red"
|
| 54 |
+
|
| 55 |
+
def run_datapoint(self, i):
|
| 56 |
+
datapoint = self.data[i]
|
| 57 |
+
value = self.predictor(datapoint)
|
| 58 |
+
guess = self.post_process(value)
|
| 59 |
+
truth = datapoint.price
|
| 60 |
+
error = abs(guess - truth)
|
| 61 |
+
color = self.color_for(error, truth)
|
| 62 |
+
title = datapoint.title if len(datapoint.title) <= 40 else datapoint.title[:40] + "..."
|
| 63 |
+
return title, guess, truth, error, color
|
| 64 |
+
|
| 65 |
+
def chart(self, title):
|
| 66 |
+
df = pd.DataFrame(
|
| 67 |
+
{
|
| 68 |
+
"truth": self.truths,
|
| 69 |
+
"guess": self.guesses,
|
| 70 |
+
"title": self.titles,
|
| 71 |
+
"error": self.errors,
|
| 72 |
+
"color": self.colors,
|
| 73 |
+
}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Pre-format hover text
|
| 77 |
+
df["hover"] = [
|
| 78 |
+
f"{t}\nGuess=${g:,.2f} Actual=${y:,.2f}"
|
| 79 |
+
for t, g, y in zip(df["title"], df["guess"], df["truth"])
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
max_val = float(max(df["truth"].max(), df["guess"].max()))
|
| 83 |
+
|
| 84 |
+
fig = px.scatter(
|
| 85 |
+
df,
|
| 86 |
+
x="truth",
|
| 87 |
+
y="guess",
|
| 88 |
+
color="color",
|
| 89 |
+
color_discrete_map={"green": "green", "orange": "orange", "red": "red"},
|
| 90 |
+
title=title,
|
| 91 |
+
labels={"truth": "Actual Price", "guess": "Predicted Price"},
|
| 92 |
+
width=1000,
|
| 93 |
+
height=800,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Assign customdata per trace (one color/category = one trace)
|
| 97 |
+
for tr in fig.data:
|
| 98 |
+
mask = df["color"] == tr.name
|
| 99 |
+
tr.customdata = df.loc[mask, ["hover"]].to_numpy()
|
| 100 |
+
tr.hovertemplate = "%{customdata[0]}<extra></extra>"
|
| 101 |
+
tr.marker.update(size=6)
|
| 102 |
+
|
| 103 |
+
# Reference line y=x
|
| 104 |
+
fig.add_trace(
|
| 105 |
+
go.Scatter(
|
| 106 |
+
x=[0, max_val],
|
| 107 |
+
y=[0, max_val],
|
| 108 |
+
mode="lines",
|
| 109 |
+
line=dict(width=2, dash="dash", color="deepskyblue"),
|
| 110 |
+
name="y = x",
|
| 111 |
+
hoverinfo="skip",
|
| 112 |
+
showlegend=False,
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
fig.update_xaxes(range=[0, max_val])
|
| 117 |
+
fig.update_yaxes(range=[0, max_val])
|
| 118 |
+
fig.update_layout(showlegend=False)
|
| 119 |
+
fig.show()
|
| 120 |
+
|
| 121 |
+
def error_trend_chart(self):
|
| 122 |
+
n = len(self.errors)
|
| 123 |
+
|
| 124 |
+
# Running mean and std (pure Python)
|
| 125 |
+
running_sums = list(accumulate(self.errors))
|
| 126 |
+
x = list(range(1, n + 1))
|
| 127 |
+
running_means = [s / i for s, i in zip(running_sums, x)]
|
| 128 |
+
|
| 129 |
+
running_squares = list(accumulate(e * e for e in self.errors))
|
| 130 |
+
running_stds = [
|
| 131 |
+
math.sqrt((sq_sum / i) - (mean**2)) if i > 1 else 0
|
| 132 |
+
for i, sq_sum, mean in zip(x, running_squares, running_means)
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
# 95% confidence interval for mean
|
| 136 |
+
ci = [1.96 * (sd / math.sqrt(i)) if i > 1 else 0 for i, sd in zip(x, running_stds)]
|
| 137 |
+
upper = [m + c for m, c in zip(running_means, ci)]
|
| 138 |
+
lower = [m - c for m, c in zip(running_means, ci)]
|
| 139 |
+
|
| 140 |
+
# Plot
|
| 141 |
+
fig = go.Figure()
|
| 142 |
+
|
| 143 |
+
# Shaded confidence interval band
|
| 144 |
+
fig.add_trace(
|
| 145 |
+
go.Scatter(
|
| 146 |
+
x=x + x[::-1],
|
| 147 |
+
y=upper + lower[::-1],
|
| 148 |
+
fill="toself",
|
| 149 |
+
fillcolor="rgba(128,128,128,0.2)",
|
| 150 |
+
line=dict(color="rgba(255,255,255,0)"),
|
| 151 |
+
hoverinfo="skip",
|
| 152 |
+
showlegend=False,
|
| 153 |
+
name="95% CI",
|
| 154 |
+
)
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Main line with hover text showing CI
|
| 158 |
+
fig.add_trace(
|
| 159 |
+
go.Scatter(
|
| 160 |
+
x=x,
|
| 161 |
+
y=running_means,
|
| 162 |
+
mode="lines",
|
| 163 |
+
line=dict(width=3, color="firebrick"),
|
| 164 |
+
name="Cumulative Avg Error",
|
| 165 |
+
customdata=list(
|
| 166 |
+
zip(
|
| 167 |
+
ci,
|
| 168 |
+
)
|
| 169 |
+
),
|
| 170 |
+
hovertemplate=(
|
| 171 |
+
"n=%{x}<br>"
|
| 172 |
+
"Avg Error=$%{y:,.2f}<br>"
|
| 173 |
+
"±95% CI=$%{customdata[0]:,.2f}<extra></extra>"
|
| 174 |
+
),
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# Title with final stats
|
| 179 |
+
final_mean = running_means[-1]
|
| 180 |
+
final_ci = ci[-1]
|
| 181 |
+
title = f"{self.title} Error: ${final_mean:,.2f} ± ${final_ci:,.2f}"
|
| 182 |
+
|
| 183 |
+
fig.update_layout(
|
| 184 |
+
title=title,
|
| 185 |
+
xaxis_title="Number of Datapoints",
|
| 186 |
+
yaxis_title="Average Absolute Error ($)",
|
| 187 |
+
width=1000,
|
| 188 |
+
height=360,
|
| 189 |
+
template="plotly_white",
|
| 190 |
+
showlegend=False,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
fig.show()
|
| 194 |
+
|
| 195 |
+
def report(self):
|
| 196 |
+
average_error = sum(self.errors) / self.size
|
| 197 |
+
mse = mean_squared_error(self.truths, self.guesses)
|
| 198 |
+
r2 = r2_score(self.truths, self.guesses) * 100
|
| 199 |
+
title = f"{self.title} results<br><b>Error:</b> ${average_error:,.2f} <b>MSE:</b> {mse:,.0f} <b>r²:</b> {r2:.1f}%"
|
| 200 |
+
self.error_trend_chart()
|
| 201 |
+
self.chart(title)
|
| 202 |
+
|
| 203 |
+
def run(self):
|
| 204 |
+
with ThreadPoolExecutor(max_workers=self.workers) as ex:
|
| 205 |
+
for title, guess, truth, error, color in tqdm(
|
| 206 |
+
ex.map(self.run_datapoint, range(self.size)), total=self.size
|
| 207 |
+
):
|
| 208 |
+
self.titles.append(title)
|
| 209 |
+
self.guesses.append(guess)
|
| 210 |
+
self.truths.append(truth)
|
| 211 |
+
self.errors.append(error)
|
| 212 |
+
self.colors.append(color)
|
| 213 |
+
print(f"{COLOR_MAP[color]}${error:.0f} ", end="")
|
| 214 |
+
self.report()
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def evaluate(function, data, size=DEFAULT_SIZE, workers=WORKERS):
|
| 218 |
+
Tester(function, data, size=size, workers=workers).run()
|
_hf_deploy/agents/frontier_agent.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
|
| 5 |
+
from groq import Groq
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
|
| 8 |
+
from agents.agent import Agent
|
| 9 |
+
from free_config import GROQ_MODEL
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class FrontierAgent(Agent):
|
| 13 |
+
name = "Frontier Agent"
|
| 14 |
+
color = Agent.BLUE
|
| 15 |
+
|
| 16 |
+
def __init__(self, collection):
|
| 17 |
+
"""
|
| 18 |
+
Set up this instance by connecting to Groq, the Chroma datastore,
|
| 19 |
+
and the local sentence-transformer embedding model.
|
| 20 |
+
"""
|
| 21 |
+
self.log("Initializing Frontier Agent")
|
| 22 |
+
self.client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 23 |
+
self.model = GROQ_MODEL
|
| 24 |
+
self.log(f"Frontier Agent is setting up with Groq ({self.model})")
|
| 25 |
+
self.collection = collection
|
| 26 |
+
self.encoder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
|
| 27 |
+
self.log("Frontier Agent is ready")
|
| 28 |
+
|
| 29 |
+
def make_context(self, similars: List[str], prices: List[float]) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Create context that can be inserted into the prompt
|
| 32 |
+
"""
|
| 33 |
+
message = "To provide some context, here are some other items that might be similar to the item you need to estimate.\n\n"
|
| 34 |
+
for similar, price in zip(similars, prices):
|
| 35 |
+
message += f"Potentially related product:\n{similar}\nPrice is ${price:.2f}\n\n"
|
| 36 |
+
return message
|
| 37 |
+
|
| 38 |
+
def messages_for(
|
| 39 |
+
self, description: str, similars: List[str], prices: List[float]
|
| 40 |
+
) -> List[Dict[str, str]]:
|
| 41 |
+
"""
|
| 42 |
+
Create the message list for the Groq chat completion call
|
| 43 |
+
"""
|
| 44 |
+
message = f"Estimate the price of this product. Respond with the price only, no explanation.\n\n{description}\n\n"
|
| 45 |
+
message += self.make_context(similars, prices)
|
| 46 |
+
return [{"role": "user", "content": message}]
|
| 47 |
+
|
| 48 |
+
def find_similars(self, description: str):
|
| 49 |
+
"""
|
| 50 |
+
Return a list of items similar to the given one by looking in the Chroma datastore
|
| 51 |
+
"""
|
| 52 |
+
self.log(
|
| 53 |
+
"Frontier Agent is performing a RAG search of the Chroma datastore to find 5 similar products"
|
| 54 |
+
)
|
| 55 |
+
vector = self.encoder.encode([description])
|
| 56 |
+
results = self.collection.query(query_embeddings=vector.astype(float).tolist(), n_results=5)
|
| 57 |
+
documents = results["documents"][0][:]
|
| 58 |
+
prices = [m["price"] for m in results["metadatas"][0][:]]
|
| 59 |
+
self.log("Frontier Agent has found similar products")
|
| 60 |
+
return documents, prices
|
| 61 |
+
|
| 62 |
+
def get_price(self, s) -> float:
|
| 63 |
+
"""
|
| 64 |
+
A utility that plucks a floating point number out of a string
|
| 65 |
+
"""
|
| 66 |
+
s = s.replace("$", "").replace(",", "")
|
| 67 |
+
match = re.search(r"[-+]?\d*\.\d+|\d+", s)
|
| 68 |
+
return float(match.group()) if match else 0.0
|
| 69 |
+
|
| 70 |
+
def price(self, description: str) -> float:
|
| 71 |
+
"""
|
| 72 |
+
Call Groq to estimate the price using RAG context from similar products.
|
| 73 |
+
"""
|
| 74 |
+
documents, prices = self.find_similars(description)
|
| 75 |
+
self.log(
|
| 76 |
+
f"Frontier Agent is about to call Groq ({self.model}) with context including 5 similar products"
|
| 77 |
+
)
|
| 78 |
+
response = self.client.chat.completions.create(
|
| 79 |
+
model=self.model,
|
| 80 |
+
messages=self.messages_for(description, documents, prices),
|
| 81 |
+
temperature=0,
|
| 82 |
+
)
|
| 83 |
+
reply = response.choices[0].message.content
|
| 84 |
+
result = self.get_price(reply)
|
| 85 |
+
self.log(f"Frontier Agent completed - predicting ${result:.2f}")
|
| 86 |
+
return result
|
_hf_deploy/agents/items.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from datasets import Dataset, DatasetDict, load_dataset
|
| 3 |
+
from typing import Optional, Self
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
PREFIX = "Price is $"
|
| 7 |
+
QUESTION = "What does this cost to the nearest dollar?"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Item(BaseModel):
|
| 11 |
+
"""
|
| 12 |
+
An Item is a data-point of a Product with a Price
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
title: str
|
| 16 |
+
category: str
|
| 17 |
+
price: float
|
| 18 |
+
full: Optional[str] = None
|
| 19 |
+
weight: Optional[float] = None
|
| 20 |
+
summary: Optional[str] = None
|
| 21 |
+
prompt: Optional[str] = None
|
| 22 |
+
id: Optional[int] = None
|
| 23 |
+
|
| 24 |
+
def make_prompt(self, text: str):
|
| 25 |
+
self.prompt = f"{QUESTION}\n\n{text}\n\n{PREFIX}{round(self.price)}.00"
|
| 26 |
+
|
| 27 |
+
def test_prompt(self) -> str:
|
| 28 |
+
return self.prompt.split(PREFIX)[0] + PREFIX
|
| 29 |
+
|
| 30 |
+
def __repr__(self) -> str:
|
| 31 |
+
return f"<{self.title} = ${self.price}>"
|
| 32 |
+
|
| 33 |
+
@staticmethod
|
| 34 |
+
def push_to_hub(dataset_name: str, train: list[Self], val: list[Self], test: list[Self]):
|
| 35 |
+
"""Push Item lists to HuggingFace Hub"""
|
| 36 |
+
DatasetDict(
|
| 37 |
+
{
|
| 38 |
+
"train": Dataset.from_list([item.model_dump() for item in train]),
|
| 39 |
+
"validation": Dataset.from_list([item.model_dump() for item in val]),
|
| 40 |
+
"test": Dataset.from_list([item.model_dump() for item in test]),
|
| 41 |
+
}
|
| 42 |
+
).push_to_hub(dataset_name)
|
| 43 |
+
|
| 44 |
+
@classmethod
|
| 45 |
+
def from_hub(cls, dataset_name: str) -> tuple[list[Self], list[Self], list[Self]]:
|
| 46 |
+
"""Load from HuggingFace Hub and reconstruct Items"""
|
| 47 |
+
ds = load_dataset(dataset_name)
|
| 48 |
+
return (
|
| 49 |
+
[cls.model_validate(row) for row in ds["train"]],
|
| 50 |
+
[cls.model_validate(row) for row in ds["validation"]],
|
| 51 |
+
[cls.model_validate(row) for row in ds["test"]],
|
| 52 |
+
)
|
_hf_deploy/agents/messaging_agent.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
from litellm import completion
|
| 5 |
+
|
| 6 |
+
from agents.deals import Opportunity
|
| 7 |
+
from agents.agent import Agent
|
| 8 |
+
from free_config import MESSAGING_MODEL, PUSHOVER_CONFIGURED
|
| 9 |
+
|
| 10 |
+
pushover_url = "https://api.pushover.net/1/messages.json"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MessagingAgent(Agent):
|
| 14 |
+
name = "Messaging Agent"
|
| 15 |
+
color = Agent.WHITE
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
"""
|
| 19 |
+
Set up Pushover notifications (optional) and Groq for message crafting.
|
| 20 |
+
"""
|
| 21 |
+
self.log("Messaging Agent is initializing")
|
| 22 |
+
self.pushover_user = os.getenv("PUSHOVER_USER", "")
|
| 23 |
+
self.pushover_token = os.getenv("PUSHOVER_TOKEN", "")
|
| 24 |
+
self.model = MESSAGING_MODEL
|
| 25 |
+
if PUSHOVER_CONFIGURED:
|
| 26 |
+
self.log("Messaging Agent has Pushover configured")
|
| 27 |
+
else:
|
| 28 |
+
self.log("Messaging Agent: Pushover not configured — alerts will be logged only")
|
| 29 |
+
|
| 30 |
+
def push(self, text):
|
| 31 |
+
"""
|
| 32 |
+
Send a Push Notification using the Pushover API, or log if not configured.
|
| 33 |
+
"""
|
| 34 |
+
if not PUSHOVER_CONFIGURED:
|
| 35 |
+
self.log(f"Deal alert (Pushover skipped): {text[:120]}...")
|
| 36 |
+
return
|
| 37 |
+
self.log("Messaging Agent is sending a push notification")
|
| 38 |
+
payload = {
|
| 39 |
+
"user": self.pushover_user,
|
| 40 |
+
"token": self.pushover_token,
|
| 41 |
+
"message": text,
|
| 42 |
+
"sound": "cashregister",
|
| 43 |
+
}
|
| 44 |
+
requests.post(pushover_url, data=payload)
|
| 45 |
+
|
| 46 |
+
def alert(self, opportunity: Opportunity):
|
| 47 |
+
"""
|
| 48 |
+
Make an alert about the specified Opportunity
|
| 49 |
+
"""
|
| 50 |
+
text = f"Deal Alert! Price=${opportunity.deal.price:.2f}, "
|
| 51 |
+
text += f"Estimate=${opportunity.estimate:.2f}, "
|
| 52 |
+
text += f"Discount=${opportunity.discount:.2f} :"
|
| 53 |
+
text += opportunity.deal.product_description[:10] + "... "
|
| 54 |
+
text += opportunity.deal.url
|
| 55 |
+
self.push(text)
|
| 56 |
+
self.log("Messaging Agent has completed")
|
| 57 |
+
|
| 58 |
+
def craft_message(
|
| 59 |
+
self, description: str, deal_price: float, estimated_true_value: float
|
| 60 |
+
) -> str:
|
| 61 |
+
user_prompt = "Please summarize this great deal in 2-3 sentences to be sent as an exciting push notification alerting the user about this deal.\n"
|
| 62 |
+
user_prompt += f"Item Description: {description}\nOffered Price: {deal_price}\nEstimated true value: {estimated_true_value}"
|
| 63 |
+
user_prompt += "\n\nRespond only with the 2-3 sentence message which will be used to alert & excite the user about this deal"
|
| 64 |
+
response = completion(
|
| 65 |
+
model=self.model,
|
| 66 |
+
messages=[
|
| 67 |
+
{"role": "user", "content": user_prompt},
|
| 68 |
+
],
|
| 69 |
+
)
|
| 70 |
+
return response.choices[0].message.content
|
| 71 |
+
|
| 72 |
+
def notify(self, description: str, deal_price: float, estimated_true_value: float, url: str):
|
| 73 |
+
"""
|
| 74 |
+
Make an alert about the specified details
|
| 75 |
+
"""
|
| 76 |
+
self.log(f"Messaging Agent is using Groq ({self.model}) to craft the message")
|
| 77 |
+
text = self.craft_message(description, deal_price, estimated_true_value)
|
| 78 |
+
self.push(text[:200] + "... " + url)
|
| 79 |
+
self.log("Messaging Agent has completed")
|
_hf_deploy/agents/neural_network_agent.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.agent import Agent
|
| 2 |
+
from agents.deep_neural_network import DeepNeuralNetworkInference
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class NeuralNetworkAgent(Agent):
|
| 6 |
+
name = "Neural Network Agent"
|
| 7 |
+
color = Agent.MAGENTA
|
| 8 |
+
|
| 9 |
+
def __init__(self):
|
| 10 |
+
"""
|
| 11 |
+
Initialize this object by loading in the saved model weights
|
| 12 |
+
and the SentenceTransformer vector encoding model
|
| 13 |
+
"""
|
| 14 |
+
self.log("Neural Network Agent is initializing")
|
| 15 |
+
self.neural_network = DeepNeuralNetworkInference()
|
| 16 |
+
self.neural_network.setup()
|
| 17 |
+
self.neural_network.load("deep_neural_network.pth")
|
| 18 |
+
self.log("Neural Network Agent is ready and weights are loaded")
|
| 19 |
+
|
| 20 |
+
def price(self, description: str) -> float:
|
| 21 |
+
"""
|
| 22 |
+
Use the Deep Neural Network to estimate the price of the described item
|
| 23 |
+
:param description: the product to be estimated
|
| 24 |
+
:return: the price as a float
|
| 25 |
+
"""
|
| 26 |
+
self.log("Neural Network Agent is starting a prediction")
|
| 27 |
+
result = self.neural_network.inference(description)
|
| 28 |
+
self.log(f"Neural Network Agent completed - predicting ${result:.2f}")
|
| 29 |
+
return result
|
_hf_deploy/agents/planning_agent.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List
|
| 2 |
+
from agents.agent import Agent
|
| 3 |
+
from agents.deals import ScrapedDeal, DealSelection, Deal, Opportunity
|
| 4 |
+
from agents.scanner_agent import ScannerAgent
|
| 5 |
+
from agents.ensemble_agent import EnsembleAgent
|
| 6 |
+
from agents.messaging_agent import MessagingAgent
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class PlanningAgent(Agent):
|
| 10 |
+
|
| 11 |
+
name = "Planning Agent"
|
| 12 |
+
color = Agent.GREEN
|
| 13 |
+
DEAL_THRESHOLD = 50
|
| 14 |
+
|
| 15 |
+
def __init__(self, collection):
|
| 16 |
+
"""
|
| 17 |
+
Create instances of the 3 Agents that this planner coordinates across
|
| 18 |
+
"""
|
| 19 |
+
self.log("Planning Agent is initializing")
|
| 20 |
+
self.scanner = ScannerAgent()
|
| 21 |
+
self.ensemble = EnsembleAgent(collection)
|
| 22 |
+
self.messenger = MessagingAgent()
|
| 23 |
+
self.log("Planning Agent is ready")
|
| 24 |
+
|
| 25 |
+
def run(self, deal: Deal) -> Opportunity:
|
| 26 |
+
"""
|
| 27 |
+
Run the workflow for a particular deal
|
| 28 |
+
:param deal: the deal, summarized from an RSS scrape
|
| 29 |
+
:returns: an opportunity including the discount
|
| 30 |
+
"""
|
| 31 |
+
self.log("Planning Agent is pricing up a potential deal")
|
| 32 |
+
estimate = self.ensemble.price(deal.product_description)
|
| 33 |
+
discount = estimate - deal.price
|
| 34 |
+
self.log(f"Planning Agent has processed a deal with discount ${discount:.2f}")
|
| 35 |
+
return Opportunity(deal=deal, estimate=estimate, discount=discount)
|
| 36 |
+
|
| 37 |
+
def plan(self, memory: List[str] = []) -> Optional[Opportunity]:
|
| 38 |
+
"""
|
| 39 |
+
Run the full workflow:
|
| 40 |
+
1. Use the ScannerAgent to find deals from RSS feeds
|
| 41 |
+
2. Use the EnsembleAgent to estimate them
|
| 42 |
+
3. Use the MessagingAgent to send a notification of deals
|
| 43 |
+
:param memory: a list of URLs that have been surfaced in the past
|
| 44 |
+
:return: an Opportunity if one was surfaced, otherwise None
|
| 45 |
+
"""
|
| 46 |
+
self.log("Planning Agent is kicking off a run")
|
| 47 |
+
selection = self.scanner.scan(memory=memory)
|
| 48 |
+
if selection:
|
| 49 |
+
opportunities = [self.run(deal) for deal in selection.deals[:5]]
|
| 50 |
+
opportunities.sort(key=lambda opp: opp.discount, reverse=True)
|
| 51 |
+
best = opportunities[0]
|
| 52 |
+
self.log(f"Planning Agent has identified the best deal has discount ${best.discount:.2f}")
|
| 53 |
+
if best.discount > self.DEAL_THRESHOLD:
|
| 54 |
+
self.messenger.alert(best)
|
| 55 |
+
self.log("Planning Agent has completed a run")
|
| 56 |
+
return best if best.discount > self.DEAL_THRESHOLD else None
|
| 57 |
+
return None
|
_hf_deploy/agents/preprocessor.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from litellm import completion
|
| 5 |
+
|
| 6 |
+
from free_config import PREPROCESSOR_MODEL
|
| 7 |
+
|
| 8 |
+
load_dotenv(override=True)
|
| 9 |
+
|
| 10 |
+
DEFAULT_REASONING_EFFORT = "low" if "gpt-oss" in PREPROCESSOR_MODEL else None
|
| 11 |
+
|
| 12 |
+
SYSTEM_PROMPT = """Create a concise description of a product. Respond only in this format. Do not include part numbers.
|
| 13 |
+
Title: Rewritten short precise title
|
| 14 |
+
Category: eg Electronics
|
| 15 |
+
Brand: Brand name
|
| 16 |
+
Description: 1 sentence description
|
| 17 |
+
Details: 1 sentence on features"""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Preprocessor:
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
model_name=PREPROCESSOR_MODEL,
|
| 24 |
+
reasoning_effort=DEFAULT_REASONING_EFFORT,
|
| 25 |
+
base_url=None,
|
| 26 |
+
):
|
| 27 |
+
self.total_input_tokens = 0
|
| 28 |
+
self.total_output_tokens = 0
|
| 29 |
+
self.total_cost = 0
|
| 30 |
+
self.model_name = model_name
|
| 31 |
+
self.reasoning_effort = reasoning_effort
|
| 32 |
+
self.base_url = base_url
|
| 33 |
+
if "ollama" in model_name and not base_url:
|
| 34 |
+
self.base_url = "http://localhost:11434"
|
| 35 |
+
|
| 36 |
+
def messages_for(self, text: str) -> list[dict]:
|
| 37 |
+
return [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": text}]
|
| 38 |
+
|
| 39 |
+
def preprocess(self, text: str) -> str:
|
| 40 |
+
messages = self.messages_for(text)
|
| 41 |
+
response = completion(
|
| 42 |
+
messages=messages,
|
| 43 |
+
model=self.model_name,
|
| 44 |
+
reasoning_effort=self.reasoning_effort,
|
| 45 |
+
api_base=self.base_url,
|
| 46 |
+
)
|
| 47 |
+
self.total_input_tokens += response.usage.prompt_tokens
|
| 48 |
+
self.total_output_tokens += response.usage.completion_tokens
|
| 49 |
+
self.total_cost += response._hidden_params["response_cost"]
|
| 50 |
+
return response.choices[0].message.content
|
_hf_deploy/agents/scanner_agent.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
|
| 6 |
+
from groq import BadRequestError, Groq
|
| 7 |
+
|
| 8 |
+
from agents.deals import ScrapedDeal, DealSelection
|
| 9 |
+
from agents.agent import Agent
|
| 10 |
+
from free_config import GROQ_MODEL
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ScannerAgent(Agent):
|
| 14 |
+
MAX_DEALS_PER_REQUEST = 15
|
| 15 |
+
|
| 16 |
+
JSON_FORMAT_HINT = (
|
| 17 |
+
'\n\nRespond with JSON only, using this shape: '
|
| 18 |
+
'{"deals": [{"product_description": "...", "price": 99.99, "url": "https://..."}]}'
|
| 19 |
+
'\nThe JSON must be valid: no double-quote characters inside product_description '
|
| 20 |
+
'(write "6.9 inches" instead of 6.9"). No trailing commas. '
|
| 21 |
+
'Close the deals array with a single ] and the object with a single }.'
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
SYSTEM_PROMPT = """You identify and summarize the 5 most detailed deals from a list, by selecting deals that have the most detailed, high quality description and the most clear price.
|
| 25 |
+
You should provide the price as a number derived from the description. If the price of a deal isn't clear, do not include that deal in your response.
|
| 26 |
+
Most important is that you respond with the 5 deals that have the most detailed product description with price. It's not important to mention the terms of the deal; most important is a thorough description of the product.
|
| 27 |
+
Be careful with products that are described as "$XXX off" or "reduced by $XXX" - this isn't the actual price of the product. Only respond with products when you are highly confident about the price.
|
| 28 |
+
Never use the double-quote character inside product_description text. Spell out inch measurements as words, e.g. "55 inches" not 55".
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
USER_PROMPT_PREFIX = """Respond with the most promising 5 deals from this list, selecting those which have the most detailed, high quality product description and a clear price that is greater than 0.
|
| 32 |
+
You should rephrase the description to be a summary of the product itself, not the terms of the deal.
|
| 33 |
+
Remember to respond with a short paragraph of text in the product_description field for each of the 5 items that you select.
|
| 34 |
+
Be careful with products that are described as "$XXX off" or "reduced by $XXX" - this isn't the actual price of the product. Only respond with products when you are highly confident about the price.
|
| 35 |
+
|
| 36 |
+
Deals:
|
| 37 |
+
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
USER_PROMPT_SUFFIX = "\n\nInclude exactly 5 deals, no more."
|
| 41 |
+
|
| 42 |
+
name = "Scanner Agent"
|
| 43 |
+
color = Agent.CYAN
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
self.log("Scanner Agent is initializing")
|
| 47 |
+
self.client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 48 |
+
self.model = GROQ_MODEL
|
| 49 |
+
self.log(f"Scanner Agent is ready (Groq / {self.model})")
|
| 50 |
+
|
| 51 |
+
def fetch_deals(self, memory) -> List[ScrapedDeal]:
|
| 52 |
+
"""
|
| 53 |
+
Look up deals published on RSS feeds
|
| 54 |
+
Return any new deals that are not already in the memory provided
|
| 55 |
+
"""
|
| 56 |
+
self.log("Scanner Agent is about to fetch deals from RSS feed")
|
| 57 |
+
urls = [opp.deal.url for opp in memory]
|
| 58 |
+
scraped = ScrapedDeal.fetch()
|
| 59 |
+
result = [scrape for scrape in scraped if scrape.url not in urls]
|
| 60 |
+
self.log(f"Scanner Agent received {len(result)} deals not already scraped")
|
| 61 |
+
return result
|
| 62 |
+
|
| 63 |
+
def make_user_prompt(self, scraped) -> str:
|
| 64 |
+
"""
|
| 65 |
+
Create a user prompt based on the scraped deals provided
|
| 66 |
+
"""
|
| 67 |
+
user_prompt = self.USER_PROMPT_PREFIX
|
| 68 |
+
user_prompt += "\n\n".join(
|
| 69 |
+
[scrape.describe() for scrape in scraped[: self.MAX_DEALS_PER_REQUEST]]
|
| 70 |
+
)
|
| 71 |
+
user_prompt += self.USER_PROMPT_SUFFIX
|
| 72 |
+
return user_prompt
|
| 73 |
+
|
| 74 |
+
@staticmethod
|
| 75 |
+
def _repair_deals_json(text: str) -> str:
|
| 76 |
+
text = text.strip()
|
| 77 |
+
text = re.sub(r'(\d+(?:\.\d+)?)"(\s+(?=[a-zA-Z0-9]))', r"\1 inches\2", text)
|
| 78 |
+
text = re.sub(r'("url"\s*:\s*"[^"]*")\s*\]', r"\1\n }", text)
|
| 79 |
+
text = re.sub(
|
| 80 |
+
r'("url"\s*:\s*"[^"]*")\s*\}\s*\n\s*\]\s*\n\s*\}\}',
|
| 81 |
+
r"\1\n }\n ]\n}",
|
| 82 |
+
text,
|
| 83 |
+
)
|
| 84 |
+
text = re.sub(r"(\])\s*\]\s*\}\s*\}", r"\1}", text)
|
| 85 |
+
text = re.sub(r"(\])\s*\]\s*\}", r"\1}", text)
|
| 86 |
+
text = re.sub(r"\}\s*\}\s*$", r"}", text)
|
| 87 |
+
return text
|
| 88 |
+
|
| 89 |
+
def _parse_deals_json(self, content: str) -> DealSelection:
|
| 90 |
+
last_error = None
|
| 91 |
+
for candidate in (content, self._repair_deals_json(content)):
|
| 92 |
+
try:
|
| 93 |
+
result = DealSelection.model_validate(json.loads(candidate))
|
| 94 |
+
result.deals = [deal for deal in result.deals if deal.price > 0]
|
| 95 |
+
return result
|
| 96 |
+
except (json.JSONDecodeError, ValueError) as exc:
|
| 97 |
+
last_error = exc
|
| 98 |
+
raise ValueError(f"Could not parse deals JSON: {last_error}")
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def _extract_failed_generation(exc: BadRequestError) -> Optional[str]:
|
| 102 |
+
body = getattr(exc, "body", None)
|
| 103 |
+
if isinstance(body, dict):
|
| 104 |
+
return body.get("error", {}).get("failed_generation")
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
def _call_groq(self, user_prompt: str) -> DealSelection:
|
| 108 |
+
messages = [
|
| 109 |
+
{"role": "system", "content": self.SYSTEM_PROMPT + self.JSON_FORMAT_HINT},
|
| 110 |
+
{"role": "user", "content": user_prompt},
|
| 111 |
+
]
|
| 112 |
+
try:
|
| 113 |
+
response = self.client.chat.completions.create(
|
| 114 |
+
model=self.model,
|
| 115 |
+
messages=messages,
|
| 116 |
+
response_format={"type": "json_object"},
|
| 117 |
+
temperature=0,
|
| 118 |
+
)
|
| 119 |
+
return self._parse_deals_json(response.choices[0].message.content)
|
| 120 |
+
except BadRequestError as exc:
|
| 121 |
+
failed = self._extract_failed_generation(exc)
|
| 122 |
+
if failed:
|
| 123 |
+
self.log("Scanner Agent repairing malformed JSON from Groq response")
|
| 124 |
+
return self._parse_deals_json(failed)
|
| 125 |
+
raise
|
| 126 |
+
|
| 127 |
+
def scan(self, memory: List[str] = []) -> Optional[DealSelection]:
|
| 128 |
+
"""
|
| 129 |
+
Call Groq to provide a high potential list of deals with good descriptions and prices
|
| 130 |
+
:param memory: a list of URLs representing deals already raised
|
| 131 |
+
:return: a selection of good deals, or None if there aren't any
|
| 132 |
+
"""
|
| 133 |
+
scraped = self.fetch_deals(memory)
|
| 134 |
+
if not scraped:
|
| 135 |
+
return None
|
| 136 |
+
user_prompt = self.make_user_prompt(scraped)
|
| 137 |
+
self.log(f"Scanner Agent is calling Groq ({self.model})")
|
| 138 |
+
try:
|
| 139 |
+
result = self._call_groq(user_prompt)
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
self.log(f"Scanner Agent failed to parse Groq response: {exc}")
|
| 142 |
+
return None
|
| 143 |
+
self.log(
|
| 144 |
+
f"Scanner Agent received {len(result.deals)} selected deals with price>0 from Groq"
|
| 145 |
+
)
|
| 146 |
+
return result
|
| 147 |
+
|
| 148 |
+
def test_scan(self, memory: List[str] = []) -> Optional[DealSelection]:
|
| 149 |
+
"""
|
| 150 |
+
Return a test DealSelection, to be used during testing
|
| 151 |
+
"""
|
| 152 |
+
results = {
|
| 153 |
+
"deals": [
|
| 154 |
+
{
|
| 155 |
+
"product_description": "The Hisense R6 Series 55R6030N is a 55-inch 4K UHD Roku Smart TV that offers stunning picture quality with 3840x2160 resolution. It features Dolby Vision HDR and HDR10 compatibility, ensuring a vibrant and dynamic viewing experience. The TV runs on Roku's operating system, allowing easy access to streaming services and voice control compatibility with Google Assistant and Alexa. With three HDMI ports available, connecting multiple devices is simple and efficient.",
|
| 156 |
+
"price": 178,
|
| 157 |
+
"url": "https://www.dealnews.com/products/Hisense/Hisense-R6-Series-55-R6030-N-55-4-K-UHD-Roku-Smart-TV/484824.html?iref=rss-c142",
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"product_description": "The Poly Studio P21 is a 21.5-inch LED personal meeting display designed specifically for remote work and video conferencing. With a native resolution of 1080p, it provides crystal-clear video quality, featuring a privacy shutter and stereo speakers. This display includes a 1080p webcam with manual pan, tilt, and zoom control, along with an ambient light sensor to adjust the vanity lighting as needed. It also supports 5W wireless charging for mobile devices, making it an all-in-one solution for home offices.",
|
| 161 |
+
"price": 30,
|
| 162 |
+
"url": "https://www.dealnews.com/products/Poly-Studio-P21-21-5-1080-p-LED-Personal-Meeting-Display/378335.html?iref=rss-c39",
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"product_description": "The Lenovo IdeaPad Slim 5 laptop is powered by a 7th generation AMD Ryzen 5 8645HS 6-core CPU, offering efficient performance for multitasking and demanding applications. It features a 16-inch touch display with a resolution of 1920x1080, ensuring bright and vivid visuals. Accompanied by 16GB of RAM and a 512GB SSD, the laptop provides ample speed and storage for all your files. This model is designed to handle everyday tasks with ease while delivering an enjoyable user experience.",
|
| 166 |
+
"price": 446,
|
| 167 |
+
"url": "https://www.dealnews.com/products/Lenovo/Lenovo-Idea-Pad-Slim-5-7-th-Gen-Ryzen-5-16-Touch-Laptop/485068.html?iref=rss-c39",
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"product_description": "The Dell G15 gaming laptop is equipped with a 6th-generation AMD Ryzen 5 7640HS 6-Core CPU, providing powerful performance for gaming and content creation. It features a 15.6-inch 1080p display with a 120Hz refresh rate, allowing for smooth and responsive gameplay. With 16GB of RAM and a substantial 1TB NVMe M.2 SSD, this laptop ensures speedy performance and plenty of storage for games and applications. Additionally, it includes the Nvidia GeForce RTX 3050 GPU for enhanced graphics and gaming experiences.",
|
| 171 |
+
"price": 650,
|
| 172 |
+
"url": "https://www.dealnews.com/products/Dell/Dell-G15-Ryzen-5-15-6-Gaming-Laptop-w-Nvidia-RTX-3050/485067.html?iref=rss-c39",
|
| 173 |
+
},
|
| 174 |
+
]
|
| 175 |
+
}
|
| 176 |
+
return DealSelection(**results)
|
_hf_deploy/agents/specialist_agent.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from agents.agent import Agent
|
| 5 |
+
from free_config import GROQ_MODEL, USE_MODAL_SPECIALIST
|
| 6 |
+
|
| 7 |
+
QUESTION = "What does this cost to the nearest dollar?"
|
| 8 |
+
PREFIX = "Price is $"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SpecialistAgent(Agent):
|
| 12 |
+
"""
|
| 13 |
+
Prices products using either:
|
| 14 |
+
1. Fine-tuned Llama on Modal (best quality, uses GPU credits), or
|
| 15 |
+
2. Groq fallback (free — same prompt format as the fine-tuned model)
|
| 16 |
+
|
| 17 |
+
Set USE_MODAL_SPECIALIST=true only if you have Modal credits to spare.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
name = "Specialist Agent"
|
| 21 |
+
color = Agent.RED
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
self.pricer = None
|
| 25 |
+
self.groq_client = None
|
| 26 |
+
self.mode = "off"
|
| 27 |
+
self.model = GROQ_MODEL
|
| 28 |
+
|
| 29 |
+
if USE_MODAL_SPECIALIST:
|
| 30 |
+
self._try_modal()
|
| 31 |
+
|
| 32 |
+
if self.mode == "off" and os.getenv("GROQ_API_KEY"):
|
| 33 |
+
from groq import Groq
|
| 34 |
+
|
| 35 |
+
self.groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 36 |
+
self.mode = "groq"
|
| 37 |
+
self.log(f"Specialist Agent using Groq fallback ({self.model}) — no Modal GPU needed")
|
| 38 |
+
|
| 39 |
+
if self.mode == "off":
|
| 40 |
+
self.log("Specialist Agent disabled — ensemble will use Frontier + Neural Network only")
|
| 41 |
+
|
| 42 |
+
def _try_modal(self):
|
| 43 |
+
try:
|
| 44 |
+
import modal
|
| 45 |
+
|
| 46 |
+
self.log("Specialist Agent is initializing - connecting to Modal")
|
| 47 |
+
Pricer = modal.Cls.from_name("pricer-service", "Pricer")
|
| 48 |
+
self.pricer = Pricer()
|
| 49 |
+
self.mode = "modal"
|
| 50 |
+
self.log("Specialist Agent connected to Modal pricer-service")
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
self.log(f"Modal unavailable ({exc}) — will try Groq fallback if configured")
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def available(self) -> bool:
|
| 56 |
+
return self.mode != "off"
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def _parse_price(text: str) -> float:
|
| 60 |
+
if PREFIX in text:
|
| 61 |
+
text = text.split(PREFIX, 1)[1]
|
| 62 |
+
text = text.replace("$", "").replace(",", "")
|
| 63 |
+
match = re.search(r"[-+]?\d*\.\d+|\d+", text)
|
| 64 |
+
return float(match.group()) if match else 0.0
|
| 65 |
+
|
| 66 |
+
def _price_with_groq(self, description: str) -> float:
|
| 67 |
+
prompt = f"{QUESTION}\n\n{description}\n\n{PREFIX}"
|
| 68 |
+
response = self.groq_client.chat.completions.create(
|
| 69 |
+
model=self.model,
|
| 70 |
+
messages=[{"role": "user", "content": prompt}],
|
| 71 |
+
temperature=0,
|
| 72 |
+
max_tokens=8,
|
| 73 |
+
)
|
| 74 |
+
reply = response.choices[0].message.content or ""
|
| 75 |
+
return self._parse_price(reply)
|
| 76 |
+
|
| 77 |
+
def price(self, description: str) -> float:
|
| 78 |
+
if self.mode == "modal":
|
| 79 |
+
self.log("Specialist Agent is calling remote fine-tuned model on Modal")
|
| 80 |
+
result = self.pricer.price.remote(description)
|
| 81 |
+
self.log(f"Specialist Agent completed - predicting ${result:.2f}")
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
if self.mode == "groq":
|
| 85 |
+
self.log(f"Specialist Agent is calling Groq ({self.model})")
|
| 86 |
+
result = self._price_with_groq(description)
|
| 87 |
+
self.log(f"Specialist Agent completed - predicting ${result:.2f}")
|
| 88 |
+
return result
|
| 89 |
+
|
| 90 |
+
return 0.0
|
_hf_deploy/app.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Spaces entry point.
|
| 3 |
+
|
| 4 |
+
Upload the entire week8/ folder into the Space repo root (this file becomes app.py),
|
| 5 |
+
or copy week8/* into the Space and rename/move as needed.
|
| 6 |
+
|
| 7 |
+
Required Space secrets: GROQ_API_KEY, HF_TOKEN, USE_MODAL_SPECIALIST=false
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
# When week8 is the Space root, agents/ and deal_agent_framework.py are siblings.
|
| 15 |
+
ROOT = Path(__file__).resolve().parent
|
| 16 |
+
if str(ROOT) not in sys.path:
|
| 17 |
+
sys.path.insert(0, str(ROOT))
|
| 18 |
+
|
| 19 |
+
os.environ.setdefault("GRADIO_SERVER_NAME", "0.0.0.0")
|
| 20 |
+
os.environ.setdefault("PRICER_PREPROCESSOR_MODEL", "groq/llama-3.1-8b-instant")
|
| 21 |
+
|
| 22 |
+
from price_is_right import App
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
App().run()
|
_hf_deploy/deal_agent_framework.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import logging
|
| 4 |
+
import json
|
| 5 |
+
from typing import List
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
import chromadb
|
| 8 |
+
from agents.planning_agent import PlanningAgent
|
| 9 |
+
from agents.deals import Opportunity
|
| 10 |
+
from sklearn.manifold import TSNE
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
load_dotenv(override=True)
|
| 14 |
+
|
| 15 |
+
# Colors for logging
|
| 16 |
+
BG_BLUE = "\033[44m"
|
| 17 |
+
WHITE = "\033[37m"
|
| 18 |
+
RESET = "\033[0m"
|
| 19 |
+
|
| 20 |
+
# Colors for plot
|
| 21 |
+
CATEGORIES = [
|
| 22 |
+
"Appliances",
|
| 23 |
+
"Automotive",
|
| 24 |
+
"Cell_Phones_and_Accessories",
|
| 25 |
+
"Electronics",
|
| 26 |
+
"Musical_Instruments",
|
| 27 |
+
"Office_Products",
|
| 28 |
+
"Tools_and_Home_Improvement",
|
| 29 |
+
"Toys_and_Games",
|
| 30 |
+
]
|
| 31 |
+
COLORS = ["red", "blue", "brown", "orange", "yellow", "green", "purple", "cyan"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def init_logging():
|
| 35 |
+
root = logging.getLogger()
|
| 36 |
+
root.setLevel(logging.INFO)
|
| 37 |
+
|
| 38 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 39 |
+
handler.setLevel(logging.INFO)
|
| 40 |
+
formatter = logging.Formatter(
|
| 41 |
+
"[%(asctime)s] [Agents] [%(levelname)s] %(message)s",
|
| 42 |
+
datefmt="%Y-%m-%d %H:%M:%S %z",
|
| 43 |
+
)
|
| 44 |
+
handler.setFormatter(formatter)
|
| 45 |
+
root.addHandler(handler)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class DealAgentFramework:
|
| 49 |
+
DB = "products_vectorstore"
|
| 50 |
+
MEMORY_FILENAME = "memory.json"
|
| 51 |
+
|
| 52 |
+
def __init__(self):
|
| 53 |
+
init_logging()
|
| 54 |
+
client = chromadb.PersistentClient(path=self.DB)
|
| 55 |
+
self.memory = self.read_memory()
|
| 56 |
+
self.collection = client.get_or_create_collection("products")
|
| 57 |
+
self.planner = None
|
| 58 |
+
|
| 59 |
+
def init_agents_as_needed(self):
|
| 60 |
+
if not self.planner:
|
| 61 |
+
self.log("Initializing Agent Framework")
|
| 62 |
+
self.planner = PlanningAgent(self.collection)
|
| 63 |
+
self.log("Agent Framework is ready")
|
| 64 |
+
|
| 65 |
+
def read_memory(self) -> List[Opportunity]:
|
| 66 |
+
if os.path.exists(self.MEMORY_FILENAME):
|
| 67 |
+
with open(self.MEMORY_FILENAME, "r") as file:
|
| 68 |
+
data = json.load(file)
|
| 69 |
+
opportunities = [Opportunity(**item) for item in data]
|
| 70 |
+
return opportunities
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
def write_memory(self) -> None:
|
| 74 |
+
data = [opportunity.model_dump() for opportunity in self.memory]
|
| 75 |
+
with open(self.MEMORY_FILENAME, "w") as file:
|
| 76 |
+
json.dump(data, file, indent=2)
|
| 77 |
+
|
| 78 |
+
@classmethod
|
| 79 |
+
def reset_memory(cls) -> None:
|
| 80 |
+
data = []
|
| 81 |
+
if os.path.exists(cls.MEMORY_FILENAME):
|
| 82 |
+
with open(cls.MEMORY_FILENAME, "r") as file:
|
| 83 |
+
data = json.load(file)
|
| 84 |
+
truncated = data[:2]
|
| 85 |
+
with open(cls.MEMORY_FILENAME, "w") as file:
|
| 86 |
+
json.dump(truncated, file, indent=2)
|
| 87 |
+
|
| 88 |
+
def log(self, message: str):
|
| 89 |
+
text = BG_BLUE + WHITE + "[Agent Framework] " + message + RESET
|
| 90 |
+
logging.info(text)
|
| 91 |
+
|
| 92 |
+
def run(self) -> List[Opportunity]:
|
| 93 |
+
self.init_agents_as_needed()
|
| 94 |
+
logging.info("Kicking off Planning Agent")
|
| 95 |
+
result = self.planner.plan(memory=self.memory)
|
| 96 |
+
logging.info(f"Planning Agent has completed and returned: {result}")
|
| 97 |
+
if result:
|
| 98 |
+
self.memory.append(result)
|
| 99 |
+
self.write_memory()
|
| 100 |
+
return self.memory
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def get_plot_data(cls, max_datapoints=2000):
|
| 104 |
+
client = chromadb.PersistentClient(path=cls.DB)
|
| 105 |
+
collection = client.get_or_create_collection("products")
|
| 106 |
+
result = collection.get(
|
| 107 |
+
include=["embeddings", "documents", "metadatas"], limit=max_datapoints
|
| 108 |
+
)
|
| 109 |
+
vectors = np.array(result["embeddings"])
|
| 110 |
+
documents = result["documents"]
|
| 111 |
+
categories = [metadata["category"] for metadata in result["metadatas"]]
|
| 112 |
+
colors = [COLORS[CATEGORIES.index(c)] for c in categories]
|
| 113 |
+
tsne = TSNE(n_components=3, random_state=42, n_jobs=-1)
|
| 114 |
+
reduced_vectors = tsne.fit_transform(vectors)
|
| 115 |
+
return documents, reduced_vectors, colors
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
DealAgentFramework().run()
|
_hf_deploy/env_utils.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load .env from repo root or week8 folder."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
WEEK8_DIR = Path(__file__).resolve().parent
|
| 8 |
+
REPO_ROOT = WEEK8_DIR.parent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_project_env() -> None:
|
| 12 |
+
for path in (REPO_ROOT / ".env", WEEK8_DIR / ".env"):
|
| 13 |
+
if path.exists():
|
| 14 |
+
load_dotenv(path, override=True)
|