@miraiva_test/miravia-order-report 0.1.13 → 0.1.15

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/dist/SKILL.md CHANGED
@@ -5,6 +5,21 @@ description: Query Miravia seller order data via Open API, export filtered order
5
5
 
6
6
  # Miravia Order Report
7
7
 
8
+ ## ⚠ Agent Self-Check Protocol (MANDATORY before task completion)
9
+
10
+ Before telling the user "report is ready", the Agent MUST answer ALL of the following:
11
+
12
+ | # | Question | Required Answer |
13
+ |---|----------|----------------|
14
+ | 1 | Is this a report scenario (refund/business/auto)? | If NO → skip to Step 6. If YES → continue. |
15
+ | 2 | Did I read `metrics.json` and generate `insights.json`? | Must be YES |
16
+ | 3 | Did I run `render_final.py` and see `RENDER_CHECK: OK`? | Must be YES |
17
+ | 4 | Is the final HTML path shown in stdout? | Must be YES |
18
+
19
+ **If any answer is NO for a report scenario → return to Step 5. Do NOT present the report.**
20
+
21
+ ---
22
+
8
23
  A Skill for Miravia (M2A) sellers. Pulls order, sub-order item and reverse-order data from the official Miravia Open API, then produces **different outputs dynamically based on scenario**:
9
24
 
10
25
  | Scenario | Output | Use Case |
@@ -50,7 +65,7 @@ These rules are referenced throughout the workflow. Each is defined **once** her
50
65
  | C3 | **Time estimate first** | Formula: `ceil(orders / 6000)` minutes. Show only time, not count. |
51
66
  | C4 | **Status filter confirmation** | Natural language status → present supported status table, ask user to confirm. Technical values (e.g., `--status canceled`) → no confirmation needed. |
52
67
  | C5 | **Auto-open browser** | Always auto-open (auth page, HTML report). Only provide URL as fallback if `open` command fails. |
53
- | C6 | **Executive Summary = LLM-generated** | The script's rule-engine placeholder MUST NOT be delivered directly. Agent must analyze `metrics.json` and inject AI insights. |
68
+ | C6 | **Executive Summary = LLM-generated (BLOCKING)** | Report scenarios (refund/business/auto) do NOT produce HTML until AI insights pass validation. Pipeline: export_orders.py metrics.json + report_data.json Agent generates insights.json → render_final.py validates & renders HTML. Invalid insights = no HTML output. |
54
69
  | C7 | **API retry** | Up to 3 retries per API call; report error only after all 3 fail. |
55
70
  | C8 | **Volume limit** | Hard cap 200,000 sub-orders. Exceeded → suggest splitting by month/week. |
56
71
  | C9 | **Zero data early-stop** | `no_data: true` → stop immediately, inform merchant, do NOT generate files. |
@@ -203,47 +218,49 @@ python scripts/export_orders.py \
203
218
  - Detect `metrics :` line → **stop polling, enter Step 5** (do NOT announce "report complete" yet).
204
219
  - `export` scenario (no `metrics :`) → skip to Step 6.
205
220
 
206
- ### Step 5: AI Insights Injection (C6, mandatory for refund/business/auto)
221
+ **Output for report scenarios (refund/business/auto):**
222
+ - `report_data :` → pre-computed dashboard data (used by render_final.py)
223
+ - `metrics :` → structured metrics for AI analysis
224
+ - `TASK_INCOMPLETE:` → HTML is NOT yet generated; proceed to Step 5
225
+
226
+ ### Step 5: AI Insights Generation & Final Render (MANDATORY for refund/business/auto)
207
227
 
208
228
  > **Full specification**: [executive_summary_prompt_v2.md](executive_summary_prompt_v2.md)
209
229
 
210
230
  **① Read** `metrics.json` (path from stdout `metrics :` line).
211
231
 
212
232
  **② Generate** `insights.json` following ALL rules in `executive_summary_prompt_v2.md`:
213
- - Filename: replace `metrics_` prefix with `insights_` (same timestamp suffix).
214
- - Schema: `{ "lang": "<code>", "generated_at": "<ISO8601>", "presets": { "0": { "summary", "highlights[]", "actions[]" }, ... } }`
233
+ - Filename: `insights.json` in the output directory.
234
+ - Schema: `{ "presets": { "0": { "summary", "highlights[]", "actions[]" }, ... } }`
215
235
  - Field constraints: `severity` ∈ {`high`, `medium`, `low`}; `priority` ∈ {`P0`, `P1`, `P2`}; `highlights` need `title` + `detail` + `severity`; `actions` need `text` + `priority`.
216
236
  - JSON encoding: ASCII quotes only, no smart quotes/em-dashes/currency symbols. See prompt file §1 for full rules.
237
+ - **Top-level has ONLY `presets`** — do NOT add `lang`, `generated_at`, or other fields.
217
238
 
218
239
  **③ Validate** JSON:
219
240
  ```bash
220
241
  python3 -c "import json; json.load(open('<insights_path>'))" && echo 'OK'
221
242
  ```
222
243
 
223
- **④ Inject** into HTML:
244
+ **④ Render final HTML** (insights validation is built-in — invalid insights = render refused):
224
245
  ```bash
225
- python3 scripts/inject_insights.py --html <report_path> --insights <insights_path>
246
+ python3 scripts/render_final.py --report-data <report_data_path> --insights <insights_path> --output <DIR> --lang <LANG>
226
247
  ```
227
248
 
