SHELLAPANDIANGANHUNGING commited on
Commit
8b5e6d5
·
verified ·
1 Parent(s): a95c654

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +186 -208
app.py CHANGED
@@ -1984,221 +1984,200 @@ else:
1984
  st.markdown("<h3 class='section-title'>OBJECTIVE 7 - Insight and Recommendation</h3>", unsafe_allow_html=True)
1985
 
1986
 
1987
-
1988
- # ============================================================== #
1989
- # Fungsi Insight & Rekomendasi (sama seperti sebelumnya, tanpa perubahan logika)
1990
- # ============================================================== #
1991
-
1992
- def compute_avg_monthly_ratio_per_location(df: pd.DataFrame) -> pd.DataFrame:
1993
- required = ['nama_lokasi_full', 'creator_nid', 'created_at', 'kode_temuan']
1994
- missing = [col for col in required if col not in df.columns]
1995
- if missing:
1996
- raise ValueError(f"Missing columns for ratio: {missing}")
1997
-
1998
- df_calc = df[required].copy()
1999
- df_calc['created_at'] = pd.to_datetime(df_calc['created_at'], errors='coerce')
2000
- df_calc = df_calc.dropna(subset=['created_at', 'nama_lokasi_full', 'creator_nid'])
2001
-
2002
- if df_calc.empty:
2003
- return pd.DataFrame(columns=[
2004
- 'nama_lokasi_full', 'avg_monthly_ratio', 'total_months_active',
2005
- 'total_findings', 'avg_unique_reporters_per_month'
2006
- ])
2007
-
2008
- df_calc['bulan'] = df_calc['created_at'].dt.to_period('M')
2009
- monthly_agg = df_calc.groupby(['nama_lokasi_full', 'bulan']).agg(
2010
- findings_count=('kode_temuan', 'size'),
2011
- unique_reporters=('creator_nid', 'nunique')
2012
- ).reset_index()
2013
-
2014
- monthly_agg = monthly_agg[monthly_agg['unique_reporters'] > 0]
2015
- monthly_agg['monthly_ratio'] = monthly_agg['findings_count'] / monthly_agg['unique_reporters']
2016
-
2017
- loc_summary = monthly_agg.groupby('nama_lokasi_full').agg(
2018
- avg_monthly_ratio=('monthly_ratio', 'mean'),
2019
- total_months_active=('bulan', 'nunique'),
2020
- total_findings=('findings_count', 'sum'),
2021
- avg_unique_reporters_per_month=('unique_reporters', 'mean')
2022
- ).reset_index()
2023
-
2024
- loc_summary['avg_monthly_ratio'] = loc_summary['avg_monthly_ratio'].round(2)
2025
- loc_summary['avg_unique_reporters_per_month'] = loc_summary['avg_unique_reporters_per_month'].round(1)
2026
-
2027
- return loc_summary
2028
-
2029
- def interpret_location_safely(df: pd.DataFrame, location_name: str) -> dict:
2030
- loc_df = df[df['nama_lokasi_full'] == location_name].copy()
2031
- if loc_df.empty:
2032
- return {
2033
- "interpretation": "No findings reported. Validation of coverage or actual safety status is required.",
2034
- "risk_signal": "Slight Risk",
2035
- "positive_rate": 0.0
2036
- }
2037
-
2038
- total = len(loc_df)
2039
- n_positive = (loc_df['temuan_kategori'] == 'Positive').sum()
2040
- n_unsafe = total - n_positive
2041
- perc_positive = n_positive / total if total > 0 else 0
2042
- unique_reporters = loc_df['creator_nid'].nunique()
2043
- months_active = loc_df['created_at'].dt.to_period('M').nunique() if 'created_at' in loc_df.columns else 1
2044
-
2045
- if total == 0:
2046
- signal = "Slight Risk"
2047
- interp = "No findings reported. Validation of coverage or actual safety status is required."
2048
- elif perc_positive >= 0.6:
2049
- signal = "Slight Risk"
2050
- interp = (
2051
- f"High reporting engagement with {total} findings and {perc_positive:.0%} positive category, "
2052
- f"contributed by {unique_reporters} unique reporter(s) over {months_active} month(s). "
2053
- f"This indicates a proactive safety culture."
2054
- )
2055
- elif perc_positive >= 0.3:
2056
- signal = "Moderate Risk"
2057
- interp = (
2058
- f"Balanced reporting with {n_unsafe} unsafe findings versus {n_positive} positive. "
2059
- f"Active monitoring is present, with opportunity to increase preventive behaviors."
2060
- )
2061
- else:
2062
- if unique_reporters == 1:
2063
- signal = "High Risk"
2064
- interp = (
2065
- f"High volume of unsafe findings with low positivity ({perc_positive:.0%}) "
2066
- f"and reliance on only one reporter. This may indicate observer fatigue, bias, "
2067
- f"or psychological barriers to broader reporting."
2068
- )
2069
- else:
2070
- signal = "Very High Risk"
2071
- interp = (
2072
- f"Predominantly unsafe findings ({n_unsafe} out of {total}) reported by multiple individuals, "
2073
- f"suggesting genuine and systemic safety hazards requiring urgent management attention."
2074
- )
2075
-
2076
- return {
2077
- "interpretation": interp,
2078
- "risk_signal": signal,
2079
- "positive_rate": perc_positive
2080
  }
