codys12 commited on
Commit
f302c17
·
verified ·
1 Parent(s): 90e73d9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +241 -69
app.py CHANGED
@@ -1,30 +1,34 @@
1
- import gradio as gr
2
- import pandas as pd
3
- import tempfile
4
- import os
5
- import json
6
- import hashlib
 
 
 
 
 
 
 
 
7
  import asyncio
 
 
 
 
8
  from io import BytesIO
9
  from pathlib import Path
10
- import openai
11
- import gradio_client.utils
12
-
13
- """NetCom → WooCommerce transformer (Try 1 schema)
14
- =================================================
15
- *Accept CSV **or** Excel schedule files and output the WooCommerce CSV.*
16
 
17
- Fixes vs last run
18
- -----------------
19
- * Output written to a **temporary file path** (Gradio BytesIO bug fixed).
20
- * **Excel upload** support.
21
- * **Pandas future‑warning** silenced (`group_keys=False`).
22
- """
23
 
24
  # -------- Gradio bool‑schema hot‑patch --------------------------------------
25
  _original = gradio_client.utils._json_schema_to_python_type
26
 
27
- def _fixed_json_schema_to_python_type(schema, defs=None):
28
  if isinstance(schema, bool):
29
  return "any"
30
  return _original(schema, defs)
@@ -32,101 +36,269 @@ def _fixed_json_schema_to_python_type(schema, defs=None):
32
  gradio_client.utils._json_schema_to_python_type = _fixed_json_schema_to_python_type # type: ignore
33
 
34
  # -------- Tiny disk cache ----------------------------------------------------
35
- CACHE_DIR = Path("ai_response_cache"); CACHE_DIR.mkdir(exist_ok=True)
 
36
 
37
- def _cache_path(p: str):
38
  return CACHE_DIR / f"{hashlib.md5(p.encode()).hexdigest()}.json"
39
 
40
- def _get_cached(p: str):
41
  try:
42
- return json.loads(_cache_path(p).read_text("utf-8"))["response"]
43
  except Exception:
44
  return None
45
 
46
- def _set_cache(p: str, r: str):
47
  try:
48
  _cache_path(p).write_text(json.dumps({"prompt": p, "response": r}), "utf-8")
49
  except Exception:
50
  pass
51
 
52
  # -------- Async GPT helpers --------------------------------------------------
53
- async def _gpt(client, prompt):
54
- c = _get_cached(prompt)
55
- if c is not None:
56
- return c
 
 
57
  try:
58
- msg = await client.chat.completions.create(model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0)
 
 
 
 
59
  text = msg.choices[0].message.content
60
- except Exception as e:
61
- text = f"Error: {e}"
 
62
  _set_cache(prompt, text)
63
  return text
64
 
65
- async def _batch(lst, instr):
66
- out = ["" for _ in lst]; idx,prompts=[],[]
67
- for i,t in enumerate(lst):
68
- if isinstance(t,str) and t.strip(): idx.append(i); prompts.append(f"{instr}\n\nText: {t}")
69
- if not prompts: return out
70
- client = openai.AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
71
- res = await asyncio.gather(*[_gpt(client,p) for p in prompts])
72
- for j,val in enumerate(res): out[idx[j]] = val
 
 
 
 
 
 
 
 
73
  return out
74
 
75
  # -------- Core converter -----------------------------------------------------
 
 
 
 
 
 
 
 
 
 
76
 
