Spaces:
Running
Running
Upload 14 files
Browse files- agent.py +196 -0
- app.py +306 -0
- chatbot.py +40 -0
- config.py +30 -0
- crypto.py +47 -0
- evaluation.py +229 -0
- helper.py +108 -0
- llm.py +18 -0
- report.py +177 -0
- state.py +27 -0
- stats.py +320 -0
- tools.py +360 -0
- visuals.py +574 -0
- 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 & 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 · 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 |
+
)
|