Joshnotfound commited on
Commit
03e2321
·
verified ·
1 Parent(s): 31fb0c5

Update neuravend.py

Browse files
Files changed (1) hide show
  1. neuravend.py +87 -116
neuravend.py CHANGED
@@ -11,14 +11,12 @@ from dataclasses import dataclass, field, asdict
11
  from typing import Any, Dict, List, Optional, Tuple
12
 
13
  # CRITICAL FIX: Ensure all required third-party libraries are imported
14
- # at the top level for module execution and type hints.
15
  import numpy as np
16
  import pandas as pd
17
  import requests
18
- # The google.generativeai import is handled within the load_gemini function
19
- # but we need to ensure the requirements.txt is correct (which we fixed previously).
20
 
21
- # Logging
 
22
  LOG_FN = "neuravend.log"
23
  logging.basicConfig(
24
  level=logging.INFO,
@@ -59,7 +57,8 @@ class Session:
59
  return cls(**data)
60
  return None
61
 
62
- # Gemini optional loader
 
63
  def load_gemini():
64
  key = os.environ.get("GEMINI_API_KEY") or os.environ.get("AI_API_KEY")
65
  if not key:
@@ -68,7 +67,6 @@ def load_gemini():
68
  try:
69
  import google.generativeai as genai
70
  genai.configure(api_key=key)
71
- # Attempt to get the model in a modern way
72
  try:
73
  model = genai.GenerativeModel("gemini-1.5-flash")
74
  except Exception as e:
@@ -77,7 +75,6 @@ def load_gemini():
77
 
78
  if model:
79
  logger.info("Gemini configured (optional).")
80
- # Return the genai module and the configured model instance
81
  return (genai, model), True
82
  except Exception as e:
83
  logger.warning(f"Gemini SDK init failed: {e}; continuing offline.")
@@ -93,7 +90,8 @@ def _set_seed():
93
  logger.info("Internal seed set.")
94
  _set_seed()
95
 
96
- # Vendor synthesis
 
97
  NAME_WORDS = ["Astra", "Blue", "Nova", "Prime", "Eco", "Vertex", "Luma", "Grid", "Core", "Pioneer", "Green"]
98
  SUFFIX = ["Ltd", "Pvt Ltd", "Inc", "LLC", "Corp"]
99
 
@@ -104,6 +102,7 @@ def synthesize_vendors(n: int = 10, scenario: str = "Normal") -> pd.DataFrame:
104
  rows = []
105
  for i in range(n):
106
  name = gen_name(i)
 
107
  if scenario == "Disruption":
108
  cost = int(max(1000, random.gauss(11000, 3000)))
109
  quality = int(min(100, max(40, random.gauss(72, 10))))
@@ -122,7 +121,7 @@ def synthesize_vendors(n: int = 10, scenario: str = "Normal") -> pd.DataFrame:
122
  delivery = int(max(1, random.gauss(9, 4)))
123
  risk = int(min(100, max(10, random.gauss(45, 15))))
124
  ethics = int(min(100, max(20, random.gauss(60, 15))))
125
- else:
126
  cost = int(max(1000, random.gauss(10000, 2000)))
127
  quality = int(min(100, max(45, random.gauss(75, 8))))
128
  delivery = int(max(1, random.gauss(7, 2)))
@@ -134,27 +133,7 @@ def synthesize_vendors(n: int = 10, scenario: str = "Normal") -> pd.DataFrame:
134
  df[col] = df[col].astype(int)
135
  return df
136
 
137
- # DuckDuckGo search wrapper
138
- def duckduckgo_search(query: str, max_items: int = 5) -> List[Dict[str, str]]:
139
- metrics["search_calls"] += 1
140
- save_metrics()
141
- try:
142
- r = requests.get("https://api.duckduckgo.com/",
143
- params={"q": query, "format": "json", "no_redirect": 1, "no_html": 1},
144
- timeout=10)
145
- data = r.json()
146
- out = []
147
- if data.get("AbstractText"):
148
- out.append({"source": "DDG", "text": data["AbstractText"]})
149
- for topic in data.get("RelatedTopics", []):
150
- if isinstance(topic, dict) and topic.get("Text"):
151
- out.append({"source": "DDG", "text": topic["Text"]})
152
- elif isinstance(topic, dict) and topic.get("Name"):
153
- out.append({"source": "DDG", "text": topic.get("Name")})
154
- return out[:max_items]
155
- except Exception:
156
- logger.warning("DuckDuckGo search failed; returning empty list.")
157
- return []
158
 
159
  # Gemini safe-call
160
  def gemini_safe_call(prompt: str, max_output_tokens: int = 200) -> Tuple[bool, str]:
@@ -163,9 +142,6 @@ def gemini_safe_call(prompt: str, max_output_tokens: int = 200) -> Tuple[bool, s
163
  return False, ""
164
  try:
165
  genai, model = GENAI_ENV
166
-
167
- # Use the standard generate_content call for modern SDKs
168
- # Note: We configured model to be a GenerativeModel instance in load_gemini
169
  resp = model.generate_content(prompt,
170
  generation_config=genai.types.GenerateContentConfig(
171
  max_output_tokens=max_output_tokens
@@ -173,7 +149,6 @@ def gemini_safe_call(prompt: str, max_output_tokens: int = 200) -> Tuple[bool, s
173
  metrics["gemini_calls"] += 1; save_metrics()
174
  text = getattr(resp, "text", str(resp))
175
  return True, text.strip()
176
-
177
  except Exception as e:
178
  logger.warning(f"Gemini call failed: {e}")
179
  USE_GEMINI = False
@@ -185,66 +160,58 @@ def offline_profile_str(row: pd.Series) -> str:
185
  return (f"{row['VendorID']}: cost {row['Cost']}, quality {row['Quality']}/100, "
186
  f"delivery {row['DeliveryTime']} days, risk {row['Risk']}/100, ethics {row.get('Ethics', 0)}/100.")
187
 
188
- def offline_search_sim(query: str, vendors_df: pd.DataFrame, top_k: int = 5) -> List[Dict[str, Any]]:
189
- scores = []
190
- q = query.lower()
191
- for _, r in vendors_df.iterrows():
192
- s = 0.2 * (r["Quality"] / 100.0) + 0.2 * (1 - r["Risk"] / 100.0) + 0.2 * (1 - r["DeliveryTime"] / 20.0)
193
- if "cost" in q or "cheap" in q:
194
- s += 0.3 * (1 - (r["Cost"] / max(1, vendors_df["Cost"].max())))
195
- if "ethical" in q or "eco" in q or "sustain" in q:
196
- s += 0.3 * (r["Ethics"] / 100.0)
197
- scores.append((s, r))
198
- scores.sort(key=lambda x: x[0], reverse=True)
199
- out = []
200
- for s, r in scores[:top_k]:
201
- d = r.to_dict()
202
- d["relevance_score"] = float(round(s, 4))
203
- out.append(d)
204
- return out
205
-
206
- # TOPSIS MCDA
207
  def topsis_scores(df: pd.DataFrame, criteria: List[str], weights: List[float], criteria_type: Dict[str, str]) -> pd.DataFrame:
208
  X = df[criteria].astype(float).values
209
  w = np.array(weights, dtype=float)
210
  if w.sum() == 0:
 
211
  raise ValueError("Weights sum to zero")
212
  w = w / w.sum()
 
 
213
  denom = np.sqrt((X**2).sum(axis=0)); denom[denom == 0] = 1e-12
214
  R = X / denom
 
 
215
  V = R * w
 
 
216
  m = V.shape[1]
217
  ideal_best = np.zeros(m); ideal_worst = np.zeros(m)
218
  for j, crit in enumerate(criteria):
219
  if criteria_type[crit] == 'benefit':
220
  ideal_best[j] = V[:, j].max(); ideal_worst[j] = V[:, j].min()
221
- else:
222
  ideal_best[j] = V[:, j].min(); ideal_worst[j] = V[:, j].max()
 
 
223
  dist_best = np.sqrt(((V - ideal_best) ** 2).sum(axis=1))
224
  dist_worst = np.sqrt(((V - ideal_worst) ** 2).sum(axis=1))
 
 
225
  denom2 = dist_best + dist_worst; denom2[denom2 == 0] = 1e-12
226
  score = dist_worst / denom2
 
 
227
  res = df.copy().reset_index(drop=True)
228
  res["TOPSIS_Score"] = score
229
  res["Rank"] = res["TOPSIS_Score"].rank(ascending=False, method="min").astype(int)
230
  return res.sort_values("Rank").reset_index(drop=True)
231
 
232
- # Agent base
 
233
  class Agent:
234
  def __init__(self, name: str, session: Session):
235
  self.name = name
236
  self.session = session
237
  self.log = logging.getLogger(name)
238
 
239
- def run(self, *args, **kwargs):
240
- raise NotImplementedError
241
-
242
  def log_event(self, tag: str, details: Dict[str, Any]):
243
  self.session.history.append({"time": time.time(), "agent": self.name, "tag": tag, "details": details})
244
  self.session.persist()
245
  self.log.info(f"{self.name}:{tag}")
246
 
247
- # DataRetrievalAgent
248
  class DataRetrievalAgent(Agent):
249
  def __init__(self, name: str, session: Session, vendors_df: pd.DataFrame):
250
  super().__init__(name, session)
@@ -253,7 +220,6 @@ class DataRetrievalAgent(Agent):
253
  def run(self, query: str = "general market scan") -> pd.DataFrame:
254
  enriched = []
255
  for idx, row in self.vendors_df.iterrows():
256
- # Use 'Description' column for LLM enrichment if available, otherwise fallback
257
  prompt = f"Provide a 1-2 sentence procurement profile for: {row.to_dict()}"
258
  ok, text = gemini_safe_call(prompt, max_output_tokens=120) if USE_GEMINI else (False, "")
259
  enriched.append(text if (ok and text) else offline_profile_str(row))
@@ -261,7 +227,6 @@ class DataRetrievalAgent(Agent):
261
  self.log_event("data_enriched", {"count": len(enriched)})
262
  return self.vendors_df
263
 
264
- # EvaluationAgent
265
  class EvaluationAgent(Agent):
266
  def __init__(self, name: str, session: Session):
267
  super().__init__(name, session)
@@ -269,6 +234,8 @@ class EvaluationAgent(Agent):
269
  def run(self, vendors_df: pd.DataFrame, scenarios: Dict[str, List[float]], criteria: List[str], criteria_type: Dict[str, str], perturb: bool = True) -> Dict[str, Any]:
270
  results = {}
271
  for scen_name, weights in scenarios.items():
 
 
272
  if perturb:
273
  delta = np.random.normal(0, 0.02, size=len(weights))
274
  w = np.array(weights) + delta
@@ -278,20 +245,20 @@ class EvaluationAgent(Agent):
278
  w = list(w / w.sum())
279
  else:
280
  w = weights
281
-
282
- # CRITICAL CHECK: Ensure weights have the same length as criteria
283
  if len(w) != len(criteria):
284
  self.log.error(f"Weights length ({len(w)}) does not match criteria length ({len(criteria)}) for scenario {scen_name}. Skipping.")
285
  continue
286
-
287
  res = topsis_scores(vendors_df, criteria, w, criteria_type)
288
  results[scen_name] = {"meta": {"weights": [float(x) for x in w]}, "result_table": res.to_dict(orient="list")}
289
  self.log_event("scenario_scored", {"scenario": scen_name, "top1": res.iloc[0]["VendorID"]})
290
- self.session.topsis_results = results
 
291
  self.session.persist()
292
  return results
293
 
294
- # EthicsAgent
295
  class EthicsAgent(Agent):
296
  def __init__(self, name: str, session: Session):
297
  super().__init__(name, session)
@@ -306,36 +273,6 @@ class EthicsAgent(Agent):
306
  self.log_event("ethics_evaluated", {"avg_penalty": float(np.mean(penalties))})
307
  return vendors_df
308
 
309
- # DecisionAgent
310
- class DecisionAgent(Agent):
311
- def __init__(self, name: str, session: Session):
312
- super().__init__(name, session)
313
-
314
- def run(self, chosen: Optional[Dict[str, Any]], initial_top: Optional[Dict[str, Any]], scenario_name: str, risk_threshold: float) -> str:
315
- prompt = (f"You are a procurement analyst. Explain why '{chosen.get('vendor') if chosen else 'None'}' is selected over '{initial_top.get('VendorID') if initial_top else 'None'}'. "
316
- f"Include key tradeoffs and next steps. Scenario: {scenario_name}, risk threshold: {risk_threshold}.")
317
- ok, text = gemini_safe_call(prompt, max_output_tokens=250) if USE_GEMINI else (False, "")
318
- if ok and text:
319
- report = text
320
- else:
321
- report = (f"Selected {chosen.get('vendor') if chosen else 'None'}: meets compliance checks and offers acceptable tradeoffs. "
322
- f"Initial top candidate: {initial_top.get('VendorID') if initial_top else 'None'}. Next steps: due diligence, reference checks.")
323
- self.session.mem["last_explanation"] = report
324
- self.session.persist()
325
- self.log_event("decision_explained", {"chosen": chosen})
326
- return report
327
-
328
- # ReportAgent
329
- class ReportAgent(Agent):
330
- def __init__(self, name: str, session: Session):
331
- super().__init__(name, session)
332
-
333
- def run(self, session: Session) -> Dict[str, Any]:
334
- summary = {"session_id": session.session_id, "scenarios": list(session.topsis_results.keys())}
335
- self.log_event("report_generated", {"scenarios": len(summary["scenarios"])})
336
- return summary
337
-
338
- # ComplianceAgent
339
  class ComplianceAgent(Agent):
340
  def __init__(self, name: str, session: Session, risk_threshold: float = 50, max_iters: int = 5):
341
  super().__init__(name, session)
@@ -344,8 +281,7 @@ class ComplianceAgent(Agent):
344
 
345
  def _assess(self, row: Dict[str, Any]) -> List[str]:
346
  issues = []
347
- # CRITICAL FIX: Ensure 'Risk' is accessed via .get() to avoid KeyError if data structure is missing it
348
- # Note: Since the DataFrame is created here, this is mainly for robustness.
349
  if row.get("Risk", 100) > self.risk_threshold:
350
  issues.append("HighRisk")
351
  if row.get("Quality", 0) < 60:
@@ -354,7 +290,6 @@ class ComplianceAgent(Agent):
354
 
355
  def find_compliant(self, topsis_df: pd.DataFrame) -> Optional[Dict[str, Any]]:
356
  for _, r in topsis_df.iterrows():
357
- # Pass dictionary row to _assess
358
  if not self._assess(r.to_dict()):
359
  return {"vendor": r["VendorID"], "row": r.to_dict()}
360
  return None
@@ -368,7 +303,6 @@ class ComplianceAgent(Agent):
368
  for name, out in session.topsis_results.items():
369
  if "result_table" not in out:
370
  continue
371
- # CRITICAL FIX: Ensure result_table is converted to a DataFrame before use
372
  try:
373
  df = pd.DataFrame(out["result_table"])
374
  except Exception as e:
@@ -381,32 +315,33 @@ class ComplianceAgent(Agent):
381
  chosen_scenario = name
382
  break
383
 
384
- # 2. Iterate if no compliant vendor found
385
  while chosen is None and iterations < self.max_iters:
386
  iterations += 1
 
387
  if iterations == 1:
388
  new = {"W_risk_strong": [0.1, 0.2, 0.2, 0.5]}
389
  elif iterations == 2:
390
  new = {"W_quality_strong": [0.15, 0.6, 0.15, 0.1]}
391
  else:
392
- new = {"W_balanced": [0.25, 0.35, 0.2, 0.2]}
393
 
394
  eval_agent = EvaluationAgent("EvalInner", session)
395
- # CRITICAL FIX: Read vendor data from JSON string back into a DataFrame
396
- # This ensures the EvaluationAgent has the necessary data frame.
397
  vendors_df_from_json = pd.read_json(session.vendors_df_json, orient="records")
398
 
 
399
  new_results = eval_agent.run(vendors_df_from_json, new, criteria, criteria_type, perturb=True)
400
  session.topsis_results.update(new_results)
401
  session.persist()
402
 
 
403
  for name, out in new_results.items():
404
  if "result_table" not in out:
405
  continue
406
  try:
407
  df = pd.DataFrame(out["result_table"])
408
- except Exception as e:
409
- self.log.error(f"Failed to create DataFrame in iteration {iterations}: {e}")
410
  continue
411
 
412
  candidate = self.find_compliant(df)
@@ -420,16 +355,52 @@ class ComplianceAgent(Agent):
420
  self.log_event("compliance_completed", {"iterations": iterations, "chosen": bool(chosen)})
421
  return {"iterations": iterations, "found": bool(chosen), "chosen": chosen, "chosen_scenario": chosen_scenario}
422
 
423
- # Orchestrator
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  def run_full_pipeline(n_vendors: int = 10, profile_weights: Optional[Dict[str, List[float]]] = None, scenario: str = "Normal", risk_threshold: float = 50) -> Tuple[Session, Dict[str, Any]]:
 
425
  session = Session.load() or Session()
426
- vendors_df = synthesize_vendors(n_vendors, scenario)
427
 
428
- # Store the vendors DataFrame as a JSON string in the session object
 
429
  session.vendors_df_json = vendors_df.to_json(orient="records")
430
  session.persist()
431
 
432
- # Data Retrieval and Ethics Agents run first
433
  data_agent = DataRetrievalAgent("DataAgent", session, vendors_df)
434
  vendors_df = data_agent.run(query="market scan")
435
  ethics_agent = EthicsAgent("EthicsAgent", session)
@@ -439,27 +410,27 @@ def run_full_pipeline(n_vendors: int = 10, profile_weights: Optional[Dict[str, L
439
  criteria_type = {"Cost": "cost", "Quality": "benefit", "DeliveryTime": "cost", "Risk": "cost"}
440
  scenarios = profile_weights or {"ProfileBase": [0.25, 0.35, 0.2, 0.2], "Equal": [0.25, 0.25, 0.25, 0.25]}
441
 
442
- # Evaluation Agent (Initial TOPSIS run)
443
  eval_agent = EvaluationAgent("EvalAgent", session)
444
- eval_results = eval_agent.run(vendors_df, scenarios, criteria, criteria_type, perturb=True)
445
 
446
- # Compliance Agent (Check compliance and re-run TOPSIS if needed)
447
  comp_agent = ComplianceAgent("CompAgent", session, risk_threshold=risk_threshold, max_iters=4)
448
  comp_out = comp_agent.run(session, scenarios, criteria, criteria_type)
449
  chosen = comp_out.get("chosen")
450
- chosen_scenario = comp_out.get("chosen_scenario") or list(scenarios.keys())[0] # Fallback to first scenario name
451
 
452
- # Decision Agent (Generate explanation)
453
  initial_top = None
454
  if chosen_scenario and session.topsis_results.get(chosen_scenario):
455
  rt = session.topsis_results[chosen_scenario]
456
  if "result_table" in rt:
457
  df = pd.DataFrame(rt["result_table"])
458
  initial_top = df.iloc[0].to_dict()
 
459
  decision_agent = DecisionAgent("DecisionAgent", session)
460
  report_text = decision_agent.run(chosen or {"vendor": "None"}, initial_top, chosen_scenario, risk_threshold)
461
 
462
- # Report Agent (Final summary)
463
  report_agent = ReportAgent("ReportAgent", session)
464
  summary = report_agent.run(session)
465
 
 
11
  from typing import Any, Dict, List, Optional, Tuple
12
 
13
  # CRITICAL FIX: Ensure all required third-party libraries are imported
 
14
  import numpy as np
15
  import pandas as pd
16
  import requests
 
 
17
 
18
+ # --- Logging and Metrics ---
19
+
20
  LOG_FN = "neuravend.log"
21
  logging.basicConfig(
22
  level=logging.INFO,
 
57
  return cls(**data)
58
  return None
59
 
60
+ # --- Gemini Configuration (Fallback Safe) ---
61
+
62
  def load_gemini():
63
  key = os.environ.get("GEMINI_API_KEY") or os.environ.get("AI_API_KEY")
64
  if not key:
 
67
  try:
68
  import google.generativeai as genai
69
  genai.configure(api_key=key)
 
70
  try:
71
  model = genai.GenerativeModel("gemini-1.5-flash")
72
  except Exception as e:
 
75
 
76
  if model:
77
  logger.info("Gemini configured (optional).")
 
78
  return (genai, model), True
79
  except Exception as e:
80
  logger.warning(f"Gemini SDK init failed: {e}; continuing offline.")
 
90
  logger.info("Internal seed set.")
91
  _set_seed()
92
 
93
+ # --- Vendor Synthesis ---
94
+
95
  NAME_WORDS = ["Astra", "Blue", "Nova", "Prime", "Eco", "Vertex", "Luma", "Grid", "Core", "Pioneer", "Green"]
96
  SUFFIX = ["Ltd", "Pvt Ltd", "Inc", "LLC", "Corp"]
97
 
 
102
  rows = []
103
  for i in range(n):
104
  name = gen_name(i)
105
+ # Data synthesis logic based on scenario
106
  if scenario == "Disruption":
107
  cost = int(max(1000, random.gauss(11000, 3000)))
108
  quality = int(min(100, max(40, random.gauss(72, 10))))
 
121
  delivery = int(max(1, random.gauss(9, 4)))
122
  risk = int(min(100, max(10, random.gauss(45, 15))))
123
  ethics = int(min(100, max(20, random.gauss(60, 15))))
124
+ else: # Normal
125
  cost = int(max(1000, random.gauss(10000, 2000)))
126
  quality = int(min(100, max(45, random.gauss(75, 8))))
127
  delivery = int(max(1, random.gauss(7, 2)))
 
133
  df[col] = df[col].astype(int)
134
  return df
135
 
136
+ # --- LLM and Offline Fallback ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  # Gemini safe-call
139
  def gemini_safe_call(prompt: str, max_output_tokens: int = 200) -> Tuple[bool, str]:
 
142
  return False, ""
143
  try:
144
  genai, model = GENAI_ENV
 
 
 
145
  resp = model.generate_content(prompt,
146
  generation_config=genai.types.GenerateContentConfig(
147
  max_output_tokens=max_output_tokens
 
149
  metrics["gemini_calls"] += 1; save_metrics()
150
  text = getattr(resp, "text", str(resp))
151
  return True, text.strip()
 
152
  except Exception as e:
153
  logger.warning(f"Gemini call failed: {e}")
154
  USE_GEMINI = False
 
160
  return (f"{row['VendorID']}: cost {row['Cost']}, quality {row['Quality']}/100, "
161
  f"delivery {row['DeliveryTime']} days, risk {row['Risk']}/100, ethics {row.get('Ethics', 0)}/100.")
162
 
163
+ # --- TOPSIS MCDA ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  def topsis_scores(df: pd.DataFrame, criteria: List[str], weights: List[float], criteria_type: Dict[str, str]) -> pd.DataFrame:
165
  X = df[criteria].astype(float).values
166
  w = np.array(weights, dtype=float)
167
  if w.sum() == 0:
168
+ # Handle zero weights by raising an error or defaulting, raise for robustness
169
  raise ValueError("Weights sum to zero")
170
  w = w / w.sum()
171
+
172
+ # 1. Normalization (Vector Normalization)
173
  denom = np.sqrt((X**2).sum(axis=0)); denom[denom == 0] = 1e-12
174
  R = X / denom
175
+
176
+ # 2. Weighted Normalized Decision Matrix
177
  V = R * w
178
+
179
+ # 3. Determine Ideal Best (A+) and Ideal Worst (A-)
180
  m = V.shape[1]
181
  ideal_best = np.zeros(m); ideal_worst = np.zeros(m)
182
  for j, crit in enumerate(criteria):
183
  if criteria_type[crit] == 'benefit':
184
  ideal_best[j] = V[:, j].max(); ideal_worst[j] = V[:, j].min()
185
+ else: # cost
186
  ideal_best[j] = V[:, j].min(); ideal_worst[j] = V[:, j].max()
187
+
188
+ # 4. Calculate Separation Measure (Euclidean distance)
189
  dist_best = np.sqrt(((V - ideal_best) ** 2).sum(axis=1))
190
  dist_worst = np.sqrt(((V - ideal_worst) ** 2).sum(axis=1))
191
+
192
+ # 5. Calculate Relative Closeness (TOPSIS Score)
193
  denom2 = dist_best + dist_worst; denom2[denom2 == 0] = 1e-12
194
  score = dist_worst / denom2
195
+
196
+ # 6. Final Results
197
  res = df.copy().reset_index(drop=True)
198
  res["TOPSIS_Score"] = score
199
  res["Rank"] = res["TOPSIS_Score"].rank(ascending=False, method="min").astype(int)
200
  return res.sort_values("Rank").reset_index(drop=True)
201
 
202
+ # --- Agent System ---
203
+
204
  class Agent:
205
  def __init__(self, name: str, session: Session):
206
  self.name = name
207
  self.session = session
208
  self.log = logging.getLogger(name)
209
 
 
 
 
210
  def log_event(self, tag: str, details: Dict[str, Any]):
211
  self.session.history.append({"time": time.time(), "agent": self.name, "tag": tag, "details": details})
212
  self.session.persist()
213
  self.log.info(f"{self.name}:{tag}")
214
 
 
215
  class DataRetrievalAgent(Agent):
216
  def __init__(self, name: str, session: Session, vendors_df: pd.DataFrame):
217
  super().__init__(name, session)
 
220
  def run(self, query: str = "general market scan") -> pd.DataFrame:
221
  enriched = []
222
  for idx, row in self.vendors_df.iterrows():
 
223
  prompt = f"Provide a 1-2 sentence procurement profile for: {row.to_dict()}"
224
  ok, text = gemini_safe_call(prompt, max_output_tokens=120) if USE_GEMINI else (False, "")
225
  enriched.append(text if (ok and text) else offline_profile_str(row))
 
227
  self.log_event("data_enriched", {"count": len(enriched)})
228
  return self.vendors_df
229
 
 
230
  class EvaluationAgent(Agent):
231
  def __init__(self, name: str, session: Session):
232
  super().__init__(name, session)
 
234
  def run(self, vendors_df: pd.DataFrame, scenarios: Dict[str, List[float]], criteria: List[str], criteria_type: Dict[str, str], perturb: bool = True) -> Dict[str, Any]:
235
  results = {}
236
  for scen_name, weights in scenarios.items():
237
+
238
+ # Weight perturbation logic
239
  if perturb:
240
  delta = np.random.normal(0, 0.02, size=len(weights))
241
  w = np.array(weights) + delta
 
245
  w = list(w / w.sum())
246
  else:
247
  w = weights
248
+
249
+ # CRITICAL CHECK: Weights length validation
250
  if len(w) != len(criteria):
251
  self.log.error(f"Weights length ({len(w)}) does not match criteria length ({len(criteria)}) for scenario {scen_name}. Skipping.")
252
  continue
253
+
254
  res = topsis_scores(vendors_df, criteria, w, criteria_type)
255
  results[scen_name] = {"meta": {"weights": [float(x) for x in w]}, "result_table": res.to_dict(orient="list")}
256
  self.log_event("scenario_scored", {"scenario": scen_name, "top1": res.iloc[0]["VendorID"]})
257
+
258
+ self.session.topsis_results.update(results) # Use update to preserve compliance iterations
259
  self.session.persist()
260
  return results
261
 
 
262
  class EthicsAgent(Agent):
263
  def __init__(self, name: str, session: Session):
264
  super().__init__(name, session)
 
273
  self.log_event("ethics_evaluated", {"avg_penalty": float(np.mean(penalties))})
274
  return vendors_df
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  class ComplianceAgent(Agent):
277
  def __init__(self, name: str, session: Session, risk_threshold: float = 50, max_iters: int = 5):
278
  super().__init__(name, session)
 
281
 
282
  def _assess(self, row: Dict[str, Any]) -> List[str]:
283
  issues = []
284
+ # Check risk threshold and quality compliance
 
285
  if row.get("Risk", 100) > self.risk_threshold:
286
  issues.append("HighRisk")
287
  if row.get("Quality", 0) < 60:
 
290
 
291
  def find_compliant(self, topsis_df: pd.DataFrame) -> Optional[Dict[str, Any]]:
292
  for _, r in topsis_df.iterrows():
 
293
  if not self._assess(r.to_dict()):
294
  return {"vendor": r["VendorID"], "row": r.to_dict()}
295
  return None
 
303
  for name, out in session.topsis_results.items():
304
  if "result_table" not in out:
305
  continue
 
306
  try:
307
  df = pd.DataFrame(out["result_table"])
308
  except Exception as e:
 
315
  chosen_scenario = name
316
  break
317
 
318
+ # 2. Iteratively re-run if no compliant vendor is found
319
  while chosen is None and iterations < self.max_iters:
320
  iterations += 1
321
+ # Define new weights to try finding a compliant vendor
322
  if iterations == 1:
323
  new = {"W_risk_strong": [0.1, 0.2, 0.2, 0.5]}
324
  elif iterations == 2:
325
  new = {"W_quality_strong": [0.15, 0.6, 0.15, 0.1]}
326
  else:
327
+ new = {"W_balanced_recheck": [0.25, 0.35, 0.2, 0.2]}
328
 
329
  eval_agent = EvaluationAgent("EvalInner", session)
330
+ # Recreate DataFrame from JSON string for inner loop consistency
 
331
  vendors_df_from_json = pd.read_json(session.vendors_df_json, orient="records")
332
 
333
+ # Run evaluation with new weights
334
  new_results = eval_agent.run(vendors_df_from_json, new, criteria, criteria_type, perturb=True)
335
  session.topsis_results.update(new_results)
336
  session.persist()
337
 
338
+ # Check new results
339
  for name, out in new_results.items():
340
  if "result_table" not in out:
341
  continue
342
  try:
343
  df = pd.DataFrame(out["result_table"])
344
+ except Exception:
 
345
  continue
346
 
347
  candidate = self.find_compliant(df)
 
355
  self.log_event("compliance_completed", {"iterations": iterations, "chosen": bool(chosen)})
356
  return {"iterations": iterations, "found": bool(chosen), "chosen": chosen, "chosen_scenario": chosen_scenario}
357
 
358
+ class DecisionAgent(Agent):
359
+ def __init__(self, name: str, session: Session):
360
+ super().__init__(name, session)
361
+
362
+ def run(self, chosen: Optional[Dict[str, Any]], initial_top: Optional[Dict[str, Any]], scenario_name: str, risk_threshold: float) -> str:
363
+
364
+ chosen_vendor_id = chosen.get('vendor') if chosen else 'None'
365
+ initial_top_id = initial_top.get('VendorID') if initial_top else 'None'
366
+
367
+ prompt = (f"You are a procurement analyst. Explain why '{chosen_vendor_id}' is selected over '{initial_top_id}'. "
368
+ f"Include key tradeoffs and next steps. Scenario: {scenario_name}, risk threshold: {risk_threshold}.")
369
+
370
+ ok, text = gemini_safe_call(prompt, max_output_tokens=250) if USE_GEMINI else (False, "")
371
+
372
+ if ok and text:
373
+ report = text
374
+ else:
375
+ report = (f"Selected **{chosen_vendor_id}**: meets compliance checks (Risk < {risk_threshold}, Quality > 60) and offers acceptable tradeoffs. "
376
+ f"Initial top candidate (before compliance check): **{initial_top_id}**. Next steps: conduct due diligence and reference checks.")
377
+
378
+ self.session.mem["last_explanation"] = report
379
+ self.session.persist()
380
+ self.log_event("decision_explained", {"chosen": chosen})
381
+ return report
382
+
383
+ class ReportAgent(Agent):
384
+ def __init__(self, name: str, session: Session):
385
+ super().__init__(name, session)
386
+
387
+ def run(self, session: Session) -> Dict[str, Any]:
388
+ summary = {"session_id": session.session_id, "scenarios": list(session.topsis_results.keys())}
389
+ self.log_event("report_generated", {"scenarios": len(summary["scenarios"])})
390
+ return summary
391
+
392
+ # --- Orchestrator ---
393
+
394
  def run_full_pipeline(n_vendors: int = 10, profile_weights: Optional[Dict[str, List[float]]] = None, scenario: str = "Normal", risk_threshold: float = 50) -> Tuple[Session, Dict[str, Any]]:
395
+
396
  session = Session.load() or Session()
 
397
 
398
+ # --- 1. Synthesis & Setup ---
399
+ vendors_df = synthesize_vendors(n_vendors, scenario)
400
  session.vendors_df_json = vendors_df.to_json(orient="records")
401
  session.persist()
402
 
403
+ # --- 2. Data & Ethics Processing ---
404
  data_agent = DataRetrievalAgent("DataAgent", session, vendors_df)
405
  vendors_df = data_agent.run(query="market scan")
406
  ethics_agent = EthicsAgent("EthicsAgent", session)
 
410
  criteria_type = {"Cost": "cost", "Quality": "benefit", "DeliveryTime": "cost", "Risk": "cost"}
411
  scenarios = profile_weights or {"ProfileBase": [0.25, 0.35, 0.2, 0.2], "Equal": [0.25, 0.25, 0.25, 0.25]}
412
 
413
+ # --- 3. Initial Evaluation ---
414
  eval_agent = EvaluationAgent("EvalAgent", session)
415
+ eval_agent.run(vendors_df, scenarios, criteria, criteria_type, perturb=True)
416
 
417
+ # --- 4. Compliance Check & Rerun Loop ---
418
  comp_agent = ComplianceAgent("CompAgent", session, risk_threshold=risk_threshold, max_iters=4)
419
  comp_out = comp_agent.run(session, scenarios, criteria, criteria_type)
420
  chosen = comp_out.get("chosen")
421
+ chosen_scenario = comp_out.get("chosen_scenario") or list(scenarios.keys())[0]
422
 
423
+ # --- 5. Decision & Reporting ---
424
  initial_top = None
425
  if chosen_scenario and session.topsis_results.get(chosen_scenario):
426
  rt = session.topsis_results[chosen_scenario]
427
  if "result_table" in rt:
428
  df = pd.DataFrame(rt["result_table"])
429
  initial_top = df.iloc[0].to_dict()
430
+
431
  decision_agent = DecisionAgent("DecisionAgent", session)
432
  report_text = decision_agent.run(chosen or {"vendor": "None"}, initial_top, chosen_scenario, risk_threshold)
433
 
 
434
  report_agent = ReportAgent("ReportAgent", session)
435
  summary = report_agent.run(session)
436