chay123-crypto commited on
Commit
f18ef61
·
verified ·
1 Parent(s): 73c6d75

Upload 14 files

Browse files
Files changed (14) hide show
  1. agent.py +196 -0
  2. app.py +306 -0
  3. chatbot.py +40 -0
  4. config.py +30 -0
  5. crypto.py +47 -0
  6. evaluation.py +229 -0
  7. helper.py +108 -0
  8. llm.py +18 -0
  9. report.py +177 -0
  10. state.py +27 -0
  11. stats.py +320 -0
  12. tools.py +360 -0
  13. visuals.py +574 -0
  14. workflow.py +66 -0
agent.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from IPython.display import FileLink, display
2
+ from state import AgentState
3
+ from langgraph.graph import END
4
+ import json
5
+ from langgraph.types import interrupt
6
+ import io
7
+ from helper import clean_report
8
+ import pandas as pd
9
+ from tools import run_profiler,domain_analyser,search_queries,web_search,causal_reasoning
10
+ from llm import llm
11
+ from visuals import deciding_plots,build_dashboard
12
+ from report import report_generator,report_maker
13
+ from stats import data_analyst,data_analysis
14
+ from crypto import anonymiser
15
+
16
+ def node_profiler(state:AgentState):
17
+ if state.get('error'):
18
+ return {}
19
+ try:
20
+ filepath=state['filepath']
21
+ df,profiler=run_profiler(filepath)
22
+ return {'df':df.to_json(orient='records'),'profiler':json.dumps(profiler,default=str)}
23
+ except Exception as e:
24
+ print(f'Pipeline aborted at node 1 due to {e}')
25
+ return {'error':str(e)}
26
+
27
+ def node_analyser(state:AgentState):
28
+ if state.get('error'):
29
+ return {}
30
+ try:
31
+ profiler=json.loads(state['profiler'])
32
+ domain,key_cols=domain_analyser(llm,profiler)
33
+ return {'domain_info':json.dumps(domain,default=str),'key_cols':json.dumps(key_cols,default=str)}
34
+ except Exception as e:
35
+ print(f'Pipeline aborted at node 2 due to {e}')
36
+ return {'error':str(e)}
37
+
38
+ def node_anonymiser(state:AgentState):
39
+ if state.get('error'):
40
+ return {}
41
+ try:
42
+ profiler=json.loads(state['profiler'])
43
+ df=pd.read_json(io.StringIO(state['df']), orient='records')
44
+ mapping_log,anonymized_df,key=anonymiser(llm,profiler,df)
45
+ print("PII COLUMNS DETECTED:", list(mapping_log.keys()))
46
+ return {'mapping_log':json.dumps(mapping_log, default=str),'anonymized_df':anonymized_df.to_json(orient='records'),'key':key}
47
+ except Exception as e:
48
+ print(f'Pipeline aborted at node 3 due to {e}')
49
+ return {'error':str(e)}
50
+
51
+ def node_data_analyser(state:AgentState):
52
+ if state.get('error'):
53
+ return {}
54
+ try:
55
+ profiler=json.loads(state['profiler'])
56
+ df=pd.read_json(io.StringIO(state['anonymized_df']), orient='records')
57
+ key_cols=json.loads(state['key_cols'])
58
+ domain_info=json.loads(state['domain_info'])
59
+ finds=data_analysis(df, key_cols, domain_info)
60
+ interpreted_finds=data_analyst(df,llm,key_cols,domain_info,profiler)
61
+ return {'interpreted_findings':json.dumps(interpreted_finds,default=str),'raw_findings':json.dumps(finds,default=str)}
62
+ except Exception as e:
63
+ print(f'Pipeline aborted at node 4 due to {e}')
64
+ return {'error':str(e)}
65
+
66
+ def node_query(state:AgentState):
67
+ if state.get('error'):
68
+ return {}
69
+ try:
70
+ domain=json.loads(state['domain_info'])
71
+ internal_findings=json.loads(state['interpreted_findings'])
72
+ queries=search_queries(llm,domain,internal_findings)
73
+ return {'search_queries':json.dumps(queries, default=str)}
74
+ except Exception as e:
75
+ print(f'Pipeline aborted at node 5 due to {e}')
76
+ return {'error':str(e)}
77
+
78
+ def node_search(state:AgentState):
79
+ if state.get('error'):
80
+ return {}
81
+ try:
82
+ queries=json.loads(state['search_queries'])
83
+ results=web_search(queries)
84
+ return {'search_results':json.dumps(results, default=str)}
85
+ except Exception as e:
86
+ print(f'Pipeline aborted at node 6 due to {e}')
87
+ return {'error':str(e)}
88
+
89
+ def node_reasoner(state:AgentState):
90
+ if state.get('error'):
91
+ return {}
92
+ try:
93
+ domain_info=json.loads(state['domain_info'])
94
+ interpreted_findings=json.loads(state['interpreted_findings'])
95
+ cleaned_results=state['search_results']
96
+ response=causal_reasoning(llm,domain_info,interpreted_findings,cleaned_results)
97
+ return {'causal_reasoning':json.dumps(response, default=str)}
98
+ except Exception as e:
99
+ print(f'Pipeline aborted at node 7 due to {e}')
100
+ return {'error':str(e)}
101
+
102
+ def node_reporter(state:AgentState):
103
+ if state.get('error'):
104
+ return {}
105
+ try:
106
+ domain=json.loads(state['domain_info'])
107
+ reasoning=json.loads(state['causal_reasoning'])
108
+ key=state['key']
109
+ feedback=state.get('report_feedback',None)
110
+ mapping_log=json.loads(state['mapping_log'])
111
+ interpreted_findings=json.loads(state['interpreted_findings'])
112
+ report=report_maker(llm,key,mapping_log,reasoning,interpreted_findings,domain,feedback)
113
+ return {'report':clean_report(report)}
114
+ except Exception as e:
115
+ print(f'Pipeline aborted at node 8 due to {e}')
116
+ return {'error':str(e)}
117
+
118
+ def node_plotdecider(state:AgentState):
119
+ if state.get('error'):
120
+ return {}
121
+ try:
122
+ key_cols=json.loads(state['key_cols'])
123
+ domain_info=json.loads(state['domain_info'])
124
+ df=pd.read_json(io.StringIO(state['anonymized_df']), orient='records')
125
+ feedback=state.get('dashboard_feedback',None)
126
+ columns=df.columns.to_list()
127
+ findings=json.loads(state['raw_findings'])
128
+ charts=deciding_plots(llm,df,findings,columns,key_cols,feedback)
129
+ return {'charts':json.dumps(charts, default=str)}
130
+ except Exception as e:
131
+ print(f'Pipeline aborted at node 9 due to {e}')
132
+ return {'error':str(e)}
133
+
134
+ def node_dashboard(state:AgentState):
135
+ if state.get('error'):
136
+ return {}
137
+ try:
138
+ key_cols=json.loads(state['key_cols'])
139
+ domain=json.loads(state['domain_info'])
140
+ df=pd.read_json(io.StringIO(state['anonymized_df']), orient='records')
141
+ inputs=json.loads(state['charts'])
142
+ key=state['key']
143
+ mapping_log=json.loads(state['mapping_log'])
144
+ print(f"[node_dashboard] Attempting to build dashboard with {len(inputs)} charts")
145
+ dashboard=build_dashboard(df,key_cols,inputs,key,mapping_log,domain)
146
+ print(f"[node_dashboard] Dashboard built successfully")
147
+ return {'dashboard':dashboard}
148
+ except Exception as e:
149
+ error_msg = f'Dashboard generation failed: {str(e)}'
150
+ print(f'[node_dashboard] Pipeline aborted: {error_msg}')
151
+ return {
152
+ 'dashboard': None,
153
+ 'dashboard_available': False,
154
+ 'dashboard_error': error_msg
155
+ }
156
+
157
+ def node_reportgen(state:AgentState):
158
+ if state.get('error'):
159
+ return {}
160
+ try:
161
+ report=state['report']
162
+ report_generator(report,"outputs/report.pdf")
163
+ display(FileLink("outputs/report.pdf"))
164
+ return {'report_saved':True}
165
+ except Exception as e:
166
+ import traceback
167
+ print(f"❌ Error: {str(e)}")
168
+ traceback.print_exc()
169
+ print(f'Pipeline aborted at node 11 due to {e}')
170
+ return {'error':str(e)}
171
+
172
+ def human_inloop_report(state):
173
+ if state.get('error'):
174
+ return {}
175
+ return {}
176
+
177
+ def human_inloop_dashboard(state):
178
+ if state.get('error'):
179
+ return {}
180
+ return {}
181
+
182
+ def should_proceed_report(state):
183
+ if state.get('report_approved')== True:
184
+ return "node_9"
185
+ return "revise_report"
186
+
187
+ def should_proceed_dashboard(state):
188
+ if state.get('dashboard_approved')== True:
189
+ return "node_10"
190
+ return "revise_charts"
191
+
192
+ def check_error(state: AgentState):
193
+ if state.get('error'):
194
+ return 'end'
195
+ else:
196
+ return 'continue'
app.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI,UploadFile,File,Body
2
+ from fastapi.responses import FileResponse,StreamingResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ import aiofiles
6
+ import asyncio
7
+ import json
8
+ from llm import chat_llm
9
+ import uvicorn
10
+ from chatbot import chatbot_followup
11
+ import uuid
12
+ from workflow import pipeline
13
+ import os
14
+
15
+ os.makedirs("uploads", exist_ok=True)
16
+ os.makedirs("outputs", exist_ok=True)
17
+
18
+ app=FastAPI()
19
+ app.mount("/templates",StaticFiles(directory="templates"))
20
+
21
+ jobs={}
22
+
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"],
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+ @app.get("/")
31
+ def home():
32
+ return FileResponse("templates/index.html")
33
+
34
+ @app.get("/sample_sales")
35
+ def get_sample():
36
+ return FileResponse("templates/sample_sales.csv",media_type="text/csv",filename="sample_sales.csv")
37
+
38
+ async def workflow_runner(job_id: str, filepath: str):
39
+ try:
40
+ jobs[job_id]["status"] = "processing"
41
+ config = {"configurable": {"thread_id": job_id}, "recursion_limit": 100}
42
+
43
+ # Start the pipeline - it will stop at check_report due to interrupt_before
44
+ result = await asyncio.to_thread(
45
+ pipeline.invoke,
46
+ {'filepath': filepath},
47
+ config=config
48
+ )
49
+
50
+ # Loop to handle multiple approval steps
51
+ while True:
52
+ # Check what node we're paused at
53
+ state = await asyncio.to_thread(pipeline.get_state, config)
54
+ next_nodes = list(state.next) if state and state.next else []
55
+
56
+ print(f"\n{'='*70}")
57
+ print(f"[STATUS CHECK] Next nodes: {next_nodes}")
58
+ print(f"{'='*70}\n")
59
+
60
+ # No more nodes = we're done
61
+ if not next_nodes:
62
+ print("[COMPLETE] No more nodes - workflow finished")
63
+ break
64
+
65
+ # Handle report approval pause
66
+ if "check_report" in next_nodes:
67
+ print("[PAUSE] Waiting at check_report")
68
+ jobs[job_id]["status"] = "pending_report"
69
+ print(f"[STATUS_UPDATE] Set status to pending_report for {job_id}")
70
+ # Store current state values
71
+ if state and state.values:
72
+ jobs[job_id]["result"] = state.values
73
+ else:
74
+ jobs[job_id]["result"] = result
75
+
76
+ # Wait for user approval
77
+ await jobs[job_id]["report_event"].wait()
78
+ jobs[job_id]["report_event"].clear()
79
+
80
+ approval = jobs[job_id]["approval_response"]
81
+ print(f"[RECEIVED] Report approval: {approval}")
82
+
83
+ # Set status to processing so frontend doesn't re-show report screen
84
+ jobs[job_id]["status"] = "processing"
85
+
86
+ # Update state with approval
87
+ await asyncio.to_thread(
88
+ pipeline.update_state,
89
+ config,
90
+ {
91
+ "report_approved": approval["approved"],
92
+ "report_feedback": approval.get("feedback", "")
93
+ }
94
+ )
95
+
96
+ # Resume pipeline - it will run to next interrupt (check_dashboard)
97
+ print("[RESUME] Continuing pipeline after report approval...")
98
+ result = await asyncio.to_thread(pipeline.invoke, None, config=config)
99
+ print("[RESUMED] Pipeline continued")
100
+ continue
101
+
102
+ # Handle dashboard approval pause
103
+ if "check_dashboard" in next_nodes:
104
+ print("[PAUSE] Waiting at check_dashboard")
105
+ jobs[job_id]["status"] = "pending_dashboard"
106
+ print(f"[STATUS_UPDATE] Set status to pending_dashboard for {job_id}")
107
+ # Store current state values with charts_html
108
+ if state and state.values:
109
+ jobs[job_id]["result"] = state.values
110
+ print(f"[DEBUG] Stored state values, keys: {list(state.values.keys())}")
111
+ else:
112
+ jobs[job_id]["result"] = result
113
+
114
+ # Wait for user approval
115
+ await jobs[job_id]["dashboard_event"].wait()
116
+ jobs[job_id]["dashboard_event"].clear()
117
+
118
+ approval = jobs[job_id]["approval_response"]
119
+ print(f"[RECEIVED] Dashboard approval: {approval}")
120
+
121
+ # Set status to processing so frontend doesn't re-show dashboard screen
122
+ jobs[job_id]["status"] = "processing"
123
+
124
+ # Update state with approval
125
+ await asyncio.to_thread(
126
+ pipeline.update_state,
127
+ config,
128
+ {
129
+ "dashboard_approved": approval["approved"],
130
+ "dashboard_feedback": approval.get("feedback", "")
131
+ }
132
+ )
133
+
134
+ # Resume pipeline
135
+ print("[RESUME] Continuing pipeline after dashboard approval...")
136
+ result = await asyncio.to_thread(pipeline.invoke, None, config=config)
137
+ print("[RESUMED] Pipeline continued")
138
+ continue
139
+
140
+ # Unexpected node
141
+ print(f"[WARNING] Unexpected next nodes: {next_nodes}")
142
+ break
143
+
144
+ # Check if the final result contains an error
145
+ if isinstance(result, dict) and result.get('error'):
146
+ jobs[job_id]["status"] = "failed"
147
+ jobs[job_id]["result"] = result
148
+ print(f"[ERROR] Pipeline encountered error: {result.get('error')}")
149
+ else:
150
+ jobs[job_id]["status"] = "completed"
151
+ jobs[job_id]["result"] = result
152
+ print(f"[SUCCESS] Job {job_id} completed successfully!")
153
+
154
+ except Exception as e:
155
+ jobs[job_id]["status"] = "failed"
156
+ jobs[job_id]["result"] = {"error": str(e)}
157
+ print(f"[ERROR] Job failed: {e}")
158
+ import traceback
159
+ traceback.print_exc()
160
+
161
+ @app.post("/upload")
162
+ async def analyze(data:UploadFile=File(...)):
163
+ if not data.filename.endswith(".csv"):
164
+ return {"error":"Invalid file format. Please upload csv file only."}
165
+ job_id=str(uuid.uuid4())[:10]
166
+
167
+ jobs[job_id]={
168
+ "status": "uploading",
169
+ "filepath": None,
170
+ "result": None,
171
+ "report_event": asyncio.Event(),
172
+ "dashboard_event": asyncio.Event(),
173
+ "approval_response": None,
174
+ "chat_history":[]
175
+ }
176
+
177
+ FILEPATH=f"uploads/{data.filename}"
178
+ async with aiofiles.open(FILEPATH,"wb") as buffer:
179
+ await buffer.write(await data.read())
180
+
181
+ asyncio.create_task(workflow_runner(job_id,FILEPATH))
182
+ return {'job_id':job_id,'filepath':FILEPATH,'msg':'File uploaded successfully. Processing will begin shortly.'}
183
+
184
+ @app.get("/status/{job_id}")
185
+ def get_status(job_id:str):
186
+ if job_id not in jobs:
187
+ return {"error":"Invalid job ID.", "status":"failed"}
188
+ status = jobs[job_id]["status"]
189
+ response = {"job_id":job_id, "status": status}
190
+ result = jobs[job_id].get("result", {})
191
+
192
+ # Check for errors in result dict regardless of status
193
+ if isinstance(result, dict) and result.get('error'):
194
+ response["status"] = "failed"
195
+ response["error"] = result.get("error", "Unknown error occurred")
196
+ elif status == "failed":
197
+ response["error"] = result.get("error", "Unknown error occurred") if isinstance(result, dict) else "Pipeline failed"
198
+
199
+ print(f"[STATUS] Job {job_id}: returning status={response['status']}")
200
+ return response
201
+
202
+ @app.get("/download_report/{job_id}")
203
+ async def download_report(job_id:str):
204
+ if job_id not in jobs:
205
+ return {"error":"Invalid job ID."}
206
+ if jobs[job_id]["status"]!="completed":
207
+ return {"error":"Job not completed yet."}
208
+ report_path="outputs/report.pdf"
209
+ return FileResponse(path=report_path, media_type="application/pdf", filename="report.pdf")
210
+
211
+ @app.get("/download_dashboard/{job_id}")
212
+ async def download_dashboard(job_id:str):
213
+ if job_id not in jobs:
214
+ return {"error":"Invalid job ID."}
215
+ if jobs[job_id]["status"]!="completed":
216
+ return {"error":"Job not completed yet."}
217
+ dashboard_path="outputs/dashboard.html"
218
+ return FileResponse(path=dashboard_path, media_type="text/html", filename="dashboard.html")
219
+
220
+ @app.get("/stream/{job_id}")
221
+ async def stream_output(job_id:str):
222
+ if job_id not in jobs:
223
+ return {'error':"Invalid job ID."}
224
+ async def event_generator():
225
+ while True:
226
+ status=jobs[job_id]['status']
227
+ yield f"data:{json.dumps({'status': status})}\n\n"
228
+ if status in ['completed','failed']:
229
+ break
230
+ await asyncio.sleep(2)
231
+ return StreamingResponse(event_generator(),media_type="text/event-stream")
232
+
233
+ @app.get("/report/{job_id}")
234
+ async def get_report(job_id:str):
235
+ if job_id not in jobs:
236
+ return {'error':"Invalid job ID."}
237
+ if jobs[job_id]['status'] not in ['pending_report','completed']:
238
+ return {'error':"Report not ready yet."}
239
+ report=jobs[job_id]["result"].get("report",{})
240
+ return {"report":report}
241
+
242
+ @app.get("/dashboard/{job_id}")
243
+ async def get_dashboard(job_id:str):
244
+ if job_id not in jobs:
245
+ print(f"[DASHBOARD] Job {job_id} not found")
246
+ return {'error':"Invalid job ID."}
247
+
248
+ status = jobs[job_id]['status']
249
+ print(f"[DASHBOARD] Job {job_id}: status={status}")
250
+
251
+ if status not in ['pending_dashboard','completed']:
252
+ print(f"[DASHBOARD] Job {job_id}: Dashboard not ready (status={status})")
253
+ return {'error':"Dashboard not ready yet."}
254
+
255
+ # During pending_dashboard, return charts recommendations
256
+ if status == 'pending_dashboard':
257
+ result = jobs[job_id]["result"]
258
+ print(f"[DASHBOARD] Pending: result keys = {list(result.keys()) if isinstance(result, dict) else 'not a dict'}")
259
+ # Keep charts as JSON string - don't parse it, just return it as-is
260
+ charts = result.get("charts", "No charts yet") if isinstance(result, dict) else "No charts yet"
261
+ print(f"[DASHBOARD] Returning charts as {type(charts).__name__}: {charts[:100] if isinstance(charts, str) else 'not a string'}...")
262
+ return {"dashboard": charts}
263
+
264
+ # After completion, return final dashboard
265
+ dashboard = jobs[job_id]["result"].get("dashboard", {}) if isinstance(jobs[job_id]["result"], dict) else {}
266
+ return {"dashboard": dashboard}
267
+
268
+ @app.post("/approve_report/{job_id}")
269
+ async def approve_report(job_id:str,approval:dict=Body(...)):
270
+ feedback=approval.get("feedback","")
271
+ approved=approval.get("approved",True)
272
+ if job_id not in jobs:
273
+ return {'error':"Invalid job ID."}
274
+ if jobs[job_id]['status']!="pending_report":
275
+ return {'error':"Report not ready for approval."}
276
+ jobs[job_id]["approval_response"]={"approved": approved, "feedback": feedback}
277
+ jobs[job_id]["report_event"].set()
278
+ return {"msg":"Report approval received."}
279
+
280
+ @app.post("/approve_dashboard/{job_id}")
281
+ async def approve_dashboard(job_id:str,approval:dict=Body(...)):
282
+ feedback=approval.get("feedback","")
283
+ approved=approval.get("approved",True)
284
+ if job_id not in jobs:
285
+ return {'error':"Invalid job ID."}
286
+ if jobs[job_id]['status']!="pending_dashboard":
287
+ return {'error':"Dashboard not ready for approval."}
288
+ jobs[job_id]["approval_response"]={"approved": approved, "feedback": feedback}
289
+ jobs[job_id]["dashboard_event"].set()
290
+ return {"msg":"Dashboard approval received."}
291
+
292
+ @app.post("/chat_with_argus/{job_id}")
293
+ def chat_with_argus(job_id:str,message:dict=Body(...)):
294
+ if job_id not in jobs:
295
+ return {'error':"Invalid job ID."}
296
+ if jobs[job_id]['status']!="completed":
297
+ return {'error':"Job not completed yet."}
298
+ result=jobs[job_id]["result"]
299
+ chat_history=jobs[job_id].setdefault("chat_history",[])
300
+ user_message=message.get("message")
301
+ reply=chatbot_followup(result,chat_llm,chat_history,user_message)
302
+ return {"response":reply}
303
+
304
+ if __name__=="__main__":
305
+ uvicorn.run(app,host="0.0.0.0",port=7860)
306
+
chatbot.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.messages import AIMessage,SystemMessage,HumanMessage
2
+ from llm import chat_llm,llm
3
+ from config import MAX_HISTORY
4
+
5
+ def chatbot_followup(result,chat_llm,chat_history,user_message):
6
+ chat_prompt=f"""You are an analyst assistant. Answer questions based ONLY on this analysis:
7
+
8
+ Domain: {result['domain_info']}
9
+ Key Metrics: {result['key_cols']}
10
+ Raw Findings:{result['raw_findings']}
11
+ Findings: {result['interpreted_findings']}
12
+ Causal Reasoning: {result['causal_reasoning']}
13
+ Report: {result['report']}
14
+ Charts : {result['charts']}
15
+ Rules:
16
+ - Only use numbers that exist in the context above
17
+ - If something isn't in the context, say so
18
+ - Be concise and specific"""
19
+ if len(chat_history)==0:
20
+ greet_prompt=f"""On startup, Greet the user with a warm tone,and provide 2 to 3 lines summary of of what data you analysed and how you can help the user.
21
+ Be friendly and concise and ask if they have questions.Instruct the user to type 'exit' if they wan to leave the chat."""
22
+ new_prompt=greet_prompt+chat_prompt
23
+ greeting=llm.invoke([
24
+ (SystemMessage(content=new_prompt)),(HumanMessage(content='hi'))
25
+ ])
26
+ print(f"\nArgusAI : {greeting.content}\n")
27
+ chat_history.append(AIMessage(content=greeting.content))
28
+ if user_message.strip() == "startup_greeting":
29
+ return greeting.content
30
+ EXIT_PHRASES = {"exit", "bye", "goodbye", "quit", "thanks", "thank you", "done"}
31
+ if user_message.strip() in EXIT_PHRASES:
32
+ return "Happy to help you with your analysis!"
33
+ chat_history.append(HumanMessage(content=user_message))
34
+ if len(chat_history) > MAX_HISTORY:
35
+ chat_history = chat_history[-MAX_HISTORY:]
36
+ messages=[SystemMessage(content=chat_prompt)]+chat_history
37
+ bot_reply=chat_llm.invoke(messages)
38
+ print(f"\nArgusAI : {bot_reply.content}\n")
39
+ chat_history.append(AIMessage(content=bot_reply.content))
40
+ return bot_reply.content
config.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ os.makedirs("cache", exist_ok=True)
4
+ CACHE_PATH = "cache/cache.db"
5
+
6
+ CEREBRAS_MODEL="gpt-oss-120b"
7
+ GROQ_MODEL="meta-llama/llama-4-scout-17b-16e-instruct"
8
+ TEMPERATURE=0.2
9
+ MAX_HISTORY=10
10
+
11
+ OUTPUT_DIR="output"
12
+ INPUT_DIR="input"
13
+
14
+ os.environ["LANGCHAIN_TRACING_V2"] = "true"
15
+ os.environ["LANGCHAIN_PROJECT"] = "ArgusAI"
16
+ os.environ["CEREBRAS_API_KEY"] = os.environ.get("CEREBRAS_API_KEY", "your-key-here")
17
+ os.environ["GROQ_API_KEY"] = os.environ.get("GROQ_API_KEY", "your-key-here")
18
+ os.environ["LANGCHAIN_API_KEY"] = os.environ.get("LANGCHAIN_API_KEY", "your-key-here")
19
+ os.environ["TAVILY_API_KEY"]=os.environ.get("TAVILY_API_KEY", "your-key-here")
20
+
21
+ RETRY_TIMEOUT: int=3
22
+ RETRY_MAX_ATTEMPTS: int=3
23
+ RETRY_BACKOFF: float=3
24
+
25
+ dashboard_palettes={
26
+ "clean":{"bg":"#f8fafc","sidebar":"#1e293b","card":"#ffffff","text":"#1e293b","muted":"#64748b","accent":"#6366f1","accent2":"#f43f5e","border":"#e2e8f0","header_text":"#ffffff","plotly":"plotly_white"},
27
+ "latte":{"bg":"#fdf6ec","sidebar":"#3b2a1a","card":"#fffcf7","text":"#3b2a1a","muted":"#92745a","accent":"#c87941","accent2":"#e05c2a","border":"#e8d5b7","header_text":"#ffffff","plotly":"ggplot2"},
28
+ "arctic":{"bg":"#eaf4fb","sidebar":"#023e8a","card":"#ffffff","text":"#023e8a","muted":"#4a90b8","accent":"#0077b6","accent2":"#00b4d8","border":"#b8ddf5","header_text":"#ffffff","plotly":"seaborn"},
29
+ "paper":{"bg":"#fffbeb","sidebar":"#1c1917","card":"#ffffff","text":"#1c1917","muted":"#78716c","accent":"#d97706","accent2":"#b45309","border":"#fde68a","header_text":"#ffffff","plotly":"ggplot2"},
30
+ }
crypto.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cryptography.fernet import Fernet
2
+ from helper import retry
3
+ from langsmith import traceable
4
+ from tools import critical_info
5
+
6
+ @traceable(name='anonymiser')
7
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
8
+ def anonymiser(llm,profiler,df):
9
+ key=Fernet.generate_key()
10
+ f=Fernet(key)
11
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
12
+ def get_critical():
13
+ critical=critical_info(llm,profiler)
14
+ return critical
15
+ critical=get_critical()
16
+ if not critical:
17
+ raise ValueError("PII detection returned empty")
18
+ anonymized_df=df.copy()
19
+ mapping_log={}
20
+
21
+ for col in critical:
22
+ if col in df.columns.to_list():
23
+ unique_vals=df[col].unique()
24
+ mapping={}
25
+
26
+ for val in unique_vals:
27
+ encoded=f.encrypt(str(val).encode())
28
+ display_id=f'ID-{encoded.decode()[9:15]}'
29
+ dmap={'full':encoded.decode(),'display':display_id}
30
+ mapping[str(val)]=dmap
31
+ display_map={v: mapping[str(v)]['display'] for v in unique_vals}
32
+ anonymized_df[col]=anonymized_df[col].map(display_map)
33
+ mapping_log[col] = mapping
34
+ return mapping_log,anonymized_df,key.decode()
35
+
36
+ def decryption(key,mapping_log):
37
+ reverse_map={}
38
+ f=Fernet(key)
39
+ for col,mapping in mapping_log.items():
40
+ for encoded,displayed in mapping.items():
41
+ try:
42
+ decrypted=f.decrypt(displayed['full'].encode()).decode()
43
+ reverse_map[displayed['display']]=decrypted
44
+ except:
45
+ reverse_map[displayed['display']]=str(encoded)
46
+
47
+ return reverse_map
evaluation.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import json
3
+ from cryptography.fernet import Fernet
4
+ import pandas as pd
5
+
6
+ def eval_1(output):
7
+ allowed=['retail_sales','manufacturing_quality','manufacturing_production',
8
+ 'manufacturing_maintenance','hr_attrition','hr_performance',
9
+ 'finance_transactions','logistics','other']
10
+ scores = {
11
+ 'valid_domain': output['Domain'] in allowed,
12
+ 'has_confidence': output['Confidence_Score'] in ['High','Medium','Low'],
13
+
14
+ 'has_key_metrics': len(output['Key_Metrics_to_watch']) > 0,
15
+ 'has_directions': len(output['Metric_Direction']) > 0,
16
+ 'keys_match': all(k in output['Metric_Direction']
17
+ for k in output['Key_Metrics_to_watch'])
18
+ }
19
+ scores['total'] = sum(scores.values()) / len(scores)
20
+ return scores
21
+
22
+ def eval_2(report, profiler, findings):
23
+ ground_truth_text = str(profiler) + str(findings)
24
+ raw_numbers = set(re.findall(r'\d+\.?\d*', ground_truth_text))
25
+ report_normalized=report.replace('\u202f', ' ').replace('\u2011', '-').replace('\u2013', '-')
26
+ expanded = set(raw_numbers)
27
+ for n in raw_numbers:
28
+ f = float(n)
29
+ if f < 1:
30
+ expanded.add(str(int(round(f * 100))))
31
+ expanded.add(str(round(f * 100, 2)))
32
+ elif f > 1:
33
+ expanded.add(str(round(f / 100, 4)))
34
+ numbers_in_findings = expanded
35
+
36
+ skip_patterns = [
37
+ r'page\s+\d+',
38
+ r'over[-\s]?\d',
39
+ r'top\s+\d+',
40
+ r'ranked.{0,10}\d+',
41
+ r'section\s+\d+',
42
+ r'\d+\s*%\s*confidence',
43
+ r'\d+\.\s+\*{0,2}\w',
44
+ r'phase\s*[\d\-]',
45
+ r'days?\b',
46
+ r'hrs?\b',
47
+ r'timeline',
48
+ r'\d{4}',
49
+ r'priority',
50
+ r'estimate',
51
+ r'approximately',
52
+ r'roughly',
53
+ r'≈',
54
+ r'~\s*\d',
55
+ r'projected?',
56
+ r'variance',
57
+ r'account\s+for',
58
+ r'attribut',
59
+ r'responsible\s+for',
60
+ r'\$[\d\.]+\s*[mk]',
61
+ r'[mk]\b',
62
+ r'replacement\s+cost',
63
+ r'hidden',
64
+ r'lost\s+product',
65
+ ]
66
+
67
+ invented = []
68
+ for match in re.finditer(r'(\d[\d,]*\.?\d*)',report):
69
+ raw_match=match.group(1)
70
+ num=raw_match.replace(',','')
71
+
72
+ try:
73
+ fnum = float(num)
74
+ except ValueError:
75
+ continue
76
+
77
+ start = max(0, match.start() - 30)
78
+ end = min(len(report), match.end() + 30)
79
+ context = report[start:end].lower()
80
+ start_context = report[max(0, match.start()-5):match.start()].lower()
81
+
82
+ if any(op in start_context for op in ['= ', '× ', 'to ', '→']):
83
+ continue
84
+ if any(re.search(p, context) for p in skip_patterns):
85
+ continue
86
+ if fnum <= 20 and re.search(r'\d+\.\s+\*{0,2}\w', context):
87
+ continue
88
+ if fnum <= 20 and any(
89
+ w in context for w in ['issue', 'rank', 'step', 'point', 'item', '#']
90
+ ):
91
+ continue
92
+ stat_patterns = [r'mean', r'average', r'avg', r'gap', r'delta', r'\bvs\b',
93
+ r'compared', r'higher', r'lower', r'unit\s+gap', r'disparity']
94
+ if any(re.search(p, context) for p in stat_patterns):
95
+ continue
96
+
97
+ if num not in numbers_in_findings:
98
+ invented.append({'number': num, 'context': context.strip()})
99
+
100
+ return {
101
+ 'invented': invented,
102
+ 'invention_count': len(invented),
103
+ 'pass': len(invented)<=5
104
+ }
105
+
106
+ def eval_3(charts):
107
+ violations=[]
108
+ seen_pairs=set()
109
+
110
+ for c in charts:
111
+ ct = c['chart_type']
112
+ x=tuple(c['x']) if isinstance(c['x'], list) else c['x']
113
+ y=tuple(c.get('y',[])) if isinstance(c.get('y'), list) else c.get('y')
114
+ pair=(x,y)
115
+
116
+ if ct=='stacked_bar' and c.get('color') == c.get('x'):
117
+ violations.append(f"stacked_bar: color==x on {c['x']}")
118
+ if ct=='pivot_heatmap' and not c.get('z'):
119
+ violations.append(f"pivot_heatmap missing z")
120
+ if ct in ['bar','line','scatter'] and not c.get('x'):
121
+ violations.append(f"{ct} missing x")
122
+ if pair in seen_pairs:
123
+ violations.append(f"Duplicate x/y pair: {pair}")
124
+ seen_pairs.add(pair)
125
+
126
+ return {
127
+ 'violations': violations,
128
+ 'violation_count': len(violations),
129
+ 'pass':len(violations)==0
130
+ }
131
+
132
+ def eval_4(report):
133
+ required_sections=[
134
+ '# Executive Summary',
135
+ '# Critical Issues',
136
+ '# Root Cause',
137
+ '# Recommended Actions',
138
+ '# Data Gaps']
139
+ import re
140
+ actions = re.findall(r'^#{1,3} .*action', report, re.MULTILINE | re.IGNORECASE)
141
+ return {
142
+ 'has_all_sections':all(s in report for s in required_sections),
143
+ 'missing_sections':[s for s in required_sections if s not in report],
144
+ 'action_count': len(actions),
145
+ 'actions_under_5':len(actions) <= 5,
146
+ 'length':True if len(report)>=1500 else False
147
+ }
148
+
149
+ def eval_5(causal_reasoning):
150
+ issues=[]
151
+ connections=causal_reasoning.get('causal_connections', [])
152
+
153
+ if len(connections)==0:
154
+ return {'pass': False, 'issues':['no causal_connections found']}
155
+
156
+ for c in connections:
157
+ label = c.get('issue','unknown')
158
+ if not c.get('internal_evidence'):
159
+ issues.append(f'{label}: missing internal_evidence')
160
+ if not c.get('causal_mechanism'):
161
+ issues.append(f'{label}: missing causal_mechanism')
162
+ if c.get('verdict') not in ('supported', 'contradicted', 'no_external_evidence'):
163
+ issues.append(f'{label}: invalid verdict value')
164
+ vd = c.get('variance_decomposition', {})
165
+ try:
166
+ nums = re.findall(r'(\d+)%', str(vd))
167
+ total = sum(int(n) for n in nums[:3])
168
+ if not (85 <= total <= 115):
169
+ issues.append(f'{label}: variance decomposition sums to {total},not ~100')
170
+ except:
171
+ issues.append(f'{label}: could not parse variance decomposition')
172
+
173
+ return {
174
+ 'issues': issues,
175
+ 'issue_count': len(issues),
176
+ 'connection_count': len(connections),
177
+ 'pass': len(issues) == 0
178
+ }
179
+
180
+ def eval_6(anonymized_df, original_df, key, mapping_log):
181
+ f=Fernet(key.encode())
182
+ issues=[]
183
+
184
+ for col in mapping_log.keys():
185
+ if col not in anonymized_df.columns:
186
+ issues.append(f"{col} missing from anonymized_df")
187
+ continue
188
+ original_vals = set(original_df[col].astype(str).unique())
189
+ anon_vals = set(anonymized_df[col].astype(str).unique())
190
+ leaked = original_vals & anon_vals
191
+ if leaked:
192
+ issues.append(f"{col} still has original values: {leaked}")
193
+ if not all(str(v).startswith('ID-') for v in anon_vals):
194
+ issues.append(f"{col} has non-anonymized values")
195
+
196
+ return {
197
+ 'issues':issues,
198
+ 'issue_count':len(issues),
199
+ 'pass':len(issues) == 0
200
+ }
201
+
202
+
203
+ def evaluation(state):
204
+ score={}
205
+ score['domain']=eval_1(json.loads(state['domain_info']))
206
+ score['report']=eval_4(state['report'])
207
+ score['charts']=eval_3(json.loads(state['charts']))
208
+ score['faithfulness']=eval_2(state['report'],json.loads(state['profiler']),json.loads(state['interpreted_findings']))
209
+ score['causal']=eval_5(json.loads(state['causal_reasoning']))
210
+ score['anonymisation']=eval_6(pd.read_json(state['anonymized_df']),pd.read_json(state['df']),state['key'],json.loads(state['mapping_log']))
211
+ print("====SCORECARD====")
212
+ total_score=0
213
+ max_score=0
214
+ for i,node in score.items():
215
+ bool_items ={k: v for k, v in node.items() if isinstance(v, bool)}
216
+ passed=sum(bool_items.values())
217
+ total=len(bool_items)
218
+ node_score=round((passed/total)*100) if total >0 else 0
219
+
220
+ total_score+=passed
221
+ max_score+=total
222
+ print(f'Node Score:{node_score}')
223
+ overall=round((total_score/max_score)*100) if max_score > 0 else 0
224
+ print(f"\n{'='*35}")
225
+ print(f"OVERALL SCORE: {overall}/100")
226
+ print(f"CHECKS PASSED: {total_score}/{max_score}")
227
+ print(f"{'='*35}")
228
+ score['overall'] = overall
229
+ return score
helper.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import colorsys
3
+ from IPython.display import IFrame
4
+ import plotly.io as pio
5
+ import re
6
+ import numpy as np
7
+ from cryptography.fernet import Fernet
8
+ import pandas as pd
9
+ import json
10
+ import time
11
+ from config import dashboard_palettes,RETRY_MAX_ATTEMPTS,RETRY_BACKOFF,RETRY_TIMEOUT
12
+ from functools import wraps
13
+
14
+ def retry(max_attempts=RETRY_MAX_ATTEMPTS,delay=RETRY_TIMEOUT,backoff=RETRY_BACKOFF,exceptions=(Exception,)):
15
+ def decorator(func):
16
+ @wraps(func)
17
+ def wrapper(*args,**kwargs):
18
+ wait=delay
19
+ for attempt in range(max_attempts):
20
+ try:
21
+ return func(*args,**kwargs)
22
+ except exceptions as e:
23
+ if attempt==max_attempts-1:
24
+ raise
25
+ print(f"[{func.__name__}] attempt {attempt+1} failed: {e}. Retrying in {wait}s...")
26
+ time.sleep(wait)
27
+ wait*=backoff
28
+ return wrapper
29
+ return decorator
30
+
31
+ def safe_parse(text):
32
+ def unescape(obj):
33
+ if isinstance(obj, str):
34
+ return obj.replace('\\n', '\n').replace('\\t', '\t')
35
+ elif isinstance(obj, dict):
36
+ return {k: unescape(v) for k, v in obj.items()}
37
+ elif isinstance(obj, list):
38
+ return [unescape(i) for i in obj]
39
+ return obj
40
+ text=re.sub(r'```(?:json)?\s*', '', text).strip()
41
+ try:
42
+ return unescape(json.loads(text))
43
+ except:
44
+ decoder = json.JSONDecoder()
45
+ text = text.strip()
46
+ for start in range(len(text)):
47
+ if text[start] in ('{', '['):
48
+ try:
49
+ obj, _ = decoder.raw_decode(text, start)
50
+ return unescape(obj)
51
+ except:
52
+ continue
53
+ raise ValueError("No valid JSON returned")
54
+
55
+ def sanitize_keys(obj, path=""):
56
+ if isinstance(obj, dict):
57
+ new_dict = {}
58
+ for k, v in obj.items():
59
+ if not isinstance(k, str):
60
+ print(f"🔴 FOUND NON-STRING KEY at {path}: {k!r} (type: {type(k).__name__})")
61
+ new_k = str(k)
62
+ else:
63
+ new_k = k
64
+ new_dict[new_k] = sanitize_keys(v, f"{path}.{new_k}")
65
+ return new_dict
66
+ elif isinstance(obj, (list, tuple)):
67
+ return [sanitize_keys(item, f"{path}[{i}]") for i, item in enumerate(obj)]
68
+ else:
69
+ return obj
70
+
71
+ def clean_report(report):
72
+ replacements = {
73
+ '\u202f': ' ',
74
+ '\u2011': '-',
75
+ '\u2013': '-',
76
+ '\u2014': '--',
77
+ '\u00a0': ' ',
78
+ }
79
+ for unicode_char, replacement in replacements.items():
80
+ report = report.replace(unicode_char, replacement)
81
+ return report
82
+
83
+ def random_template():
84
+ bootstrap_themes=["flatly","darkly","cyborg","cosmo","morph","quartz","vapor","lux","slate","solar"]
85
+ dp=random.choice(list(dashboard_palettes.values()))
86
+ bs=random.choice(bootstrap_themes)
87
+ return dp["plotly"], bs, dp
88
+
89
+ def derive_accents(base_hex, n=4):
90
+ h=int(base_hex.lstrip('#'), 16)
91
+ r,g,b=(h >> 16) / 255, ((h >> 8) & 0xff) / 255, (h & 0xff) / 255
92
+ hue,sat,val=colorsys.rgb_to_hsv(r, g, b)
93
+ return [
94
+ '#%02x%02x%02x' % tuple(int(c * 255) for c in colorsys.hsv_to_rgb((hue + i / n) % 1, sat, val))
95
+ for i in range(n)
96
+ ]
97
+
98
+ def clean_results(search_results):
99
+ extracted=[]
100
+ for result in search_results:
101
+ contents=[r['content'] for r in result['response']['results']]
102
+ extracted.append(
103
+ {
104
+ 'query':result['query'],
105
+ 'content':contents
106
+ }
107
+ )
108
+ return extracted
llm.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_cerebras import ChatCerebras
2
+ from langchain_groq import ChatGroq
3
+ from langchain_core.globals import set_llm_cache
4
+ from langchain_community.cache import SQLiteCache
5
+ from config import CEREBRAS_MODEL,GROQ_MODEL,CACHE_PATH,TEMPERATURE
6
+ import os
7
+
8
+ cerebras_llm=ChatCerebras(
9
+ model=CEREBRAS_MODEL,
10
+ cerebras_api_key=os.environ["CEREBRAS_API_KEY"],temperature=TEMPERATURE)
11
+
12
+ groq_llm=ChatGroq(
13
+ model=GROQ_MODEL,groq_api_key=os.environ["GROQ_API_KEY"],temperature=TEMPERATURE)
14
+
15
+ llm=cerebras_llm.with_fallbacks([groq_llm])
16
+ chat_llm=groq_llm.with_fallbacks([cerebras_llm])
17
+
18
+ set_llm_cache(SQLiteCache(database_path=CACHE_PATH))
report.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import markdown
3
+ import pdfkit
4
+ import json
5
+ from langsmith import traceable
6
+ from helper import retry,clean_report
7
+ from crypto import decryption
8
+
9
+ @traceable(name='report_maker')
10
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
11
+ def report_maker(llm, key, mapping_log, reasoning, internal_finding, domain, feedback=None):
12
+ decrypt_map = decryption(key, mapping_log)
13
+ domain_label=domain['Domain']
14
+ if domain_label=='other' and domain.get('Subdomain',"NIL"):
15
+ domain_label=f"other ({domain['Subdomain']})"
16
+
17
+ feedback_section=f"\nREVISION FEEDBACK: {feedback}\nAddress this feedback specifically in the revised report.\n" if feedback else ""
18
+
19
+ prompt=f"""You are a BI analyst writing a data quality report for C-suite executives.
20
+
21
+ INPUTS:
22
+ Domain Label:{domain_label}
23
+ Domain: {domain}
24
+ Internal Findings: {json.dumps(internal_finding,indent=2,default=str)}
25
+ Causal Reasoning: {json.dumps(reasoning, indent=2,default=str)}
26
+ {feedback_section}
27
+
28
+ MANDATORY: The Causal Reasoning input contains external_evidence fields with real research citations.
29
+ You MUST use these in the Root Cause Analysis section.
30
+ For each issue, cite the external_evidence exactly like this:
31
+ "[External Research: <source query> — <finding>]"
32
+ If you do not cite external evidence for at least 3 issues, your report FAILS quality gates.
33
+
34
+ RULES (apply to every sentence):
35
+ 1. Every claim cites an exact number from findings. No vague words (high/notable/significant/improve/analyze).
36
+ 2. Every percentage has a source tag: (industry benchmark) / (pilot data) / (estimate pending validation). Example: 82.37% → 65.90% (20% reduction) and 186.51 → 130.56 (30% reduction) is found in the report - these must be backed up with valid ecternal citations like
37
+ industry benchmarks or other target metrics found on external research. If a claim percentage has no source compulsorily omit the percentage claim instead say something like requires baseline period data to set target.
38
+ 3. Every action has: Owner, Timeline, Phases, Success Metric with current→target numbers.
39
+ 4. Root causes explain WHY via mechanism, not just WHAT. Link: Issue → Cause → Impact → Action → Outcome.
40
+ 5. Severity: HIGH=blocks analytics/breaks systems | MEDIUM=reduces accuracy | LOW=optimization.
41
+ 6. No invented numbers. Only use values present in findings.
42
+ 7. Be decisive: "Primary driver is X" not "may be caused by X".
43
+ 8. Word count: MINIMUM 1500 words. Count before submitting.
44
+ 9.When computing target from current→target, use: target = current × (1 - reduction%). Do not approximate.
45
+ 10.Integrate external research findings as natural prose, never as raw citation brackets.
46
+
47
+ OUTPUT — return ONLY this markdown structure, no preamble, no code blocks:
48
+
49
+ # Executive Summary
50
+ [250-300 words. Top 5 issues with exact evidence + 1 key action with owner/timeline/target.]
51
+
52
+ # Critical Issues
53
+ ## [Issue Name]
54
+ - Evidence: [exact metric, value, unit, n]
55
+ - Root Cause: [specific mechanism with supporting data]
56
+ - Impact: [quantified business consequence]
57
+ - Severity: [HIGH/MEDIUM/LOW—one sentence justification]
58
+
59
+ [Repeat for each issue]
60
+
61
+ # Root Cause Analysis
62
+ [Per issue: mechanism→evidence ruling out alternatives → business impact link]
63
+
64
+ # Recommended Actions
65
+ [Max 5 actions, ordered by priority]
66
+ **Action N: [Title]** (Owner: [Team], Timeline: [X days], Priority: HIGH/MEDIUM)
67
+ - Objective: [problem solved + current→target metric]
68
+ - Phase 1 ([X days]): [specific step]
69
+ - Phase 2 ([X days]): [specific step]
70
+ - Phase 3 ([X days]): [specific step]
71
+ - Success Metric: [exact number current→target + source]
72
+ - Resource Needs: [roles + hours]
73
+
74
+ # Data Gaps and Next Steps
75
+ - [Data needed] (Owner: [Team],[X days]):Impact—[what decision this unlocks]
76
+
77
+ EXAMPLES:
78
+
79
+ BAD(invented baseline not in findings):
80
+ "Success Metric: 10% improvement in model accuracy (from 80% to 88%)"
81
+ — WRONG because 80% accuracy appears nowhere in findings. 88% was invented by calculating 80 × 1.10 = 88, but 80 was never a real number.
82
+
83
+ GOOD(uses only numbers from findings, shows calculation):
84
+ "Success Metric: Reduce Amount outliers by 50% (from 4,076 to 2,038)"
85
+ — CORRECT. 4,076 comes from outlier_detection findings. Target = 4,076 × (1 - 0.50) = 2,038.
86
+
87
+ ANOTHER GOOD EXAMPLE:
88
+ "Success Metric: Reduce missing CustomerID from 24.93% to 12.47% (50% reduction)"
89
+ — CORRECT. 24.93% comes from profiler null_percent. Target = 24.93 × (1 - 0.50) = 12.465 ≈ 12.47%.
90
+
91
+ RULE:
92
+ - The starting number MUST exist verbatim in findings or profiler.
93
+ - The target MUST be calculated as: target = current × (1 - reduction%).
94
+ - Always show the calculation explicitly: "current × (1 - X%) = target"
95
+ - If the current baseline is unknown, write:
96
+ "Success Metric: Reduce [metric] to within acceptable threshold — baseline to be established in Phase 1."
97
+
98
+ ANTI-HALLUCINATION (CRITICAL):
99
+ - Impact quantification: ONLY use numbers present in findings. If no impact number exists, write "Impact: Unquantified — requires [specific data] to estimate" — DO NOT invent percentages.
100
+ - Root cause ruling-out: ONLY cite evidence present in findings. If no counter-evidence exists, omit the ruling-out section entirely.
101
+ - A z-score is NOT a count. Never use a z-score as a quantity to reduce. For outlier actions, success metric must be "% of transactions with |z-score| > 3".
102
+ - If a metric has no clear business interpretation (e.g. skewness of 186.5, negative % growth in InvoiceNo), flag it as "Requires domain validation before reporting to C-suite" — do not invent a business narrative.
103
+ - Do not cite statistics from external research unless they appear verbatim in the causal_reasoning input. Never invent study findings.
104
+ EXAMPLE:
105
+
106
+ ## 30 Days
107
+ ## 60 Days
108
+ ## 90 Days"""
109
+ max_attempts=3
110
+ for attempt in range(max_attempts):
111
+ report=llm.invoke(prompt).content
112
+ word_count = len(report.split())
113
+ print(f"Attempt {attempt+1}: {word_count} words")
114
+
115
+ if word_count>=1500:
116
+ break
117
+
118
+ if attempt<max_attempts-1:
119
+ print(f"Report too short ({word_count} words). Regenerating with expansion prompt...")
120
+ prompt+=f"\n\nPREVIOUS ATTEMPT WAS {word_count} WORDS — TOO SHORT. You must write at least 1500 words. Expand root cause analysis and action phases with more specific detail. Do not add filler — add substance."
121
+ else:
122
+ print(f"Warning: Could not reach 1500 words after {max_attempts} attempts. Using last generated report ({word_count} words).")
123
+
124
+ for display,real in decrypt_map.items():
125
+ report=report.replace(display,real)
126
+ return report
127
+
128
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
129
+ def report_generator(report,output_path="outputs/report.pdf"):
130
+ report=clean_report(report).replace('\\n', '\n')
131
+ html_body = markdown.markdown(report, extensions=["tables", "fenced_code", "nl2br"])
132
+ html = f"""
133
+ <!DOCTYPE html>
134
+ <html>
135
+ <head>
136
+ <meta charset="utf-8">
137
+ <style>
138
+ @page {{
139
+ size: A4;
140
+ margin: 2.5cm 2.2cm;
141
+ @bottom-right {{
142
+ content: "Page " counter(page) " of " counter(pages);
143
+ font-size: 9px; color: #888;
144
+ }}
145
+ }}
146
+ body {{ font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; line-height: 1.7; color: #1a1a2e; }}
147
+ h1 {{ font-size: 28px; font-weight: 700; color: #0f1117; margin: 0 0 10px; padding-bottom: 8px; border-bottom: 2px solid #4f46e5; }}
148
+ h2 {{ font-size: 20px; font-weight: 600; color: #1a1d27; margin: 28px 0 8px; border-left: 3px solid #4f46e5; padding-left: 10px; }}
149
+ h3 {{ font-size: 18px; font-weight: 600; color: #333; margin: 16px 0 6px; }}
150
+ p {{ margin: 0 0 13px; }}
151
+ ul, ol {{ margin: 0 0 10px 20px; }}
152
+ li {{ margin-bottom: 5px; }}
153
+ strong {{ color: #0f1117; }}
154
+ table {{ width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 13px; }}
155
+ th {{ background: #1a1d27; color: #fff; font-weight: 600; padding: 8px 12px; text-align: left; }}
156
+ td {{ padding: 7px 12px; border-bottom: 0.5px solid #e0e0e0; }}
157
+ tr:nth-child(even) td {{ background: #f9f9fb; }}
158
+ blockquote {{ border-left: 3px solid #4f46e5; margin: 12px 0; padding: 8px 14px; background: #f0f0ff; color: #444; font-style: italic; }}
159
+ hr {{ border: none; border-top: 1px solid #e0e0e0; margin: 20px 0; }}
160
+ </style>
161
+ </head>
162
+ <body>{html_body}</body>
163
+ </html>"""
164
+
165
+ options ={
166
+ 'page-size': 'A4',
167
+ 'margin-top': '2.5cm',
168
+ 'margin-right': '2.2cm',
169
+ 'margin-bottom': '2.5cm',
170
+ 'margin-left': '2.2cm',
171
+ 'encoding': "UTF-8",
172
+ 'no-outline': None,
173
+ 'enable-local-file-access': None
174
+ }
175
+ config = pdfkit.configuration(wkhtmltopdf='C:\\Program Files\\wkhtmltopdf\\bin\\wkhtmltopdf.exe')
176
+ pdfkit.from_string(html, output_path, options=options, configuration=config)
177
+ return output_path
state.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict
2
+
3
+ class AgentState(TypedDict):
4
+ df:object
5
+ filepath:str
6
+ key_cols:list
7
+ profiler:dict
8
+ domain_info:dict
9
+ raw_findings:dict
10
+ report:str
11
+ charts:list
12
+ search_queries:list
13
+ search_results:list
14
+ interpreted_findings:dict
15
+ causal_reasoning:dict
16
+ key:object
17
+ mapping_log:dict
18
+ anonymized_df:object
19
+ out:list
20
+ dashboard:str
21
+ report_saved:bool
22
+ report_approved:bool
23
+ report_feedback:str
24
+ dashboard_approved:bool
25
+ dashboard_feedback:str
26
+ error:str
27
+ waiting_for:str
stats.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from scipy import stats
3
+ import pandas as pd
4
+ from helper import sanitize_keys,safe_parse,retry
5
+ from langsmith import traceable
6
+ import json
7
+ from scipy import stats
8
+
9
+ def outlier_detection(df,key_cols):
10
+ findings={}
11
+ for col in key_cols:
12
+ if col in df.select_dtypes(include=np.number).columns:
13
+ if df[col].std()==0:
14
+ continue
15
+ z_score=abs(stats.zscore(df[col].dropna()))
16
+
17
+ findings[col]={
18
+ 'counts':int((z_score>3).sum()),
19
+ 'max_zscore':round(float(z_score.max()),3)
20
+ }
21
+ if findings[col]['counts']>0:
22
+ pass
23
+ else:
24
+ del findings[col]
25
+ return findings
26
+
27
+ def trend_detection(df,key_cols):
28
+ date_cols=[c for c in df.columns.to_list() if 'date' in c.lower()]
29
+ if not date_cols:
30
+ return {}
31
+ new_df=df.copy()
32
+ new_df[date_cols[0]]=pd.to_datetime(new_df[date_cols[0]])
33
+ new_df=new_df.sort_values(date_cols[0])
34
+
35
+ findings={}
36
+ for col in key_cols:
37
+ if col in new_df.select_dtypes(include=np.number).columns:
38
+ y=new_df[col].dropna().values
39
+ slope,_,r,_,_=stats.linregress(np.arange(len(y)),y)
40
+ epsilon=1e-5
41
+ if abs(slope)<=epsilon :
42
+ findings[col]={
43
+ 'trend':'NIL',
44
+ 'value':round(float(slope),3),
45
+ 'Correlation':round(float((r)**2),3)
46
+ }
47
+ elif slope<0 :
48
+ findings[col]={
49
+ 'trend':'Negative',
50
+ 'value':round(float(slope),3),
51
+ 'Correlation':round(float((r)**2),3)
52
+ }
53
+ elif slope>0 :
54
+ findings[col]={
55
+ 'trend':'Positive',
56
+ 'value':round(float(slope),3),
57
+ 'Correlation':round(float((r)**2),3)
58
+ }
59
+ return findings
60
+
61
+ def correlation_analysis(df):
62
+ pos=[]
63
+ corr=df[df.select_dtypes(include=np.number).columns].corr()
64
+ for i in range(len(corr.columns)):
65
+ for j in range(i+1,len(corr.columns)):
66
+ value=corr.iloc[i,j]
67
+ if abs(value)>=0.5:
68
+ pos.append(
69
+ {
70
+ 'col1':corr.columns[i],
71
+ 'col2':corr.columns[j],
72
+ 'corr':round(float(value),4),
73
+ 'degree':'strong' if abs(value)>=0.7 else 'moderate',
74
+ 'warning':'both columns might mean the same' if abs(value)>0.95 else None
75
+ }
76
+ )
77
+ return pos
78
+
79
+ def performer_rank(df,key_cols,domain_info):
80
+ performer={}
81
+ for cat_col in df.select_dtypes(include='object').columns:
82
+ for metric in key_cols:
83
+ if not metric in df.columns:
84
+ continue
85
+ if not np.issubdtype(df[metric].dtype, np.number):
86
+ continue
87
+ try:
88
+ grouped=df.groupby(cat_col)[metric].agg(
89
+ ['mean','std','count'])
90
+ direction=domain_info['Metric_Direction'].get(metric,"lower_is_better")
91
+ if direction=="lower_is_better":
92
+ worst=grouped['mean'].idxmax()
93
+ best=grouped['mean'].idxmin()
94
+ else:
95
+ worst=grouped['mean'].idxmin()
96
+ best=grouped['mean'].idxmax()
97
+ performer[f"{cat_col}_{metric}"] = {
98
+ 'worst':str(worst),
99
+ 'worst_value': round(
100
+ grouped.loc[str(worst), 'mean'], 4
101
+ ),
102
+ 'best':str(best),
103
+ 'best_value':round(
104
+ grouped.loc[str(best),'mean'], 4
105
+ ),
106
+ 'gap':round(
107
+ grouped['mean'].max()-grouped['mean'].min(),4)
108
+ }
109
+ except Exception as e:
110
+ print(f"Skipping {cat_col}_{metric}: {e}")
111
+ continue
112
+ return performer
113
+
114
+
115
+ def percentage_growth(df,key_cols):
116
+ growth={}
117
+ date_cols=[c for c in df.columns.to_list() if 'date' in c.lower()]
118
+ if not date_cols:
119
+ return {}
120
+ new_df=df.copy()
121
+ new_df[date_cols[0]]=pd.to_datetime(new_df[date_cols[0]])
122
+ new_df=new_df.sort_values(date_cols[0])
123
+ for cat_col in new_df.select_dtypes(include='object').columns:
124
+ for metric in key_cols:
125
+ if not pd.api.types.is_numeric_dtype(new_df[metric]):
126
+ continue
127
+ if metric in new_df.columns:
128
+ grouped=new_df.groupby(cat_col)[metric].mean()
129
+ growth[f'{cat_col}-{metric}']={
130
+ 'percent_growth':round(((grouped.iloc[-1]-grouped.iloc[0])/grouped.iloc[0]*100 if grouped.iloc[0]!=0 else 0),2)}
131
+ return growth
132
+
133
+ def segmentation_analysis(df,key_cols):
134
+ findings={}
135
+ cat_cols=df.select_dtypes(include='object').columns
136
+ numeric_cols = df.select_dtypes(include=np.number).columns.tolist()
137
+
138
+ for cat_col in cat_cols:
139
+ findings[cat_col]={}
140
+ for metric in key_cols:
141
+ if metric in df.columns:
142
+ if np.issubdtype(df[metric].dtype, np.number):
143
+ grouped=df.groupby(cat_col)[metric].mean()
144
+
145
+ findings[cat_col][metric]={
146
+ 'best':str(grouped.idxmax()),
147
+ 'worst':str(grouped.idxmin())
148
+ }
149
+ return findings
150
+
151
+ def distribution_analysis(df,key_cols):
152
+ num_data=df[df.select_dtypes(include=np.number).columns]
153
+ findings={}
154
+ for col in num_data.columns:
155
+ if col in key_cols:
156
+ data=num_data[col].dropna()
157
+ sample=data.sample(min(len(data),5000),random_state=42)
158
+ _,pvalue=stats.shapiro(sample)
159
+ skew=float(data.skew())
160
+ kurt=float(data.kurt())
161
+ if pvalue>0.05:
162
+ label='normal'
163
+ elif kurt > 3:
164
+ label='heavy_tailed'
165
+ elif skew>1:
166
+ label='right_skewed'
167
+ elif abs(skew)<0.5:
168
+ label='symmetric'
169
+ elif skew<-1:
170
+ label = 'left_skewed'
171
+ elif skew>0:
172
+ label='slightly_right_skewed'
173
+ else:
174
+ label='slightly_left_skewed'
175
+ findings[col]={
176
+ 'skew':skew,
177
+ 'distribution':label,
178
+ 'p_value':round(float(pvalue),2),
179
+ 'n':len(data)
180
+ }
181
+ return findings
182
+
183
+ def concentration_analysis(df,key_cols):
184
+ findings={}
185
+ cat_cols=df.select_dtypes(include='object').columns
186
+
187
+ for cat_col in cat_cols:
188
+ for metric in key_cols:
189
+ if metric not in df.columns:
190
+ continue
191
+ if not np.issubdtype(df[metric].dtype, np.number):
192
+ continue
193
+
194
+ grouped=df.groupby(cat_col)[metric].sum()
195
+ total=grouped.sum()
196
+ if total==0:
197
+ continue
198
+
199
+ top_contributor=grouped.idxmax()
200
+ top_pct=round(float(grouped.max()/total*100),2)
201
+
202
+ findings[f"{cat_col}_{metric}"]={
203
+ 'top_contributor':str(top_contributor),
204
+ 'contribution_pct':top_pct,
205
+ 'is_concentrated':top_pct>50}
206
+
207
+ return findings
208
+
209
+ def pareto_summary(concentration_findings):
210
+ critical={
211
+ k:v for k, v in concentration_findings.items()
212
+ if v['contribution_pct']>80}
213
+ return critical
214
+
215
+ def data_analysis(df,key_cols,domain_info):
216
+ findings={}
217
+ findings['outlier_detection']=outlier_detection(df,key_cols)
218
+ findings['segmentation_analysis']=segmentation_analysis(df,key_cols)
219
+ findings['percentage_growth']=percentage_growth(df,key_cols)
220
+ #findings['performer_rank']=performer_rank(df,key_cols,domain_info)
221
+ findings['correlation_analysis']=correlation_analysis(df)
222
+ findings['trend_detection']=trend_detection(df,key_cols)
223
+ findings['concentration_analysis']=concentration_analysis(df,key_cols)
224
+ findings['distribution_analysis']=distribution_analysis(df,key_cols)
225
+ findings['pareto_summary']=pareto_summary(findings['concentration_analysis'])
226
+ findings=sanitize_keys(findings)
227
+ return findings
228
+
229
+ @traceable(name='data_analyst')
230
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
231
+ def data_analyst(df,llm,key_cols,domain_info,profiler):
232
+ slim={
233
+ 'shape': profiler['shape'],
234
+ 'columns': profiler['columns'],
235
+ 'null_percent': profiler['null_percent'],
236
+ 'likely_date_columns': profiler['likely_date_columns'],
237
+ 'likely_categorical_columns': profiler['likely_categorical_columns'],
238
+ 'likely_id_columns': profiler['likely_id_columns'],
239
+ 'sample_rows': profiler['sample_rows'],
240
+ }
241
+ finds=data_analysis(df,key_cols,domain_info)
242
+ prompt=f"""You are a senior data analyst at a large enterprise company.
243
+ You are given two inputs:
244
+ 1. A dataset profile (schema, stats, nulls, etc.)
245
+ 2. Pre-computed statistical findings from analysis functions
246
+
247
+ Your job is to INTERPRET these findings in clear business language for a non-technical manager.
248
+
249
+ ═══════════════════════════════
250
+ STRICT RULES — READ BEFORE ANYTHING ELSE
251
+ ═══════════════════════════════
252
+ - ONLY reference numbers that exist in the findings JSON below.
253
+ Do NOT invent, estimate, round, or extrapolate any value.
254
+ - If a field has no supporting data, output null. Never fill gaps with guesses.
255
+ - Do NOT use: "approximately", "likely around", "seems to suggest", "may indicate"
256
+ unless you are directly quoting a finding.
257
+ - Do NOT reference columns, metrics, or entities not present in the inputs.
258
+ - Your job is interpretation, NOT recalculation. Trust every number as-is.
259
+
260
+ ═══════════════════════════════
261
+ INPUTS
262
+ ═══════════════════════════════
263
+ Domain: {domain_info['Domain']}
264
+ Key Metrics: {json.dumps(key_cols, default=str)}
265
+ Dataset Profile: {json.dumps(slim, default=str)}
266
+ Statistical Findings: {json.dumps(finds, default=str)}
267
+
268
+ ═══════════════════════════════
269
+ OUTPUT — strict JSON, exact keys below
270
+ ═══════════════════════════════
271
+ {{
272
+ "executive_summary": "3-4 sentences. State the domain, the 2-3 biggest issues with exact numbers from findings, and one immediate action. No vague language.",
273
+
274
+ "critical_issues": [
275
+ {{
276
+ "issue": "Short title",
277
+ "evidence": "Exact value from findings — e.g. z-score of X in column Y",
278
+ "cause": "Specific mechanism, not a generic statement",
279
+ "severity": "High | Medium | Low",
280
+ "potential_harm": "Concrete business consequence"
281
+ }}
282
+ // exactly 5 issues, ranked High to Low
283
+ ],
284
+
285
+ "root_cause_hypotheses": {{
286
+ "issue_title": "One specific hypothesis per issue grounded in the data"
287
+ // one key per critical issue above
288
+ }},
289
+
290
+ "recommended_actions": [
291
+ {{
292
+ "action": "Specific action title",
293
+ "target_issue": "Which critical issue this addresses",
294
+ "steps": ["Step 1", "Step 2"],
295
+ "success_metric": "Measurable outcome with a number"
296
+ }}
297
+ // 1-2 actions per issue max
298
+ ],
299
+
300
+ "investigation_focus": [
301
+ "Specific question for external research — must name a column or metric from findings",
302
+ "Specific question 2",
303
+ "Specific question 3"
304
+ ]
305
+ }}
306
+ "cause": "Name the specific system/process failure that produced this — format: '[System/Process] failed to [action] because [reason]'. NOT a restatement of the issue.",
307
+
308
+ "root_cause_hypotheses": {{
309
+ "<issue_title>": "One level deeper than cause. Format: 'If [specific condition] exists in the [system/process], it would produce [observed effect] because [mechanism]'"
310
+ }}
311
+ "investigation_focus": [
312
+ "Must reference a specific column name AND a specific value from findings.",
313
+ "Must be a causal question, not a descriptive one.",
314
+ "❌ 'What causes missing data?' ✅ 'Why do 24.93% of CustomerID values go uncaptured in retail POS transactions under £10?'"
315
+ ]
316
+
317
+ Return only valid JSON. No markdown, no backticks, no explanation outside the JSON.
318
+ """
319
+ response=llm.invoke(prompt)
320
+ return safe_parse(response.content)
tools.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import ast
3
+ import json
4
+ import os
5
+ from tavily import TavilyClient
6
+ from langsmith import traceable
7
+ from helper import retry, safe_parse
8
+
9
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
10
+ def run_profiler(filepath):
11
+ encodings=['utf-8', 'latin-1', 'iso-8859-1', 'cp1252']
12
+ df=None
13
+ for encoding in encodings:
14
+ try:
15
+ df=pd.read_csv(filepath, encoding=encoding)
16
+ print(f"Successfully read with encoding:{encoding}")
17
+ break
18
+ except UnicodeDecodeError:
19
+ continue
20
+ if df is None:
21
+ raise ValueError("Could not read file with any encoding")
22
+ likely_ids=[]
23
+ likely_dates=[]
24
+ likely_categorical=[]
25
+ likely_numeric=[]
26
+
27
+ for col in list(df.columns):
28
+ if df[col].dtype==object:
29
+ converted=pd.to_datetime(df[col],errors="coerce",infer_datetime_format=True)
30
+ if converted.notna().mean()>0.6:
31
+ likely_dates.append(col)
32
+ continue
33
+ numeric_converted = pd.to_numeric(df[col], errors="coerce")
34
+ success_rate = numeric_converted.notna().sum() / len(df)
35
+ if success_rate > 0.8:
36
+ likely_numeric.append(col)
37
+ continue
38
+ unique=df[col].nunique()
39
+ if unique>=0.9*len(df):
40
+ likely_ids.append(col)
41
+ continue
42
+ if unique < 100:
43
+ likely_categorical.append(col)
44
+ numeric_converted=pd.to_numeric(df[col], errors="coerce")
45
+ elif pd.api.types.is_numeric_dtype(df[col]):
46
+ likely_numeric.append(col)
47
+
48
+ profiler={
49
+ 'shape':df.shape,
50
+ 'dtypes':df.dtypes.astype(str).to_dict(),
51
+ 'columns':df.columns.tolist(),
52
+ 'null_counts':df.isnull().sum().to_dict(),
53
+ "null_percent":(df.isnull().mean() * 100).round(2).to_dict(),
54
+ "numeric_stats":{col: {str(k): v for k, v in stats.items()} for col, stats in df.describe().to_dict().items()},
55
+ "cardinality":df.nunique().to_dict(),
56
+ "duplicates":int(df.duplicated().sum()),
57
+ "skewness":{k: round(v, 3) for k, v in df.skew(numeric_only=True).items()},
58
+ "kurtosis":{k: round(v, 3) for k, v in df.kurt(numeric_only=True).items()},
59
+ "likely_date_columns":likely_dates,
60
+ "likely_categorical_columns": likely_categorical,
61
+ "likely_numeric_columns":likely_numeric,
62
+ "likely_id_columns":likely_ids,
63
+ "sample_rows":df.head(1).to_dict()
64
+ }
65
+ for col in df.columns:
66
+ if df[col].dtype=='object':
67
+ df[col]=df[col].fillna('Unknown')
68
+ elif pd.api.types.is_numeric_dtype(df[col]):
69
+ if abs(df[col].skew())<=1:
70
+ df[col]=df[col].fillna(df[col].mean())
71
+ else:
72
+ df[col]=df[col].fillna(df[col].median())
73
+ return df,profiler
74
+
75
+ @traceable(name='domain_analyser')
76
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
77
+ def domain_analyser(llm,profile):
78
+ dtypes=profile['dtypes']
79
+ all_cols=profile['columns']
80
+
81
+ imp_cols=[c for c in all_cols if dtypes.get(c) in ('int64','float64','int32','float32') and not any(x in c.lower() for x in ['key','code','num','index','date','time','year','month'])]
82
+ imp_cols=imp_cols[:30] if len(imp_cols)>30 else imp_cols
83
+ prompt=f"""You are a senior business data analyst. Analyze the CSV profile and return a single valid JSON object.
84
+
85
+ RULES:
86
+ - You must never leave the Key_Metrics_to_watch output empty.
87
+ - For finding Key_Metrics_to_watch,column names given to you should only be taken, do not take any other column names not in the profile.
88
+ - Return ONLY valid JSON. No markdown, no backticks, no explanation.
89
+ - Use exact key names as specified below.
90
+ - If columns are named V1, V2, ..., Vn alongside 'Amount' and 'Class' or 'Time', this is likely finance_transactions (PCA-transformed fraud detection data)
91
+ - Do not add any keys not listed below.
92
+
93
+ OUTPUT SCHEMA:
94
+ {{
95
+ "Domain": "<one of: retail_sales | manufacturing_quality | manufacturing_production | manufacturing_maintenance | hr_attrition | hr_performance | finance_transactions | logistics | other>",
96
+ "Subdomain": "<if Domain is 'other', give a 2-3 word label like 'clinical_outcomes' | 'student_performance' | 'crime_statistics' | 'energy_consumption' | 'environmental_monitoring' | 'sports_analytics' | 'other_unknown' etc.>",
97
+ "Confidence_Score": "<one of: High | Medium | Low>",
98
+ "Key_Metrics_to_watch": ["<col1>", "<col2>", "..."],
99
+ "Metric_Direction": {{
100
+ "<col1>": "<higher_is_better | lower_is_better>",
101
+ "<col2>": "<higher_is_better | lower_is_better>"
102
+ }},
103
+ "Reason": "<exactly 2 sentences explaining your domain choice>"
104
+ }}
105
+
106
+ CONSTRAINTS:
107
+ - Key_Metrics_to_watch: only include numeric columns that are meaningful business metrics. Exclude ID columns, date columns, and free-text fields.
108
+ - Metric_Direction: must have one entry for every column in Key_Metrics_to_watch, no more, no less.
109
+
110
+ COLUMN NAMES: {imp_cols}
111
+
112
+ underscores not spaces. No variation allowed.
113
+ """
114
+ response=llm.invoke(prompt)
115
+ response=safe_parse(response.content)
116
+ key_cols=response['Key_Metrics_to_watch']
117
+ return response,key_cols
118
+
119
+ def critical_info(llm,profiler):
120
+ columns=profiler['columns']
121
+ prompt=f"""You are a data privacy analyst.
122
+
123
+ You are given a list of column names from a business dataset: {columns}
124
+
125
+ Your task: identify which columns contain Personally Identifiable Information (PII)
126
+ that must be anonymized before sending data to an external system.
127
+
128
+ ═══════════════════════════════
129
+ FLAG THESE (PII):
130
+ ═══════════════════════════════
131
+ - Person names (employee, operator, customer)
132
+ - Employee IDs or operator IDs
133
+ - Email addresses, phone numbers
134
+ - National IDs, passport numbers
135
+ - Batch or reference codes directly traceable to a specific individual
136
+
137
+ ═══════════════════════════════
138
+ DO NOT FLAG THESE (not PII):
139
+ ═══════════════════════════════
140
+ - Machine IDs, equipment codes, production line numbers
141
+ - Temperatures, pressures, or any sensor/operational metrics
142
+ - Timestamps, dates, shift codes
143
+ - Product names, SKUs, categories
144
+ - Numeric aggregates (counts, averages, totals)
145
+
146
+ ═══════════════════════════════
147
+ OUTPUT
148
+ ═══════════════════════════════
149
+ A valid Python list of column names that are PII. Nothing else.
150
+ If no PII columns exist, return an empty list: []
151
+
152
+ Example: ['EmployeeName', 'OperatorID', 'CustomerEmail']
153
+ """
154
+ response=llm.invoke(prompt)
155
+ try:
156
+ return ast.literal_eval(response.content.strip())
157
+ except:
158
+ return []
159
+
160
+ @traceable(name='search_queries')
161
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
162
+ def search_queries(llm,domain,internal_findings):
163
+ focus=internal_findings['investigation_focus']
164
+ issues=internal_findings['critical_issues']
165
+ issue_details = []
166
+ for i, issue in enumerate(issues):
167
+ issue_details.append(f"""
168
+ - Issue {i+1}: {issue.get('issue', 'Unknown')}
169
+ - Severity: {issue.get('severity', 'Unknown')}
170
+ - Evidence: {issue.get('evidence', 'Unknown')}
171
+ - Why it matters: {issue.get('potential_harm', 'Unknown')}
172
+ """)
173
+ prompt=f"""You are a business research analyst tasked with generating highly specific web search queries.
174
+ CONTEXT:
175
+ Your goal is to find ROOT CAUSES and PRACTICAL EXPLANATIONS for these specific data quality issues:
176
+ -DOMAIN: {domain['Domain']}
177
+
178
+ CRITICAL ISSUES TO RESEARCH:
179
+ {''.join(issue_details)}
180
+
181
+ INVESTIGATION FOCUS AREAS:
182
+ {json.dumps(focus[:3], indent=2)}
183
+
184
+ GOOD EXAMPLES FOR YOUR ISSUES:
185
+ For Outliers in Quantity:
186
+ "Why do promotional bulk orders create extreme outliers e-commerce inventory"
187
+ "Detecting real bulk orders vs data entry errors retail systems"
188
+ "Supply chain disruptions causing quantity outliers 2024"
189
+
190
+ OUTPUT REQUIREMENTS:
191
+ 1. Each query MUST be a single line, enclosed in double quotes
192
+ 2. NO bullet points, NO markdown, NO explanations
193
+ 3. Return ONLY the queries, one per line
194
+
195
+ QUERY DESIGN RULES (MANDATORY):
196
+ 1. SPECIFIC ROOT CAUSES, not symptoms
197
+ ❌ BAD: "retail sales issues"
198
+ ✅ GOOD: "Why do bulk orders cause high quantity outliers e-commerce"
199
+
200
+ 2. Include mechanism/process, not just the problem
201
+ ❌ BAD: "missing customer IDs"
202
+ ✅ GOOD: "Guest checkout flows that bypass customer ID capture"
203
+
204
+ 3. Target REAL-WORLD IMPLEMENTATIONS, not academic theory
205
+ ❌ BAD: "customer data collection best practices"
206
+ ✅ GOOD: "Shopify/WooCommerce guest checkout missing customer field 2024"
207
+
208
+ 4. Include INDUSTRY CONTEXT (retail, e-commerce, data quality, etc.)
209
+ ❌ BAD: "Why do systems fail"
210
+ ✅ GOOD: "Root cause of duplicate invoice numbers in retail POS systems"
211
+
212
+ 5. Include YEAR or RECENCY if applicable
213
+ ❌ BAD: "data entry errors"
214
+ ✅ GOOD: "Data validation failures retail 2024 industry case studies"
215
+
216
+ ANTI-PATTERNS (Avoid these):
217
+ - Generic statistics ("retail trends", "market analysis")
218
+ - Textbook definitions ("what is data quality")
219
+ - Generic best practices ("how to prevent errors")
220
+ - Vague terminology ("unusual patterns", "data issues")
221
+
222
+ GOOD EXAMPLES FOR YOUR ISSUES:
223
+ For Outliers in Quantity:
224
+ "Why do promotional bulk orders create extreme outliers e-commerce inventory"
225
+ "Detecting real bulk orders vs data entry errors retail systems"
226
+ "Supply chain disruptions causing quantity outliers 2024"
227
+
228
+ For Missing CustomerID:
229
+ "Why guest checkout abandons customer ID field collection e-commerce"
230
+ "Shopify/WooCommerce missing customer data rates implementation"
231
+ "How to enforce customer ID capture checkout validation rules"
232
+
233
+ Strictly output only a valid JSON array like this:
234
+ [
235
+ "Query 1 here",
236
+ "Query 2 here",
237
+ "Query 3 here",
238
+ "Query 4 here",
239
+ "Query 5 here",
240
+ "Query 6 here"
241
+ ]
242
+ "Return ONE single flat JSON array containing ALL queries together. Do NOT group by issue. Do NOT return multiple arrays."
243
+ No other text. No markdown. No backticks."""
244
+
245
+ response=llm.invoke(prompt)
246
+ try:
247
+ content=response.content
248
+ return safe_parse(content)
249
+ except Exception as e:
250
+ print(f"Error: {e}")
251
+ return []
252
+
253
+ def clean_results(search_results):
254
+ extracted=[]
255
+ for result in search_results:
256
+ contents=[r['content'] for r in result['response']['results']]
257
+ extracted.append(
258
+ {
259
+ 'query':result['query'],
260
+ 'content':contents
261
+ }
262
+ )
263
+ return extracted
264
+
265
+ @traceable(name='web_search')
266
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
267
+ def web_search(queries):
268
+ results=[]
269
+ search_client=TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
270
+ for query in queries:
271
+ result=search_client.search(query=query.strip(),max_results=3,search_depth="basic")
272
+ results.append({'query':query,'response':result})
273
+ return clean_results(results)
274
+
275
+ @traceable(name='causal_reasoning')
276
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
277
+ def causal_reasoning(llm,domain_info,interpreted_findings,cleaned_results):
278
+ internal={
279
+ 'critical_issues':interpreted_findings['critical_issues'],
280
+ 'root_cause_hypotheses':interpreted_findings['root_cause_hypotheses'],
281
+ 'executive_summary':interpreted_findings['executive_summary']
282
+ }
283
+ external_block=json.dumps(cleaned_results, indent=2) if cleaned_results else "NO EXTERNAL RESEARCH AVAILABLE"
284
+ prompt=f"""You are a causal inference specialist. Evaluate whether the available evidence supports,
285
+ partially supports,contradicts or is insufficient to infer causality. Never state a causal relationship as fact unless both
286
+ internal evidence and external evidence support it.
287
+ INPUTS:
288
+ Domain: {domain_info}
289
+ Internal Findings: {json.dumps(internal, indent=2, default=str)}
290
+ External Research: {external_block}
291
+
292
+ FOR EACH critical issue, complete all 4 gates:
293
+
294
+ GATE 1 — FACT: State exact metric + value + unit + % of total. No vague words (high/notable/significant banned).
295
+
296
+ GATE 2 — MECHANISM: [External Factor] → [Specific Process] → [Internal Effect].
297
+ If the intermediate process is unknown,
298
+ describe the uncertainty explicitly and reduce confidence accordingly.
299
+ Do not invent missing process steps.
300
+
301
+ GATE 3 — VARIANCE: Only assign numerical percentages if the internal findings,
302
+ external evidence, or domain knowledge explicitly supports them.
303
+ Otherwise use:
304
+ "primary":"Calibration drift — dominant contributor (percentage unknown)"
305
+ "secondary":"Operator delay — secondary contributor (percentage unknown)"
306
+ "unexplained":"Requires maintenance logs"
307
+ Never invent percentages.
308
+
309
+ GATE 4 — FALSIFIABILITY: One specific data point that confirms. One that refutes.
310
+
311
+ DOMAIN CAPS (automatic confidence limits):
312
+ - Geographic concentration >70%: MEDIUM cap unless regional expansion data present
313
+ - Missing data >20%: MEDIUM cap until source audited
314
+ - Outliers present: MEDIUM cap without transaction logs
315
+
316
+ CITATION RULE: For external_evidence, you MUST copy a verbatim phrase from the research above and name the query it came from.
317
+ Format: "Source query: '<query>' | Finding: '<copied phrase>'"
318
+ If external_block is empty or NO EXTERNAL RESEARCH AVAILABLE, set external_evidence=null and verdict=no_external_evidence for all issues.
319
+
320
+ RULES:
321
+ - Every percentage has a source tag: (industry benchmark) / (pilot data) / (estimate pending validation). Example: 82.37% → 65.90% (20% reduction) and 186.51 → 130.56 (30% reduction) is found in the report - these must be backed up with valid ecternal citations like
322
+ industry benchmarks or other target metrics found on external research. If a claim percentage has no source compulsorily omit the percentage claim instead say something like requires baseline period data to set target.
323
+ - external_evidence must quote a specific finding from research above, or set null
324
+ - All JSON string values single-line (no literal newlines inside strings)
325
+ -If internal evidence and external research disagree preserve both,explain why. Do NOT average them and set verdict="contradicted".
326
+ - If external research is unavailable, set verdict="no_external_evidence" and keep all fields except verdict null
327
+
328
+ OUTPUT:
329
+ {{
330
+ "causal_connections": [
331
+ {{
332
+ "issue": "<label + key metric>",
333
+ "internal_evidence": "<exact metric, value, unit, n>",
334
+ "external_evidence":{{"source_query":"","quoted_finding":"","relevance":"Explain in one sentence how this research relates to the internal issue."}},
335
+ "causal_mechanism": "<[Factor] → [Process] → [Effect] in [timeframe]>",
336
+ "variance_decomposition": {{
337
+ "primary": "<cause: X%> — <one-line justification>",
338
+ "secondary": "<cause: Y%> — <justification>",
339
+ "unexplained": "<Z%> — <what data resolves this>"
340
+ }},
341
+ "confidence": "<HIGH|MEDIUM|LOW>: <one sentence citing variance band>",
342
+ "confirmation_test": "<specific data point>",
343
+ "refutation_test": "<specific data point>",
344
+ "verdict": "<supported|contradicted|no_external_evidence>"
345
+ }}
346
+ ],
347
+ "overall_diagnosis": "<3 sentences max: dominant causal story, confidence levels, highest uncertainty link>",
348
+ "meta_assessment": {{
349
+ "binding_constraint": "<single data gap most limiting analysis>",
350
+ "validation_sequence": ["<step 1>", "<step 2>", "<step 3>"]
351
+ }},
352
+ "evidence_strength": {{
353
+ "internal": "<Strong/Moderate/Weak>: <one sentence justification>",
354
+ "external": "<Strong/Moderate/Weak>: <one sentence justification>",
355
+ "overall": "<Strong/Moderate/Weak>: <one sentence justification>"
356
+ }}
357
+ }}"""
358
+ response=llm.invoke(prompt)
359
+ return safe_parse(response.content)
360
+
visuals.py ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import plotly.express as px
2
+ import plotly.io as pio
3
+ import plotly.graph_objects as go
4
+ import pandas as pd
5
+ import plotly.offline as pyo
6
+ from IPython.display import display
7
+ import json
8
+ import numpy as np
9
+ from crypto import decryption
10
+ from helper import safe_parse,derive_accents,random_template,retry
11
+ from langsmith import traceable
12
+ pyo.init_notebook_mode(connected=True)
13
+
14
+ def render_charts(inputs,df):
15
+ figs=[]
16
+ inputs=validate_charts(inputs,df)
17
+ for c in inputs:
18
+ try:
19
+ chart_type=c['chart_type']
20
+ x=c.get('x')
21
+ y=c.get('y')
22
+ z=c.get('z')
23
+ color=c.get('color')
24
+ priority=c.get('priority', 1)
25
+
26
+ if chart_type=='stacked_bar':
27
+ if x not in df.columns or y not in df.columns or color not in df.columns:
28
+ print(f"Skipping stacked_bar: missing columns {x}, {y}, {color}")
29
+ continue
30
+ grouped=df.groupby([x,color])[y].sum().reset_index()
31
+ if grouped.empty:
32
+ print(f"Skipping stacked_bar: no data after grouping")
33
+ continue
34
+ fig1=px.bar(grouped,x=x,y=y,color=color,barmode='stack',title=f'{x} by {y} and {color}',template='plotly_white')
35
+ figs.append(fig1)
36
+
37
+ elif chart_type=='bar':
38
+ if x not in df.columns or y not in df.columns:
39
+ print(f"Skipping bar: missing columns {x}, {y}")
40
+ continue
41
+ grouped=df.groupby(x)[y].sum().reset_index()
42
+ if grouped.empty:
43
+ print(f"Skipping bar: no data after grouping")
44
+ continue
45
+ fig2=px.bar(grouped,x=x,y=y,title=f'{x} vs {y}',template='plotly_white')
46
+ figs.append(fig2)
47
+
48
+ elif chart_type=='histogram':
49
+ col=x if x else y
50
+ if col not in df.columns:
51
+ print(f"Skipping histogram: column {col} not found")
52
+ continue
53
+ fig3=px.histogram(df,x=col,nbins=20,marginal='box',title=f'Distribution of {col}',template='plotly_white')
54
+ figs.append(fig3)
55
+
56
+ elif chart_type=='treemap':
57
+ if x not in df.columns or y not in df.columns:
58
+ print(f"Skipping treemap: missing columns {x}, {y}")
59
+ continue
60
+ grouped=df.groupby(x)[y].sum().reset_index()
61
+ grouped=grouped[grouped[y] > 0]
62
+ if grouped.empty:
63
+ print(f"Skipping treemap: no positive data after grouping")
64
+ continue
65
+ fig4=px.treemap(grouped,path=[px.Constant('All'),x],values=y,title=f'{x} vs {y}',color=y,color_continuous_scale='RdYlGn_r',template='plotly_white')
66
+ figs.append(fig4)
67
+
68
+ elif chart_type=='pareto':
69
+ if x not in df.columns or y not in df.columns:
70
+ print(f"Skipping pareto: missing columns {x}, {y}")
71
+ continue
72
+ grouped =df.groupby(x)[y].mean().reset_index()
73
+ grouped =grouped.sort_values(y, ascending=False)
74
+ if grouped.empty:
75
+ print(f"Skipping pareto: no data after grouping")
76
+ continue
77
+ grouped['cumulative_pct']=(
78
+ grouped[y].cumsum()/grouped[y].sum()*100
79
+ )
80
+
81
+ fig5= go.Figure()
82
+ fig5.add_trace(go.Bar(x=grouped[x],y=grouped[y],name=y))
83
+ fig5.add_trace(go.Scatter(
84
+ x=grouped[x],
85
+ y=grouped['cumulative_pct'],
86
+ name='cumulative %',
87
+ yaxis='y2',
88
+ mode='lines+markers'
89
+ ))
90
+ fig5.update_layout(
91
+ yaxis2=dict(overlaying='y',side='right',range=[0,100]))
92
+ figs.append(fig5)
93
+
94
+ elif chart_type=='correlation_heatmap':
95
+ numeric_df = df.select_dtypes(include='number')
96
+ if numeric_df.shape[1] < 2:
97
+ print(f"Skipping correlation_heatmap: not enough numeric columns")
98
+ continue
99
+ corr = numeric_df.corr()
100
+ fig6=px.imshow(corr,color_continuous_scale='RdBu_r',zmin=-1,zmax=1,title=f'Correlation Heatmap',text_auto=True,template='plotly_white')
101
+ figs.append(fig6)
102
+
103
+ elif chart_type=='scatter':
104
+ if x not in df.columns or y not in df.columns:
105
+ print(f"Skipping scatter: missing columns {x}, {y}")
106
+ continue
107
+ fig7=px.scatter(df,x=x,y=y,color=color if color else None,trendline='ols',title=f'{x} vs {y}',template='plotly_white')
108
+ figs.append(fig7)
109
+
110
+ elif chart_type=='line':
111
+ if x not in df.columns or y not in df.columns:
112
+ print(f"Skipping line: missing columns {x}, {y}")
113
+ continue
114
+ df_sorted=df.copy()
115
+ try:
116
+ df_sorted[x]=pd.to_datetime(df_sorted[x], errors='coerce')
117
+ df_sorted=df_sorted.dropna(subset=[x])
118
+ df_sorted=df_sorted.sort_values(x)
119
+ grouped=df_sorted.groupby(x)[y].mean().reset_index()
120
+ if grouped.empty:
121
+ print(f"Skipping line: no data after grouping")
122
+ continue
123
+ fig8=px.line(grouped,x=x,y=y,markers=True,title=f'{y} over time',template='plotly_white')
124
+ figs.append(fig8)
125
+ except Exception as e:
126
+ print(f"Skipping line chart: {e}")
127
+ continue
128
+
129
+ elif chart_type=='pivot_heatmap':
130
+ if z not in df.columns or x not in df.columns or y not in df.columns:
131
+ print(f"Skipping pivot_heatmap: missing columns {x}, {y}, {z}")
132
+ continue
133
+ if not np.issubdtype(df[z].dtype, np.number):
134
+ print(f"Skipping pivot_heatmap: '{z}' is not numeric")
135
+ continue
136
+ pivot=df.pivot_table(values=z,index=x,columns=y,aggfunc='mean')
137
+ if pivot.empty:
138
+ print(f"Skipping pivot_heatmap: pivot table is empty")
139
+ continue
140
+ fig9=px.imshow(pivot,color_continuous_scale='RdYlGn_r',text_auto=True,template='plotly_white',title=f'{z} by {x} and {y}')
141
+ figs.append(fig9)
142
+
143
+ else:
144
+ print(f"Unknown chart type: {chart_type}")
145
+ except Exception as e:
146
+ print(f"[ERROR] Failed to render {c.get('chart_type')}: {e}")
147
+ import traceback
148
+ traceback.print_exc()
149
+ continue
150
+
151
+ return figs
152
+
153
+ def validate_charts(chart_recs,df):
154
+ valid=[]
155
+ for chart in chart_recs:
156
+ ct=chart.get('chart_type')
157
+ x=chart.get('x')
158
+ y=chart.get('y')
159
+ z=chart.get('z')
160
+ color=chart.get('color')
161
+ if not ct:
162
+ print(f"Skipping chart: missing chart_type")
163
+ continue
164
+ if isinstance(x, list):
165
+ if ct != 'correlation_heatmap':
166
+ print(f"Skipping {ct}: x is a list {x}, not a string")
167
+ continue
168
+ if isinstance(y, list):
169
+ print(f"Skipping {ct}: y is a list {y}, not a string")
170
+ continue
171
+ if isinstance(z, list):
172
+ print(f"Skipping {ct}: z is a list {z}, not a string")
173
+ continue
174
+ if isinstance(color, list):
175
+ print(f"Skipping {ct}: color is a list {color}, not a string")
176
+ continue
177
+ if x is None and ct not in ['correlation_heatmap']:
178
+ print(f"Skipping {ct}: x column is None")
179
+ continue
180
+ if y is None and ct not in ['histogram', 'correlation_heatmap']:
181
+ print(f"Skipping {ct}: y column is None")
182
+ continue
183
+ is_valid = True
184
+ for col in [x, y, z, color]:
185
+ if isinstance(col, list):
186
+ for c in col:
187
+ if c not in df.columns:
188
+ print(f"Skipping {ct}: column '{c}' not found in dataframe")
189
+ is_valid = False
190
+ break
191
+ elif col and col not in df.columns:
192
+ print(f"Skipping {ct}: column '{col}' not found in dataframe")
193
+ is_valid = False
194
+ break
195
+ if not is_valid:
196
+ continue
197
+
198
+ if ct in ['scatter','histogram'] and x and isinstance(x, str) and x in df.columns:
199
+ if not pd.api.types.is_numeric_dtype(df[x]):
200
+ print(f"Skipping {ct}: '{x}' is not numeric")
201
+ continue
202
+ if ct in ['scatter','bar'] and y and isinstance(y, str) and y in df.columns:
203
+ if not pd.api.types.is_numeric_dtype(df[y]):
204
+ print(f"Skipping {ct}: '{y}' is not numeric")
205
+ continue
206
+ valid.append(chart)
207
+
208
+ print(f"Validated {len(valid)}/{len(chart_recs)} charts")
209
+ return valid
210
+
211
+ @traceable(name='deciding_plots')
212
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
213
+ def deciding_plots(llm,df,findings, columns, key_cols, feedback=None):
214
+ numeric_cols = df.select_dtypes(include=np.number).columns.tolist()
215
+ categorical_cols =[c for c in df.columns if df[c].nunique() < 50 and c not in numeric_cols]
216
+ feedback_section = f"""
217
+ REVISION FEEDBACK: {feedback}
218
+ Address this feedback specifically in your chart selection.""" if feedback else ""
219
+
220
+ prompt=f"""You are a senior data analyst. Select exactly 6 charts for a BI dashboard.
221
+
222
+ REQUIRED (4 charts):
223
+ 1. stacked_bar: x=category, y=metric, color=DIFFERENT_category
224
+ 2. pivot_heatmap: x=category, y=category, z=numeric_metric
225
+ 3. correlation_heatmap: 3. correlation_heatmap: x=null, y=null, z=null (auto-uses all numeric columns)
226
+ 4. histogram: x=numeric_metric (distribution)
227
+
228
+ OPTIONAL (2 charts, choose from):
229
+ - bar, line, scatter, treemap, pareto
230
+
231
+ STRICT RULES:
232
+ 1. Each chart must have unique x,y combination (no duplicates)
233
+ 2. stacked_bar: x and color MUST be different columns
234
+ 3. pivot_heatmap: z (values) must be numeric
235
+ 4. correlation_heatmap: include only numeric columns
236
+ 5. Avoid: ID columns, high-cardinality columns (>100 unique values)
237
+ 6. Order by business impact: Revenue > Quantity > Price > Distribution
238
+ 7. pareto: ONLY if concentration_analysis shows is_concentrated=true
239
+ 8. treemap: ONLY if any category >50% of total
240
+
241
+ EXAMPLES (good):
242
+ - stacked_bar: x=Country, y=Revenue, color=ProductCategory ✓
243
+ - bar: x=Country, y=Quantity (simple relationship) ✓
244
+ - Bad: stacked_bar: x=Country, y=Revenue, color=Country ✗ (duplicate)
245
+
246
+ CAUTION:
247
+ 1. NEVER recommend scatter/line with non-numeric columns
248
+ 2. NEVER use columns: {[c for c in columns if c not in numeric_cols + categorical_cols]}
249
+ 3. For any chart using {categorical_cols}, verify it's categorical first
250
+ 4. If a column is not in NUMERIC list, do NOT use it for x/y in scatter/line
251
+
252
+ Allowed columns: {columns}
253
+ Key metrics: {key_cols}
254
+
255
+ Data findings: {json.dumps(findings, indent=2, default=str)}
256
+
257
+ {feedback_section}
258
+
259
+ CRITICAL VALIDATION RULES:
260
+ - EVERY chart MUST have ALL required columns filled (NO null values for x, y, color fields)
261
+ - x: MUST be a valid column name from allowed columns
262
+ - y: MUST be a valid column name from allowed columns
263
+ - z: MUST be a valid column name from allowed columns (for pivot_heatmap, correlation_heatmap, or treemap)
264
+ - color: MUST be a valid column name from allowed columns (for stacked_bar only)
265
+ - NEVER use null for required fields. If a required field cannot be filled, SKIP that chart type
266
+
267
+ RESPOND ONLY WITH VALID JSON (no markdown, no explanation):
268
+ [
269
+ {{"chart_type": "stacked_bar", "x": "product_line", "y": "sales", "z": null, "color": "region", "priority": 1, "reason": "..."}},
270
+ {{"chart_type": "histogram", "x": "revenue", "y": null, "z": null, "color": null, "priority": 2, "reason": "..."}},
271
+ ...
272
+ ]"""
273
+ response = llm.invoke(prompt)
274
+ charts = safe_parse(response.content)
275
+ return charts
276
+
277
+ @retry(max_attempts=3,delay=3,backoff=3,exceptions=(Exception,))
278
+ def build_dashboard(df,key_cols,inputs,key,mapping_log,domain_info):
279
+ inputs=validate_charts(inputs,df)
280
+ inputs=inputs[:6]
281
+
282
+ if not inputs:
283
+ raise ValueError("No valid charts after validation")
284
+
285
+ decrypt_map=decryption(key, mapping_log)
286
+
287
+ temp,bs,dp=random_template()
288
+
289
+ try:
290
+ figs=render_charts(inputs, df)
291
+ except Exception as e:
292
+ print(f"[ERROR] render_charts failed: {e}")
293
+ raise
294
+
295
+ chart_htmls_top=[]
296
+ chart_htmls_bot=[]
297
+ for i, fig in enumerate(figs):
298
+ try:
299
+ fig.update_layout(
300
+ template='plotly_white',
301
+ margin=dict(t=40, b=20, l=16, r=16),
302
+ height=300 if i < 2 else 240,
303
+ paper_bgcolor=dp["card"],
304
+ plot_bgcolor=dp["card"],
305
+ font=dict(family="Inter, Segoe UI, sans-serif", size=11, color=dp["text"]),
306
+ title_font=dict(size=12, color=dp["text"], family="Inter, Segoe UI, sans-serif"),
307
+ legend=dict(font=dict(size=10, color=dp["muted"]), bgcolor="rgba(0,0,0,0)"),
308
+ )
309
+ except Exception as e:
310
+ print(f"[WARNING] Failed to update layout for chart {i}: {e}, using default template")
311
+ fig.update_layout(
312
+ margin=dict(t=40, b=20, l=16, r=16),
313
+ height=300 if i < 2 else 240,
314
+ font=dict(family="Inter, Segoe UI, sans-serif", size=11),
315
+ title_font=dict(size=12, family="Inter, Segoe UI, sans-serif"),
316
+ )
317
+
318
+ try:
319
+ html_chunk=pio.to_html(fig,full_html=False,include_plotlyjs='cdn')
320
+ if i<2:
321
+ chart_htmls_top.append(f'<div class="chart-card">{html_chunk}</div>')
322
+ else:
323
+ chart_htmls_bot.append(f'<div class="chart-card-sm">{html_chunk}</div>')
324
+ except Exception as e:
325
+ print(f"[ERROR] Failed to convert chart {i} to HTML: {e}")
326
+ continue
327
+
328
+ top_row="".join(chart_htmls_top)
329
+ bot_row="".join(chart_htmls_bot)
330
+ date_cols = [c for c in df.columns if 'date' in c.lower()]
331
+ if date_cols:
332
+ df = df.copy()
333
+ df[date_cols[0]] = pd.to_datetime(df[date_cols[0]], errors='coerce')
334
+ df = df.sort_values(date_cols[0])
335
+ metric_direction=domain_info.get("Metric_Direction", {})
336
+ kpis = []
337
+ for metric in key_cols:
338
+ if metric not in df.columns:
339
+ continue
340
+ if not np.issubdtype(df[metric].dtype, np.number):
341
+ continue
342
+ mid=len(df)//2
343
+ base=df[metric].iloc[:mid].mean()
344
+ delta=((df[metric].iloc[mid:].mean()-base) /base*100) if base != 0 else 0
345
+ val=float(df[metric].mean())
346
+ val_str=f"{val/1000:.2f}K" if val >= 1000 else str(round(val, 2))
347
+ kpis.append({
348
+ "label": metric.replace("_", " ").title(),
349
+ "value": val_str,
350
+ "direction": metric_direction.get(metric, "higher_is_better"),
351
+ "delta": round(delta, 2),
352
+ })
353
+ kpis=kpis[:6]
354
+
355
+ accents=[dp["accent"],dp["accent2"],dp["muted"],
356
+ dp["accent"],dp["accent2"],dp["muted"]]
357
+
358
+ def kpi_card(k, accent):
359
+ sign="▲" if k["delta"] >= 0 else "▼"
360
+ is_good=(k["delta"] >= 0 and k["direction"] == "higher_is_better") or \
361
+ (k["delta"] < 0 and k["direction"] == "lower_is_better")
362
+ dcolor="#16a34a" if is_good else "#dc2626"
363
+ return f"""
364
+ <div class="kpi-card" style="border-top:3px solid {accent}">
365
+ <div class="kpi-label">{k['label']}</div>
366
+ <div class="kpi-value">{k['value']}</div>
367
+ <div class="kpi-delta" style="color:{dcolor}">{sign} {abs(k['delta'])}% vs prior</div>
368
+ </div>"""
369
+
370
+ kpi_html="".join(kpi_card(k, accents[i]) for i, k in enumerate(kpis))
371
+
372
+ cat_cols = list(df.select_dtypes(include="object").columns[:7])
373
+ btn_colors = derive_accents(dp["accent"], max(len(cat_cols), 4))
374
+ sidebar_items = ""
375
+ for j, col in enumerate(cat_cols):
376
+ color = btn_colors[j % len(btn_colors)]
377
+ sidebar_items += f"""
378
+ <div class="nav-btn" style="background:{color}">
379
+ {col.replace("_"," ").title()}
380
+ </div>"""
381
+
382
+ filter_col=cat_cols[0] if cat_cols else None
383
+ filter_vals=list(df[filter_col].dropna().unique()[:5]) if filter_col else []
384
+ filter_pills=""
385
+ if filter_vals:
386
+ for v in filter_vals:
387
+ filter_pills+=f'<div class="filter-pill">{v}</div>'
388
+ filter_section=f"""
389
+ <div class="filter-group">
390
+ <span class="filter-label">{filter_col.replace("_"," ").title() if filter_col else ""}</span>
391
+ {filter_pills}
392
+ </div>"""
393
+ else:
394
+ filter_section=""
395
+
396
+ title=domain_info.get("Domain","Analytics").replace("_", " ").title()
397
+ subdomain=domain_info.get("Subdomain", "")
398
+ confidence=domain_info.get("Confidence_Score", "")
399
+
400
+ html=f"""<!DOCTYPE html>
401
+ <html lang="en">
402
+ <head>
403
+ <meta charset="utf-8">
404
+ <meta name="viewport" content="width=device-width,initial-scale=1">
405
+ <title>{title} Dashboard</title>
406
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
407
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
408
+ <style>
409
+ *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
410
+ body{{font-family:'Inter','Segoe UI',sans-serif;background:{dp['bg']};color:{dp['text']};min-height:100vh;display:flex;flex-direction:column}}
411
+
412
+ /* ── HEADER ── */
413
+ .header{{
414
+ background:{dp['sidebar']};
415
+ padding:0 24px;
416
+ display:flex;align-items:center;justify-content:space-between;
417
+ flex-shrink:0;
418
+ }}
419
+ .header-left{{display:flex;flex-direction:column;justify-content:center;padding:10px 0}}
420
+ .header-title{{
421
+ font-size:22px;font-weight:800;
422
+ color:{dp['header_text']};
423
+ letter-spacing:1px;text-transform:uppercase;
424
+ line-height:1.1;
425
+ }}
426
+ .header-sub{{
427
+ font-size:11px;font-weight:400;
428
+ color:{dp['header_text']}88;margin-top:2px;
429
+ }}
430
+ .header-right{{display:flex;align-items:center;gap:8px;flex-direction:column;align-items:flex-end;padding:8px 0}}
431
+ .pills-row{{display:flex;gap:6px;align-items:center}}
432
+ .filter-group{{display:flex;align-items:center;gap:6px}}
433
+ .filter-label{{font-size:10px;font-weight:600;color:{dp['header_text']}88;text-transform:uppercase;letter-spacing:0.6px}}
434
+ .filter-pill{{
435
+ font-size:10px;font-weight:600;padding:4px 10px;
436
+ border-radius:4px;border:1px solid {dp['header_text']}44;
437
+ color:{dp['header_text']};background:{dp['header_text']}15;
438
+ cursor:pointer;transition:background 0.15s;
439
+ }}
440
+ .filter-pill:hover{{background:{dp['header_text']}30}}
441
+ .theme-pill{{
442
+ font-size:10px;font-weight:600;padding:4px 10px;border-radius:4px;
443
+ border:1px solid {dp['accent']}66;color:{dp['accent']};background:{dp['accent']}18;
444
+ text-transform:uppercase;letter-spacing:0.4px;
445
+ }}
446
+ .confidence-pill{{
447
+ font-size:10px;font-weight:600;padding:4px 10px;border-radius:4px;
448
+ background:#16a34a22;color:#16a34a;border:1px solid #16a34a55;
449
+ text-transform:uppercase;letter-spacing:0.4px;
450
+ }}
451
+
452
+ /* ── LAYOUT ── */
453
+ .body-wrap{{display:flex;flex:1;overflow:hidden}}
454
+
455
+ /* ── SIDEBAR ── */
456
+ .sidebar{{
457
+ width:150px;background:{dp['sidebar']};
458
+ flex-shrink:0;padding:16px 10px;
459
+ display:flex;flex-direction:column;gap:8px;
460
+ }}
461
+ .sidebar-label{{
462
+ font-size:9px;font-weight:700;
463
+ color:{dp['header_text']}44;letter-spacing:1.2px;
464
+ text-transform:uppercase;margin-bottom:4px;
465
+ }}
466
+ .nav-btn{{
467
+ font-size:11px;font-weight:600;
468
+ color:#ffffff;
469
+ padding:9px 12px;
470
+ border-radius:5px;
471
+ cursor:pointer;
472
+ text-align:center;
473
+ opacity:0.92;
474
+ transition:opacity 0.15s,transform 0.1s;
475
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
476
+ }}
477
+ .nav-btn:hover{{opacity:1;transform:translateX(2px)}}
478
+
479
+ /* ── MAIN ── */
480
+ .main{{flex:1;padding:16px;overflow-y:auto;display:flex;flex-direction:column;gap:14px}}
481
+
482
+ /* ── KPI ROW ── */
483
+ .kpi-grid{{display:grid;grid-template-columns:repeat(6,1fr);gap:10px}}
484
+ .kpi-card{{
485
+ background:{dp['card']};
486
+ border:1px solid {dp['border']};
487
+ border-radius:6px;
488
+ padding:14px 14px 12px;
489
+ display:flex;flex-direction:column;gap:3px;
490
+ }}
491
+ .kpi-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:{dp['muted']}}}
492
+ .kpi-value{{font-size:28px;font-weight:800;line-height:1.1;letter-spacing:-0.5px;color:{dp['text']}}}
493
+ .kpi-delta{{font-size:10px;font-weight:500;margin-top:2px}}
494
+
495
+ /* ── TOP CHARTS (2 col) ── */
496
+ .charts-top{{display:grid;grid-template-columns:1fr 1fr;gap:12px}}
497
+ .chart-card{{
498
+ background:{dp['card']};border:1px solid {dp['border']};
499
+ border-radius:6px;padding:14px;overflow:hidden;
500
+ }}
501
+
502
+ /* ── BOTTOM CHARTS (up to 4 col) ── */
503
+ .charts-bot{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}}
504
+ .chart-card-sm{{
505
+ background:{dp['card']};border:1px solid {dp['border']};
506
+ border-radius:6px;padding:12px;overflow:hidden;
507
+ }}
508
+
509
+ /* ── SECTION LABEL ── */
510
+ .section-label{{
511
+ font-size:9px;font-weight:700;text-transform:uppercase;
512
+ letter-spacing:1px;color:{dp['muted']};margin-bottom:6px;
513
+ }}
514
+
515
+ /* ── FOOTER ── */
516
+ .footer{{
517
+ background:{dp['sidebar']};
518
+ padding:8px 24px;
519
+ display:flex;justify-content:space-between;align-items:center;
520
+ font-size:10px;color:{dp['header_text']}66;flex-shrink:0;
521
+ }}
522
+ .footer-brand{{font-weight:700;color:{dp['accent']};letter-spacing:0.3px}}
523
+ </style>
524
+ </head>
525
+ <body>
526
+
527
+ <!-- HEADER -->
528
+ <div class="header">
529
+ <div class="header-left">
530
+ <div class="header-title">{title} Analytics Dashboard</div>
531
+ {f'<div class="header-sub">/ {subdomain.replace("_"," ").title()}</div>' if subdomain and subdomain != "other_unknown" else ""}
532
+ </div>
533
+ <div class="header-right">
534
+ {filter_section}
535
+ <div class="pills-row">
536
+ <span class="theme-pill">Theme: {bs}</span>
537
+ {f'<span class="confidence-pill">Confidence: {confidence}</span>' if confidence else ""}
538
+ </div>
539
+ </div>
540
+ </div>
541
+ <div class="body-wrap">
542
+ <!-- SIDEBAR -->
543
+ <div class="sidebar">
544
+ <div class="sidebar-label">Dimensions</div>
545
+ {sidebar_items}
546
+ </div>
547
+ <!-- MAIN -->
548
+ <div class="main">
549
+ <!-- KPIs -->
550
+ <div>
551
+ <div class="section-label">Key Performance Indicators</div>
552
+ <div class="kpi-grid">{kpi_html}</div>
553
+ </div>
554
+ <!-- TOP CHARTS -->
555
+ {'<div><div class="section-label">Charts &amp; Analysis</div><div class="charts-top">' + top_row + '</div></div>' if top_row else ''}
556
+ <!-- BOTTOM CHARTS -->
557
+ {'<div><div class="charts-bot">' + bot_row + '</div></div>' if bot_row else ''}
558
+ </div>
559
+ </div>
560
+ <!-- FOOTER -->
561
+ <div class="footer">
562
+ <span>Last refreshed: <span id="ts"></span></span>
563
+ <span class="footer-brand">ArgusAI &nbsp;·&nbsp; Automated Insight Engine</span>
564
+ </div>
565
+ <script>document.getElementById('ts').textContent = new Date().toLocaleString();</script>
566
+ </body>
567
+ </html>"""
568
+ for display,real in decrypt_map.items():
569
+ html=html.replace(display,real)
570
+ with open("outputs/dashboard.html","w",encoding="utf-8") as f:
571
+ f.write(html)
572
+ print(f"Dashboard saved. Theme: {bs}")
573
+ return "outputs/dashboard.html"
574
+
workflow.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langgraph.graph import StateGraph,END
2
+ import sqlite3
3
+ from langgraph.checkpoint.sqlite import SqliteSaver
4
+ from agent import AgentState
5
+ from agent import node_profiler,node_analyser,node_anonymiser,node_data_analyser,node_query,node_search,node_reasoner,node_reporter,node_plotdecider,node_dashboard,node_reportgen,human_inloop_report,human_inloop_dashboard,should_proceed_dashboard,check_error,should_proceed_report
6
+
7
+ graph=StateGraph(AgentState)
8
+
9
+ graph.add_node("node_1",node_profiler)
10
+ graph.add_node("node_2",node_analyser)
11
+ graph.add_node("node_3",node_anonymiser)
12
+ graph.add_node("node_4",node_data_analyser)
13
+ graph.add_node("node_5",node_query)
14
+ graph.add_node("node_6",node_search)
15
+ graph.add_node("node_7",node_reasoner)
16
+ graph.add_node("node_8",node_reporter)
17
+ graph.add_node("node_9",node_plotdecider)
18
+ graph.add_node("node_10",node_dashboard)
19
+ graph.add_node("node_11",node_reportgen)
20
+ graph.add_node("check_report",human_inloop_report)
21
+ graph.add_node("check_dashboard",human_inloop_dashboard)
22
+
23
+ graph.set_entry_point("node_1")
24
+ graph.add_edge("node_2", "node_3")
25
+ graph.add_edge("node_3", "node_4")
26
+ graph.add_edge("node_4", "node_5")
27
+ graph.add_edge("node_5", "node_6")
28
+ graph.add_edge("node_6", "node_7")
29
+ graph.add_edge("node_7", "node_8")
30
+ graph.add_edge("node_8","check_report")
31
+ graph.add_edge("node_9", "check_dashboard")
32
+ graph.add_edge("node_10","node_11")
33
+ graph.add_edge("node_11", END)
34
+
35
+ graph.add_conditional_edges(
36
+ "node_1",
37
+ check_error,
38
+ {'continue': 'node_2', 'end': END}
39
+ )
40
+ graph.add_conditional_edges(
41
+ "check_report",
42
+ should_proceed_report,
43
+ {
44
+ "node_9": "node_9",
45
+ "revise_report": "node_8",
46
+ }
47
+ )
48
+ graph.add_conditional_edges(
49
+ "check_dashboard",
50
+ should_proceed_dashboard,
51
+ {
52
+ "node_10": "node_10",
53
+ "revise_charts": "node_9",
54
+ }
55
+ )
56
+ conn = sqlite3.connect(
57
+ "checkpoints.db",
58
+ check_same_thread=False
59
+ )
60
+
61
+ memory = SqliteSaver(conn)
62
+
63
+ pipeline = graph.compile(
64
+ checkpointer=memory,
65
+ interrupt_before=["check_report", "check_dashboard"],
66
+ )