77
- def _read(path: str):
78
- return pd.read_excel(path) if path.lower().endswith((".xlsx",".xls")) else pd.read_csv(path, encoding="latin1")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  def convert(path: str) -> BytesIO:
81
- logos = {"Amazon Web Services":"/wp-content/uploads/2025/04/aws.png","Cisco":"/wp-content/uploads/2025/04/cisco-e1738593292198-1.webp","Microsoft":"/wp-content/uploads/2025/04/Microsoft-e1737494120985-1.png","Google Cloud":"/wp-content/uploads/2025/04/Google_Cloud.png","EC Council":"/wp-content/uploads/2025/04/Ec_Council.png","ITIL":"/wp-content/uploads/2025/04/ITIL.webp","PMI":"/wp-content/uploads/2025/04/PMI.png","Comptia":"/wp-content/uploads/2025/04/Comptia.png","Autodesk":"/wp-content/uploads/2025/04/autodesk.png","ISC2":"/wp-content/uploads/2025/04/ISC2.png","AICerts":"/wp-content/uploads/2025/04/aicerts-logo-1.png"}
82
- default_pre = "No specific prerequisites are required for this course. Basic computer literacy and familiarity with fundamental concepts in the subject area are recommended for the best learning experience."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- df = _read(path); df.columns = df.columns.str.strip()
85
- c = lambda *o: next((x for x in o if x in df.columns), None)
86
- dcol, ocol, pcol, acol, dur, sid = c("Description","Decription"), c("Objectives","objectives"), c("RequiredPrerequisite","Required Pre-requisite"), c("Outline"), c("Duration"), c("Course SID","Course SID")
87
- if dur is None: df["Duration"]=""; dur="Duration"
 
 
88
 
89
- loop=asyncio.new_event_loop(); asyncio.set_event_loop(loop)
90
- sdesc, ldesc, fobj, fout = loop.run_until_complete(asyncio.gather(
91
- _batch(df.get(dcol,"").fillna("").tolist(), "Create a concise 250-character summary of this course description:"),
92
- _batch(df.get(dcol,"").fillna("").tolist(), "Condense this description to a maximum of 750 characters in paragraph format, with clean formatting:"),
93
- _batch(df.get(ocol,"").fillna("").tolist(), "Format these objectives into a bullet list with clean formatting. Start each bullet with '• ':") ,
94
- _batch(df.get(acol,"").fillna("").tolist(), "Format this agenda into a bullet list with clean formatting. Start each bullet with '• ':")))
95
- loop.close()
96
- fpre=[default_pre if not str(p).strip() else asyncio.run(_batch([p],"Format these prerequisites into a bullet list with clean formatting. Start each bullet with '• ':"))[0] for p in df.get(pcol,"").fillna("").tolist()]
97
 
98
- df["Short_Description"],df["Condensed_Description"],df["Formatted_Objectives"],df["Formatted_Agenda"],df["Formatted_Prerequisites"] = sdesc,ldesc,fobj,fout,fpre
 
99
 
 
 
 
 
 
 
 
100
  df["Course Start Date"] = pd.to_datetime(df["Course Start Date"], errors="coerce")
101
  df["Date_fmt"] = df["Course Start Date"].dt.strftime("%-m/%-d/%Y")
102
- dsorted=df.sort_values(["Course ID","Course Start Date"])
103
- d_agg = dsorted.groupby("Course ID")["Date_fmt"].apply(lambda s: ",".join(s.dropna().unique())).reset_index(name="Dates")
104
- t_agg = dsorted.groupby("Course ID",group_keys=False).apply(lambda g: ",".join(f"{st}-{et} {tz}" for st,et,tz in zip(g["Course Start Time"],g["Course End Time"],g["Time Zone"]))).reset_index(name="Times")
 
 
 
 
 
 
 
 
 
 
 
 
105
  parents = dsorted.drop_duplicates("Course ID").merge(d_agg).merge(t_agg)
