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.
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()