Spaces:
Runtime error
Runtime error
Update neuravend.py
Browse files- 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
|
|
|
|
| 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
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 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
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
| 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 |
-
#
|
| 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.
|
| 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 = {"
|
| 393 |
|
| 394 |
eval_agent = EvaluationAgent("EvalInner", session)
|
| 395 |
-
#
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
| 429 |
session.vendors_df_json = vendors_df.to_json(orient="records")
|
| 430 |
session.persist()
|
| 431 |
|
| 432 |
-
#
|
| 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 |
-
#
|
| 443 |
eval_agent = EvaluationAgent("EvalAgent", session)
|
| 444 |
-
|
| 445 |
|
| 446 |
-
#
|
| 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]
|
| 451 |
|
| 452 |
-
# Decision
|
| 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 |
|