228
- **⑤ Validate rendering**:
229
- ```bash
230
- python3 scripts/validate_render.py <report_path>
231
- ```
232
- - `RENDER_CHECK: OK`proceed to ⑥.
233
- - `FAIL: ...` → clear insights and re-inject empty (triggers frontend fallback):
234
- ```bash
235
- python3 -c "import json; open('<insights_path>','w').write(json.dumps({'presets':{}}))"
236
- python3 scripts/inject_insights.py --html <report_path> --insights <insights_path>
237
- ```
238
-
239
- **⑥ Open report** (C5):
240
- ```bash
241
- open <report_path> # macOS
242
- xdg-open <report_path> # Linux
243
- ```
249
+ **Expected stdout signals:**
250
+ - `insights : validated OK` → structure passed
251
+ - `report :` → HTML file path
252
+ - `RENDER_CHECK: OK` → ✅ proceed to Step 6
253
+ - Exit code 2 = insights validation failed regenerate insights.json and retry
254
+
255
+ **⑤ If `RENDER_CHECK: OK`** → report is complete, proceed to Step 6.
256
+
257
+ **⑤b If exit code 2** (validation failed) → fix insights.json and re-run render_final.py.
258
+ Common fixes: ensure all `highlights` are objects (not strings), check `severity`/`priority` values.
244
259
 
245
260
  ### Step 6: Present Results
246
261
 
262
+ **Precondition (refund/business/auto):** `RENDER_CHECK: OK` observed in Step 5 stdout. If not met → return to Step 5.
263
+
247
264
  - `export` → "Export file: `<path>`"
248
265
  - `refund`/`business`/`auto` → "Report opened in your browser. File path: `<path>`" (only show path if open fails).
249
266
  - **Never mention**: pagination, API internals, offset/limit, time-window slicing.
@@ -300,9 +317,12 @@ See [reference.md](reference.md) for field mapping and Excel column definitions.
300
317
  | `progress: fetched X orders` | Batch progress | "Fetched X orders..." (strip total) |
301
318
  | `progress: M main, N sub-order rows` | Final aggregation | "Retrieved M orders, N records" (safe to show) |
302
319
  | `output :` | Excel/CSV path | Show to user |
303
- | `report :` | HTML path | Internal: proceed to Step 5 |
304
- | `metrics :` | Metrics JSON path | Internal: read for LLM analysis |
305
- | `injected :` | Injection success | Internal confirmation |
320
+ | `report_data :` | Pre-computed data JSON path | Internal: used by render_final.py |
321
+ | `metrics :` | Metrics JSON path | **STOP polling enter Step 5** (do NOT announce completion) |
322
+ | `NEXT_ACTION_REQUIRED:` | render_final.py command | Agent MUST generate insights then execute this |
323
+ | `TASK_INCOMPLETE:` | Hard gate signal | HTML not yet generated — insights + render pending |
324
+ | `report :` | Final HTML path (from render_final.py) | Show to user, open in browser |
325
+ | `RENDER_CHECK: OK` | HTML validated | Step 5 complete ✅ (proceed to Step 6) |
306
326
  | `no_data :` | Zero orders | Inform merchant, stop (C9) |
307
327
  | `estimate :` | Time estimate JSON | Show only time (C2, C3) |
308
328
  | `ping OK/FAIL` | Credential check | Step 2 flow |
@@ -320,8 +340,9 @@ Features: Platform filter (All/Miravia/M2A), time filter (all/7d/30d), Chart.js
320
340
  ## Related Documents
321
341
 
322
342
  | Document | Purpose |
323
- |----------|---------|
343
+ |----------|--------|
324
344
  | [executive_summary_prompt_v2.md](executive_summary_prompt_v2.md) | Authoritative prompt for AI insights generation |
345
+ | [scripts/render_final.py](scripts/render_final.py) | Final HTML renderer (validates insights before rendering) |
325
346
  | [reference.md](reference.md) | API fields & Excel column mapping |
326
347
  | [docs/windows_compat.md](docs/windows_compat.md) | Windows Constrained Language Mode bypass |
327
348
  | [docs/vm_fallback.md](docs/vm_fallback.md) | VM/sandbox authorization fallback |
