@miraiva_test/miravia-order-report 0.1.13
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/bin/cli.js +61 -0
- package/dist/.keypair.json +5 -0
- package/dist/README.md +249 -0
- package/dist/SKILL.md +329 -0
- package/dist/VERSION +1 -0
- package/dist/executive_summary_prompt_v2.md +574 -0
- package/dist/reference.md +409 -0
- package/dist/requirements.txt +6 -0
- package/dist/scripts/auth_cli.py +1442 -0
- package/dist/scripts/config.py +216 -0
- package/dist/scripts/cred_crypto.py +235 -0
- package/dist/scripts/estimate_time.py +244 -0
- package/dist/scripts/export_orders.py +1549 -0
- package/dist/scripts/i18n.py +1162 -0
- package/dist/scripts/inject_insights.py +96 -0
- package/dist/scripts/login.bat +14 -0
- package/dist/scripts/miravia_client.py +468 -0
- package/dist/scripts/report_v4.py +1694 -0
- package/dist/scripts/run.bat +15 -0
- package/dist/scripts/skill_crypto.py +86 -0
- package/dist/scripts/skill_keypair.py +86 -0
- package/dist/scripts/skill_logger.py +262 -0
- package/dist/scripts/test_v4_render.py +322 -0
- package/dist/scripts/trace_logger.py +97 -0
- package/dist/scripts/validate_render.py +101 -0
- package/dist/static/chart.umd.min.js +20 -0
- package/dist/static/chartjs-plugin-datalabels.min.js +7 -0
- package/dist/static/miravia-logo.svg +61 -0
- package/dist/templates/report_v4.html +1005 -0
- package/lib/build-dist.js +86 -0
- package/lib/clean-dist.js +18 -0
- package/lib/installer.js +444 -0
- package/package.json +34 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Test script for v4 unified dashboard rendering.
|
|
4
|
+
|
|
5
|
+
Generates mock order data and calls report_v4.render() to produce HTML files.
|
|
6
|
+
Tests three scenarios: business (rich data), refund (high refund rate), and empty data.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Ensure scripts dir is on path
|
|
16
|
+
THIS_DIR = Path(__file__).resolve().parent
|
|
17
|
+
sys.path.insert(0, str(THIS_DIR))
|
|
18
|
+
|
|
19
|
+
# Import from export_orders
|
|
20
|
+
from export_orders import EXPORT_COLUMNS, _jinja_env
|
|
21
|
+
import report_v4
|
|
22
|
+
from i18n import SUPPORTED_LANGS
|
|
23
|
+
|
|
24
|
+
random.seed(42)
|
|
25
|
+
|
|
26
|
+
# ---------- mock data generators ----------
|
|
27
|
+
|
|
28
|
+
PRODUCTS = [
|
|
29
|
+
("SKU-LED-001", "LED Smart Lamp Aurora", 29.99, "Home & Living"),
|
|
30
|
+
("SKU-LED-002", "LED Strip Light 5m RGB", 15.99, "Home & Living"),
|
|
31
|
+
("SKU-AUR-001", "Auriculares Bluetooth Pro", 49.99, "Electronics"),
|
|
32
|
+
("SKU-AUR-002", "Altavoz Bluetooth Portable", 34.50, "Electronics"),
|
|
33
|
+
("SKU-CAR-001", "Cargador USB-C 65W", 24.99, "Electronics"),
|
|
34
|
+
("SKU-SAR-001", "Sartén Antiadherente 28cm", 22.00, "Kitchen"),
|
|
35
|
+
("SKU-COC-001", "Cafetera Espresso Automática", 89.99, "Kitchen"),
|
|
36
|
+
("SKU-MOC-001", "Mochila senderismo 40L", 39.99, "Sports & Outdoors"),
|
|
37
|
+
("SKU-CRE-001", "Crema hidratante facial", 12.99, "Beauty & Personal Care"),
|
|
38
|
+
("SKU-CAM-001", "Camiseta algodón premium", 14.99, "Fashion & Accessories"),
|
|
39
|
+
("SKU-ZAP-001", "Zapato running deportivo", 55.00, "Fashion & Accessories"),
|
|
40
|
+
("SKU-BEB-001", "Juguetes educativos bebé", 27.99, "Baby & Toys"),
|
|
41
|
+
("SKU-CAR-002", "Coche control remoto RC", 45.00, "Auto Parts"),
|
|
42
|
+
("SKU-LAM-001", "Lámpara de mesa madera", 33.50, "Home & Living"),
|
|
43
|
+
("SKU-PER-001", "Perfume floral 100ml", 28.99, "Beauty & Personal Care"),
|
|
44
|
+
("SKU-COC-002", "Cuchillo chef acero inox", 18.50, "Kitchen"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
CARRIERS = ["CTT Express", "DHL", "SEUR", "MRW", "Correos", "GLS"]
|
|
48
|
+
CITIES = ["Madrid", "Barcelona", "Valencia", "Sevilla", "Bilbao", "Málaga", "Zaragoza"]
|
|
49
|
+
COUNTRIES = ["ES", "PT", "FR", "DE", "IT"]
|
|
50
|
+
PAY_METHODS = ["Credit Card", "PayPal", "Klarna", "Bizum", "Apple Pay"]
|
|
51
|
+
CANCEL_REASONS = ["Out of stock", "Buyer request", "Price error", "Shipping delay", "Address issue"]
|
|
52
|
+
CANCEL_INITIATORS = ["buyer", "seller", "system"]
|
|
53
|
+
REFUND_REASONS = [
|
|
54
|
+
"Product not as described",
|
|
55
|
+
"Damaged in transit",
|
|
56
|
+
"Wrong item received",
|
|
57
|
+
"Late delivery",
|
|
58
|
+
"Quality issue",
|
|
59
|
+
"Buyer changed mind",
|
|
60
|
+
"Missing parts",
|
|
61
|
+
]
|
|
62
|
+
REFUND_TYPES = ["RETURN_REFUND", "MONEY_ONLY", "RESEND"]
|
|
63
|
+
DUTY_PARTIES = ["buyer", "seller", "pending"]
|
|
64
|
+
SHIPMENT_TYPES = ["standard", "express", "pickup"]
|
|
65
|
+
ITEM_STATUSES = ["shipped", "delivered", "pending", "canceled", "returned"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _gen_order_id(i):
|
|
69
|
+
return f"ORD-{20260600 + i:07d}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _gen_item_id(i):
|
|
73
|
+
return f"ITM-{30000000 + i:08d}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _gen_buyer_id(i):
|
|
77
|
+
return f"BUY-{50000 + i:05d}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _gen_refund_id(i):
|
|
81
|
+
return f"RFD-{800000 + i:06d}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _gen_tracking(i):
|
|
85
|
+
return f"TRK{100000000 + i:09d}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def gen_mock_rows(n_days=14, n_orders_per_day=8, refund_rate=0.18, cancel_rate=0.12,
|
|
89
|
+
miravia_ratio=0.6):
|
|
90
|
+
"""Generate mock sub-order rows for the v4 dashboard."""
|
|
91
|
+
rows = []
|
|
92
|
+
idx = 0
|
|
93
|
+
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
94
|
+
|
|
95
|
+
for d in range(n_days):
|
|
96
|
+
date = today - timedelta(days=n_days - 1 - d)
|
|
97
|
+
date_str = date.strftime("%Y-%m-%d %H:%M:%S")
|
|
98
|
+
|
|
99
|
+
for o in range(n_orders_per_day):
|
|
100
|
+
order_id = _gen_order_id(idx)
|
|
101
|
+
is_miravia = random.random() < miravia_ratio
|
|
102
|
+
marketplace = "miravia" if is_miravia else "aliexpress"
|
|
103
|
+
buyer_id = _gen_buyer_id(idx % 200) # Some repeat buyers
|
|
104
|
+
country = random.choice(COUNTRIES)
|
|
105
|
+
city = random.choice(CITIES)
|
|
106
|
+
pay_method = random.choice(PAY_METHODS)
|
|
107
|
+
carrier = random.choice(CARRIERS)
|
|
108
|
+
ship_type = random.choice(SHIPMENT_TYPES)
|
|
109
|
+
items_count = random.randint(1, 3)
|
|
110
|
+
|
|
111
|
+
for item_i in range(items_count):
|
|
112
|
+
sku, name, price, cat = random.choice(PRODUCTS)
|
|
113
|
+
qty = random.randint(1, 2)
|
|
114
|
+
unit_price = price
|
|
115
|
+
discount_seller = round(price * random.choice([0, 0, 0, 0.05, 0.10, 0.15]), 2)
|
|
116
|
+
buyer_paid = round((unit_price - discount_seller) * qty, 2)
|
|
117
|
+
shipping_paid = round(random.choice([0, 0, 4.99, 6.99, 9.99]), 2)
|
|
118
|
+
item_status = random.choices(ITEM_STATUSES, weights=[30, 35, 15, 10, 10])[0]
|
|
119
|
+
|
|
120
|
+
is_canceled = random.random() < cancel_rate
|
|
121
|
+
if is_canceled:
|
|
122
|
+
item_status = "canceled"
|
|
123
|
+
cancel_initiator = random.choice(CANCEL_INITIATORS)
|
|
124
|
+
cancel_reason = random.choice(CANCEL_REASONS)
|
|
125
|
+
else:
|
|
126
|
+
cancel_initiator = ""
|
|
127
|
+
cancel_reason = ""
|
|
128
|
+
|
|
129
|
+
is_refund = (not is_canceled) and (random.random() < refund_rate)
|
|
130
|
+
if is_refund:
|
|
131
|
+
refund_id = _gen_refund_id(idx)
|
|
132
|
+
refund_apply_at = date_str
|
|
133
|
+
refund_status = random.choice(["SUCCESS", "PROCESSING", "FAILED"])
|
|
134
|
+
refund_amount = round(buyer_paid * random.uniform(0.5, 1.0), 2)
|
|
135
|
+
refund_reason = random.choice(REFUND_REASONS)
|
|
136
|
+
is_fast_refund = random.random() < 0.3
|
|
137
|
+
is_instant_refund = random.random() < 0.15
|
|
138
|
+
refund_tracking = _gen_tracking(idx) if random.random() < 0.4 else ""
|
|
139
|
+
request_type = random.choice(REFUND_TYPES)
|
|
140
|
+
duty_party = random.choice(DUTY_PARTIES)
|
|
141
|
+
seller_agreed = random.random() < 0.7
|
|
142
|
+
refund_payment = random.choice(PAY_METHODS)
|
|
143
|
+
reason_code = f"RC{random.randint(1000, 9999)}"
|
|
144
|
+
else:
|
|
145
|
+
refund_id = ""
|
|
146
|
+
refund_apply_at = ""
|
|
147
|
+
refund_status = ""
|
|
148
|
+
refund_amount = ""
|
|
149
|
+
refund_reason = ""
|
|
150
|
+
is_fast_refund = False
|
|
151
|
+
is_instant_refund = False
|
|
152
|
+
refund_tracking = ""
|
|
153
|
+
request_type = ""
|
|
154
|
+
duty_party = ""
|
|
155
|
+
seller_agreed = ""
|
|
156
|
+
refund_payment = ""
|
|
157
|
+
reason_code = ""
|
|
158
|
+
|
|
159
|
+
row = {
|
|
160
|
+
# Order
|
|
161
|
+
"order_id": order_id,
|
|
162
|
+
"marketplace": marketplace,
|
|
163
|
+
"created_at": date_str,
|
|
164
|
+
"items_count": items_count,
|
|
165
|
+
"payment_method": pay_method,
|
|
166
|
+
"buyer_id": buyer_id,
|
|
167
|
+
"buyer_name": f"Customer {buyer_id}",
|
|
168
|
+
"ship_address": f"Street {random.randint(1, 999)}, {city}",
|
|
169
|
+
"ship_country": country,
|
|
170
|
+
"warehouse_code": f"WH-{random.choice(['MAD', 'BCN', 'VAL'])}",
|
|
171
|
+
"buyer_remark": random.choice(["", "", "Please ship fast"]),
|
|
172
|
+
"seller_remark": random.choice(["", "", "VIP customer"]),
|
|
173
|
+
# Item
|
|
174
|
+
"order_item_id": _gen_item_id(idx),
|
|
175
|
+
"item_status": item_status,
|
|
176
|
+
"item_updated_at": date_str,
|
|
177
|
+
# Product
|
|
178
|
+
"product_id": f"PID-{idx:06d}",
|
|
179
|
+
"product_name": name,
|
|
180
|
+
"product_url": f"https://miravia.es/p/{idx}",
|
|
181
|
+
"sku": sku,
|
|
182
|
+
"shop_sku": f"SS-{sku}",
|
|
183
|
+
"quantity": qty,
|
|
184
|
+
"unit_price": unit_price,
|
|
185
|
+
# Amount
|
|
186
|
+
"buyer_paid": buyer_paid,
|
|
187
|
+
"discount_total": discount_seller,
|
|
188
|
+
"discount_platform": round(discount_seller * 0.3, 2),
|
|
189
|
+
"discount_seller": discount_seller,
|
|
190
|
+
"shipping_paid": shipping_paid,
|
|
191
|
+
"shipping_disc_total": round(shipping_paid * 0.5, 2) if shipping_paid > 0 else 0,
|
|
192
|
+
"shipping_disc_platform": 0,
|
|
193
|
+
"shipping_disc_seller": round(shipping_paid * 0.3, 2) if shipping_paid > 0 else 0,
|
|
194
|
+
"currency": "EUR",
|
|
195
|
+
# Logistics
|
|
196
|
+
"shipment_type": ship_type,
|
|
197
|
+
"tracking_no": _gen_tracking(idx) if not is_canceled else "",
|
|
198
|
+
"package_id": f"PKG-{idx:06d}",
|
|
199
|
+
"shipping_provider": carrier,
|
|
200
|
+
# Cancellation
|
|
201
|
+
"is_canceled": is_canceled,
|
|
202
|
+
"cancel_initiator": cancel_initiator,
|
|
203
|
+
"cancel_reason": cancel_reason,
|
|
204
|
+
# Refund
|
|
205
|
+
"refund_id": refund_id,
|
|
206
|
+
"refund_apply_at": refund_apply_at,
|
|
207
|
+
"refund_status": refund_status,
|
|
208
|
+
"refund_amount": refund_amount,
|
|
209
|
+
"refund_reason": refund_reason,
|
|
210
|
+
"is_fast_refund": is_fast_refund,
|
|
211
|
+
"is_instant_refund": is_instant_refund,
|
|
212
|
+
"refund_tracking_no": refund_tracking,
|
|
213
|
+
# ---- v4 additions ----
|
|
214
|
+
"city": city,
|
|
215
|
+
"ship_to": f"Recipient {idx}",
|
|
216
|
+
"channel": "online",
|
|
217
|
+
"order_flag": random.choice(["", "", "priority", "gift"]),
|
|
218
|
+
"is_fbl": 1 if random.random() < 0.25 else 0,
|
|
219
|
+
"is_digital": 1 if random.random() < 0.05 else 0,
|
|
220
|
+
"is_sof": 1 if random.random() < 0.10 else 0,
|
|
221
|
+
"shipping_fee_original": shipping_paid,
|
|
222
|
+
"shipping_fee_disc_seller": round(shipping_paid * 0.3, 2) if shipping_paid > 0 else 0,
|
|
223
|
+
"voucher_seller_lpi": round(discount_seller, 2) if discount_seller > 0 else 0,
|
|
224
|
+
"variation": f"Variant-{random.choice(['A', 'B', 'C'])}",
|
|
225
|
+
"returnable": 1 if not is_canceled else 0,
|
|
226
|
+
"request_type": request_type,
|
|
227
|
+
"duty_party": duty_party,
|
|
228
|
+
"is_not_received": 1 if (is_refund and random.random() < 0.3) else 0,
|
|
229
|
+
"refund_payment_method": refund_payment,
|
|
230
|
+
"reason_code": reason_code,
|
|
231
|
+
"ofc_status": random.choice(["", "OK", "WARNING"]),
|
|
232
|
+
"refund_currency": "EUR" if is_refund else "",
|
|
233
|
+
"seller_sku_id": f"SID-{sku}",
|
|
234
|
+
"seller_agreed": seller_agreed,
|
|
235
|
+
}
|
|
236
|
+
rows.append(row)
|
|
237
|
+
idx += 1
|
|
238
|
+
|
|
239
|
+
return rows
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main():
|
|
243
|
+
out_dir = THIS_DIR.parent / "out"
|
|
244
|
+
out_dir.mkdir(exist_ok=True)
|
|
245
|
+
jinja_env = _jinja_env()
|
|
246
|
+
|
|
247
|
+
# ---- Scenario 1: Business (rich data, balanced) — all languages ----
|
|
248
|
+
print("=" * 60)
|
|
249
|
+
print("Scenario 1: business (rich data) — multi-language")
|
|
250
|
+
print("=" * 60)
|
|
251
|
+
rows = gen_mock_rows(n_days=14, n_orders_per_day=8, refund_rate=0.15, cancel_rate=0.10)
|
|
252
|
+
for lang in SUPPORTED_LANGS:
|
|
253
|
+
out_path = out_dir / f"test_v4_business_{lang}.html"
|
|
254
|
+
report_v4.render(
|
|
255
|
+
rows=rows, out_path=out_path,
|
|
256
|
+
range_label="2026-06-08 ~ 2026-06-21",
|
|
257
|
+
jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
|
|
258
|
+
default_max_orders=10000, lang=lang,
|
|
259
|
+
)
|
|
260
|
+
print(f" [{lang}] Generated: {out_path} ({out_path.stat().st_size / 1024:.1f} KB)")
|
|
261
|
+
print(f" Rows: {len(rows)}")
|
|
262
|
+
# Keep reference to en version for browser open
|
|
263
|
+
out_path = out_dir / "test_v4_business_en.html"
|
|
264
|
+
|
|
265
|
+
# ---- Scenario 2: Refund (high refund rate) ----
|
|
266
|
+
print("\n" + "=" * 60)
|
|
267
|
+
print("Scenario 2: refund (high refund rate)")
|
|
268
|
+
print("=" * 60)
|
|
269
|
+
rows2 = gen_mock_rows(n_days=30, n_orders_per_day=6, refund_rate=0.35, cancel_rate=0.08)
|
|
270
|
+
out_path2 = out_dir / "test_v4_refund.html"
|
|
271
|
+
report_v4.render(
|
|
272
|
+
rows=rows2, out_path=out_path2,
|
|
273
|
+
range_label="2026-05-23 ~ 2026-06-21",
|
|
274
|
+
jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
|
|
275
|
+
default_max_orders=10000, lang="es",
|
|
276
|
+
)
|
|
277
|
+
print(f" Generated: {out_path2} ({out_path2.stat().st_size / 1024:.1f} KB)")
|
|
278
|
+
print(f" Rows: {len(rows2)}")
|
|
279
|
+
|
|
280
|
+
# ---- Scenario 3: Empty data (degradation test) ----
|
|
281
|
+
print("\n" + "=" * 60)
|
|
282
|
+
print("Scenario 3: empty data (degradation)")
|
|
283
|
+
print("=" * 60)
|
|
284
|
+
out_path3 = out_dir / "test_v4_empty.html"
|
|
285
|
+
report_v4.render(
|
|
286
|
+
rows=[], out_path=out_path3,
|
|
287
|
+
range_label="2026-06-15 ~ 2026-06-21",
|
|
288
|
+
jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
|
|
289
|
+
default_max_orders=10000, lang="zh",
|
|
290
|
+
)
|
|
291
|
+
print(f" Generated: {out_path3} ({out_path3.stat().st_size / 1024:.1f} KB)")
|
|
292
|
+
print(f" Rows: 0")
|
|
293
|
+
|
|
294
|
+
# ---- Scenario 4: Auto (mixed, small dataset) ----
|
|
295
|
+
print("\n" + "=" * 60)
|
|
296
|
+
print("Scenario 4: auto (small dataset, 3 days)")
|
|
297
|
+
print("=" * 60)
|
|
298
|
+
rows4 = gen_mock_rows(n_days=3, n_orders_per_day=5, refund_rate=0.25, cancel_rate=0.20,
|
|
299
|
+
miravia_ratio=0.3)
|
|
300
|
+
out_path4 = out_dir / "test_v4_auto.html"
|
|
301
|
+
report_v4.render(
|
|
302
|
+
rows=rows4, out_path=out_path4,
|
|
303
|
+
range_label="2026-06-19 ~ 2026-06-21",
|
|
304
|
+
jinja_env=jinja_env, export_columns=EXPORT_COLUMNS,
|
|
305
|
+
default_max_orders=10000,
|
|
306
|
+
)
|
|
307
|
+
print(f" Generated: {out_path4} ({out_path4.stat().st_size / 1024:.1f} KB)")
|
|
308
|
+
print(f" Rows: {len(rows4)}")
|
|
309
|
+
|
|
310
|
+
print("\n" + "=" * 60)
|
|
311
|
+
print("All scenarios completed! Opening in browser...")
|
|
312
|
+
print("=" * 60)
|
|
313
|
+
|
|
314
|
+
# Open the main business report
|
|
315
|
+
import webbrowser
|
|
316
|
+
webbrowser.open(f"file://{out_path}")
|
|
317
|
+
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
sys.exit(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
OpenAPI Trace Logger
|
|
5
|
+
|
|
6
|
+
Records every Miravia API call with traceId for debugging and tracing.
|
|
7
|
+
Log file: logs/trace-{YYYY-MM-DD}.log (one JSON line per API call)
|
|
8
|
+
Retention: 3 days (auto-cleanup on each write)
|
|
9
|
+
|
|
10
|
+
Fields per entry:
|
|
11
|
+
- time : Request timestamp (ISO format with milliseconds)
|
|
12
|
+
- api : API endpoint path (e.g. /orders/get)
|
|
13
|
+
- params : Business request parameters (excluding sign/token/common fields)
|
|
14
|
+
- traceId : Trace ID returned by the API (may be multiple per session)
|
|
15
|
+
- success : Whether the request was successful (true/false)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
from datetime import datetime, timedelta
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
# Skill root directory / logs
|
|
25
|
+
_SKILL_DIR = Path(__file__).resolve().parent.parent
|
|
26
|
+
_LOGS_DIR = _SKILL_DIR / "logs"
|
|
27
|
+
|
|
28
|
+
# Trace log retention: 5 days
|
|
29
|
+
_TRACE_RETENTION_DAYS = 5
|
|
30
|
+
|
|
31
|
+
_logger = logging.getLogger("miravia.trace_logger")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _trace_log_path() -> Path:
|
|
35
|
+
"""Return today's trace log file path: logs/trace-{YYYY-MM-DD}.log"""
|
|
36
|
+
_LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
38
|
+
return _LOGS_DIR / f"trace-{date_str}.log"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cleanup_old_trace_logs() -> None:
|
|
42
|
+
"""Delete trace log files older than _TRACE_RETENTION_DAYS days."""
|
|
43
|
+
if not _LOGS_DIR.exists():
|
|
44
|
+
return
|
|
45
|
+
cutoff = datetime.now() - timedelta(days=_TRACE_RETENTION_DAYS)
|
|
46
|
+
for f in _LOGS_DIR.glob("trace-*.log"):
|
|
47
|
+
try:
|
|
48
|
+
date_part = f.stem.replace("trace-", "")
|
|
49
|
+
file_date = datetime.strptime(date_part, "%Y-%m-%d")
|
|
50
|
+
if file_date < cutoff:
|
|
51
|
+
f.unlink(missing_ok=True)
|
|
52
|
+
except (ValueError, OSError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Common/sensitive keys to exclude from logged params
|
|
57
|
+
_EXCLUDED_PARAM_KEYS = {
|
|
58
|
+
"app_key", "access_token", "sign", "sign_method", "timestamp",
|
|
59
|
+
"skill_name", "action", "agent_type", "model_name",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def record_trace(api: str, trace_id: Optional[str], success: bool,
|
|
64
|
+
params: Optional[Dict[str, Any]] = None) -> None:
|
|
65
|
+
"""Write one trace record to logs/trace-{date}.log.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
api: API endpoint path (e.g. "/orders/get")
|
|
69
|
+
trace_id: Trace ID from API response (may be None if not present)
|
|
70
|
+
success: Whether the API call was successful
|
|
71
|
+
params: Request parameters (common/sensitive fields will be filtered out)
|
|
72
|
+
"""
|
|
73
|
+
# Filter out common/sensitive params, keep only business params
|
|
74
|
+
biz_params = {}
|
|
75
|
+
if params:
|
|
76
|
+
biz_params = {k: v for k, v in params.items() if k not in _EXCLUDED_PARAM_KEYS}
|
|
77
|
+
|
|
78
|
+
entry = {
|
|
79
|
+
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
|
|
80
|
+
"api": api,
|
|
81
|
+
"params": biz_params,
|
|
82
|
+
"traceId": trace_id or "",
|
|
83
|
+
"success": success,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
log_path = _trace_log_path()
|
|
87
|
+
try:
|
|
88
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
89
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
90
|
+
except OSError as e:
|
|
91
|
+
_logger.warning("Failed to write trace log: %s", e)
|
|
92
|
+
|
|
93
|
+
# Auto-cleanup expired trace logs
|
|
94
|
+
try:
|
|
95
|
+
_cleanup_old_trace_logs()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Post-injection rendering validator for miravia-order-report HTML reports.
|
|
4
|
+
|
|
5
|
+
Validates that injected agentInsights JSON conforms to the frontend schema,
|
|
6
|
+
preventing undefined/broken renders in the browser.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 scripts/validate_render.py <report_html_path>
|
|
10
|
+
|
|
11
|
+
Exit codes:
|
|
12
|
+
0 - RENDER_CHECK: OK (all presets valid)
|
|
13
|
+
1 - FAIL: structural errors detected (details printed to stdout)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_report(html_path: str) -> tuple[bool, str]:
|
|
22
|
+
"""Validate agentInsights structure in an HTML report.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
(success: bool, message: str)
|
|
26
|
+
"""
|
|
27
|
+
with open(html_path, encoding="utf-8") as f:
|
|
28
|
+
html = f.read()
|
|
29
|
+
|
|
30
|
+
m = re.search(r"const\s+DATA\s*=\s*", html)
|
|
31
|
+
if not m:
|
|
32
|
+
return False, "DATA block not found"
|
|
33
|
+
|
|
34
|
+
start = m.end()
|
|
35
|
+
depth = 0
|
|
36
|
+
i = start
|
|
37
|
+
while i < len(html):
|
|
38
|
+
if html[i] == "{":
|
|
39
|
+
depth += 1
|
|
40
|
+
elif html[i] == "}":
|
|
41
|
+
depth -= 1
|
|
42
|
+
if depth == 0:
|
|
43
|
+
break
|
|
44
|
+
i += 1
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(html[start : i + 1])
|
|
48
|
+
except json.JSONDecodeError as e:
|
|
49
|
+
return False, f"DATA JSON parse error: {e}"
|
|
50
|
+
|
|
51
|
+
ai = data.get("agentInsights", {}).get("presets", {})
|
|
52
|
+
if not ai:
|
|
53
|
+
return False, "agentInsights.presets is empty"
|
|
54
|
+
|
|
55
|
+
errors = []
|
|
56
|
+
for pk, preset in ai.items():
|
|
57
|
+
if not isinstance(preset, dict) or not preset.get("summary"):
|
|
58
|
+
errors.append(f"preset {pk}: missing summary")
|
|
59
|
+
continue
|
|
60
|
+
for h in preset.get("highlights", []):
|
|
61
|
+
if not isinstance(h, dict):
|
|
62
|
+
errors.append(f"preset {pk}: highlight is not an object")
|
|
63
|
+
elif "title" not in h or "detail" not in h or "severity" not in h:
|
|
64
|
+
errors.append(f"preset {pk}: highlight missing title/detail/severity")
|
|
65
|
+
elif h["severity"] not in ("high", "medium", "low"):
|
|
66
|
+
errors.append(
|
|
67
|
+
f"preset {pk}: highlight severity '{h['severity']}' invalid (must be high/medium/low)"
|
|
68
|
+
)
|
|
69
|
+
for ac in preset.get("actions", []):
|
|
70
|
+
if not isinstance(ac, dict):
|
|
71
|
+
errors.append(f"preset {pk}: action is not an object")
|
|
72
|
+
elif "text" not in ac or "priority" not in ac:
|
|
73
|
+
errors.append(f"preset {pk}: action missing text/priority")
|
|
74
|
+
elif ac["priority"] not in ("P0", "P1", "P2"):
|
|
75
|
+
errors.append(
|
|
76
|
+
f"preset {pk}: action priority '{ac['priority']}' invalid (must be P0/P1/P2)"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if errors:
|
|
80
|
+
return False, "; ".join(errors)
|
|
81
|
+
return True, "OK"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main():
|
|
85
|
+
if len(sys.argv) < 2:
|
|
86
|
+
print(f"Usage: {sys.argv[0]} <report_html_path>", file=sys.stderr)
|
|
87
|
+
sys.exit(2)
|
|
88
|
+
|
|
89
|
+
html_path = sys.argv[1]
|
|
90
|
+
success, message = validate_report(html_path)
|
|
91
|
+
|
|
92
|
+
if success:
|
|
93
|
+
print(f"RENDER_CHECK: {message}")
|
|
94
|
+
sys.exit(0)
|
|
95
|
+
else:
|
|
96
|
+
print(f"FAIL: {message}")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|