2081
 
2082
- def detect_unsafe_terms(df: pd.DataFrame):
2083
- text_cols = ['hasil_keyword_dan_kondisi', 'judul_dan_kondisi', 'kondisi', 'judul']
2084
- text_col = None
2085
- for col in text_cols:
2086
- if col in df.columns and df[col].notna().any():
2087
- text_col = col
2088
- break
2089
- if text_col is None:
2090
- return []
2091
-
2092
- all_text = ' '.join(df[text_col].dropna().astype(str).str.lower())
2093
- unsafe_terms = [
2094
- 'terbuka', 'tidak terkunci', 'tanpa izin', 'tanpa pelindung', 'tanpa alat',
2095
- 'korsleting', 'overload', 'grounding', 'exposed', 'unlocked', 'no ppe',
2096
- 'jatuh', 'slip', 'trip', 'kebakaran', 'fire', 'fall', 'unauthorized',
2097
- 'tidak kompeten', 'untrained', 'prosedur dilanggar', 'bypass'
2098
- ]
2099
- found = [term for term in unsafe_terms if term in all_text]
2100
- return list(set(found))
2101
-
2102
- def generate_insight_and_recommendation(df: pd.DataFrame):
2103
- if df.empty:
2104
- return "Insufficient data for insight generation.", "Ensure dataset is populated and filtered appropriately."
2105
-
2106
- insights_parts = []
2107
- recommendations_parts = []
2108
-
2109
- # --- Insight 1: Top Active Locations (Interpreted) ---
2110
- if {'nama_lokasi_full', 'temuan_kategori', 'creator_nid', 'created_at'}.issubset(df.columns):
2111
- top_locs = df['nama_lokasi_full'].value_counts().head(3).index.tolist()
2112
- for loc in top_locs:
2113
- interp = interpret_location_safely(df, loc)
2114
- insights_parts.append(f"Location {loc}: {interp['interpretation']}")
2115
-
2116
- # --- Insight 2: Organizational Safety Maturity ---
2117
- if 'temuan_kategori' in df.columns:
2118
- total = len(df)
2119
- n_positive = (df['temuan_kategori'] == 'Positive').sum()
2120
- positive_rate = n_positive / total if total > 0 else 0
2121
- insights_parts.append(
2122
- f"Organization-wide, {positive_rate:.1%} of findings are Positive (proactive), "
2123
- f"while {100 - positive_rate * 100:.1f}% are reactive responses to existing hazards."
2124
  )