package/dist/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.13
1
+ 0.1.15
@@ -507,7 +507,13 @@ def _decrypt_cipher_to_creds(
507
507
 
508
508
 
509
509
  def _fmt_expires(expires_str: str) -> str:
510
- """Format expiry time string (YYYY-MM-DD HH:MM) with remaining time description."""
510
+ """Format expiry time string (YYYY-MM-DD HH:MM) with remaining time description.
511
+
512
+ Since expires_at is stored at minute precision (YYYY-MM-DD HH:MM, i.e. seconds=0),
513
+ we align 'now' to the same minute granularity (truncate seconds to 0) before computing
514
+ the remaining delta. This prevents display artifacts like '29d 23h 59m' when the actual
515
+ remaining time is effectively 30 full days.
516
+ """
511
517
  if not expires_str:
512
518
  return "<unknown>"
513
519
  # Try parsing YYYY-MM-DD HH:MM format
@@ -521,7 +527,10 @@ def _fmt_expires(expires_str: str) -> str:
521
527
  ts = int(expires_str)
522
528
  except (ValueError, TypeError):
523
529
  return expires_str
524
- delta = ts - int(time.time())
530
+ # Align now to minute boundary (truncate seconds) to match expires_at precision
531
+ now_ts = int(time.time())
532
+ now_ts = now_ts - (now_ts % 60)
533
+ delta = ts - now_ts
525
534
  iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
526
535
  if delta <= 0:
527
536
  return f"{iso} (EXPIRED {-delta}s ago)"
@@ -717,6 +726,9 @@ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
717
726
 
718
727
  Parses ``YYYY-MM-DD HH:MM`` format (produced by _expires_at()).
719
728
  Falls back to fixed 30-day text on failure, ensuring callback page is not blocked.
729
+
730
+ Since expires_at is minute-precision, aligns 'now' to the same granularity
731
+ to avoid '29 days' display when the actual validity is effectively 30 days.
720
732
  """
721
733
  t = _I18N_TEXTS.get(lang, _I18N_TEXTS[_DEFAULT_LANG])
722
734
  if not expires_at:
@@ -732,7 +744,10 @@ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
732
744
  ts = int(expires_at)
733
745
  except (TypeError, ValueError):
734
746
  return t["token_valid_30days"]
735
- delta = ts - int(time.time())
747
+ # Align now to minute boundary to match expires_at precision
748
+ now_ts = int(time.time())
749
+ now_ts = now_ts - (now_ts % 60)
750
+ delta = ts - now_ts
736
751
  if delta <= 0:
737
752
  return t["token_expired"]
738
753
  iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
@@ -1334,13 +1334,28 @@ def run(args: argparse.Namespace) -> int:
1334
1334
  print(f"output : {excel_path}")
1335
1335
 
1336
1336
  if do_report:
1337
- html_path = out_dir / f"miravia_order_report_{fname_range}_{ts}.html"
1338
- html_path = _resolve_collision(html_path)
1339
- render_v4_report(rows, html_path, range_label=range_label, lang=getattr(args, 'lang', 'en'))
1340
- print(f"report : {html_path}")
1337
+ # Phase 1: Compute report data payload and metrics (NO HTML yet).
1338
+ # HTML rendering is deferred to render_final.py AFTER Agent generates
1339
+ # and validates insights.json. This ensures the final HTML always
1340
+ # contains a complete AI Executive Summary.
1341
+ import report_v4
1342
+
1343
+ # Compute pre-aggregated report data (window.DATA payload)
1344
+ report_data = report_v4.prepare_report_data(
1345
+ rows=rows,
1346
+ range_label=range_label,
1347
+ export_columns=EXPORT_COLUMNS,
1348
+ lang=getattr(args, 'lang', 'en'),
1349
+ )
1350
+ report_data_path = out_dir / f"report_data_{fname_range}_{ts}.json"
1351
+ report_data_path = _resolve_collision(report_data_path)
1352
+ report_data_path.write_text(
1353
+ json.dumps(report_data, ensure_ascii=False, indent=2, default=str),
1354
+ encoding="utf-8",
1355
+ )
1356
+ print(f"report_data : {report_data_path}")
1341
1357
 
1342
1358
  # Export structured metrics for Agent LLM analysis
1343
- import report_v4
1344
1359
  metrics_data = report_v4.export_metrics(
1345
1360
  rows=rows,
1346
1361
  export_columns=EXPORT_COLUMNS,
@@ -1355,8 +1370,9 @@ def run(args: argparse.Namespace) -> int:
1355
1370
  )
1356
1371
  print(f"metrics : {metrics_path}")
1357
1372
 
1358
- if getattr(args, "open_browser", True):
1359
- _open_in_browser(html_path)
1373
+ # Hard gate: Agent MUST generate insights and call render_final.py
1374
+ print(f"NEXT_ACTION_REQUIRED: python3 scripts/render_final.py --report-data {report_data_path} --insights <insights_path> --output {out_dir} --lang {getattr(args, 'lang', 'en')}")
1375
+ print(f"TASK_INCOMPLETE: Data exported. Agent must now generate insights.json from metrics, then call render_final.py to produce the final HTML report.")
1360
1376
 
1361
1377
  # Write skill invocation log
1362
1378
  _write_run_log(
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Render final HTML report with pre-validated AI insights (SKILL.md Step 5 terminal).
4
+
5
+ This script is the MANDATORY final step for report scenarios (refund/business/auto).
6
+ It performs strict validation of insights.json before rendering HTML, ensuring
7
+ the delivered report always contains a complete AI Executive Summary.
8
+
9
+ Workflow:
10
+ 1. export_orders.py generates: report_data.json + metrics.json
11
+ 2. Agent reads metrics.json, generates insights.json (LLM analysis)
12
+ 3. THIS SCRIPT validates insights.json structure → renders final HTML
13
+
14
+ Usage:
15
+ python3 scripts/render_final.py \
16
+ --report-data ./out/report_data_xxx.json \
17
+ --insights ./out/insights.json \
18
+ --output ./out \
19
+ --lang en
20
+
21
+ Prerequisites:
22
+ - report_data.json: produced by export_orders.py (pre-computed window.DATA payload)
23
+ - insights.json: produced by Agent per executive_summary_prompt_v2.md
24
+
25
+ Exit codes:
26
+ 0 = success (HTML rendered and opened)
27
+ 1 = argument/file error
28
+ 2 = insights validation failed (Agent must regenerate)
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import sys
36
+ import time
37
+ from pathlib import Path
38
+ from typing import Any, Dict
39
+
40
+ # Allow direct execution from scripts/ directory
41
+ THIS_DIR = Path(__file__).resolve().parent
42
+ sys.path.insert(0, str(THIS_DIR))
43
+
44
+
45
+ # -------- Insights Validation --------
46
+
47
+ def _validate_insight_object(insight: Any, preset_key: str) -> list[str]:
48
+ """Validate a single InsightObject against frontend _validateInsight() rules.
49
+
50
+ Returns a list of error messages (empty = valid).
51
+ """
52
+ errors: list[str] = []
53
+ prefix = f"presets.{preset_key}"
54
+
55
+ if not insight or not isinstance(insight, dict):
56
+ errors.append(f"{prefix}: must be a non-null object")
57
+ return errors
58
+
59
+ # summary: required string
60
+ summary = insight.get("summary")
61
+ if not isinstance(summary, str) or not summary.strip():
62
+ errors.append(f"{prefix}.summary: must be a non-empty string")
63
+
64
+ # highlights: required array of objects
65
+ highlights = insight.get("highlights")
66
+ if not isinstance(highlights, list):
67
+ errors.append(f"{prefix}.highlights: must be an array")
68
+ else:
69
+ for i, h in enumerate(highlights):
70
+ if not isinstance(h, dict):
71
+ errors.append(f"{prefix}.highlights[{i}]: must be an object, got {type(h).__name__}")
72
+ continue
73
+ if not isinstance(h.get("title"), str):
74
+ errors.append(f"{prefix}.highlights[{i}].title: must be a string")
75
+ if not isinstance(h.get("detail"), str):
76
+ errors.append(f"{prefix}.highlights[{i}].detail: must be a string")
77
+ sev = h.get("severity")
78
+ if sev not in ("high", "medium", "low"):
79
+ errors.append(f"{prefix}.highlights[{i}].severity: must be high/medium/low, got {sev!r}")
80
+
81
+ # actions: required array of objects
82
+ actions = insight.get("actions")
83
+ if not isinstance(actions, list):
84
+ errors.append(f"{prefix}.actions: must be an array")
85
+ else:
86
+ for i, ac in enumerate(actions):
87
+ if not isinstance(ac, dict):
88
+ errors.append(f"{prefix}.actions[{i}]: must be an object, got {type(ac).__name__}")
89
+ continue
90
+ if not isinstance(ac.get("text"), str):
91
+ errors.append(f"{prefix}.actions[{i}].text: must be a string")
92
+ pri = ac.get("priority")
93
+ if pri not in ("P0", "P1", "P2"):
94
+ errors.append(f"{prefix}.actions[{i}].priority: must be P0/P1/P2, got {pri!r}")
95
+
96
+ return errors
97
+
98
+
99
+ def validate_insights(insights_data: Any) -> tuple[bool, list[str]]:
100
+ """Validate insights.json structure per executive_summary_prompt_v2.md §8.
101
+
102
+ Checks:
103
+ 1. Top-level has "presets" key (dict)
104
+ 2. Each preset value is a valid InsightObject
105
+ 3. At least one non-empty preset exists
106
+
107
+ Returns:
108
+ (is_valid, error_messages)
109
+ """
110
+ errors: list[str] = []
111
+
112
+ if not isinstance(insights_data, dict):
113
+ return False, ["Root must be a JSON object"]
114
+
115
+ presets = insights_data.get("presets")
116
+ if not isinstance(presets, dict):
117
+ return False, ["Root must contain 'presets' key as an object"]
118
+
119
+ if not presets:
120
+ return False, ["'presets' is empty — no analysis generated"]
121
+
122
+ for key, value in presets.items():
123
+ errs = _validate_insight_object(value, key)
124
+ errors.extend(errs)
125
+
126
+ is_valid = len(errors) == 0
127
+ return is_valid, errors
128
+
129
+
130
+ # -------- Rendering --------
131
+
132
+ def _jinja_env():
133
+ from jinja2 import Environment, FileSystemLoader
134
+ return Environment(
135
+ loader=FileSystemLoader(str(THIS_DIR.parent / "templates")),
136
+ autoescape=True,
137
+ )
138
+
139
+
140
+ def render_html(report_data: Dict[str, Any], insights_data: Dict[str, Any],
141
+ out_dir: Path, lang: str = "en") -> Path:
142
+ """Inject validated insights into report data and render final HTML.
143
+
144
+ Returns the path to the generated HTML file.
145
+ """
146
+ import report_v4
147
+
148
+ # Inject insights into the data payload
149
+ report_data["agentInsights"] = insights_data
150
+
151
+ # Determine output filename from meta
152
+ range_start = report_data.get("meta", {}).get("range_start", "")
153
+ range_end = report_data.get("meta", {}).get("range_end", "")
154
+ range_label = f"{range_start} ~ {range_end}" if range_end else range_start
155
+
156
+ # Generate filename
157
+ ts = time.strftime("%Y%m%d%H%M%S")
158
+
159
+ def _safe_fn(s: str) -> str:
160
+ import re
161
+ m = re.match(r'^(.*?)([+-]\d{2}:\d{2}|Z)?$', s)
162
+ if m:
163
+ base, tz = m.group(1), m.group(2) or ""
164
+ base = base.replace(":", "").replace("T", "_")
165
+ tz = tz.replace(":", "").replace("+", "p").replace("-", "m")
166
+ return base + tz
167
+ return s.replace(":", "").replace("+", "p").replace("-", "m").replace("T", "_")
168
+
169
+ fname_range = f"{_safe_fn(range_start)}_{_safe_fn(range_end)}" if range_end else _safe_fn(range_start)
170
+ html_path = out_dir / f"miravia_order_report_{fname_range}_{ts}.html"
171
+
172
+ # Avoid collision
173
+ if html_path.exists():
174
+ for i in range(1, 100):
175
+ cand = html_path.parent / f"{html_path.stem}_{i}{html_path.suffix}"
176
+ if not cand.exists():
177
+ html_path = cand
178
+ break
179
+
180
+ # Render via report_v4.render_from_data()
181
+ jinja_env = _jinja_env()
182
+ report_v4.render_from_data(report_data, html_path, range_label, jinja_env, lang)
183
+ return html_path
184
+
185
+
186
+ def _open_in_browser(html_path: Path) -> None:
187
+ """Open HTML report in default browser (best-effort)."""
188
+ import webbrowser
189
+ import subprocess
190
+ import platform
191
+
192
+ url = html_path.resolve().as_uri()
193
+ try:
194
+ if webbrowser.open(url, new=2):
195
+ print(f"opened : {url}")
196
+ return
197
+ except Exception as e:
198
+ print(f"warning: webbrowser.open failed: {e}", file=sys.stderr)
199
+ try:
200
+ sysname = platform.system()
201
+ if sysname == "Darwin":
202
+ subprocess.Popen(["open", str(html_path)])
203
+ elif sysname == "Windows":
204
+ os.startfile(str(html_path)) # type: ignore[attr-defined]
205
+ else:
206
+ subprocess.Popen(["xdg-open", str(html_path)])
207
+ print(f"opened : {url}")
208
+ except Exception as e:
209
+ print(f"warning: failed to open browser ({e}); open manually: {html_path}",
210
+ file=sys.stderr)
211
+
212
+
213
+ # -------- CLI --------
214
+
215
+ def main() -> int:
216
+ p = argparse.ArgumentParser(
217
+ prog="render_final",
218
+ description=(
219
+ "Render final HTML report with validated AI insights. "
220
+ "This is the terminal step of the report pipeline — HTML is only "
221
+ "produced after insights pass structural validation."
222
+ ),
223
+ )
224
+ p.add_argument("--report-data", required=True,
225
+ help="Path to report_data JSON (from export_orders.py)")
226
+ p.add_argument("--insights", required=True,
227
+ help="Path to insights.json (Agent-generated)")
228
+ p.add_argument("--output", default="./out",
229
+ help="Output directory for the HTML file")
230
+ p.add_argument("--lang", default="en", choices=["en", "es", "it", "zh"],
231
+ help="Report language")
232
+ p.add_argument("--no-open", dest="open_browser", action="store_false",
233
+ default=True, help="Do not auto-open browser after render")
234
+ args = p.parse_args()
235
+
236
+ # 1. Load report_data
237
+ report_data_path = Path(args.report_data).expanduser().resolve()
238
+ if not report_data_path.exists():
239
+ print(f"error: report_data file not found: {report_data_path}", file=sys.stderr)
240
+ return 1
241
+ try:
242
+ report_data = json.loads(report_data_path.read_text(encoding="utf-8"))
243
+ except (json.JSONDecodeError, OSError) as e:
244
+ print(f"error: failed to read report_data: {e}", file=sys.stderr)
245
+ return 1
246
+
247
+ # 2. Load insights
248
+ insights_path = Path(args.insights).expanduser().resolve()
249
+ if not insights_path.exists():
250
+ print(f"error: insights file not found: {insights_path}", file=sys.stderr)
251
+ return 1
252
+ try:
253
+ insights_data = json.loads(insights_path.read_text(encoding="utf-8"))
254
+ except json.JSONDecodeError as e:
255
+ print(f"error: insights.json is not valid JSON: {e}", file=sys.stderr)
256
+ print(" Agent must regenerate insights.json from scratch.", file=sys.stderr)
257
+ return 2
258
+
259
+ # 3. Validate insights structure (BLOCKING — invalid insights = no HTML)
260
+ is_valid, errors = validate_insights(insights_data)
261
+ if not is_valid:
262
+ print("error: insights.json failed structural validation:", file=sys.stderr)
263
+ for err in errors[:10]: # show first 10 errors
264
+ print(f" - {err}", file=sys.stderr)
265
+ if len(errors) > 10:
266
+ print(f" ... and {len(errors) - 10} more errors", file=sys.stderr)
267
+ print("\nAgent must fix insights.json and retry. See executive_summary_prompt_v2.md §8.",
268
+ file=sys.stderr)
269
+ return 2
270
+
271
+ print(f"insights : validated OK ({len(insights_data.get('presets', {}))} presets)")
272
+
273
+ # 4. Render final HTML
274
+ out_dir = Path(args.output).expanduser().resolve()
275
+ out_dir.mkdir(parents=True, exist_ok=True)
276
+ html_path = render_html(report_data, insights_data, out_dir, lang=args.lang)
277
+ print(f"report : {html_path}")
278
+
279
+ # 5. Validate rendering (lightweight check: file exists and has reasonable size)
280
+ if html_path.exists():
281
+ size_kb = html_path.stat().st_size / 1024
282
+ if size_kb < 10:
283
+ print(f"warning: HTML file suspiciously small ({size_kb:.1f} KB)", file=sys.stderr)
284
+ print(f"RENDER_CHECK: OK (size={size_kb:.0f}KB)")
285
+ else:
286
+ print("RENDER_CHECK: FAIL (file not created)", file=sys.stderr)
287
+ return 1
288
+
289
+ # 6. Open in browser
290
+ if args.open_browser:
291
+ _open_in_browser(html_path)
292
+
293
+ return 0
294
+
295
+
296
+ if __name__ == "__main__":
297
+ sys.exit(main())
@@ -1608,8 +1608,15 @@ _PRESET_DAYS = [7, 30, 90, 180, 365]
1608
1608
 
1609
1609
  def render(rows: List[Dict[str, Any]], out_path: Path,
1610
1610
  range_label: str, jinja_env, export_columns,
1611
- default_max_orders: int, lang: str = "en") -> Path:
1612
- """Render v4 unified dashboard HTML with embedded AI-generated insights."""
1611
+ default_max_orders: int, lang: str = "en",
1612
+ insights: Any = None) -> Path:
1613
+ """Render v4 unified dashboard HTML with embedded AI-generated insights.
1614
+
1615
+ Args:
1616
+ insights: Optional pre-validated insights dict. If provided, it is
1617
+ embedded directly into window.DATA.agentInsights, eliminating
1618
+ the need for post-hoc inject_insights.py injection.
1619
+ """
1613
1620
  import pandas as pd
1614
1621
 
1615
1622
  t = get_translations(lang)
@@ -1638,11 +1645,11 @@ def render(rows: List[Dict[str, Any]], out_path: Path,
1638
1645
  else:
1639
1646
  preset_data[str(days)] = all_dims # same as all, share reference
1640
1647
 
1641
- # ---- agentInsights placeholder (LLM injects real content via inject_insights.py) ----
1642
- # The rule-engine generate_insights() has been retired — only LLM-generated
1643
- # multi-language insights are used. The empty placeholder ensures the HTML
1644
- # renders gracefully until inject_insights.py overwrites this field.
1645
- agent_insights = {"presets": {}}
1648
+ # ---- agentInsights: use provided insights or empty placeholder ----
1649
+ if insights and isinstance(insights, dict):
1650
+ agent_insights = insights
1651
+ else:
1652
+ agent_insights = {"presets": {}}
1646
1653
 
1647
1654
  # ---- assemble data payload ----
1648
1655
  range_start = range_label.split(" ~ ")[0] if " ~ " in range_label else range_label
@@ -1664,6 +1671,78 @@ def render(rows: List[Dict[str, Any]], out_path: Path,
1664
1671
  **all_dims,
1665
1672
  }
1666
1673
 
1674
+ return render_from_data(data, out_path, range_label, jinja_env, lang)
1675
+
1676
+
1677
+ def prepare_report_data(rows: List[Dict[str, Any]], range_label: str,
1678
+ export_columns, lang: str = "en") -> Dict[str, Any]:
1679
+ """Compute report data payload WITHOUT rendering HTML.
1680
+
1681
+ Returns the complete window.DATA dict (minus agentInsights) that can be
1682
+ serialized to JSON and later passed to render_from_data() along with
1683
+ validated insights.
1684
+
1685
+ This enables the two-phase workflow:
1686
+ Phase 1: export_orders.py → rows → prepare_report_data() → report_data.json + metrics.json
1687
+ Phase 2: Agent generates insights.json
1688
+ Phase 3: render_final.py → validates insights + render_from_data() → final HTML
1689
+ """
1690
+ import pandas as pd
1691
+
1692
+ t = get_translations(lang)
1693
+
1694
+ df = pd.DataFrame(rows, columns=export_columns) if rows else pd.DataFrame(columns=export_columns)
1695
+ currency = _common_currency(df)
1696
+ overall = _compute_overall(df)
1697
+
1698
+ all_dims = _compute_dims(df, currency, t=t)
1699
+ preset_data: Dict[str, Any] = {"0": all_dims}
1700
+
1701
+ if not df.empty:
1702
+ dates = pd.to_datetime(df["created_at"], errors="coerce")
1703
+ max_date = dates.max()
1704
+ for days in _PRESET_DAYS:
1705
+ cutoff = max_date.normalize() - pd.Timedelta(days=days - 1)
1706
+ subset = df[dates >= cutoff]
1707
+ if len(subset) < len(df):
1708
+ preset_data[str(days)] = _compute_dims(subset, currency, t=t)
1709
+ else:
1710
+ preset_data[str(days)] = all_dims
1711
+
1712
+ range_start = range_label.split(" ~ ")[0] if " ~ " in range_label else range_label
1713
+ range_end = range_label.split(" ~ ")[1] if " ~ " in range_label else ""
1714
+
1715
+ return {
1716
+ "meta": {
1717
+ "range_start": range_start,
1718
+ "range_end": range_end,
1719
+ "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
1720
+ "currency": currency,
1721
+ "total_orders": overall["orders"],
1722
+ "total_items": overall["items"],
1723
+ "buyer_id_available": _check_buyer_id_available(df),
1724
+ },
1725
+ "dailyData": _build_daily_data(df, range_start=range_start, range_end=range_end),
1726
+ "presetData": preset_data,
1727
+ "agentInsights": {"presets": {}}, # placeholder; replaced at render time
1728
+ # Top-level dimensional fields (backward compat)
1729
+ **all_dims,
1730
+ }
1731
+
1732
+
1733
+ def render_from_data(data: Dict[str, Any], out_path: Path,
1734
+ range_label: str, jinja_env,
1735
+ lang: str = "en") -> Path:
1736
+ """Render HTML from a pre-computed data payload.
1737
+
1738
+ This is the final rendering step. Accepts an already-assembled data dict
1739
+ (from prepare_report_data or render) and produces the HTML file.
1740
+ If data['agentInsights'] contains validated insights, they will be embedded
1741
+ directly — no post-hoc injection needed.
1742
+ """
1743
+ t = get_translations(lang)
1744
+ currency = data.get("meta", {}).get("currency", "EUR")
1745
+
1667
1746
  # ---- load static assets for inline embedding ----
1668
1747
  _static_dir = Path(__file__).resolve().parent.parent / "static"
1669
1748
  _chartjs_path = _static_dir / "chart.umd.min.js"
@@ -974,9 +974,18 @@ function switchLang(lang){
974
974
  document.documentElement.lang=T.lang_code||lang;
975
975
  document.title=T.page_title||document.title;
976
976
  // Update all static elements with data-i18n attribute
977
+ // Preserve child elements (e.g. ai-badge spans) by only updating text nodes
977
978
  document.querySelectorAll('[data-i18n]').forEach(el=>{
978
979
  const key=el.dataset.i18n;
979
- if(T[key]!=null)el.textContent=T[key];
980
+ if(T[key]==null)return;
981
+ if(el.children.length>0){
982
+ // Element has child elements — only replace the leading text node
983
+ const first=el.firstChild;
984
+ if(first&&first.nodeType===3){first.textContent=T[key]+' ';}
985
+ else{el.insertBefore(document.createTextNode(T[key]+' '),el.firstChild);}
986
+ }else{
987
+ el.textContent=T[key];
988
+ }
980
989
  });
981
990
  // Re-init time filter buttons & platform filters with new labels
982
991
  initTF();initPlatformFilters();
@@ -0,0 +1,91 @@
1
+ /**
2
+ * build-zip.js — Generate miravia-order-report.zip for OSS upload.
3
+ *
4
+ * Usage: node lib/build-zip.js
5
+ *
6
+ * Steps:
7
+ * 1. Run build-dist.js to regenerate dist/ (with VERSION stamp)
8
+ * 2. Zip dist/ contents → <workspace>/dist/miravia-order-report.zip
9
+ *
10
+ * This script is the SINGLE source of truth for zip packaging.
11
+ * Do NOT manually run zip commands — always use this script.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { execSync } = require('child_process');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+
20
+ // ---------- Paths ----------
21
+ const LIB_DIR = __dirname;
22
+ const PACKAGE_ROOT = path.join(LIB_DIR, '..');
23
+ const DIST_DIR = path.join(PACKAGE_ROOT, 'dist');
24
+
25
+ // Workspace root: traverse up to find pom.xml at project root
26
+ function findWorkspaceRoot(start) {
27
+ let dir = start;
28
+ while (dir !== path.dirname(dir)) {
29
+ if (fs.existsSync(path.join(dir, 'pom.xml')) && fs.existsSync(path.join(dir, 'ai-skill'))) {
30
+ return dir;
31
+ }
32
+ dir = path.dirname(dir);
33
+ }
34
+ // Fallback: 10 levels up from npm-package/lib
35
+ return path.resolve(PACKAGE_ROOT, '..', '..', '..', '..', '..', '..', '..', '..');
36
+ }
37
+
38
+ const WORKSPACE_ROOT = findWorkspaceRoot(PACKAGE_ROOT);
39
+ const OUTPUT_DIR = path.join(WORKSPACE_ROOT, 'dist');
40
+ const OUTPUT_ZIP = path.join(OUTPUT_DIR, 'miravia-order-report.zip');
41
+
42
+ // ---------- Step 1: Build dist/ ----------
43
+ console.log('━━━ Step 1: Building dist/ via build-dist.js ━━━\n');
44
+ execSync(`node ${path.join(LIB_DIR, 'build-dist.js')}`, { stdio: 'inherit' });
45
+
46
+ // Verify dist/ exists and has VERSION
47
+ if (!fs.existsSync(path.join(DIST_DIR, 'VERSION'))) {
48
+ console.error('\n✗ ERROR: dist/VERSION not found after build. Aborting.');
49
+ process.exit(1);
50
+ }
51
+
52
+ // ---------- Step 2: Create zip ----------
53
+ console.log('\n━━━ Step 2: Creating zip package ━━━\n');
54
+
55
+ // Ensure output directory exists
56
+ if (!fs.existsSync(OUTPUT_DIR)) {
57
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
58
+ }
59
+
60
+ // Remove old zip if exists
61
+ if (fs.existsSync(OUTPUT_ZIP)) {
62
+ fs.unlinkSync(OUTPUT_ZIP);
63
+ }
64
+
65
+ // Create zip from dist/ contents (no wrapping folder, flat structure)
66
+ try {
67
+ execSync(`zip -r "${OUTPUT_ZIP}" . -x "*.DS_Store"`, {
68
+ cwd: DIST_DIR,
69
+ stdio: 'inherit',
70
+ });
71
+ } catch (e) {
72
+ console.error('\n✗ ERROR: zip command failed. Ensure zip is installed.');
73
+ process.exit(1);
74
+ }
75
+
76
+ // ---------- Step 3: Verify ----------
77
+ console.log('\n━━━ Step 3: Verification ━━━\n');
78
+
79
+ const stat = fs.statSync(OUTPUT_ZIP);
80
+ const sizeKB = (stat.size / 1024).toFixed(1);
81
+ const version = fs.readFileSync(path.join(DIST_DIR, 'VERSION'), 'utf8').trim();
82
+
83
+ console.log(` ✓ Output: ${OUTPUT_ZIP}`);
84
+ console.log(` ✓ Size: ${sizeKB} KB`);
85
+ console.log(` ✓ Version: ${version}`);
86
+
87
+ // ---------- Step 4: Clean up dist/ ----------
88
+ fs.rmSync(DIST_DIR, { recursive: true });
89
+ console.log(` ✓ Cleaned: dist/ removed`);
90
+
91
+ console.log(`\n✔ Done. Ready to upload to OSS.`);
package/lib/installer.js CHANGED
@@ -28,6 +28,9 @@ const AGENT_DIRS = {
28
28
  path.join(process.cwd(), '.qoder', 'skills'),
29
29
  path.join(os.homedir(), '.qoder', 'skills'),
30
30
  ],
31
+ qoderwork: [
32
+ path.join(os.homedir(), '.qoderwork', 'skills'),
33
+ ],
31
34
  cursor: [
32
35
  path.join(process.cwd(), '.cursor', 'skills'),
33
36
  path.join(os.homedir(), '.cursor', 'skills'),
@@ -36,8 +39,16 @@ const AGENT_DIRS = {
36
39
  path.join(os.homedir(), '.claude', 'skills'),
37
40
  ],
38
41
  codex: [
42
+ path.join(os.homedir(), '.agents', 'skills'),
43
+ path.join(process.cwd(), '.agents', 'skills'),
44
+ ],
45
+ antigravity: [
46
+ path.join(os.homedir(), '.agents', 'skills'),
39
47
  path.join(process.cwd(), '.agents', 'skills'),
48
+ ],
49
+ opencode: [
40
50
  path.join(os.homedir(), '.agents', 'skills'),
51
+ path.join(process.cwd(), '.agents', 'skills'),
41
52
  ],
42
53
  gemini: [
43
54
  path.join(process.cwd(), '.gemini', 'skills'),
@@ -55,6 +66,8 @@ function getAllCandidateDirs() {
55
66
  // Cross-platform standard (highest priority)
56
67
  path.join(process.cwd(), '.agents', 'skills'),
57
68
  path.join(os.homedir(), '.agents', 'skills'),
69
+ // QoderWork user-global skill directory (UI panel scans here)
70
+ path.join(os.homedir(), '.qoderwork', 'skills'),
58
71
  // Platform-specific directories
59
72
  path.join(process.cwd(), '.qoder', 'skills'),
60
73
  path.join(process.cwd(), '.cursor', 'skills'),
@@ -101,10 +114,38 @@ function detectSkillDir(flags) {
101
114
  }
102
115
  }
103
116
 
104
- // Fresh install: default to the cross-platform standard directory (.agents/skills)
117
+ // Fresh install: detect runtime environment and choose the appropriate default.
118
+ // Key concern: avoid installing into transient sandbox/workspace directories that
119
+ // the platform's UI/scanner cannot discover.
120
+
121
+ // QoderWork: CWD is inside ~/.qoderwork/workspace/<session>/ (ephemeral sandbox).
122
+ // Install to ~/.qoderwork/skills/ so the UI panel can find it.
123
+ const qoderworkWsPrefix = path.join(os.homedir(), '.qoderwork', 'workspace');
124
+ if (process.cwd().startsWith(qoderworkWsPrefix)) {
125
+ return path.join(os.homedir(), '.qoderwork', 'skills');
126
+ }
127
+
128
+ // Cloud sandbox detection (Codex / Antigravity / OpenCode):
129
+ // These run inside ephemeral containers. ~/.agents/skills is correct for the session
130
+ // but won't persist. Prefer project-level .agents/skills so user can commit to repo.
131
+ // Detect cloud sandbox: CWD is not under user's real home, or typical cloud workspace roots.
132
+ const cwd = process.cwd();
133
+ const home = os.homedir();
134
+ const isCloudSandbox = (
135
+ cwd === '/workspace' || cwd.startsWith('/workspace/') ||
136
+ cwd === '/home/user' || cwd.startsWith('/home/user/') ||
137
+ process.env.CODEX_SANDBOX === '1' ||
138
+ process.env.CLOUD_ENVIRONMENT === 'true'
139
+ );
140
+ if (isCloudSandbox) {
141
+ // Project-level: persists if user commits to git
142
+ return path.join(cwd, '.agents', 'skills');
143
+ }
144
+
145
+ // Local environment (Qoder, Cursor, Claude Code, etc.):
146
+ // Default to user-global ~/.agents/skills (cross-platform standard).
105
147
  // This is the universal convention supported by Codex, Gemini, Antigravity, OpenCode, etc.
106
- // Do NOT fall back to .qoder/skills or .cursor/skills just because they exist.
107
- return path.join(process.cwd(), '.agents', 'skills');
148
+ return path.join(home, '.agents', 'skills');
108
149
  }
109
150
 
110
151
  /**
@@ -314,7 +355,7 @@ function install(flags) {
314
355
  }
315
356
 
316
357
  console.log('\n\x1b[1m📋 Next steps:\x1b[0m');
317
- console.log(' 1. Open your AI Agent (Qoder / Cursor / Claude Code)');
358
+ console.log(' 1. Open your AI Agent (Qoder / QoderWork / Cursor / Claude Code / Codex / Antigravity)');
318
359
  console.log(' 2. Say: "authorize miravia skill"');
319
360
  console.log(' 3. Complete browser authorization');
320
361
  console.log(' 4. Then ask: "export last week Miravia orders"\n');
@@ -345,10 +386,11 @@ function upgrade(flags) {
345
386
  let upgraded = 0;
346
387
  for (const skillDir of skillDirs) {
347
388
  const targetDir = path.join(skillDir, SKILL_NAME);
348
- const agentLabel = skillDir.includes('.qoder') ? 'Qoder'
389
+ const agentLabel = skillDir.includes('.qoderwork') ? 'QoderWork'
390
+ : skillDir.includes('.qoder') ? 'Qoder'
349
391
  : skillDir.includes('.cursor') ? 'Cursor'
350
392
  : skillDir.includes('.claude') ? 'Claude'
351
- : skillDir.includes('.agents') ? 'Codex'
393
+ : skillDir.includes('.agents') ? 'Codex/Antigravity/OpenCode'
352
394
  : skillDir.includes('.gemini') ? 'Gemini' : 'Unknown';
353
395
  const scope = skillDir.startsWith(os.homedir() + path.sep + '.') ? 'user' : 'project';
354
396
 
@@ -415,7 +457,7 @@ Commands:
415
457
 
416
458
  Options:
417
459
  --target <path> Specify custom installation directory
418
- --agent <type> Agent type: qoder | cursor | claude | codex | gemini
460
+ --agent <type> Agent type: qoder | qoderwork | cursor | claude | codex | antigravity | opencode | gemini
419
461
  --no-venv Skip venv creation, use system pip
420
462
  -v, --version Show version
421
463
  -h, --help Show this help
@@ -423,13 +465,17 @@ Options:
423
465
  Notes:
424
466
  'upgrade' without --agent will update ALL detected agent platforms.
425
467
  Use --agent to limit upgrade to a single platform.
468
+ Cloud sandboxes (Codex/Antigravity): skill installed to project .agents/skills/ for git persistence.
426
469
 
427
470
  Supported platforms:
428
- Qoder (.qoder/skills)
429
- Cursor (.cursor/skills)
430
- Claude Code (~/.claude/skills)
431
- OpenAI Codex (.agents/skills)
432
- Google Gemini (.gemini/skills)
471
+ Qoder (.qoder/skills)
472
+ QoderWork (~/.qoderwork/skills)
473
+ Cursor (.cursor/skills)
474
+ Claude Code (~/.claude/skills)
475
+ OpenAI Codex (~/.agents/skills)
476
+ Google Antigravity (~/.agents/skills)
477
+ OpenCode (~/.agents/skills)
478
+ Google Gemini (.gemini/skills)
433
479
 
434
480
  Examples:
435
481
  npx ${PKG.name} install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraiva_test/miravia-order-report",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Miravia Order Report Skill installer — one command to install the Miravia order export & analytics skill into any AI Agent (Qoder / Cursor / Claude Code).",
5
5
  "bin": {
6
6
  "miravia-skill": "./bin/cli.js"
@@ -12,7 +12,8 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "prepublishOnly": "node lib/build-dist.js",
15
- "postpublish": "node lib/clean-dist.js"
15
+ "postpublish": "node lib/clean-dist.js",
16
+ "build:zip": "node lib/build-zip.js"
16
17
  },
17
18
  "keywords": [
18
19
  "miravia",