python_uml_class 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rspec_status +13 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +56 -41
- data/README.md +59 -2
- data/README_JA.md +99 -0
- data/img/class.png +0 -0
- data/lib/config/setting.json +23 -2
- data/lib/config.ru +0 -2
- data/lib/create_uml_class.rb +161 -34
- data/lib/css/index.css +6 -0
- data/lib/js/main.js +24 -10
- data/lib/python_uml_class/version.rb +1 -1
- data/lib/server.rb +12 -0
- data/lib/start.rb +28 -7
- data/lib/wsserver.rb +8 -1
- data/python_uml_class.gemspec +3 -0
- data/test_run.log +3617 -0
- data/test_run.rb +7 -0
- data/test_run_new.log +3083 -0
- data/test_script.py +1674 -0
- data/user_code.py +52 -0
- metadata +51 -2
data/test_script.py
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import shlex
|
|
7
|
+
import smtplib
|
|
8
|
+
from email.mime.text import MIMEText
|
|
9
|
+
from email.mime.multipart import MIMEMultipart
|
|
10
|
+
from typing import Optional, Type, Any, List, Dict
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
from langchain_core.tools import BaseTool, Tool
|
|
16
|
+
from pydantic import BaseModel, Field, model_validator
|
|
17
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
18
|
+
from langchain_community.utilities import SerpAPIWrapper
|
|
19
|
+
from langchain_community.tools.tavily_search import TavilySearchResults
|
|
20
|
+
from googleapiclient.discovery import build
|
|
21
|
+
import asyncio
|
|
22
|
+
from browser_use import Agent, BrowserProfile
|
|
23
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
24
|
+
from browser_use.llm.google.chat import ChatGoogle
|
|
25
|
+
from langchain.agents import AgentExecutor, create_tool_calling_agent, create_react_agent
|
|
26
|
+
from langchain_core.prompts import ChatPromptTemplate, PromptTemplate, MessagesPlaceholder
|
|
27
|
+
from langchain_core.output_parsers import JsonOutputParser
|
|
28
|
+
from langchain_core.messages import HumanMessage, AIMessage
|
|
29
|
+
|
|
30
|
+
# Load environment variables
|
|
31
|
+
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
|
|
32
|
+
load_dotenv(dotenv_path, override=True)
|
|
33
|
+
|
|
34
|
+
# --- Database Setup ---
|
|
35
|
+
DB_NAME = "products.db"
|
|
36
|
+
|
|
37
|
+
def init_db():
|
|
38
|
+
conn = sqlite3.connect(DB_NAME)
|
|
39
|
+
cursor = conn.cursor()
|
|
40
|
+
|
|
41
|
+
# Products table
|
|
42
|
+
cursor.execute('''
|
|
43
|
+
CREATE TABLE IF NOT EXISTS products (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
name TEXT NOT NULL,
|
|
46
|
+
store TEXT,
|
|
47
|
+
price TEXT,
|
|
48
|
+
url TEXT,
|
|
49
|
+
description TEXT,
|
|
50
|
+
model_number TEXT,
|
|
51
|
+
release_date TEXT,
|
|
52
|
+
ram TEXT,
|
|
53
|
+
ssd TEXT,
|
|
54
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
55
|
+
)
|
|
56
|
+
''')
|
|
57
|
+
|
|
58
|
+
# Check if columns exist (for migration)
|
|
59
|
+
try:
|
|
60
|
+
cursor.execute("ALTER TABLE products ADD COLUMN model_number TEXT")
|
|
61
|
+
except sqlite3.OperationalError:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
cursor.execute("ALTER TABLE products ADD COLUMN release_date TEXT")
|
|
65
|
+
except sqlite3.OperationalError:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
cursor.execute("ALTER TABLE products ADD COLUMN ram TEXT")
|
|
69
|
+
except sqlite3.OperationalError:
|
|
70
|
+
pass
|
|
71
|
+
try:
|
|
72
|
+
cursor.execute("ALTER TABLE products ADD COLUMN ssd TEXT")
|
|
73
|
+
except sqlite3.OperationalError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# Agent logs table
|
|
77
|
+
cursor.execute('''
|
|
78
|
+
CREATE TABLE IF NOT EXISTS agent_logs (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
81
|
+
query TEXT,
|
|
82
|
+
scratchpad TEXT
|
|
83
|
+
)
|
|
84
|
+
''')
|
|
85
|
+
|
|
86
|
+
conn.commit()
|
|
87
|
+
conn.close()
|
|
88
|
+
|
|
89
|
+
# Initialize the database
|
|
90
|
+
init_db()
|
|
91
|
+
|
|
92
|
+
# --- Helper Functions ---
|
|
93
|
+
|
|
94
|
+
class TavilySearchWrapper:
|
|
95
|
+
def __init__(self):
|
|
96
|
+
self.api_key = os.getenv("TAVILY_API_KEY")
|
|
97
|
+
if not self.api_key:
|
|
98
|
+
raise ValueError("TAVILY_API_KEY must be set for Tavily Search.")
|
|
99
|
+
self.tool = TavilySearchResults(api_key=self.api_key)
|
|
100
|
+
|
|
101
|
+
def run(self, query: str) -> str:
|
|
102
|
+
try:
|
|
103
|
+
results = self.tool.invoke({"query": query})
|
|
104
|
+
|
|
105
|
+
formatted_results = []
|
|
106
|
+
for item in results:
|
|
107
|
+
content = item.get('content')
|
|
108
|
+
url = item.get('url')
|
|
109
|
+
formatted_results.append(f"Content: {content}\nURL: {url}\n")
|
|
110
|
+
|
|
111
|
+
return "\n".join(formatted_results) if formatted_results else "No results found."
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return f"Error during Tavily Search: {e}"
|
|
114
|
+
|
|
115
|
+
class BrowserUseSearchWrapper:
|
|
116
|
+
def __init__(self):
|
|
117
|
+
model_name = os.getenv("MODEL_NAME", "gemini-2.0-flash")
|
|
118
|
+
self.llm = ChatGoogle(model=model_name, api_key=os.getenv("GOOGLE_API_KEY"))
|
|
119
|
+
|
|
120
|
+
# CAPTCHA回避設定
|
|
121
|
+
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
122
|
+
self.browser_profile = BrowserProfile(headless=False, user_agent=user_agent)
|
|
123
|
+
|
|
124
|
+
async def _search_async(self, query: str) -> str:
|
|
125
|
+
task = f"""
|
|
126
|
+
Web全体から '{query}' を検索してください。
|
|
127
|
+
検索結果の上位の製品について、以下の情報を確実に抽出してください:
|
|
128
|
+
1. 製品名(タイトル)
|
|
129
|
+
2. 正確な製品ページのURL
|
|
130
|
+
3. 詳細な製品概要(スペックや特徴)
|
|
131
|
+
4. 価格
|
|
132
|
+
5. 型番(モデル番号)
|
|
133
|
+
6. 発売日
|
|
134
|
+
|
|
135
|
+
重要:URLと製品概要は必須です。URLは必ず http または https で始まる有効なものを取得してください。
|
|
136
|
+
型番や発売日が見つかる場合はそれらも必ず抽出してください。
|
|
137
|
+
検索結果ページだけでなく、必要であれば個別の製品ページにアクセスして情報を取得してください。
|
|
138
|
+
ページが完全に読み込まれるまで待ち、正確な情報を取得するようにしてください。
|
|
139
|
+
また、メモリ(RAM)やストレージ(SSDなど)の容量を抽出する際、「最大〇〇GB」「〇〇GBまで増設可能」などと記載されている拡張上限の数値は対象外とし、必ず「標準搭載(初期状態)」の容量を抽出してください。
|
|
140
|
+
|
|
141
|
+
【極密事項】
|
|
142
|
+
ページ内に以下の文言が含まれている商品は「販売不可」とみなし、絶対に抽出・出力しないでください。
|
|
143
|
+
- 「販売終了」
|
|
144
|
+
- 「お探しのページは見つかりません」
|
|
145
|
+
- 「404 Not Found」
|
|
146
|
+
- 「この商品は現在お取り扱いできません」
|
|
147
|
+
"""
|
|
148
|
+
agent = Agent(task=task, llm=self.llm, browser_profile=self.browser_profile)
|
|
149
|
+
result = await agent.run()
|
|
150
|
+
return result.final_result()
|
|
151
|
+
|
|
152
|
+
def run(self, query: str) -> str:
|
|
153
|
+
try:
|
|
154
|
+
return asyncio.run(self._search_async(query))
|
|
155
|
+
except Exception as e:
|
|
156
|
+
return f"Error during Browser Use Search: {e}"
|
|
157
|
+
|
|
158
|
+
def get_search_tool_func():
|
|
159
|
+
provider = os.getenv("SEARCH_PROVIDER", "serpapi")
|
|
160
|
+
if provider == "tavily_api":
|
|
161
|
+
return TavilySearchWrapper()
|
|
162
|
+
elif provider == "browser_use":
|
|
163
|
+
return BrowserUseSearchWrapper()
|
|
164
|
+
else:
|
|
165
|
+
return SerpAPIWrapper()
|
|
166
|
+
|
|
167
|
+
def get_all_products():
|
|
168
|
+
conn = sqlite3.connect(DB_NAME)
|
|
169
|
+
cursor = conn.cursor()
|
|
170
|
+
cursor.execute("SELECT id, name, store, price, url, description, model_number, release_date, ram, ssd, updated_at FROM products")
|
|
171
|
+
rows = cursor.fetchall()
|
|
172
|
+
conn.close()
|
|
173
|
+
products = []
|
|
174
|
+
for r in rows:
|
|
175
|
+
products.append({
|
|
176
|
+
'id': r[0],
|
|
177
|
+
'name': r[1],
|
|
178
|
+
'store': r[2],
|
|
179
|
+
'price': r[3],
|
|
180
|
+
'url': r[4],
|
|
181
|
+
'description': r[5],
|
|
182
|
+
'model_number': r[6] if len(r) > 6 else "",
|
|
183
|
+
'release_date': r[7] if len(r) > 7 else "",
|
|
184
|
+
'ram': r[8] if len(r) > 8 else "",
|
|
185
|
+
'ssd': r[9] if len(r) > 9 else "",
|
|
186
|
+
'updated_at': r[10] if len(r) > 10 else ""
|
|
187
|
+
})
|
|
188
|
+
return products
|
|
189
|
+
|
|
190
|
+
def parse_price_val(p_str):
|
|
191
|
+
if not p_str:
|
|
192
|
+
return float('inf')
|
|
193
|
+
s = str(p_str).replace(',', '')
|
|
194
|
+
|
|
195
|
+
# Handle "万" (ten thousand)
|
|
196
|
+
# Match patterns like "1.5万", "10万"
|
|
197
|
+
match_man = re.search(r'(\d+(\.\d+)?)万', s)
|
|
198
|
+
if match_man:
|
|
199
|
+
try:
|
|
200
|
+
val = float(match_man.group(1)) * 10000
|
|
201
|
+
return int(val)
|
|
202
|
+
except:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
# Fallback: extract all digits and join them
|
|
206
|
+
nums = re.findall(r'\d+', s)
|
|
207
|
+
return int(''.join(nums)) if nums else float('inf')
|
|
208
|
+
|
|
209
|
+
def extract_alphanumeric(s: str) -> str:
|
|
210
|
+
"""Extracts only alphanumeric characters from a string and converts to lowercase for robust comparison."""
|
|
211
|
+
if not s:
|
|
212
|
+
return ""
|
|
213
|
+
# Remove everything except a-z, A-Z, 0-9 and convert to lower
|
|
214
|
+
return re.sub(r'[^a-zA-Z0-9]', '', str(s)).lower()
|
|
215
|
+
|
|
216
|
+
def parse_date_val(d_str: str) -> str:
|
|
217
|
+
"""
|
|
218
|
+
Normalizes date strings for comparison.
|
|
219
|
+
Examples: '2021年2月' -> '202102', '2021-02' -> '202102'
|
|
220
|
+
"""
|
|
221
|
+
if not d_str:
|
|
222
|
+
return ""
|
|
223
|
+
|
|
224
|
+
# 連続する数字を抽出 (年、月、日の順を想定)
|
|
225
|
+
nums = re.findall(r'\d+', str(d_str))
|
|
226
|
+
|
|
227
|
+
if len(nums) >= 2:
|
|
228
|
+
year = nums[0]
|
|
229
|
+
month = nums[1].zfill(2) # 0埋め
|
|
230
|
+
return f"{year}{month}"
|
|
231
|
+
elif len(nums) == 1:
|
|
232
|
+
# 年だけの場合
|
|
233
|
+
return nums[0]
|
|
234
|
+
else:
|
|
235
|
+
return extract_alphanumeric(d_str)
|
|
236
|
+
|
|
237
|
+
def is_similar_model(m1: str, m2: str) -> bool:
|
|
238
|
+
"""
|
|
239
|
+
Checks if two model strings are substantially similar.
|
|
240
|
+
Considers them similar if the alphanumeric string of one is entirely contained in the other.
|
|
241
|
+
e.g., 'dynabookg83hs7n11' and 'g83hs7n11' -> True
|
|
242
|
+
"""
|
|
243
|
+
am1 = extract_alphanumeric(m1)
|
|
244
|
+
am2 = extract_alphanumeric(m2)
|
|
245
|
+
|
|
246
|
+
if not am1 and not am2:
|
|
247
|
+
return True
|
|
248
|
+
if not am1 or not am2:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
return am1 in am2 or am2 in am1
|
|
252
|
+
|
|
253
|
+
def save_agent_log(query, steps):
|
|
254
|
+
"""Saves the agent's scratchpad (intermediate steps) to the database."""
|
|
255
|
+
if not steps:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
log_content = []
|
|
259
|
+
for action, observation in steps:
|
|
260
|
+
# Check if action is a list (some agents return list of actions)
|
|
261
|
+
if isinstance(action, list):
|
|
262
|
+
for a in action:
|
|
263
|
+
log_content.append(f"Tool: {a.tool}")
|
|
264
|
+
log_content.append(f"Input: {a.tool_input}")
|
|
265
|
+
log_content.append(f"Log: {a.log}")
|
|
266
|
+
else:
|
|
267
|
+
log_content.append(f"Tool: {action.tool}")
|
|
268
|
+
log_content.append(f"Input: {action.tool_input}")
|
|
269
|
+
log_content.append(f"Log: {action.log}")
|
|
270
|
+
|
|
271
|
+
log_content.append(f"Observation: {observation}")
|
|
272
|
+
log_content.append("-" * 20)
|
|
273
|
+
|
|
274
|
+
scratchpad_text = "\n".join(log_content)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
conn = sqlite3.connect(DB_NAME)
|
|
278
|
+
cursor = conn.cursor()
|
|
279
|
+
cursor.execute("INSERT INTO agent_logs (query, scratchpad) VALUES (?, ?)", (query, scratchpad_text))
|
|
280
|
+
conn.commit()
|
|
281
|
+
conn.close()
|
|
282
|
+
print(f" [Log saved to database]")
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f"Error saving log: {e}")
|
|
285
|
+
|
|
286
|
+
def get_all_agent_logs():
|
|
287
|
+
"""Fetches all agent logs from the database."""
|
|
288
|
+
try:
|
|
289
|
+
conn = sqlite3.connect(DB_NAME)
|
|
290
|
+
cursor = conn.cursor()
|
|
291
|
+
cursor.execute("SELECT query, scratchpad FROM agent_logs ORDER BY timestamp DESC")
|
|
292
|
+
rows = cursor.fetchall()
|
|
293
|
+
conn.close()
|
|
294
|
+
|
|
295
|
+
logs = []
|
|
296
|
+
for r in rows:
|
|
297
|
+
logs.append(f"Query: {r[0]}\nLog:\n{r[1]}\n")
|
|
298
|
+
return "\n".join(logs)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
print(f"Error fetching logs: {e}")
|
|
301
|
+
return ""
|
|
302
|
+
|
|
303
|
+
def send_email_notification(subject: str, body: str):
|
|
304
|
+
"""Sends an email notification."""
|
|
305
|
+
smtp_server = os.getenv("EMAIL_SMTP_SERVER")
|
|
306
|
+
smtp_port = os.getenv("EMAIL_SMTP_PORT")
|
|
307
|
+
sender_email = os.getenv("EMAIL_SENDER_ADDRESS")
|
|
308
|
+
sender_password = os.getenv("EMAIL_SENDER_PASSWORD")
|
|
309
|
+
receiver_email = os.getenv("EMAIL_RECEIVER_ADDRESS")
|
|
310
|
+
|
|
311
|
+
if not all([smtp_server, smtp_port, sender_email, receiver_email]):
|
|
312
|
+
print("Email configuration missing (Server, Port, Sender, Receiver). Skipping notification.")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
msg = MIMEMultipart()
|
|
317
|
+
msg['From'] = sender_email
|
|
318
|
+
msg['To'] = receiver_email
|
|
319
|
+
msg['Subject'] = subject
|
|
320
|
+
msg.attach(MIMEText(body, 'plain'))
|
|
321
|
+
|
|
322
|
+
server = smtplib.SMTP(smtp_server, int(smtp_port))
|
|
323
|
+
server.starttls()
|
|
324
|
+
|
|
325
|
+
# Only login if password is provided
|
|
326
|
+
if sender_password:
|
|
327
|
+
server.login(sender_email, sender_password)
|
|
328
|
+
|
|
329
|
+
server.send_message(msg)
|
|
330
|
+
server.quit()
|
|
331
|
+
print(f"Email notification sent: {subject}")
|
|
332
|
+
except Exception as e:
|
|
333
|
+
print(f"Failed to send email: {e}")
|
|
334
|
+
|
|
335
|
+
# --- Tool Definitions ---
|
|
336
|
+
|
|
337
|
+
class SaveProductInput(BaseModel):
|
|
338
|
+
name: str = Field(description="Name of the product")
|
|
339
|
+
store: str = Field(description="Name of the store selling the product")
|
|
340
|
+
price: str = Field(description="Price of the product")
|
|
341
|
+
url: Optional[str] = Field(description="URL of the product page", default="")
|
|
342
|
+
description: Optional[str] = Field(description="Brief description of the product", default="")
|
|
343
|
+
model_number: Optional[str] = Field(description="Model number (型番) of the product", default="")
|
|
344
|
+
release_date: Optional[str] = Field(description="Release date (発売日) of the product", default="")
|
|
345
|
+
ram: Optional[str] = Field(description="RAM size", default="")
|
|
346
|
+
ssd: Optional[str] = Field(description="SSD size", default="")
|
|
347
|
+
|
|
348
|
+
@model_validator(mode='before')
|
|
349
|
+
@classmethod
|
|
350
|
+
def parse_json_input(cls, data: Any) -> Any:
|
|
351
|
+
if isinstance(data, dict):
|
|
352
|
+
if 'name' in data and ('store' not in data or 'price' not in data):
|
|
353
|
+
name_val = data['name']
|
|
354
|
+
if isinstance(name_val, str) and name_val.strip().startswith('{') and name_val.strip().endswith('}'):
|
|
355
|
+
try:
|
|
356
|
+
parsed = json.loads(name_val)
|
|
357
|
+
if isinstance(parsed, dict):
|
|
358
|
+
data = parsed
|
|
359
|
+
except json.JSONDecodeError:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
if isinstance(data, dict) and 'price' in data:
|
|
363
|
+
if isinstance(data['price'], (int, float)):
|
|
364
|
+
data['price'] = str(data['price'])
|
|
365
|
+
|
|
366
|
+
return data
|
|
367
|
+
|
|
368
|
+
class SaveProductTool(BaseTool):
|
|
369
|
+
name = "save_product"
|
|
370
|
+
description = "Saves product information (name, store, price, url, description, model_number, release_date, ram, ssd) to the database."
|
|
371
|
+
args_schema: Type[BaseModel] = SaveProductInput
|
|
372
|
+
|
|
373
|
+
def _run(self, name: str, store: str = None, price: str = None, url: str = "", description: str = "", model_number: str = "", release_date: str = "", ram: str = "", ssd: str = "", **kwargs):
|
|
374
|
+
try:
|
|
375
|
+
# Attempt to extract data if 'name' is a dictionary or a JSON string
|
|
376
|
+
parsed_data = {}
|
|
377
|
+
if isinstance(name, dict):
|
|
378
|
+
parsed_data = name
|
|
379
|
+
elif isinstance(name, str) and name.strip().startswith('{') and name.strip().endswith('}'):
|
|
380
|
+
try:
|
|
381
|
+
parsed_data = json.loads(name)
|
|
382
|
+
except json.JSONDecodeError:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
if parsed_data:
|
|
386
|
+
if 'name' in parsed_data:
|
|
387
|
+
name = parsed_data['name']
|
|
388
|
+
if store is None:
|
|
389
|
+
store = parsed_data.get('store')
|
|
390
|
+
if price is None:
|
|
391
|
+
price = parsed_data.get('price')
|
|
392
|
+
if not url:
|
|
393
|
+
url = parsed_data.get('url', "")
|
|
394
|
+
if not description:
|
|
395
|
+
description = parsed_data.get('description', "")
|
|
396
|
+
if not model_number:
|
|
397
|
+
model_number = parsed_data.get('model_number', "")
|
|
398
|
+
if not release_date:
|
|
399
|
+
release_date = parsed_data.get('release_date', "")
|
|
400
|
+
if not ram:
|
|
401
|
+
ram = parsed_data.get('ram', "")
|
|
402
|
+
if not ssd:
|
|
403
|
+
ssd = parsed_data.get('ssd', "")
|
|
404
|
+
|
|
405
|
+
if store is None:
|
|
406
|
+
store = kwargs.get('store')
|
|
407
|
+
if price is None:
|
|
408
|
+
price = kwargs.get('price')
|
|
409
|
+
if not model_number:
|
|
410
|
+
model_number = kwargs.get('model_number', "")
|
|
411
|
+
if not release_date:
|
|
412
|
+
release_date = kwargs.get('release_date', "")
|
|
413
|
+
if not ram:
|
|
414
|
+
ram = kwargs.get('ram', "")
|
|
415
|
+
if not ssd:
|
|
416
|
+
ssd = kwargs.get('ssd', "")
|
|
417
|
+
|
|
418
|
+
if not name or not store or not price:
|
|
419
|
+
return f"Error: Required arguments missing. Name: {name}, Store: {store}, Price: {price}"
|
|
420
|
+
|
|
421
|
+
# Validate URL and Description presence
|
|
422
|
+
if not url or not description:
|
|
423
|
+
return f"Skipped saving product '{name}': URL or description is missing. URL: '{url}', Description: '{description}'"
|
|
424
|
+
|
|
425
|
+
# Validate URL format (must start with http or https)
|
|
426
|
+
if not url.startswith('http://') and not url.startswith('https://'):
|
|
427
|
+
return f"Skipped saving product '{name}': URL must start with 'http://' or 'https://'. URL: '{url}'"
|
|
428
|
+
|
|
429
|
+
if isinstance(price, (int, float)):
|
|
430
|
+
price = str(price)
|
|
431
|
+
|
|
432
|
+
price_val = parse_price_val(price)
|
|
433
|
+
|
|
434
|
+
if price_val == float('inf'):
|
|
435
|
+
# Check if it has any digit
|
|
436
|
+
if not re.search(r'\d', str(price)):
|
|
437
|
+
return f"Skipped saving product '{name}': Price info is missing or invalid ('{price}')."
|
|
438
|
+
|
|
439
|
+
conn = sqlite3.connect(DB_NAME)
|
|
440
|
+
cursor = conn.cursor()
|
|
441
|
+
|
|
442
|
+
# Fetch all existing records for this product name
|
|
443
|
+
cursor.execute("SELECT id, store, price, url FROM products WHERE name = ?", (name,))
|
|
444
|
+
rows = cursor.fetchall()
|
|
445
|
+
|
|
446
|
+
items = []
|
|
447
|
+
for r in rows:
|
|
448
|
+
items.append({
|
|
449
|
+
'id': r[0],
|
|
450
|
+
'store': r[1],
|
|
451
|
+
'price_str': r[2],
|
|
452
|
+
'price_val': parse_price_val(r[2]),
|
|
453
|
+
'url': r[3]
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
new_price_val = parse_price_val(price)
|
|
457
|
+
msg = ""
|
|
458
|
+
|
|
459
|
+
# Sort existing items by price (cheapest first)
|
|
460
|
+
items.sort(key=lambda x: x['price_val'])
|
|
461
|
+
|
|
462
|
+
current_cheapest = items[0] if items else None
|
|
463
|
+
|
|
464
|
+
should_save = False
|
|
465
|
+
should_update = False
|
|
466
|
+
|
|
467
|
+
if not current_cheapest:
|
|
468
|
+
# No existing record, save new one
|
|
469
|
+
should_save = True
|
|
470
|
+
else:
|
|
471
|
+
# Compare with current cheapest
|
|
472
|
+
if new_price_val < current_cheapest['price_val']:
|
|
473
|
+
# New price is cheaper -> Delete all old records and save new one
|
|
474
|
+
should_save = True
|
|
475
|
+
# Delete all existing records for this product name
|
|
476
|
+
cursor.execute("DELETE FROM products WHERE name = ?", (name,))
|
|
477
|
+
msg_prefix = f"Found cheaper price! Updated {name} from {current_cheapest['store']} ({current_cheapest['price_str']}) to {store} ({price})."
|
|
478
|
+
elif new_price_val == current_cheapest['price_val']:
|
|
479
|
+
# Same price -> If same store, update info. If different store, maybe keep existing?
|
|
480
|
+
# Requirement: "Keep cheapest". If prices are equal, we can overwrite if it's the same store (update info),
|
|
481
|
+
# or if it's a different store, we might keep the existing one to avoid flapping,
|
|
482
|
+
# OR we could overwrite if the new one has more info.
|
|
483
|
+
# Let's say:
|
|
484
|
+
# 1. If same store -> Update (URL/Desc might have changed)
|
|
485
|
+
# 2. If different store -> Keep existing (First come first served for same price)
|
|
486
|
+
|
|
487
|
+
if store == current_cheapest['store']:
|
|
488
|
+
should_update = True
|
|
489
|
+
msg_prefix = f"Updated info for {name} at {store}."
|
|
490
|
+
else:
|
|
491
|
+
msg = f"Product {name} exists with same price at {current_cheapest['store']}. Keeping existing."
|
|
492
|
+
else:
|
|
493
|
+
# New price is higher -> Ignore, unless it's the SAME store updating its price (price increase)
|
|
494
|
+
# If the store is the same as the current cheapest, we must update (price increase)
|
|
495
|
+
if store == current_cheapest['store']:
|
|
496
|
+
should_update = True
|
|
497
|
+
msg_prefix = f"Price increased for {name} at {store}: {current_cheapest['price_str']} -> {price}."
|
|
498
|
+
else:
|
|
499
|
+
msg = f"Product {name} exists cheaper at {current_cheapest['store']} ({current_cheapest['price_str']}). Ignoring {store} ({price})."
|
|
500
|
+
|
|
501
|
+
if should_save:
|
|
502
|
+
cursor.execute('''
|
|
503
|
+
INSERT INTO products (name, store, price, url, description, model_number, release_date, ram, ssd, updated_at)
|
|
504
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
505
|
+
''', (name, store, price, url, description, model_number, release_date, ram, ssd))
|
|
506
|
+
if not msg:
|
|
507
|
+
msg = f"Saved product: {name} from {store} for {price}."
|
|
508
|
+
else:
|
|
509
|
+
msg = msg_prefix
|
|
510
|
+
|
|
511
|
+
# Send email notification for new/saved product
|
|
512
|
+
email_subject = f"Product Saved: {name}"
|
|
513
|
+
email_body = f"Action: Saved (New or Cheaper)\n\nName: {name}\nStore: {store}\nPrice: {price}\nURL: {url}\nModel: {model_number}\nRelease: {release_date}\nRAM: {ram}\nSSD: {ssd}\nDescription: {description}\n\nMessage: {msg}"
|
|
514
|
+
send_email_notification(email_subject, email_body)
|
|
515
|
+
|
|
516
|
+
if should_update:
|
|
517
|
+
# Check if anything actually changed (URL, Description, or Price string)
|
|
518
|
+
# We already know price_val is same (or higher for same store), but check text representation
|
|
519
|
+
|
|
520
|
+
# Fetch current full details
|
|
521
|
+
cursor.execute("SELECT price, url, description, model_number, release_date, ram, ssd FROM products WHERE id = ?", (current_cheapest['id'],))
|
|
522
|
+
curr_row = cursor.fetchone()
|
|
523
|
+
curr_price_str = curr_row[0]
|
|
524
|
+
curr_url = curr_row[1]
|
|
525
|
+
curr_desc = curr_row[2]
|
|
526
|
+
curr_model = curr_row[3]
|
|
527
|
+
curr_release = curr_row[4]
|
|
528
|
+
curr_ram = curr_row[5] if len(curr_row) > 5 else ""
|
|
529
|
+
curr_ssd = curr_row[6] if len(curr_row) > 6 else ""
|
|
530
|
+
|
|
531
|
+
# Handle cases where existing DB has None
|
|
532
|
+
final_model = model_number if model_number else curr_model
|
|
533
|
+
final_release = release_date if release_date else curr_release
|
|
534
|
+
final_ram = ram if ram else curr_ram
|
|
535
|
+
final_ssd = ssd if ssd else curr_ssd
|
|
536
|
+
|
|
537
|
+
# Logic to update if ANY field changed
|
|
538
|
+
if (price != curr_price_str or url != curr_url or description != curr_desc or
|
|
539
|
+
final_model != curr_model or final_release != curr_release or
|
|
540
|
+
final_ram != curr_ram or final_ssd != curr_ssd):
|
|
541
|
+
cursor.execute('''
|
|
542
|
+
UPDATE products
|
|
543
|
+
SET price = ?, url = ?, description = ?, model_number = ?, release_date = ?, ram = ?, ssd = ?, store = ?, updated_at = CURRENT_TIMESTAMP
|
|
544
|
+
WHERE id = ?
|
|
545
|
+
''', (price, url, description, final_model, final_release, final_ram, final_ssd, store, current_cheapest['id']))
|
|
546
|
+
if not msg:
|
|
547
|
+
msg = f"Updated product {name} info."
|
|
548
|
+
else:
|
|
549
|
+
msg = msg_prefix
|
|
550
|
+
|
|
551
|
+
# Send email notification for updated product
|
|
552
|
+
email_subject = f"Product Updated: {name}"
|
|
553
|
+
email_body = f"Action: Updated Info\n\nName: {name}\nStore: {store}\nPrice: {price}\nURL: {url}\nModel: {final_model}\nRelease: {final_release}\nRAM: {final_ram}\nSSD: {final_ssd}\nDescription: {description}\n\nMessage: {msg}"
|
|
554
|
+
send_email_notification(email_subject, email_body)
|
|
555
|
+
else:
|
|
556
|
+
msg = f"No changes for {name} at {store}."
|
|
557
|
+
|
|
558
|
+
# Cleanup: Ensure only 1 record exists per product name (Sanity check)
|
|
559
|
+
# This handles cases where multiple records might have existed before
|
|
560
|
+
if should_save or should_update:
|
|
561
|
+
# Re-fetch all to be sure
|
|
562
|
+
cursor.execute("SELECT id, price FROM products WHERE name = ?", (name,))
|
|
563
|
+
rows = cursor.fetchall()
|
|
564
|
+
if len(rows) > 1:
|
|
565
|
+
# Keep only the one we just touched (or the cheapest)
|
|
566
|
+
# Since we did DELETE for should_save, this is mostly for should_update cases
|
|
567
|
+
# or if concurrent writes happened (unlikely here)
|
|
568
|
+
|
|
569
|
+
# Sort by price, then by ID (newer ID usually means newer insert if logic allows)
|
|
570
|
+
rows_parsed = []
|
|
571
|
+
for r in rows:
|
|
572
|
+
rows_parsed.append({'id': r[0], 'val': parse_price_val(r[1])})
|
|
573
|
+
|
|
574
|
+
rows_parsed.sort(key=lambda x: x['val'])
|
|
575
|
+
winner = rows_parsed[0]
|
|
576
|
+
|
|
577
|
+
# Delete losers
|
|
578
|
+
for loser in rows_parsed[1:]:
|
|
579
|
+
cursor.execute("DELETE FROM products WHERE id = ?", (loser['id'],))
|
|
580
|
+
msg += " (Cleaned up duplicate records)"
|
|
581
|
+
|
|
582
|
+
conn.commit()
|
|
583
|
+
conn.close()
|
|
584
|
+
return msg
|
|
585
|
+
except Exception as e:
|
|
586
|
+
return f"Error saving product: {str(e)}"
|
|
587
|
+
|
|
588
|
+
class UpdatePricesInput(BaseModel):
|
|
589
|
+
query: str = Field(description="Optional query", default="")
|
|
590
|
+
|
|
591
|
+
class UpdatePricesTool(BaseTool):
|
|
592
|
+
name = "update_prices"
|
|
593
|
+
description = "Accesses the registered URL for each product in the database directly to check stock, price, and specs. Updates info or deletes if unavailable."
|
|
594
|
+
args_schema: Type[BaseModel] = UpdatePricesInput
|
|
595
|
+
|
|
596
|
+
def _fetch_page_content(self, url: str) -> (bool, str, str):
|
|
597
|
+
"""
|
|
598
|
+
URLにアクセスし、(成功したか, 理由/エラー, HTMLテキスト) を返す
|
|
599
|
+
"""
|
|
600
|
+
if not url or not url.startswith('http'):
|
|
601
|
+
return (False, "Invalid URL", "")
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
req = urllib.request.Request(
|
|
605
|
+
url,
|
|
606
|
+
headers={
|
|
607
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
608
|
+
'Accept-Language': 'ja,en-US;q=0.9,en;q=0.8'
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
with urllib.request.urlopen(req, timeout=15) as response:
|
|
612
|
+
html_content = response.read().decode('utf-8', errors='ignore')
|
|
613
|
+
|
|
614
|
+
# 自動アクセス防止画面(Bot確認)のチェック
|
|
615
|
+
bot_keywords = [
|
|
616
|
+
"ロボットではありません", "アクセスが制限されています", "キャプチャ", "CAPTCHA",
|
|
617
|
+
"Are you a human?", "Please verify you are a human", "Incapsula", "Cloudflare"
|
|
618
|
+
]
|
|
619
|
+
html_lower = html_content.lower()
|
|
620
|
+
for kw in bot_keywords:
|
|
621
|
+
if kw.lower() in html_lower:
|
|
622
|
+
return (False, "Bot Challenge Detected", "")
|
|
623
|
+
|
|
624
|
+
# 不要なタグを簡易的に除去してテキストを抽出 (Token節約のため)
|
|
625
|
+
# <style>, <script> の中身を削除
|
|
626
|
+
clean_text = re.sub(r'<script.*?>.*?</script>', '', html_content, flags=re.DOTALL|re.IGNORECASE)
|
|
627
|
+
clean_text = re.sub(r'<style.*?>.*?</style>', '', clean_text, flags=re.DOTALL|re.IGNORECASE)
|
|
628
|
+
# 画像のalt属性をテキストとして残す
|
|
629
|
+
clean_text = re.sub(r'<img[^>]+alt="([^"]*)"[^>]*>', r' \1 ', clean_text, flags=re.IGNORECASE)
|
|
630
|
+
clean_text = re.sub(r"<img[^>]+alt='([^']*)'[^>]*>", r' \1 ', clean_text, flags=re.IGNORECASE)
|
|
631
|
+
|
|
632
|
+
# HTMLタグを消してテキストのみに
|
|
633
|
+
clean_text = re.sub(r'<.*?>', ' ', clean_text)
|
|
634
|
+
# 余分な空白を圧縮
|
|
635
|
+
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
|
|
636
|
+
|
|
637
|
+
# LLMへの入力制限のため先頭10000文字程度に切り詰める(通常商品情報はこの辺にある)
|
|
638
|
+
if len(clean_text) > 10000:
|
|
639
|
+
clean_text = clean_text[:10000]
|
|
640
|
+
|
|
641
|
+
return (True, "Success", clean_text)
|
|
642
|
+
|
|
643
|
+
except urllib.error.HTTPError as e:
|
|
644
|
+
if e.code == 404:
|
|
645
|
+
return (False, "404 Not Found", "")
|
|
646
|
+
elif e.code == 410:
|
|
647
|
+
return (False, "410 Gone", "")
|
|
648
|
+
elif e.code in [500, 502, 503, 504]:
|
|
649
|
+
return (False, f"Retryable Server Error ({e.code})", "")
|
|
650
|
+
elif e.code == 403:
|
|
651
|
+
return (False, "403 Forbidden (Possible Bot Block)", "")
|
|
652
|
+
else:
|
|
653
|
+
return (False, f"HTTP Error {e.code}", "")
|
|
654
|
+
except urllib.error.URLError as e:
|
|
655
|
+
return (False, f"URL Error: {e.reason}", "")
|
|
656
|
+
except Exception as e:
|
|
657
|
+
return (False, f"Connection Error: {e}", "")
|
|
658
|
+
|
|
659
|
+
def _delete_product(self, product: dict, reason: str):
|
|
660
|
+
try:
|
|
661
|
+
conn = sqlite3.connect(DB_NAME)
|
|
662
|
+
cursor = conn.cursor()
|
|
663
|
+
cursor.execute("DELETE FROM products WHERE id = ?", (product['id'],))
|
|
664
|
+
conn.commit()
|
|
665
|
+
conn.close()
|
|
666
|
+
|
|
667
|
+
msg = f"Deleted {product['name']} at {product['store']} ({reason})"
|
|
668
|
+
print(f" {msg}")
|
|
669
|
+
|
|
670
|
+
email_subject = f"Product Deleted: {product['name']}"
|
|
671
|
+
email_body = f"Action: Deleted (Unavailable/Not Found)\n\nName: {product['name']}\nStore: {product['store']}\nURL: {product['url']}\nReason: {reason}\n\nMessage: {msg}"
|
|
672
|
+
send_email_notification(email_subject, email_body)
|
|
673
|
+
return True
|
|
674
|
+
except Exception as e:
|
|
675
|
+
print(f" Error deleting product: {e}")
|
|
676
|
+
return False
|
|
677
|
+
|
|
678
|
+
def _run(self, query: str = "", **kwargs):
|
|
679
|
+
print("\n--- Starting Price Update (Direct URL Access) ---")
|
|
680
|
+
products = get_all_products()
|
|
681
|
+
if not products:
|
|
682
|
+
return "No products in database to update."
|
|
683
|
+
|
|
684
|
+
model_name = os.getenv("MODEL_NAME", "gemini-2.0-flash")
|
|
685
|
+
llm = ChatGoogleGenerativeAI(model=model_name, temperature=0)
|
|
686
|
+
|
|
687
|
+
updated_count = 0
|
|
688
|
+
deleted_count = 0
|
|
689
|
+
|
|
690
|
+
for p in products:
|
|
691
|
+
name = p['name']
|
|
692
|
+
store = p['store']
|
|
693
|
+
url = p['url']
|
|
694
|
+
|
|
695
|
+
print(f"Checking: {name} at {store} (ID: {p['id']})")
|
|
696
|
+
|
|
697
|
+
if not url:
|
|
698
|
+
print(f" [Warning] No URL for this product. Skipping.")
|
|
699
|
+
continue
|
|
700
|
+
|
|
701
|
+
success, access_reason, page_text = self._fetch_page_content(url)
|
|
702
|
+
|
|
703
|
+
if not success:
|
|
704
|
+
# 明確な「存在しない」エラー(404, 410, Invalid URL)の場合のみ削除
|
|
705
|
+
if "404 Not Found" in access_reason or "410 Gone" in access_reason or "Invalid URL" in access_reason:
|
|
706
|
+
print(f" [Info] URL is dead ({access_reason}). Deleting product.")
|
|
707
|
+
if self._delete_product(p, access_reason):
|
|
708
|
+
deleted_count += 1
|
|
709
|
+
else:
|
|
710
|
+
# 503などのリトライ可能なエラーや、Bot検知(403/CAPTCHA)などの場合は削除せずスキップ
|
|
711
|
+
print(f" [Warning] Skipping update due to temporary/access error: {access_reason}")
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
# アクセスできた場合は、LLMにテキストを渡して判定させる
|
|
715
|
+
prompt = f"""
|
|
716
|
+
以下のテキストは、ある商品のウェブページから抽出した内容です。
|
|
717
|
+
このページの内容を分析し、以下のタスクを行ってください。
|
|
718
|
+
|
|
719
|
+
対象商品名: {name}
|
|
720
|
+
対象店舗: {store}
|
|
721
|
+
現在の価格: {p['price']}
|
|
722
|
+
|
|
723
|
+
抽出テキスト:
|
|
724
|
+
{page_text}
|
|
725
|
+
|
|
726
|
+
タスク:
|
|
727
|
+
1. このページで対象商品が現在も「販売中」かつ「在庫がある」か判定してください。
|
|
728
|
+
※「販売終了」「お探しのページは見つかりません」「在庫なし」「在庫切れ」「取り扱いできません」「該当の商品がありません」などの明確な記載(テキストや画像の代替テキスト(alt属性)含む)がある場合は is_unavailable を true にしてください。
|
|
729
|
+
※商品とは無関係な別商品の在庫情報に騙されないでください。
|
|
730
|
+
2. 販売中である場合、最新の「価格」「詳細情報・スペック(description)」「型番(model_number)」「発売日(release_date)」「メモリ容量(ram)」「SSD容量(ssd)」を抽出してください。発売日は数字とハイフンのみの日付にしてください(例: 2023-10-01)。型番は日付ではなくメーカー名やシリーズ名を含む英数字の文字列を抽出してください。もし発売日と混同されるような表記や数字とハイフンのみであれば、型番として抽出しないでください。
|
|
731
|
+
見つからない項目は空文字("")にしてください。
|
|
732
|
+
※注意: メモリ(RAM)やSSDなどの容量を抽出する際は、「最大〇〇GB」「〇〇GBまで増設可能」といった拡張上限の数値は対象外とし、必ず「標準搭載の容量」を抽出・記載してください。
|
|
733
|
+
※RAM容量の単位はGBに統一してください(例: 16384MB -> 16GB)。また、RAM容量やSSD容量に複数候補がある場合は、/(スラッシュ)区切りで保存してください(例: 256GB/512GB/1TB)。
|
|
734
|
+
|
|
735
|
+
JSON形式で返してください。
|
|
736
|
+
出力例:
|
|
737
|
+
{{
|
|
738
|
+
"is_unavailable": false,
|
|
739
|
+
"unavailability_reason": "",
|
|
740
|
+
"price": "10,500円",
|
|
741
|
+
"description": "最新モデル、送料無料",
|
|
742
|
+
"model_number": "ABC-123",
|
|
743
|
+
"release_date": "2023-10-01",
|
|
744
|
+
"ram": "16GB",
|
|
745
|
+
"ssd": "512GB"
|
|
746
|
+
}}
|
|
747
|
+
"""
|
|
748
|
+
|
|
749
|
+
try:
|
|
750
|
+
response = llm.invoke(prompt)
|
|
751
|
+
content = response.content
|
|
752
|
+
if "```json" in content:
|
|
753
|
+
content = content.split("```json")[1].split("```")[0]
|
|
754
|
+
elif "```" in content:
|
|
755
|
+
content = content.split("```")[1].split("```")[0]
|
|
756
|
+
|
|
757
|
+
content = content.strip()
|
|
758
|
+
# Invalid \escape を防ぐため、バックスラッシュをエスケープする
|
|
759
|
+
# ただし、すでに正しくエスケープされている \" や \n などは壊さないようにする簡易的な対処
|
|
760
|
+
# json.loads(..., strict=False) を使いつつ、明らかな不正バックスラッシュを置換
|
|
761
|
+
content = re.sub(r'\\(?![/"\\bfnrtu])', r'\\\\', content)
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
result_data = json.loads(content, strict=False)
|
|
765
|
+
except json.JSONDecodeError as e:
|
|
766
|
+
print(f" [Warning] Failed to parse JSON: {e}. Attempting further cleanup.")
|
|
767
|
+
# 最後の手段として、バックスラッシュを全て消す
|
|
768
|
+
content = content.replace('\\', '')
|
|
769
|
+
result_data = json.loads(content, strict=False)
|
|
770
|
+
|
|
771
|
+
is_unavailable = result_data.get("is_unavailable", False)
|
|
772
|
+
unavailability_reason = result_data.get("unavailability_reason", "ページ内に在庫なし・販売終了の記載あり")
|
|
773
|
+
|
|
774
|
+
if is_unavailable:
|
|
775
|
+
print(f" [Info] LLM determined product is unavailable. Reason: {unavailability_reason}. Deleting product.")
|
|
776
|
+
if self._delete_product(p, unavailability_reason):
|
|
777
|
+
deleted_count += 1
|
|
778
|
+
else:
|
|
779
|
+
# 更新処理
|
|
780
|
+
new_price = result_data.get("price", "")
|
|
781
|
+
new_desc = result_data.get("description", "")
|
|
782
|
+
new_model = result_data.get("model_number", "")
|
|
783
|
+
new_release = result_data.get("release_date", "")
|
|
784
|
+
new_ram = result_data.get("ram", "")
|
|
785
|
+
new_ssd = result_data.get("ssd", "")
|
|
786
|
+
|
|
787
|
+
if not new_price:
|
|
788
|
+
# 価格が取れない=おそらく商品ページではない/在庫なしとして扱う場合もあるが、とりあえず今回はスキップ
|
|
789
|
+
print(f" [Warning] Could not extract price from page. Skipping update.")
|
|
790
|
+
continue
|
|
791
|
+
|
|
792
|
+
final_desc = new_desc if new_desc else p['description']
|
|
793
|
+
final_model = new_model if new_model else p.get('model_number', "")
|
|
794
|
+
final_release = new_release if new_release else p.get('release_date', "")
|
|
795
|
+
final_ram = new_ram if new_ram else p.get('ram', "")
|
|
796
|
+
final_ssd = new_ssd if new_ssd else p.get('ssd', "")
|
|
797
|
+
|
|
798
|
+
# 変更箇所の特定
|
|
799
|
+
changes = []
|
|
800
|
+
|
|
801
|
+
# 価格は数字のみを抽出して比較する (表記ゆれによる更新を防ぐため)
|
|
802
|
+
new_price_val = parse_price_val(new_price)
|
|
803
|
+
old_price_val = parse_price_val(p['price'])
|
|
804
|
+
|
|
805
|
+
if new_price_val != old_price_val:
|
|
806
|
+
changes.append(f"Price ({p['price']} -> {new_price})")
|
|
807
|
+
else:
|
|
808
|
+
# 価格の数値が同じなら、元の文字列のままにして不要な更新を防ぐ
|
|
809
|
+
new_price = p['price']
|
|
810
|
+
|
|
811
|
+
# スペック情報も英数字のみで比較し、表記ゆれを無視する
|
|
812
|
+
old_model = p.get('model_number', "")
|
|
813
|
+
if not is_similar_model(final_model, old_model):
|
|
814
|
+
changes.append(f"Model ({old_model} -> {final_model})")
|
|
815
|
+
else:
|
|
816
|
+
# 包含関係があれば元のデータを正として維持する(不要な更新を防ぐ)
|
|
817
|
+
# ただし、新しい方が情報が多い(長い)場合は新しい方を採用するアプローチもあるが、
|
|
818
|
+
# 今回は「更新なし」と見なすため既存の値を保持する
|
|
819
|
+
final_model = old_model
|
|
820
|
+
|
|
821
|
+
old_release = p.get('release_date', "")
|
|
822
|
+
if parse_date_val(final_release) != parse_date_val(old_release):
|
|
823
|
+
changes.append(f"Release Date ({old_release} -> {final_release})")
|
|
824
|
+
else:
|
|
825
|
+
final_release = old_release
|
|
826
|
+
|
|
827
|
+
old_ram = p.get('ram', "")
|
|
828
|
+
if extract_alphanumeric(final_ram) != extract_alphanumeric(old_ram):
|
|
829
|
+
changes.append(f"RAM ({old_ram} -> {final_ram})")
|
|
830
|
+
else:
|
|
831
|
+
final_ram = old_ram
|
|
832
|
+
|
|
833
|
+
old_ssd = p.get('ssd', "")
|
|
834
|
+
if extract_alphanumeric(final_ssd) != extract_alphanumeric(old_ssd):
|
|
835
|
+
changes.append(f"SSD ({old_ssd} -> {final_ssd})")
|
|
836
|
+
else:
|
|
837
|
+
final_ssd = old_ssd
|
|
838
|
+
|
|
839
|
+
# 詳細情報の変更はトリガーとして扱わないが、他の項目が更新されたらついでに上書きする
|
|
840
|
+
# if final_desc != p['description']: ...
|
|
841
|
+
|
|
842
|
+
if changes:
|
|
843
|
+
try:
|
|
844
|
+
conn = sqlite3.connect(DB_NAME)
|
|
845
|
+
cursor = conn.cursor()
|
|
846
|
+
cursor.execute('''
|
|
847
|
+
UPDATE products
|
|
848
|
+
SET price = ?, description = ?, model_number = ?, release_date = ?, ram = ?, ssd = ?, updated_at = CURRENT_TIMESTAMP
|
|
849
|
+
WHERE id = ?
|
|
850
|
+
''', (new_price, final_desc, final_model, final_release, final_ram, final_ssd, p['id']))
|
|
851
|
+
conn.commit()
|
|
852
|
+
|
|
853
|
+
if cursor.rowcount > 0:
|
|
854
|
+
updated_count += 1
|
|
855
|
+
changes_str = ", ".join(changes)
|
|
856
|
+
msg = f"Updated {name} at {store}. Changes: {changes_str}"
|
|
857
|
+
print(f" {msg}")
|
|
858
|
+
|
|
859
|
+
email_subject = f"Product Updated: {name}"
|
|
860
|
+
email_body = f"Action: Updated Info (Direct URL Check)\n\nName: {name}\nStore: {store}\nURL: {url}\n\nChanged Fields:\n{changes_str}\n\n--- Current Data ---\nPrice: {new_price}\nModel: {final_model}\nRelease: {final_release}\nRAM: {final_ram}\nSSD: {final_ssd}\nDescription: {final_desc}\n\nMessage: {msg}"
|
|
861
|
+
send_email_notification(email_subject, email_body)
|
|
862
|
+
conn.close()
|
|
863
|
+
except Exception as e:
|
|
864
|
+
print(f" Error updating {name} at {store}: {e}")
|
|
865
|
+
else:
|
|
866
|
+
print(f" No spec/price changes for {name} at {store}.")
|
|
867
|
+
|
|
868
|
+
except Exception as e:
|
|
869
|
+
print(f" Error processing LLM response for {name}: {e}")
|
|
870
|
+
|
|
871
|
+
time.sleep(1) # API/Webへの負荷軽減
|
|
872
|
+
|
|
873
|
+
return f"Price update complete. Updated {updated_count} items, Deleted {deleted_count} unavailable items."
|
|
874
|
+
|
|
875
|
+
class SearchProductsInput(BaseModel):
|
|
876
|
+
query: str = Field(description="Natural language query to search products in the database")
|
|
877
|
+
|
|
878
|
+
class SearchProductsTool(BaseTool):
|
|
879
|
+
name = "search_products"
|
|
880
|
+
description = "Searches for products in the database using natural language queries (e.g., 'cheapest products', 'items with 16GB memory')."
|
|
881
|
+
args_schema: Type[BaseModel] = SearchProductsInput
|
|
882
|
+
|
|
883
|
+
def _run(self, query: str, **kwargs):
|
|
884
|
+
print(f"\n--- Searching Database: {query} ---")
|
|
885
|
+
|
|
886
|
+
# 1. Use LLM to understand intent
|
|
887
|
+
model_name = os.getenv("MODEL_NAME", "gemini-2.0-flash")
|
|
888
|
+
llm = ChatGoogleGenerativeAI(model=model_name, temperature=0)
|
|
889
|
+
|
|
890
|
+
prompt = f"""
|
|
891
|
+
Analyze the user's search query for products and extract search criteria.
|
|
892
|
+
|
|
893
|
+
User Query: {query}
|
|
894
|
+
|
|
895
|
+
Return a JSON object with the following keys:
|
|
896
|
+
- keyword_groups: List of LISTS of keywords. Each inner list represents synonyms (OR condition), and all outer lists must be satisfied (AND condition).
|
|
897
|
+
- Example for "Cheap Mouse": [["mouse", "マウス"]] (Price is handled by sort_by)
|
|
898
|
+
- Example for "16GB Memory": [["16GB", "16G", "16ギガ"], ["memory", "メモリ"]] -> "memory" is often redundant if "16GB" is unique, so prefer specific specs.
|
|
899
|
+
- Example for "32GB PC": [["32GB", "32G"], ["PC", "パソコン", "computer"]]
|
|
900
|
+
- exclude_keywords: List of keywords that MUST NOT appear in the product info (name, description, url).
|
|
901
|
+
- Example for "No SSD": ["SSD"]
|
|
902
|
+
- Example for "exclude memory info": ["memory", "メモリ"]
|
|
903
|
+
- empty_fields: List of field names that must be empty or null (e.g. for "no description", "desc is empty", "url not set").
|
|
904
|
+
- Valid values: "name", "price", "store", "url", "description"
|
|
905
|
+
- sort_by: "price_asc" (cheapest), "price_desc" (expensive), or null (relevance)
|
|
906
|
+
- max_price: integer or null
|
|
907
|
+
- min_price: integer or null
|
|
908
|
+
|
|
909
|
+
Important Rules for Keywords extraction:
|
|
910
|
+
1. Exclude Metadata Field Names: NEVER include words that refer to database columns like "URL", "url", "price", "name", "title", "description", "store" in `keyword_groups`.
|
|
911
|
+
- CORRECT: "URL with example" -> [["example"]]
|
|
912
|
+
- WRONG: "URL with example" -> [["URL"], ["example"]]
|
|
913
|
+
- CORRECT: "URLにexampleが含まれる" -> [["example"]]
|
|
914
|
+
2. Exclude Action Verbs: Do not include "search", "find", "探して", "検索", "教えて".
|
|
915
|
+
3. Exclude General Terms: Do not include "product", "item", "thing", "もの", "商品".
|
|
916
|
+
4. If the query implies a category (e.g. "PC"), include it as a keyword group.
|
|
917
|
+
|
|
918
|
+
Example JSON:
|
|
919
|
+
{{
|
|
920
|
+
"keyword_groups": [["mouse", "マウス"]],
|
|
921
|
+
"exclude_keywords": [],
|
|
922
|
+
"empty_fields": [],
|
|
923
|
+
"sort_by": "price_asc",
|
|
924
|
+
"max_price": 5000,
|
|
925
|
+
"min_price": null
|
|
926
|
+
}}
|
|
927
|
+
"""
|
|
928
|
+
|
|
929
|
+
try:
|
|
930
|
+
response = llm.invoke(prompt)
|
|
931
|
+
content = response.content.strip()
|
|
932
|
+
if "```json" in content:
|
|
933
|
+
content = content.split("```json")[1].split("```")[0]
|
|
934
|
+
elif "```" in content:
|
|
935
|
+
content = content.split("```")[1].split("```")[0]
|
|
936
|
+
|
|
937
|
+
criteria = json.loads(content)
|
|
938
|
+
print(f" Search Criteria: {criteria}")
|
|
939
|
+
|
|
940
|
+
# 2. Fetch all products and filter in Python
|
|
941
|
+
all_products = get_all_products()
|
|
942
|
+
filtered_products = []
|
|
943
|
+
|
|
944
|
+
keyword_groups = criteria.get('keyword_groups', [])
|
|
945
|
+
exclude_keywords = criteria.get('exclude_keywords', [])
|
|
946
|
+
empty_fields = criteria.get('empty_fields', [])
|
|
947
|
+
sort_by = criteria.get('sort_by')
|
|
948
|
+
max_p = criteria.get('max_price')
|
|
949
|
+
min_p = criteria.get('min_price')
|
|
950
|
+
|
|
951
|
+
for p in all_products:
|
|
952
|
+
text_to_search = (p['name'] + " " + (p['description'] or "") + " " + (p['url'] or "")).lower()
|
|
953
|
+
|
|
954
|
+
# 2.0 Empty Field Filter
|
|
955
|
+
if empty_fields:
|
|
956
|
+
is_empty_match = True
|
|
957
|
+
for field in empty_fields:
|
|
958
|
+
val = p.get(field)
|
|
959
|
+
if val and str(val).strip(): # if value exists and is not empty
|
|
960
|
+
is_empty_match = False
|
|
961
|
+
break
|
|
962
|
+
if not is_empty_match:
|
|
963
|
+
continue
|
|
964
|
+
|
|
965
|
+
# 2.0 Exclude Filter
|
|
966
|
+
if exclude_keywords:
|
|
967
|
+
should_exclude = False
|
|
968
|
+
for k in exclude_keywords:
|
|
969
|
+
if k.lower() in text_to_search:
|
|
970
|
+
should_exclude = True
|
|
971
|
+
break
|
|
972
|
+
if should_exclude:
|
|
973
|
+
continue
|
|
974
|
+
|
|
975
|
+
# 2.1 Keyword Match (AND of ORs logic)
|
|
976
|
+
if keyword_groups:
|
|
977
|
+
all_groups_match = True
|
|
978
|
+
for group in keyword_groups:
|
|
979
|
+
# Check if ANY keyword in this group matches (OR)
|
|
980
|
+
group_match = False
|
|
981
|
+
for k in group:
|
|
982
|
+
if k.lower() in text_to_search:
|
|
983
|
+
group_match = True
|
|
984
|
+
break
|
|
985
|
+
if not group_match:
|
|
986
|
+
all_groups_match = False
|
|
987
|
+
break
|
|
988
|
+
|
|
989
|
+
if not all_groups_match:
|
|
990
|
+
continue
|
|
991
|
+
|
|
992
|
+
# 2.2 Price Filter
|
|
993
|
+
price_val = parse_price_val(p['price'])
|
|
994
|
+
if max_p is not None and price_val > max_p:
|
|
995
|
+
continue
|
|
996
|
+
if min_p is not None and price_val < min_p:
|
|
997
|
+
continue
|
|
998
|
+
|
|
999
|
+
# Add price_val for sorting
|
|
1000
|
+
p['price_val'] = price_val
|
|
1001
|
+
filtered_products.append(p)
|
|
1002
|
+
|
|
1003
|
+
# 3. Sort
|
|
1004
|
+
if sort_by == "price_asc":
|
|
1005
|
+
filtered_products.sort(key=lambda x: x['price_val'])
|
|
1006
|
+
elif sort_by == "price_desc":
|
|
1007
|
+
filtered_products.sort(key=lambda x: x['price_val'], reverse=True)
|
|
1008
|
+
|
|
1009
|
+
if not filtered_products:
|
|
1010
|
+
return "No products found matching your criteria."
|
|
1011
|
+
|
|
1012
|
+
# 4. Format Results
|
|
1013
|
+
result_str = f"Found {len(filtered_products)} products:\n"
|
|
1014
|
+
# Limit results
|
|
1015
|
+
for p in filtered_products[:10]:
|
|
1016
|
+
result_str += f"- [ID: {p['id']}] {p['name']} ({p['price']}) @ {p['store']}\n"
|
|
1017
|
+
if p['description']:
|
|
1018
|
+
result_str += f" Desc: {p['description'][:100]}...\n"
|
|
1019
|
+
if p['url']:
|
|
1020
|
+
result_str += f" URL: {p['url']}\n"
|
|
1021
|
+
result_str += "\n"
|
|
1022
|
+
|
|
1023
|
+
return result_str
|
|
1024
|
+
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
return f"Error executing search: {e}"
|
|
1027
|
+
|
|
1028
|
+
class FindSimilarProductsInput(BaseModel):
|
|
1029
|
+
query: str = Field(description="Optional query", default="")
|
|
1030
|
+
|
|
1031
|
+
class FindSimilarProductsTool(BaseTool):
|
|
1032
|
+
name = "find_similar_products"
|
|
1033
|
+
description = "Searches for similar products to those in the database and adds the best ones if found."
|
|
1034
|
+
args_schema: Type[BaseModel] = FindSimilarProductsInput
|
|
1035
|
+
agent_logs: str = ""
|
|
1036
|
+
|
|
1037
|
+
def _run(self, query: str = "", **kwargs):
|
|
1038
|
+
print("\n--- Starting Similar Product Search ---")
|
|
1039
|
+
products = get_all_products()
|
|
1040
|
+
if not products:
|
|
1041
|
+
return "No products in database to base search on."
|
|
1042
|
+
|
|
1043
|
+
# Filter products based on query if provided
|
|
1044
|
+
target_products = []
|
|
1045
|
+
if query:
|
|
1046
|
+
print(f"Filtering products with query: {query}")
|
|
1047
|
+
query_lower = query.lower()
|
|
1048
|
+
for p in products:
|
|
1049
|
+
if query_lower in p['name'].lower() or \
|
|
1050
|
+
(p['description'] and query_lower in p['description'].lower()):
|
|
1051
|
+
target_products.append(p)
|
|
1052
|
+
else:
|
|
1053
|
+
target_products = products
|
|
1054
|
+
|
|
1055
|
+
if not target_products:
|
|
1056
|
+
return f"No products found matching query '{query}'."
|
|
1057
|
+
|
|
1058
|
+
# Deduplicate by name but keep product data
|
|
1059
|
+
unique_products = {}
|
|
1060
|
+
for p in target_products:
|
|
1061
|
+
if p['name'] not in unique_products:
|
|
1062
|
+
unique_products[p['name']] = p
|
|
1063
|
+
|
|
1064
|
+
target_names = list(unique_products.keys())
|
|
1065
|
+
print(f"Found {len(target_names)} target products to find similar items for.")
|
|
1066
|
+
|
|
1067
|
+
# Use the pre-loaded logs if available
|
|
1068
|
+
logs_context = ""
|
|
1069
|
+
if self.agent_logs:
|
|
1070
|
+
logs_context = f"\n参考情報 (過去の検索履歴):\n{self.agent_logs}\n"
|
|
1071
|
+
|
|
1072
|
+
search = get_search_tool_func()
|
|
1073
|
+
model_name = os.getenv("MODEL_NAME", "gemini-2.0-flash")
|
|
1074
|
+
llm = ChatGoogleGenerativeAI(model=model_name, temperature=0)
|
|
1075
|
+
save_tool = SaveProductTool()
|
|
1076
|
+
|
|
1077
|
+
cached_similar_items = []
|
|
1078
|
+
|
|
1079
|
+
# Limit to avoid too many API calls if many products matched
|
|
1080
|
+
max_targets = 5
|
|
1081
|
+
if len(target_names) > max_targets:
|
|
1082
|
+
print(f"Limiting search to first {max_targets} products.")
|
|
1083
|
+
target_names = target_names[:max_targets]
|
|
1084
|
+
|
|
1085
|
+
for name in target_names:
|
|
1086
|
+
product_data = unique_products[name]
|
|
1087
|
+
description = product_data.get('description', '')
|
|
1088
|
+
|
|
1089
|
+
print(f"Searching for similar items to: {name}")
|
|
1090
|
+
search_query = f"{name} 類似商品 おすすめ 比較 スペック"
|
|
1091
|
+
try:
|
|
1092
|
+
search_results = search.run(search_query)
|
|
1093
|
+
except Exception as e:
|
|
1094
|
+
print(f"Search failed for {name}: {e}")
|
|
1095
|
+
continue
|
|
1096
|
+
|
|
1097
|
+
prompt = f"""
|
|
1098
|
+
以下の検索結果に基づいて、"{name}" に類似した、または競合する製品を抽出してください。
|
|
1099
|
+
データベースに既に存在する "{name}" は除外してください。
|
|
1100
|
+
|
|
1101
|
+
【重要】選定基準:
|
|
1102
|
+
基準となる商品情報:
|
|
1103
|
+
名前: {name}
|
|
1104
|
+
詳細: {description}
|
|
1105
|
+
|
|
1106
|
+
上記の基準商品と比較して、「スペック(CPU、メモリ、ストレージ、機能など)が同等かそれ以上」の製品のみを厳選してください。
|
|
1107
|
+
基準商品より明らかにスペックが劣る製品(例: 古い世代のCPU、少ないメモリ、低い解像度など)は絶対に含めないでください。
|
|
1108
|
+
価格が安くてもスペックが低いものは除外します。
|
|
1109
|
+
|
|
1110
|
+
{logs_context}
|
|
1111
|
+
検索結果:
|
|
1112
|
+
{search_results}
|
|
1113
|
+
|
|
1114
|
+
タスク:
|
|
1115
|
+
条件に合う製品の 名前、価格、販売店舗、URL、簡単な説明、型番(model_number)、発売日(release_date) を抽出してJSONリストで返してください。型番や発売日が不明な場合は空文字列にしてください。
|
|
1116
|
+
|
|
1117
|
+
重要:
|
|
1118
|
+
- URLと詳細情報は必須です。URLは必ず http または https で始まる有効なものにしてください。これらが見つからない、または取得できない場合は、その商品はスキップしてください。
|
|
1119
|
+
- 価格が不明な場合、または商品名や価格に(例)などと記載されている場合もスキップしてください。
|
|
1120
|
+
- 【必須条件】検索結果のスニペットやページ内に「販売終了」「お探しのページは見つかりません」「404 Not Found」「この商品は現在お取り扱いできません」のいずれかが含まれている場合は、その商品は「無効」とみなし、絶対にリストに含めないでください(スキップしてください)。
|
|
1121
|
+
- メモリ(RAM)やSSDなどの容量を説明に含める際、「最大〇〇GB」「〇〇GBまで増設可能」といった拡張上限の数値は対象外とし、必ず「標準搭載の容量」を抽出してください。
|
|
1122
|
+
|
|
1123
|
+
JSON出力例:
|
|
1124
|
+
[
|
|
1125
|
+
{{
|
|
1126
|
+
"name": "競合商品A",
|
|
1127
|
+
"store": "Amazon",
|
|
1128
|
+
"price": "5,000円",
|
|
1129
|
+
"url": "https://www.amazon.co.jp/...",
|
|
1130
|
+
"description": "商品Aの類似品。機能X搭載。",
|
|
1131
|
+
"model_number": "XYZ-999",
|
|
1132
|
+
"release_date": "2024-01-15"
|
|
1133
|
+
}}
|
|
1134
|
+
]
|
|
1135
|
+
"""
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
response = llm.invoke(prompt)
|
|
1139
|
+
content = response.content
|
|
1140
|
+
if "```json" in content:
|
|
1141
|
+
content = content.split("```json")[1].split("```")[0]
|
|
1142
|
+
elif "```" in content:
|
|
1143
|
+
content = content.split("```")[1].split("```")[0]
|
|
1144
|
+
|
|
1145
|
+
items = json.loads(content)
|
|
1146
|
+
if isinstance(items, list):
|
|
1147
|
+
for item in items:
|
|
1148
|
+
# Avoid adding the original product itself
|
|
1149
|
+
if item.get('name') == name:
|
|
1150
|
+
continue
|
|
1151
|
+
cached_similar_items.append(item)
|
|
1152
|
+
print(f" Cached: {item.get('name')} ({item.get('price')})")
|
|
1153
|
+
|
|
1154
|
+
except Exception as e:
|
|
1155
|
+
print(f"Error processing similar items for {name}: {e}")
|
|
1156
|
+
|
|
1157
|
+
time.sleep(1)
|
|
1158
|
+
|
|
1159
|
+
if not cached_similar_items:
|
|
1160
|
+
return "No similar products found."
|
|
1161
|
+
|
|
1162
|
+
print(f"\nCached {len(cached_similar_items)} items. Selecting top 3 recommendations...")
|
|
1163
|
+
|
|
1164
|
+
# Select top 3 recommendations from cache
|
|
1165
|
+
selection_prompt = f"""
|
|
1166
|
+
以下の類似商品リストから、最もおすすめの製品を最大3つ選んでください。
|
|
1167
|
+
選定基準:
|
|
1168
|
+
1. 元の商品と同等かそれ以上の性能・品質であること。
|
|
1169
|
+
2. 価格と性能のバランスが良いこと。
|
|
1170
|
+
3. 詳細情報が豊富であること。
|
|
1171
|
+
|
|
1172
|
+
{logs_context}
|
|
1173
|
+
|
|
1174
|
+
候補リスト:
|
|
1175
|
+
{json.dumps(cached_similar_items, ensure_ascii=False, indent=2)}
|
|
1176
|
+
|
|
1177
|
+
タスク:
|
|
1178
|
+
選定した3つの商品をJSONリスト形式で返してください。形式は入力と同じです。
|
|
1179
|
+
"""
|
|
1180
|
+
|
|
1181
|
+
added_count = 0
|
|
1182
|
+
try:
|
|
1183
|
+
response = llm.invoke(selection_prompt)
|
|
1184
|
+
content = response.content
|
|
1185
|
+
if "```json" in content:
|
|
1186
|
+
content = content.split("```json")[1].split("```")[0]
|
|
1187
|
+
elif "```" in content:
|
|
1188
|
+
content = content.split("```")[1].split("```")[0]
|
|
1189
|
+
|
|
1190
|
+
top_picks = json.loads(content)
|
|
1191
|
+
|
|
1192
|
+
if isinstance(top_picks, list):
|
|
1193
|
+
for item in top_picks:
|
|
1194
|
+
print(f" Saving recommendation: {item.get('name')}")
|
|
1195
|
+
res = save_tool._run(
|
|
1196
|
+
name=item.get('name'),
|
|
1197
|
+
store=item.get('store', 'Unknown'),
|
|
1198
|
+
price=item.get('price'),
|
|
1199
|
+
url=item.get('url', ''),
|
|
1200
|
+
description=item.get('description', ''),
|
|
1201
|
+
model_number=item.get('model_number', ''),
|
|
1202
|
+
release_date=item.get('release_date', '')
|
|
1203
|
+
)
|
|
1204
|
+
print(f" -> {res}")
|
|
1205
|
+
added_count += 1
|
|
1206
|
+
except Exception as e:
|
|
1207
|
+
return f"Error selecting top recommendations: {e}"
|
|
1208
|
+
|
|
1209
|
+
return f"Similar product search complete. Added {added_count} recommended items."
|
|
1210
|
+
|
|
1211
|
+
class CompareProductsInput(BaseModel):
|
|
1212
|
+
query: str = Field(description="Optional category or query to filter products for comparison (e.g., 'laptop', 'monitor').", default="")
|
|
1213
|
+
|
|
1214
|
+
class CompareProductsTool(BaseTool):
|
|
1215
|
+
name = "compare_products"
|
|
1216
|
+
description = "Generates a comparison table of products (e.g. RAM, SSD, Price) and ranks them by recommendation. Saves the result as JSON."
|
|
1217
|
+
args_schema: Type[BaseModel] = CompareProductsInput
|
|
1218
|
+
|
|
1219
|
+
def _run(self, query: str = "", **kwargs):
|
|
1220
|
+
print(f"\n--- Generating Product Comparison: {query} ---")
|
|
1221
|
+
|
|
1222
|
+
products = get_all_products()
|
|
1223
|
+
if not products:
|
|
1224
|
+
return "No products found in database."
|
|
1225
|
+
|
|
1226
|
+
# Filter if query is provided
|
|
1227
|
+
target_products = []
|
|
1228
|
+
if query:
|
|
1229
|
+
query_lower = query.lower()
|
|
1230
|
+
for p in products:
|
|
1231
|
+
text = (p['name'] + " " + (p['description'] or "")).lower()
|
|
1232
|
+
if query_lower in text:
|
|
1233
|
+
target_products.append(p)
|
|
1234
|
+
else:
|
|
1235
|
+
target_products = products
|
|
1236
|
+
|
|
1237
|
+
if not target_products:
|
|
1238
|
+
return f"No products found matching '{query}'."
|
|
1239
|
+
|
|
1240
|
+
# 出力トークン切れを防ぐため、チャンクサイズを小さくする
|
|
1241
|
+
CHUNK_SIZE = 5
|
|
1242
|
+
print(f"Step 1: Extracting specs from {len(target_products)} products in chunks of {CHUNK_SIZE}...")
|
|
1243
|
+
|
|
1244
|
+
model_name = os.getenv("MODEL_NAME", "gemini-2.0-flash")
|
|
1245
|
+
llm = ChatGoogleGenerativeAI(model=model_name, temperature=0, max_output_tokens=8192)
|
|
1246
|
+
|
|
1247
|
+
extracted_specs = []
|
|
1248
|
+
|
|
1249
|
+
try:
|
|
1250
|
+
for i in range(0, len(target_products), CHUNK_SIZE):
|
|
1251
|
+
chunk = target_products[i:i + CHUNK_SIZE]
|
|
1252
|
+
print(f" Processing chunk {i//CHUNK_SIZE + 1} ({len(chunk)} items)...")
|
|
1253
|
+
|
|
1254
|
+
# Build prompt for LLM to extract specs only
|
|
1255
|
+
prompt_extract = f"""
|
|
1256
|
+
以下の製品リストから、各製品の主要スペック情報を抽出してください。
|
|
1257
|
+
|
|
1258
|
+
製品リスト:
|
|
1259
|
+
{json.dumps([{k: v for k, v in p.items() if k != 'updated_at'} for p in chunk], ensure_ascii=False, indent=2)}
|
|
1260
|
+
|
|
1261
|
+
タスク:
|
|
1262
|
+
各製品について以下の情報を抽出し、JSONリスト形式で出力してください:
|
|
1263
|
+
1. id: 元の製品ID (必須)
|
|
1264
|
+
2. name: 製品名
|
|
1265
|
+
3. price: 価格 (そのまま)
|
|
1266
|
+
4. url: 製品ページのURL
|
|
1267
|
+
5. ram: メモリ容量 (例: "16GB", "8GB", 不明なら "-") ※「最大〇〇GB」「増設可能」は無視し、標準搭載量のみを抽出すること。
|
|
1268
|
+
6. ssd: ストレージ容量 (例: "512GB", "1TB", 不明なら "-") ※「最大〇〇GB」「増設可能」は無視し、標準搭載量のみを抽出すること。
|
|
1269
|
+
7. cpu: プロセッサ (例: "Core i5", "M2", 不明なら "-")
|
|
1270
|
+
8. os: OSの種類 (例: "Windows 11", "macOS", "ChromeOS", 不明なら "-")
|
|
1271
|
+
9. model_number: 型番 (不明なら "-")
|
|
1272
|
+
10. release_date: 発売日 (不明なら "-")
|
|
1273
|
+
|
|
1274
|
+
出力はJSONのみとし、Markdownコードブロックで囲ってください。
|
|
1275
|
+
"""
|
|
1276
|
+
|
|
1277
|
+
response = llm.invoke(prompt_extract)
|
|
1278
|
+
content = response.content
|
|
1279
|
+
if "```json" in content:
|
|
1280
|
+
content = content.split("```json")[1].split("```")[0]
|
|
1281
|
+
elif "```" in content:
|
|
1282
|
+
content = content.split("```")[1].split("```")[0]
|
|
1283
|
+
|
|
1284
|
+
content = content.strip()
|
|
1285
|
+
try:
|
|
1286
|
+
chunk_data = json.loads(content)
|
|
1287
|
+
except json.JSONDecodeError as e:
|
|
1288
|
+
print(f" [Warning] Failed to parse JSON in chunk {i//CHUNK_SIZE + 1}. Attempting aggressive recovery.")
|
|
1289
|
+
print(f" Error detail: {e}")
|
|
1290
|
+
|
|
1291
|
+
# 途中で切れている場合の強引な復旧処理
|
|
1292
|
+
try:
|
|
1293
|
+
start_idx = content.find('[')
|
|
1294
|
+
if start_idx != -1:
|
|
1295
|
+
clean_content = content[start_idx:]
|
|
1296
|
+
# もし配列が閉じていなければ閉じる
|
|
1297
|
+
if not clean_content.rstrip().endswith(']'):
|
|
1298
|
+
# 最後の完全なオブジェクト '}' まで探す
|
|
1299
|
+
last_brace = clean_content.rfind('}')
|
|
1300
|
+
if last_brace != -1:
|
|
1301
|
+
clean_content = clean_content[:last_brace+1] + ']'
|
|
1302
|
+
else:
|
|
1303
|
+
clean_content += ']'
|
|
1304
|
+
|
|
1305
|
+
chunk_data = json.loads(clean_content)
|
|
1306
|
+
else:
|
|
1307
|
+
raise ValueError("No JSON list found.")
|
|
1308
|
+
except Exception as e2:
|
|
1309
|
+
print(f" [Error] Could not recover JSON even after aggressive repair: {e2}")
|
|
1310
|
+
# さらに単純にパースできそうな部分だけを抽出する最終手段 (正規表現)
|
|
1311
|
+
# JSONを直すより、チャンクをスキップして進める方が安全
|
|
1312
|
+
continue
|
|
1313
|
+
|
|
1314
|
+
if isinstance(chunk_data, list):
|
|
1315
|
+
extracted_specs.extend(chunk_data)
|
|
1316
|
+
|
|
1317
|
+
time.sleep(1) # API制限配慮
|
|
1318
|
+
|
|
1319
|
+
print(f"Step 2: Ranking {len(extracted_specs)} products based on extracted specs...")
|
|
1320
|
+
|
|
1321
|
+
# 抽出された全スペック情報から、全体での比較とランキング付けを行う
|
|
1322
|
+
# 出力トークン削減のため、元の情報を再出力せず「id, rank, note」のみを要求する
|
|
1323
|
+
prompt_rank = f"""
|
|
1324
|
+
以下の製品の主要スペック一覧を分析し、価格と性能のバランスに基づいて全体の中からランキングを付けてください。
|
|
1325
|
+
|
|
1326
|
+
製品スペックリスト:
|
|
1327
|
+
{json.dumps(extracted_specs, ensure_ascii=False, indent=2)}
|
|
1328
|
+
|
|
1329
|
+
タスク:
|
|
1330
|
+
各製品に対して、以下の3つの情報のみを含むJSONリスト形式で出力してください:
|
|
1331
|
+
1. id: 元の製品ID (必須)
|
|
1332
|
+
2. note: 詳細なコメント (推奨理由、メリット・デメリット、他の製品と比較した際の特徴などを具体的に記述してください。例: "同価格帯の中で最もCPU性能が高く、動画編集に適している", "価格は安いがメモリが少ないため、軽作業向け")
|
|
1333
|
+
3. rank: 全体の中での「おすすめ順位」 (1から始まる連番)
|
|
1334
|
+
|
|
1335
|
+
重要: 出力トークンを節約するため、nameやprice, url, specs(ram/ssd/cpu/os)等の再出力は絶対にしないでください。「id」「note」「rank」の3つだけを出力してください。
|
|
1336
|
+
|
|
1337
|
+
出力はJSONのみとし、Markdownコードブロックで囲ってください。
|
|
1338
|
+
リストの並び順は、rank(1位から順番)にしてください。
|
|
1339
|
+
"""
|
|
1340
|
+
|
|
1341
|
+
response_rank = llm.invoke(prompt_rank)
|
|
1342
|
+
content_rank = response_rank.content
|
|
1343
|
+
if "```json" in content_rank:
|
|
1344
|
+
content_rank = content_rank.split("```json")[1].split("```")[0]
|
|
1345
|
+
elif "```" in content_rank:
|
|
1346
|
+
content_rank = content_rank.split("```")[1].split("```")[0]
|
|
1347
|
+
|
|
1348
|
+
content_rank = content_rank.strip()
|
|
1349
|
+
try:
|
|
1350
|
+
ranking_data = json.loads(content_rank)
|
|
1351
|
+
except json.JSONDecodeError as e:
|
|
1352
|
+
print(f" [Warning] Failed to parse ranking JSON. Error: {e}. Attempting recovery.")
|
|
1353
|
+
start_idx = content_rank.find('[')
|
|
1354
|
+
if start_idx != -1:
|
|
1355
|
+
clean_content = content_rank[start_idx:]
|
|
1356
|
+
if not clean_content.rstrip().endswith(']'):
|
|
1357
|
+
last_brace = clean_content.rfind('}')
|
|
1358
|
+
if last_brace != -1:
|
|
1359
|
+
clean_content = clean_content[:last_brace+1] + ']'
|
|
1360
|
+
else:
|
|
1361
|
+
clean_content += ']'
|
|
1362
|
+
try:
|
|
1363
|
+
ranking_data = json.loads(clean_content)
|
|
1364
|
+
except Exception as e2:
|
|
1365
|
+
print(f" [Error] Could not recover ranking JSON: {e2}")
|
|
1366
|
+
return "Error generating comparison: Invalid JSON format returned by LLM."
|
|
1367
|
+
else:
|
|
1368
|
+
return "Error generating comparison: Invalid JSON format returned by LLM."
|
|
1369
|
+
|
|
1370
|
+
# Python側でマージする
|
|
1371
|
+
spec_dict = {item['id']: item for item in extracted_specs}
|
|
1372
|
+
target_dict = {item['id']: item for item in target_products}
|
|
1373
|
+
final_comparison_data = []
|
|
1374
|
+
|
|
1375
|
+
for rank_item in ranking_data:
|
|
1376
|
+
p_id = rank_item.get('id')
|
|
1377
|
+
if p_id in spec_dict:
|
|
1378
|
+
merged = spec_dict[p_id].copy()
|
|
1379
|
+
merged['rank'] = rank_item.get('rank')
|
|
1380
|
+
merged['note'] = rank_item.get('note', '')
|
|
1381
|
+
if p_id in target_dict:
|
|
1382
|
+
merged['updated_at'] = target_dict[p_id].get('updated_at', '')
|
|
1383
|
+
final_comparison_data.append(merged)
|
|
1384
|
+
|
|
1385
|
+
# ランクでソートしておく(念のため)
|
|
1386
|
+
final_comparison_data.sort(key=lambda x: x.get('rank', 9999))
|
|
1387
|
+
|
|
1388
|
+
# 型番や発売日を追加したことで None が入る可能性があるので、None を空文字に変換
|
|
1389
|
+
for item in final_comparison_data:
|
|
1390
|
+
if 'model_number' not in item or item['model_number'] is None:
|
|
1391
|
+
item['model_number'] = ""
|
|
1392
|
+
if 'release_date' not in item or item['release_date'] is None:
|
|
1393
|
+
item['release_date'] = ""
|
|
1394
|
+
|
|
1395
|
+
# Save to file
|
|
1396
|
+
from datetime import datetime
|
|
1397
|
+
output_data = {
|
|
1398
|
+
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1399
|
+
"products": final_comparison_data
|
|
1400
|
+
}
|
|
1401
|
+
output_file = "product_comparison.json"
|
|
1402
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
1403
|
+
json.dump(output_data, f, ensure_ascii=False, indent=2)
|
|
1404
|
+
|
|
1405
|
+
return f"Comparison table generated and saved to {output_file}. Included {len(final_comparison_data)} ranked items."
|
|
1406
|
+
|
|
1407
|
+
except Exception as e:
|
|
1408
|
+
return f"Error generating comparison: {e}"
|
|
1409
|
+
|
|
1410
|
+
# --- Agent Setup ---
|
|
1411
|
+
|
|
1412
|
+
def display_products():
|
|
1413
|
+
conn = sqlite3.connect(DB_NAME)
|
|
1414
|
+
cursor = conn.cursor()
|
|
1415
|
+
cursor.execute("SELECT id, name, store, price FROM products")
|
|
1416
|
+
rows = cursor.fetchall()
|
|
1417
|
+
conn.close()
|
|
1418
|
+
|
|
1419
|
+
if not rows:
|
|
1420
|
+
print("\nNo products saved yet.")
|
|
1421
|
+
return
|
|
1422
|
+
|
|
1423
|
+
def parse_price_sort(p_str):
|
|
1424
|
+
nums = re.findall(r'\d+', str(p_str).replace(',', ''))
|
|
1425
|
+
return int(''.join(nums)) if nums else float('inf')
|
|
1426
|
+
|
|
1427
|
+
rows.sort(key=lambda x: (x[1], parse_price_sort(x[3])))
|
|
1428
|
+
|
|
1429
|
+
print("\n--- All Saved Products ---")
|
|
1430
|
+
print(f"{'ID':<5} {'Name':<40} {'Store':<20} {'Price':<15}")
|
|
1431
|
+
print("-" * 85)
|
|
1432
|
+
for row in rows:
|
|
1433
|
+
name_disp = (row[1][:37] + '..') if len(row[1]) > 39 else row[1]
|
|
1434
|
+
store_disp = (row[2][:18] + '..') if len(row[2]) > 20 else row[2]
|
|
1435
|
+
print(f"{row[0]:<5} {name_disp:<40} {store_disp:<20} {row[3]:<15}")
|
|
1436
|
+
print("-" * 85)
|
|
1437
|
+
|
|
1438
|
+
def show_product_details(product_id):
|
|
1439
|
+
conn = sqlite3.connect(DB_NAME)
|
|
1440
|
+
cursor = conn.cursor()
|
|
1441
|
+
cursor.execute("SELECT id, name, store, price, url, description, model_number, release_date FROM products WHERE id = ?", (product_id,))
|
|
1442
|
+
row = cursor.fetchone()
|
|
1443
|
+
conn.close()
|
|
1444
|
+
|
|
1445
|
+
if not row:
|
|
1446
|
+
print(f"\nProduct with ID {product_id} not found.")
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
print("\n--- Product Details ---")
|
|
1450
|
+
print(f"ID: {row[0]}")
|
|
1451
|
+
print(f"Name: {row[1]}")
|
|
1452
|
+
print(f"Store: {row[2]}")
|
|
1453
|
+
print(f"Price: {row[3]}")
|
|
1454
|
+
print(f"Model: {row[6] if len(row)>6 else ''}")
|
|
1455
|
+
print(f"Release: {row[7] if len(row)>7 else ''}")
|
|
1456
|
+
print(f"URL: {row[4]}")
|
|
1457
|
+
print(f"Description: {row[5]}")
|
|
1458
|
+
print("-" * 30)
|
|
1459
|
+
|
|
1460
|
+
def delete_product_records(identifiers: List[str]):
|
|
1461
|
+
conn = sqlite3.connect(DB_NAME)
|
|
1462
|
+
cursor = conn.cursor()
|
|
1463
|
+
|
|
1464
|
+
deleted_count = 0
|
|
1465
|
+
errors = []
|
|
1466
|
+
|
|
1467
|
+
print(f"\nAttempting to delete: {identifiers}")
|
|
1468
|
+
|
|
1469
|
+
for identifier in identifiers:
|
|
1470
|
+
try:
|
|
1471
|
+
# Check if identifier is an ID (digit)
|
|
1472
|
+
if identifier.isdigit():
|
|
1473
|
+
cursor.execute("DELETE FROM products WHERE id = ?", (int(identifier),))
|
|
1474
|
+
else:
|
|
1475
|
+
# Treat as Name
|
|
1476
|
+
cursor.execute("DELETE FROM products WHERE name = ?", (identifier,))
|
|
1477
|
+
|
|
1478
|
+
if cursor.rowcount > 0:
|
|
1479
|
+
deleted_count += cursor.rowcount
|
|
1480
|
+
print(f" Deleted: {identifier}")
|
|
1481
|
+
else:
|
|
1482
|
+
errors.append(f"No product found with ID/Name: {identifier}")
|
|
1483
|
+
except Exception as e:
|
|
1484
|
+
errors.append(f"Error deleting {identifier}: {e}")
|
|
1485
|
+
|
|
1486
|
+
conn.commit()
|
|
1487
|
+
conn.close()
|
|
1488
|
+
|
|
1489
|
+
print(f"\nTotal deleted: {deleted_count}")
|
|
1490
|
+
if errors:
|
|
1491
|
+
print("Errors/Warnings:")
|
|
1492
|
+
for err in errors:
|
|
1493
|
+
print(f" - {err}")
|
|
1494
|
+
|
|
1495
|
+
def main():
|
|
1496
|
+
if not os.getenv("GOOGLE_API_KEY"):
|
|
1497
|
+
print("Error: GOOGLE_API_KEY not found.")
|
|
1498
|
+
return
|
|
1499
|
+
|
|
1500
|
+
provider = os.getenv("SEARCH_PROVIDER", "serpapi")
|
|
1501
|
+
if provider == "serpapi" and not os.getenv("SERPAPI_API_KEY"):
|
|
1502
|
+
print("Error: SERPAPI_API_KEY not found.")
|
|
1503
|
+
return
|
|
1504
|
+
elif provider == "tavily_api" and not os.getenv("TAVILY_API_KEY"):
|
|
1505
|
+
print("Error: TAVILY_API_KEY not found for Tavily search.")
|
|
1506
|
+
return
|
|
1507
|
+
elif provider == "browser_use":
|
|
1508
|
+
# Browser Use only needs GOOGLE_API_KEY for the LLM, which is checked above.
|
|
1509
|
+
pass
|
|
1510
|
+
|
|
1511
|
+
model_name = os.getenv("MODEL_NAME", "gemini-2.0-flash")
|
|
1512
|
+
print(f"Using model: {model_name}")
|
|
1513
|
+
print(f"Using Search Provider: {provider}")
|
|
1514
|
+
|
|
1515
|
+
llm = ChatGoogleGenerativeAI(model=model_name, temperature=0, max_retries=10)
|
|
1516
|
+
|
|
1517
|
+
search = get_search_tool_func()
|
|
1518
|
+
search_tool = Tool(
|
|
1519
|
+
name="google_search",
|
|
1520
|
+
description="Search Google for recent results.",
|
|
1521
|
+
func=search.run,
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
# Load agent logs at startup
|
|
1525
|
+
startup_logs = get_all_agent_logs()
|
|
1526
|
+
print(f"Loaded {len(startup_logs)} characters of agent logs.")
|
|
1527
|
+
|
|
1528
|
+
save_tool = SaveProductTool()
|
|
1529
|
+
db_search_tool = SearchProductsTool()
|
|
1530
|
+
update_tool = UpdatePricesTool()
|
|
1531
|
+
similar_tool = FindSimilarProductsTool(agent_logs=startup_logs)
|
|
1532
|
+
compare_tool = CompareProductsTool()
|
|
1533
|
+
|
|
1534
|
+
tools = [search_tool, save_tool, db_search_tool, update_tool, similar_tool, compare_tool]
|
|
1535
|
+
|
|
1536
|
+
# Define Prompt with Chat History
|
|
1537
|
+
prompt = ChatPromptTemplate.from_messages([
|
|
1538
|
+
("system", """あなたは、商品の検索、保存、価格更新、類似商品検索、比較表作成を行う有能なアシスタントです。
|
|
1539
|
+
|
|
1540
|
+
利用可能なツール:
|
|
1541
|
+
1. google_search: インターネット上の商品情報の検索に使用します。
|
|
1542
|
+
2. save_product: 商品情報をデータベースに保存します。
|
|
1543
|
+
3. search_products: データベース内に保存された商品を自然言語で検索します(例:「安いもの」「メモリが多いもの」)。
|
|
1544
|
+
4. update_prices: データベース内の全商品の価格を最新の状態に更新します。
|
|
1545
|
+
5. find_similar_products: データベース内の商品に類似した商品を探して追加します。
|
|
1546
|
+
6. compare_products: データベース内の商品の比較表(RAM, SSD, 価格など)を作成し、おすすめ順に並べます。
|
|
1547
|
+
|
|
1548
|
+
重要: 検索を行って商品が見つかった場合は、必ず `save_product` ツールを使用して、見つかった各商品をデータベースに保存してください。
|
|
1549
|
+
保存する際は、商品名、価格、店舗名、詳細、URLを含めてください。
|
|
1550
|
+
|
|
1551
|
+
【重要】URLと詳細情報(description)は保存において必須項目です。
|
|
1552
|
+
特にURLは `http://` または `https://` で始まる有効な形式である必要があります。
|
|
1553
|
+
これらが取得できない場合やURLが無効な場合は、その商品は保存しないでください。
|
|
1554
|
+
検索結果から情報を抽出する際は、これらの項目を必ず探してください。
|
|
1555
|
+
また、可能であれば「型番(model_number)」と「発売日(release_date)」も抽出・保存してください。
|
|
1556
|
+
|
|
1557
|
+
【無効な商品の保存禁止】
|
|
1558
|
+
検索結果のスニペットや実際のページ内に、以下のいずれかの文言が含まれている商品は、現在利用できない無効な商品です。これらは絶対に `save_product` でデータベースに保存しないでください。
|
|
1559
|
+
- 「販売終了」
|
|
1560
|
+
- 「お探しのページは見つかりません」
|
|
1561
|
+
- 「404 Not Found」
|
|
1562
|
+
- 「この商品は現在お取り扱いできません」
|
|
1563
|
+
|
|
1564
|
+
価格情報が曖昧な場合(例:「10万円以下」)でも、上記の無効条件に該当せず、URLと詳細情報があれば `save_product` を使用して保存してください。
|
|
1565
|
+
その際、priceフィールドには見つかったテキスト(例:「10万円以下」)を入力してください。
|
|
1566
|
+
|
|
1567
|
+
ユーザーの指示に従って適切なツールを使用してください。
|
|
1568
|
+
「価格を更新して」と言われたら update_prices を使用してください。
|
|
1569
|
+
「類似商品を探して」と言われたら find_similar_products を使用してください。
|
|
1570
|
+
"""),
|
|
1571
|
+
MessagesPlaceholder(variable_name="chat_history"),
|
|
1572
|
+
("human", "{input}"),
|
|
1573
|
+
("placeholder", "{agent_scratchpad}"),
|
|
1574
|
+
])
|
|
1575
|
+
|
|
1576
|
+
agent = create_tool_calling_agent(llm, tools, prompt)
|
|
1577
|
+
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, return_intermediate_steps=True)
|
|
1578
|
+
|
|
1579
|
+
print("Advanced AI Agent initialized.")
|
|
1580
|
+
print("Commands: 'list', 'show <ID>', 'update', 'similar', 'delete <ID/Name> ...', 'quit'")
|
|
1581
|
+
|
|
1582
|
+
chat_history = []
|
|
1583
|
+
|
|
1584
|
+
while True:
|
|
1585
|
+
try:
|
|
1586
|
+
try:
|
|
1587
|
+
user_input = input("\nEnter command or search query: ")
|
|
1588
|
+
except EOFError:
|
|
1589
|
+
print("\nEOF detected. Exiting...")
|
|
1590
|
+
break
|
|
1591
|
+
|
|
1592
|
+
if user_input.lower() in ['quit', 'exit']:
|
|
1593
|
+
break
|
|
1594
|
+
|
|
1595
|
+
if user_input.lower() == 'list':
|
|
1596
|
+
display_products()
|
|
1597
|
+
continue
|
|
1598
|
+
|
|
1599
|
+
# Direct Command Execution for Update
|
|
1600
|
+
if user_input.lower() == 'update':
|
|
1601
|
+
result = update_tool._run()
|
|
1602
|
+
print(result)
|
|
1603
|
+
continue
|
|
1604
|
+
|
|
1605
|
+
# Direct Command Execution for Similar Search
|
|
1606
|
+
if user_input.lower() == 'similar':
|
|
1607
|
+
result = similar_tool._run()
|
|
1608
|
+
print(result)
|
|
1609
|
+
continue
|
|
1610
|
+
|
|
1611
|
+
# Direct Command Execution for Comparison
|
|
1612
|
+
if user_input.lower().startswith('compare'):
|
|
1613
|
+
parts = user_input.split(maxsplit=1)
|
|
1614
|
+
query = parts[1] if len(parts) > 1 else ""
|
|
1615
|
+
result = compare_tool._run(query)
|
|
1616
|
+
print(result)
|
|
1617
|
+
continue
|
|
1618
|
+
|
|
1619
|
+
# Direct Command Execution for Search
|
|
1620
|
+
if user_input.lower().startswith('search_products '):
|
|
1621
|
+
query = user_input[16:].strip()
|
|
1622
|
+
if query:
|
|
1623
|
+
result = db_search_tool._run(query)
|
|
1624
|
+
print(result)
|
|
1625
|
+
else:
|
|
1626
|
+
print("Usage: search_products <query>")
|
|
1627
|
+
continue
|
|
1628
|
+
|
|
1629
|
+
if user_input.lower().startswith('show '):
|
|
1630
|
+
parts = user_input.split()
|
|
1631
|
+
if len(parts) > 1 and parts[1].isdigit():
|
|
1632
|
+
show_product_details(int(parts[1]))
|
|
1633
|
+
else:
|
|
1634
|
+
print("Usage: show <product_id>")
|
|
1635
|
+
continue
|
|
1636
|
+
|
|
1637
|
+
if user_input.lower().startswith('delete '):
|
|
1638
|
+
try:
|
|
1639
|
+
parts = shlex.split(user_input)
|
|
1640
|
+
if len(parts) > 1:
|
|
1641
|
+
identifiers = parts[1:]
|
|
1642
|
+
delete_product_records(identifiers)
|
|
1643
|
+
else:
|
|
1644
|
+
print("Usage: delete <ID/Name> ...")
|
|
1645
|
+
except ValueError as e:
|
|
1646
|
+
print(f"Error parsing command: {e}")
|
|
1647
|
+
continue
|
|
1648
|
+
|
|
1649
|
+
if user_input:
|
|
1650
|
+
print(f"\nProcessing: {user_input}...\n")
|
|
1651
|
+
result = agent_executor.invoke({
|
|
1652
|
+
"input": user_input,
|
|
1653
|
+
"chat_history": chat_history
|
|
1654
|
+
})
|
|
1655
|
+
|
|
1656
|
+
# Update Chat History
|
|
1657
|
+
chat_history.append(HumanMessage(content=user_input))
|
|
1658
|
+
if isinstance(result["output"], str):
|
|
1659
|
+
chat_history.append(AIMessage(content=result["output"]))
|
|
1660
|
+
|
|
1661
|
+
# Save scratchpad logs
|
|
1662
|
+
if "intermediate_steps" in result:
|
|
1663
|
+
save_agent_log(user_input, result["intermediate_steps"])
|
|
1664
|
+
|
|
1665
|
+
display_products()
|
|
1666
|
+
|
|
1667
|
+
except KeyboardInterrupt:
|
|
1668
|
+
print("\nExiting...")
|
|
1669
|
+
break
|
|
1670
|
+
except Exception as e:
|
|
1671
|
+
print(f"An error occurred: {e}")
|
|
1672
|
+
|
|
1673
|
+
if __name__ == "__main__":
|
|
1674
|
+
main()
|