2125
-
2126
- # --- Insight 3: Emerging Unsafe Conditions ---
2127
- unsafe_terms = detect_unsafe_terms(df)
2128
- if unsafe_terms:
2129
- top_terms = ', '.join(sorted(unsafe_terms)[:5])
2130
- insights_parts.append(f"Text analysis identifies recurring unsafe conditions related to: {top_terms}.")
2131
-
2132
- # --- Insight 4: Low-Activity Locations ---
2133
- if 'nama_lokasi_full' in df.columns:
2134
- loc_counts = df['nama_lokasi_full'].value_counts()
2135
- low_activity_locs = loc_counts[loc_counts <= 2].index.tolist()
2136
- for loc in low_activity_locs[:3]:
2137
- interp = interpret_location_safely(df, loc)
2138
- if 0 < interp['positive_rate'] < 0.5:
2139
- insights_parts.append(
2140
- f"Location {loc} reports low volume ({loc_counts[loc]} findings) with a positive rate of "
2141
- f"{interp['positive_rate']:.0%}, suggesting possible under-reporting or unobserved hazards."
2142
- )
2143
-
2144
- # --- Build Recommendation + Risk Mitigation Strategy ---
2145
- rec_parts = []
2146
- mitigation_parts = []
2147
-
2148
- # Recommendation: Culture & Capability
2149
- rec_parts.append(
2150
- "Strengthen agentic safety behaviors by launching an Agentic Safety Program, including incentives for near-miss reporting, "
2151
- "training of Safety Coaches per division, and adoption of the percentage of Positive findings as a leading performance indicator."
2152
- )
2153
- mitigation_parts.append(
2154
- "Shift from compliance-driven audits to capability-building engagements. Measure success by reduction in repeat unsafe findings and increase in proactive interventions."
2155
- )
2156
-
2157
- # Recommendation: Data-Driven Intervention
2158
- rec_parts.append(
2159
- "Conduct targeted Risk Blitz campaigns for high-frequency unsafe conditions identified through text analysis, "
2160
- "supported by updated checklists and photo-based verification of corrective actions."
2161
- )
2162
- mitigation_parts.append(
2163
- "Integrate text analytics into monthly safety reviews to detect emerging risks earlier. Automate alerts when unsafe keywords exceed baseline thresholds."
2164
- )
2165
-
2166
- # Recommendation: Coverage & Equity
2167
- rec_parts.append(
2168
- "Improve inspection coverage equity through mandatory auditor rotation, geotagged field validation, and deployment of micro-checklists for frontline personnel."
2169
- )
2170
- mitigation_parts.append(
2171
- "Monitor the Gini coefficient of reporter distribution across locations monthly. Set an organizational target of below 0.5 to ensure balanced surveillance."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2172
  )
2173
 
2174
- # Recommendation: Psychological Safety
2175
- rec_parts.append(
2176
- "Assess and improve psychological safety in high-risk locations using anonymous surveys and leadership listening sessions, "
2177
- "particularly where reporting relies on very few individuals."
2178
- )
2179
- mitigation_parts.append(
2180
- "Decouple reporting volume from individual performance evaluation. Reward quality, learning, and prevention impact instead."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2181
  )
2182
 
2183
- # Combine all
2184
- insight_text = " ".join(insights_parts) if insights_parts else "No significant patterns detected in current data."
2185
- recommendation_text = " ".join(rec_parts)
2186
- mitigation_text = " ".join(mitigation_parts)
2187
 
2188
- return insight_text, recommendation_text, mitigation_text
 
 
 
 
 
 
 
 
 
2189
 
2190
- # ============================================================== #
2191
- # Eksekusi & Tampilan SATU CARD PER BAGIAN
2192
- # ============================================================== #
 
 
2193
 
