PD03 commited on
Commit
994118b
·
verified ·
1 Parent(s): ed3eae1

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +344 -0
app.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, json, logging, tempfile
2
+ import gradio as gr
3
+ import pandas as pd
4
+ import numpy as np
5
+
6
+ # quiet logs
7
+ logging.getLogger("cmdstanpy").setLevel(logging.WARNING)
8
+ logging.getLogger("prophet").setLevel(logging.WARNING)
9
+
10
+ # -----------------------------
11
+ # Auth: set OPENAI_API_KEY in HF/Colab secrets
12
+ # -----------------------------
13
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
14
+ if not OPENAI_API_KEY:
15
+ print("⚠️ OPENAI_API_KEY not set. Set a Space secret or env var. Tools will still run locally; the agent needs it.")
16
+
17
+ # -----------------------------
18
+ # Tools (your requested @tool style)
19
+ # -----------------------------
20
+ from smolagents import tool, CodeAgent, OpenAIServerModel
21
+
22
+ @tool
23
+ def forecast_tool(
24
+ horizon_months: int = 1,
25
+ use_demo: bool = True,
26
+ history_csv_path: str = ""
27
+ ) -> str:
28
+ """
29
+ Forecast monthly demand for finished goods using Prophet (demo-friendly).
30
+
31
+ Args:
32
+ horizon_months (int): Number of future months to forecast. Defaults to 1.
33
+ use_demo (bool): If True, generate synthetic history for two SKUs (FG100/FG200). Defaults to True.
34
+ history_csv_path (str): Optional path to CSV with columns [product_id,date,qty] to override demo.
35
+
36
+ Returns:
37
+ str: JSON string list of objects:
38
+ {"product_id": str, "period_start": "YYYY-MM-01", "forecast_qty": float}
39
+ """
40
+ from prophet import Prophet
41
+
42
+ # 1) Build history
43
+ if use_demo or not history_csv_path:
44
+ rng = pd.date_range("2023-01-01", periods=24, freq="MS")
45
+ rows = []
46
+ np.random.seed(0)
47
+ for pid, base in [("FG100", 1800), ("FG200", 900)]:
48
+ season = 1 + 0.15 * np.sin(2 * np.pi * (np.arange(len(rng)) / 12.0))
49
+ qty = (base * season).astype(float)
50
+ for d, q in zip(rng, qty):
51
+ rows.append({"product_id": pid, "date": d, "qty": float(q)})
52
+ df = pd.DataFrame(rows)
53
+ else:
54
+ df = pd.read_csv(history_csv_path)
55
+ assert {"product_id", "date", "qty"} <= set(df.columns), "Missing required columns: product_id,date,qty"
56
+ df["date"] = pd.to_datetime(df["date"], errors="coerce")
57
+ df = df.dropna(subset=["date"])
58
+ df["qty"] = pd.to_numeric(df["qty"], errors="coerce").fillna(0.0)
59
+
60
+ # 2) Forecast per product with Prophet
61
+ out = []
62
+ horizon_months = max(1, int(horizon_months))
63
+ for pid, g in df.groupby("product_id"):
64
+ s = (
65
+ g.set_index("date")["qty"]
66
+ .resample("MS").sum()
67
+ .asfreq("MS").fillna(0.0)
68
+ )
69
+ m = Prophet(yearly_seasonality=True, weekly_seasonality=False, daily_seasonality=False, n_changepoints=10)
70
+ m.fit(pd.DataFrame({"ds": s.index, "y": s.values}))
71
+ future = m.make_future_dataframe(periods=horizon_months, freq="MS", include_history=False)
72
+ pred = m.predict(future)[["ds", "yhat"]]
73
+ for _, r in pred.iterrows():
74
+ out.append({
75
+ "product_id": str(pid),
76
+ "period_start": r["ds"].strftime("%Y-%m-%d"),
77
+ "forecast_qty": float(r["yhat"])
78
+ })
79
+ return json.dumps(out)
80
+
81
+
82
+ @tool
83
+ def optimize_supply_tool(
84
+ forecast_json: str
85
+ ) -> str:
86
+ """
87
+ Optimize a single-month supply plan (demo LP) using forecasted demand.
88
+
89
+ Args:
90
+ forecast_json (str): JSON string returned by forecast_tool.
91
+
92
+ Returns:
93
+ str: JSON string with plan summary:
94
+ {
95
+ "status": "OPTIMAL",
96
+ "profit": float,
97
+ "products": [{"product_id": ..., "produce_qty": ..., "sell_qty": ...}],
98
+ "raw_materials": [{"rm_id": ..., "purchase_qty": ..., "consumption_qty": ...}],
99
+ "resources": [{"resource_id": "R1"/"R2", "used_hours": ..., "available_hours": ...}]
100
+ }
101
+ """
102
+ # Demo master data (same as earlier examples)
103
+ demand_rows = json.loads(forecast_json)
104
+ # Use first month per product
105
+ demand = {}
106
+ for row in demand_rows:
107
+ p = row["product_id"]
108
+ demand.setdefault(p, row) # first occurrence only
109
+
110
+ P = sorted(demand.keys()) or ["FG100", "FG200"] # default if empty
111
+ # Prices / conversion costs / resource usage
112
+ price = {"FG100": 98.0, "FG200": 120.0}
113
+ conv = {"FG100": 12.5, "FG200": 15.0}
114
+ r1 = {"FG100": 0.03, "FG200": 0.05}
115
+ r2 = {"FG100": 0.02, "FG200": 0.01}
116
+ # RM data + BOM eff usage
117
+ RMs = ["RM_A", "RM_B"]
118
+ rm_cost = {"RM_A": 20.0, "RM_B": 30.0}
119
+ rm_start = {"RM_A": 1000.0, "RM_B": 100.0}
120
+ rm_cap = {"RM_A": 5000.0, "RM_B": 5000.0}
121
+ bom = {
122
+ "FG100": {"RM_A": 0.8, "RM_B": 0.2 * 1.02}, # scrap on B
123
+ "FG200": {"RM_A": 1.0, "RM_B": 0.1},
124
+ }
125
+ r1_cap, r2_cap = 320.0, 480.0
126
+ start_inv = {p: 0.0 for p in P} # keep the LP minimal
127
+ safety = {p: 0.0 for p in P}
128
+
129
+ # Build LP: variables = produce[p], sell[p], purchase[r], end_inv_rm[r], end_inv[p]
130
+ from scipy.optimize import linprog
131
+ nP, nR = len(P), len(RMs)
132
+ pidx = {p:i for i,p in enumerate(P)}
133
+ ridx = {r:i for i,r in enumerate(RMs)}
134
+
135
+ def i_prod(p): return pidx[p]
136
+ def i_sell(p): return nP + pidx[p]
137
+ def i_einv(p): return 2*nP + pidx[p]
138
+ def i_pur(r): return 3*nP + ridx[r]
139
+ def i_einr(r): return 3*nP + nR + ridx[r]
140
+
141
+ n_vars = 3*nP + 2*nR
142
+ c = np.zeros(n_vars)
143
+ bounds = [None]*n_vars
144
+
145
+ # objective: minimize (costs - revenue)
146
+ for p in P:
147
+ c[i_prod(p)] += conv[p]
148
+ c[i_sell(p)] -= price[p]
149
+ c[i_einv(p)] += 0.0
150
+ bounds[i_prod(p)] = (0, None)
151
+ bounds[i_sell(p)] = (0, float(demand[p]["forecast_qty"]))
152
+ bounds[i_einv(p)] = (safety[p], None)
153
+ for r in RMs:
154
+ c[i_pur(r)] += rm_cost[r]
155
+ c[i_einr(r)] += 0.0
156
+ bounds[i_pur(r)] = (0, rm_cap[r])
157
+ bounds[i_einr(r)] = (0, None)
158
+
159
+ # equalities
160
+ Aeq, beq = [], []
161
+ # FG balance: start + produce - sell - end_inv = 0
162
+ for p in P:
163
+ row = np.zeros(n_vars)
164
+ row[i_prod(p)] = 1; row[i_sell(p)] = -1; row[i_einv(p)] = -1
165
+ Aeq.append(row); beq.append(-start_inv[p])
166
+ # RM balance: start + purchase - sum(use*produce) - end_inv_rm = 0
167
+ for r in RMs:
168
+ row = np.zeros(n_vars)
169
+ row[i_pur(r)] = 1; row[i_einr(r)] = -1
170
+ for p in P:
171
+ row[i_prod(p)] -= bom.get(p, {}).get(r, 0.0)
172
+ Aeq.append(row); beq.append(-rm_start[r])
173
+
174
+ Aeq, beq = np.array(Aeq), np.array(beq)
175
+
176
+ # inequalities (resources)
177
+ Aub, bub = [], []
178
+ row = np.zeros(n_vars)
179
+ for p in P: row[i_prod(p)] = r1[p]
180
+ Aub.append(row); bub.append(r1_cap)
181
+ row = np.zeros(n_vars)
182
+ for p in P: row[i_prod(p)] = r2[p]
183
+ Aub.append(row); bub.append(r2_cap)
184
+ Aub, bub = np.array(Aub), np.array(bub)
185
+
186
+ res = linprog(c, A_ub=Aub, b_ub=bub, A_eq=Aeq, b_eq=beq, bounds=bounds, method="highs")
187
+ if not res.success:
188
+ return json.dumps({"status": "FAILED", "message": res.message})
189
+
190
+ x = res.x
191
+ def v(idx): return float(x[idx])
192
+
193
+ # Build outputs
194
+ prod_rows = []
195
+ for p in P:
196
+ prod_rows.append({
197
+ "product_id": p,
198
+ "produce_qty": v(i_prod(p)),
199
+ "sell_qty": v(i_sell(p))
200
+ })
201
+ # resource usage
202
+ r1_used = float(sum(r1[p]*v(i_prod(p)) for p in P))
203
+ r2_used = float(sum(r2[p]*v(i_prod(p)) for p in P))
204
+ resources = [
205
+ {"resource_id": "R1", "used_hours": r1_used, "available_hours": r1_cap, "slack_hours": r1_cap - r1_used},
206
+ {"resource_id": "R2", "used_hours": r2_used, "available_hours": r2_cap, "slack_hours": r2_cap - r2_used},
207
+ ]
208
+ # raw material flows
209
+ raw_rows = []
210
+ rm_purch_cost = 0.0
211
+ for r in RMs:
212
+ purchase = v(i_pur(r))
213
+ cons = float(sum(bom.get(p, {}).get(r, 0.0)*v(i_prod(p)) for p in P))
214
+ rm_purch_cost += purchase*rm_cost[r]
215
+ raw_rows.append({
216
+ "rm_id": r, "purchase_qty": purchase, "consumption_qty": cons
217
+ })
218
+ revenue = float(sum(price[p]*v(i_sell(p)) for p in P))
219
+ conv_cost = float(sum(conv[p]*v(i_prod(p)) for p in P))
220
+ profit = revenue - conv_cost - rm_purch_cost
221
+
222
+ out = {
223
+ "status": "OPTIMAL",
224
+ "profit": profit,
225
+ "revenue": revenue,
226
+ "conversion_cost": conv_cost,
227
+ "rm_purchase_cost": rm_purch_cost,
228
+ "products": prod_rows,
229
+ "raw_materials": raw_rows,
230
+ "resources": resources
231
+ }
232
+ return json.dumps(out)
233
+
234
+
235
+ @tool
236
+ def update_sap_md61_tool(
237
+ forecast_json: str,
238
+ plant: str = "PLANT01",
239
+ uom: str = "EA",
240
+ mrp_area: str = ""
241
+ ) -> str:
242
+ """
243
+ Prepare an MD61-style demand upload (SIMULATION ONLY).
244
+
245
+ Args:
246
+ forecast_json (str): JSON string returned by forecast_tool.
247
+ plant (str): SAP plant (WERKS). Defaults to 'PLANT01'.
248
+ uom (str): Unit of measure to write. Defaults to 'EA'.
249
+ mrp_area (str): Optional MRP area.
250
+
251
+ Returns:
252
+ str: JSON string with {"status":"SIMULATED","csv_path": "...", "preview":[...5 rows...]}
253
+ """
254
+ rows = json.loads(forecast_json)
255
+ md61 = []
256
+ for r in rows:
257
+ md61.append({
258
+ "Material": r["product_id"],
259
+ "Plant": plant,
260
+ "MRP_Area": mrp_area,
261
+ "Req_Date": r["period_start"], # month start; in practice, align to bucket conventions
262
+ "Req_Qty": float(r["forecast_qty"]),
263
+ "UoM": uom,
264
+ "Version": "00" # demo default
265
+ })
266
+ df = pd.DataFrame(md61)
267
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
268
+ df.to_csv(tmp.name, index=False)
269
+ return json.dumps({
270
+ "status": "SIMULATED",
271
+ "csv_path": tmp.name,
272
+ "preview": df.head(5).to_dict(orient="records")
273
+ })
274
+
275
+ # -----------------------------
276
+ # Agent: runs forecast -> optimize -> MD61
277
+ # -----------------------------
278
+ def make_agent():
279
+ model = OpenAIServerModel(
280
+ model_id="gpt-4o-mini",
281
+ api_key=OPENAI_API_KEY,
282
+ temperature=0
283
+ )
284
+ tools = [forecast_tool, optimize_supply_tool, update_sap_md61_tool]
285
+ return CodeAgent(tools=tools, model=model, add_base_tools=False, stream_outputs=False)
286
+
287
+ SYSTEM_PLAN = (
288
+ "Run the following pipeline strictly and return one final JSON object:\n"
289
+ "1) Call forecast_tool with the given arguments.\n"
290
+ "2) Call optimize_supply_tool using the JSON returned by forecast_tool.\n"
291
+ "3) Call update_sap_md61_tool using the JSON returned by forecast_tool (demand), "
292
+ " not the optimization plan.\n"
293
+ "Return final_answer as JSON with keys: 'forecast', 'plan', and 'md61'."
294
+ )
295
+
296
+ def run_workflow(horizon, use_demo, plant, file_obj):
297
+ agent = make_agent()
298
+ if file_obj is not None:
299
+ history_path = file_obj.name
300
+ user_prompt = (
301
+ f"{SYSTEM_PLAN}\n"
302
+ f"Args:\n"
303
+ f"- forecast_tool: horizon_months={int(horizon)}, use_demo=False, history_csv_path='{history_path}'\n"
304
+ f"- optimize_supply_tool: (use forecast JSON)\n"
305
+ f"- update_sap_md61_tool: plant='{plant}', uom='EA'\n"
306
+ f"Return the final JSON only."
307
+ )
308
+ else:
309
+ user_prompt = (
310
+ f"{SYSTEM_PLAN}\n"
311
+ f"Args:\n"
312
+ f"- forecast_tool: horizon_months={int(horizon)}, use_demo=True\n"
313
+ f"- optimize_supply_tool: (use forecast JSON)\n"
314
+ f"- update_sap_md61_tool: plant='{plant}', uom='EA'\n"
315
+ f"Return the final JSON only."
316
+ )
317
+ try:
318
+ out = agent.run(user_prompt)
319
+ except Exception as e:
320
+ out = f"Agent error: {e}"
321
+ return out
322
+
323
+ # -----------------------------
324
+ # Gradio UI (simple and clean)
325
+ # -----------------------------
326
+ with gr.Blocks(title="Forecast → Optimize → SAP MD61 (Demo)") as demo:
327
+ gr.Markdown("## Forecast → Optimize → Update SAP MD61 (Demo)\nMinimal agent workflow with Prophet, LP, and an MD61 CSV preview.")
328
+ with gr.Row():
329
+ horizon = gr.Number(label="Horizon (months)", value=1, precision=0)
330
+ plant = gr.Textbox(label="SAP Plant (WERKS)", value="PLANT01")
331
+ with gr.Row():
332
+ use_demo = gr.Checkbox(label="Use demo synthetic history", value=True)
333
+ file = gr.File(label="Or upload history CSV (product_id,date,qty)", file_types=[".csv"])
334
+ run_btn = gr.Button("Run end-to-end")
335
+ out_box = gr.Textbox(label="Agent Output (JSON)", lines=14)
336
+
337
+ def on_run(h, p, demo_flag, f):
338
+ # if a file is supplied, ignore demo flag
339
+ return run_workflow(h, (f is None) and demo_flag, p, f)
340
+
341
+ run_btn.click(on_run, inputs=[horizon, plant, use_demo, file], outputs=[out_box])
342
+
343
+ if __name__ == "__main__":
344
+ demo.launch()