106
 
 
107
  parent = pd.DataFrame({
108
- "Type":"variable","SKU":parents["Course ID"],"Name":parents["Course Name"],"Published":1,"Visibility in catalog":"visible","Short description":parents["Short_Description"],"Description":parents["Condensed_Description"],"Tax status":"taxable","In stock?":1,"Stock":1,"Sold individually?":1,"Regular price":parents["SRP Pricing"].replace("[\\$,]","",regex=True),"Categories":"courses","Images":parents["Vendor"].map(logos).fillna(""),"Parent":"","Brands":parents["Vendor"],"Attribute 1 name":"Date","Attribute 1 value(s)":parents["Dates"],"Attribute 1 visible":"visible","Attribute 1 global":1,"Attribute 2 name":"Location","Attribute 2 value(s)":"Virtual","Attribute 2 visible":"visible","Attribute 2 global":1,"Attribute 3 name":"Time","Attribute 3 value(s)":parents["Times"],"Attribute 3 visible":"visible","Attribute 3 global":1,"Meta: outline":parents["Formatted_Agenda"],"Meta: days":parents[dur],"Meta: location":"Virtual","Meta: overview":parents["Target Audience"],"Meta: objectives":parents["Formatted_Objectives"],"Meta: prerequisites":parents["Formatted_Prerequisites"],"Meta: agenda":parents["Formatted_Agenda"]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  child = pd.DataFrame({
110
- "Type":"variation, virtual","SKU":dsorted[sid].astype(str).str.strip(),"Name":dsorted["Course Name"],"Published":1,"Visibility in catalog":"visible","Short description":dsorted["Short_Description"],"Description":dsorted["Condensed_Description"],"Tax status":"taxable","In stock?":1,"Stock":1,"Sold individually?":1,"Regular price":dsorted["SRP Pricing"].replace("[\\$,]","",regex=True),"Categories":"courses","Images":dsorted["Vendor"].map(logos).fillna(""),"Parent":dsorted["Course ID"],"Brands":dsorted["Vendor"],"Attribute 1 name":"Date","Attribute 1 value(s)":dsorted["Date_fmt"],"Attribute 1 visible":"visible","Attribute 1 global":1,"Attribute 2 name":"Location","Attribute 2 value(s)":"Virtual","Attribute 2 visible":"visible","Attribute 2 global":1,"Attribute 3 name":"Time","Attribute 3 value(s)":dsorted.apply(lambda r:f"{r['Course Start Time']}-{r['Course End Time']} {r['Time Zone']}",axis=1),"Attribute 3 visible":"visible","Attribute 3 global":1,"Meta: outline":dsorted["Formatted_Agenda"],"Meta: days":dsorted[dur],"Meta: location":"Virtual","Meta: overview":dsorted["Target Audience"],"Meta: objectives":dsorted["Formatted_Objectives"],"Meta: prerequisites":dsorted["Formatted_Prerequisites"],"Meta: agenda":dsorted["Formatted_Agenda"]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
- all_rows = pd.concat([parent,child],ignore_index=True)
113
- order=["Type","SKU","Name","Published","Visibility in catalog","Short description","Description","Tax status","In stock?","Stock","Sold individually?","Regular price","Categories","Images","Parent","Brands","Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Attribute 3 name","Attribute 3 value(s)","Attribute 3 visible","Attribute 3 global","Meta: outline","Meta: days","Meta: location","Meta: overview","Meta: objectives","Meta: prerequisites","Meta: agenda"]
114
- out=BytesIO(); all_rows[order].to_csv(out,index=False,encoding="utf-8-sig"); out.seek(0); return out
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  # -------- Gradio wrappers ----------------------------------------------------
117
 
118
- def process_file(upload):
119
  csv_bytes = convert(upload.name)
120
  with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
121
- tmp.write(csv_bytes.getvalue()); path = tmp.name
 
122
  return path
123
 
124
  ui = gr.Interface(
125
  fn=process_file,
126
- inputs=gr.File(label="Upload NetCom CSV / Excel", file_types=[".csv",".xlsx",".xls"]),
127
  outputs=gr.File(label="Download WooCommerce CSV"),
128
- title="NetCom → WooCommerce CSV Processor",
129
- description="Upload NetCom schedule (.csv/.xlsx) to get the Try 1‑formatted WooCommerce CSV.",
130
  analytics_enabled=False,
131
  )
132
 
 
1
+ """NetCom WooCommerce transformer (Try 2 schema — cleaned async)
2
+ =============================================================
3
+ *Accept CSV **or** Excel schedule files and output the WooCommerce CSV.*
4
+
5
+ Changes vs Try 1
6
+ ----------------
7
+ * Use **one** event‑loop via `asyncio.run()` — no manual `new_event_loop()` / `loop.close()` gymnastics.
8
+ * **One** shared `openai.AsyncOpenAI` client, properly closed with an `async with` block.
9
+ * Fixed pandas future‑warning by adding `include_groups=False`.
10
+ * Same Gradio interface, caching, and JSON‑schema hot‑patch as before.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
  import asyncio
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import tempfile
20
  from io import BytesIO
21
  from pathlib import Path
 
 
 
 
 
 
22
 
23
+ import gradio as gr
24
+ import gradio_client.utils
25
+ import openai
26
+ import pandas as pd
 
 
27
 
28
  # -------- Gradio bool‑schema hot‑patch --------------------------------------
29
  _original = gradio_client.utils._json_schema_to_python_type
30
 
31
+ def _fixed_json_schema_to_python_type(schema, defs=None): # type: ignore
32
  if isinstance(schema, bool):
33
  return "any"
34
  return _original(schema, defs)
 
36
  gradio_client.utils._json_schema_to_python_type = _fixed_json_schema_to_python_type # type: ignore
37
 
38
  # -------- Tiny disk cache ----------------------------------------------------
39
+ CACHE_DIR = Path("ai_response_cache")
40
+ CACHE_DIR.mkdir(exist_ok=True)
41
 
42
+ def _cache_path(p: str) -> Path:
43
  return CACHE_DIR / f"{hashlib.md5(p.encode()).hexdigest()}.json"
44
 
45
+ def _get_cached(p: str) -> str | None:
46
  try:
47
+ return json.loads(_cache_path(p).read_text("utf-8"))['response']
48
  except Exception:
49
  return None
50
 
51
+ def _set_cache(p: str, r: str) -> None:
52
  try:
53
  _cache_path(p).write_text(json.dumps({"prompt": p, "response": r}), "utf-8")
54
  except Exception:
55
  pass
56
 
57
  # -------- Async GPT helpers --------------------------------------------------
58
+ async def _gpt_async(client: openai.AsyncOpenAI, prompt: str) -> str:
59
+ """Single LLM call with on‑disk response cache."""
60
+ cached = _get_cached(prompt)
61
+ if cached is not None:
62
+ return cached
63
+
64
  try:
65
+ msg = await client.chat.completions.create(
66
+ model="gpt-4o-mini",
67
+ messages=[{"role": "user", "content": prompt}],
68
+ temperature=0,
69
+ )
70
  text = msg.choices[0].message.content
71
+ except Exception as exc: # network or auth failure ‑ return explicit error string
72
+ text = f"Error: {exc}"
73
+
74
  _set_cache(prompt, text)
75
  return text
76
 
77
+ async def _batch_async(lst: list[str], instruction: str, client: openai.AsyncOpenAI) -> list[str]:
78
+ """Vectorised helper returns an output list matching *lst* length."""
79
+ out: list[str] = ["" for _ in lst]
80
+ idx, prompts = [], []
81
+ for i, txt in enumerate(lst):
82
+ if isinstance(txt, str) and txt.strip():
83
+ idx.append(i)
84
+ prompts.append(f"{instruction}\n\nText: {txt}")
85
+ # Fast‑path: nothing to do
86
+ if not prompts:
87
+ return out
88
+
89
+ # Fire off all prompts concurrently
90
+ responses = await asyncio.gather(*[_gpt_async(client, p) for p in prompts])
91
+ for j, val in enumerate(responses):
92
+ out[idx[j]] = val
93
  return out
94
 
95
  # -------- Core converter -----------------------------------------------------
96
+ DEFAULT_PREREQ = (
97
+ "No specific prerequisites are required for this course. Basic computer literacy and "
98
+ "familiarity with fundamental concepts in the subject area are recommended for the best "
99
+ "learning experience."
100
+ )
101
+
102
+ def _read(path: str) -> pd.DataFrame:
103
+ if path.lower().endswith((".xlsx", ".xls")):
104
+ return pd.read_excel(path)
105
+ return pd.read_csv(path, encoding="latin1")
106
 
107
+ async def _enrich_dataframe(df: pd.DataFrame, dcol: str, ocol: str, pcol: str, acol: str) -> tuple[list[str], list[str], list[str], list[str], list[str]]:
108
+ """Run all LLM batches concurrently and return the five enrichment columns."""
109
+ async with openai.AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) as client:
110
+ # 1) Descriptions and objectives/agenda batches
111
+ sdesc, ldesc, fobj, fout = await asyncio.gather(
112
+ _batch_async(df.get(dcol, "").fillna("").tolist(),
113
+ "Create a concise 250-character summary of this course description:", client),
114
+ _batch_async(df.get(dcol, "").fillna("").tolist(),
115
+ "Condense this description to a maximum of 750 characters in paragraph format, with clean formatting:", client),
116
+ _batch_async(df.get(ocol, "").fillna("").tolist(),
117
+ "Format these objectives into a bullet list with clean formatting. Start each bullet with '• ':", client),
118
+ _batch_async(df.get(acol, "").fillna("").tolist(),
119
+ "Format this agenda into a bullet list with clean formatting. Start each bullet with '• ':", client),
120
+ )
121
+ # 2) Prerequisites batch (some rows may be empty → DEFAULT_PREREQ)
122
+ prereq_raw = df.get(pcol, "").fillna("").tolist()
123
+ fpre: list[str] = []
124
+ for req in prereq_raw:
125
+ if not str(req).strip():
126
+ fpre.append(DEFAULT_PREREQ)
127
+ else:
128
+ formatted = await _batch_async([req],
129
+ "Format these prerequisites into a bullet list with clean formatting. Start each bullet with '• ':",
130
+ client)
131
+ fpre.append(formatted[0])
132
+
133
+ return sdesc, ldesc, fobj, fout, fpre
134
 
135
  def convert(path: str) -> BytesIO:
136
+ logos = {
137
+ "Amazon Web Services": "/wp-content/uploads/2025/04/aws.png",
138
+ "Cisco": "/wp-content/uploads/2025/04/cisco-e1738593292198-1.webp",
139
+ "Microsoft": "/wp-content/uploads/2025/04/Microsoft-e1737494120985-1.png",
140
+ "Google Cloud": "/wp-content/uploads/2025/04/Google_Cloud.png",
141
+ "EC Council": "/wp-content/uploads/2025/04/Ec_Council.png",
142
+ "ITIL": "/wp-content/uploads/2025/04/ITIL.webp",
143
+ "PMI": "/wp-content/uploads/2025/04/PMI.png",
144
+ "Comptia": "/wp-content/uploads/2025/04/Comptia.png",
145
+ "Autodesk": "/wp-content/uploads/2025/04/autodesk.png",
146
+ "ISC2": "/wp-content/uploads/2025/04/ISC2.png",
147
+ "AICerts": "/wp-content/uploads/2025/04/aicerts-logo-1.png",
148
+ }
149
+
150
+ df = _read(path)
151
+ df.columns = df.columns.str.strip()
152
+
153
+ # Helper to locate first existing column name from a list of candidates
154
+ first_col = lambda *candidates: next((c for c in candidates if c in df.columns), None)
155
 
156
+ dcol = first_col("Description", "Decription")
157
+ ocol = first_col("Objectives", "objectives")
158
+ pcol = first_col("RequiredPrerequisite", "Required Pre-requisite")
159
+ acol = first_col("Outline")
160
+ dur = first_col("Duration") or "Duration"
161
+ sid = first_col("Course SID", "Course SID")
162
 
163
+ if dur not in df.columns:
164
+ df[dur] = "" # create empty Duration col if missing
 
 
 
 
 
 
165
 
166
+ # ---------- LLM enrichment (async) -------------------------------------
167
+ sdesc, ldesc, fobj, fout, fpre = asyncio.run(_enrich_dataframe(df, dcol, ocol, pcol, acol))
168
 
169
+ df["Short_Description"] = sdesc
170
+ df["Condensed_Description"] = ldesc
171
+ df["Formatted_Objectives"] = fobj
172
+ df["Formatted_Agenda"] = fout
173
+ df["Formatted_Prerequisites"] = fpre
174
+
175
+ # ---------- Schedule aggregation --------------------------------------
176
  df["Course Start Date"] = pd.to_datetime(df["Course Start Date"], errors="coerce")
177
  df["Date_fmt"] = df["Course Start Date"].dt.strftime("%-m/%-d/%Y")
178
+
179
+ dsorted = df.sort_values(["Course ID", "Course Start Date"])
180
+ d_agg = (
181
+ dsorted
182
+ .groupby("Course ID")["Date_fmt"]
183
+ .apply(lambda s: ",".join(s.dropna().unique()))
184
+ .reset_index(name="Dates")
185
+ )
186
+ t_agg = (
187
+ dsorted
188
+ .groupby("Course ID", group_keys=False, include_groups=False)
189
+ .apply(lambda g: ",".join(f"{st}-{et} {tz}" for st, et, tz in zip(g["Course Start Time"], g["Course End Time"], g["Time Zone"])))
190
+ .reset_index(name="Times")
191
+ )
192
+
193
  parents = dsorted.drop_duplicates("Course ID").merge(d_agg).merge(t_agg)
194
 
195
+ # ---------- Parent / child product rows --------------------------------
196
  parent = pd.DataFrame({
197
+ "Type": "variable",
198
+ "SKU": parents["Course ID"],
199
+ "Name": parents["Course Name"],
200
+ "Published": 1,
201
+ "Visibility in catalog": "visible",
202
+ "Short description": parents["Short_Description"],
203
+ "Description": parents["Condensed_Description"],
204
+ "Tax status": "taxable",
205
+ "In stock?": 1,
206
+ "Stock": 1,
207
+ "Sold individually?": 1,
208
+ "Regular price": parents["SRP Pricing"].replace("[\\$,]", "", regex=True),
209
+ "Categories": "courses",
210
+ "Images": parents["Vendor"].map(logos).fillna(""),
211
+ "Parent": "",
212
+ "Brands": parents["Vendor"],
213
+ "Attribute 1 name": "Date",
214
+ "Attribute 1 value(s)": parents["Dates"],
215
+ "Attribute 1 visible": "visible",
216
+ "Attribute 1 global": 1,
217
+ "Attribute 2 name": "Location",
218
+ "Attribute 2 value(s)": "Virtual",
219
+ "Attribute 2 visible": "visible",
220
+ "Attribute 2 global": 1,
221
+ "Attribute 3 name": "Time",
222
+ "Attribute 3 value(s)": parents["Times"],
223
+ "Attribute 3 visible": "visible",
224
+ "Attribute 3 global": 1,
225
+ "Meta: outline": parents["Formatted_Agenda"],
226
+ "Meta: days": parents[dur],
227
+ "Meta: location": "Virtual",
228
+ "Meta: overview": parents["Target Audience"],
229
+ "Meta: objectives": parents["Formatted_Objectives"],
230
+ "Meta: prerequisites": parents["Formatted_Prerequisites"],
231
+ "Meta: agenda": parents["Formatted_Agenda"],
232
+ })
233
+
234
  child = pd.DataFrame({
235
+ "Type": "variation, virtual",
236
+ "SKU": dsorted[sid].astype(str).str.strip(),
237
+ "Name": dsorted["Course Name"],
238
+ "Published": 1,
239
+ "Visibility in catalog": "visible",
240
+ "Short description": dsorted["Short_Description"],
241
+ "Description": dsorted["Condensed_Description"],
242
+ "Tax status": "taxable",
243
+ "In stock?": 1,
244
+ "Stock": 1,
245
+ "Sold individually?": 1,
246
+ "Regular price": dsorted["SRP Pricing"].replace("[\\$,]", "", regex=True),
247
+ "Categories": "courses",
248
+ "Images": dsorted["Vendor"].map(logos).fillna(""),
249
+ "Parent": dsorted["Course ID"],
250
+ "Brands": dsorted["Vendor"],
251
+ "Attribute 1 name": "Date",
252
+ "Attribute 1 value(s)": dsorted["Date_fmt"],
253
+ "Attribute 1 visible": "visible",
254
+ "Attribute 1 global": 1,
255
+ "Attribute 2 name": "Location",
256
+ "Attribute 2 value(s)": "Virtual",
257
+ "Attribute 2 visible": "visible",
258
+ "Attribute 2 global": 1,
259
+ "Attribute 3 name": "Time",
260
+ "Attribute 3 value(s)": dsorted.apply(lambda r: f"{r['Course Start Time']}-{r['Course End Time']} {r['Time Zone']}", axis=1),
261
+ "Attribute 3 visible": "visible",
262
+ "Attribute 3 global": 1,
263
+ "Meta: outline": dsorted["Formatted_Agenda"],
264
+ "Meta: days": dsorted[dur],
265
+ "Meta: location": "Virtual",
266
+ "Meta: overview": dsorted["Target Audience"],
267
+ "Meta: objectives": dsorted["Formatted_Objectives"],
268
+ "Meta: prerequisites": dsorted["Formatted_Prerequisites"],
269
+ "Meta: agenda": dsorted["Formatted_Agenda"],
270
+ })
271
 
272
+ all_rows = pd.concat([parent, child], ignore_index=True)
273
+ order = [
274
+ "Type", "SKU", "Name", "Published", "Visibility in catalog", "Short description", "Description",
275
+ "Tax status", "In stock?", "Stock", "Sold individually?", "Regular price", "Categories", "Images",
276
+ "Parent", "Brands", "Attribute 1 name", "Attribute 1 value(s)", "Attribute 1 visible", "Attribute 1 global",
277
+ "Attribute 2 name", "Attribute 2 value(s)", "Attribute 2 visible", "Attribute 2 global", "Attribute 3 name",
278
+ "Attribute 3 value(s)", "Attribute 3 visible", "Attribute 3 global", "Meta: outline", "Meta: days", "Meta: location",
279
+ "Meta: overview", "Meta: objectives", "Meta: prerequisites", "Meta: agenda",
280
+ ]
281
+
282
+ out = BytesIO()
283
+ all_rows[order].to_csv(out, index=False, encoding="utf-8-sig")
284
+ out.seek(0)
285
+ return out
286
 
287
  # -------- Gradio wrappers ----------------------------------------------------
288
 
289
+ def process_file(upload: gr.File) -> str:
290
  csv_bytes = convert(upload.name)
291
  with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
292
+ tmp.write(csv_bytes.getvalue())
293
+ path = tmp.name
294
  return path
295
 
296
  ui = gr.Interface(
297
  fn=process_file,
298
+ inputs=gr.File(label="Upload NetCom CSV / Excel", file_types=[".csv", ".xlsx", ".xls"]),
299
  outputs=gr.File(label="Download WooCommerce CSV"),
300
+ title="NetCom → WooCommerce CSV Processor (Try 2)",
301
+ description="Upload NetCom schedule (.csv/.xlsx) to get the Try 2‑formatted WooCommerce CSV.",
302
  analytics_enabled=False,
303
  )
304