groove-dev 0.27.134 → 0.27.136
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.
- package/moe-training/client/domain-tagger.js +1 -1
- package/moe-training/scripts/retag-delegate-yield.js +303 -0
- package/moe-training/test/shared/envelope-schema.test.js +3 -3
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/adaptive.js +77 -0
- package/node_modules/@groove-dev/daemon/src/api.js +35 -5
- package/node_modules/@groove-dev/daemon/src/journalist.js +28 -12
- package/node_modules/@groove-dev/daemon/src/model-lab.js +53 -76
- package/node_modules/@groove-dev/daemon/src/process.js +91 -2
- package/node_modules/@groove-dev/daemon/src/rotator.js +45 -3
- package/node_modules/@groove-dev/gui/dist/assets/{index-Dozp69tK.js → index-BrZHF7pK.js} +1770 -1766
- package/node_modules/@groove-dev/gui/dist/assets/index-DIfiwdKl.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +60 -18
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +42 -20
- package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +2 -22
- package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +9 -9
- package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +7 -0
- package/node_modules/@groove-dev/gui/src/components/lab/chat-playground.jsx +59 -51
- package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +48 -48
- package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +39 -38
- package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +4 -5
- package/node_modules/@groove-dev/gui/src/components/lab/preset-manager.jsx +11 -11
- package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +66 -62
- package/node_modules/@groove-dev/gui/src/components/lab/system-prompt-editor.jsx +13 -13
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +62 -22
- package/node_modules/@groove-dev/gui/src/components/ui/slider.jsx +16 -17
- package/node_modules/@groove-dev/gui/src/components/ui/table-tree.jsx +38 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +23 -9
- package/node_modules/@groove-dev/gui/src/views/editor.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +101 -87
- package/node_modules/moe-training/client/domain-tagger.js +1 -1
- package/node_modules/moe-training/scripts/retag-delegate-yield.js +303 -0
- package/node_modules/moe-training/test/shared/envelope-schema.test.js +3 -3
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/adaptive.js +77 -0
- package/packages/daemon/src/api.js +35 -5
- package/packages/daemon/src/journalist.js +28 -12
- package/packages/daemon/src/model-lab.js +53 -76
- package/packages/daemon/src/process.js +91 -2
- package/packages/daemon/src/rotator.js +45 -3
- package/packages/gui/dist/assets/{index-Dozp69tK.js → index-BrZHF7pK.js} +1770 -1766
- package/packages/gui/dist/assets/index-DIfiwdKl.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-chat.jsx +60 -18
- package/packages/gui/src/components/agents/agent-feed.jsx +42 -20
- package/packages/gui/src/components/agents/agent-file-tree.jsx +1 -1
- package/packages/gui/src/components/agents/workspace-mode.jsx +1 -1
- package/packages/gui/src/components/chat/chat-messages.jsx +2 -22
- package/packages/gui/src/components/editor/code-editor.jsx +9 -9
- package/packages/gui/src/components/editor/file-tree.jsx +1 -1
- package/packages/gui/src/components/editor/terminal.jsx +7 -0
- package/packages/gui/src/components/lab/chat-playground.jsx +59 -51
- package/packages/gui/src/components/lab/lab-assistant.jsx +48 -48
- package/packages/gui/src/components/lab/metrics-panel.jsx +39 -38
- package/packages/gui/src/components/lab/parameter-panel.jsx +4 -5
- package/packages/gui/src/components/lab/preset-manager.jsx +11 -11
- package/packages/gui/src/components/lab/runtime-config.jsx +66 -62
- package/packages/gui/src/components/lab/system-prompt-editor.jsx +13 -13
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/packages/gui/src/components/preview/preview-workspace.jsx +62 -22
- package/packages/gui/src/components/ui/slider.jsx +16 -17
- package/packages/gui/src/components/ui/table-tree.jsx +38 -0
- package/packages/gui/src/stores/groove.js +23 -9
- package/packages/gui/src/views/editor.jsx +1 -1
- package/packages/gui/src/views/model-lab.jsx +101 -87
- package/plan_files/DELEGATE_YIELD_TRAINING_TAGS.md +135 -0
- package/plan_files/session-quality-rotation-fixes.md +218 -0
- package/test.py +571 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BgQL4bNl.css +0 -1
- package/packages/gui/dist/assets/index-BgQL4bNl.css +0 -1
- /package/{AGENT_ORCHESTRATION.md → plan_files/AGENT_ORCHESTRATION.md} +0 -0
- /package/{DYNAMIC_LEAF_ARCH.md → plan_files/DYNAMIC_LEAF_ARCH.md} +0 -0
- /package/{EMBEDDING_DIAGNOSTIC.md → plan_files/EMBEDDING_DIAGNOSTIC.md} +0 -0
- /package/{EMBEDDING_SERVICE_BUILD_PLAN.md → plan_files/EMBEDDING_SERVICE_BUILD_PLAN.md} +0 -0
- /package/{MOE_TRAINING_PIPELINE.md → plan_files/MOE_TRAINING_PIPELINE.md} +0 -0
package/test.py
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
import os
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
import mysql.connector
|
|
5
|
+
import logging
|
|
6
|
+
import csv
|
|
7
|
+
import urllib.parse
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from openai import OpenAI
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
# Load environment variables
|
|
14
|
+
env_path = "/var/www/html/security/.env"
|
|
15
|
+
load_dotenv(dotenv_path=env_path)
|
|
16
|
+
|
|
17
|
+
# Logging setup
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
filename="/var/www/facebook/catalog/logs/pull-inventory-facebook.log",
|
|
20
|
+
level=logging.INFO,
|
|
21
|
+
format="%(asctime)s %(levelname)s: %(message)s"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Database credentials
|
|
25
|
+
DB_PASS = os.getenv('PASSWORD')
|
|
26
|
+
DATABASE = os.getenv('DBNAME_CLIENT')
|
|
27
|
+
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
28
|
+
|
|
29
|
+
def get_facebook_body_style(body_field_from_vauto):
|
|
30
|
+
"""
|
|
31
|
+
Translates a descriptive body field (e.g., "4D Sport Utility")
|
|
32
|
+
into a Facebook-approved body style enum.
|
|
33
|
+
"""
|
|
34
|
+
if not body_field_from_vauto:
|
|
35
|
+
return 'OTHER'
|
|
36
|
+
body = body_field_from_vauto.lower()
|
|
37
|
+
if 'convertible' in body:
|
|
38
|
+
return 'CONVERTIBLE'
|
|
39
|
+
elif 'crewmax' in body or 'crew cab' in body or 'supercrew' in body or 'quad cab' in body or 'pickup' in body or 'truck' in body or 'xtra cab' in body:
|
|
40
|
+
return 'TRUCK'
|
|
41
|
+
elif 'sport utility' in body or 'suv' in body:
|
|
42
|
+
return 'SUV'
|
|
43
|
+
elif 'coupe' in body:
|
|
44
|
+
return 'COUPE'
|
|
45
|
+
elif 'passenger van' in body or 'minivan' in body or 'van' in body:
|
|
46
|
+
return 'VAN'
|
|
47
|
+
elif 'sedan' in body:
|
|
48
|
+
return 'SEDAN'
|
|
49
|
+
elif 'hatchback' in body:
|
|
50
|
+
return 'HATCHBACK'
|
|
51
|
+
elif 'wagon' in body:
|
|
52
|
+
return 'WAGON'
|
|
53
|
+
else:
|
|
54
|
+
return 'OTHER'
|
|
55
|
+
|
|
56
|
+
def connect_to_db():
|
|
57
|
+
"""Connect to MySQL database."""
|
|
58
|
+
try:
|
|
59
|
+
conn = mysql.connector.connect(
|
|
60
|
+
host="localhost",
|
|
61
|
+
user="root",
|
|
62
|
+
password=DB_PASS,
|
|
63
|
+
database=DATABASE
|
|
64
|
+
)
|
|
65
|
+
return conn
|
|
66
|
+
except mysql.connector.Error as e:
|
|
67
|
+
logging.error(f"Database connection error: {e}")
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
# --- RENAMED: This function gets dealer *config*, not vAuto data ---
|
|
71
|
+
def get_facebook_dealers():
|
|
72
|
+
"""Fetch dealers with Facebook integration."""
|
|
73
|
+
conn = connect_to_db()
|
|
74
|
+
if not conn:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
cursor = conn.cursor(dictionary=True)
|
|
78
|
+
try:
|
|
79
|
+
# This query is correct, it's fetching dealers *to process*
|
|
80
|
+
cursor.execute("SELECT id, dealer_name, search_link_new, search_link_used, address, city, state, postal_code, phone, privacy_policy_url, inventory_type, campaign_product_id, ad_prospecting_id, page_id FROM facebook_dealers WHERE process_catalog = true AND dealer_name = 'San Jose Mitsubishi'")
|
|
81
|
+
dealers = cursor.fetchall()
|
|
82
|
+
logging.info(f"Retrieved {len(dealers)} dealers for Facebook feed generation.")
|
|
83
|
+
return dealers
|
|
84
|
+
except mysql.connector.Error as e:
|
|
85
|
+
logging.error(f"Error fetching dealers: {e}")
|
|
86
|
+
return []
|
|
87
|
+
finally:
|
|
88
|
+
cursor.close()
|
|
89
|
+
conn.close()
|
|
90
|
+
|
|
91
|
+
# --- NEW: Function to get inventory from dealer_inventory table ---
|
|
92
|
+
def get_dealer_inventory(dealer_name):
|
|
93
|
+
"""Fetch all active inventory for a specific dealer from the database."""
|
|
94
|
+
conn = connect_to_db()
|
|
95
|
+
if not conn:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
cursor = conn.cursor(dictionary=True) # dictionary=True is key!
|
|
99
|
+
try:
|
|
100
|
+
cursor.execute("""
|
|
101
|
+
SELECT * FROM dealer_inventory
|
|
102
|
+
WHERE dealer_name = %s AND vehicle_status = 'active'
|
|
103
|
+
""", (dealer_name,))
|
|
104
|
+
inventory = cursor.fetchall()
|
|
105
|
+
logging.info(f"Retrieved {len(inventory)} active vehicles from DB for {dealer_name}")
|
|
106
|
+
return inventory
|
|
107
|
+
except mysql.connector.Error as e:
|
|
108
|
+
logging.error(f"Error fetching inventory for {dealer_name}: {e}")
|
|
109
|
+
return []
|
|
110
|
+
finally:
|
|
111
|
+
cursor.close()
|
|
112
|
+
conn.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_vehicle_copy(dealer_name, vin):
|
|
116
|
+
conn = connect_to_db()
|
|
117
|
+
if not conn:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
cursor = conn.cursor(dictionary=True)
|
|
121
|
+
cursor.execute("""
|
|
122
|
+
SELECT * FROM facebook_vehicle_copy
|
|
123
|
+
WHERE dealer_name = %s AND vin = %s
|
|
124
|
+
""", (dealer_name, vin))
|
|
125
|
+
result = cursor.fetchone()
|
|
126
|
+
return result
|
|
127
|
+
finally:
|
|
128
|
+
cursor.close()
|
|
129
|
+
conn.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def save_vehicle_copy(dealer_name, vin, copy_data):
|
|
133
|
+
conn = connect_to_db()
|
|
134
|
+
if not conn:
|
|
135
|
+
return False
|
|
136
|
+
try:
|
|
137
|
+
cursor = conn.cursor()
|
|
138
|
+
query = """
|
|
139
|
+
INSERT INTO facebook_vehicle_copy
|
|
140
|
+
(dealer_name, vin, custom_headline, custom_description,
|
|
141
|
+
custom_headline_retarget, custom_description_retarget, raw_json)
|
|
142
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
143
|
+
ON DUPLICATE KEY UPDATE
|
|
144
|
+
custom_headline = VALUES(custom_headline),
|
|
145
|
+
custom_description = VALUES(custom_description),
|
|
146
|
+
custom_headline_retarget = VALUES(custom_headline_retarget),
|
|
147
|
+
custom_description_retarget = VALUES(custom_description_retarget),
|
|
148
|
+
raw_json = VALUES(raw_json)
|
|
149
|
+
"""
|
|
150
|
+
cursor.execute(query, (
|
|
151
|
+
dealer_name, vin,
|
|
152
|
+
copy_data.get("custom_headline"),
|
|
153
|
+
copy_data.get("custom_description"),
|
|
154
|
+
copy_data.get("custom_headline_retarget"),
|
|
155
|
+
copy_data.get("custom_description_retarget"),
|
|
156
|
+
json.dumps(copy_data)
|
|
157
|
+
))
|
|
158
|
+
conn.commit()
|
|
159
|
+
return True
|
|
160
|
+
finally:
|
|
161
|
+
cursor.close()
|
|
162
|
+
conn.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- MODIFIED: Handles DB data types (int, Decimal) and column names ---
|
|
166
|
+
def create_prospecting_copy(dealer_name, vehicle):
|
|
167
|
+
"""Generate AI ad copy from a DB inventory row (dictionary)."""
|
|
168
|
+
# Normalize key names from DB and cast non-strings
|
|
169
|
+
year = str(vehicle.get('Year', ''))
|
|
170
|
+
make = (vehicle.get('Make', '') or '').strip()
|
|
171
|
+
model = (vehicle.get('Model', '') or '').strip()
|
|
172
|
+
series = (vehicle.get('Series', '') or '').strip()
|
|
173
|
+
engine = (vehicle.get('Engine', '') or '').strip()
|
|
174
|
+
days_on_lot = str(vehicle.get('Age', ''))
|
|
175
|
+
vin = (vehicle.get('VIN', '') or '').strip()
|
|
176
|
+
price = str(vehicle.get('Price', ''))
|
|
177
|
+
body = (vehicle.get('Body', '') or '').strip()
|
|
178
|
+
transmission = (vehicle.get('Transmission', '') or '').strip()
|
|
179
|
+
drivetrain = (vehicle.get('Drivetrain_Desc', '') or '').strip() # DB col: Drivetrain_Desc
|
|
180
|
+
mileage = str(vehicle.get('Odometer', ''))
|
|
181
|
+
fuel = (vehicle.get('Fuel', '') or '').strip()
|
|
182
|
+
colour = (vehicle.get('Colour', '') or '').strip()
|
|
183
|
+
features = (vehicle.get('Features', '') or '').strip()
|
|
184
|
+
description = (vehicle.get('Description', '') or '').strip()
|
|
185
|
+
|
|
186
|
+
# Handle the "New_Used" column from DB
|
|
187
|
+
vehicle_new_used = (vehicle.get('New_Used') or '').strip().upper() # DB col: New_Used
|
|
188
|
+
if vehicle_new_used == 'U':
|
|
189
|
+
vehicle_new_used = 'a Used vehicle'
|
|
190
|
+
elif vehicle_new_used == 'N':
|
|
191
|
+
vehicle_new_used = 'a Brand New vehicle'
|
|
192
|
+
else:
|
|
193
|
+
logging.warning(f"VIN {vin}: Missing or invalid 'New_Used' value — defaulting to Not Sure")
|
|
194
|
+
vehicle_new_used = 'Not Sure'
|
|
195
|
+
|
|
196
|
+
# Log what we’re passing to the AI
|
|
197
|
+
logging.info(f"{dealer_name}: Processing {year} {make} {model} ({vin}) as {vehicle_new_used}")
|
|
198
|
+
|
|
199
|
+
# Construct the AI prompt
|
|
200
|
+
prompt = f"""
|
|
201
|
+
You are an expert automotive copywriter and Facebook Ads strategist.
|
|
202
|
+
Your task is to take raw vehicle data and transform it into a complete, high-performing ad creative package,
|
|
203
|
+
formatted as a single JSON object. Your tone should be professional, yet conversational and personal.
|
|
204
|
+
Your goal is to generate excitement and trust, encouraging potential buyers to visit the dealership.
|
|
205
|
+
|
|
206
|
+
**CRITICAL RULES & CONSTRAINTS:**
|
|
207
|
+
- FINAL OUTPUT MUST BE A SINGLE, VALID JSON OBJECT AND NOTHING ELSE.
|
|
208
|
+
- ABSOLUTELY NO EMOJIS or HASHTAGS.
|
|
209
|
+
- Adhere strictly to character limits.
|
|
210
|
+
- Focus on the vehicle’s features, condition, and dealership value.
|
|
211
|
+
- Do not discuss credit, payments, or financing (Facebook will flag it).
|
|
212
|
+
- Include price naturally within the first two sentences.
|
|
213
|
+
- Avoid claims like “lowest price” or unrealistic offers.
|
|
214
|
+
- If no price or price is 0, do not mention it anywhere in the ad.
|
|
215
|
+
|
|
216
|
+
**VEHICLE DATA FOR THIS AD:**
|
|
217
|
+
Dealer Name: {dealer_name}
|
|
218
|
+
- {year} {make} {model} {series}
|
|
219
|
+
- Body Style: {body}
|
|
220
|
+
- Transmission: {transmission}
|
|
221
|
+
- Drivetrain: {drivetrain}
|
|
222
|
+
- Mileage: {mileage}
|
|
223
|
+
- Fuel Type: {fuel}
|
|
224
|
+
- Color: {colour}
|
|
225
|
+
- Price: ${price}
|
|
226
|
+
- Key Features: {features}
|
|
227
|
+
- Dealer Description: {description}
|
|
228
|
+
- Vehicle Type: {vehicle_new_used}
|
|
229
|
+
- Days on Lot: {days_on_lot}
|
|
230
|
+
|
|
231
|
+
If it's a new vehicle, do not mention mileage.
|
|
232
|
+
If it's used, mention low mileage only when relevant.
|
|
233
|
+
|
|
234
|
+
**TASK: Generate the following components as valid JSON**
|
|
235
|
+
1. `primary_text`: Full ad copy (first sentence under 125 characters, persuasive CTA to visit {dealer_name})
|
|
236
|
+
2. `headlines`: 3 distinct headlines (each under 40 characters)
|
|
237
|
+
3. `link_descriptions`: 2 short value-driven descriptions (each under 30 characters)
|
|
238
|
+
4. `call_to_action_type`: One from [SHOP_NOW, GET_QUOTE]
|
|
239
|
+
|
|
240
|
+
**FINAL JSON OUTPUT FORMAT:**
|
|
241
|
+
{{
|
|
242
|
+
"primary_text": "...",
|
|
243
|
+
"headlines": ["...", "...", "..."],
|
|
244
|
+
"link_descriptions": ["...", "..."],
|
|
245
|
+
"call_to_action_type": "..."
|
|
246
|
+
}}
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
max_retries = 3
|
|
250
|
+
for attempt in range(max_retries):
|
|
251
|
+
try:
|
|
252
|
+
response = client.chat.completions.create(
|
|
253
|
+
model="gpt-5-mini", # ✅ Kept your specified model
|
|
254
|
+
messages=[
|
|
255
|
+
{"role": "system", "content": "You are a Facebook Ads expert. Respond in clean JSON only."},
|
|
256
|
+
{"role": "user", "content": prompt}
|
|
257
|
+
],
|
|
258
|
+
response_format={"type": "json_object"},
|
|
259
|
+
reasoning_effort="low", # ✅ Kept your original parameters
|
|
260
|
+
store=False
|
|
261
|
+
)
|
|
262
|
+
ai_response = response.choices[0].message.content.strip()
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
json.loads(ai_response)
|
|
266
|
+
return ai_response # On success, return the response
|
|
267
|
+
except json.JSONDecodeError:
|
|
268
|
+
logging.warning(f"AI for {vin} returned invalid JSON on attempt {attempt + 1}. Retrying...")
|
|
269
|
+
# Let the loop continue to the next attempt
|
|
270
|
+
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logging.warning(f"AI generation for VIN {vin} failed on attempt {attempt + 1}/{max_retries}. Error: {e}")
|
|
273
|
+
|
|
274
|
+
if attempt < max_retries - 1: # Don't sleep on the final failed attempt
|
|
275
|
+
time.sleep(2) # Wait 2 seconds before retrying
|
|
276
|
+
|
|
277
|
+
logging.error(f"AI generation for {vin} failed after all retries.")
|
|
278
|
+
return None # If all retries fail, return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --- RENAMED & MODIFIED: Reads from DB data, not CSV file ---
|
|
282
|
+
def transform_db_inventory_for_facebook(dealer, output_csv, inv_type, campaign_id, prospecting_ad_id):
|
|
283
|
+
"""
|
|
284
|
+
Convert DB inventory data to Facebook Vehicle Catalog format and
|
|
285
|
+
write it to *output_csv*.
|
|
286
|
+
"""
|
|
287
|
+
temp_output_csv = f"{output_csv}.tmp"
|
|
288
|
+
MAX_IMAGES = 20 # Facebook's limit for images
|
|
289
|
+
|
|
290
|
+
# --- NEW: Get inventory from database ---
|
|
291
|
+
inventory_data = get_dealer_inventory(dealer['dealer_name'])
|
|
292
|
+
if not inventory_data:
|
|
293
|
+
logging.warning(f"No active inventory found in DB for {dealer['dealer_name']}. Skipping feed generation.")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
# --- REMOVED: `with open(csv_file, 'r', ...)` ---
|
|
298
|
+
with open(temp_output_csv, 'w', encoding='utf-8', newline='') as outfile:
|
|
299
|
+
|
|
300
|
+
# Define base fieldnames and add dynamic image fields
|
|
301
|
+
fieldnames = [
|
|
302
|
+
"vehicle_id", "vehicle_registration_plate", "vin", "make", "model", "year",
|
|
303
|
+
"transmission", "body_style", "fuel_type", "drivetrain", "description",
|
|
304
|
+
]
|
|
305
|
+
# Add image fields: image[0].url, image[1].url, etc.
|
|
306
|
+
fieldnames.extend([f"image[{i}].url" for i in range(MAX_IMAGES)])
|
|
307
|
+
fieldnames.extend([
|
|
308
|
+
"mileage.value", "mileage.unit", "url", "title", "price",
|
|
309
|
+
"state_of_vehicle", "exterior_color", "address", "latitude", "longitude",
|
|
310
|
+
"trim", "interior_colour", "dealer_id", "dealer_name", "postal_code",
|
|
311
|
+
"dealer_phone", "fb_page_id", "dealer_communication_channel",
|
|
312
|
+
"dealer_privacy_policy_url"
|
|
313
|
+
])
|
|
314
|
+
|
|
315
|
+
writer = csv.DictWriter(outfile, fieldnames=fieldnames)
|
|
316
|
+
writer.writeheader()
|
|
317
|
+
|
|
318
|
+
def force_https(url: str) -> str:
|
|
319
|
+
return url.replace("http://", "https://", 1) if url and url.startswith("http://") else url
|
|
320
|
+
|
|
321
|
+
# for testing to limit vehicles
|
|
322
|
+
# MAX_TEST_VEHICLES = 2 # limit for testing
|
|
323
|
+
# count = 0
|
|
324
|
+
|
|
325
|
+
# --- MODIFIED: Loop over DB inventory list, not CSV reader ---
|
|
326
|
+
for row in inventory_data:
|
|
327
|
+
# for testing to limit vehicles
|
|
328
|
+
# if count >= MAX_TEST_VEHICLES:
|
|
329
|
+
# break
|
|
330
|
+
# count += 1
|
|
331
|
+
|
|
332
|
+
# --- REMOVED: 'sold' check (already done in SQL) ---
|
|
333
|
+
|
|
334
|
+
vin = (row.get("VIN") or "").strip()
|
|
335
|
+
if not vin:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
# DB col: New_Used
|
|
339
|
+
db_type = (row.get('New_Used') or '').strip().upper() # 'N' or 'U'
|
|
340
|
+
if inv_type == 'new' and db_type != 'N':
|
|
341
|
+
continue
|
|
342
|
+
elif inv_type == 'used' and db_type != 'U':
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
cached_copy = get_vehicle_copy(dealer['dealer_name'], vin)
|
|
346
|
+
if cached_copy and cached_copy.get('custom_headline'):
|
|
347
|
+
custom_headline = cached_copy['custom_headline']
|
|
348
|
+
custom_description = cached_copy['custom_description']
|
|
349
|
+
# Add other cached fields if needed
|
|
350
|
+
else:
|
|
351
|
+
# Generate new copy
|
|
352
|
+
logging.info(f"Generating new AI copy for VIN: {vin}")
|
|
353
|
+
ai_response = create_prospecting_copy(dealer['dealer_name'], row)
|
|
354
|
+
|
|
355
|
+
custom_headline = ""
|
|
356
|
+
custom_description = ""
|
|
357
|
+
|
|
358
|
+
if ai_response: # Check if the AI returned anything at all
|
|
359
|
+
try:
|
|
360
|
+
copy_json = json.loads(ai_response)
|
|
361
|
+
# Use .get() for safe access to prevent KeyErrors
|
|
362
|
+
headlines = copy_json.get('headlines', [])
|
|
363
|
+
primary_text = copy_json.get('primary_text', '')
|
|
364
|
+
|
|
365
|
+
if headlines and primary_text:
|
|
366
|
+
custom_headline = headlines[0]
|
|
367
|
+
custom_description = primary_text
|
|
368
|
+
|
|
369
|
+
# Only save if the generation was successful
|
|
370
|
+
save_vehicle_copy(dealer['dealer_name'], vin, {
|
|
371
|
+
"custom_headline": custom_headline,
|
|
372
|
+
"custom_description": custom_description,
|
|
373
|
+
"custom_headline_retarget": headlines[1] if len(headlines) > 1 else '',
|
|
374
|
+
"custom_description_retarget": copy_json.get('link_descriptions', [''])[0],
|
|
375
|
+
"raw": copy_json
|
|
376
|
+
})
|
|
377
|
+
else:
|
|
378
|
+
logging.warning(f"AI response for {vin} was valid JSON but missing required keys (headlines/primary_text).")
|
|
379
|
+
|
|
380
|
+
except json.JSONDecodeError:
|
|
381
|
+
logging.error(f"Failed to decode AI JSON response for {vin}.")
|
|
382
|
+
else:
|
|
383
|
+
logging.error(f"AI generation failed for {vin}; no response was returned.")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# Price from DB is Decimal, float() handles it
|
|
387
|
+
price_val = float(row.get("Price") or 0)
|
|
388
|
+
price = f"{price_val:.2f} USD" # Format: 19995.00 USD
|
|
389
|
+
|
|
390
|
+
# Mileage from DB is int, str() handles it
|
|
391
|
+
mileage_val = str(row.get("Odometer") or '0')
|
|
392
|
+
mileage_unit = "MI" # Or "KM"
|
|
393
|
+
|
|
394
|
+
year = str(row.get('Year', '')) # Year from DB is int
|
|
395
|
+
make = row.get('Make', '')
|
|
396
|
+
model = row.get('Model', '')
|
|
397
|
+
# DB cols: Series_Detail, Series
|
|
398
|
+
trim = (row.get('Series_Detail') or row.get('Series') or '').strip()
|
|
399
|
+
|
|
400
|
+
vehicle_type = row.get('New_Used') # DB col: New_Used
|
|
401
|
+
|
|
402
|
+
if vehicle_type == 'U':
|
|
403
|
+
vehicle_type = 'used'
|
|
404
|
+
else:
|
|
405
|
+
vehicle_type = 'new'
|
|
406
|
+
|
|
407
|
+
# Use pre-built VDP URL from inventory, fallback to search_link
|
|
408
|
+
redirect_url = row.get('vdp_url')
|
|
409
|
+
if not redirect_url:
|
|
410
|
+
base_link = dealer['search_link_used'] if vehicle_type == 'used' else dealer['search_link_new']
|
|
411
|
+
redirect_url = base_link.replace('{VIN}', str(vin))
|
|
412
|
+
|
|
413
|
+
# Direct link to dealer VDP with tracking params (no redirect)
|
|
414
|
+
_sep = "&" if "?" in redirect_url else "?"
|
|
415
|
+
vehicle_url = redirect_url + _sep + urllib.parse.urlencode({
|
|
416
|
+
"utm_source": "facebook",
|
|
417
|
+
"utm_medium": "paid_social",
|
|
418
|
+
"utm_campaign": "{{campaign.name}}",
|
|
419
|
+
"utm_content": "{{adset.name}}",
|
|
420
|
+
"utm_term": "{{ad.name}}",
|
|
421
|
+
"ad_id": "{{ad.id}}",
|
|
422
|
+
"adset_id": "{{adset.id}}",
|
|
423
|
+
"campaign_id": "{{campaign.id}}",
|
|
424
|
+
"site_source": "{{site_source_name}}",
|
|
425
|
+
"placement": "{{placement}}",
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
# Title
|
|
429
|
+
title = f"{year} {make} {model} {trim}".strip()
|
|
430
|
+
|
|
431
|
+
# Address JSON object
|
|
432
|
+
address_obj = {
|
|
433
|
+
"addr1": dealer.get('address', 'N/A'),
|
|
434
|
+
"city": dealer.get('city', 'N/A'),
|
|
435
|
+
"region": dealer.get('state', 'N/A'),
|
|
436
|
+
"postal_code": dealer.get('postal_code', 'N/A'),
|
|
437
|
+
"country": "US" # Assuming United States
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
# Transmission (Must be an approved enum)
|
|
441
|
+
transmission_raw = (row.get('Transmission') or '').lower()
|
|
442
|
+
if 'automatic' in transmission_raw or 'cvt' in transmission_raw:
|
|
443
|
+
transmission = 'AUTOMATIC'
|
|
444
|
+
elif 'manual' in transmission_raw:
|
|
445
|
+
transmission = 'MANUAL'
|
|
446
|
+
else:
|
|
447
|
+
transmission = 'OTHER'
|
|
448
|
+
|
|
449
|
+
# Drivetrain (Must be an approved enum)
|
|
450
|
+
# DB col: Drivetrain_Desc
|
|
451
|
+
drivetrain_raw = (row.get('Drivetrain_Desc') or '').upper()
|
|
452
|
+
if drivetrain_raw in ['AWD', '4WD', '4X4']:
|
|
453
|
+
drivetrain = 'ALL_WHEEL_DRIVE'
|
|
454
|
+
elif drivetrain_raw == 'FWD':
|
|
455
|
+
drivetrain = 'FRONT_WHEEL_DRIVE'
|
|
456
|
+
elif drivetrain_raw == 'RWD':
|
|
457
|
+
drivetrain = 'REAR_WHEEL_DRIVE'
|
|
458
|
+
else:
|
|
459
|
+
drivetrain = 'OTHER'
|
|
460
|
+
|
|
461
|
+
# Fuel Type (Must be an approved enum)
|
|
462
|
+
fuel_type_raw = (row.get('Fuel') or '').upper()
|
|
463
|
+
if fuel_type_raw == 'GASOLINE':
|
|
464
|
+
fuel_type = 'GASOLINE'
|
|
465
|
+
elif fuel_type_raw == 'DIESEL':
|
|
466
|
+
fuel_type = 'DIESEL'
|
|
467
|
+
elif fuel_type_raw == 'ELECTRIC':
|
|
468
|
+
fuel_type = 'ELECTRIC'
|
|
469
|
+
elif fuel_type_raw in ('HYBRID', 'GASOLINE/HYBRID'):
|
|
470
|
+
fuel_type = 'HYBRID_ENGINE'
|
|
471
|
+
elif fuel_type_raw == 'FLEX FUEL':
|
|
472
|
+
fuel_type = 'FLEX'
|
|
473
|
+
else:
|
|
474
|
+
fuel_type = 'OTHER'
|
|
475
|
+
|
|
476
|
+
# --- IMAGE HANDLING ---
|
|
477
|
+
# DB col: Photo_Url_List
|
|
478
|
+
photos_raw = (row.get('Photo_Url_List') or '').split('|')
|
|
479
|
+
images = [force_https(p.strip()) for p in photos_raw if p and p.strip()]
|
|
480
|
+
|
|
481
|
+
# Skip if fewer than 4 usable images
|
|
482
|
+
if len(images) < 5:
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
# Write up to MAX_IMAGES valid URLs
|
|
486
|
+
image_dict = {}
|
|
487
|
+
for i, img_url in enumerate(images[:MAX_IMAGES]):
|
|
488
|
+
image_dict[f"image[{i}].url"] = img_url
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# --- ROW ASSEMBLY ---
|
|
492
|
+
output_row = {
|
|
493
|
+
'vehicle_id': vin,
|
|
494
|
+
'vin': vin,
|
|
495
|
+
'make': make,
|
|
496
|
+
'model': model,
|
|
497
|
+
'year': year,
|
|
498
|
+
'transmission': transmission,
|
|
499
|
+
'body_style': get_facebook_body_style(row.get('Body')),
|
|
500
|
+
'fuel_type': fuel_type,
|
|
501
|
+
'drivetrain': drivetrain,
|
|
502
|
+
'description': custom_description or f"Explore this {year} {make} {model} at {dealer['dealer_name']} today!",
|
|
503
|
+
'mileage.value': mileage_val,
|
|
504
|
+
'mileage.unit': mileage_unit,
|
|
505
|
+
'url': vehicle_url,
|
|
506
|
+
'title': custom_headline or title,
|
|
507
|
+
'price': price,
|
|
508
|
+
'state_of_vehicle': vehicle_type,
|
|
509
|
+
'exterior_color': row.get('Colour', ''),
|
|
510
|
+
'address': json.dumps(address_obj),
|
|
511
|
+
'trim': trim,
|
|
512
|
+
'interior_colour': row.get('Interior_Color', ''), # DB col: Interior_Color
|
|
513
|
+
'dealer_id': dealer.get('id'), # Your internal dealer ID
|
|
514
|
+
'dealer_name': dealer.get('dealer_name'),
|
|
515
|
+
'postal_code': dealer.get('postal_code', ''),
|
|
516
|
+
'dealer_phone': dealer.get('phone', ''),
|
|
517
|
+
'fb_page_id': dealer.get('fb_page_id', ''),
|
|
518
|
+
'dealer_communication_channel': 'CHAT', # Or 'PHONE_CALL', 'LEAD_FORM'
|
|
519
|
+
'dealer_privacy_policy_url': dealer.get('privacy_policy_url', '')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# Add the dynamic image fields to the row
|
|
523
|
+
output_row.update(image_dict)
|
|
524
|
+
|
|
525
|
+
writer.writerow(output_row)
|
|
526
|
+
|
|
527
|
+
os.replace(temp_output_csv, output_csv)
|
|
528
|
+
logging.info(f"Transformed Facebook CSV for {dealer['dealer_name']} written to {output_csv}")
|
|
529
|
+
|
|
530
|
+
except Exception as e:
|
|
531
|
+
logging.error(f"Error transforming Facebook CSV for {dealer['dealer_name']}: {e}", exc_info=True)
|
|
532
|
+
if os.path.exists(temp_output_csv):
|
|
533
|
+
os.remove(temp_output_csv)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def format_filename_component(value):
|
|
538
|
+
"""Formats a string for use in a filename."""
|
|
539
|
+
if value:
|
|
540
|
+
return value.replace(' ', '_').replace('&', 'and')
|
|
541
|
+
return ''
|
|
542
|
+
|
|
543
|
+
# --- MODIFIED: Main function to call the new DB-driven functions ---
|
|
544
|
+
def process_inventory():
|
|
545
|
+
"""Main function to process inventory from DB and generate Facebook Catalog CSV."""
|
|
546
|
+
dealers = get_facebook_dealers() # Renamed function
|
|
547
|
+
if not dealers:
|
|
548
|
+
logging.info("No dealers found with Facebook integration enabled.")
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
for dealer in dealers:
|
|
552
|
+
dealer_name = dealer["dealer_name"]
|
|
553
|
+
dealer_id = dealer["id"]
|
|
554
|
+
inv_type = dealer['inventory_type']
|
|
555
|
+
|
|
556
|
+
campaign_id = dealer['campaign_product_id']
|
|
557
|
+
prospecting_ad_id = dealer['campaign_product_id']
|
|
558
|
+
|
|
559
|
+
# --- REMOVED: csv_file path and os.path.exists check ---
|
|
560
|
+
safe_dealer_name = format_filename_component(dealer_name)
|
|
561
|
+
# --- MODIFIED: Changed "vauto_feed" to "db_feed" for clarity ---
|
|
562
|
+
output_csv = f"/var/www/html/app.studio19.io/fb_catalog_csv/{safe_dealer_name}_db_feed_english.csv"
|
|
563
|
+
|
|
564
|
+
logging.info(f"Processing DB inventory for {dealer_name}")
|
|
565
|
+
|
|
566
|
+
# --- MODIFIED: Call renamed function and pass correct args ---
|
|
567
|
+
transform_db_inventory_for_facebook(dealer, output_csv, inv_type, campaign_id, prospecting_ad_id)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
if __name__ == "__main__":
|
|
571
|
+
process_inventory()
|