2194
- try:
2195
- insight, recommendation, risk_mitigation = generate_insight_and_recommendation(df_filtered)
2196
- except Exception as e:
2197
- insight = "Error during insight generation."
2198
- recommendation = f"Review data pipeline: {str(e)}"
2199
- risk_mitigation = "Ensure required columns are present and datetime formats are consistent."
2200
 
2201
- # Card Insight
2202
  st.markdown(
2203
  f"""
2204
  <div class="card" style="
@@ -2210,13 +2189,12 @@ st.markdown(
2210
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
2211
  ">
2212
  <h4 style="margin-top: 0; color: #1976d2;">Insight Summary</h4>
2213
- <p style="margin-bottom: 0;">{insight}</p>
2214
  </div>
2215
  """,
2216
  unsafe_allow_html=True
2217
  )
2218
 
2219
- # Card Recommendation + Risk Mitigation
2220
  st.markdown(
2221
  f"""
2222
  <div class="card" style="
@@ -2228,8 +2206,8 @@ st.markdown(
2228
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
2229
  ">
2230
  <h4 style="margin-top: 0; color: #2e7d32;">Recommended Actions and Risk Mitigation Strategy</h4>
2231
- <p><strong>Recommended Actions:</strong> {recommendation}</p>
2232
- <p><strong>Risk Mitigation Strategy:</strong> {risk_mitigation}</p>
2233
  </div>
2234
  """,
2235
  unsafe_allow_html=True
 
1984
  st.markdown("<h3 class='section-title'>OBJECTIVE 7 - Insight and Recommendation</h3>", unsafe_allow_html=True)
1985
 
1986
 
1987
+ def extract_critical_deviations(df: pd.DataFrame):
1988
+ dev = {
1989
+ "obj2_locations_ratio_1": [],
1990
+ "obj3a_lowest_div_ratio": None,
1991
+ "obj3b_lowest_reporter": None,
1992
+ "obj3c_slowest_div_leadtime": None,
1993
+ "obj3d_slowest_executor": None,
1994
+ "obj4_unsafe_share": {},
1995
+ "obj5_quadrant_I": [],
1996
+ "obj5_quadrant_II": [],
1997
+ "obj6_top2_bubbles": []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1998
  }
1999
 
2000
+ # === OBJ 2: 9 lokasi dengan finding ratio ≈ 1.0 (rentang 0.95–1.05) ===
2001
+ if {'nama_lokasi_full', 'creator_nid', 'created_at', 'kode_temuan'}.issubset(df.columns):
2002
+ df_calc = df[['nama_lokasi_full', 'creator_nid', 'created_at', 'kode_temuan']].copy()
2003
+ df_calc['created_at'] = pd.to_datetime(df_calc['created_at'], errors='coerce')
2004
+ df_calc = df_calc.dropna(subset=['created_at', 'nama_lokasi_full', 'creator_nid'])
2005
+ df_calc['bulan'] = df_calc['created_at'].dt.to_period('M')
2006
+ monthly_agg = df_calc.groupby(['nama_lokasi_full', 'bulan']).agg(
2007
+ findings=('kode_temuan', 'size'),
2008
+ reporters=('creator_nid', 'nunique')
2009
+ ).reset_index()
2010
+ monthly_agg = monthly_agg[monthly_agg['reporters'] > 0]
2011
+ monthly_agg['ratio'] = monthly_agg['findings'] / monthly_agg['reporters']
2012
+ loc_avg = monthly_agg.groupby('nama_lokasi_full')['ratio'].mean().reset_index()
2013
+ # Ambil yang 0.95 ratio ≤ 1.05
2014
+ near_1 = loc_avg[(loc_avg['ratio'] >= 0.95) & (loc_avg['ratio'] <= 1.05)]
2015
+ dev["obj2_locations_ratio_1"] = near_1.nlargest(9, 'ratio')['nama_lokasi_full'].tolist()
2016
+
2017
+ # === OBJ 3a: Divisi dengan rasio temuan/orang terendah ===
2018
+ if {'nama', 'creator_nid', 'created_at', 'kode_temuan'}.issubset(df.columns):
2019
+ df_ratio = df[['nama', 'creator_nid', 'created_at', 'kode_temuan']].copy()
2020
+ df_ratio['bulan'] = pd.to_datetime(df_ratio['created_at']).dt.to_period('M')
2021
+ agg = df_ratio.groupby(['nama', 'bulan']).agg(
2022
+ findings=('kode_temuan', 'size'),
2023
+ reporters=('creator_nid', 'nunique')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2024
  )
2025
+ agg = agg[agg['reporters'] > 0].reset_index()
2026
+ agg['ratio'] = agg['findings'] / agg['reporters']
2027
+ div_ratio = agg.groupby('nama')['ratio'].mean()
2028
+ if not div_ratio.empty:
2029
+ lowest = div_ratio.idxmin()
2030
+ dev["obj3a_lowest_div_ratio"] = (lowest, round(div_ratio.min(), 2))
2031
+
2032
+ # === OBJ 3b: Reporter dengan frekuensi terendah (>0) ===
2033
+ if {'creator_name', 'created_at'}.issubset(df.columns):
2034
+ df_rep = df[['creator_name', 'created_at']].copy()
2035
+ df_rep['bulan'] = pd.to_datetime(df_rep['created_at']).dt.to_period('M')
2036
+ rep_monthly = df_rep.groupby(['creator_name', 'bulan']).size().reset_index(name='count')
2037
+ rep_avg = rep_monthly.groupby('creator_name')['count'].mean()
2038
+ if not rep_avg.empty and rep_avg.min() > 0:
2039
+ lowest = rep_avg.idxmin()
2040
+ dev["obj3b_lowest_reporter"] = (lowest, round(rep_avg.min(), 2))
2041
+
2042
+ # === OBJ 3c & 3d: Lead time terpanjang (divisi & individu) ===
2043
+ if 'days_to_close' in df.columns:
2044
+ valid_df = df[df['days_to_close'].notna() & (df['days_to_close'] >= 0)]
2045
+
2046
+ # 3c: divisi
2047
+ if 'nama' in valid_df.columns:
2048
+ div_lead = valid_df.groupby('nama')['days_to_close'].mean()
2049
+ if not div_lead.empty:
2050
+ slowest = div_lead.idxmax()
2051
+ dev["obj3c_slowest_div_leadtime"] = (slowest, round(div_lead.max(), 1))
2052
+
2053
+ # 3d: executor (prioritas: nama_pic → creator_name)
2054
+ executor_col = 'nama_pic' if 'nama_pic' in valid_df.columns else 'creator_name'
2055
+ if executor_col in valid_df.columns:
2056
+ exec_lead = valid_df.groupby(executor_col)['days_to_close'].mean()
2057
+ if not exec_lead.empty:
2058
+ slowest = exec_lead.idxmax()
2059
+ dev["obj3d_slowest_executor"] = (slowest, round(exec_lead.max(), 1))
2060
+
2061
+ # === OBJ 4: Pie chart — unsafe share ===
2062
+ if 'temuan_kategori' in df.columns:
2063
+ cat_counts = df['temuan_kategori'].value_counts(normalize=True) * 100
2064
+ unsafe_cats = ['Unsafe Condition', 'Unsafe Action', 'Near Miss']
2065
+ for cat in unsafe_cats:
2066
+ if cat in cat_counts.index:
2067
+ dev["obj4_unsafe_share"][cat] = round(cat_counts[cat], 1)
2068
+
2069
+ # === OBJ 5: Risk Matrix kuadran ===
2070
+ # Gunakan logika yang sama seperti Objective 5 (X_LIMIT=20, Y_LIMIT=3)
2071
+ X_LIMIT, Y_LIMIT = 20, 3
2072
+ if 'nama' in df.columns and 'days_to_close' in df.columns:
2073
+ df_risk = df.copy()
2074
+ df_risk['created_at'] = pd.to_datetime(df_risk['created_at'], errors='coerce')
2075
+ df_risk = df_risk.assign(month=df_risk['created_at'].dt.to_period('M').astype(str))
2076
+ # Avg bulanan per divisi
2077
+ monthly_counts = df_risk.groupby(['nama', 'month'])['kode_temuan'].nunique().reset_index()
2078
+ avg_count = monthly_counts.groupby('nama')['kode_temuan'].mean().reset_index(name='Finding Count')
2079
+ leadtime = df_risk.groupby('nama')['days_to_close'].mean().reset_index(name='Average Lead Time')
2080
+ risk_mat = avg_count.merge(leadtime, on='nama', how='left').fillna(0)
2081
+ risk_mat['Average Lead Time'] = risk_mat['Average Lead Time'].clip(lower=0)
2082
+
2083
+ for _, row in risk_mat.iterrows():
2084
+ div = row['nama']
2085
+ cnt = row['Finding Count']
2086
+ lt = row['Average Lead Time']
2087
+ if cnt >= X_LIMIT and lt >= Y_LIMIT:
2088
+ dev["obj5_quadrant_I"].append(div)
2089
+ elif cnt < X_LIMIT and lt >= Y_LIMIT:
2090
+ dev["obj5_quadrant_II"].append(div)
2091
+
2092
+ # === OBJ 6: Whiteboard — 2 bubble terbesar (Avg/Month tertinggi) ===
2093
+ if 'kategori' in df.columns and 'temuan_kategori' in df.columns:
2094
+ df_nonpos = df[df['temuan_kategori'] != 'Positive']
2095
+ if not df_nonpos.empty:
2096
+ start_month = df['created_at'].min().to_period('M')
2097
+ end_month = df['created_at'].max().to_period('M')
2098
+ n_months = len(pd.period_range(start=start_month, end=end_month, freq='M'))
2099
+ cat_avg = (
2100
+ df_nonpos.groupby('kategori').size() / n_months
2101
+ ).sort_values(ascending=False).head(2)
2102
+ dev["obj6_top2_bubbles"] = [(cat, round(val, 2)) for cat, val in cat_avg.items()]
2103
+
2104
+ return dev
2105
+
2106
+ # Jalankan ekstraksi
2107
+ deviations = extract_critical_deviations(df_filtered)
2108
+
2109
+ # Bangun insight berbasis temuan nyata
2110
+ insight_parts = []
2111
+ rec_parts = []
2112
+
2113
+ # Objective 2
2114
+ if deviations["obj2_locations_ratio_1"]:
2115
+ locs = ", ".join(deviations["obj2_locations_ratio_1"][:5]) # Tampilkan 5 saja di teks
2116
+ insight_parts.append(
2117
+ f"Nine locations show near-optimal finding-to-reporter ratio (~1.0), indicating balanced workload: "
2118
+ f"{locs}, and others."
2119
  )
2120
 
2121
+ # Objective 3
2122
+ if deviations["obj3a_lowest_div_ratio"]:
2123
+ div, ratio = deviations["obj3a_lowest_div_ratio"]
2124
+ insight_parts.append(f"Division {div} has the lowest reporting ratio ({ratio}), suggesting potential under-utilization or resource gaps.")
2125
+ if deviations["obj3b_lowest_reporter"]:
2126
+ name, rate = deviations["obj3b_lowest_reporter"]
2127
+ insight_parts.append(f"Reporter {name} averages only {rate} finding(s) per month the lowest among active staff.")
2128
+ if deviations["obj3c_slowest_div_leadtime"]:
2129
+ div, lt = deviations["obj3c_slowest_div_leadtime"]
2130
+ insight_parts.append(f"Division {div} takes longest to resolve findings (avg {lt} days), risking SLA breach.")
2131
+ if deviations["obj3d_slowest_executor"]:
2132
+ name, lt = deviations["obj3d_slowest_executor"]
2133
+ insight_parts.append(f"Executor {name} has the longest lead time ({lt} days), requiring workflow review.")
2134
+
2135
+ # Objective 4
2136
+ if deviations["obj4_unsafe_share"]:
2137
+ unsafe_list = [f"{cat} ({pct}%)" for cat, pct in deviations["obj4_unsafe_share"].items()]
2138
+ unsafe_str = "; ".join(unsafe_list)
2139
+ insight_parts.append(f"Unsafe issues dominate: {unsafe_str} of all findings.")
2140
+
2141
+ # Objective 5
2142
+ if deviations["obj5_quadrant_I"]:
2143
+ q1 = ", ".join(deviations["obj5_quadrant_I"][:3])
2144
+ insight_parts.append(f"High-risk divisions (high volume + slow resolution): {q1}.")
2145
+ if deviations["obj5_quadrant_II"]:
2146
+ q2 = ", ".join(deviations["obj5_quadrant_II"][:3])
2147
+ insight_parts.append(f"Hidden-risk divisions (low volume but very slow): {q2} — may indicate capacity or priority issues.")
2148
+
2149
+ # Objective 6
2150
+ if deviations["obj6_top2_bubbles"]:
2151
+ bub1, bub2 = deviations["obj6_top2_bubbles"]
2152
+ insight_parts.append(
2153
+ f"The two most frequently recurring unsafe issues are {bub1[0]} ({bub1[1]}/month) "
2154
+ f"and {bub2[0]} ({bub2[1]}/month), indicating systemic root causes."
2155
  )
2156
 
2157
+ # Combine insight
2158
+ insight_text = " ".join(insight_parts) if insight_parts else "No significant deviations detected based on current filters."
 
 
2159
 
2160
+ # Rekomendasi & Risk Mitigation
2161
+ rec_parts.append(
2162
+ "Prioritize capacity assessment and coaching for divisions and individuals with lowest activity or longest resolution times."
2163
+ )
2164
+ rec_parts.append(
2165
+ "Initiate root-cause analysis on top two high-frequency unsafe categories to prevent recurrence."
2166
+ )
2167
+ rec_parts.append(
2168
+ "Review workload distribution for locations with ratio ≈1.0 — they represent a benchmark for sustainable inspection load."
2169
+ )
2170
 
2171
+ mitigation_parts = [
2172
+ "Establish SLA thresholds: max 7 days lead time, min 0.5 findings/reporter/month for active status.",
2173
+ "Deploy predictive alerts when a division enters Quadrant I or II in the risk matrix.",
2174
+ "Integrate category-level trend monitoring into monthly safety meetings to catch emerging risks early."
2175
+ ]
2176
 
2177
+ recommendation_text = " ".join(rec_parts)
2178
+ mitigation_text = " ".join(mitigation_parts)
 
 
 
 
2179
 
2180
+ # Tampilkan — SATU CARD INSIGHT, SATU CARD REKOMENDASI + MITIGASI
2181
  st.markdown(
2182
  f"""
2183
  <div class="card" style="
 
2189
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
2190
  ">
2191
  <h4 style="margin-top: 0; color: #1976d2;">Insight Summary</h4>
2192
+ <p style="margin-bottom: 0;">{insight_text}</p>
2193
  </div>
2194
  """,
2195
  unsafe_allow_html=True
2196
  )
2197
 
 
2198
  st.markdown(
2199
  f"""
2200
  <div class="card" style="
 
2206
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
2207
  ">
2208
  <h4 style="margin-top: 0; color: #2e7d32;">Recommended Actions and Risk Mitigation Strategy</h4>
2209
+ <p><strong>Recommended Actions:</strong> {recommendation_text}</p>
2210
+ <p><strong>Risk Mitigation Strategy:</strong> {mitigation_text}</p>
2211
  </div>
2212
  """,
2213
  unsafe_allow_html=True